pgkiln 0.84.0 → 0.86.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 (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -5,18 +5,19 @@ import { icon } from '../icons.ts';
5
5
  import { clientIp, logActivity } from '../session.ts';
6
6
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
7
7
  import { currentWorkspace } from './workspaces.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // Application groups (092; APEX: Workspace Utilities → Application Groups): a
10
11
  // workspace's named groups of applications. The App Builder home filters and
11
12
  // labels applications by group. Every developer of the workspace manages them.
12
13
 
13
14
  const NAME = /^\S(.{0,58}\S)?$/;
14
- const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Application groups', `${BASE}/app-groups`], ...extra];
15
+ const crumbs = (extra: [string, string?][] = []): [string, string?][] => [[tx('rail.app_builder'), BASE], [tx('wu.title'), `${BASE}/utilities`], [tx('wu.app_groups'), `${BASE}/app-groups`], ...extra];
15
16
 
16
17
  const groupError = (e: unknown) => {
17
18
  const m = (e as Error).message;
18
- if (m.includes('app_group_workspace_id_name_key')) return 'A group with this name exists already.';
19
- if (m.includes('app_group_name_check')) return 'The name is 1 to 60 characters, without spaces at the start or end.';
19
+ if (m.includes('app_group_workspace_id_name_key')) return tx('agr.exists');
20
+ if (m.includes('app_group_name_check')) return tx('agr.bad_name');
20
21
  return m;
21
22
  };
22
23
 
@@ -37,36 +38,36 @@ export async function appGroupRoutes(app: FastifyInstance) {
37
38
  [ws],
38
39
  ),
39
40
  ]);
40
- const main = html`<div class="title-row"><h1>Application groups</h1></div>
41
- <p class="muted u-mt0">Groups organise the applications of this workspace (${s.workspace?.name ?? ''}) on the App Builder home, which can show one group at a time. They change nothing in the applications.</p>
41
+ const main = html`<div class="title-row"><h1>${tx('wu.app_groups')}</h1></div>
42
+ <p class="muted u-mt0">${tx('agr.intro', { workspace: s.workspace?.name ?? '' })}</p>
42
43
  <div class="columns wide-left">
43
- ${region('Groups', groups.rows.length
44
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Group</th><th class="num">Applications</th><th>Rename</th><th></th></tr></thead><tbody>
44
+ ${region(tx('agr.groups'), groups.rows.length
45
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('agr.group')}</th><th class="num">${tx('agr.applications')}</th><th>${tx('agr.rename')}</th><th></th></tr></thead><tbody>
45
46
  ${groups.rows.map((g) => html`<tr>
46
- <td data-label="Group"><a href="${BASE}?group=${g.id}">${g.name}</a>${g.description ? html`<br><span class="muted small">${g.description}</span>` : ''}</td>
47
- <td class="num" data-label="Applications">${g.apps}</td>
48
- <td data-label="Rename"><form method="post" action="${BASE}/app-groups/${g.id}" class="search u-mwnone">${csrf(s)}
49
- <label class="sr-only" for="g-${g.id}">New name for ${g.name}</label><input id="g-${g.id}" name="name" value="${g.name}" required maxlength="60">
50
- <button class="btn btn-sm">Rename</button></form></td>
51
- <td><form method="post" action="${BASE}/app-groups/${g.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the group ${g.name}? Its applications stay, without a group.">${icon('trash')}<span class="sr-only">Delete ${g.name}</span></button></form></td></tr>`)}
47
+ <td data-label="${tx('agr.group')}"><a href="${BASE}?group=${g.id}">${g.name}</a>${g.description ? html`<br><span class="muted small">${g.description}</span>` : ''}</td>
48
+ <td class="num" data-label="${tx('agr.applications')}">${g.apps}</td>
49
+ <td data-label="${tx('agr.rename')}"><form method="post" action="${BASE}/app-groups/${g.id}" class="search u-mwnone">${csrf(s)}
50
+ <label class="sr-only" for="g-${g.id}">${tx('agr.new_name_for', { name: g.name })}</label><input id="g-${g.id}" name="name" value="${g.name}" required maxlength="60">
51
+ <button class="btn btn-sm">${tx('agr.rename')}</button></form></td>
52
+ <td><form method="post" action="${BASE}/app-groups/${g.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('agr.delete_confirm', { name: g.name })}">${icon('trash')}<span class="sr-only">${tx('agr.delete_name', { name: g.name })}</span></button></form></td></tr>`)}
52
53
  </tbody></table></div>`
53
- : html`<p class="muted">No groups yet.</p>`)}
54
- ${region('Add a group', html`<form method="post" action="${BASE}/app-groups">${csrf(s)}
55
- ${input('name', 'Name', '', { required: true, placeholder: 'Finance' })}
56
- ${input('description', 'Description', '')}
57
- <div class="buttons"><button class="btn btn-hot">${icon('plus')} Add group</button></div></form>`)}
54
+ : html`<p class="muted">${tx('agr.no_groups')}</p>`)}
55
+ ${region(tx('agr.add_title'), html`<form method="post" action="${BASE}/app-groups">${csrf(s)}
56
+ ${input('name', tx('agr.name'), '', { required: true, placeholder: tx('agr.name_ph') })}
57
+ ${input('description', tx('agr.description'), '')}
58
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('agr.add')}</button></div></form>`)}
58
59
  </div>
59
60
  <div class="u-spacer"></div>
