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:
vcoppeandvcoppe authored and GitHub committed 2026-10-31 18:08:22 +01:00
1 parent 6a0d434371
commit 8fe7d8d64d
484 files changed
+53949 -41152

No files matched your search

+61 -48
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';
@@ -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);
}