import json
from pathlib import Path
from html import escape
from grafo_d_sqlite_adapter import load_database
import sqlite3

INPUT = "DATABASE/GRAFO_D_DATABASE.sqlite"
OUTPUT = "outputs/timeline_excel_like.html"

CELL_W = 120
CELL_H = 46
LEFT_PAD = 260
TOP_PAD = 38
BAR_H = 30

SELECTED_LANE_H = 56

COLORS = [
    "#1d7afc", "#43a047", "#f4511e", "#8e24aa", "#e53935",
    "#00a6a6", "#5145cd", "#d29600", "#d81b8a", "#795548"
]

raw_db = load_database()
if isinstance(raw_db, dict):
    events = raw_db.get("events", [])
elif isinstance(raw_db, list):
    events = raw_db
else:
    events = []

def clean(v):
    if v is None:
        return ""
    return str(v).strip()

def is_year_value(v):
    try:
        x = int(float(clean(v).replace("~", "")))
        return -46000 <= x <= 9999
    except Exception:
        return False


def load_sqlite_graph_labels_for_empty_rows():
    """
    Load graph rows directly from SQLite graphs.

    A graph with zero events must still be visible as a graph/container.
    It must not fabricate a timeline component.
    """
    db_path = Path("DATABASE/GRAFO_D_DATABASE.sqlite")
    labels = {}

    if not db_path.exists():
        return labels

    con = sqlite3.connect(str(db_path))
    con.row_factory = sqlite3.Row

    try:
        rows = con.execute(
            "SELECT row_key, visible_name FROM graphs ORDER BY id"
        ).fetchall()

        for row in rows:
            row_key = str(row["row_key"] or "").strip()
            visible_name = str(row["visible_name"] or row_key).strip()
            if row_key:
                labels[row_key] = visible_name or row_key
    finally:
        con.close()

    return labels


rows = sorted(set(e["row"] for e in events))

score = {}
for r in rows:
    vals = [e["name"] for e in events if e["row"] == r]
    numeric_count = sum(1 for v in vals if is_year_value(v))
    has_2025 = any(clean(v).replace("~", "") in ["2025", "2025.0"] for v in vals)
    score[r] = numeric_count + (1000 if has_2025 else 0)

axis_row = max(score, key=score.get)

axis_events = [
    e for e in events
    if e["row"] == axis_row and is_year_value(e["name"])
]

# Ensure dynamic timeline axis covers all event columns.
# API/live events may extend beyond the last historical year marker.
max_event_col = max(e.get("end_col", 0) for e in events) if events else 0
max_axis_col = max((e.get("end_col", 0) for e in axis_events), default=0)

if max_event_col > max_axis_col:
    live_years = sorted({
        str(e.get("date", ""))[:4]
        for e in events
        if e.get("end_col", 0) > max_axis_col and str(e.get("date", ""))[:4].isdigit()
    })

    fallback_year = "2026"
    for year in live_years or [fallback_year]:
        axis_events.append({
            "row": axis_row,
            "name": year,
            "start_col": max_axis_col + 1,
            "end_col": max_event_col
        })
        break

graph_events = [
    e for e in events
    if e["row"] != axis_row
]

row_labels = {}
for e in graph_events:
    row_labels.setdefault(e["row"], e["name"])

graph_labels = {}
if isinstance(raw_db, dict):
    graph_labels = raw_db.get("ui_state", {}).get("graph_labels", {}) or {}

for row_key, visible_name in graph_labels.items():
    if row_key in row_labels and visible_name:
        row_labels[row_key] = visible_name

sqlite_graph_labels = load_sqlite_graph_labels_for_empty_rows()

for row_key, visible_name in sqlite_graph_labels.items():
    row_labels[row_key] = visible_name or row_key

graph_rows = sorted(
    set(e["row"] for e in graph_events) | set(sqlite_graph_labels.keys()),
    key=lambda r: clean(row_labels.get(r, r)).lower()
)

max_col = max((e["end_col"] for e in events), default=1)
base_w = LEFT_PAD + max_col * CELL_W + 800
base_h = TOP_PAD + len(graph_rows) * SELECTED_LANE_H + 800

sidebar_items = []

for lane, row in enumerate(graph_rows):
    color = COLORS[lane % len(COLORS)]
    label = escape(clean(row_labels[row]))

    sidebar_items.append(f"""
    <label class="graphOption" style="--c:{color};">
        <input type="checkbox" class="graphCheck" value="{row}">
        <span class="dot"></span>
        <span class="graphName">{label}</span>
        <span class="toggleItems" title="Ver elementos">▸</span>
    </label>
    """)

event_data_js = []

for e in graph_events:
    lane = graph_rows.index(e["row"])
    color = COLORS[lane % len(COLORS)]

    brief_value = (
        e.get("brief_description")
        or e.get("brief")
        or e.get("BRIEF")
        or e.get("description")
        or ""
    )

    api_value = (
        e.get("api_text")
        or e.get("api")
        or ""
    )

    event_data_js.append({
        "id": clean(e.get("id")),
        "db_id": e.get("db_id", ""),
        "row": str(e["row"]),
        "row_key": str(e.get("row_key") or e["row"]),
        "name": clean(e["name"]),
        "event_name": clean(e.get("event_name") or e["name"]),
        "start": e["start_col"],
        "end": e["end_col"],
        "start_col": e["start_col"],
        "end_col": e["end_col"],
        "color": color,

        # V55: campos canónicos visibles en frontend.
        "date": clean(e.get("date") or e.get("date_text")),
        "date_text": clean(e.get("date_text") or e.get("date")),
        "year": e.get("year") or e.get("date_year") or "",
        "month": e.get("month") or e.get("date_month") or "",
        "day": e.get("day") or e.get("date_day") or "",
        "place": clean(e.get("place") or e.get("PLACE")),
        "PLACE": clean(e.get("place") or e.get("PLACE")),
        "who": clean(e.get("who") or e.get("WHO")),
        "WHO": clean(e.get("who") or e.get("WHO")),
        "description": clean(brief_value),
        "brief": clean(brief_value),
        "BRIEF": clean(brief_value),
        "brief_description": clean(brief_value),
        "source_link": clean(e.get("source_link")),
        "source": clean(e.get("source_link") or e.get("source")),
        "api": clean(api_value),
        "api_text": clean(api_value),
    })

axis_data_js = [
    {
        "name": clean(e["name"]).replace(".0", ""),
        "start": e["start_col"],
        "end": e["end_col"],
        "x": LEFT_PAD + ((e["start_col"] + e["end_col"]) / 2 - 1) * CELL_W
    }
    for e in axis_events
]

row_label_js = {
    str(r): clean(row_labels[r])
    for r in graph_rows
}

