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
@@ -13,6 +13,7 @@ import { sourceProblems } from '../websources.ts';
13
13
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
14
14
  import { currentWorkspace } from './workspaces.ts';
15
15
  import { scopeForWorkspace } from '../network-acl.ts';
16
+ import { scopedTx as tx } from './i18n.ts';
16
17
 
17
18
  // Workspace utilities → REST Source Catalogs (migration 095; APEX: Workspace
18
19
  // Utilities → REST Source Catalogs): the current workspace's catalogs, a
@@ -25,7 +26,7 @@ import { scopeForWorkspace } from '../network-acl.ts';
25
26
  const NOTES = '__CATALOG_NOTES';
26
27
  const MAX_DOC_BYTES = 5 * 1024 * 1024;
27
28
  const root = `${BASE}/rest-catalogs`;
28
- const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['REST Source Catalogs', root], ...extra];
29
+ const crumbs = (extra: [string, string?][] = []): [string, string?][] => [[tx('rcat.app_builder'), BASE], [tx('rcat.workspace_utilities'), `${BASE}/utilities`], ['REST Source Catalogs', root], ...extra];
29
30
  const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
30
31
  const pretty = (v: unknown) => JSON.stringify(v ?? null, null, 2);
31
32
 
@@ -41,22 +42,22 @@ function notesHtml(s: Session): Raw | '' {
41
42
  delete s.state[NOTES];
42
43
  const notes = JSON.parse(v) as string[];
43
44
  return notes.length
44
- ? html`<div class="alert alert-info" role="status"><b>What the document had that the catalog doesn't use:</b><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></div>`
45
+ ? html`<div class="alert alert-info" role="status"><b>${tx('rcat.what_the_document_had_that')}</b><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></div>`
45
46
  : '';
46
47
  }
47
48
 
48
49
  /** The import form: a file, pasted text or a URL; base URL and name optional. */
49
50
  function importForm(s: Session, action: string, replacing: boolean): Raw {
50
51
  return html`<form method="post" action="${action}" enctype="multipart/form-data">${csrf(s)}
51
- <p class="muted u-mt0">An <b>OpenAPI 3</b> or <b>Swagger 2.0</b> document in JSON (e.g. <code>openapi.json</code>), or a catalog file downloaded from pgkiln.${replacing ? ' The catalog\'s services are replaced by the document\'s; data sources made from it earlier stay as they are.' : ''}</p>
52
+ <p class="muted u-mt0">${tx.rich('rcat.doc_intro', { openapi: html`<b>${tx('rcat.openapi_3')}</b>`, swagger: html`<b>${tx('rcat.swagger_2_0')}</b>`, file: html`<code>openapi.json</code>` })}${replacing ? ` ${tx('rcat.doc_replacing')}` : ''}</p>
52
53
  <div class="form-grid">
53
- <div class="field"><label class="label" for="f_file">File</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
54
- ${input('url', 'Or read it from a URL', '', { placeholder: 'https://api.example.com/openapi.json', help: 'The server reads it; the host must be on its allow-list (PGKILN_REST_ALLOWED_HOSTS).' })}
55
- <div class="field" data-wide><label class="label" for="f_text">Or paste it</label><textarea id="f_text" name="text" class="code" rows="4" spellcheck="false"></textarea></div>
56
- ${input('base_url', 'Base URL', '', { placeholder: 'from the document', help: 'Instead of the document\'s server, e.g. https://test.api.example.com/v1.' })}
57
- ${replacing ? '' : input('name', 'Name', '', { placeholder: 'the document\'s title' })}
54
+ <div class="field"><label class="label" for="f_file">${tx('rcat.file')}</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
55
+ ${input('url', tx('rcat.or_read_it_from_a'), '', { placeholder: tx('rcat.https_api_example_com_openapi'), help: tx('rcat.the_server_reads_it_the') })}
56
+ <div class="field" data-wide><label class="label" for="f_text">${tx('rcat.or_paste_it')}</label><textarea id="f_text" name="text" class="code" rows="4" spellcheck="false"></textarea></div>
57
+ ${input('base_url', tx('rcat.base_url'), '', { placeholder: tx('rcat.from_the_document'), help: tx('rcat.instead_of_the_document_s') })}
58
+ ${replacing ? '' : input('name', tx('rcat.name'), '', { placeholder: tx('rcat.the_document_s_title') })}
58
59
  </div>
59
- <div class="buttons"><button class="btn btn-hot">${icon('upload')} ${replacing ? 'Replace the services' : 'Import'}</button></div></form>`;
60
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} ${replacing ? tx('rcat.replace_the_services') : tx('rcat.import')}</button></div></form>`;
60
61
  }
61
62
 
62
63
  /** The text of an uploaded, pasted or fetched document (and where it came from). */
@@ -66,14 +67,14 @@ async function documentText(req: Req): Promise<{ text: string; from?: string; ur
66
67
  const parsed = await readMultipart(req, MAX_DOC_BYTES / 1024 / 1024);
67
68
  req.body = parsed.body as Body;
68
69
  const f = parsed.files.get('file');
69
- if (f?.truncated) throw new Error('The file is larger than 5 MB.');
70
+ if (f?.truncated) throw new Error(tx('rcat.the_file_is_larger_than'));
70
71
  if (f) text = f.data.toString('utf8');
71
72
  }
72
73
  if (!text.trim()) text = String(req.body?.text ?? '');
73
74
  if (text.trim()) return { text };
74
75
  const url = String(req.body?.url ?? '').trim();
75
- if (!url) throw new Error('Choose a file, paste a document or give its URL.');
76
- if (!/^https?:\/\//i.test(url)) throw new Error('The URL starts with http:// or https://.');
76
+ if (!url) throw new Error(tx('rcat.choose_a_file_paste_a'));
77
+ if (!/^https?:\/\//i.test(url)) throw new Error(tx('rcat.the_url_starts_with_http'));
77
78
  return { text: '', url };
78
79
  }
79
80
 
@@ -87,7 +88,7 @@ async function documentOf(doc: { text: string; from?: string; url?: string }, s:
87
88
  headers: { accept: 'application/json' }, timeoutMs: 15_000, maxBytes: MAX_DOC_BYTES,
88
89
  scope: await scopeForWorkspace(currentWorkspace(s), 'REST source catalog (read from a URL)'),
89
90
  });
90
- if (r.status !== 200) throw new Error(`The URL answered HTTP ${r.status}.`);
91
+ if (r.status !== 200) throw new Error(tx('rcat.url_answered', { status: r.status }));
91
92
  return { text: r.body.toString('utf8'), from: r.url };
92
93
  }
93
94
 
@@ -123,17 +124,17 @@ function serviceForm(s: Session, action: string, v: Partial<CatalogService>, lab
123
124
  html`<div class="field" data-wide><label class="label" for="f_${name}">${title}</label><textarea id="f_${name}" name="${name}" class="code" rows="${rows}" spellcheck="false">${value}</textarea><small class="help">${help}</small></div>`;
124
125
  return html`<form method="post" action="${action}">${csrf(s)}
125
126
  <div class="form-grid">
