pgkiln 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -111,6 +111,7 @@ first version (MVP). Every finding below has a regression test in
111
111
  | App Gallery (sprint 120) | Installing a sample app runs shipped SQL as the owner, so only administrators may, with the CSRF token, into their current workspace; a request names only a key of `gallery.json` of its own kind (sample, starter, utility), never a path, and the file names in the catalogue must match fixed patterns. The script and the workspace placement run in one transaction: a failing script leaves nothing behind. Utility apps are blueprints: any developer installs one into their own workspace, only after the review. An installed sample is edited only from its own workspace. The search term is escaped. |
112
112
  | Builder language (sprint 121) | Only the codes of the builder's languages are accepted: anything else on a sign-in link is ignored (also a repeated parameter), the status bar's menu refuses it (400) and checks the CSRF token. A sign-in link sets only an HttpOnly, SameSite=Lax cookie that the sign-in page reads; a developer's stored choice changes only through their own signed-in session. Translated texts are escaped like the English ones; placeholders are filled with escaped values unless the code passes markup on purpose (`tx.rich`). |
113
113
  | Ask and smart filters set facets (sprint 122) | A phrase can only set facets of the faceted search and smart filters regions on the page that the user sees, on their configured columns; values and range bounds become the facets' own URL parameters (bounds checked as numbers or dates) and reach SQL as query parameters, never as text. Values in a phrase are looked up in the report's rows with a query parameter, as the application's role. A facet chip's Remove is checked against the chips the form showed (a tampered key is refused). Sample apps keep their demo accounts with known passwords: install them on development or demo instances only. |
114
+ | Builder texts in the browser (sprint 123) | The builder's JavaScript gets its texts as JSON in an attribute (`<div id="builder-texts" data-texts>`), escaped like any attribute value, so builder pages still have no inline script. The texts are the fixed message files, never data; scripts put them into the page with `textContent` and attributes, not as HTML. |
114
115
 
115
116
  ## Findings of the MVP review and their fixes
116
117
 
