pgkiln 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/SECURITY.md +2 -0
  2. package/docs/apex-feature-parity.md +4 -4
  3. package/docs/guide/03-builder.md +11 -4
  4. package/docs/guide/04-pages-and-regions.md +26 -0
  5. package/docs/guide/12-development.md +14 -0
  6. package/examples/gallery/README.md +4 -0
  7. package/examples/gallery/gallery.json +8 -0
  8. package/examples/gallery/samples/sample-approvals.sql +153 -0
  9. package/examples/gallery/samples/sample-data-loading.sql +142 -0
  10. package/examples/gallery/samples/sample-files.sql +160 -0
  11. package/examples/gallery/samples/sample-maps.sql +175 -0
  12. package/examples/gallery/samples/sample-reporting.sql +4 -3
  13. package/package.json +1 -1
  14. package/public/builder.css +1 -1
  15. package/public/worksheet.js +31 -26
  16. package/src/builder/i18n/de.ts +371 -0
  17. package/src/builder/i18n/en.ts +371 -0
  18. package/src/builder/i18n/es.ts +371 -0
  19. package/src/builder/i18n/fr.ts +371 -0
  20. package/src/builder/i18n/nl.ts +371 -0
  21. package/src/builder/i18n/pt.ts +371 -0
  22. package/src/builder/i18n.ts +10 -0
  23. package/src/builder/object-browser.ts +71 -67
  24. package/src/builder/region-help.ts +1 -1
  25. package/src/builder/region-settings.ts +3 -1
  26. package/src/builder/table-editor.ts +51 -48
  27. package/src/builder/ui.ts +3 -2
  28. package/src/builder/worksheet.ts +160 -154
  29. package/src/i18n/ar.ts +1 -0
  30. package/src/i18n/cs.ts +1 -0
  31. package/src/i18n/da.ts +1 -0
  32. package/src/i18n/de.ts +1 -0
  33. package/src/i18n/el.ts +1 -0
  34. package/src/i18n/es.ts +1 -0
  35. package/src/i18n/fi.ts +1 -0
  36. package/src/i18n/fr.ts +1 -0
  37. package/src/i18n/he.ts +1 -0
  38. package/src/i18n/it.ts +1 -0
  39. package/src/i18n/ja.ts +1 -0
  40. package/src/i18n/ko.ts +1 -0
  41. package/src/i18n/nb.ts +1 -0
  42. package/src/i18n/pl.ts +1 -0
  43. package/src/i18n/pt.ts +1 -0
  44. package/src/i18n/ru.ts +1 -0
  45. package/src/i18n/sv.ts +1 -0
  46. package/src/i18n/tr.ts +1 -0
  47. package/src/i18n/uk.ts +1 -0
  48. package/src/i18n/zh.ts +1 -0
  49. package/src/i18n.ts +2 -0
  50. package/src/runtime/ai-filter.ts +22 -6
  51. package/src/runtime/ask-facets.ts +72 -0
  52. package/src/runtime/ir-plan.ts +17 -3
  53. package/src/runtime/smart-filters.ts +36 -2
