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
@@ -6,6 +6,7 @@ import { appOr404 } from './forms.ts';
6
6
  import { issuesFrom } from '../team.ts';
7
7
  import { toIssueButton } from './team.ts';
8
8
  import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
9
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
9
10
 
10
11
  // Application → Feedback (APEX: Team Development → Feedback, migration 084):
11
12
  // switch the application's Feedback link on or off, read what users sent
@@ -13,8 +14,10 @@ import { appHeader, back, BASE, csrf, developer, flash, region, select, send, sh
13
14
  // and a response the user sees on the feedback page. Answers are data, not
14
15
  // a change of the application: allowed while it is locked or Run only.
15
16
 
16
- const STATUSES: [string, string][] = [['open', 'Open'], ['in_progress', 'In progress'], ['done', 'Done'], ['declined', 'Declined']];
17
- const KINDS: [string, string][] = [['comment', 'Comment'], ['idea', 'Idea'], ['problem', 'Problem']];
17
+ const STATUSES: [string, BuilderKey][] = [['open', 'fbk.status.open'], ['in_progress', 'fbk.status.in_progress'], ['done', 'fbk.status.done'], ['declined', 'fbk.status.declined']];
18
+ const KINDS: [string, BuilderKey][] = [['comment', 'fbk.kind.comment'], ['idea', 'fbk.kind.idea'], ['problem', 'fbk.kind.problem']];
19
+ /** Options with their labels in the developer's language. */
20
+ const options = (list: [string, BuilderKey][]): [string, string][] => list.map(([v, k]) => [v, tx(k)]);
18
21
  const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
19
22
 
20
23
  export async function feedbackRoutes(app: FastifyInstance) {
@@ -33,39 +36,39 @@ export async function feedbackRoutes(app: FastifyInstance) {
33
36
  // (sprint 113) feedback turned into a Team Development issue links to it
34
37
  const issues = await issuesFrom('feedback', rows.map((f) => Number(f.id)));
35
38
  const body = html`
36
- ${region('Feedback', html`
39
+ ${region(tx('fbk.title'), html`
37
40
  <form method="post" action="${BASE}/apps/${a.id}/feedback/settings" class="u-mb1">${csrf(s)}
38
- <label class="check"><input type="checkbox" name="feedback" value="true"${a.feedback ? raw(' checked') : ''}> Users can send feedback</label>
39
- <small class="help">A <b>Feedback</b> link in the application's navigation bar opens a page for a comment, an idea or a problem about the page the user came from, with their earlier feedback and your answers.</small>
40
- <div class="buttons"><button class="btn btn-sm">Save</button></div></form>
41
- <p class="muted">${count('open')} open, ${count('in_progress')} in progress, ${count('done')} done, ${count('declined')} declined.</p>
41
+ <label class="check"><input type="checkbox" name="feedback" value="true"${a.feedback ? raw(' checked') : ''}> ${tx('fbk.enable')}</label>
42
+ <small class="help">${tx.rich('fbk.enable_help', { link: html`<b>${tx('fbk.title')}</b>` })}</small>
43
+ <div class="buttons"><button class="btn btn-sm">${tx('common.save')}</button></div></form>
44
+ <p class="muted">${tx('fbk.counts', { open: count('open'), in_progress: count('in_progress'), done: count('done'), declined: count('declined') })}</p>
42
45
  <form method="get" class="filter-row">
43
- ${select('status', 'Status', status, [['', 'All'], ...STATUSES])}
44
- ${select('kind', 'Kind', kind, [['', 'All'], ...KINDS])}
45
- <button class="btn btn-sm">${icon('filter')} Show</button></form>
46
+ ${select('status', tx('fbk.status'), status, [['', tx('fbk.all')], ...options(STATUSES)])}
47
+ ${select('kind', tx('fbk.kind'), kind, [['', tx('fbk.all')], ...options(KINDS)])}
48
+ <button class="btn btn-sm">${icon('filter')} ${tx('common.show')}</button></form>
46
49
  ${rows.length ? html`<ul class="feedback-admin">${rows.map((f) => html`<li class="feedback-item" id="feedback-${f.id}">
47
- <div class="feedback-meta"><span class="tag">${KINDS.find(([v]) => v === f.kind)?.[1]}</span>
48
- <b>${f.username}</b> <span class="muted">${when(f.created_at)}${f.page_no ? html` · <a href="/a/${a.alias}/${f.page_no}" target="_blank" rel="noopener">page ${f.page_no}</a>` : ''}${f.rating ? ` · ${'★'.repeat(f.rating)}` : ''}${f.user_agent ? ` · ${f.user_agent}` : ''}</span></div>
50
+ <div class="feedback-meta"><span class="tag">${options(KINDS).find(([v]) => v === f.kind)?.[1]}</span>
51
+ <b>${f.username}</b> <span class="muted">${when(f.created_at)}${f.page_no ? html` · <a href="/a/${a.alias}/${f.page_no}" target="_blank" rel="noopener">${tx('fbk.page', { n: f.page_no })}</a>` : ''}${f.rating ? ` · ${'★'.repeat(f.rating)}` : ''}${f.user_agent ? ` · ${f.user_agent}` : ''}</span></div>
49
52
  <p class="feedback-body">${f.body}</p>
50
53
  <p class="feedback-issue">${toIssueButton(s, 'feedback', Number(f.id), issues.get(Number(f.id)))}</p>
51
54
  <form method="post" action="${BASE}/apps/${a.id}/feedback/${f.id}">${csrf(s)}
52
55
  <div class="form-grid">
53
- ${select('status', 'Status', f.status, STATUSES)}
54
- <div class="field" data-wide><label class="label" for="f_response_${f.id}">Answer (the user sees it)</label>
56
+ ${select('status', tx('fbk.status'), f.status, options(STATUSES))}
57
+ <div class="field" data-wide><label class="label" for="f_response_${f.id}">${tx('fbk.answer')}</label>
55
58
  <textarea id="f_response_${f.id}" name="response" rows="2" maxlength="4000">${f.response ?? ''}</textarea>
56
- ${f.responded_by ? html`<small class="help">By ${f.responded_by}, ${when(f.responded_at)}</small>` : ''}</div>
59
+ ${f.responded_by ? html`<small class="help">${tx('fbk.answered_by', { user: f.responded_by, when: when(f.responded_at) })}</small>` : ''}</div>
57
60
  </div>
58
- <div class="buttons"><button class="btn btn-sm btn-hot">Save</button></div></form>
59
- </li>`)}</ul>` : html`<p class="muted">No feedback${status || kind ? ' with these filters' : ' yet'}.</p>`}`)}`;
61
+ <div class="buttons"><button class="btn btn-sm btn-hot">${tx('common.save')}</button></div></form>
62
+ </li>`)}</ul>` : html`<p class="muted">${tx(status || kind ? 'fbk.none_filtered' : 'fbk.none')}</p>`}`)}`;
60
63
  const main = html`${appHeader(a, 'activity', s)}<div class="ide-body">${body}</div>`;
61
- return send(reply, s, shell(s, `${a.name} feedback`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Feedback']], main));
64
+ return send(reply, s, shell(s, tx('fbk.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.activity'), `${BASE}/apps/${a.id}/activity`], [tx('fbk.title')]], main));
62
65
  });
63
66
 
64
67
  app.post(`${BASE}/apps/:id(^\\d+$)/feedback/settings`, async (req: Req, reply) => {
65
68
  const s = await developer(req, reply);
66
69
  if (!s) return;
67
70
  await owner.query('update meta.app set feedback = $2, updated_at = now() where id = $1', [req.params.id, req.body?.feedback === 'true']);
68
- flash(s, 'Saved.');
71
+ flash(s, tx('fbk.saved'));
69
72
  return back(reply, s, `${BASE}/apps/${req.params.id}/feedback`);
70
73
  });
71
74
 
@@ -81,7 +84,7 @@ export async function feedbackRoutes(app: FastifyInstance) {
81
84
  responded_at = case when $4::text is distinct from response then now() else responded_at end
82
85
  where id = $2 and app_id = $1`,
