@captiveorigins/payload-plugin-polyglot 0.1.0 → 0.2.0
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/README.md +24 -3
- package/dist/claude.d.ts +7 -3
- package/dist/claude.js +46 -9
- package/dist/client/PolyglotDashboard.js +5 -3
- package/dist/endpoints.js +16 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/microsoft.js +3 -1
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +15 -0
- package/dist/rsc/PolyglotView.d.ts +6 -1
- package/dist/rsc/PolyglotView.js +21 -4
- package/dist/rsc/index.d.ts +1 -1
- package/dist/rsc/index.js +1 -1
- package/dist/settings.d.ts +37 -0
- package/dist/settings.js +152 -0
- package/dist/text.d.ts +1 -1
- package/dist/text.js +3 -2
- package/dist/translate.d.ts +10 -3
- package/dist/translate.js +85 -48
- package/dist/types.d.ts +38 -3
- package/dist/types.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,11 +15,22 @@ have, a little at a time.
|
|
|
15
15
|
- **Never wastes calls on placeholders.** Lorem ipsum (and friends) is never sent.
|
|
16
16
|
- **Respects people.** Languages marked "Reviewed by a human" are never overwritten; when the
|
|
17
17
|
source changes they are flagged as stale instead.
|
|
18
|
-
- **
|
|
19
|
-
|
|
18
|
+
- **Cheap or free engines.** Claude built in (Haiku by default: in our test a 24,000-character
|
|
19
|
+
site into 21 languages cost $0.30, about half that through the Batch API, with quality close
|
|
20
|
+
to Opus); Microsoft Translator (free tier, about 2 million characters a month) built in; bring
|
|
21
|
+
your own engine.
|
|
20
22
|
A per-run character cap and clean stops on a used-up quota keep you inside free tiers: the
|
|
21
23
|
next run continues where the last stopped.
|
|
22
24
|
- **Do-not-translate list** for product names, brands and scientific names.
|
|
25
|
+
- **Translation settings in the admin** (no deploy): pause automatic translation, languages not
|
|
26
|
+
machine-translated, extra never-translate terms, notes for the translator (tone, audience), a
|
|
27
|
+
glossary of preferred translations per language, a per-sweep character cap, and characters
|
|
28
|
+
sent per month. The Translations view shows coverage per collection and language, with
|
|
29
|
+
Translate now.
|
|
30
|
+
- **Tells admins when it stops.** Out of credits, a free quota used up or a refused key stops
|
|
31
|
+
translation cleanly (nothing is lost) and shows why on the admin dashboard, as ⚠ next to
|
|
32
|
+
Translations in the menu, and on the Translations view; `onStop` runs once (e.g. to email
|
|
33
|
+
admins) and everything clears with the next successful translation.
|
|
23
34
|
- **Engine per language** (`translatorFor`), e.g. one engine for European languages and another
|
|
24
35
|
for the rest.
|
|
25
36
|
|
|
@@ -55,7 +66,7 @@ Run the sweep from a cron job or script:
|
|
|
55
66
|
```ts
|
|
56
67
|
import { syncAll } from '@captiveorigins/payload-plugin-polyglot'
|
|
57
68
|
|
|
58
|
-
const report = await syncAll(payload) // { translated, pending, stopped: 'done' | 'budget' | 'quota', … }
|
|
69
|
+
const report = await syncAll(payload) // { translated, pending, stopped: 'done' | 'budget' | 'quota' | 'paused', … }
|
|
59
70
|
```
|
|
60
71
|
|
|
61
72
|
Claude instead of Microsoft:
|
|
@@ -64,9 +75,15 @@ Claude instead of Microsoft:
|
|
|
64
75
|
import { claudeTranslator } from '@captiveorigins/payload-plugin-polyglot/claude'
|
|
65
76
|
|
|
66
77
|
translator: claudeTranslator({ instructions: 'A website for reptile keepers.' }) // ANTHROPIC_API_KEY
|
|
78
|
+
// Default model claude-haiku-5-5; pass { model: 'claude-sonnet-5-5' } for more care.
|
|
67
79
|
// syncAll(payload, { useBatch: true }) uses the Message Batches API (half price)
|
|
68
80
|
```
|
|
69
81
|
|
|
82
|
+
Notes and glossary from Translation settings reach AI engines (Claude); Microsoft ignores them.
|
|
83
|
+
Engines we tried and did not build in: MyMemory (no key, but about 50,000 characters a day),
|
|
84
|
+
Firefox's Bergamot models and MADLAD-400 (free and local, but noticeably weaker, and MADLAD
|
|
85
|
+
repeats sentences), LibreTranslate's public server (now needs a key).
|
|
86
|
+
|
|
70
87
|
Show visitors what they are reading: `languageStatus(payload, collection, doc, locale)` returns
|
|
71
88
|
`source`, `machine`, `reviewed`, `stale` or `pending`.
|
|
72
89
|
|
|
@@ -88,6 +105,10 @@ Show visitors what they are reading: `languageStatus(payload, collection, doc, l
|
|
|
88
105
|
| `statusField` | `translations` | Per-document "Reviewed by a human" languages |
|
|
89
106
|
| `writeContext` | | Context for writes (e.g. `{ disableRevalidate: true }` in scripts) |
|
|
90
107
|
| `onPublish` | `true` | Translate after publishing |
|
|
108
|
+
| `onStop` | | Called once when translation stops (`{ payload, stop: { reason, message, at } }`), e.g. to email admins |
|
|
109
|
+
| `canManage` | signed-in users | Who sees the Translations view and can Translate now |
|
|
110
|
+
| `canConfigure` | `canManage` | Who may change Translation settings |
|
|
111
|
+
| `settings` | `true` | Add the Translation settings global (`settingsSlug` `polyglot-settings`, `settingsGroup` `Settings`) |
|
|
91
112
|
|
|
92
113
|
## License
|
|
93
114
|
|
package/dist/claude.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
import type
|
|
2
|
+
import { type Item, type StopReason, type Translator } from './types.js';
|
|
3
3
|
/** Keeps translations whose key was asked for and whose placeholders match the source. */
|
|
4
4
|
export declare function acceptTranslations(source: Item[], raw: string): Map<string, string>;
|
|
5
|
+
/** Errors no retry fixes: no credits, key refused, spend limit reached. */
|
|
6
|
+
export declare function stopReasonOf(status: number | undefined, type: string | undefined, message: string): StopReason | undefined;
|
|
5
7
|
export type ClaudeTranslatorOptions = {
|
|
6
8
|
/** An Anthropic client; default `new Anthropic()` (reads ANTHROPIC_API_KEY). */
|
|
7
9
|
client?: Anthropic;
|
|
8
|
-
/** Default `claude-
|
|
10
|
+
/** Default `claude-haiku-5-5`: fast, very low cost, and close to Opus for website translation. */
|
|
9
11
|
model?: string;
|
|
12
|
+
/** Retry refusals on a fallback model (server-side fallback). Default on, except for Haiku models (not supported). */
|
|
13
|
+
fallbacks?: boolean;
|
|
10
14
|
/** What your site is about, tone, terms not to translate… */
|
|
11
15
|
instructions?: string;
|
|
12
16
|
/** Name a locale code for the model, e.g. `de` → `German (Deutsch)`. */
|
|
@@ -14,5 +18,5 @@ export type ClaudeTranslatorOptions = {
|
|
|
14
18
|
/** Thinking effort. Default `low` (translation rarely needs more). */
|
|
15
19
|
effort?: 'low' | 'medium' | 'high';
|
|
16
20
|
};
|
|
17
|
-
/** Translation with Claude: structured output, placeholder checks, server-side fallback on refusals, and the Batch API for sweeps. */
|
|
21
|
+
/** Translation with Claude (Haiku by default): structured output, placeholder checks, editors' notes and glossary, server-side fallback on refusals where supported, and the Batch API for sweeps. */
|
|
18
22
|
export declare function claudeTranslator(options?: ClaudeTranslatorOptions): Translator;
|
package/dist/claude.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { QuotaExceededError } from './types.js';
|
|
2
3
|
/** Response shape: the same keys, translated (structured output keeps it parseable). */
|
|
3
4
|
const SCHEMA = {
|
|
4
5
|
type: 'object',
|
|
@@ -36,13 +37,29 @@ export function acceptTranslations(source, raw) {
|
|
|
36
37
|
}
|
|
37
38
|
return accepted;
|
|
38
39
|
}
|
|
40
|
+
/** Errors no retry fixes: no credits, key refused, spend limit reached. */
|
|
41
|
+
export function stopReasonOf(status, type, message) {
|
|
42
|
+
if (status === 402 || type === 'billing_error' || /credit balance|billing|spend limit|usage limit/i.test(message))
|
|
43
|
+
return 'credits';
|
|
44
|
+
if (status === 401 || status === 403 || type === 'authentication_error' || type === 'permission_error')
|
|
45
|
+
return 'auth';
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const stopping = (error) => {
|
|
49
|
+
if (!(error instanceof Anthropic.APIError))
|
|
50
|
+
return error;
|
|
51
|
+
const type = error.error?.error?.type;
|
|
52
|
+
const reason = stopReasonOf(error.status, type, error.message);
|
|
53
|
+
return reason ? new QuotaExceededError(`Claude: ${error.message}`, reason) : error;
|
|
54
|
+
};
|
|
39
55
|
const textOf = (m) => m.content.flatMap((b) => (b.type === 'text' ? [b.text] : [])).join('');
|
|
40
|
-
/** Translation with Claude: structured output, placeholder checks, server-side fallback on refusals, and the Batch API for sweeps. */
|
|
56
|
+
/** Translation with Claude (Haiku by default): structured output, placeholder checks, editors' notes and glossary, server-side fallback on refusals where supported, and the Batch API for sweeps. */
|
|
41
57
|
export function claudeTranslator(options = {}) {
|
|
42
58
|
const client = () => options.client ?? new Anthropic();
|
|
43
|
-
const model = options.model ?? 'claude-
|
|
59
|
+
const model = options.model ?? 'claude-haiku-5-5';
|
|
60
|
+
const fallbacks = options.fallbacks ?? !model.includes('haiku');
|
|
44
61
|
const system = [options.instructions?.trim(), RULES].filter(Boolean).join('\n\n');
|
|
45
|
-
const params = ({ locale, items, context, keep, sourceLocale }) => ({
|
|
62
|
+
const params = ({ locale, items, context, keep, sourceLocale, instructions, glossary }) => ({
|
|
46
63
|
model,
|
|
47
64
|
max_tokens: 32000,
|
|
48
65
|
output_config: { effort: options.effort ?? 'low', format: { type: 'json_schema', schema: SCHEMA } },
|
|
@@ -50,7 +67,7 @@ export function claudeTranslator(options = {}) {
|
|
|
50
67
|
messages: [
|
|
51
68
|
{
|
|
52
69
|
role: 'user',
|
|
53
|
-
content: `Source language: ${options.languageName?.(sourceLocale) ?? sourceLocale}. Target language: ${options.languageName?.(locale) ?? locale}.${context ? `\nContext: ${context}` : ''}${keep.length ? `\nNever translate these terms: ${keep.join(', ')}` : ''}\n\nItems (JSON):\n${JSON.stringify({ items })}`,
|
|
70
|
+
content: `Source language: ${options.languageName?.(sourceLocale) ?? sourceLocale}. Target language: ${options.languageName?.(locale) ?? locale}.${context ? `\nContext: ${context}` : ''}${keep.length ? `\nNever translate these terms: ${keep.join(', ')}` : ''}${instructions ? `\nNotes from the site's editors: ${instructions}` : ''}${glossary?.length ? `\nUse these translations: ${glossary.map((g) => `${g.term} → ${g.translation}`).join('; ')}` : ''}\n\nItems (JSON):\n${JSON.stringify({ items })}`,
|
|
54
71
|
},
|
|
55
72
|
],
|
|
56
73
|
});
|
|
@@ -58,9 +75,17 @@ export function claudeTranslator(options = {}) {
|
|
|
58
75
|
async translate(args) {
|
|
59
76
|
if (!args.items.length)
|
|
60
77
|
return new Map();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
let m;
|
|
79
|
+
try {
|
|
80
|
+
m = (fallbacks
|
|
81
|
+
? await client()
|
|
82
|
+
.beta.messages.stream({ ...params(args), betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' })
|
|
83
|
+
.finalMessage()
|
|
84
|
+
: await client().messages.stream(params(args)).finalMessage());
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw stopping(error);
|
|
88
|
+
}
|
|
64
89
|
if (m.stop_reason !== 'end_turn')
|
|
65
90
|
return new Map();
|
|
66
91
|
return acceptTranslations(args.items, textOf(m));
|
|
@@ -69,8 +94,10 @@ export function claudeTranslator(options = {}) {
|
|
|
69
94
|
if (!requests.length)
|
|
70
95
|
return [];
|
|
71
96
|
const c = client();
|
|
72
|
-
const created = await c.messages.batches
|
|
73
|
-
requests: requests.map((r, i) => ({ custom_id: `r${i}`, params: params(r) }))
|
|
97
|
+
const created = await c.messages.batches
|
|
98
|
+
.create({ requests: requests.map((r, i) => ({ custom_id: `r${i}`, params: params(r) })) })
|
|
99
|
+
.catch((error) => {
|
|
100
|
+
throw stopping(error);
|
|
74
101
|
});
|
|
75
102
|
for (;;) {
|
|
76
103
|
const b = await c.messages.batches.retrieve(created.id);
|
|
@@ -79,11 +106,21 @@ export function claudeTranslator(options = {}) {
|
|
|
79
106
|
await new Promise((res) => setTimeout(res, 30_000));
|
|
80
107
|
}
|
|
81
108
|
const out = requests.map(() => new Map());
|
|
109
|
+
let stop;
|
|
82
110
|
for await (const result of await c.messages.batches.results(created.id)) {
|
|
83
111
|
const i = Number(result.custom_id.slice(1));
|
|
84
112
|
if (result.result.type === 'succeeded' && result.result.message.stop_reason === 'end_turn')
|
|
85
113
|
out[i] = acceptTranslations(requests[i].items, textOf(result.result.message));
|
|
114
|
+
if (result.result.type === 'errored') {
|
|
115
|
+
const e = result.result.error.error;
|
|
116
|
+
const reason = stopReasonOf(undefined, e?.type, e?.message ?? '');
|
|
117
|
+
if (reason)
|
|
118
|
+
stop ??= new QuotaExceededError(`Claude: ${e?.message ?? e?.type}`, reason);
|
|
119
|
+
}
|
|
86
120
|
}
|
|
121
|
+
// Nothing came back because of credits or the key: stop and tell admins (what did succeed is kept by the caller next run).
|
|
122
|
+
if (stop && out.every((m) => m.size === 0))
|
|
123
|
+
throw stop;
|
|
87
124
|
return out;
|
|
88
125
|
},
|
|
89
126
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useCallback, useEffect, useState } from 'react';
|
|
4
4
|
/** Coverage per collection and language, filtered by collection, with Translate now. */
|
|
5
5
|
export function PolyglotDashboard({ apiRoute, collections, labels }) {
|
|
@@ -31,9 +31,11 @@ export function PolyglotDashboard({ apiRoute, collections, labels }) {
|
|
|
31
31
|
const scope = filter ? [filter] : undefined;
|
|
32
32
|
const pct = (c, docs) => (docs ? Math.round(((c.translated + c.reviewed) / docs) * 100) : 100);
|
|
33
33
|
const cell = { padding: '4px 8px', borderBottom: '1px solid var(--theme-elevation-100)', textAlign: 'center' };
|
|
34
|
-
return (_jsxs("div", { style: { display: 'grid', gap: 'var(--base)' }, children: [_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }, children: [_jsxs("label", { children: ["Collection", ' ', _jsxs("select", { value: filter, onChange: (e) => setFilter(e.target.value), style: { padding: '4px 8px' }, children: [_jsx("option", { value: "", children: "All collections" }), collections.map((c) => (_jsx("option", { value: c, children: labels[c] ?? c }, c)))] })] }), _jsx("button", { type: "button", className: "btn btn--style-primary btn--size-small", onClick: () => run({ collections: scope }, filter ? labels[filter] ?? filter : 'everything'), children: "Translate now" }), _jsx("button", { type: "button", className: "btn btn--style-secondary btn--size-small", onClick: () => void load(), children: "Refresh" })] }), message && _jsx("p", { children: message }), !data ? (_jsx("p", { children: "Loading coverage\u2026" })) : (_jsx("div", { style: { overflowX: 'auto' }, children: _jsxs("table", { style: { borderCollapse: 'collapse', fontSize: '0.9em' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { ...cell, textAlign: 'start' }, children: "Collection" }), data.locales.map((l) => (_jsx("th", { style: cell, children: _jsx("button", { type: "button", title: `Translate ${l} now`, onClick: () => run({ collections: scope, locales: [l] }, l), style: { background: 'none', border: 0, cursor: 'pointer', fontWeight: 600 }, children: l }) }, l)))] }) }), _jsx("tbody", { children: data.rows.map((r) => (_jsxs("tr", { children: [_jsxs("td", { style: { ...cell, textAlign: 'start' }, children: [_jsx("button", { type: "button", title: "Translate this collection now", onClick: () => run({ collections: [r.collection] }, labels[r.collection] ?? r.collection), style: { background: 'none', border: 0, cursor: 'pointer', textAlign: 'start' }, children: labels[r.collection] ?? r.collection }), ' ', _jsxs("span", { style: { color: 'var(--theme-elevation-500)' }, children: ["(", r.documents, ")"] })] }), data.locales.map((l) => {
|
|
34
|
+
return (_jsxs("div", { style: { display: 'grid', gap: 'var(--base)' }, children: [_jsxs("div", { style: { display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center' }, children: [_jsxs("label", { children: ["Collection", ' ', _jsxs("select", { value: filter, onChange: (e) => setFilter(e.target.value), style: { padding: '4px 8px' }, children: [_jsx("option", { value: "", children: "All collections" }), collections.map((c) => (_jsx("option", { value: c, children: labels[c] ?? c }, c)))] })] }), _jsx("button", { type: "button", className: "btn btn--style-primary btn--size-small", onClick: () => run({ collections: scope }, filter ? labels[filter] ?? filter : 'everything'), children: "Translate now" }), _jsx("button", { type: "button", className: "btn btn--style-secondary btn--size-small", onClick: () => void load(), children: "Refresh" })] }), data?.settings?.stop && (_jsxs("div", { role: "alert", style: { border: '1px solid var(--theme-error-500)', background: 'var(--theme-error-50)', color: 'var(--theme-error-800)', borderRadius: 4, padding: 'calc(var(--base) / 2) var(--base)' }, children: [_jsxs("strong", { children: ["Translation stopped ", new Date(data.settings.stop.at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }), "."] }), " ", data.settings.stop.help, _jsx("div", { style: { fontSize: '0.85em', opacity: 0.8, marginTop: 4 }, children: data.settings.stop.message })] })), data?.settings && (_jsxs("p", { style: { margin: 0 }, children: [_jsx("strong", { children: "Automatic translation:" }), ' ', !data.settings.enabled ? 'off here (no engine, or turned off for this environment)' : data.settings.stop ? 'stopped (see above)' : data.settings.paused ? 'paused' : 'on', ' · ', data.settings.charactersThisMonth.toLocaleString(), " characters sent this month", data.settings.skipped.length ? ` · not machine-translated: ${data.settings.skipped.join(', ')}` : '', data.settings.url && (_jsxs(_Fragment, { children: [' · ', _jsx("a", { href: data.settings.url, children: "Translation settings" })] }))] })), message && _jsx("p", { children: message }), !data ? (_jsx("p", { children: "Loading coverage\u2026" })) : (_jsx("div", { style: { overflowX: 'auto' }, children: _jsxs("table", { style: { borderCollapse: 'collapse', fontSize: '0.9em' }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { ...cell, textAlign: 'start' }, children: "Collection" }), data.locales.map((l) => (_jsx("th", { style: cell, children: _jsx("button", { type: "button", title: `Translate ${l} now`, onClick: () => run({ collections: scope, locales: [l] }, l), style: { background: 'none', border: 0, cursor: 'pointer', fontWeight: 600 }, children: l }) }, l)))] }) }), _jsx("tbody", { children: data.rows.map((r) => (_jsxs("tr", { children: [_jsxs("td", { style: { ...cell, textAlign: 'start' }, children: [_jsx("button", { type: "button", title: "Translate this collection now", onClick: () => run({ collections: [r.collection] }, labels[r.collection] ?? r.collection), style: { background: 'none', border: 0, cursor: 'pointer', textAlign: 'start' }, children: labels[r.collection] ?? r.collection }), ' ', _jsxs("span", { style: { color: 'var(--theme-elevation-500)' }, children: ["(", r.documents, ")"] })] }), data.locales.map((l) => {
|
|
35
35
|
const c = r.locales[l];
|
|
36
36
|
const p = pct(c, r.documents);
|
|
37
|
+
if (data.settings?.skipped.includes(l))
|
|
38
|
+
return (_jsx("td", { style: { ...cell, color: 'var(--theme-elevation-400)' }, title: "Not machine-translated (Translation settings)", children: "\u2013" }, l));
|
|
37
39
|
return (_jsxs("td", { style: { ...cell, color: p === 100 ? 'var(--theme-success-600)' : undefined }, title: `${c.translated} machine, ${c.reviewed} reviewed, ${c.stale} reviewed but source changed, ${c.pending} pending`, children: [p, "%", c.stale ? ' ⚠' : ''] }, l));
|
|
38
|
-
})] }, r.collection))) })] }) })), _jsx("p", { style: { color: 'var(--theme-elevation-500)', fontSize: '0.85em' }, children: "Percent of documents translated (machine or reviewed). \u26A0 marks reviewed translations whose source has changed. Click a language or collection to translate just that." })] }));
|
|
40
|
+
})] }, r.collection))) })] }) })), _jsx("p", { style: { color: 'var(--theme-elevation-500)', fontSize: '0.85em' }, children: "Percent of documents translated (machine or reviewed). \u26A0 marks reviewed translations whose source has changed. Click a language or collection to translate just that. \u2013 marks languages not machine-translated." })] }));
|
|
39
41
|
}
|
package/dist/endpoints.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { APIError, addDataAndFileToRequest } from 'payload';
|
|
2
|
+
import { charactersThisMonth, loadSettings, STOP_HELP } from './settings.js';
|
|
2
3
|
import { coverage, polyglotOf, syncAll } from './translate.js';
|
|
3
4
|
const allowed = async (req) => {
|
|
4
5
|
const o = polyglotOf(req.payload);
|
|
@@ -16,7 +17,20 @@ export const coverageEndpoint = {
|
|
|
16
17
|
handler: async (req) => {
|
|
17
18
|
const o = await allowed(req);
|
|
18
19
|
const rows = await coverage(req.payload, { collections: list(req.searchParams?.get('collections')) });
|
|
19
|
-
|
|
20
|
+
const s = await loadSettings(req.payload, o);
|
|
21
|
+
const enabled = o.enabled ? await o.enabled({ payload: req.payload, req }) : true;
|
|
22
|
+
return Response.json({
|
|
23
|
+
locales: o.locales,
|
|
24
|
+
rows,
|
|
25
|
+
settings: {
|
|
26
|
+
stop: s.stop ? { ...s.stop, help: STOP_HELP[s.stop.reason] } : null,
|
|
27
|
+
enabled,
|
|
28
|
+
paused: s.paused,
|
|
29
|
+
skipped: [...s.skipLocales],
|
|
30
|
+
charactersThisMonth: charactersThisMonth(s),
|
|
31
|
+
url: o.settings ? `${req.payload.config.routes.admin}/globals/${o.settingsSlug}` : null,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
20
34
|
},
|
|
21
35
|
};
|
|
22
36
|
/** POST /api/polyglot/sync { collections?, locales? }: translate now (after the response in Next.js). */
|
|
@@ -29,7 +43,7 @@ export const syncEndpoint = {
|
|
|
29
43
|
throw new APIError('Translation is turned off here.', 400, undefined, true);
|
|
30
44
|
await addDataAndFileToRequest(req);
|
|
31
45
|
const filters = { collections: list(req.data?.collections), locales: list(req.data?.locales) };
|
|
32
|
-
const run = () => syncAll(req.payload, filters).then((r) => req.payload.logger.info({ ...r, ...filters }, 'Polyglot: translate now'));
|
|
46
|
+
const run = () => syncAll(req.payload, { ...filters, force: true }).then((r) => req.payload.logger.info({ ...r, ...filters }, 'Polyglot: translate now'));
|
|
33
47
|
try {
|
|
34
48
|
const { after } = await import('next/server.js');
|
|
35
49
|
after(run);
|
package/dist/index.d.ts
CHANGED
|
@@ -4,3 +4,4 @@ export { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
|
4
4
|
export { FILLER, SKIP_KEYS, textsOf, visitPaths, visitText, fingerprint, memoryKey } from './text.js';
|
|
5
5
|
export { microsoftTranslator, type MicrosoftTranslatorOptions } from './microsoft.js';
|
|
6
6
|
export { QuotaExceededError, type DocumentResult, type Item, type PolyglotOptions, type ResolvedPolyglot, type TranslateArgs, type Translator } from './types.js';
|
|
7
|
+
export { loadSettings, type GlossaryEntry, type Settings } from './settings.js';
|
package/dist/index.js
CHANGED
|
@@ -4,3 +4,4 @@ export { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
|
4
4
|
export { FILLER, SKIP_KEYS, textsOf, visitPaths, visitText, fingerprint, memoryKey } from './text.js';
|
|
5
5
|
export { microsoftTranslator } from './microsoft.js';
|
|
6
6
|
export { QuotaExceededError } from './types.js';
|
|
7
|
+
export { loadSettings } from './settings.js';
|
package/dist/microsoft.js
CHANGED
|
@@ -48,8 +48,10 @@ export function microsoftTranslator(options) {
|
|
|
48
48
|
await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 2 ** attempt * 5) * 1000));
|
|
49
49
|
continue;
|
|
50
50
|
}
|
|
51
|
+
if (res.status === 401)
|
|
52
|
+
throw new QuotaExceededError(`Microsoft Translator refused the key: ${await res.text()}`, 'auth');
|
|
51
53
|
if (res.status === 403)
|
|
52
|
-
throw new QuotaExceededError(`Microsoft Translator: ${await res.text()}
|
|
54
|
+
throw new QuotaExceededError(`Microsoft Translator quota used up: ${await res.text()}`, 'quota');
|
|
53
55
|
if (!res.ok)
|
|
54
56
|
throw new Error(`Microsoft Translator ${res.status}: ${await res.text()}`);
|
|
55
57
|
const body = (await res.json());
|
package/dist/plugin.d.ts
CHANGED
|
@@ -15,5 +15,10 @@ export declare function localizeFields(fields: Field[], targets: Set<string>, pr
|
|
|
15
15
|
* - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
|
|
16
16
|
* human" are never overwritten (they are flagged when the source changes).
|
|
17
17
|
* - Identity fields (slug, path…) never change when another language is saved.
|
|
18
|
+
* - Translation settings (a global) let admins pause translation, skip languages,
|
|
19
|
+
* add never-translate terms, notes and a glossary, and see characters sent.
|
|
20
|
+
* - When the engine stops (out of credits, quota used up, key refused) admins
|
|
21
|
+
* see why on the dashboard, the menu and the Translations view; `onStop`
|
|
22
|
+
* runs once (e.g. email); everything clears on the next success.
|
|
18
23
|
*/
|
|
19
24
|
export declare const polyglotPlugin: (options: PolyglotOptions) => Plugin;
|
package/dist/plugin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FILLER, SKIP_KEYS } from './text.js';
|
|
2
2
|
import { coverageEndpoint, syncEndpoint } from './endpoints.js';
|
|
3
|
+
import { loadSettings, settingsGlobal } from './settings.js';
|
|
3
4
|
import { included, translateDocument } from './translate.js';
|
|
4
5
|
/** Marks the listed fields localized (paths ignore tabs/rows; `a.b` reaches into array/group `a`). */
|
|
5
6
|
export function localizeFields(fields, targets, prefix = '') {
|
|
@@ -67,6 +68,11 @@ const memoryCollection = (slug) => ({
|
|
|
67
68
|
* - Placeholder copy (lorem ipsum) is never sent; languages marked "Reviewed by a
|
|
68
69
|
* human" are never overwritten (they are flagged when the source changes).
|
|
69
70
|
* - Identity fields (slug, path…) never change when another language is saved.
|
|
71
|
+
* - Translation settings (a global) let admins pause translation, skip languages,
|
|
72
|
+
* add never-translate terms, notes and a glossary, and see characters sent.
|
|
73
|
+
* - When the engine stops (out of credits, quota used up, key refused) admins
|
|
74
|
+
* see why on the dashboard, the menu and the Translations view; `onStop`
|
|
75
|
+
* runs once (e.g. email); everything clears on the next success.
|
|
70
76
|
*/
|
|
71
77
|
export const polyglotPlugin = (options) => (config) => {
|
|
72
78
|
const loc = config.localization;
|
|
@@ -91,6 +97,11 @@ export const polyglotPlugin = (options) => (config) => {
|
|
|
91
97
|
describe: options.describe,
|
|
92
98
|
onPublish: options.onPublish ?? true,
|
|
93
99
|
canManage: options.canManage,
|
|
100
|
+
canConfigure: options.canConfigure,
|
|
101
|
+
onStop: options.onStop,
|
|
102
|
+
settings: options.settings ?? true,
|
|
103
|
+
settingsSlug: options.settingsSlug ?? 'polyglot-settings',
|
|
104
|
+
settingsGroup: options.settingsGroup ?? 'Settings',
|
|
94
105
|
adminView: options.adminView ?? true,
|
|
95
106
|
defaultLocale: loc.defaultLocale,
|
|
96
107
|
locales: codes.filter((c) => c !== loc.defaultLocale),
|
|
@@ -111,6 +122,8 @@ export const polyglotPlugin = (options) => (config) => {
|
|
|
111
122
|
return doc;
|
|
112
123
|
if (resolved.enabled && !(await resolved.enabled({ payload: req.payload, req })))
|
|
113
124
|
return doc;
|
|
125
|
+
if ((await loadSettings(req.payload, resolved)).paused)
|
|
126
|
+
return doc;
|
|
114
127
|
const run = () => translateDocument(req.payload, collection.slug, String(doc.id), { inRequest: true }).catch((err) => req.payload.logger.error({ err }, 'Polyglot: publish translation failed'));
|
|
115
128
|
try {
|
|
116
129
|
const { after } = await import('next/server.js');
|
|
@@ -141,12 +154,14 @@ export const polyglotPlugin = (options) => (config) => {
|
|
|
141
154
|
...config,
|
|
142
155
|
custom: { ...config.custom, polyglot: resolved },
|
|
143
156
|
endpoints: [...(config.endpoints ?? []), coverageEndpoint, syncEndpoint],
|
|
157
|
+
globals: resolved.settings && !(config.globals ?? []).some((g) => g.slug === resolved.settingsSlug) ? [...(config.globals ?? []), settingsGlobal(resolved, labels)] : config.globals,
|
|
144
158
|
admin: resolved.adminView
|
|
145
159
|
? {
|
|
146
160
|
...config.admin,
|
|
147
161
|
components: {
|
|
148
162
|
...components,
|
|
149
163
|
afterNavLinks: [...(components.afterNavLinks ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotNavLink'],
|
|
164
|
+
beforeDashboard: [...(components.beforeDashboard ?? []), '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotStopBanner'],
|
|
150
165
|
views: { ...components.views, polyglot: { Component: '@captiveorigins/payload-plugin-polyglot/rsc#PolyglotView', path: '/polyglot' } },
|
|
151
166
|
},
|
|
152
167
|
}
|
|
@@ -2,8 +2,13 @@ import type { AdminViewServerProps, Payload } from 'payload';
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
/** /admin/polyglot: coverage per collection and language, Translate now. */
|
|
4
4
|
export declare function PolyglotView({ initPageResult, params, searchParams }: AdminViewServerProps): Promise<React.JSX.Element>;
|
|
5
|
-
/** Nav link, for people who may manage translations. */
|
|
5
|
+
/** Nav link, for people who may manage translations (⚠ while translation is stopped). */
|
|
6
6
|
export declare function PolyglotNavLink({ payload, user }: {
|
|
7
7
|
payload: Payload;
|
|
8
8
|
user?: unknown;
|
|
9
9
|
}): Promise<React.JSX.Element | null>;
|
|
10
|
+
/** Dashboard banner while translation is stopped (credits, quota, key), for people who may manage translations. */
|
|
11
|
+
export declare function PolyglotStopBanner({ payload, user }: {
|
|
12
|
+
payload: Payload;
|
|
13
|
+
user?: unknown;
|
|
14
|
+
}): Promise<React.JSX.Element | null>;
|
package/dist/rsc/PolyglotView.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { DefaultTemplate } from '@payloadcms/next/templates';
|
|
3
3
|
import { PolyglotDashboard } from '../client/PolyglotDashboard.js';
|
|
4
|
+
import { loadSettings, STOP_HELP } from '../settings.js';
|
|
4
5
|
import { polyglotOf } from '../translate.js';
|
|
5
6
|
const labelOf = (payload, slug) => {
|
|
6
7
|
const l = payload.collections[slug]?.config.labels?.plural;
|
|
@@ -14,10 +15,26 @@ export async function PolyglotView({ initPageResult, params, searchParams }) {
|
|
|
14
15
|
const collections = Object.keys(o.collections);
|
|
15
16
|
return (_jsx(DefaultTemplate, { i18n: req.i18n, locale: locale, params: params, payload: req.payload, permissions: permissions, req: req, searchParams: searchParams, user: req.user ?? undefined, visibleEntities: visibleEntities, children: _jsxs("div", { className: "gutter--left gutter--right", style: { paddingBlock: 'calc(var(--base) * 2)' }, children: [_jsx("h1", { children: "Translations" }), allowed ? (_jsx(PolyglotDashboard, { apiRoute: req.payload.config.routes.api, collections: collections, labels: Object.fromEntries(collections.map((c) => [c, labelOf(req.payload, c)])) })) : (_jsx("p", { children: "You don\u2019t have access to translations." }))] }) }));
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
-
export async function PolyglotNavLink({ payload, user }) {
|
|
18
|
+
const mayManage = async (payload, user) => {
|
|
19
19
|
const o = polyglotOf(payload);
|
|
20
|
-
|
|
20
|
+
return Boolean(user) && (await (o.canManage ?? (() => true))({ req: { user, payload } }));
|
|
21
|
+
};
|
|
22
|
+
/** Nav link, for people who may manage translations (⚠ while translation is stopped). */
|
|
23
|
+
export async function PolyglotNavLink({ payload, user }) {
|
|
24
|
+
if (!(await mayManage(payload, user)))
|
|
21
25
|
return null;
|
|
22
|
-
|
|
26
|
+
const { stop } = await loadSettings(payload, polyglotOf(payload));
|
|
27
|
+
return (_jsx("a", { className: "nav__link", href: `${payload.config.routes.admin}/polyglot`, style: { display: 'block', marginTop: 'calc(var(--base) / 2)' }, title: stop ? 'Translation has stopped' : undefined, children: _jsxs("span", { className: "nav__link-label", children: ["Translations", stop ? ' ⚠' : ''] }) }));
|
|
28
|
+
}
|
|
29
|
+
/** Dashboard banner while translation is stopped (credits, quota, key), for people who may manage translations. */
|
|
30
|
+
export async function PolyglotStopBanner({ payload, user }) {
|
|
31
|
+
if (!(await mayManage(payload, user)))
|
|
32
|
+
return null;
|
|
33
|
+
const { stop } = await loadSettings(payload, polyglotOf(payload));
|
|
34
|
+
if (!stop)
|
|
35
|
+
return null;
|
|
36
|
+
return _jsx(StopNotice, { stop: stop, href: `${payload.config.routes.admin}/polyglot` });
|
|
37
|
+
}
|
|
38
|
+
function StopNotice({ stop, href }) {
|
|
39
|
+
return (_jsxs("div", { role: "alert", style: { border: '1px solid var(--theme-error-500)', background: 'var(--theme-error-50)', color: 'var(--theme-error-800)', borderRadius: 4, padding: 'calc(var(--base) / 2) var(--base)', marginBottom: 'var(--base)' }, children: [_jsxs("strong", { children: ["Translation stopped ", new Date(stop.at).toLocaleString('en', { dateStyle: 'medium', timeStyle: 'short' }), "."] }), " ", STOP_HELP[stop.reason], ' ', href && _jsx("a", { href: href, children: "Open Translations" }), _jsx("div", { style: { fontSize: '0.85em', opacity: 0.8, marginTop: 4 }, children: stop.message })] }));
|
|
23
40
|
}
|
package/dist/rsc/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { PolyglotNavLink, PolyglotView } from './PolyglotView.js';
|
|
1
|
+
export { PolyglotNavLink, PolyglotStopBanner, PolyglotView } from './PolyglotView.js';
|
package/dist/rsc/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { PolyglotNavLink, PolyglotView } from './PolyglotView.js';
|
|
1
|
+
export { PolyglotNavLink, PolyglotStopBanner, PolyglotView } from './PolyglotView.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GlobalConfig, Payload } from 'payload';
|
|
2
|
+
import type { EngineStop, QuotaExceededError, ResolvedPolyglot } from './types.js';
|
|
3
|
+
export type GlossaryEntry = {
|
|
4
|
+
locale: string;
|
|
5
|
+
term: string;
|
|
6
|
+
translation: string;
|
|
7
|
+
};
|
|
8
|
+
/** What admins can change without a deploy (the Translation settings global). */
|
|
9
|
+
export type Settings = {
|
|
10
|
+
paused: boolean;
|
|
11
|
+
skipLocales: Set<string>;
|
|
12
|
+
keep: string[];
|
|
13
|
+
instructions?: string;
|
|
14
|
+
glossary: GlossaryEntry[];
|
|
15
|
+
maxCharactersPerRun?: number;
|
|
16
|
+
usage: {
|
|
17
|
+
month: string;
|
|
18
|
+
characters: number;
|
|
19
|
+
}[];
|
|
20
|
+
/** Set while translation is stopped (credits, quota, key); cleared by the next success. */
|
|
21
|
+
stop?: EngineStop;
|
|
22
|
+
};
|
|
23
|
+
export declare function settingsGlobal(o: ResolvedPolyglot, labels: {
|
|
24
|
+
label: string;
|
|
25
|
+
value: string;
|
|
26
|
+
}[]): GlobalConfig;
|
|
27
|
+
/** Current settings (defaults when the settings global is off). */
|
|
28
|
+
export declare function loadSettings(payload: Payload, o: ResolvedPolyglot): Promise<Settings>;
|
|
29
|
+
/** Adds characters sent this month (keeps a year of months). */
|
|
30
|
+
export declare function recordUsage(payload: Payload, o: ResolvedPolyglot, characters: number): Promise<void>;
|
|
31
|
+
export declare const charactersThisMonth: (s: Settings) => number;
|
|
32
|
+
/** What admins should do about a stop. */
|
|
33
|
+
export declare const STOP_HELP: Record<EngineStop['reason'], string>;
|
|
34
|
+
/** Records that translation stopped; calls `onStop` once (not again until it has recovered). */
|
|
35
|
+
export declare function recordStop(payload: Payload, o: ResolvedPolyglot, error: QuotaExceededError): Promise<void>;
|
|
36
|
+
/** Clears a recorded stop after a successful translation. */
|
|
37
|
+
export declare function clearStop(payload: Payload, o: ResolvedPolyglot): Promise<void>;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const DEFAULTS = { paused: false, skipLocales: new Set(), keep: [], glossary: [], usage: [] };
|
|
2
|
+
const month = () => new Date().toISOString().slice(0, 7);
|
|
3
|
+
const hasGlobal = (payload, slug) => payload.config.globals?.some((g) => g.slug === slug);
|
|
4
|
+
export function settingsGlobal(o, labels) {
|
|
5
|
+
const access = async ({ req }) => Boolean(req.user) && (await (o.canConfigure ?? o.canManage ?? (() => true))({ req }));
|
|
6
|
+
return {
|
|
7
|
+
slug: o.settingsSlug,
|
|
8
|
+
label: 'Translation settings',
|
|
9
|
+
admin: { group: o.settingsGroup, description: 'How automatic translation behaves. Changes apply to the next translation; nothing needs a deploy.' },
|
|
10
|
+
access: { read: access, update: access },
|
|
11
|
+
fields: [
|
|
12
|
+
{
|
|
13
|
+
name: 'paused',
|
|
14
|
+
label: 'Pause automatic translation',
|
|
15
|
+
type: 'checkbox',
|
|
16
|
+
defaultValue: false,
|
|
17
|
+
admin: { description: 'Publishing and the scheduled sweep stop translating. “Translate now” in Translations still works.' },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'skipLocales',
|
|
21
|
+
label: 'Languages not machine-translated',
|
|
22
|
+
type: 'select',
|
|
23
|
+
hasMany: true,
|
|
24
|
+
options: labels,
|
|
25
|
+
admin: { description: 'Visitors see the default language there until someone translates by hand. Existing translations stay.' },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'keep',
|
|
29
|
+
label: 'Never translate',
|
|
30
|
+
type: 'textarea',
|
|
31
|
+
admin: { description: 'One term per line (names, brands, product names). Added to the terms set in code.' },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'instructions',
|
|
35
|
+
label: 'Notes for the translator',
|
|
36
|
+
type: 'textarea',
|
|
37
|
+
admin: { description: 'Tone, audience, words to prefer, e.g. “Address readers formally in German.” Used by AI engines (Claude); ignored by Microsoft.' },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'glossary',
|
|
41
|
+
label: 'Preferred translations',
|
|
42
|
+
type: 'array',
|
|
43
|
+
admin: { description: 'How a term should be translated in one language. Used by AI engines (Claude); changing it does not re-translate existing text.' },
|
|
44
|
+
fields: [
|
|
45
|
+
{
|
|
46
|
+
type: 'row',
|
|
47
|
+
fields: [
|
|
48
|
+
{ name: 'locale', type: 'select', options: labels, required: true },
|
|
49
|
+
{ name: 'term', type: 'text', required: true },
|
|
50
|
+
{ name: 'translation', type: 'text', required: true },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'maxCharactersPerRun',
|
|
57
|
+
label: 'Characters per sweep (cap)',
|
|
58
|
+
type: 'number',
|
|
59
|
+
min: 0,
|
|
60
|
+
admin: { description: `Stops a sweep after sending this many characters; the next run continues. Empty: ${o.maxCharactersPerRun ? `${o.maxCharactersPerRun.toLocaleString('en')} (set in code)` : 'no cap'}.` },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'stop',
|
|
64
|
+
label: 'Stopped',
|
|
65
|
+
type: 'group',
|
|
66
|
+
admin: {
|
|
67
|
+
readOnly: true,
|
|
68
|
+
condition: (data) => Boolean(data?.stop?.reason),
|
|
69
|
+
description: 'Translation stopped and is waiting for someone to act. Cleared automatically by the next successful translation.',
|
|
70
|
+
},
|
|
71
|
+
fields: [
|
|
72
|
+
{
|
|
73
|
+
type: 'row',
|
|
74
|
+
fields: [
|
|
75
|
+
{ name: 'reason', type: 'select', options: [{ label: 'Out of credits', value: 'credits' }, { label: 'Free quota used up', value: 'quota' }, { label: 'Key refused', value: 'auth' }] },
|
|
76
|
+
{ name: 'at', type: 'date', admin: { date: { pickerAppearance: 'dayAndTime' } } },
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{ name: 'message', type: 'textarea' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'usage',
|
|
84
|
+
label: 'Characters sent, by month',
|
|
85
|
+
type: 'array',
|
|
86
|
+
admin: { readOnly: true, description: 'Recorded by Polyglot: characters sent to the translation engine (memory hits and skipped text are free).' },
|
|
87
|
+
fields: [
|
|
88
|
+
{
|
|
89
|
+
type: 'row',
|
|
90
|
+
fields: [
|
|
91
|
+
{ name: 'month', type: 'text' },
|
|
92
|
+
{ name: 'characters', type: 'number' },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Current settings (defaults when the settings global is off). */
|
|
101
|
+
export async function loadSettings(payload, o) {
|
|
102
|
+
if (!hasGlobal(payload, o.settingsSlug))
|
|
103
|
+
return { ...DEFAULTS, skipLocales: new Set() };
|
|
104
|
+
const g = (await payload.findGlobal({ slug: o.settingsSlug, depth: 0, overrideAccess: true }));
|
|
105
|
+
return {
|
|
106
|
+
paused: Boolean(g.paused),
|
|
107
|
+
skipLocales: new Set(g.skipLocales ?? []),
|
|
108
|
+
keep: String(g.keep ?? '')
|
|
109
|
+
.split('\n')
|
|
110
|
+
.map((t) => t.trim())
|
|
111
|
+
.filter(Boolean),
|
|
112
|
+
instructions: typeof g.instructions === 'string' && g.instructions.trim() ? g.instructions.trim() : undefined,
|
|
113
|
+
glossary: (g.glossary ?? []).filter((e) => e.locale && e.term && e.translation),
|
|
114
|
+
maxCharactersPerRun: typeof g.maxCharactersPerRun === 'number' ? g.maxCharactersPerRun : undefined,
|
|
115
|
+
usage: (g.usage ?? []).map(({ month, characters }) => ({ month, characters })),
|
|
116
|
+
stop: g.stop?.reason ? g.stop : undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Adds characters sent this month (keeps a year of months). */
|
|
120
|
+
export async function recordUsage(payload, o, characters) {
|
|
121
|
+
if (!characters || !hasGlobal(payload, o.settingsSlug))
|
|
122
|
+
return;
|
|
123
|
+
const { usage } = await loadSettings(payload, o);
|
|
124
|
+
const now = month();
|
|
125
|
+
const rows = usage.some((u) => u.month === now) ? usage.map((u) => (u.month === now ? { ...u, characters: u.characters + characters } : u)) : [...usage, { month: now, characters }];
|
|
126
|
+
await payload.updateGlobal({ slug: o.settingsSlug, data: { usage: rows.slice(-12) }, overrideAccess: true, context: { polyglot: true } });
|
|
127
|
+
}
|
|
128
|
+
export const charactersThisMonth = (s) => s.usage.find((u) => u.month === month())?.characters ?? 0;
|
|
129
|
+
/** What admins should do about a stop. */
|
|
130
|
+
export const STOP_HELP = {
|
|
131
|
+
credits: 'The translation engine’s account is out of credits (or hit its spending limit). Add credits, then click Translate now; nothing is lost meanwhile.',
|
|
132
|
+
quota: 'The engine’s free quota for this period is used up. Translation resumes on its own next period, or upgrade the plan.',
|
|
133
|
+
auth: 'The engine refused its key (missing, wrong or revoked). Update the key, then click Translate now.',
|
|
134
|
+
};
|
|
135
|
+
/** Records that translation stopped; calls `onStop` once (not again until it has recovered). */
|
|
136
|
+
export async function recordStop(payload, o, error) {
|
|
137
|
+
const current = (await loadSettings(payload, o)).stop;
|
|
138
|
+
const stop = { reason: error.reason, message: error.message.slice(0, 1000), at: new Date().toISOString() };
|
|
139
|
+
payload.logger.error({ reason: stop.reason, message: stop.message }, 'Polyglot: translation stopped');
|
|
140
|
+
if (current?.reason === stop.reason)
|
|
141
|
+
return;
|
|
142
|
+
if (hasGlobal(payload, o.settingsSlug))
|
|
143
|
+
await payload.updateGlobal({ slug: o.settingsSlug, data: { stop }, overrideAccess: true, context: { polyglot: true } });
|
|
144
|
+
await o.onStop?.({ payload, stop });
|
|
145
|
+
}
|
|
146
|
+
/** Clears a recorded stop after a successful translation. */
|
|
147
|
+
export async function clearStop(payload, o) {
|
|
148
|
+
if (!hasGlobal(payload, o.settingsSlug))
|
|
149
|
+
return;
|
|
150
|
+
await payload.updateGlobal({ slug: o.settingsSlug, data: { stop: { reason: null, message: null, at: null } }, overrideAccess: true, context: { polyglot: true } });
|
|
151
|
+
payload.logger.info('Polyglot: translation running again');
|
|
152
|
+
}
|
package/dist/text.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Default placeholder copy that is never translated (nor paid for). */
|
|
1
|
+
/** Default placeholder copy that is never translated (nor paid for): any text with three or more lorem ipsum words, wherever it starts. */
|
|
2
2
|
export declare const FILLER: RegExp;
|
|
3
3
|
/** Keys whose string values are never visible text. */
|
|
4
4
|
export declare const SKIP_KEYS: string[];
|
package/dist/text.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
const LOREM_WORDS = 'lorem|ipsum|dolor|amet|consectetur|adipiscing|eiusmod|tempor|incididunt|labore|dolore|aliqua|veniam|nostrud|exercitation|ullamco|laboris|aliquip|commodo|consequat|reprehenderit|voluptate|cillum|fugiat|pariatur|excepteur|occaecat|cupidatat|proident|culpa|officia|deserunt|mollit';
|
|
3
|
+
/** Default placeholder copy that is never translated (nor paid for): any text with three or more lorem ipsum words, wherever it starts. */
|
|
4
|
+
export const FILLER = new RegExp(`(?:\\b(?:${LOREM_WORDS})\\b[\\s\\S]*?){3}`, 'i');
|
|
4
5
|
/** Keys whose string values are never visible text. */
|
|
5
6
|
export const SKIP_KEYS = [
|
|
6
7
|
'id', 'url', 'href', 'slug', 'path', 'key', 'type', 'blockType', 'blockName', 'format', 'mode', 'direction', 'version',
|
package/dist/translate.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Payload } from 'payload';
|
|
2
|
-
import { type
|
|
2
|
+
import { type Settings } from './settings.js';
|
|
3
|
+
import { type DocumentResult, type ResolvedPolyglot, type StopReason } from './types.js';
|
|
3
4
|
export declare const polyglotOf: (payload: Payload) => ResolvedPolyglot;
|
|
4
5
|
/** Is this document one Polyglot translates? */
|
|
5
6
|
export declare function included(o: ResolvedPolyglot, collection: string, doc: Record<string, unknown>): boolean;
|
|
@@ -23,6 +24,7 @@ export declare function translateDocument(payload: Payload, collection: string,
|
|
|
23
24
|
memoryOnly?: boolean;
|
|
24
25
|
inRequest?: boolean;
|
|
25
26
|
budget?: Budget;
|
|
27
|
+
settings?: Settings;
|
|
26
28
|
}): Promise<DocumentResult>;
|
|
27
29
|
export type SyncReport = {
|
|
28
30
|
documents: number;
|
|
@@ -32,14 +34,18 @@ export type SyncReport = {
|
|
|
32
34
|
reviewedStale: number;
|
|
33
35
|
failed: number;
|
|
34
36
|
characters: number;
|
|
35
|
-
stopped: 'done' | 'budget' | 'quota';
|
|
37
|
+
stopped: 'done' | 'budget' | 'quota' | 'paused';
|
|
38
|
+
/** With `stopped: 'quota'`: credits, quota or auth (see Translation settings). */
|
|
39
|
+
stopReason?: StopReason;
|
|
36
40
|
};
|
|
37
41
|
/**
|
|
38
42
|
* Find all content and bring it up to date (the sweep). With a translator that
|
|
39
43
|
* has `batch` and `useBatch`, every missing sentence of every language goes in
|
|
40
44
|
* one batch first (cheaper), then documents are written from memory. Stops
|
|
41
45
|
* cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
|
|
42
|
-
* next run continues. `dryRun` only reports what is pending.
|
|
46
|
+
* next run continues. `dryRun` only reports what is pending. Respects the
|
|
47
|
+
* Translation settings (paused, skipped languages, cap); `force` runs even when
|
|
48
|
+
* paused (Translate now).
|
|
43
49
|
*/
|
|
44
50
|
export declare function syncAll(payload: Payload, opts?: {
|
|
45
51
|
dryRun?: boolean;
|
|
@@ -47,6 +53,7 @@ export declare function syncAll(payload: Payload, opts?: {
|
|
|
47
53
|
limit?: number;
|
|
48
54
|
collections?: string[];
|
|
49
55
|
locales?: string[];
|
|
56
|
+
force?: boolean;
|
|
50
57
|
}): Promise<SyncReport>;
|
|
51
58
|
/** Translation status of one document in one language, for notices: source, reviewed, stale, machine or pending. */
|
|
52
59
|
export declare function languageStatus(payload: Payload, collection: string, doc: Record<string, unknown>, locale: string): Promise<"source" | "stale" | "pending" | "reviewed" | "machine">;
|
package/dist/translate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fingerprint, memoryKey, textsOf, visitPaths } from './text.js';
|
|
2
|
+
import { clearStop, loadSettings, recordStop, recordUsage } from './settings.js';
|
|
2
3
|
import { QuotaExceededError } from './types.js';
|
|
3
4
|
export const polyglotOf = (payload) => payload.config.custom?.polyglot;
|
|
4
5
|
const rulesOf = (o) => ({ skip: o.skip, skipKeys: new Set(o.skipKeys) });
|
|
@@ -74,13 +75,16 @@ async function remember(payload, o, locale, pairs) {
|
|
|
74
75
|
}
|
|
75
76
|
const CHUNK = 80;
|
|
76
77
|
const chunks = (list) => Array.from({ length: Math.ceil(list.length / CHUNK) }, (_, i) => list.slice(i * CHUNK, (i + 1) * CHUNK));
|
|
77
|
-
const argsFor = (o, locale, items, context) => ({
|
|
78
|
+
const argsFor = (o, s, locale, items, context) => ({
|
|
78
79
|
locale,
|
|
79
80
|
items,
|
|
80
81
|
context,
|
|
81
82
|
sourceLocale: o.defaultLocale,
|
|
82
|
-
keep: o.keep,
|
|
83
|
+
keep: [...new Set([...o.keep, ...s.keep])],
|
|
84
|
+
instructions: s.instructions,
|
|
85
|
+
glossary: s.glossary.filter((g) => g.locale === locale).map(({ term, translation }) => ({ term, translation })),
|
|
83
86
|
});
|
|
87
|
+
const localesOf = (o, s, wanted) => (wanted ?? o.locales).filter((l) => !s.skipLocales.has(l));
|
|
84
88
|
/** A per-run character budget (free tiers). */
|
|
85
89
|
export class Budget {
|
|
86
90
|
max;
|
|
@@ -103,12 +107,13 @@ export class Budget {
|
|
|
103
107
|
*/
|
|
104
108
|
export async function translateDocument(payload, collection, id, opts = {}) {
|
|
105
109
|
const o = polyglotOf(payload);
|
|
110
|
+
const settings = opts.settings ?? (await loadSettings(payload, o));
|
|
106
111
|
const result = { translated: [], upToDate: [], reviewedStale: [], failed: [], pending: [] };
|
|
107
112
|
const src = await loadSource(payload, o, collection, id);
|
|
108
113
|
if (!src)
|
|
109
114
|
return result;
|
|
110
115
|
const todo = [];
|
|
111
|
-
for (const locale of
|
|
116
|
+
for (const locale of localesOf(o, settings, opts.locales)) {
|
|
112
117
|
const row = src.state.get(locale);
|
|
113
118
|
if (row?.hash === src.hash)
|
|
114
119
|
result.upToDate.push(locale);
|
|
@@ -124,51 +129,65 @@ export async function translateDocument(payload, collection, id, opts = {}) {
|
|
|
124
129
|
return { ...result, pending: todo };
|
|
125
130
|
const unique = [...new Set(src.texts)];
|
|
126
131
|
const context = { ...(o.writeContext?.({ inRequest: Boolean(opts.inRequest) }) ?? {}), polyglot: true };
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
let sent = 0;
|
|
133
|
+
let recovered = !settings.stop;
|
|
134
|
+
try {
|
|
135
|
+
for (const [n, locale] of todo.entries()) {
|
|
136
|
+
try {
|
|
137
|
+
let known = await recall(payload, o, locale, unique);
|
|
138
|
+
const missing = unique.filter((t) => !known.has(t));
|
|
139
|
+
if (missing.length && !opts.memoryOnly) {
|
|
140
|
+
const chars = missing.reduce((s, t) => s + t.length, 0);
|
|
141
|
+
if (opts.budget && !opts.budget.allows(chars)) {
|
|
142
|
+
result.pending.push(...todo.slice(n));
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
for (const part of chunks(missing)) {
|
|
146
|
+
const items = part.map((text, i) => ({ key: String(i), text }));
|
|
147
|
+
const out = await engineFor(o, locale).translate(argsFor(o, settings, locale, items, o.describe?.(collection)));
|
|
148
|
+
const chars = part.reduce((s, t) => s + t.length, 0);
|
|
149
|
+
opts.budget?.spend(chars);
|
|
150
|
+
sent += chars;
|
|
151
|
+
if (!recovered && out.size) {
|
|
152
|
+
recovered = true;
|
|
153
|
+
await clearStop(payload, o);
|
|
154
|
+
}
|
|
155
|
+
await remember(payload, o, locale, items.flatMap((it) => (out.get(it.key) ? [[it.text, out.get(it.key)]] : [])));
|
|
156
|
+
}
|
|
157
|
+
known = await recall(payload, o, locale, unique);
|
|
136
158
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await remember(payload, o, locale, items.flatMap((it) => (out.get(it.key) ? [[it.text, out.get(it.key)]] : [])));
|
|
159
|
+
if (unique.some((t) => !known.has(t))) {
|
|
160
|
+
;
|
|
161
|
+
(opts.memoryOnly ? result.pending : result.failed).push(locale);
|
|
162
|
+
continue;
|
|
142
163
|
}
|
|
143
|
-
|
|
164
|
+
const data = structuredClone(src.source);
|
|
165
|
+
visitPaths(data, o.collections[collection], rulesOf(o), (t, set) => set(known.get(t) ?? t));
|
|
166
|
+
await payload.update({
|
|
167
|
+
collection: collection,
|
|
168
|
+
id,
|
|
169
|
+
locale: locale,
|
|
170
|
+
data: { ...data, ...(src.doc._status ? { _status: 'published' } : {}) },
|
|
171
|
+
overrideAccess: true,
|
|
172
|
+
context,
|
|
173
|
+
});
|
|
174
|
+
await saveState(payload, o, collection, id, locale, src.hash, false, src.state.get(locale));
|
|
175
|
+
result.translated.push(locale);
|
|
144
176
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
collection: collection,
|
|
154
|
-
id,
|
|
155
|
-
locale: locale,
|
|
156
|
-
data: { ...data, ...(src.doc._status ? { _status: 'published' } : {}) },
|
|
157
|
-
overrideAccess: true,
|
|
158
|
-
context,
|
|
159
|
-
});
|
|
160
|
-
await saveState(payload, o, collection, id, locale, src.hash, false, src.state.get(locale));
|
|
161
|
-
result.translated.push(locale);
|
|
162
|
-
}
|
|
163
|
-
catch (error) {
|
|
164
|
-
if (error instanceof QuotaExceededError) {
|
|
165
|
-
result.pending.push(...todo.slice(n));
|
|
166
|
-
throw Object.assign(error, { result });
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error instanceof QuotaExceededError) {
|
|
179
|
+
result.pending.push(...todo.slice(n));
|
|
180
|
+
await recordStop(payload, o, error).catch((err) => payload.logger.warn({ err }, 'Polyglot: could not record the stop'));
|
|
181
|
+
throw Object.assign(error, { result });
|
|
182
|
+
}
|
|
183
|
+
payload.logger.error({ err: error, collection, id, locale }, 'Polyglot: translation failed');
|
|
184
|
+
result.failed.push(locale);
|
|
167
185
|
}
|
|
168
|
-
payload.logger.error({ err: error, collection, id, locale }, 'Polyglot: translation failed');
|
|
169
|
-
result.failed.push(locale);
|
|
170
186
|
}
|
|
171
187
|
}
|
|
188
|
+
finally {
|
|
189
|
+
await recordUsage(payload, o, sent).catch((err) => payload.logger.warn({ err }, 'Polyglot: could not record usage'));
|
|
190
|
+
}
|
|
172
191
|
return result;
|
|
173
192
|
}
|
|
174
193
|
/**
|
|
@@ -176,12 +195,17 @@ export async function translateDocument(payload, collection, id, opts = {}) {
|
|
|
176
195
|
* has `batch` and `useBatch`, every missing sentence of every language goes in
|
|
177
196
|
* one batch first (cheaper), then documents are written from memory. Stops
|
|
178
197
|
* cleanly at `maxCharactersPerRun` or when the engine's quota is used up; the
|
|
179
|
-
* next run continues. `dryRun` only reports what is pending.
|
|
198
|
+
* next run continues. `dryRun` only reports what is pending. Respects the
|
|
199
|
+
* Translation settings (paused, skipped languages, cap); `force` runs even when
|
|
200
|
+
* paused (Translate now).
|
|
180
201
|
*/
|
|
181
202
|
export async function syncAll(payload, opts = {}) {
|
|
182
203
|
const o = polyglotOf(payload);
|
|
183
204
|
const report = { documents: 0, upToDate: 0, translated: 0, pending: 0, reviewedStale: 0, failed: 0, characters: 0, stopped: 'done' };
|
|
184
|
-
const
|
|
205
|
+
const settings = await loadSettings(payload, o);
|
|
206
|
+
if (settings.paused && !opts.force && !opts.dryRun)
|
|
207
|
+
return { ...report, stopped: 'paused' };
|
|
208
|
+
const budget = new Budget(settings.maxCharactersPerRun ?? o.maxCharactersPerRun);
|
|
185
209
|
const targets = [];
|
|
186
210
|
for (const collection of Object.keys(o.collections).filter((c) => !opts.collections || opts.collections.includes(c))) {
|
|
187
211
|
const { docs } = await payload.find({ collection: collection, locale: o.defaultLocale, depth: 0, pagination: false, overrideAccess: true, draft: false });
|
|
@@ -189,7 +213,7 @@ export async function syncAll(payload, opts = {}) {
|
|
|
189
213
|
if (included(o, collection, d))
|
|
190
214
|
targets.push({ collection, id: String(d.id) });
|
|
191
215
|
}
|
|
192
|
-
const locales =
|
|
216
|
+
const locales = localesOf(o, settings, opts.locales);
|
|
193
217
|
const work = opts.limit ? targets.slice(0, opts.limit) : targets;
|
|
194
218
|
const batching = Boolean(opts.useBatch && o.translator.batch && !o.translatorFor && !opts.dryRun);
|
|
195
219
|
if (batching) {
|
|
@@ -217,11 +241,23 @@ export async function syncAll(payload, opts = {}) {
|
|
|
217
241
|
break;
|
|
218
242
|
}
|
|
219
243
|
budget.spend(chars);
|
|
220
|
-
requests.push(argsFor(o, locale, part.map((text, i) => ({ key: String(i), text }))));
|
|
244
|
+
requests.push(argsFor(o, settings, locale, part.map((text, i) => ({ key: String(i), text }))));
|
|
221
245
|
}
|
|
222
246
|
}
|
|
223
247
|
if (requests.length) {
|
|
224
|
-
|
|
248
|
+
let results;
|
|
249
|
+
try {
|
|
250
|
+
results = await o.translator.batch(requests);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (!(error instanceof QuotaExceededError))
|
|
254
|
+
throw error;
|
|
255
|
+
await recordStop(payload, o, error);
|
|
256
|
+
return { ...report, stopped: 'quota', stopReason: error.reason, pending: requests.length };
|
|
257
|
+
}
|
|
258
|
+
await recordUsage(payload, o, budget.used);
|
|
259
|
+
if (settings.stop && results.some((m) => m.size))
|
|
260
|
+
await clearStop(payload, o);
|
|
225
261
|
for (const [i, r] of requests.entries())
|
|
226
262
|
await remember(payload, o, r.locale, r.items.flatMap((it) => (results[i]?.get(it.key) ? [[it.text, results[i].get(it.key)]] : [])));
|
|
227
263
|
}
|
|
@@ -229,13 +265,14 @@ export async function syncAll(payload, opts = {}) {
|
|
|
229
265
|
for (const t of work) {
|
|
230
266
|
let r;
|
|
231
267
|
try {
|
|
232
|
-
r = await translateDocument(payload, t.collection, t.id, { locales, dryRun: opts.dryRun, memoryOnly: batching, budget: batching ? undefined : budget });
|
|
268
|
+
r = await translateDocument(payload, t.collection, t.id, { locales, dryRun: opts.dryRun, memoryOnly: batching, budget: batching ? undefined : budget, settings });
|
|
233
269
|
}
|
|
234
270
|
catch (error) {
|
|
235
271
|
if (!(error instanceof QuotaExceededError))
|
|
236
272
|
throw error;
|
|
237
273
|
r = error.result;
|
|
238
274
|
report.stopped = 'quota';
|
|
275
|
+
report.stopReason = error.reason;
|
|
239
276
|
}
|
|
240
277
|
report.documents++;
|
|
241
278
|
report.upToDate += r.upToDate.length;
|
package/dist/types.d.ts
CHANGED
|
@@ -15,15 +15,35 @@ export type TranslateArgs = {
|
|
|
15
15
|
context?: string;
|
|
16
16
|
sourceLocale: string;
|
|
17
17
|
keep: string[];
|
|
18
|
+
/** Editors' notes (tone, audience); AI engines use them, others may ignore them. */
|
|
19
|
+
instructions?: string;
|
|
20
|
+
/** Preferred translations for this language; AI engines use them, others may ignore them. */
|
|
21
|
+
glossary?: {
|
|
22
|
+
term: string;
|
|
23
|
+
translation: string;
|
|
24
|
+
}[];
|
|
18
25
|
};
|
|
19
26
|
export type Translator = {
|
|
20
27
|
translate: (args: TranslateArgs) => Promise<Map<string, string>>;
|
|
21
28
|
batch?: (requests: TranslateArgs[]) => Promise<Map<string, string>[]>;
|
|
22
29
|
};
|
|
23
|
-
/**
|
|
30
|
+
/** Why an engine stopped: out of credits (paid), a free quota used up, or the key refused. */
|
|
31
|
+
export type StopReason = 'credits' | 'quota' | 'auth';
|
|
32
|
+
/**
|
|
33
|
+
* Thrown by a translator when it cannot continue until someone acts (credits,
|
|
34
|
+
* quota, key): the run stops cleanly, admins are told why, and the next run
|
|
35
|
+
* after the fix continues.
|
|
36
|
+
*/
|
|
24
37
|
export declare class QuotaExceededError extends Error {
|
|
25
|
-
|
|
38
|
+
readonly reason: StopReason;
|
|
39
|
+
constructor(message?: string, reason?: StopReason);
|
|
26
40
|
}
|
|
41
|
+
/** A recorded stop, shown to admins until a translation succeeds again. */
|
|
42
|
+
export type EngineStop = {
|
|
43
|
+
reason: StopReason;
|
|
44
|
+
message: string;
|
|
45
|
+
at: string;
|
|
46
|
+
};
|
|
27
47
|
export type PolyglotOptions = {
|
|
28
48
|
/** Fields to keep in every language, per collection. Dotted paths reach into arrays/groups: `sections.heading`. */
|
|
29
49
|
collections: Record<string, string[]>;
|
|
@@ -66,12 +86,27 @@ export type PolyglotOptions = {
|
|
|
66
86
|
canManage?: (args: {
|
|
67
87
|
req: PayloadRequest;
|
|
68
88
|
}) => boolean | Promise<boolean>;
|
|
89
|
+
/** Who may change Translation settings (pause, languages, glossary…). Default: `canManage`. */
|
|
90
|
+
canConfigure?: (args: {
|
|
91
|
+
req: PayloadRequest;
|
|
92
|
+
}) => boolean | Promise<boolean>;
|
|
93
|
+
/** Add the Translation settings global (admin-editable pause, skipped languages, glossary, usage). Default true. */
|
|
94
|
+
settings?: boolean;
|
|
95
|
+
/** Slug of the settings global. Default `polyglot-settings`. */
|
|
96
|
+
settingsSlug?: string;
|
|
97
|
+
/** Admin group of the settings global. Default `Settings`. */
|
|
98
|
+
settingsGroup?: string;
|
|
69
99
|
/** Add the Polyglot admin view (coverage per collection and language, Translate now). Default true. */
|
|
70
100
|
adminView?: boolean;
|
|
71
101
|
/** Translate after a document is published (in Next.js, after the response). Default true. */
|
|
72
102
|
onPublish?: boolean;
|
|
103
|
+
/** Called once when translation stops (credits, quota, key), e.g. to email admins. Not called again until it has recovered. */
|
|
104
|
+
onStop?: (args: {
|
|
105
|
+
payload: Payload;
|
|
106
|
+
stop: EngineStop;
|
|
107
|
+
}) => void | Promise<void>;
|
|
73
108
|
};
|
|
74
|
-
export type ResolvedPolyglot = Required<Omit<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage'>> & Pick<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage'> & {
|
|
109
|
+
export type ResolvedPolyglot = Required<Omit<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage' | 'canConfigure' | 'onStop'>> & Pick<PolyglotOptions, 'enabled' | 'include' | 'writeContext' | 'describe' | 'translatorFor' | 'maxCharactersPerRun' | 'canManage' | 'canConfigure' | 'onStop'> & {
|
|
75
110
|
defaultLocale: string;
|
|
76
111
|
locales: string[];
|
|
77
112
|
};
|
package/dist/types.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by a translator when it cannot continue until someone acts (credits,
|
|
3
|
+
* quota, key): the run stops cleanly, admins are told why, and the next run
|
|
4
|
+
* after the fix continues.
|
|
5
|
+
*/
|
|
2
6
|
export class QuotaExceededError extends Error {
|
|
3
|
-
|
|
7
|
+
reason;
|
|
8
|
+
constructor(message = 'Translation quota used up', reason = 'quota') {
|
|
4
9
|
super(message);
|
|
10
|
+
this.reason = reason;
|
|
5
11
|
this.name = 'QuotaExceededError';
|
|
6
12
|
}
|
|
7
13
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@captiveorigins/payload-plugin-polyglot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Keeps all your Payload content translated into every language you have, a little at a time: finds content on its own, translates on publish and in a sweep, remembers every sentence, skips placeholder text, and never overwrites a human-reviewed translation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"payload",
|