pgkiln 0.82.0 → 0.83.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 (61) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/docs/apex-feature-parity.md +3 -3
  3. package/docs/guide/03-builder.md +8 -1
  4. package/docs/guide/04-pages-and-regions.md +2 -1
  5. package/docs/guide/07-dynamic-actions.md +27 -0
  6. package/docs/guide/11-from-apex.md +2 -1
  7. package/docs/guide/12-development.md +9 -1
  8. package/package.json +1 -1
  9. package/public/app.css +2 -0
  10. package/public/app.js +85 -4
  11. package/public/builder.css +25 -2
  12. package/public/data-modeler.js +8 -5
  13. package/public/sql-monitor.js +5 -2
  14. package/src/builder/ai-builder.ts +50 -46
  15. package/src/builder/api.ts +49 -49
  16. package/src/builder/autorest.ts +92 -87
  17. package/src/builder/changelog.ts +39 -37
  18. package/src/builder/components.ts +5 -4
  19. package/src/builder/data-modeler.ts +89 -84
  20. package/src/builder/data-security.ts +39 -37
  21. package/src/builder/dataload.ts +69 -65
  22. package/src/builder/datapump.ts +108 -97
  23. package/src/builder/ddl-tools.ts +38 -34
  24. package/src/builder/document-view.ts +11 -11
  25. package/src/builder/i18n/de.ts +1627 -0
  26. package/src/builder/i18n/en.ts +1627 -0
  27. package/src/builder/i18n/es.ts +1627 -0
  28. package/src/builder/i18n/fr.ts +1627 -0
  29. package/src/builder/i18n/it.ts +1627 -0
  30. package/src/builder/i18n/ja.ts +1627 -0
  31. package/src/builder/i18n/ko.ts +1627 -0
  32. package/src/builder/i18n/nl.ts +1627 -0
  33. package/src/builder/i18n/pt.ts +1627 -0
  34. package/src/builder/i18n/zh-cn.ts +1627 -0
  35. package/src/builder/i18n/zh-tw.ts +1627 -0
  36. package/src/builder/i18n.ts +18 -0
  37. package/src/builder/object-actions.ts +18 -16
  38. package/src/builder/object-browser.ts +2 -2
  39. package/src/builder/querybuilder.ts +42 -36
  40. package/src/builder/quicksql.ts +16 -18
  41. package/src/builder/region-settings.ts +201 -199
  42. package/src/builder/report-settings.ts +64 -60
  43. package/src/builder/rest-modules.ts +145 -143
  44. package/src/builder/sampledata.ts +59 -52
  45. package/src/builder/scheduling.ts +180 -159
  46. package/src/builder/scripts.ts +71 -65
  47. package/src/builder/sql-charts.ts +36 -30
  48. package/src/builder/sql-monitor.ts +95 -89
  49. package/src/builder/sql-utilities.ts +47 -40
  50. package/src/builder/top-sql.ts +21 -17
  51. package/src/builder/unload.ts +48 -44
  52. package/src/builder/worksheet.ts +3 -3
  53. package/src/cron-schedule.ts +47 -17
  54. package/src/metadata.ts +3 -1
  55. package/src/runtime/calendar.ts +3 -2
  56. package/src/runtime/context.ts +2 -0
  57. package/src/runtime/grid.ts +1 -0
  58. package/src/runtime/maps.ts +7 -4
  59. package/src/runtime/render.ts +6 -1
  60. package/src/runtime/routes.ts +4 -0
  61. package/src/runtime/tree.ts +4 -3
@@ -18,6 +18,7 @@ import { MAX_EXTRA_LAYERS } from '../runtime/maps.ts';
18
18
  import { postgis } from '../runtime/spatial.ts';
19
19
  import { reporterSettingsForm } from './reporter.ts';
20
20
  import { formDesignerSettingsForm } from './form-designer.ts';
21
+ import { scopedTx as tx, tr, withTranslator } from './i18n.ts';
21
22
 
22
23
  // Page designer → a region → Settings: the region's "config" JSON as a form
23
24
  // for grid, chart, cards, calendar and faceted search regions (report
