Add option to export GPX files as ZIP, add Osmand line width and colour (#246)

pull/256/head
Candid Dauth 2024-03-10 03:18:33 +01:00
rodzic 2999e4646c
commit d3e3c513f6
23 zmienionych plików z 686 dodań i 301 usunięć

Wyświetl plik

@ -44,8 +44,14 @@
...Object.values(client.value.types).flatMap((type) => type.fields.map((field) => field.name)) ...Object.values(client.value.types).flatMap((type) => type.fields.map((field) => field.name))
])); ]));
const routeTypeOptions = {
"tracks": "Track points",
"zip": "Track points, one file per line (ZIP file)",
"routes": "Route points"
};
const format = ref<keyof typeof formatOptions>("gpx"); const format = ref<keyof typeof formatOptions>("gpx");
const useTracks = ref<"1" | "0">("1"); const routeType = ref<keyof typeof routeTypeOptions>("tracks");
const filter = ref(true); const filter = ref(true);
const hide = ref(new Set<string>()); const hide = ref(new Set<string>());
const typeId = ref<ID>(); const typeId = ref<ID>();
@ -69,7 +75,7 @@
const resolveTypeId = (typeId: ID | undefined) => typeId != null && client.value.types[typeId] ? typeId : undefined; const resolveTypeId = (typeId: ID | undefined) => typeId != null && client.value.types[typeId] ? typeId : undefined;
const resolvedTypeId = computed(() => resolveTypeId(typeId.value)); const resolvedTypeId = computed(() => resolveTypeId(typeId.value));
const canSelectUseTracks = computed(() => format.value === "gpx"); const canSelectRouteType = computed(() => format.value === "gpx");
const canSelectType = computed(() => format.value === "csv" || (format.value === "table" && method.value === "copy")); const canSelectType = computed(() => format.value === "csv" || (format.value === "table" && method.value === "copy"));
const mustSelectType = computed(() => canSelectType.value); const mustSelectType = computed(() => canSelectType.value);
const canSelectHide = computed(() => ["table", "csv"].includes(format.value)); const canSelectHide = computed(() => ["table", "csv"].includes(format.value));
@ -83,8 +89,8 @@
const url = computed(() => { const url = computed(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (canSelectUseTracks.value) { if (canSelectRouteType.value) {
params.set("useTracks", useTracks.value); params.set("useTracks", routeType.value === "routes" ? "0" : "1");
} }
if (canSelectHide.value && hide.value.size > 0) { if (canSelectHide.value && hide.value.size > 0) {
params.set("hide", [...hide.value].join(",")); params.set("hide", [...hide.value].join(","));
@ -130,6 +136,16 @@
); );
} }
case "gpx": {
return (
context.baseUrl
+ client.value.padData!.id
+ `/${format.value}`
+ (routeType.value === "zip" ? `/zip` : "")
+ (paramsStr ? `?${paramsStr}` : '')
);
}
default: { default: {
return ( return (
context.baseUrl context.baseUrl
@ -232,14 +248,18 @@
</div> </div>
</div> </div>
<div v-if="canSelectUseTracks" class="row mb-3"> <div v-if="canSelectRouteType" class="row mb-3">
<label class="col-sm-3 col-form-label" :for="`${id}-route-type-select`"> <label class="col-sm-3 col-form-label" :for="`${id}-route-type-select`">
Route type Route type
<HelpPopover> <HelpPopover>
<p> <p>
<strong>Track points</strong> will export your lines exactly as they are on your map. <strong>Track points</strong> will export your lines exactly as they are on your map.
</p> </p>
<p>
<strong>Track points, one file per line (ZIP file)</strong> will create a ZIP file with one GPX file
for all markers and one GPX file for each line. This works better with apps such as Osmand that only
support one line style per file.
</p>
<p> <p>
<strong>Route points</strong> will export only the from/via/to route points of your lines, and your <strong>Route points</strong> will export only the from/via/to route points of your lines, and your
navigation software/device will have to calculate the route using its own map data and algorithm. navigation software/device will have to calculate the route using its own map data and algorithm.
@ -247,9 +267,8 @@
</HelpPopover> </HelpPopover>
</label> </label>
<div class="col-sm-9"> <div class="col-sm-9">
<select class="form-select" v-model="useTracks" :id="`${id}-route-type-select`"> <select class="form-select" v-model="routeType" :id="`${id}-route-type-select`">
<option value="1">Track points</option> <option v-for="(label, value) in routeTypeOptions" :value="value" :key="value">{{label}}</option>
<option value="0">Route points</option>
</select> </select>
</div> </div>
</div> </div>

Wyświetl plik

@ -7,6 +7,7 @@
import { useToasts } from "./toasts/toasts.vue"; import { useToasts } from "./toasts/toasts.vue";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
import vTooltip from "../../utils/tooltip"; import vTooltip from "../../utils/tooltip";
import { getSafeFilename } from "facilmap-utils";
const context = injectContextRequired(); const context = injectContextRequired();
const toasts = useToasts(); const toasts = useToasts();
@ -26,7 +27,7 @@
try { try {
const exported = await props.getExport(format); const exported = await props.getExport(format);
saveAs(new Blob([exported], { type: "application/gpx+xml" }), `${props.filename}.gpx`); saveAs(new Blob([exported], { type: "application/gpx+xml" }), `${getSafeFilename(props.filename)}.gpx`);
} catch(err: any) { } catch(err: any) {
toasts.showErrorToast(`fm${context.id}-export-dropdown-error`, "Error exporting route", err); toasts.showErrorToast(`fm${context.id}-export-dropdown-error`, "Error exporting route", err);
} finally { } finally {

Wyświetl plik

@ -149,12 +149,20 @@
</template> </template>
<style lang="scss"> <style lang="scss">
.fm-popover { .fm-popover.fm-popover {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> .popover-body { > .popover-body {
overflow: auto; overflow: auto;
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
} }
} }
</style> </style>

Wyświetl plik

@ -66,6 +66,7 @@
"string-similarity": "^4.0.4", "string-similarity": "^4.0.4",
"strip-bom-buf": "^4.0.0", "strip-bom-buf": "^4.0.0",
"unzipper": "^0.10.14", "unzipper": "^0.10.14",
"zip-stream": "^6.0.0",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {

Wyświetl plik

@ -118,7 +118,7 @@ export default class DatabaseHelpers {
this._db = db; this._db = db;
} }
async _updateObjectStyles(objects: Marker | Line | AsyncGenerator<Marker | Line, void, void>): Promise<void> { async _updateObjectStyles(objects: Marker | Line | AsyncIterable<Marker | Line>): Promise<void> {
const types: Record<ID, Type> = { }; const types: Record<ID, Type> = { };
for await (const object of Symbol.asyncIterator in objects ? objects : arrayToAsyncIterator([objects])) { for await (const object of Symbol.asyncIterator in objects ? objects : arrayToAsyncIterator([objects])) {
const padId = object.padId; const padId = object.padId;
@ -187,7 +187,7 @@ export default class DatabaseHelpers {
return data; return data;
} }
async* _getPadObjects<T>(type: string, padId: PadId, condition?: FindOptions): AsyncGenerator<T, void, void> { async* _getPadObjects<T>(type: string, padId: PadId, condition?: FindOptions): AsyncIterable<T> {
const includeData = [ "Marker", "Line" ].includes(type); const includeData = [ "Marker", "Line" ].includes(type);
if(includeData) { if(includeData) {
@ -390,10 +390,23 @@ export default class DatabaseHelpers {
} }
} }
async _bulkCreateInBatches<T>(model: ModelCtor<Model>, data: Array<Record<string, unknown>>): Promise<Array<T>> { async _bulkCreateInBatches<T>(model: ModelCtor<Model>, data: Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>): Promise<Array<T>> {
const result: Array<any> = []; const result: Array<any> = [];
for(let i=0; i<data.length; i+=ITEMS_PER_BATCH) let slice: Array<Record<string, unknown>> = [];
result.push(...(await model.bulkCreate(data.slice(i, i+ITEMS_PER_BATCH))).map((it) => it.toJSON())); const createSlice = async () => {
result.push(...(await model.bulkCreate(slice)).map((it) => it.toJSON()));
slice = [];
};
for await (const item of data) {
slice.push(item);
if (slice.length >= ITEMS_PER_BATCH) {
createSlice();
}
}
if (slice.length > 0) {
createSlice();
}
return result; return result;
} }

Wyświetl plik

@ -100,7 +100,7 @@ export default class DatabaseHistory {
} }
getHistory(padId: PadId, types?: HistoryEntryType[]): AsyncGenerator<HistoryEntry, void, never> { getHistory(padId: PadId, types?: HistoryEntryType[]): AsyncIterable<HistoryEntry> {
const query: FindOptions = { order: [[ "time", "DESC" ]] }; const query: FindOptions = { order: [[ "time", "DESC" ]] };
if(types) if(types)
query.where = {type: types}; query.where = {type: types};

Wyświetl plik

@ -179,22 +179,15 @@ export default class DatabaseLines {
this.LineModel.hasMany(this.LineDataModel, { foreignKey: "lineId" }); this.LineModel.hasMany(this.LineDataModel, { foreignKey: "lineId" });
} }
getPadLines(padId: PadId, fields?: Array<keyof Line>): AsyncGenerator<Line, void, void> { getPadLines(padId: PadId, fields?: Array<keyof Line>): AsyncIterable<Line> {
const cond = fields ? { attributes: fields } : { }; const cond = fields ? { attributes: fields } : { };
return this._db.helpers._getPadObjects<Line>("Line", padId, cond); return this._db.helpers._getPadObjects<Line>("Line", padId, cond);
} }
getPadLinesByType(padId: PadId, typeId: ID): AsyncGenerator<Line, void, void> { getPadLinesByType(padId: PadId, typeId: ID): AsyncIterable<Line> {
return this._db.helpers._getPadObjects<Line>("Line", padId, { where: { typeId: typeId } }); return this._db.helpers._getPadObjects<Line>("Line", padId, { where: { typeId: typeId } });
} }
async* getPadLinesWithPoints(padId: PadId): AsyncGenerator<LineWithTrackPoints, void, void> {
for await (const line of this.getPadLines(padId)) {
const trackPoints = await this.getAllLinePoints(line.id);
yield { ...line, trackPoints };
}
}
async getLineTemplate(padId: PadId, data: { typeId: ID }): Promise<Line> { async getLineTemplate(padId: PadId, data: { typeId: ID }): Promise<Line> {
const lineTemplate = { const lineTemplate = {
...this.LineModel.build({ ...data, padId: padId } satisfies Partial<CreationAttributes<LineModel>> as any).toJSON(), ...this.LineModel.build({ ...data, padId: padId } satisfies Partial<CreationAttributes<LineModel>> as any).toJSON(),
@ -310,7 +303,7 @@ export default class DatabaseLines {
return oldLine; return oldLine;
} }
async* getLinePointsForPad(padId: PadId, bboxWithZoom: BboxWithZoom & BboxWithExcept): AsyncGenerator<{ id: ID; trackPoints: TrackPoint[] }, void, void> { async* getLinePointsForPad(padId: PadId, bboxWithZoom: BboxWithZoom & BboxWithExcept): AsyncIterable<{ id: ID; trackPoints: TrackPoint[] }> {
const lines = await this.LineModel.findAll({ attributes: ["id"], where: { padId } }); const lines = await this.LineModel.findAll({ attributes: ["id"], where: { padId } });
const chunks = chunk(lines.map((line) => line.id), 50000); const chunks = chunk(lines.map((line) => line.id), 50000);
for (const lineIds of chunks) { for (const lineIds of chunks) {
@ -336,12 +329,14 @@ export default class DatabaseLines {
} }
} }
async getAllLinePoints(lineId: ID): Promise<TrackPoint[]> { async* getAllLinePoints(lineId: ID): AsyncIterable<TrackPoint> {
const points = await this.LineModel.build({ id: lineId } satisfies Partial<CreationAttributes<LineModel>> as any).getLinePoints({ const points = await this.LineModel.build({ id: lineId } satisfies Partial<CreationAttributes<LineModel>> as any).getLinePoints({
attributes: [ "pos", "lat", "lon", "ele", "zoom", "idx" ], attributes: [ "pos", "lat", "lon", "ele", "zoom", "idx" ],
order: [["idx", "ASC"]] order: [["idx", "ASC"]]
}); });
return points.map((point) => omit(point.toJSON(), ["pos"]) as TrackPoint); for (const point of points) {
yield omit(point.toJSON(), ["pos"]) as TrackPoint;
}
} }
} }

Wyświetl plik

@ -76,11 +76,11 @@ export default class DatabaseMarkers {
this.MarkerModel.hasMany(this.MarkerDataModel, { foreignKey: "markerId" }); this.MarkerModel.hasMany(this.MarkerDataModel, { foreignKey: "markerId" });
} }
getPadMarkers(padId: PadId, bbox?: BboxWithZoom & BboxWithExcept): AsyncGenerator<Marker, void, void> { getPadMarkers(padId: PadId, bbox?: BboxWithZoom & BboxWithExcept): AsyncIterable<Marker> {
return this._db.helpers._getPadObjects<Marker>("Marker", padId, { where: this._db.helpers.makeBboxCondition(bbox) }); return this._db.helpers._getPadObjects<Marker>("Marker", padId, { where: this._db.helpers.makeBboxCondition(bbox) });
} }
getPadMarkersByType(padId: PadId, typeId: ID): AsyncGenerator<Marker, void, void> { getPadMarkersByType(padId: PadId, typeId: ID): AsyncIterable<Marker> {
return this._db.helpers._getPadObjects<Marker>("Marker", padId, { where: { padId: padId, typeId: typeId } }); return this._db.helpers._getPadObjects<Marker>("Marker", padId, { where: { padId: padId, typeId: typeId } });
} }

Wyświetl plik

@ -6,6 +6,7 @@ import { type BboxWithExcept, createModel, getPosType, getVirtualLatType, getVir
import { calculateRouteForLine } from "../routing/routing.js"; import { calculateRouteForLine } from "../routing/routing.js";
import { omit } from "lodash-es"; import { omit } from "lodash-es";
import type { Point as GeoJsonPoint } from "geojson"; import type { Point as GeoJsonPoint } from "geojson";
import { asyncIteratorToArray } from "../utils/streams.js";
const updateTimes: Record<string, number> = {}; const updateTimes: Record<string, number> = {};
@ -155,24 +156,24 @@ export default class DatabaseRoutes {
return; return;
const line = await this._db.lines.getLine(padId, lineId); const line = await this._db.lines.getLine(padId, lineId);
const linePoints = await this._db.lines.getAllLinePoints(lineId); const linePointsIt = this._db.lines.getAllLinePoints(lineId);
const linePoints = await asyncIteratorToArray((async function*() {
for await (const linePoint of linePointsIt) {
yield {
routeId,
lat: linePoint.lat,
lon: linePoint.lon,
ele: linePoint.ele,
zoom: linePoint.zoom,
idx: linePoint.idx
};
}
})());
if(thisTime != updateTimes[routeId]) if(thisTime != updateTimes[routeId])
return; return;
const create = []; await this._db.helpers._bulkCreateInBatches(this.RoutePointModel, linePoints);
for(const linePoint of linePoints) {
create.push({
routeId,
lat: linePoint.lat,
lon: linePoint.lon,
ele: linePoint.ele,
zoom: linePoint.zoom,
idx: linePoint.idx
});
}
await this._db.helpers._bulkCreateInBatches(this.RoutePointModel, create);
if(thisTime != updateTimes[routeId]) if(thisTime != updateTimes[routeId])
return; return;
@ -214,12 +215,14 @@ export default class DatabaseRoutes {
return data.map((d) => omit(d.toJSON(), ["pos"]) as TrackPoint); return data.map((d) => omit(d.toJSON(), ["pos"]) as TrackPoint);
} }
async getAllRoutePoints(routeId: string): Promise<TrackPoint[]> { async* getAllRoutePoints(routeId: string): AsyncIterable<TrackPoint> {
const data = await this.RoutePointModel.findAll({ const points = await this.RoutePointModel.findAll({
where: {routeId}, where: { routeId },
attributes: [ "pos", "lat", "lon", "idx", "ele", "zoom"] attributes: [ "pos", "lat", "lon", "idx", "ele", "zoom"]
}); });
return data.map((d) => omit(d.toJSON(), ["pos"]) as TrackPoint); for (const point of points) {
yield omit(point.toJSON(), ["pos"]) as TrackPoint;
}
} }
} }

Wyświetl plik

@ -85,7 +85,7 @@ export default class DatabaseTypes {
PadModel.hasMany(this.TypeModel, { foreignKey: "padId" }); PadModel.hasMany(this.TypeModel, { foreignKey: "padId" });
} }
getTypes(padId: PadId): AsyncGenerator<Type, void, void> { getTypes(padId: PadId): AsyncIterable<Type> {
return this._db.helpers._getPadObjects<Type>("Type", padId); return this._db.helpers._getPadObjects<Type>("Type", padId);
} }

Wyświetl plik

@ -57,7 +57,7 @@ export default class DatabaseViews {
this._db.pads.PadModel.hasMany(this.ViewModel, { foreignKey: "padId" }); this._db.pads.PadModel.hasMany(this.ViewModel, { foreignKey: "padId" });
} }
getViews(padId: PadId): AsyncGenerator<View, void, void> { getViews(padId: PadId): AsyncIterable<View> {
return this._db.helpers._getPadObjects<View>("View", padId); return this._db.helpers._getPadObjects<View>("View", padId);
} }

Wyświetl plik

@ -1,9 +1,8 @@
import { jsonStream, asyncIteratorToArray, streamPromiseToStream } from "../utils/streams.js"; import { asyncIteratorToArray, streamPromiseToStream, jsonStreamArray, mapAsyncIterator, jsonStreamRecord, type JsonStream, concatAsyncIterators, flatMapAsyncIterator } from "../utils/streams.js";
import { compileExpression } from "facilmap-utils"; import { compileExpression } from "facilmap-utils";
import type { Marker, MarkerFeature, LineFeature, PadId } from "facilmap-types"; import type { Marker, MarkerFeature, PadId, TrackPoint, Line } from "facilmap-types";
import Database from "../database/database.js"; import Database from "../database/database.js";
import { cloneDeep, keyBy, mapValues, omit } from "lodash-es"; import { cloneDeep, keyBy, mapValues, omit } from "lodash-es";
import type { LineWithTrackPoints } from "../database/line.js";
import type { ReadableStream } from "stream/web"; import type { ReadableStream } from "stream/web";
export function exportGeoJson(database: Database, padId: PadId, filter?: string): ReadableStream<string> { export function exportGeoJson(database: Database, padId: PadId, filter?: string): ReadableStream<string> {
@ -17,49 +16,41 @@ export function exportGeoJson(database: Database, padId: PadId, filter?: string)
const types = keyBy(await asyncIteratorToArray(database.types.getTypes(padId)), "id"); const types = keyBy(await asyncIteratorToArray(database.types.getTypes(padId)), "id");
return jsonStream({ return jsonStreamRecord({
type: "FeatureCollection", type: "FeatureCollection",
...(padData.defaultView ? { bbox: "%bbox%" } : { }), ...(padData.defaultView ? {
facilmap: { bbox: [padData.defaultView.left, padData.defaultView.bottom, padData.defaultView.right, padData.defaultView.top]
name: "%name%", } : { }),
searchEngines: "%searchEngines%", facilmap: jsonStreamRecord({
description: "%description%", name: padData.name,
clusterMarkers: "%clusterMarkers", searchEngines: padData.searchEngines,
views: "%views%", description: padData.description,
types: "%types%" clusterMarkers: padData.clusterMarkers,
}, views: jsonStreamArray(mapAsyncIterator(database.views.getViews(padId), (view) => omit(view, ["id", "padId"])))
features: "%features%" }),
}, {
bbox: padData.defaultView && [padData.defaultView.left, padData.defaultView.bottom, padData.defaultView.right, padData.defaultView.top],
name: padData.name,
searchEngines: padData.searchEngines,
description: padData.description,
clusterMarkers: padData.clusterMarkers,
views: async function*() {
for await (const view of database.views.getViews(padId)) {
yield omit(view, ["id", "padId"]);
}
},
types: mapValues(types, (type) => omit(type, ["id", "padId"])), types: mapValues(types, (type) => omit(type, ["id", "padId"])),
features: async function*() { features: jsonStreamArray(concatAsyncIterators(
for await (const marker of database.markers.getPadMarkers(padId)) { flatMapAsyncIterator(database.markers.getPadMarkers(padId), (marker) => {
if (filterFunc(marker, types[marker.typeId])) { if (filterFunc(marker, types[marker.typeId])) {
yield markerToGeoJson(marker); return [markerToGeoJson(marker)];
} else {
return [];
} }
} }),
flatMapAsyncIterator(database.lines.getPadLines(padId), (line) => {
for await (const line of database.lines.getPadLinesWithPoints(padId)) {
if (filterFunc(line, types[line.typeId])) { if (filterFunc(line, types[line.typeId])) {
yield lineToGeoJson(line); return [lineToGeoJson(line, database.lines.getAllLinePoints(line.id))];
} else {
return [];
} }
} })
} ))
}); });
})()); })());
} }
function markerToGeoJson(marker: Marker): MarkerFeature { function markerToGeoJson(marker: Marker): JsonStream {
return { return jsonStreamRecord({
type: "Feature", type: "Feature",
geometry: { geometry: {
type: "Point", type: "Point",
@ -74,16 +65,16 @@ function markerToGeoJson(marker: Marker): MarkerFeature {
data: cloneDeep(marker.data), data: cloneDeep(marker.data),
typeId: marker.typeId typeId: marker.typeId
} }
}; } satisfies MarkerFeature);
} }
function lineToGeoJson(line: LineWithTrackPoints): LineFeature { function lineToGeoJson(line: Line, trackPoints: AsyncIterable<TrackPoint>): ReadableStream<string> {
return { return jsonStreamRecord({
type: "Feature", type: "Feature",
geometry: { geometry: jsonStreamRecord({
type: "LineString", type: "LineString",
coordinates: line.trackPoints.map((trackPoint) => [trackPoint.lon, trackPoint.lat]) coordinates: jsonStreamArray(mapAsyncIterator(trackPoints, (trackPoint) => [trackPoint.lon, trackPoint.lat]))
}, }),
properties: { properties: {
name: line.name, name: line.name,
mode: line.mode, mode: line.mode,
@ -96,5 +87,5 @@ function lineToGeoJson(line: LineWithTrackPoints): LineFeature {
routePoints: line.routePoints, routePoints: line.routePoints,
typeId: line.typeId typeId: line.typeId
} }
}; });
} }

Wyświetl plik

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name><%=line.name || 'FacilMap route'%></name>
<time><%=time%></time>
</metadata>
<<%=useTracks ? 'trk' : 'rte'%>>
<name><%=line.name || 'FacilMap route'%></name>
<% if(desc) { -%>
<desc><%=desc%></desc>
<% } -%>
<% if(useTracks) { -%>
<trkseg>
<% for(let trackPoint of line.trackPoints) { -%>
<trkpt lat="<%=trackPoint.lat%>" lon="<%=trackPoint.lon%>"<% if(trackPoint.ele != null){ %> ele="<%=trackPoint.ele%>"<% } %> />
<% } -%>
</trkseg>
<% } else { -%>
<% for(let routePoint of line.routePoints) { -%>
<rtept lat="<%=routePoint.lat%>" lon="<%=routePoint.lon%>" />
<% } -%>
<% } -%>
</<%=useTracks ? 'trk' : 'rte'%>>
</gpx>

Wyświetl plik

@ -1,14 +1,19 @@
import { asyncIteratorToArray, asyncIteratorToStream } from "../utils/streams.js"; import { asyncIteratorToArray, asyncIteratorToStream, getZipEncodeStream, indentStream, stringToStream, type ZipEncodeStreamItem } from "../utils/streams.js";
import { compile } from "ejs";
import Database from "../database/database.js"; import Database from "../database/database.js";
import type { Field, PadId, Type } from "facilmap-types"; import type { Field, Line, Marker, PadId, TrackPoint, Type } from "facilmap-types";
import { compileExpression, normalizeLineName, normalizeMarkerName, normalizePadName, quoteHtml } from "facilmap-utils"; import { compileExpression, getSafeFilename, normalizeLineName, normalizeMarkerName, normalizePadName, quoteHtml } from "facilmap-utils";
import type { LineWithTrackPoints } from "../database/line.js"; import type { LineWithTrackPoints } from "../database/line.js";
import { keyBy } from "lodash-es"; import { keyBy } from "lodash-es";
import gpxLineEjs from "./gpx-line.ejs?raw";
import type { ReadableStream } from "stream/web"; import type { ReadableStream } from "stream/web";
const lineTemplate = compile(gpxLineEjs); const gpxHeader = (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:osmand="https://osmand.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">`
);
const gpxFooter = (
`</gpx>`
);
const markerShapeToOsmand: Record<string, string> = { const markerShapeToOsmand: Record<string, string> = {
"drop": "circle", "drop": "circle",
@ -34,6 +39,67 @@ function dataToText(fields: Field[], data: Record<string, string>) {
return text.join('\n\n'); return text.join('\n\n');
} }
function getMetadataGpx(data: { name: string }, extensions: Record<string, string> = {}): string {
return (
`<metadata>\n` +
Object.entries({
time: new Date().toISOString(),
...data
}).map(([k, v]) => `\t<${quoteHtml(k)}>${quoteHtml(v)}</${quoteHtml(k)}>\n`).join("") +
`</metadata>` +
(Object.keys(extensions).length > 0 ? (
`\n` +
`<extensions>\n` +
Object.entries(extensions).map(([k, v]) => `\t<${quoteHtml(k)}>${quoteHtml(v)}</${quoteHtml(k)}>\n`).join("") +
`</extensions>`
) : "")
);
}
function getMarkerGpx(marker: Marker, type: Type): ReadableStream<string> {
const osmandBackground = markerShapeToOsmand[marker.shape || "drop"];
return stringToStream(
`<wpt lat="${quoteHtml(marker.lat)}" lon="${quoteHtml(marker.lon)}"${marker.ele != null ? ` ele="${quoteHtml(marker.ele)}"` : ""}>\n` +
`\t<name>${quoteHtml(normalizeMarkerName(marker.name))}</name>\n` +
`\t<desc>${quoteHtml(dataToText(type.fields, marker.data))}</desc>\n` +
`\t<extensions>\n` +
(osmandBackground ? `\t\t<osmand:background>${osmandBackground}</osmand:background>\n` : "") +
`\t\t<osmand:color>#${marker.colour}</osmand:color>\n` +
`\t</extensions>\n` +
`</wpt>`
);
}
function getLineRouteGpx(line: LineForExport, type: Type | undefined): ReadableStream<string> {
return stringToStream(
`<rte>\n` +
`\t<name>${quoteHtml(normalizeLineName(line.name))}</name>\n` +
(type ? `\t<desc>${quoteHtml(dataToText(type.fields, line.data ?? {}))}</desc>\n` : "") +
line.routePoints.map((routePoint) => (
`\t<rtept lat="${quoteHtml(routePoint.lat)}" lon="${quoteHtml(routePoint.lon)}" />\n`
)).join("") +
`</rte>`
);
}
function getLineTrackGpx(line: LineForExport, type: Type | undefined, trackPoints: AsyncIterable<TrackPoint>): ReadableStream<string> {
return asyncIteratorToStream((async function*() {
yield (
`<trk>\n` +
`\t<name>${quoteHtml(normalizeLineName(line.name))}</name>\n` +
(type ? `\t<desc>${quoteHtml(dataToText(type.fields, line.data ?? {}))}</desc>\n` : "") +
`\t<trkseg>\n`
);
for await (const trackPoint of trackPoints) {
yield `\t\t<trkpt lat="${quoteHtml(trackPoint.lat)}" lon="${quoteHtml(trackPoint.lon)}"${trackPoint.ele != null ? ` ele="${quoteHtml(trackPoint.ele)}"` : ""} />\n`;
}
yield (
`\t</trkseg>\n` +
`</trk>`
);
})());
}
export function exportGpx(database: Database, padId: PadId, useTracks: boolean, filter?: string): ReadableStream<string> { export function exportGpx(database: Database, padId: PadId, useTracks: boolean, filter?: string): ReadableStream<string> {
return asyncIteratorToStream((async function* () { return asyncIteratorToStream((async function* () {
const filterFunc = compileExpression(filter); const filterFunc = compileExpression(filter);
@ -47,69 +113,150 @@ export function exportGpx(database: Database, padId: PadId, useTracks: boolean,
throw new Error(`Pad ${padId} could not be found.`); throw new Error(`Pad ${padId} could not be found.`);
yield ( yield (
`<?xml version="1.0" encoding="UTF-8"?>\n` + `${gpxHeader}\n` +
`<gpx xmlns="http://www.topografix.com/GPX/1/1" creator="FacilMap" version="1.1" xmlns:osmand="https://osmand.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">\n` + `\t${getMetadataGpx({ name: normalizePadName(padData.name) }).replaceAll("\n", "\n\t")}\n`
`\t<metadata>\n` +
`\t\t<name>${quoteHtml(normalizePadName(padData.name))}</name>\n` +
`\t\t<time>${quoteHtml(new Date().toISOString())}</time>\n` +
`\t</metadata>\n`
); );
for await (const marker of database.markers.getPadMarkers(padId)) { for await (const marker of database.markers.getPadMarkers(padId)) {
if (filterFunc(marker, types[marker.typeId])) { if (filterFunc(marker, types[marker.typeId])) {
const osmandBackground = markerShapeToOsmand[marker.shape || "drop"]; for await (const chunk of indentStream(getMarkerGpx(marker, types[marker.typeId]), { indent: "\t", indentFirst: true, addNewline: true })) {
yield ( yield chunk;
`\t<wpt lat="${quoteHtml(marker.lat)}" lon="${quoteHtml(marker.lon)}"${marker.ele != null ? ` ele="${quoteHtml(marker.ele)}"` : ""}>\n` +
`\t\t<name>${quoteHtml(normalizeMarkerName(marker.name))}</name>\n` +
`\t\t<desc>${quoteHtml(dataToText(types[marker.typeId].fields, marker.data))}</desc>\n` +
`\t\t<extensions>\n` +
(osmandBackground ? `\t\t\t<osmand:background>${osmandBackground}</osmand:background>\n` : "") +
`\t\t\t<osmand:color>#${marker.colour}</osmand:color>\n` +
`\t\t</extensions>\n` +
`\t</wpt>\n`
);
}
}
for await (const line of database.lines.getPadLinesWithPoints(padId)) {
if (filterFunc(line, types[line.typeId])) {
if (useTracks || line.mode == "track") {
yield (
`\t<trk>\n` +
`\t\t<name>${quoteHtml(normalizeLineName(line.name))}</name>\n` +
`\t\t<desc>${dataToText(types[line.typeId].fields, line.data)}</desc>\n` +
`\t\t<trkseg>\n` +
line.trackPoints.map((trackPoint) => (
`\t\t\t<trkpt lat="${quoteHtml(trackPoint.lat)}" lon="${quoteHtml(trackPoint.lon)}"${trackPoint.ele != null ? ` ele="${quoteHtml(trackPoint.ele)}"` : ""} />\n`
)).join("") +
`\t\t</trkseg>\n` +
`\t</trk>\n`
);
} else {
yield (
`\t<rte>\n` +
`\t\t<name>${quoteHtml(normalizeLineName(line.name))}</name>\n` +
`\t\t<desc>${quoteHtml(dataToText(types[line.typeId].fields, line.data))}</desc>\n` +
line.routePoints.map((routePoint) => (
`\t\t<rtept lat="${quoteHtml(routePoint.lat)}" lon="${quoteHtml(routePoint.lon)}" />\n`
)).join("") +
`\t</rte>\n`
);
} }
} }
} }
yield `</gpx>`; for await (const line of database.lines.getPadLines(padId)) {
if (filterFunc(line, types[line.typeId])) {
if (useTracks || line.mode == "track") {
const trackPoints = database.lines.getAllLinePoints(line.id);
for await (const chunk of indentStream(getLineTrackGpx(line, types[line.typeId], trackPoints), { indent: "\t", indentFirst: true, addNewline: true })) {
yield chunk;
}
} else {
for await (const chunk of indentStream(getLineRouteGpx(line, types[line.typeId]), { indent: "\t", indentFirst: true, addNewline: true })) {
yield chunk;
}
}
}
}
yield gpxFooter;
})()); })());
} }
type LineForExport = Partial<Pick<LineWithTrackPoints, "name" | "data" | "mode" | "trackPoints" | "routePoints">>; export function exportGpxZip(database: Database, padId: PadId, useTracks: boolean, filter?: string): ReadableStream<Uint8Array> {
const encodeZipStream = getZipEncodeStream();
export async function exportLineToGpx(line: LineForExport, type: Type | undefined, useTracks: boolean): Promise<string> { asyncIteratorToStream((async function*(): AsyncIterable<ZipEncodeStreamItem> {
return lineTemplate({ const filterFunc = compileExpression(filter);
useTracks: (useTracks || line.mode == "track"),
time: new Date().toISOString(), const [padData, types] = await Promise.all([
desc: type && dataToText(type.fields, line.data ?? {}), database.pads.getPadData(padId),
line asyncIteratorToArray(database.types.getTypes(padId)).then((types) => keyBy(types, 'id'))
]);
if (!padData) {
throw new Error(`Pad ${padId} could not be found.`);
}
yield {
filename: "markers.gpx",
data: asyncIteratorToStream((async function*() {
yield (
`${gpxHeader}\n` +
`\t${getMetadataGpx({ name: normalizePadName(padData.name) }).replaceAll("\n", "\n\t")}\n`
);
for await (const marker of database.markers.getPadMarkers(padId)) {
if (filterFunc(marker, types[marker.typeId])) {
for await (const chunk of indentStream(getMarkerGpx(marker, types[marker.typeId]), { indent: "\t", indentFirst: true, addNewline: true })) {
yield chunk;
}
}
}
yield gpxFooter;
})())
};
yield {
filename: "lines/",
data: null
};
const names = new Set<string>();
for await (const line of database.lines.getPadLines(padId)) {
if (filterFunc(line, types[line.typeId])) {
const lineName = normalizeLineName(line.name);
let name = lineName;
for (let i = 1; names.has(name); i++) {
name = `${lineName} (${i})`;
}
names.add(name);
const filename = `lines/${getSafeFilename(name)}.gpx`;
if (useTracks || line.mode == "track") {
const trackPoints = database.lines.getAllLinePoints(line.id);
yield {
filename,
data: exportLineToTrackGpx(line, types[line.typeId], trackPoints)
};
} else {
yield {
filename,
data: exportLineToRouteGpx(line, types[line.typeId])
};
}
}
}
})()).pipeTo(encodeZipStream.writable);
return encodeZipStream.readable;
}
type LineForExport = Pick<LineWithTrackPoints, "name" | "data" | "mode" | "routePoints"> & Partial<Pick<Line, "colour" | "width">>;
function getLineMetadataGpx(line: LineForExport): string {
return getMetadataGpx({
name: normalizeLineName(line.name)
}, {
...(line.colour ? {
color: `#${line.colour}`
} : {}),
...(line.width ? {
width: `${line.width}`
} : {})
}); });
} }
export function exportLineToTrackGpx(line: LineForExport, type: Type | undefined, trackPoints: AsyncIterable<TrackPoint>): ReadableStream<string> {
return asyncIteratorToStream((async function*() {
yield (
`${gpxHeader}\n` +
`\t${getLineMetadataGpx(line).replaceAll("\n", "\n\t")}\n`
);
for await (const chunk of indentStream(getLineTrackGpx(line, type, trackPoints), { indent: "\t", indentFirst: true, addNewline: true })) {
yield chunk;
}
yield gpxFooter;
})());
}
export function exportLineToRouteGpx(line: LineForExport, type: Type | undefined): ReadableStream<string> {
return asyncIteratorToStream((async function*() {
yield (
`${gpxHeader}\n` +
`\t${getLineMetadataGpx(line).replaceAll("\n", "\n\t")}\n`
);
for await (const chunk of indentStream(getLineRouteGpx(line, type), { indent: "\t", indentFirst: true, addNewline: true })) {
yield chunk;
}
yield gpxFooter;
})());
}

Wyświetl plik

@ -7,7 +7,7 @@ import { Router, type RequestHandler } from "express";
import { static as expressStatic } from "express"; import { static as expressStatic } from "express";
import { normalizePadName, type InjectedConfig, quoteHtml } from "facilmap-utils"; import { normalizePadName, type InjectedConfig, quoteHtml } from "facilmap-utils";
import config from "./config"; import config from "./config";
import { asyncIteratorToArray, jsonStream, streamPromiseToStream, streamReplace } from "./utils/streams"; import { streamPromiseToStream, streamReplace } from "./utils/streams";
import { ReadableStream } from "stream/web"; import { ReadableStream } from "stream/web";
import { generateRandomId } from "./utils/utils"; import { generateRandomId } from "./utils/utils";
import type { TableParams } from "./export/table"; import type { TableParams } from "./export/table";
@ -143,10 +143,7 @@ export async function getStaticFrontendMiddleware(): Promise<RequestHandler> {
export async function getPwaManifest(): Promise<string> { export async function getPwaManifest(): Promise<string> {
const template = await readFile(paths.pwaManifest).then((t) => t.toString()); const template = await readFile(paths.pwaManifest).then((t) => t.toString());
const chunks = await asyncIteratorToArray(jsonStream(JSON.parse(template), { return template.replaceAll("%APP_NAME%", config.appName);
APP_NAME: config.appName
}));
return chunks.join("");
} }
export async function getOpensearchXml(baseUrl: string): Promise<string> { export async function getOpensearchXml(baseUrl: string): Promise<string> {

Wyświetl plik

@ -1,4 +1,6 @@
declare module "*?raw" { declare module "*?raw" {
declare const content: string; declare const content: string;
export default content; export default content;
} }
declare module "zip-stream";

Wyświetl plik

@ -1,8 +1,8 @@
import { promiseProps, type PromiseMap } from "../utils/utils.js"; import { promiseProps, type PromiseMap } from "../utils/utils.js";
import { asyncIteratorToArray } from "../utils/streams.js"; import { asyncIteratorToArray, streamToString } from "../utils/streams.js";
import { isInBbox } from "../utils/geo.js"; import { isInBbox } from "../utils/geo.js";
import { Socket, type Socket as SocketIO } from "socket.io"; import { Socket, type Socket as SocketIO } from "socket.io";
import { exportLineToGpx } from "../export/gpx.js"; import { exportLineToRouteGpx, exportLineToTrackGpx } from "../export/gpx.js";
import { find } from "../search.js"; import { find } from "../search.js";
import { geoipLookup } from "../geoip.js"; import { geoipLookup } from "../geoip.js";
import { cloneDeep, isEqual, omit } from "lodash-es"; import { cloneDeep, isEqual, omit } from "lodash-es";
@ -254,7 +254,7 @@ export class SocketConnectionV2 extends SocketConnection {
if (data.mode != "track") { if (data.mode != "track") {
for (const route of [...(this.route ? [this.route] : []), ...Object.values(this.routes)]) { for (const route of [...(this.route ? [this.route] : []), ...Object.values(this.routes)]) {
if(isEqual(route.routePoints, data.routePoints) && data.mode == route.mode) { if(isEqual(route.routePoints, data.routePoints) && data.mode == route.mode) {
fromRoute = { ...route, trackPoints: await this.database.routes.getAllRoutePoints(route.id) }; fromRoute = { ...route, trackPoints: await asyncIteratorToArray(this.database.routes.getAllRoutePoints(route.id)) };
break; break;
} }
} }
@ -273,7 +273,7 @@ export class SocketConnectionV2 extends SocketConnection {
if (data.mode != "track") { if (data.mode != "track") {
for (const route of [...(this.route ? [this.route] : []), ...Object.values(this.routes)]) { for (const route of [...(this.route ? [this.route] : []), ...Object.values(this.routes)]) {
if(isEqual(route.routePoints, data.routePoints) && data.mode == route.mode) { if(isEqual(route.routePoints, data.routePoints) && data.mode == route.mode) {
fromRoute = { ...route, trackPoints: await this.database.routes.getAllRoutePoints(route.id) }; fromRoute = { ...route, trackPoints: await asyncIteratorToArray(this.database.routes.getAllRoutePoints(route.id)) };
break; break;
} }
} }
@ -300,19 +300,16 @@ export class SocketConnectionV2 extends SocketConnection {
const lineP = this.database.lines.getLine(this.padId, data.id); const lineP = this.database.lines.getLine(this.padId, data.id);
lineP.catch(() => null); // Avoid unhandled promise error (https://stackoverflow.com/a/59062117/242365) lineP.catch(() => null); // Avoid unhandled promise error (https://stackoverflow.com/a/59062117/242365)
const [line, trackPoints, type] = await Promise.all([ const [line, type] = await Promise.all([
lineP, lineP,
this.database.lines.getAllLinePoints(data.id),
lineP.then((line) => this.database.types.getType(this.padId as string, line.typeId)) lineP.then((line) => this.database.types.getType(this.padId as string, line.typeId))
]); ]);
const lineWithTrackPoints = { ...line, trackPoints };
switch(data.format) { switch(data.format) {
case "gpx-trk": case "gpx-trk":
return exportLineToGpx(lineWithTrackPoints, type, true); return await streamToString(exportLineToTrackGpx(line, type, this.database.lines.getAllLinePoints(line.id)));
case "gpx-rte": case "gpx-rte":
return exportLineToGpx(lineWithTrackPoints, type, false); return await streamToString(exportLineToRouteGpx(line, type));
default: default:
throw new Error("Unknown format."); throw new Error("Unknown format.");
} }
@ -536,15 +533,13 @@ export class SocketConnectionV2 extends SocketConnection {
throw new Error("Route not available."); throw new Error("Route not available.");
} }
const trackPoints = await this.database.routes.getAllRoutePoints(route.id); const routeInfo = { ...route, name: "FacilMap route", data: {} };
const routeInfo = { ...this.route, trackPoints };
switch(data.format) { switch(data.format) {
case "gpx-trk": case "gpx-trk":
return await exportLineToGpx(routeInfo, undefined, true); return await streamToString(exportLineToTrackGpx(routeInfo, undefined, this.database.routes.getAllRoutePoints(route.id)));
case "gpx-rte": case "gpx-rte":
return await exportLineToGpx(routeInfo, undefined, false); return await streamToString(exportLineToRouteGpx(routeInfo, undefined));
default: default:
throw new Error("Unknown format."); throw new Error("Unknown format.");
} }

Wyświetl plik

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { arrayToAsyncIterator, asyncIteratorToArray, asyncIteratorToStream, jsonStream, streamPromiseToStream, streamReplace } from "../streams.js"; import { arrayToAsyncIterator, asyncIteratorToArray, asyncIteratorToStream, jsonStreamArray, jsonStreamRecord, streamPromiseToStream, streamReplace } from "../streams.js";
import { ReadableStream } from "stream/web"; import { ReadableStream } from "stream/web";
describe("streamPromiseToStream", () => { describe("streamPromiseToStream", () => {
@ -48,64 +48,60 @@ describe("streamPromiseToStream", () => {
}); });
test('jsonStream', async () => { test('jsonStream', async () => {
const template = { const stream = jsonStreamRecord({
test1: "%var1%", test1: { test: 'object' },
test2: { test2: jsonStreamRecord({
one: "one", one: "one",
two: "%var2%", two: jsonStreamArray([ { object: 'one' }, { object: 'two' } ]),
three: "three" three: "three"
}, }),
test3: "%var3%", test3: jsonStreamRecord(arrayToAsyncIterator(Object.entries({
test4: "%var4%", one: "one",
test5: "%var5%", two: jsonStreamArray(arrayToAsyncIterator([ { object: 'one' }, { object: 'two' } ])),
test6: "%var6%", three: "three"
test7: "%var7%", }))),
test8: "bla" test4: jsonStreamArray([
}; "one",
jsonStreamRecord({ object1: "one", object2: "two" }),
const stream = jsonStream(template, { "three"
var1: { test: 'object' }, ]),
var2: arrayToAsyncIterator([ { object: 'one' }, { object: 'two' } ]), test5: jsonStreamArray(arrayToAsyncIterator([
var3: () => arrayToAsyncIterator([ { object: 'one' }, { object: 'two' } ]), "one",
var4: 'asdf', jsonStreamRecord(arrayToAsyncIterator(Object.entries({ object1: "one", object2: "two" }))),
var5: () => 'bla', "three"
var6: Promise.resolve('promise'), ])),
var7: () => Promise.resolve('async') test6: Promise.resolve("promise"),
test7: "string"
}); });
const result = (await asyncIteratorToArray(stream as any)).join(""); const result = (await asyncIteratorToArray(stream as any)).join("");
expect(result).toBe( expect(result).toBe(JSON.stringify({
`{ test1: {
"test1": { test: "object"
"test": "object"
},
"test2": {
"one": "one",
"two": [
{
"object": "one"
},
{
"object": "two"
}
],
"three": "three"
},
"test3": [
{
"object": "one"
}, },
{ test2: {
"object": "two" one: "one",
} two: [{ object: "one" }, { object: "two" }],
], three: "three"
"test4": "asdf", },
"test5": "bla", test3: {
"test6": "promise", one: "one",
"test7": "async", two: [{ object: "one" }, { object: "two" }],
"test8": "bla" three: "three"
}` },
); test4: [
"one",
{ object1: "one", object2: "two" },
"three"
],
test5: [
"one",
{ object1: "one", object2: "two" },
"three"
],
test6: "promise",
test7: "string"
}, undefined, "\t"));
}); });
test("streamReplace", async () => { test("streamReplace", async () => {

Wyświetl plik

@ -1,4 +1,6 @@
import { ReadableStream, TransformStream } from "stream/web"; import { Readable } from "stream";
import { type QueuingStrategy, ReadableStream, TransformStream } from "stream/web";
import Packer from "zip-stream";
export async function asyncIteratorToArray<T>(iterator: AsyncIterable<T>): Promise<Array<T>> { export async function asyncIteratorToArray<T>(iterator: AsyncIterable<T>): Promise<Array<T>> {
const result: T[] = []; const result: T[] = [];
@ -8,23 +10,48 @@ export async function asyncIteratorToArray<T>(iterator: AsyncIterable<T>): Promi
return result; return result;
} }
export async function* arrayToAsyncIterator<T>(array: T[]): AsyncGenerator<T, void, void> { export async function* arrayToAsyncIterator<T>(array: T[]): AsyncIterable<T> {
for (const it of array) { for (const it of array) {
yield it; yield it;
} }
} }
export function asyncIteratorToStream<T>(iterator: AsyncGenerator<T, void, void>): ReadableStream<T> { export function asyncIteratorToStream<T>(iterator: AsyncIterable<T>, strategy?: QueuingStrategy<T>): ReadableStream<T> {
const it = iterator[Symbol.asyncIterator]();
return new ReadableStream<T>({ return new ReadableStream<T>({
async pull(controller) { async pull(controller) {
const { value, done } = await iterator.next(); const { value, done } = await it.next();
if (done) { if (done) {
controller.close(); controller.close();
} else { } else {
controller.enqueue(value); controller.enqueue(value);
} }
}, },
}); }, strategy);
}
export function mapAsyncIterator<T, O>(iterator: AsyncIterable<T>, mapper: (it: T) => O): AsyncIterable<O> {
return flatMapAsyncIterator(iterator, (it) => [mapper(it)]);
}
export function filterAsyncIterator<T>(iterator: AsyncIterable<T>, filter: (it: T) => boolean): AsyncIterable<T> {
return flatMapAsyncIterator(iterator, (it) => filter(it) ? [it] : []);
}
export async function* flatMapAsyncIterator<T, O>(iterator: AsyncIterable<T>, mapper: (it: T) => O[]): AsyncIterable<O> {
for await (const it of iterator) {
for (const o of mapper(it)) {
yield o;
}
}
}
export async function* concatAsyncIterators<T>(...iterators: Array<AsyncIterable<T>>): AsyncIterable<T> {
for (const iterator of iterators) {
for await (const it of iterator) {
yield it;
}
}
} }
export function streamPromiseToStream<T>(streamPromise: Promise<ReadableStream<T>>): ReadableStream<T> { export function streamPromiseToStream<T>(streamPromise: Promise<ReadableStream<T>>): ReadableStream<T> {
@ -57,40 +84,56 @@ export function flatMapStream<T, O>(stream: ReadableStream<T>, mapper: (it: T) =
return transform.readable; return transform.readable;
} }
export function jsonStream(template: any, data: Record<string, AsyncGenerator<any, any, void> | Promise<any> | any | (() => AsyncGenerator<any, any, void> | Promise<any> | any)>): ReadableStream<string> { type Stringifiable = boolean | number | string | undefined | null | Array<Stringifiable> | /* Should be Record<any, Stringifiable> here, but that does not work */ object;
return asyncIteratorToStream((async function*() { const jsonStreamSymbol = Symbol("jsonStream");
let lastIndent = ''; export type JsonStream = ReadableStream<string> & { [jsonStreamSymbol]: true };
const isJsonStream = (obj: unknown): obj is JsonStream => !!obj && typeof obj === "object" && jsonStreamSymbol in obj && !!obj[jsonStreamSymbol];
const parts = JSON.stringify(template, undefined, "\t").split(/"%([a-zA-Z0-9-_]+)%"/); export function jsonStreamArray(iterator: Iterable<Stringifiable | JsonStream> | AsyncIterable<Stringifiable | JsonStream>): JsonStream {
for (let i = 0; i < parts.length; i++) { return Object.assign(asyncIteratorToStream((async function*() {
const part = parts[i]; let first = true;
yield "[";
for await (const value of iterator) {
const prefix = `${first ? "" : ","}\n\t`;
first = false;
if (i % 2 == 0) { if (isJsonStream(value)) {
const lastLineBreak = part.lastIndexOf('\n'); yield prefix;
if (lastLineBreak != -1) for await (const chunk of streamReplace(value, { "\n": `\n\t` })) {
lastIndent = part.slice(lastLineBreak + 1).match(/^(\t*)/)![1]; yield chunk;
yield part;
} else {
const value = await (typeof data[part] === 'function' ? data[part]() : data[part]);
if (typeof value === 'object' && value && Symbol.asyncIterator in value) {
let first = true;
const indent = lastIndent + "\t";
yield '[\n';
for await (const obj of value) {
const prefix = first ? '' : ',\n';
first = false;
yield prefix + JSON.stringify(obj, undefined, "\t").replace(/^/gm, indent);
}
yield '\n' + lastIndent + ']';
} else {
const indent = lastIndent;
yield JSON.stringify(value, undefined, "\t").replace(/\n/g, '\n' + indent);
} }
} else {
yield `${prefix}${JSON.stringify(value, undefined, "\t").replaceAll("\n", "\n\t")}`;
} }
} }
})()); yield `${first ? "" : "\n"}]`;
})()), {
[jsonStreamSymbol]: true as const
})
}
export function jsonStreamRecord(iterator: Record<string | number, Stringifiable | Promise<Stringifiable> | JsonStream> | AsyncIterable<[key: string | number, value: Stringifiable | Promise<Stringifiable> | JsonStream]>): JsonStream {
return Object.assign(asyncIteratorToStream((async function*() {
let first = true;
yield "{";
const normalizedIterator = Symbol.asyncIterator in iterator ? iterator : Object.entries(iterator);
for await (const [key, value] of normalizedIterator) {
const prefix = `${first ? "" : ","}\n\t${JSON.stringify(key)}: `;
first = false;
if (isJsonStream(value)) {
yield prefix;
for await (const chunk of streamReplace(value, { "\n": `\n\t` })) {
yield chunk;
}
} else {
yield `${prefix}${JSON.stringify(await value, undefined, "\t").replaceAll("\n", "\n\t")}`;
}
}
yield `${first ? "" : "\n"}}`;
})()), {
[jsonStreamSymbol]: true as const
})
} }
export function stringToStream(string: string): ReadableStream<string> { export function stringToStream(string: string): ReadableStream<string> {
@ -99,6 +142,10 @@ export function stringToStream(string: string): ReadableStream<string> {
})()); })());
} }
export async function streamToString(stream: ReadableStream<string>): Promise<string> {
return (await asyncIteratorToArray(stream)).join("");
}
export function streamReplace(stream: ReadableStream<string> | string, replace: Record<string, ReadableStream<string> | string>): ReadableStream<string> { export function streamReplace(stream: ReadableStream<string> | string, replace: Record<string, ReadableStream<string> | string>): ReadableStream<string> {
const normalizedStream = typeof stream === "string" ? stringToStream(stream) : stream; const normalizedStream = typeof stream === "string" ? stringToStream(stream) : stream;
@ -159,3 +206,47 @@ export function streamReplace(stream: ReadableStream<string> | string, replace:
} }
})()); })());
} }
export type ZipEncodeStreamItem = { filename: string, data: ReadableStream<string> | null };
export function getZipEncodeStream(): TransformStream<ZipEncodeStreamItem, Uint8Array> {
const archive = new Packer();
const writable = new WritableStream<ZipEncodeStreamItem>({
async write(chunk) {
await new Promise<void>((resolve, reject) => {
archive.entry(chunk.data && Readable.fromWeb(chunk.data), { name: chunk.filename }, (err: any) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
},
close() {
archive.finish();
}
});
return {
readable: Readable.toWeb(archive),
writable
};
}
export function indentStream(stream: ReadableStream<string>, { indent, indentFirst, addNewline }: { indent: string, indentFirst: boolean; addNewline: boolean }): ReadableStream<string> {
return asyncIteratorToStream((async function*() {
let first = true;
for await (const chunk of streamReplace(stream, { "\n": `\n${indent}` })) {
if (chunk.length > 0) {
yield `${first && indentFirst ? indent : ""}${chunk}`;
first = false;
}
}
if (addNewline && !first) {
yield "\n";
}
})());
}

Wyświetl plik

@ -38,4 +38,4 @@ export async function fileExists(filename: string): Promise<boolean> {
throw err; throw err;
} }
} }
} }

Wyświetl plik

@ -5,11 +5,11 @@ import { stringifiedIdValidator, type PadId } from "facilmap-types";
import { createSingleTable, createTable } from "./export/table.js"; import { createSingleTable, createTable } from "./export/table.js";
import Database from "./database/database"; import Database from "./database/database";
import { exportGeoJson } from "./export/geojson.js"; import { exportGeoJson } from "./export/geojson.js";
import { exportGpx } from "./export/gpx.js"; import { exportGpx, exportGpxZip } from "./export/gpx.js";
import domainMiddleware from "express-domain-middleware"; import domainMiddleware from "express-domain-middleware";
import { Readable, Writable } from "stream"; import { Readable, Writable } from "stream";
import { getOpensearchXml, getPwaManifest, getStaticFrontendMiddleware, renderMap, type RenderMapParams } from "./frontend"; import { getOpensearchXml, getPwaManifest, getStaticFrontendMiddleware, renderMap, type RenderMapParams } from "./frontend";
import { normalizePadName } from "facilmap-utils"; import { getSafeFilename, normalizePadName } from "facilmap-utils";
import { paths } from "facilmap-frontend/build.js"; import { paths } from "facilmap-frontend/build.js";
import config from "./config"; import config from "./config";
import { exportCsv } from "./export/csv.js"; import { exportCsv } from "./export/csv.js";
@ -104,10 +104,26 @@ export async function initWebserver(database: Database, port: number, host?: str
throw new Error(`Map with ID ${req.params.padId} could not be found.`); throw new Error(`Map with ID ${req.params.padId} could not be found.`);
res.set("Content-type", "application/gpx+xml"); res.set("Content-type", "application/gpx+xml");
res.attachment(padData.name.replace(/[\\/:*?"<>|]+/g, '_') + ".gpx"); res.attachment(`${getSafeFilename(normalizePadName(padData.name))}.gpx`);
exportGpx(database, padData ? padData.id : req.params.padId, query.useTracks == "1", query.filter).pipeTo(Writable.toWeb(res)); exportGpx(database, padData ? padData.id : req.params.padId, query.useTracks == "1", query.filter).pipeTo(Writable.toWeb(res));
}); });
app.get("/:padId/gpx/zip", async (req: Request<{ padId: string }>, res: Response<string>) => {
const query = z.object({
useTracks: z.enum(["0", "1"]).default("0"),
filter: z.string().optional()
}).parse(req.query);
const padData = await database.pads.getPadDataByAnyId(req.params.padId);
if(!padData)
throw new Error(`Map with ID ${req.params.padId} could not be found.`);
res.set("Content-type", "application/zip");
res.attachment(padData.name.replace(/[\\/:*?"<>|]+/g, '_') + ".zip");
exportGpxZip(database, padData ? padData.id : req.params.padId, query.useTracks == "1", query.filter).pipeTo(Writable.toWeb(res));
});
app.get("/:padId/table", async (req: Request<{ padId: string }>, res: Response<string>) => { app.get("/:padId/table", async (req: Request<{ padId: string }>, res: Response<string>) => {
const query = z.object({ const query = z.object({
filter: z.string().optional(), filter: z.string().optional(),

Wyświetl plik

@ -3,12 +3,16 @@ import { cloneDeep, isEqual } from "lodash-es";
const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const LENGTH = 12; const LENGTH = 12;
export function quoteJavaScript(str: any): string { export function quoteHtml(str: string | number): string {
return "'" + `${str}`.replace(/['\\]/g, '\\$1').replace(/\n/g, "\\n") + "'"; return `${str}`
} .replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
export function quoteHtml(str: any): string { .replace(/>/g, "&gt;")
return `${str}`.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;"); .replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/\n/g, "&#10;")
.replace(/\r/g, "&#13;")
.replace(/\t/g, "&#9;");
} }
export function quoteRegExp(str: string): string { export function quoteRegExp(str: string): string {
@ -159,3 +163,7 @@ export function mergeObject<T extends Record<keyof any, any>>(oldObject: T | und
targetObject[i] = cloneDeep(newObject[i]); targetObject[i] = cloneDeep(newObject[i]);
} }
} }
export function getSafeFilename(fname: string): string {
return fname.replace(/[\\/:*?"<>|]+/g, '_');
}

140
yarn.lock
Wyświetl plik

@ -1686,6 +1686,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"abort-controller@npm:^3.0.0":
version: 3.0.0
resolution: "abort-controller@npm:3.0.0"
dependencies:
event-target-shim: ^5.0.0
checksum: 170bdba9b47b7e65906a28c8ce4f38a7a369d78e2271706f020849c1bfe0ee2067d4261df8bbb66eb84f79208fd5b710df759d64191db58cfba7ce8ef9c54b75
languageName: node
linkType: hard
"accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.7": "accepts@npm:~1.3.4, accepts@npm:~1.3.5, accepts@npm:~1.3.7":
version: 1.3.8 version: 1.3.8
resolution: "accepts@npm:1.3.8" resolution: "accepts@npm:1.3.8"
@ -1816,6 +1825,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"archiver-utils@npm:^5.0.0":
version: 5.0.1
resolution: "archiver-utils@npm:5.0.1"
dependencies:
glob: ^10.0.0
graceful-fs: ^4.2.0
lazystream: ^1.0.0
lodash: ^4.17.15
normalize-path: ^3.0.0
readable-stream: ^3.6.0
checksum: a6e907cea41486ff95fdbe1b267890df96d51e2712b6604cb9b0c5f7348aea475091f41e0e6fced3a893490deddfd8d0704f66c8aa470394cff3618bbec6d4cc
languageName: node
linkType: hard
"argparse@npm:^2.0.1": "argparse@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "argparse@npm:2.0.1" resolution: "argparse@npm:2.0.1"
@ -1979,6 +2002,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
languageName: node
linkType: hard
"base64id@npm:2.0.0, base64id@npm:~2.0.0": "base64id@npm:2.0.0, base64id@npm:~2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "base64id@npm:2.0.0" resolution: "base64id@npm:2.0.0"
@ -2109,6 +2139,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"buffer@npm:^6.0.3":
version: 6.0.3
resolution: "buffer@npm:6.0.3"
dependencies:
base64-js: ^1.3.1
ieee754: ^1.2.1
checksum: 5ad23293d9a731e4318e420025800b42bf0d264004c0286c8cc010af7a270c7a0f6522e84f54b9ad65cbd6db20b8badbfd8d2ebf4f80fa03dab093b89e68c3f9
languageName: node
linkType: hard
"buffers@npm:~0.1.1": "buffers@npm:~0.1.1":
version: 0.1.1 version: 0.1.1
resolution: "buffers@npm:0.1.1" resolution: "buffers@npm:0.1.1"
@ -2353,6 +2393,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"compress-commons@npm:^6.0.0":
version: 6.0.1
resolution: "compress-commons@npm:6.0.1"
dependencies:
crc-32: ^1.2.0
crc32-stream: ^6.0.0
normalize-path: ^3.0.0
readable-stream: ^4.0.0
checksum: a2ecd7c536cb4e1f029863464202e584ef4ea1b1f6a22da526efaf744ba6b59d29e860a637c046118cc38559f0b202f1d4dfa5c445a8c0f4bbf54eabcbd74bbf
languageName: node
linkType: hard
"compressible@npm:~2.0.16": "compressible@npm:~2.0.16":
version: 2.0.18 version: 2.0.18
resolution: "compressible@npm:2.0.18" resolution: "compressible@npm:2.0.18"
@ -2517,6 +2569,25 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"crc-32@npm:^1.2.0":
version: 1.2.2
resolution: "crc-32@npm:1.2.2"
bin:
crc32: bin/crc32.njs
checksum: ad2d0ad0cbd465b75dcaeeff0600f8195b686816ab5f3ba4c6e052a07f728c3e70df2e3ca9fd3d4484dc4ba70586e161ca5a2334ec8bf5a41bf022a6103ff243
languageName: node
linkType: hard
"crc32-stream@npm:^6.0.0":
version: 6.0.0
resolution: "crc32-stream@npm:6.0.0"
dependencies:
crc-32: ^1.2.0
readable-stream: ^4.0.0
checksum: e6edc2f81bc387daef6d18b2ac18c2ffcb01b554d3b5c7d8d29b177505aafffba574658fdd23922767e8dab1183d1962026c98c17e17fb272794c33293ef607c
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": "cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3":
version: 7.0.3 version: 7.0.3
resolution: "cross-spawn@npm:7.0.3" resolution: "cross-spawn@npm:7.0.3"
@ -3467,6 +3538,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"event-target-shim@npm:^5.0.0":
version: 5.0.1
resolution: "event-target-shim@npm:5.0.1"
checksum: 1ffe3bb22a6d51bdeb6bf6f7cf97d2ff4a74b017ad12284cc9e6a279e727dc30a5de6bb613e5596ff4dc3e517841339ad09a7eec44266eccb1aa201a30448166
languageName: node
linkType: hard
"events@npm:^3.3.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
checksum: f6f487ad2198aa41d878fa31452f1a3c00958f46e9019286ff4787c84aac329332ab45c9cdc8c445928fc6d7ded294b9e005a7fce9426488518017831b272780
languageName: node
linkType: hard
"execa@npm:^8.0.1": "execa@npm:^8.0.1":
version: 8.0.1 version: 8.0.1
resolution: "execa@npm:8.0.1" resolution: "execa@npm:8.0.1"
@ -3734,6 +3819,7 @@ __metadata:
vite-plugin-dts: ^3.7.3 vite-plugin-dts: ^3.7.3
vite-tsconfig-paths: ^4.3.1 vite-tsconfig-paths: ^4.3.1
vitest: ^1.3.1 vitest: ^1.3.1
zip-stream: ^6.0.0
zod: ^3.22.4 zod: ^3.22.4
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -4174,7 +4260,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": "glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
version: 10.3.10 version: 10.3.10
resolution: "glob@npm:10.3.10" resolution: "glob@npm:10.3.10"
dependencies: dependencies:
@ -4264,7 +4350,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": "graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
version: 4.2.11 version: 4.2.11
resolution: "graceful-fs@npm:4.2.11" resolution: "graceful-fs@npm:4.2.11"
checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7 checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7
@ -4465,7 +4551,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ieee754@npm:^1.1.12": "ieee754@npm:^1.1.12, ieee754@npm:^1.2.1":
version: 1.2.1 version: 1.2.1
resolution: "ieee754@npm:1.2.1" resolution: "ieee754@npm:1.2.1"
checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e checksum: 5144c0c9815e54ada181d80a0b810221a253562422e7c6c3a60b1901154184f49326ec239d618c416c1c5945a2e197107aee8d986a3dd836b53dffefd99b5e7e
@ -5017,6 +5103,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lazystream@npm:^1.0.0":
version: 1.0.1
resolution: "lazystream@npm:1.0.1"
dependencies:
readable-stream: ^2.0.5
checksum: 822c54c6b87701a6491c70d4fabc4cafcf0f87d6b656af168ee7bb3c45de9128a801cb612e6eeeefc64d298a7524a698dd49b13b0121ae50c2ae305f0dcc5310
languageName: node
linkType: hard
"leaflet-auto-graticule@npm:^2.0.0": "leaflet-auto-graticule@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "leaflet-auto-graticule@npm:2.0.0" resolution: "leaflet-auto-graticule@npm:2.0.0"
@ -5199,7 +5294,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lodash@npm:^4.17.21, lodash@npm:~4.17.15": "lodash@npm:^4.17.15, lodash@npm:^4.17.21, lodash@npm:~4.17.15":
version: 4.17.21 version: 4.17.21
resolution: "lodash@npm:4.17.21" resolution: "lodash@npm:4.17.21"
checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7
@ -6414,6 +6509,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"process@npm:^0.11.10":
version: 0.11.10
resolution: "process@npm:0.11.10"
checksum: bfcce49814f7d172a6e6a14d5fa3ac92cc3d0c3b9feb1279774708a719e19acd673995226351a082a9ae99978254e320ccda4240ddc474ba31a76c79491ca7c3
languageName: node
linkType: hard
"promise-retry@npm:^2.0.1": "promise-retry@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "promise-retry@npm:2.0.1" resolution: "promise-retry@npm:2.0.1"
@ -6523,7 +6625,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"readable-stream@npm:^2.0.2, readable-stream@npm:~2.3.6": "readable-stream@npm:^2.0.2, readable-stream@npm:^2.0.5, readable-stream@npm:~2.3.6":
version: 2.3.8 version: 2.3.8
resolution: "readable-stream@npm:2.3.8" resolution: "readable-stream@npm:2.3.8"
dependencies: dependencies:
@ -6538,7 +6640,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"readable-stream@npm:^3.0.2": "readable-stream@npm:^3.0.2, readable-stream@npm:^3.6.0":
version: 3.6.2 version: 3.6.2
resolution: "readable-stream@npm:3.6.2" resolution: "readable-stream@npm:3.6.2"
dependencies: dependencies:
@ -6549,6 +6651,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"readable-stream@npm:^4.0.0":
version: 4.5.2
resolution: "readable-stream@npm:4.5.2"
dependencies:
abort-controller: ^3.0.0
buffer: ^6.0.3
events: ^3.3.0
process: ^0.11.10
string_decoder: ^1.3.0
checksum: c4030ccff010b83e4f33289c535f7830190773e274b3fcb6e2541475070bdfd69c98001c3b0cb78763fc00c8b62f514d96c2b10a8bd35d5ce45203a25fa1d33a
languageName: node
linkType: hard
"readdirp@npm:~3.6.0": "readdirp@npm:~3.6.0":
version: 3.6.0 version: 3.6.0
resolution: "readdirp@npm:3.6.0" resolution: "readdirp@npm:3.6.0"
@ -7303,7 +7418,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"string_decoder@npm:^1.1.1": "string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0":
version: 1.3.0 version: 1.3.0
resolution: "string_decoder@npm:1.3.0" resolution: "string_decoder@npm:1.3.0"
dependencies: dependencies:
@ -8427,6 +8542,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zip-stream@npm:^6.0.0":
version: 6.0.0
resolution: "zip-stream@npm:6.0.0"
dependencies:
archiver-utils: ^5.0.0
compress-commons: ^6.0.0
readable-stream: ^4.0.0
checksum: 114565901579dc023b41b2d3d3ce2721ad734032295d50977dd58899cb6d1922abb6336ee388f6f90b74e0a384cb9c590f80cf9f1ac9ede865bd212ba96f6070
languageName: node
linkType: hard
"zod@npm:^3.22.4": "zod@npm:^3.22.4":
version: 3.22.4 version: 3.22.4
resolution: "zod@npm:3.22.4" resolution: "zod@npm:3.22.4"