@@ -0,0 +1,160 @@
1
+ -- =====================================================================
2
+ -- Sample app "Sample Files" (App Gallery → Sample Apps)
3
+ --
4
+ -- File upload and download: a file per record in the table's own columns
5
+ -- (name, type, size and date kept beside it), several files per record in a
6
+ -- child table, and a picture cropped in the browser before it is uploaded.
7
+ -- Every download link is bound to the user and the record; files are read
8
+ -- as the application's role and sent as attachments.
9
+ --
10
+ -- Installed by an administrator from the App Gallery, or with
11
+ -- psql "$DATABASE_URL" -1 -f examples/gallery/samples/sample-files.sql
12
+ -- then open /a/sample-files 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
+
18
+ -- 1. Schema and data ---------------------------------------------------------
19
+ create schema sample_files;
20
+
21
+ create table sample_files.document (
22
+ id int generated always as identity primary key,
23
+ title text not null,
24
+ category text not null default 'General' check (category in ('General', 'Contract', 'Invoice', 'Manual')),
25
+ content bytea check (octet_length(content) <= 5 * 1024 * 1024),
26
+ filename text,
27
+ mime_type text,
28
+ file_size int,
29
+ updated timestamptz
30
+ );
31
+
32
+ insert into sample_files.document (title, category, content, filename, mime_type, file_size, updated)
33
+ select t, c, convert_to(body, 'utf8'), f, 'text/plain', octet_length(convert_to(body, 'utf8')), now()
34
+ from (values
35
+ ('Read me', 'General', 'readme.txt', E'Sample Files\n\nUpload a PDF, a picture or a text file on the document form.\n'),
36
+ ('Opening hours', 'Manual', 'opening-hours.txt', E'Monday to Friday 9:00-17:00\nSaturday 10:00-14:00\n')
37
+ ) as d (t, c, f, body);
38
+ insert into sample_files.document (title, category) values ('Supplier contract (no file yet)', 'Contract');
39
+
40
+ create table sample_files.folder (
41
+ id int generated always as identity primary key,
42
+ name text not null unique,
43
+ description text
44
+ );
45
+
46
+ create table sample_files.attachment (
47
+ id int generated always as identity primary key,
48
+ folder_id int not null references sample_files.folder on delete cascade,
49
+ filename text not null,
50
+ mime_type text not null,
51
+ content bytea not null check (octet_length(content) <= 5 * 1024 * 1024)
52
+ );
53
+
54
+ insert into sample_files.folder (name, description) values
55
+ ('Project Alpha', 'Plans and minutes'), ('Office move', 'Floor plans and the moving checklist');
56
+ insert into sample_files.attachment (folder_id, filename, mime_type, content)
57
+ select f.id, a.filename, 'text/plain', convert_to(a.body, 'utf8')
58
+ from sample_files.folder f join (values
59
+ ('Project Alpha', 'minutes-week-1.txt', E'Kick-off: scope agreed, next meeting Thursday.\n'),
60
+ ('Project Alpha', 'plan.txt', E'1. Design\n2. Build\n3. Test\n'),
61
+ ('Office move', 'checklist.txt', E'- Boxes\n- Labels\n- Network\n')
62
+ ) as a (folder, filename, body) on a.folder = f.name;
63
+
64
+ create table sample_files.person (
65
+ id int generated always as identity primary key,
66
+ name text not null,
67
+ photo bytea check (octet_length(photo) <= 2 * 1024 * 1024),
68
+ photo_name text,
69
+ photo_mime text
70
+ );
71
+ insert into sample_files.person (name) values ('Ada'), ('Grace'), ('Linus');
72
+
73
+ -- 2. The application's database role ----------------------------------------
74
+ do $$ begin
75
+ if not exists (select 1 from pg_roles where rolname = 'app_sample_files') then create role app_sample_files nologin; end if;
76
+ end $$;
77
+ grant app_sample_files to pgkiln_runtime;
78
+ grant usage on schema sample_files to app_sample_files;
79
+ grant select, insert, update, delete on all tables in schema sample_files to app_sample_files;
80
+
81
+ -- 3. The application ----------------------------------------------------------
82
+ insert into meta.app (alias, name, home_page, authentication, db_role)
83
+ values ('sample-files', 'Sample Files', 1, 'app_users', 'app_sample_files');
84
+
85
+ insert into meta.app_user (app_id, username, password_hash, roles)
86
+ select id, 'demo', meta.hash_password('demo'), '{}' from meta.app where alias = 'sample-files';
87
+
88
+ insert into meta.page (app_id, page_no, name, title)
89
+ select id, 1, 'Sample Files', 'Sample Files' from meta.app where alias = 'sample-files';
90
+
91
+ -- the Report and form wizard: documents (2, 3), folders (4, 5), people (6, 7)
92
+ select meta.generate_page('sample-files', 'report_form', 'sample_files.document', 2, '{"label": "Documents", "form_page": 3, "nav": false}');
93
+ select meta.generate_page('sample-files', 'report_form', 'sample_files.folder', 4, '{"label": "Folders", "form_page": 5, "nav": false}');
94
+ select meta.generate_page('sample-files', 'report_form', 'sample_files.person', 6, '{"label": "People", "form_page": 7, "nav": false}');
95
+ update meta.page set parent_page = 1 where app_id = (select id from meta.app where alias = 'sample-files') and page_no in (2, 4, 6);
96
+
97
+ -- the reports leave the file contents out
98
+ update meta.region set source = 'select id, title, category, filename, file_size, updated from sample_files.document order by title',
99
+ config = config || '{"headings": {"file_size": "Size (bytes)"}}'
100
+ where page_id = pg_temp.page_id('sample-files', 2) and type = 'report';
101
+ update meta.region set source = E'select f.id, f.name, f.description, count(a.id) as files\n from sample_files.folder f left join sample_files.attachment a on a.folder_id = f.id\n group by f.id order by f.name'
102
+ where page_id = pg_temp.page_id('sample-files', 4) and type = 'report';
103
+ update meta.region set source = 'select id, name, photo_name from sample_files.person order by name',
104
+ config = config || '{"headings": {"photo_name": "Photo"}}'
105
+ where page_id = pg_temp.page_id('sample-files', 6) and type = 'report';
106
+
107
+ -- page 3: one file in the document's own columns; its name, type, size and date are kept beside it
108
+ delete from meta.item where page_id = pg_temp.page_id('sample-files', 3) and name in ('P3_FILENAME', 'P3_MIME_TYPE', 'P3_FILE_SIZE', 'P3_UPDATED');
109
+ update meta.item set type = 'file', label = 'File', help = 'A PDF, a picture, a text or a Word file of at most 5 MB.',
110
+ config = '{"filename_column": "filename", "mime_column": "mime_type", "size_column": "file_size", "updated_column": "updated",
111
+ "accept": ".pdf,image/*,.txt,.csv,.docx", "max_mb": 5}'
112
+ where page_id = pg_temp.page_id('sample-files', 3) and name = 'P3_CONTENT';
113
+ update meta.item set type = 'select', lov = 'STATIC:General,Contract,Invoice,Manual'
114
+ where page_id = pg_temp.page_id('sample-files', 3) and name = 'P3_CATEGORY';
115
+
116
+ -- page 5: several files per folder, in the child table sample_files.attachment
117
+ insert into meta.item (page_id, region_id, seq, name, label, type, source_column, help, config)
118
+ select r.page_id, r.id, 100, 'P5_FILES', 'Files', 'file', 'content', 'Up to five files of at most 5 MB each.',
119
+ '{"multiple": true, "max_files": 5, "max_mb": 5, "table": "sample_files.attachment", "parent_column": "folder_id",
120
+ "key_column": "id", "filename_column": "filename", "mime_column": "mime_type", "wide": true}'
121
+ from meta.region r where r.page_id = pg_temp.page_id('sample-files', 5) and r.type = 'form';
122
+
123
+ -- page 7: a picture, cropped square in the browser before it is uploaded
124
+ delete from meta.item where page_id = pg_temp.page_id('sample-files', 7) and name in ('P7_PHOTO_NAME', 'P7_PHOTO_MIME');
125
+ update meta.item set type = 'file', label = 'Photo', help = 'A picture: choose the square part to keep.',
126
+ config = '{"filename_column": "photo_name", "mime_column": "photo_mime", "accept": "image/png,image/jpeg,image/webp", "max_mb": 2,
127
+ "crop": "1:1", "max_px": 800, "capture": "user"}'
128
+ where page_id = pg_temp.page_id('sample-files', 7) and name = 'P7_PHOTO';
129
+
130
+ insert into meta.nav_entry (app_id, seq, label, icon, target_page)
131
+ select a.id, n.seq, n.label, n.icon, n.page
132
+ from meta.app a, (values (10, 'Home', 'home', 1), (20, 'Documents', 'file', 2), (30, 'Folders', 'folder', 4), (40, 'People', 'users', 6)) as n (seq, label, icon, page)
133
+ where a.alias = 'sample-files';
134
+
135
+ insert into meta.list (app_id, name, type, description)
136
+ select id, 'FILE_TOPICS', 'static', 'The pages of Sample Files' from meta.app where alias = 'sample-files';
137
+
138
+ insert into meta.list_entry (app_id, list_name, seq, label, icon, target_page, description)
139
+ select a.id, 'FILE_TOPICS', e.seq, e.label, e.icon, e.page, e.description
140
+ from meta.app a, (values
141
+ (10, 'Documents', 'file', 2, 'A file in the record''s own bytea column, with its name, type, size and date beside it.'),
142
+ (20, 'Folders', 'folder', 4, 'Several files per record in a child table: add some, remove some, all saved with the record.'),
143
+ (30, 'People', 'users', 6, 'A photo cropped square in the browser and made smaller before it is uploaded.')
144
+ ) as e (seq, label, icon, page, description)
145
+ where a.alias = 'sample-files';
146
+
147
+ insert into meta.region (page_id, seq, title, type, columns, template, source, config)
148
+ select pg_temp.page_id('sample-files', r.page), r.seq, r.title, r.type, 12, r.template, r.source, r.config::jsonb
149
+ from (values
150
+ (1, 10, 'Sample Files', 'static', 'hero',
151
+ '<p>A file item uploads into a <code>bytea</code> column, or into a child table with a row per file. Choose, drop or paste a file; the server checks its type and size, and the form saves it with the record in one transaction. Download links are bound to the user and the record, the file is read as the application''s role (grants and row level security apply), and it is sent as an attachment, so an uploaded HTML or SVG file never runs in the application.</p>',
152
+ '{"icon": "file"}'),
153
+ (1, 20, 'Topics', 'list', 'plain', null, '{"list": "FILE_TOPICS", "template": "cards"}'),
154
+ (2, 5, 'About this page', 'static', 'plain',
155
+ '<p class="lead">Open a document: the form shows its file with a download link (pictures with a preview) and a <strong>Remove file</strong> box. The file item''s <code>filename_column</code>, <code>mime_column</code>, <code>size_column</code> and <code>updated_column</code> fill those columns on upload; <code>accept</code> and <code>max_mb</code> limit what may be uploaded.</p>', '{}'),
156
+ (4, 5, 'About this page', 'static', 'plain',
157
+ '<p class="lead">Open a folder: <code>"multiple": true</code> with <code>table</code> and <code>parent_column</code> keeps a row per file in <code>sample_files.attachment</code>. The form lists the folder''s files with download links; tick <strong>Remove file</strong> to delete one on save, or add up to five at once.</p>', '{}'),
158
+ (6, 5, 'About this page', 'static', 'plain',
159
+ '<p class="lead">Open a person and choose a photo: with <code>"crop": "1:1"</code> the browser asks which square part to keep, and <code>max_px</code> makes it smaller before the upload. On a phone, <code>capture</code> offers the camera.</p>', '{}')
160
+ ) as r (page, seq, title, type, template, source, config);
@@ -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.57.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
@@ -1216,7 +1216,7 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
1216
1216
  .ob-folder > summary .kind { margin-left: auto; }
