4908245042
* feat(reader): Word Wise — inline native-language vocabulary hints Kindle-style Word Wise: a short native-language gloss renders above difficult words as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2); tapping a glossed word opens the existing dictionary. - Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure offset-aware planner (EN regex + jieba for CJK). - Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent (verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word offsets and find-in-book. - Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and downloaded on demand into local storage (sha-verified, single-flight) when enabled. - Settings: a Word Wise sub-page under Settings → Language (enable, level, hint language, per-pack download/manage, auto-download toggle). - Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict + FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * data(wordwise): bundled gloss packs + manifest + attribution 13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict + FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
4.3 KiB
JavaScript
115 lines
4.3 KiB
JavaScript
// Upload the committed Word Wise gloss packs + manifest to the cdn.readest.com
|
|
// R2 bucket under /wordwise/. Maintainer/CI tool — run after regenerating data.
|
|
//
|
|
// WORDWISE_R2_BUCKET=<bucket> node scripts/sync-wordwise-r2.mjs
|
|
//
|
|
// Pack files get a one-year immutable cache (the app cache-busts via a ?v=<sha8>
|
|
// query); manifest.json gets a short max-age so new packs surface quickly. Packs
|
|
// upload first, manifest LAST, so the CDN manifest never points at a missing pack.
|
|
//
|
|
// Robustness: each `wrangler r2 object put` is a separate process, spawned with NO
|
|
// stdin (so it can't block on a prompt) and telemetry off. Some wrangler versions
|
|
// don't exit promptly after the upload when behind an HTTP proxy (the socket stays
|
|
// open), which would wedge a synchronous loop on the very first file. So we watch
|
|
// the output and, once "Upload complete" is printed, give wrangler a moment to exit
|
|
// and otherwise kill it and move on. A per-file timeout is the backstop; one failed
|
|
// file is logged and the batch continues, returning a non-zero exit if any failed.
|
|
import { readdirSync } from 'node:fs';
|
|
import { spawn } from 'node:child_process';
|
|
import { resolve } from 'node:path';
|
|
|
|
const SRC_DIR = resolve('data/wordwise');
|
|
const PACK_CACHE = 'public, max-age=31536000, immutable';
|
|
const MANIFEST_CACHE = 'public, max-age=300';
|
|
const SUCCESS_RE = /Upload complete/i;
|
|
const POST_SUCCESS_GRACE_MS = 2_000; // wait this long for a clean exit after success
|
|
const PER_FILE_TIMEOUT_MS = 120_000; // hard backstop per file
|
|
|
|
const uploadOne = (bucket, file) =>
|
|
new Promise((resolveP) => {
|
|
const cacheControl = file === 'manifest.json' ? MANIFEST_CACHE : PACK_CACHE;
|
|
const key = `${bucket}/wordwise/${file}`;
|
|
console.log(`Uploading ${file} -> ${key}`);
|
|
const child = spawn(
|
|
'wrangler',
|
|
[
|
|
'r2', 'object', 'put', key,
|
|
'--file', resolve(SRC_DIR, file),
|
|
'--remote',
|
|
'--content-type', 'application/json',
|
|
'--cache-control', cacheControl,
|
|
],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, WRANGLER_SEND_METRICS: 'false' } },
|
|
);
|
|
|
|
let out = '';
|
|
let sawSuccess = false;
|
|
let settled = false;
|
|
let graceTimer = null;
|
|
const finish = (success) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(hardTimer);
|
|
clearTimeout(graceTimer);
|
|
if (!child.killed) child.kill('SIGKILL');
|
|
resolveP(success);
|
|
};
|
|
const onData = (d) => {
|
|
const s = d.toString();
|
|
process.stdout.write(s);
|
|
out += s;
|
|
if (!sawSuccess && SUCCESS_RE.test(out)) {
|
|
sawSuccess = true;
|
|
// Upload landed. Let wrangler exit on its own; kill it if it hangs (proxy).
|
|
graceTimer = setTimeout(() => finish(true), POST_SUCCESS_GRACE_MS);
|
|
}
|
|
};
|
|
child.stdout.on('data', onData);
|
|
child.stderr.on('data', onData);
|
|
const hardTimer = setTimeout(() => {
|
|
console.error(` ✗ ${file}: timed out after ${PER_FILE_TIMEOUT_MS / 1000}s`);
|
|
finish(sawSuccess);
|
|
}, PER_FILE_TIMEOUT_MS);
|
|
child.on('exit', (code) => {
|
|
const success = code === 0 || sawSuccess;
|
|
if (!success) console.error(` ✗ ${file}: wrangler exited ${code}`);
|
|
finish(success);
|
|
});
|
|
child.on('error', (e) => {
|
|
console.error(` ✗ ${file}: ${e.message}`);
|
|
finish(false);
|
|
});
|
|
});
|
|
|
|
async function main() {
|
|
const bucket = process.env.WORDWISE_R2_BUCKET;
|
|
if (!bucket) {
|
|
throw new Error('WORDWISE_R2_BUCKET env var is required (the cdn.readest.com R2 bucket name)');
|
|
}
|
|
const all = readdirSync(SRC_DIR).filter((f) => f.endsWith('.json'));
|
|
if (!all.includes('manifest.json')) {
|
|
throw new Error('manifest.json missing — run `pnpm wordwise:manifest` first');
|
|
}
|
|
const ordered = [...all.filter((f) => f !== 'manifest.json').sort(), 'manifest.json'];
|
|
|
|
let ok = 0;
|
|
const failed = [];
|
|
for (const file of ordered) {
|
|
// Sequential: keep output readable and avoid hammering the proxy.
|
|
// eslint-disable-next-line no-await-in-loop
|
|
if (await uploadOne(bucket, file)) ok += 1;
|
|
else failed.push(file);
|
|
}
|
|
|
|
console.log(`\nSynced ${ok}/${ordered.length} files to ${bucket}/wordwise/`);
|
|
if (failed.length) {
|
|
console.error(`Failed: ${failed.join(', ')}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err instanceof Error ? err.message : err);
|
|
process.exit(1);
|
|
});
|