@@ -99,19 +100,19 @@ function columnsHint(cols: { columns: string[] } | { error: string }, expected?:
99
100
 
100
101
  function linkFieldset(
101
102
  id: (n: string) => string, link: { page?: number; items?: Record<string, string> } | undefined, pages: { page_no: number; name: string }[], what: string,
102
- { prefix = 'link', legend = 'Link', placeholder = 'P3_ID=#id#', help = "ITEM=#column#, comma separated; #column# is replaced by the row's value." } = {},
103
+ { prefix = 'link', legend = tx('rs.link'), placeholder = 'P3_ID=#id#', help = tx('rs.link_help') }: { prefix?: string; legend?: string; placeholder?: string; help?: string } = {},
103
104
  ) {
104
105
  return html`<fieldset class="prop-group"><legend>${legend}</legend><div class="form-grid">
105
- <div class="field"><label class="label" for="${id(`${prefix}_page`)}">${what} links to page</label>
106
- <select id="${id(`${prefix}_page`)}" name="${prefix}_page">${opt('', '- no link -', link?.page)}${pages.map((p) => opt(String(p.page_no), `${p.page_no}. ${p.name}`, link?.page))}</select></div>
107
- <div class="field" data-wide><label class="label" for="${id(`${prefix}_items`)}">Set items</label>
106
+ <div class="field"><label class="label" for="${id(`${prefix}_page`)}">${tx('rs.links_to_page', { what })}</label>
107
+ <select id="${id(`${prefix}_page`)}" name="${prefix}_page">${opt('', tx('rs.no_link'), link?.page)}${pages.map((p) => opt(String(p.page_no), `${p.page_no}. ${p.name}`, link?.page))}</select></div>
108
+ <div class="field" data-wide><label class="label" for="${id(`${prefix}_items`)}">${tx('rs.set_items')}</label>
108
109
  <input id="${id(`${prefix}_items`)}" name="${prefix}_items" value="${linkItemsText(link?.items)}" placeholder="${placeholder}">
109
110
  <small class="help">${help}</small></div>
110
111
  </div></fieldset>`;
111
112
  }
112
113
 
113
- const emptyField = (id: (n: string) => string, cfg: Config) => html`<div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
114
- <input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="No data found"></div>`;
114
+ const emptyField = (id: (n: string) => string, cfg: Config) => html`<div class="field" data-wide><label class="label" for="${id('empty')}">${tx('rs.text_when_there_are_no')}</label>
115
+ <input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="${tx('rs.no_data_found')}"></div>`;
115
116
 
116
117
  /** Columns in the settings that the query no longer returns stay listed (marked), so they can be cleared. */
117
118
  function withStale(names: string[], configured: string[]) {
@@ -119,7 +120,7 @@ function withStale(names: string[], configured: string[]) {
119
120
  return { all: [...names, ...[...new Set(configured)].filter((n) => !known.has(n))], known };
120
121
  }
121
122
 
122
- const staleTag = (known: Set<string>, n: string) => (known.has(n) ? '' : html` <span class="tag tag-error">not in the query</span>`);
123
+ const staleTag = (known: Set<string>, n: string) => (known.has(n) ? '' : html` <span class="tag tag-error">${tx('rs.not_in_query')}</span>`);
123
124
 
124
125
  // ---------------------------------------------------------------- merges (pure, unit tested)
125
126
 
@@ -408,13 +409,13 @@ function mergeStyle(b: Body, p: string): Config | undefined {
408
409
  }
409
410
  function styleFields(id: (n: string) => string, p: string, st: Config | undefined) {
410
411
  const s = (st ?? {}) as Config;
411
- return html`<div class="field"><label class="label" for="${id(`${p}_fill_color`)}">Fill colour (lines / polygons)</label>
412
+ return html`<div class="field"><label class="label" for="${id(`${p}_fill_color`)}">${tx('rs.fill_colour_lines_polygons')}</label>
412
413
  <input id="${id(`${p}_fill_color`)}" name="${p}_fill_color" value="${s.fill_color ?? ''}" placeholder="#2e5c8a" pattern="#[0-9a-fA-F]{6}"></div>
413
- <div class="field"><label class="label" for="${id(`${p}_stroke_color`)}">Line colour</label>
414
+ <div class="field"><label class="label" for="${id(`${p}_stroke_color`)}">${tx('rs.line_colour')}</label>
414
415
  <input id="${id(`${p}_stroke_color`)}" name="${p}_stroke_color" value="${s.stroke_color ?? ''}" placeholder="#1b2230" pattern="#[0-9a-fA-F]{6}"></div>
415
- <div class="field"><label class="label" for="${id(`${p}_stroke_width`)}">Line width</label>
416
+ <div class="field"><label class="label" for="${id(`${p}_stroke_width`)}">${tx('rs.line_width')}</label>
416
417
  <input id="${id(`${p}_stroke_width`)}" name="${p}_stroke_width" type="number" min="0" max="20" step="0.5" value="${s.stroke_width ?? ''}"></div>
417
- <div class="field"><label class="label" for="${id(`${p}_opacity`)}">Opacity (0–1)</label>
418
+ <div class="field"><label class="label" for="${id(`${p}_opacity`)}">${tx('rs.opacity_0_1')}</label>
418
419
  <input id="${id(`${p}_opacity`)}" name="${p}_opacity" type="number" min="0" max="1" step="0.05" value="${s.opacity ?? ''}"></div>`;
419
420
  }
420
421
 
@@ -509,67 +510,67 @@ async function gridFields(appId: number, r: RegionRow, id: (n: string) => string
509
510
  const lovSelect = (n: string, i: number) => {
510
511
  const cur = colCfg[n]?.lov as string | undefined;
511
512
  const custom = cur && !lovs.some((l) => `LOV:${l}` === cur.toUpperCase()) ? cur : null;
512
- return html`<select name="lov_${i}" aria-label="List of values of ${n}">${opt('', '- text field -', cur)}${lovs.map((l) => opt(`LOV:${l}`, l, cur?.toUpperCase()))}${custom ? opt(custom, `custom: ${custom.length > 40 ? custom.slice(0, 40) + '…' : custom}`, cur) : ''}</select>`;
513
+ return html`<select name="lov_${i}" aria-label="${tx('rs.aria_list_of_values_of', { name: n })}">${opt('', tx('rs.text_field'), cur)}${lovs.map((l) => opt(`LOV:${l}`, l, cur?.toUpperCase()))}${custom ? opt(custom, `custom: ${custom.length > 40 ? custom.slice(0, 40) + '…' : custom}`, cur) : ''}</select>`;
513
514
  };
514
515
  const rows = all.map((n, i) => html`<tr>
515
- <td data-label="Column"><code>${n}</code>${staleTag(known, n)}<input type="hidden" name="col_${i}" value="${n}"></td>
516
- <td data-label="Heading"><input name="heading_${i}" value="${cfg.headings?.[n] ?? ''}" placeholder="${heading(n)}" aria-label="Heading of ${n}"></td>
517
- <td data-label="Shown"><input type="checkbox" name="shown_${i}" value="true"${hidden.has(n.toLowerCase()) ? '' : raw(' checked')} aria-label="Show ${n}"></td>
518
- <td data-label="Read-only"><input type="checkbox" name="readonly_${i}" value="true"${readonly.has(n.toLowerCase()) ? raw(' checked') : ''} aria-label="${n} is read-only"></td>
519
- <td data-label="Required"><input type="checkbox" name="required_${i}" value="true"${colCfg[n]?.required ? raw(' checked') : ''} aria-label="${n} is required"></td>
520
- <td data-label="Edit as">${lovSelect(n, i)}</td>
516
+ <td data-label="${tx('rs.column')}"><code>${n}</code>${staleTag(known, n)}<input type="hidden" name="col_${i}" value="${n}"></td>
517
+ <td data-label="${tx('rs.heading')}"><input name="heading_${i}" value="${cfg.headings?.[n] ?? ''}" placeholder="${heading(n)}" aria-label="${tx('rs.aria_heading_of', { name: n })}"></td>
518
+ <td data-label="${tx('rs.shown')}"><input type="checkbox" name="shown_${i}" value="true"${hidden.has(n.toLowerCase()) ? '' : raw(' checked')} aria-label="${tx('rs.aria_show', { name: n })}"></td>
519
+ <td data-label="${tx('rs.read_only')}"><input type="checkbox" name="readonly_${i}" value="true"${readonly.has(n.toLowerCase()) ? raw(' checked') : ''} aria-label="${n} is read-only"></td>
520
+ <td data-label="${tx('rs.required')}"><input type="checkbox" name="required_${i}" value="true"${colCfg[n]?.required ? raw(' checked') : ''} aria-label="${n} is required"></td>
521
+ <td data-label="${tx('rs.edit_as')}">${lovSelect(n, i)}</td>
521
522
  </tr>`);
522
523
 
523
524
  return html`${'error' in cols ? columnsHint(cols) : ''}
524
525
  <input type="hidden" name="n" value="${all.length}">
525
- <fieldset class="prop-group"><legend>Behaviour</legend><div class="form-grid">
526
- <div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
526
+ <fieldset class="prop-group"><legend>${tx('rs.behaviour')}</legend><div class="form-grid">
527
+ <div class="field"><label class="label" for="${id('page_size')}">${tx('rs.rows_per_page')}</label>
527
528
  <select id="${id('page_size')}" name="page_size">${GRID_PAGE_SIZES.map((x) => opt(x, x, cfg.page_size ?? 25))}</select></div>
528
- ${check('allow_insert', 'Users may add rows', allow.insert !== false)}
529
- ${check('allow_update', 'Users may change rows', allow.update !== false)}
530
- ${check('allow_delete', 'Users may delete rows', allow.delete !== false)}
529
+ ${check('allow_insert', tx('rs.users_may_add_rows'), allow.insert !== false)}
530
+ ${check('allow_update', tx('rs.users_may_change_rows'), allow.update !== false)}
531
+ ${check('allow_delete', tx('rs.users_may_delete_rows'), allow.delete !== false)}
531
532
  </div>
532
- <small class="help">Saving needs a process of type "grid_dml" for this region; without one the grid is read-only.</small></fieldset>
533
+ <small class="help">${tx('rs.saving_needs_a_process_of')}</small></fieldset>
533
534
  <input type="hidden" name="grid_features" value="1">
534
- <fieldset class="prop-group"><legend>Totals and columns</legend><div class="form-grid">
535
- <div class="field" data-wide><label class="label" for="${id('aggregates')}">Aggregates in the footer</label>
535
+ <fieldset class="prop-group"><legend>${tx('rs.totals_and_columns')}</legend><div class="form-grid">
536
+ <div class="field" data-wide><label class="label" for="${id('aggregates')}">${tx('rs.aggregates_in_the_footer')}</label>
536
537
  <input id="${id('aggregates')}" name="aggregates" value="${aggregatesText(cfg.aggregates)}" placeholder="sal=sum,avg; empno=count">
537
- <small class="help">column=sum, avg, count, min or max; separate columns with ";". Computed over all rows of the search, not only the page. Users can add their own (Actions → Aggregate).</small></div>
538
- <div class="field"><label class="label" for="${id('frozen')}">Frozen columns</label>
538
+ <small class="help">${tx('rs.column_sum_avg_count_min')}</small></div>
539
+ <div class="field"><label class="label" for="${id('frozen')}">${tx('rs.frozen_columns')}</label>
539
540
  <select id="${id('frozen')}" name="frozen">${['0', '1', '2', '3', '4', '5'].map((x) => opt(x, x === '0' ? 'none' : `the first ${x}`, cfg.frozen ?? 0))}</select>
540
- <small class="help">Stay in view while the grid scrolls sideways. Users can change it, move, resize and hide columns; "layout" in the JSON sets the default order and widths.</small></div>
541
- ${check('actions', 'Actions menu (columns, aggregates, saved reports)', cfg.actions !== false)}
542
- ${check('saved_reports', 'Users may save grid reports', cfg.saved_reports !== false)}
543
- <div class="field"><label class="label" for="${id('public_reports')}">Public reports by</label>
544
- <select id="${id('public_reports')}" name="public_reports">${opt('', '- nobody -', cfg.public_reports)}${authz.map((n) => opt(n, n, cfg.public_reports))}</select></div>
541
+ <small class="help">${tx('rs.stay_in_view_while_the')}</small></div>
542
+ ${check('actions', tx('rs.actions_menu_columns_aggregates_saved'), cfg.actions !== false)}
543
+ ${check('saved_reports', tx('rs.users_may_save_grid_reports'), cfg.saved_reports !== false)}
544
+ <div class="field"><label class="label" for="${id('public_reports')}">${tx('rs.public_reports_by')}</label>
545
+ <select id="${id('public_reports')}" name="public_reports">${opt('', tx('rs.nobody'), cfg.public_reports)}${authz.map((n) => opt(n, n, cfg.public_reports))}</select></div>
545
546
  </div></fieldset>
546
547
  <input type="hidden" name="grid_views" value="1">
547
- <fieldset class="prop-group"><legend>Views</legend><div class="form-grid">
548
- ${GRID_USER_VIEWS.map((v) => check(`view_${v}`, GRID_VIEW_LABELS[v], !Array.isArray(cfg.views) || cfg.views.includes(v)))}
548
+ <fieldset class="prop-group"><legend>${tx('rs.views')}</legend><div class="form-grid">
549
+ ${GRID_USER_VIEWS.map((v) => check(`view_${v}`, tx.dyn(`rs.gridview.${v}`, GRID_VIEW_LABELS[v]), !Array.isArray(cfg.views) || cfg.views.includes(v)))}
549
550
  </div>
550
- <small class="help">Views users may switch to besides the grid. The single row view edits one row at a time (also from the row actions menu); group by and chart are set up in Actions.</small></fieldset>
551
- <fieldset class="prop-group"><legend>Row actions</legend><div class="form-grid">
552
- ${check('row_actions', 'Row actions menu', !!cfg.row_actions)}
553
- ${check('row_duplicate', 'Duplicate', cfg.row_actions?.duplicate !== false)}
554
- ${check('row_delete', 'Delete', cfg.row_actions?.delete !== false)}
551
+ <small class="help">${tx('rs.views_users_may_switch_to')}</small></fieldset>
552
+ <fieldset class="prop-group"><legend>${tx('rs.row_actions')}</legend><div class="form-grid">
553
+ ${check('row_actions', tx('rs.row_actions_menu'), !!cfg.row_actions)}
554
+ ${check('row_duplicate', tx('rs.duplicate'), cfg.row_actions?.duplicate !== false)}
555
+ ${check('row_delete', tx('rs.delete'), cfg.row_actions?.delete !== false)}
555
556
  </div></fieldset>
556
- ${linkFieldset(id, cfg.row_actions?.edit, pages, 'Edit', { prefix: 'edit', legend: 'Row actions: edit', placeholder: 'P3_ID=#id#' })}
557
- <fieldset class="prop-group"><legend>Master-detail</legend><div class="form-grid">
558
- <div class="field"><label class="label" for="${id('select_column')}">As a master: column of the selected row</label>
559
- <select id="${id('select_column')}" name="select_column">${opt('', '- not a master -', cfg.select_row?.column)}${all.map((n) => opt(n, n, cfg.select_row?.column))}</select></div>
560
- <div class="field"><label class="label" for="${id('select_item')}">… goes into item</label>
557
+ ${linkFieldset(id, cfg.row_actions?.edit, pages, tx('rs.w.edit'), { prefix: 'edit', legend: tx('rs.row_actions_edit'), placeholder: 'P3_ID=#id#' })}
558
+ <fieldset class="prop-group"><legend>${tx('rs.master_detail')}</legend><div class="form-grid">
559
+ <div class="field"><label class="label" for="${id('select_column')}">${tx('rs.as_a_master_column_of')}</label>
560
+ <select id="${id('select_column')}" name="select_column">${opt('', tx('rs.not_a_master'), cfg.select_row?.column)}${all.map((n) => opt(n, n, cfg.select_row?.column))}</select></div>
561
+ <div class="field"><label class="label" for="${id('select_item')}">${tx('rs.goes_into_item')}</label>
561
562
  <input id="${id('select_item')}" name="select_item" value="${cfg.select_row?.item ?? ''}" placeholder="P1_ID"></div>
562
- <div class="field"><label class="label" for="${id('master_item')}">As a detail: follows item</label>
563
+ <div class="field"><label class="label" for="${id('master_item')}">${tx('rs.as_a_detail_follows_item')}</label>
563
564
  <input id="${id('master_item')}" name="master_item" value="${cfg.master?.item ?? ''}" placeholder="P1_ID"></div>
564
- <div class="field"><label class="label" for="${id('master_column')}">… and new rows get it in column</label>
565
+ <div class="field"><label class="label" for="${id('master_column')}">${tx('rs.and_new_rows_get_it')}</label>
565
566
  <input id="${id('master_column')}" name="master_column" value="${cfg.master?.column ?? ''}" placeholder="dept_id"></div>
566
567
  </div>
567
- <small class="help">A master grid's selected row puts its value into a page item; detail grids and reports use it in their query (:P1_ID) and are refreshed without reloading the page. Other region types follow with {"master": {"item": "P1_ID"}}.</small></fieldset>
568
- <fieldset class="prop-group"><legend>Columns</legend>
568
+ <small class="help">${tx('rs.a_master_grid_s_selected')}</small></fieldset>
569
+ <fieldset class="prop-group"><legend>${tx('rs.columns')}</legend>
569
570
  ${all.length
570
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Heading</th><th>Shown</th><th>Read-only</th><th>Required</th><th>Edit as</th></tr></thead><tbody>${rows}</tbody></table></div>
571
- <small class="help">"Edit as" picks a shared list of values (Shared Components → Lists of values) for a select list.</small>`
572
- : html`<p class="muted">No columns yet.</p>`}
571
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rs.column')}</th><th>${tx('rs.heading')}</th><th>${tx('rs.shown')}</th><th>${tx('rs.read_only')}</th><th>${tx('rs.required')}</th><th>${tx('rs.edit_as')}</th></tr></thead><tbody>${rows}</tbody></table></div>
572
+ <small class="help">${tx('rs.edit_as_picks_a_shared')}</small>`
573
+ : html`<p class="muted">${tx('rs.no_columns_yet')}</p>`}
573
574
  </fieldset>`;
574
575
  }
575
576
 
@@ -577,9 +578,9 @@ async function gridFields(appId: number, r: RegionRow, id: (n: string) => string
577
578
  /** How a map layer's rows reach the browser: with the page, for the visible area, or as vector tiles. */
578
579
  function loadField(id: (n: string) => string, name: string, l: Config) {
579
580
  const load = l.tiles === true ? 'tiles' : l.visible_area === true ? 'area' : '';
580
- return html`<div class="field"><label class="label" for="${id(name)}">Load</label>
581
- <select id="${id(name)}" name="${name}">${opt('', 'All rows with the page', load)}${opt('area', 'Rows in the visible area', load)}${opt('tiles', 'Vector tiles (large data sets)', load)}</select>
582
- <small class="help">The visible area and vector tiles need <code>lat</code>/<code>lng</code> (or a PostGIS geometry) and load again as the map moves. Vector tiles draw dots, lines and areas, without clustering or a list.</small></div>`;
581
+ return html`<div class="field"><label class="label" for="${id(name)}">${tx('rs.load')}</label>
582
+ <select id="${id(name)}" name="${name}">${opt('', tx('rs.all_rows_with_the_page'), load)}${opt('area', tx('rs.rows_in_the_visible_area'), load)}${opt('tiles', tx('rs.vector_tiles_large_data_sets'), load)}</select>
583
+ <small class="help">${tx.code('rs.the_visible_area_and_vector')}</small></div>`;
583
584
  }
584
585
 
585
586
  async function mapLayersFieldsets(cfg: Config, appId: number, allPages: { page_no: number; name: string }[], id: (n: string) => string) {
@@ -589,23 +590,23 @@ async function mapLayersFieldsets(cfg: Config, appId: number, allPages: { page_n
589
590
  for (const [i, l] of shown.entries()) {
590
591
  const p = `layer${i}`;
591
592
  const hint = l.source ? columnsHint(await reportColumns(appId, l.source)) : '';
592
- parts.push(html`<fieldset class="prop-group"><legend>${l.source ? `Layer ${i + 2}: ${l.name ?? ''}` : 'Add a layer'}</legend>
593
+ parts.push(html`<fieldset class="prop-group"><legend>${l.source ? tx('rs.layer_n', { n: i + 2, name: l.name ?? '' }) : tx('rs.add_layer')}</legend>
593
594
  ${hint}
594
595
  <div class="form-grid">
595
- <div class="field"><label class="label" for="${id(`${p}_name`)}">Name (legend)</label>
596
+ <div class="field"><label class="label" for="${id(`${p}_name`)}">${tx('rs.name_legend')}</label>
596
597
  <input id="${id(`${p}_name`)}" name="${p}_name" value="${l.name ?? ''}" maxlength="60"></div>
597
- <div class="field"><label class="label" for="${id(`${p}_layer`)}">Show places as</label>
598
- <select id="${id(`${p}_layer`)}" name="${p}_layer">${opt('', 'Markers', l.layer)}${opt('heat', 'Heat map', l.layer)}${opt('lines', 'Lines', l.layer)}${opt('polygons', 'Polygons', l.layer)}${opt('extruded', 'Extruded polygons (shaded by height)', l.layer)}</select></div>
598
+ <div class="field"><label class="label" for="${id(`${p}_layer`)}">${tx('rs.show_places_as')}</label>
599
+ <select id="${id(`${p}_layer`)}" name="${p}_layer">${opt('', tx('rs.markers'), l.layer)}${opt('heat', tx('rs.heat_map'), l.layer)}${opt('lines', tx('rs.lines'), l.layer)}${opt('polygons', tx('rs.polygons'), l.layer)}${opt('extruded', tx('rs.extruded_polygons_shaded_by_height'), l.layer)}</select></div>
599
600
  ${styleFields(id, p, l.style)}
600
601
  ${loadField(id, `${p}_load`, l)}
601
- ${check(`${p}_cluster`, 'Group close markers', l.cluster === true)}
602
- ${check(`${p}_hidden`, 'Off at first', l.hidden === true)}
603
- <div class="field" data-wide><label class="label" for="${id(`${p}_source`)}">Query</label>
602
+ ${check(`${p}_cluster`, tx('rs.group_markers'), l.cluster === true)}
603
+ ${check(`${p}_hidden`, tx('rs.off_at_first'), l.hidden === true)}
604
+ <div class="field" data-wide><label class="label" for="${id(`${p}_source`)}">${tx('rs.query')}</label>
604
605
  <textarea id="${id(`${p}_source`)}" name="${p}_source" class="code" rows="5" spellcheck="false" data-code="sql" placeholder="select lat, lng, title from …">${l.source ?? ''}</textarea>
605
- <small class="help">The same columns as the map's own query. Empty the query to remove the layer.</small></div>
606
- <div class="field"><label class="label" for="${id(`${p}_link_page`)}">Each place links to page</label>
607
- <select id="${id(`${p}_link_page`)}" name="${p}_link_page">${opt('', '- no link -', l.link?.page)}${allPages.map((x) => opt(String(x.page_no), `${x.page_no}. ${x.name}`, l.link?.page))}</select></div>
608
- <div class="field"><label class="label" for="${id(`${p}_link_items`)}">Set items</label>
606
+ <small class="help">${tx('rs.the_same_columns_as_the')}</small></div>
607
+ <div class="field"><label class="label" for="${id(`${p}_link_page`)}">${tx('rs.each_place_links_to_page')}</label>
608
+ <select id="${id(`${p}_link_page`)}" name="${p}_link_page">${opt('', tx('rs.no_link'), l.link?.page)}${allPages.map((x) => opt(String(x.page_no), `${x.page_no}. ${x.name}`, l.link?.page))}</select></div>
609
+ <div class="field"><label class="label" for="${id(`${p}_link_items`)}">${tx('rs.set_items')}</label>
609
610
  <input id="${id(`${p}_link_items`)}" name="${p}_link_items" value="${linkItemsText(l.link?.items)}" placeholder="P3_ID=#id#"></div>
610
611
  </div></fieldset>`);
611
612
  }
@@ -620,15 +621,15 @@ async function mapReportFieldset(r: RegionRow, pageId: number, appId: number, id
620
621
  const cols = target ? await reportColumns(appId, await designSql(appId, target)) : null;
621
622
  // with PostGIS a geometry/geography column filters too (only the column names are known here)
622
623
  const noPosition = cols && 'columns' in cols && !positionColumns(cols.columns) && !(await postgis());
623
- return html`<fieldset class="prop-group"><legend>Filter a report</legend><div class="form-grid">
624
- <div class="field"><label class="label" for="${id('report')}">Report region</label>
625
- <select id="${id('report')}" name="report">${opt('', '- none -', target?.id)}${reports.map((x) => opt(String(x.id), `${x.title ?? '(untitled)'} (#${x.id})`, cfg.report))}</select>
626
- <small class="help">${reports.length ? 'Users can show only the rows in the map\'s visible area ("Show this area in the list"), or near its centre. The report needs lat and lng (or location) columns, or a PostGIS geometry/geography column.' : 'Add a report region to this page to filter it by the map area.'}</small></div>
627
- <div class="field"><label class="label" for="${id('filter')}">Filter by</label>
628
- <select id="${id('filter')}" name="filter">${opt('', 'The visible area', cfg.filter)}${opt('distance', 'Distance from the centre', cfg.filter)}</select>
629
- <small class="help">The condition runs on the server: with PostGIS installed on a geometry/geography column, else on latitude and longitude.</small></div>
624
+ return html`<fieldset class="prop-group"><legend>${tx('rs.filter_a_report')}</legend><div class="form-grid">
625
+ <div class="field"><label class="label" for="${id('report')}">${tx('rs.report_region')}</label>
626
+ <select id="${id('report')}" name="report">${opt('', tx('rs.none'), target?.id)}${reports.map((x) => opt(String(x.id), `${x.title ?? tx('rs.untitled')} (#${x.id})`, cfg.report))}</select>
627
+ <small class="help">${reports.length ? tx('rs.map_report_help') : tx('rs.map_report_none')}</small></div>
628
+ <div class="field"><label class="label" for="${id('filter')}">${tx('rs.filter_by')}</label>
629
+ <select id="${id('filter')}" name="filter">${opt('', tx('rs.the_visible_area'), cfg.filter)}${opt('distance', tx('rs.distance_from_the_centre'), cfg.filter)}</select>
630
+ <small class="help">${tx('rs.the_condition_runs_on_the')}</small></div>
630
631
  </div>
631
- ${noPosition ? html`<div class="alert alert-error" role="alert">The report has no <code>lat</code>/<code>lng</code> or <code>location</code> columns, so the map area can't filter it.</div>` : ''}
632
+ ${noPosition ? html`<div class="alert alert-error" role="alert">${tx.code('rs.no_position_alert')}</div>` : ''}
632
633
  </fieldset>`;
633
634
  }
634
635
 
@@ -651,38 +652,38 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
651
652
  const type = f?.type === 'range' || f?.type === 'star' ? f.type : 'checkbox';
652
653
  const custom = type === 'range' && (f?.custom === true || (f?.custom !== false && !(Array.isArray(f?.ranges) && f.ranges.length)));
653
654
  return html`<tr>
654
- <td data-label="Column"><code>${n}</code>${staleTag(known, n)}<input type="hidden" name="col_${i}" value="${n}"></td>
655
- <td data-label="Facet"><input type="checkbox" name="on_${i}" value="true"${f ? raw(' checked') : ''} aria-label="Facet on ${n}"></td>
656
- <td data-label="Label"><input name="label_${i}" value="${f?.label ?? ''}" placeholder="${heading(n)}" aria-label="Label of ${n}"></td>
657
- <td data-label="Type"><select name="type_${i}" aria-label="Type of the facet on ${n}">${Object.entries(FACET_TYPE_LABELS).map(([k, l]) => opt(k, l, type))}</select></td>
658
- <td data-label="Values shown"><input name="limit_${i}" type="number" min="1" max="50" value="${f?.limit ?? ''}" placeholder="${FACET_LIMIT}" aria-label="Values shown for ${n}" class="u-mw6"></td>
659
- <td data-label="Exclude"><input type="checkbox" name="exclude_${i}" value="true"${f?.exclude === true ? raw(' checked') : ''} aria-label="Users may exclude values of ${n}"></td>
660
- <td data-label="Ranges"><input name="ranges_${i}" value="${rangesText(f?.ranges)}" placeholder="..1000; 1000..3000 = Middle; 3000.." aria-label="Ranges of ${n}"></td>
661
- <td data-label="From/to"><input type="checkbox" name="custom_${i}" value="true"${custom ? raw(' checked') : ''} aria-label="Users may type a range for ${n}"></td>
662
- ${smart ? '' : html`<td data-label="Chart"><select name="chart_${i}" aria-label="Chart of the facet on ${n}">${opt('', 'None', f?.chart)}${opt('bar', 'Bar', f?.chart)}${opt('pie', 'Pie', f?.chart)}</select></td>`}
663
- <td data-label="Order"><input name="seq_${i}" type="number" value="${(i + 1) * 10}" aria-label="Order of ${n}" class="u-mw6"></td>
655
+ <td data-label="${tx('rs.column')}"><code>${n}</code>${staleTag(known, n)}<input type="hidden" name="col_${i}" value="${n}"></td>
656
+ <td data-label="${tx('rs.facet')}"><input type="checkbox" name="on_${i}" value="true"${f ? raw(' checked') : ''} aria-label="${tx('rs.aria_facet_on', { name: n })}"></td>
657
+ <td data-label="${tx('rs.label')}"><input name="label_${i}" value="${f?.label ?? ''}" placeholder="${heading(n)}" aria-label="${tx('rs.aria_label_of', { name: n })}"></td>
658
+ <td data-label="${tx('rs.type')}"><select name="type_${i}" aria-label="${tx('rs.aria_type_of_the_facet_on', { name: n })}">${Object.entries(FACET_TYPE_LABELS).map(([k, l]) => opt(k, tx.dyn(`rs.facet.${k}`, l), type))}</select></td>
659
+ <td data-label="${tx('rs.values_shown')}"><input name="limit_${i}" type="number" min="1" max="50" value="${f?.limit ?? ''}" placeholder="${FACET_LIMIT}" aria-label="${tx('rs.aria_values_shown_for', { name: n })}" class="u-mw6"></td>
660
+ <td data-label="${tx('rs.exclude')}"><input type="checkbox" name="exclude_${i}" value="true"${f?.exclude === true ? raw(' checked') : ''} aria-label="${tx('rs.aria_users_may_exclude_values_of', { name: n })}"></td>
661
+ <td data-label="${tx('rs.ranges')}"><input name="ranges_${i}" value="${rangesText(f?.ranges)}" placeholder="..1000; 1000..3000 = Middle; 3000.." aria-label="${tx('rs.aria_ranges_of', { name: n })}"></td>
662
+ <td data-label="${tx('rs.from_to')}"><input type="checkbox" name="custom_${i}" value="true"${custom ? raw(' checked') : ''} aria-label="${tx('rs.aria_users_may_type_a_range', { name: n })}"></td>
663
+ ${smart ? '' : html`<td data-label="${tx('rs.chart')}"><select name="chart_${i}" aria-label="${tx('rs.aria_chart_of_the_facet_on', { name: n })}">${opt('', tx('rs.none_2'), f?.chart)}${opt('bar', tx('rs.bar'), f?.chart)}${opt('pie', tx('rs.pie'), f?.chart)}</select></td>`}
664
+ <td data-label="${tx('rs.order')}"><input name="seq_${i}" type="number" value="${(i + 1) * 10}" aria-label="${tx('rs.aria_order_of', { name: n })}" class="u-mw6"></td>
664
665
  </tr>`;
665
666
  });
666
667
  return html`<input type="hidden" name="n" value="${all.length}">
667
- <fieldset class="prop-group"><legend>Filters</legend><div class="form-grid">
668
- <div class="field"><label class="label" for="${id('report')}">Report region</label>
669
- <select id="${id('report')}" name="report">${opt('', '- choose -', target?.id)}${reports.map((x) => opt(String(x.id), `${x.title ?? '(untitled)'} (#${x.id})`, cfg.report))}</select>
670
- <small class="help">${reports.length ? `The report on this page that the ${smart ? 'smart filters' : 'facets'} filter. Save to list its columns.` : 'Add a report region to this page first.'}</small></div>
668
+ <fieldset class="prop-group"><legend>${tx('rs.filters')}</legend><div class="form-grid">
669
+ <div class="field"><label class="label" for="${id('report')}">${tx('rs.report_region')}</label>
670
+ <select id="${id('report')}" name="report">${opt('', tx('rs.choose'), target?.id)}${reports.map((x) => opt(String(x.id), `${x.title ?? tx('rs.untitled')} (#${x.id})`, cfg.report))}</select>
671
+ <small class="help">${reports.length ? tx(smart ? 'rs.report_smart_help' : 'rs.report_facets_help') : tx('rs.report_first')}</small></div>
671
672
  ${smart
672
- ? html`<div class="field"><label class="label" for="${id('suggestions')}">Suggestions per facet (0–10)</label>
673
+ ? html`<div class="field"><label class="label" for="${id('suggestions')}">${tx('rs.suggestions_per_facet_0_10')}</label>
673
674
  <input id="${id('suggestions')}" name="suggestions" type="number" min="0" max="10" value="${cfg.suggestions ?? 3}"></div>
674
- <div class="field" data-wide><label class="label" for="${id('placeholder')}">Placeholder of the search field</label>
675
- <input id="${id('placeholder')}" name="placeholder" maxlength="100" value="${cfg.placeholder ?? ''}" placeholder="Search or filter…"></div>
676
- ${check('ask', 'Read the search as a phrase ("clerks with salary between 1000 and 3000") and offer it as filters', cfg.ask === true)}`
677
- : html`${check('search', 'A search field (searches all columns of the report)', cfg.search === true)}`}
675
+ <div class="field" data-wide><label class="label" for="${id('placeholder')}">${tx('rs.placeholder_of_the_search_field')}</label>
676
+ <input id="${id('placeholder')}" name="placeholder" maxlength="100" value="${cfg.placeholder ?? ''}" placeholder="${tx('rs.search_or_filter')}"></div>
677
+ ${check('ask', tx('rs.read_the_search_as_a'), cfg.ask === true)}`
678
+ : html`${check('search', tx('rs.a_search_field_searches_all'), cfg.search === true)}`}
678
679
  </div></fieldset>
679
680
  ${cols && 'error' in cols ? columnsHint(cols) : ''}
680
681
  ${target
681
- ? html`<fieldset class="prop-group"><legend>Facets</legend>
682
+ ? html`<fieldset class="prop-group"><legend>${tx('rs.facets')}</legend>
682
683
  ${all.length
683
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Facet</th><th>Label</th><th>Type</th><th>Values shown</th><th>Exclude</th><th>Ranges</th><th>From/to</th>${smart ? '' : html`<th>Chart</th>`}<th>Order</th></tr></thead><tbody>${rows}</tbody></table></div>
684
- <small class="help">Checkboxes list the most frequent values (Exclude lets users filter them out instead). Ranges and From/to need a number or date column: ranges are <code>from..to = label</code> separated by <code>;</code>, an open end left empty (<code>..1000</code>, <code>2020-01-01..</code>); a range includes its start, not its end. A star rating offers "4 stars and up" and so on (up to 5; <code>"max"</code> in the JSON changes it).${smart ? '' : ' Chart: a bar or pie chart of the counts under the facet (collapsed); a bar or slice filters on its value.'}</small>`
685
- : html`<p class="muted">The report has no columns.</p>`}
684
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rs.column')}</th><th>${tx('rs.facet')}</th><th>${tx('rs.label')}</th><th>${tx('rs.type')}</th><th>${tx('rs.values_shown')}</th><th>${tx('rs.exclude')}</th><th>${tx('rs.ranges')}</th><th>${tx('rs.from_to')}</th>${smart ? '' : html`<th>${tx('rs.chart')}</th>`}<th>${tx('rs.order')}</th></tr></thead><tbody>${rows}</tbody></table></div>
685
+ <small class="help">${tx.code('rs.facets_help')}${smart ? '' : html` ${tx('rs.facets_help_chart')}`}</small>`
686
+ : html`<p class="muted">${tx('rs.the_report_has_no_columns')}</p>`}
686
687
  </fieldset>`
687
688
  : ''}`;
688
689
  }
@@ -696,33 +697,33 @@ async function calendarFields(appId: number, r: RegionRow, id: (n: string) => st
696
697
  const views = calendarViews(cfg);
697
698
  const schemes = (await owner.query('select name from meta.authz_scheme where app_id = $1 order by name', [appId])).rows.map((x) => x.name as string);
698
699
  return html`${columnsHint(await reportColumns(appId, await designSql(appId, r)), ['start_date', 'title'])}
699
- <p class="muted u-mt0">The query returns <code>start_date</code>, <code>title</code> and optionally <code>end_date</code> (dates for all-day events, timestamps for events with a time).</p>
700
- <fieldset class="prop-group"><legend>Views</legend>
700
+ <p class="muted u-mt0">${tx.code('rs.calendar_help')}</p>
701
+ <fieldset class="prop-group"><legend>${tx('rs.views')}</legend>
701
702
  <div class="form-grid">
702
- ${CALENDAR_VIEWS.map((v) => check(`view_${v}`, VIEW_LABELS[v], views.includes(v)))}
703
- <div class="field"><label class="label" for="${id('view')}">Shown first</label>
704
- <select id="${id('view')}" name="view">${CALENDAR_VIEWS.map((v) => opt(v, VIEW_LABELS[v], cfg.view ?? views[0]))}</select></div>
705
- <div class="field"><label class="label" for="${id('day_start')}">Week and day views from hour</label>
703
+ ${CALENDAR_VIEWS.map((v) => check(`view_${v}`, tx.dyn(`rs.view.${v}`, VIEW_LABELS[v]), views.includes(v)))}
704
+ <div class="field"><label class="label" for="${id('view')}">${tx('rs.shown_first')}</label>
705
+ <select id="${id('view')}" name="view">${CALENDAR_VIEWS.map((v) => opt(v, tx.dyn(`rs.view.${v}`, VIEW_LABELS[v]), cfg.view ?? views[0]))}</select></div>
706
+ <div class="field"><label class="label" for="${id('day_start')}">${tx('rs.week_and_day_views_from')}</label>
706
707
  <input id="${id('day_start')}" name="day_start" type="number" min="0" max="23" value="${cfg.day_start ?? ''}" placeholder="8"></div>
707
- <div class="field"><label class="label" for="${id('day_end')}">Until hour</label>
708
+ <div class="field"><label class="label" for="${id('day_end')}">${tx('rs.until_hour')}</label>
708
709
  <input id="${id('day_end')}" name="day_end" type="number" min="1" max="24" value="${cfg.day_end ?? ''}" placeholder="18"></div>
709
710
  </div>
710
- <small class="help">Users switch between the ticked views. Hours outside the range are added when an event needs them.</small></fieldset>
711
- ${linkFieldset(id, cfg.link, pages, 'Each event', { legend: 'Edit link' })}
712
- ${linkFieldset(id, cfg.create, pages, 'An empty day or hour slot', {
713
- prefix: 'create', legend: 'Create on click', placeholder: 'P3_START=#start#,P3_END=#end#',
714
- help: 'ITEM=#start#, #end# or #date#: the slot clicked (YYYY-MM-DD, or YYYY-MM-DD HH:MI for an hour slot).',
711
+ <small class="help">${tx('rs.users_switch_between_the_ticked')}</small></fieldset>
712
+ ${linkFieldset(id, cfg.link, pages, tx('rs.w.event'), { legend: tx('rs.edit_link') })}
713
+ ${linkFieldset(id, cfg.create, pages, tx('rs.w.slot'), {
714
+ prefix: 'create', legend: tx('rs.create_on_click'), placeholder: 'P3_START=#start#,P3_END=#end#',
715
+ help: tx('rs.create_help'),
715
716
  })}
716
- <fieldset class="prop-group"><legend>Drag and drop</legend><div class="form-grid">
717
- <div class="field" data-wide><label class="label" for="${id('move')}">SQL that moves an event</label>
717
+ <fieldset class="prop-group"><legend>${tx('rs.drag_and_drop')}</legend><div class="form-grid">
718
+ <div class="field" data-wide><label class="label" for="${id('move')}">${tx('rs.sql_that_moves_an_event')}</label>
718
719
  <textarea id="${id('move')}" name="move" rows="3" data-code="sql" placeholder="update my_schema.event set starts_at = :NEW_START::timestamp, ends_at = :NEW_END::timestamp where id = :EVENT_ID::int">${cfg.move ?? ''}</textarea>
719
- <small class="help">Runs as the application's database role (row level security applies) with <code>:EVENT_ID</code> (the key column's value), <code>:NEW_START</code> and <code>:NEW_END</code> (same duration as before). Empty: events can't be dragged.</small></div>
720
- <div class="field"><label class="label" for="${id('key')}">Key column</label>
720
+ <small class="help">${tx.code('rs.runs_as_the_application_s')}</small></div>
721
+ <div class="field"><label class="label" for="${id('key')}">${tx('rs.key_column')}</label>
721
722
  <input id="${id('key')}" name="key" value="${cfg.key ?? ''}" placeholder="id"></div>
722
- <div class="field"><label class="label" for="${id('move_authz')}">Who may drag</label>
723
- <select id="${id('move_authz')}" name="move_authz">${opt('', 'Everyone who sees the calendar', cfg.move_authz)}${opt('MUST_NOT_BE_PUBLIC_USER', 'Signed-in users', cfg.move_authz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.move_authz))}</select></div>
723
+ <div class="field"><label class="label" for="${id('move_authz')}">${tx('rs.who_may_drag')}</label>
724
+ <select id="${id('move_authz')}" name="move_authz">${opt('', tx('rs.everyone_who_sees_the_calendar'), cfg.move_authz)}${opt('MUST_NOT_BE_PUBLIC_USER', tx('rs.signed_in_users'), cfg.move_authz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, cfg.move_authz))}</select></div>
724
725
  </div>
725
- <small class="help">Only events the user sees in this calendar can be moved; without a mouse, the edit link changes the dates.</small></fieldset>`;
726
+ <small class="help">${tx('rs.only_events_the_user_sees')}</small></fieldset>`;
726
727
  }
727
728
 
728
729
  /** The display selector's settings: style and options, and which regions of the page take part. */
@@ -730,31 +731,34 @@ async function displaySelectorFields(r: RegionRow, pageId: number, id: (n: strin
730
731
  const cfg = r.config ?? {};
731
732
  const others = (await owner.query(`select id, title, type, config->'display_selector' as flag from meta.region
732
733
  where page_id = $1 and id <> $2 and type <> 'display_selector' order by seq, id`, [pageId, r.id])).rows;
733
- return html`<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
734
- <div class="field"><label class="label" for="${id('style')}">Show as</label>
735
- <select id="${id('style')}" name="style">${opt('', 'Tabs', cfg.style)}${opt('select', 'Select list', cfg.style)}${opt('carousel', 'Carousel (one at a time, Previous and Next)', cfg.style)}</select></div>
736
- <div class="field"><label class="label" for="${id('interval')}">Carousel: next region every (seconds)</label>
734
+ return html`<fieldset class="prop-group"><legend>${tx('rs.appearance')}</legend><div class="form-grid">
735
+ <div class="field"><label class="label" for="${id('style')}">${tx('rs.show_as')}</label>
736
+ <select id="${id('style')}" name="style">${opt('', tx('rs.tabs'), cfg.style)}${opt('select', tx('rs.select_list'), cfg.style)}${opt('carousel', tx('rs.carousel_one_at_a_time'), cfg.style)}</select></div>
737
+ <div class="field"><label class="label" for="${id('interval')}">${tx('rs.carousel_next_region_every_seconds')}</label>
737
738
  <input id="${id('interval')}" name="interval" type="number" min="1" max="120" value="${cfg.interval ?? ''}" placeholder="empty: only by hand">
738
- <small class="help">Not while the pointer or the focus is in the carousel, nor for users who prefer reduced motion. A carousel has no "Show all".</small></div>
739
+ <small class="help">${tx('rs.not_while_the_pointer_or')}</small></div>
739
740
  </div>
740
- ${check('show_all', '"Show all" choice (shows every region)', cfg.show_all !== false)}
741
- ${check('remember', 'Remember the choice during the session', cfg.remember !== false)}</fieldset>
742
- <fieldset class="prop-group"><legend>Regions</legend>
741
+ ${check('show_all', tx('rs.show_all_choice_shows_every'), cfg.show_all !== false)}
742
+ ${check('remember', tx('rs.remember_the_choice_during_the'), cfg.remember !== false)}</fieldset>
743
+ <fieldset class="prop-group"><legend>${tx('rs.regions')}</legend>
743
744
  <input type="hidden" name="members" value="1">
744
745
  ${others.length
745
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Region</th><th>In a tab</th><th>Tab name</th></tr></thead><tbody>
746
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rs.region')}</th><th>${tx('rs.in_a_tab')}</th><th>${tx('rs.tab_name')}</th></tr></thead><tbody>
746
747
  ${others.map((x) => html`<tr>
747
- <td data-label="Region">${x.title || '(untitled)'} <span class="muted">(${x.type} #${x.id})</span></td>
748
- <td data-label="In a tab"><input type="checkbox" name="member_${x.id}" value="true"${x.flag === true || (typeof x.flag === 'string' && x.flag.trim()) ? raw(' checked') : ''} aria-label="Show ${x.title || `region ${x.id}`} in the display selector"></td>
749
- <td data-label="Tab name"><input name="tab_${x.id}" maxlength="60" value="${typeof x.flag === 'string' ? x.flag : ''}" placeholder="${x.title || ''}" aria-label="Tab name of ${x.title || `region ${x.id}`}"></td>
748
+ <td data-label="${tx('rs.region')}">${x.title || '(untitled)'} <span class="muted">(${x.type} #${x.id})</span></td>
749
+ <td data-label="${tx('rs.in_a_tab')}"><input type="checkbox" name="member_${x.id}" value="true"${x.flag === true || (typeof x.flag === 'string' && x.flag.trim()) ? raw(' checked') : ''} aria-label="${tx('rs.aria_show_in_the_display_selector', { name: x.title || `region ${x.id}` })}"></td>
750
+ <td data-label="${tx('rs.tab_name')}"><input name="tab_${x.id}" maxlength="60" value="${typeof x.flag === 'string' ? x.flag : ''}" placeholder="${x.title || ''}" aria-label="${tx('rs.aria_tab_name_of', { name: x.title || `region ${x.id}` })}"></td>
750
751
  </tr>`)}</tbody></table></div>`
751
- : html`<p class="muted">There are no other regions on this page.</p>`}
752
- <small class="help">A chosen region gets <code>"display_selector": true</code> in its settings (a tab named after it), or the tab name: regions with the same tab name share one tab (e.g. smart filters and their report). Without JavaScript every region shows, with links to each.</small>
752
+ : html`<p class="muted">${tx('rs.there_are_no_other_regions')}</p>`}
753
+ <small class="help">${tx.code('rs.a_chosen_region_gets_display')}</small>
753
754
  </fieldset>`;
754
755
  }
755
756
 
756
757
  /** The settings form under a region in the page designer, or '' for types without one. */
757
- export async function regionSettingsForm(pageId: number, appId: number, r: RegionRow, s: Session): Promise<Raw | ''> {
758
+ /** (sprint 162) in the developer's builder language: the helpers below use the request's scoped translator */
759
+ export const regionSettingsForm = (pageId: number, appId: number, r: RegionRow, s: Session) => withTranslator(tr(s), () => regionSettingsFormInner(pageId, appId, r, s));
760
+
761
+ async function regionSettingsFormInner(pageId: number, appId: number, r: RegionRow, s: Session): Promise<Raw | ''> {
758
762
  if (r.type === 'report') return html`${await reportSettingsForm(pageId, appId, r, s)}${await columnTemplatesForm(pageId, appId, r, s)}${await aiFilterSettingsForm(pageId, appId, r, s)}`;
759
763
  if (r.type === 'ai_assistant') return assistantSettingsForm(pageId, appId, r, s);
760
764
  if (r.type === 'template_component') return templateRegionForm(pageId, appId, r, s);
@@ -766,137 +770,135 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
766
770
  const pages = async () => (await owner.query('select page_no, name from meta.page where app_id = $1 order by page_no', [appId])).rows;
767
771
  const schemes = async () => (await owner.query('select name from meta.authz_scheme where app_id = $1 order by name', [appId])).rows.map((x) => x.name as string);
768
772
  let body: Raw;
769
- let title = 'Settings';
773
+ let title = tx('rs.t.settings');
770
774
  switch (r.type as SettingsType) {
771
775
  case 'grid':
772
- title = 'Grid settings';
776
+ title = tx('rs.t.grid');
773
777
  body = await gridFields(appId, r, id);
774
778
  break;
775
779
  case 'chart':
776
- title = 'Chart settings';
780
+ title = tx('rs.t.chart');
777
781
  body = html`${columnsHint(await reportColumns(appId, await designSql(appId, r)))}
778
- <p class="muted u-mt0">The first column is the label; each following numeric column is a series (up to 8).
779
- A Gantt chart reads <code>label, start, end</code> (dates or timestamps; an empty end is a milestone) and optional columns named
780
- <code>progress</code> (0 to 100), <code>task_id</code> and <code>depends_on</code> (the ids a task waits for, e.g. <code>3,4</code>).</p>
781
- <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
782
- <div class="field"><label class="label" for="${id('kind')}">Chart type</label>
783
- <select id="${id('kind')}" name="kind">${CHART_KINDS.map((k) => opt(k, CHART_LABELS[k], cfg.kind ?? 'bar'))}</select></div>
782
+ <p class="muted u-mt0">${tx.code('rs.chart_help')}</p>
783
+ <fieldset class="prop-group"><legend>${tx('rs.appearance')}</legend><div class="form-grid">
784
+ <div class="field"><label class="label" for="${id('kind')}">${tx('rs.chart_type')}</label>
785
+ <select id="${id('kind')}" name="kind">${CHART_KINDS.map((k) => opt(k, tx.dyn(`rs.chart.${k}`, CHART_LABELS[k]), cfg.kind ?? 'bar'))}</select></div>
784
786
  ${emptyField(id, cfg)}
785
787
  </div></fieldset>
786
- <fieldset class="prop-group"><legend>Gauge</legend><div class="form-grid">
787
- ${GAUGE_KEYS.map((k) => html`<div class="field"><label class="label" for="${id(`gauge_${k}`)}">${{ min: 'Minimum', max: 'Maximum', warning: 'Warning from', critical: 'Critical from' }[k]}</label>
788
+ <fieldset class="prop-group"><legend>${tx('rs.gauge')}</legend><div class="form-grid">
789
+ ${GAUGE_KEYS.map((k) => html`<div class="field"><label class="label" for="${id(`gauge_${k}`)}">${tx(`rs.gauge.${k}`)}</label>
788
790
  <input id="${id(`gauge_${k}`)}" name="gauge_${k}" type="number" step="any" value="${cfg.gauge?.[k] ?? ''}" placeholder="${k === 'min' ? '0' : k === 'max' ? 'automatic' : ''}"></div>`)}
789
791
  </div>
790
- <small class="help">Gauge charts only. A warning threshold above the critical one means low values are bad (e.g. warning 50, critical 20).</small></fieldset>
791
- ${linkFieldset(id, cfg.link, await pages(), 'Each data point')}
792
- <small class="help">Drill-down: <code>#column#</code> takes the value from the point's row, <code>#series#</code> the name of its series. Columns only the link refers to are not drawn.</small>
793
- <fieldset class="prop-group"><legend>Gantt: drag and drop</legend><div class="form-grid">
794
- <div class="field" data-wide><label class="label" for="${id('gantt_move')}">SQL that moves a task</label>
792
+ <small class="help">${tx('rs.gauge_charts_only_a_warning')}</small></fieldset>
793
+ ${linkFieldset(id, cfg.link, await pages(), tx('rs.w.point'))}
794
+ <small class="help">${tx.code('rs.drill_down_column_takes_the')}</small>
795
+ <fieldset class="prop-group"><legend>${tx('rs.gantt_drag_and_drop')}</legend><div class="form-grid">
796
+ <div class="field" data-wide><label class="label" for="${id('gantt_move')}">${tx('rs.sql_that_moves_a_task')}</label>
795
797
  <textarea id="${id('gantt_move')}" name="gantt_move" rows="3" data-code="sql" placeholder="update my_schema.task set starts = :NEW_START::date, ends = :NEW_END::date where id = :TASK_ID::int">${cfg.gantt_move ?? ''}</textarea>
796
- <small class="help">Gantt charts with a <code>task_id</code> column. Runs as the application's database role (row level security applies) with <code>:TASK_ID</code>, <code>:NEW_START</code> and <code>:NEW_END</code> (<code>YYYY-MM-DD</code>, or <code>YYYY-MM-DDTHH:MM</code> by the hour; a milestone gets the same start and end). Empty: tasks can't be dragged.</small></div>
797
- <div class="field"><label class="label" for="${id('gantt_move_authz')}">Who may drag</label>
798
- <select id="${id('gantt_move_authz')}" name="gantt_move_authz">${opt('', 'Everyone who sees the chart', cfg.gantt_move_authz)}${opt('MUST_NOT_BE_PUBLIC_USER', 'Signed-in users', cfg.gantt_move_authz)}${(await schemes()).map((n) => opt(n, `Authorization: ${n}`, cfg.gantt_move_authz))}</select></div>
799
- <div class="field"><label class="label" for="${id('gantt_snap')}">Steps</label>
800
- <select id="${id('gantt_snap')}" name="gantt_snap">${opt('', 'Days, or hours when a task has a time', cfg.gantt_snap)}${opt('day', 'Days', cfg.gantt_snap)}${opt('hour', 'Hours', cfg.gantt_snap)}</select></div>
801
- </div><small class="help">Only tasks the user sees in this chart can be moved; the chart is drawn again from its query afterwards.</small></fieldset>`;
798
+ <small class="help">${tx.code('rs.gantt_charts_with_a_task')}</small></div>
799
+ <div class="field"><label class="label" for="${id('gantt_move_authz')}">${tx('rs.who_may_drag')}</label>
800
+ <select id="${id('gantt_move_authz')}" name="gantt_move_authz">${opt('', tx('rs.everyone_who_sees_the_chart'), cfg.gantt_move_authz)}${opt('MUST_NOT_BE_PUBLIC_USER', tx('rs.signed_in_users'), cfg.gantt_move_authz)}${(await schemes()).map((n) => opt(n, `Authorization: ${n}`, cfg.gantt_move_authz))}</select></div>
801
+ <div class="field"><label class="label" for="${id('gantt_snap')}">${tx('rs.steps')}</label>
802
+ <select id="${id('gantt_snap')}" name="gantt_snap">${opt('', tx('rs.days_or_hours_when_a'), cfg.gantt_snap)}${opt('day', tx('rs.days'), cfg.gantt_snap)}${opt('hour', tx('rs.hours'), cfg.gantt_snap)}</select></div>
803
+ </div><small class="help">${tx('rs.only_tasks_the_user_sees')}</small></fieldset>`;
802
804
  break;
803
805
  case 'cards':
804
- title = 'Cards settings';
806
+ title = tx('rs.t.cards');
805
807
  body = html`${columnsHint(await reportColumns(appId, await designSql(appId, r)))}
806
808
  <p class="muted u-mt0">Cards show the columns ${CARD_COLUMNS.map((c, i) => html`${i ? ', ' : ''}<code>${c}</code>`)}; KPI tiles show title, badge (the value) and icon.</p>
807
- <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
808
- <div class="field"><label class="label" for="${id('style')}">Style</label>
809
- <select id="${id('style')}" name="style">${opt('', 'Cards', cfg.style)}${opt('metric', 'KPI tiles (metric)', cfg.style)}</select></div>
809
+ <fieldset class="prop-group"><legend>${tx('rs.appearance')}</legend><div class="form-grid">
810
+ <div class="field"><label class="label" for="${id('style')}">${tx('rs.style')}</label>
811
+ <select id="${id('style')}" name="style">${opt('', tx('rs.cards'), cfg.style)}${opt('metric', tx('rs.kpi_tiles_metric'), cfg.style)}</select></div>
810
812
  ${emptyField(id, cfg)}
811
813
  </div></fieldset>
812
- ${linkFieldset(id, cfg.link, await pages(), 'Each card')}`;
814
+ ${linkFieldset(id, cfg.link, await pages(), tx('rs.w.card'))}`;
813
815
  break;
814
816
  case 'calendar':
815
- title = 'Calendar settings';
817
+ title = tx('rs.t.calendar');
816
818
  body = await calendarFields(appId, r, id, await pages());
817
819
  break;
818
820
  case 'facets':
819
- title = 'Faceted search settings';
821
+ title = tx('rs.t.facets');
820
822
  body = await facetsFields(r, pageId, appId, id, false);
821
823
  break;
822
824
  case 'smart_filters':
823
- title = 'Smart filters settings';
825
+ title = tx('rs.t.smart');
824
826
  body = await facetsFields(r, pageId, appId, id, true);
825
827
  break;
826
828
  case 'display_selector':
827
- title = 'Display selector settings';
829
+ title = tx('rs.t.selector');
828
830
  body = await displaySelectorFields(r, pageId, id);
829
831
  break;
830
832
  case 'list': {
831
- title = 'List settings';
833
+ title = tx('rs.t.list');
832
834
  const lists = (await owner.query('select name, type from meta.list where app_id = $1 order by name', [appId])).rows;
833
- body = html`<p class="muted u-mt0">Shows a list from Shared Components → Lists. Entries the user may not open are left out.</p>
834
- <fieldset class="prop-group"><legend>List</legend><div class="form-grid">
835
- <div class="field"><label class="label" for="${id('list')}">List</label>
836
- <select id="${id('list')}" name="list">${opt('', '- choose -', cfg.list)}${lists.map((l) => opt(l.name, `${l.name} (${l.type})`, cfg.list))}${cfg.list && !lists.some((l) => l.name === cfg.list) ? opt(cfg.list, `${cfg.list} (missing!)`, cfg.list) : ''}</select></div>
837
- <div class="field"><label class="label" for="${id('template')}">Template</label>
838
- <select id="${id('template')}" name="template">${opt('', 'Links (nested)', cfg.template)}${opt('badges', 'Badge list', cfg.template)}${opt('cards', 'Cards (menu)', cfg.template)}${opt('tabs', 'Tabs', cfg.template)}${opt('wizard_progress', 'Wizard progress (numbered steps)', cfg.template)}</select></div>
835
+ body = html`<p class="muted u-mt0">${tx('rs.shows_a_list_from_shared')}</p>
836
+ <fieldset class="prop-group"><legend>${tx('rs.list')}</legend><div class="form-grid">
837
+ <div class="field"><label class="label" for="${id('list')}">${tx('rs.list')}</label>
838
+ <select id="${id('list')}" name="list">${opt('', tx('rs.choose'), cfg.list)}${lists.map((l) => opt(l.name, `${l.name} (${l.type})`, cfg.list))}${cfg.list && !lists.some((l) => l.name === cfg.list) ? opt(cfg.list, `${cfg.list} (missing!)`, cfg.list) : ''}</select></div>
839
+ <div class="field"><label class="label" for="${id('template')}">${tx('rs.template')}</label>
840
+ <select id="${id('template')}" name="template">${opt('', tx('rs.links_nested'), cfg.template)}${opt('badges', tx('rs.badge_list'), cfg.template)}${opt('cards', tx('rs.cards_menu'), cfg.template)}${opt('tabs', tx('rs.tabs'), cfg.template)}${opt('wizard_progress', tx('rs.wizard_progress_numbered_steps'), cfg.template)}</select></div>
839
841
  </div></fieldset>`;
840
842
  break;
841
843
  }
842
844
  case 'map': {
843
- title = 'Map settings';
845
+ title = tx('rs.t.map');
844
846
  const allPages = await pages();
845
847
  body = html`${columnsHint(await reportColumns(appId, await designSql(appId, r)))}
846
- <p class="muted u-mt0">Each row is a marker at <code>lat</code>, <code>lng</code> (or <code>location</code> as "lat,lng"), with <code>title</code> and <code>body</code> in its popup; a <code>geojson</code> column draws lines and areas (with PostGIS installed, a <code>geometry</code> or <code>geography</code> column in WGS 84 works too). A heat map weighs each place by a <code>weight</code> column.</p>
847
- <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
848
- <div class="field"><label class="label" for="${id('layer')}">Show places as</label>
849
- <select id="${id('layer')}" name="layer">${opt('', 'Markers', cfg.layer)}${opt('heat', 'Heat map', cfg.layer)}${opt('lines', 'Lines', cfg.layer)}${opt('polygons', 'Polygons', cfg.layer)}${opt('extruded', 'Extruded polygons (shaded by height)', cfg.layer)}</select></div>
848
+ <p class="muted u-mt0">${tx.code('rs.map_help')}</p>
849
+ <fieldset class="prop-group"><legend>${tx('rs.appearance')}</legend><div class="form-grid">
850
+ <div class="field"><label class="label" for="${id('layer')}">${tx('rs.show_places_as')}</label>
851
+ <select id="${id('layer')}" name="layer">${opt('', tx('rs.markers'), cfg.layer)}${opt('heat', tx('rs.heat_map'), cfg.layer)}${opt('lines', tx('rs.lines'), cfg.layer)}${opt('polygons', tx('rs.polygons'), cfg.layer)}${opt('extruded', tx('rs.extruded_polygons_shaded_by_height'), cfg.layer)}</select></div>
850
852
  ${styleFields(id, 'style', cfg.style)}
851
- <div class="field"><label class="label" for="${id('name')}">Layer name (legend)</label>
853
+ <div class="field"><label class="label" for="${id('name')}">${tx('rs.layer_name_legend')}</label>
852
854
  <input id="${id('name')}" name="name" value="${cfg.name ?? ''}" maxlength="60" placeholder="the region title"></div>
853
- <div class="field"><label class="label" for="${id('height')}">Height</label>
854
- <select id="${id('height')}" name="height">${opt('small', 'Small', cfg.height)}${opt('', 'Medium', cfg.height)}${opt('large', 'Large', cfg.height)}</select></div>
855
- <div class="field"><label class="label" for="${id('zoom')}">Zoom for a single place (1–19)</label>
855
+ <div class="field"><label class="label" for="${id('height')}">${tx('rs.height')}</label>
856
+ <select id="${id('height')}" name="height">${opt('small', tx('rs.small'), cfg.height)}${opt('', tx('rs.medium'), cfg.height)}${opt('large', tx('rs.large'), cfg.height)}</select></div>
857
+ <div class="field"><label class="label" for="${id('zoom')}">${tx('rs.zoom_for_a_single_place')}</label>
856
858
  <input id="${id('zoom')}" name="zoom" type="number" min="1" max="19" value="${cfg.zoom ?? ''}" placeholder="14"></div>
857
859
  ${loadField(id, 'load', cfg)}
858
- ${check('cluster', 'Group markers that are close together (clustering)', cfg.cluster === true)}
860
+ ${check('cluster', tx('rs.group_markers_that_are_close'), cfg.cluster === true)}
859
861
  ${emptyField(id, cfg)}
860
862
  </div></fieldset>
861
- ${linkFieldset(id, cfg.link, allPages, 'Each place')}
863
+ ${linkFieldset(id, cfg.link, allPages, tx('rs.w.place'))}
862
864
  ${await mapLayersFieldsets(cfg, appId, allPages, id)}
863
865
  ${await mapReportFieldset(r, pageId, appId, id)}`;
864
866
  break;
865
867
  }
866
868
  case 'tree':
867
- title = 'Tree settings';
869
+ title = tx('rs.t.tree');
868
870
  body = html`${columnsHint(await reportColumns(appId, await designSql(appId, r)), ['id', 'parent_id', 'label'])}
869
- <p class="muted u-mt0">The query returns <code>id</code>, <code>parent_id</code> and <code>label</code> (and optionally <code>icon</code>); rows whose parent isn't in the result are the roots.</p>
870
- <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
871
- <div class="field"><label class="label" for="${id('expanded')}">Levels open at first</label>
871
+ <p class="muted u-mt0">${tx.code('rs.tree_help')}</p>
872
+ <fieldset class="prop-group"><legend>${tx('rs.appearance')}</legend><div class="form-grid">
873
+ <div class="field"><label class="label" for="${id('expanded')}">${tx('rs.levels_open_at_first')}</label>
872
874
  <input id="${id('expanded')}" name="expanded" type="number" min="0" max="20" value="${cfg.expanded ?? 1}"></div>
873
875
  ${emptyField(id, cfg)}
874
876
  </div></fieldset>
875
- ${linkFieldset(id, cfg.link, await pages(), 'Each node')}`;
877
+ ${linkFieldset(id, cfg.link, await pages(), tx('rs.w.node'))}`;
876
878
  break;
877
879
  case 'workflows':
878
- title = 'Workflow console settings';
879
- body = html`<p class="muted u-mt0">Workflows come from workflow definitions (Shared Components); application SQL starts them with <code>meta.start_workflow(…)</code>.</p>
880
- <fieldset class="prop-group"><legend>Workflows</legend><div class="form-grid">
881
- <div class="field"><label class="label" for="${id('context')}">Show</label>
882
- <select id="${id('context')}" name="context">${opt('', 'Workflows I started', cfg.context)}${opt('admin', 'Workflows I administer', cfg.context)}</select></div>
880
+ title = tx('rs.t.workflows');
881
+ body = html`<p class="muted u-mt0">${tx.code('rs.workflow_help')}</p>
882
+ <fieldset class="prop-group"><legend>${tx('rs.workflows')}</legend><div class="form-grid">
883
+ <div class="field"><label class="label" for="${id('context')}">${tx('rs.show')}</label>
884
+ <select id="${id('context')}" name="context">${opt('', tx('rs.workflows_i_started'), cfg.context)}${opt('admin', tx('rs.workflows_i_administer'), cfg.context)}</select></div>
883
885
  ${emptyField(id, cfg)}
884
886
  </div>
885
- ${check('completed', 'Include completed and terminated workflows', cfg.completed === true)}</fieldset>`;
887
+ ${check('completed', tx('rs.include_completed_and_terminated_workflows'), cfg.completed === true)}</fieldset>`;
886
888
  break;
887
889
  case 'tasks':
888
- title = 'Task list settings';
889
- body = html`<p class="muted u-mt0">Tasks come from task definitions (Shared Components); application SQL creates them with <code>meta.create_task(…)</code>.</p>
890
- <fieldset class="prop-group"><legend>Tasks</legend><div class="form-grid">
891
- <div class="field"><label class="label" for="${id('context')}">Show</label>
892
- <select id="${id('context')}" name="context">${opt('', 'My tasks: to act on, claim, or assigned to me', cfg.context)}${opt('initiated', 'Tasks I requested', cfg.context)}${opt('admin', 'Tasks I administer', cfg.context)}</select></div>
890
+ title = tx('rs.t.tasks');
891
+ body = html`<p class="muted u-mt0">${tx.code('rs.task_help')}</p>
892
+ <fieldset class="prop-group"><legend>${tx('rs.tasks')}</legend><div class="form-grid">
893
+ <div class="field"><label class="label" for="${id('context')}">${tx('rs.show')}</label>
894
+ <select id="${id('context')}" name="context">${opt('', tx('rs.my_tasks_to_act_on'), cfg.context)}${opt('initiated', tx('rs.tasks_i_requested'), cfg.context)}${opt('admin', tx('rs.tasks_i_administer'), cfg.context)}</select></div>
893
895
  ${emptyField(id, cfg)}
894
896
  </div>
895
- ${check('completed', 'Include completed and cancelled tasks', cfg.completed === true)}</fieldset>`;
897
+ ${check('completed', tx('rs.include_completed_and_cancelled_tasks'), cfg.completed === true)}</fieldset>`;
896
898
  break;
897
899
  }
898
900
  return html`<h3 class="u-mt15">${title}</h3>
899
- <p class="muted u-mt0">These fields write the region's settings JSON above (other keys are kept).</p>
901
+ <p class="muted u-mt0">${tx('rs.these_fields_write_the_region')}</p>
900
902
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/settings" class="component-form">${csrf(s)}
901
903
  ${body!}
902
904
  <div class="buttons"><button class="btn btn-hot">Save ${title.toLowerCase()}</button></div>
@@ -948,7 +950,7 @@ export async function regionSettingsRoutes(app: FastifyInstance) {
948
950
  [pid, r.id, JSON.stringify(flags)],
949
951
  );
950
952
  }
951
- flash(s, 'Settings saved.');
953
+ flash(s, tr(s)('rs.saved'));
952
954
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
953
955
  });
954
956
  }