126
- ${input('name', 'Name', v.name ?? '', { required: true, placeholder: 'GET_PETS', help: 'The data source\'s name when an application makes one from this service.' })}
127
- <div class="field"><label class="label" for="f_method">Method</label><select id="f_method" name="method">${['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => html`<option${(v.method ?? 'GET') === m ? raw(' selected') : ''}>${m}</option>`)}</select></div>
128
- ${input('path', 'Path', v.path ?? '', { required: true, placeholder: '/pets/{pet_id}', help: 'After the catalog\'s base URL; {name} is a path parameter.' })}
129
- ${input('row_selector', 'Row selector', v.row_selector ?? '', { placeholder: 'items', help: 'Where the rows are in the response; empty: the response.' })}
130
- <div class="field" data-wide><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v.description ?? ''}"></div>
131
- ${area('params', 'Parameters (JSON)', pretty(v.params ?? []), '[{"name": "page_size", "in": "query", "key": "pageSize", "default": "20"}] · in: path, query, header, body; "key": the service\'s name for it when it isn\'t lower case with _.')}
132
- ${area('columns', 'Columns (JSON)', pretty(v.columns ?? []), '[{"name": "id", "path": "id", "type": "integer"}] · types: text, number, integer, boolean, date, timestamp, json.', 6)}
133
- ${area('headers', 'Headers (JSON)', pretty(v.headers ?? {}), 'Extra request headers, not secrets: an application\'s web credential signs in.', 2)}
134
- ${area('body', 'Body template', v.body ?? '', '{"query": {q}} — empty: the body parameters as a JSON object.', 2)}
135
- ${input('key_columns', 'Key columns', (v.key_columns ?? []).join(', '), { placeholder: 'id' })}
136
- ${area('operations', 'Operations (JSON)', pretty(v.operations ?? {}), '{"update": {"method": "PUT", "path": "/{id}"}, "delete": {"method": "DELETE", "path": "/{id}"}} · write-back for forms and grids.', 3)}
127
+ ${input('name', tx('rcat.name'), v.name ?? '', { required: true, placeholder: 'GET_PETS', help: tx('rcat.the_data_source_s_name') })}
128
+ <div class="field"><label class="label" for="f_method">${tx('rcat.method')}</label><select id="f_method" name="method">${['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => html`<option${(v.method ?? 'GET') === m ? raw(' selected') : ''}>${m}</option>`)}</select></div>
129
+ ${input('path', tx('rcat.path'), v.path ?? '', { required: true, placeholder: tx('rcat.pets_pet_id'), help: tx('rcat.after_the_catalog_s_base') })}
130
+ ${input('row_selector', tx('rcat.row_selector'), v.row_selector ?? '', { placeholder: 'items', help: tx('rcat.where_the_rows_are_in') })}
131
+ <div class="field" data-wide><label class="label" for="f_description">${tx('rcat.description')}</label><input id="f_description" name="description" value="${v.description ?? ''}"></div>
132
+ ${area('params', tx('rcat.parameters_json'), pretty(v.params ?? []), tx('rcat.params_help'))}
133
+ ${area('columns', tx('rcat.columns_json'), pretty(v.columns ?? []), '[{"name": "id", "path": "id", "type": "integer"}] · types: text, number, integer, boolean, date, timestamp, json.', 6)}
134
+ ${area('headers', tx('rcat.headers_json'), pretty(v.headers ?? {}), tx('rcat.headers_help'), 2)}
135
+ ${area('body', tx('rcat.body_template'), v.body ?? '', '{"query": {q}} — empty: the body parameters as a JSON object.', 2)}
136
+ ${input('key_columns', tx('rcat.key_columns'), (v.key_columns ?? []).join(', '), { placeholder: 'id' })}
137
+ ${area('operations', tx('rcat.operations_json'), pretty(v.operations ?? {}), '{"update": {"method": "PUT", "path": "/{id}"}, "delete": {"method": "DELETE", "path": "/{id}"}} · write-back for forms and grids.', 3)}
137
138
  </div>
138
139
  <div class="buttons"><button class="btn btn-hot">${icon('save')} ${label}</button></div></form>`;
139
140
  }
@@ -147,24 +148,24 @@ export async function restCatalogRoutes(app: FastifyInstance) {
147
148
  `select c.id, c.name, c.description, c.base_url, c.server, c.source, c.api_version, c.updated_at,
148
149
  (select count(*)::int from meta.rest_catalog_service x where x.catalog_id = c.id) as services
149
150
  from meta.rest_catalog c where c.workspace_id = $1 order by lower(c.name)`, [currentWorkspace(s)])).rows;
150
- const main = html`<div class="title-row"><h1>REST Source Catalogs</h1></div>
151
- <p class="muted u-mt0">Web services described once for ${s.workspace?.name ?? 'this workspace'}: their base URL and services. An application makes a REST data source from a service in Shared Components → REST data sources → New. The data source is a copy; credentials stay with the application.</p>
151
+ const main = html`<div class="title-row"><h1>${tx('rcat.rest_source_catalogs')}</h1></div>
152
+ <p class="muted u-mt0">${tx('rcat.intro', { workspace: s.workspace?.name ?? tx('rcat.this_workspace') })}</p>
152
153
  ${notesHtml(s)}
153
- ${region('Catalogs', html`<div class="table-wrap"><table class="report report-reflow">
154
- <thead><tr><th>Catalog</th><th>Base URL</th><th class="num">Services</th><th>From</th><th>Updated (UTC)</th></tr></thead>
154
+ ${region(tx('rcat.catalogs'), html`<div class="table-wrap"><table class="report report-reflow">
155
+ <thead><tr><th>${tx('rcat.catalog')}</th><th>${tx('rcat.base_url')}</th><th class="num">${tx('rcat.services')}</th><th>${tx('rcat.from')}</th><th>${tx('rcat.updated_utc')}</th></tr></thead>
155
156
  <tbody>${rows.length ? rows.map((c) => html`<tr>
156
- <td data-label="Catalog"><a href="${root}/${c.id}">${c.name}</a>${c.description ? html`<br><span class="muted small">${String(c.description).slice(0, 160)}</span>` : ''}</td>
157
- <td data-label="Base URL">${c.server ? html`<span class="tag">${icon('server')} ${c.server}</span> ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code></td>
158
- <td class="num" data-label="Services">${c.services}</td>
159
- <td data-label="From">${c.source === 'openapi' ? html`OpenAPI${c.api_version ? html` <span class="muted">${c.api_version}</span>` : ''}` : 'by hand'}</td>
160
- <td data-label="Updated">${when(c.updated_at)}</td></tr>`)
161
- : html`<tr><td colspan="5" class="empty">No catalogs yet: import an OpenAPI document or create one below.</td></tr>`}</tbody></table></div>`)}
162
- ${region('Import a catalog', importForm(s, `${root}/import`, false))}
163
- ${region('Create a catalog by hand', html`<form method="post" action="${root}">${csrf(s)}
164
- <div class="form-grid">${input('name', 'Name', '', { required: true, placeholder: 'e.g. Weather service' })}${input('base_url', 'Base URL', '', { required: true, placeholder: 'https://api.example.com/v1' })}
165
- <div class="field" data-wide><label class="label" for="f_cdescription">Description</label><input id="f_cdescription" name="description"></div></div>
166
- <div class="buttons"><button class="btn">${icon('plus')} Create</button></div></form>`)}`;
167
- return send(reply, s, shell(s, 'REST Source Catalogs', crumbs(), main));
157
+ <td data-label="${tx('rcat.catalog')}"><a href="${root}/${c.id}">${c.name}</a>${c.description ? html`<br><span class="muted small">${String(c.description).slice(0, 160)}</span>` : ''}</td>
158
+ <td data-label="${tx('rcat.base_url')}">${c.server ? html`<span class="tag">${icon('server')} ${c.server}</span> ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code></td>
159
+ <td class="num" data-label="${tx('rcat.services')}">${c.services}</td>
160
+ <td data-label="${tx('rcat.from')}">${c.source === 'openapi' ? html`OpenAPI${c.api_version ? html` <span class="muted">${c.api_version}</span>` : ''}` : 'by hand'}</td>
161
+ <td data-label="${tx('rcat.updated')}">${when(c.updated_at)}</td></tr>`)
162
+ : html`<tr><td colspan="5" class="empty">${tx('rcat.no_catalogs_yet_import_an')}</td></tr>`}</tbody></table></div>`)}
163
+ ${region(tx('rcat.import_a_catalog'), importForm(s, `${root}/import`, false))}
164
+ ${region(tx('rcat.create_a_catalog_by_hand'), html`<form method="post" action="${root}">${csrf(s)}
165
+ <div class="form-grid">${input('name', tx('rcat.name'), '', { required: true, placeholder: tx('rcat.e_g_weather_service') })}${input('base_url', tx('rcat.base_url'), '', { required: true, placeholder: tx('rcat.https_api_example_com_v1') })}
166
+ <div class="field" data-wide><label class="label" for="f_cdescription">${tx('rcat.description')}</label><input id="f_cdescription" name="description"></div></div>
167
+ <div class="buttons"><button class="btn">${icon('plus')} ${tx('rcat.create')}</button></div></form>`)}`;
168
+ return send(reply, s, shell(s, tx('rcat.rest_source_catalogs'), crumbs(), main));
168
169
  });
169
170
 
170
171
  app.post(root, async (req: Req, reply) => {
@@ -181,7 +182,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
181
182
  }
182
183
  try {
183
184
  const id = await insertCatalog(owner, currentWorkspace(s), c, s.username);
184
- flash(s, 'Catalog created: add its services.');
185
+ flash(s, tx('rcat.catalog_created_add_its_services'));
185
186
  return back(reply, s, `${root}/${id}`);
186
187
  } catch (e) {
187
188
  flash(s, uniqueError(e), 'error');
@@ -203,14 +204,14 @@ export async function restCatalogRoutes(app: FastifyInstance) {
203
204
  doc = await documentOf(doc, s);
204
205
  const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: req.body?.name });
205
206
  const problems = wholeCatalogProblems(catalog);
206
- if (problems.length) throw new Error(`The catalog can't be used as it is: ${problems.slice(0, 8).join(' ')}${problems.length > 8 ? ` (and ${problems.length - 8} more)` : ''}`);
207
+ if (problems.length) throw new Error(tx('rcat.unusable', { problems: `${problems.slice(0, 8).join(' ')}${problems.length > 8 ? ` ${tx('rcat.and_more', { n: problems.length - 8 })}` : ''}` }));
207
208
  const id = await owner.tx((c) => insertCatalog(c, currentWorkspace(s), catalog, s.username));
208
209
  s.state[NOTES] = JSON.stringify(notes);
209
210
  await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `imported catalog ${catalog.name} (${catalog.services.length} services)` });
