pgkiln 0.54.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.
Files changed (102) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/127_builder_language.sql +16 -0
  3. package/docs/apex-feature-parity.md +7 -7
  4. package/docs/guide/03-builder.md +30 -3
  5. package/docs/guide/04-pages-and-regions.md +26 -0
  6. package/docs/guide/12-development.md +41 -0
  7. package/examples/gallery/README.md +4 -0
  8. package/examples/gallery/gallery.json +8 -0
  9. package/examples/gallery/samples/sample-approvals.sql +153 -0
  10. package/examples/gallery/samples/sample-data-loading.sql +142 -0
  11. package/examples/gallery/samples/sample-files.sql +160 -0
  12. package/examples/gallery/samples/sample-maps.sql +175 -0
  13. package/examples/gallery/samples/sample-reporting.sql +4 -3
  14. package/package.json +1 -1
  15. package/public/builder.css +18 -0
  16. package/public/worksheet.css +3 -2
  17. package/src/builder/advisor.ts +1 -1
  18. package/src/builder/ai-agents.ts +5 -5
  19. package/src/builder/ai-builder.ts +3 -3
  20. package/src/builder/api.ts +1 -1
  21. package/src/builder/autorest.ts +2 -2
  22. package/src/builder/availability.ts +1 -1
  23. package/src/builder/backups.ts +1 -1
  24. package/src/builder/changelog.ts +1 -1
  25. package/src/builder/data-modeler.ts +4 -4
  26. package/src/builder/data-security.ts +1 -1
  27. package/src/builder/dataload.ts +1 -1
  28. package/src/builder/datapump.ts +1 -1
  29. package/src/builder/ddl-tools.ts +2 -2
  30. package/src/builder/designer.ts +134 -124
  31. package/src/builder/diagnostics.ts +2 -2
  32. package/src/builder/errors.ts +10 -12
  33. package/src/builder/feedback.ts +1 -1
  34. package/src/builder/globalization.ts +1 -1
  35. package/src/builder/home.ts +59 -57
  36. package/src/builder/i18n/de.ts +675 -0
  37. package/src/builder/i18n/en.ts +678 -0
  38. package/src/builder/i18n/es.ts +675 -0
  39. package/src/builder/i18n/fr.ts +675 -0
  40. package/src/builder/i18n/nl.ts +675 -0
  41. package/src/builder/i18n/pt.ts +675 -0
  42. package/src/builder/i18n.ts +116 -0
  43. package/src/builder/language.ts +74 -0
  44. package/src/builder/launchpad.ts +13 -8
  45. package/src/builder/object-browser.ts +1 -1
  46. package/src/builder/plugins.ts +1 -1
  47. package/src/builder/querybuilder.ts +1 -1
  48. package/src/builder/quicksql.ts +1 -1
  49. package/src/builder/region-help.ts +1 -1
  50. package/src/builder/region-settings.ts +3 -1
  51. package/src/builder/rest-modules.ts +2 -2
  52. package/src/builder/routes.ts +88 -66
  53. package/src/builder/sampledata.ts +1 -1
  54. package/src/builder/scheduling.ts +1 -1
  55. package/src/builder/scripts.ts +1 -1
  56. package/src/builder/search.ts +1 -1
  57. package/src/builder/shared.ts +5 -5
  58. package/src/builder/sql-charts.ts +2 -2
  59. package/src/builder/sql-monitor.ts +2 -2
  60. package/src/builder/sql-tools.ts +6 -0
  61. package/src/builder/sql-utilities.ts +3 -3
  62. package/src/builder/sql.ts +24 -22
  63. package/src/builder/static-files.ts +1 -1
  64. package/src/builder/subscriptions.ts +1 -1
  65. package/src/builder/supporting.ts +1 -1
  66. package/src/builder/table-editor.ts +1 -1
  67. package/src/builder/team.ts +7 -5
  68. package/src/builder/themeroller.ts +1 -1
  69. package/src/builder/top-sql.ts +1 -1
  70. package/src/builder/ui.ts +87 -77
  71. package/src/builder/unload.ts +1 -1
  72. package/src/builder/wizards.ts +1 -1
  73. package/src/builder/workflow-migrate.ts +1 -1
  74. package/src/builder/workingcopies.ts +2 -2
  75. package/src/builder/worksheet.ts +1 -1
  76. package/src/builder/workspace-home.ts +62 -59
  77. package/src/builder/workspaces.ts +2 -1
  78. package/src/i18n/ar.ts +1 -0
  79. package/src/i18n/cs.ts +1 -0
  80. package/src/i18n/da.ts +1 -0
  81. package/src/i18n/de.ts +1 -0
  82. package/src/i18n/el.ts +1 -0
  83. package/src/i18n/es.ts +1 -0
  84. package/src/i18n/fi.ts +1 -0
  85. package/src/i18n/fr.ts +1 -0
  86. package/src/i18n/he.ts +1 -0
  87. package/src/i18n/it.ts +1 -0
  88. package/src/i18n/ja.ts +1 -0
  89. package/src/i18n/ko.ts +1 -0
  90. package/src/i18n/nb.ts +1 -0
  91. package/src/i18n/pl.ts +1 -0
  92. package/src/i18n/pt.ts +1 -0
  93. package/src/i18n/ru.ts +1 -0
  94. package/src/i18n/sv.ts +1 -0
  95. package/src/i18n/tr.ts +1 -0
  96. package/src/i18n/uk.ts +1 -0
  97. package/src/i18n/zh.ts +1 -0
  98. package/src/i18n.ts +2 -0
  99. package/src/runtime/ai-filter.ts +22 -6
  100. package/src/runtime/ask-facets.ts +72 -0
  101. package/src/runtime/ir-plan.ts +17 -3
  102. 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.54.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",
