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
@@ -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
  }
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { saveState, type Session } from '../session.ts';
4
4
  import { COMPONENTS } from './components.ts';
5
5
  import { BASE, developer, flash, type Req } from './ui.ts';
6
+ import { scopedTx as tx } from './i18n.ts';
6
7
 
7
8
  // Page designer layout: moving regions, items and buttons (drag and drop on
8
9
  // the Layout tab, or the Move up / Move down / column span buttons), creating
@@ -142,7 +143,7 @@ async function targetRegion(c: Q, pid: number, value: string): Promise<number |
142
143
  if (value === '') return null;
143
144
  const id = int(value);
144
145
  if (id === null || !(await c.query('select 1 from meta.region where id = $1 and page_id = $2', [id, pid])).rowCount)
145
- throw new LayoutError(400, 'That region is not on this page.');
146
+ throw new LayoutError(400, tx('arr.that_region_is_not_on'));
146
147
  return id;
147
148
  }
148
149
 
@@ -165,8 +166,8 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
165
166
  if (kind === 'region' && o.position !== undefined && !o.dir) place = o.position || 'body';
166
167
  if (kind === 'button' && o.slot !== undefined && !o.dir) place = o.slot || 'region';
167
168
  if (kind === 'button' && region === null) place = '';
168
- if (kind === 'region' && !POSITIONS().includes(place)) throw new LayoutError(400, 'Unknown position.');
169
- if (kind === 'button' && place && !SLOTS().includes(place)) throw new LayoutError(400, 'Unknown button position.');
169
+ if (kind === 'region' && !POSITIONS().includes(place)) throw new LayoutError(400, tx('arr.unknown_position'));
170
+ if (kind === 'button' && place && !SLOTS().includes(place)) throw new LayoutError(400, tx('arr.unknown_button_position'));
170
171
  const scope = kind === 'region' ? " and coalesce(position, 'body') = $2"
171
172
  : kind === 'button' ? " and region_id is not distinct from $2 and (region_id is null or coalesce(slot, 'region') = $3)"
172
173
  : ' and region_id is not distinct from $2';
@@ -178,13 +179,13 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
178
179
  const now = list.indexOf(id);
179
180
  at = Math.max(0, Math.min(others.length, now + (o.dir === 'up' ? -1 : 1)));
180
181
  } else if (o.dir) {
181
- throw new LayoutError(400, 'Unknown direction.');
182
+ throw new LayoutError(400, tx('arr.unknown_direction'));
182
183
  } else if (!o.before) {
183
184
  at = others.length;
184
185
  } else {
185
186
  const before = int(o.before);
186
187
  at = before === null ? -1 : others.indexOf(before);
187
- if (at < 0) throw new LayoutError(400, 'The place to move to is not in that region.');
188
+ if (at < 0) throw new LayoutError(400, tx('arr.the_place_to_move_to'));
188
189
  }
189
190
  others.splice(at, 0, id);
190
191
  const stored = place === 'body' || place === 'region' || place === '' ? null : place;
@@ -198,9 +199,9 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
198
199
  /** Sets a region's column span (1–12), or widens / narrows it by one column. */
199
200
  export async function spanRegion(c: Q, pid: number, id: number, o: { columns?: string; delta?: string }) {
200
201
  const row = (await c.query('select columns from meta.region where id = $1 and page_id = $2', [id, pid])).rows[0];
201
- if (!row) throw new LayoutError(404, 'Region not found on this page.');
202
+ if (!row) throw new LayoutError(404, tx('arr.region_not_found_on_this'));
202
203
  const n = o.delta === '1' ? row.columns + 1 : o.delta === '-1' ? row.columns - 1 : int(o.columns);
203
- if (n === null || n < 1 || n > 12) throw new LayoutError(400, 'The column span is 1 to 12.');
204
+ if (n === null || n < 1 || n > 12) throw new LayoutError(400, tx('arr.the_column_span_is_1'));
204
205
  await c.query('update meta.region set columns = $3 where id = $1 and page_id = $2', [id, pid, n]);
205
206
  return n;
206
207
  }
