pgkiln 0.72.0 → 0.74.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 (79) hide show
  1. package/db/migrations/140_item_region_types.sql +27 -0
  2. package/db/migrations/141_workspace_resources.sql +115 -0
  3. package/docs/apex-feature-parity.md +91 -12
  4. package/docs/guide/01-installation.md +1 -0
  5. package/docs/guide/03-builder.md +47 -4
  6. package/docs/guide/04-pages-and-regions.md +41 -1
  7. package/docs/guide/05-items.md +43 -3
  8. package/docs/guide/11-from-apex.md +3 -0
  9. package/docs/guide/12-development.md +25 -3
  10. package/docs/guide/16-files.md +29 -0
  11. package/docs/guide/19-rest-data-sources.md +41 -3
  12. package/examples/hr/hr_75_item_region_types.sql +81 -0
  13. package/examples/hr/hr_76_remote_servers.sql +48 -0
  14. package/package.json +1 -1
  15. package/public/app.css +48 -0
  16. package/public/app.js +377 -1
  17. package/src/ai/service.ts +10 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/advisor.ts +18 -2
  20. package/src/builder/ai.ts +29 -3
  21. package/src/builder/arrange.ts +7 -0
  22. package/src/builder/components.ts +5 -5
  23. package/src/builder/designer.ts +9 -3
  24. package/src/builder/home.ts +3 -0
  25. package/src/builder/i18n/de.ts +6 -0
  26. package/src/builder/i18n/en.ts +6 -0
  27. package/src/builder/i18n/es.ts +6 -0
  28. package/src/builder/i18n/fr.ts +6 -0
  29. package/src/builder/i18n/it.ts +6 -0
  30. package/src/builder/i18n/ja.ts +6 -0
  31. package/src/builder/i18n/ko.ts +6 -0
  32. package/src/builder/i18n/nl.ts +6 -0
  33. package/src/builder/i18n/pt.ts +6 -0
  34. package/src/builder/i18n/zh-cn.ts +6 -0
  35. package/src/builder/i18n/zh-tw.ts +6 -0
  36. package/src/builder/network-access.ts +33 -0
  37. package/src/builder/region-help.ts +8 -2
  38. package/src/builder/rest-catalogs.ts +22 -14
  39. package/src/builder/routes.ts +5 -1
  40. package/src/builder/static-files.ts +14 -4
  41. package/src/builder/websources.ts +12 -6
  42. package/src/builder/workflows.ts +2 -1
  43. package/src/builder/workspace-resources.ts +528 -0
  44. package/src/i18n/ar.ts +13 -0
  45. package/src/i18n/cs.ts +13 -0
  46. package/src/i18n/da.ts +13 -0
  47. package/src/i18n/de.ts +13 -0
  48. package/src/i18n/el.ts +13 -0
  49. package/src/i18n/es.ts +13 -0
  50. package/src/i18n/fi.ts +13 -0
  51. package/src/i18n/fr.ts +13 -0
  52. package/src/i18n/he.ts +13 -0
  53. package/src/i18n/it.ts +13 -0
  54. package/src/i18n/ja.ts +13 -0
  55. package/src/i18n/ko.ts +13 -0
  56. package/src/i18n/nb.ts +13 -0
  57. package/src/i18n/pl.ts +13 -0
  58. package/src/i18n/pt.ts +13 -0
  59. package/src/i18n/ru.ts +13 -0
  60. package/src/i18n/sv.ts +13 -0
  61. package/src/i18n/tr.ts +13 -0
  62. package/src/i18n/uk.ts +13 -0
  63. package/src/i18n/zh.ts +13 -0
  64. package/src/i18n.ts +26 -0
  65. package/src/metadata.ts +22 -5
  66. package/src/remote-servers.ts +133 -0
  67. package/src/restcatalog.ts +21 -10
  68. package/src/restsync.ts +1 -1
  69. package/src/runtime/context.ts +2 -0
  70. package/src/runtime/items.ts +32 -3
  71. package/src/runtime/page-regions.ts +50 -0
  72. package/src/runtime/region-cache.ts +1 -1
  73. package/src/runtime/regions.ts +11 -0
  74. package/src/runtime/render.ts +3 -17
  75. package/src/runtime/report.ts +55 -3
  76. package/src/runtime/static-files.ts +46 -17
  77. package/src/runtime/template-components.ts +2 -1
  78. package/src/websources.ts +43 -18
  79. package/src/workspace-export.ts +80 -1
