pgkiln 0.56.0 → 0.57.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.
@@ -6,6 +6,7 @@ import { owner } from '../db.ts';
6
6
  import { devDbForApp } from '../workspace-db.ts';
7
7
  import { clientIp, logActivity, type Session } from '../session.ts';
8
8
  import { back, bicon, BASE, csrf, developer, flash, send, shell, workshopTabs, type Req } from './ui.ts';
9
+ import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
9
10
  import { clearCompletions } from './code-editor.ts';
10
11
  import { createTemplate, listObjects, OB_TYPES, pickSchema, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
11
12
  import { objectMenu } from './object-actions.ts';
@@ -92,8 +93,8 @@ async function recordHistory(s: Session, run: WorksheetRun) {
92
93
  }
93
94
 
94
95
  /** The navigator's tree for a schema and an object type. */
95
- async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, type: string): Promise<Raw> {
96
- if (!schema) return html`<p class="muted ws-nav-empty">No schema you may use.</p>`;
96
+ async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, type: string, tx: BuilderTranslate): Promise<Raw> {
97
+ if (!schema) return html`<p class="muted ws-nav-empty">${tx('ws.nav.no_schema')}</p>`;
97
98
  let objects: (Obj | { type: 'policy'; name: string; ref: string; rls: false; ext: false })[] = await listObjects(db, schema, false);
98
99
  if (type === 'all' || type === 'policy') {
99
100
  const pol = (await db.query<{ name: string; ref: string }>(
@@ -114,26 +115,27 @@ async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema:
114
115
  for (const r of rows) cols.set(r.ref, [...(cols.get(r.ref) ?? []), r]);
115
116
  }
116
117
  const iconOf = (t: string) => (OB_TYPES.find(([k]) => k === t)?.[2] ?? 'shield');
117
- const label = (t: string) => NAV_TYPES.find(([k]) => k === t)?.[1] ?? t;
118
- if (!objects.length) return html`<p class="muted ws-nav-empty">No ${type === 'all' ? 'objects' : label(type).toLowerCase()} in ${schema}.</p>`;
118
+ const label = (t: string) => tx.dyn(`ob.type.${t}`, NAV_TYPES.find(([k]) => k === t)?.[1] ?? t);
119
+ // English lower-cases the type in the sentence; other languages keep the label as it is (German nouns)
120
+ if (!objects.length) return html`<p class="muted ws-nav-empty">${type === 'all' ? tx('ws.nav.no_objects', { schema }) : tx('ws.nav.no_type', { type: tx.lang === 'en' ? label(type).toLowerCase() : label(type), schema })}</p>`;
119
121
  return html`<ul class="ws-tree" id="ws-tree">${objects.map((o) => {
120
122
  const list = cols.get(o.ref);
121
123
  const obLink = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
122
124
  if (!list) return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}"><a href="${obLink}" title="${label(o.type)}">${icon(iconOf(o.type))}<span>${o.name}</span></a>${objectMenu(schema, o)}</li>`;
123
125
  const st = objectStatements(schema, o.name, list, o.type);
124
126
  return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}">${objectMenu(schema, o)}<details>
125
- <summary draggable="true" data-ws-sql="${st.select}" title="${label(o.type).replace(/s$/, '')}: drag into the editor for a SELECT">${icon(iconOf(o.type))}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}</summary>
127
+ <summary draggable="true" data-ws-sql="${st.select}" title="${tx('ws.nav.drag', { type: tx.lang === 'en' ? label(o.type).replace(/s$/, '') : label(o.type) })}">${icon(iconOf(o.type))}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}</summary>
126
128
  <ul class="ws-cols">${list.map((c) => html`<li title="${c.type}${c.notnull ? ' not null' : ''}">${c.pk ? icon('key') : icon('minus')}<span>${c.name}</span><small class="muted">${c.type}</small></li>`)}</ul>
127
129
  <div class="ws-obj-actions">
128
130
  ${Object.entries(st).map(([k, sql]) => html`<button type="button" class="btn btn-sm" data-ws-insert="${sql}" hidden>${k.toUpperCase()}</button>`)}
129
- <a class="btn btn-sm" href="${obLink}">Open</a>
131
+ <a class="btn btn-sm" href="${obLink}">${tx('ws.nav.open')}</a>
130
132
  </div>
131
133
  </details></li>`;
132
- })}</ul>${objects.length > rels.length && rels.length === MAX_COLUMN_OBJECTS ? html`<p class="muted small ws-nav-empty">Columns are listed for the first ${MAX_COLUMN_OBJECTS} tables and views.</p>` : ''}`;
134
+ })}</ul>${objects.length > rels.length && rels.length === MAX_COLUMN_OBJECTS ? html`<p class="muted small ws-nav-empty">${tx('ws.nav.cols_limit', { n: MAX_COLUMN_OBJECTS })}</p>` : ''}`;
133
135
  }
134
136
 
135
137
  /** The output panes of a run (empty: the page as opened). */
136
- function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string, chart: Raw | null): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
138
+ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string, chart: Raw | null, tx: BuilderTranslate): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
137
139
  const results = run?.results ?? [];
138
140
  const ran = results.filter((r) => r.status !== 'not_run');
139
141
  const query = [...ran].reverse().find((r) => r.columns);
@@ -141,111 +143,114 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
141
143
  const warnings = run?.notices.filter((n) => n.level === 'WARNING').length ?? 0;
142
144
  const oneLine = (sql: string) => { const x = sql.replace(/\s+/g, ' '); return x.length > 80 ? `${x.slice(0, 79)}…` : x; };
143
145
 
144
- const rowsLabel = (q: NonNullable<typeof query>) => `${q.rows ?? q.data!.length} row${q.rows === 1 ? '' : 's'}`;
146
+ const rowsLabel = (q: NonNullable<typeof query>) => tx(q.rows === 1 ? 'ws.rows_one' : 'ws.rows', { n: q.rows ?? q.data!.length });
147
+ const errorText = (line: number | undefined, col: number | undefined, error: string | undefined) =>
148
+ line ? (col ? tx('ws.error_at_col', { line, col, error }) : tx('ws.error_at', { line, error })) : tx('ws.error', { error });
145
149
  let download: Raw | '' = '';
146
150
  if (query) {
147
151
  try {
148
152
  unloadStatement(query.sql);
149
- download = html`<details class="menu ws-dl"><summary class="btn btn-sm">${icon('download')}<span>Download</span></summary>
153
+ download = html`<details class="menu ws-dl"><summary class="btn btn-sm">${icon('download')}<span>${tx('ws.download')}</span></summary>
150
154
  <div class="menu-panel align-right"><div class="menu-section menu-links">${(['csv', 'json', 'xml', 'xlsx', 'sql'] as const).map((f) =>
151
- html`<button form="ws-dl" name="format" value="${f}">${{ csv: 'CSV', json: 'JSON', xml: 'XML', xlsx: 'Excel', sql: 'SQL inserts' }[f]}</button>`)}</div></div></details>`;
155
+ html`<button form="ws-dl" name="format" value="${f}">${{ csv: 'CSV', json: 'JSON', xml: 'XML', xlsx: 'Excel', sql: tx('ws.dl.sql') }[f]}</button>`)}</div></div></details>`;
152
156
  } catch {
153
157
  download = '';
154
158
  }
155
159
  }
156
160
  const result: Raw = query
157
161
  ? html`<div class="ws-result-bar">
158
- <p class="muted small ws-info">${rowsLabel(query)} · ${query.ms} ms${query.more ? html` · showing the first ${query.data!.length}` : ''} · line ${query.line}</p>
162
+ <p class="muted small ws-info">${rowsLabel(query)} · ${query.ms} ms${query.more ? html` · ${tx('ws.result.showing', { n: query.data!.length })}` : ''} · ${tx('ws.line', { n: query.line })}</p>
159
163
  <span class="ws-spacer"></span>
