Forum

Bitte oder Registrieren, um Beiträge und Themen zu erstellen.

PGN - Merger


#pgnm-root{
--pgnm-navy:#0B1220;
--pgnm-panel:#10284A;
--pgnm-line:#1E3A63;
--pgnm-blue:#2E86FF;
--pgnm-blue-dim:#1D5FBF;
--pgnm-text:#E7ECF5;
--pgnm-muted:#8FA3C2;
--pgnm-amber:#E8B923;
--pgnm-amber-bg:#2A2410;
--pgnm-mono: "SF Mono", "Cascadia Code", Consolas, "Courier New", monospace;
--pgnm-sans: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

background:var(--pgnm-navy);
color:var(--pgnm-text);
font-family:var(--pgnm-sans);
border-radius:12px;
padding:28px 24px 44px;
max-width:980px;
margin:0 auto;
box-sizing:border-box;
}
#pgnm-root *{box-sizing:border-box;}
#pgnm-root .pgnm-wrap{max-width:980px;margin:0 auto;}

#pgnm-root header{display:flex;align-items:flex-start;gap:16px;margin-bottom:28px;}
#pgnm-root header svg{flex:0 0 auto;margin-top:4px;}
#pgnm-root h1{font-size:26px;margin:0 0 4px;font-weight:700;letter-spacing:-0.01em;color:var(--pgnm-text);}
#pgnm-root header p{margin:0;color:var(--pgnm-muted);font-size:14.5px;max-width:560px;line-height:1.5;}

#pgnm-root .pgnm-step{
background:var(--pgnm-panel);
border:1px solid var(--pgnm-line);
border-radius:10px;
padding:20px 22px;
margin-bottom:20px;
}
#pgnm-root .pgnm-step h2{
display:flex;align-items:center;gap:10px;
font-size:15px;text-transform:uppercase;letter-spacing:.06em;
color:var(--pgnm-blue);margin:0 0 14px;font-weight:700;
}
#pgnm-root .pgnm-step h2 .pgnm-num{
width:22px;height:22px;border-radius:50%;background:var(--pgnm-blue-dim);
color:#fff;font-size:12px;display:flex;align-items:center;justify-content:center;
letter-spacing:0;
}

#pgnm-root .pgnm-dropzone{
display:block;
border:1.5px dashed var(--pgnm-line);
border-radius:8px;
padding:22px;
text-align:center;
color:var(--pgnm-muted);
font-size:14px;
cursor:pointer;
transition:border-color .15s, background .15s;
}
#pgnm-root .pgnm-dropzone:hover, #pgnm-root .pgnm-dropzone.pgnm-drag{border-color:var(--pgnm-blue);background:rgba(46,134,255,0.06);}
#pgnm-root .pgnm-dropzone input{display:none;}
#pgnm-root .pgnm-or{margin:10px 0;font-size:12px;color:var(--pgnm-muted);text-align:center;}

#pgnm-root textarea.pgnm-paste{
width:100%;min-height:90px;margin-top:10px;
background:var(--pgnm-navy);color:var(--pgnm-text);
border:1px solid var(--pgnm-line);border-radius:6px;
font-family:var(--pgnm-mono);font-size:12.5px;padding:10px;resize:vertical;
}
#pgnm-root .pgnm-paste-row{display:flex;gap:10px;align-items:flex-start;margin-top:10px;}
#pgnm-root .pgnm-paste-row textarea{flex:1;}
#pgnm-root .pgnm-btn{
background:var(--pgnm-blue);color:#fff;border:none;border-radius:6px;
padding:9px 16px;font-size:13.5px;font-weight:600;cursor:pointer;
transition:background .15s;white-space:nowrap;
}
#pgnm-root .pgnm-btn:hover{background:#4C99FF;}
#pgnm-root .pgnm-btn.pgnm-secondary{background:transparent;border:1px solid var(--pgnm-line);color:var(--pgnm-text);}
#pgnm-root .pgnm-btn.pgnm-secondary:hover{border-color:var(--pgnm-blue);color:var(--pgnm-blue);}
#pgnm-root .pgnm-btn:disabled{opacity:.4;cursor:not-allowed;}
#pgnm-root .pgnm-btn.pgnm-small{padding:5px 10px;font-size:12px;}