@@ -249,7 +249,7 @@ row above where there is one.
249
249
  | WS-4 AI Agents with tools | ✅ | (sprint 111) AI Agents and AI Tools as shared components, a Navigate tool, Gemini and OpenAI-compatible services ([Workflow, automation and AI](#workflow-automation-and-ai)) |
250
250
  | WS-5 AI interactive reports: chips, computed columns | ✅ | (sprint 112) *Ask in your own words* with chips, Undo and computed columns; a phrase parser that needs no AI service; (sprint 122) phrases set faceted search and smart filter facets |
251
251
  | WS-6 Workflow 26.2: outcomes, migrating running instances, drafts, diagram export | ✅ | (sprint 114) Custom task outcomes, task events, Send E-Mail and draft steps, migrating running instances, the diagram as SVG / PNG ([Workflow, automation and AI](#workflow-automation-and-ai)) |
252
- | WS-7 The builder in more languages | ✅ | (sprint 121) The builder in Deutsch, English, Español, Français, Nederlands and Português: links on the sign-in page, a language menu in the status bar, the choice stored per developer (migration 127). Translated: the builder's frame, sign-in page, Workspace home, App Builder home, an application's home and tabs, the Page Designer's toolbar, menus, tree and gallery, the SQL Workshop's home and tabs; other screens move over incrementally (English until then). Builder texts are keyed message files with a coverage check in CI |
252
+ | WS-7 The builder in more languages | ✅ | (sprint 121) The builder in Deutsch, English, Español, Français, Nederlands and Português: links on the sign-in page, a language menu in the status bar, the choice stored per developer (migration 127). Translated: the builder's frame, sign-in page, Workspace home, App Builder home, an application's home and tabs, the Page Designer's toolbar, menus, tree and gallery, the SQL Workshop's home and tabs; other screens move over incrementally (English until then). Builder texts are keyed message files with a coverage check in CI (sprint 123) Also the SQL Worksheet, the Object Browser and Table Properties, with the texts of the browser code. |
253
253
  | WS-8 App Gallery: more sample apps, Utility Apps | ✅ | (sprint 120) Four tabs with counts and a search that also works without JavaScript ([chapter 3](guide/03-builder.md#app-gallery)). *Sample Apps*: HR Demo, Tasks, ten single-topic apps (Sample Charts, Sample Cards, Sample Calendar, Sample Interactive Grids, Sample Dynamic Actions, Sample Reporting; sprint 122: Sample Maps, Sample Approvals, Sample Data Loading, Sample Files) and the **UX Pattern Catalog**, each with its own schema, data, demo account and explanatory texts, installed by administrators from one SQL file in one transaction; *Utility Apps*: Lookup Lists, Release Notes and Announcements as blueprints any developer installs into the workspace. APEX's 27 sample apps include topics pgkiln shows in HR Demo instead (REST, trees, collections); no remote catalogue |
254
254
  | WS-9 Mail queue reporting, workspace SMTP, workspace network ACLs | ✅ | (sprint 118) Administration → Mail queue (per hour and per workspace / application, the slowest provider calls, failures with the provider's answer, Retry and Purge), a workspace's own mail service, and a workspace's network access list that narrows the installation's allow-list for every outgoing call ([Administration](#administration)) |
255
255
  | WS-10 Universal Theme 26.2: Pillar appearances, navigation polish | ✅ | (sprint 117) Theme Roller → Navigation appearance Pillar Light / Pillar Dark per application or style, with a header strip colour for light and dark mode; the current page filled, its parents bold with an accent guide line; `u-color-*-subtle` utilities; a lighter processing indicator; RTL mirrored |
@@ -274,8 +274,9 @@ device; other developers keep their own. It only changes the builder: your appli
274
274
 
275
275
  What is translated: the builder's frame (the rail, the top bar and its search, your account menu, the status bar,
276
276
  standard messages and error pages), the sign-in page, the Workspace home, the App Builder home, an application's
277
- home page and its tabs, the Page Designer's toolbar, menus, component tree, panes and gallery, and the SQL Workshop's
278
- home page and tabs. Other builder pages are still in English (they move over screen by screen); so are the
277
+ home page and its tabs, the Page Designer's toolbar, menus, component tree, panes and gallery, the SQL Workshop's
278
+ home page and tabs, and (sprint 123) the **SQL Worksheet** (with its messages in the browser), the **Object Browser**
279
+ and **Table Properties**. Other builder pages are still in English (they move over screen by screen); so are the
279
280
  Page Designer's property names and their help, the links to this guide, and any SQL the builder generates.
280
281
 
281
282
  ## Workspace home
@@ -589,6 +589,20 @@ translated label (German nouns). Flash messages are translated where they are se
589
589
  display position, button position, component kind and SQL Workshop tool has its run-time key; and that every
590
590
  language file has exactly the English keys, the same `{placeholders}`, no empty texts and is mostly translated.
591
591
 
592
+ **Texts in the browser** (sprint 123). The builder's own JavaScript (`public/*.js`) asks for texts with keys
593
+ `js.<screen>.<what>`: `shell()` puts every `js.*` key in the developer's language into the page as JSON in an
594
+ attribute, `<div id="builder-texts" hidden data-texts="…">` (escaped like any value; builder pages keep having no
595
+ inline script). A script reads it once and fills placeholders itself:
596
+
597
+ ```js
598
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
599
+ const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
600
+ announce(msg('js.ws.copied', { what }));
601
+ ```
602
+
603
+ The coverage check also scans `public/*.js` for `msg('js.…')` and fails when a key has no English text. Text
604
+ from the server (a JSON answer's `message`) is translated on the server.
605
+
592
606
  Stays English by design: the property editor's field names and help, links to the guide, generated SQL, and pages
593
607
  not moved yet. The builder's language is separate from an application's languages (`src/i18n.ts`).
594
608
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -1216,7 +1216,7 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
1216
1216
  .ob-folder > summary .kind { margin-left: auto; }
1217
1217
  .ob-folder .tree { padding-left: 0.75rem; }
1218
1218
  .ob-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.5rem; margin-bottom: 0.75rem; }
1219
- .ob-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.5rem; overflow-wrap: anywhere; }
1219
+ .ob-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0 0.5rem; overflow-wrap: anywhere; }
1220
1220
  .ob-actions { display: grid; gap: 0.6rem; margin-top: 1rem; }
1221
1221
  .ob-action { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
1222
1222
  .ob-action input:not([type=checkbox]) { width: 10rem; }
@@ -21,6 +21,9 @@
21
21
  'use strict';
22
22
  const form = document.getElementById('ws');
23
23
  if (!form) return;
24
+ // (sprint 123) texts in the builder's language: #builder-texts (shell(), keys js.*), English in en.ts
25
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
26
+ const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
24
27
  const ta = form.querySelector('textarea[name=sql]');
25
28
  const out = form.querySelector('.ws-output');
26
29
  const store = {
@@ -149,9 +152,9 @@
149
152
  const copy = async (text, what) => {
150
153
  try {
151
154
  await navigator.clipboard.writeText(text);
152
- announce(`${what} copied.`);
155
+ announce(msg('js.ws.copied', { what }));
153
156
  } catch {
154
- announce('Copying is not allowed here.');
157
+ announce(msg('js.ws.no_copy'));
155
158
  }
156
159
  };
157
160
  const announce = (msg) => {
@@ -165,7 +168,7 @@
165
168
  if (e.target.closest('[data-ws-copy]')) {
166
169
  const head = [...grid.tHead.rows[0].cells].slice(1).map((th) => th.textContent.trim());
167
170
  const rows = [...grid.tBodies[0].rows].map((tr) => [...tr.cells].slice(1).map((td) => cellValue(td).replace(/[\t\n]/g, ' ')));
168
- copy([head, ...rows].map((r) => r.join('\t')).join('\n'), `${rows.length} rows`);
171
+ copy([head, ...rows].map((r) => r.join('\t')).join('\n'), msg('js.ws.rows', { n: rows.length }));
169
172
  }
170
173
  const sort = e.target.closest('[data-ws-sort]');
171
174
  if (sort) {
@@ -218,7 +221,7 @@
218
221
  run('statement');
219
222
  }
220
223
  const c = e.target.closest('[data-ws-copy-text]');
221
- if (c) copy(c.dataset.wsCopyText, 'The statement');
224
+ if (c) copy(c.dataset.wsCopyText, msg('js.ws.the_statement'));
222
225
  });
223
226
 
224
227
  // ------------------------------------------------------------ (sprint 93) worksheets as tabs, kept in this browser
@@ -226,7 +229,7 @@
226
229
  const readTabs = () => { try { const v = JSON.parse(store.get('tabs') || 'null'); return v && Array.isArray(v.tabs) ? v : null; } catch { return null; } };
227
230
  const book = readTabs() || { active: '', tabs: [] };
228
231
  const newKey = () => Math.random().toString(36).slice(2, 10);
229
- const untitled = () => { let n = 1; while (book.tabs.some((t) => t.name === `Untitled ${n}`)) n++; return `Untitled ${n}`; };
232
+ const untitled = () => { let n = 1; while (book.tabs.some((x) => x.name === msg('js.ws.untitled', { n }))) n++; return msg('js.ws.untitled', { n }); };
230
233
  const src = form.dataset.source;
231
234
  const sid = form.dataset.scriptId ? Number(form.dataset.scriptId) : null;
232
235
  const sname = form.dataset.scriptName;
@@ -273,16 +276,16 @@
273
276
  if (t === cur) b.setAttribute('aria-current', 'true');
274
277
  b.dataset.wsWtab = t.key;
275
278
  b.textContent = `${t.name}${dirty(t) ? '*' : ''}`;
276
- b.title = t.scriptId ? `Script “${t.name}”${dirty(t) ? ' (unsaved changes)' : ''}` : 'Not saved as a script';
279
+ b.title = t.scriptId ? `${msg('js.ws.script', { name: t.name })}${dirty(t) ? ` ${msg('js.ws.unsaved')}` : ''}` : msg('js.ws.not_saved');
277
280
  const x = document.createElement('button');
278
281
  x.type = 'button';
279
282
  x.className = 'ws-wtab-close';
280
283
  x.dataset.wsWclose = t.key;
281
- x.setAttribute('aria-label', `Close ${t.name}`);
284
+ x.setAttribute('aria-label', msg('js.ws.close_tab', { name: t.name }));
282
285
  x.textContent = '×';
283
286
  wrap.append(b, x);
284
287
  return wrap;
285
- }), Object.assign(document.createElement('button'), { type: 'button', className: 'ws-wtab-new', title: 'New worksheet', textContent: '+' }));
288
+ }), Object.assign(document.createElement('button'), { type: 'button', className: 'ws-wtab-new', title: msg('js.ws.new_tab'), textContent: '+' }));
286
289
  persist();
287
290
  };
