commit aa0e42cea30ea96110febf8627cdd2ca0b9948a6
parent e13d4b60880143a4376da6285d25f0b13fce1619
Author: Sergej Orlov <wladimirych@gmail.com>
Date: Thu, 6 Feb 2025 15:23:44 +0100
tracks: add feature "Save all tracks to ZIP file"
fixes #1188
Diffstat:
3 files changed, 203 insertions(+), 10 deletions(-)
diff --git a/src/lib/binary-stream/index.js b/src/lib/binary-stream/index.js
@@ -64,11 +64,14 @@ class BinStream {
this._pos += 4;
}
- writeString(s, zeroTerminated) {
- s = utf8.encode(s);
+ writeBinaryString(s) {
for (let i = 0; i < s.length; i++) {
this.writeUint8(s.charCodeAt(i));
}
+ }
+
+ writeString(s, zeroTerminated) {
+ this.writeBinaryString(utf8.encode(s));
if (zeroTerminated) {
this.writeUint8(0);
}
diff --git a/src/lib/leaflet.control.track-list/track-list.js b/src/lib/leaflet.control.track-list/track-list.js
@@ -24,6 +24,7 @@ import {fetch} from '~/lib/xhr-promise';
import config from '~/config';
import md5 from 'blueimp-md5';
import {wrapLatLngToTarget, wrapLatLngBoundsToTarget} from '~/lib/leaflet.fixes/fixWorldCopyJump';
+import {createZipFile} from '~/lib/zip-writer';
import {splitLinesAt180Meridian} from "./lib/meridian180";
import {ElevationProvider} from '~/lib/elevations';
import {parseNktkSequence} from './lib/parsers/nktk';
@@ -42,6 +43,23 @@ const TrackSegment = L.MeasuredLine.extend({
});
TrackSegment.mergeOptions(L.Polyline.EditMixinOptions);
+// name: str
+// seen: Set[str]
+// return str[]
+function makeNameUnique(name, seen) {
+ const maxTries = 10_000;
+ let uniqueName = name;
+ let i = 0;
+ while (seen.has(uniqueName)) {
+ i += 1;
+ if (i > maxTries) {
+ throw new Error(`Failed to create unique name for "${name}"`);
+ }
+ uniqueName = `${name}(${i})`;
+ }
+ return uniqueName;
+}
+
function getLinkToShare(keysToExclude, paramsToAdd) {
const {origin, pathname, hash} = window.location;
@@ -210,6 +228,11 @@ L.Control.TrackList = L.Control.extend({
text: 'Create new track from all visible tracks',
callback: this.createNewTrackFromVisibleTracks.bind(this)
},
+ () => ({
+ text: 'Save all tracks to ZIP file',
+ callback: this.saveAllTracksToZipFile.bind(this),
+ disabled: !this.tracks().length
+ }),
'-',
{text: 'Delete all tracks', callback: this.deleteAllTracks.bind(this)},
{text: 'Delete hidden tracks', callback: this.deleteHiddenTracks.bind(this)}
@@ -654,7 +677,7 @@ L.Control.TrackList = L.Control.extend({
this.copyTracksLinkToClipboard([track], mouseEvent);
},
- saveTrackAsFile: async function(track, exporter, extension, addElevations = false) {
+ exportTrackAsFile: async function(track, exporter, extension, addElevations, allowEmpty) {
var lines = this.getTrackPolylines(track)
.map(function(line) {
return line.getFixedLatLngs();
@@ -663,7 +686,7 @@ L.Control.TrackList = L.Control.extend({
lines = splitLinesAt180Meridian(lines);
var points = this.getTrackPoints(track);
let name = track.name();
- // Browser (Chrome) removes leading dots.
+ // Browser (Chrome) removes leading dots. Also we do not want to create hidden files on Linux
name = name.replace(/^\./u, '_');
for (let extensions of [this.options.splitExtensionsFirstStage, this.options.splitExtensions]) {
let i = name.lastIndexOf('.');
@@ -671,9 +694,8 @@ L.Control.TrackList = L.Control.extend({
name = name.slice(0, i);
}
}
- if (lines.length === 0 && points.length === 0) {
- notify('Track is empty, nothing to save');
- return;
+ if (!allowEmpty && lines.length === 0 && points.length === 0) {
+ return {error: 'Track is empty, nothing to save'};
}
if (addElevations) {
@@ -706,9 +728,21 @@ L.Control.TrackList = L.Control.extend({
}
}
- var fileText = exporter(lines, name, points);
- var filename = name + extension;
- saveAs(blobFromString(fileText), filename, true);
+ return {
+ content: exporter(lines, name, points),
+ filename: name + extension,
+ };
+ },
+
+ saveTrackAsFile: async function(track, exporter, extension, addElevations = false) {
+ const {error, content, filename} = await this.exportTrackAsFile(
+ track, exporter, extension, addElevations, false
+ );
+ if (error) {
+ notify(error);
+ return;
+ }
+ saveAs(blobFromString(content), filename, true);
},
renameTrack: function(track) {
@@ -1435,6 +1469,40 @@ L.Control.TrackList = L.Control.extend({
this.addTrack({name: newTrackName, tracks: newTrackSegments, points: newTrackPoints});
},
+ saveAllTracksToZipFile: async function() {
+ const tracks = this.tracks();
+ const trackFilesData = [];
+ const seenNames = new Set();
+ for (const track of tracks) {
+ const {error, content, filename} = await this.exportTrackAsFile(
+ track, geoExporters.saveGpx, '', false, true
+ );
+ if (error) {
+ notify(error);
+ return;
+ }
+ const safeFilename = filename.replaceAll(/[<>:"/\\|?*]/ug, '_');
+ const uniqueFilename = makeNameUnique(safeFilename, seenNames);
+ seenNames.add(uniqueFilename);
+ trackFilesData.push({content, filename: uniqueFilename + '.gpx'});
+ }
+ const zipFile = createZipFile(trackFilesData);
+ const now = new Date();
+ const dateString = [
+ String(now.getDate()).padStart(2, '0'),
+ '.',
+ String(now.getMonth()).padStart(2, '0'),
+ '.',
+ now.getFullYear(),
+ '_',
+ String(now.getHours()).padStart(2, '0'),
+ '.',
+ String(now.getMinutes()).padStart(2, '0'),
+ ].join('');
+ const zipFilename = `nakarte_tracks_${dateString}.zip`;
+ saveAs(new Blob([zipFile], {type: 'application/download'}), zipFilename, true);
+ },
+
exportTracks: function(minTicksIntervalMeters) {
var that = this;
/* eslint-disable max-nested-callbacks */
diff --git a/src/lib/zip-writer/index.js b/src/lib/zip-writer/index.js
@@ -0,0 +1,122 @@
+import utf8 from 'utf8';
+
+import {BinStream} from '~/lib/binary-stream';
+
+// Reference:
+// https://users.cs.jmu.edu/buchhofp/forensics/formats/pkzip.html
+// https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
+
+const CRC_TABLE = new Uint32Array(256);
+
+(function fillCrcTable() {
+ for (let i = 0; i < 256; ++i) {
+ let crc = i;
+ for (let j = 0; j < 8; ++j) {
+ crc = (crc >>> 1) ^ (crc & 0x01 && 0xedb88320);
+ }
+ CRC_TABLE[i] = crc;
+ }
+})();
+
+function crc32(s) {
+ let crc = -1;
+ for (let i = 0, l = s.length; i < l; i++) {
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc & 0xff) ^ s.charCodeAt(i)];
+ }
+ return (crc ^ -1) >>> 0;
+}
+
+function buildDOSDateTime(date) {
+ return {
+ dosTime: (date.getSeconds() >> 1) | (date.getMinutes() << 5) | (date.getHours() << 11),
+ dosDate: date.getDate() | ((date.getMonth() + 1) << 5) | ((date.getFullYear() - 1980) << 9),
+ };
+}
+
+function writeLocalFileHeader(stream, fileName, size, date, crc) {
+ const dosDateTime = buildDOSDateTime(date);
+ stream.writeBinaryString('\x50\x4b\x03\x04'); // signature
+ stream.writeUint16(10); // Version to extract
+ stream.writeUint16(1 << 11); // Flags = language encoding
+ stream.writeUint16(0); // No compression
+ stream.writeUint16(dosDateTime.dosTime);
+ stream.writeUint16(dosDateTime.dosDate);
+ stream.writeUint32(crc);
+ stream.writeUint32(size);
+ stream.writeUint32(size);
+ stream.writeUint16(fileName.length);
+ stream.writeUint16(0); // Extra field len
+ stream.writeBinaryString(fileName);
+}
+
+function writeCentralDirectoryFileHeader(stream, fileName, size, date, crc, localHeaderOffset) {
+ const dosDateTime = buildDOSDateTime(date);
+ stream.writeBinaryString('\x50\x4b\x01\x02'); // signature
+ stream.writeUint8(10); // Version made by = 1.0
+ stream.writeUint8(3); // Made in OS = UNIX
+ stream.writeUint16(10); // Version to extract = 1.0
+ stream.writeUint16(1 << 11); // Flags = language encoding
+ stream.writeUint16(0); // No compression
+ stream.writeUint16(dosDateTime.dosTime);
+ stream.writeUint16(dosDateTime.dosDate);
+ stream.writeUint32(crc);
+ stream.writeUint32(size);
+ stream.writeUint32(size);
+ stream.writeUint16(fileName.length);
+ stream.writeUint16(0); // Extra field len
+ stream.writeUint16(0); // File comment length
+ stream.writeUint16(0); // Disk # start
+ stream.writeUint16(0); // Internal attr
+ stream.writeUint32(0); // External attr
+ stream.writeUint32(localHeaderOffset); // External attr
+ stream.writeBinaryString(fileName);
+}
+
+function writeEndOfCentralDirectory(stream, filesNumber, centralDirectoryOffset, centralDirectorySize) {
+ stream.writeBinaryString('\x50\x4b\x05\x06');
+ stream.writeUint16(0); // Disk number
+ stream.writeUint16(0); // Disk number with central directory
+ stream.writeUint16(filesNumber);
+ stream.writeUint16(filesNumber);
+ stream.writeUint32(centralDirectorySize);
+ stream.writeUint32(centralDirectoryOffset);
+ stream.writeUint16(0); // Comment length
+}
+
+// files: Array of Object with items:
+// "filename" - string
+// "content" - binary string
+// returns: ArrayBuffer
+function createZipFile(files) {
+ const stream = new BinStream(true);
+ const now = new Date();
+ const encodedFilenames = [];
+ const checkSums = [];
+ const headerOffsets = [];
+
+ for (const [i, file] of files.entries()) {
+ encodedFilenames[i] = utf8.encode(file.filename);
+ checkSums[i] = crc32(file.content);
+ headerOffsets[i] = stream.tell();
+ writeLocalFileHeader(stream, encodedFilenames[i], file.content.length, now, checkSums[i]);
+ stream.writeBinaryString(file.content);
+ }
+
+ const centralDirectoryOffset = stream.tell();
+ for (const [i, file] of files.entries()) {
+ writeCentralDirectoryFileHeader(
+ stream,
+ encodedFilenames[i],
+ file.content.length,
+ now,
+ checkSums[i],
+ headerOffsets[i]
+ );
+ }
+
+ writeEndOfCentralDirectory(stream, files.length, centralDirectoryOffset, stream.tell() - centralDirectoryOffset);
+
+ return stream.getBuffer();
+}
+
+export {createZipFile};