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.
- package/SECURITY.md +1 -0
- package/docs/apex-feature-parity.md +1 -1
- package/docs/guide/03-builder.md +3 -2
- package/docs/guide/12-development.md +14 -0
- package/package.json +1 -1
- package/public/builder.css +1 -1
- package/public/worksheet.js +31 -26
- package/src/builder/i18n/de.ts +371 -0
- package/src/builder/i18n/en.ts +371 -0
- package/src/builder/i18n/es.ts +371 -0
- package/src/builder/i18n/fr.ts +371 -0
- package/src/builder/i18n/nl.ts +371 -0
- package/src/builder/i18n/pt.ts +371 -0
- package/src/builder/i18n.ts +10 -0
- package/src/builder/object-browser.ts +71 -67
- package/src/builder/table-editor.ts +51 -48
- package/src/builder/ui.ts +3 -2
- package/src/builder/worksheet.ts +160 -154
package/src/builder/worksheet.ts
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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$/, '')
|
|
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}"
|
|
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"
|
|
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>) =>
|
|
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
|
|
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: '
|
|
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
|
|
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="
|
|
161
|
-
<button class="btn btn-sm" name="mode" value="chart" title="
|
|
162
|
-
<button type="button" class="btn btn-sm" data-ws-copy hidden>${icon('copy')}<span
|
|
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"
|
|
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 ? '
|
|
170
|
-
: html`<p class="muted"
|
|
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"
|
|
175
|
-
const head = html`<span class="ws-log-sql"
|
|
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"
|
|
178
|
-
const body = r.columns ? `${textTable(r.columns, r.data ?? [])}\n${r.more ?
|
|
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
|
|
182
|
-
: html`<p class="muted"
|
|
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"
|
|
186
|
-
: html`<p class="muted"
|
|
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 ?? '
|
|
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"
|
|
196
|
-
<label class="sr-only" for="ws-hist-s"
|
|
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' ?
|
|
200
|
-
<a href="${BASE}/sql/worksheet?history=${h.id}" data-ws-history="${h.sql}" title="
|
|
201
|
-
<small class="muted ws-hist-meta">${h.status === 'ok' && h.rows !== null ? `${h.rows
|
|
202
|
-
<button type="button" class="btn btn-sm" data-ws-run="${h.sql}" title="
|
|
203
|
-
<button type="button" class="btn btn-sm btn-icon" data-ws-copy-text="${h.sql}" title="
|
|
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"
|
|
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 ? '
|
|
210
|
-
?
|
|
211
|
-
: mode === 'explain' || mode === 'autotrace' ?
|
|
212
|
-
: query && ran.length === 1 ?
|
|
213
|
-
:
|
|
214
|
-
const status = html`<span class="ws-count ${errors.length ? 'is-err' : ''}" title="
|
|
215
|
-
<span class="ws-count ${warnings ? 'is-warn' : ''}" title="
|
|
216
|
-
<span class="ws-busy" hidden>${icon('refresh')}
|
|
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"
|
|
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 ?
|
|
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 ?? '
|
|
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 ? '
|
|
232
|
-
return `${state === 'aborted' ? '
|
|
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
|
-
|
|
238
|
-
if (t
|
|
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:
|
|
246
|
-
if (action === 'commit' && ended.tag !== 'COMMIT') return { ok: false, message: '
|
|
247
|
-
const what =
|
|
248
|
-
return { ok: true, message: action === 'commit' ?
|
|
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 = '
|
|
307
|
-
else if (text.length > 5_000_000) fileError = '
|
|
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 = '
|
|
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 = '
|
|
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 =
|
|
327
|
+
fileError = tx('ws.file.exists', { name });
|
|
322
328
|
}
|
|
323
329
|
if (!fileError && script) {
|
|
324
|
-
flash(s, body.mode === 'rename' ?
|
|
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', '
|
|
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 = '
|
|
428
|
-
else if (txnAsked && !tabKey) txnError = '
|
|
429
|
-
else if (txn && (asApp?.id ?? null) !== txn.asAppId) txnError =
|
|
430
|
-
else if (txn?.busy) txnError = '
|
|
431
|
-
else if (blocked) txnError =
|
|
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 ?
|
|
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 =
|
|
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: '
|
|
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 = '
|
|
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"
|
|
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')}
|
|
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"
|
|
526
|
-
<button class="btn btn-sm btn-hot" formaction="${BASE}/sql/charts">${icon('save')}
|
|
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"
|
|
543
|
-
<p class="muted small"
|
|
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>&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>&${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')}
|
|
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 ?
|
|
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 = '
|
|
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"
|
|
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"
|
|
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="
|
|
568
|
-
<div class="ws-nav-switch" role="tablist" aria-label="
|
|
569
|
-
<button type="button" role="tab" aria-selected="true" aria-controls="ws-nav-objects" data-ws-navtab="objects"
|
|
570
|
-
<button type="button" role="tab" aria-selected="false" aria-controls="ws-files" data-ws-navtab="files"
|
|
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"
|
|
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"
|
|
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"
|
|
580
|
-
<input id="ws-filter" type="search" placeholder="
|
|
581
|
-
<button class="btn btn-sm btn-icon" name="mode" value="nav" title="
|
|
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="
|
|
587
|
-
<h2 class="ws-files-title"
|
|
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"
|
|
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="
|
|
592
|
-
<section class="ws-main" aria-label="
|
|
593
|
-
<div class="ws-toolbar" role="toolbar" aria-label="
|
|
594
|
-
<button type="button" class="btn btn-sm btn-icon" data-ws-toggle-nav title="
|
|
595
|
-
<details class="menu ws-file"><summary class="btn btn-sm" title="
|
|
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"
|
|
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="
|
|
601
|
-
${script ? html`<button class="btn btn-sm" name="mode" value="save_as"
|
|
602
|
-
<button class="btn btn-sm" name="mode" value="download">${icon('download')}
|
|
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')}
|
|
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="
|
|
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', '
|
|
609
|
-
${toolBtn('script', 'file', '
|
|
610
|
-
${toolBtn('explain', 'route', '
|
|
611
|
-
${toolBtn('autotrace', 'activity', '
|
|
612
|
-
<label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}>
|
|
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="
|
|
615
|
-
<button type="button" class="btn btn-sm btn-icon" data-ws-case title="
|
|
616
|
-
<button type="button" class="btn btn-sm btn-icon" data-ws-find title="
|
|
617
|
-
<a class="btn btn-sm btn-icon" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="
|
|
618
|
-
<details class="menu ws-keys"><summary class="btn btn-sm btn-icon" title="
|
|
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
|
|
621
|
-
<dt>Ctrl+S</dt><dd
|
|
622
|
-
<dt>Ctrl+G</dt><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
|
|
625
|
-
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help
|
|
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
|
|
628
|
-
<option value=""
|
|
629
|
-
<span class="ws-txn-badge" data-ws-txn-badge data-state="${tState}" role="status" title="${tState === 'none' ? '' :
|
|
630
|
-
<button class="btn btn-sm" name="mode" value="commit" data-ws-txn-commit title="
|
|
631
|
-
<button class="btn btn-sm" name="mode" value="rollback" data-ws-txn-rollback title="
|
|
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
|
|
634
|
-
<select name="as_app" title="
|
|
635
|
-
<option value="">${db === owner ? '
|
|
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="
|
|
639
|
-
<button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="
|
|
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"
|
|
642
|
-
?
|
|
643
|
-
: '
|
|
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="
|
|
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"
|
|
649
|
-
<label class="sr-only" for="ws-find-r"
|
|
650
|
-
<label class="ws-check"><input type="checkbox" data-ws-find-re>
|
|
651
|
-
<label class="ws-check"><input type="checkbox" data-ws-find-cs>
|
|
652
|
-
<button type="button" class="btn btn-sm" data-ws-find-next
|
|
653
|
-
<button type="button" class="btn btn-sm" data-ws-find-replace
|
|
654
|
-
<button type="button" class="btn btn-sm" data-ws-find-all
|
|
655
|
-
<label class="sr-only" for="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="
|
|
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="
|
|
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="
|
|
665
|
-
${TABS.map(([k
|
|
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, '
|
|
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
|
}
|