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
@@ -8,6 +8,7 @@ import { runScript, splitScript, type ScriptRun, type StatementResult } from '..
8
8
  import { clearCompletions } from './code-editor.ts';
9
9
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
10
10
  import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
11
+ import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
11
12
 
12
13
  // SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
13
14
  // delete), run with a result per statement (stop at the first error or go on,
@@ -73,54 +74,55 @@ const when = (t: string | Date) => String(t instanceof Date ? t.toISOString() :
73
74
  /** A safe download file name for a script name. */
74
75
  export const scriptFileName = (name: string) => `${name.replace(/\.sql$/i, '').replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '').slice(0, 100) || 'script'}.sql`;
75
76
 
76
- function statusTag(r: Pick<StatementResult, 'status'>) {
77
+ function statusTag(tx: BuilderTranslate, r: Pick<StatementResult, 'status'>) {
77
78
  const [cls, label] =
78
- r.status === 'ok' ? ['tag tag-ok', 'OK'] : r.status === 'error' ? ['tag tag-error', 'Error'] : r.status === 'skipped' ? ['tag tag-warning', 'Skipped'] : ['tag', 'Not run'];
79
+ r.status === 'ok' ? ['tag tag-ok', 'OK'] : r.status === 'error' ? ['tag tag-error', tx('scr.error')] : r.status === 'skipped' ? ['tag tag-warning', tx('scr.skipped')] : ['tag', tx('scr.not_run')];
79
80
  return html`<span class="${cls}">${label}</span>`;
80
81
  }
81
82
 
82
- function runSummary(run: { statements: number; succeeded: number; failed: number; rolled_back?: boolean; transactional?: boolean; stop_on_error?: boolean }) {
83
+ function runSummary(tx: BuilderTranslate, run: { statements: number; succeeded: number; failed: number; rolled_back?: boolean; transactional?: boolean; stop_on_error?: boolean }) {
83
84
  const ok = run.failed === 0;
85
+ const rest = run.statements - run.succeeded - run.failed;
84
86
  return html`<div class="alert ${ok ? 'alert-success' : 'alert-error'}" role="${ok ? 'status' : 'alert'}">
85
- ${run.statements} statement(s): ${run.succeeded} succeeded, ${run.failed} failed${run.statements - run.succeeded - run.failed > 0 ? `, ${run.statements - run.succeeded - run.failed} not run or skipped` : ''}.
86
- ${run.rolled_back ? html` <strong>The transaction was rolled back: nothing was changed.</strong>` : ''}</div>`;
87
+ ${rest > 0 ? tx('scr.summary_rest', { n: run.statements, ok: run.succeeded, failed: run.failed, rest }) : tx('scr.summary', { n: run.statements, ok: run.succeeded, failed: run.failed })}
88
+ ${run.rolled_back ? html` <strong>${tx('scr.rolled_back_note')}</strong>` : ''}</div>`;
87
89
  }
88
90
 
89
- export function resultsHtml(results: StatementResult[]) {
90
- if (!results.length) return html`<p class="muted">The script has no statements.</p>`;
91
+ export function resultsHtml(results: StatementResult[], tx: BuilderTranslate = translatorFor('en')) {
92
+ if (!results.length) return html`<p class="muted">${tx('scr.no_statements')}</p>`;
91
93
  return html`<ol class="script-results">${results.map(
92
94
  (r) => html`<li class="script-result">
93
- <div class="script-result-head">${statusTag(r)} <strong>#${r.n || '-'}</strong>
94
- <span class="muted small">${r.line ? `line ${r.line}` : ''}${r.status === 'ok' ? ` · ${r.command ?? 'done'}${r.rows !== null && r.rows !== undefined ? `, ${r.rows} row(s)` : ''}` : ''}${r.status === 'ok' || r.status === 'error' ? ` · ${r.ms} ms` : ''}</span></div>
95
- <details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="Statement">${r.sql}</pre></details>
95
+ <div class="script-result-head">${statusTag(tx, r)} <strong>#${r.n || '-'}</strong>
96
+ <span class="muted small">${r.line ? tx('scr.line', { line: r.line }) : ''}${r.status === 'ok' ? ` · ${r.command ?? tx('scr.done')}${r.rows !== null && r.rows !== undefined ? `, ${tx('scr.rows', { n: r.rows })}` : ''}` : ''}${r.status === 'ok' || r.status === 'error' ? ` · ${r.ms} ms` : ''}</span></div>
97
+ <details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('sm.statement')}">${r.sql}</pre></details>
96
98
  ${r.error ? html`<div class="alert ${r.status === 'error' ? 'alert-error' : 'alert-note'}">${r.error}</div>` : ''}
97
99
  ${r.columns?.length
98
100
  ? html`<div class="table-wrap"><table class="report"><thead><tr>${r.columns.map((c) => html`<th>${c}</th>`)}</tr></thead>
99
101
  <tbody>${(r.sample ?? []).map((row) => html`<tr>${row.map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : v}</td>`)}</tr>`)}</tbody></table></div>
100
- ${r.rows && r.sample && r.rows > r.sample.length ? html`<p class="muted small">First ${r.sample.length} of ${r.rows} row(s).</p>` : ''}`
102
+ ${r.rows && r.sample && r.rows > r.sample.length ? html`<p class="muted small">${tx('scr.first_rows', { shown: r.sample.length, n: r.rows })}</p>` : ''}`
101
103
  : ''}
102
104
  </li>`,
103
105
  )}</ol>`;
104
106
  }
105
107
 
106
108
  /** Run options (radio + checkbox), shared by the script editor and Quick SQL. */
107
- export const runOptions = (b: Body = {}) => html`<fieldset class="field"><legend class="label">When a statement fails</legend><div class="radio-group">
108
- <label class="check"><input type="radio" name="on_error" value="stop"${b.on_error !== 'continue' ? raw(' checked') : ''}> Stop the script</label>
109
- <label class="check"><input type="radio" name="on_error" value="continue"${b.on_error === 'continue' ? raw(' checked') : ''}> Continue with the next statement</label></div></fieldset>
110
- <div class="field"><label class="check"><input type="checkbox" name="transaction" value="true"${b.transaction === 'true' ? raw(' checked') : ''}> Run in one transaction (with “Stop”, a failure rolls back the whole script)</label></div>`;
109
+ export const runOptions = (b: Body = {}, tx: BuilderTranslate = translatorFor('en')) => html`<fieldset class="field"><legend class="label">${tx('scr.when_fails')}</legend><div class="radio-group">
110
+ <label class="check"><input type="radio" name="on_error" value="stop"${b.on_error !== 'continue' ? raw(' checked') : ''}> ${tx('scr.stop')}</label>
111
+ <label class="check"><input type="radio" name="on_error" value="continue"${b.on_error === 'continue' ? raw(' checked') : ''}> ${tx('scr.continue')}</label></div></fieldset>
112
+ <div class="field"><label class="check"><input type="checkbox" name="transaction" value="true"${b.transaction === 'true' ? raw(' checked') : ''}> ${tx('scr.one_tx')}</label></div>`;
111
113
 
112
114
  export async function scriptRoutes(app: FastifyInstance) {
113
115
  const page = (s: Session, reply: FastifyReply, title: string, crumbs: [string, string?][], main: Raw) =>
114
- send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ...crumbs], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('scripts', s)}${main}`, 'sql'));
116
+ send(reply, s, shell(s, title, [[tr(s)('rail.sql_workshop'), `${BASE}/sql`], ...crumbs], html`<h1 class="u-mb1">${tr(s)('rail.sql_workshop')}</h1>${workshopTabs('scripts', s)}${main}`, 'sql'));
115
117
 
116
- const historyTable = (runs: any[], withName: boolean) =>
118
+ const historyTable = (tx: BuilderTranslate, runs: any[], withName: boolean) =>
117
119
  runs.length
118
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Started</th>${withName ? html`<th>Script</th>` : ''}<th>By</th><th class="num">Statements</th><th class="num">Failed</th><th class="num">ms</th><th>Result</th></tr></thead><tbody>
120
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('scr.col.started')}</th>${withName ? html`<th>${tx('scr.col.script')}</th>` : ''}<th>${tx('scr.col.by')}</th><th class="num">${tx('scr.col.statements')}</th><th class="num">${tx('scr.col.failed')}</th><th class="num">ms</th><th>${tx('scr.col.result')}</th></tr></thead><tbody>
119
121
  ${runs.map((r) => html`<tr><td><a href="${BASE}/sql/scripts/runs/${r.id}">${when(r.started_at)}</a></td>${withName ? html`<td>${r.script_name}</td>` : ''}<td>${r.run_by}</td>
120
122
  <td class="num">${r.statements}</td><td class="num">${r.failed}</td><td class="num">${r.elapsed_ms}</td>
121
- <td>${r.failed ? html`<span class="tag tag-error">${r.rolled_back ? 'Rolled back' : 'Errors'}</span>` : html`<span class="tag tag-ok">OK</span>`}</td></tr>`)}
123
+ <td>${r.failed ? html`<span class="tag tag-error">${r.rolled_back ? tx('scr.rolled_back') : tx('scr.errors')}</span>` : html`<span class="tag tag-ok">OK</span>`}</td></tr>`)}
122
124
  </tbody></table></div>`
123
- : html`<p class="muted">No runs yet.</p>`;
125
+ : html`<p class="muted">${tx('scr.no_runs')}</p>`;
124
126
 
125
127
  // ------------------------------------------------------------ list
126
128
  app.get(`${BASE}/sql/scripts`, async (req: Req, reply) => {
@@ -138,29 +140,30 @@ export async function scriptRoutes(app: FastifyInstance) {
138
140
  )
139
141
  ).rows;
140
142
  const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
143
+ const tx = tr(s);
141
144
  const list = scripts.length
142
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>Open</th></tr></thead><tbody>
145
+ ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('scr.col.script')}</th><th>${tx('scr.col.updated')}</th><th class="num">${tx('scr.col.size')}</th><th>${tx('scr.col.last_run')}</th><th>${tx('scr.col.open')}</th></tr></thead><tbody>
143
146
  ${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
144
- <td>${when(x.updated_at)}${x.updated_by ? html` <span class="muted small">by ${x.updated_by}</span>` : ''}</td><td class="num">${bytes(Number(x.size))}</td>
145
- <td>${x.last_run ? html`${when(x.last_run)} ${x.last_failed ? html`<span class="tag tag-error">Errors</span>` : html`<span class="tag tag-ok">OK</span>`}` : html`<span class="muted">never</span>`}</td>
146
- <td><a class="btn btn-sm" href="${BASE}/sql/worksheet?script=${x.id}">${icon('code')} Worksheet</a> <a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} Download</a></td></tr>`)}
147
+ <td>${when(x.updated_at)}${x.updated_by ? html` <span class="muted small">${tx('scr.by', { user: x.updated_by })}</span>` : ''}</td><td class="num">${bytes(Number(x.size))}</td>
148
+ <td>${x.last_run ? html`${when(x.last_run)} ${x.last_failed ? html`<span class="tag tag-error">${tx('scr.errors')}</span>` : html`<span class="tag tag-ok">OK</span>`}` : html`<span class="muted">${tx('scr.never')}</span>`}</td>
149
+ <td><a class="btn btn-sm" href="${BASE}/sql/worksheet?script=${x.id}">${icon('code')} ${tx('scr.worksheet')}</a> <a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} ${tx('scr.download')}</a></td></tr>`)}
147
150
  </tbody></table></div>`
148
- : html`<p class="muted">No scripts yet. Create one, upload a .sql file, or generate one with Quick SQL.</p>`;
151
+ : html`<p class="muted">${tx('scr.none_yet')}</p>`;
149
152
  return page(
150
153
  s,
151
154
  reply,
152
- 'SQL Scripts',
153
- [['SQL Scripts']],
154
- html`<div class="buttons u-mb1"><a class="btn btn-hot" href="${BASE}/sql/scripts/new">${icon('plus')} Create script</a>
155
- <a class="btn" href="${BASE}/sql/quick">${icon('bolt')} Quick SQL</a></div>
156
- ${region('Scripts', list, html`<span class="count">${scripts.length}</span>`)}
155
+ tx('sql.tool.scripts'),
156
+ [[tx('sql.tool.scripts')]],
157
+ html`<div class="buttons u-mb1"><a class="btn btn-hot" href="${BASE}/sql/scripts/new">${icon('plus')} ${tx('scr.create')}</a>
158
+ <a class="btn" href="${BASE}/sql/quick">${icon('bolt')} ${tx('sql.tool.quicksql')}</a></div>
159
+ ${region(tx('scr.scripts'), list, html`<span class="count">${scripts.length}</span>`)}
157
160
  <div class="u-spacer"></div>
158
- ${region('Upload a script', html`<form method="post" action="${BASE}/sql/scripts/upload" enctype="multipart/form-data">${csrf(s)}
159
- <div class="form-grid"><div class="field"><label class="label" for="f_script_file">.sql file (UTF-8, up to ${SCRIPT_MAX_MB} MB)</label>
161
+ ${region(tx('scr.upload_title'), html`<form method="post" action="${BASE}/sql/scripts/upload" enctype="multipart/form-data">${csrf(s)}
162
+ <div class="form-grid"><div class="field"><label class="label" for="f_script_file">${tx('scr.file', { mb: SCRIPT_MAX_MB })}</label>
160
163
  <input type="file" id="f_script_file" name="file" accept=".sql,.txt,text/plain,application/sql" required></div></div>
161
- <div class="buttons"><button class="btn">${icon('upload')} Upload</button></div></form>`)}
164
+ <div class="buttons"><button class="btn">${icon('upload')} ${tx('scr.upload')}</button></div></form>`)}
162
165
  <div class="u-spacer"></div>
163
- ${region('Recent runs', historyTable(runs, true))}`,
166
+ ${region(tx('scr.recent_runs'), historyTable(tx, runs, true))}`,
164
167
  );
165
168
  });
166
169
 
@@ -170,30 +173,31 @@ export async function scriptRoutes(app: FastifyInstance) {
170
173
  const action = isNew ? `${BASE}/sql/scripts` : `${BASE}/sql/scripts/${row.id}`;
171
174
  const runs = isNew ? [] : (await owner.query('select * from meta.sql_script_run where script_id = $1 order by id desc limit 20', [row.id])).rows;
172
175
  const statements = row.content ? splitScript(row.content).length : 0;
176
+ const tx = tr(s);
173
177
  const form = html`<form method="post" action="${action}">${csrf(s)}
