pgkiln 0.82.0 → 0.83.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 (61) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/docs/apex-feature-parity.md +3 -3
  3. package/docs/guide/03-builder.md +8 -1
  4. package/docs/guide/04-pages-and-regions.md +2 -1
  5. package/docs/guide/07-dynamic-actions.md +27 -0
  6. package/docs/guide/11-from-apex.md +2 -1
  7. package/docs/guide/12-development.md +9 -1
  8. package/package.json +1 -1
  9. package/public/app.css +2 -0
  10. package/public/app.js +85 -4
  11. package/public/builder.css +25 -2
  12. package/public/data-modeler.js +8 -5
  13. package/public/sql-monitor.js +5 -2
  14. package/src/builder/ai-builder.ts +50 -46
  15. package/src/builder/api.ts +49 -49
  16. package/src/builder/autorest.ts +92 -87
  17. package/src/builder/changelog.ts +39 -37
  18. package/src/builder/components.ts +5 -4
  19. package/src/builder/data-modeler.ts +89 -84
  20. package/src/builder/data-security.ts +39 -37
  21. package/src/builder/dataload.ts +69 -65
  22. package/src/builder/datapump.ts +108 -97
  23. package/src/builder/ddl-tools.ts +38 -34
  24. package/src/builder/document-view.ts +11 -11
  25. package/src/builder/i18n/de.ts +1627 -0
  26. package/src/builder/i18n/en.ts +1627 -0
  27. package/src/builder/i18n/es.ts +1627 -0
  28. package/src/builder/i18n/fr.ts +1627 -0
  29. package/src/builder/i18n/it.ts +1627 -0
  30. package/src/builder/i18n/ja.ts +1627 -0
  31. package/src/builder/i18n/ko.ts +1627 -0
  32. package/src/builder/i18n/nl.ts +1627 -0
  33. package/src/builder/i18n/pt.ts +1627 -0
  34. package/src/builder/i18n/zh-cn.ts +1627 -0
  35. package/src/builder/i18n/zh-tw.ts +1627 -0
  36. package/src/builder/i18n.ts +18 -0
  37. package/src/builder/object-actions.ts +18 -16
  38. package/src/builder/object-browser.ts +2 -2
  39. package/src/builder/querybuilder.ts +42 -36
  40. package/src/builder/quicksql.ts +16 -18
  41. package/src/builder/region-settings.ts +201 -199
  42. package/src/builder/report-settings.ts +64 -60
  43. package/src/builder/rest-modules.ts +145 -143
  44. package/src/builder/sampledata.ts +59 -52
  45. package/src/builder/scheduling.ts +180 -159
  46. package/src/builder/scripts.ts +71 -65
  47. package/src/builder/sql-charts.ts +36 -30
  48. package/src/builder/sql-monitor.ts +95 -89
  49. package/src/builder/sql-utilities.ts +47 -40
  50. package/src/builder/top-sql.ts +21 -17
  51. package/src/builder/unload.ts +48 -44
  52. package/src/builder/worksheet.ts +3 -3
  53. package/src/cron-schedule.ts +47 -17
  54. package/src/metadata.ts +3 -1
  55. package/src/runtime/calendar.ts +3 -2
  56. package/src/runtime/context.ts +2 -0
  57. package/src/runtime/grid.ts +1 -0
  58. package/src/runtime/maps.ts +7 -4
  59. package/src/runtime/render.ts +6 -1
  60. package/src/runtime/routes.ts +4 -0
  61. package/src/runtime/tree.ts +4 -3
@@ -1,7 +1,7 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { apiRoleProblem, issueApiToken } from '../api.ts';
3
3
  import { owner } from '../db.ts';
4
- import { html, raw, type Raw } from '../html.ts';
4
+ import { esc, html, raw, type Raw } from '../html.ts';
5
5
  import { icon } from '../icons.ts';
6
6
  import { handlerProblems, METHODS, PARAM_TYPES, templateProblems, type Handler, type HandlerParam, type Template } from '../runtime/rest.ts';
7
7
  import { clientIp, logActivity, type Session } from '../session.ts';
@@ -10,6 +10,7 @@ import { lockText } from './locks.ts';
10
10
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
11
11
  import { splitRoles } from './users.ts';
12
12
  import { currentWorkspace } from './workspaces.ts';
13
+ import { scopedTx as tx, tr, translatorFor, withTranslator, type BuilderTranslate } from './i18n.ts';
13
14
 
14
15
  // (sprint 104, Database Actions DA-11 part 2; migration 117) SQL Workshop › RESTful Services › Modules: an
15
16
  // application's REST modules (meta.rest_module, served by runtime/rest.ts) edited the ORDS way, as a tree of
@@ -22,13 +23,8 @@ import { currentWorkspace } from './workspaces.ts';
22
23
 
23
24
  export const MODULES = `${BASE}/sql/rest/modules`;
24
25
  const NAME = /^[a-z][a-z0-9_-]{0,39}$/;
25
- const TYPE_LABELS: Record<string, string> = {
26
- collection: 'Collection query: rows, paged',
27
- item: 'Single row: an object, 404 without a row',
28
- sql: 'SQL: the first row of the last statement',
29
- plpgsql: 'PL/pgSQL block: meta.rest_reply(body, status)',
30
- media: 'Media resource: content_type and content',
31
- };
26
+ const TYPES = ['collection', 'item', 'sql', 'plpgsql', 'media'] as const;
27
+ const typeLabel = (k: string) => ((TYPES as readonly string[]).includes(k) ? tx(`rm.type.${k as (typeof TYPES)[number]}`) : k);
32
28
  const MAX_TRY_BODY = 20_000;
33
29
 