1217
1217
  .ob-folder .tree { padding-left: 0.75rem; }
1218
1218
  .ob-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.5rem; margin-bottom: 0.75rem; }
1219
- .ob-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.5rem; overflow-wrap: anywhere; }
1219
+ .ob-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0 0.5rem; overflow-wrap: anywhere; }
1220
1220
  .ob-actions { display: grid; gap: 0.6rem; margin-top: 1rem; }
1221
1221
  .ob-action { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
1222
1222
  .ob-action input:not([type=checkbox]) { width: 10rem; }
@@ -21,6 +21,9 @@
21
21
  'use strict';
22
22
  const form = document.getElementById('ws');
23
23
  if (!form) return;
24
+ // (sprint 123) texts in the builder's language: #builder-texts (shell(), keys js.*), English in en.ts
25
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
26
+ const msg = (key, params = {}) => (TEXTS[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
24
27
  const ta = form.querySelector('textarea[name=sql]');
25
28
  const out = form.querySelector('.ws-output');
26
29
  const store = {
@@ -149,9 +152,9 @@
149
152
  const copy = async (text, what) => {
150
153
  try {
151
154
  await navigator.clipboard.writeText(text);
152
- announce(`${what} copied.`);
155
+ announce(msg('js.ws.copied', { what }));
153
156
  } catch {
154
- announce('Copying is not allowed here.');
157
+ announce(msg('js.ws.no_copy'));
155
158
  }
156
159
  };
157
160
  const announce = (msg) => {
@@ -165,7 +168,7 @@
165
168
  if (e.target.closest('[data-ws-copy]')) {
166
169
  const head = [...grid.tHead.rows[0].cells].slice(1).map((th) => th.textContent.trim());
167
170
  const rows = [...grid.tBodies[0].rows].map((tr) => [...tr.cells].slice(1).map((td) => cellValue(td).replace(/[\t\n]/g, ' ')));
168
- copy([head, ...rows].map((r) => r.join('\t')).join('\n'), `${rows.length} rows`);
171
+ copy([head, ...rows].map((r) => r.join('\t')).join('\n'), msg('js.ws.rows', { n: rows.length }));
169
172
  }
170
173
  const sort = e.target.closest('[data-ws-sort]');
171
174
  if (sort) {
@@ -218,7 +221,7 @@
218
221
  run('statement');
219
222
  }
220
223
  const c = e.target.closest('[data-ws-copy-text]');
221
- if (c) copy(c.dataset.wsCopyText, 'The statement');
224
+ if (c) copy(c.dataset.wsCopyText, msg('js.ws.the_statement'));
222
225
  });
223
226
 
224
227
  // ------------------------------------------------------------ (sprint 93) worksheets as tabs, kept in this browser
@@ -226,7 +229,7 @@
226
229
  const readTabs = () => { try { const v = JSON.parse(store.get('tabs') || 'null'); return v && Array.isArray(v.tabs) ? v : null; } catch { return null; } };
227
230
  const book = readTabs() || { active: '', tabs: [] };
228
231
  const newKey = () => Math.random().toString(36).slice(2, 10);
229
- const untitled = () => { let n = 1; while (book.tabs.some((t) => t.name === `Untitled ${n}`)) n++; return `Untitled ${n}`; };
232
+ const untitled = () => { let n = 1; while (book.tabs.some((x) => x.name === msg('js.ws.untitled', { n }))) n++; return msg('js.ws.untitled', { n }); };
230
233
  const src = form.dataset.source;
231
234
  const sid = form.dataset.scriptId ? Number(form.dataset.scriptId) : null;
232
235
  const sname = form.dataset.scriptName;
@@ -273,16 +276,16 @@
273
276
  if (t === cur) b.setAttribute('aria-current', 'true');
274
277
  b.dataset.wsWtab = t.key;
275
278
  b.textContent = `${t.name}${dirty(t) ? '*' : ''}`;
276
- b.title = t.scriptId ? `Script “${t.name}”${dirty(t) ? ' (unsaved changes)' : ''}` : 'Not saved as a script';
279
+ b.title = t.scriptId ? `${msg('js.ws.script', { name: t.name })}${dirty(t) ? ` ${msg('js.ws.unsaved')}` : ''}` : msg('js.ws.not_saved');
277
280
  const x = document.createElement('button');
278
281
  x.type = 'button';
279
282
  x.className = 'ws-wtab-close';
280
283
  x.dataset.wsWclose = t.key;
281
- x.setAttribute('aria-label', `Close ${t.name}`);
284
+ x.setAttribute('aria-label', msg('js.ws.close_tab', { name: t.name }));
282
285
  x.textContent = '×';
283
286
  wrap.append(b, x);
284
287
  return wrap;
285
- }), Object.assign(document.createElement('button'), { type: 'button', className: 'ws-wtab-new', title: 'New worksheet', textContent: '+' }));
288
+ }), Object.assign(document.createElement('button'), { type: 'button', className: 'ws-wtab-new', title: msg('js.ws.new_tab'), textContent: '+' }));
286
289
  persist();
