pmtiles-swarm 0.78.0 → 0.79.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 CHANGED
@@ -7,6 +7,36 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.79.0
11
+ ### ✨ Features and improvements
12
+ - **Duplicate a stack.** A button beside Edit on every row, which opens the editor on a copy of that
13
+ recipe: the same sources in the same order, the same masks, the same output, under a name of its
14
+ own and saved only when you save it. Most stacks after the first are a variation on one that
15
+ already works, and rebuilding that by hand is where a source gets left out.
16
+
17
+ Two things are changed for the copy and nothing else is. The name becomes `<name>-copy`, counting
18
+ up until it is one nothing is using, and the title gains `copy` — two stacks under one title are
19
+ two rows nobody can tell apart in the list they both appear in. Both are editable before saving.
20
+
21
+ It copies the **recipe** rather than the row. The list holds what each source resolved to, so a
22
+ copy taken from it would pin the infohashes the original follows by category and stop following
23
+ rebuilds from the moment it was made.
24
+
25
+ ### 🐞 Bug fixes
26
+ - **Saving a stack with no name reported that the reply was not JSON.** `Unexpected token '<',
27
+ "<!DOCTYPE "... is not valid JSON`, which says nothing about a missing name. A stack with no name
28
+ is a `PUT /api/stacks/`, and that matches no route at all — `:id` needs a segment to be — so what
29
+ came back was express's own HTML error page, which the console then tried to parse.
30
+
31
+ Both halves are fixed. Every unmatched path under `/api` answers JSON now, so any future typo says
32
+ `no route for PUT /api/stacks/` rather than arriving as a parse error; and the dialog refuses an
33
+ empty or malformed name itself, since it is the one that knows what the box is for.
34
+
35
+ - **A new stack could be saved over an existing one without a word.** `PUT` upserts, which is right
36
+ for editing and wrong for naming: typing a name already in use replaced that stack rather than
37
+ refusing. It is refused now when naming a new stack or a copy — where the stack it would have
38
+ replaced is usually the one being copied. Editing is unaffected; a stack keeps the name it has.
39
+
10
40
  ## 0.78.0
11
41
  ### ✨ Features and improvements
12
42
  - **Somewhere to clear the caches and the files nothing is waiting for.** A merged tile cache with
@@ -1586,10 +1586,33 @@ editor exists for — an editor whose primary action only works with a mouse is
1586
1586
  an editor half the people cannot use. The console has no drag-and-drop anywhere
1587
1587
  yet, so this is the first, and the buttons are what make it safe to add.
1588
1588
 
1589
+ ### Starting from a stack that already works
1590
+
1591
+ **Duplicate**, beside Edit on every row. It opens the editor on a copy of that
1592
+ recipe: the same sources in the same order, the same masks, the same output —
1593
+ under a name of its own, and saved only when it is saved. Most stacks after the
1594
+ first are a variation on one that already works, and rebuilding that by hand is
1595
+ where a source gets left out.
1596
+
1597
+ Two things are changed for the copy and nothing else is. The name becomes
1598
+ `<name>-copy`, counting up until it is one nothing is using; the title gains
1599
+ `copy`, because two stacks under one title are two rows nobody can tell apart in
1600
+ the list they both appear in. Both are editable before saving — the point is
1601
+ that neither is left matching by accident.
1602
+
1603
+ It copies the **recipe** rather than the row. The list holds what each source
1604
+ resolved to, so a copy taken from it would pin the infohashes the original
1605
+ follows by category, and stop following rebuilds from the moment it was made.
1606
+ The editor reads `/api/stacks/<id>/raw` for the same reason.
1607
+
1589
1608
  ### What the editor refuses, and what it only warns about
1590
1609
 
1591
1610
  Refuses, because the stack cannot work:
1592
1611
 
1612
+ - A name that is already a stack's, when naming a new one or a copy. `PUT`
1613
+ upserts, so this is the difference between adding a stack and replacing one —
1614
+ and for a copy, the stack it would replace is usually the one being copied.
1615
+ Editing an existing stack is unaffected: it keeps the name it has.
1593
1616
  - A recipe naming an `output.tileSize` that is not 256 or 512.
1594
1617
  - A source that resolves to nothing — an empty category, or an archive that
1595
1618
  retention has removed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.78.0",
3
+ "version": "0.79.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
@@ -3758,6 +3758,19 @@ export function createApp({
3758
3758
  }
3759
3759
  }
3760
3760
 
3761
+ // An API path no route claimed. Without this express's own handler answers
3762
+ // with an HTML error page, and a caller that parses every reply as JSON --
3763
+ // the console does -- reports `Unexpected token '<'`, which says nothing at
3764
+ // all about what was wrong with the request.
3765
+ //
3766
+ // The one that produced it was a stack saved with no name: `PUT
3767
+ // /api/stacks/` matches no route, because `:id` needs a segment to be, and
3768
+ // the reply came back as the start of an HTML document.
3769
+ app.use('/api', (req, res) => {
3770
+ const where = req.originalUrl.split('?')[0];
3771
+ res.status(404).json({ error: `no route for ${req.method} ${where}` });
3772
+ });
3773
+
3761
3774
  app.use(express.static(path.join(here, 'web')));