174
178
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
175
179
  <div class="form-grid">
176
- <div class="field"><label class="label" for="f_name">Name</label><input id="f_name" name="name" value="${row.name ?? ''}" required maxlength="200"></div>
177
- <div class="field"><label class="label" for="f_description">Description</label><input id="f_description" name="description" value="${row.description ?? ''}"></div>
180
+ <div class="field"><label class="label" for="f_name">${tx('scr.name')}</label><input id="f_name" name="name" value="${row.name ?? ''}" required maxlength="200"></div>
181
+ <div class="field"><label class="label" for="f_description">${tx('scr.description')}</label><input id="f_description" name="description" value="${row.description ?? ''}"></div>
178
182
  </div>
179
- <div class="field"><label class="label" for="f_content">Script</label>
183
+ <div class="field"><label class="label" for="f_content">${tx('scr.col.script')}</label>
180
184
  <textarea id="f_content" name="content" class="code sql-editor" rows="18" spellcheck="false" data-code="plpgsql">${row.content ?? ''}</textarea>
181
- <small class="help">Statements end with a semicolon (outside strings, comments and $$ bodies). psql commands (\\set, \\i …) are skipped.${statements ? ` ${statements} statement(s).` : ''}</small></div>
182
- ${runOptions(req.body ?? {})}
183
- <div class="buttons"><button class="btn" name="action" value="save">${icon('check')} Save</button>
184
- <button class="btn btn-hot" name="action" value="run">${icon('play')} Save and run</button></div>
185
+ <small class="help">${tx('scr.content_help')}${statements ? ` ${tx('scr.statements', { n: statements })}` : ''}</small></div>
186
+ ${runOptions(req.body ?? {}, tx)}
187
+ <div class="buttons"><button class="btn" name="action" value="save">${icon('check')} ${tx('scr.save')}</button>
188
+ <button class="btn btn-hot" name="action" value="run">${icon('play')} ${tx('scr.save_run')}</button></div>
185
189
  </form>`;
186
190
  const extras = isNew
187
191
  ? ''
188
- : html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${row.id}/download">${icon('download')} Download</a></div>
189
- <form method="post" action="${BASE}/sql/scripts/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete this script and its run history?">Delete</button></form>`;
192
+ : html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${row.id}/download">${icon('download')} ${tx('scr.download')}</a></div>
193
+ <form method="post" action="${BASE}/sql/scripts/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('scr.delete_confirm')}">${tx('scr.delete')}</button></form>`;
190
194
  return page(
191
195
  s,
192
196
  reply,
193
- isNew ? 'New script' : row.name,
194
- [['SQL Scripts', `${BASE}/sql/scripts`], [isNew ? 'New script' : row.name]],
195
- html`${region(isNew ? 'New script' : `Script: ${row.name}`, html`${form}${extras}`)}
196
- ${isNew ? '' : html`<div class="u-spacer"></div>${region('Runs', historyTable(runs, false))}`}`,
197
+ isNew ? tx('scr.new') : row.name,
198
+ [[tx('sql.tool.scripts'), `${BASE}/sql/scripts`], [isNew ? tx('scr.new') : row.name]],
199
+ html`${region(isNew ? tx('scr.new') : tx('scr.script_title', { name: row.name }), html`${form}${extras}`)}
200
+ ${isNew ? '' : html`<div class="u-spacer"></div>${region(tx('scr.runs'), historyTable(tx, runs, false))}`}`,
197
201
  );
198
202
  };
199
203
 
@@ -223,11 +227,11 @@ export async function scriptRoutes(app: FastifyInstance) {
223
227
  const row = { id: id ? Number(id) : undefined, name, description: b.description ?? '', content };
224
228
  if (!name || name.length > 200) {
225
229
  reply.code(422);
226
- return editor(req, reply, s, row, 'Give the script a name (at most 200 characters).');
230
+ return editor(req, reply, s, row, tr(s)('scr.err.name'));
227
231
  }
228
232
  if (Buffer.byteLength(content) > SCRIPT_MAX_MB * 1024 * 1024) {
229
233
  reply.code(422);
230
- return editor(req, reply, s, row, `The script is larger than ${SCRIPT_MAX_MB} MB.`);
234
+ return editor(req, reply, s, row, tr(s)('scr.err.size', { mb: SCRIPT_MAX_MB }));
231
235
  }
232
236
  let scriptId: number;
233
237
  try {
@@ -248,7 +252,7 @@ export async function scriptRoutes(app: FastifyInstance) {
248
252
  } catch (e) {
249
253
  if ((e as { code?: string }).code === '23505') {
250
254
  reply.code(422);
251
- return editor(req, reply, s, row, `A script named “${name}” already exists.`);
255
+ return editor(req, reply, s, row, tr(s)('scr.err.exists', { name }));
252
256
  }
253
257
  throw e;
254
258
  }
@@ -262,7 +266,7 @@ export async function scriptRoutes(app: FastifyInstance) {
262
266
  });
263
267
  return back(reply, s, `${BASE}/sql/scripts/runs/${runId}`);
264
268
  }
265
- flash(s, `Script “${name}” saved.`);
269
+ flash(s, tr(s)('scr.saved', { name }));
266
270
  return back(reply, s, `${BASE}/sql/scripts/${scriptId}`);
267
271
  };
268
272
  app.post(`${BASE}/sql/scripts`, save);
@@ -278,7 +282,7 @@ export async function scriptRoutes(app: FastifyInstance) {
278
282
  await c.query('delete from meta.sql_script_run where script_id = $1', [req.params.id]);
279
283
  return (await c.query('delete from meta.sql_script where id = $1 returning name', [req.params.id])).rows[0];
280
284
  });
281
- if (r) flash(s, `Script “${r.name}” deleted.`);
285
+ if (r) flash(s, tr(s)('scr.deleted', { name: r.name }));
282
286
  return back(reply, s, `${BASE}/sql/scripts`);
283
287
  });
284
288
 
@@ -306,20 +310,21 @@ export async function scriptRoutes(app: FastifyInstance) {
306
310
  flash(s, message, 'error');
307
311
  return back(reply, s, `${BASE}/sql/scripts`);
308
312
  };
309
- if (!file) return fail('Choose a .sql file.');
310
- if (file.truncated) return fail(`The file is larger than ${SCRIPT_MAX_MB} MB.`);
313
+ const tx = tr(s);
314
+ if (!file) return fail(tx('scr.err.choose'));
315
+ if (file.truncated) return fail(tx('scr.err.file_size', { mb: SCRIPT_MAX_MB }));
311
316
  let content: string;
312
317
  try {
313
318
  content = new TextDecoder('utf-8', { fatal: true }).decode(file.data).replace(/^/, '').replace(/\r\n/g, '\n');
314
319
  } catch {
315
- return fail('The file is not UTF-8 text.');
320
+ return fail(tx('scr.err.utf8'));
316
321
  }
317
- if (content.includes('\0')) return fail('The file is not a text file.');
322
+ if (content.includes('\0')) return fail(tx('scr.err.text'));
318
323
  const base = file.filename.replace(/^.*[\\/]/, '').replace(/\.sql$/i, '').slice(0, 180) || 'script';
319
324
  let name = base;
320
325
  for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [name, currentWorkspace(s)]); n++) name = `${base} (${n})`;
321
326
  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`, [name, content, s.username, currentWorkspace(s)]);
