pgkiln 0.84.0 → 0.86.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 (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -5,6 +5,7 @@ import { ICONS } from '../icons.ts';
5
5
  import { appOr404 } from './forms.ts';
6
6
  import { addDashboard } from './appsheets.ts';
7
7
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // Create page wizards (APEX: Create Page). Step 1, on the app's Pages tab,
10
11
  // picks the page type and a table or view (a GET form); step 2 (GET
@@ -46,12 +47,15 @@ export const MORE_KINDS: [string, string, string][] = [
46
47
  ];
47
48
  /** All the page types step 2 knows: [key, label, description]. */
48
49
  export const PAGE_KINDS: [string, string, string][] = [...WIZARD_KINDS, ...MORE_KINDS];
50
+ /** A page type's label and description in the builder language (the lists above stay English for the AI path). */
51
+ export const kindLabel = (k: string, fallback: string) => tx.dyn(`wiz.kind.${k}`, fallback);
52
+ export const kindHelp = (k: string, fallback: string) => tx.dyn(`wiz.kind_help.${k}`, fallback);
49
53
  const KINDS = new Set(PAGE_KINDS.map((k) => k[0]));
50
54
  /** Page types that need no table (search takes one optionally, for a new search configuration). */
51
55
  export const NO_TABLE = new Set(['search', 'tasks', 'workflows', 'dashboard']);
52
- const TASK_CONTEXTS: [string, string][] = [['my', 'My tasks (to act on or claim)'], ['initiated', 'Tasks I requested'], ['admin', 'Tasks I administer']];
56
+ const TASK_CONTEXTS = (): [string, string][] => [['my', tx('wiz.my_tasks_to_act_on')], ['initiated', tx('wiz.tasks_i_requested')], ['admin', tx('wiz.tasks_i_administer')]];
53
57
  const CHART_KINDS = ['bar', 'column', 'line', 'area', 'donut', 'pie', 'funnel'];
54
- const FUNCTIONS: [string, string][] = [['count', 'Count'], ['sum', 'Sum'], ['avg', 'Average'], ['min', 'Minimum'], ['max', 'Maximum']];
58
+ const FUNCTIONS = (): [string, string][] => [['count', tx('wiz.count')], ['sum', tx('wiz.sum')], ['avg', tx('wiz.average')], ['min', tx('wiz.minimum')], ['max', tx('wiz.maximum')]];
55
59
 
56
60
  interface Col {
57
61
  column_name: string;
@@ -121,7 +125,7 @@ export function wizardOptions(kind: string, b: Record<string, unknown>) {
121
125
  break;
122
126
  case 'chart':
123
127
  o.chart = CHART_KINDS.includes(str('chart')) ? str('chart') : 'bar';
124
- o.function = FUNCTIONS.some(([f]) => f === str('function')) ? str('function') : 'count';
128
+ o.function = FUNCTIONS().some(([f]) => f === str('function')) ? str('function') : 'count';
125
129
  o.label_column = str('label_column') || null;
126
130
  o.value_column = str('value_column') || null;
127
131
  break;
@@ -176,7 +180,7 @@ export function wizardOptions(kind: string, b: Record<string, unknown>) {
176
180
  o.form_page = formPage();
177
181
  break;
178
182
  case 'tasks':
179
- o.context = TASK_CONTEXTS.some(([c]) => c === str('context')) ? str('context') : 'my';
183
+ o.context = TASK_CONTEXTS().some(([c]) => c === str('context')) ? str('context') : 'my';
180
184
  o.completed = b.completed === 'true';
181
185
  break;
182
186
  case 'workflows':
@@ -213,7 +217,7 @@ export async function wizardRoutes(app: FastifyInstance) {
213
217
  )
214
218
  : null;
215
219
  if (!rel && !(NO_TABLE.has(kind) && !table)) {
216
- flash(s, KINDS.has(kind) ? 'Choose a table or view.' : 'Choose a page type.', 'error');
220
+ flash(s, KINDS.has(kind) ? tx('wiz.choose_a_table_or_view') : tx('wiz.choose_a_page_type'), 'error');
217
221
  // (137) back to the type's table step on the Create page grid
218
222
  return back(reply, s, KINDS.has(kind) ? `${BASE}/apps/${a.id}/create-page?type=${encodeURIComponent(kind)}` : `${BASE}/apps/${a.id}/create-page`);
219
223
  }
@@ -233,7 +237,7 @@ export async function wizardRoutes(app: FastifyInstance) {
233
237
  )
234
238
  : null;
235
239
 
236
- const none: [string, string] = ['', '- none -'];
240
+ const none: [string, string] = ['', tx('wiz.none')];
237
241
  const colOpts = (pred: (c: Col) => boolean, withNone = true): [string, string][] => [
238
242
  ...(withNone ? [none] : []),
239
243
  ...cols.filter(pred).map((c): [string, string] => [c.column_name, `${c.column_name} (${c.type_sql}${c.fk_table ? `, shows ${c.fk_table}.${c.fk_display ?? '?'}` : ''})`]),
@@ -245,7 +249,7 @@ export async function wizardRoutes(app: FastifyInstance) {
245
249
  html`<fieldset class="prop-group"><legend>${legend}</legend><div class="qb-pick">${cols.filter(pred).map(
246
250
  (c) => html`<label class="check"><input type="checkbox" name="${name}" value="${c.column_name}"${chosen.includes(c.column_name) ? raw(' checked') : ''}> ${c.column_name} <span class="muted small">${c.type_sql}${note(c)}</span></label>`,
247
251
  )}</div></fieldset>`;
248
- const formPage = (help: string) => input('form_page', 'Form page', '', { type: 'number', placeholder: `e.g. ${nextPage + 1} (empty: no form)`, help });
252
+ const formPage = (help: string) => input('form_page', tx('wiz.form_page'), '', { type: 'number', placeholder: tx('wiz.form_page_ph', { n: nextPage + 1 }), help });
249
253
  const needsPk = ['report_form', 'grid', 'form', 'master_detail'].includes(kind);
250
254
  const allCols = (c: Col) => c.kind !== 'binary';
251
255
  const dates = (c: Col) => c.kind === 'date' || c.kind === 'timestamp';
@@ -253,146 +257,146 @@ export async function wizardRoutes(app: FastifyInstance) {
253
257
  let fields: Raw | string = '';
254
258
  switch (kind) {
255
259
  case 'report_form':
256
- fields = input('form_page', 'Form page', nextPage + 1, { type: 'number', required: true, help: 'The modal form page opened from the report.' });
260
+ fields = input('form_page', tx('wiz.form_page'), nextPage + 1, { type: 'number', required: true, help: tx('wiz.the_modal_form_page_opened') });
257
261
  break;
258
262
  case 'form':
259
263
  fields = html`<div class="form-grid">
260
- ${select('mode', 'Page mode', defaults.mode, [['normal', 'Normal page'], ['modal', 'Modal dialog']])}
261
- ${input('return_page', 'After saving or cancelling, go to page', defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? `empty: the home page (${a.home_page})` : 'empty: this page', help: 'Cancel, Create, Apply Changes and Delete return there.' })}
264
+ ${select('mode', tx('wiz.page_mode'), defaults.mode, [['normal', tx('wiz.normal_page')], ['modal', tx('wiz.modal_dialog')]])}
265
+ ${input('return_page', tx('wiz.after_saving_or_cancelling_go'), defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? tx('wiz.return_home', { n: a.home_page }) : tx('wiz.return_this'), help: tx('wiz.cancel_create_apply_changes_and') })}
262
266
  </div>
263
- ${colChecks('columns', 'Columns', defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary', (c) => (c.not_null && !c.has_default ? ', required: always included' : c.fk_table ? `, a select list of ${c.fk_table}` : ''))}`;
267
+ ${colChecks('columns', tx('wiz.col_columns'), defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary', (c) => (c.not_null && !c.has_default ? tx('wiz.note_required') : c.fk_table ? tx('wiz.note_select_list', { table: c.fk_table }) : ''))}`;
264
268
  break;
265
269
  case 'cards':
266
270
  fields = html`<div class="form-grid">
267
- ${select('title', 'Title', defaults.title, colOpts(notBinary, false))}
268
- ${select('subtitle', 'Subtitle', defaults.subtitle ?? '', colOpts(notBinary))}
269
- ${select('body', 'Body', defaults.body ?? '', colOpts(notBinary))}
270
- ${select('badge', 'Badge', defaults.badge ?? '', colOpts(notBinary))}
271
+ ${select('title', tx('wiz.title'), defaults.title, colOpts(notBinary, false))}
272
+ ${select('subtitle', tx('wiz.subtitle'), defaults.subtitle ?? '', colOpts(notBinary))}
273
+ ${select('body', tx('wiz.body'), defaults.body ?? '', colOpts(notBinary))}
274
+ ${select('badge', tx('wiz.badge'), defaults.badge ?? '', colOpts(notBinary))}
271
275
  ${pk ? formPage('A modal form page for a card (a link on each card and a Create button).') : ''}
272
276
  </div>`;
273
277
  break;
274
278
  case 'calendar':
275
279
  fields = html`<div class="form-grid">
276
- ${select('start', 'Start date', defaults.start ?? '', colOpts((c) => c.kind === 'date' || c.kind === 'timestamp', false))}
277
- ${select('end', 'End date', defaults.end ?? '', colOpts((c) => c.kind === 'date' || c.kind === 'timestamp'))}
278
- ${select('title', 'Event title', defaults.title ?? '', colOpts(notBinary))}
280
+ ${select('start', tx('wiz.start_date'), defaults.start ?? '', colOpts((c) => c.kind === 'date' || c.kind === 'timestamp', false))}
281
+ ${select('end', tx('wiz.end_date'), defaults.end ?? '', colOpts((c) => c.kind === 'date' || c.kind === 'timestamp'))}
282
+ ${select('title', tx('wiz.event_title'), defaults.title ?? '', colOpts(notBinary))}
279
283
  ${pk ? formPage('A modal form page: each event links to it, and a + on every day creates an event there.') : ''}
280
284
  </div>
281
- ${pk ? check('drag', 'Drag and drop', !!defaults.drag, 'Users who see the calendar can move events to another day or hour (an UPDATE of the start and end columns as the app\'s role; row level security applies). Set an authorization in the region\'s "move_authz" to limit it.') : ''}`;
285
+ ${pk ? check('drag', tx('wiz.drag_and_drop'), !!defaults.drag, tx('wiz.users_who_see_the_calendar')) : ''}`;
282
286
  break;
283
287
  case 'chart':
284
288
  fields = html`<div class="form-grid">
285
- ${select('chart', 'Chart type', defaults.chart, CHART_KINDS)}
286
- ${select('label_column', 'Label (one bar, slice or point per value)', defaults.label_column ?? '', colOpts(notBinary, false))}
287
- ${select('function', 'Value', defaults.function, FUNCTIONS)}
288
- ${select('value_column', 'Of column', defaults.value_column ?? '', colOpts((c) => c.kind === 'number'), 'Count: empty counts the rows. Sum, average, minimum and maximum need a number column.')}
289
+ ${select('chart', tx('wiz.chart_type'), defaults.chart, CHART_KINDS)}
290
+ ${select('label_column', tx('wiz.label_one_bar_slice_or'), defaults.label_column ?? '', colOpts(notBinary, false))}
291
+ ${select('function', tx('wiz.value'), defaults.function, FUNCTIONS())}
292
+ ${select('value_column', tx('wiz.of_column'), defaults.value_column ?? '', colOpts((c) => c.kind === 'number'), tx('wiz.count_empty_counts_the_rows'))}
289
293
  </div>`;
290
294
  break;
291
295
  case 'map':
292
296
  fields = html`<div class="form-grid">
293
- ${select('location', 'Position', defaults.location ?? '', colOpts((c) => c.kind === 'geometry' || c.kind === 'point' || c.kind === 'text'), 'A PostGIS geometry, a point, or text "latitude,longitude" (a location item). Empty: the latitude and longitude columns.')}
294
- ${select('lat', 'Latitude', defaults.lat ?? '', colOpts((c) => c.kind === 'number'))}
295
- ${select('lng', 'Longitude', defaults.lng ?? '', colOpts((c) => c.kind === 'number'))}
296
- ${select('title', 'Popup title', defaults.title ?? '', colOpts(notBinary))}
297
- ${select('body', 'Popup text', defaults.body ?? '', colOpts(notBinary))}
298
- ${pk ? formPage('A modal form page, linked from the popups and the list.') : ''}
297
+ ${select('location', tx('wiz.position'), defaults.location ?? '', colOpts((c) => c.kind === 'geometry' || c.kind === 'point' || c.kind === 'text'), 'A PostGIS geometry, a point, or text "latitude,longitude" (a location item). Empty: the latitude and longitude columns.')}
298
+ ${select('lat', tx('wiz.latitude'), defaults.lat ?? '', colOpts((c) => c.kind === 'number'))}
299
+ ${select('lng', tx('wiz.longitude'), defaults.lng ?? '', colOpts((c) => c.kind === 'number'))}
300
+ ${select('title', tx('wiz.popup_title'), defaults.title ?? '', colOpts(notBinary))}
301
+ ${select('body', tx('wiz.popup_text'), defaults.body ?? '', colOpts(notBinary))}
302
+ ${pk ? formPage(tx('wiz.form_map')) : ''}
299
303
  </div>
300
- ${check('report', 'A list below the map', defaults.report !== false, 'An interactive report of the same rows; moving the map offers "Show this area in the list".')}`;
304
+ ${check('report', tx('wiz.a_list_below_the_map'), defaults.report !== false, tx('wiz.an_interactive_report_of_the'))}`;
301
305
  break;
302
306
  case 'facets':
303
307
  fields = html`
304
- ${colChecks('columns', 'Report columns', defaults.columns ?? [], notBinary, (c) => (c.is_pk ? ', key: always included' : c.fk_table && c.fk_display ? `, with ${c.fk_table}.${c.fk_display}` : ''))}
305
- ${colChecks('facets', 'Facets', defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? `: values of ${c.fk_table}.${c.fk_display ?? c.column_name}` : ['number', 'date', 'timestamp'].includes(c.kind) ? ': a range (from / to)' : ': values with counts'))}
306
- ${check('search', 'A search field above the facets', defaults.search !== false)}
307
- ${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
308
+ ${colChecks('columns', tx('wiz.col_report_columns'), defaults.columns ?? [], notBinary, (c) => (c.is_pk ? tx('wiz.note_key') : c.fk_table && c.fk_display ? tx('wiz.note_with', { col: `${c.fk_table}.${c.fk_display}` }) : ''))}
309
+ ${colChecks('facets', tx('wiz.col_facets'), defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? tx('wiz.note_values_of', { col: `${c.fk_table}.${c.fk_display ?? c.column_name}` }) : ['number', 'date', 'timestamp'].includes(c.kind) ? tx('wiz.note_range') : tx('wiz.note_counts')))}
310
+ ${check('search', tx('wiz.a_search_field_above_the'), defaults.search !== false)}
311
+ ${pk ? html`<div class="form-grid">${formPage(tx('wiz.form_rows'))}</div>` : ''}`;
308
312
  break;
309
313
  // (137) the page types of migration 137
310
314
  case 'report':
311
- fields = pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : '';
315
+ fields = pk ? html`<div class="form-grid">${formPage(tx('wiz.form_rows'))}</div>` : '';
312
316
  break;
313
317
  case 'smart_filters':
314
318
  fields = html`
315
- ${colChecks('facets', 'Filters (suggested in the search field)', defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? `: values of ${c.fk_table}.${c.fk_display ?? c.column_name}` : ['number', 'date', 'timestamp'].includes(c.kind) ? ': ranges' : ': values'))}
316
- ${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
319
+ ${colChecks('facets', tx('wiz.col_filters'), defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? tx('wiz.note_values_of', { col: `${c.fk_table}.${c.fk_display ?? c.column_name}` }) : ['number', 'date', 'timestamp'].includes(c.kind) ? tx('wiz.note_ranges') : tx('wiz.note_values')))}
320
+ ${pk ? html`<div class="form-grid">${formPage(tx('wiz.form_rows'))}</div>` : ''}`;
317
321
  break;
318
322
  case 'tree':
319
323
  fields = html`<div class="form-grid">
320
- ${select('id_column', 'Node id', defaults.id_column ?? '', colOpts(allCols, false), 'What identifies a node: usually the primary key.')}
321
- ${select('parent_column', 'Parent', defaults.parent_column ?? '', colOpts(allCols), 'The column with the parent node\'s id; proposed from a foreign key to the table itself. Rows whose parent is not in the table are the top level.')}
322
- ${select('label_column', 'Label', defaults.label_column ?? '', colOpts(notBinary, false))}
323
- ${input('expanded', 'Levels open at first', defaults.expanded ?? 1, { type: 'number' })}
324
+ ${select('id_column', tx('wiz.node_id'), defaults.id_column ?? '', colOpts(allCols, false), tx('wiz.what_identifies_a_node_usually'))}
325
+ ${select('parent_column', tx('wiz.parent'), defaults.parent_column ?? '', colOpts(allCols), tx('wiz.the_column_with_the_parent'))}
326
+ ${select('label_column', tx('wiz.label'), defaults.label_column ?? '', colOpts(notBinary, false))}
327
+ ${input('expanded', tx('wiz.levels_open_at_first'), defaults.expanded ?? 1, { type: 'number' })}
324
328
  ${pk ? formPage('A modal form page, opened by clicking a node.') : ''}
325
329
  </div>`;
326
330
  break;
327
331
  case 'timeline':
328
332
  fields = html`<div class="form-grid">
329
- ${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false))}
330
- ${select('when', 'When', defaults.when ?? '', colOpts(dates), 'The events come newest first.')}
331
- ${select('who', 'Who', defaults.who ?? '', colOpts(notBinary))}
332
- ${select('body', 'Text', defaults.body ?? '', colOpts(notBinary))}
333
+ ${select('title', tx('wiz.title'), defaults.title ?? '', colOpts(notBinary, false))}
334
+ ${select('when', tx('wiz.when'), defaults.when ?? '', colOpts(dates), tx('wiz.the_events_come_newest_first'))}
335
+ ${select('who', tx('wiz.who'), defaults.who ?? '', colOpts(notBinary))}
336
+ ${select('body', tx('wiz.text'), defaults.body ?? '', colOpts(notBinary))}
333
337
  ${pk ? formPage('A modal form page, linked from each title.') : ''}
334
338
  </div>`;
335
339
  break;
336
340
  case 'media_list':
337
341
  case 'content_row':
338
342
  fields = html`<div class="form-grid">
339
- ${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false), 'Its first two letters are the initials.')}
340
- ${select('description', 'Description', defaults.description ?? '', colOpts(notBinary))}
341
- ${kind === 'content_row' ? select('misc', 'Secondary text (on the right)', defaults.misc ?? '', colOpts(notBinary)) : ''}
342
- ${select('badge', 'Badge', defaults.badge ?? '', colOpts(notBinary))}
343
+ ${select('title', tx('wiz.title'), defaults.title ?? '', colOpts(notBinary, false), tx('wiz.its_first_two_letters_are'))}
344
+ ${select('description', tx('wiz.description'), defaults.description ?? '', colOpts(notBinary))}
345
+ ${kind === 'content_row' ? select('misc', tx('wiz.secondary_text_on_the_right'), defaults.misc ?? '', colOpts(notBinary)) : ''}
346
+ ${select('badge', tx('wiz.badge'), defaults.badge ?? '', colOpts(notBinary))}
343
347
  ${pk ? formPage('A modal form page, linked from each title.') : ''}
344
348
  </div>`;
345
349
  break;
346
350
  case 'comments':
347
351
  fields = html`<div class="form-grid">
348
- ${select('user', 'Who wrote it', defaults.user ?? '', colOpts(notBinary))}
349
- ${select('date', 'When', defaults.date ?? '', colOpts(dates), 'The newest first.')}
350
- ${select('comment', 'Comment', defaults.comment ?? '', colOpts(notBinary, false))}
352
+ ${select('user', tx('wiz.who_wrote_it'), defaults.user ?? '', colOpts(notBinary))}
353
+ ${select('date', tx('wiz.when'), defaults.date ?? '', colOpts(dates), tx('wiz.the_newest_first'))}
354
+ ${select('comment', tx('wiz.comment'), defaults.comment ?? '', colOpts(notBinary, false))}
351
355
  </div>`;
352
356
  break;
353
357
  case 'data_load': {
354
358
  const defs = (await owner.query<{ name: string; table_name: string }>('select name, table_name from meta.data_load_def where app_id = $1 order by name', [a.id])).rows;
355
359
  fields = html`<div class="form-grid">
356
- ${input('definition', 'Data load definition', defaults.definition ?? '', { required: true, help: `Upper case, e.g. ORDERS_LOAD. A new definition for ${rel?.t ?? 'the table'}; an existing one of that name is used as it is${defs.length ? ` (this application has ${defs.map((d) => d.name).join(', ')})` : ''}.` })}
357
- ${select('mode', 'Mode', defaults.mode ?? 'append', [['append', 'Append: add the rows'], ['merge', 'Merge: update rows with the same key, add the others'], ['replace', 'Replace: delete all rows first']])}
360
+ ${input('definition', tx('wiz.data_load_definition'), defaults.definition ?? '', { required: true, help: defs.length ? tx('wiz.def_help_has', { table: rel?.t ?? tx('wiz.the_table'), list: defs.map((d) => d.name).join(', ') }) : tx('wiz.def_help', { table: rel?.t ?? tx('wiz.the_table') }) })}
361
+ ${select('mode', tx('wiz.mode'), defaults.mode ?? 'append', [['append', tx('wiz.append_add_the_rows')], ['merge', tx('wiz.merge_update_rows_with_the')], ['replace', tx('wiz.replace_delete_all_rows_first')]])}
358
362
  </div>
359
- ${check('skip_errors', 'Skip rows with errors', !!defaults.skip_errors, 'Load the good rows and list the others; otherwise nothing is loaded when a row fails.')}`;
363
+ ${check('skip_errors', tx('wiz.skip_rows_with_errors'), !!defaults.skip_errors, tx('wiz.load_the_good_rows_and'))}`;
360
364
  break;
361
365
  }
362
366
  case 'wizard':
363
367
  fields = html`<div class="form-grid">
364
- ${input('steps', 'Steps', defaults.steps ?? 2, { type: 'number', help: 'One page per step, numbered from the page number up; the columns are shared out in order.' })}
365
- ${input('return_page', 'After Finish or Cancel, go to page', defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? `empty: the home page (${a.home_page})` : 'empty: this page' })}
368
+ ${input('steps', tx('wiz.steps'), defaults.steps ?? 2, { type: 'number', help: tx('wiz.one_page_per_step_numbered') })}
369
+ ${input('return_page', tx('wiz.after_finish_or_cancel_go'), defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? tx('wiz.return_home', { n: a.home_page }) : tx('wiz.return_this') })}
366
370
  </div>
367
- ${colChecks('columns', 'Columns', defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary' && c.kind !== 'geometry', (c) => (c.not_null && !c.has_default ? ', required: always included' : c.fk_table ? `, a select list of ${c.fk_table}` : ''))}`;
371
+ ${colChecks('columns', tx('wiz.col_columns'), defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary' && c.kind !== 'geometry', (c) => (c.not_null && !c.has_default ? tx('wiz.note_required') : c.fk_table ? tx('wiz.note_select_list', { table: c.fk_table }) : ''))}`;
368
372
  break;
369
373
  case 'search': {
370
374
  const configs = (await owner.query<{ name: string; label: string; source: string }>('select name, label, source from meta.search_config where app_id = $1 order by seq, name', [a.id])).rows;
371
375
  const tables = await wizardTables(role);
372
376
  fields = html`
373
377
  ${configs.length
374
- ? html`<fieldset class="prop-group"><legend>Search configurations</legend><div class="qb-pick">${configs.map((c) => html`<label class="check"><input type="checkbox" name="configs" value="${c.name}" checked> ${c.label} <span class="muted small">${c.name}, ${c.source.slice(0, 60)}</span></label>`)}</div></fieldset>`
375
- : html`<p class="muted">This application has no search configurations yet (<a href="${BASE}/apps/${a.id}/shared?type=search_config">Shared Components → Search configurations</a>): choose a table to search below.</p>`}
378
+ ? html`<fieldset class="prop-group"><legend>${tx('wiz.search_configurations')}</legend><div class="qb-pick">${configs.map((c) => html`<label class="check"><input type="checkbox" name="configs" value="${c.name}" checked> ${c.label} <span class="muted small">${c.name}, ${c.source.slice(0, 60)}</span></label>`)}</div></fieldset>`
379
+ : html`<p class="muted">${tx.rich('wiz.no_search_configs', { link: html`<a href="${BASE}/apps/${a.id}/shared?type=search_config">${tx('wiz.shared_components_search_configurations')}</a>` })}</p>`}
376
380
  <div class="form-grid">
377
- ${select('table', 'And a new configuration on', rel?.t ?? '', [['', '- none -'], ...tables.filter((t) => t.access).map((t): [string, string] => [t.t, t.t])], 'Searches the table\'s text columns and shows its name (or first text) column as the result\'s title.')}
378
- ${formPage('With a table: a modal form page that each result links to.')}
381
+ ${select('table', tx('wiz.and_a_new_configuration_on'), rel?.t ?? '', [['', tx('wiz.none')], ...tables.filter((t) => t.access).map((t): [string, string] => [t.t, t.t])], tx('wiz.searches_the_table_s_text'))}
382
+ ${formPage(tx('wiz.form_search'))}
379
383
  </div>`;
380
384
  break;
381
385
  }
382
386
  case 'tasks':
383
- fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'my', TASK_CONTEXTS)}</div>
384
- ${check('completed', 'Also completed tasks', !!defaults.completed)}
385
- <p class="muted small">Tasks come from task definitions (Shared Components → Task definitions) and approvals started in processes or workflows.</p>`;
387
+ fields = html`<div class="form-grid">${select('context', tx('wiz.show'), defaults.context ?? 'my', TASK_CONTEXTS())}</div>
388
+ ${check('completed', tx('wiz.also_completed_tasks'), !!defaults.completed)}
389
+ <p class="muted small">${tx('wiz.tasks_come_from_task_definitions')}</p>`;
386
390
  break;
387
391
  case 'workflows':
388
- fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'initiator', [['initiator', 'The workflows the user started'], ['admin', 'The workflows the user administers']])}</div>`;
392
+ fields = html`<div class="form-grid">${select('context', tx('wiz.show'), defaults.context ?? 'initiator', [['initiator', tx('wiz.the_workflows_the_user_started')], ['admin', tx('wiz.the_workflows_the_user_administers')]])}</div>`;
389
393
  break;
390
394
  case 'dashboard': {
391
395
  const tables = (await wizardTables(role)).filter((t) => t.access);
392
396
  fields = tables.length
393
- ? html`<fieldset class="prop-group"><legend>Tables</legend><div class="qb-pick">${tables.map((t, n) => html`<label class="check"><input type="checkbox" name="tables" value="${t.t}"${n < 12 ? raw(' checked') : ''}> ${t.t}</label>`)}</div></fieldset>
394
- <p class="muted small">A chart per table (at most six) of the rows per parent row or per repeating value, read from the data; tables with row level security are left out.</p>`
395
- : html`<p class="alert alert-error" role="status">The application's database role can't read any table.</p>`;
397
+ ? html`<fieldset class="prop-group"><legend>${tx('wiz.tables')}</legend><div class="qb-pick">${tables.map((t, n) => html`<label class="check"><input type="checkbox" name="tables" value="${t.t}"${n < 12 ? raw(' checked') : ''}> ${t.t}</label>`)}</div></fieldset>
398
+ <p class="muted small">${tx('wiz.a_chart_per_table_at')}</p>`
399
+ : html`<p class="alert alert-error" role="status">${tx('wiz.the_application_s_database_role')}</p>`;
396
400
  break;
397
401
  }
398
402
  case 'master_detail': {
@@ -407,33 +411,33 @@ export async function wizardRoutes(app: FastifyInstance) {
407
411
  )
408
412
  ).rows;
409
413
  fields = details.length
410
- ? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
411
- : html`<p class="alert alert-error" role="status">No table has a foreign key to ${t}: a master-detail page needs one.</p>`;
414
+ ? select('detail', tx('wiz.detail_table'), defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${t})`]), tx('wiz.tables_with_a_foreign_key'))
415
+ : html`<p class="alert alert-error" role="status">${tx('wiz.no_fk', { table: t })}</p>`;
412
416
  break;
413
417
  }
414
418
  }
415
419
 
416
420
  const kindDef = PAGE_KINDS.find((k) => k[0] === kind)!;
417
- const kindName = kindDef[1];
421
+ const kindName = kindLabel(kindDef[0], kindDef[1]);
418
422
  const main = html`${appHeader(a, 'pages', s)}
419
- ${region(rel && kind !== 'search' ? `${kindName} on ${rel.t}` : kindName, html`
420
- <p class="muted u-mt0">${kindDef[2]} ${rel ? 'The proposals come from the table\'s columns, key and foreign keys; change them below.' : ''} <a href="${BASE}/apps/${a.id}/create-page">Back to choose another type${rel ? ' or table' : ''}</a>.</p>
421
- ${rel && needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
423
+ ${region(rel && kind !== 'search' ? tx('wiz.kind_on', { kind: kindName, table: rel.t }) : kindName, html`
424
+ <p class="muted u-mt0">${kindHelp(kindDef[0], kindDef[2])} ${rel ? tx('wiz.the_proposals_come_from_the') : ''} <a href="${BASE}/apps/${a.id}/create-page">${rel ? tx('wiz.back_choose_table') : tx('wiz.back_choose')}</a>.</p>
425
+ ${rel && needsPk && !pk ? html`<p class="alert alert-error" role="status">${tx('wiz.no_pk', { table: rel.t })}</p>` : ''}
422
426
  ${rel && role && access && !(access.s && (!(needsPk || kind === 'wizard' || kind === 'data_load') || (access.i && (kind === 'wizard' || (access.u && access.d)))))
423
- ? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : kind === 'wizard' ? 'select, insert' : kind === 'data_load' ? 'select, insert, update, delete for merge or replace' : 'select'}).</p>`
427
+ ? html`<p class="alert alert-error" role="status">${tx.rich(access.s ? 'wiz.role_read_only' : 'wiz.role_no_access', { role: html`<code>${role}</code>`, table: rel.t, privs: needsPk ? 'select, insert, update, delete' : kind === 'wizard' ? 'select, insert' : kind === 'data_load' ? tx('wiz.privs_merge') : 'select' })}</p>`
424
428
  : ''}
425
429
  <form method="post" action="${BASE}/apps/${a.id}/wizard">${csrf(s)}
426
430
  <input type="hidden" name="kind" value="${kind}">${rel && kind !== 'search' ? html`<input type="hidden" name="table" value="${rel.t}">` : ''}
427
431
  <div class="form-grid">
428
- ${input('report_page', 'Page number', nextPage, { type: 'number', required: true })}
429
- ${input('label', 'Name', defaults.label ?? '', { help: 'The page title and the navigation entry.' })}
430
- ${select('icon', 'Menu icon', defaults.icon ?? 'table', [...ICONS])}
432
+ ${input('report_page', tx('wiz.page_number'), nextPage, { type: 'number', required: true })}
433
+ ${input('label', tx('wiz.name'), defaults.label ?? '', { help: tx('wiz.the_page_title_and_the') })}
434
+ ${select('icon', tx('wiz.menu_icon'), defaults.icon ?? 'table', [...ICONS])}
431
435
  </div>
432
- ${['report_form', 'grid', 'dashboard'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
436
+ ${['report_form', 'grid', 'dashboard'].includes(kind) ? '' : check('nav', tx('wiz.add_a_navigation_entry'), !!defaults.nav)}
433
437
  ${fields}
434
- <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}">Cancel</a><button class="btn btn-hot">Create page</button></div>
438
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}">${tx('wiz.cancel')}</a><button class="btn btn-hot">${tx('wiz.create_page')}</button></div>
435
439
  </form>`)}`;
436
- return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page`], [kindName]], main));
440
+ return send(reply, s, shell(s, tx('wiz.page_title', { app: a.name }), [[tx('wiz.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('wiz.create_page'), `${BASE}/apps/${a.id}/create-page`], [kindName]], main));
437
441
  });
438
442
 
439
443
  app.post(`${BASE}/apps/:id/wizard`, async (req: Req, reply) => {
@@ -445,22 +449,22 @@ export async function wizardRoutes(app: FastifyInstance) {
445
449
  const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}${table && kind !== 'search' ? `&table=${encodeURIComponent(table)}` : ''}`;
446
450
  try {
447
451
  const page = pageNo(b.report_page);
448
- if (page === null) throw new Error('Enter a page number.');
452
+ if (page === null) throw new Error(tx('wiz.enter_a_page_number'));
449
453
  const options = wizardOptions(kind, b);
450
- if (!table && !NO_TABLE.has(kind)) throw new Error('Choose a table or view.');
454
+ if (!table && !NO_TABLE.has(kind)) throw new Error(tx('wiz.choose_a_table_or_view'));
451
455
  const id = await owner.tx(async (c) => {
452
456
  const a = await c.query('select alias, db_role from meta.app where id = $1', [req.params.id]);
453
- if (!a.rowCount) throw new Error('This application does not exist.');
457
+ if (!a.rowCount) throw new Error(tx('wiz.this_application_does_not_exist'));
454
458
  // report and form and grid keep their own icons and need no step 2 (older forms post here directly)
455
- if (kind === 'report_form' && options.form_page === null) throw new Error('Enter the form page number.');
459
+ if (kind === 'report_form' && options.form_page === null) throw new Error(tx('wiz.enter_the_form_page_number'));
456
460
  if (['report_form', 'grid'].includes(kind) && !options.icon) options.icon = kind === 'grid' ? 'grid' : 'table';
457
461
  if (kind === 'dashboard') {
458
462
  // (137) only tables the application's role can read
459
463
  const readable = new Set((await wizardTables(a.rows[0].db_role)).filter((t) => t.access).map((t) => t.t));
460
464
  const tables = (options.tables as string[]).filter((t) => readable.has(t));
461
- if (!tables.length) throw new Error('Choose at least one table the application can read.');
465
+ if (!tables.length) throw new Error(tx('wiz.choose_at_least_one_table'));
462
466
  const made = await addDashboard(c, a.rows[0].alias, tables, page);
463
- if (!made) throw new Error('None of these tables has a column worth a chart (a foreign key, or values that repeat).');
467
+ if (!made) throw new Error(tx('wiz.none_of_these_tables_has'));
464
468
  const pid = (await c.query('select id from meta.page where app_id = $1 and page_no = $2', [req.params.id, page])).rows[0].id as number;
465
469
  if (options.label) {
466
470
  await c.query('update meta.page set name = $2, title = $2 where id = $1', [pid, options.label]);
@@ -472,7 +476,9 @@ export async function wizardRoutes(app: FastifyInstance) {
472
476
  return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table || null, page, JSON.stringify(options)])).rows[0].id as number;
473
477
  });
474
478
  const pages = (await owner.query('select page_no from meta.page where app_id = $1 and id >= $2 order by page_no', [req.params.id, id])).rows.map((r) => r.page_no);
475
- flash(s, `${pages.length > 1 ? `Pages ${pages.join(', ')}` : `Page ${page}`} created${table ? ` for ${table}. Make sure the app's database role has privileges on it` : ''}.`);
479
+ flash(s, table
480
+ ? (pages.length > 1 ? tx('wiz.created_pages_for', { list: pages.join(', '), table }) : tx('wiz.created_page_for', { n: page, table }))
481
+ : (pages.length > 1 ? tx('wiz.created_pages', { list: pages.join(', ') }) : tx('wiz.created_page', { n: page })));
476
482
  return back(reply, s, `${BASE}/pages/${id}`);
477
483
  } catch (e) {
478
484
  flash(s, (e as Error).message, 'error');
@@ -5,6 +5,7 @@ import { clientIp, logActivity } from '../session.ts';
5
5
  import { workflowDiagram, type Step } from '../workflow.ts';
6
6
  import { appOr404 } from './forms.ts';
7
7
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // (123) Workflows in the builder: migrating running instances to a newer
10
11
  // version (APEX 26.2: in-flight workflow migration) and the diagram as a file.
@@ -80,7 +81,7 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
80
81
  const pick = v ? versions.find((x) => x.version === v) : versions[versions.length - 1];
81
82
  if (!pick) return reply.code(404).send('No such version');
82
83
  const svg = workflowDiagram(pick.steps, { id: 'export', standalone: { title: `${d.name} version ${pick.version}` } }).toString();
83
- if (!svg) return reply.code(404).send('This version has no steps to draw.');
84
+ if (!svg) return reply.code(404).send(tx('wfm.no_steps'));
84
85
  const file = `${d.name.toLowerCase()}-v${pick.version}.svg`.replace(/[^a-z0-9._-]/gi, '_');
85
86
  return reply
86
87
  .header('content-type', 'image/svg+xml; charset=utf-8')
@@ -126,7 +127,7 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
126
127
  let preview: { workflow_id: string; branch: string | null; from_version: string | null; from_step: string | null; to_step: string | null; problem: string | null }[] | null = null;
127
128
  let previewError: string | null = null;
128
129
  if (q.preview && to) {
129
- if (!chosen.length) previewError = 'Choose the instances to migrate.';
130
+ if (!chosen.length) previewError = tx('wfm.choose_instances');
130
131
  else {
131
132
  try {
132
133
  preview = (await owner.query('select workflow_id::text, branch, from_version, from_step, to_step, problem from meta.migrate_workflows($1, $2, $3::bigint[], $4, false, $5)',
@@ -141,52 +142,50 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
141
142
  const self = `${url(d)}/migrate`;
142
143
  const hidden = html`<input type="hidden" name="to" value="${to ?? ''}">${chosen.map((w) => html`<input type="hidden" name="wf" value="${w.id}">`)}${Object.entries(mapping).map(([k, v]) => html`<input type="hidden" name="${`m.${k}`}" value="${v}">`)}`;
143
144
  const body = !running.length
144
- ? region('Migrate running instances', html`<p class="muted u-mt0">${d.name} has no running instances.</p><p><a class="btn" href="${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}">Back to ${d.name}</a></p>`)
145
+ ? region(tx('wfm.title'), html`<p class="muted u-mt0">${tx('wfm.none_running', { name: d.name })}</p><p><a class="btn" href="${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}">${tx('wfm.back_to', { name: d.name })}</a></p>`)
145
146
  : !targets.length
146
- ? region('Migrate running instances', html`<p class="muted u-mt0">Every running instance of ${d.name} runs the active version ${d.version}: there is no newer version to move them to.</p>
147
- <p><a class="btn" href="${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}">Back to ${d.name}</a></p>`)
148
- : html`${region('Migrate running instances', html`<form method="get" action="${self}">
147
+ ? region(tx('wfm.title'), html`<p class="muted u-mt0">${tx('wfm.all_current', { name: d.name, version: d.version })}</p>
148
+ <p><a class="btn" href="${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}">${tx('wfm.back_to', { name: d.name })}</a></p>`)
149
+ : html`${region(tx('wfm.title'), html`<form method="get" action="${self}">
149
150
  <input type="hidden" name="preview" value="1">
150
- <p class="muted u-mt0">Move running instances to a newer version: each goes on at the step of the new version its current step is mapped to,
151
- keeping its variables and the task it waits for. An instance inside parallel branches moves as a whole: its main path at its join and each open
152
- branch at its own step, each mapped (a join stays a join). Every position must have a mapping, or nothing moves.</p>
151
+ <p class="muted u-mt0">${tx('wfm.intro')}</p>
153
152
  <div class="form-grid">
154
- <div class="field"><label class="label" for="wf-to">To version</label>
155
- <select id="wf-to" name="to">${targets.map((v) => html`<option value="${v}"${v === to ? raw(' selected') : ''}>${v}${v === d.version ? ' (active)' : ' (inactive)'}</option>`)}</select>
156
- <small class="help">Changing it: preview again to see its steps.</small></div>
153
+ <div class="field"><label class="label" for="wf-to">${tx('wfm.to_version')}</label>
154
+ <select id="wf-to" name="to">${targets.map((v) => html`<option value="${v}"${v === to ? raw(' selected') : ''}>${v} (${tx(v === d.version ? 'wfv.state.active' : 'wfv.state.inactive')})</option>`)}</select>
155
+ <small class="help">${tx('wfm.to_help')}</small></div>
157
156
  </div>
158
- <fieldset class="prop-group u-mt1"><legend>Instances on an older version</legend>
157
+ <fieldset class="prop-group u-mt1"><legend>${tx('wfm.older')}</legend>
159
158
  ${older.length
160
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Migrate</th><th class="num">Id</th><th>Title</th><th>Version</th><th>State</th><th>At</th></tr></thead><tbody>
161
- ${older.map((w) => html`<tr><td data-label="Migrate"><input type="checkbox" name="wf" value="${w.id}" id="${`wf-${w.id}`}"${picked.includes(w.id) ? raw(' checked') : ''} aria-label="${`Migrate instance ${w.id}`}"></td>
162
- <td class="num" data-label="Id"><label for="${`wf-${w.id}`}">${w.id}</label></td><td data-label="Title">${w.title}</td><td data-label="Version">${w.version}</td>
163
- <td data-label="State">${w.state}</td><td data-label="At">${w.current_step ?? '—'}${w.branch_steps.length ? html`<br><small class="muted">branches at ${w.branch_steps.join(', ')}</small>` : ''}</td></tr>`)}
159
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('wfm.migrate')}</th><th class="num">${tx('wfv.id')}</th><th>${tx('wfv.title')}</th><th>${tx('wfv.version')}</th><th>${tx('wfv.state')}</th><th>${tx('wfv.at')}</th></tr></thead><tbody>
160
+ ${older.map((w) => html`<tr><td data-label="${tx('wfm.migrate')}"><input type="checkbox" name="wf" value="${w.id}" id="${`wf-${w.id}`}"${picked.includes(w.id) ? raw(' checked') : ''} aria-label="${tx('wfm.migrate_instance', { id: w.id })}"></td>
161
+ <td class="num" data-label="${tx('wfv.id')}"><label for="${`wf-${w.id}`}">${w.id}</label></td><td data-label="${tx('wfv.title')}">${w.title}</td><td data-label="${tx('wfv.version')}">${w.version}</td>
162
+ <td data-label="${tx('wfv.state')}">${w.state}</td><td data-label="${tx('wfv.at')}">${w.current_step ?? '—'}${w.branch_steps.length ? html`<br><small class="muted">${tx('wfm.branches_at', { steps: w.branch_steps.join(', ') })}</small>` : ''}</td></tr>`)}
164
163
  </tbody></table></div>`
165
- : html`<p class="muted">No running instance is older than version ${to}.</p>`}
164
+ : html`<p class="muted">${tx('wfm.none_older', { version: to ?? '' })}</p>`}
166
165
  </fieldset>
167
166
  ${current.length
168
- ? html`<fieldset class="prop-group u-mt1"><legend>Map the current steps to steps of version ${to}</legend>
167
+ ? html`<fieldset class="prop-group u-mt1"><legend>${tx('wfm.map', { version: to ?? '' })}</legend>
169
168
  <div class="wf-migrate-map">${current.map((st) => html`<label>${st}
170
- <select name="${`m.${st}`}"><option value="">— no mapping —</option>${targetSteps.map((x) => html`<option value="${x.name}"${mapping[st] === x.name ? raw(' selected') : ''}>${x.name} (${x.type})</option>`)}</select></label>`)}</div>
169
+ <select name="${`m.${st}`}"><option value="">${tx('wfm.no_mapping')}</option>${targetSteps.map((x) => html`<option value="${x.name}"${mapping[st] === x.name ? raw(' selected') : ''}>${x.name} (${x.type})</option>`)}</select></label>`)}</div>
171
170
  </fieldset>`
172
171
  : ''}
173
- <div class="buttons u-mt1"><button class="btn">Preview</button> <a class="btn" href="${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}">Cancel</a></div>
172
+ <div class="buttons u-mt1"><button class="btn">${tx('wfm.preview')}</button> <a class="btn" href="${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}">${tx('common.cancel')}</a></div>
174
173
  </form>`)}
175
174
  ${previewError ? html`<div class="alert alert-error u-mt1" role="alert">${previewError}</div>` : ''}
176
175
  ${preview
177
- ? region('Preview', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">Id</th><th>Path</th><th>From</th><th>To</th><th>Result</th></tr></thead><tbody>
178
- ${preview.map((r) => html`<tr><td class="num" data-label="Id">${r.workflow_id}</td><td data-label="Path">${r.branch ? `branch ${r.branch}` : 'main'}</td><td data-label="From">${r.from_version ? `${r.from_version}: ${r.from_step ?? '—'}` : '—'}</td>
179
- <td data-label="To">${r.to_step ? `${to}: ${r.to_step}` : '—'}</td><td data-label="Result">${r.problem ? html`<span class="wf-problem">${r.problem}</span>` : html`<span class="tag tag-info">ready</span>`}</td></tr>`)}
176
+ ? region(tx('wfm.preview'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">${tx('wfv.id')}</th><th>${tx('wfm.path')}</th><th>${tx('wfm.from')}</th><th>${tx('wfm.to')}</th><th>${tx('wfm.result')}</th></tr></thead><tbody>
177
+ ${preview.map((r) => html`<tr><td class="num" data-label="${tx('wfv.id')}">${r.workflow_id}</td><td data-label="${tx('wfm.path')}">${r.branch ? tx('wfm.branch', { name: r.branch }) : tx('wfm.main')}</td><td data-label="${tx('wfm.from')}">${r.from_version ? `${r.from_version}: ${r.from_step ?? '—'}` : '—'}</td>
178
+ <td data-label="${tx('wfm.to')}">${r.to_step ? `${to}: ${r.to_step}` : '—'}</td><td data-label="${tx('wfm.result')}">${r.problem ? html`<span class="wf-problem">${r.problem}</span>` : html`<span class="tag tag-info">${tx('wfm.ready')}</span>`}</td></tr>`)}
180
179
  </tbody></table></div>
181
180
  ${ready
182
181
  ? html`<form method="post" action="${self}" class="u-mt1">${csrf(s)}${hidden}
183
- <button class="btn btn-hot" data-confirm="${`Migrate ${instances} instance(s) to version ${to}?`}">Migrate ${instances} instance(s)</button></form>`
184
- : html`<p class="wf-problem">Map a step for every instance and branch (or leave the instances out) and preview again: nothing is migrated while one can't move.</p>`}`)
182
+ <button class="btn btn-hot" data-confirm="${tx('wfm.confirm', { n: instances, version: to ?? '' })}">${tx('wfm.do', { n: instances })}</button></form>`
183
+ : html`<p class="wf-problem">${tx('wfm.not_ready')}</p>`}`)
185
184
  : ''}
186
- ${target ? region(`Version ${to}`, html`<div class="wf-wrap">${workflowDiagram(target.steps, { id: 'target' })}</div>`) : ''}`;
185
+ ${target ? region(tx('wfm.version', { version: to ?? '' }), html`<div class="wf-wrap">${workflowDiagram(target.steps, { id: 'target' })}</div>`) : ''}`;
187
186
  const main = html`${appHeader(a, 'shared', s)}<div class="ide-body">${body}</div>`;
188
- return send(reply, s, shell(s, `Migrate ${d.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
189
- [d.name, `${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}`], ['Migrate instances']], main));
187
+ return send(reply, s, shell(s, tx('wfm.page_title', { name: d.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.shared'), `${BASE}/apps/${a.id}/shared`],
188
+ [d.name, `${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}`], [tx('wfm.crumb')]], main));
190
189
  });
191
190
 
192
191
  app.post(`${BASE}/apps/:id/shared/workflow_definition/:cid/migrate`, async (req: Req, reply) => {
@@ -200,12 +199,12 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
200
199
  const mapping = mappingOf(b);
201
200
  const self = `${url(d)}/migrate`;
202
201
  try {
203
- if (!to) throw new Error('Choose the version to migrate to.');
202
+ if (!to) throw new Error(tx('wfm.choose_version'));
204
203
  const rows = await owner.tx(async (c) =>
205
204
  (await c.query('select workflow_id::text, from_step, to_step from meta.migrate_workflows($1, $2, $3::bigint[], $4, true, $5)', [d.id, to, ids, mapping, s.username])).rows);
206
205
  const moved = new Set(rows.map((r) => r.workflow_id)).size;
207
206
  logActivity({ appId: d.app_id, username: s.username, event: 'workflow', ip: clientIp(req), detail: `${d.name}: ${moved} instance(s) migrated to version ${to}` });
208
- flash(s, `${moved} instance(s) of ${d.name} migrated to version ${to}.`);
207
+ flash(s, tx('wfm.done', { n: moved, name: d.name, version: to }));
209
208
  return back(reply, s, `${BASE}/apps/${d.app_id}/shared?c=workflow_definition-${d.id}`);
210
209
  } catch (e) {
211
210
  flash(s, (e as Error).message, 'error');