html = f"""
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>GRAFO D - Línea Temporal Inteligente</title>
<style>
html, body {{
    margin: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    font-family: Arial, sans-serif;
    background: #0b0b0b;
}}

#toolbar {{
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: 58px;
    z-index: 400;
    background: linear-gradient(#111, #050505);
    color: white;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 0 14px;
    box-shadow: 0 2px 12px #0008;
}}

button {{
    height: 34px;
    padding: 0 14px;
    background: #1d1d1d;
    color: white;
    border: 1px solid #333;
    border-radius: 6px;
    cursor: pointer;
}}

button:hover {{
    background: #1565c0;
}}

#zoomLabel {{
    margin-left: auto;
    margin-right: 20px;
}}

#sidebar {{
    font-size: 11px;
    position: fixed;
    top: 58px;
    left: 0;
    bottom: 0;
    width: 250px;
    z-index: 350;
    background: #111;
    color: white;
    overflow: auto;
    border-right: 1px solid #333;
}}

#searchBox {{
    position: sticky;
    top: 0;
    z-index: 50;
    background: #111;
    padding: 10px 12px;
    border-bottom: 1px solid #222;
    display: flex;
    align-items: center;
    gap: 8px;
}}

.searchInputWrap {{
    position: relative;
    flex: 1 1 auto;
    min-width: 0;
}}

#graphSearch {{
    width: 100%;
    height: 30px;
    box-sizing: border-box;
    border: 1px solid #333;
    border-radius: 5px;
    background: #0b0b0b;
    color: white;
    padding: 0 28px 0 8px;
    outline: none;
}}

#graphSearch:focus {{
    border-color: #6b7280;
}}

#clearSearch {{
    position: absolute;
    right: 6px;
    top: 5px;
    width: 20px;
    height: 20px;
    border: none;
    background: transparent;
    color: #9ca3af;
    cursor: pointer;
    font-size: 16px;
    line-height: 18px;
    padding: 0;
    z-index: 2;
}}

#clearSearch:hover {{
    color: white;
}}

#folderEdit {{
    flex: 0 0 auto;
    width: 30px;
    height: 30px;
    border: 1px solid #333;
    border-radius: 5px;
    background: #0b0b0b;
    color: #9ca3af;
    cursor: pointer;
    font-size: 14px;
    line-height: 18px;
    padding: 0;
}}

#folderEdit:hover {{
    color: white;
    border-color: #555;
}}

#rootDropZone {{
    display: none;
    margin: 10px;
    height: 44px;
    border: 1px dashed #3f3f46;
    border-radius: 6px;
    color: #71717a;
    align-items: center;
    justify-content: center;
    font-size: 11px;
}}

#folderTools.active ~ #rootDropZone {{
    display: flex;
}}

#rootDropZone.dragTarget {{
    border-color: #e5e7eb;
    color: #e5e7eb;
    background: #1f1f1f;
}}

.folderBlock {{
    border-bottom: 1px solid #222;
}}

.folderHeader {{
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 7px 10px;
    cursor: pointer;
    color: #e5e7eb;
    font-size: 12px;
    background: #151515;
}}

.folderHeader:hover {{
    background: #1f1f1f;
}}

.folderName {{
    flex: 1;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}}

.folderCount {{
    color: #9ca3af;
    font-size: 11px;
}}

.folderContent {{
    display: block;
}}

.folderContent.closed {{
    display: none;
}}

.folderTools {{
    display: none;
    padding: 6px 10px;
    background: #101010;
    border-bottom: 1px solid #222;
}}

.folderTools.active {{
    display: flex;
    gap: 6px;
}}

.folderTools button {{
    height: 26px;
    font-size: 11px;
    padding: 0 8px;
}}

.graphOption {{
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    cursor: pointer;
    font-size: 11px;
    border-bottom: 1px solid #222;
}}

.graphCheck,
.folderCheck {{
    width: 11px;
    height: 11px;
    accent-color: #3b82f6;
}}

body:not(.editMode) .graphCheck,
body:not(.editMode) .folderCheck {{
    appearance: none;
    border-radius: 50%;
    border: 1px solid #6b7280;
    background: #111;
}}

body:not(.editMode) .graphCheck:checked,
body:not(.editMode) .folderCheck:checked {{
    background: #3b82f6;
    border-color: #3b82f6;
    box-shadow: inset 0 0 0 2px #111;
}}

body.editMode .graphCheck,
body.editMode .folderCheck {{
    appearance: auto;
    border-radius: 2px;
}}

.graphOption.dragging {{
    opacity: 0.45;
}}

.folderHeader.dragTarget {{
    outline: 1px dashed #9ca3af;
    background: #2a2a2a;
}}

.graphOption:hover {{
    background: #1e1e1e;
}}

.dot {{
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: var(--c);
    flex: 0 0 auto;
}}

.graphName {{
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    flex: 1;
}}

.toggleItems {{
    margin-left: auto;
    color: #9ca3af;
    font-size: 13px;
    cursor: pointer;
    padding: 2px 6px;
    border-radius: 0;
}}

.toggleItems:hover {{
    background: #333;
    color: #fff;
}}

.graphDetails {{
    display: none;
    margin: 0 0 0 22px;
    padding: 4px 8px 8px 8px;
    border-bottom: 1px solid #222;
    color: #d1d5db;
    font-size: 11px;
}}

.graphDetails summary {{
    cursor: pointer;
    color: #9ca3af;
    padding: 3px 0;
}}

.graphDetails ul {{
    margin: 6px 0 0 0;
    padding-left: 14px;
    max-height: 220px;
    overflow-y: auto;
}}

.graphDetails li {{
    margin: 3px 0;
    line-height: 1.25;
}}

#viewport {{
    position: absolute;
    top: 58px;
    left: 250px;
    right: 0;
    bottom: 0;
    overflow: scroll;
    background: white;
}}

#frozenLabels {{
    position: fixed;
    top: 140px;
    left: 250px;
    width: 250px;
    bottom: 0;
    z-index: 280;
    pointer-events: none;
}}

#spacer {{
    position: relative;
    width: {base_w}px;
    height: {base_h}px;
}}

#canvas {{
    position: absolute;
    left: 0;
    top: 0;
    width: {base_w}px;
    min-height: {base_h}px;
    transform-origin: 0 0;
    background: white;
}}

#axis {{
    position: sticky;
    top: 0;
    left: 0;
    z-index: 300;
    height: 82px;
    background: #f2f4f7;
    border-bottom: 1px solid #c8ccd2;
    box-shadow: 0 2px 6px #0001;
}}

#axisTitle {{
    position: absolute;
    left: 22px;
    top: 28px;
    color: #1f2937;
    font-weight: bold;
    font-size: 15px;
}}

#axisLine {{
    position: absolute;
    left: {LEFT_PAD}px;
    top: 41px;
    width: {max_col * CELL_W}px;
    height: 1px;
    background: #9aa3af;
}}

.yearTick {{
    position: absolute;
    top: 20px;
    height: 42px;
    border-left: 1px solid #6b7280;
}}

.yearLabel {{
    position: absolute;
    top: 13px;
    left: 4px;
    min-width: 58px;
    text-align: left;
    color: #111827;
    font-weight: 700;
    font-size: 13px;
    background: #ffffff;
    border: 1px solid #c8ccd2;
    border-radius: 3px;
    padding: 2px 5px;
    box-shadow: 0 1px 2px #0001;
}}

#selectedGraphLayer {{
    position: relative;
    top: 0;
    left: 0;
}}

.graphLaneLabel {{
    position: absolute;
    left: 0;
    width: max-content;
    min-width: 90px;
    max-width: 215px;
    height: 30px;
    line-height: 30px;
    padding: 0 14px 0 10px;
    box-sizing: border-box;
    border-left: 8px solid var(--c);
    border-radius: 5px;
    background: #f7f7f7;
    color: #102a43;
    font-size: 12px;
    font-weight: 700;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}}

.event {{
    position: absolute;
    height: {BAR_H}px;
    line-height: {BAR_H}px;
    padding: 0 7px;
    box-sizing: border-box;
    border: 1px solid var(--c);
    border-radius: 0 !important;
    background: color-mix(in srgb, var(--c) 18%, white);
    color: #102a43;
    font-size: 13px;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}}


.event {{
    overflow: visible !important;
}}

.eventMainText {{
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}}

.eventPlaceTag {{
    position: absolute;
    left: 0;
    top: -15px;
    z-index: 85;
    height: 12px;
    line-height: 12px;
    max-width: 21ch;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 1px 6px;
    background: #05070b;
    color: #f8fafc;
    border: 1px solid rgba(148, 163, 184, 0.65);
    border-radius: 0;
    font-size: 9px;
    font-weight: 700;
    white-space: nowrap;
    pointer-events: none;
}}

.eventWhoTag {{
    position: absolute;
    left: 0;
    bottom: -13px;
    z-index: 80;
    height: 12px;
    line-height: 12px;
    max-width: 21ch;
    overflow: hidden;
    text-overflow: ellipsis;
    padding: 1px 6px;
    background: #05070b;
    color: #f8fafc;
    border: 1px solid rgba(148, 163, 184, 0.65);
    border-radius: 0;
    font-size: 9px;
    font-weight: 700;
    white-space: nowrap;
    pointer-events: none;
}}

.event:hover {{
    background: #fff3b0;
    z-index: 250;
    width: auto !important;
    min-width: 260px;
    overflow: visible;
}}

#deleteSelectedGraphs {{
    height: 26px;
    border: 1px solid #444;
    border-radius: 5px;
    background: #171717;
    color: #e5e7eb;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
}}

#deleteSelectedGraphs:hover {{
    background: #262626;
    border-color: #666;
}}


body.editMode .graphOption {{
    cursor: pointer;
}}

body.editMode .graphOption.graphSelected {{
    background: rgba(37, 99, 235, 0.35) !important;
    outline: 1px solid #60a5fa !important;
}}

body.editMode .graphCheck,
body.editMode .folderCheck {{
    width: 14px !important;
    height: 14px !important;
    accent-color: #3b82f6 !important;
    cursor: pointer !important;
}}

#deleteSelectedGraphs {{
    height: 26px;
    border: 1px solid #444;
    border-radius: 5px;
    background: #171717;
    color: #e5e7eb;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
}}

#deleteSelectedGraphs:hover {{
    background: #262626;
    border-color: #666;
}}



</style>
</head>
<body>

<div id="toolbar">
    <strong>GRAFO D — Línea Temporal Inteligente</strong>
    <button id="v49SaveButton" type="button" title="Autosave status">Save <span id="v49SaveDotFinal" aria-hidden="true"></span></button>
</div>

<div id="sidebar">
    <div id="searchBox">
        <div class="searchInputWrap">
            <input id="graphSearch" type="text" placeholder="Search">
            <button id="clearSearch" type="button" title="Clear search">×</button>
        </div>
        <button id="folderEdit" type="button" title="Edit folders">▦</button>
    </div>
    <div id="folderTools" class="folderTools">
        <button id="newFolder" type="button">New folder</button>
        <button id="newGraph" type="button">New graph</button>
        <button id="moveSelected" type="button">Move selected</button>
        <button id="deleteSelectedGraphs" type="button">Delete selected</button>
    </div>
    {''.join(sidebar_items)}
</div>

<div id="frozenLabels"></div>

<div id="viewport">
    <div id="spacer">
        <div id="canvas">
            <div id="axis">
                <div id="axisTitle">TIMELINE</div>
                <div id="axisLine"></div>
                <div id="ticks"></div>
            </div>
            <div id="selectedGraphLayer"></div>
        </div>
    </div>
</div>

<script>
let zoom = 1.0;
let selectionOrder = [];
let selectedEmptyFolders = [];
let editMode = false;

const axisData = {json.dumps(axis_data_js, ensure_ascii=False)};
const eventData = {json.dumps(event_data_js, ensure_ascii=False)};
const rowLabels = {json.dumps(row_label_js, ensure_ascii=False)};

const canvas = document.getElementById("canvas");
const spacer = document.getElementById("spacer");
const label = document.getElementById("zoomLabel");
const ticks = document.getElementById("ticks");
const layer = document.getElementById("selectedGraphLayer");
const frozenLabels = document.getElementById("frozenLabels");

let baseW = {base_w};
let baseH = {base_h};

function selectedRows() {{
    return Array.from(document.querySelectorAll(".graphCheck:checked"))
        .map(x => x.value);
}}


function selectedFolderRows() {{
    return [...selectedEmptyFolders];
}}

function setEmptyFolderSelected(path, checked) {{
    if (!path || path === "Uncategorized") return;

    if (checked && !selectedEmptyFolders.includes(path)) {{
        selectedEmptyFolders.push(path);
    }}

    if (!checked) {{
        selectedEmptyFolders = selectedEmptyFolders.filter(x => x !== path);
    }}
}}

function clearEmptyFolderSelection() {{
    selectedEmptyFolders = [];
}}

function deleteEmptyFolderSql(path) {{
    return fetch("/api/folders/" + encodeURIComponent(path), {{
        method: "DELETE"
    }}).then(r => r.json().then(data => {{
        if (!r.ok || data.ok === false) {{
            throw new Error(data.error || ("Folder delete failed: " + path));
        }}

        if (!data.deleted || !data.deleted.includes(path)) {{
            const reason = data.skipped && data.skipped.length
                ? data.skipped.map(x => x.folder + ": " + x.reason).join("; ")
                : "folder was not deleted";
            throw new Error(reason);
        }}

        return data;
    }}));
}}


function syncSelectionOrder() {{
    const active = selectedRows();

    selectionOrder = selectionOrder.filter(r => active.includes(r));

    active.forEach(r => {{
        if (!selectionOrder.includes(r)) {{
            selectionOrder.push(r);
        }}
    }});

    reorderSidebar();
}}

function reorderSidebar() {{
    renderFolderTree();
}}

let grafoDFolderState = null;

function normalizeFolderState(state) {{
    if (!state || typeof state !== "object") state = {{}};
    if (!Array.isArray(state.folders)) state.folders = ["Uncategorized"];
    if (!state.graphFolder || typeof state.graphFolder !== "object") state.graphFolder = {{}};
    if (!state.open || typeof state.open !== "object") state.open = {{"Uncategorized": true}};
    if (!Array.isArray(state.deletedRows)) state.deletedRows = [];
    if (!state.folders.includes("Uncategorized")) state.folders.unshift("Uncategorized");
    return state;
}}

function getFolderState() {{
    if (!grafoDFolderState) {{
        grafoDFolderState = normalizeFolderState({{
            folders: ["Uncategorized"],
            graphFolder: {{}},
            open: {{"Uncategorized": true}},
            deletedRows: []
        }});
    }}
    return grafoDFolderState;
}}

function loadFolderStateFromDatabase() {{
    fetch("/api/db")
        .then(r => r.json())
        .then(db => {{
            grafoDFolderState = normalizeFolderState(
                db && db.ui_state && db.ui_state.folders ? db.ui_state.folders : null
            );
            renderFolderTree();
            syncFolderChecks();
        }})
        .catch(() => {{}});
}}

function saveFolderState(state) {{
    grafoDFolderState = normalizeFolderState(state);
    return fetch("/api/ui-state", {{
        method: "POST",
        headers: {{"Content-Type": "application/json"}},
        body: JSON.stringify({{key: "folders", value: grafoDFolderState}})
    }}).catch(() => {{}});
}}

try {{
}} catch(e) {{}}

loadFolderStateFromDatabase();

let grafoDMasterBlocks = null;

function collectGraphBlocks() {{
    if (!grafoDMasterBlocks) {{
        grafoDMasterBlocks = Array.from(document.querySelectorAll(".graphOption")).map(label => {{
            const check = label.querySelector(".graphCheck");
            const details = label.nextElementSibling && label.nextElementSibling.classList.contains("graphDetails")
                ? label.nextElementSibling
                : null;

            if (check) {{
                label.draggable = true;
                label.dataset.row = check.value;
            }}

            return {{
                row: check ? check.value : "",
                selected: check ? check.checked : false,
                label: label,
                details: details
            }};
        }});
    }}

    grafoDMasterBlocks.forEach(b => {{
        const check = b.label.querySelector(".graphCheck");
        b.selected = check ? check.checked : false;
    }});

    const state = getFolderState();
    const deleted = new Set(state.deletedRows || []);
    return grafoDMasterBlocks.filter(b => !deleted.has(b.row));
}}

function folderDepth(path) {{
    if (path === "Uncategorized") return 0;
    return path.split("/").length - 1;
}}

function folderName(path) {{
    const parts = path.split("/");
    return parts[parts.length - 1];
}}

function directChildren(path, folders) {{
    if (path === "Uncategorized") {{
        return [];
    }}

    const prefix = path + "/";

    return folders
        .filter(f => f !== path)
        .filter(f => f.startsWith(prefix) && !f.slice(prefix.length).includes("/"))
        .sort((a, b) => folderName(a).localeCompare(folderName(b)));
}}

function rowsUnderFolder(path, blocks, state) {{
    return blocks
        .filter(b => {{
            const f = state.graphFolder[b.row] || "Uncategorized";
            return f === path || f.startsWith(path + "/");
        }})
        .map(b => b.row);
}}

function renderFolderTree() {{
    const sidebar = document.getElementById("sidebar");
    const searchBox = document.getElementById("searchBox");
    const tools = document.getElementById("folderTools");
    const q = document.getElementById("graphSearch").value.trim().toLowerCase();

    const state = getFolderState();
    const blocks = collectGraphBlocks();

    const selected = blocks.filter(b => b.selected);
    const unselected = blocks.filter(b => !b.selected);
    const ordered = selected.concat(unselected);

    sidebar.innerHTML = "";
    sidebar.appendChild(searchBox);
    sidebar.appendChild(tools);

    const rootDrop = document.createElement("div");
    rootDrop.id = "rootDropZone";
    rootDrop.textContent = "Drop folder here to move to root";

    if (document.getElementById("folderTools").classList.contains("active")) {{
        rootDrop.style.display = "flex";
    }} else {{
        rootDrop.style.display = "none";
    }}

    rootDrop.addEventListener("dragover", function(ev) {{
        ev.preventDefault();
        rootDrop.classList.add("dragTarget");
    }});

    rootDrop.addEventListener("dragleave", function() {{
        rootDrop.classList.remove("dragTarget");
    }});

    rootDrop.addEventListener("drop", function(ev) {{
        ev.preventDefault();
        rootDrop.classList.remove("dragTarget");

        const movingFolder = ev.dataTransfer.getData("application/grafo-folder");
        if (!movingFolder) return;

        const state = getFolderState();
        moveFolderToFolder(movingFolder, "Uncategorized", state);
        saveFolderState(state);
        renderFolderTree();
    }});

    sidebar.appendChild(rootDrop);

    function renderNode(path, parent) {{
        const depth = folderDepth(path);
        const children = directChildren(path, state.folders);

        const exactMembers = ordered.filter(b => (state.graphFolder[b.row] || "Uncategorized") === path);

        const visibleMembers = exactMembers.filter(b => {{
            const text = b.label.innerText.toLowerCase();
            return !q || text.includes(q) || path.toLowerCase().includes(q);
        }});

        const visibleChildren = children.filter(child => {{
            if (!q) return true;

            const childRows = rowsUnderFolder(child, blocks, state);
            const childHasVisibleGraph = blocks.some(b =>
                childRows.includes(b.row) && b.label.innerText.toLowerCase().includes(q)
            );

            return child.toLowerCase().includes(q) || childHasVisibleGraph;
        }});

        if (q && visibleMembers.length === 0 && visibleChildren.length === 0) return;

        const block = document.createElement("div");
        block.className = "folderBlock";

        const header = document.createElement("div");
        header.className = "folderHeader";
        header.style.paddingLeft = (10 + depth * 18) + "px";
        header.dataset.folder = path;
        header.draggable = path !== "Uncategorized";

        header.addEventListener("dragstart", function(ev) {{
            if (path === "Uncategorized") return;
            ev.dataTransfer.setData("application/grafo-folder", path);
            ev.dataTransfer.setData("text/plain", "");
        }});

        const folderRows = rowsUnderFolder(path, blocks, state);
        const selectedRowsInFolder = folderRows.filter(row => selectionOrder.includes(row)).length;

        const folderCheck = document.createElement("input");
        folderCheck.type = "checkbox";
        folderCheck.className = "folderCheck";
        folderCheck.checked = folderRows.length > 0
            ? selectedRowsInFolder === folderRows.length
            : selectedEmptyFolders.includes(path);
        folderCheck.indeterminate = folderRows.length > 0 &&
            selectedRowsInFolder > 0 &&
            selectedRowsInFolder < folderRows.length;

        folderCheck.addEventListener("click", function(ev) {{
            ev.stopPropagation();

            const checked = folderCheck.checked;

            if (folderRows.length === 0) {{
                setEmptyFolderSelected(path, checked);
                renderFolderTree();
                return;
            }}

            folderRows.forEach(row => {{
                const input = document.querySelector('.graphCheck[value="' + row + '"]');
                if (input) input.checked = checked;

                if (checked && !selectionOrder.includes(row)) {{
                    selectionOrder.push(row);
                }}

                if (!checked) {{
                    selectionOrder = selectionOrder.filter(x => x !== row);
                }}
            }});

            if (editMode) {{
                renderFolderTree();
                return;
            }}

            applyFilter();
            applyZoom();
        }});

        const arrow = document.createElement("span");
        arrow.textContent = state.open[path] === false ? "▸" : "▾";

        const name = document.createElement("span");
        name.className = "folderName";
        name.textContent = folderName(path);

        const count = document.createElement("span");
        count.className = "folderCount";
        count.textContent = folderRows.length;

        header.appendChild(folderCheck);
        header.appendChild(arrow);
        header.appendChild(name);
        header.appendChild(count);

        const content = document.createElement("div");
        content.className = "folderContent";
        if (state.open[path] === false) content.classList.add("closed");

        header.addEventListener("click", function() {{
            state.open[path] = !(state.open[path] !== false);
            saveFolderState(state);
            renderFolderTree();
            syncFolderChecks();
        }});

        header.addEventListener("dragover", function(ev) {{
            ev.preventDefault();
            header.classList.add("dragTarget");
        }});

        header.addEventListener("dragleave", function() {{
            header.classList.remove("dragTarget");
        }});

        header.addEventListener("drop", function(ev) {{
            ev.preventDefault();
            header.classList.remove("dragTarget");

            const movingFolder = ev.dataTransfer.getData("application/grafo-folder");
            const row = ev.dataTransfer.getData("text/plain");

            const state = getFolderState();

            if (movingFolder) {{
                moveFolderToFolder(movingFolder, path, state);
                saveFolderState(state);
                renderFolderTree();
                return;
            }}

            if (!row) return;

            state.graphFolder[row] = path;
            state.open[path] = true;
            saveFolderState(state);
            renderFolderTree();
            syncFolderChecks();
        }});

        block.appendChild(header);

        visibleChildren.forEach(child => renderNode(child, content));

        visibleMembers.forEach(b => {{
            b.label.style.marginLeft = (depth * 18 + 12) + "px";

            b.label.addEventListener("dragstart", function(ev) {{
                ev.dataTransfer.setData("text/plain", b.row);
                b.label.classList.add("dragging");
            }});

            b.label.addEventListener("dragend", function() {{
                b.label.classList.remove("dragging");
            }});

            const graphInput = b.label.querySelector(".graphCheck");
            if (graphInput) {{
                const isSelected = selectionOrder.includes(b.row);
                graphInput.checked = isSelected;
                b.label.classList.toggle("graphSelected", isSelected);

                graphInput.onclick = function(ev) {{
                    ev.stopPropagation();
                    setGraphSelected(b.row, graphInput.checked);
                    if (!editMode) {{
                        applyFilter();
                        applyZoom();
                    }}
                }};

                b.label.onclick = function(ev) {{
                    if (!editMode) return;
                    if (ev.target.closest(".toggleItems")) return;
                    if (ev.target.classList.contains("graphCheck")) return;

                    graphInput.checked = !graphInput.checked;
                    setGraphSelected(b.row, graphInput.checked);
                }};
            }}

            content.appendChild(b.label);
            if (b.details) content.appendChild(b.details);
        }});

        block.appendChild(content);
        parent.appendChild(block);
    }}

    renderNode("Uncategorized", sidebar);

    state.folders
        .filter(f => f !== "Uncategorized" && !f.includes("/"))
        .sort((a, b) => folderName(a).localeCompare(folderName(b)))
        .forEach(f => renderNode(f, sidebar));
}}

function moveFolderToFolder(source, target, state) {{
    if (!source || source === "Uncategorized") return;
    if (source === target) return;
    if (target.startsWith(source + "/")) return;

    const shortName = folderName(source);
    const newBase = target === "Uncategorized" ? shortName : target + "/" + shortName;

    if (state.folders.includes(newBase)) {{
        alert("A folder with that name already exists in the destination.");
        return;
    }}

    const oldFolders = [...state.folders];

    oldFolders.forEach(f => {{
        if (f === source || f.startsWith(source + "/")) {{
            const suffix = f.slice(source.length);
            const newPath = newBase + suffix;

            state.folders = state.folders.filter(x => x !== f);
            if (!state.folders.includes(newPath)) state.folders.push(newPath);

            state.open[newPath] = state.open[f] !== false;
            delete state.open[f];

            Object.keys(state.graphFolder).forEach(row => {{
                if (state.graphFolder[row] === f) {{
                    state.graphFolder[row] = newPath;
                }}
            }});
        }}
    }});

    state.open[target] = true;
    state.open[newBase] = true;
}}

function createGraph() {{
    const state = getFolderState();

    const name = prompt("New graph name:");
    if (!name) return;

    const cleanName = name.trim();
    if (!cleanName) return;

    const folder = prompt(
        "Folder path for new graph:",
        state.folders[0] || "Uncategorized"
    );

    const cleanFolder = (folder || "Uncategorized").trim() || "Uncategorized";

    fetch("/api/graphs", {{
        method: "POST",
        headers: {{"Content-Type": "application/json"}},
        body: JSON.stringify({{
            name: cleanName,
            folder: cleanFolder
        }})
    }})
    .then(r => r.json())
    .then(data => {{
        if (!data.ok) {{
            throw new Error(data.error || "Graph creation failed");
        }}
        window.location.reload();
    }})
    .catch(err => {{
        alert("Error creating graph: " + err.message);
    }});
}}


function createFolder() {{
    const name = prompt("Folder name:");

    if (!name) return;

    const cleanName = name.trim();
    if (!cleanName) return;

    fetch("/api/folders", {{
        method: "POST",
        headers: {{"Content-Type": "application/json"}},
        body: JSON.stringify({{name: cleanName}})
    }})
    .then(r => r.json())
    .then(data => {{
        if (!data.ok) {{
            throw new Error(data.error || "Folder creation failed");
        }}

        window.location.reload();
    }})
    .catch(err => {{
        alert("Error creating folder: " + err.message);
    }});
}}

function createSubfolder() {{
    const state = getFolderState();

    const parent = prompt(
        "Parent folder path:",
        state.folders[state.folders.length - 1] || "Uncategorized"
    );

    if (!parent) return;

    const cleanParent = parent.trim();

    if (!state.folders.includes(cleanParent)) {{
        alert("Parent folder does not exist.");
        return;
    }}

    const name = prompt("Subfolder name:");
    if (!name) return;

    const cleanName = name.trim();
    if (!cleanName) return;

    const path = cleanParent === "Uncategorized"
        ? cleanName
        : cleanParent + "/" + cleanName;

    if (!state.folders.includes(path)) {{
        state.folders.push(path);
        state.open[path] = true;
        state.open[cleanParent] = true;
        saveFolderState(state);
    }}

    renderFolderTree();
}}


function setGraphSelected(row, checked) {{
    const input = document.querySelector('.graphCheck[value="' + row + '"]');
    if (input) input.checked = checked;

    if (checked && !selectionOrder.includes(row)) {{
        selectionOrder.push(row);
    }}

    if (!checked) {{
        selectionOrder = selectionOrder.filter(x => x !== row);
    }}

    document.querySelectorAll('.graphCheck[value="' + row + '"]').forEach(chk => {{
        const label = chk.closest(".graphOption");
        if (label) label.classList.toggle("graphSelected", checked);
    }});

    syncFolderChecks();
}}

async function deleteSelectedGraphs() {{
    const selectedGraphs = Array.isArray(selectionOrder) ? [...selectionOrder] : [];

    let selectedFolders = [];
    try {{
        if (typeof selectedFolderRows === "function") {{
            selectedFolders = selectedFolderRows();
        }}
    }} catch (err) {{
        selectedFolders = [];
    }}

    selectedFolders = selectedFolders.filter(function(x) {{
        return x && x !== "Uncategorized";
    }});

    if (selectedGraphs.length === 0 && selectedFolders.length === 0) {{
        alert("No graphs or folders selected.");
        return;
    }}

    let msg = "This will permanently delete selected item(s) from SQLite.\\n\\n";

    if (selectedGraphs.length) {{
        msg += "Graphs:\\n" + selectedGraphs.map(x => " - " + x).join("\\n") + "\\n\\n";
    }}

    if (selectedFolders.length) {{
        msg += "Empty folders:\\n" + selectedFolders.map(x => " - " + x).join("\\n") + "\\n\\n";
        msg += "Only folders that are empty will be deleted.\\n\\n";
    }}

    msg += "This action cannot be undone. Continue?";

    if (!confirm(msg)) return;

    async function deleteGraphSql(row) {{
        const r = await fetch("/api/graphs/" + encodeURIComponent(row), {{
            method: "DELETE"
        }});

        let data = {{}};
        try {{
            data = await r.json();
        }} catch (err) {{
            data = {{ok: false, error: "Invalid JSON response"}};
        }}

        if (!r.ok || data.ok === false) {{
            throw new Error(data.error || ("Graph delete failed: " + row));
        }}

        return data;
    }}

    async function deleteFolderSql(folder) {{
        if (typeof deleteEmptyFolderSql === "function") {{
            return await deleteEmptyFolderSql(folder);
        }}

        const r = await fetch("/api/folders/" + encodeURIComponent(folder), {{
            method: "DELETE"
        }});

        let data = {{}};
        try {{
            data = await r.json();
        }} catch (err) {{
            data = {{ok: false, error: "Invalid JSON response"}};
        }}

        if (!r.ok || data.ok === false) {{
            throw new Error(data.error || ("Folder delete failed: " + folder));
        }}

        return data;
    }}

    Promise.resolve()
        .then(async function() {{
            for (const row of selectedGraphs) {{
                await deleteGraphSql(row);
            }}

            for (const folder of selectedFolders) {{
                await deleteFolderSql(folder);
            }}

            selectionOrder = [];

            try {{
                if (typeof clearEmptyFolderSelection === "function") {{
                    clearEmptyFolderSelection();
                }}
            }} catch (err) {{}}

            window.location.href = window.location.pathname + "?v=deleted_" + Date.now();
        }})
        .catch(function(err) {{
            alert("Delete failed: " + err.message);
        }});
}}


function moveSelectedToFolder() {{
    const state = getFolderState();
    const selected = selectedRows();

    if (selected.length === 0) {{
        alert("Select one or more graphs first.");
        return;
    }}

    const folder = prompt(
        "Move selected graphs to folder path:",
        state.folders[0] || "Uncategorized"
    );

    if (!folder) return;

    const cleanFolder = folder.trim();
    if (!cleanFolder) return;

    if (!state.folders.includes(cleanFolder)) {{
        state.folders.push(cleanFolder);
        state.open[cleanFolder] = true;
    }}

    selected.forEach(row => {{
        state.graphFolder[row] = cleanFolder;
    }});

    saveFolderState(state);
    renderFolderTree();
}}

function toggleFolderTools() {{
    const tools = document.getElementById("folderTools");
    tools.classList.toggle("active");

    editMode = tools.classList.contains("active");
    document.body.classList.toggle("editMode", editMode);

    document.querySelectorAll(".graphOption").forEach(label => {{
        label.draggable = editMode;
    }});

    document.querySelectorAll(".graphCheck").forEach(input => {{
        input.checked = false;
    }});

    if (!editMode) {{
        selectionOrder = [];
        clearEmptyFolderSelection();
        applyFilter();
        applyZoom();
    }}

    renderFolderTree();
}}


function syncFolderChecks() {{
    document.querySelectorAll(".folderHeader").forEach(header => {{
        const check = header.querySelector(".folderCheck");
        if (!check) return;

        const path = header.dataset.folder;
        if (!path) return;

        const state = getFolderState();
        const blocks = collectGraphBlocks();
        const folderRows = rowsUnderFolder(path, blocks, state);

        const selectedCount = folderRows.filter(row => {{
            const input = document.querySelector('.graphCheck[value="' + row + '"]');
            return input && input.checked;
        }}).length;

        check.checked = folderRows.length > 0
            ? selectedCount === folderRows.length
            : selectedEmptyFolders.includes(path);
        check.indeterminate = folderRows.length > 0 &&
            selectedCount > 0 &&
            selectedCount < folderRows.length;
    }});
}}


function applyFilter() {{
    syncSelectionOrder();
    syncFolderChecks();

    layer.innerHTML = "";
    frozenLabels.innerHTML = "";

    let usedCols = new Set();

    selectionOrder.forEach(row => {{
        eventData
            .filter(e => e.row === row)
            .forEach(e => {{
                for (let c = e.start; c <= e.end; c++) {{
                    usedCols.add(c);
                }}
            }});
    }});

    const compactCols = Array.from(usedCols).sort((a, b) => a - b);
    const colMap = {{}};

    compactCols.forEach((c, i) => {{
        colMap[c] = i + 1;
    }});

    selectionOrder.forEach((row, index) => {{
        const rawRowEvents = eventData.filter(e => e.row === row);
        if (rawRowEvents.length === 0) return;
        const rowEvents = rawRowEvents;
if (rowEvents.length === 0) return;

        const color = rowEvents[0].color;
        const yBase = {TOP_PAD} + index * {SELECTED_LANE_H};

        const laneLabel = document.createElement("div");
        laneLabel.className = "graphLaneLabel";
        laneLabel.style.top = (yBase + 9) + "px";
        laneLabel.style.setProperty("--c", color);
        laneLabel.textContent = rowLabels[row];
        frozenLabels.appendChild(laneLabel);

        rowEvents.forEach(e => {{
            const mappedStart = colMap[e.start];
            const mappedEnd = colMap[e.end];

            if (!mappedStart || !mappedEnd) return;

            const x1 = {LEFT_PAD} + (mappedStart - 1) * {CELL_W};
            const x2 = {LEFT_PAD} + mappedEnd * {CELL_W};
            const w = Math.max(80, x2 - x1);

            const ev = document.createElement("div");
            ev.className = "event";
            ev.style.left = x1 + "px";
            ev.style.top = (yBase + 9) + "px";
            ev.style.width = w + "px";
            ev.style.setProperty("--c", color);
            ev.title = e.name + " | fila " + e.row + " | col " + e.start + "–" + e.end;
            ev.dataset.eventId = stableEventId(e);

            const nameSpan = document.createElement("span");
            nameSpan.className = "eventMainText";
            nameSpan.textContent = e.name;
            ev.appendChild(nameSpan);

            const meta = getEventMetaStore()[stableEventId(e)] || {{}};
            if (meta.place) {{
                const placeTag = document.createElement("span");
                placeTag.className = "eventPlaceTag";
                placeTag.textContent = String(meta.place).slice(0, 21);
                placeTag.title = meta.place;
                ev.appendChild(placeTag);
            }}

            if (meta.who) {{
                const whoTag = document.createElement("span");
                whoTag.className = "eventWhoTag";
                whoTag.textContent = String(meta.who).slice(0, 21);
                whoTag.title = meta.who;
                ev.appendChild(whoTag);
            }}

            layer.appendChild(ev);
        }});
    }});

    baseW = {LEFT_PAD} + Math.max(compactCols.length, 1) * {CELL_W} + 800;
    baseH = {TOP_PAD} + Math.max(selectionOrder.length, 1) * {SELECTED_LANE_H} + 500;

    canvas.style.width = baseW + "px";
    canvas.style.height = baseH + "px";
    spacer.style.width = (baseW * zoom) + "px";
    spacer.style.height = (baseH * zoom) + "px";

    document.getElementById("axisLine").style.width =
        (Math.max(compactCols.length, 1) * {CELL_W}) + "px";

    renderAxisCompact(colMap);
}}

function renderAxisCompact(colMap) {{
    ticks.innerHTML = "";

    axisData.forEach(t => {{
        const mapped = colMap[t.start] || colMap[t.end];

        if (!mapped) return;

        const tick = document.createElement("div");
        tick.className = "yearTick";
        tick.style.left = ({LEFT_PAD} + (mapped - 1) * {CELL_W}) + "px";

        const label = document.createElement("div");
        label.className = "yearLabel";
        label.textContent = t.name;

        tick.appendChild(label);
        ticks.appendChild(tick);
    }});
}}

function applyZoom() {{
    canvas.style.transform = "scale(" + zoom + ")";
    spacer.style.width = (baseW * zoom) + "px";
    spacer.style.height = (baseH * zoom) + "px";
    label.innerText = "Zoom: " + Math.round(zoom * 100) + "%";
}}

function zoomIn() {{}}
function zoomOut() {{}}
function resetZoom() {{}}
function panorama() {{}}

function selectNone() {{
    document.querySelectorAll(".graphCheck").forEach(x => x.checked = false);
    selectionOrder = [];
    applyFilter();
    applyZoom();
}}

document.querySelectorAll(".graphCheck").forEach(x => {{
    x.addEventListener("change", function() {{
        if (editMode) {{
            renderFolderTree();
            return;
        }}

        applyFilter();
        applyZoom();
    }});
}});

function yearForEvent(e) {{
    let best = null;
    let bestDistance = Infinity;

    axisData.forEach(t => {{
        const center = (t.start + t.end) / 2;
        const d = Math.abs(center - e.start);

        if (d < bestDistance) {{
            bestDistance = d;
            best = t.name;
        }}
    }});

    return best || "?";
}}

function buildGraphDetails() {{
    document.querySelectorAll(".graphOption").forEach(label => {{
        const check = label.querySelector(".graphCheck");
        if (!check) return;

        const row = check.value;
        const rowEvents = eventData.filter(e => e.row === row);
const details = document.createElement("div");
        details.className = "graphDetails";

        const ul = document.createElement("ul");

        rowEvents.forEach(e => {{
            const li = document.createElement("li");
            li.textContent = e.name + " (" + yearForEvent(e) + ")";
            ul.appendChild(li);
        }});

        details.appendChild(ul);
        label.insertAdjacentElement("afterend", details);

        const toggle = label.querySelector(".toggleItems");

        if (toggle) {{
            toggle.textContent = "▸ " + rowEvents.length;
            toggle.addEventListener("click", function(ev) {{
                ev.preventDefault();
                ev.stopPropagation();

                const visible = details.style.display === "block";
                details.style.display = visible ? "none" : "block";
                details.dataset.open = visible ? "0" : "1";
                toggle.textContent = (visible ? "▸ " : "▾ ") + rowEvents.length;
            }});
        }}
    }});
}}

const viewport = document.getElementById("viewport");

viewport.addEventListener("scroll", function() {{
    frozenLabels.style.transform = "translateY(" + (-viewport.scrollTop) + "px)";
}});

viewport.addEventListener("wheel", function(e) {{
    e.preventDefault();
    viewport.scrollLeft += e.deltaY + e.deltaX;
}}, {{ passive: false }});

buildGraphDetails();

const graphSearch = document.getElementById("graphSearch");
const clearSearch = document.getElementById("clearSearch");
const folderEdit = document.getElementById("folderEdit");
const newFolder = document.getElementById("newFolder");
const newGraph = document.getElementById("newGraph");
const moveSelected = document.getElementById("moveSelected");

folderEdit.addEventListener("click", toggleFolderTools);
newFolder.addEventListener("click", createFolder);
if (newGraph) {{
    newGraph.addEventListener("click", createGraph);
}}
moveSelected.addEventListener("click", moveSelectedToFolder);

const deleteSelectedGraphsButton = document.getElementById("deleteSelectedGraphs");
if (deleteSelectedGraphsButton) {{
    deleteSelectedGraphsButton.addEventListener("click", deleteSelectedGraphs);
}}

clearSearch.addEventListener("click", function() {{
    graphSearch.value = "";
    graphSearch.dispatchEvent(new Event("input"));
    graphSearch.focus();
}});

let graphSearchTimer = null;

graphSearch.addEventListener("input", function() {{
    clearTimeout(graphSearchTimer);

    graphSearchTimer = setTimeout(function() {{
        const q = graphSearch.value.trim().toLowerCase();

        renderFolderTree();

        document.querySelectorAll(".graphOption").forEach(label => {{
            const text = label.innerText.toLowerCase();
            const details = label.nextElementSibling;
            const show = !q || text.includes(q);

            label.style.display = show ? "flex" : "none";

            if (details && details.classList.contains("graphDetails")) {{
                details.style.display = show && details.dataset.open === "1" ? "block" : "none";
            }}
        }});

        syncFolderChecks();
    }}, 120);
}});

document.body.classList.toggle("editMode", editMode);
applyFilter();
applyZoom();
</script>



</body>
</html>
"""

