pgkiln 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -7,6 +7,7 @@ import { publicUrl } from '../sso.ts';
7
7
  import { icon } from '../icons.ts';
8
8
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
9
9
  import { roleHints, roleHintsHtml, splitRoles } from './users.ts';
10
+ import { scopedTx as tx } from './i18n.ts';
10
11
 
11
12
  // Per-application REST API page: the database role API tokens use, what
12
13
  // PostgREST exposes to it, and developer-issued tokens for trying it out.
@@ -58,43 +59,42 @@ async function clientsRegion(s: Session, a: any, secret?: NewSecret, usable = tr
58
59
  html`<form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/${path}" class="inline-form">${csrf(s)}${extra}<button class="${cls}">${label}</button></form>`;
59
60
  const createForm = html`<form method="post" action="${BASE}/apps/${a.id}/api/clients">${csrf(s)}
60
61
  <div class="form-grid">
61
- ${input('name', 'Name', '', { required: true, placeholder: 'e.g. payroll-sync', help: 'Lower case letters, digits, . _ -' })}
62
- ${input('token_minutes', 'Token lifetime (minutes)', 60, { type: 'number', help: '5 to 1440.' })}
63
- <div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" placeholder="comma separated, or pick below">${roleHintsHtml(hints)}</div>
64
- ${input('description', 'Description', '', { placeholder: 'who uses it' })}
62
+ ${input('name', tx('apip.name'), '', { required: true, placeholder: tx('apip.e_g_payroll_sync'), help: tx('apip.lower_case_letters_digits') })}
63
+ ${input('token_minutes', tx('apip.token_lifetime_minutes'), 60, { type: 'number', help: tx('apip.5_to_1440') })}
64
+ <div class="field"><label class="label" for="f_roles">${tx('apip.roles')}</label><input id="f_roles" name="roles" placeholder="${tx('apip.comma_separated_or_pick_below')}">${roleHintsHtml(hints)}</div>
65
+ ${input('description', tx('apip.description'), '', { placeholder: tx('apip.who_uses_it') })}
65
66
  </div>
66
- <div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>Create client</button></div>
67
+ <div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>${tx('apip.create_client')}</button></div>
67
68
  </form>`;
68
69
  return region(
69
70
  'OAuth clients',
70
- html`<p class="muted u-mt0">For systems that call the API (like ORDS's <code>oauth.create_client</code>): a client exchanges its id and secret for an access token at
71
- <code>${tokenUrl}</code> (<code>grant_type=client_credentials</code>) and acts as <code>client:&lt;name&gt;</code> with its roles, read at every request.</p>
71
+ html`<p class="muted u-mt0">${tx.rich('apip.oauth_intro', { ords: html`<code>oauth.create_client</code>`, url: html`<code>${tokenUrl}</code>`, grant: html`<code>grant_type=client_credentials</code>`, client: html`<code>client:&lt;name&gt;</code>` })}</p>
72
72
  ${secret
73
- ? html`<div class="alert alert-success" role="status">${secret.rotated ? `New secret for ${secret.name}` : `Client ${secret.name} created`}. Copy the secret now: it is shown only once.${
74
- secret.rotated && secret.graceHours ? ` The previous secret keeps working for ${secret.graceHours} hours.` : ''}</div>
73
+ ? html`<div class="alert alert-success" role="status">${secret.rotated ? tx('apip.new_secret_for', { name: secret.name }) : tx('apip.client_created', { name: secret.name })}. ${tx('apip.copy_now')}${
74
+ secret.rotated && secret.graceHours ? ` ${tx('apip.grace', { n: secret.graceHours })}` : ''}</div>
75
75
  <div class="form-grid">
76
- <div class="field"><label class="label" for="oauth_id">Client ID</label><input id="oauth_id" class="code" readonly value="${secret.clientId}"></div>
77
- <div class="field"><label class="label" for="oauth_secret">Client secret</label><input id="oauth_secret" class="code" readonly value="${secret.secret}"></div>
76
+ <div class="field"><label class="label" for="oauth_id">${tx('apip.client_id')}</label><input id="oauth_id" class="code" readonly value="${secret.clientId}"></div>
77
+ <div class="field"><label class="label" for="oauth_secret">${tx('apip.client_secret')}</label><input id="oauth_secret" class="code" readonly value="${secret.secret}"></div>
78
78
  </div>
79
79
  <pre class="source">curl -u '${secret.clientId}:${secret.secret}' -d grant_type=client_credentials ${tokenUrl}</pre>`
80
80
  : ''}
81
81
  ${clients.length
82
- ? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th>Client</th><th>Roles</th><th class="num">Token</th><th>Secret changed</th><th>Last used</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
82
+ ? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th>${tx('apip.client')}</th><th>${tx('apip.roles')}</th><th class="num">${tx('apip.token')}</th><th>${tx('apip.secret_changed')}</th><th>${tx('apip.last_used')}</th><th><span class="sr-only">${tx('apip.actions')}</span></th></tr></thead><tbody>
83
83
  ${clients.map((c) => html`<tr${c.active ? '' : raw(' class="muted"')}>
84
- <td data-label="Client"><strong>${c.name}</strong>${c.active ? '' : html` <span class="tag">revoked</span>`}<br><code class="small">${c.client_id}</code>${c.description ? html`<br><small class="help">${c.description}</small>` : ''}</td>
85
- <td data-label="Roles"><form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/roles" class="search roles-form u-m0 u-mwnone">${csrf(s)}
86
- <input name="roles" value="${c.roles.join(', ')}" aria-label="Roles of ${c.name}" placeholder="no roles"><button class="btn btn-sm">Save</button>${roleHintsHtml(hints, 'Add')}</form></td>
87
- <td class="num" data-label="Token">${c.token_minutes} min</td>
88
- <td data-label="Secret changed">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">old secret valid until ${when(c.previous_valid_until)}</small>` : ''}</td>
89
- <td data-label="Last used">${when(c.last_used_at) || html`<span class="muted">never</span>`}</td>
84
+ <td data-label="${tx('apip.client')}"><strong>${c.name}</strong>${c.active ? '' : html` <span class="tag">revoked</span>`}<br><code class="small">${c.client_id}</code>${c.description ? html`<br><small class="help">${c.description}</small>` : ''}</td>
85
+ <td data-label="${tx('apip.roles')}"><form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/roles" class="search roles-form u-m0 u-mwnone">${csrf(s)}
86
+ <input name="roles" value="${c.roles.join(', ')}" aria-label="${tx('apip.aria_roles', { name: c.name })}" placeholder="${tx('apip.no_roles')}"><button class="btn btn-sm">${tx('apip.save')}</button>${roleHintsHtml(hints, tx('apip.add'))}</form></td>
87
+ <td class="num" data-label="${tx('apip.token')}">${tx('apip.minutes', { n: c.token_minutes })}</td>
88
+ <td data-label="${tx('apip.secret_changed')}">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">${tx('apip.old_valid_until', { when: when(c.previous_valid_until) })}</small>` : ''}</td>
89
+ <td data-label="${tx('apip.last_used')}">${when(c.last_used_at) || html`<span class="muted">${tx('apip.never')}</span>`}</td>
90
90
  <td class="actions"><div class="api-client-actions">
91
- ${action(c, 'rotate', 'New secret', html`<select name="grace" aria-label="Keep the old secret"><option value="24">old valid 24 h</option><option value="168">old valid 7 days</option><option value="0">old invalid now</option></select>`, 'btn btn-sm')}
92
- ${action(c, 'active', c.active ? 'Revoke' : 'Reactivate', html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}">`, 'btn btn-sm')}
93
- ${action(c, 'delete', 'Delete', '', 'btn btn-sm btn-danger')}
91
+ ${action(c, 'rotate', tx('apip.new_secret'), html`<select name="grace" aria-label="${tx('apip.keep_the_old_secret')}"><option value="24">${tx('apip.old_24h')}</option><option value="168">${tx('apip.old_7d')}</option><option value="0">${tx('apip.old_now')}</option></select>`, 'btn btn-sm')}
92
+ ${action(c, 'active', c.active ? tx('apip.revoke') : tx('apip.reactivate'), html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}">`, 'btn btn-sm')}
93
+ ${action(c, 'delete', tx('apip.delete'), '', 'btn btn-sm btn-danger')}
94
94
  </div></td></tr>`)}
95
95
  </tbody></table></div>
96
- <details class="api-new u-mt1"><summary class="btn">${icon('plus')} New client</summary><div class="api-new-body">${createForm}</div></details>`
97
- : html`<p class="muted">No clients yet.</p>${createForm}`}`,
96
+ <details class="api-new u-mt1"><summary class="btn">${icon('plus')} ${tx('apip.new_client')}</summary><div class="api-new-body">${createForm}</div></details>`
97
+ : html`<p class="muted">${tx('apip.no_clients_yet')}</p>${createForm}`}`,
98
98
  html`<span class="count">${clients.length}</span>`,
99
99
  );
100
100
  }
@@ -117,42 +117,42 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
117
117
  `curl -X POST ${url}/rpc/${e.name} -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" -d '{ … }'`),
118
118
  ].join('\n');
119
119
 
120
- const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? 'OK:' : 'Problem:'}</span>${text}</li>`;
120
+ const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? tx('apip.ok') : tx('apip.problem')}</span>${text}</li>`;
121
121
  const main = html`${appHeader(a, 'api', s)}
122
- <p class="muted api-intro">PostgREST runs next to pgkiln and serves the <code>${API_SCHEMA}</code> schema over HTTP. Requests carry a JWT whose <code>role</code> claim is this application’s API role; row level security uses the same <code>meta.app_user()</code> and <code>meta.has_role()</code> as the web pages.</p>
123
- <ul class="api-status" aria-label="Status">
124
- ${pill(status.ok, html`PostgREST at <code>${url}</code>: ${status.ok ? status.detail : html`<b>not reachable</b> (${status.detail})`}`)}
125
- ${role ? pill(!problem, problem ?? html`<code>${role}</code> is a dedicated role`) : pill(false, 'No API role: tokens are for the REST modules pgkiln serves only')}
126
- ${role && !problem ? pill(!!granted?.ok, granted?.ok ? html`pgkiln_authenticator may switch to <code>${role}</code>` : html`<b>run</b> <code>grant ${role} to pgkiln_authenticator;</code>`) : ''}
122
+ <p class="muted api-intro">${tx.rich('apip.intro', { schema: html`<code>${API_SCHEMA}</code>`, role: html`<code>role</code>`, user: html`<code>meta.app_user()</code>`, has: html`<code>meta.has_role()</code>` })}</p>
123
+ <ul class="api-status" aria-label="${tx('apip.status')}">
124
+ ${pill(status.ok, html`${tx.rich('apip.at', { url: html`<code>${url}</code>` })} ${status.ok ? status.detail : html`<b>${tx('apip.not_reachable')}</b> (${status.detail})`}`)}
125
+ ${role ? pill(!problem, problem ?? tx.rich('apip.dedicated', { role: html`<code>${role}</code>` })) : pill(false, tx('apip.no_role'))}
126
+ ${role && !problem ? pill(!!granted?.ok, granted?.ok ? tx.rich('apip.may_switch', { role: html`<code>${role}</code>` }) : tx.rich('apip.run_grant', { run: html`<b>${tx('apip.run')}</b>`, sql: html`<code>grant ${role} to pgkiln_authenticator;</code>` })) : ''}
127
127
  </ul>
128
128
  <div class="columns api-top">
129
- ${region('Setup', html`<form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
130
- ${input('api_role', 'API database role', role, { placeholder: 'e.g. myapp_api', help: 'Grant it only the api schema’s views and functions, and grant it to pgkiln_authenticator. Empty: tokens work only for the REST modules pgkiln serves (Shared Components → REST modules).' })}
131
- <div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> Users create their own tokens</label>
132
- <small class="help">On <em>My account</em>, signed-in users create a token that acts as them. A user the application requires two-factor sign-in of gives a second step first (if their last one is older than 15 minutes).</small></div>
133
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
129
+ ${region(tx('apip.setup'), html`<form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
130
+ ${input('api_role', tx('apip.api_database_role'), role, { placeholder: tx('apip.e_g_myapp_api'), help: tx('apip.grant_it_only_the_api') })}
131
+ <div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> ${tx('apip.users_create_their_own_tokens')}</label>
132
+ <small class="help">${tx.rich('apip.self_help', { account: html`<em>${tx('apip.my_account')}</em>` })}</small></div>
133
+ <div class="buttons"><button class="btn btn-hot">${tx('apip.save')}</button></div>
134
134
  </form>`)}
135
- ${region('Issue a token', html`<p class="muted u-mt0">For development and trusted scripts: the token acts as the account until it expires. Roles are read at each request; deactivating the account or revoking its access stops the token at once (<code>meta.api_check</code>). To invalidate all tokens, change <code>API_JWT_SECRET</code>.</p>
135
+ ${region(tx('apip.issue_a_token'), html`<p class="muted u-mt0">${tx.code('apip.for_development_and_trusted_scripts')}</p>
136
136
  <form method="post" action="${BASE}/apps/${a.id}/api/token">${csrf(s)}
137
137
  <div class="form-grid">
138
- ${input('username', 'Account', issued?.username ?? '', { required: true, auto: 'off' })}
139
- ${input('hours', 'Valid for (hours)', issued?.expiresInHours ?? 8, { type: 'number', help: `1 to ${MAX_TOKEN_HOURS}.` })}
138
+ ${input('username', tx('apip.account'), issued?.username ?? '', { required: true, auto: 'off' })}
139
+ ${input('hours', tx('apip.valid_for_hours'), issued?.expiresInHours ?? 8, { type: 'number', help: `1 to ${MAX_TOKEN_HOURS}.` })}
140
140
  </div>
141
- <div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}>Issue token</button></div>
141
+ <div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}>${tx('apip.issue_token')}</button></div>
142
142
  </form>
143
143
  ${issued ? html`<div class="field u-mt1" data-wide><label class="label" for="api_token">Token for ${issued.username} (shown once, valid ${issued.expiresInHours} h)</label>
144
144
  <textarea id="api_token" class="code" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>` : ''}`)}
145
145
  </div>
146
- ${region('Endpoints', html`${eps.length
147
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Path</th><th>Kind</th><th>Methods for ${role}</th></tr></thead>
148
- <tbody>${eps.map((e) => html`<tr><td data-label="Path"><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td data-label="Kind">${e.kind}</td><td data-label="Methods">${e.methods || html`<span class="muted">none</span>`}</td></tr>`)}</tbody></table></div>`
149
- : html`<p class="muted u-mt0">Nothing in the <code>${API_SCHEMA}</code> schema yet${role ? '' : ', or no API role set'}. Create views and functions there and grant them to the API role.</p>`}
150
- <h3 class="api-sub">Try it</h3>
146
+ ${region(tx('apip.endpoints'), html`${eps.length
147
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('apip.path')}</th><th>${tx('apip.kind')}</th><th>${tx('apip.methods_for', { role: role ?? '' })}</th></tr></thead>
148
+ <tbody>${eps.map((e) => html`<tr><td data-label="${tx('apip.path')}"><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td data-label="${tx('apip.kind')}">${e.kind}</td><td data-label="${tx('apip.methods')}">${e.methods || html`<span class="muted">${tx('apip.none')}</span>`}</td></tr>`)}</tbody></table></div>`
149
+ : html`<p class="muted u-mt0">${tx.rich(role ? 'apip.nothing_yet' : 'apip.nothing_yet_no_role', { schema: html`<code>${API_SCHEMA}</code>` })}</p>`}
150
+ <h3 class="api-sub">${tx('apip.try_it')}</h3>
151
151
  <pre class="source">${examples}</pre>
152
- <p class="muted small">After schema changes run <code>notify pgrst, 'reload schema';</code> so PostgREST picks them up. OpenAPI description: <a href="${url}/" target="_blank" rel="noopener">${url}/</a>.</p>`,
152
+ <p class="muted small">${tx.rich('apip.reload', { sql: html`<code>notify pgrst, 'reload schema';</code>`, link: html`<a href="${url}/" target="_blank" rel="noopener">${url}/</a>` })}</p>`,
153
153
  html`<span class="count">${eps.length}</span>`)}
154
154
  ${await clientsRegion(s, a, secret, !problem)}`;
155
- return shell(s, `${a.name} REST API`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['REST API']], main);
155
+ return shell(s, `${a.name} REST API`, [[tx('apip.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('apip.rest_api')]], main);
156
156
  }
157
157
 
158
158
  export async function apiRoutes(app: FastifyInstance) {
@@ -214,7 +214,7 @@ export async function apiRoutes(app: FastifyInstance) {
214
214
  return send(reply.header('cache-control', 'no-store'), s, await apiPage(s, a, undefined, { name, clientId: r.client_id, secret: r.client_secret, rotated: false }));
215
215
  } catch (e) {
216
216
  const err = e as { code?: string; message: string };
217
- flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? 'Check the name (lower case letters, digits, . _ -) and the token lifetime (5–1440 minutes).' : err.message, 'error');
217
+ flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? tx('apip.check_the_name_lower_case') : err.message, 'error');
218
218
  return back(reply, s, `${BASE}/apps/${a.id}/api`);
219
219
  }
220
220
  });
@@ -246,7 +246,7 @@ export async function apiRoutes(app: FastifyInstance) {
246
246
  flash(s, active ? `${c.name} can get tokens again.` : `${c.name} is revoked: its tokens stop working now.`);
247
247
  } else if (op === 'roles') {
248
248
  await owner.query('update meta.api_client set roles = $2 where id = $1', [c.id, splitRoles(req.body?.roles ?? '')]);
249
- flash(s, `Roles of ${c.name} saved.`);
249
+ flash(s, tx('apip.roles_saved', { name: c.name }));
250
250
  } else if (op === 'delete') {
251
251
  await owner.query('delete from meta.api_client where id = $1', [c.id]);
252
252
  flash(s, `${c.name} deleted.`);
@@ -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
  });