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
@@ -6,12 +6,14 @@
6
6
  // A page gets its translator from the session: `const tx = tr(s)`, then `tx('rail.app_builder')`, or with
7
7
  // {placeholders}: `tx('ab.count', { n: 3 })`. The result is plain text: the html`` tag escapes it like any value.
8
8
  // `tx.rich()` puts markup (links) into a sentence: the message is escaped, Raw parameters are kept.
9
+ // `tx.code()` (sprint 162): a sentence whose `backticked` parts are code (syntax help).
9
10
  // `tx.dyn()` takes a key made at run time (a region type's gallery label, a SQL Workshop tool) with a fallback.
10
11
  // A text that is not keyed yet simply stays English. Moving a screen over: docs/guide/12-development.md.
11
12
  //
12
13
  // The developer's choice: meta.developer.language (migration 127), copied into the session (__BLANG) at sign-in;
13
14
  // before signing in, the sign-in page's language links set a cookie (LANG_COOKIE). See language.ts.
14
15
 
16
+ import { AsyncLocalStorage } from 'node:async_hooks';
15
17
  import type { FastifyRequest } from 'fastify';
16
18
  import { esc, raw, Raw } from '../html.ts';
17
19
  import type { Session } from '../session.ts';
@@ -70,6 +72,8 @@ export interface BuilderTranslate {
70
72
  dyn(key: string, fallback: string, params?: Params): string;
71
73
  /** A sentence with markup: the message is escaped, string parameters too; Raw parameters (links) are kept. */
72
74
  rich(key: BuilderKey, params: Record<string, string | number | Raw>): Raw;
75
+ /** (sprint 162) A sentence with `code` in backticks: escaped, each `…` becomes <code>…</code> (syntax help, settings). */
76
+ code(key: BuilderKey, params?: Params): Raw;
73
77
  /** An English label passed around as text (a breadcrumb, a page title) in this language, when it is a known one. */
74
78
  label(text: string): string;
75
79
  }
@@ -117,6 +121,7 @@ export function builderTranslator(lang: BuilderLang, own: Readonly<Record<string
117
121
  const v = params[k];
118
122
  return v === undefined ? m : v instanceof Raw ? v.value : esc(v);
119
123
  }));
124
+ tx.code = (key, params) => raw(esc(fill(text(key) ?? key, params)).replace(/`([^`]+)`/g, '<code>$1</code>'));
120
125
  tx.label = (label) => (Object.hasOwn(LABELS, label) ? tx(LABELS[label]) : label);
121
126
  return tx;
122
127
  }
@@ -134,3 +139,16 @@ export const tr = (s: Pick<Session, 'state'> | null | undefined) => translatorFo
134
139
 
135
140
  /** For tests and the coverage check: the message files. */
136
141
  export const builderMessages = (lang: BuilderLang): Readonly<Record<string, string>> => MESSAGES[lang];
142
+
143
+ // (sprint 162, CD-8) Long form builders (region and report settings) take the translator of the request being rendered
144
+ // from the async context instead of a parameter on every helper: withTranslator(tr(s), () => build()), and inside,
145
+ // scopedTx('key') like tx. Outside withTranslator it is English.
146
+ const SCOPE = new AsyncLocalStorage<BuilderTranslate>();
147
+ const current = () => SCOPE.getStore() ?? translatorFor('en');
148
+ export const withTranslator = <T>(t: BuilderTranslate, fn: () => T): T => SCOPE.run(t, fn);
149
+ export const scopedTx = ((key: BuilderKey, params?: Params) => current()(key, params)) as BuilderTranslate;
150
+ Object.defineProperty(scopedTx, 'lang', { get: () => current().lang });
151
+ scopedTx.dyn = (key, fallback, params) => current().dyn(key, fallback, params);
152
+ scopedTx.rich = (key, params) => current().rich(key, params);
153
+ scopedTx.code = (key, params) => current().code(key, params);
154
+ scopedTx.label = (label) => current().label(label);
@@ -6,6 +6,7 @@ import { readSchema } from '../ddl.ts';
6
6
  import { back, BASE, developer, flash, type Req } from './ui.ts';
7
7
  import { listObjects, objectDetail, OB_TYPES, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
8
8
  import { usableSchemas, workshopDb } from './workspaces.ts';
9
+ import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
9
10
 
10
11
  // (sprint 94, Database Actions DA-8) An object's menu in the SQL Worksheet's navigator and the Object Browser's
11
12
  // tree (Database Actions: the navigator's right-click menu): Open (the Object Browser's detail, in a panel beside
@@ -20,26 +21,26 @@ export type MenuObj = Pick<Obj, 'name' | 'ref'> & { type: Obj['type'] | 'policy'
20
21
  const RELATIONS = new Set(['table', 'view', 'matview']);
21
22
 
22
23
  /** The ⋯ menu of an object (works without JavaScript: a details menu of links). */
23
- export function objectMenu(schema: string, o: MenuObj): Raw {
24
+ export function objectMenu(schema: string, o: MenuObj, tx: BuilderTranslate = translatorFor('en')): Raw {
24
25
  const q = (act: string) => `${BASE}/sql/objects/action?${new URLSearchParams({ schema, k: o.type, o: o.ref, ...(o.type === 'policy' || o.type === 'trigger' ? { n: o.name } : {}), do: act })}`;
25
26
  const open = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
26
27
  const item = (href: string, ic: string, label: string, extra = '') => html`<a href="${href}"${extra ? html` ${extra}` : ''}>${icon(ic)} ${label}</a>`;
27
28
  const table = o.type === 'table';
28
- return html`<details class="menu obj-menu"><summary title="Actions for ${o.name}" aria-label="Actions for ${o.name}">⋯</summary>
29
+ return html`<details class="menu obj-menu"><summary title="${tx('oa.actions_for', { name: o.name })}" aria-label="${tx('oa.actions_for', { name: o.name })}">⋯</summary>
29
30
  <div class="menu-panel"><div class="menu-section menu-links">
30
- ${o.type === 'policy' ? '' : html`<a href="${open}" data-obj-open="${`${BASE}/sql/objects/panel?${new URLSearchParams({ schema, k: o.type, o: o.ref })}`}">${icon('eye')} Open</a>`}
31
- ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', 'Edit…') : ''}
32
- ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
33
- ${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', 'Security…') : ''}
34
- ${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
35
- ${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add full-text search…') : ''}
36
- ${table ? item(`${BASE}/sql/objects/vector?${new URLSearchParams({ o: o.ref })}`, 'search', 'Add vector column…') : ''}
37
- ${table ? item(`${BASE}/sql/objects/document?${new URLSearchParams({ o: o.ref })}`, 'file', 'Document view…') : ''}
38
- ${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
39
- ${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
40
- ${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
41
- ${item(q('download'), 'download', 'Quick DDL as a file')}
42
- ${item(q('drop'), 'trash', 'Drop…')}
31
+ ${o.type === 'policy' ? '' : html`<a href="${open}" data-obj-open="${`${BASE}/sql/objects/panel?${new URLSearchParams({ schema, k: o.type, o: o.ref })}`}">${icon('eye')} ${tx('oa.open')}</a>`}
32
+ ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', tx('oa.edit')) : ''}
33
+ ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', tx('oa.template')) : ''}
34
+ ${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', tx('oa.security')) : ''}
35
+ ${table ? item(`${BASE}/sql/load`, 'upload', tx('oa.load')) : ''}
36
+ ${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', tx('oa.fulltext')) : ''}
37
+ ${table ? item(`${BASE}/sql/objects/vector?${new URLSearchParams({ o: o.ref })}`, 'search', tx('oa.vector')) : ''}
38
+ ${table ? item(`${BASE}/sql/objects/document?${new URLSearchParams({ o: o.ref })}`, 'file', tx('oa.document')) : ''}
39
+ ${table ? html`${item(q('index'), 'list', tx('oa.index'))}${item(q('trigger'), 'bolt', tx('oa.trigger'))}${item(q('policy'), 'shield', tx('oa.policy'))}` : ''}
40
+ ${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', tx('oa.rest')) : ''}
41
+ ${item(q('ddl'), 'code', tx('oa.ddl'))}
42
+ ${item(q('download'), 'download', tx('oa.ddl_file'))}
43
+ ${item(q('drop'), 'trash', tx('oa.drop'))}
43
44
  </div></div></details>`;
44
45
  }
45
46
 
@@ -149,7 +150,8 @@ export async function objectActionRoutes(app: FastifyInstance) {
149
150
  }
150
151
  const sql = await draft(db, String(q.schema), o, act);
151
152
  s.state.__SQLDRAFT = sql ?? '';
