pgkiln 0.83.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 (126) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +3 -2
  3. package/docs/guide/03-builder.md +16 -5
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +13 -1
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +11 -0
  15. package/src/builder/ai-agents.ts +86 -85
  16. package/src/builder/ai-builder.ts +30 -30
  17. package/src/builder/ai.ts +93 -92
  18. package/src/builder/api.ts +47 -47
  19. package/src/builder/app-groups.ts +33 -32
  20. package/src/builder/appfromfile.ts +57 -58
  21. package/src/builder/appsheets.ts +33 -32
  22. package/src/builder/appwizard.ts +65 -65
  23. package/src/builder/arrange.ts +23 -22
  24. package/src/builder/assistant.ts +39 -38
  25. package/src/builder/automations.ts +32 -31
  26. package/src/builder/availability.ts +24 -24
  27. package/src/builder/backups.ts +30 -31
  28. package/src/builder/blueprints.ts +62 -61
  29. package/src/builder/code-editor.ts +1 -1
  30. package/src/builder/components.ts +10 -6
  31. package/src/builder/create-page.ts +52 -50
  32. package/src/builder/cross-app.ts +37 -35
  33. package/src/builder/dataload.ts +2 -2
  34. package/src/builder/datasets.ts +107 -106
  35. package/src/builder/dependencies.ts +20 -19
  36. package/src/builder/designer.ts +36 -36
  37. package/src/builder/diagnostics.ts +58 -57
  38. package/src/builder/features.ts +39 -35
  39. package/src/builder/feedback.ts +23 -20
  40. package/src/builder/find-workspace.ts +28 -24
  41. package/src/builder/form-designer.ts +36 -35
  42. package/src/builder/gallery.ts +25 -29
  43. package/src/builder/globalization.ts +50 -49
  44. package/src/builder/i18n/de.ts +3461 -0
  45. package/src/builder/i18n/en.ts +3461 -0
  46. package/src/builder/i18n/es.ts +3461 -0
  47. package/src/builder/i18n/fr.ts +3461 -0
  48. package/src/builder/i18n/it.ts +3461 -0
  49. package/src/builder/i18n/ja.ts +3461 -0
  50. package/src/builder/i18n/ko.ts +3461 -0
  51. package/src/builder/i18n/nl.ts +3461 -0
  52. package/src/builder/i18n/pt.ts +3461 -0
  53. package/src/builder/i18n/zh-cn.ts +3461 -0
  54. package/src/builder/i18n/zh-tw.ts +3461 -0
  55. package/src/builder/i18n.ts +17 -3
  56. package/src/builder/json-sources.ts +63 -62
  57. package/src/builder/layouts.ts +12 -11
  58. package/src/builder/locks.ts +29 -28
  59. package/src/builder/login-page.ts +26 -25
  60. package/src/builder/mail.ts +148 -147
  61. package/src/builder/newapp.ts +15 -12
  62. package/src/builder/notification-settings.ts +27 -0
  63. package/src/builder/plugins.ts +47 -44
  64. package/src/builder/process-jobs.ts +11 -10
  65. package/src/builder/pwa.ts +22 -21
  66. package/src/builder/remote-databases.ts +55 -56
  67. package/src/builder/report-settings.ts +1 -1
  68. package/src/builder/reporter.ts +43 -42
  69. package/src/builder/rest-catalogs.ts +87 -86
  70. package/src/builder/rest.ts +9 -8
  71. package/src/builder/routes.ts +30 -27
  72. package/src/builder/search.ts +14 -13
  73. package/src/builder/static-files.ts +31 -32
  74. package/src/builder/subscriptions.ts +47 -45
  75. package/src/builder/supporting.ts +23 -18
  76. package/src/builder/team.ts +179 -179
  77. package/src/builder/telemetry.ts +59 -62
  78. package/src/builder/templates.ts +66 -65
  79. package/src/builder/themeroller.ts +60 -63
  80. package/src/builder/ui-defaults.ts +21 -20
  81. package/src/builder/ui.ts +2 -1
  82. package/src/builder/upgrade.ts +25 -24
  83. package/src/builder/utilities.ts +48 -47
  84. package/src/builder/vector-providers.ts +67 -66
  85. package/src/builder/websources.ts +43 -42
  86. package/src/builder/wizards.ts +95 -89
  87. package/src/builder/workflow-migrate.ts +30 -31
  88. package/src/builder/workflows.ts +40 -39
  89. package/src/builder/workingcopies.ts +41 -49
  90. package/src/builder/workspace-backups.ts +21 -20
  91. package/src/builder/workspace-mail.ts +17 -16
  92. package/src/builder/workspace-resources.ts +104 -112
  93. package/src/builder/workspace-transfer.ts +34 -26
  94. package/src/cli/replace.ts +2 -0
  95. package/src/i18n/ar.ts +9 -0
  96. package/src/i18n/cs.ts +9 -0
  97. package/src/i18n/da.ts +9 -0
  98. package/src/i18n/de.ts +9 -0
  99. package/src/i18n/el.ts +9 -0
  100. package/src/i18n/es.ts +9 -0
  101. package/src/i18n/fi.ts +9 -0
  102. package/src/i18n/fr.ts +9 -0
  103. package/src/i18n/he.ts +9 -0
  104. package/src/i18n/it.ts +9 -0
  105. package/src/i18n/ja.ts +9 -0
  106. package/src/i18n/ko.ts +9 -0
  107. package/src/i18n/nb.ts +9 -0
  108. package/src/i18n/pl.ts +9 -0
  109. package/src/i18n/pt.ts +9 -0
  110. package/src/i18n/ru.ts +9 -0
  111. package/src/i18n/sv.ts +9 -0
  112. package/src/i18n/tr.ts +9 -0
  113. package/src/i18n/uk.ts +9 -0
  114. package/src/i18n/zh.ts +9 -0
  115. package/src/i18n.ts +18 -0
  116. package/src/metadata.ts +9 -3
  117. package/src/runtime/engine.ts +2 -1
  118. package/src/runtime/feedback.ts +1 -1
  119. package/src/runtime/lists.ts +43 -11
  120. package/src/runtime/notifications.ts +237 -0
  121. package/src/runtime/processes.ts +45 -1
  122. package/src/runtime/regions.ts +11 -15
  123. package/src/runtime/render.ts +12 -3
  124. package/src/runtime/routes.ts +12 -4
  125. package/src/runtime/signin.ts +2 -1
  126. package/src/session.ts +13 -4
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
5
5
  import { applicationRoles, applyRemote, disconnect, dropRemote, fdwStatus, MAX_TABLES, remoteDbProblems, serverNameOf, type RemoteDb } from '../remote-db.ts';
