pgkiln 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -5,6 +5,7 @@ import type { Session } from '../session.ts';
5
5
  import { invokeStepReferences, outcomeMap, stepProblems, stepWarnings, workflowDiagram, type Step } from '../workflow.ts';
6
6
  import type { ComponentSpec } from './components.ts';
7
7
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
8
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
8
9
 
9
10
  // Shared Components → Workflows: versions (APEX: development, active,
10
11
  // inactive), the diagram of the steps, problems with them, and the instances
@@ -53,7 +54,7 @@ export async function workflowBeforeSave(values: Record<string, unknown>, cid: s
53
54
  return false;
54
55
  }
55
56
  };
56
- if (!cid && hasDraft(values.steps)) throw new Error('Draft steps belong in a development version: create the workflow without them, then create a new version to model with drafts.');
57
+ if (!cid && hasDraft(values.steps)) throw new Error(tx('wfv.draft_new'));
57
58
  if (!cid || values.steps === undefined) return;
58
59
  const d = await owner.one('select version, dev_version, steps = $2::jsonb as same from meta.workflow_definition where id = $1', [cid, values.steps]);
59
60
  if (!d) return;
@@ -61,8 +62,8 @@ export async function workflowBeforeSave(values: Record<string, unknown>, cid: s
61
62
  values.dev_steps = values.steps;
62
63
  delete values.steps;
63
64
  } else if (d.same) delete values.steps;
64
- else if (hasDraft(values.steps)) throw new Error(`Version ${d.version} is active: draft steps belong in a development version.`);
65
- else throw new Error(`Version ${d.version} is active and can't be changed. Create a new version to edit its steps.`);
65
+ else if (hasDraft(values.steps)) throw new Error(tx('wfv.draft_active', { version: d.version }));
66
+ else throw new Error(tx('wfv.active_fixed', { version: d.version }));
66
67
  }
67
68
 
68
69
  export async function workflowExtras(appId: number, row: Definition, s: Session, query: Record<string, string | undefined> = {}) {
@@ -103,7 +104,7 @@ export async function workflowExtras(appId: number, row: Definition, s: Session,
103
104
  const action = `${base}/versions`;
104
105
  const usage = (v: string) => {
105
106
  const u = perVersion.get(v);
106
- return u ? `${u.n}${u.running ? ` (${u.running} running)` : ''}` : '0';
107
+ return u ? `${u.n}${u.running ? ` ${tx('wfv.running', { n: u.running })}` : ''}` : '0';
107
108
  };
108
109
  const inactive = Array.isArray(row.inactive_versions) ? row.inactive_versions : [];
109
110
  const nextLabel = String(Math.max(0, ...[row.version, ...inactive.map((v) => v.version)].filter((v) => /^\d{1,9}$/.test(v)).map(Number)) + 1);
@@ -112,58 +113,58 @@ export async function workflowExtras(appId: number, row: Definition, s: Session,
112
113
  { version: row.version, state: 'active', at: row.activated_at, steps: row.steps },
113
114
  ...[...inactive].reverse().map((v) => ({ version: v.version, state: 'inactive', at: v.activated_at, steps: v.steps })),
114
115
  ];
115
- return html`<fieldset class="prop-group u-mt125"><legend>Versions</legend>
116
- <p class="muted u-mt0">New instances start the <b>active</b> version; running instances keep the version they started with. Edit a <b>development</b> version, then activate it.</p>
117
- <div class="table-wrap"><table class="report report-reflow wf-versions"><thead><tr><th>Version</th><th>State</th><th>Activated</th><th class="num">Instances</th><th></th></tr></thead><tbody>
116
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('wfv.versions')}</legend>
117
+ <p class="muted u-mt0">${tx.rich('wfv.intro', { active: html`<b>${tx('wfv.state.active')}</b>`, development: html`<b>${tx('wfv.state.development')}</b>` })}</p>
118
+ <div class="table-wrap"><table class="report report-reflow wf-versions"><thead><tr><th>${tx('wfv.version')}</th><th>${tx('wfv.state')}</th><th>${tx('wfv.activated')}</th><th class="num">${tx('wfv.instances')}</th><th></th></tr></thead><tbody>
118
119
  ${versions.map((v, i) => html`<tr>
119
- <td data-label="Version"><b>${v.version}</b></td>
120
- <td data-label="State"><span class="tag${v.state === 'active' ? ' tag-info' : v.state === 'development' ? ' tag-warning' : ''}">${v.state}</span></td>
121
- <td data-label="Activated">${v.state === 'development' ? '—' : when(v.at)}</td>
122
- <td class="num" data-label="Instances">${usage(v.version)}</td>
120
+ <td data-label="${tx('wfv.version')}"><b>${v.version}</b></td>
121
+ <td data-label="${tx('wfv.state')}"><span class="tag${v.state === 'active' ? ' tag-info' : v.state === 'development' ? ' tag-warning' : ''}">${tx(`wfv.state.${v.state}` as BuilderKey)}</span></td>
122
+ <td data-label="${tx('wfv.activated')}">${v.state === 'development' ? '—' : when(v.at)}</td>
123
+ <td class="num" data-label="${tx('wfv.instances')}">${usage(v.version)}</td>
123
124
  <td data-label="">${v.state === 'development'
124
125
  ? html`<form method="post" action="${action}" class="wf-version-actions">${csrf(s)}
125
- <button class="btn btn-hot" name="action" value="activate"${problems.length || drafts.length ? raw(' disabled') : ''} data-confirm="${`Activate version ${v.version}? New instances start it; running ones keep their version.`}">Activate</button>
126
- <button class="btn btn-danger" name="action" value="discard" data-confirm="${`Discard version ${v.version} and its changes?`}">Discard</button></form>`
127
- : v.steps !== shown ? html`<details class="wf-version-diagram"><summary>Diagram</summary><div class="wf-wrap">${workflowDiagram(v.steps, { id: `v${i}` })}</div></details>` : ''}</td>
126
+ <button class="btn btn-hot" name="action" value="activate"${problems.length || drafts.length ? raw(' disabled') : ''} data-confirm="${tx('wfv.activate_confirm', { version: v.version })}">${tx('wfv.activate')}</button>
127
+ <button class="btn btn-danger" name="action" value="discard" data-confirm="${tx('wfv.discard_confirm', { version: v.version })}">${tx('wfv.discard')}</button></form>`
128
+ : v.steps !== shown ? html`<details class="wf-version-diagram"><summary>${tx('wfv.diagram')}</summary><div class="wf-wrap">${workflowDiagram(v.steps, { id: `v${i}` })}</div></details>` : ''}</td>
128
129
  </tr>`)}
129
130
  </tbody></table></div>
130
131
  ${dev
131
132
  ? ''
132
133
  : html`<form method="post" action="${action}" class="search u-mwnone u-mt075">${csrf(s)}
133
- <label class="sr-only" for="wf-new-version">Label of the new version</label>
134
+ <label class="sr-only" for="wf-new-version">${tx('wfv.new_label')}</label>
134
135
  <input id="wf-new-version" name="version" placeholder="${nextLabel}" maxlength="30" aria-describedby="wf-new-version-help">
135
- <button class="btn" name="action" value="new">Create new version</button>
136
- <small id="wf-new-version-help" class="help">A copy of version ${row.version} to edit; the label is optional (${nextLabel}).</small>
136
+ <button class="btn" name="action" value="new">${tx('wfv.new')}</button>
137
+ <small id="wf-new-version-help" class="help">${tx('wfv.new_help', { version: row.version, next: nextLabel })}</small>
137
138
  </form>`}
138
139
  </fieldset>
139
- <fieldset class="prop-group u-mt125"><legend>Diagram of version ${dev ? `${row.dev_version} (development)` : row.version}</legend>
140
+ <fieldset class="prop-group u-mt125"><legend>${dev ? tx('wfv.diagram_dev', { version: row.dev_version! }) : tx('wfv.diagram_of', { version: row.version })}</legend>
140
141
  ${problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : ''}
141
142
  ${warnings.length ? html`<div class="alert wf-warnings" role="status"><ul class="u-m0">${warnings.map((p) => html`<li>${p}</li>`)}</ul></div>` : ''}
142
- ${drafts.length ? html`<div class="alert wf-warnings" role="status">Draft steps (${drafts.join(', ')}) are placeholders: replace them before activating version ${row.dev_version}.</div>` : ''}
143
+ ${drafts.length ? html`<div class="alert wf-warnings" role="status">${tx('wfv.drafts', { steps: drafts.join(', '), version: row.dev_version! })}</div>` : ''}
143
144
  <div class="wf-wrap">${workflowDiagram(shown, { id: 'def' })}</div>
144
145
  <p class="wf-export">
145
- <a class="btn btn-sm" href="${`${base}/diagram.svg?v=${encodeURIComponent(dev ? row.dev_version! : row.version)}`}" download data-wf-svg>Download SVG</a>
146
- <button type="button" class="btn btn-sm" data-wf-png hidden>Download PNG</button>
146
+ <a class="btn btn-sm" href="${`${base}/diagram.svg?v=${encodeURIComponent(dev ? row.dev_version! : row.version)}`}" download data-wf-svg>${tx('wfv.svg')}</a>
147
+ <button type="button" class="btn btn-sm" data-wf-png hidden>${tx('wfv.png')}</button>
147
148
  </p>
148
- <p class="muted small">Instances: ${counts.length ? counts.map((c, i) => html`${i ? ', ' : ''}${c.n} ${c.state}`) : 'none yet'}. Running instances keep the steps of the version they started with.</p>
149
+ <p class="muted small">${tx.rich('wfv.counts', { counts: counts.length ? html`${counts.map((c, i) => html`${i ? ', ' : ''}${c.n} ${c.state}`)}` : tx('wfv.none_yet') })}</p>
149
150
  </fieldset>
150
151
  ${instances.length
151
- ? html`<fieldset class="prop-group u-mt125"><legend>Instances</legend>
152
+ ? html`<fieldset class="prop-group u-mt125"><legend>${tx('wfv.instances')}</legend>
152
153
  ${instances.some((w) => ['active', 'waiting', 'faulted'].includes(w.state) && (w.version ?? '1') !== row.version)
153
- ? html`<p class="u-mt0"><a class="btn btn-sm" href="${`${base}/migrate`}" title="Migrate running instances to a newer version">Migrate instances…</a></p>` : ''}
154
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">Id</th><th>Title</th><th>Version</th><th>State</th><th>At</th><th>Started</th></tr></thead><tbody>
154
+ ? html`<p class="u-mt0"><a class="btn btn-sm" href="${`${base}/migrate`}" title="${tx('wfv.migrate_title')}">${tx('wfv.migrate')}</a></p>` : ''}
155
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">${tx('wfv.id')}</th><th>${tx('wfv.title')}</th><th>${tx('wfv.version')}</th><th>${tx('wfv.state')}</th><th>${tx('wfv.at')}</th><th>${tx('wfv.started')}</th></tr></thead><tbody>
155
156
  ${instances.map((w) => html`<tr${picked?.id === w.id ? raw(' aria-current="true"') : ''}>
156
- <td class="num" data-label="Id"><a href="?c=workflow_definition-${row.id}&amp;wf=${w.id}">${w.id}</a></td>
157
- <td data-label="Title">${w.title}</td>
158
- <td data-label="Version">${w.version ?? '1'}</td>
159
- <td data-label="State"><span class="tag${w.state === 'faulted' ? ' tag-error' : w.state === 'completed' || w.state === 'terminated' ? '' : ' tag-info'}">${w.state}</span></td>
160
- <td data-label="At">${w.active_steps.join(', ') || '—'}</td>
161
- <td data-label="Started">${when(w.started_at)} · ${w.initiator}</td></tr>`)}
157
+ <td class="num" data-label="${tx('wfv.id')}"><a href="?c=workflow_definition-${row.id}&amp;wf=${w.id}">${w.id}</a></td>
158
+ <td data-label="${tx('wfv.title')}">${w.title}</td>
159
+ <td data-label="${tx('wfv.version')}">${w.version ?? '1'}</td>
160
+ <td data-label="${tx('wfv.state')}"><span class="tag${w.state === 'faulted' ? ' tag-error' : w.state === 'completed' || w.state === 'terminated' ? '' : ' tag-info'}">${w.state}</span></td>
161
+ <td data-label="${tx('wfv.at')}">${w.active_steps.join(', ') || '—'}</td>
162
+ <td data-label="${tx('wfv.started')}">${when(w.started_at)} · ${w.initiator}</td></tr>`)}
162
163
  </tbody></table></div>
163
164
  ${picked && Array.isArray(pickedSteps)
164
- ? html`<h3 class="u-mt125">Instance ${picked.id}, version ${picked.version ?? '1'}${picked.active_steps.length ? `: at ${picked.active_steps.join(', ')}` : ''}</h3>
165
+ ? html`<h3 class="u-mt125">${picked.active_steps.length ? tx('wfv.instance_at', { id: picked.id, version: picked.version ?? '1', steps: picked.active_steps.join(', ') }) : tx('wfv.instance', { id: picked.id, version: picked.version ?? '1' })}</h3>
165
166
  <div class="wf-wrap">${workflowDiagram(pickedSteps, { active: picked.active_steps, id: `wf${picked.id}` })}</div>`
