pgkiln 0.84.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 (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -5,6 +5,7 @@ import { icon } from '../icons.ts';
5
5
  import { APPEARANCE_DEFAULT, appStyles, BASE_STYLES, baseStyleOf, FONT_SIZES, FONTS, HEX, ITEM_REF, MAX_STYLES, NAV_APPEARANCES, navAppearance, parseStyle, RADII, STYLE_NAME, type StyleVariant } from '../runtime/styles.ts';
6
6
  import { REGION_OPTIONS, BUTTON_OPTIONS } from '../runtime/template-options.ts';
7
7
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
8
+ import { scopedTx as tx } from './i18n.ts';
8
9
 
9
10
  // App → Settings → Theme Roller: style variants (APEX: theme styles). The
10
11
  // styles live in meta.app.theme ("styles", "style", "style_choice"), so they
@@ -27,12 +28,12 @@ async function updateTheme(id: string, change: (theme: Theme) => Theme | string)
27
28
  });
28
29
  }
29
30
 
30
- const label = (list: Record<string, { label: string }>, k: string | undefined) => (k && list[k] ? list[k].label : '- as the base -');
31
+ const label = (list: Record<string, { label: string }>, k: string | undefined) => (k && list[k] ? list[k].label : tx('thr.as_base'));
31
32
  /** (sprint 117) a style's navigation appearance as shown in the list */
32
- const appearanceLabel = (k: string | undefined) => (k === APPEARANCE_DEFAULT ? 'Default' : k && NAV_APPEARANCES[k] ? NAV_APPEARANCES[k].label : '- as the application -');
33
+ const appearanceLabel = (k: string | undefined) => (k === APPEARANCE_DEFAULT ? tx('thr.default') : k && NAV_APPEARANCES[k] ? NAV_APPEARANCES[k].label : tx('thr.as_app'));
33
34
  const appearanceChoices = (forStyle: boolean): [string, string][] => [
34
- forStyle ? ['', '- as the application -'] : ['', 'Default (the base style\'s header and menu)'],
35
- ...(forStyle ? [[APPEARANCE_DEFAULT, 'Default (the base style\'s header and menu)'] as [string, string]] : []),
35
+ forStyle ? ['', tx('thr.as_the_application')] : ['', tx('thr.default_the_base_style_s')],
36
+ ...(forStyle ? [[APPEARANCE_DEFAULT, tx('thr.default_base_style')] as [string, string]] : []),
36
37
  ...Object.entries(NAV_APPEARANCES).map(([k, v]): [string, string] => [k, v.label]),
37
38
  ];
38
39
  const APPEARANCE_HELP = 'APEX 26.2: Pillar Light puts the header and menu on the page\'s own light surfaces, Pillar Dark makes them one dark column in the header colour; both have a header pillar strip and show the current page as a filled pill.';