Path("outputs").mkdir(exist_ok=True)

with open(OUTPUT, "w", encoding="utf-8") as f:
    f.write(html)

print("Visualización generada:", OUTPUT)

# --- PATCH FICHA TECNICA POST-HTML ---
TECH_CARD_PATCH = r'''
<style>
#techCard {
    position: fixed;
    right: 18px;
    top: 18px;
    width: 390px;
    height: calc(100vh - 36px);
    z-index: 9999;
    background: #111827;
    color: #f9fafb;
    border: 1px solid #374151;
    border-radius: 10px;
    box-shadow: 0 12px 34px #0009;
    display: none;
    overflow: hidden;
}
#techCardHeader {
    height: 36px;
    display: flex;
    align-items: center;
    padding: 0 10px;
    background: #0b1220;
    border-bottom: 1px solid #374151;
    font-size: 12px;
    font-weight: 700;
}
#techCardTitle { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
#techCardClose { width: 26px; height: 26px; padding: 0; border-radius: 5px; }
#techCardBody { padding: 10px; overflow: auto; max-height: calc(100vh - 86px); }
.techField { margin-bottom: 8px; }
.techField label {
    display: block;
    margin-bottom: 3px;
    color: #9ca3af;
    font-size: 10px;
    font-weight: 700;
}
.techField input, .techField textarea {
    width: 100%;
    box-sizing: border-box;
    border: 1px solid #374151;
    border-radius: 6px;
    background: #030712;
    color: #f9fafb;
    padding: 7px;
    font-size: 12px;
}
.techField textarea { min-height: 116px; resize: vertical; }
#techGraphList {
    max-height: 285px;
    overflow: auto;
    background: #030712;
    border: 1px solid #374151;
    border-radius: 6px;
    padding: 7px 9px;
    font-size: 11px;
    line-height: 1.35;
}
.techActions { display: flex; gap: 8px; margin-top: 8px; }
.techActions button { flex: 1; height: 30px; font-size: 11px; }
.event.selectedEvent {
    outline: 2px solid #111827;
    box-shadow: 0 0 0 3px #facc15;
    z-index: 260;
}

#deleteSelectedGraphs {{
    height: 26px;
    border: 1px solid #444;
    border-radius: 5px;
    background: #171717;
    color: #e5e7eb;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
}}

#deleteSelectedGraphs:hover {{
    background: #262626;
    border-color: #666;
}}


body.editMode .graphOption {{
    cursor: pointer;
}}

body.editMode .graphOption.graphSelected {{
    background: rgba(37, 99, 235, 0.35) !important;
    outline: 1px solid #60a5fa !important;
}}

body.editMode .graphCheck,
body.editMode .folderCheck {{
    width: 14px !important;
    height: 14px !important;
    accent-color: #3b82f6 !important;
    cursor: pointer !important;
}}

#deleteSelectedGraphs {{
    height: 26px;
    border: 1px solid #444;
    border-radius: 5px;
    background: #171717;
    color: #e5e7eb;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
}}

#deleteSelectedGraphs:hover {{
    background: #262626;
    border-color: #666;
}}

</style>

<div id="techCard">
    <div id="techCardHeader">
        <span id="techCardTitle">Technical Card</span>
        <button id="techCardClose" type="button">×</button>
    </div>
    <div id="techCardBody">
        <div class="techField"><label>GRAPH GROUP</label><input id="techGroup" type="text"></div>
        <div class="techField"><label>NAME</label><input id="techName" type="text"></div>
        <div class="techField"><label>DATE — YEAR - MONTH - DAY</label><input id="techDate" type="text"></div>
        <div class="techField"><label>PLACE — COUNTRY - CITY</label><input id="techPlace" type="text"></div>
        <div class="techField"><label>WHO</label><input id="techWho" type="text"></div>
        <div class="techField"><label>BRIEF DESCRIPTION</label><textarea id="techDescription"></textarea></div>
        <div class="techField"><label>SOURCE LINK</label><input id="techSourceLink" type="url" placeholder="https://..."></div>
        <div class="techField"><label>GRAPH LIST SUMMARY</label><div id="techGraphList"></div></div>
        <div class="techField"><label>API STATUS</label><textarea id="techApi" readonly style="min-height:86px"></textarea></div>
        <div class="techActions">
            <button id="techSave" type="button">Save card</button>
            <button id="techExport" type="button">Export JSON</button>
        </div>
        <div id="liveApiSensor" class="liveApiSensor">
            <div class="liveApiSensorTitle">LIVE API SENSOR</div>
            <div id="liveApiSensorState" class="liveApiSensorState">NO GRAPH SELECTED</div>
            <div id="liveApiSensorDetail" class="liveApiSensorDetail">Open a graph event to inspect API runtime state.</div>
        </div>
    </div>
</div>

<script>
const eventMetaKey = "grafoD_eventMeta_v1";
let activeEventObj = null;
let activeEventId = null;

function stableEventId(e) {
    return e.row + ":" + e.start + ":" + e.end + ":" + e.name;
}

let grafoDEventMetaStore = null;

function getEventMetaStore() {
    if (!grafoDEventMetaStore) grafoDEventMetaStore = {};
    return grafoDEventMetaStore;
}

function loadEventMetaStoreFromDatabase() {
    fetch("/api/db")
        .then(r => r.json())
        .then(db => {
            grafoDEventMetaStore =
                db && db.ui_state && db.ui_state.event_meta && typeof db.ui_state.event_meta === "object"
                    ? db.ui_state.event_meta
                    : {};
        })
        .catch(() => {});
}

function saveEventMetaStore(store) {
    grafoDEventMetaStore = store && typeof store === "object" ? store : {};
    fetch("/api/ui-state", {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify({key: "event_meta", value: grafoDEventMetaStore})
    }).catch(() => {});
}

loadEventMetaStoreFromDatabase();

function parseYearFromDate(text) {
    const m = String(text || "").match(/-?\d{1,5}/);
    return m ? String(parseInt(m[0], 10)) : "";
}

function findAxisByYear(yearText) {
    const y = parseYearFromDate(yearText);
    if (!y) return null;
    return axisData.find(t => String(t.name).replace(".0", "") === y) || null;
}

function currentFolderForRow(row) {
    const state = getFolderState();
    return state.graphFolder[String(row)] || "Uncategorized";
}

function setFolderForRow(row, folder) {
    const state = getFolderState();
    const f = String(folder || "").trim() || "Uncategorized";
    if (!state.folders.includes(f)) {
        state.folders.push(f);
        state.open[f] = true;
    }
    state.graphFolder[String(row)] = f;
    saveFolderState(state);
}

function openTechCardByEvent(e, domEl) {
    activeEventObj = e;
    activeEventId = stableEventId(e);

    document.querySelectorAll(".event").forEach(x => x.classList.remove("selectedEvent"));
    if (domEl) domEl.classList.add("selectedEvent");

    const store = getEventMetaStore();
    const saved = store[activeEventId] || {};

    document.getElementById("techCard").style.display = "block";
    document.getElementById("techCardTitle").textContent = saved.name || e.name || "Technical Card";
    document.getElementById("techGroup").value = currentFolderForRow(e.row);
    document.getElementById("techName").value = saved.name || e.name || "";
    document.getElementById("techDate").value = saved.date || e.date || yearForEvent(e);
    document.getElementById("techPlace").value = saved.place || e.place || "";
    document.getElementById("techWho").value = saved.who || e.who || "";
    document.getElementById("techDescription").value = saved.description || e.description || "";
    document.getElementById("techSourceLink").value = saved.source_link || e.source_link || e.source || "";
    document.getElementById("techApi").value = saved.api || "";

    const list = document.getElementById("techGraphList");
    list.innerHTML = "";

    eventData.filter(x => x.row === e.row).forEach(x => {
        const div = document.createElement("div");
        if (stableEventId(x) === activeEventId) {
            div.innerHTML = "<strong>" + x.name + " (" + yearForEvent(x) + ")</strong>";
        } else {
            div.textContent = x.name + " (" + yearForEvent(x) + ")";
        }
        list.appendChild(div);
    });
}

function saveTechCard() {
    if (!activeEventObj) return;

    const oldId = activeEventId;
    const name = document.getElementById("techName").value.trim();
    const date = document.getElementById("techDate").value.trim();
    const group = document.getElementById("techGroup").value.trim();

    if (name) activeEventObj.name = name;

    const axis = findAxisByYear(date);
    if (axis) {
        activeEventObj.start = axis.start;
        activeEventObj.end = axis.end;
    } else if (date) {
        alert("Year not found on the timeline axis. The card is saved, but the box is not repositioned.");
    }

    if (group) setFolderForRow(activeEventObj.row, group);

    activeEventId = stableEventId(activeEventObj);

    const store = getEventMetaStore();
    delete store[oldId];
    store[activeEventId] = {
        group: currentFolderForRow(activeEventObj.row),
        name: activeEventObj.name,
        date: date || yearForEvent(activeEventObj),
        place: document.getElementById("techPlace").value.trim(),
        who: document.getElementById("techWho").value.trim(),
        description: document.getElementById("techDescription").value.trim(),
        source_link: document.getElementById("techSourceLink").value.trim(),
        api: document.getElementById("techApi").value.trim()
    };
    saveEventMetaStore(store);

    renderFolderTree();
    applyFilter();
    applyZoom();
    buildGraphDetails();
    openTechCardByEvent(activeEventObj, null);
}

function closeTechCard() {
    activeEventObj = null;
    activeEventId = null;
    document.getElementById("techCard").style.display = "none";
    document.querySelectorAll(".event").forEach(x => x.classList.remove("selectedEvent"));
}

function exportTechCards() {
    const payload = {
        version: "grafoD_eventMeta_v1",
        exportedAt: new Date().toISOString(),
        metadata: getEventMetaStore(),
        events: eventData
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json"});
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = "grafoD_event_cards.json";
    document.body.appendChild(a);
    a.click();
    a.remove();
}

document.addEventListener("click", function(ev) {
    const box = ev.target.closest(".event");
    if (!box) return;

    const id = box.dataset.eventId || "";
    const match = eventData.find(e => stableEventId(e) === id);

    if (match) {
        ev.stopPropagation();
        openTechCardByEvent(match, box);
    }
}, true);

document.getElementById("techSave").addEventListener("click", saveTechCard);
document.getElementById("techExport").addEventListener("click", exportTechCards);
document.getElementById("techCardClose").addEventListener("click", closeTechCard);
</script>
'''