210
- flash(s, `Catalog ${catalog.name} imported with ${catalog.services.length} service(s).`);
211
+ flash(s, tx('rcat.imported', { name: catalog.name, n: catalog.services.length }));
211
212
  return back(reply, s, `${root}/${id}`);
212
213
  } catch (e) {
213
- flash(s, `Import failed: ${uniqueError(e)}`, 'error');
214
+ flash(s, tx('rcat.import_failed', { error: uniqueError(e) }), 'error');
214
215
  return back(reply, s, root);
215
216
  }
216
217
  });
@@ -224,35 +225,35 @@ export async function restCatalogRoutes(app: FastifyInstance) {
224
225
  const base = `${root}/${c.id}`;
225
226
  const sel = req.query.service ? c.services.find((x) => String(x.id) === req.query.service) : null;
226
227
  const main = html`<div class="title-row"><h1>${c.name}</h1>
227
- <a class="btn" href="${base}/download">${icon('download')} Download</a></div>
228
- <p class="muted u-mt0">${c.server ? html`On remote server <a href="${BASE}/remote-servers">${c.server}</a> · ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code>${c.source === 'openapi' ? html` · from an OpenAPI document${c.api_version ? html` (version ${c.api_version})` : ''}` : ''} · updated ${when(c.updated_at)} UTC</p>
228
+ <a class="btn" href="${base}/download">${icon('download')} ${tx('rcat.download')}</a></div>
229
+ <p class="muted u-mt0">${c.server ? html`${tx.rich('rcat.on_server', { server: html`<a href="${BASE}/remote-servers">${c.server}</a>` })} · ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code>${c.source === 'openapi' ? html` · ${c.api_version ? tx('rcat.from_openapi_version', { version: c.api_version }) : tx('rcat.from_openapi')}` : ''} · ${tx('rcat.updated_when', { when: when(c.updated_at) })}</p>
229
230
  ${notesHtml(s)}
230
- ${region('Services', html`<div class="table-wrap"><table class="report report-reflow">
231
- <thead><tr><th>Service</th><th>Method</th><th>Path</th><th class="num">Parameters</th><th class="num">Columns</th><th>Write back</th></tr></thead>
231
+ ${region(tx('rcat.services'), html`<div class="table-wrap"><table class="report report-reflow">
232
+ <thead><tr><th>${tx('rcat.service')}</th><th>${tx('rcat.method')}</th><th>${tx('rcat.path')}</th><th class="num">${tx('rcat.parameters')}</th><th class="num">${tx('rcat.columns')}</th><th>${tx('rcat.write_back')}</th></tr></thead>
232
233
  <tbody>${c.services.length ? c.services.map((x) => html`<tr${sel?.id === x.id ? raw(' aria-current="true"') : ''}>
233
- <td data-label="Service"><a href="${base}?service=${x.id}#service">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description.slice(0, 140)}</span>` : ''}</td>
234
- <td data-label="Method"><span class="tag">${x.method}</span></td>
235
- <td data-label="Path"><code class="wrap-anywhere">${x.path}</code></td>
236
- <td class="num" data-label="Parameters">${x.params.length}</td>
237
- <td class="num" data-label="Columns">${x.columns.length}</td>
238
- <td data-label="Write back">${Object.keys(x.operations).join(', ') || html`<span class="muted">—</span>`}</td></tr>`)
239
- : html`<tr><td colspan="6" class="empty">No services yet.</td></tr>`}</tbody></table></div>
240
- <p class="u-m0 u-mt1"><a class="btn" href="${base}?service=new#service">${icon('plus')} Add a service</a></p>`)}
234
+ <td data-label="${tx('rcat.service')}"><a href="${base}?service=${x.id}#service">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description.slice(0, 140)}</span>` : ''}</td>
235
+ <td data-label="${tx('rcat.method')}"><span class="tag">${x.method}</span></td>
236
+ <td data-label="${tx('rcat.path')}"><code class="wrap-anywhere">${x.path}</code></td>
237
+ <td class="num" data-label="${tx('rcat.parameters')}">${x.params.length}</td>
238
+ <td class="num" data-label="${tx('rcat.columns')}">${x.columns.length}</td>
239
+ <td data-label="${tx('rcat.write_back')}">${Object.keys(x.operations).join(', ') || html`<span class="muted">—</span>`}</td></tr>`)
240
+ : html`<tr><td colspan="6" class="empty">${tx('rcat.no_services_yet')}</td></tr>`}</tbody></table></div>
241
+ <p class="u-m0 u-mt1"><a class="btn" href="${base}?service=new#service">${icon('plus')} ${tx('rcat.add_a_service')}</a></p>`)}
241
242
  ${req.query.service === 'new'
242
- ? region('New service', serviceForm(s, `${base}/services`, { method: 'GET', path: '/' }, 'Add'), '', 'service')
243
+ ? region(tx('rcat.new_service'), serviceForm(s, `${base}/services`, { method: 'GET', path: '/' }, tx('rcat.add')), '', 'service')
243
244
  : sel
244
- ? region(`Service ${sel.name}`, html`<p class="muted u-mt0">URL: <code class="wrap-anywhere">${c.server ? `${c.server} + ${sel.path}` : serviceUrl(c.base_url ?? '', sel.path)}</code></p>
245
- ${serviceForm(s, `${base}/services/${sel.id}`, sel, 'Save')}
246
- <form method="post" action="${base}/services/${sel.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the service ${sel.name}?">Delete service</button></form>`, '', 'service')
245
+ ? region(tx('rcat.service_title', { name: sel.name }), html`<p class="muted u-mt0">${tx('rcat.url')} <code class="wrap-anywhere">${c.server ? `${c.server} + ${sel.path}` : serviceUrl(c.base_url ?? '', sel.path)}</code></p>
246
+ ${serviceForm(s, `${base}/services/${sel.id}`, sel, tx('rcat.save'))}
247
+ <form method="post" action="${base}/services/${sel.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('rcat.delete_service_confirm', { name: sel.name })}">${tx('rcat.delete_service')}</button></form>`, '', 'service')
247
248
  : ''}