@@ -44,13 +45,13 @@ function styleForm(appId: number, s: Parameters<typeof csrf>[0], st: StyleVarian
44
45
  const value = (own ? st?.[name] : undefined) ?? (typeof base[name] === 'string' && HEX.test(base[name] as string) ? (base[name] as string) : fallback);
45
46
  return html`<div class="field"><label class="label" for="f_${name}">${title}</label>
46
47
  <input id="f_${name}" name="${name}" type="color" value="${value}">
47
- <label class="check"><input type="checkbox" name="${name}_own" value="true"${own ? raw(' checked') : ''}> Use this colour (else the base colour)</label>
48
- <label class="sub" for="f_${name}_item">…or the value of an item</label>
48
+ <label class="check"><input type="checkbox" name="${name}_own" value="true"${own ? raw(' checked') : ''}> ${tx('thr.use_this_colour_else_the')}</label>
49
+ <label class="sub" for="f_${name}_item">${tx('thr.or_the_value_of_an')}</label>
49
50
  <input id="f_${name}_item" name="${name}_item" value="${ref}" placeholder="&amp;APP_BRAND_COLOUR." autocomplete="off" spellcheck="false">
50
- <small class="help">A dynamic colour: the item's value when it is #rrggbb (else the base colour).</small></div>`;
51
+ <small class="help">${tx('thr.a_dynamic_colour_the_item')}</small></div>`;
51
52
  };
52
53
  const baseOf = BASE_STYLES[baseStyleOf(base)];
53
- const choices = (list: Record<string, { label: string }>) => [['', '- as the base -'] as [string, string], ...Object.entries(list).map(([k, v]): [string, string] => [k, v.label])];
54
+ const choices = (list: Record<string, { label: string }>) => [['', tx('thr.as_the_base')] as [string, string], ...Object.entries(list).map(([k, v]): [string, string] => [k, v.label])];
54
55
  // the live preview (builder.js): the style form's values as CSS variables on a sample of the application's look
55
56
  const lists = JSON.stringify({
56
57
  fonts: Object.fromEntries(Object.entries(FONTS).map(([k, v]) => [k, v.css])),
@@ -59,40 +60,40 @@ function styleForm(appId: number, s: Parameters<typeof csrf>[0], st: StyleVarian
59
60
  });
60
61
  // (sprint 117) the preview has a menu: the current page, its parent and the appearance in use
61
62
  const appearance = navAppearance(base as never, st) ?? '';
62
- const preview = html`<div class="tr-preview tr-preview-${baseStyleOf(base)}" data-tr-preview="${lists}" data-tr-app-appearance="${navAppearance(base as never, null) ?? ''}" data-appearance="${appearance}" aria-label="Preview of the style">
63
- <div class="tr-preview-header">${icon('menu')}<span>Preview</span></div>
63
+ const preview = html`<div class="tr-preview tr-preview-${baseStyleOf(base)}" data-tr-preview="${lists}" data-tr-app-appearance="${navAppearance(base as never, null) ?? ''}" data-appearance="${appearance}" aria-label="${tx('thr.preview_of_the_style')}">
64
+ <div class="tr-preview-header">${icon('menu')}<span>${tx('thr.preview')}</span></div>
64
65
  <div class="tr-preview-main">
65
- <ul class="tr-preview-nav" aria-label="Sample menu">
66
- <li><span class="tr-nav-parent">${icon('users')}<span>Employees</span></span>
67
- <ul><li><span class="tr-nav-current">Employee list</span></li><li><span>Directory</span></li></ul></li>
68
- <li><span>${icon('chart')}<span>Reports</span></span></li>
66
+ <ul class="tr-preview-nav" aria-label="${tx('thr.sample_menu')}">
67
+ <li><span class="tr-nav-parent">${icon('users')}<span>${tx('thr.employees')}</span></span>
68
+ <ul><li><span class="tr-nav-current">${tx('thr.employee_list')}</span></li><li><span>${tx('thr.directory')}</span></li></ul></li>
69
+ <li><span>${icon('chart')}<span>${tx('thr.reports')}</span></span></li>
69
70
  </ul>
70
71
  <div class="tr-preview-body">
71
- <section class="region region-standard"><header class="region-header"><h2>A region</h2></header>
72
- <div class="region-body"><p class="u-mt0">Text with a <a href="#tr-preview-link" id="tr-preview-link">link</a> and a <span class="tc-badge tc-badge-info">badge</span>.</p>
73
- <div class="field"><label class="label" for="tr-preview-input">A field</label><input id="tr-preview-input" value="Value" readonly></div>
74
- <div class="buttons"><button type="button" class="btn">Cancel</button><button type="button" class="btn btn-hot">Save</button></div></div></section>
72
+ <section class="region region-standard"><header class="region-header"><h2>${tx('thr.a_region')}</h2></header>
73
+ <div class="region-body"><p class="u-mt0">${tx.rich('thr.preview_text', { link: html`<a href="#tr-preview-link" id="tr-preview-link">${tx('thr.link')}</a>`, badge: html`<span class="tc-badge tc-badge-info">${tx('thr.badge')}</span>` })}</p>
74
+ <div class="field"><label class="label" for="tr-preview-input">${tx('thr.a_field')}</label><input id="tr-preview-input" value="${tx('thr.value')}" readonly></div>
75
+ <div class="buttons"><button type="button" class="btn">${tx('thr.cancel')}</button><button type="button" class="btn btn-hot">${tx('thr.save')}</button></div></div></section>
75
76
  </div></div></div>`;
76
77
  return html`${preview}<form method="post" action="${BASE}/apps/${appId}/theme/styles" data-tr-form>${csrf(s)}
77
78
  ${st ? html`<input type="hidden" name="original" value="${st.name}">` : ''}
78
79
  <div class="form-grid">
79
- ${input('name', 'Name', st?.name ?? '', { required: true, placeholder: 'e.g. Ocean', help: 'Shown to users who may choose a style. 1–30 letters, digits, spaces, - or _.' })}
80
- ${colour('accent', 'Accent colour', baseOf.accent)}
81
- ${colour('header', 'Header colour', baseOf.header)}
82
- ${colour('accent_dark', 'Accent colour in dark mode', '#a59cff')}
83
- ${colour('header_dark', 'Header colour in dark mode', '#0d0c1a')}
84
- ${select('appearance', 'Navigation appearance', st?.appearance ?? '', appearanceChoices(true), APPEARANCE_HELP)}
85
- ${colour('strip', 'Header strip colour', baseOf.accent)}
86
- ${colour('strip_dark', 'Header strip colour in dark mode', '#a59cff')}
87
- ${select('font', 'Font', st?.font ?? '', choices(FONTS))}
88
- ${select('font_size', 'Font size', st?.font_size ?? '', choices(FONT_SIZES))}
89
- ${select('radius', 'Corners', st?.radius ?? '', choices(RADII))}
90
- <div class="field" data-wide><label class="label" for="f_condition">Condition (SQL)</label>
80
+ ${input('name', tx('thr.name'), st?.name ?? '', { required: true, placeholder: tx('thr.e_g_ocean'), help: tx('thr.shown_to_users_who_may') })}
81
+ ${colour('accent', tx('thr.c_accent'), baseOf.accent)}
82
+ ${colour('header', tx('thr.c_header'), baseOf.header)}
83
+ ${colour('accent_dark', tx('thr.c_accent_dark'), '#a59cff')}
84
+ ${colour('header_dark', tx('thr.c_header_dark'), '#0d0c1a')}
85
+ ${select('appearance', tx('thr.navigation_appearance'), st?.appearance ?? '', appearanceChoices(true), APPEARANCE_HELP)}
86
+ ${colour('strip', tx('thr.c_strip'), baseOf.accent)}
87
+ ${colour('strip_dark', tx('thr.c_strip_dark'), '#a59cff')}
88
+ ${select('font', tx('thr.font'), st?.font ?? '', choices(FONTS))}
89
+ ${select('font_size', tx('thr.font_size'), st?.font_size ?? '', choices(FONT_SIZES))}
90
+ ${select('radius', tx('thr.corners'), st?.radius ?? '', choices(RADII))}
91
+ <div class="field" data-wide><label class="label" for="f_condition">${tx('thr.condition_sql')}</label>
91
92
  <textarea id="f_condition" name="condition" class="code" rows="2" spellcheck="false" data-code="sql" placeholder=":APP_TENANT = 'north'">${st?.condition ?? ''}</textarea>
92
- <small class="help">Optional. A boolean expression with bind variables, run as the application's role on every page: users who did not choose a style get the first style whose condition holds, else the default style (APEX: conditional theme styles).</small></div>
93
+ <small class="help">${tx('thr.optional_a_boolean_expression_with')}</small></div>
93
94
  </div>
94
- <div class="buttons"><button class="btn btn-hot">${st ? 'Save style' : 'Add style'}</button>
95
- ${st ? html`<a class="btn" href="${BASE}/apps/${appId}/theme">Cancel</a>` : ''}</div>
95
+ <div class="buttons"><button class="btn btn-hot">${st ? tx('thr.save_style') : tx('thr.add_style')}</button>
96
+ ${st ? html`<a class="btn" href="${BASE}/apps/${appId}/theme">${tx('thr.cancel')}</a>` : ''}</div>
96
97
  </form>`;
97
98
  }
