commit d3be40f037d8d1b7bed3b9567e2ad3bcdfd348b0 Spenser Truex <truex@equwal.com> 2026-05-09 08:51:58 -0700 Add page
index.html | 651 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 651 insertions(+)
diff --git a/index.html b/index.html new file mode 100755 index 0000000..55374b0 --- /dev/null +++ b/index.html @@ -0,0 +1,651 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Yomitan Converter — dickt.store</title> +<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> +<style> + :root { + --bg: #0e0e0f; + --surface: #18181b; + --surface2: #222226; + --border: #2e2e34; + --border2: #3e3e46; + --text: #f0f0f2; + --muted: #888892; + --accent: #e8ff6b; + --accent-dim: #c8df4a; + --accent-text: #0e0e0f; + --danger: #ff6b6b; + --success: #6bffb0; + --mono: 'Courier New', monospace; + --sans: system-ui, -apple-system, sans-serif; + } + + * { box-sizing: border-box; margin: 0; padding: 0; } + + body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + min-height: 100vh; + display: flex; + flex-direction: column; + } + + header { + border-bottom: 1px solid var(--border); + padding: 1.25rem 2rem; + display: flex; + align-items: baseline; + gap: 1rem; + } + + .wordmark { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + } + + .wordmark span { + color: var(--muted); + font-weight: 400; + letter-spacing: 0.04em; + text-transform: none; + } + + main { + flex: 1; + max-width: 640px; + width: 100%; + margin: 0 auto; + padding: 3rem 1.5rem 4rem; + } + + h1 { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.4rem; + } + + h1 em { + font-style: normal; + color: var(--accent); + } + + .subtitle { + font-size: 14px; + color: var(--muted); + margin-bottom: 2.5rem; + line-height: 1.5; + } + + .section { + margin-bottom: 1.25rem; + } + + label { + display: block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: 6px; + } + + input[type="text"], + input[type="password"] { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-family: var(--sans); + font-size: 14px; + padding: 10px 12px; + outline: none; + transition: border-color 0.15s; + } + + input[type="text"]:focus, + input[type="password"]:focus { + border-color: var(--border2); + } + + input[type="password"] { + font-family: var(--mono); + font-size: 13px; + letter-spacing: 0.05em; + } + + .key-row { + position: relative; + } + + .key-row input { + padding-right: 44px; + } + + .eye-btn { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 0; + font-size: 16px; + line-height: 1; + transition: color 0.15s; + } + + .eye-btn:hover { color: var(--text); } + + .key-hint { + font-size: 11px; + color: var(--muted); + margin-top: 5px; + } + + .key-hint a { + color: var(--accent); + text-decoration: none; + } + + .key-hint a:hover { text-decoration: underline; } + + .drop-zone { + border: 1.5px dashed var(--border2); + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + position: relative; + } + + .drop-zone:hover, + .drop-zone.over { + border-color: var(--accent); + background: rgba(232, 255, 107, 0.03); + } + + .drop-zone.loaded { + border-color: var(--success); + border-style: solid; + } + + .drop-zone input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; + } + + .drop-icon { + font-size: 28px; + margin-bottom: 8px; + color: var(--muted); + } + + .drop-zone.loaded .drop-icon { color: var(--success); } + + .drop-label { + font-size: 14px; + color: var(--muted); + } + + .drop-label strong { + color: var(--text); + font-weight: 500; + } + + .field-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .run-btn { + width: 100%; + padding: 13px; + background: var(--accent); + color: var(--accent-text); + border: none; + border-radius: 6px; + font-size: 15px; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + margin-top: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .run-btn:hover { background: var(--accent-dim); } + .run-btn:active { transform: scale(0.99); } + .run-btn:disabled { + background: var(--surface2); + color: var(--muted); + cursor: default; + transform: none; + } + + .progress { + margin-top: 1.5rem; + display: none; + } + + .progress.visible { display: block; } + + .progress-bar-track { + height: 2px; + background: var(--surface2); + border-radius: 2px; + overflow: hidden; + margin-bottom: 12px; + } + + .progress-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + width: 0%; + transition: width 0.4s ease; + } + + .log { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 12px 14px; + font-family: var(--mono); + font-size: 12px; + line-height: 1.7; + color: var(--muted); + max-height: 180px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + } + + .log .ok { color: var(--success); } + .log .err { color: var(--danger); } + .log .info { color: var(--accent); } + + .result { + margin-top: 1.25rem; + display: none; + } + + .result.visible { display: block; } + + .dl-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + .dl-info { flex: 1; min-width: 0; } + + .dl-name { + font-size: 14px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .dl-meta { + font-size: 12px; + color: var(--muted); + margin-top: 2px; + } + + .dl-btn { + background: var(--accent); + color: var(--accent-text); + border: none; + border-radius: 6px; + padding: 8px 16px; + font-size: 13px; + font-weight: 700; + cursor: pointer; + white-space: nowrap; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 6px; + transition: background 0.15s; + } + + .dl-btn:hover { background: var(--accent-dim); } + + .error-msg { + margin-top: 1rem; + font-size: 13px; + color: var(--danger); + display: none; + } + + .error-msg.visible { display: block; } + + footer { + border-top: 1px solid var(--border); + padding: 1rem 2rem; + font-size: 12px; + color: var(--muted); + text-align: center; + } + + footer a { color: var(--muted); text-decoration: none; } + footer a:hover { color: var(--text); } + + .divider { + border: none; + border-top: 1px solid var(--border); + margin: 1.75rem 0; + } + + @media (max-width: 480px) { + .field-pair { grid-template-columns: 1fr; } + h1 { font-size: 22px; } + } +</style> +</head> +<body> + +<header> + <div class="wordmark">dickt<span>.store</span></div> + <div style="font-size:13px; color:var(--muted);">/ yomitan converter</div> +</header> + +<main> + <h1>MDict → <em>Yomitan</em></h1> + <p class="subtitle">Drop any MDict .txt export. Claude figures out the format and builds a ready-to-import Yomitan dictionary zip.</p> + + <div class="section"> + <label>Anthropic API key</label> + <div class="key-row"> + <input type="password" id="api-key" placeholder="sk-ant-…" autocomplete="off" spellcheck="false"> + <button class="eye-btn" id="eye-btn" title="Show/hide key">👁</button> + </div> + <p class="key-hint">Your key stays in the browser — never sent to our servers. Get one at <a href="https://console.anthropic.com" target="_blank">console.anthropic.com</a></p> + </div> + + <hr class="divider"> + + <div class="section"> + <label>Dictionary file</label> + <div class="drop-zone" id="drop-zone"> + <input type="file" id="file-input" accept=".txt"> + <div class="drop-icon" id="drop-icon">📄</div> + <p class="drop-label" id="drop-label"><strong>Drop .txt file here</strong> or click to browse</p> + </div> + </div> + + <div class="field-pair"> + <div class="section"> + <label>Dictionary title</label> + <input type="text" id="dict-title" placeholder="My Dictionary"> + </div> + <div class="section"> + <label>Output filename</label> + <input type="text" id="out-name" placeholder="my_dict_yomitan.zip"> + </div> + </div> + + <button class="run-btn" id="run-btn" disabled> + <span id="btn-label">Convert</span> + </button> + + <div class="error-msg" id="error-msg"></div> + + <div class="progress" id="progress"> + <div class="progress-bar-track"><div class="progress-bar-fill" id="progress-fill"></div></div> + <div class="log" id="log"></div> + </div> + + <div class="result" id="result"> + <div class="dl-card"> + <div class="dl-info"> + <div class="dl-name" id="dl-name">output.zip</div> + <div class="dl-meta" id="dl-meta"></div> + </div> + <a class="dl-btn" id="dl-btn" href="#" download>↓ Download</a> + </div> + </div> +</main> + +<footer> + <a href="https://dickt.store">dickt.store</a> — dictionary tools +</footer> + +<script> +const $ = id => document.getElementById(id); +let fileText = '', fileName = ''; + +// Eye toggle +$('eye-btn').addEventListener('click', () => { + const inp = $('api-key'); + inp.type = inp.type === 'password' ? 'text' : 'password'; +}); + +// Save key to localStorage +$('api-key').addEventListener('input', () => { + localStorage.setItem('anthropic_key', $('api-key').value); +}); +const savedKey = localStorage.getItem('anthropic_key'); +if (savedKey) $('api-key').value = savedKey; + +// File loading +const dropZone = $('drop-zone'); +const fileInput = $('file-input'); + +dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('over'); }); +dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over')); +dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('over'); loadFile(e.dataTransfer.files[0]); }); +fileInput.addEventListener('change', () => loadFile(fileInput.files[0])); + +function loadFile(f) { + if (!f || !f.name.endsWith('.txt')) return; + fileName = f.name; + const reader = new FileReader(); + reader.onload = e => { + fileText = e.target.result; + dropZone.classList.add('loaded'); + $('drop-icon').textContent = '✅'; + $('drop-label').innerHTML = `<strong>${f.name}</strong> — ${(f.size/1024).toFixed(0)} KB`; + if (!$('dict-title').value) $('dict-title').value = f.name.replace(/[_-]/g,' ').replace(/\.txt$/i,''); + if (!$('out-name').value) $('out-name').value = f.name.replace(/\.txt$/i,'') + '_yomitan.zip'; + checkReady(); + }; + reader.readAsText(f, 'utf-8'); +} + +function checkReady() { + $('run-btn').disabled = !(fileText && $('api-key').value.trim()); +} +$('api-key').addEventListener('input', checkReady); + +// Logging +function setProgress(pct) { + $('progress-fill').style.width = pct + '%'; +} + +function log(msg, cls = '') { + const el = $('log'); + const line = document.createElement('span'); + if (cls) line.className = cls; + line.textContent = msg + '\n'; + el.appendChild(line); + el.scrollTop = el.scrollHeight; +} + +function showError(msg) { + const el = $('error-msg'); + el.textContent = msg; + el.classList.add('visible'); +} + +function clearError() { + $('error-msg').classList.remove('visible'); +} + +// Main +$('run-btn').addEventListener('click', async () => { + clearError(); + $('result').classList.remove('visible'); + $('progress').classList.add('visible'); + $('log').textContent = ''; + $('run-btn').disabled = true; + $('btn-label').textContent = 'Working…'; + setProgress(5); + + const apiKey = $('api-key').value.trim(); + const dictTitle = $('dict-title').value.trim() || 'Dictionary'; + const outFile = $('out-name').value.trim() || 'yomitan.zip'; + const sample = fileText.slice(0, 8000); + + log('→ Sending sample to Claude…', 'info'); + + const system = `You are a dictionary format expert. Analyze a sample of a plain-text MDict export file and write JavaScript functions to parse it into Yomitan entries. + +Return ONLY raw JSON (no markdown fences) with this exact shape: +{ + "encoding_note": "one line description", + "parse": "function parseEntries(text) { /* ... */ return [{headword, html}]; }", + "html_to_text": "function htmlToText(html) { /* ... */ return string; }" +} + +Rules for parseEntries: +- Blocks are separated by </> +- @@@LINK=TARGET lines mean this headword is an alias — resolve it to the html of the TARGET block + - Pair by POSITION: LINK blocks always immediately precede their HTML block, so match positionally rather than by key lookup (keys may be corrupted) + - Multiple consecutive LINK blocks can precede one HTML block +- Headwords ending in a Unicode replacement char (\\uFFFD) followed by %[hex digit] are corrupted — SKIP them as headwords +- Return one entry per valid headword (both kana aliases and clean kanji headwords if present) +- The html field is the raw HTML body string + +Rules for htmlToText: +- Strip all HTML tags +- Keep meaningful text content +- Return a clean readable string`; + + let parsed; + try { + const resp = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-calls': 'true' }, + body: JSON.stringify({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1000, + system, + messages: [{ role: 'user', content: `File sample:\n\n${sample}` }] + }) + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error?.message || `HTTP ${resp.status}`); + } + const data = await resp.json(); + const raw = data.content.map(c => c.text || '').join('').trim() + .replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '').trim(); + parsed = JSON.parse(raw); + log(`← Format detected: ${parsed.encoding_note}`, 'ok'); + setProgress(25); + } catch (e) { + showError('Claude API error: ' + e.message); + log('✗ ' + e.message, 'err'); + $('run-btn').disabled = false; + $('btn-label').textContent = 'Convert'; + return; + } + + log('→ Running parser…', 'info'); + setProgress(35); + + let parseEntries, htmlToText; + try { + parseEntries = new Function('return (' + parsed.parse + ')')(); + htmlToText = new Function('return (' + parsed.html_to_text + ')')(); + } catch (e) { + showError('Generated parser has a syntax error: ' + e.message); + log('✗ ' + e.message, 'err'); + $('run-btn').disabled = false; + $('btn-label').textContent = 'Convert'; + return; + } + + let entries; + try { + entries = parseEntries(fileText); + if (!Array.isArray(entries) || entries.length === 0) throw new Error('Parser returned no entries'); + log(`← ${entries.length} entries found`, 'ok'); + setProgress(60); + } catch (e) { + showError('Parser error: ' + e.message); + log('✗ ' + e.message, 'err'); + $('run-btn').disabled = false; + $('btn-label').textContent = 'Convert'; + return; + } + + log('→ Building Yomitan zip…', 'info'); + + const BANK = 5000; + const terms = entries.map((e, i) => [ + e.headword, '', '', '', 0, + [{ type: 'structured-content', content: [htmlToText(e.html || '')] }], + i, '' + ]); + + const zip = new JSZip(); + zip.file('index.json', JSON.stringify({ + title: dictTitle, revision: '1', sequenced: false, format: 3, + author: '', url: '', description: '' + })); + + const banks = Math.ceil(terms.length / BANK); + for (let i = 0; i < terms.length; i += BANK) { + const n = Math.floor(i / BANK) + 1; + zip.file(`term_bank_${n}.json`, JSON.stringify(terms.slice(i, i + BANK))); + log(` term_bank_${n}.json (${Math.min(BANK, terms.length - i)} entries)`); + setProgress(60 + (n / banks) * 30); + } + + const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }); + const url = URL.createObjectURL(blob); + setProgress(100); + log(`✓ Done`, 'ok'); + + $('dl-btn').href = url; + $('dl-btn').download = outFile; + $('dl-name').textContent = outFile; + $('dl-meta').textContent = `${entries.length.toLocaleString()} entries · ${banks} bank${banks !== 1 ? 's' : ''} · ${(blob.size / 1024).toFixed(0)} KB`; + $('result').classList.add('visible'); + + $('run-btn').disabled = false; + $('btn-label').textContent = 'Convert again'; +}); +</script> + +</body> +</html>