160
- ${query.more ? html`<button class="btn btn-sm" name="mode" value="statement_all" title="Run the statement again and show up to ${ALL_ROWS.toLocaleString('en')} rows">All rows</button>` : ''}
161
- <button class="btn btn-sm" name="mode" value="chart" title="Create Chart (Ctrl+F11)">${icon('chart')}<span>Chart</span></button>
162
- <button type="button" class="btn btn-sm" data-ws-copy hidden>${icon('copy')}<span>Copy</span></button>
164
+ ${query.more ? html`<button class="btn btn-sm" name="mode" value="statement_all" title="${tx('ws.all_rows.title', { n: ALL_ROWS.toLocaleString(tx.lang) })}">${tx('ws.all_rows')}</button>` : ''}
165
+ <button class="btn btn-sm" name="mode" value="chart" title="${tx('ws.chart.title')}">${icon('chart')}<span>${tx('ws.tab.chart')}</span></button>
166
+ <button type="button" class="btn btn-sm" data-ws-copy hidden>${icon('copy')}<span>${tx('ws.copy')}</span></button>
163
167
  ${download}
164
168
  </div>
165
169
  <div class="table-wrap ws-grid"><table class="report" data-ws-grid><thead><tr><th scope="col" class="ws-rownum">#</th>${query.columns!.map((c, k) => html`<th scope="col"${query.numeric?.[k] ? raw(' class="num"') : ''}><button type="button" class="ws-sort" data-ws-sort="${k}" hidden>${c}</button><span class="ws-sort-label">${c}</span></th>`)}</tr></thead>
166
170
  <tbody>${query.data!.map((r, k) => html`<tr><td class="ws-rownum">${k + 1}</td>${r.map((v, i) => html`<td${query.numeric?.[i] ? raw(' class="num"') : ''}>${v === null ? html`<span class="null">(null)</span>` : v}</td>`)}</tr>`)}</tbody></table></div>
167
- <dialog class="ws-cell" id="ws-cell" aria-labelledby="ws-cell-title"><div class="ws-cell-head"><strong id="ws-cell-title">Value</strong><button type="button" class="btn btn-sm" data-ws-cell-close>Close</button></div><pre></pre></dialog>`
171
+ <dialog class="ws-cell" id="ws-cell" aria-labelledby="ws-cell-title"><div class="ws-cell-head"><strong id="ws-cell-title">${tx('ws.value')}</strong><button type="button" class="btn btn-sm" data-ws-cell-close>${tx('ws.close')}</button></div><pre></pre></dialog>`
168
172
  : run && mode !== 'explain' && mode !== 'autotrace'
169
- ? html`<p class="muted">${errors.length ? 'The statement failed: see Script Output.' : 'No rows: the last statement was not a query. See Script Output.'}</p>`
170
- : html`<p class="muted">Run a query (Ctrl+Enter) to see its rows here.</p>`;
173
+ ? html`<p class="muted">${errors.length ? tx('ws.result.failed') : tx('ws.result.no_rows')}</p>`
174
+ : html`<p class="muted">${tx('ws.result.empty')}</p>`;
171
175
 
172
176
  const log = run
173
177
  ? html`<pre class="ws-log">${results.map((r) => {
174
- if (r.status === 'not_run') return html`<span class="muted">-- ${r.n}. line ${r.line}: not run (an earlier statement failed)\n</span>`;
175
- const head = html`<span class="ws-log-sql">-- ${r.n}. line ${r.line}: ${oneLine(r.sql)}</span>\n`;
178
+ if (r.status === 'not_run') return html`<span class="muted">${tx('ws.log.not_run', { n: r.n, line: r.line })}\n</span>`;
179
+ const head = html`<span class="ws-log-sql">${tx('ws.log.head', { n: r.n, line: r.line, sql: oneLine(r.sql) })}</span>\n`;
176
180
  if (r.status === 'skipped') return html`${head}<span class="ws-log-warn">${r.error}</span>\n\n`;
177
- if (r.status === 'error') return html`${head}<span class="ws-log-err">Error${r.errorLine ? ` at line ${r.errorLine}${r.errorColumn ? `, column ${r.errorColumn}` : ''}` : ''}: ${r.error}</span>\n\n`;
178
- const body = r.columns ? `${textTable(r.columns, r.data ?? [])}\n${r.more ? `(first ${r.data!.length} of ${r.rows} rows)\n` : ''}` : '';
181
+ if (r.status === 'error') return html`${head}<span class="ws-log-err">${errorText(r.errorLine, r.errorColumn, r.error)}</span>\n\n`;
182
+ const body = r.columns ? `${textTable(r.columns, r.data ?? [])}\n${r.more ? `${tx('ws.log.first', { n: r.data!.length, rows: r.rows })}\n` : ''}` : '';
179
183
  const notes = run.notices.filter((n) => n.n === r.n).map((n) => `${n.level}: ${n.message}\n`).join('');
180
184
  return html`${head}${body}${notes}<span class="ws-log-ok">${r.tag}</span> <span class="muted">(${r.ms} ms)</span>\n\n`;
181
- })}<span class="muted">${ran.length} statement${ran.length === 1 ? '' : 's'} run, ${errors.length} failed, ${run.ms} ms.</span></pre>`
182
- : html`<p class="muted">Run Script (F5) runs every statement and logs each one here.</p>`;
185
+ })}<span class="muted">${tx(ran.length === 1 ? 'ws.log.summary_one' : 'ws.log.summary', { n: ran.length, failed: errors.length, ms: run.ms })}</span></pre>`
186
+ : html`<p class="muted">${tx('ws.log.empty')}</p>`;
183
187
 
184
188
  const notices = run?.notices.length
185
- ? html`<ul class="ws-notices">${run.notices.map((n) => html`<li><span class="tag${n.level === 'WARNING' ? ' tag-warn' : ''}">${n.level}</span> <span>${n.message}</span> <small class="muted">statement ${n.n} · ${n.at}</small></li>`)}</ul>`
186
- : html`<p class="muted">RAISE NOTICE, INFO and WARNING messages of the last run show here.</p>`;
189
+ ? html`<ul class="ws-notices">${run.notices.map((n) => html`<li><span class="tag${n.level === 'WARNING' ? ' tag-warn' : ''}">${n.level}</span> <span>${n.message}</span> <small class="muted">${tx('ws.notice.meta', { n: n.n, at: n.at })}</small></li>`)}</ul>`
190
+ : html`<p class="muted">${tx('ws.notice.empty')}</p>`;
187
191
 
188
192
  const planOf = (m: RunMode, empty: string) => mode === m && run
189
- ? (run.tree ? planView(run.tree, run.plan ?? '') : html`<p class="alert alert-error">${run.results[0]?.error ?? 'No plan.'}</p>`)
193
+ ? (run.tree ? planView(run.tree, run.plan ?? '') : html`<p class="alert alert-error">${run.results[0]?.error ?? tx('ws.no_plan')}</p>`)
190
194
  : html`<p class="muted">${empty}</p>`;
191
195
 
192
196
  const when = (at: string) => new Date(at).toISOString().slice(0, 16).replace('T', ' ');
193
197
  const hist = history.length
194
198
  ? html`<div class="ws-hist-bar" hidden>
195
- <label class="sr-only" for="ws-hist-q">Search the history</label><input id="ws-hist-q" type="search" placeholder="Search the history" data-ws-hist-q>
196
- <label class="sr-only" for="ws-hist-s">Status</label><select id="ws-hist-s" data-ws-hist-s><option value="">All</option><option value="ok">Succeeded</option><option value="error">Failed</option></select>
199
+ <label class="sr-only" for="ws-hist-q">${tx('ws.hist.search')}</label><input id="ws-hist-q" type="search" placeholder="${tx('ws.hist.search')}" data-ws-hist-q>
200
+ <label class="sr-only" for="ws-hist-s">${tx('ws.hist.status')}</label><select id="ws-hist-s" data-ws-hist-s><option value="">${tx('ws.hist.all')}</option><option value="ok">${tx('ws.hist.ok')}</option><option value="error">${tx('ws.hist.failed')}</option></select>
197
201
  </div>
