@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +15 -0
  6. package/dist/lib/content/controllers/get-entry-locales.controller.d.ts +15 -0
  7. package/dist/lib/content/controllers/get-entry-locales.controller.d.ts.map +1 -0
  8. package/dist/lib/content/controllers/get-entry-locales.controller.js +45 -0
  9. package/dist/lib/content/controllers/locale-summary.controller.d.ts +30 -0
  10. package/dist/lib/content/controllers/locale-summary.controller.d.ts.map +1 -0
  11. package/dist/lib/content/controllers/locale-summary.controller.js +61 -0
  12. package/dist/lib/content/controllers/resolve-type.d.ts +9 -0
  13. package/dist/lib/content/controllers/resolve-type.d.ts.map +1 -0
  14. package/dist/lib/content/controllers/resolve-type.js +20 -0
  15. package/dist/lib/content/dto/locale-summary.dto.d.ts +10 -0
  16. package/dist/lib/content/dto/locale-summary.dto.d.ts.map +1 -0
  17. package/dist/lib/content/dto/locale-summary.dto.js +30 -0
  18. package/dist/lib/content/locale-group-lock.d.ts +34 -0
  19. package/dist/lib/content/locale-group-lock.d.ts.map +1 -0
  20. package/dist/lib/content/locale-group-lock.js +38 -0
  21. package/dist/lib/content/services/entry-locale-extension.service.d.ts +188 -0
  22. package/dist/lib/content/services/entry-locale-extension.service.d.ts.map +1 -0
  23. package/dist/lib/content/services/entry-locale-extension.service.js +662 -0
  24. package/dist/lib/content/services/locale-group.service.d.ts +87 -0
  25. package/dist/lib/content/services/locale-group.service.d.ts.map +1 -0
  26. package/dist/lib/content/services/locale-group.service.js +135 -0
  27. package/dist/lib/copilot/i18n-tool.provider.d.ts +53 -0
  28. package/dist/lib/copilot/i18n-tool.provider.d.ts.map +1 -0
  29. package/dist/lib/copilot/i18n-tool.provider.js +157 -0
  30. package/dist/lib/copilot/translation-proposal.applier.d.ts +87 -0
  31. package/dist/lib/copilot/translation-proposal.applier.d.ts.map +1 -0
  32. package/dist/lib/copilot/translation-proposal.applier.js +242 -0
  33. package/dist/lib/copilot/translation-proposal.provider.d.ts +114 -0
  34. package/dist/lib/copilot/translation-proposal.provider.d.ts.map +1 -0
  35. package/dist/lib/copilot/translation-proposal.provider.js +419 -0
  36. package/dist/lib/domain/errors/index.d.ts +4 -0
  37. package/dist/lib/domain/errors/index.d.ts.map +1 -0
  38. package/dist/lib/domain/errors/index.js +9 -0
  39. package/dist/lib/domain/errors/invalid-locale-set.error.d.ts +10 -0
  40. package/dist/lib/domain/errors/invalid-locale-set.error.d.ts.map +1 -0
  41. package/dist/lib/domain/errors/invalid-locale-set.error.js +16 -0
  42. package/dist/lib/domain/errors/invalid-locale.error.d.ts +10 -0
  43. package/dist/lib/domain/errors/invalid-locale.error.d.ts.map +1 -0
  44. package/dist/lib/domain/errors/invalid-locale.error.js +16 -0
  45. package/dist/lib/domain/errors/unknown-locale.error.d.ts +11 -0
  46. package/dist/lib/domain/errors/unknown-locale.error.d.ts.map +1 -0
  47. package/dist/lib/domain/errors/unknown-locale.error.js +18 -0
  48. package/dist/lib/domain/locale-policy.d.ts +42 -0
  49. package/dist/lib/domain/locale-policy.d.ts.map +1 -0
  50. package/dist/lib/domain/locale-policy.js +61 -0
  51. package/dist/lib/domain/value-objects/locale-set.d.ts +41 -0
  52. package/dist/lib/domain/value-objects/locale-set.d.ts.map +1 -0
  53. package/dist/lib/domain/value-objects/locale-set.js +89 -0
  54. package/dist/lib/domain/value-objects/locale.d.ts +55 -0
  55. package/dist/lib/domain/value-objects/locale.d.ts.map +1 -0
  56. package/dist/lib/domain/value-objects/locale.js +73 -0
  57. package/dist/lib/i18n.constants.d.ts +33 -0
  58. package/dist/lib/i18n.constants.d.ts.map +1 -0
  59. package/dist/lib/i18n.constants.js +89 -0
  60. package/dist/lib/i18n.module.d.ts +14 -0
  61. package/dist/lib/i18n.module.d.ts.map +1 -0
  62. package/dist/lib/i18n.module.js +82 -0
  63. package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts +23 -0
  64. package/dist/lib/insights/http/controllers/localization-coverage.controller.d.ts.map +1 -0
  65. package/dist/lib/insights/http/controllers/localization-coverage.controller.js +46 -0
  66. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts +59 -0
  67. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.d.ts.map +1 -0
  68. package/dist/lib/insights/infrastructure/queries/localization-coverage.query.js +179 -0
  69. package/dist/lib/insights/types/i18n-insights-view.d.ts +83 -0
  70. package/dist/lib/insights/types/i18n-insights-view.d.ts.map +1 -0
  71. package/dist/lib/insights/types/i18n-insights-view.js +10 -0
  72. package/dist/lib/locales/controllers/list-locales.controller.d.ts +26 -0
  73. package/dist/lib/locales/controllers/list-locales.controller.d.ts.map +1 -0
  74. package/dist/lib/locales/controllers/list-locales.controller.js +48 -0
  75. package/dist/lib/locales/services/locale-registry.service.d.ts +26 -0
  76. package/dist/lib/locales/services/locale-registry.service.d.ts.map +1 -0
  77. package/dist/lib/locales/services/locale-registry.service.js +70 -0
  78. package/dist/lib/locales/services/orphaned-locale.checker.d.ts +60 -0
  79. package/dist/lib/locales/services/orphaned-locale.checker.d.ts.map +1 -0
  80. package/dist/lib/locales/services/orphaned-locale.checker.js +114 -0
  81. package/dist/lib/types/locale.d.ts +68 -0
  82. package/dist/lib/types/locale.d.ts.map +1 -0
  83. package/dist/lib/types/locale.js +8 -0
  84. package/dist/lib/utils/i18n-plugin.d.ts +41 -0
  85. package/dist/lib/utils/i18n-plugin.d.ts.map +1 -0
  86. package/dist/lib/utils/i18n-plugin.js +53 -0
  87. 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"}