pmtiles-swarm 0.60.0 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/docs/configuration.md +1 -1
- package/docs/internals.md +1 -1
- package/docs/serving-tiles.md +2 -2
- package/docs/tilejson.md +2 -2
- package/package.json +1 -1
- package/src/api.js +14 -3
- package/src/web/index.html +81 -17
- package/src/web/public.html +53 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.61.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **"Style URL" is now "source URL", because that is what it is.** It goes in a style's
|
|
13
|
+
`sources` block and is not itself a style, so the old name told a reader to put it in the
|
|
14
|
+
wrong place. The field on `/api/categories` and `/latest/` is `sourceUrl`; `styleUrl` is
|
|
15
|
+
still sent alongside it and is deprecated, so nothing reading the old name breaks on the
|
|
16
|
+
correction.
|
|
17
|
+
|
|
18
|
+
- **A copy button copies, rather than opening a box to copy from.** `navigator.clipboard`
|
|
19
|
+
needs a secure context and a console reached by IP over plain HTTP on a LAN is not one —
|
|
20
|
+
which is how most of them are reached — so the console fell back to `window.prompt` every
|
|
21
|
+
time. It now falls back to the selection API, which predates the clipboard API and carries
|
|
22
|
+
no such requirement, and confirms on the button itself the way the public page does. The
|
|
23
|
+
public page gained the same fallback: it was failing outright wherever the console was
|
|
24
|
+
prompting, which is the same nodes.
|
|
25
|
+
|
|
26
|
+
- **The XYZ template is offered on the public catalogue page too**, beside the TileJSON it
|
|
27
|
+
already had. 0.60.0 added it to the console only, which is the wrong way round: the console
|
|
28
|
+
is for the operator, and the person who needs a tile URL to paste into a Leaflet layer or a
|
|
29
|
+
GIS client is usually looking at the public page. Both draw from the same builder, so the
|
|
30
|
+
field was already in the public payload and only the button was missing.
|
|
31
|
+
|
|
32
|
+
### 🐞 Bug fixes
|
|
33
|
+
- _...Add new stuff here..._
|
|
34
|
+
|
|
10
35
|
## 0.60.0
|
|
11
36
|
### ✨ Features and improvements
|
|
12
37
|
- **A tile URL that survives a rebuild.** Every archive is addressed by infohash, which is
|
package/docs/configuration.md
CHANGED
|
@@ -407,7 +407,7 @@ on. This is that address.
|
|
|
407
407
|
Narrower than `publicUrl` on purpose: `publicUrl` overrides every URL the node
|
|
408
408
|
emits and so gives up the multi-domain behaviour, while this overrides only the
|
|
409
409
|
ones that have to be permanent. Everything else — TileJSON, tile templates,
|
|
410
|
-
`.torrent` links,
|
|
410
|
+
`.torrent` links, source URLs, the feeds — goes on naming whichever host the
|
|
411
411
|
request arrived as.
|
|
412
412
|
|
|
413
413
|
```json
|
package/docs/internals.md
CHANGED
|
@@ -725,7 +725,7 @@ own and asks for nothing guarded.
|
|
|
725
725
|
|
|
726
726
|
Three things joined the public list to make it work, and each is a read of
|
|
727
727
|
something already published: `/api/categories`, which groups the same archives
|
|
728
|
-
and carries the
|
|
728
|
+
and carries the source URL for each; the per-archive `/preview`; and `/vendor/`,
|
|
729
729
|
which is the MapLibre bundle the preview renders with. The preview used to be
|
|
730
730
|
excluded on the grounds that it is console furniture and would not render
|
|
731
731
|
without `/vendor` anyway — both true, and both answered by publishing the pair
|
package/docs/serving-tiles.md
CHANGED
|
@@ -45,7 +45,7 @@ See [internals.md](internals.md#serving-an-mbtiles-archive).
|
|
|
45
45
|
|
|
46
46
|
`GET /latest/` lists every category this node publishes, with the endpoints
|
|
47
47
|
that resolve to each one's newest build — the TileJSON, the `.torrent`, the
|
|
48
|
-
magnet, the per-category feed, and the
|
|
48
|
+
magnet, the per-category feed, and the source URL with the magnet in its
|
|
49
49
|
fragment.
|
|
50
50
|
|
|
51
51
|
Public, and deliberately so. Everything else under `/latest/` is — the
|
|
@@ -274,7 +274,7 @@ everywhere:
|
|
|
274
274
|
| torrent-aware | joins **before any network call**, and still can if the fetch fails |
|
|
275
275
|
|
|
276
276
|
The console's **Copy TileJSON URL + swarm** button produces exactly this, and so
|
|
277
|
-
does the `
|
|
277
|
+
does the `sourceUrl` on every row of `/api/categories` and `/latest/`.
|
|
278
278
|
|
|
279
279
|
### Two handles, and why both
|
|
280
280
|
|
package/docs/tilejson.md
CHANGED
|
@@ -110,7 +110,7 @@ minor one. **A browser has no DHT** — WebTorrent's `browser` field maps
|
|
|
110
110
|
UDP or TCP sockets at all. Given only a public key, a browser would have to fetch
|
|
111
111
|
this TileJSON before it could join anything. Since this magnet is routinely
|
|
112
112
|
carried in the _fragment of the TileJSON URL itself_ — see
|
|
113
|
-
[`
|
|
113
|
+
[`sourceUrl`](#where-this-magnet-shows-up) — that would make the fragment useless
|
|
114
114
|
to the one client most likely to be reading it.
|
|
115
115
|
|
|
116
116
|
The infohash does go stale on the next rebuild. That is acceptable and expected:
|
|
@@ -122,7 +122,7 @@ going to follow the series anyway. It is a starting point, not a subscription.
|
|
|
122
122
|
Three places, all built from the same function, so they agree:
|
|
123
123
|
|
|
124
124
|
- `torrent.mutable.magnet` in this document.
|
|
125
|
-
- The fragment on `
|
|
125
|
+
- The fragment on `sourceUrl`, from `GET /api/categories` — a
|
|
126
126
|
`…/latest/<category>/tiles.json#magnet:?…`. One string that a plain client
|
|
127
127
|
fetches over HTTP and a swarm client joins directly, with no extra round trip
|
|
128
128
|
for either.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/api.js
CHANGED
|
@@ -158,12 +158,17 @@ function withSwarmHandles(url, { torrent, magnet }) {
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* A TileJSON URL carrying the ways into the swarm in its fragment.
|
|
161
|
+
*
|
|
162
|
+
* A *source* URL, not a style one — it goes in a style's `sources` block, and
|
|
163
|
+
* a style is the document that would contain it. It was called the other thing
|
|
164
|
+
* for a while, which is a name that tells a reader to put it in the wrong
|
|
165
|
+
* place.
|
|
161
166
|
* @param {string} category - Which category.
|
|
162
167
|
* @param {object} newest - Its newest entry.
|
|
163
168
|
* @param {string} base - Public base URL.
|
|
164
|
-
* @returns {string} - The URL a
|
|
169
|
+
* @returns {string} - The URL a source should point at.
|
|
165
170
|
*/
|
|
166
|
-
function
|
|
171
|
+
function sourceUrlFor(category, newest, base) {
|
|
167
172
|
const url = `${base}/latest/${category}/tiles.json`;
|
|
168
173
|
const magnet = newest?.mutable?.publicKey
|
|
169
174
|
? mutableMagnet(newest.mutable.publicKey, {
|
|
@@ -2178,7 +2183,13 @@ export function createApp({
|
|
|
2178
2183
|
// and resolves the current build over the DHT. Otherwise the
|
|
2179
2184
|
// newest build's own magnet, which pins that build but still
|
|
2180
2185
|
// beats a blank map when the fallback is needed at all.
|
|
2181
|
-
|
|
2186
|
+
// What goes in a style's `sources` block. It is a TileJSON URL
|
|
2187
|
+
// with the ways into the swarm in its fragment, so it is a source
|
|
2188
|
+
// by every reading: what it addresses, and where it is written.
|
|
2189
|
+
sourceUrl: servable ? sourceUrlFor(category, newest, base) : null,
|
|
2190
|
+
// The name this had until 0.61.0, kept so a consumer that reads it
|
|
2191
|
+
// is not broken by the correction. Deprecated: read `sourceUrl`.
|
|
2192
|
+
styleUrl: servable ? sourceUrlFor(category, newest, base) : null,
|
|
2182
2193
|
// Points at the category, not at a build. The page reads the
|
|
2183
2194
|
// TileJSON beside it, so it renders whatever is current — which
|
|
2184
2195
|
// makes it the same URL a style holds, demonstrating itself rather
|
package/src/web/index.html
CHANGED
|
@@ -241,9 +241,11 @@
|
|
|
241
241
|
padding: 0 0.45rem;
|
|
242
242
|
}
|
|
243
243
|
button.copy:hover { color: var(--fg); border-color: var(--fg); }
|
|
244
|
+
/* The same green the public catalogue page confirms a copy with. */
|
|
245
|
+
button.copy.done { border-color: currentColor; color: #3fb950; }
|
|
244
246
|
/* Category endpoints. `th, td` is nowrap for the archive table, where a
|
|
245
247
|
wrapped number is worse than a wide one — but here the cell holds a
|
|
246
|
-
|
|
248
|
+
source URL and a paragraph explaining it, so nowrap pushed the table
|
|
247
249
|
wider than the window and took the Copy and Open buttons off the far
|
|
248
250
|
edge with it. Fixed layout keeps the two end columns where they are
|
|
249
251
|
and gives the middle whatever is left. */
|
|
@@ -999,17 +1001,60 @@
|
|
|
999
1001
|
return data;
|
|
1000
1002
|
}
|
|
1001
1003
|
|
|
1002
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Puts text on the clipboard, in a console that is usually not a secure
|
|
1006
|
+
* context.
|
|
1007
|
+
*
|
|
1008
|
+
* `navigator.clipboard` requires one, and a console reached by IP over
|
|
1009
|
+
* plain HTTP on a LAN is not — which is how most of them are reached. So
|
|
1010
|
+
* the interesting path here is the fallback, not the modern call: the
|
|
1011
|
+
* selection API predates the clipboard API and carries no such
|
|
1012
|
+
* requirement, so it works exactly where the other one does not.
|
|
1013
|
+
*
|
|
1014
|
+
* What this replaces was `window.prompt`, which does put the text
|
|
1015
|
+
* somewhere it can be copied from and makes the reader do the copying.
|
|
1016
|
+
* A button that says "copy" should copy.
|
|
1017
|
+
*
|
|
1018
|
+
* Deliberately duplicated in public.html rather than shared. Both pages
|
|
1019
|
+
* are single files with their script inlined, and one browser shim is a
|
|
1020
|
+
* poor reason to give that up.
|
|
1021
|
+
* @param {string} text - What to put on the clipboard.
|
|
1022
|
+
* @returns {Promise<boolean>} - Whether it landed.
|
|
1023
|
+
*/
|
|
1024
|
+
const toClipboard = async (text) => {
|
|
1003
1025
|
try {
|
|
1004
1026
|
await navigator.clipboard.writeText(text);
|
|
1005
|
-
|
|
1027
|
+
return true;
|
|
1006
1028
|
} catch {
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
|
|
1029
|
+
// Off-screen rather than hidden: an element with `display:none` or
|
|
1030
|
+
// `visibility:hidden` cannot hold a selection, so the copy would
|
|
1031
|
+
// silently do nothing.
|
|
1032
|
+
const field = document.createElement('textarea');
|
|
1033
|
+
field.value = text;
|
|
1034
|
+
field.setAttribute('readonly', '');
|
|
1035
|
+
field.style.position = 'fixed';
|
|
1036
|
+
field.style.top = '-1000px';
|
|
1037
|
+
document.body.append(field);
|
|
1038
|
+
field.select();
|
|
1039
|
+
// select() alone is not enough on iOS.
|
|
1040
|
+
field.setSelectionRange(0, field.value.length);
|
|
1041
|
+
try {
|
|
1042
|
+
return document.execCommand('copy');
|
|
1043
|
+
} catch {
|
|
1044
|
+
return false;
|
|
1045
|
+
} finally {
|
|
1046
|
+
field.remove();
|
|
1047
|
+
}
|
|
1010
1048
|
}
|
|
1011
1049
|
};
|
|
1012
1050
|
|
|
1051
|
+
const copy = async (text, what) => {
|
|
1052
|
+
if (await toClipboard(text)) return toast(`${what} copied`);
|
|
1053
|
+
// Both ways of reaching the clipboard refused. Showing the text is the
|
|
1054
|
+
// only thing left that is better than nothing.
|
|
1055
|
+
window.prompt(`Copy ${what}:`, text);
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1013
1058
|
// ── Archives ──────────────────────────────────────────────────────────
|
|
1014
1059
|
let archives = [];
|
|
1015
1060
|
let selected = null;
|
|
@@ -3676,7 +3721,7 @@ Every piece is hashed against the ` +
|
|
|
3676
3721
|
// torrent client, a feed reader — and following one from here
|
|
3677
3722
|
// achieves nothing, so they copy.
|
|
3678
3723
|
//
|
|
3679
|
-
// The
|
|
3724
|
+
// The source URL is not printed. It is a TileJSON URL carrying a
|
|
3680
3725
|
// `.torrent` URL and a percent-encoded magnet in its fragment,
|
|
3681
3726
|
// several hundred characters of it, and it was truncated to 96
|
|
3682
3727
|
// here — long enough to fill the row and too short to be the
|
|
@@ -3721,14 +3766,16 @@ Every piece is hashed against the ` +
|
|
|
3721
3766
|
${copyable(ends.magnet, 'magnet', true)}
|
|
3722
3767
|
${copyable(ends.feed, 'RSS')}
|
|
3723
3768
|
${copyable(ends.latestFeed, 'RSS, newest only')}
|
|
3724
|
-
${copyable(ends.
|
|
3769
|
+
${copyable(ends.sourceUrl, 'source URL')}
|
|
3725
3770
|
</div>
|
|
3726
3771
|
<div class="sub" style="margin-top:0.5rem">
|
|
3727
|
-
The
|
|
3728
|
-
the magnet in its fragment, which is never sent to
|
|
3729
|
-
server — an ordinary client fetches the TileJSON
|
|
3730
|
-
ignores them, a swarm-aware one reads them first,
|
|
3731
|
-
both follow the category rather than this build.
|
|
3772
|
+
The source URL is the TileJSON with the .torrent URL
|
|
3773
|
+
and the magnet in its fragment, which is never sent to
|
|
3774
|
+
the server — an ordinary client fetches the TileJSON
|
|
3775
|
+
and ignores them, a swarm-aware one reads them first,
|
|
3776
|
+
and both follow the category rather than this build.
|
|
3777
|
+
It goes in a style's <code>sources</code> block; it is
|
|
3778
|
+
not itself a style.
|
|
3732
3779
|
</div>`
|
|
3733
3780
|
: '<div class="sub">not a PMTiles archive, so there is no tile endpoint</div>'
|
|
3734
3781
|
}
|
|
@@ -3737,15 +3784,32 @@ Every piece is hashed against the ` +
|
|
|
3737
3784
|
.join('');
|
|
3738
3785
|
|
|
3739
3786
|
for (const button of list.querySelectorAll('[data-copy-url]')) {
|
|
3787
|
+
// The URL either way, so there is something to select by hand if
|
|
3788
|
+
// both routes to the clipboard are shut.
|
|
3789
|
+
button.title = button.dataset.copyUrl;
|
|
3790
|
+
const label = button.textContent;
|
|
3791
|
+
// Said on the button rather than in a toast: these sit in a row of
|
|
3792
|
+
// seven, and which one was pressed is the thing worth confirming.
|
|
3793
|
+
// The public catalogue page does the same, and the two are the same
|
|
3794
|
+
// control in two places.
|
|
3795
|
+
const said = (text, ok) => {
|
|
3796
|
+
button.textContent = text;
|
|
3797
|
+
if (ok) button.classList.add('done');
|
|
3798
|
+
setTimeout(() => {
|
|
3799
|
+
button.textContent = label;
|
|
3800
|
+
button.classList.remove('done');
|
|
3801
|
+
}, 1200);
|
|
3802
|
+
};
|
|
3740
3803
|
button.onclick = async () => {
|
|
3741
3804
|
const url = button.dataset.copyUrl;
|
|
3742
|
-
if (!button.dataset.copyFetch) return copy(url, 'URL');
|
|
3743
3805
|
try {
|
|
3744
3806
|
// A plain fetch, not `api`: that one parses JSON, and this
|
|
3745
3807
|
// endpoint answers text/plain.
|
|
3746
|
-
const
|
|
3747
|
-
|
|
3748
|
-
|
|
3808
|
+
const value = button.dataset.copyFetch
|
|
3809
|
+
? (await (await fetch(url)).text()).trim()
|
|
3810
|
+
: url;
|
|
3811
|
+
const landed = await toClipboard(value);
|
|
3812
|
+
said(landed ? 'copied' : 'it is in the tooltip', landed);
|
|
3749
3813
|
} catch (error) {
|
|
3750
3814
|
toast(error.message);
|
|
3751
3815
|
}
|
package/src/web/public.html
CHANGED
|
@@ -290,6 +290,48 @@
|
|
|
290
290
|
|
|
291
291
|
// A row of "label: copyable URL". The URLs are the point of the page, so
|
|
292
292
|
// they are shown in full rather than hidden behind link text.
|
|
293
|
+
/**
|
|
294
|
+
* Puts text on the clipboard, including where there is no secure context.
|
|
295
|
+
*
|
|
296
|
+
* `navigator.clipboard` requires one. This page is usually reached over
|
|
297
|
+
* HTTPS and so usually has it — but a node answering by IP on a LAN does
|
|
298
|
+
* not, and there the button did nothing but say so. The selection API
|
|
299
|
+
* predates the clipboard API and carries no such requirement, so it
|
|
300
|
+
* works exactly where the other one fails.
|
|
301
|
+
*
|
|
302
|
+
* Deliberately duplicated in index.html rather than shared. Both pages
|
|
303
|
+
* are single files with their script inlined, and one browser shim is a
|
|
304
|
+
* poor reason to give that up.
|
|
305
|
+
* @param {string} text - What to put on the clipboard.
|
|
306
|
+
* @returns {Promise<boolean>} - Whether it landed.
|
|
307
|
+
*/
|
|
308
|
+
const toClipboard = async (text) => {
|
|
309
|
+
try {
|
|
310
|
+
await navigator.clipboard.writeText(text);
|
|
311
|
+
return true;
|
|
312
|
+
} catch {
|
|
313
|
+
// Off-screen rather than hidden: an element with `display:none` or
|
|
314
|
+
// `visibility:hidden` cannot hold a selection, so the copy would
|
|
315
|
+
// silently do nothing.
|
|
316
|
+
const field = document.createElement('textarea');
|
|
317
|
+
field.value = text;
|
|
318
|
+
field.setAttribute('readonly', '');
|
|
319
|
+
field.style.position = 'fixed';
|
|
320
|
+
field.style.top = '-1000px';
|
|
321
|
+
document.body.append(field);
|
|
322
|
+
field.select();
|
|
323
|
+
// select() alone is not enough on iOS.
|
|
324
|
+
field.setSelectionRange(0, field.value.length);
|
|
325
|
+
try {
|
|
326
|
+
return document.execCommand('copy');
|
|
327
|
+
} catch {
|
|
328
|
+
return false;
|
|
329
|
+
} finally {
|
|
330
|
+
field.remove();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
293
335
|
const urlRow = (label, url) => {
|
|
294
336
|
const row = el('div');
|
|
295
337
|
row.append(el('span', null, label));
|
|
@@ -454,9 +496,11 @@
|
|
|
454
496
|
const value = fetched
|
|
455
497
|
? (await (await fetch(absolute)).text()).trim()
|
|
456
498
|
: absolute;
|
|
457
|
-
await
|
|
458
|
-
button.classList.
|
|
459
|
-
button.textContent =
|
|
499
|
+
const landed = await toClipboard(value);
|
|
500
|
+
button.classList.toggle('done', landed);
|
|
501
|
+
button.textContent = landed
|
|
502
|
+
? 'copied'
|
|
503
|
+
: 'copy failed — it is in the tooltip';
|
|
460
504
|
setTimeout(() => {
|
|
461
505
|
button.classList.remove('done');
|
|
462
506
|
button.textContent = text;
|
|
@@ -470,6 +514,11 @@
|
|
|
470
514
|
|
|
471
515
|
const ends = entry.endpoints;
|
|
472
516
|
if (ends.tileJson) copy(ends.tileJson, 'TileJSON');
|
|
517
|
+
// The XYZ template beside the document that carries it. Anything
|
|
518
|
+
// that takes a URL with braces in it rather than a TileJSON — a
|
|
519
|
+
// Leaflet layer, an OpenLayers source, a GIS client — wants this
|
|
520
|
+
// one, and it follows the category, so it survives a rebuild.
|
|
521
|
+
if (ends.xyz) copy(ends.xyz, 'XYZ');
|
|
473
522
|
// The newest build's preview, which is what "show me this
|
|
474
523
|
// category" means.
|
|
475
524
|
if (ends.preview) add(ends.preview, 'preview');
|
|
@@ -486,7 +535,7 @@
|
|
|
486
535
|
// URL carrying the .torrent URL and a percent-encoded magnet in its
|
|
487
536
|
// fragment. Printed in full it was most of the card — several
|
|
488
537
|
// hundred characters of it — and it is never read, only pasted.
|
|
489
|
-
if (ends.
|
|
538
|
+
if (ends.sourceUrl) copy(ends.sourceUrl, 'source URL');
|
|
490
539
|
card.append(links);
|
|
491
540
|
|
|
492
541
|
host.append(card);
|