166
- : html`<p class="muted small">Pick an instance to see where it is in the diagram of its version.</p>`}
167
+ : html`<p class="muted small">${tx('wfv.pick')}</p>`}
167
168
  </fieldset>`
168
169
  : ''}`;
169
170
  }
@@ -182,20 +183,20 @@ export async function workflowBuilderRoutes(app: FastifyInstance) {
182
183
  try {
183
184
  if (b.action === 'new') {
184
185
  const label = String(b.version ?? '').trim();
185
- if (label && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,29}$/.test(label)) throw new Error('A version label is letters, digits, dots, dashes and underscores (at most 30).');
186
+ if (label && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,29}$/.test(label)) throw new Error(tx('wfv.bad_label'));
186
187
  const v = (await owner.one('select meta.new_workflow_version($1, $2) as v', [d.id, label || null])).v;
187
- flash(s, `Version ${v} created (development): edit its steps, then activate it.`);
188
+ flash(s, tx('wfv.created', { version: v }));
188
189
  } else if (b.action === 'activate') {
189
- if (!d.dev_version) throw new Error('There is no version in development.');
190
+ if (!d.dev_version) throw new Error(tx('wfv.no_dev'));
190
191
  const tasks = new Set((await owner.query('select name from meta.task_definition where app_id = $1', [id])).rows.map((r) => r.name as string));
191
192
  const outcomes = outcomeMap((await owner.query('select name, type, outcomes from meta.task_definition where app_id = $1', [id])).rows);
192
193
  const problems = stepProblems(d.dev_steps, tasks, { outcomes, drafts: false });
193
- if (problems.length) throw new Error(`Version ${d.dev_version} can't be activated: ${problems.join(' ')}`);
194
+ if (problems.length) throw new Error(tx('wfv.cant_activate', { version: d.dev_version, problems: problems.join(' ') }));
194
195
  const v = (await owner.one('select meta.activate_workflow_version($1) as v', [d.id])).v;
195
- flash(s, `Version ${v} is active: new instances start it.`);
196
+ flash(s, tx('wfv.activated_flash', { version: v }));
196
197
  } else if (b.action === 'discard') {
197
198
  await owner.query('select meta.discard_workflow_version($1)', [d.id]);
198
- flash(s, d.dev_version ? `Version ${d.dev_version} discarded.` : 'There was no version in development.');
199
+ flash(s, d.dev_version ? tx('wfv.discarded', { version: d.dev_version }) : tx('wfv.was_no_dev'));
199
200
  } else return reply.code(400).send('Unknown action');
