pgkiln 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
@@ -12,6 +12,7 @@ import { icon } from '../icons.ts';
12
12
  import { clientIp, logActivity, type Session } from '../session.ts';
13
13
  import { isAdmin } from './locks.ts';
14
14
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
15
+ import { tr, type BuilderKey, type BuilderTranslate } from './i18n.ts';
15
16
 
16
17
  // (sprint 103, Database Actions DA-16: Data Pump) SQL Workshop › Schema Export / Import, for administrators: pg_dump of a
17
18
  // schema (custom or plain, definitions and/or data, tables in or out) and pg_restore of an archive into a new or
@@ -19,68 +20,70 @@ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs,
19
20
  // The work: src/datapump.ts (jobs, pg_dump, pg_restore) and src/dumpscript.ts (the import's script runner).
20
21
 
21
22
  const URL_BASE = `${BASE}/sql/data-pump`;
22
- const TITLE = 'Schema Export / Import';
23
23
  const jobUrl = (id: number) => `${URL_BASE}/job/${id}`;
24
- const crumbs = (...more: [string, string?][]): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], [TITLE, more.length ? URL_BASE : undefined], ...more];
24
+ const crumbs = (tx: BuilderTranslate, ...more: [string, string?][]): [string, string?][] => [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.datapump'), more.length ? URL_BASE : undefined], ...more];
25
25
  const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
26
- const CONTENT: [Content, string][] = [['all', 'Definitions and data'], ['definitions', 'Definitions only'], ['data', 'Data only']];
26
+ const CONTENT: [Content, BuilderKey][] = [['all', 'dp.content.all'], ['definitions', 'dp.content.definitions'], ['data', 'dp.content.data']];
27
+ const contentLabel = (tx: BuilderTranslate, c: unknown) => { const x = CONTENT.find(([k]) => k === c); return x ? tx(x[1]) : ''; };
27
28
 
28
29
  /** An administrator's session; others get a 403 page. */
29
30
  async function admin(req: Req, reply: FastifyReply) {
30
31
  const s = await developer(req, reply);
31
32
  if (!s) return null;
32
33
  if (await isAdmin(s.username)) return s;
33
- const main = html`<h1 class="u-mb1">${TITLE}</h1><div class="alert alert-error" role="alert">Only administrators export and import schemas: an export holds every row of a schema, an import runs its SQL as pgkiln's owner role.</div>`;
34
- await send(reply.code(403), s, shell(s, TITLE, crumbs(), main, 'sql'));
34
+ const tx = tr(s);
35
+ const main = html`<h1 class="u-mb1">${tx('sql.tool.datapump')}</h1><div class="alert alert-error" role="alert">${tx('dp.admins_only')}</div>`;
36
+ await send(reply.code(403), s, shell(s, tx('sql.tool.datapump'), crumbs(tx), main, 'sql'));
35
37
  return null;
36
38
  }
37
39
 
38
40
  const page = (reply: FastifyReply, s: Session, title: string, trail: [string, string?][], body: Raw, code = 200) =>
39
- send(reply.code(code), s, shell(s, title, crumbs(...trail), html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('datapump', s)}${body}`, 'sql'));
41
+ send(reply.code(code), s, shell(s, title, crumbs(tr(s), ...trail), html`<h1 class="u-mb1">${tr(s)('rail.sql_workshop')}</h1>${workshopTabs('datapump', s)}${body}`, 'sql'));
40
42
 
41
43
  export const size = (n: number | null) =>
42
44
  n === null ? '' : n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MB` : `${(n / 1024 ** 3).toFixed(2)} GB`;
43
45
 
44
- const STATUS: Record<Job['status'], [string, string]> = {
45
- uploaded: ['tag-info', 'uploaded'], running: ['tag-warning', 'running'], succeeded: ['tag-ok', 'succeeded'], failed: ['tag-error', 'failed'], cancelled: ['', 'cancelled'],
46
- };
47
- const status = (j: Job) => html`<span class="tag ${STATUS[j.status][0]}">${STATUS[j.status][1]}</span>`;
48
- const what = (j: Job) =>
46
+ const STATUS: Record<Job['status'], string> = { uploaded: 'tag-info', running: 'tag-warning', succeeded: 'tag-ok', failed: 'tag-error', cancelled: '' };
47
+ const status = (tx: BuilderTranslate, j: Job) => html`<span class="tag ${STATUS[j.status]}">${tx.dyn(`dp.status.${j.status}`, j.status)}</span>`;
48
+ const what = (tx: BuilderTranslate, j: Job) =>
49
49
  j.kind === 'export'
50
- ? html`Export of <strong>${j.schema_name ?? ''}</strong>${(j.options.format as string) === 'plain' ? ' (plain SQL)' : ''}`
50
+ ? html`${tx.rich('dp.what.export', { schema: html`<strong>${j.schema_name ?? ''}</strong>` })}${(j.options.format as string) === 'plain' ? ` ${tx('dp.plain_sql')}` : ''}`
51
51
  : j.status === 'uploaded'
52
- ? html`Upload <strong class="u-break">${j.original_name ?? ''}</strong>`
53
- : html`Import of <strong>${j.schema_name ?? ''}</strong> into <strong>${j.target_schema ?? ''}</strong>`;
52
+ ? tx.rich('dp.what.upload', { file: html`<strong class="u-break">${j.original_name ?? ''}</strong>` })
53
+ : tx.rich('dp.what.import', { schema: html`<strong>${j.schema_name ?? ''}</strong>`, target: html`<strong>${j.target_schema ?? ''}</strong>` });
54
54
 
55
55
  /** Can this job's file be imported? (an export in custom format, or an upload; while its file is kept) */
56
56
  const importable = (j: Job) =>
57
57
  j.file_live && ((j.kind === 'export' && j.status === 'succeeded' && j.options.format !== 'plain') || (j.kind === 'import' && !j.source_job && j.status !== 'running'));
58
58
 
59
- const toolNote = (t: ToolState) =>
59
+ const toolNote = (tx: BuilderTranslate, t: ToolState) =>
60
60
  t.ok
61
- ? html`<p class="muted small">${t.tools.version} and pg_restore for a PostgreSQL ${String(t.server)} server. Files are kept ${String(KEEP_DAYS)} day${KEEP_DAYS === 1 ? '' : 's'} in the dump directory.</p>`
62
- : html`<div class="alert alert-warning" role="alert"><strong>Export and import need pg_dump and pg_restore.</strong> ${t.reason}${t.found.length ? html` Found: ${t.found.join('; ')}.` : ''}</div>`;
61
+ ? html`<p class="muted small">${tx(KEEP_DAYS === 1 ? 'dp.tools_one' : 'dp.tools', { version: t.tools.version, server: String(t.server), n: KEEP_DAYS })}</p>`
62
+ : html`<div class="alert alert-warning" role="alert"><strong>${tx('dp.need_tools')}</strong> ${t.reason}${t.found.length ? html` ${tx('dp.found', { list: t.found.join('; ') })}` : ''}</div>`;
63
63
 
64
64
  async function listPage(reply: FastifyReply, s: Session) {
65
65
  const [jobs, tools] = await Promise.all([listJobs(), findTools()]);
66
- const body = html`<div class="ide-head-title u-mb1"><div><h2 class="u-m0">${TITLE}</h2><span class="muted small">A schema to a file with pg_dump, and a file into a schema with pg_restore (Data Pump), as background jobs.</span></div>
67
- <div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/export">${icon('download')} Export a schema</a> <a class="btn" href="${URL_BASE}/import">${icon('upload')} Import</a></div></div>
68
- ${toolNote(tools)}
69
- ${region('Jobs', html`<div class="table-wrap"><table class="report report-reflow dp-jobs"><thead><tr><th>Job</th><th>What</th><th>Status</th><th class="num">Size</th><th>Started</th><th class="num">Seconds</th><th>By</th></tr></thead>
66
+ const tx = tr(s);
67
+ const L = { job: tx('dp.col.job'), what: tx('dp.col.what'), status: tx('sm.col.state'), size: tx('scr.col.size'), started: tx('scr.col.started'), seconds: tx('dp.col.seconds'), by: tx('scr.col.by') };
68
+ const body = html`<div class="ide-head-title u-mb1"><div><h2 class="u-m0">${tx('sql.tool.datapump')}</h2><span class="muted small">${tx('dp.intro')}</span></div>
69
+ <div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/export">${icon('download')} ${tx('dp.export_schema')}</a> <a class="btn" href="${URL_BASE}/import">${icon('upload')} ${tx('dp.import')}</a></div></div>
70
+ ${toolNote(tx, tools)}
71
+ ${region(tx('dp.jobs'), html`<div class="table-wrap"><table class="report report-reflow dp-jobs"><thead><tr><th>${L.job}</th><th>${L.what}</th><th>${L.status}</th><th class="num">${L.size}</th><th>${L.started}</th><th class="num">${L.seconds}</th><th>${L.by}</th></tr></thead>
70
72
  <tbody>${jobs.length ? jobs.map((j) => html`<tr>
71
- <td data-label="Job"><a href="${jobUrl(j.id)}">#${String(j.id)}</a></td>
72
- <td data-label="What" class="u-break">${what(j)}</td>
73
- <td data-label="Status">${status(j)}</td>
74
- <td data-label="Size" class="num">${size(j.bytes)}${j.file_name && !j.file_live ? '' : j.bytes !== null && !j.file_name ? html` <span class="muted small">(removed)</span>` : ''}</td>
75
- <td data-label="Started">${j.started_at ?? j.created_at}</td>
76
- <td data-label="Seconds" class="num">${j.seconds === null ? '' : String(j.seconds)}</td>
77
- <td data-label="By" class="u-break">${j.username}</td></tr>`)
78
- : html`<tr><td colspan="7" class="empty">No exports or imports yet.</td></tr>`}</tbody></table></div>`)}`;
79
- return page(reply, s, TITLE, [], body);
73
+ <td data-label="${L.job}"><a href="${jobUrl(j.id)}">#${String(j.id)}</a></td>
74
+ <td data-label="${L.what}" class="u-break">${what(tx, j)}</td>
75
+ <td data-label="${L.status}">${status(tx, j)}</td>
76
+ <td data-label="${L.size}" class="num">${size(j.bytes)}${j.file_name && !j.file_live ? '' : j.bytes !== null && !j.file_name ? html` <span class="muted small">(${tx('dp.removed')})</span>` : ''}</td>
77
+ <td data-label="${L.started}">${j.started_at ?? j.created_at}</td>
78
+ <td data-label="${L.seconds}" class="num">${j.seconds === null ? '' : String(j.seconds)}</td>
79
+ <td data-label="${L.by}" class="u-break">${j.username}</td></tr>`)
80
+ : html`<tr><td colspan="7" class="empty">${tx('dp.no_jobs')}</td></tr>`}</tbody></table></div>`)}`;
81
+ return page(reply, s, tx('sql.tool.datapump'), [], body);
80
82
  }
81
83
 
82
84
  async function exportPage(reply: FastifyReply, s: Session, q: Record<string, string | string[] | undefined>, errors: string[] = []) {
83
85
  const schemas = await exportableSchemas();
86
+ const tx = tr(s);
84
87
  const schema = schemas.includes(String(q.schema ?? '')) ? String(q.schema) : '';
85
88
  const tables = schema ? await schemaTables(schema) : [];
86
89
  const mode = ['all', 'only', 'except'].includes(String(q.mode)) ? String(q.mode) : 'all';
@@ -88,45 +91,48 @@ async function exportPage(reply: FastifyReply, s: Session, q: Record<string, str
88
91
  const format = q.format === 'plain' ? 'plain' : 'custom';
89
92
  const content = CONTENT.some(([c]) => c === q.content) ? String(q.content) : 'all';
90
93
  const privileges = q.schema === undefined || q.privileges === '1';
91
- const body = html`${toolNote(await findTools())}
94
+ const body = html`${toolNote(tx, await findTools())}
92
95
  ${errors.length ? html`<div class="alert alert-error" role="alert">${errors.map((e) => html`<div>${e}</div>`)}</div>` : ''}
93
- <form method="get" action="${URL_BASE}/export" class="u-mb1"><div class="form-grid"><div class="field"><label class="label" for="dp-schema">Schema</label>
94
- <select id="dp-schema" name="schema" required><option value="">- choose a schema -</option>${schemas.map((x) => html`<option value="${x}"${x === schema ? raw(' selected') : ''}>${x}</option>`)}</select></div></div>
95
- <div class="buttons"><button class="btn">${icon('table')} Choose</button></div></form>
96
- ${schema ? region(`Export ${schema}`, html`<form method="post" action="${URL_BASE}/export">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
97
- <fieldset class="prop-group"><legend>Format</legend><div class="form-grid">
98
- <label class="check"><input type="radio" name="format" value="custom"${format === 'custom' ? raw(' checked') : ''}> Custom (compressed; imports here with pg_restore)</label>
99
- <label class="check"><input type="radio" name="format" value="plain"${format === 'plain' ? raw(' checked') : ''}> Plain SQL (for psql; not imported here)</label></div></fieldset>
100
- <fieldset class="prop-group"><legend>Content</legend><div class="form-grid">
101
- ${CONTENT.map(([c, label]) => html`<label class="check"><input type="radio" name="content" value="${c}"${content === c ? raw(' checked') : ''}> ${label}</label>`)}
102
- <label class="check"><input type="checkbox" name="privileges" value="1"${privileges ? raw(' checked') : ''}> Grants</label></div></fieldset>
103
- <fieldset class="prop-group"><legend>Tables, views and sequences</legend>
104
- <div class="form-grid">${[['all', 'The whole schema'], ['only', 'Only the checked ones'], ['except', 'All but the checked ones']].map(([m, label]) =>
105
- html`<label class="check"><input type="radio" name="mode" value="${m}"${mode === m ? raw(' checked') : ''}> ${label}</label>`)}</div>
96
+ <form method="get" action="${URL_BASE}/export" class="u-mb1"><div class="form-grid"><div class="field"><label class="label" for="dp-schema">${tx('sm.schema')}</label>
97
+ <select id="dp-schema" name="schema" required><option value="">${tx('ddl.choose_schema')}</option>${schemas.map((x) => html`<option value="${x}"${x === schema ? raw(' selected') : ''}>${x}</option>`)}</select></div></div>
98
+ <div class="buttons"><button class="btn">${icon('table')} ${tx('ul.choose')}</button></div></form>
99
+ ${schema ? region(tx('dp.export_of', { schema }), html`<form method="post" action="${URL_BASE}/export">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
100
+ <fieldset class="prop-group"><legend>${tx('cl.format')}</legend><div class="form-grid">
101
+ <label class="check"><input type="radio" name="format" value="custom"${format === 'custom' ? raw(' checked') : ''}> ${tx('dp.fmt.custom')}</label>
102
+ <label class="check"><input type="radio" name="format" value="plain"${format === 'plain' ? raw(' checked') : ''}> ${tx('dp.fmt.plain')}</label></div></fieldset>
103
+ <fieldset class="prop-group"><legend>${tx('dp.content')}</legend><div class="form-grid">
104
+ ${CONTENT.map(([c, label]) => html`<label class="check"><input type="radio" name="content" value="${c}"${content === c ? raw(' checked') : ''}> ${tx(label)}</label>`)}
105
+ <label class="check"><input type="checkbox" name="privileges" value="1"${privileges ? raw(' checked') : ''}> ${tx('ddl.grants')}</label></div></fieldset>
106
+ <fieldset class="prop-group"><legend>${tx('dp.tables')}</legend>
107
+ <div class="form-grid">${([['all', 'dp.mode.all'], ['only', 'dp.mode.only'], ['except', 'dp.mode.except']] as [string, BuilderKey][]).map(([m, label]) =>
108
+ html`<label class="check"><input type="radio" name="mode" value="${m}"${mode === m ? raw(' checked') : ''}> ${tx(label)}</label>`)}</div>
106
109
  ${tables.length ? html`<div class="dp-tables">${tables.map((t) => html`<label class="check u-break"><input type="checkbox" name="t" value="${t}"${picked.has(t) ? raw(' checked') : ''}> ${t}</label>`)}</div>`
107
- : html`<p class="muted">The schema has no tables, views or sequences.</p>`}
108
- <p class="muted small">Only some tables: the file has no CREATE SCHEMA, and functions, types and the other objects of the schema stay out.</p></fieldset>
109
- <div class="buttons"><button class="btn btn-hot">${icon('download')} Start the export</button></div></form>`) : ''}`;
110
- return page(reply, s, 'Export a schema', [['Export']], body, errors.length ? 422 : 200);
110
+ : html`<p class="muted">${tx('dp.no_tables')}</p>`}
111
+ <p class="muted small">${tx('dp.some_tables_note')}</p></fieldset>
112
+ <div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('dp.start_export')}</button></div></form>`) : ''}`;
113
+ return page(reply, s, tx('dp.export_schema'), [[tx('dp.export')]], body, errors.length ? 422 : 200);
111
114
  }
112
115
 
113
116
  async function importPage(reply: FastifyReply, s: Session) {
114
117
  const files = (await listJobs()).filter(importable);
115
- const body = html`${toolNote(await findTools())}
116
- ${region('Upload a dump', html`<form method="post" action="${URL_BASE}/upload" enctype="multipart/form-data">${csrf(s)}
117
- <div class="form-grid"><div class="field"><label class="label" for="dp-file">A custom or tar archive of one schema (pg_dump -Fc or -Ft), up to ${String(uploadMaxMb())} MB</label>
118
+ const tx = tr(s);
119
+ const L = { job: tx('dp.col.job'), file: tx('dp.col.file'), size: tx('scr.col.size'), made: tx('dp.col.made') };
120
+ const body = html`${toolNote(tx, await findTools())}
121
+ ${region(tx('dp.upload_title'), html`<form method="post" action="${URL_BASE}/upload" enctype="multipart/form-data">${csrf(s)}
122
+ <div class="form-grid"><div class="field"><label class="label" for="dp-file">${tx('dp.upload_label', { mb: String(uploadMaxMb()) })}</label>
118
123
  <input type="file" id="dp-file" name="file" required></div></div>
119
- <div class="buttons"><button class="btn btn-hot">${icon('upload')} Upload</button></div></form>
120
- <p class="muted small">The next page shows what the archive holds before anything runs. A dump is code: its functions and triggers run as pgkiln's owner role, so import only dumps you trust.</p>`)}
121
- ${region('Or an export of this server', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Job</th><th>File</th><th class="num">Size</th><th>Made</th><th></th></tr></thead>
122
- <tbody>${files.length ? files.map((j) => html`<tr><td data-label="Job"><a href="${jobUrl(j.id)}">#${String(j.id)}</a></td><td data-label="File" class="u-break">${what(j)}</td>
123
- <td data-label="Size" class="num">${size(j.bytes)}</td><td data-label="Made">${j.finished_at ?? j.created_at}</td>
124
- <td><a class="btn btn-sm" href="${jobUrl(j.id)}/import">${icon('upload')} Import…</a></td></tr>`)
125
- : html`<tr><td colspan="5" class="empty">No exports in custom format are kept.</td></tr>`}</tbody></table></div>`)}`;
126
- return page(reply, s, 'Import', [['Import']], body);
124
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} ${tx('scr.upload')}</button></div></form>
125
+ <p class="muted small">${tx('dp.upload_note')}</p>`)}
126
+ ${region(tx('dp.or_export'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${L.job}</th><th>${L.file}</th><th class="num">${L.size}</th><th>${L.made}</th><th></th></tr></thead>
127
+ <tbody>${files.length ? files.map((j) => html`<tr><td data-label="${L.job}"><a href="${jobUrl(j.id)}">#${String(j.id)}</a></td><td data-label="${L.file}" class="u-break">${what(tx, j)}</td>
128
+ <td data-label="${L.size}" class="num">${size(j.bytes)}</td><td data-label="${L.made}">${j.finished_at ?? j.created_at}</td>
129
+ <td><a class="btn btn-sm" href="${jobUrl(j.id)}/import">${icon('upload')} ${tx('dp.import_dots')}</a></td></tr>`)
130
+ : html`<tr><td colspan="5" class="empty">${tx('dp.no_exports')}</td></tr>`}</tbody></table></div>`)}`;
131
+ return page(reply, s, tx('dp.import'), [[tx('dp.import')]], body);
127
132
  }
128
133
 
129
134
  async function importForm(reply: FastifyReply, s: Session, j: Job, body: Body = {}, errors: string[] = []) {
135
+ const tx = tr(s);
130
136
  let preview: Raw;
131
137
  let schema = '';
132
138
  try {
@@ -141,9 +147,9 @@ async function importForm(reply: FastifyReply, s: Session, j: Job, body: Body =
141
147
  const rows = toc.entries.filter((e) => e.desc === 'TABLE DATA').length;
142
148
  preview = html`${problem ? html`<div class="alert alert-error" role="alert">${problem}</div>` : ''}
143
149
  <dl class="dp-facts">${toc.header.filter(([k]) => /^(Archive created at|dbname|Format|Dumped from database version|Dumped by pg_dump version)$/.test(k)).map(([k, v]) => html`<dt>${k}</dt><dd class="u-break">${v}</dd>`)}
144
- <dt>Schema</dt><dd>${toc.schemas.join(', ') || '-'}${toc.schemaEntry ? '' : html` <span class="muted small">(some tables: no CREATE SCHEMA)</span>`}</dd>
145
- <dt>Tables</dt><dd class="u-break">${tables.length ? tables.join(', ') : html`<span class="muted">none</span>`}${rows ? html` <span class="muted small">(data of ${String(rows)})</span>` : ''}</dd></dl>
146
- <div class="table-wrap"><table class="report"><thead><tr><th>Object type</th><th class="num">Entries</th></tr></thead>
150
+ <dt>${tx('sm.schema')}</dt><dd>${toc.schemas.join(', ') || '-'}${toc.schemaEntry ? '' : html` <span class="muted small">${tx('dp.no_create_schema')}</span>`}</dd>
151
+ <dt>${tx('su.tables')}</dt><dd class="u-break">${tables.length ? tables.join(', ') : html`<span class="muted">${tx('dp.none')}</span>`}${rows ? html` <span class="muted small">${tx('dp.data_of', { n: String(rows) })}</span>` : ''}</dd></dl>
152
+ <div class="table-wrap"><table class="report"><thead><tr><th>${tx('dp.object_type')}</th><th class="num">${tx('dp.entries')}</th></tr></thead>
147
153
  <tbody>${toc.counts.map(([d, n]) => html`<tr><td>${d.toLowerCase()}</td><td class="num">${String(n)}</td></tr>`)}</tbody></table></div>`;
148
154
  if (problem) schema = '';
149
155
  } catch (e) {
@@ -151,43 +157,48 @@ async function importForm(reply: FastifyReply, s: Session, j: Job, body: Body =
151
157
  }
152
158
  const target = body.target ?? schema;
153
159
  const content = CONTENT.some(([c]) => c === body.content) ? body.content! : 'all';
154
- const form = schema ? region('Import into', html`<form method="post" action="${jobUrl(j.id)}/import">${csrf(s)}
155
- <div class="form-grid"><div class="field"><label class="label" for="dp-target">Schema</label>
160
+ const form = schema ? region(tx('dp.import_into'), html`<form method="post" action="${jobUrl(j.id)}/import">${csrf(s)}
161
+ <div class="form-grid"><div class="field"><label class="label" for="dp-target">${tx('sm.schema')}</label>
156
162
  <input id="dp-target" name="target" value="${target}" required maxlength="63" pattern="[A-Za-z_][A-Za-z0-9_$]{0,62}" autocomplete="off" spellcheck="false">
157
- <span class="help muted small">A new schema is created; into an existing one, the dump's objects are added (or replaced, below). A name other than ${schema} renames the dump's schema in the same transaction.</span></div></div>
158
- <fieldset class="prop-group"><legend>Content</legend><div class="form-grid">
159
- ${CONTENT.map(([c, label]) => html`<label class="check"><input type="radio" name="content" value="${c}"${content === c ? raw(' checked') : ''}> ${label}</label>`)}
160
- <label class="check"><input type="checkbox" name="clean" value="1"${body.clean === '1' ? raw(' checked') : ''}> Drop the dump's objects in the schema first</label>
161
- <label class="check"><input type="checkbox" name="privileges" value="1"${body.target === undefined || body.privileges === '1' ? raw(' checked') : ''}> Grants</label></div></fieldset>
162
- <p class="muted small">Everything runs in one transaction: an error rolls it all back. Objects are owned by pgkiln's owner role. Names inside PL/pgSQL bodies are text: after a rename they still say ${schema}.</p>
163
- <div class="buttons"><button class="btn btn-hot">${icon('upload')} Start the import</button></div></form>`) : '';
163
+ <span class="help muted small">${tx('dp.target_help', { schema })}</span></div></div>
164
+ <fieldset class="prop-group"><legend>${tx('dp.content')}</legend><div class="form-grid">
165
+ ${CONTENT.map(([c, label]) => html`<label class="check"><input type="radio" name="content" value="${c}"${content === c ? raw(' checked') : ''}> ${tx(label)}</label>`)}
166
+ <label class="check"><input type="checkbox" name="clean" value="1"${body.clean === '1' ? raw(' checked') : ''}> ${tx('dp.clean')}</label>
167
+ <label class="check"><input type="checkbox" name="privileges" value="1"${body.target === undefined || body.privileges === '1' ? raw(' checked') : ''}> ${tx('ddl.grants')}</label></div></fieldset>
168
+ <p class="muted small">${tx('dp.one_tx_note', { schema })}</p>
169
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} ${tx('dp.start_import')}</button></div></form>`) : '';
164
170
  const main = html`${errors.length ? html`<div class="alert alert-error" role="alert">${errors.map((e) => html`<div>${e}</div>`)}</div>` : ''}
