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.
@@ -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
  }
@@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { esc, html, raw, type Raw } from '../html.ts';
3
3
  import { icon } from '../icons.ts';
4
4
  import { clientIp, logActivity, type Session } from '../session.ts';
5
+ import { tr } from './i18n.ts';
5
6
  import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
6
7
  import { SYSTEM_SCHEMAS } from './object-browser.ts';
7
8
  import { clearCompletions } from './code-editor.ts';
@@ -77,76 +78,78 @@ async function tables(db: Awaited<ReturnType<typeof workshopDb>>) {
77
78
 
78
79
  function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schemas: string[]; refTables: string[]; ddl: string[]; errors: string[]; output: Raw | ''; tab: string; ref: string }): Raw {
79
80
  const { editing } = opts;
81
+ const tx = tr(s);
80
82
  const chk = (on: boolean) => (on ? raw(' checked') : '');
81
83
  const sel = (v: string, cur: string) => (v === cur ? raw(' selected') : '');
82
84
  const typeList = html`<datalist id="te-types">${TYPES.map((t) => html`<option value="${t}">`)}</datalist>`;
83
85
  const colRow = (c: ColumnDef | null, i: number) => html`<tr class="te-row${c ? '' : ' te-spare'}">
84
- <td class="te-move"><button type="button" class="btn btn-sm btn-icon" data-te-up title="Move up" hidden>↑</button><button type="button" class="btn btn-sm btn-icon" data-te-down title="Move down" hidden>↓</button></td>
85
- <td><input type="hidden" name="col.${i}.orig" value="${c?.orig ?? ''}"><input id="te-c${i}-name" aria-label="Name" name="col.${i}.name" value="${c?.name ?? ''}" autocomplete="off" spellcheck="false"></td>
86
- <td><input id="te-c${i}-type" aria-label="Type" name="col.${i}.type" value="${c?.type ?? ''}" list="te-types" autocomplete="off" spellcheck="false"></td>
87
- <td class="te-c"><input type="checkbox" aria-label="Primary key" id="te-c${i}-pk" name="col.${i}.pk"${chk(!!c?.pk)}></td>
88
- <td class="te-c"><input type="checkbox" aria-label="Not null" id="te-c${i}-nn" name="col.${i}.nn"${chk(!!c?.notNull)}></td>
86
+ <td class="te-move"><button type="button" class="btn btn-sm btn-icon" data-te-up title="${tx('te.move_up')}" hidden>↑</button><button type="button" class="btn btn-sm btn-icon" data-te-down title="${tx('te.move_down')}" hidden>↓</button></td>
87
+ <td><input type="hidden" name="col.${i}.orig" value="${c?.orig ?? ''}"><input id="te-c${i}-name" aria-label="${tx('te.name')}" name="col.${i}.name" value="${c?.name ?? ''}" autocomplete="off" spellcheck="false"></td>
88
+ <td><input id="te-c${i}-type" aria-label="${tx('te.type')}" name="col.${i}.type" value="${c?.type ?? ''}" list="te-types" autocomplete="off" spellcheck="false"></td>
89
+ <td class="te-c"><input type="checkbox" aria-label="${tx('te.pk')}" id="te-c${i}-pk" name="col.${i}.pk"${chk(!!c?.pk)}></td>
90
+ <td class="te-c"><input type="checkbox" aria-label="${tx('te.not_null')}" id="te-c${i}-nn" name="col.${i}.nn"${chk(!!c?.notNull)}></td>
89
91
  <td>${c?.generated
90
- ? html`<code class="te-generated" title="Generated (stored)">= ${c.generated}</code><input type="hidden" name="col.${i}.def" value="">`
91
- : html`<input id="te-c${i}-def" aria-label="Default" name="col.${i}.def" value="${c?.default ?? ''}" class="code" spellcheck="false">`}</td>
92
- <td><select id="te-c${i}-id" aria-label="Identity" name="col.${i}.identity"><option value="">-</option><option value="by default"${sel('by default', c?.identity ?? '')}>by default</option><option value="always"${sel('always', c?.identity ?? '')}>always</option></select></td>
93
- <td><input id="te-c${i}-cm" aria-label="Comment" name="col.${i}.comment" value="${c?.comment ?? ''}"></td>
94
- <td class="te-c"><label class="te-drop"><input type="checkbox" name="col.${i}.drop"><span>Remove</span></label></td></tr>`;
92
+ ? html`<code class="te-generated" title="${tx('te.generated')}">= ${c.generated}</code><input type="hidden" name="col.${i}.def" value="">`
93
+ : html`<input id="te-c${i}-def" aria-label="${tx('te.default')}" name="col.${i}.def" value="${c?.default ?? ''}" class="code" spellcheck="false">`}</td>
94
+ <td><select id="te-c${i}-id" aria-label="${tx('te.identity')}" name="col.${i}.identity"><option value="">-</option><option value="by default"${sel('by default', c?.identity ?? '')}>by default</option><option value="always"${sel('always', c?.identity ?? '')}>always</option></select></td>
95
+ <td><input id="te-c${i}-cm" aria-label="${tx('te.comment')}" name="col.${i}.comment" value="${c?.comment ?? ''}"></td>
96
+ <td class="te-c"><label class="te-drop"><input type="checkbox" name="col.${i}.drop"><span>${tx('te.remove')}</span></label></td></tr>`;
95
97
  const simpleRows = <T,>(list: T[], prefix: string, cells: (x: T | null, i: number) => Raw) =>
96
- [...list, ...Array<null>(SPARE).fill(null)].map((x, i) => html`<tr class="te-row${x ? '' : ' te-spare'}">${cells(x, i)}<td class="te-c"><label class="te-drop"><input type="checkbox" name="${prefix}.${i}.drop"><span>Remove</span></label></td></tr>`);
98
+ [...list, ...Array<null>(SPARE).fill(null)].map((x, i) => html`<tr class="te-row${x ? '' : ' te-spare'}">${cells(x, i)}<td class="te-c"><label class="te-drop"><input type="checkbox" name="${prefix}.${i}.drop"><span>${tx('te.remove')}</span></label></td></tr>`);
97
99
  // extra: attributes written by this file (constants, or values escaped with esc())
98
100
  const input = (name: string, value: string, label: string, extra = '') => html`<input id="te-${name}" aria-label="${label}" name="${name}" value="${value}" autocomplete="off" spellcheck="false"${extra ? raw(` ${extra}`) : ''}>`;
99
101
  const sections: [string, string, Raw][] = [
100
- ['columns', 'Columns', html`<div class="table-wrap"><table class="report te-table" data-te-grid="col"><thead><tr><th></th><th>Name</th><th>Type</th><th>Key</th><th>Not null</th><th>Default</th><th>Identity</th><th>Comment</th><th></th></tr></thead>
102
+ ['columns', tx('te.columns'), html`<div class="table-wrap"><table class="report te-table" data-te-grid="col"><thead><tr><th></th><th>${tx('te.name')}</th><th>${tx('te.type')}</th><th>${tx('te.key')}</th><th>${tx('te.not_null')}</th><th>${tx('te.default')}</th><th>${tx('te.identity')}</th><th>${tx('te.comment')}</th><th></th></tr></thead>
101
103
  <tbody>${[...def.columns, ...Array<null>(SPARE).fill(null)].map((c, i) => colRow(c, i))}</tbody></table></div>
102
- <div class="buttons"><button type="button" class="btn btn-sm" data-te-add="col" hidden>${icon('plus')} Add column</button></div>
103
- <div class="field u-mt1"><label class="label" for="te-pk_name">Primary key name</label>${input('pk_name', def.pkName, 'Primary key name', `placeholder="${esc(def.name ? `${def.name}_pkey` : 'table_pkey')}"`)}<small class="help">Tick Key on the columns of the primary key.</small></div>`],
104
- ['keys', 'Unique keys', html`<div class="table-wrap"><table class="report te-table" data-te-grid="uk"><thead><tr><th>Name</th><th>Columns (comma-separated)</th><th></th></tr></thead><tbody>
105
- ${simpleRows(def.uniques, 'uk', (k, i) => html`<td>${input(`uk.${i}.name`, k?.name ?? '', 'Name')}</td><td>${input(`uk.${i}.cols`, k?.columns ?? '', 'Columns')}</td>`)}</tbody></table></div>`],
106
- ['fks', 'Foreign keys', html`<div class="table-wrap"><table class="report te-table" data-te-grid="fk"><thead><tr><th>Name</th><th>Columns</th><th>References</th><th>Their columns</th><th>On delete</th><th></th></tr></thead><tbody>
107
- ${simpleRows(def.fks, 'fk', (f, i) => html`<td>${input(`fk.${i}.name`, f?.name ?? '', 'Name')}</td><td>${input(`fk.${i}.cols`, f?.columns ?? '', 'Columns')}</td>
108
- <td><select id="te-fk${i}-ref" aria-label="References" name="fk.${i}.ref"><option value="">-</option>${opts.refTables.map((t) => html`<option value="${t}"${sel(t, f?.refTable ?? '')}>${t}</option>`)}</select></td>
109
- <td>${input(`fk.${i}.refcols`, f?.refColumns ?? '', 'Their columns', 'placeholder="its primary key"')}</td>
110
- <td><select id="te-fk${i}-del" aria-label="On delete" name="fk.${i}.del">${ON_DELETE.map((d) => html`<option value="${d}"${sel(d, f?.onDelete ?? 'no action')}>${d}</option>`)}</select></td>`)}</tbody></table></div>`],
111
- ['checks', 'Checks', html`<div class="table-wrap"><table class="report te-table" data-te-grid="ck"><thead><tr><th>Name</th><th>Condition</th><th></th></tr></thead><tbody>
112
- ${simpleRows(def.checks, 'ck', (k, i) => html`<td>${input(`ck.${i}.name`, k?.name ?? '', 'Name')}</td><td>${input(`ck.${i}.expr`, k?.expr ?? '', 'Condition', 'class="code" placeholder="salary &gt;= 0"')}</td>`)}</tbody></table></div>`],
113
- ['indexes', 'Indexes', html`<div class="table-wrap"><table class="report te-table" data-te-grid="ix"><thead><tr><th>Name</th><th>Columns or expressions</th><th>Unique</th><th></th></tr></thead><tbody>
114
- ${simpleRows(def.indexes.filter((i) => !i.fixed), 'ix', (x, i) => html`<td>${input(`ix.${i}.name`, x?.name ?? '', 'Name')}</td><td>${input(`ix.${i}.cols`, x?.columns ?? '', 'Columns', 'class="code" placeholder="lower(name), created_at desc"')}</td>
115
- <td class="te-c"><input type="checkbox" aria-label="Unique" id="te-ix${i}-u" name="ix.${i}.unique"${chk(!!x?.unique)}></td>`)}</tbody></table></div>
116
- ${def.indexes.some((i) => i.fixed) ? html`<p class="muted small">Kept as they are (another method or a condition): ${def.indexes.filter((i) => i.fixed).map((i) => html`<code>${i.fixed}</code> `)}</p>` : ''}`],
117
- ['more', 'Comment and security', html`<div class="field"><label class="label" for="te-comment">Comment</label><textarea id="te-comment" name="comment" rows="2">${def.comment}</textarea></div>
118
- <label class="check"><input type="checkbox" name="rls"${chk(def.rls)}> Row level security: rows only through policies</label>
119
- <label class="check"><input type="checkbox" name="force_rls"${chk(def.forceRls)}> Force it for the table's owner too</label>
120
- ${editing ? '' : html`<fieldset class="prop-group u-mt1"><legend>Partitioning</legend><div class="form-grid">
121
- <div class="field"><label class="label" for="te-part_by">Partition by</label><select id="te-part_by" name="part_by"><option value="">Not partitioned</option>${(['range', 'list', 'hash'] as const).map((p) => html`<option value="${p}"${sel(p, def.partitionBy)}>${p}</option>`)}</select></div>
122
- <div class="field"><label class="label" for="te-part_key">Key</label>${input('part_key', def.partitionKey, 'Key', 'class="code" placeholder="created_at"')}</div></div>
123
- <small class="help">The partitions themselves are tables made with CREATE TABLE … PARTITION OF in the worksheet.</small></fieldset>`}`],
124
- ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
125
- ['output', 'Output', opts.output || html`<p class="muted">Apply shows what happened here.</p>`],
104
+ <div class="buttons"><button type="button" class="btn btn-sm" data-te-add="col" hidden>${icon('plus')} ${tx('te.add_col')}</button></div>
105
+ <div class="field u-mt1"><label class="label" for="te-pk_name">${tx('te.pk_name')}</label>${input('pk_name', def.pkName, tx('te.pk_name'), `placeholder="${esc(def.name ? `${def.name}_pkey` : 'table_pkey')}"`)}<small class="help">${tx('te.pk_help')}</small></div>`],
106
+ ['keys', tx('te.uniques'), html`<div class="table-wrap"><table class="report te-table" data-te-grid="uk"><thead><tr><th>${tx('te.name')}</th><th>${tx('te.cols_comma')}</th><th></th></tr></thead><tbody>
107
+ ${simpleRows(def.uniques, 'uk', (k, i) => html`<td>${input(`uk.${i}.name`, k?.name ?? '', tx('te.name'))}</td><td>${input(`uk.${i}.cols`, k?.columns ?? '', tx('te.columns'))}</td>`)}</tbody></table></div>`],
108
+ ['fks', tx('te.fks'), html`<div class="table-wrap"><table class="report te-table" data-te-grid="fk"><thead><tr><th>${tx('te.name')}</th><th>${tx('te.columns')}</th><th>${tx('te.references')}</th><th>${tx('te.their_cols')}</th><th>${tx('te.on_delete')}</th><th></th></tr></thead><tbody>
109
+ ${simpleRows(def.fks, 'fk', (f, i) => html`<td>${input(`fk.${i}.name`, f?.name ?? '', tx('te.name'))}</td><td>${input(`fk.${i}.cols`, f?.columns ?? '', tx('te.columns'))}</td>
110
+ <td><select id="te-fk${i}-ref" aria-label="${tx('te.references')}" name="fk.${i}.ref"><option value="">-</option>${opts.refTables.map((t) => html`<option value="${t}"${sel(t, f?.refTable ?? '')}>${t}</option>`)}</select></td>
111
+ <td>${input(`fk.${i}.refcols`, f?.refColumns ?? '', tx('te.their_cols'), `placeholder="${esc(tx('te.its_pk'))}"`)}</td>
112
+ <td><select id="te-fk${i}-del" aria-label="${tx('te.on_delete')}" name="fk.${i}.del">${ON_DELETE.map((d) => html`<option value="${d}"${sel(d, f?.onDelete ?? 'no action')}>${d}</option>`)}</select></td>`)}</tbody></table></div>`],
113
+ ['checks', tx('te.checks'), html`<div class="table-wrap"><table class="report te-table" data-te-grid="ck"><thead><tr><th>${tx('te.name')}</th><th>${tx('te.condition')}</th><th></th></tr></thead><tbody>
114
+ ${simpleRows(def.checks, 'ck', (k, i) => html`<td>${input(`ck.${i}.name`, k?.name ?? '', tx('te.name'))}</td><td>${input(`ck.${i}.expr`, k?.expr ?? '', tx('te.condition'), 'class="code" placeholder="salary &gt;= 0"')}</td>`)}</tbody></table></div>`],
115
+ ['indexes', tx('te.indexes'), html`<div class="table-wrap"><table class="report te-table" data-te-grid="ix"><thead><tr><th>${tx('te.name')}</th><th>${tx('te.cols_exprs')}</th><th>${tx('te.unique')}</th><th></th></tr></thead><tbody>
116
+ ${simpleRows(def.indexes.filter((i) => !i.fixed), 'ix', (x, i) => html`<td>${input(`ix.${i}.name`, x?.name ?? '', tx('te.name'))}</td><td>${input(`ix.${i}.cols`, x?.columns ?? '', tx('te.columns'), 'class="code" placeholder="lower(name), created_at desc"')}</td>
117
+ <td class="te-c"><input type="checkbox" aria-label="${tx('te.unique')}" id="te-ix${i}-u" name="ix.${i}.unique"${chk(!!x?.unique)}></td>`)}</tbody></table></div>
118
+ ${def.indexes.some((i) => i.fixed) ? html`<p class="muted small">${tx('te.kept')} ${def.indexes.filter((i) => i.fixed).map((i) => html`<code>${i.fixed}</code> `)}</p>` : ''}`],
119
+ ['more', tx('te.more'), html`<div class="field"><label class="label" for="te-comment">${tx('te.comment')}</label><textarea id="te-comment" name="comment" rows="2">${def.comment}</textarea></div>
120
+ <label class="check"><input type="checkbox" name="rls"${chk(def.rls)}> ${tx('te.rls')}</label>
121
+ <label class="check"><input type="checkbox" name="force_rls"${chk(def.forceRls)}> ${tx('te.force_rls')}</label>
122
+ ${editing ? '' : html`<fieldset class="prop-group u-mt1"><legend>${tx('te.partitioning')}</legend><div class="form-grid">
123
+ <div class="field"><label class="label" for="te-part_by">${tx('te.partition_by')}</label><select id="te-part_by" name="part_by"><option value="">${tx('te.not_partitioned')}</option>${(['range', 'list', 'hash'] as const).map((p) => html`<option value="${p}"${sel(p, def.partitionBy)}>${p}</option>`)}</select></div>
124
+ <div class="field"><label class="label" for="te-part_key">${tx('te.key')}</label>${input('part_key', def.partitionKey, tx('te.key'), 'class="code" placeholder="created_at"')}</div></div>
125
+ <small class="help">${tx('te.part_help')}</small></fieldset>`}`],
126
+ ['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl" data-code-view="sql" tabindex="0" aria-label="DDL">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? tx('te.no_changes') : tx('te.fill_in')}</p>`],
127
+ ['output', tx('te.output'), opts.output || html`<p class="muted">${tx('te.output_empty')}</p>`],
126
128
  ];