6
6
  import { clientIp, logActivity, type Session } from '../session.ts';
7
7
  import { administrator, back, BASE, csrf, flash, input, region, send, shell, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // (sprint 150, AP-7 leftover; APEX: REST Enabled SQL services) Workspace utilities → Remote databases
10
11
  // (administrators): another PostgreSQL database's tables as foreign tables in a local schema (src/remote-db.ts).
@@ -16,8 +17,8 @@ import { administrator, back, BASE, csrf, flash, input, region, send, shell, typ
16
17
  // POST /builder/remote-databases/extension create extension postgres_fdw (a superuser connection)
17
18
 
18
19
  const PAGE = `${BASE}/remote-databases`;
19
- const TITLE = 'Remote databases';
20
- const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [TITLE]];
20
+ const TITLE = () => tx('rdb.title');
21
+ const crumbs = (): [string, string?][] => [[tx('rdb.app_builder'), BASE], [tx('rdb.workspace_utilities'), `${BASE}/utilities`], [TITLE()]];
21
22
  const rid = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
22
23
  const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
23
24
  const list = (v: unknown) => (Array.isArray(v) ? v.map(String) : typeof v === 'string' && v ? [v] : []);
@@ -37,72 +38,70 @@ function fromBody(b: Record<string, unknown>): RemoteDb {
37
38
 
38
39
  function form(s: Session, x: Partial<Row>, roles: string[]) {
39
40
  const editing = !!x.id;
40
- return region(editing ? `Edit ${x.name}` : 'Add a remote database', html`<form method="post" action="${PAGE}" id="rdb-form">${csrf(s)}
41
+ return region(editing ? tx('rdb.edit_named', { name: x.name }) : tx('rdb.add_a_remote_database'), html`<form method="post" action="${PAGE}" id="rdb-form">${csrf(s)}
41
42
  ${editing ? html`<input type="hidden" name="id" value="${x.id}">` : ''}
42
43
  <div class="form-grid">
43
- ${input('name', 'Name', x.name ?? '', { help: 'A static id, e.g. CRM. The server is pgkiln_rdb_<name>.', required: true })}
44
- ${input('description', 'Description', x.description ?? '')}
45
- ${input('host', 'Host', x.host ?? '', { help: 'As the database server reaches it (it makes the connection, not pgkiln).', required: true })}
46
- ${input('port', 'Port', String(x.port ?? 5432), { type: 'number' })}
47
- ${input('dbname', 'Database', x.dbname ?? '', { required: true })}
48
- ${input('user_name', 'User', x.user_name ?? '', { required: true, help: 'A user of the remote database: what it may read or change there is what the applications may.' })}
49
- ${input('password', 'Password', '', { type: 'password', auto: 'new-password', help: editing ? 'Kept by PostgreSQL in the user mapping and never shown; type a new one to replace it.' : 'Kept by PostgreSQL in the user mapping (pgkiln never stores it).' })}
50
- ${input('remote_schema', 'Remote schema', x.remote_schema ?? 'public')}
51
- ${input('local_schema', 'Local schema', x.local_schema ?? '', { required: true, help: 'A new schema here for the foreign tables, e.g. crm: regions query crm.<table>.' })}
52
- ${input('tables', 'Tables', (x.tables ?? []).join(', '), { help: `Remote tables and views to import, separated by commas (at most ${MAX_TABLES}); empty: all of the remote schema's.` })}
44
+ ${input('name', tx('rdb.name'), x.name ?? '', { help: tx('rdb.a_static_id_e_g'), required: true })}
45
+ ${input('description', tx('rdb.description'), x.description ?? '')}
46
+ ${input('host', tx('rdb.host'), x.host ?? '', { help: tx('rdb.as_the_database_server_reaches'), required: true })}
47
+ ${input('port', tx('rdb.port'), String(x.port ?? 5432), { type: 'number' })}
48
+ ${input('dbname', tx('rdb.database'), x.dbname ?? '', { required: true })}
49
+ ${input('user_name', tx('rdb.user'), x.user_name ?? '', { required: true, help: tx('rdb.a_user_of_the_remote') })}
50
+ ${input('password', tx('rdb.password'), '', { type: 'password', auto: 'new-password', help: editing ? tx('rdb.kept_by_postgresql_in_the') : tx('rdb.kept_by_postgresql_in_the_2') })}
51
+ ${input('remote_schema', tx('rdb.remote_schema'), x.remote_schema ?? 'public')}
52
+ ${input('local_schema', tx('rdb.local_schema'), x.local_schema ?? '', { required: true, help: tx('rdb.a_new_schema_here_for') })}
53
+ ${input('tables', tx('rdb.tables'), (x.tables ?? []).join(', '), { help: tx('rdb.tables_help', { n: MAX_TABLES }) })}
53
54
  </div>
54
- <fieldset class="prop-group u-mt1"><legend>Grant to the applications' database roles</legend>
55
+ <fieldset class="prop-group u-mt1"><legend>${tx('rdb.grant_to_the_applications_database')}</legend>
55
56
  ${roles.length
56
57
  ? html`<div class="check-list">${roles.map((r) => html`<label><input type="checkbox" name="grant_roles" value="${r}"${(x.grant_roles ?? []).includes(r) ? raw(' checked') : ''}> ${r}</label>`)}</div>`
57
- : html`<p class="muted">No application has a database role yet (Application settings → Database role).</p>`}
58
- <label class="u-mt1"><input type="checkbox" name="writable" value="true"${x.writable ? raw(' checked') : ''}> Also INSERT, UPDATE and DELETE (else SELECT only)</label>
58
+ : html`<p class="muted">${tx('rdb.no_application_has_a_database')}</p>`}
59
+ <label class="u-mt1"><input type="checkbox" name="writable" value="true"${x.writable ? raw(' checked') : ''}> ${tx('rdb.also_insert_update_and_delete')}</label>
59
60
  </fieldset>
60
61
  <div class="buttons u-mt1">
61
- <button class="btn btn-hot" name="mode" value="save">${editing ? 'Save and import again' : 'Add and import'}</button>
62
- <button class="btn" name="mode" value="test">Test the connection</button>
63
- <a class="btn" href="${PAGE}">Cancel</a>
62
+ <button class="btn btn-hot" name="mode" value="save">${editing ? tx('rdb.save_and_import_again') : tx('rdb.add_and_import')}</button>
63
+ <button class="btn" name="mode" value="test">${tx('rdb.test_the_connection')}</button>
64
+ <a class="btn" href="${PAGE}">${tx('rdb.cancel')}</a>
64
65
  </div></form>`);
65
66
  }
66
67
 
67
68
  export async function remoteDatabaseRoutes(app: FastifyInstance) {
68
69
  app.get(PAGE, async (req: Req, reply) => {
69
- const s = await administrator(req, reply, TITLE);
70
+ const s = await administrator(req, reply, TITLE());
70
71
  if (!s) return;
71
72
  const q = (req.query ?? {}) as Record<string, string>;
72
73
  const st = await fdwStatus(owner);
73
74
  const all = await rows();
74
75
  const roles = await applicationRoles(owner);
75
76
  const edit = rid(q.edit) ? all.find((r) => r.id === rid(q.edit)) : undefined;
76
- const main = html`<h1 class="u-mb1">${TITLE}</h1>
77
- <p class="muted u-mt0">Another PostgreSQL database's tables as foreign tables (postgres_fdw) in a local schema: regions, reports, forms and lists
78
- of values use them like the application's own tables, as its database role, with the grants given here.</p>
77
+ const main = html`<h1 class="u-mb1">${TITLE()}</h1>
78
+ <p class="muted u-mt0">${tx('rdb.another_postgresql_database_s_tables')}</p>
79
79
  ${st.installed
80
- ? html`<p class="muted">${icon('check')} postgres_fdw is installed.</p>`
81
- : html`<div class="alert alert-info" role="status"><p><strong>postgres_fdw is not installed in this database.</strong></p>
80
+ ? html`<p class="muted">${icon('check')} ${tx('rdb.fdw_installed')}</p>`
81
+ : html`<div class="alert alert-info" role="status"><p><strong>${tx('rdb.postgres_fdw_is_not_installed')}</strong></p>
82
82
  ${st.available && st.canCreate
83
- ? html`<form method="post" action="${PAGE}/extension">${csrf(s)}<button class="btn">Create extension postgres_fdw</button></form>`
84
- : html`<p>${st.available ? 'A superuser runs' : 'The server needs PostgreSQL\'s contrib package, then a superuser runs'} <code>create extension postgres_fdw;</code>
85
- and <code>grant usage on foreign data wrapper postgres_fdw to</code> pgkiln's owner role.</p>`}</div>`}
86
- ${region('Remote databases', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Name</th><th>Connection</th><th>Local schema</th>
87
- <th class="num">Tables</th><th>Granted to</th><th>Imported</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
83
+ ? html`<form method="post" action="${PAGE}/extension">${csrf(s)}<button class="btn">${tx('rdb.create_extension_postgres_fdw')}</button></form>`
84
+ : html`<p>${tx.rich(st.available ? 'rdb.fdw_superuser' : 'rdb.fdw_missing', { create: html`<code>create extension postgres_fdw;</code>`, grant: html`<code>grant usage on foreign data wrapper postgres_fdw to</code>` })}</p>`}</div>`}
85
+ ${region(tx('rdb.remote_databases'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rdb.name')}</th><th>${tx('rdb.connection')}</th><th>${tx('rdb.local_schema')}</th>
86
+ <th class="num">${tx('rdb.tables')}</th><th>${tx('rdb.granted_to')}</th><th>${tx('rdb.imported')}</th><th><span class="sr-only">${tx('rdb.actions')}</span></th></tr></thead><tbody>
88
87
  ${all.length ? all.map((r) => html`<tr>
89
- <td data-label="Name"><strong>${r.name}</strong>${r.description ? html`<br><small class="muted">${r.description}</small>` : ''}</td>
90
- <td data-label="Connection"><code>${r.user_name}@${r.host}:${r.port}/${r.dbname}</code> (${r.remote_schema})</td>
91
- <td data-label="Local schema"><code>${r.local_schema}</code></td>
92
- <td class="num" data-label="Tables">${r.imported ?? '—'}</td>
93
- <td data-label="Granted to">${r.grant_roles.join(', ') || '—'}${r.writable ? ' (read and write)' : ''}</td>
94
- <td data-label="Imported">${when(r.imported_at)}${r.error ? html`<br><span class="wf-problem">${r.error}</span>` : ''}</td>
95
- <td data-label="Actions"><a class="btn btn-sm" href="${PAGE}?edit=${r.id}#rdb-form">Edit</a>
96
- <form method="post" action="${`${PAGE}/${r.id}/refresh`}" class="inline">${csrf(s)}<button class="btn btn-sm">Import again</button></form>
97
- <form method="post" action="${`${PAGE}/${r.id}/delete`}" class="inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${`Delete ${r.name}? Its schema ${r.local_schema} and its foreign tables are dropped.`}">Delete</button></form></td>
98
- </tr>`) : html`<tr><td colspan="7" class="empty">No remote databases yet.</td></tr>`}
88
+ <td data-label="${tx('rdb.name')}"><strong>${r.name}</strong>${r.description ? html`<br><small class="muted">${r.description}</small>` : ''}</td>
89
+ <td data-label="${tx('rdb.connection')}"><code>${r.user_name}@${r.host}:${r.port}/${r.dbname}</code> (${r.remote_schema})</td>
90
+ <td data-label="${tx('rdb.local_schema')}"><code>${r.local_schema}</code></td>
91
+ <td class="num" data-label="${tx('rdb.tables')}">${r.imported ?? '—'}</td>
92
+ <td data-label="${tx('rdb.granted_to')}">${r.grant_roles.join(', ') || '—'}${r.writable ? ' (read and write)' : ''}</td>
93
+ <td data-label="${tx('rdb.imported')}">${when(r.imported_at)}${r.error ? html`<br><span class="wf-problem">${r.error}</span>` : ''}</td>
94
+ <td data-label="${tx('rdb.actions')}"><a class="btn btn-sm" href="${PAGE}?edit=${r.id}#rdb-form">${tx('rdb.edit')}</a>
95
+ <form method="post" action="${`${PAGE}/${r.id}/refresh`}" class="inline">${csrf(s)}<button class="btn btn-sm">${tx('rdb.import_again')}</button></form>
96
+ <form method="post" action="${`${PAGE}/${r.id}/delete`}" class="inline">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('rdb.delete_confirm', { name: r.name, schema: r.local_schema })}">${tx('rdb.delete')}</button></form></td>
97
+ </tr>`) : html`<tr><td colspan="7" class="empty">${tx('rdb.no_remote_databases_yet')}</td></tr>`}
99
98
  </tbody></table></div>`)}
100
99
  ${st.installed ? form(s, edit ?? {}, roles) : ''}`;
101
- return send(reply, s, shell(s, TITLE, crumbs, main));
100
+ return send(reply, s, shell(s, TITLE(), crumbs(), main));
102
101
  });