98
99
 
@@ -110,46 +111,42 @@ export async function themeRollerRoutes(app: FastifyInstance) {
110
111
  const v = typeof theme[k] === 'string' && HEX.test(theme[k] as string) ? (theme[k] as string) : '';
111
112
  return html`<div class="field"><label class="label" for="f_app_${k}">${title}</label>
112
113
  <input id="f_app_${k}" name="${k}" type="color" value="${v || fallback}">
113
- <label class="check"><input type="checkbox" name="${k}_own" value="true"${v ? raw(' checked') : ''}> Use this colour (else the accent colour)</label></div>`;
114
+ <label class="check"><input type="checkbox" name="${k}_own" value="true"${v ? raw(' checked') : ''}> ${tx('thr.use_this_colour_else_the_2')}</label></div>`;
114
115
  };
115
116
  const swatch = (hex: string | undefined) =>
116
117
  !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
118
  const main = html`${appHeader(a, 'settings', s)}
118
119
  <div class="ide-body">
119
- ${region('Theme Roller: style variants', html`
120
- <p class="muted u-mt0">Several saved styles for this application (APEX: theme styles). The base style
121
- (${BASE_STYLES[baseStyleOf(a.theme)].label}) and colours under <a href="${BASE}/apps/${a.id}/settings">Settings → Theme</a>
122
- are what users see without a style; each style below changes the colours and may set a font, font size and corners.
123
- The colours apply to the light theme, the dark-mode colours to the dark theme (pick light colours there); fonts, sizes and corners to both.</p>
120
+ ${region(tx('thr.theme_roller_style_variants'), html`
121
+ <p class="muted u-mt0">${tx.rich('thr.variants_intro', { base: BASE_STYLES[baseStyleOf(a.theme)].label, settings: html`<a href="${BASE}/apps/${a.id}/settings">${tx('thr.settings_theme')}</a>` })}</p>
124
122
  ${styles.length
125
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Name</th><th>Accent</th><th>Header</th><th>Dark accent</th><th>Dark header</th><th>Navigation</th><th>Strip</th><th>Font</th><th>Font size</th><th>Corners</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
123
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('thr.name')}</th><th>${tx('thr.accent')}</th><th>${tx('thr.header')}</th><th>${tx('thr.dark_accent')}</th><th>${tx('thr.dark_header')}</th><th>${tx('thr.navigation')}</th><th>${tx('thr.strip')}</th><th>${tx('thr.font')}</th><th>${tx('thr.font_size')}</th><th>${tx('thr.corners')}</th><th><span class="sr-only">${tx('thr.actions')}</span></th></tr></thead><tbody>
126
124
  ${styles.map((x) => html`<tr><td>${x.name}${theme.style === x.name ? html` <span class="badge">default</span>` : ''}${x.condition ? html`<br><small class="muted">when <code>${x.condition.length > 60 ? `${x.condition.slice(0, 60)}…` : x.condition}</code></small>` : ''}</td>
127
125
  <td>${swatch(x.accent)}</td><td>${swatch(x.header)}</td><td>${swatch(x.accent_dark)}</td><td>${swatch(x.header_dark)}</td><td>${appearanceLabel(x.appearance)}</td><td>${swatch(x.strip)}</td><td>${label(FONTS, x.font)}</td><td>${label(FONT_SIZES, x.font_size)}</td><td>${label(RADII, x.radius)}</td>
128
- <td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/theme?edit=${encodeURIComponent(x.name)}">${icon('edit')} Edit</a>
126
+ <td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/theme?edit=${encodeURIComponent(x.name)}">${icon('edit')} ${tx('thr.edit')}</a>
129
127
  <form method="post" action="${BASE}/apps/${a.id}/theme/styles/delete" class="u-inline">${csrf(s)}<input type="hidden" name="name" value="${x.name}">
130
- <button class="btn btn-sm btn-danger" data-confirm="Delete style ${x.name}?">Delete</button></form></td></tr>`)}
128
+ <button class="btn btn-sm btn-danger" data-confirm="${tx('thr.delete_confirm', { name: x.name })}">${tx('thr.delete')}</button></form></td></tr>`)}
131
129
  </tbody></table></div>`
132
- : html`<p class="muted">No styles yet: the application uses its base colours.</p>`}
130
+ : html`<p class="muted">${tx('thr.no_styles_yet_the_application')}</p>`}
133
131
  <form method="post" action="${BASE}/apps/${a.id}/theme/settings" class="u-mt1">${csrf(s)}
134
132
  <div class="form-grid">
135
- ${select('style', 'Default style', typeof theme.style === 'string' ? theme.style : '', [['', 'None (the base style and colours)'], ...styles.map((x): [string, string] => [x.name, x.name])], 'What everyone sees unless they chose another style.')}
136
- ${select('appearance', 'Navigation appearance', navAppearance(theme as never, null) ?? '', appearanceChoices(false), `${APPEARANCE_HELP} A style may choose its own.`)}
137
- ${appColour('strip', 'Header strip colour', BASE_STYLES[baseStyleOf(a.theme)].accent)}
138
- ${appColour('strip_dark', 'Header strip colour in dark mode', '#a59cff')}
133
+ ${select('style', tx('thr.default_style'), typeof theme.style === 'string' ? theme.style : '', [['', tx('thr.none_the_base_style_and')], ...styles.map((x): [string, string] => [x.name, x.name])], tx('thr.what_everyone_sees_unless_they'))}
134
+ ${select('appearance', tx('thr.navigation_appearance'), navAppearance(theme as never, null) ?? '', appearanceChoices(false), `${APPEARANCE_HELP} A style may choose its own.`)}
135
+ ${appColour('strip', tx('thr.c_strip'), BASE_STYLES[baseStyleOf(a.theme)].accent)}
136
+ ${appColour('strip_dark', tx('thr.c_strip_dark'), '#a59cff')}
139
137
  </div>
140
- <div class="field"><label class="check"><input type="checkbox" name="style_choice" value="true"${theme.style_choice === true ? raw(' checked') : ''}> Users may choose a style</label>
141
- <small class="help">Adds the styles to the user menu and My account (APEX: "Enable End Users to Choose Theme Style"). A user's choice is kept per application on the account; only this application's styles can be chosen.</small></div>
142
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
138
+ <div class="field"><label class="check"><input type="checkbox" name="style_choice" value="true"${theme.style_choice === true ? raw(' checked') : ''}> ${tx('thr.users_may_choose_a_style')}</label>
139
+ <small class="help">${tx('thr.adds_the_styles_to_the')}</small></div>
140
+ <div class="buttons"><button class="btn btn-hot">${tx('thr.save')}</button></div>
143
141
  </form>`)}
144
- ${region(editing ? `Edit style ${editing.name}` : 'Add a style', styles.length >= MAX_STYLES && !editing
145
- ? html`<p class="muted">At most ${MAX_STYLES} styles per application.</p>`
142
+ ${region(editing ? tx('thr.edit_style', { name: editing.name }) : tx('thr.add_a_style'), styles.length >= MAX_STYLES && !editing
143
+ ? html`<p class="muted">${tx('thr.max_styles', { n: MAX_STYLES })}</p>`
146
144
  : styleForm(a.id, s, editing, theme))}
147
- ${region('Template options', html`<p class="muted u-mt0">Regions and buttons have <em>Template options</em> in the Page Designer
148
- (group Appearance): CSS classes from a fixed list, which these styles also affect.</p>
149
- <ul class="u-mt0"><li>Regions: ${REGION_OPTIONS.map((o, i) => html`${i ? ', ' : ''}<code>${o.cls}</code>`)}</li>
150
- <li>Buttons: ${BUTTON_OPTIONS.map((o, i) => html`${i ? ', ' : ''}<code>${o.cls}</code>`)}</li></ul>`)}
145
+ ${region(tx('thr.template_options'), html`<p class="muted u-mt0">${tx.rich('thr.options_intro', { options: html`<em>${tx('thr.template_options')}</em>` })}</p>
146
+ <ul class="u-mt0"><li>${tx.rich('thr.regions_list', { list: html`${REGION_OPTIONS.map((o, i) => html`${i ? ', ' : ''}<code>${o.cls}</code>`)}` })}</li>
147
+ <li>${tx.rich('thr.buttons_list', { list: html`${BUTTON_OPTIONS.map((o, i) => html`${i ? ', ' : ''}<code>${o.cls}</code>`)}` })}</li></ul>`)}
151
148
  </div>`;
152
- return send(reply, s, shell(s, `${a.name} Theme Roller`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Settings', `${BASE}/apps/${a.id}/settings`], ['Theme Roller']], main));
149
+ return send(reply, s, shell(s, tx('thr.title', { app: a.name }), [[tx('thr.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('thr.settings'), `${BASE}/apps/${a.id}/settings`], [tx('thr.theme_roller')]], main));
153
150
  });
154
151
 
155
152
  // add or change a style
@@ -180,9 +177,9 @@ export async function themeRollerRoutes(app: FastifyInstance) {
180
177
  const problem = await updateTheme(id, (t) => {
181
178
  const styles = appStyles(t as never);
182
179
  const at = original ? styles.findIndex((x) => x.name === original) : -1;
183
- if (original && at < 0) return `Style ${original} no longer exists.`;
184
- if (styles.some((x, i) => i !== at && x.name.toLowerCase() === parsed.name.toLowerCase())) return `There is already a style named ${parsed.name}.`;
185
- if (at < 0 && styles.length >= MAX_STYLES) return `At most ${MAX_STYLES} styles per application.`;
180
+ if (original && at < 0) return tx('thr.gone', { name: original });
181
+ if (styles.some((x, i) => i !== at && x.name.toLowerCase() === parsed.name.toLowerCase())) return tx('thr.exists', { name: parsed.name });
182
+ if (at < 0 && styles.length >= MAX_STYLES) return tx('thr.max_styles', { n: MAX_STYLES });
186
183
  if (at >= 0) styles[at] = parsed;
187
184
  else styles.push(parsed);
188
185
  const next: Theme = { ...t, styles };
@@ -196,7 +193,7 @@ export async function themeRollerRoutes(app: FastifyInstance) {
196
193
  // users who chose the renamed style keep it
197
194
  if (original && original !== parsed.name)
198
195
  await owner.query('update meta.account_style set style = $3 where app_id = $1 and style = $2', [id, original, parsed.name]);
199
- flash(s, `Style ${parsed.name} saved.`);
196
+ flash(s, tx('thr.saved', { name: parsed.name }));
200
197
  return back(reply, s, target);
201
198
  });
202
199
 
@@ -214,7 +211,7 @@ export async function themeRollerRoutes(app: FastifyInstance) {
214
211
  return next;
215
212
  });
216
213
  if (!problem) await owner.query('delete from meta.account_style where app_id = $1 and style = $2', [id, name]);
217
- flash(s, problem ?? `Style ${name} deleted.`, problem ? 'error' : 'ok');
214
+ flash(s, problem ?? tx('thr.deleted', { name }), problem ? 'error' : 'ok');
218
215
  return back(reply, s, `${BASE}/apps/${id}/theme`);
219
216
  });
220
217
 
@@ -242,7 +239,7 @@ export async function themeRollerRoutes(app: FastifyInstance) {
242
239
  else return 'Choose one of the styles.';
243
240
  return next;
244
241
  });
245
- flash(s, problem ?? 'Theme Roller settings saved.', problem ? 'error' : 'ok');
242
+ flash(s, problem ?? tx('thr.theme_roller_settings_saved'), problem ? 'error' : 'ok');
246
243
  return back(reply, s, `${BASE}/apps/${id}/theme`);
247
244
  });
248
245
  }
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
+ import { scopedTx as tx } from './i18n.ts';
5
6
  import { back, BASE, csrf, developer, flash, region, send, shell, type Body, type Req } from './ui.ts';
6
7
  import { usableSchemas, workshopDb } from './workspaces.ts';
7
8
 
@@ -72,33 +73,33 @@ export async function uiDefaultRoutes(app: FastifyInstance) {
72
73
  const t = schema ? await findTable(s, schema, q.table) : null;
73
74
  const counts = (await owner.query<{ schema_name: string; n: number }>('select schema_name, count(*)::int as n from meta.ui_default group by 1')).rows;
74
75
  const pick = html`<form method="get" class="util-filters">
75
- <div class="field"><label class="label" for="f_schema">Schema</label><select id="f_schema" name="schema"><option value="">Choose a schema</option>
76
+ <div class="field"><label class="label" for="f_schema">${tx('uid.schema')}</label><select id="f_schema" name="schema"><option value="">${tx('uid.choose_schema')}</option>
76
77
  ${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}${counts.find((c) => c.schema_name === n) ? ` (${counts.find((c) => c.schema_name === n)!.n})` : ''}</option>`)}</select></div>
