pgkiln 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
@@ -328,6 +328,10 @@ src/
328
328
  availability.ts availabilityRefusal(): who may use an application (migration 083); loadContext, the login page
329
329
  and REST modules ask it (PostgREST: meta.api_check)
330
330
  feedback.ts users' feedback page and the navigation bar entry (migration 084)
331
+ notifications.ts (sprint 165, migration 154) the notification bell (bellMarkup: unread count, newest 8, Mark all read),
332
+ /a/:alias/notifications, POST …/notifications/:id/open (the user's own only, marks read, its signed
333
+ link under the current alias) and …/notifications/read; GET …/:page/live (liveState: list entry
334
+ badges n:/b:<key>, buttons btn:<id>, the bell) with loadContext touch: false (no last_seen, no new session)
331
335
  data-items.ts display image and address items (migration 086): imageUrl (https origins into the CSP's img-src),
332
336
  GET …/image/:item (a BLOB from the item's query), POST …/geocode/:item, and items kept per user
333
337
  (loadPreferences / savePreferences through meta.get_preference / set_preference)
@@ -338,7 +342,8 @@ src/
338
342
  components.ts property spec of every component (drives the property editor)
339
343
  ui.ts IDE shell (icon rail, toolbar, breadcrumb, status bar), builder theme, form helpers, CSRF check, app tabs
340
344
  i18n.ts (sprint 121) the builder's own texts: BUILDER_LANGUAGES, tr(s) / translatorFor(lang) → tx(key, params),
341
- tx.rich (markup in a sentence), tx.dyn (keys made at run time), tx.label (known breadcrumb labels)
345
+ tx.rich (markup in a sentence), tx.dyn (keys made at run time), tx.label (known breadcrumb labels),
346
+ (sprint 162) tx.code (`backticks` become <code>), withTranslator / scopedTx (a request-scoped translator)
342
347
  i18n/ en.ts defines the builder's keys; de, es, fr, it, nl, pt, zh-cn, zh-tw, ja, ko (sprint 134) are Records of the same keys
343
348
  language.ts (migration 127) the status bar's language menu (POST /builder/language), the sign-in page's
344
349
  language links (?lang=, a cookie), meta.developer.language
@@ -610,6 +615,8 @@ test/
610
615
  fixtures/ test files (employees.xlsx)
611
616
  template-components.test.ts template language, escaping, plug-ins, regions and column templates
612
617
  logic.test.ts computations, branches, menu buttons and badges, new dynamic actions, build options, export
618
+ notifications.test.ts (165) meta.notify and its checks, the bell, opening and Mark all read, list entry badge queries,
619
+ the live endpoint, send_notification, the timer event, the settings (security cases in security.test.ts)
613
620
  page-logic.test.ts download, chain (background jobs) and workflow processes, function/app branches, dialog_closed (HR page 28)
614
621
  code-editor.test.ts code editor: completions scoped to the app's role, the check, marked fields
615
622
  builder-i18n.test.ts (sprint 121) builder languages: the coverage check of src/builder against i18n/en.ts, the message
@@ -683,6 +690,7 @@ test/
683
690
  e2e/globalization.test.ts the browser's time zone (sign-in, app.js), no-JavaScript fallback, a masked number item
684
691
  e2e/grid.test.ts interactive grid in a browser: master-detail refresh, move/resize columns, row menu, copy/paste; without JavaScript
685
692
  e2e/page-logic.test.ts dialog_closed refreshes a region without a reload, download process in a browser, dialog link without JavaScript
693
+ e2e/notifications.test.ts (165) a notification reaches the open page (bell and navigation bar badge), opened from the bell; a timer action
686
694
  e2e/ai-builder.test.ts App Builder AI in a browser (mock Claude): SQL from a question, a drafted description, proposed pages,
687
695
  a blueprint drafted and reviewed
688
696
  e2e/ai-assistant.test.ts the AI assistant and report questions on HR page 38 in a browser (mock Claude), with and without JavaScript
@@ -704,8 +712,15 @@ const tx = tr(s); // the developer's language (
704
712
  html`<h2>${tx('wh.summary')}</h2><p>${tx('ab.count', { n: 3 })}</p>`; // plain text: html`` escapes it
705
713
  tx.rich('ab.none_yet', { create: html`<a href="…">${tx('ab.create_one')}</a>` }); // markup inside a sentence
706
714
  tx.dyn(`gallery.region.${type}`, REGION_LABELS[type][0]); // a key made at run time, with a fallback
715
+ tx.code('cr.schedule_help'); // (sprint 162) `backticked` parts become <code>: syntax help
707
716
  ```
708
717
 
718
+ (sprint 162) A long form built by many helpers (the region and report settings, RESTful Services' pages) doesn't
719
+ pass `tx` to each of them: its entry point runs inside `withTranslator(tr(s), () => …)`, and the helpers import
720
+ `scopedTx as tx`, which takes the translator of the request being rendered from an `AsyncLocalStorage` (so two
721
+ requests in different languages never mix; `test/builder-i18n.test.ts` renders German and French concurrently).
722
+ Outside `withTranslator` it is English.
723
+
709
724
  A missing text falls back to English, never to the raw key. Shared helpers that render chrome take the session as
710
725
  an optional last argument (`appHeader(a, 'pages', s)`, `workshopTabs('sql', s)`); `shell()` translates known English
711
726
  breadcrumb labels (`tx.label`) so pages that aren't moved yet still get a translated trail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.82.0",
3
+ "version": "0.84.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -280,6 +280,35 @@ h2 { font-size: 1rem; margin: 0; font-weight: 600; }
280
280
  .t-user > summary::-webkit-details-marker { display: none; }
281
281
  .t-user > summary:hover { background: rgb(255 255 255 / 10%); }
282
282
 
283
+ /* (165) the notification bell: the unread count on the icon, the newest notifications in its menu */
284
+ .t-bell > summary { position: relative; }
285
+ .t-bell-count {
286
+ position: absolute; top: 1px; inset-inline-end: 0; min-width: 1.15rem; height: 1.15rem; padding: 0 0.3rem; border-radius: 99px;
287
+ background: #d92d20; color: #fff; font-size: 0.68rem; font-weight: 700; line-height: 1.15rem; text-align: center;
288
+ box-shadow: 0 0 0 2px var(--header, #1d1b31);
289
+ }
290
+ .t-bell-panel { width: min(380px, calc(100vw - 1rem)); }
291
+ .t-bell-head { flex-direction: row; align-items: center; justify-content: space-between; }
292
+ .t-bell-read { margin: 0; }
293
+ .t-bell-read .link-button { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.85rem; }
294
+ .t-bell-none { padding: 0.5rem 0.9rem; margin: 0; }
295
+ .t-bell-list, .t-notification-list { list-style: none; margin: 0; padding: 0; }
296
+ .t-bell-list form, .t-notification-list form { margin: 0; }
297
+ .t-notification {
298
+ display: flex; gap: 0.6rem; align-items: flex-start; width: 100%; padding: 0.55rem 0.9rem; border: 0; background: none;
299
+ color: var(--text); font: inherit; text-align: start; cursor: pointer;
300
+ }
301
+ .t-notification:hover, .t-notification:focus-visible { background: var(--surface-2, rgb(0 0 0 / 4%)); }
302
+ .t-notification > .icon { flex: none; margin-top: 0.15rem; color: var(--muted); }
303
+ .t-notification-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
304
+ .t-notification-title { font-weight: 500; }
305
+ .t-notification-body { color: var(--muted); font-size: 0.88rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
306
+ .t-notification-time { color: var(--muted); font-size: 0.78rem; }
307
+ .is-unread > form > .t-notification { box-shadow: inset 3px 0 0 var(--accent); }
308
+ .is-unread .t-notification-title { font-weight: 700; }
309
+ .is-unread > form > .t-notification > .icon { color: var(--accent); }
310
+ .t-notification-list > li + li { border-top: 1px solid var(--border); }
311
+
283
312
  /* ---------- menus (details dropdowns) ---------- */
284
313
  details.menu { position: relative; }
285
314
  details.menu > summary { list-style: none; }
@@ -1468,6 +1497,8 @@ path.map-near { stroke: #1f2733; stroke-dasharray: 6 4; fill: #1f2733; fill-opac
1468
1497
  .tree-view a, .tree-view summary > span, .tree-leaf > span { display: inline-flex; align-items: center; gap: 0.35rem; }
1469
1498
  .tree-leaf { padding-left: 1rem; }
1470
1499
  .tree-count { font-size: 0.75rem; color: var(--muted); }
1500
+ /* (161) the node a tree_selection_change dynamic action chose */
1501
+ .tree-view li.tree-selected > :is(a, span:not(.tree-count), .tree-row), .tree-view li.tree-selected > details > summary > span { background: var(--accent-soft); border-radius: 3px; outline: 1px solid var(--accent); outline-offset: 1px; }
1471
1502
  /* a linked branch: the disclosure triangle sits in front of the link's row */
1472
1503
  .tree-branch { position: relative; }
1473
1504
  .tree-branch > .tree-row { display: inline-flex; align-items: center; gap: 0.35rem; }
package/public/app.js CHANGED
@@ -507,6 +507,8 @@ const pgkilnUnsaved = (() => {
507
507
 
508
508
  /** (085) what runDa returns to stop the actions after it (a declined confirm) */
509
509
  const STOP = Symbol('stop');
510
+ /** (161) the element of a component event (a grid row, calendar cell, map, tree node): execute_javascript's `element` */
511
+ let componentElement = null;
510
512
  /** (156) does a Cancel Event in these actions hold its condition (decided at once, so the event's effect can be stopped)? */
511
513
  const cancels = (list) => list.some((da) => da.action === 'cancel_event' && conditionHolds(da));
512
514
  const text = (k) => (meta.texts && meta.texts[k]) || k;
@@ -634,6 +636,11 @@ const pgkilnUnsaved = (() => {
634
636
  case 'clear':
635
637
  for (const n of da.items) setItemValue(n, '');
636
638
  return;
639
+ // (161) set_value without SQL on a component event: the event's value, set in the browser
640
+ case 'set_value':
641
+ if (!da.eventValue) break;
642
+ for (const n of da.items) setItemValue(n, value == null ? '' : String(value));
643
+ return;
637
644
  case 'open_region':
638
645
  case 'close_region':
639
646
  for (const el of targets(da)) {
@@ -731,7 +738,7 @@ const pgkilnUnsaved = (() => {
731
738
  const fn = da.fn && actions.get(da.fn);
732
739
  if (!fn) return console.warn(`pgkiln: no function "${da.fn}" registered (pgkiln.actions.register) for dynamic action ${da.id}`);
733
740
  try {
734
- await fn({ value, items: da.items, region: da.region ? document.getElementById(`R${da.region}`) : null, elements: targets(da), message: da.message });
741
+ await fn({ value, items: da.items, region: da.region ? document.getElementById(`R${da.region}`) : null, elements: targets(da), message: da.message, element: componentElement });
735
742
  } catch (e) {
736
743
  console.error(e);
737
744
  showError(e.message);
@@ -908,6 +915,16 @@ const pgkilnUnsaved = (() => {
908
915
  requestAnimationFrame(() => { queued = false; runChain(run); });
909
916
  }, { passive: true });
910
917
  }
918
+ // (165) timer: every so many seconds (the trigger), while the page is visible
919
+ {
920
+ const bySeconds = new Map();
921
+ for (const da of das.filter((d) => d.event === 'timer')) {
922
+ const seconds = Number(da.trigger[0]);
923
+ if (!(seconds >= 5 && seconds <= 3600)) continue;
924
+ bySeconds.set(seconds, [...(bySeconds.get(seconds) || []), da]);
925
+ }
926
+ for (const [seconds, run] of bySeconds) setInterval(() => { if (!document.hidden) runChain(run); }, seconds * 1000);
927
+ }
911
928
  // page_unload: the page is left (only quick, local actions finish)
912
929
  window.addEventListener('pagehide', () => runChain(das.filter((da) => da.event === 'page_unload')));
913
930
  // facets_change: a faceted search region's facets were applied (the page came back from its form)
@@ -920,6 +937,58 @@ const pgkilnUnsaved = (() => {
920
937
  runChain(regionRun('facets_change', document.getElementById(`R${changed.replace(/\D/g, '')}`)));
921
938
  }
922
939
  }
940
+ // (161) component events of grid, calendar, map and tree regions (trigger: the region). The component
941
+ // raises pgkiln:component {event, element, value}; detail.cancelled tells it a cancel_event holds
942
+ // (the calendar's Create link, the tree node's link) and detail.ran how many actions run.
943
+ document.addEventListener('pgkiln:component', (e) => {
944
+ const d = e.detail || {};
945
+ const region = d.element && d.element.closest && d.element.closest('section[id^="R"], div[id^="R"], dialog[id^="R"]');
946
+ const run = regionRun(d.event, region);
947
+ d.ran = run.length;
948
+ if (!run.length) return;
949
+ const value = d.value == null ? '' : String(d.value);
950
+ if (run.some((da) => da.action === 'cancel_event' && conditionHolds(da, value))) d.cancelled = true;
951
+ componentElement = d.element;
952
+ runChain(run, value).finally(() => (componentElement = null));
953
+ });
954
+ // grid_save: the page that comes back after a grid's Save
955
+ for (const id of meta.savedGrids || []) {
956
+ const run = regionRun('grid_save', document.getElementById(`R${id}`));
957
+ if (run.length) runChain(run);
958
+ }
959
+ // calendar_view_change: the page that comes back from a calendar's view or period link (cal_changed=<region>)
960
+ {
961
+ const params = new URLSearchParams(location.search);
962
+ const changed = params.get('cal_changed');
963
+ if (changed) {
964
+ params.delete('cal_changed');
965
+ history.replaceState(history.state, '', `${location.pathname}${params.size ? `?${params}` : ''}${location.hash}`);
966
+ const region = document.getElementById(`R${changed.replace(/\D/g, '')}`);
967
+ const run = regionRun('calendar_view_change', region);
968
+ if (run.length) runChain(run, region.querySelector('.calendar')?.dataset.view || '');
969
+ }
970
+ }
971
+ document.addEventListener('click', (e) => {
972
+ const a = e.target.closest?.('.calendar .cal-toolbar a[href]');
973
+ const region = a && a.closest('section[id^="R"], div[id^="R"]');
974
+ if (!region || !regionRun('calendar_view_change', region).length) return;
975
+ const url = new URL(a.href, location.href);
976
+ url.searchParams.set('cal_changed', region.id.slice(1));
977
+ a.href = url.pathname + url.search + url.hash;
978
+ }, true);
979
+ // tree_selection_change: a node's label chosen (not the disclosure of a linked branch); the node is marked
980
+ document.addEventListener('click', (e) => {
981
+ const node = e.target.closest?.('.tree-view li[data-node]');
982
+ if (!node) return;
983
+ const summary = e.target.closest('summary');
984
+ if (summary && !summary.querySelector(':scope > span:not(.sr-only), :scope > a')) return;
985
+ const d = { event: 'tree_selection_change', element: node, value: node.dataset.node };
986
+ document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: d }));
987
+ if (!d.ran) return;
988
+ node.closest('.tree-view').querySelectorAll('li.tree-selected').forEach((li) => li.classList.remove('tree-selected'));
989
+ node.classList.add('tree-selected');
990
+ if (d.cancelled) e.preventDefault();
991
+ });
923
992
  // after_refresh: a region replaced by a refresh, a lazy load or a master row
924
993
  document.addEventListener('pgkiln:replaced', (e) => {
925
994
  const run = regionRun('after_refresh', e.detail);
@@ -1143,10 +1212,23 @@ const pgkilnUnsaved = (() => {
1143
1212
 
1144
1213
  // ------------------------------------------------------------ calendar
1145
1214
  // Create on click: a click on an empty day or hour slot follows its "+" link.
1215
+ // (161) a day or hour chosen (a click on the cell or its "+"): calendar_date_select; its cancel_event keeps the link closed
1216
+ let addClicked = false;
1146
1217
  document.addEventListener('click', (e) => {
1147
- const cell = e.target.closest?.('.calendar [data-add]');
1148
- if (!cell || e.target.closest('a, button')) return;
1149
- cell.querySelector(':scope > .cal-add')?.click();
1218
+ if (addClicked) return void (addClicked = false);
1219
+ const cell = e.target.closest?.('.calendar [data-date]');
1220
+ if (!cell) return;
1221
+ const add = e.target.closest('a.cal-add');
1222
+ if (!add && e.target.closest('a, button')) return;
1223
+ const d = { event: 'calendar_date_select', element: cell, value: cell.dataset.date };
1224
+ document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: d }));
1225
+ if (d.cancelled) return void e.preventDefault();
1226
+ const link = !add && cell.hasAttribute('data-add') && cell.querySelector(':scope > .cal-add');
1227
+ if (link) {
1228
+ addClicked = true;
1229
+ link.click();
1230
+ addClicked = false;
1231
+ }
1150
1232
  });
1151
1233
  // Drag and drop (mouse): drop an event on a day or hour slot; the server moves it
1152
1234
  // and sends the calendar back. Without JS (and from the keyboard) the event's
@@ -1442,6 +1524,9 @@ const pgkilnUnsaved = (() => {
1442
1524
  return row;
1443
1525
  }
1444
1526
 
1527
+ /** (161) grid_row_init: a new row (Add row, Duplicate); the condition sees the row's number among the new rows */
1528
+ const rowInit = (row) => document.dispatchEvent(new CustomEvent('pgkiln:component', { detail: { event: 'grid_row_init', element: row, value: row.dataset.newRow } }));
1529
+
1445
1530
  /** The editable control of a cell, if any. */
1446
1531
  const control = (td) => td?.querySelector('input:not([type=hidden]), select');
1447
1532
 
@@ -1482,6 +1567,7 @@ const pgkilnUnsaved = (() => {
1482
1567
  if (add) {
1483
1568
  const row = addRow(add.closest('[data-grid]'));
1484
1569
  row?.querySelector('input:not([type=hidden]), select')?.focus();
1570
+ if (row) rowInit(row);
1485
1571
  return;
1486
1572
  }
1487
1573
  // Duplicate: the row's values in a new row (without JS a link does it on the server)
@@ -1498,6 +1584,7 @@ const pgkilnUnsaved = (() => {
1498
1584
  if (from && control(td) && control(from)) setCell(td, control(from).tagName === 'SELECT' ? control(from).value : cellText(from));
1499
1585
  }
1500
1586
  control(row.querySelector('td[data-col]:not([hidden])'))?.focus();
1587
+ rowInit(row);
1501
1588
  return;
1502
1589
  }
1503
1590
  if (e.target.closest('[data-grid-del]')) {
@@ -2774,6 +2861,66 @@ document.addEventListener('DOMContentLoaded', () => {
2774
2861
  for (const form of document.querySelectorAll('form[data-autosubmit]')) form.submit();
2775
2862
  });
2776
2863
 
2864
+ // (165) Live badges and the notification bell (body data-live: seconds; GET <base>/<page>/live).
2865
+ const liveBadges = (() => {
2866
+ let seconds = 0;
2867
+ let timer = 0;
2868
+ let last = 0;
2869
+ let busy = false;
2870
+ let stopped = false;
2871
+ const apply = (state) => {
2872
+ for (const el of document.querySelectorAll('[data-badge]')) {
2873
+ const key = el.dataset.badge;
2874
+ if (key === 'bell' || !(key in state.badges)) continue;
2875
+ const v = state.badges[key];
2876
+ el.textContent = v ?? '';
2877
+ el.hidden = v === null || v === '';
2878
+ }
2879
+ const bell = state.bell && document.querySelector('[data-bell]');
2880
+ if (!bell) return;
2881
+ const count = bell.querySelector('[data-badge="bell"]');
2882
+ if (count) {
2883
+ count.textContent = state.bell.unread ? (state.bell.unread > 99 ? '99+' : String(state.bell.unread)) : '';
2884
+ count.hidden = !state.bell.unread;
2885
+ }
2886
+ const summary = bell.querySelector('summary');
2887
+ if (summary) {
2888
+ summary.title = state.bell.label;
2889
+ summary.setAttribute('aria-label', state.bell.label);
2890
+ }
2891
+ // the list is replaced while the menu is closed only (not under the user's pointer or focus)
2892
+ const panel = bell.querySelector('[data-bell-panel]');
2893
+ if (panel && !bell.open && panel.innerHTML !== state.bell.panel) panel.innerHTML = state.bell.panel;
2894
+ };
2895
+ const poll = () => {
2896
+ const base = document.body.dataset.base;
2897
+ const page = document.body.dataset.page;
2898
+ if (stopped || busy || !base || !page) return;
2899
+ busy = true;
2900
+ last = Date.now();
2901
+ fetch(`${base}/${page}/live`, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
2902
+ .then((r) => {
2903
+ if (r.status === 401 || r.status === 403 || r.status === 404) stopped = true;
2904
+ return r.ok ? r.json() : null;
2905
+ })
2906
+ .then((state) => state && state.badges && apply(state))
2907
+ .catch(() => {})
2908
+ .finally(() => { busy = false; });
2909
+ };
2910
+ return {
2911
+ start(s) {
2912
+ if (!(s >= 10) || timer) return;
2913
+ seconds = s;
2914
+ timer = setInterval(() => { if (!document.hidden) poll(); }, seconds * 1000);
2915
+ // back to a tab that was hidden longer than the interval: at once
2916
+ document.addEventListener('visibilitychange', () => { if (!document.hidden && Date.now() - last > seconds * 1000) poll(); });
2917
+ },
2918
+ now() {
2919
+ if (seconds) poll();
2920
+ },
2921
+ };
2922
+ })();
2923
+
2777
2924
  // ------------------------------------------------------------------ Progressive Web App and field work
2778
2925
  (() => {
2779
2926
  const metaEl = document.getElementById('pgkiln-meta');
@@ -2802,10 +2949,17 @@ document.addEventListener('DOMContentLoaded', () => {
2802
2949
  }
2803
2950
  navigator.serviceWorker.addEventListener('message', (e) => {
2804
2951
  if (e.data && e.data.type === 'pgkiln:queue') showQueue(e.data.items, post);
2952
+ // (165) a push notification arrived: the badges and the bell now, not at the next refresh
2953
+ if (e.data && e.data.type === 'pgkiln:pushed') liveBadges.now();
2805
2954
  });
2806
2955
  if (new URLSearchParams(location.search).get('queued') === '1') note(t('pwa.queued'));
2807
2956
  }
2808
2957
 
2958
+ // (165) live badges: every data-live seconds the page asks for its badges (navigation menu and bar,
2959
+ // buttons with a badge query) and the bell, and updates them in place; not while the tab is hidden.
2960
+ // A signed-out session (401) or a page without them (404, 403) stops it.
2961
+ liveBadges.start(Number(body.dataset.live) || 0);
2962
+
2809
2963
  // offline: say so (the page may come from the device)
2810
2964
  const banner = () => {
2811
2965
  let b = document.querySelector('.offline-banner');
@@ -3247,6 +3401,8 @@ document.addEventListener('DOMContentLoaded', () => {
3247
3401
  // without a Referer: the tiles get the site's origin only (no paths or item values)
3248
3402
  window.L.tileLayer(data.tiles, { attribution: data.attribution, maxZoom: 19, referrerPolicy: 'strict-origin-when-cross-origin' }).addTo(map);
3249
3403
  const popup = (p) => {
3404
+ // (161) a marker or shape clicked (its popup opens): map_object_click sees its id, else its title
3405
+ el.dispatchEvent(new CustomEvent('pgkiln:component', { bubbles: true, detail: { event: 'map_object_click', element: el, value: p.id ?? p.title ?? '' } }));
3250
3406
  // built with the DOM, not HTML strings: titles and texts are data
3251
3407
  const box = document.createElement('div');
3252
3408
  const b = document.createElement('strong');
@@ -3290,6 +3446,8 @@ document.addEventListener('DOMContentLoaded', () => {
3290
3446
  if (data.layers.some((l) => l.kind === 'heat')) heatLegend(map, data.legend);
3291
3447
  if (several) layerSwitch(map, group, built, data.layersLabel);
3292
3448
  if (f) areaFilter(map, f);
3449
+ // (161) map_change: moved or zoomed by the user (after the first view); the condition sees the zoom level
3450
+ map.on('moveend', () => el.dispatchEvent(new CustomEvent('pgkiln:component', { bubbles: true, detail: { event: 'map_change', element: el, value: map.getZoom() } })));
3293
3451
  }
3294
3452
  });
3295
3453
 
@@ -5160,3 +5318,4 @@ document.addEventListener('change', (e) => {
5160
5318
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send(); });
5161
5319
  window.addEventListener('pagehide', send);
5162
5320
  })();
5321
+
@@ -401,6 +401,10 @@ body.ide {
401
401
  .ide-head-actions { margin-left: auto; }
402
402
  .ide-tabs { display: flex; gap: 4px; margin-top: 12px; overflow-x: auto; scrollbar-width: thin; }
403
403
  .ide-page > .ide-tabs { margin-top: 0; border-bottom: 1px solid var(--border); }
404
+ /* (sprint 163) regions and two-column rows stacked on a page always have a gap between them (pages used to rely on
405
+ an explicit .u-spacer, and the ones without one let a region touch the one above); not the regions side by side in a
406
+ grid, which have the grid's gap; :where() keeps it weaker than any page's own margin rule */
407
+ :where(.ide-page :is(.region, .columns, :has(> .region)) + :is(.region, .columns, :has(> .region)):not(:is(.columns, .qs-columns, .vec-columns, .ws-cols, .wh-top, [class*="grid"]) > *)) { margin-top: 1rem; }
404
408
  .ide-tab {
405
409
  position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 8px 11px 9px; color: var(--muted); text-decoration: none;
406
410
  border-bottom: 2px solid transparent; border-radius: var(--radius-sm) var(--radius-sm) 0 0; white-space: nowrap; font-weight: 550;
@@ -1340,7 +1344,7 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1340
1344
  .cron-field-grid input { width: 100%; font-family: var(--mono, monospace); }
1341
1345
  .cron-preview { border-left: 3px solid var(--accent); padding: 0.4rem 0.8rem; margin: 0.6rem 0; background: var(--accent-soft); border-radius: 0 4px 4px 0; }
1342
1346
  .cron-runs { margin: 0.25rem 0 0; padding-left: 1.4rem; font-variant-numeric: tabular-nums; }
1343
- .cron-error { color: var(--danger, #b42318); }
1347
+ .cron-error { color: var(--danger); }
1344
1348
  .cron-sql { white-space: pre-wrap; overflow-wrap: anywhere; }
1345
1349
  /* (sprint 102) SQL Monitor: refresh controls, session actions, bars in Top SQL */
1346
1350
  .sm-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
@@ -1526,7 +1530,7 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1526
1530
  .mail-queue-filters .field { min-width: 0; flex: 1 1 160px; max-width: 260px; }
1527
1531
  .mail-queue-stats { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
1528
1532
  .mail-queue-stats .stat b { font-size: 1.3rem; }
1529
- .stat.stat-bad b { color: var(--danger, #b42318); }
1533
+ .stat.stat-bad b { color: var(--danger); }
1530
1534
  .inline-form { display: inline; margin: 0; }
1531
1535
  .mail-failures .buttons { flex-wrap: wrap; gap: 6px; }
1532
1536
  .mail-answer { overflow-wrap: anywhere; font-family: var(--font-mono, monospace); font-size: 0.85em; }
@@ -1648,3 +1652,22 @@ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
1648
1652
 
1649
1653
  /* (sprint 139, AP-6) OpenTelemetry */
1650
1654
  .otel-problem { color: var(--danger); overflow-wrap: anywhere; }
1655
+
1656
+ /* (sprint 163) an application's REST API page: status pills under the intro, the New client form behind a button */
1657
+ .api-intro { max-width: 80ch; margin: 0 0 0.6rem; }
1658
+ .api-status { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; padding: 0; margin: 0; }
1659
+ .api-status li { display: inline-block; border: 1px solid var(--border); border-radius: 14px; padding: 3px 12px; font-size: 0.875rem; overflow-wrap: anywhere; }
1660
+ .api-status li.is-ok > span:first-child { color: var(--success); }
1661
+ .api-status li.is-bad { border-color: var(--danger); }
1662
+ .api-status li.is-bad > span:first-child { color: var(--danger); font-weight: 700; }
1663
+ .api-top { align-items: start; }
1664
+ .api-top form > .field + .field { margin-top: 0.9rem; }
1665
+ .api-sub { font-size: 0.95rem; margin: 1.25rem 0 0.5rem; }
1666
+ .api-new > summary { list-style: none; display: inline-flex; }
1667
+ .api-new > summary::-webkit-details-marker { display: none; }
1668
+ .api-new[open] > summary { margin-bottom: 0.75rem; }
1669
+ .api-new-body { border-top: 1px solid var(--border); padding-top: 0.75rem; }
1670
+ .api-client-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
1671
+ .api-client-actions select { width: auto; min-height: 0; padding-block: 0.2rem; font-size: 0.8125rem; }
1672
+ .api-client-actions .inline-form { display: flex; gap: 6px; align-items: center; }
1673
+ .api-clients td.actions { text-align: end; }
@@ -10,6 +10,9 @@
10
10
  // Strict CSP: no inline styles; geometry goes into SVG attributes, sizes through the CSSOM.
11
11
  (() => {
12
12
  'use strict';
13
+ // (sprint 162) texts in the builder's language: #builder-texts (shell(), keys js.*), English in en.ts
14
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
15
+ const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
13
16
  // the data dictionary's Print button
14
17
  for (const b of document.querySelectorAll('.dm-dict-actions [data-dm=print]')) {
15
18
  b.hidden = false;
@@ -104,7 +107,7 @@
104
107
  btn('redo').disabled = !redo.length;
105
108
  const d = dirty();
106
109
  btn('save').classList.toggle('btn-hot', d);
107
- status.textContent = d ? 'Unsaved changes' : '';
110
+ status.textContent = d ? msg('js.dm.unsaved') : '';
108
111
  }
109
112
  function remember(before) {
110
113
  undo.push(before);
@@ -136,15 +139,15 @@
136
139
  format: 'json',
137
140
  positions: JSON.stringify([...boxes].map(([name, b]) => ({ name, x: Math.round(b.x), y: Math.round(b.y) }))),
138
141
  });
139
- status.textContent = 'Saving…';
142
+ status.textContent = msg('js.dm.saving');
140
143
  try {
141
144
  const res = await fetch(saveForm.action, { method: 'POST', body, credentials: 'same-origin', headers: { accept: 'application/json' } });
142
145
  if (!res.ok) throw new Error(String(res.status));
143
146
  saved = JSON.stringify(snapshot());
144
147
  changed();
145
- status.textContent = 'Saved';
148
+ status.textContent = msg('js.dm.saved');
146
149
  } catch (e) {
147
- status.textContent = `Not saved (${e.message}): try again`;
150
+ status.textContent = msg('js.dm.not_saved', { error: e.message });
148
151
  }
149
152
  }
150
153
  btn('save').addEventListener('click', save);
@@ -333,7 +336,7 @@
333
336
  ctx.drawImage(img, 0, 0, W, H);
334
337
  c.toBlob((blob) => blob && download(blob, `${fileName}.png`), 'image/png');
335
338
  };
336
- img.onerror = () => (status.textContent = 'The PNG could not be made: export SVG instead');
339
+ img.onerror = () => (status.textContent = msg('js.dm.no_png'));
337
340
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText())}`;
338
341
  });