152
- flash(s, act === 'ddl' ? `The DDL of ${o.ref}.` : act === 'drop' ? 'Review the DROP statement and run it yourself: nothing has been dropped yet.' : 'Fill in the statement and run it: nothing has been changed yet.');
153
+ const tx = tr(s);
154
+ flash(s, act === 'ddl' ? tx('oa.flash.ddl', { name: o.ref }) : act === 'drop' ? tx('oa.flash.drop') : tx('oa.flash.fill'));
153
155
  return back(reply, s, `${BASE}/sql/worksheet`);
154
156
  });
155
157
 
@@ -140,7 +140,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
140
140
  if (!list.length && filter) return '';
141
141
  return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
142
142
  <summary>${icon(ic)}<span>${tx.dyn(`ob.type.${t}`, label)}</span><span class="kind">${list.length}</span></summary>
143
- <ul class="tree">${list.map((o) => html`<li class="ob-item"><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">${tx('ob.extension')}</span>` : ''}</a>${objectMenu(schema, o)}</li>`)}</ul>
143
+ <ul class="tree">${list.map((o) => html`<li class="ob-item"><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">${tx('ob.extension')}</span>` : ''}</a>${objectMenu(schema, o, tx)}</li>`)}</ul>
144
144
  </details>`;
145
145
  })}</div>`;
146
146
 
@@ -160,7 +160,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
160
160
  <a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>${tx('ob.tile.load')}</b><small class="muted">${tx('ob.tile.load.desc')}</small></span></a></div>
161
161
  <p class="muted">${tx('ob.select_hint')}</p>`);
162
162
 
163
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('objects', s)}
163
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('objects', s)}
164
164
  <div class="ob-head">
165
165
  <form method="get" class="ob-schema">${filter ? html`<input type="hidden" name="q" value="${filter}">` : ''}
166
166
  <label class="label" for="ob-schema">${tx('ob.schema')}</label>
@@ -4,6 +4,7 @@ import { owner } from '../db.ts';
4
4
  import { html, raw } from '../html.ts';
5
5
  import { icon } from '../icons.ts';
6
6
  import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
7
+ import { tr } from './i18n.ts';
7
8
 
8
9
  // SQL Workshop → Query Builder: pick tables and views of a schema; joins
9
10
  // follow their foreign keys, or columns joined by the developer (drawn on
@@ -90,6 +91,8 @@ export interface BuiltQuery {
90
91
  /** whether the query groups (a column has a function) */
91
92
  grouped: boolean;
92
93
  notes: string[];
94
+ /** (sprint 162) the tables of `notes`: no foreign key connects them, so they are cross joined */
95
+ crossJoined: string[];
93
96
  }
94
97
 
95
98
  /** Build the SELECT for a spec, using only names that exist in `relations`. */
@@ -98,6 +101,7 @@ export function buildQuery(relations: CatalogRelation[], fks: CatalogFk[], spec:
98
101
  const tables = [...new Set(spec.tables)].filter((t) => byName.has(t));
99
102
  if (!tables.length) return null;
100
103
  const notes: string[] = [];
104
+ const crossJoined: string[] = [];
101
105
  const aliases = new Map(tables.map((t, k) => [t, `t${k + 1}`]));
102
106
  const q = (t: string) => `${ident(spec.schema)}.${ident(t)}`;
103
107
 
@@ -130,6 +134,7 @@ export function buildQuery(relations: CatalogRelation[], fks: CatalogFk[], spec:
130
134
  if (pick < 0) {
131
135
  pick = 0;
132
136
  notes.push(`No foreign key connects ${pending[0]} to the other tables: it is cross joined. Join two of their columns on the canvas, or under Joins.`);
137
+ crossJoined.push(pending[0]);
133
138
  }
134
139
  const t = pending.splice(pick, 1)[0];
135
140
  joins.push({ alias: aliases.get(t)!, table: t, fk: customFor(t).length ? null : fk, custom: customFor(t) });
@@ -196,7 +201,7 @@ export function buildQuery(relations: CatalogRelation[], fks: CatalogFk[], spec:
196
201
  if (groupBy.length) sql += `\n group by ${groupBy.join(', ')}`;
197
202
  if (order.length) sql += `\n order by ${order.join(', ')}`;
198
203
  if (spec.limit) sql += `\n limit ${spec.limit}`;
199
- return { sql, aliases, joins, notes, grouped };
204
+ return { sql, aliases, joins, notes, crossJoined, grouped };
200
205
  }
201
206
 
202
207
  /** Tables and views of a schema, with their columns, and the foreign keys between its tables. */
@@ -272,6 +277,7 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
272
277
  app.get(`${BASE}/sql/query`, async (req: Req, reply) => {
273
278
  const s = await developer(req, reply);
274
279
  if (!s) return;
280
+ const tx = tr(s);
275
281
  const schemas = (
276
282
  await owner.query(
277
283
  `select nspname as name from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema') order by nspname = 'meta', nspname`,
@@ -299,72 +305,72 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
299
305
  const orders = [...spec.order.filter((o) => o.column), { column: '', dir: 'asc' as const }];
300
306
 
301
307
  const form = html`<form method="get" action="${BASE}/sql/query">${chosen.length ? html`<input type="hidden" name="o" value="${chosen.join(',')}">` : ''}
302
- ${region('1. Tables and views', html`<div class="form-grid"><div class="field"><label class="label" for="f_schema">Schema</label>
308
+ ${region(`1. ${tx('qb.tables')}`, html`<div class="form-grid"><div class="field"><label class="label" for="f_schema">${tx('sm.schema')}</label>
303
309
  <select id="f_schema" name="schema">${schemas.map((x) => html`<option${sel(x === schema)}>${x}</option>`)}</select></div></div>
304
310
  ${rels.length
305
- ? html`<fieldset class="field"><legend class="label">Tables and views of ${schema}</legend><div class="qb-pick">
306
- ${rels.map((r) => html`<label class="check"><input type="checkbox" name="t" value="${r.name}"${check(chosen.includes(r.name))}> ${r.name}${r.kind === 'view' ? html` <span class="tag">view</span>` : ''}${chosen.includes(r.name) ? html` <span class="muted small">${built!.aliases.get(r.name)}</span>` : ''}</label>`)}
311
+ ? html`<fieldset class="field"><legend class="label">${tx('qb.tables_of', { schema })}</legend><div class="qb-pick">
312
+ ${rels.map((r) => html`<label class="check"><input type="checkbox" name="t" value="${r.name}"${check(chosen.includes(r.name))}> ${r.name}${r.kind === 'view' ? html` <span class="tag">${tx('qb.view')}</span>` : ''}${chosen.includes(r.name) ? html` <span class="muted small">${built!.aliases.get(r.name)}</span>` : ''}</label>`)}
307
313
  </div></fieldset>`
308
- : html`<p class="muted">This schema has no tables or views.</p>`}
309
- <div class="buttons"><button class="btn">${icon('check')} Apply</button></div>`)}
314
+ : html`<p class="muted">${tx('qb.no_tables')}</p>`}
315
+ <div class="buttons"><button class="btn">${icon('check')} ${tx('qb.apply')}</button></div>`)}
310
316
  ${built
311
317
  ? html`<div class="u-spacer"></div>
312
- ${region('2. Columns', html`<p class="muted u-mt0">None ticked: all columns. A function (count, sum, …) groups the rows by the other chosen columns. Drag a table by its handle (or focus the handle and use the arrow keys); drag the dot next to a column onto a column of another table to join them.</p>
313
- <label class="check u-mb1"><input type="checkbox" name="distinct" value="1"${check(spec.distinct)}> Distinct rows</label>
318
+ ${region(`2. ${tx('ul.columns')}`, html`<p class="muted u-mt0">${tx('qb.columns_help')}</p>
319
+ <label class="check u-mb1"><input type="checkbox" name="distinct" value="1"${check(spec.distinct)}> ${tx('qb.distinct')}</label>
314
320
  <div class="qb-canvas" data-joins="${JSON.stringify(lines)}">${chosen.map((t, k) => {
315
321
  const a = built!.aliases.get(t)!;
316
322
  const pos = spec.positions[t] ?? { x: 16 + k * 272, y: 16 };
317
323
  return html`<div class="qb-table" data-table="${t}" data-alias="${a}" data-x="${pos.x}" data-y="${pos.y}">
318
324
  ${spec.positions[t] ? html`<input type="hidden" name="p" value="${t}:${pos.x},${pos.y}">` : ''}
319
- <div class="qb-table-head"><button type="button" class="qb-move" hidden aria-label="Move ${t} (arrow keys)" title="Drag to move">${icon('menu')}</button>
325
+ <div class="qb-table-head"><button type="button" class="qb-move" hidden aria-label="${tx('qb.move', { table: t })}" title="${tx('qb.drag_move')}">${icon('menu')}</button>
320
326
  <strong>${t}</strong> <span class="muted small">${a}</span></div>
321
327
  <ul class="qb-cols">${rels.find((r) => r.name === t)!.columns.map((c) => {
322
328
  const ref = `${a}.${c.name}`;
323
329
  return html`<li class="qb-col" data-ref="${ref}">
324
330
  <label class="check"><input type="checkbox" name="c" value="${ref}"${check(spec.columns.includes(ref))}> ${c.name}</label>
325
331
  <span class="muted small qb-type">${c.type}</span>
326
- <select name="fn" aria-label="Function for ${ref}"><option value="">-</option>${Object.entries(FUNCTIONS).map(([fn, label]) => html`<option value="${ref}:${fn}"${sel(spec.fns[ref] === fn)}>${label}</option>`)}</select>
327
- ${chosen.length > 1 ? html`<button type="button" class="qb-link" hidden data-ref="${ref}" aria-label="Join ${ref} to a column of another table" title="Drag onto a column of another table to join them"></button>` : ''}
332
+ <select name="fn" aria-label="${tx('qb.function_for', { column: ref })}"><option value="">-</option>${Object.entries(FUNCTIONS).map(([fn, label]) => html`<option value="${ref}:${fn}"${sel(spec.fns[ref] === fn)}>${tx.dyn(`qb.fn.${fn}`, label)}</option>`)}</select>
333
+ ${chosen.length > 1 ? html`<button type="button" class="qb-link" hidden data-ref="${ref}" aria-label="${tx('qb.join_label', { column: ref })}" title="${tx('qb.join_title')}"></button>` : ''}
328
334
  </li>`;
329
335
  })}</ul></div>`;
330
336
  })}</div>`)}
331
337
  ${built.joins.length
332
- ? html`<div class="u-spacer"></div>${region('3. Joins', html`<p class="muted u-mt0">From the foreign keys between the chosen tables, or the columns you joined.</p>
333
- ${built.notes.map((n) => html`<div class="alert alert-error" role="alert">${n}</div>`)}
338
+ ? html`<div class="u-spacer"></div>${region(`3. ${tx('qb.joins')}`, html`<p class="muted u-mt0">${tx('qb.joins_help')}</p>
339
+ ${built.crossJoined.map((t) => html`<div class="alert alert-error" role="alert">${tx('qb.cross_join', { table: t })}</div>`)}
334
340
  <ul class="qb-joins">${built.joins.map((j) => html`<li class="u-mb1">${j.table} <span class="muted">${j.alias}</span>
335
341
  ${j.custom.length
336
- ? html` on ${j.custom.map((c, k) => html`${k ? ' and ' : ''}<label class="check qb-inline"><input type="checkbox" name="j" value="${c.a}=${c.b}" checked> ${c.a} = ${c.b}</label>`)}`
337
- : j.fk ? html` on ${j.fk.from}(${j.fk.fromCols.join(', ')}) → ${j.fk.to}(${j.fk.toCols.join(', ')})` : html` (cross join)`}
338
- ${j.fk || j.custom.length ? html`<label class="sr-only" for="f_jt_${j.alias}">Join type for ${j.table}</label>
339
- <select id="f_jt_${j.alias}" name="jt_${j.alias}"><option value="inner">inner join</option><option value="left"${sel(spec.joinTypes[j.alias] === 'left')}>left join (keep rows without a match)</option></select>` : ''}</li>`)}</ul>
340
- <fieldset class="field"><legend class="label">Join two columns</legend><div class="qb-cond">
341
- <select name="ja" aria-label="Join: column">${colOptions('')}</select><span class="qb-eq">=</span>
342
- <select name="jb" aria-label="Join: column of another table">${colOptions('')}</select></div></fieldset>
343
- <div class="buttons"><button class="btn">${icon('check')} Apply</button></div>`)}`
342
+ ? html` ${tx('qb.on')} ${j.custom.map((c, k) => html`${k ? ` ${tx('qb.and')} ` : ''}<label class="check qb-inline"><input type="checkbox" name="j" value="${c.a}=${c.b}" checked> ${c.a} = ${c.b}</label>`)}`
343
+ : j.fk ? html` ${tx('qb.on')} ${j.fk.from}(${j.fk.fromCols.join(', ')}) → ${j.fk.to}(${j.fk.toCols.join(', ')})` : html` (${tx('qb.cross')})`}
344
+ ${j.fk || j.custom.length ? html`<label class="sr-only" for="f_jt_${j.alias}">${tx('qb.join_type', { table: j.table })}</label>
345
+ <select id="f_jt_${j.alias}" name="jt_${j.alias}"><option value="inner">${tx('qb.inner')}</option><option value="left"${sel(spec.joinTypes[j.alias] === 'left')}>${tx('qb.left')}</option></select>` : ''}</li>`)}</ul>
346
+ <fieldset class="field"><legend class="label">${tx('qb.join_two')}</legend><div class="qb-cond">
347
+ <select name="ja" aria-label="${tx('qb.join_a')}">${colOptions('')}</select><span class="qb-eq">=</span>
348
+ <select name="jb" aria-label="${tx('qb.join_b')}">${colOptions('')}</select></div></fieldset>
349
+ <div class="buttons"><button class="btn">${icon('check')} ${tx('qb.apply')}</button></div>`)}`
344
350
  : ''}
345
351
  <div class="u-spacer"></div>
346
- ${region(`${built.joins.length ? 4 : 3}. Conditions`, html`<fieldset class="field"><legend class="label">Rows must match</legend><div class="radio-group">
347
- <label class="check"><input type="radio" name="any" value="and"${check(!spec.any)}> all conditions</label>
348
- <label class="check"><input type="radio" name="any" value="or"${check(spec.any)}> any condition</label></div></fieldset>
352
+ ${region(`${built.joins.length ? 4 : 3}. ${tx('qb.conditions')}`, html`<fieldset class="field"><legend class="label">${tx('qb.rows_match')}</legend><div class="radio-group">
353
+ <label class="check"><input type="radio" name="any" value="and"${check(!spec.any)}> ${tx('qb.all')}</label>
354
+ <label class="check"><input type="radio" name="any" value="or"${check(spec.any)}> ${tx('qb.any')}</label></div></fieldset>
349
355
  ${wheres.map((w, k) => html`<div class="qb-cond">
350
- <select name="wc" aria-label="Condition ${k + 1}: column">${colOptions(w.column)}</select>
351
- <select name="wo" aria-label="Condition ${k + 1}: operator">${Object.entries(OPERATORS).map(([op, label]) => html`<option value="${op}"${sel(op === w.op)}>${op} · ${label}</option>`)}</select>
352
- <input name="wv" value="${w.value}" aria-label="Condition ${k + 1}: value"></div>`)}
353
- <div class="buttons"><button class="btn">${icon('plus')} Apply / add a condition</button></div>`)}
356
+ <select name="wc" aria-label="${tx('qb.cond_column', { n: k + 1 })}">${colOptions(w.column)}</select>
357
+ <select name="wo" aria-label="${tx('qb.cond_operator', { n: k + 1 })}">${Object.entries(OPERATORS).map(([op, label]) => html`<option value="${op}"${sel(op === w.op)}>${op} · ${tx.dyn(`qb.op.${op.replace(/[^a-z]+/g, '_') || { '=': 'eq', '<>': 'ne', '<': 'lt', '<=': 'le', '>': 'gt', '>=': 'ge' }[op]}`, label)}</option>`)}</select>
358
+ <input name="wv" value="${w.value}" aria-label="${tx('qb.cond_value', { n: k + 1 })}"></div>`)}
359
+ <div class="buttons"><button class="btn">${icon('plus')} ${tx('qb.add_condition')}</button></div>`)}
354
360
  <div class="u-spacer"></div>