288
291
  const switchTo = (t) => {
@@ -297,8 +300,8 @@
297
300
  const newTab = () => switchTo(add({ key: newKey(), name: untitled(), scriptId: null, text: '', saved: '' }));
298
301
  const closeTab = (t) => {
299
302
  const txnOpen = t === cur ? txnBox.dataset.state !== 'none' : !!t.txnOpen;
300
- if (txnOpen && !window.confirm(`“${t.name}” has an open transaction. Close it and roll the transaction back?`)) return;
301
- if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(`Close “${t.name}” without saving?`)) return;
303
+ if (txnOpen && !window.confirm(msg('js.ws.close_txn', { name: t.name }))) return;
304
+ if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(msg('js.ws.close_unsaved', { name: t.name }))) return;
302
305
  if (t.txn) txnPost(t.key, 'rollback'); // the server knows best whether one is open
303
306
  const i = book.tabs.indexOf(t);
304
307
  book.tabs.splice(i, 1);
@@ -360,15 +363,15 @@
360
363
  const rollbackBtn = txnBox.querySelector('[data-ws-txn-rollback]');
361
364
  const commitNote = form.querySelector('[data-ws-commit-note]');
362
365
  const idleSeconds = Number(txnBox.dataset.idle) || 300;
363
- const idleWords = idleSeconds % 60 ? `${idleSeconds} seconds` : `${idleSeconds / 60} minute${idleSeconds === 60 ? '' : 's'}`;
366
+ const idleWords = idleSeconds % 60 ? msg('js.ws.idle_seconds', { n: idleSeconds }) : msg(idleSeconds === 60 ? 'js.ws.idle_minute' : 'js.ws.idle_minutes', { n: idleSeconds / 60 });
364
367
  let txnInfo = { state: txnBox.dataset.state, statements: Number(txnBox.dataset.statements) || 0, startedMs: Number(txnBox.dataset.started) || null,
365
368
  lastMs: Number(txnBox.dataset.last) || null, note: txnBox.dataset.note || null };