77
- ${schema ? html`<div class="field"><label class="label" for="f_table">Table or view</label><select id="f_table" name="table"><option value="">Choose a table</option>
78
+ ${schema ? html`<div class="field"><label class="label" for="f_table">${tx('uid.table_or_view')}</label><select id="f_table" name="table"><option value="">${tx('uid.choose_table')}</option>
78
79
  ${tables.map((x) => html`<option value="${x.name}"${x.name === t?.table ? raw(' selected') : ''}>${x.name}${x.n ? ` (${x.n})` : ''}</option>`)}</select></div>` : ''}
79
- <div class="buttons"><button class="btn btn-sm">${icon('table')} Show</button></div></form>`;
80
+ <div class="buttons"><button class="btn btn-sm">${icon('table')} ${tx('common.show')}</button></div></form>`;
80
81
  let body = html`${pick}`;
81
82
  if (t) {
82
83
  const cols = await columnsOf(t.oid, t.schema, t.table);
83
84
  body = html`${pick}
84
85
  <form method="post" action="${BASE}/ui-defaults">${csrf(s)}<input type="hidden" name="schema" value="${t.schema}"><input type="hidden" name="table" value="${t.table}">
85
- <div class="table-wrap"><table class="table ui-defaults report-reflow"><thead><tr><th scope="col">Column</th><th scope="col">Label</th><th scope="col">Format mask</th><th scope="col">Alignment</th><th scope="col">Help</th><th scope="col">List of values</th></tr></thead><tbody>
86
+ <div class="table-wrap"><table class="table ui-defaults report-reflow"><thead><tr><th scope="col">${tx('uid.col_column')}</th><th scope="col">${tx('uid.col_label')}</th><th scope="col">${tx('uid.col_mask')}</th><th scope="col">${tx('uid.col_alignment')}</th><th scope="col">${tx('uid.col_help')}</th><th scope="col">${tx('uid.col_lov')}</th></tr></thead><tbody>
86
87
  ${cols.map((c, i) => html`<tr>
87
- <td data-label="Column"><code>${c.name}</code><br><span class="muted small">${c.type}</span><input type="hidden" name="c.${i}.name" value="${c.name}"></td>
88
- <td data-label="Label"><input id="f_l${i}" aria-label="Label of ${c.name}" name="c.${i}.label" value="${c.d?.label ?? ''}" maxlength="200" placeholder="${c.wizard}"></td>
89
- <td data-label="Format mask"><input id="f_m${i}" aria-label="Format mask of ${c.name}" name="c.${i}.format_mask" value="${c.d?.format_mask ?? ''}" maxlength="60" placeholder="${/numeric|integer|bigint|smallint|real|double/.test(c.type) ? '999G990D00' : /date|time/.test(c.type) ? 'DD.MM.YYYY' : ''}"></td>
90
- <td data-label="Alignment"><select id="f_a${i}" aria-label="Alignment of ${c.name}" name="c.${i}.alignment">${ALIGNMENTS.map((x) => html`<option value="${x}"${(c.d?.alignment ?? '') === x ? raw(' selected') : ''}>${x || '–'}</option>`)}</select></td>
91
- <td data-label="Help"><textarea id="f_h${i}" aria-label="Help of ${c.name}" name="c.${i}.help" rows="1" maxlength="4000">${c.d?.help ?? ''}</textarea></td>
92
- <td data-label="List of values"><input id="f_v${i}" aria-label="List of values of ${c.name}" name="c.${i}.lov" value="${c.d?.lov ?? ''}" maxlength="4000" placeholder="LOV:NAME"></td>
88
+ <td data-label="${tx('uid.col_column')}"><code>${c.name}</code><br><span class="muted small">${c.type}</span><input type="hidden" name="c.${i}.name" value="${c.name}"></td>
89
+ <td data-label="${tx('uid.col_label')}"><input id="f_l${i}" aria-label="${tx('uid.label_of', { column: c.name })}" name="c.${i}.label" value="${c.d?.label ?? ''}" maxlength="200" placeholder="${c.wizard}"></td>
90
+ <td data-label="${tx('uid.col_mask')}"><input id="f_m${i}" aria-label="${tx('uid.mask_of', { column: c.name })}" name="c.${i}.format_mask" value="${c.d?.format_mask ?? ''}" maxlength="60" placeholder="${/numeric|integer|bigint|smallint|real|double/.test(c.type) ? '999G990D00' : /date|time/.test(c.type) ? 'DD.MM.YYYY' : ''}"></td>
91
+ <td data-label="${tx('uid.col_alignment')}"><select id="f_a${i}" aria-label="${tx('uid.alignment_of', { column: c.name })}" name="c.${i}.alignment">${ALIGNMENTS.map((x) => html`<option value="${x}"${(c.d?.alignment ?? '') === x ? raw(' selected') : ''}>${x || '–'}</option>`)}</select></td>
92
+ <td data-label="${tx('uid.col_help')}"><textarea id="f_h${i}" aria-label="${tx('uid.help_of', { column: c.name })}" name="c.${i}.help" rows="1" maxlength="4000">${c.d?.help ?? ''}</textarea></td>
93
+ <td data-label="${tx('uid.col_lov')}"><input id="f_v${i}" aria-label="${tx('uid.lov_of', { column: c.name })}" name="c.${i}.lov" value="${c.d?.lov ?? ''}" maxlength="4000" placeholder="LOV:NAME"></td>
93
94
  </tr>`)}</tbody></table></div>
94
- <div class="buttons"><button class="btn btn-hot" name="mode" value="save">${icon('save')} Save</button>
95
- <button class="btn" name="mode" value="seed">${icon('edit')} Seed from comments</button></div>
96
- <p class="muted small">An empty row has no UI default. The placeholder of a label shows what the wizards use without one. Lists of values: <code>LOV:NAME</code> (a shared list of values of the application), <code>STATIC:Yes;Y,No;N</code> or a query (<code>select name d, id r from sales.status</code>).</p>
95
+ <div class="buttons"><button class="btn btn-hot" name="mode" value="save">${icon('save')} ${tx('common.save')}</button>
96
+ <button class="btn" name="mode" value="seed">${icon('edit')} ${tx('uid.seed')}</button></div>
97
+ <p class="muted small">${tx.code('uid.help')}</p>
97
98
  </form>`;
98
99
  }
99
- const main = html`<h1 class="u-mb1">UI defaults</h1>
100
- ${region('UI defaults per column', html`<p class="muted u-mt0">Label, format mask, help text, alignment and list of values per table column (APEX: Attribute Dictionary). The page wizards and the Create Application wizard use the label for every generated report heading and form item, and a generated form item also gets the format mask, help and list of values. They apply to pages generated after the change, in every application.</p>${body}`)}`;
101
- return send(reply, s, shell(s, 'UI defaults', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['UI defaults']], main));
100
+ const main = html`<h1 class="u-mb1">${tx('util.c_ui_defaults')}</h1>
101
+ ${region(tx('uid.per_column'), html`<p class="muted u-mt0">${tx('uid.intro')}</p>${body}`)}`;
102
+ return send(reply, s, shell(s, tx('util.c_ui_defaults'), [[tx('rail.app_builder'), BASE], [tx('admin.utilities'), `${BASE}/utilities`], [tx('util.c_ui_defaults')]], main));
102
103
  });
103
104
 
104
105
  app.post(`${BASE}/ui-defaults`, async (req: Req, reply) => {
@@ -123,7 +124,7 @@ export async function uiDefaultRoutes(app: FastifyInstance) {
123
124
  [t.schema, t.table, c.name, short ? text : null, short ? null : text.slice(0, 4000), s.username]);
124
125
  n++;
125
126
  }
126
- flash(s, n ? `UI defaults of ${n} column${n === 1 ? '' : 's'} made from their comments.` : 'No column without a UI default has a comment.');
127
+ flash(s, n ? tx(n === 1 ? 'uid.seeded_one' : 'uid.seeded', { n }) : tx('uid.no_comments'));
127
128
  return back(reply, s, to);
128
129
  }
129
130
  const rows: { name: string; label: string | null; mask: string | null; help: string | null; align: string | null; lov: string | null }[] = [];
@@ -131,9 +132,9 @@ export async function uiDefaultRoutes(app: FastifyInstance) {
131
132
  const name = b[`c.${i}.name`];
132
133
  if (typeof name !== 'string' || !known.has(name)) continue;
133
134
  const mask = trim(b[`c.${i}.format_mask`], 60);
134
- if (mask && !MASK.test(mask)) throw new Error(`Format mask of ${name}: letters, digits and . , : / - only (e.g. 999G990D00 or DD.MM.YYYY).`);
135
+ if (mask && !MASK.test(mask)) throw new Error(tx('uid.bad_mask', { column: name }));
135
136
  const align = trim(b[`c.${i}.alignment`], 10);
136
- if (align && !(ALIGNMENTS as readonly string[]).includes(align)) throw new Error(`Alignment of ${name}: start, center or end.`);
137
+ if (align && !(ALIGNMENTS as readonly string[]).includes(align)) throw new Error(tx('uid.bad_alignment', { column: name }));
137
138
  rows.push({ name, label: trim(b[`c.${i}.label`], 200), mask, help: trim(b[`c.${i}.help`], 4000), align, lov: trim(b[`c.${i}.lov`], 4000) });
138
139
  }
139
140
  await owner.tx(async (c) => {
@@ -152,7 +153,7 @@ export async function uiDefaultRoutes(app: FastifyInstance) {
152
153
  [t.schema, t.table, r.name, r.label, r.mask, r.help, r.align, r.lov, s.username]);
153
154
  }
154
155
  });
155
- flash(s, `UI defaults of ${t.schema}.${t.table} saved.`);
156
+ flash(s, tx('uid.saved', { table: `${t.schema}.${t.table}` }));
156
157
  } catch (e) {
157
158
  flash(s, (e as Error).message, 'error');
158
159
  }
package/src/builder/ui.ts CHANGED
@@ -10,7 +10,7 @@ import { getSession, saveState, takeFlash, type Session } from '../session.ts';
10
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 { clientTexts, tr } from './i18n.ts';
13
+ import { clientTexts, setRequestTranslator, tr } from './i18n.ts';
14
14
  import { languageMenu, loadLanguage } from './language.ts';
15
15
  import { enrolmentDue, requirement } from '../mfa.ts';
16
16
 
@@ -322,6 +322,7 @@ export async function developer(req: Req, reply: FastifyReply) {
322
322
  }
323
323
  // (sprint 121) a session from before the developer's language was kept in it: the stored choice (language.ts)
324
324
  if (s.state.__BLANG === undefined) await loadLanguage(s);
325
+ setRequestTranslator(tr(s)); // (sprint 164) scopedTx in this request's handler and helpers
325
326
  // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
326
327
  delete s.state.__MFA_DUE;
327
328
  const admin = await isAdmin(s.username);
@@ -5,6 +5,7 @@ import { recordHistory } from '../history.ts';
5
5
  import { html, raw } from '../html.ts';
6
6
  import { icon } from '../icons.ts';
7
7
  import { appHeader, back, BASE, csrf, designerUrl, developer, flash, region, send, shell, type Req } from './ui.ts';
8
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
8
9
  import { diffHtml } from './utilities.ts';
9
10
 
10
11
  // (sprint 138, AP-5) App Builder → Utilities → Upgrade application (APEX:
@@ -39,10 +40,10 @@ export interface Candidate {
39
40
  }
40
41
 
41
42
  export type Rule = 'nav_appearance' | 'date_picker' | 'popup_lov';
42
- export const RULES: Record<Rule, { title: string; since: string }> = {
43
- nav_appearance: { title: 'Navigation appearance', since: 'Navigation appearances (Pillar Light, Pillar Dark) came with sprint 117.' },
44
- date_picker: { title: 'Date picker for date columns', since: 'Date and date-time items have had pgkiln\'s date picker since sprint 84.' },
45
- popup_lov: { title: 'Paged list of values for large tables', since: 'Popup lists of values search and page on the server.' },
43
+ export const RULES: Record<Rule, { title: BuilderKey; since: BuilderKey }> = {
44
+ nav_appearance: { title: 'upg.rule_nav_title', since: 'upg.rule_nav_since' },
45
+ date_picker: { title: 'upg.rule_date_title', since: 'upg.rule_date_since' },
46
+ popup_lov: { title: 'upg.rule_popup_title', since: 'upg.rule_popup_since' },
46
47
  };
47
48
 
48
49
  /** The relations a list of values query reads (FROM / JOIN names). */
@@ -57,8 +58,8 @@ export async function upgradeCandidates(appId: number): Promise<Candidate[]> {
57
58
  const theme = app.theme && typeof app.theme === 'object' ? app.theme : {};
58
59
  if (typeof theme.appearance !== 'string' || !theme.appearance) {
59
60
  out.push({
60
- key: 'nav_appearance:app', rule: 'nav_appearance', label: 'Application settings › Theme', page: null, url: `${BASE}/apps/${appId}/theme`,
61
- reason: 'The application has no navigation appearance: Pillar Light gives the header strip and the filled current page of new applications.',
61
+ key: 'nav_appearance:app', rule: 'nav_appearance', label: tx('upg.label_theme'), page: null, url: `${BASE}/apps/${appId}/theme`,
62
+ reason: tx('upg.reason_nav'),
62
63
  before: { appearance: theme.appearance ?? null }, after: { appearance: 'pillar_light' },
63
64
  sql: [`update meta.app set theme = coalesce(theme, '{}'::jsonb) || '{"appearance": "pillar_light"}' where id = $1 and coalesce(theme ->> 'appearance', '') = ''`, [appId]],
64
65
  });
@@ -74,8 +75,8 @@ export async function upgradeCandidates(appId: number): Promise<Candidate[]> {
74
75
  for (const r of dates) {
75
76
  const to = r.is_date ? 'date' : 'datetime';
76
77
  out.push({
77
- key: `date_picker:${r.id}`, rule: 'date_picker', label: `Page ${r.page_no} › item ${r.name}`, page: r.page_no, url: designerUrl(appId, r.page_no, `c=item-${r.id}`),
78
- reason: `${r.table_name}.${r.source_column} is a ${r.coltype} column shown as a text field: a ${to} item has the date picker and reads the date in the application's format.`,
78
+ key: `date_picker:${r.id}`, rule: 'date_picker', label: tx('upg.label_item', { page: r.page_no, name: r.name }), page: r.page_no, url: designerUrl(appId, r.page_no, `c=item-${r.id}`),
79
+ reason: tx(r.is_date ? 'upg.reason_date' : 'upg.reason_datetime', { column: `${r.table_name}.${r.source_column}`, type: r.coltype }),
79
80
  before: { type: 'text' }, after: { type: to },
80
81
  sql: [`update meta.item set type = $2 where id = $1 and type = 'text'`, [r.id, to]],
81
82
  });