198
202
  <ul class="ws-history">${history.map((h) => html`<li data-status="${h.status}" data-sql="${h.sql.toLowerCase()}">
199
- <span class="ws-hist-icon ${h.status === 'error' ? 'is-err' : 'is-ok'}" title="${h.status === 'error' ? `Failed: ${h.error ?? ''}` : 'Succeeded'}">${icon(h.status === 'error' ? 'x-circle' : 'check-circle')}</span>
200
- <a href="${BASE}/sql/worksheet?history=${h.id}" data-ws-history="${h.sql}" title="Insert at the cursor: ${h.sql.slice(0, 500)}"><code>${oneLine(h.sql)}</code></a>
201
- <small class="muted ws-hist-meta">${h.status === 'ok' && h.rows !== null ? `${h.rows} row${h.rows === '1' ? '' : 's'} · ` : ''}${h.ms ?? 0} ms${h.runs > 1 ? ` · ${h.runs}×` : ''} · ${when(h.at)}</small>
202
- <button type="button" class="btn btn-sm" data-ws-run="${h.sql}" title="Put it in the editor and run it" hidden>Run</button>
203
- <button type="button" class="btn btn-sm btn-icon" data-ws-copy-text="${h.sql}" title="Copy" hidden>${icon('copy')}<span class="sr-only">Copy</span></button>
203
+ <span class="ws-hist-icon ${h.status === 'error' ? 'is-err' : 'is-ok'}" title="${h.status === 'error' ? tx('ws.hist.failed_title', { error: h.error ?? '' }) : tx('ws.hist.ok')}">${icon(h.status === 'error' ? 'x-circle' : 'check-circle')}</span>
204
+ <a href="${BASE}/sql/worksheet?history=${h.id}" data-ws-history="${h.sql}" title="${tx('ws.hist.insert', { sql: h.sql.slice(0, 500) })}"><code>${oneLine(h.sql)}</code></a>
205
+ <small class="muted ws-hist-meta">${h.status === 'ok' && h.rows !== null ? `${tx(h.rows === '1' ? 'ws.rows_one' : 'ws.rows', { n: h.rows })} · ` : ''}${h.ms ?? 0} ms${h.runs > 1 ? ` · ${h.runs}×` : ''} · ${when(h.at)}</small>
206
+ <button type="button" class="btn btn-sm" data-ws-run="${h.sql}" title="${tx('ws.hist.run_title')}" hidden>${tx('common.run')}</button>
207
+ <button type="button" class="btn btn-sm btn-icon" data-ws-copy-text="${h.sql}" title="${tx('ws.copy')}" hidden>${icon('copy')}<span class="sr-only">${tx('ws.copy')}</span></button>
204
208
  </li>`)}</ul>`
205
- : html`<p class="muted">Your statements show here after you run them.</p>`;
209
+ : html`<p class="muted">${tx('ws.hist.empty')}</p>`;
206
210
 
207
211
  const active: Tab = chart && run ? 'chart' : !run ? (TABS.find(([k]) => k === tabAsked)?.[0] ?? 'result') : mode === 'explain' ? 'explain' : mode === 'autotrace' ? 'autotrace' : mode === 'script' || !query || errors.length ? 'output' : 'result';
208
212
  const time = new Date().toTimeString().slice(0, 8);
209
- const last = !run ? 'Ready.' : errors.length
210
- ? `Error${errors[0].errorLine ? ` at line ${errors[0].errorLine}` : ''}: ${errors[0].error}`
211
- : mode === 'explain' || mode === 'autotrace' ? `${mode === 'explain' ? 'Explain plan' : 'Autotrace'} in ${run.ms} ms.`
212
- : query && ran.length === 1 ? `${query.rows} row${query.rows === 1 ? '' : 's'} in ${query.ms} ms.`
213
- : `${ran.length} statement${ran.length === 1 ? '' : 's'} in ${run.ms} ms.`;
214
- const status = html`<span class="ws-count ${errors.length ? 'is-err' : ''}" title="Errors">${icon('x-circle')} ${errors.length}</span>
215
- <span class="ws-count ${warnings ? 'is-warn' : ''}" title="Warnings">${icon('alert')} ${warnings}</span>
216
- <span class="ws-busy" hidden>${icon('refresh')} Running…</span>
213
+ const last = !run ? tx('ws.ready') : errors.length
214
+ ? errorText(errors[0].errorLine, undefined, errors[0].error)
215
+ : mode === 'explain' || mode === 'autotrace' ? tx(mode === 'explain' ? 'ws.status.explain' : 'ws.status.autotrace', { ms: run.ms })
216
+ : query && ran.length === 1 ? tx(query.rows === 1 ? 'ws.status.rows_one' : 'ws.status.rows', { n: query.rows, ms: query.ms })
217
+ : tx(ran.length === 1 ? 'ws.status.stmts_one' : 'ws.status.stmts', { n: ran.length, ms: run.ms });
218
+ const status = html`<span class="ws-count ${errors.length ? 'is-err' : ''}" title="${tx('ws.errors')}">${icon('x-circle')} ${errors.length}</span>
219
+ <span class="ws-count ${warnings ? 'is-warn' : ''}" title="${tx('ws.warnings')}">${icon('alert')} ${warnings}</span>
220
+ <span class="ws-busy" hidden>${icon('refresh')} ${tx('ws.running')}</span>
217
221
  <span class="ws-last">${last}</span><span class="muted ws-time">${run ? time : ''}</span>`;