34
30
  interface ModRow {
@@ -48,9 +44,9 @@ interface ModRow {
48
44
 
49
45
  /** RESTful Services' two parts as tabs: Modules and AutoREST. */
50
46
  export const restNav = (active: 'modules' | 'autorest') =>
51
- html`<nav class="settings-tabs" aria-label="RESTful Services">
52
- <a href="${MODULES}"${active === 'modules' ? raw(' aria-current="page"') : ''}>Modules</a>
53
- <a href="${BASE}/sql/rest"${active === 'autorest' ? raw(' aria-current="page"') : ''}>AutoREST (tables and views)</a></nav>`;
47
+ html`<nav class="settings-tabs" aria-label="${tx('sql.tool.rest')}">
48
+ <a href="${MODULES}"${active === 'modules' ? raw(' aria-current="page"') : ''}>${tx('rm.modules')}</a>
49
+ <a href="${BASE}/sql/rest"${active === 'autorest' ? raw(' aria-current="page"') : ''}>${tx('rm.autorest_tab')}</a></nav>`;
54
50
 
55
51
  /** The modules of the current workspace's applications (one, with id). */
56
52
  async function modules(s: Session, id?: number): Promise<ModRow[]> {
@@ -68,9 +64,9 @@ async function workspaceApps(s: Session) {
68
64
  /** Why the application can't be changed now (Run only, or locked by another developer), or null. */
69
65
  async function changeRefused(s: Session, appId: number): Promise<string | null> {
70
66
  const a = await owner.one<{ run_only: boolean }>(`select build_status = 'run_only' as run_only from meta.app where id = $1`, [appId]);
71
- if (a?.run_only) return `${lockText({ page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true })} Set it back to Run and build to change it.`;
67
+ if (a?.run_only) return `${lockText({ page_no: 0, locked_by: '', locked_at: '', note: null, run_only: true })} ${tr(s)('rm.run_only_suffix')}`;
72
68
  const lock = await owner.one(`select page_no, locked_by, locked_at::text, note from meta.builder_lock where app_id = $1 and page_no = 0 and locked_by <> $2`, [appId, s.username]);
73
- return lock ? `${lockText(lock)} Changes are refused until it is unlocked.` : null;
69
+ return lock ? `${lockText(lock)} ${tr(s)('rm.locked_suffix')}` : null;
74
70
  }
75
71
 
76
72
  /** The module's templates: the declared ones and every handler's path, sorted. */
@@ -86,13 +82,14 @@ const q = (v: Record<string, string>) => new URLSearchParams(v).toString();
86
82
  const tplUrl = (m: ModRow, uri: string) => `${MODULES}/${m.id}/template?${q({ t: uri })}`;
87
83
  const handlerUrl = (m: ModRow, h: Pick<Handler, 'path' | 'method'>) => `${MODULES}/${m.id}/handler?${q({ t: h.path, m: h.method })}`;
88
84
  const baseUrl = (m: Pick<ModRow, 'app_alias' | 'name'>) => `${publicUrl()}/a/${m.app_alias}/rest/${m.name}/`;
85
+ const anyOf = (roles: string[]) => roles.join(` ${tx('rm.or')} `);
89
86
  const access = (m: ModRow, h: Handler) =>
90
- [h.auth === 'public' ? 'public' : 'token', m.roles.length ? `module role ${m.roles.join(' or ')}` : '', h.roles?.length ? `role ${h.roles.join(' or ')}` : ''].filter(Boolean).join(', ');
87
+ [h.auth === 'public' ? tx('rm.public') : tx('rm.token'), m.roles.length ? tx('rm.module_role', { roles: anyOf(m.roles) }) : '', h.roles?.length ? tx('rm.role', { roles: anyOf(h.roles) }) : ''].filter(Boolean).join(', ');
91
88
 
92
89
  /** The module's tree: module › templates › handlers. */
93
90
  function tree(m: ModRow, sel: { t?: string; m?: string } = {}) {
94
91
  const here = (on: boolean) => (on ? raw(' aria-current="page"') : '');
95
- return html`<nav aria-label="Templates and handlers" class="rm-tree"><ul class="tree">
92
+ return html`<nav aria-label="${tx('rm.tree_aria')}" class="rm-tree"><ul class="tree">
96
93
  <li><a href="${MODULES}/${m.id}"${here(sel.t === undefined)}>${icon('folder')}<span>${m.name}/</span></a>
97
94
  <ul>${templatesOf(m).map((t) => html`<li><a href="${tplUrl(m, t.uri)}"${here(sel.t === t.uri && !sel.m)}>${icon('chevron')}<span>${t.uri || '/'}</span></a>
98
95
  <ul>${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<li><a href="${handlerUrl(m, h)}"${here(sel.t === t.uri && sel.m === h.method)}><code>${h.method}</code><span>${h.type}</span></a></li>`)}</ul></li>`)}
@@ -101,45 +98,46 @@ function tree(m: ModRow, sel: { t?: string; m?: string } = {}) {
101
98
 
102
99
  /** The page frame: tabs, the module's tree beside the content. */
103
100
  function frame(s: Session, m: ModRow, title: string, crumbs: [string, string?][], sel: { t?: string; m?: string }, body: Raw) {
104
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest', s)}${restNav('modules')}
105
- <h2 class="u-mb1 wrap-anywhere">${m.title} <span class="muted small">${m.app_name} · /a/${m.app_alias}/rest/${m.name}/${m.enabled ? '' : ' · disabled'}</span></h2>
106
- <div class="rm-layout">${region('Module', tree(m, sel))}<div class="rm-main">${body}</div></div>`;
107
- return shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', MODULES], ...crumbs], main, 'sql');
101
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('rest', s)}${restNav('modules')}
102
+ <h2 class="u-mb1 wrap-anywhere">${m.title} <span class="muted small">${m.app_name} · /a/${m.app_alias}/rest/${m.name}/${m.enabled ? '' : ` · ${tx('rm.disabled_suffix')}`}</span></h2>
103
+ <div class="rm-layout">${region(tx('rm.module'), tree(m, sel))}<div class="rm-main">${body}</div></div>`;
104
+ return shell(s, title, [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.rest'), MODULES], ...crumbs], main, 'sql');
108
105
  }
109
106
 
110
107
  // ------------------------------------------------------------------ pages
111
108
 
112
- async function listPage(s: Session, b: Record<string, unknown> = {}, problems: string[] = []) {
109
+ const listPage = (s: Session, b: Record<string, unknown> = {}, problems: string[] = []) => withTranslator(tr(s), () => listPageInner(s, b, problems));
110
+ async function listPageInner(s: Session, b: Record<string, unknown>, problems: string[]) {
113
111
  const [list, apps] = await Promise.all([modules(s), workspaceApps(s)]);
114
112
  const table = list.length
115
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Module</th><th>Base path</th><th>Application</th><th>Templates</th><th>Handlers</th><th>Privilege</th></tr></thead><tbody>
113
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.module')}</th><th>${tx('rm.base_path')}</th><th>${tx('rm.application')}</th><th>${tx('rm.templates')}</th><th>${tx('rm.handlers')}</th><th>${tx('rm.privilege')}</th></tr></thead><tbody>
116
114
  ${list.map((m) => html`<tr${m.enabled ? '' : raw(' class="muted"')}>
117
- <td data-label="Module"><a href="${MODULES}/${m.id}">${m.title}</a>${m.enabled ? '' : html` <span class="tag">off</span>`}</td>
118
- <td data-label="Base path" class="wrap-anywhere"><code>/a/${m.app_alias}/rest/${m.name}/</code></td>
119
- <td data-label="Application">${m.app_name}</td>
120
- <td data-label="Templates">${templatesOf(m).length}</td>
121
- <td data-label="Handlers">${Array.isArray(m.handlers) ? m.handlers.length : 0}</td>
122
- <td data-label="Privilege">${m.roles.length ? `role ${m.roles.join(' or ')}` : 'any token'}</td></tr>`)}
115
+ <td data-label="${tx('rm.module')}"><a href="${MODULES}/${m.id}">${m.title}</a>${m.enabled ? '' : html` <span class="tag">${tx('rm.off')}</span>`}</td>
116
+ <td data-label="${tx('rm.base_path')}" class="wrap-anywhere"><code>/a/${m.app_alias}/rest/${m.name}/</code></td>
117
+ <td data-label="${tx('rm.application')}">${m.app_name}</td>
118
+ <td data-label="${tx('rm.templates')}">${templatesOf(m).length}</td>
119
+ <td data-label="${tx('rm.handlers')}">${Array.isArray(m.handlers) ? m.handlers.length : 0}</td>
120
+ <td data-label="${tx('rm.privilege')}">${m.roles.length ? tx('rm.role', { roles: anyOf(m.roles) }) : tx('rm.any_token')}</td></tr>`)}
123
121
  </tbody></table></div>`
124
- : html`<p class="muted">No REST modules yet. Create one below: a base path, then templates and handlers.</p>`;
122
+ : html`<p class="muted">${tx('rm.none_yet')}</p>`;
125
123
  const v = (k: string) => String(b[k] ?? '');
126
124
  const form = html`<form method="post" action="${MODULES}" id="create">${csrf(s)}${errorList(problems)}
127
125
  <div class="form-grid">
128
- <div class="field"><label class="label" for="f_app">Application</label>
126
+ <div class="field"><label class="label" for="f_app">${tx('rm.application')}</label>
129
127
  <select id="f_app" name="app_id" required>${apps.map((a) => html`<option value="${a.id}"${v('app_id') === String(a.id) ? raw(' selected') : ''}>${a.name} (${a.alias})</option>`)}</select>
130
- <small class="help">Its database role runs the handlers; its tokens and roles decide who may call them.</small></div>
131
- <div class="field"><label class="label" for="f_name">Base path</label><input id="f_name" name="name" required value="${v('name')}" placeholder="e.g. sales">
132
- <small class="help">/a/&lt;app&gt;/rest/<b>name</b>/ — lower case letters, digits, _ and - (at most 40)</small></div>
133
- <div class="field"><label class="label" for="f_title">Title</label><input id="f_title" name="title" required value="${v('title')}" placeholder="e.g. Sales API"></div>
134
- <div class="field"><label class="label" for="f_roles">Privilege: roles</label><input id="f_roles" name="roles" value="${v('roles')}" placeholder="any caller with a token">
135
- <small class="help">Comma separated: every handler needs one of these application roles.</small></div>
128
+ <small class="help">${tx('rm.app_help')}</small></div>
129
+ <div class="field"><label class="label" for="f_name">${tx('rm.base_path')}</label><input id="f_name" name="name" required value="${v('name')}" placeholder="${tx('rm.eg', { example: 'sales' })}">
130
+ <small class="help">/a/&lt;app&gt;/rest/<b>name</b>/ — ${tx('rm.base_path_rule')}</small></div>
131
+ <div class="field"><label class="label" for="f_title">${tx('rm.title')}</label><input id="f_title" name="title" required value="${v('title')}" placeholder="${tx('rm.eg_sales_api')}"></div>
132
+ <div class="field"><label class="label" for="f_roles">${tx('rm.privilege_roles')}</label><input id="f_roles" name="roles" value="${v('roles')}" placeholder="${tx('rm.any_caller')}">
133
+ <small class="help">${tx('rm.roles_help_app')}</small></div>
136
134
  </div>
137
- <div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v('description')}"></div>
138
- <div class="buttons"><button class="btn btn-hot"${apps.length ? '' : raw(' disabled')}>${icon('plus')} Create module</button></div></form>`;
139
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest', s)}${restNav('modules')}
140
- <p class="muted">RESTful Services modules: a module is a base path of an application, its templates are URIs with <code>:parameters</code>, and each template has handlers per method with their source: a collection query, a single row, SQL, a PL/pgSQL block or a media resource. Handlers run as the application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply; parameters are bound, never pasted into the SQL. Every module has an OpenAPI document. Modules belong to their application: they travel with its export (also in Shared Components › RESTful Services).</p>
141
- ${region('Modules', table)}${region('Create a module', form)}`;
142
- return shell(s, 'RESTful Services: Modules', [['SQL Workshop', `${BASE}/sql`], ['RESTful Services', `${BASE}/sql/rest`], ['Modules']], main, 'sql');
135
+ <div class="field"><label class="label" for="f_description">${tx('rm.description')}</label><input id="f_description" name="description" value="${v('description')}"></div>
136
+ <div class="buttons"><button class="btn btn-hot"${apps.length ? '' : raw(' disabled')}>${icon('plus')} ${tx('rm.create_module')}</button></div></form>`;
137
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('rest', s)}${restNav('modules')}
138
+ <p class="muted">${tx.code('rm.intro')}</p>
139
+ ${region(tx('rm.modules'), table)}${region(tx('rm.create_a_module'), form)}`;
140
+ return shell(s, tx('rm.list_title'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.rest'), `${BASE}/sql/rest`], [tx('rm.modules')]], main, 'sql');
143
141
  }
144
142
 
145
143
  /** Checks of the role the handlers run as. */
@@ -147,74 +145,76 @@ async function roleChecks(m: ModRow) {
147
145
  const problem = m.db_role ? await apiRoleProblem(m.db_role) : null;
148
146
  return html`<ul class="checklist">
149
147
  <li>${problem ? '✗' : m.db_role ? '✓' : 'ℹ'} ${problem
150
- ? html`The application's database role: ${problem} Its handlers answer 403.`
151
- : m.db_role ? html`Handlers run as <code>${m.db_role}</code> with <code>meta.app_user()</code> the caller` : html`The application has no database role: handlers run as pgkiln's runtime role. Give it its own role (Application › Settings) so grants and row level security are its own.`}</li>
152
- <li>${m.enabled ? '✓' : '✗'} ${m.enabled ? 'Enabled' : 'Disabled: every handler answers 404'}</li>
153
- <li>ℹ ${m.roles.length ? `Privilege: callers need the role ${m.roles.join(' or ')}` : 'No privilege roles: any valid token of the application (handlers may add roles)'}</li>
148
+ ? tx('rm.chk.role_problem', { problem })
149
+ : m.db_role ? tx.code('rm.chk.runs_as', { role: m.db_role }) : tx('rm.chk.no_role')}</li>
150
+ <li>${m.enabled ? '✓' : '✗'} ${m.enabled ? tx('rm.enabled') : tx('rm.chk.disabled')}</li>
151
+ <li>ℹ ${m.roles.length ? tx('rm.chk.privilege', { roles: anyOf(m.roles) }) : tx('rm.chk.no_privilege')}</li>
154
152
  </ul>`;
155
153
  }
156
154
 
157
- async function modulePage(s: Session, m: ModRow, problems: string[] = [], b?: Record<string, unknown>) {
155
+ const modulePage = (s: Session, m: ModRow, problems: string[] = [], b?: Record<string, unknown>) => withTranslator(tr(s), () => modulePageInner(s, m, problems, b));
156
+ async function modulePageInner(s: Session, m: ModRow, problems: string[], b?: Record<string, unknown>) {
158
157
  const v = (k: keyof ModRow & string, d: unknown) => String(b?.[k] ?? d ?? '');
159
158
  const openapi = `${baseUrl(m)}openapi.json`;
160
159
  const settings = html`<form method="post" action="${MODULES}/${m.id}">${csrf(s)}${errorList(problems)}
161
160
  <div class="form-grid">
162
- <div class="field"><label class="label" for="f_name">Base path</label><input id="f_name" name="name" required value="${v('name', m.name)}"><small class="help">/a/${m.app_alias}/rest/<b>name</b>/</small></div>
163
- <div class="field"><label class="label" for="f_title">Title</label><input id="f_title" name="title" required value="${v('title', m.title)}"></div>
164
- <div class="field"><label class="label" for="f_roles">Privilege: roles</label><input id="f_roles" name="roles" value="${b ? String(b.roles ?? '') : m.roles.join(', ')}" placeholder="any caller with a token"><small class="help">Comma separated: every handler needs one of these roles.</small></div>
165
- <div class="field"><label class="label" for="f_enabled">Status</label><select id="f_enabled" name="enabled"><option value="1"${m.enabled ? raw(' selected') : ''}>Enabled</option><option value="0"${m.enabled ? '' : raw(' selected')}>Disabled (404)</option></select></div>
161
+ <div class="field"><label class="label" for="f_name">${tx('rm.base_path')}</label><input id="f_name" name="name" required value="${v('name', m.name)}"><small class="help">/a/${m.app_alias}/rest/<b>name</b>/</small></div>
162
+ <div class="field"><label class="label" for="f_title">${tx('rm.title')}</label><input id="f_title" name="title" required value="${v('title', m.title)}"></div>
163
+ <div class="field"><label class="label" for="f_roles">${tx('rm.privilege_roles')}</label><input id="f_roles" name="roles" value="${b ? String(b.roles ?? '') : m.roles.join(', ')}" placeholder="${tx('rm.any_caller')}"><small class="help">${tx('rm.roles_help')}</small></div>
164
+ <div class="field"><label class="label" for="f_enabled">${tx('rm.status')}</label><select id="f_enabled" name="enabled"><option value="1"${m.enabled ? raw(' selected') : ''}>${tx('rm.enabled')}</option><option value="0"${m.enabled ? '' : raw(' selected')}>${tx('rm.disabled_404')}</option></select></div>
166
165
  </div>
167
- <div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v('description', m.description)}"></div>
168
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`;
166
+ <div class="field"><label class="label" for="f_description">${tx('rm.description')}</label><input id="f_description" name="description" value="${v('description', m.description)}"></div>
167
+ <div class="buttons"><button class="btn btn-hot">${tx('rm.save')}</button></div></form>`;
169
168
  const templates = templatesOf(m);