103
102
 
104
103
  app.post(PAGE, async (req: Req, reply) => {
105
- const s = await administrator(req, reply, TITLE);
104
+ const s = await administrator(req, reply, TITLE());
106
105
  if (!s) return;
107
106
  const b = (req.body ?? {}) as Record<string, unknown>;
108
107
  const x = fromBody(b);
@@ -110,7 +109,7 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
110
109
  const password = typeof b.password === 'string' && b.password ? b.password : null;
111
110
  const retry = editId ? `${PAGE}?edit=${editId}#rdb-form` : `${PAGE}#rdb-form`;
112
111
  const problems = remoteDbProblems(x, await applicationRoles(owner));
113
- if (password && password.length > 1000) problems.push('The password has at most 1000 characters.');
112
+ if (password && password.length > 1000) problems.push(tx('rdb.the_password_has_at_most'));
114
113
  if (problems.length) {
115
114
  flash(s, problems.join(' '), 'error');
116
115
  return back(reply, s, retry);
@@ -119,14 +118,14 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
119
118
  try {
120
119
  const count = await owner.tx(async (c) => {
121
120
  const old = editId ? (await c.query<Row>('select * from meta.remote_database where id = $1 for update', [editId])).rows[0] : undefined;
122
- if (editId && !old) throw new Error('No such remote database.');
121
+ if (editId && !old) throw new Error(tx('rdb.no_such_remote_database'));
123
122
  // a renamed one, or a new local schema: the old server and schema go first
124
123
  if (old && (old.name !== x.name || old.local_schema !== x.local_schema)) {
125
- if (!password) throw new Error('Renaming it, or moving it to another local schema, needs the password again.');
124
+ if (!password) throw new Error(tx('rdb.renaming_it_or_moving_it'));
126
125
  await dropRemote(c, old);
127
126
  }
128
127
  const n = await applyRemote(c, x, password, old && old.name === x.name && old.local_schema === x.local_schema ? old.grant_roles : []);
129
- if (test) throw Object.assign(new Error('rolled back'), { tested: n });
128
+ if (test) throw Object.assign(new Error(tx('rdb.rolled_back')), { tested: n });
130
129
  if (old)
131
130
  await c.query(
132
131
  `update meta.remote_database set name = $2, description = $3, host = $4, port = $5, dbname = $6, user_name = $7, remote_schema = $8, local_schema = $9,
@@ -149,22 +148,22 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
149
148
  } catch (e) {
150
149
  const tested = (e as { tested?: number }).tested;
151
150
  if (tested !== undefined) {
152
- flash(s, `The connection works: ${tested} table(s) would be imported into ${x.local_schema}. Nothing was saved.`);
151
+ flash(s, tx('rdb.test_ok', { n: tested, schema: x.local_schema }));
153
152
  } else {
154
- const msg = (e as Error).message.includes('remote_database_name_key') ? `There is a remote database ${x.name} already.`
155
- : (e as Error).message.includes('remote_database_local_schema_key') ? `Another remote database uses schema ${x.local_schema}.` : (e as Error).message;
156
- flash(s, `${test ? 'The test failed' : 'Nothing was changed'}: ${msg}`, 'error');
153
+ const msg = (e as Error).message.includes('remote_database_name_key') ? tx('rdb.name_taken', { name: x.name })
154
+ : (e as Error).message.includes('remote_database_local_schema_key') ? tx('rdb.schema_taken', { schema: x.local_schema }) : (e as Error).message;
155
+ flash(s, `${test ? tx('rdb.the_test_failed') : tx('rdb.nothing_was_changed')}: ${msg}`, 'error');
157
156
  }
158
157
  return back(reply, s, retry);
159
158
  }
160
159
  });
161
160
 
162
161
  app.post(`${PAGE}/:rid/refresh`, async (req: Req, reply) => {
163
- const s = await administrator(req, reply, TITLE);
162
+ const s = await administrator(req, reply, TITLE());
164
163
  if (!s) return;
165
164
  const r = (await rows()).find((x) => x.id === rid(req.params.rid));
166
165
  if (!r) {
167
- flash(s, 'No such remote database.', 'error');
166
+ flash(s, tx('rdb.no_such_remote_database'), 'error');
168
167
  return back(reply, s, PAGE);
169
168
  }
170
169
  try {
@@ -180,11 +179,11 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
180
179
  });
181
180
 
182
181
  app.post(`${PAGE}/:rid/delete`, async (req: Req, reply) => {
183
- const s = await administrator(req, reply, TITLE);
182
+ const s = await administrator(req, reply, TITLE());
184
183
  if (!s) return;
185
184
  const r = (await rows()).find((x) => x.id === rid(req.params.rid));
186
185
  if (!r) {
187
- flash(s, 'No such remote database.', 'error');
186
+ flash(s, tx('rdb.no_such_remote_database'), 'error');
188
187
  return back(reply, s, PAGE);
189
188
  }
190
189
  try {
@@ -201,12 +200,12 @@ export async function remoteDatabaseRoutes(app: FastifyInstance) {
201
200
  });
202
201
 
203
202
  app.post(`${PAGE}/extension`, async (req: Req, reply) => {
204
- const s = await administrator(req, reply, TITLE);
203
+ const s = await administrator(req, reply, TITLE());
205
204
  if (!s) return;
206
205
  try {
207
206
  await owner.query('create extension if not exists postgres_fdw');
208
207
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: 'create extension if not exists postgres_fdw' });
209
- flash(s, 'The postgres_fdw extension is installed.');
208
+ flash(s, tx('rdb.the_postgres_fdw_extension_is'));
210
209
  } catch (e) {
211
210
  flash(s, `create extension postgres_fdw failed: ${(e as Error).message}`, 'error');
212
211
  }
@@ -160,7 +160,7 @@ async function reportSettingsFormInner(pageId: number, appId: number, r: { id: n
160
160
  <fieldset class="prop-group"><legend>${tx('rpt.json_path_columns')}</legend><div class="form-grid">
161
161
  <div class="field" data-wide><label class="label" for="${id('json_columns')}">${tx('rpt.columns_read_from_a_json')}</label>
162
162
  <input id="${id('json_columns')}" name="json_columns" value="${jsonColumnsText(cfg.json_columns)}" placeholder="city=profile:address.city; first_skill=profile:skills.0.name; since=profile:since:date" class="code">
163
- <small class="help">name=column:path[:type]; separate them with ";". A path is keys separated by dots, a number picks an element of an array. Types: ${JSON_TYPES.join(', ')} (text when left out); a value that doesn't read as the type shows empty. They are columns like the others: search, filters, sorting, headings and downloads. To edit documents, use a JSON source (Shared Components).</small></div>
163
+ <small class="help">${tx('rs.json_columns_help', { types: JSON_TYPES.join(', ') })}</small></div>
164
164
  </div></fieldset>
165
165
  <fieldset class="prop-group"><legend>${tx('rpt.columns')}</legend>
166
166
  ${all.length
@@ -5,6 +5,7 @@ import { heading } from '../runtime/items.ts';
5
5
  import { RESERVED_SCHEMAS, SOURCE_ID, sourcesOf, type Source, type SourceColumn } from '../runtime/data-reporter.ts';
6
6
  import type { Session } from '../session.ts';
7
7
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // Page designer → a Data Reporter region → Settings: the data sources the
10
11
  // region offers (tables and views, which of their columns, labels and format
@@ -113,7 +114,7 @@ export async function mergeReporterSettings(
113
114
  const format = clip(b[`s${i}_fmt_${j}`], 40);
114
115
  columns.push({ name, ...(label ? { label } : {}), ...(format ? { format } : {}) });
115
116
  }
116
- if (!columns.length) errors.push(`Data source ${s.id} offers no columns.`);
117
+ if (!columns.length) errors.push(tx('drep.no_columns', { id: s.id }));
117
118
  const label = clip(b[`s${i}_label`], 80);
118
119
  const description = clip(b[`s${i}_description`], 300);
119
120
  sources.push({ id: s.id, ...(label ? { label } : {}), ...(description ? { description } : {}), schema: s.schema, table: s.table, columns });
@@ -121,7 +122,7 @@ export async function mergeReporterSettings(
121
122
 
122
123
  if (b.new_object) {
123
124
  const obj = /^\d{1,10}$/.test(b.new_object) ? await lookup.byOid(Number(b.new_object)) : null;
124
- if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push('Choose a table or view for the new data source.');
125
+ if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push(tx('drep.choose_a_table_or_view'));
125
126
  else if (sources.length >= MAX_SOURCES) errors.push(`A region offers at most ${MAX_SOURCES} data sources.`);
126
127
  else {
127
128
  let id = clip(b.new_id, 40).toLowerCase();
@@ -130,7 +131,7 @@ export async function mergeReporterSettings(
130
131
  id = '';
131
132
  }
132
133
  if (id && sources.some((s) => s.id === id)) {
133
- errors.push(`There is already a data source ${id}.`);
134
+ errors.push(tx('drep.exists', { id }));
134
135
  id = '';
135
136
  }
136
137
  if (!id) {
@@ -146,7 +147,7 @@ export async function mergeReporterSettings(
146
147
  // (sprint 110) a dataset of the workspace as a new source
147
148
  if (b.new_dataset) {
148
149
  const name = b.new_dataset.trim().toLowerCase();
149
- if (!a.datasets?.has(name)) errors.push('Choose one of the workspace\'s datasets.');
150
+ if (!a.datasets?.has(name)) errors.push(tx('drep.choose_one_of_the_workspace'));
150
151
  else if (sources.length >= MAX_SOURCES) errors.push(`A region offers at most ${MAX_SOURCES} data sources.`);
151
152
  else {
152
153
  let id = name.slice(0, 36);
@@ -179,12 +180,12 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
179
180
  <input type="hidden" name="s${i}_key" value="${src.id}">
180
181
  ${!d ? html`<div class="alert alert-error" role="alert">The workspace has no dataset ${src.dataset} (any more).</div>` : ''}
181
182
  <div class="form-grid">
182
- <div class="field"><label class="label" for="${id(`s${i}_id`)}">Static id</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
183
- <div class="field"><label class="label" for="${id(`s${i}_label`)}">Label</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${d?.label ?? ''}"></div>
184
- <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">Description (shown to users)</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}" placeholder="the dataset's"></div>
183
+ <div class="field"><label class="label" for="${id(`s${i}_id`)}">${tx('drep.static_id')}</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
184
+ <div class="field"><label class="label" for="${id(`s${i}_label`)}">${tx('drep.label')}</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${d?.label ?? ''}"></div>
185
+ <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">${tx('drep.description_shown_to_users')}</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}" placeholder="${tx('drep.the_dataset_s')}"></div>
185
186
  </div>
186
- <p class="muted">Its tables, columns, labels, masks and row filter are the dataset's: <a href="${BASE}/reporter">App Builder → Data Reporter</a>.</p>
187
- <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> Remove this data source (users' reports on it stop working)</label></div>
187
+ <p class="muted">${tx.rich('drep.dataset_note', { link: html`<a href="${BASE}/reporter">${tx('drep.app_builder_data_reporter')}</a>` })}</p>
188
+ <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> ${tx('drep.remove_this_data_source_users')}</label></div>
188
189
  </fieldset>`;
189
190
  }
190
191
  const obj = await findObject(src.schema, src.table);
@@ -194,56 +195,56 @@ export async function reporterSettingsForm(pageId: number, appId: number, r: { i
194
195
  const readable = obj ? objects.find((o) => o.oid === obj.oid)?.access : undefined;
195
196
  return html`<fieldset class="prop-group"><legend>${src.label || src.id} <span class="muted">(${src.schema}.${src.table})</span></legend>
196
197
  <input type="hidden" name="s${i}_key" value="${src.id}">
197
- ${!obj ? html`<div class="alert alert-error" role="alert">The table or view ${src.schema}.${src.table} does not exist (any more).</div>` : ''}
198
- ${obj && readable === false ? html`<div class="alert alert-error" role="alert">The application's database role may not read ${src.schema}.${src.table}: grant it SELECT.</div>` : ''}
198
+ ${!obj ? html`<div class="alert alert-error" role="alert">${tx('drep.no_object', { object: `${src.schema}.${src.table}` })}</div>` : ''}
199
+ ${obj && readable === false ? html`<div class="alert alert-error" role="alert">${tx('drep.no_select', { object: `${src.schema}.${src.table}` })}</div>` : ''}
199
200
  <div class="form-grid">
200
- <div class="field"><label class="label" for="${id(`s${i}_id`)}">Static id</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
201
- <div class="field"><label class="label" for="${id(`s${i}_label`)}">Label</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
202
- <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">Description (shown to users)</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
201
+ <div class="field"><label class="label" for="${id(`s${i}_id`)}">${tx('drep.static_id')}</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
202
+ <div class="field"><label class="label" for="${id(`s${i}_label`)}">${tx('drep.label')}</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
203
+ <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">${tx('drep.description_shown_to_users')}</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
203
204
  </div>
204
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Label</th><th scope="col">Format mask</th></tr></thead><tbody>
205
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('drep.offer')}</th><th scope="col">${tx('drep.column')}</th><th scope="col">${tx('drep.label')}</th><th scope="col">${tx('drep.format_mask')}</th></tr></thead><tbody>
205
206
  ${all.map((c, j) => {
206
207
  const o = offered.get(c.name);
207
208
  return html`<tr>
208
- <td data-label="Offer"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="Offer ${c.name}"></td>
209
- <td data-label="Column"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">not in the table</span>` : ''}</td>
210
- <td data-label="Label"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="Label of ${c.name}"></td>
211
- <td data-label="Format mask"><input name="s${i}_fmt_${j}" maxlength="40" value="${o?.format ?? ''}" placeholder="FML999G990D00" aria-label="Format mask of ${c.name}"></td>
209
+ <td data-label="${tx('drep.offer')}"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="${tx('drep.aria_offer', { column: c.name })}"></td>
210
+ <td data-label="${tx('drep.column')}"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">${tx('drep.not_in_the_table')}</span>` : ''}</td>
211
+ <td data-label="${tx('drep.label')}"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="${tx('drep.aria_label', { column: c.name })}"></td>
212
+ <td data-label="${tx('drep.format_mask')}"><input name="s${i}_fmt_${j}" maxlength="40" value="${o?.format ?? ''}" placeholder="FML999G990D00" aria-label="${tx('drep.aria_format', { column: c.name })}"></td>
212
213
  </tr>`;
213
214
  })}
214
215
  </tbody></table></div>
215
- <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> Remove this data source (users' reports on it stop working)</label></div>
216
+ <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> ${tx('drep.remove_this_data_source_users')}</label></div>
216
217
  </fieldset>`;
217
218
  }),
218
219
  );
219
- return html`<h3 class="u-mt15">Data Reporter settings</h3>
220
- <p class="muted u-mt0">Signed-in users build their own reports from the data sources below: they pick offered columns, filter, group with totals, sort and draw a chart, and save reports privately or shared. Reports run as the application's database role, so grants and row level security apply.</p>
220
+ return html`<h3 class="u-mt15">${tx('drep.data_reporter_settings')}</h3>
221
+ <p class="muted u-mt0">${tx('drep.signed_in_users_build_their')}</p>
221
222
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/reporter" class="component-form">${csrf(s)}
222
- <fieldset class="prop-group"><legend>Reports</legend><div class="form-grid">
223
- <div class="field"><label class="label" for="${id('sharing')}">Who may share reports</label>
224
- <select id="${id('sharing')}" name="sharing">${opt('', 'Every signed-in user', sharing)}${opt('off', 'Nobody (private reports only)', sharing)}${schemes.map((n) => opt(`authz:${n}`, `Authorization: ${n}`, sharing))}</select></div>
225
- <div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
223
+ <fieldset class="prop-group"><legend>${tx('drep.reports')}</legend><div class="form-grid">
224
+ <div class="field"><label class="label" for="${id('sharing')}">${tx('drep.who_may_share_reports')}</label>
225
+ <select id="${id('sharing')}" name="sharing">${opt('', tx('drep.every_signed_in_user'), sharing)}${opt('off', tx('drep.nobody_private_reports_only'), sharing)}${schemes.map((n) => opt(`authz:${n}`, `Authorization: ${n}`, sharing))}</select></div>
226
+ <div class="field"><label class="label" for="${id('page_size')}">${tx('drep.rows_per_page')}</label>
226
227
  <input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
227
- <div class="field"><label class="label" for="${id('edit_authz')}">Who may build and save reports</label>
228
- <select id="${id('edit_authz')}" name="edit_authz">${opt('', 'Every user', cfg.edit_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.edit_authz ?? ''))}</select>
229
- <small class="help">Others only run shared reports.</small></div>
230
- <div class="field"><label class="label" for="${id('admin_authz')}">Who may unpublish and delete anyone's reports</label>
231
- <select id="${id('admin_authz')}" name="admin_authz">${opt('', 'Nobody', cfg.admin_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.admin_authz ?? ''))}</select></div>
232
- <div class="field"><label class="check"><input type="checkbox" name="publishing" value="true"${cfg.publishing === true ? raw(' checked') : ''}> Shared reports are published: a stable URL, pins</label></div>
233
- <div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
234
- <input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
228
+ <div class="field"><label class="label" for="${id('edit_authz')}">${tx('drep.who_may_build_and_save')}</label>
229
+ <select id="${id('edit_authz')}" name="edit_authz">${opt('', tx('drep.every_user'), cfg.edit_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.edit_authz ?? ''))}</select>
230
+ <small class="help">${tx('drep.others_only_run_shared_reports')}</small></div>
231
+ <div class="field"><label class="label" for="${id('admin_authz')}">${tx('drep.who_may_unpublish_and_delete')}</label>
232
+ <select id="${id('admin_authz')}" name="admin_authz">${opt('', tx('drep.nobody'), cfg.admin_authz ?? '')}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.admin_authz ?? ''))}</select></div>
233
+ <div class="field"><label class="check"><input type="checkbox" name="publishing" value="true"${cfg.publishing === true ? raw(' checked') : ''}> ${tx('drep.shared_reports_are_published_a')}</label></div>
234
+ <div class="field" data-wide><label class="label" for="${id('empty')}">${tx('drep.text_when_there_are_no')}</label>
235
+ <input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="${tx('drep.no_data_found')}"></div>
235
236
  </div></fieldset>