83
86
  [req.params.id, req.params.fid, status, response, s.username]);
84
- flash(s, r.rowCount ? 'Saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
87
+ flash(s, r.rowCount ? tx('fbk.saved') : tx('fbk.not_found'), r.rowCount ? 'ok' : 'error');
85
88
  return back(reply, s, `${BASE}/apps/${req.params.id}/feedback`);
86
89
  });
87
90
  }
@@ -1,12 +1,13 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
- import { esc, html, type Raw } from '../html.ts';
3
+ import { esc, html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { documentShell } from '../layout.ts';
6
6
  import { senderAllowed, sendWith, type MailService } from '../mail/mail.ts';
7
7
  import { clientIp, getSession, logActivity, saveState, takeFlash, type Session } from '../session.ts';
8
8
  import { publicUrl } from '../sso.ts';
9
9
  import { back, BASE, brandMark, builderHead, csrf, flash, THEME_COOKIE, validTheme, type Req } from './ui.ts';
10
+ import { cookieLanguage, translatorFor, type BuilderTranslate } from './i18n.ts';
10
11
 
11
12
  // "Find my workspace" on the builder's sign-in page (migration 096; APEX: Find
12
13
  // My Workspace): a developer who forgot which workspace to sign in to enters
@@ -30,8 +31,8 @@ export async function builderMail(): Promise<{ service: MailService; from: strin
30
31
  return service && from && senderAllowed(service.senders, from) ? { service, from } : null;
31
32
  }
32
33
 
33
- /** The mail for one address: each account with it, and its workspaces. */
34
- export async function workspacesMail(email: string): Promise<{ to: string; subject: string; text: string; html: string } | null> {
34
+ /** The mail for one address: each account with it, and its workspaces (in the language of the page it was asked from). */
35
+ export async function workspacesMail(email: string, tx: BuilderTranslate = translatorFor('en')): Promise<{ to: string; subject: string; text: string; html: string } | null> {
35
36
  const accounts = (await owner.query<{ username: string; admin: boolean; workspaces: string[] }>(
36
37
  `select d.username, d.is_admin as admin,
37
38
  coalesce((select array_agg(w.name order by w.id = 1 desc, lower(w.name)) from meta.workspace w
@@ -39,28 +40,29 @@ export async function workspacesMail(email: string): Promise<{ to: string; subje
39
40
  from meta.developer d where lower(d.email) = lower($1) order by d.username`, [email])).rows;
40
41
  if (!accounts.length) return null;
41
42
  const signIn = `${publicUrl()}${BASE}/login`;
42
- const lines = accounts.map((a) => `${a.username}${a.admin ? ' (administrator: every workspace)' : ''}\n${a.workspaces.length ? a.workspaces.map((w) => ` - ${w}`).join('\n') : ' (no workspace yet: ask an administrator)'}`);
43
- const text = `You asked which pgkiln workspaces you can sign in to.\n\n${lines.join('\n\n')}\n\nSign in at ${signIn}\n\nIf you didn't ask, you can ignore this mail: nothing was changed.`;
44
- const body = `<p>You asked which pgkiln workspaces you can sign in to.</p>${accounts.map((a) => `<p><b>${esc(a.username)}</b>${a.admin ? ' (administrator: every workspace)' : ''}</p><ul>${a.workspaces.length ? a.workspaces.map((w) => `<li>${esc(w)}</li>`).join('') : '<li>No workspace yet: ask an administrator.</li>'}</ul>`).join('')}<p>Sign in at <a href="${esc(signIn)}">${esc(signIn)}</a></p><p>If you didn't ask, you can ignore this mail: nothing was changed.</p>`;
45
- return { to: email, subject: 'Your pgkiln workspaces', text, html: body };
43
+ const admin = ` (${tx('fw.mail_admin')})`;
44
+ const lines = accounts.map((a) => `${a.username}${a.admin ? admin : ''}\n${a.workspaces.length ? a.workspaces.map((w) => ` - ${w}`).join('\n') : ` (${tx('fw.mail_no_ws')})`}`);
45
+ const text = `${tx('fw.mail_intro')}\n\n${lines.join('\n\n')}\n\n${tx('fw.mail_sign_in', { url: signIn })}\n\n${tx('fw.mail_ignore')}`;
46
+ const body = `<p>${esc(tx('fw.mail_intro'))}</p>${accounts.map((a) => `<p><b>${esc(a.username)}</b>${a.admin ? esc(admin) : ''}</p><ul>${a.workspaces.length ? a.workspaces.map((w) => `<li>${esc(w)}</li>`).join('') : `<li>${esc(tx('fw.mail_no_ws'))}</li>`}</ul>`).join('')}<p>${tx.rich('fw.mail_sign_in', { url: raw(`<a href="${esc(signIn)}">${esc(signIn)}</a>`) })}</p><p>${esc(tx('fw.mail_ignore'))}</p>`;
47
+ return { to: email, subject: tx('fw.mail_subject'), text, html: body };
46
48
  }
47
49
 
48
- const page = (s: Session, theme: ReturnType<typeof validTheme>, body: Raw) =>
50
+ const page = (s: Session, theme: ReturnType<typeof validTheme>, body: Raw, tx: BuilderTranslate) =>
49
51
  documentShell(
50
- 'Find my workspace · Builder',
52
+ `${tx('signin.find_ws')} · ${tx('shell.builder')}`,
51
53
  html`<main class="signin">
52
54
  <section class="signin-hero" aria-label="pgkiln">
53
55
  <span class="signin-logo" aria-hidden="true">${brandMark()}</span>
54
56
  <h1 class="signin-title">pgkiln</h1>
55
- <p class="signin-tagline">Build data applications on PostgreSQL, in SQL you can trust</p>
57
+ <p class="signin-tagline">${tx('signin.tagline')}</p>
56
58
  </section>
57
59
  <section class="signin-panel"><div class="signin-form">${body}
58
- <nav class="signin-actions" aria-label="Quick actions"><a href="${BASE}/login">${icon('arrow-left')} Back to sign in</a></nav></div></section>
60
+ <nav class="signin-actions" aria-label="${tx('signin.quick')}"><a href="${BASE}/login">${icon('arrow-left')} ${tx('fw.back')}</a></nav></div></section>
59
61
  </main>`,
60
62
  'login-body ide-login signin-body',
61
63
  {},
62
64
  builderHead(),
63
- { theme: theme ?? 'dark' },
65
+ { theme: theme ?? 'dark', lang: tx.lang },
64
66
  );
65
67
 
66
68
  export async function findWorkspaceRoutes(app: FastifyInstance) {
@@ -71,31 +73,33 @@ export async function findWorkspaceRoutes(app: FastifyInstance) {
71
73
  await saveState(s);
72
74
  const mail = await builderMail();
73
75
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
76
+ const tx = translatorFor(cookieLanguage(req));
74
77
  const body = !mail
75
- ? html`<h2>Find my workspace</h2><p class="muted u-mt0">This installation doesn't send mail to developers. Ask an administrator which workspaces you belong to; or sign in with your user name and password only: the workspace is optional.</p>`
78
+ ? html`<h2>${tx('signin.find_ws')}</h2><p class="muted u-mt0">${tx('fw.no_mail')}</p>`
76
79
  : done
77
- ? html`<h2>Check your mail</h2><div class="alert alert-success" role="status">${done}</div>`
78
- : html`<h2>Find my workspace</h2>
79
- <p class="muted u-mt0">Enter the e-mail address of your developer account: we send you your user name and the workspaces you can sign in to.</p>
80
+ ? html`<h2>${tx('fw.check_mail')}</h2><div class="alert alert-success" role="status">${done}</div>`
81
+ : html`<h2>${tx('signin.find_ws')}</h2>
82
+ <p class="muted u-mt0">${tx('fw.intro')}</p>
80
83
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
81
84
  <form method="post" action="${BASE}/find-workspace">${csrf(s)}
82
- <div class="field signin-field"><label class="sr-only" for="f_email">E-mail address</label>
83
- <div class="signin-input">${icon('at')}<input id="f_email" name="email" type="email" placeholder="E-mail address" required autocomplete="email" maxlength="254"></div></div>
84
- <button class="btn btn-hot signin-submit">Send</button>
85
+ <div class="field signin-field"><label class="sr-only" for="f_email">${tx('dev.email')}</label>
86
+ <div class="signin-input">${icon('at')}<input id="f_email" name="email" type="email" placeholder="${tx('dev.email')}" required autocomplete="email" maxlength="254"></div></div>
87
+ <button class="btn btn-hot signin-submit">${tx('fw.send')}</button>
85
88
  </form>`;
86
- return reply.type('text/html').send(page(s, theme, body));
89
+ return reply.type('text/html').send(page(s, theme, body, tx));
87
90
  });
88
91
 
89
92
  app.post(`${BASE}/find-workspace`, async (req: Req, reply) => {
90
93
  const s = await getSession(req, reply, null, BASE);
91
94
  const to = `${BASE}/find-workspace`;
95
+ const tx = translatorFor(cookieLanguage(req));
92
96
  if (req.body?.__csrf !== s.csrf_token) {
93
- flash(s, 'Your session expired. Please try again.', 'error');
97
+ flash(s, tx('signin.expired'), 'error');
94
98
  return back(reply, s, to);
95
99
  }
96
100
  const email = String(req.body?.email ?? '').trim().slice(0, 254);
97
101
  if (!EMAIL.test(email)) {
98
- flash(s, 'Enter an e-mail address, e.g. ann@example.com.', 'error');
102
+ flash(s, tx('fw.bad_email'), 'error');
99
103
  return back(reply, s, to);
100
104
  }
101
105
  const ip = clientIp(req);
@@ -109,11 +113,11 @@ export async function findWorkspaceRoutes(app: FastifyInstance) {
109
113
  if (mail && !limited)
110
114
  // after the answer: the time it takes never tells whether an account has the address
111
115
  setImmediate(() => {
112
- workspacesMail(email)
116
+ workspacesMail(email, tx)
113
117
  .then((m) => (m ? sendWith(mail.service, { from: mail.from, to: [m.to], cc: [], bcc: [], replyTo: null, subject: m.subject, text: m.text, html: m.html, attachments: [] }) : null))
114
118
  .catch((e) => console.error('find my workspace: the mail was not sent:', (e as Error).message));
115
119
  });
116
- flash(s, `If a developer account has the address ${email}, it gets a mail with its user name and workspaces in a few minutes. No mail? Ask an administrator.`);
120
+ flash(s, tx('fw.sent', { email }));
117
121
  return back(reply, s, to);
118
122
  });
119
123
  }
@@ -7,6 +7,7 @@ import { fdSourcesOf, type FdColumn, type FdSource } from '../runtime/form-desig
7
7
  import type { Session } from '../session.ts';
8
8
  import { findObject, objectList, type DbObject } from './reporter.ts';
9
9
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
10
+ import { scopedTx as tx } from './i18n.ts';
10
11
 
11
12
  // Page designer → a Form Designer region → Settings (sprint 85): the tables
12
13
  // and views the region offers (which columns forms may show and change, their
@@ -53,7 +54,7 @@ export async function mergeFormDesignerSettings(
53
54
  else delete out.empty;
54
55
  delete out.design_authz;
55
56
  if (b.design_authz && a.authz.has(b.design_authz.toUpperCase())) out.design_authz = b.design_authz;
56
- else if (b.design_authz) errors.push(`There is no authorization scheme ${b.design_authz}.`);
57
+ else if (b.design_authz) errors.push(tx('fdz.no_authz', { name: String(b.design_authz) }));
57
58
 
58
59
  const lovOk = (v: string) => LOV_REF.test(v) && (!/^LOV:/i.test(v) || a.lovs.has(v.slice(4).toUpperCase()));
59
60
  const sources: FdSource[] = [];
@@ -78,7 +79,7 @@ export async function mergeFormDesignerSettings(
78
79
  }
79
80
  columns.push({ name, ...(label ? { label } : {}), ...(lov ? { lov } : {}) });
80
81
  }
81
- if (!columns.length) errors.push(`Data source ${s.id} offers no columns.`);
82
+ if (!columns.length) errors.push(tx('fdz.no_columns', { id: s.id }));
82
83
  const key = clip(b[`s${i}_keycol`], 63);
83
84
  if (key && !names.has(key)) errors.push(`${s.id}: the key column ${key} is not in ${s.schema}.${s.table}.`);
84
85
  const label = clip(b[`s${i}_label`], 80);
@@ -92,7 +93,7 @@ export async function mergeFormDesignerSettings(
92
93
 
93
94
  if (b.new_object) {
94
95
  const obj = /^\d{1,10}$/.test(b.new_object) ? await lookup.byOid(Number(b.new_object)) : null;
95
- if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push('Choose a table or view for the new data source.');
96
+ if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push(tx('fdz.choose_a_table_or_view'));
96
97
  else if (sources.length >= MAX_FD_SOURCES) errors.push(`A region offers at most ${MAX_FD_SOURCES} data sources.`);
97
98
  else {
98
99
  let id = clip(b.new_id, 40).toLowerCase();
@@ -101,7 +102,7 @@ export async function mergeFormDesignerSettings(
101
102
  id = '';
102
103
  }
103
104
  if (id && sources.some((s) => s.id === id)) {
104
- errors.push(`There is already a data source ${id}.`);
105
+ errors.push(tx('fdz.dup_source', { id }));
105
106
  id = '';
106
107
  }
107
108
  if (!id) {
@@ -154,56 +155,56 @@ export async function formDesignerSettingsForm(pageId: number, appId: number, r:
154
155
  const missing = privileges ? (['sel', 'ins', 'upd', 'del'] as const).filter((k) => !privileges[k] && (k === 'sel' || src[({ ins: 'insert', upd: 'update', del: 'delete' } as const)[k as 'ins' | 'upd' | 'del']])) : [];
155
156
  const grant = { sel: 'SELECT', ins: 'INSERT', upd: 'UPDATE', del: 'DELETE' };
156
157
  const check = (k: 'insert' | 'update' | 'delete', label: string, needsKey: boolean) =>
157
- html`<label class="check"><input type="checkbox" name="s${i}_${k}" value="true"${src[k] ? raw(' checked') : ''}> ${label}${needsKey && !src.key ? ' (needs a key column)' : ''}</label>`;
158
+ html`<label class="check"><input type="checkbox" name="s${i}_${k}" value="true"${src[k] ? raw(' checked') : ''}> ${label}${needsKey && !src.key ? ` ${tx('fdz.needs_key')}` : ''}</label>`;
158
159
  return html`<fieldset class="prop-group"><legend>${src.label || src.id} <span class="muted">(${src.schema}.${src.table})</span></legend>
159
160
  <input type="hidden" name="s${i}_key" value="${src.id}">
160
- ${!obj ? html`<div class="alert alert-error" role="alert">The table or view ${src.schema}.${src.table} does not exist (any more).</div>` : ''}
161
- ${missing.length ? html`<div class="alert alert-error" role="alert">The application's database role lacks ${missing.map((k) => grant[k]).join(', ')} on ${src.schema}.${src.table}: grant it, or forms can't do that.</div>` : ''}
161
+ ${!obj ? html`<div class="alert alert-error" role="alert">${tx('fdz.no_object', { object: `${src.schema}.${src.table}` })}</div>` : ''}
162
+ ${missing.length ? html`<div class="alert alert-error" role="alert">${tx('fdz.lacks', { privs: missing.map((k) => grant[k]).join(', '), object: `${src.schema}.${src.table}` })}</div>` : ''}
162
163
  <div class="form-grid">
163
- <div class="field"><label class="label" for="${id(`s${i}_id`)}">Static id</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
164
- <div class="field"><label class="label" for="${id(`s${i}_label`)}">Label</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
165
- <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">Description (shown to designers)</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
166
- <div class="field"><label class="label" for="${id(`s${i}_keycol`)}">Key column (identifies a row)</label>
167
- <select id="${id(`s${i}_keycol`)}" name="s${i}_keycol"><option value="">- none: forms only add rows -</option>${(obj?.columns ?? []).map((c) => opt(c.name, c.name, src.key ?? ''))}</select></div>
168
- <div class="field"><span class="label">Forms on it may</span>
169
- ${check('insert', 'add rows', false)} ${check('update', 'change rows', true)} ${check('delete', 'delete rows', true)}</div>
164
+ <div class="field"><label class="label" for="${id(`s${i}_id`)}">${tx('fdz.static_id')}</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
165
+ <div class="field"><label class="label" for="${id(`s${i}_label`)}">${tx('fdz.label')}</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
166
+ <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">${tx('fdz.description_shown_to_designers')}</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
167
+ <div class="field"><label class="label" for="${id(`s${i}_keycol`)}">${tx('fdz.key_column_identifies_a_row')}</label>
168
+ <select id="${id(`s${i}_keycol`)}" name="s${i}_keycol"><option value="">${tx('fdz.no_key')}</option>${(obj?.columns ?? []).map((c) => opt(c.name, c.name, src.key ?? ''))}</select></div>
169
+ <div class="field"><span class="label">${tx('fdz.forms_on_it_may')}</span>
170
+ ${check('insert', tx('fdz.add_rows'), false)} ${check('update', tx('fdz.change_rows'), true)} ${check('delete', tx('fdz.delete_rows'), true)}</div>
170
171
  </div>
171
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Label</th><th scope="col">List of values</th></tr></thead><tbody>
172
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('fdz.offer')}</th><th scope="col">${tx('fdz.column')}</th><th scope="col">${tx('fdz.label')}</th><th scope="col">${tx('fdz.list_of_values')}</th></tr></thead><tbody>
172
173
  ${all.map((c, j) => {
173
174
  const o = offered.get(c.name);
174
175
  return html`<tr>
175
- <td data-label="Offer"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="Offer ${c.name}"></td>
176
- <td data-label="Column"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">not in the table</span>` : ''}</td>
177
- <td data-label="Label"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="Label of ${c.name}"></td>
178
- <td data-label="List of values"><input name="s${i}_lov_${j}" maxlength="2000" value="${o?.lov ?? ''}" placeholder="LOV:NAME" list="${id('lovs')}" aria-label="List of values of ${c.name}"></td>
176
+ <td data-label="${tx('fdz.offer')}"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="${tx('fdz.offer_col', { col: c.name })}"></td>
177
+ <td data-label="${tx('fdz.column')}"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">${tx('fdz.not_in_the_table')}</span>` : ''}</td>
178
+ <td data-label="${tx('fdz.label')}"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="${tx('fdz.label_of', { col: c.name })}"></td>
179
+ <td data-label="${tx('fdz.list_of_values')}"><input name="s${i}_lov_${j}" maxlength="2000" value="${o?.lov ?? ''}" placeholder="LOV:NAME" list="${id('lovs')}" aria-label="${tx('fdz.lov_of', { col: c.name })}"></td>
179
180
  </tr>`;
180
181
  })}
181
182
  </tbody></table></div>
182
- <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> Remove this data source (forms on it stop working)</label></div>
183
+ <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> ${tx('fdz.remove_this_data_source_forms')}</label></div>
183
184
  </fieldset>`;
184
185
  }),
185
186
  );
186
- return html`<h3 class="u-mt15">Form Designer settings</h3>
187
- <p class="muted u-mt0">Users that pass the designers' authorization scheme build data entry forms on the tables and views below: they pick offered columns, labels, help texts, required fields and defaults, and publish forms for everyone who can see this region. Forms read and write rows as the application's database role, so grants and row level security apply.</p>
187
+ return html`<h3 class="u-mt15">${tx('fdz.form_designer_settings')}</h3>
188
+ <p class="muted u-mt0">${tx('fdz.users_that_pass_the_designers')}</p>
188
189
  <datalist id="${id('lovs')}">${lovs.map((n) => html`<option value="LOV:${n}">`)}</datalist>
189
190
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/form-designer" class="component-form">${csrf(s)}
190
- <fieldset class="prop-group"><legend>Forms</legend><div class="form-grid">
191
- <div class="field"><label class="label" for="${id('design_authz')}">Who may design forms</label>
192
- <select id="${id('design_authz')}" name="design_authz">${opt('', 'Nobody (choose an authorization scheme)', designAuthz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, designAuthz))}</select></div>
193
- <div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
191
+ <fieldset class="prop-group"><legend>${tx('fdz.forms')}</legend><div class="form-grid">
192
+ <div class="field"><label class="label" for="${id('design_authz')}">${tx('fdz.who_may_design_forms')}</label>
193
+ <select id="${id('design_authz')}" name="design_authz">${opt('', tx('fdz.nobody_choose_an_authorization_scheme'), designAuthz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, designAuthz))}</select></div>
194
+ <div class="field"><label class="label" for="${id('page_size')}">${tx('fdz.rows_per_page')}</label>
194
195
  <input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
