pgkiln 0.82.0 → 0.84.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 (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
@@ -1,5 +1,5 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
- import { html, raw, type Raw } from '../html.ts';
2
+ import { esc, html, raw, type Raw } from '../html.ts';
3
3
  import { icon } from '../icons.ts';
4
4
  import { owner } from '../db.ts';
5
5
  import { clientIp, logActivity, type Session } from '../session.ts';
@@ -9,6 +9,7 @@ import { clearCompletions } from './code-editor.ts';
9
9
  import { roleHints, splitRoles } from './users.ts';
10
10
  import { currentWorkspace, inWorkspaceSql, usableSchemas, workshopDb } from './workspaces.ts';
11
11
  import { joinStatements, recordChanges } from '../changelog.ts';
12
+ import { tr } from './i18n.ts';
12
13
  import {
13
14
  APP_ROLE_RE, checkSecurity, currentDef, MASK_LABELS, MASKS, maskedColumns, readSecurity, securitySql, sees,
14
15
  type ColumnMask, type SecurityDef, type TableSecurity,
@@ -55,64 +56,65 @@ function fromForm(b: Body, cur: TableSecurity): SecurityDef {
55
56
  }
56
57
 
57
58
  function form(s: Session, cur: TableSecurity, def: SecurityDef, opts: { ref: string; sql: string[]; warnings: string[]; errors: string[]; output: Raw | '' }): Raw {
59
+ const tx = tr(s);
58
60
  const chk = (x: boolean) => (x ? raw(' checked') : '');
59
61
  const all = cur.columns.map((c) => c.name);
60
62
  const v = def.view;
61
63
  const masked = new Set(maskedColumns(v));
62
64
  const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref })}`;
63
65
  const policyUrl = `${BASE}/sql/objects/action?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref, do: 'policy' })}`;
64
- const rows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Database roles and columns</caption>
65
- <thead><tr><th scope="col">Database role</th>${all.map((c) => html`<th scope="col"><code>${c}</code>${masked.has(c) ? html` <span class="badge" title="Masked in ${v.name}">masked</span>` : ''}</th>`)}</tr></thead>
66
+ const rows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">${tx('ds.roles_and_columns')}</caption>
67
+ <thead><tr><th scope="col">${tx('ds.db_role')}</th>${all.map((c) => html`<th scope="col"><code>${c}</code>${masked.has(c) ? html` <span class="badge" title="${tx('ds.masked_in', { view: v.name })}">${tx('ds.masked')}</span>` : ''}</th>`)}</tr></thead>
66
68
  <tbody>${cur.grants.length ? def.grants.map((g, i) => {
67
69
  const info = cur.grants[i];
68
- return html`<tr><th scope="row"><code>${g.role === 'PUBLIC' ? 'PUBLIC' : g.role}</code>${info.apps.length ? html`<br><small class="muted">runs ${info.apps.join(', ')}</small>` : ''}</th>
69
- ${all.map((c, j) => html`<td class="ds-cell"><label class="check"><input type="checkbox" name="g.${i}.s.${j}"${chk(g.select.includes(c))}> read</label>
70
- <label class="check"><input type="checkbox" name="g.${i}.u.${j}"${chk(g.update.includes(c))}> update</label></td>`)}</tr>`;
71
- }) : html`<tr><td colspan="${all.length + 1}" class="muted">No database role has privileges on this table, and no application of the workspace runs with a database role.</td></tr>`}</tbody></table></div>
72
- ${cur.excluded.length ? html`<p class="muted small">Not in the matrix, because column privileges can't restrict them: ${cur.excluded.map((x) => html`<code>${x.role}</code> (${x.why}) `)}</p>` : ''}`;
73
- const maskRows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Masks per column</caption>
74
- <thead><tr><th scope="col">Column</th><th scope="col">Mask</th>${v.roles.map((r) => html`<th scope="col">${r} sees it</th>`)}<th scope="col">Everyone else sees it</th></tr></thead>
70
+ return html`<tr><th scope="row"><code>${g.role === 'PUBLIC' ? 'PUBLIC' : g.role}</code>${info.apps.length ? html`<br><small class="muted">${tx('ds.runs', { apps: info.apps.join(', ') })}</small>` : ''}</th>
71
+ ${all.map((c, j) => html`<td class="ds-cell"><label class="check"><input type="checkbox" name="g.${i}.s.${j}"${chk(g.select.includes(c))}> ${tx('ds.read')}</label>
72
+ <label class="check"><input type="checkbox" name="g.${i}.u.${j}"${chk(g.update.includes(c))}> ${tx('ds.update')}</label></td>`)}</tr>`;
73
+ }) : html`<tr><td colspan="${all.length + 1}" class="muted">${tx('ds.no_grants')}</td></tr>`}</tbody></table></div>
74
+ ${cur.excluded.length ? html`<p class="muted small">${tx('ds.excluded')} ${cur.excluded.map((x) => html`<code>${x.role}</code> (${x.why}) `)}</p>` : ''}`;
75
+ const maskRows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">${tx('ds.masks_per_column')}</caption>
76
+ <thead><tr><th scope="col">${tx('ds.column')}</th><th scope="col">${tx('ds.mask')}</th>${v.roles.map((r) => html`<th scope="col">${tx('ds.role_sees', { role: r })}</th>`)}<th scope="col">${tx('ds.others_see')}</th></tr></thead>
75
77
  <tbody>${all.map((c, j) => {
76
78
  const m = v.cols[c];
77
79
  return html`<tr><th scope="row"><code>${c}</code> <small class="muted">${cur.columns[j].type}</small></th>