127
129
  const tab = sections.some(([k]) => k === opts.tab) ? opts.tab : opts.errors.length ? 'columns' : 'columns';
128
130
  return html`<form method="post" action="${BASE}/sql/objects/table" class="te" data-te-tab="${tab}">${csrf(s)}
129
131
  <input type="hidden" name="ref" value="${opts.ref}">${typeList}
130
132
  ${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
131
133
  <div class="form-grid te-head">
132
- <div class="field"><label class="label" for="te-schema">Schema</label>${editing
134
+ <div class="field"><label class="label" for="te-schema">${tx('te.schema')}</label>${editing
133
135
  ? html`<input id="te-schema" value="${def.schema}" readonly><input type="hidden" name="schema" value="${def.schema}">`
134
136
  : html`<select id="te-schema" name="schema">${opts.schemas.map((n) => html`<option value="${n}"${sel(n, def.schema)}>${n}</option>`)}</select>`}</div>
135
- <div class="field"><label class="label" for="te-name">Table name</label><input id="te-name" name="name" value="${def.name}" required autocomplete="off" spellcheck="false"></div>
137
+ <div class="field"><label class="label" for="te-name">${tx('te.table_name')}</label><input id="te-name" name="name" value="${def.name}" required autocomplete="off" spellcheck="false"></div>
136
138
  </div>
137
- <nav class="te-tabs" role="tablist" aria-label="Table properties" hidden>${sections.map(([k, label]) => html`<button type="button" role="tab" aria-selected="${k === tab ? 'true' : 'false'}" data-te-show="${k}">${label}</button>`)}</nav>
139
+ <nav class="te-tabs" role="tablist" aria-label="${tx('te.props')}" hidden>${sections.map(([k, label]) => html`<button type="button" role="tab" aria-selected="${k === tab ? 'true' : 'false'}" data-te-show="${k}">${label}</button>`)}</nav>
138
140
  ${sections.map(([k, label, body]) => html`<section class="te-section" data-te-section="${k}"><h2>${label}</h2>${body}</section>`)}
139
141
  <div class="buttons te-buttons">
140
- <button class="btn" name="mode" value="preview">${icon('eye')} Preview DDL</button>
141
- <button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
142
- <button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
143
- <a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">Cancel</a>
142
+ <button class="btn" name="mode" value="preview">${icon('eye')} ${tx('te.preview')}</button>
143
+ <button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('te.apply')}</button>
144
+ <button class="btn" name="mode" value="worksheet">${icon('code')} ${tx('te.open_ws')}</button>
145
+ <a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">${tx('common.cancel')}</a>
144
146
  </div></form>`;