195
- <div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
196
- <input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
196
+ <div class="field" data-wide><label class="label" for="${id('empty')}">${tx('fdz.text_when_there_are_no')}</label>
197
+ <input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="${tx('fdz.no_data_found')}"></div>
197
198
  </div></fieldset>
198
199
  ${sourceSets}
199
- <fieldset class="prop-group"><legend>Add a data source</legend><div class="form-grid">
200
- <div class="field" data-wide><label class="label" for="${id('new_object')}">Table or view</label>
200
+ <fieldset class="prop-group"><legend>${tx('fdz.add_a_data_source')}</legend><div class="form-grid">
201
+ <div class="field" data-wide><label class="label" for="${id('new_object')}">${tx('fdz.table_or_view')}</label>
201
202
  <select id="${id('new_object')}" name="new_object"><option value="">- none -</option>${objects.map((o) => html`<option value="${o.oid}">${o.name}${o.access ? '' : ' (no access for the app role)'}</option>`)}</select></div>
202
- <div class="field"><label class="label" for="${id('new_id')}">Static id</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="from the name"></div>
203
- <div class="field"><label class="label" for="${id('new_label')}">Label</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
203
+ <div class="field"><label class="label" for="${id('new_id')}">${tx('fdz.static_id')}</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="${tx('fdz.from_the_name')}"></div>
204
+ <div class="field"><label class="label" for="${id('new_label')}">${tx('fdz.label')}</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
204
205
  </div>