78
- <td><select name="m.${j}.mask" aria-label="Mask of ${c}"><option value="">Not masked</option>${MASKS.map((x) => html`<option value="${x}"${m?.mask === x ? raw(' selected') : ''}>${MASK_LABELS[x]}</option>`)}</select></td>
79
- ${v.roles.map((r, k) => html`<td class="ds-c"><input type="checkbox" aria-label="${r} sees ${c}" name="m.${j}.see.${k}"${chk(!!m && sees(v, c, r))}></td>`)}
80
- <td class="ds-c"><input type="checkbox" aria-label="Everyone else sees ${c}" name="m.${j}.others"${chk(!!m && m.others)}></td></tr>`;
80
+ <td><select name="m.${j}.mask" aria-label="${tx('ds.aria_mask', { column: c })}"><option value="">${tx('ds.not_masked')}</option>${MASKS.map((x) => html`<option value="${x}"${m?.mask === x ? raw(' selected') : ''}>${tx.dyn(`ds.mask.${x}`, MASK_LABELS[x])}</option>`)}</select></td>
81
+ ${v.roles.map((r, k) => html`<td class="ds-c"><input type="checkbox" aria-label="${tx('ds.aria_role_sees', { role: r, column: c })}" name="m.${j}.see.${k}"${chk(!!m && sees(v, c, r))}></td>`)}
82
+ <td class="ds-c"><input type="checkbox" aria-label="${tx('ds.aria_others_see', { column: c })}" name="m.${j}.others"${chk(!!m && m.others)}></td></tr>`;
81
83
  })}</tbody></table></div>`;
82
84
  return html`<form method="post" action="${BASE}/sql/objects/security" class="ds">${csrf(s)}
83
85
  <input type="hidden" name="ref" value="${opts.ref}"><input type="hidden" name="shown_roles" value="${v.roles.join(', ')}">