322
- flash(s, `Script “${name}” uploaded.`);
327
+ flash(s, tx('scr.uploaded', { name }));
323
328
  return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
324
329
  });
325
330
 
@@ -330,18 +335,19 @@ export async function scriptRoutes(app: FastifyInstance) {
330
335
  const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1 and workspace_id = $2', [req.params.run, currentWorkspace(s)]) : undefined;
331
336
  if (!run) return reply.code(404).send('Not found');
332
337
  const exists = run.script_id ? await owner.one('select id from meta.sql_script where id = $1', [run.script_id]) : undefined;
333
- const options = `${run.stop_on_error ? 'stop on error' : 'continue on error'}${run.transactional ? ', one transaction' : ''}`;
338
+ const tx = tr(s);
339
+ const options = `${run.stop_on_error ? tx('scr.opt.stop') : tx('scr.opt.continue')}${run.transactional ? `, ${tx('scr.opt.one_tx')}` : ''}`;
334
340
  return page(
335
341
  s,
336
342
  reply,
337
- 'Script results',
338
- [['SQL Scripts', `${BASE}/sql/scripts`], ...(exists ? [[run.script_name, `${BASE}/sql/scripts/${run.script_id}`] as [string, string]] : []), ['Results']],
343
+ tx('scr.results_title'),
344
+ [[tx('sql.tool.scripts'), `${BASE}/sql/scripts`], ...(exists ? [[run.script_name, `${BASE}/sql/scripts/${run.script_id}`] as [string, string]] : []), [tx('scr.results')]],
339
345
  region(
340
- `Results: ${run.script_name}`,
341
- html`<p class="muted">Run by ${run.run_by} at ${when(run.started_at)} in ${run.elapsed_ms} ms (${options}).</p>
342
- ${runSummary(run)}
343
- ${resultsHtml(run.results)}
344
- ${exists ? html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${run.script_id}">${icon('edit')} Edit the script</a></div>` : ''}`,
346
+ tx('scr.results_of', { name: run.script_name }),
347
+ html`<p class="muted">${tx('scr.run_by', { user: run.run_by, time: when(run.started_at), ms: run.elapsed_ms, options })}</p>
348
+ ${runSummary(tx, run)}
349
+ ${resultsHtml(run.results, tx)}
350
+ ${exists ? html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${run.script_id}">${icon('edit')} ${tx('scr.edit')}</a></div>` : ''}`,
345
351
  ),
346
352
  );
347
353
  });
