pgkiln 0.55.0 → 0.56.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.
@@ -0,0 +1,175 @@
1
+ -- =====================================================================
2
+ -- Sample app "Sample Maps" (App Gallery → Sample Apps)
3
+ --
4
+ -- Map regions on Dutch museums, without PostGIS: markers with popups and
5
+ -- clustering; several layers (markers, a route and an area as GeoJSON, a
6
+ -- heat map that starts hidden); a map that filters the report beside it by
7
+ -- distance; and a form with a location item ("Use my location") whose
8
+ -- places show on a map.
9
+ --
10
+ -- Installed by an administrator from the App Gallery, or with
11
+ -- psql "$DATABASE_URL" -1 -f examples/gallery/samples/sample-maps.sql
12
+ -- then open /a/sample-maps and sign in as demo / demo.
13
+ -- =====================================================================
14
+
15
+ create or replace function pg_temp.page_id(p_alias text, p_page int) returns int language sql stable as
16
+ $$ select p.id from meta.page p join meta.app a on a.id = p.app_id where a.alias = p_alias and p.page_no = p_page $$;
17
+ create or replace function pg_temp.region_id(p_alias text, p_page int, p_static text) returns int language sql stable as
18
+ $$ select r.id from meta.region r where r.page_id = pg_temp.page_id(p_alias, p_page) and r.static_id = p_static $$;
19
+
20
+ -- 1. Schema and data ---------------------------------------------------------
21
+ create schema sample_maps;
22
+
23
+ create table sample_maps.museum (
24
+ id int generated always as identity primary key,
25
+ name text not null unique,
26
+ city text not null,
27
+ kind text not null check (kind in ('Art', 'History', 'Science', 'Maritime', 'Nature')),
28
+ visitors int not null,
29
+ lat numeric(8, 5) not null,
30
+ lng numeric(8, 5) not null
31
+ );
32
+
33
+ insert into sample_maps.museum (name, city, kind, visitors, lat, lng) values
34
+ ('Rijksmuseum', 'Amsterdam', 'Art', 2500000, 52.36000, 4.88520),
35
+ ('Van Gogh Museum', 'Amsterdam', 'Art', 2100000, 52.35840, 4.88110),
36
+ ('Stedelijk Museum', 'Amsterdam', 'Art', 700000, 52.35800, 4.87970),
37
+ ('Anne Frank House', 'Amsterdam', 'History', 1300000, 52.37520, 4.88400),
38
+ ('NEMO Science Museum', 'Amsterdam', 'Science', 650000, 52.37380, 4.91230),
39
+ ('National Maritime Museum', 'Amsterdam', 'Maritime', 400000, 52.37140, 4.91490),
40
+ ('Mauritshuis', 'The Hague', 'Art', 450000, 52.08040, 4.31430),
41
+ ('Kunstmuseum Den Haag', 'The Hague', 'Art', 400000, 52.08970, 4.28060),
42
+ ('Kunsthal', 'Rotterdam', 'Art', 280000, 51.91070, 4.47330),
43
+ ('Maritime Museum Rotterdam', 'Rotterdam', 'Maritime', 300000, 51.91740, 4.48230),
44
+ ('Centraal Museum', 'Utrecht', 'Art', 200000, 52.08360, 5.12590),
45
+ ('Rijksmuseum van Oudheden', 'Leiden', 'History', 230000, 52.15840, 4.48590),
46
+ ('Naturalis', 'Leiden', 'Nature', 400000, 52.16520, 4.47370),
47
+ ('Frans Hals Museum', 'Haarlem', 'Art', 200000, 52.37720, 4.63660),
48
+ ('Teylers Museum', 'Haarlem', 'Science', 150000, 52.38010, 4.64030),
49
+ ('Kröller-Müller Museum', 'Otterlo', 'Art', 350000, 52.09550, 5.81700),
50
+ ('Van Abbemuseum', 'Eindhoven', 'Art', 120000, 51.43420, 5.48180),
51
+ ('Groninger Museum', 'Groningen', 'Art', 250000, 53.21240, 6.56600),
52
+ ('Bonnefanten', 'Maastricht', 'Art', 120000, 50.84220, 5.70200),
53
+ ('Zuiderzeemuseum', 'Enkhuizen', 'History', 250000, 52.70360, 5.29410);
54
+
55
+ -- lines and areas as GeoJSON text (with PostGIS a geometry column works the same way)
56
+ create table sample_maps.shape (
57
+ id int generated always as identity primary key,
58
+ name text not null,
59
+ geojson text not null
60
+ );
61
+
62
+ insert into sample_maps.shape (name, geojson) values
63
+ ('Museum walk: Anne Frank House to the Rijksmuseum',
64
+ '{"type": "LineString", "coordinates": [[4.8840, 52.3752], [4.8851, 52.3700], [4.8868, 52.3650], [4.8852, 52.3600], [4.8811, 52.3584]]}'),
65
+ ('Museumplein',
66
+ '{"type": "Polygon", "coordinates": [[[4.8790, 52.3590], [4.8850, 52.3590], [4.8855, 52.3565], [4.8795, 52.3560], [4.8790, 52.3590]]]}');
67
+
68
+ create table sample_maps.place (
69
+ id int generated always as identity primary key,
70
+ name text not null,
71
+ note text,
72
+ location text check (location ~ '^-?\d{1,2}(\.\d+)?,-?\d{1,3}(\.\d+)?$')
73
+ );
74
+
75
+ insert into sample_maps.place (name, note, location) values
76
+ ('Amsterdam Centraal', 'Trains every few minutes', '52.37890,4.90040'),
77
+ ('Vondelpark', 'Lunch in the park', '52.35800,4.86860');
78
+
79
+ -- 2. The application's database role ----------------------------------------
80
+ do $$ begin
81
+ if not exists (select 1 from pg_roles where rolname = 'app_sample_maps') then create role app_sample_maps nologin; end if;
82
+ end $$;
83
+ grant app_sample_maps to pgkiln_runtime;
84
+ grant usage on schema sample_maps to app_sample_maps;
85
+ grant select on sample_maps.museum, sample_maps.shape to app_sample_maps;
86
+ grant select, insert, update, delete on sample_maps.place to app_sample_maps;
87
+
88
+ -- 3. The application ----------------------------------------------------------
89
+ insert into meta.app (alias, name, home_page, authentication, db_role)
90
+ values ('sample-maps', 'Sample Maps', 1, 'app_users', 'app_sample_maps');
91
+
92
+ insert into meta.app_user (app_id, username, password_hash, roles)
93
+ select id, 'demo', meta.hash_password('demo'), '{}' from meta.app where alias = 'sample-maps';
94
+
95
+ insert into meta.page (app_id, page_no, name, title, parent_page)
96
+ select a.id, p.no, p.name, p.name, p.parent
97
+ from meta.app a, (values (1, 'Sample Maps', null), (2, 'Museums', 1), (3, 'Layers', 1), (4, 'Map and list', 1)) as p (no, name, parent)
98
+ where a.alias = 'sample-maps';
99
+
100
+ -- page 5 and its form, page 6: the Report and form wizard on the places
101
+ select meta.generate_page('sample-maps', 'report_form', 'sample_maps.place', 5, '{"label": "My places", "form_page": 6, "nav": false}');
102
+ update meta.page set parent_page = 1 where app_id = (select id from meta.app where alias = 'sample-maps') and page_no = 5;
103
+ update meta.item set type = 'location', help = 'Latitude,longitude; Use my location fills it in from the device.'
104
+ where page_id = pg_temp.page_id('sample-maps', 6) and name = 'P6_LOCATION';
105
+
106
+ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
107
+ select a.id, n.seq, n.label, n.icon, n.page
108
+ from meta.app a, (values
109
+ (10, 'Home', 'home', 1), (20, 'Museums', 'map', 2), (30, 'Layers', 'layers', 3),
110
+ (40, 'Map and list', 'list', 4), (50, 'My places', 'map', 5)
111
+ ) as n (seq, label, icon, page)
112
+ where a.alias = 'sample-maps';
113
+
114
+ insert into meta.list (app_id, name, type, description)
115
+ select id, 'MAP_TOPICS', 'static', 'The pages of Sample Maps' from meta.app where alias = 'sample-maps';
116
+
117
+ insert into meta.list_entry (app_id, list_name, seq, label, icon, target_page, description)
118
+ select a.id, 'MAP_TOPICS', e.seq, e.label, e.icon, e.page, e.description
119
+ from meta.app a, (values
120
+ (10, 'Museums', 'map', 2, 'Markers from latitude and longitude columns, popups, and clusters that open as you zoom in.'),
121
+ (20, 'Layers', 'layers', 3, 'Several queries on one map: markers, a route and an area as GeoJSON, a heat map you switch on.'),
122
+ (30, 'Map and list', 'list', 4, 'Move the map, then show only the museums within that distance in the list beside it.'),
123
+ (40, 'My places', 'map', 5, 'A form with a location item: Use my location stores latitude,longitude, the map shows them.')
124
+ ) as e (seq, label, icon, page, description)
125
+ where a.alias = 'sample-maps';
126
+
127
+ insert into meta.region (page_id, seq, title, type, columns, template, source, config, static_id)
128
+ select pg_temp.page_id('sample-maps', r.page), r.seq, r.title, r.type, r.cols, r.template, r.source, r.config::jsonb, r.sid
129
+ from (values
130
+ -- 1 Home
131
+ (1, 10, 'Sample Maps', 'static', 12, 'hero',
132
+ '<p>A map region draws the rows of a SELECT: a place per row from <code>lat</code> and <code>lng</code> columns (or a <code>location</code> column holding <code>latitude,longitude</code>), lines and areas from a <code>geojson</code> column. No GIS extension is needed; with PostGIS a <code>geometry</code> column works too, and filtering by area runs on the server.</p>',
133
+ '{"icon": "map"}', null),
134
+ (1, 20, 'Topics', 'list', 12, 'plain', null, '{"list": "MAP_TOPICS", "template": "cards"}', null),
135
+
136
+ -- 2 Museums
137
+ (2, 5, 'About this page', 'static', 12, 'plain',
138
+ '<p class="lead">One query, one marker per museum: <code>title</code> and <code>body</code> fill the popup. With <code>"cluster": true</code> markers that are close together become one round marker with their number; click it, or zoom in, and they come apart. Below the map the same places are listed, which is also what you get without JavaScript.</p>',
139
+ '{}', null),
140
+ (2, 10, 'Museums in the Netherlands', 'map', 12, 'standard',
141
+ E'select name as title, city || '' · '' || kind || '' · '' || to_char(visitors, ''FM9G999G999'') || '' visitors a year'' as body, lat, lng\n from sample_maps.museum order by name',
142
+ '{"cluster": true, "height": "large", "name": "Museums"}', 'museums'),
143
+
144
+ -- 3 Layers
145
+ (3, 5, 'About this page', 'static', 12, 'plain',
146
+ '<p class="lead">The region''s query is the first layer; <code>layers</code> adds more, each with its own query and settings. The walk and Museumplein come from a <code>geojson</code> column; the visitor heat map (<code>"layer": "heat"</code>, weighted by a <code>weight</code> column) starts hidden: switch it on in the <strong>Layers</strong> legend.</p>',
147
+ '{}', null),
148
+ (3, 10, 'Amsterdam', 'map', 12, 'standard',
149
+ E'select name as title, kind as body, lat, lng from sample_maps.museum where city = ''Amsterdam''',
150
+ '{"name": "Museums", "height": "large",
151
+ "layers": [{"name": "Walk and square", "source": "select name as title, geojson from sample_maps.shape"},
152
+ {"name": "Visitors", "layer": "heat", "hidden": true,
153
+ "source": "select lat, lng, visitors / 100000 as weight from sample_maps.museum"}]}', 'layers'),
154
+
155
+ -- 4 Map and list
156
+ (4, 5, 'About this page', 'static', 12, 'plain',
157
+ '<p class="lead">The map has <code>"report"</code>: the id of the report beside it, and <code>"filter": "distance"</code>. Move or zoom the map and press <strong>Show places within … km of the centre</strong>: the report keeps only the museums within that distance (a removable chip), filtered in its SQL on the server. The report needs position columns like the map''s.</p>',
158
+ '{}', null),
159
+ (4, 10, 'Map', 'map', 6, 'standard',
160
+ 'select name as title, city as body, lat, lng from sample_maps.museum', '{"filter": "distance", "zoom": 8}', 'filter-map'),
161
+ (4, 20, 'Museums', 'report', 6, 'standard',
162
+ 'select name, city, kind, visitors, lat, lng from sample_maps.museum order by visitors desc',
163
+ '{"hidden": ["lat", "lng"], "page_size": 20, "formats": {"visitors": "999G999G999"}}', 'filter-list'),
164
+
165
+ -- 5 My places (the wizard's report follows at seq 10)
166
+ (5, 5, 'About this page', 'static', 12, 'plain',
167
+ '<p class="lead">The form''s <strong>Location</strong> is a location item: <em>Use my location</em> asks the browser for the device''s position and stores it as <code>latitude,longitude</code> text, which a check constraint on the column keeps in shape. The map reads the same column.</p>',
168
+ '{}', null),
169
+ (5, 20, 'On the map', 'map', 12, 'standard',
170
+ 'select id, name as title, note as body, location from sample_maps.place where location is not null',
171
+ '{"link": {"page": 6, "items": {"P6_ID": "#id#"}}, "empty": "No place has a location yet."}', null)
172
+ ) as r (page, seq, title, type, cols, template, source, config, sid);
173
+
174
+ update meta.region set config = config || jsonb_build_object('report', pg_temp.region_id('sample-maps', 4, 'filter-list'))
175
+ where id = pg_temp.region_id('sample-maps', 4, 'filter-map');
@@ -122,7 +122,7 @@ select pg_temp.page_id('sample-reporting', r.page), r.seq, r.title, r.type, r.co
122
122
 
123
123
  -- 5 Smart filters
124
124
  (5, 5, 'About this page', 'static', 12, 'plain',
125
- '<p class="lead">A <code>smart_filters</code> region is the compact alternative: one search field above the report, the filters in use as chips and suggestions below it. Type <em>dutch</em> or <em>crime</em> and choose a suggestion.</p>',
125
+ '<p class="lead">A <code>smart_filters</code> region is the compact alternative: one search field above the report, the filters in use as chips and suggestions below it. Type <em>dutch</em> or <em>crime</em> and choose a suggestion. With <code>"ask": true</code> it also reads a phrase: type <em>novels published between 1850 and 1900</em> and choose <strong>Understood as</strong>.</p>',
126
126
  '{}', null),
127
127
  (5, 10, 'Search', 'smart_filters', 12, 'plain', null, '{}', 'smart'),
128
128
  (5, 20, 'Books', 'report', 12, 'standard',
@@ -144,8 +144,9 @@ select pg_temp.page_id('sample-reporting', r.page), r.seq, r.title, r.type, r.co
144
144
 
145
145
  update meta.region set config = jsonb_build_object(
146
146
  'report', pg_temp.region_id('sample-reporting', 5, 'smart-report'),
147
- 'placeholder', 'Search or filter the books…',
147
+ 'placeholder', 'Search, filter, or type e.g. novels published between 1850 and 1900',
148
+ 'ask', true,
148
149
  'facets', '[{"column": "genre", "label": "Genre"}, {"column": "language", "label": "Language"},
149
- {"column": "published", "label": "Published", "type": "range",
150
+ {"column": "published", "label": "Published", "type": "range", "custom": true,
150
151
  "ranges": [{"to": 1900}, {"from": 1900, "to": 1950}, {"from": 1950}]}]'::jsonb)
151
152
  where id = pg_temp.region_id('sample-reporting', 5, 'smart');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.55.0",
3
+ "version": "0.56.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -15,7 +15,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
15
15
  [['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME","views":["row","group","chart"] (views besides the grid; left out: all three)}'],
16
16
  [['calendar'], '{"link":{...}}'],
17
17
  [['facets'], '{"report":<region id>,"search":true,"facets":[{"column":"job","label":"Job","exclude":true},{"column":"sal","type":"range","ranges":[{"to":1000},{"from":1000}],"custom":true},{"column":"rating","type":"star","max":5}]}'],
18
- [['smart_filters'], '{"report":<region id>,"suggestions":3,"placeholder":"…","facets":[…as facets]}'],
18
+ [['smart_filters'], '{"report":<region id>,"suggestions":3,"placeholder":"…","ask":true,"facets":[…as facets]}'],
19
19
  [['display_selector'], '{"style":"tabs"|"select","show_all":true,"remember":true}'],
20
20
  [['list'], '{"list":"NAME","template":"links"|"badges"|"cards"|"tabs"} (lists under Shared Components → Lists)'],
21
21
  [['*'], '{"display_selector":true} puts it in the page\'s display selector'],
@@ -356,6 +356,7 @@ export function mergeSmartFiltersSettings(config: Config, b: Body, a: Allowed):
356
356
  const n = Number(b.suggestions);
357
357
  set('suggestions', b.suggestions !== undefined && b.suggestions !== '' && Number.isInteger(n) && n >= 0 && n <= 10 && n !== 3 ? n : undefined);
358
358
  set('placeholder', b.placeholder?.trim().slice(0, 100) || undefined);
359
+ set('ask', b.ask === 'true' ? true : undefined);
359
360
  return out;
360
361
  }
361
362
 
@@ -647,7 +648,8 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
647
648
  ? html`<div class="field"><label class="label" for="${id('suggestions')}">Suggestions per facet (0–10)</label>
648
649
  <input id="${id('suggestions')}" name="suggestions" type="number" min="0" max="10" value="${cfg.suggestions ?? 3}"></div>
649
650
  <div class="field" data-wide><label class="label" for="${id('placeholder')}">Placeholder of the search field</label>
650
- <input id="${id('placeholder')}" name="placeholder" maxlength="100" value="${cfg.placeholder ?? ''}" placeholder="Search or filter…"></div>`
651
+ <input id="${id('placeholder')}" name="placeholder" maxlength="100" value="${cfg.placeholder ?? ''}" placeholder="Search or filter…"></div>
652
+ ${check('ask', 'Read the search as a phrase ("clerks with salary between 1000 and 3000") and offer it as filters', cfg.ask === true)}`
651
653
  : html`${check('search', 'A search field (searches all columns of the report)', cfg.search === true)}`}
652
654
  </div></fieldset>
653
655
  ${cols && 'error' in cols ? columnsHint(cols) : ''}
package/src/i18n/ar.ts CHANGED
@@ -406,6 +406,7 @@ export const ar: Record<MessageKey, string> = {
406
406
  'smart.suggested': "عوامل تصفية مقترحة:",
407
407
  'smart.matching': "التصفية حسب:",
408
408
  'smart.no_match': "لا يطابق أي عامل تصفية البحث؛ يُبحث عن النص في كل الأعمدة.",
409
+ 'smart.understood': "فُهم على أنه:",
409
410
  'rds.label': "المناطق",
410
411
  'rds.region': "المنطقة",
411
412
  'rds.show_all': "إظهار الكل",
package/src/i18n/cs.ts CHANGED
@@ -406,6 +406,7 @@ export const cs: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Navrhované filtry:",
407
407
  'smart.matching': "Filtrovat podle:",
408
408
  'smart.no_match': "Hledání neodpovídá žádný filtr; text se hledá ve všech sloupcích.",
409
+ 'smart.understood': "Pochopeno jako:",
409
410
  'rds.label': "Oblasti",
410
411
  'rds.region': "Oblast",
411
412
  'rds.show_all': "Zobrazit vše",
package/src/i18n/da.ts CHANGED
@@ -406,6 +406,7 @@ export const da: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Foreslåede filtre:",
407
407
  'smart.matching': "Filtrér på:",
408
408
  'smart.no_match': "Intet filter passer til søgningen; teksten søges i alle kolonner.",
409
+ 'smart.understood': "Forstået som:",
409
410
  'rds.label': "Regioner",
410
411
  'rds.region': "Region",
411
412
  'rds.show_all': "Vis alle",
package/src/i18n/de.ts CHANGED
@@ -406,6 +406,7 @@ export const de: Record<MessageKey, string> = {
406
406
  'smart.suggested': 'Vorgeschlagene Filter:',
407
407
  'smart.matching': 'Filtern nach:',
408
408
  'smart.no_match': 'Kein Filter passt zur Suche; der Text wird in allen Spalten gesucht.',
409
+ 'smart.understood': 'Verstanden als:',
409
410
  'rds.label': 'Regionen',
410
411
  'rds.region': 'Region',
411
412
  'rds.show_all': 'Alle anzeigen',
package/src/i18n/el.ts CHANGED
@@ -406,6 +406,7 @@ export const el: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Προτεινόμενα φίλτρα:",
407
407
  'smart.matching': "Φιλτράρισμα κατά:",
408
408
  'smart.no_match': "Κανένα φίλτρο δεν ταιριάζει· το κείμενο αναζητείται σε όλες τις στήλες.",
409
+ 'smart.understood': "Κατανοήθηκε ως:",
409
410
  'rds.label': "Περιοχές",
410
411
  'rds.region': "Περιοχή",
411
412
  'rds.show_all': "Εμφάνιση όλων",
package/src/i18n/es.ts CHANGED
@@ -406,6 +406,7 @@ export const es: Record<MessageKey, string> = {
406
406
  'smart.suggested': 'Filtros sugeridos:',
407
407
  'smart.matching': 'Filtrar por:',
408
408
  'smart.no_match': 'Ningún filtro coincide con la búsqueda; el texto se busca en todas las columnas.',
409
+ 'smart.understood': 'Entendido como:',
409
410
  'rds.label': 'Regiones',
410
411
  'rds.region': 'Región',
411
412
  'rds.show_all': 'Mostrar todo',
package/src/i18n/fi.ts CHANGED
@@ -406,6 +406,7 @@ export const fi: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Ehdotetut suodattimet:",
407
407
  'smart.matching': "Suodata:",
408
408
  'smart.no_match': "Mikään suodatin ei vastaa hakua; tekstiä haetaan kaikista sarakkeista.",
409
+ 'smart.understood': "Tulkittu näin:",
409
410
  'rds.label': "Alueet",
410
411
  'rds.region': "Alue",
411
412
  'rds.show_all': "Näytä kaikki",
package/src/i18n/fr.ts CHANGED
@@ -406,6 +406,7 @@ export const fr: Record<MessageKey, string> = {
406
406
  'smart.suggested': 'Filtres suggérés :',
407
407
  'smart.matching': 'Filtrer sur :',
408
408
  'smart.no_match': 'Aucun filtre ne correspond à la recherche ; le texte est recherché dans toutes les colonnes.',
409
+ 'smart.understood': 'Compris comme :',
409
410
  'rds.label': 'Régions',
410
411
  'rds.region': 'Région',
411
412
  'rds.show_all': 'Tout afficher',
package/src/i18n/he.ts CHANGED
@@ -406,6 +406,7 @@ export const he: Record<MessageKey, string> = {
406
406
  'smart.suggested': "מסננים מוצעים:",
407
407
  'smart.matching': "סינון לפי:",
408
408
  'smart.no_match': "אין מסנן שמתאים לחיפוש; הטקסט מחופש בכל העמודות.",
409
+ 'smart.understood': "הובן כ:",
409
410
  'rds.label': "אזורים",
410
411
  'rds.region': "אזור",
411
412
  'rds.show_all': "הצגת הכול",
package/src/i18n/it.ts CHANGED
@@ -406,6 +406,7 @@ export const it: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Filtri suggeriti:",
407
407
  'smart.matching': "Filtra per:",
408
408
  'smart.no_match': "Nessun filtro corrisponde alla ricerca; il testo viene cercato in tutte le colonne.",
409
+ 'smart.understood': "Interpretato come:",
409
410
  'rds.label': "Regioni",
410
411
  'rds.region': "Regione",
411
412
  'rds.show_all': "Mostra tutto",
package/src/i18n/ja.ts CHANGED
@@ -406,6 +406,7 @@ export const ja: Record<MessageKey, string> = {
406
406
  'smart.suggested': "おすすめのフィルター:",
407
407
  'smart.matching': "絞り込み:",
408
408
  'smart.no_match': "検索に一致するフィルターはありません。テキストはすべての列で検索されます。",
409
+ 'smart.understood': "次のように解釈:",
409
410
  'rds.label': "リージョン",
410
411
  'rds.region': "リージョン",
411
412
  'rds.show_all': "すべて表示",
package/src/i18n/ko.ts CHANGED
@@ -406,6 +406,7 @@ export const ko: Record<MessageKey, string> = {
406
406
  'smart.suggested': "추천 필터:",
407
407
  'smart.matching': "필터 조건:",
408
408
  'smart.no_match': "검색과 일치하는 필터가 없어 모든 열에서 텍스트를 검색합니다.",
409
+ 'smart.understood': "이렇게 이해함:",
409
410
  'rds.label': "영역",
410
411
  'rds.region': "영역",
411
412
  'rds.show_all': "모두 표시",
package/src/i18n/nb.ts CHANGED
@@ -406,6 +406,7 @@ export const nb: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Foreslåtte filtre:",
407
407
  'smart.matching': "Filtrer på:",
408
408
  'smart.no_match': "Ingen filtre passer til søket; teksten søkes i alle kolonner.",
409
+ 'smart.understood': "Forstått som:",
409
410
  'rds.label': "Regioner",
410
411
  'rds.region': "Region",
411
412
  'rds.show_all': "Vis alle",
package/src/i18n/pl.ts CHANGED
@@ -406,6 +406,7 @@ export const pl: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Sugerowane filtry:",
407
407
  'smart.matching': "Filtruj według:",
408
408
  'smart.no_match': "Żaden filtr nie pasuje do wyszukiwania; tekst jest szukany we wszystkich kolumnach.",
409
+ 'smart.understood': "Zrozumiano jako:",
409
410
  'rds.label': "Regiony",
410
411
  'rds.region': "Region",
411
412
  'rds.show_all': "Pokaż wszystkie",
package/src/i18n/pt.ts CHANGED
@@ -406,6 +406,7 @@ export const pt: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Filtros sugeridos:",
407
407
  'smart.matching': "Filtrar por:",
408
408
  'smart.no_match': "Nenhum filtro corresponde à pesquisa; o texto é procurado em todas as colunas.",
409
+ 'smart.understood': "Entendido como:",
409
410
  'rds.label': "Regiões",
410
411
  'rds.region': "Região",
411
412
  'rds.show_all': "Mostrar tudo",
package/src/i18n/ru.ts CHANGED
@@ -406,6 +406,7 @@ export const ru: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Предлагаемые фильтры:",
407
407
  'smart.matching': "Фильтровать по:",
408
408
  'smart.no_match': "Ни один фильтр не подходит; текст ищется во всех столбцах.",
409
+ 'smart.understood': "Понято как:",
409
410
  'rds.label': "Области",
410
411
  'rds.region': "Область",
411
412
  'rds.show_all': "Показать все",
package/src/i18n/sv.ts CHANGED
@@ -406,6 +406,7 @@ export const sv: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Föreslagna filter:",
407
407
  'smart.matching': "Filtrera på:",
408
408
  'smart.no_match': "Inget filter matchar sökningen; texten söks i alla kolumner.",
409
+ 'smart.understood': "Tolkat som:",
409
410
  'rds.label': "Regioner",
410
411
  'rds.region': "Region",
411
412
  'rds.show_all': "Visa alla",
package/src/i18n/tr.ts CHANGED
@@ -406,6 +406,7 @@ export const tr: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Önerilen süzgeçler:",
407
407
  'smart.matching': "Şuna göre süz:",
408
408
  'smart.no_match': "Aramayla eşleşen süzgeç yok; metin tüm sütunlarda aranır.",
409
+ 'smart.understood': "Şöyle anlaşıldı:",
409
410
  'rds.label': "Bölgeler",
410
411
  'rds.region': "Bölge",
411
412
  'rds.show_all': "Tümünü göster",
package/src/i18n/uk.ts CHANGED
@@ -406,6 +406,7 @@ export const uk: Record<MessageKey, string> = {
406
406
  'smart.suggested': "Запропоновані фільтри:",
407
407
  'smart.matching': "Фільтрувати за:",
408
408
  'smart.no_match': "Жоден фільтр не підходить; текст шукається в усіх стовпцях.",
409
+ 'smart.understood': "Зрозуміло як:",
409
410
  'rds.label': "Області",
410
411
  'rds.region': "Область",
411
412
  'rds.show_all': "Показати всі",
package/src/i18n/zh.ts CHANGED
@@ -406,6 +406,7 @@ export const zh: Record<MessageKey, string> = {
406
406
  'smart.suggested': "建议的筛选器:",
407
407
  'smart.matching': "筛选条件:",
408
408
  'smart.no_match': "没有与搜索匹配的筛选器;将在所有列中搜索该文本。",
409
+ 'smart.understood': "理解为:",
409
410
  'rds.label': "区域",
410
411
  'rds.region': "区域",
411
412
  'rds.show_all': "全部显示",
package/src/i18n.ts CHANGED
@@ -433,6 +433,7 @@ const en = {
433
433
  'smart.suggested': 'Suggested filters:',
434
434
  'smart.matching': 'Filter on:',
435
435
  'smart.no_match': 'No filter matches the search; the text is searched in all columns.',
436
+ 'smart.understood': 'Understood as:',
436
437
  'rds.label': 'Regions',
437
438
  'rds.region': 'Region',
438
439
  'rds.show_all': 'Show all',
@@ -1234,6 +1235,7 @@ const nl: Record<MessageKey, string> = {
1234
1235
  'smart.suggested': 'Voorgestelde filters:',
1235
1236
  'smart.matching': 'Filteren op:',
1236
1237
  'smart.no_match': 'Geen filter past bij de zoektekst; de tekst wordt in alle kolommen gezocht.',
1238
+ 'smart.understood': 'Begrepen als:',
1237
1239
  'rds.label': 'Regio\'s',
1238
1240
  'rds.region': 'Regio',
1239
1241
  'rds.show_all': 'Alles tonen',
@@ -11,6 +11,8 @@ import { REPORT_CHART_KINDS } from './charts.ts';
11
11
  import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
12
12
  import { applyPlan, checkPlan, chipsOf, ChipRefused, editChip, planIsEmpty, removeChip, type Chip, type Column, type Kind, type Plan } from './ir-plan.ts';
13
13
  import { parseQuestion, type Lookup } from './ir-phrases.ts';
14
+ import { applyFacetSettings, clearFacets, facetSettings } from './ask-facets.ts';
15
+ import { describeFacetFilter, reportFacetDefs, type FacetFilter } from './facet-state.ts';
14
16
  import { AGGREGATES, fieldsOf, headingOf, HIGHLIGHT_COLORS, isNumeric, opLabel, OPERATORS, q, reportState, visibleColumns, withComputations } from './report.ts';
15
17
  import { resolveRestRegion } from './rest-sources.ts';
16
18
  import { loadContext, simplePage, txContext, type Req } from './routes.ts';
@@ -37,6 +39,10 @@ import { loadContext, simplePage, txContext, type Req } from './routes.ts';
37
39
  // small form) and Remove; Undo returns to the settings before the last
38
40
  // change. All are buttons of one POST form (CSRF-checked), so they work
39
41
  // without JavaScript; the undo history is kept in the session.
42
+ //
43
+ // (sprint 122) With a faceted search or smart filters region on the same
44
+ // report, a condition one of its facets can hold sets that facet instead of
45
+ // a report filter (ask-facets.ts); the facets' selections are chips too.
40
46
 
41
47
  export { type Column } from './ir-plan.ts';
42
48
 
@@ -108,7 +114,7 @@ async function askColumns(ctx: PageContext, r: Region, params: URLSearchParams)
108
114
  }
109
115
 
110
116
  /** Stored values of the report's text columns matching the candidates (a query parameter): for "only clerks". */
111
- function valueLookup(ctx: PageContext, columns: Column[], src: string): Lookup {
117
+ export function valueLookup(ctx: PageContext, columns: Column[], src: string): Lookup {
112
118
  return async (column, candidates) => {
113
119
  const cols = columns.filter((c) => c.kind === 'text' && !c.computed && (column === null || c.name === column)).slice(0, MAX_LOOKUP_COLUMNS);
114
120
  const cands = [...new Set(candidates.map((x) => x.slice(0, 200).replace(/\0/g, '')).filter(Boolean))].slice(0, MAX_CANDIDATES);
@@ -234,10 +240,14 @@ export const checkAnswer = (json: unknown, columns: Column[]) => checkPlan(json,
234
240
 
235
241
  type T = PageContext['locale']['t'];
236
242
 
237
- function describePlan(p: Plan, t: T, label: (n: string) => string) {
243
+ /** The page's facets on the report (their selections are chips and Ask may set them). */
244
+ const pageFacets = (ctx: PageContext, rid: number) => reportFacetDefs(ctx.page.regions, rid);
245
+
246
+ function describePlan(p: Plan, t: T, label: (n: string) => string, facets: FacetFilter[] = []) {
238
247
  return [
239
248
  ...(p.reset ? [t('report.reset')] : []),
240
249
  ...p.filters.map((f) => `${label(f.column)} ${opLabel(t, f.operator)}${f.value ? ` ${f.value}` : ''}`),
250
+ ...facets.map((f) => describeFacetFilter(f, label(f.column), t)),
241
251
  ...(p.search ? [`"${p.search}"`] : []),
242
252
  ...(p.sort ? [t('ai_filter.sort', { column: `${p.sort.label}${p.desc ? ' ↓' : ''}` })] : []),
243
253
  ...p.highlights.map((h) => `${t('report.highlight')}: ${label(h.column)} ${opLabel(t, h.operator)}${h.value ? ` ${h.value}` : ''}`),
@@ -268,6 +278,8 @@ function chipText(chip: Chip, t: T, label: (n: string) => string, byPos: (pos: n
268
278
  case 'chart': return `${t('report.chart')}: ${t(`chart.${chip.type}`)}, ${label(chip.label)}, ${t(`agg.${chip.fn}`)}: ${label(chip.value)}`;
269
279
  case 'hide': return `${t('report.hidden_column')}: ${label(chip.column)}`;
270
280
  case 'area': case 'near': return t('report.map_area');
281
+ case 'facet': return describeFacetFilter({ kind: 'values', column: chip.column, values: [chip.value], exclude: chip.exclude }, label(chip.column), t);
282
+ case 'facetrange': return describeFacetFilter({ kind: 'range', column: chip.column, from: chip.from, to: chip.to, custom: chip.custom }, label(chip.column), t, chip.def);
271
283
  }
272
284
  }
273
285
 
@@ -287,7 +299,7 @@ export async function renderAiFilter(ctx: PageContext, r: Region): Promise<Raw |
287
299
  <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}"><input type="hidden" name="params" value="${params}"></form>`);
288
300
 
289
301
  // the chips: the report's settings in the URL, each with Edit and Remove
290
- const chips = chipsOf(ctx.params, r.id);
302
+ const chips = chipsOf(ctx.params, r.id, pageFacets(ctx, r.id));
291
303
  let columns: Column[] = [];
292
304
  let computeErrors: Awaited<ReturnType<typeof askColumns>>['errors'] = [];
293
305
  if (chips.length && ctx.client)
@@ -444,7 +456,7 @@ export async function aiFilterRoutes(app: FastifyInstance) {
444
456
  dbg(ctx, 6, 'ai', () => `report settings from the phrases: ${JSON.stringify(plan)}; not understood: ${JSON.stringify(parsed.left)}`);
445
457
  if (planIsEmpty(plan)) throw new AiError('output', [t('ai_filter.no_match'), ...(parsed.left.length ? [t('ai_filter.not_understood', { what: parsed.left.join('; ') })] : [])].join(' '));
446
458
  } else {
447
- const current = chipsOf(params, r.id).map((ch) => chipText(ch, t, label, (pos) => columns.find((c) => c.pos === pos)?.label ?? `#${pos}`)).join('; ');
459
+ const current = chipsOf(params, r.id, pageFacets(ctx, r.id)).map((ch) => chipText(ch, t, label, (pos) => columns.find((c) => c.pos === pos)?.label ?? `#${pos}`)).join('; ');
448
460
  const res = await generate(conf.service!, { system: planSystem(columns, new Date().toISOString().slice(0, 10), current), prompt: question, schema: planSchema(columns), maxTokens: 3000 }, {
449
461
  appId: ctx.app.id, pageNo: ctx.page.page_no, user: ctx.user, source: 'nl2ir', debug: (l, x) => dbg(ctx, l, 'ai', x),
450
462
  });
@@ -452,9 +464,13 @@ export async function aiFilterRoutes(app: FastifyInstance) {
452
464
  dbg(ctx, 6, 'ai', () => `report settings from the question: ${JSON.stringify(plan)}`);
453
465
  if (planIsEmpty(plan)) throw new AiError('output', t('ai_filter.nothing'));
454
466
  }
467
+ // conditions a facet on the page can hold set that facet (the facets of regions the user sees)
468
+ const facets = facetSettings(plan, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), columns);
469
+ if (plan.reset) clearFacets(params, r.id);
455
470
  applyPlan(params, r.id, plan);
471
+ applyFacetSettings(params, r.id, facets);
456
472
  if (params.toString() !== before) pushUndo(ctx, r.id, before);
457
- ctx.session.state.__FLASH = [t('ai_filter.applied', { what: describePlan(plan, t, label) }), ...(parsed?.left.length ? [t('ai_filter.not_understood', { what: parsed.left.join('; ') })] : [])].join(' ');
473
+ ctx.session.state.__FLASH = [t('ai_filter.applied', { what: describePlan(plan, t, label, facets) }), ...(parsed?.left.length ? [t('ai_filter.not_understood', { what: parsed.left.join('; ') })] : [])].join(' ');
458
474
  } catch (e) {
459
475
  if (e instanceof Forbidden) return refuse(`ai filter: region ${r.id} on page ${ctx.page.page_no}`);
460
476
  ctx.session.state.__FLASH_ERROR = e instanceof AiError ? e.message : await publicError(ctx, e, 'AI report filter');
@@ -476,7 +492,7 @@ export async function aiFilterRoutes(app: FastifyInstance) {
476
492
  await saveState(ctx.session);
477
493
  return back(reply, ctx, r.id, prev === null ? params : new URLSearchParams(prev));
478
494
  }
479
- const chips = chipsOf(params, r.id);
495
+ const chips = chipsOf(params, r.id, pageFacets(ctx, r.id));
480
496
  const which = typeof body.edit === 'string' ? body.edit : typeof body.remove === 'string' ? body.remove : '';
481
497
  const i = /^\d{1,3}$/.test(which) ? Number(which) : -1;
482
498
  const chip = chips[i];
@@ -0,0 +1,72 @@
1
+ import type { Column, Plan } from './ir-plan.ts';
2
+ import { CUSTOM_RANGE, facetKeys, facetParamNames, isFacetParam, rangeValue, validBound, type FacetDef, type FacetFilter } from './facet-state.ts';
3
+
4
+ // (sprint 122, WS-5) "Ask in your own words" on faceted search and smart
5
+ // filters (APEX 26.2: natural language for faceted search and smart filters).
6
+ // A condition that a facet on the page can hold is set on that facet instead
7
+ // of becoming a report filter, so the facet shows it (checked values, a chosen
8
+ // range, a smart filter chip) and its counts follow:
9
+ //
10
+ // job is clerk, job is analyst a checkbox facet: its values (one or more)
11
+ // job is not clerk ... excluded, when the facet allows exclude
12
+ // salary between 1000 and 3000 a range facet with custom ranges: from / to (at least, at most)
13
+ // rating at least 4 a star facet: "4 stars & up"
14
+ //
15
+ // Anything else (greater than, contains, a facet without custom ranges, a
16
+ // column without a facet) stays a report filter. The facets are those of the
17
+ // page's faceted search and smart filters regions on the report (columns
18
+ // checked there); values and bounds become the facets' own URL parameters,
19
+ // which facet-state.ts reads as query parameters, never SQL text.
20
+
21
+ /** Take the conditions the report's facets can hold out of the plan (plan.filters keeps the rest). */
22
+ export function facetSettings(plan: Plan, defs: Map<string, FacetDef>, columns: Column[]): FacetFilter[] {
23
+ const out: FacetFilter[] = [];
24
+ const taken = new Set<string>();
25
+ const byColumn = new Map<string, Plan['filters']>();
26
+ for (const f of plan.filters) if (defs.has(f.column)) byColumn.set(f.column, [...(byColumn.get(f.column) ?? []), f]);
27
+ for (const [column, list] of byColumn) {
28
+ const def = defs.get(column)!;
29
+ const ops = new Set(list.map((f) => f.operator));
30
+ let s: FacetFilter | null = null;
31
+ if (def.type === 'checkbox') {
32
+ const values = [...new Set(list.map((f) => f.value))].filter((v) => v !== '');
33
+ if (values.length === list.length && ops.size === 1 && (ops.has('eq') || (ops.has('ne') && def.exclude)))
34
+ s = { column, kind: 'values', values, exclude: ops.has('ne') };
35
+ } else if (def.type === 'star') {
36
+ const n = Number(list[0].value);
37
+ if (list.length === 1 && ops.has('ge') && Number.isInteger(n) && n >= 1 && n <= def.max) s = { column, kind: 'range', from: String(n), to: '', custom: false };
38
+ } else if (def.custom) {
39
+ const kind = columns.find((c) => c.name === column)?.kind;
40
+ const from = list.find((f) => f.operator === 'ge')?.value ?? '';
41
+ const to = list.find((f) => f.operator === 'le')?.value ?? '';
42
+ if ((kind === 'number' || kind === 'date') && list.length <= 2 && [...ops].every((o) => o === 'ge' || o === 'le') && ops.size === list.length && validBound(from, kind) && validBound(to, kind))
43
+ s = { column, kind: 'range', from, to, custom: true };
44
+ }
45
+ if (s) (out.push(s), taken.add(column));
46
+ }
47
+ plan.filters = plan.filters.filter((f) => !taken.has(f.column));
48
+ return out;
49
+ }
50
+
51
+ /** Set each facet as given (replacing what that facet had). */
52
+ export function applyFacetSettings(params: URLSearchParams, rid: number, settings: FacetFilter[]) {
53
+ const k = facetKeys(rid);
54
+ for (const s of settings) {
55
+ for (const n of facetParamNames(rid, s.column)) params.delete(n);
56
+ if (s.kind === 'values') {
57
+ for (const v of s.values) params.append(k.values + s.column, v);
58
+ if (s.exclude) params.set(k.exclude + s.column, '1');
59
+ } else if (!s.custom) params.set(k.range + s.column, rangeValue(s));
60
+ else {
61
+ params.set(k.range + s.column, CUSTOM_RANGE);
62
+ if (s.from) params.set(k.from + s.column, s.from);
63
+ if (s.to) params.set(k.to + s.column, s.to);
64
+ }
65
+ }
66
+ if (settings.length) params.delete(`r${rid}_p`);
67
+ }
68
+
69
+ /** "Start over" clears the report's facets too. */
70
+ export function clearFacets(params: URLSearchParams, rid: number) {
71
+ for (const n of [...new Set(params.keys())]) if (isFacetParam(rid, n)) params.delete(n);
72
+ }