Skip to content

Commit 82b6643

Browse files
committed
feat: Web Worker + batch + detect + scripts encyclopedia + CLI improvements
Five more big-impact surfaces built on the worker infrastructure. 1. Web Worker for transliteration (interscript.org) - src/scripts/transliteration-worker.ts: long-running worker that owns the interscript-ts runtime - src/scripts/worker-client.ts: main-thread RPC client with promise- based API (transliterate, loadMap, reset, terminate) - CompareMode + BatchProcessor + DetectPanel all route through it - 5 simultaneous transliterations and bulk batches no longer jank the main thread 2. /batch — bulk transliteration - Paste many names (one per line), pick a system, click run - Results stream in as each name finishes - One-click CSV export for spreadsheet / MARC editor workflows - Privacy: text never leaves the browser - Targeted at libraries (catalog cleanup), newsrooms (story sources), genealogy (parish records), academia (bibliographies) 3. /detect — detection playground - Paste source + observed romanization, find which authority system best explains the pair - User picks script family; we test every system in the family - Ranked by Levenshtein distance (smaller = better match) - Use cases: provenance research, quality control, entity resolution 4. /scripts — visual encyclopedia - One card per ISO 15924 script Interscript handles - Each card shows a real sample (source + Latin) from the test suite - Expandable system list per script - Glossary table with ISO 15924 codes + numeric identifiers - All names resolved via @iso24229/iso15924-data 5. CLI improvements (interscript-ts) - Subcommand-based: transliterate (t), batch (b), list (l), detect (d) - Global flags: --maps-dir, --http, --no-cache - Auto-resolves maps from (in order): --maps-dir, --http, ./maps/, ./public/maps/, https://interscript.org/maps/ - list filters by --authority, --source-script, --destination-script - batch emits CSV (--csv) or TSV with --output file support - 9 new CLI tests covering happy path + edge cases 6. GitHub Action snippet on /api - Copy-paste workflow that romanizes names.txt on every push - Uses npx interscript-ts@latest with HTTP loader — zero install Navigation: 10 → 13 destinations (added /batch, /detect, /scripts). Removed /blog from primary nav (still accessible; legacy Opal content). Tests: - interscript-ts: 133 (was 125; +9 CLI tests + others) - interscript.org: 171 (was 153; +18 round3 tests) - All 304 tests pass
1 parent b09ef2e commit 82b6643

14 files changed

Lines changed: 1805 additions & 30 deletions

