@orthacms/i18n-server 0.0.0-reserve.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/lib/content/controllers/get-entry-locales.controller.d.ts +15 -0
- package/dist/lib/content/controllers/get-entry-locales.controller.d.ts.map +1 -0
- package/dist/lib/content/controllers/get-entry-locales.controller.js +45 -0
- package/dist/lib/content/controllers/locale-summary.controller.d.ts +30 -0
- package/dist/lib/content/controllers/locale-summary.controller.d.ts.map +1 -0
- package/dist/lib/content/controllers/locale-summary.controller.js +61 -0
- package/dist/lib/content/controllers/resolve-type.d.ts +9 -0
- package/dist/lib/content/controllers/resolve-type.d.ts.map +1 -0
- package/dist/lib/content/controllers/resolve-type.js +20 -0
- package/dist/lib/content/dto/locale-summary.dto.d.ts +10 -0
- package/dist/lib/content/dto/locale-summary.dto.d.ts.map +1 -0
- package/dist/lib/content/dto/locale-summary.dto.js +30 -0
- package/dist/lib/content/locale-group-lock.d.ts +34 -0
- package/dist/lib/content/locale-group-lock.d.ts.map +1 -0
- package/dist/lib/content/locale-group-lock.js +38 -0
- package/dist/lib/content/services/entry-locale-extension.service.d.ts +188 -0
- package/dist/lib/content/services/entry-locale-extension.service.d.ts.map +1 -0
- package/dist/lib/content/services/entry-locale-extension.service.js +662 -0
- package/dist/lib/content/services/locale-group.service.d.ts +87 -0
- package/dist/lib/content/services/locale-group.service.d.ts.map +1 -0
- package/dist/lib/content/services/locale-group.service.js +135 -0
- package/dist/lib/copilot/i18n-tool.provider.d.ts +53 -0
- package/dist/lib/copilot/i18n-tool.provider.d.ts.map +1 -0
- package/dist/lib/copilot/i18n-tool.provider.js +157 -0
- package/dist/lib/copilot/translation-proposal.applier.d.ts +87 -0
- package/dist/lib/copilot/translation-proposal.applier.d.ts.map +1 -0
- package/dist/lib/copilot/translation-proposal.applier.js +242 -0
- package/dist/lib/copilot/translation-proposal.provider.d.ts +114 -0
- package/dist/lib/copilot/translation-proposal.provider.d.ts.map +1 -0
- package/dist/lib/copilot/translation-proposal.provider.js +419 -0
- package/dist/lib/domain/errors/index.d.ts +4 -0
- package/dist/lib/domain/errors/index.d.ts.map +1 -0
- package/dist/lib/domain/errors/index.js +9 -0
- package/dist/lib/domain/errors/invalid-locale-set.error.d.ts +10 -0
- package/dist/lib/domain/errors/invalid-locale-set.error.d.ts.map +1 -0
- package/dist/lib/domain/errors/invalid-locale-set.error.js +16 -0
- package/dist/lib/domain/errors/invalid-locale.error.d.ts +10 -0
- package/dist/lib/domain/errors/invalid-locale.error.d.ts.map +1 -0
- package/dist/lib/domain/errors/invalid-locale.error.js +16 -0
- package/dist/lib/domain/errors/unknown-locale.error.d.ts +11 -0
- package/dist/lib/domain/errors/unknown-locale.error.d.ts.map +1 -0
- package/dist/lib/domain/errors/unknown-locale.error.js +18 -0
- package/dist/lib/domain/locale-policy.d.ts +42 -0
- package/dist/lib/domain/locale-policy.d.ts.map +1 -0
- package/dist/lib/domain/locale-policy.js +61 -0
- package/dist/lib/domain/value-objects/locale-set.d.ts +41 -0
- package/dist/lib/domain/value-objects/locale-set.d.ts.map +1 -0
- package/dist/lib/domain/value-objects/locale-set.js +89 -0
- package/dist/lib/domain/value-objects/locale.d.ts +55 -0
- package/dist/lib/domain/value-objects/locale.d.ts.map +1 -0
- package/dist/lib/domain/value-objects/locale.js +73 -0
- package/dist/lib/i18n.constants.d.ts +33 -0
- package/dist/lib/i18n.constants.d.ts.map +1 -0
- package/dist/lib/i18n.constants.js +89 -0
- package/dist/lib/i18n.module.d.ts +14 -0
- package/dist/lib/i18n.module.d.ts.map +1 -0
- package/dist/lib/i18n.module.js +82 -0
- package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts +23 -0
- package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts.map +1 -0
- package/dist/lib/insights/http/controllers/localization-coverage.controller.js +46 -0
- package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts +59 -0
- package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts.map +1 -0
- package/dist/lib/insights/infrastructure/queries/localization-coverage.query.js +179 -0
- package/dist/lib/insights/types/i18n-insights-view.d.ts +83 -0
- package/dist/lib/insights/types/i18n-insights-view.d.ts.map +1 -0
- package/dist/lib/insights/types/i18n-insights-view.js +10 -0
- package/dist/lib/locales/controllers/list-locales.controller.d.ts +26 -0
- package/dist/lib/locales/controllers/list-locales.controller.d.ts.map +1 -0
- package/dist/lib/locales/controllers/list-locales.controller.js +48 -0
- package/dist/lib/locales/services/locale-registry.service.d.ts +26 -0
- package/dist/lib/locales/services/locale-registry.service.d.ts.map +1 -0
- package/dist/lib/locales/services/locale-registry.service.js +70 -0
- package/dist/lib/locales/services/orphaned-locale.checker.d.ts +60 -0
- package/dist/lib/locales/services/orphaned-locale.checker.d.ts.map +1 -0
- package/dist/lib/locales/services/orphaned-locale.checker.js +114 -0
- package/dist/lib/types/locale.d.ts +68 -0
- package/dist/lib/types/locale.d.ts.map +1 -0
- package/dist/lib/types/locale.js +8 -0
- package/dist/lib/utils/i18n-plugin.d.ts +41 -0
- package/dist/lib/utils/i18n-plugin.d.ts.map +1 -0
- package/dist/lib/utils/i18n-plugin.js +53 -0
- package/package.json +47 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TranslationProposalToolProvider = exports.I18N_PROPOSAL_KINDS = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const identity_server_1 = require("@orthacms/identity-server");
|
|
7
|
+
const content_server_1 = require("@orthacms/content-server");
|
|
8
|
+
const tools_server_1 = require("@orthacms/tools-server");
|
|
9
|
+
const locale_registry_service_1 = require("../locales/services/locale-registry.service");
|
|
10
|
+
const locale_group_service_1 = require("../content/services/locale-group.service");
|
|
11
|
+
/** The proposal kinds this plugin declares and applies. */
|
|
12
|
+
exports.I18N_PROPOSAL_KINDS = {
|
|
13
|
+
/** Create a sibling row of an entry in another locale. */
|
|
14
|
+
createTranslation: 'i18n.entry.translate',
|
|
15
|
+
/**
|
|
16
|
+
* Create several sibling rows — more locales of one record, more records
|
|
17
|
+
* in one locale, or both — as **one** change.
|
|
18
|
+
*
|
|
19
|
+
* Its own kind rather than a repeated `createTranslation`, for the reason
|
|
20
|
+
* `content.entry.bulk-save` is: a proposal row is the receipt for one tool
|
|
21
|
+
* call, and "translate this into five languages" written as five rows is
|
|
22
|
+
* five cards in the transcript for a single instruction, none of which says
|
|
23
|
+
* what the other four were.
|
|
24
|
+
*/
|
|
25
|
+
bulkTranslation: 'i18n.entry.bulk-translate'
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The plugin's **write** tools: `i18n_propose_translation` for one locale of one
|
|
29
|
+
* entry, and `i18n_propose_bulk_translation` for a batch of them.
|
|
30
|
+
*
|
|
31
|
+
* Like every propose tool their handlers **write nothing** — they compute the
|
|
32
|
+
* change and hand it back, and the run engine records and applies it. What they
|
|
33
|
+
* do is the part a model cannot be trusted to get right on its own: resolve the
|
|
34
|
+
* locale slug against the configured set, confirm the target locale does not
|
|
35
|
+
* already exist in the group (a duplicate is a 409 at apply time, after the
|
|
36
|
+
* card has said the change is happening), and name the source entry so the
|
|
37
|
+
* applier can inherit its shared values.
|
|
38
|
+
*
|
|
39
|
+
* Only **localized** fields are translated. A field the type declares as shared
|
|
40
|
+
* is one value across the whole group by definition — writing a per-locale
|
|
41
|
+
* version of it would either be ignored or silently overwrite every sibling,
|
|
42
|
+
* and neither is something to show a reader as a translation.
|
|
43
|
+
*/
|
|
44
|
+
let TranslationProposalToolProvider = class TranslationProposalToolProvider {
|
|
45
|
+
registry;
|
|
46
|
+
locales;
|
|
47
|
+
groups;
|
|
48
|
+
grants;
|
|
49
|
+
toolRegistry;
|
|
50
|
+
constructor(registry, locales, groups, grants, toolRegistry) {
|
|
51
|
+
this.registry = registry;
|
|
52
|
+
this.locales = locales;
|
|
53
|
+
this.groups = groups;
|
|
54
|
+
this.grants = grants;
|
|
55
|
+
this.toolRegistry = toolRegistry;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Register with the shared tool registry once the DI graph is built —
|
|
59
|
+
* the same catalogue the MCP endpoint serves, narrowed to the `copilot`
|
|
60
|
+
* surface by each tool's `surfaces`. `@Optional()` because a deployment
|
|
61
|
+
* may run neither consumer, in which case these simply go unregistered.
|
|
62
|
+
*/
|
|
63
|
+
onModuleInit() {
|
|
64
|
+
this.toolRegistry?.register(this);
|
|
65
|
+
}
|
|
66
|
+
/** The two i18n write tools — one translation, and the batch. */
|
|
67
|
+
tools() {
|
|
68
|
+
return [this.proposeTranslation(), this.proposeBulkTranslation()];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A granted, registered, **localized** type — the gate both write tools
|
|
72
|
+
* open with.
|
|
73
|
+
*
|
|
74
|
+
* The two failures are worth separating: "unknown type" is the uniform
|
|
75
|
+
* answer every content tool gives a name it cannot reach (it must not
|
|
76
|
+
* distinguish "no such type" from "not granted here"), while "not
|
|
77
|
+
* localized" describes a type the caller has already proved it can reach
|
|
78
|
+
* and is safe — and useful — to say out loud.
|
|
79
|
+
*/
|
|
80
|
+
async resolveLocalizedType(typeName, workspaceId) {
|
|
81
|
+
const type = this.registry.get(typeName);
|
|
82
|
+
const granted = await this.grants.grantedSlugs(workspaceId);
|
|
83
|
+
if (!type || !granted.has(type.name)) {
|
|
84
|
+
throw new Error(`Unknown content type "${typeName}" in this workspace.`);
|
|
85
|
+
}
|
|
86
|
+
if (!type.i18n) {
|
|
87
|
+
throw new Error(`Content type "${type.name}" is not localized, so it cannot be translated.`);
|
|
88
|
+
}
|
|
89
|
+
return type;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The type's **localized** fields, keyed by name — the only ones a
|
|
93
|
+
* translation may carry.
|
|
94
|
+
*
|
|
95
|
+
* A field the type declares as shared is one value across the whole group
|
|
96
|
+
* by definition: writing a per-locale version of it would be fanned back
|
|
97
|
+
* out over every sibling by this plugin's own sync, so a "translated"
|
|
98
|
+
* shared field silently rewrites every other language.
|
|
99
|
+
*/
|
|
100
|
+
localizedFields(typeName) {
|
|
101
|
+
const schema = this.registry.serialize(typeName);
|
|
102
|
+
return new Map((schema?.fields ?? [])
|
|
103
|
+
.filter((field) => field.localized)
|
|
104
|
+
.map((field) => [field.name, field]));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Rejects anything in `values` that is not a localized field, and an empty
|
|
108
|
+
* bag — the check that makes a translation tool a translation tool.
|
|
109
|
+
*
|
|
110
|
+
* `prefix` names which item is at fault in a batch, and is empty for the
|
|
111
|
+
* single-entry tool so its message is unchanged.
|
|
112
|
+
*/
|
|
113
|
+
assertTranslatable(values, localized, prefix = '') {
|
|
114
|
+
const rejected = Object.keys(values).filter((name) => !localized.has(name));
|
|
115
|
+
if (rejected.length > 0) {
|
|
116
|
+
throw new Error(`${prefix}These fields are shared across locales and cannot be translated: ` +
|
|
117
|
+
`${rejected.join(', ')}. Only ${[...localized.keys()].join(', ')} ` +
|
|
118
|
+
'vary per locale on this type.');
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(values).length === 0) {
|
|
121
|
+
throw new Error(`${prefix}No translated values were supplied.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** The field's admin label, when it has one — for the card's diff rows. */
|
|
125
|
+
labelOf(field) {
|
|
126
|
+
const label = field?.admin?.['label'];
|
|
127
|
+
return typeof label === 'string' ? label : undefined;
|
|
128
|
+
}
|
|
129
|
+
/** `i18n_propose_translation` — one entry into one locale. */
|
|
130
|
+
proposeTranslation() {
|
|
131
|
+
return {
|
|
132
|
+
name: 'i18n_propose_translation',
|
|
133
|
+
title: 'Propose a translation',
|
|
134
|
+
description: 'Propose a translation of an entry into another locale, creating that locale’s ' +
|
|
135
|
+
'row in the same translation group. This does NOT save anything — it drafts ' +
|
|
136
|
+
'the change for the user to approve, and the reply will say so. Read the ' +
|
|
137
|
+
'source entry first (admin_content_get) and translate its text yourself; only ' +
|
|
138
|
+
'fields the type marks as localized can differ per locale.',
|
|
139
|
+
inputSchema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
typeName: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: 'The entry’s content type. It must be localized.'
|
|
145
|
+
},
|
|
146
|
+
id: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
description: 'The id of the source entry — the row you translated from.'
|
|
149
|
+
},
|
|
150
|
+
locale: {
|
|
151
|
+
type: 'string',
|
|
152
|
+
maxLength: 35,
|
|
153
|
+
description: 'The locale slug to create, from i18n_locales_list. It must not ' +
|
|
154
|
+
'already exist in this entry’s translation group.'
|
|
155
|
+
},
|
|
156
|
+
values: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
description: 'The translated values, keyed by field name. Only localized ' +
|
|
159
|
+
'fields are accepted; shared fields are copied by the CMS.'
|
|
160
|
+
},
|
|
161
|
+
summary: {
|
|
162
|
+
type: 'string',
|
|
163
|
+
maxLength: 200,
|
|
164
|
+
description: 'One line for the approval card, e.g. “German translation of ' +
|
|
165
|
+
'Spring launch”.'
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
required: ['typeName', 'id', 'locale', 'values', 'summary'],
|
|
169
|
+
additionalProperties: false
|
|
170
|
+
},
|
|
171
|
+
// `content:update` rather than `content:create`: the design's v1
|
|
172
|
+
// catalogue says so, and it is the right key — a translation is a
|
|
173
|
+
// change to an existing piece of content, not a new one, which is
|
|
174
|
+
// exactly how an editor thinks about it.
|
|
175
|
+
requires: [identity_server_1.PERMISSIONS.CONTENT_UPDATE],
|
|
176
|
+
readOnly: false,
|
|
177
|
+
effect: 'propose',
|
|
178
|
+
// Copilot-only: it reads the admin services (a viewer must see
|
|
179
|
+
// drafts) or writes through propose-then-apply with the human as
|
|
180
|
+
// actor. MCP's content tools are the public-API set.
|
|
181
|
+
surfaces: ['copilot'],
|
|
182
|
+
handler: async (input, ctx) => {
|
|
183
|
+
const args = (input ?? {});
|
|
184
|
+
const type = await this.resolveLocalizedType(args.typeName, ctx.workspaceId);
|
|
185
|
+
const target = this.locales.get(args.locale);
|
|
186
|
+
if (!target) {
|
|
187
|
+
throw new Error(`Unknown locale "${args.locale}". Call i18n_locales_list for the ` +
|
|
188
|
+
'configured slugs.');
|
|
189
|
+
}
|
|
190
|
+
// The group read doubles as the existence check on the source
|
|
191
|
+
// entry: it 404s a missing or soft-deleted row, workspace-scoped.
|
|
192
|
+
const group = await this.groups.entryLocales(type, args.id, ctx.workspaceId);
|
|
193
|
+
const existing = group.items.find((item) => item.locale === target.slug);
|
|
194
|
+
if (existing?.entry) {
|
|
195
|
+
throw new Error(`This entry already has a "${target.slug}" translation. Use ` +
|
|
196
|
+
'content_propose_update on that row instead.');
|
|
197
|
+
}
|
|
198
|
+
const localized = this.localizedFields(type.name);
|
|
199
|
+
const values = args.values ?? {};
|
|
200
|
+
this.assertTranslatable(values, localized);
|
|
201
|
+
return {
|
|
202
|
+
kind: exports.I18N_PROPOSAL_KINDS.createTranslation,
|
|
203
|
+
target: {
|
|
204
|
+
typeName: type.name,
|
|
205
|
+
sourceId: args.id,
|
|
206
|
+
localeGroupId: group.localeGroupId,
|
|
207
|
+
locale: target.slug
|
|
208
|
+
},
|
|
209
|
+
patch: { values },
|
|
210
|
+
summary: args.summary,
|
|
211
|
+
changes: Object.entries(values).map(([field, after]) => {
|
|
212
|
+
const label = this.labelOf(localized.get(field));
|
|
213
|
+
return {
|
|
214
|
+
field,
|
|
215
|
+
...(label ? { label } : {}),
|
|
216
|
+
after
|
|
217
|
+
};
|
|
218
|
+
})
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* `i18n_propose_bulk_translation` — several translations as **one** change.
|
|
225
|
+
*
|
|
226
|
+
* The single-entry tool scales badly in both directions users actually push
|
|
227
|
+
* it: "translate this into German, French and Spanish" is three calls, and
|
|
228
|
+
* "translate these eight posts into German" is eight. Each one spent a step
|
|
229
|
+
* of a bounded run and left its own card, so one instruction read back as a
|
|
230
|
+
* pile of receipts, none of which said what the others were.
|
|
231
|
+
*
|
|
232
|
+
* **Why this rather than `content_propose_bulk_save` with a
|
|
233
|
+
* `localeGroupId`.** That is what a model reached for before, and it is the
|
|
234
|
+
* shape that quietly breaks a group: a create is a whole row, so every
|
|
235
|
+
* shared field the batch did not name arrives as `null`, and this plugin's
|
|
236
|
+
* sync then pushes those nulls onto every sibling — see
|
|
237
|
+
* {@link TranslationProposalApplier}. Joining a translation group needs the
|
|
238
|
+
* source's shared values inherited at apply time, which is knowledge that
|
|
239
|
+
* lives here, so the content tools no longer offer `localeGroupId` at all
|
|
240
|
+
* and this is the one route in.
|
|
241
|
+
*
|
|
242
|
+
* An item is one **(source entry, target locale)** pair, so both shapes are
|
|
243
|
+
* the same list: five items naming one `id` and five locales, eight naming
|
|
244
|
+
* eight ids and one locale, or any mixture.
|
|
245
|
+
*/
|
|
246
|
+
proposeBulkTranslation() {
|
|
247
|
+
return {
|
|
248
|
+
name: 'i18n_propose_bulk_translation',
|
|
249
|
+
title: 'Propose several translations',
|
|
250
|
+
description: 'Translate one or more entries into one or more locales in a SINGLE change. ' +
|
|
251
|
+
'Use this instead of calling i18n_propose_translation repeatedly — the user ' +
|
|
252
|
+
'sees one card listing every translation, and you spend one step instead of ' +
|
|
253
|
+
'one per language. Each item is one entry translated into one locale: repeat ' +
|
|
254
|
+
'the same `id` with different locales to translate a record into several ' +
|
|
255
|
+
'languages, or list different `id`s with the same locale to translate several ' +
|
|
256
|
+
'records. Read the source entries first (admin_content_get) and translate ' +
|
|
257
|
+
'their text yourself; only fields the type marks as localized can differ per ' +
|
|
258
|
+
'locale, and the CMS copies the shared ones. If a translation fails, the ' +
|
|
259
|
+
'change stops there and the reply says how many landed — do not claim the ' +
|
|
260
|
+
'whole batch saved without reading it.',
|
|
261
|
+
inputSchema: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
properties: {
|
|
264
|
+
typeName: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
description: 'The content type every item belongs to. It must be localized.'
|
|
267
|
+
},
|
|
268
|
+
items: {
|
|
269
|
+
type: 'array',
|
|
270
|
+
minItems: 1,
|
|
271
|
+
maxItems: content_server_1.BULK_MAX_SAVE_ITEMS,
|
|
272
|
+
description: `The translations to create (1…${content_server_1.BULK_MAX_SAVE_ITEMS}), applied in order.`,
|
|
273
|
+
items: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
properties: {
|
|
276
|
+
id: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
description: 'The id of the source entry — the row you translated from. ' +
|
|
279
|
+
'Any locale of the record will do; they share one translation ' +
|
|
280
|
+
'group.'
|
|
281
|
+
},
|
|
282
|
+
locale: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
maxLength: 35,
|
|
285
|
+
description: 'The locale slug to create, from i18n_locales_list. It must ' +
|
|
286
|
+
'not already exist in this entry’s translation group.'
|
|
287
|
+
},
|
|
288
|
+
values: {
|
|
289
|
+
type: 'object',
|
|
290
|
+
description: 'The translated values for THIS locale, keyed by field name. ' +
|
|
291
|
+
'Only localized fields are accepted; shared fields are copied ' +
|
|
292
|
+
'by the CMS.'
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
required: ['id', 'locale', 'values'],
|
|
296
|
+
additionalProperties: false
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
summary: {
|
|
300
|
+
type: 'string',
|
|
301
|
+
maxLength: 200,
|
|
302
|
+
description: 'One line describing the whole batch, shown to the user on the card. ' +
|
|
303
|
+
'Write it for a person, e.g. “Translate Spring launch into 3 languages”.'
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
required: ['typeName', 'items', 'summary'],
|
|
307
|
+
additionalProperties: false
|
|
308
|
+
},
|
|
309
|
+
// The same key the single-entry tool takes, and for the same
|
|
310
|
+
// reason: a translation is a change to existing content, not a new
|
|
311
|
+
// piece of it. Batching is a way of *asking* — it must not become a
|
|
312
|
+
// route to authority the one-at-a-time tool was never given
|
|
313
|
+
// (ADR-0005 §7).
|
|
314
|
+
requires: [identity_server_1.PERMISSIONS.CONTENT_UPDATE],
|
|
315
|
+
readOnly: false,
|
|
316
|
+
effect: 'propose',
|
|
317
|
+
surfaces: ['copilot'],
|
|
318
|
+
handler: async (input, ctx) => {
|
|
319
|
+
const args = (input ?? {});
|
|
320
|
+
const type = await this.resolveLocalizedType(args.typeName, ctx.workspaceId);
|
|
321
|
+
const items = args.items ?? [];
|
|
322
|
+
if (items.length === 0) {
|
|
323
|
+
throw new Error('Nothing to translate — `items` was empty. Send at least one translation.');
|
|
324
|
+
}
|
|
325
|
+
if (items.length > content_server_1.BULK_MAX_SAVE_ITEMS) {
|
|
326
|
+
throw new Error(`Too many translations in one change: ${items.length}, and the limit ` +
|
|
327
|
+
`is ${content_server_1.BULK_MAX_SAVE_ITEMS}. Split it into smaller batches.`);
|
|
328
|
+
}
|
|
329
|
+
const localized = this.localizedFields(type.name);
|
|
330
|
+
const changes = [];
|
|
331
|
+
const patched = [];
|
|
332
|
+
// One group read per distinct source id: translating one record
|
|
333
|
+
// into five languages is five items naming the same entry, and
|
|
334
|
+
// re-reading its group five times would be five identical
|
|
335
|
+
// queries answering an identical question.
|
|
336
|
+
const groups = new Map();
|
|
337
|
+
// Every (group, locale) this batch already claims. Two items
|
|
338
|
+
// aiming at the same slot is a unique-index violation waiting
|
|
339
|
+
// to happen halfway through the apply — after the first
|
|
340
|
+
// translations have already been written.
|
|
341
|
+
const claimed = new Set();
|
|
342
|
+
for (const [index, item] of items.entries()) {
|
|
343
|
+
const at = `Item ${index + 1}: `;
|
|
344
|
+
if (typeof item.id !== 'string' || item.id.length === 0) {
|
|
345
|
+
throw new Error(`${at}no source entry \`id\`. Every item names the entry it translates.`);
|
|
346
|
+
}
|
|
347
|
+
const target = this.locales.get(item.locale);
|
|
348
|
+
if (!target) {
|
|
349
|
+
throw new Error(`${at}unknown locale "${item.locale}". Call i18n_locales_list for ` +
|
|
350
|
+
'the configured slugs.');
|
|
351
|
+
}
|
|
352
|
+
// Reads the group, and with it proves the source row exists
|
|
353
|
+
// in this workspace — so a bad id fails HERE, before
|
|
354
|
+
// anything is written, rather than partway through the
|
|
355
|
+
// batch. The whole view is cached, not just its group id:
|
|
356
|
+
// nothing is written during a propose, so which locales
|
|
357
|
+
// exist cannot change under the loop, and every later item
|
|
358
|
+
// naming the same entry answers "is this locale taken?"
|
|
359
|
+
// from it. Keyed by id rather than by group, which is the
|
|
360
|
+
// honest cost of not knowing two ids are siblings until one
|
|
361
|
+
// of them has been read.
|
|
362
|
+
let group = groups.get(item.id);
|
|
363
|
+
if (!group) {
|
|
364
|
+
group = await this.groups.entryLocales(type, item.id, ctx.workspaceId);
|
|
365
|
+
groups.set(item.id, group);
|
|
366
|
+
}
|
|
367
|
+
const existing = group.items.find((candidate) => candidate.locale === target.slug);
|
|
368
|
+
if (existing?.entry) {
|
|
369
|
+
throw new Error(`${at}this entry already has a "${target.slug}" translation. Use ` +
|
|
370
|
+
'content_propose_update on that row instead.');
|
|
371
|
+
}
|
|
372
|
+
const localeGroupId = group.localeGroupId;
|
|
373
|
+
const slot = `${localeGroupId}:${target.slug}`;
|
|
374
|
+
if (claimed.has(slot)) {
|
|
375
|
+
throw new Error(`${at}an earlier item already translates this record into ` +
|
|
376
|
+
`"${target.slug}". A record has one row per locale, so send each ` +
|
|
377
|
+
'locale of it once.');
|
|
378
|
+
}
|
|
379
|
+
claimed.add(slot);
|
|
380
|
+
const values = item.values ?? {};
|
|
381
|
+
this.assertTranslatable(values, localized, at);
|
|
382
|
+
patched.push({
|
|
383
|
+
sourceId: item.id,
|
|
384
|
+
localeGroupId,
|
|
385
|
+
locale: target.slug,
|
|
386
|
+
values
|
|
387
|
+
});
|
|
388
|
+
for (const [field, after] of Object.entries(values)) {
|
|
389
|
+
changes.push({
|
|
390
|
+
// Prefixed by position, because the card keys its
|
|
391
|
+
// rows on `field` and five locales translating
|
|
392
|
+
// `title` would otherwise collapse into one row.
|
|
393
|
+
field: `items[${index}].${field}`,
|
|
394
|
+
label: `#${index + 1} ${target.slug} · ${this.labelOf(localized.get(field)) ?? field}`,
|
|
395
|
+
after
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
kind: exports.I18N_PROPOSAL_KINDS.bulkTranslation,
|
|
401
|
+
target: { typeName: type.name },
|
|
402
|
+
patch: { items: patched },
|
|
403
|
+
summary: args.summary,
|
|
404
|
+
changes
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
exports.TranslationProposalToolProvider = TranslationProposalToolProvider;
|
|
411
|
+
exports.TranslationProposalToolProvider = TranslationProposalToolProvider = tslib_1.__decorate([
|
|
412
|
+
(0, common_1.Injectable)(),
|
|
413
|
+
tslib_1.__param(0, (0, content_server_1.InjectContentRegistry)()),
|
|
414
|
+
tslib_1.__param(4, (0, common_1.Optional)()),
|
|
415
|
+
tslib_1.__metadata("design:paramtypes", [Function, locale_registry_service_1.LocaleRegistryService,
|
|
416
|
+
locale_group_service_1.LocaleGroupService,
|
|
417
|
+
content_server_1.WorkspaceGrantsQuery,
|
|
418
|
+
tools_server_1.ToolRegistry])
|
|
419
|
+
], TranslationProposalToolProvider);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/lib/domain/errors/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UnknownLocaleError = exports.InvalidLocaleSetError = exports.InvalidLocaleError = void 0;
|
|
4
|
+
var invalid_locale_error_1 = require("./invalid-locale.error");
|
|
5
|
+
Object.defineProperty(exports, "InvalidLocaleError", { enumerable: true, get: function () { return invalid_locale_error_1.InvalidLocaleError; } });
|
|
6
|
+
var invalid_locale_set_error_1 = require("./invalid-locale-set.error");
|
|
7
|
+
Object.defineProperty(exports, "InvalidLocaleSetError", { enumerable: true, get: function () { return invalid_locale_set_error_1.InvalidLocaleSetError; } });
|
|
8
|
+
var unknown_locale_error_1 = require("./unknown-locale.error");
|
|
9
|
+
Object.defineProperty(exports, "UnknownLocaleError", { enumerable: true, get: function () { return unknown_locale_error_1.UnknownLocaleError; } });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised by {@link LocaleSet} when the *collection* of locales violates an
|
|
3
|
+
* invariant no single {@link Locale} can guard — an empty set, a duplicate
|
|
4
|
+
* slug, not exactly one default, or removing the default. Transport-agnostic;
|
|
5
|
+
* the plugin factory surfaces it at construction.
|
|
6
|
+
*/
|
|
7
|
+
export declare class InvalidLocaleSetError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=invalid-locale-set.error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invalid-locale-set.error.d.ts","sourceRoot":"","sources":["../../../../src/lib/domain/errors/invalid-locale-set.error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,qBAAsB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM;CAI9B"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InvalidLocaleSetError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Raised by {@link LocaleSet} when the *collection* of locales violates an
|
|
6
|
+
* invariant no single {@link Locale} can guard — an empty set, a duplicate
|
|
7
|
+
* slug, not exactly one default, or removing the default. Transport-agnostic;
|
|
8
|
+
* the plugin factory surfaces it at construction.
|
|
9
|
+
*/
|
|
10
|
+
class InvalidLocaleSetError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'InvalidLocaleSetError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.InvalidLocaleSetError = InvalidLocaleSetError;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised by {@link Locale.create} when a locale's slug is malformed (not the
|
|
3
|
+
* lowercase BCP-47-ish shape) or its display name is blank. Transport-agnostic
|
|
4
|
+
* — the plugin factory surfaces it at construction, so a misconfigured host
|
|
5
|
+
* fails before boot.
|
|
6
|
+
*/
|
|
7
|
+
export declare class InvalidLocaleError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=invalid-locale.error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invalid-locale.error.d.ts","sourceRoot":"","sources":["../../../../src/lib/domain/errors/invalid-locale.error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI9B"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InvalidLocaleError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Raised by {@link Locale.create} when a locale's slug is malformed (not the
|
|
6
|
+
* lowercase BCP-47-ish shape) or its display name is blank. Transport-agnostic
|
|
7
|
+
* — the plugin factory surfaces it at construction, so a misconfigured host
|
|
8
|
+
* fails before boot.
|
|
9
|
+
*/
|
|
10
|
+
class InvalidLocaleError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'InvalidLocaleError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.InvalidLocaleError = InvalidLocaleError;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raised by {@link LocalePolicy.resolve} when a request names a locale slug
|
|
3
|
+
* that isn't configured. Transport-agnostic — {@link LocaleRegistryService}
|
|
4
|
+
* maps it to HTTP 400 (the uniform unknown-locale gate every request path
|
|
5
|
+
* funnels through).
|
|
6
|
+
*/
|
|
7
|
+
export declare class UnknownLocaleError extends Error {
|
|
8
|
+
readonly slug: string;
|
|
9
|
+
constructor(slug: string);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=unknown-locale.error.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unknown-locale.error.d.ts","sourceRoot":"","sources":["../../../../src/lib/domain/errors/unknown-locale.error.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;aACb,IAAI,EAAE,MAAM;gBAAZ,IAAI,EAAE,MAAM;CAI3C"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UnknownLocaleError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Raised by {@link LocalePolicy.resolve} when a request names a locale slug
|
|
6
|
+
* that isn't configured. Transport-agnostic — {@link LocaleRegistryService}
|
|
7
|
+
* maps it to HTTP 400 (the uniform unknown-locale gate every request path
|
|
8
|
+
* funnels through).
|
|
9
|
+
*/
|
|
10
|
+
class UnknownLocaleError extends Error {
|
|
11
|
+
slug;
|
|
12
|
+
constructor(slug) {
|
|
13
|
+
super(`Unknown locale "${slug}".`);
|
|
14
|
+
this.slug = slug;
|
|
15
|
+
this.name = 'UnknownLocaleError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.UnknownLocaleError = UnknownLocaleError;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Locale } from './value-objects/locale';
|
|
2
|
+
import type { LocaleSet } from './value-objects/locale-set';
|
|
3
|
+
/**
|
|
4
|
+
* The locale **domain policy** — the pure fallback-chain and publish rules of
|
|
5
|
+
* the i18n context, framework- and database-free so they can be unit-tested
|
|
6
|
+
* without any content plumbing. It reads a {@link LocaleSet}; it renders no
|
|
7
|
+
* SQL. The extension adapter's `listScope` builds the equivalent query, but
|
|
8
|
+
* the *rule* it encodes lives here as the single source of truth.
|
|
9
|
+
*/
|
|
10
|
+
export declare class LocalePolicy {
|
|
11
|
+
private readonly locales;
|
|
12
|
+
constructor(locales: LocaleSet);
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a request's locale slug: absent → the default locale; unknown →
|
|
15
|
+
* {@link UnknownLocaleError}. The uniform gate every request-facing path
|
|
16
|
+
* funnels through.
|
|
17
|
+
*/
|
|
18
|
+
resolve(slug: string | undefined): Locale;
|
|
19
|
+
/**
|
|
20
|
+
* Whether a `localeFallback=default` list should **widen** to the default
|
|
21
|
+
* locale's rows: true only when default fallback is requested *and* the
|
|
22
|
+
* requested locale isn't already the default (nothing to widen to
|
|
23
|
+
* otherwise). The pure form of the extension's `listScope` strict-vs-widen
|
|
24
|
+
* branch.
|
|
25
|
+
*/
|
|
26
|
+
shouldWidenToDefault(requested: Locale, fallbackRequested: boolean): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The ordered locales to try when reading a translation group in
|
|
29
|
+
* `requested`: `[requested]` in strict mode, or `[requested, default]` when
|
|
30
|
+
* default fallback applies ({@link shouldWidenToDefault}).
|
|
31
|
+
*/
|
|
32
|
+
fallbackChain(requested: Locale, fallbackRequested: boolean): Locale[];
|
|
33
|
+
/**
|
|
34
|
+
* The locales that must hold a row before a translation group is
|
|
35
|
+
* considered fully published: the **default locale is always required** —
|
|
36
|
+
* a group can't go live without its canonical translation. A pure rule the
|
|
37
|
+
* publish flow can consult; the group's own per-sibling validation stays in
|
|
38
|
+
* the extension adapter.
|
|
39
|
+
*/
|
|
40
|
+
requiredLocalesForPublish(): Locale[];
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=locale-policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locale-policy.d.ts","sourceRoot":"","sources":["../../../src/lib/domain/locale-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAE5D;;;;;;GAMG;AACH,qBAAa,YAAY;IACT,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,SAAS;IAE/C;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM;IAOzC;;;;;;OAMG;IACH,oBAAoB,CAChB,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,OAAO,GAC3B,OAAO;IAIV;;;;OAIG;IACH,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,iBAAiB,EAAE,OAAO,GAAG,MAAM,EAAE;IAMtE;;;;;;OAMG;IACH,yBAAyB,IAAI,MAAM,EAAE;CAGxC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocalePolicy = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
/**
|
|
6
|
+
* The locale **domain policy** — the pure fallback-chain and publish rules of
|
|
7
|
+
* the i18n context, framework- and database-free so they can be unit-tested
|
|
8
|
+
* without any content plumbing. It reads a {@link LocaleSet}; it renders no
|
|
9
|
+
* SQL. The extension adapter's `listScope` builds the equivalent query, but
|
|
10
|
+
* the *rule* it encodes lives here as the single source of truth.
|
|
11
|
+
*/
|
|
12
|
+
class LocalePolicy {
|
|
13
|
+
locales;
|
|
14
|
+
constructor(locales) {
|
|
15
|
+
this.locales = locales;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a request's locale slug: absent → the default locale; unknown →
|
|
19
|
+
* {@link UnknownLocaleError}. The uniform gate every request-facing path
|
|
20
|
+
* funnels through.
|
|
21
|
+
*/
|
|
22
|
+
resolve(slug) {
|
|
23
|
+
if (slug === undefined)
|
|
24
|
+
return this.locales.default();
|
|
25
|
+
const locale = this.locales.get(slug);
|
|
26
|
+
if (!locale)
|
|
27
|
+
throw new errors_1.UnknownLocaleError(slug);
|
|
28
|
+
return locale;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whether a `localeFallback=default` list should **widen** to the default
|
|
32
|
+
* locale's rows: true only when default fallback is requested *and* the
|
|
33
|
+
* requested locale isn't already the default (nothing to widen to
|
|
34
|
+
* otherwise). The pure form of the extension's `listScope` strict-vs-widen
|
|
35
|
+
* branch.
|
|
36
|
+
*/
|
|
37
|
+
shouldWidenToDefault(requested, fallbackRequested) {
|
|
38
|
+
return fallbackRequested && !requested.equals(this.locales.default());
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The ordered locales to try when reading a translation group in
|
|
42
|
+
* `requested`: `[requested]` in strict mode, or `[requested, default]` when
|
|
43
|
+
* default fallback applies ({@link shouldWidenToDefault}).
|
|
44
|
+
*/
|
|
45
|
+
fallbackChain(requested, fallbackRequested) {
|
|
46
|
+
return this.shouldWidenToDefault(requested, fallbackRequested)
|
|
47
|
+
? [requested, this.locales.default()]
|
|
48
|
+
: [requested];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The locales that must hold a row before a translation group is
|
|
52
|
+
* considered fully published: the **default locale is always required** —
|
|
53
|
+
* a group can't go live without its canonical translation. A pure rule the
|
|
54
|
+
* publish flow can consult; the group's own per-sibling validation stays in
|
|
55
|
+
* the extension adapter.
|
|
56
|
+
*/
|
|
57
|
+
requiredLocalesForPublish() {
|
|
58
|
+
return [this.locales.default()];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
exports.LocalePolicy = LocalePolicy;
|