287
290
  };
288
291
  const switchTo = (t) => {
@@ -297,8 +300,8 @@
297
300
  const newTab = () => switchTo(add({ key: newKey(), name: untitled(), scriptId: null, text: '', saved: '' }));
298
301
  const closeTab = (t) => {
299
302
  const txnOpen = t === cur ? txnBox.dataset.state !== 'none' : !!t.txnOpen;
300
- if (txnOpen && !window.confirm(`“${t.name}” has an open transaction. Close it and roll the transaction back?`)) return;
301
- if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(`Close “${t.name}” without saving?`)) return;
303
+ if (txnOpen && !window.confirm(msg('js.ws.close_txn', { name: t.name }))) return;
304
+ if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(msg('js.ws.close_unsaved', { name: t.name }))) return;
302
305
  if (t.txn) txnPost(t.key, 'rollback'); // the server knows best whether one is open
303
306
  const i = book.tabs.indexOf(t);
304
307
  book.tabs.splice(i, 1);
@@ -360,15 +363,15 @@
360
363
  const rollbackBtn = txnBox.querySelector('[data-ws-txn-rollback]');
361
364
  const commitNote = form.querySelector('[data-ws-commit-note]');
362
365
  const idleSeconds = Number(txnBox.dataset.idle) || 300;
