pgkiln 0.47.0 → 0.49.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 (69) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/db/migrations/116_dump_jobs.sql +41 -0
  7. package/db/migrations/117_rest_handlers.sql +63 -0
  8. package/db/migrations/118_change_log.sql +54 -0
  9. package/docs/apex-feature-parity.md +11 -11
  10. package/docs/guide/01-installation.md +4 -0
  11. package/docs/guide/03-builder.md +267 -7
  12. package/docs/guide/08-security.md +29 -0
  13. package/docs/guide/12-development.md +70 -6
  14. package/docs/guide/13-rest-api.md +133 -10
  15. package/docs/guide/15-extensions.md +1 -1
  16. package/examples/hr/hr_62_rest_module.sql +39 -0
  17. package/package.json +1 -1
  18. package/public/app.css +1 -1
  19. package/public/builder-icons.svg +2 -0
  20. package/public/builder.css +442 -173
  21. package/public/builder.js +48 -0
  22. package/public/data-modeler.css +56 -0
  23. package/public/data-modeler.js +367 -0
  24. package/public/palette.js +133 -0
  25. package/public/sql-monitor.js +73 -0
  26. package/public/worksheet.css +49 -0
  27. package/public/worksheet.js +4 -0
  28. package/src/app.ts +10 -0
  29. package/src/automations.ts +6 -0
  30. package/src/binds.ts +2 -1
  31. package/src/builder/advisor.ts +24 -2
  32. package/src/builder/autorest.ts +363 -0
  33. package/src/builder/changelog.ts +240 -0
  34. package/src/builder/components.ts +1 -1
  35. package/src/builder/data-modeler.ts +382 -0
  36. package/src/builder/datapump.ts +364 -0
  37. package/src/builder/db-users.ts +472 -0
  38. package/src/builder/ddl-tools.ts +13 -4
  39. package/src/builder/find-workspace.ts +2 -2
  40. package/src/builder/launchpad.ts +140 -0
  41. package/src/builder/object-actions.ts +1 -0
  42. package/src/builder/object-browser.ts +7 -1
  43. package/src/builder/plan-view.ts +25 -0
  44. package/src/builder/rest-modules.ts +629 -0
  45. package/src/builder/rest.ts +1 -0
  46. package/src/builder/routes.ts +4 -2
  47. package/src/builder/scheduling.ts +495 -0
  48. package/src/builder/sql-charts.ts +203 -0
  49. package/src/builder/sql-monitor.ts +397 -0
  50. package/src/builder/sql-tools.ts +53 -0
  51. package/src/builder/sql-utilities.ts +7 -39
  52. package/src/builder/sql.ts +20 -9
  53. package/src/builder/table-editor.ts +4 -1
  54. package/src/builder/top-sql.ts +24 -6
  55. package/src/builder/ui.ts +25 -17
  56. package/src/builder/worksheet.ts +35 -28
  57. package/src/builder/workspaces.ts +4 -2
  58. package/src/changelog.ts +109 -0
  59. package/src/cli/replace.ts +3 -1
  60. package/src/cron-schedule.ts +282 -0
  61. package/src/datamodel.ts +324 -0
  62. package/src/datapump.ts +546 -0
  63. package/src/dbroles.ts +215 -0
  64. package/src/dumpscript.ts +407 -0
  65. package/src/pgcron.ts +225 -0
  66. package/src/runtime/autorest.ts +336 -0
  67. package/src/runtime/rest-handlers.ts +75 -0
  68. package/src/runtime/rest.ts +120 -26
  69. package/src/session.ts +2 -0
package/public/builder.js CHANGED
@@ -1234,3 +1234,51 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
1234
1234
  // the button is for pages without JavaScript
1235
1235
  picker.form.querySelector('button')?.setAttribute('hidden', '');
1236
1236
  })();