@@ -92,8 +93,8 @@ export async function upgradeCandidates(appId: number): Promise<Candidate[]> {
92
93
  [names, POPUP_ROWS]).catch(() => ({ rows: [] }))).rows[0];
93
94
  if (!big) continue;
94
95
  out.push({
95
- key: `popup_lov:${r.id}`, rule: 'popup_lov', label: `Page ${r.page_no} › item ${r.name}`, page: r.page_no, url: designerUrl(appId, r.page_no, `c=item-${r.id}`),
96
- reason: `Its list of values reads ${big.name} (about ${big.n} rows): a select list sends every row with the page; a popup list of values searches and pages on the server.`,
96
+ key: `popup_lov:${r.id}`, rule: 'popup_lov', label: tx('upg.label_item', { page: r.page_no, name: r.name }), page: r.page_no, url: designerUrl(appId, r.page_no, `c=item-${r.id}`),
97
+ reason: tx('upg.reason_popup', { table: big.name, n: big.n }),
97
98
  before: { type: 'select' }, after: { type: 'popup_lov' },
98
99
  sql: [`update meta.item set type = 'popup_lov' where id = $1 and type = 'select'`, [r.id]],
99
100
  });
@@ -114,20 +115,20 @@ export async function upgradeRoutes(app: FastifyInstance) {
114
115
  const u = `${BASE}/apps/${a.id}/utilities/upgrade`;
115
116
  const sections = (Object.keys(RULES) as Rule[]).map((rule) => {
116
117
  const list = all.filter((c) => c.rule === rule);
117
- return html`<section class="util-upgrade" aria-labelledby="up-${rule}"><h3 id="up-${rule}">${RULES[rule].title} <span class="tag">${list.length}</span></h3>
118
- <p class="muted small">${RULES[rule].since}</p>
119
- ${list.length ? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Component</th><th scope="col">Why</th><th scope="col">Actions</th></tr></thead><tbody>
120
- ${list.map((c) => html`<tr${preview?.key === c.key ? raw(' aria-current="true"') : ''}><td data-label="Component"><a href="${c.url}">${c.label}</a></td><td data-label="Why">${c.reason}</td>
121
- <td data-label="Actions"><div class="buttons"><a class="btn btn-sm" href="${u}?preview=${encodeURIComponent(c.key)}#preview">Preview</a>
122
- <form method="post" action="${u}">${csrf(s)}<input type="hidden" name="key" value="${c.key}"><button class="btn btn-sm btn-hot">${icon('refresh')} Upgrade</button></form></div></td></tr>`)}</tbody></table></div>
123
- ${list.length > 1 ? html`<form method="post" action="${u}" class="u-mt075">${csrf(s)}<input type="hidden" name="rule" value="${rule}"><button class="btn btn-sm">${icon('refresh')} Upgrade all ${list.length}</button></form>` : ''}`
124
- : html`<p class="muted">Nothing to upgrade.</p>`}</section>`;
118
+ return html`<section class="util-upgrade" aria-labelledby="up-${rule}"><h3 id="up-${rule}">${tx(RULES[rule].title)} <span class="tag">${list.length}</span></h3>
119
+ <p class="muted small">${tx(RULES[rule].since)}</p>
120
+ ${list.length ? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">${tx('upg.col_component')}</th><th scope="col">${tx('upg.col_why')}</th><th scope="col">${tx('upg.col_actions')}</th></tr></thead><tbody>
121
+ ${list.map((c) => html`<tr${preview?.key === c.key ? raw(' aria-current="true"') : ''}><td data-label="${tx('upg.col_component')}"><a href="${c.url}">${c.label}</a></td><td data-label="${tx('upg.col_why')}">${c.reason}</td>
122
+ <td data-label="${tx('upg.col_actions')}"><div class="buttons"><a class="btn btn-sm" href="${u}?preview=${encodeURIComponent(c.key)}#preview">${tx('upg.preview')}</a>
123
+ <form method="post" action="${u}">${csrf(s)}<input type="hidden" name="key" value="${c.key}"><button class="btn btn-sm btn-hot">${icon('refresh')} ${tx('upg.upgrade')}</button></form></div></td></tr>`)}</tbody></table></div>
124
+ ${list.length > 1 ? html`<form method="post" action="${u}" class="u-mt075">${csrf(s)}<input type="hidden" name="rule" value="${rule}"><button class="btn btn-sm">${icon('refresh')} ${tx('upg.upgrade_all', { n: list.length })}</button></form>` : ''}`
125
+ : html`<p class="muted">${tx('upg.nothing')}</p>`}</section>`;
125
126
  });
126
127
  const main = html`${appHeader(a, 'utilities', s)}
127
- ${region('Upgrade application', html`<p class="muted u-mt0">Components made before a pgkiln version that brought a better default. Preview a change, then upgrade one component or all of a kind; every upgrade is in the change history (and the backups) if you want to go back.</p>${sections}`)}
128
- ${preview ? region(`Preview: ${preview.label}`, html`<p class="muted u-mt0">${preview.reason}</p>${diffHtml('settings', stableJson(preview.before), stableJson(preview.after), ['now', 'upgraded'])}
129
- <form method="post" action="${u}">${csrf(s)}<input type="hidden" name="key" value="${preview.key}"><div class="buttons"><button class="btn btn-hot">${icon('refresh')} Upgrade</button></div></form>`, '', 'preview') : ''}`;
130
- return send(reply, s, shell(s, `Upgrade application · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Upgrade application']], main));
128
+ ${region(tx('util.c_upgrade'), html`<p class="muted u-mt0">${tx('upg.intro')}</p>${sections}`)}
129
+ ${preview ? region(tx('upg.preview_title', { label: preview.label }), html`<p class="muted u-mt0">${preview.reason}</p>${diffHtml('settings', stableJson(preview.before), stableJson(preview.after), ['now', 'upgraded'])}
130
+ <form method="post" action="${u}">${csrf(s)}<input type="hidden" name="key" value="${preview.key}"><div class="buttons"><button class="btn btn-hot">${icon('refresh')} ${tx('upg.upgrade')}</button></div></form>`, '', 'preview') : ''}`;
131
+ return send(reply, s, shell(s, tx('upg.title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.utilities'), `${BASE}/apps/${a.id}/utilities`], [tx('util.c_upgrade')]], main));
131
132
  });