205
- <small class="help">All columns are offered at first (except binary and JSON ones), the primary key is the key column, and forms may add and change rows, not delete them. Untick columns designers should not put on forms, such as generated keys and audit columns.</small></fieldset>
206
- <div class="buttons"><button class="btn btn-hot">Save Form Designer settings</button></div>
206
+ <small class="help">${tx('fdz.all_columns_are_offered_at')}</small></fieldset>
207
+ <div class="buttons"><button class="btn btn-hot">${tx('fdz.save_form_designer_settings')}</button></div>
207
208
  </form>`;
208
209
  }
209
210
 
@@ -223,7 +224,7 @@ export async function formDesignerBuilderRoutes(app: FastifyInstance) {
223
224
  { authz: new Set(schemes.map((x) => x.name.toUpperCase())), lovs: new Set(lovs.map((x) => x.name.toUpperCase())) });
224
225
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
225
226
  if (errors.length) flash(s, errors.join(' '), 'error');
226
- else flash(s, 'Settings saved.');
227
+ else flash(s, tx('fdz.settings_saved'));
227
228
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
228
229
  });
229
230
  }
@@ -12,6 +12,7 @@ import { isAdmin } from './locks.ts';
12
12
  import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
13
13
  import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
14
14
  import { workspacesChanged } from '../workspace-db.ts';
15
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
15
16
 
16
17
  // The App Gallery (sprint 61, sprint 120; APEX: App Gallery with Sample, Starter, Utility and
17
18
  // Custom Apps).
@@ -69,7 +70,7 @@ const SAMPLE_FILE = /^(samples\/)?[a-z0-9-]+\.sql$/;
69
70
  const galleryFile = (rel: string) => readFileSync(join(rel.includes('/') ? GALLERY : join(root, 'examples'), rel), 'utf8');
70
71
 
71
72
  type Tab = 'samples' | 'starters' | 'utilities' | 'custom';
72
- const TABS: readonly [Tab, string][] = [['samples', 'Sample Apps'], ['starters', 'Starter Apps'], ['utilities', 'Utility Apps'], ['custom', 'Custom Apps']];
73
+ const TABS: readonly [Tab, BuilderKey][] = [['samples', 'gal.tab.samples'], ['starters', 'gal.tab.starters'], ['utilities', 'gal.tab.utilities'], ['custom', 'gal.tab.custom']];
73
74
 
74
75
  export async function galleryRoutes(app: FastifyInstance) {
75
76
  app.get(`${BASE}/gallery`, async (req: Req, reply) => {
@@ -100,50 +101,45 @@ export async function galleryRoutes(app: FastifyInstance) {
100
101
  const card = (ic: string, title: string, by: string, text: string, action: unknown) =>
101
102
  html`<article class="gal-card" data-filter-row><div class="gal-head"><span class="gal-icon">${icon(ic)}</span><div><h2>${title}</h2><span class="muted small">${by}</span></div></div>