355
- ${region(`${built.joins.length ? 5 : 4}. Sort and limit`, html`${orders.map((o, k) => html`<div class="qb-cond">
356
- <select name="oc" aria-label="Sort ${k + 1}: column">${colOptions(o.column)}</select>
357
- <select name="od" aria-label="Sort ${k + 1}: direction"><option value="asc">ascending</option><option value="desc"${sel(o.dir === 'desc')}>descending</option></select><span></span></div>`)}
358
- <div class="form-grid"><div class="field"><label class="label" for="f_limit">At most (rows)</label><input id="f_limit" name="limit" type="number" min="1" max="100000" value="${spec.limit ?? ''}"></div></div>
359
- <div class="buttons"><button class="btn btn-hot">${icon('check')} Apply</button></div>`)}`
361
+ ${region(`${built.joins.length ? 5 : 4}. ${tx('qb.sort_limit')}`, html`${orders.map((o, k) => html`<div class="qb-cond">
362
+ <select name="oc" aria-label="${tx('qb.sort_column', { n: k + 1 })}">${colOptions(o.column)}</select>
363
+ <select name="od" aria-label="${tx('qb.sort_dir', { n: k + 1 })}"><option value="asc">${tx('qb.asc')}</option><option value="desc"${sel(o.dir === 'desc')}>${tx('qb.desc')}</option></select><span></span></div>`)}
364
+ <div class="form-grid"><div class="field"><label class="label" for="f_limit">${tx('qb.limit')}</label><input id="f_limit" name="limit" type="number" min="1" max="100000" value="${spec.limit ?? ''}"></div></div>
365
+ <div class="buttons"><button class="btn btn-hot">${icon('check')} ${tx('qb.apply')}</button></div>`)}`
360
366
  : ''}
361
367
  </form>`;
362
368
 
363
369
  const output = built
364
- ? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
370
+ ? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('qs.generated')}">${built.sql}</pre>
365
371
  <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${built.sql}">
366
- <div class="buttons"><button class="btn btn-hot">${icon('play')} Run in the SQL Worksheet</button></div></form>`)}`
372
+ <div class="buttons"><button class="btn btn-hot">${icon('play')} ${tx('qb.run')}</button></div></form>`)}`
367
373
  : '';
368
- return send(reply, s, shell(s, 'Query Builder', [['SQL Workshop', `${BASE}/sql`], ['Query Builder']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('query', s)}${form}${output}`, 'sql'));
374
+ return send(reply, s, shell(s, tx('sql.tool.query'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.query')]], html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('query', s)}${form}${output}`, 'sql'));
369
375
  });
370
376
  }
@@ -7,6 +7,7 @@ import { clientIp, type Session } from '../session.ts';
7
7
  import { runAndRecord, runOptions, SCRIPT_MAX_MB } from './scripts.ts';
8
8
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
9
  import { currentWorkspace } from './workspaces.ts';
10
+ import { tr } from './i18n.ts';
10
11
 
11
12
  // SQL Workshop → Quick SQL: shorthand → DDL preview, then save it as a SQL
