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
@@ -4,6 +4,7 @@ import { html, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { COMPONENTS, type FieldKind } from './components.ts';
6
6
  import { appHeader, BASE, designerUrl, developer, region, send, shell, type Req } from './ui.ts';
7
+ import { scopedTx as tx } from './i18n.ts';
7
8
 
8
9
  // App Builder → Search and "Where used": every component of an application
9
10
  // (pages, regions, items, buttons, dynamic actions, validations, processes
@@ -184,8 +185,8 @@ export function targetOf(kind: string, row: any): Target | null {
184
185
 
185
186
  function hitList(hits: Hit[], showPage = true) {
186
187
  return html`<ul class="hits">${hits.map((h) => html`<li>
187
- <a href="${h.entry.url}">${icon(h.entry.icon)}<span>${h.entry.kindLabel}: ${h.entry.label}</span></a>
188
- ${showPage && h.entry.pageNo !== null && h.entry.kind !== 'page' ? html`<span class="tag">page ${h.entry.pageNo}</span>` : ''}
188
+ <a href="${h.entry.url}">${icon(h.entry.icon)}<span>${tx.dyn(`comp.${h.entry.kind}`, h.entry.kindLabel)}: ${h.entry.label}</span></a>
189
+ ${showPage && h.entry.pageNo !== null && h.entry.kind !== 'page' ? html`<span class="tag">${tx('srch.page_tag', { n: h.entry.pageNo })}</span>` : ''}
189
190
  <span class="muted">${h.field}</span>
190
191
  <code class="hit-snippet">${h.snippet}</code>
191
192
  </li>`)}</ul>`;
@@ -197,9 +198,9 @@ export async function usedInPanel(appId: number, kind: string, row: any): Promis
197
198
  if (!target) return '';
198
199
  const hits = whereUsed(await appEntries(appId), target, { kind, id: row.id });
199
200
  return html`<section class="used-in" aria-labelledby="used-in-${kind}-${row.id}">
200
- <h3 id="used-in-${kind}-${row.id}">Used in (${hits.length})</h3>
201
- ${hits.length ? hitList(hits) : html`<p class="muted">Not referenced anywhere in this application${target.type === 'item' ? ' (outside its own definition)' : ''}.</p>`}
202
- ${target.type === 'item' || target.type === 'page' ? html`<p class="muted small">Found in SQL, substitutions, links and settings of this application. Database code (views, functions, policies) isn't searched.</p>` : ''}
201
+ <h3 id="used-in-${kind}-${row.id}">${tx('srch.used_in', { n: hits.length })}</h3>
202
+ ${hits.length ? hitList(hits) : html`<p class="muted">${tx(target.type === 'item' ? 'srch.not_referenced_item' : 'srch.not_referenced')}</p>`}
203
+ ${target.type === 'item' || target.type === 'page' ? html`<p class="muted small">${tx('srch.found_in')}</p>` : ''}
203
204
  </section>`;
204
205
  }
205
206
 
@@ -214,16 +215,16 @@ export async function searchRoutes(app: FastifyInstance) {
214
215
  const groups = new Map<string, Hit[]>();
215
216
  for (const h of hits) groups.set(h.entry.kindLabel, [...(groups.get(h.entry.kindLabel) ?? []), h]);
216
217
  const main = html`${appHeader(a, 'search', s)}
217
- ${region('Search', html`<form method="get" action="${BASE}/apps/${a.id}/search" class="search u-mwnone" role="search">
218
- <input type="search" name="q" value="${q}" placeholder="Text in names, titles, SQL, settings…" aria-label="Search the application" autofocus>
219
- <button class="btn btn-hot">Search</button></form>
218
+ ${region(tx('common.search'), html`<form method="get" action="${BASE}/apps/${a.id}/search" class="search u-mwnone" role="search">
219
+ <input type="search" name="q" value="${q}" placeholder="${tx('srch.placeholder')}" aria-label="${tx('srch.aria')}" autofocus>
220
+ <button class="btn btn-hot">${tx('common.search')}</button></form>
220
221
  ${q.trim()
221
222
  ? hits.length
222
- ? html`<p class="muted">${hits.length}${hits.length >= 500 ? '+' : ''} matches</p>
223
- ${[...groups].map(([label, list]) => html`<h3>${label} (${list.length})</h3>${hitList(list)}`)}`
224
- : html`<p class="muted">Nothing found for “${q}”.</p>`
225
- : html`<p class="muted">Searches every page and component of ${a.name}: names, titles, SQL, conditions, settings and help texts.</p>`}`)}`;
226
- return send(reply, s, shell(s, `Search · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Search']], main));
223
+ ? html`<p class="muted">${tx(hits.length >= 500 ? 'srch.matches_more' : 'srch.matches', { n: hits.length })}</p>
224
+ ${[...groups].map(([label, list]) => html`<h3>${tx.dyn(`comp.${list[0].entry.kind}`, label)} (${list.length})</h3>${hitList(list)}`)}`
225
+ : html`<p class="muted">${tx('srch.nothing', { q })}</p>`
226
+ : html`<p class="muted">${tx('srch.intro', { name: a.name })}</p>`}`)}`;
227
+ return send(reply, s, shell(s, tx('srch.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('common.search')]], main));
227
228
  });
228
229
  }
229
230
 
@@ -8,6 +8,7 @@ import type { Session } from '../session.ts';
8
8
  import { WORKSPACE_FILES } from '../metadata.ts';
9
9
  import { appOr404 } from './forms.ts';
10
10
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
11
+ import { scopedTx as tx } from './i18n.ts';
11
12
 
12
13
  // Shared Components → Static application files (APEX: Static Application
13
14
  // Files): upload, write and remove an application's JavaScript, CSS, images
@@ -56,48 +57,46 @@ async function page(s: Session, a: any, query: Record<string, string | undefined
56
57
  const everyPage = new Set<string>(a.static_includes ?? []);
57
58
 
58
59
  const editor = editName || creating
59
- ? region(editName ? `Edit ${editName}` : 'New file', html`<form method="post" action="${base}/save">${csrf(s)}
60
+ ? region(editName ? tx('sf.edit_title', { name: editName }) : tx('sf.new_file'), html`<form method="post" action="${base}/save">${csrf(s)}
60
61
  ${editName ? html`<input type="hidden" name="original" value="${editName}">` : ''}
61
- <div class="form-grid">${input('name', 'File name', editName ?? '', { required: true, placeholder: 'app.js', help: 'Letters, digits, _, - and . (e.g. app.js, styles.css, data.json).' })}</div>
62
- <div class="field" data-wide><label class="label" for="f_content">Content</label>
62
+ <div class="form-grid">${input('name', tx('sf.file_name'), editName ?? '', { required: true, placeholder: 'app.js', help: tx('sf.file_name_help') })}</div>
63
+ <div class="field" data-wide><label class="label" for="f_content">${tx('sf.content')}</label>
63
64
  <textarea id="f_content" name="content" class="code" rows="18" spellcheck="false">${content}</textarea>
64
- <small class="help">JavaScript registers functions for the dynamic action "Execute JavaScript" with <code>pgkiln.actions.register('name', (da) =&gt; { … })</code>; it can also use <code>pgkiln.getValue</code>, <code>setValue</code>, <code>showSuccess</code> and <code>showError</code>. No inline scripts: everything runs from these files.</small></div>
65
- <div class="buttons"><a class="btn" href="${base}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div>
65
+ <small class="help">${tx.code('sf.content_help')}</small></div>
66
+ <div class="buttons"><a class="btn" href="${base}">${tx('common.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('common.save')}</button></div>
66
67
  </form>`)
67
68
  : '';
68
69
 
69
70
  return html`${appHeader(a, 'shared', s)}
70
71
  ${editor}
71
- ${region('Static application files', html`<p class="muted u-mt0">JavaScript, CSS, JSON, images and fonts of this application, served at
72
- <code>/a/${a.alias}/static/<i>name</i></code> and exported with it. Pages load the scripts and stylesheets chosen below
73
- (and those a page adds in its properties), so code runs from files, never inline. Up to ${size(STATIC_MAX)} per file; no HTML.</p>
72
+ ${region(tx('sf.title'), html`<p class="muted u-mt0">${tx.rich('sf.intro', { path: html`<code>/a/${a.alias}/static/<i>name</i></code>`, max: size(STATIC_MAX) })}</p>
74
73
  ${files.length
75
- ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">Static application files</caption>
76
- <thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col" class="num">Size</th><th scope="col">Loaded by</th><th scope="col">Changed</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
74
+ ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${tx('sf.title')}</caption>
75
+ <thead><tr><th scope="col">${tx('sf.name')}</th><th scope="col">${tx('sf.type')}</th><th scope="col" class="num">${tx('sf.size')}</th><th scope="col">${tx('sf.loaded_by')}</th><th scope="col">${tx('sf.changed')}</th><th scope="col"><span class="sr-only">${tx('sf.actions')}</span></th></tr></thead>
77
76
  <tbody>${files.map((f) => html`<tr>
78
77
  <td><a href="/a/${a.alias}/static/${encodeURIComponent(f.name)}" target="_blank" rel="noopener">${f.name}</a></td>
79
78
  <td>${f.mime}</td><td class="num">${size(Number(f.bytes))}</td>
80
- <td>${includable(f.name) ? [everyPage.has(f.name) ? 'every page' : '', pagesOf(f.name).length ? `page ${pagesOf(f.name).join(', ')}` : ''].filter(Boolean).join('; ') || html`<span class="muted">-</span>` : ''}</td>
79
+ <td>${includable(f.name) ? [everyPage.has(f.name) ? tx('sf.every_page') : '', pagesOf(f.name).length ? tx('sf.pages', { pages: pagesOf(f.name).join(', ') }) : ''].filter(Boolean).join('; ') || html`<span class="muted">-</span>` : ''}</td>
81
80
  <td>${f.changed}</td>
82
- <td class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${base}?edit=${encodeURIComponent(f.name)}">Edit</a> ` : ''}
81
+ <td class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${base}?edit=${encodeURIComponent(f.name)}">${tx('sf.edit')}</a> ` : ''}
83
82
  <form method="post" action="${base}/delete" class="inline">${csrf(s)}<input type="hidden" name="name" value="${f.name}">
84
- <button class="btn btn-small btn-danger" data-confirm="Delete ${f.name}?" aria-label="Delete ${f.name}">${icon('trash')}</button></form></td>
83
+ <button class="btn btn-small btn-danger" data-confirm="${tx('sf.delete_confirm', { name: f.name })}" aria-label="${tx('sf.delete_name', { name: f.name })}">${icon('trash')}</button></form></td>
85
84
  </tr>`)}</tbody></table></div>`
86
- : html`<p class="muted">No files yet.</p>`}
87
- <div class="buttons u-mt1"><a class="btn" href="${base}?new=1">${icon('plus')} New file</a></div>
85
+ : html`<p class="muted">${tx('sf.no_files')}</p>`}
86
+ <div class="buttons u-mt1"><a class="btn" href="${base}?new=1">${icon('plus')} ${tx('sf.new_file')}</a></div>
88
87
  <form method="post" action="${base}/upload" enctype="multipart/form-data" class="search u-mwnone u-mt1">${csrf(s)}
89
- <input type="file" name="file" multiple aria-label="Files to upload" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
90
- <button class="btn">${icon('upload')} Upload</button>
91
- <small class="help">A file with the same name is replaced.</small>
88
+ <input type="file" name="file" multiple aria-label="${tx('sf.files_to_upload')}" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
89
+ <button class="btn">${icon('upload')} ${tx('sf.upload')}</button>
90
+ <small class="help">${tx('sf.replaced')}</small>
92
91
  </form>`)}
93
- ${region('Every page loads', html`<form method="post" action="${base}/includes">${csrf(s)}
94
- <div class="form-grid">${input('includes', 'Scripts and stylesheets, in order', (a.static_includes ?? []).join(', '), { placeholder: 'e.g. library.js, app.js, app-styles.css', help: `Up to ${MAX_INCLUDES} .js and .css files, loaded in this order after pgkiln's own (scripts deferred). #WORKSPACE_FILES#name is a static workspace file (Workspace utilities). A page adds its own files under Page → Appearance.` })}</div>
95
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
92
+ ${region(tx('sf.every_page_loads'), html`<form method="post" action="${base}/includes">${csrf(s)}
93
+ <div class="form-grid">${input('includes', tx('sf.includes'), (a.static_includes ?? []).join(', '), { placeholder: tx('sf.includes_ph'), help: tx('sf.includes_help', { max: MAX_INCLUDES }) })}</div>
94
+ <div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div>
96
95
  </form>`)}`;
97
96
  }
98
97
 
99
98
  export async function staticFileBuilderRoutes(app: FastifyInstance) {
100
- const title = (a: any) => [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Static application files']] as [string, string?][];
99
+ const title = (a: any) => [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.shared'), `${BASE}/apps/${a.id}/shared`], [tx('sf.title')]] as [string, string?][];
101
100
  const find = async (id: string) => (/^\d+$/.test(id) ? appOr404(id) : undefined);
102
101
 
103
102
  app.get(`${BASE}/apps/:id/static-files`, async (req: Req, reply) => {
@@ -105,7 +104,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
105
104
  if (!s) return;
106
105
  const a = await find(req.params.id);
107
106
  if (!a) return reply.code(404).send('Not found');
108
- return send(reply, s, shell(s, 'Static application files', title(a), await page(s, a, req.query ?? {})));
107
+ return send(reply, s, shell(s, tx('sf.title'), title(a), await page(s, a, req.query ?? {})));
109
108
  });
110
109
 
111
110
  app.post(`${BASE}/apps/:id/static-files/upload`, async (req: Req, reply) => {
@@ -121,15 +120,15 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
121
120
  if (!a) return reply.code(404).send('Not found');
122
121
  const target = `${BASE}/apps/${a.id}/static-files`;
123
122
  if (!uploads.length) {
124
- flash(s, 'Choose one or more files.', 'error');
123
+ flash(s, tx('sf.choose_files'), 'error');
125
124
  return back(reply, s, target);
126
125
  }
127
126
  const saved: string[] = [];
128
127
  for (const u of uploads) {
129
128
  const name = u.filename.split(/[\\/]/).pop()!.trim();
130
129
  const type = staticType(name);
131
- if (u.truncated) flash(s, `${name} is larger than ${size(STATIC_MAX)}.`, 'error');
132
- else if (!type) flash(s, `${name}: not an allowed name or type (letters, digits, _, - and .; ${Object.keys(STATIC_TYPES).join(', ')}).`, 'error');
130
+ if (u.truncated) flash(s, tx('sf.too_large_name', { name, max: size(STATIC_MAX) }), 'error');
131
+ else if (!type) flash(s, tx('sf.bad_type', { name, types: Object.keys(STATIC_TYPES).join(', ') }), 'error');
133
132
  else {
134
133
  await owner.query(
135
134
  `insert into meta.static_file (app_id, name, mime, content) values ($1, $2, $3, $4)
@@ -138,7 +137,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
138
137
  saved.push(name);
139
138
  }
140
139
  }
141
- if (saved.length) flash(s, `Uploaded: ${saved.join(', ')}.`);
140
+ if (saved.length) flash(s, tx('sf.uploaded', { names: saved.join(', ') }));
142
141
  return back(reply, s, target);
143
142
  });
144
143
 
@@ -154,11 +153,11 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
154
153
  const content = Buffer.from(String(b.content ?? '').replace(/\r\n/g, '\n'), 'utf8');
155
154
  const base = `${BASE}/apps/${a.id}/static-files`;
156
155
  if (!type || !editable(name)) {
157
- flash(s, 'The name must end in a text type (.js, .css, .json, .txt, .csv, .md, .map) and use letters, digits, _, - and . only.', 'error');
156
+ flash(s, tx('sf.bad_text_name'), 'error');
158
157
  return back(reply, s, original ? `${base}?edit=${encodeURIComponent(original)}` : `${base}?new=1`);
159
158
  }
160
159
  if (content.length > STATIC_MAX) {
161
- flash(s, `The file is larger than ${size(STATIC_MAX)}.`, 'error');
160
+ flash(s, tx('sf.too_large', { max: size(STATIC_MAX) }), 'error');
162
161
  return back(reply, s, base);
163
162
  }
164
163
  try {
@@ -172,7 +171,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
172
171
  `insert into meta.static_file (app_id, name, mime, content) values ($1, $2, $3, $4)
173
172
  on conflict (app_id, name) do update set mime = excluded.mime, content = excluded.content, updated_at = now()`,
174
173
  [a.id, name, type, content]);
175
- flash(s, `${name} saved.`);
174
+ flash(s, tx('sf.saved_name', { name }));
176
175
  return back(reply, s, `${base}?edit=${encodeURIComponent(name)}`);
177
176
  } catch (e) {
178
177
  flash(s, (e as Error).message, 'error');
@@ -187,7 +186,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
187
186
  if (!a) return reply.code(404).send('Not found');
188
187
  const name = String(req.body?.name ?? '');
189
188
  const r = await owner.query('delete from meta.static_file where app_id = $1 and name = $2', [a.id, name]);
190
- flash(s, r.rowCount ? `${name} deleted.` : 'No such file.', r.rowCount ? 'ok' : 'error');
189
+ flash(s, r.rowCount ? tx('sf.deleted', { name }) : tx('sf.no_such_file'), r.rowCount ? 'ok' : 'error');
191
190
  return back(reply, s, `${BASE}/apps/${a.id}/static-files`);
192
191
  });