3762
3775
 
3763
3776
  // Four parameters, two of them unused: express identifies an error handler
@@ -840,7 +840,8 @@
840
840
 
841
841
  <div class="field">
842
842
  <label for="stack-id">Name</label>
843
- <input id="stack-id" placeholder="planet-terrain" autocomplete="off" />
843
+ <input id="stack-id" placeholder="planet-terrain" autocomplete="off"
844
+ required pattern="[A-Za-z0-9][A-Za-z0-9._-]*" />
844
845
  <div class="sub">
845
846
  Used in the URL: <code id="stack-url-preview">/stacks/…/tiles.json</code>
846
847
  </div>
@@ -7342,6 +7343,8 @@ Every piece is hashed against the ` +
7342
7343
  : `<button type="button" data-stack-bake="${escapeHtml(stack.id)}" title="Run this stack over its sources and write the result as a real archive, with its own infohash, seeded like any other.">Export to archive</button>`
7343
7344
  }
7344
7345
  <button type="button" data-stack-edit="${escapeHtml(stack.id)}">Edit</button>
7346
+ <button type="button" data-stack-copy="${escapeHtml(stack.id)}"
7347
+ title="Opens the editor on a copy of this recipe, under a name of its own. Nothing is saved until you save it.">Duplicate</button>
7345
7348
  <button type="button" data-stack-delete="${escapeHtml(stack.id)}">Delete</button>`;
7346
7349
 
7347
7350
  return `
@@ -7431,6 +7434,26 @@ Every piece is hashed against the ` +
7431
7434
  // space, so a form that re-derived itself from its own inputs would have
7432
7435
  // to know which of them are currently real.
7433
7436
  let stackDraft = null;
7437
+ // Whether the dialog is naming a stack rather than editing one that is
7438
+ // already named, and what names are already spoken for. Held here
7439
+ // because the save is the only place it can be checked: `PUT` upserts,
7440
+ // so a name that is taken replaces what is there rather than refusing.
7441
+ let stackNaming = false;
7442
+ let stackNamesTaken = new Set();
7443
+
7444
+ /**
7445
+ * A name like this one that nothing is using.
7446
+ * @param {string} base - The name being copied.
7447
+ * @param {Set<string>} taken - What is spoken for.
7448
+ * @returns {string} - A free name.
7449
+ */
7450
+ const freeStackId = (base, taken) => {
7451
+ let candidate = `${base}-copy`;
7452
+ for (let n = 2; taken.has(candidate); n += 1) {
7453
+ candidate = `${base}-copy-${n}`;
7454
+ }
7455
+ return candidate;
7456
+ };
7434
7457
 
7435
7458
  /** Forty characters of hex is not a label. */
7436
7459
  const shortHash = (hash) => (hash ? `${hash.slice(0, 12)}…` : hash);