84
86
  ${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
85
- <section class="ds-section"><h2>${icon('shield')} Rows</h2>
86
- <p>Row level security is <strong>${cur.rls ? (cur.forceRls ? 'on, also for the owner' : 'on') : 'off'}</strong>${cur.rls ? '' : ': every role that may read the table reads every row'}.
87
- <a href="${BASE}/sql/objects/table?${new URLSearchParams({ o: opts.ref })}">Table Properties</a> switches it on; <a href="${policyUrl}">Add policy</a> writes one in the worksheet.</p>
88
- ${cur.policies.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Policy</th><th>Command</th><th>Roles</th><th>Using</th><th>With check</th></tr></thead><tbody>
89
- ${cur.policies.map((p) => html`<tr><td>${p.name}${p.permissive === 'RESTRICTIVE' ? html` <span class="badge">restrictive</span>` : ''}</td><td>${p.command}</td><td><code>${p.roles}</code></td><td><code>${p.using ?? ''}</code></td><td><code>${p.check ?? ''}</code></td></tr>`)}</tbody></table></div>`
90
- : html`<p class="muted">No policies${cur.rls ? ': with row level security on, the roles below read no rows at all' : ''}.</p>`}
87
+ <section class="ds-section"><h2>${icon('shield')} ${tx('ds.rows')}</h2>
88
+ <p>${tx.rich('ds.rls_is', { state: raw(`<strong>${esc(cur.rls ? (cur.forceRls ? tx('ds.rls_on_owner') : tx('ds.rls_on')) : tx('ds.rls_off'))}</strong>`) })}${cur.rls ? '' : `: ${tx('ds.rls_off_detail')}`}.
89
+ ${tx.rich('ds.rls_how', { props: raw(`<a href="${BASE}/sql/objects/table?${new URLSearchParams({ o: opts.ref })}">${esc(tx('ds.table_properties'))}</a>`), policy: raw(`<a href="${policyUrl}">${esc(tx('ds.add_policy'))}</a>`) })}</p>
90
+ ${cur.policies.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('ds.policy')}</th><th>${tx('ds.command')}</th><th>${tx('ds.roles')}</th><th>Using</th><th>With check</th></tr></thead><tbody>
91
+ ${cur.policies.map((p) => html`<tr><td>${p.name}${p.permissive === 'RESTRICTIVE' ? html` <span class="badge">${tx('ds.restrictive')}</span>` : ''}</td><td>${p.command}</td><td><code>${p.roles}</code></td><td><code>${p.using ?? ''}</code></td><td><code>${p.check ?? ''}</code></td></tr>`)}</tbody></table></div>`
92
+ : html`<p class="muted">${cur.rls ? tx('ds.no_policies_rls') : tx('ds.no_policies')}</p>`}
91
93
  </section>
92
- <section class="ds-section"><h2>${icon('table')} Columns</h2>
93
- <p class="muted">What each database role may read and update, per column. A column a role may not read is refused by PostgreSQL whoever sends the query: pages, REST handlers, AutoREST, AI tools, MCP or psql.</p>
94
+ <section class="ds-section"><h2>${icon('table')} ${tx('ds.columns')}</h2>
95
+ <p class="muted">${tx('ds.columns_help')}</p>
94
96
  ${rows}
95
97
  </section>
96
- <section class="ds-section"><h2>${icon('eye')} Values</h2>
97
- <p class="muted">Masked columns show their value only to the application roles you tick (<code>meta.has_role()</code> of the signed-in user); others see the mask. The masks live in a view next to the table that the roles above read instead of the table.</p>
98
- <label class="check"><input type="checkbox" name="view"${chk(v.enabled)}> Masking view</label>
98
+ <section class="ds-section"><h2>${icon('eye')} ${tx('ds.values')}</h2>
99
+ <p class="muted">${tx.code('ds.values_help')}</p>
100
+ <label class="check"><input type="checkbox" name="view"${chk(v.enabled)}> ${tx('ds.masking_view')}</label>
99
101
  <div class="form-grid">
100
- <div class="field"><label class="label" for="ds-view">View name</label><input id="ds-view" name="view_name" value="${v.name}" autocomplete="off" spellcheck="false"></div>
101
- <div class="field"><label class="label" for="ds-roles">Application roles</label><input id="ds-roles" name="roles" value="${v.roles.join(', ')}" autocomplete="off" spellcheck="false">
102
- <small class="help">Comma-separated. Changed roles get their column in the table below after Preview SQL.</small></div>
102
+ <div class="field"><label class="label" for="ds-view">${tx('ds.view_name')}</label><input id="ds-view" name="view_name" value="${v.name}" autocomplete="off" spellcheck="false"></div>
103
+ <div class="field"><label class="label" for="ds-roles">${tx('ds.app_roles')}</label><input id="ds-roles" name="roles" value="${v.roles.join(', ')}" autocomplete="off" spellcheck="false">
104
+ <small class="help">${tx('ds.app_roles_help')}</small></div>
103
105
  </div>
104
106
  ${maskRows}
105
107
  </section>
106
108
  <section class="ds-section"><h2>${icon('code')} SQL</h2>
107
109
  ${opts.warnings.length ? html`<div class="alert alert-warning" role="status"><ul>${opts.warnings.map((w) => html`<li>${w}</li>`)}</ul></div>` : ''}
108
- ${opts.sql.length ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="SQL">${opts.sql.join(';\n\n')};</pre>` : html`<p class="muted">No changes: the table's security is as the form shows it.</p>`}
110
+ ${opts.sql.length ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="SQL">${opts.sql.join(';\n\n')};</pre>` : html`<p class="muted">${tx('ds.no_changes')}</p>`}
109
111
  ${opts.output}
110
112
  </section>
111
113
  <div class="buttons">
112
- <button class="btn" name="mode" value="preview">${icon('eye')} Preview SQL</button>
113
- <button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
114
- <button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
115
- <a class="btn" href="${objUrl}">Cancel</a>
114
+ <button class="btn" name="mode" value="preview">${icon('eye')} ${tx('ds.preview_sql')}</button>
115
+ <button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
116
+ <button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
117
+ <a class="btn" href="${objUrl}">${tx('common.cancel')}</a>
116
118
  </div></form>`;
117
119
  }
118
120
 
@@ -139,7 +141,7 @@ async function page(req: Req, reply: FastifyReply) {
139
141
  if (!errors.length) ({ statements: sql, warnings } = securitySql(def, cur));
140
142
  if (!errors.length && b.mode === 'worksheet') {
141
143
  s.state.__SQLDRAFT = sql.length ? `${sql.join(';\n\n')};` : '-- no changes';
142
- flash(s, 'Review the statements and run them: nothing has been changed yet.');
144
+ flash(s, tr(s)('te.review'));
143
145
  return back(reply, s, `${BASE}/sql/worksheet`);
144
146
  }
145
147
  if (!errors.length && b.mode === 'apply' && sql.length) {
@@ -163,16 +165,16 @@ async function page(req: Req, reply: FastifyReply) {
163
165
  clearCompletions();
164
166
  if (!failed) {
165
167
  await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', name: `security_${cur.table}`.slice(0, 63), defaultSchema: cur.schema, statements: [joinStatements(sql)] });
166
- flash(s, `Changed the security of ${cur.schema}.${cur.table}: ${sql.length} statement${sql.length === 1 ? '' : 's'}.`);
168
+ flash(s, sql.length === 1 ? tr(s)('ds.changed_1', { table: `${cur.schema}.${cur.table}` }) : tr(s)('ds.changed', { table: `${cur.schema}.${cur.table}`, n: sql.length }));
167
169
  return back(reply, s, `${BASE}/sql/objects/security?${new URLSearchParams({ o: ref })}`);
168
170
  }
169
- output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
171
+ output = html`<div class="alert alert-error" role="alert"><p>${tr(s)('ds.rolled_back')}</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tr(s)('ds.failed_statement')}">${at}</pre></div>`;
170
172
  }
171
173
  }
172
- const title = `Security of ${cur.schema}.${cur.table}`;
174
+ const title = tr(s)('ds.title', { table: `${cur.schema}.${cur.table}` });
173
175
  const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: ref })}`;
174
176
  const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects', s)}${form(s, cur, def, { ref, sql, warnings, errors, output })}`;
