mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
export
This commit is contained in:
1 parent
e8c4eb92f9
commit
606a0e974a
8 files changed
+1002
-304
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user