This commit is contained in:
vcoppe committed 2026-10-08 18:34:19 +02:00
1 parent 606a0e974a
commit 67a4c090bd
107 files changed
+3474 -3948

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" }
idb = "0.6.5"
js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4.79"
+148 -14
View File
@@ -9,11 +9,17 @@
//! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take
//! them as such.
//! - Every function returns `false` when its arguments are invalid or the command did nothing.
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds};
mod storage;
use storage::IdbStorage;
use gpx_engine::{
self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds, Persister, Storage,
};
use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array, Uint32Array};
#[wasm_bindgen]
@@ -92,12 +98,18 @@ thread_local! {
}
fn execute(action: Action) -> bool {
ENGINE.with(|engine| {
engine
.borrow_mut()
.as_mut()
.is_some_and(|engine| engine.execute(action))
})
let (changed, persist) = ENGINE.with(|engine| match engine.borrow_mut().as_mut() {
Some(engine) => {
let changed = engine.execute(action);
let persist = changed && (engine.last_diff().is_some() || engine.order_changed());
(changed, persist)
}
None => (false, false),
});
if persist {
schedule_save();
}
changed
}
fn edit(command: Command) -> bool {
@@ -131,6 +143,133 @@ pub fn start() {
});
}
// Storage
//
// The files are saved in IndexedDB after each change that touches them, without making the change
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them.
thread_local! {
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
static SAVING: Cell<bool> = const { Cell::new(false) };
static DIRTY: Cell<bool> = const { Cell::new(false) };
/// Resolve functions of the promises of `flush_storage`.
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console, js_name = error)]
fn console_error(message: &str);
}
fn storage() -> Option<Rc<IdbStorage>> {
STORAGE.with(|storage| storage.borrow().clone())
}
fn schedule_save() {
if storage().is_none() {
return;
}
DIRTY.set(true);
if SAVING.replace(true) {
// the running loop will go around again
return;
}
wasm_bindgen_futures::spawn_local(async {
while DIRTY.replace(false) {
let Some(storage) = storage() else { break };
let Some(snapshot) = with_engine(Engine::snapshot) else {
break;
};
let mut persister = PERSISTER
.with(|persister| persister.borrow_mut().take())
.unwrap_or_default();
let result = persister.save(&*storage, snapshot).await;
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
if let Err(error) = result {
// what failed is written with the next change
console_error(&error.to_string());
}
}
SAVING.set(false);
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
let _ = resolve.call0(&JsValue::UNDEFINED);
}
});
}
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
/// in the engine, which has to be empty, and returns the settings: an object of JSON strings by
/// key. The changes of the files that follow are saved. Call `last_update` afterwards to learn
/// about the files.
#[wasm_bindgen]
pub async fn open_storage(name: &str) -> Result<Object, JsValue> {
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
if !empty {
return Err(JsValue::from_str("the engine already has files"));
}
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
let mut persister = Persister::default();
let restored = persister.restore(&storage).await.map_err(to_js_error)?;
let settings = storage.load_settings().await.map_err(to_js_error)?;
ENGINE.with(|engine| {
if let Some(engine) = engine.borrow_mut().as_mut() {
engine.restore(restored);
}
});
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
let object = Object::new();
for (key, value) in settings {
set(&object, &key, value);
}
Ok(object)
}
fn to_js_error(error: engine::StorageError) -> JsValue {
JsValue::from_str(&error.to_string())
}
/// Resolves when what was changed has been written to the storage.
#[wasm_bindgen]
pub async fn flush_storage() -> Result<(), JsValue> {
if !SAVING.get() {
return Ok(());
}
let promise = js_sys::Promise::new(&mut |resolve, _| {
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
});
wasm_bindgen_futures::JsFuture::from(promise).await?;
Ok(())
}
/// Saves a setting, `value` being its JSON.
#[wasm_bindgen]
pub fn set_setting(key: String, value: String) {
if let Some(storage) = storage() {
wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = storage.put_setting(&key, &value).await {
console_error(&error.to_string());
}
});
}
}
#[wasm_bindgen]
pub fn delete_setting(key: String) {
if let Some(storage) = storage() {
wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = storage.delete_setting(&key).await {
console_error(&error.to_string());
}
});
}
}
// Statistics buffers
//
// Each getter returns a copy of the buffer, as a new JS typed array.
@@ -1309,12 +1448,7 @@ pub fn exportable_data(file_ids: &[u8]) -> u8 {
};
let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE);
[
data.time,
data.hr,
data.cad,
data.atemp,
data.power,
data.osm,
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
+234
View File
@@ -0,0 +1,234 @@
//! The storage of the engine in the browser: IndexedDB.
//!
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
//! store per kind of data. A batch is a single transaction, so that the stored files always are
//! ones that the engine had.
use gpx_engine::{Batch, FileId, Storage, StorageError, StoredData};
use idb::TransactionMode;
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
use js_sys::Uint8Array;
use uuid::Uuid;
use wasm_bindgen::JsValue;
const FILES: &str = "files";
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
/// The order of the files and the categories of the trackpoints, under their own names.
const META: &str = "meta";
const SETTINGS: &str = "settings";
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
const ORDER_KEY: &str = "order";
const CATEGORIES_KEY: &str = "categories";
pub struct IdbStorage {
database: Database,
}
fn error(error: impl std::fmt::Display) -> StorageError {
StorageError(error.to_string())
}
fn key(id: Uuid) -> JsValue {
JsValue::from_str(&id.to_string())
}
fn bytes(value: &[u8]) -> JsValue {
Uint8Array::from(value).into()
}
fn to_vec(value: &JsValue) -> Vec<u8> {
Uint8Array::new(value).to_vec()
}
fn to_uuid(key: &JsValue) -> Result<Uuid, StorageError> {
key.as_string()
.and_then(|key| Uuid::parse_str(&key).ok())
.ok_or_else(|| error("a key is not an id"))
}
impl IdbStorage {
/// Opens the database, which is created if it does not exist.
pub async fn open(name: &str) -> Result<Self, StorageError> {
let factory = Factory::new().map_err(error)?;
let mut request = factory.open(name, Some(1)).map_err(error)?;
request.on_upgrade_needed(|event| {
if let Ok(database) = event.database() {
for store in STORES {
let _ = database.create_object_store(store, ObjectStoreParams::new());
}
}
});
let database = request.await.map_err(error)?;
Ok(Self { database })
}
fn transaction(
&self,
stores: &[&str],
mode: TransactionMode,
) -> Result<Transaction, StorageError> {
self.database.transaction(stores, mode).map_err(error)
}
/// Everything stored in the object store, as `(key, bytes)`.
async fn read_all(store: &ObjectStore) -> Result<Vec<(JsValue, Vec<u8>)>, StorageError> {
let keys = store.get_all_keys(None, None).map_err(error)?;
let values = store.get_all(None, None).map_err(error)?;
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
}
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
let files = transaction.object_store(FILES).map_err(error)?;
let trackpoints = transaction.object_store(TRACKPOINT_CHUNKS).map_err(error)?;
let waypoints = transaction.object_store(WAYPOINT_CHUNKS).map_err(error)?;
let meta = transaction.object_store(META).map_err(error)?;
// the requests are all made before waiting for them
let mut puts = vec![];
let mut deletes = vec![];
for (store, chunks) in [
(&trackpoints, &batch.put_trackpoint_chunks),
(&waypoints, &batch.put_waypoint_chunks),
] {
for (id, data) in chunks {
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
}
}
for (FileId(id), data) in &batch.put_files {
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
}
if let Some(order) = &batch.order {
let value = bytes(order);
puts.push(
meta.put(&value, Some(&JsValue::from_str(ORDER_KEY)))
.map_err(error)?,
);
}
if let Some(categories) = &batch.categories {
let value = bytes(categories);
puts.push(
meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY)))
.map_err(error)?,
);
}
for FileId(id) in &batch.delete_files {
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
}
for (store, ids) in [
(&trackpoints, &batch.delete_trackpoint_chunks),
(&waypoints, &batch.delete_waypoint_chunks),
] {
for id in ids {
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
}
}
for put in puts {
put.await.map_err(error)?;
}
for delete in deletes {
delete.await.map_err(error)?;
}
Ok(())
}
}
impl Storage for IdbStorage {
async fn load(&self) -> Result<StoredData, StorageError> {
let transaction = self.transaction(
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
TransactionMode::ReadOnly,
)?;
let store = |name| transaction.object_store(name).map_err(error);
let files = Self::read_all(&store(FILES)?).await?;
let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?;
let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?;
let meta = store(META)?;
let order = meta
.get(JsValue::from_str(ORDER_KEY))
.map_err(error)?
.await
.map_err(error)?;
let categories = meta
.get(JsValue::from_str(CATEGORIES_KEY))
.map_err(error)?
.await
.map_err(error)?;
transaction.await.map_err(error)?;
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
records
.into_iter()
.map(|(k, v)| Ok((to_uuid(&k)?, v)))
.collect()
};
Ok(StoredData {
files: ids(files)?
.into_iter()
.map(|(id, data)| (FileId(id), data))
.collect(),
trackpoint_chunks: ids(trackpoint_chunks)?,
waypoint_chunks: ids(waypoint_chunks)?,
order: order.as_ref().map(to_vec),
categories: categories.as_ref().map(to_vec),
})
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
let transaction = self.transaction(
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
TransactionMode::ReadWrite,
)?;
match self.write(&transaction, batch).await {
Ok(()) => {
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
Err(err) => {
// nothing of the batch is kept
let _ = transaction.abort();
Err(err)
}
}
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
let keys = store.get_all_keys(None, None).map_err(error)?;
let values = store.get_all(None, None).map_err(error)?;
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
transaction.await.map_err(error)?;
Ok(keys
.into_iter()
.zip(values)
.filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?)))
.collect())
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
store
.put(&JsValue::from_str(value), Some(&JsValue::from_str(key)))
.map_err(error)?
.await
.map_err(error)?;
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
store
.delete(Query::Key(JsValue::from_str(key)))
.map_err(error)?
.await
.map_err(error)?;
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
}