175
- return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], ['Security']], main, 'sql'));
177
+ return send(reply, s, shell(s, title, [[tr(s)('rail.sql_workshop'), `${BASE}/sql`], [tr(s)('sql.tool.objects'), `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], [tr(s)('ds.security')]], main, 'sql'));
176
178
  }
177
179
 
178
180
  export async function dataSecurityRoutes(app: FastifyInstance) {
@@ -1,6 +1,6 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
- import { html, raw, type Raw } from '../html.ts';
3
+ import { esc, html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import {
6
6
  applyMapping,
@@ -27,6 +27,7 @@ import type { Session } from '../session.ts';
27
27
  import type { ComponentSpec } from './components.ts';
28
28
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
29
29
  import { appWorkspace, currentWorkspace, workshopDb } from './workspaces.ts';
30
+ import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
30
31
 
31
32
  // SQL Workshop → Load Data: upload a CSV/TSV/XLSX/JSON/XML file, preview it,
32
33
  // and load it into a new table (column types inferred) or an existing one
@@ -125,21 +126,21 @@ export function preview(sheet: Sheet) {
125
126
  <tbody>${sheet.rows.slice(0, PREVIEW_ROWS).map((r) => html`<tr>${r.map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : v}</td>`)}</tr>`)}</tbody></table></div>`;
126
127
  }
127
128
 
128
- export function resultHtml(r: LoadResult, failed: boolean) {
129
+ export function resultHtml(r: LoadResult, failed: boolean, tx: BuilderTranslate = translatorFor('en')) {
129
130
  const errors = r.errors.length
130
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th class="num">Row</th><th>Error</th></tr></thead>
131
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th class="num">${tx('dl.row')}</th><th>${tx('dl.error')}</th></tr></thead>
131
132
  <tbody>${r.errors.map((e) => html`<tr><td class="num">${e.row}</td><td>${e.message}</td></tr>`)}</tbody></table></div>
132
- ${r.failed > r.errors.length ? html`<p class="muted">The first ${r.errors.length} errors are shown.</p>` : ''}`
133
+ ${r.failed > r.errors.length ? html`<p class="muted">${tx('dl.first_errors', { n: r.errors.length })}</p>` : ''}`
133
134
  : '';
134
135
  const summary = failed
135
- ? html`<div class="alert alert-error" role="alert">${r.failed}${r.errors.length >= 100 ? '+' : ''} row(s) could not be loaded, so nothing was loaded. Fix the file, or tick “Skip rows with errors”.</div>`
136
- : html`<div class="alert alert-success" role="status">${r.table}: ${r.inserted} row(s) inserted${r.updated ? `, ${r.updated} updated` : ''}${r.failed ? `, ${r.failed} skipped` : ''}.</div>`;
136
+ ? html`<div class="alert alert-error" role="alert">${tx('dl.failed', { n: `${r.failed}${r.errors.length >= 100 ? '+' : ''}` })}</div>`
137
+ : html`<div class="alert alert-success" role="status">${r.table}: ${tx('dl.inserted', { n: r.inserted })}${r.updated ? `, ${tx('dl.updated', { n: r.updated })}` : ''}${r.failed ? `, ${tx('dl.skipped', { n: r.failed })}` : ''}.</div>`;
137
138
  return html`${summary}${errors}`;
138
139
  }
139
140
 
140
141
  export async function dataLoadRoutes(app: FastifyInstance) {
141
142
  const page = (s: Session, reply: FastifyReply, main: Raw) =>
142
- send(reply, s, shell(s, 'Load Data', [['SQL Workshop', `${BASE}/sql`], ['Load Data']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('load', s)}${main}`, 'sql'));
143
+ send(reply, s, shell(s, tr(s)('sql.tool.load'), [[tr(s)('rail.sql_workshop'), `${BASE}/sql`], [tr(s)('sql.tool.load')]], html`<h1 class="u-mb1">${tr(s)('rail.sql_workshop')}</h1>${workshopTabs('load', s)}${main}`, 'sql'));
143
144
 
144
145
  // step 1: choose a file