170
169
  const tplTable = templates.length
171
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Template</th><th>Handlers</th><th>Description</th></tr></thead><tbody>
172
- ${templates.map((t) => html`<tr><td data-label="Template"><a href="${tplUrl(m, t.uri)}"><code>${t.uri || '/'}</code></a></td>
173
- <td data-label="Handlers">${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<a href="${handlerUrl(m, h)}"><code>${h.method}</code></a> `)}</td>
174
- <td data-label="Description">${t.description ?? ''}</td></tr>`)}</tbody></table></div>`
175
- : html`<p class="muted">No templates yet.</p>`;
170
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.template')}</th><th>${tx('rm.handlers')}</th><th>${tx('rm.description')}</th></tr></thead><tbody>
171
+ ${templates.map((t) => html`<tr><td data-label="${tx('rm.template')}"><a href="${tplUrl(m, t.uri)}"><code>${t.uri || '/'}</code></a></td>
172
+ <td data-label="${tx('rm.handlers')}">${m.handlers.filter((h) => h.path === t.uri).map((h) => html`<a href="${handlerUrl(m, h)}"><code>${h.method}</code></a> `)}</td>
173
+ <td data-label="${tx('rm.description')}">${t.description ?? ''}</td></tr>`)}</tbody></table></div>`
174
+ : html`<p class="muted">${tx('rm.no_templates')}</p>`;
176
175
  const addTpl = html`<form method="post" action="${MODULES}/${m.id}/template" class="u-mt1">${csrf(s)}
