From 8fe7d8d64d2e1371cf6ba98f9382429e97b5bafa Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 31 Oct 2026 18:08:22 +0100 Subject: [PATCH] Migration to new Rust-based engine (#401) * wasm first steps * parsing start * parsing continued * progress * progress * progress * progress * progress * progress * progress * progress * fixes * reorganize * change type * fix * progress * refactoring * progress * progress * progress * keep style info * progress * progress * remove (copy) in name * progress * merge methods into 1 * splice * progress * progress * progress * cleaning * progress * progress * progress * visibility * progress * progress * progress * progress * progress * fixes * progress * fix markers * fix * progress * progress * progress * progress * progress * progress * small fix * use engine info * fix popups * small fix * progress * progress * progress * clean tool * handle self closing elements * reduce data exchanges across boundary * copy on wasm side to avoid any data corruption * speed fix * routing * refactoring * reverse * reverse * crop * split * merge * progress * time tool * fix bounds for waypoints * reduce * fix reactivity * handle corrupt time data * export * progress * remove gpx js package * remove todo * update readme * cleaning * progress * fix link fields * remove unused fields * refactoring and tests * import and export of routes, ref #192 * per-chunk categories storage * cross-tab sync * outdated state * refactoring * reorder methods * refactoring * fix * improve flow * renaming * progress * progress * fix * progress * per file history * keep original flag * use history variant * fix * progress * file explorer * progress * fix * sticky folders * improved sticky folders * fix sortable * fix sortable * fix * fix tab size * no file delete on desktop * fix routing on other file * fix sorting * remember language * open links in browser * commit missing changes * avoid selecting on resize * clarify filename * explorer actions * fix folder styling * drop anywhere in folder * cleaning * fixes * remove focus-ring * full screen * small fix * consistent highlight color * improved preview * performance improvements * sparser data transfers * sparser data transfers * show popup once data is loaded * fix routing control size * fix * sparse desktop build * remove custom ign styles * use new endpoint for openhikingmap * strip native menu * reorder context menu items * route full support * route - track conversion * create route file action * route export, filter out anchors at high zoom level * clarify name handling * move hide & center to view * routes continued * update docs * fix power format, closes #378 * lint fixes * fix display of additional data with gaps, closes #361 * fix linting issues * fix import modules * fix multidrag * change base to resolve * fix type errors * fix layer typings * fix last type error * each keys * improve typings * deploy to cloudflare --------- Co-authored-by: vcoppe --- .github/workflows/deploy-cloudflare.yml | 67 + .github/workflows/deploy.yml | 26 +- .gitignore | 3 +- Cargo.lock | 5330 ++++++++ Cargo.toml | 5 + README.md | 20 +- gpx-rs/Cargo.toml | 11 + gpx-rs/README.md | 10 + gpx-rs/data/self_closing_points.gpx | 26 + {gpx/test-data => gpx-rs/data}/simple.gpx | 22 +- {gpx/test-data => gpx-rs/data}/with_cad.gpx | 0 gpx-rs/data/with_highway.gpx | 62 + {gpx/test-data => gpx-rs/data}/with_hr.gpx | 0 gpx-rs/data/with_links.gpx | 38 + .../data}/with_power_1.gpx | 0 .../data}/with_power_2.gpx | 11 +- gpx-rs/data/with_power_3.gpx | 505 + gpx-rs/data/with_route_extensions.gpx | 23 + gpx-rs/data/with_route_points.gpx | 30 + .../test-data => gpx-rs/data}/with_routes.gpx | 0 .../data}/with_segments.gpx | 0 {gpx/test-data => gpx-rs/data}/with_style.gpx | 0 .../data}/with_surface.gpx | 0 {gpx/test-data => gpx-rs/data}/with_temp.gpx | 0 {gpx/test-data => gpx-rs/data}/with_time.gpx | 0 .../test-data => gpx-rs/data}/with_tracks.gpx | 0 .../data}/with_tracks_and_segments.gpx | 0 .../data}/with_waypoint.gpx | 19 +- gpx-rs/src/api/commands.rs | 672 + gpx-rs/src/api/dto.rs | 636 + gpx-rs/src/api/mod.rs | 21 + gpx-rs/src/api/queries.rs | 277 + gpx-rs/src/core/algorithm/anchors.rs | 112 + gpx-rs/src/core/algorithm/mod.rs | 10 + gpx-rs/src/core/algorithm/reverse.rs | 245 + gpx-rs/src/core/algorithm/simplify.rs | 232 + gpx-rs/src/core/algorithm/smooth.rs | 141 + gpx-rs/src/core/algorithm/timestamps.rs | 465 + gpx-rs/src/core/gpx/categories.rs | 96 + gpx-rs/src/core/gpx/chunk.rs | 133 + gpx-rs/src/core/gpx/chunked.rs | 619 + gpx-rs/src/core/gpx/common.rs | 114 + gpx-rs/src/core/gpx/file.rs | 81 + gpx-rs/src/core/gpx/mod.rs | 23 + gpx-rs/src/core/gpx/route.rs | 15 + gpx-rs/src/core/gpx/segment.rs | 411 + gpx-rs/src/core/gpx/track.rs | 25 + gpx-rs/src/core/gpx/trackpoint.rs | 24 + gpx-rs/src/core/gpx/waypoint.rs | 35 + gpx-rs/src/core/gpx/waypoints.rs | 315 + gpx-rs/src/core/io/mod.rs | 5 + gpx-rs/src/core/io/parse.rs | 1262 ++ gpx-rs/src/core/io/write.rs | 803 ++ gpx-rs/src/core/mod.rs | 11 + gpx-rs/src/core/statistics/compute.rs | 527 + gpx-rs/src/core/statistics/mod.rs | 5 + gpx-rs/src/core/statistics/utils.rs | 44 + gpx-rs/src/core/utils.rs | 208 + gpx-rs/src/engine/action.rs | 46 + gpx-rs/src/engine/command/apply.rs | 30 + gpx-rs/src/engine/command/edit/convert.rs | 163 + gpx-rs/src/engine/command/edit/metadata.rs | 178 + gpx-rs/src/engine/command/edit/mod.rs | 13 + gpx-rs/src/engine/command/edit/new_route.rs | 55 + gpx-rs/src/engine/command/edit/new_track.rs | 98 + .../engine/command/edit/new_track_segment.rs | 42 + gpx-rs/src/engine/command/edit/style.rs | 81 + gpx-rs/src/engine/command/file/delete.rs | 305 + gpx-rs/src/engine/command/file/delete_all.rs | 50 + .../engine/command/file/delete_waypoint.rs | 120 + gpx-rs/src/engine/command/file/duplicate.rs | 323 + gpx-rs/src/engine/command/file/load.rs | 236 + gpx-rs/src/engine/command/file/mod.rs | 21 + .../src/engine/command/file/move_elements.rs | 453 + gpx-rs/src/engine/command/file/new.rs | 148 + gpx-rs/src/engine/command/file/paste.rs | 820 ++ gpx-rs/src/engine/command/file/sync.rs | 151 + gpx-rs/src/engine/command/file/transfer.rs | 462 + gpx-rs/src/engine/command/fixture.rs | 34 + gpx-rs/src/engine/command/mod.rs | 97 + gpx-rs/src/engine/command/pattern/copy.rs | 45 + gpx-rs/src/engine/command/pattern/mod.rs | 11 + gpx-rs/src/engine/command/pattern/produce.rs | 81 + .../engine/command/pattern/update_segments.rs | 91 + .../engine/command/pattern/update_selected.rs | 352 + .../engine/command/pattern/update_waypoint.rs | 29 + gpx-rs/src/engine/command/routes_tests.rs | 724 ++ .../engine/command/tools/change_loop_start.rs | 144 + gpx-rs/src/engine/command/tools/clean.rs | 426 + gpx-rs/src/engine/command/tools/crop.rs | 336 + .../src/engine/command/tools/edit_waypoint.rs | 162 + gpx-rs/src/engine/command/tools/elevation.rs | 131 + gpx-rs/src/engine/command/tools/extract.rs | 674 + .../src/engine/command/tools/insert_anchor.rs | 230 + gpx-rs/src/engine/command/tools/merge.rs | 725 ++ gpx-rs/src/engine/command/tools/mod.rs | 33 + .../src/engine/command/tools/move_waypoint.rs | 107 + .../src/engine/command/tools/new_waypoint.rs | 277 + gpx-rs/src/engine/command/tools/reduce.rs | 149 + gpx-rs/src/engine/command/tools/reverse.rs | 306 + gpx-rs/src/engine/command/tools/round_trip.rs | 144 + gpx-rs/src/engine/command/tools/route.rs | 813 ++ gpx-rs/src/engine/command/tools/split.rs | 458 + gpx-rs/src/engine/command/tools/time.rs | 191 + .../src/engine/derived/coordinates_cache.rs | 154 + gpx-rs/src/engine/derived/file_structure.rs | 186 + .../engine/derived/file_structure_cache.rs | 92 + gpx-rs/src/engine/derived/mod.rs | 13 + gpx-rs/src/engine/derived/routing_buffer.rs | 162 + .../src/engine/derived/statistics_buffer.rs | 746 ++ gpx-rs/src/engine/derived/statistics_cache.rs | 306 + gpx-rs/src/engine/mod.rs | 329 + gpx-rs/src/engine/outcome.rs | 19 + gpx-rs/src/engine/state/clipboard.rs | 500 + gpx-rs/src/engine/state/history/global.rs | 421 + gpx-rs/src/engine/state/history/mod.rs | 128 + gpx-rs/src/engine/state/history/per_file.rs | 350 + gpx-rs/src/engine/state/mod.rs | 17 + gpx-rs/src/engine/state/order.rs | 97 + gpx-rs/src/engine/state/selection.rs | 888 ++ gpx-rs/src/engine/tests.rs | 1612 +++ gpx-rs/src/lib.rs | 10 + gpx-rs/src/storage/codec/mod.rs | 735 ++ gpx-rs/src/storage/codec/v1.rs | 413 + gpx-rs/src/storage/memory.rs | 268 + gpx-rs/src/storage/mod.rs | 231 + gpx-rs/src/storage/persister.rs | 756 ++ gpx/.gitignore | 2 - gpx/package-lock.json | 1829 --- gpx/package.json | 31 - gpx/src/gpx.ts | 1894 --- gpx/src/index.ts | 5 - gpx/src/io.ts | 180 - gpx/src/simplify.ts | 162 - gpx/src/statistics.ts | 391 - gpx/src/types.ts | 125 - gpx/tsconfig.json | 10 - tauri/.gitignore | 2 + tauri/Cargo.toml | 30 + tauri/build.rs | 3 + tauri/capabilities/default.json | 7 + tauri/icons/128x128.png | Bin 0 -> 2923 bytes tauri/icons/128x128@2x.png | Bin 0 -> 5967 bytes tauri/icons/32x32.png | Bin 0 -> 820 bytes tauri/icons/64x64.png | Bin 0 -> 1511 bytes tauri/icons/Square107x107Logo.png | Bin 0 -> 2527 bytes tauri/icons/Square142x142Logo.png | Bin 0 -> 3283 bytes tauri/icons/Square150x150Logo.png | Bin 0 -> 3418 bytes tauri/icons/Square284x284Logo.png | Bin 0 -> 6590 bytes tauri/icons/Square30x30Logo.png | Bin 0 -> 800 bytes tauri/icons/Square310x310Logo.png | Bin 0 -> 7374 bytes tauri/icons/Square44x44Logo.png | Bin 0 -> 1087 bytes tauri/icons/Square71x71Logo.png | Bin 0 -> 1709 bytes tauri/icons/Square89x89Logo.png | Bin 0 -> 2065 bytes tauri/icons/StoreLogo.png | Bin 0 -> 1224 bytes tauri/icons/icon.icns | Bin 0 -> 73228 bytes tauri/icons/icon.ico | Bin 0 -> 12089 bytes tauri/icons/icon.png | Bin 0 -> 12198 bytes tauri/package.json | 8 + tauri/src/bytes.rs | 36 + tauri/src/commands/disk.rs | 454 + tauri/src/commands/edit.rs | 230 + tauri/src/commands/files.rs | 124 + tauri/src/commands/mod.rs | 15 + tauri/src/commands/queries.rs | 112 + tauri/src/commands/selection.rs | 72 + tauri/src/commands/statistics.rs | 106 + tauri/src/commands/storage.rs | 48 + tauri/src/commands/system.rs | 28 + tauri/src/lib.rs | 239 + tauri/src/main.rs | 6 + tauri/src/state.rs | 337 + tauri/src/store.rs | 285 + tauri/src/tests.rs | 787 ++ tauri/src/workspace.rs | 1852 +++ tauri/tauri.conf.json | 48 + wasm/.gitignore | 1 + wasm/Cargo.toml | 21 + wasm/README.md | 20 + wasm/src/command/edit.rs | 40 + wasm/src/command/files.rs | 107 + wasm/src/command/history.rs | 29 + wasm/src/command/mod.rs | 6 + wasm/src/command/routing.rs | 66 + wasm/src/command/tools.rs | 83 + wasm/src/command/waypoints.rs | 76 + wasm/src/convert.rs | 28 + wasm/src/idb.rs | 274 + wasm/src/ids.rs | 17 + wasm/src/lib.rs | 32 + wasm/src/persistence.rs | 267 + wasm/src/queries/coordinates.rs | 33 + wasm/src/queries/files.rs | 121 + wasm/src/queries/mod.rs | 4 + wasm/src/queries/routing.rs | 48 + wasm/src/queries/statistics.rs | 107 + wasm/src/selection.rs | 93 + wasm/src/session.rs | 48 + wasm/src/tests.rs | 520 + wasm/src/ts.rs | 215 + wasm/src/types.rs | 77 + website/.eslintignore | 13 - website/.eslintrc.cjs | 31 - website/components.json | 36 +- website/eslint.config.js | 47 + website/package-lock.json | 2234 +--- website/package.json | 19 +- website/src/app.css | 8 +- website/src/app.d.ts | 5 + website/src/hooks.server.js | 17 +- .../src/lib/actions/default-file-name.test.ts | 43 + website/src/lib/actions/default-file-name.ts | 19 + website/src/lib/actions/file-actions.ts | 204 + .../src/lib/assets/custom/ign-fr-plan.json | 10261 ---------------- .../lib/assets/custom/ign-fr-satellite.json | 6673 ---------- .../src/lib/assets/custom/ign-fr-topo.json | 10261 ---------------- website/src/lib/assets/example.ts | 4 - .../lib/components/AlgoliaDocSearch.svelte | 4 +- .../lib/components/ButtonWithTooltip.svelte | 2 +- website/src/lib/components/Footer.svelte | 9 +- .../src/lib/components/GPXStatistics.svelte | 38 +- website/src/lib/components/Help.svelte | 5 +- .../src/lib/components/LanguageSelect.svelte | 11 +- website/src/lib/components/Logo.svelte | 6 +- website/src/lib/components/Menu.svelte | 409 +- website/src/lib/components/ModeSwitch.svelte | 2 +- website/src/lib/components/Nav.svelte | 11 +- website/src/lib/components/Resizer.svelte | 16 + website/src/lib/components/Shortcut.svelte | 2 +- website/src/lib/components/WithUnits.svelte | 6 +- .../collapsible-tree/CollapsibleTree.svelte | 26 +- .../CollapsibleTreeNode.svelte | 64 +- .../lib/components/collapsible-tree/sticky.ts | 79 + .../collapsible-tree/utils.svelte.ts | 31 + website/src/lib/components/docs/docs.ts | 83 +- .../src/lib/components/docs/guides.test.ts | 81 + website/src/lib/components/docs/guides.ts | 84 + .../elevation-profile/ElevationProfile.svelte | 20 +- .../elevation-profile/elevation-profile.ts | 406 +- .../lib/components/embedding/Embedding.svelte | 56 +- .../embedding/EmbeddingPlayground.svelte | 17 +- .../lib/components/embedding/OpenIn.svelte | 5 +- .../components/embedding/embedding.test.ts | 148 + .../src/lib/components/embedding/embedding.ts | 16 +- .../lib/components/explorer/Explorer.svelte | 190 + .../components/explorer/ExplorerRow.svelte | 342 + .../components/explorer/ExplorerTree.svelte | 155 + .../lib/components/explorer/NameInput.svelte | 45 + website/src/lib/components/explorer/index.ts | 1 + .../lib/components/explorer/state.svelte.ts | 49 + .../src/lib/components/export/Export.svelte | 109 +- .../lib/components/export/file-names.test.ts | 97 + .../src/lib/components/export/file-names.ts | 47 + .../src/lib/components/export/utils.svelte.ts | 87 +- .../lib/components/file-list/FileList.svelte | 46 +- .../components/file-list/FileListNode.svelte | 122 +- .../file-list/FileListNodeContent.svelte | 161 +- .../file-list/FileListNodeLabel.svelte | 354 +- .../file-list/FileListNodeStore.svelte | 19 +- .../components/file-list/UnsavedDialog.svelte | 69 + .../src/lib/components/file-list/file-list.ts | 300 - .../file-list/metadata/MetadataDialog.svelte | 93 +- .../file-list/sortable-file-list.ts | 440 +- .../src/lib/components/file-list/sortable.ts | 7 + .../file-list/style/StyleDialog.svelte | 109 +- .../file-list/unsaved-prompt.svelte.ts | 32 + .../components/map/CoordinatesPopup.svelte | 11 +- website/src/lib/components/map/Map.svelte | 2 +- .../src/lib/components/map/MapPopup.svelte | 5 +- .../map/gpx-layer/CopyCoordinates.svelte | 7 +- .../map/gpx-layer/TrackpointPopup.svelte | 22 +- .../map/gpx-layer/WaypointPopup.svelte | 41 +- .../map/gpx-layer/distance-markers.ts | 32 +- .../lib/components/map/gpx-layer/gpx-layer.ts | 439 +- .../components/map/gpx-layer/gpx-layers.ts | 10 +- .../map/gpx-layer/start-end-markers.ts | 36 +- .../map/layer-control/CustomLayers.svelte | 49 +- .../map/layer-control/LayerControl.svelte | 2 +- .../layer-control/LayerControlSettings.svelte | 18 +- .../map/layer-control/LayerTree.svelte | 2 +- .../map/layer-control/LayerTreeNode.svelte | 28 +- .../map/layer-control/OverpassPopup.svelte | 53 +- .../map/layer-control/extension-api.test.ts | 153 + .../map/layer-control/extension-api.ts | 25 +- .../map/layer-control/overpass-layer.ts | 128 +- .../map/layer-control/utils.test.ts | 74 + .../lib/components/map/layer-control/utils.ts | 2 +- .../components/map/map-layer-event-manager.ts | 29 +- website/src/lib/components/map/map-popup.ts | 12 +- website/src/lib/components/map/map.ts | 32 +- .../StreetViewControl.svelte | 4 +- .../map/street-view-control/google.ts | 2 +- .../map/street-view-control/mapillary.ts | 6 +- website/src/lib/components/map/style.ts | 55 +- .../src/lib/components/toolbar/Toolbar.svelte | 2 +- .../components/toolbar/ToolbarItemMenu.svelte | 2 +- .../lib/components/toolbar/tools/Clean.svelte | 36 +- .../components/toolbar/tools/Elevation.svelte | 25 +- .../components/toolbar/tools/Extract.svelte | 72 +- .../lib/components/toolbar/tools/Merge.svelte | 64 +- .../lib/components/toolbar/tools/Time.svelte | 161 +- .../toolbar/tools/reduce/Reduce.svelte | 43 +- .../toolbar/tools/reduce/utils.svelte.ts | 206 +- .../toolbar/tools/routing/Routing.svelte | 113 +- .../tools/routing/RoutingControlPopup.svelte | 2 +- .../toolbar/tools/routing/routing-controls.ts | 1176 +- .../toolbar/tools/routing/routing.test.ts | 282 + .../toolbar/tools/routing/routing.ts | 256 +- .../toolbar/tools/routing/simplify.ts | 51 - .../toolbar/tools/scissors/Scissors.svelte | 118 +- .../toolbar/tools/scissors/scissors.ts | 88 +- .../toolbar/tools/scissors/split-controls.ts | 147 +- .../toolbar/tools/waypoint/Waypoint.svelte | 77 +- .../toolbar/tools/waypoint/waypoint.ts | 129 +- .../lib/components/ui/button/button.svelte | 3 + .../lib/components/ui/slider/slider.svelte | 10 +- .../ui/time-picker/TimePicker.svelte | 19 +- website/src/lib/data/colors.test.ts | 51 + website/src/lib/{assets => data}/colors.ts | 16 +- .../custom/bikerouter-gravel.json | 0 website/src/lib/data/example/example-data.ts | 290 + website/src/lib/data/example/example.gpx | 1 + website/src/lib/data/example/example.test.ts | 48 + website/src/lib/data/example/index.ts | 81 + website/src/lib/data/layers.test.ts | 103 + website/src/lib/{assets => data}/layers.ts | 44 +- website/src/lib/{assets => data}/symbols.ts | 0 website/src/lib/db.ts | 37 - website/src/lib/desktop/links.ts | 52 + website/src/lib/docs/be/files-and-stats.mdx | 14 +- website/src/lib/docs/ca/files-and-stats.mdx | 14 +- website/src/lib/docs/cs/files-and-stats.mdx | 14 +- website/src/lib/docs/da/files-and-stats.mdx | 14 +- website/src/lib/docs/de/files-and-stats.mdx | 14 +- website/src/lib/docs/el/files-and-stats.mdx | 14 +- website/src/lib/docs/en/files-and-stats.mdx | 16 +- website/src/lib/docs/en/gpx.mdx | 18 +- website/src/lib/docs/en/menu/edit.mdx | 36 +- website/src/lib/docs/en/menu/file.mdx | 4 + website/src/lib/docs/en/menu/view.mdx | 12 +- website/src/lib/docs/en/toolbar/extract.mdx | 2 +- website/src/lib/docs/en/toolbar/merge.mdx | 6 +- website/src/lib/docs/en/toolbar/scissors.mdx | 2 +- website/src/lib/docs/es/files-and-stats.mdx | 14 +- website/src/lib/docs/eu/files-and-stats.mdx | 14 +- website/src/lib/docs/fi/files-and-stats.mdx | 14 +- website/src/lib/docs/fr/files-and-stats.mdx | 14 +- website/src/lib/docs/he/files-and-stats.mdx | 14 +- website/src/lib/docs/hu/files-and-stats.mdx | 14 +- website/src/lib/docs/id/files-and-stats.mdx | 14 +- website/src/lib/docs/it/files-and-stats.mdx | 14 +- website/src/lib/docs/ko/files-and-stats.mdx | 14 +- website/src/lib/docs/lt/files-and-stats.mdx | 14 +- website/src/lib/docs/lv/files-and-stats.mdx | 14 +- website/src/lib/docs/nl/files-and-stats.mdx | 14 +- website/src/lib/docs/no/files-and-stats.mdx | 14 +- website/src/lib/docs/pl/files-and-stats.mdx | 14 +- .../src/lib/docs/pt-BR/files-and-stats.mdx | 14 +- website/src/lib/docs/pt/files-and-stats.mdx | 14 +- website/src/lib/docs/ro/files-and-stats.mdx | 14 +- website/src/lib/docs/ru/files-and-stats.mdx | 14 +- website/src/lib/docs/sr/files-and-stats.mdx | 14 +- website/src/lib/docs/sv/files-and-stats.mdx | 14 +- website/src/lib/docs/th/files-and-stats.mdx | 14 +- website/src/lib/docs/tr/files-and-stats.mdx | 14 +- website/src/lib/docs/uk/files-and-stats.mdx | 14 +- website/src/lib/docs/vi/files-and-stats.mdx | 14 +- .../src/lib/docs/zh-HK/files-and-stats.mdx | 14 +- website/src/lib/docs/zh/files-and-stats.mdx | 14 +- website/src/lib/engine/backend/index.ts | 18 + website/src/lib/engine/backend/tauri.ts | 253 + website/src/lib/engine/backend/types.ts | 349 + website/src/lib/engine/backend/wasm.ts | 177 + website/src/lib/engine/convert.ts | 8 + website/src/lib/engine/engine.test.ts | 506 + website/src/lib/engine/engine.ts | 1097 ++ website/src/lib/engine/file-colors.test.ts | 63 + website/src/lib/engine/file-colors.ts | 59 + website/src/lib/engine/file-state.ts | 118 + .../src/lib/engine/file-visibility.test.ts | 221 + website/src/lib/engine/file-visibility.ts | 175 + website/src/lib/engine/index.ts | 13 + website/src/lib/engine/observer.ts | 50 + .../src/lib/engine/selection-helpers.test.ts | 315 + website/src/lib/engine/selection-helpers.ts | 280 + website/src/lib/engine/selection-stores.ts | 63 + website/src/lib/engine/statistics.ts | 214 + website/src/lib/engine/storage.ts | 127 + .../lib/engine/trackpoint-categories.test.ts | 106 + .../src/lib/engine/trackpoint-categories.ts | 97 + website/src/lib/engine/types.ts | 265 + website/src/lib/engine/workspace.test.ts | 93 + website/src/lib/engine/workspace.ts | 59 + website/src/lib/geo/closest-point.test.ts | 50 + website/src/lib/geo/closest-point.ts | 71 + website/src/lib/geo/coordinates.test.ts | 31 + website/src/lib/geo/coordinates.ts | 11 + website/src/lib/{ => i18n}/i18n.svelte.ts | 2 +- website/src/lib/i18n/keys.test.ts | 84 + website/src/lib/{ => i18n}/languages.ts | 0 website/src/lib/index.ts | 1 - website/src/lib/logic/bounds.ts | 134 - website/src/lib/logic/file-action-manager.ts | 253 - website/src/lib/logic/file-actions.ts | 1139 -- website/src/lib/logic/file-state.ts | 208 - website/src/lib/logic/hidden.ts | 67 - website/src/lib/logic/selection-tree.ts | 140 - website/src/lib/logic/selection.ts | 424 - website/src/lib/logic/settings.ts | 349 - website/src/lib/logic/statistics-tree.ts | 74 - website/src/lib/logic/statistics.ts | 89 - website/src/lib/scripts/example-statistics.ts | 95 + website/src/lib/scripts/pwa-manifest.ts | 4 +- website/src/lib/scripts/sitemap.ts | 2 +- website/src/lib/state/bounds.ts | 149 + .../src/lib/{logic => state}/map-cursor.ts | 4 +- website/src/lib/state/selection-statistics.ts | 20 + website/src/lib/state/setting.test.ts | 313 + website/src/lib/state/setting.ts | 230 + website/src/lib/state/settings.test.ts | 75 + website/src/lib/state/settings.ts | 189 + website/src/lib/storage/cached-fetch.test.ts | 143 + website/src/lib/storage/cached-fetch.ts | 89 + website/src/lib/storage/legacy-import.test.ts | 189 + website/src/lib/storage/legacy-import.ts | 211 + website/src/lib/units/conversions.test.ts | 75 + website/src/lib/units/conversions.ts | 94 + website/src/lib/{ => units}/units.ts | 107 +- website/src/lib/utils.test.ts | 50 + website/src/lib/utils.ts | 154 +- website/src/locales/be.json | 1 - website/src/locales/ca.json | 1 - website/src/locales/cs.json | 1 - website/src/locales/da.json | 1 - website/src/locales/de.json | 1 - website/src/locales/el.json | 1 - website/src/locales/en.json | 48 +- website/src/locales/es.json | 1 - website/src/locales/eu.json | 1 - website/src/locales/fi.json | 1 - website/src/locales/fr.json | 1 - website/src/locales/he.json | 1 - website/src/locales/hu.json | 1 - website/src/locales/id.json | 1 - website/src/locales/it.json | 1 - website/src/locales/ko.json | 1 - website/src/locales/lt.json | 1 - website/src/locales/lv.json | 1 - website/src/locales/nl.json | 1 - website/src/locales/no.json | 1 - website/src/locales/pl.json | 1 - website/src/locales/pt-BR.json | 1 - website/src/locales/pt.json | 1 - website/src/locales/ro.json | 1 - website/src/locales/ru.json | 1 - website/src/locales/sr.json | 1 - website/src/locales/sv.json | 1 - website/src/locales/th.json | 1 - website/src/locales/tr.json | 1 - website/src/locales/uk.json | 1 - website/src/locales/vi.json | 1 - website/src/locales/zh-HK.json | 1 - website/src/locales/zh.json | 1 - website/src/routes/+layout.server.ts | 4 +- website/src/routes/+layout.svelte | 12 +- website/src/routes/404/+page.svelte | 9 +- .../src/routes/[[language]]/+page.server.ts | 2 +- website/src/routes/[[language]]/+page.svelte | 28 +- .../routes/[[language]]/app/+page.server.ts | 2 +- .../src/routes/[[language]]/app/+page.svelte | 169 +- .../routes/[[language]]/embed/+page.server.ts | 2 +- .../routes/[[language]]/help/+layout.svelte | 11 +- .../routes/[[language]]/help/+page.server.ts | 2 +- .../src/routes/[[language]]/help/+page.svelte | 13 +- .../help/[...guide]/+page.server.ts | 10 +- .../[[language]]/help/[...guide]/+page.svelte | 17 +- website/src/test/hooks.mjs | 31 + website/src/test/register.mjs | 12 + website/src/test/stubs/app-environment.ts | 8 + website/src/test/stubs/app-paths.ts | 6 + website/src/test/stubs/app-state.ts | 2 + website/src/test/stubs/env-static-public.ts | 1 + website/svelte.config.js | 7 +- website/vite.config.ts | 49 +- 484 files changed, 53949 insertions(+), 41152 deletions(-) create mode 100644 .github/workflows/deploy-cloudflare.yml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 gpx-rs/Cargo.toml create mode 100644 gpx-rs/README.md create mode 100644 gpx-rs/data/self_closing_points.gpx rename {gpx/test-data => gpx-rs/data}/simple.gpx (90%) rename {gpx/test-data => gpx-rs/data}/with_cad.gpx (100%) create mode 100644 gpx-rs/data/with_highway.gpx rename {gpx/test-data => gpx-rs/data}/with_hr.gpx (100%) create mode 100644 gpx-rs/data/with_links.gpx rename {gpx/test-data => gpx-rs/data}/with_power_1.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_power_2.gpx (97%) create mode 100644 gpx-rs/data/with_power_3.gpx create mode 100644 gpx-rs/data/with_route_extensions.gpx create mode 100644 gpx-rs/data/with_route_points.gpx rename {gpx/test-data => gpx-rs/data}/with_routes.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_segments.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_style.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_surface.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_temp.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_time.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_tracks.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_tracks_and_segments.gpx (100%) rename {gpx/test-data => gpx-rs/data}/with_waypoint.gpx (91%) create mode 100644 gpx-rs/src/api/commands.rs create mode 100644 gpx-rs/src/api/dto.rs create mode 100644 gpx-rs/src/api/mod.rs create mode 100644 gpx-rs/src/api/queries.rs create mode 100644 gpx-rs/src/core/algorithm/anchors.rs create mode 100644 gpx-rs/src/core/algorithm/mod.rs create mode 100644 gpx-rs/src/core/algorithm/reverse.rs create mode 100644 gpx-rs/src/core/algorithm/simplify.rs create mode 100644 gpx-rs/src/core/algorithm/smooth.rs create mode 100644 gpx-rs/src/core/algorithm/timestamps.rs create mode 100644 gpx-rs/src/core/gpx/categories.rs create mode 100644 gpx-rs/src/core/gpx/chunk.rs create mode 100644 gpx-rs/src/core/gpx/chunked.rs create mode 100644 gpx-rs/src/core/gpx/common.rs create mode 100644 gpx-rs/src/core/gpx/file.rs create mode 100644 gpx-rs/src/core/gpx/mod.rs create mode 100644 gpx-rs/src/core/gpx/route.rs create mode 100644 gpx-rs/src/core/gpx/segment.rs create mode 100644 gpx-rs/src/core/gpx/track.rs create mode 100644 gpx-rs/src/core/gpx/trackpoint.rs create mode 100644 gpx-rs/src/core/gpx/waypoint.rs create mode 100644 gpx-rs/src/core/gpx/waypoints.rs create mode 100644 gpx-rs/src/core/io/mod.rs create mode 100644 gpx-rs/src/core/io/parse.rs create mode 100644 gpx-rs/src/core/io/write.rs create mode 100644 gpx-rs/src/core/mod.rs create mode 100644 gpx-rs/src/core/statistics/compute.rs create mode 100644 gpx-rs/src/core/statistics/mod.rs create mode 100644 gpx-rs/src/core/statistics/utils.rs create mode 100644 gpx-rs/src/core/utils.rs create mode 100644 gpx-rs/src/engine/action.rs create mode 100644 gpx-rs/src/engine/command/apply.rs create mode 100644 gpx-rs/src/engine/command/edit/convert.rs create mode 100644 gpx-rs/src/engine/command/edit/metadata.rs create mode 100644 gpx-rs/src/engine/command/edit/mod.rs create mode 100644 gpx-rs/src/engine/command/edit/new_route.rs create mode 100644 gpx-rs/src/engine/command/edit/new_track.rs create mode 100644 gpx-rs/src/engine/command/edit/new_track_segment.rs create mode 100644 gpx-rs/src/engine/command/edit/style.rs create mode 100644 gpx-rs/src/engine/command/file/delete.rs create mode 100644 gpx-rs/src/engine/command/file/delete_all.rs create mode 100644 gpx-rs/src/engine/command/file/delete_waypoint.rs create mode 100644 gpx-rs/src/engine/command/file/duplicate.rs create mode 100644 gpx-rs/src/engine/command/file/load.rs create mode 100644 gpx-rs/src/engine/command/file/mod.rs create mode 100644 gpx-rs/src/engine/command/file/move_elements.rs create mode 100644 gpx-rs/src/engine/command/file/new.rs create mode 100644 gpx-rs/src/engine/command/file/paste.rs create mode 100644 gpx-rs/src/engine/command/file/sync.rs create mode 100644 gpx-rs/src/engine/command/file/transfer.rs create mode 100644 gpx-rs/src/engine/command/fixture.rs create mode 100644 gpx-rs/src/engine/command/mod.rs create mode 100644 gpx-rs/src/engine/command/pattern/copy.rs create mode 100644 gpx-rs/src/engine/command/pattern/mod.rs create mode 100644 gpx-rs/src/engine/command/pattern/produce.rs create mode 100644 gpx-rs/src/engine/command/pattern/update_segments.rs create mode 100644 gpx-rs/src/engine/command/pattern/update_selected.rs create mode 100644 gpx-rs/src/engine/command/pattern/update_waypoint.rs create mode 100644 gpx-rs/src/engine/command/routes_tests.rs create mode 100644 gpx-rs/src/engine/command/tools/change_loop_start.rs create mode 100644 gpx-rs/src/engine/command/tools/clean.rs create mode 100644 gpx-rs/src/engine/command/tools/crop.rs create mode 100644 gpx-rs/src/engine/command/tools/edit_waypoint.rs create mode 100644 gpx-rs/src/engine/command/tools/elevation.rs create mode 100644 gpx-rs/src/engine/command/tools/extract.rs create mode 100644 gpx-rs/src/engine/command/tools/insert_anchor.rs create mode 100644 gpx-rs/src/engine/command/tools/merge.rs create mode 100644 gpx-rs/src/engine/command/tools/mod.rs create mode 100644 gpx-rs/src/engine/command/tools/move_waypoint.rs create mode 100644 gpx-rs/src/engine/command/tools/new_waypoint.rs create mode 100644 gpx-rs/src/engine/command/tools/reduce.rs create mode 100644 gpx-rs/src/engine/command/tools/reverse.rs create mode 100644 gpx-rs/src/engine/command/tools/round_trip.rs create mode 100644 gpx-rs/src/engine/command/tools/route.rs create mode 100644 gpx-rs/src/engine/command/tools/split.rs create mode 100644 gpx-rs/src/engine/command/tools/time.rs create mode 100644 gpx-rs/src/engine/derived/coordinates_cache.rs create mode 100644 gpx-rs/src/engine/derived/file_structure.rs create mode 100644 gpx-rs/src/engine/derived/file_structure_cache.rs create mode 100644 gpx-rs/src/engine/derived/mod.rs create mode 100644 gpx-rs/src/engine/derived/routing_buffer.rs create mode 100644 gpx-rs/src/engine/derived/statistics_buffer.rs create mode 100644 gpx-rs/src/engine/derived/statistics_cache.rs create mode 100644 gpx-rs/src/engine/mod.rs create mode 100644 gpx-rs/src/engine/outcome.rs create mode 100644 gpx-rs/src/engine/state/clipboard.rs create mode 100644 gpx-rs/src/engine/state/history/global.rs create mode 100644 gpx-rs/src/engine/state/history/mod.rs create mode 100644 gpx-rs/src/engine/state/history/per_file.rs create mode 100644 gpx-rs/src/engine/state/mod.rs create mode 100644 gpx-rs/src/engine/state/order.rs create mode 100644 gpx-rs/src/engine/state/selection.rs create mode 100644 gpx-rs/src/engine/tests.rs create mode 100644 gpx-rs/src/lib.rs create mode 100644 gpx-rs/src/storage/codec/mod.rs create mode 100644 gpx-rs/src/storage/codec/v1.rs create mode 100644 gpx-rs/src/storage/memory.rs create mode 100644 gpx-rs/src/storage/mod.rs create mode 100644 gpx-rs/src/storage/persister.rs delete mode 100644 gpx/.gitignore delete mode 100644 gpx/package-lock.json delete mode 100644 gpx/package.json delete mode 100644 gpx/src/gpx.ts delete mode 100644 gpx/src/index.ts delete mode 100644 gpx/src/io.ts delete mode 100644 gpx/src/simplify.ts delete mode 100644 gpx/src/statistics.ts delete mode 100644 gpx/src/types.ts delete mode 100644 gpx/tsconfig.json create mode 100644 tauri/.gitignore create mode 100644 tauri/Cargo.toml create mode 100644 tauri/build.rs create mode 100644 tauri/capabilities/default.json create mode 100644 tauri/icons/128x128.png create mode 100644 tauri/icons/128x128@2x.png create mode 100644 tauri/icons/32x32.png create mode 100644 tauri/icons/64x64.png create mode 100644 tauri/icons/Square107x107Logo.png create mode 100644 tauri/icons/Square142x142Logo.png create mode 100644 tauri/icons/Square150x150Logo.png create mode 100644 tauri/icons/Square284x284Logo.png create mode 100644 tauri/icons/Square30x30Logo.png create mode 100644 tauri/icons/Square310x310Logo.png create mode 100644 tauri/icons/Square44x44Logo.png create mode 100644 tauri/icons/Square71x71Logo.png create mode 100644 tauri/icons/Square89x89Logo.png create mode 100644 tauri/icons/StoreLogo.png create mode 100644 tauri/icons/icon.icns create mode 100644 tauri/icons/icon.ico create mode 100644 tauri/icons/icon.png create mode 100644 tauri/package.json create mode 100644 tauri/src/bytes.rs create mode 100644 tauri/src/commands/disk.rs create mode 100644 tauri/src/commands/edit.rs create mode 100644 tauri/src/commands/files.rs create mode 100644 tauri/src/commands/mod.rs create mode 100644 tauri/src/commands/queries.rs create mode 100644 tauri/src/commands/selection.rs create mode 100644 tauri/src/commands/statistics.rs create mode 100644 tauri/src/commands/storage.rs create mode 100644 tauri/src/commands/system.rs create mode 100644 tauri/src/lib.rs create mode 100644 tauri/src/main.rs create mode 100644 tauri/src/state.rs create mode 100644 tauri/src/store.rs create mode 100644 tauri/src/tests.rs create mode 100644 tauri/src/workspace.rs create mode 100644 tauri/tauri.conf.json create mode 100644 wasm/.gitignore create mode 100644 wasm/Cargo.toml create mode 100644 wasm/README.md create mode 100644 wasm/src/command/edit.rs create mode 100644 wasm/src/command/files.rs create mode 100644 wasm/src/command/history.rs create mode 100644 wasm/src/command/mod.rs create mode 100644 wasm/src/command/routing.rs create mode 100644 wasm/src/command/tools.rs create mode 100644 wasm/src/command/waypoints.rs create mode 100644 wasm/src/convert.rs create mode 100644 wasm/src/idb.rs create mode 100644 wasm/src/ids.rs create mode 100644 wasm/src/lib.rs create mode 100644 wasm/src/persistence.rs create mode 100644 wasm/src/queries/coordinates.rs create mode 100644 wasm/src/queries/files.rs create mode 100644 wasm/src/queries/mod.rs create mode 100644 wasm/src/queries/routing.rs create mode 100644 wasm/src/queries/statistics.rs create mode 100644 wasm/src/selection.rs create mode 100644 wasm/src/session.rs create mode 100644 wasm/src/tests.rs create mode 100644 wasm/src/ts.rs create mode 100644 wasm/src/types.rs delete mode 100644 website/.eslintignore delete mode 100644 website/.eslintrc.cjs create mode 100644 website/eslint.config.js create mode 100644 website/src/lib/actions/default-file-name.test.ts create mode 100644 website/src/lib/actions/default-file-name.ts create mode 100644 website/src/lib/actions/file-actions.ts delete mode 100644 website/src/lib/assets/custom/ign-fr-plan.json delete mode 100644 website/src/lib/assets/custom/ign-fr-satellite.json delete mode 100644 website/src/lib/assets/custom/ign-fr-topo.json delete mode 100644 website/src/lib/assets/example.ts create mode 100644 website/src/lib/components/collapsible-tree/sticky.ts create mode 100644 website/src/lib/components/docs/guides.test.ts create mode 100644 website/src/lib/components/docs/guides.ts create mode 100644 website/src/lib/components/embedding/embedding.test.ts create mode 100644 website/src/lib/components/explorer/Explorer.svelte create mode 100644 website/src/lib/components/explorer/ExplorerRow.svelte create mode 100644 website/src/lib/components/explorer/ExplorerTree.svelte create mode 100644 website/src/lib/components/explorer/NameInput.svelte create mode 100644 website/src/lib/components/explorer/index.ts create mode 100644 website/src/lib/components/explorer/state.svelte.ts create mode 100644 website/src/lib/components/export/file-names.test.ts create mode 100644 website/src/lib/components/export/file-names.ts create mode 100644 website/src/lib/components/file-list/UnsavedDialog.svelte delete mode 100644 website/src/lib/components/file-list/file-list.ts create mode 100644 website/src/lib/components/file-list/sortable.ts create mode 100644 website/src/lib/components/file-list/unsaved-prompt.svelte.ts create mode 100644 website/src/lib/components/map/layer-control/extension-api.test.ts create mode 100644 website/src/lib/components/map/layer-control/utils.test.ts create mode 100644 website/src/lib/components/toolbar/tools/routing/routing.test.ts delete mode 100644 website/src/lib/components/toolbar/tools/routing/simplify.ts create mode 100644 website/src/lib/data/colors.test.ts rename website/src/lib/{assets => data}/colors.ts (89%) rename website/src/lib/{assets => data}/custom/bikerouter-gravel.json (100%) create mode 100644 website/src/lib/data/example/example-data.ts create mode 100644 website/src/lib/data/example/example.gpx create mode 100644 website/src/lib/data/example/example.test.ts create mode 100644 website/src/lib/data/example/index.ts create mode 100644 website/src/lib/data/layers.test.ts rename website/src/lib/{assets => data}/layers.ts (95%) rename website/src/lib/{assets => data}/symbols.ts (100%) delete mode 100644 website/src/lib/db.ts create mode 100644 website/src/lib/desktop/links.ts create mode 100644 website/src/lib/engine/backend/index.ts create mode 100644 website/src/lib/engine/backend/tauri.ts create mode 100644 website/src/lib/engine/backend/types.ts create mode 100644 website/src/lib/engine/backend/wasm.ts create mode 100644 website/src/lib/engine/convert.ts create mode 100644 website/src/lib/engine/engine.test.ts create mode 100644 website/src/lib/engine/engine.ts create mode 100644 website/src/lib/engine/file-colors.test.ts create mode 100644 website/src/lib/engine/file-colors.ts create mode 100644 website/src/lib/engine/file-state.ts create mode 100644 website/src/lib/engine/file-visibility.test.ts create mode 100644 website/src/lib/engine/file-visibility.ts create mode 100644 website/src/lib/engine/index.ts create mode 100644 website/src/lib/engine/observer.ts create mode 100644 website/src/lib/engine/selection-helpers.test.ts create mode 100644 website/src/lib/engine/selection-helpers.ts create mode 100644 website/src/lib/engine/selection-stores.ts create mode 100644 website/src/lib/engine/statistics.ts create mode 100644 website/src/lib/engine/storage.ts create mode 100644 website/src/lib/engine/trackpoint-categories.test.ts create mode 100644 website/src/lib/engine/trackpoint-categories.ts create mode 100644 website/src/lib/engine/types.ts create mode 100644 website/src/lib/engine/workspace.test.ts create mode 100644 website/src/lib/engine/workspace.ts create mode 100644 website/src/lib/geo/closest-point.test.ts create mode 100644 website/src/lib/geo/closest-point.ts create mode 100644 website/src/lib/geo/coordinates.test.ts create mode 100644 website/src/lib/geo/coordinates.ts rename website/src/lib/{ => i18n}/i18n.svelte.ts (96%) create mode 100644 website/src/lib/i18n/keys.test.ts rename website/src/lib/{ => i18n}/languages.ts (100%) delete mode 100644 website/src/lib/index.ts delete mode 100644 website/src/lib/logic/bounds.ts delete mode 100644 website/src/lib/logic/file-action-manager.ts delete mode 100644 website/src/lib/logic/file-actions.ts delete mode 100644 website/src/lib/logic/file-state.ts delete mode 100644 website/src/lib/logic/hidden.ts delete mode 100644 website/src/lib/logic/selection-tree.ts delete mode 100644 website/src/lib/logic/selection.ts delete mode 100644 website/src/lib/logic/settings.ts delete mode 100644 website/src/lib/logic/statistics-tree.ts delete mode 100644 website/src/lib/logic/statistics.ts create mode 100644 website/src/lib/scripts/example-statistics.ts create mode 100644 website/src/lib/state/bounds.ts rename website/src/lib/{logic => state}/map-cursor.ts (95%) create mode 100644 website/src/lib/state/selection-statistics.ts create mode 100644 website/src/lib/state/setting.test.ts create mode 100644 website/src/lib/state/setting.ts create mode 100644 website/src/lib/state/settings.test.ts create mode 100644 website/src/lib/state/settings.ts create mode 100644 website/src/lib/storage/cached-fetch.test.ts create mode 100644 website/src/lib/storage/cached-fetch.ts create mode 100644 website/src/lib/storage/legacy-import.test.ts create mode 100644 website/src/lib/storage/legacy-import.ts create mode 100644 website/src/lib/units/conversions.test.ts create mode 100644 website/src/lib/units/conversions.ts rename website/src/lib/{ => units}/units.ts (62%) create mode 100644 website/src/lib/utils.test.ts create mode 100644 website/src/test/hooks.mjs create mode 100644 website/src/test/register.mjs create mode 100644 website/src/test/stubs/app-environment.ts create mode 100644 website/src/test/stubs/app-paths.ts create mode 100644 website/src/test/stubs/app-state.ts create mode 100644 website/src/test/stubs/env-static-public.ts diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml new file mode 100644 index 000000000..a291dc08a --- /dev/null +++ b/.github/workflows/deploy-cloudflare.yml @@ -0,0 +1,67 @@ +name: Deploy to Cloudflare Pages +on: + push: + branches: 'dev' + workflow_dispatch: + +concurrency: + group: cloudflare-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + deployments: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + 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 + run: npm install --prefix website + + - name: Test website + run: npm test --prefix website + + - name: Create env file + run: echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env + + - name: Build website + env: + BASE_PATH: '' + run: npm run build --prefix website + + - name: Deploy to Cloudflare Pages + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy website/build --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME || 'gpxstudio' }} --branch=${{ github.ref_name }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d354c062b..7b97a1c22 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,19 +15,31 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: | - gpx/package-lock.json - website/package-lock.json + cache-dependency-path: website/package-lock.json - - name: Install dependencies for gpx - run: npm install --prefix gpx + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown - - name: Build gpx - run: npm run build --prefix gpx + - name: Install wasm-pack + 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 run: npm install --prefix website + - name: Test website + run: npm test --prefix website + - name: Create env file run: | touch website/.env diff --git a/.gitignore b/.gitignore index 496ee2ca6..f2564bc44 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.DS_Store \ No newline at end of file +.DS_Store +target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..162d5b9ed --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5330 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[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 = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[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 = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[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", + "serde", + "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 2.0.21", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[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 = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[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 = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[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-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[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 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpx-engine" +version = "0.1.0" +dependencies = [ + "chrono", + "postcard", + "quick-xml", + "serde", + "uuid", +] + +[[package]] +name = "gpx-studio-desktop" +version = "0.1.0" +dependencies = [ + "gpx-engine", + "notify", + "notify-debouncer-mini", + "pollster", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-opener", + "tauri-plugin-single-instance", + "trash", + "uuid", +] + +[[package]] +name = "gpx-wasm" +version = "0.1.0" +dependencies = [ + "console_error_panic_hook", + "gpx-engine", + "idb", + "js-sys", + "serde", + "serde-wasm-bindgen", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[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 = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idb" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828" +dependencies = [ + "indexmap 2.14.2", + "js-sys", + "num-traits", + "thiserror 2.0.21", + "tokio", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[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 = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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 = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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 = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[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 = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[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 = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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 = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[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_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[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 = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c404ecf956ff241bf6b39dd688614724e1bf1f63255abb4fc46d1c40371672ea" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02f468f9a9bcd404f57e5c191d91024e3de953d4b610f78c2907e7110615cb1c" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.118", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "131bffec939b20642380317f8403a33c8888489925e14bb965ceaff0d949b1cb" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1140cf34a3b3b836a13103dcab17f18831d5cc3534cbd435dc01a5c6daa65aa2" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dca325d6e66e9170f2e1b9631722e4e544bcbc51781575d266abdaba697bd51" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213fae2d85d4b76b06de5915d1862db9fa74541171f9d2c15da6ac9bd6e788c6" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "trash" +version = "5.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" +dependencies = [ + "chrono", + "libc", + "log", + "objc2", + "objc2-foundation", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows", +] + +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[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 = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[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 = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows", + "windows-core", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[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-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..43d8f2060 --- /dev/null +++ b/Cargo.toml @@ -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" \ No newline at end of file diff --git a/README.md b/README.md index dce2a14f0..4a8f5f895 100644 --- a/README.md +++ b/README.md @@ -25,19 +25,20 @@ Any help is greatly appreciated! ## Development -The code is split into two parts: +The code is split into four parts: -- `gpx`: a Typescript library for parsing and manipulating GPX files, -- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application. +- `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`), +- `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/) 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` library +### Building the WebAssembly engine ```bash -cd gpx -npm install -npm run build +cd wasm +wasm-pack build --target bundler ``` ### Running the website @@ -64,9 +65,6 @@ This project has been made possible thanks to the following open source projects - [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling - [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts - Logic: - - [immer](https://github.com/immerjs/immer) — complex state management - - [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper - - [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing - [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree - Mapping: - [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) — beautiful and fast interactive map rendering diff --git a/gpx-rs/Cargo.toml b/gpx-rs/Cargo.toml new file mode 100644 index 000000000..f6e72066f --- /dev/null +++ b/gpx-rs/Cargo.toml @@ -0,0 +1,11 @@ +[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"] } diff --git a/gpx-rs/README.md b/gpx-rs/README.md new file mode 100644 index 000000000..9473ae43b --- /dev/null +++ b/gpx-rs/README.md @@ -0,0 +1,10 @@ +# gpx-rs + +The GPX engine: plain Rust, no knowledge of who uses it. Tested natively with `cargo test` (from +here or from the root). + +- `src/core`, `src/engine`, `src/storage`: the engine itself. +- `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 + bytes, results to JS objects or JSON, slices to typed arrays or raw bytes. diff --git a/gpx-rs/data/self_closing_points.gpx b/gpx-rs/data/self_closing_points.gpx new file mode 100644 index 000000000..2b119cd85 --- /dev/null +++ b/gpx-rs/data/self_closing_points.gpx @@ -0,0 +1,26 @@ + + + + self closing points + Points without children are self-closing elements. + + 2.0 + + + + + with children + + + track + + + 12.5 + + + + + + + diff --git a/gpx/test-data/simple.gpx b/gpx-rs/data/simple.gpx similarity index 90% rename from gpx/test-data/simple.gpx rename to gpx-rs/data/simple.gpx index be565954b..c73a67fb5 100644 --- a/gpx/test-data/simple.gpx +++ b/gpx-rs/data/simple.gpx @@ -1,18 +1,32 @@ + xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" + creator="https://gpx.studio"> simple + description gpx.studio - + + link text + - simple + track name + track comment + track description + track source + + track link text + Cycling diff --git a/gpx/test-data/with_cad.gpx b/gpx-rs/data/with_cad.gpx similarity index 100% rename from gpx/test-data/with_cad.gpx rename to gpx-rs/data/with_cad.gpx diff --git a/gpx-rs/data/with_highway.gpx b/gpx-rs/data/with_highway.gpx new file mode 100644 index 000000000..d79900067 --- /dev/null +++ b/gpx-rs/data/with_highway.gpx @@ -0,0 +1,62 @@ + + + + with_highway + + + with_highway + + + 109.0 + + + + asphalt + residential + + + + + + 110.0 + + + + asphalt + residential + + + + + + 111.0 + + + 112.0 + + + + gravel + track + mountain_hiking + 1 + + + + + + 113.0 + + + + gravel + hiking + 1 + + + + + + + diff --git a/gpx/test-data/with_hr.gpx b/gpx-rs/data/with_hr.gpx similarity index 100% rename from gpx/test-data/with_hr.gpx rename to gpx-rs/data/with_hr.gpx diff --git a/gpx-rs/data/with_links.gpx b/gpx-rs/data/with_links.gpx new file mode 100644 index 000000000..a650cfd1d --- /dev/null +++ b/gpx-rs/data/with_links.gpx @@ -0,0 +1,38 @@ + + + + with links + + someone + + author + + first + + + + summit + one + + three + + + track + + two + + + + + + + route + + + + + + + email variants + + diff --git a/gpx/test-data/with_power_1.gpx b/gpx-rs/data/with_power_1.gpx similarity index 100% rename from gpx/test-data/with_power_1.gpx rename to gpx-rs/data/with_power_1.gpx diff --git a/gpx/test-data/with_power_2.gpx b/gpx-rs/data/with_power_2.gpx similarity index 97% rename from gpx/test-data/with_power_2.gpx rename to gpx-rs/data/with_power_2.gpx index b0a530733..717fe4cf7 100644 --- a/gpx/test-data/with_power_2.gpx +++ b/gpx-rs/data/with_power_2.gpx @@ -1,9 +1,14 @@ + xmlns:gpxpx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" + xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" + creator="https://gpx.studio"> with_power diff --git a/gpx-rs/data/with_power_3.gpx b/gpx-rs/data/with_power_3.gpx new file mode 100644 index 000000000..35f7337c1 --- /dev/null +++ b/gpx-rs/data/with_power_3.gpx @@ -0,0 +1,505 @@ + + + + with_power + + gpx.studio + + + + + with_power + Cycling + + + 109.0 + + 200 + + + + 110.8 + + 200 + + + + 110.3 + + 200 + + + + 110.0 + + 200 + + + + 110.3 + + 200 + + + + 109.3 + + 200 + + + + 107.0 + + 200 + + + + 106.0 + + 200 + + + + 108.5 + + 200 + + + + 109.8 + + 200 + + + + 110.8 + + 200 + + + + 112.0 + + 200 + + + + 112.8 + + 200 + + + + 113.5 + + 200 + + + + 114.3 + + 200 + + + + 115.3 + + 200 + + + + 114.8 + + 200 + + + + 114.3 + + 200 + + + + 114.3 + + 200 + + + + 114.5 + + 200 + + + + 115.0 + + 200 + + + + 115.8 + + 200 + + + + 115.8 + + 200 + + + + 116.0 + + 200 + + + + 115.8 + + 200 + + + + 115.5 + + 200 + + + + 114.5 + + 200 + + + + 112.5 + + 200 + + + + 110.8 + + 200 + + + + 109.0 + + 200 + + + + 106.3 + + 200 + + + + 104.3 + + 200 + + + + 104.0 + + 200 + + + + 103.8 + + 200 + + + + 103.3 + + 200 + + + + 103.5 + + 200 + + + + 103.8 + + 200 + + + + 104.8 + + 200 + + + + 105.8 + + 200 + + + + 106.8 + + 200 + + + + 107.8 + + 200 + + + + 108.5 + + 200 + + + + 109.3 + + 200 + + + + 110.0 + + 200 + + + + 110.5 + + 200 + + + + 110.8 + + 200 + + + + 111.8 + + 200 + + + + 112.8 + + 200 + + + + 113.3 + + 200 + + + + 113.5 + + 200 + + + + 113.5 + + 200 + + + + 113.8 + + 200 + + + + 114.8 + + 200 + + + + 115.8 + + 200 + + + + 118.5 + + 200 + + + + 119.5 + + 200 + + + + 121.3 + + 200 + + + + 122.0 + + 200 + + + + 122.8 + + 200 + + + + 123.5 + + 200 + + + + 126.3 + + 200 + + + + 128.0 + + 200 + + + + 129.0 + + 200 + + + + 130.0 + + 200 + + + + 130.8 + + 200 + + + + 131.8 + + 200 + + + + 132.3 + + 200 + + + + 132.8 + + 200 + + + + 135.8 + + 200 + + + + 135.5 + + 200 + + + + 132.5 + + 200 + + + + 134.0 + + 200 + + + + 136.8 + + 200 + + + + 137.5 + + 200 + + + + 137.3 + + 200 + + + + 137.5 + + 200 + + + + 137.3 + + 200 + + + + 134.8 + + 200 + + + + 132.3 + + 200 + + + + 129.5 + + 210 + + + + + \ No newline at end of file diff --git a/gpx-rs/data/with_route_extensions.gpx b/gpx-rs/data/with_route_extensions.gpx new file mode 100644 index 000000000..9236a8246 --- /dev/null +++ b/gpx-rs/data/with_route_extensions.gpx @@ -0,0 +1,23 @@ + + + + with extensions + + 10 + + + + + + + + + 20 + + + + + + empty + + diff --git a/gpx-rs/data/with_route_points.gpx b/gpx-rs/data/with_route_points.gpx new file mode 100644 index 000000000..41be56470 --- /dev/null +++ b/gpx-rs/data/with_route_points.gpx @@ -0,0 +1,30 @@ + + + + Lausanne + comment + description + Cycling + + 500 + + + + + + + + + + 510 + + + + + + + + 505 + + + diff --git a/gpx/test-data/with_routes.gpx b/gpx-rs/data/with_routes.gpx similarity index 100% rename from gpx/test-data/with_routes.gpx rename to gpx-rs/data/with_routes.gpx diff --git a/gpx/test-data/with_segments.gpx b/gpx-rs/data/with_segments.gpx similarity index 100% rename from gpx/test-data/with_segments.gpx rename to gpx-rs/data/with_segments.gpx diff --git a/gpx/test-data/with_style.gpx b/gpx-rs/data/with_style.gpx similarity index 100% rename from gpx/test-data/with_style.gpx rename to gpx-rs/data/with_style.gpx diff --git a/gpx/test-data/with_surface.gpx b/gpx-rs/data/with_surface.gpx similarity index 100% rename from gpx/test-data/with_surface.gpx rename to gpx-rs/data/with_surface.gpx diff --git a/gpx/test-data/with_temp.gpx b/gpx-rs/data/with_temp.gpx similarity index 100% rename from gpx/test-data/with_temp.gpx rename to gpx-rs/data/with_temp.gpx diff --git a/gpx/test-data/with_time.gpx b/gpx-rs/data/with_time.gpx similarity index 100% rename from gpx/test-data/with_time.gpx rename to gpx-rs/data/with_time.gpx diff --git a/gpx/test-data/with_tracks.gpx b/gpx-rs/data/with_tracks.gpx similarity index 100% rename from gpx/test-data/with_tracks.gpx rename to gpx-rs/data/with_tracks.gpx diff --git a/gpx/test-data/with_tracks_and_segments.gpx b/gpx-rs/data/with_tracks_and_segments.gpx similarity index 100% rename from gpx/test-data/with_tracks_and_segments.gpx rename to gpx-rs/data/with_tracks_and_segments.gpx diff --git a/gpx/test-data/with_waypoint.gpx b/gpx-rs/data/with_waypoint.gpx similarity index 91% rename from gpx/test-data/with_waypoint.gpx rename to gpx-rs/data/with_waypoint.gpx index c908b0515..8fb47aa8b 100644 --- a/gpx/test-data/with_waypoint.gpx +++ b/gpx-rs/data/with_waypoint.gpx @@ -1,9 +1,14 @@ + xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" + creator="https://gpx.studio"> with_waypoint @@ -13,10 +18,14 @@ 122.0 - Waypoint - Comment - Description + waypoint name + waypoint comment + waypoint description + + waypoint link text + Bike Trail + Bike Trail with_waypoint diff --git a/gpx-rs/src/api/commands.rs b/gpx-rs/src/api/commands.rs new file mode 100644 index 000000000..3c6be8f9c --- /dev/null +++ b/gpx-rs/src/api/commands.rs @@ -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(engine: &mut Engine, action: Action) -> Outcome { + match engine.execute(action) { + Ok(outcome) => Outcome::new(&outcome, None), + Err(error) => Outcome::failed(error.to_string()), + } +} + +pub fn edit(engine: &mut Engine, command: Command) -> Outcome { + execute(engine, Action::Edit(command)) +} + +fn file_ids(ids: &[Uuid]) -> Vec { + ids.iter().copied().map(engine::FileId).collect() +} + +// Selection + +/// Selects files. Unknown files are ignored, and selecting nothing deselects everything. +pub fn select( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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(engine: &mut Engine) -> 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(engine: &mut Engine, down: bool, add: bool) -> Outcome { + execute(engine, Action::ArrowSelect { down, add }) +} + +pub fn select_routes( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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(engine: &mut Engine) -> Outcome { + execute(engine, Action::Undo) +} + +pub fn redo(engine: &mut Engine) -> 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( + engine: &mut Engine, + name: &str, + lng: Option, + lat: Option, + ele: Option, + 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( + engine: &mut Engine, + 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; `name` is +/// its name when its data has none. The files to `remove` are deleted. The selection is left alone. +pub fn sync_files( + engine: &mut Engine, + 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(engine: &mut Engine) -> 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(engine: &mut Engine, whole_files: bool) -> Outcome { + edit(engine, Command::Delete(engine::Delete { whole_files })) +} + +pub fn delete_all(engine: &mut Engine) -> 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(engine: &mut Engine, 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(engine: &mut Engine, 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(engine: &mut Engine) -> Outcome { + execute(engine, Action::Copy) +} + +/// Like `copy`, but the elements are moved when they are pasted. +pub fn cut(engine: &mut Engine) -> 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(engine: &mut Engine) -> 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( + engine: &mut Engine, + what: Selection, + to: MoveTarget, +) -> Outcome { + edit( + engine, + Command::Move(engine::Move { + what: what.into(), + to: to.into(), + }), + ) +} + +pub fn metadata(engine: &mut Engine, name: &str, desc: &str) -> Outcome { + edit(engine, Command::Metadata(engine::Metadata { name, desc })) +} + +/// Only the given fields are changed. +pub fn style( + engine: &mut Engine, + color: Option<&str>, + opacity: Option, + width: Option, +) -> 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(engine: &mut Engine) -> Outcome { + edit(engine, Command::Convert(engine::Convert)) +} + +pub fn new_route(engine: &mut Engine) -> Outcome { + edit(engine, Command::NewRoute(engine::NewRoute)) +} + +pub fn new_track(engine: &mut Engine) -> Outcome { + edit(engine, Command::NewTrack(engine::NewTrack)) +} + +pub fn new_track_segment(engine: &mut Engine) -> 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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(engine: &mut Engine) -> 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(engine: &mut Engine) -> 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(engine: &mut Engine, 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(engine: &mut Engine, 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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(engine: &mut Engine) -> 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(engine: &mut Engine, 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + revision: u32, + f: impl FnOnce(&mut Engine) -> 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( + engine: &mut Engine, + 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( + engine: &mut Engine, + 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(engine: &mut Engine, 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( + engine: &mut Engine, + 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, + }), + ) + }) +} diff --git a/gpx-rs/src/api/dto.rs b/gpx-rs/src/api/dto.rs new file mode 100644 index 000000000..af7224d3f --- /dev/null +++ b/gpx-rs/src/api/dto.rs @@ -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 { + 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, + modified: Vec, + removed: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + /// 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) -> 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(engine: &engine::Engine) -> 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, from: Vec) { + 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) -> 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, + }, + Route { + file_id: Uuid, + route_ids: Vec, + }, + Track { + file_id: Uuid, + track_ids: Vec, + }, + Segment { + file_id: Uuid, + track_id: Uuid, + segment_ids: Vec, + }, + Waypoints { + file_id: Uuid, + }, + Waypoint { + file_id: Uuid, + waypoint_ids: Vec, + }, +} + +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 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 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, + cut: bool, +} + +impl Clipboard { + pub fn new(clipboard: &engine::Clipboard) -> Self { + let strings = |ids: Vec| 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, + routes: Vec, + tracks: Vec, + waypoints: Vec, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + desc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + opacity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, +} + +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, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + sym: Option, +} + +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, + #[serde(skip_serializing_if = "Option::is_none")] + total_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + moving_time: Option, + elevation_gain: f64, + elevation_loss: f64, + #[serde(skip_serializing_if = "Option::is_none")] + start_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + end_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + total_speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + moving_speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bounds: Option, +} + +#[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| 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, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + desc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cmt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + sym: Option, + #[serde(skip_serializing_if = "Option::is_none")] + links: Option>, +} + +#[derive(Serialize)] +struct Link { + href: String, + #[serde(skip_serializing_if = "Option::is_none")] + text: Option, +} + +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, +} + +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, + /// ms since the epoch. + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option, + /// Slope (%) and length (km) of the smoothed elevation segment of the trackpoint. + #[serde(skip_serializing_if = "Option::is_none")] + pub slope_segment_slope: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slope_segment_distance: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub surface: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub highway: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sac_scale: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtb_scale: Option, +} + +// Routing + +/// A category of the trackpoints of a route, as in `RouteCategory` of the engine. +#[derive(Default, Deserialize)] +pub struct RouteCategory { + pub starts: Vec, + pub values: Vec, + pub names: Vec, +} + +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, +} diff --git a/gpx-rs/src/api/mod.rs b/gpx-rs/src/api/mod.rs new file mode 100644 index 000000000..41ac8b906 --- /dev/null +++ b/gpx-rs/src/api/mod.rs @@ -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::*; diff --git a/gpx-rs/src/api/queries.rs b/gpx-rs/src/api/queries.rs new file mode 100644 index 000000000..5556e2296 --- /dev/null +++ b/gpx-rs/src/api/queries.rs @@ -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(engine: &Engine) -> Vec { + ids(engine.order()) +} + +/// Structure of a file, `None` if the id is unknown. +pub fn file_structure(engine: &Engine, file_id: Uuid) -> Option { + engine + .file_structure(&FileId(file_id)) + .map(FileStructure::new) +} + +/// Global statistics of a file, `None` if the id is unknown. +pub fn file_statistics(engine: &Engine, file_id: Uuid) -> Option { + engine + .file_statistics(&FileId(file_id)) + .map(|stats| GlobalStatistics::new(&stats)) +} + +/// The current selection. +pub fn selection(engine: &Engine) -> Selection { + engine.selection().into() +} + +/// What was copied or cut and is waiting to be pasted. +pub fn clipboard(engine: &Engine) -> Option { + engine.clipboard().map(Clipboard::new) +} + +pub fn can_undo(engine: &Engine) -> bool { + engine.can_undo() +} + +pub fn can_redo(engine: &Engine) -> bool { + engine.can_redo() +} + +/// Whether the clipboard can be pasted with the current selection. +pub fn can_paste(engine: &Engine) -> bool { + engine.can_paste() +} + +/// All the data of a waypoint of a file, `None` if it does not exist. +pub fn waypoint( + engine: &Engine, + file_id: Uuid, + waypoint_id: Uuid, +) -> Option { + 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( + engine: &Engine, + file_id: Uuid, + segment_id: Uuid, + index: usize, +) -> Option { + 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( + engine: &Engine, + file_id: Uuid, + options: engine::ExportOptions, +) -> Option> { + 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(engine: &Engine, file_ids: &[Uuid]) -> u8 { + let ids: Vec = 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(engine: &Engine) -> Vec { + 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(engine: &Engine, 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(engine: &Engine, 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(engine: &Engine) -> 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( + engine: &Engine, + start: usize, + end: usize, +) -> Option { + engine + .statistics() + .slice(start, end) + .map(|stats| GlobalStatistics::new(&stats)) +} + +macro_rules! buffers { + ($($name:ident),*) => {$( + pub fn $name(engine: &Engine) -> &[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(engine: &Engine) -> 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(engine: &Engine) -> &[u32] { + &engine.statistics().slope_segments.starts +} + +pub fn slope_segment_slopes(engine: &Engine) -> &[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(engine: &Engine, index: usize) -> Option { + 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: `_starts` holds the index of the first trackpoint of each interval, +// and `_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(engine: &Engine) -> &[u32] { + &engine.statistics().$field.starts + } + + pub fn $values(engine: &Engine) -> &[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(engine: &Engine) -> &[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(engine: &Engine) -> &[u32] { + &engine.routing().anchor_indices +} + +/// The lowest map zoom level at which each anchor is shown. +pub fn anchor_zooms(engine: &Engine) -> &[u8] { + &engine.routing().anchor_zooms +} + +/// Index in the selection of the first trackpoint of each selected segment that has some. +pub fn segment_starts(engine: &Engine) -> &[u32] { + &engine.routing().segment_starts +} + +/// The id of each selected segment that has trackpoints, as `segment_starts`. +pub fn segment_ids(engine: &Engine) -> Vec { + 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(engine: &Engine) -> u32 { + engine.routing().revision +} diff --git a/gpx-rs/src/core/algorithm/anchors.rs b/gpx-rs/src/core/algorithm/anchors.rs new file mode 100644 index 000000000..3a78daf32 --- /dev/null +++ b/gpx-rs/src/core/algorithm/anchors.rs @@ -0,0 +1,112 @@ +use std::f64::consts::PI; + +use crate::{ + TrackSegment, TrackSegmentIndex, core::utils::crossarc_lnglat, ramer_douglas_peucker_by, +}; + +/// Highest zoom level of the map at which an anchor can appear. +pub const MAX_ANCHOR_ZOOM: u8 = 22; + +/// Details smaller than this (in meters) do not get an anchor. +const ANCHOR_EPSILON: f64 = 1.0; + +const EARTH_RADIUS_METERS: f64 = 6371008.8; + +/// Lowest map zoom level at which an anchor selected at `distance` meters from the line of its +/// neighbours is worth showing: the smaller the detail, the higher the zoom. The ends of a +/// segment (no distance) are always shown. +pub fn anchor_zoom(lat: f64, distance: Option) -> u8 { + let Some(distance) = distance else { + return 0; + }; + let width = EARTH_RADIUS_METERS * (lat * PI / 180.0).cos() / distance; + width.log2().round().clamp(0.0, MAX_ANCHOR_ZOOM as f64) as u8 +} + +/// The anchors of a segment, as `(index, zoom)` sorted by index: the trackpoints kept by the +/// Ramer-Douglas-Peucker simplification of its path, the ones for smaller details being shown at +/// higher zoom levels only. +pub fn compute_anchors(trkseg: &TrackSegment) -> Vec<(usize, u8)> { + let measure = |start: TrackSegmentIndex, end: TrackSegmentIndex, idx: TrackSegmentIndex| { + crossarc_lnglat( + trkseg[start].coordinates, + trkseg[end].coordinates, + trkseg[idx].coordinates, + ) + }; + ramer_douglas_peucker_by(trkseg, &measure, ANCHOR_EPSILON) + .into_iter() + .map(|(idx, distance)| (idx.flat, anchor_zoom(trkseg[idx].coordinates.lat, distance))) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::{LngLat, Trackpoint, TrackpointChunk}; + + use super::*; + + fn segment(points: &[(f64, f64)]) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + let mut chunk = TrackpointChunk::default(); + for (lng, lat) in points { + chunk.trkpt.push(Trackpoint { + coordinates: LngLat { + lng: *lng, + lat: *lat, + }, + ..Default::default() + }); + } + trkseg.push(chunk); + trkseg + } + + #[test] + fn test_zoom_depends_on_the_size_of_the_detail() { + assert_eq!(anchor_zoom(45.0, None), 0); + // 2^13 meters is close to the radius of the earth divided by 1 km + assert_eq!(anchor_zoom(0.0, Some(1_000.0)), 13); + assert!(anchor_zoom(0.0, Some(10.0)) > anchor_zoom(0.0, Some(1_000.0))); + assert_eq!(anchor_zoom(0.0, Some(1e-9)), MAX_ANCHOR_ZOOM); + assert_eq!(anchor_zoom(0.0, Some(1e9)), 0); + // the poles do not give NaN or a panic + assert_eq!(anchor_zoom(90.0, Some(10.0)), 0); + } + + #[test] + fn test_anchors_of_a_straight_line_are_its_ends() { + let line: Vec<_> = (0..10).map(|i| (i as f64 * 0.001, 0.0)).collect(); + assert_eq!(compute_anchors(&segment(&line)), vec![(0, 0), (9, 0)]); + } + + #[test] + fn test_anchors_keep_the_corners() { + // a big corner and a small bump + let points = [ + (0.0, 0.0), + (0.01, 0.0), + (0.02, 0.0), + (0.02, 0.01), + (0.02, 0.02), + (0.02, 0.020005), + (0.02, 0.03), + ]; + let anchors = compute_anchors(&segment(&points)); + let indices: Vec<_> = anchors.iter().map(|(i, _)| *i).collect(); + assert_eq!(indices, vec![0, 2, 6]); + assert_eq!(anchors[0].1, 0); + assert_eq!(anchors[2].1, 0); + assert!(anchors[1].1 > 0); + } + + #[test] + fn test_anchors_of_short_segments() { + assert!(compute_anchors(&segment(&[])).is_empty()); + assert_eq!(compute_anchors(&segment(&[(1.0, 2.0)])), vec![(0, 0)]); + assert_eq!( + compute_anchors(&segment(&[(1.0, 2.0), (1.0, 2.1)])), + vec![(0, 0), (1, 0)] + ); + } +} diff --git a/gpx-rs/src/core/algorithm/mod.rs b/gpx-rs/src/core/algorithm/mod.rs new file mode 100644 index 000000000..4e5d539dc --- /dev/null +++ b/gpx-rs/src/core/algorithm/mod.rs @@ -0,0 +1,10 @@ +mod anchors; +mod reverse; +mod simplify; +mod smooth; +mod timestamps; + +pub use anchors::*; +pub use reverse::*; +pub use simplify::*; +pub use timestamps::*; diff --git a/gpx-rs/src/core/algorithm/reverse.rs b/gpx-rs/src/core/algorithm/reverse.rs new file mode 100644 index 000000000..cfe702fc8 --- /dev/null +++ b/gpx-rs/src/core/algorithm/reverse.rs @@ -0,0 +1,245 @@ +//! Reversing a path: the order of its trackpoints, and of the segments and tracks that hold them. +//! +//! The timestamps are mirrored, so that the reversed path goes through the same period of time: +//! a trackpoint at time `t` of a part that spans `start..end` ends up at `start + end - t`. When +//! the part is made of several ones (the segments of a track, the tracks of a file), the gaps +//! 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. + +use crate::{Route, Track, TrackSegment, Trackpoint}; + +/// The time of the first trackpoint of a segment. +pub fn start_time(segment: &TrackSegment) -> Option { + segment.iter().next().and_then(|trkpt| trkpt.time) +} + +/// The time of the last trackpoint of a segment. +pub fn end_time(segment: &TrackSegment) -> Option { + segment.last_index().and_then(|idx| segment[idx].time) +} + +/// The time of the first trackpoint of a track. +pub fn track_start_time(track: &Track) -> Option { + track.trkseg.first().and_then(start_time) +} + +/// The time of the last trackpoint of a track. +pub fn track_end_time(track: &Track) -> Option { + track.trkseg.last().and_then(end_time) +} + +/// Reverses the trackpoints of a segment. +/// +/// `original_next` is the time at which what came after the segment started, and `new_previous` +/// the time at which what comes before it now ends: the segment is mirrored to start there. Both +/// are those of the segment itself if the segment is reversed on its own, which are the ones +/// used when they are missing (`new_previous` is the start of the segment, and `original_next` +/// follows from the duration of the segment). Without timestamps, the trackpoints are only put +/// in the opposite order. +pub fn reverse_segment( + segment: &mut TrackSegment, + original_next: Option, + new_previous: Option, +) { + let (original_start, original_end) = (start_time(segment), end_time(segment)); + let new_previous = new_previous.or(original_start); + let original_next = original_next.or_else(|| { + // the segment lasts as long as before + Some(new_previous? + original_end? - original_start?) + }); + + let len = segment.len(); + let mut points: Vec = segment.iter().cloned().collect(); + if let (Some(next), Some(previous), Some(end)) = (original_next, new_previous, original_end) { + let new_start = previous + next - end; + for point in points.iter_mut() { + point.time = point.time.map(|time| new_start + (end - time)); + } + } + points.reverse(); + segment.splice(0, len, points); + segment.rev_id = Default::default(); +} + +/// Makes a segment go back where it started: a reversed copy of it is added after its last +/// trackpoint, without the first trackpoint of the copy, which is the last one of the segment. +/// +/// The timestamps of the copy go on from the end of the segment: it would start at the time of the +/// last trackpoint, and it lasts as long as the segment. Only the end of the segment changes, so +/// its other chunks stay shared. +pub fn round_trip(segment: &mut TrackSegment) { + let len = segment.len(); + let end = end_time(segment); + let mut back = segment.clone(); + reverse_segment(&mut back, end, end); + segment.splice(len, len, back.iter().skip(1).cloned().collect()); + segment.rev_id = Default::default(); +} + +/// Reverses the segments of a track, and their trackpoints: see [`reverse_segment`] for the +/// meaning of the times, which are the ones of the track as a whole. +pub fn reverse_segments( + segments: &mut [TrackSegment], + original_next: Option, + new_previous: Option, +) { + segments.reverse(); + let (mut next, mut previous) = (original_next, new_previous); + for segment in segments.iter_mut() { + // the next one is the one that was before, which started here + let original_start = start_time(segment); + reverse_segment(segment, next, previous); + next = original_start; + previous = end_time(segment); + } +} + +/// 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, new_previous: Option) { + 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. +pub fn reverse_tracks(tracks: &mut [Track], original_next: Option, new_previous: Option) { + tracks.reverse(); + let (mut next, mut previous) = (original_next, new_previous); + for track in tracks.iter_mut() { + let original_start = track_start_time(track); + reverse_segments(&mut track.trkseg, next, previous); + next = original_start; + previous = track_end_time(track); + } +} + +#[cfg(test)] +mod tests { + use crate::LngLat; + + use super::*; + + fn segment(points: &[(f64, Option)]) -> TrackSegment { + let mut segment = TrackSegment::default(); + let points = points + .iter() + .map(|(lng, time)| Trackpoint { + coordinates: LngLat { + lng: *lng, + lat: 0.0, + }, + time: *time, + ..Default::default() + }) + .collect(); + segment.splice(0, 0, points); + segment + } + + fn lngs(segment: &TrackSegment) -> Vec { + segment.iter().map(|p| p.coordinates.lng).collect() + } + + fn times(segment: &TrackSegment) -> Vec> { + segment.iter().map(|p| p.time).collect() + } + + #[test] + fn test_a_segment_is_mirrored_in_its_own_time_span() { + let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]); + let (start, end) = (start_time(&s), end_time(&s)); + reverse_segment(&mut s, end, start); + assert_eq!(lngs(&s), [2.0, 1.0, 0.0]); + // same span, the durations between points follow the points + assert_eq!(times(&s), [Some(0), Some(30), Some(40)]); + } + + #[test] + fn test_reverse_segment_defaults() { + // without the times around, the segment is mirrored where it is + let mut s = segment(&[(0.0, Some(100)), (1.0, Some(130))]); + reverse_segment(&mut s, None, None); + assert_eq!(times(&s), [Some(100), Some(130)]); + assert_eq!(lngs(&s), [1.0, 0.0]); + } + + #[test] + fn test_a_segment_without_times_is_only_reversed() { + let mut s = segment(&[(0.0, None), (1.0, None), (2.0, None)]); + reverse_segment(&mut s, None, None); + assert_eq!(lngs(&s), [2.0, 1.0, 0.0]); + assert_eq!(times(&s), [None, None, None]); + } + + #[test] + fn test_round_trip_goes_back_in_time_from_the_end() { + let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]); + let first = s.rev_id; + round_trip(&mut s); + assert_ne!(s.rev_id, first); + // the last trackpoint is the turning point, it is not repeated + assert_eq!(lngs(&s), [0.0, 1.0, 2.0, 1.0, 0.0]); + // the way back lasts as long as the way there, and starts when it ended + assert_eq!(times(&s), [Some(0), Some(10), Some(40), Some(70), Some(80)]); + // and it is a segment whose ends are anchors + assert_eq!(s[0].anchor, Some(0)); + assert_eq!(s[4].anchor, Some(0)); + } + + #[test] + fn test_round_trip_without_times() { + let mut s = segment(&[(0.0, None), (1.0, None)]); + round_trip(&mut s); + assert_eq!(lngs(&s), [0.0, 1.0, 0.0]); + assert_eq!(times(&s), [None; 3]); + } + + #[test] + fn test_segments_keep_the_gaps_between_them() { + // 0..20, then 100..120: a gap of 80 + let mut segments = vec![ + segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(20))]), + segment(&[(3.0, Some(100)), (4.0, Some(110)), (5.0, Some(120))]), + ]; + let (end, start) = (end_time(&segments[1]), start_time(&segments[0])); + reverse_segments(&mut segments, end, start); + assert_eq!(lngs(&segments[0]), [5.0, 4.0, 3.0]); + assert_eq!(lngs(&segments[1]), [2.0, 1.0, 0.0]); + assert_eq!(times(&segments[0]), [Some(0), Some(10), Some(20)]); + // the gap is still 80 + assert_eq!(times(&segments[1]), [Some(100), Some(110), Some(120)]); + } + + #[test] + fn test_tracks_are_reversed_like_segments() { + let track = |segments: Vec| Track { + trkseg: segments, + ..Default::default() + }; + let mut tracks = vec![ + track(vec![ + segment(&[(0.0, Some(0)), (1.0, Some(10))]), + segment(&[(2.0, Some(50)), (3.0, Some(60))]), + ]), + track(vec![segment(&[(4.0, Some(200)), (5.0, Some(230))])]), + ]; + let (end, start) = (track_end_time(&tracks[1]), track_start_time(&tracks[0])); + reverse_tracks(&mut tracks, end, start); + let all: Vec = tracks + .iter() + .flat_map(|t| t.trkseg.iter().flat_map(lngs)) + .collect(); + assert_eq!(all, [5.0, 4.0, 3.0, 2.0, 1.0, 0.0]); + // 3 segments: durations 10, 10, 30 and gaps 40 and 140 are reversed to 30, 10, 10 and + // gaps 140 and 40 (the first part of the time is the longest) + assert_eq!(times(&tracks[0].trkseg[0]), [Some(0), Some(30)]); + assert_eq!(times(&tracks[1].trkseg[0]), [Some(170), Some(180)]); + assert_eq!(times(&tracks[1].trkseg[1]), [Some(220), Some(230)]); + } +} diff --git a/gpx-rs/src/core/algorithm/simplify.rs b/gpx-rs/src/core/algorithm/simplify.rs new file mode 100644 index 000000000..5dfe90de2 --- /dev/null +++ b/gpx-rs/src/core/algorithm/simplify.rs @@ -0,0 +1,232 @@ +use crate::{ + core::gpx::{TrackSegment, TrackSegmentIndex}, + core::utils::{crossarc, crossarc_lnglat}, +}; + +pub fn ramer_douglas_peucker( + trkseg: &TrackSegment, + mapping: &F, + epsilon: f64, +) -> Vec +where + F: Fn(TrackSegmentIndex) -> (f64, f64), +{ + let measure = |start, end, idx| { + let (start, end, pt) = (mapping(start), mapping(end), mapping(idx)); + crossarc(start.0, start.1, end.0, end.1, pt.0, pt.1) + }; + ramer_douglas_peucker_by(trkseg, &measure, epsilon) + .into_iter() + .map(|(idx, _)| idx) + .collect() +} + +/// Ramer-Douglas-Peucker with any distance from a point (the last argument) to the line made of +/// two others (the first ones). Gives the kept points, with the distance at which each of them +/// was selected (`None` for the ends of the segment, which are always kept). +pub fn ramer_douglas_peucker_by( + trkseg: &TrackSegment, + measure: &M, + epsilon: f64, +) -> Vec<(TrackSegmentIndex, Option)> +where + M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64, +{ + match trkseg.len() { + 0 => vec![], + 1 => vec![(trkseg.first_index().unwrap(), None)], + 2 => vec![ + (trkseg.first_index().unwrap(), None), + (trkseg.last_index().unwrap(), None), + ], + _ => { + let first = trkseg.first_index().unwrap(); + let last = trkseg.last_index().unwrap(); + + let mut kept = vec![(first, None)]; + ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept); + kept.push((last, None)); + kept + } + } +} + +fn ramer_douglas_peucker_helper( + trkseg: &TrackSegment, + start: TrackSegmentIndex, + end: TrackSegmentIndex, + measure: &M, + epsilon: f64, + kept: &mut Vec<(TrackSegmentIndex, Option)>, +) where + M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64, +{ + let mut max_idx = None; + let mut max_dist = 0.0; + + let mut cur = trkseg.next_index(Some(start)); + while let Some(idx) = cur { + if idx == end { + break; + } + + let dist = measure(start, end, idx); + if dist > max_dist { + max_idx = Some(idx); + max_dist = dist; + } + + cur = trkseg.next_index(cur); + } + + if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) { + ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept); + kept.push((idx, Some(max_dist))); + ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept); + } +} + +/// Lowest tolerance (in meters) of the reduction of a segment, see [`reduction_distances`]. +pub const MIN_REDUCE_TOLERANCE: f64 = 0.1; + +/// The distance (in meters) from a trackpoint to the line between two others of a segment. +fn lnglat_measure( + trkseg: &TrackSegment, +) -> impl Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64 + '_ { + move |start, end, idx| { + crossarc_lnglat( + trkseg[start].coordinates, + trkseg[end].coordinates, + trkseg[idx].coordinates, + ) + } +} + +/// The indices of the trackpoints of a segment that are kept when it is reduced with `tolerance` +/// meters (Ramer-Douglas-Peucker), in order. The ends are always kept. +pub fn reduce_indices(trkseg: &TrackSegment, tolerance: f64) -> Vec { + ramer_douglas_peucker_by(trkseg, &lnglat_measure(trkseg), tolerance) + .into_iter() + .map(|(idx, _)| idx.flat) + .collect() +} + +/// For each trackpoint of a segment, the tolerance (in meters) up to which it is kept by +/// [`reduce_indices`]: infinite for the ends, which are always kept, zero for the trackpoints that +/// are not selected even at [`MIN_REDUCE_TOLERANCE`]. A trackpoint is kept with a tolerance if its +/// value is greater than it. +/// +/// A trackpoint is only selected if the ones that split the path before it are, so its value is +/// the smallest distance at which it or one of them was selected. +pub fn reduction_distances(trkseg: &TrackSegment) -> Vec { + let mut distances = vec![0.0; trkseg.len()]; + let (Some(first), Some(last)) = (trkseg.first_index(), trkseg.last_index()) else { + return distances; + }; + distances[first.flat] = f64::INFINITY; + distances[last.flat] = f64::INFINITY; + if trkseg.len() > 2 { + let measure = lnglat_measure(trkseg); + reduction_distances_helper(trkseg, first, last, &measure, f64::INFINITY, &mut distances); + } + distances +} + +fn reduction_distances_helper( + trkseg: &TrackSegment, + start: TrackSegmentIndex, + end: TrackSegmentIndex, + measure: &M, + bound: f64, + distances: &mut [f64], +) where + M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64, +{ + let mut max_idx = None; + let mut max_dist = 0.0; + let mut cur = trkseg.next_index(Some(start)); + while let Some(idx) = cur { + if idx == end { + break; + } + let dist = measure(start, end, idx); + if dist > max_dist { + max_idx = Some(idx); + max_dist = dist; + } + cur = trkseg.next_index(cur); + } + + if let Some(idx) = max_idx.filter(|_| max_dist > MIN_REDUCE_TOLERANCE) { + let distance = max_dist.min(bound); + distances[idx.flat] = distance; + reduction_distances_helper(trkseg, start, idx, measure, distance, distances); + reduction_distances_helper(trkseg, idx, end, measure, distance, distances); + } +} + +#[cfg(test)] +mod tests { + use crate::{Trackpoint, TrackpointChunk}; + + use super::*; + + fn segment(eles: &[f64]) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + // several small chunks, to also exercise the chunk boundaries + for part in eles.chunks(3) { + let mut chunk = TrackpointChunk::default(); + for ele in part { + chunk.trkpt.push(Trackpoint { + ele: *ele, + ..Default::default() + }); + } + trkseg.push(chunk); + } + trkseg + } + + fn simplify(eles: &[f64], epsilon: f64) -> Vec { + let trkseg = segment(eles); + ramer_douglas_peucker(&trkseg, &|idx| (idx.flat as f64, trkseg[idx].ele), epsilon) + .iter() + .map(|idx| idx.flat) + .collect() + } + + #[test] + fn test_empty_and_single_point() { + assert!(simplify(&[], 1.0).is_empty()); + assert_eq!(simplify(&[5.0], 1.0), vec![0]); + } + + #[test] + fn test_collinear_points_are_removed() { + let eles: Vec = (0..20).map(|i| i as f64 * 2.0).collect(); + assert_eq!(simplify(&eles, 0.1), vec![0, 19]); + } + + #[test] + fn test_peak_is_kept_when_above_epsilon() { + let eles = [0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0]; + assert_eq!(simplify(&eles, 5.0), vec![0, 3, 6]); + } + + #[test] + fn test_peak_is_dropped_when_below_epsilon() { + let eles = [0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0]; + assert_eq!(simplify(&eles, 1.0), vec![0, 6]); + } + + #[test] + fn test_indices_are_sorted_and_keep_extremities() { + let eles = [0.0, 5.0, -3.0, 8.0, 1.0, 9.0, -4.0, 2.0, 7.0, 0.0]; + let kept = simplify(&eles, 0.5); + assert_eq!(kept.first(), Some(&0)); + assert_eq!(kept.last(), Some(&9)); + assert!(kept.windows(2).all(|w| w[0] < w[1])); + // a larger epsilon never keeps more points + assert!(simplify(&eles, 5.0).len() <= kept.len()); + } +} diff --git a/gpx-rs/src/core/algorithm/smooth.rs b/gpx-rs/src/core/algorithm/smooth.rs new file mode 100644 index 000000000..046b7f67e --- /dev/null +++ b/gpx-rs/src/core/algorithm/smooth.rs @@ -0,0 +1,141 @@ +#[macro_export] +macro_rules! for_each_window { + ( + $trkseg:expr, + $left:expr, + $right:expr, + $window:expr, + |$a:ident, $b:ident| $distance:expr, + |$i:ident, $l:ident, $r:ident| $body:block, + ) => {{ + let mut window_start = $left.unwrap_or_default(); + let mut window_center = $left; + let mut window_end = $left.unwrap_or_default(); + while let Some(idx) = window_center { + // advance left if needed + while window_start != idx && { + let $a = window_start; + let $b = idx; + $distance + } > $window { + if let Some(next) = $trkseg.next_index(Some(window_start)) { + if next == idx { + break; + } + window_start = next; + } else { + break; + } + } + // advance right if needed + window_end = window_end.max(idx); + while let Some(next) = $trkseg.next_index(Some(window_end)) { + if window_end != idx && { + let $a = idx; + let $b = next; + $distance + } > $window { + break; + } + window_end = next; + } + // apply body + let $i = idx; + let $l = window_start; + let $r = window_end; + $body + // go next + if window_center == $right { + break; + } + window_center = $trkseg.next_index(window_center); + } + }}; +} + +#[cfg(test)] +mod tests { + use crate::{TrackSegment, Trackpoint, TrackpointChunk}; + + fn segment(eles: &[f64]) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + // several small chunks, to also exercise the chunk boundaries + for part in eles.chunks(3) { + let mut chunk = TrackpointChunk::default(); + for ele in part { + chunk.trkpt.push(Trackpoint { + ele: *ele, + ..Default::default() + }); + } + trkseg.push(chunk); + } + trkseg + } + + /// Returns (center, left, right) flat indices for each visited center. + fn windows(len: usize, window: f64) -> Vec<(usize, usize, usize)> { + let trkseg = segment(&vec![0.0; len]); + let mut visited = vec![]; + for_each_window!( + trkseg, + trkseg.first_index(), + trkseg.last_index(), + window, + |a, b| (b.flat - a.flat) as f64, + |i, l, r| { + visited.push((i.flat, l.flat, r.flat)); + }, + ); + visited + } + + #[test] + fn test_window_bounds() { + let visited = windows(10, 2.0); + assert_eq!(visited.len(), 10); + for (i, l, r) in visited { + assert_eq!(l, i.saturating_sub(2), "left of {i}"); + assert_eq!(r, (i + 2).min(9), "right of {i}"); + } + } + + #[test] + fn test_window_always_includes_neighbours() { + for (i, l, r) in windows(6, 0.0) { + assert_eq!((l, r), (i.saturating_sub(1), (i + 1).min(5))); + } + } + + #[test] + fn test_window_larger_than_segment() { + for (_, l, r) in windows(5, 100.0) { + assert_eq!((l, r), (0, 4)); + } + } + + #[test] + fn test_window_sub_range() { + let trkseg = segment(&[0.0; 10]); + let mut centers = vec![]; + let mut first = trkseg.first_index(); + for _ in 0..3 { + first = trkseg.next_index(first); + } + let mut last = first; + for _ in 0..2 { + last = trkseg.next_index(last); + } + for_each_window!( + trkseg, + first, + last, + 1.0, + |a, b| (b.flat - a.flat) as f64, + |i, _l, _r| { + centers.push(i.flat); + }, + ); + assert_eq!(centers, vec![3, 4, 5]); + } +} diff --git a/gpx-rs/src/core/algorithm/timestamps.rs b/gpx-rs/src/core/algorithm/timestamps.rs new file mode 100644 index 000000000..0ee42049f --- /dev/null +++ b/gpx-rs/src/core/algorithm/timestamps.rs @@ -0,0 +1,465 @@ +use crate::{Statistics, TrackSegment, Trackpoint, distance}; + +/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on +/// from the end of the previous one, the first one starts at `start_time` (in milliseconds). +/// +/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none +/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See +/// [`shifted_and_compressed`] and [`with_timestamps`]. +pub fn changed_segment_times( + segments: &[&TrackSegment], + start_time: i64, + speed: f64, + ratio: f64, +) -> Vec>> { + let mut last: Option = None; + segments + .iter() + .map(|segment| { + let points: Vec = segment.iter().cloned().collect(); + let Some(first) = points.first() else { + return vec![]; + }; + let start = last.clone().unwrap_or_else(|| { + let mut start = first.clone(); + start.time = Some(start_time); + start + }); + let points = if first.time.is_none() { + with_timestamps(points, Some(speed), Some(&start), Some(start_time)) + } else { + shifted_and_compressed(points, Some(speed), ratio, &start) + }; + last = points.last().cloned(); + points.iter().map(|point| point.time).collect() + }) + .collect() +} + +/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at +/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the +/// steeper an interval the more of it. Each segment goes on from the end of the previous one. +pub fn artificial_segment_times( + segments: &[&TrackSegment], + start_time: i64, + total_time: f64, +) -> Vec>> { + let mut weights: Vec> = segments + .iter() + .map(|segment| { + let points: Vec = segment.iter().cloned().collect(); + let slopes: Vec = Statistics::compute(segment) + .local + .iter() + .map(|l| l.slope) + .collect(); + artificial_weights(&points, &slopes) + }) + .collect(); + let mut total: f64 = weights.iter().flatten().sum(); + if total.is_nan() || total <= 0.0 { + // no distance to share the time by: every interval takes as long + for weights in &mut weights { + weights.iter_mut().for_each(|w| *w = 1.0); + } + total = weights.iter().map(Vec::len).sum::() as f64; + } + let ms_per_weight = if total > 0.0 { + total_time * 1000.0 / total + } else { + 0.0 + }; + + let mut start = start_time; + segments + .iter() + .zip(&weights) + .map(|(segment, weights)| { + let points: Vec = segment.iter().cloned().collect(); + if points.is_empty() { + return vec![]; + } + let points = with_artificial_timestamps(points, weights, ms_per_weight, start); + // the next segment goes on from the end of this one + start = points.last().and_then(|point| point.time).unwrap_or(start); + points.iter().map(|point| point.time).collect() + }) + .collect() +} + +/// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the +/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library. +/// +/// The timestamps only matter if the path has some, or if `speed` (in km/h) is given: then the +/// new points that have none get some (a trackpoint after the previous one at `speed`, or one +/// second after it without a speed), and so do the points before or after the range if they +/// had none. `start_time` is the time of the first trackpoint when nothing else gives it. +/// New points that have timestamps are shifted if they are too early to follow the previous +/// trackpoint, and so are the points after the range. With `remove_gaps`, the new points are +/// also brought closer to the previous trackpoint if they come long after it. +/// +/// Panics if `start..end` is not inside `trkpt`. +pub fn replace_trackpoints( + trkpt: &mut Vec, + start: usize, + end: usize, + mut points: Vec, + speed: Option, + start_time: Option, + remove_gaps: bool, +) { + assert!(start <= end && end <= trkpt.len()); + let has_times = trkpt.first().is_some_and(|first| first.time.is_some()); + + if speed.is_some() || has_times { + if start > 0 && trkpt[0].time.is_none() { + // the points before the range have no timestamps + let before: Vec<_> = trkpt.drain(..start).collect(); + trkpt.splice(0..0, with_timestamps(before, speed, None, start_time)); + } + + if !points.is_empty() { + let last = start.checked_sub(1).map(|i| &trkpt[i]); + let missing = + points[0].time.is_none() || points.get(1).is_some_and(|p| p.time.is_none()); + if missing { + points = with_timestamps(points, speed, last, start_time); + } else if let Some(last) = last { + if is_before(points[0].time, last.time) { + // too early + points = shifted_and_compressed(points, speed, 1.0, last); + } else if remove_gaps { + if same_place(&points[0], last) { + // the same place: the new points start at the previous trackpoint, which + // is the new first one + if is_before(last.time, points[0].time) { + points = shifted_and_compressed(points, speed, 1.0, last); + points.remove(0); + } + } else if matches!((points[0].time, last.time), (Some(new), Some(old)) if new - old > 1000) + { + // another place: the new points start one second after the previous one + let mut artificial_last = points[0].clone(); + artificial_last.time = last.time.map(|time| time + 1000); + points = shifted_and_compressed(points, speed, 1.0, &artificial_last); + } + } + } + } + + if end < trkpt.len() { + // the points after the range + let last = points + .last() + .or_else(|| start.checked_sub(1).map(|i| &trkpt[i])) + .cloned(); + if trkpt[end].time.is_none() { + let after: Vec<_> = trkpt.drain(end..).collect(); + let after = with_timestamps(after, speed, last.as_ref(), start_time); + trkpt.extend(after); + } else if let Some(last) = last + && is_before(trkpt[end].time, last.time) + { + let after: Vec<_> = trkpt.drain(end..).collect(); + trkpt.extend(shifted_and_compressed(after, speed, 1.0, &last)); + } + } + } + + trkpt.splice(start..end, points); +} + +/// How long each interval between two consecutive trackpoints of a segment should last, relative +/// to the others, for a made-up pace: the longer the interval, and the steeper it goes up, the +/// longer. `slopes` has the slope (in %) at each trackpoint. +pub fn artificial_weights(points: &[Trackpoint], slopes: &[f64]) -> Vec { + points + .windows(2) + .zip(slopes) + .map(|(pair, slope)| { + let km = distance(pair[0].coordinates, pair[1].coordinates); + km * (0.5 + 1.0 / (1.0 + (-0.2 * slope).exp())) + }) + .collect() +} + +/// `points` with made-up timestamps: the first one is at `start`, and each next one follows the +/// previous one after `weights[i]` times `ms_per_weight` milliseconds (see [`artificial_weights`]). +pub fn with_artificial_timestamps( + mut points: Vec, + weights: &[f64], + ms_per_weight: f64, + start: i64, +) -> Vec { + let mut elapsed = 0.0; + for (i, point) in points.iter_mut().enumerate() { + if i > 0 { + elapsed += weights[i - 1] * ms_per_weight; + } + point.time = Some(start + elapsed as i64); + } + points +} + +fn same_place(a: &Trackpoint, b: &Trackpoint) -> bool { + a.coordinates.lng == b.coordinates.lng && a.coordinates.lat == b.coordinates.lat +} + +/// Whether both times are known and `a` is before `b`. +fn is_before(a: Option, b: Option) -> bool { + matches!((a, b), (Some(a), Some(b)) if a < b) +} + +/// The time at which `b` is reached after `a`, at `speed` km/h: one second later without a +/// speed, unknown if `a` has no time. +/// +/// The old code gave an invalid date for a null or negative speed: it is as if there was none. +fn timestamp_after(a: &Trackpoint, b: &Trackpoint, speed: Option) -> Option { + let time = a.time?; + match speed.filter(|speed| speed.is_finite() && *speed > 0.0) { + None => Some(time + 1000), + Some(speed) => { + let km = distance(a.coordinates, b.coordinates); + Some(time + (3_600_000.0 * km / speed) as i64) + } + } +} + +/// `points` with the timestamps that follow `last` (the trackpoint before them), one after the +/// other. Without `last`, the first one is at `start_time`. +/// +/// Unlike the old code, a `last` without a time does not get `start_time` itself, only the copy +/// used here. +pub fn with_timestamps( + points: Vec, + speed: Option, + last: Option<&Trackpoint>, + start_time: Option, +) -> Vec { + let Some(first) = points.first() else { + return points; + }; + let mut last = match last { + Some(last) if last.time.is_some() => last.clone(), + other => { + let mut last = other.unwrap_or(first).clone(); + last.time = start_time; + last + } + }; + points + .into_iter() + .map(|mut point| { + point.time = timestamp_after(&last, &point, speed); + last = point.clone(); + point + }) + .collect() +} + +/// `points` with their timestamps moved so that the first one follows `last`, and their durations +/// multiplied by `ratio`. The ones that have no timestamp follow the previous one, as in +/// [`with_timestamps`]. Without a time for `last`, or for the first point, none is known. +pub fn shifted_and_compressed( + points: Vec, + speed: Option, + ratio: f64, + last: &Trackpoint, +) -> Vec { + let Some(first) = points.first() else { + return points; + }; + let start = timestamp_after(last, first, speed); + let first_time = first.time; + let mut previous = first.clone(); + points + .into_iter() + .map(|point| { + let mut shifted = point.clone(); + shifted.time = match point.time { + None => timestamp_after(&previous, &point, speed), + Some(time) => start + .zip(first_time) + .map(|(start, first)| start + (ratio * (time - first) as f64) as i64), + }; + previous = shifted.clone(); + shifted + }) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::LngLat; + + use super::*; + + /// A point every 1 km along the equator... about, at 0.009 degrees of longitude. + fn point(i: usize, time: Option) -> Trackpoint { + Trackpoint { + coordinates: LngLat { + lng: i as f64 * 0.009, + lat: 0.0, + }, + time, + ..Default::default() + } + } + + fn timed(range: std::ops::Range, start: i64, step: i64) -> Vec { + range + .map(|i| point(i, Some(start + step * i as i64))) + .collect() + } + + fn untimed(range: std::ops::Range) -> Vec { + range.map(|i| point(i, None)).collect() + } + + fn times(trkpt: &[Trackpoint]) -> Vec> { + trkpt.iter().map(|p| p.time).collect() + } + + #[test] + fn test_without_times_nor_speed_it_is_a_splice() { + let mut trkpt = untimed(0..5); + replace_trackpoints(&mut trkpt, 1, 3, untimed(10..13), None, None, false); + assert_eq!(trkpt.len(), 6); + assert!(trkpt.iter().all(|p| p.time.is_none())); + assert_eq!(trkpt[1].coordinates.lng, point(10, None).coordinates.lng); + assert_eq!(trkpt[4].coordinates.lng, point(3, None).coordinates.lng); + } + + #[test] + fn test_new_points_follow_the_previous_one_at_the_speed() { + // 1 km in 10 s is 360 km/h + let mut trkpt = timed(0..4, 0, 10_000); + let km = distance(point(0, None).coordinates, point(1, None).coordinates); + let speed = 36.0 * km / 0.01; // the time of 1 point to the next is 100 s + replace_trackpoints(&mut trkpt, 2, 3, untimed(2..4), Some(speed), None, false); + let expected = 10_000 + (3_600_000.0 * km / speed) as i64; + assert_eq!(trkpt[2].time, Some(expected)); + assert!(trkpt[2].time > trkpt[1].time); + // the points after follow the new ones, they were not too early + assert_eq!(trkpt.len(), 5); + assert_eq!(trkpt[4].time, Some(30_000)); + } + + #[test] + fn test_without_a_speed_new_points_are_one_second_apart() { + let mut trkpt = timed(0..3, 5_000, 60_000); + replace_trackpoints(&mut trkpt, 1, 2, untimed(10..13), None, None, false); + assert_eq!( + times(&trkpt[..4]), + [Some(5_000), Some(6_000), Some(7_000), Some(8_000)] + ); + // what comes after was after, it is kept + assert_eq!(trkpt[4].time, Some(125_000)); + } + + #[test] + fn test_the_points_after_are_shifted_if_they_are_too_early() { + let mut trkpt = timed(0..4, 0, 10_000); + // 30 new points, one second apart: the last one is at 30 s, but the next original + // trackpoint is at 20 s + replace_trackpoints(&mut trkpt, 1, 2, untimed(10..40), None, None, false); + assert_eq!(trkpt.len(), 33); + assert_eq!(trkpt[1].time, Some(1_000)); + assert_eq!(trkpt[30].time, Some(30_000)); + // the following points keep the duration between them, after the new ones + assert_eq!(trkpt[31].time, Some(31_000)); + assert_eq!(trkpt[32].time, Some(41_000)); + } + + #[test] + fn test_a_path_without_times_gets_them_from_the_speed_and_start_time() { + let mut trkpt = untimed(0..4); + replace_trackpoints( + &mut trkpt, + 3, + 4, + untimed(3..6), + Some(10.0), + Some(1_000), + false, + ); + let times: Vec<_> = times(&trkpt); + assert_eq!(times[0], Some(1_000)); + assert!(times.iter().all(Option::is_some)); + assert!(times.windows(2).all(|w| w[0].unwrap() < w[1].unwrap())); + } + + #[test] + fn test_an_unusable_speed_is_as_if_there_was_none() { + for speed in [0.0, -5.0, f64::NAN, f64::INFINITY] { + let mut trkpt = timed(0..2, 0, 10_000); + replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(speed), None, false); + assert_eq!( + times(&trkpt), + [Some(0), Some(10_000), Some(11_000), Some(12_000)], + "{speed}" + ); + } + } + + #[test] + fn test_new_points_with_times_that_are_too_early_are_shifted() { + let mut trkpt = timed(0..3, 100_000, 10_000); + // they are at 0, 5 s: they are moved to follow the trackpoint at 110 s + let new = vec![point(5, Some(0)), point(6, Some(5_000))]; + replace_trackpoints(&mut trkpt, 2, 3, new, None, None, false); + // they follow the previous trackpoint after one second, keeping their own durations + assert_eq!(trkpt[2].time, Some(111_000)); + assert_eq!(trkpt[3].time, Some(116_000)); + } + + #[test] + fn test_remove_gaps_at_the_same_place_and_elsewhere() { + // same place: the first new point is the previous trackpoint + let mut trkpt = timed(0..3, 0, 10_000); + let new = vec![point(2, Some(500_000)), point(3, Some(510_000))]; + replace_trackpoints(&mut trkpt, 3, 3, new.clone(), None, None, true); + assert_eq!(trkpt.len(), 4); + // without a speed, it is one second after the previous trackpoint, not at its time + assert_eq!(trkpt[3].time, Some(31_000)); + // with a speed, the distance is zero + let mut trkpt = timed(0..3, 0, 10_000); + replace_trackpoints(&mut trkpt, 3, 3, new, Some(10.0), None, true); + assert_eq!(trkpt[3].time, Some(30_000)); + + // another place: one second after the previous one + let mut trkpt = timed(0..3, 0, 10_000); + let new = vec![point(5, Some(500_000)), point(6, Some(510_000))]; + replace_trackpoints(&mut trkpt, 3, 3, new, None, None, true); + assert_eq!(trkpt.len(), 5); + // the old code adds the second twice with no speed, see `timestamp_after` + assert_eq!(trkpt[3].time, Some(22_000)); + assert_eq!(trkpt[4].time, Some(32_000)); + + // without removing the gaps they stay + let mut trkpt = timed(0..3, 0, 10_000); + let new = vec![point(5, Some(500_000)), point(6, Some(510_000))]; + replace_trackpoints(&mut trkpt, 3, 3, new, None, None, false); + assert_eq!(trkpt[3].time, Some(500_000)); + } + + #[test] + fn test_removing_points_adapts_nothing_if_the_next_one_is_after() { + let mut trkpt = timed(0..5, 0, 10_000); + replace_trackpoints(&mut trkpt, 1, 3, vec![], None, None, false); + assert_eq!(times(&trkpt), [Some(0), Some(30_000), Some(40_000)]); + // from the beginning + replace_trackpoints(&mut trkpt, 0, 2, vec![], None, None, false); + assert_eq!(times(&trkpt), [Some(40_000)]); + replace_trackpoints(&mut trkpt, 0, 1, vec![], None, None, false); + assert!(trkpt.is_empty()); + } + + #[test] + fn test_points_without_a_known_previous_time_get_none() { + // nothing gives a time to start with + let mut trkpt = untimed(0..2); + replace_trackpoints(&mut trkpt, 2, 2, untimed(2..4), Some(10.0), None, false); + assert!(trkpt.iter().all(|p| p.time.is_none())); + } +} diff --git a/gpx-rs/src/core/gpx/categories.rs b/gpx-rs/src/core/gpx/categories.rs new file mode 100644 index 000000000..ad0f3097d --- /dev/null +++ b/gpx-rs/src/core/gpx/categories.rs @@ -0,0 +1,96 @@ +/// Maps the values of a category of trackpoint data (the surface, the highway...) to small codes, +/// so that the trackpoints only hold a `u8` instead of a string. +/// +/// The codes are the positions of the names in the order of first appearance. The table only +/// grows: a code never changes meaning, so it stays valid in every state of the history. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Categories(Vec); + +impl Categories { + /// Maximum number of distinct values. + pub const CAPACITY: usize = u8::MAX as usize; + + /// The code of `name`, which is added to the table if it is new. `None` if the table is full. + pub fn code(&mut self, name: &str) -> Option { + let index = match self.0.iter().position(|known| known == name) { + Some(index) => index, + None if self.0.len() < Self::CAPACITY => { + self.0.push(name.to_owned()); + self.0.len() - 1 + } + None => return None, + }; + u8::try_from(index).ok() + } + + /// The name of a code, `None` if it is unknown. + pub fn name(&self, code: u8) -> Option<&str> { + self.0.get(usize::from(code)).map(String::as_str) + } + + /// All the names, in the order of their codes. + pub fn names(&self) -> &[String] { + &self.0 + } +} + +/// The categories of the data of the trackpoints. There is one table per engine, shared by all +/// its files. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct TrackpointCategories { + pub surface: Categories, + pub highway: Categories, + pub sac_scale: Categories, + pub mtb_scale: Categories, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_codes_follow_the_order_of_appearance() { + let mut categories = Categories::default(); + assert_eq!(categories.code("asphalt"), Some(0)); + assert_eq!(categories.code("gravel"), Some(1)); + // a known name keeps its code + assert_eq!(categories.code("asphalt"), Some(0)); + assert_eq!(categories.names(), ["asphalt", "gravel"]); + } + + #[test] + fn test_names_of_codes() { + let mut categories = Categories::default(); + let code = categories.code("asphalt").unwrap(); + assert_eq!(categories.name(code), Some("asphalt")); + assert_eq!(categories.name(code + 1), None); + assert_eq!(Categories::default().name(0), None); + } + + #[test] + fn test_a_full_table_gives_no_code_to_new_names() { + let mut categories = Categories::default(); + for i in 0..Categories::CAPACITY { + assert_eq!(categories.code(&i.to_string()), Some(i as u8)); + } + assert_eq!(categories.code("one too many"), None); + assert_eq!(categories.names().len(), Categories::CAPACITY); + // the known ones still have their code, the last one included + assert_eq!(categories.code("0"), Some(0)); + assert_eq!(categories.code("254"), Some(254)); + assert_eq!(categories.name(254), Some("254")); + } + + #[test] + fn test_the_categories_are_independent() { + let mut categories = TrackpointCategories::default(); + categories.surface.code("asphalt"); + assert_eq!(categories.highway.code("path"), Some(0)); + assert_eq!(categories.sac_scale.code("hiking"), Some(0)); + assert_eq!(categories.mtb_scale.code("1"), Some(0)); + assert_eq!(categories.surface.names(), ["asphalt"]); + assert_eq!(categories.highway.names(), ["path"]); + assert_eq!(categories.sac_scale.names(), ["hiking"]); + assert_eq!(categories.mtb_scale.names(), ["1"]); + } +} diff --git a/gpx-rs/src/core/gpx/chunk.rs b/gpx-rs/src/core/gpx/chunk.rs new file mode 100644 index 000000000..7fffb5c5b --- /dev/null +++ b/gpx-rs/src/core/gpx/chunk.rs @@ -0,0 +1,133 @@ +use crate::{Trackpoint, Waypoint}; + +use super::common::uuid_id; + +/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk +/// that is not touched by an edit is not copied. +/// +/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new +/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is. +pub trait Chunk { + type Item: Clone; + + /// Maximum number of items of a chunk. + const MAX_SIZE: usize; + + /// A chunk with a new identity, holding `items`. + fn new(items: Vec) -> Self; + + /// Gives the chunk a new identity, after its items were changed in place. + fn renew(&mut self); + + fn items(&self) -> &Vec; + + fn items_mut(&mut self) -> &mut Vec; + + fn is_full(&self) -> bool { + self.items().len() >= Self::MAX_SIZE + } +} + +const MAX_TRKPT_CHUNK_SIZE: usize = 4096; + +uuid_id!(TrackpointChunkId); + +#[derive(Debug, Default)] +pub struct TrackpointChunk { + pub id: TrackpointChunkId, + pub trkpt: Vec, +} + +impl Chunk for TrackpointChunk { + type Item = Trackpoint; + const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE; + + fn new(trkpt: Vec) -> Self { + Self { + trkpt, + ..Default::default() + } + } + + fn renew(&mut self) { + self.id = Default::default(); + } + + fn items(&self) -> &Vec { + &self.trkpt + } + + fn items_mut(&mut self) -> &mut Vec { + &mut self.trkpt + } +} + +uuid_id!(WaypointChunkId); + +const MAX_WPT_CHUNK_SIZE: usize = 128; + +#[derive(Debug, Default)] +pub struct WaypointChunk { + pub id: WaypointChunkId, + pub wpt: Vec, +} + +impl Chunk for WaypointChunk { + type Item = Waypoint; + const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE; + + fn new(wpt: Vec) -> Self { + Self { + wpt, + ..Default::default() + } + } + + fn renew(&mut self) { + self.id = Default::default(); + } + + fn items(&self) -> &Vec { + &self.wpt + } + + fn items_mut(&mut self) -> &mut Vec { + &mut self.wpt + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_trackpoint_chunk_full() { + let mut chunk = TrackpointChunk::default(); + assert!(!chunk.is_full()); + for _ in 0..MAX_TRKPT_CHUNK_SIZE - 1 { + chunk.trkpt.push(Trackpoint::default()); + } + assert!(!chunk.is_full()); + chunk.trkpt.push(Trackpoint::default()); + assert!(chunk.is_full()); + } + + #[test] + fn test_waypoint_chunk_full() { + let mut chunk = WaypointChunk::default(); + assert!(!chunk.is_full()); + for _ in 0..MAX_WPT_CHUNK_SIZE { + chunk.wpt.push(Waypoint::default()); + } + assert!(chunk.is_full()); + } + + #[test] + fn test_new_chunks_have_their_own_identity() { + let a = TrackpointChunk::new(vec![Trackpoint::default()]); + let b = TrackpointChunk::new(vec![Trackpoint::default()]); + assert_ne!(a.id, b.id); + assert_eq!(a.items().len(), 1); + assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id); + } +} diff --git a/gpx-rs/src/core/gpx/chunked.rs b/gpx-rs/src/core/gpx/chunked.rs new file mode 100644 index 000000000..a406d1df7 --- /dev/null +++ b/gpx-rs/src/core/gpx/chunked.rs @@ -0,0 +1,619 @@ +use std::{ops::Index, rc::Rc}; + +use crate::Chunk; + +/// A list of items, stored in [`Chunk`]s that are shared between its versions: editing it only +/// copies the chunks around the change, so that a version costs little more than the change. +/// +/// There are no empty chunks. +#[derive(Debug)] +pub struct Chunked { + chunks: Vec>, + cumul_length: Vec, +} + +impl Clone for Chunked { + fn clone(&self) -> Self { + Self { + chunks: self.chunks.clone(), + cumul_length: self.cumul_length.clone(), + } + } +} + +impl Default for Chunked { + fn default() -> Self { + Self { + chunks: vec![], + cumul_length: vec![], + } + } +} + +/// The position of an item: in which chunk, where in it, and among all the items. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord)] +pub struct ChunkIndex { + pub chunk: usize, + pub pos: usize, + pub flat: usize, +} + +impl Chunked { + /// Adds a chunk at the end, which is dropped if it is empty. + pub fn push(&mut self, chunk: C) { + self.push_shared(Rc::new(chunk)); + } + + pub fn push_shared(&mut self, chunk: Rc) { + if chunk.items().is_empty() { + return; + } + self.cumul_length + .push(self.cumul_length.last().copied().unwrap_or_default() + chunk.items().len()); + self.chunks.push(chunk); + } + + pub fn chunks(&self) -> &[Rc] { + &self.chunks + } + + /// Replaces the items in `start..end` by `items`. Panics if the range is out of bounds. + /// + /// Chunks that are not concerned are kept as they are (shared), only the chunks around the + /// range are copied and refilled. + pub fn splice(&mut self, start: usize, end: usize, items: Vec) { + assert!( + start <= end && end <= self.len(), + "splice range out of bounds" + ); + let old = std::mem::take(&mut self.chunks); + self.cumul_length.clear(); + + let mut items = Some(items); + let mut pending = vec![]; + let mut offset = 0; + for chunk in old { + let (lo, hi) = (offset, offset + chunk.items().len()); + offset = hi; + // a chunk ending at `start` is extended unless it is full, to avoid tiny chunks + if hi < start || (hi == start && chunk.is_full()) { + self.push_shared(chunk); + continue; + } + if let Some(items) = items.take() { + self.fill(&mut pending, chunk.items()[..start - lo].iter().cloned()); + self.fill(&mut pending, items); + } + if hi <= end { + continue; + } + if lo >= end { + self.flush(&mut pending); + self.push_shared(chunk); + } else { + self.fill(&mut pending, chunk.items()[end - lo..].iter().cloned()); + } + } + if let Some(items) = items { + self.fill(&mut pending, items); + } + self.flush(&mut pending); + } + + fn fill(&mut self, pending: &mut Vec, items: impl IntoIterator) { + for item in items { + pending.push(item); + if pending.len() >= C::MAX_SIZE { + self.flush(pending); + } + } + } + + fn flush(&mut self, pending: &mut Vec) { + self.push(C::new(std::mem::take(pending))); + } + + /// Edits the items of the chunks that contain an item accepted by `filter`. + /// + /// `f` receives a copy of the items of such a chunk and returns whether it changed them. A + /// changed chunk is replaced by a new one (so that what is derived from it is computed + /// again) and the chunks left empty are dropped. The other chunks are kept. Returns whether + /// anything changed. + pub fn edit( + &mut self, + filter: impl Fn(&C::Item) -> bool, + mut f: impl FnMut(&mut Vec) -> bool, + ) -> bool { + let mut changed = false; + let old = std::mem::take(&mut self.chunks); + self.cumul_length.clear(); + for chunk in old { + if !chunk.items().iter().any(&filter) { + self.push_shared(chunk); + continue; + } + let mut items = chunk.items().clone(); + if !f(&mut items) { + self.push_shared(chunk); + continue; + } + changed = true; + self.push(C::new(items)); + } + changed + } + + /// Changes what is in the chunk `chunk`, but not how many items there are. A chunk that is + /// shared is copied first, so that the other versions do not change. + fn replace_chunk(&mut self, chunk: usize, f: impl FnOnce(&mut Vec)) { + let len = self.chunks[chunk].items().len(); + match Rc::get_mut(&mut self.chunks[chunk]) { + Some(unique) => { + f(unique.items_mut()); + unique.renew(); + } + None => { + let mut items = self.chunks[chunk].items().clone(); + f(&mut items); + self.chunks[chunk] = Rc::new(C::new(items)); + } + } + debug_assert_eq!(self.chunks[chunk].items().len(), len); + } + + /// Applies `f` to the item at `index`, which is copied in a new chunk. Panics if there is no + /// such item. + pub fn update(&mut self, index: usize, f: impl FnOnce(&mut C::Item)) { + let ChunkIndex { chunk, pos, .. } = self.locate(index).unwrap(); + self.replace_chunk(chunk, |items| f(&mut items[pos])); + } + + /// Applies `f` to every item, with its index, in new chunks. + pub fn update_all(&mut self, mut f: impl FnMut(usize, &mut C::Item)) { + let mut offset = 0; + for chunk in 0..self.chunks.len() { + let len = self.chunks[chunk].items().len(); + self.replace_chunk(chunk, |items| { + for (i, item) in items.iter_mut().enumerate() { + f(offset + i, item); + } + }); + offset += len; + } + } + + pub fn len(&self) -> usize { + self.cumul_length.last().copied().unwrap_or_default() + } + + pub fn is_empty(&self) -> bool { + self.chunks.is_empty() + } + + pub fn iter(&self) -> ChunkedIter<'_, C> { + ChunkedIter::new(self) + } + + pub fn first_index(&self) -> Option { + self.next_index(None) + } + + pub fn last_index(&self) -> Option { + self.prev_index(None) + } + + pub fn next_index(&self, cur: Option) -> Option { + let mut next = cur.map_or_default(|idx| ChunkIndex { + chunk: idx.chunk, + pos: idx.pos + 1, + flat: idx.flat + 1, + }); + loop { + if next.chunk >= self.chunks.len() { + return None; + } + if next.pos == self.chunks[next.chunk].items().len() { + next.chunk += 1; + next.pos = 0; + } else { + return Some(next); + } + } + } + + pub fn prev_index(&self, cur: Option) -> Option { + let mut prev = cur.unwrap_or(ChunkIndex { + chunk: self.chunks.len(), + pos: 0, + flat: self.cumul_length.last().copied().unwrap_or_default(), + }); + if prev.pos == 0 { + while prev.chunk > 0 { + prev.chunk -= 1; + if !self.chunks[prev.chunk].items().is_empty() { + prev.pos = self.chunks[prev.chunk].items().len() - 1; + prev.flat -= 1; + return Some(prev); + } + } + None + } else { + prev.pos -= 1; + prev.flat -= 1; + Some(prev) + } + } + + /// The position of the item `idx` among all the items. + pub fn locate(&self, idx: usize) -> Option { + let chunk = self.cumul_length.partition_point(|l| idx >= *l); + if chunk >= self.chunks.len() { + return None; + } + let pos = if chunk > 0 { + idx - self.cumul_length[chunk - 1] + } else { + idx + }; + if pos >= self.chunks[chunk].items().len() { + None + } else { + Some(ChunkIndex { + chunk, + pos, + flat: idx, + }) + } + } +} + +impl Index for Chunked { + type Output = C::Item; + + fn index(&self, idx: ChunkIndex) -> &Self::Output { + &self.chunks[idx.chunk].items()[idx.pos] + } +} + +impl Index for Chunked { + type Output = C::Item; + + fn index(&self, idx: usize) -> &Self::Output { + &self[self.locate(idx).unwrap()] + } +} + +impl<'a, C: Chunk> IntoIterator for &'a Chunked { + type Item = &'a C::Item; + type IntoIter = ChunkedIter<'a, C>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +pub struct ChunkedIter<'a, C: Chunk> { + chunked: &'a Chunked, + /// The item returned last. + idx: Option, + exhausted: bool, +} + +impl Clone for ChunkedIter<'_, C> { + fn clone(&self) -> Self { + Self { + chunked: self.chunked, + idx: self.idx, + exhausted: self.exhausted, + } + } +} + +impl<'a, C: Chunk> ChunkedIter<'a, C> { + pub fn new(chunked: &'a Chunked) -> Self { + Self { + chunked, + idx: None, + exhausted: false, + } + } + + fn move_to(&mut self, idx: Option) -> Option<&'a C::Item> { + self.exhausted = idx.is_none(); + self.idx = idx.or(self.idx); + idx.map(|idx| &self.chunked[idx]) + } +} + +impl<'a, C: Chunk> Iterator for ChunkedIter<'a, C> { + type Item = &'a C::Item; + + fn next(&mut self) -> Option { + if self.exhausted { + return None; + } + let next = self.chunked.next_index(self.idx); + self.move_to(next) + } + + fn nth(&mut self, n: usize) -> Option { + if self.exhausted { + return None; + } + let target = self.idx.map_or(0, |idx| idx.flat + 1).saturating_add(n); + let next = self.chunked.locate(target); + self.move_to(next) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Chunks of at most 3 numbers, with an identity like the real ones. + #[derive(Debug)] + struct Numbers { + id: usize, + items: Vec, + } + + thread_local! { + static NEXT_ID: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + fn next_id() -> usize { + NEXT_ID.with(|id| id.replace(id.get() + 1)) + } + + impl Chunk for Numbers { + type Item = u32; + const MAX_SIZE: usize = 3; + + fn new(items: Vec) -> Self { + Self { + id: next_id(), + items, + } + } + + fn renew(&mut self) { + self.id = next_id(); + } + + fn items(&self) -> &Vec { + &self.items + } + + fn items_mut(&mut self) -> &mut Vec { + &mut self.items + } + } + + /// The numbers `0..n` in chunks of 3. + fn numbers(n: u32) -> Chunked { + let mut chunked = Chunked::default(); + for start in (0..n).step_by(3) { + chunked.push(Numbers::new((start..(start + 3).min(n)).collect())); + } + chunked + } + + fn values(chunked: &Chunked) -> Vec { + chunked.iter().copied().collect() + } + + fn ids(chunked: &Chunked) -> Vec { + chunked.chunks().iter().map(|chunk| chunk.id).collect() + } + + fn assert_consistent(chunked: &Chunked) { + let sizes: Vec = chunked.chunks().iter().map(|c| c.items.len()).collect(); + assert!( + sizes + .iter() + .all(|&size| 0 < size && size <= Numbers::MAX_SIZE) + ); + assert_eq!(chunked.len(), sizes.iter().sum::()); + for index in 0..chunked.len() { + assert_eq!(chunked.locate(index).unwrap().flat, index); + } + assert!(chunked.locate(chunked.len()).is_none()); + } + + #[test] + fn test_push_drops_empty_chunks() { + let mut chunked = numbers(4); + chunked.push(Numbers::new(vec![])); + assert_eq!(chunked.chunks().len(), 2); + assert_eq!(chunked.len(), 4); + assert!(Chunked::::default().is_empty()); + assert!(!chunked.is_empty()); + } + + #[test] + fn test_locate_index_and_iterate() { + let chunked = numbers(7); + assert_eq!( + chunked.locate(4), + Some(ChunkIndex { + chunk: 1, + pos: 1, + flat: 4 + }) + ); + assert_eq!(chunked[4], 4); + assert_eq!(chunked[chunked.locate(6).unwrap()], 6); + assert!(chunked.locate(7).is_none()); + assert_eq!(values(&chunked), (0..7).collect::>()); + assert_eq!(chunked.iter().count(), 7); + assert_eq!((&chunked).into_iter().last(), Some(&6)); + assert_eq!(values(&Chunked::default()), Vec::::new()); + } + + #[test] + fn test_first_last_next_and_previous_index() { + let chunked = numbers(7); + let first = chunked.first_index().unwrap(); + let last = chunked.last_index().unwrap(); + assert_eq!((first.flat, last.flat), (0, 6)); + assert_eq!((last.chunk, last.pos), (2, 0)); + + let mut forward = vec![]; + let mut cur = None; + while let Some(next) = chunked.next_index(cur) { + forward.push(chunked[next]); + cur = Some(next); + } + assert_eq!(forward, (0..7).collect::>()); + + let mut backward = vec![]; + let mut cur = None; + while let Some(prev) = chunked.prev_index(cur) { + backward.push(chunked[prev]); + cur = Some(prev); + } + assert_eq!(backward, (0..7).rev().collect::>()); + + let empty = Chunked::::default(); + assert!(empty.first_index().is_none() && empty.last_index().is_none()); + } + + #[test] + #[allow(clippy::iter_nth_zero)] + fn test_iterator_nth_goes_on_from_the_current_item() { + let chunked = numbers(10); + let mut iter = chunked.iter(); + assert_eq!(iter.nth(1), Some(&1)); + assert_eq!(iter.next(), Some(&2)); + // skips 3 and 4 + assert_eq!(iter.nth(2), Some(&5)); + assert_eq!(iter.nth(0), Some(&6)); + assert_eq!(iter.nth(2), Some(&9)); + assert_eq!(iter.nth(0), None); + assert_eq!(iter.next(), None, "an exhausted iterator stays exhausted"); + + assert_eq!(values(&numbers(10)).iter().skip(4).count(), 6); + assert_eq!( + chunked.iter().skip(4).copied().collect::>(), + (4..10).collect::>() + ); + assert_eq!( + chunked.iter().step_by(4).copied().collect::>(), + vec![0, 4, 8] + ); + } + + #[test] + fn test_splice_replaces_inserts_deletes_and_appends() { + let mut chunked = numbers(10); + chunked.splice(2, 5, vec![100, 101]); + assert_eq!(values(&chunked), [0, 1, 100, 101, 5, 6, 7, 8, 9]); + assert_consistent(&chunked); + + chunked.splice(0, 0, vec![50]); + assert_eq!(values(&chunked)[..3], [50, 0, 1]); + chunked.splice(chunked.len(), chunked.len(), vec![7, 7, 7, 7]); + assert_eq!(values(&chunked)[chunked.len() - 5..], [9, 7, 7, 7, 7]); + assert_consistent(&chunked); + + let len = chunked.len(); + chunked.splice(0, len, vec![]); + assert!(chunked.is_empty()); + assert_eq!(chunked.len(), 0); + } + + #[test] + #[should_panic(expected = "splice range out of bounds")] + fn test_splice_out_of_bounds_panics() { + numbers(4).splice(2, 5, vec![]); + } + + #[test] + fn test_splice_keeps_the_chunks_it_does_not_touch() { + let mut chunked = numbers(12); + let before = ids(&chunked); + // inside the second chunk + chunked.splice(4, 5, vec![40]); + let after = ids(&chunked); + assert_eq!(after.len(), before.len()); + assert_eq!( + (after[0], after[2], after[3]), + (before[0], before[2], before[3]) + ); + assert_ne!(after[1], before[1]); + } + + #[test] + fn test_edit_replaces_only_the_changed_chunks() { + let mut chunked = numbers(9); + let before = ids(&chunked); + + // nothing is changed: nothing is replaced + assert!(!chunked.edit(|n| *n == 4, |_| false)); + assert_eq!(ids(&chunked), before); + + // the filter picks the chunk, the hook changes it + assert!(chunked.edit( + |n| *n == 4, + |items| { + items[1] = 400; + true + } + )); + let after = ids(&chunked); + assert_eq!((after[0], after[2]), (before[0], before[2])); + assert_ne!(after[1], before[1]); + assert_eq!(values(&chunked), [0, 1, 2, 3, 400, 5, 6, 7, 8]); + + // a chunk left empty is dropped + assert!(chunked.edit( + |n| *n == 0, + |items| { + items.clear(); + true + } + )); + assert_eq!(chunked.chunks().len(), 2); + assert_consistent(&chunked); + } + + #[test] + fn test_update_copies_the_chunk_so_that_other_versions_do_not_change() { + let mut chunked = numbers(6); + let version = chunked.clone(); + let before = ids(&chunked); + chunked.update(4, |n| *n = 40); + assert_eq!(values(&chunked), [0, 1, 2, 3, 40, 5]); + assert_eq!(values(&version), (0..6).collect::>()); + assert_eq!(ids(&version), before); + assert_eq!(ids(&chunked)[0], before[0]); + assert_ne!(ids(&chunked)[1], before[1]); + } + + #[test] + fn test_updated_chunks_get_a_new_identity_even_when_nothing_shares_them() { + // what is derived from or kept of a chunk is keyed by its identity + let mut chunked = numbers(6); + let before = ids(&chunked); + chunked.update(1, |n| *n += 1); + assert_ne!(ids(&chunked)[0], before[0]); + assert_eq!(ids(&chunked)[1], before[1]); + + let before = ids(&chunked); + chunked.update_all(|_, n| *n += 1); + let after = ids(&chunked); + assert!(before.iter().zip(&after).all(|(a, b)| a != b)); + } + + #[test] + fn test_update_all_gives_the_index_of_each_item() { + let mut chunked = numbers(8); + let mut seen = vec![]; + chunked.update_all(|index, n| { + seen.push(index); + *n = 10 * index as u32; + }); + assert_eq!(seen, (0..8).collect::>()); + assert_eq!(values(&chunked), [0, 10, 20, 30, 40, 50, 60, 70]); + } +} diff --git a/gpx-rs/src/core/gpx/common.rs b/gpx-rs/src/core/gpx/common.rs new file mode 100644 index 000000000..017f5b66d --- /dev/null +++ b/gpx-rs/src/core/gpx/common.rs @@ -0,0 +1,114 @@ +/// Defines an identifier made of a UUID, which is a new one by default. +macro_rules! uuid_id { + ($(#[$meta:meta])* $name:ident) => { + $(#[$meta])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name(pub uuid::Uuid); + + impl Default for $name { + fn default() -> Self { + Self(uuid::Uuid::new_v4()) + } + } + }; +} + +pub(crate) use uuid_id; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Link { + pub href: String, + pub text: Option, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct LngLat { + pub lng: f64, + pub lat: f64, +} + +#[derive(Debug, Clone, Copy)] +pub struct LngLatBounds { + pub sw: LngLat, + pub ne: LngLat, +} + +impl Default for LngLatBounds { + fn default() -> Self { + Self { + sw: LngLat { + lng: 180.0, + lat: 90.0, + }, + ne: LngLat { + lng: -180.0, + lat: -90.0, + }, + } + } +} + +impl LngLatBounds { + pub fn extend(&mut self, coordinates: LngLat) { + self.sw.lng = self.sw.lng.min(coordinates.lng); + self.sw.lat = self.sw.lat.min(coordinates.lat); + self.ne.lng = self.ne.lng.max(coordinates.lng); + self.ne.lat = self.ne.lat.max(coordinates.lat); + } + + pub fn merge(&mut self, other: &LngLatBounds) { + self.sw.lng = self.sw.lng.min(other.sw.lng); + self.sw.lat = self.sw.lat.min(other.sw.lat); + self.ne.lng = self.ne.lng.max(other.ne.lng); + self.ne.lat = self.ne.lat.max(other.ne.lat); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bounds_extend() { + let mut bounds = LngLatBounds::default(); + bounds.extend(LngLat { + lng: 4.0, + lat: 50.0, + }); + assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 50.0)); + assert_eq!((bounds.ne.lng, bounds.ne.lat), (4.0, 50.0)); + + bounds.extend(LngLat { + lng: 6.0, + lat: 45.0, + }); + bounds.extend(LngLat { + lng: 5.0, + lat: 48.0, + }); + assert_eq!((bounds.sw.lng, bounds.sw.lat), (4.0, 45.0)); + assert_eq!((bounds.ne.lng, bounds.ne.lat), (6.0, 50.0)); + } + + #[test] + fn test_bounds_merge() { + let mut a = LngLatBounds::default(); + a.extend(LngLat { lng: 0.0, lat: 0.0 }); + a.extend(LngLat { lng: 1.0, lat: 1.0 }); + let mut b = LngLatBounds::default(); + b.extend(LngLat { + lng: -2.0, + lat: 0.5, + }); + b.extend(LngLat { lng: 0.5, lat: 3.0 }); + + a.merge(&b); + assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0)); + assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0)); + + // merging an empty bounds changes nothing + a.merge(&LngLatBounds::default()); + assert_eq!((a.sw.lng, a.sw.lat), (-2.0, 0.0)); + assert_eq!((a.ne.lng, a.ne.lat), (1.0, 3.0)); + } +} diff --git a/gpx-rs/src/core/gpx/file.rs b/gpx-rs/src/core/gpx/file.rs new file mode 100644 index 000000000..02472acee --- /dev/null +++ b/gpx-rs/src/core/gpx/file.rs @@ -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, + pub trk: Vec, + pub wpt: Waypoints, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct FileInfo { + pub name: String, + pub desc: Option, + pub author: Option, + pub links: Vec, + pub time: Option, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Author { + pub name: Option, + /// `id@domain`, which the file gives as the two attributes of an `email` element. + pub email: Option, + /// The author has a single link, unlike the other elements. + pub link: Option, +} + +/// 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 { + 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 + '_ { + (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() + } +} diff --git a/gpx-rs/src/core/gpx/mod.rs b/gpx-rs/src/core/gpx/mod.rs new file mode 100644 index 000000000..7aadecbab --- /dev/null +++ b/gpx-rs/src/core/gpx/mod.rs @@ -0,0 +1,23 @@ +mod categories; +mod chunk; +mod chunked; +mod common; +mod file; +mod route; +mod segment; +mod track; +mod trackpoint; +mod waypoint; +mod waypoints; + +pub use categories::*; +pub use chunk::*; +pub use chunked::*; +pub use common::*; +pub use file::*; +pub use route::*; +pub use segment::*; +pub use track::*; +pub use trackpoint::*; +pub use waypoint::*; +pub use waypoints::*; diff --git a/gpx-rs/src/core/gpx/route.rs b/gpx-rs/src/core/gpx/route.rs new file mode 100644 index 000000000..45643a14c --- /dev/null +++ b/gpx-rs/src/core/gpx/route.rs @@ -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, +} diff --git a/gpx-rs/src/core/gpx/segment.rs b/gpx-rs/src/core/gpx/segment.rs new file mode 100644 index 000000000..a1c54e54c --- /dev/null +++ b/gpx-rs/src/core/gpx/segment.rs @@ -0,0 +1,411 @@ +use std::ops::Deref; +use std::rc::Rc; + +use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors}; + +use super::common::uuid_id; + +uuid_id!(TrackSegmentId); + +uuid_id!(TrackSegmentRevisionId); + +#[derive(Debug, Default, Clone)] +pub struct TrackSegment { + pub id: TrackSegmentId, + pub rev_id: TrackSegmentRevisionId, + points: Chunked, +} + +/// The position of a trackpoint in a segment. +pub type TrackSegmentIndex = ChunkIndex; + +pub type TrackSegmentIterator<'a> = ChunkedIter<'a, TrackpointChunk>; + +impl Deref for TrackSegment { + type Target = Chunked; + + fn deref(&self) -> &Self::Target { + &self.points + } +} + +impl TrackSegment { + /// Adds a chunk after the last trackpoint, which is dropped if it is empty. + pub fn push(&mut self, chunk: TrackpointChunk) { + self.points.push(chunk); + self.rev_id = Default::default(); + } + + /// Adds a chunk that is shared with something else after the last trackpoint. + pub fn push_shared(&mut self, chunk: Rc) { + self.points.push_shared(chunk); + self.rev_id = Default::default(); + } + + /// Applies `f` to the trackpoint at `index`, see [`Chunked::update`]. Panics if there is no + /// such trackpoint. + pub fn update(&mut self, index: usize, f: impl FnOnce(&mut Trackpoint)) { + self.points.update(index, f); + self.rev_id = Default::default(); + } + + /// Applies `f` to every trackpoint, with its index, see [`Chunked::update_all`]. + pub fn update_all(&mut self, f: impl FnMut(usize, &mut Trackpoint)) { + self.points.update_all(f); + self.rev_id = Default::default(); + } + + /// Replaces the points in `start..end` by `points`, see [`Chunked::splice`]. The first and + /// last trackpoints are anchors afterwards. + pub fn splice(&mut self, start: usize, end: usize, points: Vec) { + self.points.splice(start, end, points); + self.rev_id = Default::default(); + self.ensure_end_anchors(); + } + + /// The first and the last trackpoints are always anchors, shown at every zoom level. + /// + /// It is done by [`TrackSegment::splice`]; commands that build segments in another way need to + /// call it. + pub fn ensure_end_anchors(&mut self) { + let Some(last) = self.len().checked_sub(1) else { + return; + }; + for index in [0, last] { + if self[index].anchor != Some(0) { + self.set_anchor(index, 0); + } + } + } + + /// Makes the trackpoint at `index` an anchor shown from the map zoom level `zoom`. Panics if + /// there is no such trackpoint. + pub fn set_anchor(&mut self, index: usize, zoom: u8) { + self.update(index, |trkpt| trkpt.anchor = Some(zoom)); + } + + /// Sets the anchors of the trackpoints from the details of the path of the segment (see + /// [`compute_anchors`]), forgetting the previous ones. + pub fn compute_anchors(&mut self) { + let anchors = compute_anchors(self); + let mut anchors = anchors.into_iter().peekable(); + self.update_all(|index, trkpt| { + trkpt.anchor = match anchors.peek() { + Some(&(anchor, zoom)) if anchor == index => { + anchors.next(); + Some(zoom) + } + _ => None, + }; + }); + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::Chunk; + + use super::*; + + fn create_track_segment(nb_chunks: usize) -> TrackSegment { + let mut trkseg = TrackSegment::default(); + let mut count = 0; + for n in 1..=nb_chunks { + let mut chunk = TrackpointChunk::default(); + for _ in 0..n { + chunk.trkpt.push(Trackpoint { + ele: count as f64, + ..Default::default() + }); + count += 1; + } + trkseg.push(chunk); + } + trkseg + } + + fn eles(trkseg: &TrackSegment) -> Vec { + trkseg.iter().map(|p| p.ele).collect() + } + + fn points(eles: &[f64]) -> Vec { + eles.iter() + .map(|&ele| Trackpoint { + ele, + ..Default::default() + }) + .collect() + } + + #[test] + fn test_splice_replace_insert_delete_append() { + // chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14] + let mut trkseg = create_track_segment(5); + trkseg.splice(4, 8, points(&[-1.0, -2.0])); + let mut expected: Vec = (0..4).map(f64::from).collect(); + expected.extend([-1.0, -2.0]); + expected.extend((8..15).map(f64::from)); + assert_eq!(eles(&trkseg), expected); + assert_eq!(trkseg.len(), expected.len()); + for i in 0..trkseg.len() { + assert_eq!(trkseg[i].ele, expected[i]); + } + + trkseg.splice(2, 2, points(&[100.0])); + expected.insert(2, 100.0); + assert_eq!(eles(&trkseg), expected); + + trkseg.splice(0, 3, vec![]); + expected.drain(..3); + assert_eq!(eles(&trkseg), expected); + + let len = trkseg.len(); + trkseg.splice(len, len, points(&[7.0, 8.0])); + expected.extend([7.0, 8.0]); + assert_eq!(eles(&trkseg), expected); + + let len = trkseg.len(); + trkseg.splice(0, len, vec![]); + assert_eq!(trkseg.len(), 0); + assert!(trkseg.first_index().is_none()); + } + + fn anchors(trkseg: &TrackSegment) -> Vec> { + trkseg.iter().map(|p| p.anchor).collect() + } + + #[test] + fn test_splice_keeps_the_ends_anchors() { + let mut trkseg = TrackSegment::default(); + assert_eq!(anchors(&trkseg), vec![]); + trkseg.splice(0, 0, points(&[0.0, 1.0, 2.0])); + assert_eq!(anchors(&trkseg), [Some(0), None, Some(0)]); + + // new ends: the previous ones stay anchors + trkseg.splice(3, 3, points(&[3.0])); + trkseg.splice(0, 0, points(&[-1.0])); + assert_eq!(anchors(&trkseg), [Some(0), Some(0), None, Some(0), Some(0)]); + + // removing the ends: the new ones become anchors + trkseg.splice(4, 5, vec![]); + trkseg.splice(0, 2, vec![]); + assert_eq!(anchors(&trkseg), [Some(0), Some(0)]); + + // a single point is both ends + trkseg.splice(1, 2, vec![]); + assert_eq!(anchors(&trkseg), [Some(0)]); + } + + #[test] + fn test_compute_anchors_replaces_the_previous_ones() { + let mut trkseg = TrackSegment::default(); + let line: Vec<_> = (0..10) + .map(|i| Trackpoint { + coordinates: crate::LngLat { + lng: i as f64 * 0.001, + lat: 0.0, + }, + anchor: Some(5), + ..Default::default() + }) + .collect(); + trkseg.splice(0, 0, line); + trkseg.compute_anchors(); + let mut expected = vec![None; 10]; + expected[0] = Some(0); + expected[9] = Some(0); + assert_eq!(anchors(&trkseg), expected); + } + + #[test] + fn test_splice_keeps_untouched_chunks_shared() { + let mut trkseg = create_track_segment(5); + // the ends are anchors already, or their chunks would be copied + trkseg.ensure_end_anchors(); + let before = trkseg.chunks().to_vec(); + // inside the third chunk only + trkseg.splice(4, 5, points(&[-1.0])); + assert!(Rc::ptr_eq(&trkseg.chunks()[0], &before[0])); + assert!(Rc::ptr_eq(&trkseg.chunks()[1], &before[1])); + assert!(Rc::ptr_eq(trkseg.chunks().last().unwrap(), &before[4])); + assert!(Rc::ptr_eq( + &trkseg.chunks()[trkseg.chunks().len() - 2], + &before[3] + )); + } + + #[test] + fn test_splice_append_extends_last_chunk_and_respects_max_size() { + let mut trkseg = TrackSegment::default(); + for i in 0..10_000 { + let len = trkseg.len(); + trkseg.splice(len, len, points(&[i as f64])); + } + assert_eq!(trkseg.len(), 10_000); + assert!(trkseg.chunks().len() <= 3); + assert!(trkseg.chunks().iter().all(|c| c.trkpt.len() <= 4096)); + assert_eq!(trkseg[9_999].ele, 9_999.0); + assert_eq!(trkseg.len(), 10_000); + } + + #[test] + fn test_len() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + assert_eq!(trkseg.len(), nb_chunks * (nb_chunks + 1) / 2); + } + + #[test] + fn test_locate() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + for i in 0..trkseg.len() { + let idx = trkseg.locate(i); + assert!(idx.is_some()); + assert_eq!(trkseg[idx.unwrap()].ele, i as f64); + } + + assert!(trkseg.locate(trkseg.len()).is_none()); + } + + #[test] + fn test_index() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + for i in 0..trkseg.len() { + assert_eq!(trkseg[i].ele, i as f64); + } + } + + #[test] + #[should_panic] + fn test_index_out_of_bounds_1() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + let _ = trkseg[trkseg.len()]; + } + + #[test] + #[should_panic] + fn test_index_out_of_bounds_2() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + let _ = trkseg[TrackSegmentIndex { + chunk: trkseg.chunks().len(), + pos: 0, + flat: 0, + }]; + } + + #[test] + fn test_iter() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + for (i, trkpt) in trkseg.iter().enumerate() { + assert!(std::ptr::eq(&trkseg[i], trkpt)); + } + } + + #[test] + fn test_iter_nth() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + let tenth = trkseg.iter().nth(10); + assert!(tenth.is_some()); + assert!(std::ptr::eq(&trkseg[10], tenth.unwrap())); + } + + #[test] + fn test_iter_skip() { + let nb_chunks = 10; + let trkseg = create_track_segment(nb_chunks); + for (i, trkpt) in trkseg.iter().enumerate().skip(5) { + assert!(std::ptr::eq(&trkseg[i], trkpt)); + } + } + + #[test] + fn test_first_last_index() { + assert!(TrackSegment::default().first_index().is_none()); + assert!(TrackSegment::default().last_index().is_none()); + + let trkseg = create_track_segment(5); // 15 points in chunks of 1..=5 + let first = trkseg.first_index().unwrap(); + let last = trkseg.last_index().unwrap(); + assert_eq!((first.chunk, first.pos, first.flat), (0, 0, 0)); + assert_eq!((last.chunk, last.pos, last.flat), (4, 4, 14)); + assert_eq!(trkseg[last].ele, 14.0); + assert_eq!(trkseg.locate(14), Some(last)); + assert!(trkseg.next_index(Some(last)).is_none()); + assert!(trkseg.prev_index(Some(first)).is_none()); + } + + #[test] + fn test_prev_next_are_inverse() { + let trkseg = create_track_segment(6); + let mut idx = trkseg.last_index(); + let mut visited = vec![]; + while let Some(i) = idx { + assert_eq!(trkseg.locate(i.flat), Some(i)); + visited.push(i.flat); + idx = trkseg.prev_index(Some(i)); + } + assert_eq!(visited, (0..trkseg.len()).rev().collect::>()); + + let mut idx = trkseg.first_index(); + while let Some(i) = idx { + let next = trkseg.next_index(Some(i)); + if let Some(n) = next { + assert_eq!(trkseg.prev_index(Some(n)), Some(i)); + } + idx = next; + } + } + + fn points_of(n: usize) -> Vec { + (0..n).map(|_| Trackpoint::default()).collect() + } + + #[test] + fn test_every_change_gives_a_new_revision() { + type Change = (&'static str, fn(&mut TrackSegment)); + let changes: [Change; 7] = [ + ("push", |s| s.push(TrackpointChunk::new(points_of(1)))), + ("push_shared", |s| { + s.push_shared(Rc::new(TrackpointChunk::new(points_of(1)))) + }), + ("update", |s| s.update(0, |p| p.ele += 1.0)), + ("update_all", |s| s.update_all(|_, p| p.ele += 1.0)), + ("splice", |s| s.splice(1, 2, points_of(2))), + ("set_anchor", |s| s.set_anchor(1, 5)), + ("compute_anchors", |s| s.compute_anchors()), + ]; + for (name, change) in changes { + let mut trkseg = create_track_segment(3); + let before = trkseg.rev_id; + change(&mut trkseg); + assert_ne!(trkseg.rev_id, before, "{name}"); + } + // reading does not + let trkseg = create_track_segment(3); + let before = trkseg.rev_id; + let _ = trkseg.iter().count(); + assert_eq!(trkseg.rev_id, before); + } + + #[test] + fn test_ends_are_anchors_after_a_splice() { + let mut trkseg = create_track_segment(3); + trkseg.splice(0, 1, points_of(2)); + let last = trkseg.len() - 1; + assert_eq!(trkseg[0].anchor, Some(0)); + assert_eq!(trkseg[last].anchor, Some(0)); + + // nothing to anchor in an empty segment + let mut empty = TrackSegment::default(); + empty.ensure_end_anchors(); + assert!(empty.is_empty()); + } +} diff --git a/gpx-rs/src/core/gpx/track.rs b/gpx-rs/src/core/gpx/track.rs new file mode 100644 index 000000000..1fc7dd612 --- /dev/null +++ b/gpx-rs/src/core/gpx/track.rs @@ -0,0 +1,25 @@ +use crate::{Link, TrackSegment}; + +use super::common::uuid_id; + +uuid_id!(TrackId); + +#[derive(Debug, Default, Clone)] +pub struct Track { + pub id: TrackId, + pub info: TrackInfo, + pub trkseg: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct TrackInfo { + pub name: Option, + pub cmt: Option, + pub desc: Option, + pub src: Option, + pub links: Vec, + pub type_: Option, + pub color: Option, + pub opacity: Option, + pub width: Option, +} diff --git a/gpx-rs/src/core/gpx/trackpoint.rs b/gpx-rs/src/core/gpx/trackpoint.rs new file mode 100644 index 000000000..625acb652 --- /dev/null +++ b/gpx-rs/src/core/gpx/trackpoint.rs @@ -0,0 +1,24 @@ +use crate::LngLat; + +#[derive(Debug, Default, Clone)] +pub struct Trackpoint { + pub coordinates: LngLat, + pub ele: f64, + pub time: Option, + pub atemp: Option, + pub hr: Option, + pub cad: Option, + pub power: Option, + /// Code of the surface, see [`crate::TrackpointCategories`]. + pub surface: Option, + /// Code of the highway, see [`crate::TrackpointCategories`]. + pub highway: Option, + /// Code of the SAC hiking scale, see [`crate::TrackpointCategories`]. + pub sac_scale: Option, + /// Code of the mountain biking scale, see [`crate::TrackpointCategories`]. + pub mtb_scale: Option, + /// Whether the trackpoint is an anchor of the routing tool (a point the user can drag to + /// reroute the segment): the lowest map zoom level at which it is shown. The first and last + /// trackpoints of a segment are always anchors that are always shown (zoom 0). + pub anchor: Option, +} diff --git a/gpx-rs/src/core/gpx/waypoint.rs b/gpx-rs/src/core/gpx/waypoint.rs new file mode 100644 index 000000000..1de687d82 --- /dev/null +++ b/gpx-rs/src/core/gpx/waypoint.rs @@ -0,0 +1,35 @@ +use crate::{Link, LngLat}; + +use super::common::uuid_id; + +uuid_id!(WaypointId); + +#[derive(Debug, Default, Clone)] +pub struct Waypoint { + pub id: WaypointId, + pub coordinates: LngLat, + pub ele: f64, + pub time: Option, + pub name: Option, + pub cmt: Option, + pub desc: Option, + pub links: Vec, + pub sym: Option, + pub type_: Option, +} + +impl Waypoint { + /// Whether the waypoints are the same, whatever their ids. + pub fn same_content(&self, other: &Waypoint) -> bool { + self.coordinates.lng == other.coordinates.lng + && self.coordinates.lat == other.coordinates.lat + && self.ele == other.ele + && self.time == other.time + && self.name == other.name + && self.cmt == other.cmt + && self.desc == other.desc + && self.links == other.links + && self.sym == other.sym + && self.type_ == other.type_ + } +} diff --git a/gpx-rs/src/core/gpx/waypoints.rs b/gpx-rs/src/core/gpx/waypoints.rs new file mode 100644 index 000000000..3be828831 --- /dev/null +++ b/gpx-rs/src/core/gpx/waypoints.rs @@ -0,0 +1,315 @@ +use std::ops::Deref; + +use crate::{Chunked, Waypoint, WaypointChunk, WaypointId}; + +use super::common::uuid_id; + +uuid_id!(FileWaypointsRevisionId); + +/// The waypoints of a file, in chunks that are shared between the versions of the file (see +/// [`Chunked`]). +/// +/// Everything that changes the waypoints changes the revision too, which is why there is no +/// mutable access to the list itself: the revision tells what is derived from the waypoints +/// (coordinates for the map...) that it is out of date. +#[derive(Debug, Default, Clone)] +pub struct Waypoints { + chunks: Chunked, + /// Changes when the waypoints do. + pub rev_id: FileWaypointsRevisionId, +} + +impl Deref for Waypoints { + type Target = Chunked; + + fn deref(&self) -> &Self::Target { + &self.chunks + } +} + +impl Waypoints { + /// Waypoints made of the given chunks, which are used as they are. + pub fn new(chunks: impl IntoIterator) -> Self { + let mut waypoints = Self::default(); + for chunk in chunks { + waypoints.push(chunk); + } + waypoints + } + + /// Adds a chunk after the last waypoint. + pub fn push(&mut self, chunk: WaypointChunk) { + self.chunks.push(chunk); + self.rev_id = Default::default(); + } + + /// Adds a chunk that is shared with something else after the last waypoint. + pub fn push_shared(&mut self, chunk: std::rc::Rc) { + self.chunks.push_shared(chunk); + self.rev_id = Default::default(); + } + + /// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`]. + pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec) { + self.chunks.splice(start, end, waypoints); + self.rev_id = Default::default(); + } + + /// Inserts waypoints, so that the first one is at `index` among the waypoints (at the end if + /// the index is past it). Only the chunks around the insertion are copied. + pub fn insert_at(&mut self, index: usize, waypoints: Vec) { + if waypoints.is_empty() { + return; + } + let index = index.min(self.len()); + self.splice(index, index, waypoints); + } + + /// Inserts waypoints right after the waypoint `after`, or at the end if there is none (or if + /// it is not there). See [`Waypoints::insert_at`]. + pub fn insert_after(&mut self, after: Option, waypoints: Vec) { + let index = after + .and_then(|after| self.position(after)) + .map_or(usize::MAX, |i| i + 1); + self.insert_at(index, waypoints); + } + + /// The position of the waypoint among the waypoints. + pub fn position(&self, id: WaypointId) -> Option { + self.iter().position(|wpt| wpt.id == id) + } + + /// Edits the waypoints of the chunks that contain one accepted by `filter`, see + /// [`Chunked::edit`]. Returns whether anything changed. + pub fn edit( + &mut self, + filter: impl Fn(&Waypoint) -> bool, + f: impl FnMut(&mut Vec) -> bool, + ) -> bool { + let changed = self.chunks.edit(filter, f); + if changed { + self.rev_id = Default::default(); + } + changed + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use super::*; + + fn waypoint() -> Waypoint { + Waypoint::default() + } + + fn waypoints_with(chunks: &[usize]) -> (Waypoints, Vec) { + let mut waypoints = Waypoints::default(); + let mut ids = vec![]; + for n in chunks { + let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect(); + ids.extend(wpt.iter().map(|w| w.id)); + waypoints.push(WaypointChunk { + wpt, + ..Default::default() + }); + } + (waypoints, ids) + } + + fn ids(waypoints: &Waypoints) -> Vec { + waypoints.iter().map(|w| w.id).collect() + } + + #[test] + fn test_new_and_push() { + let (waypoints, ids_) = waypoints_with(&[2, 1]); + assert_eq!(waypoints.len(), 3); + assert_eq!(waypoints.chunks().len(), 2); + assert_eq!(ids(&waypoints), ids_); + assert_eq!(waypoints.position(ids_[2]), Some(2)); + assert_eq!(waypoints.position(WaypointId::default()), None); + // empty chunks are dropped + let waypoints = Waypoints::new([WaypointChunk::default()]); + assert!(waypoints.is_empty()); + } + + #[test] + fn test_insert_at_the_end() { + let (mut waypoints, before) = waypoints_with(&[2, 1]); + let first = waypoints.chunks()[0].clone(); + let rev = waypoints.rev_id; + let new = vec![waypoint(), waypoint()]; + let new_ids: Vec<_> = new.iter().map(|w| w.id).collect(); + waypoints.insert_after(None, new); + assert_eq!(ids(&waypoints), [before.clone(), new_ids].concat()); + // the chunk that is not touched is kept, the last one is extended + assert_eq!(waypoints.chunks().len(), 2); + assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first)); + assert_ne!(waypoints.rev_id, rev); + // an unknown waypoint is an insertion at the end too + let (mut waypoints, before) = waypoints_with(&[2]); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_after(Some(WaypointId::default()), vec![one]); + assert_eq!(ids(&waypoints), [before, vec![one_id]].concat()); + } + + #[test] + fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_next_ones() { + let (mut waypoints, before) = waypoints_with(&[2, 2]); + let last = waypoints.chunks()[1].clone(); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_after(Some(before[1]), vec![one]); + assert_eq!( + ids(&waypoints), + [&before[..2], &[one_id], &before[2..]].concat() + ); + assert_eq!(waypoints.chunks().len(), 2); + assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last)); + } + + #[test] + fn test_insert_in_the_middle_of_a_chunk_cuts_it() { + let (mut waypoints, before) = waypoints_with(&[3, 1]); + let last = waypoints.chunks()[1].clone(); + let new = vec![waypoint(), waypoint()]; + let new_ids: Vec<_> = new.iter().map(|w| w.id).collect(); + waypoints.insert_after(Some(before[0]), new); + assert_eq!( + ids(&waypoints), + [&before[..1], &new_ids[..], &before[1..]].concat() + ); + // the cut chunk is refilled, the other one is kept + assert_eq!(waypoints.chunks().len(), 2); + assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last)); + } + + #[test] + fn test_insert_nothing() { + let (mut waypoints, before) = waypoints_with(&[2]); + let rev = waypoints.rev_id; + waypoints.insert_after(Some(before[0]), vec![]); + assert_eq!(ids(&waypoints), before); + assert_eq!(waypoints.rev_id, rev); + } + + #[test] + fn test_insert_many_fills_chunks() { + let mut waypoints = Waypoints::default(); + let new: Vec<_> = (0..300).map(|_| waypoint()).collect(); + let new_ids: Vec<_> = new.iter().map(|w| w.id).collect(); + waypoints.insert_after(None, new); + assert_eq!(ids(&waypoints), new_ids); + assert_eq!(waypoints.chunks().len(), 3); + assert!(waypoints.chunks()[..2].iter().all(|chunk| { + use crate::Chunk; + chunk.is_full() + })); + } + + #[test] + fn test_insert_at_an_index() { + // at the start + let (mut waypoints, before) = waypoints_with(&[2, 1]); + let first = waypoints.chunks()[0].clone(); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_at(0, vec![one]); + assert_eq!(ids(&waypoints), [vec![one_id], before.clone()].concat()); + // the existing chunks are kept + assert_eq!(waypoints.chunks().len(), 3); + assert!(Rc::ptr_eq(&waypoints.chunks()[1], &first)); + + // between two chunks + let (mut waypoints, before) = waypoints_with(&[2, 1]); + let last = waypoints.chunks()[1].clone(); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_at(2, vec![one]); + assert_eq!( + ids(&waypoints), + [&before[..2], &[one_id], &before[2..]].concat() + ); + assert!(Rc::ptr_eq(&waypoints.chunks()[1], &last)); + + // inside a chunk + let (mut waypoints, before) = waypoints_with(&[3]); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_at(1, vec![one]); + assert_eq!( + ids(&waypoints), + [&before[..1], &[one_id], &before[1..]].concat() + ); + + // at the end, or past it + for index in [3, 100, usize::MAX] { + let (mut waypoints, before) = waypoints_with(&[3]); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_at(index, vec![one]); + assert_eq!(ids(&waypoints), [before, vec![one_id]].concat()); + } + + // in a file without waypoints + let mut waypoints = Waypoints::default(); + let one = waypoint(); + let one_id = one.id; + waypoints.insert_at(0, vec![one]); + assert_eq!(ids(&waypoints), vec![one_id]); + } + + #[test] + fn test_splice_replaces_waypoints() { + let (mut waypoints, before) = waypoints_with(&[2, 2]); + let rev = waypoints.rev_id; + waypoints.splice(1, 3, vec![]); + assert_eq!(ids(&waypoints), [before[0], before[3]]); + assert_ne!(waypoints.rev_id, rev); + } + + #[test] + fn test_edit_changes_the_chunks_with_a_match_only() { + let (mut waypoints, before) = waypoints_with(&[2, 2, 1]); + let (first, last) = (waypoints.chunks()[0].clone(), waypoints.chunks()[2].clone()); + let rev = waypoints.rev_id; + let target = before[2]; + let changed = waypoints.edit( + |wpt| wpt.id == target, + |wpts| { + wpts.retain(|wpt| wpt.id != target); + true + }, + ); + assert!(changed); + assert_ne!(waypoints.rev_id, rev); + assert_eq!(ids(&waypoints), [&before[..2], &before[3..]].concat()); + // the chunks without a match are the same ones, the changed one is new + assert!(Rc::ptr_eq(&waypoints.chunks()[0], &first)); + assert!(Rc::ptr_eq(&waypoints.chunks()[2], &last)); + assert_eq!(waypoints.chunks().len(), 3); + + // a chunk left empty is dropped + let (mut waypoints, before) = waypoints_with(&[1, 2]); + waypoints.edit( + |wpt| wpt.id == before[0], + |wpts| { + wpts.clear(); + true + }, + ); + assert_eq!(waypoints.chunks().len(), 1); + assert_eq!(ids(&waypoints), before[1..]); + + // a chunk that is not changed is kept, and so is the revision + let (mut waypoints, before) = waypoints_with(&[2]); + let (chunk, rev) = (waypoints.chunks()[0].clone(), waypoints.rev_id); + assert!(!waypoints.edit(|wpt| wpt.id == before[0], |_| false)); + assert!(Rc::ptr_eq(&waypoints.chunks()[0], &chunk)); + assert_eq!(waypoints.rev_id, rev); + assert!(!waypoints.edit(|_| false, |_| true)); + } +} diff --git a/gpx-rs/src/core/io/mod.rs b/gpx-rs/src/core/io/mod.rs new file mode 100644 index 000000000..b6d5f5f77 --- /dev/null +++ b/gpx-rs/src/core/io/mod.rs @@ -0,0 +1,5 @@ +mod parse; +mod write; + +pub use parse::*; +pub use write::*; diff --git a/gpx-rs/src/core/io/parse.rs b/gpx-rs/src/core/io/parse.rs new file mode 100644 index 000000000..f221f40eb --- /dev/null +++ b/gpx-rs/src/core/io/parse.rs @@ -0,0 +1,1262 @@ +use crate::{ + Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint, + TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance, +}; +use chrono::DateTime; +use quick_xml::Error; +use quick_xml::escape::resolve_predefined_entity; +use quick_xml::events::Event; +use quick_xml::events::attributes::Attributes; +use quick_xml::reader::Reader; + +enum GPXElement { + Metadata, + Name, + Comment, + Description, + Source, + Author(Author), + Link(Link), + Text, + /// A track, or a route, whose information is read the same way. + Track(Track), + Segment(TrackSegment), + Trackpoint(Trackpoint), + RoutePoint(LngLat), + Waypoint(Waypoint), + Elevation, + Time, + Temperature, + Heartrate, + Cadence, + Power, + Surface, + Highway, + SacScale, + MtbScale, + Symbol, + Type, + Color, + Opacity, + Width, +} + +fn parse_coordinates(attributes: Attributes<'_>) -> LngLat { + let mut coordinates = LngLat::default(); + for attr in attributes.flatten() { + match attr.key.as_ref() { + "lat" => coordinates.lat = attr.value.parse().unwrap_or_default(), + "lon" => coordinates.lng = attr.value.parse().unwrap_or_default(), + _ => (), + } + } + coordinates +} + +/// Whether the element is a point of the detailed path of a route point (`gpxx:rpt`). +fn is_route_point_extension(name: &str) -> bool { + name == "rpt" || name.ends_with(":rpt") +} + +/// Whether the element holds a value, which is its text. +fn holds_text(element: Option<&GPXElement>) -> bool { + matches!( + element, + Some( + GPXElement::Name + | GPXElement::Comment + | GPXElement::Description + | GPXElement::Source + | GPXElement::Text + | GPXElement::Elevation + | GPXElement::Time + | GPXElement::Temperature + | GPXElement::Heartrate + | GPXElement::Cadence + | GPXElement::Power + | GPXElement::Surface + | GPXElement::Highway + | GPXElement::SacScale + | GPXElement::MtbScale + | GPXElement::Symbol + | GPXElement::Type + | GPXElement::Color + | GPXElement::Opacity + | GPXElement::Width + ) + ) +} + +/// Gives the text `e` of the element at the top of the stack, which is removed, to what holds it. +fn apply_text( + stack: &mut Vec, + gpx: &mut File, + categories: &mut TrackpointCategories, + e: &str, +) { + match stack.last_mut() { + Some(GPXElement::Name) => { + stack.pop(); + match stack.last_mut() { + Some(GPXElement::Metadata) => { + gpx.info.name = e.to_owned(); + } + Some(GPXElement::Author(author)) => { + author.name = Some(e.to_owned()); + } + Some(GPXElement::Track(trk)) => { + trk.info.name = Some(e.to_owned()); + } + Some(GPXElement::Waypoint(wpt)) => { + wpt.name = Some(e.to_owned()); + } + _ => (), + } + } + Some(GPXElement::Comment) => { + stack.pop(); + match stack.last_mut() { + Some(GPXElement::Track(trk)) => { + trk.info.cmt = Some(e.to_owned()); + } + Some(GPXElement::Waypoint(wpt)) => { + wpt.cmt = Some(e.to_owned()); + } + _ => (), + } + } + Some(GPXElement::Description) => { + stack.pop(); + match stack.last_mut() { + Some(GPXElement::Metadata) => { + gpx.info.desc = Some(e.to_owned()); + } + Some(GPXElement::Track(trk)) => { + trk.info.desc = Some(e.to_owned()); + } + Some(GPXElement::Waypoint(wpt)) => { + wpt.desc = Some(e.to_owned()); + } + _ => (), + } + } + Some(GPXElement::Source) => { + stack.pop(); + if let Some(GPXElement::Track(trk)) = stack.last_mut() { + trk.info.src = Some(e.to_owned()); + } + } + Some(GPXElement::Text) => { + stack.pop(); + if let Some(GPXElement::Link(link)) = stack.last_mut() { + link.text = Some(e.to_owned()); + } + } + Some(GPXElement::Elevation) => { + stack.pop(); + match stack.last_mut() { + Some(GPXElement::Trackpoint(trkpt)) => { + trkpt.ele = e.parse().unwrap_or_default(); + } + Some(GPXElement::Waypoint(wpt)) => { + wpt.ele = e.parse().unwrap_or_default(); + } + _ => (), + } + } + Some(GPXElement::Time) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.time = DateTime::parse_from_rfc3339(e) + .ok() + .map(|time| time.timestamp_millis()); + } + } + Some(GPXElement::Temperature) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.atemp = e.parse().ok(); + } + } + Some(GPXElement::Heartrate) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.hr = e.parse().ok(); + } + } + Some(GPXElement::Cadence) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.cad = e.parse().ok(); + } + } + Some(GPXElement::Power) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.power = e.parse().ok(); + } + } + Some(GPXElement::Surface) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.surface = categories.surface.code(e); + } + } + Some(GPXElement::Highway) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.highway = categories.highway.code(e); + } + } + Some(GPXElement::SacScale) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.sac_scale = categories.sac_scale.code(e); + } + } + Some(GPXElement::MtbScale) => { + stack.pop(); + if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { + trkpt.mtb_scale = categories.mtb_scale.code(e); + } + } + Some(GPXElement::Symbol) => { + stack.pop(); + if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() { + wpt.sym = Some(e.to_owned()); + } + } + Some(GPXElement::Type) => { + stack.pop(); + match stack.last_mut() { + Some(GPXElement::Track(trk)) => { + trk.info.type_ = Some(e.to_owned()); + } + Some(GPXElement::Waypoint(wpt)) => { + wpt.type_ = Some(e.to_owned()); + } + _ => (), + } + } + Some(GPXElement::Color) => { + stack.pop(); + if let Some(GPXElement::Track(trk)) = stack.last_mut() { + trk.info.color = Some(e.to_owned()); + } + } + Some(GPXElement::Opacity) => { + stack.pop(); + if let Some(GPXElement::Track(trk)) = stack.last_mut() { + trk.info.opacity = e.parse().ok(); + } + } + Some(GPXElement::Width) => { + stack.pop(); + if let Some(GPXElement::Track(trk)) = stack.last_mut() { + trk.info.width = e.parse().ok(); + } + } + _ => (), + } +} + +/// The email of the author, given as the `id` and `domain` attributes of the element. +fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) { + let Some(GPXElement::Author(author)) = stack.last_mut() else { + return; + }; + let (mut id, mut domain) = (None, None); + for attr in attributes.flatten() { + match attr.key.as_ref() { + "id" => id = Some(attr.value.to_string()), + "domain" => domain = Some(attr.value.to_string()), + _ => (), + } + } + if let (Some(id), Some(domain)) = (id, domain) { + author.email = Some(format!("{id}@{domain}")); + } +} + +/// The link with the given `href`, if it is an attribute of the element. +fn parse_link(attributes: Attributes<'_>) -> Link { + let mut link = Link::default(); + for attr in attributes.flatten() { + if attr.key.as_ref() == "href" { + link.href = attr.value.to_string(); + } + } + link +} + +/// Gives a link to what holds it, which is at the top of the stack. +fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) { + match stack.last_mut() { + // the author has a single link, the other elements have as many as they want + Some(GPXElement::Author(author)) => author.link = Some(link), + Some(GPXElement::Track(trk)) => trk.info.links.push(link), + Some(GPXElement::Waypoint(wpt)) => wpt.links.push(link), + Some(GPXElement::Metadata) => gpx.info.links.push(link), + _ => (), + } +} + +/// Builds the segment of a route. +/// +/// The route points are the anchors of the segment, and are the only ones that the file gives +/// (no anchor is computed). The points of the detailed path between a route point and the next one +/// (`gpxx:rpt`, which only have a position) are regular trackpoints, with the elevation worked out +/// from the one of the route points around them. +#[derive(Default)] +struct RouteReader { + segment: TrackSegment, + chunk: TrackpointChunk, + /// The intermediate points that follow the last route point, up to the next one. + pending: Vec, + /// Position and elevation of the last route point. + previous: Option<(LngLat, f64)>, +} + +impl RouteReader { + fn push(&mut self, point: Trackpoint) { + self.chunk.trkpt.push(point); + if self.chunk.is_full() { + self.segment.push(std::mem::take(&mut self.chunk)); + } + } + + /// Adds a route point, after the intermediate points of the previous one. `following` are the + /// intermediate points that lead from it to the next one. + fn push_route_point(&mut self, mut rtept: Trackpoint, following: Vec) { + rtept.anchor = Some(0); + let here = (rtept.coordinates, rtept.ele); + self.flush(Some(here)); + self.previous = Some(here); + self.push(rtept); + self.pending = following; + } + + /// Adds the pending intermediate points, which lead to the route point `next` if there is one. + fn flush(&mut self, next: Option<(LngLat, f64)>) { + let mut pending = std::mem::take(&mut self.pending); + if let Some(previous) = self.previous { + interpolate_elevations(&mut pending, previous, next); + } + for point in pending { + self.push(point); + } + } + + fn finish(mut self) -> TrackSegment { + self.flush(None); + self.segment.push(std::mem::take(&mut self.chunk)); + self.segment.ensure_end_anchors(); + self.segment + } +} + +/// Gives the points of a path from `from` to `to` an elevation that goes from the one of the first +/// to the one of the second, in proportion to the distance along the path. Without `to`, they have +/// the elevation of `from`. +fn interpolate_elevations( + points: &mut [Trackpoint], + from: (LngLat, f64), + to: Option<(LngLat, f64)>, +) { + let Some((to_position, to_ele)) = to else { + points.iter_mut().for_each(|point| point.ele = from.1); + return; + }; + let mut distances = Vec::with_capacity(points.len() + 1); + let mut total = 0.0; + let mut last = from.0; + for position in points + .iter() + .map(|point| point.coordinates) + .chain([to_position]) + { + total += distance(last, position); + distances.push(total); + last = position; + } + for (point, along) in points.iter_mut().zip(distances) { + point.ele = if total > 0.0 { + from.1 + (to_ele - from.1) * along / total + } else { + from.1 + }; + } +} + +/// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of +/// `categories`, which learns the values it does not know yet. +pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { + let mut reader = Reader::from_reader(data); + let mut buf = vec![]; + let mut gpx = File::default(); + let mut stack: Vec = vec![]; + let mut trkpt_chunk = TrackpointChunk::default(); + let mut wpt_chunk = WaypointChunk::default(); + // 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. + let mut route = RouteReader::default(); + let mut rpt_points: Vec = vec![]; + // the text of the element at the top of the stack, up to now + let mut text = String::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => match e.name().as_ref() { + "metadata" => stack.push(GPXElement::Metadata), + "name" => stack.push(GPXElement::Name), + "cmt" => stack.push(GPXElement::Comment), + "desc" => stack.push(GPXElement::Description), + "src" => stack.push(GPXElement::Source), + "author" => stack.push(GPXElement::Author(Author::default())), + "link" => stack.push(GPXElement::Link(parse_link(e.attributes()))), + "email" => set_email(&mut stack, e.attributes()), + "text" => stack.push(GPXElement::Text), + "trk" | "rte" => stack.push(GPXElement::Track(Track::default())), + "trkseg" => { + stack.push(GPXElement::Segment(TrackSegment::default())); + } + "trkpt" | "rtept" => { + stack.push(GPXElement::Trackpoint(Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + })); + } + "wpt" => { + stack.push(GPXElement::Waypoint(Waypoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + })); + } + name if is_route_point_extension(name) => { + stack.push(GPXElement::RoutePoint(parse_coordinates(e.attributes()))); + } + "ele" => stack.push(GPXElement::Elevation), + "time" => stack.push(GPXElement::Time), + e if e.ends_with("atemp") => stack.push(GPXElement::Temperature), + e if e.ends_with("hr") => stack.push(GPXElement::Heartrate), + e if e.ends_with("cad") => stack.push(GPXElement::Cadence), + e if e.ends_with("power") => stack.push(GPXElement::Power), + e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power), + "surface" => stack.push(GPXElement::Surface), + "highway" => stack.push(GPXElement::Highway), + "sac_scale" => stack.push(GPXElement::SacScale), + "mtb_scale" => stack.push(GPXElement::MtbScale), + "sym" => stack.push(GPXElement::Symbol), + "type" => stack.push(GPXElement::Type), + e if e.ends_with("color") => stack.push(GPXElement::Color), + e if e.ends_with("opacity") => stack.push(GPXElement::Opacity), + e if e.ends_with("width") => stack.push(GPXElement::Width), + _ => (), + }, + // Self-closing points (``), which have no children + Ok(Event::Empty(e)) => match e.name().as_ref() { + "link" => attach_link(&mut stack, &mut gpx, parse_link(e.attributes())), + "email" => set_email(&mut stack, e.attributes()), + "trkpt" => { + if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() { + trkpt_chunk.trkpt.push(Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }); + if trkpt_chunk.is_full() { + trkseg.push(std::mem::take(&mut trkpt_chunk)); + } + } + } + "rtept" => { + if let Some(GPXElement::Track(_)) = stack.last() { + route.push_route_point( + Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }, + vec![], + ); + } + } + name if is_route_point_extension(name) => { + if let Some(GPXElement::Trackpoint(_)) = stack.last() { + rpt_points.push(Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }); + } + } + "wpt" => { + wpt_chunk.wpt.push(Waypoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }); + if wpt_chunk.is_full() { + gpx.wpt.push(std::mem::take(&mut wpt_chunk)); + } + } + _ => (), + }, + Ok(Event::End(e)) => { + if holds_text(stack.last()) { + let value = std::mem::take(&mut text); + if value.is_empty() { + stack.pop(); + } else { + apply_text(&mut stack, &mut gpx, categories, &value); + } + } + match e.name().as_ref() { + "gpx" => { + if !wpt_chunk.wpt.is_empty() { + gpx.wpt.push(std::mem::take(&mut wpt_chunk)); + } + } + "metadata" => { + stack.pop(); + } + "author" => { + if let Some(GPXElement::Author(author)) = stack.pop() { + gpx.info.author = Some(author); + } + } + "link" => { + if let Some(GPXElement::Link(link)) = stack.pop() { + attach_link(&mut stack, &mut gpx, link); + } + } + "trk" => { + if let Some(GPXElement::Track(trk)) = stack.pop() { + gpx.trk.push(trk); + } + } + "rte" => { + if let Some(GPXElement::Track(trk)) = stack.pop() { + gpx.rte.push(Route { + info: trk.info, + seg: std::mem::take(&mut route).finish(), + ..Default::default() + }); + } + } + "rtept" => { + if let Some(GPXElement::Trackpoint(rtept)) = stack.pop() + && let Some(GPXElement::Track(_)) = stack.last() + { + route.push_route_point(rtept, std::mem::take(&mut rpt_points)); + } + } + name if is_route_point_extension(name) => { + if let Some(GPXElement::RoutePoint(coordinates)) = stack.pop() { + rpt_points.push(Trackpoint { + coordinates, + ..Default::default() + }); + } + } + "trkseg" => { + if let Some(GPXElement::Segment(mut trkseg)) = stack.pop() + && let Some(GPXElement::Track(trk)) = stack.last_mut() + { + // `push` ignores the chunk if it is empty + trkseg.push(std::mem::take(&mut trkpt_chunk)); + trkseg.compute_anchors(); + trk.trkseg.push(trkseg); + } + } + "trkpt" => { + if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop() + && let Some(GPXElement::Segment(trkseg)) = stack.last_mut() + { + trkpt_chunk.trkpt.push(trkpt); + if trkpt_chunk.is_full() { + trkseg.push(std::mem::take(&mut trkpt_chunk)); + } + } + } + "wpt" => { + if let Some(GPXElement::Waypoint(wpt)) = stack.pop() { + wpt_chunk.wpt.push(wpt); + if wpt_chunk.is_full() { + gpx.wpt.push(std::mem::take(&mut wpt_chunk)); + } + } + } + _ => (), + } + } + // The text of an element comes in pieces: the entities (`&`) are events of their own + Ok(Event::Text(e)) => { + if holds_text(stack.last()) { + text.push_str(&e); + } + } + Ok(Event::CData(e)) => { + if holds_text(stack.last()) { + text.push_str(&e); + } + } + Ok(Event::GeneralRef(e)) => { + if holds_text(stack.last()) { + if let Some(c) = e.resolve_char_ref()? { + text.push(c); + } else if let Some(entity) = resolve_predefined_entity(&e) { + text.push_str(entity); + } + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(e), + _ => (), + } + buf.clear(); + } + Ok(gpx) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_data(name: &str) -> File { + parse( + &std::fs::read(format!("data/{name}.gpx")).unwrap(), + &mut Default::default(), + ) + .unwrap() + } + + #[test] + fn test_parse_entities_and_cdata_in_text() { + let data = r#"Tom & Jerry <3 éA "x" 'y' + & c]]> and > more + 3& + t & u4 + "#; + let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); + assert_eq!(gpx.info.name, "Tom & Jerry <3 éA \"x\" 'y'"); + assert_eq!(gpx.info.desc.as_deref(), Some("a & c and > more")); + let wpt = gpx.wpt.iter().next().unwrap(); + // empty elements leave the value unknown, and do not get in the way of the next ones + assert_eq!(wpt.name, None); + assert_eq!(wpt.desc, None); + assert_eq!(wpt.ele, 3.0); + assert_eq!(wpt.cmt.as_deref(), Some("&")); + assert_eq!(gpx.trk[0].info.name.as_deref(), Some("t & u")); + assert_eq!(gpx.trk[0].trkseg[0][0].ele, 4.0); + } + + #[test] + fn test_parse_self_closing_points() { + let gpx = parse_data("self_closing_points"); + + assert_eq!(gpx.info.name, "self closing points"); + let wpt: Vec<_> = gpx.wpt.iter().collect(); + assert_eq!(wpt.len(), 3); + assert_eq!(wpt[0].coordinates.lat, 50.0); + assert_eq!(wpt[0].name, None); + assert_eq!(wpt[1].name.as_deref(), Some("with children")); + assert_eq!(wpt[2].coordinates.lng, 4.2); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 2); + let seg = &trk.trkseg[0]; + assert_eq!(seg.len(), 3); + assert_eq!(seg[0].coordinates.lat, 50.0); + assert_eq!(seg[0].ele, 0.0); + assert_eq!(seg[1].ele, 12.5); + assert_eq!(seg[2].coordinates.lng, 4.02); + assert_eq!(trk.trkseg[1].len(), 1); + } + + #[test] + fn test_parse_simple() { + let gpx = parse_data("simple"); + + assert_eq!(gpx.info.name, "simple"); + assert!(gpx.info.desc.is_some_and(|d| d == "description")); + assert!(gpx.info.author.is_some()); + let author = gpx.info.author.as_ref().unwrap(); + assert!(author.name.as_ref().is_some_and(|n| n == "gpx.studio")); + assert!(author.link.is_some()); + let link = author.link.as_ref().unwrap(); + assert_eq!(link.href, "https://gpx.studio"); + assert!(link.text.as_ref().is_some_and(|t| t == "link text")); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert!(trk.info.name.as_ref().is_some_and(|n| n == "track name")); + assert!(trk.info.cmt.as_ref().is_some_and(|c| c == "track comment")); + assert!( + trk.info + .desc + .as_ref() + .is_some_and(|d| d == "track description") + ); + assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source")); + assert_eq!(trk.info.links.len(), 1); + let link = &trk.info.links[0]; + assert_eq!(link.href, "https://gpx.studio"); + assert!(link.text.as_ref().is_some_and(|t| t == "track link text")); + assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); + + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 80); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.790867); + assert_eq!(trkpt.coordinates.lng, 4.404968); + assert_eq!(trkpt.ele, 109.0); + } + + #[test] + fn test_parse_tracks() { + let gpx = parse_data("with_tracks"); + + assert_eq!(gpx.trk.len(), 2); + let trk = &gpx.trk[0]; + assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 1")); + assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); + + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 49); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.790867); + assert_eq!(trkpt.coordinates.lng, 4.404968); + assert_eq!(trkpt.ele, 109.0); + + let trk = &gpx.trk[1]; + assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 2")); + assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); + + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 28); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.782212); + assert_eq!(trkpt.coordinates.lng, 4.406377); + assert_eq!(trkpt.ele, 115.5); + } + + #[test] + fn test_parse_routes() { + let gpx = parse_data("with_routes"); + + assert!(gpx.trk.is_empty()); + assert_eq!(gpx.rte.len(), 2); + let rte = &gpx.rte[0]; + assert_eq!(rte.info.name.as_deref(), Some("route 1")); + assert_eq!(rte.info.type_.as_deref(), Some("Cycling")); + assert_eq!(rte.seg.len(), 49); + let trkpt = &rte.seg[0]; + assert_eq!(trkpt.coordinates.lat, 50.790867); + assert_eq!(trkpt.coordinates.lng, 4.404968); + assert_eq!(trkpt.ele, 109.0); + + let rte = &gpx.rte[1]; + assert_eq!(rte.info.name.as_deref(), Some("route 2")); + assert_eq!(rte.seg.len(), 28); + assert_ne!(gpx.rte[0].id, rte.id); + } + + #[test] + fn test_parse_route_point_extensions() { + let gpx = parse_data("with_route_extensions"); + + // a route without points has an empty segment + assert_eq!(gpx.rte.len(), 2); + assert!(gpx.rte[1].seg.is_empty()); + + let rte = &gpx.rte[0]; + assert_eq!(rte.info.name.as_deref(), Some("with extensions")); + let points: Vec<_> = rte.seg.iter().collect(); + // the route points are kept, the points of the detailed path follow the one they come with + let coordinates: Vec<_> = points + .iter() + .map(|p| (p.coordinates.lat, p.coordinates.lng)) + .collect(); + assert_eq!( + coordinates, + [(1.0, 1.0), (1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)] + ); + assert_eq!(points[3].ele, 20.0); + assert!(points[3].time.is_some()); + } + + #[test] + fn test_route_points_are_the_anchors_and_nothing_else_is() { + let gpx = parse_data("with_route_extensions"); + let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect(); + 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 + let gpx = parse_data("with_routes"); + for rte in &gpx.rte { + assert!(rte.seg.iter().all(|p| p.anchor == Some(0))); + } + // while a track gets the anchors that its shape needs + let track = parse_data("with_tracks"); + let segment = &track.trk[0].trkseg[0]; + let anchors = segment.iter().filter(|p| p.anchor.is_some()).count(); + assert!(anchors >= 2 && anchors < segment.len()); + } + + #[test] + fn test_parse_the_points_of_the_detailed_path_of_a_route() { + let gpx = parse_data("with_route_points"); + assert_eq!(gpx.rte.len(), 1); + let rte = &gpx.rte[0]; + assert_eq!(rte.info.name.as_deref(), Some("Lausanne")); + assert_eq!(rte.info.cmt.as_deref(), Some("comment")); + assert_eq!(rte.info.desc.as_deref(), Some("description")); + assert_eq!(rte.info.type_.as_deref(), Some("Cycling")); + + let points: Vec<_> = rte.seg.iter().cloned().collect(); + // 3 route points and 4 points between them, the repeated one being kept + assert_eq!(points.len(), 7); + let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect(); + assert_eq!(kinds, [true, false, false, false, true, false, true]); + assert_eq!(points[0].coordinates.lat, 46.54462809674442); + assert_eq!(points[0].coordinates.lng, 6.658079791814089); + assert_eq!(points[1].coordinates.lat, 46.54448392800987); + assert_eq!(points[3].coordinates.lng, points[2].coordinates.lng); + assert_eq!(points[4].coordinates.lat, 46.5441); + assert_eq!(points[5].coordinates.lat, 46.5439); + + // the points in between have no elevation of their own: it goes from one route point to + // the next + let ele: Vec<_> = points.iter().map(|p| p.ele).collect(); + assert_eq!((ele[0], ele[4], ele[6]), (500.0, 510.0, 505.0)); + assert!(ele[0] < ele[1] && ele[1] < ele[2] && ele[2] <= ele[3] && ele[3] < ele[4]); + assert!(ele[5] < 510.0 && ele[5] > 505.0); + } + + #[test] + fn test_the_last_point_of_a_route_is_an_anchor_even_after_a_path() { + // the last route point comes with a path that leads nowhere + let data = r#" + 10 + 20 + + + + + "#; + let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); + let points: Vec<_> = gpx.rte[0].seg.iter().cloned().collect(); + let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect(); + 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 + let anchors: Vec<_> = points.iter().map(|p| p.anchor).collect(); + assert_eq!(anchors, [Some(0), Some(0), None, Some(0)]); + // with the elevation of the last route point, as there is no next one + assert_eq!(points[2].ele, 20.0); + assert_eq!(points[3].ele, 20.0); + + // a path after a single route point + let data = r#" + + "#; + let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); + let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect(); + assert_eq!(anchors, [Some(0), Some(0)]); + } + + #[test] + fn test_elevations_between_route_points() { + let here = |lng: f64| LngLat { lng, lat: 0.0 }; + let point = |lng: f64| Trackpoint { + coordinates: here(lng), + ..Default::default() + }; + // evenly spaced points go up evenly + let mut points = vec![point(1.0), point(2.0), point(3.0)]; + interpolate_elevations(&mut points, (here(0.0), 100.0), Some((here(4.0), 200.0))); + let ele: Vec<_> = points.iter().map(|p| p.ele.round()).collect(); + assert_eq!(ele, [125.0, 150.0, 175.0]); + + // the distance counts, not the number of points + let mut points = vec![point(1.0)]; + interpolate_elevations(&mut points, (here(0.0), 0.0), Some((here(4.0), 400.0))); + assert!((points[0].ele - 100.0).abs() < 1e-6); + + // after the last route point, the elevation does not change + let mut points = vec![point(1.0), point(2.0)]; + interpolate_elevations(&mut points, (here(0.0), 321.0), None); + assert!(points.iter().all(|p| p.ele == 321.0)); + + // route points at the same place + let mut points = vec![point(0.0)]; + interpolate_elevations(&mut points, (here(0.0), 10.0), Some((here(0.0), 20.0))); + assert_eq!(points[0].ele, 10.0); + // nothing between them is fine + interpolate_elevations(&mut [], (here(0.0), 1.0), Some((here(1.0), 2.0))); + } + + #[test] + fn test_parse_every_link_and_the_email() { + let gpx = parse_data("with_links"); + + // the file has as many links as it says, self-closing or not + let hrefs = |links: &[Link]| links.iter().map(|l| l.href.clone()).collect::>(); + assert_eq!( + hrefs(&gpx.info.links), + ["https://example.com/file-1", "https://example.com/file-2"] + ); + assert_eq!(gpx.info.links[0].text.as_deref(), Some("first")); + assert_eq!(gpx.info.links[1].text, None); + + // the author has one link and an email, which is given by two attributes + let author = gpx.info.author.as_ref().unwrap(); + assert_eq!(author.name.as_deref(), Some("someone")); + assert_eq!(author.email.as_deref(), Some("someone@example.com")); + assert_eq!( + author.link.as_ref().unwrap().href, + "https://example.com/author" + ); + + let waypoints: Vec<_> = gpx.wpt.iter().collect(); + assert_eq!( + hrefs(&waypoints[0].links), + [ + "https://example.com/wpt-1", + "https://example.com/wpt-2", + "https://example.com/wpt-3" + ] + ); + assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three")); + assert!(waypoints[1].links.is_empty()); + + // tracks, and routes + assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1)); + assert_eq!( + hrefs(&gpx.trk[0].info.links), + ["https://example.com/trk-1", "https://example.com/trk-2"] + ); + assert_eq!( + hrefs(&gpx.rte[0].info.links), + ["https://example.com/rte-1", "https://example.com/rte-2"] + ); + } + + #[test] + fn test_parse_email_variants() { + let author = |xml: &str| { + let data = format!("{xml}"); + parse(data.as_bytes(), &mut Default::default()) + .unwrap() + .info + .author + .unwrap() + }; + assert_eq!( + author(r#""#) + .email + .as_deref(), + Some("a@b.c") + ); + assert_eq!( + author(r#"n"#) + .email + .as_deref(), + Some("a@b.c") + ); + // an email that is not complete is not one + assert_eq!(author(r#""#).email, None); + assert_eq!(author("n").email, None); + // outside of an author it means nothing + let gpx = parse( + br#"n"#, + &mut Default::default(), + ) + .unwrap(); + assert!(gpx.info.author.is_none()); + } + + #[test] + fn test_parse_segments() { + let gpx = parse_data("with_segments"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + + assert_eq!(trk.trkseg.len(), 2); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 49); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.790867); + assert_eq!(trkpt.coordinates.lng, 4.404968); + assert_eq!(trkpt.ele, 109.0); + + let trkseg = &trk.trkseg[1]; + assert_eq!(trkseg.len(), 28); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.782212); + assert_eq!(trkpt.coordinates.lng, 4.406377); + assert_eq!(trkpt.ele, 115.5); + } + + #[test] + fn test_parse_tracks_and_segments() { + let gpx = parse_data("with_tracks_and_segments"); + + assert_eq!(gpx.trk.len(), 2); + let trk = &gpx.trk[0]; + + assert_eq!(trk.trkseg.len(), 2); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 16); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.790867); + assert_eq!(trkpt.coordinates.lng, 4.404968); + assert_eq!(trkpt.ele, 109.0); + + let trkseg = &trk.trkseg[1]; + assert_eq!(trkseg.len(), 34); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.78727108169855); + assert_eq!(trkpt.coordinates.lng, 4.406133681127736); + assert_eq!(trkpt.ele, 115.0); + + let trk = &gpx.trk[1]; + + assert_eq!(trk.trkseg.len(), 2); + let trkseg = &trk.trkseg[0]; + assert_eq!(trkseg.len(), 19); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.782212); + assert_eq!(trkpt.coordinates.lng, 4.406377); + assert_eq!(trkpt.ele, 115.5); + + let trkseg = &trk.trkseg[1]; + assert_eq!(trkseg.len(), 10); + let trkpt = &trkseg[0]; + assert_eq!(trkpt.coordinates.lat, 50.77906316558724); + assert_eq!(trkpt.coordinates.lng, 4.412547477922485); + assert_eq!(trkpt.ele, 133.3); + } + + #[test] + fn test_parse_waypoint() { + let gpx = parse_data("with_waypoint"); + + assert_eq!(gpx.wpt.len(), 1); + let wpt = &gpx.wpt[0]; + assert_eq!(wpt.coordinates.lat, 50.7836710064975); + assert_eq!(wpt.coordinates.lng, 4.410764082658738); + assert!(wpt.name.as_ref().is_some_and(|n| n == "waypoint name")); + assert!(wpt.cmt.as_ref().is_some_and(|c| c == "waypoint comment")); + assert!( + wpt.desc + .as_ref() + .is_some_and(|d| d == "waypoint description") + ); + assert_eq!(wpt.links.len(), 1); + let link = &wpt.links[0]; + assert_eq!(link.href, "https://gpx.studio"); + assert!( + link.text + .as_ref() + .is_some_and(|t| t == "waypoint link text") + ); + assert!(wpt.sym.as_ref().is_some_and(|s| s == "Bike Trail")); + assert!(wpt.type_.as_ref().is_some_and(|t| t == "Bike Trail")); + } + + #[test] + fn test_parse_trackpoint_time() { + let gpx = parse_data("with_time"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.time.is_some_and(|t| t == 1704063600000)); + } + + #[test] + fn test_parse_trackpoint_hr() { + let gpx = parse_data("with_hr"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.hr.is_some_and(|h| h == 150)); + } + + fn parse_data_with_categories(name: &str) -> (File, TrackpointCategories) { + let mut categories = TrackpointCategories::default(); + let file = parse( + &std::fs::read(format!("data/{name}.gpx")).unwrap(), + &mut categories, + ) + .unwrap(); + (file, categories) + } + + #[test] + fn test_parse_trackpoint_surface() { + let (gpx, categories) = parse_data_with_categories("with_surface"); + + let trkseg = &gpx.trk[0].trkseg[0]; + assert_eq!(trkseg.len(), 80); + // the codes follow the order of appearance in the file + assert_eq!(categories.surface.names(), ["asphalt", "cobblestone"]); + let asphalt = Some(0); + let cobblestone = Some(1); + assert_eq!(trkseg.iter().filter(|p| p.surface == asphalt).count(), 79); + assert_eq!( + trkseg.iter().filter(|p| p.surface == cobblestone).count(), + 1 + ); + // no highway in this file + assert!(trkseg.iter().all(|trkpt| trkpt.highway.is_none())); + assert!(categories.highway.names().is_empty()); + } + + #[test] + fn test_parse_trackpoint_surface_highway_and_scales() { + let (gpx, categories) = parse_data_with_categories("with_highway"); + + let trkseg = &gpx.trk[0].trkseg[0]; + let names = |code: Option, names: &crate::Categories| { + code.and_then(|code| names.name(code)).map(str::to_owned) + }; + let values: Vec<_> = trkseg + .iter() + .map(|trkpt| { + [ + names(trkpt.surface, &categories.surface), + names(trkpt.highway, &categories.highway), + names(trkpt.sac_scale, &categories.sac_scale), + names(trkpt.mtb_scale, &categories.mtb_scale), + ] + }) + .collect(); + let some = |s: &str| Some(s.to_owned()); + assert_eq!( + values, + [ + [some("asphalt"), some("residential"), None, None], + [some("asphalt"), some("residential"), None, None], + [None, None, None, None], + [ + some("gravel"), + some("track"), + some("mountain_hiking"), + some("1") + ], + [some("gravel"), None, some("hiking"), some("1")], + ] + ); + // the tables are separate + assert_eq!(categories.sac_scale.names(), ["mountain_hiking", "hiking"]); + assert_eq!(categories.mtb_scale.names(), ["1"]); + } + + #[test] + fn test_parse_shares_the_categories_between_files() { + let mut categories = TrackpointCategories::default(); + let mut parsed = |name: &str| { + parse( + &std::fs::read(format!("data/{name}.gpx")).unwrap(), + &mut categories, + ) + .unwrap() + }; + let first = parsed("with_highway"); + let second = parsed("with_surface"); + let code = |file: &File, i: usize| file.trk[0].trkseg[0][i].surface; + // "asphalt" is the same in both, "cobblestone" comes after "gravel" + assert_eq!(code(&first, 0), Some(0)); + assert_eq!(code(&second, 0), Some(0)); + assert_eq!( + categories.surface.names(), + ["asphalt", "gravel", "cobblestone"] + ); + } + + #[test] + fn test_parse_trackpoint_cad() { + let gpx = parse_data("with_cad"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.cad.is_some_and(|c| c == 80)); + } + + #[test] + fn test_parse_trackpoint_power_1() { + let gpx = parse_data("with_power_1"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.power.is_some_and(|p| p == 200)); + } + + #[test] + fn test_parse_trackpoint_power_2() { + let gpx = parse_data("with_power_2"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.power.is_some_and(|p| p == 200)); + } + + #[test] + fn test_parse_trackpoint_power_3() { + let gpx = parse_data("with_power_3"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.power.is_some_and(|p| p == 200)); + } + + #[test] + fn test_parse_trackpoint_atemp() { + let gpx = parse_data("with_temp"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + let trkseg = &trk.trkseg[0]; + assert!(!trkseg.is_empty()); + let trkpt = &trkseg[0]; + assert!(trkpt.atemp.is_some_and(|t| t == 21)); + } + + #[test] + fn test_parse_track_style() { + let gpx = parse_data("with_style"); + + assert_eq!(gpx.trk.len(), 1); + let trk = &gpx.trk[0]; + assert_eq!(trk.trkseg.len(), 1); + assert!(trk.info.color.as_ref().is_some_and(|c| c == "2d3ee9")); + assert!(trk.info.opacity.is_some_and(|o| o == 0.5)); + assert!(trk.info.width.is_some_and(|w| w == 6.0)); + } +} diff --git a/gpx-rs/src/core/io/write.rs b/gpx-rs/src/core/io/write.rs new file mode 100644 index 000000000..f615d34bd --- /dev/null +++ b/gpx-rs/src/core/io/write.rs @@ -0,0 +1,803 @@ +use std::io; + +use chrono::DateTime; +use quick_xml::Writer; +use quick_xml::events::{BytesDecl, BytesText, Event}; + +use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint}; + +/// 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`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExportOptions { + pub time: bool, + pub hr: bool, + pub cad: bool, + pub atemp: bool, + pub power: bool, + /// The OpenStreetMap data: surface, highway, SAC scale and MTB scale. + pub osm: bool, +} + +impl ExportOptions { + pub const ALL: Self = Self::of(true); + pub const NONE: Self = Self::of(false); + + const fn of(value: bool) -> Self { + Self { + time: value, + hr: value, + cad: value, + atemp: value, + power: value, + osm: value, + } + } + + /// The options that are on in `self` or in `other`. + pub fn union(self, other: Self) -> Self { + Self { + time: self.time || other.time, + hr: self.hr || other.hr, + cad: self.cad || other.cad, + atemp: self.atemp || other.atemp, + power: self.power || other.power, + osm: self.osm || other.osm, + } + } +} + +impl File { + /// The data that the trackpoints of the file have (and waypoints for the time), which the + /// options of [`write`] can leave out. + pub fn exportable_data(&self) -> ExportOptions { + let mut data = ExportOptions::NONE; + for point in self.segments().flat_map(|seg| seg.iter()) { + data.time |= point.time.is_some(); + data.hr |= point.hr.is_some(); + data.cad |= point.cad.is_some(); + data.atemp |= point.atemp.is_some(); + data.power |= point.power.is_some(); + data.osm |= point.surface.is_some() + || point.highway.is_some() + || point.sac_scale.is_some() + || point.mtb_scale.is_some(); + if data == ExportOptions::ALL { + break; + } + } + data + } +} + +/// 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"; + +type XmlWriter = Writer>; + +/// 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 routes are written as routes (`rte`): the anchors of their segment that are shown from the +/// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between +/// two of them are the `gpxx:rpt` of the `rtept` before them (which only have a position, unlike +/// the `rtept`). The first and last trackpoints always are `rtept`. +pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec { + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4); + // writing to memory does not fail + write_file(&mut writer, file, categories, options).expect("writing to memory"); + writer.into_inner() +} + +fn write_file<'a>( + w: &mut XmlWriter, + file: &'a File, + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + w.create_element("gpx") + .with_attributes([ + ("creator", "https://gpx.studio"), + ("version", "1.1"), + ("xmlns", "http://www.topografix.com/GPX/1/1"), + ("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"), + ("xsi:schemaLocation", SCHEMA_LOCATION), + ( + "xmlns:gpxtpx", + "http://www.garmin.com/xmlschemas/TrackPointExtension/v1", + ), + ( + "xmlns:gpxx", + "http://www.garmin.com/xmlschemas/GpxExtensions/v3", + ), + ( + "xmlns:gpxpx", + "http://www.garmin.com/xmlschemas/PowerExtension/v1", + ), + ( + "xmlns:gpx_style", + "http://www.topografix.com/GPX/gpx_style/0/2", + ), + ]) + .write_inner_content(|w| { + write_metadata(w, file, options)?; + for wpt in file.wpt.iter() { + write_waypoint(w, wpt, options)?; + } + // a single unnamed route or track is named like the file + let alone = file.rte.len() + file.trk.len() == 1; + let name = |info: &'a crate::TrackInfo| match &info.name { + Some(name) if !name.is_empty() => Some(name.as_str()), + _ if alone => Some(file.info.name.as_str()), + _ => None, + }; + for rte in &file.rte { + write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?; + } + for trk in &file.trk { + write_track(w, trk, name(&trk.info), categories, options)?; + } + Ok(()) + })?; + Ok(()) +} + +fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> { + if !value.is_empty() { + w.create_element(name) + .write_text_content(BytesText::new(value))?; + } + Ok(()) +} + +fn optional_text(w: &mut XmlWriter, name: &str, value: &Option) -> io::Result<()> { + text(w, name, value.as_deref().unwrap_or_default()) +} + +fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> { + text(w, name, &value.to_string()) +} + +fn time(w: &mut XmlWriter, millis: Option) -> io::Result<()> { + if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) { + text( + w, + "time", + &time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + )?; + } + Ok(()) +} + +fn link(w: &mut XmlWriter, link: &Link) -> io::Result<()> { + if !link.href.is_empty() { + w.create_element("link") + .with_attribute(("href", link.href.as_str())) + .write_inner_content(|w| optional_text(w, "text", &link.text))?; + } + Ok(()) +} + +fn links(w: &mut XmlWriter, links: &[Link]) -> io::Result<()> { + links.iter().try_for_each(|l| link(w, l)) +} + +fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> { + let info = &file.info; + let time = options.time.then_some(info.time).flatten(); + if info.name.is_empty() + && info.desc.is_none() + && info.author.is_none() + && info.links.is_empty() + && time.is_none() + { + return Ok(()); + } + w.create_element("metadata").write_inner_content(|w| { + text(w, "name", &info.name)?; + optional_text(w, "desc", &info.desc)?; + if let Some(author) = &info.author { + w.create_element("author").write_inner_content(|w| { + optional_text(w, "name", &author.name)?; + if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@')) + { + w.create_element("email") + .with_attributes([("id", id), ("domain", domain)]) + .write_empty()?; + } + author.link.iter().try_for_each(|l| link(w, l)) + })?; + } + links(w, &info.links)?; + self::time(w, time) + })?; + Ok(()) +} + +fn write_coordinates<'a>( + w: &'a mut XmlWriter, + name: &'a str, + coordinates: crate::LngLat, +) -> quick_xml::writer::ElementWriter<'a, Vec> { + w.create_element(name).with_attributes([ + ("lat", coordinates.lat.to_string().as_str()), + ("lon", coordinates.lng.to_string().as_str()), + ]) +} + +fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> { + write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| { + number(w, "ele", wpt.ele)?; + if options.time { + time(w, wpt.time)?; + } + optional_text(w, "name", &wpt.name)?; + optional_text(w, "cmt", &wpt.cmt)?; + optional_text(w, "desc", &wpt.desc)?; + links(w, &wpt.links)?; + optional_text(w, "sym", &wpt.sym)?; + optional_text(w, "type", &wpt.type_) + })?; + Ok(()) +} + +/// The information of a track that its routes have too: name, comment, description, source, +/// links, type and style. +fn write_track_info( + w: &mut XmlWriter, + name: Option<&str>, + info: &crate::TrackInfo, +) -> io::Result<()> { + text(w, "name", name.unwrap_or_default())?; + optional_text(w, "cmt", &info.cmt)?; + optional_text(w, "desc", &info.desc)?; + optional_text(w, "src", &info.src)?; + links(w, &info.links)?; + optional_text(w, "type", &info.type_)?; + if info.color.is_some() || info.opacity.is_some() || info.width.is_some() { + w.create_element("extensions").write_inner_content(|w| { + w.create_element("gpx_style:line") + .write_inner_content(|w| { + optional_text(w, "gpx_style:color", &info.color)?; + if let Some(opacity) = info.opacity { + number(w, "gpx_style:opacity", opacity)?; + } + if let Some(width) = info.width { + number(w, "gpx_style:width", width)?; + } + Ok(()) + })?; + Ok(()) + })?; + } + Ok(()) +} + +fn write_track( + w: &mut XmlWriter, + trk: &Track, + name: Option<&str>, + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + w.create_element("trk").write_inner_content(|w| { + write_track_info(w, name, &trk.info)?; + for segment in &trk.trkseg { + w.create_element("trkseg").write_inner_content(|w| { + for point in segment.iter() { + write_point(w, "trkpt", point, &[], categories, options)?; + } + Ok(()) + })?; + } + Ok(()) + })?; + Ok(()) +} + +fn write_route( + w: &mut XmlWriter, + info: &crate::TrackInfo, + segment: &crate::TrackSegment, + name: Option<&str>, + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + w.create_element("rte").write_inner_content(|w| { + write_track_info(w, name, info)?; + let points: Vec<&Trackpoint> = segment.iter().collect(); + let last = points.len().saturating_sub(1); + 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; + while i < points.len() { + // the trackpoints up to the next route point + let mut next = i + 1; + while next < points.len() && !is_route_point(next) { + next += 1; + } + write_point( + w, + "rtept", + points[i], + &points[i + 1..next], + categories, + options, + )?; + i = next; + } + Ok(()) + })?; + Ok(()) +} + +/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one, +/// of which only the positions are written. +fn write_point( + w: &mut XmlWriter, + tag: &str, + point: &Trackpoint, + path: &[&Trackpoint], + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + let atemp = point.atemp.filter(|_| options.atemp); + let hr = point.hr.filter(|_| options.hr); + let cad = point.cad.filter(|_| options.cad); + let power = point.power.filter(|_| options.power); + let osm = [ + ("surface", point.surface, &categories.surface), + ("highway", point.highway, &categories.highway), + ("sac_scale", point.sac_scale, &categories.sac_scale), + ("mtb_scale", point.mtb_scale, &categories.mtb_scale), + ] + .map(|(name, code, categories)| { + let value = code + .filter(|_| options.osm) + .and_then(|code| categories.name(code)); + (name, value) + }); + let has_osm = osm.iter().any(|(_, value)| value.is_some()); + let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm; + + write_coordinates(w, tag, point.coordinates).write_inner_content(|w| { + number(w, "ele", point.ele)?; + if options.time { + time(w, point.time)?; + } + if !has_track_point_extension && power.is_none() && path.is_empty() { + return Ok(()); + } + w.create_element("extensions").write_inner_content(|w| { + if has_track_point_extension { + w.create_element("gpxtpx:TrackPointExtension") + .write_inner_content(|w| { + if let Some(atemp) = atemp { + number(w, "gpxtpx:atemp", atemp)?; + } + if let Some(hr) = hr { + number(w, "gpxtpx:hr", hr)?; + } + if let Some(cad) = cad { + number(w, "gpxtpx:cad", cad)?; + } + if has_osm { + w.create_element("gpxtpx:Extensions") + .write_inner_content(|w| { + for (name, value) in osm { + text(w, name, value.unwrap_or_default())?; + } + Ok(()) + })?; + } + Ok(()) + })?; + } + // `PowerInWatts` is itself the root element of Garmin's power extension, without a + // wrapper + if let Some(power) = power { + number(w, "gpxpx:PowerInWatts", power)?; + } + if !path.is_empty() { + w.create_element("gpxx:RoutePointExtension") + .write_inner_content(|w| { + for point in path { + write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?; + } + Ok(()) + })?; + } + Ok(()) + })?; + Ok(()) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::parse; + + use super::*; + + fn read(name: &str) -> (File, TrackpointCategories) { + let mut categories = TrackpointCategories::default(); + let data = std::fs::read(format!("data/{name}.gpx")).unwrap(); + (parse(&data, &mut categories).unwrap(), categories) + } + + fn written_text( + file: &File, + categories: &TrackpointCategories, + options: ExportOptions, + ) -> String { + String::from_utf8(write(file, categories, options)).unwrap() + } + + /// Writes a file and reads it again. + fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) { + let (file, mut categories) = read(name); + let written = write(&file, &categories, options); + let again = parse(&written, &mut categories).unwrap(); + (file, again, categories) + } + + fn points(file: &File) -> Vec { + file.segments() + .flat_map(|seg| seg.iter().cloned()) + .collect() + } + + fn same_points(a: &[Trackpoint], b: &[Trackpoint]) { + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + assert_eq!(a.coordinates.lat, b.coordinates.lat); + assert_eq!(a.coordinates.lng, b.coordinates.lng); + assert_eq!(a.ele, b.ele); + assert_eq!(a.time, b.time); + assert_eq!(a.atemp, b.atemp); + assert_eq!(a.hr, b.hr); + assert_eq!(a.cad, b.cad); + assert_eq!(a.power, b.power); + assert_eq!(a.surface, b.surface); + assert_eq!(a.highway, b.highway); + assert_eq!(a.sac_scale, b.sac_scale); + assert_eq!(a.mtb_scale, b.mtb_scale); + } + } + + #[test] + fn test_links_and_the_email_are_written() { + let data = std::fs::read("data/with_links.gpx").unwrap(); + let file = parse(&data, &mut Default::default()).unwrap(); + let again = parse( + &write(&file, &Default::default(), ExportOptions::ALL), + &mut Default::default(), + ) + .unwrap(); + + assert_eq!(again.info.links, file.info.links); + assert_eq!(again.info.links.len(), 2); + assert_eq!(again.info.author, file.info.author); + assert_eq!( + again.info.author.as_ref().unwrap().email.as_deref(), + Some("someone@example.com") + ); + let waypoints = |file: &File| file.wpt.iter().map(|w| w.links.clone()).collect::>(); + assert_eq!(waypoints(&again), waypoints(&file)); + assert_eq!(again.wpt.iter().next().unwrap().links.len(), 3); + for (a, b) in again.trk.iter().zip(&file.trk) { + assert_eq!(a.info.links, b.info.links); + assert_eq!(a.info.links.len(), 2); + } + } + + #[test] + fn test_files_survive_a_round_trip() { + for name in [ + "simple", + "with_links", + "with_time", + "with_hr", + "with_cad", + "with_temp", + "with_power_1", + "with_power_2", + "with_power_3", + "with_surface", + "with_highway", + "with_style", + "with_waypoint", + "with_tracks_and_segments", + "self_closing_points", + ] { + let (file, again, _) = round_trip(name, ExportOptions::ALL); + assert_eq!(file.info.name, again.info.name, "{name}"); + assert_eq!(file.info.desc, again.info.desc, "{name}"); + assert_eq!(file.info.author, again.info.author, "{name}"); + assert_eq!(file.trk.len(), again.trk.len(), "{name}"); + for (a, b) in file.trk.iter().zip(&again.trk) { + assert_eq!(a.info, b.info, "{name}"); + assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}"); + } + same_points(&points(&file), &points(&again)); + let waypoints: Vec<_> = file.wpt.iter().collect(); + let again_waypoints: Vec<_> = again.wpt.iter().collect(); + assert_eq!(waypoints.len(), again_waypoints.len(), "{name}"); + for (a, b) in waypoints.iter().zip(&again_waypoints) { + assert!(a.same_content(b), "{name}"); + } + } + } + + #[test] + fn test_options_leave_data_out() { + let (file, categories) = read("with_hr"); + assert!(points(&file).iter().any(|p| p.hr.is_some())); + let options = ExportOptions { + hr: false, + ..ExportOptions::ALL + }; + let written = written_text(&file, &categories, options); + assert!(!written.contains("hr>")); + + let (file, categories) = read("with_time"); + let options = ExportOptions { + time: false, + ..ExportOptions::ALL + }; + assert!(!written_text(&file, &categories, options).contains(""), 1); + } + + #[test] + fn test_route_points_keep_their_data_and_the_options_apply_to_them() { + let (file, categories) = read("with_hr"); + let file = as_routes(file); + let with_all = written_text(&file, &categories, ExportOptions::ALL); + assert!(with_all.contains("")); + let without = written_text( + &file, + &categories, + ExportOptions { + hr: false, + time: false, + ..ExportOptions::ALL + }, + ); + assert!(!without.contains("")); + assert!(!without.contains("