out = Path(OUTPUT)
html2 = out.read_text(encoding="utf-8")
if "grafoD_eventMeta_v1" not in html2:
    html2 = html2.replace("</body>", TECH_CARD_PATCH + "\n</body>")
    out.write_text(html2, encoding="utf-8")
    print("Technical Card inyectada en:", OUTPUT)
else:
    print("Technical Card ya estaba inyectada en:", OUTPUT)
# --- FIN PATCH FICHA TECNICA POST-HTML ---

# --- SAFE PATCH: FOLDER TECHNICAL CARD POST-HTML ---
SAFE_FOLDER_CARD_PATCH = r'''
<style>
.folderInfoIcon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 16px;
    height: 16px;
    margin-left: auto;
    margin-right: 6px;
    border-radius: 50%;
    font-size: 10px;
    font-weight: 700;
    color: #9ca3af;
    background: #111827;
    border: 1px solid #374151;
    box-shadow: 0 1px 6px #0009;
    cursor: pointer;
    opacity: 0.75;
    flex: 0 0 auto;
}

.folderInfoIcon:hover,
.folderInfoIcon.active {
    color: #facc15;
    border-color: #facc15;
    opacity: 1;
}

#folderTechCard {
    position: fixed;
    right: 18px;
    top: 18px;
    width: 390px;
    height: calc(100vh - 36px);
    z-index: 10002;
    background: #111827;
    color: #f9fafb;
    border: 1px solid #374151;
    border-radius: 10px;
    box-shadow: 0 12px 34px #0009;
    display: none;
    overflow: hidden;
}

#folderTechCardHeader {
    height: 36px;
    display: flex;
    align-items: center;
    padding: 0 10px;
    background: #0b1220;
    border-bottom: 1px solid #374151;
    font-size: 12px;
    font-weight: 700;
}

#folderTechCardTitle {
    flex: 1;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}

#folderTechCardClose {
    width: 26px;
    height: 26px;
    padding: 0;
    border-radius: 5px;
}

#folderTechCardBody {
    padding: 10px;
    overflow: auto;
    max-height: calc(100vh - 86px);
}

.folderTechField {
    margin-bottom: 8px;
}

.folderTechField label {
    display: block;
    margin-bottom: 3px;
    color: #9ca3af;
    font-size: 10px;
    font-weight: 700;
}

.folderTechField input,
.folderTechField textarea {
    width: 100%;
    box-sizing: border-box;
    border: 1px solid #374151;
    border-radius: 6px;
    background: #030712;
    color: #f9fafb;
    padding: 7px;
    font-size: 12px;
}

#folderDescription {
    min-height: 116px;
    resize: vertical;
}

#folderApis {
    min-height: 180px;
    resize: vertical;
}

.folderTechActions {
    display: flex;
    gap: 8px;
    margin-top: 8px;
}

.folderTechActions button {
    flex: 1;
    height: 30px;
    font-size: 11px;
}

#deleteSelectedGraphs {{
    height: 26px;
    border: 1px solid #444;
    border-radius: 5px;
    background: #171717;
    color: #e5e7eb;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
}}

#deleteSelectedGraphs:hover {{
    background: #262626;
    border-color: #666;
}}


body.editMode .graphOption {{
    cursor: pointer;
}}

body.editMode .graphOption.graphSelected {{
    background: rgba(37, 99, 235, 0.35) !important;
    outline: 1px solid #60a5fa !important;
}}

body.editMode .graphCheck,
body.editMode .folderCheck {{
    width: 14px !important;
    height: 14px !important;
    accent-color: #3b82f6 !important;
    cursor: pointer !important;
}}

#deleteSelectedGraphs {{
    height: 26px;
    border: 1px solid #444;
    border-radius: 5px;
    background: #171717;
    color: #e5e7eb;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
}}

#deleteSelectedGraphs:hover {{
    background: #262626;
    border-color: #666;
}}

</style>

<div id="folderTechCard">
    <div id="folderTechCardHeader">
        <span id="folderTechCardTitle">Graph Technical Card</span>
        <button id="folderTechCardClose" type="button">×</button>
    </div>
    <div id="folderTechCardBody">
        <div class="folderTechField">
            <label>GRAPH NAME</label>
            <input id="folderNameField" type="text">
        </div>

        <div class="folderTechField">
            <label>GRAPH DESCRIPTION — WHAT THIS GRAPH ANSWERS</label>
            <textarea id="folderDescription"></textarea>
        </div>

        <div class="folderTechField">
            <label>ACTIVE APIs</label>
            <textarea id="folderApis"></textarea>
        </div>

        <div class="folderTechActions">
            <button id="folderTechSave" type="button">Save graph card</button>
            <button id="folderTechExport" type="button">Export JSON</button>
        </div>
    </div>
</div>

<script>
(function() {
    const folderMetaKey = "grafoD_folderMeta_v1";
    let activeFolderPath = null;

    let grafoDFolderMetaStore = null;

    function getFolderMetaStore() {
        if (!grafoDFolderMetaStore) grafoDFolderMetaStore = {};
        return grafoDFolderMetaStore;
    }

    function loadFolderMetaStoreFromDatabase() {
        fetch("/api/db")
            .then(r => r.json())
            .then(db => {
                grafoDFolderMetaStore =
                    db && db.ui_state && db.ui_state.folder_meta && typeof db.ui_state.folder_meta === "object"
                        ? db.ui_state.folder_meta
                        : {};
            })
            .catch(() => {});
    }

    function saveFolderMetaStore(store) {
        grafoDFolderMetaStore = store && typeof store === "object" ? store : {};
        fetch("/api/ui-state", {
            method: "POST",
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify({key: "folder_meta", value: grafoDFolderMetaStore})
        }).catch(() => {});
    }

    loadFolderMetaStoreFromDatabase();

    function closeEventCardIfOpen() {
        const eventCard = document.getElementById("techCard");
        if (eventCard) eventCard.style.display = "none";
    }

    function closeFolderCard() {
        activeFolderPath = null;
        document.getElementById("folderTechCard").style.display = "none";
        document.querySelectorAll(".folderInfoIcon").forEach(x => x.classList.remove("active"));
    }

    function openFolderCard(folderPath, icon) {
        const card = document.getElementById("folderTechCard");

        if (activeFolderPath === folderPath && card.style.display === "block") {
            closeFolderCard();
            return;
        }

        closeEventCardIfOpen();

        activeFolderPath = folderPath;

        document.querySelectorAll(".folderInfoIcon").forEach(x => x.classList.remove("active"));
        if (icon) icon.classList.add("active");

        const store = getFolderMetaStore();
        const saved = store[folderPath] || {};

        document.getElementById("folderTechCard").style.display = "block";
        document.getElementById("folderTechCardTitle").textContent = saved.name || folderPath;
        document.getElementById("folderNameField").value = saved.name || folderPath;
        document.getElementById("folderDescription").value = saved.description || "";
        document.getElementById("folderApis").value = saved.apis || "";
    }

    function saveFolderCard() {
        if (!activeFolderPath) return;

        const store = getFolderMetaStore();
        store[activeFolderPath] = {
            name: document.getElementById("folderNameField").value.trim() || activeFolderPath,
            description: document.getElementById("folderDescription").value.trim(),
            apis: document.getElementById("folderApis").value.trim()
        };

        saveFolderMetaStore(store);
        document.getElementById("folderTechCardTitle").textContent = store[activeFolderPath].name;
    }

    function exportFolderCards() {
        const payload = {
            version: "grafoD_folderMeta_v1",
            exportedAt: new Date().toISOString(),
            metadata: getFolderMetaStore()
        };

        const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json"});
        const a = document.createElement("a");
        a.href = URL.createObjectURL(blob);
        a.download = "grafoD_graph_folder_cards.json";
        document.body.appendChild(a);
        a.click();
        a.remove();
    }

    function injectFolderIcons() {
        document.querySelectorAll(".folderHeader").forEach(header => {
            if (header.dataset.folderIconReady === "1") return;

            const folderPath = header.dataset.folder;
            const count = header.querySelector(".folderCount");
            if (!folderPath || !count) return;

            const icon = document.createElement("span");
            icon.className = "folderInfoIcon";
            icon.textContent = "i";
            icon.title = "Open / close graph technical card";
            icon.dataset.folder = folderPath;

            icon.addEventListener("click", function(ev) {
                ev.preventDefault();
                ev.stopPropagation();
                openFolderCard(folderPath, icon);
            });

            header.insertBefore(icon, count);
            header.dataset.folderIconReady = "1";
        });
    }

    const originalRenderFolderTree = window.renderFolderTree;
    if (typeof originalRenderFolderTree === "function") {
        window.renderFolderTree = function() {
            originalRenderFolderTree();
            injectFolderIcons();
        };
    }

    document.getElementById("folderTechSave").addEventListener("click", saveFolderCard);
    document.getElementById("folderTechExport").addEventListener("click", exportFolderCards);
    document.getElementById("folderTechCardClose").addEventListener("click", closeFolderCard);

    injectFolderIcons();
})();
</script>
'''

out = Path(OUTPUT)
html2 = out.read_text(encoding="utf-8")

if "grafoD_folderMeta_v1" not in html2:
    html2 = html2.replace("</body>", SAFE_FOLDER_CARD_PATCH + "\n</body>")
    out.write_text(html2, encoding="utf-8")
    print("Safe folder technical card injected in:", OUTPUT)
else:
    print("Folder technical card already exists in:", OUTPUT)
# --- END SAFE PATCH ---

# --- HARD FIX: SEARCH INPUT WITHOUT FREEZE POST-HTML ---
SEARCH_HARD_FIX_PATCH = r'''
<script>
(function() {
    const MARKER = "grafoD_search_hard_fix_v1";

    function normalizeText(s) {
        return String(s || "")
            .toLowerCase()
            .normalize("NFD")
            .replace(/[\u0300-\u036f]/g, "");
    }

    function hardFixSearch() {
        const oldInput =
            document.getElementById("graphSearch") ||
            document.querySelector('input[placeholder="Search"]');

        if (!oldInput || oldInput.dataset.searchHardFixed === "1") return;

        const parent = oldInput.parentElement;
        const newInput = oldInput.cloneNode(true);
        newInput.value = "";
        newInput.dataset.searchHardFixed = "1";
        oldInput.replaceWith(newInput);

        const clearButton =
            parent.querySelector("button") ||
            parent.querySelector(".searchClear") ||
            parent.querySelector("[data-clear-search]");

        let timer = null;

        function applySearchNow() {
            const q = normalizeText(newInput.value.trim());

            const graphOptions = Array.from(document.querySelectorAll(".graphOption"));
            const folderHeaders = Array.from(document.querySelectorAll(".folderHeader"));

            graphOptions.forEach(option => {
                const text = normalizeText(option.textContent);
                const show = !q || text.includes(q);
                option.style.display = show ? "" : "none";

                const details = option.nextElementSibling;
                if (details && details.classList && details.classList.contains("graphDetails")) {
                    if (!show) details.style.display = "none";
                }
            });

            folderHeaders.forEach(header => {
                const folderText = normalizeText(header.textContent);
                let showFolder = !q || folderText.includes(q);

                let node = header.nextElementSibling;
                while (node && !node.classList.contains("folderHeader")) {
                    if (
                        node.classList &&
                        node.classList.contains("graphOption") &&
                        node.style.display !== "none"
                    ) {
                        showFolder = true;
                        break;
                    }
                    node = node.nextElementSibling;
                }

                header.style.display = showFolder ? "" : "none";
            });
        }

        function scheduleSearch() {
            clearTimeout(timer);
            timer = setTimeout(() => {
                requestAnimationFrame(applySearchNow);
            }, 180);
        }

        newInput.addEventListener("input", scheduleSearch);
        newInput.addEventListener("keydown", function(e) {
            if (e.key === "Escape") {
                newInput.value = "";
                applySearchNow();
            }
        });

        if (clearButton && clearButton.dataset.searchClearHardFixed !== "1") {
            const newClear = clearButton.cloneNode(true);
            newClear.dataset.searchClearHardFixed = "1";
            clearButton.replaceWith(newClear);

            newClear.addEventListener("click", function(e) {
                e.preventDefault();
                e.stopPropagation();
                newInput.value = "";
                applySearchNow();
                newInput.focus();
            });
        }
    }

    document.addEventListener("DOMContentLoaded", hardFixSearch);
    setTimeout(hardFixSearch, 300);

    window[MARKER] = true;
})();
</script>
'''

out = Path(OUTPUT)
html2 = out.read_text(encoding="utf-8")

if "grafoD_search_hard_fix_v1" not in html2:
    html2 = html2.replace("</body>", SEARCH_HARD_FIX_PATCH + "\n</body>")
    out.write_text(html2, encoding="utf-8")
    print("Search hard fix injected in:", OUTPUT)
else:
    print("Search hard fix already exists in:", OUTPUT)
# --- END HARD FIX SEARCH ---

# --- GRAFO D PATCH: graph-set technical cards, injected after render ---
from pathlib import Path as _GrafoDPath

_gd_html = _GrafoDPath("outputs/timeline_excel_like.html")
if _gd_html.exists():
    _gd_s = _gd_html.read_text(encoding="utf-8")

    if "grafoD_graph_set_cards_v1" not in _gd_s:
        _gd_css = """
<style>
.grafoDGraphSetInfoBtn {
  width:17px;
  height:17px;
  min-width:17px;
  border-radius:50%;
  border:1px solid #334155;
  background:#111827;
  color:#94a3b8;
  font-size:11px;
  line-height:15px;
  font-weight:700;
  cursor:pointer;
  text-align:center;
  padding:0;
  margin-left:5px;
}
.grafoDGraphSetInfoBtn:hover,
.grafoDGraphSetInfoBtn.active {
  color:#facc15;
  border-color:#facc15;
  background:#1f2937;
}
.grafoDGraphSetCard {
  position:fixed;
  top:74px;
  right:14px;
  width:380px;
  height:calc(100vh - 98px);
  background:#111827;
  color:#e5e7eb;
  border:1px solid #334155;
  border-radius:10px;
  box-shadow:0 18px 45px rgba(0,0,0,.45);
  z-index:9999;
  padding:12px;
  box-sizing:border-box;
  display:none;
}
.grafoDGraphSetCard.open { display:block; }
.grafoDGraphSetCard h3 { margin:0 0 12px 0;font-size:13px; }
.grafoDGraphSetCard label {
  display:block;
  margin-top:10px;
  margin-bottom:4px;
  color:#9ca3af;
  font-size:10px;
  font-weight:700;
  text-transform:uppercase;
}
.grafoDGraphSetCard input,
.grafoDGraphSetCard textarea {
  width:100%;
  box-sizing:border-box;
  background:#020617;
  color:#e5e7eb;
  border:1px solid #374151;
  border-radius:5px;
  padding:8px;
  font-size:12px;
}
.grafoDGraphSetCard textarea {
  min-height:110px;
  resize:vertical;
}
.grafoDGraphSetCard .actions {
  display:flex;
  gap:8px;
  margin-top:10px;
}
.grafoDGraphSetCard button {
  flex:1;
  height:30px;
  background:#1f2937;
  color:#e5e7eb;
  border:1px solid #374151;
  border-radius:5px;
  cursor:pointer;
}
</style>
"""

        _gd_js = """
<script>
(function(){
const STORE="grafoD_graph_set_cards_v1";
let activeGraphSet=null;

function clean(v){return (v||"").replace(/\\s+/g," ").trim();}
let grafoDGraphSetCardsStore = null;

function load(){
  if(!grafoDGraphSetCardsStore) grafoDGraphSetCardsStore = {};
  return grafoDGraphSetCardsStore;
}

function loadGraphSetCardsFromDatabase(){
  fetch("/api/db")
    .then(r=>r.json())
    .then(db=>{
      grafoDGraphSetCardsStore =
        db && db.ui_state && db.ui_state.graph_set_cards && typeof db.ui_state.graph_set_cards === "object"
          ? db.ui_state.graph_set_cards
          : {};
    })
    .catch(()=>{});
}

function save(o){
  grafoDGraphSetCardsStore = o && typeof o === "object" ? o : {};
  fetch("/api/ui-state", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({key: "graph_set_cards", value: grafoDGraphSetCardsStore})
  }).catch(() => {});
}

loadGraphSetCardsFromDatabase();

function graphSetName(row){
  const clone=row.cloneNode(true);
  clone.querySelectorAll("button,input,.grafoDGraphSetInfoBtn").forEach(x=>x.remove());
  let t=clean(clone.innerText||clone.textContent||"");
  t=t.replace(/[▸▾]/g," ");
  t=t.replace(/\\s+\\d+$/,"");
  return clean(t);
}

function makePanel(){
  if(document.getElementById("grafoDGraphSetCard")) return;

  const p=document.createElement("div");
  p.id="grafoDGraphSetCard";
  p.className="grafoDGraphSetCard";
  p.innerHTML=`
    <h3 id="graphSetCardTitle">Graph card</h3>

    <label>GRAPH NAME</label>
    <input id="graphSetNameField">

    <label>GRAPH DESCRIPTION — WHAT THIS GRAPH ANSWERS</label>
    <textarea id="graphSetDescription"></textarea>

    <label>ACTIVE APIs</label>
    <textarea id="graphSetApis"></textarea>

    <div class="actions">
      <button id="graphSetSave">Save graph card</button>
      <button id="graphSetExport">Export JSON</button>
    </div>
  `;
  document.body.appendChild(p);

  document.getElementById("graphSetSave").onclick=function(){
    if(!activeGraphSet) return;
    const db=load();
    db[activeGraphSet]={
      graph_name:document.getElementById("graphSetNameField").value,
      description:document.getElementById("graphSetDescription").value,
      active_apis:document.getElementById("graphSetApis").value
    };
    save(db);
  };

  document.getElementById("graphSetExport").onclick=function(){
    if(!activeGraphSet) return;
    alert(JSON.stringify((load()[activeGraphSet]||{}),null,2));
  };
}

function openGraphSetCard(name,btn){
  makePanel();

  document.querySelectorAll(".grafoDGraphSetInfoBtn").forEach(b=>b.classList.remove("active"));

  const p=document.getElementById("grafoDGraphSetCard");
  if(p.classList.contains("open") && activeGraphSet===name){
    p.classList.remove("open");
    activeGraphSet=null;
    return;
  }

  activeGraphSet=name;
  btn.classList.add("active");

  const rec=load()[name]||{};
  document.getElementById("graphSetCardTitle").textContent=name;
  document.getElementById("graphSetNameField").value=rec.graph_name||name;
  document.getElementById("graphSetDescription").value=rec.description||"";
  document.getElementById("graphSetApis").value=rec.active_apis||"";

  p.classList.add("open");
}

function install(){
  makePanel();

  document.querySelectorAll(".graphOption").forEach(row=>{
    if(row.querySelector(".grafoDGraphSetInfoBtn")) return;

    const name=graphSetName(row);
    if(!name) return;

    const btn=document.createElement("button");
    btn.type="button";
    btn.className="grafoDGraphSetInfoBtn";
    btn.textContent="i";
    btn.title="Open graph technical card";
    btn.onclick=function(ev){
      ev.preventDefault();
      ev.stopPropagation();
      openGraphSetCard(name,btn);
    };

    row.appendChild(btn);
  });
}

window.addEventListener("load",install);
setTimeout(install,500);
setInterval(install,1500);
})();
</script>
"""

        _gd_s = _gd_s.replace("</head>", _gd_css + "\n</head>")
        _gd_s = _gd_s.replace("</body>", _gd_js + "\n</body>")
        _gd_html.write_text(_gd_s, encoding="utf-8")
