This commit is contained in:
vcoppe committed 2026-10-08 09:24:15 +02:00
1 parent e8c4eb92f9
commit 606a0e974a
8 files changed
+1002 -304

No files matched your search

+47 -45
View File
@@ -5,8 +5,10 @@
import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui';
import {
allFileIds,
exportAllFiles,
exportSelectedFiles,
selectedFileIds,
ExportState,
exportState,
} from '$lib/components/export/utils.svelte';
@@ -21,55 +23,46 @@
SquareActivity,
} from '@lucide/svelte';
import { i18n } from '$lib/i18n.svelte';
import { GPXGlobalStatistics } from 'gpx';
import { ListRootItem } from '$lib/components/file-list/file-list';
import { fileStateCollection } from '$lib/logic/file-state';
import { selection } from '$lib/logic/selection';
import { gpxStatistics } from '$lib/logic/statistics';
import { get } from 'svelte/store';
import { engine, type ExportOptions } from '$lib/engine';
const selection = engine.selection;
const order = engine.order;
const files = engine.files;
let open = $derived(exportState.current !== ExportState.NONE);
let exportOptions: Record<string, boolean> = $state({
let exportOptions: ExportOptions = $state({
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
extensions: false,
osm: false,
});
let hide: Record<string, boolean> = $derived.by(() => {
if (exportState.current === ExportState.NONE) {
return {
time: false,
hr: false,
cad: false,
atemp: false,
power: false,
extensions: false,
};
} else {
let statistics = $gpxStatistics.global;
if (exportState.current === ExportState.ALL) {
statistics = Array.from(get(fileStateCollection).values())
.map((file) => file.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global);
}
return acc;
}, new GPXGlobalStatistics());
}
return {
time: statistics.time.total === 0,
hr: statistics.hr.count === 0,
cad: statistics.cad.count === 0,
atemp: statistics.atemp.count === 0,
power: statistics.power.count === 0,
extensions: Object.keys(statistics.extensions).length === 0,
};
// the files that are exported
let fileIds: string[] = $derived.by(() => {
// the selection and the order change with the files
void $selection;
void $order;
void $files;
if (exportState.current === ExportState.SELECTION) {
return selectedFileIds();
} else if (exportState.current === ExportState.ALL) {
return allFileIds();
}
return [];
});
// the data that none of them has is not offered
let hide: Record<keyof ExportOptions, boolean> = $derived.by(() => {
const available = engine.exportableData(fileIds);
return {
time: !available.time,
hr: !available.hr,
cad: !available.cad,
atemp: !available.atemp,
power: !available.power,
osm: !available.osm,
};
});
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
$effect(() => {
if (open) {
@@ -112,17 +105,26 @@
variant="outline"
class="grow"
onclick={() => {
// what is not offered is left out
const options = {
time: exportOptions.time && !hide.time,
hr: exportOptions.hr && !hide.hr,
cad: exportOptions.cad && !hide.cad,
atemp: exportOptions.atemp && !hide.atemp,
power: exportOptions.power && !hide.power,
osm: exportOptions.osm && !hide.osm,
};
if (exportState.current === ExportState.SELECTION) {
exportSelectedFiles(exclude);
exportSelectedFiles(options);
} else if (exportState.current === ExportState.ALL) {
exportAllFiles(exclude);
exportAllFiles(options);
}
open = false;
exportState.current = ExportState.NONE;
}}
>
<Download size="16" />
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
{#if fileIds.length === 1}
{i18n._('menu.download_file')}
{:else}
{i18n._('menu.download_files')}
@@ -184,10 +186,10 @@
</Label>
</div>
<div
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}"
>
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
<Label for="export-extensions" class="flex flex-row items-center gap-1">
<Checkbox id="export-osm" bind:checked={exportOptions.osm} />
<Label for="export-osm" class="flex flex-row items-center gap-1">
<Earth size="16" />
{i18n._('quantities.osm_extensions')}
</Label>
@@ -1,7 +1,4 @@
import { selection } from '$lib/logic/selection';
import { fileStateCollection } from '$lib/logic/file-state';
import { settings } from '$lib/logic/settings';
import { buildGPX, type GPXFile } from 'gpx';
import { engine, type ExportOptions } from '$lib/engine';
import FileSaver from 'file-saver';
import JSZip from 'jszip';
import { get } from 'svelte/store';
@@ -15,52 +12,51 @@ export const exportState = $state({
current: ExportState.NONE,
});
async function exportFiles(fileIds: string[], exclude: string[]) {
if (fileIds.length > 1) {
await exportFilesAsZip(fileIds, exclude);
} else {
const firstFileId = fileIds.at(0);
if (firstFileId != null) {
const file = fileStateCollection.getFile(firstFileId);
if (file) {
exportFile(file, exclude);
}
}
/** The files that contain the selection, in the order of the selection. */
export function selectedFileIds(): string[] {
const selection = get(engine.selection);
switch (selection.type) {
case 'file':
return selection.fileIds;
case 'empty':
return [];
default:
return [selection.fileId];
}
}
export async function exportSelectedFiles(exclude: string[]) {
const fileIds: string[] = [];
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
fileIds.push(fileId);
export function allFileIds(): string[] {
return get(engine.order);
}
async function exportFiles(fileIds: string[], options: ExportOptions) {
const files = fileIds.flatMap((fileId) => {
const gpx = engine.exportFile(fileId, options);
const name = get(engine.files).get(fileId);
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
});
await exportFiles(fileIds, exclude);
}
export async function exportAllFiles(exclude: string[]) {
await exportFiles(get(settings.fileOrder), exclude);
}
function exportFile(file: GPXFile, exclude: string[]) {
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
}
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
const zip = new JSZip();
for (const fileId of fileIds) {
const file = fileStateCollection.getFile(fileId);
if (file) {
const gpx = buildGPX(file, exclude);
let filename = file.metadata.name;
if (files.length === 1) {
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${files[0].name}.gpx`);
} else if (files.length > 1) {
const zip = new JSZip();
for (const { name, gpx } of files) {
let filename = name;
for (let i = 1; zip.files[filename + '.gpx']; i++) {
filename = file.metadata.name + `-${i}`;
filename = name + `-${i}`;
}
zip.file(filename + '.gpx', gpx);
}
}
if (Object.keys(zip.files).length > 0) {
const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip');
}
}
export async function exportSelectedFiles(options: ExportOptions) {
await exportFiles(selectedFileIds(), options);
}
export async function exportAllFiles(options: ExportOptions) {
await exportFiles(allFileIds(), options);
}
+53
View File
@@ -125,6 +125,26 @@ export type RoutePoints = {
mtbScale?: readonly (string | undefined)[];
};
/** Which data of the trackpoints a file is exported with (see `Engine.exportFile`). */
export type ExportOptions = {
time: boolean;
hr: boolean;
cad: boolean;
atemp: boolean;
power: boolean;
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
osm: boolean;
};
export const ALL_EXPORT_OPTIONS: ExportOptions = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
osm: true,
};
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
@@ -690,6 +710,39 @@ class Engine {
return this.run((w) => w.createArtificialTimestamps(startTime.getTime(), totalTime));
}
/**
* A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the
* trackpoints to keep.
*/
exportFile(
fileId: string,
options: ExportOptions = ALL_EXPORT_OPTIONS
): Uint8Array<ArrayBuffer> | undefined {
// a copy out of the memory of the engine, so a plain ArrayBuffer
return this.wasm?.export_file(
fileId,
options.time,
options.hr,
options.cad,
options.atemp,
options.power,
options.osm
) as Uint8Array<ArrayBuffer> | undefined;
}
/** The data that some files have, which the options of `exportFile` can leave out. */
exportableData(fileIds: string[]): ExportOptions {
const bits = this.wasm?.exportable_data(idsToBytes(fileIds)) ?? 0;
return {
time: (bits & 1) !== 0,
hr: (bits & 2) !== 0,
cad: (bits & 4) !== 0,
atemp: (bits & 8) !== 0,
power: (bits & 16) !== 0,
osm: (bits & 32) !== 0,
};
}
/**
* Merges the selected files, tracks or segments into the first of them (see `MergeType`). With
* `removeGaps`, the parts that are connected get closer in time if they have timestamps.