docs-i18n 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{assemble-IOHQYYHI.js → assemble-ZHDLGVTL.js} +3 -4
- package/dist/chunk-I74LIORX.js +11211 -0
- package/dist/{chunk-QSVWLTGQ.js → chunk-OSMPWXSQ.js} +1 -1
- package/dist/{chunk-AKLW2MUS.js → chunk-PHDMD6EM.js} +29 -7
- package/dist/cli.js +6 -7
- package/dist/{rescan-VB2PILB2.js → rescan-OJTVWDAP.js} +2 -3
- package/dist/server-HNVJP43X.js +2742 -0
- package/dist/{status-EWQEACVF.js → status-ZG7F3FRT.js} +1 -2
- package/dist/translate-2PCYIWIG.js +14531 -0
- package/package.json +3 -2
- package/src/admin/index.html +13 -0
- package/src/admin/server/index.ts +88 -0
- package/src/admin/server/routes/jobs.ts +113 -0
- package/src/admin/server/routes/models.ts +87 -0
- package/src/admin/server/routes/status.ts +57 -0
- package/src/admin/server/services/job-manager.ts +184 -0
- package/src/admin/server/services/status.ts +183 -0
- package/src/admin/ui/App.tsx +326 -0
- package/src/admin/ui/components/FileList.tsx +438 -0
- package/src/admin/ui/components/JobDialog.tsx +360 -0
- package/src/admin/ui/components/JobPanel.tsx +134 -0
- package/src/admin/ui/components/LangGrid.tsx +54 -0
- package/src/admin/ui/components/Preview.tsx +369 -0
- package/src/admin/ui/components/ProgressBar.tsx +21 -0
- package/src/admin/ui/lib/api.ts +154 -0
- package/src/admin/ui/lib/flags.ts +30 -0
- package/src/admin/ui/main.tsx +19 -0
- package/src/admin/ui/styles.css +1096 -0
- package/src/admin/vite.config.ts +7 -0
- package/dist/build-4EQEL4NI.js +0 -12
- package/dist/build2-3W5WMFHZ.js +0 -4901
- package/dist/chunk-3YNFMSJH.js +0 -30
- package/dist/chunk-55MBYBVK.js +0 -368
- package/dist/chunk-FYDB7MZX.js +0 -38944
- package/dist/chunk-O35QHRY6.js +0 -6
- package/dist/chunk-PTIH4GGE.js +0 -44
- package/dist/chunk-SUIDX6IZ.js +0 -122
- package/dist/chunk-VKKNQBDN.js +0 -6487
- package/dist/dist-6C32URTL.js +0 -19
- package/dist/dist-HOWMMQFV.js +0 -6677
- package/dist/false-JGP4AGWN.js +0 -7
- package/dist/main-QVE5TVA3.js +0 -2505
- package/dist/node-4GLCLDJ6.js +0 -875
- package/dist/node-NUDVMOF2.js +0 -129
- package/dist/postcss-3SK7VUC2.js +0 -5886
- package/dist/postcss-import-JD46KA2Z.js +0 -458
- package/dist/prompt-BYQIwEjg-TG7DLENB.js +0 -915
- package/dist/server-ER56DGPR.js +0 -548
- package/dist/translate-F3AQFN6X.js +0 -707
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docs-i18n",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Universal documentation translation engine
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Universal documentation translation engine — parse, translate, cache, assemble, manage.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"docs-i18n": "dist/cli.js"
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|
|
17
|
+
"src/admin",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
@@ -0,0 +1,13 @@
|
|
|
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">
|
|
6
|
+
<title>Translation Admin</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<script>document.documentElement.dataset.theme=localStorage.getItem('theme')||'dark'</script>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/ui/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import type { DocsI18nConfig } from '../../config';
|
|
5
|
+
import { initStatus } from './services/status';
|
|
6
|
+
import jobRoutes from './routes/jobs';
|
|
7
|
+
import modelRoutes from './routes/models';
|
|
8
|
+
import statusRoutes from './routes/status';
|
|
9
|
+
|
|
10
|
+
export async function startAdmin(config: DocsI18nConfig, port = 3456) {
|
|
11
|
+
initStatus(config);
|
|
12
|
+
|
|
13
|
+
const app = new Hono();
|
|
14
|
+
app.route('/api/status', statusRoutes);
|
|
15
|
+
app.route('/api/jobs', jobRoutes);
|
|
16
|
+
app.route('/api/models', modelRoutes);
|
|
17
|
+
app.get('/api/health', (c) => c.json({ ok: true }));
|
|
18
|
+
app.get('/api/config', (c) => c.json({ projectRoot: process.cwd() }));
|
|
19
|
+
|
|
20
|
+
// Open file in editor
|
|
21
|
+
app.post('/api/open-file', async (c) => {
|
|
22
|
+
const { file } = await c.req.json<{ file: string }>();
|
|
23
|
+
if (!file) return c.json({ error: 'Missing file' }, 400);
|
|
24
|
+
const fullPath = resolve(process.cwd(), file);
|
|
25
|
+
if (!fullPath.startsWith(process.cwd())) return c.json({ error: 'Invalid path' }, 400);
|
|
26
|
+
|
|
27
|
+
const candidates = process.env.EDITOR_CMD ? [process.env.EDITOR_CMD] : ['code', 'cursor', 'zed'];
|
|
28
|
+
for (const cmd of candidates) {
|
|
29
|
+
const which = Bun.spawn(['which', cmd], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
30
|
+
await which.exited;
|
|
31
|
+
if (which.exitCode === 0) {
|
|
32
|
+
Bun.spawn([cmd, fullPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
33
|
+
return c.json({ opened: fullPath, editor: cmd });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const fallback = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
37
|
+
Bun.spawn([fallback, fullPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
38
|
+
return c.json({ opened: fullPath, editor: fallback });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const adminRoot = resolve(import.meta.dir, '..');
|
|
42
|
+
|
|
43
|
+
// Try to load Vite for dev mode (SPA)
|
|
44
|
+
try {
|
|
45
|
+
const { createServer: createViteServer } = await import('vite');
|
|
46
|
+
const vite = await createViteServer({
|
|
47
|
+
root: adminRoot,
|
|
48
|
+
server: { middlewareMode: true },
|
|
49
|
+
appType: 'spa',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const server = createServer(async (req, res) => {
|
|
53
|
+
const url = req.url ?? '/';
|
|
54
|
+
if (url.startsWith('/api')) {
|
|
55
|
+
const headers = new Headers();
|
|
56
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
57
|
+
if (v) headers.set(k, Array.isArray(v) ? v.join(', ') : v);
|
|
58
|
+
}
|
|
59
|
+
const body = req.method !== 'GET' && req.method !== 'HEAD'
|
|
60
|
+
? await new Promise<string>((r) => {
|
|
61
|
+
let data = '';
|
|
62
|
+
req.on('data', (c: Buffer) => { data += c.toString(); });
|
|
63
|
+
req.on('end', () => r(data));
|
|
64
|
+
})
|
|
65
|
+
: undefined;
|
|
66
|
+
const webReq = new Request(`http://localhost:${port}${url}`, {
|
|
67
|
+
method: req.method,
|
|
68
|
+
headers,
|
|
69
|
+
body,
|
|
70
|
+
});
|
|
71
|
+
const webRes = await app.fetch(webReq);
|
|
72
|
+
res.writeHead(webRes.status, Object.fromEntries(webRes.headers.entries()));
|
|
73
|
+
res.end(Buffer.from(await webRes.arrayBuffer()));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
vite.middlewares(req, res);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
server.listen(port, () => {
|
|
80
|
+
console.log(`🌐 docs-i18n admin → http://localhost:${port}`);
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
console.error('Failed to start admin UI (vite not available). API-only mode.');
|
|
84
|
+
// Fallback: just run Hono without Vite
|
|
85
|
+
const server = Bun.serve({ port, fetch: app.fetch });
|
|
86
|
+
console.log(`🌐 docs-i18n admin (API only) → http://localhost:${port}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { streamSSE } from 'hono/streaming';
|
|
3
|
+
import { jobManager } from '../services/job-manager';
|
|
4
|
+
|
|
5
|
+
const app = new Hono();
|
|
6
|
+
|
|
7
|
+
/** GET /api/jobs — List all jobs */
|
|
8
|
+
app.get('/', (c) => {
|
|
9
|
+
return c.json(
|
|
10
|
+
jobManager.list().map((j) => ({
|
|
11
|
+
...j,
|
|
12
|
+
logLines: (j.logLines ?? []).slice(-20), // Last 20 lines in list view
|
|
13
|
+
})),
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** POST /api/jobs — Start a new translation job */
|
|
18
|
+
app.post('/', async (c) => {
|
|
19
|
+
const body = await c.req.json<{
|
|
20
|
+
lang: string;
|
|
21
|
+
version: string;
|
|
22
|
+
max?: number;
|
|
23
|
+
concurrency?: number;
|
|
24
|
+
model?: string;
|
|
25
|
+
modelRotate?: string[];
|
|
26
|
+
md5?: boolean;
|
|
27
|
+
files?: string[];
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
if (!body.lang || !body.version) {
|
|
31
|
+
return c.json({ error: 'Missing lang or version' }, 400);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const job = jobManager.start(body);
|
|
36
|
+
return c.json(job, 201);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return c.json(
|
|
39
|
+
{ error: err instanceof Error ? err.message : 'Unknown error' },
|
|
40
|
+
409,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** GET /api/jobs/:id — Get job details */
|
|
46
|
+
app.get('/:id', (c) => {
|
|
47
|
+
const job = jobManager.get(c.req.param('id'));
|
|
48
|
+
if (!job) return c.json({ error: 'Job not found' }, 404);
|
|
49
|
+
return c.json(job);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** DELETE /api/jobs/:id — Cancel a running job or remove a finished one */
|
|
53
|
+
app.delete('/:id', (c) => {
|
|
54
|
+
const id = c.req.param('id');
|
|
55
|
+
const job = jobManager.get(id);
|
|
56
|
+
if (!job) return c.json({ error: 'Job not found' }, 404);
|
|
57
|
+
|
|
58
|
+
if (job.status === 'running') {
|
|
59
|
+
jobManager.cancel(id);
|
|
60
|
+
} else {
|
|
61
|
+
jobManager.remove(id);
|
|
62
|
+
}
|
|
63
|
+
return c.json({ ok: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** GET /api/jobs/:id/stream — SSE stream of job events */
|
|
67
|
+
app.get('/:id/stream', (c) => {
|
|
68
|
+
const id = c.req.param('id');
|
|
69
|
+
const job = jobManager.get(id);
|
|
70
|
+
if (!job) return c.json({ error: 'Job not found' }, 404);
|
|
71
|
+
|
|
72
|
+
return streamSSE(c, async (stream) => {
|
|
73
|
+
// Send current state
|
|
74
|
+
await stream.writeSSE({
|
|
75
|
+
data: JSON.stringify({
|
|
76
|
+
type: 'state',
|
|
77
|
+
data: { ...job, logLines: (job.logLines ?? []).slice(-50) },
|
|
78
|
+
}),
|
|
79
|
+
event: 'message',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Subscribe to future events
|
|
83
|
+
const unsubscribe = jobManager.subscribe(id, async (event) => {
|
|
84
|
+
try {
|
|
85
|
+
await stream.writeSSE({
|
|
86
|
+
data: JSON.stringify(event),
|
|
87
|
+
event: 'message',
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
// Client disconnected
|
|
91
|
+
unsubscribe();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Keep alive until client disconnects or job ends
|
|
96
|
+
while (true) {
|
|
97
|
+
const currentJob = jobManager.get(id);
|
|
98
|
+
if (!currentJob || currentJob.status !== 'running') {
|
|
99
|
+
// Send final state
|
|
100
|
+
await stream.writeSSE({
|
|
101
|
+
data: JSON.stringify({ type: 'done', data: currentJob }),
|
|
102
|
+
event: 'message',
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
await stream.sleep(1000);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
unsubscribe();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
export default app;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
|
|
3
|
+
const app = new Hono();
|
|
4
|
+
|
|
5
|
+
interface OpenRouterModel {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
pricing: {
|
|
9
|
+
prompt: string;
|
|
10
|
+
completion: string;
|
|
11
|
+
};
|
|
12
|
+
context_length: number;
|
|
13
|
+
architecture: {
|
|
14
|
+
modality: string;
|
|
15
|
+
output_modalities: string[];
|
|
16
|
+
tokenizer: string;
|
|
17
|
+
};
|
|
18
|
+
top_provider: {
|
|
19
|
+
context_length: number;
|
|
20
|
+
max_completion_tokens: number;
|
|
21
|
+
is_moderated: boolean;
|
|
22
|
+
};
|
|
23
|
+
supported_parameters: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let cachedResult: ReturnType<typeof formatModels> | null = null;
|
|
27
|
+
let cacheTime = 0;
|
|
28
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** GET /api/models — List OpenRouter models */
|
|
31
|
+
app.get('/', async (c) => {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (cachedResult && now - cacheTime < CACHE_TTL) {
|
|
34
|
+
return c.json(cachedResult);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch('https://openrouter.ai/api/v1/models');
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
return c.json({ error: `OpenRouter API error: ${res.status}` }, 502);
|
|
41
|
+
}
|
|
42
|
+
const { data } = (await res.json()) as { data: OpenRouterModel[] };
|
|
43
|
+
cachedResult = formatModels(data);
|
|
44
|
+
cacheTime = now;
|
|
45
|
+
return c.json(cachedResult);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return c.json(
|
|
48
|
+
{ error: err instanceof Error ? err.message : 'Failed to fetch models' },
|
|
49
|
+
500,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function formatModels(models: OpenRouterModel[]) {
|
|
55
|
+
return models
|
|
56
|
+
.filter((m) => {
|
|
57
|
+
if (!m.pricing) return false;
|
|
58
|
+
const pp = Number.parseFloat(m.pricing.prompt);
|
|
59
|
+
const cp = Number.parseFloat(m.pricing.completion);
|
|
60
|
+
if (pp < 0 || cp < 0) return false;
|
|
61
|
+
// Only text→text models
|
|
62
|
+
if (!m.architecture?.modality?.startsWith('text')) return false;
|
|
63
|
+
if (!m.architecture.output_modalities?.includes('text')) return false;
|
|
64
|
+
return true;
|
|
65
|
+
})
|
|
66
|
+
.map((m) => {
|
|
67
|
+
const pp = Number.parseFloat(m.pricing.prompt) * 1_000_000;
|
|
68
|
+
const cp = Number.parseFloat(m.pricing.completion) * 1_000_000;
|
|
69
|
+
return {
|
|
70
|
+
id: m.id,
|
|
71
|
+
name: m.name,
|
|
72
|
+
promptPrice: pp,
|
|
73
|
+
completionPrice: cp,
|
|
74
|
+
contextLength: m.context_length,
|
|
75
|
+
maxOutput: m.top_provider?.max_completion_tokens ?? 0,
|
|
76
|
+
isFree: pp === 0 && cp === 0,
|
|
77
|
+
supportsJson:
|
|
78
|
+
m.supported_parameters?.includes('response_format') ||
|
|
79
|
+
m.supported_parameters?.includes('structured_outputs'),
|
|
80
|
+
supportsTools: m.supported_parameters?.includes('tools'),
|
|
81
|
+
provider: m.id.split('/')[0],
|
|
82
|
+
};
|
|
83
|
+
})
|
|
84
|
+
.sort((a, b) => a.promptPrice - b.promptPrice);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default app;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import {
|
|
3
|
+
getCache,
|
|
4
|
+
getFileBlocks,
|
|
5
|
+
getFileCoverage,
|
|
6
|
+
getFileDetail,
|
|
7
|
+
getLangs,
|
|
8
|
+
getOverview,
|
|
9
|
+
getVersions,
|
|
10
|
+
rescan,
|
|
11
|
+
} from '../services/status';
|
|
12
|
+
|
|
13
|
+
const app = new Hono();
|
|
14
|
+
|
|
15
|
+
app.get('/', (c) => {
|
|
16
|
+
return c.json({
|
|
17
|
+
versions: getVersions(),
|
|
18
|
+
langs: ['en', ...getLangs()],
|
|
19
|
+
data: getOverview(),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
app.get('/:version/:lang', (c) => {
|
|
24
|
+
const { version, lang } = c.req.param();
|
|
25
|
+
if (lang === 'en') {
|
|
26
|
+
const anyLang = getLangs()[0];
|
|
27
|
+
const coverage = getFileCoverage(version, anyLang);
|
|
28
|
+
return c.json(coverage.map((f) => ({ file: f.file, total: f.total, translated: f.total })));
|
|
29
|
+
}
|
|
30
|
+
return c.json(getFileCoverage(version, lang));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
app.get('/:version/:lang/blocks', (c) => {
|
|
34
|
+
const { version, lang } = c.req.param();
|
|
35
|
+
const filePath = c.req.query('path');
|
|
36
|
+
if (!filePath) return c.json({ error: 'Missing path query param' }, 400);
|
|
37
|
+
const blocks = getFileBlocks(version, lang, filePath);
|
|
38
|
+
if (!blocks) return c.json({ error: 'File not found' }, 404);
|
|
39
|
+
return c.json({ file: filePath, lang, version, blocks });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
app.delete('/:version/:lang/cache', (c) => {
|
|
43
|
+
const { lang } = c.req.param();
|
|
44
|
+
const key = c.req.query('key');
|
|
45
|
+
if (!key) return c.json({ error: 'Missing key query param' }, 400);
|
|
46
|
+
const cache = getCache();
|
|
47
|
+
cache.db.prepare('DELETE FROM translations WHERE lang = ? AND key = ?').run(lang, key);
|
|
48
|
+
return c.json({ deleted: key, lang });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.post('/:version/rescan', (c) => {
|
|
52
|
+
const { version } = c.req.param();
|
|
53
|
+
const count = rescan(version);
|
|
54
|
+
return c.json({ version, files: count });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export default app;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const PROJECT_ROOT = process.cwd();
|
|
5
|
+
|
|
6
|
+
export interface Job {
|
|
7
|
+
id: string;
|
|
8
|
+
lang: string;
|
|
9
|
+
version: string;
|
|
10
|
+
project: string;
|
|
11
|
+
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
|
12
|
+
startedAt: string;
|
|
13
|
+
finishedAt?: string;
|
|
14
|
+
exitCode?: number | null;
|
|
15
|
+
logLines?: string[];
|
|
16
|
+
currentFile?: string;
|
|
17
|
+
translatedFiles: number;
|
|
18
|
+
totalFiles: number;
|
|
19
|
+
toTranslate: number;
|
|
20
|
+
errorFiles: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JobEvent {
|
|
24
|
+
type: 'log' | 'exit';
|
|
25
|
+
data?: string;
|
|
26
|
+
code?: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class JobManager {
|
|
30
|
+
private jobs = new Map<string, Job>();
|
|
31
|
+
private processes = new Map<string, ChildProcess>();
|
|
32
|
+
private subscribers = new Map<string, Set<(event: JobEvent) => void>>();
|
|
33
|
+
private nextId = 1;
|
|
34
|
+
|
|
35
|
+
start(opts: {
|
|
36
|
+
lang: string;
|
|
37
|
+
version: string;
|
|
38
|
+
project?: string;
|
|
39
|
+
max?: number;
|
|
40
|
+
concurrency?: number;
|
|
41
|
+
model?: string;
|
|
42
|
+
files?: string[];
|
|
43
|
+
}): Job {
|
|
44
|
+
// Prevent duplicate
|
|
45
|
+
for (const [, job] of this.jobs) {
|
|
46
|
+
if (job.lang === opts.lang && job.version === opts.version && job.status === 'running') {
|
|
47
|
+
throw new Error(`Job already running for ${opts.lang}/${opts.version}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const id = `job-${this.nextId++}`;
|
|
52
|
+
const job: Job = {
|
|
53
|
+
id,
|
|
54
|
+
lang: opts.lang,
|
|
55
|
+
version: opts.version,
|
|
56
|
+
project: opts.project ?? '',
|
|
57
|
+
status: 'running',
|
|
58
|
+
startedAt: new Date().toISOString(),
|
|
59
|
+
logLines: [],
|
|
60
|
+
translatedFiles: 0,
|
|
61
|
+
totalFiles: 0,
|
|
62
|
+
toTranslate: 0,
|
|
63
|
+
errorFiles: 0,
|
|
64
|
+
};
|
|
65
|
+
this.jobs.set(id, job);
|
|
66
|
+
|
|
67
|
+
// Build CLI args — call docs-i18n translate
|
|
68
|
+
const args = [
|
|
69
|
+
'src/cli.ts', // In dev; in production would be the installed bin
|
|
70
|
+
'translate',
|
|
71
|
+
'--lang', opts.lang,
|
|
72
|
+
'--version', opts.version,
|
|
73
|
+
'--max', String(opts.max ?? 999),
|
|
74
|
+
'--concurrency', String(opts.concurrency ?? 3),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
if (opts.project) args.push('--project', opts.project);
|
|
78
|
+
if (opts.model) args.push('--model', opts.model);
|
|
79
|
+
if (opts.files?.length) args.push('--files', opts.files.join(','));
|
|
80
|
+
|
|
81
|
+
const proc = spawn('bun', args, {
|
|
82
|
+
cwd: PROJECT_ROOT,
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
env: { ...process.env, NO_TTY: '1', FORCE_COLOR: '0' },
|
|
85
|
+
});
|
|
86
|
+
this.processes.set(id, proc);
|
|
87
|
+
|
|
88
|
+
proc.stdout?.on('data', (d: Buffer) => {
|
|
89
|
+
for (const line of d.toString().split('\n').filter(Boolean)) {
|
|
90
|
+
this.addLog(id, line);
|
|
91
|
+
this.parseProgress(job, line);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
proc.stderr?.on('data', (d: Buffer) => {
|
|
96
|
+
for (const line of d.toString().split('\n').filter(Boolean)) {
|
|
97
|
+
this.addLog(id, line);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
proc.on('exit', (code) => {
|
|
102
|
+
job.status = code === 0 ? 'completed' : 'failed';
|
|
103
|
+
job.exitCode = code;
|
|
104
|
+
job.finishedAt = new Date().toISOString();
|
|
105
|
+
job.currentFile = undefined;
|
|
106
|
+
this.addLog(id, `Process exited with code ${code}`);
|
|
107
|
+
this.processes.delete(id);
|
|
108
|
+
this.emit(id, { type: 'exit', code });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
proc.on('error', (err) => {
|
|
112
|
+
job.status = 'failed';
|
|
113
|
+
job.finishedAt = new Date().toISOString();
|
|
114
|
+
this.addLog(id, `Process error: ${err.message}`);
|
|
115
|
+
this.processes.delete(id);
|
|
116
|
+
this.emit(id, { type: 'exit', code: -1 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return job;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cancel(id: string): boolean {
|
|
123
|
+
const proc = this.processes.get(id);
|
|
124
|
+
const job = this.jobs.get(id);
|
|
125
|
+
if (!proc || !job) return false;
|
|
126
|
+
proc.kill('SIGTERM');
|
|
127
|
+
job.status = 'cancelled';
|
|
128
|
+
job.finishedAt = new Date().toISOString();
|
|
129
|
+
this.processes.delete(id);
|
|
130
|
+
this.emit(id, { type: 'exit', code: null });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
list(): Job[] {
|
|
135
|
+
return [...this.jobs.values()].sort(
|
|
136
|
+
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get(id: string): Job | undefined {
|
|
141
|
+
return this.jobs.get(id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
remove(id: string): boolean {
|
|
145
|
+
const job = this.jobs.get(id);
|
|
146
|
+
if (!job || job.status === 'running') return false;
|
|
147
|
+
this.jobs.delete(id);
|
|
148
|
+
this.subscribers.delete(id);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
subscribe(id: string, callback: (event: JobEvent) => void): () => void {
|
|
153
|
+
if (!this.subscribers.has(id)) this.subscribers.set(id, new Set());
|
|
154
|
+
this.subscribers.get(id)?.add(callback);
|
|
155
|
+
return () => { this.subscribers.get(id)?.delete(callback); };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private addLog(id: string, line: string) {
|
|
159
|
+
const job = this.jobs.get(id);
|
|
160
|
+
if (!job) return;
|
|
161
|
+
const ts = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
162
|
+
const entry = `[${ts}] ${line}`;
|
|
163
|
+
job.logLines = job.logLines ?? [];
|
|
164
|
+
job.logLines.push(entry);
|
|
165
|
+
if (job.logLines.length > 500) job.logLines.shift();
|
|
166
|
+
this.emit(id, { type: 'log', data: entry });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private emit(id: string, event: JobEvent) {
|
|
170
|
+
this.subscribers.get(id)?.forEach((cb) => cb(event));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private parseProgress(job: Job, line: string) {
|
|
174
|
+
const cached = line.match(/\+(\d+) cached/);
|
|
175
|
+
if (cached) job.translatedFiles += Number.parseInt(cached[1], 10);
|
|
176
|
+
const untranslated = line.match(/(\d+) untranslated keys/);
|
|
177
|
+
if (untranslated) job.toTranslate = Number.parseInt(untranslated[1], 10);
|
|
178
|
+
const chunk = line.match(/chunk \d+\/(\d+)/);
|
|
179
|
+
if (chunk) job.totalFiles = Number.parseInt(chunk[1], 10);
|
|
180
|
+
if (line.includes('⏳')) job.currentFile = line.replace(/^⏳\s*/, '');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const jobManager = new JobManager();
|