pmtiles-swarm 0.65.0 → 0.66.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
@@ -4,6 +4,31 @@
4
4
  ### ✨ Features and improvements
5
5
  - _...Add new stuff here..._
6
6
 
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
9
+
10
+ ## 0.66.0
11
+ ### ✨ Features and improvements
12
+ - **An export asks where it should go, and what to call it.** **Export to archive** now opens a
13
+ dialog instead of a confirmation, because there is something to decide. The save location uses
14
+ the same picker every other destination in this console uses, so a location named once under
15
+ Settings is offered here too — a baked planet is hundreds of gigabytes and which disk it lands on
16
+ is not a detail. The name and description default to the stack's and can be changed.
17
+
18
+ The filename is shown as the name is typed. A name is not a filename — separators and the
19
+ characters Windows refuses become dashes, and the date is appended so successive builds do not
20
+ collide — and a preview promising one thing while the server writes another would be worse than
21
+ no preview at all. A test asserts the browser's rule and `safeSegment` agree.
22
+
23
+ ### 🐞 Bug fixes
24
+ - **A save location the node did not know was silently ignored.** The export resolved it after the
25
+ merge and swallowed the error, so naming a location that did not exist, or one that could not be
26
+ written, meant an hour of merging answered with a shrug and the archive on the default disk. It
27
+ is resolved before anything starts and the error is allowed through, which is what lets the
28
+ dialog put it beside the field that caused it.
29
+
30
+
31
+
7
32
  ### 🐞 Bug fixes
8
33
  - _...Add new stuff here..._
9
34
 
@@ -730,6 +730,20 @@ worth writing at all.
730
730
  planet bake is hours, and a request that waited for the archive is a request
731
731
  nothing could hold open. `DELETE` on the same address stops it.
732
732
 
733
+ The button opens a dialog rather than a confirmation, because there is
734
+ something to decide. **Where it lands** uses the same picker every other
735
+ destination in this console uses, so a location named once under Settings is
736
+ offered here too — and a baked planet is hundreds of gigabytes, so which disk it
737
+ goes on is not a detail. The location is resolved _before_ anything is merged:
738
+ a name this node does not know, or a path it cannot write, is the caller's
739
+ mistake and they can fix it, but only if they are told now rather than an hour
740
+ later.
741
+
742
+ **What it is called** defaults to the stack's title and can be changed, as can
743
+ the description. The dialog shows the filename as the name is typed, because a
744
+ name is not a filename — the same sanitising the server does, and a preview that
745
+ promised something else would be worse than no preview.
746
+
733
747
  A bake has two halves and they are watched in two places, deliberately. Merging
734
748
  is about a stack, so it is reported on the stack — tiles written, tiles skipped,
735
749
  the zoom it is working through. What happens afterwards is an archive being
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.65.0",
3
+ "version": "0.66.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
@@ -3181,6 +3181,8 @@ export function createApp({
3181
3181
  categories: req.body?.categories,
3182
3182
  location: req.body?.location,
3183
3183
  savePath: req.body?.savePath,
3184
+ name: req.body?.name,
3185
+ description: req.body?.description,
3184
3186
  });
3185
3187
  return res.status(202).json({ bake: job });