@@ -10,6 +10,7 @@ import type { Session } from '../session.ts';
10
10
  import { unloadStatement } from '../unload.ts';
11
11
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
12
12
  import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
13
+ import { tr, translatorFor, type BuilderKey, type BuilderTranslate } from './i18n.ts';
13
14
 
14
15
  // (sprint 97, Database Actions DA-6: Create Chart and Charts) A query drawn as a chart with pgkiln's own
15
16
  // server-rendered charts (src/runtime/charts.ts): the worksheet's Chart tab draws the last query, guessing the label
@@ -17,7 +18,7 @@ import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
17
18
  // migration 110) are listed under SQL Workshop → Charts, the ones on the dashboard drawn there. A saved chart's
18
19
  // SELECT runs read-only, with a statement timeout and at most 500 rows, as the workshop's connection.
19
20
 
20
- export const SQL_CHART_KINDS: [ChartKind, string][] = [['bar', 'Bar'], ['column', 'Column'], ['stacked', 'Stacked bar'], ['line', 'Line'], ['area', 'Area'], ['donut', 'Donut'], ['pie', 'Pie'], ['scatter', 'Scatter']];
21
+ export const SQL_CHART_KINDS: [ChartKind, BuilderKey][] = [['bar', 'ch.kind.bar'], ['column', 'ch.kind.column'], ['stacked', 'ch.kind.stacked'], ['line', 'ch.kind.line'], ['area', 'ch.kind.area'], ['donut', 'ch.kind.donut'], ['pie', 'ch.kind.pie'], ['scatter', 'ch.kind.scatter']];
21
22
  const MAX_ROWS = 500;