193
192
 
@@ -202,7 +201,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
202
201
  `select n from unnest($2::text[]) n
203
202
  where not exists (select 1 from meta.static_file f where f.app_id = $1 and f.name = n)
204
203
  and not exists (select 1 from meta.workspace_file w where w.workspace_id = meta.app_workspace($1) and $3 || w.name = n)`, [a.id, names, WORKSPACE_FILES])).rows.map((r) => r.n);
205
- flash(s, missing.length ? `Saved. Not uploaded yet (left out until they are): ${missing.join(', ')}.` : 'Saved.');
204
+ flash(s, missing.length ? tx('sf.saved_missing', { names: missing.join(', ') }) : tx('sf.saved'));
206
205
  return back(reply, s, `${BASE}/apps/${a.id}/static-files`);
207
206
  });
208
207
  }
@@ -8,6 +8,7 @@ import {
8
8
  SubscriptionError, subscribeGroup, unsubscribe, type AppType, type Kind,
9
9
  } from '../subscriptions.ts';
10
10
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
11
+ import { scopedTx as tx } from './i18n.ts';
11
12
 
12
13
  // Shared Components → Subscriptions (APEX: subscribed shared components; 26.1
13
14
  // theme and library applications). An application subscribes to a component
@@ -26,7 +27,9 @@ const TYPE_HELP: Record<AppType, string> = {
26
27
  reporting: 'Made by App Builder → Data Reporter on datasets of the workspace: its users build, publish and share reports.',
27
28
  };
