pgkiln 0.53.0 → 0.55.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 (83) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/127_builder_language.sql +16 -0
  3. package/docs/apex-feature-parity.md +7 -7
  4. package/docs/guide/03-builder.md +62 -8
  5. package/docs/guide/12-development.md +50 -3
  6. package/examples/gallery/README.md +37 -6
  7. package/examples/gallery/gallery.json +23 -1
  8. package/examples/gallery/samples/sample-calendar.sql +172 -0
  9. package/examples/gallery/samples/sample-cards.sql +157 -0
  10. package/examples/gallery/samples/sample-charts.sql +265 -0
  11. package/examples/gallery/samples/sample-dynamic-actions.sql +177 -0
  12. package/examples/gallery/samples/sample-grids.sql +151 -0
  13. package/examples/gallery/samples/sample-reporting.sql +151 -0
  14. package/examples/gallery/samples/ux-patterns.sql +295 -0
  15. package/examples/gallery/utilities/announcements.json +24 -0
  16. package/examples/gallery/utilities/lookup-lists.json +28 -0
  17. package/examples/gallery/utilities/release-notes.json +41 -0
  18. package/package.json +1 -1
  19. package/public/app.css +2 -0
  20. package/public/builder.css +39 -1
  21. package/public/worksheet.css +3 -2
  22. package/src/builder/advisor.ts +1 -1
  23. package/src/builder/ai-agents.ts +5 -5
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/api.ts +1 -1
  26. package/src/builder/autorest.ts +2 -2
  27. package/src/builder/availability.ts +1 -1
  28. package/src/builder/backups.ts +1 -1
  29. package/src/builder/changelog.ts +1 -1
  30. package/src/builder/data-modeler.ts +4 -4
  31. package/src/builder/data-security.ts +1 -1
  32. package/src/builder/dataload.ts +1 -1
  33. package/src/builder/datapump.ts +1 -1
  34. package/src/builder/ddl-tools.ts +2 -2
  35. package/src/builder/designer.ts +134 -124
  36. package/src/builder/diagnostics.ts +2 -2
  37. package/src/builder/errors.ts +10 -12
  38. package/src/builder/feedback.ts +1 -1
  39. package/src/builder/gallery.ts +94 -47
  40. package/src/builder/globalization.ts +1 -1
  41. package/src/builder/home.ts +59 -57
  42. package/src/builder/i18n/de.ts +675 -0
  43. package/src/builder/i18n/en.ts +678 -0
  44. package/src/builder/i18n/es.ts +675 -0
  45. package/src/builder/i18n/fr.ts +675 -0
  46. package/src/builder/i18n/nl.ts +675 -0
  47. package/src/builder/i18n/pt.ts +675 -0
  48. package/src/builder/i18n.ts +116 -0
  49. package/src/builder/language.ts +74 -0
  50. package/src/builder/launchpad.ts +13 -8
  51. package/src/builder/object-browser.ts +1 -1
  52. package/src/builder/plugins.ts +1 -1
  53. package/src/builder/querybuilder.ts +1 -1
  54. package/src/builder/quicksql.ts +1 -1
  55. package/src/builder/rest-modules.ts +2 -2
  56. package/src/builder/routes.ts +88 -66
  57. package/src/builder/sampledata.ts +1 -1
  58. package/src/builder/scheduling.ts +1 -1
  59. package/src/builder/scripts.ts +1 -1
  60. package/src/builder/search.ts +1 -1
  61. package/src/builder/shared.ts +5 -5
  62. package/src/builder/sql-charts.ts +2 -2
  63. package/src/builder/sql-monitor.ts +2 -2
  64. package/src/builder/sql-tools.ts +6 -0
  65. package/src/builder/sql-utilities.ts +3 -3
  66. package/src/builder/sql.ts +24 -22
  67. package/src/builder/static-files.ts +1 -1
  68. package/src/builder/subscriptions.ts +1 -1
  69. package/src/builder/supporting.ts +1 -1
  70. package/src/builder/table-editor.ts +1 -1
  71. package/src/builder/team.ts +7 -5
  72. package/src/builder/themeroller.ts +1 -1
  73. package/src/builder/top-sql.ts +1 -1
  74. package/src/builder/ui.ts +87 -77
  75. package/src/builder/unload.ts +1 -1
  76. package/src/builder/wizards.ts +1 -1
  77. package/src/builder/workflow-migrate.ts +1 -1
  78. package/src/builder/workingcopies.ts +2 -2
  79. package/src/builder/worksheet.ts +1 -1
  80. package/src/builder/workspace-home.ts +62 -59
  81. package/src/builder/workspaces.ts +2 -1
  82. package/src/runtime/charts.ts +4 -1
  83. package/src/runtime/regions.ts +8 -5
