mirror of
https://github.com/Jieyab89/OSINT-Cheat-sheet.git
synced 2026-08-17 18:35:41 +02:00
2607 lines
114 KiB
HTML
2607 lines
114 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<link rel="stylesheet" href="https://jsdelivr.net">
|
||
<title>Jieyab89 SOCMINT X — Graph</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1117;
|
||
--surface: #1a1d27;
|
||
--surface2: #21253a;
|
||
--border: #2a2d3a;
|
||
--text: #e8eaf0;
|
||
--muted: #8890a4;
|
||
--accent: #5865f2;
|
||
--accent-bg: #1e2240;
|
||
--success: #22c55e;
|
||
--danger: #ef4444;
|
||
--warn: #f59e0b;
|
||
--purple: #8b5cf6;
|
||
--purple-bg: #1e1535;
|
||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
html, body {
|
||
height: 100%;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: var(--font);
|
||
font-size: 14px;
|
||
overflow: hidden;
|
||
}
|
||
body { display: flex; flex-direction: column; }
|
||
|
||
/* ── Header ── */
|
||
header {
|
||
padding: 10px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--surface);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
header h1 { font-size: 14px; font-weight: 600; }
|
||
header .sep { color: var(--border); }
|
||
header .sub { color: var(--muted); font-size: 12px; flex: 1; }
|
||
.hamburger-btn {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
padding: 5px 9px;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
transition: all 0.15s;
|
||
}
|
||
.hamburger-btn:hover { color: var(--text); border-color: var(--accent); }
|
||
.nav-menu {
|
||
position: absolute;
|
||
top: 100%;
|
||
right: 12px;
|
||
margin-top: 6px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 7px;
|
||
padding: 5px;
|
||
min-width: 175px;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
|
||
z-index: 200;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.nav-menu.hidden { display: none; }
|
||
.nav-menu a {
|
||
padding: 7px 12px;
|
||
border-radius: 5px;
|
||
color: var(--muted);
|
||
text-decoration: none;
|
||
font-size: 13px;
|
||
transition: all 0.12s;
|
||
display: block;
|
||
}
|
||
.nav-menu a:hover { color: var(--text); background: var(--accent-bg); }
|
||
.nav-menu a.current { color: var(--accent); background: var(--accent-bg); }
|
||
.nav-divider { border: none; border-top: 1px solid var(--border); margin: 3px 0; }
|
||
/* ── Toolbar ── */
|
||
#toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
padding: 7px 16px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
#toolbar select, #toolbar input[type="text"], #toolbar input[type="number"] {
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
border-radius: 5px;
|
||
padding: 5px 8px;
|
||
font-size: 12px;
|
||
font-family: var(--font);
|
||
line-height: 1.4;
|
||
}
|
||
#toolbar select:focus, #toolbar input:focus { outline: none; border-color: var(--accent); }
|
||
#queryInput { flex: 1; min-width: 200px; }
|
||
#countInput { width: 68px; }
|
||
|
||
/* ── Archive read-only mode — replaces the search controls when this page
|
||
was opened as "View as Graph" from a saved archive instead of a live
|
||
search. No query/tool/mode/count inputs, no Search/Archive All buttons:
|
||
nothing here can trigger a new fetch, only visualize what's already in
|
||
the archive's JSON. */
|
||
#archiveModeLabel {
|
||
display: none;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
#archiveModeLabel .amb-pill {
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
background: var(--accent-bg);
|
||
color: #818cf8;
|
||
border: 1px solid #34348a;
|
||
flex-shrink: 0;
|
||
}
|
||
#archiveModeText { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
||
/* ── Buttons ── */
|
||
.btn {
|
||
padding: 5px 12px;
|
||
border-radius: 5px;
|
||
border: 1px solid var(--border);
|
||
font-size: 12px;
|
||
font-family: var(--font);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
line-height: 1.4;
|
||
}
|
||
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
.btn-primary:hover:not(:disabled) { background: #4752c4; }
|
||
.btn-ghost { background: transparent; color: var(--muted); }
|
||
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--accent); background: var(--accent-bg); }
|
||
.btn-success { background: transparent; color: var(--success); border-color: #1a4d2e; }
|
||
.btn-success:hover:not(:disabled) { background: rgba(34,197,94,0.1); border-color: var(--success); }
|
||
.btn-danger { background: transparent; color: var(--danger); border-color: #4d1a1a; }
|
||
.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.1); border-color: var(--danger); }
|
||
.btn:disabled { opacity: 0.38; cursor: not-allowed; }
|
||
|
||
/* ── Archive progress bar — same look as the non-graph search page ── */
|
||
.archive-bar {
|
||
padding: 6px 16px;
|
||
background: var(--surface);
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
flex-shrink: 0;
|
||
}
|
||
.archive-bar-fill-wrap {
|
||
height: 3px;
|
||
background: var(--border);
|
||
border-radius: 2px;
|
||
margin-top: 6px;
|
||
overflow: hidden;
|
||
}
|
||
.archive-bar-fill {
|
||
height: 100%;
|
||
background: var(--accent);
|
||
border-radius: 2px;
|
||
transition: width 0.3s;
|
||
}
|
||
|
||
/* ── Main area ── */
|
||
#main {
|
||
flex: 1;
|
||
display: flex;
|
||
min-height: 0;
|
||
position: relative;
|
||
}
|
||
|
||
/* ── Cytoscape canvas ── */
|
||
#cy {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: var(--bg);
|
||
}
|
||
|
||
/* ── Info panel ── */
|
||
#infoPanel {
|
||
width: 310px;
|
||
border-left: 1px solid var(--border);
|
||
background: var(--surface);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
overflow: hidden;
|
||
transition: width 0.2s ease;
|
||
}
|
||
#infoPanel.hidden { width: 0; border-left: none; }
|
||
|
||
#infoHeader {
|
||
padding: 9px 12px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-shrink: 0;
|
||
gap: 8px;
|
||
}
|
||
#infoTitle {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
#btnClosePanel {
|
||
background: none;
|
||
border: none;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
padding: 0 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
#btnClosePanel:hover { color: var(--text); }
|
||
|
||
#infoContent {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px 12px;
|
||
font-size: 12px;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border) transparent;
|
||
}
|
||
.panel-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding-bottom: 12px;
|
||
margin-bottom: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.panel-avatar {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
border: 2px solid var(--border);
|
||
flex-shrink: 0;
|
||
background: var(--bg);
|
||
}
|
||
.panel-avatar-fallback {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--muted);
|
||
}
|
||
.panel-identity { min-width: 0; }
|
||
.panel-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.panel-handle { font-size: 11px; color: var(--muted); }
|
||
.verified-badge { color: var(--accent); font-weight: 700; font-size: 0.85em; margin-left: 3px; }
|
||
/* Compact variant for tweet/reply nodes — the node's own text is a row
|
||
below, so this stays a small identity line rather than a full profile
|
||
block (that treatment is reserved for user/retweeter nodes). */
|
||
.panel-byline { padding-bottom: 10px; }
|
||
.panel-byline .panel-avatar { width: 28px; height: 28px; }
|
||
.panel-byline .panel-identity { display: flex; align-items: baseline; gap: 6px; }
|
||
.panel-bio {
|
||
font-size: 11px;
|
||
color: var(--text);
|
||
opacity: 0.85;
|
||
line-height: 1.5;
|
||
padding-bottom: 10px;
|
||
margin-bottom: 4px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.info-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
align-items: flex-start;
|
||
}
|
||
.info-key {
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
min-width: 95px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
flex-shrink: 0;
|
||
padding-top: 1px;
|
||
line-height: 1.5;
|
||
}
|
||
.info-val {
|
||
color: var(--text);
|
||
flex: 1;
|
||
word-break: break-word;
|
||
line-height: 1.5;
|
||
}
|
||
.info-val a { color: var(--accent); text-decoration: none; }
|
||
.info-val a:hover { text-decoration: underline; }
|
||
/* "Replying to @x @y" — x.com trims these from the visible tweet body and
|
||
shows them as their own line instead; matches that here. Display-only,
|
||
the underlying text value (Dump JSON / Archive All) is untouched. */
|
||
.reply-context {
|
||
font-size: 10px;
|
||
color: var(--muted);
|
||
margin-bottom: 4px;
|
||
}
|
||
.info-media {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 5px;
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.info-media a img {
|
||
width: 80px;
|
||
height: 60px;
|
||
object-fit: cover;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
display: block;
|
||
}
|
||
.info-media video.info-video {
|
||
display: block;
|
||
width: 100%;
|
||
max-width: 260px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
background: #000;
|
||
}
|
||
|
||
#infoActions {
|
||
padding: 10px 12px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
flex-shrink: 0;
|
||
}
|
||
#infoActions .btn { width: 100%; text-align: center; justify-content: center; }
|
||
#infoActions a.btn { text-decoration: none; display: block; }
|
||
|
||
/* ── Legend / Help popup ── */
|
||
#legendToggle {
|
||
position: absolute;
|
||
bottom: 14px;
|
||
left: 14px;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: #FFFFFF;
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
z-index: 11;
|
||
transition: all 0.15s;
|
||
}
|
||
#legendToggle:hover { color: var(--text); border-color: var(--accent); }
|
||
#legend {
|
||
position: absolute;
|
||
bottom: 50px;
|
||
left: 14px;
|
||
background: rgba(26,29,39,0.96);
|
||
border: 1px solid var(--border);
|
||
border-radius: 7px;
|
||
padding: 10px 14px;
|
||
font-size: 11px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
z-index: 10;
|
||
box-shadow: 0 8px 28px rgba(0,0,0,0.5);
|
||
width: 260px;
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--border) transparent;
|
||
}
|
||
#legend.hidden { display: none; }
|
||
.leg-row { display: flex; align-items: center; gap: 8px; color: var(--muted); }
|
||
.leg-icon {
|
||
width: 14px;
|
||
height: 14px;
|
||
flex-shrink: 0;
|
||
border: 2px solid;
|
||
}
|
||
.leg-icon.search { background: var(--accent); border-color: var(--accent); transform: rotate(45deg); border-radius: 1px; }
|
||
.leg-icon.tweet { background: var(--accent-bg); border-color: var(--accent); border-radius: 2px; }
|
||
.leg-icon.reply { background: rgba(34,197,94,0.12); border-color: var(--success); border-radius: 2px; }
|
||
.leg-icon.user { background: var(--purple-bg); border-color: var(--purple); border-radius: 50%; }
|
||
.leg-icon.wayback { background: #2a1f08; border-color: var(--warn); border-radius: 2px; }
|
||
.leg-icon.cse { background: #2a0d0d; border-color: #ef4444; border-radius: 2px; }
|
||
.leg-icon.viewed { background: #ffffff; border-color: #c7c7c7; border-radius: 2px; }
|
||
.leg-line { width: 20px; height: 0; flex-shrink: 0; border-top: 2px solid; }
|
||
.leg-line-correlation { border-top-color: #facc15; }
|
||
.leg-heading {
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--muted);
|
||
margin: 6px 0 0;
|
||
}
|
||
.leg-note { color: var(--muted); font-size: 10px; line-height: 1.5; max-width: 220px; margin-top: 4px; }
|
||
.param-heading {
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--muted);
|
||
margin: 8px 0 3px;
|
||
padding-top: 6px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.param-heading:first-child { margin-top: 0; padding-top: 0; border-top: none; }
|
||
.param-row { color: var(--muted); font-size: 10.5px; line-height: 1.5; margin: 2px 0; }
|
||
.param-key {
|
||
display: inline-block;
|
||
color: var(--accent);
|
||
background: var(--accent-bg);
|
||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||
font-size: 10px;
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
margin-right: 4px;
|
||
}
|
||
|
||
/* ── Source badges (info panel) ── */
|
||
.source-badge {
|
||
display: inline-block;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.source-badge.src-cookie { color: #a78bfa; border-color: #4c3a8f; background: var(--purple-bg); }
|
||
.source-badge.src-xquik { color: #8891f7; border-color: #34348a; background: var(--accent-bg); }
|
||
.source-badge.src-wayback { color: var(--warn); border-color: #78350f; background: #1c0e02; }
|
||
.source-badge.src-cse { color: #f87171; border-color: #7f1d1d; background: #2a0d0d; }
|
||
|
||
.age-badge {
|
||
display: inline-block;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.age-badge.age-new { color: var(--danger); border-color: #7f1d1d; background: #1c0505; }
|
||
.age-badge.age-recent { color: var(--warn); border-color: #78350f; background: #1c0e02; }
|
||
.age-badge.age-established { color: var(--success); border-color: #14532d; background: #052011; }
|
||
|
||
/* What kind of X/Twitter page a Google CSE / Wayback result actually is —
|
||
a keyword match linking to x.com/someone reads as "just a profile" with
|
||
no way to tell a tweet permalink apart from a bare profile page or an
|
||
unrelated page otherwise. */
|
||
.content-type-badge {
|
||
display: inline-block;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
padding: 2px 9px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.content-type-badge.ct-tweet { color: var(--success); border-color: #14532d; background: #052011; }
|
||
.content-type-badge.ct-profile { color: #a78bfa; border-color: #4c3a8f; background: var(--purple-bg); }
|
||
.content-type-badge.ct-twitter_other { color: var(--warn); border-color: #78350f; background: #1c0e02; }
|
||
.content-type-badge.ct-other { color: var(--muted); border-color: var(--border); background: var(--bg); }
|
||
|
||
/* ── Status bar ── */
|
||
#statusBar {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(26,29,39,0.94);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
padding: 4px 16px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
pointer-events: none;
|
||
z-index: 20;
|
||
transition: opacity 0.3s;
|
||
max-width: 420px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
#statusBar.error { color: var(--danger); border-color: #4d1a1a; }
|
||
#statusBar.hidden { opacity: 0; }
|
||
|
||
/* ── Node count badge ── */
|
||
#nodeCount {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
padding: 3px 9px;
|
||
background: var(--bg);
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* ── Spinner ── */
|
||
.spin {
|
||
display: inline-block;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Empty state hint ── */
|
||
#emptyHint {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
color: var(--border);
|
||
font-size: 13px;
|
||
pointer-events: none;
|
||
text-align: center;
|
||
line-height: 2;
|
||
z-index: 5;
|
||
}
|
||
#emptyHint.hidden { display: none; }
|
||
|
||
/* Active case indicator in toolbar */
|
||
.case-indicator {
|
||
display: none; align-items: center; gap: 6px;
|
||
padding: 3px 8px; border-radius: 5px;
|
||
background: var(--accent-bg); border: 1px solid var(--accent); font-size: 11px;
|
||
}
|
||
.case-indicator.visible { display: flex; }
|
||
.case-indicator-name {
|
||
max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
color: var(--accent); font-weight: 500; color: #fff;
|
||
}
|
||
.case-checkpoint-btn {
|
||
padding: 4px 10px; font-size: 12px; border-radius: 5px;
|
||
background: transparent; border: 1px solid var(--accent); color: var(--accent);
|
||
font-family: var(--font); cursor: pointer; transition: all 0.12s; flex-shrink: 0; color: #fff;
|
||
}
|
||
.case-checkpoint-btn:hover:not(:disabled) { background: var(--accent-bg); }
|
||
.case-checkpoint-btn:disabled { opacity: 0.5; cursor: wait; }
|
||
.case-save-bar {
|
||
background: var(--surface); border-bottom: 1px solid var(--border);
|
||
padding: 10px 16px; display: flex; flex-direction: column; gap: 8px;
|
||
}
|
||
.case-save-bar[hidden] { display: none; }
|
||
.case-save-label { font-size: 12px; color: var(--muted); font-weight: 500; }
|
||
.case-save-input {
|
||
width: 100%; padding: 7px 10px; box-sizing: border-box;
|
||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||
color: var(--text); font-family: var(--font); font-size: 13px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.case-save-input:focus { outline: none; border-color: var(--accent); }
|
||
.case-save-input::placeholder { color: var(--muted); }
|
||
.case-save-btns { display: flex; gap: 6px; }
|
||
.case-save-confirm {
|
||
padding: 6px 16px; font-size: 12px; border-radius: 5px; white-space: nowrap;
|
||
background: var(--accent); color: #fff; border: none;
|
||
font-family: var(--font); font-weight: 500; cursor: pointer; transition: opacity 0.15s;
|
||
}
|
||
.case-save-confirm:disabled { opacity: 0.45; cursor: wait; }
|
||
.case-save-confirm:not(:disabled):hover { opacity: 0.85; }
|
||
.case-save-cancel {
|
||
padding: 6px 10px; font-size: 12px; border-radius: 5px; white-space: nowrap;
|
||
background: transparent; color: var(--muted); border: 1px solid var(--border);
|
||
font-family: var(--font); cursor: pointer; transition: all 0.12s;
|
||
}
|
||
.case-save-cancel:hover { color: var(--text); border-color: var(--muted); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>Jieyab89 SOCMINT X</h1>
|
||
<span class="sep">|</span>
|
||
<span class="sub">Graph Visualization</span>
|
||
<button id="navToggle" class="hamburger-btn" aria-label="Navigation menu">☰</button>
|
||
<nav id="navMenu" class="nav-menu hidden">
|
||
<a href="/">Home</a>
|
||
<a href="/graph" class="current">Graph</a>
|
||
<hr class="nav-divider">
|
||
<a href="/archives">Archives</a>
|
||
<a href="/analytics">Analytics</a>
|
||
<hr class="nav-divider">
|
||
<a href="/cases">Cases</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<div id="toolbar">
|
||
<span id="archiveModeLabel"><span class="amb-pill">Archive · read-only</span><span id="archiveModeText">—</span></span>
|
||
<select id="toolSelect">
|
||
<option value="tweet_search_extractor">Tweet Search</option>
|
||
<option value="follower_explorer">Follower Explorer</option>
|
||
<option value="following_explorer">Following Explorer [Cookie]</option>
|
||
<option value="post_extractor">Post Extractor</option>
|
||
<option value="article_extractor">Article Extractor</option>
|
||
<option value="community_post_extractor">Community Posts</option>
|
||
<option value="tweet_replies_extractor">Tweet Replies [Cookie]</option>
|
||
<option value="tweet_retweeters_extractor">Tweet Retweeters [Cookie]</option>
|
||
<option value="geo_post_extractor">Geo Post Search [Cookie]</option>
|
||
<option value="wayback_archive_search">Wayback Archive Search</option>
|
||
<option value="multi_source_search">Multi Source Search [All]</option>
|
||
</select>
|
||
<select id="modeSelect">
|
||
<option value="cookie">Cookie</option>
|
||
<option value="api">API</option>
|
||
</select>
|
||
<input id="queryInput" type="text" placeholder="Search query…" spellcheck="false" autocomplete="off">
|
||
<input id="dateFromInput" type="text" placeholder="From YYYYMMDD" title="Optional start date" maxlength="8" style="width:100px;display:none">
|
||
<input id="dateToInput" type="text" placeholder="To YYYYMMDD" title="Optional end date" maxlength="8" style="width:100px;display:none">
|
||
<input id="countInput" type="number" value="20" min="1" max="2000" title="Result count">
|
||
<input id="graphFilterInput" type="text" placeholder="Filter graph…" spellcheck="false" autocomplete="off" style="display:none;width:160px" title="Show only nodes whose data matches this text">
|
||
<span id="nodeCount">0 nodes</span>
|
||
<span id="caseIndicator" class="case-indicator">
|
||
<span class="case-indicator-name" id="caseIndicatorName"></span>
|
||
</span>
|
||
<button id="caseCheckpointBtn" class="case-checkpoint-btn">Save Case</button>
|
||
<button id="btnSearch" class="btn btn-primary">Search</button>
|
||
<button id="btnArchiveAll" class="btn btn-ghost">Archive All</button>
|
||
<button id="btnDump" class="btn btn-ghost">Dump JSON</button>
|
||
<button id="btnClear" class="btn btn-danger">Clear</button>
|
||
</div>
|
||
|
||
<div id="archiveBar" class="archive-bar" style="display:none"></div>
|
||
<div id="caseSaveForm" class="case-save-bar" hidden>
|
||
<span class="case-save-label">Case name</span>
|
||
<input id="caseSaveName" class="case-save-input" type="text" placeholder="Case name…" maxlength="120" autocomplete="off" spellcheck="false">
|
||
<div class="case-save-btns">
|
||
<button id="caseSaveConfirm" class="case-save-confirm">Save</button>
|
||
<button id="caseSaveCancel" class="case-save-cancel">Cancel</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="main">
|
||
<div id="cy"></div>
|
||
|
||
<!-- Right info panel -->
|
||
<div id="infoPanel" class="hidden">
|
||
<div id="infoHeader">
|
||
<span id="infoTitle">Node Details</span>
|
||
<button id="btnClosePanel" title="Close">×</button>
|
||
</div>
|
||
<div id="infoContent"></div>
|
||
<div id="infoActions">
|
||
<button id="btnExpandReplies" class="btn btn-ghost" style="display:none">↩ Expand Replies</button>
|
||
<button id="btnExpandRetweets" class="btn btn-ghost" style="display:none">↗ Expand Retweets</button>
|
||
<button id="btnViewAuthor" class="btn btn-ghost" style="display:none">View Author Profile</button>
|
||
<button id="btnExpandPosts" class="btn btn-ghost" style="display:none">Expand Posts</button>
|
||
<button id="btnExpandFollowers" class="btn btn-ghost" style="display:none">Expand Followers</button>
|
||
<button id="btnExpandFollowing" class="btn btn-ghost" style="display:none">Expand Following</button>
|
||
<button id="btnLoadMoreSearch" class="btn btn-ghost" style="display:none">⤓ Load More Results</button>
|
||
<a id="btnOpenTweet" class="btn btn-success" style="display:none" href="#" target="_blank" rel="noopener noreferrer">Open Tweet ↗</a>
|
||
<button id="btnDeleteNode" class="btn btn-danger" style="display:none">Delete Node</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status bar (floats over cy) -->
|
||
<div id="statusBar" class="hidden">Ready</div>
|
||
|
||
<!-- Empty state -->
|
||
<div id="emptyHint">
|
||
Use the toolbar to search<br>
|
||
Click any node to inspect · Click tweet nodes to expand replies / retweets<br>
|
||
Shift/Alt/Ctrl+drag to box-select multiple nodes, then drag any one to move them together
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Legend / help popup (floats over cy, bottom-left) -->
|
||
<button id="legendToggle" aria-label="Legend / help" title="Legend / help">?</button>
|
||
<div id="legend" class="hidden">
|
||
<div class="leg-heading">Controls</div>
|
||
<div class="leg-note">Drag background to pan · Scroll to zoom · Click a node to inspect it<br>Shift/Alt/Ctrl+drag to box-select multiple nodes, then drag any one to move the whole selection together</div>
|
||
|
||
<div class="leg-row"><div class="leg-icon search"></div> Search root</div>
|
||
<div class="leg-row"><div class="leg-icon tweet"></div> Tweet</div>
|
||
<div class="leg-row"><div class="leg-icon reply"></div> Reply</div>
|
||
<div class="leg-row"><div class="leg-icon user"></div> User / Retweeter / Follower / Following</div>
|
||
<div class="leg-row"><div class="leg-icon wayback"></div> Wayback snapshot</div>
|
||
<div class="leg-row"><div class="leg-icon cse"></div> Google CSE web result</div>
|
||
<div class="leg-row"><div class="leg-icon viewed"></div> Viewed (clicked)</div>
|
||
<div class="leg-note">Double border = this tweet/reply has replies of its own — click it, then Expand Replies to go a level deeper. Works on reply nodes too, so a thread nests as deep as it actually goes.</div>
|
||
<div class="leg-note">A user/retweeter node's photo is their real avatar when one was captured (cookie mode) — the plain purple circle is the fallback when it wasn't.</div>
|
||
|
||
<div class="leg-heading">Expanding a node further</div>
|
||
<div class="leg-note">Tweet/Reply — Expand Replies, Expand Retweets, or View Author Profile (pulls the author out as its own node, free — no request, the data's already on the tweet).<br>User/Retweeter — Expand Posts, Expand Followers, or Expand Following. Works on a "View Author" node too, so a reply's author can be looked up in full: posts, followers, following.</div>
|
||
<div class="leg-note">Edge labels show how two nodes relate once expanded: <em>replied by</em>, <em>retweeted by</em>, <em>posted</em>, <em>followed by</em>, <em>follows</em>, <em>authored by</em>. A plain unlabeled edge just means "matched the search."</div>
|
||
<div class="leg-note"><em>quoted</em> — a quote-tweet's edge to the tweet it quoted, drawn only when that original tweet already exists as its own node in this graph (no "expand quotes" action exists, so it's opportunistic like other correlations below).</div>
|
||
<div class="leg-row"><div class="leg-line leg-line-correlation"></div> Correlation — this connects to a node already reached some other way (e.g. a reply's author who also turns up as a follower). Marks where two separate paths through the graph converge on the same account/tweet.</div>
|
||
|
||
<div class="leg-heading">Source (info panel)</div>
|
||
<div class="leg-row"><span class="source-badge src-cookie">Cookie</span></div>
|
||
<div class="leg-row"><span class="source-badge src-xquik">Xquik API</span></div>
|
||
<div class="leg-row"><span class="source-badge src-wayback">Wayback Machine</span></div>
|
||
<div class="leg-row"><span class="source-badge src-cse">Google CSE</span></div>
|
||
|
||
<div class="leg-heading">Content type — Google CSE / Wayback only (info panel)</div>
|
||
<div class="leg-row"><span class="content-type-badge ct-tweet">Tweet</span></div>
|
||
<div class="leg-row"><span class="content-type-badge ct-profile">Profile page</span></div>
|
||
<div class="leg-row"><span class="content-type-badge ct-twitter_other">Other X/Twitter page</span></div>
|
||
<div class="leg-row"><span class="content-type-badge ct-other">External page (non-X)</span></div>
|
||
<div class="leg-note">What kind of page a keyword match actually landed on — a search hit isn't always a specific tweet.</div>
|
||
|
||
<div class="leg-heading">Account age (info panel)</div>
|
||
<div class="leg-row"><span class="age-badge age-new">New</span> < 30 days</div>
|
||
<div class="leg-row"><span class="age-badge age-recent">Recent</span> < 1 year</div>
|
||
<div class="leg-row"><span class="age-badge age-established">Established</span> 1+ year</div>
|
||
<div class="leg-note">Decoded from the account's numeric ID. "~" = estimated (pre-Snowflake id); "Unknown" = id in an undatable gap.</div>
|
||
|
||
<div class="leg-heading">What each parameter means</div>
|
||
{% include "_field_glossary.html" %}
|
||
</div>
|
||
|
||
|
||
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js" crossorigin="anonymous"></script>
|
||
<script nonce="{{ g.csp_nonce }}">
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────────
|
||
const allItems = []; // { type, item }
|
||
let selectedNode = null;
|
||
let cy;
|
||
|
||
// ── Archive read-only mode ("View as Graph" from a saved archive) ──────────
|
||
// Set once, at boot, from ?archiveId=<id> in the URL — never toggled after.
|
||
// Gates every code path that would otherwise fetch fresh live data (the
|
||
// toolbar's own Search/Archive All, and the info panel's Expand Posts/
|
||
// Followers/Following/Load More Results — Expand Replies/Retweets get a
|
||
// LOCAL equivalent instead, see ARCHIVE_EXPAND_KINDS below, since a reply's
|
||
// in_reply_to_tweet_id and a retweeter's retweeted_tweet_id are both
|
||
// already sitting right there in the archive's own saved JSON, no live
|
||
// fetch needed to find them), leaving pan/zoom/click/box-select/
|
||
// local-filter/Dump JSON/Clear/delete-node fully working since none of
|
||
// those touch the network — this view is purely a visualization of the
|
||
// archive's already-saved JSON, matching what the plain card list on
|
||
// archive.html itself shows for the exact same data.
|
||
let READONLY_ARCHIVE_MODE = false;
|
||
var archiveFullItems = null; // set once in enterArchiveReadOnlyMode() — every record this archive has, whether revealed as a node yet or not
|
||
|
||
// ── Cytoscape styles ──────────────────────────────────────────────────────────
|
||
const CY_STYLE = [
|
||
{ selector: 'node[type="search"]', style: {
|
||
shape: 'diamond',
|
||
'background-color': '#5865f2',
|
||
label: 'data(label)',
|
||
color: '#e8eaf0',
|
||
'font-size': '11px',
|
||
'min-zoomed-font-size': 7,
|
||
'text-valign': 'bottom',
|
||
'text-halign': 'center',
|
||
'text-margin-y': '9px',
|
||
width: '46px',
|
||
height: '46px',
|
||
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||
}},
|
||
// Tweet/reply/wayback cards: fixed, compact footprint regardless of content —
|
||
// text-wrap 'ellipsis' truncates long post text to fit instead of growing the
|
||
// box or overflowing it, which keeps dense graphs (100s of nodes) readable.
|
||
// Full untruncated text is always available in the side panel on click.
|
||
{ selector: 'node[type="tweet"], node[type="reply"], node[type="wayback"], node[type="cse"]', style: {
|
||
shape: 'roundrectangle',
|
||
label: 'data(label)',
|
||
color: '#e8eaf0',
|
||
'font-size': '9px',
|
||
'min-zoomed-font-size': 7,
|
||
'text-valign': 'center',
|
||
'text-halign': 'center',
|
||
'text-wrap': 'ellipsis',
|
||
'text-max-width': '128px',
|
||
'border-width': '2px',
|
||
width: '148px',
|
||
height: '52px',
|
||
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||
}},
|
||
{ selector: 'node[type="tweet"]', style: {
|
||
'background-color': '#1e2240',
|
||
'border-color': '#5865f2',
|
||
}},
|
||
{ selector: 'node[type="reply"]', style: {
|
||
'background-color': '#0d2018',
|
||
'border-color': '#22c55e',
|
||
}},
|
||
{ selector: 'node[type="wayback"]', style: {
|
||
'background-color': '#2a1f08',
|
||
'border-color': '#f59e0b',
|
||
}},
|
||
{ selector: 'node[type="cse"]', style: {
|
||
'background-color': '#2a0d0d',
|
||
'border-color': '#ef4444',
|
||
}},
|
||
{ selector: 'node[type="retweeter"], node[type="user"]', style: {
|
||
shape: 'ellipse',
|
||
'background-color': '#1e1535',
|
||
'border-color': '#8b5cf6',
|
||
'border-width': '2px',
|
||
label: 'data(label)',
|
||
color: '#e8eaf0',
|
||
'font-size': '10px',
|
||
'min-zoomed-font-size': 7,
|
||
'text-valign': 'bottom',
|
||
'text-halign': 'center',
|
||
'text-margin-y': '7px',
|
||
'text-wrap': 'ellipsis',
|
||
'text-max-width': '90px',
|
||
width: '68px',
|
||
height: '68px',
|
||
'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||
}},
|
||
// Viewed marker — set once a node has been clicked/inspected. Deliberately
|
||
// plain white with no other color coding so it reads as one thing: "seen".
|
||
// Placed after the type selectors so it always wins on background/border.
|
||
{ selector: 'node[?viewed][type="tweet"], node[?viewed][type="reply"], node[?viewed][type="wayback"], node[?viewed][type="cse"]', style: {
|
||
'background-color': '#ffffff',
|
||
'border-color': '#c7c7c7',
|
||
color: '#0f1117',
|
||
}},
|
||
{ selector: 'node[?viewed][type="search"], node[?viewed][type="user"], node[?viewed][type="retweeter"]', style: {
|
||
'background-color': '#ffffff',
|
||
'border-color': '#c7c7c7',
|
||
}},
|
||
// Avatar texture — replaces the flat fill with the person's actual profile
|
||
// photo when one was captured (cookie mode only; xquik/API-mode records
|
||
// don't carry it). The colored ring from the selectors above stays as a
|
||
// type indicator; only the fill becomes a photo. Declared after the
|
||
// "viewed" block so the photo wins regardless of viewed state.
|
||
{ selector: 'node[type="user"][?hasAvatar], node[type="retweeter"][?hasAvatar]', style: {
|
||
'background-image': 'data(avatarUrl)',
|
||
'background-fit': 'cover',
|
||
'background-clip': 'node',
|
||
}},
|
||
// Nested-reply indicator — a slightly thicker, double-lined border marks a
|
||
// tweet/reply node that has further replies of its own to expand into,
|
||
// glanceable on the canvas without opening the info panel.
|
||
{ selector: 'node[?hasReplies]', style: {
|
||
'border-width': '3px',
|
||
'border-style': 'double',
|
||
}},
|
||
// Multi-select highlight (Maltego-style box select) — wins over every
|
||
// type's own border-color above since it's declared last in this array
|
||
// (Cytoscape applies matching selectors in declaration order, last wins).
|
||
{ selector: 'node:selected', style: {
|
||
'border-width': '3px',
|
||
'border-color': '#5865f2',
|
||
'overlay-color': '#5865f2',
|
||
'overlay-opacity': 0.22,
|
||
'overlay-padding': '6px',
|
||
}},
|
||
// Softer, thinner and semi-transparent — a hub with many edges (a popular
|
||
// tweet's replies, a big multi-source search) stays legible instead of
|
||
// turning into a bright white tangle.
|
||
{ selector: 'edge', style: {
|
||
'line-color': '#FFFFFF',
|
||
'line-style': 'dashed',
|
||
'line-opacity': 1.0,
|
||
width: '1.1px',
|
||
'target-arrow-color': '#FFFFFF',
|
||
'target-arrow-shape': 'triangle',
|
||
'arrow-scale': 1.0,
|
||
'curve-style': 'bezier',
|
||
// Empty for a plain "matched the search" edge (addEdge's label
|
||
// defaults to ''), so most edges in a fresh search still draw with no
|
||
// visible text — this only shows up on the relationship-specific
|
||
// edges node-expand actions create (replied by / retweeted by /
|
||
// posted / followed by / follows / authored by).
|
||
label: 'data(label)',
|
||
'font-size': '8px',
|
||
color: '#c7c7c7',
|
||
'text-rotation': 'autorotate',
|
||
'text-background-color': '#0f1117',
|
||
'text-background-opacity': 0.85,
|
||
'text-background-padding': '2px',
|
||
}},
|
||
// Correlation — this edge connects to a node that already existed via
|
||
// some OTHER path (a different search, a different expand, ...) rather
|
||
// than one just discovered. Declared after the base 'edge' selector so it
|
||
// wins on line/arrow color; solid + thicker on top of the color change so
|
||
// it's still readable once a dense graph desaturates everything else.
|
||
{ selector: 'edge[?correlation]', style: {
|
||
'line-color': '#facc15',
|
||
'line-style': 'solid',
|
||
width: '1.8px',
|
||
'target-arrow-color': '#facc15',
|
||
color: '#facc15',
|
||
}},
|
||
];
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
function init() {
|
||
cy = cytoscape({
|
||
container: document.getElementById('cy'),
|
||
style: CY_STYLE,
|
||
layout: { name: 'preset' },
|
||
minZoom: 0.08,
|
||
maxZoom: 4,
|
||
wheelSensitivity: 0.25,
|
||
// Maltego-style multi-select: hold shift/alt/ctrl/cmd and drag on empty
|
||
// canvas to rubber-band select several nodes at once, then drag any one
|
||
// of them to move the whole selection together — this is native
|
||
// Cytoscape drag behavior once elements are selected, no extra code
|
||
// needed. Plain drag-on-background still pans (Cytoscape requires a
|
||
// modifier key for box select whenever panning is also enabled, so the
|
||
// two gestures never conflict).
|
||
boxSelectionEnabled: true,
|
||
// Keep panning/zooming smooth once a graph has hundreds of nodes/edges —
|
||
// edges & a rasterized texture stand in for the full render mid-gesture.
|
||
hideEdgesOnViewport: true,
|
||
textureOnViewport: true,
|
||
});
|
||
|
||
cy.on('tap', 'node', function (evt) {
|
||
selectedNode = evt.target;
|
||
selectedNode.data('viewed', true);
|
||
showPanel(selectedNode.data());
|
||
});
|
||
|
||
cy.on('tap', function (evt) {
|
||
if (evt.target === cy) hidePanel();
|
||
});
|
||
|
||
// Multi-select bookkeeping — the single-node info panel only makes sense
|
||
// for exactly one selected node, so a box-select of several hides it
|
||
// (without touching the selection itself, unlike hidePanel()) and the
|
||
// node-count badge picks up the slack to show what's selected.
|
||
cy.on('select unselect', 'node', function () {
|
||
updateNodeCount();
|
||
if (cy.nodes(':selected').length > 1) {
|
||
document.getElementById('infoPanel').classList.add('hidden');
|
||
selectedNode = null;
|
||
}
|
||
});
|
||
|
||
// Hamburger nav
|
||
var navBtn = document.getElementById('navToggle');
|
||
var navMenu = document.getElementById('navMenu');
|
||
navBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
navMenu.classList.toggle('hidden');
|
||
});
|
||
document.addEventListener('click', function() { navMenu.classList.add('hidden'); });
|
||
navMenu.addEventListener('click', function(e) { e.stopPropagation(); });
|
||
|
||
// Legend / help popup
|
||
var legendBtn = document.getElementById('legendToggle');
|
||
var legendPopup = document.getElementById('legend');
|
||
legendBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
legendPopup.classList.toggle('hidden');
|
||
});
|
||
document.addEventListener('click', function () { legendPopup.classList.add('hidden'); });
|
||
legendPopup.addEventListener('click', function (e) { e.stopPropagation(); });
|
||
|
||
document.getElementById('btnSearch').addEventListener('click', runSearch);
|
||
document.getElementById('queryInput').addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') runSearch();
|
||
});
|
||
document.getElementById('toolSelect').addEventListener('change', function () {
|
||
updatePlaceholder();
|
||
updateModeVisibility();
|
||
});
|
||
document.getElementById('btnArchiveAll').addEventListener('click', archiveAll);
|
||
document.getElementById('btnDump').addEventListener('click', dumpJSON);
|
||
document.getElementById('btnClear').addEventListener('click', clearGraph);
|
||
document.getElementById('btnClosePanel').addEventListener('click', hidePanel);
|
||
document.getElementById('btnDeleteNode').addEventListener('click', deleteSelectedNodes);
|
||
|
||
// Delete/Backspace removes whatever's currently selected — one node (the
|
||
// panel's own Delete button covers that too) or a whole box-selected
|
||
// group at once, standard graph-editor convention. Guarded against
|
||
// hijacking a real text edit: the toolbar's query/filter/date/count
|
||
// inputs are actual text fields, and Backspace there must keep editing
|
||
// text, not start deleting graph nodes just because a node happens to
|
||
// still be selected in the background.
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||
var tag = (document.activeElement && document.activeElement.tagName) || '';
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||
if (!cy.nodes(':selected').length && !selectedNode) return;
|
||
e.preventDefault();
|
||
deleteSelectedNodes();
|
||
});
|
||
document.getElementById('btnExpandReplies').addEventListener('click', function () {
|
||
if (READONLY_ARCHIVE_MODE) { expandArchiveNode('replies'); } else { expandNode('tweet_replies_extractor'); }
|
||
});
|
||
document.getElementById('btnExpandRetweets').addEventListener('click', function () {
|
||
if (READONLY_ARCHIVE_MODE) { expandArchiveNode('retweets'); } else { expandNode('tweet_retweeters_extractor'); }
|
||
});
|
||
document.getElementById('btnViewAuthor').addEventListener('click', viewAuthor);
|
||
document.getElementById('btnExpandPosts').addEventListener('click', function () {
|
||
expandNode('post_extractor');
|
||
});
|
||
document.getElementById('btnExpandFollowers').addEventListener('click', function () {
|
||
expandNode('follower_explorer');
|
||
});
|
||
document.getElementById('btnExpandFollowing').addEventListener('click', function () {
|
||
expandNode('following_explorer');
|
||
});
|
||
document.getElementById('btnLoadMoreSearch').addEventListener('click', function () {
|
||
// Archive-mode's search root carries its own archiveItems array (see
|
||
// enterArchiveReadOnlyMode/revealArchiveBatch) — "Load More" there means
|
||
// reveal more of what's already in memory, never a live fetch, so it's
|
||
// routed to a completely separate (network-free) handler.
|
||
if (selectedNode && selectedNode.data('archiveItems')) { expandArchiveSearch(); }
|
||
else { expandSearch(); }
|
||
});
|
||
// Debounced — applyGraphFilter() walks every node/edge in the graph, so
|
||
// on a big graph re-running it on every single keystroke (rather than
|
||
// once typing pauses) is what actually made the filter box feel laggy,
|
||
// not the walk itself (searchText caching above already made each pass
|
||
// cheap — this is about how OFTEN that pass runs).
|
||
var graphFilterDebounce = null;
|
||
document.getElementById('graphFilterInput').addEventListener('input', function (e) {
|
||
var val = e.target.value;
|
||
clearTimeout(graphFilterDebounce);
|
||
graphFilterDebounce = setTimeout(function () { filterGraph(val); }, 180);
|
||
});
|
||
|
||
updatePlaceholder();
|
||
updateModeVisibility();
|
||
|
||
var archiveIdParam = new URLSearchParams(location.search).get('archiveId');
|
||
if (archiveIdParam) enterArchiveReadOnlyMode(archiveIdParam);
|
||
|
||
var caseIdParam = new URLSearchParams(location.search).get('caseId');
|
||
if (caseIdParam && !archiveIdParam) {
|
||
history.replaceState(null, '', location.pathname);
|
||
fetch('/api/cases/' + encodeURIComponent(caseIdParam))
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(json) {
|
||
if (!json.ok) throw new Error(json.error || 'Case not found');
|
||
cmGRestoreState(json.state, json.meta);
|
||
})
|
||
.catch(function(e) { setStatus('Error loading case: ' + e.message, true); });
|
||
}
|
||
}
|
||
|
||
// ── Archive read-only bootstrap ─────────────────────────────────────────────
|
||
// Entered via /graph?archiveId=<id> — archive.html's "View as Graph" button
|
||
// links here. Swaps the toolbar's search controls for a plain label, then
|
||
// loads that one archive's saved results.json through the exact same
|
||
// addNodes()/resolveNodeType() pipeline runSearch() uses for a live result
|
||
// set, so the visualization (node colors, correlation edges — including the
|
||
// 'quoted' one — panel, everything) behaves identically; only the data
|
||
// source differs, and there is no code path left that can fetch more.
|
||
async function enterArchiveReadOnlyMode(archiveId) {
|
||
READONLY_ARCHIVE_MODE = true;
|
||
|
||
['toolSelect', 'modeSelect', 'queryInput', 'dateFromInput', 'dateToInput', 'countInput', 'btnSearch', 'btnArchiveAll']
|
||
.forEach(function (id) { document.getElementById(id).style.display = 'none'; });
|
||
document.getElementById('archiveModeLabel').style.display = 'flex';
|
||
|
||
setStatus('Loading archive…', false, true);
|
||
try {
|
||
var res = await fetch('/api/archive/' + encodeURIComponent(archiveId) + '/results');
|
||
var json = await res.json();
|
||
if (!json.ok) throw new Error(json.error || 'Archive not found');
|
||
|
||
var meta = json.meta || {};
|
||
var toolRaw = meta.tool || 'unknown';
|
||
// archive.py names an archive by its tool_type verbatim — a "View as
|
||
// Graph"-created archive here in graph.html itself stores it as
|
||
// 'graph_<tool>' (archiveAll() below prefixes it deliberately, to tell
|
||
// apart "archived while viewing this in the graph" from "archived from
|
||
// the plain search page" in the archive list). resolveNodeType() below
|
||
// matches against the bare tool name ('follower_explorer', etc.), so
|
||
// that prefix has to come off before node-type resolution — otherwise
|
||
// every graph-sourced archive (a very common case) would silently fall
|
||
// through resolveNodeType's default and render every node as a plain
|
||
// tweet, even a follower list or retweeters list.
|
||
var tool = toolRaw.replace(/^graph_/, '');
|
||
var q = meta.query || {};
|
||
var query = q.searchQuery || q.targetUsername || q.targetTweetId || q.targetCommunityId || q.query || '';
|
||
var items = Array.isArray(json.results) ? json.results : [];
|
||
archiveFullItems = items;
|
||
|
||
document.getElementById('archiveModeText').textContent =
|
||
toolRaw.replace(/_/g, ' ') + (query ? ' — "' + query + '"' : '') + ' · ' + items.length + ' item(s)';
|
||
|
||
// A saved archive can hold thousands of records — a follower/following
|
||
// list that was never re-expanded down to a handful, a big multi-page
|
||
// tweet search, etc. Dumping all of them onto the canvas at once (even
|
||
// batched) still ends with a hairball nobody asked to see in full.
|
||
// Instead only the first ARCHIVE_GRAPH_REVEAL_BATCH items become nodes
|
||
// up front — click the root node (the diamond) and hit "Load More
|
||
// Results" to reveal the next batch, same button/label the live
|
||
// paginated search already uses, just pulling from this archive's own
|
||
// already-fetched `items` array (via archiveItems/archiveOffset on the
|
||
// root node's data) instead of a live API cursor. See expandArchiveSearch().
|
||
var searchId = 'search_archive_' + archiveId;
|
||
cy.add({
|
||
data: {
|
||
id: searchId, type: 'search', label: query || toolRaw.replace(/_/g, ' '), tweetId: '', raw: { query: query },
|
||
tool: tool, query: query, mode: 'archive', count: items.length,
|
||
archiveItems: items, archiveOffset: 0,
|
||
searchCursor: null, searchExhausted: false,
|
||
}
|
||
});
|
||
|
||
var added = revealArchiveBatch(searchId, ARCHIVE_GRAPH_REVEAL_BATCH);
|
||
runLayout(true, true);
|
||
updateNodeCount();
|
||
var shown = cy.getElementById(searchId).data('archiveOffset');
|
||
setStatus('Loaded from archive — showing ' + shown + ' of ' + items.length + ' item(s) (' + added + ' node(s)); click the root node, then Load More to reveal more', false, true);
|
||
} catch (e) {
|
||
setStatus('Error loading archive: ' + e.message, true, true);
|
||
}
|
||
}
|
||
|
||
// How many archive items become nodes per reveal — both the very first
|
||
// batch shown on load and every subsequent "Load More Results" click.
|
||
// Deliberately small: the point is to explore a big archive step by step
|
||
// instead of it dumping a hairball of thousands of nodes on open.
|
||
const ARCHIVE_GRAPH_REVEAL_BATCH = 5;
|
||
|
||
// Adds the next ARCHIVE_GRAPH_REVEAL_BATCH-sized slice of a search-root
|
||
// node's stored archiveItems as real nodes, advancing archiveOffset and
|
||
// setting searchCursor/searchExhausted to the same sentinel values the live
|
||
// paginated-search path uses — showPanel()'s Load More button label/disabled
|
||
// logic already reads those two fields and doesn't need to know or care
|
||
// whether "more" means another live API page or just more of this same
|
||
// in-memory array.
|
||
function revealArchiveBatch(nodeId, batchSize) {
|
||
var node = cy.getElementById(nodeId);
|
||
var data = node.data();
|
||
var items = data.archiveItems || [];
|
||
var offset = data.archiveOffset || 0;
|
||
var batch = items.slice(offset, offset + batchSize);
|
||
var added = addNodes(batch, function (item) { return resolveNodeType(data.tool, item); }, nodeId);
|
||
var newOffset = offset + batch.length;
|
||
node.data('archiveOffset', newOffset);
|
||
node.data('searchExhausted', newOffset >= items.length);
|
||
node.data('searchCursor', newOffset < items.length ? 'more' : null);
|
||
return added;
|
||
}
|
||
|
||
// Local-only equivalent of expandSearch() — no network request, no
|
||
// throttle/cooldown (nothing here touches the cookie/wayback rate limit),
|
||
// just reveals the next slice of the archive's own already-fetched items.
|
||
function expandArchiveSearch() {
|
||
if (!selectedNode) return;
|
||
var data = selectedNode.data();
|
||
if (data.type !== 'search' || !data.archiveItems) return;
|
||
|
||
var added = revealArchiveBatch(selectedNode.id(), ARCHIVE_GRAPH_REVEAL_BATCH);
|
||
runLayout(false, false);
|
||
updateNodeCount();
|
||
|
||
var refreshed = selectedNode.data();
|
||
setStatus('Revealed ' + added + ' more node(s) — ' + refreshed.archiveOffset + ' / ' + refreshed.archiveItems.length + ' shown', false);
|
||
showPanel(refreshed); // refresh the Load More button's own label/disabled state
|
||
}
|
||
|
||
// ── Placeholder by tool type ──────────────────────────────────────────────────
|
||
function updatePlaceholder() {
|
||
var map = {
|
||
tweet_search_extractor: 'Search query…',
|
||
follower_explorer: 'Username or user ID…',
|
||
following_explorer: 'Username or user ID…',
|
||
post_extractor: 'Username or user ID…',
|
||
article_extractor: 'Tweet ID…',
|
||
community_post_extractor: 'Community ID…',
|
||
tweet_replies_extractor: 'Tweet ID…',
|
||
tweet_retweeters_extractor: 'Tweet ID…',
|
||
geo_post_extractor: 'Keyword (geocodes profile location)…',
|
||
wayback_archive_search: 'Username or tweet URL…',
|
||
multi_source_search: 'Keyword, mention, username or URL…',
|
||
};
|
||
var tool = document.getElementById('toolSelect').value;
|
||
document.getElementById('queryInput').placeholder = map[tool] || 'Query…';
|
||
}
|
||
|
||
// These tools don't use the Cookie/API mode select: wayback needs no auth at
|
||
// all, multi_source_search always fans out across every mode simultaneously.
|
||
// Both also accept an optional YYYYMMDD date range to narrow results.
|
||
var NO_AUTH_TOOLS = ['wayback_archive_search', 'multi_source_search'];
|
||
|
||
function updateModeVisibility() {
|
||
var tool = document.getElementById('toolSelect').value;
|
||
var noAuth = NO_AUTH_TOOLS.indexOf(tool) !== -1;
|
||
var noDate = (tool === 'article_extractor');
|
||
document.getElementById('modeSelect').style.display = noAuth ? 'none' : '';
|
||
document.getElementById('dateFromInput').style.display = noDate ? 'none' : '';
|
||
document.getElementById('dateToInput').style.display = noDate ? 'none' : '';
|
||
}
|
||
|
||
// ── Status bar ────────────────────────────────────────────────────────────────
|
||
var _statusTimer = null;
|
||
|
||
function setStatus(msg, isError, persist) {
|
||
var bar = document.getElementById('statusBar');
|
||
bar.textContent = msg;
|
||
bar.classList.toggle('error', !!isError);
|
||
bar.classList.remove('hidden');
|
||
if (_statusTimer) clearTimeout(_statusTimer);
|
||
if (!persist) {
|
||
_statusTimer = setTimeout(function () { bar.classList.add('hidden'); }, 4500);
|
||
}
|
||
}
|
||
|
||
// ── Graph filter ─────────────────────────────────────────────────────────────
|
||
// Same idea as the "Search results…" box on the Search page, applied to the
|
||
// canvas instead of a card list: typing narrows the graph down to only the
|
||
// nodes whose data actually matches, so a big graph doesn't have to be
|
||
// visually scanned node-by-node to find one account or keyword.
|
||
var graphFilterQuery = '';
|
||
|
||
function flatText(obj) {
|
||
if (obj == null) return '';
|
||
if (typeof obj !== 'object') return String(obj);
|
||
return Object.values(obj).map(flatText).join(' ');
|
||
}
|
||
|
||
// Root search nodes are graph structure, not a result themselves — always
|
||
// kept visible so a filter narrowing the results underneath one doesn't also
|
||
// disconnect that whole subtree from view.
|
||
function applyGraphFilter() {
|
||
var q = graphFilterQuery.toLowerCase().trim();
|
||
cy.batch(function () {
|
||
if (!q) {
|
||
cy.elements().show();
|
||
return;
|
||
}
|
||
cy.nodes().forEach(function (node) {
|
||
var data = node.data();
|
||
var matches = data.type === 'search'
|
||
|| (data.label || '').toLowerCase().includes(q)
|
||
|| (data.searchText || '').includes(q);
|
||
if (matches) node.show(); else node.hide();
|
||
});
|
||
cy.edges().forEach(function (edge) {
|
||
if (edge.source().visible() && edge.target().visible()) edge.show(); else edge.hide();
|
||
});
|
||
});
|
||
}
|
||
|
||
function filterGraph(query) {
|
||
graphFilterQuery = query;
|
||
updateNodeCount();
|
||
}
|
||
|
||
function updateNodeCount() {
|
||
applyGraphFilter(); // re-applied here too, so newly added nodes honor an already-active filter
|
||
var n = cy.nodes().length;
|
||
var visN = cy.nodes(':visible').length;
|
||
var selN = cy.nodes(':selected').length;
|
||
var label = (graphFilterQuery.trim() && visN !== n)
|
||
? (visN + ' of ' + n + ' node' + (n !== 1 ? 's' : ''))
|
||
: (n + ' node' + (n !== 1 ? 's' : ''));
|
||
if (selN > 1) label += ' · ' + selN + ' selected';
|
||
document.getElementById('nodeCount').textContent = label;
|
||
document.getElementById('emptyHint').classList.toggle('hidden', n > 0);
|
||
document.getElementById('graphFilterInput').style.display = n > 0 ? '' : 'none';
|
||
}
|
||
|
||
// ── API helpers ───────────────────────────────────────────────────────────────
|
||
function buildBody(tool, query, count, mode) {
|
||
var body = { toolType: tool, mode: mode, count: count };
|
||
if (tool === 'tweet_search_extractor' || tool === 'geo_post_extractor' ||
|
||
tool === 'wayback_archive_search' || tool === 'multi_source_search') {
|
||
body.searchQuery = query;
|
||
} else if (tool === 'follower_explorer' || tool === 'following_explorer' || tool === 'post_extractor') {
|
||
body.targetUsername = query;
|
||
} else if (
|
||
tool === 'article_extractor' ||
|
||
tool === 'tweet_replies_extractor' ||
|
||
tool === 'tweet_retweeters_extractor'
|
||
) {
|
||
body.targetTweetId = query;
|
||
} else if (tool === 'community_post_extractor') {
|
||
body.targetCommunityId = query;
|
||
}
|
||
|
||
if (tool !== 'article_extractor') {
|
||
var from = document.getElementById('dateFromInput').value.trim();
|
||
var to = document.getElementById('dateToInput').value.trim();
|
||
if (tool === 'wayback_archive_search') {
|
||
if (from) body.waybackFrom = from;
|
||
if (to) body.waybackTo = to;
|
||
} else {
|
||
if (from) body.dateFrom = from;
|
||
if (to) body.dateTo = to;
|
||
}
|
||
}
|
||
return body;
|
||
}
|
||
|
||
// Single choke point every live fetch in this page goes through — runSearch(),
|
||
// expandNode(), and expandSearch() all call this, never fetch('/api/run')
|
||
// directly. Hiding the buttons that trigger those (done in showPanel()/
|
||
// enterArchiveReadOnlyMode()) only stops the normal click path; it does
|
||
// nothing against calling runSearch()/expandNode('...')/expandSearch()
|
||
// straight from devtools, and the archive-mode search root node even carries
|
||
// a real tool/query/mode (needed for its own local-only "Load More" —
|
||
// see revealArchiveBatch()), which would otherwise be enough for a stray
|
||
// expandSearch() call to build a genuine live request body. Guarding here
|
||
// instead — the one place that actually reaches the network — closes that
|
||
// off regardless of how it's invoked, which is the only place "read-only"
|
||
// can be an actual guarantee rather than just a UI suggestion.
|
||
async function apiFetch(body) {
|
||
if (READONLY_ARCHIVE_MODE) {
|
||
throw new Error('Read-only archive view — this graph only shows data already saved to the archive, no live requests are made here');
|
||
}
|
||
var res = await fetch('/api/run', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
var json = await res.json();
|
||
if (!json.ok) {
|
||
var err = new Error(json.error || 'API error');
|
||
if (res.status === 429 && json.retryAfter) err.retryAfter = json.retryAfter;
|
||
throw err;
|
||
}
|
||
return { items: json.data, nextCursor: json.nextCursor || null, sourceErrors: json.sourceErrors || null };
|
||
}
|
||
|
||
var SOURCE_LABEL_MAP = { cookie: 'Twitter Cookie', xquik: 'Xquik API', wayback: 'Wayback Machine', cse: 'Google CSE' };
|
||
|
||
// multi_source_search only — a lane running dry (no more matches) and a lane
|
||
// failing (network blip, quota hit) both just look like "fewer new nodes"
|
||
// otherwise, so this spells out which lane and why rather than leaving it silent.
|
||
function sourceErrorNote(sourceErrors) {
|
||
if (!sourceErrors || !Object.keys(sourceErrors).length) return '';
|
||
var parts = Object.keys(sourceErrors).map(function (key) {
|
||
return (SOURCE_LABEL_MAP[key] || key) + ': ' + sourceErrors[key];
|
||
});
|
||
return ' — ' + parts.join(' · ');
|
||
}
|
||
|
||
// ── Cookie/Wayback cooldown (mirrors the server's 5s-per-source throttle) ──
|
||
var cooldownUntil = { cookie: 0, wayback: 0 };
|
||
var cooldownTimer = null;
|
||
|
||
// Returns the list of rate-limit clocks a request against this tool/mode
|
||
// touches. multi_source_search fans out to both cookie and wayback
|
||
// internally, so continuing it has to wait out (and then restart) both
|
||
// cooldowns at once, not just one.
|
||
function throttleSourceFor(tool, mode) {
|
||
if (tool === 'wayback_archive_search') return ['wayback'];
|
||
if (tool === 'multi_source_search') return ['cookie', 'wayback'];
|
||
var COOKIE_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'following_explorer', 'post_extractor',
|
||
'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor', 'geo_post_extractor'];
|
||
if (mode === 'cookie' && COOKIE_TOOLS.indexOf(tool) !== -1) return ['cookie'];
|
||
return [];
|
||
}
|
||
|
||
function stampCooldown(sources) {
|
||
(sources || []).forEach(function (source) { cooldownUntil[source] = Date.now() + 5000; });
|
||
}
|
||
|
||
// Tools whose root search (not just node-expand) supports cursor pagination
|
||
// — everything except article_extractor, which only ever returns one item.
|
||
var ROOT_PAGINATED_TOOLS = ['tweet_search_extractor', 'follower_explorer', 'following_explorer', 'post_extractor',
|
||
'community_post_extractor', 'tweet_replies_extractor', 'tweet_retweeters_extractor',
|
||
'geo_post_extractor', 'wayback_archive_search', 'multi_source_search'];
|
||
|
||
// ── Label builder ─────────────────────────────────────────────────────────────
|
||
// Tapping "Reply" on X auto-prefixes the compose box with every account the
|
||
// reply-chain already has tagged, and that prefix is genuinely part of the
|
||
// reply's own raw text — but x.com's own UI never shows it inline, trimming
|
||
// it into a separate "Replying to @x @y" line instead. `reply_to_mentions`
|
||
// (from cookie_client.py's _leading_reply_mentions) lists exactly which
|
||
// leading @mentions those are; this only touches how text RENDERS — the
|
||
// node's own raw data (and what Dump JSON / Archive All send) is untouched.
|
||
function stripLeadingMentions(text, mentions) {
|
||
if (!text || !mentions || !mentions.length) return text;
|
||
var rest = text;
|
||
for (var i = 0; i < mentions.length; i++) {
|
||
var re = new RegExp('^\\s*@' + String(mentions[i]).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
|
||
var match = rest.match(re);
|
||
if (!match) break;
|
||
rest = rest.slice(match[0].length);
|
||
}
|
||
rest = rest.replace(/^\s+/, '');
|
||
return rest || text;
|
||
}
|
||
|
||
function makeLabel(item, type) {
|
||
if (type === 'user' || type === 'retweeter') {
|
||
var handle = item.screen_name || item.user || '';
|
||
// Cytoscape node labels are plain canvas text, no HTML/color available
|
||
// the way verifiedBadgeHtml() gets in the info panel — a plain ✓ prefix
|
||
// is the same signal without needing any of that.
|
||
var badge = (item.verified || item.is_blue_verified) ? '✓ ' : '';
|
||
return handle ? badge + '@' + handle : 'Unknown';
|
||
}
|
||
if (type === 'wayback') {
|
||
var snap = (item.post_title || item.post_text || item.original || 'Snapshot').replace(/\s+/g, ' ').trim();
|
||
return snap.length > 60 ? snap.substring(0, 60) + '…' : snap;
|
||
}
|
||
if (type === 'cse') {
|
||
var web = (item.post_title || item.post_text || item.display_link || 'Web result').replace(/\s+/g, ' ').trim();
|
||
return web.length > 60 ? web.substring(0, 60) + '…' : web;
|
||
}
|
||
var rawText = item.text ? stripLeadingMentions(item.text, item.reply_to_mentions) : item.text;
|
||
var text = (rawText || item.retweeted_text || '').replace(/\s+/g, ' ').trim();
|
||
return text.length > 60 ? text.substring(0, 60) + '…' : (text || ('Tweet ' + (item.id || '?')));
|
||
}
|
||
|
||
// Per-item node type — multi_source_search mixes live tweets with Wayback
|
||
// snapshots and Google CSE web results in one result set, so the type has to
|
||
// be resolved per item rather than fixed for the whole tool/search.
|
||
function resolveNodeType(tool, item) {
|
||
if (tool === 'follower_explorer' || tool === 'following_explorer') return 'user';
|
||
if (tool === 'tweet_retweeters_extractor') return 'retweeter';
|
||
if (tool === 'wayback_archive_search') return 'wayback';
|
||
if (tool === 'multi_source_search') {
|
||
if (item.source === 'Wayback Machine') return 'wayback';
|
||
if (item.source === 'Google CSE') return 'cse';
|
||
return 'tweet';
|
||
}
|
||
return 'tweet';
|
||
}
|
||
|
||
// Only a plain https URL is ever handed to Cytoscape's background-image —
|
||
// it ends up as a CSS url() token internally, so the same reasoning as
|
||
// index.html's isSafeImageUrl applies: reject anything but a clean https
|
||
// URL rather than trying to escape one value for two contexts.
|
||
function isSafeImageUrl(u) {
|
||
return typeof u === 'string' && /^https:\/\/[^\s'"<>()]+$/.test(u);
|
||
}
|
||
|
||
// ── Graph operations ──────────────────────────────────────────────────────────
|
||
|
||
// Retroactive quote-correlation index — quotedTweetId -> [quotingCyId, ...]
|
||
// still waiting for that tweet to show up as its own node. The plain
|
||
// forward check below (does the quoted tweet already exist right now) is
|
||
// order-dependent: fine for a live search, where related tweets usually
|
||
// land in the same page/batch, but archive mode's "Load More" reveals items
|
||
// off a fixed offset into the archive's own array — a quote-tweet and the
|
||
// tweet it quotes can easily end up several reveals apart, in either order.
|
||
// Without this index, revealing literally every item in the archive would
|
||
// still silently miss any pair where the quoted tweet happened to be added
|
||
// AFTER the tweet quoting it — the opposite of "expanding everything gives
|
||
// the same graph a one-shot load would have." This closes that gap: every
|
||
// node addition checks BOTH directions (do I have a target already there to
|
||
// link to; does anything already waiting have ME as its target), so by the
|
||
// time both halves of a pair exist, the edge exists too, regardless of
|
||
// which one arrived first. Reset by clearGraph() along with everything else.
|
||
var pendingQuoteEdges = {};
|
||
|
||
// edgeLabel is optional — omitted for a plain "this matched the search"
|
||
// connection (search root → result, continued-search pagination); every
|
||
// node-expand action (replies/retweets/posts/followers/following/author)
|
||
// passes one, since with 6+ relationship types now a bare arrow stopped
|
||
// being enough to tell what an edge actually means.
|
||
function addNodes(items, nodeTypeOrFn, parentId, edgeLabel) {
|
||
var added = 0;
|
||
items.forEach(function (item, i) {
|
||
var nodeType = typeof nodeTypeOrFn === 'function' ? nodeTypeOrFn(item) : nodeTypeOrFn;
|
||
var tweetId = String(item.id || '');
|
||
var cyId = tweetId ? 'n_' + tweetId : 'n_' + Date.now() + '_' + i;
|
||
|
||
if (cy.getElementById(cyId).length) {
|
||
// The node this would-be edge points to already exists — reached via
|
||
// some OTHER path already (a different search, a different expand, a
|
||
// reply's author who also turned up as a follower, ...). That's a
|
||
// genuine correlation between two otherwise-separate parts of the
|
||
// graph, worth calling out on its own rather than blending in with
|
||
// every other edge once the graph gets big.
|
||
if (parentId) addEdge(parentId, cyId, edgeLabel, true);
|
||
return;
|
||
}
|
||
|
||
var avatarUrl = item.avatar || item.user_avatar || '';
|
||
if (!isSafeImageUrl(avatarUrl)) avatarUrl = '';
|
||
|
||
cy.add({
|
||
data: {
|
||
id: cyId,
|
||
type: nodeType,
|
||
label: makeLabel(item, nodeType),
|
||
tweetId: tweetId,
|
||
raw: item,
|
||
// Precomputed once here rather than inside applyGraphFilter() —
|
||
// that function used to call flatText(data.raw) fresh on every
|
||
// single filter pass, for every node, meaning a big graph paid the
|
||
// full recursive-stringify cost of its raw record on every
|
||
// keystroke. Computing it once at add-time makes each filter pass
|
||
// a plain substring check instead.
|
||
searchText: flatText(item).toLowerCase(),
|
||
hasReplies: (nodeType === 'tweet' || nodeType === 'reply') && Number(item.reply_count) > 0,
|
||
hasAvatar: (nodeType === 'user' || nodeType === 'retweeter') && !!avatarUrl,
|
||
avatarUrl: avatarUrl,
|
||
}
|
||
});
|
||
|
||
// Quote-tweet correlation — the ORIGINAL tweet being quoted isn't
|
||
// separately fetched the way a reply's parent is (there's no "expand
|
||
// quotes" action, X doesn't expose that as a list the way replies/
|
||
// retweets are), so this can only draw an edge once that original tweet
|
||
// is itself a node — via some other path (it also matched the same
|
||
// search, came from a different tool run, ...) or, in archive mode, a
|
||
// later "Load More" reveal. Forward: link now if the quoted tweet is
|
||
// already here. Otherwise queue it in pendingQuoteEdges so whichever
|
||
// node gets added later (this one or the quoted one) still closes the
|
||
// link — see the reverse check right after, which runs for every node
|
||
// added, not just quote-tweets, since any node can be the thing some
|
||
// earlier quote-tweet was waiting on.
|
||
if (item.quoted_tweet_id) {
|
||
var quotedNodeId = 'n_' + item.quoted_tweet_id;
|
||
if (cy.getElementById(quotedNodeId).length) {
|
||
addEdge(cyId, quotedNodeId, 'quoted', true);
|
||
} else {
|
||
(pendingQuoteEdges[item.quoted_tweet_id] = pendingQuoteEdges[item.quoted_tweet_id] || []).push(cyId);
|
||
}
|
||
}
|
||
if (tweetId && pendingQuoteEdges[tweetId]) {
|
||
pendingQuoteEdges[tweetId].forEach(function (quotingCyId) {
|
||
addEdge(quotingCyId, cyId, 'quoted', true);
|
||
});
|
||
delete pendingQuoteEdges[tweetId];
|
||
}
|
||
|
||
if (parentId) addEdge(parentId, cyId, edgeLabel, false);
|
||
allItems.push({ type: nodeType, item: item });
|
||
added++;
|
||
});
|
||
return added;
|
||
}
|
||
|
||
// A single live search/expand response CAN be large (a follower/following
|
||
// page, "count" set high in the toolbar, a busy retweeters list) — running
|
||
// addNodes() over hundreds of items is itself fast, but doing it as one
|
||
// long synchronous pass still means the tab can't paint or respond until
|
||
// every last one is in, which reads as a freeze on a big response exactly
|
||
// like an unbatched archive load used to (see ARCHIVE_GRAPH_REVEAL_BATCH).
|
||
// This chunks the exact same addNodes() call across yields (setTimeout 0)
|
||
// instead — same node/edge results, same resolveNodeType()/correlation
|
||
// logic, nothing behaves differently, just spread out so the UI stays
|
||
// responsive. A small response (the common case) finishes in one chunk
|
||
// and behaves identically to calling addNodes() directly.
|
||
const LIVE_NODE_ADD_CHUNK = 100;
|
||
|
||
function addNodesChunked(items, nodeTypeOrFn, parentId, edgeLabel) {
|
||
return new Promise(function (resolve) {
|
||
var i = 0, added = 0;
|
||
(function step() {
|
||
added += addNodes(items.slice(i, i + LIVE_NODE_ADD_CHUNK), nodeTypeOrFn, parentId, edgeLabel);
|
||
i += LIVE_NODE_ADD_CHUNK;
|
||
if (i < items.length) setTimeout(step, 0);
|
||
else resolve(added);
|
||
})();
|
||
});
|
||
}
|
||
|
||
// A node pair can only ever have one edge between them (id is derived purely
|
||
// from src+tgt) — if a second expand action connects the same two nodes with
|
||
// a different label (e.g. someone who's both a follower and a retweeter),
|
||
// the first label/correlation state that got there wins rather than being
|
||
// silently replaced.
|
||
function addEdge(src, tgt, label, isCorrelation) {
|
||
var eId = 'e_' + src + '_' + tgt;
|
||
if (!cy.getElementById(eId).length) {
|
||
cy.add({ data: { id: eId, source: src, target: tgt, label: label || '', correlation: !!isCorrelation } });
|
||
}
|
||
}
|
||
|
||
// Force-directed (cose) layout cost scales roughly with node/edge count —
|
||
// fine at the graph sizes a typical search produces, but re-running a full
|
||
// 1000-iteration ANIMATED pass (every frame re-rendering the WHOLE canvas)
|
||
// on every single node addition gets progressively slower once a graph has
|
||
// already grown into the hundreds/thousands, exactly the point at which
|
||
// staying responsive matters most. Past LAYOUT_BIG_THRESHOLD this falls
|
||
// back to a faster, non-animated pass with fewer iterations instead — still
|
||
// a real re-layout, just not one paying full animation cost on a big canvas.
|
||
const LAYOUT_BIG_THRESHOLD = 300;
|
||
|
||
function runLayout(fit, randomize) {
|
||
var big = cy.nodes().length > LAYOUT_BIG_THRESHOLD;
|
||
cy.layout({
|
||
name: 'cose',
|
||
animate: !big,
|
||
animationDuration: 450,
|
||
randomize: !!randomize,
|
||
fit: !!fit,
|
||
nodeRepulsion: 10000,
|
||
idealEdgeLength: 130,
|
||
gravity: 0.35,
|
||
numIter: big ? 300 : 1000,
|
||
padding: 40,
|
||
}).run();
|
||
}
|
||
|
||
// ── Search ────────────────────────────────────────────────────────────────────
|
||
async function runSearch() {
|
||
var tool = document.getElementById('toolSelect').value;
|
||
var query = document.getElementById('queryInput').value.trim();
|
||
var count = Math.max(1, Math.min(2000, parseInt(document.getElementById('countInput').value, 10) || 20));
|
||
var mode = document.getElementById('modeSelect').value;
|
||
|
||
if (!query) { setStatus('Enter a query first', true); return; }
|
||
|
||
document.getElementById('btnSearch').disabled = true;
|
||
setStatus('Searching…', false, true);
|
||
|
||
try {
|
||
var result = await apiFetch(buildBody(tool, query, count, mode));
|
||
var items = Array.isArray(result.items) ? result.items : [result.items];
|
||
stampCooldown(throttleSourceFor(tool, mode));
|
||
|
||
// Central search node — carries everything expandSearch() needs to fetch
|
||
// the next page later (tool/query/mode/count fixed at the time of this
|
||
// search, cursor updated after every page).
|
||
var searchId = 'search_' + Date.now();
|
||
var shortQ = query.length > 40 ? query.substring(0, 40) + '…' : query;
|
||
cy.add({
|
||
data: {
|
||
id: searchId, type: 'search', label: shortQ, tweetId: '', raw: { query: query },
|
||
tool: tool, query: query, mode: mode, count: count,
|
||
searchCursor: result.nextCursor || null,
|
||
searchExhausted: !result.nextCursor,
|
||
}
|
||
});
|
||
|
||
var added = await addNodesChunked(items, function (item) { return resolveNodeType(tool, item); }, searchId);
|
||
runLayout(true, true);
|
||
updateNodeCount();
|
||
setStatus('Done — ' + items.length + ' result(s) (' + added + ' new nodes)' + sourceErrorNote(result.sourceErrors), false, !!result.sourceErrors);
|
||
} catch (e) {
|
||
setStatus('Error: ' + e.message, true);
|
||
} finally {
|
||
document.getElementById('btnSearch').disabled = false;
|
||
}
|
||
}
|
||
|
||
// ── Expand a node further (replies/retweets/posts/followers/following) ────────
|
||
// First click on a node = fresh fetch. If more is available, the button
|
||
// relabels itself and a second click continues from the stored cursor —
|
||
// same cursor/cooldown mechanism as the non-graph page's scroll load-more.
|
||
// targetKind picks which id the API call needs: a tweet node sends its own
|
||
// tweetId as targetTweetId; a user/retweeter node sends the account's own
|
||
// id (preferred) or handle as targetUsername.
|
||
var EXPAND_TOOLS = {
|
||
tweet_replies_extractor: { targetKind: 'tweet', nodeType: 'reply', button: 'btnExpandReplies', cursorKey: 'repliesCursor', exhaustedKey: 'repliesExhausted', label: 'Replies', edgeLabel: 'replied by', icon: '↩ ', countField: 'reply_count' },
|
||
tweet_retweeters_extractor: { targetKind: 'tweet', nodeType: 'retweeter', button: 'btnExpandRetweets', cursorKey: 'retweetersCursor', exhaustedKey: 'retweetersExhausted', label: 'Retweets', edgeLabel: 'retweeted by', icon: '↗ ', countField: 'retweet_count' },
|
||
post_extractor: { targetKind: 'user', nodeType: 'tweet', button: 'btnExpandPosts', cursorKey: 'postsCursor', exhaustedKey: 'postsExhausted', label: 'Posts', edgeLabel: 'posted', icon: '', countField: 'tweet_count' },
|
||
follower_explorer: { targetKind: 'user', nodeType: 'user', button: 'btnExpandFollowers', cursorKey: 'followersCursor', exhaustedKey: 'followersExhausted', label: 'Followers', edgeLabel: 'followed by', icon: '', countField: 'followers_count' },
|
||
following_explorer: { targetKind: 'user', nodeType: 'user', button: 'btnExpandFollowing', cursorKey: 'followingCursor', exhaustedKey: 'followingExhausted', label: 'Following', edgeLabel: 'follows', icon: '', countField: 'following_count' },
|
||
};
|
||
|
||
// Shared by every Expand button in the panel — label reflects whether this
|
||
// node's already been expanded and whether more is available; before the
|
||
// first click, falls back to the account/tweet's own self-reported count
|
||
// (reply_count, followers_count, ...) so it's never a blind "Expand".
|
||
function updateExpandButtonLabel(btn, data, cfg, raw) {
|
||
if (data[cfg.exhaustedKey]) {
|
||
btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (all loaded)';
|
||
} else if (data[cfg.cursorKey]) {
|
||
btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (more available)';
|
||
} else {
|
||
var n = Number(raw[cfg.countField]) || 0;
|
||
btn.textContent = n > 0 ? cfg.icon + 'Expand ~' + n + ' ' + cfg.label : cfg.icon + 'Expand ' + cfg.label;
|
||
}
|
||
btn.disabled = !!data[cfg.exhaustedKey];
|
||
}
|
||
|
||
function targetIdFor(cfg, data) {
|
||
if (cfg.targetKind === 'tweet') return data.tweetId || null;
|
||
var raw = data.raw || {};
|
||
// user_id is unambiguous (survives a screen_name change); screen_name/user
|
||
// are the fallback for any record shape that never captured a numeric id.
|
||
return raw.user_id || raw.id || raw.screen_name || raw.user || null;
|
||
}
|
||
|
||
// ── Archive-mode local equivalents of Expand Replies / Expand Retweets ─────
|
||
// A reply's in_reply_to_tweet_id and a retweeter's retweeted_tweet_id both
|
||
// point straight at another tweet's id — the exact same shape of lookup
|
||
// EXPAND_TOOLS above satisfies with a live fetch, except the answer is
|
||
// already sitting in archiveFullItems (this archive's own saved JSON), so
|
||
// no request is needed to find it. Posts/Followers/Following have no such
|
||
// self-contained same-archive relationship to derive this way (a follower
|
||
// list IS its own archive, not a nested field on some other item), so those
|
||
// three stay live-only and hidden in read-only mode — see showPanel().
|
||
var ARCHIVE_EXPAND_KINDS = {
|
||
replies: { matchField: 'in_reply_to_tweet_id', nodeType: 'reply', edgeLabel: 'replied by', button: 'btnExpandReplies', offsetKey: 'archiveRepliesOffset', cursorKey: 'archiveRepliesCursor', exhaustedKey: 'archiveRepliesExhausted', label: 'Replies', icon: '↩ ' },
|
||
retweets: { matchField: 'retweeted_tweet_id', nodeType: 'retweeter', edgeLabel: 'retweeted by', button: 'btnExpandRetweets', offsetKey: 'archiveRetweetsOffset', cursorKey: 'archiveRetweetsCursor', exhaustedKey: 'archiveRetweetsExhausted', label: 'Retweets', icon: '↗ ' },
|
||
};
|
||
|
||
// Linear scan over the archive's full item list — cheap enough for an
|
||
// archive's scale (thousands, not millions, of records) and only run on
|
||
// demand (opening a tweet's panel, clicking Expand), never per node-add.
|
||
function archiveMatches(tweetId, matchField) {
|
||
if (!archiveFullItems || !tweetId) return [];
|
||
return archiveFullItems.filter(function (it) {
|
||
return it && String(it[matchField] || '') === tweetId;
|
||
});
|
||
}
|
||
|
||
// Label mirrors updateExpandButtonLabel()'s states (not-yet-expanded /
|
||
// more available / all loaded) but the "not yet expanded" count is the
|
||
// REAL number of matching records already in the archive, not a
|
||
// self-reported reply_count/retweet_count that a live fetch might not
|
||
// actually be able to back up in full — knowing the true count up front
|
||
// is exactly what a static, already-saved dataset can offer that a live
|
||
// estimate can't, so a genuine zero disables the button outright instead
|
||
// of inviting a click that would find nothing.
|
||
function updateArchiveExpandButtonLabel(btn, data, cfg) {
|
||
if (data[cfg.exhaustedKey]) {
|
||
btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (all loaded)';
|
||
btn.disabled = true;
|
||
return;
|
||
}
|
||
if (data[cfg.cursorKey]) {
|
||
btn.textContent = cfg.icon + 'Expand ' + cfg.label + ' (more available)';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
var n = archiveMatches(data.tweetId, cfg.matchField).length;
|
||
btn.textContent = n > 0
|
||
? cfg.icon + 'Expand ' + n + ' ' + cfg.label + ' (from archive)'
|
||
: cfg.icon + 'No ' + cfg.label + ' saved in archive';
|
||
btn.disabled = n === 0;
|
||
}
|
||
|
||
function expandArchiveNode(kind) {
|
||
if (!selectedNode) return;
|
||
var cfg = ARCHIVE_EXPAND_KINDS[kind];
|
||
var data = selectedNode.data();
|
||
var tweetId = data.tweetId;
|
||
if (!tweetId) return;
|
||
|
||
var matches = archiveMatches(tweetId, cfg.matchField);
|
||
var offset = data[cfg.offsetKey] || 0;
|
||
var batch = matches.slice(offset, offset + ARCHIVE_GRAPH_REVEAL_BATCH);
|
||
|
||
var added = addNodes(batch, cfg.nodeType, selectedNode.id(), cfg.edgeLabel);
|
||
var newOffset = offset + batch.length;
|
||
selectedNode.data(cfg.offsetKey, newOffset);
|
||
selectedNode.data(cfg.cursorKey, newOffset < matches.length ? 'more' : null);
|
||
selectedNode.data(cfg.exhaustedKey, newOffset >= matches.length);
|
||
|
||
runLayout(false, false);
|
||
updateNodeCount();
|
||
setStatus(
|
||
matches.length
|
||
? 'Revealed ' + added + ' ' + cfg.label.toLowerCase() + ' from the archive — ' + newOffset + ' / ' + matches.length
|
||
: 'No ' + cfg.label.toLowerCase() + ' saved in this archive',
|
||
matches.length === 0
|
||
);
|
||
showPanel(selectedNode.data()); // refresh this button's own label/disabled state
|
||
}
|
||
|
||
async function expandNode(expandTool) {
|
||
if (!selectedNode) return;
|
||
var cfg = EXPAND_TOOLS[expandTool];
|
||
var data = selectedNode.data();
|
||
var targetId = targetIdFor(cfg, data);
|
||
if (!targetId) { setStatus('Node has no ' + (cfg.targetKind === 'tweet' ? 'tweet ID' : 'user ID/handle'), true); return; }
|
||
|
||
var sources = throttleSourceFor(expandTool, 'cookie'); // always 'cookie' — every expand tool here is cookie-only
|
||
var btn = document.getElementById(cfg.button);
|
||
var remaining = Math.max(0, ...sources.map(function (s) { return cooldownUntil[s] - Date.now(); }));
|
||
if (remaining > 0) {
|
||
armCooldownButtons(remaining, [btn], 'Ready — click Expand ' + cfg.label + ' again');
|
||
return;
|
||
}
|
||
|
||
var count = Math.max(1, Math.min(2000, parseInt(document.getElementById('countInput').value, 10) || 50));
|
||
var parentId = selectedNode.id();
|
||
var cursor = selectedNode.data(cfg.cursorKey) || null;
|
||
|
||
var body = { toolType: expandTool, mode: 'cookie', count: count };
|
||
if (cfg.targetKind === 'tweet') body.targetTweetId = targetId;
|
||
else body.targetUsername = targetId;
|
||
if (cursor) body.cursor = cursor;
|
||
|
||
btn.disabled = true;
|
||
setStatus(cursor ? 'Loading more…' : 'Expanding…', false, true);
|
||
|
||
try {
|
||
var result = await apiFetch(body);
|
||
stampCooldown(sources);
|
||
var items = Array.isArray(result.items) ? result.items : [result.items];
|
||
|
||
var added = await addNodesChunked(items, cfg.nodeType, parentId, cfg.edgeLabel);
|
||
runLayout(false, false);
|
||
updateNodeCount();
|
||
setStatus('Expanded — ' + items.length + ' result(s) (' + added + ' new nodes)');
|
||
|
||
selectedNode.data(cfg.cursorKey, result.nextCursor || null);
|
||
selectedNode.data(cfg.exhaustedKey, !result.nextCursor);
|
||
showPanel(selectedNode.data()); // refresh button labels/disabled state (e.g. "all loaded")
|
||
} catch (e) {
|
||
if (e.retryAfter) {
|
||
// Keep the button disabled for the cooldown window — armCooldownButtons
|
||
// re-enables it itself once it elapses, so don't touch it here.
|
||
armCooldownButtons(e.retryAfter * 1000, [btn], 'Ready — click Expand ' + cfg.label + ' again');
|
||
} else {
|
||
setStatus('Error: ' + e.message, true);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── View a tweet/reply node's author as its own node ───────────────────────
|
||
// A reply, retweet, or search-result tweet already carries its author's
|
||
// avatar/bio/account-age info (cookie_client.py's _tweet_to_dict embeds it
|
||
// on every tweet) — this never hits the network, it just re-shapes that
|
||
// already-fetched data into a standalone user node with the same field
|
||
// names cookie_client.py's _user_to_dict itself produces, so the node
|
||
// renders identically to a real follower/following result. The synthesized
|
||
// node's id is the author's own user_id, so an author who's already on
|
||
// canvas some other way (e.g. also appears as a follower elsewhere) merges
|
||
// into that same node instead of duplicating it — this is exactly what
|
||
// makes a reply's author "look-up-able": select them afterward and Expand
|
||
// Posts/Followers/Following works on them like any other user node.
|
||
var AUTHOR_REMAP = { user_avatar: 'avatar', user_banner: 'banner', user_bio: 'description' };
|
||
var AUTHOR_PASSTHROUGH = ['name', 'verified', 'is_blue_verified', 'user_location',
|
||
'account_created', 'account_age', 'account_age_flag', 'account_age_precision',
|
||
'source', 'fetched_at'];
|
||
|
||
function authorFromRaw(raw) {
|
||
if (!raw || (!raw.user_id && !raw.user)) return null;
|
||
var out = { id: raw.user_id || null, screen_name: raw.user || null };
|
||
Object.keys(raw).forEach(function (k) {
|
||
if (AUTHOR_REMAP[k] && raw[k] != null) out[AUTHOR_REMAP[k]] = raw[k];
|
||
});
|
||
AUTHOR_PASSTHROUGH.forEach(function (k) {
|
||
if (raw[k] != null) out[k] = raw[k];
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function viewAuthor() {
|
||
if (!selectedNode) return;
|
||
var raw = selectedNode.data('raw') || {};
|
||
var author = authorFromRaw(raw);
|
||
if (!author) { setStatus('No author info on this node', true); return; }
|
||
|
||
var added = addNodes([author], 'user', selectedNode.id(), 'authored by');
|
||
runLayout(false, false);
|
||
updateNodeCount();
|
||
setStatus(added ? 'Author profile added — select it to Expand Posts/Followers/Following'
|
||
: 'Author already on canvas — linked');
|
||
}
|
||
|
||
// ── Load more results for the root search node ─────────────────────────────
|
||
// Mirrors expandNode() above — same click+cursor+cooldown mechanism, applied
|
||
// to the search node itself instead of a tweet node, so any tool (not just
|
||
// replies/retweets) can page past its first result set.
|
||
async function expandSearch() {
|
||
if (!selectedNode) return;
|
||
var data = selectedNode.data();
|
||
if (data.type !== 'search' || !data.tool) return;
|
||
|
||
var sources = throttleSourceFor(data.tool, data.mode);
|
||
var btn = document.getElementById('btnLoadMoreSearch');
|
||
var remaining = Math.max(0, ...sources.map(function (s) { return cooldownUntil[s] - Date.now(); }));
|
||
if (remaining > 0) {
|
||
armCooldownButtons(remaining, [btn], 'Ready — click Load More again');
|
||
return;
|
||
}
|
||
|
||
var cursor = selectedNode.data('searchCursor') || null;
|
||
btn.disabled = true;
|
||
setStatus('Loading more…', false, true);
|
||
|
||
try {
|
||
var body = buildBody(data.tool, data.query, data.count, data.mode);
|
||
if (cursor) body.cursor = cursor;
|
||
var result = await apiFetch(body);
|
||
stampCooldown(sources);
|
||
var items = Array.isArray(result.items) ? result.items : [result.items];
|
||
|
||
var added = await addNodesChunked(items, function (item) { return resolveNodeType(data.tool, item); }, selectedNode.id());
|
||
runLayout(false, false);
|
||
updateNodeCount();
|
||
setStatus('Loaded more — ' + items.length + ' result(s) (' + added + ' new nodes)' + sourceErrorNote(result.sourceErrors), false, !!result.sourceErrors);
|
||
|
||
selectedNode.data('searchCursor', result.nextCursor || null);
|
||
selectedNode.data('searchExhausted', !result.nextCursor);
|
||
showPanel(selectedNode.data());
|
||
} catch (e) {
|
||
if (e.retryAfter) {
|
||
armCooldownButtons(e.retryAfter * 1000, [btn], 'Ready — click Load More again');
|
||
} else {
|
||
setStatus('Error: ' + e.message, true);
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function armCooldownButtons(msRemaining, buttons, readyMessage) {
|
||
var secs = Math.max(1, Math.ceil(msRemaining / 1000));
|
||
setStatus('Rate-limit cooldown — retry in ' + secs + 's…', false, true);
|
||
buttons.forEach(function (b) { b.disabled = true; });
|
||
if (cooldownTimer) clearTimeout(cooldownTimer);
|
||
cooldownTimer = setTimeout(function () {
|
||
cooldownTimer = null;
|
||
buttons.forEach(function (b) { b.disabled = false; });
|
||
setStatus(readyMessage, false, false);
|
||
}, msRemaining);
|
||
}
|
||
|
||
// ── Info panel ────────────────────────────────────────────────────────────────
|
||
function esc(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// LIST ARR PARAMS
|
||
|
||
var PRIORITY_KEYS = [
|
||
'source', 'content_type', 'account_age_flag', 'account_age', 'account_created',
|
||
'user', 'screen_name', 'name', 'text', 'post_title', 'post_text', 'created_at', 'fetched_at',
|
||
'retweeted_by_user', 'retweeted_by_name', 'retweeted_text', 'retweeted_by_bio',
|
||
'quoted_user', 'quoted_name', 'quoted_text', 'quoted_at', 'quoted_tweet_id',
|
||
'reply_count', 'retweet_count', 'favorite_count', 'view_count',
|
||
'followers_count', 'following_count', 'tweet_count',
|
||
'description', 'user_location', 'in_reply_to_tweet_id',
|
||
'retweeted_tweet_id', 'retweeted_at', 'verified', 'is_blue_verified',
|
||
'archive_url', 'result_url', 'preview_image', 'display_link', 'serp_title',
|
||
'iso_date', 'original', 'statuscode',
|
||
];
|
||
|
||
// Same labels/reasoning as card_constants.js's CONTENT_TYPE_LABELS — kept as
|
||
// a separate copy here since graph.html doesn't load that shared file (its
|
||
// PRIORITY_KEYS/SKIP_KEYS are already their own independent copy, not the
|
||
// index.html/archive.html shared ones).
|
||
var CONTENT_TYPE_LABELS = {
|
||
tweet: 'Tweet',
|
||
profile: 'Profile page',
|
||
twitter_other: 'Other X/Twitter page',
|
||
other: 'External page (non-X)',
|
||
};
|
||
|
||
// avatar/banner URLs are rendered as an actual image in the panel header
|
||
// below instead of a raw-link row.
|
||
var SKIP_KEYS = new Set(['id', 'media', 'card', 'user_id', 'retweeted_by_user_id', 'avatar', 'banner', 'user_avatar', 'user_banner']);
|
||
|
||
var SOURCE_CLASS = { 'Twitter Cookie': 'src-cookie', 'Xquik API': 'src-xquik', 'Wayback Machine': 'src-wayback', 'Google CSE': 'src-cse' };
|
||
|
||
// ── Media helpers — same normalization as the non-graph search page ───────────
|
||
function extractMedia(item) {
|
||
// Cookie mode: item.media = [{type, thumb, url}]
|
||
if (Array.isArray(item.media) && item.media.length) {
|
||
var first = item.media[0];
|
||
if (first && typeof first === 'object' && ('thumb' in first || 'url' in first)) {
|
||
return item.media;
|
||
}
|
||
}
|
||
// API mode: item.extended_entities.media[] or item.entities.media[]
|
||
var src = (item.extended_entities && item.extended_entities.media)
|
||
|| (item.entities && item.entities.media);
|
||
if (!Array.isArray(src)) return [];
|
||
return src.map(function (m) {
|
||
var mtype = m.type || 'photo';
|
||
var thumb = m.media_url_https || m.media_url || '';
|
||
var url = thumb;
|
||
if (mtype === 'video' || mtype === 'animated_gif') {
|
||
var variants = (m.video_info && m.video_info.variants) || [];
|
||
var mp4s = variants.filter(function (v) { return v.content_type === 'video/mp4'; });
|
||
if (mp4s.length) url = mp4s.reduce(function (b, v) { return (v.bitrate || 0) > (b.bitrate || 0) ? v : b; }).url;
|
||
}
|
||
return { type: mtype, thumb: thumb, url: url };
|
||
}).filter(function (m) { return m.thumb; });
|
||
}
|
||
|
||
// Same blue-checkmark treatment card_constants.js's verifiedBadgeHtml()
|
||
// gives index.html/archive.html cards — graph.html keeps its own
|
||
// independent copy of every other rendering constant/helper already (see
|
||
// PRIORITY_KEYS's comment), so this is that page's own copy too rather than
|
||
// loading a second, unrelated script just for one function.
|
||
function verifiedBadgeHtml(raw) {
|
||
if (!raw || (!raw.verified && !raw.is_blue_verified)) return '';
|
||
var title = raw.is_blue_verified ? 'Blue verified (X Premium)' : 'Verified (legacy)';
|
||
return '<span class="verified-badge" title="' + esc(title) + '">✓</span>';
|
||
}
|
||
|
||
// Same header treatment index.html's cards give a user — avatar, name,
|
||
// handle, bio — but for the info panel, which every node type funnels
|
||
// through (search root, tweet, reply, user, retweeter, ...).
|
||
function buildPanelHeader(raw) {
|
||
var avatarRaw = raw.avatar || raw.user_avatar || '';
|
||
var avatar = isSafeImageUrl(avatarRaw) ? avatarRaw : '';
|
||
var name = raw.name || '';
|
||
var handle = raw.screen_name || raw.user || '';
|
||
|
||
if (!avatar && !name && !handle) return '';
|
||
|
||
var avatarHtml = avatar
|
||
? '<img class="panel-avatar" src="' + esc(avatar) + '" alt="" loading="lazy" referrerpolicy="no-referrer">'
|
||
: (name || handle)
|
||
? '<div class="panel-avatar panel-avatar-fallback">' + esc((name || handle).charAt(0).toUpperCase()) + '</div>'
|
||
: '';
|
||
var identityHtml = (name || handle)
|
||
? '<div class="panel-identity">' +
|
||
(name ? '<div class="panel-name">' + esc(name) + verifiedBadgeHtml(raw) + '</div>' : '') +
|
||
(handle ? '<div class="panel-handle">@' + esc(handle) + '</div>' : '') +
|
||
'</div>'
|
||
: '';
|
||
|
||
// A tweet/reply node's own text already shows a few rows down — leading
|
||
// with the author's full bio here would bury it the same way it did on
|
||
// index.html's cards. Bio only makes sense when the node itself IS a
|
||
// user (follower/retweeter), not a tweet someone happened to write.
|
||
var isTweetLike = raw.text !== undefined || raw.full_text !== undefined || raw.article_text !== undefined;
|
||
if (isTweetLike) {
|
||
return '<div class="panel-header panel-byline">' + avatarHtml + identityHtml + '</div>';
|
||
}
|
||
|
||
var bio = raw.description || raw.user_bio || '';
|
||
var bioHtml = bio ? '<div class="panel-bio">' + esc(bio) + '</div>' : '';
|
||
|
||
return '<div class="panel-header">' + avatarHtml + identityHtml + '</div>' + bioHtml;
|
||
}
|
||
|
||
function showPanel(data) {
|
||
var raw = data.raw || {};
|
||
var type = data.type;
|
||
|
||
var titles = { search: 'Search Node', tweet: 'Tweet', reply: 'Reply', user: 'User', retweeter: 'Retweeter', wayback: 'Wayback Snapshot', cse: 'Web Result' };
|
||
document.getElementById('infoTitle').textContent = titles[type] || 'Node';
|
||
|
||
// Build key list (priority first, then remaining)
|
||
var allKeys = Object.keys(raw);
|
||
var ordered = PRIORITY_KEYS.concat(allKeys.filter(function (k) { return PRIORITY_KEYS.indexOf(k) === -1; }));
|
||
var keys = ordered.filter(function (k) {
|
||
return !SKIP_KEYS.has(k) && raw[k] !== null && raw[k] !== undefined && raw[k] !== '';
|
||
});
|
||
|
||
var rows = keys.map(function (k) {
|
||
var v = raw[k];
|
||
var val = esc(String(v));
|
||
|
||
// Format tweet ID fields as links
|
||
if ((k === 'retweeted_tweet_id' || k === 'in_reply_to_tweet_id' || k === 'quoted_tweet_id') && /^\d+$/.test(String(v))) {
|
||
var handle = k === 'quoted_tweet_id' ? (raw.quoted_user || '') : (raw.retweeted_by_user || raw.user || '');
|
||
if (handle) {
|
||
val = '<a href="https://x.com/' + encodeURIComponent(handle) + '/status/' + encodeURIComponent(String(v)) +
|
||
'" target="_blank" rel="noopener noreferrer">' + esc(String(v)) + ' ↗</a>';
|
||
}
|
||
} else if (k === 'tweet_url' || k === 'archive_url' || k === 'result_url' || k === 'preview_image') {
|
||
// Full raw URL as the link text itself, not hidden behind a generic
|
||
// label — same treatment as the live search page and archive.html.
|
||
val = '<a href="' + esc(String(v)) + '" target="_blank" rel="noopener noreferrer">' + esc(String(v)) + '</a>';
|
||
} else if (k === 'source') {
|
||
val = '<span class="source-badge ' + (SOURCE_CLASS[v] || '') + '">' + esc(String(v)) + '</span>';
|
||
} else if (k === 'account_age_flag') {
|
||
var ageLabels = { new: 'New account', recent: 'Recent account', established: 'Established account' };
|
||
val = '<span class="age-badge age-' + esc(String(v)) + '">' + esc(ageLabels[v] || String(v)) + '</span>';
|
||
} else if (k === 'content_type') {
|
||
val = '<span class="content-type-badge ct-' + esc(String(v)) + '">' + esc(CONTENT_TYPE_LABELS[v] || String(v)) + '</span>';
|
||
} else if (k === 'text' && raw.reply_to_mentions && raw.reply_to_mentions.length) {
|
||
var mentionBadge = '<div class="reply-context">↩ Replying to ' +
|
||
raw.reply_to_mentions.map(function (m) { return '@' + esc(m); }).join(', ') + '</div>';
|
||
val = mentionBadge + esc(stripLeadingMentions(String(v), raw.reply_to_mentions));
|
||
}
|
||
|
||
var label = esc(k.replace(/_/g, ' '));
|
||
return '<div class="info-row"><div class="info-key">' + label + '</div><div class="info-val">' + val + '</div></div>';
|
||
}).join('');
|
||
|
||
// Media thumbnails — normalized cookie/API shape; videos play inline via
|
||
// the backend proxy instead of just linking out to the raw stream.
|
||
var media = extractMedia(raw);
|
||
if (media.length) {
|
||
var mediaParts = ['<div class="info-media">'];
|
||
media.forEach(function (m) {
|
||
if (!m.thumb) return;
|
||
if (m.type === 'video' || m.type === 'animated_gif') {
|
||
var proxied = '/api/video?url=' + encodeURIComponent(m.url);
|
||
var loop = m.type === 'animated_gif' ? 'loop muted' : '';
|
||
mediaParts.push(
|
||
'<video class="info-video" controls ' + loop + ' poster="' + esc(m.thumb) + '" preload="none">' +
|
||
'<source src="' + proxied + '" type="video/mp4"></video>'
|
||
);
|
||
} else {
|
||
mediaParts.push(
|
||
'<a href="' + esc(m.url) + '" target="_blank" rel="noopener noreferrer">' +
|
||
'<img src="' + esc(m.thumb) + '" loading="lazy" alt="media"></a>'
|
||
);
|
||
}
|
||
});
|
||
mediaParts.push('</div>');
|
||
rows += mediaParts.join('');
|
||
}
|
||
|
||
document.getElementById('infoContent').innerHTML = buildPanelHeader(raw) + rows;
|
||
|
||
// Action buttons
|
||
var isTweet = type === 'tweet' || type === 'reply';
|
||
var isUserNode = type === 'user' || type === 'retweeter';
|
||
var isWayback = type === 'wayback';
|
||
var tweetId = data.tweetId;
|
||
var user = raw.user || raw.screen_name || '';
|
||
|
||
var repliesBtn = document.getElementById('btnExpandReplies');
|
||
var retweetersBtn = document.getElementById('btnExpandRetweets');
|
||
var authorBtn = document.getElementById('btnViewAuthor');
|
||
var postsBtn = document.getElementById('btnExpandPosts');
|
||
var followersBtn = document.getElementById('btnExpandFollowers');
|
||
var followingBtn = document.getElementById('btnExpandFollowing');
|
||
var loadMoreBtn = document.getElementById('btnLoadMoreSearch');
|
||
|
||
// Expand Posts/Followers/Following and Load More Results all fetch fresh
|
||
// live data via /api/run — those stay unavailable in archive read-only
|
||
// mode, along with View Author Profile (still shown regardless, see
|
||
// below — it's synthesized from data already on the node, no request)
|
||
// and Open Tweet (an outbound link, not a fetch). Expand Replies/Retweets
|
||
// are the exception: they have a local, network-free equivalent in
|
||
// archive mode (see ARCHIVE_EXPAND_KINDS/expandArchiveNode above), so
|
||
// they stay visible in both modes — only which handler/label logic runs
|
||
// differs, wired in init() and just below.
|
||
repliesBtn.style.display = isTweet ? '' : 'none';
|
||
retweetersBtn.style.display = isTweet ? '' : 'none';
|
||
// A synthesized "view author" pivot only makes sense when there's
|
||
// somewhere to pivot TO — a tweet/reply always embeds its author's
|
||
// user_id/screen_name (cookie_client.py's _tweet_to_dict), but a bare
|
||
// xquik/API-mode record might not have captured either.
|
||
authorBtn.style.display = (isTweet && (raw.user_id || raw.user)) ? '' : 'none';
|
||
|
||
postsBtn.style.display = (isUserNode && !READONLY_ARCHIVE_MODE) ? '' : 'none';
|
||
followersBtn.style.display = (isUserNode && !READONLY_ARCHIVE_MODE) ? '' : 'none';
|
||
followingBtn.style.display = (isUserNode && !READONLY_ARCHIVE_MODE) ? '' : 'none';
|
||
|
||
// Archive mode's own root node (data.archiveItems present) always counts
|
||
// as paginable — it's revealing more of an in-memory array, not a live
|
||
// API page, so it's exempt from the READONLY_ARCHIVE_MODE block that
|
||
// otherwise hides every fetch-driven button in this panel.
|
||
var isPaginableSearch = type === 'search' && (data.archiveItems
|
||
? true
|
||
: (!READONLY_ARCHIVE_MODE && ROOT_PAGINATED_TOOLS.indexOf(data.tool) !== -1));
|
||
loadMoreBtn.style.display = isPaginableSearch ? '' : 'none';
|
||
if (isPaginableSearch) {
|
||
loadMoreBtn.textContent = data.searchExhausted ? '⤓ Load More Results (all loaded)'
|
||
: data.searchCursor ? '⤓ Load More Results (more available)' : '⤓ Load More Results';
|
||
loadMoreBtn.disabled = !!data.searchExhausted;
|
||
}
|
||
|
||
if (isTweet) {
|
||
if (READONLY_ARCHIVE_MODE) {
|
||
updateArchiveExpandButtonLabel(repliesBtn, data, ARCHIVE_EXPAND_KINDS.replies);
|
||
updateArchiveExpandButtonLabel(retweetersBtn, data, ARCHIVE_EXPAND_KINDS.retweets);
|
||
} else {
|
||
updateExpandButtonLabel(repliesBtn, data, EXPAND_TOOLS.tweet_replies_extractor, raw);
|
||
updateExpandButtonLabel(retweetersBtn, data, EXPAND_TOOLS.tweet_retweeters_extractor, raw);
|
||
}
|
||
}
|
||
if (isUserNode) {
|
||
updateExpandButtonLabel(postsBtn, data, EXPAND_TOOLS.post_extractor, raw);
|
||
updateExpandButtonLabel(followersBtn, data, EXPAND_TOOLS.follower_explorer, raw);
|
||
updateExpandButtonLabel(followingBtn, data, EXPAND_TOOLS.following_explorer, raw);
|
||
}
|
||
|
||
var openBtn = document.getElementById('btnOpenTweet');
|
||
if (isTweet && tweetId && user) {
|
||
openBtn.href = 'https://x.com/' + encodeURIComponent(user) + '/status/' + encodeURIComponent(tweetId);
|
||
openBtn.textContent = 'Open Tweet ↗';
|
||
openBtn.style.display = '';
|
||
} else if (isWayback && raw.archive_url) {
|
||
openBtn.href = raw.archive_url;
|
||
openBtn.textContent = 'Open Archived Snapshot ↗';
|
||
openBtn.style.display = '';
|
||
} else if (type === 'cse' && raw.result_url) {
|
||
openBtn.href = raw.result_url;
|
||
openBtn.textContent = 'Open Page ↗';
|
||
openBtn.style.display = '';
|
||
} else {
|
||
openBtn.style.display = 'none';
|
||
}
|
||
|
||
// Deletable regardless of node type or mode — removing a node from the
|
||
// canvas is purely a local graph edit, never a network call, so it's
|
||
// available in both live and archive read-only mode. The one exception is
|
||
// the search root itself (see deleteNode()'s own guard for why) — hidden
|
||
// here too so the panel doesn't offer an action that's just going to be
|
||
// refused.
|
||
document.getElementById('btnDeleteNode').style.display = (type === 'search') ? 'none' : '';
|
||
|
||
document.getElementById('infoPanel').classList.remove('hidden');
|
||
}
|
||
|
||
function hidePanel() {
|
||
document.getElementById('infoPanel').classList.add('hidden');
|
||
selectedNode = null;
|
||
cy.elements().unselect();
|
||
}
|
||
|
||
// ── Delete node(s) ───────────────────────────────────────────────────────────
|
||
// A single node removal purges anything that referenced it by cyId so later
|
||
// code never trips over a dangling reference:
|
||
// - pendingQuoteEdges (see addNodes()) can hold this node's id as a
|
||
// quote-tweet still waiting on its target — if that target never
|
||
// arrives now, the entry would sit there forever pointing at nothing,
|
||
// and if it DID arrive later, addEdge() would try to link to a node
|
||
// that's no longer in the graph.
|
||
// - allItems (Dump JSON's own source list) is kept in sync so an export
|
||
// right after deleting doesn't still include what's no longer on screen.
|
||
// node.remove() itself already takes care of any edges touching the node —
|
||
// Cytoscape never leaves a dangling edge behind on its own.
|
||
//
|
||
// The search root (type: 'search', the diamond) is refused outright — it's
|
||
// not just another result, it's the entry point that carries the state
|
||
// needed to keep exploring from it: searchCursor/searchExhausted for a live
|
||
// paginated search's own "Load More Results", and archiveItems/archiveOffset
|
||
// for archive mode's local reveal (see revealArchiveBatch()). Deleting it
|
||
// would silently strand every node still hanging off it and make "load the
|
||
// rest of this archive" impossible — there'd be nothing left to click
|
||
// Load More on. Returns whether the node was actually removed, so a
|
||
// multi-select delete can tell how many it had to skip.
|
||
function deleteNode(node) {
|
||
if (node.data('type') === 'search') return false;
|
||
|
||
var cyId = node.id();
|
||
|
||
Object.keys(pendingQuoteEdges).forEach(function (key) {
|
||
var idx = pendingQuoteEdges[key].indexOf(cyId);
|
||
if (idx !== -1) pendingQuoteEdges[key].splice(idx, 1);
|
||
if (!pendingQuoteEdges[key].length) delete pendingQuoteEdges[key];
|
||
});
|
||
|
||
var raw = node.data('raw');
|
||
if (raw) {
|
||
var itemIdx = allItems.findIndex(function (e) { return e.item === raw; });
|
||
if (itemIdx !== -1) allItems.splice(itemIdx, 1);
|
||
}
|
||
|
||
node.remove();
|
||
return true;
|
||
}
|
||
|
||
// Deletes whatever's currently selected — one node via the panel's Delete
|
||
// button (selectedNode), or a whole box-selected group via the Delete/
|
||
// Backspace key (see the keydown listener in init()). Either way the same
|
||
// deleteNode() cleanup (and its search-root guard) runs per node.
|
||
function deleteSelectedNodes() {
|
||
var selected = cy.nodes(':selected');
|
||
if (!selected.length && selectedNode) selected = selectedNode;
|
||
if (!selected.length) return;
|
||
|
||
var deleted = 0, skipped = 0;
|
||
selected.forEach(function (node) {
|
||
if (deleteNode(node)) deleted++; else skipped++;
|
||
});
|
||
|
||
hidePanel();
|
||
updateNodeCount();
|
||
|
||
var msg = deleted ? deleted + ' node' + (deleted === 1 ? '' : 's') + ' deleted' : '';
|
||
if (skipped) msg += (msg ? ' — ' : '') + skipped + ' search root node' + (skipped === 1 ? '' : 's') + ' can\'t be deleted';
|
||
setStatus(msg || 'Nothing to delete', !deleted && !!skipped);
|
||
}
|
||
|
||
// ── Archive All ───────────────────────────────────────────────────────────────
|
||
// First click creates a new archive; once that succeeds, graphArchivedId is
|
||
// set and subsequent clicks (e.g. after expanding more reply/retweet nodes)
|
||
// UPDATE that same archive in place instead of creating a new one each time
|
||
// — same checkpoint behavior as the non-graph search page.
|
||
var graphArchivedId = null;
|
||
|
||
async function archiveAll() {
|
||
// Same reasoning as apiFetch()'s guard: btnArchiveAll is hidden in
|
||
// read-only mode, but that alone doesn't stop archiveAll() from being
|
||
// called directly. This view already IS an already-saved archive — there
|
||
// is nothing here that should ever be written back.
|
||
if (READONLY_ARCHIVE_MODE) { setStatus('Read-only archive view — this graph already IS a saved archive, nothing to archive again', true); return; }
|
||
if (!allItems.length) { setStatus('Nothing to archive yet', true); return; }
|
||
var query = document.getElementById('queryInput').value.trim();
|
||
var tool = document.getElementById('toolSelect').value;
|
||
|
||
var archiveBar = document.getElementById('archiveBar');
|
||
archiveBar.style.display = '';
|
||
var label = graphArchivedId ? 'Saving checkpoint…' : 'Archiving…';
|
||
archiveBar.innerHTML = '<span>' + label + '</span><div class="archive-bar-fill-wrap"><div class="archive-bar-fill" style="width:0%"></div></div>';
|
||
|
||
try {
|
||
var body = {
|
||
toolType: 'graph_' + tool,
|
||
data: allItems.map(function (d) { return d.item; }),
|
||
queryInfo: { query: query, source: 'graph', nodes: cy.nodes().length },
|
||
};
|
||
if (graphArchivedId) body.archiveId = graphArchivedId;
|
||
|
||
var res = await fetch('/api/archive', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
var json = await res.json();
|
||
if (!json.ok) throw new Error(json.error);
|
||
graphArchivedId = json.archiveId;
|
||
document.getElementById('btnArchiveAll').textContent = 'Update Archive';
|
||
autoCheckpoint(); // persist graphArchivedId into the case so progress survives a page loss
|
||
pollGraphArchive(json.archiveId);
|
||
} catch (e) {
|
||
archiveBar.innerHTML = '<span style="color:var(--danger)">Archive error: ' + esc(e.message) + '</span>';
|
||
}
|
||
}
|
||
|
||
function pollGraphArchive(archiveId) {
|
||
var archiveBar = document.getElementById('archiveBar');
|
||
var interval = setInterval(async function () {
|
||
var res = await fetch('/api/archive/' + archiveId + '/status');
|
||
var json = await res.json();
|
||
if (!json.ok) { clearInterval(interval); return; }
|
||
|
||
var status = json.status, progress = json.progress, total = json.total;
|
||
var fill = archiveBar.querySelector('.archive-bar-fill');
|
||
|
||
if (status === 'saving') {
|
||
archiveBar.querySelector('span').textContent = 'Saving results…';
|
||
} else if (status === 'downloading') {
|
||
var pct = total > 0 ? Math.round((progress / total) * 100) : 0;
|
||
archiveBar.querySelector('span').textContent = 'Downloading media ' + progress + '/' + total;
|
||
if (fill) fill.style.width = pct + '%';
|
||
} else if (status === 'done') {
|
||
clearInterval(interval);
|
||
if (fill) fill.style.width = '100%';
|
||
archiveBar.querySelector('span').textContent = 'Archived ✓ (' + total + ' media files saved) — ID: ' + archiveId;
|
||
}
|
||
}, 1200);
|
||
}
|
||
|
||
// ── Dump JSON ─────────────────────────────────────────────────────────────────
|
||
function dumpJSON() {
|
||
if (!allItems.length) { setStatus('Nothing to export yet', true); return; }
|
||
var payload = {
|
||
exported_at: new Date().toISOString(),
|
||
query: document.getElementById('queryInput').value.trim(),
|
||
tool: document.getElementById('toolSelect').value,
|
||
node_count: cy.nodes().length,
|
||
items: allItems,
|
||
};
|
||
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'graph_export_' + Date.now() + '.json';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
setStatus('Exported ' + allItems.length + ' item(s)');
|
||
autoCheckpoint(); // checkpoint so archiveId is preserved if the page is lost after export
|
||
}
|
||
|
||
// ── Cases (save / resume investigation sessions) ─────────────────────────────
|
||
|
||
var currentCaseId = null;
|
||
var currentCaseName = '';
|
||
|
||
function updateCaseIndicator() {
|
||
var ind = document.getElementById('caseIndicator');
|
||
var name = document.getElementById('caseIndicatorName');
|
||
var btn = document.getElementById('caseCheckpointBtn');
|
||
if (currentCaseId) {
|
||
name.textContent = currentCaseName;
|
||
ind.classList.add('visible');
|
||
if (btn && btn.textContent !== 'Saving…' && btn.textContent !== 'Saved!' && btn.textContent !== 'Failed') btn.textContent = 'Checkpoint';
|
||
} else {
|
||
ind.classList.remove('visible');
|
||
if (btn && btn.textContent !== 'Saving…' && btn.textContent !== 'Saved!' && btn.textContent !== 'Failed') btn.textContent = 'Save Case';
|
||
}
|
||
}
|
||
|
||
function _graphToolName() {
|
||
var sel = document.getElementById('toolSelect');
|
||
return 'Graph · ' + sel.options[sel.selectedIndex].text;
|
||
}
|
||
|
||
document.getElementById('caseCheckpointBtn').addEventListener('click', async function() {
|
||
var toolName = _graphToolName();
|
||
if (currentCaseId) {
|
||
// update existing case directly
|
||
var state = cmGCaptureState();
|
||
if (!state) return;
|
||
this.disabled = true; this.textContent = 'Saving…';
|
||
try {
|
||
var res = await fetch('/api/cases/' + currentCaseId, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ state: state, tool: toolName }),
|
||
});
|
||
var json = await res.json();
|
||
if (!json.ok) throw new Error(json.error);
|
||
this.textContent = 'Saved!';
|
||
setTimeout(function() { var b = document.getElementById('caseCheckpointBtn'); if (b) b.textContent = 'Checkpoint'; }, 1800);
|
||
} catch (e) {
|
||
this.textContent = 'Failed';
|
||
setTimeout(function() { var b = document.getElementById('caseCheckpointBtn'); if (b) b.textContent = 'Checkpoint'; }, 1800);
|
||
} finally {
|
||
this.disabled = false;
|
||
}
|
||
return;
|
||
}
|
||
// no active case → toggle save form
|
||
var state = cmGCaptureState();
|
||
var form = document.getElementById('caseSaveForm');
|
||
if (!state) {
|
||
this.textContent = 'No data yet';
|
||
setTimeout(function() { var b = document.getElementById('caseCheckpointBtn'); if (b) b.textContent = 'Save Case'; }, 1500);
|
||
return;
|
||
}
|
||
if (form.hidden) {
|
||
var dateStr = new Date().toLocaleDateString('en-CA');
|
||
document.getElementById('caseSaveName').value = toolName + ' · ' + dateStr;
|
||
form.hidden = false;
|
||
requestAnimationFrame(function() {
|
||
var inp = document.getElementById('caseSaveName');
|
||
inp.focus(); inp.select();
|
||
});
|
||
} else {
|
||
form.hidden = true;
|
||
}
|
||
});
|
||
|
||
document.getElementById('caseSaveName').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); document.getElementById('caseSaveConfirm').click(); }
|
||
});
|
||
|
||
document.getElementById('caseSaveCancel').addEventListener('click', function() {
|
||
document.getElementById('caseSaveForm').hidden = true;
|
||
});
|
||
|
||
document.getElementById('caseSaveConfirm').addEventListener('click', async function() {
|
||
var nameEl = document.getElementById('caseSaveName');
|
||
var name = nameEl.value.trim() || 'Unnamed Case';
|
||
var state = cmGCaptureState();
|
||
if (!state) return;
|
||
var toolName = _graphToolName();
|
||
this.disabled = true; this.textContent = 'Saving…';
|
||
try {
|
||
var res = await fetch('/api/cases', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name, page: 'graph', tool: toolName, state: state }),
|
||
});
|
||
var json = await res.json();
|
||
if (!json.ok) throw new Error(json.error);
|
||
currentCaseId = json.caseId;
|
||
currentCaseName = json.meta.name;
|
||
updateCaseIndicator();
|
||
document.getElementById('caseSaveForm').hidden = true;
|
||
nameEl.value = '';
|
||
} catch (e) {
|
||
// re-enable for retry
|
||
} finally {
|
||
this.disabled = false; this.textContent = 'Save';
|
||
}
|
||
});
|
||
|
||
// ── Auto-checkpoint (triggered by Archive All and Dump JSON) ─────────────────
|
||
async function autoCheckpoint() {
|
||
var state = cmGCaptureState();
|
||
if (!state) return;
|
||
var toolName = _graphToolName();
|
||
var dateStr = new Date().toLocaleDateString('en-CA');
|
||
try {
|
||
if (currentCaseId) {
|
||
await fetch('/api/cases/' + currentCaseId, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ state: state, tool: toolName }),
|
||
});
|
||
} else {
|
||
var res = await fetch('/api/cases', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: 'Auto-save · ' + toolName + ' ' + dateStr, page: 'graph', tool: toolName, state: state }),
|
||
});
|
||
var json = await res.json();
|
||
if (json.ok) {
|
||
currentCaseId = json.caseId;
|
||
currentCaseName = json.meta.name;
|
||
updateCaseIndicator();
|
||
}
|
||
}
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
// ── Periodic auto-save (every 60 s) — guards against power loss / crash ───────
|
||
setInterval(function() { autoCheckpoint(); }, 60000);
|
||
|
||
// ── beforeunload beacon — fires on tab/browser close ─────────────────────────
|
||
window.addEventListener('beforeunload', function() {
|
||
var state = cmGCaptureState();
|
||
if (!state) return;
|
||
var toolName = _graphToolName();
|
||
var dateStr = new Date().toLocaleDateString('en-CA');
|
||
var payload = currentCaseId
|
||
? { caseId: currentCaseId, state: state, tool: toolName }
|
||
: { name: 'Auto-save · ' + toolName + ' ' + dateStr, page: 'graph', tool: toolName, state: state };
|
||
navigator.sendBeacon('/api/cases/beacon', new Blob([JSON.stringify(payload)], { type: 'application/json' }));
|
||
});
|
||
|
||
function cmGCaptureState() {
|
||
if (!cy || !cy.nodes().length) return null;
|
||
return {
|
||
elements: cy.elements().jsons(),
|
||
toolbar: {
|
||
tool: document.getElementById('toolSelect').value,
|
||
mode: document.getElementById('modeSelect').value,
|
||
query: document.getElementById('queryInput').value.trim(),
|
||
count: document.getElementById('countInput').value,
|
||
dateFrom: document.getElementById('dateFromInput').value.trim(),
|
||
dateTo: document.getElementById('dateToInput').value.trim(),
|
||
},
|
||
graphArchivedId: graphArchivedId || null,
|
||
};
|
||
}
|
||
|
||
function cmGRestoreState(state, meta) {
|
||
if (!state || !meta) return;
|
||
clearGraph();
|
||
|
||
if (state.toolbar) {
|
||
var t = state.toolbar;
|
||
if (t.tool) { document.getElementById('toolSelect').value = t.tool; updatePlaceholder(); updateModeVisibility(); }
|
||
if (t.mode) document.getElementById('modeSelect').value = t.mode;
|
||
if (t.query) document.getElementById('queryInput').value = t.query;
|
||
if (t.count) document.getElementById('countInput').value = t.count;
|
||
if (t.dateFrom) document.getElementById('dateFromInput').value = t.dateFrom;
|
||
if (t.dateTo) document.getElementById('dateToInput').value = t.dateTo;
|
||
}
|
||
|
||
if (Array.isArray(state.elements) && state.elements.length) {
|
||
try {
|
||
cy.add(state.elements);
|
||
} catch (e) {
|
||
setStatus('Error restoring graph: ' + e.message, true);
|
||
return;
|
||
}
|
||
// Rebuild allItems from node data (type + raw) without double-storing
|
||
cy.nodes().forEach(function(node) {
|
||
var raw = node.data('raw');
|
||
var type = node.data('type');
|
||
if (raw && type) allItems.push({ type: type, item: raw });
|
||
});
|
||
cy.layout({ name: 'preset' }).run();
|
||
cy.fit(undefined, 60);
|
||
updateNodeCount();
|
||
}
|
||
|
||
currentCaseId = meta.id;
|
||
currentCaseName = meta.name;
|
||
updateCaseIndicator();
|
||
|
||
if (state.graphArchivedId) {
|
||
graphArchivedId = state.graphArchivedId;
|
||
document.getElementById('btnArchiveAll').textContent = 'Update Archive';
|
||
}
|
||
|
||
setStatus('Case "' + esc(meta.name) + '" restored — ' + cy.nodes().length + ' node(s)');
|
||
}
|
||
|
||
// ── Clear ─────────────────────────────────────────────────────────────────────
|
||
function clearGraph() {
|
||
cy.elements().remove();
|
||
allItems.length = 0;
|
||
pendingQuoteEdges = {};
|
||
hidePanel();
|
||
graphFilterQuery = '';
|
||
document.getElementById('graphFilterInput').value = '';
|
||
updateNodeCount();
|
||
graphArchivedId = null;
|
||
document.getElementById('btnArchiveAll').textContent = 'Archive All';
|
||
document.getElementById('archiveBar').style.display = 'none';
|
||
setStatus('Graph cleared');
|
||
}
|
||
|
||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|