package/public/app.js CHANGED
@@ -264,6 +264,8 @@ const pgkilnUnsaved = (() => {
264
264
  if (el.closest('[data-range]')) return els.some((e) => e.value) ? els.slice(0, 2).map((e) => e.value).join(':') : '';
265
265
  if (el.type === 'radio') return (els.find((e) => e.checked) || {}).value || '';
266
266
  if (el.type === 'checkbox') return el.checked ? 'true' : 'false';
267
+ // (140) a multi-value list (multiselect, shuttle, select many): its values colon-separated, in order
268
+ if (el.tagName === 'SELECT' && el.multiple) return [...el.selectedOptions].map((o) => o.value).join(':');
267
269
  return el.value;
268
270
  }
269
271
 
@@ -281,7 +283,10 @@ const pgkilnUnsaved = (() => {
281
283
  els.slice(0, 2).forEach((e, i) => (e.value = parts[i] || ''));
282
284
  } else if (el.type === 'radio') els.forEach((e) => (e.checked = e.value === value));
283
285
  else if (el.type === 'checkbox') el.checked = ['true', 't', 'on', '1', 'yes', 'y'].includes(String(value).toLowerCase());
284
- else el.value = value ?? '';
286
+ else if (el.tagName === 'SELECT' && el.multiple) {
287
+ const values = String(value ?? '').split(':').filter(Boolean);
288
+ for (const o of el.options) o.selected = values.includes(o.value);
289
+ } else el.value = value ?? '';
285
290
  if (depth < 5) {
286
291
  depth++;
287
292
  el.dispatchEvent(new Event('change', { bubbles: true }));
@@ -2162,6 +2167,377 @@ function popupLov(sel) {
2162
2167
  });
2163
2168
  }
2164
2169
 
2170
+ // (140; APEX: Shuttle) Two lists, available and chosen, with buttons to move one or all values across and to
2171
+ // order the chosen ones. The item's own <select multiple> (it works without JavaScript) stays in the form, hidden:
2172
+ // its selected options are the value, in the order of the chosen list (they come first in it).
2173
+ function enhanceShuttles(root) {
2174
+ const boxes = root.matches?.('[data-shuttle]') ? [root] : [...(root.querySelectorAll?.('[data-shuttle]') ?? [])];
2175
+ boxes.forEach(shuttle);
2176
+ }
2177
+ document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceShuttles(e.detail));
2178
+ enhanceShuttles(document);
2179
+ function shuttle(box) {
2180
+ const sel = box.querySelector('select[multiple]');
2181
+ if (!sel || box.dataset.enhanced) return;
2182
+ box.dataset.enhanced = '1';
2183
+ const d = box.dataset;
2184
+ const id = sel.id;
2185
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
2186
+ const name = (label?.textContent || sel.name).replace(/\*\s*$/, '').trim();
2187
+ const el = (tag, cls) => {
2188
+ const x = document.createElement(tag);
2189
+ if (cls) x.className = cls;
2190
+ return x;
2191
+ };
2192
+ const list = (suffix, text) => {
2193
+ const l = el('select', 'shuttle-list');
2194
+ l.multiple = true;
2195
+ l.id = `${id}_${suffix}`;
2196
+ l.size = Math.max(sel.size || 6, 6);
2197
+ l.setAttribute('aria-label', `${name}: ${text}`);
2198
+ for (const a of ['aria-describedby', 'aria-invalid']) if (sel.hasAttribute(a)) l.setAttribute(a, sel.getAttribute(a));
2199
+ return l;
2200
+ };
2201
+ const avail = list('available', d.availableLabel || 'Available');
2202
+ const chosen = list('chosen', d.chosenLabel || 'Selected');
2203
+ const filter = el('input', 'shuttle-filter');
2204
+ filter.type = 'search';
2205
+ filter.id = `${id}_filter`;
2206
+ filter.autocomplete = 'off';
2207
+ filter.placeholder = d.filterLabel || 'Filter';
2208
+ filter.setAttribute('aria-label', `${name}: ${d.filterLabel || 'Filter'}`);
2209
+ filter.setAttribute('aria-controls', avail.id);
2210
+ const button = (cmd, text, glyph) => {
2211
+ const b = el('button', 'btn btn-sm shuttle-btn');
2212
+ b.type = 'button';
2213
+ b.dataset.cmd = cmd;
2214
+ b.title = text;
2215
+ b.setAttribute('aria-label', text);
2216
+ const g = el('span');
2217
+ g.setAttribute('aria-hidden', 'true');
2218
+ g.textContent = glyph;
2219
+ b.append(g);
2220
+ return b;
2221
+ };
2222
+ const moves = el('div', 'shuttle-moves');
2223
+ moves.setAttribute('role', 'group');
2224
+ moves.append(button('all', d.moveAllLabel || 'Move all', '»'), button('move', d.moveLabel || 'Move', '›'), button('remove', d.removeLabel || 'Remove', '‹'), button('none', d.removeAllLabel || 'Remove all', '«'));
2225
+ const order = el('div', 'shuttle-order');
2226
+ order.setAttribute('role', 'group');
2227
+ order.append(button('top', d.topLabel || 'Move to top', '⤒'), button('up', d.upLabel || 'Move up', '↑'), button('down', d.downLabel || 'Move down', '↓'), button('bottom', d.bottomLabel || 'Move to bottom', '⤓'));
2228
+ const left = el('div', 'shuttle-col');
2229
+ left.append(filter, avail);
2230
+ const right = el('div', 'shuttle-col');
2231
+ right.append(chosen);
2232
+ const ui = el('div', 'shuttle-ui');
2233
+ ui.append(left, moves, right, order);
2234
+ sel.hidden = true;
2235
+ box.append(ui);
2236
+ if (label) label.htmlFor = avail.id;
2237
+
2238
+ const src = new Map(); // a shown option → the item's option
2239
+ const lovIndex = (o) => Number(o.dataset.i ?? 0);
2240
+ // the item's options: the chosen ones first (in their order), then the others in the list of values' order
2241
+ const arrange = (picked) => {
2242
+ const rest = [...sel.options].filter((o) => !picked.includes(o)).sort((a, b) => lovIndex(a) - lovIndex(b));
2243
+ for (const o of sel.options) o.selected = picked.includes(o);
2244
+ sel.append(...picked, ...rest);
2245
+ };
2246
+ const picked = () => [...sel.options].filter((o) => o.selected);
2247
+ const draw = (keep = new Set()) => {
2248
+ const q = filter.value.trim().toLowerCase();
2249
+ const fill = (l, opts) => {
2250
+ l.replaceChildren(
2251
+ ...opts.map((o) => {
2252
+ const x = new Option(o.text, o.value);
2253
+ x.selected = keep.has(o);
2254
+ src.set(x, o);
2255
+ return x;
2256
+ }),
2257
+ );
2258
+ };
2259
+ src.clear();
2260
+ fill(avail, [...sel.options].filter((o) => !o.selected && (!q || o.text.toLowerCase().includes(q))));
2261
+ fill(chosen, picked());
2262
+ };
2263
+ let own = false;
2264
+ const changed = (keep) => {
2265
+ draw(keep);
2266
+ own = true;
2267
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
2268
+ own = false;
2269
+ };
2270
+ const marked = (l) => [...l.selectedOptions].map((x) => src.get(x)).filter(Boolean);
2271
+ const run = (cmd) => {
2272
+ let now = picked();
2273
+ let keep = new Set();
2274
+ if (cmd === 'move' || cmd === 'all') {
2275
+ const add = cmd === 'all' ? [...avail.options].map((x) => src.get(x)) : marked(avail);
2276
+ if (!add.length) return;
2277
+ now = [...now, ...add];
2278
+ keep = new Set(add);
2279
+ } else if (cmd === 'remove' || cmd === 'none') {
2280
+ const drop = cmd === 'none' ? now : marked(chosen);
2281
+ if (!drop.length) return;
2282
+ now = now.filter((o) => !drop.includes(o));
2283
+ keep = new Set(drop);
2284
+ } else {
2285
+ const m = marked(chosen);
2286
+ if (!m.length) return;
2287
+ keep = new Set(m);
2288
+ const others = now.filter((o) => !keep.has(o));
2289
+ if (cmd === 'top') now = [...m, ...others];
2290
+ else if (cmd === 'bottom') now = [...others, ...m];
2291
+ else {
2292
+ const step = cmd === 'up' ? -1 : 1;
2293
+ const idx = (cmd === 'up' ? m : [...m].reverse()).map((o) => now.indexOf(o));
2294
+ for (const i of idx) {
2295
+ const j = i + step;
2296
+ if (j < 0 || j >= now.length || keep.has(now[j])) continue;
2297
+ [now[i], now[j]] = [now[j], now[i]];
2298
+ }
2299
+ }
2300
+ }
2301
+ arrange(now);
2302
+ changed(keep);
2303
+ };
2304
+ ui.addEventListener('click', (e) => {
2305
+ const b = e.target.closest('button[data-cmd]');
2306
+ if (b) run(b.dataset.cmd);
2307
+ });
2308
+ avail.addEventListener('dblclick', () => run('move'));
2309
+ chosen.addEventListener('dblclick', () => run('remove'));
2310
+ // the keyboard: Enter moves the marked values across; in the chosen list Alt+arrows order them
2311
+ avail.addEventListener('keydown', (e) => {
2312
+ if (e.key === 'Enter') {
2313
+ e.preventDefault();
2314
+ run('move');
2315
+ }
2316
+ });
2317
+ chosen.addEventListener('keydown', (e) => {
2318
+ if (e.key === 'Enter' || e.key === 'Delete') {
2319
+ e.preventDefault();
2320
+ run('remove');
2321
+ } else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
2322
+ e.preventDefault();
2323
+ run(e.key === 'ArrowUp' ? (e.shiftKey ? 'top' : 'up') : e.shiftKey ? 'bottom' : 'down');
2324
+ }
2325
+ });
2326
+ // the lists' own change events are not the item's
2327
+ for (const l of [avail, chosen]) l.addEventListener('change', (e) => e.stopPropagation());
2328
+ filter.addEventListener('input', () => draw(new Set(marked(avail))));
2329
+ filter.addEventListener('change', (e) => e.stopPropagation());
2330
+ filter.addEventListener('keydown', (e) => {
2331
+ if (e.key === 'Enter') e.preventDefault();
2332
+ });
2333
+ // a dynamic action (Set value, Clear) changed the item
2334
+ sel.addEventListener('change', () => {
2335
+ if (!own) draw();
2336
+ });
2337
+ arrange(picked());
2338
+ draw();
2339
+ }
2340
+
2341
+ // (140; APEX 24.2: Select One, Select Many) A search field over the item's list of values: typing filters a list
2342
+ // box below it (arrow keys, Enter, Escape); Select Many shows the chosen values as chips with a remove button.
2343
+ // The item's <select> (single or multiple; it works without JavaScript) stays in the form, hidden, as the value.
2344
+ function enhanceSelectPicks(root) {
2345
+ const boxes = root.matches?.('[data-select-pick]') ? [root] : [...(root.querySelectorAll?.('[data-select-pick]') ?? [])];
2346
+ boxes.forEach(selectPick);
2347
+ }
2348
+ let selectPickSeq = 0;
2349
+ document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceSelectPicks(e.detail));
2350
+ enhanceSelectPicks(document);
2351
+ function selectPick(box) {
2352
+ const sel = box.querySelector('select');
2353
+ if (!sel || box.dataset.enhanced) return;
2354
+ box.dataset.enhanced = '1';
2355
+ const d = box.dataset;
2356
+ const many = sel.multiple;
2357
+ const id = sel.id;
2358
+ const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
2359
+ const input = document.createElement('input');
2360
+ input.type = 'text';
2361
+ input.id = `${id}_search`;
2362
+ input.className = 'select-pick-input';
2363
+ input.autocomplete = 'off';
2364
+ input.spellcheck = false;
2365
+ input.placeholder = d.searchLabel || 'Search…';
2366
+ input.setAttribute('role', 'combobox');
2367
+ input.setAttribute('aria-autocomplete', 'list');
2368
+ input.setAttribute('aria-expanded', 'false');
2369
+ for (const a of ['aria-describedby', 'aria-invalid', 'aria-required']) if (sel.hasAttribute(a)) input.setAttribute(a, sel.getAttribute(a));
2370
+ const listId = `${id}_listbox_${++selectPickSeq}`;
2371
+ input.setAttribute('aria-controls', listId);
2372
+ const list = document.createElement('ul');
2373
+ list.id = listId;
2374
+ list.className = 'select-pick-list';
2375
+ list.setAttribute('role', 'listbox');
2376
+ if (label) list.setAttribute('aria-label', label.textContent.replace(/\*\s*$/, '').trim());
2377
+ if (many) list.setAttribute('aria-multiselectable', 'true');
2378
+ list.hidden = true;
2379
+ const chips = document.createElement('ul');
2380
+ chips.className = 'tag-list select-pick-chips';
2381
+ const ui = document.createElement('div');
2382
+ ui.className = 'select-pick-ui';
2383
+ if (many) ui.append(chips);
2384
+ ui.append(input, list);
2385
+ sel.hidden = true;
2386
+ box.append(ui);
2387
+ if (label) label.htmlFor = input.id;
2388
+ const status = document.createElement('span');
2389
+ status.className = 'sr-only';
2390
+ status.setAttribute('aria-live', 'polite');
2391
+ ui.append(status);
2392
+
2393
+ let active = -1;
2394
+ let shown = [];
2395
+ const options = () => [...sel.options];
2396
+ const textOf = (o) => o.text;
2397
+ const syncInput = () => {
2398
+ if (!many) input.value = sel.value ? (sel.selectedOptions[0]?.text ?? '') : '';
2399
+ };
2400
+ const drawChips = () => {
2401
+ if (!many) return;
2402
+ chips.replaceChildren(
2403
+ ...[...sel.selectedOptions].map((o) => {
2404
+ const li = document.createElement('li');
2405
+ li.className = 'tag';
2406
+ li.append(o.text);
2407
+ const x = document.createElement('button');
2408
+ x.type = 'button';
2409
+ x.className = 'tag-remove';
2410
+ x.textContent = '×';
2411
+ x.setAttribute('aria-label', (d.removeLabel || 'Remove {value}').replace('{value}', o.text));
2412
+ x.addEventListener('click', () => {
2413
+ o.selected = false;
2414
+ changed();
2415
+ input.focus();
2416
+ });
2417
+ li.append(x);
2418
+ return li;
2419
+ }),
2420
+ );
2421
+ };
2422
+ let own = false;
2423
+ const changed = () => {
2424
+ drawChips();
2425
+ syncInput();
2426
+ if (!list.hidden) draw();
2427
+ own = true;
2428
+ sel.dispatchEvent(new Event('change', { bubbles: true }));
2429
+ own = false;
2430
+ };
2431
+ const setActive = (i) => {
2432
+ active = i;
2433
+ [...list.children].forEach((li, n) => li.classList.toggle('is-active', n === i));
2434
+ const li = list.children[i];
2435
+ if (li && li.id) {
2436
+ input.setAttribute('aria-activedescendant', li.id);
2437
+ li.scrollIntoView?.({ block: 'nearest' });
2438
+ } else input.removeAttribute('aria-activedescendant');
2439
+ };
2440
+ const draw = () => {
2441
+ const q = (many || input.dataset.typed ? input.value : '').trim().toLowerCase();
2442
+ shown = options().filter((o) => (many ? o.value !== '' : true) && (!q || textOf(o).toLowerCase().includes(q)));
2443
+ list.replaceChildren(
2444
+ ...shown.map((o, n) => {
2445
+ const li = document.createElement('li');
2446
+ li.id = `${listId}_${n}`;
2447
+ li.setAttribute('role', 'option');
2448
+ li.setAttribute('aria-selected', String(o.selected && (many || o.value !== '')));
2449
+ li.textContent = textOf(o);
2450
+ li.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the field
2451
+ li.addEventListener('click', () => pick(n));
2452
+ return li;
2453
+ }),
2454
+ );
2455
+ if (!shown.length) {
2456
+ const li = document.createElement('li');
2457
+ li.className = 'select-pick-none';
2458
+ li.textContent = d.noneLabel || 'Nothing found.';
2459
+ list.append(li);
2460
+ }
2461
+ status.textContent = shown.length ? '' : d.noneLabel || 'Nothing found.';
2462
+ setActive(shown.length ? Math.min(Math.max(active, 0), shown.length - 1) : -1);
2463
+ };
2464
+ const open = () => {
2465
+ if (!list.hidden) return;
2466
+ list.hidden = false;
2467
+ input.setAttribute('aria-expanded', 'true');
2468
+ active = many ? 0 : Math.max(0, options().findIndex((o) => o.selected));
2469
+ draw();
2470
+ };
2471
+ const close = () => {
2472
+ list.hidden = true;
2473
+ input.setAttribute('aria-expanded', 'false');
2474
+ input.removeAttribute('aria-activedescendant');
2475
+ delete input.dataset.typed;
2476
+ if (many) input.value = '';
2477
+ else syncInput();
2478
+ };
2479
+ const pick = (n) => {
2480
+ const o = shown[n];
2481
+ if (!o) return;
2482
+ if (many) {
2483
+ o.selected = !o.selected;
2484
+ input.value = '';
2485
+ active = n;
2486
+ changed();
2487
+ } else {
2488
+ sel.value = o.value;
2489
+ close();
2490
+ changed();
2491
+ }
2492
+ };
2493
+ input.addEventListener('focus', () => {
2494
+ if (!many) input.select?.();
2495
+ });
2496
+ input.addEventListener('click', open);
2497
+ input.addEventListener('input', () => {
2498
+ input.dataset.typed = '1';
2499
+ if (list.hidden) open();
2500
+ active = 0;
2501
+ draw();
2502
+ });
2503
+ input.addEventListener('change', (e) => e.stopPropagation()); // the item's value is the select's
2504
+ input.addEventListener('keydown', (e) => {
2505
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2506
+ e.preventDefault();
2507
+ if (list.hidden) return open();
2508
+ if (shown.length) setActive((active + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length);
2509
+ } else if (e.key === 'Enter') {
2510
+ if (list.hidden) return;
2511
+ e.preventDefault();
2512
+ if (active >= 0) pick(active);
2513
+ } else if (e.key === 'Escape') {
2514
+ if (list.hidden) return;
2515
+ e.preventDefault();
2516
+ close();
2517
+ } else if (e.key === 'Backspace' && many && !input.value && sel.selectedOptions.length) {
2518
+ sel.selectedOptions[sel.selectedOptions.length - 1].selected = false;
2519
+ changed();
2520
+ } else if (e.key === 'Tab') close();
2521
+ });
2522
+ input.addEventListener('blur', () => {
2523
+ if (list.hidden) return;
2524
+ // Select One: a field emptied by the user chooses no value
2525
+ if (!many && input.dataset.typed && !input.value.trim() && [...sel.options].some((o) => o.value === '')) {
2526
+ sel.value = '';
2527
+ close();
2528
+ changed();
2529
+ } else close();
2530
+ });
2531
+ // a dynamic action (Set value, Clear) changed the item
2532
+ sel.addEventListener('change', () => {
2533
+ if (own) return;
2534
+ drawChips();
2535
+ if (document.activeElement !== input) syncInput();
2536
+ });
2537
+ drawChips();
2538
+ syncInput();
2539
+ }
2540
+
2165
2541
  // Builder: role suggestions. Clicking a role adds it to the roles field of
