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

@@ -0,0 +1,43 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defaultFileName } from '$lib/actions/default-file-name';
describe('default file name', () => {
it('is the default name when there is no other file with it', () => {
assert.equal(defaultFileName('New file', []), 'New file');
assert.equal(defaultFileName('New file', ['Morning ride', 'track']), 'New file');
});
it('is numbered when other files have the default name', () => {
assert.equal(defaultFileName('New file', ['New file']), 'New file 2');
assert.equal(defaultFileName('New file', ['New file', 'New file 2']), 'New file 3');
});
it('goes after the highest number', () => {
assert.equal(defaultFileName('New file', ['New file 4', 'New file']), 'New file 5');
// a number that was freed is not used again
assert.equal(defaultFileName('New file', ['New file', 'New file 3']), 'New file 4');
// the numbered ones count even if the first one was renamed
assert.equal(defaultFileName('New file', ['New file 2']), 'New file 3');
});
it('ignores the names that only look like it', () => {
assert.equal(defaultFileName('New file', ['New file copy', 'New files 3']), 'New file');
assert.equal(
defaultFileName('New file', ['New file 2b', 'New file 0', 'New file -3']),
'New file'
);
assert.equal(defaultFileName('New file', ['New file ', 'New file 2']), 'New file');
assert.equal(defaultFileName('New file', ['my New file']), 'New file');
});
it('works with any language', () => {
assert.equal(defaultFileName('Nouveau fichier', ['Nouveau fichier']), 'Nouveau fichier 2');
assert.equal(defaultFileName('新文件', ['新文件', '新文件 2']), '新文件 3');
// the default name of another language is just a name
assert.equal(
defaultFileName('Nouveau fichier', ['New file', 'New file 2']),
'Nouveau fichier'
);
});
});
@@ -0,0 +1,19 @@
/**
* The name of a new file: the default name (translated), followed by a number when other files
* already have a default name (the name alone counts as number 1): "New file", "New file 2",
* "New file 3"...
*/
export function defaultFileName(defaultName: string, existingNames: string[]): string {
let highest = 0;
for (const name of existingNames) {
if (name === defaultName) {
highest = Math.max(highest, 1);
} else if (name.startsWith(`${defaultName} `)) {
const suffix = name.slice(defaultName.length + 1);
if (/^[1-9][0-9]*$/.test(suffix)) {
highest = Math.max(highest, parseInt(suffix));
}
}
}
return highest === 0 ? defaultName : `${defaultName} ${highest + 1}`;
}
+204
View File
@@ -0,0 +1,204 @@
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { i18n } from '$lib/i18n/i18n.svelte';
import { get } from 'svelte/store';
import { boundsManager } from '$lib/state/bounds';
import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/actions/default-file-name';
import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
import { toast } from 'svelte-sonner';
import type { UnsavedChoice } from '$lib/engine';
/** The name of a new file: the translated default name, numbered if other files have it. */
export function newFileName() {
const names = [...get(engine.files).values()].map((file) => get(file).structure.name);
return defaultFileName(i18n._('menu.new_file'), names);
}
export function createFile() {
// the engine selects the new file
engine.newFile(newFileName());
currentTool.set(Tool.ROUTING);
}
/** Creates a file that starts with an empty route, which the routing tool then draws. */
export function createRouteFile() {
engine.newFile(newFileName(), undefined, true);
currentTool.set(Tool.ROUTING);
}
/** Asks for the files to open: with the file picker of the system in the desktop app. */
export async function openFiles() {
if (get(engine.workspace)) {
const ids = await engine.openFiles();
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
} else {
triggerFileInput();
}
}
/** The names of files, to show them to the user. */
function namesOf(ids: string[]): string[] {
const files = get(engine.files);
return ids.map((id) => {
const file = files.get(id);
return file ? get(file).structure.name : id;
});
}
/** The ids among `ids` of the files that have changes that are not saved. */
function unsavedAmong(ids: string[]): string[] {
const workspace = get(engine.workspace);
return ids.filter((id) => workspace?.files[id]?.dirty);
}
/**
* What to do with the files among `ids` that have unsaved changes, which the user is asked when
* there are some: `undefined` when there are none, `'cancel'` when they do not want to go on.
*/
async function askAboutUnsavedFiles(ids: string[]): Promise<UnsavedChoice | undefined | 'cancel'> {
const unsaved = unsavedAmong(ids);
return unsaved.length > 0 ? askUnsaved(namesOf(unsaved)) : undefined;
}
/** All the open files. */
function allFileIds(): string[] {
return get(engine.order);
}
/**
* Opens a folder, which is watched (the desktop app). Its files are not opened, they are in its
* tree. The folder that was open is closed with its files: the user is asked what to do with the
* unsaved ones.
*/
export async function openFolder() {
const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved !== 'cancel') {
await engine.openFolder(unsaved);
}
}
/** Closes the open folder and all the files: the user is asked what to do with the unsaved ones. */
export async function closeFolder() {
const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved !== 'cancel') {
await engine.closeFolder(unsaved);
}
}
/** Closes files: the user is asked what to do with the ones that have unsaved changes. */
export async function closeFiles(ids: string[]) {
if (ids.length === 0) {
return;
}
const unsaved = await askAboutUnsavedFiles(ids);
if (unsaved !== 'cancel') {
await engine.closeFiles(ids, unsaved);
}
}
/** Closes all the files. */
export function closeAll() {
return closeFiles(allFileIds());
}
/**
* Writes files to the file system. The user is asked first about the ones whose file was also
* changed on the disk, which saving overwrites: they can take the disk instead of their changes.
*/
async function saveFiles(ids: string[]) {
if (ids.length === 0) {
return;
}
const workspace = get(engine.workspace);
const replaced = ids.filter((id) => workspace?.files[id]?.replaced);
if (replaced.length > 0) {
const choice = await askOverwrite(namesOf(replaced));
if (choice === 'cancel') {
return;
}
if (choice === 'reload') {
// they are the files on the disk now, there is nothing to save
await engine.reload(replaced);
ids = ids.filter((id) => !replaced.includes(id));
}
}
if (ids.length > 0) {
await engine.save(ids);
}
}
/** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */
export function saveSelected(selectedFileIds: string[]) {
return saveFiles(selectedFileIds.length > 0 ? selectedFileIds : unsavedAmong(allFileIds()));
}
/** Saves all the files that have unsaved edits to the file system. */
export function saveAll() {
return saveFiles(unsavedAmong(allFileIds()));
}
/** Saves a file where the user says, which is where it is from then on (not for several files). */
export async function saveAs(selectedFileIds: string[]) {
if (selectedFileIds.length === 1) {
await engine.saveAs(selectedFileIds[0]);
}
}
/**
* Opens files of the file system, like the ones of the tree of the open folder: as a preview
* (`preview`, a single click), which the next preview replaces, or for good. The map fits the files
* that were opened.
*/
export async function openPaths(paths: string[], preview = false) {
const ids = await engine.openPaths(paths, preview);
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
}
/** Tells the user why the engine did not do what was asked (a key of the dictionaries, or text). */
export function showEngineError() {
const error = get(engine.lastError);
if (error !== undefined && error !== 'nothing to do') {
toast.error(i18n._(error, error));
}
}
export function triggerFileInput() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.gpx';
input.multiple = true;
input.className = 'hidden';
input.onchange = () => {
if (input.files) {
loadFiles(input.files);
}
};
input.click();
}
/**
* Loads GPX files in the engine, as a single command: one undo step, the first file that could be
* read is selected, and the map fits the files. A file without name is named like the file on
* disk. Returns the ids of the files that were added.
*/
export async function loadFiles(list: FileList | File[]): Promise<string[]> {
const files = await Promise.all(
Array.from(list).map(async (file) => ({
data: new Uint8Array(await file.arrayBuffer()),
name: file.name.split('.').slice(0, -1).join('.'),
}))
);
const before = new Set(get(engine.order));
await engine.loadFiles(files);
const ids = get(engine.order).filter((id) => !before.has(id));
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
}
return ids;
}