@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,87 @@
|
|
|
1
|
+
import { type Database } from '@orthacms/database';
|
|
2
|
+
import type { AnyContentType, EntryStatus } from '@orthacms/content-server';
|
|
3
|
+
import type { LocaleDir } from '../../i18n.constants';
|
|
4
|
+
import { LocaleRegistryService } from '../../locales/services/locale-registry.service';
|
|
5
|
+
/** One locale's slot in a translation group — the row, or null if missing. */
|
|
6
|
+
export interface EntryLocaleItem {
|
|
7
|
+
/**
|
|
8
|
+
* The configured locale slug — a BCP-47 language tag, usable verbatim as
|
|
9
|
+
* the HTML `lang` of this translation's editor surface and preview.
|
|
10
|
+
*/
|
|
11
|
+
locale: string;
|
|
12
|
+
/**
|
|
13
|
+
* This locale's text direction, so the editor can set `dir` alongside
|
|
14
|
+
* `lang` without a second call to `GET /api/i18n/locales`.
|
|
15
|
+
*/
|
|
16
|
+
dir: LocaleDir;
|
|
17
|
+
/** Whether it's the configured default locale. */
|
|
18
|
+
isDefault: boolean;
|
|
19
|
+
/** The group's row in this locale, or null when not yet translated. */
|
|
20
|
+
entry: {
|
|
21
|
+
id: string;
|
|
22
|
+
/** Publish state — publishable types only. */
|
|
23
|
+
status?: EntryStatus;
|
|
24
|
+
/**
|
|
25
|
+
* ISO timestamp of when this locale last went live, or `null` if never
|
|
26
|
+
* — publishable types only. Paired with `status` it separates a
|
|
27
|
+
* never-published draft from one carrying unpublished edits over live
|
|
28
|
+
* content, which is the difference the locale switcher renders.
|
|
29
|
+
*/
|
|
30
|
+
publishedAt?: string | null;
|
|
31
|
+
/** ISO last-updated timestamp. */
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
} | null;
|
|
34
|
+
}
|
|
35
|
+
/** The `GET /api/i18n/content/:typeName/:id/locales` response envelope. */
|
|
36
|
+
export interface EntryLocalesView {
|
|
37
|
+
/** The entry's translation-group id. */
|
|
38
|
+
localeGroupId: string;
|
|
39
|
+
/** One item per **configured** locale, in config order. */
|
|
40
|
+
items: EntryLocaleItem[];
|
|
41
|
+
}
|
|
42
|
+
/** One group member in a batch summary. */
|
|
43
|
+
export interface LocaleSummaryItem {
|
|
44
|
+
locale: string;
|
|
45
|
+
entryId: string;
|
|
46
|
+
/** Publish state — publishable types only. */
|
|
47
|
+
status?: EntryStatus;
|
|
48
|
+
/** When this locale last went live, or `null` — publishable types only. */
|
|
49
|
+
publishedAt?: string | null;
|
|
50
|
+
}
|
|
51
|
+
/** The `POST /api/i18n/content/:typeName/locale-summary` response envelope. */
|
|
52
|
+
export interface LocaleSummaryView {
|
|
53
|
+
/** Per requested group id: its live members (config-ordered). */
|
|
54
|
+
groups: Record<string, LocaleSummaryItem[]>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reads over translation groups: the per-entry locale panel (which locales
|
|
58
|
+
* exist, with status) and the records table's batched per-page summary.
|
|
59
|
+
* Read-only — writes live in {@link TranslationService}.
|
|
60
|
+
*/
|
|
61
|
+
export declare class LocaleGroupService {
|
|
62
|
+
private readonly db;
|
|
63
|
+
private readonly locales;
|
|
64
|
+
constructor(db: Database, locales: LocaleRegistryService);
|
|
65
|
+
/**
|
|
66
|
+
* The locale panel for one entry: every configured locale with its
|
|
67
|
+
* sibling row (id, status, updatedAt) or null. 404 when the entry is
|
|
68
|
+
* missing (or soft-deleted) in this workspace.
|
|
69
|
+
*/
|
|
70
|
+
entryLocales(type: AnyContentType, id: string, workspaceId: string): Promise<EntryLocalesView>;
|
|
71
|
+
/**
|
|
72
|
+
* Batched group summary for the records table's Locales column: for each
|
|
73
|
+
* requested group id, its live members **in a configured locale**, with
|
|
74
|
+
* per-row status — one query for the whole page.
|
|
75
|
+
*
|
|
76
|
+
* The response's keys are exactly the request's: every requested id is
|
|
77
|
+
* seeded to `[]` up front and the rows are workspace-scoped, so a group id
|
|
78
|
+
* from another workspace and one that names nothing at all come back
|
|
79
|
+
* identical. A caller learns nothing it did not already supply.
|
|
80
|
+
*/
|
|
81
|
+
summaries(type: AnyContentType, groupIds: string[], workspaceId: string): Promise<LocaleSummaryView>;
|
|
82
|
+
/** A row's `published_at` as an ISO string, or null when it never went live. */
|
|
83
|
+
private publishedAt;
|
|
84
|
+
/** A predicate AND workspace scope AND (paranoid) the not-deleted guard. */
|
|
85
|
+
private liveWhere;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=locale-group.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locale-group.service.d.ts","sourceRoot":"","sources":["../../../../src/lib/content/services/locale-group.service.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,gDAAgD,CAAC;AAKvF,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC5B;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,EAAE,SAAS,CAAC;IACf,kDAAkD;IAClD,SAAS,EAAE,OAAO,CAAC;IACnB,uEAAuE;IACvE,KAAK,EAAE;QACH,EAAE,EAAE,MAAM,CAAC;QACX,8CAA8C;QAC9C,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB;;;;;WAKG;QACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC5B,kCAAkC;QAClC,SAAS,EAAE,MAAM,CAAC;KACrB,GAAG,IAAI,CAAC;CACZ;AAED,2EAA2E;AAC3E,MAAM,WAAW,gBAAgB;IAC7B,wCAAwC;IACxC,aAAa,EAAE,MAAM,CAAC;IACtB,2DAA2D;IAC3D,KAAK,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,2CAA2C;AAC3C,MAAM,WAAW,iBAAiB;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,2EAA2E;IAC3E,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,+EAA+E;AAC/E,MAAM,WAAW,iBAAiB;IAC9B,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,EAAE,CAAC,CAAC;CAC/C;AAED;;;;GAIG;AACH,qBACa,kBAAkB;IAEL,OAAO,CAAC,QAAQ,CAAC,EAAE;IACrC,OAAO,CAAC,QAAQ,CAAC,OAAO;gBADW,EAAE,EAAE,QAAQ,EAC9B,OAAO,EAAE,qBAAqB;IAGnD;;;;OAIG;IACG,YAAY,CACd,IAAI,EAAE,cAAc,EACpB,EAAE,EAAE,MAAM,EACV,WAAW,EAAE,MAAM,GACpB,OAAO,CAAC,gBAAgB,CAAC;IAyD5B;;;;;;;;;OASG;IACG,SAAS,CACX,IAAI,EAAE,cAAc,EACpB,QAAQ,EAAE,MAAM,EAAE,EAClB,WAAW,EAAE,MAAM,GACpB,OAAO,CAAC,iBAAiB,CAAC;IAoD7B,gFAAgF;IAChF,OAAO,CAAC,WAAW;IAKnB,4EAA4E;IAC5E,OAAO,CAAC,SAAS;CAYpB"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LocaleGroupService = 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 locale_registry_service_1 = require("../../locales/services/locale-registry.service");
|
|
9
|
+
/**
|
|
10
|
+
* Reads over translation groups: the per-entry locale panel (which locales
|
|
11
|
+
* exist, with status) and the records table's batched per-page summary.
|
|
12
|
+
* Read-only — writes live in {@link TranslationService}.
|
|
13
|
+
*/
|
|
14
|
+
let LocaleGroupService = class LocaleGroupService {
|
|
15
|
+
db;
|
|
16
|
+
locales;
|
|
17
|
+
constructor(db, locales) {
|
|
18
|
+
this.db = db;
|
|
19
|
+
this.locales = locales;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The locale panel for one entry: every configured locale with its
|
|
23
|
+
* sibling row (id, status, updatedAt) or null. 404 when the entry is
|
|
24
|
+
* missing (or soft-deleted) in this workspace.
|
|
25
|
+
*/
|
|
26
|
+
async entryLocales(type, id, workspaceId) {
|
|
27
|
+
const table = type.table;
|
|
28
|
+
const [row] = (await this.db
|
|
29
|
+
.select()
|
|
30
|
+
.from(type.table)
|
|
31
|
+
.where(this.liveWhere(type, (0, drizzle_orm_1.eq)(table['id'], id), workspaceId))
|
|
32
|
+
.limit(1));
|
|
33
|
+
if (!row) {
|
|
34
|
+
throw new common_1.NotFoundException(`No entry "${id}" on content type "${type.name}".`);
|
|
35
|
+
}
|
|
36
|
+
const groupId = row['localeGroupId'];
|
|
37
|
+
const siblings = (await this.db
|
|
38
|
+
.select()
|
|
39
|
+
.from(type.table)
|
|
40
|
+
.where(this.liveWhere(type, (0, drizzle_orm_1.eq)(table['localeGroupId'], groupId), workspaceId)));
|
|
41
|
+
const byLocale = new Map(siblings.map((sibling) => [sibling['locale'], sibling]));
|
|
42
|
+
return {
|
|
43
|
+
localeGroupId: groupId,
|
|
44
|
+
items: this.locales.all().map((locale) => {
|
|
45
|
+
const sibling = byLocale.get(locale.slug);
|
|
46
|
+
return {
|
|
47
|
+
locale: locale.slug,
|
|
48
|
+
// Always resolved by the registry; the fallback is for
|
|
49
|
+
// type flow only.
|
|
50
|
+
dir: locale.dir ?? 'ltr',
|
|
51
|
+
isDefault: locale.isDefault ?? false,
|
|
52
|
+
entry: sibling
|
|
53
|
+
? {
|
|
54
|
+
id: sibling['id'],
|
|
55
|
+
...(type.publishable
|
|
56
|
+
? {
|
|
57
|
+
status: sibling['status'],
|
|
58
|
+
publishedAt: this.publishedAt(sibling)
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
updatedAt: sibling['updatedAt'].toISOString()
|
|
62
|
+
}
|
|
63
|
+
: null
|
|
64
|
+
};
|
|
65
|
+
})
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Batched group summary for the records table's Locales column: for each
|
|
70
|
+
* requested group id, its live members **in a configured locale**, with
|
|
71
|
+
* per-row status — one query for the whole page.
|
|
72
|
+
*
|
|
73
|
+
* The response's keys are exactly the request's: every requested id is
|
|
74
|
+
* seeded to `[]` up front and the rows are workspace-scoped, so a group id
|
|
75
|
+
* from another workspace and one that names nothing at all come back
|
|
76
|
+
* identical. A caller learns nothing it did not already supply.
|
|
77
|
+
*/
|
|
78
|
+
async summaries(type, groupIds, workspaceId) {
|
|
79
|
+
const groups = {};
|
|
80
|
+
for (const groupId of groupIds)
|
|
81
|
+
groups[groupId] = [];
|
|
82
|
+
if (!groupIds.length)
|
|
83
|
+
return { groups };
|
|
84
|
+
const table = type.table;
|
|
85
|
+
const rows = (await this.db
|
|
86
|
+
.select()
|
|
87
|
+
.from(type.table)
|
|
88
|
+
.where(this.liveWhere(type, (0, drizzle_orm_1.inArray)(table['localeGroupId'], groupIds), workspaceId)));
|
|
89
|
+
// Config order within each group, so badge order is stable. The map
|
|
90
|
+
// doubles as the **configured-set filter**: a row in a slug the host no
|
|
91
|
+
// longer declares is not a translation the UI can offer, and reporting
|
|
92
|
+
// it here made this endpoint the one place an orphaned locale stayed
|
|
93
|
+
// visible — the locale panel iterates the configured set, coverage
|
|
94
|
+
// filters it out, and `?locale=` 400s it, so the records table's badges
|
|
95
|
+
// contradicted every other view of the same record.
|
|
96
|
+
const order = new Map(this.locales.all().map((locale, index) => [locale.slug, index]));
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
const groupId = row['localeGroupId'];
|
|
99
|
+
if (!order.has(row['locale']))
|
|
100
|
+
continue;
|
|
101
|
+
groups[groupId]?.push({
|
|
102
|
+
locale: row['locale'],
|
|
103
|
+
entryId: row['id'],
|
|
104
|
+
...(type.publishable
|
|
105
|
+
? {
|
|
106
|
+
status: row['status'],
|
|
107
|
+
publishedAt: this.publishedAt(row)
|
|
108
|
+
}
|
|
109
|
+
: {})
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
for (const members of Object.values(groups)) {
|
|
113
|
+
// Every surviving member is configured, so the lookup always hits.
|
|
114
|
+
members.sort((a, b) => order.get(a.locale) -
|
|
115
|
+
order.get(b.locale));
|
|
116
|
+
}
|
|
117
|
+
return { groups };
|
|
118
|
+
}
|
|
119
|
+
/** A row's `published_at` as an ISO string, or null when it never went live. */
|
|
120
|
+
publishedAt(row) {
|
|
121
|
+
const value = row['publishedAt'];
|
|
122
|
+
return value ? value.toISOString() : null;
|
|
123
|
+
}
|
|
124
|
+
/** A predicate AND workspace scope AND (paranoid) the not-deleted guard. */
|
|
125
|
+
liveWhere(type, predicate, workspaceId) {
|
|
126
|
+
const table = type.table;
|
|
127
|
+
return (0, drizzle_orm_1.and)(predicate, (0, drizzle_orm_1.eq)(table['workspaceId'], workspaceId), ...(type.paranoid ? [(0, drizzle_orm_1.isNull)(table['deletedAt'])] : []));
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
exports.LocaleGroupService = LocaleGroupService;
|
|
131
|
+
exports.LocaleGroupService = LocaleGroupService = tslib_1.__decorate([
|
|
132
|
+
(0, common_1.Injectable)(),
|
|
133
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
134
|
+
tslib_1.__metadata("design:paramtypes", [Object, locale_registry_service_1.LocaleRegistryService])
|
|
135
|
+
], LocaleGroupService);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { WorkspaceGrantsQuery, type ContentTypeRegistry } from '@orthacms/content-server';
|
|
3
|
+
import { ToolRegistry } from '@orthacms/tools-server';
|
|
4
|
+
import type { ToolDefinition, ToolProvider } from '@orthacms/tools-server';
|
|
5
|
+
import { LocaleRegistryService } from '../locales/services/locale-registry.service';
|
|
6
|
+
import { LocaleGroupService } from '../content/services/locale-group.service';
|
|
7
|
+
/**
|
|
8
|
+
* The i18n plugin's read tools — `i18n_locales_list` and
|
|
9
|
+
* `i18n_translations_get`.
|
|
10
|
+
*
|
|
11
|
+
* **The two land on different surfaces, which is worth reading before adding a
|
|
12
|
+
* third here.** `i18n_locales_list` is offered to MCP as well as the copilot:
|
|
13
|
+
* it returns deployment configuration, identical for every caller, with no
|
|
14
|
+
* publish state to leak. `i18n_translations_get` stays copilot-only because
|
|
15
|
+
* `LocaleGroupService.entryLocales` scopes to workspace and soft-delete but
|
|
16
|
+
* **not** publish state, so it reports a draft sibling and its status — which
|
|
17
|
+
* an MCP `read` token must not see (the endpoint's own `content_translations`
|
|
18
|
+
* is published-only). Same folder, same plugin, opposite answers; the deciding
|
|
19
|
+
* question is always what the tool would show a token holding `content:read`.
|
|
20
|
+
*
|
|
21
|
+
* **`listLocales` is what makes `admin_content_search`'s `locale` usable.**
|
|
22
|
+
* The configured locale slugs are deployment config, not content, so nothing
|
|
23
|
+
* else tells the model they exist: without this it either omits `locale` and
|
|
24
|
+
* silently searches the default, or guesses a slug and gets a tool error. One
|
|
25
|
+
* cheap call replaces both failure modes.
|
|
26
|
+
*
|
|
27
|
+
* `getTranslations` answers the question a localized CMS is actually asked —
|
|
28
|
+
* "which languages is this in, and which is missing?" — from the same
|
|
29
|
+
* `LocaleGroupService` the admin's locale panel reads, so the answer can't
|
|
30
|
+
* drift from what the editor shows.
|
|
31
|
+
*/
|
|
32
|
+
export declare class I18nCopilotToolProvider implements ToolProvider, OnModuleInit {
|
|
33
|
+
private readonly locales;
|
|
34
|
+
private readonly groups;
|
|
35
|
+
private readonly registry;
|
|
36
|
+
private readonly grants;
|
|
37
|
+
private readonly toolRegistry?;
|
|
38
|
+
constructor(locales: LocaleRegistryService, groups: LocaleGroupService, registry: ContentTypeRegistry, grants: WorkspaceGrantsQuery, toolRegistry?: ToolRegistry | undefined);
|
|
39
|
+
/**
|
|
40
|
+
* Register with the shared tool registry once the DI graph is built —
|
|
41
|
+
* the same catalogue the MCP endpoint serves, narrowed to the `copilot`
|
|
42
|
+
* surface by each tool's `surfaces`. `@Optional()` because a deployment
|
|
43
|
+
* may run neither consumer, in which case these simply go unregistered.
|
|
44
|
+
*/
|
|
45
|
+
onModuleInit(): void;
|
|
46
|
+
/** The two locale tools, in the order the model sees them. */
|
|
47
|
+
tools(): readonly ToolDefinition[];
|
|
48
|
+
/** `i18n_locales_list` — the deployment's configured locales. */
|
|
49
|
+
private listLocales;
|
|
50
|
+
/** `i18n_translations_get` — which locales an entry exists in. */
|
|
51
|
+
private getTranslations;
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=i18n-tool.provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n-tool.provider.d.ts","sourceRoot":"","sources":["../../../src/lib/copilot/i18n-tool.provider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEzE,OAAO,EAEH,oBAAoB,EACpB,KAAK,mBAAmB,EAC3B,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,6CAA6C,CAAC;AACpF,OAAO,EAAE,kBAAkB,EAAE,MAAM,0CAA0C,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,qBACa,uBAAwB,YAAW,YAAY,EAAE,YAAY;IAElE,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAEvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACX,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;gBALzB,OAAO,EAAE,qBAAqB,EAC9B,MAAM,EAAE,kBAAkB,EAE1B,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE,oBAAoB,EAChB,YAAY,CAAC,EAAE,YAAY,YAAA;IAG5D;;;;;OAKG;IACH,YAAY,IAAI,IAAI;IAIpB,8DAA8D;IAC9D,KAAK,IAAI,SAAS,cAAc,EAAE;IAIlC,iEAAiE;IACjE,OAAO,CAAC,WAAW;IAiCnB,kEAAkE;IAClE,OAAO,CAAC,eAAe;CA8D1B"}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.I18nCopilotToolProvider = 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
|
+
/**
|
|
12
|
+
* The i18n plugin's read tools — `i18n_locales_list` and
|
|
13
|
+
* `i18n_translations_get`.
|
|
14
|
+
*
|
|
15
|
+
* **The two land on different surfaces, which is worth reading before adding a
|
|
16
|
+
* third here.** `i18n_locales_list` is offered to MCP as well as the copilot:
|
|
17
|
+
* it returns deployment configuration, identical for every caller, with no
|
|
18
|
+
* publish state to leak. `i18n_translations_get` stays copilot-only because
|
|
19
|
+
* `LocaleGroupService.entryLocales` scopes to workspace and soft-delete but
|
|
20
|
+
* **not** publish state, so it reports a draft sibling and its status — which
|
|
21
|
+
* an MCP `read` token must not see (the endpoint's own `content_translations`
|
|
22
|
+
* is published-only). Same folder, same plugin, opposite answers; the deciding
|
|
23
|
+
* question is always what the tool would show a token holding `content:read`.
|
|
24
|
+
*
|
|
25
|
+
* **`listLocales` is what makes `admin_content_search`'s `locale` usable.**
|
|
26
|
+
* The configured locale slugs are deployment config, not content, so nothing
|
|
27
|
+
* else tells the model they exist: without this it either omits `locale` and
|
|
28
|
+
* silently searches the default, or guesses a slug and gets a tool error. One
|
|
29
|
+
* cheap call replaces both failure modes.
|
|
30
|
+
*
|
|
31
|
+
* `getTranslations` answers the question a localized CMS is actually asked —
|
|
32
|
+
* "which languages is this in, and which is missing?" — from the same
|
|
33
|
+
* `LocaleGroupService` the admin's locale panel reads, so the answer can't
|
|
34
|
+
* drift from what the editor shows.
|
|
35
|
+
*/
|
|
36
|
+
let I18nCopilotToolProvider = class I18nCopilotToolProvider {
|
|
37
|
+
locales;
|
|
38
|
+
groups;
|
|
39
|
+
registry;
|
|
40
|
+
grants;
|
|
41
|
+
toolRegistry;
|
|
42
|
+
constructor(locales, groups, registry, grants, toolRegistry) {
|
|
43
|
+
this.locales = locales;
|
|
44
|
+
this.groups = groups;
|
|
45
|
+
this.registry = registry;
|
|
46
|
+
this.grants = grants;
|
|
47
|
+
this.toolRegistry = toolRegistry;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Register with the shared tool registry once the DI graph is built —
|
|
51
|
+
* the same catalogue the MCP endpoint serves, narrowed to the `copilot`
|
|
52
|
+
* surface by each tool's `surfaces`. `@Optional()` because a deployment
|
|
53
|
+
* may run neither consumer, in which case these simply go unregistered.
|
|
54
|
+
*/
|
|
55
|
+
onModuleInit() {
|
|
56
|
+
this.toolRegistry?.register(this);
|
|
57
|
+
}
|
|
58
|
+
/** The two locale tools, in the order the model sees them. */
|
|
59
|
+
tools() {
|
|
60
|
+
return [this.listLocales(), this.getTranslations()];
|
|
61
|
+
}
|
|
62
|
+
/** `i18n_locales_list` — the deployment's configured locales. */
|
|
63
|
+
listLocales() {
|
|
64
|
+
return {
|
|
65
|
+
name: 'i18n_locales_list',
|
|
66
|
+
title: 'List locales',
|
|
67
|
+
description: 'List the locales this CMS is configured for, with their slugs and which one ' +
|
|
68
|
+
'is the default. Call this before passing a `locale` to admin_content_search ' +
|
|
69
|
+
'— locale slugs are deployment configuration and are not in your system ' +
|
|
70
|
+
'prompt, so guessing one produces an error and omitting one silently searches ' +
|
|
71
|
+
'the default language.',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {},
|
|
75
|
+
additionalProperties: false
|
|
76
|
+
},
|
|
77
|
+
// Locales are deployment configuration carrying no workspace data,
|
|
78
|
+
// and the HTTP route needs only a session. `content:read` is the
|
|
79
|
+
// narrowest key that means "may look at content at all", which is
|
|
80
|
+
// the only thing this list is useful for.
|
|
81
|
+
requires: [identity_server_1.PERMISSIONS.CONTENT_READ],
|
|
82
|
+
readOnly: true,
|
|
83
|
+
effect: 'read',
|
|
84
|
+
// No `surfaces` — offered to both. The tension that splits every
|
|
85
|
+
// other content tool is about *visibility of drafts* and *who a
|
|
86
|
+
// write is attributed to*, and a locale list has neither: it
|
|
87
|
+
// returns the same deployment configuration to every caller, holds
|
|
88
|
+
// no workspace data, and writes nothing. An MCP client needs it for
|
|
89
|
+
// exactly the reason the copilot does — `?locale=` on the public
|
|
90
|
+
// reads takes a slug that appears in no schema and no prompt.
|
|
91
|
+
handler: async () => ({ locales: this.locales.all() })
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** `i18n_translations_get` — which locales an entry exists in. */
|
|
95
|
+
getTranslations() {
|
|
96
|
+
return {
|
|
97
|
+
name: 'i18n_translations_get',
|
|
98
|
+
title: 'An entry’s translations',
|
|
99
|
+
description: 'For one entry of a localized type, list every configured locale and whether ' +
|
|
100
|
+
'the entry has been translated into it — with each translation’s own entry id ' +
|
|
101
|
+
'and publish state. Use it to answer “what is missing a German version?” or to ' +
|
|
102
|
+
'get the id of a sibling translation before reading it.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
typeName: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
description: 'The content type. It must be localized; a non-localized type is an error.'
|
|
109
|
+
},
|
|
110
|
+
id: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
description: 'The id of any one locale’s row, as returned by admin_content_search.'
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
required: ['typeName', 'id'],
|
|
116
|
+
additionalProperties: false
|
|
117
|
+
},
|
|
118
|
+
requires: [identity_server_1.PERMISSIONS.CONTENT_READ],
|
|
119
|
+
readOnly: true,
|
|
120
|
+
effect: 'read',
|
|
121
|
+
surfaces: ['copilot'],
|
|
122
|
+
handler: async (input, ctx) => {
|
|
123
|
+
const args = (input ?? {});
|
|
124
|
+
// The same grant re-check every content tool makes, for the
|
|
125
|
+
// same reason: `WorkspaceGuard` proved membership, not that
|
|
126
|
+
// this workspace may reach this type, and the name came from
|
|
127
|
+
// the model. `resolveI18nType` (the HTTP path) deliberately
|
|
128
|
+
// does not check grants — its callers are already behind a
|
|
129
|
+
// workspace-scoped controller reached from the admin's own UI.
|
|
130
|
+
const type = this.registry.get(args.typeName);
|
|
131
|
+
const granted = await this.grants.grantedSlugs(ctx.workspaceId);
|
|
132
|
+
if (!type || !granted.has(type.name)) {
|
|
133
|
+
throw new Error(`Unknown content type "${args.typeName}" in this workspace.`);
|
|
134
|
+
}
|
|
135
|
+
// "Not localized" is a different fact from "no such type" and
|
|
136
|
+
// is safe to say: it describes the type the caller already
|
|
137
|
+
// proved it can reach. Answering `[]` would read as "this has
|
|
138
|
+
// no other languages" when the truth is "this content is not
|
|
139
|
+
// translated at all" — the same distinction the public API
|
|
140
|
+
// draws by 400-ing `?translations=preview` on a plain type.
|
|
141
|
+
if (!type.i18n) {
|
|
142
|
+
throw new Error(`Content type "${type.name}" is not localized, so it has no translations.`);
|
|
143
|
+
}
|
|
144
|
+
return this.groups.entryLocales(type, args.id, ctx.workspaceId);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
exports.I18nCopilotToolProvider = I18nCopilotToolProvider;
|
|
150
|
+
exports.I18nCopilotToolProvider = I18nCopilotToolProvider = tslib_1.__decorate([
|
|
151
|
+
(0, common_1.Injectable)(),
|
|
152
|
+
tslib_1.__param(2, (0, content_server_1.InjectContentRegistry)()),
|
|
153
|
+
tslib_1.__param(4, (0, common_1.Optional)()),
|
|
154
|
+
tslib_1.__metadata("design:paramtypes", [locale_registry_service_1.LocaleRegistryService,
|
|
155
|
+
locale_group_service_1.LocaleGroupService, Function, content_server_1.WorkspaceGrantsQuery,
|
|
156
|
+
tools_server_1.ToolRegistry])
|
|
157
|
+
], I18nCopilotToolProvider);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type Database } from '@orthacms/database';
|
|
2
|
+
import { EntryWriterService, WorkspaceGrantsQuery, type ContentTypeRegistry } from '@orthacms/content-server';
|
|
3
|
+
import type { ProposalActor, ProposalApplier, ProposalApplyResult, ProposalTarget } from '@orthacms/copilot-domain';
|
|
4
|
+
/**
|
|
5
|
+
* Applies `i18n.entry.translate` by creating the sibling row through
|
|
6
|
+
* {@link EntryWriterService.create} — the same call `POST /api/content/:type`
|
|
7
|
+
* makes, with a `localeGroupId`.
|
|
8
|
+
*
|
|
9
|
+
* **There is no "create translation" write path to reuse, and that is the
|
|
10
|
+
* point**: joining an existing group is what `localeGroupId` on a create
|
|
11
|
+
* already means, and the bound entry extension stamps and validates it inside
|
|
12
|
+
* the write transaction. So this applier passes two extra arguments to the
|
|
13
|
+
* ordinary create rather than reaching for a second mechanism — which is also
|
|
14
|
+
* what makes a duplicate `(group, locale)` a clean 409 instead of a corrupt
|
|
15
|
+
* group.
|
|
16
|
+
*
|
|
17
|
+
* ## Why it reads the source entry first
|
|
18
|
+
*
|
|
19
|
+
* The propose tool accepts **only localized values** — a shared field is one
|
|
20
|
+
* value across the group, so asking a model to translate it is meaningless. But
|
|
21
|
+
* a create is a whole row: `coerceValues` stamps every declared field onto the
|
|
22
|
+
* bag, so a shared field nobody supplied arrives as `null` rather than as
|
|
23
|
+
* "absent". Two things then go wrong at once, and neither is obvious:
|
|
24
|
+
*
|
|
25
|
+
* 1. The new row fails its own required-field validation — `tag.slug` is
|
|
26
|
+
* required and shared, and no translation of a tag ever carries it.
|
|
27
|
+
* 2. Worse, the bound extension treats that `null` as a shared value the save
|
|
28
|
+
* *carries* and pushes it onto every sibling in the group. A published
|
|
29
|
+
* sibling is re-validated and fails, which rolls the create back — but a
|
|
30
|
+
* group whose siblings are all drafts has no such guard, and the shared
|
|
31
|
+
* field is silently blanked in every locale.
|
|
32
|
+
*
|
|
33
|
+
* So the values the create is given are the **source entry's**, with the
|
|
34
|
+
* translated ones layered on top: the same thing the admin's own "create
|
|
35
|
+
* translation" does (it sends the source's values), and the reason that path
|
|
36
|
+
* has never hit this. The shared values then match the group exactly, the
|
|
37
|
+
* extension's `IS DISTINCT FROM` predicate finds nothing to sync, and no
|
|
38
|
+
* sibling is touched at all.
|
|
39
|
+
*
|
|
40
|
+
* Read at **apply** time rather than frozen into the proposal: a shared field
|
|
41
|
+
* can be edited between drafting and applying, and the group's current value is
|
|
42
|
+
* the one that must not be clobbered.
|
|
43
|
+
*/
|
|
44
|
+
export declare class TranslationProposalApplier implements ProposalApplier {
|
|
45
|
+
private readonly registry;
|
|
46
|
+
private readonly writer;
|
|
47
|
+
private readonly grants;
|
|
48
|
+
private readonly db;
|
|
49
|
+
readonly kind: "i18n.entry.translate";
|
|
50
|
+
constructor(registry: ContentTypeRegistry, writer: EntryWriterService, grants: WorkspaceGrantsQuery, db: Database);
|
|
51
|
+
apply(input: {
|
|
52
|
+
target: ProposalTarget;
|
|
53
|
+
patch: Record<string, unknown>;
|
|
54
|
+
}, actor: ProposalActor): Promise<ProposalApplyResult>;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Applies `i18n.entry.bulk-translate` — several translations carried out as one
|
|
58
|
+
* change.
|
|
59
|
+
*
|
|
60
|
+
* **Item by item through the same `EntryWriterService.create` the single-entry
|
|
61
|
+
* applier calls**, each with its own source entry's shared values inherited: a
|
|
62
|
+
* batch is a way of asking for N translations, not a second way of writing one.
|
|
63
|
+
* Sequentially rather than concurrently, because every write takes the
|
|
64
|
+
* workspace's shared content lock and (on a localized type) an advisory lock
|
|
65
|
+
* over the translation group — a batch translating one record into five
|
|
66
|
+
* languages contends with itself by construction, so a fan-out would buy
|
|
67
|
+
* nothing but lock waits.
|
|
68
|
+
*
|
|
69
|
+
* **It stops at the first failure and throws**, exactly as the content plugin's
|
|
70
|
+
* bulk applier does. A proposal is one row with one status, so there is no
|
|
71
|
+
* per-item verdict to render; carrying on would grow the number of rows written
|
|
72
|
+
* under a receipt that then reports failure. The error names how many landed,
|
|
73
|
+
* which is what the model reads back to the user.
|
|
74
|
+
*/
|
|
75
|
+
export declare class BulkTranslationProposalApplier implements ProposalApplier {
|
|
76
|
+
private readonly registry;
|
|
77
|
+
private readonly writer;
|
|
78
|
+
private readonly grants;
|
|
79
|
+
private readonly db;
|
|
80
|
+
readonly kind: "i18n.entry.bulk-translate";
|
|
81
|
+
constructor(registry: ContentTypeRegistry, writer: EntryWriterService, grants: WorkspaceGrantsQuery, db: Database);
|
|
82
|
+
apply(input: {
|
|
83
|
+
target: ProposalTarget;
|
|
84
|
+
patch: Record<string, unknown>;
|
|
85
|
+
}, actor: ProposalActor): Promise<ProposalApplyResult>;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=translation-proposal.applier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"translation-proposal.applier.d.ts","sourceRoot":"","sources":["../../../src/lib/copilot/translation-proposal.applier.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AACnE,OAAO,EACH,kBAAkB,EAElB,oBAAoB,EAIpB,KAAK,mBAAmB,EAC3B,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EACR,aAAa,EACb,eAAe,EACf,mBAAmB,EACnB,cAAc,EACjB,MAAM,0BAA0B,CAAC;AAMlC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,qBACa,0BAA2B,YAAW,eAAe;IAK1D,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACL,OAAO,CAAC,QAAQ,CAAC,EAAE;IAPzC,QAAQ,CAAC,IAAI,yBAAyC;gBAIjC,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE,kBAAkB,EAC1B,MAAM,EAAE,oBAAoB,EACV,EAAE,EAAE,QAAQ;IAG7C,KAAK,CACP,KAAK,EAAE;QAAE,MAAM,EAAE,cAAc,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EACjE,KAAK,EAAE,aAAa,GACrB,OAAO,CAAC,mBAAmB,CAAC;CAkDlC;AA0DD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBACa,8BAA+B,YAAW,eAAe;IAK9D,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACL,OAAO,CAAC,QAAQ,CAAC,EAAE;IAPzC,QAAQ,CAAC,IAAI,8BAAuC;gBAI/B,QAAQ,EAAE,mBAAmB,EAC7B,MAAM,EAAE,kBAAkB,EAC1B,MAAM,EAAE,oBAAoB,EACV,EAAE,EAAE,QAAQ;IAG7C,KAAK,CACP,KAAK,EAAE;QAAE,MAAM,EAAE,cAAc,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,EACjE,KAAK,EAAE,aAAa,GACrB,OAAO,CAAC,mBAAmB,CAAC;CA8FlC"}
|