339
342
  btn('print').addEventListener('click', () => {
@@ -7,6 +7,9 @@
7
7
  // JavaScript the page's Refresh link reloads it.
8
8
  (() => {
9
9
  'use strict';
10
+ // (sprint 162) texts in the builder's language: #builder-texts (shell(), keys js.*), English in en.ts
11
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
12
+ const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
10
13
  const live = document.querySelector('[data-sm-live]');
11
14
  const controls = document.querySelector('[data-sm-controls]');
12
15
  if (!live || !controls) return;
@@ -26,7 +29,7 @@
26
29
 
27
30
  const show = () => {
28
31
  pause.setAttribute('aria-pressed', paused ? 'true' : 'false');
29
- pause.textContent = paused ? 'Resume' : 'Pause';
32
+ pause.textContent = paused ? msg('js.sm.resume') : msg('js.sm.pause');
30
33
  live.classList.toggle('is-paused', paused);
31
34
  };
32
35
  const schedule = () => {
@@ -52,7 +55,7 @@
52
55
  paused = true;
53
56
  show();
54
57
  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.';
58
+ if (note) note.textContent = msg('js.sm.stopped');
56
59
  }
57
60
  }
58
61
  select.addEventListener('change', () => {
package/public/sw.js CHANGED
@@ -210,7 +210,11 @@ self.addEventListener('push', (event) => {
210
210
  data: { url: appUrl(m.url) || appUrl(`${PGKILN.base}/`) },
211
211
  };
212
212
  if (typeof m.tag === 'string' && m.tag) Object.assign(options, { tag: m.tag, renotify: true });
213
- event.waitUntil(self.registration.showNotification(typeof m.title === 'string' && m.title ? m.title : 'Notification', options));
213
+ event.waitUntil(Promise.all([
214
+ self.registration.showNotification(typeof m.title === 'string' && m.title ? m.title : 'Notification', options),
215
+ // (165) open pages refresh their badges and the bell now
216
+ self.clients.matchAll({ type: 'window' }).then((wins) => wins.forEach((w) => w.postMessage({ type: 'pgkiln:pushed' }))),
217
+ ]));
214
218
  });
215
219
 
216
220
  self.addEventListener('notificationclick', (event) => {
package/src/app.ts CHANGED
@@ -60,6 +60,7 @@ import { assistantBuilderRoutes } from './builder/assistant.ts';
60
60
  import { aiBuilderRoutes } from './builder/ai-builder.ts';
61
61
  import { blueprintRoutes } from './builder/blueprints.ts';
62
62
  import { accountRoutes } from './runtime/account.ts';
63
+ import { notificationRoutes } from './runtime/notifications.ts';
63
64
  import { taskRoutes } from './runtime/tasks.ts';
64
65
  import { dataReporterRoutes } from './runtime/data-reporter.ts';
65
66
  import { datasetRoutes } from './builder/datasets.ts';
@@ -210,6 +211,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
210
211
  });
211
212
  await app.register(runtimeRoutes);
212
213
  await app.register(accountRoutes);
214
+ await app.register(notificationRoutes);
213
215
  await app.register(taskRoutes);
214
216
  await app.register(workflowRoutes);
215
217
  await app.register(pwaRoutes);