28
29
 
29
- const what = (kind: Kind, name: string) => (kind === 'theme' ? 'Theme' : `${KINDS[kind].label} ${name}`);
30
+ const kindLabel = (kind: Kind) => tx.dyn(`sub.k.${kind}`, KINDS[kind].label);
31
+ const typeLabel = (t: AppType) => tx.dyn(`st.type.${t}`, APP_TYPE_LABELS[t] ?? t);
32
+ const what = (kind: Kind, name: string) => (kind === 'theme' ? tx('sub.theme') : tx('sub.what', { kind: kindLabel(kind), name }));
30
33
 
31
34
  /** Another developer's application lock (page 0): such applications are not changed by publishing. */
32
35
  const lockedByOther = (username: string) => async (appId: number) =>
@@ -40,16 +43,15 @@ export async function subscriptionNote(appId: number, kind: string, row: any, s:
40
43
  const subscribers = (await owner.one<{ n: number }>('select count(*)::int as n from meta.subscription where master_app_id = $1 and kind = $2 and name = $3', [appId, kind, name]))!.n;
41
44
  if (!sub && !subscribers) return '';
42
45
  const hidden = html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
43
- return html`<section class="used-in"><h3>Subscription</h3>
46
+ return html`<section class="used-in"><h3>${tx('sub.subscription')}</h3>
44
47
  ${sub
45
- ? html`<p class="u-mt0">Subscribed from <a href="${BASE}/apps/${sub.master_app_id}/subscriptions">${sub.master_name}</a>${sub.refreshed_at ? `, refreshed ${sub.refreshed_at.slice(0, 16)}` : ''}.
46
- Changes made here are overwritten by the next refresh.</p>
47
- <form method="post" action="${BASE}/apps/${appId}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">Refresh from ${sub.master_name}</button></form>`
48
+ ? html`<p class="u-mt0">${((m) => (sub.refreshed_at ? tx.rich('sub.subscribed_from_at', { master: m, at: sub.refreshed_at.slice(0, 16) }) : tx.rich('sub.subscribed_from', { master: m })))(html`<a href="${BASE}/apps/${sub.master_app_id}/subscriptions">${sub.master_name}</a>`)}</p>
49
+ <form method="post" action="${BASE}/apps/${appId}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">${tx('sub.refresh_from', { master: sub.master_name })}</button></form>`
48
50
  : ''}
49
51
  ${subscribers
50
- ? html`<p>${subscribers} application${subscribers === 1 ? ' subscribes' : 's subscribe'} to this ${KINDS[kind].label.toLowerCase()}.</p>
51
- <form method="post" action="${BASE}/apps/${appId}/subscriptions/publish" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">Publish to subscribers</button></form>
52
- <a class="btn btn-sm" href="${BASE}/apps/${appId}/subscriptions">Subscribers…</a>`
52
+ ? html`<p>${subscribers === 1 ? tx('sub.subscribers_1', { kind: kindLabel(kind).toLowerCase() }) : tx('sub.subscribers_n', { n: subscribers, kind: kindLabel(kind).toLowerCase() })}</p>
53
+ <form method="post" action="${BASE}/apps/${appId}/subscriptions/publish" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">${tx('sub.publish_to_subscribers')}</button></form>
54
+ <a class="btn btn-sm" href="${BASE}/apps/${appId}/subscriptions">${tx('sub.subscribers')}</a>`
53
55
  : ''}
54
56
  </section>`;
55
57
  }
@@ -64,61 +66,59 @@ export async function subscriptionRoutes(app: FastifyInstance) {
64
66
  const sync = await Promise.all(subs.map((x) => inSync(x)));
65
67
  const subSync = await Promise.all(subscribers.map((x) => inSync(x)));
66
68
  const hidden = (kind: string, name: string) => html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
67
- const state = (ok: boolean) => (ok ? html`<span class="badge">in sync</span>` : html`<span class="badge badge-warn">differs</span>`);
69
+ const state = (ok: boolean) => (ok ? html`<span class="badge">${tx('sub.in_sync')}</span>` : html`<span class="badge badge-warn">differs</span>`);
68
70
  const groups = new Map<number, typeof offered>();
69
71
  for (const o of offered) groups.set(o.master.id, [...(groups.get(o.master.id) ?? []), o]);
70
72
  const published = [...new Map(subscribers.map((x) => [`${x.kind}|${x.name}`, x])).values()];
71
73
 
72
74
  const main = html`${appHeader(a, 'shared', s)}<div class="ide-body">
73
- ${region('Application type', html`<p class="u-mt0"><b>${APP_TYPE_LABELS[a.app_type as AppType] ?? a.app_type}</b>: ${TYPE_HELP[a.app_type as AppType] ?? ''}
74
- Change it under <a href="${BASE}/apps/${a.id}/settings">Settings</a>.</p>`)}
75
- ${region('Subscriptions', html`
76
- <p class="muted u-mt0">Components of this application that are copies of a component in a theme or library application.
77
- Refreshing copies the master's definition again, replacing changes made here.</p>
75
+ ${region(tx('sub.application_type'), html`<p class="u-mt0">${tx.rich('sub.type_line', { type: html`<b>${typeLabel(a.app_type as AppType)}</b>`, help: tx.dyn(`sub.th.${a.app_type}`, TYPE_HELP[a.app_type as AppType] ?? ''), settings: html`<a href="${BASE}/apps/${a.id}/settings">${tx('sub.settings')}</a>` })}</p>`)}
76
+ ${region(tx('sub.subscriptions'), html`
77
+ <p class="muted u-mt0">${tx('sub.components_of_this_application_that')}</p>
78
78
  ${subs.length
79
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Component</th><th>From</th><th>Refreshed</th><th>State</th><th>Actions</th></tr></thead><tbody>
79
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('sub.component')}</th><th>${tx('sub.from')}</th><th>${tx('sub.refreshed')}</th><th>${tx('sub.state')}</th><th>${tx('sub.actions')}</th></tr></thead><tbody>
80
80
  ${subs.map((x, i) => html`<tr><td>${what(x.kind, x.name)}</td><td><a href="${BASE}/apps/${x.master_app_id}/subscriptions">${x.master_name}</a></td>
81
81
  <td>${x.refreshed_at ? x.refreshed_at.slice(0, 16) : '—'}</td><td>${state(sync[i])}</td>
82
- <td><form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm">Refresh</button></form>
83
- <form method="post" action="${BASE}/apps/${a.id}/subscriptions/unsubscribe" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm" data-confirm="Unsubscribe? The component stays as it is.">Unsubscribe</button></form></td></tr>`)}
82
+ <td><form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm">${tx('sub.refresh')}</button></form>
83
+ <form method="post" action="${BASE}/apps/${a.id}/subscriptions/unsubscribe" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm" data-confirm="${tx('sub.unsubscribe_the_component_stays_as')}">${tx('sub.unsubscribe')}</button></form></td></tr>`)}
84
84
  </tbody></table></div>
85
- <form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh">${csrf(s)}<div class="buttons"><button class="btn">Refresh all</button></div></form>`
86
- : html`<p class="muted">No subscriptions.</p>`}
87
- <h3>Subscribe</h3>
85
+ <form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh">${csrf(s)}<div class="buttons"><button class="btn">${tx('sub.refresh_all')}</button></div></form>`
86
+ : html`<p class="muted">${tx('sub.no_subscriptions')}</p>`}
87
+ <h3>${tx('sub.subscribe')}</h3>
88
88
  ${offered.length
89
89
  ? html`<form method="post" action="${BASE}/apps/${a.id}/subscriptions">${csrf(s)}
90
- <div class="field"><label class="label" for="f_component">Component</label>
91
- <select id="f_component" name="component" required><option value="">- choose -</option>
92
- ${[...groups.values()].map((list) => html`<optgroup label="${list[0].master.name} (${APP_TYPE_LABELS[list[0].master.app_type]})">
90
+ <div class="field"><label class="label" for="f_component">${tx('sub.component')}</label>
91
+ <select id="f_component" name="component" required><option value="">${tx('sub.choose_dash')}</option>
92
+ ${[...groups.values()].map((list) => html`<optgroup label="${list[0].master.name} (${typeLabel(list[0].master.app_type)})">
93
93
  ${list.map((o) => html`<option value="${o.master.id}|${o.kind}|${o.name}">${what(o.kind, o.name)}</option>`)}</optgroup>`)}
94
94
  </select>
95
- <small class="help">The component is copied now. A component of this application with the same name is replaced.</small></div>
96
- <div class="buttons"><button class="btn btn-hot">Subscribe</button></div></form>`
97
- : html`<p class="muted">No theme or library applications offer components. Set an application's type under its Settings.</p>`}
95
+ <small class="help">${tx('sub.the_component_is_copied_now')}</small></div>
96
+ <div class="buttons"><button class="btn btn-hot">${tx('sub.subscribe')}</button></div></form>`
97
+ : html`<p class="muted">${tx('sub.no_theme_or_library_applications')}</p>`}
98
98
  ${groupList.length
99
- ? html`<h3>Subscribe to a component group</h3>
99
+ ? html`<h3>${tx('sub.subscribe_to_a_component_group')}</h3>
100
100
  <form method="post" action="${BASE}/apps/${a.id}/subscriptions/group">${csrf(s)}
101
- <div class="field"><label class="label" for="f_group">Component group</label>
102
- <select id="f_group" name="group" required><option value="">- choose -</option>
101
+ <div class="field"><label class="label" for="f_group">${tx('sub.component_group')}</label>
102
+ <select id="f_group" name="group" required><option value="">${tx('sub.choose_dash')}</option>
103
103
  ${groupList.map((g) => html`<option value="${g.master_id}|${g.name}">${g.master_name}: ${g.name} (${g.members.length})</option>`)}
104
104
  </select>
105
- <small class="help">Every component of the group is copied now and subscribed (Shared Components → Component Groups of a theme or library application).</small></div>
106
- <div class="buttons"><button class="btn btn-hot">Subscribe to the group</button></div></form>`
105
+ <small class="help">${tx('sub.every_component_of_the_group')}</small></div>
106
+ <div class="buttons"><button class="btn btn-hot">${tx('sub.subscribe_to_the_group')}</button></div></form>`
107
107
  : ''}`)}