#pgnm-root .pgnm-gamelist{margin-top:16px;display:flex;flex-direction:column;gap:6px;}
#pgnm-root .pgnm-gamerow{
display:flex;align-items:center;gap:10px;
background:var(--pgnm-navy);border:1px solid var(--pgnm-line);border-radius:6px;
padding:9px 12px;font-size:13px;
}
#pgnm-root .pgnm-gamerow .pgnm-idx{color:var(--pgnm-muted);font-family:var(--pgnm-mono);font-size:12px;width:18px;}
#pgnm-root .pgnm-gamerow .pgnm-glabel{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
#pgnm-root .pgnm-gamerow .pgnm-glabel .pgnm-gsrc{color:var(--pgnm-muted);font-size:11.5px;margin-left:6px;}
#pgnm-root .pgnm-gamerow .pgnm-gtag{
font-size:10.5px;text-transform:uppercase;letter-spacing:.04em;
color:var(--pgnm-blue);border:1px solid var(--pgnm-blue-dim);border-radius:4px;
padding:2px 6px;flex:0 0 auto;
}
#pgnm-root .pgnm-gamerow .pgnm-ord{display:flex;gap:2px;}
#pgnm-root .pgnm-gamerow button.pgnm-icon{
background:none;border:1px solid var(--pgnm-line);color:var(--pgnm-muted);
border-radius:4px;width:24px;height:24px;cursor:pointer;font-size:12px;
display:flex;align-items:center;justify-content:center;
}
#pgnm-root .pgnm-gamerow button.pgnm-icon:hover{border-color:var(--pgnm-blue);color:var(--pgnm-blue);}
#pgnm-root .pgnm-gamerow button.pgnm-icon.pgnm-danger:hover{border-color:#E85D5D;color:#E85D5D;}
#pgnm-root .pgnm-empty-note{color:var(--pgnm-muted);font-size:13px;padding:10px 2px;}

#pgnm-root .pgnm-options{display:flex;gap:22px;flex-wrap:wrap;margin:16px 0 4px;font-size:13.5px;}
#pgnm-root .pgnm-options label{display:flex;align-items:center;gap:7px;cursor:pointer;color:var(--pgnm-text);}
#pgnm-root .pgnm-options input[type=radio], #pgnm-root .pgnm-options input[type=checkbox]{accent-color:var(--pgnm-blue);}

#pgnm-root .pgnm-callout{
background:var(--pgnm-amber-bg);border:1px solid var(--pgnm-amber);
border-radius:6px;padding:10px 14px;font-size:12.5px;color:#F0D28C;
margin-top:14px;line-height:1.5;
}
#pgnm-root .pgnm-callout b{color:var(--pgnm-amber);}

#pgnm-root .pgnm-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:16px;}

#pgnm-root textarea.pgnm-output{
width:100%;min-height:220px;background:var(--pgnm-navy);color:var(--pgnm-text);
border:1px solid var(--pgnm-line);border-radius:6px;font-family:var(--pgnm-mono);
font-size:12.5px;padding:12px;line-height:1.5;
}
#pgnm-root .pgnm-output-actions{display:flex;justify-content:space-between;align-items:center;margin-top:10px;gap:10px;flex-wrap:wrap;}
#pgnm-root .pgnm-output-actions .pgnm-stats{color:var(--pgnm-muted);font-size:12.5px;}
#pgnm-root .pgnm-output-actions .pgnm-btns{display:flex;gap:8px;}

#pgnm-root .pgnm-footer-note{text-align:center;color:var(--pgnm-muted);font-size:12px;margin-top:30px;}

#pgnm-root ::selection{background:var(--pgnm-blue-dim);}

PGN-Merger

Führt mehrere Partien aus einer oder mehreren PGN-Dateien zu einer einzigen Partie mit Variantenbaum zusammen — inklusive Kommentaren, Bewertungssymbolen (NAGs) und Transpositions-freundlicher Struktur. Läuft komplett lokal im Browser, es wird nichts hochgeladen.

1 Partien laden

PGN-Dateien hier ablegen oder klicken zum Auswählen
Mehrere Dateien und Multi-Partien-Dateien werden unterstützt

— oder —

Noch keine Partien geladen.

2 Merge-Optionen


Hinweis zur Reihenfolge: Die erste Partie in der Liste unten wird zur Hauptlinie. Alle folgenden Partien werden dort, wo sie abweichen, als Variante angehängt. Mit den Pfeilen ↑↓ bei jeder Partie lässt sich die Reihenfolge anpassen. Echte Transpositions-Erkennung ist nicht enthalten.

3 Zusammenführen