@@ -7439,24 +7462,35 @@ Every piece is hashed against the ` +
7439
7462
  const draftIsRgba = () => stackDraft?.space === 'rgba';
7440
7463
 
7441
7464
  /**
7442
- * Opens the dialog on a new stack, or on one that already exists.
7443
- * @param {object} [existing] - The stack to edit.
7465
+ * Opens the dialog on a new stack, on one that already exists, or on a
7466
+ * copy of one.
7467
+ * @param {object} [existing] - The stack to edit, or the copy to name.
7468
+ * @param {object} [options] - `copy` when the draft is a duplicate, and
7469
+ * `taken` the names already in use.
7444
7470
  * @returns {Promise<void>} - Resolves once the dialog is open.
7445
7471
  */
7446
- const openStackDialog = async (existing) => {
7472
+ const openStackDialog = async (existing, options = {}) => {
7447
7473
  stackDraft = existing
7448
7474
  ? JSON.parse(JSON.stringify(existing))
7449
7475
  : { id: '', title: '', space: 'elevation', sources: [], output: {} };
7450
7476
  stackDraft.sources ??= [];
7451
7477
  stackDraft.output ??= {};
7452
7478
 
7453
- $('stack-dialog-title').textContent = existing
7454
- ? 'Edit stack'
7455
- : 'Add stack';
7479
+ // A copy arrives filled in but unnamed as far as the node is
7480
+ // concerned, so it is edited like a new stack rather than like the one
7481
+ // it came from.
7482
+ stackNaming = Boolean(options.copy) || !existing;
7483
+ stackNamesTaken = new Set(options.taken ?? []);
7484
+
7485
+ $('stack-dialog-title').textContent = options.copy
7486
+ ? 'Duplicate stack'
7487
+ : existing
7488
+ ? 'Edit stack'
7489
+ : 'Add stack';
7456
7490
  // An id is what the URL is made of, so changing one would silently
7457
7491
  // orphan whatever points at the old address.
7458
7492
  $('stack-id').value = stackDraft.id ?? '';
7459
- $('stack-id').disabled = Boolean(existing);
7493
+ $('stack-id').disabled = !stackNaming;
7460
7494
  $('stack-title').value = stackDraft.title ?? '';
7461
7495
  for (const radio of document.querySelectorAll(
7462
7496
  'input[name="stack-space"]',
@@ -8021,6 +8055,30 @@ Every piece is hashed against the ` +
8021
8055
  $('stack-form').addEventListener('submit', async (event) => {
8022
8056
  event.preventDefault();
8023
8057
  const id = $('stack-id').value.trim();
8058
+ // Caught here because the node cannot catch it: a stack with no name
8059
+ // is a `PUT /api/stacks/`, which matches no route at all, and what
8060
+ // comes back says only that the reply was not JSON.
8061
+ if (!id) {
8062
+ $('stack-error').textContent =
8063
+ 'A name is needed. It is what the stack is served under, so ' +
8064
+ 'there is nowhere to save one without it.';
8065
+ return;
8066
+ }
8067
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) {
8068
+ $('stack-error').textContent =
8069
+ 'A name may hold letters, digits, dots, dashes and underscores, ' +
8070
+ 'and has to start with a letter or a digit — it goes in a URL.';
8071
+ return;
8072
+ }
8073
+ // `PUT` upserts, so a name already in use is replaced rather than
8074
+ // refused -- which for a duplicate means quietly overwriting the very
8075
+ // stack it was copied from.
8076
+ if (stackNaming && stackNamesTaken.has(id)) {
8077
+ $('stack-error').textContent =
8078
+ `There is already a stack called "${id}". A name is what its URL ` +
8079
+ 'is made of, so saving would replace it rather than add this one.';
8080
+ return;
8081
+ }
8024
8082
  const body = {
8025
8083
  title: $('stack-title').value.trim() || undefined,
8026
8084
  space: stackDraft.space,
@@ -8086,8 +8144,16 @@ Every piece is hashed against the ` +
8086
8144
  loadStacks().catch((e) => toast(e.message));
8087
8145
  });
8088
8146
 
8089
- $('stacks-add').onclick = () =>
8090
- openStackDialog().catch((e) => toast(e.message));
8147
+ $('stacks-add').onclick = async () => {
8148
+ try {
8149
+ const { stacks } = await api('/api/stacks');
8150
+ await openStackDialog(undefined, {
8151
+ taken: stacks.map((one) => one.id),
8152
+ });
8153
+ } catch (error) {
8154
+ toast(error.message);
8155
+ }
8156
+ };
8091
8157
 
8092
8158
  /**
8093
8159
  * Asks where an export should land before starting it.
@@ -8231,6 +8297,7 @@ Every piece is hashed against the ` +
8231
8297
  // change, so they are delegated the same way the source rows are.
8232
8298
  $('stacks-list').addEventListener('click', async (event) => {
8233
8299
  const edit = event.target.dataset.stackEdit;
8300
+ const copy = event.target.dataset.stackCopy;
8234
8301
  const drop = event.target.dataset.stackDelete;
8235
8302
  const bake = event.target.dataset.stackBake;
8236
8303
  const stopBake = event.target.dataset.stackBakeStop;
@@ -8260,6 +8327,23 @@ Every piece is hashed against the ` +
8260
8327
  const raw = await api(`/api/stacks/${encodeURIComponent(edit)}/raw`);
8261
8328
  await openStackDialog(raw.stack);
8262
8329
  }
8330
+ } else if (copy !== undefined) {
8331
+ const { stacks } = await api('/api/stacks');
8332
+ // The recipe, not the report -- the same reason editing reads it.
8333
+ // A report holds what each source resolved to, so a copy made from
8334
+ // one would pin infohashes the original follows by category.
8335
+ const raw = await api(`/api/stacks/${encodeURIComponent(copy)}/raw`);
8336
+ const taken = stacks.map((one) => one.id);
8337
+ await openStackDialog(
8338
+ {
8339
+ ...raw.stack,
8340
+ id: freeStackId(copy, new Set(taken)),
8341
+ // Named apart on purpose: two stacks under one title are two
8342
+ // rows nobody can tell apart in the list they both appear in.
8343
+ title: `${raw.stack.title ?? copy} copy`,
8344
+ },
8345
+ { copy: true, taken },
8346
+ );
8263
8347
  } else if (drop !== undefined) {
8264
8348
  if (!confirm(`Delete the stack "${drop}"?`)) return;
8265
8349
  try {