200
201
  } catch (e) {
201
202
  flash(s, (e as Error).message, 'error');
@@ -10,6 +10,7 @@ import {
10
10
  import { lockedFor } from '../cli/working-copy.ts';
11
11
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
12
12
  import { appAllowed } from './workspaces.ts';
13
+ import { scopedTx as tx } from './i18n.ts';
13
14
 
14
15
  // Working copies (APEX 24.1+): App → Working copies lists an application's
15
16
  // copies and makes new ones; a copy's page compares it with its main
@@ -22,12 +23,12 @@ import { appAllowed } from './workspaces.ts';
22
23
  const appRow = async (id: string) => (/^\d{1,9}$/.test(id) ? owner.one('select id, name, alias from meta.app where id = $1', [id]) : undefined);
23
24
 
24
25
  const SIDE: Record<string, string> = { added: 'added', deleted: 'deleted', changed: 'changed' };
25
- const STATUS: Record<Change['status'], string> = { copy: 'Changed in the copy', main: 'Changed in the main application', conflict: 'Conflict: changed on both sides' };
26
+ const STATUS = (): Record<Change['status'], string> => ({ copy: tx('wcp.changed_in_the_copy'), main: tx('wcp.changed_in_the_main_application'), conflict: tx('wcp.conflict_changed_on_both_sides') });
26
27
 
27
28
  function diffPanel(ch: Change) {
28
29
  const paths = [...new Set([...(ch.main?.keys() ?? []), ...(ch.copy?.keys() ?? [])])].sort();
29
30
  return html`<div id="diff">
30
- <p class="muted u-mt0">Lines with <code>-</code> are the main application's, lines with <code>+</code> the working copy's.</p>
31
+ <p class="muted u-mt0">${tx.code('wcp.lines_with_are_the_main')}</p>
31
32
  ${paths.map((p) => {
32
33
  const a = ch.main?.get(p), b = ch.copy?.get(p);
33
34
  if (a && b && a.equals(b)) return '';
@@ -46,38 +47,33 @@ export async function workingCopyRoutes(app: FastifyInstance) {
46
47
  let body;
47
48
  if (wc) {
48
49
  const main = await owner.one('select id, name, alias from meta.app where id = $1', [wc.main_app_id]);
49
- body = region('Working copy', html`
50
- <p class="u-mt0">This application is the working copy <b>${wc.name}</b> of
51
- <a href="${BASE}/apps/${main.id}">${main.name}</a> (<code>/a/${main.alias}</code>), made by ${wc.created_by}
52
- on ${wc.created_at.slice(0, 16)}.${wc.refreshed_at ? ` Last refreshed ${wc.refreshed_at.slice(0, 16)}.` : ''}${wc.merged_at ? ` Last merged ${wc.merged_at.slice(0, 16)} by ${wc.merged_by}.` : ''}</p>
53
- <p class="muted">Change it like any application: it runs against the main application's schema and data, with
54
- its automations and synchronisations switched off. Then compare it with the main application and merge it back.</p>
50
+ body = region(tx('wcp.working_copy'), html`
51
+ <p class="u-mt0">${tx.rich('wcp.is_copy', { name: html`<b>${wc.name}</b>`, main: html`<a href="${BASE}/apps/${main.id}">${main.name}</a>`, path: html`<code>/a/${main.alias}</code>`, by: wc.created_by, at: wc.created_at.slice(0, 16) })}${wc.refreshed_at ? ` ${tx('wcp.last_refreshed', { at: wc.refreshed_at.slice(0, 16) })}` : ''}${wc.merged_at ? ` ${tx('wcp.last_merged_by', { at: wc.merged_at.slice(0, 16), by: wc.merged_by })}` : ''}</p>
52
+ <p class="muted">${tx('wcp.change_it_like_any_application')}</p>
55
53
  <div class="buttons">
56
- <a class="btn btn-hot" href="${BASE}/apps/${a.id}/compare">${icon('layers')} Compare and merge</a>
54
+ <a class="btn btn-hot" href="${BASE}/apps/${a.id}/compare">${icon('layers')} ${tx('wcp.compare_and_merge')}</a>
57
55
  <form method="post" action="${BASE}/apps/${a.id}/working-copy/delete" class="u-inline">${csrf(s)}
58
- <button class="btn btn-danger" data-confirm="Delete the working copy ${wc.name} and its changes that are not merged?">Delete working copy</button></form>
56
+ <button class="btn btn-danger" data-confirm="${tx('wcp.delete_confirm', { name: wc.name })}">${tx('wcp.delete_working_copy')}</button></form>
59
57
  </div>`);
60
58
  } else {
61
59
  const copies = await copiesOf(a.id);
62
- body = html`${region('Working copies', html`
63
- <p class="muted u-mt0">A working copy is a second application made from this one, for changes in isolation
64
- (APEX: working copies). Compare it with this application and merge it back component by component;
65
- components changed on both sides are shown as conflicts to resolve.</p>
60
+ body = html`${region(tx('wcp.working_copies'), html`
61
+ <p class="muted u-mt0">${tx('wcp.a_working_copy_is_a')}</p>
66
62
  ${copies.length
67
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Name</th><th>Application</th><th>Made by</th><th>Made</th><th>Last merged</th><th>Actions</th></tr></thead><tbody>
63
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('wcp.name')}</th><th>${tx('wcp.application')}</th><th>${tx('wcp.made_by')}</th><th>${tx('wcp.made')}</th><th>${tx('wcp.last_merged')}</th><th>${tx('wcp.actions')}</th></tr></thead><tbody>
68
64
  ${copies.map((c) => html`<tr><td>${c.name}</td><td><a href="${BASE}/apps/${c.app_id}">${c.app_id}</a> <code>/a/${c.alias}</code></td>
69
65
  <td>${c.created_by}</td><td>${c.created_at.slice(0, 16)}</td><td>${c.merged_at ? c.merged_at.slice(0, 16) : html`<span class="muted">never</span>`}</td>
70
- <td><a class="btn btn-sm" href="${BASE}/apps/${c.app_id}/compare">${icon('layers')} Compare and merge</a></td></tr>`)}
66
+ <td><a class="btn btn-sm" href="${BASE}/apps/${c.app_id}/compare">${icon('layers')} ${tx('wcp.compare_and_merge')}</a></td></tr>`)}
71
67
  </tbody></table></div>`
72
- : html`<p class="muted">No working copies.</p>`}`)}
73
- ${region('Create a working copy', html`<form method="post" action="${BASE}/working-copies">${csrf(s)}
68
+ : html`<p class="muted">${tx('wcp.no_working_copies')}</p>`}`)}
69
+ ${region(tx('wcp.create_a_working_copy'), html`<form method="post" action="${BASE}/working-copies">${csrf(s)}
74
70
  <input type="hidden" name="main_app_id" value="${a.id}">
75
- <div class="form-grid">${input('name', 'Name', '', { required: true, placeholder: 'e.g. new-dashboard', help: `1–40 letters, digits, spaces, - or _. The copy runs at /a/${a.alias}-<name>.` })}</div>
76
- <div class="buttons"><button class="btn btn-hot">${icon('plus')} Create working copy</button></div>
71
+ <div class="form-grid">${input('name', tx('wcp.name'), '', { required: true, placeholder: tx('wcp.e_g_new_dashboard'), help: `1–40 letters, digits, spaces, - or _. The copy runs at /a/${a.alias}-<name>.` })}</div>
72
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('wcp.create_working_copy')}</button></div>
77
73
  </form>`)}`;
78
74
  }
79
75
  const main = html`${appHeader(a, 'pages', s)}<div class="ide-body">${body}</div>`;