363
- const idleWords = idleSeconds % 60 ? `${idleSeconds} seconds` : `${idleSeconds / 60} minute${idleSeconds === 60 ? '' : 's'}`;
366
+ const idleWords = idleSeconds % 60 ? msg('js.ws.idle_seconds', { n: idleSeconds }) : msg(idleSeconds === 60 ? 'js.ws.idle_minute' : 'js.ws.idle_minutes', { n: idleSeconds / 60 });
364
367
  let txnInfo = { state: txnBox.dataset.state, statements: Number(txnBox.dataset.statements) || 0, startedMs: Number(txnBox.dataset.started) || null,
365
368
  lastMs: Number(txnBox.dataset.last) || null, note: txnBox.dataset.note || null };
366
369
  let clockSkew = Date.now() - (Number(txnBox.dataset.now) || Date.now());
367
370
  let txnTimer = 0;
368
371
  const badgeText = (i) => {
369
- if (i.state === 'none') return i.note || 'No open transaction';
372
+ if (i.state === 'none') return i.note || msg('js.ws.txn_none');
370
373
  const min = i.startedMs ? Math.floor((Date.now() - clockSkew - i.startedMs) / 60000) : 0;
371
- return `${i.state === 'aborted' ? 'Transaction failed' : 'Transaction open'} · ${i.statements} statement${i.statements === 1 ? '' : 's'} · ${min < 1 ? 'under a minute' : `${min} min`}`;
374
+ return `${msg(i.state === 'aborted' ? 'js.ws.txn_failed' : 'js.ws.txn_open')} · ${msg(i.statements === 1 ? 'js.ws.stmt_one' : 'js.ws.stmt', { n: i.statements })} · ${min < 1 ? msg('js.ws.under_minute') : msg('js.ws.min', { n: min })}`;
372
375
  };
