pgkiln 0.31.0 → 0.35.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 (107) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/docs/apex-feature-parity.md +55 -35
  15. package/docs/guide/01-installation.md +1 -0
  16. package/docs/guide/03-builder.md +118 -2
  17. package/docs/guide/04-pages-and-regions.md +115 -0
  18. package/docs/guide/05-items.md +47 -0
  19. package/docs/guide/06-processing.md +210 -2
  20. package/docs/guide/07-dynamic-actions.md +60 -2
  21. package/docs/guide/08-security.md +13 -2
  22. package/docs/guide/09-reference.md +21 -0
  23. package/docs/guide/11-from-apex.md +5 -1
  24. package/docs/guide/12-development.md +33 -1
  25. package/docs/guide/14-globalization.md +15 -0
  26. package/docs/guide/15-extensions.md +1 -1
  27. package/docs/guide/18-cli.md +30 -1
  28. package/docs/guide/20-ai-agents.md +3 -2
  29. package/examples/hr/hr_50_search.sql +47 -0
  30. package/examples/hr/hr_51_mail.sql +70 -0
  31. package/examples/hr/hr_52_feedback.sql +15 -0
  32. package/examples/hr/hr_53_data_settings.sql +108 -0
  33. package/package.json +3 -1
  34. package/public/app.css +62 -0
  35. package/public/app.js +408 -11
  36. package/public/builder.css +11 -0
  37. package/public/builder.js +27 -0
  38. package/src/app.ts +8 -0
  39. package/src/appfiles.ts +12 -0
  40. package/src/automations.ts +12 -0
  41. package/src/builder/advisor.ts +54 -14
  42. package/src/builder/arrange.ts +2 -2
  43. package/src/builder/availability.ts +74 -0
  44. package/src/builder/backups.ts +134 -0
  45. package/src/builder/components.ts +109 -15
  46. package/src/builder/designer.ts +8 -3
  47. package/src/builder/feedback.ts +82 -0
  48. package/src/builder/forms.ts +4 -1
  49. package/src/builder/home.ts +3 -0
  50. package/src/builder/locks.ts +15 -5
  51. package/src/builder/mail.ts +425 -0
  52. package/src/builder/newapp.ts +1 -1
  53. package/src/builder/region-help.ts +71 -0
  54. package/src/builder/routes.ts +44 -1
  55. package/src/builder/search.ts +4 -4
  56. package/src/builder/shared.ts +3 -2
  57. package/src/builder/ui.ts +7 -1
  58. package/src/cli/apps.ts +46 -5
  59. package/src/cli/main.ts +21 -5
  60. package/src/cli/mcp.ts +16 -4
  61. package/src/cli/replace.ts +36 -4
  62. package/src/geocode.ts +44 -0
  63. package/src/i18n/ar.ts +39 -0
  64. package/src/i18n/cs.ts +39 -0
  65. package/src/i18n/da.ts +39 -0
  66. package/src/i18n/de.ts +39 -0
  67. package/src/i18n/el.ts +39 -0
  68. package/src/i18n/es.ts +39 -0
  69. package/src/i18n/fi.ts +39 -0
  70. package/src/i18n/fr.ts +39 -0
  71. package/src/i18n/he.ts +39 -0
  72. package/src/i18n/it.ts +39 -0
  73. package/src/i18n/ja.ts +39 -0
  74. package/src/i18n/ko.ts +39 -0
  75. package/src/i18n/nb.ts +39 -0
  76. package/src/i18n/pl.ts +39 -0
  77. package/src/i18n/pt.ts +39 -0
  78. package/src/i18n/ru.ts +39 -0
  79. package/src/i18n/sv.ts +39 -0
  80. package/src/i18n/tr.ts +39 -0
  81. package/src/i18n/uk.ts +39 -0
  82. package/src/i18n/zh.ts +39 -0
  83. package/src/i18n.ts +78 -0
  84. package/src/mail/mail.ts +148 -0
  85. package/src/mail/providers.ts +444 -0
  86. package/src/metadata.ts +26 -8
  87. package/src/runtime/ajax.ts +97 -0
  88. package/src/runtime/authz.ts +1 -1
  89. package/src/runtime/availability.ts +37 -0
  90. package/src/runtime/context.ts +46 -6
  91. package/src/runtime/data-export.ts +94 -0
  92. package/src/runtime/data-items.ts +194 -0
  93. package/src/runtime/devbar.ts +182 -0
  94. package/src/runtime/engine.ts +26 -1
  95. package/src/runtime/feedback.ts +128 -0
  96. package/src/runtime/items.ts +8 -0
  97. package/src/runtime/processes.ts +83 -2
  98. package/src/runtime/regions.ts +8 -0
  99. package/src/runtime/render.ts +33 -7
  100. package/src/runtime/rest.ts +4 -0
  101. package/src/runtime/routes.ts +59 -5
  102. package/src/runtime/search.ts +252 -0
  103. package/src/runtime/url-region.ts +93 -0
  104. package/src/security.ts +11 -1
  105. package/src/server.ts +2 -0
  106. package/src/session.ts +8 -2
  107. package/src/workingcopy.ts +2 -2