165
- ${region(`What ${j.original_name ?? j.file_name ?? ''} holds`, preview)}${form}`;
166
- return page(reply, s, 'Import', [[`Job #${j.id}`, jobUrl(j.id)], ['Import']], main, errors.length ? 422 : 200);
171
+ ${region(tx('dp.holds', { file: j.original_name ?? j.file_name ?? '' }), preview)}${form}`;
172
+ return page(reply, s, tx('dp.import'), [[tx('dp.job', { id: j.id }), jobUrl(j.id)], [tx('dp.import')]], main, errors.length ? 422 : 200);
167
173
  }
168
174
 
169
175
  async function jobPage(reply: FastifyReply, s: Session, j: Job) {
170
176
  const o = j.options;
177
+ const tx = tr(s);
178
+ const exportOpts = () => [
179
+ tx('dp.opt.format', { format: String(o.format) }), contentLabel(tx, o.content), ...(o.privileges ? [tx('ddl.grants')] : []),
180
+ ].join(', ') + ((o.tables as string[])?.length ? `; ${tx('dp.opt.only', { list: (o.tables as string[]).join(', ') })}` : '') + ((o.exclude as string[])?.length ? `; ${tx('dp.opt.without', { list: (o.exclude as string[]).join(', ') })}` : '');
181
+ const importOpts = () => [contentLabel(tx, o.content), ...(o.clean ? [tx('dp.opt.cleaned')] : []), ...(o.privileges ? [tx('ddl.grants')] : [])].join(', ');
171
182
  const facts: [string, Raw | string][] = [
172
- ['Status', html`${status(j)}${j.error ? html` <span class="u-break">${j.error}</span>` : ''}`],
173
- ['By', j.username],
174
- ['Created', j.created_at],
175
- ...(j.started_at ? [['Started', j.started_at] as [string, string]] : []),
176
- ...(j.finished_at ? [['Finished', `${j.finished_at} (${j.seconds} s)`] as [string, string]] : []),
177
- ...(j.kind === 'export' ? [['Options', `${o.format} format, ${CONTENT.find(([c]) => c === o.content)?.[1].toLowerCase() ?? ''}${o.privileges ? ', grants' : ''}${(o.tables as string[])?.length ? `; only ${(o.tables as string[]).join(', ')}` : ''}${(o.exclude as string[])?.length ? `; without ${(o.exclude as string[]).join(', ')}` : ''}`] as [string, string]] : []),
178
- ...(j.kind === 'import' && j.target_schema ? [['Options', `${CONTENT.find(([c]) => c === o.content)?.[1].toLowerCase() ?? ''}${o.clean ? ', existing objects dropped first' : ''}${o.privileges ? ', grants' : ''}`] as [string, string]] : []),
179
- ...(j.source_job ? [['From', html`<a href="${jobUrl(j.source_job)}">job #${String(j.source_job)}</a>`] as [string, Raw]] : []),
180
- ['File', j.file_live ? html`${j.original_name ?? j.file_name ?? ''} · ${size(j.bytes)} · kept until ${j.expires_at ?? ''}` : j.file_name || j.bytes === null ? html`<span class="muted">none</span>` : html`<span class="muted">removed (${size(j.bytes)})</span>`],
183
+ [tx('sm.col.state'), html`${status(tx, j)}${j.error ? html` <span class="u-break">${j.error}</span>` : ''}`],
184
+ [tx('scr.col.by'), j.username],
185
+ [tx('dp.created'), j.created_at],
186
+ ...(j.started_at ? [[tx('scr.col.started'), j.started_at] as [string, string]] : []),
187
+ ...(j.finished_at ? [[tx('dp.finished'), `${j.finished_at} (${j.seconds} s)`] as [string, string]] : []),
188
+ ...(j.kind === 'export' ? [[tx('dp.options'), exportOpts()] as [string, string]] : []),
189
+ ...(j.kind === 'import' && j.target_schema ? [[tx('dp.options'), importOpts()] as [string, string]] : []),
190
+ ...(j.source_job ? [[tx('cl.col.from'), html`<a href="${jobUrl(j.source_job)}">${tx('dp.job_lower', { id: String(j.source_job) })}</a>`] as [string, Raw]] : []),
191
+ [tx('dp.col.file'), j.file_live ? html`${j.original_name ?? j.file_name ?? ''} · ${size(j.bytes)} · ${tx('dp.kept_until', { time: j.expires_at ?? '' })}` : j.file_name || j.bytes === null ? html`<span class="muted">${tx('dp.none')}</span>` : html`<span class="muted">${tx('dp.removed')} (${size(j.bytes)})</span>`],
181
192
  ];
