423 lines
14 KiB
HTML
423 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Template Merge</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #333; padding: 2rem; }
|
|
h1 { margin-bottom: 1.5rem; }
|
|
.container { max-width: 900px; margin: 0 auto; display: flex; flex-direction: column; gap: 1.5rem; }
|
|
label { font-weight: 600; display: block; margin-bottom: 0.3rem; }
|
|
textarea { width: 100%; min-height: 120px; font-family: monospace; font-size: 0.95rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; resize: vertical; }
|
|
.data-section { border: 1px solid #ccc; border-radius: 4px; padding: 0.75rem; background: #fff; }
|
|
.data-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; }
|
|
.data-row input { flex: 1; font-family: monospace; font-size: 0.9rem; padding: 0.35rem 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
|
.data-row .remove-btn { background: #e55; color: #fff; border: none; border-radius: 4px; padding: 0.35rem 0.6rem; cursor: pointer; }
|
|
.btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
|
button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem; }
|
|
.add-btn { background: #4a9; color: #fff; }
|
|
.merge-btn { background: #36c; color: #fff; font-size: 1rem; padding: 0.7rem 1.5rem; }
|
|
.clear-btn { background: #aaa; color: #fff; }
|
|
.clear-btn:disabled, #copy-btn:disabled, #tpl-clear-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
#output { width: 100%; min-height: 150px; font-family: monospace; font-size: 0.95rem; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; background: #fff; white-space: pre-wrap; }
|
|
.hint { font-size: 0.8rem; color: #888; margin-top: 0.2rem; }
|
|
.error { color: #c33; font-size: 0.85rem; margin-top: 0.3rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Template Merge</h1>
|
|
|
|
<div>
|
|
<label for="template">Template</label>
|
|
<div style="position:relative;">
|
|
<textarea id="template" placeholder="Hello {{name}}, you have {{count}} messages. Items: {{#items}}- {{value}} {{/items}}"></textarea>
|
|
<button class="add-btn" onclick="pasteTemplate()" id="paste-btn" style="position:absolute;top:0.5rem;right:0.5rem;padding:0.35rem 0.6rem;">Paste</button>
|
|
<button class="clear-btn" onclick="clearTemplate()" id="tpl-clear-btn" style="position:absolute;top:0.5rem;right:4.7rem;padding:0.35rem 0.6rem;">Clear</button>
|
|
</div>
|
|
<div class="hint">Use <code>{{key}}</code> for variables, <code>{{#list}}...{{/list}}</code> for loop blocks. Use <code>{{value}}</code> when data is a simple string.</div>
|
|
</div>
|
|
|
|
<div class="data-section">
|
|
<label>Data List</label>
|
|
<div id="data-list"></div>
|
|
<div class="btn-row">
|
|
<button class="add-btn" onclick="addDataItem('')">+ String</button>
|
|
<button class="add-btn" onclick="addDataItem('{}')">+ JSON</button>
|
|
<button class="clear-btn" onclick="clearData()">Clear All</button>
|
|
<button class="add-btn" onclick="showJsonlImport()">Import JSONL</button>
|
|
<button class="add-btn" onclick="showTextImport()">Import Lines</button>
|
|
</div>
|
|
<div id="jsonl-panel" style="display:none; margin-top:0.5rem;">
|
|
<textarea id="jsonl-input" placeholder='{"key":"value"}\n{"key2":"value2"}' style="min-height:80px;"></textarea>
|
|
<div class="btn-row">
|
|
<button class="merge-btn" onclick="importJsonl()">Import</button>
|
|
<button class="clear-btn" onclick="hideJsonlImport()">Cancel</button>
|
|
</div>
|
|
<div id="jsonl-error" class="error"></div>
|
|
</div>
|
|
<div id="text-panel" style="display:none; margin-top:0.5rem;">
|
|
<textarea id="text-input" placeholder="line1 line2 line3" style="min-height:80px;"></textarea>
|
|
<div class="btn-row">
|
|
<button class="merge-btn" onclick="importLines()">Import</button>
|
|
<button class="clear-btn" onclick="hideTextImport()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="merge-btn" onclick="merge()">Merge</button>
|
|
|
|
<div>
|
|
<label for="output">Output</label>
|
|
<div id="error" class="error"></div>
|
|
<div style="position:relative;">
|
|
<div id="output" readonly></div>
|
|
<button class="add-btn" onclick="copyOutput()" id="copy-btn" style="position:absolute;top:0.5rem;right:0.5rem;padding:0.35rem 0.6rem;" disabled>Copy</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let dataItems = [];
|
|
let itemIdCounter = 0;
|
|
|
|
// --- LocalStorage ---
|
|
|
|
function saveState() {
|
|
localStorage.setItem('template', document.getElementById('template').value);
|
|
localStorage.setItem('dataItems', JSON.stringify(dataItems));
|
|
}
|
|
|
|
function loadState() {
|
|
const tpl = localStorage.getItem('template');
|
|
if (tpl != null) document.getElementById('template').value = tpl;
|
|
const items = localStorage.getItem('dataItems');
|
|
if (items != null) {
|
|
try {
|
|
dataItems = JSON.parse(items);
|
|
itemIdCounter = dataItems.reduce((max, d) => Math.max(max, d.id), 0) + 1;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function addDataItem(value) {
|
|
const id = itemIdCounter++;
|
|
dataItems.push({ id, value });
|
|
renderDataList();
|
|
saveState();
|
|
}
|
|
|
|
function removeDataItem(id) {
|
|
dataItems = dataItems.filter(d => d.id !== id);
|
|
renderDataList();
|
|
saveState();
|
|
}
|
|
|
|
function updateDataItem(id, value) {
|
|
const item = dataItems.find(d => d.id === id);
|
|
if (item) item.value = value;
|
|
saveState();
|
|
}
|
|
|
|
function clearData() {
|
|
dataItems = [];
|
|
renderDataList();
|
|
saveState();
|
|
}
|
|
|
|
function showJsonlImport() {
|
|
document.getElementById('jsonl-panel').style.display = 'block';
|
|
document.getElementById('jsonl-error').textContent = '';
|
|
}
|
|
|
|
function hideJsonlImport() {
|
|
document.getElementById('jsonl-panel').style.display = 'none';
|
|
document.getElementById('jsonl-input').value = '';
|
|
}
|
|
|
|
function showTextImport() {
|
|
document.getElementById('text-panel').style.display = 'block';
|
|
}
|
|
|
|
function hideTextImport() {
|
|
document.getElementById('text-panel').style.display = 'none';
|
|
document.getElementById('text-input').value = '';
|
|
}
|
|
|
|
function importLines() {
|
|
const raw = document.getElementById('text-input').value.trim();
|
|
if (!raw) return;
|
|
raw.split(/\n/).filter(l => l.trim()).forEach(line => addDataItem(line.trim()));
|
|
hideTextImport();
|
|
}
|
|
|
|
function importJsonl() {
|
|
const raw = document.getElementById('jsonl-input').value.trim();
|
|
const errorEl = document.getElementById('jsonl-error');
|
|
errorEl.textContent = '';
|
|
if (!raw) { errorEl.textContent = 'Input is empty.'; return; }
|
|
const lines = raw.split(/\n/).filter(l => l.trim());
|
|
for (const line of lines) {
|
|
try {
|
|
JSON.parse(line.trim());
|
|
} catch {
|
|
errorEl.textContent = 'Invalid JSON line: ' + line.trim().slice(0, 60);
|
|
return;
|
|
}
|
|
addDataItem(line.trim());
|
|
}
|
|
hideJsonlImport();
|
|
}
|
|
|
|
function renderDataList() {
|
|
const container = document.getElementById('data-list');
|
|
container.innerHTML = '';
|
|
dataItems.forEach(item => {
|
|
const row = document.createElement('div');
|
|
row.className = 'data-row';
|
|
const isJson = item.value.trim().startsWith('{');
|
|
const placeholder = isJson ? '{"key": "value"}' : 'simple text value';
|
|
row.innerHTML = `<input value="${escapeHtml(item.value)}" placeholder="${placeholder}" oninput="updateDataItem(${item.id}, this.value)"><button class="remove-btn" onclick="removeDataItem(${item.id})">x</button>`;
|
|
container.appendChild(row);
|
|
});
|
|
document.querySelector('.clear-btn').disabled = dataItems.length === 0;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
function copyOutput() {
|
|
const text = document.getElementById('output').textContent;
|
|
if (!text) return;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const btn = document.getElementById('copy-btn');
|
|
btn.textContent = 'Copied!';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
|
});
|
|
}
|
|
|
|
function pasteTemplate() {
|
|
navigator.clipboard.readText().then(text => {
|
|
document.getElementById('template').value = text;
|
|
updateTemplateButtons();
|
|
saveState();
|
|
});
|
|
}
|
|
|
|
let undoTimeout = null;
|
|
let undoContent = null;
|
|
let undoCountdown = null;
|
|
|
|
function clearTemplate() {
|
|
const tpl = document.getElementById('template').value;
|
|
if (!tpl) return;
|
|
undoContent = tpl;
|
|
document.getElementById('template').value = '';
|
|
|
|
const btn = document.getElementById('tpl-clear-btn');
|
|
btn.textContent = 'Undo (10)';
|
|
btn.onclick = restoreTemplate;
|
|
|
|
updateTemplateButtons();
|
|
saveState();
|
|
|
|
let seconds = 10;
|
|
if (undoTimeout) clearTimeout(undoTimeout);
|
|
if (undoCountdown) clearInterval(undoCountdown);
|
|
undoCountdown = setInterval(() => {
|
|
seconds--;
|
|
if (seconds <= 0) {
|
|
clearInterval(undoCountdown);
|
|
return;
|
|
}
|
|
btn.textContent = `Undo (${seconds})`;
|
|
}, 1000);
|
|
|
|
undoTimeout = setTimeout(() => {
|
|
clearInterval(undoCountdown);
|
|
btn.textContent = 'Clear';
|
|
btn.onclick = clearTemplate;
|
|
undoContent = null;
|
|
updateTemplateButtons();
|
|
}, 10000);
|
|
}
|
|
|
|
function restoreTemplate() {
|
|
if (undoContent == null) return;
|
|
document.getElementById('template').value = undoContent;
|
|
updateTemplateButtons();
|
|
saveState();
|
|
undoContent = null;
|
|
|
|
const btn = document.getElementById('tpl-clear-btn');
|
|
btn.textContent = 'Clear';
|
|
btn.onclick = clearTemplate;
|
|
if (undoTimeout) { clearTimeout(undoTimeout); undoTimeout = null; }
|
|
if (undoCountdown) { clearInterval(undoCountdown); undoCountdown = null; }
|
|
}
|
|
|
|
function updateTemplateButtons() {
|
|
const tpl = document.getElementById('template').value;
|
|
const btn = document.getElementById('tpl-clear-btn');
|
|
if (btn.textContent.startsWith('Undo')) {
|
|
btn.disabled = false;
|
|
} else {
|
|
btn.disabled = !tpl;
|
|
}
|
|
}
|
|
|
|
// --- AST Parser ---
|
|
|
|
function parseTemplate(template) {
|
|
const tokens = tokenize(template);
|
|
return buildAst(tokens);
|
|
}
|
|
|
|
function tokenize(template) {
|
|
const tokens = [];
|
|
let i = 0;
|
|
while (i < template.length) {
|
|
const openIdx = template.indexOf('{{', i);
|
|
if (openIdx === -1) {
|
|
if (i < template.length) tokens.push({ type: 'text', value: template.slice(i) });
|
|
break;
|
|
}
|
|
if (openIdx > i) tokens.push({ type: 'text', value: template.slice(i, openIdx) });
|
|
const closeIdx = template.indexOf('}}', openIdx + 2);
|
|
if (closeIdx === -1) {
|
|
tokens.push({ type: 'text', value: template.slice(openIdx) });
|
|
break;
|
|
}
|
|
const inner = template.slice(openIdx + 2, closeIdx).trim();
|
|
if (inner.startsWith('#')) {
|
|
tokens.push({ type: 'open', key: inner.slice(1).trim() });
|
|
} else if (inner.startsWith('/')) {
|
|
tokens.push({ type: 'close', key: inner.slice(1).trim() });
|
|
} else {
|
|
// Split on first ':' for default value: {{key:default}}
|
|
const colonIdx = inner.indexOf(':');
|
|
if (colonIdx > 0) {
|
|
tokens.push({ type: 'var', key: inner.slice(0, colonIdx), default: inner.slice(colonIdx + 1) });
|
|
} else {
|
|
tokens.push({ type: 'var', key: inner });
|
|
}
|
|
}
|
|
i = closeIdx + 2;
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function buildAst(tokens) {
|
|
const ast = [];
|
|
let pos = 0;
|
|
function parseBlock(endType) {
|
|
const nodes = [];
|
|
while (pos < tokens.length) {
|
|
const tok = tokens[pos];
|
|
if (tok.type === endType) { pos++; return nodes; }
|
|
if (tok.type === 'text') { nodes.push({ type: 'text', value: tok.value }); pos++; continue; }
|
|
if (tok.type === 'var') { nodes.push({ type: 'var', key: tok.key, default: tok.default }); pos++; continue; }
|
|
if (tok.type === 'open') {
|
|
const key = tok.key;
|
|
pos++;
|
|
const children = parseBlock('close');
|
|
// verify matching key
|
|
nodes.push({ type: 'block', key, children });
|
|
continue;
|
|
}
|
|
pos++;
|
|
}
|
|
return nodes;
|
|
}
|
|
return parseBlock(null);
|
|
}
|
|
|
|
// --- Resolve helper ---
|
|
|
|
function resolvePath(ctx, keyPath) {
|
|
const parts = keyPath.split('.');
|
|
let obj = ctx;
|
|
for (const p of parts) {
|
|
if (obj == null || typeof obj !== 'object') return undefined;
|
|
obj = obj[p];
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
// --- Render AST ---
|
|
|
|
function renderAst(ast, ctx) {
|
|
let result = '';
|
|
for (const node of ast) {
|
|
if (node.type === 'text') { result += node.value; }
|
|
else if (node.type === 'var') {
|
|
const val = resolvePath(ctx, node.key);
|
|
if (val != null) { result += String(val); }
|
|
else if (node.default !== undefined) { result += node.default; }
|
|
}
|
|
else if (node.type === 'block') {
|
|
const arr = resolvePath(ctx, node.key);
|
|
if (Array.isArray(arr)) {
|
|
for (const item of arr) {
|
|
result += renderAst(node.children, item);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// --- Merge action ---
|
|
|
|
function merge() {
|
|
const errorEl = document.getElementById('error');
|
|
const outputEl = document.getElementById('output');
|
|
errorEl.textContent = '';
|
|
outputEl.textContent = '';
|
|
|
|
const template = document.getElementById('template').value;
|
|
if (!template.trim()) { errorEl.textContent = 'Template is empty.'; return; }
|
|
|
|
let ast;
|
|
try {
|
|
ast = parseTemplate(template);
|
|
} catch (e) {
|
|
errorEl.textContent = 'Template parse error: ' + e.message;
|
|
return;
|
|
}
|
|
|
|
// Render template once per data item, concatenate results
|
|
const results = dataItems
|
|
.filter(item => item.value.trim())
|
|
.map(item => {
|
|
const trimmed = item.value.trim();
|
|
let ctx;
|
|
if (trimmed.startsWith('{')) {
|
|
try {
|
|
ctx = JSON.parse(trimmed);
|
|
} catch {
|
|
ctx = { value: trimmed };
|
|
}
|
|
} else {
|
|
ctx = { value: trimmed };
|
|
}
|
|
return renderAst(ast, ctx);
|
|
});
|
|
|
|
outputEl.textContent = results.join('\n');
|
|
document.getElementById('copy-btn').disabled = results.length === 0 || results.join('') === '';
|
|
}
|
|
|
|
// Init
|
|
document.getElementById('template').addEventListener('input', () => { saveState(); updateTemplateButtons(); });
|
|
loadState();
|
|
renderDataList();
|
|
updateTemplateButtons();
|
|
if (dataItems.length === 0) addDataItem('');
|
|
</script>
|
|
</body>
|
|
</html>
|