248
- ${region('Catalog', html`<form method="post" action="${base}">${csrf(s)}
249
- <div class="form-grid">${input('name', 'Name', c.name, { required: true })}${input('base_url', 'Base URL', c.base_url ?? '', { help: 'e.g. a test system\'s URL (optional on a remote server). Data sources made earlier keep the URL they were made with.' })}
250
- ${input('server', 'Remote server', c.server ?? '', { placeholder: 'e.g. CRM', help: 'The static id of a remote server of this workspace (Workspace utilities → Remote servers): data sources made from the catalog then name the server and the service\'s path, so they move with it.' })}
251
- ${input('api_version', 'API version', c.api_version ?? '')}
252
- <div class="field" data-wide><label class="label" for="f_cdescription">Description</label><textarea id="f_cdescription" name="description" rows="2">${c.description ?? ''}</textarea></div></div>
253
- <div class="buttons"><button class="btn btn-hot">${icon('save')} Save</button></div></form>`)}
254
- ${region('Replace from a document', importForm(s, `${base}/import`, true))}
255
- <form method="post" action="${base}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the catalog ${c.name} and its services? Data sources made from it stay.">Delete catalog</button></form>`;
249
+ ${region(tx('rcat.catalog'), html`<form method="post" action="${base}">${csrf(s)}
250
+ <div class="form-grid">${input('name', tx('rcat.name'), c.name, { required: true })}${input('base_url', tx('rcat.base_url'), c.base_url ?? '', { help: tx('rcat.e_g_a_test_system') })}
251
+ ${input('server', tx('rcat.remote_server'), c.server ?? '', { placeholder: tx('rcat.e_g_crm'), help: tx('rcat.the_static_id_of_a') })}
252
+ ${input('api_version', tx('rcat.api_version'), c.api_version ?? '')}
253
+ <div class="field" data-wide><label class="label" for="f_cdescription">${tx('rcat.description')}</label><textarea id="f_cdescription" name="description" rows="2">${c.description ?? ''}</textarea></div></div>
254
+ <div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('rcat.save')}</button></div></form>`)}
255
+ ${region(tx('rcat.replace_from_a_document'), importForm(s, `${base}/import`, true))}
256
+ <form method="post" action="${base}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('rcat.delete_catalog_confirm', { name: c.name })}">${tx('rcat.delete_catalog')}</button></form>`;
256
257
  return send(reply, s, shell(s, c.name, crumbs([[c.name]]), main));
257
258
  });
258
259
 
@@ -274,9 +275,9 @@ export async function restCatalogRoutes(app: FastifyInstance) {
274
275
  await owner.query('update meta.rest_catalog set name = $2, base_url = $3, description = $4, api_version = $5, server = $6, updated_at = now() where id = $1',
275
276
  [c.id, v.name, v.base_url || null, v.description, v.api_version, v.server]);
276
277
  if (v.server && !(await owner.one('select 1 as ok from meta.remote_server where workspace_id = $1 and static_id = $2', [c.workspace_id, v.server])))
277
- flash(s, `Catalog saved. This workspace has no remote server ${v.server} yet: add it under Workspace utilities → Remote servers.`, 'error');
278
+ flash(s, tx('rcat.no_server', { server: v.server }), 'error');
278
279
  else
279
- flash(s, 'Catalog saved.');
280
+ flash(s, tx('rcat.catalog_saved'));
280
281
  } catch (e) {
281
282
  flash(s, uniqueError(e), 'error');
282
283
  }
@@ -305,9 +306,9 @@ export async function restCatalogRoutes(app: FastifyInstance) {
305
306
  await owner.tx((db) => replaceCatalog(db, c.id, catalog));
306
307
  s.state[NOTES] = JSON.stringify(notes);
307
308
  await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `replaced catalog ${c.name} (${catalog.services.length} services)` });
308
- flash(s, `The services of ${c.name} were replaced: ${catalog.services.length} service(s).`);
309
+ flash(s, tx('rcat.replaced', { name: c.name, n: catalog.services.length }));
309
310
  } catch (e) {
310
- flash(s, `Import failed: ${uniqueError(e)}`, 'error');
311
+ flash(s, tx('rcat.import_failed', { error: uniqueError(e) }), 'error');
311
312
  }
312
313
  return back(reply, s, `${root}/${c.id}`);
313
314
  });
@@ -330,7 +331,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
330
331
  if (!c) return reply.code(404).send('Not found');
331
332
  await owner.query('delete from meta.rest_catalog where id = $1', [c.id]);
