pgkiln 0.83.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +3 -2
  3. package/docs/guide/03-builder.md +16 -5
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +13 -1
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +11 -0
  15. package/src/builder/ai-agents.ts +86 -85
  16. package/src/builder/ai-builder.ts +30 -30
  17. package/src/builder/ai.ts +93 -92
  18. package/src/builder/api.ts +47 -47
  19. package/src/builder/app-groups.ts +33 -32
  20. package/src/builder/appfromfile.ts +57 -58
  21. package/src/builder/appsheets.ts +33 -32
  22. package/src/builder/appwizard.ts +65 -65
  23. package/src/builder/arrange.ts +23 -22
  24. package/src/builder/assistant.ts +39 -38
  25. package/src/builder/automations.ts +32 -31
  26. package/src/builder/availability.ts +24 -24
  27. package/src/builder/backups.ts +30 -31
  28. package/src/builder/blueprints.ts +62 -61
  29. package/src/builder/code-editor.ts +1 -1
  30. package/src/builder/components.ts +10 -6
  31. package/src/builder/create-page.ts +52 -50
  32. package/src/builder/cross-app.ts +37 -35
  33. package/src/builder/dataload.ts +2 -2
  34. package/src/builder/datasets.ts +107 -106
  35. package/src/builder/dependencies.ts +20 -19
  36. package/src/builder/designer.ts +36 -36
  37. package/src/builder/diagnostics.ts +58 -57
  38. package/src/builder/features.ts +39 -35
  39. package/src/builder/feedback.ts +23 -20
  40. package/src/builder/find-workspace.ts +28 -24
  41. package/src/builder/form-designer.ts +36 -35
  42. package/src/builder/gallery.ts +25 -29
  43. package/src/builder/globalization.ts +50 -49
  44. package/src/builder/i18n/de.ts +3461 -0
  45. package/src/builder/i18n/en.ts +3461 -0
  46. package/src/builder/i18n/es.ts +3461 -0
  47. package/src/builder/i18n/fr.ts +3461 -0
  48. package/src/builder/i18n/it.ts +3461 -0
  49. package/src/builder/i18n/ja.ts +3461 -0
  50. package/src/builder/i18n/ko.ts +3461 -0
  51. package/src/builder/i18n/nl.ts +3461 -0
  52. package/src/builder/i18n/pt.ts +3461 -0
  53. package/src/builder/i18n/zh-cn.ts +3461 -0
  54. package/src/builder/i18n/zh-tw.ts +3461 -0
  55. package/src/builder/i18n.ts +17 -3
  56. package/src/builder/json-sources.ts +63 -62
  57. package/src/builder/layouts.ts +12 -11
  58. package/src/builder/locks.ts +29 -28
  59. package/src/builder/login-page.ts +26 -25
  60. package/src/builder/mail.ts +148 -147
  61. package/src/builder/newapp.ts +15 -12
  62. package/src/builder/notification-settings.ts +27 -0
  63. package/src/builder/plugins.ts +47 -44
  64. package/src/builder/process-jobs.ts +11 -10
  65. package/src/builder/pwa.ts +22 -21
  66. package/src/builder/remote-databases.ts +55 -56
  67. package/src/builder/report-settings.ts +1 -1
  68. package/src/builder/reporter.ts +43 -42
  69. package/src/builder/rest-catalogs.ts +87 -86
  70. package/src/builder/rest.ts +9 -8
  71. package/src/builder/routes.ts +30 -27
  72. package/src/builder/search.ts +14 -13
  73. package/src/builder/static-files.ts +31 -32
  74. package/src/builder/subscriptions.ts +47 -45
  75. package/src/builder/supporting.ts +23 -18
  76. package/src/builder/team.ts +179 -179
  77. package/src/builder/telemetry.ts +59 -62
  78. package/src/builder/templates.ts +66 -65
  79. package/src/builder/themeroller.ts +60 -63
  80. package/src/builder/ui-defaults.ts +21 -20
  81. package/src/builder/ui.ts +2 -1
  82. package/src/builder/upgrade.ts +25 -24
  83. package/src/builder/utilities.ts +48 -47
  84. package/src/builder/vector-providers.ts +67 -66
  85. package/src/builder/websources.ts +43 -42
  86. package/src/builder/wizards.ts +95 -89
  87. package/src/builder/workflow-migrate.ts +30 -31
  88. package/src/builder/workflows.ts +40 -39
  89. package/src/builder/workingcopies.ts +41 -49
  90. package/src/builder/workspace-backups.ts +21 -20
  91. package/src/builder/workspace-mail.ts +17 -16
  92. package/src/builder/workspace-resources.ts +104 -112
  93. package/src/builder/workspace-transfer.ts +34 -26
  94. package/src/cli/replace.ts +2 -0
  95. package/src/i18n/ar.ts +9 -0
  96. package/src/i18n/cs.ts +9 -0
  97. package/src/i18n/da.ts +9 -0
  98. package/src/i18n/de.ts +9 -0
  99. package/src/i18n/el.ts +9 -0
  100. package/src/i18n/es.ts +9 -0
  101. package/src/i18n/fi.ts +9 -0
  102. package/src/i18n/fr.ts +9 -0
  103. package/src/i18n/he.ts +9 -0
  104. package/src/i18n/it.ts +9 -0
  105. package/src/i18n/ja.ts +9 -0
  106. package/src/i18n/ko.ts +9 -0
  107. package/src/i18n/nb.ts +9 -0
  108. package/src/i18n/pl.ts +9 -0
  109. package/src/i18n/pt.ts +9 -0
  110. package/src/i18n/ru.ts +9 -0
  111. package/src/i18n/sv.ts +9 -0
  112. package/src/i18n/tr.ts +9 -0
  113. package/src/i18n/uk.ts +9 -0
  114. package/src/i18n/zh.ts +9 -0
  115. package/src/i18n.ts +18 -0
  116. package/src/metadata.ts +9 -3
  117. package/src/runtime/engine.ts +2 -1
  118. package/src/runtime/feedback.ts +1 -1
  119. package/src/runtime/lists.ts +43 -11
  120. package/src/runtime/notifications.ts +237 -0
  121. package/src/runtime/processes.ts +45 -1
  122. package/src/runtime/regions.ts +11 -15
  123. package/src/runtime/render.ts +12 -3
  124. package/src/runtime/routes.ts +12 -4
  125. package/src/runtime/signin.ts +2 -1
  126. package/src/session.ts +13 -4
