pmtiles-swarm 0.4.5 → 0.5.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 +41 -0
- package/README.md +2 -2
- package/docs/subscribing.md +1 -1
- package/package.json +1 -1
- package/src/api.js +2 -2
- package/src/catalog.js +2 -2
- package/src/config.js +24 -1
- package/src/hooks.js +1 -1
- package/src/index.js +13 -0
- package/src/prewarm.js +152 -0
- package/src/subscriptions.js +1 -1
- package/src/web/index.html +46 -11
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.5.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **The head of a newly joined archive is read without waiting to be asked.** A PMTiles archive
|
|
13
|
+
is useless until its 127-byte header has been read: it names where the root directory and the
|
|
14
|
+
JSON metadata live, and reading it raises both to a high piece priority — so the head of the
|
|
15
|
+
file arrives out of order instead of whenever a download happens to reach byte zero. That
|
|
16
|
+
machinery existed and was spec-correct, but only ran when something read the archive, and the
|
|
17
|
+
backfill that would have followed up began by requiring a summary to already exist. A freshly
|
|
18
|
+
joined archive has none, and the one thing that would have created one was the TileJSON route,
|
|
19
|
+
which is exactly what fails without a header. So an archive being mirrored stayed unservable
|
|
20
|
+
for hours while the few kilobytes that would have made it servable sat at position zero.
|
|
21
|
+
|
|
22
|
+
It now reads one archive's head at a time — several at once turn a queue of archives into a
|
|
23
|
+
queue of stalled reads competing for the same bandwidth — with the long metadata timeout
|
|
24
|
+
rather than the interactive one, backing off between attempts, because a young archive having
|
|
25
|
+
no peer that holds its first piece is ordinary rather than exceptional. It comes back for
|
|
26
|
+
vector layers separately, since a writer may put the JSON metadata after every tile and one
|
|
27
|
+
read routinely gets the header and not the metadata.
|
|
28
|
+
|
|
29
|
+
New under `tiles`: `prewarm` (default true), `prewarmIntervalSeconds` (30) and
|
|
30
|
+
`prewarmBackoffSeconds` (120). Turn it off on a node that distributes archives but never
|
|
31
|
+
serves tiles from them.
|
|
32
|
+
|
|
33
|
+
## 0.4.6
|
|
34
|
+
### ✨ Features and improvements
|
|
35
|
+
- **A feed's categories are a list, and are called categories.** The console offered a single
|
|
36
|
+
"Tag as" string while the configuration has always accepted `categories` as a list — one
|
|
37
|
+
concept under two names across two editors. It is now the same Categories column a watched
|
|
38
|
+
folder has. An existing `"category": "openmaptiles"` still works and needs no migration.
|
|
39
|
+
Everything user-facing now says category rather than tag, including the `%G` placeholder's
|
|
40
|
+
description and the API table.
|
|
41
|
+
|
|
42
|
+
### 🐞 Bug fixes
|
|
43
|
+
- **Saving in the console no longer deletes settings it does not show.** Each record was rebuilt
|
|
44
|
+
from the rendered columns alone, so every field without a column was dropped the first time
|
|
45
|
+
anyone pressed Save — a watched folder's `pieceLength`, `stabilitySeconds`, `trackers` and
|
|
46
|
+
`sparse`, a subscription's `savePath`. Nothing warned, because from the console's side the
|
|
47
|
+
save succeeded. Each row now remembers the entry it was rendered from and a save starts from
|
|
48
|
+
that, overlaying the columns; an emptied box still removes its field, since that is an
|
|
49
|
+
instruction rather than a gap.
|
|
50
|
+
|
|
10
51
|
## 0.4.5
|
|
11
52
|
### 🐞 Bug fixes
|
|
12
53
|
- **A hook whose command could not be started is tried again.** Completion is recorded before
|
package/README.md
CHANGED
|
@@ -587,7 +587,7 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
587
587
|
| `GET` | `/api/torrents/:infoHash/peers`, `/trackers`, `/content` | Per-peer, per-tracker and per-file detail |
|
|
588
588
|
| `GET` | `/api/torrents/:infoHash/pieces` | Which pieces are held, how rare each is, and what peers hold |
|
|
589
589
|
| `PATCH` | `/api/torrents/:infoHash/mode` | Switch between mirror and cache |
|
|
590
|
-
| `PATCH` | `/api/torrents/:infoHash/categories` | Set, add or remove
|
|
590
|
+
| `PATCH` | `/api/torrents/:infoHash/categories` | Set, add or remove categories |
|
|
591
591
|
| `PATCH` | `/api/torrents/:infoHash/seeding` | Per-archive seeding limit, or "use the global one" |
|
|
592
592
|
| `PATCH` `GET` | `/api/torrents/:infoHash/location` | Move the data; poll the move |
|
|
593
593
|
| `POST` | `/api/torrents/:infoHash/pause`, `/resume` | Stop offering it, without forgetting it |
|
|
@@ -599,7 +599,7 @@ matters there is `maxConnections`, since every peer holds a NAT table entry. See
|
|
|
599
599
|
| `POST` | `/api/check-origins` | Check every archive with a watchable source |
|
|
600
600
|
| `GET` `DELETE` | `/api/adds` | Downloads still in flight, and cancelling one by URL |
|
|
601
601
|
| `GET` `POST` | `/api/speed` | Which speed limits are in force, and the manual switch between the two sets |
|
|
602
|
-
| `GET` | `/api/categories` | Every
|
|
602
|
+
| `GET` | `/api/categories` | Every category, with the endpoints resolving to its newest build |
|
|
603
603
|
| `POST` | `/api/adopt`, `/api/adopt/candidates` | Take over what an engine or another node holds |
|
|
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 |
|
package/docs/subscribing.md
CHANGED
|
@@ -156,7 +156,7 @@ the peer's to retract:
|
|
|
156
156
|
Provenance is what makes this possible: an archive records which subscription
|
|
157
157
|
sent it, and only that subscription can ever propose removing it.
|
|
158
158
|
|
|
159
|
-
## Sharing only what you
|
|
159
|
+
## Sharing only what you categorise
|
|
160
160
|
|
|
161
161
|
Category feeds let a *subscriber* narrow what it takes. They do not narrow what
|
|
162
162
|
you publish: `/feed.xml` carries the whole catalogue, so a peer who could follow
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
@@ -843,7 +843,7 @@ export function createApp({
|
|
|
843
843
|
// Tags, after the fact. They could only be set when an archive was added,
|
|
844
844
|
// which is the wrong moment to have to know: a build becomes "weekly" when
|
|
845
845
|
// there is a second one, and an archive is marked for sharing long after it
|
|
846
|
-
// arrives. Accepts a whole list, or add/remove for one
|
|
846
|
+
// arrives. Accepts a whole list, or add/remove for one category at a time.
|
|
847
847
|
app.patch(
|
|
848
848
|
'/api/torrents/:infoHash/categories',
|
|
849
849
|
route(async (req, res) => {
|
|
@@ -1426,7 +1426,7 @@ export function createApp({
|
|
|
1426
1426
|
/**
|
|
1427
1427
|
* Whether an archive may leave this node, for this caller.
|
|
1428
1428
|
*
|
|
1429
|
-
* Any
|
|
1429
|
+
* Any category matching is enough. A category names one thing an archive is, not the
|
|
1430
1430
|
* whole of what it is, so a planet build tagged both "basemaps" and "weekly"
|
|
1431
1431
|
* belongs in a basemaps feed whether or not weekly is also published.
|
|
1432
1432
|
* @param {object} entry - Catalog entry.
|
package/src/catalog.js
CHANGED
|
@@ -34,7 +34,7 @@ import path from 'node:path';
|
|
|
34
34
|
* them. Accepts the older single `category` string so catalogues written before
|
|
35
35
|
* tagging keep working — they are read as a list of one.
|
|
36
36
|
* @param {object} source - Anything with `categories` and/or `category`.
|
|
37
|
-
* @returns {string[]} - Sorted, de-duplicated, non-empty
|
|
37
|
+
* @returns {string[]} - Sorted, de-duplicated, non-empty categories.
|
|
38
38
|
*/
|
|
39
39
|
export function normalizeCategories(source) {
|
|
40
40
|
const raw = [
|
|
@@ -117,7 +117,7 @@ export class Catalog {
|
|
|
117
117
|
* @returns {CatalogEntry[]} - Matching entries.
|
|
118
118
|
*/
|
|
119
119
|
byCategory(category) {
|
|
120
|
-
// Any match, not all: a
|
|
120
|
+
// Any match, not all: a category names one thing an archive is, and asking for
|
|
121
121
|
// "terrain" should find everything tagged terrain whatever else it is.
|
|
122
122
|
return this.list().filter((entry) =>
|
|
123
123
|
normalizeCategories(entry).includes(category),
|
package/src/config.js
CHANGED
|
@@ -444,6 +444,29 @@ const DEFAULTS = {
|
|
|
444
444
|
* like a hang rather than like "not yet".
|
|
445
445
|
*/
|
|
446
446
|
headerTimeoutMs: 12000,
|
|
447
|
+
/**
|
|
448
|
+
* Read the head of a newly joined archive without waiting to be asked.
|
|
449
|
+
*
|
|
450
|
+
* A PMTiles archive is useless until its header has been read: it names
|
|
451
|
+
* where the root directory and the JSON metadata are, and reading it also
|
|
452
|
+
* raises those to a high piece priority, so the head of the file arrives
|
|
453
|
+
* out of order rather than whenever a download happens to reach it. Left
|
|
454
|
+
* to the first request, an archive being mirrored is unservable for hours
|
|
455
|
+
* while the bytes that would make it servable sit at position zero.
|
|
456
|
+
*
|
|
457
|
+
* Off makes sense on a node that only distributes and never serves tiles.
|
|
458
|
+
*/
|
|
459
|
+
prewarm: true,
|
|
460
|
+
/** How often to look for an archive whose head has not been read. */
|
|
461
|
+
prewarmIntervalSeconds: 30,
|
|
462
|
+
/**
|
|
463
|
+
* How long to leave an archive alone after a failed read.
|
|
464
|
+
*
|
|
465
|
+
* Failure here is ordinary rather than exceptional — a brand-new archive
|
|
466
|
+
* may have no peer holding its first piece yet — so this is a retry
|
|
467
|
+
* interval, not an error budget.
|
|
468
|
+
*/
|
|
469
|
+
prewarmBackoffSeconds: 120,
|
|
447
470
|
/**
|
|
448
471
|
* What a missing tile answers with: true for 404, false for 204.
|
|
449
472
|
*
|
|
@@ -643,7 +666,7 @@ const DEFAULTS = {
|
|
|
643
666
|
* }
|
|
644
667
|
*
|
|
645
668
|
* Placeholders match a torrent client's, so an existing script keeps working:
|
|
646
|
-
* %N name, %L first category, %G all
|
|
669
|
+
* %N name, %L first category, %G all categories, %F content path, %D save path,
|
|
647
670
|
* %Z size, %C file count, %I infohash.
|
|
648
671
|
*
|
|
649
672
|
* Command and arguments are separate rather than one string a shell pulls
|
package/src/hooks.js
CHANGED
|
@@ -31,7 +31,7 @@ const TAIL_LINES = 20;
|
|
|
31
31
|
* The set mirrors a torrent client's, so an existing script keeps working:
|
|
32
32
|
*
|
|
33
33
|
* %N name %I infohash %F content path
|
|
34
|
-
* %L category %G
|
|
34
|
+
* %L category %G categories %D save path
|
|
35
35
|
* %Z size %C file count
|
|
36
36
|
*
|
|
37
37
|
* @param {string} argument - An argument possibly containing placeholders.
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { closeServer, installSignalHandlers, runStoppers } from './shutdown.js';
|
|
|
19
19
|
import { ScheduledSourceManager } from './sources.js';
|
|
20
20
|
import { SubscriptionManager } from './subscriptions.js';
|
|
21
21
|
import { TileStore } from './tiles.js';
|
|
22
|
+
import { HeadWarmer } from './prewarm.js';
|
|
22
23
|
import { WarmRunner } from './warm.js';
|
|
23
24
|
import { WatchManager } from './watch.js';
|
|
24
25
|
|
|
@@ -216,6 +217,12 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
216
217
|
library.attachTiles(tiles);
|
|
217
218
|
const warm = new WarmRunner(tiles);
|
|
218
219
|
|
|
220
|
+
// Reads the head of anything joined but not yet understood — the header,
|
|
221
|
+
// then the root directory and metadata it points at. Without this an archive
|
|
222
|
+
// being mirrored is unservable until the download happens to reach byte
|
|
223
|
+
// zero, and the request that would have read it times out long before.
|
|
224
|
+
const headWarmer = new HeadWarmer(tiles, catalog, config);
|
|
225
|
+
|
|
219
226
|
// Restarting one subsystem, rather than the process, for the settings that
|
|
220
227
|
// only that subsystem reads. Each stops and starts from the live config, so
|
|
221
228
|
// nothing here has to know what changed — only what to rebuild.
|
|
@@ -337,6 +344,11 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
337
344
|
}
|
|
338
345
|
hooks.start();
|
|
339
346
|
completion.start();
|
|
347
|
+
// The header is 127 bytes and names where the root directory and the JSON
|
|
348
|
+
// metadata live (PMTiles v3 spec, fields at offsets 8 and 24). Reading it
|
|
349
|
+
// raises both to a high piece priority, so an archive becomes servable in
|
|
350
|
+
// seconds rather than at whatever hour the download reaches byte zero.
|
|
351
|
+
headWarmer.start();
|
|
340
352
|
|
|
341
353
|
// Watch the sources archives were built from. A changed source does not
|
|
342
354
|
// invalidate its torrent, but it does mean any web seed pointing there will
|
|
@@ -375,6 +387,7 @@ PMTILES_SWARM_PUBLIC_URL
|
|
|
375
387
|
completion.stop();
|
|
376
388
|
subscriptions.stop();
|
|
377
389
|
warm.stop();
|
|
390
|
+
headWarmer.stop();
|
|
378
391
|
}, ms: 1000 },
|
|
379
392
|
{ label: 'watchers', stop: () => watch.stop() },
|
|
380
393
|
{
|
package/src/prewarm.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the head of a newly joined archive, before anybody asks for it.
|
|
3
|
+
*
|
|
4
|
+
* A PMTiles archive is useless until its header has been read: the header is
|
|
5
|
+
* the first 127 bytes and names where the root directory and the JSON metadata
|
|
6
|
+
* live, and without it there is no TileJSON, no vector layers, and a preview
|
|
7
|
+
* that renders black. Reading it also *prioritises* what it found — the source
|
|
8
|
+
* hints the root directory as critical and the metadata as high — so the head
|
|
9
|
+
* of the file arrives out of order rather than whenever the download reaches
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* None of that happened on its own. The read is on the interactive path, so it
|
|
13
|
+
* ran when somebody opened the archive; and the backfill that follows it up
|
|
14
|
+
* required a summary to already exist, which is precisely what a freshly joined
|
|
15
|
+
* archive does not have. So the first request paid for the header, and if it
|
|
16
|
+
* timed out first — which against a 72 GiB mirror with no web seed it does —
|
|
17
|
+
* nothing ever tried again.
|
|
18
|
+
*
|
|
19
|
+
* A mirror gets there eventually by downloading everything. The point of doing
|
|
20
|
+
* it deliberately is that "eventually" is hours, and the archive is servable in
|
|
21
|
+
* the first few seconds if the right few kilobytes are asked for first.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** How long to leave an archive alone after a failed attempt. */
|
|
25
|
+
const DEFAULT_BACKOFF_MS = 120000;
|
|
26
|
+
|
|
27
|
+
export class HeadWarmer {
|
|
28
|
+
#tiles;
|
|
29
|
+
#catalog;
|
|
30
|
+
#config;
|
|
31
|
+
#now;
|
|
32
|
+
#timer;
|
|
33
|
+
#tried = new Map();
|
|
34
|
+
#running = false;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} tiles - The tile store, for `summarize`.
|
|
38
|
+
* @param {object} catalog - Where the summary is written.
|
|
39
|
+
* @param {object} config - Resolved configuration.
|
|
40
|
+
* @param {Function} [now] - Clock, for testing.
|
|
41
|
+
*/
|
|
42
|
+
constructor(tiles, catalog, config, now = () => Date.now()) {
|
|
43
|
+
this.#tiles = tiles;
|
|
44
|
+
this.#catalog = catalog;
|
|
45
|
+
this.#config = config;
|
|
46
|
+
this.#now = now;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {boolean} - Whether this node can and should warm anything. */
|
|
50
|
+
get enabled() {
|
|
51
|
+
return (
|
|
52
|
+
this.#config.tiles?.prewarm !== false &&
|
|
53
|
+
typeof this.#tiles?.summarize === 'function'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether an archive is worth reading the head of right now.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately narrow: an archive that has been summarised is done, unless it
|
|
61
|
+
* is vector and its layers are still missing — that section sits at the far
|
|
62
|
+
* end of the file and routinely arrives later than the header.
|
|
63
|
+
* @param {object} entry - A catalog entry.
|
|
64
|
+
* @returns {boolean} - True to attempt a read.
|
|
65
|
+
*/
|
|
66
|
+
due(entry) {
|
|
67
|
+
// Only PMTiles has a head worth reading. A .osm.pbf from a feed is not an
|
|
68
|
+
// archive this can say anything about.
|
|
69
|
+
if (entry.kind && entry.kind !== 'pmtiles') return false;
|
|
70
|
+
|
|
71
|
+
const summary = entry.pmtiles;
|
|
72
|
+
if (summary && (summary.format !== 'pbf' || summary.vectorLayers)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const last = this.#tried.get(entry.infoHash) ?? 0;
|
|
77
|
+
const backoff = (this.#config.tiles?.prewarmBackoffSeconds ?? 120) * 1000;
|
|
78
|
+
return this.#now() - last >= (backoff || DEFAULT_BACKOFF_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reads the head of one archive that needs it.
|
|
83
|
+
*
|
|
84
|
+
* One per pass on purpose. Each read is a byte range fetched out of a swarm
|
|
85
|
+
* that may have no peer holding it yet, and starting several at once turns a
|
|
86
|
+
* queue of archives into a queue of stalled reads competing for the same
|
|
87
|
+
* bandwidth — the same mistake as prefetching leaf directories eagerly, which
|
|
88
|
+
* measured four times slower than not bothering.
|
|
89
|
+
* @returns {Promise<object|null>} - The entry warmed, or null.
|
|
90
|
+
*/
|
|
91
|
+
async sweep() {
|
|
92
|
+
if (!this.enabled || this.#running) return null;
|
|
93
|
+
|
|
94
|
+
const entry = this.#catalog.list().find((candidate) => this.due(candidate));
|
|
95
|
+
if (!entry) return null;
|
|
96
|
+
|
|
97
|
+
this.#running = true;
|
|
98
|
+
this.#tried.set(entry.infoHash, this.#now());
|
|
99
|
+
try {
|
|
100
|
+
// The long timeout, not the interactive one: this is a byte range from
|
|
101
|
+
// an archive nobody has asked for a piece of yet.
|
|
102
|
+
const summary = await this.#tiles.summarize(entry.infoHash, {
|
|
103
|
+
timeoutMs: this.#config.tiles?.metadataTimeoutMs ?? 120000,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const stored = await this.#catalog.put({
|
|
107
|
+
infoHash: entry.infoHash,
|
|
108
|
+
pmtiles: { ...entry.pmtiles, ...summary },
|
|
109
|
+
});
|
|
110
|
+
console.log(
|
|
111
|
+
`[warm] read the head of ${entry.name}` +
|
|
112
|
+
(summary.vectorLayers
|
|
113
|
+
? ` (${summary.vectorLayers.length} vector layers)`
|
|
114
|
+
: ''),
|
|
115
|
+
);
|
|
116
|
+
return stored;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// Ordinary while an archive is young — the piece holding the header may
|
|
119
|
+
// not exist anywhere reachable yet. Worth saying once per attempt, since
|
|
120
|
+
// it is the answer to "why is my preview blank", and the backoff keeps
|
|
121
|
+
// it from becoming noise.
|
|
122
|
+
console.warn(`[warm] ${entry.name}: ${error.message}`);
|
|
123
|
+
return null;
|
|
124
|
+
} finally {
|
|
125
|
+
this.#running = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Starts warming, and keeps at it.
|
|
131
|
+
* @returns {void}
|
|
132
|
+
*/
|
|
133
|
+
start() {
|
|
134
|
+
if (!this.enabled) return;
|
|
135
|
+
const seconds = this.#config.tiles?.prewarmIntervalSeconds ?? 30;
|
|
136
|
+
if (seconds <= 0) return;
|
|
137
|
+
|
|
138
|
+
const run = () =>
|
|
139
|
+
this.sweep().catch((error) =>
|
|
140
|
+
console.error(`[warm] sweep failed: ${error.message}`),
|
|
141
|
+
);
|
|
142
|
+
run();
|
|
143
|
+
this.#timer = setInterval(run, seconds * 1000);
|
|
144
|
+
this.#timer.unref?.();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stops warming. @returns {void} */
|
|
148
|
+
stop() {
|
|
149
|
+
if (this.#timer) clearInterval(this.#timer);
|
|
150
|
+
this.#timer = undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/subscriptions.js
CHANGED
|
@@ -346,7 +346,7 @@ export class SubscriptionManager {
|
|
|
346
346
|
*/
|
|
347
347
|
async #add(item, subscription) {
|
|
348
348
|
const options = {
|
|
349
|
-
// A subscriber may file a peer's archives under its own
|
|
349
|
+
// A subscriber may file a peer's archives under its own categories; failing
|
|
350
350
|
// that, whatever the peer tagged them with comes across.
|
|
351
351
|
categories:
|
|
352
352
|
subscription.categories ??
|
package/src/web/index.html
CHANGED
|
@@ -357,7 +357,7 @@
|
|
|
357
357
|
addressed by its infohash — which is what makes a tile immutable and
|
|
358
358
|
cacheable for a year — so a style pointed at one is pinned to that
|
|
359
359
|
build forever. These endpoints always resolve to the newest archive
|
|
360
|
-
carrying the
|
|
360
|
+
carrying the category.
|
|
361
361
|
</div>
|
|
362
362
|
<div id="category-list"></div>
|
|
363
363
|
<div class="empty" id="categories-empty" hidden>
|
|
@@ -543,8 +543,8 @@
|
|
|
543
543
|
<button type="button" id="add-category">Add</button>
|
|
544
544
|
</div>
|
|
545
545
|
<div class="sub">
|
|
546
|
-
An archive can carry several. Feeds include
|
|
547
|
-
the category asked for.
|
|
546
|
+
An archive can carry several. Feeds include every archive
|
|
547
|
+
carrying the category asked for.
|
|
548
548
|
</div>
|
|
549
549
|
</div>
|
|
550
550
|
|
|
@@ -2327,6 +2327,11 @@
|
|
|
2327
2327
|
const { into, key, title, blurb, columns, rows, restart, preview, peerPreview, footnote } =
|
|
2328
2328
|
spec;
|
|
2329
2329
|
rowEditorColumns[key] = columns;
|
|
2330
|
+
// Kept so a save can put back what this editor never showed. An entry
|
|
2331
|
+
// holds more than there are columns for it — a watch folder's
|
|
2332
|
+
// pieceLength, a subscription's savePath — and a save that rebuilt each
|
|
2333
|
+
// record from the columns alone deleted every one of them.
|
|
2334
|
+
rowEditorRows[key] = rows;
|
|
2330
2335
|
const panel = document.createElement('div');
|
|
2331
2336
|
panel.className = 'panel';
|
|
2332
2337
|
panel.style.marginBottom = '1rem';
|
|
@@ -2368,8 +2373,8 @@
|
|
|
2368
2373
|
}
|
|
2369
2374
|
</td>`;
|
|
2370
2375
|
|
|
2371
|
-
const rowHtml = (row = {}) => `
|
|
2372
|
-
<tr>
|
|
2376
|
+
const rowHtml = (row = {}, index) => `
|
|
2377
|
+
<tr${index === undefined ? '' : ` data-origin="${index}"`}>
|
|
2373
2378
|
${columns.map((column) => cell(column, row[column.field])).join('')}
|
|
2374
2379
|
<td>
|
|
2375
2380
|
${preview ? '<button type="button" data-act="preview">Preview</button>' : ''}
|
|
@@ -2392,7 +2397,9 @@
|
|
|
2392
2397
|
<th></th>
|
|
2393
2398
|
</tr>
|
|
2394
2399
|
</thead>
|
|
2395
|
-
<tbody>${[...rows, {}]
|
|
2400
|
+
<tbody>${[...rows, {}]
|
|
2401
|
+
.map((row, index) => rowHtml(row, index < rows.length ? index : undefined))
|
|
2402
|
+
.join('')}</tbody>
|
|
2396
2403
|
</table>
|
|
2397
2404
|
</div>
|
|
2398
2405
|
<div style="margin-top:0.5rem">
|
|
@@ -2492,11 +2499,19 @@
|
|
|
2492
2499
|
* @param {object[]} columns - Column definitions.
|
|
2493
2500
|
* @returns {object} - The record.
|
|
2494
2501
|
*/
|
|
2495
|
-
function readRow(row, columns) {
|
|
2496
|
-
|
|
2502
|
+
function readRow(row, columns, original = {}) {
|
|
2503
|
+
// Starts from what was there. The editor shows the fields worth
|
|
2504
|
+
// offering, not every field an entry may hold, and rebuilding from the
|
|
2505
|
+
// columns alone silently dropped the rest.
|
|
2506
|
+
const record = { ...original };
|
|
2497
2507
|
for (const column of columns) {
|
|
2498
2508
|
const raw = row.querySelector(`[data-field="${column.field}"]`)?.value.trim() ?? '';
|
|
2499
|
-
|
|
2509
|
+
// Emptied on purpose is still an instruction, so the field goes
|
|
2510
|
+
// rather than the original value surviving underneath it.
|
|
2511
|
+
if (!raw) {
|
|
2512
|
+
delete record[column.field];
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2500
2515
|
if (column.list) {
|
|
2501
2516
|
record[column.field] = raw
|
|
2502
2517
|
.split(',')
|
|
@@ -2527,8 +2542,19 @@
|
|
|
2527
2542
|
for (const [key, columns] of Object.entries(specs)) {
|
|
2528
2543
|
const panel = document.querySelector(`[data-row-editor="${key}"]`);
|
|
2529
2544
|
if (!panel) continue;
|
|
2545
|
+
const originals = rowEditorRows[key] ?? [];
|
|
2530
2546
|
const records = [...panel.querySelectorAll('tbody tr')]
|
|
2531
|
-
.map((row) =>
|
|
2547
|
+
.map((row) => {
|
|
2548
|
+
// A row added in the console has no original; one rendered from
|
|
2549
|
+
// the configuration is matched back to it, so a removed row takes
|
|
2550
|
+
// its own hidden fields with it and the others keep theirs.
|
|
2551
|
+
const origin = row.dataset.origin;
|
|
2552
|
+
return readRow(
|
|
2553
|
+
row,
|
|
2554
|
+
columns,
|
|
2555
|
+
origin === undefined ? {} : (originals[Number(origin)] ?? {}),
|
|
2556
|
+
);
|
|
2557
|
+
})
|
|
2532
2558
|
.filter((record) => record[columns[0].field]);
|
|
2533
2559
|
updates[key] = records;
|
|
2534
2560
|
}
|
|
@@ -3022,6 +3048,9 @@
|
|
|
3022
3048
|
// ── Settings ──────────────────────────────────────────────────────────
|
|
3023
3049
|
let restartKeys = new Set();
|
|
3024
3050
|
let rowEditorColumns = {};
|
|
3051
|
+
// What each editor was rendered from, so a save can put back the fields
|
|
3052
|
+
// it never showed.
|
|
3053
|
+
let rowEditorRows = {};
|
|
3025
3054
|
|
|
3026
3055
|
async function loadSettings() {
|
|
3027
3056
|
const { config, restartRequired, configPath } = await api('/api/config');
|
|
@@ -3080,6 +3109,7 @@
|
|
|
3080
3109
|
// are lists of small records, which is exactly what a torrent client
|
|
3081
3110
|
// gives a grid for, and what a textarea full of braces is worst at.
|
|
3082
3111
|
rowEditorColumns = {};
|
|
3112
|
+
rowEditorRows = {};
|
|
3083
3113
|
await renderTokenEditor(body);
|
|
3084
3114
|
renderHookEditor(body, config, restartKeys);
|
|
3085
3115
|
|
|
@@ -3352,7 +3382,12 @@
|
|
|
3352
3382
|
placeholder: '1',
|
|
3353
3383
|
number: true,
|
|
3354
3384
|
},
|
|
3355
|
-
{
|
|
3385
|
+
{
|
|
3386
|
+
field: 'categories',
|
|
3387
|
+
label: 'Categories',
|
|
3388
|
+
placeholder: 'from-peer, planet',
|
|
3389
|
+
list: true,
|
|
3390
|
+
},
|
|
3356
3391
|
{ field: 'filter', label: 'Name filter', placeholder: 'terrain' },
|
|
3357
3392
|
{ field: 'token', label: 'Token', placeholder: 'if issued one', secret: true },
|
|
3358
3393
|
{
|