366
369
  let clockSkew = Date.now() - (Number(txnBox.dataset.now) || Date.now());
367
370
  let txnTimer = 0;
368
371
  const badgeText = (i) => {
369
- if (i.state === 'none') return i.note || 'No open transaction';
372
+ if (i.state === 'none') return i.note || msg('js.ws.txn_none');
370
373
  const min = i.startedMs ? Math.floor((Date.now() - clockSkew - i.startedMs) / 60000) : 0;
371
- return `${i.state === 'aborted' ? 'Transaction failed' : 'Transaction open'} · ${i.statements} statement${i.statements === 1 ? '' : 's'} · ${min < 1 ? 'under a minute' : `${min} min`}`;
374
+ return `${msg(i.state === 'aborted' ? 'js.ws.txn_failed' : 'js.ws.txn_open')} · ${msg(i.statements === 1 ? 'js.ws.stmt_one' : 'js.ws.stmt', { n: i.statements })} · ${min < 1 ? msg('js.ws.under_minute') : msg('js.ws.min', { n: min })}`;
372
375
  };
373
376
  const txnApply = (i) => {
374
377
  txnInfo = i;
@@ -381,8 +384,8 @@
381
384
  commitBtn.disabled = i.state !== 'open';
382
385
  rollbackBtn.disabled = i.state === 'none';
383
386
  commitNote.textContent = on
384
- ? `The statements stay in one transaction until Commit or Rollback; it is rolled back after ${idleWords} without a run.`
385
- : 'Each statement commits on its own.';
387
+ ? msg('js.ws.commit_txn', { idle: idleWords })
388
+ : msg('js.ws.commit_auto');
386
389
  cur.txn = on;
387
390
  cur.txnOpen = i.state !== 'none';
388
391
  persist();
@@ -407,7 +410,7 @@
407
410
  if (j.state !== 'none') modeSel.value = 'on';
408
411
  fromServer(j);
409
412
  } catch {
410
- badge.textContent = 'Transaction state unknown';
413
+ badge.textContent = msg('js.ws.txn_unknown');
411
414
  }
412
415
  }
413
416
  async function txnPost(key, action) {
@@ -421,13 +424,13 @@
421
424
  }
422
425
  return j;