# --- END GRAFO D PATCH ---


# --- GENERIC API STATUS SENSOR PATCH ---
from pathlib import Path as _ApiPatchPath
_api_html = _ApiPatchPath("outputs/timeline_excel_like.html")
if _api_html.exists():
    _html = _api_html.read_text(encoding="utf-8")
    if "GRAFO_D_GENERIC_API_SENSOR_V1" not in _html:
        _sensor = """
<script id="GRAFO_D_GENERIC_API_SENSOR_V1">
async function grafoDApiStatusForGraph(row) {
    try {
        const r = await fetch("/api/db?v=" + Date.now());
        const db = await r.json();
        const apis = (db.api_registry || []).filter(a => a.target_graph === row);
        if (!apis.length) return "No active API registered for this graph.";
        return apis.map(a => [
            "API: " + (a.name || a.id || "?"),
            "Enabled: " + String(!!a.enabled),
            "Status: " + (a.status || "unknown"),
            "Method: " + (a.method || "?"),
            "Interval: " + (a.interval_seconds || "?") + " s",
            "Last run: " + (a.last_run_at || "never"),
            "Last status: " + (a.last_status || "unknown"),
            "Last latency: " + (a.last_latency_ms || "n/a") + " ms",
            "Endpoint: " + (a.endpoint || "?")
        ].join("\\n")).join("\\n\\n");
    } catch(e) {
        return "API sensor error: " + e;
    }
}

const _gdOpenTechCardOriginal = openTechCardByEvent;
openTechCardByEvent = async function(e, domEl) {
    _gdOpenTechCardOriginal(e, domEl);
    const apiBox = document.getElementById("techApi");
    if (apiBox) apiBox.value = await grafoDApiStatusForGraph(e.row);
};
</script>
"""
        _html = _html.replace("</body>", _sensor + "\n</body>")
        _api_html.write_text(_html, encoding="utf-8")
        print("Generic API status sensor injected.")

# --- LIVE API SENSOR PANEL SAFE PATCH V3 ---
from pathlib import Path as _LiveSensorPath

_live_html_path = _LiveSensorPath("outputs/timeline_excel_like.html")
if _live_html_path.exists():
    _live_html = _live_html_path.read_text(encoding="utf-8")

    if "GRAFO_D_LIVE_API_SENSOR_STYLE_V3" not in _live_html:
        _live_css = """
<style id="GRAFO_D_LIVE_API_SENSOR_STYLE_V3">
.liveApiSensor {
    margin-top: 12px;
    padding: 10px;
    border: 1px solid #374151;
    background: #0b1220;
    color: #e5e7eb;
    font-family: monospace;
    font-size: 12px;
}
.liveApiSensorTitle { font-size:10px; font-weight:700; color:#9ca3af; margin-bottom:6px; }
.liveApiSensorState { font-size:14px; font-weight:800; margin-bottom:6px; }
.liveApiSensorState.online { color:#22c55e; }
.liveApiSensorState.degraded { color:#f59e0b; }
.liveApiSensorState.offline { color:#ef4444; }
.liveApiSensorState.none { color:#9ca3af; }
.liveApiSensorDetail { white-space:pre-wrap; line-height:1.35; }
</style>
"""
        _live_html = _live_html.replace("</head>", _live_css + "\n</head>")

    if 'id="liveApiSensor"' not in _live_html:
        _live_panel = """
        <div id="liveApiSensor" class="liveApiSensor">
            <div class="liveApiSensorTitle">LIVE API SENSOR</div>
            <div id="liveApiSensorState" class="liveApiSensorState none">NO GRAPH SELECTED</div>
            <div id="liveApiSensorDetail" class="liveApiSensorDetail">Open a graph event to inspect API runtime state.</div>
        </div>
"""
        _live_html = _live_html.replace('</div>\n    </div>\n</div>', '</div>\n' + _live_panel + '    </div>\n</div>', 1)

    if "GRAFO_D_LIVE_API_SENSOR_V3" not in _live_html:
        _live_js = """
<script id="GRAFO_D_LIVE_API_SENSOR_V3">
let grafoDLiveApiSensorRow = null;
let grafoDLiveApiSensorTimer = null;

function grafoDApiRuntimeState(api) {
    const interval = Number(api.interval_seconds || 120);
    const last = api.last_run_at ? new Date(api.last_run_at) : null;
    if (!api.enabled) return {state:"OFFLINE", cls:"offline", reason:"API disabled"};
    if (!last || isNaN(last.getTime())) return {state:"OFFLINE", cls:"offline", reason:"No heartbeat"};
    const ageSec = Math.round((Date.now() - last.getTime()) / 1000);
    const maxAge = Math.max(interval * 2.5, interval + 60);
    if (String(api.last_status) !== "200") return {state:"ERROR", cls:"offline", reason:"HTTP " + api.last_status, ageSec};
    if (ageSec > maxAge) return {state:"OFFLINE", cls:"offline", reason:"Heartbeat expired", ageSec};
    if (Number(api.last_latency_ms || 0) > 8000) return {state:"DEGRADED", cls:"degraded", reason:"High latency", ageSec};
    return {state:"ONLINE", cls:"online", reason:"Heartbeat valid", ageSec};
}

async function grafoDUpdateLiveApiSensor(row) {
    const stateBox = document.getElementById("liveApiSensorState");
    const detailBox = document.getElementById("liveApiSensorDetail");
    if (!stateBox || !detailBox) return;

    try {
        const r = await fetch("/api/db?v=" + Date.now());
        const db = await r.json();
        const apis = (db.api_registry || []).filter(a => a.target_graph === row);

        if (!apis.length) {
            stateBox.className = "liveApiSensorState none";
            stateBox.textContent = "NO API REGISTERED";
            detailBox.textContent = "Graph row: " + row;
            return;
        }

        const api = apis[0];
        const rt = grafoDApiRuntimeState(api);
        stateBox.className = "liveApiSensorState " + rt.cls;
        stateBox.textContent = "● " + rt.state;
        detailBox.textContent = [
            "Graph row: " + row,
            "API: " + (api.name || api.id || "?"),
            "Enabled: " + String(!!api.enabled),
            "Connector status: " + (api.status || "unknown"),
            "Runtime reason: " + rt.reason,
            "Last run: " + (api.last_run_at || "never"),
            "Age: " + (rt.ageSec ?? "n/a") + " s",
            "Interval: " + (api.interval_seconds || "?") + " s",
            "HTTP status: " + (api.last_status || "unknown"),
            "Latency: " + (api.last_latency_ms || "n/a") + " ms"
        ].join("\\n");
    } catch(e) {
        stateBox.className = "liveApiSensorState offline";
        stateBox.textContent = "● SENSOR ERROR";
        detailBox.textContent = String(e);
    }
}

const _openTechLiveSensor = openTechCardByEvent;
openTechCardByEvent = async function(e, domEl) {
    await _openTechLiveSensor(e, domEl);
    grafoDLiveApiSensorRow = e.row;
    if (grafoDLiveApiSensorTimer) clearInterval(grafoDLiveApiSensorTimer);
    grafoDUpdateLiveApiSensor(e.row);
    grafoDLiveApiSensorTimer = setInterval(() => grafoDUpdateLiveApiSensor(grafoDLiveApiSensorRow), 5000);
};
</script>
"""
        _live_html = _live_html.replace("</body>", _live_js + "\n</body>")

    _live_html_path.write_text(_live_html, encoding="utf-8")
    print("Live API sensor V3 injected.")


# --- V47 MANUAL ELEMENT CONTEXT MENU PATCH ---
# Adds right-click manual element creation on graph names.
# Graph creation remains container-only. Elements are created only by explicit user save.

from pathlib import Path as _V47Path

def _v47_inject_manual_element_context_menu():
    html_path = _V47Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    token = "V47_MANUAL_ELEMENT_CONTEXT_MENU"
    if token in html:
        return

    injection = r"""
<script id="V47_MANUAL_ELEMENT_CONTEXT_MENU">
(function(){
    function esc(s) {
        return String(s == null ? "" : s)
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;");
    }

    function removeManualMenu() {
        const old = document.getElementById("v47ManualGraphMenu");
        if (old) old.remove();
    }

    function removeManualModal() {
        const old = document.getElementById("v47ManualElementModal");
        if (old) old.remove();
    }

    function graphRowFromTarget(target) {
        const option = target.closest(".graphOption");
        if (!option) return null;

        const check = option.querySelector(".graphCheck");
        if (!check) return null;

        const row = check.value || "";
        const nameEl = option.querySelector(".graphName");
        const label = nameEl ? nameEl.textContent.trim() : row;

        if (!row) return null;

        return {row: row, label: label || row};
    }

    function openManualElementCard(row, label) {
        removeManualMenu();
        removeManualModal();

        const modal = document.createElement("div");
        modal.id = "v47ManualElementModal";
        modal.innerHTML = `
            <div class="v47-modal-backdrop"></div>
            <div class="v47-modal-card">
                <div class="v47-modal-head">
                    <strong>New element — ${esc(label)}</strong>
                    <button type="button" id="v47CloseManualElement">×</button>
                </div>

                <div class="v47-form">
                    <label>GRAPH</label>
                    <input id="v47ElementGraph" value="${esc(row)}" readonly>

                    <label>NAME</label>
                    <input id="v47ElementName" placeholder="Element name">

                    <label>DATE — YEAR - MONTH - DAY</label>
                    <input id="v47ElementDate" placeholder="YYYY-MM-DD">

                    <label>PLACE — COUNTRY - CITY</label>
                    <input id="v47ElementPlace" placeholder="Place">

                    <label>WHO</label>
                    <input id="v47ElementWho" placeholder="Actor / source">

                    <label>BRIEF DESCRIPTION</label>
                    <textarea id="v47ElementDescription" placeholder="Brief description"></textarea>

                    <label>SOURCE LINK</label>
                    <input id="v47ElementSource" placeholder="https://...">
                </div>

                <div class="v47-modal-actions">
                    <button type="button" id="v47CancelManualElement">Cancel</button>
                    <button type="button" id="v47SaveManualElement">Save element</button>
                </div>

                <div id="v47ManualElementStatus"></div>
            </div>
        `;

        document.body.appendChild(modal);

        document.getElementById("v47CloseManualElement").onclick = removeManualModal;
        document.getElementById("v47CancelManualElement").onclick = removeManualModal;

        document.getElementById("v47SaveManualElement").onclick = function(){
            const status = document.getElementById("v47ManualElementStatus");

            const name = document.getElementById("v47ElementName").value.trim();
            const date = document.getElementById("v47ElementDate").value.trim();
            const place = document.getElementById("v47ElementPlace").value.trim();
            const who = document.getElementById("v47ElementWho").value.trim();
            const description = document.getElementById("v47ElementDescription").value.trim();
            const source = document.getElementById("v47ElementSource").value.trim();

            if (!name) {
                status.textContent = "NAME is required.";
                return;
            }

            if (!date) {
                status.textContent = "DATE is required. No timeline element will be fabricated without date.";
                return;
            }

            const payload = {
                row: row,
                row_key: row,
                graph: row,
                graph_row: row,

                name: name,
                title: name,
                event_name: name,

                date: date,
                date_text: date,

                place: place,
                who: who,

                description: description,
                brief_description: description,

                url: source,
                source_link: source,

                source: "manual-ui",
                created_by: "manual-context-menu"
            };

            status.textContent = "Saving...";

            fetch("/api/events", {
                method: "POST",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify(payload)
            })
            .then(function(r){
                return r.json().then(function(data){
                    if (!r.ok || data.ok === false) {
                        throw new Error(data.error || ("HTTP " + r.status));
                    }
                    return data;
                });
            })
            .then(function(){
                status.textContent = "Saved.";
                window.location.href = window.location.pathname + "?v=manual_element_" + Date.now() + "&select=" + encodeURIComponent(row);
            })
            .catch(function(err){
                status.textContent = "Save failed: " + err.message;
            });
        };

        setTimeout(function(){
            const input = document.getElementById("v47ElementName");
            if (input) input.focus();
        }, 50);
    }

    function showManualGraphMenu(x, y, row, label) {
        removeManualMenu();

        const menu = document.createElement("div");
        menu.id = "v47ManualGraphMenu";
        menu.innerHTML = `
            <button type="button" id="v47AddElementToGraph">Add element</button>
        `;

        menu.style.left = x + "px";
        menu.style.top = y + "px";

        document.body.appendChild(menu);

        document.getElementById("v47AddElementToGraph").onclick = function(){
            openManualElementCard(row, label);
        };
    }

    document.addEventListener("contextmenu", function(ev){
        const graphName = ev.target.closest(".graphName");
        if (!graphName) return;

        const info = graphRowFromTarget(graphName);
        if (!info) return;

        ev.preventDefault();
        showManualGraphMenu(ev.clientX, ev.clientY, info.row, info.label);
    });

    document.addEventListener("click", function(ev){
        const menu = document.getElementById("v47ManualGraphMenu");
        if (menu && !menu.contains(ev.target)) {
            removeManualMenu();
        }
    });

    const style = document.createElement("style");
    style.textContent = `
        #v47ManualGraphMenu {
            position: fixed;
            z-index: 999999;
            background: #101722;
            border: 1px solid #334155;
            border-radius: 6px;
            padding: 6px;
            box-shadow: 0 8px 24px rgba(0,0,0,0.45);
        }

        #v47ManualGraphMenu button {
            background: #1f2937;
            color: #e5e7eb;
            border: 1px solid #475569;
            border-radius: 4px;
            padding: 6px 12px;
            cursor: pointer;
            font-size: 12px;
        }

        #v47ManualGraphMenu button:hover {
            background: #263449;
        }

        #v47ManualElementModal {
            position: fixed;
            inset: 0;
            z-index: 999998;
            font-family: system-ui, sans-serif;
        }

        #v47ManualElementModal .v47-modal-backdrop {
            position: absolute;
            inset: 0;
            background: rgba(0,0,0,0.55);
        }

        #v47ManualElementModal .v47-modal-card {
            position: absolute;
            right: 28px;
            top: 70px;
            width: 360px;
            max-height: calc(100vh - 100px);
            overflow: auto;
            background: #0f1724;
            color: #e5e7eb;
            border: 1px solid #334155;
            border-radius: 10px;
            box-shadow: 0 12px 36px rgba(0,0,0,0.55);
            padding: 12px;
        }

        #v47ManualElementModal .v47-modal-head {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 12px;
        }

        #v47ManualElementModal .v47-modal-head button {
            background: #1f2937;
            color: #e5e7eb;
            border: 1px solid #475569;
            border-radius: 4px;
            cursor: pointer;
        }

        #v47ManualElementModal .v47-form label {
            display: block;
            font-size: 10px;
            color: #9ca3af;
            margin-top: 8px;
            margin-bottom: 3px;
        }

        #v47ManualElementModal input,
        #v47ManualElementModal textarea {
            width: 100%;
            box-sizing: border-box;
            background: #020817;
            color: #e5e7eb;
            border: 1px solid #334155;
            border-radius: 5px;
            padding: 7px;
            font-size: 12px;
        }

        #v47ManualElementModal textarea {
            min-height: 92px;
            resize: vertical;
        }

        #v47ManualElementModal .v47-modal-actions {
            display: flex;
            gap: 8px;
            margin-top: 12px;
        }

        #v47ManualElementModal .v47-modal-actions button {
            flex: 1;
            background: #1f2937;
            color: #e5e7eb;
            border: 1px solid #475569;
            border-radius: 5px;
            padding: 7px;
            cursor: pointer;
        }

        #v47ManualElementModal .v47-modal-actions button:hover {
            background: #263449;
        }

        #v47ManualElementStatus {
            margin-top: 8px;
            min-height: 16px;
            font-size: 11px;
            color: #fbbf24;
        }
    `;
    document.head.appendChild(style);
})();
</script>
"""

    if "</body>" in html:
        html = html.replace("</body>", injection + "\n</body>")
    else:
        html = html + "\n" + injection

    html_path.write_text(html, encoding="utf-8")


_v47_inject_manual_element_context_menu()


# --- V47B FORCE MANUAL ELEMENT GRAPH RENDER PATCH ---
# Ensures that after creating a manual element the owning graph is selected
# and the visual timeline is forced to render. It does not fabricate data.

from pathlib import Path as _V47BPath

def _v47b_inject_force_manual_graph_render():
    html_path = _V47BPath("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")
    token = "V47B_FORCE_MANUAL_ELEMENT_GRAPH_RENDER"
    if token in html:
        return

    injection = r"""
<script id="V47B_FORCE_MANUAL_ELEMENT_GRAPH_RENDER">
(function(){
    function getParam(name) {
        const p = new URLSearchParams(window.location.search);
        return p.get(name);
    }

    function forceSelectGraphRow(row) {
        if (!row) return;

        try {
            if (typeof setGraphSelected === "function") {
                setGraphSelected(row, true);
            }

            document.querySelectorAll('.graphCheck[value="' + row.replace(/"/g, '\\"') + '"]').forEach(function(chk){
                chk.checked = true;
            });

            if (Array.isArray(window.selectionOrder)) {
                if (!window.selectionOrder.includes(row)) {
                    window.selectionOrder.push(row);
                }
            } else if (typeof selectionOrder !== "undefined" && Array.isArray(selectionOrder)) {
                if (!selectionOrder.includes(row)) {
                    selectionOrder.push(row);
                }
            }

            if (typeof renderSelectedTimeline === "function") {
                renderSelectedTimeline();
            } else if (typeof renderSelectedGraphs === "function") {
                renderSelectedGraphs();
            } else if (typeof applyFilter === "function") {
                applyFilter();
            }

            if (typeof applyZoom === "function") {
                applyZoom();
            }
        } catch (err) {
            console.warn("V47B forceSelectGraphRow failed:", err);
        }
    }

    function debugManualElementPresence() {
        try {
            if (typeof eventData === "undefined") return;
            const manual = eventData.filter(function(e){
                return String(e.row || "").toUpperCase() === "SPACE" &&
                       String(e.name || "").toUpperCase().includes("COHETE");
            });
            console.log("V47B manual element debug:", manual);
        } catch (err) {
            console.warn("V47B debug failed:", err);
        }
    }

    const row = getParam("select");
    if (row) {
        setTimeout(function(){
            forceSelectGraphRow(row);
            debugManualElementPresence();
        }, 250);

        setTimeout(function(){
            forceSelectGraphRow(row);
        }, 1000);
    }

    window.v47ForceSelectGraphRow = forceSelectGraphRow;
})();
</script>
"""

    if "</body>" in html:
        html = html.replace("</body>", injection + "\n</body>")
    else:
        html += "\n" + injection

    html_path.write_text(html, encoding="utf-8")


