pgkiln 0.56.0 → 0.58.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.
@@ -86,6 +86,16 @@ const LABELS: Record<string, BuilderKey> = {
86
86
  Settings: 'app.tab.settings',
87
87
  };
88
88
 
89
+ /**
90
+ * (sprint 123) The texts the builder's own JavaScript shows (keys js.*), in the language: shell() puts them in the
91
+ * page as JSON in an attribute (<div id="builder-texts" hidden data-texts="…">, escaped like any value; no inline
92
+ * script), and public/*.js read them with a small helper. English fills any gap.
93
+ */
94
+ export function clientTexts(lang: BuilderLang): Record<string, string> {
95
+ const own = MESSAGES[lang];
96
+ return Object.fromEntries(Object.keys(en).filter((k) => k.startsWith('js.')).map((k) => [k, own[k] ?? (en as Record<string, string>)[k]]));
97
+ }
98
+
89
99
  /** A translator: the language's messages (`own`, for tests: a partial file), then English, then the key itself. */
90
100
  export function builderTranslator(lang: BuilderLang, own: Readonly<Record<string, string>> = MESSAGES[lang]): BuilderTranslate {
91
101
  const text = (key: string): string | undefined => own[key] ?? (en as Record<string, string>)[key];
@@ -3,6 +3,7 @@ import pg from 'pg';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { readSchema } from '../ddl.ts';
6
+ import { tr } from './i18n.ts';
6
7
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
7
8
  import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
8
9
  import { objectMenu } from './object-actions.ts';
@@ -50,6 +51,7 @@ const TABLE_TABS = [
50
51
  ['columns', 'Columns'], ['data', 'Data'], ['indexes', 'Indexes'], ['constraints', 'Constraints'], ['triggers', 'Triggers'],
51
52
  ['grants', 'Grants'], ['rls', 'Row level security'], ['dependencies', 'Dependencies'], ['statistics', 'Statistics'], ['ddl', 'DDL'],
52
53
  ] as const;
54
+ type TabKey = (typeof TABLE_TABS)[number][0];
53
55
 
54
56
  /** CREATE statements the + menu and the tiles start with (the SQL Worksheet shows them; nothing runs until Run). */
55
57
  export function createTemplate(kind: string, schema: string): string | null {
@@ -110,6 +112,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
110
112
  app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
111
113
  const s = await developer(req, reply);
112
114
  if (!s) return;
115
+ const tx = tr(s);
113
116
  const db = await workshopDb(s);
114
117
  const q = req.query ?? {};
115
118
  const system = q.system === '1';
@@ -131,18 +134,18 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
131
134
  const link = (extra: Record<string, string>) => `?${new URLSearchParams({ schema, ...(system ? { system: '1' } : {}), ...(filter ? { q: filter } : {}), ...extra })}`;
132
135
 
133
136
  const tree = html`<form method="get" class="ob-filter" role="search"><input type="hidden" name="schema" value="${schema}">${system ? html`<input type="hidden" name="system" value="1">` : ''}
134
- <label class="sr-only" for="ob-q">Filter objects</label><input id="ob-q" name="q" type="search" value="${filter}" placeholder="Filter" data-filter-tree="ob-tree"></form>
137
+ <label class="sr-only" for="ob-q">${tx('ob.filter_label')}</label><input id="ob-q" name="q" type="search" value="${filter}" placeholder="${tx('ob.filter')}" data-filter-tree="ob-tree"></form>
135
138
  <div id="ob-tree">${OB_TYPES.map(([t, label, ic]) => {
136
139
  const list = shown.filter((o) => o.type === t);
137
140
  if (!list.length && filter) return '';
138
141
  return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
139
- <summary>${icon(ic)}<span>${label}</span><span class="kind">${list.length}</span></summary>
140
- <ul class="tree">${list.map((o) => html`<li class="ob-item"><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">extension</span>` : ''}</a>${objectMenu(schema, o)}</li>`)}</ul>
142
+ <summary>${icon(ic)}<span>${tx.dyn(`ob.type.${t}`, label)}</span><span class="kind">${list.length}</span></summary>
143
+ <ul class="tree">${list.map((o) => html`<li class="ob-item"><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">${tx('ob.extension')}</span>` : ''}</a>${objectMenu(schema, o)}</li>`)}</ul>
141
144
  </details>`;
142
145
  })}</div>`;
143
146
 
144
147
  let detail: Raw;
145
- if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
148
+ if (!schema) detail = html`<p class="muted">${tx('ob.no_schema')}</p>`;
146
149
  else if (selected) {
147
150
  // (sprint 96) pin the object to the SQL Workshop's home
148
151
  const href = `${BASE}/sql/objects?${new URLSearchParams({ schema, k: selected.type, o: selected.ref })}`;
@@ -150,33 +153,33 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
150
153
  detail = html`<div class="ob-pin">${pin}</div>${await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link)}`;
151
154
  }
152
155
  else
153
- detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">
154
- <a class="create-source" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema })}">${icon('table')}<span><b>Table (form)</b><small class="muted">Columns, keys, indexes and security in a form</small></span></a>${[
155
- ['table', 'table', 'Table'], ['view', 'layers', 'View'], ['function', 'code', 'Function'], ['sequence', 'hash', 'Sequence'], ['index', 'list', 'Index'],
156
- ].map(([k, ic, label]) => html`<a class="create-source" href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${label}</b><small class="muted">Start a CREATE ${label.toUpperCase()} statement</small></span></a>`)}
157
- <a class="create-source" href="${BASE}/sql/quick">${icon('bolt')}<span><b>Quick SQL</b><small class="muted">A data model in shorthand</small></span></a>
158
- <a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>Load data</b><small class="muted">A table from a file</small></span></a></div>
159
- <p class="muted">Select an object in the tree, or filter it by name.</p>`);
156
+ detail = region(tx('ob.create_in', { schema }), html`<div class="ob-tiles">
157
+ <a class="create-source" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema })}">${icon('table')}<span><b>${tx('ob.tile.table_form')}</b><small class="muted">${tx('ob.tile.table_form.desc')}</small></span></a>${(['table', 'view', 'function', 'sequence', 'index'] as const).map((k) => [k, { table: 'table', view: 'layers', function: 'code', sequence: 'hash', index: 'list' }[k]] as const)
158
+ .map(([k, ic]) => html`<a class="create-source" href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${tx(`ob.kind.${k}`)}</b><small class="muted">${tx('ob.tile.start', { what: k.toUpperCase() })}</small></span></a>`)}
159
+ <a class="create-source" href="${BASE}/sql/quick">${icon('bolt')}<span><b>${tx('ob.tile.quick')}</b><small class="muted">${tx('ob.tile.quick.desc')}</small></span></a>
160
+ <a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>${tx('ob.tile.load')}</b><small class="muted">${tx('ob.tile.load.desc')}</small></span></a></div>
161
+ <p class="muted">${tx('ob.select_hint')}</p>`);
160
162
 
