mirror of
https://github.com/simonw/datasette.git
synced 2026-05-28 12:56:18 +02:00
Compare commits
980 commits
schema-ver
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
316daf9a43 |
||
|
|
b1289a73f9 | ||
|
|
2fde692a3e | ||
|
|
90e19a7d58 | ||
|
|
ec438496a9 | ||
|
|
56160e44fc | ||
|
|
2eb307b8c6 | ||
|
|
3c29b002ca | ||
|
|
cef52b1ffc | ||
|
|
7214cc3761 | ||
|
|
d6de8e7520 | ||
|
|
c3ceabae03 | ||
|
|
58e2e3a8ab | ||
|
|
1bcd99df90 | ||
|
|
e89ffa0e06 | ||
|
|
ca4907ab6b | ||
|
|
e2864fc895 | ||
|
|
cafb6b9dbd | ||
|
|
02a1468f1b | ||
|
|
56b14f37d5 | ||
|
|
2f73869c09 | ||
|
|
b1029acc68 | ||
|
|
4bf1c4b065 | ||
|
|
0cadd07187 | ||
|
|
24887004cf | ||
|
|
180a6a86fd | ||
|
|
ac6ee097dd | ||
|
|
024b911772 | ||
|
|
f7e9dbc27e | ||
|
|
5dca2dc9be | ||
|
|
6033bf8e40 | ||
|
|
eb7c25c57c | ||
|
|
70b23ff4a5 | ||
|
|
0fcaa5792b | ||
|
|
71c76e3853 | ||
|
|
866852eff6 | ||
|
|
1ac4265ffd | ||
|
|
1cd162e9da | ||
|
|
4a1a4d7807 | ||
|
|
f1dd86ebfb | ||
|
|
8ab8999ba9 | ||
|
|
4208ded249 | ||
|
|
1f7c26ffea | ||
|
|
de55a76d40 |
||
|
|
e1261442c0 | ||
|
|
abb17ba773 | ||
|
|
66bbbbc947 | ||
|
|
1bce34a338 | ||
|
|
2b5b4ed66b | ||
|
|
f0b59971f7 | ||
|
|
6eee6c81e8 | ||
|
|
310c36ae94 | ||
|
|
4a70b89355 | ||
|
|
539ff9ddfc | ||
|
|
2d07c3b99e | ||
|
|
e62a5ea337 | ||
|
|
e0d39ba69f | ||
|
|
b7505a9fc2 | ||
|
|
ef43c10388 | ||
|
|
2d77e3334b | ||
|
|
3b26b7aff0 | ||
|
|
040e42ddca | ||
|
|
4b5fac9cf7 | ||
|
|
221be2632e | ||
|
|
b4c63966f8 | ||
|
|
7e1abd0da4 | ||
|
|
daeeca6c6b | ||
|
|
a855a1acec | ||
|
|
6cafdcb6fa |
||
|
|
f403ea4e53 | ||
|
|
f3a34c5012 | ||
|
|
6aaed2d9b5 |
||
|
|
857af9293c | ||
|
|
312740b97c |
||
|
|
d11326b250 |
||
|
|
b013aa1f7f |
||
|
|
b9cb8e9a30 | ||
|
|
a75c9f2401 |
||
|
|
c1525cb467 | ||
|
|
c980234c41 | ||
|
|
cef6aa85b6 | ||
|
|
c73ed1ee4e | ||
|
|
21a79b34b8 | ||
|
|
0f7e4410c1 | ||
|
|
9c1f8621eb | ||
|
|
be1b5b2b5c | ||
|
|
1590444fa3 | ||
|
|
09ccab97cc |
||
|
|
865f35ff10 | ||
|
|
9e7419db8d | ||
|
|
f46c245563 | ||
|
|
fba67250d1 | ||
|
|
d44cfc3a55 | ||
|
|
0eb78dec9a | ||
|
|
8568320a23 | ||
|
|
6057c76165 | ||
|
|
9909bd654b | ||
|
|
1000d50220 | ||
|
|
fae847ac10 | ||
|
|
d3330695fa | ||
|
|
54b272baf6 | ||
|
|
bbbc1cd596 | ||
|
|
5d6de0154d | ||
|
|
7a914f8c65 | ||
|
|
40e78e0927 |
||
|
|
c1b3081863 | ||
|
|
10a1caac53 | ||
|
|
3110faa0ba |
||
|
|
46d90a0b88 | ||
|
|
036aa6aa2e | ||
|
|
db16003865 | ||
|
|
345f910043 |
||
|
|
aa84fe008d | ||
|
|
0dc7bb19d9 | ||
|
|
b15ce18ddc |
||
|
|
a6031c9847 | ||
|
|
1cd53e1fc3 |
||
|
|
630e557cdb | ||
|
|
b3001c1e5a | ||
|
|
c9a7dc9be2 | ||
|
|
03eeeb9d92 | ||
|
|
ede942a32e | ||
|
|
df96e12737 | ||
|
|
d23b32c3e5 | ||
|
|
c0153386ef | ||
|
|
34cc320eab | ||
|
|
d72dd35378 | ||
|
|
290f27158f | ||
|
|
dabf8e4199 | ||
|
|
ade0ef8a60 | ||
|
|
2638200d26 |
||
|
|
1f99d5dd20 | ||
|
|
67349e0e02 | ||
|
|
1a7030d668 | ||
|
|
5f39036b9b | ||
|
|
73f338b9f3 | ||
|
|
4922fc2e39 | ||
|
|
a973e3ffa1 | ||
|
|
028cc2446f | ||
|
|
f02484c3de | ||
|
|
9c164572d3 |
||
|
|
0b639a8122 |
||
|
|
fc1794719a |
||
|
|
94d14e3d37 | ||
|
|
312f41b0c2 |
||
|
|
9b5cb1347c |
||
|
|
1a64d5e55e |
||
|
|
c479e7dec9 |
||
|
|
4fcf474088 | ||
|
|
c673ee9819 | ||
|
|
cb293572c4 | ||
|
|
cb5cc0cc22 | ||
|
|
611b8ad463 | ||
|
|
2b06da29a1 | ||
|
|
d440c20984 | ||
|
|
fa1d8f0fa5 | ||
|
|
feaba9b18b |
||
|
|
0f81553b3f | ||
|
|
68966880c2 |
||
|
|
d02072bc9d | ||
|
|
e800312b54 | ||
|
|
fd016f7986 |
||
|
|
63d73a806f |
||
|
|
bc7a19b39d |
||
|
|
b7578a4884 |
||
|
|
c73a1c907a |
||
|
|
da0ea4382c |
||
|
|
dd9b83301c |
||
|
|
8af98c24c2 |
||
|
|
72c8c71518 |
||
|
|
9fe10cd1aa |
||
|
|
77bbfb5f7e |
||
|
|
32e4a31913 |
||
|
|
ad6a020e6d |
||
|
|
5db4f6953d |
||
|
|
de4269629b |
||
|
|
e8472bc0cd |
||
|
|
73225ccad0 |
||
|
|
7f93353549 |
||
|
|
5805a126db | ||
|
|
e2c1e81ec9 |
||
|
|
97201f067c | ||
|
|
1263380ea6 |
||
|
|
8f0d60236f | ||
|
|
e4ff5e27d3 | ||
|
|
1246c6576b | ||
|
|
2bc1dd2275 |
||
|
|
24d801b7f7 |
||
|
|
c96dc5ce26 |
||
|
|
6a2c27b15b | ||
|
|
2f0e64df68 |
||
|
|
7a66456615 |
||
|
|
1c6c6d2e68 | ||
|
|
5c3137d148 | ||
|
|
51e341b06a | ||
|
|
170f9de774 | ||
|
|
8a315f3d7d | ||
|
|
80b7f987ca |
||
|
|
5873578d49 | ||
|
|
b771e930bc |
||
|
|
40a37307de |
||
|
|
ffadb5f74c |
||
|
|
3f8f97e92a | ||
|
|
2f7b120177 | ||
|
|
7988a179fe | ||
|
|
7915c46ddd |
||
|
|
66d2a033f8 | ||
|
|
b0436faa5e |
||
|
|
b52655e856 | ||
|
|
757ce92baf | ||
|
|
6fede23a2e | ||
|
|
eae94dc2c3 | ||
|
|
97496d5a67 | ||
|
|
232a404743 | ||
|
|
3b4c7e1abe | ||
|
|
4cbdfcc07d |
||
|
|
1d4448fc56 |
||
|
|
2ca00b6c75 | ||
|
|
03ab359208 | ||
|
|
3eca3ad6d4 | ||
|
|
0a924524be |
||
|
|
170b3ff61c | ||
|
|
c6c2a238c3 | ||
|
|
68f1179bac | ||
|
|
2125115cd9 | ||
|
|
93b455239a | ||
|
|
4b4add4d31 | ||
|
|
5125bef573 | ||
|
|
23a640d38b |
||
|
|
32a425868c |
||
|
|
291f71ec6b |
||
|
|
354d7a2873 |
||
|
|
a508fc4a8e | ||
|
|
8bc9b1ee03 |
||
|
|
1df4028d78 | ||
|
|
257e1c1b1b | ||
|
|
d814e81b32 |
||
|
|
ec99bb46f8 | ||
|
|
3c2254463b |
||
|
|
f12f6cc2ab |
||
|
|
12016342e7 | ||
|
|
b4385a3ff7 | ||
|
|
ce464da34b | ||
|
|
9f74dc22a8 | ||
|
|
8b371495dc | ||
|
|
f257ca6edb |
||
|
|
295e4a2e87 | ||
|
|
95a1fef280 | ||
|
|
dc3f9fe9e4 | ||
|
|
5d4dfcec6b | ||
|
|
b3b8c5831b | ||
|
|
b212895b97 | ||
|
|
18fd373a8f |
||
|
|
c76c3e6e6f | ||
|
|
fa978ec100 | ||
|
|
2459285052 | ||
|
|
506ce5b0ac | ||
|
|
063bf7a96f | ||
|
|
7e09e1bf1b | ||
|
|
e37aa37edc | ||
|
|
b8cee8768e | ||
|
|
5c16c6687d | ||
|
|
a528555e84 |
||
|
|
2b962beaeb | ||
|
|
5705ce0d95 |
||
|
|
1f8995e776 | ||
|
|
47e4060469 | ||
|
|
48982a0ff5 | ||
|
|
223dcc7c0e | ||
|
|
3184bfae54 | ||
|
|
e5f392ae7a | ||
|
|
400fa08e4c |
||
|
|
b7ef968c6f | ||
|
|
ba654b5576 | ||
|
|
e4be95b16c |
||
|
|
87aa798148 | ||
|
|
6a71bde37f |
||
|
|
5247856bd4 | ||
|
|
ce4b0794b2 |
||
|
|
53e6a72a95 | ||
|
|
1289eb0589 | ||
|
|
5da3c9f4bd | ||
|
|
b018eb3171 | ||
|
|
73014abe8b | ||
|
|
b3721eaf50 | ||
|
|
5c537e0a3e | ||
|
|
2c8e92acf2 | ||
|
|
e7ed948238 | ||
|
|
06b442c894 | ||
|
|
6de83bf3a9 | ||
|
|
4fe1765dc3 | ||
|
|
653c94209c | ||
|
|
95286fbb60 | ||
|
|
653475edde | ||
|
|
c652e92049 | ||
|
|
d769e97ab8 | ||
|
|
ee4fcff5c0 | ||
|
|
ee62bf9bdc | ||
|
|
7d9d7acb0b | ||
|
|
5530a19d9f | ||
|
|
6854270da3 | ||
|
|
fb9cd5c72c | ||
|
|
bda69ff1c9 | ||
|
|
59994e18e4 | ||
|
|
62b99b1f55 | ||
|
|
f18d1ecac6 | ||
|
|
e7c7e21277 | ||
|
|
d7d7ead0ef | ||
|
|
20ed5a00e7 | ||
|
|
e4f549301b | ||
|
|
deb0b87e1b | ||
|
|
86ea2d2c99 | ||
|
|
c3eeecfb22 | ||
|
|
ca435d16f6 | ||
|
|
11fb528958 | ||
|
|
08014c9732 | ||
|
|
de21a4209c | ||
|
|
d300200ba5 | ||
|
|
eff4f931af | ||
|
|
82cc3d5c86 | ||
|
|
60ed646d45 | ||
|
|
66f2dbb64a | ||
|
|
10ea23a59c | ||
|
|
4760cb9e06 | ||
|
|
13318feb8e | ||
|
|
a5910f200e | ||
|
|
fabcfd68ad | ||
|
|
6df364cb2c | ||
|
|
d0237187c4 | ||
|
|
ee1d7983ba | ||
|
|
bc81975d85 | ||
|
|
5c6b76f2f0 | ||
|
|
5feb5fcf5d | ||
|
|
60a38cee85 | ||
|
|
b5f41772ca | ||
|
|
ad00bb11f6 | ||
|
|
559a13a8c6 | ||
|
|
09194c72f8 | ||
|
|
d07f8944fa | ||
|
|
562a84e3f9 | ||
|
|
0fb148b1f4 | ||
|
|
e5762b1f22 | ||
|
|
182bfaed8e | ||
|
|
6584c9e03f | ||
|
|
30e2f9064b | ||
|
|
e1582c1424 | ||
|
|
dc241e8691 | ||
|
|
fe2084df66 | ||
|
|
59ccf797c4 | ||
|
|
7aaff5e3d2 | ||
|
|
387afb0f69 | ||
|
|
cde1624d0a | ||
|
|
a0659075a3 | ||
|
|
224084facc | ||
|
|
235962cd35 | ||
|
|
4be7eece8c | ||
|
|
4d03e8c12e | ||
|
|
e8b79970fb | ||
|
|
a2994cc5bb | ||
|
|
7c6bc0b902 | ||
|
|
5138e95d69 | ||
|
|
8674aaa392 | ||
|
|
2620938661 | ||
|
|
23715d6c00 | ||
|
|
58ac5ccd6e | ||
|
|
b311f735f9 | ||
|
|
79879b834a | ||
|
|
a21a1b6c14 | ||
|
|
c0b5ce04c3 | ||
|
|
9172020535 | ||
|
|
4d6730e3c4 | ||
|
|
c7278c73f3 | ||
|
|
96d2e16e83 | ||
|
|
eb5a95ee6e | ||
|
|
8e47f99874 | ||
|
|
7d04211559 | ||
|
|
d73b6f169f | ||
|
|
130dad268d | ||
|
|
e333827687 | ||
|
|
8b098e4b3e | ||
|
|
06af34240f | ||
|
|
7423c1a999 | ||
|
|
98493b7587 | ||
|
|
8b5bf3e487 | ||
|
|
2ed2849a14 | ||
|
|
b8d26754df | ||
|
|
c06e05b7db | ||
|
|
65c427e4ee | ||
|
|
b9c6e7a0f6 | ||
|
|
159b9f3fec | ||
|
|
8e9916b286 | ||
|
|
b1080e7d30 | ||
|
|
5b0baf7cd5 | ||
|
|
2b879e462f | ||
|
|
e951f7e81f |
||
|
|
2df06e1fda |
||
|
|
7ce723edcf |
||
|
|
ec38ad3768 |
||
|
|
659673614a | ||
|
|
e2a739c496 | ||
|
|
27084caa04 |
||
|
|
85da8474d4 |
||
|
|
909448fb7a | ||
|
|
5d09ab3ff1 | ||
|
|
571ce651c1 | ||
|
|
d87bd12dbc | ||
|
|
9dc2a3ffe5 | ||
|
|
7a602140df | ||
|
|
e2497fdb59 | ||
|
|
1c77a7e33f | ||
|
|
6f7f4c7d89 | ||
|
|
f4274e7a2e | ||
|
|
271aa09056 | ||
|
|
d5c6e502fb |
||
|
|
f2485dce9c |
||
|
|
f6446b3095 | ||
|
|
d03273e205 |
||
|
|
d021ce97aa |
||
|
|
7945f4fbf2 | ||
|
|
da209ed2ba |
||
|
|
333f786cb0 | ||
|
|
6e512caa59 | ||
|
|
209bdee0e8 | ||
|
|
e59fd01757 | ||
|
|
cd9182a551 | ||
|
|
7f23411002 | ||
|
|
f95ac19e71 | ||
|
|
53a3b3c80e |
||
|
|
962da77d61 |
||
|
|
b9047d812a | ||
|
|
9e41d19f73 | ||
|
|
f57977a08f | ||
|
|
4dff846271 | ||
|
|
d48e5ae0ce |
||
|
|
b190b87ec6 | ||
|
|
d9a450b197 | ||
|
|
308c243cfd | ||
|
|
37873e02b0 | ||
|
|
34390bbed8 | ||
|
|
1902735c63 |
||
|
|
72f8ac680a | ||
|
|
7077b8b1ba | ||
|
|
e85517dab3 | ||
|
|
dce718961c | ||
|
|
b0b600b79f | ||
|
|
832f76ce26 | ||
|
|
ea9f66f9fb | ||
|
|
a542870bfb |
||
|
|
0bc6a2af89 | ||
|
|
2ec4d8a4d5 | ||
|
|
f601425015 | ||
|
|
6da8d09a14 | ||
|
|
deb482a41e | ||
|
|
2170269258 |
||
|
|
92c4d41ca6 | ||
|
|
dc288056b8 | ||
|
|
9ecce07b08 | ||
|
|
dc1d152476 | ||
|
|
bc46066f9d | ||
|
|
f28ff8e4f0 | ||
|
|
8a63cdccc7 | ||
|
|
34a6b2ac84 | ||
|
|
9028d7f805 | ||
|
|
1f3fb5f96b |
||
|
|
4efcc29d02 |
||
|
|
39dfc7d7d7 |
||
|
|
d444b6aad5 | ||
|
|
7d8dd2ac7f | ||
|
|
0dd41efce6 |
||
|
|
53a8ae1871 | ||
|
|
9cb5700d60 |
||
|
|
6d91d082e0 |
||
|
|
05dfd34fd0 | ||
|
|
160d82f06e |
||
|
|
492378c2a0 | ||
|
|
cf4274f2a3 |
||
|
|
e9d34a99b8 | ||
|
|
06d4ffb92e | ||
|
|
93067668fe | ||
|
|
bf953628bb | ||
|
|
f6bd2bf8b0 | ||
|
|
2e82eb108c | ||
|
|
e9f598609b | ||
|
|
ff710ed7eb | ||
|
|
5bd6853bf4 | ||
|
|
78ce105413 | ||
|
|
2b0a61ee19 | ||
|
|
8dc9bfa2ab | ||
|
|
2ad51baa31 | ||
|
|
bd7d3bb70f | ||
|
|
169ee5d710 |
||
|
|
81b68a143a | ||
|
|
feccfa2a4d |
||
|
|
2edf45b1b6 | ||
|
|
a23c2aee00 |
||
|
|
56adfff8d2 |
||
|
|
c2e8e5085b | ||
|
|
263788906a | ||
|
|
7316dd4ac6 | ||
|
|
62686114ee | ||
|
|
93534fd3d0 | ||
|
|
45c27603d2 | ||
|
|
8f86d2af6a |
||
|
|
64a125b860 | ||
|
|
d118d5c5bb | ||
|
|
b39b01a890 | ||
|
|
780deaa275 | ||
|
|
2b6bfddafc | ||
|
|
7437d40e5d | ||
|
|
9a3c3bfcc7 | ||
|
|
c698d008e0 | ||
|
|
e1bfab3fca |
||
|
|
8f9509f00c |
||
|
|
7d6d471dc5 | ||
|
|
2a08ffed5c |
||
|
|
63714cb2b7 | ||
|
|
d32176c5b8 |
||
|
|
19b6a37336 | ||
|
|
1edb24f124 | ||
|
|
da68662767 |
||
|
|
67e66f36c1 |
||
|
|
261fc8d875 | ||
|
|
eb8545c172 | ||
|
|
54f5604caf | ||
|
|
5af6837725 | ||
|
|
8b6f155b45 | ||
|
|
c92f326ed1 | ||
|
|
feddd61789 | ||
|
|
9cc6f1908f | ||
|
|
e088abdb46 | ||
|
|
828ef9899f | ||
|
|
8d456aae45 | ||
|
|
b8711988b9 | ||
|
|
7339cc51de | ||
|
|
06281a0b8e | ||
|
|
909c85cd2b | ||
|
|
daf5ca02ca | ||
|
|
7b32d5f7d8 | ||
|
|
7818e8b9d1 | ||
|
|
a395256c8c | ||
|
|
090dff542b |
||
|
|
c6e8a4a76c |
||
|
|
4d24bf6b34 | ||
|
|
5de6797d4a | ||
|
|
86335dc722 | ||
|
|
57c1ce0e8b | ||
|
|
6ec0081f5d |
||
|
|
f99c2f5f8c | ||
|
|
c863443ea1 | ||
|
|
dfd4ad558b |
||
|
|
434123425f | ||
|
|
103b4decbd |
||
|
|
158d5d96e9 |
||
|
|
28bf3a933f | ||
|
|
26300738e3 | ||
|
|
27409a7892 | ||
|
|
392ca2e24c | ||
|
|
b36a2d8f4b | ||
|
|
3856a8cb24 | ||
|
|
81629dbeff | ||
|
|
a4fa1ef3bd | ||
|
|
10f9ba1a00 | ||
|
|
5e0e440f2c | ||
|
|
e1c80efff8 | ||
|
|
9906f937d9 | ||
|
|
3a999a85fb | ||
|
|
244f3ff83a | ||
|
|
8bfa3a51c2 | ||
|
|
232a30459b | ||
|
|
47e29e948b | ||
|
|
97de4d6362 | ||
|
|
b89cac3b6a |
||
|
|
5d79974186 |
||
|
|
398a92cf1e | ||
|
|
bd9ed62e5d | ||
|
|
dcd9ea3622 |
||
|
|
c62cfa6de8 | ||
|
|
c954795f9a | ||
|
|
4e944c29e4 | ||
|
|
528d89d1a3 | ||
|
|
b5ccc4d608 | ||
|
|
574687834f | ||
|
|
900d15bcb8 | ||
|
|
569aacd39b |
||
|
|
9989f25709 | ||
|
|
e0794ddd52 | ||
|
|
1e31821d9f | ||
|
|
df8d1c055a |
||
|
|
d0089ba776 | ||
|
|
c64453a4a1 | ||
|
|
ad01f9d321 |
||
|
|
9ac9f0152f | ||
|
|
60c6692f68 |
||
|
|
52a1dac5d2 | ||
|
|
f049103852 | ||
|
|
69c6e95323 | ||
|
|
5d21057cf1 | ||
|
|
5a63ecc557 | ||
|
|
1e901aa690 | ||
|
|
85a1dfe6e0 | ||
|
|
efc7357554 | ||
|
|
503545b203 | ||
|
|
7219a56d1e | ||
|
|
5ea7098e4d | ||
|
|
4ea109ac4d | ||
|
|
6ccef35cc9 | ||
|
|
be4f02335f | ||
|
|
d4bc2b2dfc | ||
|
|
4da581d09b | ||
|
|
b466749e88 | ||
|
|
bcf7ef963f | ||
|
|
2e4a03b2c4 |
||
|
|
bcc4f6bf1f |
||
|
|
890615b3f2 |
||
|
|
959e020297 | ||
|
|
04e8835297 |
||
|
|
b8230694ff |
||
|
|
5c64af6936 | ||
|
|
c3caf36af7 |
||
|
|
7a5adb592a | ||
|
|
a25bf6bea7 | ||
|
|
0f63cb83ed |
||
|
|
7506a89be0 | ||
|
|
48148e66a8 | ||
|
|
2ff4d4a60a | ||
|
|
0b2c6a7ebd | ||
|
|
1fc76fee62 | ||
|
|
c7a4706bcc |
||
|
|
45b88f2056 | ||
|
|
872dae1e1a | ||
|
|
978249beda | ||
|
|
4284c74bc1 |
||
|
|
89c8ca0f3f | ||
|
|
067cc75dfa |
||
|
|
452a587e23 |
||
|
|
4b534b89a5 | ||
|
|
11f7fd38a4 | ||
|
|
a4b401f470 | ||
|
|
3d6d1e3050 |
||
|
|
35deaabcb1 |
||
|
|
4e1188f60f | ||
|
|
85a41987c7 | ||
|
|
d51e63d3bb | ||
|
|
836b1587f0 | ||
|
|
e4f868801a | ||
|
|
f130c7c0a8 | ||
|
|
2da1a6acec | ||
|
|
b7cf0200e2 | ||
|
|
80a9cd9620 | ||
|
|
b0d0a0e5de | ||
|
|
947520c1fe | ||
|
|
10bc805473 | ||
|
|
6763572948 |
||
|
|
b0e5d8afa3 |
||
|
|
6ed7908580 | ||
|
|
f56e043747 | ||
|
|
852f501485 | ||
|
|
16f0b6d822 | ||
|
|
b2ec8717c3 |
||
|
|
a4c96d01b2 | ||
|
|
b645174271 |
||
|
|
c26370485a | ||
|
|
ab040470e2 | ||
|
|
dbfad6d220 | ||
|
|
2200abfa17 | ||
|
|
fbcb103c0c | ||
|
|
e4abae3fd7 |
||
|
|
e86eaaa4f3 |
||
|
|
05707aa16b |
||
|
|
31d5c4ec05 | ||
|
|
fd083e37ec | ||
|
|
98ffad9aed | ||
|
|
9cead33fb9 |
||
|
|
4c3ef03311 |
||
|
|
2caa53a52a |
||
|
|
6bfe104d47 |
||
|
|
30b28c8367 | ||
|
|
bb12229794 | ||
|
|
50da908213 |
||
|
|
a1f3d75a52 |
||
|
|
92b8bf38c0 |
||
|
|
d28f12092d |
||
|
|
2e2825869f | ||
|
|
d8351b08ed | ||
|
|
d9aad1fd04 |
||
|
|
527cec66b0 | ||
|
|
bdf59eb7db | ||
|
|
64fd1d788e | ||
|
|
2ce7872e3b | ||
|
|
17ec309e14 |
||
|
|
01e0558825 |
||
|
|
943df09dcc | ||
|
|
4535568f2c | ||
|
|
33251d04e7 | ||
|
|
a3593c9015 | ||
|
|
4a42476bb7 | ||
|
|
19ab4552e2 | ||
|
|
90cb9ca58d | ||
|
|
856ca68d94 | ||
|
|
e34d09c6ec | ||
|
|
8920d425f4 | ||
|
|
26be9f0445 | ||
|
|
cd57b0f712 | ||
|
|
1377a290cd |
||
|
|
5139c0886a |
||
|
|
adf54f5c80 |
||
|
|
0818182399 | ||
|
|
18dd88ee4d | ||
|
|
dc5171eb1b | ||
|
|
278ac91a4d | ||
|
|
3a51ca9014 |
||
|
|
0f7192b615 | ||
|
|
42ca574720 | ||
|
|
2fd871a906 | ||
|
|
45e6d370ce | ||
|
|
50a6355c08 | ||
|
|
c076fb65e0 | ||
|
|
0183e1a72d | ||
|
|
38fcc96e67 | ||
|
|
3b336d8071 | ||
|
|
d7b21a8623 | ||
|
|
8cd60fd1d8 | ||
|
|
c39d600aef | ||
|
|
99ba051188 | ||
|
|
84b32b447a | ||
|
|
d45a7213ed | ||
|
|
ede6203618 |
||
|
|
d1d78ec0eb |
||
|
|
dda99fc09f |
||
|
|
b49fa446d6 | ||
|
|
9584879534 | ||
|
|
2e43a14da1 | ||
|
|
49184c569c |
||
|
|
d3d16b5ccf |
||
|
|
55c526a537 | ||
|
|
0b0c5cd7a9 | ||
|
|
249fcf8e3e |
||
|
|
5890a20c37 | ||
|
|
4c1e277edb | ||
|
|
30c88e3570 |
||
|
|
bbd5489dbc |
||
|
|
d52402447e |
||
|
|
848a9a420d |
||
|
|
651b78d8e6 | ||
|
|
c025b0180f |
||
|
|
db8cf899e2 |
||
|
|
5c1cfa451d |
||
|
|
3feed1f66e | ||
|
|
d97e82df3c |
||
|
|
56b0758a5f | ||
|
|
25fdbe6b27 | ||
|
|
bd39cb4805 | ||
|
|
1ad92a1d87 | ||
|
|
a53b893c46 |
||
|
|
0b4a286914 | ||
|
|
e4ebef082d |
||
|
|
6a352e99ab |
||
|
|
25a612fe09 | ||
|
|
50fd94e04f | ||
|
|
2c86774179 |
||
|
|
8e70734043 |
||
|
|
4880638f13 |
||
|
|
7dd671310a | ||
|
|
5e672df168 | ||
|
|
7b48664d75 | ||
|
|
0f7c71a86f | ||
|
|
fee658ad05 | ||
|
|
c41278b46f | ||
|
|
adfcec51d6 | ||
|
|
deb5fcbed4 |
||
|
|
572bdb5b80 | ||
|
|
d94a3c4326 |
||
|
|
3c352b7132 | ||
|
|
5bbe2bcc50 | ||
|
|
a2dca62360 | ||
|
|
ca07fff3e2 | ||
|
|
3af313e165 | ||
|
|
994ce46ed4 |
||
|
|
8059c8a27c | ||
|
|
8aa9cf629c | ||
|
|
234230e595 | ||
|
|
1fda4806d4 | ||
|
|
c635f6ebac | ||
|
|
3bd05b854a | ||
|
|
677ba9dddd | ||
|
|
e03aed0002 | ||
|
|
a21c00b54d | ||
|
|
23335e123b | ||
|
|
a27c0a0124 | ||
|
|
0ea139dfe5 | ||
|
|
d1d369456a | ||
|
|
8b73fc6b47 | ||
|
|
63fb750f39 | ||
|
|
89cffcf14c | ||
|
|
9c43b4164d | ||
|
|
0e42444866 | ||
|
|
e70974a4f1 | ||
|
|
42a66c2f04 | ||
|
|
be95359a80 | ||
|
|
ef74d0ff70 | ||
|
|
4a151b15cc | ||
|
|
30f1a0705b | ||
|
|
b998c2793f | ||
|
|
bc88491cb7 | ||
|
|
1335bcb893 | ||
|
|
ebd3358e49 | ||
|
|
d94d363ec0 | ||
|
|
95900b9d02 | ||
|
|
3001eec66a | ||
|
|
425ac4357f | ||
|
|
b077e63dc6 | ||
|
|
5ee954e34b |
||
|
|
013496862f |
||
|
|
0b68996cc5 | ||
|
|
38d28dd958 | ||
|
|
51ee8caa4a | ||
|
|
dc18f62089 | ||
|
|
e054704fb6 | ||
|
|
6e1e815c78 |
||
|
|
8b9d7fdbd8 | ||
|
|
8cac6ff301 | ||
|
|
9ad76d279e | ||
|
|
c094dde3ff | ||
|
|
e238df3959 | ||
|
|
1a3dcf4943 | ||
|
|
420d0a0ee2 | ||
|
|
6e5ab9e7b3 | ||
|
|
d98a8effb1 | ||
|
|
fdf7c27b54 | ||
|
|
d4cc1374f4 | ||
|
|
f84acae98e | ||
|
|
d4b98d3924 |
||
|
|
45979eb723 | ||
|
|
34ad574bac | ||
|
|
a1a372f179 | ||
|
|
260fbb598e | ||
|
|
2aa2adaa8b | ||
|
|
809fad2392 | ||
|
|
c13dada2f8 | ||
|
|
14f1cc4984 | ||
|
|
98eff2cde9 | ||
|
|
e95b490d88 | ||
|
|
9cc1a7c4c8 | ||
|
|
c6a811237c | ||
|
|
3e6a208ba3 | ||
|
|
c5d30b58a1 | ||
|
|
8bf06a76b5 |
||
|
|
e539c1c024 |
||
|
|
bffefc7db0 |
||
|
|
05daa15aac | ||
|
|
34cffff02a | ||
|
|
dee18ed8ce | ||
|
|
9342b60f14 | ||
|
|
6b27537988 | ||
|
|
272982e8a6 |
||
|
|
93ababe6f7 | ||
|
|
cab5b60e09 |
||
|
|
d7e5e3c9f9 | ||
|
|
27efa8c381 | ||
|
|
03f247845e |
||
|
|
e2f71c6f81 |
||
|
|
692fbfc40a | ||
|
|
f3c8da7acd | ||
|
|
99da46f725 | ||
|
|
7fde34cfcb | ||
|
|
9a1536b52a | ||
|
|
31d6a0bc5e | ||
|
|
f0fadc28dd | ||
|
|
418eb7c5c6 |
||
|
|
ec1dde5dd2 |
||
|
|
2cd7ecaa0a | ||
|
|
6bfd71f5c6 | ||
|
|
4c18730e71 | ||
|
|
48725bb4ea | ||
|
|
4ddd77e512 |
||
|
|
8404b21556 | ||
|
|
5518397338 | ||
|
|
6b47734c04 | ||
|
|
9f5321ff1e |
||
|
|
7588d27f4a |
||
|
|
53a8e5bae5 | ||
|
|
4a0bd960e9 | ||
|
|
07aad51176 |
||
|
|
b8fc8e2cd7 |
||
|
|
4d49a5a397 | ||
|
|
6bda225786 | ||
|
|
1154048f79 | ||
|
|
484bef0d3b | ||
|
|
21f8aab531 | ||
|
|
733447d7c7 | ||
|
|
72ac9bf82f | ||
|
|
5be728c2dd | ||
|
|
0fe1619910 | ||
|
|
ee64130fa8 | ||
|
|
c588a89f26 | ||
|
|
b29ccb59c7 | ||
|
|
3ecd131e57 |
||
|
|
63f923d013 | ||
|
|
3db37e9a21 | ||
|
|
83a6872d1b | ||
|
|
52bf222d48 | ||
|
|
98611b3da0 | ||
|
|
22bade4562 | ||
|
|
8494be07ae | ||
|
|
710be684b8 | ||
|
|
b35522c6dd | ||
|
|
b470ab5c41 | ||
|
|
df2cc923c6 | ||
|
|
e15ff2d86e | ||
|
|
3e61a41b9b | ||
|
|
aff7a6985e | ||
|
|
00e233d7a7 | ||
|
|
ae11fa5887 |
||
|
|
6f610e1d94 | ||
|
|
eac028d3f7 | ||
|
|
3652b7472a | ||
|
|
f156bf9e6b | ||
|
|
187d91d686 | ||
|
|
518fc63224 | ||
|
|
575a29c424 | ||
|
|
264d0ab471 | ||
|
|
65521f03db | ||
|
|
612da8eae6 | ||
|
|
db796771e2 | ||
|
|
c603faac5b | ||
|
|
ca66ea57d2 | ||
|
|
51d60d7ddf | ||
|
|
f832435b88 | ||
|
|
fa9cc9efaf | ||
|
|
26262d08f3 | ||
|
|
aacf25cf19 |
||
|
|
bcc781f4c5 | ||
|
|
fb8b6b2311 | ||
|
|
2355067ef5 | ||
|
|
bb030ba46f | ||
|
|
c51d9246b9 | ||
|
|
9b5a73ba4c | ||
|
|
719e757252 | ||
|
|
000eeb4464 | ||
|
|
042881a522 | ||
|
|
0b166befc0 | ||
|
|
497290beaf | ||
|
|
9bec7c38eb | ||
|
|
93a02281da | ||
|
|
00632ded30 | ||
|
|
2865d3956f | ||
|
|
4f16e14d7a | ||
|
|
fedbfcc368 | ||
|
|
9eb9ffae3d | ||
|
|
f6bf2d8045 | ||
|
|
c35859ae3d | ||
|
|
c9b5f5d598 | ||
|
|
61171f0154 | ||
|
|
26af9b9c4a | ||
|
|
641bc4453b | ||
|
|
2ea60e12d9 | ||
|
|
6e788b49ed | ||
|
|
a2a5dff709 | ||
|
|
a51608090b | ||
|
|
6958e21b5c | ||
|
|
b597bb6b3e | ||
|
|
918f356120 | ||
|
|
51c436fed2 | ||
|
|
382a871583 | ||
|
|
af5d5d0243 | ||
|
|
55f860c304 | ||
|
|
c7956eed77 | ||
|
|
c556fad65d | ||
|
|
c36a74ece1 | ||
|
|
c23fa850e7 | ||
|
|
0f013ff497 | ||
|
|
b29e487bc3 | ||
|
|
7ab091e8ef | ||
|
|
68ccb7578b | ||
|
|
42f8b402e6 | ||
|
|
f9ae92b377 | ||
|
|
05b479224f | ||
|
|
6d085af28c | ||
|
|
02ae1a0029 | ||
|
|
83adf55b2d |
241 changed files with 53425 additions and 7505 deletions
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
|
|
@ -5,9 +5,7 @@ updates:
|
|||
schedule:
|
||||
interval: daily
|
||||
time: "13:00"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: black
|
||||
versions:
|
||||
- 21.4b0
|
||||
- 21.4b1
|
||||
groups:
|
||||
python-packages:
|
||||
patterns:
|
||||
- "*"
|
||||
|
|
|
|||
94
.github/workflows/deploy-latest.yml
vendored
94
.github/workflows/deploy-latest.yml
vendored
|
|
@ -1,9 +1,11 @@
|
|||
name: Deploy latest.datasette.io
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
# - 1.0-dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -13,36 +15,35 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
python-version: "3.13"
|
||||
cache: pip
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e .[test]
|
||||
python -m pip install -e .[docs]
|
||||
python -m pip install . --group dev
|
||||
python -m pip install sphinx-to-sqlite==0.1a1
|
||||
- name: Run tests
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
- name: Build fixtures.db
|
||||
run: python tests/fixtures.py fixtures.db fixtures.json plugins --extra-db-filename extra_database.db
|
||||
- name: Build fixtures.db and other files needed to deploy the demo
|
||||
run: |-
|
||||
python tests/fixtures.py \
|
||||
fixtures.db \
|
||||
fixtures-config.json \
|
||||
fixtures-metadata.json \
|
||||
plugins \
|
||||
--extra-db-filename extra_database.db
|
||||
- name: Build docs.db
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |-
|
||||
cd docs
|
||||
sphinx-build -b xml . _build
|
||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||
sphinx-to-sqlite ../docs.db _build
|
||||
cd ..
|
||||
- name: Set up the alternate-route demo
|
||||
|
|
@ -56,25 +57,70 @@ jobs:
|
|||
db.route = "alternative-route"
|
||||
' > plugins/alternative_route.py
|
||||
cp fixtures.db fixtures2.db
|
||||
- name: Set up Cloud Run
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
- name: And the counters writable stored query demo
|
||||
run: |
|
||||
cat > plugins/counters.py <<EOF
|
||||
from datasette import hookimpl
|
||||
@hookimpl
|
||||
def startup(datasette):
|
||||
db = datasette.add_memory_database("counters")
|
||||
async def inner():
|
||||
await db.execute_write("create table if not exists counters (name text primary key, value integer)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_a', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_b', 0)")
|
||||
await db.execute_write("insert or ignore into counters (name, value) values ('counter_c', 0)")
|
||||
for name in ("counter_a", "counter_b", "counter_c"):
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"increment_{}".format(name),
|
||||
"update counters set value = value + 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} incremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
is_trusted=True,
|
||||
)
|
||||
await datasette.add_query(
|
||||
"counters",
|
||||
"decrement_{}".format(name),
|
||||
"update counters set value = value - 1 where name = '{}'".format(name),
|
||||
on_success_message_sql="select 'Counter {name} decremented to ' || value from counters where name = '{name}'".format(name=name),
|
||||
is_write=True,
|
||||
is_trusted=True,
|
||||
)
|
||||
return inner
|
||||
EOF
|
||||
# - name: Make some modifications to metadata.json
|
||||
# run: |
|
||||
# cat fixtures.json | \
|
||||
# jq '.databases |= . + {"ephemeral": {"allow": {"id": "*"}}}' | \
|
||||
# jq '.plugins |= . + {"datasette-ephemeral-tables": {"table_ttl": 900}}' \
|
||||
# > metadata.json
|
||||
# cat metadata.json
|
||||
- id: auth
|
||||
name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v3
|
||||
with:
|
||||
version: '275.0.0'
|
||||
service_account_email: ${{ secrets.GCP_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GCP_SA_KEY }}
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
- name: Deploy to Cloud Run
|
||||
env:
|
||||
LATEST_DATASETTE_SECRET: ${{ secrets.LATEST_DATASETTE_SECRET }}
|
||||
run: |-
|
||||
gcloud config set run/region us-central1
|
||||
gcloud config set project datasette-222320
|
||||
export SUFFIX="-${GITHUB_REF#refs/heads/}"
|
||||
export SUFFIX=${SUFFIX#-main}
|
||||
# Replace 1.0 with one-dot-zero in SUFFIX
|
||||
export SUFFIX=${SUFFIX//1.0/one-dot-zero}
|
||||
datasette publish cloudrun fixtures.db fixtures2.db extra_database.db \
|
||||
-m fixtures.json \
|
||||
-m fixtures-metadata.json \
|
||||
--plugins-dir=plugins \
|
||||
--branch=$GITHUB_SHA \
|
||||
--version-note=$GITHUB_SHA \
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb" \
|
||||
--service "datasette-latest$SUFFIX"
|
||||
--extra-options="--setting template_debug 1 --setting trace_debug 1 --crossdb --root" \
|
||||
--install 'datasette-ephemeral-tables>=0.2.2' \
|
||||
--service "datasette-latest$SUFFIX" \
|
||||
--secret $LATEST_DATASETTE_SECRET
|
||||
- name: Deploy to docs as well (only for main)
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |-
|
||||
|
|
|
|||
2
.github/workflows/documentation-links.yml
vendored
2
.github/workflows/documentation-links.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Read the Docs Pull Request Preview
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
|
|
|
|||
4
.github/workflows/prettier.yml
vendored
4
.github/workflows/prettier.yml
vendored
|
|
@ -10,8 +10,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
name: Configure npm caching
|
||||
with:
|
||||
path: ~/.npm
|
||||
|
|
|
|||
77
.github/workflows/publish.yml
vendored
77
.github/workflows/publish.yml
vendored
|
|
@ -12,23 +12,18 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e '.[test]'
|
||||
pip install . --group dev
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest
|
||||
|
|
@ -36,63 +31,55 @@ jobs:
|
|||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test]
|
||||
environment: release
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-publish-pip-
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install setuptools wheel twine
|
||||
- name: Publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
pip install setuptools wheel build
|
||||
- name: Build
|
||||
run: |
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
python -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
deploy_static_docs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-publish-pip-
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -e .[docs]
|
||||
python -m pip install . --group dev
|
||||
python -m pip install sphinx-to-sqlite==0.1a1
|
||||
- name: Build docs.db
|
||||
run: |-
|
||||
cd docs
|
||||
sphinx-build -b xml . _build
|
||||
DISABLE_SPHINX_INLINE_TABS=1 sphinx-build -b xml . _build
|
||||
sphinx-to-sqlite ../docs.db _build
|
||||
cd ..
|
||||
- name: Set up Cloud Run
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
- id: auth
|
||||
name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
version: '275.0.0'
|
||||
service_account_email: ${{ secrets.GCP_SA_EMAIL }}
|
||||
service_account_key: ${{ secrets.GCP_SA_KEY }}
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
- name: Set up Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v3
|
||||
- name: Deploy stable-docs.datasette.io to Cloud Run
|
||||
run: |-
|
||||
gcloud config set run/region us-central1
|
||||
|
|
@ -105,7 +92,7 @@ jobs:
|
|||
needs: [deploy]
|
||||
if: "!github.event.release.prerelease"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
2
.github/workflows/push_docker_tag.yml
vendored
2
.github/workflows/push_docker_tag.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
deploy_docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Build and push to Docker Hub
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
|
|
|
|||
21
.github/workflows/spellcheck.yml
vendored
21
.github/workflows/spellcheck.yml
vendored
|
|
@ -9,22 +9,19 @@ jobs:
|
|||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.9
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e '.[docs]'
|
||||
pip install . --group dev
|
||||
- name: Check spelling
|
||||
run: |
|
||||
codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
|
|
|||
76
.github/workflows/stable-docs.yml
vendored
Normal file
76
.github/workflows/stable-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: Update Stable Docs
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update_stable_docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # We need all commits to find docs/ changes
|
||||
- name: Set up Git user
|
||||
run: |
|
||||
git config user.name "Automated"
|
||||
git config user.email "actions@users.noreply.github.com"
|
||||
- name: Create stable branch if it does not yet exist
|
||||
run: |
|
||||
if ! git ls-remote --heads origin stable | grep -qE '\bstable\b'; then
|
||||
# Make sure we have all tags locally
|
||||
git fetch --tags --quiet
|
||||
|
||||
# Latest tag that is just numbers and dots (optionally prefixed with 'v')
|
||||
# e.g., 0.65.2 or v0.65.2 — excludes 1.0a20, 1.0-rc1, etc.
|
||||
LATEST_RELEASE=$(
|
||||
git tag -l --sort=-v:refname \
|
||||
| grep -E '^v?[0-9]+(\.[0-9]+){1,3}$' \
|
||||
| head -n1
|
||||
)
|
||||
|
||||
git checkout -b stable
|
||||
|
||||
# If there are any stable releases, copy docs/ from the most recent
|
||||
if [ -n "$LATEST_RELEASE" ]; then
|
||||
rm -rf docs/
|
||||
git checkout "$LATEST_RELEASE" -- docs/ || true
|
||||
fi
|
||||
|
||||
git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes"
|
||||
git push -u origin stable
|
||||
fi
|
||||
- name: Handle Release
|
||||
if: github.event_name == 'release' && !github.event.release.prerelease
|
||||
run: |
|
||||
git fetch --all
|
||||
git checkout stable
|
||||
git reset --hard ${GITHUB_REF#refs/tags/}
|
||||
git push origin stable --force
|
||||
- name: Handle Commit to Main
|
||||
if: contains(github.event.head_commit.message, '!stable-docs')
|
||||
run: |
|
||||
git fetch origin
|
||||
git checkout -b stable origin/stable
|
||||
# Get the list of modified files in docs/ from the current commit
|
||||
FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)
|
||||
# Check if the list of files is non-empty
|
||||
if [[ -n "$FILES" ]]; then
|
||||
# Checkout those files to the stable branch to over-write with their contents
|
||||
for FILE in $FILES; do
|
||||
git checkout ${{ github.sha }} -- $FILE
|
||||
done
|
||||
git add docs/
|
||||
git commit -m "Doc changes from ${{ github.sha }}"
|
||||
git push origin stable
|
||||
else
|
||||
echo "No changes to docs/ in this commit."
|
||||
exit 0
|
||||
fi
|
||||
19
.github/workflows/test-coverage.yml
vendored
19
.github/workflows/test-coverage.yml
vendored
|
|
@ -15,28 +15,23 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out datasette
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.9
|
||||
- uses: actions/cache@v2
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e .[test]
|
||||
python -m pip install . --group dev
|
||||
python -m pip install pytest-cov
|
||||
- name: Run tests
|
||||
run: |-
|
||||
ls -lah
|
||||
cat .coveragerc
|
||||
pytest --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term
|
||||
pytest -m "not serial" --cov=datasette --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term -x
|
||||
ls -lah
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@v1
|
||||
|
|
|
|||
8
.github/workflows/test-pyodide.yml
vendored
8
.github/workflows/test-pyodide.yml
vendored
|
|
@ -12,15 +12,15 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: 'pip'
|
||||
cache-dependency-path: '**/setup.py'
|
||||
cache-dependency-path: '**/pyproject.toml'
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright/
|
||||
key: ${{ runner.os }}-browsers
|
||||
|
|
|
|||
53
.github/workflows/test-sqlite-support.yml
vendored
Normal file
53
.github/workflows/test-sqlite-support.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Test SQLite versions
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ${{ matrix.platform }}
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
sqlite-version: [
|
||||
#"3", # latest version
|
||||
"3.46",
|
||||
#"3.45",
|
||||
#"3.27",
|
||||
#"3.26",
|
||||
"3.25",
|
||||
#"3.25.3", # 2018-09-25, window functions breaks test_upsert for some reason on 3.10, skip for now
|
||||
#"3.24", # 2018-06-04, added UPSERT support
|
||||
#"3.23.1" # 2018-04-10, before UPSERT
|
||||
]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Set up SQLite ${{ matrix.sqlite-version }}
|
||||
uses: asg017/sqlite-versions@71ea0de37ae739c33e447af91ba71dda8fcf22e6
|
||||
with:
|
||||
version: ${{ matrix.sqlite-version }}
|
||||
cflags: "-DSQLITE_ENABLE_DESERIALIZE -DSQLITE_ENABLE_FTS5 -DSQLITE_ENABLE_FTS4 -DSQLITE_ENABLE_FTS3_PARENTHESIS -DSQLITE_ENABLE_RTREE -DSQLITE_ENABLE_JSON1"
|
||||
- run: python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
|
||||
- run: echo $LD_LIBRARY_PATH
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install . --group dev
|
||||
pip freeze
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
30
.github/workflows/test.yml
vendored
30
.github/workflows/test.yml
vendored
|
|
@ -10,31 +10,35 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v3
|
||||
name: Configure pip caching
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
allow-prereleases: true
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Build extension for --load-extension test
|
||||
run: |-
|
||||
(cd tests && gcc ext.c -fPIC -shared -o ext.so)
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install -e '.[test]'
|
||||
pip install . --group dev
|
||||
pip freeze
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest -n auto -m "not serial"
|
||||
pytest -m "serial"
|
||||
# And the test that exceeds a localhost HTTPS server
|
||||
tests/test_datasette_https_server.sh
|
||||
- name: Black
|
||||
run: |
|
||||
black --version
|
||||
black --check .
|
||||
- name: Ruff
|
||||
run: ruff check datasette tests
|
||||
- name: Check if cog needs to be run
|
||||
run: |
|
||||
cog --check docs/*.rst
|
||||
|
|
@ -42,3 +46,7 @@ jobs:
|
|||
run: |
|
||||
# This fails on syntax errors, or a diff was applied
|
||||
blacken-docs -l 60 docs/*.rst
|
||||
- name: Test DATASETTE_LOAD_PLUGINS
|
||||
run: |
|
||||
pip install datasette-init datasette-json-html
|
||||
tests/test-datasette-load-plugins.sh
|
||||
|
|
|
|||
2
.github/workflows/tmate-mac.yml
vendored
2
.github/workflows/tmate-mac.yml
vendored
|
|
@ -10,6 +10,6 @@ jobs:
|
|||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
|
|
|
|||
5
.github/workflows/tmate.yml
vendored
5
.github/workflows/tmate.yml
vendored
|
|
@ -5,11 +5,14 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -5,6 +5,12 @@ scratchpad
|
|||
|
||||
.vscode
|
||||
|
||||
uv.lock
|
||||
data.db
|
||||
|
||||
# test databases
|
||||
*.db
|
||||
|
||||
# We don't use Pipfile, so ignore them
|
||||
Pipfile
|
||||
Pipfile.lock
|
||||
|
|
@ -123,4 +129,6 @@ node_modules
|
|||
# include it in source control.
|
||||
tests/*.dylib
|
||||
tests/*.so
|
||||
tests/*.dll
|
||||
tests/*.dll
|
||||
|
||||
.idea
|
||||
|
|
@ -1,16 +1,17 @@
|
|||
version: 2
|
||||
|
||||
build:
|
||||
os: ubuntu-20.04
|
||||
tools:
|
||||
python: "3.9"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
configuration: docs/conf.py
|
||||
|
||||
python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
extra_requirements:
|
||||
- docs
|
||||
build:
|
||||
os: ubuntu-24.04
|
||||
tools:
|
||||
python: "3.13"
|
||||
jobs:
|
||||
install:
|
||||
- pip install --upgrade pip
|
||||
- pip install . --group dev
|
||||
|
||||
formats:
|
||||
- pdf
|
||||
- epub
|
||||
|
|
|
|||
60
Justfile
Normal file
60
Justfile
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
export DATASETTE_SECRET := "not_a_secret"
|
||||
|
||||
# Run tests and linters
|
||||
@default: test lint
|
||||
|
||||
# Setup project
|
||||
@init:
|
||||
uv sync
|
||||
|
||||
# Run pytest with supplied options
|
||||
@test *options: init
|
||||
uv run pytest -n auto {{options}}
|
||||
|
||||
@codespell:
|
||||
uv run codespell README.md --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell docs/*.rst --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell datasette -S datasette/static --ignore-words docs/codespell-ignore-words.txt
|
||||
uv run codespell tests --ignore-words docs/codespell-ignore-words.txt
|
||||
|
||||
# Run linters: black, ruff, cog
|
||||
@lint: codespell
|
||||
uv run black datasette tests --check
|
||||
uv run ruff check datasette tests
|
||||
uv run cog --check README.md docs/*.rst
|
||||
|
||||
# Apply ruff fixes
|
||||
@fix:
|
||||
uv run ruff check --fix datasette tests
|
||||
|
||||
# Rebuild docs with cog
|
||||
@cog:
|
||||
uv run cog -r README.md docs/*.rst
|
||||
|
||||
# Serve live docs on localhost:8000
|
||||
@docs: cog blacken-docs
|
||||
uv run make -C docs livehtml
|
||||
|
||||
# Build docs as static HTML
|
||||
@docs-build: cog blacken-docs
|
||||
rm -rf docs/_build && cd docs && uv run make html
|
||||
|
||||
# Apply Black
|
||||
@black:
|
||||
uv run black datasette tests
|
||||
|
||||
# Apply blacken-docs
|
||||
@blacken-docs:
|
||||
uv run blacken-docs -l 60 docs/*.rst
|
||||
|
||||
# Apply prettier
|
||||
@prettier:
|
||||
npm run fix
|
||||
|
||||
# Format code with both black and prettier
|
||||
@format: black prettier blacken-docs
|
||||
|
||||
@serve *options:
|
||||
uv run sqlite-utils create-database data.db
|
||||
uv run sqlite-utils create-table data.db docs id integer title text --pk id --ignore
|
||||
uv run python -m datasette data.db --root --reload {{options}}
|
||||
10
README.md
10
README.md
|
|
@ -1,13 +1,13 @@
|
|||
<img src="https://datasette.io/static/datasette-logo.svg" alt="Datasette">
|
||||
|
||||
[](https://pypi.org/project/datasette/)
|
||||
[](https://docs.datasette.io/en/stable/changelog.html)
|
||||
[](https://docs.datasette.io/en/latest/changelog.html)
|
||||
[](https://pypi.org/project/datasette/)
|
||||
[](https://github.com/simonw/datasette/actions?query=workflow%3ATest)
|
||||
[](https://docs.datasette.io/en/latest/?badge=latest)
|
||||
[](https://github.com/simonw/datasette/blob/main/LICENSE)
|
||||
[](https://hub.docker.com/r/datasetteproject/datasette)
|
||||
[](https://discord.gg/ktd74dm5mw)
|
||||
[](https://datasette.io/discord)
|
||||
|
||||
*An open source multi-tool for exploring and publishing data*
|
||||
|
||||
|
|
@ -15,14 +15,14 @@ Datasette is a tool for exploring and publishing data. It helps people take data
|
|||
|
||||
Datasette is aimed at data journalists, museum curators, archivists, local governments, scientists, researchers and anyone else who has data that they wish to share with the world.
|
||||
|
||||
[Explore a demo](https://global-power-plants.datasettes.com/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out by [uploading and publishing your own CSV data](https://docs.datasette.io/en/stable/getting_started.html#try-datasette-without-installing-anything-using-glitch).
|
||||
[Explore a demo](https://datasette.io/global-power-plants/global-power-plants), watch [a video about the project](https://simonwillison.net/2021/Feb/7/video/) or try it out [on GitHub Codespaces](https://github.com/datasette/datasette-studio).
|
||||
|
||||
* [datasette.io](https://datasette.io/) is the official project website
|
||||
* Latest [Datasette News](https://datasette.io/news)
|
||||
* Comprehensive documentation: https://docs.datasette.io/
|
||||
* Examples: https://datasette.io/examples
|
||||
* Live demo of current `main` branch: https://latest.datasette.io/
|
||||
* Questions, feedback or want to talk about the project? Join our [Discord](https://discord.gg/ktd74dm5mw)
|
||||
* Questions, feedback or want to talk about the project? Join our [Discord](https://datasette.io/discord)
|
||||
|
||||
Want to stay up-to-date with the project? Subscribe to the [Datasette newsletter](https://datasette.substack.com/) for tips, tricks and news on what's new in the Datasette ecosystem.
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ You can also install it using `pip` or `pipx`:
|
|||
|
||||
pip install datasette
|
||||
|
||||
Datasette requires Python 3.7 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||
Datasette requires Python 3.8 or higher. We also have [detailed installation instructions](https://docs.datasette.io/en/stable/installation.html) covering other options such as Docker.
|
||||
|
||||
## Basic usage
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
from datasette.permissions import Permission # noqa
|
||||
from datasette.version import __version_info__, __version__ # noqa
|
||||
from datasette.events import Event # noqa
|
||||
from datasette.tokens import TokenHandler, TokenRestrictions # noqa
|
||||
from datasette.utils.asgi import Forbidden, NotFound, Request, Response # noqa
|
||||
from datasette.utils import actor_matches_allow # noqa
|
||||
from datasette.views import Context # noqa
|
||||
from .hookspecs import hookimpl # noqa
|
||||
from .hookspecs import hookspec # noqa
|
||||
|
|
|
|||
108
datasette/_pytest_plugin.py
Normal file
108
datasette/_pytest_plugin.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
Pytest plugin that automatically closes any Datasette instances constructed
|
||||
during a pytest test — both in the test body and in function-scoped
|
||||
fixtures. Instances constructed by session-, module-, class- or package-
|
||||
scoped fixtures are left alone, because other tests in the session will
|
||||
still want to use them.
|
||||
|
||||
Registered as a pytest11 entry point in pyproject.toml so that downstream
|
||||
projects using Datasette get the same FD-safety net for their own tests.
|
||||
|
||||
Opt out by setting ``datasette_autoclose = false`` in pytest.ini (or the
|
||||
equivalent ini file).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
import weakref
|
||||
|
||||
import pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
|
||||
_active_instances: contextvars.ContextVar[list | None] = contextvars.ContextVar(
|
||||
"datasette_active_instances", default=None
|
||||
)
|
||||
|
||||
_original_init = Datasette.__init__
|
||||
|
||||
|
||||
def _tracking_init(self, *args, **kwargs):
|
||||
_original_init(self, *args, **kwargs)
|
||||
instances = _active_instances.get()
|
||||
if instances is not None:
|
||||
instances.append(weakref.ref(self))
|
||||
|
||||
|
||||
Datasette.__init__ = _tracking_init
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addini(
|
||||
"datasette_autoclose",
|
||||
help=(
|
||||
"Automatically close Datasette instances created inside test "
|
||||
"bodies and function-scoped fixtures (default: true)."
|
||||
),
|
||||
default="true",
|
||||
)
|
||||
|
||||
|
||||
def _enabled(config) -> bool:
|
||||
value = config.getini("datasette_autoclose")
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() not in ("false", "0", "no", "off")
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_protocol(item, nextitem):
|
||||
"""Track Datasette instances across setup, call and teardown; close at end."""
|
||||
if not _enabled(item.config):
|
||||
yield
|
||||
return
|
||||
refs: list[weakref.ref] = []
|
||||
token = _active_instances.set(refs)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_active_instances.reset(token)
|
||||
for ref in reversed(refs):
|
||||
ds = ref()
|
||||
if ds is None:
|
||||
continue
|
||||
try:
|
||||
ds.close()
|
||||
except Exception as e:
|
||||
item.warn(
|
||||
pytest.PytestUnraisableExceptionWarning(
|
||||
f"Error closing Datasette instance: {e!r}"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_fixture_setup(fixturedef, request):
|
||||
"""Exempt instances created by non-function-scoped fixtures.
|
||||
|
||||
Session-, module-, class- and package-scoped fixtures produce Datasette
|
||||
instances that must survive beyond the current test — other tests in
|
||||
the session will still use them. When such a fixture creates one or
|
||||
more Datasette instances during its setup, we snapshot the tracking
|
||||
list before the fixture runs and subtract off any instances that were
|
||||
added during its setup, so they don't get closed at test teardown.
|
||||
"""
|
||||
refs = _active_instances.get()
|
||||
if refs is None:
|
||||
yield
|
||||
return
|
||||
before_ids = {id(ref) for ref in refs}
|
||||
yield
|
||||
if fixturedef.scope != "function":
|
||||
new_refs = [ref for ref in refs if id(ref) not in before_ids]
|
||||
for new_ref in new_refs:
|
||||
try:
|
||||
refs.remove(new_ref)
|
||||
except ValueError:
|
||||
pass
|
||||
2115
datasette/app.py
2115
datasette/app.py
File diff suppressed because it is too large
Load diff
460
datasette/cli.py
460
datasette/cli.py
|
|
@ -4,16 +4,17 @@ import click
|
|||
from click import formatting
|
||||
from click.types import CompositeParamType
|
||||
from click_default_group import DefaultGroup
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from runpy import run_module
|
||||
import shutil
|
||||
from subprocess import call
|
||||
import sys
|
||||
from runpy import run_module
|
||||
import textwrap
|
||||
import webbrowser
|
||||
from .app import (
|
||||
OBSOLETE_SETTINGS,
|
||||
Datasette,
|
||||
DEFAULT_SETTINGS,
|
||||
SETTINGS,
|
||||
|
|
@ -24,11 +25,13 @@ from .utils import (
|
|||
LoadExtension,
|
||||
StartupError,
|
||||
check_connection,
|
||||
deep_dict_update,
|
||||
find_spatialite,
|
||||
parse_metadata,
|
||||
ConnectionProblem,
|
||||
SpatialiteConnectionProblem,
|
||||
initial_path_for_datasette,
|
||||
pairs_to_nested_config,
|
||||
temporary_docker_directory,
|
||||
value_as_boolean,
|
||||
SpatialiteNotFound,
|
||||
|
|
@ -39,6 +42,18 @@ from .utils.sqlite import sqlite3
|
|||
from .utils.testing import TestClient
|
||||
from .version import __version__
|
||||
|
||||
|
||||
def run_sync(coro_func):
|
||||
"""Run an async callable to completion on a fresh event loop."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro_func())
|
||||
finally:
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
|
||||
# Use Rich for tracebacks if it is installed
|
||||
try:
|
||||
from rich.traceback import install
|
||||
|
|
@ -48,93 +63,61 @@ except ImportError:
|
|||
pass
|
||||
|
||||
|
||||
class Config(click.ParamType):
|
||||
# This will be removed in Datasette 1.0 in favour of class Setting
|
||||
name = "config"
|
||||
|
||||
def convert(self, config, param, ctx):
|
||||
if ":" not in config:
|
||||
self.fail(f'"{config}" should be name:value', param, ctx)
|
||||
return
|
||||
name, value = config.split(":", 1)
|
||||
if name not in DEFAULT_SETTINGS:
|
||||
msg = (
|
||||
OBSOLETE_SETTINGS.get(name)
|
||||
or f"{name} is not a valid option (--help-settings to see all)"
|
||||
)
|
||||
self.fail(
|
||||
msg,
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return
|
||||
# Type checking
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, value_as_boolean(value)
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
return
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return
|
||||
return name, int(value)
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
|
||||
|
||||
class Setting(CompositeParamType):
|
||||
name = "setting"
|
||||
arity = 2
|
||||
|
||||
def convert(self, config, param, ctx):
|
||||
name, value = config
|
||||
if name not in DEFAULT_SETTINGS:
|
||||
msg = (
|
||||
OBSOLETE_SETTINGS.get(name)
|
||||
or f"{name} is not a valid option (--help-settings to see all)"
|
||||
)
|
||||
self.fail(
|
||||
msg,
|
||||
param,
|
||||
ctx,
|
||||
)
|
||||
return
|
||||
# Type checking
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, value_as_boolean(value)
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
return
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return
|
||||
return name, int(value)
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
if name in DEFAULT_SETTINGS:
|
||||
# For backwards compatibility with how this worked prior to
|
||||
# Datasette 1.0, we turn bare setting names into setting.name
|
||||
# Type checking for those older settings
|
||||
default = DEFAULT_SETTINGS[name]
|
||||
name = "settings.{}".format(name)
|
||||
if isinstance(default, bool):
|
||||
try:
|
||||
return name, "true" if value_as_boolean(value) else "false"
|
||||
except ValueAsBooleanError:
|
||||
self.fail(f'"{name}" should be on/off/true/false/1/0', param, ctx)
|
||||
elif isinstance(default, int):
|
||||
if not value.isdigit():
|
||||
self.fail(f'"{name}" should be an integer', param, ctx)
|
||||
return name, value
|
||||
elif isinstance(default, str):
|
||||
return name, value
|
||||
else:
|
||||
# Should never happen:
|
||||
self.fail("Invalid option")
|
||||
return name, value
|
||||
|
||||
|
||||
def sqlite_extensions(fn):
|
||||
return click.option(
|
||||
fn = click.option(
|
||||
"sqlite_extensions",
|
||||
"--load-extension",
|
||||
type=LoadExtension(),
|
||||
envvar="SQLITE_EXTENSIONS",
|
||||
envvar="DATASETTE_LOAD_EXTENSION",
|
||||
multiple=True,
|
||||
help="Path to a SQLite extension to load, and optional entrypoint",
|
||||
)(fn)
|
||||
|
||||
# Wrap it in a custom error handler
|
||||
@functools.wraps(fn)
|
||||
def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AttributeError as e:
|
||||
if "enable_load_extension" in str(e):
|
||||
raise click.ClickException(textwrap.dedent("""
|
||||
Your Python installation does not have the ability to load SQLite extensions.
|
||||
|
||||
More information: https://datasette.io/help/extensions
|
||||
""").strip())
|
||||
raise
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)
|
||||
@click.version_option(version=__version__)
|
||||
|
|
@ -159,9 +142,7 @@ def inspect(files, inspect_file, sqlite_extensions):
|
|||
This can then be passed to "datasette --inspect-file" to speed up count
|
||||
operations against immutable database files.
|
||||
"""
|
||||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
loop = asyncio.get_event_loop()
|
||||
inspect_data = loop.run_until_complete(inspect_(files, sqlite_extensions))
|
||||
inspect_data = run_sync(lambda: inspect_(files, sqlite_extensions))
|
||||
if inspect_file == "-":
|
||||
sys.stdout.write(json.dumps(inspect_data, indent=2))
|
||||
else:
|
||||
|
|
@ -173,9 +154,6 @@ async def inspect_(files, sqlite_extensions):
|
|||
app = Datasette([], immutables=files, sqlite_extensions=sqlite_extensions)
|
||||
data = {}
|
||||
for name, database in app.databases.items():
|
||||
if name == "_internal":
|
||||
# Don't include the in-memory _internal database
|
||||
continue
|
||||
counts = await database.table_counts(limit=3600 * 1000)
|
||||
data[name] = {
|
||||
"hash": database.hash,
|
||||
|
|
@ -201,15 +179,23 @@ pm.hook.publish_subcommand(publish=publish)
|
|||
|
||||
@cli.command()
|
||||
@click.option("--all", help="Include built-in default plugins", is_flag=True)
|
||||
@click.option(
|
||||
"--requirements", help="Output requirements.txt of installed plugins", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
help="Path to directory containing custom plugins",
|
||||
)
|
||||
def plugins(all, plugins_dir):
|
||||
def plugins(all, requirements, plugins_dir):
|
||||
"""List currently installed plugins"""
|
||||
app = Datasette([], plugins_dir=plugins_dir)
|
||||
click.echo(json.dumps(app._plugins(all=all), indent=4))
|
||||
if requirements:
|
||||
for plugin in app._plugins():
|
||||
if plugin["version"]:
|
||||
click.echo("{}=={}".format(plugin["name"], plugin["version"]))
|
||||
else:
|
||||
click.echo(json.dumps(app._plugins(all=all), indent=4))
|
||||
|
||||
|
||||
@cli.command()
|
||||
|
|
@ -319,15 +305,32 @@ def package(
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("packages", nargs=-1, required=True)
|
||||
@click.argument("packages", nargs=-1)
|
||||
@click.option(
|
||||
"-U", "--upgrade", is_flag=True, help="Upgrade packages to latest version"
|
||||
)
|
||||
def install(packages, upgrade):
|
||||
@click.option(
|
||||
"-r",
|
||||
"--requirement",
|
||||
type=click.Path(exists=True),
|
||||
help="Install from requirements file",
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--editable",
|
||||
help="Install a project in editable mode from this path",
|
||||
)
|
||||
def install(packages, upgrade, requirement, editable):
|
||||
"""Install plugins and packages from PyPI into the same environment as Datasette"""
|
||||
if not packages and not requirement and not editable:
|
||||
raise click.UsageError("Please specify at least one package to install")
|
||||
args = ["pip", "install"]
|
||||
if upgrade:
|
||||
args += ["--upgrade"]
|
||||
if editable:
|
||||
args += ["--editable", editable]
|
||||
if requirement:
|
||||
args += ["-r", requirement]
|
||||
args += list(packages)
|
||||
sys.argv = args
|
||||
run_module("pip", run_name="__main__")
|
||||
|
|
@ -408,16 +411,17 @@ def uninstall(packages, yes):
|
|||
)
|
||||
@click.option("--memory", is_flag=True, help="Make /_memory database available")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
type=Config(),
|
||||
help="Deprecated: set config option using configname:value. Use --setting instead.",
|
||||
multiple=True,
|
||||
type=click.File(mode="r"),
|
||||
help="Path to JSON/YAML Datasette configuration file",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--setting",
|
||||
"settings",
|
||||
type=Setting(),
|
||||
help="Setting, see docs.datasette.io/en/stable/settings.html",
|
||||
help="nested.key, value setting to use in Datasette configuration",
|
||||
multiple=True,
|
||||
)
|
||||
@click.option(
|
||||
|
|
@ -430,10 +434,28 @@ def uninstall(packages, yes):
|
|||
help="Output URL that sets a cookie authenticating the root user",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--default-deny",
|
||||
help="Deny all permissions by default",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--get",
|
||||
help="Run an HTTP GET request against this path, print results and exit",
|
||||
)
|
||||
@click.option(
|
||||
"--headers",
|
||||
is_flag=True,
|
||||
help="Include HTTP headers in --get output",
|
||||
)
|
||||
@click.option(
|
||||
"--token",
|
||||
help="API token to send with --get requests",
|
||||
)
|
||||
@click.option(
|
||||
"--actor",
|
||||
help="Actor to use for --get requests (JSON string)",
|
||||
)
|
||||
@click.option("--version-note", help="Additional note to show on /-/versions")
|
||||
@click.option("--help-settings", is_flag=True, help="Show available settings")
|
||||
@click.option("--pdb", is_flag=True, help="Launch debugger on any errors")
|
||||
|
|
@ -462,10 +484,17 @@ def uninstall(packages, yes):
|
|||
@click.option(
|
||||
"--ssl-keyfile",
|
||||
help="SSL key file",
|
||||
envvar="DATASETTE_SSL_KEYFILE",
|
||||
)
|
||||
@click.option(
|
||||
"--ssl-certfile",
|
||||
help="SSL certificate file",
|
||||
envvar="DATASETTE_SSL_CERTFILE",
|
||||
)
|
||||
@click.option(
|
||||
"--internal",
|
||||
type=click.Path(),
|
||||
help="Path to a persistent Datasette internal SQLite database",
|
||||
)
|
||||
def serve(
|
||||
files,
|
||||
|
|
@ -486,7 +515,11 @@ def serve(
|
|||
settings,
|
||||
secret,
|
||||
root,
|
||||
default_deny,
|
||||
get,
|
||||
headers,
|
||||
token,
|
||||
actor,
|
||||
version_note,
|
||||
help_settings,
|
||||
pdb,
|
||||
|
|
@ -496,6 +529,7 @@ def serve(
|
|||
nolock,
|
||||
ssl_keyfile,
|
||||
ssl_certfile,
|
||||
internal,
|
||||
return_instance=False,
|
||||
):
|
||||
"""Serve up specified SQLite database files with a web UI"""
|
||||
|
|
@ -513,9 +547,11 @@ def serve(
|
|||
if reload:
|
||||
import hupper
|
||||
|
||||
reloader = hupper.start_reloader("datasette.cli.serve")
|
||||
reloader = hupper.start_reloader("datasette.cli.cli")
|
||||
if immutable:
|
||||
reloader.watch_files(immutable)
|
||||
if config:
|
||||
reloader.watch_files([config.name])
|
||||
if metadata:
|
||||
reloader.watch_files([metadata.name])
|
||||
|
||||
|
|
@ -528,44 +564,60 @@ def serve(
|
|||
if metadata:
|
||||
metadata_data = parse_metadata(metadata.read())
|
||||
|
||||
combined_settings = {}
|
||||
config_data = None
|
||||
if config:
|
||||
click.echo(
|
||||
"--config name:value will be deprecated in Datasette 1.0, use --setting name value instead",
|
||||
err=True,
|
||||
)
|
||||
combined_settings.update(config)
|
||||
combined_settings.update(settings)
|
||||
config_data = parse_metadata(config.read())
|
||||
|
||||
config_data = config_data or {}
|
||||
|
||||
# Merge in settings from -s/--setting
|
||||
if settings:
|
||||
settings_updates = pairs_to_nested_config(settings)
|
||||
# Merge recursively, to avoid over-writing nested values
|
||||
# https://github.com/simonw/datasette/issues/2389
|
||||
deep_dict_update(config_data, settings_updates)
|
||||
|
||||
kwargs = dict(
|
||||
immutables=immutable,
|
||||
cache_headers=not reload,
|
||||
cors=cors,
|
||||
inspect_data=inspect_data,
|
||||
config=config_data,
|
||||
metadata=metadata_data,
|
||||
sqlite_extensions=sqlite_extensions,
|
||||
template_dir=template_dir,
|
||||
plugins_dir=plugins_dir,
|
||||
static_mounts=static,
|
||||
settings=combined_settings,
|
||||
settings=None, # These are passed in config= now
|
||||
memory=memory,
|
||||
secret=secret,
|
||||
version_note=version_note,
|
||||
pdb=pdb,
|
||||
crossdb=crossdb,
|
||||
nolock=nolock,
|
||||
internal=internal,
|
||||
default_deny=default_deny,
|
||||
)
|
||||
|
||||
# if files is a single directory, use that as config_dir=
|
||||
if 1 == len(files) and os.path.isdir(files[0]):
|
||||
kwargs["config_dir"] = pathlib.Path(files[0])
|
||||
files = []
|
||||
# Separate directories from files
|
||||
directories = [f for f in files if os.path.isdir(f)]
|
||||
file_paths = [f for f in files if not os.path.isdir(f)]
|
||||
|
||||
# Handle config_dir - only one directory allowed
|
||||
if len(directories) > 1:
|
||||
raise click.ClickException(
|
||||
"Cannot pass multiple directories. Pass a single directory as config_dir."
|
||||
)
|
||||
elif len(directories) == 1:
|
||||
kwargs["config_dir"] = pathlib.Path(directories[0])
|
||||
|
||||
# Verify list of files, create if needed (and --create)
|
||||
for file in files:
|
||||
for file in file_paths:
|
||||
if not pathlib.Path(file).exists():
|
||||
if create:
|
||||
sqlite3.connect(file).execute("vacuum")
|
||||
conn = sqlite3.connect(file)
|
||||
conn.execute("vacuum")
|
||||
conn.close()
|
||||
else:
|
||||
raise click.ClickException(
|
||||
"Invalid value for '[FILES]...': Path '{}' does not exist.".format(
|
||||
|
|
@ -573,8 +625,32 @@ def serve(
|
|||
)
|
||||
)
|
||||
|
||||
# De-duplicate files so 'datasette db.db db.db' only attaches one /db
|
||||
files = list(dict.fromkeys(files))
|
||||
# Check for duplicate files by resolving all paths to their absolute forms
|
||||
# Collect all database files that will be loaded (explicit files + config_dir files)
|
||||
all_db_files = []
|
||||
|
||||
# Add explicit files
|
||||
for file in file_paths:
|
||||
all_db_files.append((file, pathlib.Path(file).resolve()))
|
||||
|
||||
# Add config_dir databases if config_dir is set
|
||||
if "config_dir" in kwargs:
|
||||
config_dir = kwargs["config_dir"]
|
||||
for ext in ("db", "sqlite", "sqlite3"):
|
||||
for db_file in config_dir.glob(f"*.{ext}"):
|
||||
all_db_files.append((str(db_file), db_file.resolve()))
|
||||
|
||||
# Check for duplicates
|
||||
seen = {}
|
||||
for original_path, resolved_path in all_db_files:
|
||||
if resolved_path in seen:
|
||||
raise click.ClickException(
|
||||
f"Duplicate database file: '{original_path}' and '{seen[resolved_path]}' "
|
||||
f"both refer to {resolved_path}"
|
||||
)
|
||||
seen[resolved_path] = original_path
|
||||
|
||||
files = file_paths
|
||||
|
||||
try:
|
||||
ds = Datasette(files, **kwargs)
|
||||
|
|
@ -587,16 +663,43 @@ def serve(
|
|||
# Private utility mechanism for writing unit tests
|
||||
return ds
|
||||
|
||||
# Run the "startup" plugin hooks
|
||||
asyncio.get_event_loop().run_until_complete(ds.invoke_startup())
|
||||
# Run async soundness checks before startup hooks, since invoke_startup
|
||||
# now populates internal tables which requires querying each database
|
||||
run_sync(lambda: check_databases(ds))
|
||||
|
||||
# Run async soundness checks - but only if we're not under pytest
|
||||
asyncio.get_event_loop().run_until_complete(check_databases(ds))
|
||||
# Run the "startup" plugin hooks
|
||||
try:
|
||||
run_sync(ds.invoke_startup)
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
if headers and not get:
|
||||
raise click.ClickException("--headers can only be used with --get")
|
||||
|
||||
if token and not get:
|
||||
raise click.ClickException("--token can only be used with --get")
|
||||
|
||||
if get:
|
||||
client = TestClient(ds)
|
||||
response = client.get(get)
|
||||
click.echo(response.text)
|
||||
request_headers = {}
|
||||
if token:
|
||||
request_headers["Authorization"] = "Bearer {}".format(token)
|
||||
cookies = {}
|
||||
if actor:
|
||||
cookies["ds_actor"] = client.actor_cookie(json.loads(actor))
|
||||
response = client.get(get, headers=request_headers, cookies=cookies)
|
||||
|
||||
if headers:
|
||||
# Output HTTP status code, headers, two newlines, then the response body
|
||||
click.echo(f"HTTP/1.1 {response.status}")
|
||||
for key, value in response.headers.items():
|
||||
click.echo(f"{key}: {value}")
|
||||
if response.text:
|
||||
click.echo()
|
||||
click.echo(response.text)
|
||||
else:
|
||||
click.echo(response.text)
|
||||
|
||||
exit_code = 0 if response.status == 200 else 1
|
||||
sys.exit(exit_code)
|
||||
return
|
||||
|
|
@ -604,16 +707,15 @@ def serve(
|
|||
# Start the server
|
||||
url = None
|
||||
if root:
|
||||
ds.root_enabled = True
|
||||
url = "http://{}:{}{}?token={}".format(
|
||||
host, port, ds.urls.path("-/auth-token"), ds._root_token
|
||||
)
|
||||
print(url)
|
||||
click.echo(url)
|
||||
if open_browser:
|
||||
if url is None:
|
||||
# Figure out most convenient URL - to table, database or homepage
|
||||
path = asyncio.get_event_loop().run_until_complete(
|
||||
initial_path_for_datasette(ds)
|
||||
)
|
||||
path = run_sync(lambda: initial_path_for_datasette(ds))
|
||||
url = f"http://{host}:{port}{path}"
|
||||
webbrowser.open(url)
|
||||
uvicorn_kwargs = dict(
|
||||
|
|
@ -628,6 +730,136 @@ def serve(
|
|||
uvicorn.run(ds.app(), **uvicorn_kwargs)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("id")
|
||||
@click.option(
|
||||
"--secret",
|
||||
help="Secret used for signing the API tokens",
|
||||
envvar="DATASETTE_SECRET",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"-e",
|
||||
"--expires-after",
|
||||
help="Token should expire after this many seconds",
|
||||
type=int,
|
||||
)
|
||||
@click.option(
|
||||
"alls",
|
||||
"-a",
|
||||
"--all",
|
||||
type=str,
|
||||
metavar="ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action",
|
||||
)
|
||||
@click.option(
|
||||
"databases",
|
||||
"-d",
|
||||
"--database",
|
||||
type=(str, str),
|
||||
metavar="DB ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action on this database",
|
||||
)
|
||||
@click.option(
|
||||
"resources",
|
||||
"-r",
|
||||
"--resource",
|
||||
type=(str, str, str),
|
||||
metavar="DB RESOURCE ACTION",
|
||||
multiple=True,
|
||||
help="Restrict token to this action on this database resource (a table, SQL view or named query)",
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
help="Show decoded token",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--plugins-dir",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
||||
help="Path to directory containing custom plugins",
|
||||
)
|
||||
def create_token(
|
||||
id, secret, expires_after, alls, databases, resources, debug, plugins_dir
|
||||
):
|
||||
"""
|
||||
Create a signed API token for the specified actor ID
|
||||
|
||||
Example:
|
||||
|
||||
datasette create-token root --secret mysecret
|
||||
|
||||
To allow only "view-database-download" for all databases:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret mysecret \\
|
||||
--all view-database-download
|
||||
|
||||
To allow "create-table" against a specific database:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret mysecret \\
|
||||
--database mydb create-table
|
||||
|
||||
To allow "insert-row" against a specific table:
|
||||
|
||||
\b
|
||||
datasette create-token root --secret myscret \\
|
||||
--resource mydb mytable insert-row
|
||||
|
||||
Restricted actions can be specified multiple times using
|
||||
multiple --all, --database, and --resource options.
|
||||
|
||||
Add --debug to see a decoded version of the token.
|
||||
"""
|
||||
ds = Datasette(secret=secret, plugins_dir=plugins_dir)
|
||||
|
||||
# Run ds.invoke_startup() in an event loop
|
||||
try:
|
||||
run_sync(ds.invoke_startup)
|
||||
except StartupError as e:
|
||||
raise click.ClickException(e.args[0])
|
||||
|
||||
# Warn about any unknown actions
|
||||
actions = []
|
||||
actions.extend(alls)
|
||||
actions.extend([p[1] for p in databases])
|
||||
actions.extend([p[2] for p in resources])
|
||||
for action in actions:
|
||||
if not ds.actions.get(action):
|
||||
click.secho(
|
||||
f" Unknown permission: {action} ",
|
||||
fg="red",
|
||||
err=True,
|
||||
)
|
||||
|
||||
from datasette.tokens import TokenRestrictions
|
||||
|
||||
restrictions = TokenRestrictions()
|
||||
for action in alls:
|
||||
restrictions.allow_all(action)
|
||||
for database, action in databases:
|
||||
restrictions.allow_database(database, action)
|
||||
for database, resource, action in resources:
|
||||
restrictions.allow_resource(database, resource, action)
|
||||
|
||||
token = run_sync(
|
||||
lambda: ds.create_token(
|
||||
id,
|
||||
expires_after=expires_after,
|
||||
restrictions=restrictions,
|
||||
handler="signed",
|
||||
)
|
||||
)
|
||||
click.echo(token)
|
||||
if debug:
|
||||
encoded = token[len("dstok_") :]
|
||||
click.echo("\nDecoded:\n")
|
||||
click.echo(json.dumps(ds.unsign(encoded, namespace="token"), indent=2))
|
||||
|
||||
|
||||
pm.hook.register_commands(cli=cli)
|
||||
|
||||
|
||||
|
|
|
|||
83
datasette/column_types.py
Normal file
83
datasette/column_types.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class SQLiteType(Enum):
|
||||
TEXT = "TEXT"
|
||||
INTEGER = "INTEGER"
|
||||
REAL = "REAL"
|
||||
BLOB = "BLOB"
|
||||
NULL = "NULL"
|
||||
|
||||
@classmethod
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
|
||||
if declared_type is None:
|
||||
return cls.NULL
|
||||
|
||||
normalized = declared_type.strip().upper()
|
||||
if not normalized:
|
||||
return cls.NULL
|
||||
|
||||
if normalized == cls.NULL.value:
|
||||
return cls.NULL
|
||||
if "INT" in normalized:
|
||||
return cls.INTEGER
|
||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||
return cls.TEXT
|
||||
if "BLOB" in normalized:
|
||||
return cls.BLOB
|
||||
if any(
|
||||
token in normalized
|
||||
for token in ("REAL", "FLOA", "DOUB") # codespell:ignore doub
|
||||
):
|
||||
return cls.REAL
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ColumnType:
|
||||
"""
|
||||
Base class for column types.
|
||||
|
||||
Subclasses must define ``name`` and ``description`` as class attributes:
|
||||
|
||||
- ``name``: Unique identifier string. Lowercase, no spaces.
|
||||
Examples: "markdown", "file", "email", "url", "point", "image".
|
||||
- ``description``: Human-readable label for admin UI dropdowns.
|
||||
Examples: "Markdown text", "File reference", "Email address".
|
||||
- ``sqlite_types``: Optional tuple of SQLiteType values restricting
|
||||
which SQLite column types this ColumnType can be assigned to.
|
||||
|
||||
Instantiate with an optional ``config`` dict to bind per-column
|
||||
configuration::
|
||||
|
||||
ct = MyColumnType(config={"key": "value"})
|
||||
ct.config # {"key": "value"}
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
sqlite_types: tuple[SQLiteType, ...] | None = None
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
"""
|
||||
Return an HTML string to render this cell value, or None to
|
||||
fall through to the default render_cell plugin hook chain.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
"""
|
||||
Validate a value before it is written. Return None if valid,
|
||||
or a string error message if invalid.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def transform_value(self, value, datasette):
|
||||
"""
|
||||
Transform a value before it appears in JSON API output.
|
||||
Return the transformed value. Default: return unchanged.
|
||||
"""
|
||||
return value
|
||||
178
datasette/csrf.py
Normal file
178
datasette/csrf.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""
|
||||
Header-based CSRF (Cross-Origin) protection.
|
||||
|
||||
Datasette uses the Sec-Fetch-Site + Origin header approach described in
|
||||
Filippo Valsorda's article (https://words.filippo.io/csrf/) and implemented
|
||||
in Go 1.25's http.CrossOriginProtection. This replaces the previous
|
||||
token-based asgi-csrf mechanism.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import urllib.parse
|
||||
|
||||
from .utils.asgi import asgi_send
|
||||
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
|
||||
|
||||
|
||||
def _normalize_headers(raw_headers):
|
||||
"""Lowercase header names; for duplicates, last value wins."""
|
||||
result = {}
|
||||
for name, value in raw_headers:
|
||||
if isinstance(name, str):
|
||||
name = name.encode("latin-1")
|
||||
if isinstance(value, str):
|
||||
value = value.encode("latin-1")
|
||||
result[name.lower()] = value
|
||||
return result
|
||||
|
||||
|
||||
def _origin_tuple(value):
|
||||
"""
|
||||
Parse an origin-like string into ``(scheme, host, port)`` with default
|
||||
ports filled in. Raises ``ValueError`` for malformed input.
|
||||
"""
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
if not scheme or not host:
|
||||
raise ValueError("missing scheme or host in {!r}".format(value))
|
||||
port = parsed.port # may raise ValueError on bad ports
|
||||
if port is None:
|
||||
port = DEFAULT_PORTS.get(scheme)
|
||||
if port is None:
|
||||
raise ValueError("unknown default port for scheme {!r}".format(scheme))
|
||||
return scheme, host, port
|
||||
|
||||
|
||||
def _install_legacy_csrftoken(scope):
|
||||
"""
|
||||
Populate ``scope["csrftoken"]`` with a callable returning a per-request
|
||||
random token. Provided for plugin compatibility only - core no longer
|
||||
uses this value for CSRF enforcement.
|
||||
"""
|
||||
|
||||
def csrftoken():
|
||||
if "_datasette_legacy_csrftoken" not in scope:
|
||||
scope["_datasette_legacy_csrftoken"] = secrets.token_urlsafe(32)
|
||||
return scope["_datasette_legacy_csrftoken"]
|
||||
|
||||
scope["csrftoken"] = csrftoken
|
||||
|
||||
|
||||
class CrossOriginProtectionMiddleware:
|
||||
"""
|
||||
Modern CSRF protection using the Sec-Fetch-Site and Origin headers.
|
||||
|
||||
Based on Filippo Valsorda's algorithm, as implemented in Go 1.25's
|
||||
http.CrossOriginProtection. See https://words.filippo.io/csrf/
|
||||
|
||||
Unsafe-method requests are allowed through only if they look same-origin.
|
||||
Non-browser clients (curl, etc.) send neither Sec-Fetch-Site nor Origin
|
||||
and are passed through unchanged - CSRF is a browser-only attack.
|
||||
"""
|
||||
|
||||
SAFE_METHODS = SAFE_METHODS
|
||||
|
||||
def __init__(self, app, datasette):
|
||||
self.app = app
|
||||
self.datasette = datasette
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
_install_legacy_csrftoken(scope)
|
||||
|
||||
if scope.get("method", "GET") in self.SAFE_METHODS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = _normalize_headers(scope.get("headers") or [])
|
||||
|
||||
authorization = headers.get(b"authorization", b"").decode("latin-1")
|
||||
cookie_header = headers.get(b"cookie")
|
||||
# Bearer-token requests are not ambient browser credentials, so they
|
||||
# are not CSRF-vulnerable. Narrowly exempt them from the header check
|
||||
# before evaluating Sec-Fetch-Site / Origin. Only "Bearer" is exempt;
|
||||
# schemes like Basic or Digest can be browser-managed and ambient.
|
||||
# If the request also carries a Cookie header, ambient cookie auth
|
||||
# could be in play, so do NOT treat it as exempt.
|
||||
if authorization and not cookie_header:
|
||||
parts = authorization.split(None, 1)
|
||||
if parts and parts[0].lower() == "bearer":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
origin_bytes = headers.get(b"origin")
|
||||
sec_fetch_site_bytes = headers.get(b"sec-fetch-site")
|
||||
host_bytes = headers.get(b"host", b"")
|
||||
origin = origin_bytes.decode("latin-1") if origin_bytes else None
|
||||
sec_fetch_site = (
|
||||
sec_fetch_site_bytes.decode("latin-1") if sec_fetch_site_bytes else None
|
||||
)
|
||||
host = host_bytes.decode("latin-1")
|
||||
|
||||
# Primary defense: Sec-Fetch-Site (set by browsers, unforgeable from JS)
|
||||
if sec_fetch_site is not None:
|
||||
if sec_fetch_site in ("same-origin", "none"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await self._forbid(
|
||||
send,
|
||||
"Sec-Fetch-Site was {!r}, expected 'same-origin' or 'none'".format(
|
||||
sec_fetch_site
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# No Sec-Fetch-Site and no Origin -> non-browser client (curl, API, etc.)
|
||||
if origin is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Fallback for older browsers: Origin must match the request's own
|
||||
# scheme + host + port. Compare full origin tuples, not host alone.
|
||||
request_scheme = self._request_scheme(scope)
|
||||
try:
|
||||
origin_tuple = _origin_tuple(origin)
|
||||
expected_tuple = _origin_tuple("{}://{}".format(request_scheme, host))
|
||||
except ValueError:
|
||||
await self._forbid(
|
||||
send,
|
||||
"Malformed Origin {!r} or Host {!r}".format(origin, host),
|
||||
)
|
||||
return
|
||||
|
||||
if origin_tuple == expected_tuple:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
await self._forbid(
|
||||
send,
|
||||
"Origin {!r} does not match Host {!r}".format(origin, host),
|
||||
)
|
||||
|
||||
def _request_scheme(self, scope):
|
||||
if self.datasette is not None:
|
||||
try:
|
||||
if self.datasette.setting("force_https_urls"):
|
||||
return "https"
|
||||
except Exception:
|
||||
pass
|
||||
return scope.get("scheme") or "http"
|
||||
|
||||
async def _forbid(self, send, reason):
|
||||
await asgi_send(
|
||||
send,
|
||||
content=await self.datasette.render_template(
|
||||
"csrf_error.html", {"reason": reason}
|
||||
),
|
||||
status=403,
|
||||
content_type="text/html; charset=utf-8",
|
||||
)
|
||||
|
|
@ -1,24 +1,32 @@
|
|||
import asyncio
|
||||
import atexit
|
||||
from collections import namedtuple
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
import janus
|
||||
import queue
|
||||
import sqlite_utils
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from .tracer import trace
|
||||
from .utils import (
|
||||
call_with_supported_arguments,
|
||||
detect_fts,
|
||||
detect_primary_keys,
|
||||
detect_spatialite,
|
||||
get_all_foreign_keys,
|
||||
get_outbound_foreign_keys,
|
||||
md5_not_usedforsecurity,
|
||||
sqlite_timelimit,
|
||||
sqlite3,
|
||||
table_columns,
|
||||
table_column_details,
|
||||
)
|
||||
from .utils.sql_analysis import SQLAnalysis, analyze_sql_tables
|
||||
from .utils.sqlite import sqlite_version
|
||||
from .inspect import inspect_hash
|
||||
|
||||
connections = threading.local()
|
||||
|
|
@ -26,29 +34,75 @@ connections = threading.local()
|
|||
AttachedDatabase = namedtuple("AttachedDatabase", ("seq", "name", "file"))
|
||||
|
||||
|
||||
class DatasetteClosedError(RuntimeError):
|
||||
"""Raised when using a Datasette or Database instance after close()."""
|
||||
|
||||
|
||||
_SHUTDOWN = object()
|
||||
|
||||
|
||||
class Database:
|
||||
# For table counts stop at this many rows:
|
||||
count_limit = 10000
|
||||
_thread_local_id_counter = 1
|
||||
|
||||
def __init__(
|
||||
self, ds, path=None, is_mutable=True, is_memory=False, memory_name=None
|
||||
self,
|
||||
ds,
|
||||
path=None,
|
||||
is_mutable=True,
|
||||
is_memory=False,
|
||||
memory_name=None,
|
||||
mode=None,
|
||||
is_temp_disk=False,
|
||||
):
|
||||
self.name = None
|
||||
self._thread_local_id = f"x{self._thread_local_id_counter}"
|
||||
Database._thread_local_id_counter += 1
|
||||
self.route = None
|
||||
self.ds = ds
|
||||
self.path = path
|
||||
self.is_mutable = is_mutable
|
||||
self.is_memory = is_memory
|
||||
self.memory_name = memory_name
|
||||
self.is_temp_disk = is_temp_disk
|
||||
if memory_name is not None:
|
||||
self.is_memory = True
|
||||
if is_temp_disk:
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".db", prefix="datasette_temp_")
|
||||
os.close(fd)
|
||||
self.path = temp_path
|
||||
self.is_mutable = True
|
||||
self.mode = "rwc"
|
||||
self._wal_enabled = False
|
||||
atexit.register(self._cleanup_temp_file)
|
||||
else:
|
||||
self._wal_enabled = False
|
||||
self.cached_hash = None
|
||||
self.cached_size = None
|
||||
self._cached_table_counts = None
|
||||
self._write_thread = None
|
||||
self._write_queue = None
|
||||
self._closed = False
|
||||
self._pending_execute_futures = set()
|
||||
self._pending_execute_futures_lock = threading.Lock()
|
||||
# These are used when in non-threaded mode:
|
||||
self._read_connection = None
|
||||
self._write_connection = None
|
||||
# This is used to track all file connections so they can be closed
|
||||
self._all_file_connections = []
|
||||
if not is_temp_disk:
|
||||
self.mode = mode
|
||||
|
||||
def _check_not_closed(self):
|
||||
if self._closed:
|
||||
raise DatasetteClosedError(
|
||||
"Database {!r} has been closed".format(self.name)
|
||||
)
|
||||
|
||||
def _remove_pending_execute_future(self, future):
|
||||
with self._pending_execute_futures_lock:
|
||||
self._pending_execute_futures.discard(future)
|
||||
|
||||
@property
|
||||
def cached_table_counts(self):
|
||||
|
|
@ -62,7 +116,15 @@ class Database:
|
|||
}
|
||||
return self._cached_table_counts
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
if self.hash:
|
||||
return self.hash[:6]
|
||||
return md5_not_usedforsecurity(self.name)[:6]
|
||||
|
||||
def suggest_name(self):
|
||||
if self.is_temp_disk:
|
||||
return "_temp_disk"
|
||||
if self.path:
|
||||
return Path(self.path).stem
|
||||
elif self.memory_name:
|
||||
|
|
@ -71,18 +133,20 @@ class Database:
|
|||
return "db"
|
||||
|
||||
def connect(self, write=False):
|
||||
extra_kwargs = {}
|
||||
if write:
|
||||
extra_kwargs["isolation_level"] = "IMMEDIATE"
|
||||
if self.memory_name:
|
||||
uri = "file:{}?mode=memory&cache=shared".format(self.memory_name)
|
||||
conn = sqlite3.connect(
|
||||
uri,
|
||||
uri=True,
|
||||
check_same_thread=False,
|
||||
uri, uri=True, check_same_thread=False, **extra_kwargs
|
||||
)
|
||||
if not write:
|
||||
conn.execute("PRAGMA query_only=1")
|
||||
return conn
|
||||
if self.is_memory:
|
||||
return sqlite3.connect(":memory:", uri=True)
|
||||
|
||||
# mode=ro or immutable=1?
|
||||
if self.is_mutable:
|
||||
qs = "?mode=ro"
|
||||
|
|
@ -93,36 +157,110 @@ class Database:
|
|||
assert not (write and not self.is_mutable)
|
||||
if write:
|
||||
qs = ""
|
||||
if self.mode is not None:
|
||||
qs = f"?mode={self.mode}"
|
||||
conn = sqlite3.connect(
|
||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False
|
||||
f"file:{self.path}{qs}", uri=True, check_same_thread=False, **extra_kwargs
|
||||
)
|
||||
self._all_file_connections.append(conn)
|
||||
if self.is_temp_disk and not self._wal_enabled:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._wal_enabled = True
|
||||
return conn
|
||||
|
||||
def close(self):
|
||||
# Close all connections - useful to avoid running out of file handles in tests
|
||||
for connection in self._all_file_connections:
|
||||
connection.close()
|
||||
"""Release all resources held by this database.
|
||||
|
||||
Idempotent. After close() further calls to execute()/execute_fn()/
|
||||
execute_write()/execute_write_fn() raise DatasetteClosedError.
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
with self._pending_execute_futures_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
pending_execute_futures = tuple(self._pending_execute_futures)
|
||||
# Shut down the write thread, if any, via a sentinel. The thread
|
||||
# drains any writes already queued before the sentinel and then
|
||||
# closes its own write connection and returns.
|
||||
write_thread = self._write_thread
|
||||
if write_thread is not None and self._write_queue is not None:
|
||||
self._write_queue.put(_SHUTDOWN)
|
||||
write_thread.join(timeout=10)
|
||||
if write_thread.is_alive():
|
||||
sys.stderr.write(
|
||||
"Datasette: write thread for {!r} did not exit within 10s\n".format(
|
||||
self.name
|
||||
)
|
||||
)
|
||||
sys.stderr.flush()
|
||||
for future in pending_execute_futures:
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
pass
|
||||
# Close anything still tracked in _all_file_connections
|
||||
for connection in self._all_file_connections:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._all_file_connections = []
|
||||
# Drop per-thread cached read connections we can reach
|
||||
try:
|
||||
delattr(connections, self._thread_local_id)
|
||||
except AttributeError:
|
||||
pass
|
||||
# Close non-threaded-mode cached connections if still open
|
||||
if self._read_connection is not None:
|
||||
try:
|
||||
self._read_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._read_connection = None
|
||||
if self._write_connection is not None:
|
||||
try:
|
||||
self._write_connection.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._write_connection = None
|
||||
if self.is_temp_disk:
|
||||
self._cleanup_temp_file()
|
||||
|
||||
def _cleanup_temp_file(self):
|
||||
if self.is_temp_disk and self.path:
|
||||
for suffix in ("", "-wal", "-shm"):
|
||||
try:
|
||||
os.unlink(self.path + suffix)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def execute_write(self, sql, params=None, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
async def execute_write(self, sql, params=None, block=True):
|
||||
def _inner(conn):
|
||||
with conn:
|
||||
return conn.execute(sql, params or [])
|
||||
return conn.execute(sql, params or [])
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), params=params):
|
||||
results = await self.execute_write_fn(_inner, block=block)
|
||||
results = await self.execute_write_fn(_inner, block=block, request=request)
|
||||
return results
|
||||
|
||||
async def execute_write_script(self, sql, block=True):
|
||||
async def execute_write_script(self, sql, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
def _inner(conn):
|
||||
with conn:
|
||||
return conn.executescript(sql)
|
||||
return conn.executescript(sql)
|
||||
|
||||
with trace("sql", database=self.name, sql=sql.strip(), executescript=True):
|
||||
results = await self.execute_write_fn(_inner, block=block)
|
||||
results = await self.execute_write_fn(
|
||||
_inner, block=block, transaction=False, request=request
|
||||
)
|
||||
return results
|
||||
|
||||
async def execute_write_many(self, sql, params_seq, block=True):
|
||||
async def execute_write_many(self, sql, params_seq, block=True, request=None):
|
||||
self._check_not_closed()
|
||||
|
||||
def _inner(conn):
|
||||
count = 0
|
||||
|
||||
|
|
@ -132,43 +270,145 @@ class Database:
|
|||
count += 1
|
||||
yield param
|
||||
|
||||
with conn:
|
||||
return conn.executemany(sql, count_params(params_seq)), count
|
||||
return conn.executemany(sql, count_params(params_seq)), count
|
||||
|
||||
with trace(
|
||||
"sql", database=self.name, sql=sql.strip(), executemany=True
|
||||
) as kwargs:
|
||||
results, count = await self.execute_write_fn(_inner, block=block)
|
||||
results, count = await self.execute_write_fn(
|
||||
_inner, block=block, request=request
|
||||
)
|
||||
kwargs["count"] = count
|
||||
return results
|
||||
|
||||
async def execute_write_fn(self, fn, block=True):
|
||||
async def execute_isolated_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
# Open a new connection just for the duration of this function
|
||||
# blocking the write queue to avoid any writes occurring during it
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = fn(isolated_connection)
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
return result
|
||||
else:
|
||||
# Threaded mode - send to write thread
|
||||
return await self._send_to_write_thread(fn, isolated_connection=True)
|
||||
|
||||
async def analyze_sql(self, sql, params=None) -> SQLAnalysis:
|
||||
self._check_not_closed()
|
||||
|
||||
return await self.execute_isolated_fn(
|
||||
lambda conn: analyze_sql_tables(conn, sql, params, database_name=self.name)
|
||||
)
|
||||
|
||||
async def execute_write_fn(self, fn, block=True, transaction=True, request=None):
|
||||
self._check_not_closed()
|
||||
pending_events = []
|
||||
|
||||
def track_event(event):
|
||||
pending_events.append(event)
|
||||
|
||||
fn = self._wrap_fn_with_hooks(fn, request, transaction, track_event)
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
if self._write_connection is None:
|
||||
self._write_connection = self.connect(write=True)
|
||||
self.ds._prepare_connection(self._write_connection, self.name)
|
||||
return fn(self._write_connection)
|
||||
if transaction:
|
||||
with self._write_connection:
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = fn(self._write_connection)
|
||||
else:
|
||||
result = await self._send_to_write_thread(
|
||||
fn, block=block, transaction=transaction
|
||||
)
|
||||
if block:
|
||||
for event in pending_events:
|
||||
await self.ds.track_event(event)
|
||||
else:
|
||||
# For non-blocking writes, spawn a background task to
|
||||
# dispatch events after the write thread completes
|
||||
task_id, reply_future = result
|
||||
|
||||
# threaded mode
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
async def _dispatch_events_after_write():
|
||||
try:
|
||||
await reply_future
|
||||
except Exception:
|
||||
# if the write failed, don't emit success events
|
||||
return
|
||||
for event in pending_events:
|
||||
await self.ds.track_event(event)
|
||||
|
||||
asyncio.ensure_future(_dispatch_events_after_write())
|
||||
result = task_id
|
||||
return result
|
||||
|
||||
def _wrap_fn_with_hooks(self, fn, request, transaction, track_event):
|
||||
from .plugins import pm
|
||||
|
||||
# Wrap fn so it receives track_event if its signature supports it.
|
||||
# Historically fn was called positionally, so any single-parameter
|
||||
# name (conn, connection, db, ...) worked. Preserve that by only
|
||||
# switching to keyword dependency injection when the callback
|
||||
# explicitly opts in by declaring a `track_event` parameter.
|
||||
original_fn = fn
|
||||
|
||||
if "track_event" in inspect.signature(original_fn).parameters:
|
||||
|
||||
def fn_with_track_event(conn):
|
||||
return call_with_supported_arguments(
|
||||
original_fn, conn=conn, track_event=track_event
|
||||
)
|
||||
|
||||
fn = fn_with_track_event
|
||||
|
||||
wrappers = pm.hook.write_wrapper(
|
||||
datasette=self.ds,
|
||||
database=self.name,
|
||||
request=request,
|
||||
transaction=transaction,
|
||||
)
|
||||
wrappers = [w for w in wrappers if w is not None]
|
||||
if not wrappers:
|
||||
return fn
|
||||
# Build the wrapped fn by nesting context manager generators.
|
||||
# The first wrapper returned by pluggy is outermost.
|
||||
for wrapper_factory in reversed(wrappers):
|
||||
fn = _apply_write_wrapper(fn, wrapper_factory, track_event)
|
||||
return fn
|
||||
|
||||
async def _send_to_write_thread(
|
||||
self, fn, block=True, isolated_connection=False, transaction=True
|
||||
):
|
||||
if self._write_queue is None:
|
||||
self._write_queue = queue.Queue()
|
||||
if self._write_thread is None:
|
||||
self._write_thread = threading.Thread(
|
||||
target=self._execute_writes, daemon=True
|
||||
)
|
||||
self._write_thread.name = "_execute_writes for database {}".format(
|
||||
self.name
|
||||
)
|
||||
self._write_thread.start()
|
||||
reply_queue = janus.Queue()
|
||||
self._write_queue.put(WriteTask(fn, task_id, reply_queue))
|
||||
task_id = uuid.uuid5(uuid.NAMESPACE_DNS, "datasette.io")
|
||||
loop = asyncio.get_running_loop()
|
||||
reply_future = loop.create_future()
|
||||
self._write_queue.put(
|
||||
WriteTask(fn, task_id, loop, reply_future, isolated_connection, transaction)
|
||||
)
|
||||
if block:
|
||||
result = await reply_queue.async_q.get()
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
else:
|
||||
return result
|
||||
return await reply_future
|
||||
else:
|
||||
return task_id
|
||||
return task_id, reply_future
|
||||
|
||||
def _execute_writes(self):
|
||||
# Infinite looping thread that protects the single write connection
|
||||
|
|
@ -182,18 +422,47 @@ class Database:
|
|||
conn_exception = e
|
||||
while True:
|
||||
task = self._write_queue.get()
|
||||
if task is _SHUTDOWN:
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
exception = None
|
||||
result = None
|
||||
if conn_exception is not None:
|
||||
result = conn_exception
|
||||
else:
|
||||
exception = conn_exception
|
||||
elif task.isolated_connection:
|
||||
isolated_connection = self.connect(write=True)
|
||||
try:
|
||||
result = task.fn(conn)
|
||||
result = task.fn(isolated_connection)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
result = e
|
||||
task.reply_queue.sync_q.put(result)
|
||||
exception = e
|
||||
finally:
|
||||
isolated_connection.close()
|
||||
try:
|
||||
self._all_file_connections.remove(isolated_connection)
|
||||
except ValueError:
|
||||
# Was probably a memory connection
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
if task.transaction:
|
||||
with conn:
|
||||
result = task.fn(conn)
|
||||
else:
|
||||
result = task.fn(conn)
|
||||
except Exception as e:
|
||||
sys.stderr.write("{}\n".format(e))
|
||||
sys.stderr.flush()
|
||||
exception = e
|
||||
_deliver_write_result(task, result, exception)
|
||||
|
||||
async def execute_fn(self, fn):
|
||||
self._check_not_closed()
|
||||
if self.ds.executor is None:
|
||||
# non-threaded mode
|
||||
if self._read_connection is None:
|
||||
|
|
@ -203,16 +472,19 @@ class Database:
|
|||
|
||||
# threaded mode
|
||||
def in_thread():
|
||||
conn = getattr(connections, self.name, None)
|
||||
conn = getattr(connections, self._thread_local_id, None)
|
||||
if not conn:
|
||||
conn = self.connect()
|
||||
self.ds._prepare_connection(conn, self.name)
|
||||
setattr(connections, self.name, conn)
|
||||
setattr(connections, self._thread_local_id, conn)
|
||||
return fn(conn)
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
self.ds.executor, in_thread
|
||||
)
|
||||
with self._pending_execute_futures_lock:
|
||||
self._check_not_closed()
|
||||
future = self.ds.executor.submit(in_thread)
|
||||
self._pending_execute_futures.add(future)
|
||||
future.add_done_callback(self._remove_pending_execute_future)
|
||||
return await asyncio.wrap_future(future)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
|
|
@ -224,6 +496,7 @@ class Database:
|
|||
log_sql_errors=True,
|
||||
):
|
||||
"""Executes sql against db_name in a thread"""
|
||||
self._check_not_closed()
|
||||
page_size = page_size or self.ds.page_size
|
||||
|
||||
def sql_operation_in_thread(conn):
|
||||
|
|
@ -271,7 +544,7 @@ class Database:
|
|||
def hash(self):
|
||||
if self.cached_hash is not None:
|
||||
return self.cached_hash
|
||||
elif self.is_mutable or self.is_memory:
|
||||
elif self.is_mutable or self.is_memory or self.is_temp_disk:
|
||||
return None
|
||||
elif self.ds.inspect_data and self.ds.inspect_data.get(self.name):
|
||||
self.cached_hash = self.ds.inspect_data[self.name]["hash"]
|
||||
|
|
@ -305,7 +578,7 @@ class Database:
|
|||
try:
|
||||
table_count = (
|
||||
await self.execute(
|
||||
f"select count(*) from [{table}]",
|
||||
f"select count(*) from (select * from [{table}] limit {self.count_limit + 1})",
|
||||
custom_time_limit=limit,
|
||||
)
|
||||
).rows[0][0]
|
||||
|
|
@ -330,7 +603,12 @@ class Database:
|
|||
# But SQLite prior to 3.16.0 doesn't support pragma functions
|
||||
results = await self.execute("PRAGMA database_list;")
|
||||
# {'seq': 0, 'name': 'main', 'file': ''}
|
||||
return [AttachedDatabase(*row) for row in results.rows if row["seq"] > 0]
|
||||
return [
|
||||
AttachedDatabase(*row)
|
||||
for row in results.rows
|
||||
# Filter out the SQLite internal "temp" database, refs #2557
|
||||
if row["seq"] > 0 and row["name"] != "temp"
|
||||
]
|
||||
|
||||
async def table_exists(self, table):
|
||||
results = await self.execute(
|
||||
|
|
@ -338,9 +616,15 @@ class Database:
|
|||
)
|
||||
return bool(results.rows)
|
||||
|
||||
async def view_exists(self, table):
|
||||
results = await self.execute(
|
||||
"select 1 from sqlite_master where type='view' and name=?", params=(table,)
|
||||
)
|
||||
return bool(results.rows)
|
||||
|
||||
async def table_names(self):
|
||||
results = await self.execute(
|
||||
"select name from sqlite_master where type='table'"
|
||||
"select name from sqlite_master where type='table' order by name"
|
||||
)
|
||||
return [r[0] for r in results.rows]
|
||||
|
||||
|
|
@ -357,12 +641,38 @@ class Database:
|
|||
return await self.execute_fn(lambda conn: detect_fts(conn, table))
|
||||
|
||||
async def label_column_for_table(self, table):
|
||||
explicit_label_column = self.ds.table_metadata(self.name, table).get(
|
||||
explicit_label_column = (await self.ds.table_config(self.name, table)).get(
|
||||
"label_column"
|
||||
)
|
||||
if explicit_label_column:
|
||||
return explicit_label_column
|
||||
column_names = await self.execute_fn(lambda conn: table_columns(conn, table))
|
||||
|
||||
def column_details(conn):
|
||||
# Returns {column_name: (type, is_unique)}
|
||||
db = sqlite_utils.Database(conn)
|
||||
columns = db[table].columns_dict
|
||||
indexes = db[table].indexes
|
||||
details = {}
|
||||
for name in columns:
|
||||
is_unique = any(
|
||||
index
|
||||
for index in indexes
|
||||
if index.columns == [name] and index.unique
|
||||
)
|
||||
details[name] = (columns[name], is_unique)
|
||||
return details
|
||||
|
||||
column_details = await self.execute_fn(column_details)
|
||||
# Is there just one unique column that's text?
|
||||
unique_text_columns = [
|
||||
name
|
||||
for name, (type_, is_unique) in column_details.items()
|
||||
if is_unique and type_ is str
|
||||
]
|
||||
if len(unique_text_columns) == 1:
|
||||
return unique_text_columns[0]
|
||||
|
||||
column_names = list(column_details.keys())
|
||||
# Is there a name or title column?
|
||||
name_or_title = [c for c in column_names if c.lower() in ("name", "title")]
|
||||
if name_or_title:
|
||||
|
|
@ -372,6 +682,7 @@ class Database:
|
|||
column_names
|
||||
and len(column_names) == 2
|
||||
and ("id" in column_names or "pk" in column_names)
|
||||
and not set(column_names) == {"id", "pk"}
|
||||
):
|
||||
return [c for c in column_names if c not in ("id", "pk")][0]
|
||||
# Couldn't find a label:
|
||||
|
|
@ -383,21 +694,92 @@ class Database:
|
|||
)
|
||||
|
||||
async def hidden_table_names(self):
|
||||
# Mark tables 'hidden' if they relate to FTS virtual tables
|
||||
hidden_tables = [
|
||||
r[0]
|
||||
for r in (
|
||||
await self.execute(
|
||||
"""
|
||||
select name from sqlite_master
|
||||
where rootpage = 0
|
||||
and (
|
||||
sql like '%VIRTUAL TABLE%USING FTS%'
|
||||
) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
"""
|
||||
)
|
||||
).rows
|
||||
]
|
||||
hidden_tables = []
|
||||
# Add any tables marked as hidden in config
|
||||
db_config = self.ds.config.get("databases", {}).get(self.name, {})
|
||||
if "tables" in db_config:
|
||||
hidden_tables += [
|
||||
t for t in db_config["tables"] if db_config["tables"][t].get("hidden")
|
||||
]
|
||||
|
||||
if sqlite_version()[1] >= 37:
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
with shadow_tables as (
|
||||
select name
|
||||
from pragma_table_list
|
||||
where [type] = 'shadow'
|
||||
order by name
|
||||
),
|
||||
core_tables as (
|
||||
select name
|
||||
from sqlite_master
|
||||
WHERE name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
combined as (
|
||||
select name from shadow_tables
|
||||
union all
|
||||
select name from core_tables
|
||||
)
|
||||
select name from combined order by 1
|
||||
""")]
|
||||
else:
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
WITH base AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE name IN ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||
OR substr(name, 1, 1) == '_'
|
||||
),
|
||||
fts_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_data'), ('_idx'), ('_docsize'), ('_content'), ('_config'))
|
||||
),
|
||||
fts5_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS%'
|
||||
),
|
||||
fts5_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts5_names.name, fts_suffixes.suffix) AS name
|
||||
FROM fts5_names
|
||||
JOIN fts_suffixes
|
||||
),
|
||||
fts3_suffixes AS (
|
||||
SELECT column1 AS suffix
|
||||
FROM (VALUES ('_content'), ('_segdir'), ('_segments'), ('_stat'), ('_docsize'))
|
||||
),
|
||||
fts3_names AS (
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%USING FTS3%'
|
||||
OR sql LIKE '%VIRTUAL TABLE%USING FTS4%'
|
||||
),
|
||||
fts3_shadow_tables AS (
|
||||
SELECT
|
||||
printf('%s%s', fts3_names.name, fts3_suffixes.suffix) AS name
|
||||
FROM fts3_names
|
||||
JOIN fts3_suffixes
|
||||
),
|
||||
final AS (
|
||||
SELECT name FROM base
|
||||
UNION ALL
|
||||
SELECT name FROM fts5_shadow_tables
|
||||
UNION ALL
|
||||
SELECT name FROM fts3_shadow_tables
|
||||
)
|
||||
SELECT name FROM final ORDER BY 1
|
||||
""")]
|
||||
# Also hide any FTS tables that have a content= argument
|
||||
hidden_tables += [x[0] for x in await self.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE sql LIKE '%VIRTUAL TABLE%'
|
||||
AND sql LIKE '%USING FTS%'
|
||||
AND sql LIKE '%content=%'
|
||||
""")]
|
||||
|
||||
has_spatialite = await self.execute_fn(detect_spatialite)
|
||||
if has_spatialite:
|
||||
# Also hide Spatialite internal tables
|
||||
|
|
@ -415,32 +797,12 @@ class Database:
|
|||
"KNN",
|
||||
"KNN2",
|
||||
] + [
|
||||
r[0]
|
||||
for r in (
|
||||
await self.execute(
|
||||
"""
|
||||
r[0] for r in (await self.execute("""
|
||||
select name from sqlite_master
|
||||
where name like "idx_%"
|
||||
and type = "table"
|
||||
"""
|
||||
)
|
||||
).rows
|
||||
""")).rows
|
||||
]
|
||||
# Add any from metadata.json
|
||||
db_metadata = self.ds.metadata(database=self.name)
|
||||
if "tables" in db_metadata:
|
||||
hidden_tables += [
|
||||
t
|
||||
for t in db_metadata["tables"]
|
||||
if db_metadata["tables"][t].get("hidden")
|
||||
]
|
||||
# Also mark as hidden any tables which start with the name of a hidden table
|
||||
# e.g. "searchable_fts" implies "searchable_fts_content" should be hidden
|
||||
for table_name in await self.table_names():
|
||||
for hidden_table in hidden_tables[:]:
|
||||
if table_name.startswith(hidden_table):
|
||||
hidden_tables.append(table_name)
|
||||
continue
|
||||
|
||||
return hidden_tables
|
||||
|
||||
|
|
@ -481,6 +843,8 @@ class Database:
|
|||
tags.append("mutable")
|
||||
if self.is_memory:
|
||||
tags.append("memory")
|
||||
if self.is_temp_disk:
|
||||
tags.append("temp_disk")
|
||||
if self.hash:
|
||||
tags.append(f"hash={self.hash}")
|
||||
if self.size is not None:
|
||||
|
|
@ -491,13 +855,88 @@ class Database:
|
|||
return f"<Database: {self.name}{tags_str}>"
|
||||
|
||||
|
||||
class WriteTask:
|
||||
__slots__ = ("fn", "task_id", "reply_queue")
|
||||
def _apply_write_wrapper(fn, wrapper_factory, track_event):
|
||||
"""Apply a single write_wrapper context manager around fn.
|
||||
|
||||
def __init__(self, fn, task_id, reply_queue):
|
||||
``wrapper_factory`` is a callable that takes ``(conn)`` and optionally
|
||||
``track_event``, and returns a generator that yields exactly once.
|
||||
Code before the yield runs before ``fn(conn)``, code after the yield
|
||||
runs after. The result of ``fn(conn)`` is sent into the generator
|
||||
via ``.send()``, and any exception raised by ``fn(conn)`` is thrown
|
||||
via ``.throw()``.
|
||||
"""
|
||||
|
||||
def wrapped(conn):
|
||||
gen = call_with_supported_arguments(
|
||||
wrapper_factory, conn=conn, track_event=track_event
|
||||
)
|
||||
# Advance to the yield point (run "before" code)
|
||||
try:
|
||||
next(gen)
|
||||
except StopIteration:
|
||||
# Generator didn't yield — just run fn unchanged
|
||||
return fn(conn)
|
||||
|
||||
# Execute the actual write
|
||||
try:
|
||||
result = fn(conn)
|
||||
except Exception:
|
||||
# Throw exception into generator so it can handle it
|
||||
try:
|
||||
gen.throw(*sys.exc_info())
|
||||
except StopIteration:
|
||||
pass
|
||||
# Re-raise the original exception
|
||||
raise
|
||||
else:
|
||||
# Send the result back through the yield
|
||||
try:
|
||||
gen.send(result)
|
||||
except StopIteration:
|
||||
pass
|
||||
return result
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class WriteTask:
|
||||
__slots__ = (
|
||||
"fn",
|
||||
"task_id",
|
||||
"loop",
|
||||
"reply_future",
|
||||
"isolated_connection",
|
||||
"transaction",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, fn, task_id, loop, reply_future, isolated_connection, transaction
|
||||
):
|
||||
self.fn = fn
|
||||
self.task_id = task_id
|
||||
self.reply_queue = reply_queue
|
||||
self.loop = loop
|
||||
self.reply_future = reply_future
|
||||
self.isolated_connection = isolated_connection
|
||||
self.transaction = transaction
|
||||
|
||||
|
||||
def _deliver_write_result(task, result, exception):
|
||||
# Called from the write thread. Delivers the result back to the
|
||||
# awaiting coroutine on its event loop via call_soon_threadsafe.
|
||||
def _set():
|
||||
if task.reply_future.done():
|
||||
# Awaiter was cancelled; nothing to do.
|
||||
return
|
||||
if exception is not None:
|
||||
task.reply_future.set_exception(exception)
|
||||
else:
|
||||
task.reply_future.set_result(result)
|
||||
|
||||
try:
|
||||
task.loop.call_soon_threadsafe(_set)
|
||||
except RuntimeError:
|
||||
# Event loop has been closed; the awaiter is gone.
|
||||
pass
|
||||
|
||||
|
||||
class QueryInterrupted(Exception):
|
||||
|
|
@ -506,6 +945,9 @@ class QueryInterrupted(Exception):
|
|||
self.sql = sql
|
||||
self.params = params
|
||||
|
||||
def __str__(self):
|
||||
return "QueryInterrupted: {}".format(self.e)
|
||||
|
||||
|
||||
class MultipleValues(Exception):
|
||||
pass
|
||||
|
|
@ -533,6 +975,9 @@ class Results:
|
|||
else:
|
||||
raise MultipleValues
|
||||
|
||||
def dicts(self):
|
||||
return [dict(row) for row in self.rows]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.rows)
|
||||
|
||||
|
|
|
|||
133
datasette/default_actions.py
Normal file
133
datasette/default_actions.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.permissions import Action
|
||||
from datasette.resources import (
|
||||
DatabaseResource,
|
||||
TableResource,
|
||||
QueryResource,
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_actions():
|
||||
"""Register the core Datasette actions."""
|
||||
return (
|
||||
# Global actions (no resource_class)
|
||||
Action(
|
||||
name="view-instance",
|
||||
abbr="vi",
|
||||
description="View Datasette instance",
|
||||
),
|
||||
Action(
|
||||
name="permissions-debug",
|
||||
abbr="pd",
|
||||
description="Access permission debug tool",
|
||||
),
|
||||
Action(
|
||||
name="debug-menu",
|
||||
abbr="dm",
|
||||
description="View debug menu items",
|
||||
),
|
||||
# Database-level actions (parent-level)
|
||||
Action(
|
||||
name="view-database",
|
||||
abbr="vd",
|
||||
description="View database",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="view-database-download",
|
||||
abbr="vdd",
|
||||
description="Download database file",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="execute-sql",
|
||||
abbr="es",
|
||||
description="Execute read-only SQL queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="execute-write-sql",
|
||||
abbr="ews",
|
||||
description="Execute writable SQL queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="view-database",
|
||||
),
|
||||
Action(
|
||||
name="create-table",
|
||||
abbr="ct",
|
||||
description="Create tables",
|
||||
resource_class=DatabaseResource,
|
||||
),
|
||||
Action(
|
||||
name="store-query",
|
||||
abbr="sq",
|
||||
description="Create stored queries",
|
||||
resource_class=DatabaseResource,
|
||||
also_requires="execute-sql",
|
||||
),
|
||||
# Table-level actions (child-level)
|
||||
Action(
|
||||
name="view-table",
|
||||
abbr="vt",
|
||||
description="View table",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="insert-row",
|
||||
abbr="ir",
|
||||
description="Insert rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-row",
|
||||
abbr="dr",
|
||||
description="Delete rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="update-row",
|
||||
abbr="ur",
|
||||
description="Update rows",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="alter-table",
|
||||
abbr="at",
|
||||
description="Alter tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="set-column-type",
|
||||
abbr="sct",
|
||||
description="Set column type",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
Action(
|
||||
name="drop-table",
|
||||
abbr="dt",
|
||||
description="Drop tables",
|
||||
resource_class=TableResource,
|
||||
),
|
||||
# Query-level actions (child-level)
|
||||
Action(
|
||||
name="view-query",
|
||||
abbr="vq",
|
||||
description="View named query results",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="update-query",
|
||||
abbr="uq",
|
||||
description="Update stored queries",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
Action(
|
||||
name="delete-query",
|
||||
abbr="dq",
|
||||
description="Delete stored queries",
|
||||
resource_class=QueryResource,
|
||||
),
|
||||
)
|
||||
81
datasette/default_column_types.py
Normal file
81
datasette/default_column_types.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
import markupsafe
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.column_types import ColumnType, SQLiteType
|
||||
|
||||
|
||||
class UrlColumnType(ColumnType):
|
||||
name = "url"
|
||||
description = "URL"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
escaped = markupsafe.escape(value.strip())
|
||||
return markupsafe.Markup(f'<a href="{escaped}">{escaped}</a>')
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return "URL must be a string"
|
||||
if not re.match(r"^https?://\S+$", value.strip()):
|
||||
return "Invalid URL"
|
||||
return None
|
||||
|
||||
|
||||
class EmailColumnType(ColumnType):
|
||||
name = "email"
|
||||
description = "Email address"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
escaped = markupsafe.escape(value.strip())
|
||||
return markupsafe.Markup(f'<a href="mailto:{escaped}">{escaped}</a>')
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return "Email must be a string"
|
||||
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()):
|
||||
return "Invalid email address"
|
||||
return None
|
||||
|
||||
|
||||
class JsonColumnType(ColumnType):
|
||||
name = "json"
|
||||
description = "JSON data"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
async def render_cell(self, value, column, table, database, datasette, request):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value) if isinstance(value, str) else value
|
||||
formatted = json.dumps(parsed, indent=2)
|
||||
escaped = markupsafe.escape(formatted)
|
||||
return markupsafe.Markup(f"<pre>{escaped}</pre>")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
async def validate(self, value, datasette):
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return "Invalid JSON"
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_column_types(datasette):
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType]
|
||||
24
datasette/default_database_actions.py
Normal file
24
datasette/default_database_actions.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
|
||||
|
||||
@hookimpl
|
||||
def database_actions(datasette, actor, database, request):
|
||||
async def inner():
|
||||
if not datasette.get_database(database).is_mutable:
|
||||
return []
|
||||
if not await datasette.allowed(
|
||||
action="execute-write-sql",
|
||||
resource=DatabaseResource(database),
|
||||
actor=actor,
|
||||
):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"href": datasette.urls.database(database) + "/-/execute-write",
|
||||
"label": "Execute write SQL",
|
||||
"description": "Run writable SQL with table permission checks.",
|
||||
}
|
||||
]
|
||||
|
||||
return inner
|
||||
75
datasette/default_debug_menu.py
Normal file
75
datasette/default_debug_menu.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
DEBUG_MENU_ITEMS = (
|
||||
(
|
||||
"/-/databases",
|
||||
"Databases",
|
||||
"List of databases known to this Datasette instance.",
|
||||
),
|
||||
(
|
||||
"/-/plugins",
|
||||
"Installed plugins",
|
||||
"Review loaded plugins, their versions and their registered hooks.",
|
||||
),
|
||||
(
|
||||
"/-/versions",
|
||||
"Version info",
|
||||
"Check the Python, SQLite and dependency versions used by this server.",
|
||||
),
|
||||
(
|
||||
"/-/settings",
|
||||
"Settings",
|
||||
"Inspect the active Datasette settings and configuration values.",
|
||||
),
|
||||
(
|
||||
"/-/permissions",
|
||||
"Debug permissions",
|
||||
"Test permission checks for actors, actions and resources.",
|
||||
),
|
||||
(
|
||||
"/-/messages",
|
||||
"Debug messages",
|
||||
"Try out temporary flash messages shown to users.",
|
||||
),
|
||||
(
|
||||
"/-/allow-debug",
|
||||
"Debug allow rules",
|
||||
"Explore how allow blocks match actors against permission rules.",
|
||||
),
|
||||
(
|
||||
"/-/threads",
|
||||
"Debug threads",
|
||||
"Inspect worker threads and database tasks.",
|
||||
),
|
||||
(
|
||||
"/-/actor",
|
||||
"Debug actor",
|
||||
"View the actor object for the current signed-in user.",
|
||||
),
|
||||
(
|
||||
"/-/patterns",
|
||||
"Pattern portfolio",
|
||||
"Browse Datasette UI patterns.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
if not await datasette.allowed(action="debug-menu", actor=actor):
|
||||
return []
|
||||
|
||||
return [
|
||||
JumpSQL.menu_item(
|
||||
label=label,
|
||||
url=datasette.urls.path(path),
|
||||
description=description,
|
||||
search_text=f"debug {label} {description}",
|
||||
item_type="debug",
|
||||
)
|
||||
for path, label, description in DEBUG_MENU_ITEMS
|
||||
]
|
||||
|
||||
return inner
|
||||
82
datasette/default_jump_items.py
Normal file
82
datasette/default_jump_items.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.jump import JumpSQL
|
||||
|
||||
|
||||
@hookimpl
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
async def inner():
|
||||
database_sql, database_params = await datasette.allowed_resources_sql(
|
||||
action="view-database", actor=actor
|
||||
)
|
||||
table_sql, table_params = await datasette.allowed_resources_sql(
|
||||
action="view-table", actor=actor
|
||||
)
|
||||
query_sql, query_params = await datasette.allowed_resources_sql(
|
||||
action="view-query", actor=actor
|
||||
)
|
||||
return [
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_databases AS (
|
||||
{database_sql}
|
||||
)
|
||||
SELECT
|
||||
'database' AS type,
|
||||
parent AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'database',
|
||||
'database', parent
|
||||
) AS url,
|
||||
parent AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_databases
|
||||
""",
|
||||
params=database_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_tables AS (
|
||||
{table_sql}
|
||||
)
|
||||
SELECT
|
||||
CASE WHEN catalog_views.view_name IS NULL THEN 'table' ELSE 'view' END AS type,
|
||||
allowed_tables.parent || ': ' || allowed_tables.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'table',
|
||||
'database', allowed_tables.parent,
|
||||
'table', allowed_tables.child
|
||||
) AS url,
|
||||
allowed_tables.parent || ' ' || allowed_tables.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_tables
|
||||
LEFT JOIN catalog_views
|
||||
ON catalog_views.database_name = allowed_tables.parent
|
||||
AND catalog_views.view_name = allowed_tables.child
|
||||
""",
|
||||
params=table_params,
|
||||
),
|
||||
JumpSQL(
|
||||
sql=f"""
|
||||
WITH allowed_queries AS (
|
||||
{query_sql}
|
||||
)
|
||||
SELECT
|
||||
'query' AS type,
|
||||
allowed_queries.parent || ': ' || allowed_queries.child AS label,
|
||||
NULL AS description,
|
||||
json_object(
|
||||
'method', 'query',
|
||||
'database', allowed_queries.parent,
|
||||
'query', allowed_queries.child
|
||||
) AS url,
|
||||
allowed_queries.parent || ' ' || allowed_queries.child AS search_text,
|
||||
NULL AS display_name
|
||||
FROM allowed_queries
|
||||
""",
|
||||
params=query_params,
|
||||
),
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -24,9 +24,12 @@ def now(key, request):
|
|||
if key == "epoch":
|
||||
return int(time.time())
|
||||
elif key == "date_utc":
|
||||
return datetime.datetime.utcnow().date().isoformat()
|
||||
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||
elif key == "datetime_utc":
|
||||
return datetime.datetime.utcnow().strftime(r"%Y-%m-%dT%H:%M:%S") + "Z"
|
||||
return (
|
||||
datetime.datetime.now(datetime.timezone.utc).strftime(r"%Y-%m-%dT%H:%M:%S")
|
||||
+ "Z"
|
||||
)
|
||||
else:
|
||||
raise KeyError
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl
|
||||
def menu_links(datasette, actor):
|
||||
async def inner():
|
||||
if not await datasette.permission_allowed(actor, "debug-menu"):
|
||||
return []
|
||||
|
||||
return [
|
||||
{"href": datasette.urls.path("/-/databases"), "label": "Databases"},
|
||||
{
|
||||
"href": datasette.urls.path("/-/plugins"),
|
||||
"label": "Installed plugins",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/versions"),
|
||||
"label": "Version info",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/metadata"),
|
||||
"label": "Metadata",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/settings"),
|
||||
"label": "Settings",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/permissions"),
|
||||
"label": "Debug permissions",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/messages"),
|
||||
"label": "Debug messages",
|
||||
},
|
||||
{
|
||||
"href": datasette.urls.path("/-/allow-debug"),
|
||||
"label": "Debug allow rules",
|
||||
},
|
||||
{"href": datasette.urls.path("/-/threads"), "label": "Debug threads"},
|
||||
{"href": datasette.urls.path("/-/actor"), "label": "Debug actor"},
|
||||
{"href": datasette.urls.path("/-/patterns"), "label": "Pattern portfolio"},
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.utils import actor_matches_allow
|
||||
|
||||
|
||||
@hookimpl(tryfirst=True)
|
||||
def permission_allowed(datasette, actor, action, resource):
|
||||
async def inner():
|
||||
if action in ("permissions-debug", "debug-menu"):
|
||||
if actor and actor.get("id") == "root":
|
||||
return True
|
||||
elif action == "view-instance":
|
||||
allow = datasette.metadata("allow")
|
||||
if allow is not None:
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "view-database":
|
||||
if resource == "_internal" and (actor is None or actor.get("id") != "root"):
|
||||
return False
|
||||
database_allow = datasette.metadata("allow", database=resource)
|
||||
if database_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow)
|
||||
elif action == "view-table":
|
||||
database, table = resource
|
||||
tables = datasette.metadata("tables", database=database) or {}
|
||||
table_allow = (tables.get(table) or {}).get("allow")
|
||||
if table_allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, table_allow)
|
||||
elif action == "view-query":
|
||||
# Check if this query has a "allow" block in metadata
|
||||
database, query_name = resource
|
||||
query = await datasette.get_canned_query(database, query_name, actor)
|
||||
assert query is not None
|
||||
allow = query.get("allow")
|
||||
if allow is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, allow)
|
||||
elif action == "execute-sql":
|
||||
# Use allow_sql block from database block, or from top-level
|
||||
database_allow_sql = datasette.metadata("allow_sql", database=resource)
|
||||
if database_allow_sql is None:
|
||||
database_allow_sql = datasette.metadata("allow_sql")
|
||||
if database_allow_sql is None:
|
||||
return None
|
||||
return actor_matches_allow(actor, database_allow_sql)
|
||||
|
||||
return inner
|
||||
34
datasette/default_permissions/__init__.py
Normal file
34
datasette/default_permissions/__init__.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""
|
||||
Default permission implementations for Datasette.
|
||||
|
||||
This module provides the built-in permission checking logic through implementations
|
||||
of the permission_resources_sql hook. The hooks are organized by their purpose:
|
||||
|
||||
1. Actor Restrictions - Enforces _r allowlists embedded in actor tokens
|
||||
2. Root User - Grants full access when --root flag is used
|
||||
3. Config Rules - Applies permissions from datasette.yaml
|
||||
4. Default Settings - Enforces default_allow_sql and default view permissions
|
||||
|
||||
IMPORTANT: These hooks return PermissionSQL objects that are combined using SQL
|
||||
UNION/INTERSECT operations. The order of evaluation is:
|
||||
- restriction_sql fields are INTERSECTed (all must match)
|
||||
- Regular sql fields are UNIONed and evaluated with cascading priority
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export all hooks and public utilities
|
||||
from .restrictions import (
|
||||
actor_restrictions_sql as actor_restrictions_sql,
|
||||
restrictions_allow_action as restrictions_allow_action,
|
||||
ActorRestrictions as ActorRestrictions,
|
||||
)
|
||||
from .root import root_user_permissions_sql as root_user_permissions_sql
|
||||
from .config import config_permissions_sql as config_permissions_sql
|
||||
from .defaults import (
|
||||
# Avoid "datasette.default_permissions" does not explicitly export attribute
|
||||
default_allow_sql_check as default_allow_sql_check,
|
||||
default_action_permissions_sql as default_action_permissions_sql,
|
||||
default_query_permissions_sql as default_query_permissions_sql,
|
||||
DEFAULT_ALLOW_ACTIONS as DEFAULT_ALLOW_ACTIONS,
|
||||
)
|
||||
442
datasette/default_permissions/config.py
Normal file
442
datasette/default_permissions/config.py
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
"""
|
||||
Config-based permission handling for Datasette.
|
||||
|
||||
Applies permission rules from datasette.yaml configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
from datasette.utils import actor_matches_allow
|
||||
|
||||
from .helpers import PermissionRowCollector, get_action_name_variants
|
||||
|
||||
|
||||
class ConfigPermissionProcessor:
|
||||
"""
|
||||
Processes permission rules from datasette.yaml configuration.
|
||||
|
||||
Configuration structure:
|
||||
|
||||
permissions: # Root-level permissions block
|
||||
view-instance:
|
||||
id: admin
|
||||
|
||||
databases:
|
||||
mydb:
|
||||
permissions: # Database-level permissions
|
||||
view-database:
|
||||
id: admin
|
||||
allow: # Database-level allow block (for view-*)
|
||||
id: viewer
|
||||
allow_sql: # execute-sql allow block
|
||||
id: analyst
|
||||
tables:
|
||||
users:
|
||||
permissions: # Table-level permissions
|
||||
view-table:
|
||||
id: admin
|
||||
allow: # Table-level allow block
|
||||
id: viewer
|
||||
queries:
|
||||
my_query:
|
||||
permissions: # Query-level permissions
|
||||
view-query:
|
||||
id: admin
|
||||
allow: # Query-level allow block
|
||||
id: viewer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
):
|
||||
self.datasette = datasette
|
||||
self.actor = actor
|
||||
self.action = action
|
||||
self.config = datasette.config or {}
|
||||
self.collector = PermissionRowCollector(prefix="cfg")
|
||||
|
||||
# Pre-compute action variants
|
||||
self.action_checks = get_action_name_variants(datasette, action)
|
||||
self.action_obj = datasette.actions.get(action)
|
||||
|
||||
# Parse restrictions if present
|
||||
self.has_restrictions = actor and "_r" in actor if actor else False
|
||||
self.restrictions = actor.get("_r", {}) if actor else {}
|
||||
|
||||
# Pre-compute restriction info for efficiency
|
||||
self.restricted_databases: Set[str] = set()
|
||||
self.restricted_tables: Set[Tuple[str, str]] = set()
|
||||
|
||||
if self.has_restrictions:
|
||||
self.restricted_databases = {
|
||||
db_name
|
||||
for db_name, db_actions in (self.restrictions.get("d") or {}).items()
|
||||
if self.action_checks.intersection(db_actions)
|
||||
}
|
||||
self.restricted_tables = {
|
||||
(db_name, table_name)
|
||||
for db_name, tables in (self.restrictions.get("r") or {}).items()
|
||||
for table_name, table_actions in tables.items()
|
||||
if self.action_checks.intersection(table_actions)
|
||||
}
|
||||
# Tables implicitly reference their parent databases
|
||||
self.restricted_databases.update(db for db, _ in self.restricted_tables)
|
||||
|
||||
def evaluate_allow_block(self, allow_block: Any) -> Optional[bool]:
|
||||
"""Evaluate an allow block against the current actor."""
|
||||
if allow_block is None:
|
||||
return None
|
||||
return actor_matches_allow(self.actor, allow_block)
|
||||
|
||||
def is_in_restriction_allowlist(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
) -> bool:
|
||||
"""Check if resource is allowed by actor restrictions."""
|
||||
if not self.has_restrictions:
|
||||
return True # No restrictions, all resources allowed
|
||||
|
||||
# Check global allowlist
|
||||
if self.action_checks.intersection(self.restrictions.get("a", [])):
|
||||
return True
|
||||
|
||||
# Check database-level allowlist
|
||||
if parent and self.action_checks.intersection(
|
||||
self.restrictions.get("d", {}).get(parent, [])
|
||||
):
|
||||
return True
|
||||
|
||||
# Check table-level allowlist
|
||||
if parent:
|
||||
table_restrictions = (self.restrictions.get("r", {}) or {}).get(parent, {})
|
||||
if child:
|
||||
table_actions = table_restrictions.get(child, [])
|
||||
if self.action_checks.intersection(table_actions):
|
||||
return True
|
||||
else:
|
||||
# Parent query should proceed if any child in this database is allowlisted
|
||||
for table_actions in table_restrictions.values():
|
||||
if self.action_checks.intersection(table_actions):
|
||||
return True
|
||||
|
||||
# Parent/child both None: include if any restrictions exist for this action
|
||||
if parent is None and child is None:
|
||||
if self.action_checks.intersection(self.restrictions.get("a", [])):
|
||||
return True
|
||||
if self.restricted_databases:
|
||||
return True
|
||||
if self.restricted_tables:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def add_permissions_rule(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
permissions_block: Optional[dict],
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""Add a rule from a permissions:{action} block."""
|
||||
if permissions_block is None:
|
||||
return
|
||||
|
||||
action_allow_block = permissions_block.get(self.action)
|
||||
result = self.evaluate_allow_block(action_allow_block)
|
||||
|
||||
self.collector.add(
|
||||
parent=parent,
|
||||
child=child,
|
||||
allow=result,
|
||||
reason=f"config {'allow' if result else 'deny'} {scope_desc}",
|
||||
if_not_none=True,
|
||||
)
|
||||
|
||||
def add_allow_block_rule(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow_block: Any,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""
|
||||
Add rules from an allow:{} block.
|
||||
|
||||
For allow blocks, if the block exists but doesn't match the actor,
|
||||
this is treated as a deny. We also handle the restriction-gate logic.
|
||||
"""
|
||||
if allow_block is None:
|
||||
return
|
||||
|
||||
# Skip if resource is not in restriction allowlist
|
||||
if not self.is_in_restriction_allowlist(parent, child):
|
||||
return
|
||||
|
||||
result = self.evaluate_allow_block(allow_block)
|
||||
bool_result = bool(result)
|
||||
|
||||
self.collector.add(
|
||||
parent,
|
||||
child,
|
||||
bool_result,
|
||||
f"config {'allow' if result else 'deny'} {scope_desc}",
|
||||
)
|
||||
|
||||
# Handle restriction-gate: add explicit denies for restricted resources
|
||||
self._add_restriction_gate_denies(parent, child, bool_result, scope_desc)
|
||||
|
||||
def _add_restriction_gate_denies(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
is_allowed: bool,
|
||||
scope_desc: str,
|
||||
) -> None:
|
||||
"""
|
||||
When a config rule denies at a higher level, add explicit denies
|
||||
for restricted resources to prevent child-level allows from
|
||||
incorrectly granting access.
|
||||
"""
|
||||
if is_allowed or child is not None or not self.has_restrictions:
|
||||
return
|
||||
|
||||
if not self.action_obj:
|
||||
return
|
||||
|
||||
reason = f"config deny {scope_desc} (restriction gate)"
|
||||
|
||||
if parent is None:
|
||||
# Root-level deny: add denies for all restricted resources
|
||||
if self.action_obj.takes_parent:
|
||||
for db_name in self.restricted_databases:
|
||||
self.collector.add(db_name, None, False, reason)
|
||||
if self.action_obj.takes_child:
|
||||
for db_name, table_name in self.restricted_tables:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
else:
|
||||
# Database-level deny: add denies for tables in that database
|
||||
if self.action_obj.takes_child:
|
||||
for db_name, table_name in self.restricted_tables:
|
||||
if db_name == parent:
|
||||
self.collector.add(db_name, table_name, False, reason)
|
||||
|
||||
def process(self) -> Optional[PermissionSQL]:
|
||||
"""Process all config rules and return combined PermissionSQL."""
|
||||
self._process_root_permissions()
|
||||
self._process_databases()
|
||||
self._process_root_allow_blocks()
|
||||
|
||||
return self.collector.to_permission_sql()
|
||||
|
||||
def _process_root_permissions(self) -> None:
|
||||
"""Process root-level permissions block."""
|
||||
root_perms = self.config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
None,
|
||||
None,
|
||||
root_perms,
|
||||
f"permissions for {self.action}",
|
||||
)
|
||||
|
||||
def _process_databases(self) -> None:
|
||||
"""Process database-level and nested configurations."""
|
||||
databases = self.config.get("databases") or {}
|
||||
|
||||
for db_name, db_config in databases.items():
|
||||
self._process_database(db_name, db_config or {})
|
||||
|
||||
def _process_database(self, db_name: str, db_config: dict) -> None:
|
||||
"""Process a single database's configuration."""
|
||||
# Database-level permissions block
|
||||
db_perms = db_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_perms,
|
||||
f"permissions for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# Process tables
|
||||
for table_name, table_config in (db_config.get("tables") or {}).items():
|
||||
self._process_table(db_name, table_name, table_config or {})
|
||||
|
||||
# Process queries
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
self._process_query(db_name, query_name, query_config)
|
||||
|
||||
# Database-level allow blocks
|
||||
self._process_database_allow_blocks(db_name, db_config)
|
||||
|
||||
def _process_table(
|
||||
self,
|
||||
db_name: str,
|
||||
table_name: str,
|
||||
table_config: dict,
|
||||
) -> None:
|
||||
"""Process a single table's configuration."""
|
||||
# Table-level permissions block
|
||||
table_perms = table_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
table_name,
|
||||
table_perms,
|
||||
f"permissions for {self.action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
# Table-level allow block (for view-table)
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
table_name,
|
||||
table_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}/{table_name}",
|
||||
)
|
||||
|
||||
def _process_query(
|
||||
self,
|
||||
db_name: str,
|
||||
query_name: str,
|
||||
query_config: Any,
|
||||
) -> None:
|
||||
"""Process a single query's configuration."""
|
||||
# Query config can be a string (just SQL) or dict
|
||||
if not isinstance(query_config, dict):
|
||||
return
|
||||
|
||||
# Query-level permissions block
|
||||
query_perms = query_config.get("permissions") or {}
|
||||
self.add_permissions_rule(
|
||||
db_name,
|
||||
query_name,
|
||||
query_perms,
|
||||
f"permissions for {self.action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
# Query-level allow block (for view-query)
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
query_name,
|
||||
query_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}/{query_name}",
|
||||
)
|
||||
|
||||
def _process_database_allow_blocks(
|
||||
self,
|
||||
db_name: str,
|
||||
db_config: dict,
|
||||
) -> None:
|
||||
"""Process database-level allow/allow_sql blocks."""
|
||||
# view-database allow block
|
||||
if self.action == "view-database":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# execute-sql allow_sql block
|
||||
if self.action == "execute-sql":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow_sql"),
|
||||
f"allow_sql for {db_name}",
|
||||
)
|
||||
|
||||
# view-table uses database-level allow for inheritance
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
# view-query uses database-level allow for inheritance
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
db_name,
|
||||
None,
|
||||
db_config.get("allow"),
|
||||
f"allow for {self.action} on {db_name}",
|
||||
)
|
||||
|
||||
def _process_root_allow_blocks(self) -> None:
|
||||
"""Process root-level allow/allow_sql blocks."""
|
||||
root_allow = self.config.get("allow")
|
||||
|
||||
if self.action == "view-instance":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-instance",
|
||||
)
|
||||
|
||||
if self.action == "view-database":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-database",
|
||||
)
|
||||
|
||||
if self.action == "view-table":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-table",
|
||||
)
|
||||
|
||||
if self.action == "view-query":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
root_allow,
|
||||
"allow for view-query",
|
||||
)
|
||||
|
||||
if self.action == "execute-sql":
|
||||
self.add_allow_block_rule(
|
||||
None,
|
||||
None,
|
||||
self.config.get("allow_sql"),
|
||||
"allow_sql",
|
||||
)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def config_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Apply permission rules from datasette.yaml configuration.
|
||||
|
||||
This processes:
|
||||
- permissions: blocks at root, database, table, and query levels
|
||||
- allow: blocks for view-* actions
|
||||
- allow_sql: blocks for execute-sql action
|
||||
"""
|
||||
processor = ConfigPermissionProcessor(datasette, actor, action)
|
||||
result = processor.process()
|
||||
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
return [result]
|
||||
114
datasette/default_permissions/defaults.py
Normal file
114
datasette/default_permissions/defaults.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""
|
||||
Default permission settings for Datasette.
|
||||
|
||||
Provides default allow rules for standard view/execute actions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
# Actions that are allowed by default (unless --default-deny is used)
|
||||
DEFAULT_ALLOW_ACTIONS = frozenset(
|
||||
{
|
||||
"view-instance",
|
||||
"view-database",
|
||||
"view-database-download",
|
||||
"view-table",
|
||||
"view-query",
|
||||
"execute-sql",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_allow_sql_check(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Enforce the default_allow_sql setting.
|
||||
|
||||
When default_allow_sql is false (the default), execute-sql is denied
|
||||
unless explicitly allowed by config or other rules.
|
||||
"""
|
||||
if action == "execute-sql":
|
||||
if not datasette.setting("default_allow_sql"):
|
||||
return PermissionSQL.deny(reason="default_allow_sql is false")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_action_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Provide default allow rules for standard view/execute actions.
|
||||
|
||||
These defaults are skipped when datasette is started with --default-deny.
|
||||
The restriction_sql mechanism (from actor_restrictions_sql) will still
|
||||
filter these results if the actor has restrictions.
|
||||
"""
|
||||
if datasette.default_deny:
|
||||
return None
|
||||
|
||||
if action in DEFAULT_ALLOW_ACTIONS:
|
||||
reason = f"default allow for {action}".replace("'", "''")
|
||||
return PermissionSQL.allow(reason=reason)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def default_query_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[PermissionSQL]:
|
||||
actor_id = actor.get("id") if isinstance(actor, dict) else None
|
||||
|
||||
if action not in {"view-query", "update-query", "delete-query"}:
|
||||
return None
|
||||
|
||||
params = {"query_owner_id": actor_id}
|
||||
rule_sqls = []
|
||||
if actor_id is not None:
|
||||
if action in {"update-query", "delete-query"}:
|
||||
# Query owner can update/delete query
|
||||
rule_sqls.append("""
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'query owner' AS reason
|
||||
FROM queries
|
||||
WHERE source = 'user'
|
||||
AND owner_id = :query_owner_id
|
||||
""")
|
||||
else:
|
||||
# Query owner can view-query
|
||||
rule_sqls.append("""
|
||||
SELECT database_name AS parent, name AS child, 1 AS allow,
|
||||
'query owner' AS reason
|
||||
FROM queries
|
||||
WHERE owner_id = :query_owner_id
|
||||
""")
|
||||
|
||||
# restriction_sql enforces private queries ONLY visible/mutable by owner
|
||||
return PermissionSQL(
|
||||
sql="\nUNION ALL\n".join(rule_sqls) if rule_sqls else None,
|
||||
restriction_sql="""
|
||||
SELECT database_name AS parent, name AS child
|
||||
FROM queries
|
||||
WHERE is_private = 0
|
||||
OR owner_id = :query_owner_id
|
||||
""",
|
||||
params=params,
|
||||
)
|
||||
85
datasette/default_permissions/helpers.py
Normal file
85
datasette/default_permissions/helpers.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""
|
||||
Shared helper utilities for default permission implementations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Set
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
def get_action_name_variants(datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""
|
||||
Get all name variants for an action (full name and abbreviation).
|
||||
|
||||
Example:
|
||||
get_action_name_variants(ds, "view-table") -> {"view-table", "vt"}
|
||||
"""
|
||||
variants = {action}
|
||||
action_obj = datasette.actions.get(action)
|
||||
if action_obj and action_obj.abbr:
|
||||
variants.add(action_obj.abbr)
|
||||
return variants
|
||||
|
||||
|
||||
def action_in_list(datasette: "Datasette", action: str, action_list: list) -> bool:
|
||||
"""Check if an action (or its abbreviation) is in a list."""
|
||||
return bool(get_action_name_variants(datasette, action).intersection(action_list))
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionRow:
|
||||
"""A single permission rule row."""
|
||||
|
||||
parent: Optional[str]
|
||||
child: Optional[str]
|
||||
allow: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class PermissionRowCollector:
|
||||
"""Collects permission rows and converts them to PermissionSQL."""
|
||||
|
||||
def __init__(self, prefix: str = "row"):
|
||||
self.rows: List[PermissionRow] = []
|
||||
self.prefix = prefix
|
||||
|
||||
def add(
|
||||
self,
|
||||
parent: Optional[str],
|
||||
child: Optional[str],
|
||||
allow: Optional[bool],
|
||||
reason: str,
|
||||
if_not_none: bool = False,
|
||||
) -> None:
|
||||
"""Add a permission row. If if_not_none=True, only add if allow is not None."""
|
||||
if if_not_none and allow is None:
|
||||
return
|
||||
self.rows.append(PermissionRow(parent, child, allow, reason))
|
||||
|
||||
def to_permission_sql(self) -> Optional[PermissionSQL]:
|
||||
"""Convert collected rows to a PermissionSQL object."""
|
||||
if not self.rows:
|
||||
return None
|
||||
|
||||
parts = []
|
||||
params = {}
|
||||
|
||||
for idx, row in enumerate(self.rows):
|
||||
key = f"{self.prefix}_{idx}"
|
||||
parts.append(
|
||||
f"SELECT :{key}_parent AS parent, :{key}_child AS child, "
|
||||
f":{key}_allow AS allow, :{key}_reason AS reason"
|
||||
)
|
||||
params[f"{key}_parent"] = row.parent
|
||||
params[f"{key}_child"] = row.child
|
||||
params[f"{key}_allow"] = 1 if row.allow else 0
|
||||
params[f"{key}_reason"] = row.reason
|
||||
|
||||
sql = "\nUNION ALL\n".join(parts)
|
||||
return PermissionSQL(sql=sql, params=params)
|
||||
195
datasette/default_permissions/restrictions.py
Normal file
195
datasette/default_permissions/restrictions.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
Actor restriction handling for Datasette permissions.
|
||||
|
||||
This module handles the _r (restrictions) key in actor dictionaries, which
|
||||
contains allowlists of resources the actor can access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, List, Optional, Set, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
from .helpers import action_in_list, get_action_name_variants
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActorRestrictions:
|
||||
"""Parsed actor restrictions from the _r key."""
|
||||
|
||||
global_actions: List[str] # _r.a - globally allowed actions
|
||||
database_actions: dict # _r.d - {db_name: [actions]}
|
||||
table_actions: dict # _r.r - {db_name: {table: [actions]}}
|
||||
|
||||
@classmethod
|
||||
def from_actor(cls, actor: Optional[dict]) -> Optional["ActorRestrictions"]:
|
||||
"""Parse restrictions from actor dict. Returns None if no restrictions."""
|
||||
if not actor:
|
||||
return None
|
||||
assert isinstance(actor, dict), "actor must be a dictionary"
|
||||
|
||||
restrictions = actor.get("_r")
|
||||
if restrictions is None:
|
||||
return None
|
||||
|
||||
return cls(
|
||||
global_actions=restrictions.get("a", []),
|
||||
database_actions=restrictions.get("d", {}),
|
||||
table_actions=restrictions.get("r", {}),
|
||||
)
|
||||
|
||||
def is_action_globally_allowed(self, datasette: "Datasette", action: str) -> bool:
|
||||
"""Check if action is in the global allowlist."""
|
||||
return action_in_list(datasette, action, self.global_actions)
|
||||
|
||||
def get_allowed_databases(self, datasette: "Datasette", action: str) -> Set[str]:
|
||||
"""Get database names where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, db_actions in self.database_actions.items():
|
||||
if action_in_list(datasette, action, db_actions):
|
||||
allowed.add(db_name)
|
||||
return allowed
|
||||
|
||||
def get_allowed_tables(
|
||||
self, datasette: "Datasette", action: str
|
||||
) -> Set[Tuple[str, str]]:
|
||||
"""Get (database, table) pairs where this action is allowed."""
|
||||
allowed = set()
|
||||
for db_name, tables in self.table_actions.items():
|
||||
for table_name, table_actions in tables.items():
|
||||
if action_in_list(datasette, action, table_actions):
|
||||
allowed.add((db_name, table_name))
|
||||
return allowed
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def actor_restrictions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
action: str,
|
||||
) -> Optional[List[PermissionSQL]]:
|
||||
"""
|
||||
Handle actor restriction-based permission rules.
|
||||
|
||||
When an actor has an "_r" key, it contains an allowlist of resources they
|
||||
can access. This function returns restriction_sql that filters the final
|
||||
results to only include resources in that allowlist.
|
||||
|
||||
The _r structure:
|
||||
{
|
||||
"a": ["vi", "pd"], # Global actions allowed
|
||||
"d": {"mydb": ["vt", "es"]}, # Database-level actions
|
||||
"r": {"mydb": {"users": ["vt"]}} # Table-level actions
|
||||
}
|
||||
"""
|
||||
if not actor:
|
||||
return None
|
||||
|
||||
restrictions = ActorRestrictions.from_actor(actor)
|
||||
|
||||
if restrictions is None:
|
||||
# No restrictions - all resources allowed
|
||||
return []
|
||||
|
||||
# If globally allowed, no filtering needed
|
||||
if restrictions.is_action_globally_allowed(datasette, action):
|
||||
return []
|
||||
|
||||
# Build restriction SQL
|
||||
allowed_dbs = restrictions.get_allowed_databases(datasette, action)
|
||||
allowed_tables = restrictions.get_allowed_tables(datasette, action)
|
||||
|
||||
# If nothing is allowed for this action, return empty-set restriction
|
||||
if not allowed_dbs and not allowed_tables:
|
||||
return [
|
||||
PermissionSQL(
|
||||
params={"deny": f"actor restrictions: {action} not in allowlist"},
|
||||
restriction_sql="SELECT NULL AS parent, NULL AS child WHERE 0",
|
||||
)
|
||||
]
|
||||
|
||||
# Build UNION of allowed resources
|
||||
selects = []
|
||||
params = {}
|
||||
counter = 0
|
||||
|
||||
# Database-level entries (parent, NULL) - allows all children
|
||||
for db_name in allowed_dbs:
|
||||
key = f"restr_{counter}"
|
||||
counter += 1
|
||||
selects.append(f"SELECT :{key}_parent AS parent, NULL AS child")
|
||||
params[f"{key}_parent"] = db_name
|
||||
|
||||
# Table-level entries (parent, child)
|
||||
for db_name, table_name in allowed_tables:
|
||||
key = f"restr_{counter}"
|
||||
counter += 1
|
||||
selects.append(f"SELECT :{key}_parent AS parent, :{key}_child AS child")
|
||||
params[f"{key}_parent"] = db_name
|
||||
params[f"{key}_child"] = table_name
|
||||
|
||||
restriction_sql = "\nUNION ALL\n".join(selects)
|
||||
|
||||
return [PermissionSQL(params=params, restriction_sql=restriction_sql)]
|
||||
|
||||
|
||||
def restrictions_allow_action(
|
||||
datasette: "Datasette",
|
||||
restrictions: dict,
|
||||
action: str,
|
||||
resource: Optional[str | Tuple[str, str]],
|
||||
) -> bool:
|
||||
"""
|
||||
Check if restrictions allow the requested action on the requested resource.
|
||||
|
||||
This is a synchronous utility function for use by other code that needs
|
||||
to quickly check restriction allowlists.
|
||||
|
||||
Args:
|
||||
datasette: The Datasette instance
|
||||
restrictions: The _r dict from an actor
|
||||
action: The action name to check
|
||||
resource: None for global, str for database, (db, table) tuple for table
|
||||
|
||||
Returns:
|
||||
True if allowed, False if denied
|
||||
"""
|
||||
# Does this action have an abbreviation?
|
||||
to_check = get_action_name_variants(datasette, action)
|
||||
|
||||
# Check global level (any resource)
|
||||
all_allowed = restrictions.get("a")
|
||||
if all_allowed is not None:
|
||||
assert isinstance(all_allowed, list)
|
||||
if to_check.intersection(all_allowed):
|
||||
return True
|
||||
|
||||
# Check database level
|
||||
if resource:
|
||||
if isinstance(resource, str):
|
||||
database_name = resource
|
||||
else:
|
||||
database_name = resource[0]
|
||||
database_allowed = restrictions.get("d", {}).get(database_name)
|
||||
if database_allowed is not None:
|
||||
assert isinstance(database_allowed, list)
|
||||
if to_check.intersection(database_allowed):
|
||||
return True
|
||||
|
||||
# Check table/resource level
|
||||
if resource is not None and not isinstance(resource, str) and len(resource) == 2:
|
||||
database, table = resource
|
||||
table_allowed = restrictions.get("r", {}).get(database, {}).get(table)
|
||||
if table_allowed is not None:
|
||||
assert isinstance(table_allowed, list)
|
||||
if to_check.intersection(table_allowed):
|
||||
return True
|
||||
|
||||
# This action is not explicitly allowed, so reject it
|
||||
return False
|
||||
29
datasette/default_permissions/root.py
Normal file
29
datasette/default_permissions/root.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""
|
||||
Root user permission handling for Datasette.
|
||||
|
||||
Grants full permissions to the root user when --root flag is used.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.permissions import PermissionSQL
|
||||
|
||||
|
||||
@hookimpl(specname="permission_resources_sql")
|
||||
async def root_user_permissions_sql(
|
||||
datasette: "Datasette",
|
||||
actor: Optional[dict],
|
||||
) -> Optional[PermissionSQL]:
|
||||
"""
|
||||
Grant root user full permissions when --root flag is used.
|
||||
"""
|
||||
if not datasette.root_enabled:
|
||||
return None
|
||||
if actor is not None and actor.get("id") == "root":
|
||||
return PermissionSQL.allow(reason="root user")
|
||||
40
datasette/default_permissions/tokens.py
Normal file
40
datasette/default_permissions/tokens.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""
|
||||
Token authentication for Datasette.
|
||||
|
||||
Registers the default SignedTokenHandler and delegates token verification
|
||||
to datasette.verify_token() so all registered handlers are tried.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datasette.app import Datasette
|
||||
|
||||
from datasette import hookimpl
|
||||
from datasette.tokens import SignedTokenHandler
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_token_handler(datasette: "Datasette"):
|
||||
"""Register the default signed token handler."""
|
||||
return SignedTokenHandler()
|
||||
|
||||
|
||||
@hookimpl(specname="actor_from_request")
|
||||
async def actor_from_signed_api_token(
|
||||
datasette: "Datasette", request
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Authenticate requests using API tokens by delegating to all registered
|
||||
token handlers via datasette.verify_token().
|
||||
"""
|
||||
authorization = request.headers.get("authorization")
|
||||
if not authorization:
|
||||
return None
|
||||
if not authorization.startswith("Bearer "):
|
||||
return None
|
||||
|
||||
token = authorization[len("Bearer ") :]
|
||||
return await datasette.verify_token(token)
|
||||
293
datasette/events.py
Normal file
293
datasette/events.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
from abc import ABC, abstractproperty
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datasette.hookspecs import hookimpl
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event(ABC):
|
||||
@abstractproperty
|
||||
def name(self):
|
||||
pass
|
||||
|
||||
created: datetime = field(
|
||||
init=False, default_factory=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
actor: dict | None
|
||||
|
||||
def properties(self):
|
||||
properties = asdict(self)
|
||||
properties.pop("actor", None)
|
||||
properties.pop("created", None)
|
||||
return properties
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginEvent(Event):
|
||||
"""
|
||||
Event name: ``login``
|
||||
|
||||
A user (represented by ``event.actor``) has logged in.
|
||||
"""
|
||||
|
||||
name = "login"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogoutEvent(Event):
|
||||
"""
|
||||
Event name: ``logout``
|
||||
|
||||
A user (represented by ``event.actor``) has logged out.
|
||||
"""
|
||||
|
||||
name = "logout"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateTokenEvent(Event):
|
||||
"""
|
||||
Event name: ``create-token``
|
||||
|
||||
A user created an API token.
|
||||
|
||||
:ivar expires_after: Number of seconds after which this token will expire.
|
||||
:type expires_after: int or None
|
||||
:ivar restrict_all: Restricted permissions for this token.
|
||||
:type restrict_all: list
|
||||
:ivar restrict_database: Restricted database permissions for this token.
|
||||
:type restrict_database: dict
|
||||
:ivar restrict_resource: Restricted resource permissions for this token.
|
||||
:type restrict_resource: dict
|
||||
"""
|
||||
|
||||
name = "create-token"
|
||||
expires_after: int | None
|
||||
restrict_all: list
|
||||
restrict_database: dict
|
||||
restrict_resource: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreateTableEvent(Event):
|
||||
"""
|
||||
Event name: ``create-table``
|
||||
|
||||
A new table has been created in the database.
|
||||
|
||||
:ivar database: The name of the database where the table was created.
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was created
|
||||
:type table: str
|
||||
:ivar schema: The SQL schema definition for the new table.
|
||||
:type schema: str
|
||||
"""
|
||||
|
||||
name = "create-table"
|
||||
database: str
|
||||
table: str
|
||||
schema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DropTableEvent(Event):
|
||||
"""
|
||||
Event name: ``drop-table``
|
||||
|
||||
A table has been dropped from the database.
|
||||
|
||||
:ivar database: The name of the database where the table was dropped.
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was dropped
|
||||
:type table: str
|
||||
"""
|
||||
|
||||
name = "drop-table"
|
||||
database: str
|
||||
table: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlterTableEvent(Event):
|
||||
"""
|
||||
Event name: ``alter-table``
|
||||
|
||||
A table has been altered.
|
||||
|
||||
:ivar database: The name of the database where the table was altered
|
||||
:type database: str
|
||||
:ivar table: The name of the table that was altered
|
||||
:type table: str
|
||||
:ivar before_schema: The table's SQL schema before the alteration
|
||||
:type before_schema: str
|
||||
:ivar after_schema: The table's SQL schema after the alteration
|
||||
:type after_schema: str
|
||||
"""
|
||||
|
||||
name = "alter-table"
|
||||
database: str
|
||||
table: str
|
||||
before_schema: str
|
||||
after_schema: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InsertRowsEvent(Event):
|
||||
"""
|
||||
Event name: ``insert-rows``
|
||||
|
||||
Rows were inserted into a table.
|
||||
|
||||
:ivar database: The name of the database where the rows were inserted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the rows were inserted.
|
||||
:type table: str
|
||||
:ivar num_rows: The number of rows that were requested to be inserted.
|
||||
:type num_rows: int
|
||||
:ivar ignore: Was ignore set?
|
||||
:type ignore: bool
|
||||
:ivar replace: Was replace set?
|
||||
:type replace: bool
|
||||
"""
|
||||
|
||||
name = "insert-rows"
|
||||
database: str
|
||||
table: str
|
||||
num_rows: int
|
||||
ignore: bool
|
||||
replace: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpsertRowsEvent(Event):
|
||||
"""
|
||||
Event name: ``upsert-rows``
|
||||
|
||||
Rows were upserted into a table.
|
||||
|
||||
:ivar database: The name of the database where the rows were inserted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the rows were inserted.
|
||||
:type table: str
|
||||
:ivar num_rows: The number of rows that were requested to be inserted.
|
||||
:type num_rows: int
|
||||
"""
|
||||
|
||||
name = "upsert-rows"
|
||||
database: str
|
||||
table: str
|
||||
num_rows: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateRowEvent(Event):
|
||||
"""
|
||||
Event name: ``update-row``
|
||||
|
||||
A row was updated in a table.
|
||||
|
||||
:ivar database: The name of the database where the row was updated.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the row was updated.
|
||||
:type table: str
|
||||
:ivar pks: The primary key values of the updated row.
|
||||
"""
|
||||
|
||||
name = "update-row"
|
||||
database: str
|
||||
table: str
|
||||
pks: list
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenameTableEvent(Event):
|
||||
"""
|
||||
Event name: ``rename-table``
|
||||
|
||||
A table has been renamed.
|
||||
|
||||
:ivar database: The name of the database containing the renamed table.
|
||||
:type database: str
|
||||
:ivar old_table: The previous name of the table.
|
||||
:type old_table: str
|
||||
:ivar new_table: The new name of the table.
|
||||
:type new_table: str
|
||||
"""
|
||||
|
||||
name = "rename-table"
|
||||
database: str
|
||||
old_table: str
|
||||
new_table: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteRowEvent(Event):
|
||||
"""
|
||||
Event name: ``delete-row``
|
||||
|
||||
A row was deleted from a table.
|
||||
|
||||
:ivar database: The name of the database where the row was deleted.
|
||||
:type database: str
|
||||
:ivar table: The name of the table where the row was deleted.
|
||||
:type table: str
|
||||
:ivar pks: The primary key values of the deleted row.
|
||||
"""
|
||||
|
||||
name = "delete-row"
|
||||
database: str
|
||||
table: str
|
||||
pks: list
|
||||
|
||||
|
||||
@hookimpl
|
||||
def write_wrapper(datasette, database, request, transaction):
|
||||
def wrapper(conn, track_event):
|
||||
# Snapshot rootpage -> name before the write
|
||||
before = {
|
||||
row[1]: row[0]
|
||||
for row in conn.execute(
|
||||
"select name, rootpage from sqlite_master"
|
||||
" where type='table' and rootpage != 0"
|
||||
).fetchall()
|
||||
}
|
||||
yield
|
||||
# Snapshot rootpage -> name after the write
|
||||
after = {
|
||||
row[1]: row[0]
|
||||
for row in conn.execute(
|
||||
"select name, rootpage from sqlite_master"
|
||||
" where type='table' and rootpage != 0"
|
||||
).fetchall()
|
||||
}
|
||||
# Detect renames: same rootpage, different name
|
||||
for rootpage, old_name in before.items():
|
||||
new_name = after.get(rootpage)
|
||||
if new_name and new_name != old_name:
|
||||
track_event(
|
||||
RenameTableEvent(
|
||||
actor=request.actor if request else None,
|
||||
database=database,
|
||||
old_table=old_name,
|
||||
new_table=new_name,
|
||||
)
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_events():
|
||||
return [
|
||||
LoginEvent,
|
||||
LogoutEvent,
|
||||
CreateTableEvent,
|
||||
CreateTokenEvent,
|
||||
AlterTableEvent,
|
||||
RenameTableEvent,
|
||||
DropTableEvent,
|
||||
InsertRowsEvent,
|
||||
UpsertRowsEvent,
|
||||
UpdateRowEvent,
|
||||
DeleteRowEvent,
|
||||
]
|
||||
|
|
@ -11,8 +11,8 @@ from datasette.utils import (
|
|||
)
|
||||
|
||||
|
||||
def load_facet_configs(request, table_metadata):
|
||||
# Given a request and the metadata configuration for a table, return
|
||||
def load_facet_configs(request, table_config):
|
||||
# Given a request and the configuration for a table, return
|
||||
# a dictionary of selected facets, their lists of configs and for each
|
||||
# config whether it came from the request or the metadata.
|
||||
#
|
||||
|
|
@ -20,21 +20,21 @@ def load_facet_configs(request, table_metadata):
|
|||
# {"source": "metadata", "config": config1},
|
||||
# {"source": "request", "config": config2}]}
|
||||
facet_configs = {}
|
||||
table_metadata = table_metadata or {}
|
||||
metadata_facets = table_metadata.get("facets", [])
|
||||
for metadata_config in metadata_facets:
|
||||
if isinstance(metadata_config, str):
|
||||
table_config = table_config or {}
|
||||
table_facet_configs = table_config.get("facets", [])
|
||||
for facet_config in table_facet_configs:
|
||||
if isinstance(facet_config, str):
|
||||
type = "column"
|
||||
metadata_config = {"simple": metadata_config}
|
||||
facet_config = {"simple": facet_config}
|
||||
else:
|
||||
assert (
|
||||
len(metadata_config.values()) == 1
|
||||
len(facet_config.values()) == 1
|
||||
), "Metadata config dicts should be {type: config}"
|
||||
type, metadata_config = list(metadata_config.items())[0]
|
||||
if isinstance(metadata_config, str):
|
||||
metadata_config = {"simple": metadata_config}
|
||||
type, facet_config = list(facet_config.items())[0]
|
||||
if isinstance(facet_config, str):
|
||||
facet_config = {"simple": facet_config}
|
||||
facet_configs.setdefault(type, []).append(
|
||||
{"source": "metadata", "config": metadata_config}
|
||||
{"source": "metadata", "config": facet_config}
|
||||
)
|
||||
qs_pairs = urllib.parse.parse_qs(request.query_string, keep_blank_values=True)
|
||||
for key, values in qs_pairs.items():
|
||||
|
|
@ -45,13 +45,12 @@ def load_facet_configs(request, table_metadata):
|
|||
elif key.startswith("_facet_"):
|
||||
type = key[len("_facet_") :]
|
||||
for value in values:
|
||||
# The value is the config - either JSON or not
|
||||
if value.startswith("{"):
|
||||
config = json.loads(value)
|
||||
else:
|
||||
config = {"simple": value}
|
||||
# The value is the facet_config - either JSON or not
|
||||
facet_config = (
|
||||
json.loads(value) if value.startswith("{") else {"simple": value}
|
||||
)
|
||||
facet_configs.setdefault(type, []).append(
|
||||
{"source": "request", "config": config}
|
||||
{"source": "request", "config": facet_config}
|
||||
)
|
||||
return facet_configs
|
||||
|
||||
|
|
@ -66,6 +65,8 @@ def register_facet_classes():
|
|||
|
||||
class Facet:
|
||||
type = None
|
||||
# How many rows to consider when suggesting facets:
|
||||
suggest_consider = 1000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -75,23 +76,23 @@ class Facet:
|
|||
sql=None,
|
||||
table=None,
|
||||
params=None,
|
||||
metadata=None,
|
||||
table_config=None,
|
||||
row_count=None,
|
||||
):
|
||||
assert table or sql, "Must provide either table= or sql="
|
||||
self.ds = ds
|
||||
self.request = request
|
||||
self.database = database
|
||||
# For foreign key expansion. Can be None for e.g. canned SQL queries:
|
||||
# For foreign key expansion. Can be None for e.g. stored SQL queries:
|
||||
self.table = table
|
||||
self.sql = sql or f"select * from [{table}]"
|
||||
self.params = params or []
|
||||
self.metadata = metadata
|
||||
self.table_config = table_config
|
||||
# row_count can be None, in which case we calculate it ourselves:
|
||||
self.row_count = row_count
|
||||
|
||||
def get_configs(self):
|
||||
configs = load_facet_configs(self.request, self.metadata)
|
||||
configs = load_facet_configs(self.request, self.table_config)
|
||||
return configs.get(self.type) or []
|
||||
|
||||
def get_querystring_pairs(self):
|
||||
|
|
@ -104,10 +105,15 @@ class Facet:
|
|||
max_returned_rows = self.ds.setting("max_returned_rows")
|
||||
table_facet_size = None
|
||||
if self.table:
|
||||
tables_metadata = self.ds.metadata("tables", database=self.database) or {}
|
||||
table_metadata = tables_metadata.get(self.table) or {}
|
||||
if table_metadata:
|
||||
table_facet_size = table_metadata.get("facet_size")
|
||||
config_facet_size = (
|
||||
self.ds.config.get("databases", {})
|
||||
.get(self.database, {})
|
||||
.get("tables", {})
|
||||
.get(self.table, {})
|
||||
.get("facet_size")
|
||||
)
|
||||
if config_facet_size:
|
||||
table_facet_size = config_facet_size
|
||||
custom_facet_size = self.request.args.get("_facet_size")
|
||||
if custom_facet_size:
|
||||
if custom_facet_size == "max":
|
||||
|
|
@ -141,17 +147,6 @@ class Facet:
|
|||
)
|
||||
).columns
|
||||
|
||||
async def get_row_count(self):
|
||||
if self.row_count is None:
|
||||
self.row_count = (
|
||||
await self.ds.execute(
|
||||
self.database,
|
||||
f"select count(*) from ({self.sql})",
|
||||
self.params,
|
||||
)
|
||||
).rows[0][0]
|
||||
return self.row_count
|
||||
|
||||
|
||||
class ColumnFacet(Facet):
|
||||
type = "column"
|
||||
|
|
@ -166,13 +161,16 @@ class ColumnFacet(Facet):
|
|||
if column in already_enabled:
|
||||
continue
|
||||
suggested_facet_sql = """
|
||||
select {column} as value, count(*) as n from (
|
||||
{sql}
|
||||
) where value is not null
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select {column} as value, count(*) as n from limited
|
||||
where value is not null
|
||||
group by value
|
||||
limit {limit}
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
suggest_consider=self.suggest_consider,
|
||||
)
|
||||
distinct_values = None
|
||||
try:
|
||||
|
|
@ -207,6 +205,17 @@ class ColumnFacet(Facet):
|
|||
continue
|
||||
return suggested_facets
|
||||
|
||||
async def get_row_count(self):
|
||||
if self.row_count is None:
|
||||
self.row_count = (
|
||||
await self.ds.execute(
|
||||
self.database,
|
||||
f"select count(*) from (select * from ({self.sql}) limit {self.suggest_consider})",
|
||||
self.params,
|
||||
)
|
||||
).rows[0][0]
|
||||
return self.row_count
|
||||
|
||||
async def facet_results(self):
|
||||
facet_results = []
|
||||
facets_timed_out = []
|
||||
|
|
@ -224,9 +233,7 @@ class ColumnFacet(Facet):
|
|||
)
|
||||
where {col} is not null
|
||||
group by {col} order by count desc, value limit {limit}
|
||||
""".format(
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
)
|
||||
""".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
@ -253,7 +260,7 @@ class ColumnFacet(Facet):
|
|||
# Attempt to expand foreign keys into labels
|
||||
values = [row["value"] for row in facet_rows]
|
||||
expanded = await self.ds.expand_foreign_keys(
|
||||
self.database, self.table, column, values
|
||||
self.request.actor, self.database, self.table, column, values
|
||||
)
|
||||
else:
|
||||
expanded = {}
|
||||
|
|
@ -309,11 +316,14 @@ class ArrayFacet(Facet):
|
|||
continue
|
||||
# Is every value in this column either null or a JSON array?
|
||||
suggested_facet_sql = """
|
||||
with limited as (select * from ({sql}) limit {suggest_consider})
|
||||
select distinct json_type({column})
|
||||
from ({sql})
|
||||
from limited
|
||||
where {column} is not null and {column} != ''
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql
|
||||
column=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
suggest_consider=self.suggest_consider,
|
||||
)
|
||||
try:
|
||||
results = await self.ds.execute(
|
||||
|
|
@ -398,7 +408,9 @@ class ArrayFacet(Facet):
|
|||
order by
|
||||
count(*) desc, value limit {limit}
|
||||
""".format(
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
col=escape_sqlite(column),
|
||||
sql=self.sql,
|
||||
limit=facet_size + 1,
|
||||
)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
|
|
@ -466,11 +478,9 @@ class DateFacet(Facet):
|
|||
# Does this column contain any dates in the first 100 rows?
|
||||
suggested_facet_sql = """
|
||||
select date({column}) from (
|
||||
{sql}
|
||||
) where {column} glob "????-??-*" limit 100;
|
||||
""".format(
|
||||
column=escape_sqlite(column), sql=self.sql
|
||||
)
|
||||
select * from ({sql}) limit 100
|
||||
) where {column} glob "????-??-*"
|
||||
""".format(column=escape_sqlite(column), sql=self.sql)
|
||||
try:
|
||||
results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
@ -516,9 +526,7 @@ class DateFacet(Facet):
|
|||
)
|
||||
where date({col}) is not null
|
||||
group by date({col}) order by count desc, value limit {limit}
|
||||
""".format(
|
||||
col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1
|
||||
)
|
||||
""".format(col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1)
|
||||
try:
|
||||
facet_rows_results = await self.ds.execute(
|
||||
self.database,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import DatabaseResource
|
||||
from datasette.views.base import DatasetteError
|
||||
from datasette.utils.asgi import BadRequest
|
||||
import json
|
||||
import numbers
|
||||
from .utils import detect_json1, escape_sqlite, path_with_removed_args
|
||||
|
||||
|
||||
|
|
@ -13,11 +13,10 @@ def where_filters(request, database, datasette):
|
|||
where_clauses = []
|
||||
extra_wheres_for_ui = []
|
||||
if "_where" in request.args:
|
||||
if not await datasette.permission_allowed(
|
||||
request.actor,
|
||||
"execute-sql",
|
||||
resource=database,
|
||||
default=True,
|
||||
if not await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise DatasetteError("_where= is not allowed", status=403)
|
||||
else:
|
||||
|
|
@ -50,7 +49,7 @@ def search_filters(request, database, table, datasette):
|
|||
extra_context = {}
|
||||
|
||||
# Figure out which fts_table to use
|
||||
table_metadata = datasette.table_metadata(database, table)
|
||||
table_metadata = await datasette.table_config(database, table)
|
||||
db = datasette.get_database(database)
|
||||
fts_table = request.args.get("_fts_table")
|
||||
fts_table = fts_table or table_metadata.get("fts_table")
|
||||
|
|
@ -80,9 +79,9 @@ def search_filters(request, database, table, datasette):
|
|||
"{fts_pk} in (select rowid from {fts_table} where {fts_table} match {match_clause})".format(
|
||||
fts_table=escape_sqlite(fts_table),
|
||||
fts_pk=escape_sqlite(fts_pk),
|
||||
match_clause=":search"
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search)",
|
||||
match_clause=(
|
||||
":search" if search_mode_raw else "escape_fts(:search)"
|
||||
),
|
||||
)
|
||||
)
|
||||
human_descriptions.append(f'search matches "{search}"')
|
||||
|
|
@ -99,9 +98,11 @@ def search_filters(request, database, table, datasette):
|
|||
"rowid in (select rowid from {fts_table} where {search_col} match {match_clause})".format(
|
||||
fts_table=escape_sqlite(fts_table),
|
||||
search_col=escape_sqlite(search_col),
|
||||
match_clause=":search_{}".format(i)
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search_{})".format(i),
|
||||
match_clause=(
|
||||
":search_{}".format(i)
|
||||
if search_mode_raw
|
||||
else "escape_fts(:search_{})".format(i)
|
||||
),
|
||||
)
|
||||
)
|
||||
human_descriptions.append(
|
||||
|
|
@ -279,6 +280,13 @@ class Filters:
|
|||
'{c} contains "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"notcontains",
|
||||
"does not contain",
|
||||
'"{c}" not like :{p}',
|
||||
'{c} does not contain "{v}"',
|
||||
format="%{}%",
|
||||
),
|
||||
TemplatedFilter(
|
||||
"endswith",
|
||||
"ends with",
|
||||
|
|
@ -359,12 +367,8 @@ class Filters:
|
|||
)
|
||||
_filters_by_key = {f.key: f for f in _filters}
|
||||
|
||||
def __init__(self, pairs, units=None, ureg=None):
|
||||
if units is None:
|
||||
units = {}
|
||||
def __init__(self, pairs):
|
||||
self.pairs = pairs
|
||||
self.units = units
|
||||
self.ureg = ureg
|
||||
|
||||
def lookups(self):
|
||||
"""Yields (lookup, display, no_argument) pairs"""
|
||||
|
|
@ -404,20 +408,6 @@ class Filters:
|
|||
def has_selections(self):
|
||||
return bool(self.pairs)
|
||||
|
||||
def convert_unit(self, column, value):
|
||||
"""If the user has provided a unit in the query, convert it into the column unit, if present."""
|
||||
if column not in self.units:
|
||||
return value
|
||||
|
||||
# Try to interpret the value as a unit
|
||||
value = self.ureg(value)
|
||||
if isinstance(value, numbers.Number):
|
||||
# It's just a bare number, assume it's the column unit
|
||||
return value
|
||||
|
||||
column_unit = self.ureg(self.units[column])
|
||||
return value.to(column_unit).magnitude
|
||||
|
||||
def build_where_clauses(self, table):
|
||||
sql_bits = []
|
||||
params = {}
|
||||
|
|
@ -425,9 +415,7 @@ class Filters:
|
|||
for column, lookup, value in self.selections():
|
||||
filter = self._filters_by_key.get(lookup, None)
|
||||
if filter:
|
||||
sql_bit, param = filter.where_clause(
|
||||
table, column, self.convert_unit(column, value), i
|
||||
)
|
||||
sql_bit, param = filter.where_clause(table, column, value, i)
|
||||
sql_bits.append(sql_bit)
|
||||
if param is not None:
|
||||
if not isinstance(param, list):
|
||||
|
|
|
|||
415
datasette/fixtures.py
Normal file
415
datasette/fixtures.py
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
from datasette.utils.sqlite import sqlite3
|
||||
from datasette.utils import documented
|
||||
import itertools
|
||||
import random
|
||||
import string
|
||||
|
||||
__all__ = [
|
||||
"EXTRA_DATABASE_SQL",
|
||||
"TABLES",
|
||||
"TABLE_PARAMETERIZED_SQL",
|
||||
"generate_compound_rows",
|
||||
"generate_sortable_rows",
|
||||
"populate_extra_database",
|
||||
"populate_fixture_database",
|
||||
"write_extra_database",
|
||||
"write_fixture_database",
|
||||
]
|
||||
|
||||
|
||||
def generate_compound_rows(num):
|
||||
"""Generate rows for the compound_three_primary_keys fixture table."""
|
||||
for a, b, c in itertools.islice(
|
||||
itertools.product(string.ascii_lowercase, repeat=3), num
|
||||
):
|
||||
yield a, b, c, f"{a}-{b}-{c}"
|
||||
|
||||
|
||||
def generate_sortable_rows(num):
|
||||
"""Generate rows for the sortable fixture table."""
|
||||
rand = random.Random(42)
|
||||
for a, b in itertools.islice(
|
||||
itertools.product(string.ascii_lowercase, repeat=2), num
|
||||
):
|
||||
yield {
|
||||
"pk1": a,
|
||||
"pk2": b,
|
||||
"content": f"{a}-{b}",
|
||||
"sortable": rand.randint(-100, 100),
|
||||
"sortable_with_nulls": rand.choice([None, rand.random(), rand.random()]),
|
||||
"sortable_with_nulls_2": rand.choice([None, rand.random(), rand.random()]),
|
||||
"text": rand.choice(["$null", "$blah"]),
|
||||
}
|
||||
|
||||
|
||||
TABLES = (
|
||||
"""
|
||||
CREATE TABLE simple_primary_key (
|
||||
id integer primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE primary_key_multiple_columns (
|
||||
id varchar(30) primary key,
|
||||
content text,
|
||||
content2 text
|
||||
);
|
||||
|
||||
CREATE TABLE primary_key_multiple_columns_explicit_label (
|
||||
id varchar(30) primary key,
|
||||
content text,
|
||||
content2 text
|
||||
);
|
||||
|
||||
CREATE TABLE compound_primary_key (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
content text,
|
||||
PRIMARY KEY (pk1, pk2)
|
||||
);
|
||||
|
||||
INSERT INTO compound_primary_key VALUES ('a', 'b', 'c');
|
||||
INSERT INTO compound_primary_key VALUES ('a/b', '.c-d', 'c');
|
||||
INSERT INTO compound_primary_key VALUES ('d', 'e', 'RENDER_CELL_DEMO');
|
||||
|
||||
CREATE TABLE compound_three_primary_keys (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
pk3 varchar(30),
|
||||
content text,
|
||||
PRIMARY KEY (pk1, pk2, pk3)
|
||||
);
|
||||
CREATE INDEX idx_compound_three_primary_keys_content ON compound_three_primary_keys(content);
|
||||
|
||||
CREATE TABLE foreign_key_references (
|
||||
pk varchar(30) primary key,
|
||||
foreign_key_with_label integer,
|
||||
foreign_key_with_blank_label integer,
|
||||
foreign_key_with_no_label varchar(30),
|
||||
foreign_key_compound_pk1 varchar(30),
|
||||
foreign_key_compound_pk2 varchar(30),
|
||||
FOREIGN KEY (foreign_key_with_label) REFERENCES simple_primary_key(id),
|
||||
FOREIGN KEY (foreign_key_with_blank_label) REFERENCES simple_primary_key(id),
|
||||
FOREIGN KEY (foreign_key_with_no_label) REFERENCES primary_key_multiple_columns(id)
|
||||
FOREIGN KEY (foreign_key_compound_pk1, foreign_key_compound_pk2) REFERENCES compound_primary_key(pk1, pk2)
|
||||
);
|
||||
|
||||
CREATE TABLE sortable (
|
||||
pk1 varchar(30),
|
||||
pk2 varchar(30),
|
||||
content text,
|
||||
sortable integer,
|
||||
sortable_with_nulls real,
|
||||
sortable_with_nulls_2 real,
|
||||
text text,
|
||||
PRIMARY KEY (pk1, pk2)
|
||||
);
|
||||
|
||||
CREATE TABLE no_primary_key (
|
||||
content text,
|
||||
a text,
|
||||
b text,
|
||||
c text
|
||||
);
|
||||
|
||||
CREATE TABLE [123_starts_with_digits] (
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE VIEW paginated_view AS
|
||||
SELECT
|
||||
content,
|
||||
'- ' || content || ' -' AS content_extra
|
||||
FROM no_primary_key;
|
||||
|
||||
CREATE TABLE "Table With Space In Name" (
|
||||
pk varchar(30) primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE "table/with/slashes.csv" (
|
||||
pk varchar(30) primary key,
|
||||
content text
|
||||
);
|
||||
|
||||
CREATE TABLE "complex_foreign_keys" (
|
||||
pk varchar(30) primary key,
|
||||
f1 integer,
|
||||
f2 integer,
|
||||
f3 integer,
|
||||
FOREIGN KEY ("f1") REFERENCES [simple_primary_key](id),
|
||||
FOREIGN KEY ("f2") REFERENCES [simple_primary_key](id),
|
||||
FOREIGN KEY ("f3") REFERENCES [simple_primary_key](id)
|
||||
);
|
||||
|
||||
CREATE TABLE "custom_foreign_key_label" (
|
||||
pk varchar(30) primary key,
|
||||
foreign_key_with_custom_label text,
|
||||
FOREIGN KEY ("foreign_key_with_custom_label") REFERENCES [primary_key_multiple_columns_explicit_label](id)
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
tag TEXT PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE searchable (
|
||||
pk integer primary key,
|
||||
text1 text,
|
||||
text2 text,
|
||||
[name with . and spaces] text
|
||||
);
|
||||
|
||||
CREATE TABLE searchable_tags (
|
||||
searchable_id integer,
|
||||
tag text,
|
||||
PRIMARY KEY (searchable_id, tag),
|
||||
FOREIGN KEY (searchable_id) REFERENCES searchable(pk),
|
||||
FOREIGN KEY (tag) REFERENCES tags(tag)
|
||||
);
|
||||
|
||||
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog', 'panther');
|
||||
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel', 'puma');
|
||||
|
||||
INSERT INTO tags VALUES ("canine");
|
||||
INSERT INTO tags VALUES ("feline");
|
||||
|
||||
INSERT INTO searchable_tags (searchable_id, tag) VALUES
|
||||
(1, "feline"),
|
||||
(2, "canine")
|
||||
;
|
||||
|
||||
CREATE VIRTUAL TABLE "searchable_fts"
|
||||
USING FTS5 (text1, text2, [name with . and spaces], content="searchable", content_rowid="pk");
|
||||
INSERT INTO "searchable_fts" (searchable_fts) VALUES ('rebuild');
|
||||
|
||||
CREATE TABLE [select] (
|
||||
[group] text,
|
||||
[having] text,
|
||||
[and] text,
|
||||
[json] text
|
||||
);
|
||||
INSERT INTO [select] VALUES ('group', 'having', 'and',
|
||||
'{"href": "http://example.com/", "label":"Example"}'
|
||||
);
|
||||
|
||||
CREATE TABLE infinity (
|
||||
value REAL
|
||||
);
|
||||
INSERT INTO infinity VALUES
|
||||
(1e999),
|
||||
(-1e999),
|
||||
(1.5)
|
||||
;
|
||||
|
||||
CREATE TABLE facet_cities (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
INSERT INTO facet_cities (id, name) VALUES
|
||||
(1, 'San Francisco'),
|
||||
(2, 'Los Angeles'),
|
||||
(3, 'Detroit'),
|
||||
(4, 'Memnonia')
|
||||
;
|
||||
|
||||
CREATE TABLE facetable (
|
||||
pk integer primary key,
|
||||
created text,
|
||||
planet_int integer,
|
||||
on_earth integer,
|
||||
state text,
|
||||
_city_id integer,
|
||||
_neighborhood text,
|
||||
tags text,
|
||||
complex_array text,
|
||||
distinct_some_null,
|
||||
n text,
|
||||
FOREIGN KEY ("_city_id") REFERENCES [facet_cities](id)
|
||||
);
|
||||
INSERT INTO facetable
|
||||
(created, planet_int, on_earth, state, _city_id, _neighborhood, tags, complex_array, distinct_some_null, n)
|
||||
VALUES
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Mission', '["tag1", "tag2"]', '[{"foo": "bar"}]', 'one', 'n1'),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Dogpatch', '["tag1", "tag3"]', '[]', 'two', 'n2'),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'SOMA', '[]', '[]', null, null),
|
||||
("2019-01-14 08:00:00", 1, 1, 'CA', 1, 'Tenderloin', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Bernal Heights', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 1, 'Hayes Valley', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Hollywood', '[]', '[]', null, null),
|
||||
("2019-01-15 08:00:00", 1, 1, 'CA', 2, 'Downtown', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Los Feliz', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'CA', 2, 'Koreatown', '[]', '[]', null, null),
|
||||
("2019-01-16 08:00:00", 1, 1, 'MI', 3, 'Downtown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Greektown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Corktown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 1, 1, 'MI', 3, 'Mexicantown', '[]', '[]', null, null),
|
||||
("2019-01-17 08:00:00", 2, 0, 'MC', 4, 'Arcadia Planitia', '[]', '[]', null, null)
|
||||
;
|
||||
|
||||
CREATE TABLE binary_data (
|
||||
data BLOB
|
||||
);
|
||||
|
||||
-- Many 2 Many demo: roadside attractions!
|
||||
|
||||
CREATE TABLE roadside_attractions (
|
||||
pk integer primary key,
|
||||
name text,
|
||||
address text,
|
||||
url text,
|
||||
latitude real,
|
||||
longitude real
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
1, "The Mystery Spot", "465 Mystery Spot Road, Santa Cruz, CA 95065", "https://www.mysteryspot.com/",
|
||||
37.0167, -122.0024
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
2, "Winchester Mystery House", "525 South Winchester Boulevard, San Jose, CA 95128", "https://winchestermysteryhouse.com/",
|
||||
37.3184, -121.9511
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
3, "Burlingame Museum of PEZ Memorabilia", "214 California Drive, Burlingame, CA 94010", null,
|
||||
37.5793, -122.3442
|
||||
);
|
||||
INSERT INTO roadside_attractions VALUES (
|
||||
4, "Bigfoot Discovery Museum", "5497 Highway 9, Felton, CA 95018", "https://www.bigfootdiscoveryproject.com/",
|
||||
37.0414, -122.0725
|
||||
);
|
||||
|
||||
CREATE TABLE attraction_characteristic (
|
||||
pk integer primary key,
|
||||
name text
|
||||
);
|
||||
INSERT INTO attraction_characteristic VALUES (
|
||||
1, "Museum"
|
||||
);
|
||||
INSERT INTO attraction_characteristic VALUES (
|
||||
2, "Paranormal"
|
||||
);
|
||||
|
||||
CREATE TABLE roadside_attraction_characteristics (
|
||||
attraction_id INTEGER REFERENCES roadside_attractions(pk),
|
||||
characteristic_id INTEGER REFERENCES attraction_characteristic(pk)
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
1, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
2, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
4, 2
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
3, 1
|
||||
);
|
||||
INSERT INTO roadside_attraction_characteristics VALUES (
|
||||
4, 1
|
||||
);
|
||||
|
||||
INSERT INTO simple_primary_key VALUES (1, 'hello');
|
||||
INSERT INTO simple_primary_key VALUES (2, 'world');
|
||||
INSERT INTO simple_primary_key VALUES (3, '');
|
||||
INSERT INTO simple_primary_key VALUES (4, 'RENDER_CELL_DEMO');
|
||||
INSERT INTO simple_primary_key VALUES (5, 'RENDER_CELL_ASYNC');
|
||||
|
||||
INSERT INTO primary_key_multiple_columns VALUES (1, 'hey', 'world');
|
||||
INSERT INTO primary_key_multiple_columns_explicit_label VALUES (1, 'hey', 'world2');
|
||||
|
||||
INSERT INTO foreign_key_references VALUES (1, 1, 3, 1, 'a', 'b');
|
||||
INSERT INTO foreign_key_references VALUES (2, null, null, null, null, null);
|
||||
|
||||
INSERT INTO complex_foreign_keys VALUES (1, 1, 2, 1);
|
||||
INSERT INTO custom_foreign_key_label VALUES (1, 1);
|
||||
|
||||
INSERT INTO [table/with/slashes.csv] VALUES (3, 'hey');
|
||||
|
||||
CREATE VIEW simple_view AS
|
||||
SELECT content, upper(content) AS upper_content FROM simple_primary_key;
|
||||
|
||||
CREATE VIEW searchable_view AS
|
||||
SELECT * from searchable;
|
||||
|
||||
CREATE VIEW searchable_view_configured_by_metadata AS
|
||||
SELECT * from searchable;
|
||||
|
||||
"""
|
||||
+ "\n".join(
|
||||
[
|
||||
'INSERT INTO no_primary_key VALUES ({i}, "a{i}", "b{i}", "c{i}");'.format(
|
||||
i=i + 1
|
||||
)
|
||||
for i in range(201)
|
||||
]
|
||||
)
|
||||
+ '\nINSERT INTO no_primary_key VALUES ("RENDER_CELL_DEMO", "a202", "b202", "c202");\n'
|
||||
+ "\n".join(
|
||||
[
|
||||
'INSERT INTO compound_three_primary_keys VALUES ("{a}", "{b}", "{c}", "{content}");'.format(
|
||||
a=a, b=b, c=c, content=content
|
||||
)
|
||||
for a, b, c, content in generate_compound_rows(1001)
|
||||
]
|
||||
)
|
||||
+ "\n".join(["""INSERT INTO sortable VALUES (
|
||||
"{pk1}", "{pk2}", "{content}", {sortable},
|
||||
{sortable_with_nulls}, {sortable_with_nulls_2}, "{text}");
|
||||
""".format(**row).replace("None", "null") for row in generate_sortable_rows(201)])
|
||||
)
|
||||
|
||||
TABLE_PARAMETERIZED_SQL = [
|
||||
("insert into binary_data (data) values (?);", [b"\x15\x1c\x02\xc7\xad\x05\xfe"]),
|
||||
("insert into binary_data (data) values (?);", [b"\x15\x1c\x03\xc7\xad\x05\xfe"]),
|
||||
("insert into binary_data (data) values (null);", []),
|
||||
]
|
||||
|
||||
EXTRA_DATABASE_SQL = """
|
||||
CREATE TABLE searchable (
|
||||
pk integer primary key,
|
||||
text1 text,
|
||||
text2 text
|
||||
);
|
||||
|
||||
CREATE VIEW searchable_view AS SELECT * FROM searchable;
|
||||
|
||||
INSERT INTO searchable VALUES (1, 'barry cat', 'terry dog');
|
||||
INSERT INTO searchable VALUES (2, 'terry dog', 'sara weasel');
|
||||
|
||||
CREATE VIRTUAL TABLE "searchable_fts"
|
||||
USING FTS3 (text1, text2, content="searchable");
|
||||
INSERT INTO "searchable_fts" (rowid, text1, text2)
|
||||
SELECT rowid, text1, text2 FROM searchable;
|
||||
"""
|
||||
|
||||
|
||||
@documented(label="datasette_fixtures_populate_fixture_database")
|
||||
def populate_fixture_database(conn):
|
||||
"""Populate a SQLite connection with Datasette's test fixture tables."""
|
||||
conn.executescript(TABLES)
|
||||
for sql, params in TABLE_PARAMETERIZED_SQL:
|
||||
with conn:
|
||||
conn.execute(sql, params)
|
||||
|
||||
|
||||
def populate_extra_database(conn):
|
||||
"""Populate a SQLite connection with the extra database used in tests."""
|
||||
conn.executescript(EXTRA_DATABASE_SQL)
|
||||
|
||||
|
||||
def write_fixture_database(db_filename):
|
||||
"""Write Datasette's test fixture tables to a SQLite database file."""
|
||||
conn = sqlite3.connect(db_filename)
|
||||
try:
|
||||
populate_fixture_database(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write_extra_database(db_filename):
|
||||
"""Write the extra test database tables to a SQLite database file."""
|
||||
conn = sqlite3.connect(db_filename)
|
||||
try:
|
||||
populate_extra_database(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
from os import stat
|
||||
from datasette import hookimpl, Response
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
from datasette import hookimpl, Response
|
||||
from .utils import await_me_maybe, add_cors_headers
|
||||
from .utils import add_cors_headers
|
||||
from .utils.asgi import (
|
||||
Base400,
|
||||
Forbidden,
|
||||
)
|
||||
from .views.base import DatasetteError
|
||||
from markupsafe import Markup
|
||||
import pdb
|
||||
import traceback
|
||||
from .plugins import pm
|
||||
|
||||
try:
|
||||
import ipdb as pdb
|
||||
except ImportError:
|
||||
import pdb
|
||||
|
||||
try:
|
||||
import rich
|
||||
|
|
@ -57,7 +59,8 @@ def handle_exception(datasette, request, exception):
|
|||
if request.path.split("?")[0].endswith(".json"):
|
||||
return Response.json(info, status=status, headers=headers)
|
||||
else:
|
||||
template = datasette.jinja_env.select_template(templates)
|
||||
environment = datasette.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
return Response.html(
|
||||
await template.render_async(
|
||||
dict(
|
||||
|
|
|
|||
|
|
@ -10,11 +10,6 @@ def startup(datasette):
|
|||
"""Fires directly after Datasette first starts running"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def get_metadata(datasette, key, database, table):
|
||||
"""Return metadata to be merged into Datasette's metadata dictionary"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def asgi_wrapper(datasette):
|
||||
"""Returns an ASGI middleware callable to wrap our ASGI application with"""
|
||||
|
|
@ -60,7 +55,17 @@ def publish_subcommand(publish):
|
|||
|
||||
|
||||
@hookspec
|
||||
def render_cell(row, value, column, table, database, datasette):
|
||||
def render_cell(
|
||||
row,
|
||||
value,
|
||||
column,
|
||||
table,
|
||||
pks,
|
||||
database,
|
||||
datasette,
|
||||
request,
|
||||
column_type,
|
||||
):
|
||||
"""Customize rendering of HTML table cell values"""
|
||||
|
||||
|
||||
|
|
@ -74,6 +79,16 @@ def register_facet_classes():
|
|||
"""Register Facet subclasses"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_actions(datasette):
|
||||
"""Register actions: returns a list of datasette.permission.Action objects"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_column_types(datasette):
|
||||
"""Return a list of ColumnType subclasses"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_routes(datasette):
|
||||
"""Register URL routes: return a list of (regex, view_function) pairs"""
|
||||
|
|
@ -89,6 +104,16 @@ def actor_from_request(datasette, request):
|
|||
"""Return an actor dictionary based on the incoming request"""
|
||||
|
||||
|
||||
@hookspec(firstresult=True)
|
||||
def actors_from_ids(datasette, actor_ids):
|
||||
"""Returns a dictionary mapping those IDs to actor dictionaries"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jinja2_environment_from_request(datasette, request, env):
|
||||
"""Return a Jinja2 environment based on the incoming request"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def filters_from_request(request, database, table, datasette):
|
||||
"""
|
||||
|
|
@ -101,13 +126,15 @@ def filters_from_request(request, database, table, datasette):
|
|||
|
||||
|
||||
@hookspec
|
||||
def permission_allowed(datasette, actor, action, resource):
|
||||
"""Check if actor is allowed to perform this action - return True, False or None"""
|
||||
def permission_resources_sql(datasette, actor, action):
|
||||
"""Return SQL query fragments for permission checks on resources.
|
||||
|
||||
Returns None, a PermissionSQL object, or a list of PermissionSQL objects.
|
||||
Each PermissionSQL contains SQL that should return rows with columns:
|
||||
parent (str|None), child (str|None), allow (int), reason (str).
|
||||
|
||||
@hookspec
|
||||
def canned_queries(datasette, database, actor):
|
||||
"""Return a dictionary of canned query definitions or an awaitable function that returns them"""
|
||||
Used to efficiently check permissions across multiple resources at once.
|
||||
"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
@ -125,21 +152,114 @@ def menu_links(datasette, actor, request):
|
|||
"""Links for the navigation menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def jump_items_sql(datasette, actor, request):
|
||||
"""SQL fragments for extra items in the jump menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Links for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Links for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Links for the query and stored query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Links for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def skip_csrf(datasette, scope):
|
||||
"""Mechanism for skipping CSRF checks for certain requests"""
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Links for the homepage actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def handle_exception(datasette, request, exception):
|
||||
"""Handle an uncaught exception. Can return a Response or None."""
|
||||
|
||||
|
||||
@hookspec
|
||||
def track_event(datasette, event):
|
||||
"""Respond to an event tracked by Datasette"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_events(datasette):
|
||||
"""Return a list of Event subclasses to use with track_event()"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_homepage(datasette, request):
|
||||
"""HTML to include at the top of the homepage"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_database(datasette, request, database):
|
||||
"""HTML to include at the top of the database page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_table(datasette, request, database, table):
|
||||
"""HTML to include at the top of the table page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_row(datasette, request, database, table, row):
|
||||
"""HTML to include at the top of the row page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_query(datasette, request, database, sql):
|
||||
"""HTML to include at the top of the query results page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def top_stored_query(datasette, request, database, query_name):
|
||||
"""HTML to include at the top of the stored query page"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def register_token_handler(datasette):
|
||||
"""Return a TokenHandler instance for token creation and verification"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def write_wrapper(datasette, database, request, transaction):
|
||||
"""Called when a write function is about to execute.
|
||||
|
||||
Return a generator function that accepts a ``conn`` argument and
|
||||
optionally a ``track_event`` argument. The generator should
|
||||
``yield`` exactly once: code before the ``yield`` runs before
|
||||
the write, code after the ``yield`` runs after the write
|
||||
completes. The result of the write is sent back through the
|
||||
``yield``, so you can capture it with ``result = yield``.
|
||||
|
||||
If your generator accepts ``track_event``, you can call
|
||||
``track_event(event)`` to queue an event that will be dispatched
|
||||
via ``datasette.track_event()`` after the write commits
|
||||
successfully. Events are discarded if the write raises an
|
||||
exception.
|
||||
|
||||
If the write raises an exception, it is thrown into the generator
|
||||
so you can handle it with a try/except around the ``yield``.
|
||||
|
||||
``request`` may be ``None`` for writes not originating from an
|
||||
HTTP request. ``transaction`` is ``True`` if the write will
|
||||
be wrapped in a transaction.
|
||||
|
||||
Return ``None`` to skip wrapping.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ from .utils import (
|
|||
sqlite3,
|
||||
)
|
||||
|
||||
|
||||
HASH_BLOCK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
|
|
@ -70,16 +69,11 @@ def inspect_tables(conn, database_metadata):
|
|||
tables[table]["foreign_keys"] = info
|
||||
|
||||
# Mark tables 'hidden' if they relate to FTS virtual tables
|
||||
hidden_tables = [
|
||||
r["name"]
|
||||
for r in conn.execute(
|
||||
"""
|
||||
hidden_tables = [r["name"] for r in conn.execute("""
|
||||
select name from sqlite_master
|
||||
where rootpage = 0
|
||||
and sql like '%VIRTUAL TABLE%USING FTS%'
|
||||
"""
|
||||
)
|
||||
]
|
||||
""")]
|
||||
|
||||
if detect_spatialite(conn):
|
||||
# Also hide Spatialite internal tables
|
||||
|
|
@ -94,14 +88,11 @@ def inspect_tables(conn, database_metadata):
|
|||
"views_geometry_columns",
|
||||
"virts_geometry_columns",
|
||||
] + [
|
||||
r["name"]
|
||||
for r in conn.execute(
|
||||
"""
|
||||
r["name"] for r in conn.execute("""
|
||||
select name from sqlite_master
|
||||
where name like "idx_%"
|
||||
and type = "table"
|
||||
"""
|
||||
)
|
||||
""")
|
||||
]
|
||||
|
||||
for t in tables.keys():
|
||||
|
|
|
|||
68
datasette/jump.py
Normal file
68
datasette/jump.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class JumpSQL:
|
||||
sql: str
|
||||
params: dict[str, Any] | None = None
|
||||
database: str | None = None
|
||||
|
||||
@classmethod
|
||||
def menu_item(
|
||||
cls,
|
||||
*,
|
||||
label: str,
|
||||
url: str,
|
||||
description: str = "Menu item",
|
||||
search_text: str | None = None,
|
||||
display_name: str | None = None,
|
||||
item_type: str = "menu",
|
||||
) -> "JumpSQL":
|
||||
if search_text is None:
|
||||
search_text = " ".join(
|
||||
text for text in (label, display_name, description) if text is not None
|
||||
)
|
||||
return cls(
|
||||
sql="""
|
||||
SELECT
|
||||
:type AS type,
|
||||
:label AS label,
|
||||
:description AS description,
|
||||
:url AS url,
|
||||
:search_text AS search_text,
|
||||
:display_name AS display_name
|
||||
""",
|
||||
params={
|
||||
"type": item_type,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"search_text": search_text,
|
||||
"display_name": display_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_PARAM_RE = re.compile(r"(?<!:):([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def namespace_sql_params(sql: str, params: dict[str, Any], prefix: str):
|
||||
"""Rename named SQL parameters so UNION query parameters cannot collide."""
|
||||
if not params:
|
||||
return sql, {}
|
||||
|
||||
renamed = {key: f"{prefix}_{key}" for key in params}
|
||||
|
||||
def replace(match):
|
||||
key = match.group(1)
|
||||
if key not in renamed:
|
||||
return match.group(0)
|
||||
return f":{renamed[key]}"
|
||||
|
||||
return _PARAM_RE.sub(replace, sql), {
|
||||
renamed[key]: value for key, value in params.items()
|
||||
}
|
||||
209
datasette/permissions.py
Normal file
209
datasette/permissions.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, NamedTuple
|
||||
import contextvars
|
||||
|
||||
# Context variable to track when permission checks should be skipped
|
||||
_skip_permission_checks = contextvars.ContextVar(
|
||||
"skip_permission_checks", default=False
|
||||
)
|
||||
|
||||
|
||||
class SkipPermissions:
|
||||
"""Context manager to temporarily skip permission checks.
|
||||
|
||||
This is not a stable API and may change in future releases.
|
||||
|
||||
Usage:
|
||||
with SkipPermissions():
|
||||
# Permission checks are skipped within this block
|
||||
response = await datasette.client.get("/protected")
|
||||
"""
|
||||
|
||||
def __enter__(self):
|
||||
self.token = _skip_permission_checks.set(True)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
_skip_permission_checks.reset(self.token)
|
||||
return False
|
||||
|
||||
|
||||
class Resource(ABC):
|
||||
"""
|
||||
Base class for all resource types.
|
||||
|
||||
Each subclass represents a type of resource (e.g., TableResource, DatabaseResource).
|
||||
The class itself carries metadata about the resource type.
|
||||
Instances represent specific resources.
|
||||
"""
|
||||
|
||||
# Class-level metadata (subclasses must define these)
|
||||
name: str = None # e.g., "table", "database", "model"
|
||||
parent_class: type["Resource"] | None = None # e.g., DatabaseResource for tables
|
||||
|
||||
# Instance-level optional extra attributes
|
||||
reasons: list[str] | None = None
|
||||
include_reasons: bool | None = None
|
||||
|
||||
def __init__(self, parent: str | None = None, child: str | None = None):
|
||||
"""
|
||||
Create a resource instance.
|
||||
|
||||
Args:
|
||||
parent: The parent identifier (meaning depends on resource type)
|
||||
child: The child identifier (meaning depends on resource type)
|
||||
"""
|
||||
self.parent = parent
|
||||
self.child = child
|
||||
self._private = None # Sentinel to track if private was set
|
||||
|
||||
@property
|
||||
def private(self) -> bool:
|
||||
"""
|
||||
Whether this resource is private (accessible to actor but not anonymous).
|
||||
|
||||
This property is only available on Resource objects returned from
|
||||
allowed_resources() when include_is_private=True is used.
|
||||
|
||||
Raises:
|
||||
AttributeError: If accessed without calling include_is_private=True
|
||||
"""
|
||||
if self._private is None:
|
||||
raise AttributeError(
|
||||
"The 'private' attribute is only available when using "
|
||||
"allowed_resources(..., include_is_private=True)"
|
||||
)
|
||||
return self._private
|
||||
|
||||
@private.setter
|
||||
def private(self, value: bool):
|
||||
self._private = value
|
||||
|
||||
@classmethod
|
||||
def __init_subclass__(cls):
|
||||
"""
|
||||
Validate resource hierarchy doesn't exceed 2 levels.
|
||||
|
||||
Raises:
|
||||
ValueError: If this resource would create a 3-level hierarchy
|
||||
"""
|
||||
super().__init_subclass__()
|
||||
|
||||
if cls.parent_class is None:
|
||||
return # Top of hierarchy, nothing to validate
|
||||
|
||||
# Check if our parent has a parent - that would create 3 levels
|
||||
if cls.parent_class.parent_class is not None:
|
||||
# We have a parent, and that parent has a parent
|
||||
# This creates a 3-level hierarchy, which is not allowed
|
||||
raise ValueError(
|
||||
f"Resource {cls.__name__} creates a 3-level hierarchy: "
|
||||
f"{cls.parent_class.parent_class.__name__} -> {cls.parent_class.__name__} -> {cls.__name__}. "
|
||||
f"Maximum 2 levels allowed (parent -> child)."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
"""
|
||||
Return SQL query that returns all resources of this type.
|
||||
|
||||
Must return two columns: parent, child
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AllowedResource(NamedTuple):
|
||||
"""A resource with the reason it was allowed (for debugging)."""
|
||||
|
||||
resource: Resource
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class Action:
|
||||
name: str
|
||||
description: str | None
|
||||
abbr: str | None = None
|
||||
resource_class: type[Resource] | None = None
|
||||
also_requires: str | None = None # Optional action name that must also be allowed
|
||||
|
||||
@property
|
||||
def takes_parent(self) -> bool:
|
||||
"""
|
||||
Whether this action requires a parent identifier when instantiating its resource.
|
||||
|
||||
Returns False for global-only actions (no resource_class).
|
||||
Returns True for all actions with a resource_class (all resources require a parent identifier).
|
||||
"""
|
||||
return self.resource_class is not None
|
||||
|
||||
@property
|
||||
def takes_child(self) -> bool:
|
||||
"""
|
||||
Whether this action requires a child identifier when instantiating its resource.
|
||||
|
||||
Returns False for global actions (no resource_class).
|
||||
Returns False for parent-level resources (DatabaseResource - parent_class is None).
|
||||
Returns True for child-level resources (TableResource, QueryResource - have a parent_class).
|
||||
"""
|
||||
if self.resource_class is None:
|
||||
return False
|
||||
return self.resource_class.parent_class is not None
|
||||
|
||||
|
||||
_reason_id = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionSQL:
|
||||
"""
|
||||
A plugin contributes SQL that yields:
|
||||
parent TEXT NULL,
|
||||
child TEXT NULL,
|
||||
allow INTEGER, -- 1 allow, 0 deny
|
||||
reason TEXT
|
||||
|
||||
For restriction-only plugins, sql can be None and only restriction_sql is provided.
|
||||
"""
|
||||
|
||||
sql: str | None = (
|
||||
None # SQL that SELECTs the 4 columns above (can be None for restriction-only)
|
||||
)
|
||||
params: dict[str, Any] | None = (
|
||||
None # bound params for the SQL (values only; no ':' prefix)
|
||||
)
|
||||
source: str | None = None # System will set this to the plugin name
|
||||
restriction_sql: str | None = (
|
||||
None # Optional SQL that returns (parent, child) for restriction filtering
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def allow(cls, reason: str, _allow: bool = True) -> "PermissionSQL":
|
||||
global _reason_id
|
||||
i = _reason_id
|
||||
_reason_id += 1
|
||||
return cls(
|
||||
sql=f"SELECT NULL AS parent, NULL AS child, {1 if _allow else 0} AS allow, :reason_{i} AS reason",
|
||||
params={f"reason_{i}": reason},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def deny(cls, reason: str) -> "PermissionSQL":
|
||||
return cls.allow(reason=reason, _allow=False)
|
||||
|
||||
|
||||
# This is obsolete, replaced by Action and ResourceType
|
||||
@dataclass
|
||||
class Permission:
|
||||
name: str
|
||||
abbr: str | None
|
||||
description: str | None
|
||||
takes_database: bool
|
||||
takes_resource: bool
|
||||
default: bool
|
||||
# This is deliberately undocumented: it's considered an internal
|
||||
# implementation detail for view-table/view-database and should
|
||||
# not be used by plugins as it may change in the future.
|
||||
implies_can_view: bool = False
|
||||
|
|
@ -1,9 +1,20 @@
|
|||
import importlib
|
||||
import os
|
||||
import pluggy
|
||||
import pkg_resources
|
||||
from pprint import pprint
|
||||
import sys
|
||||
from . import hookspecs
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
import importlib.resources as importlib_resources
|
||||
else:
|
||||
import importlib_resources
|
||||
if sys.version_info >= (3, 10):
|
||||
import importlib.metadata as importlib_metadata
|
||||
else:
|
||||
import importlib_metadata
|
||||
|
||||
|
||||
DEFAULT_PLUGINS = (
|
||||
"datasette.publish.heroku",
|
||||
"datasette.publish.cloudrun",
|
||||
|
|
@ -12,20 +23,69 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.sql_functions",
|
||||
"datasette.actor_auth_cookie",
|
||||
"datasette.default_permissions",
|
||||
"datasette.default_permissions.tokens",
|
||||
"datasette.default_actions",
|
||||
"datasette.default_column_types",
|
||||
"datasette.default_magic_parameters",
|
||||
"datasette.blob_renderer",
|
||||
"datasette.default_menu_links",
|
||||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.default_database_actions",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
"datasette.events",
|
||||
)
|
||||
|
||||
pm = pluggy.PluginManager("datasette")
|
||||
pm.add_hookspecs(hookspecs)
|
||||
|
||||
if not hasattr(sys, "_called_from_test"):
|
||||
DATASETTE_TRACE_PLUGINS = os.environ.get("DATASETTE_TRACE_PLUGINS", None)
|
||||
|
||||
|
||||
def before(hook_name, hook_impls, kwargs):
|
||||
print(file=sys.stderr)
|
||||
print(f"{hook_name}:", file=sys.stderr)
|
||||
pprint(kwargs, width=40, indent=4, stream=sys.stderr)
|
||||
print("Hook implementations:", file=sys.stderr)
|
||||
pprint(hook_impls, width=40, indent=4, stream=sys.stderr)
|
||||
|
||||
|
||||
def after(outcome, hook_name, hook_impls, kwargs):
|
||||
results = outcome.get_result()
|
||||
if not isinstance(results, list):
|
||||
results = [results]
|
||||
print("Results:", file=sys.stderr)
|
||||
pprint(results, width=40, indent=4, stream=sys.stderr)
|
||||
|
||||
|
||||
if DATASETTE_TRACE_PLUGINS:
|
||||
pm.add_hookcall_monitoring(before, after)
|
||||
|
||||
|
||||
DATASETTE_LOAD_PLUGINS = os.environ.get("DATASETTE_LOAD_PLUGINS", None)
|
||||
|
||||
if not hasattr(sys, "_called_from_test") and DATASETTE_LOAD_PLUGINS is None:
|
||||
# Only load plugins if not running tests
|
||||
pm.load_setuptools_entrypoints("datasette")
|
||||
|
||||
# Load any plugins specified in DATASETTE_LOAD_PLUGINS")
|
||||
if DATASETTE_LOAD_PLUGINS is not None:
|
||||
for package_name in [
|
||||
name for name in DATASETTE_LOAD_PLUGINS.split(",") if name.strip()
|
||||
]:
|
||||
try:
|
||||
distribution = importlib_metadata.distribution(package_name)
|
||||
entry_points = distribution.entry_points
|
||||
for entry_point in entry_points:
|
||||
if entry_point.group == "datasette":
|
||||
mod = entry_point.load()
|
||||
pm.register(mod, name=entry_point.name)
|
||||
# Ensure name can be found in plugin_to_distinfo later:
|
||||
pm._plugin_distinfo.append((mod, distribution))
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
sys.stderr.write("Plugin {} could not be found\n".format(package_name))
|
||||
|
||||
|
||||
# Load default plugins
|
||||
for plugin in DEFAULT_PLUGINS:
|
||||
mod = importlib.import_module(plugin)
|
||||
|
|
@ -38,21 +98,24 @@ def get_plugins():
|
|||
for plugin in pm.get_plugins():
|
||||
static_path = None
|
||||
templates_path = None
|
||||
if plugin.__name__ not in DEFAULT_PLUGINS:
|
||||
plugin_name = (
|
||||
plugin.__name__
|
||||
if hasattr(plugin, "__name__")
|
||||
else plugin.__class__.__name__
|
||||
)
|
||||
if plugin_name not in DEFAULT_PLUGINS:
|
||||
try:
|
||||
if pkg_resources.resource_isdir(plugin.__name__, "static"):
|
||||
static_path = pkg_resources.resource_filename(
|
||||
plugin.__name__, "static"
|
||||
if (importlib_resources.files(plugin_name) / "static").is_dir():
|
||||
static_path = str(importlib_resources.files(plugin_name) / "static")
|
||||
if (importlib_resources.files(plugin_name) / "templates").is_dir():
|
||||
templates_path = str(
|
||||
importlib_resources.files(plugin_name) / "templates"
|
||||
)
|
||||
if pkg_resources.resource_isdir(plugin.__name__, "templates"):
|
||||
templates_path = pkg_resources.resource_filename(
|
||||
plugin.__name__, "templates"
|
||||
)
|
||||
except (KeyError, ImportError):
|
||||
# Caused by --plugins_dir= plugins - KeyError/ImportError thrown in Py3.5
|
||||
except (TypeError, ModuleNotFoundError):
|
||||
# Caused by --plugins_dir= plugins
|
||||
pass
|
||||
plugin_info = {
|
||||
"name": plugin.__name__,
|
||||
"name": plugin_name,
|
||||
"static_path": static_path,
|
||||
"templates_path": templates_path,
|
||||
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
|
||||
|
|
@ -60,6 +123,6 @@ def get_plugins():
|
|||
distinfo = plugin_to_distinfo.get(plugin)
|
||||
if distinfo:
|
||||
plugin_info["version"] = distinfo.version
|
||||
plugin_info["name"] = distinfo.project_name
|
||||
plugin_info["name"] = distinfo.name or distinfo.project_name
|
||||
plugins.append(plugin_info)
|
||||
return plugins
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import click
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from subprocess import check_call, check_output
|
||||
from subprocess import CalledProcessError, check_call, check_output
|
||||
|
||||
from .common import (
|
||||
add_common_publish_arguments_and_options,
|
||||
|
|
@ -23,7 +23,9 @@ def publish_subcommand(publish):
|
|||
help="Application name to use when building",
|
||||
)
|
||||
@click.option(
|
||||
"--service", default="", help="Cloud Run service to deploy (or over-write)"
|
||||
"--service",
|
||||
default="",
|
||||
help="Cloud Run service to deploy (or over-write)",
|
||||
)
|
||||
@click.option("--spatialite", is_flag=True, help="Enable SpatialLite extension")
|
||||
@click.option(
|
||||
|
|
@ -55,13 +57,32 @@ def publish_subcommand(publish):
|
|||
@click.option(
|
||||
"--max-instances",
|
||||
type=int,
|
||||
help="Maximum Cloud Run instances",
|
||||
default=1,
|
||||
show_default=True,
|
||||
help="Maximum Cloud Run instances (use 0 to remove the limit)",
|
||||
)
|
||||
@click.option(
|
||||
"--min-instances",
|
||||
type=int,
|
||||
help="Minimum Cloud Run instances",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-repository",
|
||||
default="datasette",
|
||||
show_default=True,
|
||||
help="Artifact Registry repository to store the image",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-region",
|
||||
default="us",
|
||||
show_default=True,
|
||||
help="Artifact Registry location (region or multi-region)",
|
||||
)
|
||||
@click.option(
|
||||
"--artifact-project",
|
||||
default=None,
|
||||
help="Project ID for Artifact Registry (defaults to the active project)",
|
||||
)
|
||||
def cloudrun(
|
||||
files,
|
||||
metadata,
|
||||
|
|
@ -91,6 +112,9 @@ def publish_subcommand(publish):
|
|||
apt_get_extras,
|
||||
max_instances,
|
||||
min_instances,
|
||||
artifact_repository,
|
||||
artifact_region,
|
||||
artifact_project,
|
||||
):
|
||||
"Publish databases to Datasette running on Cloud Run"
|
||||
fail_if_publish_binary_not_installed(
|
||||
|
|
@ -100,6 +124,21 @@ def publish_subcommand(publish):
|
|||
"gcloud config get-value project", shell=True, universal_newlines=True
|
||||
).strip()
|
||||
|
||||
artifact_project = artifact_project or project
|
||||
|
||||
# Ensure Artifact Registry exists for the target image
|
||||
_ensure_artifact_registry(
|
||||
artifact_project=artifact_project,
|
||||
artifact_region=artifact_region,
|
||||
artifact_repository=artifact_repository,
|
||||
)
|
||||
|
||||
artifact_host = (
|
||||
artifact_region
|
||||
if artifact_region.endswith("-docker.pkg.dev")
|
||||
else f"{artifact_region}-docker.pkg.dev"
|
||||
)
|
||||
|
||||
if not service:
|
||||
# Show the user their current services, then prompt for one
|
||||
click.echo("Please provide a service name for this deployment\n")
|
||||
|
|
@ -117,6 +156,11 @@ def publish_subcommand(publish):
|
|||
click.echo("")
|
||||
service = click.prompt("Service name", type=str)
|
||||
|
||||
image_id = (
|
||||
f"{artifact_host}/{artifact_project}/"
|
||||
f"{artifact_repository}/datasette-{service}"
|
||||
)
|
||||
|
||||
extra_metadata = {
|
||||
"title": title,
|
||||
"license": license,
|
||||
|
|
@ -173,7 +217,6 @@ def publish_subcommand(publish):
|
|||
print(fp.read())
|
||||
print("\n====================\n")
|
||||
|
||||
image_id = f"gcr.io/{project}/{name}"
|
||||
check_call(
|
||||
"gcloud builds submit --tag {}{}".format(
|
||||
image_id, " --timeout {}".format(timeout) if timeout else ""
|
||||
|
|
@ -187,7 +230,7 @@ def publish_subcommand(publish):
|
|||
("--max-instances", max_instances),
|
||||
("--min-instances", min_instances),
|
||||
):
|
||||
if value:
|
||||
if value is not None:
|
||||
extra_deploy_options.append("{} {}".format(option, value))
|
||||
check_call(
|
||||
"gcloud run deploy --allow-unauthenticated --platform=managed --image {} {}{}".format(
|
||||
|
|
@ -199,6 +242,52 @@ def publish_subcommand(publish):
|
|||
)
|
||||
|
||||
|
||||
def _ensure_artifact_registry(artifact_project, artifact_region, artifact_repository):
|
||||
"""Ensure Artifact Registry API is enabled and the repository exists."""
|
||||
|
||||
enable_cmd = (
|
||||
"gcloud services enable artifactregistry.googleapis.com "
|
||||
f"--project {artifact_project} --quiet"
|
||||
)
|
||||
try:
|
||||
check_call(enable_cmd, shell=True)
|
||||
except CalledProcessError as exc:
|
||||
raise click.ClickException(
|
||||
"Failed to enable artifactregistry.googleapis.com. "
|
||||
"Please ensure you have permissions to manage services."
|
||||
) from exc
|
||||
|
||||
describe_cmd = (
|
||||
"gcloud artifacts repositories describe {repo} --project {project} "
|
||||
"--location {location} --quiet"
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
project=artifact_project,
|
||||
location=artifact_region,
|
||||
)
|
||||
try:
|
||||
check_call(describe_cmd, shell=True)
|
||||
return
|
||||
except CalledProcessError:
|
||||
create_cmd = (
|
||||
"gcloud artifacts repositories create {repo} --repository-format=docker "
|
||||
'--location {location} --project {project} --description "Datasette Cloud Run images" --quiet'
|
||||
).format(
|
||||
repo=artifact_repository,
|
||||
location=artifact_region,
|
||||
project=artifact_project,
|
||||
)
|
||||
try:
|
||||
check_call(create_cmd, shell=True)
|
||||
click.echo(f"Created Artifact Registry repository '{artifact_repository}'")
|
||||
except CalledProcessError as exc:
|
||||
raise click.ClickException(
|
||||
"Failed to create Artifact Registry repository. "
|
||||
"Use --artifact-repository/--artifact-region to point to an existing repo "
|
||||
"or create one manually."
|
||||
) from exc
|
||||
|
||||
|
||||
def get_existing_services():
|
||||
services = json.loads(
|
||||
check_output(
|
||||
|
|
@ -214,6 +303,7 @@ def get_existing_services():
|
|||
"url": service["status"]["address"]["url"],
|
||||
}
|
||||
for service in services
|
||||
if "url" in service["status"]
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ from datasette import hookimpl
|
|||
import click
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import shutil
|
||||
from subprocess import call, check_output
|
||||
import tempfile
|
||||
|
||||
|
|
@ -28,6 +30,11 @@ def publish_subcommand(publish):
|
|||
"--tar",
|
||||
help="--tar option to pass to Heroku, e.g. --tar=/usr/local/bin/gtar",
|
||||
)
|
||||
@click.option(
|
||||
"--generate-dir",
|
||||
type=click.Path(dir_okay=True, file_okay=False),
|
||||
help="Output generated application files and stop without deploying",
|
||||
)
|
||||
def heroku(
|
||||
files,
|
||||
metadata,
|
||||
|
|
@ -49,6 +56,7 @@ def publish_subcommand(publish):
|
|||
about_url,
|
||||
name,
|
||||
tar,
|
||||
generate_dir,
|
||||
):
|
||||
"Publish databases to Datasette running on Heroku"
|
||||
fail_if_publish_binary_not_installed(
|
||||
|
|
@ -105,6 +113,16 @@ def publish_subcommand(publish):
|
|||
secret,
|
||||
extra_metadata,
|
||||
):
|
||||
if generate_dir:
|
||||
# Recursively copy files from current working directory to it
|
||||
if pathlib.Path(generate_dir).exists():
|
||||
raise click.ClickException("Directory already exists")
|
||||
shutil.copytree(".", generate_dir)
|
||||
click.echo(
|
||||
f"Generated files written to {generate_dir}, stopping without deploying",
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
app_name = None
|
||||
if name:
|
||||
# Check to see if this app already exists
|
||||
|
|
@ -176,7 +194,7 @@ def temporary_heroku_directory(
|
|||
fp.write(json.dumps(metadata_content, indent=2))
|
||||
|
||||
with open("runtime.txt", "w") as fp:
|
||||
fp.write("python-3.8.10")
|
||||
fp.write("python-3.11.0")
|
||||
|
||||
if branch:
|
||||
install = [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from datasette.utils import (
|
|||
remove_infinites,
|
||||
CustomJSONEncoder,
|
||||
path_from_row_pks,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import Response
|
||||
|
||||
|
|
@ -19,14 +20,14 @@ def convert_specific_columns_to_json(rows, columns, json_cols):
|
|||
if column in json_cols:
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (TypeError, ValueError) as e:
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
new_row.append(value)
|
||||
new_rows.append(new_row)
|
||||
return new_rows
|
||||
|
||||
|
||||
def json_renderer(args, data, view_name):
|
||||
def json_renderer(request, args, data, error, truncated=None):
|
||||
"""Render a response as JSON"""
|
||||
status_code = 200
|
||||
|
||||
|
|
@ -44,28 +45,38 @@ def json_renderer(args, data, view_name):
|
|||
data["rows"] = [remove_infinites(row) for row in data["rows"]]
|
||||
|
||||
# Deal with the _shape option
|
||||
shape = args.get("_shape", "arrays")
|
||||
shape = args.get("_shape", "objects")
|
||||
# if there's an error, ignore the shape entirely
|
||||
if data.get("error"):
|
||||
shape = "arrays"
|
||||
|
||||
next_url = data.get("next_url")
|
||||
data["ok"] = True
|
||||
if error:
|
||||
shape = "objects"
|
||||
status_code = 400
|
||||
data["error"] = error
|
||||
data["ok"] = False
|
||||
|
||||
if truncated is not None:
|
||||
data["truncated"] = truncated
|
||||
if shape == "arrayfirst":
|
||||
data = [row[0] for row in data["rows"]]
|
||||
if not data["rows"]:
|
||||
data = []
|
||||
elif isinstance(data["rows"][0], sqlite3.Row):
|
||||
data = [row[0] for row in data["rows"]]
|
||||
else:
|
||||
assert isinstance(data["rows"][0], dict)
|
||||
data = [next(iter(row.values())) for row in data["rows"]]
|
||||
elif shape in ("objects", "object", "array"):
|
||||
columns = data.get("columns")
|
||||
rows = data.get("rows")
|
||||
if rows and columns:
|
||||
if rows and columns and not isinstance(rows[0], dict):
|
||||
data["rows"] = [dict(zip(columns, row)) for row in rows]
|
||||
if shape == "object":
|
||||
error = None
|
||||
shape_error = None
|
||||
if "primary_keys" not in data:
|
||||
error = "_shape=object is only available on tables"
|
||||
shape_error = "_shape=object is only available on tables"
|
||||
else:
|
||||
pks = data["primary_keys"]
|
||||
if not pks:
|
||||
error = (
|
||||
shape_error = (
|
||||
"_shape=object not available for tables with no primary keys"
|
||||
)
|
||||
else:
|
||||
|
|
@ -74,13 +85,18 @@ def json_renderer(args, data, view_name):
|
|||
pk_string = path_from_row_pks(row, pks, not pks)
|
||||
object_rows[pk_string] = row
|
||||
data = object_rows
|
||||
if error:
|
||||
data = {"ok": False, "error": error}
|
||||
if shape_error:
|
||||
data = {"ok": False, "error": shape_error}
|
||||
elif shape == "array":
|
||||
data = data["rows"]
|
||||
|
||||
elif shape == "arrays":
|
||||
pass
|
||||
if not data["rows"]:
|
||||
pass
|
||||
elif isinstance(data["rows"][0], sqlite3.Row):
|
||||
data["rows"] = [list(row) for row in data["rows"]]
|
||||
else:
|
||||
data["rows"] = [list(row.values()) for row in data["rows"]]
|
||||
else:
|
||||
status_code = 400
|
||||
data = {
|
||||
|
|
@ -89,6 +105,12 @@ def json_renderer(args, data, view_name):
|
|||
"status": 400,
|
||||
"title": None,
|
||||
}
|
||||
|
||||
# Don't include "columns" in output
|
||||
# https://github.com/simonw/datasette/issues/2136
|
||||
if isinstance(data, dict) and "columns" not in request.args.getlist("_extra"):
|
||||
data.pop("columns", None)
|
||||
|
||||
# Handle _nl option for _shape=array
|
||||
nl = args.get("_nl", "")
|
||||
if nl and shape == "array":
|
||||
|
|
@ -98,8 +120,6 @@ def json_renderer(args, data, view_name):
|
|||
body = json.dumps(data, cls=CustomJSONEncoder)
|
||||
content_type = "application/json; charset=utf-8"
|
||||
headers = {}
|
||||
if next_url:
|
||||
headers["link"] = f'<{next_url}>; rel="next"'
|
||||
return Response(
|
||||
body, status=status_code, headers=headers, content_type=content_type
|
||||
)
|
||||
|
|
|
|||
58
datasette/resources.py
Normal file
58
datasette/resources.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Core resource types for Datasette's permission system."""
|
||||
|
||||
from datasette.permissions import Resource
|
||||
|
||||
|
||||
class DatabaseResource(Resource):
|
||||
"""A database in Datasette."""
|
||||
|
||||
name = "database"
|
||||
parent_class = None # Top of the resource hierarchy
|
||||
|
||||
def __init__(self, database: str):
|
||||
super().__init__(parent=database, child=None)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, NULL AS child
|
||||
FROM catalog_databases
|
||||
"""
|
||||
|
||||
|
||||
class TableResource(Resource):
|
||||
"""A table in a database."""
|
||||
|
||||
name = "table"
|
||||
parent_class = DatabaseResource
|
||||
|
||||
def __init__(self, database: str, table: str):
|
||||
super().__init__(parent=database, child=table)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
return """
|
||||
SELECT database_name AS parent, table_name AS child
|
||||
FROM catalog_tables
|
||||
UNION ALL
|
||||
SELECT database_name AS parent, view_name AS child
|
||||
FROM catalog_views
|
||||
"""
|
||||
|
||||
|
||||
class QueryResource(Resource):
|
||||
"""A stored query in a database."""
|
||||
|
||||
name = "query"
|
||||
parent_class = DatabaseResource
|
||||
|
||||
def __init__(self, database: str, query: str):
|
||||
super().__init__(parent=database, child=query)
|
||||
|
||||
@classmethod
|
||||
async def resources_sql(cls, datasette, actor=None) -> str:
|
||||
return """
|
||||
SELECT q.database_name AS parent, q.name AS child
|
||||
FROM queries q
|
||||
JOIN catalog_databases cd ON cd.database_name = q.database_name
|
||||
"""
|
||||
|
|
@ -63,6 +63,14 @@ em {
|
|||
}
|
||||
/* end reset */
|
||||
|
||||
/* Modal CSS variables (shared by web components via Shadow DOM) */
|
||||
:root {
|
||||
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
|
||||
--modal-backdrop-blur: blur(4px);
|
||||
--modal-border-radius: 0.75rem;
|
||||
--modal-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
--modal-animation-duration: 0.2s;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
|
@ -163,28 +171,22 @@ h6,
|
|||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
border-left: 10px solid #666;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
padding-right: 0.2em;
|
||||
}
|
||||
.page-header details {
|
||||
display: inline-flex;
|
||||
}
|
||||
.page-header details > summary {
|
||||
|
||||
.page-action-menu details > summary {
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-header details > summary::-webkit-details-marker {
|
||||
.page-action-menu details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
@ -228,12 +230,6 @@ button.button-as-link:focus {
|
|||
color: #67C98D;
|
||||
}
|
||||
|
||||
a img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: monospace;
|
||||
|
|
@ -271,24 +267,28 @@ a.not-underlined {
|
|||
|
||||
/* Page Furniture ========================================================= */
|
||||
/* Header */
|
||||
header,
|
||||
footer {
|
||||
header.hd,
|
||||
footer.ft {
|
||||
padding: 0.6rem 1rem 0.5rem 1rem;
|
||||
background-color: #276890;
|
||||
background: linear-gradient(180deg, rgba(96,144,173,1) 0%, rgba(39,104,144,1) 50%);
|
||||
color: rgba(255,255,244,0.9);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
min-height: 2.6rem;
|
||||
}
|
||||
header p,
|
||||
footer p {
|
||||
footer.ft {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
header.hd p,
|
||||
footer.ft p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
header .crumbs {
|
||||
header.hd .crumbs {
|
||||
float: left;
|
||||
}
|
||||
header .actor {
|
||||
header.hd .actor {
|
||||
float: right;
|
||||
text-align: right;
|
||||
padding-left: 1rem;
|
||||
|
|
@ -297,32 +297,32 @@ header .actor {
|
|||
top: -3px;
|
||||
}
|
||||
|
||||
footer a:link,
|
||||
footer a:visited,
|
||||
footer a:hover,
|
||||
footer a:focus,
|
||||
footer a:active,
|
||||
footer button.button-as-link {
|
||||
footer.ft a:link,
|
||||
footer.ft a:visited,
|
||||
footer.ft a:hover,
|
||||
footer.ft a:focus,
|
||||
footer.ft a:active,
|
||||
footer.ft button.button-as-link {
|
||||
color: rgba(255,255,244,0.8);
|
||||
}
|
||||
header a:link,
|
||||
header a:visited,
|
||||
header a:hover,
|
||||
header a:focus,
|
||||
header a:active,
|
||||
header button.button-as-link {
|
||||
header.hd a:link,
|
||||
header.hd a:visited,
|
||||
header.hd a:hover,
|
||||
header.hd a:focus,
|
||||
header.hd a:active,
|
||||
header.hd button.button-as-link {
|
||||
color: rgba(255,255,244,0.8);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover,
|
||||
footer a:focus,
|
||||
footer a:active,
|
||||
footer.button-as-link:hover,
|
||||
footer.button-as-link:focus,
|
||||
header a:hover,
|
||||
header a:focus,
|
||||
header a:active,
|
||||
footer.ft a:hover,
|
||||
footer.ft a:focus,
|
||||
footer.ft a:active,
|
||||
footer.ft .button-as-link:hover,
|
||||
footer.ft .button-as-link:focus,
|
||||
header.hd a:hover,
|
||||
header.hd a:focus,
|
||||
header.hd a:active,
|
||||
button.button-as-link:hover,
|
||||
button.button-as-link:focus {
|
||||
color: rgba(255,255,244,1);
|
||||
|
|
@ -334,11 +334,6 @@ section.content {
|
|||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Navigation menu */
|
||||
details.nav-menu > summary {
|
||||
list-style: none;
|
||||
|
|
@ -352,25 +347,85 @@ details.nav-menu > summary::-webkit-details-marker {
|
|||
}
|
||||
details .nav-menu-inner {
|
||||
position: absolute;
|
||||
top: 2rem;
|
||||
top: 2.6rem;
|
||||
right: 10px;
|
||||
width: 180px;
|
||||
background-color: #276890;
|
||||
padding: 1rem;
|
||||
z-index: 1000;
|
||||
padding: 0;
|
||||
}
|
||||
.nav-menu-inner li,
|
||||
form.nav-menu-logout {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-top: 1px solid #ffffff69;
|
||||
}
|
||||
.nav-menu-inner a {
|
||||
display: block;
|
||||
}
|
||||
.nav-menu-inner button.button-as-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
.nav-menu-inner .keyboard-shortcut {
|
||||
float: right;
|
||||
box-sizing: border-box;
|
||||
min-width: 1.4em;
|
||||
margin-left: 0.75rem;
|
||||
padding: 0 0.35em;
|
||||
border: 1px solid rgba(255,255,244,0.6);
|
||||
border-radius: 3px;
|
||||
background: rgba(255,255,244,0.12);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.nav-menu-inner .keyboard-shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Table/database actions menu */
|
||||
.page-header {
|
||||
.page-action-menu {
|
||||
position: relative;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.actions-menu-links {
|
||||
display: inline;
|
||||
}
|
||||
.actions-menu-links .dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
left: -10px;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
}
|
||||
.page-action-menu .icon-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: .25rem;
|
||||
padding: 5px 12px 3px 7px;
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
font-size: 0.8em;
|
||||
background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);
|
||||
border-color: #007bff;
|
||||
}
|
||||
.page-action-menu .icon-text span {
|
||||
/* Nudge text up a bit */
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.page-action-menu .icon-text:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-action-menu .icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Components ============================================================== */
|
||||
|
|
@ -423,36 +478,30 @@ h2 em {
|
|||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
table.rows-and-columns {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
td {
|
||||
table.rows-and-columns td {
|
||||
border-top: 1px solid #aaa;
|
||||
border-right: 1px solid #eee;
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
td.type-pk {
|
||||
table.rows-and-columns td.type-pk {
|
||||
font-weight: bold;
|
||||
}
|
||||
td em {
|
||||
table.rows-and-columns td em {
|
||||
font-style: normal;
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
}
|
||||
th {
|
||||
table.rows-and-columns th {
|
||||
padding-right: 1em;
|
||||
}
|
||||
table a:link {
|
||||
table.rows-and-columns a:link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.rows-and-columns td:before {
|
||||
display: block;
|
||||
color: black;
|
||||
margin-left: -10%;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.rows-and-columns td ol,
|
||||
.rows-and-columns td ul {
|
||||
list-style: initial;
|
||||
|
|
@ -470,10 +519,8 @@ a.blob-download {
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Forms =================================================================== */
|
||||
|
||||
|
||||
form.sql textarea {
|
||||
border: 1px solid #ccc;
|
||||
width: 70%;
|
||||
|
|
@ -482,27 +529,30 @@ form.sql textarea {
|
|||
font-family: monospace;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
form label {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
form.sql label {
|
||||
width: 15%;
|
||||
}
|
||||
.advanced-export form label {
|
||||
width: auto;
|
||||
}
|
||||
.advanced-export input[type=submit] {
|
||||
font-size: 0.6em;
|
||||
margin-left: 1em;
|
||||
}
|
||||
label.sort_by_desc {
|
||||
width: auto;
|
||||
padding-right: 1em;
|
||||
}
|
||||
pre#sql-query {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
form input[type=text],
|
||||
form input[type=search] {
|
||||
|
||||
.core label,
|
||||
label.core {
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.core input[type=text],
|
||||
input.core[type=text],
|
||||
.core input[type=search],
|
||||
input.core[type=search] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
width: 60%;
|
||||
|
|
@ -511,19 +561,27 @@ form input[type=search] {
|
|||
font-size: 1em;
|
||||
font-family: Helvetica, sans-serif;
|
||||
}
|
||||
/* Stop Webkit from styling search boxes in an inconsistent way */
|
||||
/* https://css-tricks.com/webkit-html5-search-inputs/ comments */
|
||||
input[type=search] {
|
||||
.core input[type=search],
|
||||
input.core[type=search] {
|
||||
/* Stop Webkit from styling search boxes in an inconsistent way */
|
||||
/* https://css-tricks.com/webkit-html5-search-inputs/ comments */
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
input[type="search"]::-webkit-search-decoration,
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-results-button,
|
||||
input[type="search"]::-webkit-search-results-decoration {
|
||||
.core input[type="search"]::-webkit-search-decoration,
|
||||
input.core[type="search"]::-webkit-search-decoration,
|
||||
.core input[type="search"]::-webkit-search-cancel-button,
|
||||
input.core[type="search"]::-webkit-search-cancel-button,
|
||||
.core input[type="search"]::-webkit-search-results-button,
|
||||
input.core[type="search"]::-webkit-search-results-button,
|
||||
.core input[type="search"]::-webkit-search-results-decoration,
|
||||
input.core[type="search"]::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form input[type=submit], form button[type=button] {
|
||||
.core input[type=submit],
|
||||
.core button[type=button],
|
||||
input.core[type=submit],
|
||||
button.core[type=button] {
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
|
|
@ -536,14 +594,16 @@ form input[type=submit], form button[type=button] {
|
|||
border-radius: .25rem;
|
||||
}
|
||||
|
||||
form input[type=submit] {
|
||||
.core input[type=submit],
|
||||
input.core[type=submit] {
|
||||
color: #fff;
|
||||
background-color: #007bff;
|
||||
background: linear-gradient(180deg, #007bff 0%, #4E79C7 100%);
|
||||
border-color: #007bff;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
form button[type=button] {
|
||||
.core button[type=button],
|
||||
button.core[type=button] {
|
||||
color: #007bff;
|
||||
background-color: #fff;
|
||||
border-color: #007bff;
|
||||
|
|
@ -573,6 +633,9 @@ form button[type=button] {
|
|||
display: inline-block;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
.select-wrapper:focus-within {
|
||||
border: 1px solid black;
|
||||
}
|
||||
.select-wrapper.filter-op {
|
||||
width: 80px;
|
||||
}
|
||||
|
|
@ -618,10 +681,14 @@ form button[type=button] {
|
|||
border-radius: 3px;
|
||||
-webkit-appearance: none;
|
||||
padding: 9px 4px;
|
||||
font-size: 1em;
|
||||
font-size: 16px;
|
||||
font-family: Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
#_search {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -701,6 +768,474 @@ p.zero-results {
|
|||
.select-wrapper.small-screen-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes datasette-modal-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes datasette-modal-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
dialog.mobile-column-actions-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
max-height: min(640px, calc(100vh - 32px));
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.mobile-column-actions-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.mobile-column-actions-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .modal-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .modal-meta {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .list-wrap {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .list-wrap::before,
|
||||
.mobile-column-actions-dialog .list-wrap::after {
|
||||
content: "";
|
||||
position: sticky;
|
||||
display: block;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .list-wrap::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .list-wrap::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.mobile-column-top-actions {
|
||||
padding: 10px 24px 0;
|
||||
}
|
||||
|
||||
.mobile-column-top-action {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobile-column-section {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-header {
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-column-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.mobile-column-name {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mobile-column-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.78em;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.mobile-column-chevron {
|
||||
color: var(--muted);
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-header[aria-expanded="true"] .mobile-column-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-actions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-actions ul,
|
||||
.mobile-column-actions-dialog .col-actions li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-actions a,
|
||||
.mobile-column-actions-dialog .col-actions button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 24px 10px 40px;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-actions a:hover,
|
||||
.mobile-column-actions-dialog .col-actions button:hover {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-actions a:active,
|
||||
.mobile-column-actions-dialog .col-actions button:active {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.mobile-column-description,
|
||||
.mobile-column-no-actions {
|
||||
margin: 0;
|
||||
padding: 0 24px 12px 24px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .footer-info {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
dialog.set-column-type-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
max-height: min(720px, calc(100vh - 32px));
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.set-column-type-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.set-column-type-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.set-column-type-dialog .modal-meta {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.set-column-type-status,
|
||||
.set-column-type-empty,
|
||||
.set-column-type-error {
|
||||
margin: 0;
|
||||
padding: 12px 24px 0;
|
||||
}
|
||||
|
||||
.set-column-type-status,
|
||||
.set-column-type-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.set-column-type-error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.set-column-type-options {
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.set-column-type-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
background: #fcfbf9;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.set-column-type-option:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.set-column-type-option input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.set-column-type-option-content {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.set-column-type-option-name {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.95rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.set-column-type-option-description {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.set-column-type-dialog .footer-info {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.set-column-type-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.set-column-type-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.set-column-type-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
dialog.mobile-column-actions-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .modal-header {
|
||||
padding: 16px 18px 14px;
|
||||
}
|
||||
|
||||
.mobile-column-top-actions {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-header {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.mobile-column-actions-dialog .col-actions a,
|
||||
.mobile-column-actions-dialog .col-actions button {
|
||||
padding-left: 34px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.mobile-column-description,
|
||||
.mobile-column-no-actions {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
dialog.set-column-type-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.set-column-type-dialog .modal-header,
|
||||
.set-column-type-status,
|
||||
.set-column-type-empty,
|
||||
.set-column-type-error,
|
||||
.set-column-type-options {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
||||
.small-screen-only {
|
||||
|
|
@ -730,7 +1265,7 @@ p.zero-results {
|
|||
left: -9999px;
|
||||
}
|
||||
|
||||
.rows-and-columns tr {
|
||||
table.rows-and-columns tr {
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 10px;
|
||||
|
|
@ -738,7 +1273,7 @@ p.zero-results {
|
|||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.rows-and-columns td {
|
||||
table.rows-and-columns td {
|
||||
/* Behave like a "row" */
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
|
@ -746,7 +1281,7 @@ p.zero-results {
|
|||
padding-left: 10%;
|
||||
}
|
||||
|
||||
.rows-and-columns td:before {
|
||||
table.rows-and-columns td:before {
|
||||
display: block;
|
||||
color: black;
|
||||
margin-left: -10%;
|
||||
|
|
@ -762,6 +1297,43 @@ p.zero-results {
|
|||
.filters input.filter-value {
|
||||
width: 140px;
|
||||
}
|
||||
button.choose-columns-mobile,
|
||||
button.column-actions-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-bottom: 1em;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
font-family: inherit;
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
button.column-actions-mobile {
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
button.column-actions-mobile svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button.column-actions-mobile span {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
button.choose-columns-mobile {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
svg.dropdown-menu-icon {
|
||||
|
|
@ -818,6 +1390,13 @@ svg.dropdown-menu-icon {
|
|||
.dropdown-menu a:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
.dropdown-menu .dropdown-description {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
max-width: 80vw;
|
||||
white-space: normal;
|
||||
}
|
||||
.dropdown-menu .hook {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
@ -830,11 +1409,15 @@ svg.dropdown-menu-icon {
|
|||
border-bottom: 5px solid #666;
|
||||
}
|
||||
|
||||
.canned-query-edit-sql {
|
||||
.stored-query-edit-sql {
|
||||
padding-left: 0.5em;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.save-query {
|
||||
display: inline-block;
|
||||
margin-left: 0.45em;
|
||||
}
|
||||
|
||||
.blob-download {
|
||||
display: block;
|
||||
|
|
|
|||
1
datasette/static/cm-editor-6.0.1.bundle.js
Normal file
1
datasette/static/cm-editor-6.0.1.bundle.js
Normal file
File diff suppressed because one or more lines are too long
74
datasette/static/cm-editor-6.0.1.js
Normal file
74
datasette/static/cm-editor-6.0.1.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { sql, SQLDialect } from "@codemirror/lang-sql";
|
||||
|
||||
// A variation of SQLite from lang-sql https://github.com/codemirror/lang-sql/blob/ebf115fffdbe07f91465ccbd82868c587f8182bc/src/sql.ts#L231
|
||||
const SQLite = SQLDialect.define({
|
||||
// Based on https://www.sqlite.org/lang_keywords.html based on likely keywords to be used in select queries
|
||||
// https://github.com/simonw/datasette/pull/1893#issuecomment-1316401895:
|
||||
keywords:
|
||||
"and as asc between by case cast count current_date current_time current_timestamp desc distinct each else escape except exists explain filter first for from full generated group having if in index inner intersect into isnull join last left like limit not null or order outer over pragma primary query raise range regexp right rollback row select set table then to union unique using values view virtual when where",
|
||||
// https://www.sqlite.org/datatype3.html
|
||||
types: "null integer real text blob",
|
||||
builtin: "",
|
||||
operatorChars: "*+-%<>!=&|/~",
|
||||
identifierQuotes: '`"',
|
||||
specialVar: "@:?$",
|
||||
});
|
||||
|
||||
// Utility function from https://codemirror.net/docs/migration/
|
||||
export function editorFromTextArea(textarea, conf = {}) {
|
||||
// This could also be configured with a set of tables and columns for better autocomplete:
|
||||
// https://github.com/codemirror/lang-sql#user-content-sqlconfig.tables
|
||||
let view = new EditorView({
|
||||
doc: textarea.value,
|
||||
extensions: [
|
||||
keymap.of([
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: function () {
|
||||
textarea.value = view.state.doc.toString();
|
||||
textarea.form.submit();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Meta-Enter",
|
||||
run: function () {
|
||||
textarea.value = view.state.doc.toString();
|
||||
textarea.form.submit();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
// This has to be after the keymap or else the basicSetup keys will prevent
|
||||
// Meta-Enter from running
|
||||
basicSetup,
|
||||
EditorView.lineWrapping,
|
||||
sql({
|
||||
dialect: SQLite,
|
||||
schema: conf.schema,
|
||||
tables: conf.tables,
|
||||
defaultTableName: conf.defaultTableName,
|
||||
defaultSchemaName: conf.defaultSchemaName,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Idea taken from https://discuss.codemirror.net/t/resizing-codemirror-6/3265.
|
||||
// Using CSS resize: both and scheduling a measurement when the element changes.
|
||||
let editorDOM = view.contentDOM.closest(".cm-editor");
|
||||
let observer = new ResizeObserver(function () {
|
||||
view.requestMeasure();
|
||||
});
|
||||
observer.observe(editorDOM, { attributes: true });
|
||||
|
||||
textarea.parentNode.insertBefore(view.dom, textarea);
|
||||
textarea.style.display = "none";
|
||||
if (textarea.form) {
|
||||
textarea.form.addEventListener("submit", () => {
|
||||
textarea.value = view.state.doc.toString();
|
||||
});
|
||||
}
|
||||
return view;
|
||||
}
|
||||
8
datasette/static/cm-resize-1.0.1.min.js
vendored
8
datasette/static/cm-resize-1.0.1.min.js
vendored
|
|
@ -1,8 +0,0 @@
|
|||
/*!
|
||||
* cm-resize v1.0.1
|
||||
* https://github.com/Sphinxxxx/cm-resize
|
||||
*
|
||||
* Copyright 2017-2018 Andreas Borgen (https://github.com/Sphinxxxx)
|
||||
* Released under the MIT license.
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.cmResize=t()}(this,function(){"use strict";return document.documentElement.firstElementChild.appendChild(document.createElement("style")).textContent=".cm-resize-handle{display:block;position:absolute;bottom:0;right:0;z-index:99;width:18px;height:18px;background:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0,0 16,16'%3E%3Cpath stroke='gray' stroke-width='2' d='M-1,12 l18,-18 M-1,18 l18,-18 M-1,24 l18,-18 M-1,30 l18,-18'/%3E%3C/svg%3E\") center/cover;box-shadow:inset -1px -1px 0 0 silver;cursor:nwse-resize}",function(r,e){var t,c=(e=e||{}).minWidth||200,l=e.minHeight||100,s=!1!==e.resizableWidth,d=!1!==e.resizableHeight,n=e.cssClass||"cm-resize-handle",o=r.display.wrapper,i=e.handle||((t=o.appendChild(document.createElement("div"))).className=n,t),a=o.querySelector(".CodeMirror-vscrollbar"),u=o.querySelector(".CodeMirror-hscrollbar");function h(){e.handle||(a.style.bottom="18px",u.style.right="18px")}r.on("update",h),h();var f=void 0,m=void 0;return function(e){var t=Element.prototype;t.matches||(t.matches=t.msMatchesSelector||t.webkitMatchesSelector),t.closest||(t.closest=function(e){var t=this;do{if(t.matches(e))return t;t="svg"===t.tagName?t.parentNode:t.parentElement}while(t);return null});var l=(e=e||{}).container||document.documentElement,n=e.selector,o=e.callback||console.log,i=e.callbackDragStart,a=e.callbackDragEnd,r=e.callbackClick,c=e.propagateEvents,s=!1!==e.roundCoords,d=!1!==e.dragOutside,u=e.handleOffset||!1!==e.handleOffset,h=null;switch(u){case"center":h=!0;break;case"topleft":case"top-left":h=!1}var f=void 0,m=void 0,p=void 0;function v(e,t,n,o){var i=e.clientX,a=e.clientY;function r(e,t,n){return Math.max(t,Math.min(e,n))}if(t){var c=t.getBoundingClientRect();i-=c.left,a-=c.top,n&&(i-=n[0],a-=n[1]),o&&(i=r(i,0,c.width),a=r(a,0,c.height)),t!==l&&(null!==h?h:"circle"===t.nodeName||"ellipse"===t.nodeName)&&(i-=c.width/2,a-=c.height/2)}return s?[Math.round(i),Math.round(a)]:[i,a]}function g(e){e.preventDefault(),c||e.stopPropagation()}function w(e){(f=n?n instanceof Element?n.contains(e.target)?n:null:e.target.closest(n):{})&&(g(e),m=n&&u?v(e,f):[0,0],p=v(e,l,m),s&&(p=p.map(Math.round)),i&&i(f,p))}function b(e){if(f){g(e);var t=v(e,l,m,!d);o(f,t,p)}}function E(e){if(f){if(a||r){var t=v(e,l,m,!d);r&&p[0]===t[0]&&p[1]===t[1]&&r(f,p),a&&a(f,t,p)}f=null}}function x(e){E(C(e))}function M(e){return void 0!==e.buttons?1===e.buttons:1===e.which}function k(e,t){1===e.touches.length?t(C(e)):E(e)}function C(e){var t=e.targetTouches[0];return t||(t=e.changedTouches[0]),t.preventDefault=e.preventDefault.bind(e),t.stopPropagation=e.stopPropagation.bind(e),t}l.addEventListener("mousedown",function(e){M(e)&&w(e)}),l.addEventListener("touchstart",function(e){k(e,w)}),window.addEventListener("mousemove",function(e){f&&(M(e)?b(e):E(e))}),window.addEventListener("touchmove",function(e){k(e,b)}),window.addEventListener("mouseup",function(e){f&&!M(e)&&E(e)}),l.addEventListener("touchend",x),l.addEventListener("touchcancel",x)}({container:i.offsetParent,selector:i,callbackDragStart:function(e,t){f=t,m=[o.clientWidth,o.clientHeight]},callback:function(e,t){var n=t[0]-f[0],o=t[1]-f[1],i=s?Math.max(c,m[0]+n):null,a=d?Math.max(l,m[1]+o):null;r.setSize(i,a)}}),i}});
|
||||
File diff suppressed because one or more lines are too long
1
datasette/static/codemirror-5.57.0.min.css
vendored
1
datasette/static/codemirror-5.57.0.min.css
vendored
File diff suppressed because one or more lines are too long
11
datasette/static/codemirror-5.57.0.min.js
vendored
11
datasette/static/codemirror-5.57.0.min.js
vendored
File diff suppressed because one or more lines are too long
699
datasette/static/column-chooser.js
Normal file
699
datasette/static/column-chooser.js
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
class ColumnChooser extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// State
|
||||
this._items = [];
|
||||
this._checked = new Set();
|
||||
this._savedItems = null;
|
||||
this._savedChecked = null;
|
||||
this._onApply = null;
|
||||
|
||||
// Drag state
|
||||
this._ghost = null;
|
||||
this._dragSrcIdx = null;
|
||||
this._dropTargetIdx = null;
|
||||
this._dropPosition = null;
|
||||
this._ghostOffX = 0;
|
||||
this._ghostOffY = 0;
|
||||
this._autoScrollRAF = null;
|
||||
this._lastPointerY = 0;
|
||||
this._lastPointerX = 0;
|
||||
this._SCROLL_ZONE = 72;
|
||||
this._SCROLL_SPEED = 0.4;
|
||||
|
||||
// Bound handlers
|
||||
this._onMove = this._onMove.bind(this);
|
||||
this._onUp = this._onUp.bind(this);
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #e8effd;
|
||||
--card: #ffffff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: min(640px, calc(100vh - 32px));
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: min(640px, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
padding: 3px 9px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
padding: 6px 24px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list-toolbar button {
|
||||
background: var(--accent-light);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.list-toolbar button:hover { background: var(--accent); color: white; }
|
||||
|
||||
.list-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.list-wrap::before,
|
||||
.list-wrap::after {
|
||||
content: '';
|
||||
position: sticky;
|
||||
display: block;
|
||||
left: 0; right: 0;
|
||||
height: 20px;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.list-wrap::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.9), transparent);
|
||||
}
|
||||
.list-wrap::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(255,255,255,0.9), transparent);
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.scroll-zone {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 72px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.scroll-zone-top { top: 0; }
|
||||
.scroll-zone-bot { bottom: 0; }
|
||||
|
||||
.drag-list {
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.drag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
position: relative;
|
||||
transition: background 0.08s;
|
||||
}
|
||||
|
||||
.drag-item:last-child { border-bottom: none; }
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
color: #c8c4bc;
|
||||
touch-action: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.drag-handle:hover { color: var(--accent); }
|
||||
.drag-handle svg { pointer-events: none; display: block; }
|
||||
|
||||
.drag-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drag-item-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drag-item-check input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drag-item-label {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 48px;
|
||||
padding-right: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.drag-item.is-dragging {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drop-indicator {
|
||||
position: absolute;
|
||||
left: 48px;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 99px;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
}
|
||||
.drop-indicator.top { top: -1px; display: block; }
|
||||
.drop-indicator.bottom { bottom: -1px; display: block; }
|
||||
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid var(--accent-light);
|
||||
opacity: 0.97;
|
||||
will-change: transform;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.scroll-pulse {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.scroll-pulse.top { top: 8px; }
|
||||
.scroll-pulse.bot { bottom: 8px; }
|
||||
.scroll-pulse.active {
|
||||
opacity: 0.18;
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: translateX(-50%) scale(1); opacity: 0.18; }
|
||||
50% { transform: translateX(-50%) scale(1.5); opacity: 0.07; }
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover { background: #1448c0; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--rule); color: var(--ink); }
|
||||
|
||||
.list-wrap::-webkit-scrollbar { width: 5px; }
|
||||
.list-wrap::-webkit-scrollbar-track { background: transparent; }
|
||||
.list-wrap::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 99px; }
|
||||
|
||||
input, textarea { -webkit-user-select: auto; user-select: auto; }
|
||||
</style>
|
||||
|
||||
<dialog aria-labelledby="modalTitle">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="modalTitle">Choose columns</span>
|
||||
<span class="modal-meta" id="selectedCount"></span>
|
||||
</div>
|
||||
<div class="list-toolbar">
|
||||
<button id="selectAllBtn">Select all</button>
|
||||
<button id="deselectAllBtn">Deselect all</button>
|
||||
</div>
|
||||
<div class="list-wrap" id="listWrap">
|
||||
<div class="scroll-pulse top" id="pulseTop"></div>
|
||||
<div class="scroll-pulse bot" id="pulseBot"></div>
|
||||
<ul class="drag-list" id="dragList"></ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="footer-info" id="footerInfo"></span>
|
||||
<button class="btn btn-ghost" id="cancelBtn">Cancel</button>
|
||||
<button class="btn btn-primary" id="applyBtn">Apply</button>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
|
||||
// DOM refs
|
||||
this._dialog = this.shadowRoot.querySelector("dialog");
|
||||
this._listWrap = this.shadowRoot.getElementById("listWrap");
|
||||
this._dragList = this.shadowRoot.getElementById("dragList");
|
||||
this._pulseTop = this.shadowRoot.getElementById("pulseTop");
|
||||
this._pulseBot = this.shadowRoot.getElementById("pulseBot");
|
||||
this._selectAllBtn = this.shadowRoot.getElementById("selectAllBtn");
|
||||
this._deselectAllBtn = this.shadowRoot.getElementById("deselectAllBtn");
|
||||
this._cancelBtn = this.shadowRoot.getElementById("cancelBtn");
|
||||
this._applyBtn = this.shadowRoot.getElementById("applyBtn");
|
||||
this._countEl = this.shadowRoot.getElementById("selectedCount");
|
||||
this._footerEl = this.shadowRoot.getElementById("footerInfo");
|
||||
|
||||
// Event listeners
|
||||
this._selectAllBtn.addEventListener("click", () => this._selectAll());
|
||||
this._deselectAllBtn.addEventListener("click", () => this._deselectAll());
|
||||
this._cancelBtn.addEventListener("click", () => this._close());
|
||||
this._applyBtn.addEventListener("click", () => this._apply());
|
||||
this._dialog.addEventListener("click", (e) => {
|
||||
if (e.target === this._dialog) this._close();
|
||||
});
|
||||
this._dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
this._close();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the column chooser dialog.
|
||||
* @param {Object} opts
|
||||
* @param {string[]} opts.columns - All available column names, in display order.
|
||||
* @param {string[]} opts.selected - Column names that should be pre-checked.
|
||||
* @param {function(string[]): void} opts.onApply - Called with the selected columns in order when Apply is clicked.
|
||||
*/
|
||||
open({ columns, selected = [], onApply }) {
|
||||
this._items = [...columns];
|
||||
this._checked = new Set(selected);
|
||||
this._onApply = onApply || null;
|
||||
|
||||
// Save state for cancel/restore
|
||||
this._savedItems = [...this._items];
|
||||
this._savedChecked = new Set(this._checked);
|
||||
|
||||
this._render();
|
||||
this._dialog.showModal();
|
||||
}
|
||||
|
||||
// ── Internal methods ──
|
||||
|
||||
_close() {
|
||||
this._items = this._savedItems ? [...this._savedItems] : this._items;
|
||||
this._checked = this._savedChecked
|
||||
? new Set(this._savedChecked)
|
||||
: this._checked;
|
||||
this._dialog.close();
|
||||
}
|
||||
|
||||
_selectAll() {
|
||||
this._items.forEach((col) => this._checked.add(col));
|
||||
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||
cb.checked = true;
|
||||
});
|
||||
this._updateCounts();
|
||||
}
|
||||
|
||||
_deselectAll() {
|
||||
this._checked.clear();
|
||||
this._dragList.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
||||
cb.checked = false;
|
||||
});
|
||||
this._updateCounts();
|
||||
}
|
||||
|
||||
_apply() {
|
||||
const selected = this._items.filter((col) => this._checked.has(col));
|
||||
this._dialog.close();
|
||||
if (this._onApply) {
|
||||
this._onApply(selected);
|
||||
}
|
||||
}
|
||||
|
||||
_render() {
|
||||
this._dragList.innerHTML = "";
|
||||
this._items.forEach((col, i) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "drag-item";
|
||||
li.dataset.idx = i;
|
||||
li.innerHTML = `
|
||||
<span class="drag-handle" aria-label="Drag to reorder">
|
||||
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor">
|
||||
<circle cx="3.5" cy="3.5" r="1.8"/>
|
||||
<circle cx="8.5" cy="3.5" r="1.8"/>
|
||||
<circle cx="3.5" cy="9" r="1.8"/>
|
||||
<circle cx="8.5" cy="9" r="1.8"/>
|
||||
<circle cx="3.5" cy="14.5" r="1.8"/>
|
||||
<circle cx="8.5" cy="14.5" r="1.8"/>
|
||||
</svg>
|
||||
</span>
|
||||
<label class="drag-item-content">
|
||||
<span class="drag-item-check">
|
||||
<input type="checkbox" ${this._checked.has(col) ? "checked" : ""}>
|
||||
</span>
|
||||
<span class="drag-item-label">${col}</span>
|
||||
</label>
|
||||
<div class="drop-indicator"></div>
|
||||
`;
|
||||
|
||||
li.querySelector("input").addEventListener("change", (e) => {
|
||||
e.target.checked ? this._checked.add(col) : this._checked.delete(col);
|
||||
this._updateCounts();
|
||||
});
|
||||
|
||||
li.querySelector(".drag-handle").addEventListener("pointerdown", (e) =>
|
||||
this._startDrag(e, i),
|
||||
);
|
||||
this._dragList.appendChild(li);
|
||||
});
|
||||
|
||||
this._updateCounts();
|
||||
}
|
||||
|
||||
_updateCounts() {
|
||||
const n = this._checked.size;
|
||||
this._countEl.textContent = `${n} of ${this._items.length} selected`;
|
||||
this._footerEl.textContent = `${this._items.length} columns`;
|
||||
}
|
||||
|
||||
// ── Drag engine ──
|
||||
|
||||
_startDrag(e, idx) {
|
||||
e.preventDefault();
|
||||
this._dragSrcIdx = idx;
|
||||
|
||||
const srcEl = this._dragList.children[idx];
|
||||
const rect = srcEl.getBoundingClientRect();
|
||||
|
||||
this._ghostOffX = e.clientX - rect.left;
|
||||
this._ghostOffY = e.clientY - rect.top;
|
||||
|
||||
// Build ghost inside shadow DOM
|
||||
this._ghost = document.createElement("div");
|
||||
this._ghost.className = "drag-ghost";
|
||||
this._ghost.style.width = rect.width + "px";
|
||||
this._ghost.style.height = rect.height + "px";
|
||||
this._ghost.innerHTML = srcEl.innerHTML;
|
||||
this._ghost.querySelector(".drop-indicator")?.remove();
|
||||
const h = this._ghost.querySelector(".drag-handle");
|
||||
if (h) h.style.color = "var(--accent)";
|
||||
this.shadowRoot.appendChild(this._ghost);
|
||||
|
||||
srcEl.classList.add("is-dragging");
|
||||
this._positionGhost(e.clientX, e.clientY);
|
||||
|
||||
document.addEventListener("pointermove", this._onMove);
|
||||
document.addEventListener("pointerup", this._onUp);
|
||||
document.addEventListener("pointercancel", this._onUp);
|
||||
}
|
||||
|
||||
_positionGhost(cx, cy) {
|
||||
this._ghost.style.left = cx - this._ghostOffX + "px";
|
||||
this._ghost.style.top = cy - this._ghostOffY + "px";
|
||||
}
|
||||
|
||||
_onMove(e) {
|
||||
this._lastPointerX = e.clientX;
|
||||
this._lastPointerY = e.clientY;
|
||||
this._positionGhost(e.clientX, e.clientY);
|
||||
this._updateDropTarget(e.clientY);
|
||||
this._updateAutoScroll(e.clientY);
|
||||
}
|
||||
|
||||
_onUp() {
|
||||
document.removeEventListener("pointermove", this._onMove);
|
||||
document.removeEventListener("pointerup", this._onUp);
|
||||
document.removeEventListener("pointercancel", this._onUp);
|
||||
|
||||
this._stopAutoScroll();
|
||||
|
||||
const noMove =
|
||||
this._dropTargetIdx === null || this._dropTargetIdx === this._dragSrcIdx;
|
||||
this._clearDropIndicators();
|
||||
|
||||
let dest = null;
|
||||
if (!noMove) {
|
||||
const moved = this._items.splice(this._dragSrcIdx, 1)[0];
|
||||
dest = this._dropTargetIdx;
|
||||
if (this._dropPosition === "after") dest++;
|
||||
if (dest > this._dragSrcIdx) dest--;
|
||||
this._items.splice(dest, 0, moved);
|
||||
}
|
||||
|
||||
this._dragSrcIdx = null;
|
||||
this._dropTargetIdx = null;
|
||||
this._dropPosition = null;
|
||||
|
||||
const g = this._ghost;
|
||||
this._ghost = null;
|
||||
|
||||
if (noMove) {
|
||||
if (g) g.remove();
|
||||
this._render();
|
||||
return;
|
||||
}
|
||||
|
||||
this._render();
|
||||
|
||||
if (g && dest !== null) {
|
||||
const landedEl = this._dragList.children[dest];
|
||||
if (landedEl) {
|
||||
landedEl.style.opacity = "0";
|
||||
const r = landedEl.getBoundingClientRect();
|
||||
g.getBoundingClientRect();
|
||||
g.style.transition =
|
||||
"left 0.15s cubic-bezier(0.22, 1, 0.36, 1), top 0.15s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.15s, opacity 0.1s 0.1s";
|
||||
g.style.left = r.left + "px";
|
||||
g.style.top = r.top + "px";
|
||||
g.style.boxShadow = "0 1px 4px rgba(0,0,0,0.08)";
|
||||
g.style.opacity = "0";
|
||||
setTimeout(() => {
|
||||
g.remove();
|
||||
if (landedEl) landedEl.style.opacity = "";
|
||||
}, 160);
|
||||
} else {
|
||||
g.remove();
|
||||
}
|
||||
} else if (g) {
|
||||
g.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_updateDropTarget(clientY) {
|
||||
this._clearDropIndicators();
|
||||
const listItems = [
|
||||
...this._dragList.querySelectorAll(".drag-item:not(.is-dragging)"),
|
||||
];
|
||||
if (!listItems.length) return;
|
||||
|
||||
let best = null,
|
||||
bestDist = Infinity;
|
||||
listItems.forEach((li) => {
|
||||
const r = li.getBoundingClientRect();
|
||||
const mid = r.top + r.height / 2;
|
||||
const dist = Math.abs(clientY - mid);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = li;
|
||||
}
|
||||
});
|
||||
|
||||
if (!best) return;
|
||||
const r = best.getBoundingClientRect();
|
||||
const mid = r.top + r.height / 2;
|
||||
const above = clientY < mid;
|
||||
const indic = best.querySelector(".drop-indicator");
|
||||
|
||||
this._dropTargetIdx = parseInt(best.dataset.idx);
|
||||
this._dropPosition = above ? "before" : "after";
|
||||
|
||||
if (indic) {
|
||||
indic.className = "drop-indicator " + (above ? "top" : "bottom");
|
||||
}
|
||||
}
|
||||
|
||||
_clearDropIndicators() {
|
||||
this._dragList.querySelectorAll(".drop-indicator").forEach((el) => {
|
||||
el.className = "drop-indicator";
|
||||
});
|
||||
}
|
||||
|
||||
_updateAutoScroll(clientY) {
|
||||
const rect = this._listWrap.getBoundingClientRect();
|
||||
const relY = clientY - rect.top;
|
||||
const distTop = relY;
|
||||
const distBot = rect.height - relY;
|
||||
|
||||
const inTop = distTop < this._SCROLL_ZONE && distTop >= 0;
|
||||
const inBot = distBot < this._SCROLL_ZONE && distBot >= 0;
|
||||
|
||||
this._pulseTop.classList.toggle("active", inTop);
|
||||
this._pulseBot.classList.toggle("active", inBot);
|
||||
|
||||
if ((inTop || inBot) && !this._autoScrollRAF) {
|
||||
let lastTime = null;
|
||||
const loop = (ts) => {
|
||||
if (!this._ghost) {
|
||||
this._stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
if (lastTime !== null) {
|
||||
const dt = ts - lastTime;
|
||||
const rect2 = this._listWrap.getBoundingClientRect();
|
||||
const relY2 = this._lastPointerY - rect2.top;
|
||||
const dTop = relY2;
|
||||
const dBot = rect2.height - relY2;
|
||||
|
||||
if (dTop < this._SCROLL_ZONE && dTop >= 0) {
|
||||
const factor = 1 - dTop / this._SCROLL_ZONE;
|
||||
this._listWrap.scrollTop -= this._SCROLL_SPEED * dt * factor * 2.5;
|
||||
} else if (dBot < this._SCROLL_ZONE && dBot >= 0) {
|
||||
const factor = 1 - dBot / this._SCROLL_ZONE;
|
||||
this._listWrap.scrollTop += this._SCROLL_SPEED * dt * factor * 2.5;
|
||||
} else {
|
||||
this._stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
this._updateDropTarget(this._lastPointerY);
|
||||
}
|
||||
lastTime = ts;
|
||||
this._autoScrollRAF = requestAnimationFrame(loop);
|
||||
};
|
||||
this._autoScrollRAF = requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
if (!inTop && !inBot) this._stopAutoScroll();
|
||||
}
|
||||
|
||||
_stopAutoScroll() {
|
||||
if (this._autoScrollRAF) {
|
||||
cancelAnimationFrame(this._autoScrollRAF);
|
||||
this._autoScrollRAF = null;
|
||||
}
|
||||
this._pulseTop.classList.remove("active");
|
||||
this._pulseBot.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("column-chooser", ColumnChooser);
|
||||
222
datasette/static/datasette-manager.js
Normal file
222
datasette/static/datasette-manager.js
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Custom events for use with the native CustomEvent API
|
||||
const DATASETTE_EVENTS = {
|
||||
INIT: "datasette_init", // returns datasette manager instance in evt.detail
|
||||
};
|
||||
|
||||
// Datasette "core" -> Methods/APIs that are foundational
|
||||
// Plugins will have greater stability if they use the functional hooks- but if they do decide to hook into
|
||||
// literal DOM selectors, they'll have an easier time using these addresses.
|
||||
const DOM_SELECTORS = {
|
||||
/** Should have one match */
|
||||
jsonExportLink: ".export-links a[href*=json]",
|
||||
|
||||
/** Event listeners that go outside of the main table, e.g. existing scroll listener */
|
||||
tableWrapper: ".table-wrapper",
|
||||
table: "table.rows-and-columns",
|
||||
aboveTablePanel: ".above-table-panel",
|
||||
|
||||
// These could have multiple matches
|
||||
/** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */
|
||||
tableHeaders: `table.rows-and-columns th`,
|
||||
|
||||
/** Used to add "where" clauses to query using direct manipulation */
|
||||
filterRows: ".filter-row",
|
||||
/** Used to show top available enum values for a column ("facets") */
|
||||
facetResults: ".facet-results [data-column]",
|
||||
};
|
||||
|
||||
/**
|
||||
* Monolith class for interacting with Datasette JS API
|
||||
* Imported with DEFER, runs after main document parsed
|
||||
* For now, manually synced with datasette/version.py
|
||||
*/
|
||||
const datasetteManager = {
|
||||
VERSION: window.datasetteVersion,
|
||||
|
||||
// TODO: Should order of registration matter more?
|
||||
|
||||
// Should plugins be allowed to clobber others or is it last-in takes priority?
|
||||
// Does pluginMetadata need to be serializable, or can we let it be stateful / have functions?
|
||||
plugins: new Map(),
|
||||
|
||||
registerPlugin: (name, pluginMetadata) => {
|
||||
if (datasetteManager.plugins.has(name)) {
|
||||
console.warn(`Warning -> plugin ${name} was redefined`);
|
||||
}
|
||||
datasetteManager.plugins.set(name, pluginMetadata);
|
||||
|
||||
// If the plugin participates in the panel... update the panel.
|
||||
if (pluginMetadata.makeAboveTablePanelConfigs) {
|
||||
datasetteManager.renderAboveTablePanel();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* New DOM elements are created on each click, so the data is not stale.
|
||||
*
|
||||
* Items
|
||||
* - must provide label (text)
|
||||
* - might provide href (string) or an onclick ((evt) => void)
|
||||
*
|
||||
* columnMeta is metadata stored on the column header (TH) as a DOMStringMap
|
||||
* - column: string
|
||||
* - columnNotNull: boolean
|
||||
* - columnType: sqlite datatype enum (text, number, etc)
|
||||
* - isPk: boolean
|
||||
*/
|
||||
makeColumnActions: (columnMeta) => {
|
||||
let columnActions = [];
|
||||
|
||||
// Accept function that returns list of columnActions with keys
|
||||
// Required: label (text)
|
||||
// Optional: onClick or href
|
||||
datasetteManager.plugins.forEach((plugin) => {
|
||||
if (plugin.makeColumnActions) {
|
||||
// Plugins can provide multiple columnActions if they want
|
||||
// If multiple try to create entry with same label, the last one deletes the others
|
||||
columnActions.push(...plugin.makeColumnActions(columnMeta));
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Validate columnAction configs and give informative error message if missing keys.
|
||||
return columnActions;
|
||||
},
|
||||
|
||||
makeJumpSections: (context) => {
|
||||
let jumpSections = [];
|
||||
|
||||
datasetteManager.plugins.forEach((plugin) => {
|
||||
if (plugin.makeJumpSections) {
|
||||
const sections = plugin.makeJumpSections(context) || [];
|
||||
jumpSections.push(...sections);
|
||||
}
|
||||
});
|
||||
|
||||
return jumpSections;
|
||||
},
|
||||
|
||||
/**
|
||||
* In MVP, each plugin can only have 1 instance.
|
||||
* In future, panels could be repeated. We omit that for now since so many plugins depend on
|
||||
* shared URL state, so having multiple instances of plugin at same time is problematic.
|
||||
* Currently, we never destroy any panels, we just hide them.
|
||||
*
|
||||
* TODO: nicer panel css, show panel selection state.
|
||||
* TODO: does this hook need to take any arguments?
|
||||
*/
|
||||
renderAboveTablePanel: () => {
|
||||
const aboveTablePanel = document.querySelector(
|
||||
DOM_SELECTORS.aboveTablePanel,
|
||||
);
|
||||
|
||||
if (!aboveTablePanel) {
|
||||
console.warn(
|
||||
"This page does not have a table, the renderAboveTablePanel cannot be used.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let aboveTablePanelWrapper = aboveTablePanel.querySelector(".panels");
|
||||
|
||||
// First render: create wrappers. Otherwise, reuse previous.
|
||||
if (!aboveTablePanelWrapper) {
|
||||
aboveTablePanelWrapper = document.createElement("div");
|
||||
aboveTablePanelWrapper.classList.add("tab-contents");
|
||||
const panelNav = document.createElement("div");
|
||||
panelNav.classList.add("tab-controls");
|
||||
|
||||
// Temporary: css for minimal amount of breathing room.
|
||||
panelNav.style.display = "flex";
|
||||
panelNav.style.gap = "8px";
|
||||
panelNav.style.marginTop = "4px";
|
||||
panelNav.style.marginBottom = "20px";
|
||||
|
||||
aboveTablePanel.appendChild(panelNav);
|
||||
aboveTablePanel.appendChild(aboveTablePanelWrapper);
|
||||
}
|
||||
|
||||
datasetteManager.plugins.forEach((plugin, pluginName) => {
|
||||
const { makeAboveTablePanelConfigs } = plugin;
|
||||
|
||||
if (makeAboveTablePanelConfigs) {
|
||||
const controls = aboveTablePanel.querySelector(".tab-controls");
|
||||
const contents = aboveTablePanel.querySelector(".tab-contents");
|
||||
|
||||
// Each plugin can make multiple panels
|
||||
const configs = makeAboveTablePanelConfigs();
|
||||
|
||||
configs.forEach((config, i) => {
|
||||
const nodeContentId = `${pluginName}_${config.id}_panel-content`;
|
||||
|
||||
// quit if we've already registered this plugin
|
||||
// TODO: look into whether plugins should be allowed to ask
|
||||
// parent to re-render, or if they should manage that internally.
|
||||
if (document.getElementById(nodeContentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add tab control button
|
||||
const pluginControl = document.createElement("button");
|
||||
pluginControl.textContent = config.label;
|
||||
pluginControl.onclick = () => {
|
||||
contents.childNodes.forEach((node) => {
|
||||
if (node.id === nodeContentId) {
|
||||
node.style.display = "block";
|
||||
} else {
|
||||
node.style.display = "none";
|
||||
}
|
||||
});
|
||||
};
|
||||
controls.appendChild(pluginControl);
|
||||
|
||||
// Add plugin content area
|
||||
const pluginNode = document.createElement("div");
|
||||
pluginNode.id = nodeContentId;
|
||||
config.render(pluginNode);
|
||||
pluginNode.style.display = "none"; // Default to hidden unless you're ifrst
|
||||
|
||||
contents.appendChild(pluginNode);
|
||||
});
|
||||
|
||||
// Let first node be selected by default
|
||||
if (contents.childNodes.length) {
|
||||
contents.childNodes[0].style.display = "block";
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Selectors for document (DOM) elements. Store identifier instead of immediate references in case they haven't loaded when Manager starts. */
|
||||
selectors: DOM_SELECTORS,
|
||||
|
||||
// Future API ideas
|
||||
// Fetch page's data in array, and cache so plugins could reuse it
|
||||
// Provide knowledge of what datasette JS or server-side via traditional console autocomplete
|
||||
// State helpers: URL params https://github.com/simonw/datasette/issues/1144 and localstorage
|
||||
// UI Hooks: command + k, tab manager hook
|
||||
// Should we notify plugins that have dependencies
|
||||
// when all dependencies were fulfilled? (leaflet, codemirror, etc)
|
||||
// https://github.com/simonw/datasette-leaflet -> this way
|
||||
// multiple plugins can all request the same copy of leaflet.
|
||||
};
|
||||
|
||||
const initializeDatasette = () => {
|
||||
// Hide the global behind __ prefix. Ideally they should be listening for the
|
||||
// DATASETTE_EVENTS.INIT event to avoid the habit of reading from the window.
|
||||
|
||||
window.__DATASETTE__ = datasetteManager;
|
||||
|
||||
const initDatasetteEvent = new CustomEvent(DATASETTE_EVENTS.INIT, {
|
||||
detail: datasetteManager,
|
||||
});
|
||||
|
||||
document.dispatchEvent(initDatasetteEvent);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main function
|
||||
* Fires AFTER the document has been parsed
|
||||
*/
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeDatasette();
|
||||
});
|
||||
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
56
datasette/static/json-format-highlight-1.0.1.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
https://github.com/luyilin/json-format-highlight
|
||||
From https://unpkg.com/json-format-highlight@1.0.1/dist/json-format-highlight.js
|
||||
MIT Licensed
|
||||
*/
|
||||
(function (global, factory) {
|
||||
typeof exports === "object" && typeof module !== "undefined"
|
||||
? (module.exports = factory())
|
||||
: typeof define === "function" && define.amd
|
||||
? define(factory)
|
||||
: (global.jsonFormatHighlight = factory());
|
||||
})(this, function () {
|
||||
"use strict";
|
||||
|
||||
var defaultColors = {
|
||||
keyColor: "dimgray",
|
||||
numberColor: "lightskyblue",
|
||||
stringColor: "lightcoral",
|
||||
trueColor: "lightseagreen",
|
||||
falseColor: "#f66578",
|
||||
nullColor: "cornflowerblue",
|
||||
};
|
||||
|
||||
function index(json, colorOptions) {
|
||||
if (colorOptions === void 0) colorOptions = {};
|
||||
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
if (typeof json !== "string") {
|
||||
json = JSON.stringify(json, null, 2);
|
||||
}
|
||||
var colors = Object.assign({}, defaultColors, colorOptions);
|
||||
json = json.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
return json.replace(
|
||||
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+]?\d+)?)/g,
|
||||
function (match) {
|
||||
var color = colors.numberColor;
|
||||
if (/^"/.test(match)) {
|
||||
color = /:$/.test(match) ? colors.keyColor : colors.stringColor;
|
||||
} else {
|
||||
color = /true/.test(match)
|
||||
? colors.trueColor
|
||||
: /false/.test(match)
|
||||
? colors.falseColor
|
||||
: /null/.test(match)
|
||||
? colors.nullColor
|
||||
: color;
|
||||
}
|
||||
return '<span style="color: ' + color + '">' + match + "</span>";
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return index;
|
||||
});
|
||||
318
datasette/static/mobile-column-actions.js
Normal file
318
datasette/static/mobile-column-actions.js
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
var MOBILE_COLUMN_BREAKPOINT = 576;
|
||||
var MOBILE_COLUMN_DIALOG_ID = "mobile-column-actions-dialog";
|
||||
var MOBILE_COLUMN_DIALOG_TITLE_ID = "mobile-column-actions-title";
|
||||
|
||||
function mobileColumnHeaders(manager) {
|
||||
return Array.from(
|
||||
document.querySelectorAll(manager.selectors.tableHeaders),
|
||||
).filter((th) => th.dataset.column && th.dataset.isLinkColumn !== "1");
|
||||
}
|
||||
|
||||
function mobileColumnMetaText(th) {
|
||||
var parts = [];
|
||||
if (th.dataset.columnType) {
|
||||
parts.push(th.dataset.columnType);
|
||||
}
|
||||
if (th.dataset.isPk === "1") {
|
||||
parts.push("pk");
|
||||
}
|
||||
if (th.dataset.columnNotNull === "1") {
|
||||
parts.push("not null");
|
||||
}
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function createMobileColumnActionNode(itemConfig, closeDialog) {
|
||||
var actionNode;
|
||||
if (itemConfig.href) {
|
||||
actionNode = document.createElement("a");
|
||||
actionNode.href = itemConfig.href;
|
||||
} else {
|
||||
actionNode = document.createElement("button");
|
||||
actionNode.type = "button";
|
||||
}
|
||||
actionNode.textContent = itemConfig.label;
|
||||
|
||||
if (itemConfig.onClick) {
|
||||
actionNode.addEventListener("click", function (ev) {
|
||||
try {
|
||||
itemConfig.onClick.call(actionNode, ev);
|
||||
} finally {
|
||||
closeDialog({ restoreFocus: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return actionNode;
|
||||
}
|
||||
|
||||
function initMobileColumnActions(manager) {
|
||||
var triggerButton = document.querySelector(".column-actions-mobile");
|
||||
if (!triggerButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!window.URLSearchParams ||
|
||||
!window.HTMLDialogElement ||
|
||||
!manager.columnActions
|
||||
) {
|
||||
triggerButton.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mobileColumnHeaders(manager).length) {
|
||||
triggerButton.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = document.createElement("dialog");
|
||||
dialog.className = "mobile-column-actions-dialog";
|
||||
dialog.id = MOBILE_COLUMN_DIALOG_ID;
|
||||
dialog.setAttribute("aria-labelledby", MOBILE_COLUMN_DIALOG_TITLE_ID);
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="${MOBILE_COLUMN_DIALOG_TITLE_ID}">Column actions</span>
|
||||
<span class="modal-meta"></span>
|
||||
</div>
|
||||
<div class="list-wrap mobile-column-list"></div>
|
||||
<div class="modal-footer">
|
||||
<span class="footer-info">Tap a column to reveal actions.</span>
|
||||
<button type="button" class="btn btn-ghost mobile-column-actions-done">Done</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
triggerButton.setAttribute("aria-haspopup", "dialog");
|
||||
triggerButton.setAttribute("aria-controls", MOBILE_COLUMN_DIALOG_ID);
|
||||
triggerButton.setAttribute("aria-expanded", "false");
|
||||
|
||||
var countEl = dialog.querySelector(".modal-meta");
|
||||
var listWrap = dialog.querySelector(".mobile-column-list");
|
||||
var doneButton = dialog.querySelector(".mobile-column-actions-done");
|
||||
var expandedSectionId = null;
|
||||
var shouldRestoreFocus = true;
|
||||
|
||||
function updateExpandedSection() {
|
||||
Array.from(dialog.querySelectorAll(".col-header")).forEach((button) => {
|
||||
var controlsId = button.getAttribute("aria-controls");
|
||||
var actionList = dialog.querySelector("#" + controlsId);
|
||||
var isExpanded = controlsId === expandedSectionId;
|
||||
button.setAttribute("aria-expanded", isExpanded ? "true" : "false");
|
||||
actionList.hidden = !isExpanded;
|
||||
actionList.classList.toggle("expanded", isExpanded);
|
||||
});
|
||||
}
|
||||
|
||||
function scrollExpandedSectionIntoView(section) {
|
||||
var sectionTop = section.offsetTop;
|
||||
var sectionBottom = sectionTop + section.offsetHeight;
|
||||
var visibleTop = listWrap.scrollTop;
|
||||
var visibleBottom = visibleTop + listWrap.clientHeight;
|
||||
var sectionHeight = section.offsetHeight;
|
||||
|
||||
if (sectionTop < visibleTop) {
|
||||
listWrap.scrollTop = sectionTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (sectionBottom <= visibleBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sectionHeight <= listWrap.clientHeight) {
|
||||
listWrap.scrollTop = sectionBottom - listWrap.clientHeight;
|
||||
} else {
|
||||
listWrap.scrollTop = sectionTop;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog(options) {
|
||||
options = options || {};
|
||||
shouldRestoreFocus = options.restoreFocus !== false;
|
||||
if (dialog.open) {
|
||||
dialog.close();
|
||||
} else {
|
||||
triggerButton.setAttribute("aria-expanded", "false");
|
||||
if (shouldRestoreFocus) {
|
||||
triggerButton.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
var headers = mobileColumnHeaders(manager);
|
||||
if (!headers.length) {
|
||||
closeDialog({ restoreFocus: false });
|
||||
triggerButton.style.display = "none";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!headers.some(
|
||||
(_th, index) => `mobile-column-actions-${index}` === expandedSectionId,
|
||||
)
|
||||
) {
|
||||
expandedSectionId = null;
|
||||
}
|
||||
|
||||
countEl.textContent = `${headers.length} column${
|
||||
headers.length === 1 ? "" : "s"
|
||||
}`;
|
||||
listWrap.innerHTML = "";
|
||||
|
||||
if (manager.columnActions.shouldShowShowAllColumns()) {
|
||||
var topActions = document.createElement("div");
|
||||
topActions.className = "mobile-column-top-actions";
|
||||
|
||||
var showAllColumns = document.createElement("a");
|
||||
showAllColumns.className = "btn btn-ghost mobile-column-top-action";
|
||||
showAllColumns.href = manager.columnActions.showAllColumnsUrl();
|
||||
showAllColumns.textContent = "Show all columns";
|
||||
|
||||
topActions.appendChild(showAllColumns);
|
||||
listWrap.appendChild(topActions);
|
||||
}
|
||||
|
||||
headers.forEach((th, index) => {
|
||||
var sectionId = `mobile-column-actions-${index}`;
|
||||
var actionState = manager.columnActions.buildColumnActionState(th, {
|
||||
includeChooseColumns: false,
|
||||
includeShowAllColumns: false,
|
||||
});
|
||||
var section = document.createElement("section");
|
||||
section.className = "mobile-column-section";
|
||||
|
||||
var headerButton = document.createElement("button");
|
||||
headerButton.type = "button";
|
||||
headerButton.className = "col-header";
|
||||
headerButton.setAttribute("aria-controls", sectionId);
|
||||
headerButton.setAttribute("aria-expanded", "false");
|
||||
|
||||
var headerText = document.createElement("span");
|
||||
headerText.className = "mobile-column-header-text";
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "mobile-column-name";
|
||||
name.textContent = th.dataset.column;
|
||||
headerText.appendChild(name);
|
||||
|
||||
var metaText = mobileColumnMetaText(th);
|
||||
if (metaText) {
|
||||
var meta = document.createElement("span");
|
||||
meta.className = "mobile-column-meta";
|
||||
meta.textContent = metaText;
|
||||
headerText.appendChild(meta);
|
||||
}
|
||||
|
||||
var chevron = document.createElement("span");
|
||||
chevron.className = "mobile-column-chevron";
|
||||
chevron.setAttribute("aria-hidden", "true");
|
||||
chevron.textContent = "▾";
|
||||
|
||||
headerButton.appendChild(headerText);
|
||||
headerButton.appendChild(chevron);
|
||||
headerButton.addEventListener("click", function () {
|
||||
expandedSectionId = expandedSectionId === sectionId ? null : sectionId;
|
||||
updateExpandedSection();
|
||||
if (expandedSectionId === sectionId) {
|
||||
scrollExpandedSectionIntoView(section);
|
||||
}
|
||||
});
|
||||
|
||||
var actionContainer = document.createElement("div");
|
||||
actionContainer.id = sectionId;
|
||||
actionContainer.className = "col-actions";
|
||||
actionContainer.hidden = true;
|
||||
|
||||
if (actionState.columnDescription) {
|
||||
var description = document.createElement("p");
|
||||
description.className = "mobile-column-description";
|
||||
description.textContent = actionState.columnDescription;
|
||||
actionContainer.appendChild(description);
|
||||
}
|
||||
|
||||
if (actionState.actionItems.length) {
|
||||
var actionList = document.createElement("ul");
|
||||
actionState.actionItems.forEach((itemConfig) => {
|
||||
var actionItem = document.createElement("li");
|
||||
actionItem.appendChild(
|
||||
createMobileColumnActionNode(itemConfig, closeDialog),
|
||||
);
|
||||
actionList.appendChild(actionItem);
|
||||
});
|
||||
actionContainer.appendChild(actionList);
|
||||
} else {
|
||||
var noActions = document.createElement("p");
|
||||
noActions.className = "mobile-column-no-actions";
|
||||
noActions.textContent = "No actions available";
|
||||
actionContainer.appendChild(noActions);
|
||||
}
|
||||
|
||||
section.appendChild(headerButton);
|
||||
section.appendChild(actionContainer);
|
||||
listWrap.appendChild(section);
|
||||
});
|
||||
|
||||
updateExpandedSection();
|
||||
return true;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT) {
|
||||
return;
|
||||
}
|
||||
if (!renderDialog()) {
|
||||
return;
|
||||
}
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
triggerButton.setAttribute("aria-expanded", "true");
|
||||
var focusTarget =
|
||||
dialog.querySelector(".mobile-column-top-action") ||
|
||||
dialog.querySelector(".col-header") ||
|
||||
doneButton;
|
||||
focusTarget.focus();
|
||||
}
|
||||
|
||||
triggerButton.addEventListener("click", function () {
|
||||
if (dialog.open) {
|
||||
closeDialog();
|
||||
} else {
|
||||
openDialog();
|
||||
}
|
||||
});
|
||||
|
||||
doneButton.addEventListener("click", function () {
|
||||
closeDialog();
|
||||
});
|
||||
|
||||
dialog.addEventListener("click", function (ev) {
|
||||
if (ev.target === dialog) {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", function (ev) {
|
||||
ev.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", function () {
|
||||
triggerButton.setAttribute("aria-expanded", "false");
|
||||
if (shouldRestoreFocus) {
|
||||
triggerButton.focus();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
if (window.innerWidth > MOBILE_COLUMN_BREAKPOINT && dialog.open) {
|
||||
closeDialog({ restoreFocus: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("datasette_init", function (evt) {
|
||||
initMobileColumnActions(evt.detail);
|
||||
});
|
||||
910
datasette/static/navigation-search.js
Normal file
910
datasette/static/navigation-search.js
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
let navigationSearchInstanceCounter = 0;
|
||||
|
||||
class NavigationSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.instanceId = ++navigationSearchInstanceCounter;
|
||||
this.inputId = `navigation-search-input-${this.instanceId}`;
|
||||
this.instructionsId = `navigation-search-instructions-${this.instanceId}`;
|
||||
this.listboxId = `navigation-search-results-${this.instanceId}`;
|
||||
this.recentHeadingId = `navigation-search-recent-${this.instanceId}`;
|
||||
this.statusId = `navigation-search-status-${this.instanceId}`;
|
||||
this.titleId = `navigation-search-title-${this.instanceId}`;
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.selectedIndex = -1;
|
||||
this.matches = [];
|
||||
this.renderedMatches = [];
|
||||
this.debounceTimer = null;
|
||||
this.restoreFocusTarget = null;
|
||||
this.shouldRestoreFocus = true;
|
||||
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
max-width: 90vw;
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: slideIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: fadeIn var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.close-search {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
font: inherit;
|
||||
font-size: 1.5rem;
|
||||
height: 2.75rem;
|
||||
line-height: 1;
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.close-search:hover,
|
||||
.close-search:focus {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
overflow-y: auto;
|
||||
height: calc(80vh - 180px);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.results-list:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 0.875rem 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.result-item.selected {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.result-item > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-start-content {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.jump-start-content:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.result-description {
|
||||
color: #374151;
|
||||
display: -webkit-box;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
margin-top: 0.35rem;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.recent-actions {
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.clear-recent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clear-recent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-text kbd {
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||
<div class="search-container">
|
||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
||||
<div id="${this.statusId}" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
|
||||
<div class="search-input-wrapper">
|
||||
<input
|
||||
id="${this.inputId}"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Jump to..."
|
||||
aria-label="Jump to"
|
||||
aria-describedby="${this.instructionsId}"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="${this.listboxId}"
|
||||
aria-expanded="false"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
>
|
||||
<button type="button" class="close-search" aria-label="Close jump menu">×</button>
|
||||
</div>
|
||||
<div class="results-container"></div>
|
||||
<div class="hint-text">
|
||||
<span><kbd>↑</kbd> <kbd>↓</kbd> Navigate</span>
|
||||
<span><kbd>Enter</kbd> Select</span>
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const closeButton = this.shadowRoot.querySelector(".close-search");
|
||||
const resultsContainer =
|
||||
this.shadowRoot.querySelector(".results-container");
|
||||
|
||||
// Global keyboard listener for "/"
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "/" && !this.isInputFocused() && !dialog.open) {
|
||||
e.preventDefault();
|
||||
this.openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const trigger = e.target.closest("[data-navigation-search-open]");
|
||||
if (trigger) {
|
||||
e.preventDefault();
|
||||
const details = trigger.closest("details");
|
||||
const restoreTarget = details?.querySelector("summary") || trigger;
|
||||
details?.removeAttribute("open");
|
||||
this.openMenu(restoreTarget);
|
||||
}
|
||||
});
|
||||
|
||||
// Input event
|
||||
input.addEventListener("input", (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.moveSelection(-1);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this.selectCurrentItem();
|
||||
} else if (e.key === "Escape") {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
closeButton.addEventListener("click", () => {
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
// Click on result item
|
||||
resultsContainer.addEventListener("click", (e) => {
|
||||
const clearRecent = e.target.closest("[data-clear-recent-items]");
|
||||
if (clearRecent) {
|
||||
e.preventDefault();
|
||||
this.clearRecentItems();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = e.target.closest(".result-item");
|
||||
if (item) {
|
||||
const index = parseInt(item.dataset.index);
|
||||
this.selectItem(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Close on backdrop click
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", (e) => {
|
||||
e.preventDefault();
|
||||
this.closeMenu();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", () => {
|
||||
this.onMenuClosed();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
return (
|
||||
activeElement &&
|
||||
(activeElement.tagName === "INPUT" ||
|
||||
activeElement.tagName === "TEXTAREA" ||
|
||||
activeElement.isContentEditable)
|
||||
);
|
||||
}
|
||||
|
||||
setElementAttribute(element, name, value) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (typeof element.setAttribute === "function") {
|
||||
element.setAttribute(name, value);
|
||||
} else {
|
||||
element[name] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
removeElementAttribute(element, name) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (typeof element.removeAttribute === "function") {
|
||||
element.removeAttribute(name);
|
||||
} else {
|
||||
delete element[name];
|
||||
}
|
||||
}
|
||||
|
||||
focusRestoreTarget(trigger) {
|
||||
if (trigger && typeof trigger.focus === "function") {
|
||||
return trigger;
|
||||
}
|
||||
if (
|
||||
document.activeElement &&
|
||||
typeof document.activeElement.focus === "function"
|
||||
) {
|
||||
return document.activeElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setNavigationTriggersExpanded(expanded) {
|
||||
if (typeof document.querySelectorAll !== "function") {
|
||||
return;
|
||||
}
|
||||
document
|
||||
.querySelectorAll("[data-navigation-search-open]")
|
||||
.forEach((trigger) => {
|
||||
this.setElementAttribute(
|
||||
trigger,
|
||||
"aria-expanded",
|
||||
expanded ? "true" : "false",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
resultOptionId(index) {
|
||||
return `${this.listboxId}-option-${index}`;
|
||||
}
|
||||
|
||||
updateComboboxState() {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const matches = this.renderedMatches || [];
|
||||
this.setElementAttribute(
|
||||
input,
|
||||
"aria-expanded",
|
||||
dialog && dialog.open && matches.length > 0 ? "true" : "false",
|
||||
);
|
||||
|
||||
if (
|
||||
dialog &&
|
||||
dialog.open &&
|
||||
this.selectedIndex >= 0 &&
|
||||
this.selectedIndex < matches.length
|
||||
) {
|
||||
this.setElementAttribute(
|
||||
input,
|
||||
"aria-activedescendant",
|
||||
this.resultOptionId(this.selectedIndex),
|
||||
);
|
||||
} else {
|
||||
this.removeElementAttribute(input, "aria-activedescendant");
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(message) {
|
||||
const status = this.shadowRoot.querySelector(`#${this.statusId}`);
|
||||
if (status) {
|
||||
status.textContent = message || "";
|
||||
}
|
||||
}
|
||||
|
||||
resultsStatus(count, truncated) {
|
||||
if (truncated) {
|
||||
return "More than 100 results. Keep typing to narrow the list.";
|
||||
}
|
||||
if (count === 0) {
|
||||
return "No results found.";
|
||||
}
|
||||
if (count === 1) {
|
||||
return "1 result.";
|
||||
}
|
||||
return `${count} results.`;
|
||||
}
|
||||
|
||||
loadInitialData() {
|
||||
const itemsAttr = this.getAttribute("items");
|
||||
if (itemsAttr) {
|
||||
try {
|
||||
this.allItems = JSON.parse(itemsAttr);
|
||||
this.matches = this.allItems;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse items attribute:", e);
|
||||
this.allItems = [];
|
||||
this.matches = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
if (query.trim()) {
|
||||
this.setStatus("Searching...");
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
const url = this.getAttribute("url");
|
||||
|
||||
if (url) {
|
||||
// Fetch from API
|
||||
this.fetchResults(url, query);
|
||||
} else {
|
||||
// Filter local items
|
||||
this.filterLocalItems(query);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async fetchResults(url, query) {
|
||||
try {
|
||||
const searchUrl = `${url}?q=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(searchUrl);
|
||||
const data = await response.json();
|
||||
this.matches = data.matches || [];
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
if (query.trim()) {
|
||||
this.setStatus(this.resultsStatus(this.matches.length, data.truncated));
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch search results:", e);
|
||||
this.matches = [];
|
||||
this.renderResults();
|
||||
this.setStatus("Search failed.");
|
||||
}
|
||||
}
|
||||
|
||||
filterLocalItems(query) {
|
||||
if (!query.trim()) {
|
||||
this.matches = this.allItems || [];
|
||||
} else {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
this.matches = (this.allItems || []).filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
(item.display_name || "").toLowerCase().includes(lowerQuery) ||
|
||||
item.url.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}
|
||||
this.selectedIndex = this.matches.length > 0 ? 0 : -1;
|
||||
this.renderResults();
|
||||
if (query.trim()) {
|
||||
this.setStatus(this.resultsStatus(this.matches.length, false));
|
||||
} else {
|
||||
this.setStatus("");
|
||||
}
|
||||
}
|
||||
|
||||
recentItemsStorageKey() {
|
||||
return "datasette.navigationSearch.recentItems";
|
||||
}
|
||||
|
||||
loadRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(this.recentItemsStorageKey());
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.filter((item) => item && item.name && item.url)
|
||||
.map((item) => ({
|
||||
name: String(item.name),
|
||||
display_name: item.display_name ? String(item.display_name) : "",
|
||||
url: String(item.url),
|
||||
type: item.type ? String(item.type) : "",
|
||||
description: item.description ? String(item.description) : "",
|
||||
}))
|
||||
.slice(0, 5);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
saveRecentItem(match) {
|
||||
if (
|
||||
typeof localStorage === "undefined" ||
|
||||
!match ||
|
||||
!match.name ||
|
||||
!match.url
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const item = {
|
||||
name: String(match.name),
|
||||
display_name: match.display_name ? String(match.display_name) : "",
|
||||
url: String(match.url),
|
||||
type: match.type ? String(match.type) : "",
|
||||
description: match.description ? String(match.description) : "",
|
||||
};
|
||||
const recentItems = this.loadRecentItems().filter(
|
||||
(recentItem) => recentItem.url !== item.url,
|
||||
);
|
||||
localStorage.setItem(
|
||||
this.recentItemsStorageKey(),
|
||||
JSON.stringify([item, ...recentItems].slice(0, 5)),
|
||||
);
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable, full, or disabled.
|
||||
}
|
||||
}
|
||||
|
||||
clearRecentItems() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.recentItemsStorageKey());
|
||||
} catch (e) {
|
||||
localStorage.setItem(this.recentItemsStorageKey(), "[]");
|
||||
}
|
||||
this.renderResults();
|
||||
this.setStatus("Recent items cleared.");
|
||||
}
|
||||
|
||||
jumpSections() {
|
||||
const manager = window.__DATASETTE__;
|
||||
if (!manager || typeof manager.makeJumpSections !== "function") {
|
||||
return [];
|
||||
}
|
||||
const sections = manager.makeJumpSections({
|
||||
navigationSearch: this,
|
||||
});
|
||||
return Array.isArray(sections)
|
||||
? sections.filter(
|
||||
(section) => section && typeof section.render === "function",
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
jumpSectionsHtml(jumpSections) {
|
||||
return jumpSections
|
||||
.map((section, index) => {
|
||||
const id = section.id
|
||||
? ` data-jump-section-id="${this.escapeHtml(section.id)}"`
|
||||
: "";
|
||||
return `<div class="jump-start-content" data-jump-section-index="${index}"${id}></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
renderJumpSections(container, jumpSections) {
|
||||
jumpSections.forEach((section, index) => {
|
||||
const node = container.querySelector(
|
||||
`[data-jump-section-index="${index}"]`,
|
||||
);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
section.render(node, {
|
||||
navigationSearch: this,
|
||||
container,
|
||||
input: this.shadowRoot.querySelector(".search-input"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
resultItemHtml(match, index) {
|
||||
const displayName = match.display_name || match.name;
|
||||
const label =
|
||||
match.display_name && match.display_name !== match.name
|
||||
? `<div class="result-label">${this.escapeHtml(match.name)}</div>`
|
||||
: "";
|
||||
const type = match.type
|
||||
? `<div class="result-type">${this.escapeHtml(match.type)}</div>`
|
||||
: "";
|
||||
const description = match.description
|
||||
? `<div class="result-description">${this.escapeHtml(
|
||||
match.description,
|
||||
)}</div>`
|
||||
: "";
|
||||
return `
|
||||
<div
|
||||
id="${this.resultOptionId(index)}"
|
||||
class="result-item ${index === this.selectedIndex ? "selected" : ""}"
|
||||
data-index="${index}"
|
||||
role="option"
|
||||
aria-selected="${index === this.selectedIndex}"
|
||||
>
|
||||
<div>
|
||||
${type}
|
||||
<div class="result-name">${this.escapeHtml(displayName)}</div>
|
||||
${label}
|
||||
<div class="result-url">${this.escapeHtml(match.url)}</div>
|
||||
${description}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderResults() {
|
||||
const container = this.shadowRoot.querySelector(".results-container");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
const showStartContent = !input.value.trim();
|
||||
const jumpSections = showStartContent ? this.jumpSections() : [];
|
||||
const startBlock = showStartContent
|
||||
? this.jumpSectionsHtml(jumpSections)
|
||||
: "";
|
||||
const recentItems = showStartContent ? this.loadRecentItems() : [];
|
||||
const defaultMatches = showStartContent ? [] : this.matches;
|
||||
const renderedMatches = [...recentItems, ...defaultMatches];
|
||||
this.renderedMatches = renderedMatches;
|
||||
const emptyListbox = `<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results"></div>`;
|
||||
|
||||
if (renderedMatches.length) {
|
||||
if (
|
||||
this.selectedIndex < 0 ||
|
||||
this.selectedIndex >= renderedMatches.length
|
||||
) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
} else {
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
if (renderedMatches.length === 0) {
|
||||
if (startBlock) {
|
||||
container.innerHTML = startBlock + emptyListbox;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
} else if (showStartContent) {
|
||||
container.innerHTML = emptyListbox;
|
||||
} else {
|
||||
const message = input.value.trim()
|
||||
? "No results found"
|
||||
: "Start typing to search...";
|
||||
container.innerHTML = `${emptyListbox}<div class="no-results">${message}</div>`;
|
||||
}
|
||||
this.updateComboboxState();
|
||||
return;
|
||||
}
|
||||
|
||||
const recentHeading = recentItems.length
|
||||
? `<div class="results-heading" id="${this.recentHeadingId}">Recent</div>`
|
||||
: "";
|
||||
const recentGroup = recentItems.length
|
||||
? `<div role="group" aria-labelledby="${this.recentHeadingId}">${recentItems
|
||||
.map((match, index) => this.resultItemHtml(match, index))
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
const recentActions = recentItems.length
|
||||
? `<div class="recent-actions"><button type="button" class="clear-recent" data-clear-recent-items>Clear recent</button></div>`
|
||||
: "";
|
||||
const defaultHtml = defaultMatches
|
||||
.map((match, index) =>
|
||||
this.resultItemHtml(match, recentItems.length + index),
|
||||
)
|
||||
.join("");
|
||||
container.innerHTML =
|
||||
startBlock +
|
||||
recentHeading +
|
||||
`<div id="${this.listboxId}" class="results-list" role="listbox" aria-label="Jump results">${recentGroup}${defaultHtml}</div>` +
|
||||
recentActions;
|
||||
this.renderJumpSections(container, jumpSections);
|
||||
this.updateComboboxState();
|
||||
|
||||
// Scroll selected item into view
|
||||
if (this.selectedIndex >= 0) {
|
||||
const selectedItem = container.querySelector(
|
||||
`.result-item[data-index="${this.selectedIndex}"]`,
|
||||
);
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveSelection(direction) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const newIndex = this.selectedIndex + direction;
|
||||
if (newIndex >= 0 && newIndex < matches.length) {
|
||||
this.selectedIndex = newIndex;
|
||||
this.renderResults();
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrentItem() {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < matches.length) {
|
||||
this.selectItem(this.selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(index) {
|
||||
const matches = this.renderedMatches || this.matches;
|
||||
const match = matches[index];
|
||||
if (match) {
|
||||
this.saveRecentItem(match);
|
||||
|
||||
// Dispatch custom event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
detail: match,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Navigate to URL
|
||||
window.location.href = match.url;
|
||||
|
||||
this.closeMenu({ restoreFocus: false });
|
||||
}
|
||||
}
|
||||
|
||||
openMenu(trigger) {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
|
||||
this.restoreFocusTarget = this.focusRestoreTarget(trigger);
|
||||
this.shouldRestoreFocus = true;
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
this.setNavigationTriggersExpanded(true);
|
||||
input.value = "";
|
||||
input.focus();
|
||||
|
||||
// Reset state, then populate the default jump list.
|
||||
this.matches = [];
|
||||
this.selectedIndex = -1;
|
||||
this.renderResults();
|
||||
this.setStatus("");
|
||||
}
|
||||
|
||||
closeMenu(options = {}) {
|
||||
const dialog = this.shadowRoot.querySelector("dialog");
|
||||
this.shouldRestoreFocus = options.restoreFocus !== false;
|
||||
if (dialog.open) {
|
||||
dialog.close();
|
||||
} else {
|
||||
this.onMenuClosed();
|
||||
}
|
||||
}
|
||||
|
||||
onMenuClosed() {
|
||||
const input = this.shadowRoot.querySelector(".search-input");
|
||||
this.setElementAttribute(input, "aria-expanded", "false");
|
||||
this.removeElementAttribute(input, "aria-activedescendant");
|
||||
this.setNavigationTriggersExpanded(false);
|
||||
this.setStatus("");
|
||||
if (
|
||||
this.shouldRestoreFocus &&
|
||||
this.restoreFocusTarget &&
|
||||
typeof this.restoreFocusTarget.focus === "function"
|
||||
) {
|
||||
this.restoreFocusTarget.focus();
|
||||
}
|
||||
this.restoreFocusTarget = null;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("navigation-search", NavigationSearch);
|
||||
|
|
@ -1,13 +1,6 @@
|
|||
var DROPDOWN_HTML = `<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul>
|
||||
<li><a class="dropdown-sort-asc" href="#">Sort ascending</a></li>
|
||||
<li><a class="dropdown-sort-desc" href="#">Sort descending</a></li>
|
||||
<li><a class="dropdown-facet" href="#">Facet by this</a></li>
|
||||
<li><a class="dropdown-hide-column" href="#">Hide this column</a></li>
|
||||
<li><a class="dropdown-show-all-columns" href="#">Show all columns</a></li>
|
||||
<li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li>
|
||||
</ul>
|
||||
<ul class="dropdown-actions"></ul>
|
||||
<p class="dropdown-column-type"></p>
|
||||
<p class="dropdown-column-description"></p>
|
||||
</div>`;
|
||||
|
|
@ -17,64 +10,518 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>`;
|
||||
|
||||
(function () {
|
||||
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
||||
var setColumnTypeDialogState = null;
|
||||
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
||||
function paramsToUrl(params) {
|
||||
var s = params.toString();
|
||||
return s ? "?" + s : location.pathname;
|
||||
}
|
||||
|
||||
function sortDescUrl(column) {
|
||||
var params = getParams();
|
||||
params.set("_sort_desc", column);
|
||||
params.delete("_sort");
|
||||
params.delete("_next");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function sortAscUrl(column) {
|
||||
var params = getParams();
|
||||
params.set("_sort", column);
|
||||
params.delete("_sort_desc");
|
||||
params.delete("_next");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function facetUrl(column) {
|
||||
var params = getParams();
|
||||
params.append("_facet", column);
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function hideColumnUrl(column) {
|
||||
var params = getParams();
|
||||
params.append("_nocol", column);
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function showAllColumnsUrl() {
|
||||
var params = getParams();
|
||||
params.delete("_nocol");
|
||||
params.delete("_col");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function notBlankUrl(column) {
|
||||
var params = getParams();
|
||||
params.set(`${column}__notblank`, "1");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
|
||||
function getDisplayedFacets() {
|
||||
return Array.from(document.querySelectorAll(".facet-info")).map(
|
||||
(el) => el.dataset.column,
|
||||
);
|
||||
}
|
||||
|
||||
function getColumnClassName(th) {
|
||||
return Array.from(th.classList).find((className) =>
|
||||
className.startsWith("col-"),
|
||||
);
|
||||
}
|
||||
|
||||
function getColumnCells(th) {
|
||||
var table = th.closest("table");
|
||||
var columnClassName = getColumnClassName(th);
|
||||
if (!table || !columnClassName) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(table.querySelectorAll("td." + columnClassName));
|
||||
}
|
||||
|
||||
function getColumnMeta(th) {
|
||||
return {
|
||||
columnName: th.dataset.column,
|
||||
columnNotNull: th.dataset.columnNotNull === "1",
|
||||
columnType: th.dataset.columnType,
|
||||
isPk: th.dataset.isPk === "1",
|
||||
};
|
||||
}
|
||||
|
||||
function getColumnTypeText(th) {
|
||||
var columnType = th.dataset.columnType;
|
||||
if (!columnType) {
|
||||
return null;
|
||||
}
|
||||
var notNull = th.dataset.columnNotNull === "1" ? " NOT NULL" : "";
|
||||
return `Type: ${columnType.toUpperCase()}${notNull}`;
|
||||
}
|
||||
|
||||
function getSetColumnTypeData() {
|
||||
return window._setColumnTypeData || null;
|
||||
}
|
||||
|
||||
function getSetColumnTypeConfig(column) {
|
||||
var data = getSetColumnTypeData();
|
||||
if (!data || !data.columns) {
|
||||
return null;
|
||||
}
|
||||
return data.columns[column] || null;
|
||||
}
|
||||
|
||||
function canSetColumnType() {
|
||||
return !!(getSetColumnTypeData() && window.HTMLDialogElement && window.fetch);
|
||||
}
|
||||
|
||||
function setColumnTypeActionLabel(column) {
|
||||
var columnConfig = getSetColumnTypeConfig(column);
|
||||
if (!columnConfig) {
|
||||
return null;
|
||||
}
|
||||
return columnConfig.current
|
||||
? `Custom type: ${columnConfig.current.type}`
|
||||
: "Set custom type";
|
||||
}
|
||||
|
||||
function createSetColumnTypeOption(value, name, description, checked) {
|
||||
var label = document.createElement("label");
|
||||
label.className = "set-column-type-option";
|
||||
|
||||
var input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "set-column-type-choice";
|
||||
input.value = value;
|
||||
input.checked = checked;
|
||||
|
||||
var content = document.createElement("span");
|
||||
content.className = "set-column-type-option-content";
|
||||
|
||||
var title = document.createElement("span");
|
||||
title.className = "set-column-type-option-name";
|
||||
title.textContent = name;
|
||||
|
||||
var detail = document.createElement("span");
|
||||
detail.className = "set-column-type-option-description";
|
||||
detail.textContent = description;
|
||||
|
||||
content.appendChild(title);
|
||||
content.appendChild(detail);
|
||||
label.appendChild(input);
|
||||
label.appendChild(content);
|
||||
return label;
|
||||
}
|
||||
|
||||
function setSetColumnTypeDialogBusy(state, isBusy) {
|
||||
state.isBusy = isBusy;
|
||||
state.saveButton.disabled = isBusy;
|
||||
state.cancelButton.disabled = isBusy;
|
||||
Array.from(
|
||||
state.optionsWrap.querySelectorAll('input[name="set-column-type-choice"]'),
|
||||
).forEach(function (input) {
|
||||
input.disabled = isBusy;
|
||||
});
|
||||
state.saveButton.textContent = isBusy ? "Saving..." : "Save";
|
||||
}
|
||||
|
||||
function clearSetColumnTypeDialogError(state) {
|
||||
state.error.hidden = true;
|
||||
state.error.textContent = "";
|
||||
}
|
||||
|
||||
function showSetColumnTypeDialogError(state, message) {
|
||||
state.error.hidden = false;
|
||||
state.error.textContent = message;
|
||||
}
|
||||
|
||||
function ensureSetColumnTypeDialog() {
|
||||
if (setColumnTypeDialogState) {
|
||||
return setColumnTypeDialogState;
|
||||
}
|
||||
if (!window.HTMLDialogElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var dialog = document.createElement("dialog");
|
||||
dialog.id = SET_COLUMN_TYPE_DIALOG_ID;
|
||||
dialog.className = "set-column-type-dialog";
|
||||
dialog.setAttribute("aria-labelledby", "set-column-type-title");
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-header">
|
||||
<span class="modal-title" id="set-column-type-title">Set custom type</span>
|
||||
<span class="modal-meta"></span>
|
||||
</div>
|
||||
<p class="set-column-type-status"></p>
|
||||
<p class="set-column-type-error" hidden></p>
|
||||
<div class="set-column-type-options"></div>
|
||||
<div class="modal-footer">
|
||||
<span class="footer-info"></span>
|
||||
<button type="button" class="btn btn-ghost set-column-type-cancel">Cancel</button>
|
||||
<button type="button" class="btn btn-primary set-column-type-save">Save</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
setColumnTypeDialogState = {
|
||||
dialog: dialog,
|
||||
meta: dialog.querySelector(".modal-meta"),
|
||||
status: dialog.querySelector(".set-column-type-status"),
|
||||
error: dialog.querySelector(".set-column-type-error"),
|
||||
optionsWrap: dialog.querySelector(".set-column-type-options"),
|
||||
footerInfo: dialog.querySelector(".footer-info"),
|
||||
cancelButton: dialog.querySelector(".set-column-type-cancel"),
|
||||
saveButton: dialog.querySelector(".set-column-type-save"),
|
||||
currentColumn: null,
|
||||
currentConfig: null,
|
||||
isBusy: false,
|
||||
};
|
||||
|
||||
setColumnTypeDialogState.cancelButton.addEventListener("click", function () {
|
||||
if (!setColumnTypeDialogState.isBusy) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("click", function (ev) {
|
||||
if (ev.target === dialog && !setColumnTypeDialogState.isBusy) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", function (ev) {
|
||||
if (setColumnTypeDialogState.isBusy) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", function () {
|
||||
clearSetColumnTypeDialogError(setColumnTypeDialogState);
|
||||
setSetColumnTypeDialogBusy(setColumnTypeDialogState, false);
|
||||
});
|
||||
|
||||
setColumnTypeDialogState.saveButton.addEventListener("click", async function () {
|
||||
var state = setColumnTypeDialogState;
|
||||
var selected = state.dialog.querySelector(
|
||||
'input[name="set-column-type-choice"]:checked',
|
||||
);
|
||||
var selectedType = selected ? selected.value : "";
|
||||
var currentType = state.currentConfig.current
|
||||
? state.currentConfig.current.type
|
||||
: "";
|
||||
|
||||
if (selectedType === currentType) {
|
||||
state.dialog.close();
|
||||
return;
|
||||
}
|
||||
|
||||
clearSetColumnTypeDialogError(state);
|
||||
setSetColumnTypeDialogBusy(state, true);
|
||||
|
||||
var payload = {
|
||||
column: state.currentColumn,
|
||||
column_type: selectedType ? { type: selectedType } : null,
|
||||
};
|
||||
|
||||
try {
|
||||
var response = await fetch(getSetColumnTypeData().path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
var data = await response.json();
|
||||
if (!response.ok || data.ok === false) {
|
||||
var message = (data.errors || ["Request failed"]).join(" ");
|
||||
throw new Error(message);
|
||||
}
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
setSetColumnTypeDialogBusy(state, false);
|
||||
showSetColumnTypeDialogError(state, error.message || "Request failed");
|
||||
}
|
||||
});
|
||||
|
||||
return setColumnTypeDialogState;
|
||||
}
|
||||
|
||||
function openSetColumnTypeDialog(th) {
|
||||
var column = th.dataset.column;
|
||||
var columnConfig = getSetColumnTypeConfig(column);
|
||||
if (!columnConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
var state = ensureSetColumnTypeDialog();
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSetColumnTypeDialogError(state);
|
||||
setSetColumnTypeDialogBusy(state, false);
|
||||
state.currentColumn = column;
|
||||
state.currentConfig = columnConfig;
|
||||
state.status.textContent = `Column: ${column}`;
|
||||
state.meta.textContent = getColumnTypeText(th) || "Type unavailable";
|
||||
state.footerInfo.textContent = columnConfig.current
|
||||
? `Current custom type: ${columnConfig.current.type}`
|
||||
: "No custom type set.";
|
||||
state.optionsWrap.innerHTML = "";
|
||||
|
||||
var currentType = columnConfig.current ? columnConfig.current.type : "";
|
||||
state.optionsWrap.appendChild(
|
||||
createSetColumnTypeOption(
|
||||
"",
|
||||
"No custom type",
|
||||
"Use standard Datasette rendering without a custom type.",
|
||||
currentType === "",
|
||||
),
|
||||
);
|
||||
|
||||
columnConfig.options.forEach(function (option) {
|
||||
state.optionsWrap.appendChild(
|
||||
createSetColumnTypeOption(
|
||||
option.name,
|
||||
option.name,
|
||||
option.description,
|
||||
option.name === currentType,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (!columnConfig.options.length) {
|
||||
var emptyState = document.createElement("p");
|
||||
emptyState.className = "set-column-type-empty";
|
||||
emptyState.textContent =
|
||||
"No registered custom types are compatible with this SQLite type.";
|
||||
state.optionsWrap.appendChild(emptyState);
|
||||
}
|
||||
|
||||
if (!state.dialog.open) {
|
||||
state.dialog.showModal();
|
||||
}
|
||||
var selectedOption = state.dialog.querySelector(
|
||||
'input[name="set-column-type-choice"]:checked',
|
||||
);
|
||||
if (selectedOption) {
|
||||
selectedOption.focus();
|
||||
} else {
|
||||
state.saveButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function canChooseColumns() {
|
||||
return !!(
|
||||
document.querySelector("column-chooser") && window._columnChooserData
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowShowAllColumns() {
|
||||
var params = getParams();
|
||||
return params.getAll("_nocol").length || params.getAll("_col").length;
|
||||
}
|
||||
|
||||
function hasMultipleVisibleColumns(manager) {
|
||||
return (
|
||||
Array.from(document.querySelectorAll(manager.selectors.tableHeaders)).filter(
|
||||
(th) => th.dataset.column && th.dataset.isLinkColumn !== "1",
|
||||
).length > 1
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumnActionItems(manager, th, options) {
|
||||
options = options || {};
|
||||
var params = getParams();
|
||||
var column = th.dataset.column;
|
||||
var columnActions = [];
|
||||
var isSortable = !!th.querySelector("a");
|
||||
var isFirstColumn = th.parentElement.querySelector("th:first-of-type") === th;
|
||||
var isSinglePk =
|
||||
th.dataset.isPk === "1" &&
|
||||
document.querySelectorAll('th[data-is-pk="1"]').length === 1;
|
||||
var hasBlankValues = getColumnCells(th).some(
|
||||
(el) => el.innerText.trim() === "",
|
||||
);
|
||||
|
||||
if (isSortable && params.get("_sort") !== column) {
|
||||
columnActions.push({
|
||||
label: "Sort ascending",
|
||||
href: sortAscUrl(column),
|
||||
});
|
||||
}
|
||||
|
||||
if (isSortable && params.get("_sort_desc") !== column) {
|
||||
columnActions.push({
|
||||
label: "Sort descending",
|
||||
href: sortDescUrl(column),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
DATASETTE_ALLOW_FACET &&
|
||||
!isFirstColumn &&
|
||||
!getDisplayedFacets().includes(column) &&
|
||||
!isSinglePk
|
||||
) {
|
||||
columnActions.push({
|
||||
label: "Facet by this",
|
||||
href: facetUrl(column),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeChooseColumns && canChooseColumns()) {
|
||||
columnActions.push({
|
||||
label: "Choose columns",
|
||||
href: "#",
|
||||
onClick:
|
||||
options.onChooseColumns ||
|
||||
function (ev) {
|
||||
ev.preventDefault();
|
||||
openColumnChooser();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (canSetColumnType() && getSetColumnTypeConfig(column)) {
|
||||
columnActions.push({
|
||||
label: setColumnTypeActionLabel(column),
|
||||
href: "#",
|
||||
onClick:
|
||||
options.onSetColumnType ||
|
||||
function (ev) {
|
||||
ev.preventDefault();
|
||||
window.setTimeout(function () {
|
||||
openSetColumnTypeDialog(th);
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (th.dataset.isPk !== "1" && hasMultipleVisibleColumns(manager)) {
|
||||
columnActions.push({
|
||||
label: "Hide this column",
|
||||
href: hideColumnUrl(column),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.includeShowAllColumns && shouldShowShowAllColumns()) {
|
||||
columnActions.push({
|
||||
label: "Show all columns",
|
||||
href: showAllColumnsUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
if (params.get(`${column}__notblank`) !== "1" && hasBlankValues) {
|
||||
columnActions.push({
|
||||
label: "Show not-blank rows",
|
||||
href: notBlankUrl(column),
|
||||
});
|
||||
}
|
||||
|
||||
return columnActions.concat(manager.makeColumnActions(getColumnMeta(th)));
|
||||
}
|
||||
|
||||
function buildColumnActionState(manager, th, options) {
|
||||
return {
|
||||
column: th.dataset.column,
|
||||
columnDescription: th.dataset.columnDescription || null,
|
||||
columnMeta: getColumnMeta(th),
|
||||
columnTypeText: getColumnTypeText(th),
|
||||
actionItems: buildColumnActionItems(manager, th, options),
|
||||
};
|
||||
}
|
||||
|
||||
function initializeColumnActions(manager) {
|
||||
manager.columnActions = {
|
||||
buildColumnActionState: function (th, options) {
|
||||
return buildColumnActionState(manager, th, options);
|
||||
},
|
||||
buildColumnActionItems: function (th, options) {
|
||||
return buildColumnActionItems(manager, th, options);
|
||||
},
|
||||
canChooseColumns: canChooseColumns,
|
||||
facetUrl: facetUrl,
|
||||
getColumnMeta: getColumnMeta,
|
||||
getColumnTypeText: getColumnTypeText,
|
||||
hideColumnUrl: hideColumnUrl,
|
||||
notBlankUrl: notBlankUrl,
|
||||
shouldShowShowAllColumns: shouldShowShowAllColumns,
|
||||
showAllColumnsUrl: showAllColumnsUrl,
|
||||
sortAscUrl: sortAscUrl,
|
||||
sortDescUrl: sortDescUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function renderActionLink(itemConfig) {
|
||||
var newLink = document.createElement("a");
|
||||
newLink.textContent = itemConfig.label;
|
||||
newLink.href = itemConfig.href || "#";
|
||||
if (itemConfig.onClick) {
|
||||
newLink.addEventListener("click", itemConfig.onClick);
|
||||
}
|
||||
return newLink;
|
||||
}
|
||||
|
||||
/** Main initialization function for Datasette Table interactions */
|
||||
const initDatasetteTable = function (manager) {
|
||||
// Feature detection
|
||||
if (!window.URLSearchParams) {
|
||||
return;
|
||||
}
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
function paramsToUrl(params) {
|
||||
var s = params.toString();
|
||||
return s ? "?" + s : location.pathname;
|
||||
}
|
||||
function sortDescUrl(column) {
|
||||
var params = getParams();
|
||||
params.set("_sort_desc", column);
|
||||
params.delete("_sort");
|
||||
params.delete("_next");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
function sortAscUrl(column) {
|
||||
var params = getParams();
|
||||
params.set("_sort", column);
|
||||
params.delete("_sort_desc");
|
||||
params.delete("_next");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
function facetUrl(column) {
|
||||
var params = getParams();
|
||||
params.append("_facet", column);
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
function hideColumnUrl(column) {
|
||||
var params = getParams();
|
||||
params.append("_nocol", column);
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
function showAllColumnsUrl() {
|
||||
var params = getParams();
|
||||
params.delete("_nocol");
|
||||
params.delete("_col");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
function notBlankUrl(column) {
|
||||
var params = getParams();
|
||||
params.set(`${column}__notblank`, "1");
|
||||
return paramsToUrl(params);
|
||||
}
|
||||
function closeMenu() {
|
||||
menu.style.display = "none";
|
||||
menu.classList.remove("anim-scale-in");
|
||||
}
|
||||
// When page loads, add scroll listener on .table-wrapper
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
var tableWrapper = document.querySelector(".table-wrapper");
|
||||
if (tableWrapper) {
|
||||
tableWrapper.addEventListener("scroll", closeMenu);
|
||||
}
|
||||
});
|
||||
|
||||
const tableWrapper = document.querySelector(manager.selectors.tableWrapper);
|
||||
if (tableWrapper) {
|
||||
tableWrapper.addEventListener("scroll", closeMenu);
|
||||
}
|
||||
document.body.addEventListener("click", (ev) => {
|
||||
/* was this click outside the menu? */
|
||||
var target = ev.target;
|
||||
|
|
@ -85,9 +532,11 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
closeMenu();
|
||||
}
|
||||
});
|
||||
function iconClicked(ev) {
|
||||
|
||||
function onTableHeaderClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
menu.innerHTML = DROPDOWN_HTML;
|
||||
var th = ev.target;
|
||||
while (th.nodeName != "TH") {
|
||||
th = th.parentNode;
|
||||
|
|
@ -95,87 +544,41 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
var rect = th.getBoundingClientRect();
|
||||
var menuTop = rect.bottom + window.scrollY;
|
||||
var menuLeft = rect.left + window.scrollX;
|
||||
var column = th.getAttribute("data-column");
|
||||
var params = getParams();
|
||||
var sort = menu.querySelector("a.dropdown-sort-asc");
|
||||
var sortDesc = menu.querySelector("a.dropdown-sort-desc");
|
||||
var facetItem = menu.querySelector("a.dropdown-facet");
|
||||
var notBlank = menu.querySelector("a.dropdown-not-blank");
|
||||
var hideColumn = menu.querySelector("a.dropdown-hide-column");
|
||||
var showAllColumns = menu.querySelector("a.dropdown-show-all-columns");
|
||||
if (params.get("_sort") == column) {
|
||||
sort.parentNode.style.display = "none";
|
||||
} else {
|
||||
sort.parentNode.style.display = "block";
|
||||
sort.setAttribute("href", sortAscUrl(column));
|
||||
}
|
||||
if (params.get("_sort_desc") == column) {
|
||||
sortDesc.parentNode.style.display = "none";
|
||||
} else {
|
||||
sortDesc.parentNode.style.display = "block";
|
||||
sortDesc.setAttribute("href", sortDescUrl(column));
|
||||
}
|
||||
/* Show hide columns options */
|
||||
if (params.get("_nocol") || params.get("_col")) {
|
||||
showAllColumns.parentNode.style.display = "block";
|
||||
showAllColumns.setAttribute("href", showAllColumnsUrl());
|
||||
} else {
|
||||
showAllColumns.parentNode.style.display = "none";
|
||||
}
|
||||
if (th.getAttribute("data-is-pk") != "1") {
|
||||
hideColumn.parentNode.style.display = "block";
|
||||
hideColumn.setAttribute("href", hideColumnUrl(column));
|
||||
} else {
|
||||
hideColumn.parentNode.style.display = "none";
|
||||
}
|
||||
/* Only show "Facet by this" if it's not the first column, not selected,
|
||||
not a single PK and the Datasette allow_facet setting is True */
|
||||
var displayedFacets = Array.from(
|
||||
document.querySelectorAll(".facet-info")
|
||||
).map((el) => el.dataset.column);
|
||||
var isFirstColumn =
|
||||
th.parentElement.querySelector("th:first-of-type") == th;
|
||||
var isSinglePk =
|
||||
th.getAttribute("data-is-pk") == "1" &&
|
||||
document.querySelectorAll('th[data-is-pk="1"]').length == 1;
|
||||
if (
|
||||
!DATASETTE_ALLOW_FACET ||
|
||||
isFirstColumn ||
|
||||
displayedFacets.includes(column) ||
|
||||
isSinglePk
|
||||
) {
|
||||
facetItem.parentNode.style.display = "none";
|
||||
} else {
|
||||
facetItem.parentNode.style.display = "block";
|
||||
facetItem.setAttribute("href", facetUrl(column));
|
||||
}
|
||||
/* Show notBlank option if not selected AND at least one visible blank value */
|
||||
var tdsForThisColumn = Array.from(
|
||||
th.closest("table").querySelectorAll("td." + th.className)
|
||||
);
|
||||
if (
|
||||
params.get(`${column}__notblank`) != "1" &&
|
||||
tdsForThisColumn.filter((el) => el.innerText.trim() == "").length
|
||||
) {
|
||||
notBlank.parentNode.style.display = "block";
|
||||
notBlank.setAttribute("href", notBlankUrl(column));
|
||||
} else {
|
||||
notBlank.parentNode.style.display = "none";
|
||||
}
|
||||
var columnTypeP = menu.querySelector(".dropdown-column-type");
|
||||
var columnType = th.dataset.columnType;
|
||||
var notNull = th.dataset.columnNotNull == 1 ? " NOT NULL" : "";
|
||||
var actionState = manager.columnActions.buildColumnActionState(th, {
|
||||
includeChooseColumns: true,
|
||||
includeShowAllColumns: true,
|
||||
onChooseColumns: function (ev) {
|
||||
ev.preventDefault();
|
||||
closeMenu();
|
||||
openColumnChooser();
|
||||
},
|
||||
onSetColumnType: function (ev) {
|
||||
ev.preventDefault();
|
||||
closeMenu();
|
||||
window.setTimeout(function () {
|
||||
openSetColumnTypeDialog(th);
|
||||
}, 0);
|
||||
},
|
||||
});
|
||||
var menuList = menu.querySelector("ul.dropdown-actions");
|
||||
menuList.innerHTML = "";
|
||||
actionState.actionItems.forEach((itemConfig) => {
|
||||
var menuItem = document.createElement("li");
|
||||
menuItem.appendChild(renderActionLink(itemConfig));
|
||||
menuList.appendChild(menuItem);
|
||||
});
|
||||
|
||||
if (columnType) {
|
||||
var columnTypeP = menu.querySelector(".dropdown-column-type");
|
||||
if (actionState.columnTypeText) {
|
||||
columnTypeP.style.display = "block";
|
||||
columnTypeP.innerText = `Type: ${columnType.toUpperCase()}${notNull}`;
|
||||
columnTypeP.innerText = actionState.columnTypeText;
|
||||
} else {
|
||||
columnTypeP.style.display = "none";
|
||||
}
|
||||
|
||||
var columnDescriptionP = menu.querySelector(".dropdown-column-description");
|
||||
if (th.dataset.columnDescription) {
|
||||
columnDescriptionP.innerText = th.dataset.columnDescription;
|
||||
if (actionState.columnDescription) {
|
||||
columnDescriptionP.innerText = actionState.columnDescription;
|
||||
columnDescriptionP.style.display = "block";
|
||||
} else {
|
||||
columnDescriptionP.style.display = "none";
|
||||
|
|
@ -185,7 +588,28 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
menu.style.left = menuLeft + "px";
|
||||
menu.style.display = "block";
|
||||
menu.classList.add("anim-scale-in");
|
||||
|
||||
// Measure width of menu and adjust position if too far right
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const windowWidth = window.innerWidth;
|
||||
if (menuLeft + menuWidth > windowWidth) {
|
||||
menu.style.left = windowWidth - menuWidth - 20 + "px";
|
||||
}
|
||||
// Align menu .hook arrow with the column cog icon
|
||||
const hook = menu.querySelector(".hook");
|
||||
const icon = th.querySelector(".dropdown-menu-icon");
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
const hookLeft = iconRect.left - menuLeft + 1 + "px";
|
||||
hook.style.left = hookLeft;
|
||||
// Move the whole menu right if the hook is too far right
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
if (iconRect.right > menuRect.right) {
|
||||
menu.style.left = iconRect.right - menuWidth + "px";
|
||||
// And move hook tip as well
|
||||
hook.style.left = menuWidth - 13 + "px";
|
||||
}
|
||||
}
|
||||
|
||||
var svg = document.createElement("div");
|
||||
svg.innerHTML = DROPDOWN_ICON_SVG;
|
||||
svg = svg.querySelector("*");
|
||||
|
|
@ -197,23 +621,25 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
menu.style.display = "none";
|
||||
document.body.appendChild(menu);
|
||||
|
||||
var ths = Array.from(document.querySelectorAll(".rows-and-columns th"));
|
||||
var ths = Array.from(
|
||||
document.querySelectorAll(manager.selectors.tableHeaders),
|
||||
);
|
||||
ths.forEach((th) => {
|
||||
if (!th.querySelector("a")) {
|
||||
return;
|
||||
}
|
||||
var icon = svg.cloneNode(true);
|
||||
icon.addEventListener("click", iconClicked);
|
||||
icon.addEventListener("click", onTableHeaderClick);
|
||||
th.appendChild(icon);
|
||||
});
|
||||
})();
|
||||
};
|
||||
|
||||
/* Add x buttons to the filter rows */
|
||||
(function () {
|
||||
function addButtonsToFilterRows(manager) {
|
||||
var x = "✖";
|
||||
var rows = Array.from(document.querySelectorAll(".filter-row")).filter((el) =>
|
||||
el.querySelector(".filter-op")
|
||||
);
|
||||
var rows = Array.from(
|
||||
document.querySelectorAll(manager.selectors.filterRow),
|
||||
).filter((el) => el.querySelector(".filter-op"));
|
||||
rows.forEach((row) => {
|
||||
var a = document.createElement("a");
|
||||
a.setAttribute("href", "#");
|
||||
|
|
@ -234,4 +660,98 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
a.style.display = "none";
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
/* Set up datalist autocomplete for filter values */
|
||||
function initAutocompleteForFilterValues(manager) {
|
||||
function createDataLists() {
|
||||
var facetResults = document.querySelectorAll(
|
||||
manager.selectors.facetResults,
|
||||
);
|
||||
Array.from(facetResults).forEach(function (facetResult) {
|
||||
// Use link text from all links in the facet result
|
||||
var links = Array.from(
|
||||
facetResult.querySelectorAll("li:not(.facet-truncated) a"),
|
||||
);
|
||||
// Create a datalist element
|
||||
var datalist = document.createElement("datalist");
|
||||
datalist.id = "datalist-" + facetResult.dataset.column;
|
||||
// Create an option element for each link text
|
||||
links.forEach(function (link) {
|
||||
var option = document.createElement("option");
|
||||
option.label = link.innerText;
|
||||
option.value = link.dataset.facetValue;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
// Add the datalist to the facet result
|
||||
facetResult.appendChild(datalist);
|
||||
});
|
||||
}
|
||||
createDataLists();
|
||||
// When any select with name=_filter_column changes, update the datalist
|
||||
document.body.addEventListener("change", function (event) {
|
||||
if (event.target.name === "_filter_column") {
|
||||
event.target
|
||||
.closest(manager.selectors.filterRow)
|
||||
.querySelector(".filter-value")
|
||||
.setAttribute("list", "datalist-" + event.target.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the column-chooser web component */
|
||||
function openColumnChooser() {
|
||||
var chooser = document.querySelector("column-chooser");
|
||||
var data = window._columnChooserData;
|
||||
if (!chooser || !data) return;
|
||||
|
||||
var nonPkColumns = data.allColumns.filter(function (col) {
|
||||
return data.primaryKeys.indexOf(col) === -1;
|
||||
});
|
||||
var selected = data.selectedColumns.filter(function (col) {
|
||||
return data.primaryKeys.indexOf(col) === -1;
|
||||
});
|
||||
|
||||
chooser.open({
|
||||
columns: nonPkColumns,
|
||||
selected: selected,
|
||||
onApply: function (cols) {
|
||||
var params = new URLSearchParams(location.search);
|
||||
params.delete("_col");
|
||||
params.delete("_nocol");
|
||||
params.delete("_next");
|
||||
|
||||
if (cols.length === nonPkColumns.length) {
|
||||
// Check if order matches original - if so, no params needed
|
||||
var orderMatches = cols.every(function (col, i) {
|
||||
return col === nonPkColumns[i];
|
||||
});
|
||||
if (!orderMatches) {
|
||||
cols.forEach(function (col) {
|
||||
params.append("_col", col);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
cols.forEach(function (col) {
|
||||
params.append("_col", col);
|
||||
});
|
||||
}
|
||||
var qs = params.toString();
|
||||
location.href = qs ? "?" + qs : location.pathname;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Ensures Table UI is initialized only after the Manager is ready.
|
||||
document.addEventListener("datasette_init", function (evt) {
|
||||
const { detail: manager } = evt;
|
||||
|
||||
initializeColumnActions(manager);
|
||||
|
||||
// Main table
|
||||
initDatasetteTable(manager);
|
||||
|
||||
// Other UI functions with interactive JS needs
|
||||
addButtonsToFilterRows(manager);
|
||||
initAutocompleteForFilterValues(manager);
|
||||
});
|
||||
|
|
|
|||
623
datasette/stored_queries.py
Normal file
623
datasette/stored_queries.py
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .resources import TableResource
|
||||
from .utils import named_parameters, sqlite3, tilde_encode, urlsafe_components
|
||||
from .utils.asgi import Forbidden
|
||||
|
||||
UNCHANGED = object()
|
||||
|
||||
QUERY_OPTION_FIELDS = (
|
||||
"hide_sql",
|
||||
"fragment",
|
||||
"on_success_message",
|
||||
"on_success_message_sql",
|
||||
"on_success_redirect",
|
||||
"on_error_message",
|
||||
"on_error_redirect",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredQuery:
|
||||
database: str
|
||||
name: str
|
||||
sql: str
|
||||
title: str | None
|
||||
description: str | None
|
||||
description_html: str | None
|
||||
hide_sql: bool
|
||||
fragment: str | None
|
||||
parameters: list[str]
|
||||
is_write: bool
|
||||
is_private: bool
|
||||
is_trusted: bool
|
||||
source: str
|
||||
owner_id: str | None
|
||||
on_success_message: str | None
|
||||
on_success_message_sql: str | None
|
||||
on_success_redirect: str | None
|
||||
on_error_message: str | None
|
||||
on_error_redirect: str | None
|
||||
private: bool | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredQueryPage:
|
||||
queries: list[StoredQuery]
|
||||
next: str | None
|
||||
has_more: bool
|
||||
limit: int
|
||||
|
||||
|
||||
def stored_query_to_dict(query: StoredQuery) -> dict[str, Any]:
|
||||
data = {
|
||||
"database": query.database,
|
||||
"name": query.name,
|
||||
"sql": query.sql,
|
||||
"title": query.title,
|
||||
"description": query.description,
|
||||
"description_html": query.description_html,
|
||||
"hide_sql": query.hide_sql,
|
||||
"fragment": query.fragment,
|
||||
"params": list(query.parameters),
|
||||
"parameters": list(query.parameters),
|
||||
"is_write": query.is_write,
|
||||
"is_private": query.is_private,
|
||||
"is_trusted": query.is_trusted,
|
||||
"source": query.source,
|
||||
"owner_id": query.owner_id,
|
||||
"on_success_message": query.on_success_message,
|
||||
"on_success_message_sql": query.on_success_message_sql,
|
||||
"on_success_redirect": query.on_success_redirect,
|
||||
"on_error_message": query.on_error_message,
|
||||
"on_error_redirect": query.on_error_redirect,
|
||||
}
|
||||
if query.private is not None:
|
||||
data["private"] = query.private
|
||||
return data
|
||||
|
||||
|
||||
def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
|
||||
return {
|
||||
"queries": [stored_query_to_dict(query) for query in page.queries],
|
||||
"next": page.next,
|
||||
"has_more": page.has_more,
|
||||
"limit": page.limit,
|
||||
}
|
||||
|
||||
|
||||
async def save_queries_from_config(datasette: Any) -> None:
|
||||
# Apply configured query entries from datasette.yaml to the internal table.
|
||||
await datasette.get_internal_database().execute_write(
|
||||
"DELETE FROM queries WHERE source = 'config'"
|
||||
)
|
||||
for dbname, db_config in ((datasette.config or {}).get("databases") or {}).items():
|
||||
for query_name, query_config in (db_config.get("queries") or {}).items():
|
||||
if not isinstance(query_config, dict):
|
||||
query_config = {"sql": query_config}
|
||||
await datasette.add_query(
|
||||
dbname,
|
||||
query_name,
|
||||
query_config["sql"],
|
||||
title=query_config.get("title"),
|
||||
description=query_config.get("description"),
|
||||
description_html=query_config.get("description_html"),
|
||||
hide_sql=bool(query_config.get("hide_sql")),
|
||||
fragment=query_config.get("fragment"),
|
||||
parameters=query_config.get("params"),
|
||||
is_write=bool(query_config.get("write")),
|
||||
is_private=bool(query_config.get("is_private")),
|
||||
is_trusted=bool(query_config.get("is_trusted", True)),
|
||||
source="config",
|
||||
on_success_message=query_config.get("on_success_message"),
|
||||
on_success_message_sql=query_config.get("on_success_message_sql"),
|
||||
on_success_redirect=query_config.get("on_success_redirect"),
|
||||
on_error_message=query_config.get("on_error_message"),
|
||||
on_error_redirect=query_config.get("on_error_redirect"),
|
||||
)
|
||||
|
||||
|
||||
def query_row_to_stored_query(
|
||||
row: Any, private: bool | None = None
|
||||
) -> StoredQuery | None:
|
||||
if row is None:
|
||||
return None
|
||||
parameters = json.loads(row["parameters"] or "[]")
|
||||
options = json.loads(row["options"] or "{}")
|
||||
return StoredQuery(
|
||||
database=row["database_name"],
|
||||
name=row["name"],
|
||||
sql=row["sql"],
|
||||
title=row["title"],
|
||||
description=row["description"],
|
||||
description_html=row["description_html"],
|
||||
hide_sql=bool(options.get("hide_sql")),
|
||||
fragment=options.get("fragment"),
|
||||
parameters=parameters,
|
||||
is_write=bool(row["is_write"]),
|
||||
is_private=bool(row["is_private"]),
|
||||
is_trusted=bool(row["is_trusted"]),
|
||||
source=row["source"],
|
||||
owner_id=row["owner_id"],
|
||||
on_success_message=options.get("on_success_message"),
|
||||
on_success_message_sql=options.get("on_success_message_sql"),
|
||||
on_success_redirect=options.get("on_success_redirect"),
|
||||
on_error_message=options.get("on_error_message"),
|
||||
on_error_redirect=options.get("on_error_redirect"),
|
||||
private=private,
|
||||
)
|
||||
|
||||
|
||||
def query_options_json(options: dict[str, Any]) -> str:
|
||||
options_dict = {}
|
||||
for field in QUERY_OPTION_FIELDS:
|
||||
value = options.get(field)
|
||||
if field == "hide_sql":
|
||||
if value:
|
||||
options_dict[field] = True
|
||||
elif value is not None:
|
||||
options_dict[field] = value
|
||||
return json.dumps(options_dict, sort_keys=True)
|
||||
|
||||
|
||||
async def add_query(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
name: str,
|
||||
sql: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
description_html: str | None = None,
|
||||
hide_sql: bool = False,
|
||||
fragment: str | None = None,
|
||||
parameters: Iterable[str] | None = None,
|
||||
is_write: bool = False,
|
||||
is_private: bool = False,
|
||||
is_trusted: bool = False,
|
||||
source: str = "plugin",
|
||||
owner_id: str | None = None,
|
||||
on_success_message: str | None = None,
|
||||
on_success_message_sql: str | None = None,
|
||||
on_success_redirect: str | None = None,
|
||||
on_error_message: str | None = None,
|
||||
on_error_redirect: str | None = None,
|
||||
replace: bool = True,
|
||||
) -> None:
|
||||
parameters_json = json.dumps(list(parameters or []))
|
||||
options_json = query_options_json(
|
||||
{
|
||||
"hide_sql": hide_sql,
|
||||
"fragment": fragment,
|
||||
"on_success_message": on_success_message,
|
||||
"on_success_message_sql": on_success_message_sql,
|
||||
"on_success_redirect": on_success_redirect,
|
||||
"on_error_message": on_error_message,
|
||||
"on_error_redirect": on_error_redirect,
|
||||
}
|
||||
)
|
||||
sql_statement = """
|
||||
INSERT INTO queries (
|
||||
database_name, name, sql, title, description, description_html,
|
||||
options, parameters, is_write, is_private, is_trusted, source, owner_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
if replace:
|
||||
sql_statement += """
|
||||
ON CONFLICT(database_name, name) DO UPDATE SET
|
||||
sql = excluded.sql,
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
description_html = excluded.description_html,
|
||||
options = excluded.options,
|
||||
parameters = excluded.parameters,
|
||||
is_write = excluded.is_write,
|
||||
is_private = excluded.is_private,
|
||||
is_trusted = excluded.is_trusted,
|
||||
source = excluded.source,
|
||||
owner_id = excluded.owner_id,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
await datasette.get_internal_database().execute_write(
|
||||
sql_statement,
|
||||
[
|
||||
database,
|
||||
name,
|
||||
sql,
|
||||
title,
|
||||
description,
|
||||
description_html,
|
||||
options_json,
|
||||
parameters_json,
|
||||
int(bool(is_write)),
|
||||
int(bool(is_private)),
|
||||
int(bool(is_trusted)),
|
||||
source,
|
||||
owner_id,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def update_query(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
name: str,
|
||||
*,
|
||||
sql=UNCHANGED,
|
||||
title=UNCHANGED,
|
||||
description=UNCHANGED,
|
||||
description_html=UNCHANGED,
|
||||
hide_sql=UNCHANGED,
|
||||
fragment=UNCHANGED,
|
||||
parameters=UNCHANGED,
|
||||
is_write=UNCHANGED,
|
||||
is_private=UNCHANGED,
|
||||
is_trusted=UNCHANGED,
|
||||
source=UNCHANGED,
|
||||
owner_id=UNCHANGED,
|
||||
on_success_message=UNCHANGED,
|
||||
on_success_message_sql=UNCHANGED,
|
||||
on_success_redirect=UNCHANGED,
|
||||
on_error_message=UNCHANGED,
|
||||
on_error_redirect=UNCHANGED,
|
||||
) -> None:
|
||||
fields = {
|
||||
"sql": sql,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"description_html": description_html,
|
||||
"parameters": parameters,
|
||||
"is_write": is_write,
|
||||
"is_private": is_private,
|
||||
"is_trusted": is_trusted,
|
||||
"source": source,
|
||||
"owner_id": owner_id,
|
||||
}
|
||||
option_fields = {
|
||||
"hide_sql": hide_sql,
|
||||
"fragment": fragment,
|
||||
"on_success_message": on_success_message,
|
||||
"on_success_message_sql": on_success_message_sql,
|
||||
"on_success_redirect": on_success_redirect,
|
||||
"on_error_message": on_error_message,
|
||||
"on_error_redirect": on_error_redirect,
|
||||
}
|
||||
updates = []
|
||||
params = []
|
||||
for field, value in fields.items():
|
||||
if value is UNCHANGED:
|
||||
continue
|
||||
if field in {"is_write", "is_private", "is_trusted"}:
|
||||
value = int(bool(value))
|
||||
elif field == "parameters":
|
||||
value = json.dumps(list(value or []))
|
||||
updates.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
changed_options = {
|
||||
field: value for field, value in option_fields.items() if value is not UNCHANGED
|
||||
}
|
||||
if changed_options:
|
||||
rows = await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT options FROM queries
|
||||
WHERE database_name = ? AND name = ?
|
||||
""",
|
||||
[database, name],
|
||||
)
|
||||
row = rows.first()
|
||||
options = json.loads(row["options"] or "{}") if row is not None else {}
|
||||
for field, value in changed_options.items():
|
||||
if field == "hide_sql":
|
||||
if value:
|
||||
options[field] = True
|
||||
else:
|
||||
options.pop(field, None)
|
||||
elif value is None:
|
||||
options.pop(field, None)
|
||||
else:
|
||||
options[field] = value
|
||||
updates.append("options = ?")
|
||||
params.append(json.dumps(options, sort_keys=True))
|
||||
if not updates:
|
||||
return
|
||||
updates.append("updated_at = CURRENT_TIMESTAMP")
|
||||
params.extend([database, name])
|
||||
await datasette.get_internal_database().execute_write(
|
||||
"""
|
||||
UPDATE queries
|
||||
SET {}
|
||||
WHERE database_name = ? AND name = ?
|
||||
""".format(", ".join(updates)),
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
async def remove_query(
|
||||
datasette: Any, database: str, name: str, source: str | None = None
|
||||
) -> None:
|
||||
sql = "DELETE FROM queries WHERE database_name = ? AND name = ?"
|
||||
params = [database, name]
|
||||
if source is not None:
|
||||
sql += " AND source = ?"
|
||||
params.append(source)
|
||||
await datasette.get_internal_database().execute_write(sql, params)
|
||||
|
||||
|
||||
async def get_query(datasette: Any, database: str, name: str) -> StoredQuery | None:
|
||||
rows = await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT * FROM queries
|
||||
WHERE database_name = ? AND name = ?
|
||||
""",
|
||||
[database, name],
|
||||
)
|
||||
return query_row_to_stored_query(rows.first())
|
||||
|
||||
|
||||
async def count_queries(
|
||||
datasette: Any,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
) -> int:
|
||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
||||
action="view-query",
|
||||
actor=actor,
|
||||
parent=database,
|
||||
)
|
||||
params = dict(allowed_params)
|
||||
where_clauses = []
|
||||
if database is not None:
|
||||
params["query_database"] = database
|
||||
where_clauses.append("q.database_name = :query_database")
|
||||
|
||||
if q:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.name LIKE :query_search
|
||||
OR q.title LIKE :query_search
|
||||
OR q.description LIKE :query_search
|
||||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
if is_private is not None:
|
||||
where_clauses.append("q.is_private = :query_is_private")
|
||||
params["query_is_private"] = int(bool(is_private))
|
||||
if is_trusted is not None:
|
||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
||||
params["query_is_trusted"] = int(bool(is_trusted))
|
||||
if source is not None:
|
||||
where_clauses.append("q.source = :query_source")
|
||||
params["query_source"] = source
|
||||
if owner_id is not None:
|
||||
where_clauses.append("q.owner_id = :query_owner_id")
|
||||
params["query_owner_id"] = owner_id
|
||||
|
||||
row = (
|
||||
await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT count(*) AS count
|
||||
FROM queries q
|
||||
JOIN (
|
||||
{allowed_sql}
|
||||
) allowed
|
||||
ON allowed.parent = q.database_name
|
||||
AND allowed.child = q.name
|
||||
WHERE {where}
|
||||
""".format(
|
||||
allowed_sql=allowed_sql,
|
||||
where=" AND ".join(where_clauses) or "1 = 1",
|
||||
),
|
||||
params,
|
||||
)
|
||||
).first()
|
||||
return row["count"]
|
||||
|
||||
|
||||
async def list_queries(
|
||||
datasette: Any,
|
||||
database: str | None = None,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
limit: int = 50,
|
||||
cursor: str | None = None,
|
||||
q: str | None = None,
|
||||
is_write: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
is_trusted: bool | None = None,
|
||||
source: str | None = None,
|
||||
owner_id: str | None = None,
|
||||
include_private: bool = False,
|
||||
) -> StoredQueryPage:
|
||||
limit = min(max(1, int(limit)), 1000)
|
||||
allowed_sql, allowed_params = await datasette.allowed_resources_sql(
|
||||
action="view-query",
|
||||
actor=actor,
|
||||
parent=database,
|
||||
include_is_private=include_private,
|
||||
)
|
||||
params = dict(allowed_params)
|
||||
params.update({"limit": limit + 1})
|
||||
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
|
||||
where_clauses = []
|
||||
order_by = "q.database_name, sort_key, q.name"
|
||||
if database is not None:
|
||||
params["query_database"] = database
|
||||
where_clauses.append("q.database_name = :query_database")
|
||||
order_by = "sort_key, q.name"
|
||||
|
||||
if cursor:
|
||||
try:
|
||||
components = urlsafe_components(cursor)
|
||||
except ValueError:
|
||||
components = []
|
||||
if database is None and len(components) == 3:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.database_name > :cursor_database
|
||||
OR (
|
||||
q.database_name = :cursor_database
|
||||
AND (
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
{sort_key_sql} = :cursor_sort_key
|
||||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_database"] = components[0]
|
||||
params["cursor_sort_key"] = components[1]
|
||||
params["cursor_name"] = components[2]
|
||||
elif database is not None and len(components) == 2:
|
||||
where_clauses.append("""
|
||||
(
|
||||
{sort_key_sql} > :cursor_sort_key
|
||||
OR (
|
||||
{sort_key_sql} = :cursor_sort_key
|
||||
AND q.name > :cursor_name
|
||||
)
|
||||
)
|
||||
""".format(sort_key_sql=sort_key_sql))
|
||||
params["cursor_sort_key"] = components[0]
|
||||
params["cursor_name"] = components[1]
|
||||
|
||||
if q:
|
||||
where_clauses.append("""
|
||||
(
|
||||
q.name LIKE :query_search
|
||||
OR q.title LIKE :query_search
|
||||
OR q.description LIKE :query_search
|
||||
OR q.sql LIKE :query_search
|
||||
)
|
||||
""")
|
||||
params["query_search"] = "%{}%".format(q)
|
||||
if is_write is not None:
|
||||
where_clauses.append("q.is_write = :query_is_write")
|
||||
params["query_is_write"] = int(bool(is_write))
|
||||
if is_private is not None:
|
||||
where_clauses.append("q.is_private = :query_is_private")
|
||||
params["query_is_private"] = int(bool(is_private))
|
||||
if is_trusted is not None:
|
||||
where_clauses.append("q.is_trusted = :query_is_trusted")
|
||||
params["query_is_trusted"] = int(bool(is_trusted))
|
||||
if source is not None:
|
||||
where_clauses.append("q.source = :query_source")
|
||||
params["query_source"] = source
|
||||
if owner_id is not None:
|
||||
where_clauses.append("q.owner_id = :query_owner_id")
|
||||
params["query_owner_id"] = owner_id
|
||||
|
||||
private_select = ", allowed.is_private AS private" if include_private else ""
|
||||
rows = list(
|
||||
(
|
||||
await datasette.get_internal_database().execute(
|
||||
"""
|
||||
SELECT q.*, {sort_key_sql} AS sort_key{private_select}
|
||||
FROM queries q
|
||||
JOIN (
|
||||
{allowed_sql}
|
||||
) allowed
|
||||
ON allowed.parent = q.database_name
|
||||
AND allowed.child = q.name
|
||||
WHERE {where}
|
||||
ORDER BY {order_by}
|
||||
LIMIT :limit
|
||||
""".format(
|
||||
allowed_sql=allowed_sql,
|
||||
private_select=private_select,
|
||||
sort_key_sql=sort_key_sql,
|
||||
where=" AND ".join(where_clauses) or "1 = 1",
|
||||
order_by=order_by,
|
||||
),
|
||||
params,
|
||||
)
|
||||
).rows
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
queries = []
|
||||
for row in rows:
|
||||
query = query_row_to_stored_query(
|
||||
row, private=bool(row["private"]) if include_private else None
|
||||
)
|
||||
assert query is not None
|
||||
queries.append(query)
|
||||
|
||||
next_token = None
|
||||
if has_more and rows:
|
||||
last_row = rows[-1]
|
||||
if database is None:
|
||||
next_token = "{},{},{}".format(
|
||||
tilde_encode(last_row["database_name"]),
|
||||
tilde_encode(last_row["sort_key"]),
|
||||
tilde_encode(last_row["name"]),
|
||||
)
|
||||
else:
|
||||
next_token = "{},{}".format(
|
||||
tilde_encode(last_row["sort_key"]),
|
||||
tilde_encode(last_row["name"]),
|
||||
)
|
||||
return StoredQueryPage(
|
||||
queries=queries,
|
||||
next=next_token,
|
||||
has_more=has_more,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_query_write_permissions(
|
||||
datasette: Any,
|
||||
database: str,
|
||||
sql: str,
|
||||
*,
|
||||
actor: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
analysis: Any = None,
|
||||
) -> Any:
|
||||
write_actions = {
|
||||
"insert": "insert-row",
|
||||
"update": "update-row",
|
||||
"delete": "delete-row",
|
||||
}
|
||||
db = datasette.get_database(database)
|
||||
if analysis is None:
|
||||
if params is None:
|
||||
params = {name: "" for name in named_parameters(sql)}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
raise Forbidden(f"Could not analyze query: {ex}") from ex
|
||||
|
||||
for access in analysis.table_accesses:
|
||||
action = write_actions.get(access.operation)
|
||||
if action is None:
|
||||
continue
|
||||
if access.database != database:
|
||||
raise Forbidden("Writable queries may not write to attached databases")
|
||||
if not await datasette.allowed(
|
||||
action=action,
|
||||
resource=TableResource(database=access.database, table=access.table),
|
||||
actor=actor,
|
||||
):
|
||||
raise Forbidden(
|
||||
f"Permission denied: need {action} on {access.database}/{access.table}"
|
||||
)
|
||||
return analysis
|
||||
28
datasette/templates/_action_menu.html
Normal file
28
datasette/templates/_action_menu.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% if action_links %}
|
||||
<div class="page-action-menu">
|
||||
<details class="actions-menu-links details-menu">
|
||||
<summary aria-haspopup="menu" aria-expanded="false">
|
||||
<div class="icon-text">
|
||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">{{ action_title }}</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>{{ action_title }}</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="dropdown-menu">
|
||||
<div class="hook"></div>
|
||||
<ul role="menu">
|
||||
{% for link in action_links %}
|
||||
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -9,8 +9,54 @@ document.body.addEventListener('click', (ev) => {
|
|||
if (target && target.tagName == 'DETAILS') {
|
||||
detailsClickedWithin = target;
|
||||
}
|
||||
Array.from(document.getElementsByTagName('details')).filter(
|
||||
Array.from(document.querySelectorAll('details.details-menu')).filter(
|
||||
(details) => details.open && details != detailsClickedWithin
|
||||
).forEach(details => details.open = false);
|
||||
});
|
||||
|
||||
/* Sync aria-expanded and add keyboard navigation for details-menu elements */
|
||||
document.querySelectorAll('details.details-menu').forEach(function(details) {
|
||||
var summary = details.querySelector('summary');
|
||||
details.addEventListener('toggle', function() {
|
||||
if (summary) {
|
||||
summary.setAttribute('aria-expanded', details.open ? 'true' : 'false');
|
||||
}
|
||||
if (details.open) {
|
||||
/* Focus first menu item when menu opens */
|
||||
var firstItem = details.querySelector('[role="menuitem"]');
|
||||
if (firstItem) { firstItem.focus(); }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.body.addEventListener('keydown', function(ev) {
|
||||
/* Keyboard navigation for open details-menu elements */
|
||||
var openDetails = Array.from(document.querySelectorAll('details.details-menu[open]'));
|
||||
if (!openDetails.length) { return; }
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
openDetails.forEach(function(details) {
|
||||
details.open = false;
|
||||
var summary = details.querySelector('summary');
|
||||
if (summary) { summary.focus(); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
|
||||
var focused = document.activeElement;
|
||||
openDetails.forEach(function(details) {
|
||||
var items = Array.from(details.querySelectorAll('[role="menuitem"]'));
|
||||
if (!items.length) { return; }
|
||||
var idx = items.indexOf(focused);
|
||||
if (idx === -1) { return; }
|
||||
ev.preventDefault();
|
||||
if (ev.key === 'ArrowDown') {
|
||||
items[(idx + 1) % items.length].focus();
|
||||
} else {
|
||||
items[(idx - 1 + items.length) % items.length].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
<script src="{{ base_url }}-/static/sql-formatter-2.3.3.min.js" defer></script>
|
||||
<script src="{{ base_url }}-/static/codemirror-5.57.0.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ base_url }}-/static/codemirror-5.57.0.min.css" />
|
||||
<script src="{{ base_url }}-/static/codemirror-5.57.0-sql.min.js"></script>
|
||||
<script src="{{ base_url }}-/static/cm-resize-1.0.1.min.js"></script>
|
||||
<script src="{{ base_url }}-/static/cm-editor-6.0.1.bundle.js"></script>
|
||||
<style>
|
||||
.CodeMirror { height: auto; min-height: 70px; width: 80%; border: 1px solid #ddd; }
|
||||
.cm-resize-handle {
|
||||
background: url("data:image/svg+xml,%3Csvg%20aria-labelledby%3D%22cm-drag-to-resize%22%20role%3D%22img%22%20fill%3D%22%23ccc%22%20stroke%3D%22%23ccc%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%2216%22%20height%3D%2216%22%3E%0A%20%20%3Ctitle%20id%3D%22cm-drag-to-resize%22%3EDrag%20to%20resize%3C%2Ftitle%3E%0A%20%20%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M1%202.75A.75.75%200%20011.75%202h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%202.75zm0%205A.75.75%200%20011.75%207h12.5a.75.75%200%20110%201.5H1.75A.75.75%200%20011%207.75zM1.75%2012a.75.75%200%20100%201.5h12.5a.75.75%200%20100-1.5H1.75z%22%3E%3C%2Fpath%3E%0A%3C%2Fsvg%3E");
|
||||
background-repeat: no-repeat;
|
||||
box-shadow: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.cm-editor {
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
width: 80%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
/* Fix autocomplete icon positioning. The icon element gets border-box sizing set due to
|
||||
the global reset, but this causes overlapping icon and text. Markup:
|
||||
`<div class="cm-completionIcon cm-completionIcon-keyword" aria-hidden="true"></div>` */
|
||||
.cm-completionIcon {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,42 @@
|
|||
<script>
|
||||
window.onload = () => {
|
||||
{% if table_columns %}
|
||||
const schema = {{ table_columns|tojson(2) }};
|
||||
{% else %}
|
||||
const schema = {};
|
||||
{% endif %}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const sqlFormat = document.querySelector("button#sql-format");
|
||||
const readOnly = document.querySelector("pre#sql-query");
|
||||
const sqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (sqlFormat && !readOnly) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.hidden = false;
|
||||
}
|
||||
if (sqlInput) {
|
||||
var editor = CodeMirror.fromTextArea(sqlInput, {
|
||||
lineNumbers: true,
|
||||
mode: "text/x-sql",
|
||||
lineWrapping: true,
|
||||
var editor = (window.editor = cm.editorFromTextArea(sqlInput, {
|
||||
schema,
|
||||
}));
|
||||
if (sqlFormat) {
|
||||
sqlFormat.addEventListener("click", (ev) => {
|
||||
const formatted = sqlFormatter.format(editor.state.doc.toString());
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editor.state.doc.length,
|
||||
insert: formatted,
|
||||
},
|
||||
});
|
||||
});
|
||||
editor.setOption("extraKeys", {
|
||||
"Shift-Enter": function() {
|
||||
document.getElementsByClassName("sql")[0].submit();
|
||||
},
|
||||
Tab: false
|
||||
});
|
||||
if (sqlFormat) {
|
||||
sqlFormat.addEventListener("click", ev => {
|
||||
editor.setValue(sqlFormatter.format(editor.getValue()));
|
||||
})
|
||||
}
|
||||
cmResize(editor, {resizableWidth: false});
|
||||
}
|
||||
}
|
||||
if (sqlFormat && readOnly) {
|
||||
const formatted = sqlFormatter.format(readOnly.innerHTML);
|
||||
if (formatted != readOnly.innerHTML) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.addEventListener("click", ev => {
|
||||
readOnly.innerHTML = formatted;
|
||||
})
|
||||
}
|
||||
const formatted = sqlFormatter.format(readOnly.innerHTML);
|
||||
if (formatted != readOnly.innerHTML) {
|
||||
sqlFormat.hidden = false;
|
||||
sqlFormat.addEventListener("click", (ev) => {
|
||||
readOnly.innerHTML = formatted;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
50
datasette/templates/_debug_common_functions.html
Normal file
50
datasette/templates/_debug_common_functions.html
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<script>
|
||||
// Common utility functions for debug pages
|
||||
|
||||
// Populate form from URL parameters on page load
|
||||
function populateFormFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
const actionField = document.getElementById('action');
|
||||
if (actionField) {
|
||||
actionField.value = action;
|
||||
}
|
||||
}
|
||||
|
||||
const parent = params.get('parent');
|
||||
if (parent) {
|
||||
const parentField = document.getElementById('parent');
|
||||
if (parentField) {
|
||||
parentField.value = parent;
|
||||
}
|
||||
}
|
||||
|
||||
const child = params.get('child');
|
||||
if (child) {
|
||||
const childField = document.getElementById('child');
|
||||
if (childField) {
|
||||
childField.value = child;
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = params.get('page_size');
|
||||
if (pageSize) {
|
||||
const pageSizeField = document.getElementById('page_size');
|
||||
if (pageSizeField) {
|
||||
pageSizeField.value = pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// HTML escape function
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{% if metadata.description_html or metadata.description %}
|
||||
{% if metadata.get("description_html") or metadata.get("description") %}
|
||||
<div class="metadata-description">
|
||||
{% if metadata.description_html %}
|
||||
{% if metadata.get("description_html") %}
|
||||
{{ metadata.description_html|safe }}
|
||||
{% else %}
|
||||
{{ metadata.description }}
|
||||
|
|
|
|||
111
datasette/templates/_execute_write_analysis_scripts.html
Normal file
111
datasette/templates/_execute_write_analysis_scripts.html
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
window.datasetteSqlAnalysis = (() => {
|
||||
if (
|
||||
window.datasetteSqlAnalysis &&
|
||||
window.datasetteSqlAnalysis.renderAnalysis
|
||||
) {
|
||||
return window.datasetteSqlAnalysis;
|
||||
}
|
||||
|
||||
function appendCodeCell(row, value, emptyText) {
|
||||
const cell = document.createElement("td");
|
||||
if (value) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = value;
|
||||
cell.appendChild(code);
|
||||
} else if (emptyText) {
|
||||
appendNotApplicable(cell);
|
||||
}
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function appendNotApplicable(cell) {
|
||||
const notApplicable = document.createElement("span");
|
||||
notApplicable.className = "execute-write-analysis-na";
|
||||
notApplicable.textContent = "n/a";
|
||||
cell.appendChild(notApplicable);
|
||||
}
|
||||
|
||||
function renderAnalysis(section, data) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
section.replaceChildren();
|
||||
if (data.has_sql === false) {
|
||||
section.hidden = true;
|
||||
return;
|
||||
}
|
||||
section.hidden = false;
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Query operations";
|
||||
section.appendChild(heading);
|
||||
|
||||
if (data.analysis_error) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "message-error";
|
||||
error.textContent = data.analysis_error;
|
||||
section.appendChild(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = data.analysis_rows || [];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent =
|
||||
"Analysis will show each affected table and required permission.";
|
||||
section.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "table-wrapper";
|
||||
const table = document.createElement("table");
|
||||
table.className = "execute-write-analysis";
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
[
|
||||
"Operation",
|
||||
"Database",
|
||||
"Table",
|
||||
"Required permission",
|
||||
"Allowed",
|
||||
].forEach((label) => {
|
||||
const th = document.createElement("th");
|
||||
th.scope = "col";
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach((analysisRow) => {
|
||||
const row = document.createElement("tr");
|
||||
appendCodeCell(row, analysisRow.operation);
|
||||
appendCodeCell(row, analysisRow.database);
|
||||
appendCodeCell(row, analysisRow.table);
|
||||
appendCodeCell(row, analysisRow.required_permission, "n/a");
|
||||
|
||||
const allowedCell = document.createElement("td");
|
||||
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
|
||||
const allowed = document.createElement("span");
|
||||
allowed.className = analysisRow.allowed
|
||||
? "execute-write-analysis-allowed"
|
||||
: "execute-write-analysis-denied";
|
||||
allowed.textContent = analysisRow.allowed ? "yes" : "no";
|
||||
allowedCell.appendChild(allowed);
|
||||
} else {
|
||||
appendNotApplicable(allowedCell);
|
||||
}
|
||||
row.appendChild(allowedCell);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrapper.appendChild(table);
|
||||
section.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return { renderAnalysis };
|
||||
})();
|
||||
</script>
|
||||
41
datasette/templates/_execute_write_analysis_styles.html
Normal file
41
datasette/templates/_execute_write_analysis_styles.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<style>
|
||||
.execute-write-analysis {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
min-width: 44rem;
|
||||
}
|
||||
.execute-write-analysis th,
|
||||
.execute-write-analysis td {
|
||||
border-bottom: 1px solid #d7dde5;
|
||||
padding: 0.45rem 0.7rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.execute-write-analysis th {
|
||||
background-color: #edf6fb;
|
||||
border-top: 1px solid #d7dde5;
|
||||
color: #39445a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis tbody tr:nth-child(even) {
|
||||
background-color: rgba(39, 104, 144, 0.05);
|
||||
}
|
||||
.execute-write-analysis code {
|
||||
background: transparent;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.execute-write-analysis-allowed {
|
||||
color: #267a3e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-denied {
|
||||
color: #b00020;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-na {
|
||||
color: #687386;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<ul class="tight-bullets">
|
||||
{% for facet_value in facet_info.results %}
|
||||
{% if not facet_value.selected %}
|
||||
<li><a href="{{ facet_value.toggle_url }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
<li><a href="{{ facet_value.toggle_url }}" data-facet-value="{{ facet_value.value }}">{{ (facet_value.label | string()) or "-" }}</a> {{ "{:,}".format(facet_value.count) }}</li>
|
||||
{% else %}
|
||||
<li>{{ facet_value.label or "-" }} · {{ "{:,}".format(facet_value.count) }} <a href="{{ facet_value.toggle_url }}" class="cross">✖</a></li>
|
||||
{% endif %}
|
||||
|
|
|
|||
145
datasette/templates/_permission_ui_styles.html
Normal file
145
datasette/templates/_permission_ui_styles.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<style>
|
||||
.permission-form {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
padding: 1.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-section input[type="text"],
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section select:focus {
|
||||
outline: 2px solid #0066cc;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
.form-section small {
|
||||
display: block;
|
||||
margin-top: 0.3em;
|
||||
color: #666;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.submit-btn {
|
||||
padding: 0.6em 1.5em;
|
||||
font-size: 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.results-container {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.results-count {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.results-table th {
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ddd;
|
||||
}
|
||||
.results-table td {
|
||||
padding: 0.75em;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.results-table tr:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.results-table tr.allow-row {
|
||||
background-color: #f1f8f4;
|
||||
}
|
||||
.results-table tr.allow-row:hover {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
.results-table tr.deny-row {
|
||||
background-color: #fef5f5;
|
||||
}
|
||||
.results-table tr.deny-row:hover {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
.resource-path {
|
||||
font-family: monospace;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 1.5em;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
}
|
||||
.pagination a {
|
||||
padding: 0.5em 1em;
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.pagination a:hover {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
.pagination span {
|
||||
color: #666;
|
||||
}
|
||||
.no-results {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.error-message {
|
||||
padding: 1em;
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
border-radius: 5px;
|
||||
color: #c62828;
|
||||
}
|
||||
.loading {
|
||||
padding: 2em;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
54
datasette/templates/_permissions_debug_tabs.html
Normal file
54
datasette/templates/_permissions_debug_tabs.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{% if has_debug_permission %}
|
||||
{% set query_string = '?' + request.query_string if request.query_string else '' %}
|
||||
|
||||
<style>
|
||||
.permissions-debug-tabs {
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
margin-bottom: 2em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.permissions-debug-tabs a {
|
||||
padding: 0.75em 1.25em;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.permissions-debug-tabs a:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-bottom-color: #999;
|
||||
}
|
||||
.permissions-debug-tabs a.active {
|
||||
color: #0066cc;
|
||||
border-bottom-color: #0066cc;
|
||||
background-color: #f0f7ff;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.permissions-debug-tabs {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
.permissions-debug-tabs a {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.permissions-debug-tabs a.active {
|
||||
border-left: 3px solid #0066cc;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="permissions-debug-tabs">
|
||||
<a href="{{ urls.path('-/permissions') }}" {% if current_tab == "permissions" %}class="active"{% endif %}>Playground</a>
|
||||
<a href="{{ urls.path('-/check') }}{{ query_string }}" {% if current_tab == "check" %}class="active"{% endif %}>Check</a>
|
||||
<a href="{{ urls.path('-/allowed') }}{{ query_string }}" {% if current_tab == "allowed" %}class="active"{% endif %}>Allowed</a>
|
||||
<a href="{{ urls.path('-/rules') }}{{ query_string }}" {% if current_tab == "rules" %}class="active"{% endif %}>Rules</a>
|
||||
<a href="{{ urls.path('-/actions') }}" {% if current_tab == "actions" %}class="active"{% endif %}>Actions</a>
|
||||
<a href="{{ urls.path('-/allow-debug') }}" {% if current_tab == "allow_debug" %}class="active"{% endif %}>Allow debug</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
293
datasette/templates/_sql_parameter_scripts.html
Normal file
293
datasette/templates/_sql_parameter_scripts.html
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
<script>
|
||||
window.datasetteSqlParameters = (() => {
|
||||
if (
|
||||
window.datasetteSqlParameters &&
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh
|
||||
) {
|
||||
return window.datasetteSqlParameters;
|
||||
}
|
||||
|
||||
function currentSql(form) {
|
||||
if (window.editor) {
|
||||
return window.editor.state.doc.toString();
|
||||
}
|
||||
const sqlInput = form.querySelector("textarea#sql-editor, input[name=sql]");
|
||||
return sqlInput ? sqlInput.value : "";
|
||||
}
|
||||
|
||||
function controlState(control) {
|
||||
return {
|
||||
value: control.value,
|
||||
expanded: control.tagName.toLowerCase() === "textarea",
|
||||
};
|
||||
}
|
||||
|
||||
function syncParameterState(manager) {
|
||||
manager.parameterState = new Map();
|
||||
manager.section
|
||||
.querySelectorAll("[data-parameter-control]")
|
||||
.forEach((control) => {
|
||||
manager.parameterState.set(control.name, controlState(control));
|
||||
});
|
||||
}
|
||||
|
||||
function createControl(parameter, id, state) {
|
||||
const control = document.createElement(state.expanded ? "textarea" : "input");
|
||||
control.id = id;
|
||||
control.name = parameter;
|
||||
control.value = state.value;
|
||||
control.setAttribute("data-parameter-control", "");
|
||||
if (state.expanded) {
|
||||
control.rows = 5;
|
||||
} else {
|
||||
control.type = "text";
|
||||
}
|
||||
return control;
|
||||
}
|
||||
|
||||
function replaceParameterControl(
|
||||
manager,
|
||||
control,
|
||||
button,
|
||||
expand,
|
||||
value,
|
||||
selectionStart
|
||||
) {
|
||||
const replacement = createControl(control.name, control.id, {
|
||||
value: value === undefined ? control.value : value,
|
||||
expanded: expand,
|
||||
});
|
||||
button.textContent = expand ? "Collapse" : "Expand";
|
||||
button.setAttribute("aria-expanded", expand ? "true" : "false");
|
||||
control.replaceWith(replacement);
|
||||
replacement.focus();
|
||||
if (selectionStart !== undefined && replacement.setSelectionRange) {
|
||||
replacement.setSelectionRange(selectionStart, selectionStart);
|
||||
}
|
||||
manager.parameterState.set(replacement.name, controlState(replacement));
|
||||
}
|
||||
|
||||
function renderParameters(manager, parameters) {
|
||||
syncParameterState(manager);
|
||||
const previousState = manager.parameterState;
|
||||
const nextState = new Map();
|
||||
manager.section.replaceChildren();
|
||||
if (!parameters.length) {
|
||||
manager.parameterState = nextState;
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Parameters";
|
||||
manager.section.appendChild(heading);
|
||||
|
||||
parameters.forEach((parameter, index) => {
|
||||
const id = `qp${index + 1}`;
|
||||
const state = previousState.get(parameter) || {
|
||||
value: "",
|
||||
expanded: false,
|
||||
};
|
||||
if (!manager.allowExpand) {
|
||||
state.expanded = false;
|
||||
}
|
||||
nextState.set(parameter, state);
|
||||
|
||||
const row = document.createElement("p");
|
||||
row.className = "sql-parameter-row";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = id;
|
||||
label.textContent = parameter;
|
||||
|
||||
const control = createControl(parameter, id, state);
|
||||
|
||||
row.append(label, control);
|
||||
if (manager.allowExpand) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "sql-parameter-toggle";
|
||||
button.setAttribute("data-parameter-toggle", "");
|
||||
button.setAttribute("aria-controls", id);
|
||||
button.setAttribute("aria-expanded", state.expanded ? "true" : "false");
|
||||
button.textContent = state.expanded ? "Collapse" : "Expand";
|
||||
row.append(" ", button);
|
||||
}
|
||||
manager.section.appendChild(row);
|
||||
});
|
||||
|
||||
manager.parameterState = nextState;
|
||||
}
|
||||
|
||||
function bindParameterControls(manager) {
|
||||
manager.form.addEventListener("input", (event) => {
|
||||
const control = event.target;
|
||||
if (!control.matches || !control.matches("[data-parameter-control]")) {
|
||||
return;
|
||||
}
|
||||
manager.parameterState.set(control.name, controlState(control));
|
||||
});
|
||||
|
||||
if (!manager.allowExpand) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.form.addEventListener("click", (event) => {
|
||||
const button = event.target.closest
|
||||
? event.target.closest("[data-parameter-toggle]")
|
||||
: null;
|
||||
if (!button || !manager.form.contains(button)) {
|
||||
return;
|
||||
}
|
||||
const control = document.getElementById(button.getAttribute("aria-controls"));
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
const expanded = control.tagName.toLowerCase() === "textarea";
|
||||
replaceParameterControl(manager, control, button, !expanded);
|
||||
});
|
||||
|
||||
manager.form.addEventListener("paste", (event) => {
|
||||
const control = event.target;
|
||||
if (
|
||||
!(control instanceof HTMLInputElement) ||
|
||||
!control.matches("[data-parameter-control]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pasted = event.clipboardData ? event.clipboardData.getData("text") : "";
|
||||
if (!/[\r\n]/.test(pasted)) {
|
||||
return;
|
||||
}
|
||||
const button = document.querySelector(
|
||||
`[data-parameter-toggle][aria-controls="${control.id}"]`
|
||||
);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const selectionStart = control.selectionStart ?? control.value.length;
|
||||
const selectionEnd = control.selectionEnd ?? selectionStart;
|
||||
const value =
|
||||
control.value.slice(0, selectionStart) +
|
||||
pasted +
|
||||
control.value.slice(selectionEnd);
|
||||
replaceParameterControl(
|
||||
manager,
|
||||
control,
|
||||
button,
|
||||
true,
|
||||
value,
|
||||
selectionStart + pasted.length
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEditorChanges(form, callback) {
|
||||
const editorElement = form.querySelector(".cm-content");
|
||||
if (editorElement) {
|
||||
editorElement.addEventListener("input", callback);
|
||||
}
|
||||
if (!window.editor) {
|
||||
const sqlInput = form.querySelector("textarea#sql-editor");
|
||||
if (sqlInput) {
|
||||
sqlInput.addEventListener("input", callback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window.editor.datasetteSqlParameterCallbacks) {
|
||||
const editor = window.editor;
|
||||
const originalDispatch = editor.dispatch.bind(editor);
|
||||
editor.datasetteSqlParameterCallbacks = [];
|
||||
editor.dispatch = (...transactions) => {
|
||||
const before = editor.state.doc.toString();
|
||||
originalDispatch(...transactions);
|
||||
if (editor.state.doc.toString() !== before) {
|
||||
editor.datasetteSqlParameterCallbacks.forEach((listener) => listener());
|
||||
}
|
||||
};
|
||||
}
|
||||
window.editor.datasetteSqlParameterCallbacks.push(callback);
|
||||
}
|
||||
|
||||
function setupSqlParameterRefresh(options) {
|
||||
const form =
|
||||
options.form || document.querySelector("form.sql.core[data-parameters-url]");
|
||||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const shouldRenderParameters = options.renderParameters !== false;
|
||||
const section =
|
||||
options.section || form.querySelector("[data-sql-parameters-section]");
|
||||
if (shouldRenderParameters && !section) {
|
||||
return null;
|
||||
}
|
||||
const manager = {
|
||||
form,
|
||||
section,
|
||||
allowExpand:
|
||||
options.allowExpand === undefined
|
||||
? section
|
||||
? section.dataset.allowExpand === "1"
|
||||
: false
|
||||
: options.allowExpand,
|
||||
parameterState: new Map(),
|
||||
};
|
||||
if (section) {
|
||||
bindParameterControls(manager);
|
||||
syncParameterState(manager);
|
||||
}
|
||||
|
||||
const url = options.url || form.dataset.parametersUrl;
|
||||
let refreshTimer = null;
|
||||
let refreshSequence = 0;
|
||||
|
||||
async function refreshParameters() {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
const sequence = ++refreshSequence;
|
||||
try {
|
||||
const requestUrl = new URL(url, window.location.href);
|
||||
requestUrl.searchParams.set("sql", currentSql(form));
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (sequence !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error((data.errors || [response.statusText]).join("; "));
|
||||
}
|
||||
if (shouldRenderParameters) {
|
||||
renderParameters(manager, data.parameters || []);
|
||||
}
|
||||
if (options.onData) {
|
||||
options.onData(data, manager);
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
if (options.onError) {
|
||||
options.onError(error, manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(refreshParameters, options.debounceMs || 350);
|
||||
}
|
||||
|
||||
bindEditorChanges(form, scheduleRefresh);
|
||||
return {
|
||||
currentSql: () => currentSql(form),
|
||||
refreshParameters,
|
||||
renderParameters: (parameters) => renderParameters(manager, parameters),
|
||||
};
|
||||
}
|
||||
|
||||
return { setupSqlParameterRefresh };
|
||||
})();
|
||||
</script>
|
||||
58
datasette/templates/_sql_parameter_styles.html
Normal file
58
datasette/templates/_sql_parameter_styles.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<style>
|
||||
form.sql .sql-editor {
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-editor textarea#sql-editor {
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .sql-parameters-section {
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-parameter-row {
|
||||
align-items: start;
|
||||
column-gap: 0.6rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 11rem) minmax(16rem, 1fr) auto;
|
||||
margin: 0 0 0.65rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
form.sql .sql-parameter-row label {
|
||||
overflow-wrap: anywhere;
|
||||
padding-top: 0.55rem;
|
||||
width: auto;
|
||||
}
|
||||
form.sql .sql-parameter-row input[data-parameter-control],
|
||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .sql-parameter-row textarea[data-parameter-control] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
min-height: 7rem;
|
||||
padding: 9px 4px;
|
||||
}
|
||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
||||
font-size: 0.72rem;
|
||||
height: 1.8rem;
|
||||
line-height: 1;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
form.sql .sql-parameter-row {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
form.sql .sql-parameter-row label {
|
||||
padding-top: 0;
|
||||
}
|
||||
form.sql.core button.sql-parameter-toggle[type=button] {
|
||||
justify-self: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
datasette/templates/_sql_parameters.html
Normal file
9
datasette/templates/_sql_parameters.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<div id="{{ sql_parameters_section_id|default("sql-parameters-section") }}" class="sql-parameters-section" data-sql-parameters-section{% if sql_parameters_allow_expand|default(false) %} data-allow-expand="1"{% endif %}>
|
||||
{% if parameter_names %}
|
||||
<h2>Parameters</h2>
|
||||
{% for parameter in parameter_names %}
|
||||
{% set parameter_id = (sql_parameter_id_prefix|default("qp")) ~ loop.index %}
|
||||
<p class="sql-parameter-row"><label for="{{ parameter_id }}">{{ parameter }}</label> <input type="text" id="{{ parameter_id }}" name="{{ parameter }}" value="{{ parameter_values.get(parameter, "") }}" data-parameter-control>{% if sql_parameters_allow_expand|default(false) %} <button type="button" class="sql-parameter-toggle" data-parameter-toggle aria-controls="{{ parameter_id }}" aria-expanded="false">Expand</button>{% endif %}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
<p class="suggested-facets">
|
||||
Suggested facets: {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.type %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
Suggested facets: {% for facet in suggested_facets %}<a href="{{ facet.toggle_url }}#facet-{{ facet.name|to_css_class }}">{{ facet.name }}</a>{% if facet.get("type") %} ({{ facet.type }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
{% if display_rows %}
|
||||
<!-- above-table-panel is a hook node for plugins to attach to . Displays even if no data available -->
|
||||
<div class="above-table-panel"> </div>
|
||||
{% if display_columns %}
|
||||
<div class="table-wrapper">
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for column in display_columns %}
|
||||
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}">
|
||||
<th {% if column.description %}data-column-description="{{ column.description }}" {% endif %}class="col-{{ column.name|to_css_class }}" scope="col" data-column="{{ column.name }}" data-column-type="{{ column.type.lower() }}" data-column-not-null="{{ column.notnull }}" data-is-pk="{% if column.is_pk %}1{% else %}0{% endif %}"{% if column.is_special_link_column %} data-is-link-column="1"{% endif %}>
|
||||
{% if not column.sortable %}
|
||||
{{ column.name }}
|
||||
{% else %}
|
||||
|
|
@ -29,6 +31,7 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
{% if not display_rows %}
|
||||
<p class="zero-results">0 records</p>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -33,9 +33,12 @@ p.message-warning {
|
|||
|
||||
<h1>Debug allow rules</h1>
|
||||
|
||||
{% set current_tab = "allow_debug" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to try out different actor and allow combinations. See <a href="https://docs.datasette.io/en/stable/authentication.html#defining-permissions-with-allow-blocks">Defining permissions with "allow" blocks</a> for documentation.</p>
|
||||
|
||||
<form action="{{ urls.path('-/allow-debug') }}" method="get">
|
||||
<form class="core" action="{{ urls.path('-/allow-debug') }}" method="get" style="margin-bottom: 1em">
|
||||
<div class="two-col">
|
||||
<p><label>Allow block</label></p>
|
||||
<textarea name="allow">{{ allow_input }}</textarea>
|
||||
|
|
|
|||
208
datasette/templates/api_explorer.html
Normal file
208
datasette/templates/api_explorer.html
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API Explorer{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>API Explorer{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
<p>Use this tool to try out the
|
||||
{% if datasette_version %}
|
||||
<a href="https://docs.datasette.io/en/{{ datasette_version }}/json_api.html">Datasette API</a>.
|
||||
{% else %}
|
||||
Datasette API.
|
||||
{% endif %}
|
||||
</p>
|
||||
<details open style="border: 2px solid #ccc; border-bottom: none; padding: 0.5em">
|
||||
<summary style="cursor: pointer;">GET</summary>
|
||||
<form class="core" method="get" id="api-explorer-get" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
<input type="submit" value="GET">
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
<details style="border: 2px solid #ccc; padding: 0.5em">
|
||||
<summary style="cursor: pointer">POST</summary>
|
||||
<form class="core" method="post" id="api-explorer-post" style="margin-top: 0.7em">
|
||||
<div>
|
||||
<label for="path">API path:</label>
|
||||
<input type="text" id="path" name="path" style="width: 60%">
|
||||
</div>
|
||||
<div style="margin: 0.5em 0">
|
||||
<label for="apiJson" style="vertical-align: top">JSON:</label>
|
||||
<textarea id="apiJson" name="json" style="width: 60%; height: 200px; font-family: monospace; font-size: 0.8em;"></textarea>
|
||||
</div>
|
||||
<p><button id="json-format" type="button">Format JSON</button> <input type="submit" value="POST"></p>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div id="output" style="display: none">
|
||||
<h2>API response: HTTP <span id="response-status"></span></h2>
|
||||
</h2>
|
||||
<ul class="errors message-error"></ul>
|
||||
<pre></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelector('#json-format').addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
let json = document.querySelector('textarea[name="json"]').value.trim();
|
||||
if (!json) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
document.querySelector('textarea[name="json"]').value = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
alert("Error parsing JSON: " + e);
|
||||
}
|
||||
});
|
||||
var postForm = document.getElementById('api-explorer-post');
|
||||
var getForm = document.getElementById('api-explorer-get');
|
||||
var output = document.getElementById('output');
|
||||
var errorList = output.querySelector('.errors');
|
||||
|
||||
// On first load or fragment change populate forms from # in URL, if present
|
||||
if (window.location.hash) {
|
||||
onFragmentChange();
|
||||
}
|
||||
function onFragmentChange() {
|
||||
var hash = window.location.hash.slice(1);
|
||||
// Treat hash as a foo=bar string and parse it:
|
||||
var params = new URLSearchParams(hash);
|
||||
var method = params.get('method');
|
||||
if (method == 'GET') {
|
||||
getForm.closest('details').open = true;
|
||||
postForm.closest('details').open = false;
|
||||
getForm.querySelector('input[name="path"]').value = params.get('path');
|
||||
} else if (method == 'POST') {
|
||||
postForm.closest('details').open = true;
|
||||
getForm.closest('details').open = false;
|
||||
postForm.querySelector('input[name="path"]').value = params.get('path');
|
||||
postForm.querySelector('textarea[name="json"]').value = params.get('json');
|
||||
}
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
onFragmentChange();
|
||||
// Animate scroll to top of page
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
});
|
||||
|
||||
// Cause GET and POST regions to toggle each other
|
||||
var getDetails = getForm.closest('details');
|
||||
var postDetails = postForm.closest('details');
|
||||
getDetails.addEventListener('toggle', (ev) => {
|
||||
if (getDetails.open) {
|
||||
postDetails.open = false;
|
||||
}
|
||||
});
|
||||
postDetails.addEventListener('toggle', (ev) => {
|
||||
if (postDetails.open) {
|
||||
getDetails.open = false;
|
||||
}
|
||||
});
|
||||
|
||||
getForm.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(getForm);
|
||||
// Update URL fragment hash
|
||||
var serialized = new URLSearchParams(formData).toString() + '&method=GET';
|
||||
window.history.pushState({}, "", location.pathname + '#' + serialized);
|
||||
// Send the request
|
||||
var path = formData.get('path');
|
||||
fetch(path, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
}).then((response) => {
|
||||
output.style.display = 'block';
|
||||
document.getElementById('response-status').textContent = response.status;
|
||||
return response.json();
|
||||
}).then((data) => {
|
||||
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||
errorList.style.display = 'none';
|
||||
}).catch((error) => {
|
||||
alert(error);
|
||||
});
|
||||
});
|
||||
|
||||
postForm.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(postForm);
|
||||
// Update URL fragment hash
|
||||
var serialized = new URLSearchParams(formData).toString() + '&method=POST';
|
||||
window.history.pushState({}, "", location.pathname + '#' + serialized);
|
||||
// Send the request
|
||||
var json = formData.get('json');
|
||||
var path = formData.get('path');
|
||||
// Validate JSON
|
||||
if (!json.length) {
|
||||
json = '{}';
|
||||
}
|
||||
try {
|
||||
var data = JSON.parse(json);
|
||||
} catch (err) {
|
||||
alert("Invalid JSON: " + err);
|
||||
return;
|
||||
}
|
||||
// POST JSON to path with content-type application/json
|
||||
fetch(path, {
|
||||
method: 'POST',
|
||||
body: json,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}).then(r => {
|
||||
document.getElementById('response-status').textContent = r.status;
|
||||
return r.json();
|
||||
}).then(data => {
|
||||
if (data.errors) {
|
||||
errorList.style.display = 'block';
|
||||
errorList.innerHTML = '';
|
||||
data.errors.forEach(error => {
|
||||
var li = document.createElement('li');
|
||||
li.textContent = error;
|
||||
errorList.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
errorList.style.display = 'none';
|
||||
}
|
||||
output.querySelector('pre').innerHTML = jsonFormatHighlight(data);
|
||||
output.style.display = 'block';
|
||||
}).catch(err => {
|
||||
alert("Error: " + err);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if example_links %}
|
||||
<h2>API endpoints</h2>
|
||||
<ul class="bullets">
|
||||
{% for database in example_links %}
|
||||
<li>Database: <strong>{{ database.name }}</strong></li>
|
||||
<ul class="bullets">
|
||||
{% for link in database.links %}
|
||||
<li><a href="{{ api_path(link) }}">{{ link.path }}</a> - {{ link.label }} </li>
|
||||
{% endfor %}
|
||||
{% for table in database.tables %}
|
||||
<li><strong>{{ table.name }}</strong>
|
||||
<ul class="bullets">
|
||||
{% for link in table.links %}
|
||||
<li><a href="{{ api_path(link) }}">{{ link.path }}</a> - {{ link.label }} </li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
{% import "_crumbs.html" as crumbs with context %}<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ urls.static('app.css') }}?{{ app_css_hash }}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
{% for url in extra_css_urls %}
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
<link rel="stylesheet" href="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}>
|
||||
{% endfor %}
|
||||
<script>window.datasetteVersion = '{{ datasette_version }}';</script>
|
||||
<script src="{{ urls.static('datasette-manager.js') }}" defer></script>
|
||||
{% for url in extra_js_urls %}
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.sri %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
<script {% if url.module %}type="module" {% endif %}src="{{ url.url }}"{% if url.get("sri") %} integrity="{{ url.sri }}" crossorigin="anonymous"{% endif %}></script>
|
||||
{% endfor %}
|
||||
{%- if alternate_url_json -%}
|
||||
<link rel="alternate" type="application/json+datasette" href="{{ alternate_url_json }}">
|
||||
|
|
@ -17,9 +19,9 @@
|
|||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<div class="not-footer">
|
||||
<header><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
{% set links = menu_links() %}{% if links or show_logout %}
|
||||
<details class="nav-menu">
|
||||
<header class="hd"><nav>{% block nav %}{% block crumbs %}{{ crumbs.nav(request=request) }}{% endblock %}
|
||||
{% set links = menu_links() %}
|
||||
<details class="nav-menu details-menu">
|
||||
<summary><svg aria-labelledby="nav-menu-svg-title" role="img"
|
||||
fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16" width="16" height="16">
|
||||
|
|
@ -27,20 +29,18 @@
|
|||
<path fill-rule="evenodd" d="M1 2.75A.75.75 0 011.75 2h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 2.75zm0 5A.75.75 0 011.75 7h12.5a.75.75 0 110 1.5H1.75A.75.75 0 011 7.75zM1.75 12a.75.75 0 100 1.5h12.5a.75.75 0 100-1.5H1.75z"></path>
|
||||
</svg></summary>
|
||||
<div class="nav-menu-inner">
|
||||
{% if links %}
|
||||
<ul>
|
||||
<li><button type="button" class="button-as-link" data-navigation-search-open aria-haspopup="dialog" aria-expanded="false" aria-keyshortcuts="/">Jump to... <kbd class="keyboard-shortcut" aria-hidden="true" title="Keyboard shortcut: press / to open Jump to">/</kbd></button></li>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if show_logout %}
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
||||
<button class="button-as-link">Log out</button>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</details>
|
||||
{% if actor %}
|
||||
<div class="actor">
|
||||
<strong>{{ display_actor(actor) }}</strong>
|
||||
|
|
@ -70,5 +70,7 @@
|
|||
{% endfor %}
|
||||
|
||||
{% if select_templates %}<!-- Templates considered: {{ select_templates|join(", ") }} -->{% endif %}
|
||||
<script src="{{ urls.static('navigation-search.js') }}" defer></script>
|
||||
<navigation-search url="/-/jump"></navigation-search>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
123
datasette/templates/create_token.html
Normal file
123
datasette/templates/create_token.html
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create an API token{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style type="text/css">
|
||||
#restrict-permissions label {
|
||||
display: inline;
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1>Create an API token</h1>
|
||||
|
||||
<p>This token will allow API access with the same abilities as your current user, <strong>{{ request.actor.id }}</strong></p>
|
||||
|
||||
{% if token %}
|
||||
<div>
|
||||
<h2>Your API token</h2>
|
||||
<form>
|
||||
<input type="text" class="copyable" style="width: 40%" value="{{ token }}">
|
||||
<span class="copy-link-wrapper"></span>
|
||||
</form>
|
||||
<!--- show token in a <details> -->
|
||||
<details style="margin-top: 1em">
|
||||
<summary>Token details</summary>
|
||||
<pre>{{ token_bits|tojson(4) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<h2>Create another token</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
{% for error in errors %}
|
||||
<p class="message-error">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form class="core" action="{{ urls.path('-/create-token') }}" method="post">
|
||||
<div>
|
||||
<div class="select-wrapper" style="width: unset">
|
||||
<select name="expire_type">
|
||||
<option value="">Token never expires</option>
|
||||
<option value="minutes">Expires after X minutes</option>
|
||||
<option value="hours">Expires after X hours</option>
|
||||
<option value="days">Expires after X days</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="expire_duration" style="width: 10%">
|
||||
<input type="submit" value="Create token">
|
||||
|
||||
<details style="margin-top: 1em" id="restrict-permissions">
|
||||
<summary style="cursor: pointer;">Restrict actions that can be performed using this token</summary>
|
||||
<h2>All databases and tables</h2>
|
||||
<ul>
|
||||
{% for permission in all_actions %}
|
||||
<li><label><input type="checkbox" name="all:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% for database in database_with_tables %}
|
||||
<h2>All tables in "{{ database.name }}"</h2>
|
||||
<ul>
|
||||
{% for permission in database_actions %}
|
||||
<li><label><input type="checkbox" name="database:{{ database.encoded }}:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<h2>Specific tables</h2>
|
||||
{% for database in database_with_tables %}
|
||||
{% for table in database.tables %}
|
||||
<h3>{{ database.name }}: {{ table.name }}</h3>
|
||||
<ul>
|
||||
{% for permission in child_actions %}
|
||||
<li><label><input type="checkbox" name="resource:{{ database.encoded }}:{{ table.encoded }}:{{ permission }}"> {{ permission }}</label></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</details>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var expireDuration = document.querySelector('input[name="expire_duration"]');
|
||||
expireDuration.style.display = 'none';
|
||||
var expireType = document.querySelector('select[name="expire_type"]');
|
||||
function showHideExpireDuration() {
|
||||
if (expireType.value) {
|
||||
expireDuration.style.display = 'inline';
|
||||
expireDuration.setAttribute("placeholder", expireType.value.replace("Expires after X ", ""));
|
||||
} else {
|
||||
expireDuration.style.display = 'none';
|
||||
}
|
||||
}
|
||||
showHideExpireDuration();
|
||||
expireType.addEventListener('change', showHideExpireDuration);
|
||||
var copyInput = document.querySelector(".copyable");
|
||||
if (copyInput) {
|
||||
var wrapper = document.querySelector(".copy-link-wrapper");
|
||||
var button = document.createElement("button");
|
||||
button.className = "copyable-copy-button";
|
||||
button.setAttribute("type", "button");
|
||||
button.innerHTML = "Copy to clipboard";
|
||||
button.onclick = (ev) => {
|
||||
ev.preventDefault();
|
||||
copyInput.select();
|
||||
document.execCommand("copy");
|
||||
button.innerHTML = "Copied!";
|
||||
setTimeout(() => {
|
||||
button.innerHTML = "Copy to clipboard";
|
||||
}, 1500);
|
||||
};
|
||||
wrapper.appendChild(button);
|
||||
wrapper.insertAdjacentElement("afterbegin", button);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
13
datasette/templates/csrf_error.html
Normal file
13
datasette/templates/csrf_error.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}CSRF check failed{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Form origin check failed</h1>
|
||||
|
||||
<p>Your request's origin could not be validated. Please return to the form and submit it again.</p>
|
||||
|
||||
<details><summary>Technical details</summary>
|
||||
<p>Developers: consult Datasette's <a href="https://docs.datasette.io/en/latest/internals.html#csrf-protection">CSRF protection documentation</a>.</p>
|
||||
<p>Reason: {{ reason }}</p>
|
||||
</details>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -5,41 +5,34 @@
|
|||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}db db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header" style="border-color: #{{ database_color(database) }}">
|
||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
|
||||
{% set links = database_actions() %}{% if links %}
|
||||
<details class="actions-menu-links">
|
||||
<summary><svg aria-labelledby="actions-menu-links-title" role="img"
|
||||
style="color: #666" xmlns="http://www.w3.org/2000/svg"
|
||||
width="28" height="28" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<title id="actions-menu-links-title">Table actions</title>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg></summary>
|
||||
<div class="dropdown-menu">
|
||||
{% if links %}
|
||||
<ul>
|
||||
{% for link in links %}
|
||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set action_links, action_title = database_actions(), "Database actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_database() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if allow_execute_sql %}
|
||||
<form class="sql" action="{{ urls.database(database) }}" method="get">
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/query" method="get" data-parameters-url="{{ urls.database(database) }}/-/query/parameters">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
{% set parameter_names = [] %}
|
||||
{% set parameter_values = {} %}
|
||||
{% set sql_parameters_allow_expand = false %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
<p>
|
||||
<button id="sql-format" type="button" hidden>Format SQL</button>
|
||||
<input type="submit" value="Run SQL">
|
||||
|
|
@ -52,7 +45,7 @@
|
|||
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
|
||||
<ul class="bullets">
|
||||
{% for db_name in attached_databases %}
|
||||
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
<li><strong>{{ db_name }}</strong> - <a href="{{ urls.database(db_name) }}/-/query?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -65,10 +58,13 @@
|
|||
<li><a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if queries_more %}
|
||||
<p><a href="{{ urls.database(database) }}/-/queries">View {{ "{:,}".format(queries_count) }} quer{% if queries_count == 1 %}y{% else %}ies{% endif %}</a></p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if tables %}
|
||||
<h2 id="tables">Tables</h2>
|
||||
<h2 id="tables">Tables <a style="font-weight: normal; font-size: 0.75em; padding-left: 0.5em;" href="{{ urls.database(database) }}/-/schema">schema</a></h2>
|
||||
{% endif %}
|
||||
|
||||
{% for table in tables %}
|
||||
|
|
@ -76,7 +72,7 @@
|
|||
<div class="db-table">
|
||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||
<p>{% if table.count is none %}Many rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
@ -95,9 +91,15 @@
|
|||
{% endif %}
|
||||
|
||||
{% if allow_download %}
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
<p class="download-sqlite">Download SQLite DB: <a href="{{ urls.database(database) }}.db" rel="nofollow">{{ database }}.db</a> <em>{{ format_bytes(size) }}</em></p>
|
||||
{% endif %}
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
43
datasette/templates/debug_actions.html
Normal file
43
datasette/templates/debug_actions.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Registered Actions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Registered actions</h1>
|
||||
|
||||
{% set current_tab = "actions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p style="margin-bottom: 2em;">
|
||||
This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}.
|
||||
Actions are used by the permission system to control access to different features.
|
||||
</p>
|
||||
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Abbr</th>
|
||||
<th>Description</th>
|
||||
<th>Resource</th>
|
||||
<th>Takes Parent</th>
|
||||
<th>Takes Child</th>
|
||||
<th>Also Requires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for action in data %}
|
||||
<tr>
|
||||
<td><strong>{{ action.name }}</strong></td>
|
||||
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
|
||||
<td>{{ action.description or "" }}</td>
|
||||
<td>{% if action.resource_class %}<code>{{ action.resource_class }}</code>{% endif %}</td>
|
||||
<td>{% if action.takes_parent %}✓{% endif %}</td>
|
||||
<td>{% if action.takes_child %}✓{% endif %}</td>
|
||||
<td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% endblock %}
|
||||
229
datasette/templates/debug_allowed.html
Normal file
229
datasette/templates/debug_allowed.html
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Allowed Resources{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Allowed resources</h1>
|
||||
|
||||
{% set current_tab = "allowed" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to check which resources the current actor is allowed to access for a given permission action. It queries the <code>/-/allowed.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="allowed-form" method="get" action="{{ urls.path("-/allowed") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in supported_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>Only certain actions are supported by this endpoint</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Filter by parent (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>Filter results to a specific parent resource</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Filter by child (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>Filter results to a specific child resource (requires parent to be set)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Allowed Resources</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('allowed-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const hasDebugPermission = {{ 'true' if has_debug_permission else 'false' }};
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function fetchResults(page = 1) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/allowed.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Allowed Resources';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total resources (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No allowed resources found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
if (hasDebugPermission) {
|
||||
html += '<th>Reason</th>';
|
||||
}
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
html += '<tr>';
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
if (hasDebugPermission) {
|
||||
// Display reason as JSON array
|
||||
let reasonHtml = '—';
|
||||
if (item.reason && Array.isArray(item.reason)) {
|
||||
reasonHtml = `<code>${escapeHtml(JSON.stringify(item.reason))}</code>`;
|
||||
}
|
||||
html += `<td>${reasonHtml}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = data.previous_url;
|
||||
prevLink.textContent = '← Previous';
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = data.next_url;
|
||||
nextLink.textContent = 'Next →';
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
parentInput.addEventListener('input', () => {
|
||||
childInput.disabled = !parentInput.value;
|
||||
if (!parentInput.value) {
|
||||
childInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize disabled state
|
||||
childInput.disabled = !parentInput.value;
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
270
datasette/templates/debug_check.html
Normal file
270
datasette/templates/debug_check.html
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Check{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
<style>
|
||||
#output {
|
||||
margin-top: 2em;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
#output.allowed {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
}
|
||||
#output.denied {
|
||||
background-color: #ffebee;
|
||||
border: 2px solid #f44336;
|
||||
}
|
||||
#output h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
#output .result-badge {
|
||||
display: inline-block;
|
||||
padding: 0.3em 0.8em;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
#output .allowed-badge {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
#output .denied-badge {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
}
|
||||
.details-section {
|
||||
margin-top: 1em;
|
||||
}
|
||||
.details-section dt {
|
||||
font-weight: bold;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.details-section dd {
|
||||
margin-left: 1em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission check</h1>
|
||||
|
||||
{% set current_tab = "check" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to test permission checks for the current actor. It queries the <code>/-/check.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="check-form" method="get" action="{{ urls.path("-/check") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="parent">Parent resource (optional):</label>
|
||||
<input type="text" id="parent" name="parent" placeholder="e.g., database name">
|
||||
<small>For database-level permissions, specify the database name</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="child">Child resource (optional):</label>
|
||||
<input type="text" id="child" name="child" placeholder="e.g., table name">
|
||||
<small>For table-level permissions, specify the table name (requires parent)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">Check Permission</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="output" style="display: none;">
|
||||
<h2>Result: <span class="result-badge" id="result-badge"></span></h2>
|
||||
|
||||
<dl class="details-section">
|
||||
<dt>Action:</dt>
|
||||
<dd id="result-action"></dd>
|
||||
|
||||
<dt>Resource Path:</dt>
|
||||
<dd id="result-resource"></dd>
|
||||
|
||||
<dt>Actor ID:</dt>
|
||||
<dd id="result-actor"></dd>
|
||||
|
||||
<div id="additional-details"></div>
|
||||
</dl>
|
||||
|
||||
<details style="margin-top: 1em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('check-form');
|
||||
const output = document.getElementById('output');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
async function performCheck() {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Checking...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/check.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResult(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Check Permission';
|
||||
}
|
||||
}
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
if (action) {
|
||||
performCheck();
|
||||
}
|
||||
})();
|
||||
|
||||
function displayResult(data) {
|
||||
output.style.display = 'block';
|
||||
|
||||
// Set badge and styling
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
if (data.allowed) {
|
||||
output.className = 'allowed';
|
||||
resultBadge.className = 'result-badge allowed-badge';
|
||||
resultBadge.textContent = 'ALLOWED ✓';
|
||||
} else {
|
||||
output.className = 'denied';
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'DENIED ✗';
|
||||
}
|
||||
|
||||
// Basic details
|
||||
document.getElementById('result-action').textContent = data.action || 'N/A';
|
||||
document.getElementById('result-resource').textContent = data.resource?.path || '/';
|
||||
document.getElementById('result-actor').textContent = data.actor_id || 'anonymous';
|
||||
|
||||
// Additional details
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '';
|
||||
|
||||
if (data.reason !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Reason:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.reason || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.source_plugin !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Source Plugin:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.source_plugin || 'N/A';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.used_default !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Used Default:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.used_default ? 'Yes' : 'No';
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
if (data.depth !== undefined) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Depth:';
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = data.depth;
|
||||
additionalDetails.appendChild(dt);
|
||||
additionalDetails.appendChild(dd);
|
||||
}
|
||||
|
||||
// Raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
// Scroll to output
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
output.style.display = 'block';
|
||||
output.className = 'denied';
|
||||
|
||||
const resultBadge = document.getElementById('result-badge');
|
||||
resultBadge.className = 'result-badge denied-badge';
|
||||
resultBadge.textContent = 'ERROR';
|
||||
|
||||
document.getElementById('result-action').textContent = 'N/A';
|
||||
document.getElementById('result-resource').textContent = 'N/A';
|
||||
document.getElementById('result-actor').textContent = 'N/A';
|
||||
|
||||
const additionalDetails = document.getElementById('additional-details');
|
||||
additionalDetails.innerHTML = '<dt>Error:</dt><dd>' + (data.error || 'Unknown error') + '</dd>';
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
|
||||
output.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Disable child input if parent is empty
|
||||
const parentInput = document.getElementById('parent');
|
||||
const childInput = document.getElementById('child');
|
||||
|
||||
childInput.addEventListener('focus', () => {
|
||||
if (!parentInput.value) {
|
||||
alert('Please specify a parent resource first before adding a child resource.');
|
||||
parentInput.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
165
datasette/templates/debug_permissions_playground.html
Normal file
165
datasette/templates/debug_permissions_playground.html
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug permissions{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
<style type="text/css">
|
||||
.check-result-true {
|
||||
color: green;
|
||||
}
|
||||
.check-result-false {
|
||||
color: red;
|
||||
}
|
||||
.check-result-no-opinion {
|
||||
color: #aaa;
|
||||
}
|
||||
.check h2 {
|
||||
font-size: 1em
|
||||
}
|
||||
.check-action, .check-when, .check-result {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
textarea {
|
||||
height: 10em;
|
||||
width: 95%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
border: 2px dotted black;
|
||||
}
|
||||
.two-col {
|
||||
display: inline-block;
|
||||
width: 48%;
|
||||
}
|
||||
.two-col label {
|
||||
width: 48%;
|
||||
}
|
||||
@media only screen and (max-width: 576px) {
|
||||
.two-col {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission playground</h1>
|
||||
|
||||
{% set current_tab = "permissions" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>This tool lets you simulate an actor and a permission check for that actor.</p>
|
||||
|
||||
<div class="permission-form">
|
||||
<form action="{{ urls.path('-/permissions') }}" id="debug-post" method="post">
|
||||
<div class="two-col">
|
||||
<div class="form-section">
|
||||
<label>Actor</label>
|
||||
<textarea name="actor">{% if actor_input %}{{ actor_input }}{% else %}{"id": "root"}{% endif %}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-col" style="vertical-align: top">
|
||||
<div class="form-section">
|
||||
<label for="permission">Action</label>
|
||||
<select name="permission" id="permission">
|
||||
{% for permission in permissions %}
|
||||
<option value="{{ permission.name }}">{{ permission.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_1">Parent</label>
|
||||
<input type="text" id="resource_1" name="resource_1" placeholder="e.g., database name">
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<label for="resource_2">Child</label>
|
||||
<input type="text" id="resource_2" name="resource_2" placeholder="e.g., table name">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn">Simulate permission check</button>
|
||||
</div>
|
||||
<pre style="margin-top: 1em" id="debugResult"></pre>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var rawPerms = {{ permissions|tojson }};
|
||||
var permissions = Object.fromEntries(rawPerms.map(p => [p.name, p]));
|
||||
var permissionSelect = document.getElementById('permission');
|
||||
var resource1 = document.getElementById('resource_1');
|
||||
var resource2 = document.getElementById('resource_2');
|
||||
var resource1Section = resource1.closest('.form-section');
|
||||
var resource2Section = resource2.closest('.form-section');
|
||||
function updateResourceVisibility() {
|
||||
var permission = permissionSelect.value;
|
||||
var {takes_parent, takes_child} = permissions[permission];
|
||||
resource1Section.style.display = takes_parent ? 'block' : 'none';
|
||||
resource2Section.style.display = takes_child ? 'block' : 'none';
|
||||
}
|
||||
permissionSelect.addEventListener('change', updateResourceVisibility);
|
||||
updateResourceVisibility();
|
||||
|
||||
// When #debug-post form is submitted, use fetch() to POST data
|
||||
var debugPost = document.getElementById('debug-post');
|
||||
var debugResult = document.getElementById('debugResult');
|
||||
debugPost.addEventListener('submit', function(ev) {
|
||||
ev.preventDefault();
|
||||
var formData = new FormData(debugPost);
|
||||
fetch(debugPost.action, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams(formData),
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed with status ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
}).then(function(data) {
|
||||
debugResult.innerText = JSON.stringify(data, null, 4);
|
||||
}).catch(function(error) {
|
||||
debugResult.innerText = JSON.stringify({ error: error.message }, null, 4);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>Recent permissions checks</h1>
|
||||
|
||||
<p>
|
||||
{% if filter != "all" %}<a href="?filter=all">All</a>{% else %}<strong>All</strong>{% endif %},
|
||||
{% if filter != "exclude-yours" %}<a href="?filter=exclude-yours">Exclude yours</a>{% else %}<strong>Exclude yours</strong>{% endif %},
|
||||
{% if filter != "only-yours" %}<a href="?filter=only-yours">Only yours</a>{% else %}<strong>Only yours</strong>{% endif %}
|
||||
</p>
|
||||
|
||||
{% if permission_checks %}
|
||||
<table class="rows-and-columns permission-checks-table" id="permission-checks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Parent</th>
|
||||
<th>Child</th>
|
||||
<th>Actor</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for check in permission_checks %}
|
||||
<tr>
|
||||
<td><span style="font-size: 0.8em">{{ check.when.split('T', 1)[0] }}</span><br>{{ check.when.split('T', 1)[1].split('+', 1)[0].split('-', 1)[0].split('Z', 1)[0] }}</td>
|
||||
<td><code>{{ check.action }}</code></td>
|
||||
<td>{{ check.parent or '—' }}</td>
|
||||
<td>{{ check.child or '—' }}</td>
|
||||
<td>{% if check.actor %}<code>{{ check.actor|tojson }}</code>{% else %}<span class="check-actor-anon">anonymous</span>{% endif %}</td>
|
||||
<td>{% if check.result %}<span class="check-result check-result-true">Allowed</span>{% elif check.result is none %}<span class="check-result check-result-no-opinion">No opinion</span>{% else %}<span class="check-result check-result-false">Denied</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-results">No permission checks have been recorded yet.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
203
datasette/templates/debug_rules.html
Normal file
203
datasette/templates/debug_rules.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Permission Rules{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script src="{{ base_url }}-/static/json-format-highlight-1.0.1.js"></script>
|
||||
{% include "_permission_ui_styles.html" %}
|
||||
{% include "_debug_common_functions.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Permission rules</h1>
|
||||
|
||||
{% set current_tab = "rules" %}
|
||||
{% include "_permissions_debug_tabs.html" %}
|
||||
|
||||
<p>Use this tool to view the permission rules that allow the current actor to access resources for a given permission action. It queries the <code>/-/rules.json</code> API endpoint.</p>
|
||||
|
||||
{% if request.actor %}
|
||||
<p>Current actor: <strong>{{ request.actor.get("id", "anonymous") }}</strong></p>
|
||||
{% else %}
|
||||
<p>Current actor: <strong>anonymous (not logged in)</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-form">
|
||||
<form id="rules-form" method="get" action="{{ urls.path("-/rules") }}">
|
||||
<div class="form-section">
|
||||
<label for="action">Action (permission name):</label>
|
||||
<select id="action" name="action" required>
|
||||
<option value="">Select an action...</option>
|
||||
{% for action_name in sorted_actions %}
|
||||
<option value="{{ action_name }}">{{ action_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small>The permission action to check</small>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<label for="page_size">Page size:</label>
|
||||
<input type="number" id="page_size" name="page_size" value="50" min="1" max="200" style="max-width: 100px;">
|
||||
<small>Number of results per page (max 200)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-btn" id="submit-btn">View Permission Rules</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<div class="results-header">
|
||||
<h2>Results</h2>
|
||||
<div class="results-count" id="results-count"></div>
|
||||
</div>
|
||||
|
||||
<div id="results-content"></div>
|
||||
|
||||
<div id="pagination" class="pagination"></div>
|
||||
|
||||
<details style="margin-top: 2em;">
|
||||
<summary style="cursor: pointer; font-weight: bold;">Raw JSON response</summary>
|
||||
<pre id="raw-json" style="margin-top: 1em; padding: 1em; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 3px; overflow-x: auto;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('rules-form');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsContent = document.getElementById('results-content');
|
||||
const resultsCount = document.getElementById('results-count');
|
||||
const pagination = document.getElementById('pagination');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
|
||||
// Populate form on initial load
|
||||
(function() {
|
||||
const params = populateFormFromURL();
|
||||
const action = params.get('action');
|
||||
const page = params.get('page');
|
||||
if (action) {
|
||||
fetchResults(page ? parseInt(page) : 1);
|
||||
}
|
||||
})();
|
||||
|
||||
async function fetchResults(page = 1) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Loading...';
|
||||
|
||||
const formData = new FormData(form);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && key !== 'page_size') {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const pageSize = document.getElementById('page_size').value || '50';
|
||||
params.append('page', page.toString());
|
||||
params.append('page_size', pageSize);
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ urls.path("-/rules.json") }}?' + params.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
} else {
|
||||
displayError(data);
|
||||
}
|
||||
} catch (error) {
|
||||
displayError({ error: error.message });
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'View Permission Rules';
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
|
||||
// Update count
|
||||
resultsCount.textContent = `Showing ${data.items.length} of ${data.total} total rules (page ${data.page})`;
|
||||
|
||||
// Display results table
|
||||
if (data.items.length === 0) {
|
||||
resultsContent.innerHTML = '<div class="no-results">No permission rules found for this action.</div>';
|
||||
} else {
|
||||
let html = '<table class="results-table">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th>Effect</th>';
|
||||
html += '<th>Resource Path</th>';
|
||||
html += '<th>Parent</th>';
|
||||
html += '<th>Child</th>';
|
||||
html += '<th>Source Plugin</th>';
|
||||
html += '<th>Reason</th>';
|
||||
html += '</tr></thead>';
|
||||
html += '<tbody>';
|
||||
|
||||
for (const item of data.items) {
|
||||
const rowClass = item.allow ? 'allow-row' : 'deny-row';
|
||||
const effectBadge = item.allow
|
||||
? '<span style="background: #4caf50; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">ALLOW</span>'
|
||||
: '<span style="background: #f44336; color: white; padding: 0.2em 0.5em; border-radius: 3px; font-weight: bold;">DENY</span>';
|
||||
|
||||
html += `<tr class="${rowClass}">`;
|
||||
html += `<td>${effectBadge}</td>`;
|
||||
html += `<td><span class="resource-path">${escapeHtml(item.resource || '/')}</span></td>`;
|
||||
html += `<td>${escapeHtml(item.parent || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.child || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.source_plugin || '—')}</td>`;
|
||||
html += `<td>${escapeHtml(item.reason || '—')}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
resultsContent.innerHTML = html;
|
||||
}
|
||||
|
||||
// Update pagination
|
||||
pagination.innerHTML = '';
|
||||
if (data.previous_url || data.next_url) {
|
||||
if (data.previous_url) {
|
||||
const prevLink = document.createElement('a');
|
||||
prevLink.href = data.previous_url;
|
||||
prevLink.textContent = '← Previous';
|
||||
pagination.appendChild(prevLink);
|
||||
}
|
||||
|
||||
const pageInfo = document.createElement('span');
|
||||
pageInfo.textContent = `Page ${data.page}`;
|
||||
pagination.appendChild(pageInfo);
|
||||
|
||||
if (data.next_url) {
|
||||
const nextLink = document.createElement('a');
|
||||
nextLink.href = data.next_url;
|
||||
nextLink.textContent = 'Next →';
|
||||
pagination.appendChild(nextLink);
|
||||
}
|
||||
}
|
||||
|
||||
// Update raw JSON
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
function displayError(data) {
|
||||
resultsContainer.style.display = 'block';
|
||||
resultsCount.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
|
||||
resultsContent.innerHTML = `<div class="error-message">Error: ${escapeHtml(data.error || 'Unknown error')}</div>`;
|
||||
|
||||
document.getElementById('raw-json').innerHTML = jsonFormatHighlight(data);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
314
datasette/templates/execute_write.html
Normal file
314
datasette/templates/execute_write.html
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Write to this database{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
<style>
|
||||
.execute-write-template-menu {
|
||||
margin: 0.9rem 0 0.8rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.execute-write-template-menu summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.execute-write-template-controls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
}
|
||||
.execute-write-template-menu .execute-write-template-controls label {
|
||||
margin-right: 0.25rem;
|
||||
width: auto;
|
||||
}
|
||||
.execute-write-template-controls select,
|
||||
.execute-write-template-controls button[type=button] {
|
||||
box-sizing: border-box;
|
||||
font-size: 0.78rem;
|
||||
height: 2rem;
|
||||
line-height: 1.1;
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
.execute-write-template-controls select {
|
||||
background-color: #fff;
|
||||
border: 1px solid #777;
|
||||
border-radius: 0.25rem;
|
||||
min-width: 13rem;
|
||||
}
|
||||
</style>
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
{% include "_sql_parameter_styles.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}execute-write db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Write to this database</h1>
|
||||
|
||||
<p>Execute SQL to insert, update or delete rows in this database.</p>
|
||||
|
||||
{% if execution_message %}
|
||||
<p class="{% if execution_ok %}message-info{% else %}message-error{% endif %}">{{ execution_message }}{% for link in execution_links %} <a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/execute-write" method="post" data-analyze-url="{{ urls.database(database) }}/-/execute-write/analyze">
|
||||
{% if write_template_tables %}
|
||||
<div class="execute-write-template-menu">
|
||||
<details>
|
||||
<summary>Start with a template</summary>
|
||||
<p class="execute-write-template-controls">
|
||||
<label for="execute-write-template-table">Table</label>
|
||||
<select id="execute-write-template-table">
|
||||
{% for table_name, columns in write_template_tables|dictsort %}
|
||||
<option value="{{ table_name }}">{{ table_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" data-sql-template="insert">Insert row</button>
|
||||
<button type="button" data-sql-template="update">Update rows</button>
|
||||
<button type="button" data-sql-template="delete">Delete rows</button>
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
{% set sql_parameters_section_id = "execute-write-parameters-section" %}
|
||||
{% set sql_parameters_allow_expand = true %}
|
||||
{% include "_sql_parameters.html" %}
|
||||
|
||||
<div id="execute-write-analysis-section">
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<input type="submit" value="Execute" data-execute-write-submit{% if execute_disabled %} disabled{% endif %}>
|
||||
{% if save_query_base_url %}<a href="{{ save_query_url or save_query_base_url }}" class="save-query" data-save-query-link data-save-query-base-url="{{ save_query_base_url }}"{% if not save_query_url %} hidden{% endif %}>Save this query</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const executeWriteSqlInput = document.querySelector("textarea#sql-editor");
|
||||
if (executeWriteSqlInput && !executeWriteSqlInput.value) {
|
||||
executeWriteSqlInput.value = "\n\n\n";
|
||||
}
|
||||
</script>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#execute-write-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-execute-write-submit]")
|
||||
: null;
|
||||
const saveQueryLink = form
|
||||
? form.querySelector("[data-save-query-link]")
|
||||
: null;
|
||||
|
||||
function updateSaveQueryLink(data) {
|
||||
if (!saveQueryLink) {
|
||||
return;
|
||||
}
|
||||
const sql = window.editor
|
||||
? window.editor.state.doc.toString()
|
||||
: executeWriteSqlInput.value;
|
||||
if (!sql.trim() || !data.ok || data.execute_disabled) {
|
||||
saveQueryLink.hidden = true;
|
||||
return;
|
||||
}
|
||||
const url = new URL(saveQueryLink.dataset.saveQueryBaseUrl, window.location.href);
|
||||
url.searchParams.set("sql", sql);
|
||||
saveQueryLink.href = url.pathname + url.search + url.hash;
|
||||
saveQueryLink.hidden = false;
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
allowExpand: true,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.execute_disabled;
|
||||
}
|
||||
updateSaveQueryLink(data);
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
if (saveQueryLink) {
|
||||
saveQueryLink.hidden = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% if write_template_tables %}
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const tableColumns = {{ write_template_tables|tojson(2) }};
|
||||
const tableSelect = document.querySelector("#execute-write-template-table");
|
||||
const templateButtons = document.querySelectorAll("[data-sql-template]");
|
||||
|
||||
function quoteIdentifier(identifier) {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function parameterNames(columns) {
|
||||
const seen = new Set();
|
||||
const names = {};
|
||||
columns.forEach((column) => {
|
||||
let base = column
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
if (!base) {
|
||||
base = "value";
|
||||
}
|
||||
if (/^[0-9]/.test(base)) {
|
||||
base = `p_${base}`;
|
||||
}
|
||||
let name = base;
|
||||
let index = 2;
|
||||
while (seen.has(name)) {
|
||||
name = `${base}_${index}`;
|
||||
index += 1;
|
||||
}
|
||||
seen.add(name);
|
||||
names[column] = name;
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function preferredWhereColumn(table, columns) {
|
||||
const lowerTableId = `${table.toLowerCase()}_id`;
|
||||
return (
|
||||
columns.find((column) => column.toLowerCase() === "id") ||
|
||||
columns.find((column) => column.toLowerCase() === lowerTableId) ||
|
||||
columns[0]
|
||||
);
|
||||
}
|
||||
|
||||
function insertSql(table, columns) {
|
||||
const names = parameterNames(columns);
|
||||
return [
|
||||
`insert into ${quoteIdentifier(table)} (`,
|
||||
columns.map((column) => ` ${quoteIdentifier(column)}`).join(",\n"),
|
||||
")",
|
||||
"values (",
|
||||
columns.map((column) => ` :${names[column]}`).join(",\n"),
|
||||
")",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function updateSql(table, columns) {
|
||||
const names = parameterNames(columns);
|
||||
const whereColumn = preferredWhereColumn(table, columns);
|
||||
const setColumns = columns.filter((column) => column !== whereColumn);
|
||||
if (!setColumns.length) {
|
||||
return [
|
||||
`update ${quoteIdentifier(table)}`,
|
||||
`set ${quoteIdentifier(whereColumn)} = :new_${names[whereColumn]}`,
|
||||
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
`update ${quoteIdentifier(table)}`,
|
||||
"set " +
|
||||
setColumns
|
||||
.map((column, index) => {
|
||||
const indent = index ? " " : "";
|
||||
return `${indent}${quoteIdentifier(column)} = :${names[column]}`;
|
||||
})
|
||||
.join(",\n"),
|
||||
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function deleteSql(table, columns) {
|
||||
const names = parameterNames(columns);
|
||||
const whereColumn = preferredWhereColumn(table, columns);
|
||||
return [
|
||||
`delete from ${quoteIdentifier(table)}`,
|
||||
`where ${quoteIdentifier(whereColumn)} = :${names[whereColumn]}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function templateSql(operation, table, columns) {
|
||||
if (operation === "insert") {
|
||||
return insertSql(table, columns);
|
||||
}
|
||||
if (operation === "update") {
|
||||
return updateSql(table, columns);
|
||||
}
|
||||
return deleteSql(table, columns);
|
||||
}
|
||||
|
||||
templateButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const table = tableSelect.value;
|
||||
const columns = tableColumns[table] || [];
|
||||
if (!columns.length) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(
|
||||
"sql",
|
||||
templateSql(button.dataset.sqlTemplate, table, columns)
|
||||
);
|
||||
window.location.href = url.toString();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,17 +2,26 @@
|
|||
|
||||
{% block title %}{{ metadata.title or "Datasette" }}: {% for database in databases %}{{ database.name }}{% if not loop.last %}, {% endif %}{% endfor %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if noindex %}<meta name="robots" content="noindex">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}index{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
|
||||
|
||||
{% set action_links, action_title = homepage_actions, "Homepage actions" %}
|
||||
{% include "_action_menu.html" %}
|
||||
|
||||
{{ top_homepage() }}
|
||||
|
||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% for database in databases %}
|
||||
<h2 style="padding-left: 10px; border-left: 10px solid #{{ database.color }}"><a href="{{ urls.database(database.name) }}">{{ database.name }}</a>{% if database.private %} 🔒{% endif %}</h2>
|
||||
<p>
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.tables_count and database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.table_rows_sum) }} rows in {% endif %}{{ database.tables_count }} table{% if database.tables_count != 1 %}s{% endif %}{% if database.hidden_tables_count %}, {% endif -%}
|
||||
{% if database.hidden_tables_count -%}
|
||||
{% if database.show_table_row_counts %}{{ "{:,}".format(database.hidden_table_rows_sum) }} rows in {% endif %}{{ database.hidden_tables_count }} hidden table{% if database.hidden_tables_count != 1 %}s{% endif -%}
|
||||
{% endif -%}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
|
||||
<p>You are logged in as <strong>{{ display_actor(actor) }}</strong></p>
|
||||
|
||||
<form action="{{ urls.logout() }}" method="post">
|
||||
<form class="core" action="{{ urls.logout() }}" method="post">
|
||||
<div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Log out">
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<p>Set a message:</p>
|
||||
|
||||
<form action="{{ urls.path('-/messages') }}" method="post">
|
||||
<form class="core" action="{{ urls.path('-/messages') }}" method="post">
|
||||
<div>
|
||||
<input type="text" name="message" style="width: 40%">
|
||||
<div class="select-wrapper">
|
||||
|
|
@ -19,7 +19,6 @@
|
|||
<option>all</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||
<input type="submit" value="Add message">
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue