940 lines
37 KiB
HTML
940 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>.PU Merger</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;700;800&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0d0d0f;
|
|
--surface: #141418;
|
|
--surface2: #1c1c22;
|
|
--surface3: #222228;
|
|
--border: #2a2a35;
|
|
--accent: #c8f542;
|
|
--accent2: #7b5ea7;
|
|
--accent3: #42c8f5;
|
|
--danger: #f54242;
|
|
--warning: #f5a623;
|
|
--text: #e8e8f0;
|
|
--muted: #6b6b80;
|
|
--radius: 8px;
|
|
}
|
|
|
|
html, body { height: 100%; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: 'Syne', sans-serif;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* HEADER */
|
|
header {
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 18px 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
background: var(--surface);
|
|
flex-shrink: 0;
|
|
}
|
|
header h1 {
|
|
font-size: 1.1rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
}
|
|
header h1 span { color: var(--muted); font-weight: 400; }
|
|
.header-right { display: flex; align-items: center; gap: 10px; }
|
|
.header-step {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.65rem;
|
|
color: var(--muted);
|
|
letter-spacing: 0.1em;
|
|
}
|
|
|
|
/* STEPS BAR */
|
|
.steps-bar {
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
padding: 0 24px;
|
|
flex-shrink: 0;
|
|
}
|
|
.step-tab {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
padding: 10px 16px;
|
|
color: var(--muted);
|
|
border-bottom: 2px solid transparent;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: all 0.15s;
|
|
cursor: default;
|
|
}
|
|
.step-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
.step-tab.done { color: var(--accent2); }
|
|
.step-num {
|
|
width: 17px; height: 17px;
|
|
border-radius: 50%;
|
|
background: var(--surface2);
|
|
color: var(--muted);
|
|
font-size: 0.55rem;
|
|
display: flex; align-items: center; justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.step-tab.active .step-num { background: var(--accent); color: #000; }
|
|
.step-tab.done .step-num { background: var(--accent2); color: #fff; }
|
|
|
|
/* MAIN */
|
|
main { flex: 1; overflow-y: auto; padding: 32px; }
|
|
|
|
/* PHASE */
|
|
.phase { display: none; }
|
|
.phase.active { display: block; }
|
|
|
|
/* BUTTONS */
|
|
button {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
cursor: pointer;
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
padding: 9px 18px;
|
|
transition: all 0.15s;
|
|
text-transform: uppercase;
|
|
}
|
|
.btn-primary { background: var(--accent); color: #000; }
|
|
.btn-primary:hover { background: #d9ff50; transform: translateY(-1px); }
|
|
.btn-primary:disabled { background: var(--surface2); color: var(--muted); cursor: not-allowed; transform: none; }
|
|
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
|
|
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
|
|
.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
|
|
.btn-secondary:hover { background: var(--surface3); }
|
|
.btn-danger { background: transparent; color: var(--danger); border: 1px solid rgba(245,66,66,0.4); padding: 6px 12px; font-size: 0.62rem; }
|
|
.btn-danger:hover { background: rgba(245,66,66,0.08); }
|
|
.btn-green { background: var(--accent); color: #000; }
|
|
.btn-green:hover { background: #d9ff50; }
|
|
.btn-sm { padding: 5px 12px; font-size: 0.6rem; }
|
|
|
|
/* DROP ZONES */
|
|
.load-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; max-width: 860px; margin: 0 auto; }
|
|
.drop-zone {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 12px;
|
|
padding: 40px 24px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
background: var(--surface);
|
|
min-height: 220px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 12px;
|
|
}
|
|
.drop-zone:hover, .drop-zone.dragover { border-color: var(--accent); background: rgba(200,245,66,0.04); }
|
|
.drop-zone.loaded { border-color: var(--accent2); border-style: solid; background: rgba(123,94,167,0.05); }
|
|
.drop-icon { font-size: 2.4rem; }
|
|
.drop-title { font-size: 1rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text); }
|
|
.drop-sub { font-family: 'Space Mono', monospace; font-size: 0.7rem; color: var(--muted); }
|
|
.file-loaded { font-family: 'Space Mono', monospace; font-size: 0.68rem; color: var(--accent); background: rgba(200,245,66,0.08); padding: 4px 10px; border-radius: 4px; }
|
|
|
|
/* SECTION HEADERS */
|
|
.section-head {
|
|
display: flex;
|
|
align-items: baseline;
|
|
justify-content: space-between;
|
|
margin-bottom: 16px;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 10px;
|
|
}
|
|
.section-title { font-size: 1.3rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text); }
|
|
.section-count { font-family: 'Space Mono', monospace; font-size: 0.65rem; color: var(--muted); }
|
|
|
|
/* CARDS */
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
margin-bottom: 10px;
|
|
overflow: hidden;
|
|
}
|
|
.card-body { padding: 16px; }
|
|
|
|
/* COLLISION ITEMS */
|
|
.collision-item {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-left: 3px solid var(--danger);
|
|
border-radius: var(--radius);
|
|
padding: 14px 16px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.collision-id {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.8rem;
|
|
font-weight: 700;
|
|
color: var(--danger);
|
|
margin-bottom: 6px;
|
|
}
|
|
.collision-names { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
|
.file-badge {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.62rem;
|
|
padding: 3px 9px;
|
|
border-radius: 20px;
|
|
}
|
|
.file-a { background: rgba(66,200,245,0.1); color: var(--accent3); border: 1px solid rgba(66,200,245,0.25); }
|
|
.file-b { background: rgba(245,166,35,0.1); color: var(--warning); border: 1px solid rgba(245,166,35,0.25); }
|
|
.resolution-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
|
|
/* RADIO GROUP */
|
|
.radio-group { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
.radio-group label {
|
|
display: flex; align-items: center; gap: 5px;
|
|
font-family: 'Space Mono', monospace; font-size: 0.65rem;
|
|
color: var(--muted); cursor: pointer;
|
|
padding: 5px 11px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
background: var(--surface2);
|
|
transition: all 0.12s;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
.radio-group label:hover { color: var(--text); border-color: var(--muted); }
|
|
.radio-group input[type="radio"] { accent-color: var(--accent); width: 11px; height: 11px; }
|
|
.radio-group label.selected-a { border-color: var(--accent3); background: rgba(66,200,245,0.08); color: var(--accent3); }
|
|
.radio-group label.selected-b { border-color: var(--warning); background: rgba(245,166,35,0.08); color: var(--warning); }
|
|
.radio-group label.selected-skip { border-color: var(--muted); background: var(--surface3); color: var(--muted); }
|
|
.radio-group label.selected-rename { border-color: var(--accent); background: rgba(200,245,66,0.08); color: var(--accent); }
|
|
|
|
/* RENAME ROW */
|
|
.rename-expand {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
padding: 10px 14px;
|
|
background: var(--surface2);
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
}
|
|
.rename-expand.visible { display: flex; }
|
|
|
|
/* TAG MAPPER */
|
|
.tag-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.tag-row:last-child { border-bottom: none; }
|
|
.tag-pill {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.68rem;
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
border: 1px solid var(--border);
|
|
background: var(--surface2);
|
|
color: var(--text);
|
|
min-width: 90px;
|
|
flex-shrink: 0;
|
|
}
|
|
.tag-count-a { font-family: 'Space Mono', monospace; font-size: 0.6rem; padding: 2px 8px; border-radius: 20px; background: rgba(66,200,245,0.1); color: var(--accent3); border: 1px solid rgba(66,200,245,0.2); flex-shrink: 0; }
|
|
.tag-count-b { font-family: 'Space Mono', monospace; font-size: 0.6rem; padding: 2px 8px; border-radius: 20px; background: rgba(245,166,35,0.1); color: var(--warning); border: 1px solid rgba(245,166,35,0.2); flex-shrink: 0; }
|
|
.arrow { color: var(--muted); font-size: 0.9rem; flex-shrink: 0; }
|
|
|
|
/* FORMS */
|
|
input[type="text"], input[type="number"], select, textarea {
|
|
background: var(--surface2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text);
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.78rem;
|
|
padding: 9px 12px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|
select option { background: var(--surface2); }
|
|
label {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.62rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* SUMMARY */
|
|
.summary-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 12px; margin-bottom: 24px; }
|
|
.summary-stat { background: var(--surface); border: 1px solid var(--border); padding: 14px; border-radius: var(--radius); text-align: center; }
|
|
.stat-val { font-size: 2rem; font-weight: 800; color: var(--accent); line-height: 1; }
|
|
.stat-lbl { font-family: 'Space Mono', monospace; font-size: 0.58rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-top: 4px; }
|
|
|
|
/* CHUNK */
|
|
.chunk { margin-bottom: 28px; }
|
|
.chunk-label {
|
|
font-family: 'Space Mono', monospace;
|
|
font-size: 0.62rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
color: var(--muted);
|
|
margin-bottom: 10px;
|
|
padding-bottom: 6px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.chunk-label.label-both { color: var(--accent2); }
|
|
.chunk-label.label-a { color: var(--accent3); }
|
|
.chunk-label.label-b { color: var(--warning); }
|
|
|
|
/* TAG RENAME INPUT */
|
|
.rename-input { width: 160px; }
|
|
|
|
/* CARD-ROW */
|
|
.card-row { display: flex; align-items: flex-start; gap: 10px; padding: 14px 16px; flex-direction: column; }
|
|
|
|
/* HR */
|
|
hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
|
|
|
|
/* TAG RENAME SUMMARY */
|
|
.rename-summary-row {
|
|
display: flex; align-items: center; gap: 12px;
|
|
font-family: 'Space Mono', monospace; font-size: 0.72rem;
|
|
padding: 8px 14px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.rename-summary-row:last-child { border-bottom: none; }
|
|
|
|
/* TOAST */
|
|
#toast {
|
|
position: fixed; bottom: 24px; right: 24px;
|
|
background: var(--accent); color: #000;
|
|
font-family: 'Space Mono', monospace; font-size: 0.72rem; font-weight: 700;
|
|
padding: 10px 18px; border-radius: var(--radius);
|
|
opacity: 0; transform: translateY(8px);
|
|
transition: all 0.2s; pointer-events: none; z-index: 999;
|
|
}
|
|
#toast.show { opacity: 1; transform: translateY(0); }
|
|
|
|
@media (max-width: 700px) {
|
|
.load-grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<h1>.PU <span>Merger</span></h1>
|
|
<div class="header-right">
|
|
<span class="header-step" id="header-step">STEP 1 — LOAD FILES</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="steps-bar">
|
|
<div class="step-tab active" id="step-tab-1"><span class="step-num">1</span> Load Files</div>
|
|
<div class="step-tab" id="step-tab-2"><span class="step-num">2</span> Resolve Collisions</div>
|
|
<div class="step-tab" id="step-tab-3"><span class="step-num">3</span> Map Tags</div>
|
|
<div class="step-tab" id="step-tab-4"><span class="step-num">4</span> Export</div>
|
|
</div>
|
|
|
|
<main>
|
|
<!-- PHASE 1: LOAD -->
|
|
<div class="phase active" id="phase-1">
|
|
<div style="max-width:860px;margin:0 auto">
|
|
<div style="margin-bottom:24px">
|
|
<h2 style="font-family:'Syne',sans-serif;font-weight:800;font-size:1.4rem;letter-spacing:0.04em;margin-bottom:6px">Load two .pu files to merge</h2>
|
|
<p style="font-family:'Space Mono',monospace;font-size:0.78rem;color:var(--muted)">Drag & drop or click to browse. File A is the base — File B will be merged into it.</p>
|
|
</div>
|
|
<div class="load-grid">
|
|
<div class="drop-zone" id="dz-a" onclick="document.getElementById('fi-a').click()" ondragover="dzOver(event,'a')" ondragleave="dzLeave('a')" ondrop="dzDrop(event,'a')">
|
|
<div class="drop-icon">📄</div>
|
|
<div class="drop-title">File A — Base</div>
|
|
<div class="drop-sub">Drop .pu file here or click</div>
|
|
<div class="file-loaded" id="fname-a" style="display:none"></div>
|
|
<input type="file" id="fi-a" accept=".pu,.json" style="display:none" onchange="loadPU('a',this.files[0])">
|
|
</div>
|
|
<div class="drop-zone" id="dz-b" onclick="document.getElementById('fi-b').click()" ondragover="dzOver(event,'b')" ondragleave="dzLeave('b')" ondrop="dzDrop(event,'b')">
|
|
<div class="drop-icon">📄</div>
|
|
<div class="drop-title">File B — Merge In</div>
|
|
<div class="drop-sub">Drop .pu file here or click</div>
|
|
<div class="file-loaded" id="fname-b" style="display:none"></div>
|
|
<input type="file" id="fi-b" accept=".pu,.json" style="display:none" onchange="loadPU('b',this.files[0])">
|
|
</div>
|
|
</div>
|
|
<div style="text-align:center;margin-top:28px">
|
|
<button class="btn-primary" id="btn-analyze" disabled onclick="goToPhase2()">Analyze & Continue →</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PHASE 2: COLLISIONS -->
|
|
<div class="phase" id="phase-2">
|
|
<div style="max-width:900px;margin:0 auto">
|
|
<div class="section-head">
|
|
<div>
|
|
<div class="section-title">ID Collisions</div>
|
|
<div class="section-count" id="collision-count-label"></div>
|
|
</div>
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn-ghost btn-sm" onclick="resolveAll('a')">All → Keep A</button>
|
|
<button class="btn-ghost btn-sm" onclick="resolveAll('b')">All → Keep B</button>
|
|
<button class="btn-ghost btn-sm" onclick="resolveAll('skip')">All → Skip B</button>
|
|
</div>
|
|
</div>
|
|
<div id="collisions-list"></div>
|
|
<div style="display:flex;justify-content:space-between;margin-top:24px">
|
|
<button class="btn-ghost" onclick="goToPhase(1)">← Back</button>
|
|
<button class="btn-primary" onclick="goToPhase3()">Continue → Tag Mapping</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PHASE 3: TAGS -->
|
|
<div class="phase" id="phase-3">
|
|
<div style="max-width:900px;margin:0 auto">
|
|
<div class="section-head">
|
|
<div>
|
|
<div class="section-title">Tag Mapping</div>
|
|
<div class="section-count">Rename or merge tags before combining. Leave blank to keep as-is.</div>
|
|
</div>
|
|
<button class="btn-ghost btn-sm" onclick="clearAllRenames()">Clear all renames</button>
|
|
</div>
|
|
|
|
<!-- Side-by-side exclusive tags -->
|
|
<div id="tag-exclusive-wrap" style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:28px">
|
|
<div>
|
|
<div class="chunk-label label-a" style="margin-bottom:8px">Only in File A</div>
|
|
<div id="tags-a-only" style="max-height:340px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)"></div>
|
|
<div id="tags-a-empty" style="display:none;font-family:'Space Mono',monospace;font-size:0.68rem;color:var(--muted);padding:12px">none</div>
|
|
</div>
|
|
<div>
|
|
<div class="chunk-label label-b" style="margin-bottom:8px">Only in File B</div>
|
|
<div id="tags-b-only" style="max-height:340px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)"></div>
|
|
<div id="tags-b-empty" style="display:none;font-family:'Space Mono',monospace;font-size:0.68rem;color:var(--muted);padding:12px">none</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Common tags -->
|
|
<div id="tag-chunk-both">
|
|
<div class="chunk-label label-both" style="margin-bottom:8px">Tags in both files</div>
|
|
<div id="tags-both" style="border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)"></div>
|
|
</div>
|
|
|
|
<div style="display:flex;justify-content:space-between;margin-top:24px">
|
|
<button class="btn-ghost" onclick="goToPhase(2)">← Back</button>
|
|
<button class="btn-primary" onclick="goToPhase4()">Continue → Export</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PHASE 4: EXPORT -->
|
|
<div class="phase" id="phase-4">
|
|
<div style="max-width:700px;margin:0 auto">
|
|
<div class="section-title" style="margin-bottom:20px">Merge Summary</div>
|
|
<div class="summary-grid" id="summary-grid"></div>
|
|
|
|
<div class="card" style="margin-bottom:20px">
|
|
<div class="card-row" style="flex-direction:column;align-items:flex-start;gap:8px">
|
|
<label class="" style="font-family:'Space Mono',monospace;font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em">Output filename</label>
|
|
<input type="text" id="output-filename" value="merged.pu" style="width:100%">
|
|
</div>
|
|
<div class="card-row" style="flex-direction:column;align-items:flex-start;gap:8px">
|
|
<label class="" style="font-family:'Space Mono',monospace;font-size:0.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em">Map ID (for merged file)</label>
|
|
<div style="display:flex;gap:8px;width:100%">
|
|
<input type="text" id="output-mapid" style="flex:1">
|
|
<button class="btn-ghost btn-sm" onclick="document.getElementById('output-mapid').value=fileA.mapId">Use A</button>
|
|
<button class="btn-ghost btn-sm" onclick="document.getElementById('output-mapid').value=fileB.mapId">Use B</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tag-rename-summary" style="margin-bottom:20px"></div>
|
|
|
|
<div style="display:flex;gap:12px;justify-content:space-between">
|
|
<button class="btn-ghost" onclick="goToPhase(3)">← Back</button>
|
|
<button class="btn-green" onclick="exportMerged()">↓ Download Merged .pu</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
// ─── STATE ─────────────────────────────────────────────────────────────────────
|
|
let fileA = null, fileB = null;
|
|
let nameA = '', nameB = '';
|
|
let collisions = {}; // id -> { section, a, b, resolution: 'a'|'b'|'skip' }
|
|
let tagRenames = {}; // oldTag -> { value: string, mode: 'rename'|'add' }
|
|
|
|
const SECTIONS = ['classes', 'majors', 'partners', 'clubs', 'punishments', 'rouletteOptions'];
|
|
|
|
// ─── FILE LOADING ──────────────────────────────────────────────────────────────
|
|
function dzOver(e, which) { e.preventDefault(); document.getElementById('dz-'+which).classList.add('dragover'); }
|
|
function dzLeave(which) { document.getElementById('dz-'+which).classList.remove('dragover'); }
|
|
function dzDrop(e, which) {
|
|
e.preventDefault(); dzLeave(which);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) loadPU(which, file);
|
|
}
|
|
|
|
function loadPU(which, file) {
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
try {
|
|
const data = JSON.parse(e.target.result);
|
|
if (which === 'a') { fileA = data; nameA = file.name; }
|
|
else { fileB = data; nameB = file.name; }
|
|
const dz = document.getElementById('dz-'+which);
|
|
dz.classList.add('loaded');
|
|
const fn = document.getElementById('fname-'+which);
|
|
fn.textContent = '✓ ' + file.name;
|
|
fn.style.display = '';
|
|
dz.querySelector('.drop-icon').textContent = '✅';
|
|
if (fileA && fileB) document.getElementById('btn-analyze').disabled = false;
|
|
toast('Loaded ' + file.name);
|
|
} catch(err) { alert('Could not parse file: ' + err.message); }
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// ─── PHASE NAVIGATION ─────────────────────────────────────────────────────────
|
|
function goToPhase(n) {
|
|
document.querySelectorAll('.phase').forEach((p,i) => p.classList.toggle('active', i===n-1));
|
|
document.querySelectorAll('.step-tab').forEach((t,i) => {
|
|
t.classList.toggle('active', i===n-1);
|
|
t.classList.toggle('done', i<n-1);
|
|
});
|
|
const labels = ['STEP 1 — LOAD FILES','STEP 2 — RESOLVE COLLISIONS','STEP 3 — MAP TAGS','STEP 4 — EXPORT'];
|
|
document.getElementById('header-step').textContent = labels[n-1];
|
|
window.scrollTo(0,0);
|
|
}
|
|
|
|
function goToPhase2() { analyzeCollisions(); goToPhase(2); }
|
|
function goToPhase3() { analyzeTags(); goToPhase(3); }
|
|
function goToPhase4() { buildSummary(); goToPhase(4); }
|
|
|
|
// ─── COLLISION ANALYSIS ────────────────────────────────────────────────────────
|
|
function analyzeCollisions() {
|
|
collisions = {};
|
|
SECTIONS.forEach(sec => {
|
|
const a = fileA[sec] || {};
|
|
const b = fileB[sec] || {};
|
|
Object.keys(b).forEach(id => {
|
|
if (a[id] !== undefined) {
|
|
collisions[sec + '::' + id] = {
|
|
section: sec, id,
|
|
nameA: a[id]?.name || a[id]?.title || id,
|
|
nameB: b[id]?.name || b[id]?.title || id,
|
|
resolution: 'a' // default keep A
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
const keys = Object.keys(collisions);
|
|
document.getElementById('collision-count-label').textContent =
|
|
keys.length === 0 ? 'No collisions found — files can merge cleanly' :
|
|
`${keys.length} ID collision${keys.length!==1?'s':''} detected`;
|
|
|
|
const list = document.getElementById('collisions-list');
|
|
if (keys.length === 0) {
|
|
list.innerHTML = `<div style="padding:32px;text-align:center;font-family:'Space Mono',monospace;font-size:0.85rem;color:var(--accent2)">
|
|
✓ No ID collisions. Files are safe to merge without any overrides.
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
// group by section
|
|
const bySec = {};
|
|
keys.forEach(k => { const c = collisions[k]; if (!bySec[c.section]) bySec[c.section]=[] ; bySec[c.section].push(k); });
|
|
|
|
list.innerHTML = Object.entries(bySec).map(([sec, ks]) => `
|
|
<div class="chunk">
|
|
<div style="font-family:'Space Mono',monospace;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);margin-bottom:8px">
|
|
${sec} (${ks.length})
|
|
</div>
|
|
${ks.map(k => renderCollision(k)).join('')}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderCollision(key) {
|
|
const c = collisions[key];
|
|
const safeKey = key.replace(/:/g,'_');
|
|
const curRename = c.renameId || '';
|
|
return `<div class="collision-item" id="col-${safeKey}">
|
|
<div class="collision-id">#${esc(c.id)}</div>
|
|
<div class="collision-names">
|
|
<span class="file-badge file-a">A: ${esc(c.nameA)}</span>
|
|
<span style="color:var(--muted);font-size:0.7rem">vs</span>
|
|
<span class="file-badge file-b">B: ${esc(c.nameB)}</span>
|
|
</div>
|
|
<div class="resolution-row">
|
|
<div class="radio-group" id="rg-${safeKey}">
|
|
<label class="selected-a">
|
|
<input type="radio" name="col-${safeKey}" value="a" checked onchange="setResolution('${key}','a')">
|
|
Keep A
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="col-${safeKey}" value="b" onchange="setResolution('${key}','b')">
|
|
Use B
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="col-${safeKey}" value="skip" onchange="setResolution('${key}','skip')">
|
|
Skip B
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="col-${safeKey}" value="rename" onchange="setResolution('${key}','rename')">
|
|
Keep both → rename B
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="rename-row-${safeKey}" style="display:${c.resolution==='rename'?'flex':'none'};align-items:center;gap:8px;margin-top:10px;padding:10px;background:var(--bg2);border-radius:3px;flex-wrap:wrap">
|
|
<span style="font-family:'Space Mono',monospace;font-size:0.68rem;color:var(--muted)">New ID for B's item:</span>
|
|
<span class="file-badge file-b">#${esc(c.id)}</span>
|
|
<span style="color:var(--muted)">→</span>
|
|
<input type="text" placeholder="new-id-for-b" value="${esc(curRename)}"
|
|
style="width:180px"
|
|
oninput="collisions['${key}'].renameId=this.value"
|
|
id="rename-input-${safeKey}">
|
|
<span style="font-family:'Space Mono',monospace;font-size:0.62rem;color:var(--muted)">(also updates item.id inside the object)</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function setResolution(key, val) {
|
|
collisions[key].resolution = val;
|
|
const safeKey = key.replace(/:/g,'_');
|
|
const rg = document.getElementById('rg-' + safeKey);
|
|
const renameRow = document.getElementById('rename-row-' + safeKey);
|
|
if (renameRow) renameRow.style.display = val === 'rename' ? 'flex' : 'none';
|
|
if (!rg) return;
|
|
rg.querySelectorAll('label').forEach(l => {
|
|
l.className = '';
|
|
const r = l.querySelector('input');
|
|
if (r && r.value === val) {
|
|
if (val === 'a') l.className = 'selected-a';
|
|
else if (val === 'b') l.className = 'selected-b';
|
|
else if (val === 'skip') l.className = 'selected-skip';
|
|
else if (val === 'rename') l.className = 'selected-rename';
|
|
}
|
|
});
|
|
}
|
|
|
|
function resolveAll(val) {
|
|
Object.keys(collisions).forEach(k => {
|
|
collisions[k].resolution = val;
|
|
setResolution(k, val);
|
|
});
|
|
}
|
|
|
|
// ─── TAG ANALYSIS ──────────────────────────────────────────────────────────────
|
|
function collectTags(pu) {
|
|
const tags = {}; // tag -> count
|
|
SECTIONS.forEach(sec => {
|
|
Object.values(pu[sec] || {}).forEach(item => {
|
|
[...Object.values(item.tasks || {}), ...Object.values(item.perks || {})].forEach(entry => {
|
|
(entry.tags || []).forEach(t => { tags[t] = (tags[t]||0) + 1; });
|
|
});
|
|
});
|
|
});
|
|
return tags;
|
|
}
|
|
|
|
function analyzeTags() {
|
|
const tagsA = collectTags(fileA);
|
|
const tagsB = collectTags(fileB);
|
|
const allA = new Set(Object.keys(tagsA));
|
|
const allB = new Set(Object.keys(tagsB));
|
|
const both = [...allA].filter(t => allB.has(t));
|
|
const aOnly = [...allA].filter(t => !allB.has(t));
|
|
const bOnly = [...allB].filter(t => !allA.has(t));
|
|
|
|
const allTags = [...new Set([...allA, ...allB])];
|
|
// init tagRenames for any not yet set
|
|
allTags.forEach(t => { if (tagRenames[t] === undefined) tagRenames[t] = { value: '', mode: 'rename' }; });
|
|
|
|
function renderTagGroup(tags) {
|
|
if (tags.length === 0) return '';
|
|
return tags.map(tag => {
|
|
const cntA = tagsA[tag] || 0;
|
|
const cntB = tagsB[tag] || 0;
|
|
const entry = tagRenames[tag] || { value: '', mode: 'rename' };
|
|
const isAdd = entry.mode === 'add';
|
|
const safeTag = esc(tag);
|
|
return `<div class="tag-row" style="padding:8px 12px" id="tagrow-${safeTag}">
|
|
<span class="tag-pill">${safeTag}</span>
|
|
${cntA > 0 ? `<span class="tag-count-a">A: ${cntA}</span>` : ''}
|
|
${cntB > 0 ? `<span class="tag-count-b">B: ${cntB}</span>` : ''}
|
|
<span class="arrow">→</span>
|
|
<div style="display:flex;border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;flex-shrink:0">
|
|
<button onclick="setTagMode('${safeTag}','rename')"
|
|
style="padding:5px 10px;font-size:0.58rem;border-radius:0;border:none;letter-spacing:0.06em;
|
|
background:${!isAdd?'var(--accent)':'var(--surface2)'};
|
|
color:${!isAdd?'#000':'var(--muted)'}">
|
|
RENAME
|
|
</button>
|
|
<button onclick="setTagMode('${safeTag}','add')"
|
|
style="padding:5px 10px;font-size:0.58rem;border-radius:0;border:none;border-left:1px solid var(--border);letter-spacing:0.06em;
|
|
background:${isAdd?'var(--accent2)':'var(--surface2)'};
|
|
color:${isAdd?'#fff':'var(--muted)'}">
|
|
ADD
|
|
</button>
|
|
</div>
|
|
<input type="text" placeholder="${isAdd ? 'tag to also add…' : 'new name…'}" value="${esc(entry.value||'')}"
|
|
oninput="tagRenames['${safeTag}'].value = this.value"
|
|
style="width:140px;flex:1;min-width:80px">
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
document.getElementById('tags-both').innerHTML = renderTagGroup(both.sort());
|
|
document.getElementById('tags-a-only').innerHTML = renderTagGroup(aOnly.sort());
|
|
document.getElementById('tags-b-only').innerHTML = renderTagGroup(bOnly.sort());
|
|
|
|
document.getElementById('tag-chunk-both').style.display = both.length ? '' : 'none';
|
|
document.getElementById('tags-a-empty').style.display = aOnly.length ? 'none' : '';
|
|
document.getElementById('tags-b-empty').style.display = bOnly.length ? 'none' : '';
|
|
document.getElementById('tag-exclusive-wrap').style.display = (aOnly.length || bOnly.length) ? 'grid' : 'none';
|
|
}
|
|
|
|
function setTagMode(tag, mode) {
|
|
if (!tagRenames[tag]) tagRenames[tag] = { value: '', mode: 'rename' };
|
|
tagRenames[tag].mode = mode;
|
|
// re-render the tag phases to reflect button state change
|
|
analyzeTags();
|
|
}
|
|
|
|
function clearAllRenames() {
|
|
Object.keys(tagRenames).forEach(k => tagRenames[k] = { value: '', mode: 'rename' });
|
|
document.querySelectorAll('.rename-input').forEach(inp => inp.value = '');
|
|
}
|
|
|
|
// ─── BUILD SUMMARY ─────────────────────────────────────────────────────────────
|
|
function buildSummary() {
|
|
// Count what will be merged
|
|
let added = 0, skipped = 0, overridden = 0;
|
|
const colKeys = Object.keys(collisions);
|
|
colKeys.forEach(k => {
|
|
const r = collisions[k].resolution;
|
|
if (r === 'skip') skipped++;
|
|
else if (r === 'b') overridden++;
|
|
else added++; // keep a = no change to count but B item dropped
|
|
});
|
|
|
|
SECTIONS.forEach(sec => {
|
|
Object.keys(fileB[sec]||{}).forEach(id => {
|
|
if (!collisions[sec+'::'+id]) added++;
|
|
});
|
|
});
|
|
|
|
const tagOps = Object.entries(tagRenames).filter(([k,v]) => v.value && v.value.trim() && v.value.trim() !== k);
|
|
|
|
document.getElementById('summary-grid').innerHTML = [
|
|
{ v: Object.keys(fileA.classes||{}).length, l: 'Classes (A)' },
|
|
{ v: Object.keys(fileB.classes||{}).length, l: 'Classes (B)' },
|
|
{ v: added, l: 'Items Added' },
|
|
{ v: skipped, l: 'Items Skipped' },
|
|
{ v: overridden, l: 'Overridden' },
|
|
{ v: tagOps.length, l: 'Tag Ops' },
|
|
].map(s => `<div class="summary-stat"><div class="stat-val">${s.v}</div><div class="stat-lbl">${s.l}</div></div>`).join('');
|
|
|
|
document.getElementById('output-mapid').value = fileA.mapId || '';
|
|
|
|
if (tagOps.length > 0) {
|
|
document.getElementById('tag-rename-summary').innerHTML = `
|
|
<div style="font-family:'Space Mono',monospace;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em;color:var(--muted);margin-bottom:8px">Tag operations</div>
|
|
<div class="card">
|
|
${tagOps.map(([from, entry]) => `
|
|
<div class="rename-summary-row">
|
|
<span style="color:var(--accent)">${esc(from)}</span>
|
|
<span class="tag-${entry.mode==='add'?'count-b':'count-a'}" style="font-size:0.6rem">${entry.mode}</span>
|
|
<span style="color:var(--muted)">→</span>
|
|
<span style="color:var(--accent2)">${esc(entry.value)}</span>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
} else {
|
|
document.getElementById('tag-rename-summary').innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// ─── MERGE & EXPORT ────────────────────────────────────────────────────────────
|
|
// Returns array of tags: rename → [newTag], add → [tag, newTag], else → [tag]
|
|
function applyTagOp(tag) {
|
|
const entry = tagRenames[tag];
|
|
if (!entry || !entry.value.trim()) return [tag];
|
|
const newVal = entry.value.trim();
|
|
if (entry.mode === 'add') return [tag, newVal];
|
|
return [newVal]; // rename
|
|
}
|
|
|
|
function retagItem(item) {
|
|
const retagEntries = entries => {
|
|
Object.values(entries).forEach(e => {
|
|
if (e.tags) {
|
|
// flatMap so 'add' mode injects extra tags, dedup
|
|
const result = [];
|
|
e.tags.forEach(t => { applyTagOp(t).forEach(nt => { if (!result.includes(nt)) result.push(nt); }); });
|
|
e.tags = result;
|
|
}
|
|
});
|
|
};
|
|
if (item.tasks) retagEntries(item.tasks);
|
|
if (item.perks) retagEntries(item.perks);
|
|
return item;
|
|
}
|
|
|
|
// Rename every internal reference to oldId → newId within a cloned item.
|
|
// Covers: item.id, task keys+task.id, parameter keys+$paramN in task text,
|
|
// perk keys+perk.id, prerequisites array.
|
|
function renameIdsInItem(item, oldId, newId) {
|
|
const old = String(oldId);
|
|
const nw = String(newId);
|
|
|
|
// top-level id
|
|
if (String(item.id) === old) item.id = nw;
|
|
|
|
// prerequisites (stored as numbers or strings)
|
|
if (Array.isArray(item.prerequisites)) {
|
|
item.prerequisites = item.prerequisites.map(p => String(p) === old ? (isNaN(nw) ? nw : Number(nw)) : p);
|
|
}
|
|
|
|
// tasks: rename keys, internal id, parameter keys, $paramN in task text
|
|
if (item.tasks && item.tasks[old] !== undefined) {
|
|
const task = item.tasks[old];
|
|
delete item.tasks[old];
|
|
|
|
// rename parameter sub-ids that match old too (task params often share the item's id namespace)
|
|
if (task.parameters) {
|
|
const newParams = {};
|
|
Object.entries(task.parameters).forEach(([pk, pv]) => {
|
|
const newPk = String(pk) === old ? nw : pk;
|
|
if (pv && String(pv.id) === old) pv.id = newPk;
|
|
newParams[newPk] = pv;
|
|
});
|
|
task.parameters = newParams;
|
|
// rewrite $paramOLD → $paramNEW in task text
|
|
if (task.task) task.task = task.task.replace(new RegExp('\\$param' + old + '\\b', 'g'), '$param' + nw);
|
|
}
|
|
|
|
if (String(task.id) === old) task.id = nw;
|
|
item.tasks[nw] = task;
|
|
}
|
|
|
|
// also rename parameter keys/ids inside ALL tasks if they reference old
|
|
if (item.tasks) {
|
|
Object.values(item.tasks).forEach(task => {
|
|
if (!task.parameters) return;
|
|
const newParams = {};
|
|
Object.entries(task.parameters).forEach(([pk, pv]) => {
|
|
const newPk = String(pk) === old ? nw : pk;
|
|
if (pv && String(pv.id) === old) pv.id = newPk;
|
|
newParams[newPk] = pv;
|
|
});
|
|
task.parameters = newParams;
|
|
if (task.task) task.task = task.task.replace(new RegExp('\\$param' + old + '\\b', 'g'), '$param' + nw);
|
|
});
|
|
}
|
|
|
|
// perks: rename keys + internal id
|
|
if (item.perks && item.perks[old] !== undefined) {
|
|
const perk = item.perks[old];
|
|
delete item.perks[old];
|
|
if (String(perk.id) === old) perk.id = nw;
|
|
item.perks[nw] = perk;
|
|
}
|
|
|
|
return item;
|
|
}
|
|
|
|
function exportMerged() {
|
|
// deep clone A as base
|
|
const merged = JSON.parse(JSON.stringify(fileA));
|
|
merged.mapId = document.getElementById('output-mapid').value || fileA.mapId;
|
|
|
|
// retag all of A
|
|
SECTIONS.forEach(sec => {
|
|
Object.values(merged[sec]||{}).forEach(item => retagItem(item));
|
|
});
|
|
|
|
// merge B sections
|
|
SECTIONS.forEach(sec => {
|
|
if (!merged[sec]) merged[sec] = {};
|
|
const bSec = fileB[sec] || {};
|
|
Object.entries(bSec).forEach(([id, item]) => {
|
|
const colKey = sec + '::' + id;
|
|
if (collisions[colKey]) {
|
|
const res = collisions[colKey].resolution;
|
|
if (res === 'b') merged[sec][id] = retagItem(JSON.parse(JSON.stringify(item)));
|
|
else if (res === 'skip') { /* drop */ }
|
|
else if (res === 'rename') {
|
|
const newId = collisions[colKey].renameId?.trim();
|
|
if (newId) {
|
|
let cloned = JSON.parse(JSON.stringify(item));
|
|
cloned = renameIdsInItem(cloned, id, newId); // rewrite all internal refs first
|
|
cloned = retagItem(cloned);
|
|
merged[sec][newId] = cloned;
|
|
}
|
|
// else treat as skip
|
|
}
|
|
// 'a' = keep existing, do nothing
|
|
} else {
|
|
// no collision, just add
|
|
merged[sec][id] = retagItem(JSON.parse(JSON.stringify(item)));
|
|
}
|
|
});
|
|
});
|
|
|
|
// merge general (keep A's but fill blanks from B)
|
|
if (fileB.general) {
|
|
if (!merged.general) merged.general = {};
|
|
Object.entries(fileB.general).forEach(([k,v]) => {
|
|
if (!merged.general[k]) merged.general[k] = v;
|
|
});
|
|
}
|
|
|
|
const filename = document.getElementById('output-filename').value || 'merged.pu';
|
|
const blob = new Blob([JSON.stringify(merged)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
toast('Merged file downloaded!');
|
|
}
|
|
|
|
// ─── HELPERS ───────────────────────────────────────────────────────────────────
|
|
function esc(str) {
|
|
return String(str)
|
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
.replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
function toast(msg) {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg; el.classList.add('show');
|
|
setTimeout(() => el.classList.remove('show'), 2200);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|