102
103
  <p>${text}</p><div class="gal-foot">${action}</div></article>`;
103
- const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${TABS.map(([t, l]) =>
104
- html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${l} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
104
+ const tabs = html`<nav class="gal-tabs segmented" aria-label="${tx('gal.kinds')}">${TABS.map(([t, l]) =>
105
+ html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${tx(l)} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
105
106
  const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
106
- html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">Install</button></form>`);
107
+ html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">${tx('gal.install')}</button></form>`);
107
108
  const cards =
108
109
  tab === 'samples'
109
110
  ? samples.filter(matches).map((x) => {
110
111
  const it = installed.get(x.alias);
111
112
  return card(x.icon, x.title, 'pgkiln', x.description,
112
113
  it
113
- ? html`<span class="tag tag-ok">Installed</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">Run</a>${it.here
114
- ? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">Edit</a>`
115
- : html`<span class="muted small">in another workspace</span>`}`
114
+ ? html`<span class="tag tag-ok">${tx('gal.installed')}</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">${tx('common.run')}</a>${it.here
115
+ ? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">${tx('gal.edit')}</a>`
116
+ : html`<span class="muted small">${tx('gal.other_workspace')}</span>`}`
116
117
  : admin
117
- ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="Install ${x.title}? It adds its own schema, data and demo accounts with known passwords: not for a production instance.">Install</button></form>`
118
- : html`<span class="muted small">An administrator installs it.</span>`);
118
+ ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
119
+ : html`<span class="muted small">${tx('gal.admin_installs')}</span>`);
119
120
  })