2166
2542
  // the same form; in a form with an application select, only that app's
2167
2543
  // suggestions are shown.
package/src/ai/service.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { owner } from '../db.ts';
2
2
  import { decryptSecret } from '../secrets.ts';
3
+ import { aiServerUrl } from '../remote-servers.ts';
3
4
  import { ClaudeProvider } from './anthropic.ts';
4
5
  import { OpenAiProvider } from './openai.ts';
5
6
  import { AiError, type AiProvider, type GenerateRequest, type GenerateResult, type ProviderName } from './types.ts';
@@ -27,6 +28,9 @@ export interface AiService {
27
28
  base_url: string | null;
28
29
  api_key_enc: string | null;
29
30
  enabled: boolean;
31
+ /** (141) a remote server whose base URL (plus server_path) the service calls instead of base_url */
32
+ server_id?: number | null;
33
+ server_path?: string | null;
30
34
  }
31
35
 
32
36
  /** The default model of a new Claude service; OpenAI services name theirs. */
@@ -78,7 +82,12 @@ export function serviceKey(s: AiService): string {
78
82
  }
79
83
 
80
84
  export async function loadService(name: string): Promise<AiService | undefined> {
81
- return owner.one<AiService>('select * from meta.ai_service where name = $1', [name.toUpperCase()]);
85
+ const s = await owner.one<AiService>('select * from meta.ai_service where name = $1', [name.toUpperCase()]);
86
+ if (!s?.server_id) return s;
87
+ // (141) on a remote server: its URL now (base_url is then what baseUrlFor gives the SDK)
88
+ const r = await aiServerUrl(s.server_id, s.server_path);
89
+ if (!r.url) throw new AiError('config', `AI service ${s.name}: ${r.problem}`);
90
+ return { ...s, base_url: r.url };
82
91
  }