332
333
  await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `deleted catalog ${c.name}` });
333
- flash(s, `Catalog ${c.name} deleted.`);
334
+ flash(s, tx('rcat.deleted', { name: c.name }));
334
335
  return back(reply, s, root);
335
336
  });
336
337
 
@@ -359,7 +360,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
359
360
  `insert into meta.rest_catalog_service (catalog_id, name, description, path, method, params, headers, body, row_selector, columns, key_columns, operations, position)
360
361
  values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (select coalesce(max(position), 0) + 10 from meta.rest_catalog_service where catalog_id = $1)) returning id`, [c.id, ...vals])).id;
361
362
  await owner.query('update meta.rest_catalog set updated_at = now() where id = $1', [c.id]);
362
- flash(s, `Service ${v.name} saved.`);
363
+ flash(s, tx('rcat.service_saved', { name: v.name }));
363
364
  return back(reply, s, `${root}/${c.id}?service=${id}#service`);
364
365
  } catch (e) {
365
366
  flash(s, uniqueError(e), 'error');
@@ -376,7 +377,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
376
377
  if (!c) return reply.code(404).send('Not found');
377
378
  await owner.query('delete from meta.rest_catalog_service where id = $1 and catalog_id = $2', [req.params.sid, c.id]);
378
379
  await owner.query('update meta.rest_catalog set updated_at = now() where id = $1', [c.id]);
379
- flash(s, 'Service deleted.');
380
+ flash(s, tx('rcat.service_deleted'));
380
381
  return back(reply, s, `${root}/${c.id}`);
381
382
  });
382
383
 
@@ -392,12 +393,12 @@ export async function restCatalogRoutes(app: FastifyInstance) {
392
393
  const svc = await owner.one<{ id: number; catalog_id: number }>(
393
394
  `select x.id, x.catalog_id from meta.rest_catalog_service x join meta.rest_catalog c on c.id = x.catalog_id
394
395
  where x.id = $1 and c.workspace_id = meta.app_workspace($2)`, [/^\d{1,9}$/.test(String(req.body?.service)) ? req.body!.service : null, appId]);
395
- if (!svc) throw new Error('Choose a service of one of this workspace\'s catalogs.');
396
+ if (!svc) throw new Error(tx('rcat.choose_a_service_of_one'));
396
397
  const c = (await loadCatalog(svc.catalog_id))!;
397
398
  const v = sourceFromService(c, c.services.find((x) => x.id === svc.id)!, { name: req.body?.name, credential: req.body?.credential });
398
- if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name)) throw new Error('The name is upper case letters, digits and _, starting with a letter, e.g. COUNTRIES.');
399
+ if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name)) throw new Error(tx('rcat.the_name_is_upper_case'));
399
400
  if (v.credential && !(await owner.one('select 1 as ok from meta.web_credential where app_id = $1 and name = $2 union all select 1 from meta.workspace_credential where workspace_id = meta.app_workspace($1) and name = $2', [appId, v.credential])))
400
- throw new Error(`This application has no web credential ${v.credential} (nor does its workspace).`);
401
+ throw new Error(tx('rcat.no_credential', { name: v.credential }));
401
402
  const problems = sourceProblems(v);
402
403
  if (problems.length) throw new Error(problems.join(' '));
403
404
  const r = await owner.one<{ id: number }>(
@@ -408,7 +409,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
408
409
  return back(reply, s, `${BASE}/apps/${appId}/shared?c=rest_source-${r!.id}`);
409
410
  } catch (e) {
410
411
  const m = (e as Error).message;
411
- flash(s, m.includes('rest_source_app_id_name_key') ? 'This application has a REST data source with that name already: choose another name.' : m, 'error');
412
+ flash(s, m.includes('rest_source_app_id_name_key') ? tx('rcat.this_application_has_a_rest') : m, 'error');
412
413
  return back(reply, s, retry);
413
414
  }
414
415
  });
@@ -430,18 +431,18 @@ export async function catalogPicker(appId: number, s: Session): Promise<Raw | ''
430
431
  `select x.id, x.name, x.method, x.path, c.name as catalog from meta.rest_catalog_service x join meta.rest_catalog c on c.id = x.catalog_id
431
432
  where c.workspace_id = meta.app_workspace($1) order by lower(c.name), x.position, x.name`, [appId])).rows;
432
433
  if (!services.length)
433
- return html`<p class="muted u-mt0 u-mb1">${icon('globe')} Services described once for the workspace: <a href="${root}">REST Source Catalogs</a>.</p>`;
434
+ return html`<p class="muted u-mt0 u-mb1">${icon('globe')} ${tx.rich('rcat.described_once', { link: html`<a href="${root}">${tx('rcat.rest_source_catalogs')}</a>` })}</p>`;
434
435
  const credentials = (await owner.query('select name from meta.web_credential where app_id = $1 union select name from meta.workspace_credential where workspace_id = meta.app_workspace($1) order by name', [appId])).rows;
435
436
  const groups = new Map<string, typeof services>();
436
437
  for (const x of services) groups.set(x.catalog, [...(groups.get(x.catalog) ?? []), x]);
437
- return region('From a REST source catalog', html`<form method="post" action="${BASE}/apps/${appId}/rest-sources/from-catalog">${csrf(s)}
438
- <p class="muted u-mt0">A copy of a service of this workspace's <a href="${root}">catalogs</a>: its URL, parameters, columns and write-back operations.</p>
438
+ return region(tx('rcat.from_a_rest_source_catalog'), html`<form method="post" action="${BASE}/apps/${appId}/rest-sources/from-catalog">${csrf(s)}
439
+ <p class="muted u-mt0">${tx.rich('rcat.copy_of', { link: html`<a href="${root}">${tx('rcat.catalogs_link')}</a>` })}</p>
439
440
  <div class="form-grid">
440
- <div class="field" data-wide><label class="label" for="f_service">Service</label><select id="f_service" name="service" required>
441
+ <div class="field" data-wide><label class="label" for="f_service">${tx('rcat.service')}</label><select id="f_service" name="service" required>
441
442
  ${[...groups].map(([cat, xs]) => html`<optgroup label="${cat}">${xs.map((x) => html`<option value="${x.id}">${x.name} · ${x.method} ${x.path}</option>`)}</optgroup>`)}</select></div>
442
- ${input('name', 'Name', '', { placeholder: 'the service\'s name' })}
443
- <div class="field"><label class="label" for="f_credential">Web credential</label><select id="f_credential" name="credential"><option value="">none</option>${credentials.map((c) => html`<option>${c.name}</option>`)}</select></div>
443
+ ${input('name', tx('rcat.name'), '', { placeholder: tx('rcat.the_service_s_name') })}
444
+ <div class="field"><label class="label" for="f_credential">${tx('rcat.web_credential')}</label><select id="f_credential" name="credential"><option value="">none</option>${credentials.map((c) => html`<option>${c.name}</option>`)}</select></div>
444
445
  </div>
445
- <div class="buttons"><button class="btn btn-hot">${icon('plus')} Create from the catalog</button></div></form>`);
446
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('rcat.create_from_the_catalog')}</button></div></form>`);
446
447
  }
447
448
 
@@ -1,6 +1,7 @@
1
1
  import { html } from '../html.ts';
2
2
  import { handlerProblems, type Handler } from '../runtime/rest.ts';
3
3
  import { publicUrl } from '../sso.ts';
4
+ import { scopedTx as tx } from './i18n.ts';
4
5
 
5
6
  // Shared Components → REST modules: the endpoints, curl examples and the
6
7
  // OpenAPI description of a module.
@@ -10,15 +11,15 @@ export function restExtras(a: { id: number; alias: string }, row: { name: string
10
11
  const handlers = Array.isArray(row.handlers) ? row.handlers : [];
11
12
  const problems = handlerProblems(handlers);
12
13
  const example = handlers.find((h) => h.method === 'GET') ?? handlers[0];
13
- return html`<fieldset class="prop-group u-mt125"><legend>Endpoints</legend>
14
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('rst.endpoints')}</legend>
14
15
  ${problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : ''}
15
- ${!row.enabled ? html`<p class="muted">The module is disabled: its endpoints answer 404.</p>` : ''}
16
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Method</th><th>URL</th><th>Type</th><th>Access</th></tr></thead>
17
- <tbody>${handlers.map((h) => html`<tr><td data-label="Method"><code>${h.method}</code></td><td data-label="URL"><code>${base}/${h.path}</code></td>
18
- <td data-label="Type">${h.type}</td><td data-label="Access">${h.auth === 'public' ? 'public' : h.roles?.length ? `token, role ${h.roles.join(' or ')}` : 'token'}</td></tr>`)}</tbody></table></div>
19
- ${example ? html`<p class="muted small">Try it with a token from <a href="/builder/apps/${a.id}/api">REST API</a>:</p>
16
+ ${!row.enabled ? html`<p class="muted">${tx('rst.disabled')}</p>` : ''}
17
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rst.method')}</th><th>${tx('rst.url')}</th><th>${tx('rst.type')}</th><th>${tx('rst.access')}</th></tr></thead>
18
+ <tbody>${handlers.map((h) => html`<tr><td data-label="${tx('rst.method')}"><code>${h.method}</code></td><td data-label="${tx('rst.url')}"><code>${base}/${h.path}</code></td>
19
+ <td data-label="${tx('rst.type')}">${h.type}</td><td data-label="${tx('rst.access')}">${h.auth === 'public' ? 'public' : h.roles?.length ? `token, role ${h.roles.join(' or ')}` : 'token'}</td></tr>`)}</tbody></table></div>
20
+ ${example ? html`<p class="muted small">${tx.rich('rst.try_it', { link: html`<a href="/builder/apps/${a.id}/api">${tx('rst.rest_api')}</a>` })}</p>
20
21
  <pre class="source">curl ${base}/${example.path.replace(/:([a-z_]+)/gi, '1')} -H "Authorization: Bearer $TOKEN"</pre>` : ''}
21
- <p class="muted small">Edit its templates and handlers, and try them, in <a href="/builder/sql/rest/modules">SQL Workshop › RESTful Services › Modules</a>.</p>
22
- <p class="wrap-anywhere">OpenAPI description: <a href="${base}/openapi.json" target="_blank" rel="noopener"><code>${base}/openapi.json</code></a></p>
22
+ <p class="muted small">${tx.rich('rst.edit_in', { link: html`<a href="/builder/sql/rest/modules">${tx('rst.modules_path')}</a>` })}</p>
23
+ <p class="wrap-anywhere">${tx('rst.openapi')} <a href="${base}/openapi.json" target="_blank" rel="noopener"><code>${base}/openapi.json</code></a></p>
23
24
  </fieldset>`;
24
25
  }
@@ -23,13 +23,14 @@ import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './works
23
23
  import { securityRoutes } from './security.ts';
24
24
  import { instanceRoutes } from './instance.ts';
25
25
  import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
26
- import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
26
+ import { notificationSettings, saveNotificationSettings } from './notification-settings.ts';
27
+ import { kindLabel, WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
27
28
  import { createPageRoutes } from './create-page.ts';
28
29
  import { featureRoutes } from './features.ts';
29
30
  import { appFromFileRoutes } from './appfromfile.ts';
30
31
  import { clearDevbar } from '../runtime/devbar.ts';
31
32
  import { EMAIL } from './find-workspace.ts';
32
- import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
33
+ import { scopedTx as tx, cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
33
34
  import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeyByCredential, passkeySignin, passkeysOf, trustBrowser, trustDays, usePasskey, userHandle } from '../mfa.ts';
34
35
  import { isIP } from 'node:net';
35
36
  import { giveChallenge, takeChallenge } from './security.ts';
@@ -587,7 +588,7 @@ export async function builderRoutes(app: FastifyInstance) {
587
588
  if (!s) return;
588
589
  try {
589
590
  const ws = currentWorkspace(s);
590
- if (ws < 1) throw new Error('you are not a developer of any workspace yet: ask an administrator to add you to one.');
591
+ if (ws < 1) throw new Error(tx('rte.you_are_not_a_developer'));
591
592
  const doc = JSON.parse(req.body?.doc ?? '');
592
593
  const r = await owner.tx(async (c) => {
593
594
  const made = (await c.query('select meta.import_app($1::jsonb, $2) as id', [JSON.stringify(doc), req.body?.alias?.trim() || null])).rows[0];
@@ -686,7 +687,7 @@ export async function builderRoutes(app: FastifyInstance) {
686
687
  <p class="muted u-mt0">${tx('app.from_table_text')}</p>
687
688
  <form method="get" action="${BASE}/apps/${a.id}/wizard">
688
689
  <div class="form-grid">
689
- ${select('kind', tx('app.page_type'), 'report_form', WIZARD_KINDS.map(([k, label]): [string, string] => [k, label]))}
690
+ ${select('kind', tx('app.page_type'), 'report_form', WIZARD_KINDS.map(([k, label]): [string, string] => [k, kindLabel(k, label)]))}
690
691
  ${select('table', tx('app.table_or_view'), '', tables.map((t): [string, string] => [t.t, t.access ? t.t : tx('app.no_access', { table: t.t, role: a.db_role })]))}
691
692
  </div>
692
693
  <div class="buttons"><button class="btn btn-hot">${tx('common.next')}</button></div>
@@ -722,7 +723,7 @@ export async function builderRoutes(app: FastifyInstance) {
722
723
  <li><a href="${BASE}/apps/${a.id}/settings?tab=definition">${icon('trash')} ${tx('app.task.delete')}</a></li>
723
724
  </ul></aside>
724
725
  </div>`;
725
- return send(reply, s, shell(s, a.name, [['App Builder', BASE], [a.name]], main));
726
+ return send(reply, s, shell(s, a.name, [[tx('rte.app_builder'), BASE], [a.name]], main));
726
727
  });
727
728
 