60
- ${region('Applications', apps.rows.length
61
+ ${region(tx('agr.applications'), apps.rows.length
61
62
  ? html`<form method="post" action="${BASE}/app-groups/assign">${csrf(s)}
62
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Group</th></tr></thead><tbody>
63
- ${apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
64
- <td data-label="Group"><label class="sr-only" for="a-${a.id}">Group of ${a.name}</label>
65
- <select id="a-${a.id}" name="app_${a.id}"><option value="">(no group)</option>${groups.rows.map((g) => html`<option value="${g.id}"${a.group_id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select></td></tr>`)}
63
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('agr.application')}</th><th>${tx('agr.group')}</th></tr></thead><tbody>
64
+ ${apps.rows.map((a) => html`<tr><td data-label="${tx('agr.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
65
+ <td data-label="${tx('agr.group')}"><label class="sr-only" for="a-${a.id}">${tx('agr.group_of', { name: a.name })}</label>
66
+ <select id="a-${a.id}" name="app_${a.id}"><option value="">${tx('agr.no_group')}</option>${groups.rows.map((g) => html`<option value="${g.id}"${a.group_id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select></td></tr>`)}
66
67
  </tbody></table></div>
67
- <div class="buttons"><button class="btn"${groups.rows.length ? '' : raw(' disabled')}>Save groups</button></div></form>`
68
- : html`<p class="muted">No applications in this workspace.</p>`)}`;
69
- return send(reply, s, shell(s, 'Application groups', crumbs(), main));
68
+ <div class="buttons"><button class="btn"${groups.rows.length ? '' : raw(' disabled')}>${tx('agr.save')}</button></div></form>`
69
+ : html`<p class="muted">${tx('agr.no_apps')}</p>`)}`;
70
+ return send(reply, s, shell(s, tx('wu.app_groups'), crumbs(), main));
70
71
  });
71
72
 
72
73
  app.post(`${BASE}/app-groups`, async (req: Req, reply) => {
@@ -74,10 +75,10 @@ export async function appGroupRoutes(app: FastifyInstance) {
74
75
  if (!s) return;
75
76
  const name = String(req.body?.name ?? '').trim();
76
77
  try {
77
- if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters, without spaces at the start or end.');
78
+ if (!NAME.test(name)) throw new Error(tx('agr.bad_name'));
78
79
  await owner.query('insert into meta.app_group (workspace_id, name, description) values ($1, $2, $3)', [currentWorkspace(s), name, String(req.body?.description ?? '').trim().slice(0, 2000) || null]);
79
80
  await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `added ${name}` });
80
- flash(s, `Group ${name} added.`);
81
+ flash(s, tx('agr.added', { name }));
81
82
  } catch (e) {
82
83
  flash(s, groupError(e), 'error');
83
84
  }
@@ -89,10 +90,10 @@ export async function appGroupRoutes(app: FastifyInstance) {
89
90
  if (!s) return;
90
91
  const name = String(req.body?.name ?? '').trim();
91
92
  try {
92
- if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters, without spaces at the start or end.');
93
+ if (!NAME.test(name)) throw new Error(tx('agr.bad_name'));
93
94
  const r = await owner.query('update meta.app_group set name = $3 where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s), name]);
94
95
  if (!r.rowCount) return reply.code(404).send('Not found');
95
- flash(s, `Group renamed to ${name}.`);
96
+ flash(s, tx('agr.renamed', { name }));
96
97
  } catch (e) {
97
98
  flash(s, groupError(e), 'error');
98
99
  }
@@ -105,7 +106,7 @@ export async function appGroupRoutes(app: FastifyInstance) {
105
106
  const r = await owner.one<{ name: string }>('delete from meta.app_group where id = $1 and workspace_id = $2 returning name', [req.params.id, currentWorkspace(s)]);
106
107
  if (!r) return reply.code(404).send('Not found');
107
108
  await logActivity({ username: s.username, event: 'app_group', ip: clientIp(req), detail: `deleted ${r.name}` });
108
- flash(s, `Group ${r.name} deleted.`);
109
+ flash(s, tx('agr.deleted', { name: r.name }));
109
110
  return back(reply, s, `${BASE}/app-groups`);
110
111
  });
111
112
 
@@ -126,7 +127,7 @@ export async function appGroupRoutes(app: FastifyInstance) {
126
127
  await c.query('insert into meta.app_group_member (app_id, group_id) values ($1, $2) on conflict (app_id) do update set group_id = excluded.group_id', [appId, groupId]);
127
128
  }
128
129
  });
129
- flash(s, 'Groups saved.');
130
+ flash(s, tx('agr.saved'));
130
131
  return back(reply, s, `${BASE}/app-groups`);
131
132
  });
132
133
  }
@@ -24,6 +24,7 @@ import { appWizardRoutes, PASTED } from './appwizard.ts';
24
24
  import { finishGeneratedApp, checkNewApp, createApp, createAppError, reservedSchema, type CheckedApp } from './newapp.ts';
25
25
  import { currentWorkspace } from './workspaces.ts';
26
26
  import { BASE, csrf, developer, input, region, select, send, shell, type Body, type Req } from './ui.ts';
27
+ import { scopedTx as tx } from './i18n.ts';
27
28
 
28
29
  // Create → From a file (APEX: Create App from a File). Step 1 uploads a
29
30
  // CSV/TSV, Excel .xlsx, JSON or XML file (parsed by src/dataload.ts, the
@@ -116,9 +117,9 @@ interface Built {
116
117
  export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Body): Promise<Built> {
117
118
  const tableName = String(b.table ?? '').trim();
118
119
  const cols = chosenColumns(sheet, b);
119
- if (!cols.length) throw new LoadError('Give at least one column a name.');
120
+ if (!cols.length) throw new LoadError(tx('aff.give_at_least_one_column'));
120
121
  // the table goes into the app's schema; createTableSql checks the names and the types
121
- if (!/^[a-z_][a-z0-9_]{0,62}$/.test(tableName)) throw new LoadError('Table name: a lower-case name (letters, digits and _) without a schema; the table goes into the application\'s schema.');
122
+ if (!/^[a-z_][a-z0-9_]{0,62}$/.test(tableName)) throw new LoadError(tx('aff.table_name_a_lower_case'));
122
123
  const table = `${checked.schema}.${tableName}`;
123
124
  const ddl = createTableSql(table, cols);
124
125
  const headers = b.h !== '0';
@@ -127,7 +128,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
127
128
  throw new LoadError(createAppError(e, checked.alias));
128
129
  });
