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
@@ -15,6 +15,7 @@ import { logActivity, clientIp, type Session } from '../session.ts';
15
15
  import { checkNewApp, createAppError } from './newapp.ts';
16
16
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
17
17
  import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
18
+ import { scopedTx as tx } from './i18n.ts';
18
19
 
19
20
  // (sprint 110, migration 120) App Builder → Data Reporter (/builder/reporter; APEX 26.1: Data Reporter as a
20
21
  // workspace tool): the workspace's datasets (tables and views of one schema, joins proposed from foreign keys,
@@ -27,7 +28,7 @@ import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
27
28
  const root = `${BASE}/reporter`;
28
29
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
29
30
  const PREVIEW_ROWS = 10;
30
- const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Data Reporter', root], ...extra];
31
+ const crumbs = (extra: [string, string?][] = []): [string, string?][] => [[tx('dts.app_builder'), BASE], ['Data Reporter', root], ...extra];
31
32
  const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
32
33
 
33
34
  const page = (s: Session, reply: FastifyReply, title: string, extra: [string, string?][], main: Raw) =>
@@ -106,7 +107,7 @@ export function datasetFromForm(d: Dataset, b: Body, cat: Awaited<ReturnType<typ
106
107
 
107
108
  /** The first rows of a dataset, read only, through the SQL Workshop's connection (a timeout; errors as text). */
108
109
  async function preview(db: Db, d: Dataset): Promise<{ fields: string[]; rows: unknown[][] } | { error: string }> {
109
- if (!d.columns.length) return { error: 'The dataset offers no columns.' };
110
+ if (!d.columns.length) return { error: tx('dts.the_dataset_offers_no_columns') };
110
111
  try {
111
112
  return await db.tx(async (c) => {
112
113
  await c.query('set transaction read only');
@@ -129,30 +130,30 @@ export async function datasetRoutes(app: FastifyInstance) {
129
130
  const ws = currentWorkspace(s);
130
131
  const sets = await workspaceDatasets(owner, ws);
131
132
  const apps = await reportingApps(ws);
132
- const main = html`<div class="title-row"><h1>Data Reporter</h1>
133
- <div class="buttons"><a class="btn btn-hot" href="${root}/datasets/new">${icon('plus')} New dataset</a><a class="btn" href="${root}/import">${icon('upload')} Import</a>${sets.length ? html`<a class="btn" href="${root}/export">${icon('download')} Export all</a>` : ''}</div></div>
134
- <p class="muted u-mt0">Datasets are trusted views of ${s.workspace?.name ?? 'this workspace'}'s data for business users: tables and views of one schema, joined, with the columns they may see, labels, format masks and descriptions, and a row filter. A reporting application on one or more datasets lets its users build, save, publish and share reports; every report runs as the application's database role, so grants and row level security apply.</p>
135
- ${region('Datasets', html`<div class="table-wrap"><table class="report report-reflow">
136
- <thead><tr><th scope="col">Dataset</th><th scope="col">Schema</th><th scope="col">Tables and views</th><th scope="col" class="num">Columns</th><th scope="col">Updated (UTC)</th><th scope="col">Actions</th></tr></thead>
133
+ const main = html`<div class="title-row"><h1>${tx('dts.data_reporter')}</h1>
134
+ <div class="buttons"><a class="btn btn-hot" href="${root}/datasets/new">${icon('plus')} ${tx('dts.new_dataset')}</a><a class="btn" href="${root}/import">${icon('upload')} ${tx('dts.import')}</a>${sets.length ? html`<a class="btn" href="${root}/export">${icon('download')} ${tx('dts.export_all')}</a>` : ''}</div></div>
135
+ <p class="muted u-mt0">${tx('dts.intro', { workspace: s.workspace?.name ?? tx('dts.this_workspace') })}</p>
136
+ ${region(tx('dts.datasets'), html`<div class="table-wrap"><table class="report report-reflow">
137
+ <thead><tr><th scope="col">${tx('dts.dataset')}</th><th scope="col">${tx('dts.schema')}</th><th scope="col">${tx('dts.tables_and_views')}</th><th scope="col" class="num">${tx('dts.columns')}</th><th scope="col">${tx('dts.updated_utc')}</th><th scope="col">${tx('dts.actions')}</th></tr></thead>
137
138
  <tbody>${sets.length ? sets.map((d) => html`<tr>
138
- <td data-label="Dataset"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
139
- <td data-label="Schema"><code>${d.schema_name}</code></td>
140
- <td data-label="Tables and views">${d.tables.map((t) => t.table).join(', ')}</td>
141
- <td class="num" data-label="Columns">${d.columns.length}</td>
142
- <td data-label="Updated (UTC)">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
143
- <td data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} Create reporting application</a></td>
144
- </tr>`) : html`<tr><td colspan="6" class="empty">No datasets yet: create one on tables and views of a schema, or import a dataset file.</td></tr>`}</tbody></table></div>`)}
145
- ${region('Reporting applications', html`<div class="table-wrap"><table class="report report-reflow">
146
- <thead><tr><th scope="col">Application</th><th scope="col">Datasets</th><th scope="col" class="num">Users</th><th scope="col" class="num">Published reports</th><th scope="col">Actions</th></tr></thead>
139
+ <td data-label="${tx('dts.dataset')}"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
140
+ <td data-label="${tx('dts.schema')}"><code>${d.schema_name}</code></td>
141
+ <td data-label="${tx('dts.tables_and_views')}">${d.tables.map((t) => t.table).join(', ')}</td>
142
+ <td class="num" data-label="${tx('dts.columns')}">${d.columns.length}</td>
143
+ <td data-label="${tx('dts.updated_utc')}">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
144
+ <td data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} ${tx('dts.create_reporting_application')}</a></td>
145
+ </tr>`) : html`<tr><td colspan="6" class="empty">${tx('dts.no_datasets_yet_create_one')}</td></tr>`}</tbody></table></div>`)}
146
+ ${region(tx('dts.reporting_applications'), html`<div class="table-wrap"><table class="report report-reflow">
147
+ <thead><tr><th scope="col">${tx('dts.application')}</th><th scope="col">${tx('dts.datasets')}</th><th scope="col" class="num">${tx('dts.users')}</th><th scope="col" class="num">${tx('dts.published_reports')}</th><th scope="col">${tx('dts.actions')}</th></tr></thead>
147
148
  <tbody>${apps.length ? apps.map((a) => html`<tr>
148
- <td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <code>${a.alias}</code></td>
149
- <td data-label="Datasets">${a.datasets.join(', ')}</td>
150
- <td class="num" data-label="Users">${a.users}</td>
151
- <td class="num" data-label="Published reports">${a.reports}</td>
152
- <td data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} Run</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} Users and roles</a></td>
153
- </tr>`) : html`<tr><td colspan="5" class="empty">No reporting applications yet: choose Create reporting application on a dataset.</td></tr>`}</tbody></table></div>
154
- <p class="muted small">Roles: <b>viewer</b> runs published reports (every user with access), <b>editor</b> also builds, saves, publishes and unpublishes reports, <b>administrator</b> also unpublishes and deletes anyone's. Give accounts access and roles under Users and roles.</p>`)}`;
155
- return page(s, reply, 'Data Reporter', [], main);
149
+ <td data-label="${tx('dts.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <code>${a.alias}</code></td>
150
+ <td data-label="${tx('dts.datasets')}">${a.datasets.join(', ')}</td>
151
+ <td class="num" data-label="${tx('dts.users')}">${a.users}</td>
152
+ <td class="num" data-label="${tx('dts.published_reports')}">${a.reports}</td>
153
+ <td data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} ${tx('dts.run')}</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} ${tx('dts.users_and_roles')}</a></td>
154
+ </tr>`) : html`<tr><td colspan="5" class="empty">${tx('dts.no_reporting_applications_yet_choose')}</td></tr>`}</tbody></table></div>
155
+ <p class="muted small">${tx.rich('dts.roles_help', { viewer: html`<b>viewer</b>`, editor: html`<b>editor</b>`, administrator: html`<b>administrator</b>` })}</p>`)}`;
156
+ return page(s, reply, tx('dts.data_reporter'), [], main);
156
157
  });