218
- return { panes: { result, chart: chart ?? html`<p class="muted">Create Chart (Ctrl+F11) draws the last query: a label column and number columns.</p>`, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
222
+ return { panes: { result, chart: chart ?? html`<p class="muted">${tx('ws.chart.empty')}</p>`, output: log, notices, explain: planOf('explain', tx('ws.explain.empty')), autotrace: planOf('autotrace', tx('ws.autotrace.empty')), history: hist }, active, status };
219
223
  }
220
224
 
221
225
  /** (sprint 107) The idle timeout in words. */
222
- const idleLabel = () => {
226
+ const idleLabel = (tx: BuilderTranslate) => {
223
227
  const sec = txnIdleSeconds();
224
- return sec % 60 ? `${sec} seconds` : `${sec / 60} minute${sec === 60 ? '' : 's'}`;
228
+ return sec % 60 ? tx('ws.idle.seconds', { n: sec }) : tx(sec === 60 ? 'ws.idle.minute' : 'ws.idle.minutes', { n: sec / 60 });
225
229
  };
226
230
 
227
231
  /** (sprint 107) The transaction badge's text (public/worksheet.js writes the same after a reload or a tab switch). */
228
- export function txnBadge(state: 'open' | 'aborted' | 'none', statements: number, startedMs: number | null, note: string | null): string {
229
- if (state === 'none') return note ?? 'No open transaction';
232
+ export function txnBadge(state: 'open' | 'aborted' | 'none', statements: number, startedMs: number | null, note: string | null, tx: BuilderTranslate = translatorFor('en')): string {
233
+ if (state === 'none') return note ?? tx('ws.txn.none');
230
234
  const min = startedMs ? Math.floor((Date.now() - startedMs) / 60_000) : 0;
231
- const age = min < 1 ? 'under a minute' : `${min} min`;
232
- return `${state === 'aborted' ? 'Transaction failed' : 'Transaction open'} · ${statements} statement${statements === 1 ? '' : 's'} · ${age}`;
235
+ const age = min < 1 ? tx('ws.txn.under_minute') : tx('ws.txn.min', { n: min });
236
+ return `${tx(state === 'aborted' ? 'ws.txn.failed' : 'ws.txn.open')} · ${tx(statements === 1 ? 'ws.stmt_one' : 'ws.stmt', { n: statements })} · ${age}`;
233
237
  }
234
238
 
235
239
  /** (sprint 107) Commit or Rollback a worksheet tab's transaction (the toolbar's buttons, and the JSON endpoint). */
236
240
  async function finishTxn(req: Req, s: Session, tab: string, t: WorksheetTxn | undefined, action: 'commit' | 'rollback'): Promise<{ ok: boolean; message: string; busy?: boolean }> {
237
- if (!t) return { ok: false, message: 'This worksheet has no open transaction.' };
238
- if (t.busy) return { ok: false, busy: true, message: 'A run is still going on in this worksheet: wait for it to finish.' };
241
+ const tx = tr(s);
242
+ if (!t) return { ok: false, message: tx('ws.txn.no_txn') };
243
+ if (t.busy) return { ok: false, busy: true, message: tx('ws.txn.busy') };
239
244
  const n = t.statements;
240
245
  const failed = txnState(t) === 'aborted';
241
246
  const ended = await endTxn(t, action);
242
247
  clearTxnNote(s.id, tab);
243
248
  if (ended.ddl.length) await recordChanges({ workspaceId: t.workspaceId, author: s.username ?? '?', source: 'worksheet', defaultSchema: t.defaultSchema, statements: ended.ddl });
244
249
  await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ended.tag.toLowerCase() });
245
- if (ended.error) return { ok: false, message: `${action.toUpperCase()} failed: ${ended.error}. The connection was closed, so the transaction is rolled back.` };
246
- if (action === 'commit' && ended.tag !== 'COMMIT') return { ok: false, message: 'The transaction had failed, so COMMIT rolled it back: nothing was saved.' };
247
- const what = `${n} statement${n === 1 ? '' : 's'}`;
248
- return { ok: true, message: action === 'commit' ? `Committed (${what}).` : `Rolled back (${what} undone${failed ? '; the transaction had failed' : ''}).` };
250
+ if (ended.error) return { ok: false, message: tx('ws.txn.end_failed', { action: action.toUpperCase(), error: ended.error }) };
251
+ if (action === 'commit' && ended.tag !== 'COMMIT') return { ok: false, message: tx('ws.txn.commit_rolled') };
252
+ const what = tx(n === 1 ? 'ws.stmt_one' : 'ws.stmt', { n });
253
+ return { ok: true, message: tx(action === 'commit' ? 'ws.txn.committed' : failed ? 'ws.txn.rolled_back_failed' : 'ws.txn.rolled_back', { what }) };
249
254
  }
250
255
 
251
256
  /** (sprint 107) A tab's transaction as JSON, for the badge after a reload or a tab switch. */
@@ -255,7 +260,7 @@ function txnStatus(s: Session, tab: string) {
255
260
  const note = state === 'none' ? txnNote(s.id, tab) : null;
256
261
  return {
257
262
  state, statements: t?.statements ?? 0, startedMs: t?.startedMs ?? null, lastMs: t?.lastMs ?? null, idleSeconds: txnIdleSeconds(),
258
- runsAs: t?.runsAs ?? null, note, badge: txnBadge(state, t?.statements ?? 0, t?.startedMs ?? null, note), now: Date.now(),
263
+ runsAs: t?.runsAs ?? null, note, badge: txnBadge(state, t?.statements ?? 0, t?.startedMs ?? null, note, tr(s)), now: Date.now(),
259
264
  };
260
265
  }
261
266
 
@@ -268,6 +273,7 @@ async function runAsApps(s: Session) {
268
273
  async function worksheet(req: Req, reply: FastifyReply) {
269
274
  const s = await developer(req, reply);
270
275
  if (!s) return;
276
+ const tx = tr(s);
271
277
  const db = await workshopDb(s);
272
278
  const body = req.body ?? {};
273
279
  const isPost = req.method === 'POST';
@@ -303,14 +309,14 @@ async function worksheet(req: Req, reply: FastifyReply) {
303
309
  return reply.header('content-disposition', `attachment; filename="${file}.sql"`).header('cache-control', 'private, no-store').type('application/sql; charset=utf-8').send(text);
304
310
  }
305
311
  try {
306
- if (name.length > 200) fileError = 'A name has at most 200 characters.';
307
- else if (text.length > 5_000_000) fileError = 'A script holds at most 5 MB.';
312
+ if (name.length > 200) fileError = tx('ws.file.name_long');
313
+ else if (text.length > 5_000_000) fileError = tx('ws.file.too_big');
308
314
  else if (body.mode === 'save' && script) {
309
315
  await owner.query('update meta.sql_script set content = $2, updated_by = $3, updated_at = now() where id = $1 and workspace_id = $4', [script.id, text, s.username, currentWorkspace(s)]);
310
316
  } else if (body.mode === 'rename' && script) {
311
- if (!name) fileError = 'Enter the new name.';
317
+ if (!name) fileError = tx('ws.file.new_name');
312
318
  else await owner.query('update meta.sql_script set name = $2, updated_by = $3, updated_at = now() where id = $1 and workspace_id = $4', [script.id, name, s.username, currentWorkspace(s)]);
313
- } else if (!name) fileError = 'Name the worksheet to save it as a script.';
319
+ } else if (!name) fileError = tx('ws.file.need_name');
314
320
  else {
315
321
  const r = await owner.one<{ id: number }>(
316
322
  `insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [name, text, s.username, currentWorkspace(s)]);
@@ -318,10 +324,10 @@ async function worksheet(req: Req, reply: FastifyReply) {
318
324
  }
319
325
  } catch (e) {
320
326
  if ((e as { code?: string }).code !== '23505') throw e;
321
- fileError = `A script named “${name}” already exists.`;
327
+ fileError = tx('ws.file.exists', { name });
322
328
  }
323
329
  if (!fileError && script) {
324
- flash(s, body.mode === 'rename' ? `Renamed to “${name}”.` : `Saved as the script “${body.mode === 'save' ? script.name : name}”.`);
330
+ flash(s, body.mode === 'rename' ? tx('ws.file.renamed', { name }) : tx('ws.file.saved', { name: body.mode === 'save' ? script.name : name }));
325
331
  return back(reply, s, `${BASE}/sql/worksheet?${new URLSearchParams({ script: String(script.id), ...(tabKey ? { wstab: tabKey } : {}) })}`);
326
332
  }
327
333
  }
@@ -387,7 +393,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
387
393
  let txnError = '';
388
394
  let txnInfo = '';
389
395
  if (txn && txn.workspaceId !== currentWorkspace(s)) {
390
- await endTxn(txn, 'rollback', 'You switched workspaces: the transaction was rolled back.');
396
+ await endTxn(txn, 'rollback', tx('ws.txn.switched'));
391
397
  txn = undefined;
392
398
  }
393
399
  if (isPost && (body.mode === 'commit' || body.mode === 'rollback')) {
@@ -424,11 +430,11 @@ async function worksheet(req: Req, reply: FastifyReply) {
424
430
  const useTxn = statements.length > 0 && txnAsked && (!explaining || (!!txn && txnState(txn) !== 'none'));
425
431
  if (statements.length) {
426
432
  const blocked = txnAsked && !explaining ? statements.find((x) => !x.psql && outsideTxnOnly(x.sql)) : undefined;
427
- if (txn && !txnAsked) txnError = 'This worksheet has an open transaction: Commit or Rollback it first, or switch back to Transaction.';
428
- else if (txnAsked && !tabKey) txnError = 'Transaction mode needs JavaScript (the worksheet tab it belongs to); this run was refused.';
429
- else if (txn && (asApp?.id ?? null) !== txn.asAppId) txnError = `This worksheet's transaction runs as ${txn.runsAs}: Commit or Rollback it before changing Run as.`;
430
- else if (txn?.busy) txnError = 'A run is still going on in this worksheet: wait for it to finish.';
431
- else if (blocked) txnError = `Line ${blocked.line}: ${outsideTxnOnly(blocked.sql)} can't run inside a transaction. Switch the worksheet to Autocommit to run it.`;
433
+ if (txn && !txnAsked) txnError = tx('ws.txn.open_first');
434
+ else if (txnAsked && !tabKey) txnError = tx('ws.txn.needs_js');
435
+ else if (txn && (asApp?.id ?? null) !== txn.asAppId) txnError = tx('ws.txn.runs_as', { who: txn.runsAs });
436
+ else if (txn?.busy) txnError = tx('ws.txn.busy');
437
+ else if (blocked) txnError = tx('ws.txn.not_in_txn', { line: blocked.line, what: outsideTxnOnly(blocked.sql) });
432
438
  }
433
439
  if (statements.length && !txnError) {
434
440
  ran = { start: statements[0].start, end: statements[statements.length - 1].end };
@@ -441,11 +447,11 @@ async function worksheet(req: Req, reply: FastifyReply) {
441
447
  if (!txn) {
442
448
  const note = txnNote(s.id, tabKey);
443
449
  const began = await beginTxn({ sessionId: s.id, username: s.username!, workspaceId: currentWorkspace(s), tab: tabKey, db: asApp ? await devDbForApp(asApp.id) : db,
444
- asApp: asApp ?? null, runsAs: asApp ? `the role ${asApp.db_role} of ${asApp.name}` : workshopRunsAs(db), searchSchema });
450
+ asApp: asApp ?? null, runsAs: asApp ? tx('ws.runs_as_role', { role: asApp.db_role, app: asApp.name }) : workshopRunsAs(db), searchSchema });
445
451
  if (typeof began === 'string') txnError = began;
446
452
  else {
447
453
  txn = began;
448
- if (note) txnInfo = `${note} A new transaction started.`;
454
+ if (note) txnInfo = tx('ws.txn.new_started', { note });
449
455
  }
450
456
  }
451
457
  if (txn) {
@@ -474,7 +480,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
474
480
  // (sprint 107) a BEGIN typed in Autocommit lasts for the run only: say so when it was left open
475
481
  if (!explaining && c.getTransactionStatus() !== 'I') {
476
482
  await c.query('rollback').catch(() => {});
477
- run.notices.push({ level: 'WARNING', message: 'The transaction started with BEGIN was still open at the end of the run and was rolled back. Switch the worksheet to Transaction to keep a transaction open between runs.', n: run.results.filter((r) => r.status !== 'not_run').length, at: new Date().toISOString().slice(11, 23) });
483
+ run.notices.push({ level: 'WARNING', message: tx('ws.txn.begin_left'), n: run.results.filter((r) => r.status !== 'not_run').length, at: new Date().toISOString().slice(11, 23) });
478
484
  }
479
485
  } catch (e) {
480
486
  run = failedRun(e);
@@ -491,7 +497,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
491
497
  if (txnState(txn) === 'none' && findTxn(s.id, tabKey) === txn) {
492
498
  await endTxn(txn, 'rollback'); // nothing open: just closes the connection
493
499
  txn = undefined;
494
- txnInfo = 'The transaction ended with the COMMIT or ROLLBACK in the editor; the next run starts a new one.';
500
+ txnInfo = tx('ws.txn.ended_editor');
495
501
  }
496
502
  }
497
503
  if (txn && findTxn(s.id, tabKey) !== txn) txn = undefined; // the database ended the connection
@@ -515,19 +521,19 @@ async function worksheet(req: Req, reply: FastifyReply) {
515
521
  let chartCss = '';
516
522
  if (chartAsked && lastQuery?.columns) {
517
523
  const spec = chartSpec(lastQuery.columns, lastQuery.numeric ?? [], askedSpec(body));
518
- if (!spec) chartPane = html`<p class="muted">Nothing to draw: the query needs a number column next to its labels.</p>`;
524
+ if (!spec) chartPane = html`<p class="muted">${tx('ws.chart.nothing')}</p>`;
519
525
  else {
520
526
  const d = drawChart('', lastQuery.columns, lastQuery.data ?? [], spec);
521
527
  chartCss = d.css;
522
528
  chartPane = html`${chartControls(lastQuery.columns, spec)}
523
- <div class="ch-actions"><button class="btn btn-sm" name="mode" value="chart">${icon('refresh')} Draw again</button>
529
+ <div class="ch-actions"><button class="btn btn-sm" name="mode" value="chart">${icon('refresh')} ${tx('ws.chart.again')}</button>
524
530
  <input type="hidden" name="chart_sql" value="${lastQuery.sql}">
525
- <label class="sr-only" for="ws-chart-name">Chart name</label><input id="ws-chart-name" name="chart_name" placeholder="Chart name" maxlength="120">
526
- <button class="btn btn-sm btn-hot" formaction="${BASE}/sql/charts">${icon('save')} Save chart</button></div>
531
+ <label class="sr-only" for="ws-chart-name">${tx('ws.chart.name')}</label><input id="ws-chart-name" name="chart_name" placeholder="${tx('ws.chart.name')}" maxlength="120">
532
+ <button class="btn btn-sm btn-hot" formaction="${BASE}/sql/charts">${icon('save')} ${tx('ws.chart.save')}</button></div>
527
533
  <div class="ch-view">${d.body}</div>`;
528
534
  }
529
535
  }
530
- const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''), chartPane);
536
+ const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''), chartPane, tx);
531
537
  const downloadable = (() => {
532
538
  try {
533
539
  return lastQuery ? unloadStatement(lastQuery.sql) : null;
@@ -539,137 +545,137 @@ async function worksheet(req: Req, reply: FastifyReply) {
539
545
  const files = (await owner.query<{ id: number; name: string; updated_at: string }>(
540
546
  'select id, name, updated_at from meta.sql_script where workspace_id = $1 order by lower(name) limit 300', [currentWorkspace(s)])).rows;
541
547
  const bindDialog = askValues ? html`<dialog class="ws-binds" id="ws-binds" open aria-labelledby="ws-binds-title" data-ws-binds>
542
- <h2 id="ws-binds-title">Values for the run</h2>
543
- <p class="muted small">Bind variables (<code>:NAME</code>) get a value as a quoted literal; substitution variables (<code>&amp;NAME.</code>) put the text into the statement as it is. Empty is NULL for a bind variable.</p>
548
+ <h2 id="ws-binds-title">${tx('ws.binds.title')}</h2>
549
+ <p class="muted small">${tx.rich('ws.binds.help', { bind: html`<code>:NAME</code>`, sub: html`<code>&amp;NAME.</code>` })}</p>
544
550
  <input type="hidden" name="binds_given" value="1">
545
551
  <div class="ws-binds-fields">${askValues.binds.map((n) => html`<label class="field"><span class="label"><code>:${n}</code></span><input name="bind_${n}" value="${remembered[`:${n}`] ?? ''}" autocomplete="off"></label>`)}${askValues.subs.map((n) => html`<label class="field"><span class="label"><code>&amp;${n}.</code></span><input name="sub_${n}" value="${remembered[`&${n}`] ?? ''}" autocomplete="off"></label>`)}</div>
546
- <div class="buttons"><button class="btn btn-hot" name="mode" value="${askValues.mode}">${icon('play')} Run</button><button class="btn" name="mode" value="nav" data-ws-binds-cancel>Cancel</button></div>
552
+ <div class="buttons"><button class="btn btn-hot" name="mode" value="${askValues.mode}">${icon('play')} ${tx('common.run')}</button><button class="btn" name="mode" value="nav" data-ws-binds-cancel>${tx('common.cancel')}</button></div>
547
553
  </dialog>` : '';
548
- const runsAs = asApp ? `the role ${asApp.db_role} of ${asApp.name}` : workshopRunsAs(db);
554
+ const runsAs = asApp ? tx('ws.runs_as_role', { role: asApp.db_role, app: asApp.name }) : workshopRunsAs(db);
549
555
  // (sprint 107) the transaction badge: the real state of the tab's transaction on the server
550
556
  const tState = txnState(txn);
551
557
  const tNote = !txn && tabKey ? txnNote(s.id, tabKey) : null;
552
- if (tState === 'aborted' && !txnError) txnError = 'A statement failed, so the transaction is aborted: Rollback ends it (COMMIT would roll it back too).';
558
+ if (tState === 'aborted' && !txnError) txnError = tx('ws.txn.aborted');
553
559
  const txnOn = txnAsked || !!txn;
554
- const badge = txnBadge(tState, txn?.statements ?? 0, txn?.startedMs ?? null, tNote);
560
+ const badge = txnBadge(tState, txn?.statements ?? 0, txn?.startedMs ?? null, tNote, tx);
555
561
 
556
562
  const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
557
563
  html`<button class="btn btn-sm${hot ? ' btn-hot' : ''}" name="mode" value="${m}" title="${label} (${key})" data-ws-key="${key}">${icon(ic)}<span>${label}</span></button>`;
558
564
  const main = html`${chartCss ? html`<style nonce="${req.cspNonce}">${raw(chartCss.replace(/</g, ''))}</style>` : ''}<div class="ws-page">
559
- <h1 class="sr-only">SQL Worksheet</h1>
565
+ <h1 class="sr-only">${tx('ws.title')}</h1>
560
566
  ${workshopTabs('sql', s)}
561
567
  <form method="post" action="${BASE}/sql/worksheet" class="ws" id="ws" data-ran="${ran ? `${ran.start},${ran.end}` : ''}" data-error-line="${firstError?.errorLine ?? ''}"
562
568
  data-source="${source}" data-script-id="${script?.id ?? ''}" data-script-name="${script?.name ?? ''}" data-tab="${tabKey}">${csrf(s)}
563
- <button class="ws-default" name="mode" value="nav" tabindex="-1">Refresh the navigator</button>
569
+ <button class="ws-default" name="mode" value="nav" tabindex="-1">${tx('ws.refresh_nav')}</button>
564
570
  <input type="hidden" name="sel_start" value=""><input type="hidden" name="sel_end" value="">
565
571
  <input type="hidden" name="script_id" value="${script?.id ?? ''}"><input type="hidden" name="ws_tab" value="${tabKey}">
566
572
  ${bindDialog}
567
- <aside class="ws-nav" id="ws-nav" aria-label="Navigator">
568
- <div class="ws-nav-switch" role="tablist" aria-label="Navigator" hidden>
569
- <button type="button" role="tab" aria-selected="true" aria-controls="ws-nav-objects" data-ws-navtab="objects">Objects</button>
570
- <button type="button" role="tab" aria-selected="false" aria-controls="ws-files" data-ws-navtab="files">Files</button>
573
+ <aside class="ws-nav" id="ws-nav" aria-label="${tx('ws.navigator')}">
574
+ <div class="ws-nav-switch" role="tablist" aria-label="${tx('ws.navigator')}" hidden>
575
+ <button type="button" role="tab" aria-selected="true" aria-controls="ws-nav-objects" data-ws-navtab="objects">${tx('ws.objects')}</button>
576
+ <button type="button" role="tab" aria-selected="false" aria-controls="ws-files" data-ws-navtab="files">${tx('ws.files')}</button>
571
577
  </div>
572
578
  <div id="ws-nav-objects" class="ws-nav-panel">
573
579
  <div class="ws-nav-head">
574
- <label class="sr-only" for="ws-schema">Schema</label>
580
+ <label class="sr-only" for="ws-schema">${tx('ws.schema')}</label>
575
581
  <select id="ws-schema" name="nav_schema" data-ws-nav>${navSchemas.map((n) => html`<option value="${n}"${n === navSchema ? raw(' selected') : ''}>${n}</option>`)}</select>
576
- <label class="sr-only" for="ws-type">Object type</label>
577
- <select id="ws-type" name="nav_type" data-ws-nav>${NAV_TYPES.map(([k, label]) => html`<option value="${k}"${k === navType ? raw(' selected') : ''}>${label}</option>`)}</select>
582
+ <label class="sr-only" for="ws-type">${tx('ws.object_type')}</label>
583
+ <select id="ws-type" name="nav_type" data-ws-nav>${NAV_TYPES.map(([k, label]) => html`<option value="${k}"${k === navType ? raw(' selected') : ''}>${tx.dyn(`ob.type.${k}`, label)}</option>`)}</select>
578
584
  <div class="ws-nav-search">
579
- <label class="sr-only" for="ws-filter">Search objects</label>
580
- <input id="ws-filter" type="search" placeholder="Search" autocomplete="off" data-ws-filter>
581
- <button class="btn btn-sm btn-icon" name="mode" value="nav" title="Refresh">${icon('refresh')}<span class="sr-only">Refresh</span></button>
585
+ <label class="sr-only" for="ws-filter">${tx('ws.search_objects')}</label>
586
+ <input id="ws-filter" type="search" placeholder="${tx('common.search')}" autocomplete="off" data-ws-filter>
587
+ <button class="btn btn-sm btn-icon" name="mode" value="nav" title="${tx('ws.refresh')}">${icon('refresh')}<span class="sr-only">${tx('ws.refresh')}</span></button>
582
588
  </div>
583
589
  </div>
584
- <div class="ws-nav-body" id="ws-nav-body">${await navigatorTree(db, navSchema, navType)}</div>
590
+ <div class="ws-nav-body" id="ws-nav-body">${await navigatorTree(db, navSchema, navType, tx)}</div>
585
591
  </div>
586
- <section id="ws-files" class="ws-nav-panel ws-files" aria-label="Files">
587
- <h2 class="ws-files-title">Files <small class="muted">(SQL Scripts)</small></h2>
588
- ${files.length ? html`<ul>${files.map((f) => html`<li><a href="${BASE}/sql/worksheet?script=${f.id}"${script?.id === f.id ? raw(' aria-current="true"') : ''}>${icon('file')}<span>${f.name}</span></a></li>`)}</ul>` : html`<p class="muted small">No scripts yet: Save a worksheet to make one.</p>`}
592
+ <section id="ws-files" class="ws-nav-panel ws-files" aria-label="${tx('ws.files')}">
593
+ <h2 class="ws-files-title">${tx('ws.files')} <small class="muted">${tx('ws.sql_scripts')}</small></h2>
594
+ ${files.length ? html`<ul>${files.map((f) => html`<li><a href="${BASE}/sql/worksheet?script=${f.id}"${script?.id === f.id ? raw(' aria-current="true"') : ''}>${icon('file')}<span>${f.name}</span></a></li>`)}</ul>` : html`<p class="muted small">${tx('ws.no_scripts')}</p>`}
589
595
  </section>
590
596
  </aside>
591
- <div class="ws-split ws-split-v" role="separator" aria-orientation="vertical" aria-label="Resize the navigator" tabindex="0" data-ws-split="nav" hidden></div>
592
- <section class="ws-main" aria-label="Worksheet">
593
- <div class="ws-toolbar" role="toolbar" aria-label="Worksheet">
594
- <button type="button" class="btn btn-sm btn-icon" data-ws-toggle-nav title="Hide or show the navigator" hidden>${bicon('left')}<span class="sr-only">Navigator</span></button>
595
- <details class="menu ws-file"><summary class="btn btn-sm" title="Save, open, rename or download the worksheet (Ctrl+S saves)">${icon('file')}<span class="ws-file-name">${script?.name ?? 'Untitled'}</span></summary>
597
+ <div class="ws-split ws-split-v" role="separator" aria-orientation="vertical" aria-label="${tx('ws.resize_nav')}" tabindex="0" data-ws-split="nav" hidden></div>
598
+ <section class="ws-main" aria-label="${tx('ws.worksheet')}">
599
+ <div class="ws-toolbar" role="toolbar" aria-label="${tx('ws.worksheet')}">
600
+ <button type="button" class="btn btn-sm btn-icon" data-ws-toggle-nav title="${tx('ws.toggle_nav')}" hidden>${bicon('left')}<span class="sr-only">${tx('ws.navigator')}</span></button>
601
+ <details class="menu ws-file"><summary class="btn btn-sm" title="${tx('ws.file.menu')}">${icon('file')}<span class="ws-file-name">${script?.name ?? tx('ws.untitled')}</span></summary>
596
602
  <div class="menu-panel"><div class="menu-section ws-file-panel">
597
- <label class="label" for="ws-name">Name</label>
603
+ <label class="label" for="ws-name">${tx('ws.name')}</label>
598
604
  <input id="ws-name" name="ws_name" value="${script?.name ?? ''}" maxlength="200" autocomplete="off">
599
605
  <div class="ws-file-actions">
600
- <button class="btn btn-sm btn-hot" name="mode" value="save" title="Save (Ctrl+S)">${icon('save')} Save</button>
601
- ${script ? html`<button class="btn btn-sm" name="mode" value="save_as">Save as new</button><button class="btn btn-sm" name="mode" value="rename">Rename</button>` : ''}
602
- <button class="btn btn-sm" name="mode" value="download">${icon('download')} Download</button>
606
+ <button class="btn btn-sm btn-hot" name="mode" value="save" title="${tx('ws.save_title')}">${icon('save')} ${tx('ws.key.save')}</button>
607
+ ${script ? html`<button class="btn btn-sm" name="mode" value="save_as">${tx('ws.save_as')}</button><button class="btn btn-sm" name="mode" value="rename">${tx('ws.rename')}</button>` : ''}
608
+ <button class="btn btn-sm" name="mode" value="download">${icon('download')} ${tx('ws.download')}</button>
603
609
  </div>
604
- <div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} New</a><a class="btn btn-sm" href="#ws-files" data-ws-open>Open…</a>
610
+ <div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} ${tx('ws.new')}</a><a class="btn btn-sm" href="#ws-files" data-ws-open>${tx('ws.open_file')}</a>
605
611
  ${script ? html`<input type="hidden" name="pin_href" value="${BASE}/sql/worksheet?script=${script.id}"><input type="hidden" name="pin_label" value="${script.name}"><input type="hidden" name="back" value="${BASE}/sql/worksheet?script=${script.id}">
606
- <button class="btn btn-sm" formaction="${BASE}/pins" title="Pin this worksheet to the SQL Workshop home">${icon('pin')} Pin</button>` : ''}</div>
612
+ <button class="btn btn-sm" formaction="${BASE}/pins" title="${tx('ws.pin_title')}">${icon('pin')} ${tx('ws.pin')}</button>` : ''}</div>
607
613
  </div></div></details>
608
- ${toolBtn('statement', 'play', 'Run Statement', 'Ctrl+Enter', true)}
609
- ${toolBtn('script', 'file', 'Run Script', 'F5')}
610
- ${toolBtn('explain', 'route', 'Explain Plan', 'F10')}
611
- ${toolBtn('autotrace', 'activity', 'Autotrace', 'F6')}
612
- <label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> Continue on error</label>
614
+ ${toolBtn('statement', 'play', tx('ws.run_statement'), 'Ctrl+Enter', true)}
615
+ ${toolBtn('script', 'file', tx('ws.run_script'), 'F5')}
616
+ ${toolBtn('explain', 'route', tx('ws.tab.explain'), 'F10')}
617
+ ${toolBtn('autotrace', 'activity', tx('ws.tab.autotrace'), 'F6')}
618
+ <label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> ${tx('ws.continue')}</label>
613
619
  <span class="ws-spacer"></span>
614
- <button type="button" class="btn btn-sm btn-icon" data-ws-format title="Format (Ctrl+F7)" hidden>${icon('sliders')}<span class="sr-only">Format</span></button>
615
- <button type="button" class="btn btn-sm btn-icon" data-ws-case title="Convert case of the selection (Ctrl+B)" hidden><span aria-hidden="true" class="ws-aa">Aa</span><span class="sr-only">Convert case</span></button>
616
- <button type="button" class="btn btn-sm btn-icon" data-ws-find title="Find and replace (Ctrl+F, Ctrl+H)" hidden>${icon('search')}<span class="sr-only">Find</span></button>
617
- <a class="btn btn-sm btn-icon" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="Load data into a table">${icon('upload')}<span class="sr-only">Load data</span></a>
618
- <details class="menu ws-keys"><summary class="btn btn-sm btn-icon" title="Keyboard shortcuts">?<span class="sr-only">Keyboard shortcuts</span></summary>
620
+ <button type="button" class="btn btn-sm btn-icon" data-ws-format title="${tx('ws.format_title')}" hidden>${icon('sliders')}<span class="sr-only">${tx('ws.format')}</span></button>
621
+ <button type="button" class="btn btn-sm btn-icon" data-ws-case title="${tx('ws.case_title')}" hidden><span aria-hidden="true" class="ws-aa">Aa</span><span class="sr-only">${tx('ws.case')}</span></button>
622
+ <button type="button" class="btn btn-sm btn-icon" data-ws-find title="${tx('ws.find_title')}" hidden>${icon('search')}<span class="sr-only">${tx('ws.find')}</span></button>
623
+ <a class="btn btn-sm btn-icon" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="${tx('ws.load_title')}">${icon('upload')}<span class="sr-only">${tx('ws.load')}</span></a>
624
+ <details class="menu ws-keys"><summary class="btn btn-sm btn-icon" title="${tx('ws.keys')}">?<span class="sr-only">${tx('ws.keys')}</span></summary>
619
625
  <div class="menu-panel align-right"><div class="menu-section"><dl class="ws-keylist">
620
- <dt>Ctrl+Enter</dt><dd>Run Statement</dd><dt>F5</dt><dd>Run Script</dd><dt>F10</dt><dd>Explain Plan</dd><dt>F6</dt><dd>Autotrace</dd>
621
- <dt>Ctrl+S</dt><dd>Save</dd><dt>Ctrl+F7</dt><dd>Format</dd><dt>Ctrl+B</dt><dd>Convert case</dd><dt>Ctrl+F / Ctrl+H</dt><dd>Find / replace</dd>
622
- <dt>Ctrl+G</dt><dd>Go to line</dd><dt>Ctrl+/</dt><dd>Comment the lines</dd><dt>Ctrl+Space</dt><dd>Suggestions</dd><dt>F1</dt><dd>Help</dd><dt>Escape</dt><dd>Leave fullscreen</dd>
626
+ <dt>Ctrl+Enter</dt><dd>${tx('ws.run_statement')}</dd><dt>F5</dt><dd>${tx('ws.run_script')}</dd><dt>F10</dt><dd>${tx('ws.tab.explain')}</dd><dt>F6</dt><dd>${tx('ws.tab.autotrace')}</dd>
627
+ <dt>Ctrl+S</dt><dd>${tx('ws.key.save')}</dd><dt>Ctrl+F7</dt><dd>${tx('ws.format')}</dd><dt>Ctrl+B</dt><dd>${tx('ws.case')}</dd><dt>Ctrl+F / Ctrl+H</dt><dd>${tx('ws.key.find')}</dd>
628
+ <dt>Ctrl+G</dt><dd>${tx('ws.key.goto')}</dd><dt>Ctrl+/</dt><dd>${tx('ws.key.comment')}</dd><dt>Ctrl+Space</dt><dd>${tx('ws.key.suggest')}</dd><dt>F1</dt><dd>${tx('ws.key.help')}</dd><dt>Escape</dt><dd>${tx('ws.key.fullscreen')}</dd>
623
629
  </dl>
624
- <label class="ws-kwcase" hidden>Format keywords in <select data-ws-kwcase><option value="lower">lower case</option><option value="upper">UPPER CASE</option></select></label>
625
- <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>Help on the SQL Worksheet</a></div></div></details>
630
+ <label class="ws-kwcase" hidden>${tx('ws.kwcase')} <select data-ws-kwcase><option value="lower">${tx('ws.lower')}</option><option value="upper">${tx('ws.upper')}</option></select></label>
631
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>${tx('ws.help')}</a></div></div></details>
626
632
  <span class="ws-txn" data-ws-txn data-state="${tState}" data-statements="${txn?.statements ?? 0}" data-started="${txn?.startedMs ?? ''}" data-last="${txn?.lastMs ?? ''}" data-idle="${txnIdleSeconds()}" data-note="${tNote ?? ''}" data-now="${Date.now()}" hidden>
627
- <label class="ws-runas"><span>Commit</span><select name="txn" data-ws-txn-mode title="Autocommit: each statement commits on its own. Transaction: the statements stay in one transaction until Commit or Rollback.">
628
- <option value="">Autocommit</option><option value="on"${txnOn ? raw(' selected') : ''}>Transaction</option></select></label>
629
- <span class="ws-txn-badge" data-ws-txn-badge data-state="${tState}" role="status" title="${tState === 'none' ? '' : `Runs as ${txn?.runsAs ?? ''}`}">${badge}</span>
630
- <button class="btn btn-sm" name="mode" value="commit" data-ws-txn-commit title="Commit the worksheet's transaction"${txn ? '' : raw(' disabled')}>${icon('check')}<span>Commit</span></button>
631
- <button class="btn btn-sm" name="mode" value="rollback" data-ws-txn-rollback title="Roll back the worksheet's transaction"${txn ? '' : raw(' disabled')}>${icon('undo')}<span>Rollback</span></button>
633
+ <label class="ws-runas"><span>${tx('ws.commit_label')}</span><select name="txn" data-ws-txn-mode title="${tx('ws.txn.mode_title')}">
634
+ <option value="">${tx('ws.autocommit')}</option><option value="on"${txnOn ? raw(' selected') : ''}>${tx('ws.transaction')}</option></select></label>
635
+ <span class="ws-txn-badge" data-ws-txn-badge data-state="${tState}" role="status" title="${tState === 'none' ? '' : tx('ws.txn.runs_as_title', { who: txn?.runsAs ?? '' })}">${badge}</span>
636
+ <button class="btn btn-sm" name="mode" value="commit" data-ws-txn-commit title="${tx('ws.txn.commit_title')}"${txn ? '' : raw(' disabled')}>${icon('check')}<span>${tx('ws.commit_label')}</span></button>
637
+ <button class="btn btn-sm" name="mode" value="rollback" data-ws-txn-rollback title="${tx('ws.txn.rollback_title')}"${txn ? '' : raw(' disabled')}>${icon('undo')}<span>${tx('ws.rollback')}</span></button>
632
638
  </span>
633
- <label class="ws-runas"><span>Run as</span>
634
- <select name="as_app" title="Who the statements run as">
635
- <option value="">${db === owner ? 'Owner connection' : 'Workspace login'}</option>
639
+ <label class="ws-runas"><span>${tx('ws.run_as')}</span>
640
+ <select name="as_app" title="${tx('ws.run_as_title')}">
641
+ <option value="">${db === owner ? tx('ws.owner_conn') : tx('ws.ws_login')}</option>
636
642
  ${apps.map((a) => html`<option value="${a.id}"${asApp?.id === a.id ? raw(' selected') : ''}>${a.name} (${a.db_role})</option>`)}
637
643
  </select></label>
638
- <button type="button" class="btn btn-sm btn-icon" data-ws-clear title="Clear the editor" hidden>${icon('trash')}<span class="sr-only">Clear</span></button>
639
- <button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="Fullscreen (Escape leaves)" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">Fullscreen</span></button>
644
+ <button type="button" class="btn btn-sm btn-icon" data-ws-clear title="${tx('ws.clear_title')}" hidden>${icon('trash')}<span class="sr-only">${tx('ws.clear')}</span></button>
645
+ <button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="${tx('ws.fullscreen_title')}" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">${tx('ws.fullscreen')}</span></button>
640
646
  </div>
641
- <p class="ws-runs-as muted small">Runs as ${runsAs}${searchSchema ? html`; <code>${searchSchema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. <span data-ws-commit-note>${txnOn
642
- ? `The statements stay in one transaction until Commit or Rollback; it is rolled back after ${idleLabel()} without a run.`
643
- : 'Each statement commits on its own.'}</span></p>
647
+ <p class="ws-runs-as muted small">${tx('ws.runs_as', { who: runsAs })}${searchSchema ? html`; ${tx.rich('ws.search_path', { schema: html`<code>${searchSchema}</code>`, change: html`<a href="${BASE}/sql">${tx('ws.change')}</a>` })}` : ''}. <span data-ws-commit-note>${txnOn
648
+ ? tx('ws.commit.txn', { idle: idleLabel(tx) })
649
+ : tx('ws.commit.auto')}</span></p>
644
650
  ${fileError ? html`<p class="alert alert-error ws-file-error" role="alert">${fileError}</p>` : ''}
645
651
  ${txnError ? html`<p class="alert alert-error ws-txn-alert" role="alert">${txnError}</p>` : txnInfo ? html`<p class="alert alert-info ws-txn-alert" role="status">${txnInfo}</p>` : ''}
646
- <div class="ws-wtabs" role="group" aria-label="Worksheets" hidden></div>
652
+ <div class="ws-wtabs" role="group" aria-label="${tx('ws.worksheets')}" hidden></div>
647
653
  <div class="ws-findbar" hidden>
648
- <label class="sr-only" for="ws-find-q">Find</label><input id="ws-find-q" type="search" placeholder="Find" autocomplete="off" data-ws-find-q>
649
- <label class="sr-only" for="ws-find-r">Replace with</label><input id="ws-find-r" placeholder="Replace with" autocomplete="off" data-ws-find-r>
650
- <label class="ws-check"><input type="checkbox" data-ws-find-re> Regex</label>
651
- <label class="ws-check"><input type="checkbox" data-ws-find-cs> Match case</label>
652
- <button type="button" class="btn btn-sm" data-ws-find-next>Next</button>
653
- <button type="button" class="btn btn-sm" data-ws-find-replace>Replace</button>
654
- <button type="button" class="btn btn-sm" data-ws-find-all>Replace all</button>
655
- <label class="sr-only" for="ws-goto">Go to line</label><input id="ws-goto" type="number" min="1" placeholder="Line" class="ws-goto" data-ws-goto>
654
+ <label class="sr-only" for="ws-find-q">${tx('ws.find')}</label><input id="ws-find-q" type="search" placeholder="${tx('ws.find')}" autocomplete="off" data-ws-find-q>
655
+ <label class="sr-only" for="ws-find-r">${tx('ws.replace_with')}</label><input id="ws-find-r" placeholder="${tx('ws.replace_with')}" autocomplete="off" data-ws-find-r>
656
+ <label class="ws-check"><input type="checkbox" data-ws-find-re> ${tx('ws.regex')}</label>
657
+ <label class="ws-check"><input type="checkbox" data-ws-find-cs> ${tx('ws.match_case')}</label>
658
+ <button type="button" class="btn btn-sm" data-ws-find-next>${tx('ws.next')}</button>
659
+ <button type="button" class="btn btn-sm" data-ws-find-replace>${tx('ws.replace')}</button>
660
+ <button type="button" class="btn btn-sm" data-ws-find-all>${tx('ws.replace_all')}</button>
661
+ <label class="sr-only" for="ws-goto">${tx('ws.key.goto')}</label><input id="ws-goto" type="number" min="1" placeholder="${tx('ws.line_ph')}" class="ws-goto" data-ws-goto>
656
662
  <span class="muted small" data-ws-find-msg></span>
657
- <button type="button" class="btn btn-sm btn-icon" data-ws-find-close title="Close (Escape)">${icon('x-circle')}<span class="sr-only">Close</span></button>
663
+ <button type="button" class="btn btn-sm btn-icon" data-ws-find-close title="${tx('ws.close_esc')}">${icon('x-circle')}<span class="sr-only">${tx('ws.close')}</span></button>
658
664
  </div>
659
665
  <div class="ws-editor">
660
666
  <textarea id="ws-sql" name="sql" aria-label="SQL" class="code sql-editor" rows="14" spellcheck="false" data-code="plpgsql">${text || (isPost ? '' : 'select current_user, version();')}</textarea>
661
667
  </div>
662
- <div class="ws-split ws-split-h" role="separator" aria-orientation="horizontal" aria-label="Resize the output" tabindex="0" data-ws-split="out" hidden></div>
668
+ <div class="ws-split ws-split-h" role="separator" aria-orientation="horizontal" aria-label="${tx('ws.resize_out')}" tabindex="0" data-ws-split="out" hidden></div>
663
669
  <div class="ws-output" data-active="${active}">
664
- <div class="ws-tabs" role="tablist" aria-label="Output">${TABS.map(([k, label]) => html`<button type="button" role="tab" id="ws-tab-${k}" aria-controls="ws-pane-${k}" aria-selected="${k === active ? 'true' : 'false'}" data-ws-tab="${k}">${label}</button>`)}</div>
665
- ${TABS.map(([k, label]) => html`<section class="ws-pane" id="ws-pane-${k}" role="tabpanel" aria-labelledby="ws-tab-${k}" data-pane="${k}"><h2 class="ws-pane-title">${label}</h2>${panes[k]}</section>`)}
670
+ <div class="ws-tabs" role="tablist" aria-label="${tx('ws.output')}">${TABS.map(([k]) => html`<button type="button" role="tab" id="ws-tab-${k}" aria-controls="ws-pane-${k}" aria-selected="${k === active ? 'true' : 'false'}" data-ws-tab="${k}">${tx(`ws.tab.${k}`)}</button>`)}</div>
671
+ ${TABS.map(([k]) => html`<section class="ws-pane" id="ws-pane-${k}" role="tabpanel" aria-labelledby="ws-tab-${k}" data-pane="${k}"><h2 class="ws-pane-title">${tx(`ws.tab.${k}`)}</h2>${panes[k]}</section>`)}
666
672
  </div>
667
673
  </section>
668
674
  <footer class="ws-status" role="status" aria-live="polite">${status}</footer>
669
675
  </form>
670
676
  ${downloadable ? html`<form method="post" action="${BASE}/sql/unload" id="ws-dl" class="ws-dl-form">${csrf(s)}<input type="hidden" name="source" value="query"><input type="hidden" name="query" value="${downloadable}"><input type="hidden" name="header" value="1"><input type="hidden" name="bom" value="1"></form>` : ''}
671
677
  </div>`;
672
- return send(reply, s, shell(s, 'SQL Worksheet', [['SQL Workshop', `${BASE}/sql`], ['SQL Worksheet']], main, 'sql', { full: true }));
678
+ return send(reply, s, shell(s, tx('ws.title'), [['SQL Workshop', `${BASE}/sql`], [tx('ws.title')]], main, 'sql', { full: true }));
673
679
  }
674
680
 
675
681
  export async function worksheetRoutes(app: FastifyInstance) {
@@ -719,6 +725,6 @@ export async function worksheetRoutes(app: FastifyInstance) {
719
725
  if (req.query?.schema && !schemas.includes(String(req.query.schema))) return reply.code(404).send('Not found');
720
726
  const schema = await pickSchema(s, db, schemas, req.query?.schema);
721
727
  const type = NAV_TYPES.some(([k]) => k === req.query?.type) ? String(req.query!.type) : 'all';
722
- return reply.header('Cache-Control', 'private, no-store').type('text/html').send(String(await navigatorTree(db, schema, type)));
728
+ return reply.header('Cache-Control', 'private, no-store').type('text/html').send(String(await navigatorTree(db, schema, type, tr(s))));
723
729
  });
724
730
  }