12
13
  // script or run it (as a script, so the results per statement are kept).
@@ -27,32 +28,29 @@ view department_employees departments employees
27
28
 
28
29
  export async function quickSqlRoutes(app: FastifyInstance) {
29
30
  const render = (s: Session, reply: FastifyReply, src: string, body: Record<string, string | undefined>, error?: string) => {
31
+ const tx = tr(s);
30
32
  const { ddl, warnings } = quickSql(src);
31
- const syntax = html`<details class="u-mt1"><summary>Syntax</summary><ul class="small">
32
- <li>A table name on a line of its own, its columns indented below it. A table indented under another table is a child table: it gets <code>&lt;parent&gt;_id</code> referencing the parent.</li>
33
- <li>Column types from the name (<code>*_id</code> bigint, <code>*_at</code> timestamptz, <code>*_date</code>/<code>*_on</code> date, <code>is_*</code> boolean, <code>price</code>/<code>amount</code> numeric, otherwise text) or written after it: <code>vc200</code>, <code>num</code>, <code>num(10,2)</code>, <code>int</code>, <code>date</code>, <code>ts</code>, <code>tstz</code>, <code>clob</code>, <code>blob</code>, <code>json</code>, <code>bool</code>, <code>uuid</code>.</li>
34
- <li>Column directives: <code>/nn</code>, <code>/pk</code>, <code>/unique</code>, <code>/idx</code>, <code>/fk table</code>, <code>/check a, b</code>, <code>/between 1 and 10</code>, <code>/default value</code>, <code>/lower</code>, <code>/upper</code>; <code>[a comment]</code>. Table directive: <code>/auditcols</code>.</li>
35
- <li>Settings: <code># pk: identity | seq | guid | none</code>, <code># schema: name</code>, <code># prefix: xx</code>, <code># drop: true</code>, <code># auditcols: true</code>, or <code># settings = { pk: "guid", schema: "app" }</code>.</li>
36
- <li><code>view name table1 table2</code>: a view joining the tables by their foreign keys. <code>--</code> starts a comment.</li></ul></details>`;
33
+ const syntax = html`<details class="u-mt1"><summary>${tx('qs.syntax')}</summary><ul class="small">
34
+ ${(['qs.syntax.tables', 'qs.syntax.types', 'qs.syntax.directives', 'qs.syntax.settings', 'qs.syntax.views'] as const).map((k) => html`<li>${tx.code(k)}</li>`)}</ul></details>`;
37
35
  const main = html`<form method="post" action="${BASE}/sql/quick">${csrf(s)}
38
36
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
39
37
  <div class="qs-columns">
40
- ${region('Quick SQL', html`<label class="sr-only" for="f_source">Quick SQL</label>
38
+ ${region(tx('sql.tool.quicksql'), html`<label class="sr-only" for="f_source">${tx('sql.tool.quicksql')}</label>
41
39
  <textarea id="f_source" name="source" class="code" rows="22" spellcheck="false">${src}</textarea>
42
- <div class="buttons"><button class="btn" name="action" value="preview">${icon('play')} Generate SQL</button></div>${syntax}`)}
40
+ <div class="buttons"><button class="btn" name="action" value="preview">${icon('play')} ${tx('qs.generate')}</button></div>${syntax}`)}
43
41
  ${region('PostgreSQL', html`${warnings.length
44
- ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${warnings.map((w) => html`<li>Line ${w.line}: ${w.message}</li>`)}</ul></div>`
42
+ ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${warnings.map((w) => html`<li>${tx('qs.line', { line: w.line, message: w.message })}</li>`)}</ul></div>`
45
43
  : ''}
46
- <pre class="source ddl" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${ddl}</pre>`)}
44
+ <pre class="source ddl" data-code-view="sql" tabindex="0" aria-label="${tx('qs.generated')}">${ddl}</pre>`)}
47
45
  </div>