157
158
 
158
159
  // ---------------------------------------------------------------- a new dataset: schema, then tables and views
@@ -163,23 +164,23 @@ export async function datasetRoutes(app: FastifyInstance) {
163
164
  const schemas = await usableSchemas(db);
164
165
  const schema = await usableSchema(db, String(req.query.schema ?? ''));
165
166
  const cat = schema ? await readSchemaCatalog(db, schema) : null;
166
- const main = html`<h1>New dataset</h1>
167
- ${region('1. Schema', html`<form method="get" action="${root}/datasets/new" class="search u-mwnone dataset-schema">
168
- <label class="label" for="f_schema">Schema</label>
167
+ const main = html`<h1>${tx('dts.new_dataset')}</h1>
168
+ ${region(tx('dts.1_schema'), html`<form method="get" action="${root}/datasets/new" class="search u-mwnone dataset-schema">
169
+ <label class="label" for="f_schema">${tx('dts.schema')}</label>
169
170
  <select id="f_schema" name="schema">${schemas.map((n) => html`<option${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
170
- <button class="btn">Show its tables and views</button></form>`)}
171
- ${cat ? region('2. Tables and views', html`<form method="post" action="${root}/datasets">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
171
+ <button class="btn">${tx('dts.show_its_tables_and_views')}</button></form>`)}
172
+ ${cat ? region(tx('dts.2_tables_and_views'), html`<form method="post" action="${root}/datasets">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
172
173
  <div class="form-grid">
173
- ${input('name', 'Name', '', { required: true, placeholder: 'e.g. sales', help: 'Lower case letters, digits and _ (at most 40): reports and files use it. It can\'t be changed later.' })}
174
- ${input('label', 'Label', '', { placeholder: 'e.g. Sales', help: 'What business users see.' })}
174
+ ${input('name', tx('dts.name'), '', { required: true, placeholder: tx('dts.e_g_sales'), help: tx('dts.lower_case_letters_digits_and') })}
175
+ ${input('label', tx('dts.label'), '', { placeholder: tx('dts.e_g_sales_2'), help: tx('dts.what_business_users_see') })}
175
176
  </div>
176
- <fieldset class="prop-group"><legend>Tables and views (the first is the main one)</legend>
177
+ <fieldset class="prop-group"><legend>${tx('dts.tables_and_views_the_first')}</legend>
177
178
  ${cat.objects.length ? html`<div class="dataset-checks">${cat.objects.map((o) => html`<label class="check"><input type="checkbox" name="tables" value="${o.name}"> ${o.name} <span class="muted">${o.kind}, ${o.columns.length} columns</span></label>`)}</div>`
178
- : html`<p class="muted">The schema has no tables or views you may use.</p>`}
179
- <small class="help">Joins are proposed from foreign keys; every column is offered at first, labelled from its name. You change all of it on the next page.</small>
179
+ : html`<p class="muted">${tx('dts.the_schema_has_no_tables')}</p>`}
180
+ <small class="help">${tx('dts.joins_are_proposed_from_foreign')}</small>
180
181
  </fieldset>
181
- <div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('plus')} Create dataset</button></div></form>`) : ''}`;
182
- return page(s, reply, 'New dataset', [['New dataset']], main);
182
+ <div class="buttons"><a class="btn" href="${root}">${tx('dts.cancel')}</a><button class="btn btn-hot">${icon('plus')} ${tx('dts.create_dataset')}</button></div></form>`) : ''}`;
183
+ return page(s, reply, tx('dts.new_dataset_title'), [[tx('dts.new_dataset')]], main);
183
184
  });
184
185
 
185
186
  app.post(`${root}/datasets`, async (req: Req, reply) => {
@@ -190,20 +191,20 @@ export async function datasetRoutes(app: FastifyInstance) {
190
191
  const schema = await usableSchema(db, String(b.schema ?? ''));
191
192
  const name = String(b.name ?? '').trim().toLowerCase();
192
193
  const again = (msg: string) => (flash(s, msg, 'error'), back(reply, s, `${root}/datasets/new${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`));
193
- if (!schema) return again('Choose one of the schemas in the list.');
194
+ if (!schema) return again(tx('dts.choose_one_of_the_schemas'));
194
195
  if (!DATASET_NAME.test(name)) return again(`"${name}" is not a valid dataset name: lower case letters, digits and _, starting with a letter (at most 40).`);
195
196
  const raw = (req.body as unknown as Record<string, unknown>)?.tables;
196
197
  const chosen = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(String);
197
- if (!chosen.length) return again('Choose at least one table or view.');
198
+ if (!chosen.length) return again(tx('dts.choose_at_least_one_table'));
198
199
  const cat = await readSchemaCatalog(db, schema);
199
200
  const proposed = proposeDataset(name, String(b.label ?? '').trim(), schema, chosen, cat);
200
201
  const { dataset, errors } = checkDataset(proposed.dataset, catalogOf(cat.objects));
201
202
  if (errors.length || !dataset.tables.length) return again([...proposed.errors, ...errors].join(' ') || 'The dataset is not valid.');
202
203
  const exists = await owner.one('select 1 from meta.dataset where workspace_id = $1 and name = $2', [currentWorkspace(s), name]);
203
- if (exists) return again(`There is already a dataset ${name}.`);
204
+ if (exists) return again(tx('dts.exists', { name }));
204
205
  const id = await storeDataset(owner, currentWorkspace(s), dataset, s.username, { replace: false });
205
206
  logActivity({ appId: null, username: s.username, event: 'dataset_create', ip: clientIp(req), detail: `${name} on ${schema}` });
206
- flash(s, proposed.errors.length ? `Dataset ${name} created. ${proposed.errors.join(' ')}` : `Dataset ${name} created: check its columns and labels.`, proposed.errors.length ? 'error' : 'ok');
207
+ flash(s, proposed.errors.length ? tx('dts.created_errors', { name, errors: proposed.errors.join(' ') }) : tx('dts.created', { name }), proposed.errors.length ? 'error' : 'ok');
207
208
  return back(reply, s, `${root}/datasets/${id}`);
208
209
  });
209
210
 
@@ -232,60 +233,60 @@ export async function datasetRoutes(app: FastifyInstance) {
232
233
  const shown = req.query.preview === '1' ? await preview(db, d) : null;
233
234
  const opt = (v: string, l: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${l}</option>`;
234
235
  const main = html`<div class="title-row"><h1>${d.label} <code>${d.name}</code></h1>
235
- <div class="buttons"><a class="btn" href="${root}/datasets/${row.id}/export">${icon('download')} Download</a><a class="btn btn-hot" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} Create reporting application</a></div></div>
236
+ <div class="buttons"><a class="btn" href="${root}/datasets/${row.id}/export">${icon('download')} ${tx('dts.download')}</a><a class="btn btn-hot" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} ${tx('dts.create_reporting_application')}</a></div></div>
236
237
  <p class="muted u-mt0">On schema <code>${d.schema}</code>${used.length ? html` · used by ${used.map((a, i) => html`${i ? ', ' : ''}<a href="${BASE}/apps/${a.id}">${a.name}</a>`)}` : ''} · updated ${when(row.updated_at)} (UTC)${row.updated_by ? ` by ${row.updated_by}` : ''}</p>
237
- ${errors.length ? html`<div class="alert alert-error" role="alert"><b>The dataset doesn't match the database (any more):</b><ul>${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
238
+ ${errors.length ? html`<div class="alert alert-error" role="alert"><b>${tx('dts.the_dataset_doesn_t_match')}</b><ul>${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
238
239
  <form method="post" action="${root}/datasets/${row.id}">${csrf(s)}
239
- ${region('Dataset', html`<div class="form-grid">
240
- ${input('label', 'Label', d.label, { required: true })}
241
- <div class="field" data-wide><label class="label" for="f_description">Description (shown to users)</label><input id="f_description" name="description" maxlength="${DATASET_LIMITS.description}" value="${d.description ?? ''}"></div>
240
+ ${region(tx('dts.dataset'), html`<div class="form-grid">
241
+ ${input('label', tx('dts.label'), d.label, { required: true })}
242
+ <div class="field" data-wide><label class="label" for="f_description">${tx('dts.description_shown_to_users')}</label><input id="f_description" name="description" maxlength="${DATASET_LIMITS.description}" value="${d.description ?? ''}"></div>
242
243
  </div>`)}
243
- ${region('Tables and views', html`<div class="table-wrap"><table class="report report-reflow">
244
- <thead><tr><th scope="col">Alias</th><th scope="col">Table or view</th><th scope="col">Joins to</th><th scope="col">Join</th><th scope="col">On (column = its column)</th><th scope="col">Remove</th></tr></thead><tbody>
244
+ ${region(tx('dts.tables_and_views'), html`<div class="table-wrap"><table class="report report-reflow">
245
+ <thead><tr><th scope="col">${tx('dts.alias')}</th><th scope="col">${tx('dts.table_or_view')}</th><th scope="col">${tx('dts.joins_to')}</th><th scope="col">${tx('dts.join')}</th><th scope="col">${tx('dts.on_header')}</th><th scope="col">${tx('dts.remove')}</th></tr></thead><tbody>
245
246
  ${d.tables.map((t, i) => html`<tr>
246
- <td data-label="Alias"><code>${t.alias}</code><input type="hidden" name="t${i}_alias" value="${t.alias}"></td>
247
- <td data-label="Table or view"><code>${t.table}</code></td>
248
- ${i === 0 ? html`<td data-label="Joins to" colspan="3"><span class="muted">the main table</span></td>` : html`
249
- <td data-label="Joins to"><select name="t${i}_to" aria-label="${t.table} joins to">${d.tables.slice(0, i).map((x) => opt(x.alias, `${x.alias} ${x.table}`, t.join?.to ?? ''))}</select></td>
250
- <td data-label="Join"><select name="t${i}_type" aria-label="Join type of ${t.table}">${opt('left', 'Left (keep rows without a match)', t.join?.type ?? 'left')}${opt('inner', 'Inner (only matching rows)', t.join?.type ?? 'left')}</select></td>
251
- <td data-label="On"><input name="t${i}_on" value="${t.join?.on.map((p) => `${p.column} = ${p.ref}`).join(', ') ?? ''}" aria-label="Join columns of ${t.table}" placeholder="deptno = deptno"></td>`}
252
- <td data-label="Remove">${i === 0 ? '' : html`<input type="checkbox" name="t${i}_remove" value="true" aria-label="Remove ${t.table}">`}</td>
247
+ <td data-label="${tx('dts.alias')}"><code>${t.alias}</code><input type="hidden" name="t${i}_alias" value="${t.alias}"></td>
248
+ <td data-label="${tx('dts.table_or_view')}"><code>${t.table}</code></td>
249
+ ${i === 0 ? html`<td data-label="${tx('dts.joins_to')}" colspan="3"><span class="muted">${tx('dts.the_main_table')}</span></td>` : html`
250
+ <td data-label="${tx('dts.joins_to')}"><select name="t${i}_to" aria-label="${t.table} joins to">${d.tables.slice(0, i).map((x) => opt(x.alias, `${x.alias} ${x.table}`, t.join?.to ?? ''))}</select></td>
251
+ <td data-label="${tx('dts.join')}"><select name="t${i}_type" aria-label="${tx('dts.aria_join_type', { table: t.table })}">${opt('left', tx('dts.join_left'), t.join?.type ?? 'left')}${opt('inner', tx('dts.join_inner'), t.join?.type ?? 'left')}</select></td>
252
+ <td data-label="${tx('dts.on')}"><input name="t${i}_on" value="${t.join?.on.map((p) => `${p.column} = ${p.ref}`).join(', ') ?? ''}" aria-label="${tx('dts.aria_join_cols', { table: t.table })}" placeholder="deptno = deptno"></td>`}
253
+ <td data-label="${tx('dts.remove')}">${i === 0 ? '' : html`<input type="checkbox" name="t${i}_remove" value="true" aria-label="${tx('dts.aria_remove', { table: t.table })}">`}</td>
253
254
  </tr>`)}</tbody></table></div>
254
255
  ${d.tables.length < DATASET_LIMITS.tables ? html`<div class="form-grid">
255
- <div class="field"><label class="label" for="f_new_table">Add a table or view</label><select id="f_new_table" name="new_table"><option value="">- none -</option>${cat.objects.map((o) => html`<option value="${o.name}">${o.name} (${o.kind})</option>`)}</select></div>
256
- <div class="field"><label class="label" for="f_new_to">Joined to</label><select id="f_new_to" name="new_to">${d.tables.map((x) => opt(x.alias, `${x.alias} ${x.table}`, ''))}</select></div>
257
- <div class="field"><label class="label" for="f_new_on">On</label><input id="f_new_on" name="new_on" placeholder="from a foreign key"><small class="help">Empty: proposed from a foreign key.</small></div>
256
+ <div class="field"><label class="label" for="f_new_table">${tx('dts.add_a_table_or_view')}</label><select id="f_new_table" name="new_table"><option value="">- none -</option>${cat.objects.map((o) => html`<option value="${o.name}">${o.name} (${o.kind})</option>`)}</select></div>
257
+ <div class="field"><label class="label" for="f_new_to">${tx('dts.joined_to')}</label><select id="f_new_to" name="new_to">${d.tables.map((x) => opt(x.alias, `${x.alias} ${x.table}`, ''))}</select></div>
258
+ <div class="field"><label class="label" for="f_new_on">${tx('dts.on')}</label><input id="f_new_on" name="new_on" placeholder="${tx('dts.from_a_foreign_key')}"><small class="help">${tx('dts.empty_proposed_from_a_foreign')}</small></div>
258
259
  </div>` : ''}`)}
259
- ${region('Columns', html`<p class="muted u-mt0">Business users see only the offered columns, under their labels. The name is how reports refer to a column: changing it breaks saved reports that use it.</p>
260
+ ${region(tx('dts.columns'), html`<p class="muted u-mt0">${tx('dts.business_users_see_only_the')}</p>
260
261
  <div class="table-wrap"><table class="report report-reflow dataset-columns">
261
- <thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Name</th><th scope="col">Label</th><th scope="col">Format mask</th><th scope="col">Description</th></tr></thead><tbody>
262
+ <thead><tr><th scope="col">${tx('dts.offer')}</th><th scope="col">${tx('dts.column')}</th><th scope="col">${tx('dts.name')}</th><th scope="col">${tx('dts.label')}</th><th scope="col">${tx('dts.format_mask')}</th><th scope="col">${tx('dts.description')}</th></tr></thead><tbody>
262
263
  ${rows.map((r, j) => html`<tr>
263
- <td data-label="Offer"><input type="hidden" name="c${j}_ref" value="${r.ref}"><input type="checkbox" name="c${j}_on" value="true"${r.c ? raw(' checked') : ''} aria-label="Offer ${r.table}.${r.column}"></td>
264
- <td data-label="Column"><code>${r.alias}.${r.column}</code> <span class="muted">${r.type}</span></td>
265
- <td data-label="Name"><input name="c${j}_name" maxlength="40" value="${r.c?.name ?? r.column.toLowerCase()}" aria-label="Name of ${r.table}.${r.column}"></td>
266
- <td data-label="Label"><input name="c${j}_label" maxlength="${DATASET_LIMITS.label}" value="${r.c?.label ?? ''}" placeholder="${heading(r.column)}" aria-label="Label of ${r.table}.${r.column}"></td>
267
- <td data-label="Format mask"><input name="c${j}_format" maxlength="${DATASET_LIMITS.format}" value="${r.c?.format ?? ''}" placeholder="999G990D00" aria-label="Format mask of ${r.table}.${r.column}"></td>
268
- <td data-label="Description"><input name="c${j}_desc" maxlength="${DATASET_LIMITS.columnDescription}" value="${r.c?.description ?? ''}" aria-label="Description of ${r.table}.${r.column}"></td>
264
+ <td data-label="${tx('dts.offer')}"><input type="hidden" name="c${j}_ref" value="${r.ref}"><input type="checkbox" name="c${j}_on" value="true"${r.c ? raw(' checked') : ''} aria-label="${tx('dts.aria_offer', { column: `${r.table}.${r.column}` })}"></td>
265
+ <td data-label="${tx('dts.column')}"><code>${r.alias}.${r.column}</code> <span class="muted">${r.type}</span></td>
266
+ <td data-label="${tx('dts.name')}"><input name="c${j}_name" maxlength="40" value="${r.c?.name ?? r.column.toLowerCase()}" aria-label="${tx('dts.aria_name', { column: `${r.table}.${r.column}` })}"></td>
267
+ <td data-label="${tx('dts.label')}"><input name="c${j}_label" maxlength="${DATASET_LIMITS.label}" value="${r.c?.label ?? ''}" placeholder="${heading(r.column)}" aria-label="${tx('dts.aria_label', { column: `${r.table}.${r.column}` })}"></td>
268
+ <td data-label="${tx('dts.format_mask')}"><input name="c${j}_format" maxlength="${DATASET_LIMITS.format}" value="${r.c?.format ?? ''}" placeholder="999G990D00" aria-label="${tx('dts.aria_format', { column: `${r.table}.${r.column}` })}"></td>
269
+ <td data-label="${tx('dts.description')}"><input name="c${j}_desc" maxlength="${DATASET_LIMITS.columnDescription}" value="${r.c?.description ?? ''}" aria-label="${tx('dts.aria_desc', { column: `${r.table}.${r.column}` })}"></td>
269
270
  </tr>`)}</tbody></table></div>`)}
270
- ${region('Row filter', html`<p class="muted u-mt0">Only rows that match every condition are in the dataset (on top of the application role's row level security).</p>
271
+ ${region(tx('dts.row_filter'), html`<p class="muted u-mt0">${tx('dts.only_rows_that_match_every')}</p>
271
272
  ${Array.from({ length: DATASET_LIMITS.filters }, (_, k) => {
272
273
  const f = d.filters[k];
273
274
  return html`<div class="dataset-filter">
274
- <select name="f${k}_col" aria-label="Filter ${k + 1}: column">${opt('', '- none -', '')}${colOptions.map(([v, l]) => opt(v, l, f ? `${f.table}.${f.column}` : ''))}</select>
275
- <select name="f${k}_op" aria-label="Filter ${k + 1}: operator">${Object.keys(OPERATORS).map((o) => opt(o, opLabel(english, o), f?.op ?? 'eq'))}</select>
276
- <input name="f${k}_value" maxlength="${DATASET_LIMITS.value}" value="${f?.value ?? ''}" aria-label="Filter ${k + 1}: value" placeholder="value">
275
+ <select name="f${k}_col" aria-label="${tx('dts.aria_filter_col', { n: k + 1 })}">${opt('', tx('dts.none_opt'), '')}${colOptions.map(([v, l]) => opt(v, l, f ? `${f.table}.${f.column}` : ''))}</select>
276
+ <select name="f${k}_op" aria-label="${tx('dts.aria_filter_op', { n: k + 1 })}">${Object.keys(OPERATORS).map((o) => opt(o, opLabel(english, o), f?.op ?? 'eq'))}</select>
277
+ <input name="f${k}_value" maxlength="${DATASET_LIMITS.value}" value="${f?.value ?? ''}" aria-label="${tx('dts.aria_filter_value', { n: k + 1 })}" placeholder="${tx('dts.value')}">
277
278
  </div>`;
278
279
  })}`)}
279
- <div class="buttons"><button class="btn btn-hot">${icon('save')} Save dataset</button></div>
280
+ <div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('dts.save_dataset')}</button></div>
280
281
  </form>
281
- ${region('Query and preview', html`<p class="muted u-mt0">The query reports run on (as the application's role). The preview runs it read only through your SQL Workshop connection.</p>
282
+ ${region(tx('dts.query_and_preview'), html`<p class="muted u-mt0">${tx('dts.the_query_reports_run_on')}</p>
282
283
  <pre class="code" data-code-view="sql">${datasetSql(d)}</pre>
283
284
  <p><a class="btn" href="${root}/datasets/${row.id}?preview=1#preview">${icon('play')} Preview ${PREVIEW_ROWS} rows</a></p>
284
285
  ${shown ? 'error' in shown ? html`<div class="alert alert-error" role="alert" id="preview">${shown.error}</div>` : html`<div class="table-wrap" id="preview"><table class="report report-reflow"><thead><tr>${shown.fields.map((f) => html`<th scope="col">${d.columns.find((c) => c.name === f)?.label ?? f}</th>`)}</tr></thead>
285
- <tbody>${shown.rows.length ? shown.rows.map((r) => html`<tr>${r.map((v, i) => html`<td data-label="${shown.fields[i]}">${cellText(v)}</td>`)}</tr>`) : html`<tr><td colspan="${shown.fields.length}" class="empty">No rows.</td></tr>`}</tbody></table></div>` : ''}`)}
286
- ${region('Delete', html`<form method="post" action="${root}/datasets/${row.id}/delete">${csrf(s)}
287
- <p class="muted u-mt0">${used.length ? `Reporting applications using it (${used.map((a) => a.name).join(', ')}) lose this data source: their reports on it stop working.` : 'No reporting application uses it.'}</p>
288
- <button class="btn btn-danger" data-confirm="Delete the dataset ${d.name}?">${icon('trash')} Delete dataset</button></form>`)}`;
286
+ <tbody>${shown.rows.length ? shown.rows.map((r) => html`<tr>${r.map((v, i) => html`<td data-label="${shown.fields[i]}">${cellText(v)}</td>`)}</tr>`) : html`<tr><td colspan="${shown.fields.length}" class="empty">${tx('dts.no_rows')}</td></tr>`}</tbody></table></div>` : ''}`)}
287
+ ${region(tx('dts.delete'), html`<form method="post" action="${root}/datasets/${row.id}/delete">${csrf(s)}
288
+ <p class="muted u-mt0">${used.length ? tx('dts.used_by', { apps: used.map((a) => a.name).join(', ') }) : tx('dts.no_reporting_application_uses_it')}</p>
289
+ <button class="btn btn-danger" data-confirm="${tx('dts.delete_confirm', { name: d.name })}">${icon('trash')} ${tx('dts.delete_dataset')}</button></form>`)}`;
289
290
  return page(s, reply, d.label, [[d.label]], main);
290
291
  });
291
292
 
@@ -297,7 +298,7 @@ export async function datasetRoutes(app: FastifyInstance) {
297
298
  const d = datasetOf(row);
298
299
  const db = await workshopDb(s);
299
300
  if (!(await usableSchema(db, d.schema))) {
300
- flash(s, `Your SQL Workshop connection may not use the schema ${d.schema}.`, 'error');
301
+ flash(s, tx('dts.schema_refused', { schema: d.schema }), 'error');
301
302
  return back(reply, s, `${root}/datasets/${row.id}`);
302
303
  }
303
304
  const cat = await readSchemaCatalog(db, d.schema);
@@ -308,7 +309,7 @@ export async function datasetRoutes(app: FastifyInstance) {
308
309
  }
309
310
  await storeDataset(owner, row.workspace_id, dataset, s.username);
310
311
  logActivity({ appId: null, username: s.username, event: 'dataset_save', ip: clientIp(req), detail: dataset.name });
311
- flash(s, errors.length ? `Saved, but: ${errors.join(' ')}` : 'Dataset saved.', errors.length ? 'error' : 'ok');
312
+ flash(s, errors.length ? `Saved, but: ${errors.join(' ')}` : tx('dts.dataset_saved'), errors.length ? 'error' : 'ok');
312
313
  return back(reply, s, `${root}/datasets/${row.id}`);
313
314
  });
314
315
 
@@ -319,7 +320,7 @@ export async function datasetRoutes(app: FastifyInstance) {
319
320
  if (!row) return reply.code(404).send('Not found');
320
321
  await owner.query('delete from meta.dataset where id = $1', [row.id]);
321
322
  logActivity({ appId: null, username: s.username, event: 'dataset_delete', ip: clientIp(req), detail: row.name });
322
- flash(s, `Dataset ${row.name} deleted.`);
323
+ flash(s, tx('dts.deleted', { name: row.name }));
323
324
  return back(reply, s, root);
324
325
  });
325
326
 
@@ -345,16 +346,16 @@ export async function datasetRoutes(app: FastifyInstance) {
345
346
  app.get(`${root}/import`, async (req: Req, reply) => {
346
347
  const s = await developer(req, reply);
347
348
  if (!s) return;
348
- const main = html`<h1>Import datasets</h1>
349
- ${region('A dataset file', html`<form method="post" action="${root}/import" enctype="multipart/form-data">${csrf(s)}
350
- <p class="muted u-mt0">A file downloaded from a Data Reporter (<code>*.dataset.json</code>, format pgkiln-dataset/1), with one dataset or several. Each is checked against this installation's database: its schema, tables, views and columns must exist here.</p>
349
+ const main = html`<h1>${tx('dts.import_datasets')}</h1>
350
+ ${region(tx('dts.a_dataset_file'), html`<form method="post" action="${root}/import" enctype="multipart/form-data">${csrf(s)}
351
+ <p class="muted u-mt0">${tx.code('dts.import_intro')}</p>
351
352
  <div class="form-grid">
352
- <div class="field"><label class="label" for="f_file">File</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
353
- <div class="field" data-wide><label class="label" for="f_text">Or paste it</label><textarea id="f_text" name="text" class="code" rows="5" spellcheck="false"></textarea></div>
354
- <div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> Replace datasets with the same name</label></div>
353
+ <div class="field"><label class="label" for="f_file">${tx('dts.file')}</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
354
+ <div class="field" data-wide><label class="label" for="f_text">${tx('dts.or_paste_it')}</label><textarea id="f_text" name="text" class="code" rows="5" spellcheck="false"></textarea></div>
355
+ <div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> ${tx('dts.replace_datasets_with_the_same')}</label></div>
355
356
  </div>
356
- <div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('upload')} Import</button></div></form>`)}`;
357
- return page(s, reply, 'Import datasets', [['Import']], main);
357
+ <div class="buttons"><a class="btn" href="${root}">${tx('dts.cancel')}</a><button class="btn btn-hot">${icon('upload')} ${tx('dts.import')}</button></div></form>`)}`;
358
+ return page(s, reply, tx('dts.import_title'), [[tx('dts.import')]], main);
358
359
  });
359
360
 
360
361
  app.post(`${root}/import`, async (req: Req, reply) => {
@@ -371,9 +372,9 @@ export async function datasetRoutes(app: FastifyInstance) {
371
372
  const s = await developer(req, reply);
372
373
  if (!s) return;
373
374
  const fail = (msg: string) => (flash(s, msg, 'error'), back(reply, s, `${root}/import`));
374
- if (tooLarge) return fail('The file is larger than 2 MB.');
375
+ if (tooLarge) return fail(tx('dts.too_large'));
375
376
  if (!text.trim()) text = String(req.body?.text ?? '');
376
- if (!text.trim()) return fail('Choose a file or paste one.');
377
+ if (!text.trim()) return fail(tx('dts.no_file'));
377
378
  let list: any[];
378
379
  try {
379
380
  list = readDatasetFile(text);
@@ -391,10 +392,10 @@ export async function datasetRoutes(app: FastifyInstance) {
391
392
  for (const loose of list) {
392
393
  const schema = typeof loose?.schema === 'string' ? loose.schema : '';
393
394
  const name = typeof loose?.name === 'string' ? loose.name : '?';
394
- if (!schemas.includes(schema)) throw new Error(`Dataset ${name}: the schema "${schema}" doesn't exist here, or your SQL Workshop connection may not use it.`);
395
+ if (!schemas.includes(schema)) throw new Error(tx('dts.no_schema', { name, schema }));
395
396
  if (!cats.has(schema)) cats.set(schema, await readSchemaCatalog(db, schema));
396
397
  const { dataset, errors } = checkDataset(loose, catalogOf(cats.get(schema)!.objects));
397
- if (errors.length || !dataset.columns.length) throw new Error(`Dataset ${name}: ${errors.join(' ') || 'it offers no columns.'}`);
398
+ if (errors.length || !dataset.columns.length) throw new Error(tx('dts.import_errors', { name, errors: errors.join(' ') || tx('dts.no_columns') }));
398
399
  const exists = (await c.query('select 1 from meta.dataset where workspace_id = $1 and name = $2', [ws, dataset.name])).rowCount;
399
400
  if (exists && !replace) {
400
401
  lines.push(`${dataset.name}: kept (same name)`);
@@ -405,7 +406,7 @@ export async function datasetRoutes(app: FastifyInstance) {
405
406
  }
406
407
  });
407
408
  } catch (e) {
408
- return fail(`Nothing was imported. ${(e as Error).message}`);
409
+ return fail(tx('dts.nothing_imported', { error: (e as Error).message }));
409
410
  }
410
411
  logActivity({ appId: null, username: s.username, event: 'dataset_import', ip: clientIp(req), detail: lines.join('; ') });
411
412
  flash(s, `Imported: ${lines.join('; ')}.`);
@@ -415,18 +416,18 @@ export async function datasetRoutes(app: FastifyInstance) {
415
416
  // ---------------------------------------------------------------- create a reporting application
416
417
  const appForm = async (s: Session, b: Body & { datasets?: string | string[] }, sets: DatasetRow[], error?: string) => {
417
418
  const chosen = new Set(Array.isArray(b.datasets) ? b.datasets : b.datasets ? [b.datasets] : []);
418
- return html`<h1>Create a reporting application</h1>
419
+ return html`<h1>${tx('dts.create_a_reporting_application')}</h1>
419
420
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
420
421
  <form method="post" action="${root}/apps">${csrf(s)}
421
- ${region('Application', html`<div class="form-grid">
422
- ${input('name', 'Name', b.name ?? '', { required: true })}
423
- ${input('alias', 'Alias', b.alias ?? '', { required: true, help: 'Its address: /a/<alias>. Its database role is app_<alias>, which may only read the datasets\' tables and views.' })}
424
- ${input('admin_user', 'First administrator', b.admin_user ?? s.username ?? '', { required: true, help: 'An account of the user directory (users sign in with their workspace accounts).' })}
425
- ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'Only for a new account.' })}
422
+ ${region(tx('dts.application'), html`<div class="form-grid">
423
+ ${input('name', tx('dts.name'), b.name ?? '', { required: true })}
424
+ ${input('alias', tx('dts.alias'), b.alias ?? '', { required: true, help: tx('dts.its_address_a_alias_its') })}
425
+ ${input('admin_user', tx('dts.first_administrator'), b.admin_user ?? s.username ?? '', { required: true, help: tx('dts.an_account_of_the_user') })}
426
+ ${input('admin_password', tx('dts.password'), '', { type: 'password', auto: 'new-password', help: tx('dts.only_for_a_new_account') })}
426
427
  </div>`)}
427
- ${region('Datasets', html`<div class="dataset-checks">${sets.map((d) => html`<label class="check"><input type="checkbox" name="datasets" value="${d.name}"${chosen.has(d.name) ? raw(' checked') : ''}> ${d.label} <code>${d.name}</code> <span class="muted">${d.schema_name}: ${d.tables.map((t) => t.table).join(', ')}</span></label>`)}</div>
428
- <small class="help">The first dataset's schema is the application's parsing schema.</small>`)}
429
- <div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('plus')} Create</button></div></form>`;
428
+ ${region(tx('dts.datasets'), html`<div class="dataset-checks">${sets.map((d) => html`<label class="check"><input type="checkbox" name="datasets" value="${d.name}"${chosen.has(d.name) ? raw(' checked') : ''}> ${d.label} <code>${d.name}</code> <span class="muted">${d.schema_name}: ${d.tables.map((t) => t.table).join(', ')}</span></label>`)}</div>
429
+ <small class="help">${tx('dts.the_first_dataset_s_schema')}</small>`)}
430
+ <div class="buttons"><a class="btn" href="${root}">${tx('dts.cancel')}</a><button class="btn btn-hot">${icon('plus')} ${tx('dts.create')}</button></div></form>`;
430
431
  };
431
432
 
432
433
  app.get(`${root}/apps/new`, async (req: Req, reply) => {
@@ -434,12 +435,12 @@ export async function datasetRoutes(app: FastifyInstance) {
434
435
  if (!s) return;
435
436
  const sets = await workspaceDatasets(owner, currentWorkspace(s));
436
437
  if (!sets.length) {
437
- flash(s, 'Create a dataset first.', 'error');
438
+ flash(s, tx('dts.create_a_dataset_first'), 'error');
438
439
  return back(reply, s, root);
439
440
  }
440
441
  const first = sets.find((d) => d.name === req.query.dataset) ?? sets[0];
441
442
  const base = first.name.replace(/_/g, '-').slice(0, 40);
442
- return page(s, reply, 'Create reporting application', [['Create reporting application']],
443
+ return page(s, reply, tx('dts.create_app_title'), [[tx('dts.create_reporting_application')]],
443
444
  await appForm(s, { name: `${first.label} reports`, alias: `${base}-reports`, datasets: first.name }, sets));
444
445
  });
445
446
 
@@ -452,10 +453,10 @@ export async function datasetRoutes(app: FastifyInstance) {
452
453
  const chosen = names.map((n) => sets.find((d) => d.name === n)).filter((d): d is DatasetRow => !!d);
453
454
  const failed = async (msg: string) => {
454
455
  reply.code(422);
455
- return page(s, reply, 'Create reporting application', [['Create reporting application']], await appForm(s, b, sets, msg));
456
+ return page(s, reply, tx('dts.create_app_title'), [[tx('dts.create_reporting_application')]], await appForm(s, b, sets, msg));
456
457
  };
457
- if (!chosen.length) return failed('Choose at least one dataset.');
458
- if (chosen.length > MAX_REPORTING_DATASETS) return failed(`Choose at most ${MAX_REPORTING_DATASETS} datasets.`);
458
+ if (!chosen.length) return failed(tx('dts.choose_one'));
459
+ if (chosen.length > MAX_REPORTING_DATASETS) return failed(tx('dts.choose_max', { n: MAX_REPORTING_DATASETS }));
459
460
  let created: { id: number; existingAccount: boolean };
460
461
  let alias = '';
461
462
  try {
@@ -466,7 +467,7 @@ export async function datasetRoutes(app: FastifyInstance) {
466
467
  return failed(createAppError(e, alias || String(b.alias ?? '')));
467
468
  }
468
469
  logActivity({ appId: created.id, username: s.username, event: 'app_create', ip: clientIp(req), detail: `reporting application on ${chosen.map((d) => d.name).join(', ')}` });
469
- flash(s, `Reporting application ${b.name} created: run it at /a/${alias}/, and give accounts access and the roles viewer, editor or administrator under Users and roles.`);
470
+ flash(s, tx('dts.app_created', { name: b.name, path: `/a/${alias}/` }));
470
471
  return back(reply, s, root);
471
472
  });
472
473
  }
@@ -8,6 +8,7 @@ import { devDbForApp } from '../workspace-db.ts';
8
8
  import { EXPLAINABLE, sqlFields, type SqlShape } from './advisor.ts';
9
9
  import { COMPONENTS } from './components.ts';
10
10
  import { appEntries, type Entry } from './search.ts';
11
+ import { scopedTx as tx } from './i18n.ts';
11
12
  import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
12
13
 
13
14
  // (sprint 138, AP-5) App Builder → Utilities → Database object dependencies
@@ -164,7 +165,7 @@ export async function appDependencies(appId: number): Promise<Dependencies> {
164
165
  continue;
165
166
  }
166
167
  const { statements, skipped } = bodyOf(f.sql, f.shape);
167
- if (skipped.length) unscanned.push({ ...use, message: `Not scanned (only SELECT, INSERT, UPDATE, DELETE and MERGE are parsed): ${skipped.join(', ')}` });
168
+ if (skipped.length) unscanned.push({ ...use, message: tx('dep.not_parsed', { statements: skipped.join(', ') }) });
168
169
  if (!statements.length) continue;
169
170
  scanned++;
170
171
  // the extended protocol takes exactly one statement: SQL that ends the function early (…; end; drop …) is refused
@@ -262,7 +263,7 @@ export const objectUrl = (o: Pick<DepObject, 'kind' | 'schema' | 'ref'>) =>
262
263
 
263
264
  const useLink = (u: Use) =>
264
265
  u.entry
265
- ? html`<li><a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>${u.entry.pageNo !== null && u.entry.kind !== 'page' ? html` <span class="tag">page ${u.entry.pageNo}</span>` : ''} <span class="muted small">${u.field}</span></li>`
266
+ ? html`<li><a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>${u.entry.pageNo !== null && u.entry.kind !== 'page' ? html` <span class="tag">${tx('dep.page_tag', { n: u.entry.pageNo })}</span>` : ''} <span class="muted small">${u.field}</span></li>`
266
267
  : html`<li><span class="muted small">${u.field}</span></li>`;
267
268
 
268
269
  export async function dependencyRoutes(app: FastifyInstance) {
@@ -279,33 +280,33 @@ export async function dependencyRoutes(app: FastifyInstance) {
279
280
  const grantsN = deps.objects.filter((o) => o.lacking.length).length;
280
281
  const pages = (o: DepObject) => [...new Set(o.usedBy.map((u) => u.entry?.pageNo).filter((p): p is number => p !== null && p !== undefined))].sort((x, y) => x - y);
281
282
  const rows: Raw = shown.length
282
- ? html`<div class="table-wrap"><table class="table dep-table report-reflow"><thead><tr><th scope="col">Object</th><th scope="col">Type</th><th scope="col">Status</th><th scope="col">Pages</th><th scope="col">Used by</th></tr></thead>
283
+ ? html`<div class="table-wrap"><table class="table dep-table report-reflow"><thead><tr><th scope="col">${tx('dep.col_object')}</th><th scope="col">${tx('dep.col_type')}</th><th scope="col">${tx('dep.col_status')}</th><th scope="col">${tx('dep.col_pages')}</th><th scope="col">${tx('dep.col_used_by')}</th></tr></thead>
283
284
  <tbody>${shown.map((o) => {
284
285
  const url = objectUrl(o);
285
286
  const status = o.missing
286
- ? html`<span class="tag tag-error">Missing</span>`
287
+ ? html`<span class="tag tag-error">${tx('dep.missing')}</span>`
287
288
  : o.lacking.length
288
- ? html`<span class="tag tag-error">No grant</span> <span class="small dep-missing">${deps.role} lacks ${o.lacking.join(', ')}</span>`
289
- : html`<span class="tag tag-success">OK</span> <span class="muted small">${o.privileges.join(', ')}</span>`;
289
+ ? html`<span class="tag tag-error">${tx('dep.no_grant')}</span> <span class="small dep-missing">${tx('dep.lacks', { role: deps.role, privileges: o.lacking.join(', ') })}</span>`
290
+ : html`<span class="tag tag-success">${tx('dep.ok')}</span> <span class="muted small">${o.privileges.join(', ')}</span>`;
290
291
  return html`<tr${o.missing || o.lacking.length ? raw(' class="dep-problem"') : ''}>
291
- <td data-label="Object">${icon(KIND_ICON[o.kind])} ${url ? html`<a href="${url}">${o.display}</a>` : html`<span class="dep-missing">${o.display}</span>`}</td>
292
- <td data-label="Type">${o.kind === 'relation' ? 'table or view' : o.kind}</td>
293
- <td data-label="Status">${status}</td>
294
- <td data-label="Pages">${pages(o).join(', ')}</td>
295
- <td data-label="Used by"><details class="dep-uses"><summary>${o.usedBy.length} component${o.usedBy.length === 1 ? '' : 's'}</summary><ul class="dep-list">${o.usedBy.map(useLink)}</ul></details></td>
292
+ <td data-label="${tx('dep.col_object')}">${icon(KIND_ICON[o.kind])} ${url ? html`<a href="${url}">${o.display}</a>` : html`<span class="dep-missing">${o.display}</span>`}</td>
293
+ <td data-label="${tx('dep.col_type')}">${o.kind === 'relation' ? 'table or view' : o.kind}</td>
294
+ <td data-label="${tx('dep.col_status')}">${status}</td>
295
+ <td data-label="${tx('dep.col_pages')}">${pages(o).join(', ')}</td>
296
+ <td data-label="${tx('dep.col_used_by')}"><details class="dep-uses"><summary>${tx(o.usedBy.length === 1 ? 'dep.n_component' : 'dep.n_components', { n: o.usedBy.length })}</summary><ul class="dep-list">${o.usedBy.map(useLink)}</ul></details></td>
296
297
  </tr>`;
297
298
  })}</tbody></table></div>`
298
- : html`<p class="muted">No objects${filter || onlyProblems ? ' match' : ''}.</p>`;
299
+ : html`<p class="muted">${tx(filter || onlyProblems ? 'dep.no_match' : 'dep.no_objects')}</p>`;
299
300
  const main = html`${appHeader(a, 'utilities', s)}
300
- ${region('Database object dependencies', html`<p class="muted u-mt0">The tables, views, sequences and functions the application's SQL uses (${deps.scanned} statements parsed as the role <code>${deps.role ?? '(the runtime connection)'}</code>; nothing is run), with the components that use them. Missing objects and privileges the role lacks are marked.</p>
301
- <p><span class="tag">${deps.objects.length} objects</span> <span class="tag tag-error">${missingN} missing</span> <span class="tag tag-warning">${grantsN} without the grants they need</span></p>
301
+ ${region(tx('util.c_deps'), html`<p class="muted u-mt0">${tx.rich('dep.intro', { n: deps.scanned, role: html`<code>${deps.role ?? tx('dep.runtime_connection')}</code>` })}</p>
302
+ <p><span class="tag">${tx('dep.n_objects', { n: deps.objects.length })}</span> <span class="tag tag-error">${tx('dep.n_missing', { n: missingN })}</span> <span class="tag tag-warning">${tx('dep.n_without_grants', { n: grantsN })}</span></p>
302
303
  <form method="get" class="util-filters" role="search">
303
- <div class="field"><label class="label" for="dep-q">Filter</label><input id="dep-q" name="q" type="search" value="${filter}" placeholder="e.g. sales.orders"></div>
304
- <div class="field"><label class="label" for="dep-show">Show</label><select id="dep-show" name="show"><option value="">All objects</option><option value="problems"${onlyProblems ? raw(' selected') : ''}>Only problems</option></select></div>
305
- <div class="buttons"><button class="btn">${icon('filter')} Filter</button></div>
304
+ <div class="field"><label class="label" for="dep-q">${tx('dep.filter')}</label><input id="dep-q" name="q" type="search" value="${filter}" placeholder="${tx('dep.filter_ph')}"></div>
305
+ <div class="field"><label class="label" for="dep-show">${tx('common.show')}</label><select id="dep-show" name="show"><option value="">${tx('dep.all_objects')}</option><option value="problems"${onlyProblems ? raw(' selected') : ''}>${tx('dep.only_problems')}</option></select></div>
306
+ <div class="buttons"><button class="btn">${icon('filter')} ${tx('dep.filter')}</button></div>
306
307
  </form>
307
308
  ${rows}`)}
308
- ${deps.unscanned.length ? region('Not scanned', html`<ul class="hits findings">${deps.unscanned.map((u) => html`<li class="finding-info">${u.entry ? html`<a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>` : html`<span>${u.field}</span>`}<span></span><span class="tag tag-info">Note</span><span class="hit-snippet"><b>${u.field}:</b> ${u.message}</span></li>`)}</ul>`) : ''}`;
309
- return send(reply, s, shell(s, `Object dependencies · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Object dependencies']], main));
309
+ ${deps.unscanned.length ? region(tx('dep.not_scanned'), html`<ul class="hits findings">${deps.unscanned.map((u) => html`<li class="finding-info">${u.entry ? html`<a href="${u.entry.url}">${icon(u.entry.icon)}<span>${u.entry.kindLabel}: ${u.entry.label}</span></a>` : html`<span>${u.field}</span>`}<span></span><span class="tag tag-info">${tx('dep.note')}</span><span class="hit-snippet"><b>${u.field}:</b> ${u.message}</span></li>`)}</ul>`) : ''}`;
310
+ return send(reply, s, shell(s, tx('dep.title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.utilities'), `${BASE}/apps/${a.id}/utilities`], [tx('dep.crumb')]], main));
310
311
  });
311
312
  }