Recently Written · git

dickt.store

BYOK yomitan dictionary converter for yomidick

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

Log | Files | Refs


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