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
@@ -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
  }
@@ -7,6 +7,7 @@ import { assistantProblems, type AssistantConfig } from '../runtime/assistant.ts
7
7
  import type { Session } from '../session.ts';
8
8
  import { agentOptions } from './ai-agents.ts';
9
9
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
10
+ import { scopedTx as tx } from './i18n.ts';
10
11
 
11
12
  // Page designer → an AI assistant region → Settings (service, prompts,
12
13
  // context queries, tools, limits), and → a report region → "Ask in your own
@@ -29,7 +30,7 @@ async function serviceOptions(appId: number, current: unknown) {
29
30
  };
30
31
  }
31
32
 
32
- const NO_SERVICE = html`<p class="muted">This application may not use any AI service yet: an administrator adds one under <a href="${BASE}/ai">Workspace utilities → AI services</a> and allows this application to use it.</p>`;
33
+ const NO_SERVICE = () => html`<p class="muted">${tx.rich('asst.no_service', { link: html`<a href="${BASE}/ai">${tx('asst.workspace_utilities_ai_services')}</a>` })}</p>`;
33
34
 
34
35
  const json = (v: unknown) => (v === undefined ? '' : JSON.stringify(v, null, 2));
35
36
 
@@ -40,41 +41,41 @@ export async function assistantSettingsForm(pageId: number, appId: number, r: {
40
41
  const problems = assistantProblems(cfg);
41
42
  const agents = await agentOptions(appId);
42
43
  const agent = typeof cfg.agent === 'string' ? cfg.agent.toUpperCase() : '';
43
- return html`<h3 class="u-mt15">AI assistant settings</h3>
44
- <p class="muted u-mt0">A chat with an AI service. What users type, the context rows and the tools' results go to the service's provider (Claude, OpenAI, Gemini, or the OpenAI-compatible endpoint in its base URL). Context queries and SQL tools run as the application's database role; SQL tools are rolled back unless they may change data.</p>
45
- ${allowed.length ? '' : NO_SERVICE}
44
+ return html`<h3 class="u-mt15">${tx('asst.ai_assistant_settings')}</h3>
45
+ <p class="muted u-mt0">${tx('asst.a_chat_with_an_ai')}</p>
46
+ ${allowed.length ? '' : NO_SERVICE()}
46
47
  ${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
47
48
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/assistant" class="component-form">${csrf(s)}
48
- <fieldset class="prop-group"><legend>Assistant</legend><div class="form-grid">
49
- <div class="field"><label class="label" for="${id('agent')}">AI agent</label>
50
- <select id="${id('agent')}" name="agent">${opt('', '- none: the service, prompt and tools below -', agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
51
- <small class="help">An agent of <a href="${BASE}/apps/${appId}/ai-agents">Shared Components → AI Agents</a> sets the service, system prompt, reasoning effort, tools and steps; saving with an agent removes the region's own service, system prompt, tools and tool rounds.</small></div>
52
- <div class="field"><label class="label" for="${id('service')}">AI service</label>
53
- <select id="${id('service')}" name="service">${opt('', '- choose -', cfg.service ?? '')}${options}</select></div>
54
- <div class="field"><label class="label" for="${id('max_rounds')}">Tool rounds per question</label>
49
+ <fieldset class="prop-group"><legend>${tx('asst.assistant')}</legend><div class="form-grid">
50
+ <div class="field"><label class="label" for="${id('agent')}">${tx('asst.ai_agent')}</label>
51
+ <select id="${id('agent')}" name="agent">${opt('', tx('asst.none_the_service_prompt_and'), agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
52
+ <small class="help">${tx.rich('asst.agent_help', { link: html`<a href="${BASE}/apps/${appId}/ai-agents">${tx('asst.shared_components_ai_agents')}</a>` })}</small></div>
53
+ <div class="field"><label class="label" for="${id('service')}">${tx('asst.ai_service')}</label>
54
+ <select id="${id('service')}" name="service">${opt('', tx('asst.choose'), cfg.service ?? '')}${options}</select></div>
55
+ <div class="field"><label class="label" for="${id('max_rounds')}">${tx('asst.tool_rounds_per_question')}</label>
55
56
  <input id="${id('max_rounds')}" name="max_rounds" type="number" min="1" max="10" value="${cfg.max_rounds ?? ''}" placeholder="5"></div>
56
- <div class="field"><label class="label" for="${id('max_turns')}">Questions per conversation</label>
57
+ <div class="field"><label class="label" for="${id('max_turns')}">${tx('asst.questions_per_conversation')}</label>
57
58
  <input id="${id('max_turns')}" name="max_turns" type="number" min="1" max="100" value="${cfg.max_turns ?? ''}" placeholder="20"></div>
58
- <div class="field" data-wide><label class="label" for="${id('system')}">System prompt</label>
59
+ <div class="field" data-wide><label class="label" for="${id('system')}">${tx('asst.system_prompt')}</label>
59
60
  <textarea id="${id('system')}" name="system" rows="4">${cfg.system ?? ''}</textarea>
60
- <small class="help">Who the assistant is and what it may answer. <code>&amp;ITEM.</code> is replaced by the item's value as delimited data (never the session id or passwords).</small></div>
61
- <div class="field" data-wide><label class="label" for="${id('welcome')}">Welcome text</label>
61
+ <small class="help">${tx.code('asst.who_the_assistant_is_and')}</small></div>
62
+ <div class="field" data-wide><label class="label" for="${id('welcome')}">${tx('asst.welcome_text')}</label>
62
63
  <textarea id="${id('welcome')}" name="welcome" rows="2">${cfg.welcome ?? ''}</textarea></div>
63
- <div class="field"><label class="label" for="${id('placeholder')}">Placeholder</label>
64
+ <div class="field"><label class="label" for="${id('placeholder')}">${tx('asst.placeholder')}</label>
64
65
  <input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
65
- <div class="field"><label class="label" for="${id('error_message')}">Error message</label>
66
- <input id="${id('error_message')}" name="error_message" maxlength="300" value="${cfg.error_message ?? ''}" placeholder="the service's message"></div>
66
+ <div class="field"><label class="label" for="${id('error_message')}">${tx('asst.error_message')}</label>
67
+ <input id="${id('error_message')}" name="error_message" maxlength="300" value="${cfg.error_message ?? ''}" placeholder="${tx('asst.the_service_s_message')}"></div>
67
68
  </div>
68
- <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> Also for users who are not signed in (every question costs tokens)</label></div></fieldset>
69
- <fieldset class="prop-group"><legend>Context and tools</legend>
70
- <div class="field"><label class="label" for="${id('context')}">Context queries (JSON)</label>
69
+ <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> ${tx('asst.also_for_users_who_are')}</label></div></fieldset>
70
+ <fieldset class="prop-group"><legend>${tx('asst.context_and_tools')}</legend>
71
+ <div class="field"><label class="label" for="${id('context')}">${tx('asst.context_queries_json')}</label>
71
72
  <textarea id="${id('context')}" name="context" rows="5" class="code" spellcheck="false">${json(cfg.context)}</textarea>
72
- <small class="help">Run before each question; their rows go to the model as data. <code>[{"name": "policy", "sql": "select title, body from hr.policy where body @@ plainto_tsquery(:AI_PROMPT)", "max_rows": 5}]</code> (<code>:AI_PROMPT</code> is the question, <code>:APP_USER</code> and items as usual).</small></div>
73
- <div class="field"><label class="label" for="${id('tools')}">Tools (JSON)</label>
73
+ <small class="help">${tx.code('asst.run_before_each_question_their')}</small></div>
74
+ <div class="field"><label class="label" for="${id('tools')}">${tx('asst.tools_json')}</label>
74
75
  <textarea id="${id('tools')}" name="tools" rows="10" class="code" spellcheck="false">${json(cfg.tools)}</textarea>
75
- <small class="help">The model calls them when it needs to. SQL: <code>{"name": "my_leave", "description": "…", "sql": "select … where status = :STATUS", "parameters": {"STATUS": {"type": "string", "enum": ["PENDING", "APPROVED"]}}}</code>; add <code>"writes": true</code> for a tool that changes data and <code>"authz": "SCHEME"</code> to offer it only to some users. REST: <code>{"name": "department", "type": "rest", "source": "DEPARTMENT", "description": "…", "parameters": {"dname": {"type": "string"}}}</code>. Parameter types: string, integer, number, boolean, date; <code>"optional": true</code> allows none.</small></div>
76
+ <small class="help">${tx.code('asst.the_model_calls_them_when')}</small></div>
76
77
  </fieldset>
77
- <div class="buttons"><button class="btn btn-hot">Save AI assistant settings</button></div>
78
+ <div class="buttons"><button class="btn btn-hot">${tx('asst.save_ai_assistant_settings')}</button></div>
78
79
  </form>`;
79
80
  }
80
81
 
@@ -84,9 +85,9 @@ async function appProblems(appId: number, cfg: AssistantConfig) {
84
85
  const sources = new Set((await owner.query<{ name: string }>('select name from meta.rest_source where app_id = $1', [appId])).rows.map((x) => x.name));
85
86
  const schemes = new Set((await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1', [appId])).rows.map((x) => x.name.toUpperCase()));
86
87
  for (const t of Array.isArray(cfg.tools) ? cfg.tools : []) {
87
- if (t?.type === 'rest' && typeof t.source === 'string' && !sources.has(t.source.toUpperCase())) out.push(`Tool "${t.name}": there is no REST data source ${t.source.toUpperCase()}.`);
88
+ if (t?.type === 'rest' && typeof t.source === 'string' && !sources.has(t.source.toUpperCase())) out.push(tx('asst.no_rest_source', { tool: t.name, source: t.source.toUpperCase() }));
88
89
  if (typeof t?.authz === 'string' && t.authz.trim() && !['MUST_NOT_BE_PUBLIC_USER'].includes(t.authz.replace(/^!/, '').trim().toUpperCase()) && !schemes.has(t.authz.replace(/^!/, '').trim().toUpperCase()))
89
- out.push(`Tool "${t.name}": there is no authorization scheme ${t.authz}.`);
90
+ out.push(tx('asst.no_authz', { tool: t.name, scheme: t.authz }));
90
91
  }
91
92
  return out;
92
93
  }
@@ -124,12 +125,12 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
124
125
  try {
125
126
  out[k] = JSON.parse(v);
126
127
  } catch {
127
- errors.push(`${k === 'tools' ? 'Tools' : 'Context queries'}: not valid JSON (kept as it was).`);
128
+ errors.push(`${k === 'tools' ? tx('asst.tools') : tx('asst.context_queries')}: not valid JSON (kept as it was).`);
128
129
  }
129
130
  }
130
131
  if (out.agent) {
131
132
  for (const k of ['service', 'system', 'tools', 'max_rounds']) delete out[k];
132
- if (!(await agentOptions(appId)).includes(out.agent)) errors.push(`There is no AI agent ${out.agent}.`);
133
+ if (!(await agentOptions(appId)).includes(out.agent)) errors.push(tx('asst.no_agent', { agent: out.agent }));
133
134
  }
134
135
  errors.push(...assistantProblems(out), ...(await appProblems(appId, out as AssistantConfig)));
135
136
  return { config: out, errors };
@@ -140,17 +141,17 @@ export async function aiFilterSettingsForm(pageId: number, appId: number, r: { i
140
141
  const cfg = (r.config?.ai_filter ?? {}) as Config;
141
142
  const id = (n: string) => `raf_${r.id}_${n}`;
142
143
  const { options } = await serviceOptions(appId, cfg.service);
143
- return html`<h3 class="u-mt15">Ask in your own words (AI)</h3>
144
- <p class="muted u-mt0">Users type a question; it becomes the report's own settings (filters, highlights, sort, control break, totals, group by, pivot, chart, computed columns; only columns the report shows, only its operators), shown as chips they can edit, remove or undo. With an AI service the question and the column names go to the service's provider; without one (<i>built-in phrases</i>) pgkiln reads common phrases itself, in English, Dutch and German.</p>
144
+ return html`<h3 class="u-mt15">${tx('asst.ask_in_your_own_words')}</h3>
145
+ <p class="muted u-mt0">${tx.rich('asst.filter_intro', { builtin: html`<i>${tx('asst.built_in_phrases')}</i>` })}</p>
145
146
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/ai-filter" class="component-form">${csrf(s)}
146
147
  <div class="form-grid">
147
- <div class="field"><label class="label" for="${id('service')}">AI service</label>
148
- <select id="${id('service')}" name="service">${opt('', '- off -', r.config?.ai_filter ? '-' : '')}${opt('-', 'Built-in phrases (no AI)', r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
149
- <div class="field" data-wide><label class="label" for="${id('placeholder')}">Placeholder</label>
148
+ <div class="field"><label class="label" for="${id('service')}">${tx('asst.ai_service')}</label>
149
+ <select id="${id('service')}" name="service">${opt('', tx('asst.off'), r.config?.ai_filter ? '-' : '')}${opt('-', tx('asst.built_in_phrases_no_ai'), r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
150
+ <div class="field" data-wide><label class="label" for="${id('placeholder')}">${tx('asst.placeholder')}</label>
150
151
  <input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
151
152
  </div>
152
- <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> Also for users who are not signed in</label></div>
153
- <div class="buttons"><button class="btn">Save</button></div>
153
+ <div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> ${tx('asst.also_for_users_who_are_2')}</label></div>
154
+ <div class="buttons"><button class="btn">${tx('asst.save')}</button></div>
154
155
  </form>`;
155
156
  }
156
157
 
@@ -170,7 +171,7 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
170
171
  const { config, errors } = await mergeAssistantSettings(r.app_id, r.config ?? {}, (req.body ?? {}) as Body);
171
172
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
172
173
  if (errors.length) flash(s, `Saved, but: ${errors.join(' ')}`, 'error');
173
- else flash(s, 'Settings saved.');
174
+ else flash(s, tx('asst.settings_saved'));
174
175
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
175
176
  });
176
177
 
@@ -193,7 +194,7 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
193
194
  if (errors.length) flash(s, errors.join(' '), 'error');
194
195
  else {
195
196
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
196
- flash(s, 'Settings saved.');
197
+ flash(s, tx('asst.settings_saved'));
197
198
  }
198
199
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
199
200
  });
@@ -5,6 +5,7 @@ import { html, type Raw } from '../html.ts';
5
5
  import { icon } from '../icons.ts';
6
6
  import type { Session } from '../session.ts';
7
7
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // Shared Components → Automations: the actions (add, edit, reorder),
10
11
  // next run, Run now and the run history with the errors per row, under the
@@ -23,13 +24,13 @@ export function actionPrefill(automation: string, actions: any[]) {
23
24
  /** Under an action's form: back to its automation. */
24
25
  export function actionExtras(row: any, automations: any[]): Raw {
25
26
  const a = automations.find((x) => x.name === row.automation_name);
26
- return a ? html`<p class="u-mt1"><a class="btn" href="?c=automation-${a.id}">${icon('clock')} Back to ${a.name}</a></p>` : html``;
27
+ return a ? html`<p class="u-mt1"><a class="btn" href="?c=automation-${a.id}">${icon('clock')} ${tx('au.back_to', { name: a.name })}</a></p>` : html``;
27
28
  }
28
29
 
29
30
  function rowErrors(errors: RowError[] | null, failed: number | null): Raw {
30
31
  if (!errors?.length) return html``;
31
- return html`<details><summary>${failed ?? errors.length} failed row(s)${failed && failed > errors.length ? ` (first ${errors.length})` : ''}</summary>
32
- <ul class="small">${errors.map((e) => html`<li>Row ${e.row}, action <b>${e.action}</b>: ${e.message}${e.values ? html`<br><code>${e.values}</code>` : ''}</li>`)}</ul></details>`;
32
+ return html`<details><summary>${tx('au.failed_rows', { n: failed ?? errors.length })}${failed && failed > errors.length ? ` ${tx('au.first', { n: errors.length })}` : ''}</summary>
33
+ <ul class="small">${errors.map((e) => html`<li>${tx.rich('au.row_action', { row: e.row, action: html`<b>${e.action}</b>` })}: ${e.message}${e.values ? html`<br><code>${e.values}</code>` : ''}</li>`)}</ul></details>`;
33
34
  }
34
35
 
35
36
  /** Actions, next run, Run now and the last runs of an automation. */
@@ -43,44 +44,44 @@ export async function automationExtras(appId: number, row: { id: number; name: s
43
44
  ).rows;
44
45
  let next: string;
45
46
  try {
46
- next = row.enabled ? when(nextRun(parseCron(row.schedule), row.time_zone, new Date())) : 'not scheduled (disabled)';
47
+ next = row.enabled ? when(nextRun(parseCron(row.schedule), row.time_zone, new Date())) : tx('au.not_scheduled');
47
48
  } catch (e) {
48
- next = `never: ${(e as Error).message}`;
49
+ next = tx('au.never', { message: (e as Error).message });
49
50
  }
50
- const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>(the scheduler is off on this server: AUTOMATIONS=off)</b>` : '';
51
+ const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>${tx('au.scheduler_off')}</b>` : '';
51
52
  const move = (id: number, dir: 'up' | 'down', label: string, disabled: boolean) =>
52
53
  html`<form method="post" action="${BASE}/apps/${appId}/shared/automation_action/${id}/move" class="u-inline">${csrf(s)}<input type="hidden" name="dir" value="${dir}"><button class="link-button" aria-label="${label}"${disabled ? html` disabled` : ''}>${dir === 'up' ? '↑' : '↓'}</button></form>`;
53
- const actionsHtml = html`<fieldset class="prop-group u-mt125"><legend>Actions</legend>
54
- <p class="muted u-mt0">Run in this order${row.query?.trim() ? ', for each row of the query' : ', once'}, in one transaction${row.error_handling === 'skip' && row.query?.trim() ? ' per run; a failing row is rolled back and skipped' : ''}.</p>
54
+ const actionsHtml = html`<fieldset class="prop-group u-mt125"><legend>${tx('au.actions')}</legend>
55
+ <p class="muted u-mt0">${tx(!row.query?.trim() ? 'au.order_once' : row.error_handling === 'skip' ? 'au.order_rows_skip' : 'au.order_rows')}</p>
55
56
  ${actions.length
56
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">Seq</th><th>Action</th><th>Condition</th><th>Order</th></tr></thead><tbody>
57
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">${tx('au.seq')}</th><th>${tx('au.action')}</th><th>${tx('au.condition')}</th><th>${tx('au.order')}</th></tr></thead><tbody>
57
58
  ${actions.map((x, i) => html`<tr>
58
- <td class="num" data-label="Seq">${x.seq}</td>
59
- <td data-label="Action"><a href="?c=automation_action-${x.id}">${x.name}</a></td>
60
- <td data-label="Condition">${x.condition ? html`<code>${x.condition.length > 80 ? x.condition.slice(0, 80) + '…' : x.condition}</code>` : html`<span class="muted">always</span>`}</td>
61
- <td data-label="Order">${move(x.id, 'up', `Move ${x.name} up`, i === 0)} ${move(x.id, 'down', `Move ${x.name} down`, i === actions.length - 1)}</td></tr>`)}
59
+ <td class="num" data-label="${tx('au.seq')}">${x.seq}</td>
60
+ <td data-label="${tx('au.action')}"><a href="?c=automation_action-${x.id}">${x.name}</a></td>
61
+ <td data-label="${tx('au.condition')}">${x.condition ? html`<code>${x.condition.length > 80 ? x.condition.slice(0, 80) + '…' : x.condition}</code>` : html`<span class="muted">${tx('au.always')}</span>`}</td>
62
+ <td data-label="${tx('au.order')}">${move(x.id, 'up', tx('au.move_up', { name: x.name }), i === 0)} ${move(x.id, 'down', tx('au.move_down', { name: x.name }), i === actions.length - 1)}</td></tr>`)}
62
63
  </tbody></table></div>`
63
- : html`<p class="muted">No actions yet: a run does nothing.</p>`}
64
- <p><a class="btn" href="?new=automation_action&amp;automation=${encodeURIComponent(row.name)}">+ Add action</a></p>
64
+ : html`<p class="muted">${tx('au.no_actions')}</p>`}
65
+ <p><a class="btn" href="?new=automation_action&amp;automation=${encodeURIComponent(row.name)}">+ ${tx('au.add_action')}</a></p>
65
66
  </fieldset>`;
66
- return html`${actionsHtml}<fieldset class="prop-group u-mt125"><legend>Runs</legend>
67
- <p>Next run: <b>${next}</b>${scheduler}</p>
68
- <p class="muted">From application code: <code>select meta.run_automation('${row.name}')</code> runs it now, in the caller's transaction.</p>
67
+ return html`${actionsHtml}<fieldset class="prop-group u-mt125"><legend>${tx('au.runs')}</legend>
68
+ <p>${tx.rich('au.next_run', { when: html`<b>${next}</b>` })}${scheduler}</p>
69
+ <p class="muted">${tx.rich('au.from_code', { call: html`<code>select meta.run_automation('${row.name}')</code>` })}</p>
69
70
  <form method="post" action="${BASE}/apps/${appId}/shared/automation/${row.id}/run">${csrf(s)}
70
- <button class="btn">${icon('play')} Run now</button>
71
+ <button class="btn">${icon('play')} ${tx('au.run_now')}</button>
71
72
  </form>
72
73
  ${log.length
73
- ? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>Started</th><th>Took</th><th>By</th><th>Status</th><th class="num">Rows</th><th class="num">Failed</th><th>Message</th></tr></thead><tbody>
74
+ ? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>${tx('au.started')}</th><th>${tx('au.took')}</th><th>${tx('au.by')}</th><th>${tx('au.status')}</th><th class="num">${tx('au.rows')}</th><th class="num">${tx('au.failed')}</th><th>${tx('au.message')}</th></tr></thead><tbody>
74
75
  ${log.map((l) => html`<tr>
75
- <td data-label="Started">${when(l.started_at)}</td>
76
- <td data-label="Took">${l.finished_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
77
- <td data-label="By">${l.trigger}${l.run_by ? html` <span class="muted">(${l.run_by})</span>` : ''}</td>
78
- <td data-label="Status"><span class="tag${STATUS_TAG[l.status] ?? ''}">${l.status}</span></td>
79
- <td class="num" data-label="Rows">${l.rows ?? ''}</td>
80
- <td class="num" data-label="Failed">${l.rows_failed || ''}</td>
81
- <td data-label="Message">${l.message ?? ''}${rowErrors(l.errors, l.rows_failed)}</td></tr>`)}
76
+ <td data-label="${tx('au.started')}">${when(l.started_at)}</td>
77
+ <td data-label="${tx('au.took')}">${l.finished_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
78
+ <td data-label="${tx('au.by')}">${l.trigger}${l.run_by ? html` <span class="muted">(${l.run_by})</span>` : ''}</td>
79
+ <td data-label="${tx('au.status')}"><span class="tag${STATUS_TAG[l.status] ?? ''}">${l.status}</span></td>
80
+ <td class="num" data-label="${tx('au.rows')}">${l.rows ?? ''}</td>
81
+ <td class="num" data-label="${tx('au.failed')}">${l.rows_failed || ''}</td>
82
+ <td data-label="${tx('au.message')}">${l.message ?? ''}${rowErrors(l.errors, l.rows_failed)}</td></tr>`)}
82
83
  </tbody></table></div>`
83
- : html`<p class="muted">No runs yet.</p>`}
84
+ : html`<p class="muted">${tx('au.no_runs')}</p>`}
84
85
  </fieldset>`;
85
86
  }
86
87
 
@@ -93,9 +94,9 @@ export async function automationRoutes(app: FastifyInstance) {
93
94
  const a = /^\d+$/.test(id) && /^\d+$/.test(cid) ? await owner.one('select id from meta.automation where id = $1 and app_id = $2', [cid, id]) : undefined;
94
95
  if (!a) return reply.code(404).send('Not found');
95
96
  const r = await runAutomation(a.id, 'manual');
96
- if (r.status === 'ok') flash(s, `Ran successfully${r.rows ? ` for ${r.rows} row(s)` : ''}.`);
97
- else if (r.status === 'warning') flash(s, `Ran with errors: ${r.message}. See the run history.`, 'error');
98
- else flash(s, r.message ?? 'The automation failed.', 'error');
97
+ if (r.status === 'ok') flash(s, r.rows ? tx('au.ran_rows', { n: r.rows }) : tx('au.ran'));
98
+ else if (r.status === 'warning') flash(s, tx('au.ran_errors', { message: r.message ?? '' }), 'error');
99
+ else flash(s, r.message ?? tx('au.failed_run'), 'error');
99
100
  return back(reply, s, target);
100
101
  });