145
146
  app.get(`${BASE}/sql/load`, async (req: Req, reply) => {
@@ -150,28 +151,28 @@ export async function dataLoadRoutes(app: FastifyInstance) {
150
151
 
151
152
  const uploadForm = async (s: Session, error?: string, chosen?: string) => {
152
153
  const defs = await definitions(s);
154
+ const tx = tr(s);
153
155
  return region(
154
- 'Load data from a file',
156
+ tx('dl.from_file'),
155
157
  html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
156
- <p class="muted">CSV or TSV (UTF-8 or Windows-1252; the delimiter is detected), Excel .xlsx (the first sheet), JSON (an array of objects, or JSON Lines) or XML (rows from a repeating element), up to ${MAX_MB} MB.
157
- Next you choose a new or existing table. Loading runs as the builder's owner connection.
158
- To download a table or a query as a file, use <a href="${BASE}/sql/unload">Unload Data</a>.</p>
158
+ <p class="muted">${tx('dl.intro', { mb: MAX_MB })}
159
+ ${tx.rich('dl.intro_unload', { link: raw(`<a href="${BASE}/sql/unload">${esc(tx('sql.tool.unload'))}</a>`) })}</p>
159
160
  <form method="post" enctype="multipart/form-data">${csrf(s)}
160
161
  <div class="form-grid">
161
- <div class="field"><label class="label" for="f_file">File</label>
162
+ <div class="field"><label class="label" for="f_file">${tx('dl.file')}</label>
162
163
  <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>
163
164
  <div class="field"><span class="label" aria-hidden="true"></span>
164
- <label class="check"><input type="checkbox" name="headers" value="true" checked> First row contains column names</label></div>
165
- <div class="field"><label class="label" for="f_row_tag">XML row element</label>
166
- <input id="f_row_tag" name="row_tag" placeholder="e.g. employee (empty: detected)">
167
- <small class="help">XML only. DTDs and entity declarations are not accepted.</small></div>
168
- <div class="field"><label class="label" for="f_definition">Data load definition</label>
169
- <select id="f_definition" name="definition"><option value="">- none: choose the table next -</option>${defs.map(
165
+ <label class="check"><input type="checkbox" name="headers" value="true" checked> ${tx('dl.headers')}</label></div>
166
+ <div class="field"><label class="label" for="f_row_tag">${tx('dl.row_tag')}</label>
167
+ <input id="f_row_tag" name="row_tag" placeholder="${tx('dl.row_tag_ph')}">
168
+ <small class="help">${tx('dl.row_tag_help')}</small></div>
169
+ <div class="field"><label class="label" for="f_definition">${tx('comp.data_load_def')}</label>
170
+ <select id="f_definition" name="definition"><option value="">${tx('dl.no_definition')}</option>${defs.map(
170
171
  (d) => html`<option value="${d.id}"${String(d.id) === chosen ? raw(' selected') : ''}>${d.app_name} · ${d.name} → ${d.table_name}</option>`,
171
172
  )}</select>
172
- <small class="help">Shared Components of an application: table, column mapping and transformations.</small></div>
173
+ <small class="help">${tx('dl.definition_help')}</small></div>
173
174
  </div>
174
- <div class="buttons"><button class="btn btn-hot">${icon('upload')} Next</button></div>
175
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} ${tx('dl.next')}</button></div>
175
176
  </form>`,
176
177
  );
177
178
  };
@@ -185,8 +186,8 @@ export async function dataLoadRoutes(app: FastifyInstance) {
185
186
  }
186
187
  const s = await developer(req, reply);
187
188
  if (!s) return;
188
- if (!file) return page(s, reply, await uploadForm(s, 'Choose a file.'));
189
- if (file.truncated) return page(s, reply, await uploadForm(s, `The file is larger than ${MAX_MB} MB.`));
189
+ if (!file) return page(s, reply, await uploadForm(s, tr(s)('dl.e.no_file')));
190
+ if (file.truncated) return page(s, reply, await uploadForm(s, tr(s)('dl.e.too_large', { mb: MAX_MB })));
190
191
  const def = await definition(s, req.body?.definition);
191
192
  const rowTag = (req.body?.row_tag ?? '').trim();
192
193
  try {
@@ -220,11 +221,14 @@ export async function dataLoadRoutes(app: FastifyInstance) {
220
221
  if (e instanceof LoadError) return page(s, reply, await uploadForm(s, e.message));
221
222
  throw e;
222
223
  }
224
+ const tx = tr(s);
223
225
  const action = `${BASE}/sql/load/${req.params.id}`;
224
226
  const hidden = html`${csrf(s)}<input type="hidden" name="h" value="${headers ? '1' : '0'}"><input type="hidden" name="rt" value="${rowTag}">`;
225
- const info = `${f.filename}: ${sheet.rows.length} row(s), ${sheet.headers.length} column(s)${
226
- sheet.format === 'xlsx' ? ', Excel' : sheet.format === 'json' ? ', JSON' : sheet.format === 'xml' ? `, XML rows <${sheet.rowPath}>` : `, delimiter ${sheet.delimiter === '\t' ? 'tab' : `“${sheet.delimiter}”`}`
227
+ const info = `${f.filename}: ${tx('dl.info', { rows: sheet.rows.length, columns: sheet.headers.length })}${
228
+ sheet.format === 'xlsx' ? ', Excel' : sheet.format === 'json' ? ', JSON' : sheet.format === 'xml' ? `, ${tx('dl.xml_rows', { path: `<${sheet.rowPath}>` })}` : `, ${tx('dl.delimiter', { d: sheet.delimiter === '\t' ? tx('dl.tab') : `“${sheet.delimiter}”` })}`
227
229
  }`;
230
+ const firstShown = sheet.rows.length > PREVIEW_ROWS ? ` ${tx('dl.first_shown', { n: PREVIEW_ROWS })}` : '';
231
+ const anotherFile = html`<a href="${BASE}/sql/load">${tx('dl.another_file')}</a>`;
228
232
  if (def) {
229
233
  let mapped: Sheet = sheet;
230
234
  let problem = '';
@@ -234,34 +238,34 @@ export async function dataLoadRoutes(app: FastifyInstance) {
234
238
  if (!(e instanceof LoadError)) throw e;
235
239
  problem = e.message;
236
240
  }
237
- const defHtml = html`<p>Definition <a href="${BASE}/apps/${def.app_id}/shared?c=data_load_def-${def.id}">${def.name}</a>: into <b>${def.table_name}</b>, mode ${def.mode}${def.skip_errors ? ', skipping rows with errors' : ''}.
238
- ${def.columns?.length ? '' : ' File columns are matched to table columns by name.'}</p>
239
- ${problem ? html`<div class="alert alert-error" role="alert">${problem}</div>` : html`<p class="muted">After the mapping and transformations${mapped.rows.length > PREVIEW_ROWS ? ` (first ${PREVIEW_ROWS} rows)` : ''}:</p>${preview(mapped)}`}
241
+ const defHtml = html`<p>${tx.rich('dl.def_into', { def: raw(`<a href="${BASE}/apps/${def.app_id}/shared?c=data_load_def-${def.id}">${esc(def.name)}</a>`), table: raw(`<b>${esc(def.table_name)}</b>`), mode: def.mode })}${def.skip_errors ? `, ${tx('dl.def_skipping')}` : ''}.
242
+ ${def.columns?.length ? '' : ` ${tx('dl.def_by_name')}`}</p>
243
+ ${problem ? html`<div class="alert alert-error" role="alert">${problem}</div>` : html`<p class="muted">${mapped.rows.length > PREVIEW_ROWS ? tx('dl.after_mapping_first', { n: PREVIEW_ROWS }) : tx('dl.after_mapping')}</p>${preview(mapped)}`}
240
244
  <form method="post" action="${action}">${hidden}<input type="hidden" name="target" value="definition"><input type="hidden" name="d" value="${def.id}">
241
- <div class="buttons"><button class="btn btn-hot"${problem ? raw(' disabled') : ''}>${icon('upload')} Load into ${def.table_name}</button></div></form>`;
245
+ <div class="buttons"><button class="btn btn-hot"${problem ? raw(' disabled') : ''}>${icon('upload')} ${tx('dl.load_into', { table: def.table_name })}</button></div></form>`;
242
246
  return page(
243
247
  s,
244
248
  reply,
245
249
  html`${result}
246
- ${region('Preview', html`<p class="muted">${info}${sheet.rows.length > PREVIEW_ROWS ? ` (first ${PREVIEW_ROWS} shown)` : ''}. <a href="${BASE}/sql/load">Choose another file</a></p>${preview(sheet)}`)}
250
+ ${region(tx('dl.preview'), html`<p class="muted">${info}${firstShown}. ${anotherFile}</p>${preview(sheet)}`)}
247
251
  <div class="u-spacer"></div>
248
- ${region(`Load with ${def.name}`, defHtml)}`,
252
+ ${region(tx('dl.load_with', { name: def.name }), defHtml)}`,
249
253
  );
250
254
  }
251
- const skip = html`<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="skip_errors" value="true"> Skip rows with errors (otherwise nothing is loaded when a row fails)</label></div>`;
255
+ const skip = html`<div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="skip_errors" value="true"> ${tx('dl.skip_errors')}</label></div>`;
252
256
 
253
257
  const newCols = suggestColumns(sheet);
254
258
  const newTable = html`<form method="post" action="${action}">${hidden}<input type="hidden" name="target" value="new">
255
- <div class="form-grid"><div class="field"><label class="label" for="f_new_table">Table name</label>
259
+ <div class="form-grid"><div class="field"><label class="label" for="f_new_table">${tx('dl.table_name')}</label>
256
260
  <input id="f_new_table" name="new_table" value="${tableName(f.filename)}" required>
257
- <small class="help">schema.table; the table gets an identity primary key <code>id</code>.</small></div></div>
258
- <div class="table-wrap"><table class="report"><thead><tr><th>File column</th><th>Column name (empty = skip)</th><th>Type</th></tr></thead><tbody>
261
+ <small class="help">${tx.code('dl.table_name_help')}</small></div></div>
262
+ <div class="table-wrap"><table class="report"><thead><tr><th>${tx('dl.file_column')}</th><th>${tx('dl.column_name')}</th><th>${tx('dl.type')}</th></tr></thead><tbody>
259
263
  ${newCols.map((c) => html`<tr><td>${sheet.headers[c.index]}</td>
260
- <td><input name="name_${c.index}" value="${c.name}" aria-label="Column name for ${sheet.headers[c.index]}"></td>
261
- <td><select name="type_${c.index}" aria-label="Type of ${sheet.headers[c.index]}">${COLUMN_TYPES.map((t) => html`<option${t === c.type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`)}
264
+ <td><input name="name_${c.index}" value="${c.name}" aria-label="${tx('dl.aria_name', { column: sheet.headers[c.index] })}"></td>
265
+ <td><select name="type_${c.index}" aria-label="${tx('dl.aria_type', { column: sheet.headers[c.index] })}">${COLUMN_TYPES.map((t) => html`<option${t === c.type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`)}
262
266
  </tbody></table></div>
263
267
  ${skip}
264
- <div class="buttons"><button class="btn btn-hot">Create table and load</button></div></form>`;
268
+ <div class="buttons"><button class="btn btn-hot">${tx('dl.create_and_load')}</button></div></form>`;
265
269
 
266
270
  const db = await workshopDb(s);
267
271
  const tables = (
@@ -279,44 +283,44 @@ export async function dataLoadRoutes(app: FastifyInstance) {
279
283
  const auto = autoMap(sheet.headers, cols);
280
284
  const hasPk = cols.some((c) => c.pk);
281
285
  mapping = html`<form method="post" action="${action}">${hidden}<input type="hidden" name="target" value="existing"><input type="hidden" name="table" value="${chosen}">
282
- <div class="table-wrap"><table class="report"><thead><tr><th>File column</th><th>Table column</th></tr></thead><tbody>
286
+ <div class="table-wrap"><table class="report"><thead><tr><th>${tx('dl.file_column')}</th><th>${tx('dl.table_column')}</th></tr></thead><tbody>
283
287
  ${sheet.headers.map((h, i) => {
284
288
  const cur = auto.find((m) => m.index === i)?.column ?? '';
285
- return html`<tr><td>${h}</td><td><select name="map_${i}" aria-label="Table column for ${h}"><option value="">- skip -</option>${cols
289
+ return html`<tr><td>${h}</td><td><select name="map_${i}" aria-label="${tx('dl.aria_map', { column: h })}"><option value="">${tx('dl.skip')}</option>${cols
286
290
  .filter((c) => !c.generated)
287
- .map((c) => html`<option value="${c.name}"${c.name === cur ? raw(' selected') : ''}>${c.name} (${c.type}${c.pk ? ', key' : ''})</option>`)}</select></td></tr>`;
291
+ .map((c) => html`<option value="${c.name}"${c.name === cur ? raw(' selected') : ''}>${c.name} (${c.type}${c.pk ? `, ${tx('dl.key')}` : ''})</option>`)}</select></td></tr>`;
288
292
  })}
289
293
  </tbody></table></div>
290
- <fieldset class="field"><legend class="label">Mode</legend><div class="radio-group">
291
- <label class="check"><input type="radio" name="mode" value="append" checked> Append: insert all rows</label>
292
- <label class="check"><input type="radio" name="mode" value="merge"${hasPk ? '' : raw(' disabled')}> Merge: update rows with the same primary key, insert the others</label>
293
- <label class="check"><input type="radio" name="mode" value="replace"> Replace: delete all rows first</label>
294
+ <fieldset class="field"><legend class="label">${tx('dl.mode')}</legend><div class="radio-group">
295
+ <label class="check"><input type="radio" name="mode" value="append" checked> ${tx('dl.mode_append')}</label>
296
+ <label class="check"><input type="radio" name="mode" value="merge"${hasPk ? '' : raw(' disabled')}> ${tx('dl.mode_merge')}</label>
297
+ <label class="check"><input type="radio" name="mode" value="replace"> ${tx('dl.mode_replace')}</label>
294
298
  </div></fieldset>
295
299
  ${skip}
296
- <div class="buttons"><button class="btn btn-hot">Load into ${chosen}</button></div>
297
- <details class="u-mt1"><summary>Save this mapping as a data load definition</summary>
298
- <p class="muted">For the data_load process of an application, and to load files like this one again.</p>
300
+ <div class="buttons"><button class="btn btn-hot">${tx('dl.load_into', { table: chosen })}</button></div>
301
+ <details class="u-mt1"><summary>${tx('dl.save_mapping')}</summary>
302
+ <p class="muted">${tx('dl.save_mapping_help')}</p>
299
303
  <div class="form-grid">
300
- <div class="field"><label class="label" for="f_def_app">Application</label>
304
+ <div class="field"><label class="label" for="f_def_app">${tx('rm.application')}</label>
301
305
  <select id="f_def_app" name="def_app">${apps.map((a) => html`<option value="${a.id}">${a.name}</option>`)}</select></div>
302
- <div class="field"><label class="label" for="f_def_name">Name</label><input id="f_def_name" name="def_name" placeholder="e.g. ${defName(f.filename)}"></div>
306
+ <div class="field"><label class="label" for="f_def_name">${tx('dl.name')}</label><input id="f_def_name" name="def_name" placeholder="${tx('rm.eg', { example: defName(f.filename) })}"></div>
303
307
  </div>
304
- <div class="buttons"><button class="btn" name="target" value="save_definition">${icon('check')} Save as definition</button></div>
308
+ <div class="buttons"><button class="btn" name="target" value="save_definition">${icon('check')} ${tx('dl.save_definition')}</button></div>
305
309
  </details></form>`;
306
310
  }
307
311
  const existing = html`<form method="get" action="${action}" class="search u-mwnone">
308
312
  <input type="hidden" name="h" value="${headers ? '1' : '0'}"><input type="hidden" name="rt" value="${rowTag}">
309
- <select name="table" aria-label="Table"><option value="">- choose a table -</option>${tables.map((t) => html`<option${t === chosen ? raw(' selected') : ''}>${t}</option>`)}</select>
310
- <button class="btn">Map columns</button></form>${mapping}`;
313
+ <select name="table" aria-label="${tx('dl.table')}"><option value="">${tx('dl.choose_table')}</option>${tables.map((t) => html`<option${t === chosen ? raw(' selected') : ''}>${t}</option>`)}</select>
314
+ <button class="btn">${tx('dl.map_columns')}</button></form>${mapping}`;
311
315
 
312
316
  return page(
313
317
  s,
314
318
  reply,
315
319
  html`${result}
316
- ${region(`Preview`, html`<p class="muted">${info}${sheet.rows.length > PREVIEW_ROWS ? ` (first ${PREVIEW_ROWS} shown)` : ''}. <a href="${BASE}/sql/load">Choose another file</a></p>${preview(sheet)}`)}
320
+ ${region(tx('dl.preview'), html`<p class="muted">${info}${firstShown}. ${anotherFile}</p>${preview(sheet)}`)}
317
321
  <div class="columns u-mt1">
318
- ${region('Load into a new table', newTable)}
319
- ${region('Load into an existing table', existing)}
322
+ ${region(tx('dl.into_new'), newTable)}
323
+ ${region(tx('dl.into_existing'), existing)}
320
324
  </div>`,
321
325
  );
322
326
  };
@@ -344,20 +348,20 @@ export async function dataLoadRoutes(app: FastifyInstance) {
344
348
  const mode = ['append', 'replace', 'merge'].includes(b.mode ?? '') ? b.mode : 'append';
345
349
  const format = sheet instanceof Error ? 'auto' : sheet.format;
346
350
  try {
347
- if (!/^\d{1,9}$/.test(String(b.def_app ?? '')) || (await appWorkspace(String(b.def_app))) !== currentWorkspace(s)) throw new LoadError('Choose an application of this workspace.');
351
+ if (!/^\d{1,9}$/.test(String(b.def_app ?? '')) || (await appWorkspace(String(b.def_app))) !== currentWorkspace(s)) throw new LoadError(tr(s)('rm.e.choose_app'));
348
352
  const r = await owner.one(
349
353
  `insert into meta.data_load_def (app_id, name, table_name, format, headers, row_tag, mode, skip_errors, columns)
350
354
  values ($1, $2, $3, $4, $5, $6, $7, $8, $9) returning id, app_id`,
351
355
  [b.def_app, name, b.table, format, headers, format === 'xml' && !(sheet instanceof Error) ? sheet.rowPath : null, mode, b.skip_errors === 'true', JSON.stringify(columns)],
352
356
  );
353
- flash(s, `Data load definition ${name} saved.`);
357
+ flash(s, tr(s)('dl.def_saved', { name }));
354
358
  return back(reply, s, `${BASE}/apps/${r!.app_id}/shared?c=data_load_def-${r!.id}`);
355
359
  } catch (e) {
356
360
  const code = (e as { code?: string }).code;
357
361
  if (!(e instanceof LoadError) && !code?.startsWith('23') && code !== '22P02') throw e;
358
362
  reply.code(422);
359
- return targetPage(req, reply, s, html`<div class="alert alert-error" role="alert">The definition could not be saved: ${
360
- code === '23505' ? `the application already has a definition named ${name}.` : (e as Error).message}</div>`);
363
+ return targetPage(req, reply, s, html`<div class="alert alert-error" role="alert">${tr(s)('dl.def_not_saved', {
364
+ reason: code === '23505' ? tr(s)('dl.def_exists', { name }) : (e as Error).message })}</div>`);
361
365
  }
362
366
  }
363
367
  let result: LoadResult | undefined;
@@ -369,12 +373,12 @@ export async function dataLoadRoutes(app: FastifyInstance) {
369
373
  const opts = { skipErrors: b.skip_errors === 'true', firstRow: headers && sheet && (sheet.format === 'csv' || sheet.format === 'xlsx') ? 2 : 1 };
370
374
  result = await (await workshopDb(s)).tx(async (c) => {
371
375
  if (def) return loadWithDefinition(c, def, f);
372
- if (!sheet) throw new LoadError('The data load definition no longer exists.');
376
+ if (!sheet) throw new LoadError(tr(s)('dl.e.def_gone'));
373
377
  if (b.target === 'new') {
374
378
  const cols: NewColumn[] = sheet.headers
375
379
  .map((_, index) => ({ index, name: (b[`name_${index}`] ?? '').trim(), type: (b[`type_${index}`] ?? 'text') as NewColumn['type'] }))
376
380
  .filter((x) => x.name);
377
- if (!cols.length) throw new LoadError('Give at least one column a name.');
381
+ if (!cols.length) throw new LoadError(tr(s)('dl.e.no_columns'));
378
382
  await c.query(createTableSql((b.new_table ?? '').trim(), cols));
379
383
  const table = (b.new_table ?? '').trim();
380
384
  return loadRows(c, sheet, { ...opts, table, mode: 'append', columns: cols.map((x) => ({ index: x.index, column: x.name })) });
@@ -395,12 +399,12 @@ export async function dataLoadRoutes(app: FastifyInstance) {
395
399
  return page(
396
400
  s,
397
401
  reply,
398
- html`${resultHtml(result, false)}<div class="buttons">
399
- <a class="btn btn-hot" href="${BASE}/sql/objects?o=${encodeURIComponent(result.table)}">${icon('table')} View ${result.table}</a>
400
- <a class="btn" href="${BASE}/sql/load">${icon('upload')} Load another file</a></div>`,
402
+ html`${resultHtml(result, false, tr(s))}<div class="buttons">
403
+ <a class="btn btn-hot" href="${BASE}/sql/objects?o=${encodeURIComponent(result.table)}">${icon('table')} ${tr(s)('dl.view', { table: result.table })}</a>
404
+ <a class="btn" href="${BASE}/sql/load">${icon('upload')} ${tr(s)('dl.load_another')}</a></div>`,
401
405
  );
402
406
  }
403
407
  reply.code(422);
404
- return targetPage(req, reply, s, result ? resultHtml(result, true) : html`<div class="alert alert-error" role="alert">${error}</div>`);
408
+ return targetPage(req, reply, s, result ? resultHtml(result, true, tr(s)) : html`<div class="alert alert-error" role="alert">${error}</div>`);
405
409
  });
406
410
  }