Recently Written · git

dickt.store

BYOK yomitan dictionary converter for yomidick

git clone https://github.com/equwal/dickt.store

Log | Files | Refs


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> &mdash; 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> &mdash; ${(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>