mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 20:19:39 +00:00
Migration to new Rust-based engine (#401)
* wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe <vcoppe@hexaly.com>
This commit is contained in:
1 parent
6a0d434371
commit
8fe7d8d64d
484 files changed
+53949
-41152
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';
|
||||
@@ -20,56 +22,60 @@
|
||||
Thermometer,
|
||||
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 { i18n } from '$lib/i18n/i18n.svelte';
|
||||
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> = $state({
|
||||
time: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
atemp: false,
|
||||
power: false,
|
||||
osm: false,
|
||||
});
|
||||
$effect(() => {
|
||||
const ids = fileIds;
|
||||
engine.exportableData(ids).then((available) => {
|
||||
// the files may have changed since
|
||||
if (ids === fileIds) {
|
||||
hide = {
|
||||
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 +118,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')}
|
||||
@@ -183,11 +198,9 @@
|
||||
{i18n._('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
|
||||
>
|
||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}">
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { sanitizeFileName, uniqueFileNames } from './file-names';
|
||||
|
||||
describe('sanitizeFileName', () => {
|
||||
it('keeps the names that are fine', () => {
|
||||
for (const name of [
|
||||
'Morning ride',
|
||||
'route-2024_05',
|
||||
'Café du coin',
|
||||
'日本の道',
|
||||
'a.b.c',
|
||||
'🚴 tour',
|
||||
]) {
|
||||
assert.equal(sanitizeFileName(name), name);
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces what is not allowed in a name', () => {
|
||||
assert.equal(sanitizeFileName('a/b'), 'a_b');
|
||||
assert.equal(sanitizeFileName('a\\b'), 'a_b');
|
||||
assert.equal(sanitizeFileName('../../etc/passwd'), '_.._etc_passwd');
|
||||
assert.equal(
|
||||
sanitizeFileName('what? <this> *is* "it": a|b'),
|
||||
'what_ _this_ _is_ _it__ a_b'
|
||||
);
|
||||
assert.equal(sanitizeFileName('tab\there\nnew\u0000line\u007f'), 'tab_here_new_line_');
|
||||
});
|
||||
|
||||
it('has no dots or spaces around, which systems drop or hide', () => {
|
||||
assert.equal(sanitizeFileName(' name '), 'name');
|
||||
assert.equal(sanitizeFileName('.hidden'), 'hidden');
|
||||
assert.equal(sanitizeFileName('name...'), 'name');
|
||||
assert.equal(sanitizeFileName('. . name . .'), 'name');
|
||||
});
|
||||
|
||||
it('is never empty', () => {
|
||||
assert.equal(sanitizeFileName(''), 'file');
|
||||
assert.equal(sanitizeFileName(' '), 'file');
|
||||
assert.equal(sanitizeFileName('...'), 'file');
|
||||
});
|
||||
|
||||
it('does not use the names Windows keeps for devices', () => {
|
||||
assert.equal(sanitizeFileName('con'), '_con');
|
||||
assert.equal(sanitizeFileName('NUL'), '_NUL');
|
||||
assert.equal(sanitizeFileName('com1'), '_com1');
|
||||
assert.equal(sanitizeFileName('LPT9'), '_LPT9');
|
||||
assert.equal(sanitizeFileName('aux.backup'), '_aux.backup');
|
||||
assert.equal(sanitizeFileName('console'), 'console');
|
||||
assert.equal(sanitizeFileName('com10'), 'com10');
|
||||
});
|
||||
|
||||
it('is not too long, whatever the characters', () => {
|
||||
assert.equal(sanitizeFileName('a'.repeat(500)).length, 100);
|
||||
const emoji = sanitizeFileName('🚴'.repeat(500));
|
||||
assert.equal([...emoji].length, 100);
|
||||
// and does not end with what was left of a cut
|
||||
assert.equal(sanitizeFileName('a'.repeat(99) + '. b'), 'a'.repeat(99));
|
||||
});
|
||||
|
||||
it('is the same when sanitized again', () => {
|
||||
for (const name of ['a/b', ' .x ', 'con', '', '🚴'.repeat(200), 'what?']) {
|
||||
const once = sanitizeFileName(name);
|
||||
assert.equal(sanitizeFileName(once), once);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueFileNames', () => {
|
||||
it('keeps different names as they are', () => {
|
||||
assert.deepEqual(uniqueFileNames(['a', 'b', 'c']), ['a', 'b', 'c']);
|
||||
assert.deepEqual(uniqueFileNames([]), []);
|
||||
});
|
||||
|
||||
it('numbers the names that are taken', () => {
|
||||
assert.deepEqual(uniqueFileNames(['ride', 'ride', 'ride']), ['ride', 'ride-1', 'ride-2']);
|
||||
// a name that looks like a number is taken too
|
||||
assert.deepEqual(uniqueFileNames(['ride-1', 'ride', 'ride']), ['ride-1', 'ride', 'ride-2']);
|
||||
});
|
||||
|
||||
it('does not tell upper and lower case apart', () => {
|
||||
assert.deepEqual(uniqueFileNames(['Ride', 'ride', 'RIDE']), ['Ride', 'ride-1', 'RIDE-2']);
|
||||
});
|
||||
|
||||
it('compares the names once they are clean', () => {
|
||||
assert.deepEqual(uniqueFileNames(['a/b', 'a_b', 'a?b']), ['a_b', 'a_b-1', 'a_b-2']);
|
||||
assert.deepEqual(uniqueFileNames(['', '', '...']), ['file', 'file-1', 'file-2']);
|
||||
});
|
||||
|
||||
it('keeps the numbered names short enough', () => {
|
||||
const long = 'x'.repeat(300);
|
||||
const names = uniqueFileNames([long, long, long]);
|
||||
assert.equal(new Set(names).size, 3);
|
||||
assert.ok(names.every((name) => name.length <= 100));
|
||||
assert.ok(names[1].endsWith('-1') && names[2].endsWith('-2'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// The names of the files that are exported come from the files of the user, which can have any
|
||||
// text as a name: they have to be made names that every system accepts.
|
||||
|
||||
/** Characters that no system accepts in a file name, or that mean something in a path. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
|
||||
|
||||
/** Names that Windows keeps for devices, with or without an extension. */
|
||||
const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
|
||||
|
||||
/** Longest name, without the extension, in characters. */
|
||||
const MAX_LENGTH = 100;
|
||||
|
||||
/** A name for a file without its extension, that is allowed on every system. */
|
||||
export function sanitizeFileName(name: string): string {
|
||||
let clean = name
|
||||
.replace(FORBIDDEN, '_')
|
||||
.normalize('NFC')
|
||||
// no dots or spaces around the name: Windows drops them, a leading dot hides the file
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '');
|
||||
clean = [...clean]
|
||||
.slice(0, MAX_LENGTH)
|
||||
.join('')
|
||||
.replace(/[\s.]+$/, '');
|
||||
if (clean === '') {
|
||||
return 'file';
|
||||
}
|
||||
return RESERVED.test(clean.split('.')[0]) ? `_${clean}` : clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of files that go in the same folder (the same zip): clean, and different, even where
|
||||
* the file system does not tell upper and lower case apart. A name that is taken gets `-1`, `-2`...
|
||||
*/
|
||||
export function uniqueFileNames(names: string[]): string[] {
|
||||
const taken = new Set<string>();
|
||||
return names.map((name) => {
|
||||
const clean = sanitizeFileName(name);
|
||||
let unique = clean;
|
||||
for (let i = 1; taken.has(unique.toLowerCase()); i++) {
|
||||
// the suffix is part of the length
|
||||
unique = `${[...clean].slice(0, MAX_LENGTH - `-${i}`.length).join('')}-${i}`;
|
||||
}
|
||||
taken.add(unique.toLowerCase());
|
||||
return unique;
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
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';
|
||||
import { sanitizeFileName, uniqueFileNames } from './file-names';
|
||||
|
||||
export enum ExportState {
|
||||
NONE,
|
||||
@@ -15,52 +13,55 @@ 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);
|
||||
});
|
||||
await exportFiles(fileIds, exclude);
|
||||
export function allFileIds(): string[] {
|
||||
return get(engine.order);
|
||||
}
|
||||
|
||||
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;
|
||||
for (let i = 1; zip.files[filename + '.gpx']; i++) {
|
||||
filename = file.metadata.name + `-${i}`;
|
||||
}
|
||||
zip.file(filename + '.gpx', gpx);
|
||||
}
|
||||
async function exportFiles(fileIds: string[], options: ExportOptions) {
|
||||
if (get(engine.workspace)) {
|
||||
// the desktop app: the user chooses where the files go
|
||||
await engine.exportFiles(fileIds, options);
|
||||
return;
|
||||
}
|
||||
if (Object.keys(zip.files).length > 0) {
|
||||
const files = (
|
||||
await Promise.all(
|
||||
fileIds.map(async (fileId) => {
|
||||
const gpx = await engine.exportFile(fileId, options);
|
||||
const name = get(engine.files).get(fileId);
|
||||
return gpx === undefined || !name ? [] : [{ name: get(name).structure.name, gpx }];
|
||||
})
|
||||
)
|
||||
).flat();
|
||||
|
||||
if (files.length === 1) {
|
||||
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
|
||||
FileSaver.saveAs(blob, `${sanitizeFileName(files[0].name)}.gpx`);
|
||||
} else if (files.length > 1) {
|
||||
const zip = new JSZip();
|
||||
const names = uniqueFileNames(files.map((file) => file.name));
|
||||
files.forEach(({ gpx }, i) => zip.file(`${names[i]}.gpx`, gpx));
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user