@@ -1545,3 +1545,21 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1545
1545
  .gal-tabs a:nth-child(3) { border-left: 1px solid var(--border); border-top: 0; border-radius: 0 0 0 var(--radius); }
1546
1546
  .gal-tabs a:last-child { border-top: 0; border-radius: 0 0 var(--radius) 0; }
1547
1547
  }
1548
+
1549
+ /* ---------------------------------------------------------------- (sprint 121) builder language: status bar menu, sign-in links */
1550
+ .ide-status .ide-lang { flex: none; }
1551
+ .ide-status .ide-lang > summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: inherit; padding: 2px 4px; border-radius: 4px; }
1552
+ .ide-status .ide-lang > summary:hover, .ide-status .ide-lang[open] > summary { color: var(--chrome-text); background: rgb(255 255 255 / 8%); }
1553
+ .ide-status .ide-lang > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
1554
+ /* the status bar clips (overflow: hidden): the menu opens fixed, above it */
1555
+ .ide-status .ide-lang .menu-panel { position: fixed; top: auto; bottom: 34px; left: auto; right: auto; min-width: 200px; font-size: 13px; white-space: normal; }
1556
+ .ide-lang-form button { width: 100%; }
1557
+ .ide-lang-form button[aria-current="true"] { font-weight: 600; }
1558
+ .ide-lang-form .icon, .ide-lang-pad { width: 1em; height: 1em; flex: none; display: inline-block; }
1559
+ .signin-langs ul { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.25rem 1rem; margin: 0; padding: 0.75rem 0 0; list-style: none; font-size: 0.85rem; }
1560
+ .signin-langs a { color: var(--muted); text-decoration: none; }
1561
+ .signin-langs a:hover { color: var(--accent); text-decoration: underline; }
1562
+ .signin-langs a[aria-current="true"] { color: var(--text); font-weight: 600; }
1563
+ @media (max-width: 640px) { .ide-status .ide-lang .menu-panel { left: 0.5rem; right: 0.5rem; min-width: 0; } }
1564
+ /* long compound words of other languages (Workspace-Dienstprogramme) wrap instead of widening a phone's page */
1565
+ .ab-tile > *, .stat span { min-width: 0; overflow-wrap: anywhere; hyphens: auto; }
@@ -263,8 +263,9 @@ details.menu.obj-menu > summary::before, details.menu.obj-menu > summary::after
263
263
  .lp-chips li { display: flex; align-items: center; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); padding-left: 10px; }
264
264
  .lp-chips a { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: var(--text); padding: 4px 0; font-size: 13px; }
265
265
  .lp-recent li { padding-right: 10px; border-style: dashed; }
