add Mapmerge tool, update unit input to freetext

This commit is contained in:
Johannes
2026-03-12 21:47:02 +01:00
parent 7b8842d3a1
commit 6e36e1ab06
2 changed files with 940 additions and 5 deletions

View File

@@ -862,11 +862,7 @@ function renderParamBlock(taskKey, paramKey, param) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Unit</label> <label>Unit</label>
<select id="p-${taskKey}-${paramKey}-unit"> <input type="text" id="p-${taskKey}-${paramKey}-unit" value="${esc(param.unit||'')}" placeholder="minutes, reps…">
${['minutes','hours','days','weeks',''].map(u =>
`<option value="${u}" ${param.unit===u?'selected':''}>${u||'(none)'}</option>`
).join('')}
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Timer info</label> <label>Timer info</label>

939
Mapmerge/Mapmerge.html Normal file
View File

@@ -0,0 +1,939 @@
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg; el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2200);
}
</script>
</body>
</html>