48
46
  <div class="u-spacer"></div>
49
- ${region('Save or run', html`<div class="form-grid"><div class="field"><label class="label" for="f_name">Script name</label>
47
+ ${region(tx('qs.save_or_run'), html`<div class="form-grid"><div class="field"><label class="label" for="f_name">${tx('qs.script_name')}</label>
50
48
  <input id="f_name" name="name" value="${body.name ?? 'Quick SQL'}" maxlength="200"></div></div>
51
- ${runOptions(body)}
52
- <div class="buttons"><button class="btn" name="action" value="save">${icon('file')} Save as script</button>
53
- <button class="btn btn-hot" name="action" value="run">${icon('play')} Run</button></div>`)}
49
+ ${runOptions(body, tx)}
50
+ <div class="buttons"><button class="btn" name="action" value="save">${icon('file')} ${tx('qs.save_script')}</button>
51
+ <button class="btn btn-hot" name="action" value="run">${icon('play')} ${tx('qs.run')}</button></div>`)}
54
52
  </form>`;
55
- return send(reply, s, shell(s, 'Quick SQL', [['SQL Workshop', `${BASE}/sql`], ['Quick SQL']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('quicksql', s)}${main}`, 'sql'));
53
+ return send(reply, s, shell(s, tx('sql.tool.quicksql'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.quicksql')]], html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('quicksql', s)}${main}`, 'sql'));
56
54
  };
57
55
 
58
56
  app.get(`${BASE}/sql/quick`, async (req: Req, reply) => {
@@ -68,7 +66,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
68
66
  const src = (b.source ?? '').replace(/\r\n/g, '\n');
69
67
  if (Buffer.byteLength(src) > 1024 * 1024) {
70
68
  reply.code(422);
71
- return render(s, reply, '', b, 'The Quick SQL is larger than 1 MB.');
69
+ return render(s, reply, '', b, tr(s)('qs.too_large'));
72
70
  }
73
71
  if (b.action !== 'save' && b.action !== 'run') return render(s, reply, src, b);
74
72
  const { ddl } = quickSql(src);
@@ -78,7 +76,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
78
76
  .join('\n')}\n\n${ddl}`;
79
77
  if (Buffer.byteLength(content) > SCRIPT_MAX_MB * 1024 * 1024) {
80
78
  reply.code(422);
81
- return render(s, reply, src, b, 'The generated script is too large.');
79
+ return render(s, reply, src, b, tr(s)('qs.script_too_large'));
82
80
  }
83
81
  const name = (b.name ?? '').trim().slice(0, 200) || 'Quick SQL';
84
82
  if (b.action === 'run') {
@@ -88,7 +86,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
88
86
  let unique = name;
89
87
  for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [unique, currentWorkspace(s)]); n++) unique = `${name} (${n})`;
90
88
  const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [unique, content, s.username, currentWorkspace(s)]);
91
- flash(s, `Saved as script “${unique}”.`);
89
+ flash(s, tr(s)('qs.saved', { name: unique }));
92
90
  return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
93
91
  });
94
92
  }