266
- .lp-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 10px; }
267
- .lp-tabs [role=tab] { border: 0; background: none; color: var(--muted); padding: 7px 12px; font: inherit; cursor: pointer; }
266
+ .lp-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 10px; overflow-x: auto; scrollbar-width: none; }
267
+ .lp-tabs [role=tab] { border: 0; background: none; color: var(--muted); padding: 7px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
268
+ @media (max-width: 480px) { .lp-tabs [role=tab] { padding: 7px 8px; } }
268
269
  .lp-tabs [role=tab][aria-selected=true] { color: var(--text); box-shadow: inset 0 -2px 0 var(--hot); }
269
270
  .js .lp-group > h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
270
271
  .lp-list { list-style: none; margin: 0 0 16px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 8px; }
@@ -453,7 +453,7 @@ export async function advisorRoutes(app: FastifyInstance) {
453
453
  <span class="hit-snippet">${f.entry ? html`<b>${f.field}:</b> ` : ''}${f.message}</span>
454
454
  </li>`)}</ul>`
455
455
  : html`<div class="alert alert-success" role="status">No problems found.</div>`;
456
- const main = html`${appHeader(a, 'advisor')}
456
+ const main = html`${appHeader(a, 'advisor', s)}
457
457
  ${region('Advisor', html`<p class="muted u-mt0">Checked ${checked} pieces of SQL as the role <code>${a.db_role ?? '(owner)'}</code> (planned with EXPLAIN and rolled back, nothing is run), and every reference to pages, items, lists of values, authorization schemes and report layouts.
458
458
  ${plpgsqlCheck ? 'PL/pgSQL functions were checked with plpgsql_check.' : html`Install the <code>plpgsql_check</code> extension to check PL/pgSQL functions too.`}</p>
459
459
  <p><span class="tag tag-error">${count('error')} errors</span> <span class="tag tag-warning">${count('warning')} warnings</span> <span class="tag tag-info">${count('info')} notes</span>
@@ -223,7 +223,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
223
223
  const agents = (a.ai_agents ?? []) as AiAgent[];
224
224
  const tools = (a.ai_tools ?? []) as AiTool[];
225
225
  const problems = definitionProblems(agents, tools);
226
- const main = html`${appHeader(a, 'shared')}
226
+ const main = html`${appHeader(a, 'shared', s)}
227
227
  <div class="title-row"><h1>AI Agents</h1></div>
228
228
  <p class="muted u-mt0">An agent answers in an AI assistant region and acts only through the tools you give it: SQL queries (read-only, as the application's database role, so row level security applies), PL/pgSQL functions, REST data sources, JavaScript actions in the browser, and links to pages (pgkiln builds the URL and its checksum, only for pages the user may open). What users type and the tools' results go to the agent's AI service. <a href="${BASE}/apps/${a.id}/ai">AI usage</a> lists every tool call.</p>
229
229
  ${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
@@ -250,7 +250,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
250
250
  const x = name ? ((a.ai_agents ?? []) as AiAgent[]).find((g) => g.name === name) : {};
251
251
  if (!x) return reply.code(404).send('Not found');
252
252
  const title = name || 'New agent';
253
- const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('AI agent', await agentForm(a, s, x, name, []))}`;
253
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('AI agent', await agentForm(a, s, x, name, []))}`;
254
254
  return send(reply, s, shell(s, title, crumbs(a, title), main));
255
255
  });
256
256
 
@@ -273,7 +273,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
273
273
  return [];
274
274
  });
275
275
  if (problems.length) {
276
- const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${original || 'New agent'}</h1></div>${region('AI agent', await agentForm(a, s, x, original, problems))}`;
276
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New agent'}</h1></div>${region('AI agent', await agentForm(a, s, x, original, problems))}`;
277
277
  return send(reply.code(422), s, shell(s, original || 'New agent', crumbs(a, original || 'New agent'), main));
278
278
  }
279
279
  if (original && original !== x.name)
@@ -305,7 +305,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
305
305
  const x = name ? ((a.ai_tools ?? []) as AiTool[]).find((t) => t.name === name) : {};
306
306
  if (!x) return reply.code(404).send('Not found');
307
307
  const title = name || 'New tool';
308
- const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('AI tool', await toolForm(a, s, x, name, []))}`;
308
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${title}</h1></div>${region('AI tool', await toolForm(a, s, x, name, []))}`;
309
309
  return send(reply, s, shell(s, title, crumbs(a, title), main));
310
310
  });