1237
+
1238
+ // (sprint 101, DA-12) SQL Workshop › Scheduling: the schedule builder's five fields and the common schedules fill the
1239
+ // schedule; the preview (in words, next five runs) follows as you type. Without JavaScript the Use / Preview buttons
1240
+ // post the form and it comes back with the same.
1241
+ (() => {
1242
+ const form = document.querySelector('form[data-cron-form]');
1243
+ const input = form?.querySelector('[data-cron-schedule]');
1244
+ const preview = form?.querySelector('[data-cron-preview]');
1245
+ if (!form || !input || !preview) return;
1246
+ const fields = [...form.querySelectorAll('[data-cron-field]')];
1247
+ let timer = 0;
1248
+ let seq = 0;
1249
+ const refresh = () => {
1250
+ clearTimeout(timer);
1251
+ timer = setTimeout(async () => {
1252
+ const mine = ++seq;
1253
+ try {
1254
+ const res = await fetch(`${preview.dataset.cronPreview}?schedule=${encodeURIComponent(input.value)}`, { headers: { accept: 'text/html' }, credentials: 'same-origin' });
1255
+ if (!res.ok || mine !== seq) return;
1256
+ const doc = new DOMParser().parseFromString(`<div>${await res.text()}</div>`, 'text/html');
1257
+ preview.replaceChildren(...doc.body.firstElementChild.childNodes);
1258
+ } catch {
1259
+ /* the Preview button still works */
1260
+ }
1261
+ }, 250);
1262
+ };
1263
+ const split = () => {
1264
+ const parts = input.value.trim().split(/\s+/);
1265
+ if (parts.length === 5) fields.forEach((f, i) => (f.value = parts[i] === '*' ? '' : parts[i]));
1266
+ };
1267
+ input.addEventListener('input', () => {
1268
+ split();
1269
+ refresh();
1270
+ });
1271
+ for (const f of fields) {
1272
+ f.addEventListener('input', () => {
1273
+ input.value = fields.map((x) => x.value.trim() || '*').join(' ');
1274
+ refresh();
1275
+ });
1276
+ }
1277
+ form.querySelector('[data-cron-preset]')?.addEventListener('change', (e) => {
1278
+ if (!e.target.value) return;
1279
+ input.value = e.target.value;
1280
+ split();
1281
+ refresh();
1282
+ });
1283
+ for (const b of form.querySelectorAll('[data-cron-nojs]')) b.hidden = true;
1284
+ })();
@@ -0,0 +1,56 @@
1
+ /* pgkiln builder: the SQL Workshop's Data Modeler (sprint 98, src/builder/data-modeler.ts, public/data-modeler.js).
2
+ The diagram is an SVG with presentation attributes (light colours: an exported file); here the builder's theme
3
+ overrides them. The canvas scrolls inside the page, so a wide diagram never widens the page. */
4
+ .dm-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; margin-bottom: 8px; }
5
+ .dm-name { margin: 0; font-size: 1.25rem; overflow-wrap: anywhere; }
6
+ .dm-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 8px; }
7
+ .dm-toolbar form { display: contents; }
8
+ .dm-toolbar .btn[disabled] { opacity: 0.5; }
9
+ .dm-status { min-width: 0; }
10
+ .dm-stage { position: relative; }
11
+ .dm-canvas { overflow: auto; max-width: 100%; height: 65vh; min-height: 18rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
12
+ .dm-canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
13
+ .dm-svg { display: block; }
14
+ .dm-svg .dm-bg { fill: var(--bg); }
15
+ .dm-svg .dm-frame { fill: var(--surface); stroke: var(--border); }
16
+ .dm-svg .dm-view .dm-frame { fill: var(--surface-2); }
17
+ .dm-svg .dm-head { fill: var(--accent-soft); stroke: var(--border); }
18
+ .dm-svg .dm-title { fill: var(--text); }
19
+ .dm-svg .dm-col { fill: var(--text); }
20
+ .dm-svg .dm-type, .dm-svg .dm-kind, .dm-svg .dm-more { fill: var(--muted); }
21
+ .dm-svg .dm-key { fill: var(--gold, #b45309); }
22
+ .dm-svg .dm-line, .dm-svg .dm-end { stroke: var(--muted); }
23
+ .dm-svg .dm-opt { fill: var(--bg); stroke: var(--muted); }
24
+ .dm-svg .dm-fk:hover .dm-line, .dm-svg .dm-fk:hover .dm-end { stroke: var(--accent); stroke-width: 2.5; }
25
+ .dm-svg .dm-link:hover .dm-title, .dm-svg .dm-link:focus .dm-title { fill: var(--accent); text-decoration: underline; }
26
+ .dm-live .dm-box { cursor: grab; touch-action: none; }
27
+ .dm-live .dm-box.dm-dragging { cursor: grabbing; }
28
+ .dm-svg .dm-box:focus { outline: none; }
29
+ .dm-svg .dm-box:focus-visible .dm-frame { stroke: var(--accent); stroke-width: 2.5; }
30
+ .dm-svg .dm-dragging .dm-frame { stroke: var(--accent); }
31
+ .dm-svg text { user-select: none; }
32
+ .dm-minimap { position: absolute; right: 12px; bottom: 12px; width: 200px; height: 140px; border: 1px solid var(--border); border-radius: var(--radius);
33
+ background: var(--surface); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); cursor: pointer; touch-action: none; }
34
+ .dm-minimap-bg { fill: var(--surface); }
35
+ .dm-minimap-view { fill: var(--accent); fill-opacity: 0.15; stroke: var(--accent); stroke-width: 4; vector-effect: non-scaling-stroke; }
36
+ .dm-legend { margin: 6px 0 12px; }
37
+ .dm-search-row { display: flex; gap: 6px; flex-wrap: wrap; }
38
+ .dm-search-row input { flex: 1 1 12rem; min-width: 0; }
39
+ .dm-settings { margin-bottom: 16px; }
40
+ .dm-settings > summary { cursor: pointer; margin-bottom: 8px; }
41
+ .dm-settings form { margin-bottom: 8px; }
42
+ .dm-dict-actions { margin-bottom: 12px; }
43
+ .dm-dict-toc { columns: 3 14rem; }
44
+ .dm-dict-table { margin-top: 1.5rem; }
45
+ .dm-dict-table h2 { border-bottom: 1px solid var(--border); padding-bottom: 4px; overflow-wrap: anywhere; }
46
+ @media (max-width: 640px) {
47
+ .dm-btn-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
48
+ .dm-canvas { height: 55vh; }
49
+ .dm-minimap { width: 130px; height: 90px; }
50
+ }
51
+ @media print {
52
+ .sw-tabs, .dm-toolbar, .dm-legend, #dm-objects, #dm-add, .dm-settings, .dm-minimap, .dm-dict-actions, .ide-alerts { display: none !important; }
53
+ .dm-canvas { height: auto; overflow: visible; border: 0; }
54
+ .dm-svg { max-width: 100%; height: auto; }
55
+ .dm-dict-table { break-inside: avoid-page; }
56
+ }
@@ -0,0 +1,367 @@
1
+ // pgkiln builder: the SQL Workshop's Data Modeler (sprint 98, src/builder/data-modeler.ts, src/datamodel.ts).
2
+ //
3
+ // Progressive enhancement of the server-rendered SVG diagram (the boxes at their saved positions):
4
+ // - drag a box (mouse or touch), or focus it and use the arrow keys (Shift: bigger steps); the foreign key
5
+ // lines follow (fkPath: the same geometry as src/datamodel.ts)
6
+ // - Save (Ctrl+S) posts the positions; Undo (Ctrl+Z) and Redo (Ctrl+Y, Ctrl+Shift+Z); unsaved changes are
7
+ // shown and leaving asks first
8
+ // - Zoom in / out, Fit, Auto layout (the server's automatic positions, data-ax/ay; undoable), a mini-map
9
+ // - Export SVG and PNG of the diagram as it is on the screen, Print
10
+ // Strict CSP: no inline styles; geometry goes into SVG attributes, sizes through the CSSOM.
11
+ (() => {
12
+ 'use strict';
13
+ // the data dictionary's Print button
14
+ for (const b of document.querySelectorAll('.dm-dict-actions [data-dm=print]')) {
15
+ b.hidden = false;
16
+ b.addEventListener('click', () => window.print());
17
+ }
18
+ const stage = document.querySelector('[data-dm-stage]');
19
+ const svg = document.getElementById('dm-svg');
20
+ if (!stage || !svg) return;
21
+ const canvas = stage.querySelector('.dm-canvas');
22
+ const toolbar = document.querySelector('.dm-toolbar');
23
+ const saveForm = toolbar.querySelector('.dm-save-form');
24
+ const status = toolbar.querySelector('.dm-status');
25
+ const btn = (name) => toolbar.querySelector(`[data-dm="${name}"]`);
26
+ for (const b of toolbar.querySelectorAll('[data-dm][hidden]')) b.hidden = false;
27
+ const NS = 'http://www.w3.org/2000/svg';
28
+
29
+ const boxes = new Map([...svg.querySelectorAll('.dm-box')].map((g) => [g.dataset.name, {
30
+ g, x: Number(g.dataset.x), y: Number(g.dataset.y), w: Number(g.dataset.w), h: Number(g.dataset.h),
31
+ ax: g.dataset.ax === undefined ? null : Number(g.dataset.ax), ay: g.dataset.ay === undefined ? null : Number(g.dataset.ay),
32
+ }]));
33
+ const fks = [...svg.querySelectorAll('.dm-fk')];
34
+ let W = Number(svg.getAttribute('width'));
35
+ let H = Number(svg.getAttribute('height'));
36
+ let zoom = 1;
37
+
38
+ // ------------------------------------------------------------ geometry (as fkPath in src/datamodel.ts)
39
+ function fkPath(a, ay, b, by) {
40
+ const y1 = a.y + ay;
41
+ const y2 = b.y + by;
42
+ let x1, x2, d1, d2, path;
43
+ if (a.x + a.w + 40 <= b.x) {
44
+ [x1, x2, d1, d2] = [a.x + a.w, b.x, 1, -1];
45
+ const mx = Math.round((x1 + x2) / 2);
46
+ path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
47
+ } else if (b.x + b.w + 40 <= a.x) {
48
+ [x1, x2, d1, d2] = [a.x, b.x + b.w, -1, 1];
49
+ const mx = Math.round((x1 + x2) / 2);
50
+ path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
51
+ } else {
52
+ [x1, x2, d1, d2] = [a.x + a.w, b.x + b.w, 1, 1];
53
+ const lx = Math.max(x1, x2) + (a === b ? 18 : 30);
54
+ const yy = a === b && y1 === y2 ? y2 + 10 : y2;
55
+ path = `M ${x1} ${y1} H ${lx} V ${yy} H ${x2}`;
56
+ }
57
+ const foot = `M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 - 7} M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 + 7} M ${x1 + d1 * 14} ${y1 - 7} V ${y1 + 7}`;
58
+ const bar = `M ${x2 + d2 * 10} ${y2 - 7} V ${y2 + 7}`;
59
+ return { path, ends: `${foot} ${bar}`, circle: { cx: x2 + d2 * 20, cy: y2 } };
60
+ }
61
+
62
+ function draw() {
63
+ for (const b of boxes.values()) b.g.setAttribute('transform', `translate(${b.x} ${b.y})`);
64
+ for (const l of fks) {
65
+ const a = boxes.get(l.dataset.from);
66
+ const b = boxes.get(l.dataset.to);
67
+ if (!a || !b) continue;
68
+ const p = fkPath(a, Number(l.dataset.fy), b, Number(l.dataset.ty));
69
+ l.querySelector('.dm-line').setAttribute('d', p.path);
70
+ l.querySelector('.dm-end').setAttribute('d', p.ends);
71
+ const c = l.querySelector('.dm-opt');
72
+ if (c) {
73
+ c.setAttribute('cx', String(p.circle.cx));
74
+ c.setAttribute('cy', String(p.circle.cy));
75
+ }
76
+ }
77
+ const all = [...boxes.values()];
78
+ W = Math.max(400, ...all.map((b) => b.x + b.w + 60));
79
+ H = Math.max(240, ...all.map((b) => b.y + b.h + 40));
80
+ svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
81
+ sizeSvg();
82
+ }
83
+ function sizeSvg() {
84
+ svg.setAttribute('width', String(Math.round(W * zoom)));
85
+ svg.setAttribute('height', String(Math.round(H * zoom)));
86
+ updateMinimap();
87
+ }
88
+
89
+ // ------------------------------------------------------------ undo, redo, unsaved changes
90
+ const snapshot = () => [...boxes].map(([name, b]) => [name, b.x, b.y]);
91
+ const restore = (snap) => {
92
+ for (const [name, x, y] of snap) {
93
+ const b = boxes.get(name);
94
+ if (b) [b.x, b.y] = [x, y];
95
+ }
96
+ draw();
97
+ };
98
+ const undo = [];
99
+ const redo = [];
100
+ let saved = JSON.stringify(snapshot());
101
+ const dirty = () => JSON.stringify(snapshot()) !== saved;
102
+ function changed() {
103
+ btn('undo').disabled = !undo.length;
104
+ btn('redo').disabled = !redo.length;
105
+ const d = dirty();
106
+ btn('save').classList.toggle('btn-hot', d);
107
+ status.textContent = d ? 'Unsaved changes' : '';
108
+ }
109
+ function remember(before) {
110
+ undo.push(before);
111
+ if (undo.length > 200) undo.shift();
112
+ redo.length = 0;
113
+ changed();
114
+ }
115
+ btn('undo').addEventListener('click', () => {
116
+ if (!undo.length) return;
117
+ redo.push(snapshot());
118
+ restore(undo.pop());
119
+ changed();
120
+ });
121
+ btn('redo').addEventListener('click', () => {
122
+ if (!redo.length) return;
123
+ undo.push(snapshot());
124
+ restore(redo.pop());
125
+ changed();
126
+ });
127
+ window.addEventListener('beforeunload', (e) => {
128
+ if (!dirty()) return;
129
+ e.preventDefault();
130
+ e.returnValue = '';
131
+ });
132
+
133
+ async function save() {
134
+ const body = new URLSearchParams({
135
+ __csrf: saveForm.querySelector('input[name=__csrf]').value,
136
+ format: 'json',
137
+ positions: JSON.stringify([...boxes].map(([name, b]) => ({ name, x: Math.round(b.x), y: Math.round(b.y) }))),
138
+ });
139
+ status.textContent = 'Saving…';
140
+ try {
141
+ const res = await fetch(saveForm.action, { method: 'POST', body, credentials: 'same-origin', headers: { accept: 'application/json' } });
142
+ if (!res.ok) throw new Error(String(res.status));
143
+ saved = JSON.stringify(snapshot());
144
+ changed();
145
+ status.textContent = 'Saved';
146
+ } catch (e) {
147
+ status.textContent = `Not saved (${e.message}): try again`;
148
+ }
149
+ }
150
+ btn('save').addEventListener('click', save);
151
+ saveForm.addEventListener('submit', (e) => {
152
+ e.preventDefault();
153
+ save();
154
+ });
155
+
156
+ // ------------------------------------------------------------ moving boxes
157
+ const moveBox = (b, x, y) => {
158
+ b.x = Math.max(0, Math.min(20000, Math.round(x)));
159
+ b.y = Math.max(0, Math.min(20000, Math.round(y)));
160
+ draw();
161
+ };
162
+ let dragged = false;
163
+ for (const b of boxes.values()) {
164
+ b.g.addEventListener('pointerdown', (e) => {
165
+ if (e.button !== 0) return;
166
+ const start = { x: e.clientX, y: e.clientY, bx: b.x, by: b.y };
167
+ const before = snapshot();
168
+ let moved = false;
169
+ const move = (ev) => {
170
+ const dx = (ev.clientX - start.x) / zoom;
171
+ const dy = (ev.clientY - start.y) / zoom;
172
+ if (!moved && Math.abs(dx) + Math.abs(dy) < 4) return;
173
+ if (!moved) {
174
+ moved = true;
175
+ // captured only once it moves: a plain click still reaches the box's name (its link)
176
+ b.g.setPointerCapture(ev.pointerId);
177
+ b.g.classList.add('dm-dragging');
178
+ // on top of the others
179
+ b.g.parentNode.append(b.g);
180
+ }
181
+ moveBox(b, start.bx + dx, start.by + dy);
182
+ };
183
+ const up = () => {
184
+ b.g.removeEventListener('pointermove', move);
185
+ b.g.removeEventListener('pointerup', up);
186
+ b.g.removeEventListener('pointercancel', up);
187
+ b.g.classList.remove('dm-dragging');
188
+ if (moved) {
189
+ dragged = true;
190
+ setTimeout(() => (dragged = false), 0);
191
+ remember(before);
192
+ }
193
+ };
194
+ b.g.addEventListener('pointermove', move);
195
+ b.g.addEventListener('pointerup', up);
196
+ b.g.addEventListener('pointercancel', up);
197
+ });
198
+ b.g.addEventListener('dragstart', (e) => e.preventDefault());
199
+ // a drag that ends over the box's name doesn't open the table
200
+ b.g.addEventListener('click', (e) => {
201
+ if (dragged) e.preventDefault();
202
+ }, true);
203
+ b.g.addEventListener('keydown', (e) => {
204
+ if (e.target !== b.g) return;
205
+ const step = e.shiftKey ? 50 : 10;
206
+ const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
207
+ if (!d) return;
208
+ e.preventDefault();
209
+ const before = snapshot();
210
+ moveBox(b, b.x + d[0], b.y + d[1]);
211
+ remember(before);
212
+ });
213
+ }
214
+
215
+ // ------------------------------------------------------------ auto layout
216
+ const auto = btn('auto');
217
+ auto.closest('form').addEventListener('submit', (e) => {
218
+ e.preventDefault();
219
+ const before = snapshot();
220
+ for (const b of boxes.values()) if (b.ax !== null) [b.x, b.y] = [b.ax, b.ay];
221
+ draw();
222
+ remember(before);
223
+ });
224
+
225
+ // ------------------------------------------------------------ zoom
226
+ const setZoom = (z) => {
227
+ zoom = Math.max(0.2, Math.min(3, Math.round(z * 100) / 100));
228
+ sizeSvg();
229
+ status.textContent = `${Math.round(zoom * 100)}%${dirty() ? ' · unsaved changes' : ''}`;
230
+ };
231
+ btn('zoom-in').addEventListener('click', () => setZoom(zoom * 1.25));
232
+ btn('zoom-out').addEventListener('click', () => setZoom(zoom / 1.25));
233
+ btn('fit').addEventListener('click', () => setZoom(Math.min(2, (canvas.clientWidth - 4) / W, (canvas.clientHeight - 4) / H)));
234
+ canvas.addEventListener('wheel', (e) => {
235
+ if (!e.ctrlKey) return;
236
+ e.preventDefault();
237
+ setZoom(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
238
+ }, { passive: false });
239
+
240
+ // ------------------------------------------------------------ mini-map
241
+ const content = svg.querySelector('.dm-content');
242
+ content.id = 'dm-content';
243
+ let mini = null;
244
+ let view = null;
245
+ function updateMinimap() {
246
+ if (!mini) return;
247
+ mini.setAttribute('viewBox', `0 0 ${W} ${H}`);
248
+ view.setAttribute('x', String(canvas.scrollLeft / zoom));
249
+ view.setAttribute('y', String(canvas.scrollTop / zoom));
250
+ view.setAttribute('width', String(Math.min(W, canvas.clientWidth / zoom)));
251
+ view.setAttribute('height', String(Math.min(H, canvas.clientHeight / zoom)));
252
+ }
253
+ const mm = btn('minimap');
254
+ mm.setAttribute('aria-pressed', 'false');
255
+ mm.addEventListener('click', () => {
256
+ if (mini) {
257
+ mini.remove();
258
+ mini = null;
259
+ mm.setAttribute('aria-pressed', 'false');
260
+ return;
261
+ }
262
+ mini = document.createElementNS(NS, 'svg');
263
+ mini.setAttribute('class', 'dm-minimap');
264
+ mini.setAttribute('aria-hidden', 'true');
265
+ mini.setAttribute('preserveAspectRatio', 'xMinYMin meet');
266
+ const bg = document.createElementNS(NS, 'rect');
267
+ bg.setAttribute('class', 'dm-minimap-bg');
268
+ bg.setAttribute('width', '100%');
269
+ bg.setAttribute('height', '100%');
270
+ const use = document.createElementNS(NS, 'use');
271
+ use.setAttribute('href', '#dm-content');
272
+ view = document.createElementNS(NS, 'rect');
273
+ view.setAttribute('class', 'dm-minimap-view');
274
+ mini.append(bg, use, view);
275
+ stage.append(mini);
276
+ mm.setAttribute('aria-pressed', 'true');
277
+ // click or drag in the mini-map: that part of the diagram in the middle
278
+ const go = (e) => {
279
+ const r = mini.getBoundingClientRect();
280
+ const scale = Math.min(r.width / W, r.height / H);
281
+ const x = (e.clientX - r.left) / scale;
282
+ const y = (e.clientY - r.top) / scale;
283
+ canvas.scrollLeft = x * zoom - canvas.clientWidth / 2;
284
+ canvas.scrollTop = y * zoom - canvas.clientHeight / 2;
285
+ };
286
+ mini.addEventListener('pointerdown', (e) => {
287
+ mini.setPointerCapture(e.pointerId);
288
+ go(e);
289
+ const move = (ev) => go(ev);
290
+ mini.addEventListener('pointermove', move);
291
+ mini.addEventListener('pointerup', () => mini.removeEventListener('pointermove', move), { once: true });
292
+ });
293
+ updateMinimap();
294
+ });
295
+ canvas.addEventListener('scroll', updateMinimap);
296
+ window.addEventListener('resize', updateMinimap);
297
+
298
+ // ------------------------------------------------------------ export and print
299
+ const fileName = (stage.dataset.name || 'diagram').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 80);
300
+ /** The diagram as a standalone SVG document (its presentation attributes carry the colours). */
301
+ function svgText() {
302
+ const copy = svg.cloneNode(true);
303
+ copy.removeAttribute('id');
304
+ copy.setAttribute('width', String(W));
305
+ copy.setAttribute('height', String(H));
306
+ copy.querySelector('.dm-content').removeAttribute('id');
307
+ for (const a of [...copy.querySelectorAll('a')]) a.replaceWith(...a.childNodes);
308
+ for (const g of copy.querySelectorAll('[tabindex]')) g.removeAttribute('tabindex');
309
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(copy)}\n`;
310
+ }
311
+ const download = (blob, name) => {
312
+ const a = document.createElement('a');
313
+ a.href = URL.createObjectURL(blob);
314
+ a.download = name;
315
+ document.body.append(a);
316
+ a.click();
317
+ a.remove();
318
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
319
+ };
320
+ btn('svg').addEventListener('click', (e) => {
321
+ e.preventDefault();
322
+ download(new Blob([svgText()], { type: 'image/svg+xml' }), `${fileName}.svg`);
323
+ });
324
+ btn('png').addEventListener('click', () => {
325
+ const img = new Image();
326
+ img.onload = () => {
327
+ const scale = 2;
328
+ const c = document.createElement('canvas');
329
+ c.width = Math.round(W * scale);
330
+ c.height = Math.round(H * scale);
331
+ const ctx = c.getContext('2d');
332
+ ctx.scale(scale, scale);
333
+ ctx.drawImage(img, 0, 0, W, H);
334
+ c.toBlob((blob) => blob && download(blob, `${fileName}.png`), 'image/png');
335
+ };
336
+ img.onerror = () => (status.textContent = 'The PNG could not be made: export SVG instead');
337
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText())}`;
338
+ });
339
+ btn('print').addEventListener('click', () => {
340
+ const z = zoom;
341
+ setZoom(1);
342
+ window.print();
343
+ setZoom(z);
344
+ });
345
+
346
+ document.addEventListener('keydown', (e) => {
347
+ const key = e.key.toLowerCase();
348
+ const mod = e.ctrlKey || e.metaKey;
349
+ if (!mod) return;
350
+ if (key === 's') {
351
+ e.preventDefault();
352
+ save();
353
+ } else if (/^(input|textarea|select)$/i.test(document.activeElement?.tagName || '')) {
354
+ return;
355
+ } else if (key === 'z' && !e.shiftKey) {
356
+ e.preventDefault();
357
+ btn('undo').click();
358
+ } else if (key === 'y' || (key === 'z' && e.shiftKey)) {
359
+ e.preventDefault();
360
+ btn('redo').click();
361
+ }
362
+ });
363
+
364
+ canvas.classList.add('dm-live');
365
+ changed();
366
+ status.textContent = '';
367
+ })();
@@ -0,0 +1,133 @@
1
+ // pgkiln builder: the search (Ctrl+K) and the SQL Workshop Launchpad (sprint 96, src/builder/launchpad.ts).
2
+ //
3
+ // - Ctrl+K (⌘K) on any builder page, or focusing the header's search field, opens a search dialog: tools,
4
+ // applications, pages and database objects by name (GET /builder/palette?format=json), arrow keys and Enter
5
+ // to open one. Without JavaScript the header's field posts to the results page.
6
+ // - SQL Workshop pages are remembered as "Recently visited" in this browser and listed on the home page.
7
+ // - The home page's tool groups (Development, Administration, Monitoring) become tabs.
8
+ // Results are built as text nodes, never parsed as HTML.
9
+ (() => {
10
+ 'use strict';
11
+ const store = {
12
+ get(k) { try { return JSON.parse(localStorage.getItem(`pgkiln.${k}`) || 'null'); } catch { return null; } },
13
+ set(k, v) { try { localStorage.setItem(`pgkiln.${k}`, JSON.stringify(v)); } catch { /* private mode */ } },
14
+ };
15
+
16
+ // ------------------------------------------------------------ recently visited SQL Workshop pages
17
+ const path = location.pathname;
18
+ if (/^\/builder\/sql\/./.test(path) && !/\/(panel|navigator|action)$/.test(path)) {
19
+ const title = document.title.replace(/ · Builder$/, '');
20
+ const href = path + location.search;
21
+ const list = (store.get('sw.recent') || []).filter((r) => r && r.href !== href);
22
+ list.unshift({ href, title });
23
+ store.set('sw.recent', list.slice(0, 8));
24
+ }
25
+ const recent = document.querySelector('[data-lp-recent]');
26
+ if (recent) {
27
+ const list = store.get('sw.recent') || [];
28
+ for (const r of list) {
29
+ if (typeof r?.href !== 'string' || !r.href.startsWith('/builder/')) continue;
30
+ const li = document.createElement('li');
31
+ const a = document.createElement('a');
32
+ a.href = r.href;
33
+ a.textContent = String(r.title || r.href);
34
+ li.append(a);
35
+ recent.append(li);
36
+ }
37
+ recent.hidden = !recent.children.length;
38
+ }
39
+
40
+ // ------------------------------------------------------------ the Launchpad's tabs
41
+ const tabs = document.querySelector('.lp-tabs');
42
+ if (tabs) {
43
+ tabs.hidden = false;
44
+ const show = (g) => {
45
+ for (const b of tabs.querySelectorAll('[data-lp-tab]')) b.setAttribute('aria-selected', String(b.dataset.lpTab === g));
46
+ for (const sct of document.querySelectorAll('[data-lp-group]')) sct.hidden = sct.dataset.lpGroup !== g;
47
+ store.set('lp.tab', g);
48
+ };
49
+ show(store.get('lp.tab') || 'development');
50
+ tabs.addEventListener('click', (e) => { const b = e.target.closest('[data-lp-tab]'); if (b) show(b.dataset.lpTab); });
51
+ }
52
+
53
+ // ------------------------------------------------------------ the search dialog
54
+ let dlg = null, input, listbox, status, items = [], active = -1, timer = 0, seq = 0;
55
+ const build = () => {
56
+ dlg = document.createElement('dialog');
57
+ dlg.className = 'palette';
58
+ dlg.setAttribute('aria-label', 'Search the builder');
59
+ input = Object.assign(document.createElement('input'), { type: 'search', placeholder: 'Tables, views, functions, applications, pages, tools…', autocomplete: 'off', id: 'palette-q' });
60
+ input.setAttribute('role', 'combobox');
61
+ input.setAttribute('aria-expanded', 'true');
62
+ input.setAttribute('aria-controls', 'palette-list');
63
+ input.setAttribute('aria-label', 'Search');
64
+ listbox = Object.assign(document.createElement('ul'), { id: 'palette-list', className: 'palette-list' });
65
+ listbox.setAttribute('role', 'listbox');
66
+ status = Object.assign(document.createElement('p'), { className: 'palette-status muted small' });
67
+ status.setAttribute('aria-live', 'polite');
68
+ dlg.append(input, listbox, status);
69
+ document.body.append(dlg);
70
+ input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(query, 150); });
71
+ input.addEventListener('keydown', (e) => {
72
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
73
+ e.preventDefault();
74
+ if (items.length) select((active + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length);
75
+ } else if (e.key === 'Enter') {
76
+ e.preventDefault();
77
+ const r = items[active] || items[0];
78
+ if (r) location.href = r.href;
79
+ else if (input.value.trim()) location.href = `/builder/palette?q=${encodeURIComponent(input.value.trim())}`;
80
+ }
81
+ });
82
+ listbox.addEventListener('click', (e) => { const li = e.target.closest('[role=option]'); if (li) location.href = items[Number(li.dataset.i)].href; });
83
+ dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
84
+ };
85
+ const select = (i) => {
86
+ active = i;
87
+ for (const li of listbox.children) li.setAttribute('aria-selected', String(Number(li.dataset.i) === i));
88
+ const li = listbox.children[i];
89
+ if (li) { input.setAttribute('aria-activedescendant', li.id); li.scrollIntoView({ block: 'nearest' }); }
90
+ };
91
+ const query = async () => {
92
+ const q = input.value.trim();
93
+ const mine = ++seq;
94
+ if (!q) { listbox.replaceChildren(); items = []; status.textContent = 'Type to search.'; return; }
95
+ try {
96
+ const res = await fetch(`/builder/palette?format=json&q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
97
+ if (!res.ok) throw new Error(String(res.status));
98
+ const data = await res.json();
99
+ if (mine !== seq) return; // a later query is on its way
100
+ items = data.results || [];
101
+ listbox.replaceChildren(...items.map((r, i) => {
102
+ const li = document.createElement('li');
103
+ li.setAttribute('role', 'option');
104
+ li.id = `palette-${i}`;
105
+ li.dataset.i = String(i);
106
+ const label = Object.assign(document.createElement('strong'), { textContent: r.label });
107
+ const detail = Object.assign(document.createElement('small'), { className: 'muted', textContent: ` ${r.kind}${r.detail ? ` · ${r.detail}` : ''}` });
108
+ li.append(label, detail);
109
+ return li;
110
+ }));
111
+ status.textContent = items.length ? `${items.length} found.` : 'Nothing found.';
112
+ select(items.length ? 0 : -1);
113
+ } catch {
114
+ status.textContent = 'The search failed.';
115
+ }
116
+ };
117
+ const open = (text = '') => {
118
+ if (!dlg) build();
119
+ if (!dlg.open) dlg.showModal();
120
+ input.value = text;
121
+ input.focus();
122
+ query();
123
+ };
124
+ document.addEventListener('keydown', (e) => {
125
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {
126
+ e.preventDefault();
127
+ open();
128
+ }
129
+ });
130
+ const field = document.querySelector('form[data-palette] input[name=q]');
131
+ field?.addEventListener('focus', () => { const v = field.value; field.blur(); open(v); });
132
+ window.pgkilnPalette = { open };
133
+ })();
@@ -0,0 +1,73 @@
1
+ // pgkiln builder: the SQL Workshop's SQL Monitor (sprint 102, src/builder/sql-monitor.ts).
2
+ //
3
+ // Progressive enhancement of the Sessions view: the live part ([data-sm-live]: tiles, sessions, wait events,
4
+ // progress) is fetched again (?fragment=1) every 5, 15 or 60 seconds and swapped in place, with a Pause button.
5
+ // The interval and the pause are remembered in localStorage (pgkiln.sqlMonitor). No refresh while the tab is
6
+ // hidden or a session's confirmation is open; a failed or redirected fetch (signed out) stops it. Without
7
+ // JavaScript the page's Refresh link reloads it.
8
+ (() => {
9
+ 'use strict';
10
+ const live = document.querySelector('[data-sm-live]');
11
+ const controls = document.querySelector('[data-sm-controls]');
12
+ if (!live || !controls) return;
13
+ const select = controls.querySelector('[data-sm-interval]');
14
+ const pause = controls.querySelector('[data-sm-pause]');
15
+ const KEY = 'pgkiln.sqlMonitor';
16
+ const load = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null') || {}; } catch { return {}; } };
17
+ const save = (v) => { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch { /* private mode */ } };
18
+ const pref = load();
19
+ const allowed = [...select.options].map((o) => Number(o.value));
20
+ let seconds = allowed.includes(Number(pref.seconds)) ? Number(pref.seconds) : allowed[0];
21
+ let paused = pref.paused === true;
22
+ let timer = 0;
23
+ let busy = false;
24
+ select.value = String(seconds);
25
+ controls.hidden = false;
26
+
27
+ const show = () => {
28
+ pause.setAttribute('aria-pressed', paused ? 'true' : 'false');
29
+ pause.textContent = paused ? 'Resume' : 'Pause';
30
+ live.classList.toggle('is-paused', paused);
31
+ };
32
+ const schedule = () => {
33
+ window.clearTimeout(timer);
34
+ if (!paused) timer = window.setTimeout(refresh, seconds * 1000);
35
+ };
36
+ async function refresh() {
37
+ if (busy || paused) return;
38
+ if (document.hidden) return schedule();
39
+ busy = true;
40
+ try {
41
+ const res = await fetch(live.dataset.smLive, { credentials: 'same-origin', headers: { accept: 'text/html' }, cache: 'no-store' });
42
+ if (!res.ok || res.redirected) throw new Error(String(res.status));
43
+ const text = await res.text();
44
+ // keep focus on a button that survives the swap (by its label)
45
+ const focused = document.activeElement && live.contains(document.activeElement) ? document.activeElement.getAttribute('aria-label') : null;
46
+ live.innerHTML = text;
47
+ if (focused) live.querySelector(`[aria-label="${CSS.escape(focused)}"]`)?.focus();
48
+ busy = false;
49
+ schedule();
50
+ } catch {
51
+ busy = false;
52
+ paused = true;
53
+ show();
54
+ const note = live.querySelector('[data-sm-updated]');
55
+ if (note) note.textContent = 'Refresh stopped: the monitor could not be reached (signed out?). Reload the page.';
56
+ }
57
+ }
58
+ select.addEventListener('change', () => {
59
+ seconds = Number(select.value);
60
+ save({ seconds, paused });
61
+ schedule();
62
+ });
63
+ pause.addEventListener('click', () => {
64
+ paused = !paused;
65
+ save({ seconds, paused });
66
+ show();
67
+ if (paused) window.clearTimeout(timer);
68
+ else refresh();
69
+ });
70
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && !paused) refresh(); });
71
+ show();
72
+ schedule();
73
+ })();