@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,662 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EntryLocaleExtensionService = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
7
|
+
const database_1 = require("@orthacms/database");
|
|
8
|
+
const content_server_1 = require("@orthacms/content-server");
|
|
9
|
+
const utils_server_1 = require("@orthacms/utils-server");
|
|
10
|
+
const content_server_2 = require("@orthacms/content-server");
|
|
11
|
+
const i18n_constants_1 = require("../../i18n.constants");
|
|
12
|
+
const locale_registry_service_1 = require("../../locales/services/locale-registry.service");
|
|
13
|
+
const locale_group_lock_1 = require("../locale-group-lock");
|
|
14
|
+
/** Virtual filter field: the group holds a row in this locale. */
|
|
15
|
+
const HAS_LOCALE = 'hasLocale';
|
|
16
|
+
/** Virtual filter field: the group is missing a row in this locale. */
|
|
17
|
+
const MISSING_LOCALE = 'missingLocale';
|
|
18
|
+
/** Virtual filter field: how many locale rows the group holds. */
|
|
19
|
+
const LOCALE_COUNT = 'localeCount';
|
|
20
|
+
/**
|
|
21
|
+
* Operators each virtual field admits (anything else is a 400).
|
|
22
|
+
*
|
|
23
|
+
* **`hasLocale` takes `eq` / `in` only, deliberately.** `ne` and `nin` were
|
|
24
|
+
* permitted and read as the negation of "has this locale" — they are not. They
|
|
25
|
+
* quantify *inside* the EXISTS, so `hasLocale ne "de"` renders
|
|
26
|
+
* `EXISTS(sibling WHERE locale <> 'de')`: "the group holds some locale other
|
|
27
|
+
* than German", which a fully-translated record satisfies. Asked for "records
|
|
28
|
+
* without a German translation" it returned the German ones. `missingLocale` is
|
|
29
|
+
* the field that expresses absence, so the negations are refused here rather
|
|
30
|
+
* than silently answering a different question — a 400 naming the field is a
|
|
31
|
+
* failure the caller can see and correct, a wrong row set is not.
|
|
32
|
+
*/
|
|
33
|
+
const LOCALE_FIELD_OPS = {
|
|
34
|
+
[HAS_LOCALE]: new Set([utils_server_1.FilterOperator.Eq, utils_server_1.FilterOperator.In]),
|
|
35
|
+
[MISSING_LOCALE]: new Set([utils_server_1.FilterOperator.Eq, utils_server_1.FilterOperator.In]),
|
|
36
|
+
[LOCALE_COUNT]: new Set([
|
|
37
|
+
utils_server_1.FilterOperator.Eq,
|
|
38
|
+
utils_server_1.FilterOperator.Ne,
|
|
39
|
+
utils_server_1.FilterOperator.Gt,
|
|
40
|
+
utils_server_1.FilterOperator.Gte,
|
|
41
|
+
utils_server_1.FilterOperator.Lt,
|
|
42
|
+
utils_server_1.FilterOperator.Lte
|
|
43
|
+
])
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* The i18n plugin's binding of content-server's `CONTENT_ENTRY_EXTENSION`
|
|
47
|
+
* port — where every locale *behavior* lives, so the content library itself
|
|
48
|
+
* stays locale-agnostic:
|
|
49
|
+
*
|
|
50
|
+
* - **List scoping** — the active-locale filter (strict; the default locale
|
|
51
|
+
* when the request names none), plus the relation picker's
|
|
52
|
+
* `localeFallback=default` union.
|
|
53
|
+
* - **Create stamping** — validates the requested slug and stamps the
|
|
54
|
+
* `locale` envelope column (the group id comes from the column default: a
|
|
55
|
+
* plain create starts its own translation group).
|
|
56
|
+
* - **Shared-field sync** — on update, non-`localized` field values propagate
|
|
57
|
+
* to every sibling row of the translation group, inside the same
|
|
58
|
+
* transaction; published siblings are re-validated so a draft edit can
|
|
59
|
+
* never silently invalidate live content. The rewritten siblings are handed
|
|
60
|
+
* back to the entries pipeline, which appends a **revision** for each — their
|
|
61
|
+
* values changed, so their history has to say so.
|
|
62
|
+
* - **Virtual filters** — `hasLocale` / `missingLocale` / `localeCount`,
|
|
63
|
+
* resolved to EXISTS / count subqueries over the group (ridden by the
|
|
64
|
+
* `(locale_group_id, locale)` unique index).
|
|
65
|
+
*
|
|
66
|
+
* Every method no-ops for non-i18n types, as the port contract requires.
|
|
67
|
+
*/
|
|
68
|
+
let EntryLocaleExtensionService = class EntryLocaleExtensionService {
|
|
69
|
+
db;
|
|
70
|
+
locales;
|
|
71
|
+
validation;
|
|
72
|
+
relations;
|
|
73
|
+
constructor(db, locales, validation,
|
|
74
|
+
// Relation storage is content's, not ours: reading a link set and
|
|
75
|
+
// replacing one are the same operations the entries pipeline performs,
|
|
76
|
+
// and a second implementation here is how the join table's ordering
|
|
77
|
+
// and de-duplication rules would drift.
|
|
78
|
+
relations) {
|
|
79
|
+
this.db = db;
|
|
80
|
+
this.locales = locales;
|
|
81
|
+
this.validation = validation;
|
|
82
|
+
this.relations = relations;
|
|
83
|
+
}
|
|
84
|
+
/** @inheritdoc */
|
|
85
|
+
listScope(type, workspaceId, params) {
|
|
86
|
+
if (!type.i18n)
|
|
87
|
+
return undefined;
|
|
88
|
+
const requested = this.locales.resolve(params.locale);
|
|
89
|
+
const table = type.table;
|
|
90
|
+
const strict = (0, drizzle_orm_1.eq)(table['locale'], requested.slug);
|
|
91
|
+
const fallback = this.locales.default();
|
|
92
|
+
if (params.localeFallback !== i18n_constants_1.LOCALE_FALLBACK_DEFAULT ||
|
|
93
|
+
requested.slug === fallback.slug) {
|
|
94
|
+
// Strict mode (the records table): untranslated groups are hidden.
|
|
95
|
+
return strict;
|
|
96
|
+
}
|
|
97
|
+
// Fallback mode (the relation picker): one row per group — the
|
|
98
|
+
// requested locale where it exists, else the default-locale row.
|
|
99
|
+
const sibling = (0, drizzle_orm_1.aliasedTable)(type.table, 'locale_sibling');
|
|
100
|
+
const s = sibling;
|
|
101
|
+
const requestedSibling = this.db
|
|
102
|
+
.select({ one: (0, drizzle_orm_1.sql) `1` })
|
|
103
|
+
.from(sibling)
|
|
104
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(s['localeGroupId'], table['localeGroupId']), (0, drizzle_orm_1.eq)(s['workspaceId'], workspaceId), (0, drizzle_orm_1.eq)(s['locale'], requested.slug), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(s['deletedAt'])] : [])));
|
|
105
|
+
return (0, drizzle_orm_1.or)(strict, (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['locale'], fallback.slug), (0, drizzle_orm_1.notExists)(requestedSibling)));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* @inheritdoc
|
|
109
|
+
*
|
|
110
|
+
* Stamps the validated `locale` (defaulting when absent). When a
|
|
111
|
+
* `localeGroupId` is given the new row **joins that existing group** (a
|
|
112
|
+
* sibling translation) — verified to name a real group in the workspace
|
|
113
|
+
* first (else 404), so a typo can't spawn a stray one-row group. Absent →
|
|
114
|
+
* the column default (`gen_random_uuid()`) starts a fresh group.
|
|
115
|
+
*/
|
|
116
|
+
async createColumns(type, workspaceId, params) {
|
|
117
|
+
if (!type.i18n)
|
|
118
|
+
return {};
|
|
119
|
+
const columns = {
|
|
120
|
+
locale: this.locales.resolve(params.locale).slug
|
|
121
|
+
};
|
|
122
|
+
if (params.localeGroupId !== undefined) {
|
|
123
|
+
await this.assertGroupExists(type, params.localeGroupId, workspaceId);
|
|
124
|
+
columns['localeGroupId'] = params.localeGroupId;
|
|
125
|
+
}
|
|
126
|
+
return columns;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Assert a translation group has ≥1 live row in this workspace, so a
|
|
130
|
+
* sibling attaches to a real group. Runs before the create transaction —
|
|
131
|
+
* the (benign) TOCTOU window is covered by the row staying valid and the
|
|
132
|
+
* `(locale_group_id, locale)` unique index still guarding duplicates.
|
|
133
|
+
*/
|
|
134
|
+
async assertGroupExists(type, localeGroupId, workspaceId) {
|
|
135
|
+
const table = type.table;
|
|
136
|
+
const [row] = await this.db
|
|
137
|
+
.select({ one: (0, drizzle_orm_1.sql) `1` })
|
|
138
|
+
.from(type.table)
|
|
139
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['localeGroupId'], localeGroupId), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(table['deletedAt'])] : [])))
|
|
140
|
+
.limit(1);
|
|
141
|
+
if (!row) {
|
|
142
|
+
throw new common_1.NotFoundException(`No translation group "${localeGroupId}" on "${type.name}".`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* @inheritdoc
|
|
147
|
+
*
|
|
148
|
+
* Takes the translation group's advisory lock, ahead of every row lock the
|
|
149
|
+
* write will acquire. See {@link lockLocaleGroup} for why ordering the
|
|
150
|
+
* sibling `FOR UPDATE` cannot replace this: the edited row is already
|
|
151
|
+
* locked by the pipeline's own `UPDATE` before any i18n code runs, so two
|
|
152
|
+
* saves in one group invert their lock order no matter how the sync sorts
|
|
153
|
+
* its siblings.
|
|
154
|
+
*
|
|
155
|
+
* No group id means a create starting a fresh group — nothing to contend
|
|
156
|
+
* on, so nothing to lock.
|
|
157
|
+
*/
|
|
158
|
+
async beforeWrite(tx, type, params) {
|
|
159
|
+
if (!type.i18n || !params.localeGroupId)
|
|
160
|
+
return;
|
|
161
|
+
await (0, locale_group_lock_1.lockLocaleGroup)(tx, params.localeGroupId);
|
|
162
|
+
}
|
|
163
|
+
/** @inheritdoc */
|
|
164
|
+
async afterUpdate(tx, type, row, values, workspaceId, context) {
|
|
165
|
+
if (!type.i18n)
|
|
166
|
+
return [];
|
|
167
|
+
// A newly-created sibling arrives carrying the source's shared *values*
|
|
168
|
+
// (the client copies them) but none of its **relations** — those never
|
|
169
|
+
// travel in a create body. Propagating outward from it would therefore
|
|
170
|
+
// push emptiness onto rows that were already right, wiping the group's
|
|
171
|
+
// links. So a create pulls its relations in from the group instead, and
|
|
172
|
+
// only its columns propagate outward (which the client has already made
|
|
173
|
+
// a no-op in the common case, and which is what makes a deliberately
|
|
174
|
+
// different shared value on a create win — long-standing behavior).
|
|
175
|
+
if (context.created) {
|
|
176
|
+
await this.inheritRelationsFromGroup(tx, type, row, workspaceId);
|
|
177
|
+
}
|
|
178
|
+
return this.propagateToSiblings(tx, type, row, values, workspaceId, {
|
|
179
|
+
relations: !context.created
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** @inheritdoc */
|
|
183
|
+
async describeFanout(type, entryId, values, workspaceId) {
|
|
184
|
+
if (!type.i18n)
|
|
185
|
+
return undefined;
|
|
186
|
+
// Cheapest question first, and the one that decides the rest: a save
|
|
187
|
+
// touching only per-locale fields is exactly what a translation is, and
|
|
188
|
+
// it must not read the database — the copilot calls this on every
|
|
189
|
+
// proposed edit, including the many that concern one language only.
|
|
190
|
+
const fields = Object.keys(values).filter((name) => this.travelsToSiblings(type, name));
|
|
191
|
+
if (!fields.length)
|
|
192
|
+
return undefined;
|
|
193
|
+
const table = type.table;
|
|
194
|
+
const sibling = (0, drizzle_orm_1.aliasedTable)(type.table, 'fanout_sibling');
|
|
195
|
+
const siblingColumns = sibling;
|
|
196
|
+
// One self-join rather than "read the group id, then read the group":
|
|
197
|
+
// the bulk tool describes up to BULK_MAX_SAVE_ITEMS items in a turn, so
|
|
198
|
+
// halving the round trips per item is worth the denser query.
|
|
199
|
+
//
|
|
200
|
+
// No lock and no `deletedAt` filter, both on purpose. The lock is
|
|
201
|
+
// covered in the port's JSDoc; the trashed rows are here because
|
|
202
|
+
// `propagateToSiblings` rewrites them too (so a restore comes back
|
|
203
|
+
// consistent with its group), and a description that quietly omitted
|
|
204
|
+
// them would understate the change it exists to disclose.
|
|
205
|
+
const rows = (await this.db
|
|
206
|
+
.select({ locale: (0, drizzle_orm_1.sql) `${siblingColumns['locale']}` })
|
|
207
|
+
.from(type.table)
|
|
208
|
+
.innerJoin(sibling, (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(siblingColumns['localeGroupId'], table['localeGroupId']), (0, drizzle_orm_1.ne)(siblingColumns['id'], table['id']), (0, drizzle_orm_1.eq)(siblingColumns['workspaceId'], workspaceId)))
|
|
209
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['id'], entryId), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId))));
|
|
210
|
+
const locales = [...new Set(rows.map((row) => row.locale))].sort();
|
|
211
|
+
// An entry alone in its group is an ordinary single-row edit. Saying so
|
|
212
|
+
// would put a caveat on the majority of edits and teach the reader to
|
|
213
|
+
// skim past it on the one that matters.
|
|
214
|
+
if (!locales.length)
|
|
215
|
+
return undefined;
|
|
216
|
+
return { fields, locales };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Whether this field's value reaches the row's siblings when it is saved —
|
|
220
|
+
* the predicate behind {@link describeFanout}, stated once against the same
|
|
221
|
+
* three rules {@link propagateToSiblings} applies.
|
|
222
|
+
*
|
|
223
|
+
* It is broader than {@link sharedColumns}, deliberately: that method
|
|
224
|
+
* answers "which columns copy across verbatim", because shared columns,
|
|
225
|
+
* mirrored FKs and join-backed link sets are *applied* by three different
|
|
226
|
+
* mechanisms. To a person being told what their change will do, the three
|
|
227
|
+
* are one fact — the value they set here lands over there.
|
|
228
|
+
*
|
|
229
|
+
* A **mirrored** relation counts even though each sibling ends up holding a
|
|
230
|
+
* different id: the sibling's link is being repointed at the record this
|
|
231
|
+
* save chose, which is a change to that row whatever id expresses it.
|
|
232
|
+
*/
|
|
233
|
+
travelsToSiblings(type, name) {
|
|
234
|
+
const spec = type.fields[name];
|
|
235
|
+
if (!spec)
|
|
236
|
+
return false;
|
|
237
|
+
// Checked ahead of the relation rules for the same reason
|
|
238
|
+
// `sharedColumns` checks it first: `localized` is the author's explicit
|
|
239
|
+
// "this varies per language", and a field carrying it never propagates
|
|
240
|
+
// however its relation options happen to read.
|
|
241
|
+
if (spec.localized)
|
|
242
|
+
return false;
|
|
243
|
+
if (spec.type === content_server_2.CONTENT_FIELD_TYPE.Relation) {
|
|
244
|
+
return (0, content_server_1.relationLocaleSync)(type, spec) !== content_server_1.RELATION_LOCALE_SYNC.None;
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Push this row's state onto every sibling in its translation group, and
|
|
250
|
+
* return the rows actually rewritten (the pipeline appends a revision for
|
|
251
|
+
* each — their content moved in this transaction, and without a version
|
|
252
|
+
* their history would skip the change and a later restore would silently
|
|
253
|
+
* undo it).
|
|
254
|
+
*
|
|
255
|
+
* Three kinds of state travel, each computed once and applied per sibling:
|
|
256
|
+
* shared **columns** (identical everywhere), **mirrored** single-relation
|
|
257
|
+
* FKs (resolved into each sibling's own locale), and the **link sets** of
|
|
258
|
+
* join-backed relations. A sibling whose every one of those already matches
|
|
259
|
+
* is left completely untouched — that guard is what stops one save
|
|
260
|
+
* re-versioning the whole group.
|
|
261
|
+
*/
|
|
262
|
+
async propagateToSiblings(tx, type, row, values, workspaceId, options) {
|
|
263
|
+
const table = type.table;
|
|
264
|
+
// Only the columns this save actually carries. A field the caller
|
|
265
|
+
// omitted is `undefined`, which `.set()` skips — so it must be left out
|
|
266
|
+
// of the change predicate too, or it would compare against a missing
|
|
267
|
+
// bind parameter.
|
|
268
|
+
const shared = Object.entries(this.sharedColumns(type, values) ?? {})
|
|
269
|
+
.filter(([, value]) => value !== undefined)
|
|
270
|
+
.map(([column, value]) => [column, value]);
|
|
271
|
+
// Decide whether anything CAN travel before reading — and, more to the
|
|
272
|
+
// point, before locking — anything. Both inputs are free: the columns
|
|
273
|
+
// come from the save's own values bag, and whether a relation
|
|
274
|
+
// propagates is a property of the schema.
|
|
275
|
+
//
|
|
276
|
+
// Skipping the query is not just an optimization. The read below takes
|
|
277
|
+
// `FOR UPDATE` on every sibling of the group, so running it
|
|
278
|
+
// unconditionally would make each save of a localized type lock rows it
|
|
279
|
+
// has no intention of writing — on a type with no shared field and no
|
|
280
|
+
// syncing relation, permanently and for nothing. The pre-relation code
|
|
281
|
+
// had this property (it returned before touching the database when
|
|
282
|
+
// there were no shared columns); restoring it keeps the lock footprint
|
|
283
|
+
// proportional to what the save actually propagates.
|
|
284
|
+
const canSyncRelations = options.relations &&
|
|
285
|
+
Object.values(type.fields).some((spec) => (0, content_server_1.relationLocaleSync)(type, spec) !== content_server_1.RELATION_LOCALE_SYNC.None);
|
|
286
|
+
if (!shared.length && !canSyncRelations)
|
|
287
|
+
return [];
|
|
288
|
+
// Every sibling, locked. We need each row's own `locale` (to resolve a
|
|
289
|
+
// mirrored link into it), and its **pre-write** publish status — the
|
|
290
|
+
// re-validation below has to read the status from before the demotion,
|
|
291
|
+
// since the UPDATE sets every touched row to `draft`. Soft-deleted
|
|
292
|
+
// siblings are included, so a later restore comes back consistent with
|
|
293
|
+
// the group. The lock also serializes two concurrent saves in different
|
|
294
|
+
// locales of the same record, which would otherwise interleave.
|
|
295
|
+
//
|
|
296
|
+
// Ordered by id so the rows are locked in one canonical sequence. That
|
|
297
|
+
// is a determinism guard, **not** the deadlock fix: the entry the
|
|
298
|
+
// caller edited was already locked by the pipeline's own `UPDATE`
|
|
299
|
+
// before this hook ran, so ordering the siblings alone still leaves two
|
|
300
|
+
// savers each holding the row the other wants. `beforeWrite`'s group
|
|
301
|
+
// advisory lock is what makes the inversion unreachable — see
|
|
302
|
+
// {@link lockLocaleGroup}.
|
|
303
|
+
const siblings = (await tx
|
|
304
|
+
.select()
|
|
305
|
+
.from(type.table)
|
|
306
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['localeGroupId'], row['localeGroupId']), (0, drizzle_orm_1.ne)(table['id'], row['id']), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId)))
|
|
307
|
+
.orderBy((0, drizzle_orm_1.sql) `${table['id']}`)
|
|
308
|
+
.for('update'));
|
|
309
|
+
if (!siblings.length)
|
|
310
|
+
return [];
|
|
311
|
+
const locales = [
|
|
312
|
+
...new Set(siblings.map((sibling) => sibling['locale']))
|
|
313
|
+
];
|
|
314
|
+
const mirrored = options.relations
|
|
315
|
+
? await this.mirroredColumnsByLocale(tx, type, values, locales, workspaceId)
|
|
316
|
+
: new Map();
|
|
317
|
+
const links = options.relations
|
|
318
|
+
? await this.linkSetsByLocale(tx, type, row['id'], locales, workspaceId)
|
|
319
|
+
: new Map();
|
|
320
|
+
const rewritten = [];
|
|
321
|
+
for (const sibling of siblings) {
|
|
322
|
+
const siblingId = sibling['id'];
|
|
323
|
+
const locale = sibling['locale'];
|
|
324
|
+
// Links first, so a links-only change still reaches the row write
|
|
325
|
+
// below — that is what stamps `updatedAt`, demotes a published
|
|
326
|
+
// sibling, and earns the row its revision.
|
|
327
|
+
let linksMoved = false;
|
|
328
|
+
for (const [field, byLocale] of links) {
|
|
329
|
+
const moved = await this.relations.replaceLinks(tx, type, siblingId, field, byLocale.get(locale) ?? []);
|
|
330
|
+
linksMoved ||= moved;
|
|
331
|
+
}
|
|
332
|
+
const columns = [
|
|
333
|
+
...shared,
|
|
334
|
+
...Object.entries(mirrored.get(locale) ?? {})
|
|
335
|
+
];
|
|
336
|
+
// Nothing to write and nothing moved — leave the row (and its
|
|
337
|
+
// history) completely alone. Guarding here matters: an empty
|
|
338
|
+
// `differs` collapses to `undefined`, which would widen the UPDATE
|
|
339
|
+
// to every sibling unconditionally.
|
|
340
|
+
if (!columns.length && !linksMoved)
|
|
341
|
+
continue;
|
|
342
|
+
// Only rewrite a sibling whose columns actually differ. The save's
|
|
343
|
+
// values bag carries every field the caller sent, so an
|
|
344
|
+
// unconditional UPDATE re-versioned every sibling on every save,
|
|
345
|
+
// even one that only changed a localized field. `IS DISTINCT FROM`
|
|
346
|
+
// rather than `<>` so a NULL on either side compares correctly.
|
|
347
|
+
//
|
|
348
|
+
// Each value is bound with **its column's own encoder**
|
|
349
|
+
// (`sql.param`), the same mapping `.set()` applies. Interpolating it
|
|
350
|
+
// bare made an array-valued field (a `jsonb` multiselect, json, or a
|
|
351
|
+
// multiple media field) expand into a parameter *list* —
|
|
352
|
+
// `IS DISTINCT FROM ($1, $2, $3)` — which Postgres reads as a record
|
|
353
|
+
// and rejects with `operator does not exist: jsonb = record`,
|
|
354
|
+
// failing every save of an i18n type that carried one.
|
|
355
|
+
const differs = (0, drizzle_orm_1.or)(...columns.map(([column, value]) => (0, drizzle_orm_1.sql) `${table[column]} IS DISTINCT FROM ${drizzle_orm_1.sql.param(value, table[column])}`));
|
|
356
|
+
// When the links moved the row has already changed, so it is
|
|
357
|
+
// written regardless of whether any column did.
|
|
358
|
+
const where = linksMoved
|
|
359
|
+
? (0, drizzle_orm_1.eq)(table['id'], siblingId)
|
|
360
|
+
: (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['id'], siblingId), differs);
|
|
361
|
+
// A **published** sibling moves back to `draft`, exactly as the
|
|
362
|
+
// entry the user edited does. Its content just changed, so its
|
|
363
|
+
// published *version* is no longer what the row holds — leaving it
|
|
364
|
+
// `published` made the same edit live in the untouched locales
|
|
365
|
+
// while still pending in the edited one, and left its
|
|
366
|
+
// freshly-appended draft version describing a row that claimed to
|
|
367
|
+
// be live. `published_at` is kept, so the sibling reads as
|
|
368
|
+
// **Modified** (live content, unpublished changes) rather than as a
|
|
369
|
+
// never-published draft. Publishing any locale is still per-row;
|
|
370
|
+
// this only stops one going live on another's save.
|
|
371
|
+
const [updated] = (await tx
|
|
372
|
+
.update(type.table)
|
|
373
|
+
.set({
|
|
374
|
+
...Object.fromEntries(columns),
|
|
375
|
+
...(type.publishable ? { status: content_server_2.ENTRY_STATUS.Draft } : {}),
|
|
376
|
+
updatedAt: new Date()
|
|
377
|
+
})
|
|
378
|
+
.where(where)
|
|
379
|
+
.returning());
|
|
380
|
+
if (!updated)
|
|
381
|
+
continue; // nothing differed after all
|
|
382
|
+
// A sibling that **was** published must stay valid once the synced
|
|
383
|
+
// state lands — re-validate and abort the whole save otherwise.
|
|
384
|
+
// Drafts may be temporarily invalid (the same rule as saving a
|
|
385
|
+
// draft directly). This reads the pre-write status captured in the
|
|
386
|
+
// locked SELECT, not the row's current one: the UPDATE has just
|
|
387
|
+
// demoted it to `draft`, so testing the returned status would
|
|
388
|
+
// silently skip every sibling.
|
|
389
|
+
if (type.publishable &&
|
|
390
|
+
sibling['status'] === content_server_2.ENTRY_STATUS.Published) {
|
|
391
|
+
const result = this.validation.validate(type, (0, content_server_1.toRecord)(type, updated).values);
|
|
392
|
+
if (!result.valid) {
|
|
393
|
+
throw new common_1.UnprocessableEntityException({
|
|
394
|
+
message: `Shared fields would invalidate the published ` +
|
|
395
|
+
`"${locale}" translation`,
|
|
396
|
+
issues: result.issues
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
rewritten.push(updated);
|
|
401
|
+
}
|
|
402
|
+
return rewritten;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Fill a **newly created** sibling's propagating relations from the group
|
|
406
|
+
* it just joined — the half of the sync that runs inward.
|
|
407
|
+
*
|
|
408
|
+
* A translation is created through the ordinary create route with a
|
|
409
|
+
* `localeGroupId`, and a create body carries no relation links at all: they
|
|
410
|
+
* are join-backed (never in the values bag) or per-locale (deliberately
|
|
411
|
+
* dropped by the client, since the source's id names another language's
|
|
412
|
+
* row). So without this a translation is born with none of the record's
|
|
413
|
+
* links, which is exactly the manual per-locale re-linking this sync
|
|
414
|
+
* exists to remove.
|
|
415
|
+
*
|
|
416
|
+
* The donor is one existing sibling — any of them agree on a shared link,
|
|
417
|
+
* and a mirrored one resolves through the same translation group whichever
|
|
418
|
+
* is asked — picked deterministically (the default locale where the group
|
|
419
|
+
* has it) so two creates never inherit from different rows.
|
|
420
|
+
*
|
|
421
|
+
* Columns it derives are also written back onto `row` **in place**: the
|
|
422
|
+
* caller snapshots that object as the new entry's first revision *after*
|
|
423
|
+
* this call, so a database-only write would leave version 1 describing
|
|
424
|
+
* something the row never held.
|
|
425
|
+
*/
|
|
426
|
+
async inheritRelationsFromGroup(tx, type, row, workspaceId) {
|
|
427
|
+
const table = type.table;
|
|
428
|
+
const locale = row['locale'];
|
|
429
|
+
const donors = (await tx
|
|
430
|
+
.select()
|
|
431
|
+
.from(type.table)
|
|
432
|
+
.where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(table['localeGroupId'], row['localeGroupId']), (0, drizzle_orm_1.ne)(table['id'], row['id']), (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(table['deletedAt'])] : []))));
|
|
433
|
+
if (!donors.length)
|
|
434
|
+
return; // a fresh group: nothing to inherit
|
|
435
|
+
const fallback = this.locales.default().slug;
|
|
436
|
+
const donor = donors.find((candidate) => candidate['locale'] === fallback) ??
|
|
437
|
+
[...donors].sort((a, b) => a['locale'].localeCompare(b['locale']))[0];
|
|
438
|
+
// Reuse the outward machinery with the roles swapped: the donor is the
|
|
439
|
+
// source, and this new row is the only "sibling" being written to.
|
|
440
|
+
const mirrored = await this.mirroredColumnsByLocale(tx, type, (0, content_server_1.toRecord)(type, donor).values, [locale], workspaceId);
|
|
441
|
+
const links = await this.linkSetsByLocale(tx, type, donor['id'], [locale], workspaceId);
|
|
442
|
+
for (const [field, byLocale] of links) {
|
|
443
|
+
await this.relations.replaceLinks(tx, type, row['id'], field, byLocale.get(locale) ?? []);
|
|
444
|
+
}
|
|
445
|
+
const columns = Object.entries(mirrored.get(locale) ?? {});
|
|
446
|
+
if (!columns.length)
|
|
447
|
+
return;
|
|
448
|
+
const [updated] = (await tx
|
|
449
|
+
.update(type.table)
|
|
450
|
+
.set(Object.fromEntries(columns))
|
|
451
|
+
.where((0, drizzle_orm_1.eq)(table['id'], row['id']))
|
|
452
|
+
.returning());
|
|
453
|
+
// Keep the caller's row object in step with what was just stored (see
|
|
454
|
+
// the note above about the first revision).
|
|
455
|
+
if (updated)
|
|
456
|
+
Object.assign(row, updated);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Per locale, the **mirrored** single-relation FK columns a sibling in that
|
|
460
|
+
* locale should hold: the source's linked record, resolved to that record's
|
|
461
|
+
* own translation in the sibling's language.
|
|
462
|
+
*
|
|
463
|
+
* An unresolvable link becomes `null` rather than being skipped. Leaving
|
|
464
|
+
* the sibling's existing value would keep it pointing at the *previous*
|
|
465
|
+
* record's translation — silently wrong data, which is worse than an empty
|
|
466
|
+
* field that the editor can see and the publish gate can block on.
|
|
467
|
+
*/
|
|
468
|
+
async mirroredColumnsByLocale(tx, type, values, locales, workspaceId) {
|
|
469
|
+
const out = new Map();
|
|
470
|
+
for (const locale of locales)
|
|
471
|
+
out.set(locale, {});
|
|
472
|
+
for (const [name, spec] of Object.entries(type.fields)) {
|
|
473
|
+
if ((0, content_server_1.relationLocaleSync)(type, spec) !==
|
|
474
|
+
content_server_1.RELATION_LOCALE_SYNC.Mirrored ||
|
|
475
|
+
(0, content_server_1.isJoinBackedRelation)(spec)) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
// A field this save didn't carry says nothing about what the
|
|
479
|
+
// siblings should hold (a merge-patch names only what changed).
|
|
480
|
+
if (!(name in values))
|
|
481
|
+
continue;
|
|
482
|
+
const sourceId = values[name];
|
|
483
|
+
if (typeof sourceId !== 'string' || !sourceId) {
|
|
484
|
+
for (const locale of locales)
|
|
485
|
+
out.get(locale)[name] = null;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
const equivalents = await this.relations.equivalentIdsByLocale(tx, spec.relation?.to(), [sourceId], locales, workspaceId);
|
|
489
|
+
for (const locale of locales) {
|
|
490
|
+
out.get(locale)[name] =
|
|
491
|
+
equivalents.get(locale)?.get(sourceId) ?? null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Per join-backed relation field, per locale, the ordered link ids a
|
|
498
|
+
* sibling in that locale should hold.
|
|
499
|
+
*
|
|
500
|
+
* A **shared** relation hands every sibling the source's ids verbatim — the
|
|
501
|
+
* target has no locales, so there is one right row for the whole group. A
|
|
502
|
+
* **mirrored** one maps each id through its translation group into the
|
|
503
|
+
* sibling's language, preserving the source's order and dropping links
|
|
504
|
+
* whose target has no translation there (a content gap on the target, not a
|
|
505
|
+
* reason to fail this save).
|
|
506
|
+
*/
|
|
507
|
+
async linkSetsByLocale(tx, type, sourceId, locales, workspaceId) {
|
|
508
|
+
const out = new Map();
|
|
509
|
+
for (const [name, spec] of Object.entries(type.fields)) {
|
|
510
|
+
const mode = (0, content_server_1.relationLocaleSync)(type, spec);
|
|
511
|
+
if (mode === content_server_1.RELATION_LOCALE_SYNC.None)
|
|
512
|
+
continue;
|
|
513
|
+
if (!(0, content_server_1.isJoinBackedRelation)(spec))
|
|
514
|
+
continue;
|
|
515
|
+
const ids = await this.relations.linkIdsOf(tx, type, sourceId, name);
|
|
516
|
+
const byLocale = new Map();
|
|
517
|
+
if (mode === content_server_1.RELATION_LOCALE_SYNC.Shared) {
|
|
518
|
+
for (const locale of locales)
|
|
519
|
+
byLocale.set(locale, ids);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
const equivalents = await this.relations.equivalentIdsByLocale(tx, spec.relation?.to(), ids, locales, workspaceId);
|
|
523
|
+
for (const locale of locales) {
|
|
524
|
+
const map = equivalents.get(locale);
|
|
525
|
+
byLocale.set(locale, ids
|
|
526
|
+
.map((id) => map?.get(id))
|
|
527
|
+
.filter((id) => !!id));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
out.set(name, byLocale);
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
/** @inheritdoc */
|
|
535
|
+
filterExtension(type) {
|
|
536
|
+
if (!type.i18n)
|
|
537
|
+
return undefined;
|
|
538
|
+
const slugs = this.locales.all().map((locale) => locale.slug);
|
|
539
|
+
const fields = {
|
|
540
|
+
[HAS_LOCALE]: { type: utils_server_1.ScalarFieldType.Enum, enumValues: slugs },
|
|
541
|
+
[MISSING_LOCALE]: { type: utils_server_1.ScalarFieldType.Enum, enumValues: slugs },
|
|
542
|
+
[LOCALE_COUNT]: { type: utils_server_1.ScalarFieldType.Number }
|
|
543
|
+
};
|
|
544
|
+
return {
|
|
545
|
+
fields,
|
|
546
|
+
resolve: (rule, context) => this.resolveLocaleRule(rule, context)
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Translate one virtual locale rule into SQL. All three fields quantify
|
|
551
|
+
* over the row's translation group via a self-EXISTS/count subquery,
|
|
552
|
+
* workspace-scoped, (on paranoid types) live-rows-only, and **restricted to
|
|
553
|
+
* the configured locale set** — the same restriction the coverage query
|
|
554
|
+
* applies (`configuredScope`).
|
|
555
|
+
*
|
|
556
|
+
* That last one is what keeps the records table and the Translation
|
|
557
|
+
* coverage card telling the same story. A row in a slug the host has since
|
|
558
|
+
* dropped is not coverage of anything: coverage filters it out, so leaving
|
|
559
|
+
* it in here made `localeCount` count higher than the number of configured
|
|
560
|
+
* locales, and a record the card reported as fully localized answered
|
|
561
|
+
* neither `localeCount eq 3` (the configured total) nor any `hasLocale`
|
|
562
|
+
* question consistent with it. Two views of one record, disagreeing.
|
|
563
|
+
*
|
|
564
|
+
* `missingLocale in [a, b]` is a `notExists` over the union, so it means
|
|
565
|
+
* "missing **all** of these", not "missing any" — the union has no matching
|
|
566
|
+
* sibling only when every named locale is absent. It is the useful reading
|
|
567
|
+
* for the records table ("show me what has none of my target languages");
|
|
568
|
+
* "missing any" is expressed as an `or` of `missingLocale eq` rules.
|
|
569
|
+
*/
|
|
570
|
+
async resolveLocaleRule(rule, context) {
|
|
571
|
+
const field = rule.path[0];
|
|
572
|
+
const allowedOps = LOCALE_FIELD_OPS[field];
|
|
573
|
+
if (!allowedOps?.has(rule.op)) {
|
|
574
|
+
throw new common_1.BadRequestException(`Operator "${rule.op}" is not supported on "${field}".`);
|
|
575
|
+
}
|
|
576
|
+
const { type, workspaceId } = context;
|
|
577
|
+
const table = type.table;
|
|
578
|
+
const sibling = (0, drizzle_orm_1.aliasedTable)(type.table, 'locale_sibling');
|
|
579
|
+
const s = sibling;
|
|
580
|
+
const groupScope = (0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(s['localeGroupId'], table['localeGroupId']), (0, drizzle_orm_1.eq)(s['workspaceId'], workspaceId),
|
|
581
|
+
// Only rows in a **configured** locale count as a translation —
|
|
582
|
+
// mirrors the coverage query's `configuredScope`, so the two
|
|
583
|
+
// cannot disagree about the same record.
|
|
584
|
+
(0, drizzle_orm_1.inArray)(s['locale'], this.locales.all().map((locale) => locale.slug)), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(s['deletedAt'])] : []));
|
|
585
|
+
if (field === LOCALE_COUNT) {
|
|
586
|
+
const countSubquery = this.db
|
|
587
|
+
.select({ n: (0, drizzle_orm_1.sql) `count(*)` })
|
|
588
|
+
.from(sibling)
|
|
589
|
+
.where(groupScope);
|
|
590
|
+
const value = rule.value;
|
|
591
|
+
switch (rule.op) {
|
|
592
|
+
case utils_server_1.FilterOperator.Eq:
|
|
593
|
+
return (0, drizzle_orm_1.sql) `(${countSubquery}) = ${value}`;
|
|
594
|
+
case utils_server_1.FilterOperator.Ne:
|
|
595
|
+
return (0, drizzle_orm_1.sql) `(${countSubquery}) <> ${value}`;
|
|
596
|
+
case utils_server_1.FilterOperator.Gt:
|
|
597
|
+
return (0, drizzle_orm_1.sql) `(${countSubquery}) > ${value}`;
|
|
598
|
+
case utils_server_1.FilterOperator.Gte:
|
|
599
|
+
return (0, drizzle_orm_1.sql) `(${countSubquery}) >= ${value}`;
|
|
600
|
+
case utils_server_1.FilterOperator.Lt:
|
|
601
|
+
return (0, drizzle_orm_1.sql) `(${countSubquery}) < ${value}`;
|
|
602
|
+
default:
|
|
603
|
+
return (0, drizzle_orm_1.sql) `(${countSubquery}) <= ${value}`;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// hasLocale / missingLocale: EXISTS (or its negation) of a sibling row
|
|
607
|
+
// in the named locale(s). Only `eq` and `in` reach here — the negating
|
|
608
|
+
// operators are refused above, because negating *inside* the EXISTS is
|
|
609
|
+
// not the negation of the EXISTS (see LOCALE_FIELD_OPS).
|
|
610
|
+
const localePredicate = rule.op === utils_server_1.FilterOperator.In
|
|
611
|
+
? (0, drizzle_orm_1.inArray)(s['locale'], rule.value)
|
|
612
|
+
: (0, drizzle_orm_1.eq)(s['locale'], rule.value);
|
|
613
|
+
const siblingInLocale = this.db
|
|
614
|
+
.select({ one: (0, drizzle_orm_1.sql) `1` })
|
|
615
|
+
.from(sibling)
|
|
616
|
+
.where((0, drizzle_orm_1.and)(groupScope, localePredicate));
|
|
617
|
+
return field === HAS_LOCALE
|
|
618
|
+
? (0, drizzle_orm_1.exists)(siblingInLocale)
|
|
619
|
+
: (0, drizzle_orm_1.notExists)(siblingInLocale);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* The column bag of the fields whose value is **identical in every locale**,
|
|
623
|
+
* drawn from a save's coerced values — the part of the sync that is the
|
|
624
|
+
* same for every sibling.
|
|
625
|
+
*
|
|
626
|
+
* Scalars and media qualify unless marked `localized`. A relation qualifies
|
|
627
|
+
* only when it is column-backed *and* {@link RELATION_LOCALE_SYNC.Shared}:
|
|
628
|
+
* a join-backed relation has no column to copy (its links go through
|
|
629
|
+
* {@link linkSetsByLocale}), and a mirrored one stores a different id in
|
|
630
|
+
* every locale by design (see {@link mirroredColumnsByLocale}), so copying
|
|
631
|
+
* one verbatim would be exactly the cross-locale link the writer rejects.
|
|
632
|
+
*
|
|
633
|
+
* `null` when the type has no such field at all.
|
|
634
|
+
*/
|
|
635
|
+
sharedColumns(type, values) {
|
|
636
|
+
const sharedFields = Object.entries(type.fields).filter(([, spec]) => {
|
|
637
|
+
if (spec.localized)
|
|
638
|
+
return false;
|
|
639
|
+
if (spec.type !== content_server_2.CONTENT_FIELD_TYPE.Relation)
|
|
640
|
+
return true;
|
|
641
|
+
if (spec.relation?.many || spec.relation?.inverse)
|
|
642
|
+
return false;
|
|
643
|
+
return ((0, content_server_1.relationLocaleSync)(type, spec) === content_server_1.RELATION_LOCALE_SYNC.Shared);
|
|
644
|
+
});
|
|
645
|
+
if (!sharedFields.length)
|
|
646
|
+
return null;
|
|
647
|
+
const allColumns = (0, content_server_1.toColumns)(type, values);
|
|
648
|
+
const shared = {};
|
|
649
|
+
for (const [name] of sharedFields) {
|
|
650
|
+
shared[name] = allColumns[name];
|
|
651
|
+
}
|
|
652
|
+
return shared;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
exports.EntryLocaleExtensionService = EntryLocaleExtensionService;
|
|
656
|
+
exports.EntryLocaleExtensionService = EntryLocaleExtensionService = tslib_1.__decorate([
|
|
657
|
+
(0, common_1.Injectable)(),
|
|
658
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
659
|
+
tslib_1.__metadata("design:paramtypes", [Object, locale_registry_service_1.LocaleRegistryService,
|
|
660
|
+
content_server_1.EntryValidationService,
|
|
661
|
+
content_server_1.RelationLinkService])
|
|
662
|
+
], EntryLocaleExtensionService);
|