108
108
  ${a.app_type === 'theme' || a.app_type === 'library' || subscribers.length
109
- ? region('Subscribers', subscribers.length
110
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Component</th><th>Application</th><th>Refreshed</th><th>State</th></tr></thead><tbody>
109
+ ? region(tx('sub.subscribers_2'), subscribers.length
110
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('sub.component')}</th><th>${tx('sub.application')}</th><th>${tx('sub.refreshed')}</th><th>${tx('sub.state')}</th></tr></thead><tbody>
111
111
  ${subscribers.map((x, i) => html`<tr><td>${what(x.kind, x.name)}</td><td><a href="${BASE}/apps/${x.app_id}/subscriptions">${x.app_name}</a></td>
112
112
  <td>${x.refreshed_at ? x.refreshed_at.slice(0, 16) : '—'}</td><td>${state(subSync[i])}</td></tr>`)}
113
113
  </tbody></table></div>
114
- <h3>Publish</h3>
115
- <p class="muted u-mt0">Publishing refreshes every subscriber of a component with this application's version. Applications locked by another developer are skipped.</p>
114
+ <h3>${tx('sub.publish')}</h3>
115
+ <p class="muted u-mt0">${tx('sub.publishing_refreshes_every_subscriber_of')}</p>
116
116
  <div class="buttons publish-buttons">${published.map((x) => html`<form method="post" action="${BASE}/apps/${a.id}/subscriptions/publish" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}
117
117
  <button class="btn btn-sm">${icon('upload')} ${what(x.kind, x.name)}</button></form>`)}</div>`
118
- : html`<p class="muted">No application subscribes to this application's components yet.</p>`)
118
+ : html`<p class="muted">${tx('sub.no_application_subscribes_to_this')}</p>`)
119
119
  : ''}