145
147
  }
146
148
 
147
149
  async function page(req: Req, reply: FastifyReply) {
148
150
  const s = await developer(req, reply);
149
151
  if (!s) return;
152
+ const tx = tr(s);
150
153
  const db = await workshopDb(s);
151
154
  const isPost = req.method === 'POST';
152
155
  const b: Body = isPost ? (req.body ?? {}) : (req.query ?? {});
@@ -174,7 +177,7 @@ async function page(req: Req, reply: FastifyReply) {
174
177
  tab = 'ddl';
175
178
  if (!errors.length && b.mode === 'worksheet') {
176
179
  s.state.__SQLDRAFT = ddl.join('\n\n') || '-- no changes';
177
- flash(s, 'Review the statements and run them: nothing has been changed yet.');
180
+ flash(s, tx('te.review'));
178
181
  return back(reply, s, `${BASE}/sql/worksheet`);
179
182
  }
180
183
  if (!errors.length && b.mode === 'apply' && ddl.length) {
@@ -199,19 +202,19 @@ async function page(req: Req, reply: FastifyReply) {
199
202
  if (!failed) {
200
203
  // (sprint 106, DA-17) one change set in the change log
201
204
  await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', defaultSchema: def.schema, statements: [joinStatements(ddl)] });
202
- flash(s, `${editing ? 'Changed' : 'Created'} ${def.schema}.${def.name}: ${ddl.length} statement${ddl.length === 1 ? '' : 's'}.`);
205
+ flash(s, tx(editing ? 'te.changed' : 'te.created', { table: `${def.schema}.${def.name}`, what: tx(ddl.length === 1 ? 'ws.stmt_one' : 'ws.stmt', { n: ddl.length }) }));
203
206
  const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
204
207
  return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
205
208
  }
206
- output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
209
+ output = html`<div class="alert alert-error" role="alert"><p><strong>${tx('te.nothing_changed')}</strong>: ${tx('te.rolled_back')}</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('te.failed_stmt')}">${at}</pre></div>`;
207
210
  tab = 'output';
208
211
  }
209
212
  if (errors.length) tab = 'columns';
210
213
  }
211
- const title = editing ? `Table ${editing.schema}.${editing.name}` : 'Create table';
214
+ const title = editing ? tx('te.title_table', { table: `${editing.schema}.${editing.name}` }) : tx('te.title_create');
212
215
  const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects', s)}
213
216
  ${form(s, def, { editing, schemas, refTables, ddl, errors, output, tab, ref: editing ? ref : '' })}`;
214
- return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [title]], main, 'sql'));
217
+ return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], [tx('ob.title'), `${BASE}/sql/objects`], [title]], main, 'sql'));
215
218
  }
216
219
 
217
220
  const pgIdent = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : `"${n.replace(/"/g, '""')}"`);
package/src/builder/ui.ts CHANGED
@@ -10,7 +10,7 @@ import { getSession, saveState, takeFlash, type Session } from '../session.ts';
10
10
  import { SQL_TOOLS, TOOL_GROUPS, toolLabel } from './sql-tools.ts';
11
11
  import { ensureDevbar } from '../runtime/devbar.ts';
12
12
  import { noteChange } from './app-updates.ts';
13
- import { tr } from './i18n.ts';
13
+ import { clientTexts, tr } from './i18n.ts';
14
14
  import { languageMenu, loadLanguage } from './language.ts';
15
15
 
16
16
  // Shared building blocks of the builder UI.
@@ -207,7 +207,8 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
207
207
  ${languageMenu(s)}
208
208
  <span class="ide-status-version">pgkiln ${VERSION}</span>
209
209
  </footer>
210
- </div>`,
210
+ </div>
211
+ <div id="builder-texts" hidden data-texts="${JSON.stringify(clientTexts(tx.lang))}"></div>`,
211
212
  `builder-body ide${opts.full ? ' ide-full' : ''}`,
212
213
  {},
213
214
  builderHead(),