pgkiln 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +2 -1
- package/docs/guide/07-dynamic-actions.md +27 -0
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +85 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/components.ts +5 -4
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1627 -0
- package/src/builder/i18n/en.ts +1627 -0
- package/src/builder/i18n/es.ts +1627 -0
- package/src/builder/i18n/fr.ts +1627 -0
- package/src/builder/i18n/it.ts +1627 -0
- package/src/builder/i18n/ja.ts +1627 -0
- package/src/builder/i18n/ko.ts +1627 -0
- package/src/builder/i18n/nl.ts +1627 -0
- package/src/builder/i18n/pt.ts +1627 -0
- package/src/builder/i18n/zh-cn.ts +1627 -0
- package/src/builder/i18n/zh-tw.ts +1627 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/metadata.ts +3 -1
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/render.ts +6 -1
- package/src/runtime/routes.ts +4 -0
- package/src/runtime/tree.ts +4 -3
package/src/builder/datapump.ts
CHANGED
|
@@ -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?][] => [['
|
|
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,
|
|
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
|
|
34
|
-
|
|
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"
|
|
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'],
|
|
45
|
-
|
|
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
|
|
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
|
|
53
|
-
:
|
|
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">${
|
|
62
|
-
: html`<div class="alert alert-warning" role="alert"><strong
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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="
|
|
72
|
-
<td data-label="
|
|
73
|
-
<td data-label="
|
|
74
|
-
<td data-label="
|
|
75
|
-
<td data-label="
|
|
76
|
-
<td data-label="
|
|
77
|
-
<td data-label="
|
|
78
|
-
: html`<tr><td colspan="7" class="empty"
|
|
79
|
-
return page(reply, s,
|
|
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"
|
|
94
|
-
<select id="dp-schema" name="schema" required><option value=""
|
|
95
|
-
<div class="buttons"><button class="btn">${icon('table')}
|
|
96
|
-
${schema ? region(
|
|
97
|
-
<fieldset class="prop-group"><legend
|
|
98
|
-
<label class="check"><input type="radio" name="format" value="custom"${format === 'custom' ? raw(' checked') : ''}>
|
|
99
|
-
<label class="check"><input type="radio" name="format" value="plain"${format === 'plain' ? raw(' checked') : ''}>
|
|
100
|
-
<fieldset class="prop-group"><legend
|
|
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') : ''}>
|
|
103
|
-
<fieldset class="prop-group"><legend
|
|
104
|
-
<div class="form-grid">${[['all', '
|
|
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"
|
|
108
|
-
<p class="muted small"
|
|
109
|
-
<div class="buttons"><button class="btn btn-hot">${icon('download')}
|
|
110
|
-
return page(reply, s, '
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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')}
|
|
120
|
-
<p class="muted small"
|
|
121
|
-
${region('
|
|
122
|
-
<tbody>${files.length ? files.map((j) => html`<tr><td data-label="
|
|
123
|
-
<td data-label="
|
|
124
|
-
<td><a class="btn btn-sm" href="${jobUrl(j.id)}/import">${icon('upload')}
|
|
125
|
-
: html`<tr><td colspan="5" class="empty"
|
|
126
|
-
return page(reply, s, '
|
|
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
|
|
145
|
-
<dt
|
|
146
|
-
<div class="table-wrap"><table class="report"><thead><tr><th
|
|
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('
|
|
155
|
-
<div class="form-grid"><div class="field"><label class="label" for="dp-target"
|
|
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"
|
|
158
|
-
<fieldset class="prop-group"><legend
|
|
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') : ''}>
|
|
161
|
-
<label class="check"><input type="checkbox" name="privileges" value="1"${body.target === undefined || body.privileges === '1' ? raw(' checked') : ''}>
|
|
162
|
-
<p class="muted small"
|
|
163
|
-
<div class="buttons"><button class="btn btn-hot">${icon('upload')}
|
|
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(
|
|
166
|
-
return page(reply, s, '
|
|
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
|
-
['
|
|
173
|
-
['
|
|
174
|
-
['
|
|
175
|
-
...(j.started_at ? [['
|
|
176
|
-
...(j.finished_at ? [['
|
|
177
|
-
...(j.kind === 'export' ? [[
|
|
178
|
-
...(j.kind === 'import' && j.target_schema ? [['
|
|
179
|
-
...(j.source_job ? [['
|
|
180
|
-
['
|
|
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')}
|
|
184
|
-
<form method="post" action="${jobUrl(j.id)}/cancel" class="inline">${csrf(s)}<button class="btn">${icon('close')}
|
|
185
|
-
${j.file_live && j.kind === 'export' && j.status === 'succeeded' ? html`<a class="btn btn-hot" href="${jobUrl(j.id)}/download">${icon('download')}
|
|
186
|
-
${importable(j) ? html`<a class="btn" href="${jobUrl(j.id)}/import">${icon('upload')}
|
|
187
|
-
${j.status !== 'running' ? html`<form method="post" action="${jobUrl(j.id)}/delete" class="inline">${csrf(s)}<button class="btn btn-danger">${icon('trash')}
|
|
188
|
-
const body = html`${region(
|
|
189
|
-
${region('
|
|
190
|
-
return page(reply, s,
|
|
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, ['
|
|
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, '
|
|
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 ?
|
|
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, [
|
|
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, ['
|
|
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) ? '
|
|
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,
|
|
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
|
}
|
package/src/builder/ddl-tools.ts
CHANGED
|
@@ -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],
|
|
18
|
-
types: '
|
|
19
|
-
functions: '
|
|
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'],
|
|
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=""
|
|
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="
|
|
38
|
-
<span class="muted small"
|
|
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(
|
|
62
|
-
<textarea class="code" rows="24" spellcheck="false" readonly aria-label="
|
|
63
|
-
html`<a class="btn" href="${BASE}/sql/ddl?${qs.toString()}">${icon('download')}
|
|
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"
|
|
69
|
-
<p class="muted"
|
|
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', '
|
|
72
|
-
<fieldset class="prop-group"><legend
|
|
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') : ''}>
|
|
75
|
-
<label class="check"><input type="checkbox" name="grants" value="1"${opts.grants ? raw(' checked') : ''}>
|
|
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')}
|
|
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, '
|
|
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"
|
|
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('
|
|
103
|
-
?
|
|
104
|
-
:
|
|
105
|
-
${groups.map(([st, list]) => html`<div class="u-spacer"></div>${region(`${STATE_LABELS[st]} (${st === 'source' ? source : st === 'target' ? 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(
|
|
110
|
-
<textarea class="code" rows="20" spellcheck="false" readonly aria-label="
|
|
111
|
-
html`<a class="btn" href="${BASE}/sql/compare?${new URLSearchParams({ source, target, download: '1' }).toString()}">${icon('download')}
|
|
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"
|
|
118
|
-
<p class="muted"
|
|
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', '
|
|
121
|
-
<div class="buttons"><button class="btn btn-hot">${icon('layers')}
|
|
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, '
|
|
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
|
}
|