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
@@ -11,6 +11,7 @@ import { buildRequest, call, clearResponseCache, clearTokens, credentialProblems
11
11
  import type { ComponentSpec } from './components.ts';
12
12
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
13
13
  import { resolveSource } from '../remote-servers.ts';
14
+ import { scopedTx as tx } from './i18n.ts';
14
15
 
15
16
  // Shared Components → Web credentials and REST data sources: their property
16
17
  // specs, the secrets' status (never a secret), "Test" for a source (its
@@ -139,9 +140,9 @@ export async function designSql(appId: number, r: { source: string | null; rest_
139
140
  export const needsSecret = (row: { type: string; grant_type?: string | null }) => row.type !== 'oauth2' || (row.grant_type ?? 'client_credentials') === 'client_credentials';
140
141
 
141
142
  const CLEARABLE: Record<string, { column: string; label: string }> = {
142
- secret: { column: 'secret_enc', label: 'the secret' },
143
- password: { column: 'password_enc', label: 'the password' },
144
- refresh: { column: 'refresh_token_enc', label: 'the refresh token' },
143
+ secret: { column: 'secret_enc', get label() { return tx('wsrc.the_secret'); } },
144
+ password: { column: 'password_enc', get label() { return tx('wsrc.the_password'); } },
145
+ refresh: { column: 'refresh_token_enc', get label() { return tx('wsrc.the_refresh_token'); } },
145
146
  };
146
147
 
147
148
  export function credentialExtras(
@@ -150,20 +151,20 @@ export function credentialExtras(
150
151
  s: Session,
151
152
  ) {
152
153
  const clear = (what: string) =>
153
- html`<form method="post" action="${BASE}/apps/${appId}/web-credentials/${row.id}/clear" class="u-inline">${csrf(s)}<input type="hidden" name="what" value="${what}"><button class="btn" data-confirm="Remove ${CLEARABLE[what].label}?">Remove ${CLEARABLE[what].label}</button></form>`;
154
+ html`<form method="post" action="${BASE}/apps/${appId}/web-credentials/${row.id}/clear" class="u-inline">${csrf(s)}<input type="hidden" name="what" value="${what}"><button class="btn" data-confirm="${tx('wsrc.remove_confirm', { what: CLEARABLE[what].label })}">${tx('wsrc.remove', { what: CLEARABLE[what].label })}</button></form>`;
154
155
  const oauth = row.type === 'oauth2';
155
156
  const grant = row.grant_type ?? 'client_credentials';
156
- return html`<fieldset class="prop-group u-mt125"><legend>Secrets</legend>
157
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('wsrc.secrets')}</legend>
157
158
  <p>${row.secret_enc
158
- ? html`<b>A secret is stored</b> (encrypted). It is never shown; type a new one above to replace it.`
159
- : needsSecret(row) ? html`<b>No secret yet.</b> Requests with this credential fail until one is entered.` : html`No client secret (a public client).`}</p>
160
- ${oauth && grant === 'password' ? html`<p>${row.password_enc ? html`<b>A password is stored</b> (encrypted).` : html`<b>No password yet.</b> The password flow fails until one is entered.`}</p>` : ''}
159
+ ? tx.rich('wsrc.secret_stored', { stored: html`<b>${tx('wsrc.a_secret_is_stored')}</b>` })
160
+ : needsSecret(row) ? tx.rich('wsrc.secret_missing', { none: html`<b>${tx('wsrc.no_secret_yet')}</b>` }) : tx('wsrc.no_client_secret')}</p>
161
+ ${oauth && grant === 'password' ? html`<p>${row.password_enc ? tx.rich('wsrc.password_stored', { stored: html`<b>${tx('wsrc.a_password_is_stored')}</b>` }) : tx.rich('wsrc.password_missing', { none: html`<b>${tx('wsrc.no_password_yet')}</b>` })}</p>` : ''}
161
162
  ${oauth
162
163
  ? html`<p>${row.refresh_token_enc
163
- ? html`<b>A refresh token is stored</b> (encrypted)${row.token_refreshed_at ? html`, received ${new Date(row.token_refreshed_at).toISOString().slice(0, 16).replace('T', ' ')} UTC` : ''}. pgkiln uses it to renew the access token and keeps the newer one the service sends.`
164
- : grant === 'refresh_token' ? html`<b>No refresh token yet.</b> Enter one above.` : html`No refresh token yet (kept when the token endpoint sends one).`}</p>`
164
+ ? (row.token_refreshed_at ? tx.rich('wsrc.refresh_stored_at', { stored: html`<b>${tx('wsrc.a_refresh_token_is_stored')}</b>`, at: new Date(row.token_refreshed_at).toISOString().slice(0, 16).replace('T', ' ') }) : tx.rich('wsrc.refresh_stored', { stored: html`<b>${tx('wsrc.a_refresh_token_is_stored')}</b>` }))
165
+ : grant === 'refresh_token' ? tx.rich('wsrc.refresh_missing', { none: html`<b>${tx('wsrc.no_refresh_token_yet')}</b>` }) : tx('wsrc.no_refresh')}</p>`
165
166
  : ''}
166
- ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: secrets can't be saved or used until it is set (at least 32 characters).</div>`}
167
+ ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('wsrc.no_secret_key')}</div>`}
167
168
  <div class="buttons">${row.secret_enc ? clear('secret') : ''}${row.password_enc ? clear('password') : ''}${row.refresh_token_enc ? clear('refresh') : ''}</div>
168
169
  </fieldset>`;
169
170
  }
@@ -185,8 +186,8 @@ const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0
185
186
 
186
187
  /** A source's synchronisation: next run, Synchronise now, the last runs. */
187
188
  async function syncExtras(appId: number, row: RestSource & Record<string, any>, s: Session) {
188
- if (!row.sync_table) return html`<fieldset class="prop-group u-mt125"><legend>Synchronisation</legend>
189
- <p class="muted">Set a local table above to copy this source's rows into a table (on demand, from SQL or on a schedule).</p></fieldset>`;
189
+ if (!row.sync_table) return html`<fieldset class="prop-group u-mt125"><legend>${tx('wsrc.synchronisation')}</legend>
190
+ <p class="muted">${tx('wsrc.set_a_local_table_above')}</p></fieldset>`;
190
191
  let next = 'not scheduled';
191
192
  if (row.sync_enabled && row.sync_schedule)
192
193
  try {
@@ -194,26 +195,26 @@ async function syncExtras(appId: number, row: RestSource & Record<string, any>,
194
195
  } catch (e) {
195
196
  next = `never: ${(e as Error).message}`;
196
197
  }
197
- const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>(the scheduler is off on this server: AUTOMATIONS=off)</b>` : '';
198
+ const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>${tx('wsrc.the_scheduler_is_off_on')}</b>` : '';
198
199
  const log = await syncLog(row.id);
199
- return html`<fieldset class="prop-group u-mt125"><legend>Synchronisation</legend>
200
- <p>Into <code>${row.sync_table}</code> (${row.sync_mode}${row.sync_mode === 'merge' && row.sync_delete ? ', deleting missing rows' : ''}). Next run: <b>${next}</b>${scheduler}</p>
201
- <p class="muted">From application code: <code>select meta.request_rest_sync('${row.name}')</code> queues a run for the server's next scheduler pass.</p>
202
- <form method="post" action="${BASE}/apps/${appId}/rest-sources/${row.id}/sync">${csrf(s)}<button class="btn">${icon('play')} Synchronise now</button></form>
200
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('wsrc.synchronisation')}</legend>
201
+ <p>${tx.rich('wsrc.sync_into', { table: html`<code>${row.sync_table}</code>`, mode: `${row.sync_mode}${row.sync_mode === 'merge' && row.sync_delete ? `, ${tx('wsrc.deleting_missing')}` : ''}`, next: html`<b>${next}</b>` })}${scheduler}</p>
202
+ <p class="muted">${tx.rich('wsrc.sync_from_code', { call: html`<code>select meta.request_rest_sync('${row.name}')</code>` })}</p>
203
+ <form method="post" action="${BASE}/apps/${appId}/rest-sources/${row.id}/sync">${csrf(s)}<button class="btn">${icon('play')} ${tx('wsrc.synchronise_now')}</button></form>
203
204
  ${log.length
204
- ? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>Requested</th><th>Took</th><th>By</th><th>Status</th><th class="num">Rows</th><th class="num">Inserted</th><th class="num">Updated</th><th class="num">Deleted</th><th>Message</th></tr></thead><tbody>
205
+ ? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>${tx('wsrc.requested')}</th><th>${tx('wsrc.took')}</th><th>${tx('wsrc.by')}</th><th>${tx('wsrc.status')}</th><th class="num">${tx('wsrc.rows')}</th><th class="num">${tx('wsrc.inserted')}</th><th class="num">${tx('wsrc.updated')}</th><th class="num">${tx('wsrc.deleted')}</th><th>${tx('wsrc.message')}</th></tr></thead><tbody>
205
206
  ${log.map((l) => html`<tr>
206
- <td data-label="Requested">${when(l.requested_at)}</td>
207
- <td data-label="Took">${l.finished_at && l.started_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
208
- <td data-label="By">${l.trigger}${l.requested_by ? html` <span class="muted">(${l.requested_by})</span>` : ''}</td>
209
- <td data-label="Status"><span class="tag${SYNC_TAG[l.status] ?? ''}">${l.status}</span></td>
210
- <td class="num" data-label="Rows">${l.rows_fetched ?? ''}</td>
211
- <td class="num" data-label="Inserted">${l.inserted ?? ''}</td>
212
- <td class="num" data-label="Updated">${l.updated ?? ''}</td>
213
- <td class="num" data-label="Deleted">${l.deleted ?? ''}</td>
214
- <td data-label="Message">${l.message ?? ''}</td></tr>`)}
207
+ <td data-label="${tx('wsrc.requested')}">${when(l.requested_at)}</td>
208
+ <td data-label="${tx('wsrc.took')}">${l.finished_at && l.started_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
209
+ <td data-label="${tx('wsrc.by')}">${l.trigger}${l.requested_by ? html` <span class="muted">(${l.requested_by})</span>` : ''}</td>
210
+ <td data-label="${tx('wsrc.status')}"><span class="tag${SYNC_TAG[l.status] ?? ''}">${l.status}</span></td>
211
+ <td class="num" data-label="${tx('wsrc.rows')}">${l.rows_fetched ?? ''}</td>
212
+ <td class="num" data-label="${tx('wsrc.inserted')}">${l.inserted ?? ''}</td>
213
+ <td class="num" data-label="${tx('wsrc.updated')}">${l.updated ?? ''}</td>
214
+ <td class="num" data-label="${tx('wsrc.deleted')}">${l.deleted ?? ''}</td>
215
+ <td data-label="${tx('wsrc.message')}">${l.message ?? ''}</td></tr>`)}
215
216
  </tbody></table></div>`
216
- : html`<p class="muted">No runs yet.</p>`}
217
+ : html`<p class="muted">${tx('wsrc.no_runs_yet')}</p>`}
217
218
  </fieldset>`;
218
219
  }
219
220
 
@@ -231,13 +232,13 @@ function testExtras(appId: number, row: RestSource, s: Session) {
231
232
  }
232
233
  delete s.state[RESULT];
233
234
  const params = Array.isArray(row.params) ? row.params : [];
234
- return html`<fieldset class="prop-group u-mt125"><legend>Test</legend>
235
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('wsrc.test')}</legend>
235
236
  <form method="post" action="${BASE}/apps/${appId}/rest-sources/${row.id}/test">${csrf(s)}
236
237
  ${params.length
237
238
  ? html`<div class="form-grid">${params.map((p) => html`<div class="field"><label class="label" for="rt_${p.name}">${p.name} <span class="muted">(${p.in})</span></label>
238
239
  <input id="rt_${p.name}" name="p_${p.name}" value="${p.default && !p.default.includes('&') ? p.default : ''}"></div>`)}</div>`
239
240
  : ''}
240
- <div class="buttons"><button class="btn">Test</button></div>
241
+ <div class="buttons"><button class="btn">${tx('wsrc.test')}</button></div>
241
242
  </form>
242
243
  ${result
243
244
  ? html`<div class="alert ${result.ok ? 'alert-success' : 'alert-error'}" role="status">${result.message}</div>
@@ -247,11 +248,11 @@ function testExtras(appId: number, row: RestSource, s: Session) {
247
248
  : ''}
248
249
  ${result.suggested
249
250
  ? html`<form method="post" action="${BASE}/apps/${appId}/rest-sources/${row.id}/columns" class="u-mt1">${csrf(s)}
250
- <label class="label" for="rt_cols">Suggested columns</label>
251
+ <label class="label" for="rt_cols">${tx('wsrc.suggested_columns')}</label>
251
252
  <textarea id="rt_cols" name="columns" class="code" rows="4" spellcheck="false">${result.suggested}</textarea>
252
- <div class="buttons"><button class="btn">Use these columns</button></div></form>`
253
+ <div class="buttons"><button class="btn">${tx('wsrc.use_these_columns')}</button></div></form>`
253
254
  : ''}
254
- ${result.excerpt ? html`<details class="u-mt1"><summary>Response</summary><pre class="source">${result.excerpt}</pre></details>` : ''}`
255
+ ${result.excerpt ? html`<details class="u-mt1"><summary>${tx('wsrc.response')}</summary><pre class="source">${result.excerpt}</pre></details>` : ''}`
255
256
  : ''}
256
257
  </fieldset>`;
257
258
  }
@@ -266,7 +267,7 @@ export async function webSourceRoutes(app: FastifyInstance) {
266
267
  // the column comes from the fixed list above, never from the request
267
268
  const r = await owner.query(`update meta.web_credential set ${what.column} = null${what.column === 'refresh_token_enc' ? ', token_refreshed_at = null' : ''} where id = $1 and app_id = $2`, [req.params.cid, req.params.id]);
268
269
  clearTokens(Number(req.params.cid));
269
- flash(s, r.rowCount ? `${what.label[0].toUpperCase()}${what.label.slice(1)} was removed.` : 'Not found.', r.rowCount ? 'ok' : 'error');
270
+ flash(s, r.rowCount ? `${what.label[0].toUpperCase()}${what.label.slice(1)} was removed.` : tx('wsrc.not_found'), r.rowCount ? 'ok' : 'error');
270
271
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared?c=web_credential-${req.params.cid}`);
271
272
  });
272
273
 
@@ -288,8 +289,8 @@ export async function webSourceRoutes(app: FastifyInstance) {
288
289
  const res = await call({ ...r, appId: src.app_id, what: `REST data source ${src.name} (builder test)`, credential, timeoutMs: src.timeout_s * 1000 });
289
290
  const text = res.body.toString('utf8');
290
291
  result.excerpt = text.length > 3000 ? `${text.slice(0, 3000)}…` : text;
291
- if (res.status < 200 || res.status > 299) throw new Error(`The web service answered ${res.status}.`);
292
- const body = parseJson(res, 'The source');
292
+ if (res.status < 200 || res.status > 299) throw new Error(tx('wsrc.answered', { status: res.status }));
293
+ const body = parseJson(res, tx('wsrc.the_source'));
293
294
  const { columns, rows, truncated } = toRows(body, { ...src, max_rows: Math.min(src.max_rows, 1000) });
294
295
  const selected = select(body, src.row_selector);
295
296
  const items = selected.length === 1 && Array.isArray(selected[0]) ? selected[0] : selected;
@@ -317,8 +318,8 @@ export async function webSourceRoutes(app: FastifyInstance) {
317
318
  if (!src) return reply.code(404).send('Not found');
318
319
  const r = await runSync(src.id, 'manual', { by: s.username ?? undefined });
319
320
  if (r.status === 'ok')
320
- flash(s, `Synchronised ${r.rows} row(s): ${r.inserted} inserted, ${r.updated} updated, ${r.deleted} deleted.${r.message ? ` ${r.message}` : ''}`);
321
- else flash(s, r.message ?? 'The synchronisation failed.', 'error');
321
+ flash(s, `${tx('wsrc.synced', { rows: r.rows, inserted: r.inserted, updated: r.updated, deleted: r.deleted })}${r.message ? ` ${r.message}` : ''}`);
322
+ else flash(s, r.message ?? tx('wsrc.the_synchronisation_failed'), 'error');
322
323
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared?c=rest_source-${src.id}`);
323
324
  });
324
325
 
@@ -330,10 +331,10 @@ export async function webSourceRoutes(app: FastifyInstance) {
330
331
  const problems = sourceProblems({ url: 'https://x', params: [], columns: cols });
331
332
  if (problems.length) throw new Error(problems.join(' '));
332
333
  const r = await owner.query('update meta.rest_source set columns = $3 where id = $1 and app_id = $2', [req.params.sid, req.params.id, JSON.stringify(cols)]);
333
- if (!r.rowCount) throw new Error('Not found.');
334
- flash(s, 'Columns saved.');
334
+ if (!r.rowCount) throw new Error(tx('wsrc.not_found'));
335
+ flash(s, tx('wsrc.columns_saved'));
335
336
  } catch (e) {
336
- flash(s, e instanceof SyntaxError ? 'The columns are not valid JSON.' : (e as Error).message, 'error');
337
+ flash(s, e instanceof SyntaxError ? tx('wsrc.the_columns_are_not_valid') : (e as Error).message, 'error');
337
338
  }
338
339
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared?c=rest_source-${req.params.sid}`);
339
340
  });