120
120
  </div>`;
121
- return send(reply, s, shell(s, `${a.name} subscriptions`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Subscriptions']], main));
121
+ return send(reply, s, shell(s, tx('sub.title', { app: a.name }), [[tx('sub.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('sub.shared_components'), `${BASE}/apps/${a.id}/shared`], [tx('sub.subscriptions')]], main));
122
122
  });
123
123
 
124
124
  const target = (id: number) => `${BASE}/apps/${id}/subscriptions`;
@@ -142,10 +142,10 @@ export async function subscriptionRoutes(app: FastifyInstance) {
142
142
  const v = String(req.body?.component ?? '');
143
143
  const m = /^(\d{1,9})\|([a-z_]+)\|(.*)$/s.exec(v);
144
144
  return handle(req, reply, s, a.id, async () => {
145
- if (!m) throw new SubscriptionError('Choose a component.');
145
+ if (!m) throw new SubscriptionError(tx('sub.choose_component'));
146
146
  await subscribe(a.id, Number(m[1]), m[2], m[3], s.username!);
147
147
  await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed ${m[2]} ${m[3]} from application ${m[1]}` });
148
- return `Subscribed: ${isKind(m[2]) ? what(m[2], m[3]) : m[2]} copied.`;
148
+ return tx('sub.subscribed', { what: isKind(m[2]) ? what(m[2], m[3]) : m[2] });
149
149
  });