182
193
  const actions = html`<div class="buttons">
183
- ${j.status === 'running' ? html`<a class="btn" href="${jobUrl(j.id)}">${icon('refresh')} Refresh</a>
184
- <form method="post" action="${jobUrl(j.id)}/cancel" class="inline">${csrf(s)}<button class="btn">${icon('close')} Cancel</button></form>` : ''}
185
- ${j.file_live && j.kind === 'export' && j.status === 'succeeded' ? html`<a class="btn btn-hot" href="${jobUrl(j.id)}/download">${icon('download')} Download</a>` : ''}
186
- ${importable(j) ? html`<a class="btn" href="${jobUrl(j.id)}/import">${icon('upload')} Import…</a>` : ''}
187
- ${j.status !== 'running' ? html`<form method="post" action="${jobUrl(j.id)}/delete" class="inline">${csrf(s)}<button class="btn btn-danger">${icon('trash')} Delete${j.file_live ? ' with its file' : ''}</button></form>` : ''}</div>`;
188
- const body = html`${region(`Job #${j.id}: ${j.kind}`, html`<p>${what(j)}</p><dl class="dp-facts">${facts.map(([k, v]) => html`<dt>${k}</dt><dd>${v}</dd>`)}</dl>${actions}`)}
189
- ${region('Log', j.log ? html`<pre class="ddl wrap-pre dp-log">${j.log}</pre>` : html`<p class="muted">Nothing logged${j.status === 'running' ? ' yet' : ''}.</p>`)}`;
190
- return page(reply, s, `Job #${j.id}`, [[`Job #${j.id}`]], body);
194
+ ${j.status === 'running' ? html`<a class="btn" href="${jobUrl(j.id)}">${icon('refresh')} ${tx('sm.refresh')}</a>
195
+ <form method="post" action="${jobUrl(j.id)}/cancel" class="inline">${csrf(s)}<button class="btn">${icon('close')} ${tx('sm.cancel')}</button></form>` : ''}
196
+ ${j.file_live && j.kind === 'export' && j.status === 'succeeded' ? html`<a class="btn btn-hot" href="${jobUrl(j.id)}/download">${icon('download')} ${tx('scr.download')}</a>` : ''}
197
+ ${importable(j) ? html`<a class="btn" href="${jobUrl(j.id)}/import">${icon('upload')} ${tx('dp.import_dots')}</a>` : ''}
198
+ ${j.status !== 'running' ? html`<form method="post" action="${jobUrl(j.id)}/delete" class="inline">${csrf(s)}<button class="btn btn-danger">${icon('trash')} ${j.file_live ? tx('dp.delete_with_file') : tx('scr.delete')}</button></form>` : ''}</div>`;
199
+ const body = html`${region(tx('dp.job_kind', { id: j.id, kind: tx.dyn(`dp.kind.${j.kind}`, j.kind) }), html`<p>${what(tx, j)}</p><dl class="dp-facts">${facts.map(([k, v]) => html`<dt>${k}</dt><dd>${v}</dd>`)}</dl>${actions}`)}
200
+ ${region(tx('dp.log'), j.log ? html`<pre class="ddl wrap-pre dp-log">${j.log}</pre>` : html`<p class="muted">${j.status === 'running' ? tx('dp.nothing_logged_yet') : tx('dp.nothing_logged')}</p>`)}`;
201
+ return page(reply, s, tx('dp.job', { id: j.id }), [[tx('dp.job', { id: j.id })]], body);
191
202
  }
192
203
 
193
204
  const exportOptions = (b: Body) => {
@@ -217,7 +228,7 @@ export async function dataPumpRoutes(app: FastifyInstance) {
217
228
  if (!s) return;
218
229
  const b = req.body ?? {};
219
230
  const o = exportOptions(b);
220
- if (b.mode === 'only' && !o.tables.length) return exportPage(reply, s, { ...b, t: [] } as never, ['Check the tables to export.']);
231
+ if (b.mode === 'only' && !o.tables.length) return exportPage(reply, s, { ...b, t: [] } as never, [tr(s)('dp.err.check_tables')]);
221
232
  try {
222
233
  const id = await startExport(s.username!, String(b.schema ?? ''), o);
223
234
  await logActivity({ username: s.username, event: 'schema_export', ip: clientIp(req), detail: `${b.schema}: job ${id}` });
@@ -270,12 +281,12 @@ export async function dataPumpRoutes(app: FastifyInstance) {
270
281
  }
271
282
  const session = s as Session;
272
283
  if (!saved) {
273
- flash(session, 'Choose a file.', 'error');
284
+ flash(session, tr(session)('dp.err.choose_file'), 'error');
274
285
  return back(reply, session, `${URL_BASE}/import`);
275
286
  }
276
287
  if (saved.truncated || !saved.bytes) {
277
288
  await rm(filePath(saved.name), { force: true });
278
- flash(session, saved.truncated ? `The file is larger than ${uploadMaxMb()} MB (PGKILN_DUMP_MAX_MB).` : 'The file is empty.', 'error');
289
+ flash(session, saved.truncated ? tr(session)('dp.err.too_large', { mb: uploadMaxMb() }) : tr(session)('dp.err.empty'), 'error');
279
290
  return back(reply, session, `${URL_BASE}/import`);
280
291
  }
281
292
  const id = await uploadJob(session.username!, saved.name, saved.original, saved.bytes);
@@ -310,9 +321,9 @@ export async function dataPumpRoutes(app: FastifyInstance) {
310
321
  const x = await withJob(req, reply);
311
322
  if (!x) return;
312
323
  const b = req.body ?? {};
313
- if (!importable(x.j)) return importForm(reply, x.s, x.j, b, ['This file can\'t be imported (any more).']);
324
+ if (!importable(x.j)) return importForm(reply, x.s, x.j, b, [tr(x.s)('dp.err.not_importable')]);
314
325
  const target = String(b.target ?? '').trim();
315
- if (!SCHEMA_NAME.test(target)) return importForm(reply, x.s, x.j, b, ['The schema: letters, digits, _ and $, up to 63, not starting with a digit.']);
326
+ if (!SCHEMA_NAME.test(target)) return importForm(reply, x.s, x.j, b, [tr(x.s)('dp.err.schema_name')]);
316
327
  try {
317
328
  const id = await startImport(x.s.username!, x.j, {
318
329
  target, content: (CONTENT.some(([c]) => c === b.content) ? b.content : 'all') as Content, clean: b.clean === '1', privileges: b.privileges === '1',
@@ -344,7 +355,7 @@ export async function dataPumpRoutes(app: FastifyInstance) {
344
355
  app.post(`${URL_BASE}/job/:id/cancel`, async (req: Req, reply) => {
345
356
  const x = await withJob(req, reply);
346
357
  if (!x) return;
347
- flash(x.s, cancelJob(x.j.id) ? 'Cancelling the job.' : 'The job doesn\'t run on this server (any more).', 'ok');
358
+ flash(x.s, cancelJob(x.j.id) ? tr(x.s)('dp.cancelling') : tr(x.s)('dp.not_running'), 'ok');
348
359
  return back(reply, x.s, jobUrl(x.j.id));
349
360
  });
350
361
 
@@ -358,7 +369,7 @@ export async function dataPumpRoutes(app: FastifyInstance) {
358
369
  flash(x.s, e.message, 'error');
359
370
  return back(reply, x.s, jobUrl(x.j.id));
360
371
  }
361
- flash(x.s, `Job #${x.j.id} deleted.`);
372
+ flash(x.s, tr(x.s)('dp.deleted', { id: x.j.id }));
362
373
  return back(reply, x.s, URL_BASE);