@@ -208,10 +209,10 @@ export async function spanRegion(c: Q, pid: number, id: number, o: { columns?: s
208
209
  /** A new component from the gallery, with a working default; placed like moveComponent. Returns its id. */
209
210
  export async function createComponent(c: Q, pid: number, kind: LayoutKind, type: string, o: { region?: string; before?: string; position?: string; slot?: string }) {
210
211
  const page = (await c.query('select page_no from meta.page where id = $1', [pid])).rows[0];
211
- if (!page) throw new LayoutError(404, 'Page not found.');
212
+ if (!page) throw new LayoutError(404, tx('arr.page_not_found'));
212
213
  let id: number;
213
214
  if (kind === 'region') {
214
- if (!REGION_TYPES().includes(type)) throw new LayoutError(400, 'Unknown region type.');
215
+ if (!REGION_TYPES().includes(type)) throw new LayoutError(400, tx('arr.unknown_region_type'));
215
216
  id = (await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 999999, $2, $3, $4) returning id`,
216
217
  [pid, `New ${REGION_LABELS[type]?.[0].toLowerCase() ?? type}`, type, NEW_REGION_SOURCE[type] ?? null])).rows[0].id;
217
218
  // (140) a breadcrumb goes in the breadcrumb bar, without a frame of its own (unless it is dropped elsewhere)
@@ -222,14 +223,14 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
222
223
  } else {
223
224
  const region = await targetRegion(c, pid, o.region ?? '');
224
225
  if (kind === 'item') {
225
- if (!ITEM_TYPES().includes(type)) throw new LayoutError(400, 'Unknown item type.');
226
+ if (!ITEM_TYPES().includes(type)) throw new LayoutError(400, tx('arr.unknown_item_type'));
226
227
  const taken = new Set((await c.query('select name from meta.item where page_id = $1', [pid])).rows.map((r) => r.name));
227
228
  const base = `P${page.page_no}_NEW`;
228
229
  let name = base;
229
230
  for (let i = 2; taken.has(name); i++) name = `${base}_${i}`;
230
231
  id = (await c.query(`insert into meta.item (page_id, region_id, seq, name, label, type) values ($1, $2, 999999, $3, 'New item', $4) returning id`, [pid, region, name, type])).rows[0].id;
231
232
  } else {
232
- if (!BUTTON_ACTIONS().includes(type)) throw new LayoutError(400, 'Unknown button action.');
233
+ if (!BUTTON_ACTIONS().includes(type)) throw new LayoutError(400, tx('arr.unknown_button_action'));
233
234
  const taken = new Set((await c.query('select name from meta.button where page_id = $1', [pid])).rows.map((r) => r.name));
234
235
  let name = 'NEW_BUTTON';
235
236
  for (let i = 2; taken.has(name); i++) name = `NEW_BUTTON_${i}`;
@@ -268,7 +269,7 @@ export async function duplicateComponent(c: Q, pid: number, kind: LayoutKind, id
268
269
  const row = (await c.query(`select * from ${table} where id = $1 and page_id = $2`, [id, pid])).rows[0];
269
270
  if (!row) throw new LayoutError(404, `${COMPONENTS[kind].label} not found on this page.`);
270
271
  const pages = (await c.query('select t.page_no from meta.page t join meta.page f on f.app_id = t.app_id where t.id = $1 and f.id = $2', [toPid, pid])).rows;
271
- if (!pages.length) throw new LayoutError(400, 'That page is not in this application.');
272
+ if (!pages.length) throw new LayoutError(400, tx('arr.that_page_is_not_in'));
272
273
  const same = toPid === pid;
273
274
  const rename = (name: string) => (same ? name : name.replace(/^P\d+_/, `P${pages[0].page_no}_`));
274
275
  const copyChild = async (k: 'item' | 'button', x: any, regionId: number | null) =>
@@ -336,24 +337,24 @@ export async function arrangeRoutes(app: FastifyInstance) {
336
337
  route('move', async (s, pid, b) => {
337
338
  const kind = b.kind;
338
339
  const id = int(b.id);
339
- if (!isKind(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
340
+ if (!isKind(kind) || id === null) throw new LayoutError(400, tx('arr.unknown_component'));
340
341
  await recorded(s, pid, `Move ${await describe(kind, id)}`, (c) => moveComponent(c, pid, kind, id, { dir: b.dir, region: b.region, before: b.before, position: b.position, slot: b.slot }));
341
342
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} moved.` };
342
343
  });
343
344
 
344
345
  route('span', async (s, pid, b) => {
345
346
  const id = int(b.id);
346
- if (id === null) throw new LayoutError(400, 'Unknown region.');
347
+ if (id === null) throw new LayoutError(400, tx('arr.unknown_region'));
347
348
  let n = 0;
348
349
  await recorded(s, pid, `Resize ${await describe('region', id)}`, async (c) => {
349
350
  n = await spanRegion(c, pid, id, { columns: b.columns, delta: b.delta });
350
351
  });
351
- return { sel: `region-${id}`, message: `Region spans ${n} of 12 columns.` };
352
+ return { sel: `region-${id}`, message: tx('arr.spans', { n }) };
352
353
  });
353
354
 
354
355
  route('create', async (s, pid, b) => {
355
356
  const kind = b.kind;
356
- if (!isKind(kind)) throw new LayoutError(400, 'Unknown component type.');
357
+ if (!isKind(kind)) throw new LayoutError(400, tx('arr.unknown_component_type'));
357
358
  const id = await owner.tx((c) => createComponent(c, pid, kind, b.type ?? '', { region: b.region, before: b.before, position: b.position, slot: b.slot }));
358
359
  keep(s, { pid, undo: [], redo: [] }); // earlier layout steps no longer line up with the new component
359
360
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} created; set its properties on the right.` };
@@ -365,7 +366,7 @@ export async function arrangeRoutes(app: FastifyInstance) {
365
366
  route('comment', async (_s, pid, b) => {
366
367
  const kind = b.kind ?? '';
367
368
  const id = int(b.id);
368
- if (!COMMENTABLE.has(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
369
+ if (!COMMENTABLE.has(kind) || id === null) throw new LayoutError(400, tx('arr.unknown_component'));
369
370
  const on = b.on !== 'false';
370
371
  const done = await owner.tx(async (c) => {
371
372
  if (on)
@@ -375,14 +376,14 @@ export async function arrangeRoutes(app: FastifyInstance) {
375
376
  on conflict (app_id, name) do nothing`, [pid]);
376
377
  return (await c.query(`update ${COMPONENTS[kind].table} set build_option = $3 where id = $1 and page_id = $2 and ${on ? 'true' : `build_option = 'COMMENTED_OUT'`}`, [id, pid, on ? 'COMMENTED_OUT' : null])).rowCount;
377
378
  });