150
150
  });
151
151
 
@@ -157,11 +157,12 @@ export async function subscriptionRoutes(app: FastifyInstance) {
157
157
  if (!a) return reply.code(404).send('Not found');
158
158
  const m = /^(\d{1,9})\|(.{1,60})$/s.exec(String(req.body?.group ?? ''));
159
159
  return handle(req, reply, s, a.id, async () => {
160
- if (!m) throw new SubscriptionError('Choose a component group.');
161
- if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError('Choose a component group.');
160
+ if (!m) throw new SubscriptionError(tx('sub.choose_group'));
161
+ if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError(tx('sub.choose_group'));
162
162
  const { done, skipped } = await subscribeGroup(a.id, Number(m[1]), m[2], s.username!);
163
163
  await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed component group ${m[2]} from application ${m[1]}: ${done.join(', ')}` });
164
- return `Subscribed to ${m[2]}: ${done.length} component${done.length === 1 ? '' : 's'} copied${skipped.length ? `; skipped ${skipped.join(', ')}` : ''}.`;
164
+ const one = done.length === 1;
165
+ return skipped.length ? tx(one ? 'sub.subscribed_group_skipped_1' : 'sub.subscribed_group_skipped', { group: m[2], n: done.length, skipped: skipped.join(', ') }) : tx(one ? 'sub.subscribed_group_1' : 'sub.subscribed_group', { group: m[2], n: done.length });
165
166
  });
166
167
  });
167
168
 
@@ -185,7 +186,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
185
186
  const a = await appRow(req.params.id);
186
187
  if (!a) return reply.code(404).send('Not found');
187
188
  return handle(req, reply, s, a.id, async () => {
188
- if (!(await unsubscribe(a.id, String(req.body?.kind ?? ''), String(req.body?.name ?? '')))) throw new SubscriptionError('Not subscribed.');
189
+ if (!(await unsubscribe(a.id, String(req.body?.kind ?? ''), String(req.body?.name ?? '')))) throw new SubscriptionError(tx('sub.not_subscribed'));
189
190
  return 'Unsubscribed: the component stays as it is.';
190
191
  });
191
192
  });
@@ -199,8 +200,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
199
200
  return handle(req, reply, s, a.id, async () => {
200
201
  const r = await publish(a.id, kind, name, s.username!, lockedByOther(s.username!));
201
202
  await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `published ${kind} ${name} to ${r.done.length} applications${r.skipped.length ? `, skipped locked ${r.skipped.join(', ')}` : ''}` });
202
- if (!r.done.length && !r.skipped.length) throw new SubscriptionError('No application subscribes to it.');
203
- return `Published to ${r.done.length} application${r.done.length === 1 ? '' : 's'}${r.skipped.length ? `; ${r.skipped.length} skipped (locked by another developer)` : ''}.`;
203
+ if (!r.done.length && !r.skipped.length) throw new SubscriptionError(tx('sub.no_subscriber'));
204
+ const one = r.done.length === 1;
205
+ return r.skipped.length ? tx(one ? 'sub.published_skipped_1' : 'sub.published_skipped', { n: r.done.length, skipped: r.skipped.length }) : tx(one ? 'sub.published_1' : 'sub.published', { n: r.done.length });
204
206
  });
205
207
  });
206
208
  }
@@ -1,10 +1,11 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { splitStatements } from '../binds.ts';
3
3
  import { appTx, owner, type Client } from '../db.ts';
4
- import { html, type Raw } from '../html.ts';
4
+ import { html, raw, type Raw } from '../html.ts';
5
5
  import { icon } from '../icons.ts';
6
6
  import { clientIp, logActivity, type Session } from '../session.ts';
7
7
  import { clearCompletions } from './code-editor.ts';
8
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
8
9
  import { appHeader, BASE, csrf, developer, region, send, shell, type Req } from './ui.ts';
9
10
  import { appOr404 } from './forms.ts';
10
11
 
@@ -58,7 +59,7 @@ export async function runScripts(app: { id: number; alias: string; db_role: stri
58
59
  const res = await c.query({ text: sql, rowMode: 'array' });
59
60
  results.push({
60
61
  script: s.name, sql, ok: true,
61
- message: `${res.command ?? 'Statement'}${res.rowCount !== null && res.rowCount !== undefined ? `, ${res.rowCount} row(s)` : ''}`,
62
+ message: res.rowCount !== null && res.rowCount !== undefined ? tx('sup.rows', { command: res.command ?? tx('sup.statement'), n: res.rowCount }) : (res.command ?? tx('sup.statement')),
62
63
  ...(res.fields?.length ? { fields: res.fields.map((f) => f.name), rows: (res.rows as unknown[][]).slice(0, SHOW_ROWS) } : {}),
63
64
  });
64
65
  } catch (e) {
@@ -76,12 +77,18 @@ export async function runScripts(app: { id: number; alias: string; db_role: stri
76
77
 
77
78
  const cell = (v: unknown) => (v === null ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v));
78
79
 
79
- export function resultsHtml(kind: string, r: RunResult): Raw {
80
+ const KIND_KEYS: Record<Kind, { heading: BuilderKey; run: BuilderKey; confirm: BuilderKey; result: BuilderKey; ran: BuilderKey; failed: BuilderKey }> = {
81
+ install: { heading: 'sup.h_install', run: 'sup.run_install', confirm: 'sup.confirm_install', result: 'sup.result_install', ran: 'sup.ran_install', failed: 'sup.failed_install' },
82
+ upgrade: { heading: 'sup.h_upgrade', run: 'sup.run_upgrade', confirm: 'sup.confirm_upgrade', result: 'sup.result_upgrade', ran: 'sup.ran_upgrade', failed: 'sup.failed_upgrade' },
83
+ deinstall: { heading: 'sup.h_deinstall', run: 'sup.run_deinstall', confirm: 'sup.confirm_deinstall', result: 'sup.result_deinstall', ran: 'sup.ran_deinstall', failed: 'sup.failed_deinstall' },
84
+ };
85
+
86
+ export function resultsHtml(kind: Kind, r: RunResult): Raw {
80
87
  return html`<div class="alert ${r.ok ? 'alert-success' : 'alert-error'}" role="status">${r.ok
81
- ? `The ${kind} scripts ran: ${r.results.length} statement(s), committed.`
82
- : `The ${kind} scripts failed: everything they did was undone (rolled back).`}</div>
88
+ ? tx(KIND_KEYS[kind].ran, { n: r.results.length })
89
+ : tx(KIND_KEYS[kind].failed)}</div>
83
90
  <ol class="support-results">${r.results.map((x) => html`<li class="${x.ok ? 'ok' : 'failed'}">
84
- <div><b>${x.script}</b> <span class="${x.ok ? 'muted' : null}">${x.ok ? x.message : html`Error: ${x.message}`}</span></div>
91
+ <div><b>${x.script}</b> <span class="${x.ok ? 'muted' : null}">${x.ok ? x.message : tx('sup.error', { message: x.message })}</span></div>
85
92
  ${x.sql ? html`<pre class="source">${x.sql.length > 2000 ? `${x.sql.slice(0, 2000)}…` : x.sql}</pre>` : ''}
86
93
  ${x.fields ? html`<div class="table-wrap"><table class="report"><thead><tr>${x.fields.map((f) => html`<th>${f}</th>`)}</tr></thead>
87
94
  <tbody>${(x.rows ?? []).map((row) => html`<tr>${row.map((v) => html`<td>${cell(v)}</td>`)}</tr>`)}</tbody></table></div>` : ''}
@@ -95,17 +102,15 @@ async function page(req: Req, appId: string, result?: { kind: Kind; run: RunResu
95
102
  const imported = req.query?.imported === '1';
96
103
  const byKind = (k: Kind) => scripts.filter((x) => x.kind === k);
97
104
  return { a, body: (s: Session) => html`${appHeader(a, 'shared', s)}
98
- ${imported && scripts.length ? html`<div class="alert alert-warning" role="status">${icon('alert')} This application came with supporting objects. They were <b>not</b> run: read them below and run them only if you trust them.</div>` : ''}
99
- ${result ? region(`Result: ${result.kind} scripts`, resultsHtml(result.kind, result.run)) : ''}
100
- ${region('Supporting objects', html`<p class="muted u-mt0">Scripts that install, upgrade or remove what the application needs in the database. They travel with the export and run only from here, when you choose to:
101
- as the application's database role <code>${a.db_role ?? '(none: the runtime connection)'}</code>, statement by statement in one transaction (an error undoes the whole run).
102
- <a href="${BASE}/apps/${a.id}/shared?new=supporting_script">Add a script</a> under Shared Components.</p>
103
- ${SCRIPT_KINDS.map((k) => html`<h3>${k[0].toUpperCase() + k.slice(1)} scripts (${byKind(k).length})</h3>
105
+ ${imported && scripts.length ? html`<div class="alert alert-warning" role="status">${icon('alert')} ${tx.rich('sup.imported', { b: raw('<b>'), b_end: raw('</b>') })}</div>` : ''}
106
+ ${result ? region(tx(KIND_KEYS[result.kind].result), resultsHtml(result.kind, result.run)) : ''}
107
+ ${region(tx('app.task.supporting'), html`<p class="muted u-mt0">${tx.rich('sup.intro', { role: html`<code>${a.db_role ?? tx('sup.no_role')}</code>`, add: html`<a href="${BASE}/apps/${a.id}/shared?new=supporting_script">${tx('sup.add_script')}</a>` })}</p>
108
+ ${SCRIPT_KINDS.map((k) => html`<h3>${tx(KIND_KEYS[k].heading, { n: byKind(k).length })}</h3>
104
109
  ${byKind(k).length
105
- ? html`${byKind(k).map((x) => html`<details class="support-script"><summary><b>${x.seq}. ${x.name}</b> <a href="${BASE}/apps/${a.id}/shared?c=supporting_script-${x.id}">Edit</a></summary><pre class="source">${x.script}</pre></details>`)}
110
+ ? html`${byKind(k).map((x) => html`<details class="support-script"><summary><b>${x.seq}. ${x.name}</b> <a href="${BASE}/apps/${a.id}/shared?c=supporting_script-${x.id}">${tx('sup.edit')}</a></summary><pre class="source">${x.script}</pre></details>`)}
106
111
  <form method="post" action="${BASE}/apps/${a.id}/supporting-objects/run" class="u-mt1">${csrf(s)}<input type="hidden" name="kind" value="${k}">
107
- <button class="btn${k === 'deinstall' ? ' btn-danger' : ''}" data-confirm="Run the ${k} scripts of ${a.name} as ${a.db_role ?? 'the runtime connection'}?">${icon('play')} Run the ${k} scripts</button></form>`
108
- : html`<p class="muted">None.</p>`}`)}`)}` };
112
+ <button class="btn${k === 'deinstall' ? ' btn-danger' : ''}" data-confirm="${tx(KIND_KEYS[k].confirm, { app: a.name, role: a.db_role ?? tx('sup.runtime_connection') })}">${icon('play')} ${tx(KIND_KEYS[k].run)}</button></form>`
113
+ : html`<p class="muted">${tx('sup.none')}</p>`}`)}`)}` };
109
114
  }
110
115
 
111
116
  export async function supportingRoutes(app: FastifyInstance) {
@@ -114,7 +119,7 @@ export async function supportingRoutes(app: FastifyInstance) {
114
119
  if (!s) return;
115
120
  const p = await page(req, req.params.id);
116
121
  if (!p) return reply.code(404).send('Not found');
117
- return send(reply, s, shell(s, 'Supporting objects', [['App Builder', BASE], [p.a.name, `${BASE}/apps/${p.a.id}`], ['Supporting objects']], p.body(s)));
122
+ return send(reply, s, shell(s, tx('app.task.supporting'), [[tx('rail.app_builder'), BASE], [p.a.name, `${BASE}/apps/${p.a.id}`], [tx('app.task.supporting')]], p.body(s)));
118
123
  });
119
124
 
120
125
  app.post(`${BASE}/apps/:id/supporting-objects/run`, async (req: Req, reply) => {
@@ -123,11 +128,11 @@ export async function supportingRoutes(app: FastifyInstance) {
123
128
  const a = await appOr404(req.params.id);
124
129
  if (!a) return reply.code(404).send('Not found');
125
130
  const kind = req.body?.kind as Kind;
126
- if (!(SCRIPT_KINDS as readonly string[]).includes(kind)) return reply.code(400).send('Unknown kind of script');
131
+ if (!(SCRIPT_KINDS as readonly string[]).includes(kind)) return reply.code(400).send(tx('sup.unknown_kind'));
127
132
  const run = await runSupportingScripts(a, kind, s.username!);
128
133
  clearCompletions(); // the scripts may have changed tables or grants
129
134
  await logActivity({ appId: a.id, username: s.username, event: 'supporting_objects', ip: clientIp(req), detail: `builder: ${kind} ${run.ok ? 'ran' : 'failed'} (${run.results.length} statement(s))` });
130
135
  const p = (await page(req, req.params.id, { kind, run }))!;
131
- return send(reply, s, shell(s, 'Supporting objects', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Supporting objects']], p.body(s)));
136
+ return send(reply, s, shell(s, tx('app.task.supporting'), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.task.supporting')]], p.body(s)));
132
137
  });
133
138
  }