_v47b_inject_force_manual_graph_render()



# --- V49 SAVE BUTTON REPLACES CLEAR PATCH ---
# Replaces the top Clear/Limpiar control with explicit Save.
# Save does not clear state. It flushes visible session state and asks backend
# to render from canonical SQLite.

from pathlib import Path as _V49SavePath

def _v49_inject_save_button_replaces_clear():
    html_path = _V49SavePath("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")
    token = "V49_SAVE_BUTTON_REPLACES_CLEAR"

    if token in html:
        return

    injection = r"""
<script id="V49_SAVE_BUTTON_REPLACES_CLEAR">
(function(){
    function textOf(el) {
        return String((el && (el.textContent || el.value)) || "").trim().toLowerCase();
    }

    function findClearButton() {
        const candidates = Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit']"));

        for (const el of candidates) {
            const t = textOf(el);
            if (t === "limpiar" || t === "clear") return el;
        }

        for (const el of candidates) {
            const t = textOf(el);
            if (t.includes("limpiar") || t.includes("clear")) return el;
        }

        return null;
    }

    function setStatus(msg, ok) {
        let box = document.getElementById("v49SaveStatus");
        if (!box) {
            box = document.createElement("span");
            box.id = "v49SaveStatus";
            box.style.marginLeft = "8px";
            box.style.fontSize = "12px";
            box.style.fontFamily = "system-ui, sans-serif";
            const btn = document.getElementById("v49SaveButton");
            if (btn && btn.parentNode) btn.parentNode.insertBefore(box, btn.nextSibling);
            else document.body.appendChild(box);
        }

        box.textContent = msg;
        box.style.color = ok ? "#4ade80" : "#fbbf24";
    }

    async function persistOpenTechnicalCardIfPresent() {
        const buttons = Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit']"));

        const saveCard = buttons.find(function(btn){
            const t = textOf(btn);
            return t === "save card" || t === "save element" || t === "save";
        });

        if (!saveCard || saveCard.id === "v49SaveButton") return;

        try {
            saveCard.click();
            await new Promise(function(resolve){ setTimeout(resolve, 450); });
        } catch (err) {
            console.warn("V49 Save: technical card save click failed:", err);
        }
    }

    async function persistFolderStateIfPresent() {
        try {
            if (typeof getFolderState === "function" && typeof saveFolderState === "function") {
                const state = getFolderState();
                const result = saveFolderState(state);
                if (result && typeof result.then === "function") {
                    await result;
                } else {
                    await new Promise(function(resolve){ setTimeout(resolve, 250); });
                }
            }
        } catch (err) {
            console.warn("V49 Save: folder state save failed:", err);
        }
    }

    async function saveSession() {
        const btn = document.getElementById("v49SaveButton");
        if (btn) {
            btn.disabled = true;
            btn.textContent = "Saving...";
        }

        setStatus("Saving session...", false);

        try {
            await persistOpenTechnicalCardIfPresent();
            await persistFolderStateIfPresent();

            const response = await fetch("/api/session/save", {
                method: "POST",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify({
                    source: "v49-save-button",
                    timestamp: new Date().toISOString()
                })
            });

            let data = {};
            try {
                data = await response.json();
            } catch (err) {
                data = {ok: false, error: "Invalid JSON response"};
            }

            if (!response.ok || data.ok === false) {
                throw new Error(data.error || ("SQLite integrity: " + data.sqlite_integrity) || ("HTTP " + response.status));
            }

            setStatus(
                "Saved. SQLite OK. Graphs: " + data.graphs + " Events: " + data.events,
                true
            );

            if (btn) {
                btn.textContent = "Save";
                btn.disabled = false;
            }

        } catch (err) {
            setStatus("Save failed: " + err.message, false);

            if (btn) {
                btn.textContent = "Save";
                btn.disabled = false;
            }
        }
    }

    function installSaveButton() {
        let btn = document.getElementById("v49SaveButton");

        if (!btn) {
            const clearBtn = findClearButton();

            if (clearBtn) {
                const clone = clearBtn.cloneNode(true);
                clone.id = "v49SaveButton";
                clone.textContent = "Save";
                clone.value = "Save";
                clone.title = "Save current session to SQLite";
                clone.dataset.v49Save = "1";

                clearBtn.parentNode.replaceChild(clone, clearBtn);
                btn = clone;
            }
        }

        if (!btn) {
            const container =
                document.querySelector(".toolbar") ||
                document.querySelector("header") ||
                document.body;

            btn = document.createElement("button");
            btn.id = "v49SaveButton";
            btn.type = "button";
            btn.textContent = "Save";
            btn.title = "Save current session to SQLite";
            container.insertBefore(btn, container.firstChild);
        }

        if (btn.dataset.v49Bound === "1") return;

        btn.dataset.v49Bound = "1";
        btn.textContent = "Save";
        btn.value = "Save";
        btn.title = "Save current session to SQLite";

        btn.addEventListener("click", function(ev){
            ev.preventDefault();
            ev.stopPropagation();
            ev.stopImmediatePropagation();
            saveSession();
        }, true);
    }

    document.addEventListener("DOMContentLoaded", installSaveButton);
    setTimeout(installSaveButton, 250);
    setTimeout(installSaveButton, 1000);

    window.v49SaveSession = saveSession;
})();
</script>
"""

    if "</body>" in html:
        html = html.replace("</body>", injection + "\n</body>")
    else:
        html = html + "\n" + injection

    html_path.write_text(html, encoding="utf-8")


_v49_inject_save_button_replaces_clear()


# --- V49K SINGLE STABLE TOP RIGHT SAVE SENSOR PATCH ---
# Single stable autosave sensor:
# - One script only.
# - No floating panel.
# - No competing visual states.
# - Top-right fixed host.
# - Green only after real /api/autosave/status returns ok.
# - Manual click uses /api/autosave/save.

from pathlib import Path as _V49KPath
import re as _v49k_re