129
130
  await c.query(ddl).catch((e) => {
130
- throw new LoadError((e as { code?: string }).code === '42P07' ? `The table ${table} already exists: choose another name.` : (e as Error).message);
131
+ throw new LoadError((e as { code?: string }).code === '42P07' ? tx('aff.table_exists', { table }) : (e as Error).message);
131
132
  });
132
133
  // explicit grants too: an existing schema may have other default privileges
133
134
  const T = (await c.query('select $1::regclass::text as t', [table])).rows[0].t as string;
@@ -159,7 +160,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
159
160
  const repeated = (col: CatalogColumn) => col.distinct_values !== null && col.distinct_values >= 2 && col.distinct_values <= 50 && col.distinct_values < rows;
160
161
  if (b.chart === 'true') {
161
162
  const group = groupColumn(catalog, rows);
162
- if (!group) notes.push('No dashboard: no text, yes/no or date column has a few values that repeat, to count the rows by.');
163
+ if (!group) notes.push(tx('aff.no_dashboard'));
163
164
  else {
164
165
  await generate('chart', next, { label: 'Dashboard', icon: 'chart', nav: true, label_column: group, function: 'count', value_column: null });
165
166
  pages.push({ page: next++, label: `Dashboard (rows per ${group})` });
@@ -172,7 +173,7 @@ export async function buildAppFromFile(checked: CheckedApp, sheet: Sheet, b: Bod
172
173
  const col = catalog.find((x) => x.column_name === f);
173
174
  return col && (col.kind !== 'text' || repeated(col));
174
175
  });
175
- if (!facets.length) notes.push('No faceted search: no column suits a filter.');
176
+ if (!facets.length) notes.push(tx('aff.no_facets'));
176
177
  else {
177
178
  await generate('facets', next, { label: `Search ${label.toLowerCase()}`, icon: 'filter', nav: true, facets });
178
179
  pages.push({ page: next++, label: `Faceted search (${facets.join(', ')})` });
@@ -195,43 +196,41 @@ export interface Created {
195
196
  export function createdHtml(built: Created | Built, adminUser: string, another: string) {
196
197
  const tables = 'table' in built ? [{ table: built.table, result: built.result }] : (built.tables ?? []);
197
198
  const errors = tables.flatMap((t) => t.result.errors.map((e) => ({ ...e, table: t.table })));
198
- const loaded = tables.map((t) => `${t.table}: ${t.result.inserted} row(s) loaded${t.result.failed ? `, ${t.result.failed} skipped` : ''}.`).join(' ');
199
- return html`<h1>Application created</h1>
200
- <div class="alert alert-success" role="status">Application ${built.app.alias} created. ${loaded}${
201
- built.app.existingAccount ? ` The existing account ${adminUser} got the admin role (its password was not changed).` : ''}</div>
199
+ const loaded = tables.map((t) => (t.result.failed ? tx('aff.loaded_skipped', { table: t.table, n: t.result.inserted, skipped: t.result.failed }) : tx('aff.loaded', { table: t.table, n: t.result.inserted }))).join(' ');
200
+ return html`<h1>${tx('aff.application_created')}</h1>
201
+ <div class="alert alert-success" role="status">${tx('aff.created', { alias: built.app.alias })} ${loaded}${
202
+ built.app.existingAccount ? ` ${tx('aff.existing_account', { user: adminUser })}` : ''}</div>
202
203
  ${errors.length
203
- ? region('Skipped rows', html`<div class="table-wrap"><table class="report"><thead><tr>${tables.length > 1 ? html`<th>Table</th>` : ''}<th class="num">Row</th><th>Error</th></tr></thead>
204
+ ? region(tx('aff.skipped_rows'), html`<div class="table-wrap"><table class="report"><thead><tr>${tables.length > 1 ? html`<th>${tx('aff.table')}</th>` : ''}<th class="num">${tx('aff.row')}</th><th>${tx('aff.error')}</th></tr></thead>
204
205
  <tbody>${errors.map((e) => html`<tr>${tables.length > 1 ? html`<td>${e.table}</td>` : ''}<td class="num">${e.row}</td><td>${e.message}</td></tr>`)}</tbody></table></div>
205
- ${tables.some((t) => t.result.failed > t.result.errors.length) ? html`<p class="muted">The first errors are shown.</p>` : ''}`)
206
+ ${tables.some((t) => t.result.failed > t.result.errors.length) ? html`<p class="muted">${tx('aff.the_first_errors_are_shown')}</p>` : ''}`)
206
207
  : ''}
207
- ${'foreignKeys' in built && built.foreignKeys?.length ? region('Foreign keys', html`<ul>${built.foreignKeys.map((x) => html`<li>${x}</li>`)}</ul>`) : ''}
208
- ${region('Pages', html`<ul>${built.pages.map((p) => html`<li>Page ${p.page}: ${p.label}</li>`)}</ul>
208
+ ${'foreignKeys' in built && built.foreignKeys?.length ? region(tx('aff.foreign_keys'), html`<ul>${built.foreignKeys.map((x) => html`<li>${x}</li>`)}</ul>`) : ''}
209
+ ${region(tx('aff.pages'), html`<ul>${built.pages.map((p) => html`<li>${tx('aff.page_item', { n: p.page, label: p.label })}</li>`)}</ul>
209
210
  ${built.notes.map((n) => html`<p class="muted">${n}</p>`)}`)}
210
211
  <div class="buttons u-mt1">
211
- <a class="btn btn-hot" href="${BASE}/apps/${built.app.id}">${icon('edit')} Edit the application</a>
212
- <a class="btn btn-run" href="/a/${built.app.alias}" target="_blank" rel="noopener">${icon('play')} Run</a>
213
- <a class="btn" href="${another}">${icon('plus')} Create another</a>
212
+ <a class="btn btn-hot" href="${BASE}/apps/${built.app.id}">${icon('edit')} ${tx('aff.edit_the_application')}</a>
213
+ <a class="btn btn-run" href="/a/${built.app.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('aff.run')}</a>
214
+ <a class="btn" href="${another}">${icon('plus')} ${tx('aff.create_another')}</a>
214
215
  </div>`;
215
216
  }
216
217
 
217
218
  export async function appFromFileRoutes(app: FastifyInstance) {
218
219
  const page = (s: Session, reply: FastifyReply, main: Raw) =>
219
- send(reply, s, shell(s, 'Create application from a file', [['App Builder', BASE], ['Create', `${BASE}/create`], ['From a file']], html`<div class="ab-narrow">${main}</div>`));
220
+ send(reply, s, shell(s, tx('aff.create_application_from_a_file'), [[tx('aff.app_builder'), BASE], [tx('aff.create'), `${BASE}/create`], [tx('aff.from_a_file')]], html`<div class="ab-narrow">${main}</div>`));
220
221
 
221
222
  const uploadForm = (s: Session, error?: string) =>
222
- html`<h1>Create an application from a file</h1>
223
- <p class="muted">Upload a spreadsheet: CSV or TSV (UTF-8 or Windows-1252; the delimiter is detected), Excel .xlsx (each sheet becomes a table, up to ${MAX_SHEETS}), JSON (an array of objects; an object with several arrays becomes several tables) or XML, up to ${MAX_MB} MB.
224
- Next you check the proposed tables and their columns. The application gets its own schema and database role, a table with the rows, a report and form, and optionally a dashboard and a faceted search.
225
- <a href="${BASE}/create/paste">Paste data</a> instead, or <a href="${BASE}/create/tables">start from existing tables</a>.</p>
226
- ${region('File', html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
223
+ html`<h1>${tx('aff.create_an_application_from_a')}</h1>
224
+ <p class="muted">${tx.rich('aff.intro', { sheets: MAX_SHEETS, mb: MAX_MB, paste: html`<a href="${BASE}/create/paste">${tx('aff.paste_data')}</a>`, tables: html`<a href="${BASE}/create/tables">${tx('aff.start_from_existing_tables')}</a>` })}</p>
225
+ ${region(tx('aff.file'), html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
227
226
  <form method="post" action="${BASE}/create/file" enctype="multipart/form-data">${csrf(s)}
228
227
  <div class="form-grid">
229
- <div class="field"><label class="label" for="f_file">File</label>
228
+ <div class="field"><label class="label" for="f_file">${tx('aff.file')}</label>
230
229
  <input type="file" id="f_file" name="file" accept=".csv,.tsv,.txt,.xlsx,.json,.jsonl,.xml,text/csv,application/json,application/xml,text/xml" required></div>
231
230
  <div class="field"><span class="label" aria-hidden="true"></span>
232
- <label class="check"><input type="checkbox" name="headers" value="true" checked> First row contains column names</label></div>
231
+ <label class="check"><input type="checkbox" name="headers" value="true" checked> ${tx('aff.first_row_contains_column_names')}</label></div>
233
232
  </div>
234
- <div class="buttons"><a class="btn" href="${BASE}/create">Cancel</a><button class="btn btn-hot">${icon('upload')} Next</button></div>
233
+ <div class="buttons"><a class="btn" href="${BASE}/create">${tx('aff.cancel')}</a><button class="btn btn-hot">${icon('upload')} ${tx('aff.next')}</button></div>
235
234
  </form>`)}`;
236
235
 
237
236
  // step 1: the file
@@ -254,12 +253,12 @@ export async function appFromFileRoutes(app: FastifyInstance) {
254
253
  reply.code(422);
255
254
  return page(s, reply, uploadForm(s, message));
256
255
  };
257
- if (!file) return fail('Choose a file.');
258
- if (file.truncated) return fail(`The file is larger than ${MAX_MB} MB.`);
256
+ if (!file) return fail(tx('aff.choose_file'));
257
+ if (file.truncated) return fail(tx('aff.too_large', { mb: MAX_MB }));
259
258
  const headers = req.body?.headers === 'true';
260
259
  try {
261
260
  const sheets = await parseBook(file.filename, file.data, { headers });
262
- if (!sheets.some((x) => x.rows.length)) throw new LoadError('The file has column names but no rows.');
261
+ if (!sheets.some((x) => x.rows.length)) throw new LoadError(tx('aff.the_file_has_column_names'));
263
262
  } catch (e) {
264
263
  if (e instanceof LoadError) return fail(e.message);
265
264
  throw e;
@@ -284,36 +283,36 @@ export async function appFromFileRoutes(app: FastifyInstance) {
284
283
  throw e;
285
284
  }
286
285
  const sheet = sheets[0];
287
- const again = f.mime_type === PASTED ? html`<a href="${BASE}/create/paste">Paste other data</a>` : html`<a href="${BASE}/create/file">Choose another file</a>`;
286
+ const again = f.mime_type === PASTED ? html`<a href="${BASE}/create/paste">${tx('aff.paste_other_data')}</a>` : html`<a href="${BASE}/create/file">${tx('aff.choose_another_file')}</a>`;
288
287
  const schemas = (await owner.query(`select nspname from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`)).rows;
289
288
  const v = (k: string, d: string) => (b ? (b[k] ?? '') : d);
290
- const appRegion = region('Application', html`<div class="form-grid">
291
- ${input('name', 'Name', v('name', appNameFor(f.filename)), { required: true })}
292
- ${input('alias', 'Alias (URL)', v('alias', await aliasFor(f.filename)), { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
293
- ${select('schema', 'Parsing schema', v('schema', ''), [['', '- new schema named after the alias -'], ...schemas.map((r): [string, string] => [r.nspname, r.nspname])],
294
- `A database role app_<alias> is created with access to this schema only; the app runs as that role. The ${sheets.length > 1 ? 'tables are' : 'table is'} created in this schema.`)}
295
- ${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
296
- ${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
297
- ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
289
+ const appRegion = region(tx('aff.application'), html`<div class="form-grid">
290
+ ${input('name', tx('aff.name'), v('name', appNameFor(f.filename)), { required: true })}
291
+ ${input('alias', tx('aff.alias_url'), v('alias', await aliasFor(f.filename)), { required: true, help: tx('aff.lowercase_e_g_inventory_a') })}
292
+ ${select('schema', tx('aff.parsing_schema'), v('schema', ''), [['', tx('aff.new_schema_named_after_the')], ...schemas.map((r): [string, string] => [r.nspname, r.nspname])],
293
+ sheets.length > 1 ? tx('aff.schema_help_n') : tx('aff.schema_help_1'))}
294
+ ${select('authentication', tx('aff.authentication'), v('authentication', 'app_users'), [['app_users', tx('aff.app_users_login_page')], ['none', tx('aff.none_public')]])}
295
+ ${input('admin_user', tx('aff.first_user'), v('admin_user', ''), { placeholder: tx('aff.e_g_your_name'), help: tx('aff.gets_the_admin_role_an') })}
296
+ ${input('admin_password', tx('aff.password'), '', { type: 'password', auto: 'new-password', help: tx('aff.for_a_new_account_at') })}
298
297
  </div>`);
299
298
  if (sheets.length > 1) {
300
- return page(s, reply, html`<h1>Create an application from a file</h1>
299
+ return page(s, reply, html`<h1>${tx('aff.create_an_application_from_a')}</h1>
301
300
  ${message}
302
- <p class="muted">${f.filename}: ${sheets.length} ${sheet.format === 'json' ? 'arrays' : 'sheets'} (${sheets.map((x) => `${x.name}: ${x.rows.length} row(s)`).join(', ')}). ${again}</p>
301
+ <p class="muted">${tx(sheet.format === 'json' ? 'aff.arrays_info' : 'aff.sheets_info', { file: f.filename, n: sheets.length, list: sheets.map((x) => tx('aff.sheet_rows', { name: x.name, n: x.rows.length })).join(', ') })} ${again}</p>
303
302
  <form method="post" action="${BASE}/create/file/${req.params.id}">${csrf(s)}<input type="hidden" name="h" value="${headers ? '1' : '0'}">
304
303
  ${appRegion}
305
304
  <div class="u-spacer"></div>
306
305
  ${sheetsForm(sheets, b)}
307
306
  <div class="u-spacer"></div>
308
- ${region('Pages', html`<p class="muted u-mt0">Page 1 is the Home page; each table gets an interactive report with a modal form to create, change and delete rows, and a navigation entry. More pages can be added later with the page wizards.</p>
309
- ${check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a text, yes/no or date column', b ? b.chart === 'true' : true)}
310
- ${check('skip_errors', 'Skip rows with errors (otherwise nothing is created when a row fails)', v('skip_errors', '') === 'true')}`)}
311
- <div class="buttons"><a class="btn" href="${BASE}/create">Cancel</a><button class="btn btn-hot" name="action" value="create">Create application</button></div>
307
+ ${region(tx('aff.pages'), html`<p class="muted u-mt0">${tx('aff.page_1_is_the_home')}</p>
308
+ ${check('chart', tx('aff.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true)}
309
+ ${check('skip_errors', tx('aff.skip_rows_with_errors_otherwise'), v('skip_errors', '') === 'true')}`)}
310
+ <div class="buttons"><a class="btn" href="${BASE}/create">${tx('aff.cancel')}</a><button class="btn btn-hot" name="action" value="create">${tx('aff.create_application')}</button></div>
312
311
  </form>`);
313
312
  }
314
313
  const suggested = suggestColumns(sheet);
315
- const info = `${f.filename}: ${sheet.rows.length} row(s), ${sheet.headers.length} column(s)${
316
- sheet.format === 'xlsx' ? ', Excel' : sheet.format === 'json' ? ', JSON' : sheet.format === 'xml' ? `, XML rows <${sheet.rowPath}>` : `, delimiter ${sheet.delimiter === '\t' ? 'tab' : `“${sheet.delimiter}”`}`
314
+ const info = `${tx('aff.info', { file: f.filename, rows: sheet.rows.length, cols: sheet.headers.length })}${
315
+ sheet.format === 'xlsx' ? ', Excel' : sheet.format === 'json' ? ', JSON' : sheet.format === 'xml' ? tx('aff.xml_rows', { path: `<${sheet.rowPath}>` }) : tx('aff.delimiter', { d: sheet.delimiter === '\t' ? tx('aff.tab') : `“${sheet.delimiter}”` })
317
316
  }`;
318
317
  const sample = (i: number) =>
319
318
  sheet.rows
@@ -322,30 +321,30 @@ export async function appFromFileRoutes(app: FastifyInstance) {
322
321
  .slice(0, SAMPLE)
323
322
  .map((x) => (x.length > 30 ? `${x.slice(0, 30)}…` : x))
324
323
  .join(' · ');
325
- const main = html`<h1>Create an application from a file</h1>
324
+ const main = html`<h1>${tx('aff.create_an_application_from_a')}</h1>
326
325
  ${message}
327
- ${region('Preview', html`<p class="muted">${info}${sheet.rows.length > 10 ? ' (first 10 shown)' : ''}. ${again}</p>${preview(sheet)}`)}
326
+ ${region(tx('aff.preview'), html`<p class="muted">${info}${sheet.rows.length > 10 ? ` ${tx('aff.first_10')}` : ''}. ${again}</p>${preview(sheet)}`)}
328
327
  <div class="u-spacer"></div>
329
328
  <form method="post" action="${BASE}/create/file/${req.params.id}">${csrf(s)}<input type="hidden" name="h" value="${headers ? '1' : '0'}">
330
329
  ${appRegion}
331
330
  <div class="u-spacer"></div>
332
- ${region('Table', html`<div class="form-grid">
333
- ${input('table', 'Table name', v('table', tableNameFor(f.filename)), { required: true, help: 'In the application\'s schema; the table gets an identity primary key id.' })}
331
+ ${region(tx('aff.table'), html`<div class="form-grid">
332
+ ${input('table', tx('aff.table_name'), v('table', tableNameFor(f.filename)), { required: true, help: tx('aff.in_the_application_s_schema') })}
334
333
  </div>
335
- <div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">File column</th><th scope="col">Values</th><th scope="col">Column name (empty = skip)</th><th scope="col">Type</th></tr></thead><tbody>
334
+ <div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">${tx('aff.file_column')}</th><th scope="col">${tx('aff.values')}</th><th scope="col">${tx('aff.col_name_head')}</th><th scope="col">${tx('aff.type')}</th></tr></thead><tbody>
336
335
  ${suggested.map((c) => {
337
336
  const type = v(`type_${c.index}`, c.type);
338
337
  return html`<tr><td>${sheet.headers[c.index]}</td><td class="muted small ff-sample">${sample(c.index)}</td>
339
- <td><input name="name_${c.index}" value="${v(`name_${c.index}`, c.name)}" aria-label="Column name for ${sheet.headers[c.index]}"></td>
340
- <td><select name="type_${c.index}" aria-label="Type of ${sheet.headers[c.index]}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
338
+ <td><input name="name_${c.index}" value="${v(`name_${c.index}`, c.name)}" aria-label="${tx('aff.col_name_for', { col: sheet.headers[c.index] })}"></td>
339
+ <td><select name="type_${c.index}" aria-label="${tx('aff.type_of', { col: sheet.headers[c.index] })}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
341
340
  })}
342
341
  </tbody></table></div>
343
- ${check('skip_errors', 'Skip rows with errors (otherwise nothing is created when a row fails)', v('skip_errors', '') === 'true')}`)}
342
+ ${check('skip_errors', tx('aff.skip_rows_with_errors_otherwise'), v('skip_errors', '') === 'true')}`)}
344
343
  <div class="u-spacer"></div>
345
- ${region('Pages', html`<p class="muted u-mt0">Page 1 is the Home page; page 2 an interactive report of the table with a modal form (page 3) to create, change and delete rows. Each page gets a navigation entry. More pages can be added later with the page wizards.</p>
346
- ${check('chart', 'Dashboard: a chart with the number of rows per value of a text, yes/no or date column', b ? b.chart === 'true' : true)}
347
- ${check('facets', 'Faceted search: a report with a filter panel (values with counts, ranges and a search field)', b ? b.facets === 'true' : true)}`)}
348
- <div class="buttons"><a class="btn" href="${BASE}/create">Cancel</a><button class="btn btn-hot">Create application</button></div>
344
+ ${region(tx('aff.pages'), html`<p class="muted u-mt0">${tx('aff.page_1_is_the_home_2')}</p>
345
+ ${check('chart', tx('aff.dashboard_a_chart_with_the'), b ? b.chart === 'true' : true)}
346
+ ${check('facets', tx('aff.faceted_search_a_report_with'), b ? b.facets === 'true' : true)}`)}
347
+ <div class="buttons"><a class="btn" href="${BASE}/create">${tx('aff.cancel')}</a><button class="btn btn-hot">${tx('aff.create_application')}</button></div>
349
348
  </form>`;
350
349
  return page(s, reply, main);
351
350
  };
@@ -370,7 +369,7 @@ export async function appFromFileRoutes(app: FastifyInstance) {
370
369
  if (b.action === 'preview') return settings(req, reply, s, b);
371
370
  let built: Built | BuiltBook;
372
371
  try {
373
- if (reservedSchema(String(b.schema ?? ''))) throw new LoadError(`The schema ${b.schema} can't be the parsing schema of an application.`);
372
+ if (reservedSchema(String(b.schema ?? ''))) throw new LoadError(tx('aff.bad_schema', { schema: String(b.schema) }));
374
373
  const checked = await checkNewApp(b, currentWorkspace(s)).catch((e) => {
375
374
  throw new LoadError((e as Error).message);
376
375
  });
@@ -22,6 +22,7 @@ import { preview } from './dataload.ts';
22
22
  import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
23
23
  import { groupColumn } from './appfromfile.ts';
24
24
  import { region, type Body } from './ui.ts';
25
+ import { scopedTx as tx } from './i18n.ts';
25
26
 
26
27
  // Create → From a file with several sheets or tables (APEX: Create App from a
27
28
  // File, several sheets). An Excel workbook with several sheets, or a JSON
@@ -66,7 +67,7 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
66
67
  try {
67
68
  book = await readXlsxFile<string>(data, { parseNumber: (s) => s, trim: true });
68
69
  } catch (e) {
69
- throw new LoadError(`This is not a readable Excel (.xlsx) file: ${(e as Error).message}`);
70
+ throw new LoadError(tx('ash.bad_xlsx', { error: (e as Error).message }));
70
71
  }
71
72
  const sheets: NamedSheet[] = [];
72
73
  let total = 0;
@@ -77,16 +78,16 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
77
78
  try {
78
79
  sheet = tableSheet('xlsx', rows, headers);
79
80
  } catch (e) {
80
- if (e instanceof LoadError) throw new LoadError(`Sheet ${s.sheet}: ${e.message}`);
81
+ if (e instanceof LoadError) throw new LoadError(tx('ash.sheet_error', { sheet: s.sheet, error: e.message }));
81
82
  throw e;
82
83
  }
83
84
  if (!sheet.rows.length) continue;
84
85
  total += sheet.rows.length;
85
86
  sheets.push({ ...sheet, name: s.sheet });
86
87
  }
87
- if (sheets.length > MAX_SHEETS) throw new LoadError(`The workbook has ${sheets.length} sheets with rows; at most ${MAX_SHEETS} become tables at once.`);
88
- if (total > MAX_ROWS) throw new LoadError(`The workbook has ${total} rows; at most ${MAX_ROWS} can be loaded at once.`);
89
- if (!sheets.length) throw new LoadError('The file has column names but no rows.');
88
+ if (sheets.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_sheets', { n: sheets.length, max: MAX_SHEETS }));
89
+ if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows_wb', { n: total, max: MAX_ROWS }));
90
+ if (!sheets.length) throw new LoadError(tx('ash.the_file_has_column_names'));
90
91
  return sheets;
91
92
  }
92
93
  if (/\.json$/i.test(filename) || /^\s*\{/.test(data.subarray(0, 64).toString('utf8').replace(/^/, ''))) {
@@ -101,9 +102,9 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
101
102
  ([, v]) => Array.isArray(v) && v.length && v.every((r) => r && typeof r === 'object' && !Array.isArray(r)),
102
103
  ) as [string, unknown[]][];
103
104
  if (arrays.length > 1) {
104
- if (arrays.length > MAX_SHEETS) throw new LoadError(`The JSON has ${arrays.length} arrays; at most ${MAX_SHEETS} become tables at once.`);
105
+ if (arrays.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_arrays', { n: arrays.length, max: MAX_SHEETS }));
105
106
  const total = arrays.reduce((n, [, v]) => n + v.length, 0);
106
- if (total > MAX_ROWS) throw new LoadError(`The file has ${total} rows; at most ${MAX_ROWS} can be loaded at once.`);
107
+ if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows', { n: total, max: MAX_ROWS }));
107
108
  return arrays.map(([name, v]) => ({ ...parseJson(JSON.stringify(v)), name }));
108
109
  }
109
110
  }
@@ -235,18 +236,18 @@ export function fkChecked(f: ForeignKey, b: Body | null) {
235
236
  /** CREATE TABLE for a planned table: an integer key column becomes an identity primary key; another key is unique and not null next to a new identity id. */
236
237
  export function plannedTableSql(schema: string, p: TablePlan) {
237
238
  const valid = /^[a-z_][a-z0-9_]{0,62}$/;
238
- const where = `Sheet ${p.sheet.name}`;
239
- if (!valid.test(p.table)) throw new LoadError(`${where}: the table name must be lower-case (letters, digits and _) without a schema; the table goes into the application's schema.`);
240
- if (!p.cols.length) throw new LoadError(`${where}: give at least one column a name.`);
239
+ const where = tx('ash.sheet', { name: p.sheet.name });
240
+ if (!valid.test(p.table)) throw new LoadError(tx('ash.bad_table_name', { where }));
241
+ if (!p.cols.length) throw new LoadError(tx('ash.no_columns', { where }));
241
242
  for (const c of p.cols) {
242
- if (!valid.test(c.name)) throw new LoadError(`${where}: "${c.name}" is not a valid column name.`);
243
- if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(`${where}: unknown column type ${c.type}.`);
243
+ if (!valid.test(c.name)) throw new LoadError(tx('ash.bad_column', { where, col: c.name }));
244
+ if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(tx('ash.bad_type', { where, type: c.type }));
244
245
  }
245
- if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(`${where}: column names must be unique.`);
246
+ if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(tx('ash.dup_columns', { where }));
246
247
  const ident = pg.escapeIdentifier;
247
248
  const defs: string[] = [];
248
249
  if (!identityKey(p)) {
249
- if (p.cols.some((c) => c.name === 'id')) throw new LoadError(`${where}: "id" is the name of the new key column; rename the file column or choose it as the primary key.`);
250
+ if (p.cols.some((c) => c.name === 'id')) throw new LoadError(tx('ash.id_taken', { where }));
250
251
  defs.push('id bigint generated by default as identity primary key');
251
252
  }
252
253
  for (const c of p.cols) {
@@ -366,9 +367,9 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
366
367
  export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet[], b: Body): Promise<BuiltBook> {
367
368
  const v = (name: string) => String(b[name] ?? '');
368
369
  const plans = planFrom(sheets, v).filter((p) => p.on);
369
- if (!plans.length) throw new LoadError('Choose at least one sheet.');
370
+ if (!plans.length) throw new LoadError(tx('ash.choose_at_least_one_sheet'));
370
371
  const ddl = plans.map((p) => plannedTableSql(checked.schema, p));
371
- if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError('Each sheet needs a table name of its own.');
372
+ if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError(tx('ash.each_sheet_needs_a_table'));
372
373
  // only proposed foreign keys can be chosen (recomputed from the posted names and types)
373
374
  const fks = proposeForeignKeys(plans).filter((f) => v(fkName(f)) === String(f.to));
374
375
  const headers = b.h !== '0';
@@ -381,7 +382,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
381
382
  const tables: BuiltBook['tables'] = [];
382
383
  for (const [n, p] of plans.entries()) {
383
384
  await c.query(ddl[n]).catch((e) => {
384
- throw new LoadError((e as { code?: string }).code === '42P07' ? `The table ${checked.schema}.${p.table} already exists: choose another name.` : (e as Error).message);
385
+ throw new LoadError((e as { code?: string }).code === '42P07' ? tx('ash.table_exists', { table: `${checked.schema}.${p.table}` }) : (e as Error).message);
385
386
  });
386
387
  const T = (await c.query('select $1::regclass::text as t', [`${pg.escapeIdentifier(checked.schema)}.${pg.escapeIdentifier(p.table)}`])).rows[0].t as string;
387
388
  qualified.set(p.k, T);
@@ -413,7 +414,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
413
414
  const ident = pg.escapeIdentifier;
414
415
  await c.query(`create index on ${A} (${ident(col)})`);
415
416
  await c.query(`alter table ${A} add foreign key (${ident(col)}) references ${B} (${ident(key)})`).catch((e) => {
416
- throw new LoadError(`Foreign key ${a.table}.${col} → ${to.table}.${key}: ${(e as { detail?: string }).detail ?? (e as Error).message} Untick it, or fix the data.`);
417
+ throw new LoadError(tx('ash.fk_failed', { fk: `${a.table}.${col} → ${to.table}.${key}`, error: (e as { detail?: string }).detail ?? (e as Error).message }));
417
418
  });
418
419
  foreignKeys.push(`${a.table}.${col} → ${to.table}.${key}`);
419
420
  }
@@ -432,7 +433,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
432
433
  if (b.chart === 'true') {
433
434
  const dash = await addDashboard(c, checked.alias, [...qualified.values()], next);
434
435
  if (dash) pages.push(dash);
435
- else notes.push('No dashboard: no table has a foreign key or a text, yes/no or date column with a few values that repeat, to count the rows by.');
436
+ else notes.push(tx('ash.no_dashboard'));
436
437
  }
437
438
  await finishGeneratedApp(c, app.id, { description: checked.description });
438
439
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
@@ -462,22 +463,22 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
462
463
  const sections = sheets.map((sheet, k) => {
463
464
  const id = field(k, 'table');
464
465
  return region(
465
- `Sheet ${sheet.name}`,
466
- html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true', `Create a table from this sheet (${sheet.rows.length} row(s), ${sheet.headers.length} column(s))`)}</div>
466
+ tx('ash.sheet', { name: sheet.name }),
467
+ html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true', tx('ash.create_from_sheet', { rows: sheet.rows.length, cols: sheet.headers.length }))}</div>
467
468
  <div class="form-grid">
468
- <div class="field"><label class="label" for="f_${id}">Table name</label><input id="f_${id}" name="${id}" value="${v(id)}"></div>
469
- <div class="field"><label class="label" for="f_${field(k, 'key')}">Primary key</label>
470
- <select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', '- a new column id (identity) -'], ...sheet.headers.map((h, i): [string, string] => [String(i), h])].map(
469
+ <div class="field"><label class="label" for="f_${id}">${tx('ash.table_name')}</label><input id="f_${id}" name="${id}" value="${v(id)}"></div>
470
+ <div class="field"><label class="label" for="f_${field(k, 'key')}">${tx('ash.primary_key')}</label>
471
+ <select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', tx('ash.a_new_column_id_identity')], ...sheet.headers.map((h, i): [string, string] => [String(i), h])].map(
471
472
  ([value, label]) => html`<option value="${value}"${v(field(k, 'key')) === value ? raw(' selected') : ''}>${label}</option>`,
472
473
  )}</select>
473
- <small class="help">A whole-number key column becomes an identity key; another key column is unique, next to a new id.</small></div>
474
+ <small class="help">${tx('ash.a_whole_number_key_column')}</small></div>
474
475
  </div>
475
- <div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">File column</th><th scope="col">Values</th><th scope="col">Column name (empty = skip)</th><th scope="col">Type</th></tr></thead><tbody>
476
+ <div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">${tx('ash.file_column')}</th><th scope="col">${tx('ash.values')}</th><th scope="col">${tx('ash.col_name_head')}</th><th scope="col">${tx('ash.type')}</th></tr></thead><tbody>
476
477
  ${sheet.headers.map((h, i) => {
477
478
  const type = v(field(k, `type_${i}`));
478
479
  return html`<tr><td>${h}</td><td class="muted small ff-sample">${sample(sheet, i)}</td>
479
- <td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="Column name for ${h} (${sheet.name})"></td>
480
- <td><select name="${field(k, `type_${i}`)}" aria-label="Type of ${h} (${sheet.name})">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
480
+ <td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="${tx('ash.col_name_for', { col: h, sheet: sheet.name })}"></td>
481
+ <td><select name="${field(k, `type_${i}`)}" aria-label="${tx('ash.type_of', { col: h, sheet: sheet.name })}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
481
482
  })}
482
483
  </tbody></table></div>
483
484
  <details class="ff-preview"><summary>Preview${sheet.rows.length > 10 ? ' (first 10 rows)' : ''}</summary>${preview(sheet)}</details>`,
@@ -491,11 +492,11 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
491
492
  const fkList = fks.length
492
493
  ? html`<ul class="ff-fks">${fks.map(
493
494
  (f) => html`<li><input type="hidden" name="fkp_${f.from}_${f.col}" value="${String(f.to)}">${checkbox(fkName(f), String(f.to), fkChecked(f, b), html`${name(f.from, f.col)} → ${keyName(f.to)}`)}
494
- ${f.missing ? html`<small class="help">${f.missing} row(s) have a value that ${keyName(f.to)} doesn't have.</small>` : ''}</li>`,
495
+ ${f.missing ? html`<small class="help">${tx('ash.missing', { n: f.missing, key: keyName(f.to) })}</small>` : ''}</li>`,
495
496
  )}</ul>`
496
- : html`<p class="muted">No column matches another table's key. A column matches when it is named like the key column (dept_id), or like the other table and its key (department_id, department_code), with the same type.</p>`;
497
+ : html`<p class="muted">${tx('ash.no_column_matches_another_table')}</p>`;
497
498
  return html`${sections.map((x) => html`${x}<div class="u-spacer"></div>`)}
498
- ${region('Foreign keys', html`<p class="muted u-mt0">Proposed where a column matches another table's primary key; ticked when every value is found. Forms get a select list for each foreign key.</p>
499
+ ${region(tx('ash.foreign_keys'), html`<p class="muted u-mt0">${tx('ash.fk_intro')}</p>
499
500
  ${fkList}
500
- <div class="buttons"><button class="btn" name="action" value="preview">Update the proposals</button></div>`)}`;
501
+ <div class="buttons"><button class="btn" name="action" value="preview">${tx('ash.update_the_proposals')}</button></div>`)}`;
501
502
  }