373
376
  const txnApply = (i) => {
374
377
  txnInfo = i;
@@ -381,8 +384,8 @@
381
384
  commitBtn.disabled = i.state !== 'open';
382
385
  rollbackBtn.disabled = i.state === 'none';
383
386
  commitNote.textContent = on
384
- ? `The statements stay in one transaction until Commit or Rollback; it is rolled back after ${idleWords} without a run.`
385
- : 'Each statement commits on its own.';
387
+ ? msg('js.ws.commit_txn', { idle: idleWords })
388
+ : msg('js.ws.commit_auto');
386
389
  cur.txn = on;
387
390
  cur.txnOpen = i.state !== 'none';
388
391
  persist();
@@ -407,7 +410,7 @@
407
410
  if (j.state !== 'none') modeSel.value = 'on';
408
411
  fromServer(j);
409
412
  } catch {
410
- badge.textContent = 'Transaction state unknown';
413
+ badge.textContent = msg('js.ws.txn_unknown');
411
414
  }
412
415
  }
413
416
  async function txnPost(key, action) {
@@ -421,13 +424,13 @@
421
424
  }
422
425
  return j;
423
426
  } catch {
424
- announce('The transaction could not be ended: reload the page.');
427
+ announce(msg('js.ws.txn_not_ended'));
425
428
  return null;
426
429
  }
427
430
  }