80
- return send(reply, s, shell(s, `${a.name} working copies`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Working copies']], main));
76
+ return send(reply, s, shell(s, `${a.name} working copies`, [[tx('wcp.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('wcp.working_copies')]], main));
81
77
  });
82
78
 
83
79
  // create a working copy (not under /apps/:id, so a locked main application can still be copied)
@@ -90,7 +86,7 @@ export async function workingCopyRoutes(app: FastifyInstance) {
90
86
  try {
91
87
  const id = await createCopy(a.id, name, s.username!);
92
88
  await logActivity({ appId: a.id, username: s.username, event: 'working_copy', ip: clientIp(req), detail: `created ${name} (application ${id})` });
93
- flash(s, `Working copy ${name} created: change it, then compare and merge it back.`);
89
+ flash(s, tx('wcp.created', { name }));
94
90
  return back(reply, s, `${BASE}/apps/${id}/working-copies`);
95
91
  } catch (e) {
96
92
  if (!(e instanceof WorkingCopyError)) throw e;
@@ -107,44 +103,40 @@ export async function workingCopyRoutes(app: FastifyInstance) {
107
103
  if (!a) return reply.code(404).send('Not found');
108
104
  const l = await compare(a.id);
109
105
  if (!l) {
110
- flash(s, 'This application is not a working copy.', 'error');
106
+ flash(s, tx('wcp.this_application_is_not_a'), 'error');
111
107
  return back(reply, s, `${BASE}/apps/${a.id}/working-copies`);
112
108
  }
113
109
  const main = await owner.one('select id, name, alias from meta.app where id = $1', [l.copy.main_app_id]);
114
110
  const shown = typeof req.query.c === 'string' ? l.changes.find((ch) => ch.id === req.query.c) : undefined;
115
111
  const conflicts = l.changes.filter((ch) => ch.status === 'conflict').length;
116
112
  const count = (st: Change['status']) => l.changes.filter((ch) => ch.status === st).length;
117
- const side = (v: Change['inMain']) => (v ? SIDE[v] : html`<span class="muted">unchanged</span>`);
113
+ const side = (v: Change['inMain']) => (v ? tx.dyn(`wcp.side.${v}`, SIDE[v]) : html`<span class="muted">${tx('wcp.unchanged')}</span>`);
118
114
  const body = l.changes.length
119
- ? html`<p class="u-mt0">${count('copy')} changed in the copy, ${count('main')} changed in the main application, ${conflicts} conflict${conflicts === 1 ? '' : 's'}.</p>
115
+ ? html`<p class="u-mt0">${tx(conflicts === 1 ? 'wcp.counts_1' : 'wcp.counts', { copy: count('copy'), main: count('main'), conflicts })}</p>
120
116
  <form method="post" action="${BASE}/apps/${a.id}/merge">${csrf(s)}<input type="hidden" name="state" value="${fingerprint(l.changes)}">
121
- <div class="table-wrap"><table class="report" aria-label="Changed components">
122
- <thead><tr><th>Component</th><th>Main application</th><th>Working copy</th><th>Result</th><th>Differences</th></tr></thead><tbody>
123
- ${l.changes.map((ch, i) => html`<tr${ch.status === 'conflict' ? raw(' class="row-conflict"') : ''}><td>${ch.label}<br><small class="muted">${STATUS[ch.status]}</small></td>
117
+ <div class="table-wrap"><table class="report" aria-label="${tx('wcp.changed_components')}">
118
+ <thead><tr><th>${tx('wcp.component')}</th><th>${tx('wcp.main_application')}</th><th>${tx('wcp.working_copy')}</th><th>${tx('wcp.result')}</th><th>${tx('wcp.differences')}</th></tr></thead><tbody>
119
+ ${l.changes.map((ch, i) => html`<tr${ch.status === 'conflict' ? raw(' class="row-conflict"') : ''}><td>${ch.label}<br><small class="muted">${STATUS()[ch.status]}</small></td>
124
120
  <td>${side(ch.inMain)}</td><td>${side(ch.inCopy)}</td>
125
121
  <td>${ch.status === 'conflict'
126
- ? html`<fieldset class="u-mt0"><legend class="sr-only">Keep for ${ch.label}</legend>
127
- <label class="check"><input type="radio" name="r_${i}" value="main" required> Main</label>
128
- <label class="check"><input type="radio" name="r_${i}" value="copy" required> Copy</label></fieldset>`
129
- : ch.status === 'copy' ? 'Copy' : 'Main'}</td>
130
- <td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/compare?c=${encodeURIComponent(ch.id)}#diff">Show</a></td></tr>`)}
122
+ ? html`<fieldset class="u-mt0"><legend class="sr-only">${tx('wcp.keep_for', { label: ch.label })}</legend>
123
+ <label class="check"><input type="radio" name="r_${i}" value="main" required> ${tx('wcp.main')}</label>
124
+ <label class="check"><input type="radio" name="r_${i}" value="copy" required> ${tx('wcp.copy')}</label></fieldset>`
125
+ : ch.status === 'copy' ? tx('wcp.copy') : tx('wcp.main')}</td>
126
+ <td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/compare?c=${encodeURIComponent(ch.id)}#diff">${tx('wcp.show')}</a></td></tr>`)}
131
127
  </tbody></table></div>
132
- <p class="muted">Merging writes the result into the main application and the working copy. Refreshing writes it into
133
- the working copy only (bringing the main application's changes in). Users, sessions, saved reports, secrets and
134
- the main application's automation switches stay as they are.</p>
128
+ <p class="muted">${tx('wcp.merging_writes_the_result_into')}</p>
135
129
  <div class="buttons">
136
- <button class="btn btn-hot" name="direction" value="merge" data-confirm="Merge the working copy into ${main.name}?">Merge into ${main.name}</button>
137
- <button class="btn" name="direction" value="refresh">Refresh the copy from ${main.name}</button>
130
+ <button class="btn btn-hot" name="direction" value="merge" data-confirm="${tx('wcp.merge_confirm', { main: main.name })}">${tx('wcp.merge_into', { main: main.name })}</button>
131
+ <button class="btn" name="direction" value="refresh">${tx('wcp.refresh_from', { main: main.name })}</button>
138
132
  </div>
139
133
  </form>`
140
- : html`<p class="u-mt0">The working copy and the main application are the same: nothing to merge.</p>`;
134
+ : html`<p class="u-mt0">${tx('wcp.the_working_copy_and_the')}</p>`;
141
135
  const page = html`${appHeader(a, 'pages', s)}<div class="ide-body">
142
- ${region(`Compare with ${main.name}`, html`<p class="muted u-mt0">Working copy <b>${l.copy.name}</b> of
143
- <a href="${BASE}/apps/${main.id}">${main.name}</a>, compared per component with the main application as it was
144
- when the copy was made${l.copy.refreshed_at || l.copy.merged_at ? ', refreshed or merged' : ''}.</p>${body}`)}
145
- ${shown ? region(`Differences: ${shown.label}`, diffPanel(shown)) : ''}
136
+ ${region(tx('wcp.compare_title', { main: main.name }), html`<p class="muted u-mt0">${tx.rich(l.copy.refreshed_at || l.copy.merged_at ? 'wcp.compare_intro_since' : 'wcp.compare_intro', { copy: html`<b>${l.copy.name}</b>`, main: html`<a href="${BASE}/apps/${main.id}">${main.name}</a>` })}</p>${body}`)}
137
+ ${shown ? region(tx('wcp.diff_title', { label: shown.label }), diffPanel(shown)) : ''}
146
138
  </div>`;
147
- return send(reply, s, shell(s, `${a.name} compare`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Working copies', `${BASE}/apps/${a.id}/working-copies`], ['Compare']], page));
139
+ return send(reply, s, shell(s, `${a.name} compare`, [[tx('wcp.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('wcp.working_copies'), `${BASE}/apps/${a.id}/working-copies`], [tx('wcp.compare')]], page));
148
140
  });
149
141
 
150
142
  // merge into the main application, or refresh the copy from it
@@ -158,14 +150,14 @@ export async function workingCopyRoutes(app: FastifyInstance) {
158
150
  const target = `${BASE}/apps/${a.id}/compare`;
159
151
  if (!direction) return reply.code(400).send('Bad request');
160
152
  const l = await compare(a.id);
161
- if (!l) return reply.code(404).send('Not a working copy');
153
+ if (!l) return reply.code(404).send(tx('wcp.not_a_copy'));
162
154
  // conflicts: every one needs a choice (the fingerprint checks they are the ones shown)
163
155
  const takeCopy = new Set<string>();
164
156
  for (const [i, ch] of l.changes.entries()) {
165
157
  if (ch.status !== 'conflict') continue;
166
158
  const v = b[`r_${i}`];
167
159
  if (v !== 'main' && v !== 'copy') {
168
- flash(s, 'Choose main or copy for every conflict.', 'error');
160
+ flash(s, tx('wcp.choose_main_or_copy_for'), 'error');
169
161
  return back(reply, s, target);
170
162
  }
171
163
  if (v === 'copy') takeCopy.add(ch.id);
@@ -173,7 +165,7 @@ export async function workingCopyRoutes(app: FastifyInstance) {
173
165
  try {
174
166
  const r = await mergeCopy(a.id, { direction, takeCopy, state: String(b.state ?? ''), username: s.username!, lockedBy: lockedFor(s.username!) });
175
167
  await logActivity({ appId: direction === 'merge' ? r.mainId : a.id, username: s.username, event: 'working_copy', ip: clientIp(req), detail: `${direction === 'merge' ? 'merged' : 'refreshed'} ${l.copy.name} (${r.changes} components)` });
176
- flash(s, direction === 'merge' ? `Working copy ${l.copy.name} merged (${r.changes} components).` : `Working copy ${l.copy.name} refreshed from the main application.`);
168
+ flash(s, direction === 'merge' ? tx('wcp.merged', { name: l.copy.name, n: r.changes }) : tx('wcp.refreshed', { name: l.copy.name }));
177
169
  return back(reply, s, direction === 'merge' ? `${BASE}/apps/${r.mainId}` : target);
178
170
  } catch (e) {
179
171
  if (!(e instanceof WorkingCopyError)) throw e;
@@ -189,11 +181,11 @@ export async function workingCopyRoutes(app: FastifyInstance) {
189
181
  if (!a) return reply.code(404).send('Not found');
190
182
  const wc = await copyOf(a.id);
191
183
  if (!wc || !(await deleteCopy(a.id))) {
192
- flash(s, 'This application is not a working copy.', 'error');
184
+ flash(s, tx('wcp.this_application_is_not_a'), 'error');
193
185
  return back(reply, s, `${BASE}/apps/${a.id}/working-copies`);
194
186
  }
195
187
  await logActivity({ appId: wc.main_app_id, username: s.username, event: 'working_copy', ip: clientIp(req), detail: `deleted ${wc.name}` });
196
- flash(s, `Working copy ${wc.name} deleted.`);
188
+ flash(s, tx('wcp.deleted', { name: wc.name }));
197
189
  return back(reply, s, `${BASE}/apps/${wc.main_app_id}/working-copies`);
198
190
  });
199
191
  }
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
5
5
  import { clientIp, logActivity } from '../session.ts';
6
6
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
7
7
  import { currentWorkspace } from './workspaces.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // Workspace utilities → Manage backups (APEX: Workspace Utilities → Manage
10
11
  // Backups): the backups (082) of every application of the current workspace on
@@ -26,28 +27,28 @@ export async function workspaceBackupRoutes(app: FastifyInstance) {
26
27
  where meta.app_workspace(a.id) = $1
27
28
  group by a.id order by lower(a.name), a.id`, [currentWorkspace(s)])).rows;
28
29
  const total = rows.reduce((t, r) => t + Number(r.bytes), 0);
29
- const main = html`<div class="title-row"><h1>Manage backups</h1></div>
30
- <p class="muted u-mt0">The backups of the applications of ${s.workspace?.name ?? 'this workspace'}: one before every import that replaces an application, a merge or a restore, one a day when it changed, and the ones made by hand. Open an application's backups to download, restore or delete one.</p>
30
+ const main = html`<div class="title-row"><h1>${tx('wu.backups')}</h1></div>
31
+ <p class="muted u-mt0">${s.workspace?.name ? tx('wbk.intro', { name: s.workspace.name }) : tx('wbk.intro_this')}</p>
31
32
  <div class="stat-grid u-mb1">
32
- ${[['Applications', rows.length], ['Backups', rows.reduce((t, r) => t + r.backups, 0)], ['Storage', size(total)], ['Changed since their newest backup', rows.filter((r) => r.changed && r.backups_keep > 0).length]]
33
+ ${[[tx('wbk.applications'), rows.length], [tx('wbk.backups'), rows.reduce((t, r) => t + r.backups, 0)], [tx('wbk.storage'), size(total)], [tx('wbk.changed_since_newest'), rows.filter((r) => r.changed && r.backups_keep > 0).length]]
33
34
  .map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
34
35
  </div>
35
- ${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
36
- <thead><tr><th>Application</th><th class="num">Backups</th><th class="num">Kept</th><th>Newest (UTC)</th><th class="num">Size</th><th></th></tr></thead>
36
+ ${region(tx('wbk.applications'), html`<div class="table-wrap"><table class="report report-reflow">
37
+ <thead><tr><th>${tx('wbk.application')}</th><th class="num">${tx('wbk.backups')}</th><th class="num">${tx('wbk.kept')}</th><th>${tx('wbk.newest_utc')}</th><th class="num">${tx('wbk.size')}</th><th></th></tr></thead>
37
38
  <tbody>${rows.length ? rows.map((a) => html`<tr>
38
- <td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
39
- <td class="num" data-label="Backups">${a.backups}</td>
40
- <td class="num" data-label="Kept">${a.backups_keep === 0 ? html`<span class="tag">none</span>` : a.backups_keep}</td>
41
- <td data-label="Newest">${a.newest ? when(a.newest) : html`<span class="muted">never</span>`}${a.changed && a.backups_keep > 0 && a.newest ? html` <span class="tag">changed since</span>` : ''}</td>
42
- <td class="num" data-label="Size">${size(Number(a.bytes))}</td>
43
- <td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} Backups</a></td></tr>`)
44
- : html`<tr><td colspan="6" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}
45
- ${rows.length ? region('Back up all applications', html`<form method="post" action="${BASE}/backups">${csrf(s)}
46
- <p class="muted u-mt0">Makes a backup of every application of this workspace that keeps backups.</p>
47
- <div class="form-grid">${input('note', 'Note', '', { placeholder: 'e.g. before the upgrade' })}
48
- <div class="field"><label class="check"><input type="checkbox" name="changed" value="true" checked> Only applications changed since their newest backup</label></div></div>
49
- <div class="buttons"><button class="btn btn-hot">${icon('save')} Back up now</button></div></form>`) : ''}`;
50
- return send(reply, s, shell(s, 'Manage backups', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Manage backups']], main));
39
+ <td data-label="${tx('wbk.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
40
+ <td class="num" data-label="${tx('wbk.backups')}">${a.backups}</td>
41
+ <td class="num" data-label="${tx('wbk.kept')}">${a.backups_keep === 0 ? html`<span class="tag">${tx('wbk.none')}</span>` : a.backups_keep}</td>
42
+ <td data-label="${tx('wbk.newest')}">${a.newest ? when(a.newest) : html`<span class="muted">${tx('wbk.never')}</span>`}${a.changed && a.backups_keep > 0 && a.newest ? html` <span class="tag">${tx('wbk.changed_since')}</span>` : ''}</td>
43
+ <td class="num" data-label="${tx('wbk.size')}">${size(Number(a.bytes))}</td>
44
+ <td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} ${tx('wbk.backups')}</a></td></tr>`)
45
+ : html`<tr><td colspan="6" class="empty">${tx('wbk.no_apps')}</td></tr>`}</tbody></table></div>`)}
46
+ ${rows.length ? region(tx('wbk.back_up_all'), html`<form method="post" action="${BASE}/backups">${csrf(s)}
47
+ <p class="muted u-mt0">${tx('wbk.back_up_all_text')}</p>
48
+ <div class="form-grid">${input('note', tx('wbk.note'), '', { placeholder: tx('wbk.note_placeholder') })}
49
+ <div class="field"><label class="check"><input type="checkbox" name="changed" value="true" checked> ${tx('wbk.only_changed')}</label></div></div>
50
+ <div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('wbk.back_up_now')}</button></div></form>`) : ''}`;
51
+ return send(reply, s, shell(s, tx('wu.backups'), [[tx('rail.app_builder'), BASE], [tx('wu.title'), `${BASE}/utilities`], [tx('wu.backups')]], main));
51
52
  });
52
53
 
53
54
  app.post(`${BASE}/backups`, async (req: Req, reply) => {
@@ -71,8 +72,8 @@ export async function workspaceBackupRoutes(app: FastifyInstance) {
71
72
  }
72
73
  }
73
74
  await logActivity({ appId: null, username: s.username, event: 'backup', ip: clientIp(req), detail: `workspace ${currentWorkspace(s)}: ${made} backup(s)` });
74
- if (failed.length) flash(s, `${made} backup(s) made; not backed up: application${failed.length > 1 ? 's' : ''} ${failed.join(', ')}.`, 'error');
75
- else flash(s, made ? `${made} backup${made > 1 ? 's' : ''} made.` : 'Nothing to back up: every application is unchanged since its newest backup, or keeps none.');
75
+ if (failed.length) flash(s, tx(failed.length > 1 ? 'wbk.failed_many' : 'wbk.failed_one', { n: made, ids: failed.join(', ') }), 'error');
76
+ else flash(s, made ? tx(made > 1 ? 'wbk.made_many' : 'wbk.made_one', { n: made }) : tx('wbk.nothing'));
76
77
  return back(reply, s, `${BASE}/backups`);
77
78
  });
78
79
  }
@@ -8,6 +8,7 @@ import { clientIp, logActivity, type Session } from '../session.ts';
8
8
  import { formValues, parseForm, PROVIDER_TEXT, runCheck, serviceForm } from './mail.ts';
9
9
  import { back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
10
10
  import { workspaceAdmin } from './workspaces.ts';
11
+ import { scopedTx as tx } from './i18n.ts';
11
12
 
12
13
  // (sprint 118, WS-9; APEX 26.1 workspace-level SMTP credentials) A workspace's
13
14
  // own mail service, managed by its administrators on /builder/workspaces/:id/mail:
@@ -23,8 +24,8 @@ type Service = MailService & { has_secret: boolean };
23
24
 
24
25
  async function wsAdminOnly(s: Session, reply: FastifyReply, wsId: string) {
25
26
  if (await workspaceAdmin(s.username, wsId)) return true;
26
- const main = html`<h1 class="u-mb1">Workspace mail service</h1><div class="alert alert-error" role="alert">Only the workspace's administrators manage its mail service.</div>`;
27
- await send(reply.code(403), s, shell(s, 'Workspace mail service', [['Administration'], ['Workspace mail service']], main, 'users'));
27
+ const main = html`<h1 class="u-mb1">${tx('wml.workspace_mail_service')}</h1><div class="alert alert-error" role="alert">${tx('wml.only_the_workspace_s_administrators')}</div>`;
28
+ await send(reply.code(403), s, shell(s, tx('wml.workspace_mail_service'), [[tx('wml.administration')], [tx('wml.workspace_mail_service')]], main, 'users'));
28
29
  return false;
29
30
  }
30
31
 
@@ -43,22 +44,22 @@ export async function workspaceMailRoutes(app: FastifyInstance) {
43
44
  from meta.app a where meta.app_workspace(a.id) = $1 order by a.alias`, [w.id])).rows;
44
45
  const shown = typed ? { ...x, description: typed.description, provider: typed.provider, settings: typed.settings, senders: typed.senders, enabled: typed.enabled } : x ?? {};
45
46
  const main = html`
46
- <div class="title-row"><h1>Mail service · ${w.name}</h1></div>
47
- <p class="muted u-mt0">This workspace's own mail service: its applications that an administrator gave no service of their own send through it (<code>meta.send_mail()</code>, the Send e-mail process). The secret is stored encrypted and never shown again. It uses the provider's own API; an SMTP server must be on the installation's allow-list and this workspace's <a href="${BASE}/network">network access</a> list.</p>
47
+ <div class="title-row"><h1>${tx('wml.title', { workspace: w.name })}</h1></div>
48
+ <p class="muted u-mt0">${tx.rich('wml.intro', { fn: html`<code>meta.send_mail()</code>`, network: html`<a href="${BASE}/network">${tx('wml.network_access')}</a>` })}</p>
48
49
  <div class="mail-form-page">
49
- ${region('Settings', html`
50
- <form method="get" class="provider-switch">${select('provider', 'Provider', provider, PROVIDERS.map((p): [string, string] => [p, PROVIDER_LABELS[p]]),
51
- x && provider !== x.provider ? 'Not saved yet: fill in the settings below and save to switch.' : PROVIDER_TEXT[provider][1])}<button class="btn btn-sm">Change</button></form>
50
+ ${region(tx('wml.settings'), html`
51
+ <form method="get" class="provider-switch">${select('provider', tx('wml.provider'), provider, PROVIDERS.map((p): [string, string] => [p, PROVIDER_LABELS[p]]),
52
+ x && provider !== x.provider ? tx('wml.not_saved_yet_fill_in') : PROVIDER_TEXT(provider)[1])}<button class="btn btn-sm">${tx('wml.change')}</button></form>
52
53
  ${serviceForm(shown, provider, `${BASE}/workspaces/${w.id}/mail`, csrf(s), !x, tried, { id: w.id })}
53
54
  ${x ? html`<form method="post" action="${BASE}/workspaces/${w.id}/mail/delete" class="danger-zone">${csrf(s)}
54
- <button class="btn btn-danger" data-confirm="Delete this workspace's mail service? Its applications without a service of their own can't send mail then.">Delete service</button></form>` : ''}`)}
55
+ <button class="btn btn-danger" data-confirm="${tx('wml.delete_this_workspace_s_mail')}">${tx('wml.delete_service')}</button></form>` : ''}`)}
55
56
  </div>
56
- ${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
57
- <thead><tr><th>Application</th><th>Sends through</th></tr></thead>
58
- <tbody>${users.length ? users.map((a) => html`<tr><td data-label="Application">${a.alias}</td>
59
- <td data-label="Sends through">${a.own ? html`${a.own} <span class="muted">(chosen by an administrator)</span>` : x ? 'this service' : html`<span class="muted">nothing yet</span>`}</td></tr>`)
60
- : html`<tr><td colspan="2" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}`;
61
- return send(reply, s, shell(s, `Mail service · ${w.name}`, [['Administration'], [w.name, `${BASE}/workspaces/${w.id}`], ['Mail service']], main, 'users'));
57
+ ${region(tx('wml.applications'), html`<div class="table-wrap"><table class="report report-reflow">
58
+ <thead><tr><th>${tx('wml.application')}</th><th>${tx('wml.sends_through')}</th></tr></thead>
59
+ <tbody>${users.length ? users.map((a) => html`<tr><td data-label="${tx('wml.application')}">${a.alias}</td>
60
+ <td data-label="${tx('wml.sends_through')}">${a.own ? html`${a.own} <span class="muted">${tx('wml.chosen_by_an_administrator')}</span>` : x ? 'this service' : html`<span class="muted">${tx('wml.nothing_yet')}</span>`}</td></tr>`)
61
+ : html`<tr><td colspan="2" class="empty">${tx('wml.no_applications')}</td></tr>`}</tbody></table></div>`)}`;
62
+ return send(reply, s, shell(s, tx('wml.title', { workspace: w.name }), [[tx('wml.administration')], [w.name, `${BASE}/workspaces/${w.id}`], [tx('wml.mail_service')]], main, 'users'));
62
63
  };
63
64
 
64
65
  app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/mail`, async (req: Req, reply) => {
@@ -88,7 +89,7 @@ export async function workspaceMailRoutes(app: FastifyInstance) {
88
89
  `insert into meta.mail_service (name, description, provider, settings, senders, enabled, secret_enc, workspace_id) values ($1, $2, $3, $4, $5, $6, $7, $8)`,
89
90
  [`WORKSPACE_${w.id}`, v.description, v.provider, JSON.stringify(v.settings), v.senders, v.enabled, secret, w.id]);
90
91
  await logActivity({ appId: null, username: s.username, event: 'mail_service', ip: clientIp(req), detail: `${old ? 'changed' : 'added'} the mail service of workspace ${w.id} (${v.provider})` });
91
- flash(s, old ? 'Mail service saved.' : 'Mail service added: send a test mail to check it.');
92
+ flash(s, old ? tx('wml.mail_service_saved') : tx('wml.mail_service_added_send_a'));
92
93
  } catch (e) {
93
94
  flash(s, (e as Error).message.includes('mail_service_name_key') ? `A service named WORKSPACE_${w.id} exists already: an administrator renames it first.` : (e as Error).message, 'error');
94
95
  }
@@ -115,7 +116,7 @@ export async function workspaceMailRoutes(app: FastifyInstance) {
115
116
  if (!s || !(await wsAdminOnly(s, reply, req.params.id))) return;
116
117
  const r = await owner.query('delete from meta.mail_service where workspace_id = $1', [req.params.id]);
117
118
  await logActivity({ appId: null, username: s.username, event: 'mail_service', ip: clientIp(req), detail: `deleted the mail service of workspace ${req.params.id}` });
118
- flash(s, r.rowCount ? 'Mail service deleted.' : 'There was no mail service.');
119
+ flash(s, r.rowCount ? tx('wml.mail_service_deleted') : tx('wml.there_was_no_mail_service'));
119
120
  return back(reply, s, `${BASE}/workspaces/${req.params.id}/mail`);
120
121
  });
121
122
  }