pgkiln 0.84.0 → 0.85.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 (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -2,10 +2,11 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { featureStates } from './features.ts';
5
+ import { featHelp, featLabel, featureStates } from './features.ts';
6
6
  import { appOr404 } from './forms.ts';
7
7
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
8
- import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
8
+ import { kindHelp, kindLabel, NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
9
+ import { scopedTx as tx } from './i18n.ts';
9
10
 
10
11
  // (sprint 137, AP-3) Create Page like APEX's: a grid of page types with small previews (tab Component), the
11
12
  // features (tab Feature, features.ts) and Create page as copy (tab Copy), at /builder/apps/:id/create-page.
@@ -14,12 +15,12 @@ import { NO_TABLE, PAGE_KINDS, wizardTables } from './wizards.ts';
14
15
  // forms: no JavaScript needed. The previews are inline SVG drawn with classes (public/builder.css), no styles.
15
16
 
16
17
  /** The grid's order (APEX's names), each with the step it starts with. */
17
- const GRID: { key: string; label: string; help: string; table: boolean }[] = [
18
- { key: 'blank', label: 'Blank page', help: 'An empty page to build in the Page Designer.', table: false },
18
+ const GRID = (): { key: string; label: string; help: string; table: boolean }[] => [
19
+ { key: 'blank', label: tx('cpg.blank_page'), help: tx('cpg.an_empty_page_to_build'), table: false },
19
20
  ...['calendar', 'cards', 'chart', 'comments', 'content_row', 'dashboard', 'data_load', 'facets', 'form', 'grid', 'report', 'map',
20
21
  'master_detail', 'media_list', 'report_form', 'search', 'smart_filters', 'timeline', 'tree', 'tasks', 'wizard', 'workflows'].map((k) => {
21
22
  const d = PAGE_KINDS.find((x) => x[0] === k)!;
22
- return { key: k, label: d[1], help: d[2], table: !NO_TABLE.has(k) };
23
+ return { key: k, label: kindLabel(k, d[1]), help: kindHelp(k, d[2]), table: !NO_TABLE.has(k) };
23
24
  }),
24
25
  ];
25
26
 
@@ -66,7 +67,7 @@ const PREVIEWS: Record<string, string> = {
66
67
  export const preview = (key: string): Raw =>
67
68
  raw(`<svg class="cp-svg" viewBox="0 0 80 50" aria-hidden="true" focusable="false">${PREVIEWS[key] ?? PREVIEWS.blank}</svg>`);
68
69
 
69
- const TABS: [string, string][] = [['component', 'Component'], ['feature', 'Feature'], ['copy', 'Copy']];
70
+ const TABS: [string, string][] = [['component', tx('cpg.component')], ['feature', tx('cpg.feature')], ['copy', tx('cpg.copy')]];
70
71
 
71
72
  export async function createPageRoutes(app: FastifyInstance) {
72
73
  app.get(`${BASE}/apps/:id/create-page`, async (req: Req, reply) => {
@@ -76,8 +77,8 @@ export async function createPageRoutes(app: FastifyInstance) {
76
77
  if (!a) return reply.code(404).send('Not found');
77
78
  const q = req.query as Record<string, string | undefined>;
78
79
  const tab = TABS.some(([t]) => t === q.tab) ? q.tab! : 'component';
79
- const type = GRID.find((g) => g.key === q.type);
80
- const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
80
+ const type = GRID().find((g) => g.key === q.type);
81
+ const crumbs: [string, string?][] = [[tx('cpg.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', type ? `${BASE}/apps/${a.id}/create-page` : undefined]];
81
82
  const nextPage = async () => {
82
83
  const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
83
84
  let n = Math.max(0, ...[...used].filter((x) => x < 9000)) + 1;
@@ -91,49 +92,49 @@ export async function createPageRoutes(app: FastifyInstance) {
91
92
  if (type.key === 'blank') {
92
93
  body = html`<form method="post" action="${BASE}/apps/${a.id}/pages">${csrf(s)}
93
94
  <div class="form-grid">
94
- ${input('page_no', 'Page number', await nextPage(), { type: 'number', required: true })}
95
- ${input('name', 'Name', '', { required: true })}
96
- ${select('mode', 'Page mode', 'normal', [['normal', 'Normal page'], ['modal', 'Modal dialog']])}
97
- ${input('parent_page', 'Breadcrumb parent', a.home_page ?? '', { type: 'number' })}
95
+ ${input('page_no', tx('cpg.page_number'), await nextPage(), { type: 'number', required: true })}
96
+ ${input('name', tx('cpg.name'), '', { required: true })}
97
+ ${select('mode', tx('cpg.page_mode'), 'normal', [['normal', tx('cpg.normal_page')], ['modal', tx('cpg.modal_dialog')]])}
98
+ ${input('parent_page', tx('cpg.breadcrumb_parent'), a.home_page ?? '', { type: 'number' })}
98
99
  </div>
99
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Create page</button></div>
100
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.create_page')}</button></div>
100
101
  </form>`;
101
102
  } else {
102
103
  const tables = await wizardTables(a.db_role);
103
104
  body = html`<form method="get" action="${BASE}/apps/${a.id}/wizard">
104
105
  <input type="hidden" name="kind" value="${type.key}">
105
106
  <div class="form-grid">
106
- ${select('table', 'Table or view', '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
107
+ ${select('table', tx('cpg.table_or_view'), '', tables.map((t): [string, string] => [t.t, t.access ? t.t : `${t.t} (no access for ${a.db_role})`]),
107
108
  'The next step proposes the columns, keys and foreign keys from the database catalog.')}
108
109
  </div>
109
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">Back</a><button class="btn btn-hot">Next</button></div>
110
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.next')}</button></div>
110
111
  </form>`;
111
112
  }
112
113
  const main = html`${appHeader(a, 'pages', s)}
113
114
  ${region(type.label, html`<div class="cp-step"><span class="cp-preview cp-preview-lg">${preview(type.key)}</span><p class="muted">${type.help}</p></div>${body}`)}`;
114
- return send(reply, s, shell(s, `Create page · ${a.name}`, [...crumbs, [type.label]], main));
115
+ return send(reply, s, shell(s, tx('cpg.page_title', { app: a.name }), [...crumbs, [type.label]], main));
115
116
  }
116
117
 
117
- const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="Create page">${TABS.map(([t, l]) =>
118
+ const tabs = html`<nav class="gal-tabs segmented cp-tabs" aria-label="${tx('cpg.create_page')}">${TABS.map(([t, l]) =>
118
119
  html`<a href="${BASE}/apps/${a.id}/create-page?tab=${t}"${t === tab ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
119
120
  let body: Raw;
120
121
  if (tab === 'component') {
121
- const href = (g: (typeof GRID)[number]) =>
122
+ const href = (g: ReturnType<typeof GRID>[number]) =>
122
123
  g.key !== 'blank' && !g.table ? `${BASE}/apps/${a.id}/wizard?kind=${g.key}` : `${BASE}/apps/${a.id}/create-page?type=${g.key}`;
123
- body = html`<p class="muted u-mt0">Choose a page type. Types on a table ask for it next; every type proposes its settings and opens the new page in the Page Designer.</p>
124
- <ul class="cp-grid">${GRID.map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
125
- <li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>Create pages with AI</b><small>Describe the pages in your own words; check the proposals before they are made.</small></a></li></ul>`;
124
+ body = html`<p class="muted u-mt0">${tx('cpg.choose_a_page_type_types')}</p>
125
+ <ul class="cp-grid">${GRID().map((g) => html`<li><a class="cp-type" href="${href(g)}"><span class="cp-preview">${preview(g.key)}</span><b>${g.label}</b><small>${g.help}</small></a></li>`)}
126
+ <li><a class="cp-type" href="${BASE}/apps/${a.id}/ai-pages"><span class="cp-preview">${preview('ai')}</span><b>${tx('cpg.create_pages_with_ai')}</b><small>${tx('cpg.describe_the_pages_in_your')}</small></a></li></ul>`;
126
127
  } else if (tab === 'feature') {
127
128
  const states = await featureStates(a.id);
128
- body = html`<p class="muted u-mt0">Features add pages and settings to the application. Each lists what it adds before it creates anything; an installed feature is greyed out.</p>
129
+ body = html`<p class="muted u-mt0">${tx('cpg.features_add_pages_and_settings')}</p>
129
130
  <ul class="cp-grid">${states.map(({ f, installed, blocked }) => html`<li><a class="cp-type${installed || blocked ? ' cp-done' : ''}" href="${BASE}/apps/${a.id}/features/${f.key}">
130
- <span class="cp-feature-icon">${icon(f.icon)}</span><b>${f.label}</b><small>${f.help}</small>
131
- ${installed ? html`<span class="tag">Installed</span>` : blocked ? html`<span class="tag">Not available</span>` : ''}</a></li>`)}</ul>`;
131
+ <span class="cp-feature-icon">${icon(f.icon)}</span><b>${featLabel(f)}</b><small>${featHelp(f)}</small>
132
+ ${installed ? html`<span class="tag">${tx('cpg.installed')}</span>` : blocked ? html`<span class="tag">${tx('cpg.not_available')}</span>` : ''}</a></li>`)}</ul>`;
132
133
  } else {
133
134
  body = await copyStep1(a, s.username!);
134
135
  }
135
- const main = html`${appHeader(a, 'pages', s)}${region('Create page', html`${tabs}${body}`)}`;
136
- return send(reply, s, shell(s, `Create page · ${a.name}`, crumbs, main));
136
+ const main = html`${appHeader(a, 'pages', s)}${region(tx('cpg.create_page'), html`${tabs}${body}`)}`;
137
+ return send(reply, s, shell(s, tx('cpg.page_title', { app: a.name }), crumbs, main));
137
138
  });
138
139
 
139
140
  // ---------------------------------------------------------------- Create page as copy
@@ -146,17 +147,17 @@ export async function createPageRoutes(app: FastifyInstance) {
146
147
  const q = req.query as Record<string, string | undefined>;
147
148
  const from = await sourceApp(a.id, q.from);
148
149
  if (!from) {
149
- flash(s, 'Choose an application of this workspace.', 'error');
150
+ flash(s, tx('cpg.choose_an_application_of_this'), 'error');
150
151
  return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
151
152
  }
152
153
  const pages = (await owner.query<{ id: number; page_no: number; name: string }>('select id, page_no, name from meta.page where app_id = $1 order by page_no', [from.id])).rows;
153
154
  const page = pages.find((p) => String(p.page_no) === q.page);
154
- const crumbs: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=copy`], ['Copy']];
155
+ const crumbs: [string, string?][] = [[tx('cpg.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('cpg.create_page'), `${BASE}/apps/${a.id}/create-page?tab=copy`], [tx('cpg.copy')]];
155
156
  let body: Raw;
156
157
  if (!page) {
157
158
  body = html`<form method="get" action="${BASE}/apps/${a.id}/copy-page"><input type="hidden" name="from" value="${from.id}">
158
- <div class="form-grid">${select('page', `Page of ${from.name}`, '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
159
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">Back</a><button class="btn btn-hot">Next</button></div></form>`;
159
+ <div class="form-grid">${select('page', tx('cpg.page_of', { app: from.name }), '', pages.map((p): [string, string] => [String(p.page_no), `${p.page_no} ${p.name}`]))}</div>
160
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=copy">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.next')}</button></div></form>`;
160
161
  } else {
161
162
  const refs = (await owner.query<{ kind: string; name: string; in_target: boolean }>('select * from meta.page_shared_refs($1, $2)', [page.id, a.id])).rows;
162
163
  const used = new Set((await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((r) => r.page_no as number));
@@ -166,27 +167,27 @@ export async function createPageRoutes(app: FastifyInstance) {
166
167
  const options = async (kind: string) => (await owner.query<{ n: string }>(SHARED_NAMES[kind], [a.id])).rows.map((r) => r.n);
167
168
  const mapRows = await Promise.all(refs.map(async (r) => {
168
169
  const names = await options(r.kind);
169
- const opts: [string, string][] = [...(same || r.in_target ? [] : [['', 'Copy it into this application'] as [string, string]]), ...names.map((n): [string, string] => [n, `Use ${n}`])];
170
- return html`<tr><td>${KIND_LABELS[r.kind] ?? r.kind}</td><td><code>${r.name}</code></td><td>
171
- <select name="map:${r.kind}:${r.name}" aria-label="${KIND_LABELS[r.kind] ?? r.kind} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
170
+ const opts: [string, string][] = [...(same || r.in_target ? [] : [['', tx('cpg.copy_it_into_this_application')] as [string, string]]), ...names.map((n): [string, string] => [n, tx('cpg.use_name', { name: n })])];
171
+ return html`<tr><td>${kindName(r.kind)}</td><td><code>${r.name}</code></td><td>
172
+ <select name="map:${r.kind}:${r.name}" aria-label="${kindName(r.kind)} ${r.name}">${opts.map(([v, l]) => html`<option value="${v}"${v === (r.in_target || same ? r.name : '') ? raw(' selected') : ''}>${l}</option>`)}</select></td></tr>`;
172
173
  }));
173
- body = html`<p class="muted u-mt0">Page ${page.page_no} <b>${page.name}</b> of ${from.name}: its regions, items, buttons, dynamic actions, validations, processes, computations and branches. Items named P${page.page_no}_… are renamed to the new number, also where the page's code refers to them.</p>
174
+ body = html`<p class="muted u-mt0">${tx.rich('cpg.copy_intro', { n: page.page_no, name: html`<b>${page.name}</b>`, app: from.name, prefix: `P${page.page_no}_` })}</p>
174
175
  <form method="post" action="${BASE}/apps/${a.id}/copy-page">${csrf(s)}
175
176
  <input type="hidden" name="from" value="${from.id}"><input type="hidden" name="page" value="${page.page_no}">
176
177
  <div class="form-grid">
177
- ${input('new_page', 'New page number', no, { type: 'number', required: true })}
178
- ${input('name', 'Name', page.name, { required: true })}
178
+ ${input('new_page', tx('cpg.new_page_number'), no, { type: 'number', required: true })}
179
+ ${input('name', tx('cpg.name'), page.name, { required: true })}
179
180
  </div>
180
181
  ${refs.length
181
- ? html`<h3>Shared components</h3><p class="muted small">The page refers to these by name. ${same ? 'In the same application they stay as they are, or point to another of the same kind.' : 'Use one of this application\'s, or copy it from the other application.'}</p>
182
- <div class="table-wrap"><table class="report"><thead><tr><th>Kind</th><th>Name</th><th>In this application</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
183
- : html`<p class="muted">The page uses no shared components.</p>`}
184
- ${same ? '' : html`<p class="muted small">Its SQL runs as this application's database role <code>${a.db_role ?? '(none)'}</code>: grant it what ${from.name}'s role may read and change.</p>`}
185
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">Back</a><button class="btn btn-hot">Copy page</button></div>
182
+ ? html`<h3>${tx('cpg.shared_components')}</h3><p class="muted small">${tx('cpg.refers_by_name')} ${same ? tx('cpg.in_the_same_application_they') : tx('cpg.use_one_of_this_application')}</p>
183
+ <div class="table-wrap"><table class="report"><thead><tr><th>${tx('cpg.kind')}</th><th>${tx('cpg.name')}</th><th>${tx('cpg.in_this_application')}</th></tr></thead><tbody>${mapRows}</tbody></table></div>`
184
+ : html`<p class="muted">${tx('cpg.the_page_uses_no_shared')}</p>`}
185
+ ${same ? '' : html`<p class="muted small">${tx.rich('cpg.sql_runs_as', { role: html`<code>${a.db_role ?? tx('cpg.none')}</code>`, app: from.name })}</p>`}
186
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/copy-page?from=${from.id}">${tx('cpg.back')}</a><button class="btn btn-hot">${tx('cpg.copy_page')}</button></div>
186
187
  </form>`;
187
188
  }
188
- const main = html`${appHeader(a, 'pages', s)}${region('Create page as copy', body)}`;
189
- return send(reply, s, shell(s, `Copy page · ${a.name}`, crumbs, main));
189
+ const main = html`${appHeader(a, 'pages', s)}${region(tx('cpg.create_page_as_copy'), body)}`;
190
+ return send(reply, s, shell(s, tx('cpg.copy_title', { app: a.name }), crumbs, main));
190
191
  });
191
192
 
192
193
  app.post(`${BASE}/apps/:id/copy-page`, async (req: Req, reply) => {
@@ -198,15 +199,15 @@ export async function createPageRoutes(app: FastifyInstance) {
198
199
  const from = await sourceApp(a.id, String(b.from ?? ''));
199
200
  const retry = `${BASE}/apps/${a.id}/copy-page?from=${encodeURIComponent(String(b.from ?? ''))}&page=${encodeURIComponent(String(b.page ?? ''))}`;
200
201
  if (!from) {
201
- flash(s, 'Choose an application of this workspace.', 'error');
202
+ flash(s, tx('cpg.choose_an_application_of_this'), 'error');
202
203
  return back(reply, s, `${BASE}/apps/${a.id}/create-page?tab=copy`);
203
204
  }
204
205
  try {
205
206
  const page = /^\d{1,6}$/.test(String(b.page ?? '')) ? Number(b.page) : NaN;
206
207
  const no = /^\d{1,6}$/.test(String(b.new_page ?? '').trim()) ? Number(String(b.new_page).trim()) : NaN;
207
- if (!(no >= 1)) throw new Error('Enter a page number.');
208
+ if (!(no >= 1)) throw new Error(tx('cpg.enter_a_page_number'));
208
209
  const name = String(b.name ?? '').trim().slice(0, 200);
209
- if (!name) throw new Error('Enter a name.');
210
+ if (!name) throw new Error(tx('cpg.enter_a_name'));
210
211
  // map:<kind>:<NAME> → a name of the target's (empty: copy); only known kinds pass
211
212
  const map: Record<string, string> = {};
212
213
  for (const [k, v] of Object.entries(b)) {
@@ -214,7 +215,7 @@ export async function createPageRoutes(app: FastifyInstance) {
214
215
  if (m && Object.hasOwn(SHARED_NAMES, m[1]) && typeof v === 'string') map[`${m[1]}:${m[2]}`] = v.slice(0, 100);
215
216
  }
216
217
  const id = await owner.tx(async (c) => (await c.query('select meta.copy_page($1, $2, $3, $4, $5, $6::jsonb) as id', [from.id, page, a.id, no, name, JSON.stringify(map)])).rows[0].id as number);
217
- flash(s, `Page ${no} "${name}" created as a copy of page ${page}${from.id === a.id ? '' : ` of ${from.name}`}.`);
218
+ flash(s, from.id === a.id ? tx('cpg.copied', { n: no, name, page }) : tx('cpg.copied_from', { n: no, name, page, app: from.name }));
218
219
  return back(reply, s, `${BASE}/pages/${id}`);
219
220
  } catch (e) {
220
221
  flash(s, (e as Error).message, 'error');
@@ -227,6 +228,7 @@ const KIND_LABELS: Record<string, string> = {
227
228
  authz_scheme: 'Authorization scheme', build_option: 'Build option', lov: 'List of values', list: 'List',
228
229
  template_component: 'Template component', search_config: 'Search configuration', data_load_def: 'Data load definition',
229
230
  };
231
+ const kindName = (k: string) => (Object.hasOwn(KIND_LABELS, k) ? tx.dyn(`cpg.k.${k}`, KIND_LABELS[k]) : k);
230
232
  /** The target application's names of each kind (the choices of the mapping). */
231
233
  const SHARED_NAMES: Record<string, string> = {
232
234
  authz_scheme: 'select name as n from meta.authz_scheme where app_id = $1 order by 1',
@@ -251,9 +253,9 @@ async function copyStep1(a: { id: number; name: string }, _user: string): Promis
251
253
  const apps = (await owner.query<{ id: number; name: string; alias: string }>(
252
254
  'select id, name, alias from meta.app where meta.app_workspace(id) = meta.app_workspace($1) order by id <> $1, lower(name)', [a.id],
253
255
  )).rows;
254
- return html`<p class="muted u-mt0">Copy a page of this application or another application of the workspace, under a new number and name. The shared components it uses (lists of values, authorization schemes, lists…) are mapped to this application's or copied.</p>
256
+ return html`<p class="muted u-mt0">${tx('cpg.copy_a_page_of_this')}</p>
255
257
  <form method="get" action="${BASE}/apps/${a.id}/copy-page">
256
- <div class="form-grid">${select('from', 'Copy from application', String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
257
- <div class="buttons"><button class="btn btn-hot">Next</button></div>
258
+ <div class="form-grid">${select('from', tx('cpg.copy_from_application'), String(a.id), apps.map((x): [string, string] => [String(x.id), x.id === a.id ? `${x.name} (this application)` : `${x.name} (${x.alias})`]))}</div>
259
+ <div class="buttons"><button class="btn btn-hot">${tx('cpg.next')}</button></div>
258
260
  </form>`;
259
261
  }
@@ -4,6 +4,7 @@ import { html, raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { BASE, developer, region, send, shell, type Req } from './ui.ts';
6
6
  import { currentWorkspace } from './workspaces.ts';
7
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
7
8
 
8
9
  // Cross-application reports (sprint 61; APEX: Workspace Utilities → Cross
9
10
  // Application Reports): one read-only report per topic over every application
@@ -11,9 +12,10 @@ import { currentWorkspace } from './workspaces.ts';
11
12
  // returns app_id and app (the name) first; those become a link.
12
13
 
13
14
  interface Report {
14
- title: string;
15
- about: string;
16
- columns: [key: string, label: string, cls?: 'num'][];
15
+ /** Message keys (src/builder/i18n): the title, the introduction and the column labels. */
16
+ title: BuilderKey;
17
+ about: BuilderKey;
18
+ columns: [key: string, label: BuilderKey, cls?: 'num'][];
17
19
  sql: string;
18
20
  }
19
21
 
@@ -22,56 +24,56 @@ const IN_WS = 'meta.app_workspace(a.id) = $1';
22
24
 
23
25
  export const REPORTS: Record<string, Report> = {
24
26
  attributes: {
25
- title: 'Application attributes',
26
- about: 'The main settings of every application: type, sign-in, availability, build status, debug level, language and database role.',
27
- columns: [['alias', 'Alias'], ['app_type', 'Type'], ['authentication', 'Sign-in'], ['availability', 'Availability'], ['build_status', 'Build status'], ['debug_level', 'Debug', 'num'], ['language', 'Language'], ['db_role', 'Database role'], ['updated', 'Updated']],
27
+ title: 'xap.attributes.title',
28
+ about: 'xap.attributes.about',
29
+ columns: [['alias', 'xap.col.alias'], ['app_type', 'xap.col.type'], ['authentication', 'xap.col.sign_in'], ['availability', 'xap.col.availability'], ['build_status', 'xap.col.build_status'], ['debug_level', 'xap.col.debug', 'num'], ['language', 'xap.col.language'], ['db_role', 'xap.col.database_role'], ['updated', 'xap.col.updated']],
28
30
  sql: `select a.id as app_id, a.name as app, a.alias, a.app_type, a.authentication, a.availability, a.build_status, a.debug_level,
29
31
  a.language, coalesce(a.db_role, '(runtime connection)') as db_role, to_char(a.updated_at, 'YYYY-MM-DD HH24:MI') as updated
30
32
  from meta.app a where ${IN_WS} order by lower(a.name)`,
31
33
  },
32
34
  'build-options': {
33
- title: 'Build options',
34
- about: 'The build options of every application and whether their components are included.',
35
- columns: [['name', 'Build option'], ['status', 'Status'], ['description', 'Description']],
35
+ title: 'xap.build_options.title',
36
+ about: 'xap.build_options.about',
37
+ columns: [['name', 'xap.col.build_option'], ['status', 'xap.col.status'], ['description', 'xap.col.description']],
36
38
  sql: `select a.id as app_id, a.name as app, b.name, b.status, b.description
37
39
  from meta.app a join meta.build_option b on b.app_id = a.id where ${IN_WS} order by lower(a.name), b.name`,
38
40
  },
39
41
  comments: {
40
- title: 'Developer comments',
41
- about: 'The developers\' comments on applications and pages, newest first.',
42
- columns: [['page_no', 'Page', 'num'], ['author', 'Author'], ['created', 'Written'], ['body', 'Comment']],
42
+ title: 'xap.comments.title',
43
+ about: 'xap.comments.about',
44
+ columns: [['page_no', 'xap.col.page', 'num'], ['author', 'xap.col.author'], ['created', 'xap.col.written'], ['body', 'xap.col.comment']],
43
45
  sql: `select a.id as app_id, a.name as app, c.page_no, c.author, to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as created, left(c.body, 500) as body
44
46
  from meta.app a join meta.dev_comment c on c.app_id = a.id where ${IN_WS} order by c.created_at desc limit 500`,
45
47
  },
46
48
  authentication: {
47
- title: 'Authentication',
48
- about: 'How people sign in to every application: the sign-in method, password sign-in, identity providers and LDAP directories, access control, the login page and Forgot password.',
49
- columns: [['authentication', 'Sign-in'], ['local_login', 'Passwords'], ['providers', 'Identity providers'], ['ldap', 'LDAP directories'], ['access_control', 'Access'], ['remember_me_days', 'Keep me signed in (days)', 'num'], ['login_page', 'Login page', 'num'], ['password_reset', 'Forgot password']],
49
+ title: 'xap.authentication.title',
50
+ about: 'xap.authentication.about',
51
+ columns: [['authentication', 'xap.col.sign_in'], ['local_login', 'xap.col.passwords'], ['providers', 'xap.col.identity_providers'], ['ldap', 'xap.col.ldap_directories'], ['access_control', 'xap.col.access'], ['remember_me_days', 'xap.col.keep_me_signed_in_days', 'num'], ['login_page', 'xap.col.login_page', 'num'], ['password_reset', 'xap.col.forgot_password']],
50
52
  sql: `select a.id as app_id, a.name as app, a.authentication, case when a.local_login then 'yes' else 'no' end as local_login,
51
53
  array_to_string(a.sso_providers, ', ') as providers, array_to_string(a.ldap_directories, ', ') as ldap,
52
54
  a.access_control, a.remember_me_days, a.login_page, case when a.password_reset then 'yes' else 'no' end as password_reset
53
55
  from meta.app a where ${IN_WS} order by lower(a.name)`,
54
56
  },
55
57
  authorization: {
56
- title: 'Authorization schemes',
57
- about: 'The authorization schemes of every application: their type and what they check.',
58
- columns: [['name', 'Scheme'], ['type', 'Type'], ['value', 'Checks'], ['error_message', 'Error message']],
58
+ title: 'xap.authorization.title',
59
+ about: 'xap.authorization.about',
60
+ columns: [['name', 'xap.col.scheme'], ['type', 'xap.col.type'], ['value', 'xap.col.checks'], ['error_message', 'xap.col.error_message']],
59
61
  sql: `select a.id as app_id, a.name as app, s.name, s.type, left(s.value, 300) as value, s.error_message
60
62
  from meta.app a join meta.authz_scheme s on s.app_id = a.id where ${IN_WS} order by lower(a.name), s.name`,
61
63
  },
62
64
  'password-items': {
63
- title: 'Password items',
64
- about: 'Every password item: pgkiln never shows their values again, never keeps them in session state for the next page and never writes them to the debug log.',
65
- columns: [['page_no', 'Page', 'num'], ['name', 'Item'], ['label', 'Label'], ['reveal', 'Show button'], ['required', 'Required']],
65
+ title: 'xap.password_items.title',
66
+ about: 'xap.password_items.about',
67
+ columns: [['page_no', 'xap.col.page', 'num'], ['name', 'xap.col.item'], ['label', 'xap.col.label'], ['reveal', 'xap.col.show_button'], ['required', 'xap.col.required']],
66
68
  sql: `select a.id as app_id, a.name as app, p.page_no, i.name, i.label,
67
69
  case when (i.config ->> 'reveal')::boolean then 'yes' else 'no' end as reveal, case when i.required then 'yes' else 'no' end as required
68
70
  from meta.app a join meta.page p on p.app_id = a.id join meta.item i on i.page_id = p.id
69
71
  where ${IN_WS} and i.type = 'password' order by lower(a.name), p.page_no, i.seq`,
70
72
  },
71
73
  theme: {
72
- title: 'Theme summary',
73
- about: 'The look of every application: base style, Theme Roller style, navigation and the colours it sets.',
74
- columns: [['base', 'Base style'], ['style', 'Style'], ['nav', 'Navigation'], ['colors', 'Colours set', 'num'], ['pwa', 'Installable (PWA)']],
74
+ title: 'xap.theme.title',
75
+ about: 'xap.theme.about',
76
+ columns: [['base', 'xap.col.base_style'], ['style', 'xap.col.style'], ['nav', 'xap.col.navigation'], ['colors', 'xap.col.colours_set', 'num'], ['pwa', 'xap.col.installable_pwa']],
75
77
  sql: `select a.id as app_id, a.name as app, coalesce(a.theme ->> 'base', 'default') as base, coalesce(a.theme ->> 'style', '-') as style,
76
78
  coalesce(a.theme ->> 'nav', 'side') as nav,
77
79
  (select count(*) from jsonb_object_keys(coalesce(a.theme -> 'colors', '{}'::jsonb)))::int as colors,
@@ -82,7 +84,7 @@ export const REPORTS: Record<string, Report> = {
82
84
 
83
85
  /** The links in Workspace utilities' side column. */
84
86
  export const crossAppLinks = () =>
85
- Object.entries(REPORTS).map(([k, r]) => html`<li><a href="${BASE}/utilities/reports/${k}">${r.title}${icon('chevron')}</a></li>`);
87
+ Object.entries(REPORTS).map(([k, r]) => html`<li><a href="${BASE}/utilities/reports/${k}">${tx(r.title)}${icon('chevron')}</a></li>`);
86
88
 
87
89
  export async function crossAppRoutes(app: FastifyInstance) {
88
90
  app.get(`${BASE}/utilities/reports/:kind`, async (req: Req, reply) => {
@@ -92,15 +94,15 @@ export async function crossAppRoutes(app: FastifyInstance) {
92
94
  if (!r) return reply.code(404).send('Not found');
93
95
  const rows = (await owner.query(r.sql, [currentWorkspace(s)])).rows;
94
96
  const cell = (v: unknown) => (v === null || v === undefined || v === '' ? html`<span class="muted">-</span>` : String(v));
95
- const main = html`<div class="title-row"><h1>${r.title}</h1></div>
96
- <p class="muted u-mt0">${r.about} Applications of the workspace ${s.workspace?.name ?? ''}.</p>
97
- <nav class="ide-tabs u-mb1" aria-label="Cross-application reports">${Object.entries(REPORTS).map(([k, x]) =>
98
- html`<a class="ide-tab" href="${BASE}/utilities/reports/${k}"${k === req.params.kind ? raw(' aria-current="page"') : ''}><span>${x.title}</span></a>`)}</nav>
99
- ${region(r.title, rows.length
100
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th>${r.columns.map(([, l, c]) => html`<th${c ? raw(` class="${c}"`) : ''}>${l}</th>`)}</tr></thead>
101
- <tbody>${rows.map((row) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${row.app_id}">${row.app}</a></td>
102
- ${r.columns.map(([k, l, c]) => html`<td data-label="${l}"${c ? raw(` class="${c}"`) : ''}>${cell(row[k])}</td>`)}</tr>`)}</tbody></table></div>`
103
- : html`<p class="muted">Nothing to report in this workspace.</p>`, html`<span class="count">${rows.length}</span>`)}`;
104
- return send(reply, s, shell(s, r.title, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [r.title]], main));
97
+ const main = html`<div class="title-row"><h1>${tx(r.title)}</h1></div>
98
+ <p class="muted u-mt0">${tx(r.about)} ${tx('xap.of_workspace', { name: s.workspace?.name ?? '' })}</p>
99
+ <nav class="ide-tabs u-mb1" aria-label="${tx('xap.nav')}">${Object.entries(REPORTS).map(([k, x]) =>
100
+ html`<a class="ide-tab" href="${BASE}/utilities/reports/${k}"${k === req.params.kind ? raw(' aria-current="page"') : ''}><span>${tx(x.title)}</span></a>`)}</nav>
101
+ ${region(tx(r.title), rows.length
102
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('xap.application')}</th>${r.columns.map(([, l, c]) => html`<th${c ? raw(` class="${c}"`) : ''}>${tx(l)}</th>`)}</tr></thead>
103
+ <tbody>${rows.map((row) => html`<tr><td data-label="${tx('xap.application')}"><a href="${BASE}/apps/${row.app_id}">${row.app}</a></td>
104
+ ${r.columns.map(([k, l, c]) => html`<td data-label="${tx(l)}"${c ? raw(` class="${c}"`) : ''}>${cell(row[k])}</td>`)}</tr>`)}</tbody></table></div>`
105
+ : html`<p class="muted">${tx('xap.nothing')}</p>`, html`<span class="count">${rows.length}</span>`)}`;
106
+ return send(reply, s, shell(s, tx(r.title), [[tx('rail.app_builder'), BASE], [tx('wu.title'), `${BASE}/utilities`], [tx(r.title)]], main));
105
107
  });
106
108
  }
@@ -27,7 +27,7 @@ import type { Session } from '../session.ts';
27
27
  import type { ComponentSpec } from './components.ts';
28
28
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
29
29
  import { appWorkspace, currentWorkspace, workshopDb } from './workspaces.ts';
30
- import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
30
+ import { scopedTx, tr, translatorFor, type BuilderTranslate } from './i18n.ts';
31
31
 
32
32
  // SQL Workshop → Load Data: upload a CSV/TSV/XLSX/JSON/XML file, preview it,
33
33
  // and load it into a new table (column types inferred) or an existing one
@@ -73,7 +73,7 @@ export const DATA_LOAD_DEF_SPEC: ComponentSpec = {
73
73
 
74
74
  /** "Load a file with it" on a definition's Shared Components page. */
75
75
  export const dataLoadDefExtras = (row: { id: number }) =>
76
- html`<p class="u-mt1"><a class="btn" href="${BASE}/sql/load?definition=${row.id}">${icon('upload')} Load a file with this definition</a></p>`;
76
+ html`<p class="u-mt1"><a class="btn" href="${BASE}/sql/load?definition=${row.id}">${icon('upload')} ${scopedTx('dl.load_with_def')}</a></p>`;
77
77
 
78
78
  /** The data load definitions of the current workspace's applications. */
79
79
  async function definitions(s: Session) {