177
176
  <div class="form-grid">
178
- <div class="field"><label class="label" for="f_uri">URI template</label><input id="f_uri" name="uri" required placeholder="e.g. orders/:id"><small class="help">Segments and <code>:parameters</code> (bound as <code>:ID</code>)</small></div>
179
- <div class="field"><label class="label" for="f_tdesc">Description</label><input id="f_tdesc" name="description"></div>
180
- </div><div class="buttons"><button class="btn">${icon('plus')} Add template</button></div></form>`;
177
+ <div class="field"><label class="label" for="f_uri">${tx('rm.uri_template')}</label><input id="f_uri" name="uri" required placeholder="${tx('rm.eg', { example: 'orders/:id' })}"><small class="help">${tx.code('rm.uri_help')}</small></div>
178
+ <div class="field"><label class="label" for="f_tdesc">${tx('rm.description')}</label><input id="f_tdesc" name="description"></div>
179
+ </div><div class="buttons"><button class="btn">${icon('plus')} ${tx('rm.add_template')}</button></div></form>`;
181
180
  const body = html`
182
- ${region('Checks', await roleChecks(m))}
183
- ${region('Templates', html`${tplTable}${addTpl}`)}
184
- ${region('OpenAPI', html`<p class="wrap-anywhere u-mt0">The module's OpenAPI 3 document (without the SQL): <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a></p>
185
- <p class="muted small">Import it into Swagger UI, Postman or a client generator. Handlers' documented parameters become its query parameters and request bodies.</p>`)}
186
- ${region('Module', html`${settings}
187
- <form method="post" action="${MODULES}/${m.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the module ${m.name} with all its templates and handlers?">${icon('trash')} Delete module</button></form>`)}`;
188
- return frame(s, m, `REST module ${m.name}`, [[m.name]], {}, body);
181
+ ${region(tx('rm.checks'), await roleChecks(m))}
182
+ ${region(tx('rm.templates'), html`${tplTable}${addTpl}`)}
183
+ ${region('OpenAPI', html`<p class="wrap-anywhere u-mt0">${tx('rm.openapi_doc')} <a href="${openapi}" target="_blank" rel="noopener"><code>${openapi}</code></a></p>
184
+ <p class="muted small">${tx('rm.openapi_help')}</p>`)}
185
+ ${region(tx('rm.module'), html`${settings}
186
+ <form method="post" action="${MODULES}/${m.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('rm.delete_module_confirm', { name: m.name })}">${icon('trash')} ${tx('rm.delete_module')}</button></form>`)}`;
187
+ return frame(s, m, tx('rm.module_title', { name: m.name }), [[m.name]], {}, body);
189
188
  }
190
189
 