236
237
  ${sourceSets}
237
- <fieldset class="prop-group"><legend>Add a data source</legend><div class="form-grid">
238
- <div class="field" data-wide><label class="label" for="${id('new_object')}">Table or view</label>
238
+ <fieldset class="prop-group"><legend>${tx('drep.add_a_data_source')}</legend><div class="form-grid">
239
+ <div class="field" data-wide><label class="label" for="${id('new_object')}">${tx('drep.table_or_view')}</label>
239
240
  <select id="${id('new_object')}" name="new_object"><option value="">- none -</option>${objects.map((o) => html`<option value="${o.oid}">${o.name}${o.access ? '' : ' (no access for the app role)'}</option>`)}</select></div>
240
- <div class="field"><label class="label" for="${id('new_id')}">Static id</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="from the name"></div>
241
- <div class="field"><label class="label" for="${id('new_label')}">Label</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
241
+ <div class="field"><label class="label" for="${id('new_id')}">${tx('drep.static_id')}</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="${tx('drep.from_the_name')}"></div>
242
+ <div class="field"><label class="label" for="${id('new_label')}">${tx('drep.label')}</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
242
243
  </div>
243
- <small class="help">All columns are offered at first (except binary ones); untick those users should not see. Prefer a view that shows exactly what business users may report on.</small>
244
- ${datasets.length ? html`<div class="form-grid"><div class="field" data-wide><label class="label" for="${id('new_dataset')}">Or a dataset of the workspace</label>
244
+ <small class="help">${tx('drep.all_columns_are_offered_at')}</small>
245
+ ${datasets.length ? html`<div class="form-grid"><div class="field" data-wide><label class="label" for="${id('new_dataset')}">${tx('drep.or_a_dataset_of_the')}</label>
245
246
  <select id="${id('new_dataset')}" name="new_dataset"><option value="">- none -</option>${datasets.map((d) => html`<option value="${d.name}">${d.label} (${d.name})</option>`)}</select></div></div>` : ''}</fieldset>
246
- <div class="buttons"><button class="btn btn-hot">Save Data Reporter settings</button></div>
247
+ <div class="buttons"><button class="btn btn-hot">${tx('drep.save_data_reporter_settings')}</button></div>
247
248
  </form>`;
248
249
  }
249
250
 
@@ -262,7 +263,7 @@ export async function reporterBuilderRoutes(app: FastifyInstance) {
262
263
  const { config, errors } = await mergeReporterSettings(r.config ?? {}, (req.body ?? {}) as Body, { authz: new Set(schemes.map((x) => x.name.toUpperCase())), datasets: new Set(datasets.map((x) => x.name)) });
263
264
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
264
265
  if (errors.length) flash(s, errors.join(' '), 'error');
265
- else flash(s, 'Settings saved.');
266
+ else flash(s, tx('drep.settings_saved'));
266
267
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
267
268
  });
268
269
  }