378
- if (!done) throw new LayoutError(404, 'Unknown component.');
379
+ if (!done) throw new LayoutError(404, tx('arr.unknown_component'));
379
380
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} ${on ? 'commented out: it stays in the page designer and is left out of the page (build option COMMENTED_OUT)' : 'back on the page'}.` };
380
381
  });
381
382
 
382
383
  route('duplicate', async (s, pid, b) => {
383
384
  const kind = b.kind;
384
385
  const id = int(b.id);
385
- if (!isKind(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
386
+ if (!isKind(kind) || id === null) throw new LayoutError(400, tx('arr.unknown_component'));
386
387
  const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id));
387
388
  keep(s, { pid, undo: [], redo: [] });
388
389
  return { sel: `${kind}-${copy}`, message: `${COMPONENTS[kind].label} duplicated.` };
@@ -398,7 +399,7 @@ export async function arrangeRoutes(app: FastifyInstance) {
398
399
  const id = int(b.id);
399
400
  try {
400
401
  if (pid === null || !(await owner.one('select 1 as ok from meta.page where id = $1', [pid]))) throw new LayoutError(404, 'Page not found');
401
- if (!isKind(kind) || id === null || to === null) throw new LayoutError(400, 'Unknown component or page.');
402
+ if (!isKind(kind) || id === null || to === null) throw new LayoutError(400, tx('arr.unknown_component_or_page'));
402
403
  const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id, to));
403
404
  if (to === pid) keep(s, { pid, undo: [], redo: [] });
404
405
  flash(s, `${COMPONENTS[kind].label} copied.`);
@@ -415,10 +416,10 @@ export async function arrangeRoutes(app: FastifyInstance) {
415
416
  route(op, async (s, pid) => {
416
417
  const h = history(s, pid);
417
418
  const step = (op === 'undo' ? h.undo : h.redo).pop();
418
- if (!step) return { sel: '', message: `Nothing to ${op}.` };
419
+ if (!step) return { sel: '', message: op === 'undo' ? tx('arr.nothing_undo') : tx('arr.nothing_redo') };
419
420
  await owner.tx((c) => restore(c, pid, op === 'undo' ? step.before : step.after));
420
421
  (op === 'undo' ? h.redo : h.undo).push(step);
421
422
  keep(s, h);
422
- return { sel: '', message: `${op === 'undo' ? 'Undone' : 'Redone'}: ${step.label}.` };
423
+ return { sel: '', message: `${op === 'undo' ? tx('arr.undone') : tx('arr.redone')}: ${step.label}.` };
423
424
  });
424
425
  }