(function(){
/* ============ PGN CORE: tokenize / parse / merge / serialize ============ */

function tokenize(movetext) {
const tokens = [];
let i = 0;
const n = movetext.length;
while (i < n) {
const c = movetext[i];
if (/s/.test(c)) { i++; continue; }
if (c === '{') {
let j = movetext.indexOf('}', i + 1);
if (j === -1) j = n;
tokens.push({ type: 'comment', value: movetext.slice(i + 1, j) });
i = j + 1; continue;
}
if (c === ';') {
let j = movetext.indexOf('n', i);
if (j === -1) j = n;
tokens.push({ type: 'comment', value: movetext.slice(i + 1, j) });
i = j + 1; continue;
}
if (c === '(') { tokens.push({ type: '(' }); i++; continue; }
if (c === ')') { tokens.push({ type: ')' }); i++; continue; }
if (c === '$') {
let j = i + 1;
while (j < n && /d/.test(movetext[j])) j++;
tokens.push({ type: 'nag', value: movetext.slice(i, j) });
i = j; continue;
}
let j = i;
while (j < n && !/[s(){}]/.test(movetext[j])) j++;
const tok = movetext.slice(i, j);
i = j;
if (/^d+.+$/.test(tok)) continue;
if (/^(1-0|0-1|1/2-1/2|*)$/.test(tok)) { tokens.push({ type: 'result', value: tok }); continue; }
tokens.push({ type: 'move', value: tok });
}
return tokens;
}

function parseMovetext(tokens) {
let pos = 0;
function parseBranch(intoArray) {
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.type === 'move') {
const node = { san: tok.value, comment: '', nags: [], children: [] };
intoArray.push(node);
pos++;
while (pos < tokens.length && (tokens[pos].type === 'comment' || tokens[pos].type === 'nag')) {
if (tokens[pos].type === 'comment') node.comment += (node.comment ? 'nn' : '') + tokens[pos].value.trim();
else node.nags.push(tokens[pos].value);
pos++;
}
while (pos < tokens.length && tokens[pos].type === '(') {
pos++;
parseBranch(intoArray);
if (pos t.san === sNode.san);
if (match) {
if (options.mergeComments && sNode.comment) {
if (!match.comment) match.comment = sNode.comment;
else if (!match.comment.includes(sNode.comment)) match.comment += 'nn' + sNode.comment;
}
if (options.mergeComments) for (const nag of sNode.nags) if (!match.nags.includes(nag)) match.nags.push(nag);
mergeBranch(match.children, sNode.children, options);
} else {
if (!options.mergeComments) stripCommentsDeep(sNode);
targetArr.push(sNode);
}
}
}

function moveToken(node, ply, forceNumber) {
const moveNumber = Math.floor(ply / 2) + 1;
const isWhite = (ply % 2 === 0);
let s = '';
if (isWhite) s += moveNumber + '. ';
else if (forceNumber) s += moveNumber + '... ';
s += node.san;
if (node.nags.length) s += ' ' + node.nags.join(' ');
if (node.comment) s += ' {' + node.comment.replace(/}/g, ')').replace(/n{2,}/g, ' ') + '}';
return s;
}

function serializeBranchSet(nodes, ply, forceNumber) {
if (!nodes || nodes.length === 0) return '';
const main = nodes[0];
let out = moveToken(main, ply, forceNumber);
const variantParts = [];
for (let i = 1; i < nodes.length; i++) variantParts.push('(' + serializeBranchSet([nodes[i]], ply, true) + ')');
if (variantParts.length) out += ' ' + variantParts.join(' ');
const cont = serializeBranchSet(main.children, ply + 1, false);
if (cont) out += ' ' + cont;
return out;
}

/* ============ APP STATE / UI (all scoped inside #pgnm-root) ============ */

let loadedGames = [];
let idCounter = 0;

const root = document.getElementById('pgnm-root');
const gameListEl = root.querySelector('#pgnm-gameList');
const emptyNote = root.querySelector('#pgnm-emptyNote');
const mergeBtn = root.querySelector('#pgnm-mergeBtn');
const outputStep = root.querySelector('#pgnm-outputStep');
const outputArea = root.querySelector('#pgnm-outputArea');
const outputStats = root.querySelector('#pgnm-outputStats');

function headerLabel(headers) {
const w = headers.White || '?';
const b = headers.Black || '?';
const ev = headers.Event;
if (w !== '?' || b !== '?') return w + ' – ' + b;
return ev || 'Unbenannte Partie';
}

function addGamesFromText(text, sourceName) {
const games = splitGames(text);
let added = 0;
for (const g of games) {
const tokens = tokenize(g.movetext);
const branches = parseMovetext(tokens);
if (branches.length === 0) continue;
loadedGames.push({
id: ++idCounter,
label: headerLabel(g.headers),
source: sourceName,
headers: g.headers,
branches: branches,
moveCount: countNodes(branches)
});
added++;
}
renderGameList();
return added;
}

function escapeHtml(s) {
return (s || '').replace(/[&"']/g, c => ({'&':'&','':'>','"':'"',"'":'''}[c]));
}

function renderGameList() {
gameListEl.innerHTML = '';
if (loadedGames.length === 0) {
gameListEl.appendChild(emptyNote);
mergeBtn.disabled = true;
return;
}
mergeBtn.disabled = false;
loadedGames.forEach((g, i) => {
const row = document.createElement('div');
row.className = 'pgnm-gamerow';
row.innerHTML = `
${i + 1}.
${escapeHtml(g.label)}${escapeHtml(g.source)} · ${g.moveCount} Halbzüge
${i === 0 ? 'Hauptlinie' : ''}




`;
gameListEl.appendChild(row);
});
}

gameListEl.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const id = Number(btn.dataset.id);
const idx = loadedGames.findIndex(g => g.id === id);
if (idx === -1) return;
if (btn.dataset.act === 'remove') loadedGames.splice(idx, 1);
if (btn.dataset.act === 'up' && idx > 0) [loadedGames[idx - 1], loadedGames[idx]] = [loadedGames[idx], loadedGames[idx - 1]];
if (btn.dataset.act === 'down' && idx {
loadedGames = [];
outputStep.style.display = 'none';
renderGameList();
});

const dropzone = root.querySelector('#pgnm-dropzone');
const fileInput = root.querySelector('#pgnm-fileInput');

fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
['dragover'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.add('pgnm-drag'); }));
['dragleave', 'drop'].forEach(evt => dropzone.addEventListener(evt, (e) => { e.preventDefault(); dropzone.classList.remove('pgnm-drag'); }));
dropzone.addEventListener('drop', (e) => { if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); });

