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.
- package/SECURITY.md +2 -0
- package/docs/apex-feature-parity.md +4 -4
- package/docs/guide/03-builder.md +11 -4
- package/docs/guide/04-pages-and-regions.md +26 -0
- package/docs/guide/12-development.md +14 -0
- package/examples/gallery/README.md +4 -0
- package/examples/gallery/gallery.json +8 -0
- package/examples/gallery/samples/sample-approvals.sql +153 -0
- package/examples/gallery/samples/sample-data-loading.sql +142 -0
- package/examples/gallery/samples/sample-files.sql +160 -0
- package/examples/gallery/samples/sample-maps.sql +175 -0
- package/examples/gallery/samples/sample-reporting.sql +4 -3
- package/package.json +1 -1
- package/public/builder.css +1 -1
- package/public/worksheet.js +31 -26
- package/src/builder/i18n/de.ts +371 -0
- package/src/builder/i18n/en.ts +371 -0
- package/src/builder/i18n/es.ts +371 -0
- package/src/builder/i18n/fr.ts +371 -0
- package/src/builder/i18n/nl.ts +371 -0
- package/src/builder/i18n/pt.ts +371 -0
- package/src/builder/i18n.ts +10 -0
- package/src/builder/object-browser.ts +71 -67
- package/src/builder/region-help.ts +1 -1
- package/src/builder/region-settings.ts +3 -1
- package/src/builder/table-editor.ts +51 -48
- package/src/builder/ui.ts +3 -2
- package/src/builder/worksheet.ts +160 -154
- package/src/i18n/ar.ts +1 -0
- package/src/i18n/cs.ts +1 -0
- package/src/i18n/da.ts +1 -0
- package/src/i18n/de.ts +1 -0
- package/src/i18n/el.ts +1 -0
- package/src/i18n/es.ts +1 -0
- package/src/i18n/fi.ts +1 -0
- package/src/i18n/fr.ts +1 -0
- package/src/i18n/he.ts +1 -0
- package/src/i18n/it.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/nb.ts +1 -0
- package/src/i18n/pl.ts +1 -0
- package/src/i18n/pt.ts +1 -0
- package/src/i18n/ru.ts +1 -0
- package/src/i18n/sv.ts +1 -0
- package/src/i18n/tr.ts +1 -0
- package/src/i18n/uk.ts +1 -0
- package/src/i18n/zh.ts +1 -0
- package/src/i18n.ts +2 -0
- package/src/runtime/ai-filter.ts +22 -6
- package/src/runtime/ask-facets.ts +72 -0
- package/src/runtime/ir-plan.ts +17 -3
- 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
|
|
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
|
|
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
package/public/builder.css
CHANGED
|
@@ -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; }
|
package/public/worksheet.js
CHANGED
|
@@ -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(
|
|
155
|
+
announce(msg('js.ws.copied', { what }));
|
|
153
156
|
} catch {
|
|
154
|
-
announce('
|
|
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'),
|
|
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, '
|
|
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((
|
|
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 ?
|
|
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',
|
|
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: '
|
|
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(
|
|
301
|
-
if (!txnOpen && dirty(t) && t.text.trim() && !window.confirm(
|
|
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 ?
|
|
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 || '
|
|
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' ? '
|
|
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
|
-
?
|
|
385
|
-
: '
|
|
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 = '
|
|
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('
|
|
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('
|
|
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('
|
|
492
|
+
announce(msg('js.ws.formatted'));
|
|
490
493
|
} catch {
|
|
491
|
-
announce('
|
|
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 =
|
|
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 = '
|
|
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 =
|
|
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: '
|
|
682
|
-
const close = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: '
|
|
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 = '
|
|
716
|
+
body.textContent = msg('js.ws.open_failed');
|
|
712
717
|
} finally {
|
|
713
718
|
body.removeAttribute('aria-busy');
|
|
714
719
|
}
|