@@ -22,6 +22,7 @@ import { preview } from './dataload.ts';
22
22
  import { finishGeneratedApp, createApp, createAppError, type CheckedApp } from './newapp.ts';
23
23
  import { groupColumn } from './appfromfile.ts';
24
24
  import { region, type Body } from './ui.ts';
25
+ import { scopedTx as tx } from './i18n.ts';
25
26
 
26
27
  // Create → From a file with several sheets or tables (APEX: Create App from a
27
28
  // File, several sheets). An Excel workbook with several sheets, or a JSON
@@ -66,7 +67,7 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
66
67
  try {
67
68
  book = await readXlsxFile<string>(data, { parseNumber: (s) => s, trim: true });
68
69
  } catch (e) {
69
- throw new LoadError(`This is not a readable Excel (.xlsx) file: ${(e as Error).message}`);
70
+ throw new LoadError(tx('ash.bad_xlsx', { error: (e as Error).message }));
70
71
  }
71
72
  const sheets: NamedSheet[] = [];
72
73
  let total = 0;
@@ -77,16 +78,16 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
77
78
  try {
78
79
  sheet = tableSheet('xlsx', rows, headers);
79
80
  } catch (e) {
80
- if (e instanceof LoadError) throw new LoadError(`Sheet ${s.sheet}: ${e.message}`);
81
+ if (e instanceof LoadError) throw new LoadError(tx('ash.sheet_error', { sheet: s.sheet, error: e.message }));
81
82
  throw e;
82
83
  }
83
84
  if (!sheet.rows.length) continue;
84
85
  total += sheet.rows.length;
85
86
  sheets.push({ ...sheet, name: s.sheet });
86
87
  }
87
- if (sheets.length > MAX_SHEETS) throw new LoadError(`The workbook has ${sheets.length} sheets with rows; at most ${MAX_SHEETS} become tables at once.`);
88
- if (total > MAX_ROWS) throw new LoadError(`The workbook has ${total} rows; at most ${MAX_ROWS} can be loaded at once.`);
89
- if (!sheets.length) throw new LoadError('The file has column names but no rows.');
88
+ if (sheets.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_sheets', { n: sheets.length, max: MAX_SHEETS }));
89
+ if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows_wb', { n: total, max: MAX_ROWS }));
90
+ if (!sheets.length) throw new LoadError(tx('ash.the_file_has_column_names'));
90
91
  return sheets;
91
92
  }