def _v49k_inject_single_stable_top_right_save_sensor():
    html_path = _V49KPath("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    # Remove any older generated sensor scripts.
    html = _v49k_re.sub(
        r'<script[^>]*id="V49[A-J][^"]*"[^>]*>.*?</script>',
        '',
        html,
        flags=_v49k_re.S
    )

    # Normalize base Save button in generated HTML.
    button = '<button id="v49SaveButton" type="button" title="Autosave status">Save <span id="v49SaveDotFinal" aria-hidden="true"></span></button>'

    html = _v49k_re.sub(
        r'<button\s+onclick=["\']selectNone\(\)["\']>\s*(Save|Limpiar|Clear)\s*</button>',
        button,
        html,
        flags=_v49k_re.I
    )

    html = _v49k_re.sub(
        r'<button\s+id=["\']v49SaveButton["\'][^>]*>.*?</button>',
        button,
        html,
        count=1,
        flags=_v49k_re.S
    )

    token = "V49K_SINGLE_STABLE_TOP_RIGHT_SAVE_SENSOR"
    if token in html:
        html_path.write_text(html, encoding="utf-8")
        return

    injection = r"""
<script id="V49K_SINGLE_STABLE_TOP_RIGHT_SAVE_SENSOR">
(function(){
    let currentState = "unknown";
    let saving = false;

    function ensureHostAndButton() {
        let host = document.getElementById("v49TopRightSaveHost");
        if (!host) {
            host = document.createElement("div");
            host.id = "v49TopRightSaveHost";
            document.body.appendChild(host);
        }

        let btn = document.getElementById("v49SaveButton");
        if (!btn) {
            btn = document.createElement("button");
            btn.id = "v49SaveButton";
            btn.type = "button";
        }

        btn.onclick = null;

        if (!document.getElementById("v49SaveDotFinal")) {
            btn.innerHTML = 'Save <span id="v49SaveDotFinal" aria-hidden="true"></span>';
        }

        if (btn.parentElement !== host) {
            host.appendChild(btn);
        }

        return btn;
    }

    function setState(state, msg) {
        if (state === currentState && state !== "saving") {
            const b0 = document.getElementById("v49SaveButton");
            if (b0 && msg) b0.title = msg;
            return;
        }

        currentState = state;

        const btn = ensureHostAndButton();
        const dot = document.getElementById("v49SaveDotFinal");

        btn.classList.remove("save-ok", "save-saving", "save-error");
        if (dot) dot.className = "";

        if (state === "ok") {
            btn.classList.add("save-ok");
            if (dot) dot.classList.add("ok");
        } else if (state === "saving") {
            btn.classList.add("save-saving");
            if (dot) dot.classList.add("saving");
        } else {
            btn.classList.add("save-error");
            if (dot) dot.classList.add("error");
        }

        btn.title = msg || "";
        if (dot) dot.title = msg || "";
    }

    async function readStatus() {
        if (saving) return;

        try {
            const r = await fetch("/api/autosave/status", {cache: "no-store"});
            const data = await r.json();

            if (!r.ok || data.ok === false || data.sqlite_integrity !== "ok" || data.dirty) {
                throw new Error(data.last_error || data.error || data.sqlite_integrity || ("HTTP " + r.status));
            }

            setState(
                "ok",
                "Autosaved. SQLite integrity: " + data.sqlite_integrity +
                ". Graphs: " + data.graphs +
                ". Events: " + data.events +
                ". Folders: " + data.folders +
                ". Revision: " + data.revision
            );
        } catch (err) {
            setState("error", "Autosave not verified: " + String(err.message || err));
        }
    }

    async function saveNow(ev) {
        if (ev) {
            ev.preventDefault();
            ev.stopPropagation();
            ev.stopImmediatePropagation();
        }

        const btn = ensureHostAndButton();
        saving = true;
        btn.disabled = true;
        setState("saving", "Saving and verifying SQLite...");

        try {
            const payload = {
                source: "v49k-single-save",
                timestamp: new Date().toISOString()
            };

            if (typeof getFolderState === "function") {
                payload.ui_state = getFolderState();
            }

            const r = await fetch("/api/autosave/save", {
                method: "POST",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify(payload)
            });

            const data = await r.json();

            if (!r.ok || data.ok === false || data.sqlite_integrity !== "ok") {
                throw new Error(data.error || data.last_error || data.sqlite_integrity || ("HTTP " + r.status));
            }

            setState(
                "ok",
                "Saved. SQLite integrity: " + data.sqlite_integrity +
                ". Graphs: " + data.graphs +
                ". Events: " + data.events +
                ". Folders: " + data.folders
            );
        } catch (err) {
            setState("error", "Save failed or not verified: " + String(err.message || err));
        } finally {
            saving = false;
            btn.disabled = false;
        }
    }

    function install() {
        const btn = ensureHostAndButton();

        if (btn.dataset.v49kBound !== "1") {
            btn.dataset.v49kBound = "1";
            btn.addEventListener("click", saveNow, true);
        }

        readStatus();
    }

    const style = document.createElement("style");
    style.id = "V49K_SINGLE_STABLE_TOP_RIGHT_SAVE_SENSOR_STYLE";
    style.textContent = `
        #v49AutosavePanel,
        #v49SaveToast {
            display: none !important;
            visibility: hidden !important;
            opacity: 0 !important;
            pointer-events: none !important;
        }

        #v49TopRightSaveHost {
            position: fixed !important;
            top: 8px !important;
            right: 18px !important;
            z-index: 2147483647 !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            pointer-events: auto !important;
        }

        #v49SaveButton {
            min-width: 78px !important;
            height: 30px !important;
            padding: 0 12px !important;
            display: inline-flex !important;
            align-items: center !important;
            justify-content: center !important;
            gap: 7px !important;
            border-radius: 7px !important;
            border: 1px solid #334155 !important;
            background: #1f2937 !important;
            color: #e5e7eb !important;
            font-size: 12px !important;
            line-height: 1 !important;
            cursor: pointer !important;
        }

        #v49SaveButton.save-ok { border-color: #22c55e !important; }
        #v49SaveButton.save-saving { border-color: #f59e0b !important; }
        #v49SaveButton.save-error { border-color: #ef4444 !important; }

        #v49SaveDotFinal {
            display: inline-block !important;
            width: 8px !important;
            height: 8px !important;
            min-width: 8px !important;
            min-height: 8px !important;
            border-radius: 50% !important;
            background: #ef4444 !important;
        }

        #v49SaveDotFinal.ok { background: #22c55e !important; }
        #v49SaveDotFinal.saving { background: #f59e0b !important; }
        #v49SaveDotFinal.error { background: #ef4444 !important; }
    `;
    document.head.appendChild(style);

    document.addEventListener("DOMContentLoaded", install);
    setTimeout(install, 100);
    setTimeout(install, 600);

    // Poll estable: no remonta el botón; solo lee estado real.
    setInterval(readStatus, 8000);

    window.v49kSaveNow = saveNow;
    window.v49kReadAutosaveStatus = readStatus;
})();
</script>
"""

    html = html.replace("</body>", injection + "\n</body>") if "</body>" in html else html + "\n" + injection
    html_path.write_text(html, encoding="utf-8")


_v49k_inject_single_stable_top_right_save_sensor()


# --- V51 CLEAN OLD DELETE/FOLDER GENERATED SCRIPTS ---
from pathlib import Path as _V51CleanPath
import re as _v51_clean_re

def _v51_clean_old_delete_folder_generated_scripts():
    html_path = _V51CleanPath("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v51_clean_re.sub(
        r'<script[^>]*id="V48_PHYSICAL_DELETE_UI_OVERRIDE"[^>]*>.*?</script>',
        '',
        html,
        flags=_v51_clean_re.S
    )

    html = _v51_clean_re.sub(
        r'<script[^>]*id="V50[^"]*(FOLDER|folder)[^"]*"[^>]*>.*?</script>',
        '',
        html,
        flags=_v51_clean_re.S
    )

    html_path.write_text(html, encoding="utf-8")


_v51_clean_old_delete_folder_generated_scripts()


# --- V52B REMOVE BROKEN EVENT CARD CANONICAL INTERCEPT ---
from pathlib import Path as _V52BPath
import re as _v52b_re

def _v52b_remove_broken_event_card_intercept():
    html_path = _V52BPath("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")
    html = _v52b_re.sub(
        r'<script[^>]*id="V52_EVENT_CARD_CANONICAL_SAVE"[^>]*>.*?</script>',
        '',
        html,
        flags=_v52b_re.S
    )
    html_path.write_text(html, encoding="utf-8")

_v52b_remove_broken_event_card_intercept()


# --- V52C MASTER SAVE FLUSH OPEN CARDS PATCH ---
# Makes the main Save button operationally flush open UI edits before autosave:
# - It does not replace Save card.
# - It does not create new buttons.
# - It clicks the existing Save card / graph card / folder card buttons when visible.
# - Then it lets the existing V49K main Save continue with /api/autosave/save.

from pathlib import Path as _V52CPath
import re as _v52c_re

def _v52c_inject_master_save_flush_open_cards():
    html_path = _V52CPath("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v52c_re.sub(
        r'<script[^>]*id="V52C_MASTER_SAVE_FLUSH_OPEN_CARDS"[^>]*>.*?</script>',
        '',
        html,
        flags=_v52c_re.S
    )

    injection = r"""
<script id="V52C_MASTER_SAVE_FLUSH_OPEN_CARDS">
(function(){
    function visible(el) {
        if (!el) return false;
        const cs = window.getComputedStyle(el);
        if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0") return false;
        const rect = el.getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
    }

    function delay(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    async function clickIfVisible(buttonId, panelIds) {
        const btn = document.getElementById(buttonId);
        if (!btn) return false;

        let panelVisible = false;
        for (const id of panelIds) {
            const panel = document.getElementById(id);
            if (visible(panel)) {
                panelVisible = true;
                break;
            }
        }

        if (!panelVisible) return false;

        btn.click();
        await delay(700);
        return true;
    }

    async function flushOpenCardsBeforeMasterSave() {
        const flushed = [];

        /*
         * Existing technical event card.
         * This is the card that draws PLACE / WHO indicators.
         */
        if (await clickIfVisible("techSave", ["techCard"])) {
            flushed.push("event-card");
        }

        /*
         * Existing folder technical card.
         */
        if (await clickIfVisible("folderTechSave", ["folderTechCard"])) {
            flushed.push("folder-card");
        }

        /*
         * Existing graph-set card.
         */
        if (await clickIfVisible("graphSetSave", ["grafoDGraphSetCard"])) {
            flushed.push("graph-card");
        }

        return flushed;
    }

    function patchV49KSaveFunction() {
        /*
         * V49K owns the top-right Save button. Instead of replacing it,
         * this capture listener runs first and marks that the open cards
         * were flushed. The V49K listener then continues to /api/autosave/save.
         */
        const btn = document.getElementById("v49SaveButton");
        if (!btn || btn.dataset.v52cFlushBound === "1") return;

        btn.dataset.v52cFlushBound = "1";

        btn.addEventListener("click", async function(ev){
            if (btn.dataset.v52cFlushing === "1") return;

            ev.preventDefault();
            ev.stopPropagation();
            ev.stopImmediatePropagation();

            btn.dataset.v52cFlushing = "1";
            const originalTitle = btn.title || "";
            btn.title = "Flushing open cards before SQLite save";

            try {
                await flushOpenCardsBeforeMasterSave();
            } catch (err) {
                console.warn("V52C pre-save flush failed:", err);
            } finally {
                btn.dataset.v52cFlushing = "0";
                btn.title = originalTitle || "Autosave status";

                /*
                 * Re-dispatch the click so the existing V49K Save handler
                 * performs /api/autosave/save and SQLite verification.
                 */
                setTimeout(function(){
                    btn.click();
                }, 50);
            }
        }, true);
    }

    document.addEventListener("DOMContentLoaded", patchV49KSaveFunction);
    setTimeout(patchV49KSaveFunction, 300);
    setTimeout(patchV49KSaveFunction, 1000);

    window.v52cFlushOpenCardsBeforeMasterSave = flushOpenCardsBeforeMasterSave;
})();
</script>
"""

    if "</body>" in html:
        html = html.replace("</body>", injection + "\n</body>")
    else:
        html += "\n" + injection

    html_path.write_text(html, encoding="utf-8")


_v52c_inject_master_save_flush_open_cards()


# --- V55 FINAL OVERRIDE PATCH: FRONT FIELDS + REAL API SENSOR ---
# Pegar este bloque al final absoluto de render_excel_like.py.
# No requiere reemplazar funciones internas anteriores.

from pathlib import Path as _V55FinalPath

def _v55_inject_front_fields_and_api_sensor():
    html_path = _V55FinalPath("outputs/timeline_excel_like.html")

    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    token = "GRAFO_D_V55_FRONT_FIELDS_AND_API_SENSOR_FINAL"

    if token in html:
        return

    css = r"""
<style id="GRAFO_D_V55_FRONT_FIELDS_AND_API_SENSOR_FINAL_STYLE">
.liveApiSensor {
    margin-top: 12px;
    padding: 10px;
    border: 1px solid #374151;
    background: #0b1220;
    color: #e5e7eb;
    font-family: monospace;
    font-size: 12px;
}
.liveApiSensorTitle {
    font-size: 10px;
    font-weight: 700;
    color: #9ca3af;
    margin-bottom: 6px;
}
.liveApiSensorState {
    font-size: 14px;
    font-weight: 800;
    margin-bottom: 6px;
}
.liveApiSensorState.active,
.liveApiSensorState.online {
    color: #22c55e;
}
.liveApiSensorState.degraded {
    color: #f59e0b;
}
.liveApiSensorState.inactive,
.liveApiSensorState.offline {
    color: #ef4444;
}
.liveApiSensorState.unknown,
.liveApiSensorState.none {
    color: #9ca3af;
}
.liveApiSensorDetail {
    white-space: pre-wrap;
    line-height: 1.35;
}
.eventPlaceTag,
.eventWhoTag {
    max-width: 28ch !important;
}
</style>
"""

    js = r"""
<script id="GRAFO_D_V55_FRONT_FIELDS_AND_API_SENSOR_FINAL">
(function() {
    function firstNonEmpty() {
        for (const value of arguments) {
            if (value !== undefined && value !== null && String(value).trim() !== "") {
                return value;
            }
        }
        return "";
    }

    function eventStableIdV55(e) {
        if (typeof stableEventId === "function") {
            return stableEventId(e);
        }
        return String(e.row || "") + ":" + String(e.start || "") + ":" + String(e.end || "") + ":" + String(e.name || "");
    }

    function eventMetaV55(e) {
        try {
            if (typeof getEventMetaStore === "function") {
                const store = getEventMetaStore();
                return store[eventStableIdV55(e)] || {};
            }
        } catch (err) {}
        return {};
    }

    function placeOfEventV55(e) {
        const meta = eventMetaV55(e);
        return firstNonEmpty(meta.place, meta.PLACE, e.place, e.PLACE);
    }

    function whoOfEventV55(e) {
        const meta = eventMetaV55(e);
        return firstNonEmpty(meta.who, meta.WHO, e.who, e.WHO);
    }

    function briefOfEventV55(e) {
        const meta = eventMetaV55(e);
        return firstNonEmpty(
            meta.description,
            meta.brief_description,
            meta.brief,
            meta.BRIEF,
            e.description,
            e.brief_description,
            e.brief,
            e.BRIEF
        );
    }

    function dateOfEventV55(e) {
        const meta = eventMetaV55(e);
        return firstNonEmpty(meta.date, meta.date_text, e.date, e.date_text, typeof yearForEvent === "function" ? yearForEvent(e) : "");
    }

    function sourceOfEventV55(e) {
        const meta = eventMetaV55(e);
        return firstNonEmpty(meta.source_link, meta.source, e.source_link, e.source);
    }

    function apiTextOfEventV55(e) {
        const meta = eventMetaV55(e);
        return firstNonEmpty(meta.api, meta.api_text, e.api, e.api_text);
    }

    function addVisibleTagsV55() {
        if (typeof eventData === "undefined" || !Array.isArray(eventData)) return;

        document.querySelectorAll(".event").forEach(function(box) {
            const id = box.dataset.eventId || "";
            const e = eventData.find(function(item) {
                return eventStableIdV55(item) === id;
            });

            if (!e) return;

            const place = placeOfEventV55(e);
            const who = whoOfEventV55(e);

            box.querySelectorAll(".eventPlaceTag.v55, .eventWhoTag.v55").forEach(function(old) {
                old.remove();
            });

            if (place) {
                const tag = document.createElement("span");
                tag.className = "eventPlaceTag v55";
                tag.textContent = String(place).slice(0, 28);
                tag.title = String(place);
                box.appendChild(tag);
            }

            if (who) {
                const tag = document.createElement("span");
                tag.className = "eventWhoTag v55";
                tag.textContent = String(who).slice(0, 28);
                tag.title = String(who);
                box.appendChild(tag);
            }

            const brief = briefOfEventV55(e);
            const titleParts = [
                e.name || "",
                e.row ? "fila " + e.row : "",
                place ? "PLACE: " + place : "",
                who ? "WHO: " + who : "",
                brief ? "BRIEF: " + brief : ""
            ].filter(Boolean);

            box.title = titleParts.join(" | ");
        });
    }

    if (typeof applyFilter === "function" && !window.__grafoDV55ApplyFilterWrapped) {
        window.__grafoDV55ApplyFilterWrapped = true;

        const originalApplyFilterV55 = applyFilter;

        applyFilter = function() {
            const result = originalApplyFilterV55.apply(this, arguments);
            setTimeout(addVisibleTagsV55, 0);
            return result;
        };

        window.applyFilter = applyFilter;
    }

    function normalizeApiStatusV55(api) {
        const raw = String(
            api.runtime_status ||
            api.status ||
            api.last_status ||
            api.http_status ||
            "unknown"
        ).toLowerCase();

        if (["active", "online", "ok", "success", "healthy", "200"].includes(raw)) return "active";
        if (["degraded", "slow", "warning", "partial"].includes(raw)) return "degraded";
        if (["inactive", "offline", "error", "failed", "timeout", "unhealthy"].includes(raw)) return "inactive";

        if (api.ok === true) return "active";
        if (api.ok === false) return "inactive";

        const http = Number(api.http_status || api.last_status || 0);
        if (http >= 200 && http < 300) return "active";
        if (http >= 400) return "inactive";

        return "unknown";
    }

    async function fetchApiStatusV55(row) {
        const encoded = encodeURIComponent(row || "");

        try {
            const response = await fetch("/api/apis/status?graph=" + encoded + "&v=" + Date.now(), {
                cache: "no-store"
            });

            if (response.ok) {
                const data = await response.json();
                const apis = Array.isArray(data.apis) ? data.apis : [];

                return apis.filter(function(api) {
                    return !row ||
                        api.target_graph === row ||
                        api.graph === row ||
                        api.row === row ||
                        api.name === row;
                });
            }
        } catch (err) {
            // El backend viejo todavía no tiene /api/apis/status.
            // Se usa fallback por /api/db para no romper el tablero.
        }

        try {
            const response = await fetch("/api/db?v=" + Date.now(), {
                cache: "no-store"
            });

            const db = await response.json();
            const apis = Array.isArray(db.api_registry) ? db.api_registry : [];

            return apis
                .filter(function(api) {
                    return !row ||
                        api.target_graph === row ||
                        api.graph === row ||
                        api.row === row ||
                        api.name === row;
                })
                .map(function(api) {
                    return {
                        id: api.id,
                        name: api.name,
                        enabled: api.enabled !== false,
                        target_graph: api.target_graph || api.graph || api.row || "",
                        endpoint: api.endpoint || api.url || "",
                        status: normalizeApiStatusV55(api),
                        runtime_status: normalizeApiStatusV55(api),
                        http_status: api.http_status || api.last_status || "",
                        latency_ms: api.latency_ms || api.last_latency_ms || "",
                        last_checked: api.last_checked || api.last_run_at || api.updated_at || "",
                        error: api.error || api.last_error || ""
                    };
                });
        } catch (err) {
            return [{
                name: "API SENSOR",
                target_graph: row,
                enabled: false,
                status: "inactive",
                runtime_status: "inactive",
                error: String(err)
            }];
        }
    }

    async function apiStatusTextForGraphV55(row) {
        const apis = await fetchApiStatusV55(row);

        if (!apis.length) {
            return "No active API registered for this graph.";
        }

        return apis.map(function(api) {
            const status = normalizeApiStatusV55(api);

            return [
                "API: " + (api.name || api.id || "?"),
                "Graph: " + (api.target_graph || api.graph || row || "?"),
                "Enabled: " + String(api.enabled !== false),
                "Runtime status: " + status,
                "HTTP status: " + (api.http_status || api.last_status || "unknown"),
                "Latency: " + (api.latency_ms || api.last_latency_ms || "n/a") + " ms",
                "Last checked: " + (api.last_checked || api.last_run_at || api.updated_at || "never"),
                "Endpoint: " + (api.endpoint || api.url || "?"),
                "Error: " + (api.error || api.last_error || "none")
            ].join("\n");
        }).join("\n\n");
    }

    async function updateLiveApiSensorV55(row) {
        const stateBox = document.getElementById("liveApiSensorState");
        const detailBox = document.getElementById("liveApiSensorDetail");

        if (!stateBox || !detailBox) return;

        const apis = await fetchApiStatusV55(row);

        if (!apis.length) {
            stateBox.className = "liveApiSensorState none";
            stateBox.textContent = "NO API REGISTERED";
            detailBox.textContent = "Graph row: " + row;
            return;
        }

        const priority = {
            inactive: 3,
            degraded: 2,
            unknown: 1,
            active: 0
        };

        let worst = "active";

        apis.forEach(function(api) {
            const status = normalizeApiStatusV55(api);
            if ((priority[status] || 1) > (priority[worst] || 0)) {
                worst = status;
            }
        });

        stateBox.className = "liveApiSensorState " + worst;
        stateBox.textContent = "● " + worst.toUpperCase();

        detailBox.textContent = apis.map(function(api) {
            const status = normalizeApiStatusV55(api);

            return [
                "Graph row: " + row,
                "API: " + (api.name || api.id || "?"),
                "Enabled: " + String(api.enabled !== false),
                "Runtime status: " + status,
                "HTTP status: " + (api.http_status || api.last_status || "unknown"),
                "Latency: " + (api.latency_ms || api.last_latency_ms || "n/a") + " ms",
                "Last checked: " + (api.last_checked || api.last_run_at || api.updated_at || "never"),
                "Endpoint: " + (api.endpoint || api.url || "?"),
                "Error: " + (api.error || api.last_error || "none")
            ].join("\n");
        }).join("\n\n");
    }

    if (typeof openTechCardByEvent === "function" && !window.__grafoDV55OpenTechWrapped) {
        window.__grafoDV55OpenTechWrapped = true;

        const originalOpenTechCardV55 = openTechCardByEvent;

        openTechCardByEvent = async function(e, domEl) {
            const result = await originalOpenTechCardV55.apply(this, arguments);

            const place = placeOfEventV55(e);
            const who = whoOfEventV55(e);
            const brief = briefOfEventV55(e);
            const date = dateOfEventV55(e);
            const source = sourceOfEventV55(e);
            const apiText = apiTextOfEventV55(e);

            const title = document.getElementById("techCardTitle");
            const group = document.getElementById("techGroup");
            const name = document.getElementById("techName");
            const dateField = document.getElementById("techDate");
            const placeField = document.getElementById("techPlace");
            const whoField = document.getElementById("techWho");
            const descriptionField = document.getElementById("techDescription");
            const sourceField = document.getElementById("techSourceLink");
            const apiField = document.getElementById("techApi");

            if (title) title.textContent = e.name || "Technical Card";
            if (group && typeof currentFolderForRow === "function") group.value = currentFolderForRow(e.row);
            if (name) name.value = e.name || "";
            if (dateField) dateField.value = date;
            if (placeField) placeField.value = place;
            if (whoField) whoField.value = who;
            if (descriptionField) descriptionField.value = brief;
            if (sourceField) sourceField.value = source;

            if (apiField) {
                const liveText = await apiStatusTextForGraphV55(e.row);
                apiField.value = liveText || apiText || "";
            }

            updateLiveApiSensorV55(e.row);

            if (window.__grafoDV55ApiTimer) {
                clearInterval(window.__grafoDV55ApiTimer);
            }

            window.__grafoDV55ApiTimer = setInterval(function() {
                updateLiveApiSensorV55(e.row);
            }, 15000);

            return result;
        };

        window.openTechCardByEvent = openTechCardByEvent;
    }

    window.saveTechCard = async function saveTechCardV55() {
        if (typeof activeEventObj === "undefined" || !activeEventObj) return;

        const oldId = typeof activeEventId !== "undefined" ? activeEventId : eventStableIdV55(activeEventObj);

        const name = document.getElementById("techName") ? document.getElementById("techName").value.trim() : "";
        const date = document.getElementById("techDate") ? document.getElementById("techDate").value.trim() : "";
        const group = document.getElementById("techGroup") ? document.getElementById("techGroup").value.trim() : "";
        const place = document.getElementById("techPlace") ? document.getElementById("techPlace").value.trim() : "";
        const who = document.getElementById("techWho") ? document.getElementById("techWho").value.trim() : "";
        const brief = document.getElementById("techDescription") ? document.getElementById("techDescription").value.trim() : "";
        const sourceLink = document.getElementById("techSourceLink") ? document.getElementById("techSourceLink").value.trim() : "";
        const apiText = document.getElementById("techApi") ? document.getElementById("techApi").value.trim() : "";

        if (name) {
            activeEventObj.name = name;
            activeEventObj.event_name = name;
        }

        if (date) {
            activeEventObj.date = date;
            activeEventObj.date_text = date;
        }

        activeEventObj.place = place;
        activeEventObj.PLACE = place;
        activeEventObj.who = who;
        activeEventObj.WHO = who;
        activeEventObj.description = brief;
        activeEventObj.brief = brief;
        activeEventObj.BRIEF = brief;
        activeEventObj.brief_description = brief;
        activeEventObj.source_link = sourceLink;
        activeEventObj.source = sourceLink;
        activeEventObj.api = apiText;
        activeEventObj.api_text = apiText;

        if (date && typeof findAxisByYear === "function") {
            const axis = findAxisByYear(date);

            if (axis) {
                activeEventObj.start = axis.start;
                activeEventObj.end = axis.end;
                activeEventObj.start_col = axis.start;
                activeEventObj.end_col = axis.end;
            }
        }

        if (group && typeof setFolderForRow === "function") {
            setFolderForRow(activeEventObj.row, group);
        }

        activeEventId = eventStableIdV55(activeEventObj);

        const store = typeof getEventMetaStore === "function" ? getEventMetaStore() : {};

        delete store[oldId];

        store[activeEventId] = {
            id: activeEventObj.id || "",
            db_id: activeEventObj.db_id || "",
            group: typeof currentFolderForRow === "function" ? currentFolderForRow(activeEventObj.row) : group,
            row: activeEventObj.row,
            row_key: activeEventObj.row_key || activeEventObj.row,
            name: activeEventObj.name,
            event_name: activeEventObj.event_name || activeEventObj.name,
            date: date || activeEventObj.date || "",
            date_text: date || activeEventObj.date_text || "",
            place: place,
            PLACE: place,
            who: who,
            WHO: who,
            description: brief,
            brief: brief,
            BRIEF: brief,
            brief_description: brief,
            source_link: sourceLink,
            api: apiText,
            api_text: apiText
        };

        try {
            if (typeof saveEventMetaStore === "function") {
                saveEventMetaStore(store);
            } else {
                await fetch("/api/ui-state", {
                    method: "POST",
                    headers: {"Content-Type": "application/json"},
                    body: JSON.stringify({key: "event_meta", value: store})
                });
            }
        } catch (err) {}

        try {
            await fetch("/api/session/save", {
                method: "POST",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify({})
            });
        } catch (err) {}

        if (typeof renderFolderTree === "function") renderFolderTree();
        if (typeof applyFilter === "function") applyFilter();
        if (typeof applyZoom === "function") applyZoom();

        setTimeout(addVisibleTagsV55, 0);

        if (typeof openTechCardByEvent === "function") {
            openTechCardByEvent(activeEventObj, null);
        }
    };

    const techSaveButton = document.getElementById("techSave");
    if (techSaveButton && !techSaveButton.dataset.v55Bound) {
        const replacement = techSaveButton.cloneNode(true);
        replacement.dataset.v55Bound = "1";
        techSaveButton.parentNode.replaceChild(replacement, techSaveButton);

        replacement.addEventListener("click", function(ev) {
            ev.preventDefault();
            ev.stopPropagation();
            window.saveTechCard();
        });
    }

    setTimeout(addVisibleTagsV55, 1000);
})();
</script>
"""

    if "</head>" in html:
        html = html.replace("</head>", css + "\n</head>", 1)
    else:
        html = css + "\n" + html

    if "</body>" in html:
        html = html.replace("</body>", js + "\n</body>", 1)
    else:
        html += "\n" + js

    html_path.write_text(html, encoding="utf-8")
    print("V55 final front fields/API sensor override injected.")


_v55_inject_front_fields_and_api_sensor()
# --- END V55 FINAL OVERRIDE PATCH ---



# --- V56_GRAPH_INBOX_IMPORT_BUTTON PATCH ---
from pathlib import Path as _V56Path
import re as _v56_re

def _v56_inject_graph_inbox_import_button():
    html_path = _V56Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v56_re.sub(
        r'<style[^>]*id=["\']V56_GRAPH_INBOX_IMPORT_BUTTON_STYLE["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v56_re.S | _v56_re.I
    )
    html = _v56_re.sub(
        r'<script[^>]*id=["\']V56_GRAPH_INBOX_IMPORT_BUTTON["\'][^>]*>.*?</script>',
        '',
        html,
        flags=_v56_re.S | _v56_re.I
    )

    injection = r"""
<style id="V56_GRAPH_INBOX_IMPORT_BUTTON_STYLE">
#v56GraphInboxHost {
    position: fixed;
    top: 54px;
    right: 14px;
    z-index: 999999;
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 4px;
    font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#v56GraphInboxButton {
    border: 1px solid #475569;
    background: #111827;
    color: #e5e7eb;
    border-radius: 8px;
    padding: 7px 10px;
    font-size: 12px;
    font-weight: 700;
    cursor: pointer;
    box-shadow: 0 8px 22px rgba(0,0,0,.25);
}
#v56GraphInboxButton:hover {
    background: #1f2937;
}
#v56GraphInboxButton:disabled {
    cursor: wait;
    opacity: .65;
}
#v56GraphInboxStatus {
    max-width: 260px;
    padding: 6px 8px;
    border-radius: 6px;
    background: rgba(15,23,42,.94);
    border: 1px solid #334155;
    color: #cbd5e1;
    font-size: 11px;
    line-height: 1.25;
    text-align: right;
    white-space: pre-wrap;
}
</style>
<script id="V56_GRAPH_INBOX_IMPORT_BUTTON">
(function(){
    function ensureHost() {
        let host = document.getElementById("v56GraphInboxHost");
        if (!host) {
            host = document.createElement("div");
            host.id = "v56GraphInboxHost";
            document.body.appendChild(host);
        }

        let btn = document.getElementById("v56GraphInboxButton");
        if (!btn) {
            btn = document.createElement("button");
            btn.id = "v56GraphInboxButton";
            btn.type = "button";
            btn.textContent = "Import TXT";
            btn.title = "Importa GRAPH_IMPORT/graph_inbox.txt como grafo nuevo en Uncategorized";
            host.appendChild(btn);
        }

        let status = document.getElementById("v56GraphInboxStatus");
        if (!status) {
            status = document.createElement("div");
            status.id = "v56GraphInboxStatus";
            status.textContent = "Inbox TXT: listo";
            host.appendChild(status);
        }

        return {host: host, btn: btn, status: status};
    }

    function setStatus(msg) {
        const ui = ensureHost();
        ui.status.textContent = msg;
    }

    async function importInbox() {
        const ui = ensureHost();

        const ok = window.confirm(
            "Importar GRAPH_IMPORT/graph_inbox.txt como grafo nuevo en la carpeta Uncategorized?\n\n" +
            "Esta acción escribirá en SQLite si el archivo contiene un grafo válido."
        );
        if (!ok) return;

        ui.btn.disabled = true;
        setStatus("Importando graph_inbox.txt...");

        try {
            const res = await fetch("/api/import/graph-inbox", {
                method: "POST",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify({source: "GRAPH_IMPORT/graph_inbox.txt"})
            });

            const data = await res.json();

            if (!res.ok || !data.ok) {
                setStatus("Importación rechazada:\n" + (data.error || data.message || res.status));
                alert("Importación rechazada:\n" + (data.error || data.message || res.status));
                return;
            }

            if (!data.imported) {
                setStatus(data.message || "No hay grafo pendiente.");
                alert(data.message || "No hay grafo pendiente.");
                return;
            }

            const graph = data.graph || {};
            const row = graph.row_key || "";
            setStatus("Importado: " + (graph.visible_name || row) + "\nNodos: " + data.nodes_imported);

            const url = new URL(window.location.href);
            url.searchParams.set("v", "sqlite_server");
            if (row) url.searchParams.set("select", row);

            setTimeout(function(){
                window.location.href = url.toString();
            }, 500);

        } catch (err) {
            console.error("V56 graph inbox import failed:", err);
            setStatus("Error de importación: " + err);
            alert("Error de importación: " + err);
        } finally {
            ui.btn.disabled = false;
        }
    }

    function bind() {
        const ui = ensureHost();
        if (ui.btn.dataset.v56Bound === "1") return;
        ui.btn.dataset.v56Bound = "1";
        ui.btn.addEventListener("click", importInbox);
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", bind);
    } else {
        bind();
    }
})();
</script>
"""

    if _v56_re.search(r"</body\s*>", html, flags=_v56_re.I):
        html = _v56_re.sub(r"</body\s*>", injection + "\n</body>", html, count=1, flags=_v56_re.I)
    else:
        html += injection

    html_path.write_text(html, encoding="utf-8")
    print("V56 graph inbox import button injected.")

_v56_inject_graph_inbox_import_button()

# --- V57_IMPORT_BUTTON_VISIBLE_HOTFIX ---
from pathlib import Path as _V57Path
import re as _v57_re

def _v57_force_import_button_visible():
    html_path = _V57Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v57_re.sub(
        r'<style[^>]*id=["\']V57_IMPORT_BUTTON_VISIBLE_HOTFIX["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v57_re.S | _v57_re.I
    )

    patch = r'''
<style id="V57_IMPORT_BUTTON_VISIBLE_HOTFIX">
#v56GraphInboxHost {
    position: fixed !important;
    top: 8px !important;
    right: 88px !important;
    z-index: 2147483647 !important;
    display: flex !important;
    flex-direction: row !important;
    align-items: center !important;
    gap: 8px !important;
    pointer-events: auto !important;
}
#v56GraphInboxButton {
    display: inline-flex !important;
    align-items: center !important;
    justify-content: center !important;
    min-width: 92px !important;
    min-height: 28px !important;
    border: 1px solid #22c55e !important;
    background: #052e16 !important;
    color: #dcfce7 !important;
    border-radius: 7px !important;
    padding: 6px 10px !important;
    font-size: 12px !important;
    font-weight: 800 !important;
    cursor: pointer !important;
    opacity: 1 !important;
    visibility: visible !important;
}
#v56GraphInboxStatus {
    display: none !important;
}
</style>
'''

    if "</head>" in html:
        html = html.replace("</head>", patch + "\n</head>", 1)
    else:
        html = patch + "\n" + html

    html_path.write_text(html, encoding="utf-8")
    print("V57 import button visibility hotfix injected.")

_v57_force_import_button_visible()

# --- V58_STATIC_IMPORT_BUTTON_HARD_PATCH ---
from pathlib import Path as _V58Path
import re as _v58_re

def _v58_static_import_button():
    html_path = _V58Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    # Remove old floating import button injections to avoid duplicate controls.
    for pattern in [
        r'<style[^>]*id=["\']V56_GRAPH_INBOX_IMPORT_BUTTON_STYLE["\'][^>]*>.*?</style>',
        r'<script[^>]*id=["\']V56_GRAPH_INBOX_IMPORT_BUTTON["\'][^>]*>.*?</script>',
        r'<style[^>]*id=["\']V57_IMPORT_BUTTON_VISIBLE_HOTFIX["\'][^>]*>.*?</style>',
        r'<style[^>]*id=["\']V58_STATIC_IMPORT_BUTTON_STYLE["\'][^>]*>.*?</style>',
        r'<script[^>]*id=["\']V58_STATIC_IMPORT_BUTTON_SCRIPT["\'][^>]*>.*?</script>',
        r'<div[^>]*id=["\']v58GraphInboxHost["\'][\s\S]*?</div>\s*',
    ]:
        html = _v58_re.sub(pattern, "", html, flags=_v58_re.S | _v58_re.I)

    patch = r'''
<style id="V58_STATIC_IMPORT_BUTTON_STYLE">
#v58GraphInboxHost {
    position: fixed !important;
    left: 12px !important;
    top: 12px !important;
    z-index: 2147483647 !important;
    display: block !important;
    width: auto !important;
    height: auto !important;
    pointer-events: auto !important;
}
#v58GraphInboxButton {
    display: inline-block !important;
    min-width: 120px !important;
    min-height: 34px !important;
    border: 2px solid #22c55e !important;
    background: #052e16 !important;
    color: #dcfce7 !important;
    border-radius: 8px !important;
    padding: 7px 12px !important;
    font-family: system-ui, sans-serif !important;
    font-size: 13px !important;
    font-weight: 900 !important;
    cursor: pointer !important;
    opacity: 1 !important;
    visibility: visible !important;
    box-shadow: 0 8px 24px rgba(0,0,0,.45) !important;
}
#v58GraphInboxButton:hover {
    background: #064e3b !important;
}
</style>

<div id="v58GraphInboxHost">
    <button id="v58GraphInboxButton" type="button">
        Import TXT
    </button>
</div>

<script id="V58_STATIC_IMPORT_BUTTON_SCRIPT">
(function(){
    async function importGraphInboxV58() {
        const btn = document.getElementById("v58GraphInboxButton");
        if (!btn) return;

        const confirmed = window.confirm(
            "Importar GRAPH_IMPORT/graph_inbox.txt como grafo nuevo en Uncategorized?\n\n" +
            "Esta acción escribirá en SQLite solamente si el archivo contiene un grafo válido."
        );
        if (!confirmed) return;

        btn.disabled = true;
        const oldText = btn.textContent;
        btn.textContent = "Importando...";

        try {
            const res = await fetch("/api/import/graph-inbox", {
                method: "POST",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify({source: "GRAPH_IMPORT/graph_inbox.txt"})
            });

            const data = await res.json();

            if (!res.ok || !data.ok) {
                alert("Importación rechazada:\n" + (data.error || data.message || res.status));
                return;
            }

            if (!data.imported) {
                alert(data.message || "No hay grafo pendiente.");
                return;
            }

            const graph = data.graph || {};
            alert("Grafo importado: " + (graph.visible_name || graph.row_key || "") + "\nNodos: " + data.nodes_imported);

            const url = new URL(window.location.href);
            url.searchParams.set("v", "sqlite_server");
            url.searchParams.set("t", String(Date.now()));
            if (graph.row_key) url.searchParams.set("select", graph.row_key);
            window.location.href = url.toString();

        } catch (err) {
            alert("Error de importación: " + err);
        } finally {
            btn.disabled = false;
            btn.textContent = oldText;
        }
    }

    function bindV58() {
        const btn = document.getElementById("v58GraphInboxButton");
        if (!btn || btn.dataset.boundV58 === "1") return;
        btn.dataset.boundV58 = "1";
        btn.addEventListener("click", importGraphInboxV58);
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", bindV58);
    } else {
        bindV58();
    }
})();
</script>
'''

    if _v58_re.search(r"<body[^>]*>", html, flags=_v58_re.I):
        html = _v58_re.sub(r"(<body[^>]*>)", r"\1\n" + patch, html, count=1, flags=_v58_re.I)
    else:
        html = patch + "\n" + html

    html_path.write_text(html, encoding="utf-8")
    print("V58 static Import TXT button injected.")

_v58_static_import_button()

# --- V59_IMPORT_BUTTON_NEXT_TO_SAVE ---
from pathlib import Path as _V59Path
import re as _v59_re

def _v59_import_button_next_to_save():
    html_path = _V59Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v59_re.sub(
        r'<style[^>]*id=["\']V59_IMPORT_BUTTON_NEXT_TO_SAVE["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v59_re.S | _v59_re.I
    )

    patch = r'''
<style id="V59_IMPORT_BUTTON_NEXT_TO_SAVE">
#v58GraphInboxHost {
    position: fixed !important;
    top: 10px !important;
    right: 86px !important;
    left: auto !important;
    z-index: 2147483647 !important;
    display: block !important;
    width: auto !important;
    height: auto !important;
    pointer-events: auto !important;
}
#v58GraphInboxButton {
    display: inline-block !important;
    min-width: 96px !important;
    min-height: 28px !important;
    height: 28px !important;
    border: 1px solid #22c55e !important;
    background: #052e16 !important;
    color: #dcfce7 !important;
    border-radius: 7px !important;
    padding: 4px 10px !important;
    font-family: system-ui, sans-serif !important;
    font-size: 12px !important;
    font-weight: 800 !important;
    line-height: 18px !important;
    cursor: pointer !important;
    opacity: 1 !important;
    visibility: visible !important;
}
</style>
'''

    if _v59_re.search(r"</body\s*>", html, flags=_v59_re.I):
        html = _v59_re.sub(r"</body\s*>", patch + "\n</body>", html, count=1, flags=_v59_re.I)
    else:
        html += "\n" + patch

    html_path.write_text(html, encoding="utf-8")
    print("V59 Import TXT button moved next to Save.")

_v59_import_button_next_to_save()

# --- V60_IMPORT_BUTTON_SAVE_ALIGNED ---
from pathlib import Path as _V60Path
import re as _v60_re

def _v60_import_button_save_aligned():
    html_path = _V60Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v60_re.sub(
        r'<style[^>]*id=["\']V60_IMPORT_BUTTON_SAVE_ALIGNED["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v60_re.S | _v60_re.I
    )

    patch = r'''
<style id="V60_IMPORT_BUTTON_SAVE_ALIGNED">
#v58GraphInboxHost {
    position: fixed !important;
    top: 11px !important;
    right: 92px !important;
    left: auto !important;
    z-index: 2147483647 !important;
    display: block !important;
    width: auto !important;
    height: auto !important;
    pointer-events: auto !important;
}
#v58GraphInboxButton {
    display: inline-block !important;
    min-width: 92px !important;
    height: 29px !important;
    min-height: 29px !important;
    border: 1px solid #22c55e !important;
    background: #052e16 !important;
    color: #dcfce7 !important;
    border-radius: 7px !important;
    padding: 4px 10px !important;
    font-family: system-ui, sans-serif !important;
    font-size: 12px !important;
    font-weight: 800 !important;
    line-height: 18px !important;
    cursor: pointer !important;
    opacity: 1 !important;
    visibility: visible !important;
    box-shadow: none !important;
}
</style>
'''

    if _v60_re.search(r"</body\s*>", html, flags=_v60_re.I):
        html = _v60_re.sub(r"</body\s*>", patch + "\n</body>", html, count=1, flags=_v60_re.I)
    else:
        html += "\n" + patch

    html_path.write_text(html, encoding="utf-8")
    print("V60 Import TXT button aligned next to Save.")

_v60_import_button_save_aligned()

# --- V61_IMPORT_BUTTON_ATTACH_TO_SAVE ---
from pathlib import Path as _V61Path
import re as _v61_re

def _v61_import_button_attach_to_save():
    html_path = _V61Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    html = _v61_re.sub(
        r'<style[^>]*id=["\']V61_IMPORT_BUTTON_ATTACH_TO_SAVE["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v61_re.S | _v61_re.I
    )
    html = _v61_re.sub(
        r'<script[^>]*id=["\']V61_IMPORT_BUTTON_ATTACH_TO_SAVE_SCRIPT["\'][^>]*>.*?</script>',
        '',
        html,
        flags=_v61_re.S | _v61_re.I
    )

    patch = r'''
<style id="V61_IMPORT_BUTTON_ATTACH_TO_SAVE">
#v58GraphInboxHost {
    position: fixed !important;
    left: auto !important;
    z-index: 2147483647 !important;
    display: block !important;
    width: auto !important;
    height: auto !important;
    pointer-events: auto !important;
}
#v58GraphInboxButton {
    display: inline-flex !important;
    align-items: center !important;
    justify-content: center !important;
    min-width: 92px !important;
    border: 1px solid #22c55e !important;
    background: #052e16 !important;
    color: #dcfce7 !important;
    border-radius: 7px !important;
    padding: 4px 10px !important;
    font-family: system-ui, sans-serif !important;
    font-size: 12px !important;
    font-weight: 800 !important;
    line-height: 18px !important;
    cursor: pointer !important;
    opacity: 1 !important;
    visibility: visible !important;
    box-shadow: none !important;
    white-space: nowrap !important;
}
</style>

<script id="V61_IMPORT_BUTTON_ATTACH_TO_SAVE_SCRIPT">
(function(){
    function findSaveButton() {
        const controls = Array.from(document.querySelectorAll(
            "button, [role='button'], input[type='button'], input[type='submit']"
        ));

        return controls.find(function(el) {
            if (!el || el.id === "v58GraphInboxButton") return false;
            const text = ((el.textContent || el.value || "") + "").trim().toLowerCase();
            return text === "save" || text.startsWith("save ");
        });
    }

    function alignImportButtonToSave() {
        const host = document.getElementById("v58GraphInboxHost");
        const btn = document.getElementById("v58GraphInboxButton");
        if (!host || !btn) return;

        const save = findSaveButton();

        host.style.setProperty("position", "fixed", "important");
        host.style.setProperty("left", "auto", "important");
        host.style.setProperty("z-index", "2147483647", "important");
        host.style.setProperty("display", "block", "important");

        if (save) {
            const r = save.getBoundingClientRect();
            const gap = 8;
            const right = Math.max(8, window.innerWidth - r.left + gap);

            host.style.setProperty("top", Math.round(r.top) + "px", "important");
            host.style.setProperty("right", Math.round(right) + "px", "important");

            btn.style.setProperty("height", Math.round(r.height) + "px", "important");
            btn.style.setProperty("min-height", Math.round(r.height) + "px", "important");
        } else {
            host.style.setProperty("top", "11px", "important");
            host.style.setProperty("right", "92px", "important");
            btn.style.setProperty("height", "29px", "important");
            btn.style.setProperty("min-height", "29px", "important");
        }
    }

    function startAligner() {
        alignImportButtonToSave();
        setTimeout(alignImportButtonToSave, 100);
        setTimeout(alignImportButtonToSave, 500);
        setTimeout(alignImportButtonToSave, 1200);
        window.addEventListener("resize", alignImportButtonToSave, {passive: true});
        window.addEventListener("scroll", alignImportButtonToSave, {passive: true});
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", startAligner);
    } else {
        startAligner();
    }
})();
</script>
'''

    if _v61_re.search(r"</body\s*>", html, flags=_v61_re.I):
        html = _v61_re.sub(r"</body\s*>", patch + "\n</body>", html, count=1, flags=_v61_re.I)
    else:
        html += "\n" + patch

    html_path.write_text(html, encoding="utf-8")
    print("V61 Import TXT button attached to Save.")

_v61_import_button_attach_to_save()

# --- V62_IMPORT_BUTTON_FIXED_NEXT_TO_SAVE ---
from pathlib import Path as _V62Path
import re as _v62_re

def _v62_import_button_fixed_next_to_save():
    html_path = _V62Path("outputs/timeline_excel_like.html")
    if not html_path.exists():
        return

    html = html_path.read_text(encoding="utf-8")

    # Remove V61 dynamic aligner because it can move the button out of view.
    html = _v62_re.sub(
        r'<style[^>]*id=["\']V61_IMPORT_BUTTON_ATTACH_TO_SAVE["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v62_re.S | _v62_re.I
    )
    html = _v62_re.sub(
        r'<script[^>]*id=["\']V61_IMPORT_BUTTON_ATTACH_TO_SAVE_SCRIPT["\'][^>]*>.*?</script>',
        '',
        html,
        flags=_v62_re.S | _v62_re.I
    )

    # Remove previous V62 if render is executed multiple times.
    html = _v62_re.sub(
        r'<style[^>]*id=["\']V62_IMPORT_BUTTON_FIXED_NEXT_TO_SAVE["\'][^>]*>.*?</style>',
        '',
        html,
        flags=_v62_re.S | _v62_re.I
    )

    patch = r'''
<style id="V62_IMPORT_BUTTON_FIXED_NEXT_TO_SAVE">
#v58GraphInboxHost {
    position: fixed !important;
    top: 9px !important;
    right: 108px !important;
    left: auto !important;
    z-index: 2147483647 !important;
    display: block !important;
    width: auto !important;
    height: auto !important;
    margin: 0 !important;
    padding: 0 !important;
    pointer-events: auto !important;
}
#v58GraphInboxButton {
    display: inline-flex !important;
    align-items: center !important;
    justify-content: center !important;
    width: 96px !important;
    min-width: 96px !important;
    max-width: 96px !important;
    height: 30px !important;
    min-height: 30px !important;
    max-height: 30px !important;
    box-sizing: border-box !important;
    border: 1px solid #22c55e !important;
    background: #052e16 !important;
    color: #dcfce7 !important;
    border-radius: 7px !important;
    padding: 0 10px !important;
    font-family: system-ui, sans-serif !important;
    font-size: 12px !important;
    font-weight: 800 !important;
    line-height: 30px !important;
    cursor: pointer !important;
    opacity: 1 !important;
    visibility: visible !important;
    box-shadow: none !important;
    white-space: nowrap !important;
}
#v58GraphInboxButton:hover {
    background: #064e3b !important;
}
</style>
'''

    if _v62_re.search(r"</body\s*>", html, flags=_v62_re.I):
        html = _v62_re.sub(r"</body\s*>", patch + "\n</body>", html, count=1, flags=_v62_re.I)
    else:
        html += "\n" + patch

    html_path.write_text(html, encoding="utf-8")
    print("V62 Import TXT button fixed next to Save.")

_v62_import_button_fixed_next_to_save()