101
102
 
@@ -4,18 +4,21 @@ import { html, raw } from '../html.ts';
4
4
  import { clientIp, logActivity } from '../session.ts';
5
5
  import { appOr404 } from './forms.ts';
6
6
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
7
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
7
8
 
8
9
  // Application → Availability (APEX: Application Availability and Build Status,
9
10
  // migration 083): who may use the running application, the message they see
10
11
  // otherwise, and whether the builder may change it (Run only). Allowed on a
11
12
  // Run only application (src/builder/locks.ts), logged as "availability".
12
13
 
13
- const MODES: [string, string][] = [
14
- ['available', 'Available'],
15
- ['developers', 'Available to developers only'],
16
- ['restricted', 'Restricted to the users below (and developers)'],
17
- ['unavailable', 'Unavailable'],
14
+ const MODE_KEYS: [string, BuilderKey][] = [
15
+ ['available', 'avl.mode.available'],
16
+ ['developers', 'avl.mode.developers'],
17
+ ['restricted', 'avl.mode.restricted'],
18
+ ['unavailable', 'avl.mode.unavailable'],
18
19
  ];
20
+ /** The statuses with their labels in the developer's language. */
21
+ const modes = (): [string, string][] => MODE_KEYS.map(([v, k]) => [v, tx(k)]);
19
22
 
20
23
  export async function availabilityRoutes(app: FastifyInstance) {
21
24
  app.get(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
@@ -23,35 +26,32 @@ export async function availabilityRoutes(app: FastifyInstance) {
23
26
  if (!s) return;
24
27
  const a = await appOr404(req.params.id);
25
28
  if (!a) return reply.code(404).send('Not found');
26
- const body = region('Availability', html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
27
- <p class="muted u-mt0">Who may use the running application. Developers are those signed in to the builder in the same browser
28
- (they see the developer toolbar). Pages, file downloads, REST modules and the REST API follow this setting; automations,
29
- workflows, background jobs and queued mail keep running. It belongs to this installation: exports leave it out and an
30
- import over the application keeps it.</p>
29
+ const body = region(tx('app.head.availability'), html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
30
+ <p class="muted u-mt0">${tx('avl.intro')}</p>
31
31
  <div class="form-grid">
32
- ${select('availability', 'Status', a.availability, MODES)}
33
- <div class="field" data-wide><label class="label" for="f_message">Message</label>
32
+ ${select('availability', tx('avl.status'), a.availability, modes())}
33
+ <div class="field" data-wide><label class="label" for="f_message">${tx('avl.message')}</label>
34
34
  <textarea id="f_message" name="message" rows="3" maxlength="2000">${a.availability_message ?? ''}</textarea>
35
- <small class="help">Shown when the application is unavailable or for developers only, e.g. "We are upgrading the application; back at 14:00." Empty: a standard message in the user's language.</small></div>
36
- <div class="field" data-wide><label class="label" for="f_users">Users (restricted)</label>
35
+ <small class="help">${tx('avl.message_help')}</small></div>
36
+ <div class="field" data-wide><label class="label" for="f_users">${tx('avl.users')}</label>
37
37
  <textarea id="f_users" name="users" rows="2">${(a.availability_users ?? []).join(', ')}</textarea>
38
- <small class="help">User names separated by commas or spaces. They sign in as usual; others get "not available to your account".</small></div>
39
- ${select('build_status', 'Build status', a.build_status, [['run_and_build', 'Run and build'], ['run_only', 'Run only']],
40
- 'Run only: the builder refuses changes to the application (pages, shared components, settings, restores) until it is set back here. pgkiln import --replace still deploys it.')}
38
+ <small class="help">${tx('avl.users_help')}</small></div>
39
+ ${select('build_status', tx('avl.build_status'), a.build_status, [['run_and_build', tx('avl.run_and_build')], ['run_only', tx('avl.run_only')]],
40
+ tx('avl.build_status_help'))}
41
41
  </div>
42
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`);
42
+ <div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div></form>`);
43
43
  const main = html`${appHeader(a, 'settings', s)}<div class="ide-body">${body}</div>`;
44
- return send(reply, s, shell(s, `${a.name} availability`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Availability']], main));
44
+ return send(reply, s, shell(s, tx('avl.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.head.availability')]], main));
45
45
  });
46
46
 
47
47
  app.post(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
48
48
  const s = await developer(req, reply);
49
49
  if (!s) return;
50
50
  const b = req.body ?? {};
51
- const mode = MODES.some(([v]) => v === b.availability) ? b.availability : 'available';
51
+ const mode = MODE_KEYS.some(([v]) => v === b.availability) ? b.availability : 'available';
52
52
  const users = [...new Set(String(b.users ?? '').split(/[\s,;]+/).map((u) => u.trim()).filter(Boolean))];
53
53
  if (users.some((u) => u.length > 100 || /[:\s]/.test(u))) {
54
- flash(s, 'User names are at most 100 characters, without spaces or colons.', 'error');
54
+ flash(s, tx('avl.users_invalid'), 'error');
55
55
  return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
56
56
  }
57
57
  const build = b.build_status === 'run_only' ? 'run_only' : 'run_and_build';
@@ -59,7 +59,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
59
59
  `update meta.app set availability = $2, availability_message = $3, availability_users = $4, build_status = $5, updated_at = now() where id = $1`,
60
60
  [req.params.id, mode, String(b.message ?? '').trim().slice(0, 2000) || null, users, build]);
61
61
  if (r.rowCount) await logActivity({ appId: Number(req.params.id), username: s.username, event: 'availability', ip: clientIp(req), detail: `${mode}, ${build}` });
62
- flash(s, r.rowCount ? 'Availability saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
62
+ flash(s, r.rowCount ? tx('avl.saved') : tx('avl.not_found'), r.rowCount ? 'ok' : 'error');
63
63
  return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
64
64
  });
65
65
  }
@@ -68,7 +68,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
68
68
  export const availabilityBadge = (a: { id: number; availability?: string; build_status?: string }) =>
69
69
  a.availability && a.availability !== 'available' || a.build_status === 'run_only'
70
70
  ? html`<a class="tag tag-warning availability-badge" href="${BASE}/apps/${a.id}/availability">${[
71
- a.availability && a.availability !== 'available' ? MODES.find(([v]) => v === a.availability)?.[1] : null,
72
- a.build_status === 'run_only' ? 'Run only' : null,
71
+ a.availability && a.availability !== 'available' ? modes().find(([v]) => v === a.availability)?.[1] : null,
72
+ a.build_status === 'run_only' ? tx('avl.run_only') : null,
73
73
  ].filter(Boolean).join(' · ')}</a>`
74
74
  : raw('');