src/components/BatchProcessor.vue

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
<script setup lang="ts">
2+
/**
3+
* BatchProcessor — paste many names, get them all transliterated.
4+
*
5+
* Practical for:
6+
* - Libraries cleaning up catalog records
7+
* - Newsrooms romanizing a story's source list
8+
* - Genealogists working through parish records
9+
* - Academics romanizing citation lists
10+
*
11+
* Uses the Web Worker so 500 names doesn't freeze the page.
12+
*/
13+
import { ref, computed, onMounted, onUnmounted } from "vue"
14+
import { createWorkerClient, type WorkerClient } from "../scripts/worker-client"
15+
16+
interface Props {
17+
systems: { code: string; label: string }[]
18+
}
19+
20+
const props = defineProps<Props>()
21+
22+
const system = ref(props.systems[0]?.code ?? "")
23+
const inputText = ref("Антон\nМихаил\nКиев\nЛев Толстой")
24+
const results = ref<{ input: string; output: string; error?: string }[]>([])
25+
const running = ref(false)
26+
const elapsedMs = ref(0)
27+
28+
let client: WorkerClient | null = null
29+
30+
async function ensureEngine() {
31+
if (client) return
32+
client = createWorkerClient()
33+
}
34+
35+
const lines = computed(() =>
36+
inputText.value
37+
.split(/\r?\n/)
38+
.map((l) => l.trim())
39+
.filter((l) => l.length > 0),
40+
)
41+
42+
async function run() {
43+
if (!client) return
44+
running.value = true
45+
const start = performance.now()
46+
const items = lines.value
47+
const out: typeof results.value = []
48+
// Process sequentially so the user sees streaming progress. Each
49+
// call goes to the worker, so the main thread stays responsive.
50+
for (let i = 0; i < items.length; i++) {
51+
const input = items[i]!
52+
try {
53+
const output = await client.transliterate(system.value, input)
54+
out.push({ input, output })
55+
} catch (e) {
56+
out.push({ input, output: "", error: (e as Error).message })
57+
}
58+
results.value = [...out]
59+
}
60+
elapsedMs.value = Math.round(performance.now() - start)
61+
running.value = false
62+
}
63+
64+
const inputCount = computed(() => lines.value.length)
65+
const outputCount = computed(() => results.value.filter((r) => !r.error).length)
66+
const errorCount = computed(() => results.value.filter((r) => r.error).length)
67+
68+
const csvOutput = computed(() => {
69+
const rows = [["input", "output", "error"]]
70+
for (const r of results.value) {
71+
rows.push([r.input, r.output, r.error ?? ""])
72+
}
73+
return rows
74+
.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
75+
.join("\n")
76+
})
77+
78+
async function copyCsv() {
79+
await navigator.clipboard.writeText(csvOutput.value)
80+
}
81+
82+
onMounted(ensureEngine)
83+
onUnmounted(() => client?.terminate())
84+
</script>
85+
86+
<template>
87+
<div class="batch">
88+
<div class="batch-controls">
89+
<div class="control-field">
90+
<label for="batch-system">System</label>
91+
<select id="batch-system" v-model="system">
92+
<option v-for="s in systems" :key="s.code" :value="s.code">{{ s.label }}</option>
93+
</select>
94+
</div>
95+
<button class="run-btn" :disabled="running || inputCount === 0" @click="run">
96+
{{ running ? `Working… (${results.length}/${inputCount})` : `Transliterate ${inputCount} →` }}
97+
</button>
98+
</div>
99+
100+
<div class="batch-grid">
101+
<div class="input-pane">
102+
<header>
103+
<span class="pane-label">Input — one name per line</span>
104+
<span class="pane-count tnum">{{ inputCount }}</span>
105+
</header>
106+
<textarea
107+
v-model="inputText"
108+
spellcheck="false"
109+
placeholder="Антон&#10;Михаил&#10;Киев"
110+
></textarea>
111+
</div>
112+
113+
<div class="output-pane">
114+
<header>
115+
<span class="pane-label">Output</span>
116+
<span class="pane-stats tnum">
117+
<span class="ok">{{ outputCount }} ok</span>
118+
<span v-if="errorCount" class="err">{{ errorCount }} errors</span>
119+
<span v-if="elapsedMs" class="time">{{ elapsedMs }}ms</span>
120+
</span>
121+
<button
122+
v-if="results.length > 0"
123+
class="copy-btn"
124+
@click="copyCsv"
125+
>Copy CSV</button>
126+
</header>
127+
<ol class="result-list">
128+
<li v-for="(r, i) in results" :key="i" :class="{ error: r.error }">
129+
<span class="row-in">{{ r.input }}</span>
130+
<span class="row-arrow" aria-hidden="true">→</span>
131+
<span v-if="r.error" class="row-err">⚠ {{ r.error }}</span>
132+
<span v-else class="row-out">{{ r.output }}</span>
133+
</li>
134+
<li v-if="results.length === 0 && !running" class="empty">
135+
Click <em>Transliterate</em> to see results.
136+
</li>
137+
</ol>
138+
</div>
139+
</div>
140+
</div>
141+
</template>
142+
143+
<style scoped>
144+
.batch {
145+
display: grid;
146+
gap: 1.5rem;
147+
}
148+
149+
.batch-controls {
150+
display: flex;
151+
gap: 0.75rem;
152+
align-items: end;
153+
flex-wrap: wrap;
154+
}
155+
.control-field {
156+
display: grid;
157+
gap: 0.4rem;
158+
flex: 1;
159+
min-width: 240px;
160+
}
161+
.control-field label {
162+
font-family: var(--font-mono);
163+
font-size: var(--text-micro);
164+
letter-spacing: 0.15em;
165+
text-transform: uppercase;
166+
color: var(--color-stone);
167+
}
168+
.control-field select {
169+
font-family: var(--font-sans);
170+
font-size: 0.95rem;
171+
padding: 0.625rem 0.75rem;
172+
background: var(--color-vellum);
173+
border: 1.5px solid var(--color-rule);
174+
border-radius: 1px;
175+
color: var(--color-ink);
176+
outline: none;
177+
}
178+
.control-field select:focus { border-color: var(--color-brand); }
179+
180+
.run-btn {
181+
font-family: var(--font-mono);
182+
font-size: 0.8125rem;
183+
letter-spacing: 0.05em;
184+
text-transform: uppercase;
185+
padding: 0.7rem 1.25rem;
186+
background: var(--color-ink);
187+
color: var(--color-vellum);
188+
border: 1.5px solid var(--color-ink);
189+
cursor: pointer;
190+
border-radius: 1px;
191+
transition: all 0.15s ease;
192+
}
193+
.run-btn:hover:not(:disabled) {
194+
background: var(--color-brand);
195+
border-color: var(--color-brand);
196+
}
197+
.run-btn:disabled {
198+
opacity: 0.5;
199+
cursor: not-allowed;
200+
}
201+
202+
.batch-grid {
203+
display: grid;
204+
grid-template-columns: 1fr;
205+
gap: 1.5rem;
206+
}
207+
@media (min-width: 900px) {
208+
.batch-grid {
209+
grid-template-columns: 1fr 1.2fr;
210+
}
211+
}
212+
213+
.input-pane, .output-pane {
214+
background: var(--color-vellum);
215+
border: 1px solid var(--color-rule);
216+
display: flex;
217+
flex-direction: column;
218+
}
219+
.input-pane header, .output-pane header {
220+
display: flex;
221+
align-items: center;
222+
gap: 0.75rem;
223+
padding: 0.75rem 1rem;
224+
border-bottom: 1px solid var(--color-rule);
225+
font-family: var(--font-mono);
226+
font-size: var(--text-micro);
227+
letter-spacing: 0.12em;
228+
text-transform: uppercase;
229+
color: var(--color-stone);
230+
}
231+
.pane-label { flex: 1; }
232+
.pane-count {
233+
background: var(--color-paper-deep);
234+
padding: 0.2rem 0.55rem;
235+
border-radius: 1px;
236+
color: var(--color-ink);
237+
}
238+
.pane-stats {
239+
display: flex;
240+
gap: 0.625rem;
241+
font-size: 0.65rem;
242+
}
243+
.pane-stats .ok { color: var(--color-brand-deep); }
244+
.pane-stats .err { color: var(--color-highlight); }
245+
.pane-stats .time { color: var(--color-stone-light); }
246+
.copy-btn {
247+
font-family: inherit;
248+
font-size: 0.65rem;
249+
letter-spacing: 0.1em;
250+
text-transform: uppercase;
251+
background: transparent;
252+
border: 1px solid var(--color-rule-strong);
253+
padding: 0.25rem 0.5rem;
254+
cursor: pointer;
255+
color: var(--color-stone);
256+
border-radius: 1px;
257+
}
258+
.copy-btn:hover {
259+
background: var(--color-ink);
260+
color: var(--color-vellum);
261+
border-color: var(--color-ink);
262+
}
263+
264+
textarea {
265+
flex: 1;
266+
min-height: 320px;
267+
font-family: var(--font-display);
268+
font-size: 1.0625rem;
269+
padding: 1rem;
270+
border: none;
271+
outline: none;
272+
resize: vertical;
273+
background: transparent;
274+
color: var(--color-ink);
275+
line-height: 1.55;
276+
}
277+
278+
.result-list {
279+
list-style: none;
280+
padding: 0.5rem 1rem;
281+
margin: 0;
282+
flex: 1;
283+
min-height: 320px;
284+
max-height: 480px;
285+
overflow-y: auto;
286+
}
287+
.result-list li {
288+
display: grid;
289+
grid-template-columns: 1fr auto 1fr;
290+
align-items: baseline;
291+
gap: 0.875rem;
292+
padding: 0.625rem 0;
293+
border-bottom: 1px dashed var(--color-rule);
294+
font-family: var(--font-display);
295+
font-size: 1rem;
296+
}
297+
.result-list li:last-child { border-bottom: none; }
298+
.result-list li.empty {
299+
grid-template-columns: 1fr;
300+
text-align: center;
301+
color: var(--color-stone);
302+
font-style: italic;
303+
font-family: var(--font-sans);
304+
font-size: 0.9rem;
305+
}
306+
.result-list li.error .row-out,
307+
.result-list li.error .row-err {
308+
color: var(--color-highlight);
309+
}
310+
.row-in { color: var(--color-stone); }
311+
.row-arrow { color: var(--color-highlight); font-family: var(--font-mono); font-size: 0.75rem; }
312+
.row-out { color: var(--color-highlight); font-style: italic; }
313+
.row-err { color: var(--color-highlight); font-family: var(--font-mono); font-size: 0.75rem; font-style: normal; }
314+
</style>

0 commit comments

Comments
 (0)