363
374
  });
364
375
  }
@@ -8,22 +8,24 @@ import { clientIp, logActivity } from '../session.ts';
8
8
  import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
9
9
  import type { Session } from '../session.ts';
10
10
  import { workshopDb } from './workspaces.ts';
11
+ import { tr, type BuilderKey, type BuilderTranslate } from './i18n.ts';
11
12
 
12
13
  // SQL Workshop → Generate DDL and Schema Comparison (APEX: SQL Workshop →
13
14
  // Utilities). Both only read the catalog (src/ddl.ts); the scripts they
14
15
  // write are shown and downloaded. (sprint 106) Compare's Apply and record
15
16
  // runs the change script and records it (changelog.ts).
16
17
 
17
- const TYPE_LABELS: Record<(typeof OBJECT_TYPES)[number], string> = {
18
- types: 'Types and domains', sequences: 'Sequences', tables: 'Tables (constraints, indexes)', views: 'Views and materialized views',
19
- functions: 'Functions and procedures', triggers: 'Triggers', policies: 'Row level security and policies',
18
+ const TYPE_LABELS: Record<(typeof OBJECT_TYPES)[number], BuilderKey> = {
19
+ types: 'ddl.type.types', sequences: 'ddl.type.sequences', tables: 'ddl.type.tables', views: 'ddl.type.views',
20
+ functions: 'ddl.type.functions', triggers: 'ddl.type.triggers', policies: 'ddl.type.policies',
20
21
  };
21
- const STATE_LABELS: Record<Difference['state'], string> = { source: 'Only in the source', target: 'Only in the target', different: 'Different' };
22
+ const STATE_LABELS: Record<Difference['state'], BuilderKey> = { source: 'cmp.only_source', target: 'cmp.only_target', different: 'cmp.different' };
23
+ const STATE_COUNTS: Record<Difference['state'], BuilderKey> = { source: 'cmp.n_only_source', target: 'cmp.n_only_target', different: 'cmp.n_different' };
22
24
 
23
25
  const many = (v: unknown) => ([] as string[]).concat((v as string | string[] | undefined) ?? []);
24
- const schemaSelect = (name: string, label: string, schemas: string[], current: string) =>
26
+ const schemaSelect = (tx: BuilderTranslate, name: string, label: string, schemas: string[], current: string) =>
25
27
  html`<div class="field"><label class="label" for="f_${name}">${label}</label>
26
- <select id="f_${name}" name="${name}" required><option value="">- choose a schema -</option>${schemas.map((x) => html`<option value="${x}"${x === current ? raw(' selected') : ''}>${x}</option>`)}</select></div>`;
28
+ <select id="f_${name}" name="${name}" required><option value="">${tx('ddl.choose_schema')}</option>${schemas.map((x) => html`<option value="${x}"${x === current ? raw(' selected') : ''}>${x}</option>`)}</select></div>`;
27
29
 
28
30
  /** A unified diff with added and removed lines marked. */
29
31
  const diffBlock = (d: Difference, source: string, target: string) => {
@@ -32,10 +34,10 @@ const diffBlock = (d: Difference, source: string, target: string) => {
32
34
  };
33
35
 
34
36
  /** (sprint 106, DA-17) Apply the change script in one transaction and record it in the change log (changelog.ts). */
35
- const applyForm = (s: Session, source: string, target: string) =>
37
+ const applyForm = (s: Session, source: string, target: string, tx = tr(s)) =>
36
38
  html`<form method="post" action="${BASE}/sql/compare/apply" class="u-mt1">${csrf(s)}<input type="hidden" name="source" value="${source}"><input type="hidden" name="target" value="${target}">
37
- <button class="btn btn-hot" data-confirm="Run the change script on ${target} in one transaction?">${icon('play')} Apply and record</button>
38
- <span class="muted small">Runs the script on ${target} (commented-out drops stay out) and records it in the <a href="${BASE}/sql/changes?schema=${encodeURIComponent(target)}">change log</a>.</span></form>`;
39
+ <button class="btn btn-hot" data-confirm="${tx('cmp.apply_confirm', { target })}">${icon('play')} ${tx('cmp.apply')}</button>
40
+ <span class="muted small">${tx.rich('cmp.apply_note', { target, log: html`<a href="${BASE}/sql/changes?schema=${encodeURIComponent(target)}">${tx('cmp.change_log')}</a>` })}</span></form>`;
39
41
 
40
42
  export async function ddlToolRoutes(app: FastifyInstance) {
41
43
  app.get(`${BASE}/sql/ddl`, async (req: Req, reply) => {
@@ -43,6 +45,7 @@ export async function ddlToolRoutes(app: FastifyInstance) {
43
45
  if (!s) return;
44
46
  const db = await workshopDb(s);
45
47
  const schemas = await userSchemas(db, { usable: db !== owner });
48
+ const tx = tr(s);
46
49
  const q = req.query ?? {};
47
50
  const schema = schemas.includes(q.schema ?? '') ? q.schema! : '';
48
51
  // the first visit: every type, comments and grants
@@ -58,26 +61,26 @@ export async function ddlToolRoutes(app: FastifyInstance) {
58
61
  if (q.download === '1')
59
62
  return reply.header('content-disposition', `attachment; filename="${schema}.sql"`).type('application/sql; charset=utf-8').send(script);
60
63
  const qs = new URLSearchParams([['schema', schema], ...[...types].map((x) => ['t', x]), ...(opts.comments ? [['comments', '1']] : []), ...(opts.grants ? [['grants', '1']] : []), ['download', '1']]);
61
- result = region(`DDL of ${schema}`, html`<p class="muted">${objects.length} object(s). Review the script before you run it (SQL Workshop → SQL Scripts).</p>
62
- <textarea class="code" rows="24" spellcheck="false" readonly aria-label="DDL of ${schema}" data-code="plpgsql">${script}</textarea>`,
63
- html`<a class="btn" href="${BASE}/sql/ddl?${qs.toString()}">${icon('download')} Download ${schema}.sql</a>`);
64
+ result = region(tx('ddl.of', { schema }), html`<p class="muted">${tx('ddl.objects_review', { n: objects.length })}</p>
65
+ <textarea class="code" rows="24" spellcheck="false" readonly aria-label="${tx('ddl.of', { schema })}" data-code="plpgsql">${script}</textarea>`,
66
+ html`<a class="btn" href="${BASE}/sql/ddl?${qs.toString()}">${icon('download')} ${tx('ddl.download', { file: `${schema}.sql` })}</a>`);
64
67
  } catch (e) {
65
68
  result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
66
69
  }
67
70
  }
68
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ddl', s)}
69
- <p class="muted">The DDL of a schema's objects, from the database catalog, in an order that runs: types, sequences, tables, functions, views, constraints, indexes, triggers, row level security, comments and grants (APEX: Generate DDL). Objects of extensions are left out.</p>
71
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('ddl', s)}
72
+ <p class="muted">${tx('ddl.intro')}</p>
70
73
  <form method="get" class="u-mb1">
71
- <div class="form-grid">${schemaSelect('schema', 'Schema', schemas, schema)}</div>
72
- <fieldset class="prop-group"><legend>Objects</legend><div class="form-grid">
73
- ${OBJECT_TYPES.map((x) => html`<label class="check"><input type="checkbox" name="t" value="${x}"${types.has(x) ? raw(' checked') : ''}> ${TYPE_LABELS[x]}</label>`)}
74
- <label class="check"><input type="checkbox" name="comments" value="1"${opts.comments ? raw(' checked') : ''}> Comments</label>
75
- <label class="check"><input type="checkbox" name="grants" value="1"${opts.grants ? raw(' checked') : ''}> Grants</label>
74
+ <div class="form-grid">${schemaSelect(tx, 'schema', tx('sm.schema'), schemas, schema)}</div>
75
+ <fieldset class="prop-group"><legend>${tx('ddl.objects')}</legend><div class="form-grid">
76
+ ${OBJECT_TYPES.map((x) => html`<label class="check"><input type="checkbox" name="t" value="${x}"${types.has(x) ? raw(' checked') : ''}> ${tx(TYPE_LABELS[x])}</label>`)}
77
+ <label class="check"><input type="checkbox" name="comments" value="1"${opts.comments ? raw(' checked') : ''}> ${tx('ddl.comments')}</label>
78
+ <label class="check"><input type="checkbox" name="grants" value="1"${opts.grants ? raw(' checked') : ''}> ${tx('ddl.grants')}</label>
76
79
  </div></fieldset>
77
- <div class="buttons"><button class="btn btn-hot">${icon('code')} Generate</button></div>
80
+ <div class="buttons"><button class="btn btn-hot">${icon('code')} ${tx('ddl.generate')}</button></div>
78
81
  </form>
79
82
  ${result}`;
80
- return send(reply, s, shell(s, 'Generate DDL', [['SQL Workshop', `${BASE}/sql`], ['Generate DDL']], main, 'sql'));
83
+ return send(reply, s, shell(s, tx('sql.tool.ddl'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.ddl')]], main, 'sql'));
81
84
  });
82
85
 
83
86
  app.get(`${BASE}/sql/compare`, async (req: Req, reply) => {
@@ -88,8 +91,9 @@ export async function ddlToolRoutes(app: FastifyInstance) {
88
91
  const q = req.query ?? {};
89
92
  const source = schemas.includes(q.source ?? '') ? q.source! : '';
90
93
  const target = schemas.includes(q.target ?? '') ? q.target! : '';
94
+ const tx = tr(s);
91
95
  let result: Raw | '' = '';
92
- if (source && target && source === target) result = html`<div class="alert alert-error" role="alert">Choose two different schemas.</div>`;
96
+ if (source && target && source === target) result = html`<div class="alert alert-error" role="alert">${tx('cmp.two_different')}</div>`;
93
97
  else if (source && target) {
94
98
  try {
95
99
  const [a, b] = await Promise.all([readSchema(db.pool, source), readSchema(db.pool, target)]);
@@ -99,28 +103,28 @@ export async function ddlToolRoutes(app: FastifyInstance) {
99
103
  if (q.download === '1')
100
104
  return reply.header('content-disposition', `attachment; filename="${target}-like-${source}.sql"`).type('application/sql; charset=utf-8').send(script);
101
105
  const groups = (['source', 'target', 'different'] as const).map((st) => [st, differences.filter((d) => d.state === st)] as const).filter(([, list]) => list.length);
102
- result = html`${region('Summary', html`<p>${differences.length
103
- ? `${differences.length} difference(s): ${groups.map(([st, list]) => `${list.length} ${STATE_LABELS[st].toLowerCase()}`).join(', ')}; ${same} object(s) the same.`
104
- : `The schemas are the same (${same} objects).`}</p>`)}
105
- ${groups.map(([st, list]) => html`<div class="u-spacer"></div>${region(`${STATE_LABELS[st]} (${st === 'source' ? source : st === 'target' ? target : `${source} and ${target}`})`,
106
+ result = html`${region(tx('cmp.summary'), html`<p>${differences.length
107
+ ? tx('cmp.differences', { n: differences.length, parts: groups.map(([st, list]) => tx(STATE_COUNTS[st], { n: list.length })).join(', '), same })
108
+ : tx('cmp.same', { n: same })}</p>`)}
109
+ ${groups.map(([st, list]) => html`<div class="u-spacer"></div>${region(`${tx(STATE_LABELS[st])} (${st === 'source' ? source : st === 'target' ? target : tx('cmp.both', { source, target })})`,
106
110
  html`<ul class="compare-list">${list.map((d) => html`<li><details><summary><span class="tag">${d.kind}</span> ${d.name}</summary>
107
111
  ${st === 'different' ? diffBlock(d, source, target) : html`<pre class="diff">${d.source ?? d.target}</pre>`}</details></li>`)}</ul>`)}`)}
108
112
  <div class="u-spacer"></div>
109
- ${region(`Change script: make ${target} like ${source}`, html`<p class="muted">Creates what only ${source} has, changes what differs, and lists what only ${target} has as commented-out drops; columns that change identity or generation, and types, domains and sequences that differ, are left for review. Nothing runs until you choose Apply and record.</p>
110
- <textarea class="code" rows="20" spellcheck="false" readonly aria-label="Change script" data-code="plpgsql">${script}</textarea>`,
111
- html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} Download</a>`)}
113
+ ${region(tx('cmp.script_title', { source, target }), html`<p class="muted">${tx('cmp.script_intro', { source, target })}</p>
114
+ <textarea class="code" rows="20" spellcheck="false" readonly aria-label="${tx('cmp.change_script')}" data-code="plpgsql">${script}</textarea>`,
115
+ html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')} ${tx('scr.download')}</a>`)}
112
116
  ${differences.length ? applyForm(s, source, target) : ''}`;
113
117
  } catch (e) {
114
118
  result = html`<div class="alert alert-error" role="alert">${(e as Error).message}</div>`;
115
119
  }
116
120
  }
117
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('compare', s)}
118
- <p class="muted">Compares the objects of two schemas of this database by their DDL (APEX: Schema Comparison), e.g. a development copy with the schema it came from, and writes a script that makes the target like the source. The order of a table's columns is not compared.</p>
121
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('compare', s)}
122
+ <p class="muted">${tx('cmp.intro')}</p>
119
123
  <form method="get" class="u-mb1">
120
- <div class="form-grid">${schemaSelect('source', 'Source', schemas, source)}${schemaSelect('target', 'Target (to make like the source)', schemas, target)}</div>
121
- <div class="buttons"><button class="btn btn-hot">${icon('layers')} Compare</button></div>
124
+ <div class="form-grid">${schemaSelect(tx, 'source', tx('cmp.source'), schemas, source)}${schemaSelect(tx, 'target', tx('cmp.target'), schemas, target)}</div>
125
+ <div class="buttons"><button class="btn btn-hot">${icon('layers')} ${tx('cmp.compare')}</button></div>
122
126
  </form>
123
127
  ${result}`;
124
- return send(reply, s, shell(s, 'Schema Comparison', [['SQL Workshop', `${BASE}/sql`], ['Schema Comparison']], main, 'sql'));
128
+ return send(reply, s, shell(s, tx('sql.tool.compare'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.compare')]], main, 'sql'));
125
129
  });
126
130
  }