mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-06 22:04:06 +00:00
progress
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
use crate::{Apply, CommandError, State};
|
use crate::{Apply, CommandError, State};
|
||||||
use crate::{
|
use crate::{
|
||||||
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, Move,
|
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, LoadFiles, Merge,
|
||||||
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, SpliceTrackpoints,
|
Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse,
|
||||||
Split, Style, Time,
|
SpliceTrackpoints, Split, Style, Time,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
||||||
@@ -14,6 +14,7 @@ use crate::{
|
|||||||
pub enum Command<'a> {
|
pub enum Command<'a> {
|
||||||
New(New<'a>),
|
New(New<'a>),
|
||||||
Load(Load<'a>),
|
Load(Load<'a>),
|
||||||
|
LoadFiles(LoadFiles<'a>),
|
||||||
Duplicate(Duplicate),
|
Duplicate(Duplicate),
|
||||||
Delete(Delete),
|
Delete(Delete),
|
||||||
Paste(Paste),
|
Paste(Paste),
|
||||||
@@ -41,6 +42,7 @@ impl Apply for Command<'_> {
|
|||||||
match self {
|
match self {
|
||||||
Command::New(c) => c.apply(state),
|
Command::New(c) => c.apply(state),
|
||||||
Command::Load(c) => c.apply(state),
|
Command::Load(c) => c.apply(state),
|
||||||
|
Command::LoadFiles(c) => c.apply(state),
|
||||||
Command::Duplicate(c) => c.apply(state),
|
Command::Duplicate(c) => c.apply(state),
|
||||||
Command::Delete(c) => c.apply(state),
|
Command::Delete(c) => c.apply(state),
|
||||||
Command::Paste(c) => c.apply(state),
|
Command::Paste(c) => c.apply(state),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{Apply, CommandError, State, parse, produce};
|
use crate::{Apply, CommandError, File, State, parse, produce};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Load<'a> {
|
pub struct Load<'a> {
|
||||||
@@ -8,14 +8,49 @@ pub struct Load<'a> {
|
|||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Apply for Load<'_> {
|
impl Load<'_> {
|
||||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
fn parse(self) -> Result<File, CommandError> {
|
||||||
let mut file =
|
let mut file =
|
||||||
parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?;
|
parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?;
|
||||||
if file.info.name.trim().is_empty() {
|
if file.info.name.trim().is_empty() {
|
||||||
file.info.name = self.name.to_owned();
|
file.info.name = self.name.to_owned();
|
||||||
}
|
}
|
||||||
produce(state, |_| vec![file]);
|
Ok(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for Load<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
LoadFiles { files: vec![self] }.apply(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads several files at once, as a single command (one undo step): the files are added at the
|
||||||
|
/// end of the list, in the given order, and the first one is selected.
|
||||||
|
///
|
||||||
|
/// The files that cannot be read are skipped, unless none of them can: then nothing is loaded
|
||||||
|
/// and the error is the one of the first file.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LoadFiles<'a> {
|
||||||
|
pub files: Vec<Load<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Apply for LoadFiles<'_> {
|
||||||
|
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||||
|
let mut files = Vec::with_capacity(self.files.len());
|
||||||
|
let mut first_error = None;
|
||||||
|
for load in self.files {
|
||||||
|
match load.parse() {
|
||||||
|
Ok(file) => files.push(file),
|
||||||
|
Err(error) => {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files.is_empty() {
|
||||||
|
return Err(first_error.unwrap_or(CommandError::NothingToDo));
|
||||||
|
}
|
||||||
|
produce(state, |_| files);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,4 +120,101 @@ mod tests {
|
|||||||
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
|
let empty = br#"<gpx version="1.1"><metadata><name> </name></metadata></gpx>"#;
|
||||||
assert_eq!(load_with_name(empty), "from disk");
|
assert_eq!(load_with_name(empty), "from disk");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gpx(name: &str) -> Vec<u8> {
|
||||||
|
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<'a>(data: &'a [u8], name: &'a str) -> Load<'a> {
|
||||||
|
Load { data, name }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_files() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
crate::New { name: "before" }
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let before = fx.order.0[0];
|
||||||
|
let (a, b, c) = (gpx("a"), gpx("b"), gpx("c"));
|
||||||
|
|
||||||
|
LoadFiles {
|
||||||
|
files: vec![load(&a, "x"), load(&b, "x"), load(&c, "x")],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// added after the others, in the order they were given
|
||||||
|
let names: Vec<_> = fx
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["before", "a", "b", "c"]);
|
||||||
|
assert_eq!(fx.order.0[0], before);
|
||||||
|
// the first one is selected
|
||||||
|
assert_eq!(fx.selected_files(), [fx.order.0[1]].into());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_files_skips_the_invalid_ones() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let (a, c) = (gpx("a"), gpx("c"));
|
||||||
|
LoadFiles {
|
||||||
|
files: vec![load(&a, "x"), load(b"<gpx><trk></gpx>", "x"), load(&c, "x")],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let names: Vec<_> = fx
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["a", "c"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_invalid_files_changes_nothing() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
|
||||||
|
let selected = fx.selected_files();
|
||||||
|
|
||||||
|
let result = LoadFiles {
|
||||||
|
files: vec![
|
||||||
|
load(b"<gpx><trk></gpx>", "x"),
|
||||||
|
load(b"<gpx><wpt></gpx>", "y"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state());
|
||||||
|
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||||
|
assert_eq!(fx.files.len(), 1);
|
||||||
|
assert_eq!(fx.order.0.len(), 1);
|
||||||
|
assert_eq!(fx.selected_files(), selected);
|
||||||
|
|
||||||
|
// nothing to load
|
||||||
|
assert_eq!(
|
||||||
|
LoadFiles { files: vec![] }.apply(&mut fx.state()),
|
||||||
|
Err(CommandError::NothingToDo)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_each_file_without_name_gets_its_own_name() {
|
||||||
|
let mut fx = Fixture::default();
|
||||||
|
let unnamed = br#"<gpx version="1.1"><trk><trkseg></trkseg></trk></gpx>"#;
|
||||||
|
LoadFiles {
|
||||||
|
files: vec![load(unnamed, "first"), load(unnamed, "second")],
|
||||||
|
}
|
||||||
|
.apply(&mut fx.state())
|
||||||
|
.unwrap();
|
||||||
|
let names: Vec<_> = fx
|
||||||
|
.order
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|id| fx.files[id].info.name.clone())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(names, ["first", "second"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1071,4 +1071,43 @@ mod tests {
|
|||||||
assert!(engine.execute(Action::Undo));
|
assert!(engine.execute(Action::Undo));
|
||||||
assert_eq!(track_ids(&engine, a), tracks);
|
assert_eq!(track_ids(&engine, a), tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_several_files_is_one_edit() {
|
||||||
|
use crate::LoadFiles;
|
||||||
|
|
||||||
|
let mut engine = Engine::default();
|
||||||
|
new(&mut engine, "before");
|
||||||
|
let gpx = |name: &str| {
|
||||||
|
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#)
|
||||||
|
.into_bytes()
|
||||||
|
};
|
||||||
|
let (a, b) = (gpx("a"), gpx("b"));
|
||||||
|
assert!(edit(
|
||||||
|
&mut engine,
|
||||||
|
Command::LoadFiles(LoadFiles {
|
||||||
|
files: vec![
|
||||||
|
Load {
|
||||||
|
data: &a,
|
||||||
|
name: "x"
|
||||||
|
},
|
||||||
|
Load {
|
||||||
|
data: &b,
|
||||||
|
name: "x"
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
));
|
||||||
|
assert_eq!(engine.order().len(), 3);
|
||||||
|
let diff = engine.last_diff().unwrap();
|
||||||
|
assert_eq!(diff.added.len(), 2);
|
||||||
|
// the first one that was loaded is selected
|
||||||
|
assert_eq!(engine.selection(), &files(&[engine.order()[1]]));
|
||||||
|
|
||||||
|
// a single undo removes both
|
||||||
|
assert!(engine.execute(Action::Undo));
|
||||||
|
assert_eq!(engine.order().len(), 1);
|
||||||
|
assert!(engine.execute(Action::Redo));
|
||||||
|
assert_eq!(engine.order().len(), 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-3
@@ -171,10 +171,26 @@ pub fn new_file(name: &str) -> bool {
|
|||||||
edit(Command::New(engine::New { name }))
|
edit(Command::New(engine::New { name }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `name`: the name of the file when the data has none (the name on disk, without extension).
|
/// Loads files as a single command (one undo step). The files are in `data`, one after the
|
||||||
|
/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when
|
||||||
|
/// its data has none (the name on disk, without extension). The files that cannot be read are
|
||||||
|
/// skipped, the first one that was read is selected.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn load_file(data: &[u8], name: &str) -> bool {
|
pub fn load_files(data: &[u8], lengths: &[u32], names: Vec<String>) -> bool {
|
||||||
edit(Command::Load(engine::Load { data, name }))
|
if lengths.len() != names.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut files = Vec::with_capacity(names.len());
|
||||||
|
let mut offset = 0;
|
||||||
|
for (length, name) in lengths.iter().zip(&names) {
|
||||||
|
let end = offset + *length as usize;
|
||||||
|
let Some(data) = data.get(offset..end) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
files.push(engine::Load { data, name });
|
||||||
|
offset = end;
|
||||||
|
}
|
||||||
|
edit(Command::LoadFiles(engine::LoadFiles { files }))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import type { GPXFile } from 'gpx';
|
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
getFilesFromEmbeddingOptions,
|
getFilesFromEmbeddingOptions,
|
||||||
@@ -15,9 +14,7 @@
|
|||||||
} from './embedding';
|
} from './embedding';
|
||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { fileStateCollection } from '$lib/logic/file-state';
|
import { loadFiles } from '$lib/logic/file-actions';
|
||||||
import { loadFile } from '$lib/logic/file-actions';
|
|
||||||
import { selection } from '$lib/logic/selection';
|
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
@@ -30,6 +27,8 @@
|
|||||||
hash = $bindable(),
|
hash = $bindable(),
|
||||||
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
||||||
|
|
||||||
|
const { files } = engine;
|
||||||
|
|
||||||
let additionalDatasets = writable<string[]>([]);
|
let additionalDatasets = writable<string[]>([]);
|
||||||
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
||||||
|
|
||||||
@@ -74,26 +73,14 @@
|
|||||||
);
|
);
|
||||||
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
|
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
|
||||||
|
|
||||||
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
|
let downloads: Promise<File>[] = getFilesFromEmbeddingOptions(options).map((url) => {
|
||||||
(url) => {
|
return fetch(url)
|
||||||
return fetch(url)
|
.then((response) => response.blob())
|
||||||
.then((response) => response.blob())
|
.then((blob) => new File([blob], url.split('/').pop() ?? url));
|
||||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
|
||||||
.then(loadFile);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Promise.all(downloads).then((answers) => {
|
|
||||||
const files = answers.filter((file) => file !== null) as GPXFile[];
|
|
||||||
let ids: string[] = [];
|
|
||||||
files.forEach((file, index) => {
|
|
||||||
let id = `gpx-${index}-embed`;
|
|
||||||
file._data.id = id;
|
|
||||||
ids.push(id);
|
|
||||||
});
|
|
||||||
fileStateCollection.setEmbeddedFiles(files);
|
|
||||||
$fileOrder = ids;
|
|
||||||
selection.selectAll();
|
|
||||||
});
|
});
|
||||||
|
Promise.all(downloads)
|
||||||
|
.then(loadFiles)
|
||||||
|
.then(() => engine.selectAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -105,7 +92,7 @@
|
|||||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||||
<div class="grow relative">
|
<div class="grow relative">
|
||||||
<Map
|
<Map
|
||||||
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
class="h-full {$files.size > 1 ? 'horizontal' : ''}"
|
||||||
maptilerKey={options.key}
|
maptilerKey={options.key}
|
||||||
geocoder={false}
|
geocoder={false}
|
||||||
geolocate={true}
|
geolocate={true}
|
||||||
@@ -114,7 +101,7 @@
|
|||||||
<OpenIn files={options.files} ids={options.ids} />
|
<OpenIn files={options.files} ids={options.ids} />
|
||||||
<LayerControl />
|
<LayerControl />
|
||||||
<GPXLayers />
|
<GPXLayers />
|
||||||
{#if $fileStateCollection.size > 1}
|
{#if $files.size > 1}
|
||||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||||
<FileList orientation="horizontal" />
|
<FileList orientation="horizontal" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { defaultFileName } from './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}`;
|
||||||
|
}
|
||||||
@@ -242,9 +242,26 @@ class Engine {
|
|||||||
return this.run((w) => w.new_file(name));
|
return this.run((w) => w.new_file(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `name` is the name of the file when the data has none (the name on disk, without extension). */
|
/**
|
||||||
loadFile(data: Uint8Array, name: string) {
|
* Loads files as a single command (one undo step). `name` is the name of a file when its data
|
||||||
return this.run((w) => w.load_file(data, name));
|
* has none (the name on disk, without extension). The files that cannot be read are skipped,
|
||||||
|
* the first one that was read is selected.
|
||||||
|
*/
|
||||||
|
loadFiles(files: { data: Uint8Array; name: string }[]) {
|
||||||
|
const lengths = Uint32Array.from(files.map((file) => file.data.length));
|
||||||
|
const data = new Uint8Array(lengths.reduce((sum, length) => sum + length, 0));
|
||||||
|
let offset = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
data.set(file.data, offset);
|
||||||
|
offset += file.data.length;
|
||||||
|
}
|
||||||
|
return this.run((w) =>
|
||||||
|
w.load_files(
|
||||||
|
data,
|
||||||
|
lengths,
|
||||||
|
files.map((file) => file.name)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
duplicate() {
|
duplicate() {
|
||||||
|
|||||||
@@ -10,15 +10,12 @@ import {
|
|||||||
ListTrackItem,
|
ListTrackItem,
|
||||||
ListTrackSegmentItem,
|
ListTrackSegmentItem,
|
||||||
ListWaypointItem,
|
ListWaypointItem,
|
||||||
ListWaypointsItem,
|
|
||||||
sortItems,
|
|
||||||
type ListItem,
|
type ListItem,
|
||||||
} from '$lib/components/file-list/file-list';
|
} from '$lib/components/file-list/file-list';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { freeze, type WritableDraft } from 'immer';
|
import { freeze } from 'immer';
|
||||||
import {
|
import {
|
||||||
GPXFile,
|
GPXFile,
|
||||||
parseGPX,
|
|
||||||
Track,
|
Track,
|
||||||
TrackPoint,
|
TrackPoint,
|
||||||
TrackSegment,
|
TrackSegment,
|
||||||
@@ -32,6 +29,8 @@ import { settings } from '$lib/logic/settings';
|
|||||||
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
|
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
|
||||||
import { gpxStatistics } from '$lib/logic/statistics';
|
import { gpxStatistics } from '$lib/logic/statistics';
|
||||||
import { boundsManager } from './bounds';
|
import { boundsManager } from './bounds';
|
||||||
|
import { engine } from '$lib/engine';
|
||||||
|
import { defaultFileName } from '$lib/default-file-name';
|
||||||
|
|
||||||
// Generate unique file ids, different from the ones in the database
|
// Generate unique file ids, different from the ones in the database
|
||||||
export function getFileIds(n: number) {
|
export function getFileIds(n: number) {
|
||||||
@@ -45,31 +44,21 @@ export function getFileIds(n: number) {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 newGPXFile() {
|
export function newGPXFile() {
|
||||||
const newFileName = i18n._('menu.new_file');
|
|
||||||
|
|
||||||
let file = new GPXFile();
|
let file = new GPXFile();
|
||||||
|
file.metadata.name = newFileName();
|
||||||
let maxNewFileNumber = 0;
|
|
||||||
fileStateCollection.forEach((fileId, file) => {
|
|
||||||
if (file.metadata.name && file.metadata.name.startsWith(newFileName)) {
|
|
||||||
let number = parseInt(file.metadata.name.split(' ').pop() ?? '0');
|
|
||||||
if (!isNaN(number) && number > maxNewFileNumber) {
|
|
||||||
maxNewFileNumber = number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
file.metadata.name = `${newFileName} ${maxNewFileNumber + 1}`;
|
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFile() {
|
export function createFile() {
|
||||||
let file = newGPXFile();
|
// the engine selects the new file
|
||||||
|
engine.newFile(newFileName());
|
||||||
fileActions.add(file);
|
|
||||||
selection.selectFileWhenLoaded(file._data.id);
|
|
||||||
currentTool.set(Tool.ROUTING);
|
currentTool.set(Tool.ROUTING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,41 +76,27 @@ export function triggerFileInput() {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFiles(list: FileList | File[]) {
|
/**
|
||||||
let files: GPXFile[] = [];
|
* Loads GPX files in the engine, as a single command: one undo step, the first file that could be
|
||||||
for (let i = 0; i < list.length; i++) {
|
* read is selected, and the map fits the files. A file without name is named like the file on
|
||||||
let file = await loadFile(list[i]);
|
* disk. Returns the ids of the files that were added.
|
||||||
if (file) {
|
*/
|
||||||
files.push(file);
|
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;
|
||||||
let ids = fileActions.addMultiple(files);
|
|
||||||
selection.selectFileWhenLoaded(ids[0]);
|
|
||||||
boundsManager.fitBoundsOnLoad(ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadFile(file: File): Promise<GPXFile | null> {
|
|
||||||
let result = await new Promise<GPXFile | null>((resolve) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
let data = reader.result?.toString() ?? null;
|
|
||||||
if (data) {
|
|
||||||
let gpx = parseGPX(data);
|
|
||||||
if (gpx.metadata === undefined) {
|
|
||||||
gpx.metadata = {};
|
|
||||||
}
|
|
||||||
if (gpx.metadata.name === undefined || gpx.metadata.name.trim() === '') {
|
|
||||||
gpx.metadata.name = file.name.split('.').slice(0, -1).join('.');
|
|
||||||
}
|
|
||||||
resolve(gpx);
|
|
||||||
} else {
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for file operations
|
// Helper functions for file operations
|
||||||
@@ -891,249 +866,3 @@ export const fileActions = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function pasteSelection() {
|
|
||||||
let fromItems = get(copied);
|
|
||||||
if (fromItems === undefined || fromItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = get(selection).getSelected();
|
|
||||||
if (selected.length === 0) {
|
|
||||||
selected = [new ListRootItem()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let fromParent = fromItems[0].getParent();
|
|
||||||
let toParent = selected[selected.length - 1];
|
|
||||||
|
|
||||||
let startIndex: number | undefined = undefined;
|
|
||||||
|
|
||||||
if (fromItems[0].level === toParent.level) {
|
|
||||||
if (
|
|
||||||
toParent instanceof ListTrackItem ||
|
|
||||||
toParent instanceof ListTrackSegmentItem ||
|
|
||||||
toParent instanceof ListWaypointItem
|
|
||||||
) {
|
|
||||||
startIndex = toParent.getId() + 1;
|
|
||||||
}
|
|
||||||
toParent = toParent.getParent();
|
|
||||||
}
|
|
||||||
|
|
||||||
let toItems: ListItem[] = [];
|
|
||||||
if (toParent.level === ListLevel.ROOT) {
|
|
||||||
let fileIds = getFileIds(fromItems.length);
|
|
||||||
fileIds.forEach((fileId) => {
|
|
||||||
toItems.push(new ListFileItem(fileId));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let toFile = fileStateCollection.getFile(toParent.getFileId());
|
|
||||||
if (toFile) {
|
|
||||||
fromItems.forEach((item, index) => {
|
|
||||||
if (toParent instanceof ListFileItem) {
|
|
||||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
|
||||||
toItems.push(
|
|
||||||
new ListTrackItem(
|
|
||||||
toParent.getFileId(),
|
|
||||||
(startIndex ?? toFile.trk.length) + index
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
|
||||||
} else if (item instanceof ListWaypointItem) {
|
|
||||||
toItems.push(
|
|
||||||
new ListWaypointItem(
|
|
||||||
toParent.getFileId(),
|
|
||||||
(startIndex ?? toFile.wpt.length) + index
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (toParent instanceof ListTrackItem) {
|
|
||||||
if (item instanceof ListTrackSegmentItem) {
|
|
||||||
let toTrackIndex = toParent.getTrackIndex();
|
|
||||||
toItems.push(
|
|
||||||
new ListTrackSegmentItem(
|
|
||||||
toParent.getFileId(),
|
|
||||||
toTrackIndex,
|
|
||||||
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (toParent instanceof ListWaypointsItem) {
|
|
||||||
if (item instanceof ListWaypointItem) {
|
|
||||||
toItems.push(
|
|
||||||
new ListWaypointItem(
|
|
||||||
toParent.getFileId(),
|
|
||||||
(startIndex ?? toFile.wpt.length) + index
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromItems.length === toItems.length) {
|
|
||||||
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
|
|
||||||
selection.resetCopied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveItems(
|
|
||||||
fromParent: ListItem,
|
|
||||||
toParent: ListItem,
|
|
||||||
fromItems: ListItem[],
|
|
||||||
toItems: ListItem[],
|
|
||||||
remove: boolean = true
|
|
||||||
) {
|
|
||||||
if (fromItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sortItems(fromItems, false);
|
|
||||||
sortItems(toItems, false);
|
|
||||||
|
|
||||||
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
|
|
||||||
fromItems.forEach((item) => {
|
|
||||||
let file = fileStateCollection.getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
context.push(file.clone());
|
|
||||||
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
|
|
||||||
context.push(file.trk[item.getTrackIndex()].clone());
|
|
||||||
} else if (
|
|
||||||
item instanceof ListTrackSegmentItem &&
|
|
||||||
item.getTrackIndex() < file.trk.length &&
|
|
||||||
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
|
|
||||||
) {
|
|
||||||
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
context.push(file.wpt.map((wpt) => wpt.clone()));
|
|
||||||
} else if (
|
|
||||||
item instanceof ListWaypointItem &&
|
|
||||||
item.getWaypointIndex() < file.wpt.length
|
|
||||||
) {
|
|
||||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (remove && !(fromParent instanceof ListRootItem)) {
|
|
||||||
sortItems(fromItems, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let files = [fromParent.getFileId(), toParent.getFileId()];
|
|
||||||
let callbacks = [
|
|
||||||
(
|
|
||||||
file: WritableDraft<GPXFile>,
|
|
||||||
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
|
|
||||||
) => {
|
|
||||||
fromItems.forEach((item) => {
|
|
||||||
if (item instanceof ListTrackItem) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
|
||||||
} else if (item instanceof ListTrackSegmentItem) {
|
|
||||||
file.replaceTrackSegments(
|
|
||||||
item.getTrackIndex(),
|
|
||||||
item.getSegmentIndex(),
|
|
||||||
item.getSegmentIndex(),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
|
||||||
} else if (item instanceof ListWaypointItem) {
|
|
||||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(
|
|
||||||
file: WritableDraft<GPXFile>,
|
|
||||||
context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]
|
|
||||||
) => {
|
|
||||||
toItems.forEach((item, i) => {
|
|
||||||
if (item instanceof ListTrackItem) {
|
|
||||||
if (context[i] instanceof Track) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
|
||||||
context[i],
|
|
||||||
]);
|
|
||||||
} else if (context[i] instanceof TrackSegment) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
|
||||||
new Track({
|
|
||||||
trkseg: [context[i]],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
item instanceof ListTrackSegmentItem &&
|
|
||||||
context[i] instanceof TrackSegment
|
|
||||||
) {
|
|
||||||
file.replaceTrackSegments(
|
|
||||||
item.getTrackIndex(),
|
|
||||||
item.getSegmentIndex(),
|
|
||||||
item.getSegmentIndex() - 1,
|
|
||||||
[context[i]]
|
|
||||||
);
|
|
||||||
} else if (item instanceof ListWaypointsItem) {
|
|
||||||
if (
|
|
||||||
Array.isArray(context[i]) &&
|
|
||||||
context[i].length > 0 &&
|
|
||||||
context[i][0] instanceof Waypoint
|
|
||||||
) {
|
|
||||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
|
|
||||||
} else if (context[i] instanceof Waypoint) {
|
|
||||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
|
|
||||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
|
|
||||||
context[i],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (fromParent instanceof ListRootItem) {
|
|
||||||
files = [];
|
|
||||||
callbacks = [];
|
|
||||||
} else if (!remove) {
|
|
||||||
files.splice(0, 1);
|
|
||||||
callbacks.splice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
fileActionManager.applyEachToFilesAndGlobal(
|
|
||||||
files,
|
|
||||||
callbacks,
|
|
||||||
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
toItems.forEach((item, i) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
if (context[i] instanceof GPXFile) {
|
|
||||||
let newFile = context[i];
|
|
||||||
if (remove) {
|
|
||||||
files.delete(newFile._data.id);
|
|
||||||
}
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
} else if (context[i] instanceof Track) {
|
|
||||||
let newFile = newGPXFile();
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
if (context[i].name) {
|
|
||||||
newFile.metadata.name = context[i].name;
|
|
||||||
}
|
|
||||||
newFile.replaceTracks(0, 0, [context[i]]);
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
} else if (context[i] instanceof TrackSegment) {
|
|
||||||
let newFile = newGPXFile();
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
newFile.replaceTracks(0, 0, [
|
|
||||||
new Track({
|
|
||||||
trkseg: [context[i]],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
context
|
|
||||||
);
|
|
||||||
|
|
||||||
selection.set(toItems);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user