428
431
  modeSel.addEventListener('change', async () => {
429
432
  if (modeSel.value !== 'on' && txnInfo.state !== 'none') {
430
- if (!window.confirm('Switching to Autocommit rolls back the open transaction. Roll it back?')) { modeSel.value = 'on'; return; }
433
+ if (!window.confirm(msg('js.ws.txn_confirm'))) { modeSel.value = 'on'; return; }
431
434
  await txnPost(cur.key, 'rollback');
432
435
  modeSel.value = '';
433
436
  }
@@ -486,9 +489,9 @@
486
489
  const out = (await res.json()).sql;
487
490
  if (whole) replaceRange(0, ta.value.length, out, false);
488
491
  else replaceRange(a, b, out);
489
- announce('Formatted.');
492
+ announce(msg('js.ws.formatted'));
490
493
  } catch {
491
- announce('Formatting failed.');
494
+ announce(msg('js.ws.format_failed'));
492
495
  }
493
496
  };
494
497
  const convertCase = () => {
@@ -521,7 +524,7 @@
521
524
  try {
522
525
  return new RegExp(fre.checked ? fq.value : fq.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), `g${fcs.checked ? '' : 'i'}`);
523
526
  } catch (err) {
524
- fmsg.textContent = `Not a valid pattern: ${err.message}`;
527
+ fmsg.textContent = msg('js.ws.bad_pattern', { error: err.message });
525
528
  return null;
526
529
  }
527
530
  };
@@ -537,7 +540,7 @@
537
540
  re.lastIndex = from;
538
541
  let m = re.exec(ta.value);
539
542
  if (!m) { re.lastIndex = 0; m = re.exec(ta.value); }
540
- if (!m || !m[0]) { fmsg.textContent = 'Not found.'; return false; }
543
+ if (!m || !m[0]) { fmsg.textContent = msg('js.ws.not_found'); return false; }
541
544
  fmsg.textContent = '';
542
545
  reveal(m.index, m.index + m[0].length);
543
546
  return true;
@@ -580,7 +583,7 @@
580
583
  if (!re) return;
581
584
  const n = (ta.value.match(re) || []).length;
582
585
  if (n) replaceRange(0, ta.value.length, ta.value.replace(re, fr.value), false);
583
- fmsg.textContent = `${n} replaced.`;
586
+ fmsg.textContent = msg('js.ws.replaced', { n });
584
587
  }
585
588
  });
586
589
 
@@ -660,6 +663,8 @@
660
663
  // to open it; Open shows the Object Browser's detail in a panel at the side (its tabs and pages load in the panel).
661
664
  (() => {
662
665
  'use strict';
666
+ const TEXTS = (() => { try { return JSON.parse(document.getElementById('builder-texts')?.dataset.texts || '{}'); } catch { return {}; } })();
667
+ const msg = (key) => TEXTS[key] ?? key;
663
668
  document.addEventListener('contextmenu', (e) => {
664
669
  const item = e.target.closest?.('.ws-obj, .ob-item');
665
670
  const menu = item?.querySelector(':scope > .obj-menu');
@@ -678,8 +683,8 @@
678
683
  const head = document.createElement('div');
679
684
  head.className = 'obj-slide-head';
680
685
  const title = Object.assign(document.createElement('strong'), { id: 'obj-slide-title' });
681
- const full = Object.assign(document.createElement('a'), { className: 'btn btn-sm', textContent: 'Open in the Object Browser' });
682
- const close = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: 'Close' });
686
+ const full = Object.assign(document.createElement('a'), { className: 'btn btn-sm', textContent: msg('js.ws.open_ob') });
687
+ const close = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: msg('js.ws.close') });
683
688
  close.addEventListener('click', () => slide.close());
684
689
  const tools = document.createElement('div');
685
690
  tools.append(full, ' ', close);
@@ -708,7 +713,7 @@
708
713
  d.querySelector('#obj-slide-title').textContent = root?.dataset.label ?? '';
709
714
  d.querySelector('.obj-slide-head a').href = url.replace('/sql/objects/panel', '/sql/objects');
710
715
  } catch {
711
- body.textContent = 'The object could not be opened.';
716
+ body.textContent = msg('js.ws.open_failed');
712
717
  } finally {
713
718
  body.removeAttribute('aria-busy');
714
719
  }