aboutsummaryrefslogtreecommitdiffstats
path: root/web/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'web/scripts')
-rw-r--r--web/scripts/gen_tool.py1388
1 files changed, 1388 insertions, 0 deletions
diff --git a/web/scripts/gen_tool.py b/web/scripts/gen_tool.py
new file mode 100644
index 0000000..232a7eb
--- /dev/null
+++ b/web/scripts/gen_tool.py
@@ -0,0 +1,1388 @@
+#!/usr/bin/env python3
+"""Generate UGI_TOOL_v0.html from ugi_registry_v0.json."""
+
+import json
+import os
+import sys
+import html
+
+
+def load_registry(path):
+ with open(path, "r") as f:
+ return json.load(f)
+
+
+def generate_html(reg):
+ reg_json = json.dumps(reg, ensure_ascii=False)
+
+ return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>UGI Tool v{reg['spec']['version']} — Encoder / Decoder / Converter</title>
+<style>
+@font-face {{ font-family: "Kawkab Mono"; src: url(fonts/KawkabMono-Regular.woff2); font-weight: normal; }}
+@font-face {{ font-family: "Kawkab Mono"; src: url(fonts/KawkabMono-Bold.woff2); font-weight: bold; }}
+* {{ box-sizing: border-box; margin: 0; padding: 0; unicode-bidi: plaintext; }}
+html {{ color: black; background-color: white; }}
+body {{
+ font-family: "Kawkab Mono", monospace;
+ font-size: 14px; line-height: 1.5;
+ max-width: 900px; margin: 0 auto; padding: 1rem;
+}}
+h1 {{ font-size: 1.3rem; margin-bottom: 0.5rem; }}
+h2 {{ font-size: 1.1rem; margin: 1rem 0 0.5rem; }}
+h3 {{ font-size: 0.95rem; margin: 0.8rem 0 0.3rem; }}
+hr {{ border: none; border-top: 1px solid; margin: 1rem 0; }}
+a {{ color: inherit; }}
+@media (prefers-color-scheme: dark) {{ html {{ filter: invert(1); }} img {{ filter: invert(1); }} }}
+.tabs {{
+ display: flex; gap: 0; border-bottom: 2px solid; margin-bottom: 1rem;
+}}
+.tab {{
+ padding: 0.4rem 1rem; cursor: pointer; border: 1px solid transparent;
+ border-bottom: none; background: rgba(128,128,128,0.1);
+ font-family: inherit; font-size: inherit;
+}}
+.tab.active {{ background: white; border-color: currentColor; border-bottom: 1px solid white; margin-bottom: -2px; font-weight: bold; }}
+.tab-content {{ display: none; }}
+.tab-content.active {{ display: block; }}
+label {{ display: block; margin: 0.3rem 0 0.1rem; font-weight: bold; }}
+input, select, textarea {{
+ font-family: inherit; font-size: inherit;
+ border: 1px solid;
+ padding: 0.3rem 0.5rem; border-radius: 3px;
+}}
+input[type="range"] {{ background: none; border: none; padding: 0; width: 200px; vertical-align: middle; }}
+input[type="checkbox"] {{ width: auto; margin-right: 0.3rem; vertical-align: middle; }}
+textarea {{ width: 100%; min-height: 100px; resize: vertical; }}
+button {{
+ font-family: inherit; font-size: inherit; padding: 0.3rem 0.8rem;
+ cursor: pointer; background: black; color: white; border: none;
+ border-radius: 3px;
+}}
+button:hover {{ opacity: 0.7; }}
+.field-section {{ margin: 0.5rem 0; padding: 0.5rem; border: 1px solid; border-radius: 3px; }}
+.field-section summary {{ cursor: pointer; font-weight: bold; }}
+.chip {{
+ display: inline-block; padding: 0.15rem 0.5rem; margin: 0.15rem;
+ background: rgba(128,128,128,0.15); border-radius: 3px; font-size: 0.85rem;
+}}
+.chip .remove {{ cursor: pointer; margin-left: 0.3rem; }}
+.output-box {{
+ background: rgba(128,128,128,0.08); padding: 0.8rem; border-radius: 3px;
+ word-break: break-all; margin: 0.5rem 0; min-height: 2rem;
+ border: 1px solid;
+}}
+.decode-table {{ width: 100%; border-collapse: collapse; margin: 0.5rem 0; }}
+.decode-table th, .decode-table td {{
+ text-align: left; padding: 0.3rem 0.5rem; border: 1px solid;
+}}
+.decode-table th {{ background: rgba(128,128,128,0.1); }}
+.humor {{ font-style: italic; font-size: 0.85rem; }}
+.slider-row {{ display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }}
+.slider-val {{ min-width: 1.5rem; text-align: center; font-weight: bold; }}
+.slider-label {{ font-size: 0.85rem; }}
+.multi-add {{ display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin: 0.3rem 0; }}
+details {{ margin: 0.5rem 0; }}
+details summary {{ cursor: pointer; }}
+.skip-check {{ margin-left: 0.5rem; font-weight: normal; font-size: 0.85rem; }}
+footer {{ text-align: center; margin-top: 3rem; font-size: 0.85rem; }}
+</style>
+</head>
+<body>
+
+<h1>UGI Tool v{reg['spec']['version']}</h1>
+<p>{html.escape(reg['spec']['description'])}</p>
+<hr>
+
+<div class="tabs">
+ <button class="tab active" onclick="switchTab('encoder')">Encoder</button>
+ <button class="tab" onclick="switchTab('decoder')">Decoder</button>
+ <button class="tab" onclick="switchTab('converter')">Converter</button>
+ <button class="tab" onclick="switchTab('spec')">Spec</button>
+</div>
+
+<div id="tab-encoder" class="tab-content active">
+ <h2>Encode your UGI</h2>
+
+ <label>Handle (required):</label>
+ <input type="text" id="enc-handle" placeholder="yourhandle" oninput="encodeUGI()">
+
+ <label>Version:</label>
+ <div class="slider-row">
+ <input type="text" id="enc-version" value="0" size="5" oninput="encodeUGI()">
+ </div>
+
+ <div id="enc-fields"></div>
+
+ <hr>
+ <h3>URI output:</h3>
+ <div class="output-box" id="enc-uri-output"></div>
+ <button onclick="copyOutput('enc-uri-output')">Copy URI</button>
+
+ <h3>Block output:</h3>
+ <pre class="output-box" id="enc-block-output"></pre>
+ <button onclick="copyOutput('enc-block-output')">Copy Block</button>
+</div>
+
+<div id="tab-decoder" class="tab-content">
+ <h2>Decode a UGI string</h2>
+ <label>Paste UGI (URI or block):</label>
+ <textarea id="dec-input" placeholder="ugi:0@jdoe:gcs$/dev,a4,b5/4,..." oninput="decodeUGI()"></textarea>
+ <div id="dec-output"></div>
+</div>
+
+<div id="tab-converter" class="tab-content">
+ <h2>Convert between formats</h2>
+ <label>Paste either format:</label>
+ <textarea id="conv-input" placeholder="Paste URI or block format..." oninput="convertUGI()"></textarea>
+ <h3>Converted output:</h3>
+ <pre class="output-box" id="conv-output"></pre>
+ <button onclick="copyOutput('conv-output')">Copy</button>
+</div>
+
+<div id="tab-spec" class="tab-content">
+ <div id="spec-content"></div>
+</div>
+
+<hr>
+<details>
+ <summary><strong>Quick Reference</strong></summary>
+ <pre id="quick-ref"></pre>
+</details>
+
+<script>
+const REG = {reg_json};
+
+/* === Tab switching === */
+function switchTab(name) {{
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
+ document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
+ document.getElementById('tab-' + name).classList.add('active');
+ document.querySelector('[onclick="switchTab(\\'' + name + '\\')"]').classList.add('active');
+}}
+
+/* === Utility === */
+function copyOutput(id) {{
+ const text = document.getElementById(id).textContent;
+ navigator.clipboard.writeText(text);
+}}
+
+function getScale(field) {{
+ const f = REG.fields[field];
+ if (!f) return {{}};
+ if (f.values) return f.values;
+ if (f.scale_labels) {{
+ const out = {{}};
+ for (const [k,v] of Object.entries(f.scale_labels)) out[k] = {{label: v}};
+ return out;
+ }}
+ const st = f.scale_type;
+ if (st && st !== 'custom') {{
+ const out = {{}};
+ for (const v of REG.scale.values) out[v.value] = {{label: v[st] || v.general}};
+ return out;
+ }}
+ return {{}};
+}}
+
+function getSubIds(field) {{
+ const f = REG.fields[field];
+ if (!f) return [];
+ const out = [];
+ if (f.sub_ids) {{
+ for (const [k,v] of Object.entries(f.sub_ids)) {{
+ const name = typeof v === 'object' ? (v.name || k) : v;
+ out.push({{code: k, name}});
+ }}
+ }}
+ if (f.sub_id_groups) {{
+ for (const [gk, group] of Object.entries(f.sub_id_groups)) {{
+ const label = group.label || gk;
+ for (const [k,v] of Object.entries(group.sub_ids)) {{
+ const name = typeof v === 'object' ? (v.name || k) : v;
+ out.push({{code: k, name, group: label}});
+ }}
+ }}
+ }}
+ if (field === 'l' && f.common_codes) {{
+ for (const [k,v] of Object.entries(f.common_codes)) {{
+ out.push({{code: k, name: v}});
+ }}
+ }}
+ return out;
+}}
+
+function getGeekDomains() {{
+ const f = REG.fields.g;
+ const out = [];
+ if (f.sub_id_groups) {{
+ for (const [gk, group] of Object.entries(f.sub_id_groups)) {{
+ const label = group.label || gk;
+ for (const [k,v] of Object.entries(group.sub_ids)) {{
+ const name = typeof v === 'string' ? v : (v.name || k);
+ out.push({{code: k, name, group: label}});
+ }}
+ }}
+ }}
+ return out;
+}}
+
+/* === Encoder state === */
+const encoderState = {{}};
+
+function markIncluded(code) {{
+ const el = document.getElementById('skip-' + code);
+ if (el && !el.disabled) el.checked = true;
+}}
+
+function buildEncoder() {{
+ const container = document.getElementById('enc-fields');
+ const allCodes = Object.keys(REG.fields).sort();
+ // Put g first (required field)
+ const codes = ['g', ...allCodes.filter(c => c !== 'g')];
+
+ for (const code of codes) {{
+ const f = REG.fields[code];
+ const isRequired = code === 'g';
+ const section = document.createElement('details');
+ section.className = 'field-section';
+ if (isRequired) section.open = true;
+
+ const summary = document.createElement('summary');
+ const skipId = 'skip-' + code;
+ const reqLabel = isRequired ? ' (required)' : '';
+ summary.innerHTML = code.toUpperCase() + ' — ' + f.name + reqLabel +
+ '<label class="skip-check"><input type="checkbox" id="' + skipId + '" checked onchange="encodeUGI()"' +
+ (isRequired ? ' disabled' : '') + '> include</label>';
+ section.appendChild(summary);
+
+ const inner = document.createElement('div');
+ inner.id = 'enc-field-' + code;
+
+ if (f.type === 'direct') {{
+ buildDirectField(inner, code, f);
+ }} else if (f.type === 'multi') {{
+ buildMultiField(inner, code, f);
+ }} else if (f.type === 'single') {{
+ buildSingleField(inner, code, f);
+ }} else if (f.type === 'special') {{
+ if (code === 'g') buildGeekField(inner, f);
+ else if (code === 'b') buildBuildField(inner, f);
+ else if (code === 'v') buildPoliticsField(inner, f);
+ else if (code === 'l') buildLangField(inner, f);
+ }}
+
+ section.appendChild(inner);
+ container.appendChild(section);
+
+ // Default: unchecked (not included) except g (always checked)
+ if (code !== 'g') {{
+ document.getElementById(skipId).checked = false;
+ }}
+ }}
+}}
+
+function buildDirectField(container, code, f) {{
+ const scale = getScale(code);
+ const mods = f.modifiers || {{}};
+
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-' + code + '-val';
+ slider.oninput = function() {{
+ document.getElementById('enc-' + code + '-display').textContent = this.value;
+ const s = scale[this.value];
+ document.getElementById('enc-' + code + '-label').textContent = s ? (s.label || '') : '';
+ const h = s ? (s.humor || '') : '';
+ document.getElementById('enc-' + code + '-humor').textContent = h;
+ markIncluded(code);
+ encodeUGI();
+ }};
+ row.appendChild(slider);
+
+ const val = document.createElement('span');
+ val.className = 'slider-val'; val.id = 'enc-' + code + '-display'; val.textContent = '4';
+ row.appendChild(val);
+
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-' + code + '-label';
+ lab.textContent = scale['4'] ? (scale['4'].label || '') : '';
+ row.appendChild(lab);
+
+ container.appendChild(row);
+
+ const humor = document.createElement('div');
+ humor.className = 'humor'; humor.id = 'enc-' + code + '-humor';
+ humor.textContent = scale['4'] ? (scale['4'].humor || '') : '';
+ container.appendChild(humor);
+
+ if (mods.alternative) {{
+ const altDiv = document.createElement('div');
+ altDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-alt-on" onchange="encodeUGI()"> Alternative value</label>';
+ const altSlider = document.createElement('input');
+ altSlider.type = 'range'; altSlider.min = 0; altSlider.max = 7; altSlider.value = 4;
+ altSlider.id = 'enc-' + code + '-alt'; altSlider.oninput = function() {{ encodeUGI(); }};
+ altDiv.appendChild(altSlider);
+ container.appendChild(altDiv);
+ }}
+
+ if (mods.paid) {{
+ const pDiv = document.createElement('div');
+ pDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-paid" onchange="encodeUGI()"> Paid ($)</label>';
+ container.appendChild(pDiv);
+ }}
+
+ if (mods.aspire) {{
+ const aDiv = document.createElement('div');
+ aDiv.innerHTML = '<label><input type="checkbox" id="enc-' + code + '-aspire-on" onchange="encodeUGI()"> Aspiring to:</label>';
+ const aSel = document.createElement('select');
+ aSel.id = 'enc-' + code + '-aspire';
+ for (let i = 5; i <= 7; i++) {{
+ const opt = document.createElement('option');
+ opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : '');
+ aSel.appendChild(opt);
+ }}
+ aSel.onchange = function() {{ encodeUGI(); }};
+ aDiv.appendChild(aSel);
+ container.appendChild(aDiv);
+ }}
+}}
+
+function buildMultiField(container, code, f) {{
+ const subIds = getSubIds(code);
+ const mods = f.modifiers || {{}};
+ const scale = getScale(code);
+
+ encoderState[code] = [];
+
+ const addRow = document.createElement('div');
+ addRow.className = 'multi-add';
+
+ const sel = document.createElement('select');
+ sel.id = 'enc-' + code + '-sel';
+ const defOpt = document.createElement('option');
+ defOpt.value = ''; defOpt.textContent = '— select —';
+ sel.appendChild(defOpt);
+
+ let lastGroup = '';
+ for (const s of subIds) {{
+ if (s.group && s.group !== lastGroup) {{
+ const optg = document.createElement('optgroup');
+ optg.label = s.group;
+ sel.appendChild(optg);
+ lastGroup = s.group;
+ }}
+ const opt = document.createElement('option');
+ opt.value = s.code; opt.textContent = s.code + ' — ' + s.name;
+ if (lastGroup) {{
+ sel.lastElementChild.appendChild(opt);
+ }} else {{
+ sel.appendChild(opt);
+ }}
+ }}
+ addRow.appendChild(sel);
+
+ const rSlider = document.createElement('input');
+ rSlider.type = 'range'; rSlider.min = 0; rSlider.max = 7; rSlider.value = 5;
+ rSlider.id = 'enc-' + code + '-rating';
+ addRow.appendChild(rSlider);
+
+ const rVal = document.createElement('span');
+ rVal.className = 'slider-val';
+ rVal.id = 'enc-' + code + '-rating-display';
+ rVal.textContent = '5';
+ addRow.appendChild(rVal);
+ const rLabel = document.createElement('span');
+ rLabel.className = 'slider-label';
+ rLabel.id = 'enc-' + code + '-rating-label';
+ rLabel.textContent = scale['5'] ? (scale['5'].label || '') : '';
+ rSlider.oninput = function() {{
+ rVal.textContent = this.value;
+ const s = scale[this.value];
+ rLabel.textContent = s ? (s.label || '') : '';
+ }};
+ addRow.appendChild(rLabel);
+
+ if (mods.paid) {{
+ const pCb = document.createElement('label');
+ pCb.innerHTML = '<input type="checkbox" id="enc-' + code + '-add-paid"> $';
+ addRow.appendChild(pCb);
+ }}
+ if (mods.aspire) {{
+ const aCb = document.createElement('label');
+ aCb.innerHTML = '<input type="checkbox" id="enc-' + code + '-add-aspire-on"> +';
+ addRow.appendChild(aCb);
+ const aSel = document.createElement('select');
+ aSel.id = 'enc-' + code + '-add-aspire';
+ for (let i = 5; i <= 7; i++) {{
+ const opt = document.createElement('option');
+ opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : '');
+ aSel.appendChild(opt);
+ }}
+ addRow.appendChild(aSel);
+ }}
+
+ const addBtn = document.createElement('button');
+ addBtn.textContent = 'Add';
+ addBtn.onclick = function() {{
+ const subId = sel.value;
+ if (!subId) return;
+ const rating = rSlider.value;
+ const entry = {{ sub: subId, rating: parseInt(rating) }};
+ if (mods.paid) {{
+ const pEl = document.getElementById('enc-' + code + '-add-paid');
+ if (pEl && pEl.checked) entry.paid = true;
+ }}
+ if (mods.aspire) {{
+ const aOn = document.getElementById('enc-' + code + '-add-aspire-on');
+ const aVal = document.getElementById('enc-' + code + '-add-aspire');
+ if (aOn && aOn.checked) entry.aspire = parseInt(aVal.value);
+ }}
+ encoderState[code].push(entry);
+ renderChips(code);
+ markIncluded(code);
+ encodeUGI();
+ }};
+ addRow.appendChild(addBtn);
+ container.appendChild(addRow);
+
+ const chipBox = document.createElement('div');
+ chipBox.id = 'enc-' + code + '-chips';
+ container.appendChild(chipBox);
+}}
+
+function renderChips(code) {{
+ const box = document.getElementById('enc-' + code + '-chips');
+ box.innerHTML = '';
+ for (let i = 0; i < encoderState[code].length; i++) {{
+ const e = encoderState[code][i];
+ const chip = document.createElement('span');
+ chip.className = 'chip';
+ let txt = e.sub + e.rating;
+ if (e.paid) txt += '$';
+ if (e.aspire) txt += '+' + e.aspire;
+ chip.innerHTML = txt + ' <span class="remove" onclick="removeChip(\\''+code+'\\','+i+')">&times;</span>';
+ box.appendChild(chip);
+ }}
+}}
+
+function removeChip(code, idx) {{
+ encoderState[code].splice(idx, 1);
+ renderChips(code);
+ encodeUGI();
+}}
+
+function buildSingleField(container, code, f) {{
+ const subIds = getSubIds(code);
+ const scale = getScale(code);
+
+ const sel = document.createElement('select');
+ sel.id = 'enc-' + code + '-sub';
+ sel.onchange = function() {{ markIncluded(code); encodeUGI(); }};
+ const defOpt = document.createElement('option');
+ defOpt.value = ''; defOpt.textContent = '— select —';
+ sel.appendChild(defOpt);
+ for (const s of subIds) {{
+ const opt = document.createElement('option');
+ opt.value = s.code; opt.textContent = s.code + ' — ' + s.name;
+ sel.appendChild(opt);
+ }}
+ container.appendChild(sel);
+
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-' + code + '-val';
+ slider.oninput = function() {{
+ document.getElementById('enc-' + code + '-display').textContent = this.value;
+ const s = scale[this.value];
+ document.getElementById('enc-' + code + '-label').textContent = s ? (s.label || '') : '';
+ markIncluded(code);
+ encodeUGI();
+ }};
+ row.appendChild(slider);
+ const val = document.createElement('span');
+ val.className = 'slider-val'; val.id = 'enc-' + code + '-display'; val.textContent = '4';
+ row.appendChild(val);
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-' + code + '-label';
+ lab.textContent = scale['4'] ? (scale['4'].label || '') : '';
+ row.appendChild(lab);
+ container.appendChild(row);
+}}
+
+function buildGeekField(container, f) {{
+ const domains = getGeekDomains();
+ encoderState.g = {{ domains: [], custom: '' }};
+
+ let lastGroup = '';
+ let gridDiv = null;
+ for (const d of domains) {{
+ if (d.group && d.group !== lastGroup) {{
+ const h = document.createElement('h3');
+ h.textContent = d.group;
+ container.appendChild(h);
+ lastGroup = d.group;
+ gridDiv = document.createElement('div');
+ gridDiv.style.display = 'grid';
+ gridDiv.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))';
+ gridDiv.style.gap = '0.1rem 0';
+ container.appendChild(gridDiv);
+ }}
+ const lbl = document.createElement('label');
+ lbl.innerHTML = '<input type="checkbox" data-gcode="' + d.code + '" onchange="updateGeek()"> ' +
+ d.code + ' ' + d.name +
+ ' <input type="checkbox" data-gpaid="' + d.code + '" onchange="updateGeek()" title="paid ($)" style="margin-left:4px"> $';
+ (gridDiv || container).appendChild(lbl);
+ }}
+
+ const customDiv = document.createElement('div');
+ customDiv.style.marginTop = '0.5rem';
+ customDiv.innerHTML = '<label>Custom (~): <input type="text" id="enc-g-custom" placeholder="fermenting" oninput="updateGeek()"></label>';
+ container.appendChild(customDiv);
+}}
+
+function updateGeek() {{
+ const domains = [];
+ document.querySelectorAll('[data-gcode]').forEach(cb => {{
+ if (cb.checked) {{
+ const code = cb.getAttribute('data-gcode');
+ const paidCb = document.querySelector('[data-gpaid="' + code + '"]');
+ domains.push({{ code, paid: paidCb && paidCb.checked }});
+ }}
+ }});
+ encoderState.g = {{
+ domains,
+ custom: (document.getElementById('enc-g-custom').value || '').trim()
+ }};
+ encodeUGI();
+}}
+
+function buildBuildField(container, f) {{
+ for (const dim of ['height', 'width']) {{
+ const vals = f.dimensions[dim].values;
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+ row.innerHTML = '<strong>' + dim + ':</strong>';
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-b-' + dim;
+ const disp = document.createElement('span');
+ disp.className = 'slider-val'; disp.id = 'enc-b-' + dim + '-display'; disp.textContent = '4';
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-b-' + dim + '-label';
+ lab.textContent = vals['4'] || '';
+ slider.oninput = function() {{
+ disp.textContent = this.value;
+ lab.textContent = vals[this.value] || '';
+ markIncluded('b');
+ encodeUGI();
+ }};
+ row.appendChild(slider); row.appendChild(disp); row.appendChild(lab);
+ container.appendChild(row);
+ }}
+
+ const aDiv = document.createElement('div');
+ aDiv.innerHTML = '<label><input type="checkbox" id="enc-b-aspire-on" onchange="encodeUGI()"> Aspiring (+)</label>';
+ container.appendChild(aDiv);
+}}
+
+function buildPoliticsField(container, f) {{
+ for (const dim of ['social', 'economic']) {{
+ const vals = f.dimensions[dim].values;
+ const row = document.createElement('div');
+ row.className = 'slider-row';
+ row.innerHTML = '<strong>' + dim + ':</strong>';
+ const slider = document.createElement('input');
+ slider.type = 'range'; slider.min = 0; slider.max = 7; slider.value = 4;
+ slider.id = 'enc-v-' + dim;
+ const disp = document.createElement('span');
+ disp.className = 'slider-val'; disp.id = 'enc-v-' + dim + '-display'; disp.textContent = '4';
+ const lab = document.createElement('span');
+ lab.className = 'slider-label'; lab.id = 'enc-v-' + dim + '-label';
+ lab.textContent = vals['4'] || '';
+ slider.oninput = function() {{
+ disp.textContent = this.value;
+ lab.textContent = vals[this.value] || '';
+ markIncluded('v');
+ encodeUGI();
+ }};
+ row.appendChild(slider); row.appendChild(disp); row.appendChild(lab);
+ container.appendChild(row);
+ }}
+}}
+
+function buildLangField(container, f) {{
+ encoderState.l = [];
+ const mods = f.modifiers || {{}};
+ const scale = getScale('l');
+
+ const addRow = document.createElement('div');
+ addRow.className = 'multi-add';
+
+ const codes = f.common_codes || {{}};
+ const sel = document.createElement('select');
+ sel.id = 'enc-l-sel';
+ const defOpt = document.createElement('option');
+ defOpt.value = ''; defOpt.textContent = '— select or type —';
+ sel.appendChild(defOpt);
+ for (const [k,v] of Object.entries(codes)) {{
+ const opt = document.createElement('option');
+ opt.value = k; opt.textContent = k + ' — ' + v;
+ sel.appendChild(opt);
+ }}
+ addRow.appendChild(sel);
+
+ const customInput = document.createElement('input');
+ customInput.type = 'text'; customInput.size = 4; customInput.maxLength = 2;
+ customInput.placeholder = 'or ISO'; customInput.id = 'enc-l-custom';
+ addRow.appendChild(customInput);
+
+ const rSlider = document.createElement('input');
+ rSlider.type = 'range'; rSlider.min = 0; rSlider.max = 7; rSlider.value = 5;
+ rSlider.id = 'enc-l-rating';
+ addRow.appendChild(rSlider);
+ const rVal = document.createElement('span');
+ rVal.className = 'slider-val'; rVal.id = 'enc-l-rating-display'; rVal.textContent = '5';
+ addRow.appendChild(rVal);
+ const rLabel = document.createElement('span');
+ rLabel.className = 'slider-label'; rLabel.id = 'enc-l-rating-label';
+ rLabel.textContent = scale['5'] ? (scale['5'].label || '') : '';
+ rSlider.oninput = function() {{
+ rVal.textContent = this.value;
+ const s = scale[this.value];
+ rLabel.textContent = s ? (s.label || '') : '';
+ }};
+ addRow.appendChild(rLabel);
+
+ if (mods.paid) {{
+ const pCb = document.createElement('label');
+ pCb.innerHTML = '<input type="checkbox" id="enc-l-add-paid"> $';
+ addRow.appendChild(pCb);
+ }}
+ if (mods.aspire) {{
+ const aCb = document.createElement('label');
+ aCb.innerHTML = '<input type="checkbox" id="enc-l-add-aspire-on"> +';
+ addRow.appendChild(aCb);
+ const aSel = document.createElement('select');
+ aSel.id = 'enc-l-add-aspire';
+ for (let i = 5; i <= 7; i++) {{
+ const opt = document.createElement('option');
+ opt.value = i; opt.textContent = i + ' — ' + (scale[i] ? (scale[i].label || '') : '');
+ aSel.appendChild(opt);
+ }}
+ addRow.appendChild(aSel);
+ }}
+
+ const addBtn = document.createElement('button');
+ addBtn.textContent = 'Add';
+ addBtn.onclick = function() {{
+ let langCode = sel.value || customInput.value.toLowerCase().trim();
+ if (!langCode || langCode.length !== 2) return;
+ const rating = document.getElementById('enc-l-rating').value;
+ const entry = {{ sub: langCode, rating: parseInt(rating) }};
+ if (mods.paid) {{
+ const pEl = document.getElementById('enc-l-add-paid');
+ if (pEl && pEl.checked) entry.paid = true;
+ }}
+ if (mods.aspire) {{
+ const aOn = document.getElementById('enc-l-add-aspire-on');
+ const aVal = document.getElementById('enc-l-add-aspire');
+ if (aOn && aOn.checked) entry.aspire = parseInt(aVal.value);
+ }}
+ encoderState.l.push(entry);
+ renderChips('l');
+ markIncluded('l');
+ encodeUGI();
+ }};
+ addRow.appendChild(addBtn);
+ container.appendChild(addRow);
+
+ const chipBox = document.createElement('div');
+ chipBox.id = 'enc-l-chips';
+ container.appendChild(chipBox);
+}}
+
+/* === Encode === */
+function encodeUGI() {{
+ const handle = document.getElementById('enc-handle').value.trim();
+ const version = document.getElementById('enc-version').value.trim() || '0';
+ if (!handle) {{
+ document.getElementById('enc-uri-output').textContent = '(enter a handle)';
+ document.getElementById('enc-block-output').textContent = '';
+ return;
+ }}
+
+ const parts = [];
+ const blockParts = [];
+
+ const codes = Object.keys(REG.fields).sort();
+ for (const code of codes) {{
+ const skipEl = document.getElementById('skip-' + code);
+ if (skipEl && !skipEl.checked) continue;
+
+ const f = REG.fields[code];
+ const ftype = f.type;
+
+ if (ftype === 'direct') {{
+ const valEl = document.getElementById('enc-' + code + '-val');
+ if (!valEl) continue;
+ let s = code + valEl.value;
+ let bs = code.toUpperCase() + valEl.value;
+ const mods = f.modifiers || {{}};
+
+ if (mods.alternative) {{
+ const altOn = document.getElementById('enc-' + code + '-alt-on');
+ if (altOn && altOn.checked) {{
+ const altVal = document.getElementById('enc-' + code + '-alt');
+ s += '/' + altVal.value;
+ bs += '/' + altVal.value;
+ }}
+ }}
+ if (mods.paid) {{
+ const pEl = document.getElementById('enc-' + code + '-paid');
+ if (pEl && pEl.checked) {{ s += '$'; bs += '$'; }}
+ }}
+ if (mods.aspire) {{
+ const aOn = document.getElementById('enc-' + code + '-aspire-on');
+ if (aOn && aOn.checked) {{
+ const aVal = document.getElementById('enc-' + code + '-aspire').value;
+ s += '+' + aVal; bs += '+' + aVal;
+ }}
+ }}
+ parts.push(s);
+ blockParts.push(bs);
+
+ }} else if (ftype === 'multi') {{
+ const entries = encoderState[code] || [];
+ if (entries.length === 0) continue;
+ let uri = code;
+ const bps = [];
+ for (const e of entries) {{
+ let frag = e.sub + e.rating;
+ if (e.paid) frag += '$';
+ if (e.aspire) frag += '+' + e.aspire;
+ uri += frag;
+ bps.push(code.toUpperCase() + frag);
+ }}
+ parts.push(uri);
+ blockParts.push(...bps);
+
+ }} else if (ftype === 'single') {{
+ const subEl = document.getElementById('enc-' + code + '-sub');
+ const valEl = document.getElementById('enc-' + code + '-val');
+ if (!subEl || !subEl.value) continue;
+ const s = code + subEl.value + valEl.value;
+ const bs = code.toUpperCase() + subEl.value + valEl.value;
+ parts.push(s);
+ blockParts.push(bs);
+
+ }} else if (ftype === 'special') {{
+ if (code === 'g') {{
+ const gState = encoderState.g || {{ domains: [], custom: '' }};
+ if (gState.domains.length === 0 && !gState.custom) continue;
+ const dParts = gState.domains.map(d => d.code + (d.paid ? '$' : ''));
+ if (gState.custom) dParts.push('~' + gState.custom);
+ const gStr = 'g' + dParts.join('/');
+ parts.push(gStr);
+ blockParts.push('G' + dParts.join('/'));
+
+ }} else if (code === 'b') {{
+ const h = document.getElementById('enc-b-height');
+ const w = document.getElementById('enc-b-width');
+ if (!h || !w) continue;
+ parts.push('b' + h.value + '/' + w.value);
+ blockParts.push('B' + h.value + '/' + w.value);
+
+ }} else if (code === 'v') {{
+ const s = document.getElementById('enc-v-social');
+ const e = document.getElementById('enc-v-economic');
+ if (!s || !e) continue;
+ parts.push('v' + s.value + '/' + e.value);
+ blockParts.push('V' + s.value + '/' + e.value);
+
+ }} else if (code === 'l') {{
+ const entries = encoderState.l || [];
+ if (entries.length === 0) continue;
+ let uri = 'l';
+ const bps = [];
+ for (const e of entries) {{
+ let frag = e.sub + e.rating;
+ if (e.paid) frag += '$';
+ if (e.aspire) frag += '+' + e.aspire;
+ uri += frag;
+ bps.push('L' + frag);
+ }}
+ parts.push(uri);
+ blockParts.push(...bps);
+ }}
+ }}
+ }}
+
+ // Build URI
+ const uri = 'ugi:' + version + '@' + handle + ':' + parts.join(',');
+ document.getElementById('enc-uri-output').textContent = uri;
+
+ // Build block
+ // G field goes on first line
+ const gPart = blockParts.find(p => p.startsWith('G'));
+ const otherParts = blockParts.filter(p => !p.startsWith('G'));
+
+ let block = '------- BEGIN UGI BLOCK -------\\n';
+ block += 'v:' + version + ' @' + handle;
+ if (gPart) block += ' ' + gPart;
+ block += '\\n';
+
+ // Group by category
+ const cats = {{}};
+ for (const bp of otherParts) {{
+ const c = bp[0].toLowerCase();
+ const fld = REG.fields[c];
+ const cat = fld ? fld.category : 'other';
+ if (!cats[cat]) cats[cat] = [];
+ cats[cat].push(bp);
+ }}
+
+ const catOrder = ['identity', 'appearance', 'tech', 'stance', 'entertainment', 'lifestyle'];
+ for (const cat of catOrder) {{
+ if (cats[cat] && cats[cat].length > 0) {{
+ block += cats[cat].join(' ') + '\\n';
+ }}
+ }}
+
+ block += '-------- END UGI BLOCK --------';
+ document.getElementById('enc-block-output').textContent = block;
+}}
+
+/* === Decoder === */
+function decodeUGI() {{
+ const input = document.getElementById('dec-input').value.trim();
+ const output = document.getElementById('dec-output');
+ if (!input) {{ output.innerHTML = ''; return; }}
+
+ try {{
+ let uri = input;
+ // Detect block format
+ if (input.includes('BEGIN UGI BLOCK')) {{
+ uri = blockToUri(input);
+ }}
+ const parsed = parseUri(uri);
+ renderDecoded(parsed, output);
+ }} catch(e) {{
+ output.innerHTML = '<p style="color:red">Parse error: ' + e.message + '</p>';
+ }}
+}}
+
+function parseUri(uri) {{
+ let rest = uri;
+ if (rest.toLowerCase().startsWith('ugi:')) rest = rest.substring(4);
+
+ const atIdx = rest.indexOf('@');
+ if (atIdx < 0) throw new Error('Missing @handle');
+ const version = rest.substring(0, atIdx);
+ rest = rest.substring(atIdx + 1);
+
+ const colonIdx = rest.indexOf(':');
+ if (colonIdx < 0) throw new Error('Missing : after handle');
+ const handle = rest.substring(0, colonIdx);
+ rest = rest.substring(colonIdx + 1);
+
+ const fieldStrs = rest.split(',');
+ const result = {{ version, handle, fields: [] }};
+
+ for (const fs of fieldStrs) {{
+ if (!fs) continue;
+ const code = fs[0].toLowerCase();
+ const raw = fs.substring(1);
+ const f = REG.fields[code];
+ if (!f) {{
+ result.fields.push({{ code, raw, name: '(unknown)', decoded: raw }});
+ continue;
+ }}
+
+ result.fields.push({{ code, raw, name: f.name, decoded: decodeField(code, raw, f) }});
+ }}
+
+ return result;
+}}
+
+function decodeField(code, raw, f) {{
+ const ftype = f.type;
+ const scale = getScale(code);
+
+ if (ftype === 'direct') {{
+ if (!raw) return '';
+ const digit = raw[0];
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ let result = label;
+ let rest = raw.substring(1);
+ if (rest.includes('/')) {{
+ const parts = rest.split('/');
+ if (parts[1]) {{
+ const altLabel = scale[parts[1][0]] ? (scale[parts[1][0]].label || parts[1][0]) : parts[1][0];
+ result += ' / ' + altLabel;
+ rest = parts[1].substring(1);
+ }}
+ }} else {{
+ // modifiers
+ rest = raw.substring(1);
+ }}
+ if (rest.includes('$')) result += ' [paid]';
+ const aspMatch = rest.match(/\\+(\\d)/);
+ if (aspMatch) result += ' [aspiring to ' + aspMatch[1] + ']';
+ return result;
+
+ }} else if (ftype === 'special') {{
+ if (code === 'g') {{
+ return raw.split('/').map(d => {{
+ if (d.startsWith('~')) return 'custom:' + d.substring(1);
+ const paid = d.endsWith('$');
+ const dcode = paid ? d.slice(0,-1) : d;
+ const name = resolveGeekDomain(dcode);
+ return name + (paid ? ' [paid]' : '');
+ }}).join(', ');
+ }} else if (code === 'b' || code === 'v') {{
+ const parts = raw.split('/');
+ const dims = Object.keys(f.dimensions);
+ return dims.map((d, i) => {{
+ const v = parts[i] ? parts[i][0] : '?';
+ const vals = f.dimensions[d].values;
+ return d + ': ' + (vals[v] || v);
+ }}).join(', ');
+ }}
+ return raw;
+
+ }} else if (ftype === 'multi' || ftype === 'single') {{
+ // l uses ISO 639-1 codes with common_codes lookup, not sub_ids
+ if (code === 'l') return decodeLangEntries(raw, f);
+ return decodeMultiEntries(code, raw, f, scale);
+ }}
+ return raw;
+}}
+
+function decodeMultiEntries(code, raw, f, scale) {{
+ const entries = [];
+ let i = 0;
+ while (i < raw.length) {{
+ if (raw[i] === '~') {{
+ let j = i + 1;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ const sub = raw.substring(i, j);
+ const digit = j < raw.length && /[0-7]/.test(raw[j]) ? raw[j] : '?';
+ let mod = '';
+ let k = j + 1;
+ while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }}
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ entries.push('custom:' + sub.substring(1) + '=' + label + modStr(mod));
+ i = k;
+ }} else if (/[a-zA-Z]/.test(raw[i])) {{
+ let j = i;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ const sub = raw.substring(i, j);
+ const digit = j < raw.length && /[0-7]/.test(raw[j]) ? raw[j] : '?';
+ let mod = '';
+ let k = j + 1;
+ while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }}
+ const name = resolveSubId(code, sub, f);
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ entries.push(name + '=' + label + modStr(mod));
+ i = k;
+ }} else {{
+ i++;
+ }}
+ }}
+ return entries.join(', ');
+}}
+
+function decodeLangEntries(raw, f) {{
+ const entries = [];
+ const scale = getScale('l');
+ let i = 0;
+ while (i < raw.length) {{
+ if (/[a-zA-Z]/.test(raw[i]) && i + 2 < raw.length) {{
+ const lang = raw.substring(i, i+2);
+ const digit = raw[i+2];
+ let mod = '';
+ let k = i + 3;
+ while (k < raw.length && /[\\$\\+0-9]/.test(raw[k])) {{ mod += raw[k]; k++; }}
+ const langName = (REG.fields.l.common_codes || {{}})[lang] || lang;
+ const label = scale[digit] ? (scale[digit].label || digit) : digit;
+ entries.push(langName + '=' + label + modStr(mod));
+ i = k;
+ }} else {{
+ i++;
+ }}
+ }}
+ return entries.join(', ');
+}}
+
+function modStr(mod) {{
+ let s = '';
+ if (mod.includes('$')) s += ' [paid]';
+ const m = mod.match(/\\+(\\d)/);
+ if (m) s += ' [aspiring to ' + m[1] + ']';
+ return s;
+}}
+
+function resolveSubId(code, sub, f) {{
+ sub = sub.toLowerCase();
+ if (f.sub_ids && f.sub_ids[sub]) {{
+ const v = f.sub_ids[sub];
+ return typeof v === 'object' ? (v.name || sub) : v;
+ }}
+ if (f.sub_id_groups) {{
+ for (const group of Object.values(f.sub_id_groups)) {{
+ if (group.sub_ids && group.sub_ids[sub]) {{
+ const v = group.sub_ids[sub];
+ return typeof v === 'object' ? (v.name || sub) : v;
+ }}
+ }}
+ }}
+ return sub;
+}}
+
+function resolveGeekDomain(code) {{
+ code = code.toLowerCase();
+ const f = REG.fields.g;
+ if (f.sub_id_groups) {{
+ for (const group of Object.values(f.sub_id_groups)) {{
+ if (group.sub_ids && group.sub_ids[code]) {{
+ const v = group.sub_ids[code];
+ return typeof v === 'string' ? v : (v.name || code);
+ }}
+ }}
+ }}
+ return code;
+}}
+
+function renderDecoded(parsed, container) {{
+ let html = '<h3>Decoded: @' + parsed.handle + ' (v' + parsed.version + ')</h3>';
+ html += '<table class="decode-table"><tr><th>Code</th><th>Field</th><th>Raw</th><th>Decoded</th></tr>';
+ for (const f of parsed.fields) {{
+ html += '<tr><td>' + f.code + '</td><td>' + f.name + '</td><td><code>' + f.raw + '</code></td><td>' + f.decoded + '</td></tr>';
+ }}
+ html += '</table>';
+ container.innerHTML = html;
+}}
+
+/* === Converter === */
+function convertUGI() {{
+ const input = document.getElementById('conv-input').value.trim();
+ const output = document.getElementById('conv-output');
+ if (!input) {{ output.textContent = ''; return; }}
+
+ try {{
+ if (input.includes('BEGIN UGI BLOCK')) {{
+ output.textContent = blockToUri(input);
+ }} else if (input.toLowerCase().startsWith('ugi:')) {{
+ output.textContent = uriToBlock(input);
+ }} else {{
+ output.textContent = '(could not detect format — paste a URI starting with "ugi:" or a block)';
+ }}
+ }} catch(e) {{
+ output.textContent = 'Error: ' + e.message;
+ }}
+}}
+
+function blockToUri(block) {{
+ const lines = block.split(/\\r?\\n/).map(l => l.trim()).filter(l => l && !l.startsWith('---'));
+ if (lines.length === 0) throw new Error('Empty block');
+
+ // First line: v:0 @handle G...
+ const firstLine = lines[0];
+ const vMatch = firstLine.match(/^v:(\\S+)\\s+@(\\S+)(?:\\s+(.*))?$/i);
+ if (!vMatch) throw new Error('Invalid first line');
+ const version = vMatch[1];
+ const handle = vMatch[2];
+ const gPart = vMatch[3] || '';
+
+ // Remaining lines: space-separated block fields
+ const blockFields = [];
+ if (gPart) blockFields.push(gPart);
+ for (let i = 1; i < lines.length; i++) {{
+ blockFields.push(...lines[i].split(/\\s+/));
+ }}
+
+ // Group by field code (first letter), merge multi entries
+ const fieldMap = {{}};
+ for (const bf of blockFields) {{
+ if (!bf) continue;
+ const code = bf[0].toLowerCase();
+ const rest = bf.substring(1);
+ if (!fieldMap[code]) fieldMap[code] = '';
+ fieldMap[code] += rest;
+ }}
+
+ const parts = [];
+ // Sort by field code
+ for (const code of Object.keys(fieldMap).sort()) {{
+ parts.push(code + fieldMap[code].toLowerCase());
+ }}
+
+ return 'ugi:' + version + '@' + handle + ':' + parts.join(',');
+}}
+
+function uriToBlock(uri) {{
+ let rest = uri;
+ if (rest.toLowerCase().startsWith('ugi:')) rest = rest.substring(4);
+ const atIdx = rest.indexOf('@');
+ const version = rest.substring(0, atIdx);
+ rest = rest.substring(atIdx + 1);
+ const colonIdx = rest.indexOf(':');
+ const handle = rest.substring(0, colonIdx);
+ rest = rest.substring(colonIdx + 1);
+
+ const fieldStrs = rest.split(',');
+ let block = '------- BEGIN UGI BLOCK -------\\n';
+ let firstLine = 'v:' + version + ' @' + handle;
+
+ const blockParts = [];
+ for (const fs of fieldStrs) {{
+ if (!fs) continue;
+ const code = fs[0].toLowerCase();
+ const raw = fs.substring(1);
+ const f = REG.fields[code];
+ if (!f) {{
+ blockParts.push({{ code, parts: [code.toUpperCase() + raw] }});
+ continue;
+ }}
+
+ if (code === 'g') {{
+ firstLine += ' G' + raw;
+ continue;
+ }}
+
+ const ftype = f.type;
+ if (ftype === 'multi' || (ftype === 'special' && code === 'l')) {{
+ // Expand: split sub-id entries
+ const expanded = expandMultiToBlock(code, raw);
+ blockParts.push({{ code, parts: expanded, cat: f.category }});
+ }} else {{
+ blockParts.push({{ code, parts: [code.toUpperCase() + raw], cat: f.category }});
+ }}
+ }}
+
+ block += firstLine + '\\n';
+
+ // Group by category
+ const cats = {{}};
+ for (const bp of blockParts) {{
+ const cat = bp.cat || 'other';
+ if (!cats[cat]) cats[cat] = [];
+ cats[cat].push(...bp.parts);
+ }}
+
+ const catOrder = ['identity', 'appearance', 'tech', 'stance', 'entertainment', 'lifestyle'];
+ for (const cat of catOrder) {{
+ if (cats[cat] && cats[cat].length > 0) {{
+ block += cats[cat].join(' ') + '\\n';
+ }}
+ }}
+
+ block += '-------- END UGI BLOCK --------';
+ return block;
+}}
+
+function expandMultiToBlock(code, raw) {{
+ const parts = [];
+ const uc = code.toUpperCase();
+ let i = 0;
+ while (i < raw.length) {{
+ if (raw[i] === '~') {{
+ let j = i + 1;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ let sub = raw.substring(i, j);
+ let digit = '';
+ if (j < raw.length && /[0-7]/.test(raw[j])) {{ digit = raw[j]; j++; }}
+ let mod = '';
+ while (j < raw.length && /[\\$\\+0-9]/.test(raw[j])) {{ mod += raw[j]; j++; }}
+ parts.push(uc + sub + digit + mod);
+ i = j;
+ }} else if (/[a-zA-Z]/.test(raw[i])) {{
+ let j = i;
+ while (j < raw.length && /[a-zA-Z]/.test(raw[j])) j++;
+ let sub = raw.substring(i, j);
+ let digit = '';
+ if (j < raw.length && /[0-7]/.test(raw[j])) {{ digit = raw[j]; j++; }}
+ let mod = '';
+ while (j < raw.length && /[\\$\\+0-9]/.test(raw[j])) {{ mod += raw[j]; j++; }}
+ parts.push(uc + sub + digit + mod);
+ i = j;
+ }} else {{
+ i++;
+ }}
+ }}
+ return parts;
+}}
+
+/* === Quick Reference === */
+function buildQuickRef() {{
+ const fields = REG.fields;
+ const codes = Object.keys(fields).sort();
+ let ref = 'FORMAT: ugi:<ver>[/<rev>]@<handle>:<fields>\\n';
+ ref += 'SCALE: 0=hostile 1=dislike 2=meh 3=slight- 4=neutral 5=like 6=strong 7=obsessed\\n';
+ ref += 'MODIFY: $ = paid + = aspire / = fluctuate,separate ~ = custom\\n\\n';
+
+ const allCodes = [];
+ for (const c of 'abcdefghijklmnopqrstuvwxyz') {{
+ if (fields[c]) {{
+ allCodes.push(c + ' ' + fields[c].name.toLowerCase());
+ }} else if (c === 'n' || c === 'u') {{
+ allCodes.push('[' + c + ' reserved]');
+ }}
+ }}
+
+ for (let i = 0; i < allCodes.length; i += 5) {{
+ ref += allCodes.slice(i, i+5).map(s => s.padEnd(16)).join('') + '\\n';
+ }}
+
+ document.getElementById('quick-ref').textContent = ref;
+}}
+
+/* === Spec tab === */
+function buildSpec() {{
+ const el = document.getElementById('spec-content');
+ let out = '';
+
+ // Global scale table
+ out += '<h2>Global Scale (0–7)</h2>';
+ out += '<table><thead><tr><th>#</th><th>General</th><th>Proficiency</th><th>Enthusiasm</th><th>Stance</th></tr></thead><tbody>';
+ for (const sv of REG.scale.values) {{
+ out += '<tr><td>' + sv.value + '</td><td>' + sv.general + '</td><td>' + sv.proficiency + '</td><td>' + sv.enthusiasm + '</td><td>' + sv.stance + '</td></tr>';
+ }}
+ out += '</tbody></table>';
+
+ // Fields: g first, then alphabetical
+ const allCodes = Object.keys(REG.fields).sort();
+ const codes = ['g', ...allCodes.filter(c => c !== 'g')];
+
+ for (const code of codes) {{
+ const f = REG.fields[code];
+ out += '<hr><h2>' + code.toUpperCase() + ' — ' + f.name;
+ if (code === 'g') out += ' <em>(required)</em>';
+ out += '</h2>';
+ if (f.description) out += '<p>' + f.description + '</p>';
+
+ let meta = '<strong>Type:</strong> ' + f.type;
+ if (f.category) meta += ' | <strong>Category:</strong> ' + f.category;
+ if (f.scale_type) meta += ' | <strong>Scale:</strong> ' + f.scale_type;
+ const mods = Object.keys(f.modifiers || {{}});
+ if (mods.length) meta += ' | <strong>Modifiers:</strong> ' + mods.join(', ');
+ out += '<p>' + meta + '</p>';
+
+ // Custom values (direct fields)
+ if (f.values) {{
+ out += '<ol start="0">';
+ for (let i = 0; i <= 7; i++) {{
+ const v = f.values[String(i)] || {{}};
+ out += '<li><strong>' + (v.label || '—') + '</strong>';
+ if (v.humor) out += ' — <em>' + v.humor + '</em>';
+ out += '</li>';
+ }}
+ out += '</ol>';
+ }}
+
+ // Custom scale labels (multi / lang / single fields)
+ if (f.scale_labels) {{
+ out += '<ol start="0">';
+ for (let i = 0; i <= 7; i++) {{
+ const label = f.scale_labels[String(i)] || '—';
+ const humor = f.humor_scale ? f.humor_scale[String(i)] : null;
+ out += '<li><strong>' + label + '</strong>';
+ if (humor) out += ' — <em>' + humor + '</em>';
+ out += '</li>';
+ }}
+ out += '</ol>';
+ }}
+
+ // Humor scale without scale_labels (multi fields referencing global scale)
+ if (!f.scale_labels && f.humor_scale) {{
+ out += '<p><em>Notable:</em></p><ul>';
+ for (const [k, h] of Object.entries(f.humor_scale)) {{
+ out += '<li>' + k + ': ' + h + '</li>';
+ }}
+ out += '</ul>';
+ }}
+
+ // Sub-IDs (flat)
+ if (f.sub_ids) {{
+ out += '<ul>';
+ for (const [sid, sv] of Object.entries(f.sub_ids)) {{
+ out += '<li><code>' + sid + '</code> — ' + sv.name;
+ if (sv.long) out += ' (block alias: <code>' + sv.long + '</code>)';
+ out += '</li>';
+ }}
+ out += '</ul>';
+ }}
+
+ // Sub-ID groups
+ if (f.sub_id_groups) {{
+ for (const [gkey, group] of Object.entries(f.sub_id_groups)) {{
+ out += '<h3>' + (group.label || gkey) + '</h3><ul>';
+ for (const [sid, sv] of Object.entries(group.sub_ids || {{}})) {{
+ out += '<li><code>' + sid + '</code> — ' + sv.name + '</li>';
+ }}
+ out += '</ul>';
+ }}
+ }}
+
+ // Build / Politics dimensions
+ if (f.dimensions) {{
+ for (const [dimName, dim] of Object.entries(f.dimensions)) {{
+ out += '<h3>' + dimName.charAt(0).toUpperCase() + dimName.slice(1) + '</h3><ul>';
+ for (const [k, v] of Object.entries(dim.values || {{}})) {{
+ out += '<li>' + k + ': ' + v + '</li>';
+ }}
+ out += '</ul>';
+ }}
+ }}
+ }}
+
+ el.innerHTML = out;
+}}
+
+/* === Init === */
+buildEncoder();
+buildQuickRef();
+buildSpec();
+encodeUGI();
+</script>
+<footer>
+<hr>
+<a href="https://gumx.cc">gumx.cc</a> /
+<a href="https://git.gumx.cc">git</a> /
+<a href="https://mail.gumx.cc">mail</a> /
+<a href="https://irc.gumx.cc">irc</a> /
+<a href="https://vpn.gumx.cc">vpn</a> /
+<a href="https://pgp.gumx.cc">pgp</a> /
+<a href="https://wk.fo">wk.fo</a>
+</footer>
+</body>
+</html>"""
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(f"Usage: {sys.argv[0]} <registry.json> <output.html>", file=sys.stderr)
+ sys.exit(1)
+
+ registry_path, output_path = sys.argv[1], sys.argv[2]
+ reg = load_registry(registry_path)
+ html_content = generate_html(reg)
+
+ os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
+ with open(output_path, "w") as f:
+ f.write(html_content)
+
+ print(f"Generated {output_path}")
+
+
+if __name__ == "__main__":
+ main()