85 Commits
Author SHA1 Message Date
vcoppe 45f877ec78 route export, filter out anchors at high zoom level 2026-10-26 19:27:12 +01:00
vcoppe 0f957fb1f2 create route file action 2026-10-26 19:22:14 +01:00
vcoppe 764d0b233a route - track conversion 2026-10-26 19:06:19 +01:00
vcoppe b6c0f5190a route full support 2026-10-26 18:51:56 +01:00
vcoppe c842da8dc1 reorder context menu items 2026-10-18 19:09:06 +02:00
vcoppe 5bc35f30d9 strip native menu 2026-10-18 18:43:12 +02:00
vcoppe 873e2e1654 Merge branch 'dev' into tauri 2026-10-18 18:15:37 +02:00
vcoppe 6c2d2879f4 use new endpoint for openhikingmap 2026-10-18 17:20:09 +02:00
vcoppe f5670cdd8e remove custom ign styles 2026-10-18 17:14:50 +02:00
vcoppe 741d35db1e sparse desktop build 2026-10-18 16:53:39 +02:00
vcoppe 6556c29492 fix 2026-10-18 16:38:59 +02:00
vcoppe 7fa850e4c8 fix routing control size 2026-10-18 16:34:03 +02:00
vcoppe f9e8d4c644 show popup once data is loaded 2026-10-18 16:27:51 +02:00
vcoppe a07da16163 sparser data transfers 2026-10-18 16:20:32 +02:00
vcoppe 4583874e7e sparser data transfers 2026-10-18 12:35:32 +02:00
vcoppe 48325e6016 performance improvements 2026-10-18 10:50:34 +02:00
vcoppe 9033401094 improved preview 2026-10-18 09:32:18 +02:00
vcoppe b64c192c4f consistent highlight color 2026-10-18 09:17:01 +02:00
vcoppe 9dd4af98a5 small fix 2026-10-18 09:09:00 +02:00
vcoppe 05510ece92 full screen 2026-10-18 00:38:53 +02:00
vcoppe 8bbcbdc097 remove focus-ring 2026-10-18 00:33:03 +02:00
vcoppe 3012f6bf73 fixes 2026-10-18 00:24:16 +02:00
vcoppe 5373d0f0d4 cleaning 2026-10-17 23:55:46 +02:00
vcoppe 8fd47d8b18 drop anywhere in folder 2026-10-17 23:39:44 +02:00
vcoppe a5c1f6ad33 fix folder styling 2026-10-17 23:35:56 +02:00
vcoppe 3ef2bf135a explorer actions 2026-10-17 23:23:29 +02:00
vcoppe adbf89e318 clarify filename 2026-10-17 22:17:17 +02:00
vcoppe 11709316d2 avoid selecting on resize 2026-10-17 20:02:09 +02:00
vcoppe d52b61f707 commit missing changes 2026-10-17 20:01:59 +02:00
vcoppe 358253b7a9 open links in browser 2026-10-17 19:57:50 +02:00
vcoppe 8d6885d58b remember language 2026-10-17 19:36:57 +02:00
vcoppe 128338aa05 fix sorting 2026-10-17 19:10:50 +02:00
vcoppe c84e1c9a06 fix routing on other file 2026-10-17 19:03:33 +02:00
vcoppe 9f49119eec no file delete on desktop 2026-10-17 18:57:40 +02:00
vcoppe 67a47cf065 fix tab size 2026-10-17 18:50:23 +02:00
vcoppe e57949dc01 fix 2026-10-17 18:33:50 +02:00
vcoppe da92fd15d2 fix sortable 2026-10-17 18:28:32 +02:00
vcoppe 01443943b3 fix sortable 2026-10-17 17:21:56 +02:00
vcoppe 970cf7d1e1 improved sticky folders 2026-10-17 16:39:41 +02:00
vcoppe 593b43e433 sticky folders 2026-10-17 16:24:19 +02:00
vcoppe 8fa88680b3 fix 2026-10-17 16:17:45 +02:00
vcoppe b2495d1466 progress 2026-10-17 16:14:17 +02:00
vcoppe ed90ba6a1f file explorer 2026-10-17 14:42:49 +02:00
vcoppe b0a3c3a9ba progress 2026-10-16 18:14:59 +02:00
vcoppe 005cc7b305 fix 2026-10-16 09:05:45 +02:00
vcoppe 1bfd319758 use history variant 2026-10-16 09:02:31 +02:00
vcoppe 187296a277 keep original flag 2026-10-16 08:54:40 +02:00
vcoppe 55a8b020e0 per file history 2026-10-16 08:42:45 +02:00
vcoppe 1ba5436af3 progress 2026-10-14 19:15:02 +02:00
vcoppe 2e1ca00afa fix 2026-10-14 09:41:07 +02:00
vcoppe 97d597e139 progress 2026-10-14 09:22:07 +02:00
vcoppe 41b9116c6a progress 2026-10-14 08:51:38 +02:00
vcoppe 6a0d434371 Merge branch 'dev' 2026-07-24 19:07:00 +02:00
vcoppe b319440a57 upgrade to maplibre v6 2026-07-24 19:06:39 +02:00
vcoppe b0d78b5f3c Merge branch 'dev' 2026-07-21 12:30:07 +02:00
vcoppe 2e8b2d6f30 add ukrainian 2026-07-21 11:20:31 +02:00
Dobrovolskyi Bohdan 5259d007c9 Fix Locale subscription (#369) 2026-07-21 12:04:42 +02:00
vcoppe cb36725a46 New Crowdin updates (#373)
* New translations en.json (German)

[ci skip]

* New translations view.mdx (German)

[ci skip]
2026-07-21 12:00:16 +02:00
vcoppe deef1bab47 fix embedding playground zoom 2026-07-21 11:11:13 +02:00
Dobrovolskyi Bohdan 108880f8cd Update language on map (#365) 2026-07-14 18:50:51 +02:00
vcoppe 37a659831b New Crowdin updates (#359)
* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations file.mdx (Ukrainian)

[ci skip]

* New translations file.mdx (Ukrainian)

[ci skip]

* New translations settings.mdx (Ukrainian)

[ci skip]

* New translations view.mdx (Ukrainian)

[ci skip]

* New translations clean.mdx (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations clean.mdx (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations extract.mdx (Ukrainian)

[ci skip]

* New translations merge.mdx (Ukrainian)

[ci skip]

* New translations minify.mdx (Ukrainian)

[ci skip]

* New translations poi.mdx (Ukrainian)

[ci skip]

* New translations elevation.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations scissors.mdx (Ukrainian)

[ci skip]

* New translations time.mdx (Ukrainian)

[ci skip]

* New translations faq.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations view.mdx (Ukrainian)

[ci skip]

* New translations faq.mdx (Ukrainian)

[ci skip]

* New translations files-and-stats.mdx (Ukrainian)

[ci skip]

* New translations getting-started.mdx (Ukrainian)

[ci skip]

* New translations gpx.mdx (Ukrainian)

[ci skip]

* New translations integration.mdx (Ukrainian)

[ci skip]

* New translations map-controls.mdx (Ukrainian)

[ci skip]

* New translations menu.mdx (Ukrainian)

[ci skip]

* New translations toolbar.mdx (Ukrainian)

[ci skip]

* New translations en.json (Basque)

[ci skip]
2026-07-14 18:49:25 +02:00
vcoppe fde6bafc10 Merge branch 'dev' 2026-06-18 23:07:43 +02:00
vcoppe 4537ea4730 Merge branch 'dev' 2026-06-03 19:05:25 +02:00
vcoppe 1954bbd764 Merge branch 'dev' 2026-06-03 18:48:43 +02:00
vcoppe 9d2da80893 Merge branch 'dev' 2026-05-31 15:26:17 +02:00
vcoppe 043d368c74 Merge branch 'dev' 2026-05-29 08:37:39 +02:00
vcoppe bb11d3375b Merge branch 'dev' 2026-05-28 19:29:34 +02:00
vcoppe bfe5961e27 Merge branch 'dev' 2026-05-21 20:27:41 +02:00
vcoppe 17559913fd Merge branch 'dev' 2026-04-19 16:49:41 +02:00
vcoppe 55590d68a6 Merge branch 'dev' 2026-04-19 16:34:17 +02:00
vcoppe 8e9f16c460 Merge branch 'dev' 2026-04-19 14:47:35 +02:00
vcoppe c60b64f24f Merge branch 'dev' 2026-04-17 22:10:40 +02:00
vcoppe fdb6fe4e52 Merge branch 'dev' 2026-04-17 20:11:25 +02:00
vcoppe 0bf168e67e Merge branch 'dev' 2026-04-09 21:10:56 +02:00
vcoppe d762a45eb9 Merge branch 'dev' 2026-04-09 20:57:30 +02:00
vcoppe 548ab9a459 Merge branch 'dev' 2026-04-07 22:19:15 +02:00
vcoppe 315c1f6a61 Merge branch 'dev' 2026-04-07 22:02:14 +02:00
vcoppe 98257bee12 Merge branch 'dev' 2026-04-06 18:26:22 +02:00
vcoppe 5561b7d9fe Merge branch 'dev' 2026-04-06 14:26:56 +02:00
vcoppe 320887206c Merge branch 'dev' 2026-04-04 23:15:01 +02:00
vcoppe 04a1bf6a55 Merge branch 'dev' 2026-04-03 08:47:57 +02:00
vcoppe 0989371874 Merge branch 'dev' 2026-04-03 08:06:19 +02:00
vcoppe 3dcd6e52d3 Merge branch 'dev' 2026-04-02 22:35:46 +02:00
vcoppe 0f06d0d461 update 2026-04-02 22:23:34 +02:00
vcoppe db33310a10 revert some changes 2026-04-02 22:20:47 +02:00
350 changed files with 20976 additions and 31300 deletions

No files matched your search

+19 -7
View File
@@ -15,19 +15,31 @@ jobs:
with: with:
node-version: 24 node-version: 24
cache: npm cache: npm
cache-dependency-path: | cache-dependency-path: website/package-lock.json
gpx/package-lock.json
website/package-lock.json
- name: Install dependencies for gpx - name: Install Rust
run: npm install --prefix gpx uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Build gpx - name: Install wasm-pack
run: npm run build --prefix gpx uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- name: Test gpx-rs
run: cargo test
- name: Build wasm
run: wasm-pack build --target bundler
working-directory: wasm
- name: Install dependencies for website - name: Install dependencies for website
run: npm install --prefix website run: npm install --prefix website
- name: Test website
run: npm test --prefix website
- name: Create env file - name: Create env file
run: | run: |
touch website/.env touch website/.env
+1
View File
@@ -1 +1,2 @@
.DS_Store .DS_Store
target/
Generated
+5330
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -0,0 +1,5 @@
[workspace]
members = ["gpx-rs", "wasm", "tauri"]
# `cargo test` at the root only tests the engine: the others need their own tooling
default-members = ["gpx-rs"]
resolver = "3"
+9 -7
View File
@@ -25,18 +25,20 @@ Any help is greatly appreciated!
## Development ## Development
The code is split into two parts: The code is split into four parts:
- `gpx-rs`: a Rust library, compiled to WebAssembly, for parsing and manipulating GPX files, - `gpx-rs`: a Rust library for parsing and manipulating GPX files (the engine), and the interface to it that the next two share (`gpx_engine::api`),
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. - `wasm`: the engine for the browser, compiled to WebAssembly,
- `tauri`: the desktop app, which runs the engine natively,
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. It also is the UI of the desktop app.
You will need [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/) and [wasm-pack](https://rustwasm.github.io/wasm-pack/) to build and run these two parts. You will need [Node.js](https://nodejs.org/), [Rust](https://rustup.rs/) and [wasm-pack](https://rustwasm.github.io/wasm-pack/) to build and run the website. See [`tauri`](tauri/README.md) for the desktop app.
### Building the `gpx-rs` library ### Building the WebAssembly engine
```bash ```bash
cd gpx-rs cd wasm
wasm-pack build wasm --target bundler --out-dir ../pkg wasm-pack build --target bundler
``` ```
### Running the website ### Running the website
-2
View File
@@ -1,2 +0,0 @@
target/
pkg/
-794
View File
@@ -1,794 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "gpx-engine"
version = "0.1.0"
dependencies = [
"chrono",
"postcard",
"quick-xml",
"serde",
"uuid",
]
[[package]]
name = "gpx-rs"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"gpx-engine",
"idb",
"js-sys",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "idb"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828"
dependencies = [
"indexmap",
"js-sys",
"num-traits",
"thiserror",
"tokio",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"wasm-bindgen",
]
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.6",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+10 -5
View File
@@ -1,6 +1,11 @@
[workspace] [package]
members = ["engine", "wasm"] name = "gpx-engine"
resolver = "3" version = "0.1.0"
edition = "2024"
[profile.release] [dependencies]
debug = true chrono = "0.4.45"
postcard = { version = "1.1.3", features = ["use-std"] }
quick-xml = "0.42.0"
serde = { version = "1.0.229", features = ["derive"] }
uuid = { version = "1.26.1", features = ["serde", "v4"] }
+7 -11
View File
@@ -1,14 +1,10 @@
# gpx-rs # gpx-rs
- `engine`: the GPX engine, plain Rust. Tested natively: `cargo test`. The GPX engine: plain Rust, no knowledge of who uses it. Tested natively with `cargo test` (from
- `wasm`: the interface to the web page, compiled to WebAssembly. here or from the root).
``` - `src/core`, `src/engine`, `src/storage`: the engine itself.
wasm-pack build wasm --target bundler --out-dir ../pkg - `src/api`: the interface to the engine that the frontends (`../wasm`, `../tauri`) share. Each
``` function takes the engine and typed arguments, and gives plain data (`api::dto`, serde types, or
slices for the numeric buffers). A frontend only converts what crosses: ids from strings or
The interface is tested in Node (it needs `wasm-pack`, which fetches the test runner the first time): bytes, results to JS objects or JSON, slices to typed arrays or raw bytes.
```
wasm-pack test --node wasm
```
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "gpx-engine"
version = "0.1.0"
edition = "2024"
[dependencies]
chrono = "0.4.45"
postcard = { version = "1.1.3", features = ["use-std"] }
quick-xml = "0.42.0"
serde = { version = "1.0.229", features = ["derive"] }
uuid = { version = "1.26.1", features = ["serde", "v4"] }
-31
View File
@@ -1,31 +0,0 @@
use crate::{Link, Track, Waypoints};
use super::common::uuid_id;
uuid_id!(FileId);
#[derive(Debug, Default, Clone)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
pub trk: Vec<Track>,
pub wpt: Waypoints,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
pub author: Option<Author>,
pub links: Vec<Link>,
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
/// `id@domain`, which the file gives as the two attributes of an `email` element.
pub email: Option<String>,
/// The author has a single link, unlike the other elements.
pub link: Option<Link>,
}
-7
View File
@@ -1,7 +0,0 @@
mod core;
mod engine;
mod storage;
pub use core::*;
pub use engine::*;
pub use storage::*;
+672
View File
@@ -0,0 +1,672 @@
//! The functions that edit the files or change the selection.
use uuid::Uuid;
use super::dto::*;
use crate::{self as engine, Action, Command, Engine, History, LngLat, LngLatBounds};
/// Executes an action and tells what it did.
pub fn execute<H: History>(engine: &mut Engine<H>, action: Action) -> Outcome {
match engine.execute(action) {
Ok(outcome) => Outcome::new(&outcome, None),
Err(error) => Outcome::failed(error.to_string()),
}
}
pub fn edit<H: History>(engine: &mut Engine<H>, command: Command) -> Outcome {
execute(engine, Action::Edit(command))
}
fn file_ids(ids: &[Uuid]) -> Vec<engine::FileId> {
ids.iter().copied().map(engine::FileId).collect()
}
// Selection
/// Selects files. Unknown files are ignored, and selecting nothing deselects everything.
pub fn select<H: History>(
engine: &mut Engine<H>,
file_ids_: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
select_elements(
engine,
engine::Selection::File {
file_ids: file_ids(file_ids_).into_iter().collect(),
},
mode,
)
}
/// Selects elements: they are merged with the selection when it holds elements of the same kind in
/// the same place (same file, same track for segments), otherwise they replace it.
pub fn select_elements<H: History>(
engine: &mut Engine<H>,
selection: engine::Selection,
mode: engine::SelectMode,
) -> Outcome {
execute(engine, Action::Select { selection, mode })
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all the
/// files, the routes or the tracks of the file, the segments of the track, the waypoints of the file. All the
/// files when nothing is selected.
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::SelectAll)
}
/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow
/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection,
/// otherwise it replaces it.
pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -> Outcome {
execute(engine, Action::ArrowSelect { down, add })
}
pub fn select_routes<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
route_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Route {
file_id: engine::FileId(file_id),
rte_ids: route_ids.iter().copied().map(engine::RouteId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_tracks<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
track_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Track {
file_id: engine::FileId(file_id),
trk_ids: track_ids.iter().copied().map(engine::TrackId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_segments<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
track_id: Uuid,
segment_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::TrackSegment {
file_id: engine::FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.iter()
.copied()
.map(engine::TrackSegmentId)
.collect(),
};
select_elements(engine, selection, mode)
}
/// Selects the node standing for all the waypoints of a file.
pub fn select_waypoint_group<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoints {
file_id: engine::FileId(file_id),
};
select_elements(engine, selection, mode)
}
pub fn select_waypoints<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Waypoint {
file_id: engine::FileId(file_id),
wpt_ids: waypoint_ids
.iter()
.copied()
.map(engine::WaypointId)
.collect(),
};
select_elements(engine, selection, mode)
}
// History
pub fn undo<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Undo)
}
pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Redo)
}
// Files
/// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
pub fn new_file<H: History>(
engine: &mut Engine<H>,
name: &str,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: bool,
) -> Outcome {
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
lng,
lat,
ele: ele.unwrap_or_default(),
});
edit(
engine,
Command::New(engine::New {
name,
trackpoint,
route,
}),
)
}
/// 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.
pub fn load_files<H: History>(
engine: &mut Engine<H>,
data: &[u8],
lengths: &[usize],
names: &[String],
) -> Outcome {
if lengths.len() != names.len() {
return Outcome::failed(format!(
"{} lengths for {} names",
lengths.len(),
names.len()
));
}
let mut files = Vec::with_capacity(names.len());
let mut offset = 0;
for (length, name) in lengths.iter().zip(names) {
let end = offset + *length;
let Some(data) = data.get(offset..end) else {
return Outcome::failed(format!("the data of {name} is shorter than its length"));
};
files.push(engine::Load { data, name });
offset = end;
}
edit(engine, Command::LoadFiles(engine::LoadFiles { files }))
}
/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none; it is called
/// `name`, whatever its data says. The files to `remove` are deleted. The selection is left alone.
pub fn sync_files<H: History>(
engine: &mut Engine<H>,
files: &[(Uuid, &[u8], &str)],
remove: &[Uuid],
) -> Outcome {
let files = files
.iter()
.map(|(id, data, name)| engine::ExternalFile {
id: engine::FileId(*id),
data,
name,
})
.collect();
edit(
engine,
Command::SyncFiles(engine::SyncFiles {
files,
remove: file_ids(remove),
}),
)
}
pub fn duplicate<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Duplicate(engine::Duplicate))
}
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are
/// deleted instead, even if only a track or a waypoint is selected.
pub fn delete<H: History>(engine: &mut Engine<H>, whole_files: bool) -> Outcome {
edit(engine, Command::Delete(engine::Delete { whole_files }))
}
pub fn delete_all<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::DeleteAll(engine::DeleteAll))
}
/// Moves the files to `index` among the other files, in the given order (not undoable).
pub fn reorder<H: History>(engine: &mut Engine<H>, file_ids_: &[Uuid], index: usize) -> Outcome {
execute(
engine,
Action::Reorder {
file_ids: file_ids(file_ids_),
index,
},
)
}
/// Names a file in all its states, as if it had always been called so (not undoable): for a file
/// that is written to a file of another name, whose name it takes.
pub fn rename_file<H: History>(engine: &mut Engine<H>, file_id: Uuid, name: &str) -> Outcome {
execute(
engine,
Action::Rename {
file_id: engine::FileId(file_id),
name: name.to_owned(),
},
)
}
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Copy)
}
/// Like `copy`, but the elements are moved when they are pasted.
pub fn cut<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Cut)
}
/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always the
/// elements as they were when they were copied or cut, even if they were changed or deleted since.
/// It can be pasted several times: what was copied is copied again, what was cut is moved the first
/// time (and the clipboard is not cut after that), then it is copied.
pub fn paste<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Paste(engine::Paste))
}
/// Moves elements to a place of the file tree, keeping their ids, like dragging and dropping them
/// does. The moved elements are selected.
///
/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks
/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes
/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the
/// waypoints of a file.
pub fn move_elements<H: History>(
engine: &mut Engine<H>,
what: Selection,
to: MoveTarget,
) -> Outcome {
edit(
engine,
Command::Move(engine::Move {
what: what.into(),
to: to.into(),
}),
)
}
pub fn metadata<H: History>(engine: &mut Engine<H>, name: &str, desc: &str) -> Outcome {
edit(engine, Command::Metadata(engine::Metadata { name, desc }))
}
/// Only the given fields are changed.
pub fn style<H: History>(
engine: &mut Engine<H>,
color: Option<&str>,
opacity: Option<f64>,
width: Option<f64>,
) -> Outcome {
edit(
engine,
Command::Style(engine::Style {
color,
opacity,
width,
}),
)
}
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
pub fn convert<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Convert(engine::Convert))
}
pub fn new_route<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewRoute(engine::NewRoute))
}
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrack(engine::NewTrack))
}
pub fn new_track_segment<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrackSegment(engine::NewTrackSegment))
}
// Waypoints. The strings that are empty are the fields that the waypoint does not have.
/// Adds a waypoint to each selected file (or to the file of the selected elements).
#[allow(clippy::too_many_arguments)]
pub fn new_waypoint<H: History>(
engine: &mut Engine<H>,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> Outcome {
edit(
engine,
Command::NewWaypoint(engine::NewWaypoint {
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
)
}
/// Moves a waypoint of a file, whatever is selected.
pub fn move_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
) -> Outcome {
edit(
engine,
Command::MoveWaypoint(engine::MoveWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
}),
)
}
/// Deletes a waypoint of a file, whatever is selected.
pub fn delete_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Outcome {
edit(
engine,
Command::DeleteWaypoint(engine::DeleteWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
}),
)
}
/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link,
/// position and elevation.
#[allow(clippy::too_many_arguments)]
pub fn update_waypoint<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
lng: f64,
lat: f64,
ele: f64,
name: &str,
desc: &str,
icon: &str,
link: &str,
) -> Outcome {
edit(
engine,
Command::EditWaypoint(engine::EditWaypoint {
file_id: engine::FileId(file_id),
waypoint_id: engine::WaypointId(waypoint_id),
lng,
lat,
ele,
name,
desc,
icon,
link,
}),
)
}
// Tools
pub fn reverse<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Reverse(engine::Reverse))
}
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so
/// that it goes back to where it started.
pub fn round_trip<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::RoundTrip(engine::RoundTrip))
}
/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its
/// segments one after the other, and removes the others (and what is left empty).
pub fn crop<H: History>(engine: &mut Engine<H>, start: u32, end: u32) -> Outcome {
if start > end {
return Outcome::failed(format!("the range {start}..{end} is reversed"));
}
edit(engine, Command::Crop(engine::Crop { start, end }))
}
/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the
/// line of their neighbours, except the ends of the segments.
pub fn reduce<H: History>(engine: &mut Engine<H>, tolerance: f64) -> Outcome {
edit(engine, Command::Reduce(engine::Reduce { tolerance }))
}
/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the
/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the
/// previous one at `speed` (km/h).
pub fn change_timestamps<H: History>(
engine: &mut Engine<H>,
start_time: f64,
speed: f64,
ratio: f64,
) -> Outcome {
edit(
engine,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Change { speed, ratio },
}),
)
}
/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and
/// lasts `total_time` seconds.
pub fn create_artificial_timestamps<H: History>(
engine: &mut Engine<H>,
start_time: f64,
total_time: f64,
) -> Outcome {
edit(
engine,
Command::Time(engine::Time {
start_time: start_time as i64,
kind: engine::TimeKind::Artificial { total_time },
}),
)
}
/// Merges the selection into its first element. `remove_gaps` brings the parts that are connected
/// closer in time, if they have timestamps. Merged files are kept (and the result is a new file) if
/// the history needs it, see [`History::KEEPS_ORIGINALS`].
pub fn merge<H: History>(
engine: &mut Engine<H>,
type_: engine::MergeType,
remove_gaps: bool,
) -> Outcome {
edit(
engine,
Command::Merge(engine::Merge {
type_,
remove_gaps,
keep_originals: H::KEEPS_ORIGINALS,
}),
)
}
/// Extracted files are kept (and the result is new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
pub fn extract<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(
engine,
Command::Extract(engine::Extract {
keep_originals: H::KEEPS_ORIGINALS,
}),
)
}
/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint.
pub fn elevation<H: History>(engine: &mut Engine<H>, ele: &[f64]) -> Outcome {
edit(engine, Command::Elevation(engine::Elevation { ele }))
}
/// Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle.
#[allow(clippy::too_many_arguments)]
pub fn clean<H: History>(
engine: &mut Engine<H>,
west: f64,
south: f64,
east: f64,
north: f64,
type_: engine::CleanType,
trkpt: bool,
wpt: bool,
) -> Outcome {
edit(
engine,
Command::Clean(engine::Clean {
bounds: LngLatBounds {
sw: LngLat {
lng: west,
lat: south,
},
ne: LngLat {
lng: east,
lat: north,
},
},
type_,
trkpt,
wpt,
}),
)
}
// Routing
//
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
// mean something for the `revision` they were read with (`routing_revision`), and the commands
// that use them do nothing if the selection is not the one of that revision anymore.
/// Makes the call `f`, unless the selection is not the one of the revision anymore.
fn at_revision<H: History>(
engine: &mut Engine<H>,
revision: u32,
f: impl FnOnce(&mut Engine<H>) -> Outcome,
) -> Outcome {
if engine.routing().revision == revision {
f(engine)
} else {
Outcome::failed("the selection changed since the routing buffers were read")
}
}
/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment
/// (a pure insertion if `start == end`, a pure removal without new points). `attributes` holds the
/// surface, highway, SAC scale and MTB scale of the new points, as intervals, and `anchors` the
/// indices among the new points of the ones that become anchors.
#[allow(clippy::too_many_arguments)]
pub fn route<H: History>(
engine: &mut Engine<H>,
revision: u32,
start: u32,
end: u32,
lng: &[f64],
lat: &[f64],
ele: &[f64],
attributes: &RouteAttributes,
anchors: &[u32],
) -> Outcome {
if lng.len() != lat.len() || lat.len() != ele.len() {
return Outcome::failed("lng, lat and ele differ in length");
}
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Reroute(engine::Reroute {
start,
end,
lng,
lat,
ele,
surface: attributes.surface.view(),
highway: attributes.highway.view(),
sac_scale: attributes.sac_scale.view(),
mtb_scale: attributes.mtb_scale.view(),
anchors,
}),
)
})
}
/// Makes an anchor of the point of the selected segments that is the closest to the coordinates,
/// inserting a trackpoint on the path if none is there.
pub fn insert_anchor<H: History>(
engine: &mut Engine<H>,
revision: u32,
lng: f64,
lat: f64,
) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::InsertAnchor(engine::InsertAnchor { lng, lat }),
)
})
}
/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the
/// trackpoints before it go to the end, and the loop closes on it).
pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, index: u32) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::ChangeLoopStart(engine::ChangeLoopStart { index }),
)
})
}
/// Cuts the file, the route, the track or the segment (see `SplitType`) of the trackpoint `at` of the
/// selection in two, there: the trackpoint ends the first part and starts the second one. A file that
/// is cut is kept (and its two parts are new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`].
pub fn split<H: History>(
engine: &mut Engine<H>,
revision: u32,
at: u32,
split_type: engine::SplitType,
) -> Outcome {
at_revision(engine, revision, |engine| {
edit(
engine,
Command::Split(engine::Split {
at,
split_type,
keep_originals: H::KEEPS_ORIGINALS,
}),
)
})
}
+636
View File
@@ -0,0 +1,636 @@
//! What crosses to the frontend, as plain data (serde): the shapes of the objects that the frontend
//! reads, which `wasm/src/ts.rs` declares in TypeScript. Ids are hyphenated UUID strings, times
//! are numbers (ms since the epoch), absent fields are skipped.
use crate as engine;
use engine::{FileId, History};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn ids(items: &[FileId]) -> Vec<String> {
items.iter().map(|id| id.0.to_string()).collect()
}
// Outcome
/// What a call that edits the files or changes the selection did.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Outcome {
pub changed: bool,
order_changed: bool,
selection_changed: bool,
clipboard_changed: bool,
added: Vec<String>,
modified: Vec<String>,
removed: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// Whether the files changed in a way that has to be saved (not sent).
#[serde(skip)]
pub saves: bool,
}
impl Outcome {
pub fn new(outcome: &engine::Outcome, error: Option<String>) -> Self {
let diff = outcome.diff.clone().unwrap_or_default();
Self {
changed: outcome.changed,
order_changed: outcome.order_changed,
selection_changed: outcome.selection_changed,
clipboard_changed: outcome.clipboard_changed,
added: ids(&diff.added),
modified: ids(&diff.modified),
removed: ids(&diff.removed),
error,
saves: outcome.changed && (outcome.diff.is_some() || outcome.order_changed),
}
}
/// The outcome of a frontend that knows nothing of the engine: all the files are added, and the
/// order, the selection and the clipboard are to be read.
pub fn everything<H: History>(engine: &engine::Engine<H>) -> Self {
Self {
changed: true,
order_changed: true,
selection_changed: true,
clipboard_changed: true,
added: ids(engine.order()),
modified: vec![],
removed: vec![],
error: None,
saves: false,
}
}
/// The outcome of a call that did nothing and has nothing to say.
pub fn failed_none() -> Self {
Self::new(&engine::Outcome::default(), None)
}
/// The ids of the files that the call added.
pub fn added(&self) -> &[String] {
&self.added
}
/// Whether the call did nothing and says why.
pub fn has_error(&self) -> bool {
self.error.is_some()
}
/// What two calls that were made one after the other did together.
pub fn merge(mut self, other: Self) -> Self {
fn union(into: &mut Vec<String>, from: Vec<String>) {
for id in from {
if !into.contains(&id) {
into.push(id);
}
}
}
self.changed |= other.changed;
self.order_changed |= other.order_changed;
self.selection_changed |= other.selection_changed;
self.clipboard_changed |= other.clipboard_changed;
self.saves |= other.saves;
// a file that was added and then modified is still an added one, and one that was added
// and then removed is not there at all
let added_before = self.added.clone();
union(&mut self.added, other.added);
for id in other.modified {
if !self.added.contains(&id) && !self.modified.contains(&id) {
self.modified.push(id);
}
}
for id in other.removed {
self.added.retain(|added| *added != id);
self.modified.retain(|modified| *modified != id);
if !added_before.contains(&id) && !self.removed.contains(&id) {
self.removed.push(id);
}
}
self.error = self.error.or(other.error);
self
}
/// What a call that does nothing returns.
pub fn failed(message: impl Into<String>) -> Self {
Self::new(&engine::Outcome::default(), Some(message.into()))
}
}
// Selection and clipboard
#[derive(Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum Selection {
Empty,
File {
file_ids: Vec<Uuid>,
},
Route {
file_id: Uuid,
route_ids: Vec<Uuid>,
},
Track {
file_id: Uuid,
track_ids: Vec<Uuid>,
},
Segment {
file_id: Uuid,
track_id: Uuid,
segment_ids: Vec<Uuid>,
},
Waypoints {
file_id: Uuid,
},
Waypoint {
file_id: Uuid,
waypoint_ids: Vec<Uuid>,
},
}
impl From<&engine::Selection> for Selection {
fn from(selection: &engine::Selection) -> Self {
use engine::Selection as S;
match selection {
S::Empty => Self::Empty,
S::File { file_ids } => Self::File {
file_ids: file_ids.iter().map(|id| id.0).collect(),
},
S::Route { file_id, rte_ids } => Self::Route {
file_id: file_id.0,
route_ids: rte_ids.iter().map(|id| id.0).collect(),
},
S::Track { file_id, trk_ids } => Self::Track {
file_id: file_id.0,
track_ids: trk_ids.iter().map(|id| id.0).collect(),
},
S::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => Self::Segment {
file_id: file_id.0,
track_id: trk_id.0,
segment_ids: trkseg_ids.iter().map(|id| id.0).collect(),
},
S::Waypoints { file_id } => Self::Waypoints { file_id: file_id.0 },
S::Waypoint { file_id, wpt_ids } => Self::Waypoint {
file_id: file_id.0,
waypoint_ids: wpt_ids.iter().map(|id| id.0).collect(),
},
}
}
}
impl From<Selection> for engine::Selection {
fn from(selection: Selection) -> Self {
use engine::Selection as S;
match selection {
Selection::Empty => S::Empty,
Selection::File { file_ids } => S::File {
file_ids: file_ids.into_iter().map(FileId).collect(),
},
Selection::Route { file_id, route_ids } => S::Route {
file_id: FileId(file_id),
rte_ids: route_ids.into_iter().map(engine::RouteId).collect(),
},
Selection::Track { file_id, track_ids } => S::Track {
file_id: FileId(file_id),
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
},
Selection::Segment {
file_id,
track_id,
segment_ids,
} => S::TrackSegment {
file_id: FileId(file_id),
trk_id: engine::TrackId(track_id),
trkseg_ids: segment_ids
.into_iter()
.map(engine::TrackSegmentId)
.collect(),
},
Selection::Waypoints { file_id } => S::Waypoints {
file_id: FileId(file_id),
},
Selection::Waypoint {
file_id,
waypoint_ids,
} => S::Waypoint {
file_id: FileId(file_id),
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
},
}
}
}
/// Where moved elements go. A negative index is the start, an infinite one the end (which JSON
/// writes as `null`, so the frontend sends a very large number instead).
#[derive(Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum MoveTarget {
Files {
index: f64,
},
Routes {
file_id: Uuid,
index: f64,
},
Tracks {
file_id: Uuid,
index: f64,
},
Segments {
file_id: Uuid,
track_id: Uuid,
index: f64,
},
Waypoints {
file_id: Uuid,
index: f64,
},
}
impl From<MoveTarget> for engine::MoveTarget {
fn from(target: MoveTarget) -> Self {
use engine::MoveTarget as T;
let index = |index: f64| index.max(0.0) as usize;
match target {
MoveTarget::Files { index: i } => T::Files { index: index(i) },
MoveTarget::Routes { file_id, index: i } => T::Routes {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Segments {
file_id,
track_id,
index: i,
} => T::Segments {
file_id: FileId(file_id),
trk_id: engine::TrackId(track_id),
index: index(i),
},
MoveTarget::Waypoints { file_id, index: i } => T::Waypoints {
file_id: FileId(file_id),
index: index(i),
},
}
}
}
#[derive(Serialize)]
pub struct Clipboard {
#[serde(rename = "type")]
kind: &'static str,
ids: Vec<String>,
cut: bool,
}
impl Clipboard {
pub fn new(clipboard: &engine::Clipboard) -> Self {
let strings = |ids: Vec<Uuid>| ids.iter().map(Uuid::to_string).collect();
let (kind, ids) = match clipboard.content.ids() {
engine::ClipboardIds::Files(ids) => {
("files", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Routes(ids) => {
("routes", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Tracks(ids) => {
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Segments(ids) => (
"segments",
strings(ids.into_iter().map(|id| id.0).collect()),
),
engine::ClipboardIds::Waypoints(ids) => (
"waypoints",
strings(ids.into_iter().map(|id| id.0).collect()),
),
};
Self {
kind,
ids,
cut: clipboard.cut,
}
}
}
// Files
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileStructure {
id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
routes: Vec<RouteNode>,
tracks: Vec<TrackNode>,
waypoints: Vec<WaypointNode>,
waypoints_rev: String,
}
/// The name, description and style of a route or a track.
#[derive(Serialize)]
struct PathInfo {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
opacity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
width: Option<f64>,
}
impl From<&engine::PathInfo> for PathInfo {
fn from(info: &engine::PathInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Serialize)]
struct RouteNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segment: SegmentNode,
}
#[derive(Serialize)]
struct TrackNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segments: Vec<SegmentNode>,
}
#[derive(Serialize)]
struct SegmentNode {
id: String,
rev: String,
length: f64,
}
impl From<&engine::SegmentNode> for SegmentNode {
fn from(seg: &engine::SegmentNode) -> Self {
Self {
id: seg.id.0.to_string(),
rev: seg.rev_id.0.to_string(),
length: seg.len as f64,
}
}
}
#[derive(Serialize)]
struct WaypointNode {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sym: Option<String>,
}
impl FileStructure {
pub fn new(file: &engine::FileStructure) -> Self {
Self {
id: file.id.0.to_string(),
name: file.name.clone(),
desc: file.desc.clone(),
routes: file
.routes
.iter()
.map(|rte| RouteNode {
id: rte.id.0.to_string(),
info: (&rte.info).into(),
segment: (&rte.segment).into(),
})
.collect(),
tracks: file
.tracks
.iter()
.map(|trk| TrackNode {
id: trk.id.0.to_string(),
info: (&trk.info).into(),
segments: trk.segments.iter().map(Into::into).collect(),
})
.collect(),
waypoints: file
.waypoints
.iter()
.map(|wpt| WaypointNode {
id: wpt.id.0.to_string(),
name: wpt.name.clone(),
sym: wpt.sym.clone(),
})
.collect(),
waypoints_rev: file.wpt_rev_id.0.to_string(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GlobalStatistics {
total_distance: f64,
#[serde(skip_serializing_if = "Option::is_none")]
moving_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
moving_time: Option<f64>,
elevation_gain: f64,
elevation_loss: f64,
#[serde(skip_serializing_if = "Option::is_none")]
start_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
end_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_speed: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
moving_speed: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
bounds: Option<Bounds>,
}
#[derive(Serialize)]
struct Bounds {
west: f64,
south: f64,
east: f64,
north: f64,
}
impl GlobalStatistics {
pub fn new(stats: &engine::GlobalStatistics) -> Self {
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
Self {
total_distance: stats.total_distance,
moving_distance: stats.moving_distance,
total_time: seconds(stats.total_time),
moving_time: seconds(stats.moving_time),
elevation_gain: stats.elevation_gain,
elevation_loss: stats.elevation_loss,
start_time: stats.start_time.map(|t| t as f64),
end_time: stats.end_time.map(|t| t as f64),
total_speed: stats.total_speed(),
moving_speed: stats.moving_speed(),
bounds: (sw.lng <= ne.lng && sw.lat <= ne.lat).then_some(Bounds {
west: sw.lng,
south: sw.lat,
east: ne.lng,
north: ne.lat,
}),
}
}
}
#[derive(Serialize)]
pub struct WaypointDetails {
id: String,
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cmt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
sym: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
links: Option<Vec<Link>>,
}
#[derive(Serialize)]
struct Link {
href: String,
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
}
impl WaypointDetails {
pub fn new(wpt: &engine::Waypoint) -> Self {
Self {
id: wpt.id.0.to_string(),
lng: wpt.coordinates.lng,
lat: wpt.coordinates.lat,
ele: wpt.ele,
time: wpt.time.map(|t| t as f64),
name: wpt.name.clone(),
desc: wpt.desc.clone(),
cmt: wpt.cmt.clone(),
sym: wpt.sym.clone(),
links: (!wpt.links.is_empty()).then(|| {
wpt.links
.iter()
.map(|link| Link {
href: link.href.clone(),
text: link.text.clone(),
})
.collect()
}),
}
}
}
#[derive(Serialize)]
pub struct TrackpointDetails {
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
}
impl TrackpointDetails {
pub fn new(pt: &engine::Trackpoint) -> Self {
Self {
lng: pt.coordinates.lng,
lat: pt.coordinates.lat,
ele: pt.ele,
time: pt.time.map(|t| t as f64),
}
}
}
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
/// Absent fields are unknown.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionPoint {
/// Slope (%) at the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope: Option<f64>,
/// ms since the epoch.
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<f64>,
/// Slope (%) and length (km) of the smoothed elevation segment of the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_slope: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub highway: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sac_scale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtb_scale: Option<String>,
}
// Routing
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
#[derive(Default, Deserialize)]
pub struct RouteCategory {
pub starts: Vec<u32>,
pub values: Vec<u8>,
pub names: Vec<String>,
}
impl RouteCategory {
pub fn view(&self) -> engine::RouteCategory<'_> {
engine::RouteCategory {
starts: &self.starts,
values: &self.values,
names: &self.names,
}
}
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct RouteAttributes {
pub surface: RouteCategory,
pub highway: RouteCategory,
pub sac_scale: RouteCategory,
pub mtb_scale: RouteCategory,
}
+21
View File
@@ -0,0 +1,21 @@
//! The interface to the engine, which the interfaces to the frontend share: the web page
//! (`wasm`) and the desktop app (`tauri`) each expose these functions, and only convert what
//! crosses to their own way of passing data.
//!
//! - `commands`: the functions that edit the files or change the selection. Each returns an
//! `Outcome`: whether anything changed, what changed (to update what the frontend keeps), and why
//! nothing was done when that is so (the command had nothing to do, the data is invalid, an
//! argument is wrong).
//! - `queries`: the functions that read the state of the engine.
//! - `dto`: the data that crosses, as serde types.
//!
//! Ids are `Uuid`s: how the frontend sends them (strings, bytes) is for the interfaces to parse.
//! Coordinates and statistics are slices of numbers, which the interfaces copy to typed arrays.
pub mod commands;
pub mod dto;
pub mod queries;
pub use commands::*;
pub use dto::*;
pub use queries::*;
+277
View File
@@ -0,0 +1,277 @@
//! The functions that read the state of the engine.
//!
//! The files are described by one ordered list of ids and one structure per file, so that the UI
//! can react to what changed only: after each command, its `Outcome` tells which structures to
//! (re)read or drop and whether the order changed. The trackpoints of a segment and the waypoints
//! of a file are in the same order as the coordinates of their buffers.
use uuid::Uuid;
use super::dto::*;
use crate::{self as engine, Engine, FileId, History};
/// Ids of the files in display order.
pub fn file_order<H: History>(engine: &Engine<H>) -> Vec<String> {
ids(engine.order())
}
/// Structure of a file, `None` if the id is unknown.
pub fn file_structure<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<FileStructure> {
engine
.file_structure(&FileId(file_id))
.map(FileStructure::new)
}
/// Global statistics of a file, `None` if the id is unknown.
pub fn file_statistics<H: History>(engine: &Engine<H>, file_id: Uuid) -> Option<GlobalStatistics> {
engine
.file_statistics(&FileId(file_id))
.map(|stats| GlobalStatistics::new(&stats))
}
/// The current selection.
pub fn selection<H: History>(engine: &Engine<H>) -> Selection {
engine.selection().into()
}
/// What was copied or cut and is waiting to be pasted.
pub fn clipboard<H: History>(engine: &Engine<H>) -> Option<Clipboard> {
engine.clipboard().map(Clipboard::new)
}
pub fn can_undo<H: History>(engine: &Engine<H>) -> bool {
engine.can_undo()
}
pub fn can_redo<H: History>(engine: &Engine<H>) -> bool {
engine.can_redo()
}
/// Whether the clipboard can be pasted with the current selection.
pub fn can_paste<H: History>(engine: &Engine<H>) -> bool {
engine.can_paste()
}
/// All the data of a waypoint of a file, `None` if it does not exist.
pub fn waypoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
waypoint_id: Uuid,
) -> Option<WaypointDetails> {
engine
.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
.map(WaypointDetails::new)
}
/// A trackpoint of a segment of a file, `None` if it does not exist.
pub fn trackpoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<TrackpointDetails> {
engine
.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
.map(TrackpointDetails::new)
}
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
/// trackpoints to keep.
pub fn export_file<H: History>(
engine: &Engine<H>,
file_id: Uuid,
options: engine::ExportOptions,
) -> Option<Vec<u8>> {
engine.export(&FileId(file_id), options)
}
/// The data that some files have, so that the ones that none has are not offered when exporting
/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the
/// OpenStreetMap data (32).
pub fn exportable_data<H: History>(engine: &Engine<H>, file_ids: &[Uuid]) -> u8 {
let ids: Vec<FileId> = file_ids.iter().copied().map(FileId).collect();
let data = engine.exportable_data(&ids);
[
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
}
/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance
/// (m) of `reduce` that keeps it: infinite for the ends of the segments.
pub fn reduction_distances<H: History>(engine: &Engine<H>) -> Vec<f64> {
engine.reduction_distances()
}
// Coordinates, as flat `[lng, lat, ...]` slices. Compare `rev` / `waypointsRev` of the file
// structures with the previous ones to know which ones actually changed.
/// Coordinates of the trackpoints of a segment (empty if the id is unknown).
pub fn segment_coordinates<H: History>(engine: &Engine<H>, segment_id: Uuid) -> &[f64] {
engine.segment_coordinates(&engine::TrackSegmentId(segment_id))
}
/// Coordinates of the waypoints of a file (empty if the id is unknown).
pub fn waypoint_coordinates<H: History>(engine: &Engine<H>, file_id: Uuid) -> &[f64] {
engine.waypoint_coordinates(&FileId(file_id))
}
// Statistics of the selection
/// Global statistics of the whole selection (the buffers below cover it).
pub fn selection_statistics<H: History>(engine: &Engine<H>) -> GlobalStatistics {
GlobalStatistics::new(&engine.statistics().global)
}
/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection,
/// as indexed by the buffers. `None` when the range is not in the selection.
pub fn slice_statistics<H: History>(
engine: &Engine<H>,
start: usize,
end: usize,
) -> Option<GlobalStatistics> {
engine
.statistics()
.slice(start, end)
.map(|stats| GlobalStatistics::new(&stats))
}
macro_rules! buffers {
($($name:ident),*) => {$(
pub fn $name<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().$name
}
)*};
}
// One entry per trackpoint of the selection.
buffers!(
total_distance,
elevation_gain,
elevation_loss,
lng,
lat,
ele
);
macro_rules! optional_buffers {
($($name:ident: $type:ty),*) => {$(
/// `None` when no trackpoint of the selection has the value, else an entry per trackpoint,
/// the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the first trackpoint
/// that has them for the cumulative distances and durations).
pub fn $name<H: History>(engine: &Engine<H>) -> Option<&[$type]> {
engine.statistics().$name.as_deref()
}
)*};
}
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
optional_buffers!(total_time: i64, moving_time: i64);
// The smoothed elevation segments of the selection, over which the slope is averaged, as
// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each
// segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`.
pub fn slope_segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().slope_segments.starts
}
pub fn slope_segment_slopes<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().slope_segments.slopes
}
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`
/// (numbered as in the buffers), `None` if there is none: for example the one that is hovered on
/// the elevation profile.
pub fn selection_point<H: History>(engine: &Engine<H>, index: usize) -> Option<SelectionPoint> {
let stats = engine.statistics();
if index >= stats.len() {
return None;
}
let categories = engine.categories();
let name = |intervals: &engine::Intervals, categories: &engine::Categories| {
let value = intervals.value_at(index);
let code = value.checked_sub(1)?;
categories.name(code).map(str::to_owned)
};
let segment = stats.slope_segments.at(index);
let finite = |value: f64| value.is_finite().then_some(value);
Some(SelectionPoint {
slope: finite(stats.slope[index]),
time: stats
.time
.as_ref()
.map(|time| time[index])
.filter(|time| *time != engine::NO_TIME)
.map(|time| time as f64),
slope_segment_slope: segment.and_then(|i| finite(stats.slope_segments.slopes[i])),
slope_segment_distance: segment.and_then(|i| finite(stats.slope_segments.distances[i])),
surface: name(&stats.surface, &categories.surface),
highway: name(&stats.highway, &categories.highway),
sac_scale: name(&stats.sac_scale, &categories.sac_scale),
mtb_scale: name(&stats.mtb_scale, &categories.mtb_scale),
})
}
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
// have the same value: `<name>_starts` holds the index of the first trackpoint of each interval,
// and `<name>_values` its value (0 when unknown, else 1 + the index in the names of the category).
// A value that is unknown for every trackpoint is a single interval.
macro_rules! categories {
($starts:ident, $values:ident, $names:ident, $field:ident) => {
pub fn $starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().$field.starts
}
pub fn $values<H: History>(engine: &Engine<H>) -> &[u8] {
&engine.statistics().$field.values
}
/// The names of the category, in the order of their codes. The list only grows: a name
/// keeps its index.
pub fn $names<H: History>(engine: &Engine<H>) -> &[String] {
engine.categories().$field.names()
}
};
}
categories!(surface_starts, surface_values, surfaces, surface);
categories!(highway_starts, highway_values, highways, highway);
categories!(sac_scale_starts, sac_scale_values, sac_scales, sac_scale);
categories!(mtb_scale_starts, mtb_scale_values, mtb_scales, mtb_scale);
// The anchors of the routing tool among the trackpoints of the selection (numbered as in the
// statistics). The ends of the segments are always anchors.
/// The index in the selection of each anchor.
pub fn anchor_indices<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.routing().anchor_indices
}
/// The lowest map zoom level at which each anchor is shown.
pub fn anchor_zooms<H: History>(engine: &Engine<H>) -> &[u8] {
&engine.routing().anchor_zooms
}
/// Index in the selection of the first trackpoint of each selected segment that has some.
pub fn segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.routing().segment_starts
}
/// The id of each selected segment that has trackpoints, as `segment_starts`.
pub fn segment_ids<H: History>(engine: &Engine<H>) -> Vec<String> {
engine
.routing()
.segment_ids
.iter()
.map(|id| id.0.to_string())
.collect()
}
/// Changes when the selected segments or their trackpoints change: the indices in the selection
/// are only valid while it stays the same.
pub fn routing_revision<H: History>(engine: &Engine<H>) -> u32 {
engine.routing().revision
}
File renamed without changes.
@@ -6,7 +6,7 @@
//! between them are kept: every part comes after the previous one in the new order, as late as the //! between them are kept: every part comes after the previous one in the new order, as late as the
//! original one came after the part that followed it. //! original one came after the part that followed it.
use crate::{Track, TrackSegment, Trackpoint}; use crate::{Route, Track, TrackSegment, Trackpoint};
/// The time of the first trackpoint of a segment. /// The time of the first trackpoint of a segment.
pub fn start_time(segment: &TrackSegment) -> Option<i64> { pub fn start_time(segment: &TrackSegment) -> Option<i64> {
@@ -94,6 +94,19 @@ pub fn reverse_segments(
} }
} }
/// Reverses the routes of a file, and their trackpoints, like the segments of a track (see
/// [`reverse_segments`]).
pub fn reverse_routes(routes: &mut [Route], original_next: Option<i64>, new_previous: Option<i64>) {
routes.reverse();
let (mut next, mut previous) = (original_next, new_previous);
for route in routes.iter_mut() {
let original_start = start_time(&route.seg);
reverse_segment(&mut route.seg, next, previous);
next = original_start;
previous = end_time(&route.seg);
}
}
/// Reverses the tracks of a file, and their segments and trackpoints. /// Reverses the tracks of a file, and their segments and trackpoints.
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) { pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
tracks.reverse(); tracks.reverse();
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+81
View File
@@ -0,0 +1,81 @@
use crate::{Link, Route, Track, TrackSegment, Waypoints};
use super::common::uuid_id;
uuid_id!(FileId);
#[derive(Debug, Default, Clone)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
/// The routes come before the tracks, as in GPX: everything that goes through the segments
/// of a file (statistics, the elevation profile...) takes the ones of the routes first.
pub rte: Vec<Route>,
pub trk: Vec<Track>,
pub wpt: Waypoints,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
pub author: Option<Author>,
pub links: Vec<Link>,
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Author {
pub name: Option<String>,
/// `id@domain`, which the file gives as the two attributes of an `email` element.
pub email: Option<String>,
/// The author has a single link, unlike the other elements.
pub link: Option<Link>,
}
/// Where a segment is in its file: the one of a route, or one of a track.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegmentIndex {
Route(usize),
Track { trk: usize, seg: usize },
}
impl File {
/// The segments of the routes, then the ones of the tracks.
pub fn segments(&self) -> impl Iterator<Item = &TrackSegment> {
self.rte
.iter()
.map(|rte| &rte.seg)
.chain(self.trk.iter().flat_map(|trk| &trk.trkseg))
}
/// Where the segments are, in the order of [`File::segments`].
pub fn segment_indices(&self) -> impl Iterator<Item = SegmentIndex> + '_ {
(0..self.rte.len())
.map(SegmentIndex::Route)
.chain(self.trk.iter().enumerate().flat_map(|(trk, track)| {
(0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg })
}))
}
/// The segment at `index`. Panics if there is none.
pub fn segment(&self, index: SegmentIndex) -> &TrackSegment {
match index {
SegmentIndex::Route(rte) => &self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg],
}
}
/// The segment at `index`. Panics if there is none.
pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment {
match index {
SegmentIndex::Route(rte) => &mut self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg],
}
}
/// Whether the file has no route nor track.
pub fn has_no_path(&self) -> bool {
self.rte.is_empty() && self.trk.is_empty()
}
}
@@ -3,6 +3,7 @@ mod chunk;
mod chunked; mod chunked;
mod common; mod common;
mod file; mod file;
mod route;
mod segment; mod segment;
mod track; mod track;
mod trackpoint; mod trackpoint;
@@ -14,6 +15,7 @@ pub use chunk::*;
pub use chunked::*; pub use chunked::*;
pub use common::*; pub use common::*;
pub use file::*; pub use file::*;
pub use route::*;
pub use segment::*; pub use segment::*;
pub use track::*; pub use track::*;
pub use trackpoint::*; pub use trackpoint::*;
+15
View File
@@ -0,0 +1,15 @@
use crate::{TrackInfo, TrackSegment};
use super::common::uuid_id;
uuid_id!(RouteId);
/// A route of the file (`rte`): like a track, but with a single segment, its points.
///
/// It has the information of a track: GPX gives the same to both.
#[derive(Debug, Default, Clone)]
pub struct Route {
pub id: RouteId,
pub info: TrackInfo,
pub seg: TrackSegment,
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
use crate::{ use crate::{
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories, Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint,
TrackpointChunk, Waypoint, WaypointChunk, distance, TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
}; };
use chrono::DateTime; use chrono::DateTime;
use quick_xml::Error; use quick_xml::Error;
@@ -18,6 +18,7 @@ enum GPXElement {
Author(Author), Author(Author),
Link(Link), Link(Link),
Text, Text,
/// A track, or a route, whose information is read the same way.
Track(Track), Track(Track),
Segment(TrackSegment), Segment(TrackSegment),
Trackpoint(Trackpoint), Trackpoint(Trackpoint),
@@ -396,7 +397,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
let mut stack: Vec<GPXElement> = vec![]; let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default(); let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default(); let mut wpt_chunk = WaypointChunk::default();
// Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed // The segment of a route is read by `RouteReader`. The points of the detailed
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read. // path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
let mut route = RouteReader::default(); let mut route = RouteReader::default();
let mut rpt_points: Vec<Trackpoint> = vec![]; let mut rpt_points: Vec<Trackpoint> = vec![];
@@ -530,12 +531,12 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
} }
} }
"rte" => { "rte" => {
if let Some(GPXElement::Track(mut trk)) = stack.pop() { if let Some(GPXElement::Track(trk)) = stack.pop() {
let trkseg = std::mem::take(&mut route).finish(); gpx.rte.push(Route {
if !trkseg.is_empty() { info: trk.info,
trk.trkseg.push(trkseg); seg: std::mem::take(&mut route).finish(),
} ..Default::default()
gpx.trk.push(trk); });
} }
} }
"rtept" => { "rtept" => {
@@ -740,38 +741,37 @@ mod tests {
} }
#[test] #[test]
fn test_parse_routes_as_tracks() { fn test_parse_routes() {
let gpx = parse_data("with_routes"); let gpx = parse_data("with_routes");
assert_eq!(gpx.trk.len(), 2); assert!(gpx.trk.is_empty());
let trk = &gpx.trk[0]; assert_eq!(gpx.rte.len(), 2);
assert_eq!(trk.info.name.as_deref(), Some("route 1")); let rte = &gpx.rte[0];
assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); assert_eq!(rte.info.name.as_deref(), Some("route 1"));
assert_eq!(trk.trkseg.len(), 1); assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
let trkseg = &trk.trkseg[0]; assert_eq!(rte.seg.len(), 49);
assert_eq!(trkseg.len(), 49); let trkpt = &rte.seg[0];
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0); assert_eq!(trkpt.ele, 109.0);
let trk = &gpx.trk[1]; let rte = &gpx.rte[1];
assert_eq!(trk.info.name.as_deref(), Some("route 2")); assert_eq!(rte.info.name.as_deref(), Some("route 2"));
assert_eq!(trk.trkseg[0].len(), 28); assert_eq!(rte.seg.len(), 28);
assert_ne!(gpx.rte[0].id, rte.id);
} }
#[test] #[test]
fn test_parse_route_point_extensions() { fn test_parse_route_point_extensions() {
let gpx = parse_data("with_route_extensions"); let gpx = parse_data("with_route_extensions");
// a route without points has no segment // a route without points has an empty segment
assert_eq!(gpx.trk.len(), 2); assert_eq!(gpx.rte.len(), 2);
assert!(gpx.trk[1].trkseg.is_empty()); assert!(gpx.rte[1].seg.is_empty());
let trk = &gpx.trk[0]; let rte = &gpx.rte[0];
assert_eq!(trk.info.name.as_deref(), Some("with extensions")); assert_eq!(rte.info.name.as_deref(), Some("with extensions"));
assert_eq!(trk.trkseg.len(), 1); let points: Vec<_> = rte.seg.iter().collect();
let points: Vec<_> = trk.trkseg[0].iter().collect();
// the route points are kept, the points of the detailed path follow the one they come with // the route points are kept, the points of the detailed path follow the one they come with
let coordinates: Vec<_> = points let coordinates: Vec<_> = points
.iter() .iter()
@@ -788,14 +788,13 @@ mod tests {
#[test] #[test]
fn test_route_points_are_the_anchors_and_nothing_else_is() { fn test_route_points_are_the_anchors_and_nothing_else_is() {
let gpx = parse_data("with_route_extensions"); let gpx = parse_data("with_route_extensions");
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]); assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]);
// no anchor is computed for the points of a route that has none in between // no anchor is computed for the points of a route that has none in between
let gpx = parse_data("with_routes"); let gpx = parse_data("with_routes");
for trk in &gpx.trk { for rte in &gpx.rte {
let segment = &trk.trkseg[0]; assert!(rte.seg.iter().all(|p| p.anchor == Some(0)));
assert!(segment.iter().all(|p| p.anchor == Some(0)));
} }
// while a track gets the anchors that its shape needs // while a track gets the anchors that its shape needs
let track = parse_data("with_tracks"); let track = parse_data("with_tracks");
@@ -807,14 +806,14 @@ mod tests {
#[test] #[test]
fn test_parse_the_points_of_the_detailed_path_of_a_route() { fn test_parse_the_points_of_the_detailed_path_of_a_route() {
let gpx = parse_data("with_route_points"); let gpx = parse_data("with_route_points");
assert_eq!(gpx.trk.len(), 1); assert_eq!(gpx.rte.len(), 1);
let trk = &gpx.trk[0]; let rte = &gpx.rte[0];
assert_eq!(trk.info.name.as_deref(), Some("Lausanne")); assert_eq!(rte.info.name.as_deref(), Some("Lausanne"));
assert_eq!(trk.info.cmt.as_deref(), Some("comment")); assert_eq!(rte.info.cmt.as_deref(), Some("comment"));
assert_eq!(trk.info.desc.as_deref(), Some("description")); assert_eq!(rte.info.desc.as_deref(), Some("description"));
assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
let points: Vec<_> = trk.trkseg[0].iter().cloned().collect(); let points: Vec<_> = rte.seg.iter().cloned().collect();
// 3 route points and 4 points between them, the repeated one being kept // 3 route points and 4 points between them, the repeated one being kept
assert_eq!(points.len(), 7); assert_eq!(points.len(), 7);
let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect(); let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect();
@@ -846,7 +845,7 @@ mod tests {
</rtept> </rtept>
</rte></gpx>"#; </rte></gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let points: Vec<_> = gpx.trk[0].trkseg[0].iter().cloned().collect(); let points: Vec<_> = gpx.rte[0].seg.iter().cloned().collect();
let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect(); let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect();
assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]); assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]);
// the route point is an anchor, and so is the end of the path // the route point is an anchor, and so is the end of the path
@@ -861,7 +860,7 @@ mod tests {
<gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension> <gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension>
</extensions></rtept></rte></gpx>"#; </extensions></rtept></rte></gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
assert_eq!(anchors, [Some(0), Some(0)]); assert_eq!(anchors, [Some(0), Some(0)]);
} }
@@ -930,14 +929,14 @@ mod tests {
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three")); assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
assert!(waypoints[1].links.is_empty()); assert!(waypoints[1].links.is_empty());
// tracks, and routes which are read as tracks // tracks, and routes
assert_eq!(gpx.trk.len(), 2); assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1));
assert_eq!( assert_eq!(
hrefs(&gpx.trk[0].info.links), hrefs(&gpx.trk[0].info.links),
["https://example.com/trk-1", "https://example.com/trk-2"] ["https://example.com/trk-1", "https://example.com/trk-2"]
); );
assert_eq!( assert_eq!(
hrefs(&gpx.trk[1].info.links), hrefs(&gpx.rte[0].info.links),
["https://example.com/rte-1", "https://example.com/rte-2"] ["https://example.com/rte-1", "https://example.com/rte-2"]
); );
} }
@@ -8,9 +8,6 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out. /// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`]. /// The same type tells which data a file has, see [`File::exportable_data`].
///
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions { pub struct ExportOptions {
pub time: bool, pub time: bool,
@@ -20,8 +17,6 @@ pub struct ExportOptions {
pub power: bool, pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale. /// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool, pub osm: bool,
/// Writes every segment as a route (`rte`) instead of a track.
pub as_route: bool,
} }
impl ExportOptions { impl ExportOptions {
@@ -36,7 +31,6 @@ impl ExportOptions {
atemp: value, atemp: value,
power: value, power: value,
osm: value, osm: value,
as_route: false,
} }
} }
@@ -49,7 +43,6 @@ impl ExportOptions {
atemp: self.atemp || other.atemp, atemp: self.atemp || other.atemp,
power: self.power || other.power, power: self.power || other.power,
osm: self.osm || other.osm, osm: self.osm || other.osm,
as_route: self.as_route || other.as_route,
} }
} }
} }
@@ -59,12 +52,7 @@ impl File {
/// options of [`write`] can leave out. /// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions { pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE; let mut data = ExportOptions::NONE;
for point in self for point in self.segments().flat_map(|seg| seg.iter()) {
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
data.time |= point.time.is_some(); data.time |= point.time.is_some();
data.hr |= point.hr.is_some(); data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some(); data.cad |= point.cad.is_some();
@@ -82,6 +70,10 @@ impl File {
} }
} }
/// The highest zoom level from which an anchor is shown that makes it a route point, see [`write`]:
/// the anchors that only appear when zooming in further are details of the path.
pub const ROUTE_POINT_MAX_ZOOM: u8 = 12;
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"; const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>; type XmlWriter = Writer<Vec<u8>>;
@@ -89,10 +81,10 @@ type XmlWriter = Writer<Vec<u8>>;
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are /// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
/// the names of their codes in `categories`. /// the names of their codes in `categories`.
/// ///
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a /// The routes are written as routes (`rte`): the anchors of their segment that are shown from the
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints /// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a /// two of them are the `gpxx:rpt` of the `rtept` before them (which only have a position, unlike
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`. /// the `rtept`). The first and last trackpoints always are `rtept`.
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> { pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4); let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail // writing to memory does not fail
@@ -100,9 +92,9 @@ pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOpti
writer.into_inner() writer.into_inner()
} }
fn write_file( fn write_file<'a>(
w: &mut XmlWriter, w: &mut XmlWriter,
file: &File, file: &'a File,
categories: &TrackpointCategories, categories: &TrackpointCategories,
options: ExportOptions, options: ExportOptions,
) -> io::Result<()> { ) -> io::Result<()> {
@@ -136,20 +128,18 @@ fn write_file(
for wpt in file.wpt.iter() { for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?; write_waypoint(w, wpt, options)?;
} }
for trk in &file.trk { // a single unnamed route or track is named like the file
// a single unnamed track is named like the file let alone = file.rte.len() + file.trk.len() == 1;
let name = match (&trk.info.name, file.trk.len()) { let name = |info: &'a crate::TrackInfo| match &info.name {
(Some(name), _) if !name.is_empty() => Some(name.as_str()), Some(name) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()), _ if alone => Some(file.info.name.as_str()),
_ => None, _ => None,
}; };
if options.as_route { for rte in &file.rte {
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) { write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?;
write_route(w, trk, segment, name, categories, options)?;
}
} else {
write_track(w, trk, name, categories, options)?;
} }
for trk in &file.trk {
write_track(w, trk, name(&trk.info), categories, options)?;
} }
Ok(()) Ok(())
})?; })?;
@@ -311,22 +301,28 @@ fn write_track(
fn write_route( fn write_route(
w: &mut XmlWriter, w: &mut XmlWriter,
trk: &Track, info: &crate::TrackInfo,
segment: &crate::TrackSegment, segment: &crate::TrackSegment,
name: Option<&str>, name: Option<&str>,
categories: &TrackpointCategories, categories: &TrackpointCategories,
options: ExportOptions, options: ExportOptions,
) -> io::Result<()> { ) -> io::Result<()> {
w.create_element("rte").write_inner_content(|w| { w.create_element("rte").write_inner_content(|w| {
write_track_info(w, name, &trk.info)?; write_track_info(w, name, info)?;
let points: Vec<&Trackpoint> = segment.iter().collect(); let points: Vec<&Trackpoint> = segment.iter().collect();
let last = points.len() - 1; let last = points.len().saturating_sub(1);
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some(); let is_route_point = |i: usize| {
i == 0
|| i == last
|| points[i]
.anchor
.is_some_and(|zoom| zoom <= ROUTE_POINT_MAX_ZOOM)
};
let mut i = 0; let mut i = 0;
while i < points.len() { while i < points.len() {
// the trackpoints up to the next anchor // the trackpoints up to the next route point
let mut next = i + 1; let mut next = i + 1;
while next < points.len() && !is_anchor(next) { while next < points.len() && !is_route_point(next) {
next += 1; next += 1;
} }
write_point( write_point(
@@ -455,9 +451,7 @@ mod tests {
} }
fn points(file: &File) -> Vec<Trackpoint> { fn points(file: &File) -> Vec<Trackpoint> {
file.trk file.segments()
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter().cloned()) .flat_map(|seg| seg.iter().cloned())
.collect() .collect()
} }
@@ -595,11 +589,20 @@ mod tests {
assert!(file.exportable_data().power); assert!(file.exportable_data().power);
} }
fn as_route() -> ExportOptions { /// The file with each segment of its tracks as a route, which has the information of its
ExportOptions { /// track.
as_route: true, fn as_routes(mut file: File) -> File {
..ExportOptions::ALL file.rte = std::mem::take(&mut file.trk)
} .into_iter()
.flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| crate::Route {
info: trk.info.clone(),
seg,
..Default::default()
})
})
.collect();
file
} }
fn count(text: &str, pattern: &str) -> usize { fn count(text: &str, pattern: &str) -> usize {
@@ -607,19 +610,17 @@ mod tests {
} }
#[test] #[test]
fn test_export_as_route_writes_routes_instead_of_tracks() { fn test_routes_are_written_with_their_route_points_and_their_path() {
let (file, categories) = read("with_route_points"); let (file, categories) = read("with_route_points");
let text = written_text(&file, &categories, as_route()); let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1); assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<trk>"), 0); assert_eq!(count(&text, "<trk>"), 0);
assert_eq!(count(&text, "<trkseg>"), 0);
assert_eq!(count(&text, "<trkpt"), 0); assert_eq!(count(&text, "<trkpt"), 0);
// the anchors are the route points, the trackpoints between them the points of their path // the anchors are the route points, the trackpoints between them the points of their path
assert_eq!(count(&text, "<rtept"), 3); assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), 4); assert_eq!(count(&text, "<gpxx:rpt"), 4);
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2); assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
// a route has the information of its track
assert!(text.contains("<name>Lausanne</name>")); assert!(text.contains("<name>Lausanne</name>"));
assert!(text.contains("<cmt>comment</cmt>")); assert!(text.contains("<cmt>comment</cmt>"));
assert!(text.contains("<desc>description</desc>")); assert!(text.contains("<desc>description</desc>"));
@@ -634,14 +635,14 @@ mod tests {
} }
#[test] #[test]
fn test_a_route_survives_being_exported_as_a_route() { fn test_a_route_survives_being_written() {
let (file, mut categories) = read("with_route_points"); let (file, mut categories) = read("with_route_points");
let written = write(&file, &categories, as_route()); let written = write(&file, &categories, ExportOptions::ALL);
let again = parse(&written, &mut categories).unwrap(); let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), 1); assert_eq!(again.rte.len(), 1);
assert_eq!(again.trk[0].info, file.trk[0].info); assert_eq!(again.rte[0].info, file.rte[0].info);
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]); let (a, b) = (&file.rte[0].seg, &again.rte[0].seg);
assert_eq!(a.len(), b.len()); assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) { for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(a.coordinates.lat, b.coordinates.lat); assert_eq!(a.coordinates.lat, b.coordinates.lat);
@@ -659,85 +660,121 @@ mod tests {
} }
#[test] #[test]
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() { fn test_the_anchors_shown_up_to_zoom_12_are_the_route_points() {
let (file, mut categories) = read("with_tracks_and_segments"); let (file, mut categories) = read("with_tracks_and_segments");
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum(); let file = as_routes(file);
assert!(segments > 2); assert!(file.rte.len() > 2);
let written = write(&file, &categories, as_route()); let written = write(&file, &categories, ExportOptions::ALL);
let text = String::from_utf8(written.clone()).unwrap();
assert_eq!(count(&text, "<rte>"), segments);
assert_eq!(count(&text, "<trk>"), 0);
// every route starts and ends with a route point
assert_eq!(count(&text, "</rte>"), segments);
let again = parse(&written, &mut categories).unwrap(); let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), segments); assert_eq!(again.rte.len(), file.rte.len());
let mut routes = again.trk.iter(); let anchors = |s: &crate::TrackSegment, max_zoom: u8| {
for track in &file.trk {
for segment in &track.trkseg {
let route = routes.next().unwrap();
// the information of the track, repeated for each of its segments
assert_eq!(route.info.name, track.info.name);
assert_eq!(route.info.type_, track.info.type_);
let (a, b) = (segment, &route.trkseg[0]);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(
(a.coordinates.lat, a.coordinates.lng),
(b.coordinates.lat, b.coordinates.lng)
);
}
// the anchors of the segment are the ones of its route
let anchors = |s: &crate::TrackSegment| {
s.iter() s.iter()
.enumerate() .enumerate()
.filter(|(_, p)| p.anchor.is_some()) .filter(|(_, p)| p.anchor.is_some_and(|zoom| zoom <= max_zoom))
.map(|(i, _)| i) .map(|(i, _)| i)
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
assert_eq!(anchors(a), anchors(b)); for (a, b) in file.rte.iter().zip(&again.rte) {
} assert_eq!(a.info.name, b.info.name);
assert_eq!(a.seg.len(), b.seg.len());
// the route points come back as anchors, the other anchors as points of the path
assert_eq!(
anchors(&a.seg, ROUTE_POINT_MAX_ZOOM),
anchors(&b.seg, u8::MAX)
);
} }
} }
#[test]
fn test_anchors_shown_from_a_higher_zoom_are_points_of_the_path() {
let (file, categories) = read("simple");
let mut file = as_routes(file);
let segment = &mut file.rte[0].seg;
segment.update_all(|_, point| point.anchor = None);
let len = segment.len();
segment.set_anchor(5, ROUTE_POINT_MAX_ZOOM);
segment.set_anchor(10, ROUTE_POINT_MAX_ZOOM + 1);
let text = written_text(&file, &categories, ExportOptions::ALL);
// the ends, and the anchor shown from zoom 12
assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), len - 3);
}
#[test] #[test]
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() { fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
let (mut file, categories) = read("simple"); let (file, categories) = read("simple");
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None); let mut file = as_routes(file);
let len = file.trk[0].trkseg[0].len(); file.rte[0].seg.update_all(|_, point| point.anchor = None);
let text = written_text(&file, &categories, as_route()); let len = file.rte[0].seg.len();
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rtept"), 2); assert_eq!(count(&text, "<rtept"), 2);
assert_eq!(count(&text, "<gpxx:rpt"), len - 2); assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
// and a single trackpoint is a route of one point // and a single trackpoint is a route of one point
file.trk[0].trkseg[0].splice(1, len, vec![]); file.rte[0].seg.splice(1, len, vec![]);
let text = written_text(&file, &categories, as_route()); let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rtept"), 1); assert_eq!(count(&text, "<rtept"), 1);
assert_eq!(count(&text, "<gpxx:rpt"), 0); assert_eq!(count(&text, "<gpxx:rpt"), 0);
} }
#[test] #[test]
fn test_empty_segments_are_not_routes() { fn test_routes_keep_their_style() {
let (mut file, categories) = read("simple");
file.trk[0].trkseg.push(crate::TrackSegment::default());
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rte>"), 1);
}
#[test]
fn test_routes_have_the_style_of_their_track() {
let (file, categories) = read("with_style"); let (file, categories) = read("with_style");
let text = written_text(&file, &categories, as_route()); let file = as_routes(file);
let text = written_text(&file, &categories, ExportOptions::ALL);
assert!(count(&text, "<gpx_style:line>") >= 1); assert!(count(&text, "<gpx_style:line>") >= 1);
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>")); assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
let again = parse(text.as_bytes(), &mut Default::default()).unwrap(); let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.trk[0].info.color, file.trk[0].info.color); assert_eq!(again.rte[0].info.color, file.rte[0].info.color);
assert_eq!(again.trk[0].info.width, file.trk[0].info.width); assert_eq!(again.rte[0].info.width, file.rte[0].info.width);
}
#[test]
fn test_routes_are_written_as_routes_before_the_tracks() {
let (mut file, categories) = read("with_tracks");
let (routes, _) = read("with_routes");
file.rte = routes.rte.clone();
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), routes.rte.len());
assert_eq!(count(&text, "<trk>"), file.trk.len());
assert!(text.rfind("</rte>").unwrap() < text.find("<trk>").unwrap());
let again = parse(text.as_bytes(), &mut categories.clone()).unwrap();
assert_eq!(again.rte.len(), file.rte.len());
assert_eq!(again.trk.len(), file.trk.len());
for (a, b) in file.rte.iter().zip(&again.rte) {
assert_eq!(a.info, b.info);
same_points(
&a.seg.iter().cloned().collect::<Vec<_>>(),
&b.seg.iter().cloned().collect::<Vec<_>>(),
);
}
same_points(&points(&file), &points(&again));
}
#[test]
fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() {
let mut file = File::default();
file.info.name = "file".into();
file.rte.push(crate::Route::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<rtept"), 0);
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.rte.len(), 1);
assert_eq!(again.rte[0].info.name.as_deref(), Some("file"));
// not when there are others
file.trk.push(Track::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
// the metadata only
assert_eq!(count(&text, "<name>file</name>"), 1);
} }
#[test] #[test]
fn test_route_points_keep_their_data_and_the_options_apply_to_them() { fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
let (file, categories) = read("with_hr"); let (file, categories) = read("with_hr");
let with_all = written_text(&file, &categories, as_route()); let file = as_routes(file);
let with_all = written_text(&file, &categories, ExportOptions::ALL);
assert!(with_all.contains("<gpxtpx:hr>")); assert!(with_all.contains("<gpxtpx:hr>"));
let without = written_text( let without = written_text(
&file, &file,
@@ -745,22 +782,10 @@ mod tests {
ExportOptions { ExportOptions {
hr: false, hr: false,
time: false, time: false,
as_route: true,
..ExportOptions::ALL ..ExportOptions::ALL
}, },
); );
assert!(!without.contains("<gpxtpx:hr>")); assert!(!without.contains("<gpxtpx:hr>"));
assert!(!without.contains("<time>")); assert!(!without.contains("<time>"));
} }
#[test]
fn test_the_route_option_is_not_data() {
let (file, _) = read("with_route_points");
assert!(!file.exportable_data().as_route);
// it is a way to write the data, never part of them
let all = std::hint::black_box(ExportOptions::ALL);
assert!(!all.as_route && !ExportOptions::NONE.as_route);
assert!(as_route().union(ExportOptions::NONE).as_route);
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
}
} }
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -36,4 +36,11 @@ pub enum Action<'a> {
file_ids: Vec<FileId>, file_ids: Vec<FileId>,
index: usize, index: usize,
}, },
/// Names a file in all the states of the history, as if it had always been called so (see
/// [`crate::History::rename`]). Not undoable: for a file that is given a name from the outside,
/// like the name of the file it is written to.
Rename {
file_id: FileId,
name: String,
},
} }
File renamed without changes.
+128
View File
@@ -0,0 +1,128 @@
use std::{collections::HashSet, rc::Rc};
use crate::{Apply, CommandError, FileId, Route, Selection, State, Track};
/// Converts the selected routes to tracks, or the selected tracks or segments to routes, in their
/// file:
///
/// - a route becomes a track with its segment. The new tracks go before the other tracks, right
/// after the routes;
/// - a track becomes one route per segment, as a route has a single segment, and goes away. A
/// selected segment becomes a route and leaves its track, which stays even if it has no segment
/// left. The routes have the information of their track, and go after the other routes.
///
/// The segments keep their ids, the new routes and tracks are selected. There is nothing to do
/// for the other selections, or for tracks without segments.
#[derive(Debug)]
pub struct Convert;
impl Apply for Convert {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
let next = match &selection {
Selection::Route { file_id, rte_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.rte)
.into_iter()
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
file.rte = kept;
let tracks: Vec<Track> = converted
.into_iter()
.map(|rte| Track {
id: Default::default(),
info: rte.info,
trkseg: vec![rte.seg],
})
.collect();
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
file.trk.splice(0..0, tracks);
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
Selection::Track { file_id, trk_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.trk)
.into_iter()
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
file.trk = kept;
let routes = converted.into_iter().flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| Route {
id: Default::default(),
info: trk.info.clone(),
seg,
})
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let (converted, kept) = std::mem::take(&mut trk.trkseg)
.into_iter()
.partition::<Vec<_>, _>(|seg| trkseg_ids.contains(&seg.id));
trk.trkseg = kept;
let info = trk.info.clone();
let routes = converted.into_iter().map(|seg| Route {
id: Default::default(),
info: info.clone(),
seg,
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::Empty
| Selection::File { .. }
| Selection::Waypoints { .. }
| Selection::Waypoint { .. } => return Err(CommandError::NothingToDo),
};
let empty = match &next {
Selection::Route { rte_ids, .. } => rte_ids.is_empty(),
Selection::Track { trk_ids, .. } => trk_ids.is_empty(),
_ => true,
};
if empty {
// nothing was converted (the changed copy of the file is dropped)
return Err(CommandError::NothingToDo);
}
*state.selection = next;
Ok(())
}
}
/// Adds the routes after the other routes, and gives the selection of them.
fn add_routes(
rte: &mut Vec<Route>,
file_id: FileId,
routes: impl Iterator<Item = Route>,
) -> Selection {
let start = rte.len();
rte.extend(routes);
Selection::Route {
file_id,
rte_ids: rte[start..].iter().map(|rte| rte.id).collect(),
}
}
@@ -1,4 +1,6 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected}; use crate::{
Apply, CommandError, Edit, Editor, File, Route, State, Track, TrackInfo, update_selected,
};
#[derive(Debug)] #[derive(Debug)]
pub struct Metadata<'a> { pub struct Metadata<'a> {
@@ -13,21 +15,33 @@ impl Apply for Metadata<'_> {
} }
} }
impl Metadata<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
info.name = Some(self.name.to_owned());
info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
impl Editor for Metadata<'_> { impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned()); file.info.desc = Some(self.desc.to_owned());
// a single track is named like the file // a single route or track is named like the file
if let [track] = file.trk.as_mut_slice() { match (file.rte.as_mut_slice(), file.trk.as_mut_slice()) {
track.info.name = Some(self.name.to_owned()); ([route], []) => route.info.name = Some(self.name.to_owned()),
([], [track]) => track.info.name = Some(self.name.to_owned()),
_ => {}
} }
Edit::Changed Edit::Changed
} }
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit { fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some(self.name.to_owned()); self.set(&mut track.info)
track.info.desc = Some(self.desc.to_owned());
Edit::Changed
} }
} }
@@ -45,12 +59,14 @@ mod tests {
New { New {
name: "first", name: "first",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
New { New {
name: "second", name: "second",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); // now selected .unwrap(); // now selected
@@ -108,6 +124,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -1,9 +1,13 @@
mod convert;
mod metadata; mod metadata;
mod new_route;
mod new_track; mod new_track;
mod new_track_segment; mod new_track_segment;
mod style; mod style;
pub use convert::*;
pub use metadata::*; pub use metadata::*;
pub use new_route::*;
pub use new_track::*; pub use new_track::*;
pub use new_track_segment::*; pub use new_track_segment::*;
pub use style::*; pub use style::*;
@@ -0,0 +1,55 @@
use crate::{Apply, CommandError, Edit, Editor, File, Route, State, update_selected};
use super::new_track::common_style;
/// Adds an empty route to the selected files, with the style that their routes and tracks agree
/// on (see [`crate::NewTrack`]).
#[derive(Debug)]
pub struct NewRoute;
impl Apply for NewRoute {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewRoute {
fn file(&mut self, file: &mut File) -> Edit {
file.rte.push(Route {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_route_has_the_style_of_the_tracks_and_an_empty_segment() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
crate::NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
file.trk[0].info.color = Some("ff0000".into());
NewRoute.apply(&mut fx.state()).unwrap();
NewRoute.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[0].id, file.rte[1].id);
assert!(file.rte[0].seg.is_empty());
assert_eq!(file.rte[1].info.color.as_deref(), Some("ff0000"));
}
}
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected}; use crate::{Apply, CommandError, Edit, Editor, File, State, Track, TrackInfo, update_selected};
#[derive(Debug)] #[derive(Debug)]
pub struct NewTrack; pub struct NewTrack;
@@ -12,20 +12,41 @@ impl Apply for NewTrack {
impl Editor for NewTrack { impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default(); file.trk.push(Track {
track.info.color = common(file, |t| &t.info.color); info: common_style(file),
track.info.opacity = common(file, |t| &t.info.opacity); ..Default::default()
track.info.width = common(file, |t| &t.info.width); });
file.trk.push(track);
Edit::Changed Edit::Changed
} }
} }
/// Returns the value of a style attribute if all existing tracks agree on it. /// The style that all the routes and tracks of the file agree on: each attribute is set if they
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> { /// all have the same value for it.
let mut tracks = file.trk.iter(); pub(super) fn common_style(file: &File) -> TrackInfo {
let first = get(tracks.next()?); let infos: Vec<&TrackInfo> = file
tracks.all(|t| get(t) == first).then(|| first.clone())? .rte
.iter()
.map(|rte| &rte.info)
.chain(file.trk.iter().map(|trk| &trk.info))
.collect();
TrackInfo {
color: common(&infos, |info| &info.color),
opacity: common(&infos, |info| &info.opacity),
width: common(&infos, |info| &info.width),
..Default::default()
}
}
/// Returns the value of a style attribute if all the infos agree on it.
fn common<T: Clone + PartialEq>(
infos: &[&TrackInfo],
get: impl Fn(&TrackInfo) -> &Option<T>,
) -> Option<T> {
let mut infos = infos.iter();
let first = get(infos.next()?);
infos
.all(|info| get(info) == first)
.then(|| first.clone())?
} }
#[cfg(test)] #[cfg(test)]
@@ -40,6 +61,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -56,6 +78,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -29,6 +29,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected}; use crate::{Apply, CommandError, Edit, Editor, Route, State, Track, TrackInfo, update_selected};
#[derive(Debug)] #[derive(Debug)]
pub struct Style<'a> { pub struct Style<'a> {
@@ -14,21 +14,31 @@ impl Apply for Style<'_> {
} }
} }
impl Editor for Style<'_> { impl Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit { fn set(&self, info: &mut TrackInfo) -> Edit {
if let Some(color) = self.color { if let Some(color) = self.color {
track.info.color = Some(color.to_owned()); info.color = Some(color.to_owned());
} }
if self.opacity.is_some() { if self.opacity.is_some() {
track.info.opacity = self.opacity; info.opacity = self.opacity;
} }
if self.width.is_some() { if self.width.is_some() {
track.info.width = self.width; info.width = self.width;
} }
Edit::Changed Edit::Changed
} }
} }
impl Editor for Style<'_> {
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
self.set(&mut track.info)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashSet; use std::collections::HashSet;
@@ -3,7 +3,7 @@ use std::rc::Rc;
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint}; use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements /// Deletes the selected elements. With `whole_files`, the files holding the selected elements
/// are deleted instead, even if only a track or a waypoint is selected. /// are deleted instead, even if only a route, a track or a waypoint is selected.
#[derive(Debug)] #[derive(Debug)]
pub struct Delete { pub struct Delete {
pub whole_files: bool, pub whole_files: bool,
@@ -23,6 +23,21 @@ impl Apply for Delete {
state.order.0.retain(|id| !file_ids.contains(id)); state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty Selection::Empty
} }
Selection::Route { file_id, rte_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.rte.len();
file.rte.retain(|rte| !rte_ids.contains(&rte.id));
if file.rte.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let file = state let file = state
.files .files
@@ -131,6 +146,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -265,6 +281,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -31,12 +31,14 @@ mod tests {
crate::New { crate::New {
name: "a", name: "a",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
crate::New { crate::New {
name: "b", name: "b",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -1,8 +1,8 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment, Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_route,
copy_track, copy_waypoint, copy_segment, copy_track, copy_waypoint,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -35,6 +35,30 @@ impl Apply for Duplicate {
state.order.0.splice(at..at, order); state.order.0.splice(at..at, order);
Selection::File { file_ids: copies } Selection::File { file_ids: copies }
} }
Selection::Route { file_id, rte_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.rte,
|rte| rte_ids.contains(&rte.id),
|rte| {
let copy = copy_route(rte);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Route {
file_id: *file_id,
rte_ids: copies,
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let file = state let file = state
.files .files
@@ -193,6 +217,7 @@ mod tests {
crate::New { crate::New {
name, name,
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -9,7 +9,7 @@ pub struct Load<'a> {
} }
impl Load<'_> { impl Load<'_> {
fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> { pub(crate) fn parse(self, categories: &mut TrackpointCategories) -> Result<File, CommandError> {
let mut file = parse(self.data, categories) let mut file = parse(self.data, categories)
.map_err(|err| CommandError::InvalidData(err.to_string()))?; .map_err(|err| CommandError::InvalidData(err.to_string()))?;
if file.info.name.trim().is_empty() { if file.info.name.trim().is_empty() {
@@ -85,6 +85,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -140,6 +141,7 @@ mod tests {
crate::New { crate::New {
name: "before", name: "before",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -189,6 +191,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -6,6 +6,7 @@ mod load;
mod move_elements; mod move_elements;
mod new; mod new;
mod paste; mod paste;
mod sync;
mod transfer; mod transfer;
pub use delete::*; pub use delete::*;
@@ -16,4 +17,5 @@ pub use load::*;
pub use move_elements::*; pub use move_elements::*;
pub use new::*; pub use new::*;
pub use paste::*; pub use paste::*;
pub use sync::*;
pub use transfer::*; pub use transfer::*;
@@ -9,6 +9,8 @@ use crate::{
pub enum MoveTarget { pub enum MoveTarget {
/// The list of files. /// The list of files.
Files { index: usize }, Files { index: usize },
/// The routes of a file.
Routes { file_id: FileId, index: usize },
/// The tracks of a file. /// The tracks of a file.
Tracks { file_id: FileId, index: usize }, Tracks { file_id: FileId, index: usize },
/// The segments of a track. /// The segments of a track.
@@ -26,9 +28,10 @@ pub enum MoveTarget {
/// ///
/// What can go where: /// What can go where:
/// - files: among the files; /// - files: among the files;
/// - tracks: among the files (each track becomes a file), or among the tracks of a file; /// - routes and tracks: among the files (each one becomes a file), or among the routes or the
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each /// tracks of a file, where they keep their kind (see [`Destination`]);
/// segment becomes a track), or among the segments of a track; /// - segments: among the files (each segment becomes a file), among the routes or the tracks of a
/// file (each segment becomes a route or a track), or among the segments of a track;
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a /// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
/// file. /// file.
#[derive(Debug)] #[derive(Debug)]
@@ -41,6 +44,9 @@ impl From<MoveTarget> for Destination {
fn from(target: MoveTarget) -> Self { fn from(target: MoveTarget) -> Self {
match target { match target {
MoveTarget::Files { index } => Destination::Files(Place::Index(index)), MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
MoveTarget::Routes { file_id, index } => {
Destination::Routes(file_id, Place::Index(index))
}
MoveTarget::Tracks { file_id, index } => { MoveTarget::Tracks { file_id, index } => {
Destination::Tracks(file_id, Place::Index(index)) Destination::Tracks(file_id, Place::Index(index))
} }
@@ -1,12 +1,16 @@
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce}; use crate::{
Apply, CommandError, File, LngLat, Route, State, Track, TrackSegment, Trackpoint, produce,
};
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a /// Creates an empty file and selects it. With `route`, the file starts with a route, which is
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single /// empty. With a `trackpoint`, the file starts with a segment that holds it, the one of its route
/// undo step. /// with `route` or of a track otherwise: it is how the routing tool starts a route on an empty map,
/// in a single undo step.
#[derive(Debug)] #[derive(Debug)]
pub struct New<'a> { pub struct New<'a> {
pub name: &'a str, pub name: &'a str,
pub trackpoint: Option<NewTrackpoint>, pub trackpoint: Option<NewTrackpoint>,
pub route: bool,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -21,8 +25,8 @@ impl Apply for New<'_> {
produce(state, |_| { produce(state, |_| {
let mut file = File::default(); let mut file = File::default();
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
if let Some(point) = self.trackpoint {
let mut segment = TrackSegment::default(); let mut segment = TrackSegment::default();
if let Some(point) = self.trackpoint {
// the ends of a segment are anchors of the routing tool // the ends of a segment are anchors of the routing tool
segment.splice( segment.splice(
0, 0,
@@ -36,6 +40,13 @@ impl Apply for New<'_> {
..Default::default() ..Default::default()
}], }],
); );
}
if self.route {
file.rte.push(Route {
seg: segment,
..Default::default()
});
} else if self.trackpoint.is_some() {
file.trk.push(Track { file.trk.push(Track {
trkseg: vec![segment], trkseg: vec![segment],
..Default::default() ..Default::default()
@@ -59,7 +70,8 @@ mod tests {
assert!( assert!(
New { New {
name: "new", name: "new",
trackpoint: None trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.is_ok() .is_ok()
@@ -83,6 +95,7 @@ mod tests {
New { New {
name: "new", name: "new",
trackpoint: Some(point), trackpoint: Some(point),
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -98,4 +111,38 @@ mod tests {
assert_eq!(segment[0].anchor, Some(0)); assert_eq!(segment[0].anchor, Some(0));
assert_eq!(fx.selected_files(), [file.id].into()); assert_eq!(fx.selected_files(), [file.id].into());
} }
#[test]
fn test_new_with_a_route() {
let mut fx = Fixture::default();
New {
name: "new",
trackpoint: None,
route: true,
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert!(file.rte[0].seg.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
// the trackpoint goes to the route
let mut fx = Fixture::default();
New {
name: "new",
trackpoint: Some(NewTrackpoint {
lng: 1.0,
lat: 2.0,
ele: 3.0,
}),
route: true,
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(file.rte[0].seg.len(), 1);
assert_eq!(file.rte[0].seg[0].anchor, Some(0));
}
} }
@@ -1,18 +1,21 @@
use std::{collections::HashSet, hash::Hash}; use std::{collections::HashSet, hash::Hash};
use crate::{ use crate::{
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId, Apply, ClipboardContent, CommandError, Destination, FileId, Place, RouteId, Selection, State,
TrackSegmentId, WaypointId, transfer, waypoint_ids, TrackId, TrackSegmentId, WaypointId, transfer, waypoint_ids,
}; };
/// Pastes what was copied or cut (see [`crate::Clipboard`]). /// Pastes what was copied or cut (see [`crate::Clipboard`]).
/// ///
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]: /// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
/// - nothing selected: the files are copied, and tracks and segments become new files (named /// - nothing selected: the files are copied, and routes, tracks and segments become new files
/// like their track, or like the file they come from); /// (named like their route or track, or like the file they come from);
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are /// - a file: the routes and tracks are added to it, segments become new tracks in it, and the
/// added to its own; /// waypoints are added to its own;
/// - a track: the segments are added to it, tracks go after it in its file; /// - a route: routes, and segments as new routes, go after it in its file, tracks go before the
/// other tracks (right after the routes);
/// - a track: the segments are added to it, tracks go after it in its file, and routes after the
/// other routes;
/// - a segment: the segments go after it in its track; /// - a segment: the segments go after it in its track;
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint. /// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
/// ///
@@ -46,6 +49,14 @@ impl Apply for Paste {
} }
} }
fn route_ids_of(state: &State, file_id: FileId) -> Vec<RouteId> {
state
.files
.get(&file_id)
.map(|file| file.rte.iter().map(|rte| rte.id).collect())
.unwrap_or_default()
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> { fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
state state
.files .files
@@ -79,14 +90,34 @@ fn destination(state: &State, content: &ClipboardContent) -> Result<Destination,
}; };
Ok(match (content, &*state.selection) { Ok(match (content, &*state.selection) {
(ClipboardContent::Files(_), _) => Destination::Files(Place::End), (ClipboardContent::Files(_), _) => Destination::Files(Place::End),
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => { (
Destination::Files(Place::End) ClipboardContent::Routes(_)
| ClipboardContent::Tracks(_)
| ClipboardContent::Segments(_),
Selection::Empty,
) => Destination::Files(Place::End),
(ClipboardContent::Routes(_), Selection::File { file_ids }) => {
Destination::Routes(last_file(file_ids)?, Place::End)
}
// routes and tracks keep their kind (see `Destination`)
(
ClipboardContent::Routes(_)
| ClipboardContent::Tracks(_)
| ClipboardContent::Segments(_),
Selection::Route { file_id, rte_ids },
) => {
let last =
last_selected(route_ids_of(state, *file_id).into_iter(), rte_ids).ok_or(nothing)?;
Destination::Routes(*file_id, Place::After(last))
} }
( (
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
Selection::File { file_ids }, Selection::File { file_ids },
) => Destination::Tracks(last_file(file_ids)?, Place::End), ) => Destination::Tracks(last_file(file_ids)?, Place::End),
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => { (
ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
Selection::Track { file_id, trk_ids },
) => {
let last = let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?; last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Tracks(*file_id, Place::After(last)) Destination::Tracks(*file_id, Place::After(last))
+155
View File
@@ -0,0 +1,155 @@
use std::rc::Rc;
use crate::{Apply, CommandError, FileId, Load, State};
/// A file that comes from the outside (the file system), under the identity it has here.
#[derive(Debug)]
pub struct ExternalFile<'a> {
pub id: FileId,
pub data: &'a [u8],
/// Name of the file outside (the name of the file on the disk, without its extension), which
/// is its name here whatever the data says: a file has a single name.
pub name: &'a str,
}
/// Brings changes of the files from the outside in, as a single command (one undo step): the files
/// that are given replace the ones of the same id (which stay where they are in the order, and keep
/// their id), or are added at the end if there are none; the ones to `remove` are deleted.
///
/// Unlike [`crate::LoadFiles`], the files are named as they are outside (see [`ExternalFile`]), and
/// the selection is left alone (what it holds of a file that was
/// replaced or deleted is dropped by the engine afterwards, as with any edit).
///
/// The files that cannot be read are skipped, and the files that are here stay as they are. It is an
/// error when nothing could be done.
#[derive(Debug, Default)]
pub struct SyncFiles<'a> {
pub files: Vec<ExternalFile<'a>>,
pub remove: Vec<FileId>,
}
impl Apply for SyncFiles<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let mut changed = false;
let mut first_error = None;
for external in self.files {
let load = Load {
data: external.data,
name: external.name,
};
match load.parse(state.categories) {
Ok(mut file) => {
file.id = external.id;
if !external.name.trim().is_empty() {
file.info.name = external.name.to_owned();
}
if state.files.insert(external.id, Rc::new(file)).is_none() {
state.order.0.push(external.id);
}
changed = true;
}
Err(error) => {
first_error.get_or_insert(error);
}
}
}
for id in self.remove {
if state.files.remove(&id).is_some() {
state.order.0.retain(|other| *other != id);
changed = true;
}
}
if changed {
Ok(())
} else {
Err(first_error.unwrap_or(CommandError::NothingToDo))
}
}
}
#[cfg(test)]
mod tests {
use crate::engine::command::fixture::Fixture;
use crate::{New, Selection};
use super::*;
const GPX: &[u8] = br#"<gpx version="1.1"><metadata><name>from disk</name></metadata></gpx>"#;
fn new_file(fx: &mut Fixture, name: &str) -> FileId {
New {
name,
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
*fx.selected_files().iter().next().unwrap()
}
fn external(id: FileId) -> ExternalFile<'static> {
ExternalFile {
id,
data: GPX,
name: "name",
}
}
#[test]
fn test_sync_replaces_a_file_in_place() {
let mut fx = Fixture::default();
let first = new_file(&mut fx, "first");
let second = new_file(&mut fx, "second");
SyncFiles {
files: vec![external(first)],
remove: vec![],
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, [first, second]);
// named as it is outside, not as the data says
assert_eq!(fx.files[&first].info.name, "name");
assert_eq!(fx.files[&first].id, first);
// the selection is not changed
assert_eq!(fx.selected_files(), [second].into());
}
#[test]
fn test_sync_adds_and_removes_files() {
let mut fx = Fixture::default();
let first = new_file(&mut fx, "first");
let added = FileId::default();
SyncFiles {
files: vec![external(added)],
remove: vec![first],
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, [added]);
assert!(!fx.files.contains_key(&first));
// what was selected is gone
assert!(matches!(fx.selection, Selection::File { .. }));
}
#[test]
fn test_sync_with_nothing_to_do_or_invalid_data_is_an_error() {
let mut fx = Fixture::default();
assert_eq!(
SyncFiles::default().apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
let invalid = SyncFiles {
files: vec![ExternalFile {
id: FileId::default(),
data: b"<gpx><trk></gpx>",
name: "x",
}],
remove: vec![],
};
assert!(matches!(
invalid.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert!(fx.files.is_empty());
}
}
@@ -5,9 +5,9 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection, ClipboardContent, ClipboardRoute, ClipboardSegment, ClipboardTrack, CommandError, File, FileId,
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file, Route, RouteId, Selection, State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
copy_segment, copy_track, copy_waypoint, WaypointId, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
}; };
/// Where elements are put, among the others of the same list. /// Where elements are put, among the others of the same list.
@@ -37,15 +37,20 @@ impl<T: Copy + PartialEq> Place<T> {
/// Where elements go. What can go where: /// Where elements go. What can go where:
/// - files: among the files; /// - files: among the files;
/// - routes: among the files (each route becomes a file, named like the route, or like the file
/// it comes from), or among the routes of a file. Among the tracks of a file, they stay routes,
/// which come before the tracks: they go after the other routes;
/// - tracks: among the files (each track becomes a file, named like the track, or like the file /// - tracks: among the files (each track becomes a file, named like the track, or like the file
/// it comes from), or among the tracks of a file; /// it comes from), or among the tracks of a file. Among the routes of a file, they stay tracks,
/// which come after the routes: they go before the other tracks;
/// - segments: among the files (each segment becomes a file with a track, named like its track, /// - segments: among the files (each segment becomes a file with a track, named like its track,
/// or like its file), among the tracks of a file (each segment becomes a track), or among the /// or like its file), among the routes or the tracks of a file (each segment becomes a route or
/// segments of a track; /// a track), or among the segments of a track;
/// - waypoints: among the waypoints of a file. /// - waypoints: among the waypoints of a file.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum Destination { pub enum Destination {
Files(Place<FileId>), Files(Place<FileId>),
Routes(FileId, Place<RouteId>),
Tracks(FileId, Place<TrackId>), Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>), Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>), Waypoints(FileId, Place<WaypointId>),
@@ -93,6 +98,14 @@ pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
state.files.retain(|id, _| !ids.contains(id)); state.files.retain(|id, _| !ids.contains(id));
state.order.0.retain(|id| !ids.contains(id)); state.order.0.retain(|id| !ids.contains(id));
} }
ClipboardContent::Routes(routes) => {
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.route.id).collect();
for file in state.files.values_mut() {
if file.rte.iter().any(|rte| ids.contains(&rte.id)) {
Rc::make_mut(file).rte.retain(|rte| !ids.contains(&rte.id));
}
}
}
ClipboardContent::Tracks(tracks) => { ClipboardContent::Tracks(tracks) => {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect(); let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
for file in state.files.values_mut() { for file in state.files.values_mut() {
@@ -141,7 +154,9 @@ pub fn transfer(
// the destination has to be there, before anything is changed // the destination has to be there, before anything is changed
let exists = match to { let exists = match to {
Destination::Files(_) => true, Destination::Files(_) => true,
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id), Destination::Routes(file_id, _) | Destination::Tracks(file_id, _) => {
state.files.contains_key(&file_id)
}
Destination::Segments(file_id, trk_id, _) => state Destination::Segments(file_id, trk_id, _) => state
.files .files
.get(&file_id) .get(&file_id)
@@ -152,17 +167,21 @@ pub fn transfer(
(content, to), (content, to),
(ClipboardContent::Files(_), Destination::Files(_)) (ClipboardContent::Files(_), Destination::Files(_))
| ( | (
ClipboardContent::Tracks(_), ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
Destination::Files(_) | Destination::Tracks(..) Destination::Files(_) | Destination::Routes(..) | Destination::Tracks(..)
) )
| ( | (
ClipboardContent::Segments(_), ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..), Destination::Files(_)
| Destination::Routes(..)
| Destination::Tracks(..)
| Destination::Segments(..),
) )
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) | (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
); );
let empty = match content { let empty = match content {
ClipboardContent::Files(files) => files.is_empty(), ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Routes(routes) => routes.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(), ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(), ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(), ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
@@ -189,6 +208,7 @@ pub fn transfer(
.collect(); .collect();
Ok(add_files(state, files, place)) Ok(add_files(state, files, place))
} }
(ClipboardContent::Routes(routes), to) => transfer_routes(state, routes, copy, to),
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to), (ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to), (ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => { (ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
@@ -198,6 +218,89 @@ pub fn transfer(
} }
} }
/// Puts the routes among the routes of a file, at `place`, and gives the selection of them.
fn insert_routes(
state: &mut State,
file_id: FileId,
place: Place<RouteId>,
routes: Vec<Route>,
) -> Result<Selection, CommandError> {
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<RouteId> = file.rte.iter().map(|rte| rte.id).collect();
let at = place.position(&existing);
file.rte.splice(at..at, routes);
Ok(Selection::Route {
file_id,
rte_ids: ids,
})
}
fn transfer_routes(
state: &mut State,
routes: &[ClipboardRoute],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let routes: Vec<(Route, &str)> = routes
.iter()
.map(|clip| {
let route = if copy {
copy_route(&clip.route)
} else {
clip.route.clone()
};
(route, clip.file_name.as_str())
})
.collect();
match to {
Destination::Files(place) => {
let files = routes
.into_iter()
.map(|(route, file_name)| {
let name = route
.info
.name
.clone()
.unwrap_or_else(|| file_name.to_owned());
Rc::new(File {
rte: vec![route],
..new_file(&name, vec![])
})
})
.collect();
Ok(add_files(state, files, place))
}
Destination::Routes(file_id, place) => {
let routes = routes.into_iter().map(|(route, _)| route).collect();
insert_routes(state, file_id, place, routes)
}
Destination::Tracks(file_id, _) => {
let routes = routes.into_iter().map(|(route, _)| route).collect();
insert_routes(state, file_id, Place::End, routes)
}
_ => Err(CommandError::NothingToDo),
}
}
/// Puts the tracks among the tracks of a file, at `place`, and gives the selection of them.
fn insert_tracks(
state: &mut State,
file_id: FileId,
place: Place<TrackId>,
tracks: Vec<Track>,
) -> Result<Selection, CommandError> {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
fn transfer_tracks( fn transfer_tracks(
state: &mut State, state: &mut State,
tracks: &[ClipboardTrack], tracks: &[ClipboardTrack],
@@ -231,16 +334,12 @@ fn transfer_tracks(
Ok(add_files(state, files, place)) Ok(add_files(state, files, place))
} }
Destination::Tracks(file_id, place) => { Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect(); let tracks = tracks.into_iter().map(|(track, _)| track).collect();
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect(); insert_tracks(state, file_id, place, tracks)
let file = file_mut(state, file_id)?; }
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect(); Destination::Routes(file_id, _) => {
let at = place.position(&existing); let tracks = tracks.into_iter().map(|(track, _)| track).collect();
file.trk.splice(at..at, tracks); insert_tracks(state, file_id, Place::Index(0), tracks)
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
} }
_ => Err(CommandError::NothingToDo), _ => Err(CommandError::NothingToDo),
} }
@@ -276,20 +375,22 @@ fn transfer_segments(
.collect(); .collect();
Ok(add_files(state, files, place)) Ok(add_files(state, files, place))
} }
Destination::Routes(file_id, place) => {
let routes = segments
.into_iter()
.map(|(seg, _)| Route {
seg,
..Default::default()
})
.collect();
insert_routes(state, file_id, place, routes)
}
Destination::Tracks(file_id, place) => { Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = segments let tracks = segments
.into_iter() .into_iter()
.map(|(segment, _)| own_track(segment)) .map(|(segment, _)| own_track(segment))
.collect(); .collect();
let ids = tracks.iter().map(|trk| trk.id).collect(); insert_tracks(state, file_id, place, tracks)
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
} }
Destination::Segments(file_id, trk_id, place) => { Destination::Segments(file_id, trk_id, place) => {
let segments: Vec<TrackSegment> = let segments: Vec<TrackSegment> =
@@ -4,6 +4,8 @@ mod file;
#[cfg(test)] #[cfg(test)]
pub(crate) mod fixture; pub(crate) mod fixture;
mod pattern; mod pattern;
#[cfg(test)]
mod routes_tests;
mod tools; mod tools;
pub use apply::*; pub use apply::*;
@@ -24,19 +26,22 @@ pub enum Command<'a> {
New(New<'a>), New(New<'a>),
Load(Load<'a>), Load(Load<'a>),
LoadFiles(LoadFiles<'a>), LoadFiles(LoadFiles<'a>),
SyncFiles(SyncFiles<'a>),
Duplicate(Duplicate), Duplicate(Duplicate),
Delete(Delete), Delete(Delete),
Paste(Paste), Paste(Paste),
Move(Move), Move(Move),
DeleteAll(DeleteAll), DeleteAll(DeleteAll),
DeleteWaypoint(DeleteWaypoint), DeleteWaypoint(DeleteWaypoint),
Convert(Convert),
Metadata(Metadata<'a>), Metadata(Metadata<'a>),
Style(Style<'a>), Style(Style<'a>),
NewRoute(NewRoute),
NewTrack(NewTrack), NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment), NewTrackSegment(NewTrackSegment),
Reverse(Reverse), Reverse(Reverse),
RoundTrip(RoundTrip), RoundTrip(RoundTrip),
Route(Route<'a>), Reroute(Reroute<'a>),
NewWaypoint(NewWaypoint<'a>), NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>), EditWaypoint(EditWaypoint<'a>),
MoveWaypoint(MoveWaypoint), MoveWaypoint(MoveWaypoint),
@@ -58,19 +63,22 @@ impl Apply for Command<'_> {
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::LoadFiles(c) => c.apply(state),
Command::SyncFiles(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),
Command::Move(c) => c.apply(state), Command::Move(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state),
Command::DeleteWaypoint(c) => c.apply(state), Command::DeleteWaypoint(c) => c.apply(state),
Command::Convert(c) => c.apply(state),
Command::Metadata(c) => c.apply(state), Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state), Command::Style(c) => c.apply(state),
Command::NewRoute(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state), Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state), Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state), Command::Reverse(c) => c.apply(state),
Command::RoundTrip(c) => c.apply(state), Command::RoundTrip(c) => c.apply(state),
Command::Route(c) => c.apply(state), Command::Reroute(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state), Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state), Command::EditWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state), Command::MoveWaypoint(c) => c.apply(state),
@@ -1,4 +1,4 @@
use crate::{File, Track, TrackSegment, Waypoint}; use crate::{File, Route, Track, TrackSegment, Waypoint};
// The trackpoints of a segment are shared chunks, so copying elements is cheap. // The trackpoints of a segment are shared chunks, so copying elements is cheap.
@@ -18,10 +18,20 @@ pub fn copy_track(track: &Track) -> Track {
} }
} }
/// A copy of the file and of its tracks and segments, with new ids. /// A copy of the route and of its segment, with new ids.
pub fn copy_route(route: &Route) -> Route {
Route {
id: Default::default(),
seg: copy_segment(&route.seg),
..route.clone()
}
}
/// A copy of the file and of its routes, tracks and segments, with new ids.
pub fn copy_file(file: &File) -> File { pub fn copy_file(file: &File) -> File {
File { File {
id: Default::default(), id: Default::default(),
rte: file.rte.iter().map(copy_route).collect(),
trk: file.trk.iter().map(copy_track).collect(), trk: file.trk.iter().map(copy_track).collect(),
..file.clone() ..file.clone()
} }
@@ -18,7 +18,7 @@ pub fn update_segments(
let file = files let file = files
.entry(location.file_id) .entry(location.file_id)
.or_insert_with(|| (*state.files[&location.file_id]).clone()); .or_insert_with(|| (*state.files[&location.file_id]).clone());
f(i, &mut file.trk[location.trk].trkseg[location.seg]); f(i, file.segment_mut(location.index));
} }
for (file_id, file) in files { for (file_id, file) in files {
state.files.insert(file_id, Rc::new(file)); state.files.insert(file_id, Rc::new(file));
@@ -31,6 +31,8 @@ mod tests {
use crate::engine::command::fixture::Fixture; use crate::engine::command::fixture::Fixture;
use crate::SegmentIndex;
use super::*; use super::*;
fn fixture() -> (Fixture, FileId) { fn fixture() -> (Fixture, FileId) {
@@ -49,13 +51,11 @@ mod tests {
let locations = [ let locations = [
SegmentLocation { SegmentLocation {
file_id: id, file_id: id,
trk: 1, index: SegmentIndex::Track { trk: 1, seg: 0 },
seg: 0,
}, },
SegmentLocation { SegmentLocation {
file_id: id, file_id: id,
trk: 0, index: SegmentIndex::Track { trk: 0, seg: 0 },
seg: 0,
}, },
]; ];
let mut calls = vec![]; let mut calls = vec![];
@@ -1,6 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint}; use crate::{File, FileId, Files, Route, Selection, State, Track, TrackSegment, Waypoint};
/// What an [`Editor`] hook did to the element it was given. /// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -21,13 +21,19 @@ impl Edit {
/// Independent edit of each selected element, without needing the rest of the state. /// Independent edit of each selected element, without needing the rest of the state.
/// ///
/// A selected file, track or segment is passed to the hook of its own level. By default a /// A selected file, route, track or segment is passed to the hook of its own level. By default
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides /// a file is forwarded to its routes and tracks, and a route or a track to its segments, so an
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment). /// editor only overrides the level(s) it cares about (metadata: file, route and track, style:
/// Waypoints are only edited when selected, never through their file. /// route and track, reverse: segment). Waypoints are only edited when selected, never through
/// their file.
pub trait Editor { pub trait Editor {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
edit_each(&mut file.trk, |trk| self.track(trk)) let routes = edit_each(&mut file.rte, |rte| self.route(rte));
routes.merge(edit_each(&mut file.trk, |trk| self.track(trk)))
}
fn route(&mut self, route: &mut Route) -> Edit {
edit_segment(self, &mut route.seg)
} }
fn track(&mut self, track: &mut Track) -> Edit { fn track(&mut self, track: &mut Track) -> Edit {
@@ -95,7 +101,7 @@ fn edit_waypoints<E: Editor + ?Sized>(
} }
} }
/// Applies the editor to every selected file, track or segment. /// Applies the editor to every selected file, route, track or segment.
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) { pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
match &*state.selection { match &*state.selection {
Selection::File { file_ids } => { Selection::File { file_ids } => {
@@ -103,6 +109,15 @@ pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
update_file(state.files, *id, |file| editor.file(file)); update_file(state.files, *id, |file| editor.file(file));
} }
} }
Selection::Route { file_id, rte_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.rte,
|rte| rte_ids.contains(&rte.id),
|rte| editor.route(rte),
)
});
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
update_file(state.files, *file_id, |file| { update_file(state.files, *file_id, |file| {
edit_where( edit_where(
+684
View File
@@ -0,0 +1,684 @@
//! The commands on routes: they work as on tracks, a route being a track of a single segment.
use std::rc::Rc;
use crate::{
Apply, Clean, CleanType, Clipboard, ClipboardContent, CommandError, Convert, Crop, Delete,
Duplicate, Extract, File, FileId, LngLat, LngLatBounds, Merge, MergeType, Metadata, Move,
MoveTarget, Paste, Reverse, RouteId, SegmentIndex, Selection, Split, SplitType, Style,
engine::command::fixture::Fixture, parse,
};
/// A file with the 2 routes of `with_routes` and the 2 tracks (of 2 segments) of
/// `with_tracks_and_segments`, which is the only file and is in the order.
fn fixture() -> (Fixture, FileId) {
let read = |name: &str| {
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
parse(&data, &mut Default::default()).unwrap()
};
let mut file = read("with_tracks_and_segments");
file.rte = read("with_routes").rte;
assert_eq!((file.rte.len(), file.trk.len()), (2, 2));
let mut fx = Fixture::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
(fx, id)
}
fn file(fx: &Fixture, id: FileId) -> &File {
&fx.files[&id]
}
fn routes(fx: &Fixture, id: FileId, indices: &[usize]) -> Selection {
Selection::Route {
file_id: id,
rte_ids: indices.iter().map(|i| file(fx, id).rte[*i].id).collect(),
}
}
fn whole(id: FileId) -> Selection {
Selection::File {
file_ids: [id].into(),
}
}
fn lengths(file: &File) -> Vec<usize> {
file.segments().map(|segment| segment.len()).collect()
}
#[test]
fn test_the_segments_of_a_file_are_the_ones_of_its_routes_then_its_tracks() {
let (fx, id) = fixture();
let file = file(&fx, id);
let locations = whole(id).segment_locations(&fx.files, &fx.order.0);
let indices: Vec<SegmentIndex> = locations.iter().map(|l| l.index).collect();
assert_eq!(
indices,
[
SegmentIndex::Route(0),
SegmentIndex::Route(1),
SegmentIndex::Track { trk: 0, seg: 0 },
SegmentIndex::Track { trk: 0, seg: 1 },
SegmentIndex::Track { trk: 1, seg: 0 },
SegmentIndex::Track { trk: 1, seg: 1 },
]
);
// a selection of routes covers their segments only
let selected = routes(&fx, id, &[1]).segment_locations(&fx.files, &fx.order.0);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].segment(&fx.files).id, file.rte[1].seg.id);
}
#[test]
fn test_routes_are_selected_like_tracks() {
let (fx, id) = fixture();
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
let mut selection = routes(&fx, id, &[0]);
selection.extend(routes(&fx, id, &[1]));
assert_eq!(selection, routes(&fx, id, &[0, 1]));
selection.toggle(routes(&fx, id, &[0]));
assert_eq!(selection, routes(&fx, id, &[1]));
// routes and tracks are not selected together
let track = Selection::Track {
file_id: id,
trk_ids: [file(&fx, id).trk[0].id].into(),
};
selection.extend(track.clone());
assert_eq!(selection, track);
assert_eq!(
routes(&fx, id, &[0]).all_at_level(&fx.files, &fx.order.0),
Some(routes(&fx, id, &[0, 1]))
);
assert_eq!(
routes(&fx, id, &[0]).neighbour(&fx.files, &fx.order.0, true),
Some(routes(&fx, id, &[1]))
);
assert_eq!(
routes(&fx, id, &[1]).neighbour(&fx.files, &fx.order.0, true),
None
);
assert_eq!(routes(&fx, id, &[1]).to_files(), whole(id));
let mut gone = Selection::Route {
file_id: id,
rte_ids: [ids[0], RouteId::default()].into(),
};
gone.retain_existing(&fx.files);
assert_eq!(gone, routes(&fx, id, &[0]));
}
#[test]
fn test_delete_and_duplicate_routes() {
let (mut fx, id) = fixture();
let kept = file(&fx, id).rte[1].id;
fx.selection = routes(&fx, id, &[0]);
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
assert_eq!(file(&fx, id).rte[0].id, kept);
assert_eq!(file(&fx, id).trk.len(), 2);
assert_eq!(fx.selection, whole(id));
fx.selection = routes(&fx, id, &[0]);
Duplicate.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[1].id, kept);
assert_ne!(file.rte[1].seg.id, file.rte[0].seg.id);
assert_eq!(file.rte[1].info, file.rte[0].info);
assert_eq!(
fx.selection,
Selection::Route {
file_id: id,
rte_ids: [file.rte[1].id].into()
}
);
}
#[test]
fn test_metadata_and_style_of_routes() {
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[1]);
Metadata {
name: "renamed",
desc: "about",
}
.apply(&mut fx.state())
.unwrap();
Style {
color: Some("00ff00"),
opacity: None,
width: Some(3.0),
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
let info = &file.rte[1].info;
assert_eq!(info.name.as_deref(), Some("renamed"));
assert_eq!(info.desc.as_deref(), Some("about"));
assert_eq!(info.color.as_deref(), Some("00ff00"));
assert_eq!(info.width, Some(3.0));
assert_ne!(file.rte[0].info.name.as_deref(), Some("renamed"));
// the style of a file is the one of its routes and tracks
fx.selection = whole(id);
Style {
color: Some("ff0000"),
opacity: None,
width: None,
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id];
assert!(
file.rte
.iter()
.map(|r| &r.info)
.chain(file.trk.iter().map(|t| &t.info))
.all(|info| info.color.as_deref() == Some("ff0000"))
);
}
#[test]
fn test_copy_and_paste_routes() {
let (mut fx, id) = fixture();
let copied = file(&fx, id).rte[0].clone();
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
assert!(matches!(
fx.clipboard.as_ref().unwrap().content,
ClipboardContent::Routes(_)
));
// after the selected route
fx.selection = routes(&fx, id, &[1]);
Paste.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 3);
assert_ne!(file.rte[2].id, copied.id);
assert_eq!(file.rte[2].seg.len(), copied.seg.len());
assert!(
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&file.rte[2].id))
);
// nothing selected: a new file, named like the route
fx.selection = Selection::Empty;
Paste.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0.len(), 2);
let new = &fx.files[&fx.order.0[1]];
assert_eq!(new.rte.len(), 1);
assert!(new.trk.is_empty());
assert_eq!(Some(&new.info.name), copied.info.name.as_ref());
// a track selected: among the routes of its file
fx.selection = Selection::Track {
file_id: id,
trk_ids: [fx.files[&id].trk[0].id].into(),
};
Paste.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&id].rte.len(), 4);
// segments do not go in a route, but become routes next to it
let file = &fx.files[&id];
let track = &file.trk[0];
fx.clipboard = Clipboard::new(
&Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [track.trkseg[1].id].into(),
},
&fx.files,
&fx.order.0,
false,
);
fx.selection = routes(&fx, id, &[0]);
Paste.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.rte.len(), 5);
assert_eq!(file.rte[1].seg.len(), file.trk[0].trkseg[1].len());
}
#[test]
fn test_cut_tracks_pasted_on_a_route_go_first_among_the_tracks() {
let (mut fx, id) = fixture();
let moved = file(&fx, id).trk[1].id;
fx.clipboard = Clipboard::new(
&Selection::Track {
file_id: id,
trk_ids: [moved].into(),
},
&fx.files,
&fx.order.0,
true,
);
fx.selection = routes(&fx, id, &[0]);
Paste.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.trk.len(), 2);
assert_eq!(file.trk[0].id, moved);
assert_eq!(file.rte.len(), 2);
}
#[test]
fn test_move_routes() {
let (mut fx, id) = fixture();
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
Move {
what: routes(&fx, id, &[1]),
to: MoveTarget::Routes {
file_id: id,
index: 0,
},
}
.apply(&mut fx.state())
.unwrap();
let order: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
assert_eq!(order, [ids[1], ids[0]]);
// to the files: a file of its own
Move {
what: routes(&fx, id, &[0]),
to: MoveTarget::Files { index: 1 },
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
let new = &fx.files[&fx.order.0[1]];
assert_eq!(new.rte[0].id, ids[1]);
// a segment moved among the routes becomes one
let track = &file(&fx, id).trk[0];
let segment = track.trkseg[0].id;
Move {
what: Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [segment].into(),
},
to: MoveTarget::Routes {
file_id: id,
index: 1,
},
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
assert_eq!(file.rte[1].seg.id, segment);
assert_eq!(file.trk[0].trkseg.len(), 1);
}
#[test]
fn test_routes_and_tracks_moved_among_each_other_keep_their_kind() {
let (mut fx, id) = fixture();
let route = file(&fx, id).rte[0].id;
let track = file(&fx, id).trk[1].id;
// a route among the tracks: after the other routes, which come first
Move {
what: routes(&fx, id, &[0]),
to: MoveTarget::Tracks {
file_id: id,
index: 1,
},
}
.apply(&mut fx.state())
.unwrap();
let moved = file(&fx, id);
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
assert_eq!(moved.rte[1].id, route);
assert_eq!(
fx.selection,
Selection::Route {
file_id: id,
rte_ids: [route].into()
}
);
// a track among the routes: before the other tracks, which come after the routes
Move {
what: Selection::Track {
file_id: id,
trk_ids: [track].into(),
},
to: MoveTarget::Routes {
file_id: id,
index: 0,
},
}
.apply(&mut fx.state())
.unwrap();
let moved = file(&fx, id);
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
assert_eq!(moved.trk[0].id, track);
// and copied routes pasted on a track stay routes
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track].into(),
};
Paste.apply(&mut fx.state()).unwrap();
let pasted = file(&fx, id);
assert_eq!((pasted.rte.len(), pasted.trk.len()), (3, 2));
}
#[test]
fn test_merge_routes() {
let (mut fx, id) = fixture();
let before = lengths(file(&fx, id));
let first = file(&fx, id).rte[0].id;
fx.selection = routes(&fx, id, &[0, 1]);
let merge = |type_| Merge {
type_,
remove_gaps: false,
keep_originals: false,
};
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 1);
assert_eq!(file.rte[0].id, first);
assert_eq!(file.rte[0].seg.len(), before[0] + before[1]);
assert_eq!(file.trk.len(), 2);
}
#[test]
fn test_merge_files_with_routes() {
let (mut fx, id) = fixture();
let other = File {
rte: file(&fx, id).rte.iter().map(crate::copy_route).collect(),
..File::default()
};
let other_id = other.id;
fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id);
let total: usize = lengths(file(&fx, id)).iter().sum::<usize>()
+ lengths(&fx.files[&other_id]).iter().sum::<usize>();
let mut grouped = Fixture {
files: fx.files.clone(),
order: fx.order.clone(),
selection: Selection::File {
file_ids: [id, other_id].into(),
},
..Default::default()
};
Merge {
type_: MergeType::Group,
remove_gaps: false,
keep_originals: false,
}
.apply(&mut grouped.state())
.unwrap();
let merged = &grouped.files[&id];
assert_eq!((merged.rte.len(), merged.trk.len()), (4, 2));
assert!(!grouped.files.contains_key(&other_id));
// connected in the first route, which comes first
fx.selection = Selection::File {
file_ids: [id, other_id].into(),
};
Merge {
type_: MergeType::Connect,
remove_gaps: false,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
let merged = &fx.files[&id];
assert_eq!((merged.rte.len(), merged.trk.len()), (1, 0));
assert_eq!(merged.rte[0].seg.len(), total);
}
#[test]
fn test_extract_a_file_with_routes() {
let (mut fx, id) = fixture();
let names: Vec<_> = file(&fx, id)
.rte
.iter()
.map(|r| r.info.name.clone().unwrap())
.collect();
fx.selection = whole(id);
Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// one file per route and one per track
assert_eq!(fx.order.0.len(), 4);
let files: Vec<&File> = fx.order.0.iter().map(|id| &*fx.files[id]).collect();
for (file, name) in files.iter().zip(&names) {
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(&file.info.name, name);
}
for file in &files[2..] {
assert_eq!((file.rte.len(), file.trk.len()), (0, 2));
}
// a route has nothing to extract
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[0]);
assert_eq!(
Extract {
keep_originals: false
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_split_a_route() {
let (mut fx, id) = fixture();
let before = file(&fx, id).rte[1].clone();
fx.selection = routes(&fx, id, &[1]);
Split {
at: 10,
split_type: SplitType::Routes,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// two routes, which share the trackpoint
let split = file(&fx, id);
assert_eq!(split.rte.len(), 3);
assert_eq!(split.rte[1].id, before.id);
assert_ne!(split.rte[2].id, before.id);
assert_eq!(split.rte[1].seg.len(), 11);
assert_eq!(split.rte[2].seg.len(), before.seg.len() - 10);
assert_eq!(split.rte[2].info, before.info);
// a route is not a track nor a segment, and the other way around
let (mut fx, id) = fixture();
fx.selection = whole(id);
let segment_start = lengths(file(&fx, id))[..2].iter().sum::<usize>() as u32;
for (at, split_type) in [
(10, SplitType::Tracks),
(10, SplitType::Segments),
(segment_start + 10, SplitType::Routes),
] {
assert_eq!(
Split {
at,
split_type,
keep_originals: false,
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert_eq!(file(&fx, id).rte.len(), 2);
// into two files: the following routes and the tracks go to the second one
let (mut fx, id) = fixture();
let first_len = file(&fx, id).rte[0].seg.len();
fx.selection = whole(id);
Split {
at: 5,
split_type: SplitType::Files,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0.len(), 2);
let (a, b) = (&fx.files[&fx.order.0[0]], &fx.files[&fx.order.0[1]]);
assert_eq!(a.id, id);
assert_eq!((a.rte.len(), a.trk.len()), (1, 0));
assert_eq!(a.rte[0].seg.len(), 6);
assert_eq!((b.rte.len(), b.trk.len()), (2, 2));
assert_eq!(b.rte[0].seg.len(), first_len - 5);
}
#[test]
fn test_crop_removes_the_routes_left_without_trackpoints() {
let (mut fx, id) = fixture();
let len = lengths(file(&fx, id));
fx.selection = whole(id);
// the second route only
let start = len[0] as u32;
Crop {
start,
end: start + len[1] as u32 - 1,
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(file.rte[0].seg.len(), len[1]);
}
#[test]
fn test_clean_removes_the_routes_left_without_trackpoints() {
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[0]);
let everywhere = LngLatBounds {
sw: LngLat {
lng: -180.0,
lat: -90.0,
},
ne: LngLat {
lng: 180.0,
lat: 90.0,
},
};
Clean {
bounds: everywhere,
type_: CleanType::Inside,
trkpt: true,
wpt: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
assert_eq!(fx.selection, whole(id));
}
#[test]
fn test_reverse_a_route_and_the_routes_of_a_file() {
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
fx.selection = routes(&fx, id, &[0]);
Reverse.apply(&mut fx.state()).unwrap();
let route = &file(&fx, id).rte[0].seg;
let original = &before.rte[0].seg;
assert_eq!(
route[0].coordinates.lat,
original[original.len() - 1].coordinates.lat
);
assert_eq!(file(&fx, id).rte[1].seg.rev_id, before.rte[1].seg.rev_id);
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
fx.selection = whole(id);
Reverse.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
// the routes are in the opposite order, and still before the tracks
assert_eq!(file.rte[0].id, before.rte[1].id);
assert_eq!(file.trk[0].id, before.trk[1].id);
}
#[test]
fn test_convert_routes_to_tracks_and_back() {
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
// a route becomes a track, first among the tracks, with its segment and its information
fx.selection = routes(&fx, id, &[1]);
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (1, 3));
assert_eq!(converted.trk[0].info, before.rte[1].info);
assert_eq!(converted.trk[0].trkseg.len(), 1);
assert_eq!(converted.trk[0].trkseg[0].id, before.rte[1].seg.id);
assert_eq!(
fx.selection,
Selection::Track {
file_id: id,
trk_ids: [converted.trk[0].id].into()
}
);
// and back: a route again, after the other routes
Convert.apply(&mut fx.state()).unwrap();
let back = file(&fx, id);
assert_eq!((back.rte.len(), back.trk.len()), (2, 2));
assert_eq!(back.rte[1].seg.id, before.rte[1].seg.id);
assert_eq!(back.rte[1].info, before.rte[1].info);
assert!(
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&back.rte[1].id))
);
}
#[test]
fn test_convert_a_track_to_one_route_per_segment() {
let (mut fx, id) = fixture();
let track = file(&fx, id).trk[0].clone();
assert_eq!(track.trkseg.len(), 2);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track.id].into(),
};
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 1));
for (route, segment) in converted.rte[2..].iter().zip(&track.trkseg) {
assert_eq!(route.seg.id, segment.id);
assert_eq!(route.info, track.info);
}
assert!(matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.len() == 2));
}
#[test]
fn test_convert_segments_to_routes_keeps_their_track() {
let (mut fx, id) = fixture();
let track = file(&fx, id).trk[1].clone();
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [track.trkseg[0].id, track.trkseg[1].id].into(),
};
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 2));
assert!(converted.trk[1].trkseg.is_empty());
assert_eq!(converted.rte[2].seg.id, track.trkseg[0].id);
// nothing to convert otherwise
for selection in [
whole(id),
Selection::Empty,
Selection::Waypoints { file_id: id },
] {
fx.selection = selection;
assert_eq!(
Convert.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -24,7 +24,7 @@ impl Apply for ChangeLoopStart {
let index = self.index as usize; let index = self.index as usize;
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?; let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&target.location.file_id]).clone(); let mut file = (*state.files[&target.location.file_id]).clone();
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg]; let segment = file.segment_mut(target.location.index);
let (start, len) = (target.start, segment.len()); let (start, len) = (target.start, segment.len());
if start == 0 || start >= len { if start == 0 || start >= len {
// it already is the start, or it is not a trackpoint // it already is the start, or it is not a trackpoint
@@ -5,15 +5,16 @@ use crate::{
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected, TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CleanType { pub enum CleanType {
Inside, Inside,
Outside, Outside,
} }
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a /// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment /// rectangle. A selected file cleans its routes, its tracks and its waypoints, a selected route,
/// only trackpoints, selected waypoints only waypoints. /// track or segment only trackpoints, selected waypoints only waypoints.
#[derive(Debug)] #[derive(Debug)]
pub struct Clean { pub struct Clean {
pub bounds: LngLatBounds, pub bounds: LngLatBounds,
@@ -84,6 +85,11 @@ struct Cleaner<'a> {
impl Editor for Cleaner<'_> { impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged; let mut edit = Edit::Unchanged;
for rte in &mut file.rte {
if self.route(rte) == Edit::Changed {
edit = Edit::Changed;
}
}
for trk in &mut file.trk { for trk in &mut file.trk {
if self.track(trk) == Edit::Changed { if self.track(trk) == Edit::Changed {
edit = Edit::Changed; edit = Edit::Changed;
@@ -123,37 +129,28 @@ impl Editor for Cleaner<'_> {
} }
} }
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their /// Removes what the clean left empty: the emptied segments, the routes of these segments and the
/// segments and the files that lost something and have nothing left. The selection falls back /// tracks that lost all their segments. A file is never removed, even if nothing is left in it: it stays, empty. The
/// to the closest element still existing. /// selection falls back to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) { fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state let ids: Vec<FileId> = state
.files .files
.iter() .iter()
.filter(|(id, file)| { .filter(|(id, file)| {
wpt_files.contains(id) wpt_files.contains(id) || file.segments().any(|seg| emptied.contains(&seg.id))
|| file
.trk
.iter()
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
}) })
.map(|(id, _)| *id) .map(|(id, _)| *id)
.collect(); .collect();
for id in ids { for id in ids {
let mut file = (*state.files[&id]).clone(); let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id); file.rte.retain(|rte| !emptied.contains(&rte.seg.id));
file.trk.retain_mut(|trk| { file.trk.retain_mut(|trk| {
let len = trk.trkseg.len(); let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id)); trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty() trk.trkseg.len() == len || !trk.trkseg.is_empty()
}); });
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file)); state.files.insert(id, Rc::new(file));
} }
}
*state.selection = fixed_selection(state); *state.selection = fixed_selection(state);
} }
@@ -172,6 +169,27 @@ fn fixed_selection(state: &State) -> Selection {
Selection::File { file_ids } Selection::File { file_ids }
} }
} }
Selection::Route { file_id, rte_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let rte_ids: HashSet<_> = file
.rte
.iter()
.map(|rte| rte.id)
.filter(|id| rte_ids.contains(id))
.collect();
if rte_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Route {
file_id: *file_id,
rte_ids,
}
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else { let Some(file) = files.get(file_id) else {
return Selection::Empty; return Selection::Empty;
@@ -296,10 +314,16 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
assert_eq!(count(&fx, id), 0); assert_eq!(count(&fx, id), 0);
// nothing is left of the file // nothing is left in the file, which stays and is still selected
assert!(!fx.files.contains_key(&id)); let file = &fx.files[&id];
assert!(fx.order.0.is_empty()); assert!(file.trk.is_empty() && file.wpt.is_empty());
assert_eq!(fx.selection, Selection::Empty); assert_eq!(fx.order.0, [id]);
assert_eq!(
fx.selection,
Selection::File {
file_ids: HashSet::from([id])
}
);
} }
#[test] #[test]
@@ -11,9 +11,9 @@ use crate::{
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted /// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others. /// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
/// ///
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment /// The segments that have no trackpoint left are removed, and so are the routes of these segments.
/// left, if tracks or files are selected (not if only segments are), and the files that have no /// So are the tracks that have no segment left, if tracks or files are selected (not if only
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it. /// segments are), and the files that have no trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
#[derive(Debug)] #[derive(Debug)]
pub struct Crop { pub struct Crop {
pub start: u32, pub start: u32,
@@ -43,8 +43,7 @@ impl Apply for Crop {
.segment_locations(state.files, &state.order.0) .segment_locations(state.files, &state.order.0)
.into_iter() .into_iter()
.map(|location| { .map(|location| {
let len = let len = location.segment(state.files).len();
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
let keep = (len > 0 && start < offset + len && end >= offset) let keep = (len > 0 && start < offset + len && end >= offset)
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1))); .then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
offset += len; offset += len;
@@ -73,14 +72,14 @@ impl Apply for Crop {
let mut removed: HashSet<TrackSegmentId> = HashSet::new(); let mut removed: HashSet<TrackSegmentId> = HashSet::new();
let mut had_points: HashSet<FileId> = HashSet::new(); let mut had_points: HashSet<FileId> = HashSet::new();
for plan in &plans { for plan in &plans {
let SegmentLocation { file_id, trk, seg } = plan.location; let SegmentLocation { file_id, index } = plan.location;
let file = files let file = files
.entry(file_id) .entry(file_id)
.or_insert_with(|| (*state.files[&file_id]).clone()); .or_insert_with(|| (*state.files[&file_id]).clone());
if plan.len > 0 { if plan.len > 0 {
had_points.insert(file_id); had_points.insert(file_id);
} }
let segment = &mut file.trk[trk].trkseg[seg]; let segment = file.segment_mut(index);
match plan.keep { match plan.keep {
None => { None => {
removed.insert(segment.id); removed.insert(segment.id);
@@ -91,6 +90,8 @@ impl Apply for Crop {
let mut gone: Vec<FileId> = vec![]; let mut gone: Vec<FileId> = vec![];
for (file_id, file) in files.iter_mut() { for (file_id, file) in files.iter_mut() {
// a route has its segment, or is not there
file.rte.retain(|route| !removed.contains(&route.seg.id));
// the tracks that lost segments // the tracks that lost segments
let touched: Vec<bool> = file let touched: Vec<bool> = file
.trk .trk
@@ -108,7 +109,7 @@ impl Apply for Crop {
file.trk file.trk
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty())); .retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
} }
if prune_files && file.trk.is_empty() && had_points.contains(file_id) { if prune_files && file.has_no_path() && had_points.contains(file_id) {
gone.push(*file_id); gone.push(*file_id);
} }
} }
@@ -14,9 +14,7 @@ impl Apply for Elevation<'_> {
.segment_locations(state.files, &state.order.0); .segment_locations(state.files, &state.order.0);
let count: usize = locations let count: usize = locations
.iter() .iter()
.map(|location| { .map(|location| location.segment(state.files).len())
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum(); .sum();
if count == 0 { if count == 0 {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
@@ -1,44 +1,59 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId, Apply, CommandError, File, FileId, LngLat, LngLatBounds, Route, Selection, State, Track,
TrackSegment, Waypoint, Waypoints, copy_waypoint, crossarc_lnglat, distance, TrackId, TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat,
distance,
}; };
/// Splits the selected elements that are made of several segments into elements that have one /// Splits the selected elements that are made of several segments into elements that have one
/// each: /// each:
/// ///
/// - files: a file with several tracks becomes one file per track, in which the track is split /// - files: a file with several routes and tracks becomes one file per route and per track, in
/// into one track per segment. A file with a single track becomes one file per segment. The new /// which the track is split into one track per segment. A file with a single track becomes one
/// files are in the place of the file, which goes, and are selected. They are named after the /// file per segment. The new files are in the place of the file, which goes, and are selected.
/// track, or the file, and numbered. The waypoints go to the files that have the segments /// They are named after the route or track, or the file, and numbered. The waypoints go to the files that have the segments
/// closest to them (to the first file if the file has no trackpoint). /// closest to them (to the first file if the file has no trackpoint).
/// - tracks: each selected track is replaced by one track per segment, which are selected. They /// - tracks: each selected track is replaced by one track per segment, which are selected. They
/// are named after the track and numbered. /// are named after the track and numbered.
/// ///
/// What has a single segment is left as it is. There is nothing to do if nothing was changed. /// What has a single segment is left as it is, which routes always are. There is nothing to do if nothing was changed.
///
/// With `keep_originals`, the files that are extracted stay where they are, and the new files come
/// right after each of them, and only the new files are selected. Extracting
/// tracks changes the file in place anyway, so the flag has no effect on it.
#[derive(Debug)] #[derive(Debug)]
pub struct Extract; pub struct Extract {
pub keep_originals: bool,
}
impl Apply for Extract { impl Apply for Extract {
fn apply(self, state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone(); let selection = state.selection.clone();
match &selection { match &selection {
Selection::File { file_ids } => extract_files(state, file_ids), Selection::File { file_ids } => extract_files(state, file_ids, self.keep_originals),
Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids), Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids),
_ => Err(CommandError::NothingToDo), _ => Err(CommandError::NothingToDo),
} }
} }
} }
fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), CommandError> { fn extract_files(
state: &mut State,
file_ids: &HashSet<FileId>,
keep_originals: bool,
) -> Result<(), CommandError> {
// the files that have something to extract, with the files that come out of them // the files that have something to extract, with the files that come out of them
let mut extracted: Vec<(FileId, Vec<File>)> = vec![]; let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) { for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let file = &state.files[id]; let file = &state.files[id];
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum(); if file.segments().count() > 1 {
if segments > 1 { let mut files = extract_file(file);
extracted.push((*id, extract_file(file))); if keep_originals {
// the tracks and segments are the ones of the file, which stays: they need new ids
files = files.iter().map(copy_file).collect();
}
extracted.push((*id, files));
} }
} }
if extracted.is_empty() { if extracted.is_empty() {
@@ -48,13 +63,25 @@ fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), Co
let mut order = vec![]; let mut order = vec![];
for id in state.order.0.iter() { for id in state.order.0.iter() {
match extracted.iter().find(|(original, _)| original == id) { match extracted.iter().find(|(original, _)| original == id) {
Some((_, files)) => order.extend(files.iter().map(|file| file.id)), Some((_, files)) => {
if keep_originals {
order.push(*id);
}
order.extend(files.iter().map(|file| file.id));
}
None => order.push(*id), None => order.push(*id),
} }
} }
let mut selected: HashSet<FileId> = file_ids.clone(); // the files that are kept stay selected, unless the originals are kept: then only what is new is
let mut selected: HashSet<FileId> = if keep_originals {
HashSet::new()
} else {
file_ids.clone()
};
for (original, files) in extracted { for (original, files) in extracted {
if !keep_originals {
state.files.remove(&original); state.files.remove(&original);
}
selected.remove(&original); selected.remove(&original);
for file in files { for file in files {
selected.insert(file.id); selected.insert(file.id);
@@ -68,31 +95,35 @@ fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), Co
/// The files that a file with several segments becomes. /// The files that a file with several segments becomes.
fn extract_file(file: &File) -> Vec<File> { fn extract_file(file: &File) -> Vec<File> {
// for each new file: its tracks and its name, and the segments that go with it // for each new file: its routes, its tracks and its name, and the segments that go with it
struct Part { struct Part {
routes: Vec<Route>,
tracks: Vec<Track>, tracks: Vec<Track>,
name: String, name: String,
} }
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 { let name = |name: &Option<String>, i: usize| {
// one file per track, the segments of a track follow it name.clone()
let parts = file .unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1))
.trk };
.iter() let (parts, owner): (Vec<Part>, Vec<usize>) =
.enumerate() if file.rte.len() + file.trk.len() > 1 {
.map(|(i, track)| Part { // one file per route and per track, the segments of a track follow it
let routes = file.rte.iter().enumerate().map(|(i, route)| Part {
routes: vec![route.clone()],
tracks: vec![],
name: name(&route.info.name, i),
});
let tracks = file.trk.iter().enumerate().map(|(i, track)| Part {
routes: vec![],
tracks: split_track(track), tracks: split_track(track),
name: track name: name(&track.info.name, file.rte.len() + i),
.info });
.name let parts = routes.chain(tracks).collect();
.clone() let owner =
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)), (0..file.rte.len())
}) .chain(file.trk.iter().enumerate().flat_map(|(i, track)| {
.collect(); track.trkseg.iter().map(move |_| file.rte.len() + i)
let owner = file }))
.trk
.iter()
.enumerate()
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
.collect(); .collect();
(parts, owner) (parts, owner)
} else { } else {
@@ -108,6 +139,7 @@ fn extract_file(file: &File) -> Vec<File> {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, segment)| Part { .map(|(i, segment)| Part {
routes: vec![],
tracks: vec![Track { tracks: vec![Track {
id: if i == 0 { track.id } else { Default::default() }, id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()], trkseg: vec![segment.clone()],
@@ -121,7 +153,7 @@ fn extract_file(file: &File) -> Vec<File> {
}; };
// the waypoints go with the segments that are the closest to them // the waypoints go with the segments that are the closest to them
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect(); let segments: Vec<&TrackSegment> = file.segments().collect();
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()]; let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
for waypoint in file.wpt.iter() { for waypoint in file.wpt.iter() {
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates) let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
@@ -149,6 +181,7 @@ fn extract_file(file: &File) -> Vec<File> {
.map(|(part, waypoints)| { .map(|(part, waypoints)| {
let mut extracted = File { let mut extracted = File {
id: Default::default(), id: Default::default(),
rte: part.routes,
trk: part.tracks, trk: part.tracks,
wpt: Waypoints::default(), wpt: Waypoints::default(),
..file.clone() ..file.clone()
@@ -372,7 +405,11 @@ mod tests {
fx.files.insert(other_id, Rc::new(other)); fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id); fx.order.0.push(other_id);
Extract.apply(&mut fx.state()).unwrap(); Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// in the place of the file, in order, named after the track // in the place of the file, in order, named after the track
assert!(!fx.files.contains_key(&id)); assert!(!fx.files.contains_key(&id));
@@ -402,6 +439,48 @@ mod tests {
if file_ids.len() == 3 && !file_ids.contains(&other_id))); if file_ids.len() == 3 && !file_ids.contains(&other_id)));
} }
#[test]
fn test_extracting_files_keeping_the_originals() {
let original = file(
"route",
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
vec![],
);
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
let (mut fx, id) = fixture(original);
let before = fx.files[&id].clone();
let single = file("single", vec![track(None, &[50.0])], vec![]);
let single_id = single.id;
fx.files.insert(single_id, Rc::new(single));
fx.order.0.push(single_id);
fx.selection = Selection::File {
file_ids: HashSet::from([id, single_id]),
};
Extract {
keep_originals: true,
}
.apply(&mut fx.state())
.unwrap();
// the file stays, the new ones come right after it
assert!(Rc::ptr_eq(&fx.files[&id], &before));
assert_eq!(
names(&fx),
["route", "climb (1)", "climb (2)", "climb (3)", "single"]
);
assert_eq!(fx.order.0[0], id);
assert_eq!(fx.order.0[4], single_id);
// the segments are copies, not the ones of the file
for new_id in &fx.order.0[1..4] {
let segment = fx.files[new_id].trk[0].trkseg[0].id;
assert!(!segment_ids.contains(&segment));
}
// only the new files are selected
assert!(matches!(&fx.selection, Selection::File { file_ids }
if *file_ids == fx.order.0[1..4].iter().copied().collect::<HashSet<_>>()));
}
#[test] #[test]
fn test_a_file_with_several_tracks_becomes_one_file_per_track() { fn test_a_file_with_several_tracks_becomes_one_file_per_track() {
let original = file( let original = file(
@@ -415,7 +494,11 @@ mod tests {
); );
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect(); let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, _) = fixture(original); let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap(); Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// named after the track, or the file // named after the track, or the file
assert_eq!(names(&fx), ["first", "route (2)", "third"]); assert_eq!(names(&fx), ["first", "route (2)", "third"]);
@@ -451,7 +534,11 @@ mod tests {
vec![shared, waypoint("lost", 100.0)], vec![shared, waypoint("lost", 100.0)],
); );
let (mut fx, _) = fixture(original); let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap(); Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
let first = &fx.files[&fx.order.0[0]]; let first = &fx.files[&fx.order.0[0]];
let second = &fx.files[&fx.order.0[1]]; let second = &fx.files[&fx.order.0[1]];
// the tie goes to both, with different ids // the tie goes to both, with different ids
@@ -480,7 +567,11 @@ mod tests {
vec![waypoint("w", 1.0)], vec![waypoint("w", 1.0)],
); );
let (mut fx, _) = fixture(original); let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap(); Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1); assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1);
assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0); assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0);
} }
@@ -503,7 +594,11 @@ mod tests {
trk_ids: [ids[1], ids[2]].into(), trk_ids: [ids[1], ids[2]].into(),
}; };
Extract.apply(&mut fx.state()).unwrap(); Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id]; let file = &fx.files[&id];
let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect(); let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect();
@@ -539,7 +634,10 @@ mod tests {
let (mut fx, id) = fixture(original); let (mut fx, id) = fixture(original);
let before = fx.files[&id].clone(); let before = fx.files[&id].clone();
assert_eq!( assert_eq!(
Extract.apply(&mut fx.state()), Extract {
keep_originals: false,
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
fx.selection = Selection::Track { fx.selection = Selection::Track {
@@ -547,7 +645,10 @@ mod tests {
trk_ids: [track_id].into(), trk_ids: [track_id].into(),
}; };
assert_eq!( assert_eq!(
Extract.apply(&mut fx.state()), Extract {
keep_originals: false,
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
for selection in [ for selection in [
@@ -561,7 +662,10 @@ mod tests {
] { ] {
fx.selection = selection; fx.selection = selection;
assert_eq!( assert_eq!(
Extract.apply(&mut fx.state()), Extract {
keep_originals: false,
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo) Err(CommandError::NothingToDo)
); );
} }
@@ -39,7 +39,7 @@ impl Apply for InsertAnchor {
.selection .selection
.segment_locations(state.files, &state.order.0) .segment_locations(state.files, &state.order.0)
{ {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; let segment = location.segment(state.files);
let mut points = segment.iter(); let mut points = segment.iter();
let Some(mut previous) = points.next() else { let Some(mut previous) = points.next() else {
continue; continue;
@@ -81,7 +81,7 @@ impl Apply for InsertAnchor {
let closest = closest.ok_or(CommandError::NothingToDo)?; let closest = closest.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&closest.location.file_id]).clone(); let mut file = (*state.files[&closest.location.file_id]).clone();
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg]; let segment = file.segment_mut(closest.location.index);
let before = closest.before; let before = closest.before;
// on a trackpoint already, if it is closer than `SNAP_DISTANCE` // on a trackpoint already, if it is closer than `SNAP_DISTANCE`
let snapped = if segment.len() == 1 { let snapped = if segment.len() == 1 {
@@ -1,11 +1,13 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId, Apply, CommandError, FileId, GlobalStatistics, Route, RouteId, Selection, State, Statistics,
TrackSegment, TrackSegmentId, Waypoint, Waypoints, replace_trackpoints, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_route, copy_track,
replace_trackpoints,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MergeType { pub enum MergeType {
/// The trackpoints of the elements are connected in a single segment. /// The trackpoints of the elements are connected in a single segment.
Connect, Connect,
@@ -15,11 +17,16 @@ pub enum MergeType {
/// Merges the selected elements into the first of them: /// Merges the selected elements into the first of them:
/// ///
/// - files: the other files are deleted. The tracks of all of them are in the first file, as /// - files: the other files are deleted. The routes and tracks of all of them are in the first
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of /// file, as they are with `Group`, or all connected in the first of them (a route or a track,
/// all of them are in the first file too, except the ones that are the same. /// the routes of a file coming before its tracks) with `Connect`. The waypoints of
/// all of them are in the first file too, except the ones that are the same. With
/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of
/// the first one and new ids), which comes right after the last of the files and is selected.
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all /// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
/// connected in one segment with `Connect`. The other tracks are removed. /// connected in one segment with `Connect`. The other tracks are removed.
/// - routes of a file: the first route gets their trackpoints, all connected with `Connect`, and
/// the other routes are removed. A route has a single segment, so there is nothing to `Group`.
/// - segments of a track: they are all connected in a segment at the position of the first one /// - segments of a track: they are all connected in a segment at the position of the first one
/// with `Connect`. With `Group` they are put one after the other, at that position. /// with `Connect`. With `Group` they are put one after the other, at that position.
/// ///
@@ -32,6 +39,8 @@ pub enum MergeType {
pub struct Merge { pub struct Merge {
pub type_: MergeType, pub type_: MergeType,
pub remove_gaps: bool, pub remove_gaps: bool,
/// Only for files: tracks and segments are merged in place anyway.
pub keep_originals: bool,
} }
impl Apply for Merge { impl Apply for Merge {
@@ -39,6 +48,7 @@ impl Apply for Merge {
let selection = state.selection.clone(); let selection = state.selection.clone();
match &selection { match &selection {
Selection::File { file_ids } => self.merge_files(state, file_ids), Selection::File { file_ids } => self.merge_files(state, file_ids),
Selection::Route { file_id, rte_ids } => self.merge_routes(state, *file_id, rte_ids),
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids), Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
Selection::TrackSegment { Selection::TrackSegment {
file_id, file_id,
@@ -72,21 +82,26 @@ impl Merge {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
}; };
let mut routes: Vec<Route> = vec![];
let mut tracks: Vec<Track> = vec![]; let mut tracks: Vec<Track> = vec![];
let mut segments: Vec<TrackSegment> = vec![];
let mut waypoints: Vec<Waypoint> = vec![]; let mut waypoints: Vec<Waypoint> = vec![];
for id in &ordered { for id in &ordered {
let file = &state.files[id]; let file = &state.files[id];
routes.extend(file.rte.iter().cloned());
tracks.extend(file.trk.iter().cloned()); tracks.extend(file.trk.iter().cloned());
segments.extend(file.segments().cloned());
for waypoint in file.wpt.iter() { for waypoint in file.wpt.iter() {
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) { if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
waypoints.push(waypoint.clone()); waypoints.push(waypoint.clone());
} }
} }
} }
let segments: Vec<TrackSegment> = tracks // the first route or track, which holds the connected segment
.iter() let first_is_route = ordered.iter().find_map(|id| {
.flat_map(|track| track.trkseg.iter().cloned()) let file = &state.files[id];
.collect(); (!file.has_no_path()).then(|| !file.rte.is_empty())
});
match self.type_ { match self.type_ {
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo), MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
@@ -94,21 +109,49 @@ impl Merge {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
} }
MergeType::Connect if !segments.is_empty() => { MergeType::Connect if !segments.is_empty() => {
// a single track, which is the first one, with the connected segment // a single route or track, which is the first one, with the connected segment
let merged = connect(&segments, segments[0].id, self.remove_gaps); let merged = connect(&segments, segments[0].id, self.remove_gaps);
if first_is_route == Some(true) {
let first = Route {
seg: merged,
..routes[0].clone()
};
(routes, tracks) = (vec![first], vec![]);
} else {
let first = Track { let first = Track {
trkseg: vec![merged], trkseg: vec![merged],
..tracks[0].clone() ..tracks[0].clone()
}; };
tracks = vec![first]; (routes, tracks) = (vec![], vec![first]);
}
} }
_ => {} _ => {}
} }
let mut target = (*state.files[&target_id]).clone(); let mut target = (*state.files[&target_id]).clone();
target.rte = routes;
target.trk = tracks; target.trk = tracks;
target.wpt = Waypoints::default(); target.wpt = Waypoints::default();
target.wpt.insert_at(0, waypoints); target.wpt.insert_at(0, waypoints);
if self.keep_originals {
// a new file, whose routes, tracks and segments do not share their ids with the
// originals
target.id = Default::default();
target.rte = target.rte.iter().map(copy_route).collect();
target.trk = target.trk.iter().map(copy_track).collect();
let at = state
.order
.0
.iter()
.rposition(|id| file_ids.contains(id))
.map_or(state.order.0.len(), |i| i + 1);
state.order.0.insert(at, target.id);
*state.selection = Selection::File {
file_ids: [target.id].into(),
};
state.files.insert(target.id, Rc::new(target));
return Ok(());
}
for id in &ordered[1..] { for id in &ordered[1..] {
state.files.remove(id); state.files.remove(id);
} }
@@ -117,6 +160,33 @@ impl Merge {
Ok(()) Ok(())
} }
fn merge_routes(
&self,
state: &mut State,
file_id: FileId,
rte_ids: &HashSet<RouteId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<usize> = (0..file.rte.len())
.filter(|i| rte_ids.contains(&file.rte[*i].id))
.collect();
if selected.len() < 2 || self.type_ == MergeType::Group {
return Err(CommandError::NothingToDo);
}
let first = selected[0];
let segments: Vec<TrackSegment> =
selected.iter().map(|i| file.rte[*i].seg.clone()).collect();
let mut file = (**file).clone();
file.rte[first].seg = connect(&segments, segments[0].id, self.remove_gaps);
let mut index = 0;
file.rte.retain(|_| {
index += 1;
!selected[1..].contains(&(index - 1))
});
state.files.insert(file_id, Rc::new(file));
Ok(())
}
fn merge_tracks( fn merge_tracks(
&self, &self,
state: &mut State, state: &mut State,
@@ -273,6 +343,7 @@ mod tests {
Merge { Merge {
type_, type_,
remove_gaps: false, remove_gaps: false,
keep_originals: false,
} }
} }
@@ -340,6 +411,73 @@ mod tests {
); );
} }
#[test]
fn test_merge_files_keeping_the_originals() {
for type_ in [MergeType::Group, MergeType::Connect] {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
let b = load(&mut fx, "data/simple.gpx");
let c = load(&mut fx, "data/simple.gpx");
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
select_files(&mut fx, &[a, b]);
Merge {
type_,
remove_gaps: false,
keep_originals: true,
}
.apply(&mut fx.state())
.unwrap();
// the files are untouched, the new one comes right after the last of them
assert!(Rc::ptr_eq(&fx.files[&a], &before_a));
assert!(Rc::ptr_eq(&fx.files[&b], &before_b));
assert_eq!(fx.files.len(), 4);
assert_eq!(&fx.order.0[..2], [a, b]);
assert_eq!(fx.order.0[3], c);
let merged_id = fx.order.0[2];
let merged = &fx.files[&merged_id];
assert_eq!(points(merged), points(&before_a) + points(&before_b));
assert_eq!(merged.info, before_a.info);
// new ids for the tracks and segments
let ids = |f: &File| -> Vec<_> {
f.trk
.iter()
.flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0)))
.collect()
};
let originals: Vec<_> = ids(&before_a).into_iter().chain(ids(&before_b)).collect();
assert!(ids(merged).iter().all(|id| !originals.contains(id)));
// and it is the selection
assert!(matches!(&fx.selection, Selection::File { file_ids }
if *file_ids == HashSet::from([merged_id])));
}
}
#[test]
fn test_keeping_the_originals_does_not_matter_when_merging_tracks() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
let tracks: HashSet<_> = fx.files[&a].trk.iter().map(|t| t.id).collect();
let before = fx.files[&a].trk.len();
assert!(before > 1);
fx.selection = Selection::Track {
file_id: a,
trk_ids: tracks,
};
Merge {
type_: MergeType::Group,
remove_gaps: false,
keep_originals: true,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files.len(), 1);
assert_eq!(fx.files[&a].trk.len(), 1);
}
#[test] #[test]
fn test_connect_files() { fn test_connect_files() {
let mut fx = Fixture::default(); let mut fx = Fixture::default();
@@ -429,6 +567,7 @@ mod tests {
Merge { Merge {
type_: MergeType::Connect, type_: MergeType::Connect,
remove_gaps, remove_gaps,
keep_originals: false,
} }
.apply(&mut fx2.state()) .apply(&mut fx2.state())
.unwrap(); .unwrap();
Loaded 100 of 350 files, more files were not shown because too many files have changed in this diff. Show more