423
426
  } catch {
424
- announce('The transaction could not be ended: reload the page.');
427
+ announce(msg('js.ws.txn_not_ended'));
425
428
  return null;
426
429
  }
427
430
  }
428
431
  modeSel.addEventListener('change', async () => {
429
432
  if (modeSel.value !== 'on' && txnInfo.state !== 'none') {
430
- if (!window.confirm('Switching to Autocommit rolls back the open transaction. Roll it back?')) { modeSel.value = 'on'; return; }
433
+ if (!window.confirm(msg('js.ws.txn_confirm'))) { modeSel.value = 'on'; return; }
431
434
  await txnPost(cur.key, 'rollback');
432
435
  modeSel.value = '';
433
436
  }
@@ -486,9 +489,9 @@
486
489
  const out = (await res.json()).sql;
487
490
  if (whole) replaceRange(0, ta.value.length, out, false);
488
491
  else replaceRange(a, b, out);
489
- announce('Formatted.');
492
+ announce(msg('js.ws.formatted'));
490
493
  } catch {
491
- announce('Formatting failed.');
494
+ announce(msg('js.ws.format_failed'));
492
495
  }
493
496
  };
494
497
  const convertCase = () => {
@@ -521,7 +524,7 @@
521
524
  try {
522
525
  return new RegExp(fre.checked ? fq.value : fq.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), `g${fcs.checked ? '' : 'i'}`);
523
526
  } catch (err) {
524
- fmsg.textContent = `Not a valid pattern: ${err.message}`;
527
+ fmsg.textContent = msg('js.ws.bad_pattern', { error: err.message });
525
528
  return null;
526
529
  }
527
530
  };
@@ -537,7 +540,7 @@
537
540
  re.lastIndex = from;
538
541
  let m = re.exec(ta.value);
539
542
  if (!m) { re.lastIndex = 0; m = re.exec(ta.value); }
540
- if (!m || !m[0]) { fmsg.textContent = 'Not found.'; return false; }
543
+ if (!m || !m[0]) { fmsg.textContent = msg('js.ws.not_found'); return false; }
541
544
  fmsg.textContent = '';
542
545
  reveal(m.index, m.index + m[0].length);
543
546
  return true;
@@ -580,7 +583,7 @@
580
583
  if (!re) return;
581
584
  const n = (ta.value.match(re) || []).length;
582
585
  if (n) replaceRange(0, ta.value.length, ta.value.replace(re, fr.value), false);
583
- fmsg.textContent = `${n} replaced.`;
586
+ fmsg.textContent = msg('js.ws.replaced', { n });
584
587
  }
585
588
  });
586
589
 
@@ -660,6 +663,8 @@
660
663
  // to open it; Open shows the Object Browser's detail in a panel at the side (its tabs and pages load in the panel).
661
664
  (() => {
662
665
  'use strict';
666
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
667
+ const msg = (key) => TEXTS[key] ?? key;
663
668
  document.addEventListener('contextmenu', (e) => {
664
669
  const item = e.target.closest?.('.ws-obj, .ob-item');
665
670
  const menu = item?.querySelector(':scope > .obj-menu');
@@ -678,8 +683,8 @@
678
683
  const head = document.createElement('div');
679
684
  head.className = 'obj-slide-head';
680
685
  const title = Object.assign(document.createElement('strong'), { id: 'obj-slide-title' });
681
- const full = Object.assign(document.createElement('a'), { className: 'btn btn-sm', textContent: 'Open in the Object Browser' });
682
- const close = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: 'Close' });
686
+ const full = Object.assign(document.createElement('a'), { className: 'btn btn-sm', textContent: msg('js.ws.open_ob') });
687
+ const close = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: msg('js.ws.close') });
683
688
  close.addEventListener('click', () => slide.close());
684
689
  const tools = document.createElement('div');
685
690
  tools.append(full, ' ', close);
@@ -708,7 +713,7 @@
708
713
  d.querySelector('#obj-slide-title').textContent = root?.dataset.label ?? '';
709
714
  d.querySelector('.obj-slide-head a').href = url.replace('/sql/objects/panel', '/sql/objects');
710
715
  } catch {
711
- body.textContent = 'The object could not be opened.';
716
+ body.textContent = msg('js.ws.open_failed');
712
717
  } finally {
713
718
  body.removeAttribute('aria-busy');
714
719
  }