728
729
  app.post(`${BASE}/apps/:id/pages`, async (req: Req, reply) => {
@@ -775,8 +776,8 @@ export async function builderRoutes(app: FastifyInstance) {
775
776
  // ---------------------------------------------------------------- settings
776
777
  /** (sprint 74) The tabs of an application's Settings. */
777
778
  const SETTINGS_TABS: [string, string][] = [
778
- ['definition', 'Definition'], ['security', 'Security'], ['signin', 'Sign-in'], ['ui', 'User Interface'],
779
- ['globalization', 'Globalization'], ['errors', 'Errors and Sessions'], ['pwa', 'Progressive Web App'],
779
+ ['definition', tx('rte.definition')], ['security', tx('rte.security')], ['signin', tx('rte.sign_in')], ['ui', tx('rte.user_interface')],
780
+ ['globalization', tx('rte.globalization')], ['errors', tx('rte.errors_and_sessions')], ['pwa', tx('rte.progressive_web_app')],
780
781
  ];
781
782
  app.get(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
782
783
  const s = await developer(req, reply);
@@ -881,6 +882,7 @@ export async function builderRoutes(app: FastifyInstance) {
881
882
  <small class="help">${tx('st.user_choice_help')}</small></div>
882
883
  <p><a class="btn" href="${BASE}/apps/${a.id}/theme">${icon('settings')} ${tx('st.roller')}</a>
883
884
  <span class="muted">${(() => { const n = appStyles(a.theme).length; return n ? tx(n === 1 ? 'st.styles_one' : 'st.styles', { n }) : tx('st.no_styles'); })()}</span></p>
885
+ ${notificationSettings(a, tx)}
884
886
  `)}${end}
885
887
  ${panel('globalization')}${region(tx('st.tab.globalization'), html`
886
888
  <div class="form-grid">
@@ -908,17 +910,17 @@ export async function builderRoutes(app: FastifyInstance) {
908
910
  ${panel('security')}${region(tx('st.checklist'), html`<ul class="checklist">
909
911
  <li>${a.db_role ? '✓' : '✗'} ${tx('st.ck.role')} ${a.db_role ? html`(<code>${a.db_role}</code>)` : html`<b>${tx('st.ck.runtime')}</b>`}</li>
910
912
  <li>${a.authentication !== 'none' ? '✓' : '•'} ${a.authentication !== 'none' ? tx('st.ck.signin') : tx('st.ck.public')}</li>
911
- ${a.authentication === 'header' ? html`<li>${headerProxiesConfigured() ? '✓' : '✗'} Sign-in: HTTP header <code>${a.header_name || DEFAULT_HEADER}</code> ${headerProxiesConfigured() ? 'from the proxies in PGKILN_AUTH_HEADER_PROXIES' : html`<b>refused: PGKILN_AUTH_HEADER_PROXIES is not set</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}${a.header_auto_create ? ', new accounts created automatically' : ''}</li>` : ''}
912
- ${a.authentication === 'database' ? html`<li>${a.db_auth_roles?.length || a.db_auth_member_of ? '✓' : '✗'} Sign-in: database accounts (${[a.db_auth_roles?.length ? `roles ${a.db_auth_roles.join(', ')}` : '', a.db_auth_member_of ? `members of ${a.db_auth_member_of}` : ''].filter(Boolean).join('; ') || html`<b>no roles allowed</b>`})</li>` : ''}
913
- ${a.authentication === 'custom' ? html`<li>${a.custom_auth_function || a.custom_auth_code ? '✓' : '✗'} Sign-in: custom ${a.custom_auth_function ? html`function <code>${a.custom_auth_function}</code>` : a.custom_auth_code ? 'function body' : html`<b>no check configured: nobody can sign in</b>`}${a.custom_auth_post_code ? ', with post-authentication code' : ''}</li>` : ''}
914
- ${a.authentication === 'app_users' ? html`<li>Sign-in: ${[a.local_login ? 'password' : '', ...a.ldap_directories.map((d: string) => `LDAP ${d}`), ...a.sso_providers].filter(Boolean).join(', ') || html`<b>no method enabled</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}</li>` : ''}
913
+ ${a.authentication === 'header' ? html`<li>${headerProxiesConfigured() ? '✓' : '✗'} Sign-in: HTTP header <code>${a.header_name || DEFAULT_HEADER}</code> ${headerProxiesConfigured() ? 'from the proxies in PGKILN_AUTH_HEADER_PROXIES' : html`<b>${tx('rte.refused_pgkiln_auth_header_proxies')}</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}${a.header_auto_create ? ', new accounts created automatically' : ''}</li>` : ''}
914
+ ${a.authentication === 'database' ? html`<li>${a.db_auth_roles?.length || a.db_auth_member_of ? '✓' : '✗'} Sign-in: database accounts (${[a.db_auth_roles?.length ? `roles ${a.db_auth_roles.join(', ')}` : '', a.db_auth_member_of ? `members of ${a.db_auth_member_of}` : ''].filter(Boolean).join('; ') || html`<b>${tx('rte.no_roles_allowed')}</b>`})</li>` : ''}
915
+ ${a.authentication === 'custom' ? html`<li>${a.custom_auth_function || a.custom_auth_code ? '✓' : '✗'} Sign-in: custom ${a.custom_auth_function ? html`function <code>${a.custom_auth_function}</code>` : a.custom_auth_code ? 'function body' : html`<b>${tx('rte.no_check_configured_nobody_can')}</b>`}${a.custom_auth_post_code ? ', with post-authentication code' : ''}</li>` : ''}
916
+ ${a.authentication === 'app_users' ? html`<li>Sign-in: ${[a.local_login ? 'password' : '', ...a.ldap_directories.map((d: string) => `LDAP ${d}`), ...a.sso_providers].filter(Boolean).join(', ') || html`<b>${tx('rte.no_method_enabled')}</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}</li>` : ''}
915
917
  <li>${a.debug ? `✗ ${tx('st.ck.debug_on')}` : `✓ ${tx('st.ck.debug_off')}`}</li>
916
918
  <li>${a.debug_level ? `✗ ${tx('st.ck.msgs_on', { level: a.debug_level })}` : `✓ ${tx('st.ck.msgs_off')}`}</li>
917
919
  <li>${tx('st.ck.unprotected', { n: (await owner.one("select count(*)::int as n from meta.page where app_id = $1 and protection = 'unrestricted'", [a.id])).n })}</li>
918
920
  <li>${tx('st.ck.public_pages', { n: (await owner.one('select count(*)::int as n from meta.page where app_id = $1 and not requires_auth', [a.id])).n })}</li>
919
921
  </ul>`)}${end}
920
922
  </div>`;
921
- return send(reply, s, shell(s, tx('st.title', { app: a.name }), [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Settings']], main));
923
+ return send(reply, s, shell(s, tx('st.title', { app: a.name }), [[tx('rte.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('rte.settings')]], main));
922
924
  });
923
925
 
924
926
  // (085) error handling function and session timeout warning
@@ -992,6 +994,7 @@ export async function builderRoutes(app: FastifyInstance) {
992
994
  (b.version ?? '').trim().slice(0, 40) || null],
993
995
  );
994
996
  await saveTimeZoneSettings(req.params.id, b);
997
+ await saveNotificationSettings(req.params.id, b, tr(s));
995
998
  await saveLoginSettings(req.params.id, b);
996
999
  flash(s, tr(s)('st.settings_saved'));
997
1000
  } catch (e) {
@@ -1041,25 +1044,25 @@ export async function builderRoutes(app: FastifyInstance) {
1041
1044
  ]);
1042
1045
  const main = html`${appHeader(a, 'activity', s)}
1043
1046
  <div class="stat-grid">
1044
- <div class="stat"><b>${stats.views}</b><span>page views (24h)</span></div>
1045
- <div class="stat"><b>${stats.users}</b><span>distinct users (24h)</span></div>
1046
- <div class="stat"><b>${stats.avg_ms} ms</b><span>average page time</span></div>
1047
- <div class="stat"><b>${stats.failures}</b><span>failed / locked sign-ins (24h)</span></div>
1048
- <div class="stat"><b>${stats.problems}</b><span>errors / access denied (24h)</span></div>
1047
+ <div class="stat"><b>${stats.views}</b><span>${tx('rte.page_views_24h')}</span></div>
1048
+ <div class="stat"><b>${stats.users}</b><span>${tx('rte.distinct_users_24h')}</span></div>
1049
+ <div class="stat"><b>${stats.avg_ms} ms</b><span>${tx('rte.average_page_time')}</span></div>
1050
+ <div class="stat"><b>${stats.failures}</b><span>${tx('rte.failed_locked_sign_ins_24h')}</span></div>
1051
+ <div class="stat"><b>${stats.problems}</b><span>${tx('rte.errors_access_denied_24h')}</span></div>
1049
1052
  </div>
1050
1053
  <div class="columns">
1051
- <p><a class="btn" href="${BASE}/apps/${a.id}/top-sql">${icon('database')} Top SQL</a> <span class="muted">the slowest statements of this application's database role</span></p>
1052
- <p><a class="btn" href="${BASE}/apps/${a.id}/ai">${icon('bolt')} AI usage</a> <span class="muted">the AI services this application may use, and its requests</span></p>
1053
- <p><a class="btn" href="${BASE}/apps/${a.id}/feedback">${icon('chat')} Feedback</a> <span class="muted">what users sent from the application's Feedback link, and your answers</span></p>
1054
- <p><a class="btn" href="${BASE}/apps/${a.id}/mail">${icon('send')} Mail</a> <span class="muted">the mail this application queued and sent, with retry and cancel</span></p>
1055
- <p><a class="btn" href="${BASE}/apps/${a.id}/debug">${icon('list')} Debug messages</a> <span class="muted">${a.debug_level ? `on (level ${a.debug_level}): ` : 'off: '}timed steps of each request and messages from <code>meta.debug()</code></span></p>
1056
- ${region('Page views by page (7 days)', html`<div class="table-wrap"><table class="report"><thead><tr><th class="num">Page</th><th>Name</th><th class="num">Views</th><th class="num">Avg ms</th><th class="num">Max ms</th></tr></thead>
1054
+ <p><a class="btn" href="${BASE}/apps/${a.id}/top-sql">${icon('database')} ${tx('rte.top_sql')}</a> <span class="muted">${tx('rte.the_slowest_statements_of_this')}</span></p>
1055
+ <p><a class="btn" href="${BASE}/apps/${a.id}/ai">${icon('bolt')} ${tx('rte.ai_usage')}</a> <span class="muted">${tx('rte.the_ai_services_this_application')}</span></p>
1056
+ <p><a class="btn" href="${BASE}/apps/${a.id}/feedback">${icon('chat')} ${tx('rte.feedback')}</a> <span class="muted">${tx('rte.what_users_sent_from_the')}</span></p>
1057
+ <p><a class="btn" href="${BASE}/apps/${a.id}/mail">${icon('send')} ${tx('rte.mail')}</a> <span class="muted">${tx('rte.the_mail_this_application_queued')}</span></p>
1058
+ <p><a class="btn" href="${BASE}/apps/${a.id}/debug">${icon('list')} ${tx('rte.debug_messages')}</a> <span class="muted">${a.debug_level ? `on (level ${a.debug_level}): ` : 'off: '}timed steps of each request and messages from <code>meta.debug()</code></span></p>
1059
+ ${region(tx('rte.page_views_by_page_7'), html`<div class="table-wrap"><table class="report"><thead><tr><th class="num">${tx('rte.page')}</th><th>${tx('rte.name')}</th><th class="num">${tx('rte.views')}</th><th class="num">${tx('rte.avg_ms')}</th><th class="num">${tx('rte.max_ms')}</th></tr></thead>
1057
1060
  <tbody>${byPage.rows.map((r) => html`<tr><td class="num">${r.page_no}</td><td>${r.name}</td><td class="num">${r.views}</td><td class="num">${r.avg_ms}</td><td class="num">${r.max_ms}</td></tr>`)}</tbody></table></div>`)}
1058
- ${region('Recent events', html`<p class="muted u-mt0">${req.query.all === '1' ? html`Showing all events. <a href="?">Hide page views</a>` : html`Sign-ins, denials and errors. <a href="?all=1">Include page views</a>`}</p>
1059
- <div class="table-wrap"><table class="report"><thead><tr><th>When</th><th>Event</th><th>User</th><th class="num">Page</th><th>IP</th><th>Detail</th></tr></thead>
1061
+ ${region(tx('rte.recent_events'), html`<p class="muted u-mt0">${req.query.all === '1' ? html`Showing all events. <a href="?">${tx('rte.hide_page_views')}</a>` : html`Sign-ins, denials and errors. <a href="?all=1">${tx('rte.include_page_views')}</a>`}</p>
1062
+ <div class="table-wrap"><table class="report"><thead><tr><th>${tx('rte.when')}</th><th>${tx('rte.event')}</th><th>${tx('rte.user')}</th><th class="num">${tx('rte.page')}</th><th>IP</th><th>${tx('rte.detail')}</th></tr></thead>
1060
1063
  <tbody>${events.rows.map((r) => html`<tr><td>${String(r.at).slice(0, 19)}</td><td><span class="ev ev-${r.event}">${r.event}</span></td><td>${r.username}</td><td class="num">${r.page_no}</td><td>${r.ip}</td><td title="${r.detail}">${r.detail}</td></tr>`)}</tbody></table></div>`)}
1061
1064
  </div>`;
1062
- return send(reply, s, shell(s, 'Activity', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity']], main));
1065
+ return send(reply, s, shell(s, tx('rte.activity'), [[tx('rte.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('rte.activity')]], main));
1063
1066
  });
1064
1067
 
1065
1068
  // ---------------------------------------------------------------- developers
@@ -1108,7 +1111,7 @@ export async function builderRoutes(app: FastifyInstance) {
1108
1111
  </form>`
1109
1112
  : html`<p class="muted">${tx('dev.admins_only')}</p>`}`)}
1110
1113
  </div>`;
1111
- return send(reply, s, shell(s, tx('dev.title'), [['Developers']], main, 'developers'));
1114
+ return send(reply, s, shell(s, tx('dev.title'), [[tx('rte.developers')]], main, 'developers'));
1112
1115
  });
1113
1116
 
1114
1117
  app.post(`${BASE}/developers`, async (req: Req, reply) => {