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
@@ -7,7 +7,7 @@ import { icon } from '../icons.ts';
7
7
  import { COMPONENTS } from './components.ts';
8
8
  import { back, BASE, bicon, csrf, designerUrl, developer, flash, input, select, send, shell, type Req } from './ui.ts';
9
9
  import { wantsHtml } from './errors.ts';
10
- import { tr } from './i18n.ts';
10
+ import { scopedTx as tx, tr } from './i18n.ts';
11
11
  import type { Session } from '../session.ts';
12
12
  import { buildOptionChoices, componentForm, lookups, saveComponent } from './forms.ts';
13
13
  import { regionConfigHelp } from './region-help.ts';
@@ -299,20 +299,20 @@ export async function designerRoutes(app: FastifyInstance) {
299
299
  </div>
300
300
  </div>`;
301
301
  const help = html`<div class="pd-help">
302
- <h3>Layout</h3>
303
- <p>Regions sit on a 12-column grid in sequence order; a region's <em>column span</em> sets its width, and regions wrap to a new row when the row is full.</p>
302
+ <h3>${tx('pdz.layout')}</h3>
303
+ <p>${tx.rich('pdz.grid_help', { span: html`<em>${tx('pdz.column_span')}</em>` })}</p>
304
304
  <ul>
305
- <li>Drag a region by its header to move it, or drag its right edge to make it wider or narrower.</li>
306
- <li>Drag items and buttons to another place or another region.</li>
307
- <li>Drag a region, item or button from the gallery onto the layout to create it; or open a gallery entry to create one with a form.</li>
308
- <li>Keyboard: focus a component on the layout and press <kbd>Alt</kbd>+<kbd>↑</kbd> / <kbd>↓</kbd> to move it, <kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>←</kbd> / <kbd>→</kbd> to change a region's width. The <strong>Arrange</strong> buttons above the properties do the same.</li>
309
- <li><strong>Undo</strong> and <strong>Redo</strong> in the toolbar take back layout changes.</li>
305
+ <li>${tx('pdz.drag_a_region_by_its')}</li>
306
+ <li>${tx('pdz.drag_items_and_buttons_to')}</li>
307
+ <li>${tx('pdz.drag_a_region_item_or')}</li>
308
+ <li>${tx.rich('pdz.keyboard_help', { move: html`<kbd>${tx('pdz.alt')}</kbd>+<kbd>↑</kbd> / <kbd>↓</kbd>`, resize: html`<kbd>${tx('pdz.alt')}</kbd>+<kbd>${tx('pdz.shift')}</kbd>+<kbd>←</kbd> / <kbd>→</kbd>`, arrange: html`<strong>${tx('pdz.arrange')}</strong>` })}</li>
309
+ <li>${tx.rich('pdz.undo_help', { undo: html`<strong>${tx('pdz.undo')}</strong>`, redo: html`<strong>${tx('pdz.redo')}</strong>` })}</li>
310
310
  </ul>
311
- <h3>Cheat sheet</h3>
312
- <p><code>:P1_ITEM</code> binds an item value in any SQL (always escaped). <code>:APP_USER</code>, <code>:APP_PAGE_ID</code>, <code>:REQUEST</code> are built in.</p>
313
- <p><code>&amp;P1_ITEM.</code> substitutes into titles, static HTML and link targets (HTML-escaped).</p>
314
- <p>In SQL, PL/pgSQL and RLS policies: <code>meta.app_user()</code>, <code>meta.has_role('admin')</code>, <code>meta.v('P1_ITEM')</code>, <code>meta.page_url(3, '{"P3_ID": 7}')</code>.</p>
315
- <p>A PL/pgSQL <code>raise exception 'Message' using column = 'sal'</code> shows the message on the item whose source column is <code>sal</code>.</p>
311
+ <h3>${tx('pdz.cheat_sheet')}</h3>
312
+ <p>${tx.code('pdz.p1_item_binds_an_item')}</p>
313
+ <p>${tx.code('pdz.p1_item_substitutes_into_titles')}</p>
314
+ <p>${tx.code('pdz.in_sql_pl_pgsql_and')}</p>
315
+ <p>${tx.code('pdz.a_pl_pgsql_raise_exception')}</p>
316
316
  </div>`;
317
317
  const pageSearch = html`<form method="get" action="${BASE}/apps/${p.app_id}/search" class="pd-search" role="search">
318
318
  <label class="label" for="pd-q">${tx('pd.search_label')}</label>
@@ -388,34 +388,34 @@ export async function designerRoutes(app: FastifyInstance) {
388
388
  heading = html`${icon('file')}<span>${tx('pd.page_title', { n: p.page_no, name: p.name })}</span>`;
389
389
  props = html`${peTabs([tab('pd-r-props', tx('pd.copy.page'), html`
390
390
  <form method="post" action="${BASE}/pages/${p.id}" class="component-form" id="${formId}">${csrf(s)}
391
- <fieldset class="prop-group"><legend>Identification</legend><div class="form-grid">
392
- ${input('page_no', 'Page number', p.page_no, { type: 'number', required: true })}
393
- ${input('name', 'Name', p.name, { required: true })}
394
- ${input('title', 'Title', p.title, { help: 'Supports &ITEM. substitutions.' })}
391
+ <fieldset class="prop-group"><legend>${tx('pdz.identification')}</legend><div class="form-grid">
392
+ ${input('page_no', tx('pdz.page_number'), p.page_no, { type: 'number', required: true })}
393
+ ${input('name', tx('pdz.name'), p.name, { required: true })}
394
+ ${input('title', tx('pdz.title'), p.title, { help: tx('pdz.title_help') })}
395
395
  </div></fieldset>
396
- <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
397
- ${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
398
- ${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
399
- ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['left_side_column', 'Left side column'], ['right_side_column', 'Right side column'], ['minimal', 'Minimal (no navigation)']], 'Side column: regions with position left (or right) show in a column beside the body (under it on narrow screens). Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
400
- ${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
401
- ${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
402
- ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order; #WORKSPACE_FILES#name for a static workspace file.' })}
403
- <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="warn_unsaved" value="true"${p.warn_unsaved ? raw(' checked') : ''}> Warn on unsaved changes</label>
404
- <small class="help">Ask before the user leaves the page (a link, a redirect button, closing the tab) while an item has a changed value. Submitting doesn't ask. Leave an item out with {"warn_unsaved": false} in its attributes.</small></div>
405
- ${select('parent_page', 'Breadcrumb parent', p.parent_page ?? '', [['', '- none -'], ...lk.pages.filter((x) => x.page_no !== p.page_no).map((x): [string, string] => [String(x.page_no), `${x.page_no}. ${x.name}`])])}
396
+ <fieldset class="prop-group"><legend>${tx('pdz.appearance')}</legend><div class="form-grid">
397
+ ${select('mode', tx('pdz.page_mode'), p.mode, [['normal', tx('pdz.normal')], ['modal', tx('pdz.modal_dialog')]])}
398
+ ${select('page_group', tx('pdz.page_group'), p.page_group ?? '', [['', tx('pdz.none')], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], tx('pdz.organises_the_pages_in_the'))}
399
+ ${select('page_template', tx('pdz.page_template'), p.page_template ?? 'standard', [['standard', tx('pdz.standard')], ['left_side_column', tx('pdz.left_side_column')], ['right_side_column', tx('pdz.right_side_column')], ['minimal', tx('pdz.minimal_no_navigation')]], tx('pdz.side_column_regions_with_position'))}
400
+ ${select('dialog_position', tx('pdz.dialog_position'), p.dialog_position ?? 'center', [['center', tx('pdz.centred_dialog')], ['right', tx('pdz.drawer_from_the_right')], ['left', tx('pdz.drawer_from_the_left')], ['top', tx('pdz.drawer_from_the_top')], ['bottom', tx('pdz.drawer_from_the_bottom')]], tx('pdz.modal_pages_only_on_phones'))}
401
+ ${select('dialog_size', tx('pdz.dialog_size'), p.dialog_size ?? 'medium', [['small', tx('pdz.small')], ['medium', tx('pdz.medium')], ['large', tx('pdz.large')]], tx('pdz.the_width_the_height_of'))}
402
+ ${input('static_includes', tx('pdz.javascript_and_css_files'), (p.static_includes ?? []).join(', '), { placeholder: tx('pdz.e_g_page_3_js'), help: tx('pdz.static_application_files_js_css') })}
403
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="warn_unsaved" value="true"${p.warn_unsaved ? raw(' checked') : ''}> ${tx('pdz.warn_on_unsaved_changes')}</label>
404
+ <small class="help">${tx('pdz.ask_before_the_user_leaves')}</small></div>
405
+ ${select('parent_page', tx('pdz.breadcrumb_parent'), p.parent_page ?? '', [['', tx('pdz.none')], ...lk.pages.filter((x) => x.page_no !== p.page_no).map((x): [string, string] => [String(x.page_no), `${x.page_no}. ${x.name}`])])}
406
406
  </div></fieldset>
407
- <fieldset class="prop-group"><legend>Security</legend><div class="form-grid">
408
- <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="requires_auth" value="true"${p.requires_auth ? raw(' checked') : ''}> Requires authentication</label></div>
409
- ${select('authz', 'Authorization scheme', p.authz ?? '', [['', '- none -'], ...['MUST_NOT_BE_PUBLIC_USER', ...lk.authz].flatMap((n): [string, string][] => [[n, n], [`!${n}`, `Not ${n}`]])])}
410
- ${select('protection', 'Page access protection', p.protection, [['checksum', 'Arguments must have checksum'], ['unrestricted', 'Unrestricted']],
407
+ <fieldset class="prop-group"><legend>${tx('pdz.security')}</legend><div class="form-grid">
408
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="requires_auth" value="true"${p.requires_auth ? raw(' checked') : ''}> ${tx('pdz.requires_authentication')}</label></div>
409
+ ${select('authz', tx('pdz.authorization_scheme'), p.authz ?? '', [['', tx('pdz.none')], ...['MUST_NOT_BE_PUBLIC_USER', ...lk.authz].flatMap((n): [string, string][] => [[n, n], [`!${n}`, tx('pdz.not_authz', { name: n })]])])}
410
+ ${select('protection', tx('pdz.page_access_protection'), p.protection, [['checksum', tx('pdz.arguments_must_have_checksum')], ['unrestricted', tx('pdz.unrestricted')]],
411
411
  'With checksum, item values in the URL (?P3_ID=…) are only accepted from links the runtime generated.')}
412
- ${select('build_option', 'Build option', p.build_option ?? '', buildOptionChoices(lk, p.build_option),
412
+ ${select('build_option', tx('pdz.build_option'), p.build_option ?? '', buildOptionChoices(lk, p.build_option),
413
413
  'While the option is excluded (or "Not": included) the page does not exist in the running application.')}
414
414
  </div></fieldset>
415
- <fieldset class="prop-group"><legend>Help</legend><div class="form-grid">
416
- <div class="field" data-wide><label class="label" for="f_help">Help text</label>
415
+ <fieldset class="prop-group"><legend>${tx('pdz.help')}</legend><div class="form-grid">
416
+ <div class="field" data-wide><label class="label" for="f_help">${tx('pdz.help_text')}</label>
417
417
  <textarea id="f_help" name="help" rows="5" maxlength="20000">${p.help ?? ''}</textarea>
418
- <small class="help">Markdown with &amp;ITEM. substitutions. A region of type Help Text on the page shows it (APEX: Page → Help).</small></div>
418
+ <small class="help">${tx('pdz.markdown_with_amp_item_substitutions')}</small></div>
419
419
  </div></fieldset>
420
420
  <div class="buttons"><button class="btn btn-hot">${tx('pd.save_page')}</button></div>
421
421
  </form>`, true)])}
@@ -494,7 +494,7 @@ export async function designerRoutes(app: FastifyInstance) {
494
494
  <section class="pd-pane pd-center tab-panel" id="pd-p-layout" data-tab${pane === 'pd-p-layout' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>${tx('pd.layout')}</span></h2>${center}</section>
495
495
  <section class="pd-pane pd-right tab-panel" id="pd-p-props" data-tab${pane === 'pd-p-props' ? raw(' data-tab-active') : ''}><h2 class="tab-title pd-pane-title"><span>${tx('pd.properties')}</span></h2>${right}</section>
496
496
  </div>`;
497
- return send(reply, s, shell(s, tx('pd.page_n_title', { n: p.page_no }), [['App Builder', BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [tx('pd.page_title', { n: p.page_no, name: p.name })]], main, 'apps', { toolbar, full: true }));
497
+ return send(reply, s, shell(s, tx('pd.page_n_title', { n: p.page_no }), [[tx('pdz.app_builder'), BASE], [p.app_name, `${BASE}/apps/${p.app_id}`], [tx('pd.page_title', { n: p.page_no, name: p.name })]], main, 'apps', { toolbar, full: true }));
498
498
  }
499
499
 
500
500
  // (sprint 59) Quick edit from the developer toolbar: an item or button of the page by its name
@@ -8,6 +8,7 @@ import { icon } from '../icons.ts';
8
8
  import { DEBUG_LEVELS } from '../debug.ts';
9
9
  import { isAdmin } from './locks.ts';
10
10
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
11
+ import { scopedTx as tx } from './i18n.ts';
11
12
 
12
13
  // App Builder → Activity → Debug messages (APEX: Debug Messages): the page
13
14
  // views an application recorded while its debug level was on, and the timed
@@ -66,46 +67,46 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
66
67
  };
67
68
  const main = html`${appHeader(a, 'activity', s)}
68
69
  <div class="columns">
69
- ${region('Debug level', html`
70
+ ${region(tx('diag.debug_level'), html`
70
71
  <p class="muted u-mt0">${a.debug_level
71
- ? html`<b>Debug messages are on (level ${a.debug_level})</b>: every request of this application records its steps with timings, and SQL can add messages with <code>meta.debug(level, text)</code>. This costs time and space: turn it off when you are done.`
72
- : html`Debug messages are off: requests record nothing, and <code>meta.debug(level, text)</code> returns at once. Choose a level to record the requests of this application.`}</p>
72
+ ? tx.rich('diag.on', { state: html`<b>${tx('diag.on_state', { level: a.debug_level })}</b>`, fn: html`<code>meta.debug(level, text)</code>` })
73
+ : tx.rich('diag.off', { fn: html`<code>meta.debug(level, text)</code>` })}</p>
73
74
  <form method="post" action="${BASE}/apps/${a.id}/debug/settings">${csrf(s)}
74
75
  <div class="form-grid">
75
- ${select('debug_level', 'Debug level', String(a.debug_level), DEBUG_LEVELS.map(([n, label]): [string, string] => [String(n), label]), 'Level 9 records posted item values (never those of password items).')}
76
- ${select('debug_retention_days', 'Keep messages for', String(a.debug_retention_days), [1, 2, 7, 14, 30, 90].map((d): [string, string] => [String(d), `${d} day${d === 1 ? '' : 's'}`]))}
76
+ ${select('debug_level', tx('diag.debug_level'), String(a.debug_level), DEBUG_LEVELS.map(([n, label]): [string, string] => [String(n), label]), tx('diag.level_9_records_posted_item'))}
77
+ ${select('debug_retention_days', tx('diag.keep_messages_for'), String(a.debug_retention_days), [1, 2, 7, 14, 30, 90].map((d): [string, string] => [String(d), `${d} day${d === 1 ? '' : 's'}`]))}
77
78
  </div>
78
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
79
+ <div class="buttons"><button class="btn btn-hot">${tx('diag.save')}</button></div>
79
80
  </form>`)}
80
- ${region('Page views', html`
81
+ ${region(tx('diag.page_views'), html`
81
82
  <form method="get" action="${BASE}/apps/${a.id}/debug" class="u-mb1">
82
83
  <div class="form-grid">
83
- <div class="field"><label class="label" for="f_page">Page</label><input id="f_page" name="page" type="number" min="1" value="${pageNo ?? ''}"></div>
84
- <div class="field"><label class="label" for="f_user">User</label><input id="f_user" name="user" value="${user ?? ''}"></div>
84
+ <div class="field"><label class="label" for="f_page">${tx('diag.page')}</label><input id="f_page" name="page" type="number" min="1" value="${pageNo ?? ''}"></div>
85
+ <div class="field"><label class="label" for="f_user">${tx('diag.user')}</label><input id="f_user" name="user" value="${user ?? ''}"></div>
85
86
  </div>
86
- <div class="field"><label class="check"><input type="checkbox" name="problems" value="1"${problems ? raw(' checked') : ''}> Only with errors or warnings</label></div>
87
- <div class="buttons"><button class="btn">${icon('search')} Filter</button></div>
87
+ <div class="field"><label class="check"><input type="checkbox" name="problems" value="1"${problems ? raw(' checked') : ''}> ${tx('diag.only_with_errors_or_warnings')}</label></div>
88
+ <div class="buttons"><button class="btn">${icon('search')} ${tx('diag.filter')}</button></div>
88
89
  </form>
89
90
  ${views.length
90
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>When</th><th class="num">Page</th><th>Request</th><th>User</th><th class="num">Status</th><th class="num">ms</th><th class="num">Entries</th><th></th></tr></thead>
91
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('diag.when')}</th><th class="num">${tx('diag.page')}</th><th>${tx('diag.request')}</th><th>${tx('diag.user')}</th><th class="num">${tx('diag.status')}</th><th class="num">ms</th><th class="num">${tx('diag.entries')}</th><th></th></tr></thead>
91
92
  <tbody>${views.map((v) => html`<tr>
92
- <td data-label="When">${String(v.started_at).slice(0, 19)}</td>
93
- <td class="num" data-label="Page">${v.page_no ?? ''}${v.page_name ? html` <span class="muted">${v.page_name}</span>` : ''}</td>
94
- <td data-label="Request"><code>${v.method} ${v.path}</code></td>
95
- <td data-label="User">${v.username ?? ''}</td>
96
- <td class="num" data-label="Status">${v.status >= 400 ? html`<span class="ev ev-error">${v.status}</span>` : v.status}</td>
93
+ <td data-label="${tx('diag.when')}">${String(v.started_at).slice(0, 19)}</td>
94
+ <td class="num" data-label="${tx('diag.page')}">${v.page_no ?? ''}${v.page_name ? html` <span class="muted">${v.page_name}</span>` : ''}</td>
95
+ <td data-label="${tx('diag.request')}"><code>${v.method} ${v.path}</code></td>
96
+ <td data-label="${tx('diag.user')}">${v.username ?? ''}</td>
97
+ <td class="num" data-label="${tx('diag.status')}">${v.status >= 400 ? html`<span class="ev ev-error">${v.status}</span>` : v.status}</td>
97
98
  <td class="num" data-label="ms">${v.ms}</td>
98
- <td class="num" data-label="Entries">${v.entries}${v.problems ? html` <span class="ev ev-error">${v.problems} problem${v.problems === 1 ? '' : 's'}</span>` : ''}</td>
99
- <td><a href="${BASE}/apps/${a.id}/debug/${v.id}">View</a></td></tr>`)}</tbody></table></div>
100
- ${more ? html`<p><a class="btn" href="?${query({ before: String(views[views.length - 1].id) })}">Older page views</a></p>` : ''}`
99
+ <td class="num" data-label="${tx('diag.entries')}">${v.entries}${v.problems ? html` <span class="ev ev-error">${v.problems} problem${v.problems === 1 ? '' : 's'}</span>` : ''}</td>
100
+ <td><a href="${BASE}/apps/${a.id}/debug/${v.id}">${tx('diag.view')}</a></td></tr>`)}</tbody></table></div>
101
+ ${more ? html`<p><a class="btn" href="?${query({ before: String(views[views.length - 1].id) })}">${tx('diag.older_page_views')}</a></p>` : ''}`
101
102
  : html`<p class="muted">No page views recorded${pageNo !== null || user || problems ? ' for this filter' : ''}.</p>`}
102
- <p class="muted">${total} page view${total === 1 ? '' : 's'} kept for ${a.debug_retention_days} day${a.debug_retention_days === 1 ? '' : 's'} (at most 5000). <a href="${BASE}/apps/${a.id}/activity">Back to Activity</a></p>
103
+ <p class="muted">${total} page view${total === 1 ? '' : 's'} kept for ${a.debug_retention_days} day${a.debug_retention_days === 1 ? '' : 's'} (at most 5000). <a href="${BASE}/apps/${a.id}/activity">${tx('diag.back_to_activity')}</a></p>
103
104
  ${total
104
105
  ? html`<form method="post" action="${BASE}/apps/${a.id}/debug/purge">${csrf(s)}
105
- <button class="btn btn-danger" data-confirm="Delete all debug messages of ${a.name}?">${icon('close')} Delete all debug messages</button></form>`
106
+ <button class="btn btn-danger" data-confirm="${tx('diag.delete_confirm', { app: a.name })}">${icon('close')} ${tx('diag.delete_all_debug_messages')}</button></form>`
106
107
  : ''}`)}
107
108
  </div>`;
108
- return send(reply, s, shell(s, `Debug messages · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Debug messages']], main));
109
+ return send(reply, s, shell(s, tx('diag.title', { app: a.name }), [[tx('diag.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('diag.activity'), `${BASE}/apps/${a.id}/activity`], [tx('diag.debug_messages')]], main));
109
110
  });
110
111
 
111
112
  app.get(`${BASE}/apps/:id/debug/:view`, async (req: Req, reply) => {
@@ -132,29 +133,29 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
132
133
  ).rows;
133
134
  const slowest = Math.max(0, ...entries.map((e) => e.dur ?? 0));
134
135
  const main = html`${appHeader(a, 'activity', s)}
135
- ${region(`Page view ${v.id}`, html`
136
+ ${region(tx('diag.page_view', { id: v.id }), html`
136
137
  <div class="stat-grid">
137
- <div class="stat"><b>${v.ms ?? '?'} ms</b><span>total time</span></div>
138
+ <div class="stat"><b>${v.ms ?? '?'} ms</b><span>${tx('diag.total_time')}</span></div>
138
139
  <div class="stat"><b>${v.page_no ?? '-'}</b><span>${v.page_name ?? 'page'}</span></div>
139
- <div class="stat"><b>${v.status ?? '?'}</b><span>HTTP status</span></div>
140
+ <div class="stat"><b>${v.status ?? '?'}</b><span>${tx('diag.http_status')}</span></div>
140
141
  <div class="stat"><b>${v.entries}</b><span>entries (level ${v.level})</span></div>
141
142
  </div>
142
143
  <p class="muted u-mt0"><code>${v.method} ${v.path}</code> · ${v.username ?? ''} · ${String(v.started_at).slice(0, 23)}</p>
143
144
  <p class="seg">
144
- ${v.prev ? html`<a href="${BASE}/apps/${a.id}/debug/${v.prev}">◂ Previous</a>` : ''}
145
- <a href="${BASE}/apps/${a.id}/debug">All page views</a>
145
+ ${v.prev ? html`<a href="${BASE}/apps/${a.id}/debug/${v.prev}">${tx('diag.previous')}</a>` : ''}
146
+ <a href="${BASE}/apps/${a.id}/debug">${tx('diag.all_page_views')}</a>
146
147
  ${v.page_no ? html`<a href="${BASE}/apps/${a.id}/debug?page=${v.page_no}">Page ${v.page_no} only</a>` : ''}
147
- ${v.next ? html`<a href="${BASE}/apps/${a.id}/debug/${v.next}">Next ▸</a>` : ''}
148
+ ${v.next ? html`<a href="${BASE}/apps/${a.id}/debug/${v.next}">${tx('diag.next')}</a>` : ''}
148
149
  </p>
149
- <div class="table-wrap"><table class="report report-reflow debug-log"><thead><tr><th class="num">Elapsed ms</th><th class="num">Duration ms</th><th class="num">Until next</th><th>Level</th><th>Component</th><th>Message</th></tr></thead>
150
+ <div class="table-wrap"><table class="report report-reflow debug-log"><thead><tr><th class="num">${tx('diag.elapsed_ms')}</th><th class="num">${tx('diag.duration_ms')}</th><th class="num">${tx('diag.until_next')}</th><th>${tx('diag.level')}</th><th>${tx('diag.component')}</th><th>${tx('diag.message')}</th></tr></thead>
150
151
  <tbody>${entries.map((e) => html`<tr${e.dur && e.dur === slowest && slowest > 0 ? raw(' class="debug-slowest"') : ''}>
151
- <td class="num" data-label="Elapsed ms">${e.ms}</td>
152
- <td class="num" data-label="Duration ms">${e.dur ?? ''}</td>
153
- <td class="num" data-label="Until next">${e.gap ?? ''}</td>
154
- <td data-label="Level">${levelTag(e.level)}</td>
155
- <td data-label="Component">${e.component ?? ''}</td>
156
- <td data-label="Message" class="debug-text">${e.message}</td></tr>`)}</tbody></table></div>`)}`;
157
- return send(reply, s, shell(s, `Debug ${v.id} · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Debug messages', `${BASE}/apps/${a.id}/debug`], [`Page view ${v.id}`]], main));
152
+ <td class="num" data-label="${tx('diag.elapsed_ms')}">${e.ms}</td>
153
+ <td class="num" data-label="${tx('diag.duration_ms')}">${e.dur ?? ''}</td>
154
+ <td class="num" data-label="${tx('diag.until_next')}">${e.gap ?? ''}</td>
155
+ <td data-label="${tx('diag.level')}">${levelTag(e.level)}</td>
156
+ <td data-label="${tx('diag.component')}">${e.component ?? ''}</td>
157
+ <td data-label="${tx('diag.message')}" class="debug-text">${e.message}</td></tr>`)}</tbody></table></div>`)}`;
158
+ return send(reply, s, shell(s, tx('diag.view_title', { id: v.id, app: a.name }), [[tx('diag.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('diag.debug_messages'), `${BASE}/apps/${a.id}/debug`], [tx('diag.page_view', { id: v.id })]], main));
158
159
  });
159
160
 
160
161
  app.post(`${BASE}/apps/:id/debug/settings`, async (req: Req, reply) => {
@@ -165,11 +166,11 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
165
166
  const level = Number(req.body?.debug_level);
166
167
  const days = Number(req.body?.debug_retention_days);
167
168
  if (!DEBUG_LEVELS.some(([n]) => n === level) || !Number.isInteger(days) || days < 1 || days > 90) {
168
- flash(s, 'Choose a debug level and a retention of 1 to 90 days.', 'error');
169
+ flash(s, tx('diag.choose_a_debug_level_and'), 'error');
169
170
  return back(reply, s, `${BASE}/apps/${a.id}/debug`);
170
171
  }
171
172
  await owner.query('update meta.app set debug_level = $2, debug_retention_days = $3, updated_at = now() where id = $1', [a.id, level, days]);
172
- flash(s, level ? `Debug messages on (level ${level}).` : 'Debug messages off.');
173
+ flash(s, level ? tx('diag.on_flash', { level }) : tx('diag.debug_messages_off'));
173
174
  return back(reply, s, `${BASE}/apps/${a.id}/debug`);
174
175
  });
175
176
 
@@ -188,8 +189,8 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
188
189
  const s = await developer(req, reply);
189
190
  if (!s) return;
190
191
  if (!(await isAdmin(s.username))) {
191
- const main = html`<h1 class="u-mb1">Installation</h1><div class="alert alert-error" role="alert">Only administrators see the installation log.</div>`;
192
- return send(reply.code(403), s, shell(s, 'Installation', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Installation']], main));
192
+ const main = html`<h1 class="u-mb1">${tx('diag.installation')}</h1><div class="alert alert-error" role="alert">${tx('diag.only_administrators_see_the_installation')}</div>`;
193
+ return send(reply.code(403), s, shell(s, tx('diag.installation'), [[tx('diag.app_builder'), BASE], [tx('diag.workspace_utilities'), `${BASE}/utilities`], [tx('diag.installation')]], main));
193
194
  }
194
195
  const has = async (t: string) => !!(await owner.one('select to_regclass($1) is not null as ok', [t]))?.ok;
195
196
  const [runs, migrations, examples] = await Promise.all([
@@ -203,33 +204,33 @@ export async function diagnosticsRoutes(app: FastifyInstance) {
203
204
  const pending = files.filter((f) => !applied.has(f));
204
205
  const unknown = migrations.filter((m) => !files.includes(m.name));
205
206
  const db = await owner.one(`select current_database() as name, split_part(version(), ' on ', 1) as version, pg_size_pretty(pg_database_size(current_database())) as size`);
206
- const main = html`<h1 class="u-mb1">Installation</h1>
207
+ const main = html`<h1 class="u-mb1">${tx('diag.installation')}</h1>
207
208
  <div class="stat-grid">
208
- <div class="stat"><b>${VERSION}</b><span>pgkiln version of this server</span></div>
209
- <div class="stat"><b>${migrations.length}</b><span>migrations applied${migrations[0] ? html`, the latest ${migrations[0].name.replace(/\.sql$/, '')}` : ''}</span></div>
210
- <div class="stat"><b>${pending.length}</b><span>migrations not applied</span></div>
209
+ <div class="stat"><b>${VERSION}</b><span>${tx('diag.pgkiln_version_of_this_server')}</span></div>
210
+ <div class="stat"><b>${migrations.length}</b><span>${migrations[0] ? tx('diag.applied_latest', { name: migrations[0].name.replace(/\.sql$/, '') }) : tx('diag.applied_n')}</span></div>
211
+ <div class="stat"><b>${pending.length}</b><span>${tx('diag.migrations_not_applied')}</span></div>
211
212
  <div class="stat"><b>${db.size}</b><span>${db.name} · ${db.version}</span></div>
212
213
  </div>
213
214
  ${pending.length
214
- ? html`<div class="alert alert-error" role="alert">This database misses ${pending.length} migration${pending.length === 1 ? '' : 's'} of this server (${pending.join(', ')}). Run <code>npm run db:migrate</code> or <code>pgkiln migrate</code>.</div>`
215
+ ? html`<div class="alert alert-error" role="alert">${tx.rich(pending.length === 1 ? 'diag.missing_1' : 'diag.missing', { n: pending.length, list: pending.join(', '), npm: html`<code>npm run db:migrate</code>`, cli: html`<code>pgkiln migrate</code>` })}</div>`
215
216
  : ''}
216
- ${unknown.length ? html`<div class="alert alert-error" role="alert">The database has migrations this server does not know (${unknown.map((m) => m.name).join(', ')}): the server is older than the database.</div>` : ''}
217
+ ${unknown.length ? html`<div class="alert alert-error" role="alert">${tx('diag.unknown', { list: unknown.map((m) => m.name).join(', ') })}</div>` : ''}
217
218
  <div class="columns">
218
- ${region('Install and upgrade runs', runs.length
219
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Started</th><th>Kind</th><th>Version</th><th>Status</th><th class="num">Files</th><th>Applied</th><th>By</th></tr></thead>
219
+ ${region(tx('diag.install_and_upgrade_runs'), runs.length
220
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('diag.started')}</th><th>${tx('diag.kind')}</th><th>${tx('diag.version')}</th><th>${tx('diag.status')}</th><th class="num">${tx('diag.files')}</th><th>${tx('diag.applied')}</th><th>${tx('diag.by')}</th></tr></thead>
220
221
  <tbody>${runs.map((r) => html`<tr>
221
- <td data-label="Started">${String(r.started_at).slice(0, 19)}</td><td data-label="Kind">${r.kind}</td><td data-label="Version">${r.version ?? ''}</td>
222
- <td data-label="Status">${r.status === 'ok' ? html`<span class="ev ev-login">ok</span>` : html`<span class="ev ev-error">failed</span>`}${r.error ? html`<div class="small">${r.error}</div>` : ''}</td>
223
- <td class="num" data-label="Files">${r.applied.length}</td>
224
- <td data-label="Applied" class="debug-text">${r.applied.join(', ')}</td><td data-label="By">${r.db_user}</td></tr>`)}</tbody></table></div>`
225
- : html`<p class="muted u-mt0">No runs recorded yet: runs are logged from version 0.25 on (each <code>db:migrate</code> or <code>pgkiln migrate</code> that applies or fails a file). The applied migrations below show when each file was applied.</p>`)}
226
- ${region('Applied migrations', html`<div class="table-wrap"><table class="report"><thead><tr><th>File</th><th>Applied</th></tr></thead>
222
+ <td data-label="${tx('diag.started')}">${String(r.started_at).slice(0, 19)}</td><td data-label="${tx('diag.kind')}">${r.kind}</td><td data-label="${tx('diag.version')}">${r.version ?? ''}</td>
223
+ <td data-label="${tx('diag.status')}">${r.status === 'ok' ? html`<span class="ev ev-login">ok</span>` : html`<span class="ev ev-error">failed</span>`}${r.error ? html`<div class="small">${r.error}</div>` : ''}</td>
224
+ <td class="num" data-label="${tx('diag.files')}">${r.applied.length}</td>
225
+ <td data-label="${tx('diag.applied')}" class="debug-text">${r.applied.join(', ')}</td><td data-label="${tx('diag.by')}">${r.db_user}</td></tr>`)}</tbody></table></div>`
226
+ : html`<p class="muted u-mt0">${tx.code('diag.no_runs_recorded_yet_runs')}</p>`)}
227
+ ${region(tx('diag.applied_migrations'), html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('diag.file')}</th><th>${tx('diag.applied')}</th></tr></thead>
227
228
  <tbody>${migrations.map((m) => html`<tr><td><code>${m.name}</code></td><td>${String(m.applied_at).slice(0, 19)}</td></tr>`)}</tbody></table></div>`)}
228
229
  ${examples.length
229
- ? region('Example and seed scripts', html`<div class="table-wrap"><table class="report"><thead><tr><th>File</th><th>Applied</th></tr></thead>
230
+ ? region(tx('diag.example_and_seed_scripts'), html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('diag.file')}</th><th>${tx('diag.applied')}</th></tr></thead>
230
231
  <tbody>${examples.map((m) => html`<tr><td><code>${m.name}</code></td><td>${String(m.applied_at).slice(0, 19)}</td></tr>`)}</tbody></table></div>`)
231
232
  : ''}
232
233
  </div>`;
233
- return send(reply, s, shell(s, 'Installation', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Installation']], main));
234
+ return send(reply, s, shell(s, tx('diag.installation'), [[tx('diag.app_builder'), BASE], [tx('diag.workspace_utilities'), `${BASE}/utilities`], [tx('diag.installation')]], main));
234
235
  });
235
236
  }
@@ -8,6 +8,7 @@ import { appOr404 } from './forms.ts';
8
8
  import { createLoginPage } from './login-page.ts';
9
9
  import { ABOUT_PAGE, ACTIVITY_PAGE, addAboutPage, addAccessSchemes, addActivityPage, ensureAdminScheme } from './newapp.ts';
10
10
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
11
+ import { scopedTx as tx } from './i18n.ts';
11
12
 
12
13
  // (sprint 137, AP-3) Add feature (APEX: Create a Page → Feature, and App home → Tasks → Add Feature Page):
13
14
  // the Create Application wizard's features (newapp.ts) and a few more, added to an existing application.
@@ -67,8 +68,8 @@ export const FEATURE_LIST: Feature[] = [
67
68
  icon: 'lock',
68
69
  help: 'Authorization schemes for the roles admin, contributor and reader, to put on pages, regions and buttons.',
69
70
  adds: () => [
70
- 'Authorization schemes CONTRIBUTOR (role admin or contributor) and READER (admin, contributor or reader), and ADMIN when the application has none',
71
- 'Give users their roles under Shared Components → Users (application access)',
71
+ tx('feat.a.access_1'),
72
+ tx('feat.a.access_2'),
72
73
  ],
73
74
  installed: async (c, a) => (await c.query(`select count(*)::int as n from meta.authz_scheme where app_id = $1 and name in ('CONTRIBUTOR', 'READER')`, [a.id])).rows[0].n === 2,
74
75
  install: async (c, a) => {
@@ -82,12 +83,12 @@ export const FEATURE_LIST: Feature[] = [
82
83
  icon: 'activity',
83
84
  help: 'A page for administrators with the page views per day and the latest activity.',
84
85
  adds: () => [
85
- `Page ${ACTIVITY_PAGE} "Activity" (authorization ADMIN): a chart of the page views per day and a report of the latest activity, from meta.app_activity()`,
86
- 'A navigation entry Activity for administrators',
87
- 'Authorization scheme ADMIN (role admin) when the application has none',
86
+ tx('feat.a.activity_1', { page: ACTIVITY_PAGE }),
87
+ tx('feat.a.activity_2'),
88
+ tx('feat.a.activity_3'),
88
89
  ],
89
90
  installed: (c, a) => pageHas(c, a.id, ACTIVITY_PAGE, 'meta.app_activity'),
90
- blocked: async (c, a) => ((await pageExists(c, a.id, ACTIVITY_PAGE)) ? `Page ${ACTIVITY_PAGE} is in use by another page.` : null),
91
+ blocked: async (c, a) => ((await pageExists(c, a.id, ACTIVITY_PAGE)) ? tx('feat.page_in_use', { page: ACTIVITY_PAGE }) : null),
91
92
  install: async (c, a) => {
92
93
  await addActivityPage(c, a.id);
93
94
  return pageId(c, a.id, ACTIVITY_PAGE);
@@ -99,19 +100,19 @@ export const FEATURE_LIST: Feature[] = [
99
100
  icon: 'sliders',
100
101
  help: 'A page where the application\'s administrators switch build options on and off while it runs.',
101
102
  adds: () => [
102
- `Page ${CONFIG_PAGE} "Configuration" (authorization ADMIN): the configurable build options as switches, and Save`,
103
- 'The build options ticked below become configurable (Shared Components → Build options → Configurable)',
104
- 'A navigation entry Configuration for administrators',
103
+ tx('feat.a.config_1', { page: CONFIG_PAGE }),
104
+ tx('feat.a.config_2'),
105
+ tx('feat.a.config_3'),
105
106
  ],
106
107
  installed: (c, a) => pageHas(c, a.id, CONFIG_PAGE, 'meta.set_configurable_options'),
107
- blocked: async (c, a) => ((await pageExists(c, a.id, CONFIG_PAGE)) ? `Page ${CONFIG_PAGE} is in use by another page.` : null),
108
+ blocked: async (c, a) => ((await pageExists(c, a.id, CONFIG_PAGE)) ? tx('feat.page_in_use', { page: CONFIG_PAGE }) : null),
108
109
  fields: async (a) => {
109
110
  const opts = (await owner.query<{ name: string; description: string | null; status: string; configurable: boolean }>(
110
111
  'select name, description, status, configurable from meta.build_option where app_id = $1 order by name', [a.id])).rows;
111
112
  return opts.length
112
- ? html`<fieldset class="prop-group"><legend>Configurable build options</legend><div class="qb-pick">${opts.map((o) =>
113
+ ? html`<fieldset class="prop-group"><legend>${tx('feat.cbo')}</legend><div class="qb-pick">${opts.map((o) =>
113
114
  html`<label class="check"><input type="checkbox" name="options" value="${o.name}"${o.configurable ? raw(' checked') : ''}> ${o.name} <span class="muted small">${o.status}${o.description ? `, ${o.description}` : ''}</span></label>`)}</div></fieldset>`
114
- : html`<p class="muted">The application has no build options yet: add them under Shared Components → Build options and tick <em>Configurable</em>; the page shows them as they come.</p>`;
115
+ : html`<p class="muted">${tx.rich('feat.no_build_options', { configurable: html`<em>${tx('feat.configurable')}</em>` })}</p>`;
115
116
  },
116
117
  install: async (c, a, b) => {
117
118
  const chosen = ([] as unknown[]).concat(b.options ?? []).map(String);
@@ -146,19 +147,19 @@ export const FEATURE_LIST: Feature[] = [
146
147
  icon: 'mail',
147
148
  help: 'A weekly e-mail to the administrators with the application\'s activity, the rows as a CSV file.',
148
149
  adds: () => [
149
- 'Application setting EMAIL_REPORT_TO: the addresses the report goes to (Shared Components → Application settings)',
150
- 'Automation EMAIL_REPORTING, every Monday at 07:00 UTC: meta.send_activity_report() with the last 7 days (Shared Components → Automations)',
151
- 'Mail goes out through the application\'s mail service (Workspace utilities → Mail services); without addresses nothing is sent',
150
+ tx('feat.a.email_1'),
151
+ tx('feat.a.email_2'),
152
+ tx('feat.a.email_3'),
152
153
  ],
153
154
  installed: async (c, a) => !!(await c.query(`select 1 from meta.automation where app_id = $1 and name = 'EMAIL_REPORTING'`, [a.id])).rowCount,
154
- fields: async () => html`<div class="field"><label class="label" for="f_report_to">Send it to</label>
155
+ fields: async () => html`<div class="field"><label class="label" for="f_report_to">${tx('feat.send_to')}</label>
155
156
  <input id="f_report_to" name="report_to" type="text" placeholder="admin@example.com, boss@example.com" autocomplete="off">
156
- <small class="help">Comma separated; may stay empty and be set later in Shared Components → Application settings (EMAIL_REPORT_TO).</small></div>`,
157
+ <small class="help">${tx('feat.send_to_help')}</small></div>`,
157
158
  install: async (c, a, b) => {
158
159
  const to = String(b.report_to ?? '').split(',').map((x) => x.trim()).filter(Boolean);
159
160
  const bad = to.find((x) => !EMAIL.test(x));
160
- if (bad) throw new Error(`"${bad.slice(0, 100)}" is not an e-mail address.`);
161
- if (to.join(', ').length > 4000) throw new Error('Too many addresses.');
161
+ if (bad) throw new Error(tx('feat.not_email', { address: bad.slice(0, 100) }));
162
+ if (to.join(', ').length > 4000) throw new Error(tx('feat.too_many'));
162
163
  await c.query(
163
164
  `insert into meta.app_setting (app_id, name, value, description) values ($1, 'EMAIL_REPORT_TO', $2, 'E-mail reporting: the addresses of the weekly activity report (comma separated)')
164
165
  on conflict (app_id, name) do nothing`,
@@ -177,7 +178,7 @@ export const FEATURE_LIST: Feature[] = [
177
178
  label: 'Feedback',
178
179
  icon: 'message-square',
179
180
  help: 'Users send feedback from the header; you read it under the application\'s Activity → Feedback.',
180
- adds: () => ['A Feedback entry in the header for signed-in users (Settings → Feedback)', 'Their feedback, with the page, under Activity → Feedback in the builder'],
181
+ adds: () => [tx('feat.a.feedback_1'), tx('feat.a.feedback_2')],
181
182
  installed: async (_c, a) => a.feedback,
182
183
  install: async (c, a) => {
183
184
  await c.query('update meta.app set feedback = true, updated_at = now() where id = $1', [a.id]);
@@ -189,7 +190,7 @@ export const FEATURE_LIST: Feature[] = [
189
190
  label: 'About page',
190
191
  icon: 'info',
191
192
  help: 'An About page in a Help menu of the header.',
192
- adds: () => [`Page ${ABOUT_PAGE} "About" with the application's name and the pages of its menu`, 'A Help menu with About in the navigation bar (list NAVBAR), unless the application has a navigation bar list already'],
193
+ adds: () => [tx('feat.a.about_1', { page: ABOUT_PAGE }), tx('feat.a.about_2')],
193
194
  installed: (c, a) => pageExists(c, a.id, ABOUT_PAGE),
194
195
  install: async (c, a) => {
195
196
  await addAboutPage(c, a.id);
@@ -201,9 +202,9 @@ export const FEATURE_LIST: Feature[] = [
201
202
  label: 'Login page',
202
203
  icon: 'log-in',
203
204
  help: 'The application\'s own login page instead of the built-in sign-in page, editable in the Page Designer.',
204
- adds: () => ['Page 9999 (or the next free number) "Login": public, page template Minimal, with a Login region', 'It becomes the login page (Settings → Sign-in methods)'],
205
+ adds: () => [tx('feat.a.login_1'), tx('feat.a.login_2')],
205
206
  installed: async (_c, a) => a.login_page !== null,
206
- blocked: async (_c, a) => (a.authentication === 'none' ? 'The application has no sign-in (authentication none).' : null),
207
+ blocked: async (_c, a) => (a.authentication === 'none' ? tx('feat.no_signin') : null),
207
208
  install: async (c, a) => (await createLoginPage(c, a)).page,
208
209
  },
209
210
  {
@@ -211,7 +212,7 @@ export const FEATURE_LIST: Feature[] = [
211
212
  label: 'Theme style selection',
212
213
  icon: 'palette',
213
214
  help: 'Users choose the light or the dark theme in their user menu (Appearance).',
214
- adds: () => ['Appearance in the user menu: light, dark or the device\'s setting (Settings → Theme → Users may choose)'],
215
+ adds: () => [tx('feat.a.theme_1')],
215
216
  installed: async (_c, a) => a.theme?.user_choice !== false,
216
217
  install: async (c, a) => {
217
218
  await c.query(`update meta.app set theme = coalesce(theme, '{}') - 'user_choice', updated_at = now() where id = $1`, [a.id]);
@@ -223,9 +224,9 @@ export const FEATURE_LIST: Feature[] = [
223
224
  label: 'Push notifications',
224
225
  icon: 'bell',
225
226
  help: 'Notifications to the users\' devices, from processes and SQL (meta.send_push); the application becomes installable.',
226
- adds: () => ['Install as an app (PWA): a manifest, a service worker and an Install button', 'Push notifications: users subscribe on My account; send with the send_push process or meta.send_push', 'The application\'s push key pair (kept encrypted)'],
227
+ adds: () => [tx('feat.a.push_1'), tx('feat.a.push_2'), tx('feat.a.push_3')],
227
228
  installed: async (_c, a) => a.pwa_push,
228
- blocked: async () => (secretKeyConfigured() ? null : 'Push notifications need PGKILN_SECRET_KEY in the server\'s environment.'),
229
+ blocked: async () => (secretKeyConfigured() ? null : tx('feat.push_needs_key')),
229
230
  install: async (c, a) => {
230
231
  await c.query('update meta.app set pwa = true, pwa_push = true, updated_at = now() where id = $1', [a.id]);
231
232
  return null;
@@ -235,6 +236,9 @@ export const FEATURE_LIST: Feature[] = [
235
236
 
236
237
  const CODE_EMAIL = `select meta.send_activity_report(meta.get_app_setting('EMAIL_REPORT_TO'), 7)`;
237
238
 
239
+ /** A feature's label and help in the builder language (FEATURE_LIST stays English). */
240
+ export const featLabel = (f: Feature) => tx.dyn(`feat.f.${f.key}`, f.label);
241
+ export const featHelp = (f: Feature) => tx.dyn(`feat.h.${f.key}`, f.help);
238
242
  const FEATURES_BY_KEY = new Map(FEATURE_LIST.map((f) => [f.key, f]));
239
243
 
240
244
  async function appRow(id: string | number, c?: Client): Promise<AppRow | null> {
@@ -269,20 +273,20 @@ export async function featureRoutes(app: FastifyInstance) {
269
273
  return { installed, blocked: installed ? null : ((await f.blocked?.(c, a)) ?? null) };
270
274
  });
271
275
  const main = html`${appHeader(a0, 'pages', s)}
272
- ${region(f.label, html`
273
- <p class="muted u-mt0">${icon(f.icon)} ${f.help}</p>
276
+ ${region(featLabel(f), html`
277
+ <p class="muted u-mt0">${icon(f.icon)} ${featHelp(f)}</p>
274
278
  ${installed
275
- ? html`<p class="alert alert-info" role="status">${f.label} is installed in this application already.</p>`
279
+ ? html`<p class="alert alert-info" role="status">${tx('feat.installed_already', { feature: featLabel(f) })}</p>`
276
280
  : blocked ? html`<p class="alert alert-error" role="status">${blocked}</p>` : ''}
277
- <h3>${installed ? 'It added' : 'It adds'}</h3>
281
+ <h3>${installed ? tx('feat.it_added') : tx('feat.it_adds')}</h3>
278
282
  <ul class="cp-adds">${f.adds(a).map((x) => html`<li>${x}</li>`)}</ul>
279
283
  ${installed || blocked
280
- ? html`<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=feature">Back to the features</a></div>`
284
+ ? html`<div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=feature">${tx('feat.back_to_the_features')}</a></div>`
281
285
  : html`<form method="post" action="${BASE}/apps/${a.id}/features/${f.key}">${csrf(s)}
282
286
  ${f.fields ? await f.fields(a) : ''}
283
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=feature">Cancel</a><button class="btn btn-hot">Add ${f.label.toLowerCase()}</button></div>
287
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}/create-page?tab=feature">${tx('feat.cancel')}</a><button class="btn btn-hot">${tx('feat.add', { feature: featLabel(f).toLowerCase() })}</button></div>
284
288
  </form>`}`)}`;
285
- return send(reply, s, shell(s, `${f.label} · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page?tab=feature`], [f.label]], main));
289
+ return send(reply, s, shell(s, tx('feat.title', { feature: featLabel(f), app: a.name }), [[tx('feat.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('feat.create_page'), `${BASE}/apps/${a.id}/create-page?tab=feature`], [featLabel(f)]], main));
286
290
  });
287
291
 
288
292
  app.post(`${BASE}/apps/:id/features/:key`, async (req: Req, reply) => {
@@ -296,14 +300,14 @@ export async function featureRoutes(app: FastifyInstance) {
296
300
  // one at a time per application, and never twice
297
301
  await c.query('select 1 from meta.app where id = $1 for update', [req.params.id]);
298
302
  const a = (await appRow(req.params.id, c))!;
299
- if (await f.installed(c, a)) throw new Error(`${f.label} is installed in this application already.`);
303
+ if (await f.installed(c, a)) throw new Error(tx('feat.installed_already', { feature: featLabel(f) }));
300
304
  const blocked = await f.blocked?.(c, a);
301
305
  if (blocked) throw new Error(blocked);
302
306
  return f.install(c, a, b);
303
307
  });
304
308
  // the push key pair after the commit (as the Create Application wizard does)
305
309
  if (f.key === 'push') await appPublicKey(Number(req.params.id)).catch(() => undefined);
306
- flash(s, `${f.label} added.`);
310
+ flash(s, tx('feat.added', { feature: featLabel(f) }));
307
311
  return back(reply, s, page ? `${BASE}/pages/${page}` : `${BASE}/apps/${req.params.id}/create-page?tab=feature`);
308
312
  } catch (e) {
309
313
  flash(s, (e as Error).message, 'error');