nakarte

Source code of https://map.sikmir.ru (fork)
git clone git://git.sikmir.ru/nakarte
Log | Files | Refs | LICENSE

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:
Msrc/lib/binary-stream/index.js | 7+++++--
Msrc/lib/leaflet.control.track-list/track-list.js | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Asrc/lib/zip-writer/index.js | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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};