22
23
 
23
24
  export interface ChartSpec { kind: ChartKind; label: string; values: string[] }
@@ -42,11 +43,11 @@ export function drawChart(title: string, columns: string[], rows: unknown[][], s
42
43
  }
43
44
 
44
45
  /** The controls of a chart (type, label column, value columns), named chart_*. */
45
- export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch'): Raw {
46
+ export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch', tx: BuilderTranslate = translatorFor('en')): Raw {
46
47
  return html`<div class="ch-controls">
47
- <label><span>Type</span><select name="chart_kind" id="${idPrefix}-kind">${SQL_CHART_KINDS.map(([k, label]) => html`<option value="${k}"${k === spec.kind ? raw(' selected') : ''}>${label}</option>`)}</select></label>
48
- <label><span>Labels</span><select name="chart_label" id="${idPrefix}-label">${columns.map((c) => html`<option value="${c}"${c === spec.label ? raw(' selected') : ''}>${c}</option>`)}</select></label>
49
- <fieldset><legend>Values</legend>${columns.map((c) => html`<label class="check"><input type="checkbox" name="chart_values" value="${c}"${spec.values.includes(c) ? raw(' checked') : ''}> ${c}</label>`)}</fieldset>
48
+ <label><span>${tx('ch.type')}</span><select name="chart_kind" id="${idPrefix}-kind">${SQL_CHART_KINDS.map(([k, label]) => html`<option value="${k}"${k === spec.kind ? raw(' selected') : ''}>${tx(label)}</option>`)}</select></label>
49
+ <label><span>${tx('ch.labels')}</span><select name="chart_label" id="${idPrefix}-label">${columns.map((c) => html`<option value="${c}"${c === spec.label ? raw(' selected') : ''}>${c}</option>`)}</select></label>
50
+ <fieldset><legend>${tx('ch.values')}</legend>${columns.map((c) => html`<label class="check"><input type="checkbox" name="chart_values" value="${c}"${spec.values.includes(c) ? raw(' checked') : ''}> ${c}</label>`)}</fieldset>
50
51
  </div>`;