92
93
  if (/\.json$/i.test(filename) || /^\s*\{/.test(data.subarray(0, 64).toString('utf8').replace(/^/, ''))) {
@@ -101,9 +102,9 @@ export async function parseBook(filename: string, data: Buffer, { headers = true
101
102
  ([, v]) => Array.isArray(v) && v.length && v.every((r) => r && typeof r === 'object' && !Array.isArray(r)),
102
103
  ) as [string, unknown[]][];
103
104
  if (arrays.length > 1) {
104
- if (arrays.length > MAX_SHEETS) throw new LoadError(`The JSON has ${arrays.length} arrays; at most ${MAX_SHEETS} become tables at once.`);
105
+ if (arrays.length > MAX_SHEETS) throw new LoadError(tx('ash.too_many_arrays', { n: arrays.length, max: MAX_SHEETS }));
105
106
  const total = arrays.reduce((n, [, v]) => n + v.length, 0);
106
- if (total > MAX_ROWS) throw new LoadError(`The file has ${total} rows; at most ${MAX_ROWS} can be loaded at once.`);
107
+ if (total > MAX_ROWS) throw new LoadError(tx('ash.too_many_rows', { n: total, max: MAX_ROWS }));
107
108
  return arrays.map(([name, v]) => ({ ...parseJson(JSON.stringify(v)), name }));
108
109
  }
109
110
  }
@@ -235,18 +236,18 @@ export function fkChecked(f: ForeignKey, b: Body | null) {
235
236
  /** CREATE TABLE for a planned table: an integer key column becomes an identity primary key; another key is unique and not null next to a new identity id. */
236
237
  export function plannedTableSql(schema: string, p: TablePlan) {
237
238
  const valid = /^[a-z_][a-z0-9_]{0,62}$/;
238
- const where = `Sheet ${p.sheet.name}`;
239
- if (!valid.test(p.table)) throw new LoadError(`${where}: the table name must be lower-case (letters, digits and _) without a schema; the table goes into the application's schema.`);
240
- if (!p.cols.length) throw new LoadError(`${where}: give at least one column a name.`);
239
+ const where = tx('ash.sheet', { name: p.sheet.name });
240
+ if (!valid.test(p.table)) throw new LoadError(tx('ash.bad_table_name', { where }));
241
+ if (!p.cols.length) throw new LoadError(tx('ash.no_columns', { where }));
241
242
  for (const c of p.cols) {
242
- if (!valid.test(c.name)) throw new LoadError(`${where}: "${c.name}" is not a valid column name.`);
243
- if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(`${where}: unknown column type ${c.type}.`);
243
+ if (!valid.test(c.name)) throw new LoadError(tx('ash.bad_column', { where, col: c.name }));
244
+ if (!COLUMN_TYPES.includes(c.type)) throw new LoadError(tx('ash.bad_type', { where, type: c.type }));
244
245
  }
245
- if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(`${where}: column names must be unique.`);
246
+ if (new Set(p.cols.map((c) => c.name)).size !== p.cols.length) throw new LoadError(tx('ash.dup_columns', { where }));
246
247
  const ident = pg.escapeIdentifier;
247
248
  const defs: string[] = [];
248
249
  if (!identityKey(p)) {
249
- if (p.cols.some((c) => c.name === 'id')) throw new LoadError(`${where}: "id" is the name of the new key column; rename the file column or choose it as the primary key.`);
250
+ if (p.cols.some((c) => c.name === 'id')) throw new LoadError(tx('ash.id_taken', { where }));
250
251
  defs.push('id bigint generated by default as identity primary key');
251
252
  }
252
253
  for (const c of p.cols) {
@@ -366,9 +367,9 @@ export async function addDashboard(c: Client, alias: string, tables: string[], p
366
367
  export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet[], b: Body): Promise<BuiltBook> {
367
368
  const v = (name: string) => String(b[name] ?? '');
368
369
  const plans = planFrom(sheets, v).filter((p) => p.on);
369
- if (!plans.length) throw new LoadError('Choose at least one sheet.');
370
+ if (!plans.length) throw new LoadError(tx('ash.choose_at_least_one_sheet'));
370
371
  const ddl = plans.map((p) => plannedTableSql(checked.schema, p));
371
- if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError('Each sheet needs a table name of its own.');
372
+ if (new Set(plans.map((p) => p.table)).size !== plans.length) throw new LoadError(tx('ash.each_sheet_needs_a_table'));
372
373
  // only proposed foreign keys can be chosen (recomputed from the posted names and types)
373
374
  const fks = proposeForeignKeys(plans).filter((f) => v(fkName(f)) === String(f.to));
374
375
  const headers = b.h !== '0';
@@ -381,7 +382,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
381
382
  const tables: BuiltBook['tables'] = [];
382
383
  for (const [n, p] of plans.entries()) {
383
384
  await c.query(ddl[n]).catch((e) => {
384
- throw new LoadError((e as { code?: string }).code === '42P07' ? `The table ${checked.schema}.${p.table} already exists: choose another name.` : (e as Error).message);
385
+ throw new LoadError((e as { code?: string }).code === '42P07' ? tx('ash.table_exists', { table: `${checked.schema}.${p.table}` }) : (e as Error).message);
385
386
  });
386
387
  const T = (await c.query('select $1::regclass::text as t', [`${pg.escapeIdentifier(checked.schema)}.${pg.escapeIdentifier(p.table)}`])).rows[0].t as string;
387
388
  qualified.set(p.k, T);
@@ -413,7 +414,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
413
414
  const ident = pg.escapeIdentifier;
414
415
  await c.query(`create index on ${A} (${ident(col)})`);
415
416
  await c.query(`alter table ${A} add foreign key (${ident(col)}) references ${B} (${ident(key)})`).catch((e) => {
416
- throw new LoadError(`Foreign key ${a.table}.${col} → ${to.table}.${key}: ${(e as { detail?: string }).detail ?? (e as Error).message} Untick it, or fix the data.`);
417
+ throw new LoadError(tx('ash.fk_failed', { fk: `${a.table}.${col} → ${to.table}.${key}`, error: (e as { detail?: string }).detail ?? (e as Error).message }));
417
418
  });
418
419
  foreignKeys.push(`${a.table}.${col} → ${to.table}.${key}`);
419
420
  }
@@ -432,7 +433,7 @@ export async function buildAppFromSheets(checked: CheckedApp, sheets: NamedSheet
432
433
  if (b.chart === 'true') {
433
434
  const dash = await addDashboard(c, checked.alias, [...qualified.values()], next);
434
435
  if (dash) pages.push(dash);
435
- else notes.push('No dashboard: no table has a foreign key or a text, yes/no or date column with a few values that repeat, to count the rows by.');
436
+ else notes.push(tx('ash.no_dashboard'));
436
437
  }
437
438
  await finishGeneratedApp(c, app.id, { description: checked.description });
438
439
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, tables, foreignKeys, pages, notes };
@@ -462,22 +463,22 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
462
463
  const sections = sheets.map((sheet, k) => {
463
464
  const id = field(k, 'table');
464
465
  return region(
465
- `Sheet ${sheet.name}`,
466
- html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true', `Create a table from this sheet (${sheet.rows.length} row(s), ${sheet.headers.length} column(s))`)}</div>
466
+ tx('ash.sheet', { name: sheet.name }),
467
+ html`<div class="field">${checkbox(field(k, 'on'), 'true', v(field(k, 'on')) === 'true', tx('ash.create_from_sheet', { rows: sheet.rows.length, cols: sheet.headers.length }))}</div>
467
468
  <div class="form-grid">
468
- <div class="field"><label class="label" for="f_${id}">Table name</label><input id="f_${id}" name="${id}" value="${v(id)}"></div>
469
- <div class="field"><label class="label" for="f_${field(k, 'key')}">Primary key</label>
470
- <select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', '- a new column id (identity) -'], ...sheet.headers.map((h, i): [string, string] => [String(i), h])].map(
469
+ <div class="field"><label class="label" for="f_${id}">${tx('ash.table_name')}</label><input id="f_${id}" name="${id}" value="${v(id)}"></div>
470
+ <div class="field"><label class="label" for="f_${field(k, 'key')}">${tx('ash.primary_key')}</label>
471
+ <select id="f_${field(k, 'key')}" name="${field(k, 'key')}">${[['', tx('ash.a_new_column_id_identity')], ...sheet.headers.map((h, i): [string, string] => [String(i), h])].map(
471
472
  ([value, label]) => html`<option value="${value}"${v(field(k, 'key')) === value ? raw(' selected') : ''}>${label}</option>`,
472
473
  )}</select>
473
- <small class="help">A whole-number key column becomes an identity key; another key column is unique, next to a new id.</small></div>
474
+ <small class="help">${tx('ash.a_whole_number_key_column')}</small></div>
474
475
  </div>
475
- <div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">File column</th><th scope="col">Values</th><th scope="col">Column name (empty = skip)</th><th scope="col">Type</th></tr></thead><tbody>
476
+ <div class="table-wrap"><table class="report ff-cols"><thead><tr><th scope="col">${tx('ash.file_column')}</th><th scope="col">${tx('ash.values')}</th><th scope="col">${tx('ash.col_name_head')}</th><th scope="col">${tx('ash.type')}</th></tr></thead><tbody>
476
477
  ${sheet.headers.map((h, i) => {
477
478
  const type = v(field(k, `type_${i}`));
478
479
  return html`<tr><td>${h}</td><td class="muted small ff-sample">${sample(sheet, i)}</td>
479
- <td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="Column name for ${h} (${sheet.name})"></td>
480
- <td><select name="${field(k, `type_${i}`)}" aria-label="Type of ${h} (${sheet.name})">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
480
+ <td><input name="${field(k, `name_${i}`)}" value="${v(field(k, `name_${i}`))}" aria-label="${tx('ash.col_name_for', { col: h, sheet: sheet.name })}"></td>
481
+ <td><select name="${field(k, `type_${i}`)}" aria-label="${tx('ash.type_of', { col: h, sheet: sheet.name })}">${COLUMN_TYPES.map((t) => html`<option value="${t}"${t === type ? raw(' selected') : ''}>${t}</option>`)}</select></td></tr>`;
481
482
  })}
482
483
  </tbody></table></div>
483
484
  <details class="ff-preview"><summary>Preview${sheet.rows.length > 10 ? ' (first 10 rows)' : ''}</summary>${preview(sheet)}</details>`,
@@ -491,11 +492,11 @@ export function sheetsForm(sheets: NamedSheet[], b: Body | null): Raw {
491
492
  const fkList = fks.length
492
493
  ? html`<ul class="ff-fks">${fks.map(
493
494
  (f) => html`<li><input type="hidden" name="fkp_${f.from}_${f.col}" value="${String(f.to)}">${checkbox(fkName(f), String(f.to), fkChecked(f, b), html`${name(f.from, f.col)} → ${keyName(f.to)}`)}
494
- ${f.missing ? html`<small class="help">${f.missing} row(s) have a value that ${keyName(f.to)} doesn't have.</small>` : ''}</li>`,
495
+ ${f.missing ? html`<small class="help">${tx('ash.missing', { n: f.missing, key: keyName(f.to) })}</small>` : ''}</li>`,
495
496
  )}</ul>`
496
- : html`<p class="muted">No column matches another table's key. A column matches when it is named like the key column (dept_id), or like the other table and its key (department_id, department_code), with the same type.</p>`;
497
+ : html`<p class="muted">${tx('ash.no_column_matches_another_table')}</p>`;
497
498
  return html`${sections.map((x) => html`${x}<div class="u-spacer"></div>`)}
498
- ${region('Foreign keys', html`<p class="muted u-mt0">Proposed where a column matches another table's primary key; ticked when every value is found. Forms get a select list for each foreign key.</p>
499
+ ${region(tx('ash.foreign_keys'), html`<p class="muted u-mt0">${tx('ash.fk_intro')}</p>
499
500
  ${fkList}
500
- <div class="buttons"><button class="btn" name="action" value="preview">Update the proposals</button></div>`)}`;
501
+ <div class="buttons"><button class="btn" name="action" value="preview">${tx('ash.update_the_proposals')}</button></div>`)}`;
501
502
  }
@@ -7,7 +7,7 @@ import { LoadError } from '../dataload.ts';
7
7
  import type { Session } from '../session.ts';
8
8
  import { aliasFor, check, ITEM, type Created } from './appfromfile.ts';
9
9
  import { addDashboard, parseBook } from './appsheets.ts';
10
- import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
10
+ import { finishGeneratedApp, checkNewApp, createApp, createAppError, featuresFrom, featureText, FEATURES, reservedSchema, startFromBoilerplate, DEFAULT_APP_ICON, type CheckedApp, type Features } from './newapp.ts';
11
11
  import { currentWorkspace } from './workspaces.ts';
12
12
  import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
13
13
  import { iconPicker } from './forms.ts';
@@ -16,6 +16,7 @@ import { BASE_STYLES } from '../runtime/styles.ts';
16
16
  import { LANGUAGE_NAMES } from '../i18n.ts';
17
17
  import { secretKeyConfigured } from '../secrets.ts';
18
18
  import { appPublicKey } from '../push.ts';
19
+ import { scopedTx as tx } from './i18n.ts';
19
20
 
20
21
  // Two more ways into the create application wizard:
21
22
  // - From pasted data (APEX: Create App from a File → Copy and Paste): CSV or
@@ -110,7 +111,7 @@ export interface Plan {
110
111
  /** Create the application on existing tables and views (each with its page type) and the features, in one transaction. */
111
112
  export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], chart: boolean, features: Partial<Features> = {}): Promise<Created> {
112
113
  plans = plans.filter((p) => p.kind);
113
- if (plans.length > MAX_TABLES) throw new LoadError(`Choose at most ${MAX_TABLES} tables and views.`);
114
+ if (plans.length > MAX_TABLES) throw new LoadError(tx('aw.at_most_tables', { n: MAX_TABLES }));
114
115
  for (const p of plans) if (!kindsFor(p.rel).includes(p.kind)) throw new LoadError(`${p.rel.relname} can't have the page type ${PAGE_KINDS[p.kind] ?? p.kind}.`);
115
116
  return owner.tx(async (c) => {
116
117
  const app = await createApp(c, checked).catch((e) => {
@@ -157,9 +158,9 @@ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], cha
157
158
  const skipped: string[] = [];
158
159
  const dash = await addDashboard(c, checked.alias, tables, next, skipped);
159
160
  if (dash) pages.push(dash);
160
- else notes.push('No dashboard: no table has a foreign key, or a text, yes/no or date column whose statistics show a few values that repeat (ANALYZE the tables first).');
161
+ else notes.push(tx('aw.no_dashboard'));
161
162
  if (skipped.length)
162
- notes.push(`Not on the dashboard: ${skipped.map((t) => t.replace(/^.*\./, '').replace(/"/g, '')).join(', ')} (row level security: the application's role may see none of the rows).`);
163
+ notes.push(tx('aw.not_on_dashboard', { tables: skipped.map((t) => t.replace(/^.*\./, '').replace(/"/g, '')).join(', ') }));
163
164
  }
164
165
  await finishGeneratedApp(c, app.id, { description: checked.description, features, notes });
165
166
  return { app: { id: app.id, alias: checked.alias, existingAccount: app.existingAccount }, pages, notes };
@@ -168,28 +169,27 @@ export async function buildAppFromTables(checked: CheckedApp, plans: Plan[], cha
168
169
 
169
170
  export async function appWizardRoutes(app: FastifyInstance) {
170
171
  const page = (s: Session, reply: FastifyReply, title: string, crumb: string, main: Raw) =>
171
- send(reply, s, shell(s, title, [['App Builder', BASE], ['Create', `${BASE}/create`], [crumb]], html`<div class="ab-narrow">${main}</div>`));
172
+ send(reply, s, shell(s, title, [[tx('aw.app_builder'), BASE], [tx('aw.create'), `${BASE}/create`], [crumb]], html`<div class="ab-narrow">${main}</div>`));
172
173
 
173
174
  // ---------------------------------------------------------------- pasted data
174
175
  const pasteForm = (s: Session, b: Body = {}, error?: string) =>
175
- html`<h1>Create an application from pasted data</h1>
176
- <p class="muted">Paste rows copied from a spreadsheet, or CSV or TSV text: the delimiter (tab, comma, semicolon or |) is detected. Next you check the proposed table and its columns, as for a file.
177
- <a href="${BASE}/create/file">Upload a file</a> instead.</p>
178
- ${region('Data', html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
176
+ html`<h1>${tx('aw.create_an_application_from_pasted')}</h1>
177
+ <p class="muted">${tx.rich('aw.paste_intro', { link: html`<a href="${BASE}/create/file">${tx('aw.upload_a_file')}</a>` })}</p>
178
+ ${region(tx('aw.data'), html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
179
179
  <form method="post" action="${BASE}/create/paste">${csrf(s)}
180
180
  <div class="form-grid">
181
- ${input('title', 'Name', b.title ?? 'Pasted data', { help: 'Names the application and the table (you can change both in the next step).' })}
182
- <div class="field" data-wide><label class="label" for="f_data">Data</label>
181
+ ${input('title', tx('aw.name'), b.title ?? tx('aw.pasted_data'), { help: tx('aw.names_the_application_and_the') })}
182
+ <div class="field" data-wide><label class="label" for="f_data">${tx('aw.data')}</label>
183
183
  <textarea id="f_data" name="data" class="code" rows="12" required spellcheck="false" placeholder="name,city,joined&#10;Ann,Utrecht,2026-01-05">${b.data ?? ''}</textarea></div>
184
- <div class="field"><label class="check"><input type="checkbox" name="headers" value="true"${b.headers === undefined || b.headers === 'true' ? raw(' checked') : ''}> First row contains column names</label></div>
184
+ <div class="field"><label class="check"><input type="checkbox" name="headers" value="true"${b.headers === undefined || b.headers === 'true' ? raw(' checked') : ''}> ${tx('aw.first_row_contains_column_names')}</label></div>
185
185
  </div>
186
- <div class="buttons"><a class="btn" href="${BASE}/create">Cancel</a><button class="btn btn-hot">${icon('upload')} Next</button></div>
186
+ <div class="buttons"><a class="btn" href="${BASE}/create">${tx('aw.cancel')}</a><button class="btn btn-hot">${icon('upload')} ${tx('aw.next')}</button></div>
187
187
  </form>`)}`;
188
188
 
189
189
  app.get(`${BASE}/create/paste`, async (req: Req, reply) => {
190
190
  const s = await developer(req, reply);
191
191
  if (!s) return;
192
- return page(s, reply, 'Create application from pasted data', 'Pasted data', pasteForm(s));
192
+ return page(s, reply, tx('aw.paste_title'), tx('aw.pasted_data'), pasteForm(s));
193
193
  });
194
194
 
195
195
  app.post(`${BASE}/create/paste`, async (req: Req, reply) => {
@@ -198,19 +198,19 @@ export async function appWizardRoutes(app: FastifyInstance) {
198
198
  const b = req.body ?? {};
199
199
  const fail = (message: string) => {
200
200
  reply.code(422);
201
- return page(s, reply, 'Create application from pasted data', 'Pasted data', pasteForm(s, { ...b, headers: b.headers === 'true' ? 'true' : 'false' }, message));
201
+ return page(s, reply, tx('aw.paste_title'), tx('aw.pasted_data'), pasteForm(s, { ...b, headers: b.headers === 'true' ? 'true' : 'false' }, message));
202
202
  };
203
203
  const text = typeof b.data === 'string' ? b.data : '';
204
- if (!text.trim()) return fail('Paste the data.');
204
+ if (!text.trim()) return fail(tx('aw.paste_the_data'));
205
205
  const data = Buffer.from(text, 'utf8');
206
- if (data.length > MAX_PASTE) return fail(`At most ${MAX_PASTE / 1024 / 1024} MB of text can be pasted; upload a file instead.`);
206
+ if (data.length > MAX_PASTE) return fail(tx('aw.paste_max', { mb: MAX_PASTE / 1024 / 1024 }));
207
207
  const filename = pastedName(String(b.title ?? ''));
208
208
  const headers = b.headers === 'true';
209
209
  try {
210
210
  // read as delimited text whatever it looks like
211
211
  const sheets = await parseBook(filename, data, { headers });
212
- if (sheets[0].format !== 'csv') throw new LoadError('Paste delimited text (CSV or TSV); upload JSON, XML or Excel files with From a file.');
213
- if (!sheets[0].rows.length) throw new LoadError('The data has column names but no rows.');
212
+ if (sheets[0].format !== 'csv') throw new LoadError(tx('aw.paste_delimited_text_csv_or'));
213
+ if (!sheets[0].rows.length) throw new LoadError(tx('aw.the_data_has_column_names'));
214
214
  } catch (e) {
215
215
  if (e instanceof LoadError) return fail(e.message);
216
216
  throw e;
@@ -253,13 +253,13 @@ export async function appWizardRoutes(app: FastifyInstance) {
253
253
  let built: Created;
254
254
  let pushKey = false;
255
255
  try {
256
- if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError('Choose one of the schemas in the list.');
256
+ if (schema && (reservedSchema(schema) || !(await appSchemas()).includes(schema))) throw new LoadError(tx('aw.choose_one_of_the_schemas'));
257
257
  const checked = await checkNewApp({ ...b, schema }, currentWorkspace(s)).catch((e) => {
258
258
  throw new LoadError((e as Error).message);
259
259
  });
260
260
  const features = featuresFrom(b, b.features === '1');
261
261
  const boilerplate = /^\d{1,9}$/.test(b.boilerplate ?? '') ? Number(b.boilerplate) : null;
262
- if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError('Choose a boilerplate application (an application of type Boilerplate).');
262
+ if (boilerplate && !(await boilerplates(s)).some((x) => x.id === boilerplate)) throw new LoadError(tx('aw.choose_a_boilerplate_application_an'));
263
263
  // the tables of the form: only names of this schema's tables and views count (looked up again, never used as SQL)
264
264
  const relations = schema ? await schemaRelations(schema) : [];
265
265
  const plans: Plan[] = [];
@@ -272,8 +272,8 @@ export async function appWizardRoutes(app: FastifyInstance) {
272
272
  if (kind) plans.push({ rel, kind });
273
273
  }
274
274
  const askedTables = fromTables || b.tables === '1' || Object.keys(b).some((k) => /^t_\d+$/.test(k));
275
- if (askedTables && !plans.length) throw new LoadError('Choose at least one table or view.');
276
- if (plans.length && boilerplate) throw new LoadError('A boilerplate application brings its own pages: choose no tables, or no boilerplate.');
275
+ if (askedTables && !plans.length) throw new LoadError(tx('aw.choose_at_least_one_table'));
276
+ if (plans.length && boilerplate) throw new LoadError(tx('aw.a_boilerplate_application_brings_its'));
277
277
  if (plans.length) built = await buildAppFromTables(checked, plans, b.chart === 'true', features);
278
278
  else {
279
279
  built = await owner.tx(async (c) => {
@@ -294,9 +294,9 @@ export async function appWizardRoutes(app: FastifyInstance) {
294
294
  // the push key of the new application, after the commit
295
295
  if (pushKey && secretKeyConfigured()) await appPublicKey(built.app.id).catch(() => undefined);
296
296
  // (sprint 73, APEX: straight to the new application's home) the result as a message on its page
297
- const pages = built.pages.length ? ` with ${built.pages.length} page(s) besides Home` : '';
298
- const account = built.app.existingAccount ? ` The existing account ${String(b.admin_user ?? '').trim()} got the admin role (its password was not changed).` : '';
299
- flash(s, `Application ${built.app.alias} created${pages}.${account}${built.notes.length ? ` ${built.notes.join(' ')}` : ''}`);
297
+ const created = built.pages.length ? tx('aw.created_pages', { alias: built.app.alias, n: built.pages.length }) : tx('aw.created', { alias: built.app.alias });
298
+ const account = built.app.existingAccount ? ` ${tx('aw.existing_account', { user: String(b.admin_user ?? '').trim() })}` : '';
299
+ flash(s, `${created}${account}${built.notes.length ? ` ${built.notes.join(' ')}` : ''}`);
300
300
  return back(reply, s, `${BASE}/apps/${built.app.id}`);
301
301
  };
302
302
  app.post(`${BASE}/apps`, (req: Req, reply) => create(req, reply));
@@ -322,8 +322,8 @@ const SOURCES: [string, string, string, string][] = [
322
322
  const rlsBadge = (r: Relation) =>
323
323
  !r.rls ? ''
324
324
  : r.policies > 0
325
- ? html` <span class="tag aw-rls" title="Row level security is on (${r.policies} ${r.policies === 1 ? 'policy' : 'policies'}): each user sees only the rows the policies allow">${icon('shield')} RLS · ${r.policies}</span>`
326
- : html` <span class="tag tag-error aw-rls" title="Row level security is on without policies: the application's role sees no rows (unless it owns the table or bypasses row level security)">${icon('shield')} RLS, no policies</span>`;
325
+ ? html` <span class="tag aw-rls" title="${r.policies === 1 ? tx('aw.rls_on_1') : tx('aw.rls_on', { n: r.policies })}">${icon('shield')} RLS · ${r.policies}</span>`
326
+ : html` <span class="tag tag-error aw-rls" title="${tx('aw.row_level_security_is_on')}">${icon('shield')} ${tx('aw.rls_no_policies')}</span>`;
327
327
 
328
328
  async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
329
329
  const schemas = await appSchemas();
@@ -335,62 +335,62 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
335
335
  // (sprint 73) the name proposed from the schema is selected on first focus, so typing replaces it (app.js data-select-once)
336
336
  const suggested = known ? schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase()) : '';
337
337
  const kindOf = (r: Relation, i: number) => (b ? (b[`t_${i}`] === r.relname ? (b[`k_${i}`] === undefined ? defaultKind(r) : String(b[`k_${i}`])) : '') : defaultKind(r));
338
- const rows = (r: Relation) => (r.rows === null ? 'not analyzed' : `about ${Number(r.rows).toLocaleString('en')} row(s)`);
338
+ const rows = (r: Relation) => (r.rows === null ? tx('aw.not_analyzed') : tx('aw.about_rows', { n: Number(r.rows).toLocaleString('en') }));
339
339
  const main = html`<div class="ab-narrow create-app">
340
- <h1>Create an application</h1>
341
- <nav class="create-sources" aria-label="Other ways to start">${SOURCES.map(
342
- ([path, ic, label, help]) => html`<a class="create-source" href="${BASE}/${path}">${icon(ic)}<span><b>${label}</b><small class="muted">${help}</small></span></a>`,
340
+ <h1>${tx('aw.create_an_application')}</h1>
341
+ <nav class="create-sources" aria-label="${tx('aw.other_ways_to_start')}">${SOURCES.map(
342
+ ([path, ic, label, help]) => html`<a class="create-source" href="${BASE}/${path}">${icon(ic)}<span><b>${tx.dyn(`aw.src.${path.replace(/^create\//, '')}`, label)}</b><small class="muted">${tx.dyn(`aw.srch.${path.replace(/^create\//, '')}`, help)}</small></span></a>`,
343
343
  )}</nav>
344
344
  ${message}
345
- ${schema && !known && !message ? html`<div class="alert alert-error" role="alert">Choose one of the schemas in the list.</div>` : ''}
346
- ${region('Data', html`<form method="get" action="${BASE}/create" class="create-schema">
347
- <div class="form-grid">${select('schema', 'Pages on the tables of', schema, [['', '- a new, empty schema (Home only) -'], ...schemas.map((x): [string, string] => [x, x])],
348
- 'The application\'s parsing schema. Choose a schema to get a page per table and view; a new schema is named after the alias.')}</div>
349
- <div class="buttons"><button class="btn">${icon('search')} Show its tables</button></div></form>`)}
345
+ ${schema && !known && !message ? html`<div class="alert alert-error" role="alert">${tx('aw.choose_one_of_the_schemas')}</div>` : ''}
346
+ ${region(tx('aw.data'), html`<form method="get" action="${BASE}/create" class="create-schema">
347
+ <div class="form-grid">${select('schema', tx('aw.pages_on_the_tables_of'), schema, [['', tx('aw.a_new_empty_schema_home')], ...schemas.map((x): [string, string] => [x, x])],
348
+ tx('aw.schema_help'))}</div>
349
+ <div class="buttons"><button class="btn">${icon('search')} ${tx('aw.show_its_tables')}</button></div></form>`)}
350
350
  <form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
351
351
  <input type="hidden" name="features" value="1">
352
352
  <div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
353
- ${region('Application', html`<div class="form-grid">
354
- <div class="field"><label class="label" for="f_name">Name</label>
353
+ ${region(tx('aw.application'), html`<div class="form-grid">
354
+ <div class="field"><label class="label" for="f_name">${tx('aw.name')}</label>
355
355
  <input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
356
- ${input('description', 'Description', v('description', ''), { placeholder: 'One line for the home and About pages' })}
357
- ${iconPicker('f_icon', 'icon', 'Icon', v('icon', DEFAULT_APP_ICON))}
356
+ ${input('description', tx('aw.description'), v('description', ''), { placeholder: tx('aw.one_line_for_the_home') })}
357
+ ${iconPicker('f_icon', 'icon', tx('aw.icon'), v('icon', DEFAULT_APP_ICON))}
358
358
  </div>`)}
359
- ${region('Appearance', html`<div class="form-grid">
360
- ${select('base', 'Theme style', v('base', 'iris'), [['iris', 'Iris (light body, dark header and menu)'], ['lavender', 'Lavender (rounded, indigo)'], ['standard', 'Standard (blue, navy header)']])}
361
- ${select('nav', 'Navigation', v('nav', 'side'), [['side', 'Side menu'], ['top', 'Top menu']])}
362
- ${input('accent', 'Accent colour', v('accent', BASE_STYLES.iris.accent), { type: 'color', help: 'Of the theme style; the default stores nothing.' })}
359
+ ${region(tx('aw.appearance'), html`<div class="form-grid">
360
+ ${select('base', tx('aw.theme_style'), v('base', 'iris'), [['iris', tx('aw.iris_light_body_dark_header')], ['lavender', tx('aw.lavender_rounded_indigo')], ['standard', tx('aw.standard_blue_navy_header')]])}
361
+ ${select('nav', tx('aw.navigation'), v('nav', 'side'), [['side', tx('aw.side_menu')], ['top', tx('aw.top_menu')]])}
362
+ ${input('accent', tx('aw.accent_colour'), v('accent', BASE_STYLES.iris.accent), { type: 'color', help: tx('aw.of_the_theme_style_the') })}
363
363
  </div>`)}
364
- ${region('Pages', html`<ul class="aw-pages">
365
- <li><span class="aw-page-kind">${icon('home')} Home</span><span class="muted">a hero with the name and icon, and a card per page</span></li>
364
+ ${region(tx('aw.pages'), html`<ul class="aw-pages">
365
+ <li><span class="aw-page-kind">${icon('home')} ${tx('aw.home')}</span><span class="muted">${tx('aw.a_hero_with_the_name')}</span></li>
366
366
  ${known
367
367
  ? relations.length
368
368
  ? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
369
369
  <label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
370
- <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
371
- <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small>${rlsBadge(r)}</li>`)
372
- : html`<li class="muted">${schema} has no tables or views.</li>`
373
- : html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
370
+ <select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
371
+ <small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? tx('aw.view') : rows(r)}${r.kind === 'table' && !r.pk ? tx('aw.no_key') : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? tx('aw.paged_grid') : ''}</small>${rlsBadge(r)}</li>`)
372
+ : html`<li class="muted">${tx('aw.no_relations', { schema })}</li>`
373
+ : html`<li class="muted">${tx('aw.choose_a_schema_under_data')}</li>`}
374
374
  </ul>
375
- ${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
376
- ${region('Features', html`<div class="aw-features">${FEATURES.map(([k, label, help]) => check(`f_${k}`, label, features[k], help))}</div>`)}
377
- ${region('Settings', html`<div class="form-grid">
378
- <div class="field"><label class="label" for="f_alias">Alias (URL)</label>
375
+ ${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
376
+ ${region(tx('aw.features'), html`<div class="aw-features">${FEATURES.map(([k, label, help]) => { const [l, h] = featureText(k, label, help); return check(`f_${k}`, l, features[k], h); })}</div>`)}
377
+ ${region(tx('aw.settings'), html`<div class="form-grid">
378
+ <div class="field"><label class="label" for="f_alias">${tx('aw.alias_url')}</label>
379
379
  <input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
380
- <small class="help">lowercase, e.g. inventory → /a/inventory; follows the name until you change it</small></div>
381
- ${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
382
- ${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
383
- ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
384
- ${select('language', 'Language', v('language', 'en'), Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
380
+ <small class="help">${tx('aw.lowercase_e_g_inventory_a')}</small></div>
381
+ ${select('authentication', tx('aw.authentication'), v('authentication', 'app_users'), [['app_users', tx('aw.app_users_login_page')], ['none', tx('aw.none_public')]])}
382
+ ${input('admin_user', tx('aw.first_user'), v('admin_user', ''), { placeholder: tx('aw.e_g_your_name'), help: tx('aw.gets_the_admin_role_an') })}
383
+ ${input('admin_password', tx('aw.password'), '', { type: 'password', auto: 'new-password', help: tx('aw.for_a_new_account_at') })}
384
+ ${select('language', tx('aw.language'), v('language', 'en'), Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
385
385
  </div>`)}
386
386
  ${!known && bp.length
387
- ? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>Advanced</h2></summary><div class="region-body"><div class="form-grid">
388
- ${select('boilerplate', 'Start from', v('boilerplate', ''), [['', '- a blank application -'], ...bp.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
389
- 'A boilerplate application\'s pages, shared components and settings are copied; the new application keeps its own name, alias, role and authentication.')}
387
+ ? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>${tx('aw.advanced')}</h2></summary><div class="region-body"><div class="form-grid">
388
+ ${select('boilerplate', tx('aw.start_from'), v('boilerplate', ''), [['', tx('aw.a_blank_application')], ...bp.map((x): [string, string] => [String(x.id), tx('aw.bp_option', { name: x.name })])],
389
+ tx('aw.bp_help'))}
390
390
  </div></div></details>`
391
391
  : ''}
392
- <div class="buttons"><a class="btn" href="${BASE}">Cancel</a><button class="btn btn-hot">Create application</button></div>
392
+ <div class="buttons"><a class="btn" href="${BASE}">${tx('aw.cancel')}</a><button class="btn btn-hot">${tx('aw.create_application')}</button></div>
393
393
  </form>
394
394
  </div>`;
395
- return send(reply, s, shell(s, 'Create application', [['App Builder', BASE], ['Create']], main));
395
+ return send(reply, s, shell(s, tx('aw.create_application'), [[tx('aw.app_builder'), BASE], [tx('aw.create')]], main));
396
396
  }