pmtiles-swarm 0.5.6 → 0.7.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 +58 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/api.js +51 -0
- package/src/config.js +15 -0
- package/src/engines/libtorrent.js +34 -1
- package/src/hooks.js +42 -0
- package/src/index.js +2 -0
- package/src/subscriptions.js +44 -0
- package/src/web/index.html +233 -34
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,64 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.7.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A feed can be told how long to keep what it brings in.** `keep` and `keepDays` now work on a
|
|
13
|
+
subscription, applied by the same code a watched folder and a scheduled source retire under and
|
|
14
|
+
with the same guards: only after something new has landed, and never the newest copy. A feed
|
|
15
|
+
publishing weekly leaves a copy behind every week and the publisher goes on listing all of them,
|
|
16
|
+
so `keepDays: 10` against a weekly feed keeps a fortnight and drops the rest.
|
|
17
|
+
|
|
18
|
+
This is not what `prune` does, and the difference is why a feed needed its own answer. Pruning is
|
|
19
|
+
about the publisher — it stopped offering this, so let it go — and needs a complete listing for an
|
|
20
|
+
absence to mean anything, which is why it applies to a catalogue and not to a feed.
|
|
21
|
+
planet.openstreetmap.org lists five dumps and says nothing about the hundreds before them. Age is
|
|
22
|
+
age however short the list is.
|
|
23
|
+
- **Feeds and remote nodes are two sections rather than one table with a dropdown.** They are not
|
|
24
|
+
one thing in two costumes: a feed is bounded and says "here is what is new", so it caps items per
|
|
25
|
+
check and can never prune; a catalogue says "here is everything", which is the only thing that
|
|
26
|
+
makes an absence meaningful. Half the columns applied to one and half to the other. A row is now
|
|
27
|
+
RSS or API because of the table it sits in, which also means a saved row states its protocol
|
|
28
|
+
instead of leaving it to be guessed from the URL later. Rows written before this are sorted into
|
|
29
|
+
a section by the same rule the subscription manager itself uses.
|
|
30
|
+
- **A feed's save path is editable.** `savePath` was accepted in the configuration and offered
|
|
31
|
+
nowhere, so it could only be set by hand — and before the row editor learned to keep fields it
|
|
32
|
+
does not show, pressing Save would have deleted it.
|
|
33
|
+
|
|
34
|
+
## 0.6.0
|
|
35
|
+
### ✨ Features and improvements
|
|
36
|
+
- **Check now, on scheduled sources and on feeds.** A schedule describes ordinary operation, and
|
|
37
|
+
setting one up is not ordinary operation — waiting six hours to find out whether a URL template
|
|
38
|
+
is right is how a typo survives a working day. `POST /api/sources/check` is new; the feed
|
|
39
|
+
equivalent existed and had no button. Both check what is *saved* rather than what is on screen,
|
|
40
|
+
since an unsaved row is not a source this node knows about.
|
|
41
|
+
- **Run the completion hook again for one archive**, from its General tab or
|
|
42
|
+
`POST /api/torrents/<infohash>/hooks/complete`. Completion is recorded before the command runs,
|
|
43
|
+
so a build taking six hours is not started six times over — but a hook that failed for a reason
|
|
44
|
+
since fixed keeps that record too, and the only way to run it again was to stop the node and
|
|
45
|
+
edit the catalog by hand. Started rather than awaited, since the command may be a planet build;
|
|
46
|
+
refused with 409 if it is already running for that archive, because two builds writing the same
|
|
47
|
+
output is worse than waiting. It does not choose *which* command — that is still the config
|
|
48
|
+
file's business, and `allowHooksFromApi` still guards choosing it.
|
|
49
|
+
|
|
50
|
+
### 🐞 Bug fixes
|
|
51
|
+
- **The node no longer crashes while shutting down.** `child.stdin` had no `error` listener, and
|
|
52
|
+
an unhandled `'error'` event is not a rejected promise — it is a throw that takes the process
|
|
53
|
+
with it. systemd's default `KillMode` signals every process in a service's cgroup, so the Python
|
|
54
|
+
sidecar exited first and the shutdown request was written into a dead pipe:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
[shutdown] SIGTERM
|
|
58
|
+
Error: write EPIPE ... at #call (src/engines/libtorrent.js)
|
|
59
|
+
Main process exited, code=exited, status=1/FAILURE
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Every stop ended that way, and because the crash happened *inside* `destroy()`, the shutdown
|
|
63
|
+
that saves resume data never ran — so this was also a third, independent reason resume data
|
|
64
|
+
went missing. `Restart=always` brought the node back five seconds later looking healthy, which
|
|
65
|
+
is why it went unnoticed. A failed write now fails the call that made it; the pipe is checked
|
|
66
|
+
before writing; and there is a test that spawns a sidecar which reports ready and then exits.
|
|
67
|
+
|
|
10
68
|
## 0.5.6
|
|
11
69
|
### 🐞 Bug fixes
|
|
12
70
|
- **The Pieces and Peers tabs redraw themselves.** A detail pane is filled once, the first time
|
package/README.md
CHANGED
|
@@ -604,6 +604,8 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
604
604
|
| `POST` | `/api/sources/preview` | What a watched web location would take, without taking it |
|
|
605
605
|
| `POST` | `/api/subscriptions/preview` | Whether a peer is reachable and what it offers |
|
|
606
606
|
| `POST` | `/api/subscriptions/refresh` | Poll subscribed feeds now |
|
|
607
|
+
| `POST` | `/api/sources/check` | Check scheduled sources now, rather than at the next due time |
|
|
608
|
+
| `POST` | `/api/torrents/:infoHash/hooks/complete` | Run the completion hook again for one archive |
|
|
607
609
|
| `GET` `POST` `DELETE` | `/api/tokens`, `/api/tokens/:id` | Mint, list and revoke access tokens |
|
|
608
610
|
| `GET` `POST` | `/api/restart` | What a restart would do, and doing it |
|
|
609
611
|
| `GET` `PATCH` | `/api/config` | Read and change settings |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
@@ -63,6 +63,8 @@ export function createApp({
|
|
|
63
63
|
catalog,
|
|
64
64
|
engine,
|
|
65
65
|
subscriptions,
|
|
66
|
+
sources,
|
|
67
|
+
hooks,
|
|
66
68
|
tiles,
|
|
67
69
|
warm,
|
|
68
70
|
config,
|
|
@@ -1386,6 +1388,55 @@ export function createApp({
|
|
|
1386
1388
|
}),
|
|
1387
1389
|
);
|
|
1388
1390
|
|
|
1391
|
+
// The same for scheduled sources: ask now rather than at the next due time.
|
|
1392
|
+
// Worth having for the same reason a feed refresh is — a schedule is a
|
|
1393
|
+
// statement about ordinary operation, and setting one up is not ordinary
|
|
1394
|
+
// operation. Waiting six hours to find out whether a URL template is right
|
|
1395
|
+
// is how a typo survives a working day.
|
|
1396
|
+
app.post(
|
|
1397
|
+
'/api/sources/check',
|
|
1398
|
+
route(async (_req, res) => {
|
|
1399
|
+
if (!sources?.sweep) {
|
|
1400
|
+
return res.status(501).json({ error: 'no scheduled sources here' });
|
|
1401
|
+
}
|
|
1402
|
+
const taken = await sources.sweep(new Date());
|
|
1403
|
+
res.json({ taken: taken.length, entries: taken });
|
|
1404
|
+
}),
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
// Run the completion hook again for one archive.
|
|
1408
|
+
//
|
|
1409
|
+
// The sweep runs it once and records that it has, so a six-hour build is not
|
|
1410
|
+
// started six times over — but a hook that failed for a reason since fixed
|
|
1411
|
+
// keeps that record too, and the only way to run it again was to stop the
|
|
1412
|
+
// node and edit the catalog by hand.
|
|
1413
|
+
//
|
|
1414
|
+
// A POST, and therefore refused to a read-only token: this runs a command as
|
|
1415
|
+
// the service user. It does not choose *which* command — that is still the
|
|
1416
|
+
// config file's business, and `allowHooksFromApi` still guards it.
|
|
1417
|
+
app.post(
|
|
1418
|
+
'/api/torrents/:infoHash/hooks/complete',
|
|
1419
|
+
route(async (req, res) => {
|
|
1420
|
+
if (!hooks?.runComplete) {
|
|
1421
|
+
return res.status(501).json({ error: 'hooks are not available here' });
|
|
1422
|
+
}
|
|
1423
|
+
try {
|
|
1424
|
+
const started = await hooks.runComplete(req.params.infoHash);
|
|
1425
|
+
res.json({ started: true, ...started });
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
// Told apart because they mean different things to whoever pressed
|
|
1428
|
+
// the button: nothing configured, nothing to run it for, or already
|
|
1429
|
+
// going.
|
|
1430
|
+
const status = /unknown archive/.test(error.message)
|
|
1431
|
+
? 404
|
|
1432
|
+
: /already running/.test(error.message)
|
|
1433
|
+
? 409
|
|
1434
|
+
: 400;
|
|
1435
|
+
res.status(status).json({ error: error.message });
|
|
1436
|
+
}
|
|
1437
|
+
}),
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1389
1440
|
/**
|
|
1390
1441
|
* How many items this feed request should return.
|
|
1391
1442
|
*
|
package/src/config.js
CHANGED
|
@@ -555,6 +555,21 @@ const DEFAULTS = {
|
|
|
555
555
|
* and pruning, which needs to be able to notice an absence.
|
|
556
556
|
*/
|
|
557
557
|
subscriptions: [],
|
|
558
|
+
/*
|
|
559
|
+
* A subscription also takes `keep` and `keepDays`, which are the same rules
|
|
560
|
+
* a watched folder and a scheduled source retire under and are applied by
|
|
561
|
+
* the same code.
|
|
562
|
+
*
|
|
563
|
+
* They answer a different question from `prune`. Pruning is about the
|
|
564
|
+
* publisher — it stopped offering this, so let it go — and needs a complete
|
|
565
|
+
* listing to mean anything, which is why it applies to a catalog and not to
|
|
566
|
+
* an RSS feed. These are about the disk: a feed publishing weekly leaves a
|
|
567
|
+
* copy behind every week and goes on listing all of them, and age is age
|
|
568
|
+
* however short the list is.
|
|
569
|
+
*
|
|
570
|
+
* Retirement runs only after something new has landed, and never removes
|
|
571
|
+
* the newest copy.
|
|
572
|
+
*/
|
|
558
573
|
/**
|
|
559
574
|
* How often to re-check whether the sources archives were built from have
|
|
560
575
|
* changed, in seconds. Zero disables it. A check is one HEAD request or stat
|
|
@@ -134,6 +134,24 @@ export class LibtorrentEngine {
|
|
|
134
134
|
});
|
|
135
135
|
this.#child = child;
|
|
136
136
|
|
|
137
|
+
// A pipe to a process that has gone raises 'error' on the stream, and an
|
|
138
|
+
// unhandled 'error' event is not a rejected promise — it is a throw that
|
|
139
|
+
// takes the whole node down with it. That is what happened on every
|
|
140
|
+
// stop: systemd's default KillMode signals every process in the cgroup,
|
|
141
|
+
// so the sidecar exited first and the shutdown request was written into
|
|
142
|
+
// a dead pipe. The service died with EPIPE and status=1/FAILURE, having
|
|
143
|
+
// never saved its resume data, and no `.catch()` on the call could have
|
|
144
|
+
// caught it.
|
|
145
|
+
child.stdin.on('error', (error) => {
|
|
146
|
+
if (!this.#stopping) {
|
|
147
|
+
console.warn(`[libtorrent] sidecar input failed: ${error.message}`);
|
|
148
|
+
}
|
|
149
|
+
for (const [id, waiter] of this.#pending) {
|
|
150
|
+
this.#pending.delete(id);
|
|
151
|
+
waiter.reject(error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
137
155
|
const timer = setTimeout(() => {
|
|
138
156
|
reject(
|
|
139
157
|
new Error(
|
|
@@ -481,7 +499,22 @@ export class LibtorrentEngine {
|
|
|
481
499
|
},
|
|
482
500
|
});
|
|
483
501
|
|
|
484
|
-
|
|
502
|
+
// Checked and reported rather than thrown at. Writing to a closed pipe
|
|
503
|
+
// is ordinary during shutdown, and a call that cannot be sent should
|
|
504
|
+
// fail as a call rather than as the process.
|
|
505
|
+
if (!child.stdin.writable || child.stdin.destroyed) {
|
|
506
|
+
this.#pending.delete(id);
|
|
507
|
+
clearTimeout(timer);
|
|
508
|
+
reject(new Error(`libtorrent ${op}: the sidecar is no longer running`));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
child.stdin.write(`${JSON.stringify({ id, op, params })}\n`, (error) => {
|
|
513
|
+
if (!error) return;
|
|
514
|
+
this.#pending.delete(id);
|
|
515
|
+
clearTimeout(timer);
|
|
516
|
+
reject(new Error(`libtorrent ${op}: ${error.message}`));
|
|
517
|
+
});
|
|
485
518
|
});
|
|
486
519
|
}
|
|
487
520
|
|
package/src/hooks.js
CHANGED
|
@@ -193,6 +193,48 @@ export class ProgramHooks {
|
|
|
193
193
|
return fired;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Runs the completion hook for one archive, now, whatever it did before.
|
|
198
|
+
*
|
|
199
|
+
* The sweep runs a hook once and records that it has: a build taking six
|
|
200
|
+
* hours must not be started six times over. But a hook that failed for a
|
|
201
|
+
* reason since fixed — a path that was wrong, a directory that was not
|
|
202
|
+
* writable — keeps that record too, and until now the only way to run it
|
|
203
|
+
* again was to stop the node and edit the catalog by hand.
|
|
204
|
+
*
|
|
205
|
+
* Started rather than awaited. The command may be a planet build, and a
|
|
206
|
+
* request that waited for it would time out long before it finished.
|
|
207
|
+
* @param {string} infoHash - The archive to run it for.
|
|
208
|
+
* @returns {Promise<object>} - What was started.
|
|
209
|
+
*/
|
|
210
|
+
async runComplete(infoHash) {
|
|
211
|
+
if (!this.#config.onComplete?.command) {
|
|
212
|
+
throw new Error('no onComplete hook is configured');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const live = await this.#library.listWithStatus().catch(() => []);
|
|
216
|
+
const entry = live.find((candidate) => candidate.infoHash === infoHash);
|
|
217
|
+
if (!entry) throw new Error('unknown archive');
|
|
218
|
+
if (this.#running.has(infoHash)) {
|
|
219
|
+
throw new Error('it is already running for this archive');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Recorded before starting, the same as the sweep does and for the same
|
|
223
|
+
// reason: whatever happens next, this is not a fresh completion to be
|
|
224
|
+
// picked up again a minute later.
|
|
225
|
+
await this.#library.catalog.put({
|
|
226
|
+
infoHash,
|
|
227
|
+
completedAt: new Date().toISOString(),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
this.#running.add(infoHash);
|
|
231
|
+
this.#fire('onComplete', entry)
|
|
232
|
+
.catch(() => {})
|
|
233
|
+
.finally(() => this.#running.delete(infoHash));
|
|
234
|
+
|
|
235
|
+
return { name: entry.name, command: this.#config.onComplete.command };
|
|
236
|
+
}
|
|
237
|
+
|
|
196
238
|
/**
|
|
197
239
|
* Runs one of the configured hooks, if it is configured.
|
|
198
240
|
* @param {string} which - 'onAdded' or 'onComplete'.
|
package/src/index.js
CHANGED
package/src/subscriptions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseFeed } from './feed.js';
|
|
2
|
+
import { retains, retire } from './retention.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Follows other nodes' feeds and picks up what they publish.
|
|
@@ -174,6 +175,12 @@ export class SubscriptionManager {
|
|
|
174
175
|
break;
|
|
175
176
|
}
|
|
176
177
|
}
|
|
178
|
+
|
|
179
|
+
// The disk side of following a feed. Pruning cannot apply here — absence
|
|
180
|
+
// from a bounded feed proves nothing — but age applies whatever the list
|
|
181
|
+
// is, which is what makes this the answer for a feed that publishes
|
|
182
|
+
// weekly for ever.
|
|
183
|
+
await this.#retire(subscription, added);
|
|
177
184
|
return added;
|
|
178
185
|
}
|
|
179
186
|
|
|
@@ -252,9 +259,46 @@ export class SubscriptionManager {
|
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
await this.#prune(subscription, document, archives);
|
|
262
|
+
await this.#retire(subscription, added);
|
|
255
263
|
return added;
|
|
256
264
|
}
|
|
257
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Removes what this feed's archives have outgrown.
|
|
268
|
+
*
|
|
269
|
+
* Separate from pruning, and answering a different question. Pruning is
|
|
270
|
+
* about the *publisher*: it has stopped offering this, so let it go. This is
|
|
271
|
+
* about the disk: a feed publishing weekly leaves a copy behind every week,
|
|
272
|
+
* and the publisher will go on listing all of them.
|
|
273
|
+
*
|
|
274
|
+
* That distinction is why an RSS feed can have this and cannot have pruning.
|
|
275
|
+
* Absence from a bounded feed is not evidence that anything was withdrawn —
|
|
276
|
+
* planet.openstreetmap.org lists five dumps and says nothing about the
|
|
277
|
+
* hundreds before them — but age is age however short the list is.
|
|
278
|
+
*
|
|
279
|
+
* Only after something new has landed, and never the newest copy, which are
|
|
280
|
+
* the same guards a scheduled source retires under. See `retire`.
|
|
281
|
+
* @param {object} subscription - The subscription.
|
|
282
|
+
* @param {object[]} added - What this pass took, newest first.
|
|
283
|
+
* @returns {Promise<void>} - Resolves once anything due has gone.
|
|
284
|
+
*/
|
|
285
|
+
async #retire(subscription, added) {
|
|
286
|
+
if (added.length === 0 || !retains(subscription)) return;
|
|
287
|
+
|
|
288
|
+
await retire({
|
|
289
|
+
library: this.#library,
|
|
290
|
+
// Only what this feed brought in. An archive built here, added by hand,
|
|
291
|
+
// or taken from another peer is not this subscription's to remove.
|
|
292
|
+
family: this.#library.catalog
|
|
293
|
+
.list()
|
|
294
|
+
.filter((entry) => entry.source?.subscription === subscription.url),
|
|
295
|
+
entry: added[0],
|
|
296
|
+
keep: subscription.keep,
|
|
297
|
+
keepDays: subscription.keepDays,
|
|
298
|
+
label: `[sync] ${subscription.url}`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
258
302
|
/**
|
|
259
303
|
* Drops archives this subscription no longer lists.
|
|
260
304
|
*
|
package/src/web/index.html
CHANGED
|
@@ -1386,6 +1386,20 @@
|
|
|
1386
1386
|
</div>`
|
|
1387
1387
|
}
|
|
1388
1388
|
|
|
1389
|
+
<h2>Completion hook</h2>
|
|
1390
|
+
<div class="actions" id="hook-box">
|
|
1391
|
+
<button id="run-hook">Run it for this archive</button>
|
|
1392
|
+
<span class="sub" id="hook-note"></span>
|
|
1393
|
+
</div>
|
|
1394
|
+
<div class="sub" style="margin-bottom:1rem">
|
|
1395
|
+
Runs the configured <code>onComplete</code> command again for this
|
|
1396
|
+
archive. It normally runs once, when the download finishes, and is
|
|
1397
|
+
recorded as having run so a build taking hours is not started over
|
|
1398
|
+
every minute — which also means a hook that failed for a reason
|
|
1399
|
+
since fixed will not retry on its own. What it does is decided in
|
|
1400
|
+
the config file; this only decides when.
|
|
1401
|
+
</div>
|
|
1402
|
+
|
|
1389
1403
|
<h2>Mode</h2>
|
|
1390
1404
|
<div class="actions" id="mode-box">
|
|
1391
1405
|
<button id="mode-mirror">Switch to mirror</button>
|
|
@@ -1448,6 +1462,30 @@
|
|
|
1448
1462
|
const loaded = new Set(['general']);
|
|
1449
1463
|
|
|
1450
1464
|
/** Shows one tab, loading it the first time. */
|
|
1465
|
+
const runHook = $('run-hook');
|
|
1466
|
+
if (runHook) {
|
|
1467
|
+
runHook.onclick = async () => {
|
|
1468
|
+
const note = $('hook-note');
|
|
1469
|
+
runHook.disabled = true;
|
|
1470
|
+
note.textContent = 'starting…';
|
|
1471
|
+
try {
|
|
1472
|
+
const result = await api(
|
|
1473
|
+
`/api/torrents/${infoHash}/hooks/complete`,
|
|
1474
|
+
{ method: 'POST' },
|
|
1475
|
+
);
|
|
1476
|
+
// Started, not finished: the command may be a planet build, and
|
|
1477
|
+
// nothing here waits for one.
|
|
1478
|
+
note.textContent = `started ${result.command ?? 'the hook'}`;
|
|
1479
|
+
toast('completion hook started');
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
note.textContent = error.message;
|
|
1482
|
+
toast(error.message);
|
|
1483
|
+
} finally {
|
|
1484
|
+
runHook.disabled = false;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1451
1489
|
const showPane = (name) => {
|
|
1452
1490
|
activeTab = name;
|
|
1453
1491
|
for (const other of panel.querySelectorAll('.tabs button')) {
|
|
@@ -2363,9 +2401,32 @@
|
|
|
2363
2401
|
* @returns {void}
|
|
2364
2402
|
*/
|
|
2365
2403
|
function renderRowEditor(spec) {
|
|
2366
|
-
const {
|
|
2367
|
-
|
|
2404
|
+
const {
|
|
2405
|
+
into,
|
|
2406
|
+
key,
|
|
2407
|
+
title,
|
|
2408
|
+
blurb,
|
|
2409
|
+
columns,
|
|
2410
|
+
rows,
|
|
2411
|
+
restart,
|
|
2412
|
+
preview,
|
|
2413
|
+
peerPreview,
|
|
2414
|
+
footnote,
|
|
2415
|
+
// Where to POST a "do it now". A schedule is a statement about
|
|
2416
|
+
// ordinary operation, and setting one up is not ordinary operation:
|
|
2417
|
+
// waiting six hours to learn whether a URL template is right is how
|
|
2418
|
+
// a typo survives a working day.
|
|
2419
|
+
checkNow,
|
|
2420
|
+
} = spec;
|
|
2368
2421
|
rowEditorColumns[key] = columns;
|
|
2422
|
+
// Where this editor's rows belong, and what they are on top of being
|
|
2423
|
+
// rows. Two editors can be two views of one array — feeds and peers
|
|
2424
|
+
// are both subscriptions — so the panel's own key is a DOM name, and
|
|
2425
|
+
// this is where its records are written.
|
|
2426
|
+
rowEditorTargets[key] = {
|
|
2427
|
+
configKey: spec.configKey ?? key,
|
|
2428
|
+
stamp: spec.stamp ?? {},
|
|
2429
|
+
};
|
|
2369
2430
|
// Kept so a save can put back what this editor never showed. An entry
|
|
2370
2431
|
// holds more than there are columns for it — a watch folder's
|
|
2371
2432
|
// pieceLength, a subscription's savePath — and a save that rebuilt each
|
|
@@ -2380,7 +2441,9 @@
|
|
|
2380
2441
|
<td>
|
|
2381
2442
|
${
|
|
2382
2443
|
column.options
|
|
2383
|
-
? `<select data-field="${column.field}"
|
|
2444
|
+
? `<select data-field="${column.field}"${
|
|
2445
|
+
column.onlyFor ? ` data-only-for="${column.onlyFor}"` : ''
|
|
2446
|
+
} style="width:${column.wide ? '17rem' : '9rem'}">
|
|
2384
2447
|
${column.options
|
|
2385
2448
|
.map(
|
|
2386
2449
|
([option, label]) =>
|
|
@@ -2392,6 +2455,7 @@
|
|
|
2392
2455
|
</select>`
|
|
2393
2456
|
: `<input
|
|
2394
2457
|
data-field="${column.field}"
|
|
2458
|
+
${column.onlyFor ? `data-only-for="${column.onlyFor}"` : ''}
|
|
2395
2459
|
${column.secret ? 'type="password" autocomplete="off"' : ''}
|
|
2396
2460
|
placeholder="${escapeHtml(column.placeholder ?? '')}"
|
|
2397
2461
|
style="width:${column.wide ? '17rem' : '9rem'}"
|
|
@@ -2443,6 +2507,11 @@
|
|
|
2443
2507
|
</div>
|
|
2444
2508
|
<div style="margin-top:0.5rem">
|
|
2445
2509
|
<button type="button" data-act="add">Add row</button>
|
|
2510
|
+
${
|
|
2511
|
+
checkNow
|
|
2512
|
+
? '<button type="button" data-act="check-now">Check now</button>'
|
|
2513
|
+
: ''
|
|
2514
|
+
}
|
|
2446
2515
|
<span class="sub" data-role="note"></span>
|
|
2447
2516
|
</div>
|
|
2448
2517
|
${footnote ? `<div class="sub" style="margin-top:0.6rem">${escapeHtml(footnote)}</div>` : ''}`;
|
|
@@ -2450,6 +2519,40 @@
|
|
|
2450
2519
|
const tbody = panel.querySelector('tbody');
|
|
2451
2520
|
const note = panel.querySelector('[data-role="note"]');
|
|
2452
2521
|
|
|
2522
|
+
/**
|
|
2523
|
+
* Greys out the fields the chosen protocol does not read.
|
|
2524
|
+
*
|
|
2525
|
+
* The two halves of a subscription are not the same thing wearing
|
|
2526
|
+
* different clothes. `newest` caps how many items a bounded feed hands
|
|
2527
|
+
* over and means nothing to a catalog, which lists everything; `prune`
|
|
2528
|
+
* needs a complete listing to make absence meaningful and so cannot
|
|
2529
|
+
* apply to a feed. Offering both to both invites a setting that is
|
|
2530
|
+
* quietly ignored, which is worse than not offering it.
|
|
2531
|
+
*
|
|
2532
|
+
* Left alone on "auto", since the protocol is then decided by the URL
|
|
2533
|
+
* and this cannot know which it will be.
|
|
2534
|
+
* @param {HTMLElement} row - The row to update.
|
|
2535
|
+
* @returns {void}
|
|
2536
|
+
*/
|
|
2537
|
+
const applyProtocol = (row) => {
|
|
2538
|
+
const chosen = row.querySelector('[data-field="protocol"]')?.value;
|
|
2539
|
+
for (const field of row.querySelectorAll('[data-only-for]')) {
|
|
2540
|
+
const belongs = field.dataset.onlyFor;
|
|
2541
|
+
const off = Boolean(chosen) && chosen !== belongs;
|
|
2542
|
+
field.disabled = off;
|
|
2543
|
+
field.title = off
|
|
2544
|
+
? `Only used by the ${belongs === 'rss' ? 'RSS' : 'catalog API'} protocol`
|
|
2545
|
+
: '';
|
|
2546
|
+
}
|
|
2547
|
+
};
|
|
2548
|
+
|
|
2549
|
+
for (const row of tbody.querySelectorAll('tr')) applyProtocol(row);
|
|
2550
|
+
panel.addEventListener('change', (event) => {
|
|
2551
|
+
if (event.target.dataset?.field === 'protocol') {
|
|
2552
|
+
applyProtocol(event.target.closest('tr'));
|
|
2553
|
+
}
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2453
2556
|
panel.onclick = async (event) => {
|
|
2454
2557
|
const token = event.target.dataset?.token;
|
|
2455
2558
|
if (token) {
|
|
@@ -2475,6 +2578,29 @@
|
|
|
2475
2578
|
tbody.insertAdjacentHTML('beforeend', rowHtml());
|
|
2476
2579
|
return;
|
|
2477
2580
|
}
|
|
2581
|
+
|
|
2582
|
+
if (action === 'check-now') {
|
|
2583
|
+
// Against what is saved, not what is on screen. An unsaved row is
|
|
2584
|
+
// not something the node knows about, and pretending otherwise
|
|
2585
|
+
// would report on a source that does not exist yet.
|
|
2586
|
+
const button = event.target;
|
|
2587
|
+
button.disabled = true;
|
|
2588
|
+
note.textContent = 'checking…';
|
|
2589
|
+
try {
|
|
2590
|
+
const result = await api(checkNow, { method: 'POST' });
|
|
2591
|
+
const count = result.added ?? result.taken ?? 0;
|
|
2592
|
+
note.textContent =
|
|
2593
|
+
count > 0
|
|
2594
|
+
? `took ${count} — save first if you have unsaved changes`
|
|
2595
|
+
: 'nothing new';
|
|
2596
|
+
if (count > 0) refresh();
|
|
2597
|
+
} catch (error) {
|
|
2598
|
+
note.textContent = error.message;
|
|
2599
|
+
} finally {
|
|
2600
|
+
button.disabled = false;
|
|
2601
|
+
}
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2478
2604
|
if (action === 'drop') {
|
|
2479
2605
|
// Never leave it with nothing to type into.
|
|
2480
2606
|
if (tbody.children.length === 1) row.querySelectorAll('input').forEach((i) => (i.value = ''));
|
|
@@ -2581,6 +2707,7 @@
|
|
|
2581
2707
|
for (const [key, columns] of Object.entries(specs)) {
|
|
2582
2708
|
const panel = document.querySelector(`[data-row-editor="${key}"]`);
|
|
2583
2709
|
if (!panel) continue;
|
|
2710
|
+
const target = rowEditorTargets[key] ?? { configKey: key, stamp: {} };
|
|
2584
2711
|
const originals = rowEditorRows[key] ?? [];
|
|
2585
2712
|
const records = [...panel.querySelectorAll('tbody tr')]
|
|
2586
2713
|
.map((row) => {
|
|
@@ -2594,8 +2721,17 @@
|
|
|
2594
2721
|
origin === undefined ? {} : (originals[Number(origin)] ?? {}),
|
|
2595
2722
|
);
|
|
2596
2723
|
})
|
|
2597
|
-
.filter((record) => record[columns[0].field])
|
|
2598
|
-
|
|
2724
|
+
.filter((record) => record[columns[0].field])
|
|
2725
|
+
// What the section says about every row in it. A row in the feeds
|
|
2726
|
+
// table is an RSS subscription because of where it is, which is
|
|
2727
|
+
// the point of having two tables rather than a dropdown.
|
|
2728
|
+
.map((record) => ({ ...record, ...target.stamp }));
|
|
2729
|
+
|
|
2730
|
+
// Appended, not assigned: several editors may write one setting.
|
|
2731
|
+
updates[target.configKey] = [
|
|
2732
|
+
...(updates[target.configKey] ?? []),
|
|
2733
|
+
...records,
|
|
2734
|
+
];
|
|
2599
2735
|
}
|
|
2600
2736
|
return updates;
|
|
2601
2737
|
}
|
|
@@ -3087,6 +3223,7 @@
|
|
|
3087
3223
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
3088
3224
|
let restartKeys = new Set();
|
|
3089
3225
|
let rowEditorColumns = {};
|
|
3226
|
+
let rowEditorTargets = {};
|
|
3090
3227
|
// What each editor was rendered from, so a save can put back the fields
|
|
3091
3228
|
// it never showed.
|
|
3092
3229
|
let rowEditorRows = {};
|
|
@@ -3148,6 +3285,7 @@
|
|
|
3148
3285
|
// are lists of small records, which is exactly what a torrent client
|
|
3149
3286
|
// gives a grid for, and what a textarea full of braces is worst at.
|
|
3150
3287
|
rowEditorColumns = {};
|
|
3288
|
+
rowEditorTargets = {};
|
|
3151
3289
|
rowEditorRows = {};
|
|
3152
3290
|
await renderTokenEditor(body);
|
|
3153
3291
|
renderHookEditor(body, config, restartKeys);
|
|
@@ -3319,6 +3457,7 @@
|
|
|
3319
3457
|
},
|
|
3320
3458
|
],
|
|
3321
3459
|
rows: config.sources ?? [],
|
|
3460
|
+
checkNow: '/api/sources/check',
|
|
3322
3461
|
preview: true,
|
|
3323
3462
|
footnote:
|
|
3324
3463
|
'Tokens combine however the upstream spells its filenames — ' +
|
|
@@ -3378,35 +3517,100 @@
|
|
|
3378
3517
|
</div>`;
|
|
3379
3518
|
body.append(feedGlobals);
|
|
3380
3519
|
|
|
3520
|
+
// Which table a subscription belongs in — the same question the
|
|
3521
|
+
// subscription manager asks itself: an explicit protocol decides it, and
|
|
3522
|
+
// otherwise the shape of the URL does.
|
|
3523
|
+
const isPeer = (row) =>
|
|
3524
|
+
row.protocol === 'api' ||
|
|
3525
|
+
(!row.protocol && /\/api\/catalog\/?$/.test(row.url ?? ''));
|
|
3526
|
+
const following = config.subscriptions ?? [];
|
|
3527
|
+
|
|
3381
3528
|
renderRowEditor({
|
|
3382
3529
|
into: body,
|
|
3383
|
-
key: '
|
|
3384
|
-
|
|
3530
|
+
key: 'feeds',
|
|
3531
|
+
configKey: 'subscriptions',
|
|
3532
|
+
// A row here is RSS because of the table it is in, which is the
|
|
3533
|
+
// point of two tables rather than a dropdown nobody reads.
|
|
3534
|
+
stamp: { protocol: 'rss' },
|
|
3535
|
+
title: 'RSS feeds',
|
|
3385
3536
|
blurb:
|
|
3386
|
-
'
|
|
3387
|
-
'
|
|
3388
|
-
'
|
|
3389
|
-
'
|
|
3390
|
-
'
|
|
3391
|
-
'everything", which is what makes reconciling possible. A token, ' +
|
|
3392
|
-
'where the peer issued one, may get you more than it publishes ' +
|
|
3393
|
-
'to the world.',
|
|
3537
|
+
'Any feed that carries torrents — another swarm node publishes ' +
|
|
3538
|
+
'one, and so does planet.openstreetmap.org for the planet dumps. ' +
|
|
3539
|
+
'A feed says "here is what is new" and is bounded by whoever ' +
|
|
3540
|
+
'publishes it, so a node offline long enough misses things for ' +
|
|
3541
|
+
'good, and an absence from one means nothing at all.',
|
|
3394
3542
|
columns: [
|
|
3395
3543
|
{
|
|
3396
3544
|
field: 'url',
|
|
3397
|
-
label: 'Feed
|
|
3398
|
-
placeholder: 'https://
|
|
3545
|
+
label: 'Feed URL',
|
|
3546
|
+
placeholder: 'https://planet.openstreetmap.org/pbf/planet-pbf-rss.xml',
|
|
3399
3547
|
wide: true,
|
|
3400
3548
|
},
|
|
3401
3549
|
{
|
|
3402
|
-
field: '
|
|
3403
|
-
label: '
|
|
3550
|
+
field: 'mode',
|
|
3551
|
+
label: 'Take as',
|
|
3552
|
+
options: [
|
|
3553
|
+
['cache', 'cache — on demand'],
|
|
3554
|
+
['mirror', 'mirror — whole copy'],
|
|
3555
|
+
],
|
|
3556
|
+
},
|
|
3557
|
+
{ field: 'newest', label: 'Items per check', placeholder: '1', number: true },
|
|
3558
|
+
{
|
|
3559
|
+
field: 'categories',
|
|
3560
|
+
label: 'Categories',
|
|
3561
|
+
placeholder: 'from-peer, planet',
|
|
3562
|
+
list: true,
|
|
3563
|
+
},
|
|
3564
|
+
{ field: 'filter', label: 'Name filter', placeholder: 'terrain' },
|
|
3565
|
+
{ field: 'savePath', label: 'Save to', placeholder: 'the default' },
|
|
3566
|
+
{ field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
|
|
3567
|
+
{ field: 'keep', label: 'Copies to keep', placeholder: 'all', number: true },
|
|
3568
|
+
{ field: 'keepDays', label: 'Keep for (days)', placeholder: 'for ever', number: true },
|
|
3569
|
+
{
|
|
3570
|
+
field: 'enabled',
|
|
3571
|
+
label: 'On',
|
|
3572
|
+
boolean: true,
|
|
3404
3573
|
options: [
|
|
3405
|
-
['', '
|
|
3406
|
-
['
|
|
3407
|
-
['api', 'catalog API'],
|
|
3574
|
+
['', 'yes'],
|
|
3575
|
+
['false', 'no'],
|
|
3408
3576
|
],
|
|
3409
3577
|
},
|
|
3578
|
+
],
|
|
3579
|
+
rows: following.filter((row) => !isPeer(row)),
|
|
3580
|
+
checkNow: '/api/subscriptions/refresh',
|
|
3581
|
+
peerPreview: true,
|
|
3582
|
+
footnote:
|
|
3583
|
+
'Items per check is 1 by default and counts from the newest, ' +
|
|
3584
|
+
'because a feed listing five planet dumps is four hundred ' +
|
|
3585
|
+
'gigabytes if you take the lot; 0 takes everything it lists. ' +
|
|
3586
|
+
'Copies to keep and Keep for are about your disk rather than the ' +
|
|
3587
|
+
'publisher: a feed publishing weekly leaves a copy behind every ' +
|
|
3588
|
+
'week and goes on listing all of them. Both run only after ' +
|
|
3589
|
+
'something new has landed, and neither removes the newest copy. ' +
|
|
3590
|
+
'There is no dropping here — absence from a bounded feed is not ' +
|
|
3591
|
+
'evidence that anything was withdrawn, which is what a catalogue ' +
|
|
3592
|
+
'is for.',
|
|
3593
|
+
});
|
|
3594
|
+
|
|
3595
|
+
renderRowEditor({
|
|
3596
|
+
into: body,
|
|
3597
|
+
key: 'peers',
|
|
3598
|
+
configKey: 'subscriptions',
|
|
3599
|
+
stamp: { protocol: 'api' },
|
|
3600
|
+
title: 'Remote nodes',
|
|
3601
|
+
blurb:
|
|
3602
|
+
'Another swarm node, followed through its catalogue rather than ' +
|
|
3603
|
+
'its feed. A catalogue says "here is everything", which is what ' +
|
|
3604
|
+
'makes reconciling possible — and dropping, which needs to be ' +
|
|
3605
|
+
'able to notice an absence. A token, where the peer issued one, ' +
|
|
3606
|
+
'may get you more than it publishes to the world.',
|
|
3607
|
+
columns: [
|
|
3608
|
+
{
|
|
3609
|
+
field: 'url',
|
|
3610
|
+
label: 'Catalogue URL',
|
|
3611
|
+
placeholder: 'https://peer.example.org/api/catalog',
|
|
3612
|
+
wide: true,
|
|
3613
|
+
},
|
|
3410
3614
|
{
|
|
3411
3615
|
field: 'mode',
|
|
3412
3616
|
label: 'Take as',
|
|
@@ -3415,19 +3619,14 @@
|
|
|
3415
3619
|
['mirror', 'mirror — whole copy'],
|
|
3416
3620
|
],
|
|
3417
3621
|
},
|
|
3418
|
-
{
|
|
3419
|
-
field: 'newest',
|
|
3420
|
-
label: 'Items per check',
|
|
3421
|
-
placeholder: '1',
|
|
3422
|
-
number: true,
|
|
3423
|
-
},
|
|
3424
3622
|
{
|
|
3425
3623
|
field: 'categories',
|
|
3426
3624
|
label: 'Categories',
|
|
3427
|
-
placeholder: 'from-peer
|
|
3625
|
+
placeholder: 'from-peer',
|
|
3428
3626
|
list: true,
|
|
3429
3627
|
},
|
|
3430
3628
|
{ field: 'filter', label: 'Name filter', placeholder: 'terrain' },
|
|
3629
|
+
{ field: 'savePath', label: 'Save to', placeholder: 'the default' },
|
|
3431
3630
|
{ field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
|
|
3432
3631
|
{
|
|
3433
3632
|
field: 'prune',
|
|
@@ -3439,6 +3638,8 @@
|
|
|
3439
3638
|
['delete', 'forget and delete'],
|
|
3440
3639
|
],
|
|
3441
3640
|
},
|
|
3641
|
+
{ field: 'keep', label: 'Copies to keep', placeholder: 'all', number: true },
|
|
3642
|
+
{ field: 'keepDays', label: 'Keep for (days)', placeholder: 'for ever', number: true },
|
|
3442
3643
|
{
|
|
3443
3644
|
field: 'enabled',
|
|
3444
3645
|
label: 'On',
|
|
@@ -3449,7 +3650,8 @@
|
|
|
3449
3650
|
],
|
|
3450
3651
|
},
|
|
3451
3652
|
],
|
|
3452
|
-
rows:
|
|
3653
|
+
rows: following.filter(isPeer),
|
|
3654
|
+
checkNow: '/api/subscriptions/refresh',
|
|
3453
3655
|
peerPreview: true,
|
|
3454
3656
|
footnote:
|
|
3455
3657
|
'Take as decides the disk this costs: cache joins the swarm and ' +
|
|
@@ -3457,10 +3659,7 @@
|
|
|
3457
3659
|
'every archive the peer lists. Dropping stays off unless chosen, ' +
|
|
3458
3660
|
'only ever considers archives this peer sent, and never acts on a ' +
|
|
3459
3661
|
'filtered or partial view — watch a new peer on "report only" ' +
|
|
3460
|
-
'before trusting it with anything more.
|
|
3461
|
-
'default and counts from the newest, because a feed listing five ' +
|
|
3462
|
-
'planet dumps is four hundred gigabytes if you take the lot; 0 ' +
|
|
3463
|
-
'takes everything it lists.',
|
|
3662
|
+
'before trusting it with anything more.',
|
|
3464
3663
|
});
|
|
3465
3664
|
|
|
3466
3665
|
for (const [key, value] of Object.entries(config)) {
|