83
92
 
84
93
  /** The provider of a service, with its key (decrypted only here, never stored elsewhere). */
package/src/app.ts CHANGED
@@ -96,6 +96,7 @@ import { findWorkspaceRoutes } from './builder/find-workspace.ts';
96
96
  import { mailQueueRoutes } from './builder/mail-queue.ts';
97
97
  import { networkAccessRoutes } from './builder/network-access.ts';
98
98
  import { workspaceMailRoutes } from './builder/workspace-mail.ts';
99
+ import { workspaceResourceRoutes } from './builder/workspace-resources.ts';
99
100
  import { runtimeScope } from './db.ts';
100
101
  // (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
101
102
  import { startTelemetry } from './telemetry/export.ts';
@@ -281,6 +282,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
281
282
  await app.register(mailQueueRoutes);
282
283
  await app.register(networkAccessRoutes);
283
284
  await app.register(workspaceMailRoutes);
285
+ await app.register(workspaceResourceRoutes);
284
286
  // (sprint 138, AP-5) an application's Utilities
285
287
  await app.register(utilityRoutes);
286
288
  await app.register(dependencyRoutes);
@@ -15,6 +15,7 @@ import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } fr
15
15
  import { COMPONENTS } from './components.ts';
16
16
  import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
17
17
  import { appEntries, type Entry } from './search.ts';
18
+ import { LOV_CHECKED } from '../runtime/items.ts';
18
19
  import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
19
20
 
20
21
  // App Builder → Advisor (APEX: Advisor): checks an application without
@@ -48,7 +49,7 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
48
49
  switch (kind) {
49
50
  case 'region':
50
51
  return [
51
- ...(['report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
52
+ ...(['report', 'classic_report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
52
53
  { name: 'condition', shape: 'boolean' },
53
54
  ];
54
55
  case 'item':
@@ -386,6 +387,10 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
386
387
  // (104) a document button names a document template or a report query
387
388
  const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
388
389
  const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
390
+ // (141) remote servers and web credentials of the application's workspace (sources and invoke steps may name them)
391
+ const wsServers = new Map((await query('select static_id, credential from meta.remote_server where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => [r.static_id as string, r.credential as string | null]));
392
+ const wsCredentials = new Set((await query('select name from meta.workspace_credential where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => r.name as string));
393
+ const appCredentials = new Set(all.filter((x) => x.kind === 'web_credential').map((x) => String(x.row.name).toUpperCase()));
389
394
 
390
395
  for (const e of entries) {
391
396
  for (const f of e.fields) {
@@ -428,6 +433,9 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
428
433
  if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
429
434
  missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
430
435
  if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
436
+ // (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
437
+ if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
438
+ missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
431
439
  // (078) a URL region: a literal origin, valid attributes
432
440
  if (kind === 'region' && row.type === 'url') {
433
441
  const target = urlTarget(row.source);
@@ -453,6 +461,14 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
453
461
  if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
454
462
  missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
455
463
  if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
464
+ if (kind === 'rest_source') {
465
+ // (141) an export may name a server or credential this workspace doesn't have (yet): it imports, and says so here
466
+ if (row.server && !wsServers.has(row.server))
467
+ missing(e, 'Remote server', `Remote server ${row.server} doesn't exist in this application's workspace: add it under Workspace utilities → Remote servers (calls fail until then).`, 'warning');
468
+ const credential = row.credential || (row.server ? wsServers.get(row.server) : null);
469
+ if (credential && !appCredentials.has(credential) && !wsCredentials.has(credential))
470
+ missing(e, 'Web credential', `Web credential ${credential} is neither the application's nor its workspace's (calls fail until it exists).`, 'warning');
471
+ }
456
472
  if (kind === 'workflow_definition') {
457
473
  const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
458
474
  const outcomes = outcomeMap(all.filter((x) => x.kind === 'task_definition').map((x) => x.row));
@@ -461,7 +477,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
461
477
  // invoke_api steps: REST data sources, parameters, web credentials, variables (sprint 32)
462
478
  const refs = {
463
479
  sources: new Map(all.filter((x) => x.kind === 'rest_source').map((x) => [String(x.row.name).toUpperCase(), x.row])),
464
- credentials: new Set(all.filter((x) => x.kind === 'web_credential').map((x) => String(x.row.name).toUpperCase())),
480
+ credentials: new Set([...appCredentials, ...wsCredentials]),
465
481
  startVars: [...String(row.title ?? '').matchAll(/&([A-Za-z][A-Za-z0-9_]*)\./g)].map((m) => m[1]),
466
482
  };
467
483
  const invokeRefs = invokeStepReferences(row.steps, refs);
package/src/builder/ai.ts CHANGED
@@ -55,7 +55,7 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
55
55
  }
56
56
 
57
57
  /** The form's values, checked; throws with a message. */
58
- function formValues(b: Record<string, string | undefined>) {
58
+ function formValues(b: Record<string, string | undefined>, hasServer = false) {
59
59
  const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
60
60
  const model = (b.model ?? '').trim();
61
61
  if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('The model is a name like claude-opus-5-5 (letters, digits and . _ : / @ -).');
@@ -65,7 +65,7 @@ function formValues(b: Record<string, string | undefined>) {
65
65
  if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('Time limit: 5 to 600 seconds.');
66
66
  const baseUrl = (b.base_url ?? '').trim() || null;
67
67
  if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('The base URL starts with http:// or https://.');
68
- if (!baseUrl && provider === 'openai_compatible') throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
68
+ if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
69
69
  const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
70
70
  return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
71
71
  }
@@ -157,12 +157,20 @@ export async function aiRoutes(app: FastifyInstance) {
157
157
  delete s.state[TEST_RESULT];
158
158
  const recent = (await owner.query(`select u.*, a.alias as app from meta.ai_usage u left join meta.app a on a.id = u.app_id where u.service_id = $1 order by u.id desc limit 50`, [x.id])).rows;
159
159
  const view = { ...x, api_key_enc: x.api_key_enc ? 'stored' : null }; // the key itself never reaches the page
160
+ // (141) the remote servers of every workspace (administrators choose one)
161
+ const servers = (await owner.query<{ id: number; static_id: string; workspace: string }>(
162
+ 'select r.id, r.static_id, w.name as workspace from meta.remote_server r join meta.workspace w on w.id = r.workspace_id order by lower(w.name), r.static_id')).rows;
160
163
  const main = html`
161
164
  <div class="title-row"><h1 class="ai-name">${x.name}</h1></div>
162
165
  <div class="columns wide-left">
163
166
  ${region('Settings', html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
164
167
  <form method="post" action="${BASE}/ai/${x.id}/delete" class="danger-zone">${csrf(s)}
165
168
  <button class="btn btn-danger" data-confirm="Delete AI service ${x.name}? Processes that use it fail until another service gets its name.">Delete service</button></form>`)}
169
+ ${region('Remote server', html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
170
+ <p class="muted u-mt0">Instead of the base URL: a remote server's URL (Workspace utilities → Remote servers) plus a path, so moving the endpoint changes one value. The API key is sent there: choose a server whose workspace administrators you trust with it.</p>
171
+ <div class="form-grid">${select('server_id', 'Remote server', x.server_id ? String(x.server_id) : '', [['', 'none: the base URL above'], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
172
+ ${input('server_path', 'Path', x.server_path ?? '', { placeholder: '/v1', help: 'After the server\'s base URL, e.g. /v1; empty: the base URL itself.' })}</div>
173
+ <div class="buttons"><button class="btn">Save</button></div></form>`)}
166
174
  ${region('Test', html`
167
175
  <p class="muted u-mt0">Sends a prompt to the service (counted in the usage log as a builder test).</p>
168
176
  <form method="post" action="${BASE}/ai/${x.id}/test">${csrf(s)}
@@ -194,7 +202,7 @@ export async function aiRoutes(app: FastifyInstance) {
194
202
  `update meta.ai_service set (${COLUMNS}) = ($2, $3, $4, $5, $6, $7, $8, $9, $10), updated_at = now(),
195
203
  api_key_enc = case when $12 then null when $11::text is null then api_key_enc else $11 end
196
204
  where id = $1`,
197
- [req.params.id, ...formValues(b), key, b.remove_key === 'true'],
205
+ [req.params.id, ...formValues(b, !!(await owner.one('select 1 as ok from meta.ai_service where id = $1 and server_id is not null', [req.params.id]))), key, b.remove_key === 'true'],
198
206
  );
199
207
  flash(s, r.rowCount ? 'AI service saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
200
208
  } catch (e) {
@@ -203,6 +211,24 @@ export async function aiRoutes(app: FastifyInstance) {
203
211
  return back(reply, s, `${BASE}/ai/${req.params.id}`);
204
212
  });
205
213
 
214
+ // (141) the service's remote server and path
215
+ app.post(`${BASE}/ai/:id(^\\d+$)/server`, async (req: Req, reply) => {
216
+ const s = await developer(req, reply);
217
+ if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
218
+ const b = req.body ?? {};
219
+ try {
220
+ const serverId = /^\d{1,9}$/.test(String(b.server_id ?? '')) ? Number(b.server_id) : null;
221
+ const path = String(b.server_path ?? '').trim() || null;
222
+ if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error('The path starts with /, without spaces, e.g. /v1.');
223
+ if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error('No such remote server.');
224
+ const r = await owner.query('update meta.ai_service set server_id = $2, server_path = $3, updated_at = now() where id = $1', [req.params.id, serverId, serverId ? path : null]);
225
+ flash(s, r.rowCount ? (serverId ? 'The service now calls its remote server: test it.' : 'The service calls its base URL again.') : 'Not found.', r.rowCount ? 'ok' : 'error');
226
+ } catch (e) {
227
+ flash(s, (e as Error).message.includes('ai_service_compatible_url') ? 'An OpenAI-compatible service needs a base URL or a remote server.' : (e as Error).message, 'error');
228
+ }
229
+ return back(reply, s, `${BASE}/ai/${req.params.id}`);
230
+ });
231
+
206
232
  app.post(`${BASE}/ai/:id(^\\d+$)/apps`, async (req: Req, reply) => {
207
233
  const s = await developer(req, reply);
208
234
  if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
@@ -45,6 +45,7 @@ export const REGION_LABELS: Record<string, [string, string]> = {
45
45
  workflows: ['Workflow Console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static Content', 'region'], dynamic: ['Dynamic Content', 'code'],
46
46
  template_component: ['Template Component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'], form_designer: ['Form Designer', 'file'],
47
47
  ai_assistant: ['AI Assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'], login: ['Login', 'lock'], plugin: ['Plug-in', 'layers'],
48
+ classic_report: ['Classic Report', 'table'], breadcrumb: ['Breadcrumb', 'chevron'], help_text: ['Help Text', 'help'],
48
49
  };
49
50
  export const ITEM_LABELS: Record<string, [string, string]> = {
50
51
  text: ['Text Field', 'item'], textarea: ['Text Area', 'item'], number: ['Number Field', 'item'], date: ['Date Picker', 'calendar'],
@@ -53,6 +54,7 @@ export const ITEM_LABELS: Record<string, [string, string]> = {
53
54
  email: ['E-mail', 'inbox'], tel: ['Phone Number', 'item'], url: ['URL', 'item'], color: ['Color Picker', 'item'], file: ['File Upload', 'upload'],
54
55
  location: ['Location', 'map'], richtext: ['Rich Text Editor', 'edit'], markdown: ['Markdown Editor', 'code'], rating: ['Star Rating', 'check'],
55
56
  combobox: ['Combobox (Tags)', 'list'], daterange: ['Date Range', 'calendar'], qrcode: ['QR Code', 'scan'], image: ['Display Image', 'file'], address: ['Geocoded Address', 'map'], hidden: ['Hidden', 'item'], display: ['Display Only', 'file'], password: ['Password', 'key'],
57
+ shuttle: ['Shuttle', 'arrow-right'], select_one: ['Select One', 'search'], select_many: ['Select Many', 'check'],
56
58
  };
57
59
  export const BUTTON_LABELS: Record<string, [string, string]> = {
58
60
  submit: ['Submit Page', 'button'], redirect: ['Redirect to Page', 'button'], da: ['Dynamic Action', 'bolt'], document: ['Download Document', 'download'],
@@ -212,6 +214,11 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
212
214
  if (!REGION_TYPES().includes(type)) throw new LayoutError(400, 'Unknown region type.');
213
215
  id = (await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 999999, $2, $3, $4) returning id`,
214
216
  [pid, `New ${REGION_LABELS[type]?.[0].toLowerCase() ?? type}`, type, NEW_REGION_SOURCE[type] ?? null])).rows[0].id;
217
+ // (140) a breadcrumb goes in the breadcrumb bar, without a frame of its own (unless it is dropped elsewhere)
218
+ if (type === 'breadcrumb') {
219
+ await c.query(`update meta.region set template = 'plain', title = 'Breadcrumb' where id = $1`, [id]);
220
+ if (!o.position && !o.before) o = { ...o, position: 'breadcrumb' };
221
+ }
215
222
  } else {
216
223
  const region = await targetRegion(c, pid, o.region ?? '');
217
224
  if (kind === 'item') {