3186
3188
  } catch (error) {
package/src/bake-jobs.js CHANGED
@@ -113,9 +113,21 @@ export class BakeManager {
113
113
  throw error;
114
114
  }
115
115
 
116
+ // Resolved before anything starts, and allowed to throw. A location that
117
+ // does not exist or cannot be written is the caller's mistake and they can
118
+ // fix it -- but only if they are told now. Checked at the end instead, it
119
+ // is an hour of merging answered with a shrug and the default disk.
120
+ const publishDir =
121
+ options.publishDir ?? (await this.#savePath(options)) ?? undefined;
122
+
123
+ // What the archive will be called, which the caller may have chosen. The
124
+ // file follows it; the date is what keeps successive builds apart.
125
+ const archiveName = options.name?.trim() || resolved.stack.title || stackId;
126
+
116
127
  const job = {
117
128
  stackId,
118
129
  title: resolved.stack.title ?? stackId,
130
+ archiveName,
119
131
  phase: 'merging',
120
132
  startedAt: new Date().toISOString(),
121
133
  written: 0,
@@ -126,7 +138,8 @@ export class BakeManager {
126
138
  finishedAt: null,
127
139
  error: null,
128
140
  infoHash: null,
129
- name: bakedName(resolved, { when: new Date() }),
141
+ name: bakedName(resolved, { when: new Date(), name: archiveName }),
142
+ publishDir,
130
143
  };
131
144
  this.#jobs.set(stackId, job);
132
145
 
@@ -171,6 +184,7 @@ export class BakeManager {
171
184
  workDir,
172
185
  destination,
173
186
  format,
187
+ options,
174
188
  );
175
189
  } finally {
176
190
  await pixels?.close().catch(() => {});
@@ -185,7 +199,7 @@ export class BakeManager {
185
199
  // Moved out of the working directory as it is taken on, so a finished
186
200
  // archive does not live among the checkpoint files of the job that made
187
201
  // it.
188
- publishDir: options.publishDir ?? (await this.#savePath(options)),
202
+ publishDir: job.publishDir,
189
203
  mode: 'mirror',
190
204
  });
191
205
 
@@ -205,7 +219,16 @@ export class BakeManager {
205
219
  * @param {string} format - The output format.
206
220
  * @returns {Promise<void>} - Resolves when the file is written.
207
221
  */
208
- async #merge(job, resolved, codec, pixels, workDir, destination, format) {
222
+ async #merge(
223
+ job,
224
+ resolved,
225
+ codec,
226
+ pixels,
227
+ workDir,
228
+ destination,
229
+ format,
230
+ options,
231
+ ) {
209
232
  // Read through the tile store rather than off the disk, so a cache-mode
210
233
  // source is scanned the same way it is served: its directories come out of
211
234
  // the swarm, and the store holds them to its own byte budget.
@@ -249,8 +272,8 @@ export class BakeManager {
249
272
  header: { format },
250
273
  pauseMs: this.#config.stacks?.bakePauseMs ?? 0,
251
274
  metadata: {
252
- name: resolved.stack.title ?? job.stackId,
253
- description: resolved.stack.description,
275
+ name: job.archiveName,
276
+ description: options.description ?? resolved.stack.description,
254
277
  attribution: resolved.stack.attribution,
255
278
  encoding: resolved.stack.output?.encoding,
256
279
  encodingFactors: resolved.stack.output,
@@ -273,9 +296,10 @@ export class BakeManager {
273
296
  * @returns {Promise<string|undefined>} - A path, or undefined for the default.
274
297
  */
275
298
  async #savePath(options) {
276
- const explicit = await this.#library
277
- .resolveSavePath(options)
278
- .catch(() => undefined);
299
+ // Not caught. `resolveSavePath` refuses a name this node does not know and
300
+ // a path it cannot write, and both are worth refusing rather than quietly
301
+ // putting hundreds of gigabytes somewhere else.
302
+ const explicit = await this.#library.resolveSavePath(options);
279
303
  return explicit ?? this.#config.savePath ?? undefined;
280
304
  }
281
305
 
@@ -295,6 +319,8 @@ export class BakeManager {
295
319
  skipped: job.skipped,
296
320
  zoom: job.zoom,
297
321
  name: job.name,
322
+ archiveName: job.archiveName,
323
+ publishDir: job.publishDir ?? null,
298
324
  infoHash: job.infoHash,
299
325
  error: job.error,
300
326
  };
package/src/bake.js CHANGED
@@ -232,12 +232,16 @@ function stamp(when = new Date()) {
232
232
  * map and two files cannot share a path. The archive's *name* is not dated --
233
233
  * see `bakedMetadata` -- so a rebuild keeps its identity the way every other
234
234
  * rebuild here does.
235
+ *
236
+ * Follows the name it is given, so renaming an export in the dialog renames the
237
+ * file too. A name and a filename that disagree is a small thing that costs an
238
+ * afternoon the first time somebody goes looking on disk.
235
239
  * @param {object} resolved - The resolved stack.
236
- * @param {object} [options] - `when`, and `suffix` to break a tie.
240
+ * @param {object} [options] - `name` to override the stack's, `when`, `suffix`.
237
241
  * @returns {string} - A filename.
238
242
  */
239
243
  export function bakedName(resolved, options = {}) {
240
- const title = resolved.stack.title ?? resolved.stack.id;
244
+ const title = options.name ?? resolved.stack.title ?? resolved.stack.id;
241
245
  const slug = safeSegment(title) || 'stack';
242
246
  const tail = options.suffix ? `-${options.suffix}` : '';
243
247
  return `${slug}-${stamp(options.when)}${tail}.pmtiles`;
@@ -644,6 +644,59 @@
644
644
  </form>
645
645
  </dialog>
646
646
 
647
+ <dialog id="bake-dialog">
648
+ <form method="dialog" id="bake-form">
649
+ <h2>Export to archive</h2>
650
+ <div class="sub" style="margin-bottom:0.8rem">
651
+ Runs the recipe over its sources and writes the result as a real
652
+ archive, with its own infohash, seeded like any other. It reads every
653
+ tile the sources hold, so it can take hours — and it can be stopped,
654
+ which keeps the work for the next run to carry on from.
655
+ </div>
656
+
657
+ <div class="field">
658
+ <label>Stack</label>
659
+ <div class="sub"><code id="bake-stack"></code></div>
660
+ </div>
661
+
662
+ <div class="field">
663
+ <label for="bake-name">Name</label>
664
+ <input id="bake-name" placeholder="Terrain" />
665
+ <div class="sub">
666
+ What the archive is called, and what a rebuild of it keeps — so
667
+ leave the date out of it. The file gets one:
668
+ <code id="bake-filename"></code>
669
+ </div>
670
+ </div>
671
+
672
+ <div class="field">
673
+ <label for="bake-description">Description</label>
674
+ <input id="bake-description" placeholder="Bathymetry under terrain" />
675
+ <div class="sub">
676
+ Optional. The date this was baked is added to whatever is here.
677
+ </div>
678
+ </div>
679
+
680
+ <div id="bake-location"></div>
681
+
682
+ <div class="field">
683
+ <label for="bake-categories">Categories</label>
684
+ <input id="bake-categories" placeholder="terrain, weekly" />
685
+ <div class="sub">
686
+ Comma separated. What the archive is filed under once it is added,
687
+ which is what <code>/latest/&lt;category&gt;/</code> follows. Left
688
+ empty, it takes the stack's own.
689
+ </div>
690
+ </div>
691
+
692
+ <div class="sub" id="bake-error" style="color: var(--danger)"></div>
693
+ <menu>
694
+ <button value="cancel">Cancel</button>
695
+ <button id="bake-go" value="default">Export</button>
696
+ </menu>
697
+ </form>
698
+ </dialog>
699
+
647
700
  <dialog id="adopt-dialog">
648
701
  <form method="dialog" id="adopt-form">
649
702
  <h2>Adopt existing torrents</h2>
@@ -7678,6 +7731,87 @@ Every piece is hashed against the ` +
7678
7731
  $('stacks-add').onclick = () =>
7679
7732
  openStackDialog().catch((e) => toast(e.message));
7680
7733
 
7734
+ /**
7735
+ * Asks where an export should land before starting it.
7736
+ *
7737
+ * A dialog rather than a confirm, because there is something to decide:
7738
+ * a baked planet is hundreds of gigabytes and the disk it goes on is not
7739
+ * a detail. The same picker every other destination in this console
7740
+ * uses, so a location named once under Settings is offered here too.
7741
+ * @param {string} id - The stack to export.
7742
+ * @returns {Promise<void>} - Resolves once it is asked for or dismissed.
7743
+ */
7744
+ async function openBakeDialog(id, stack) {
7745
+ $('bake-stack').textContent = id;
7746
+ $('bake-error').textContent = '';
7747
+ $('bake-name').value = stack?.title ?? id;
7748
+ $('bake-description').value = stack?.description ?? '';
7749
+ $('bake-categories').value = (stack?.categories ?? []).join(', ');
7750
+ showBakeFilename();
7751
+ $('bake-location').innerHTML = locationPicker('bake-loc');
7752
+ await fillLocations('bake-loc');
7753
+ $('bake-dialog').showModal();
7754
+ }
7755
+
7756
+ /**
7757
+ * Shows what the file will be called, as the name is typed.
7758
+ *
7759
+ * The same rules the server applies, because a name is not a filename:
7760
+ * separators and the characters Windows refuses become dashes, and the
7761
+ * date is appended so successive builds do not collide. Seeing that
7762
+ * before pressing the button beats finding it on disk afterwards.
7763
+ * @returns {void}
7764
+ */
7765
+ function showBakeFilename() {
7766
+ const typed = $('bake-name').value.trim() || $('bake-stack').textContent;
7767
+ const slug =
7768
+ typed
7769
+ .replace(/[/\\<>:"|?*\u0000-\u001f\u007f -]/g, '-')
7770
+ .replace(/-{2,}/g, '-')
7771
+ .replace(/^[.\s-]+/, '')
7772
+ .replace(/[.\s-]+$/, '')
7773
+ .slice(0, 120) || 'stack';
7774
+ const day = new Date().toISOString().slice(0, 10).replaceAll('-', '');
7775
+ $('bake-filename').textContent = `${slug}-${day}.pmtiles`;
7776
+ }
7777
+
7778
+ $('bake-name').addEventListener('input', showBakeFilename);
7779
+
7780
+ $('bake-form').addEventListener('submit', async (event) => {
7781
+ if (event.submitter?.value === 'cancel') return;
7782
+ event.preventDefault();
7783
+
7784
+ const id = $('bake-stack').textContent;
7785
+ const categories = $('bake-categories')
7786
+ .value.split(',')
7787
+ .map((one) => one.trim())
7788
+ .filter(Boolean);
7789
+ const name = $('bake-name').value.trim();
7790
+ const description = $('bake-description').value.trim();
7791
+
7792
+ try {
7793
+ await api(`/api/stacks/${encodeURIComponent(id)}/bake`, {
7794
+ method: 'POST',
7795
+ body: {
7796
+ ...chosenLocation('bake-loc'),
7797
+ ...(categories.length > 0 ? { categories } : {}),
7798
+ ...(name ? { name } : {}),
7799
+ ...(description ? { description } : {}),
7800
+ },
7801
+ });
7802
+ } catch (error) {
7803
+ // Kept open, because the answer is usually something to change here
7804
+ // -- a location that does not exist, or a codec that is not
7805
+ // installed -- rather than something to go away and think about.
7806
+ $('bake-error').textContent = error.message;
7807
+ return;
7808
+ }
7809
+
7810
+ $('bake-dialog').close();
7811
+ toast(`Exporting ${id}`);
7812
+ loadStacks().catch((e) => toast(e.message));
7813
+ });
7814
+
7681
7815
  // Editing and deleting hang off the list, which is redrawn on every
7682
7816
  // change, so they are delegated the same way the source rows are.
7683
7817
  $('stacks-list').addEventListener('click', async (event) => {
@@ -7686,27 +7820,11 @@ Every piece is hashed against the ` +
7686
7820
  const bake = event.target.dataset.stackBake;
7687
7821
  const stopBake = event.target.dataset.stackBakeStop;
7688
7822
  if (bake !== undefined) {
7689
- // Confirmed, because it is not a small thing: it reads every tile
7690
- // every source holds, writes a whole archive, and then hashes it.
7691
- if (
7692
- !confirm(
7693
- `Run "${bake}" over its sources and write the result as a new ` +
7694
- 'archive?\n\nThis reads every tile the sources hold and can ' +
7695
- 'take hours. It can be stopped, and stopping keeps the work.',
7696
- )
7697
- ) {
7698
- return;
7699
- }
7700
- try {
7701
- await api(`/api/stacks/${encodeURIComponent(bake)}/bake`, {
7702
- method: 'POST',
7703
- body: {},
7704
- });
7705
- toast(`Exporting ${bake}`);
7706
- } catch (error) {
7707
- toast(error.message);
7708
- }
7709
- loadStacks().catch((e) => toast(e.message));
7823
+ const { stacks } = await api('/api/stacks');
7824
+ await openBakeDialog(
7825
+ bake,
7826
+ stacks.find((one) => one.id === bake),
7827
+ ).catch((e) => toast(e.message));
7710
7828
  } else if (stopBake !== undefined) {
7711
7829
  try {
7712
7830
  await api(`/api/stacks/${encodeURIComponent(stopBake)}/bake`, {