package/public/app.js CHANGED
@@ -140,6 +140,68 @@ const pgkilnPush = (() => {
140
140
  return { supported, subscribe, unsubscribe, isOn, t };
141
141
  })();
142
142
 
143
+ // Warn on unsaved changes (migration 077): a page form with data-warn-unsaved asks
144
+ // before the user leaves the page (a link, a redirect button, another form, closing
145
+ // or reloading the tab, closing a dialog) while one of its items differs from the
146
+ // value the server rendered. Submitting the page doesn't ask. Items inside an
147
+ // element with data-no-warn, and the interactive grid (it has its own check), are
148
+ // left out. data-warn-unsaved="changed": the page came back from a failed submit,
149
+ // so its values are not saved yet.
150
+ const pgkilnUnsaved = (() => {
151
+ const form = document.querySelector('form.page-form[data-warn-unsaved]');
152
+ const metaEl = document.getElementById('pgkiln-meta');
153
+ const meta = (metaEl ? JSON.parse(metaEl.textContent) : null) || {};
154
+ const text = (meta.texts && meta.texts['page.unsaved_changes']) || 'You have unsaved changes. Leave this page anyway?';
155
+ let leaving = false;
156
+ const tracked = (el) =>
157
+ el.name && !el.name.startsWith('__') && (el.type !== 'hidden' || 'initial' in el.dataset) && el.type !== 'submit' && el.type !== 'button' && !el.disabled &&
158
+ !el.closest('[data-no-warn], [data-grid], .grid-template');
159
+ function differs(el) {
160
+ if (el.type === 'hidden') return el.value !== el.dataset.initial;
161
+ if (el.type === 'checkbox' || el.type === 'radio') return el.checked !== el.defaultChecked;
162
+ if (el.type === 'file') return !!(el.files && el.files.length);
163
+ if (el.tagName === 'SELECT') {
164
+ const opts = [...el.options];
165
+ if (el.multiple) return opts.some((o) => o.selected !== o.defaultSelected);
166
+ const def = opts.findIndex((o) => o.defaultSelected);
167
+ return el.selectedIndex !== (def >= 0 ? def : el.size > 1 ? -1 : 0);
168
+ }
169
+ return 'defaultValue' in el && el.value !== el.defaultValue;
170
+ }
171
+ const changed = () => !!form && (form.dataset.warnUnsaved === 'changed' || [...form.elements].some((el) => tracked(el) && differs(el)));
172
+ /** True when the user may leave: nothing changed, or they said so. */
173
+ function mayLeave() {
174
+ if (leaving || !changed()) return true;
175
+ if (!window.confirm(text)) return false;
176
+ leaving = true;
177
+ return true;
178
+ }
179
+ if (form) {
180
+ document.addEventListener('click', (e) => {
181
+ const a = e.target.closest?.('a[href]');
182
+ if (!a || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
183
+ const href = a.getAttribute('href') || '';
184
+ // a dialog, a new tab, a download or a place on this page doesn't leave it
185
+ if (href.startsWith('#') || /^javascript:/i.test(href) || a.hasAttribute('download') || a.target === '_blank' || a.hasAttribute('data-dialog') || a.hasAttribute('data-dialog-cancel')) return;
186
+ if (!mayLeave()) e.preventDefault();
187
+ });
188
+ // submitting the page saves; another form (a menu, a switch) leaves it
189
+ document.addEventListener('submit', (e) => {
190
+ if (e.target === form) leaving = true;
191
+ else if (!e.defaultPrevented && (e.target.method || '').toLowerCase() === 'post' && !mayLeave()) e.preventDefault();
192
+ }, true);
193
+ window.addEventListener('beforeunload', (e) => {
194
+ if (leaving || !changed()) return;
195
+ e.preventDefault();
196
+ e.returnValue = '';
197
+ });
198
+ // a navigation that was answered "leave" and then cancelled (download, same page) arms the check again
199
+ window.addEventListener('pageshow', () => (leaving = false));
200
+ }
201
+ return { changed, mayLeave, text };
202
+ })();
203
+
204
+
143
205
  (() => {
144
206
  const body = document.body;
145
207
  const base = body.dataset.base;
@@ -383,6 +445,26 @@ const pgkilnPush = (() => {
383
445
  },
384
446
  }),
385
447
  getValue: (name) => itemValue(name),
448
+ // (085) raise a custom event: dynamic actions with event "custom" and this name run
449
+ trigger: (name, value) => runChain(das.filter((da) => da.event === 'custom' && da.trigger.some((t) => String(t).toLowerCase() === String(name).toLowerCase())), value),
450
+ // (085) APEX: apex.server.process. An Ajax callback (a process with point "ajax") by name: posts the items given
451
+ // (a list of names, or {name: value}) and x01 … x10; answers { data, rows, items } and sets the items it changed.
452
+ server: Object.freeze({
453
+ async process(name, opts = {}) {
454
+ const data = {};
455
+ if (Array.isArray(opts.items)) for (const n of opts.items) data[n] = itemValue(n);
456
+ else if (opts.items && typeof opts.items === 'object') Object.assign(data, opts.items);
457
+ for (let i = 1; i <= 10; i++) {
458
+ const k = `x${String(i).padStart(2, '0')}`;
459
+ if (opts[k] !== undefined && opts[k] !== null) data[k] = String(opts[k]);
460
+ }
461
+ const res = await post(`/ajax/${encodeURIComponent(name)}`, data);
462
+ for (const [n, v] of Object.entries(res.items || {})) setItemValue(n, v);
463
+ return res;
464
+ },
465
+ }),
466
+ // (077) APEX: apex.page.isChanged()
467
+ isChanged: () => pgkilnUnsaved.changed(),
386
468
  setValue: (name, value) => setItemValue(name, value),
387
469
  showSuccess: (message) => showMessage(String(message), 'success'),
388
470
  showError: (message, item) => (item ? itemError(item, String(message)) : showMessage(String(message), 'error')),
@@ -390,6 +472,38 @@ const pgkilnPush = (() => {
390
472
  page: pageNo,
391
473
  });
392
474
 
475
+ /** (085) what runDa returns to stop the actions after it (a declined confirm) */
476
+ const STOP = Symbol('stop');
477
+ const text = (k) => (meta.texts && meta.texts[k]) || k;
478
+
479
+ /** The actions of one event and trigger, one after the other in sequence order; false when one stopped them. */
480
+ async function runChain(list, value, extra) {
481
+ for (const da of list) if ((await runDa(da, false, value, extra)) === STOP) return false;
482
+ return true;
483
+ }
484
+
485
+ /** (085) The download dynamic action: the file of the download process it names, saved by the browser. */
486
+ async function download(da) {
487
+ const params = new URLSearchParams();
488
+ for (const n of da.submit) params.set(n, itemValue(n));
489
+ params.set('__csrf', meta.csrf);
490
+ try {
491
+ const res = await fetch(`${base}/${pageNo}/da/${da.id}/download`, { method: 'POST', body: params, credentials: 'same-origin' });
492
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
493
+ const name = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(res.headers.get('content-disposition') || '')?.[1];
494
+ const url = URL.createObjectURL(await res.blob());
495
+ const a = document.createElement('a');
496
+ a.href = url;
497
+ a.download = name ? decodeURIComponent(name) : 'download';
498
+ document.body.appendChild(a);
499
+ a.click();
500
+ a.remove();
501
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
502
+ } catch (e) {
503
+ showError(e.message);
504
+ }
505
+ }
506
+
393
507
  async function runDa(da, initial, value, extra) {
394
508
  const holds = conditionHolds(da, value);
395
509
  switch (da.action) {
@@ -411,6 +525,54 @@ const pgkilnPush = (() => {
411
525
  case 'alert':
412
526
  window.alert(da.message || '');
413
527
  return;
528
+ // (085) a question: No stops the actions after it (and a page submit for before_submit)
529
+ case 'confirm':
530
+ return window.confirm(da.message || '') ? undefined : STOP;
531
+ case 'close_dialog':
532
+ case 'cancel_dialog':
533
+ if (window.parent !== window) window.parent.postMessage({ type: 'pgkiln:close', reload: da.action === 'close_dialog', page: body.dataset.dialogPage || String(pageNo) }, location.origin);
534
+ return;
535
+ case 'clear':
536
+ for (const n of da.items) setItemValue(n, '');
537
+ return;
538
+ case 'open_region':
539
+ case 'close_region':
540
+ for (const el of targets(da)) {
541
+ const details = el.querySelector(':scope > details');
542
+ if (details) details.open = da.action === 'open_region';
543
+ else el.hidden = da.action === 'close_region';
544
+ }
545
+ return;
546
+ case 'print':
547
+ window.print();
548
+ return;
549
+ case 'current_position':
550
+ return new Promise((resolve) => {
551
+ if (!navigator.geolocation) return showError(text('item.locate_error')), resolve();
552
+ navigator.geolocation.getCurrentPosition(
553
+ (pos) => {
554
+ if (da.items[0]) setItemValue(da.items[0], `${pos.coords.latitude.toFixed(5)},${pos.coords.longitude.toFixed(5)}`);
555
+ resolve();
556
+ },
557
+ () => (showError(text('item.locate_error')), resolve()),
558
+ { enableHighAccuracy: true, timeout: 15000 },
559
+ );
560
+ });
561
+ case 'share': {
562
+ const data = { title: document.title, text: da.message || undefined, url: location.href };
563
+ try {
564
+ if (navigator.share) await navigator.share(data);
565
+ else {
566
+ await navigator.clipboard.writeText(location.href);
567
+ showMessage(text('page.link_copied'), 'success');
568
+ }
569
+ } catch {
570
+ // the user closed the share sheet
571
+ }
572
+ return;
573
+ }
574
+ case 'download':
575
+ return download(da);
414
576
  case 'set_focus':
415
577
  focusTarget(targets(da)[0]);
416
578
  return;
@@ -499,7 +661,7 @@ const pgkilnPush = (() => {
499
661
  document.addEventListener('change', (e) => {
500
662
  const name = e.target.name;
501
663
  if (!name) return;
502
- for (const da of das) if (da.event === 'change' && da.trigger.includes(name)) runDa(da, false);
664
+ runChain(das.filter((da) => da.event === 'change' && da.trigger.includes(name)));
503
665
  cascade(name);
504
666
  if (e.target.closest('[data-submit-on-change]') && e.target.form === form && depth === 0) form.requestSubmit();
505
667
  });
@@ -511,21 +673,69 @@ const pgkilnPush = (() => {
511
673
  // (without JavaScript the button submits and the page process runs instead)
512
674
  const ai = run.some((da) => da.action === 'ai_generate');
513
675
  if (ai && btn.type === 'submit') e.preventDefault();
514
- const pending = run.map((da) => runDa(da, false));
676
+ const pending = runChain(run);
515
677
  if (ai && btn.tagName === 'BUTTON') {
516
678
  // one request at a time: the button waits for the answer
517
679
  btn.disabled = true;
518
680
  btn.setAttribute('aria-busy', 'true');
519
- Promise.allSettled(pending).then(() => {
681
+ Promise.allSettled([pending]).then(() => {
520
682
  btn.disabled = false;
521
683
  btn.removeAttribute('aria-busy');
522
684
  });
523
685
  }
524
686
  });
525
- for (const da of das) {
526
- if (da.event !== 'dialog_closed' && ['show', 'hide', 'enable', 'disable'].includes(da.action)) runDa(da, true);
527
- if (da.event === 'load') runDa(da, false);
528
- }
687
+ for (const da of das) if (da.event !== 'dialog_closed' && ['show', 'hide', 'enable', 'disable'].includes(da.action)) runDa(da, true);
688
+ runChain(das.filter((da) => da.event === 'load'));
689
+
690
+ // (085) more events. Item events: focus, blur, key_release (trigger: item names).
691
+ const itemEvent = (event) => (e) => {
692
+ const name = e.target && e.target.name;
693
+ if (!name) return;
694
+ const run = das.filter((da) => da.event === event && da.trigger.includes(name));
695
+ if (run.length) runChain(run);
696
+ };
697
+ document.addEventListener('focusin', itemEvent('focus'));
698
+ document.addEventListener('focusout', itemEvent('blur'));
699
+ document.addEventListener('keyup', itemEvent('key_release'));
700
+ // region events (trigger: region ids or static ids, case-insensitive)
701
+ const regionRun = (event, region) => {
702
+ if (!region || !/^R\d+$/.test(region.id || '')) return [];
703
+ const keys = [region.id.slice(1), (region.dataset.staticId || '').toLowerCase()].filter(Boolean);
704
+ return das.filter((da) => da.event === event && da.trigger.some((t) => keys.includes(String(t).toLowerCase())));
705
+ };
706
+ // after_refresh: a region replaced by a refresh, a lazy load or a master row
707
+ document.addEventListener('pgkiln:replaced', (e) => {
708
+ const run = regionRun('after_refresh', e.detail);
709
+ if (run.length) runChain(run);
710
+ });
711
+ // selection_change: the row checkboxes of a report (and a grid's selected master row, see selectMaster)
712
+ const selectionChanged = (region) => {
713
+ const run = regionRun('selection_change', region);
714
+ if (!run.length) return;
715
+ const boxes = region.querySelectorAll('td.row-select input[type=checkbox]:checked');
716
+ runChain(run, [...boxes].map((b) => b.value).join(':'));
717
+ };
718
+ document.addEventListener('change', (e) => {
719
+ if (e.target.closest?.('.row-select')) selectionChanged(e.target.closest('section[id^="R"]'));
720
+ });
721
+ document.addEventListener('pgkiln:selection', (e) => selectionChanged(e.detail));
722
+ // before_submit: the page's form; a declined confirm keeps the page
723
+ let submitting = false;
724
+ document.addEventListener('submit', async (e) => {
725
+ if (e.target !== form || submitting) return;
726
+ const run = das.filter((da) => da.event === 'before_submit');
727
+ if (!run.length) return;
728
+ e.preventDefault();
729
+ e.stopImmediatePropagation();
730
+ const submitter = e.submitter;
731
+ if (!(await runChain(run))) return;
732
+ submitting = true;
733
+ try {
734
+ form.requestSubmit(submitter && submitter.form === form ? submitter : undefined);
735
+ } finally {
736
+ submitting = false;
737
+ }
738
+ }, true);
529
739
 
530
740
  // ------------------------------------------------------------ cascading lists of values
531
741
  async function cascade(parent) {
@@ -641,7 +851,19 @@ const pgkilnPush = (() => {
641
851
  dlg.setAttribute('aria-labelledby', 't-dialog-title');
642
852
  dlg.innerHTML = '<div class="t-dialog-head"><h2 id="t-dialog-title"></h2><button type="button" class="icon-button t-dialog-x" aria-label="Close">×</button></div><iframe title="Dialog"></iframe>';
643
853
  document.body.appendChild(dlg);
644
- dlg.querySelector('.t-dialog-x').addEventListener('click', () => dlg.close());
854
+ // (077) the page in the dialog decides whether its unsaved changes may go
855
+ const mayClose = () => {
856
+ try {
857
+ const w = dlg.querySelector('iframe').contentWindow;
858
+ return !w.pgkiln?.isChanged?.() || window.confirm(pgkilnUnsaved.text);
859
+ } catch {
860
+ return true;
861
+ }
862
+ };
863
+ dlg.querySelector('.t-dialog-x').addEventListener('click', () => mayClose() && dlg.close());
864
+ dlg.addEventListener('cancel', (e) => {
865
+ if (!mayClose()) e.preventDefault();
866
+ });
645
867
  dlg.addEventListener('close', () => (dlg.querySelector('iframe').src = 'about:blank'));
646
868
  }
647
869
  const frame = dlg.querySelector('iframe');
@@ -790,7 +1012,7 @@ const pgkilnPush = (() => {
790
1012
  document.addEventListener('click', (e) => {
791
1013
  if (!e.target.closest('[data-dialog-cancel]')) return;
792
1014
  e.preventDefault();
793
- tell(false);
1015
+ if (pgkilnUnsaved.mayLeave()) tell(false);
794
1016
  });
795
1017
  }
796
1018
  })();
@@ -932,7 +1154,7 @@ const pgkilnPush = (() => {
932
1154
  return;
933
1155
  }
934
1156
  const leave = e.target.closest('[data-grid-leave]');
935
- if (leave && dirty && !window.confirm('You have unsaved changes in the grid. Leave anyway?')) e.preventDefault();
1157
+ if (leave && dirty && !window.confirm(pgkilnUnsaved.text)) e.preventDefault();
936
1158
  });
937
1159
  document.addEventListener('input', (e) => {
938
1160
  const row = e.target.closest?.('[data-grid] table.grid-table > tbody > tr');
@@ -969,7 +1191,7 @@ const pgkilnPush = (() => {
969
1191
  const master = link.closest('[data-grid]');
970
1192
  const ids = link.dataset.gridSelect.split(',').filter(Boolean);
971
1193
  const dirtyDetail = ids.some((id) => document.querySelector(`#R${id} [data-grid] tr.dirty, #R${id} [data-grid] tbody:not(.grid-template) tr.grid-new`));
972
- if (dirtyDetail && !window.confirm('You have unsaved changes in the grid. Leave anyway?')) return;
1194
+ if (dirtyDetail && !window.confirm(pgkilnUnsaved.text)) return;
973
1195
  const query = new URL(link.href, location.href).search.slice(1);
974
1196
  for (const id of ids) {
975
1197
  const region = document.getElementById(`R${id}`);
@@ -1003,6 +1225,8 @@ const pgkilnPush = (() => {
1003
1225
  return;
1004
1226
  }
1005
1227
  }
1228
+ // (085) the dynamic actions on the master's "selection change"
1229
+ document.dispatchEvent(new CustomEvent('pgkiln:selection', { detail: master.closest('section[id^="R"]') }));
1006
1230
  // mark the row, and make the page's other links and forms carry the new selection
1007
1231
  for (const a of master.querySelectorAll('a[data-grid-select]')) {
1008
1232
  const on = a === link;
@@ -1787,6 +2011,58 @@ document.addEventListener('DOMContentLoaded', () => {
1787
2011
  );
1788
2012
  });
1789
2013
 
2014
+ // (086) address items: the geocoder's addresses for the text; the one chosen replaces it, its position goes into the coordinates item
2015
+ document.addEventListener('click', async (e) => {
2016
+ const btn = e.target.closest && e.target.closest('[data-geocode]');
2017
+ if (!btn) return;
2018
+ const input = document.getElementById(btn.dataset.geocode);
2019
+ const list = document.getElementById(`${btn.dataset.geocode}_places`);
2020
+ if (!input || !list) return;
2021
+ const csrf = (metaEl ? JSON.parse(metaEl.textContent).csrf : '') || '';
2022
+ btn.setAttribute('aria-busy', 'true');
2023
+ let places = null;
2024
+ try {
2025
+ const res = await fetch(btn.dataset.url, { method: 'POST', body: new URLSearchParams({ q: input.value, __csrf: csrf }), headers: { accept: 'application/json' }, credentials: 'same-origin' });
2026
+ const data = await res.json().catch(() => ({}));
2027
+ if (!res.ok) note(data.error || t('item.address_error'), 'error');
2028
+ else places = data.places || [];
2029
+ } catch {
2030
+ note(t('item.address_error'), 'error');
2031
+ }
2032
+ btn.removeAttribute('aria-busy');
2033
+ list.replaceChildren();
2034
+ if (!places) return;
2035
+ if (!places.length) {
2036
+ list.hidden = true;
2037
+ note(t('item.address_none'), 'error');
2038
+ return;
2039
+ }
2040
+ for (const p of places) {
2041
+ const li = document.createElement('li');
2042
+ li.setAttribute('role', 'option');
2043
+ const b = document.createElement('button');
2044
+ b.type = 'button';
2045
+ b.className = 'link-button';
2046
+ b.textContent = p.label;
2047
+ b.addEventListener('click', () => {
2048
+ input.value = p.label;
2049
+ input.dispatchEvent(new Event('change', { bubbles: true }));
2050
+ const target = btn.dataset.coords && document.getElementById(btn.dataset.coords);
2051
+ if (target) {
2052
+ target.value = `${Number(p.lat).toFixed(5)},${Number(p.lon).toFixed(5)}`;
2053
+ target.dispatchEvent(new Event('change', { bubbles: true }));
2054
+ }
2055
+ list.hidden = true;
2056
+ list.replaceChildren();
2057
+ input.focus();
2058
+ });
2059
+ li.append(b);
2060
+ list.append(li);
2061
+ }
2062
+ list.hidden = false;
2063
+ list.querySelector('button').focus();
2064
+ });
2065
+
1790
2066
  // scan buttons: only where the browser reads barcodes (BarcodeDetector, e.g. Chrome on Android)
1791
2067
  if ('BarcodeDetector' in window && navigator.mediaDevices) {
1792
2068
  for (const b of document.querySelectorAll('[data-scan]')) b.hidden = false;
@@ -2908,6 +3184,8 @@ function areaFilter(map, f) {
2908
3184
  hidden.type = 'hidden';
2909
3185
  hidden.name = input.name;
2910
3186
  hidden.value = input.value;
3187
+ // (077) a hidden input's value is its default too: the value to compare with for "unsaved changes"
3188
+ hidden.dataset.initial = input.value;
2911
3189
  input.removeAttribute('name');
2912
3190
  input.value = '';
2913
3191
  const list = document.createElement('ul');
@@ -3036,3 +3314,122 @@ function areaFilter(map, f) {
3036
3314
  }
3037
3315
  });
3038
3316
  })();
3317
+
3318
+ // Search region (migration 080): search as the user types. The field is a GET form that works
3319
+ // without JavaScript; here a pause in typing fetches the region (GET …/region/<id>) and swaps in
3320
+ // its results only, so the field keeps its focus and cursor. Answers that arrive late are dropped.
3321
+ (() => {
3322
+ const base = document.body.dataset.base;
3323
+ const page = document.body.dataset.page;
3324
+ if (!base || !page) return;
3325
+ const timers = new WeakMap();
3326
+ const latest = new WeakMap();
3327
+ async function search(field) {
3328
+ const id = field.dataset.searchRegion;
3329
+ if (!/^\d+$/.test(id || '') || !field.form) return;
3330
+ // (the form keeps the page's own parameters, dialog=1 among them)
3331
+ const params = new URLSearchParams(new FormData(field.form));
3332
+ const n = (latest.get(field) || 0) + 1;
3333
+ latest.set(field, n);
3334
+ try {
3335
+ const res = await fetch(`${base}/${page}/region/${id}?${params}`, { headers: { accept: 'application/json' }, credentials: 'same-origin' });
3336
+ if (!res.ok || latest.get(field) !== n) return;
3337
+ const json = await res.json();
3338
+ const fresh = new DOMParser().parseFromString(json.html || '', 'text/html').getElementById(`R${id}_results`);
3339
+ const results = document.getElementById(`R${id}_results`);
3340
+ if (!fresh || !results) return;
3341
+ results.replaceChildren(...[...fresh.childNodes].map((node) => document.importNode(node, true)));
3342
+ history.replaceState(history.state, '', `${location.pathname}?${params}${location.hash}`);
3343
+ } catch {
3344
+ // offline or the session ended: Enter still submits the form
3345
+ }
3346
+ }
3347
+ document.addEventListener('input', (e) => {
3348
+ const field = e.target.closest?.('input[data-search-region]');
3349
+ if (!field) return;
3350
+ clearTimeout(timers.get(field));
3351
+ timers.set(field, setTimeout(() => search(field), 250));
3352
+ });
3353
+ })();
3354
+
3355
+ // Session timeout warning (migration 085; APEX: Session timeout warning). The page knows when its
3356
+ // session ends: after the idle time since the page was loaded (or since "Stay signed in"), and at
3357
+ // the session's maximum length. A dialog warns `warn` seconds before; "Stay signed in" marks the
3358
+ // session active on the server. At the maximum length the user can only save their work.
3359
+ (() => {
3360
+ const metaEl = document.getElementById('pgkiln-meta');
3361
+ if (!metaEl) return;
3362
+ const meta = JSON.parse(metaEl.textContent);
3363
+ const s = meta.session;
3364
+ if (!s || !s.idle || !s.warn) return;
3365
+ const t = (k) => (meta.texts && meta.texts[k]) || k;
3366
+ let last = Date.now();
3367
+ let timer;
3368
+ let dlg;
3369
+ let shown = '';
3370
+ let dismissed = false;
3371
+ const end = () => Math.min(last + s.idle * 1000, s.maxEnd || Infinity);
3372
+ const atMax = () => s.maxEnd && end() === s.maxEnd;
3373
+ function schedule(ms) {
3374
+ clearTimeout(timer);
3375
+ timer = setTimeout(check, Math.max(500, Math.min(ms, 2 ** 31 - 1)));
3376
+ }
3377
+ function check() {
3378
+ const left = end() - Date.now();
3379
+ if (left <= 0) return show('ended');
3380
+ if (left > s.warn * 1000) return schedule(left - s.warn * 1000);
3381
+ // the idle warning counts down until it is dismissed; the maximum length is said once
3382
+ if (atMax() ? shown !== 'max' : !dismissed) show(atMax() ? 'max' : 'idle', left);
3383
+ schedule(Math.min(left, 30000));
3384
+ }
3385
+ function button(label, cls, onClick) {
3386
+ const b = document.createElement('button');
3387
+ b.type = 'button';
3388
+ b.className = `btn ${cls}`;
3389
+ b.textContent = label;
3390
+ b.addEventListener('click', onClick);
3391
+ return b;
3392
+ }
3393
+ function show(kind, left) {
3394
+ if (!dlg) {
3395
+ dlg = document.createElement('dialog');
3396
+ dlg.className = 'session-dialog';
3397
+ dlg.setAttribute('aria-labelledby', 'session-dialog-text');
3398
+ dlg.innerHTML = '<p id="session-dialog-text"></p><div class="buttons"></div>';
3399
+ document.body.appendChild(dlg);
3400
+ // Escape dismisses the warning like the button
3401
+ dlg.addEventListener('cancel', () => (dismissed = true));
3402
+ }
3403
+ if (kind === 'max' && shown === 'max' && dlg.open) return;
3404
+ shown = kind;
3405
+ const p = dlg.querySelector('p');
3406
+ const buttons = dlg.querySelector('.buttons');
3407
+ buttons.replaceChildren();
3408
+ if (kind === 'ended') {
3409
+ clearTimeout(timer);
3410
+ p.textContent = t('session.ended');
3411
+ buttons.append(button(t('login.sign_in_link'), 'btn-hot', () => (location.href = `${document.body.dataset.base}/login?next=${encodeURIComponent(location.pathname + location.search)}`)));
3412
+ } else if (kind === 'max') {
3413
+ p.textContent = t('session.max').replace('{time}', new Date(s.maxEnd).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
3414
+ buttons.append(button('OK', 'btn-hot', () => dlg.close()));
3415
+ } else {
3416
+ p.textContent = t('session.warning').replace('{minutes}', String(Math.max(1, Math.ceil(left / 60000))));
3417
+ buttons.append(button(t('session.stay'), 'btn-hot', stay), button(t('common.dismiss'), '', () => ((dismissed = true), dlg.close())));
3418
+ }
3419
+ if (!dlg.open) dlg.showModal();
3420
+ }
3421
+ async function stay() {
3422
+ try {
3423
+ const res = await fetch(s.extend, { method: 'POST', body: new URLSearchParams({ __csrf: meta.csrf }), headers: { accept: 'application/json' }, credentials: 'same-origin' });
3424
+ if (!res.ok) return show('ended');
3425
+ last = Date.now();
3426
+ shown = '';
3427
+ dismissed = false;
3428
+ dlg.close();
3429
+ check();
3430
+ } catch {
3431
+ // offline: try again with the next click
3432
+ }
3433
+ }
3434
+ check();
3435
+ })();
@@ -759,3 +759,14 @@ button.is-busy { opacity: 0.7; cursor: progress; }
759
759
  .icon-choice .icon { width: 20px; height: 20px; }
760
760
  .icon-none { font-size: 18px; line-height: 20px; color: var(--muted); }
761
761
  .icon-name { font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; line-height: 1.2; }
762
+
763
+ /* Mail services: one provider's settings in groups, at a readable width */
764
+ .mail-form-page { max-width: 960px; }
765
+ .mail-service-form .prop-group { margin: 0 0 16px; }
766
+ details.prop-group-more { border: 1px solid var(--border); border-radius: 4px; padding: 8px 12px; margin: 0 0 16px; }
767
+ details.prop-group-more > summary { cursor: pointer; font-weight: 600; }
768
+ details.prop-group-more[open] > summary { margin-bottom: 8px; }
769
+ .provider-switch { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; margin-bottom: 16px; }
770
+ .provider-switch .field { margin: 0; min-width: min(100%, 280px); }
771
+ .mail-check-buttons { margin: 4px 0 14px; }
772
+ .mail-check-result:not(:empty) { margin-top: 8px; }
package/public/builder.js CHANGED
@@ -782,3 +782,30 @@
782
782
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
783
783
  else init();
784
784
  })();
785
+
786
+ // Mail services: Test connection and Send test mail answer in place, so the form keeps what was typed
787
+ // (the secret too). Without JavaScript the buttons post the form and the page comes back with the result.
788
+ document.addEventListener('click', async (e) => {
789
+ const btn = e.target.closest?.('button[name="check"][formaction$="/mail/check"]');
790
+ if (!btn || !btn.form) return;
791
+ e.preventDefault();
792
+ const form = btn.form;
793
+ const out = form.querySelector('.mail-check-result');
794
+ const data = new FormData(form);
795
+ data.set('check', btn.value);
796
+ const buttons = form.querySelectorAll('button[name="check"]');
797
+ buttons.forEach((b) => (b.disabled = true));
798
+ if (out) out.textContent = btn.value === 'send' ? 'Sending…' : 'Checking…';
799
+ try {
800
+ const res = await fetch(btn.formAction, { method: 'POST', body: new URLSearchParams(data), headers: { accept: 'application/json' }, credentials: 'same-origin' });
801
+ const json = await res.json();
802
+ const div = document.createElement('div');
803
+ div.className = `alert ${json.ok ? 'alert-success' : 'alert-error'}`;
804
+ div.textContent = json.text || (json.ok ? 'OK' : 'The check failed.');
805
+ out?.replaceChildren(div);
806
+ } catch {
807
+ if (out) out.textContent = 'The check could not be made (the session may have ended: reload the page).';
808
+ } finally {
809
+ buttons.forEach((b) => (b.disabled = false));
810
+ }
811
+ });
package/src/app.ts CHANGED
@@ -23,6 +23,10 @@ import { topSqlRoutes } from './builder/top-sql.ts';
23
23
  import { diagnosticsRoutes } from './builder/diagnostics.ts';
24
24
  import { ldapRoutes } from './builder/ldap.ts';
25
25
  import { aiRoutes } from './builder/ai.ts';
26
+ import { mailRoutes } from './builder/mail.ts';
27
+ import { backupRoutes } from './builder/backups.ts';
28
+ import { availabilityRoutes } from './builder/availability.ts';
29
+ import { feedbackRoutes as builderFeedbackRoutes } from './builder/feedback.ts';
26
30
  import { documentRoutes } from './builder/documents.ts';
27
31
  import { pwaBuilderRoutes } from './builder/pwa.ts';
28
32
  import { themeRollerRoutes } from './builder/themeroller.ts';
@@ -170,6 +174,10 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
170
174
  await app.register(diagnosticsRoutes);
171
175
  await app.register(ldapRoutes);
172
176
  await app.register(aiRoutes);
177
+ await app.register(mailRoutes);
178
+ await app.register(backupRoutes);
179
+ await app.register(availabilityRoutes);
180
+ await app.register(builderFeedbackRoutes);
173
181
  await app.register(documentRoutes);
174
182
  await app.register(pwaBuilderRoutes);
175
183
  await app.register(themeRollerRoutes);
package/src/appfiles.ts CHANGED
@@ -127,6 +127,10 @@ const CODE: Record<string, Record<string, string>> = {
127
127
  list: { query: 'sql' },
128
128
  supporting_script: { script: 'sql' },
129
129
  plugin: { install_sql: 'sql' },
130
+ // (080) a search configuration on a SELECT
131
+ search_config: { source: 'sql' },
132
+ // (081) an e-mail template's bodies
133
+ email_template: { html_body: 'html', text_body: 'txt' },
130
134
  };
131
135
  const isLong = (v: unknown): v is string => typeof v === 'string' && (v.includes('\n') || v.length > 60);
132
136
  const codeExt = (table: string, column: string, row: any) =>
@@ -180,6 +184,12 @@ const NAMED: [section: string, dir: string, table: string][] = [
180
184
  ['supporting_scripts', 'shared/supporting-objects', 'supporting_script'],
181
185
  // (069) plug-ins: install SQL in <name>.install_sql.sql
182
186
  ['plugins', 'shared/plugins', 'plugin'],
187
+ // (080) search configurations
188
+ ['search_configs', 'shared/search-configurations', 'search_config'],
189
+ // (081) e-mail templates
190
+ ['email_templates', 'shared/email-templates', 'email_template'],
191
+ // (086) application settings
192
+ ['app_settings', 'shared/app-settings', 'app_setting'],
183
193
  ];
184
194
 
185
195
  /** Components of a page: [array in the document, directory, table, key source]. */
@@ -198,6 +208,8 @@ const PAGE_PARTS: [section: string, dir: string, table: string, key: (r: any) =>
198
208
  const SINGLE: [section: string, path: string, sort: string[]][] = [
199
209
  ['group_roles', 'shared/group-roles.json', ['group_name', 'role']],
200
210
  ['text_messages', 'globalization/text-messages.json', ['name', 'language']],
211
+ // (086) dynamic translations (meta.lang)
212
+ ['dynamic_translations', 'globalization/dynamic-translations.json', ['language', 'source']],
201
213
  ];
202
214
 
203
215
  const ACTIONS_DIR = 'shared/automation-actions';
@@ -305,6 +305,18 @@ export function startScheduler() {
305
305
  } catch (e) {
306
306
  console.error('push notifications:', (e as Error).message);
307
307
  }
308
+ try {
309
+ // (082) a daily backup of every application that changed (src/builder/backups.ts)
310
+ await (await import('./builder/backups.ts')).backupTick();
311
+ } catch (e) {
312
+ console.error('backups:', (e as Error).message);
313
+ }
314
+ try {
315
+ // mail queued from SQL (meta.send_mail; src/mail/mail.ts), when the listener missed it, and retries
316
+ await (await import('./mail/mail.ts')).mailTick();
317
+ } catch (e) {
318
+ console.error('mail:', (e as Error).message);
319
+ }
308
320
  try {
309
321
  // AI requests queued from SQL (meta.ai_generate; src/ai/requests.ts)
310
322
  await (await import('./ai/requests.ts')).aiRequestTick();