index.html (16731 bytes)
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Yomitan Converter — dickt.store</title> 7 <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> 8 <style> 9 :root { 10 --bg: #0e0e0f; 11 --surface: #18181b; 12 --surface2: #222226; 13 --border: #2e2e34; 14 --border2: #3e3e46; 15 --text: #f0f0f2; 16 --muted: #888892; 17 --accent: #e8ff6b; 18 --accent-dim: #c8df4a; 19 --accent-text: #0e0e0f; 20 --danger: #ff6b6b; 21 --success: #6bffb0; 22 --mono: 'Courier New', monospace; 23 --sans: system-ui, -apple-system, sans-serif; 24 } 25 26 * { box-sizing: border-box; margin: 0; padding: 0; } 27 28 body { 29 background: var(--bg); 30 color: var(--text); 31 font-family: var(--sans); 32 min-height: 100vh; 33 display: flex; 34 flex-direction: column; 35 } 36 37 header { 38 border-bottom: 1px solid var(--border); 39 padding: 1.25rem 2rem; 40 display: flex; 41 align-items: baseline; 42 gap: 1rem; 43 } 44 45 .wordmark { 46 font-size: 13px; 47 font-weight: 600; 48 letter-spacing: 0.12em; 49 text-transform: uppercase; 50 color: var(--accent); 51 } 52 53 .wordmark span { 54 color: var(--muted); 55 font-weight: 400; 56 letter-spacing: 0.04em; 57 text-transform: none; 58 } 59 60 main { 61 flex: 1; 62 max-width: 640px; 63 width: 100%; 64 margin: 0 auto; 65 padding: 3rem 1.5rem 4rem; 66 } 67 68 h1 { 69 font-size: 28px; 70 font-weight: 700; 71 letter-spacing: -0.02em; 72 margin-bottom: 0.4rem; 73 } 74 75 h1 em { 76 font-style: normal; 77 color: var(--accent); 78 } 79 80 .subtitle { 81 font-size: 14px; 82 color: var(--muted); 83 margin-bottom: 2.5rem; 84 line-height: 1.5; 85 } 86 87 .section { 88 margin-bottom: 1.25rem; 89 } 90 91 label { 92 display: block; 93 font-size: 11px; 94 font-weight: 600; 95 letter-spacing: 0.08em; 96 text-transform: uppercase; 97 color: var(--muted); 98 margin-bottom: 6px; 99 } 100 101 input[type="text"], 102 input[type="password"] { 103 width: 100%; 104 background: var(--surface); 105 border: 1px solid var(--border); 106 border-radius: 6px; 107 color: var(--text); 108 font-family: var(--sans); 109 font-size: 14px; 110 padding: 10px 12px; 111 outline: none; 112 transition: border-color 0.15s; 113 } 114 115 input[type="text"]:focus, 116 input[type="password"]:focus { 117 border-color: var(--border2); 118 } 119 120 input[type="password"] { 121 font-family: var(--mono); 122 font-size: 13px; 123 letter-spacing: 0.05em; 124 } 125 126 .key-row { 127 position: relative; 128 } 129 130 .key-row input { 131 padding-right: 44px; 132 } 133 134 .eye-btn { 135 position: absolute; 136 right: 12px; 137 top: 50%; 138 transform: translateY(-50%); 139 background: none; 140 border: none; 141 color: var(--muted); 142 cursor: pointer; 143 padding: 0; 144 font-size: 16px; 145 line-height: 1; 146 transition: color 0.15s; 147 } 148 149 .eye-btn:hover { color: var(--text); } 150 151 .key-hint { 152 font-size: 11px; 153 color: var(--muted); 154 margin-top: 5px; 155 } 156 157 .key-hint a { 158 color: var(--accent); 159 text-decoration: none; 160 } 161 162 .key-hint a:hover { text-decoration: underline; } 163 164 .drop-zone { 165 border: 1.5px dashed var(--border2); 166 border-radius: 8px; 167 padding: 2rem; 168 text-align: center; 169 cursor: pointer; 170 transition: border-color 0.15s, background 0.15s; 171 position: relative; 172 } 173 174 .drop-zone:hover, 175 .drop-zone.over { 176 border-color: var(--accent); 177 background: rgba(232, 255, 107, 0.03); 178 } 179 180 .drop-zone.loaded { 181 border-color: var(--success); 182 border-style: solid; 183 } 184 185 .drop-zone input { 186 position: absolute; 187 inset: 0; 188 opacity: 0; 189 cursor: pointer; 190 width: 100%; 191 height: 100%; 192 } 193 194 .drop-icon { 195 font-size: 28px; 196 margin-bottom: 8px; 197 color: var(--muted); 198 } 199 200 .drop-zone.loaded .drop-icon { color: var(--success); } 201 202 .drop-label { 203 font-size: 14px; 204 color: var(--muted); 205 } 206 207 .drop-label strong { 208 color: var(--text); 209 font-weight: 500; 210 } 211 212 .field-pair { 213 display: grid; 214 grid-template-columns: 1fr 1fr; 215 gap: 12px; 216 } 217 218 .run-btn { 219 width: 100%; 220 padding: 13px; 221 background: var(--accent); 222 color: var(--accent-text); 223 border: none; 224 border-radius: 6px; 225 font-size: 15px; 226 font-weight: 700; 227 letter-spacing: 0.01em; 228 cursor: pointer; 229 transition: background 0.15s, transform 0.1s; 230 margin-top: 1.5rem; 231 display: flex; 232 align-items: center; 233 justify-content: center; 234 gap: 8px; 235 } 236 237 .run-btn:hover { background: var(--accent-dim); } 238 .run-btn:active { transform: scale(0.99); } 239 .run-btn:disabled { 240 background: var(--surface2); 241 color: var(--muted); 242 cursor: default; 243 transform: none; 244 } 245 246 .progress { 247 margin-top: 1.5rem; 248 display: none; 249 } 250 251 .progress.visible { display: block; } 252 253 .progress-bar-track { 254 height: 2px; 255 background: var(--surface2); 256 border-radius: 2px; 257 overflow: hidden; 258 margin-bottom: 12px; 259 } 260 261 .progress-bar-fill { 262 height: 100%; 263 background: var(--accent); 264 border-radius: 2px; 265 width: 0%; 266 transition: width 0.4s ease; 267 } 268 269 .log { 270 background: var(--surface); 271 border: 1px solid var(--border); 272 border-radius: 6px; 273 padding: 12px 14px; 274 font-family: var(--mono); 275 font-size: 12px; 276 line-height: 1.7; 277 color: var(--muted); 278 max-height: 180px; 279 overflow-y: auto; 280 white-space: pre-wrap; 281 word-break: break-all; 282 } 283 284 .log .ok { color: var(--success); } 285 .log .err { color: var(--danger); } 286 .log .info { color: var(--accent); } 287 288 .result { 289 margin-top: 1.25rem; 290 display: none; 291 } 292 293 .result.visible { display: block; } 294 295 .dl-card { 296 background: var(--surface); 297 border: 1px solid var(--border); 298 border-radius: 8px; 299 padding: 1rem 1.25rem; 300 display: flex; 301 align-items: center; 302 justify-content: space-between; 303 gap: 1rem; 304 } 305 306 .dl-info { flex: 1; min-width: 0; } 307 308 .dl-name { 309 font-size: 14px; 310 font-weight: 500; 311 white-space: nowrap; 312 overflow: hidden; 313 text-overflow: ellipsis; 314 } 315 316 .dl-meta { 317 font-size: 12px; 318 color: var(--muted); 319 margin-top: 2px; 320 } 321 322 .dl-btn { 323 background: var(--accent); 324 color: var(--accent-text); 325 border: none; 326 border-radius: 6px; 327 padding: 8px 16px; 328 font-size: 13px; 329 font-weight: 700; 330 cursor: pointer; 331 white-space: nowrap; 332 text-decoration: none; 333 display: inline-flex; 334 align-items: center; 335 gap: 6px; 336 transition: background 0.15s; 337 } 338 339 .dl-btn:hover { background: var(--accent-dim); } 340 341 .error-msg { 342 margin-top: 1rem; 343 font-size: 13px; 344 color: var(--danger); 345 display: none; 346 } 347 348 .error-msg.visible { display: block; } 349 350 footer { 351 border-top: 1px solid var(--border); 352 padding: 1rem 2rem; 353 font-size: 12px; 354 color: var(--muted); 355 text-align: center; 356 } 357 358 footer a { color: var(--muted); text-decoration: none; } 359 footer a:hover { color: var(--text); } 360 361 .divider { 362 border: none; 363 border-top: 1px solid var(--border); 364 margin: 1.75rem 0; 365 } 366 367 @media (max-width: 480px) { 368 .field-pair { grid-template-columns: 1fr; } 369 h1 { font-size: 22px; } 370 } 371 </style> 372 </head> 373 <body> 374 375 <header> 376 <div class="wordmark">dickt<span>.store</span></div> 377 <div style="font-size:13px; color:var(--muted);">/ yomitan converter</div> 378 </header> 379 380 <main> 381 <h1>MDict → <em>Yomitan</em></h1> 382 <p class="subtitle">Drop any MDict .txt export. Claude figures out the format and builds a ready-to-import Yomitan dictionary zip.</p> 383 384 <div class="section"> 385 <label>Anthropic API key</label> 386 <div class="key-row"> 387 <input type="password" id="api-key" placeholder="sk-ant-…" autocomplete="off" spellcheck="false"> 388 <button class="eye-btn" id="eye-btn" title="Show/hide key">👁</button> 389 </div> 390 <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> 391 </div> 392 393 <hr class="divider"> 394 395 <div class="section"> 396 <label>Dictionary file</label> 397 <div class="drop-zone" id="drop-zone"> 398 <input type="file" id="file-input" accept=".txt"> 399 <div class="drop-icon" id="drop-icon">📄</div> 400 <p class="drop-label" id="drop-label"><strong>Drop .txt file here</strong> or click to browse</p> 401 </div> 402 </div> 403 404 <div class="field-pair"> 405 <div class="section"> 406 <label>Dictionary title</label> 407 <input type="text" id="dict-title" placeholder="My Dictionary"> 408 </div> 409 <div class="section"> 410 <label>Output filename</label> 411 <input type="text" id="out-name" placeholder="my_dict_yomitan.zip"> 412 </div> 413 </div> 414 415 <button class="run-btn" id="run-btn" disabled> 416 <span id="btn-label">Convert</span> 417 </button> 418 419 <div class="error-msg" id="error-msg"></div> 420 421 <div class="progress" id="progress"> 422 <div class="progress-bar-track"><div class="progress-bar-fill" id="progress-fill"></div></div> 423 <div class="log" id="log"></div> 424 </div> 425 426 <div class="result" id="result"> 427 <div class="dl-card"> 428 <div class="dl-info"> 429 <div class="dl-name" id="dl-name">output.zip</div> 430 <div class="dl-meta" id="dl-meta"></div> 431 </div> 432 <a class="dl-btn" id="dl-btn" href="#" download>↓ Download</a> 433 </div> 434 </div> 435 </main> 436 437 <footer> 438 <a href="https://dickt.store">dickt.store</a> — dictionary tools 439 </footer> 440 441 <script> 442 const $ = id => document.getElementById(id); 443 let fileText = '', fileName = ''; 444 445 // Eye toggle 446 $('eye-btn').addEventListener('click', () => { 447 const inp = $('api-key'); 448 inp.type = inp.type === 'password' ? 'text' : 'password'; 449 }); 450 451 // Save key to localStorage 452 $('api-key').addEventListener('input', () => { 453 localStorage.setItem('anthropic_key', $('api-key').value); 454 }); 455 const savedKey = localStorage.getItem('anthropic_key'); 456 if (savedKey) $('api-key').value = savedKey; 457 458 // File loading 459 const dropZone = $('drop-zone'); 460 const fileInput = $('file-input'); 461 462 dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('over'); }); 463 dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over')); 464 dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('over'); loadFile(e.dataTransfer.files[0]); }); 465 fileInput.addEventListener('change', () => loadFile(fileInput.files[0])); 466 467 function loadFile(f) { 468 if (!f || !f.name.endsWith('.txt')) return; 469 fileName = f.name; 470 const reader = new FileReader(); 471 reader.onload = e => { 472 fileText = e.target.result; 473 dropZone.classList.add('loaded'); 474 $('drop-icon').textContent = '✅'; 475 $('drop-label').innerHTML = `<strong>${f.name}</strong> — ${(f.size/1024).toFixed(0)} KB`; 476 if (!$('dict-title').value) $('dict-title').value = f.name.replace(/[_-]/g,' ').replace(/\.txt$/i,''); 477 if (!$('out-name').value) $('out-name').value = f.name.replace(/\.txt$/i,'') + '_yomitan.zip'; 478 checkReady(); 479 }; 480 reader.readAsText(f, 'utf-8'); 481 } 482 483 function checkReady() { 484 $('run-btn').disabled = !(fileText && $('api-key').value.trim()); 485 } 486 $('api-key').addEventListener('input', checkReady); 487 488 // Logging 489 function setProgress(pct) { 490 $('progress-fill').style.width = pct + '%'; 491 } 492 493 function log(msg, cls = '') { 494 const el = $('log'); 495 const line = document.createElement('span'); 496 if (cls) line.className = cls; 497 line.textContent = msg + '\n'; 498 el.appendChild(line); 499 el.scrollTop = el.scrollHeight; 500 } 501 502 function showError(msg) { 503 const el = $('error-msg'); 504 el.textContent = msg; 505 el.classList.add('visible'); 506 } 507 508 function clearError() { 509 $('error-msg').classList.remove('visible'); 510 } 511 512 // Main 513 $('run-btn').addEventListener('click', async () => { 514 clearError(); 515 $('result').classList.remove('visible'); 516 $('progress').classList.add('visible'); 517 $('log').textContent = ''; 518 $('run-btn').disabled = true; 519 $('btn-label').textContent = 'Working…'; 520 setProgress(5); 521 522 const apiKey = $('api-key').value.trim(); 523 const dictTitle = $('dict-title').value.trim() || 'Dictionary'; 524 const outFile = $('out-name').value.trim() || 'yomitan.zip'; 525 const sample = fileText.slice(0, 8000); 526 527 log('→ Sending sample to Claude…', 'info'); 528 529 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. 530 531 Return ONLY raw JSON (no markdown fences) with this exact shape: 532 { 533 "encoding_note": "one line description", 534 "parse": "function parseEntries(text) { /* ... */ return [{headword, html}]; }", 535 "html_to_text": "function htmlToText(html) { /* ... */ return string; }" 536 } 537 538 Rules for parseEntries: 539 - Blocks are separated by </> 540 - @@@LINK=TARGET lines mean this headword is an alias — resolve it to the html of the TARGET block 541 - Pair by POSITION: LINK blocks always immediately precede their HTML block, so match positionally rather than by key lookup (keys may be corrupted) 542 - Multiple consecutive LINK blocks can precede one HTML block 543 - Headwords ending in a Unicode replacement char (\\uFFFD) followed by %[hex digit] are corrupted — SKIP them as headwords 544 - Return one entry per valid headword (both kana aliases and clean kanji headwords if present) 545 - The html field is the raw HTML body string 546 547 Rules for htmlToText: 548 - Strip all HTML tags 549 - Keep meaningful text content 550 - Return a clean readable string`; 551 552 let parsed; 553 try { 554 const resp = await fetch('https://api.anthropic.com/v1/messages', { 555 method: 'POST', 556 headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-calls': 'true' }, 557 body: JSON.stringify({ 558 model: 'claude-sonnet-4-20250514', 559 max_tokens: 1000, 560 system, 561 messages: [{ role: 'user', content: `File sample:\n\n${sample}` }] 562 }) 563 }); 564 if (!resp.ok) { 565 const err = await resp.json().catch(() => ({})); 566 throw new Error(err.error?.message || `HTTP ${resp.status}`); 567 } 568 const data = await resp.json(); 569 const raw = data.content.map(c => c.text || '').join('').trim() 570 .replace(/^```[a-z]*\n?/, '').replace(/\n?```$/, '').trim(); 571 parsed = JSON.parse(raw); 572 log(`← Format detected: ${parsed.encoding_note}`, 'ok'); 573 setProgress(25); 574 } catch (e) { 575 showError('Claude API error: ' + e.message); 576 log('✗ ' + e.message, 'err'); 577 $('run-btn').disabled = false; 578 $('btn-label').textContent = 'Convert'; 579 return; 580 } 581 582 log('→ Running parser…', 'info'); 583 setProgress(35); 584 585 let parseEntries, htmlToText; 586 try { 587 parseEntries = new Function('return (' + parsed.parse + ')')(); 588 htmlToText = new Function('return (' + parsed.html_to_text + ')')(); 589 } catch (e) { 590 showError('Generated parser has a syntax error: ' + e.message); 591 log('✗ ' + e.message, 'err'); 592 $('run-btn').disabled = false; 593 $('btn-label').textContent = 'Convert'; 594 return; 595 } 596 597 let entries; 598 try { 599 entries = parseEntries(fileText); 600 if (!Array.isArray(entries) || entries.length === 0) throw new Error('Parser returned no entries'); 601 log(`← ${entries.length} entries found`, 'ok'); 602 setProgress(60); 603 } catch (e) { 604 showError('Parser error: ' + e.message); 605 log('✗ ' + e.message, 'err'); 606 $('run-btn').disabled = false; 607 $('btn-label').textContent = 'Convert'; 608 return; 609 } 610 611 log('→ Building Yomitan zip…', 'info'); 612 613 const BANK = 5000; 614 const terms = entries.map((e, i) => [ 615 e.headword, '', '', '', 0, 616 [{ type: 'structured-content', content: [htmlToText(e.html || '')] }], 617 i, '' 618 ]); 619 620 const zip = new JSZip(); 621 zip.file('index.json', JSON.stringify({ 622 title: dictTitle, revision: '1', sequenced: false, format: 3, 623 author: '', url: '', description: '' 624 })); 625 626 const banks = Math.ceil(terms.length / BANK); 627 for (let i = 0; i < terms.length; i += BANK) { 628 const n = Math.floor(i / BANK) + 1; 629 zip.file(`term_bank_${n}.json`, JSON.stringify(terms.slice(i, i + BANK))); 630 log(` term_bank_${n}.json (${Math.min(BANK, terms.length - i)} entries)`); 631 setProgress(60 + (n / banks) * 30); 632 } 633 634 const blob = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }); 635 const url = URL.createObjectURL(blob); 636 setProgress(100); 637 log(`✓ Done`, 'ok'); 638 639 $('dl-btn').href = url; 640 $('dl-btn').download = outFile; 641 $('dl-name').textContent = outFile; 642 $('dl-meta').textContent = `${entries.length.toLocaleString()} entries · ${banks} bank${banks !== 1 ? 's' : ''} · ${(blob.size / 1024).toFixed(0)} KB`; 643 $('result').classList.add('visible'); 644 645 $('run-btn').disabled = false; 646 $('btn-label').textContent = 'Convert again'; 647 }); 648 </script> 649 650 </body> 651 </html>