311
311
 
@@ -337,7 +337,7 @@ export async function aiAgentRoutes(app: FastifyInstance) {
337
337
  return [];
338
338
  });
339
339
  if (problems.length) {
340
- const main = html`${appHeader(a, 'shared')}<div class="title-row"><h1 class="ai-name">${original || 'New tool'}</h1></div>${region('AI tool', await toolForm(a, s, x, original, problems))}`;
340
+ const main = html`${appHeader(a, 'shared', s)}<div class="title-row"><h1 class="ai-name">${original || 'New tool'}</h1></div>${region('AI tool', await toolForm(a, s, x, original, problems))}`;
341
341
  return send(reply.code(422), s, shell(s, original || 'New tool', crumbs(a, original || 'New tool'), main));
342
342
  }
343
343
  flash(s, 'AI tool saved.');
@@ -142,7 +142,7 @@ const SQL_SCHEMA = {
142
142
 
143
143
  async function workshopPage(s: Session, reply: FastifyReply, out: { question?: string; schema?: string; sql?: string; error?: string; result?: Raw }) {
144
144
  const list = await schemas(s);
145
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai')}
145
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai', s)}
146
146
  ${await serviceBox(s)}
147
147
  <p><a href="${BASE}/sql/ai/describe">Describe tables for AI ▸</a> <span class="muted">(better answers: say what tables and columns mean)</span></p>
148
148
  ${out.result ?? ''}
@@ -178,7 +178,7 @@ async function describePage(s: Session, reply: FastifyReply, schema: string, tab
178
178
  const t = table ? tables.find((x) => x.table === table) : undefined;
179
179
  const noted = new Set((await owner.query<{ t: string }>(`select distinct table_name as t from meta.ai_table_note where schema_name = $1`, [schema])).rows.map((r) => r.t));
180
180
  const value = (col: string, current: string | null) => (draft ? (col === '' ? draft.table : draft.columns?.[col]) ?? current ?? '' : current ?? '');
181
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai')}
181
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai', s)}
182
182
  <p><a href="${BASE}/sql/ai">◂ SQL Workshop AI</a></p>
183
183
  ${message ?? ''}
184
184
  ${region('Describe tables for AI', html`
@@ -225,7 +225,7 @@ async function pagesPage(s: Session, reply: FastifyReply, a: any, description: s
225
225
  const tables = (await wizardTables(a.db_role)).filter((t) => t.access).map((t) => t.t);
226
226
  const svc = await builderService();
227
227
  const kindOptions = WIZARD_KINDS.map(([k, label]): [string, string] => [k, label]);
228
- const main = html`${appHeader(a, 'pages')}
228
+ const main = html`${appHeader(a, 'pages', s)}
229
229
  ${message ?? ''}
230
230
  ${region('Create pages with AI', html`
231
231
  <p class="muted u-mt0">Describe the pages you want. The App Builder's AI service proposes pages of the create page wizards for tables and views the application's role (${a.db_role ?? 'the owner'}) can read; you check the proposals and create the ones you tick. Table and column names, types and <a href="${BASE}/sql/ai/describe">descriptions</a> are sent to the service, never rows.</p>
@@ -116,7 +116,7 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
116
116
  ].join('\n');
117
117
 
118
118
  const check = (ok: boolean, text: Raw | string) => html`<li>${ok ? '✓' : '✗'} ${text}</li>`;
119
- const main = html`${appHeader(a, 'api')}
119
+ const main = html`${appHeader(a, 'api', s)}
120
120
  <div class="columns">
121
121
  ${region('Setup', html`
122
122
  <p class="muted u-mt0">PostgREST runs next to pgkiln and serves the <code>${API_SCHEMA}</code> schema over HTTP. Requests carry a JWT whose <code>role</code> claim is this application’s API role; row level security uses the same <code>meta.app_user()</code> and <code>meta.has_role()</code> as the web pages.</p>
@@ -167,7 +167,7 @@ async function listPage(s: Session, q: Record<string, unknown>, problems: string
167
167
  <div class="buttons"><button class="btn btn-hot"${usable.length ? '' : raw(' disabled')}>${icon('bolt')} Enable</button></div>
168
168
  ${usable.length ? '' : html`<p class="muted">No application of this workspace has a database role to run as.</p>`}
169
169
  </form>`;
170
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}${restNav('autorest')}
170
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest', s)}${restNav('autorest')}
171
171
  <p class="muted">RESTful Services: publish a table or view as REST endpoints (AutoREST). Rows come paged as JSON, with filters; a primary key gives single-row GET, PUT and DELETE. Requests run as the publishing application's database role with the caller as <code>meta.app_user()</code>, so its grants and row level security apply. For tailored endpoints with your own SQL or PL/pgSQL, create a <a href="${BASE}/sql/rest/modules">module</a>.</p>
172
172
  ${region('Published objects', table)}
173
173
  ${region('Enable a table or view', form)}`;
@@ -227,7 +227,7 @@ async function detailPage(s: Session, o: Row, problems: string[] = []) {
227
227
  ].filter(Boolean).join('\n');
228
228
  const fetchEx = `const res = await fetch('${base}?limit=10'${o.auth === 'public' ? '' : `, {\n headers: { Authorization: \`Bearer \${token}\` },\n}`});\nconst { items, has_more } = await res.json();`;
229
229
  const openapi = `${publicUrl()}/a/${o.app_alias}/autorest/openapi.json`;
230
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest')}
230
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('rest', s)}
231
231
  <h2 class="u-mb1 wrap-anywhere">${o.schema_name}.${o.object_name} <span class="muted small">${info ? kindLabel(info.kind) : ''} · ${o.enabled ? 'enabled' : 'disabled'}</span></h2>
232
232
  ${errorList(problems)}
233
233
  ${!o.enabled ? html`<div class="alert alert-info" role="status">Disabled: the endpoints answer 404.</div>` : ''}
@@ -40,7 +40,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
40
40
  'Run only: the builder refuses changes to the application (pages, shared components, settings, restores) until it is set back here. pgkiln import --replace still deploys it.')}
41
41
  </div>
42
42
  <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`);
43
- const main = html`${appHeader(a, 'settings')}<div class="ide-body">${body}</div>`;
43
+ const main = html`${appHeader(a, 'settings', s)}<div class="ide-body">${body}</div>`;
44
44
  return send(reply, s, shell(s, `${a.name} availability`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Availability']], main));
45
45
  });
46
46
 
@@ -66,7 +66,7 @@ export async function backupRoutes(app: FastifyInstance) {
66
66
  <div class="form-grid">${input('keep', 'Backups to keep', a.backups_keep, { type: 'number', help: '0 to 500; 0 makes no backups at all (also not before an import).' })}</div>
67
67
  <div class="buttons"><button class="btn">Save</button></div></form>`)}
68
68
  </div>`;
69
- const main = html`${appHeader(a, 'pages')}<div class="ide-body">${body}</div>`;
69
+ const main = html`${appHeader(a, 'pages', s)}<div class="ide-body">${body}</div>`;
70
70
  return send(reply, s, shell(s, `${a.name} backups`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Backups']], main));
71
71
  });
72
72
 
@@ -106,7 +106,7 @@ async function listPage(s: Session, q: Record<string, unknown>) {
106
106
  ${exp ? html`<input type="hidden" name="app" value="${exp.id}">` : ''}
107
107
  <div class="buttons"><button class="btn btn-hot">${icon('download')} Download migration</button></div></form>`
108
108
  : '';
109
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('changes')}
109
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('changes', s)}
110
110
  <p class="muted">The change log (Liquibase, Flyway): DDL run from the SQL Worksheet, Table Properties and Schema Comparison is kept as change sets per schema, with who ran it and when. Download them as a migration for another database: Flyway versioned files, a Liquibase formatted SQL changelog or a plain script. Only statements that succeeded are kept; data changes (INSERT, UPDATE, DELETE) are not.</p>
111
111
  ${nav}
112
112
  ${schema ? region(`Change sets of ${schema}`, html`${exportPick}${table}${downloads}`) : region('Change sets', table)}`;
@@ -87,7 +87,7 @@ async function listPage(req: Req, reply: FastifyReply) {
87
87
  <small class="help">Names separated by commas or spaces; <code>*</code> for all, <code>ord*</code> for those starting with ord. You can add more later.</small></div>
88
88
  </div>
89
89
  <div class="buttons"><button class="btn btn-hot">${icon('plus')} Create diagram</button></div></form>`;
90
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
90
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler', s)}
91
91
  <p class="muted u-mt0">Relational diagrams of a schema's tables and views: their columns, keys and the foreign keys between them. From a diagram: its DDL and a data dictionary.</p>
92
92
  ${region('Diagrams', list)}<div class="u-spacer"></div>${region('Create diagram', create)}`;
93
93
  return send(reply, s, shell(s, 'Data Modeler', [['SQL Workshop', `${BASE}/sql`], ['Data Modeler']], main, 'sql'));
@@ -102,7 +102,7 @@ async function diagramPage(req: Req, reply: FastifyReply) {
102
102
  const m = await model(s, d);
103
103
  const crumbs: [string, string?][] = [['SQL Workshop', `${BASE}/sql`], ['Data Modeler', M], [d.name]];
104
104
  if (!m) {
105
- const main = html`<h1 class="u-mb1">${d.name}</h1>${workshopTabs('modeler')}<div class="alert alert-error" role="alert">The schema ${d.schema_name} doesn't exist any more, or this workspace can't use it.</div>
105
+ const main = html`<h1 class="u-mb1">${d.name}</h1>${workshopTabs('modeler', s)}<div class="alert alert-error" role="alert">The schema ${d.schema_name} doesn't exist any more, or this workspace can't use it.</div>
106
106
  <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the diagram ${d.name}?">${icon('trash')} Delete the diagram</button></form>`;
107
107
  return send(reply, s, shell(s, d.name, crumbs, main, 'sql'));
108
108
  }
@@ -151,7 +151,7 @@ async function diagramPage(req: Req, reply: FastifyReply) {
151
151
  const settings = html`<details class="dm-settings"><summary>Rename or delete the diagram</summary>
152
152
  <form method="post" action="${M}/${d.id}/rename" class="dm-search-row">${csrf(s)}<label class="sr-only" for="f_rename">Name</label><input id="f_rename" name="name" value="${d.name}" maxlength="120" required><button class="btn">${icon('edit')} Rename</button></form>
153
153
  <form method="post" action="${M}/${d.id}/delete">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete the diagram ${d.name}? The tables stay as they are.">${icon('trash')} Delete diagram</button></form></details>`;
154
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
154
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler', s)}
155
155
  <div class="dm-head"><h2 class="dm-name">${d.name}</h2><span class="muted small">Schema ${d.schema_name} · ${tables.length} objects · updated ${when(d.updated_at)} by ${d.updated_by ?? d.created_by}</span></div>
156
156
  ${missing.length ? html`<div class="alert alert-error" role="alert">Not in ${d.schema_name} any more: ${missing.join(', ')}. Saving the diagram leaves them out.</div>` : ''}
157
157
  ${toolbar}
@@ -353,7 +353,7 @@ export async function dataModelerRoutes(app: FastifyInstance) {
353
353
  const script = diagramDdl(d.schema_name, new Set(m.tables.map((t) => t.name)), await readSchema(m.db.pool, d.schema_name));
354
354
  if (req.query.download === '1')
355
355
  return reply.header('content-disposition', `attachment; filename="${fileName(d.name)}.sql"`).type('application/sql; charset=utf-8').send(script);
356
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler')}
356
+ const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('modeler', s)}
357
357
  ${region(`DDL of ${d.name}`, html`<p class="muted u-mt0">The CREATE statements of the diagram's ${m.tables.length} objects in schema ${d.schema_name}: tables, their keys, indexes, triggers, row level security, policies, comments and grants, and views. Review before running.</p>
358
358
  <pre class="source" data-code-view="sql" tabindex="0" aria-label="DDL">${script}</pre>
359
359
  <form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${script}">
@@ -171,7 +171,7 @@ async function page(req: Req, reply: FastifyReply) {
171
171
  }
172
172
  const title = `Security of ${cur.schema}.${cur.table}`;
173
173
  const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: ref })}`;
174
- const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects')}${form(s, cur, def, { ref, sql, warnings, errors, output })}`;
174
+ const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects', s)}${form(s, cur, def, { ref, sql, warnings, errors, output })}`;
175
175
  return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], ['Security']], main, 'sql'));
176
176
  }
177
177