161
163
  const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('objects', s)}
162
164
  <div class="ob-head">
163
165
  <form method="get" class="ob-schema">${filter ? html`<input type="hidden" name="q" value="${filter}">` : ''}
164
- <label class="label" for="ob-schema">Schema</label>
166
+ <label class="label" for="ob-schema">${tx('ob.schema')}</label>
165
167
  <select id="ob-schema" name="schema" data-autosubmit>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
166
- <label class="check"><input type="checkbox" name="system" value="1"${system ? raw(' checked') : ''} data-autosubmit> Show system objects</label>
167
- <button class="btn btn-sm">Show</button></form>
168
- <details class="menu ob-create"><summary class="btn btn-hot">${icon('plus')} Create</summary>
169
- <div class="menu-panel align-right"><div class="menu-section menu-links">${['table', 'view', 'function', 'sequence', 'index'].map((k) =>
170
- html`<a href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
168
+ <label class="check"><input type="checkbox" name="system" value="1"${system ? raw(' checked') : ''} data-autosubmit> ${tx('ob.system')}</label>
169
+ <button class="btn btn-sm">${tx('common.show')}</button></form>
170
+ <details class="menu ob-create"><summary class="btn btn-hot">${icon('plus')} ${tx('ob.create')}</summary>
171
+ <div class="menu-panel align-right"><div class="menu-section menu-links">${(['table', 'view', 'function', 'sequence', 'index'] as const).map((k) =>
172
+ html`<a href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${tx(`ob.kind.${k}`)}</a>`)}</div></div></details>
171
173
  </div>
172
- <div class="designer"><aside class="region region-standard ob-tree" aria-label="Database objects">${tree}</aside><div>${detail}</div></div>`;
173
- return send(reply, s, shell(s, 'Object Browser', [['SQL Workshop', `${BASE}/sql`], ['Object Browser']], main, 'sql'));
174
+ <div class="designer"><aside class="region region-standard ob-tree" aria-label="${tx('ob.db_objects')}">${tree}</aside><div>${detail}</div></div>`;
175
+ return send(reply, s, shell(s, tx('ob.title'), [['SQL Workshop', `${BASE}/sql`], [tx('ob.title')]], main, 'sql'));
174
176
  });
175
177
 
176
178
  // column actions: the ALTER TABLE statement, into the SQL Worksheet to review and run
177
179
  app.post(`${BASE}/sql/objects/alter`, async (req: Req, reply) => {
178
180
  const s = await developer(req, reply);
179
181
  if (!s) return;
182
+ const tx = tr(s);
180
183
  const db = await workshopDb(s);
181
184
  const b = req.body ?? {};
182
185
  const table = await db.one<{ q: string; ok: boolean }>(
@@ -188,7 +191,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
188
191
  ).catch(() => undefined);
189
192
  const backTo = `${BASE}/sql/objects?${new URLSearchParams({ schema: String(b.schema ?? ''), k: 'table', o: String(b.table ?? ''), tab: 'columns' })}`;
190
193
  if (!table?.ok) {
191
- flash(s, 'Choose a table you may use.', 'error');
194
+ flash(s, tx('ob.err.table'), 'error');
192
195
  return back(reply, s, `${BASE}/sql/objects`);
193
196
  }
194
197
  const col = String(b.column ?? '').trim();
@@ -198,14 +201,14 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
198
201
  let problem = '';
199
202
  if (b.action === 'add') {
200
203
  const type = String(b.type ?? '').trim();
201
- if (!NAME_RE.test(col)) problem = 'A column name is a letter or _ followed by letters, digits, _ or $.';
202
- else if (cols.includes(col)) problem = `${col} is a column already.`;
203
- else if (!TYPE_RE.test(type)) problem = 'A type is a name with an optional size, e.g. text, numeric(9,2), varchar(40), int[].';
204
+ if (!NAME_RE.test(col)) problem = tx('ob.err.col_name');
205
+ else if (cols.includes(col)) problem = tx('ob.err.col_exists', { column: col });
206
+ else if (!TYPE_RE.test(type)) problem = tx('ob.err.type');
204
207
  else sql = `alter table ${table.q} add column ${ident(col)} ${type}${b.not_null === 'true' ? ' not null' : ''};`;
205
208
  } else if (b.action === 'rename') {
206
209
  const to = String(b.new_name ?? '').trim();
207
- if (!cols.includes(col)) problem = 'Choose a column of the table.';
208
- else if (!NAME_RE.test(to) || cols.includes(to)) problem = 'The new name must be a valid name that the table doesn\'t have yet.';
210
+ if (!cols.includes(col)) problem = tx('ob.err.choose_col');
211
+ else if (!NAME_RE.test(to) || cols.includes(to)) problem = tx('ob.err.new_name');
209
212
  else sql = `alter table ${table.q} rename column ${ident(col)} to ${ident(to)};`;
210
213
  } else if (b.action === 'modify') {
211
214
  // (sprint 89, SQL-4) type, nulls and default in one ALTER TABLE; a default is one expression, never a statement
@@ -213,28 +216,28 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
213
216
  const expr = String(b.default ?? '').trim();
214
217
  const parts: string[] = [];
215
218
  const c = ident(col);
216
- if (!cols.includes(col)) problem = 'Choose a column of the table.';
217
- else if (type && !TYPE_RE.test(type)) problem = 'A type is a name with an optional size, e.g. text, numeric(9,2), varchar(40), int[].';
218
- else if (b.default_action === 'set' && (!expr || expr.length > 200 || /;|--|\/\*/.test(expr))) problem = 'A default is one expression (at most 200 characters, without ; or comments), e.g. now(), 0 or \'new\'.';
219
+ if (!cols.includes(col)) problem = tx('ob.err.choose_col');
220
+ else if (type && !TYPE_RE.test(type)) problem = tx('ob.err.type');
221
+ else if (b.default_action === 'set' && (!expr || expr.length > 200 || /;|--|\/\*/.test(expr))) problem = tx('ob.err.default');
219
222
  else {
220
223
  if (type) parts.push(`alter column ${c} type ${type} using ${c}::${type}`);
221
224
  if (b.nullable === 'not_null') parts.push(`alter column ${c} set not null`);
222
225
  else if (b.nullable === 'null') parts.push(`alter column ${c} drop not null`);
223
226
  if (b.default_action === 'set') parts.push(`alter column ${c} set default ${expr}`);
224
227
  else if (b.default_action === 'drop') parts.push(`alter column ${c} drop default`);
225
- if (!parts.length) problem = 'Nothing to change: choose a new type, nulls or a default.';
228
+ if (!parts.length) problem = tx('ob.err.nothing');
226
229
  else sql = `alter table ${table.q}\n ${parts.join(',\n ')};`;
227
230
  }
228
231
  } else if (b.action === 'drop') {
229
- if (!cols.includes(col)) problem = 'Choose a column of the table.';
232
+ if (!cols.includes(col)) problem = tx('ob.err.choose_col');
230
233
  else sql = `alter table ${table.q} drop column ${ident(col)};`;
231
- } else problem = 'Unknown action.';
234
+ } else problem = tx('ob.err.action');
232
235
  if (!sql) {
233
236
  flash(s, problem, 'error');
234
237
  return back(reply, s, backTo);
235
238
  }
236
239
  s.state.__SQLDRAFT = sql;
237
- flash(s, 'Review the statement and press Run: nothing has been changed yet.');
240
+ flash(s, tx('ob.review'));
238
241
  return back(reply, s, `${BASE}/sql/worksheet`);
239
242
  });
240
243
  }
@@ -270,8 +273,9 @@ export async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, sc
270
273
  return system ? rows : rows.filter((o) => !o.ext);
271
274
  }
272
275
 
273
- export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csrf_token: string }, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
274
- const table = (rows: Record<string, unknown>[], empty = 'None.') =>
276
+ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: Pick<Session, 'csrf_token' | 'state'>, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
277
+ const tx = tr(s);
278
+ const table = (rows: Record<string, unknown>[], empty = tx('ob.none')) =>
275
279
  rows.length
276
280
  ? html`<div class="table-wrap"><table class="report"><thead><tr>${Object.keys(rows[0]).map((k) => html`<th scope="col">${k}</th>`)}</tr></thead>
277
281
  <tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
@@ -279,22 +283,22 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
279
283
  const ddlOf = async (match: (x: { kind: string; name: string; table?: string }) => boolean) => {
280
284
  const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
281
285
  const text = objects.filter(match).map((x) => x.ddl).join('\n\n');
282
- return text ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="DDL of ${o.name}">${text}</pre>` : html`<p class="muted">No DDL (an extension's object, or not readable with this connection).</p>`;
286
+ return text ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('ob.ddl_of', { name: o.name })}">${text}</pre>` : html`<p class="muted">${tx('ob.no_ddl')}</p>`;
283
287
  };
284
288
  const relname = o.ref.includes('.') ? o.ref.slice(o.ref.lastIndexOf('.') + 1).replace(/^"|"$/g, '') : o.ref.replace(/^"|"$/g, '');
285
289
 
286
290
  if (o.type === 'function' || o.type === 'procedure') {
287
291
  const src = await db.one<{ def: string }>('select pg_get_functiondef($1::regprocedure) as def', [o.ref]).catch(() => undefined);
288
- return region(o.name, src ? html`<pre class="source" data-code-view="plpgsql" tabindex="0" aria-label="Source of ${o.name}">${src.def}</pre>` : html`<p class="muted">An aggregate or window function: no source.</p>`);
292
+ return region(o.name, src ? html`<pre class="source" data-code-view="plpgsql" tabindex="0" aria-label="${tx('ob.source_of', { name: o.name })}">${src.def}</pre>` : html`<p class="muted">${tx('ob.no_source')}</p>`);
289
293
  }
290
294
  if (o.type === 'trigger') {
291
295
  const [rel, name] = o.ref.split('|');
292
296
  const t = await db.one<{ def: string }>('select pg_get_triggerdef(oid, true) as def from pg_trigger where tgrelid = $1::regclass and tgname = $2', [rel, name]);
293
- return region(o.name, html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Definition of ${o.name}">${t?.def ?? ''}</pre>`);
297
+ return region(o.name, html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('ob.definition_of', { name: o.name })}">${t?.def ?? ''}</pre>`);
294
298
  }
295
299
  if (o.type === 'index') {
296
300
  const i = await db.one<{ def: string; size: string }>('select pg_get_indexdef($1::regclass) as def, pg_size_pretty(pg_relation_size($1::regclass)) as size', [o.ref]);
297
- return region(o.name, html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="Definition of ${o.name}">${i?.def ?? ''};</pre><p class="muted">Size: ${i?.size ?? ''}</p>`);
301
+ return region(o.name, html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('ob.definition_of', { name: o.name })}">${i?.def ?? ''};</pre><p class="muted">${tx('ob.size', { size: i?.size ?? '' })}</p>`);
298
302
  }
299
303
  if (o.type === 'sequence') {
300
304
  const q = await db.one<Record<string, unknown>>(
@@ -313,11 +317,11 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
313
317
  const tab = tabs.some(([k]) => k === tabAsked) ? tabAsked : 'columns';
314
318
  // (sprint 89, SQL-4) the tabs on one row: the main ones and the current one, the rest under More
315
319
  const MAIN = ['columns', 'data', 'indexes', 'constraints', 'ddl'];
316
- const tabLink = ([k, label]: readonly [string, string]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`;
320
+ const tabLink = ([k]: readonly [string, string]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${tx(`ob.tab.${k as TabKey}`)}</a>`;
317
321
  const shownTabs = tabs.filter(([k]) => MAIN.includes(k) || k === tab);
318
322
  const moreTabs = tabs.filter(([k]) => !shownTabs.some(([x]) => x === k));
319
323
  const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${shownTabs.map(tabLink)}${moreTabs.length
320
- ? html`<details class="menu ob-more"><summary>More ${icon('chevron-down')}</summary><div class="menu-panel"><div class="menu-section menu-links">${moreTabs.map(tabLink)}</div></div></details>`
324
+ ? html`<details class="menu ob-more"><summary>${tx('ob.more')} ${icon('chevron-down')}</summary><div class="menu-panel"><div class="menu-section menu-links">${moreTabs.map(tabLink)}</div></div></details>`
321
325
  : ''}</nav>`;
322
326
  let body: Raw;
323
327
  switch (tab) {
@@ -333,30 +337,30 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
333
337
  ).rows;
334
338
  const names = cols.map((c) => String(c.column));
335
339
  const hidden = html`${raw(`<input type="hidden" name="__csrf" value="${s.csrf_token}">`)}<input type="hidden" name="table" value="${o.ref}"><input type="hidden" name="schema" value="${schema}">`;
336
- const pick = (id: string) => html`<label class="label" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
340
+ const pick = (id: string) => html`<label class="label" for="${id}">${tx('ob.column')}</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
337
341
  // (sprint 89, SQL-4; APEX: the Columns toolbar) Add, Modify, Rename and Drop above the columns, each a small form
338
342
  body = html`${isTable
339
- ? html`<div class="ob-toolbar" role="toolbar" aria-label="Columns">
340
- <details class="menu ob-tool"><summary class="btn btn-sm">${icon('plus')} Add column</summary><div class="menu-panel">
343
+ ? html`<div class="ob-toolbar" role="toolbar" aria-label="${tx('ob.tab.columns')}">
344
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('plus')} ${tx('ob.add_col')}</summary><div class="menu-panel">
341
345
  <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
342
- <label class="label" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
343
- <label class="label" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
344
- <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
345
- <details class="menu ob-tool"><summary class="btn btn-sm">${icon('edit')} Modify column</summary><div class="menu-panel">
346
+ <label class="label" for="ob-add-name">${tx('ob.name')}</label><input id="ob-add-name" name="column" placeholder="name" required>
347
+ <label class="label" for="ob-add-type">${tx('ob.type')}</label><input id="ob-add-type" name="type" placeholder="text" required>
348
+ <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm btn-hot">${tx('ob.show_sql')}</button></form></div></details>
349
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('edit')} ${tx('ob.modify_col')}</summary><div class="menu-panel">
346
350
  <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="modify">
347
351
  ${pick('ob-mod-col')}
348
- <label class="label" for="ob-mod-type">New type</label><input id="ob-mod-type" name="type" placeholder="the same">
349
- <label class="label" for="ob-mod-null">Nulls</label><select id="ob-mod-null" name="nullable"><option value="">the same</option><option value="null">allowed</option><option value="not_null">not null</option></select>
350
- <label class="label" for="ob-mod-def">Default</label><select id="ob-mod-def" name="default_action"><option value="">the same</option><option value="set">set to</option><option value="drop">none</option></select>
351
- <label class="sr-only" for="ob-mod-expr">Default expression</label><input id="ob-mod-expr" name="default" placeholder="now(), 0, 'x'">
352
- <button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
353
- <details class="menu ob-tool"><summary class="btn btn-sm">${icon('tag')} Rename column</summary><div class="menu-panel">
352
+ <label class="label" for="ob-mod-type">${tx('ob.new_type')}</label><input id="ob-mod-type" name="type" placeholder="${tx('ob.the_same')}">
353
+ <label class="label" for="ob-mod-null">${tx('ob.nulls')}</label><select id="ob-mod-null" name="nullable"><option value="">${tx('ob.the_same')}</option><option value="null">${tx('ob.allowed')}</option><option value="not_null">not null</option></select>
354
+ <label class="label" for="ob-mod-def">${tx('ob.default')}</label><select id="ob-mod-def" name="default_action"><option value="">${tx('ob.the_same')}</option><option value="set">${tx('ob.set_to')}</option><option value="drop">${tx('ob.default_none')}</option></select>
355
+ <label class="sr-only" for="ob-mod-expr">${tx('ob.default_expr')}</label><input id="ob-mod-expr" name="default" placeholder="now(), 0, 'x'">
356
+ <button class="btn btn-sm btn-hot">${tx('ob.show_sql')}</button></form></div></details>
357
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('tag')} ${tx('ob.rename_col')}</summary><div class="menu-panel">
354
358
  <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
355
- ${pick('ob-ren-col')}<label class="label" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
356
- <details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} Drop column</summary><div class="menu-panel">
359
+ ${pick('ob-ren-col')}<label class="label" for="ob-ren-to">${tx('ob.new_name')}</label><input id="ob-ren-to" name="new_name" placeholder="${tx('ob.new_name_ph')}" required><button class="btn btn-sm btn-hot">${tx('ob.show_sql')}</button></form></div></details>
360
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} ${tx('ob.drop_col')}</summary><div class="menu-panel">
357
361
  <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
358
- ${pick('ob-drop-col')}<button class="btn btn-sm btn-danger">Show the SQL</button></form></div></details>
359
- <span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into the SQL Worksheet: review it there and run it.</span>
362
+ ${pick('ob-drop-col')}<button class="btn btn-sm btn-danger">${tx('ob.show_sql')}</button></form></div></details>
363
+ <span class="muted ob-toolbar-note">${tx('ob.toolbar_note')}</span>
360
364
  </div>`
361
365
  : ''}${table(cols)}`;
362
366
  break;
@@ -369,9 +373,9 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
369
373
  const more = data.rows.length > PAGE_SIZE;
370
374
  const rows = data.rows.slice(0, PAGE_SIZE).map((r: unknown[]) => Object.fromEntries(data.fields.map((f, i) => [f.name, r[i]])));
371
375
  const p = offset / PAGE_SIZE + 1;
372
- body = html`${table(rows, 'No rows.')}<nav class="pager" aria-label="Pages">${p > 1 ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p - 1) })}">‹ Previous</a>` : ''}
373
- <span>Rows ${rows.length ? offset + 1 : 0}–${offset + rows.length}</span>${more ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p + 1) })}">Next ›</a>` : ''}</nav>
374
- <p class="muted">To change rows, use the SQL Worksheet, or an interactive grid on the table in an application.</p>`;
376
+ body = html`${table(rows, tx('ob.no_rows'))}<nav class="pager" aria-label="${tx('ob.pages')}">${p > 1 ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p - 1) })}">${tx('ob.previous')}</a>` : ''}
377
+ <span>${tx('ob.rows_range', { from: rows.length ? offset + 1 : 0, to: offset + rows.length })}</span>${more ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p + 1) })}">${tx('ob.next')}</a>` : ''}</nav>
378
+ <p class="muted">${tx('ob.change_rows')}</p>`;
375
379
  }
376
380
  break;
377
381
  }
@@ -392,25 +396,25 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
392
396
  break;
393
397
  case 'rls': {
394
398
  const on = await db.one<{ rls: boolean; force: boolean }>('select relrowsecurity as rls, relforcerowsecurity as force from pg_class where oid = $1::regclass', [o.ref]);
395
- body = html`<p>${on?.rls ? html`<b>Row level security is on</b>${on.force ? ' (forced for the owner too)' : ''}.` : 'Row level security is off.'}</p>
396
- ${table((await db.query(`select policyname as policy, permissive, cmd, roles::text, qual as "using", with_check from pg_policies where schemaname = $1 and tablename = $2 order by policyname`, [schema, relname])).rows, 'No policies.')}`;
399
+ body = html`<p>${on?.rls ? html`<b>${tx('ob.rls_on')}</b>${on.force ? ` ${tx('ob.rls_forced')}` : ''}.` : tx('ob.rls_off')}</p>
400
+ ${table((await db.query(`select policyname as policy, permissive, cmd, roles::text, qual as "using", with_check from pg_policies where schemaname = $1 and tablename = $2 order by policyname`, [schema, relname])).rows, tx('ob.no_policies'))}`;
397
401
  break;
398
402
  }
399
403
  case 'dependencies':
400
- body = html`<h3>Used by</h3>${table((await db.query(
404
+ body = html`<h3>${tx('ob.used_by')}</h3>${table((await db.query(
401
405
  `select distinct dc.oid::regclass::text as object, case dc.relkind when 'v' then 'view' when 'm' then 'materialized view' else 'table' end as type
402
406
  from pg_depend d join pg_rewrite r on r.oid = d.objid join pg_class dc on dc.oid = r.ev_class
403
407
  where d.classid = 'pg_rewrite'::regclass and d.refobjid = $1::regclass and dc.oid <> $1::regclass
404
408
  union
405
409
  select conrelid::regclass::text, 'foreign key ' || conname from pg_constraint where confrelid = $1::regclass and contype = 'f' and conrelid <> $1::regclass
406
- order by 1`, [o.ref])).rows, 'Nothing refers to it.')}
407
- <h3>Uses</h3>${table((await db.query(
410
+ order by 1`, [o.ref])).rows, tx('ob.nothing_refers'))}
411
+ <h3>${tx('ob.uses')}</h3>${table((await db.query(
408
412
  `select distinct d.refobjid::regclass::text as object, 'table or view' as type
409
413
  from pg_rewrite r join pg_depend d on d.objid = r.oid and d.classid = 'pg_rewrite'::regclass and d.refclassid = 'pg_class'::regclass
410
414
  where r.ev_class = $1::regclass and d.refobjid <> $1::regclass
411
415
  union
412
416
  select confrelid::regclass::text, 'foreign key ' || conname from pg_constraint where conrelid = $1::regclass and contype = 'f' and confrelid <> $1::regclass
413
- order by 1`, [o.ref])).rows, 'It refers to nothing else.')}`;
417
+ order by 1`, [o.ref])).rows, tx('ob.refers_nothing'))}`;
414
418
  break;
415
419
  case 'statistics':
416
420
  body = table([(await db.one(
@@ -422,5 +426,5 @@ export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s
422
426
  body = await ddlOf((x) => (x.kind === 'table' || x.kind === 'view' || x.kind === 'materialized view') ? x.name === relname : x.table === relname);
423
427
  }
424
428
  return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}${o.type === 'table'
425
- ? html` <a class="btn btn-sm" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}">${icon('edit')} Edit table</a> <a class="btn btn-sm" href="${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}">${icon('shield')} Security</a>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
429
+ ? html` <a class="btn btn-sm" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}">${icon('edit')} ${tx('ob.edit_table')}</a> <a class="btn btn-sm" href="${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}">${icon('shield')} ${tx('ob.security')}</a>` : ''}</h2>${nav}${region(tx(`ob.tab.${tab as TabKey}`), body)}`;
426
430
  }