132
133
 
133
134
  app.post(`${BASE}/apps/:id/utilities/upgrade`, async (req: Req, reply) => {
@@ -141,14 +142,14 @@ export async function upgradeRoutes(app: FastifyInstance) {
141
142
  const chosen = key ? all.filter((c) => c.key === key) : all.filter((c) => c.rule === rule);
142
143
  const to = `${BASE}/apps/${a.id}/utilities/upgrade`;
143
144
  if (!chosen.length) {
144
- flash(s, 'Nothing to upgrade: the component was changed or upgraded in the meantime.', 'error');
145
+ flash(s, tx('upg.gone'), 'error');
145
146
  return back(reply, s, to);
146
147
  }
147
148
  await owner.tx(async (c) => {
148
149
  for (const x of chosen) await c.query(x.sql[0], x.sql[1]);
149
150
  });
150
151
  await recordHistory(a.id, s.username, 'upgrade').catch((err) => req.log.warn({ err }, 'change history not recorded'));
151
- flash(s, chosen.length === 1 ? `${chosen[0].label} upgraded.` : `${chosen.length} components upgraded.`);
152
+ flash(s, chosen.length === 1 ? tx('upg.done_one', { label: chosen[0].label }) : tx('upg.done_n', { n: chosen.length }));
152
153
  return back(reply, s, to);
153
154
  });
154
155
  }