@@ -94,7 +94,7 @@ async function page(req: Req, appId: string, result?: { kind: Kind; run: RunResu
94
94
  const scripts = (await owner.query('select id, name, kind, seq, script from meta.supporting_script where app_id = $1 order by kind, seq, name', [a.id])).rows;
95
95
  const imported = req.query?.imported === '1';
96
96
  const byKind = (k: Kind) => scripts.filter((x) => x.kind === k);
97
- return { a, body: (s: Session) => html`${appHeader(a, 'shared')}
97
+ return { a, body: (s: Session) => html`${appHeader(a, 'shared', s)}
98
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
99
  ${result ? region(`Result: ${result.kind} scripts`, resultsHtml(result.kind, result.run)) : ''}
100
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:
@@ -209,7 +209,7 @@ async function page(req: Req, reply: FastifyReply) {
209
209
  if (errors.length) tab = 'columns';
210
210
  }
211
211
  const title = editing ? `Table ${editing.schema}.${editing.name}` : 'Create table';
212
- const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects')}
212
+ const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects', s)}
213
213
  ${form(s, def, { editing, schemas, refTables, ddl, errors, output, tab, ref: editing ? ref : '' })}`;
214
214
  return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [title]], main, 'sql'));
215
215
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from '../team.ts';
9
9
  import { TEAM_LANGUAGES, TEAM_SAMPLES } from '../team-samples.ts';
10
10
  import { ago } from './home.ts';
11
+ import { tr } from './i18n.ts';
11
12
  import { back, BASE, csrf, designerUrl, developer, flash, region, send, shell, type Req } from './ui.ts';
12
13
  import { appAllowed, appWorkspace, currentWorkspace, workspaceAdmin } from './workspaces.ts';
13
14
 
@@ -975,12 +976,13 @@ export async function teamHomeBlock(s: Session) {
975
976
  const ws = currentWorkspace(s);
976
977
  if (ws < 0) return '';
977
978
  const c = await teamCounts(ws, s.username);
978
- return html`<section class="wh-team" aria-labelledby="wh-team-h"><h2 id="wh-team-h">Team Development</h2>
979
+ const tx = tr(s);
980
+ return html`<section class="wh-team" aria-labelledby="wh-team-h"><h2 id="wh-team-h">${tx('rail.team')}</h2>
979
981
  <ul class="ab-links ab-tasks">
980
- <li><a href="${TEAM}">${icon('kanban')} Open issues <b class="wh-team-n">${c.open}</b></a></li>
981
- <li><a href="${TEAM}?assignee=me">${icon('user')} Assigned to you <b class="wh-team-n">${c.mine}</b></a></li>
982
- ${c.mentions ? html`<li><a href="${TEAM}#mentions">${icon('at')} Mentions of you <b class="wh-team-n">${c.mentions}</b></a></li>` : ''}
983
- ${c.set_up ? '' : html`<li><a href="${TEAM}/setup">${icon('settings')} Set up Team Development</a></li>`}
982
+ <li><a href="${TEAM}">${icon('kanban')} ${tx('team.open')} <b class="wh-team-n">${c.open}</b></a></li>
983
+ <li><a href="${TEAM}?assignee=me">${icon('user')} ${tx('team.mine')} <b class="wh-team-n">${c.mine}</b></a></li>
984
+ ${c.mentions ? html`<li><a href="${TEAM}#mentions">${icon('at')} ${tx('team.mentions')} <b class="wh-team-n">${c.mentions}</b></a></li>` : ''}
985
+ ${c.set_up ? '' : html`<li><a href="${TEAM}/setup">${icon('settings')} ${tx('team.setup')}</a></li>`}
984
986
  </ul></section>`;
985
987
  }
986
988
 
@@ -114,7 +114,7 @@ export async function themeRollerRoutes(app: FastifyInstance) {
114
114
  };
115
115
  const swatch = (hex: string | undefined) =>
116
116
  !hex ? html`<span class="muted">base</span>` : ITEM_REF.test(hex) ? html`<code>${hex}</code>` : html`<input type="color" value="${hex}" disabled aria-label="${hex}" class="swatch"> <code>${hex}</code>`;
117
- const main = html`${appHeader(a, 'settings')}
117
+ const main = html`${appHeader(a, 'settings', s)}
118
118
  <div class="ide-body">
119
119
  ${region('Theme Roller: style variants', html`
120
120
  <p class="muted u-mt0">Several saved styles for this application (APEX: theme styles). The base style
@@ -84,7 +84,7 @@ export async function topSqlRoutes(app: FastifyInstance) {
84
84
  : top.reason === 'no_role'
85
85
  ? html`<div class="alert alert-error" role="alert">This application runs as the owner role, so its statements can't be told apart. Give it a database role in Settings.</div>`
86
86
  : statementsNote(top.reason, top.message);
87
- const main = html`${appHeader(a, 'activity')}
87
+ const main = html`${appHeader(a, 'activity', s)}
88
88
  ${region('Top SQL', html`<p class="muted u-mt0">Statements run as the role <code>${a.db_role ?? '(owner)'}</code> since the statistics were last reset, from <code>pg_stat_statements</code>. Literals and binds show as <code>$1</code>, <code>$2</code>…; applications that share a role share this list. <a href="${BASE}/apps/${a.id}/activity">Back to Activity</a></p>
89
89
  ${body}`)}`;
90
90
  return send(reply, s, shell(s, `Top SQL · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Top SQL']], main));
package/src/builder/ui.ts CHANGED
@@ -7,9 +7,11 @@ import { html, raw, type Raw } from '../html.ts';
7
7
  import { icon } from '../icons.ts';
8
8
  import { documentShell } from '../layout.ts';
9
9
  import { getSession, saveState, takeFlash, type Session } from '../session.ts';
10
- import { SQL_TOOLS, TOOL_GROUPS } from './sql-tools.ts';
10
+ import { SQL_TOOLS, TOOL_GROUPS, toolLabel } from './sql-tools.ts';
11
11
  import { ensureDevbar } from '../runtime/devbar.ts';
12
12
  import { noteChange } from './app-updates.ts';
13
+ import { tr } from './i18n.ts';
14
+ import { languageMenu, loadLanguage } from './language.ts';
13
15
 
14
16
  // Shared building blocks of the builder UI.
15
17
 
@@ -101,94 +103,97 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
101
103
  const ok = takeFlash(s, '__FLASH');
102
104
  const err = takeFlash(s, '__ERROR');
103
105
  const theme = builderTheme(s);
106
+ // (sprint 121) the developer's builder language (i18n.ts); known English breadcrumb labels are translated here
107
+ const tx = tr(s);
104
108
  // the application these pages belong to (for the Search entry), from the breadcrumb
105
109
  const appId = crumbs.map(([, href]) => /^\/builder\/apps\/(\d+)$/.exec(href ?? '')?.[1]).find(Boolean);
106
110
  const rail = (key: Section | 'search' | 'help', href: string, ic: string, label: string, extra: Raw | '' = '') =>
107
111
  html`<li><a href="${href}" class="rail-link" title="${label}"${section === key ? raw(' aria-current="page"') : ''}${extra}>${bicon(ic)}<span class="rail-label">${label}</span></a></li>`;
108
- const back = [...crumbs].reverse().find(([, href], i) => i > 0 && href);
109
112
  // (sprint 74) the default-password warning: on the Workspace home (sprint 109), the App Builder home and the Developers page only, until
110
113
  // dismissed (for this sign-in); a dot on the avatar stays until the password is changed
111
114
  const weak = !!s.state.__WEAK;
112
115
  const weakBanner = weak && !s.state.__WEAK_HIDDEN && crumbs.length === 1 && (crumbs[0][0] === 'Home' || crumbs[0][0] === 'App Builder' || crumbs[0][0] === 'Developers');
116
+ const trail = crumbs.map(([label, href]): [string, string?] => [tx.label(label), href]);
117
+ const back = [...trail].reverse().find(([, href], i) => i > 0 && href);
113
118
  const themeButton = (t: BuilderTheme, ic: string, label: string) =>
114
119
  html`<button name="theme" value="${t}" aria-pressed="${theme === t ? 'true' : 'false'}">${bicon(ic)}<span>${label}</span></button>`;
115
120
  return documentShell(
116
- `${title} · Builder`,
117
- html`<a class="skip-link" href="#main">Skip to content</a>
118
- <nav class="ide-rail" aria-label="Builder">
119
- <a class="rail-logo" href="${BASE}/home" title="pgkiln Builder">${brandMark()}<span class="sr-only">pgkiln Builder home</span></a>
121
+ `${tx.label(title)} · ${tx('shell.builder')}`,
122
+ html`<a class="skip-link" href="#main">${tx('shell.skip')}</a>
123
+ <nav class="ide-rail" aria-label="${tx('shell.builder')}">
124
+ <a class="rail-logo" href="${BASE}/home" title="${tx('shell.logo')}">${brandMark()}<span class="sr-only">${tx('shell.logo_home')}</span></a>
120
125
  <ul class="rail-list">
121
- ${rail('home', `${BASE}/home`, 'home', 'Home')}
122
- ${rail('apps', `${BASE}/apps`, 'builder', 'App Builder')}
123
- ${s.workspace?.sql_workshop === false ? '' : rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
124
- ${rail('gallery', `${BASE}/gallery`, 'package', 'App Gallery')}
125
- ${rail('reporter', `${BASE}/reporter`, 'pie-chart', 'Data Reporter')}
126
- ${rail('team', `${BASE}/team`, 'kanban', 'Team Development')}
127
- ${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', 'Search this application') : ''}
126
+ ${rail('home', `${BASE}/home`, 'home', tx('rail.home'))}
127
+ ${rail('apps', `${BASE}/apps`, 'builder', tx('rail.app_builder'))}
128
+ ${s.workspace?.sql_workshop === false ? '' : rail('sql', `${BASE}/sql`, 'database', tx('rail.sql_workshop'))}
129
+ ${rail('gallery', `${BASE}/gallery`, 'package', tx('rail.gallery'))}
130
+ ${rail('reporter', `${BASE}/reporter`, 'pie-chart', tx('rail.reporter'))}
131
+ ${rail('team', `${BASE}/team`, 'kanban', tx('rail.team'))}
132
+ ${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', tx('rail.search_app')) : ''}
128
133
  </ul>
129
134
  <ul class="rail-list rail-bottom">
130
135
  <li><details class="menu rail-user rail-admin">
131
- <summary class="rail-link${section === 'users' || section === 'developers' ? ' rail-open' : ''}" title="Administration"${section === 'users' || section === 'developers' ? raw(' aria-current="page"') : ''}>${icon('settings')}<span class="rail-label">Administration</span></summary>
136
+ <summary class="rail-link${section === 'users' || section === 'developers' ? ' rail-open' : ''}" title="${tx('rail.admin')}"${section === 'users' || section === 'developers' ? raw(' aria-current="page"') : ''}>${icon('settings')}<span class="rail-label">${tx('rail.admin')}</span></summary>
132
137
  <div class="menu-panel">
133
138
  <div class="menu-section menu-links">
134
- <strong>Administration</strong>
135
- <a href="${BASE}/utilities">${icon('grid')} Workspace utilities</a>
139
+ <strong>${tx('rail.admin')}</strong>
140
+ <a href="${BASE}/utilities">${icon('grid')} ${tx('admin.utilities')}</a>
136
141
  </div>
137
142
  ${s.workspace?.admin ? html`<div class="menu-section menu-links">
138
- <span class="small muted">Manage service: ${s.workspace.name}</span>
139
- <a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} Workspace message and environment banner</a>
140
- <a href="${BASE}/workspaces/${s.workspace.id}#preferences">${icon('sliders-horizontal')} Workspace preferences</a>
141
- <a href="${BASE}/workspaces/${s.workspace.id}#links">${icon('external-link')} Extension links</a>
142
- <a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} Workspace utilization</a>
143
- <a href="${BASE}/mail-queue">${icon('inbox')} Mail queue</a>
144
- <a href="${BASE}/network">${icon('globe')} Network access</a>
145
- <a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} Workspace and developers</a>
143
+ <span class="small muted">${tx('admin.manage_service', { name: s.workspace.name })}</span>
144
+ <a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} ${tx('admin.ws_message')}</a>
145
+ <a href="${BASE}/workspaces/${s.workspace.id}#preferences">${icon('sliders-horizontal')} ${tx('admin.ws_prefs')}</a>
146
+ <a href="${BASE}/workspaces/${s.workspace.id}#links">${icon('external-link')} ${tx('admin.ext_links')}</a>
147
+ <a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} ${tx('admin.utilization')}</a>
148
+ <a href="${BASE}/mail-queue">${icon('inbox')} ${tx('admin.mail_queue')}</a>
149
+ <a href="${BASE}/network">${icon('globe')} ${tx('admin.network')}</a>
150
+ <a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} ${tx('admin.ws_devs')}</a>
146
151
  </div>` : ''}
147
152
  <div class="menu-section menu-links">
148
- <a href="${BASE}/users">${icon('user')} Manage users and groups</a>
149
- <a href="${BASE}/developers">${icon('users')} Developers</a>
150
- <a href="${BASE}/dashboard">${icon('activity')} Monitor activity</a>
151
- ${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} Database users</a>` : ''}
152
- <a href="${BASE}/developers">${icon('key')} Change my password</a>
153
+ <a href="${BASE}/users">${icon('user')} ${tx('admin.users')}</a>
154
+ <a href="${BASE}/developers">${icon('users')} ${tx('admin.developers')}</a>
155
+ <a href="${BASE}/dashboard">${icon('activity')} ${tx('admin.monitor')}</a>
156
+ ${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} ${tx('admin.db_users')}</a>` : ''}
157
+ <a href="${BASE}/developers">${icon('key')} ${tx('admin.change_my_password')}</a>
153
158
  </div>
154
159
  </div></details></li>
155
160
  ${s.workspace?.links?.length ? html`<li><details class="menu rail-user rail-admin rail-extensions">
156
- <summary class="rail-link" title="Extensions">${icon('puzzle')}<span class="rail-label">Extensions</span></summary>
157
- <div class="menu-panel"><div class="menu-section menu-links"><strong>Extensions</strong>
161
+ <summary class="rail-link" title="${tx('rail.extensions')}">${icon('puzzle')}<span class="rail-label">${tx('rail.extensions')}</span></summary>
162
+ <div class="menu-panel"><div class="menu-section menu-links"><strong>${tx('rail.extensions')}</strong>
158
163
  ${s.workspace.links.map((l) => html`<a href="${l.url}" target="_blank" rel="noopener noreferrer"${l.description ? html` title="${l.description}"` : ''}>${icon('external-link')} ${l.label}</a>`)}
159
164
  </div></div></details></li>` : ''}
160
- ${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', 'Help', raw(' target="_blank" rel="noopener"'))}
165
+ ${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', tx('rail.help'), raw(' target="_blank" rel="noopener"'))}
161
166
  <li><details class="menu rail-user">
162
- <summary class="rail-avatar${weak ? ' has-dot' : ''}" title="${weak ? `${s.username}: change the default password` : s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">Account: ${s.username}${weak ? ' (the default password: change it)' : ''}</span></summary>
167
+ <summary class="rail-avatar${weak ? ' has-dot' : ''}" title="${weak ? tx('user.weak_title', { user: s.username }) : s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">${tx(weak ? 'user.account_weak' : 'user.account', { user: s.username })}</span></summary>
163
168
  <div class="menu-panel">
164
- <div class="menu-section"><strong>${s.username}</strong><span class="muted small">Developer</span></div>
165
- ${weak ? html`<div class="menu-section"><a class="weak-note" href="${BASE}/developers">${icon('alert')} You use the default password: change it</a></div>` : ''}
169
+ <div class="menu-section"><strong>${s.username}</strong><span class="muted small">${tx('user.developer')}</span></div>
170
+ ${weak ? html`<div class="menu-section"><a class="weak-note" href="${BASE}/developers">${icon('alert')} ${tx('user.weak_note')}</a></div>` : ''}
166
171
  ${workspaceMenu(s)}
167
- <div class="menu-section"><span class="small muted" id="theme-label">Builder theme</span>
172
+ <div class="menu-section"><span class="small muted" id="theme-label">${tx('user.theme')}</span>
168
173
  <form method="post" action="${BASE}/theme" class="segmented ide-theme" role="group" aria-labelledby="theme-label">${csrf(s)}
169
- ${themeButton('dark', 'moon', 'Dark')}${themeButton('light', 'sun', 'Light')}${themeButton('auto', 'monitor', 'System')}
174
+ ${themeButton('dark', 'moon', tx('user.theme_dark'))}${themeButton('light', 'sun', tx('user.theme_light'))}${themeButton('auto', 'monitor', tx('user.theme_auto'))}
170
175
  </form></div>
171
176
  <div class="menu-section menu-links">
172
- <a href="${BASE}/developers">${icon('key')} Change password</a>
173
- <form method="post" action="${BASE}/logout">${csrf(s)}<button>${icon('logout')} Sign out</button></form>
177
+ <a href="${BASE}/developers">${icon('key')} ${tx('user.change_password')}</a>
178
+ <form method="post" action="${BASE}/logout">${csrf(s)}<button>${icon('logout')} ${tx('user.sign_out')}</button></form>
174
179
  </div>
175
180
  </div></details></li>
176
181
  </ul>
177
182
  </nav>
178
183
  <div class="ide-main">
179
184
  <header class="ide-bar">
180
- ${back ? html`<a class="ide-back" href="${back[1]}" title="Back to ${back[0]}">${bicon('back')}<span class="sr-only">Back to ${back[0]}</span></a>` : ''}
181
- <nav class="ide-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(([label, href], i) =>
182
- html`<li>${href && i < crumbs.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
185
+ ${back ? html`<a class="ide-back" href="${back[1]}" title="${tx('bar.back', { page: back[0] })}">${bicon('back')}<span class="sr-only">${tx('bar.back', { page: back[0] })}</span></a>` : ''}
186
+ <nav class="ide-crumbs" aria-label="${tx('bar.breadcrumb')}"><ol>${trail.map(([label, href], i) =>
187
+ html`<li>${href && i < trail.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
183
188
  <form method="get" action="${BASE}/palette" class="ide-search" role="search" data-palette>
184
- <label class="sr-only" for="ide-search-q">Search the builder</label>
185
- <input id="ide-search-q" name="q" type="search" placeholder="Search (Ctrl+K)" autocomplete="off" aria-keyshortcuts="Control+K"></form>
189
+ <label class="sr-only" for="ide-search-q">${tx('bar.search_label')}</label>
190
+ <input id="ide-search-q" name="q" type="search" placeholder="${tx('bar.search_placeholder')}" autocomplete="off" aria-keyshortcuts="Control+K"></form>
186
191
  <div class="ide-tools">${opts.toolbar ?? ''}</div>
187
192
  </header>
188
- ${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
193
+ ${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="${tx('bar.environment')}">${s.workspace.banner_label}</div>` : ''}
189
194
  ${weakBanner || ok || err ? html`<div class="ide-alerts">
190
- ${weakBanner ? html`<div class="alert alert-error alert-dismissible" role="alert"><span>You signed in with the default password. <a href="${BASE}/developers">Change it now.</a></span>
191
- <form method="post" action="${BASE}/weak/dismiss">${csrf(s)}<button class="link-button" aria-label="Dismiss until the next sign-in" title="Dismiss until the next sign-in">${icon('close')}</button></form></div>` : ''}
195
+ ${weakBanner ? html`<div class="alert alert-error alert-dismissible" role="alert"><span>${tx.rich('weak.banner', { link: html`<a href="${BASE}/developers">${tx('weak.change_now')}</a>` })}</span>
196
+ <form method="post" action="${BASE}/weak/dismiss">${csrf(s)}<button class="link-button" aria-label="${tx('weak.dismiss')}" title="${tx('weak.dismiss')}">${icon('close')}</button></form></div>` : ''}
192
197
  ${ok ? html`<div class="alert alert-success" role="status">${ok}</div>` : ''}
193
198
  ${err ? html`<div class="alert alert-error" role="alert">${err}</div>` : ''}
194
199
  </div>` : ''}
@@ -196,17 +201,17 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
196
201
  ${main}
197
202
  </main>
198
203
  <footer class="ide-status">
199
- <span title="Signed in as">${icon('user')}${s.username}</span>
200
- ${s.workspace ? html`<span title="Workspace">${icon('layers')}${s.workspace.name}</span>` : ''}
201
- <span title="Database">${icon('database')}${DATABASE}</span>
202
- <span title="Builder language">en</span>
204
+ <span title="${tx('status.signed_in_as')}">${icon('user')}${s.username}</span>
205
+ ${s.workspace ? html`<span title="${tx('status.workspace')}">${icon('layers')}${s.workspace.name}</span>` : ''}
206
+ <span title="${tx('status.database')}">${icon('database')}${DATABASE}</span>
207
+ ${languageMenu(s)}
203
208
  <span class="ide-status-version">pgkiln ${VERSION}</span>
204
209
  </footer>
205
210
  </div>`,
206
211
  `builder-body ide${opts.full ? ' ide-full' : ''}`,
207
212
  {},
208
213
  builderHead(),
209
- { theme },
214
+ { theme, lang: tx.lang },
210
215
  );
211
216
  }
212
217
 
@@ -232,50 +237,52 @@ export function select(name: string, label: string, value: unknown, options: (st
232
237
  export const region = (title: string, body: Raw | Raw[], extra: Raw | '' = '', id = '') =>
233
238
  html`<section class="region region-standard"${id ? raw(` id="${id.replace(/[^a-z0-9-]/g, '')}"`) : ''}><header class="region-header"><h2>${title}</h2>${extra}</header><div class="region-body">${body}</div></section>`;
234
239
 
235
- /** Title and tabs of an application's builder pages (Pages, Shared Components, …). */
236
- export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor') => {
240
+ /** Title and tabs of an application's builder pages (Pages, Shared Components, …), in the session's builder language. */
241
+ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor', s?: Pick<Session, 'state'>) => {
242
+ const tx = tr(s);
237
243
  const tab = (key: typeof active, href: string, ic: string, label: string) =>
238
244
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${bicon(ic)}<span>${label}</span></a>`;
239
245
  return html`
240
246
  <div class="ide-head">
241
247
  <div class="ide-head-title"><span class="ide-app-icon" aria-hidden="true">${String(a.name ?? '?').slice(0, 1).toUpperCase()}</span>
242
- <div><h1>${a.name}</h1><span class="muted small">Application ${a.id} · /a/${a.alias}</span> ${availabilityBadge(a)}</div>
248
+ <div><h1>${a.name}</h1><span class="muted small">${tx('app.head.id', { id: a.id })} · /a/${a.alias}</span> ${availabilityBadge(a)}</div>
243
249
  <div class="buttons ide-head-actions">
244
- <a class="btn btn-sm" href="${BASE}/apps/${a.id}/working-copies">${icon('layers')} Working copies</a>
245
- <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} Backups</a>
246
- <a class="btn btn-sm" href="${BASE}/apps/${a.id}/availability">${icon('power')} Availability</a>
247
- <a class="btn btn-sm" href="${BASE}/apps/${a.id}/export">${icon('download')} Export</a>
248
- <a class="btn btn-sm btn-run" href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} Run</a>
250
+ <a class="btn btn-sm" href="${BASE}/apps/${a.id}/working-copies">${icon('layers')} ${tx('app.head.copies')}</a>
251
+ <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} ${tx('app.head.backups')}</a>
252
+ <a class="btn btn-sm" href="${BASE}/apps/${a.id}/availability">${icon('power')} ${tx('app.head.availability')}</a>
253
+ <a class="btn btn-sm" href="${BASE}/apps/${a.id}/export">${icon('download')} ${tx('app.head.export')}</a>
254
+ <a class="btn btn-sm btn-run" href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('common.run')}</a>
249
255
  </div></div>
250
- <nav class="ide-tabs" aria-label="Application">
251
- ${tab('pages', `${BASE}/apps/${a.id}`, 'pages', 'Pages')}
252
- ${tab('shared', `${BASE}/apps/${a.id}/shared`, 'shapes', 'Shared Components')}
253
- ${tab('activity', `${BASE}/apps/${a.id}/activity`, 'activity', 'Activity')}
254
- ${tab('api', `${BASE}/apps/${a.id}/api`, 'code', 'REST API')}
255
- ${tab('settings', `${BASE}/apps/${a.id}/settings`, 'settings', 'Settings')}
256
- ${tab('search', `${BASE}/apps/${a.id}/search`, 'search', 'Search')}
257
- ${tab('advisor', `${BASE}/apps/${a.id}/advisor`, 'check', 'Advisor')}
256
+ <nav class="ide-tabs" aria-label="${tx('app.head.tabs')}">
257
+ ${tab('pages', `${BASE}/apps/${a.id}`, 'pages', tx('app.tab.pages'))}
258
+ ${tab('shared', `${BASE}/apps/${a.id}/shared`, 'shapes', tx('app.tab.shared'))}
259
+ ${tab('activity', `${BASE}/apps/${a.id}/activity`, 'activity', tx('app.tab.activity'))}
260
+ ${tab('api', `${BASE}/apps/${a.id}/api`, 'code', tx('app.tab.api'))}
261
+ ${tab('settings', `${BASE}/apps/${a.id}/settings`, 'settings', tx('app.tab.settings'))}
262
+ ${tab('search', `${BASE}/apps/${a.id}/search`, 'search', tx('common.search'))}
263
+ ${tab('advisor', `${BASE}/apps/${a.id}/advisor`, 'check', tx('app.tab.advisor'))}
258
264
  </nav>
259
265
  </div>`;
260
266
  };
261
267
 
262
- /** Tabs of the SQL Workshop. */
268
+ /** Tabs of the SQL Workshop, in the session's builder language (tool names: sql.tool.<key> in i18n/en.ts). */
263
269
  export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities' | 'charts' | 'modeler' | 'rest' | 'scheduling' | 'datapump' | 'monitor' | 'changes';
264
- export const workshopTabs = (active: WorkshopTab) => {
270
+ export const workshopTabs = (active: WorkshopTab, s?: Pick<Session, 'state'>) => {
271
+ const tx = tr(s);
265
272
  const tab = (key: string, href: string, ic: string, label: string) =>
266
273
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
267
274
  // (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never
268
275
  // scrolls; (sprint 96) the More menu under the Launchpad's headings, from SQL_TOOLS
269
276
  const main = SQL_TOOLS.filter((t) => t.main);
270
277
  const open = SQL_TOOLS.find((t) => !t.main && t.tab === active);
271
- return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="SQL Workshop">
272
- ${tab('home', `${BASE}/sql`, 'home', 'Home')}${main.map((t) => tab(t.tab!, t.href, t.icon, t.label))}
273
- ${open ? tab(open.tab!, open.href, open.icon, open.label) : ''}
274
- <details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>More</span></summary>
278
+ return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="${tx('rail.sql_workshop')}">
279
+ ${tab('home', `${BASE}/sql`, 'home', tx('rail.home'))}${main.map((t) => tab(t.tab!, t.href, t.icon, toolLabel(tx, t)))}
280
+ ${open ? tab(open.tab!, open.href, open.icon, toolLabel(tx, open)) : ''}
281
+ <details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>${tx('sw.more')}</span></summary>
275
282
  <div class="menu-panel align-right">${TOOL_GROUPS.map(([g, heading]) => {
276
283
  // administrators' tools are on the Launchpad and the Utilities page, not in every developer's menu
277
284
  const list = SQL_TOOLS.filter((t) => t.group === g && !t.main && !t.admin);
278
- return list.length ? html`<div class="menu-section menu-links"><span class="small muted">${heading}</span>${list.map((t) => html`<a href="${t.href}"${t.tab && t.tab === active ? raw(' aria-current="page"') : ''}>${icon(t.icon)} ${t.label}</a>`)}</div>` : '';
285
+ return list.length ? html`<div class="menu-section menu-links"><span class="small muted">${tx.dyn(`sql.group.${g}`, heading)}</span>${list.map((t) => html`<a href="${t.href}"${t.tab && t.tab === active ? raw(' aria-current="page"') : ''}>${icon(t.icon)} ${toolLabel(tx, t)}</a>`)}</div>` : '';
279
286
  })}</div></details></nav>`;
280
287
  };
281
288
 
@@ -304,9 +311,11 @@ export async function developer(req: Req, reply: FastifyReply) {
304
311
  return null;
305
312
  }
306
313
  if (req.method === 'POST' && req.body?.__csrf !== s.csrf_token) {
307
- reply.code(403).send('Invalid CSRF token; reload the page and try again.');
314
+ reply.code(403).send(tr(s)('error.csrf'));
308
315
  return null;
309
316
  }
317
+ // (sprint 121) a session from before the developer's language was kept in it: the stored choice (language.ts)
318
+ if (s.state.__BLANG === undefined) await loadLanguage(s);
310
319
  // (079) the developer toolbar on the applications' pages in this browser
311
320
  await ensureDevbar(req, reply, s);
312
321
  // workspaces: an application outside the developer's workspaces doesn't exist for them
@@ -318,8 +327,9 @@ export async function developer(req: Req, reply: FastifyReply) {
318
327
  }
319
328
  // (093) a workspace preference may turn the SQL Workshop off
320
329
  if (s.workspace?.sql_workshop === false && /^\/builder\/sql(?:[/?]|$)/.test(req.url)) {
330
+ const tx = tr(s);
321
331
  await send(reply.code(403), s, shell(s, 'SQL Workshop', [['App Builder', BASE], ['SQL Workshop']],
322
- html`<h1>SQL Workshop</h1><div class="alert alert-info" role="status">The SQL Workshop is turned off for the workspace ${s.workspace.name}. Its administrators can turn it on in the workspace preferences${s.workspaces && s.workspaces.length > 1 ? '; or switch to another workspace' : ''}.</div>`, 'sql'));
332
+ html`<h1>${tx('rail.sql_workshop')}</h1><div class="alert alert-info" role="status">${tx(s.workspaces && s.workspaces.length > 1 ? 'error.sql_off_switch' : 'error.sql_off', { name: s.workspace.name })}</div>`, 'sql'));
323
333
  return null;
324
334
  }
325
335
  // a page or application locked by another developer can't be changed (locks.ts)
@@ -88,7 +88,7 @@ const list = (v: unknown) => (Array.isArray(v) ? v : v === undefined || v === nu
88
88
  export async function unloadRoutes(app: FastifyInstance) {
89
89
  const page = (s: Session, reply: FastifyReply, main: Raw, code = 200) => {
90
90
  reply.code(code);
91
- return send(reply, s, shell(s, 'Unload Data', [['SQL Workshop', `${BASE}/sql`], ['Unload Data']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('unload')}${main}`, 'sql'));
91
+ return send(reply, s, shell(s, 'Unload Data', [['SQL Workshop', `${BASE}/sql`], ['Unload Data']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('unload', s)}${main}`, 'sql'));
92
92
  };
93
93
 
94
94
  const formatFields = (b: Body) => {
@@ -246,7 +246,7 @@ export async function wizardRoutes(app: FastifyInstance) {
246
246
  }
247
247
 
248
248
  const kindName = WIZARD_KINDS.find((k) => k[0] === kind)![1];
249
- const main = html`${appHeader(a, 'pages')}
249
+ const main = html`${appHeader(a, 'pages', s)}
250
250
  ${region(`${kindName} on ${rel.t}`, html`
251
251
  <p class="muted u-mt0">${WIZARD_KINDS.find((k) => k[0] === kind)![2]} The proposals come from the table's columns, key and foreign keys; change them below. <a href="${BASE}/apps/${a.id}">Back to choose another type or table</a>.</p>
252
252
  ${needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
@@ -179,7 +179,7 @@ export async function workflowMigrateRoutes(app: FastifyInstance) {
179
179
  : html`<p class="wf-problem">Map a step for every instance (or leave the others out) and preview again: nothing is migrated while one can't move.</p>`}`)
180
180
  : ''}
181
181
  ${target ? region(`Version ${to}`, html`<div class="wf-wrap">${workflowDiagram(target.steps, { id: 'target' })}</div>`) : ''}`;
182
- const main = html`${appHeader(a, 'shared')}<div class="ide-body">${body}</div>`;
182
+ const main = html`${appHeader(a, 'shared', s)}<div class="ide-body">${body}</div>`;
183
183
  return send(reply, s, shell(s, `Migrate ${d.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`],
184
184
  [d.name, `${BASE}/apps/${a.id}/shared?c=workflow_definition-${d.id}`], ['Migrate instances']], main));
185
185
  });
@@ -86,7 +86,7 @@ export async function workingCopyRoutes(app: FastifyInstance) {
86
86
  <div class="buttons"><button class="btn btn-hot">${icon('plus')} Create working copy</button></div>
87
87
  </form>`)}`;
88
88
  }
89
- const main = html`${appHeader(a, 'pages')}<div class="ide-body">${body}</div>`;
89
+ const main = html`${appHeader(a, 'pages', s)}<div class="ide-body">${body}</div>`;
90
90
  return send(reply, s, shell(s, `${a.name} working copies`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Working copies']], main));
91
91
  });
92
92
 
@@ -148,7 +148,7 @@ export async function workingCopyRoutes(app: FastifyInstance) {
148
148
  </div>
149
149
  </form>`
150
150
  : html`<p class="u-mt0">The working copy and the main application are the same: nothing to merge.</p>`;
151
- const page = html`${appHeader(a, 'pages')}<div class="ide-body">
151
+ const page = html`${appHeader(a, 'pages', s)}<div class="ide-body">
152
152
  ${region(`Compare with ${main.name}`, html`<p class="muted u-mt0">Working copy <b>${l.copy.name}</b> of
153
153
  <a href="${BASE}/apps/${main.id}">${main.name}</a>, compared per component with the main application as it was
154
154
  when the copy was made${l.copy.refreshed_at || l.copy.merged_at ? ', refreshed or merged' : ''}.</p>${body}`)}
@@ -557,7 +557,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
557
557
  html`<button class="btn btn-sm${hot ? ' btn-hot' : ''}" name="mode" value="${m}" title="${label} (${key})" data-ws-key="${key}">${icon(ic)}<span>${label}</span></button>`;
558
558
  const main = html`${chartCss ? html`<style nonce="${req.cspNonce}">${raw(chartCss.replace(/</g, ''))}</style>` : ''}<div class="ws-page">
559
559
  <h1 class="sr-only">SQL Worksheet</h1>
560
- ${workshopTabs('sql')}
560
+ ${workshopTabs('sql', s)}
561
561
  <form method="post" action="${BASE}/sql/worksheet" class="ws" id="ws" data-ran="${ran ? `${ran.start},${ran.end}` : ''}" data-error-line="${firstError?.errorLine ?? ''}"
562
562
  data-source="${source}" data-script-id="${script?.id ?? ''}" data-script-name="${script?.name ?? ''}" data-tab="${tabKey}">${csrf(s)}
563
563
  <button class="ws-default" name="mode" value="nav" tabindex="-1">Refresh the navigator</button>