191
- async function templatePage(s: Session, m: ModRow, uri: string, problems: string[] = []) {
190
+ const templatePage = (s: Session, m: ModRow, uri: string, problems: string[] = []) => withTranslator(tr(s), () => templatePageInner(s, m, uri, problems));
191
+ async function templatePageInner(s: Session, m: ModRow, uri: string, problems: string[]) {
192
192
  const t = templatesOf(m).find((x) => x.uri === uri)!;
193
193
  const handlers = m.handlers.filter((h) => h.path === uri);
194
194
  const used = new Set<string>(handlers.map((h) => h.method));
195
195
  const free = METHODS.filter((x) => !used.has(x));
196
196
  const body = html`${errorList(problems)}
197
- ${region('Handlers', html`${handlers.length
198
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Method</th><th>Source type</th><th>Access</th><th>Description</th></tr></thead><tbody>
199
- ${handlers.map((h) => html`<tr><td data-label="Method"><a href="${handlerUrl(m, h)}"><code>${h.method}</code></a></td><td data-label="Source type">${TYPE_LABELS[h.type] ?? h.type}</td>
200
- <td data-label="Access">${access(m, h)}</td><td data-label="Description">${h.description ?? ''}</td></tr>`)}</tbody></table></div>`
201
- : html`<p class="muted">No handlers yet: the URI answers 404.</p>`}
197
+ ${region(tx('rm.handlers'), html`${handlers.length
198
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rm.method')}</th><th>${tx('rm.source_type')}</th><th>${tx('rm.access')}</th><th>${tx('rm.description')}</th></tr></thead><tbody>
199
+ ${handlers.map((h) => html`<tr><td data-label="${tx('rm.method')}"><a href="${handlerUrl(m, h)}"><code>${h.method}</code></a></td><td data-label="${tx('rm.source_type')}">${typeLabel(h.type)}</td>
200
+ <td data-label="${tx('rm.access')}">${access(m, h)}</td><td data-label="${tx('rm.description')}">${h.description ?? ''}</td></tr>`)}</tbody></table></div>`
201
+ : html`<p class="muted">${tx('rm.no_handlers')}</p>`}
202
202
  ${free.length ? html`<form method="get" action="${MODULES}/${m.id}/handler" class="search u-mwnone u-mt1"><input type="hidden" name="t" value="${uri}">
203
- <div class="field"><label class="label" for="f_new">Method</label><select id="f_new" name="new">${free.map((x) => html`<option>${x}</option>`)}</select></div>
204
- <button class="btn u-selfend">${icon('plus')} Add handler</button></form>` : ''}
205
- <p class="wrap-anywhere muted small">URL: <code>${baseUrl(m)}${uri}</code></p>`)}
206
- ${region('Template', html`<form method="post" action="${MODULES}/${m.id}/template">${csrf(s)}<input type="hidden" name="orig" value="${uri}">
203
+ <div class="field"><label class="label" for="f_new">${tx('rm.method')}</label><select id="f_new" name="new">${free.map((x) => html`<option>${x}</option>`)}</select></div>
204
+ <button class="btn u-selfend">${icon('plus')} ${tx('rm.add_handler')}</button></form>` : ''}
205
+ <p class="wrap-anywhere muted small">${tx('rm.url')} <code>${baseUrl(m)}${uri}</code></p>`)}
206
+ ${region(tx('rm.template'), html`<form method="post" action="${MODULES}/${m.id}/template">${csrf(s)}<input type="hidden" name="orig" value="${uri}">
207
207
  <div class="form-grid">
208
- <div class="field"><label class="label" for="f_uri">URI template</label><input id="f_uri" name="uri" required value="${uri}"><small class="help">Renaming it moves its handlers too.</small></div>
209
- <div class="field"><label class="label" for="f_tdesc">Description</label><input id="f_tdesc" name="description" value="${t?.description ?? ''}"></div>
210
- </div><div class="buttons"><button class="btn btn-hot">Save</button></div></form>
208
+ <div class="field"><label class="label" for="f_uri">${tx('rm.uri_template')}</label><input id="f_uri" name="uri" required value="${uri}"><small class="help">${tx('rm.uri_rename_help')}</small></div>
209
+ <div class="field"><label class="label" for="f_tdesc">${tx('rm.description')}</label><input id="f_tdesc" name="description" value="${t?.description ?? ''}"></div>
210
+ </div><div class="buttons"><button class="btn btn-hot">${tx('rm.save')}</button></div></form>
211
211
  <form method="post" action="${MODULES}/${m.id}/template/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="uri" value="${uri}">
212
- <button class="btn btn-danger" data-confirm="Delete the template ${uri} and its ${handlers.length} handler(s)?">${icon('trash')} Delete template</button></form>`)}`;
212
+ <button class="btn btn-danger" data-confirm="${tx('rm.delete_template_confirm', { uri, n: handlers.length })}">${icon('trash')} ${tx('rm.delete_template')}</button></form>`)}`;
213
213
  return frame(s, m, `REST ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/']], { t: uri }, body);
214
214
  }
215
215
 
216
216
  /** "name in [type] [required] [description]" per line ↔ the handler's params. */
217
- export function parseParams(text: string): { params: HandlerParam[]; problems: string[] } {
217
+ export function parseParams(text: string, t: BuilderTranslate = translatorFor('en')): { params: HandlerParam[]; problems: string[] } {
218
218
  const params: HandlerParam[] = [];
219
219
  const problems: string[] = [];
220
220
  for (const [i, line] of text.split(/\r?\n/).entries()) {
@@ -222,7 +222,7 @@ export function parseParams(text: string): { params: HandlerParam[]; problems: s
222
222
  if (!words.length) continue;
223
223
  const [name, where, ...rest] = words;
224
224
  if (where !== 'query' && where !== 'body') {
225
- problems.push(`Parameters, line ${i + 1}: write "name query" or "name body", then optionally a type (${PARAM_TYPES.join(', ')}), "required" and a description.`);
225
+ problems.push(t('rm.e.params_line', { n: i + 1, types: PARAM_TYPES.join(', ') }));
226
226
  continue;
227
227
  }
228
228
  const p: HandlerParam = { name, in: where };
@@ -252,7 +252,9 @@ interface TryResult {
252
252
  error?: string;
253
253
  }
254
254
 
255
- async function handlerPage(s: Session, m: ModRow, uri: string, method: string | null, opts: { problems?: string[]; form?: Record<string, unknown>; tried?: TryResult; tryForm?: Record<string, unknown> } = {}) {
255
+ type HandlerOpts = { problems?: string[]; form?: Record<string, unknown>; tried?: TryResult; tryForm?: Record<string, unknown> };
256
+ const handlerPage = (s: Session, m: ModRow, uri: string, method: string | null, opts: HandlerOpts = {}) => withTranslator(tr(s), () => handlerPageInner(s, m, uri, method, opts));
257
+ async function handlerPageInner(s: Session, m: ModRow, uri: string, method: string | null, opts: HandlerOpts) {
256
258
  const existing = method ? m.handlers.find((h) => h.path === uri && h.method === method) : undefined;
257
259
  const isNew = !existing;
258
260
  const f = opts.form;
@@ -262,30 +264,30 @@ async function handlerPage(s: Session, m: ModRow, uri: string, method: string |
262
264
  const editor = html`<form method="post" action="${MODULES}/${m.id}/handler">${csrf(s)}${errorList(opts.problems ?? [])}
263
265
  <input type="hidden" name="t" value="${uri}">${existing ? html`<input type="hidden" name="orig_m" value="${existing.method}">` : ''}
264
266
  <div class="form-grid">
265
- <div class="field"><label class="label" for="f_method">Method</label><select id="f_method" name="method">${METHODS.map((x) => html`<option${v('method', h.method) === x ? raw(' selected') : ''}>${x}</option>`)}</select></div>
266
- <div class="field"><label class="label" for="f_type">Source type</label><select id="f_type" name="type">${Object.entries(TYPE_LABELS).map(([k, l]) => html`<option value="${k}"${type === k ? raw(' selected') : ''}>${l}</option>`)}</select></div>
267
- <div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${v('description', h.description)}"></div>
267
+ <div class="field"><label class="label" for="f_method">${tx('rm.method')}</label><select id="f_method" name="method">${METHODS.map((x) => html`<option${v('method', h.method) === x ? raw(' selected') : ''}>${x}</option>`)}</select></div>
268
+ <div class="field"><label class="label" for="f_type">${tx('rm.source_type')}</label><select id="f_type" name="type">${TYPES.map((k) => html`<option value="${k}"${type === k ? raw(' selected') : ''}>${typeLabel(k)}</option>`)}</select></div>
269
+ <div class="field"><label class="label" for="f_description">${tx('rm.description')}</label><input id="f_description" name="description" value="${v('description', h.description)}"></div>
268
270
  </div>
269
- <div class="field"><label class="label" for="f_source">Source</label>
271
+ <div class="field"><label class="label" for="f_source">${tx('rm.source')}</label>
270
272
  <textarea id="f_source" name="source" class="code sql-editor" rows="10" spellcheck="false" data-code="${type === 'plpgsql' ? 'plpgsql' : 'sql'}" required>${v('source', h.source)}</textarea>
271
- <small class="help">Binds, upper case: path parameters (${pathParams(uri).map((p) => `:${p.toUpperCase()}`).join(', ') || 'none here'}), query parameters and JSON body fields; <code>:BODY</code> is the whole body; <code>:APP_USER</code> is the caller. Values are bound, never pasted into the SQL. PL/pgSQL: a block (<code>declare … begin … end</code>, or statements) that answers with <code>perform meta.rest_reply(jsonb_build_object(…), 201)</code>. Media: a query returning <code>content_type</code>, <code>content</code> and optionally <code>filename</code>.</small></div>
272
- <fieldset class="prop-group"><legend>Who may call it</legend>
273
- <label class="check"><input type="radio" name="auth" value="token"${v('auth', h.auth ?? 'token') !== 'public' ? raw(' checked') : ''}> A bearer token of the application${m.roles.length ? ` with the module's role ${m.roles.join(' or ')}` : ''}</label>
274
- <label class="check"><input type="radio" name="auth" value="public"${v('auth', h.auth) === 'public' ? raw(' checked') : ''}> Public: no token <small class="help">(runs as <code>nobody</code>${m.roles.length ? '; the module\'s roles still apply, so it answers 403' : ''})</small></label>
273
+ <small class="help">${tx.code('rm.source_help', { params: pathParams(uri).map((p) => `:${p.toUpperCase()}`).join(', ') || tx('rm.none_here') })}</small></div>
274
+ <fieldset class="prop-group"><legend>${tx('rm.who')}</legend>
275
+ <label class="check"><input type="radio" name="auth" value="token"${v('auth', h.auth ?? 'token') !== 'public' ? raw(' checked') : ''}> ${m.roles.length ? tx('rm.auth_token_role', { roles: anyOf(m.roles) }) : tx('rm.auth_token')}</label>
276
+ <label class="check"><input type="radio" name="auth" value="public"${v('auth', h.auth) === 'public' ? raw(' checked') : ''}> ${tx('rm.auth_public')} <small class="help">${m.roles.length ? tx.code('rm.auth_public_403') : tx.code('rm.auth_public_help')}</small></label>
275
277
  </fieldset>
276
278
  <div class="form-grid">
277
- <div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" value="${f ? String(f.roles ?? '') : (h.roles ?? []).join(', ')}" placeholder="no further role"><small class="help">Comma separated: the caller needs one of them too.</small></div>
278
- <div class="field"><label class="label" for="f_page_size">Page size</label><input id="f_page_size" name="page_size" type="number" min="1" max="500" value="${v('page_size', h.page_size)}" placeholder="25"><small class="help">Collection queries: rows without ?limit=.</small></div>
279
- <div class="field"><label class="label" for="f_status">Status</label><input id="f_status" name="status" type="number" min="200" max="299" value="${v('status', h.status)}" placeholder="201 for POST, else 200"><small class="help">SQL and PL/pgSQL handlers.</small></div>
279
+ <div class="field"><label class="label" for="f_roles">${tx('rm.roles')}</label><input id="f_roles" name="roles" value="${f ? String(f.roles ?? '') : (h.roles ?? []).join(', ')}" placeholder="${tx('rm.no_further_role')}"><small class="help">${tx('rm.roles_help_handler')}</small></div>
280
+ <div class="field"><label class="label" for="f_page_size">${tx('rm.page_size')}</label><input id="f_page_size" name="page_size" type="number" min="1" max="500" value="${v('page_size', h.page_size)}" placeholder="25"><small class="help">${tx('rm.page_size_help')}</small></div>
281
+ <div class="field"><label class="label" for="f_status">${tx('rm.status')}</label><input id="f_status" name="status" type="number" min="200" max="299" value="${v('status', h.status)}" placeholder="${tx('rm.status_ph')}"><small class="help">${tx('rm.status_help')}</small></div>
280
282
  </div>
281
- <div class="field"><label class="label" for="f_params">Parameters (documentation)</label>
283
+ <div class="field"><label class="label" for="f_params">${tx('rm.params')}</label>
282
284
  <textarea id="f_params" name="params" class="code" rows="3" spellcheck="false" placeholder="start_date body string required First day">${f ? String(f.params ?? '') : paramsText(h.params)}</textarea>
283
- <small class="help">One per line: <code>name query|body [${PARAM_TYPES.join('|')}] [required] [description]</code>. They go into the OpenAPI document and the Try it form; a required one missing answers 400.</small></div>
284
- <div class="buttons"><button class="btn btn-hot">${isNew ? 'Create handler' : 'Save'}</button></div></form>
285
+ <small class="help">${tx.code('rm.params_help', { types: PARAM_TYPES.join('|') })}</small></div>
286
+ <div class="buttons"><button class="btn btn-hot">${isNew ? tx('rm.create_handler') : tx('rm.save')}</button></div></form>
285
287
  ${existing ? html`<form method="post" action="${MODULES}/${m.id}/handler/delete" class="danger-zone">${csrf(s)}<input type="hidden" name="t" value="${uri}"><input type="hidden" name="m" value="${existing.method}">
286
- <button class="btn btn-danger" data-confirm="Delete the handler ${existing.method} ${uri}?">${icon('trash')} Delete handler</button></form>` : ''}`;
287
- const body = html`${region(isNew ? `New handler for ${uri || '/'}` : `${h.method} ${uri || '/'}`, editor)}${existing ? await tryPanel(s, m, existing, opts.tried, opts.tryForm) : ''}`;
288
- return frame(s, m, `REST ${h.method} ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/', tplUrl(m, uri)], [isNew ? 'New handler' : String(h.method)]], { t: uri, m: existing?.method }, body);
288
+ <button class="btn btn-danger" data-confirm="${tx('rm.delete_handler_confirm', { method: existing.method, uri })}">${icon('trash')} ${tx('rm.delete_handler')}</button></form>` : ''}`;
289
+ const body = html`${region(isNew ? tx('rm.new_handler_for', { uri: uri || '/' }) : `${h.method} ${uri || '/'}`, editor)}${existing ? await tryPanel(s, m, existing, opts.tried, opts.tryForm) : ''}`;
290
+ return frame(s, m, `REST ${h.method} ${m.name}/${uri}`, [[m.name, `${MODULES}/${m.id}`], [uri || '/', tplUrl(m, uri)], [isNew ? tx('rm.new_handler') : String(h.method)]], { t: uri, m: existing?.method }, body);
289
291
  }
290
292
 
291
293
  /** Accounts a Try it request may run as: those with access to the application (all active ones for "any user"). */
@@ -302,21 +304,21 @@ async function tryPanel(s: Session, m: ModRow, h: Handler, tried?: TryResult, f?
302
304
  const qs = (h.params ?? []).filter((p) => p.in === 'query').map((p) => `${p.name}=`).join('&');
303
305
  const bodyEx = h.method === 'GET' || h.method === 'DELETE' ? '' : JSON.stringify(Object.fromEntries((h.params ?? []).filter((p) => p.in === 'body').map((p) => [p.name, p.type === 'integer' || p.type === 'number' ? 0 : p.type === 'boolean' ? false : ''])), null, 2);
304
306
  const result = tried
305
- ? html`<div class="u-mt1" role="status"><h3 class="small">Response</h3>
306
- <p class="wrap-anywhere"><code>${tried.method} ${tried.url}</code> as <b>${tried.as || 'no token'}</b></p>
307
+ ? html`<div class="u-mt1" role="status"><h3 class="small">${tx('rm.response')}</h3>
308
+ <p class="wrap-anywhere">${tx.rich('rm.response_as', { request: raw(`<code>${esc(`${tried.method} ${tried.url}`)}</code>`), as: raw(`<b>${esc(tried.as || tx('rm.no_token'))}</b>`) })}</p>
307
309
  ${tried.error ? html`<div class="alert alert-error">${tried.error}</div>` : html`<p><b>${tried.status}</b> <span class="muted">${tried.type ?? ''}</span></p>
308
310
  ${tried.body ? html`<pre class="source wrap-pre">${tried.body}</pre>` : ''}`}</div>`
309
311
  : '';
310
312
  const params = pathParams(h.path);
311
- return region('Try it', html`<form method="post" action="${MODULES}/${m.id}/try">${csrf(s)}<input type="hidden" name="t" value="${h.path}"><input type="hidden" name="m" value="${h.method}">
312
- <p class="muted u-mt0">Sends the request to the real endpoint: a PUT, POST or DELETE changes data. <em>As</em> an account issues a short-lived token for it, as App › REST API does; without one you see what a caller without a token gets.</p>
313
+ return region(tx('rm.try_it'), html`<form method="post" action="${MODULES}/${m.id}/try">${csrf(s)}<input type="hidden" name="t" value="${h.path}"><input type="hidden" name="m" value="${h.method}">
314
+ <p class="muted u-mt0">${tx.rich('rm.try_intro', { as: raw(`<em>${esc(tx('rm.as'))}</em>`) })}</p>
313
315
  <div class="form-grid">
314
- <div class="field"><label class="label" for="f_as">As</label><select id="f_as" name="as"><option value="">No token</option>${accounts.map((u) => html`<option${v('as') === u ? raw(' selected') : ''}>${u}</option>`)}</select></div>
316
+ <div class="field"><label class="label" for="f_as">${tx('rm.as')}</label><select id="f_as" name="as"><option value="">${tx('rm.no_token_option')}</option>${accounts.map((u) => html`<option${v('as') === u ? raw(' selected') : ''}>${u}</option>`)}</select></div>
315
317
  ${params.map((p) => html`<div class="field"><label class="label" for="f_p_${p}">:${p}</label><input id="f_p_${p}" name="p_${p}" value="${v(`p_${p}`)}" required></div>`)}
316
- <div class="field"><label class="label" for="f_query">Query string</label><input id="f_query" name="query" value="${v('query', qs)}" placeholder="limit=10&amp;offset=0"></div>
318
+ <div class="field"><label class="label" for="f_query">${tx('rm.query_string')}</label><input id="f_query" name="query" value="${v('query', qs)}" placeholder="limit=10&amp;offset=0"></div>
317
319
  </div>
318
- ${h.method === 'GET' || h.method === 'DELETE' ? '' : html`<div class="field"><label class="label" for="f_body">JSON body</label><textarea id="f_body" name="body" class="code" rows="5" spellcheck="false" data-code="json">${v('body', bodyEx)}</textarea></div>`}
319
- <div class="buttons"><button class="btn btn-hot">${icon('play')} Send ${h.method}</button></div></form>
320
+ ${h.method === 'GET' || h.method === 'DELETE' ? '' : html`<div class="field"><label class="label" for="f_body">${tx('rm.json_body')}</label><textarea id="f_body" name="body" class="code" rows="5" spellcheck="false" data-code="json">${v('body', bodyEx)}</textarea></div>`}
321
+ <div class="buttons"><button class="btn btn-hot">${icon('play')} ${tx('rm.send', { method: h.method })}</button></div></form>
320
322
  ${result}
321
323
  <h3 class="small">cURL</h3><pre class="source wrap-pre">curl${h.method === 'GET' ? '' : ` -X ${h.method}`} "${baseUrl(m)}${h.path.replace(/:([a-z][a-z0-9_]*)/gi, (_, p) => `<${p}>`)}"${h.auth === 'public' ? '' : ' -H "Authorization: Bearer $TOKEN"'}${bodyEx ? ` -H "Content-Type: application/json" -d '${bodyEx.replace(/\s+/g, ' ')}'` : ''}</pre>`);
322
324
  }
@@ -324,10 +326,10 @@ async function tryPanel(s: Session, m: ModRow, h: Handler, tried?: TryResult, f?
324
326
  // ------------------------------------------------------------------ changes
325
327
 
326
328
  /** Change a module's definition in a transaction (row locked), after the Run only / lock check. */
327
- async function change(m: ModRow, fn: (cur: { handlers: Handler[]; templates: Template[] }) => { handlers: Handler[]; templates: Template[] } | string[]): Promise<string[]> {
329
+ async function change(t: BuilderTranslate, m: ModRow, fn: (cur: { handlers: Handler[]; templates: Template[] }) => { handlers: Handler[]; templates: Template[] } | string[]): Promise<string[]> {
328
330
  return owner.tx(async (c) => {
329
331
  const cur = (await c.query('select handlers, templates from meta.rest_module where id = $1 for update', [m.id])).rows[0];
330
- if (!cur) return ['The module no longer exists.'];
332
+ if (!cur) return [t('rm.e.module_gone')];
331
333
  const next = fn({ handlers: Array.isArray(cur.handlers) ? cur.handlers : [], templates: Array.isArray(cur.templates) ? cur.templates : [] });
332
334
  if (Array.isArray(next)) return next;
333
335
  const problems = [...templateProblems(next.templates), ...handlerProblems(next.handlers)];
@@ -338,28 +340,28 @@ async function change(m: ModRow, fn: (cur: { handlers: Handler[]; templates: Tem
338
340
  }
339
341
 
340
342
  /** A handler from the form, or the problems with it. */
341
- function readHandler(b: Record<string, unknown>, uri: string): { h: Handler; problems: string[] } {
343
+ function readHandler(t: BuilderTranslate, b: Record<string, unknown>, uri: string): { h: Handler; problems: string[] } {
342
344
  const problems: string[] = [];
343
345
  const str = (k: string) => String(b[k] ?? '').trim();
344
346
  const h: Handler = { method: str('method') as Handler['method'], path: uri, type: str('type') as Handler['type'], source: String(b.source ?? '').replace(/\r\n/g, '\n').trim() };
345
347
  const auth = str('auth') || 'token';
346
348
  if (auth === 'public') h.auth = 'public';
347
- else if (auth !== 'token') problems.push('Choose who may call it: a token of the application, or public.');
349
+ else if (auth !== 'token') problems.push(t('rm.e.auth'));
348
350
  if (str('description')) h.description = str('description').slice(0, 500);
349
351
  const roles = splitRoles(str('roles'));
350
- if (roles.some((r) => r.length > 100)) problems.push('Role names are at most 100 characters.');
352
+ if (roles.some((r) => r.length > 100)) problems.push(t('rm.e.role_length'));
351
353
  if (roles.length) h.roles = roles;
352
354
  if (str('page_size')) {
353
355
  const n = Number(str('page_size'));
354
- if (!Number.isInteger(n) || n < 1 || n > 500) problems.push('The page size is 1 to 500.');
356
+ if (!Number.isInteger(n) || n < 1 || n > 500) problems.push(t('rm.e.page_size'));
355
357
  else h.page_size = n;
356
358
  }
357
359
  if (str('status')) {
358
360
  const n = Number(str('status'));
359
- if (!Number.isInteger(n) || n < 200 || n > 299) problems.push('The status is 200 to 299.');
361
+ if (!Number.isInteger(n) || n < 200 || n > 299) problems.push(t('rm.e.status'));
360
362
  else h.status = n;
361
363
  }
362
- const p = parseParams(String(b.params ?? ''));
364
+ const p = parseParams(String(b.params ?? ''), t);
363
365
  problems.push(...p.problems);
364
366
  if (p.params.length) h.params = p.params;
365
367
  return { h, problems };
@@ -392,12 +394,12 @@ export async function restModuleRoutes(app: FastifyInstance) {
392
394
  const b = (req.body ?? {}) as Record<string, string>;
393
395
  const again = async (problems: string[]) => send(reply.code(422), s, await listPage(s, b, problems));
394
396
  const a = (await workspaceApps(s)).find((x) => String(x.id) === String(b.app_id ?? ''));
395
- if (!a) return again(['Choose an application of this workspace.']);
397
+ if (!a) return again([tr(s)('rm.e.choose_app')]);
396
398
  const name = String(b.name ?? '').trim();
397
399
  const title = String(b.title ?? '').trim();
398
400
  const problems: string[] = [];
399
- if (!NAME.test(name)) problems.push('The base path starts with a lower case letter: letters, digits, _ and - (at most 40).');
400
- if (!title || title.length > 200) problems.push('The title is 1 to 200 characters.');
401
+ if (!NAME.test(name)) problems.push(tr(s)('rm.e.base_path'));
402
+ if (!title || title.length > 200) problems.push(tr(s)('rm.e.title'));
401
403
  const roles = splitRoles(b.roles);
402
404
  const refused = await changeRefused(s, a.id);
403
405
  if (refused) problems.push(refused);
@@ -407,11 +409,11 @@ export async function restModuleRoutes(app: FastifyInstance) {
407
409
  `insert into meta.rest_module (app_id, name, title, description, roles, handlers, templates) values ($1, $2, $3, $4, $5, '[]', '[]') returning id`,
408
410
  [a.id, name, title, String(b.description ?? '').trim() || null, roles]);
409
411
  await log(req, s, { app_id: a.id, name }, 'module created');
410
- flash(s, `Module ${name} created at /a/${a.alias}/rest/${name}/. Add a template next.`);
412
+ flash(s, tr(s)('rm.created', { name, path: `/a/${a.alias}/rest/${name}/` }));
411
413
  return back(reply, s, `${MODULES}/${r!.id}`);
412
414
  } catch (e) {
413
415
  if ((e as { code?: string }).code !== '23505') throw e;
414
- return again([`${a.name} already has a module ${name}.`]);
416
+ return again([tr(s)('rm.e.exists', { app: a.name, name })]);
415
417
  }
416
418
  });
417
419
 
@@ -432,8 +434,8 @@ export async function restModuleRoutes(app: FastifyInstance) {
432
434
  const name = String(b.name ?? '').trim();
433
435
  const title = String(b.title ?? '').trim();
434
436
  const problems: string[] = [];
435
- if (!NAME.test(name)) problems.push('The base path starts with a lower case letter: letters, digits, _ and - (at most 40).');
436
- if (!title || title.length > 200) problems.push('The title is 1 to 200 characters.');
437
+ if (!NAME.test(name)) problems.push(tr(s)('rm.e.base_path'));
438
+ if (!title || title.length > 200) problems.push(tr(s)('rm.e.title'));
437
439
  const refused = await changeRefused(s, m.app_id);
438
440
  if (refused) problems.push(refused);
439
441
  if (!problems.length) {
@@ -442,12 +444,12 @@ export async function restModuleRoutes(app: FastifyInstance) {
442
444
  [m.id, name, title, String(b.description ?? '').trim() || null, splitRoles(b.roles), b.enabled !== '0']);
443
445
  } catch (e) {
444
446
  if ((e as { code?: string }).code !== '23505') throw e;
445
- problems.push(`${m.app_name} already has a module ${name}.`);
447
+ problems.push(tr(s)('rm.e.exists', { app: m.app_name, name }));
446
448
  }
447
449
  }
448
450
  if (problems.length) return send(reply.code(422), s, await modulePage(s, m, problems, b));
449
451
  await log(req, s, { app_id: m.app_id, name }, `module saved (${b.enabled === '0' ? 'disabled' : 'enabled'}, roles: ${splitRoles(b.roles).join(', ') || 'none'})`);
450
- flash(s, 'Saved.');
452
+ flash(s, tr(s)('rm.saved'));
451
453
  return back(reply, s, `${MODULES}/${m.id}`);
452
454
  });
453
455
 
@@ -463,7 +465,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
463
465
  }
464
466
  await owner.query('delete from meta.rest_module where id = $1', [m.id]);
465
467
  await log(req, s, m, 'module deleted');
466
- flash(s, `Module ${m.name} deleted: its URLs answer 404.`);
468
+ flash(s, tr(s)('rm.deleted', { name: m.name }));
467
469
  return back(reply, s, MODULES);
468
470
  });
469
471
 
@@ -488,10 +490,10 @@ export async function restModuleRoutes(app: FastifyInstance) {
488
490
  const orig = b.orig === undefined ? null : String(b.orig);
489
491
  const description = String(b.description ?? '').trim().slice(0, 500);
490
492
  const refused = await changeRefused(s, m.app_id);
491
- const problems = refused ? [refused] : await change(m, (cur) => {
493
+ const problems = refused ? [refused] : await change(tr(s), m, (cur) => {
492
494
  const list = templatesOf(cur);
493
- if (orig === null && list.some((t) => t.uri === uri)) return [`The template ${uri} is there already.`];
494
- if (orig !== null && !list.some((t) => t.uri === orig)) return ['The template no longer exists.'];
495
+ if (orig === null && list.some((t) => t.uri === uri)) return [tr(s)('rm.e.template_exists', { uri })];
496
+ if (orig !== null && !list.some((t) => t.uri === orig)) return [tr(s)('rm.e.template_gone')];
495
497
  const next = list.filter((t) => t.uri !== orig).map((t) => ({ uri: t.uri, ...(t.description ? { description: t.description } : {}) }));
496
498
  next.push({ uri, ...(description ? { description } : {}) });
497
499
  return { templates: next.sort((x, y) => x.uri.localeCompare(y.uri)), handlers: cur.handlers.map((h) => (orig !== null && h.path === orig ? { ...h, path: uri } : h)) };
@@ -501,7 +503,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
501
503
  return send(reply.code(422), s, await modulePage(s, m, problems));
502
504
  }
503
505
  await log(req, s, m, orig === null ? `template ${uri} added` : `template ${orig} saved as ${uri}`);
504
- flash(s, 'Saved.');
506
+ flash(s, tr(s)('rm.saved'));
505
507
  return back(reply, s, tplUrl(m, uri));
506
508
  });
507
509
 
@@ -512,13 +514,13 @@ export async function restModuleRoutes(app: FastifyInstance) {
512
514
  if (!m) return;
513
515
  const uri = String(req.body?.uri ?? '');
514
516
  const refused = await changeRefused(s, m.app_id);
515
- const problems = refused ? [refused] : await change(m, (cur) => ({ templates: cur.templates.filter((t) => t?.uri !== uri), handlers: cur.handlers.filter((h) => h.path !== uri) }));
517
+ const problems = refused ? [refused] : await change(tr(s), m, (cur) => ({ templates: cur.templates.filter((t) => t?.uri !== uri), handlers: cur.handlers.filter((h) => h.path !== uri) }));
516
518
  if (problems.length) {
517
519
  flash(s, problems.join(' '), 'error');
518
520
  return back(reply, s, `${MODULES}/${m.id}`);
519
521
  }
520
522
  await log(req, s, m, `template ${uri} deleted`);
521
- flash(s, `Template ${uri || '/'} and its handlers deleted.`);
523
+ flash(s, tr(s)('rm.template_deleted', { uri: uri || '/' }));
522
524
  return back(reply, s, `${MODULES}/${m.id}`);
523
525
  });
524
526
 
@@ -544,12 +546,12 @@ export async function restModuleRoutes(app: FastifyInstance) {
544
546
  const uri = String(b.t ?? '');
545
547
  const orig = b.orig_m ? String(b.orig_m) : null;
546
548
  if (!templatesOf(m).some((t) => t.uri === uri)) return reply.code(404).send('Not found');
547
- const { h, problems } = readHandler(b, uri);
549
+ const { h, problems } = readHandler(tr(s), b, uri);
548
550
  const refused = await changeRefused(s, m.app_id);
549
551
  if (refused) problems.push(refused);
550
552
  if (!problems.length)
551
- problems.push(...await change(m, (cur) => {
552
- if (orig !== null && !cur.handlers.some((x) => x.path === uri && x.method === orig)) return ['The handler no longer exists.'];
553
+ problems.push(...await change(tr(s), m, (cur) => {
554
+ if (orig !== null && !cur.handlers.some((x) => x.path === uri && x.method === orig)) return [tr(s)('rm.e.handler_gone')];
553
555
  const at = orig === null ? -1 : cur.handlers.findIndex((x) => x.path === uri && x.method === orig);
554
556
  const handlers = [...cur.handlers];
555
557
  if (at >= 0) handlers[at] = h;
@@ -558,7 +560,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
558
560
  }));
559
561
  if (problems.length) return send(reply.code(422), s, await handlerPage(s, m, uri, orig, { problems, form: b }));
560
562
  await log(req, s, m, `handler ${h.method} ${uri} ${orig === null ? 'created' : 'saved'} (${h.type}, ${h.auth === 'public' ? 'public' : 'token'})`);
561
- flash(s, 'Saved.');
563
+ flash(s, tr(s)('rm.saved'));
562
564
  return back(reply, s, handlerUrl(m, h));
563
565
  });
564
566
 
@@ -570,13 +572,13 @@ export async function restModuleRoutes(app: FastifyInstance) {
570
572
  const uri = String(req.body?.t ?? '');
571
573
  const method = String(req.body?.m ?? '');
572
574
  const refused = await changeRefused(s, m.app_id);
573
- const problems = refused ? [refused] : await change(m, (cur) => ({ templates: cur.templates, handlers: cur.handlers.filter((h) => !(h.path === uri && h.method === method)) }));
575
+ const problems = refused ? [refused] : await change(tr(s), m, (cur) => ({ templates: cur.templates, handlers: cur.handlers.filter((h) => !(h.path === uri && h.method === method)) }));
574
576
  if (problems.length) {
575
577
  flash(s, problems.join(' '), 'error');
576
578
  return back(reply, s, `${MODULES}/${m.id}`);
577
579
  }
578
580
  await log(req, s, m, `handler ${method} ${uri} deleted`);
579
- flash(s, `Handler ${method} ${uri || '/'} deleted.`);
581
+ flash(s, tr(s)('rm.handler_deleted', { method, uri: uri || '/' }));
580
582
  return back(reply, s, templatesOf({ ...m, handlers: m.handlers.filter((h) => !(h.path === uri && h.method === method)) }).some((t) => t.uri === uri) ? tplUrl(m, uri) : `${MODULES}/${m.id}`);
581
583
  });
582
584
 
@@ -598,7 +600,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
598
600
  try {
599
601
  const headers: Record<string, string> = {};
600
602
  if (as) {
601
- if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error('Choose an account with access to the application.');
603
+ if (!(await tryAccounts(m.app_id)).includes(as)) throw new Error(tr(s)('rm.e.choose_account'));
602
604
  headers.authorization = `Bearer ${(await issueApiToken(m.app_id, as, 1, { internal: true })).token}`;
603
605
  }
604
606
  const raw = h.method === 'GET' || h.method === 'DELETE' ? '' : String(b.body ?? '').trim();
@@ -606,7 +608,7 @@ export async function restModuleRoutes(app: FastifyInstance) {
606
608
  try {
607
609
  JSON.parse(raw);
608
610
  } catch {
609
- throw new Error('The body is not valid JSON.');
611
+ throw new Error(tr(s)('rm.e.bad_json'));
610
612
  }
611
613
  headers['content-type'] = 'application/json';
612
614
  }
@@ -618,8 +620,8 @@ export async function restModuleRoutes(app: FastifyInstance) {
618
620
  try {
619
621
  text = JSON.stringify(JSON.parse(res.body), null, 2);
620
622
  } catch { /* as sent */ }
621
- tried.body = text.length > MAX_TRY_BODY ? `${text.slice(0, MAX_TRY_BODY)}\n… (${text.length} characters)` : text;
622
- } else if (res.rawPayload.length) tried.body = `(${res.rawPayload.length} bytes of ${tried.type || 'content'})`;
623
+ tried.body = text.length > MAX_TRY_BODY ? `${text.slice(0, MAX_TRY_BODY)}\n… ${tr(s)('rm.characters', { n: text.length })}` : text;
624
+ } else if (res.rawPayload.length) tried.body = tr(s)('rm.bytes_of', { n: res.rawPayload.length, type: tried.type || tr(s)('rm.content') });
623
625
  await log(req, s, m, `tried ${h.method} ${uri} as ${as || 'no token'}: ${res.statusCode}`);
624
626
  } catch (e) {
625
627
  tried.error = (e as Error).message;