120
121
  : tab === 'starters' || tab === 'utilities'
121
122
  ? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
122
- : custom.filter(matches).map((x) => card('copy', x.name, 'This workspace', x.description ?? 'A boilerplate application: a new application starts with its pages, shared components and settings.',
123
- html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">Create from it</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">Edit</a>`));
123
+ : custom.filter(matches).map((x) => card('copy', x.name, tx('gal.this_workspace'), x.description ?? tx('gal.boilerplate_desc'),
124
+ html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
124
125
  const empty = q && counts[tab]
125
- ? html`<p class="muted">No app matches <strong>${q}</strong>. <a href="${BASE}/gallery?tab=${tab}">Show all</a></p>`
126
+ ? html`<p class="muted">${tx.rich('gal.no_match', { q: html`<strong>${q}</strong>` })} <a href="${BASE}/gallery?tab=${tab}">${tx('gal.show_all')}</a></p>`
126
127
  : tab === 'custom'
127
- ? html`<p class="muted">No custom apps yet: make an application of type <em>Boilerplate</em> (Application settings → Application type) and it appears here.</p>`
128
- : html`<p class="muted">The gallery is empty.</p>`;
129
- const intro = {
130
- samples: 'Applications that show what pgkiln can do, each with its own schema, data and demo account: the showcase, the tutorial, one app per feature area and the UX Pattern Catalog. An administrator installs them on a development or demo instance.',
131
- starters: 'Small, ready-to-use applications. Install shows what will be created (tables, pages, sample data) before anything happens; the application gets its own role and schema in this workspace.',
132
- utilities: 'Applications a workspace uses rather than learns from: shared lookup lists, release notes, announcements. Like starter apps, Install shows what will be created first, and the application gets its own role and schema in this workspace.',
133
- custom: 'The boilerplate applications of this workspace: start a new application from one.',
134
- }[tab];
128
+ ? html`<p class="muted">${tx.rich('gal.no_custom', { type: html`<em>${tx('gal.boilerplate')}</em>` })}</p>`
129
+ : html`<p class="muted">${tx('gal.empty')}</p>`;
130
+ const intro = tx(({ samples: 'gal.intro.samples', starters: 'gal.intro.starters', utilities: 'gal.intro.utilities', custom: 'gal.intro.custom' } as const)[tab]);
135
131
  const main = html`<div class="gal">
136
132
  <div class="gal-bar">
137
- <form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">Search apps</label>${icon('search')}
133
+ <form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">${tx('gal.search')}</label>${icon('search')}
138
134
  <input type="hidden" name="tab" value="${tab}">
139
- <input id="gal-q" name="q" type="search" placeholder="Search apps" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
135
+ <input id="gal-q" name="q" type="search" placeholder="${tx('gal.search')}" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
140
136
  ${tabs}
141
137
  </div>
142
- <h1 class="sr-only">App Gallery</h1>
138
+ <h1 class="sr-only">${tx('rail.gallery')}</h1>
143
139
  <p class="muted">${intro}</p>
144
140
  ${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
145
141
  </div>`;
146
- return send(reply, s, shell(s, 'App Gallery', [['App Gallery']], main, 'gallery'));
142
+ return send(reply, s, shell(s, tx('rail.gallery'), [[tx('rail.gallery')]], main, 'gallery'));
147
143
  });
148
144
 
149
145
  // a sample app: an administrator installs it (shipped SQL, run as the owner)
@@ -154,12 +150,12 @@ export async function galleryRoutes(app: FastifyInstance) {
154
150
  const x = KEY.test(req.params.key) ? catalog().samples.find((c) => c.key === req.params.key) : undefined;
155
151
  if (!x) return reply.code(404).send('Not found');
156
152
  try {
157
- if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(`an application with the alias ${x.alias} exists already.`);
153
+ if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(tx('gal.err_alias', { alias: x.alias }));
158
154
  const ws = currentWorkspace(s);
159
155
  // into the current workspace (an isolated one gets the application's role too)
160
156
  const place = async (c: Client) => {
161
157
  const made = (await c.query('select id, db_role from meta.app where alias = $1', [x.alias])).rows[0] as { id: number; db_role: string | null } | undefined;
162
- if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
158
+ if (!made) throw new Error(tx('gal.err_no_app', { alias: x.alias }));
163
159
  if (ws > 1) {
164
160
  if (made.db_role && (await c.query('select 1 from meta.workspace where id = $1 and isolated', [ws])).rowCount)
165
161
  await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [ws, made.db_role]);
@@ -178,13 +174,13 @@ export async function galleryRoutes(app: FastifyInstance) {
178
174
  await c.query(sql);
179
175
  return place(c);
180
176
  });
181
- } else throw new Error('the gallery entry names nothing to install.');
177
+ } else throw new Error(tx('gal.err_nothing'));
182
178
  await workspacesChanged();
183
179
  await logActivity({ appId: made.id, username: s.username, event: 'gallery', ip: clientIp(req), detail: `installed sample app ${x.key}` });
184
- flash(s, `${x.title} installed (/a/${x.alias}).`);
180
+ flash(s, tx('gal.installed_flash', { title: x.title, alias: x.alias }));
185
181
  return back(reply, s, `${BASE}/apps/${made.id}`);
186
182
  } catch (e) {
187
- flash(s, `${x.title} was not installed: ${(e as Error).message}`, 'error');
183
+ flash(s, tx('gal.not_installed', { title: x.title, message: (e as Error).message }), 'error');
188
184
  return back(reply, s, `${BASE}/gallery`);
189
185
  }
190
186
  });