mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-10 07:36:29 +00:00
progress
This commit is contained in:
1 parent
606a0e974a
commit
67a4c090bd
107 files changed
+3474
-3948
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user