function handleFiles(files) {
const readers = [];
for (const file of files) {
readers.push(new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => { addGamesFromText(reader.result, file.name); resolve(); };
reader.readAsText(file);
}));
}
Promise.all(readers).then(() => { fileInput.value = ''; });
}

root.querySelector('#pgnm-addPasteBtn').addEventListener('click', () => {
const area = root.querySelector('#pgnm-pasteArea');
const text = area.value.trim();
if (!text) return;
const n = addGamesFromText(text, 'Eingefügt');
if (n > 0) area.value = '';
});

mergeBtn.addEventListener('click', () => {
const mode = root.querySelector('input[name=pgnm-mode]:checked').value;
const options = { mergeComments: mode === 'full' };
const merged = [];
for (const g of loadedGames) {
const cloned = JSON.parse(JSON.stringify(g.branches));
mergeBranch(merged, cloned, options);
}
const movetext = serializeBranchSet(merged, 0, false);
const first = loadedGames[0];
const headerLines = [];
headerLines.push(`[Event "Merged repertoire (${loadedGames.length} Partien)"]`);
headerLines.push(`[Site "PGN-Merger"]`);
headerLines.push(`[Date "????.??.??"]`);
headerLines.push(`[White "${(first && first.headers.White) || '?'}"]`);
headerLines.push(`[Black "${(first && first.headers.Black) || '?'}"]`);
headerLines.push(`[Result "*"]`);
const pgn = headerLines.join('n') + 'nn' + movetext + ' *n';
outputArea.value = pgn;
const totalNodes = countNodes(merged);
outputStats.textContent = `${loadedGames.length} Partien → 1 Baum mit ${totalNodes} Stellungen (Modus: ${mode === 'full' ? 'vollständig' : 'nur Zugfolgen'})`;
outputStep.style.display = 'block';
outputStep.scrollIntoView({ behavior: 'smooth', block: 'start' });
});

root.querySelector('#pgnm-copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText(outputArea.value).then(() => {
const btn = root.querySelector('#pgnm-copyBtn');
const old = btn.textContent;
btn.textContent = 'Kopiert ✓';
setTimeout(() => btn.textContent = old, 1500);
});
});

root.querySelector('#pgnm-downloadBtn').addEventListener('click', () => {
const blob = new Blob([outputArea.value], { type: 'application/x-chess-pgn' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'merged_repertoire.pgn';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
})();

Nach oben scrollen