51
52
  }
52
53
 
@@ -73,12 +74,13 @@ async function runChart(s: Session, sql: string) {
73
74
  }
74
75
 
75
76
  async function chartCard(s: Session, ch: SavedChart, full = false): Promise<{ body: Raw; css: string }> {
77
+ const tx = tr(s);
76
78
  try {
77
79
  const r = await runChart(s, ch.sql);
78
80
  const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
79
- if (!spec) return { body: html`<p class="muted">The query has no number column to draw.</p>`, css: '' };
81
+ if (!spec) return { body: html`<p class="muted">${tx('ch.no_number')}</p>`, css: '' };
80
82
  const d = drawChart(ch.name, r.columns, r.rows, spec);
81
- return { body: html`${d.body}${full ? html`<p class="muted small">${r.rows.length} row${r.rows.length === 1 ? '' : 's'}${r.rows.length === MAX_ROWS ? ` (the first ${MAX_ROWS})` : ''}.</p>` : ''}`, css: d.css };
83
+ return { body: html`${d.body}${full ? html`<p class="muted small">${r.rows.length === MAX_ROWS ? tx('ch.rows_first', { n: MAX_ROWS }) : r.rows.length === 1 ? tx('ch.row_one') : tx('ch.rows', { n: r.rows.length })}</p>` : ''}`, css: d.css };
82
84
  } catch (e) {
83
85
  return { body: html`<p class="alert alert-error">${(e as Error).message}</p>`, css: '' };
84
86
  }
@@ -92,6 +94,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
92
94
  const s = await developer(req, reply);
93
95
  if (!s) return;
94
96
  const charts = (await owner.query<SavedChart>('select * from meta.sql_chart where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
97
+ const tx = tr(s);
95
98
  let css = '';
96
99
  const cards: Raw[] = [];
97
100
  for (const ch of charts.filter((c) => c.on_dashboard)) {
@@ -99,14 +102,15 @@ export async function sqlChartRoutes(app: FastifyInstance) {
99
102
  css += `\n${card.css}`;
100
103
  cards.push(html`<section class="ch-card"><h2><a href="${BASE}/sql/charts/${ch.id}">${ch.name}</a></h2>${card.body}</section>`);
101
104
  }
102
- const main = html`<h1 class="u-mb1">Charts</h1>${workshopTabs('charts', s)}${style(req.cspNonce, css)}
103
- <p class="muted">Queries drawn as charts. Make one from a query in the <a href="${BASE}/sql/worksheet">SQL Worksheet</a> (its Chart tab, Ctrl+F11), then save it.</p>
105
+ const kindLabel = (k: string) => { const x = SQL_CHART_KINDS.find(([kk]) => kk === k); return x ? tx(x[1]) : k; };
106
+ const main = html`<h1 class="u-mb1">${tx('sql.tool.charts')}</h1>${workshopTabs('charts', s)}${style(req.cspNonce, css)}
107
+ <p class="muted">${tx.rich('ch.intro', { worksheet: html`<a href="${BASE}/sql/worksheet">${tx('sql.tool.worksheet')}</a>` })}</p>
104
108
  ${cards.length ? html`<div class="ch-dashboard">${cards}</div>` : ''}
105
- ${region('All charts', charts.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Chart</th><th>Type</th><th>On the dashboard</th><th>Changed</th></tr></thead><tbody>
106
- ${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${SQL_CHART_KINDS.find(([k]) => k === c.kind)?.[1] ?? c.kind}</td><td>${c.on_dashboard ? 'Yes' : 'No'}</td>
107
- <td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small">by ${c.created_by}</span>` : ''}</td></tr>`)}</tbody></table></div>`
108
- : html`<p class="muted">No charts yet.</p>`)}`;
109
- return send(reply, s, shell(s, 'Charts', [['SQL Workshop', `${BASE}/sql`], ['Charts']], main, 'sql'));
109
+ ${region(tx('ch.all'), charts.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('ch.chart')}</th><th>${tx('ch.type')}</th><th>${tx('ch.on_dashboard_col')}</th><th>${tx('ch.changed')}</th></tr></thead><tbody>
110
+ ${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${kindLabel(c.kind)}</td><td>${c.on_dashboard ? tx('ch.yes') : tx('ch.no')}</td>
111
+ <td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small">${tx('scr.by', { user: c.created_by })}</span>` : ''}</td></tr>`)}</tbody></table></div>`
112
+ : html`<p class="muted">${tx('ch.none_yet')}</p>`)}`;
113
+ return send(reply, s, shell(s, tx('sql.tool.charts'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.charts')]], main, 'sql'));
110
114
  });
111
115
 
112
116
  // one chart: drawn, with its settings
@@ -116,26 +120,27 @@ export async function sqlChartRoutes(app: FastifyInstance) {
116
120
  const ch = /^\d{1,9}$/.test(String(req.params.id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
117
121
  if (!ch) return reply.code(404).send('Not found');
118
122
  const card = await chartCard(s, ch, true);
123
+ const tx = tr(s);
119
124
  let controls: Raw | '' = '';
120
125
  try {
121
126
  const r = await runChart(s, ch.sql);
122
127
  const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
123
- if (spec) controls = chartControls(r.columns, spec, 'sch');
128
+ if (spec) controls = chartControls(r.columns, spec, 'sch', tx);
124
129
  } catch {
125
130
  controls = '';
126
131
  }
127
132
  const main = html`<h1 class="u-mb1">${ch.name}</h1>${workshopTabs('charts', s)}${style(req.cspNonce, card.css)}
128
133
  <div class="ch-view">${card.body}</div>
129
134
  <form method="post" action="${BASE}/sql/charts/${ch.id}" class="ch-edit">${csrf(s)}
130
- <div class="field"><label class="label" for="ch-name">Name</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
135
+ <div class="field"><label class="label" for="ch-name">${tx('scr.name')}</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
131
136
  ${controls}
132
- <label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> On the Charts dashboard</label>
133
- <details class="u-mt1"><summary>The query</summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="The query">${ch.sql}</pre></details>
134
- <div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} Save</button>
135
- <button class="btn" name="action" value="worksheet">${icon('code')} Open in the worksheet</button>
136
- <button class="btn btn-danger" name="action" value="delete" data-confirm="Delete the chart ${ch.name}?">${icon('trash')} Delete</button></div>
137
+ <label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> ${tx('ch.on_dashboard')}</label>
138
+ <details class="u-mt1"><summary>${tx('ch.query')}</summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('ch.query')}">${ch.sql}</pre></details>
139
+ <div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} ${tx('scr.save')}</button>
140
+ <button class="btn" name="action" value="worksheet">${icon('code')} ${tx('ch.open_worksheet')}</button>
141
+ <button class="btn btn-danger" name="action" value="delete" data-confirm="${tx('ch.delete_confirm', { name: ch.name })}">${icon('trash')} ${tx('scr.delete')}</button></div>
137
142
  </form>`;
138
- return send(reply, s, shell(s, ch.name, [['SQL Workshop', `${BASE}/sql`], ['Charts', `${BASE}/sql/charts`], [ch.name]], main, 'sql'));
143
+ return send(reply, s, shell(s, ch.name, [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.charts'), `${BASE}/sql/charts`], [ch.name]], main, 'sql'));
139
144
  });
140
145
 
141
146
  // save a chart from the worksheet (a new one), or change, open or delete a saved one
@@ -144,6 +149,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
144
149
  if (!s) return;
145
150
  const b = req.body ?? {};
146
151
  const ws = currentWorkspace(s);
152
+ const tx = tr(s);
147
153
  const id = req.params?.id;
148
154
  const name = String(b.name ?? b.chart_name ?? '').trim();
149
155
  const asked = askedSpec(b);
@@ -153,7 +159,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
153
159
  if (!ch) return reply.code(404).send('Not found');
154
160
  if (b.action === 'delete') {
155
161
  await owner.query('delete from meta.sql_chart where id = $1', [ch.id]);
156
- flash(s, `Deleted the chart ${ch.name}.`);
162
+ flash(s, tx('ch.deleted', { name: ch.name }));
157
163
  return back(reply, s, `${BASE}/sql/charts`);
158
164
  }
159
165
  if (b.action === 'worksheet') {
@@ -161,7 +167,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
161
167
  return back(reply, s, `${BASE}/sql/worksheet`);
162
168
  }
163
169
  if (!name || name.length > 120) {
164
- flash(s, 'A chart needs a name (at most 120 characters).', 'error');
170
+ flash(s, tx('ch.err.name'), 'error');
165
171
  return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
166
172
  }
167
173
  try {
@@ -169,32 +175,32 @@ export async function sqlChartRoutes(app: FastifyInstance) {
169
175
  [ch.id, name, kind, asked.label ?? ch.label_col, asked.values?.length ? asked.values : ch.value_cols, b.on_dashboard === 'on']);
170
176
  } catch (e) {
171
177
  if ((e as { code?: string }).code !== '23505') throw e;
172
- flash(s, `A chart named “${name}” exists already.`, 'error');
178
+ flash(s, tx('ch.err.exists', { name }), 'error');
173
179
  return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
174
180
  }
175
- flash(s, `Saved the chart ${name}.`);
181
+ flash(s, tx('ch.saved', { name }));
176
182
  return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
177
183
  }
178
184
  let sql = '';
179
185
  try {
180
186
  sql = unloadStatement(String(b.chart_sql ?? ''));
181
187
  } catch {
182
- flash(s, 'Only a chart of one SELECT can be saved.', 'error');
188
+ flash(s, tx('ch.err.select'), 'error');
183
189
  return back(reply, s, `${BASE}/sql/worksheet`);
184
190
  }
185
191
  if (!name || name.length > 120) {
186
- flash(s, 'Name the chart to save it (at most 120 characters).', 'error');
192
+ flash(s, tx('ch.err.name_new'), 'error');
187
193
  return back(reply, s, `${BASE}/sql/worksheet`);
188
194
  }
189
195
  try {
190
196
  const r = await owner.one<{ id: number }>(
191
197
  `insert into meta.sql_chart (workspace_id, name, sql, kind, label_col, value_cols, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
192
198
  [ws, name, sql, kind, asked.label ?? '', asked.values ?? [], s.username]);
193
- flash(s, `Saved the chart ${name}: it is on the Charts dashboard.`);
199
+ flash(s, tx('ch.saved_new', { name }));
194
200
  return back(reply, s, `${BASE}/sql/charts/${r!.id}`);
195
201
  } catch (e) {
196
202
  if ((e as { code?: string }).code !== '23505') throw e;
197
- flash(s, `A chart named “${name}” exists already.`, 'error');
203
+ flash(s, tx('ch.err.exists', { name }), 'error');
198
204
  return back(reply, s, `${BASE}/sql/worksheet`);
199
205
  }
200
206
  };