2026-05-14 17:35:29 +03:00
#[ allow(unused_imports) ]
2026-05-13 10:21:24 +03:00
use tauri_plugin_sql ::{ Migration , MigrationKind } ;
2026-05-14 14:17:57 +03:00
#[ allow(unused_imports) ]
use tauri ::Manager ;
2026-09-29 23:19:37 +03:00
#[ allow(unused_imports) ]
use tauri ::Emitter ; // app.emit(...) for progress events (Tauri v2)
2026-05-14 14:17:57 +03:00
use serde ::Serialize ;
2026-05-13 10:21:24 +03:00
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
#[ allow(unused, dead_code) ]
mod ble ;
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Directory where the Kotlin GpsRecorderService writes native point files.
// Kotlin uses `filesDir.parentFile` (the app root, e.g. /data/user/0/<pkg>/).
// Rust's app_data_dir() nesting on Android is unreliable, so instead of a fixed
// `parent().parent()`, scan candidate ancestors and pick the one that actually
// holds the pointer / point files. Falls back to the old app_data_dir()/../.. .
fn native_root ( app : & tauri ::AppHandle ) -> std ::path ::PathBuf {
let data_dir = app . path ( ) . app_data_dir ( ) . unwrap_or_default ( ) ;
let candidates : Vec < std ::path ::PathBuf > = [
data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | p | p . to_path_buf ( ) ) ,
data_dir . parent ( ) . map ( | p | p . to_path_buf ( ) ) ,
Some ( data_dir . clone ( ) ) ,
] . into_iter ( ) . flatten ( ) . collect ( ) ;
// Prefer the dir that has the live pointer file.
for c in & candidates {
if c . join ( " gps_live_points.jsonl " ) . exists ( ) { return c . clone ( ) ; }
}
// Otherwise the dir that has any gps_points_*.jsonl.
for c in & candidates {
if let Ok ( rd ) = std ::fs ::read_dir ( c ) {
if rd . flatten ( ) . any ( | e | e . file_name ( ) . to_str ( )
. map_or ( false , | n | n . starts_with ( " gps_points_ " ) & & n . ends_with ( " .jsonl " ) ) ) {
return c . clone ( ) ;
}
}
}
data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | p | p . to_path_buf ( ) ) . unwrap_or ( data_dir )
}
// Diagnostic: report app_data_dir, each candidate root, whether the pointer
// exists there and how many gps_points_*.jsonl it holds. Surfaced to JS/logcat.
#[ tauri::command ]
fn debug_native_paths ( app : tauri ::AppHandle ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let candidates : Vec < ( String , std ::path ::PathBuf ) > = vec! [
( " grandparent(../..) " . into ( ) , data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | p | p . to_path_buf ( ) ) . unwrap_or_default ( ) ) ,
( " parent(..) " . into ( ) , data_dir . parent ( ) . map ( | p | p . to_path_buf ( ) ) . unwrap_or_default ( ) ) ,
( " app_data_dir " . into ( ) , data_dir . clone ( ) ) ,
] ;
let mut out = format! ( " app_data_dir= {} chosen= {} " , data_dir . display ( ) , native_root ( & app ) . display ( ) ) ;
for ( label , dir ) in & candidates {
let ptr = dir . join ( " gps_live_points.jsonl " ) . exists ( ) ;
let cnt = std ::fs ::read_dir ( dir ) . map ( | rd | rd . flatten ( )
. filter ( | e | e . file_name ( ) . to_str ( ) . map_or ( false , | n | n . starts_with ( " gps_points_ " ) & & n . ends_with ( " .jsonl " ) ) )
. count ( ) ) . unwrap_or ( 0 ) ;
out . push_str ( & format! ( " | {} = {} ptr= {} gps_points= {} " , label , dir . display ( ) , ptr , cnt ) ) ;
}
Ok ( out )
}
2026-07-07 00:28:27 +03:00
// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content).
// Runs the blocking ureq call on a worker thread (spawn_blocking) with short
// timeouts, so an unreachable host (e.g. router 192.168.1.1 when off home wifi)
// never freezes the UI — it just fails after a few seconds in the background.
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
#[ tauri::command ]
2026-07-07 00:28:27 +03:00
async fn http_request ( url : String , method : String , body : Option < String > ) -> Result < String , String > {
tauri ::async_runtime ::spawn_blocking ( move | | -> Result < String , String > {
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( )
. timeout_connect ( Some ( std ::time ::Duration ::from_secs ( 4 ) ) )
. timeout_global ( Some ( std ::time ::Duration ::from_secs ( 12 ) ) )
. build ( )
. into ( ) ;
let mut resp = match method . to_uppercase ( ) . as_str ( ) {
" POST " = > {
let b = body . unwrap_or_default ( ) ;
agent . post ( & url )
. header ( " Content-Type " , " application/json " )
. send ( b . as_bytes ( ) )
. map_err ( | e | e . to_string ( ) ) ?
}
_ = > agent . get ( & url ) . call ( ) . map_err ( | e | e . to_string ( ) ) ? ,
} ;
resp . body_mut ( ) . read_to_string ( ) . map_err ( | e | e . to_string ( ) )
} )
. await
. map_err ( | e | e . to_string ( ) ) ?
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
}
2026-08-27 03:27:15 +03:00
// HTTP proxy for the "Честный знак" API. Unlike http_request it (a) sends a
// browser-ish User-Agent (the CRPT gateway is picky) and (b) returns the HTTP
// status alongside the body as a JSON envelope {"status":N,"body":"..."} so the
// caller can drive session logic: 204 = no nutrition data, 401/400 = refresh sid,
// 427 = version/attestation gate. http_status_as_error(false) keeps 4xx/5xx as
// normal responses instead of errors so we can read their status/body.
#[ tauri::command ]
async fn chzn_http ( url : String , method : String , body : Option < String > ) -> Result < String , String > {
tauri ::async_runtime ::spawn_blocking ( move | | -> Result < String , String > {
let agent : ureq ::Agent = ureq ::Agent ::config_builder ( )
. timeout_connect ( Some ( std ::time ::Duration ::from_secs ( 5 ) ) )
. timeout_global ( Some ( std ::time ::Duration ::from_secs ( 15 ) ) )
. http_status_as_error ( false )
. build ( )
. into ( ) ;
let ua = " Mozilla/5.0 (Linux; Android 15) " ;
let mut resp = match method . to_uppercase ( ) . as_str ( ) {
" POST " = > {
let b = body . unwrap_or_default ( ) ;
agent . post ( & url )
. header ( " Content-Type " , " application/json " )
. header ( " User-Agent " , ua )
. send ( b . as_bytes ( ) )
. map_err ( | e | e . to_string ( ) ) ?
}
_ = > agent . get ( & url ) . header ( " User-Agent " , ua ) . call ( ) . map_err ( | e | e . to_string ( ) ) ? ,
} ;
let status = resp . status ( ) . as_u16 ( ) ;
let text = resp . body_mut ( ) . read_to_string ( ) . unwrap_or_default ( ) ;
Ok ( serde_json ::json! ( { " status " : status , " body " : text } ) . to_string ( ) )
} )
. await
. map_err ( | e | e . to_string ( ) ) ?
}
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// Get the active points file name from gps_live_points.jsonl pointer
#[ tauri::command ]
fn get_points_filename ( app : tauri ::AppHandle ) -> Result < String , String > {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
let app_root = native_root ( & app ) ;
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
let ptr_path = app_root . join ( " gps_live_points.jsonl " ) ;
if ptr_path . exists ( ) {
let content = std ::fs ::read_to_string ( & ptr_path ) . unwrap_or_default ( ) . trim ( ) . to_string ( ) ;
if content . ends_with ( " .jsonl " ) { Ok ( content ) } else { Ok ( String ::new ( ) ) }
} else {
Ok ( String ::new ( ) )
}
}
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Hand the just-created session id to the running GPS service. The service, on
// its next flush tick, renames the active gps_points_<ms>.jsonl to
// gps_points_<ms>_s<id>.jsonl so recovery can bind the file to its session with
// no guessing. Written to gps_session.txt in the same dir the service watches.
#[ tauri::command ]
fn gps_set_session_id ( app : tauri ::AppHandle , session_id : String ) -> Result < String , String > {
// Guard: digits only (it becomes part of a filename).
if session_id . is_empty ( ) | | ! session_id . chars ( ) . all ( | c | c . is_ascii_digit ( ) ) {
return Err ( " invalid session id " . to_string ( ) ) ;
}
let app_root = native_root ( & app ) ;
let path = app_root . join ( " gps_session.txt " ) ;
std ::fs ::write ( & path , session_id ) . map_err ( | e | e . to_string ( ) ) ? ;
Ok ( " ok " . to_string ( ) )
}
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// Delete a GPS points file after confirmed DB insert
#[ tauri::command ]
fn delete_points_file ( app : tauri ::AppHandle , filename : String ) -> Result < String , String > {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
let app_root = native_root ( & app ) ;
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
let path = app_root . join ( & filename ) ;
if path . exists ( ) & & filename . starts_with ( " gps_points_ " ) & & filename . ends_with ( " .jsonl " ) {
std ::fs ::remove_file ( & path ) . map_err ( | e | e . to_string ( ) ) ? ;
Ok ( " deleted " . to_string ( ) )
} else {
Ok ( " not_found " . to_string ( ) )
}
}
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
// Ask Kotlin to show the SAF "open document" picker. Kotlin copies the chosen
// file into the app dir and writes its path to open_file_result.txt.
#[ tauri::command ]
fn pick_file_open ( app : tauri ::AppHandle , mime : String ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
std ::fs ::create_dir_all ( & data_dir ) . ok ( ) ;
let m = if mime . is_empty ( ) { " */* " . to_string ( ) } else { mime } ;
for dir in [ Some ( data_dir . clone ( ) ) , data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | p | p . to_path_buf ( ) ) ] {
if let Some ( d ) = dir {
let _ = std ::fs ::write ( d . join ( " open_file_result.txt " ) , " " ) ;
let _ = std ::fs ::write ( d . join ( " gps_command.txt " ) , format! ( " open: {} " , m ) ) ;
}
}
Ok ( " ok " . to_string ( ) )
}
// Poll the result of pick_file_open: "" while the picker is open, otherwise the
// copied file's path, or "CANCELLED" / "ERROR:...".
#[ tauri::command ]
fn get_picked_file ( app : tauri ::AppHandle ) -> Result < String , String > {
let app_root = native_root ( & app ) ;
let p = app_root . join ( " open_file_result.txt " ) ;
if p . exists ( ) {
Ok ( std ::fs ::read_to_string ( & p ) . unwrap_or_default ( ) . trim ( ) . to_string ( ) )
} else {
Ok ( String ::new ( ) )
}
}
// List all native GPS point files (gps_points_*.jsonl) in the app root.
// Used by on-device track recovery: the foreground service writes every point
// here regardless of screen state, so these files can rebuild a session's DB
// rows if the JS import ever falls behind.
#[ tauri::command ]
fn list_points_files ( app : tauri ::AppHandle ) -> Result < String , String > {
let app_root = native_root ( & app ) ;
let mut names : Vec < String > = Vec ::new ( ) ;
if let Ok ( entries ) = std ::fs ::read_dir ( & app_root ) {
for e in entries . flatten ( ) {
if let Some ( n ) = e . file_name ( ) . to_str ( ) {
if n . starts_with ( " gps_points_ " ) & & n . ends_with ( " .jsonl " ) {
names . push ( n . to_string ( ) ) ;
}
}
}
}
names . sort ( ) ;
Ok ( serde_json ::to_string ( & names ) . unwrap_or_else ( | _ | " [] " . to_string ( ) ) )
}
// Read a specific native points file by name (validated, no path traversal).
#[ tauri::command ]
fn read_points_file ( app : tauri ::AppHandle , filename : String ) -> Result < String , String > {
if ! ( filename . starts_with ( " gps_points_ " ) & & filename . ends_with ( " .jsonl " ) ) | | filename . contains ( '/' ) {
return Err ( " bad filename " . to_string ( ) ) ;
}
let app_root = native_root ( & app ) ;
let path = app_root . join ( & filename ) ;
if path . exists ( ) {
std ::fs ::read_to_string ( & path ) . map_err ( | e | e . to_string ( ) )
} else {
Ok ( String ::new ( ) )
}
}
2026-10-17 02:07:44 +03:00
// Read a small app state file (allowlisted) written by Kotlin, e.g. the location on/off state.
#[ tauri::command ]
fn read_state_file ( app : tauri ::AppHandle , name : String ) -> Result < String , String > {
if name ! = " location_state.json " { return Err ( " bad name " . to_string ( ) ) ; }
let p = native_root ( & app ) . join ( & name ) ;
if p . exists ( ) { std ::fs ::read_to_string ( & p ) . map_err ( | e | e . to_string ( ) ) } else { Ok ( String ::new ( ) ) }
}
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
// Download binary file from URL to local path (for tile DB import from router)
// read_to_vec() has a 10MB limit, so use as_reader() for unlimited binary reads
#[ tauri::command ]
fn download_file ( url : String , dest : String ) -> Result < String , String > {
use std ::io ::Read ;
let resp = ureq ::get ( & url ) . call ( ) . map_err ( | e | format! ( " fetch: {e} " ) ) ? ;
let mut reader = resp . into_body ( ) . into_reader ( ) ;
let mut data = Vec ::new ( ) ;
reader . read_to_end ( & mut data ) . map_err ( | e | format! ( " read: {e} " ) ) ? ;
std ::fs ::write ( & dest , & data ) . map_err ( | e | format! ( " write: {e} " ) ) ? ;
Ok ( format! ( " {} " , data . len ( ) ) )
}
// Rebuild road_points from all GPS tracks with 2m deduplication.
// Runs entirely in Rust/SQLite — no Tauri SQL plugin row limits.
#[ tauri::command ]
fn rebuild_roads ( app : tauri ::AppHandle , min_speed_ms : f64 ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let db_path = {
let p1 = data_dir . join ( " ../../baldav.db " ) ;
let p2 = data_dir . join ( " baldav.db " ) ;
let p3 = std ::path ::PathBuf ::from (
data_dir . to_str ( ) . unwrap_or ( " " ) . replace ( " /files/dev.baldav.app " , " " )
) . join ( " baldav.db " ) ;
if p1 . exists ( ) { p1 } else if p2 . exists ( ) { p2 } else { p3 }
} ;
let db = rusqlite ::Connection ::open ( & db_path ) . map_err ( | e | format! ( " open: {e} " ) ) ? ;
db . execute ( " DROP TABLE IF EXISTS road_points " , [ ] ) . ok ( ) ;
2026-10-22 00:49:18 +03:00
// `passes` = how many distinct times this ~2 m stretch was ridden (used for line thickness).
db . execute ( " CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT, passes INTEGER NOT NULL DEFAULT 1) " , [ ] )
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
. map_err ( | e | format! ( " create: {e} " ) ) ? ;
db . execute ( " CREATE INDEX idx_road_lat_lon ON road_points(lat, lon) " , [ ] )
. map_err ( | e | format! ( " index: {e} " ) ) ? ;
2026-10-22 00:49:18 +03:00
// ~2 m cell in degrees at 47N: lat ~0.000018, lon ~0.000027.
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
let d_lat : f64 = 0.000018 ;
let d_lon : f64 = 0.000027 ;
2026-09-14 12:14:04 +03:00
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
2026-10-22 00:49:18 +03:00
// activity — so pushed-across-a-field bits don't become a rideable road. session_id is
// read too, so a cell re-entered in a later session counts as another pass.
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
let mut read_stmt = db . prepare (
2026-10-22 00:49:18 +03:00
" SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END, p.session_id
2026-09-14 12:14:04 +03:00
FROM gps_points p
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
JOIN gps_sessions s ON s . id = p . session_id
WHERE p . speed_ms > = ? 1
ORDER BY p . session_id , p . seq "
) . map_err ( | e | format! ( " prepare read: {e} " ) ) ? ;
2026-10-22 00:49:18 +03:00
let points : Vec < ( f64 , f64 , String , i64 ) > = read_stmt . query_map ( rusqlite ::params! [ min_speed_ms ] , | row | {
Ok ( ( row . get ::< _ , f64 > ( 0 ) ? , row . get ::< _ , f64 > ( 1 ) ? , row . get ::< _ , String > ( 2 ) . unwrap_or_default ( ) , row . get ::< _ , i64 > ( 3 ) . unwrap_or ( - 1 ) ) )
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
} ) . map_err ( | e | format! ( " query: {e} " ) ) ?
. filter_map ( | r | r . ok ( ) )
. collect ( ) ;
drop ( read_stmt ) ;
2026-09-29 23:19:37 +03:00
let total = points . len ( ) ;
let _ = app . emit ( " roads_progress " , ( 0 u64 , total as u64 ) ) ; // start
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
2026-10-22 00:49:18 +03:00
// Glue + count passes in memory: one cell per ~2 m grid square, first-seen geometry kept,
// `passes` incremented once per ENTRY into the cell (not per point, so a slow crawl that
// drops many points in one cell still counts as a single pass). Leaving and re-entering
// the cell — or a new session — is a fresh entry. Much faster than a per-point SQL EXISTS.
use std ::collections ::HashMap ;
let mut index : HashMap < ( i64 , i64 ) , usize > = HashMap ::new ( ) ;
let mut cells : Vec < ( f64 , f64 , String , u32 ) > = Vec ::new ( ) ; // lat, lon, atype, passes
let mut prev_key : Option < ( i64 , i64 ) > = None ;
let mut prev_session : i64 = i64 ::MIN ;
for ( i , ( lat , lon , atype , session ) ) in points . iter ( ) . enumerate ( ) {
if i % 20000 = = 0 { let _ = app . emit ( " roads_progress " , ( i as u64 , total as u64 ) ) ; }
let key = ( ( lat / d_lat ) . floor ( ) as i64 , ( lon / d_lon ) . floor ( ) as i64 ) ;
let entry = prev_key ! = Some ( key ) | | * session ! = prev_session ;
match index . get ( & key ) {
Some ( & idx ) = > { if entry { cells [ idx ] . 3 + = 1 ; } }
None = > { index . insert ( key , cells . len ( ) ) ; cells . push ( ( * lat , * lon , atype . clone ( ) , 1 ) ) ; }
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
}
2026-10-22 00:49:18 +03:00
prev_key = Some ( key ) ;
prev_session = * session ;
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
}
2026-10-22 00:49:18 +03:00
db . execute ( " BEGIN " , [ ] ) . map_err ( | e | format! ( " begin: {e} " ) ) ? ;
{
let mut ins = db . prepare ( " INSERT INTO road_points (lat, lon, atype, passes) VALUES (?1, ?2, ?3, ?4) " )
. map_err ( | e | format! ( " prepare insert: {e} " ) ) ? ;
for ( lat , lon , atype , passes ) in cells . iter ( ) {
ins . execute ( rusqlite ::params! [ lat , lon , atype , passes ] )
. map_err ( | e | format! ( " insert: {e} " ) ) ? ;
}
}
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
db . execute ( " COMMIT " , [ ] ) . map_err ( | e | format! ( " commit: {e} " ) ) ? ;
2026-10-22 00:49:18 +03:00
let inserted = cells . len ( ) ;
2026-09-29 23:19:37 +03:00
let _ = app . emit ( " roads_progress " , ( total as u64 , total as u64 ) ) ; // done
2026-10-22 00:49:18 +03:00
Ok ( format! ( " {{ \" total_points \" : {} , \" road_points \" : {} }} " , total , inserted ) )
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
}
// Read road_points in batches — bypasses Tauri SQL plugin's ~28K row limit
#[ tauri::command ]
fn get_road_points ( app : tauri ::AppHandle , offset : u32 , batch_size : u32 ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
// Find main DB — same path resolution as import_seed_products
let db_path = {
let p1 = data_dir . join ( " ../../baldav.db " ) ;
let p2 = data_dir . join ( " baldav.db " ) ;
let p3 = std ::path ::PathBuf ::from (
data_dir . to_str ( ) . unwrap_or ( " " ) . replace ( " /files/dev.baldav.app " , " " )
) . join ( " baldav.db " ) ;
if p1 . exists ( ) { p1 } else if p2 . exists ( ) { p2 } else { p3 }
} ;
let db = rusqlite ::Connection ::open ( & db_path ) . map_err ( | e | format! ( " open: {e} " ) ) ? ;
let total : u32 = db . query_row (
" SELECT count(*) FROM road_points " , [ ] , | r | r . get ( 0 )
) . unwrap_or ( 0 ) ;
2026-10-22 00:49:18 +03:00
// `passes` column exists only after a rebuild on this build; tolerate older DBs (passes=1).
let has_passes : bool = db . query_row (
" SELECT COUNT(*) FROM pragma_table_info('road_points') WHERE name='passes' " , [ ] , | r | r . get ::< _ , i64 > ( 0 )
) . map ( | n | n > 0 ) . unwrap_or ( false ) ;
let sql = if has_passes {
" SELECT lat, lon, COALESCE(atype,'bike'), COALESCE(passes,1) FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2 "
} else {
" SELECT lat, lon, COALESCE(atype,'bike'), 1 FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2 "
} ;
let mut stmt = db . prepare ( sql ) . map_err ( | e | format! ( " prepare: {e} " ) ) ? ;
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
let mut points = Vec ::new ( ) ;
let rows = stmt . query_map ( rusqlite ::params! [ batch_size , offset ] , | row | {
let lat : f64 = row . get ( 0 ) ? ;
let lon : f64 = row . get ( 1 ) ? ;
let atype : String = row . get ( 2 ) ? ;
2026-10-22 00:49:18 +03:00
let passes : i64 = row . get ( 3 ) ? ;
Ok ( ( lat , lon , atype , passes ) )
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
} ) . map_err ( | e | format! ( " query: {e} " ) ) ? ;
for row in rows {
2026-10-22 00:49:18 +03:00
let ( lat , lon , atype , passes ) = row . map_err ( | e | format! ( " row: {e} " ) ) ? ;
2026-07-31 22:11:55 +03:00
let t = if atype = = " walk " { " w " } else if atype = = " run " { " r " } else { " b " } ;
2026-10-22 00:49:18 +03:00
points . push ( format! ( " [ {:.7} , {:.7} , \" {} \" , {} ] " , lat , lon , t , passes ) ) ;
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
}
let has_more = offset + batch_size < total ;
Ok ( format! ( " {{ \" points \" :[ {} ], \" total \" : {} , \" has_more \" : {} }} " , points . join ( " , " ) , total , has_more ) )
}
2026-09-14 12:35:39 +03:00
// Build a routable graph from the user's RIDDEN tracks (excludes walk=1 points), so the planner
// can prefer real roads before public OSM data. Points are snapped to a ~20 m grid (nodes);
// consecutive points of the same session make an edge (weight = metres). Returns compact JSON
// { "nodes": [[lat,lon],...], "edges": [[a,b,dist],...] } (node index = position in nodes).
#[ tauri::command ]
fn get_ride_graph ( app : tauri ::AppHandle , min_speed_ms : f64 ) -> Result < String , String > {
use std ::collections ::HashMap ;
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let db_path = {
let p1 = data_dir . join ( " ../../baldav.db " ) ;
let p2 = data_dir . join ( " baldav.db " ) ;
let p3 = std ::path ::PathBuf ::from ( data_dir . to_str ( ) . unwrap_or ( " " ) . replace ( " /files/dev.baldav.app " , " " ) ) . join ( " baldav.db " ) ;
if p1 . exists ( ) { p1 } else if p2 . exists ( ) { p2 } else { p3 }
} ;
let db = rusqlite ::Connection ::open ( & db_path ) . map_err ( | e | format! ( " open: {e} " ) ) ? ;
fn hav ( la1 : f64 , lo1 : f64 , la2 : f64 , lo2 : f64 ) -> f64 {
let r = 6371000.0_ f64 ;
let dla = ( la2 - la1 ) . to_radians ( ) ;
let dlo = ( lo2 - lo1 ) . to_radians ( ) ;
let a = ( dla / 2.0 ) . sin ( ) . powi ( 2 ) + la1 . to_radians ( ) . cos ( ) * la2 . to_radians ( ) . cos ( ) * ( dlo / 2.0 ) . sin ( ) . powi ( 2 ) ;
r * 2.0 * a . sqrt ( ) . asin ( )
}
let d_lat = 0.00018_ f64 ; // ~20 m
let d_lon = 0.00027_ f64 ;
let mut stmt = db . prepare (
" SELECT lat, lon, session_id, seq FROM gps_points
WHERE COALESCE ( walk , 0 ) = 0 AND speed_ms > = ? 1
ORDER BY session_id , COALESCE ( seq , 999999 ) , ts "
) . map_err ( | e | format! ( " prepare: {e} " ) ) ? ;
let rows = stmt . query_map ( rusqlite ::params! [ min_speed_ms ] , | r | {
Ok ( ( r . get ::< _ , f64 > ( 0 ) ? , r . get ::< _ , f64 > ( 1 ) ? , r . get ::< _ , i64 > ( 2 ) ? ) )
} ) . map_err ( | e | format! ( " query: {e} " ) ) ? ;
let mut node_idx : HashMap < ( i64 , i64 ) , usize > = HashMap ::new ( ) ;
let mut nodes : Vec < ( f64 , f64 ) > = Vec ::new ( ) ;
let mut edges : HashMap < ( usize , usize ) , f64 > = HashMap ::new ( ) ;
let mut prev : Option < ( i64 , usize , f64 , f64 ) > = None ; // (session_id, node, lat, lon)
for row in rows {
let ( lat , lon , sid ) = row . map_err ( | e | format! ( " row: {e} " ) ) ? ;
let c = ( ( lat / d_lat ) . round ( ) as i64 , ( lon / d_lon ) . round ( ) as i64 ) ;
let ni = match node_idx . get ( & c ) {
Some ( & i ) = > i ,
None = > { let i = nodes . len ( ) ; nodes . push ( ( lat , lon ) ) ; node_idx . insert ( c , i ) ; i }
} ;
if let Some ( ( psid , pn , _plat , _plon ) ) = prev {
if psid = = sid & & pn ! = ni {
// Edge weight = distance between the two NODES (not the consecutive points),
// else intra-cell travel is lost and the path is badly under-weighted.
let ( nla , nlo ) = nodes [ pn ] ;
let d = hav ( nla , nlo , lat , lon ) ;
if d < = 200.0 { // skip absurd jumps (GPS gap) between consecutive points
let key = if pn < ni { ( pn , ni ) } else { ( ni , pn ) } ;
let e = edges . entry ( key ) . or_insert ( f64 ::INFINITY ) ;
if d < * e { * e = d ; }
}
}
}
prev = Some ( ( sid , ni , lat , lon ) ) ;
}
let nodes_json = nodes . iter ( ) . map ( | ( la , lo ) | format! ( " [ {:.6} , {:.6} ] " , la , lo ) ) . collect ::< Vec < _ > > ( ) . join ( " , " ) ;
let edges_json = edges . iter ( ) . map ( | ( ( a , b ) , d ) | format! ( " [ {} , {} , {:.1} ] " , a , b , d ) ) . collect ::< Vec < _ > > ( ) . join ( " , " ) ;
Ok ( format! ( " {{ \" nodes \" :[ {} ], \" edges \" :[ {} ] }} " , nodes_json , edges_json ) )
}
2026-10-26 21:08:22 +03:00
// Elevation correction grid from ridden tracks: per ~20m cell, the MEDIAN vertical
// offset (measured altitude_gps_m minus terrain altitude_srtm_m) over all passes.
// SRTM stays the base; this is only the up/down nudge where you've actually ridden.
// Returns [[cellLat, cellLon, medianDelta, passes], ...] for cells ridden on at least
// `min_passes` distinct sessions. The JS side adds a smoothed delta onto SRTM elevations.
#[ tauri::command ]
fn get_elev_correction ( app : tauri ::AppHandle , min_passes : u32 ) -> Result < String , String > {
use std ::collections ::{ HashMap , HashSet } ;
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let db_path = {
let p1 = data_dir . join ( " ../../baldav.db " ) ;
let p2 = data_dir . join ( " baldav.db " ) ;
let p3 = std ::path ::PathBuf ::from ( data_dir . to_str ( ) . unwrap_or ( " " ) . replace ( " /files/dev.baldav.app " , " " ) ) . join ( " baldav.db " ) ;
if p1 . exists ( ) { p1 } else if p2 . exists ( ) { p2 } else { p3 }
} ;
let db = rusqlite ::Connection ::open ( & db_path ) . map_err ( | e | format! ( " open: {e} " ) ) ? ;
let d_lat = 0.00018_ f64 ; // ~20 m (same grid as the ride graph)
let d_lon = 0.00027_ f64 ;
let mut stmt = db . prepare (
" SELECT lat, lon, altitude_gps_m, altitude_srtm_m, session_id FROM gps_points
WHERE altitude_gps_m IS NOT NULL AND altitude_srtm_m IS NOT NULL AND COALESCE ( walk , 0 ) = 0 "
) . map_err ( | e | format! ( " prepare: {e} " ) ) ? ;
let rows = stmt . query_map ( [ ] , | r | {
Ok ( ( r . get ::< _ , f64 > ( 0 ) ? , r . get ::< _ , f64 > ( 1 ) ? , r . get ::< _ , f64 > ( 2 ) ? , r . get ::< _ , f64 > ( 3 ) ? , r . get ::< _ , i64 > ( 4 ) ? ) )
} ) . map_err ( | e | format! ( " query: {e} " ) ) ? ;
let mut cells : HashMap < ( i64 , i64 ) , ( Vec < f64 > , HashSet < i64 > ) > = HashMap ::new ( ) ;
for row in rows {
let ( lat , lon , gps , srtm , sid ) = row . map_err ( | e | format! ( " row: {e} " ) ) ? ;
let delta = gps - srtm ;
if ! delta . is_finite ( ) | | delta . abs ( ) > 300.0 { continue ; } // guard against garbage fixes
let c = ( ( lat / d_lat ) . round ( ) as i64 , ( lon / d_lon ) . round ( ) as i64 ) ;
let e = cells . entry ( c ) . or_insert_with ( | | ( Vec ::new ( ) , HashSet ::new ( ) ) ) ;
e . 0. push ( delta ) ;
e . 1. insert ( sid ) ;
}
let mut out : Vec < String > = Vec ::new ( ) ;
for ( ( clat , clon ) , ( mut deltas , sessions ) ) in cells {
if ( sessions . len ( ) as u32 ) < min_passes { continue ; }
deltas . sort_by ( | a , b | a . partial_cmp ( b ) . unwrap_or ( std ::cmp ::Ordering ::Equal ) ) ;
let m = deltas . len ( ) ;
let median = if m % 2 = = 1 { deltas [ m / 2 ] } else { ( deltas [ m / 2 - 1 ] + deltas [ m / 2 ] ) / 2.0 } ;
let lat = ( clat as f64 ) * d_lat ;
let lon = ( clon as f64 ) * d_lon ;
out . push ( format! ( " [ {:.6} , {:.6} , {:.2} , {} ] " , lat , lon , median , sessions . len ( ) ) ) ;
}
Ok ( format! ( " [ {} ] " , out . join ( " , " ) ) )
}
2026-05-14 17:35:29 +03:00
#[ derive(Serialize) ]
struct AppInfo {
version : String ,
git_hash : String ,
build_date : String ,
2026-08-16 17:15:42 +03:00
debug : bool ,
2026-05-14 17:35:29 +03:00
}
#[ tauri::command ]
fn get_app_info ( ) -> AppInfo {
AppInfo {
version : env ! ( " CARGO_PKG_VERSION " ) . to_string ( ) ,
git_hash : env ! ( " GIT_HASH " ) . to_string ( ) ,
build_date : env ! ( " BUILD_DATE " ) . to_string ( ) ,
2026-08-16 17:15:42 +03:00
// Compiled-in build profile: on for `tauri dev` / `--debug`, off for release.
// The JS side uses this to silence diagnostic logs in release builds.
debug : cfg ! ( debug_assertions ) ,
2026-05-14 17:35:29 +03:00
}
}
OTA updates, BLE HR, water tracking, GPS altitude filter, BMR fix, nutrient units
OTA: VPS nginx endpoint, build.sh -u upload flag, Kotlin check + browser download
BLE HR: FitPro protocol (PAIR/CCCD/HR_SWITCH/REALTIME/MEASURE), GATT queue,
ACK responses, auto-detect device type, auto-start with GPS, re-trigger 25s
Kotlin BleHrService, btleplug for desktop, file IPC for Android
Water: products.water_pct column, getDailyWaterMl(), progress bar on today screen
GPS altitude: outlier spike filter (>4m), stuck run interpolation (5+ identical),
configurable smoothing (altSmoothHalf setting), filtered raw for kcal calc
BMR: removed broken estimateRealBmr, added activity level dropdown (1.2-1.725),
fixed 7-day trend using dailyBase instead of raw bmr
Nutrients: ginseng/resveratrol/coq10 columns, fixed folate/selenium units (removed
mult:1000), iodine/chromium input in mcg, addSupplement now inserts all 41 columns
UI: parseAmount() for 4*30 expressions, recipe portion amount field, nutrient
reserves strip, GPS sessions in today view, pan on charts, showToast(),
HR norms in settings, HR on recording screen, HR chart in track details
Scrapers: 5ka Load JSON button, magnit_scraper.user.js (NUXT_DATA parsing)
2026-06-07 01:36:38 +03:00
// Read update check result written by Kotlin (update_info.json)
#[ tauri::command ]
fn get_update_info ( app : tauri ::AppHandle ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let paths = [
data_dir . join ( " update_info.json " ) ,
data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | r | r . join ( " update_info.json " ) ) . unwrap_or_default ( ) ,
] ;
for p in & paths {
if p . exists ( ) {
if let Ok ( s ) = std ::fs ::read_to_string ( p ) {
let s = s . trim ( ) . to_string ( ) ;
if ! s . is_empty ( ) { return Ok ( s ) ; }
}
}
}
Ok ( " {} " . to_string ( ) )
}
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// GPS service control — writes command file, Kotlin reads and starts/stops service
#[ tauri::command ]
fn gps_service_command ( app : tauri ::AppHandle , cmd : String ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
std ::fs ::create_dir_all ( & data_dir ) . ok ( ) ;
2026-05-16 10:49:55 +03:00
// Write to app_data_dir
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
let cmd_path = data_dir . join ( " gps_command.txt " ) ;
std ::fs ::write ( & cmd_path , & cmd ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-05-16 10:49:55 +03:00
// Also write to app root dir (Android: Kotlin watches there)
if let Some ( root ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) {
let alt = root . join ( " gps_command.txt " ) ;
let _ = std ::fs ::write ( & alt , & cmd ) ;
}
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
Ok ( format! ( " sent: {} " , cmd ) )
}
2026-10-24 01:13:43 +03:00
// Write hydration.json to the app root, where the Hydration singleton reads it: the level-based
// drink reminder (accrue sweat during a session, beep when the deficit exceeds alert_ml).
2026-10-24 01:29:30 +03:00
// start_deficit_ml: how far behind on water you were at session start (from logged intake vs
// need-so-far), computed in JS and seeded into the native deficit. From the settings screen it is 0.
2026-10-24 01:13:43 +03:00
#[ tauri::command ]
2026-10-24 01:29:30 +03:00
fn set_hydration_config ( app : tauri ::AppHandle , enabled : bool , alert_ml : u32 , sweat_ml_per_hour : u32 , re_alert_min : u32 , start_deficit_ml : u32 ) -> Result < String , String > {
2026-10-24 01:13:43 +03:00
let json = format! (
2026-10-24 01:29:30 +03:00
" {{ \" enabled \" :{}, \" alertMl \" :{}, \" sweatMlPerHour \" :{}, \" reAlertMin \" :{}, \" startDeficitMl \" :{}}} " ,
enabled , alert_ml . clamp ( 100 , 3000 ) , sweat_ml_per_hour . clamp ( 60 , 2000 ) , re_alert_min . clamp ( 2 , 120 ) , start_deficit_ml . clamp ( 0 , 5000 )
2026-10-24 01:13:43 +03:00
) ;
write_app_root_file ( & app , " hydration.json " , & json ) ;
Ok ( json )
}
2026-10-26 16:12:11 +03:00
// Write live_share.json to the app root, where the LiveShare singleton reads it. Opt-in live
// position sharing: when enabled, the GPS service POSTs the current fix to `url` (botovod's
// /api/live/push) every ~10s, authenticated by `token`. url/token are kept only on the device
// (this runtime file) and never committed. From the UI, disabling just flips enabled=false.
#[ tauri::command ]
fn set_live_config ( app : tauri ::AppHandle , enabled : bool , url : String , token : String ) -> Result < String , String > {
let json = serde_json ::json! ( {
" enabled " : enabled ,
" url " : url ,
" token " : token ,
} ) . to_string ( ) ;
write_app_root_file ( & app , " live_share.json " , & json ) ;
Ok ( json )
}
// SMS "я тут": write the request (number + prefilled text with a Yandex Maps link) where the
// Kotlin side reads it, then trigger the command. MainActivity sends it directly if SEND_SMS was
// granted, otherwise opens the system SMS composer prefilled. Works over 2G/EDGE, no internet.
#[ tauri::command ]
fn send_sms ( app : tauri ::AppHandle , number : String , text : String ) -> Result < String , String > {
let json = serde_json ::json! ( { " number " : number , " text " : text } ) . to_string ( ) ;
write_app_root_file ( & app , " sms_request.json " , & json ) ;
gps_service_command ( app , " send_sms " . to_string ( ) )
}
// Launch the system contact picker (an Activity); the chosen number lands in contact_result.json,
// which the UI reads via get_contact_result.
#[ tauri::command ]
fn pick_contact ( app : tauri ::AppHandle ) -> Result < String , String > {
gps_service_command ( app , " pick_contact " . to_string ( ) )
}
// Read and clear the contact picker result (JSON {number,name}); empty string if not ready yet.
#[ tauri::command ]
fn get_contact_result ( app : tauri ::AppHandle ) -> Result < String , String > {
let mut candidates : Vec < std ::path ::PathBuf > = vec! [ native_root ( & app ) . join ( " contact_result.json " ) ] ;
if let Ok ( data_dir ) = app . path ( ) . app_data_dir ( ) {
if let Some ( root ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) {
candidates . push ( root . join ( " contact_result.json " ) ) ;
}
}
for p in candidates {
if p . exists ( ) {
let content = std ::fs ::read_to_string ( & p ) . unwrap_or_default ( ) ;
let _ = std ::fs ::remove_file ( & p ) ;
if ! content . trim ( ) . is_empty ( ) {
return Ok ( content ) ;
}
}
}
Ok ( String ::new ( ) )
}
2026-10-24 01:13:43 +03:00
// Write a small file to the app root — both where native_root resolves and the explicit
// app_data_dir/../.. (= Kotlin filesDir.parentFile) — so the Kotlin side always finds it.
fn write_app_root_file ( app : & tauri ::AppHandle , name : & str , contents : & str ) {
let _ = std ::fs ::write ( native_root ( app ) . join ( name ) , contents ) ;
if let Ok ( data_dir ) = app . path ( ) . app_data_dir ( ) {
if let Some ( root ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) {
let _ = std ::fs ::write ( root . join ( name ) , contents ) ;
}
}
}
BRouter: analysis + embed the offline routing engine (Stage 1)
Analysis in docs/brouter-findings.md: how BRouter knows roads (.rd5
segments), routes and reaches low elevation gain (profile uphill/downhill
cost + kinematics), the three call surfaces, and an evaluation of two ideas
— fill GPS gaps along roads, and offline waypoint route planning. License is
MIT; the engine has no desktop-only deps, so it embeds on Android ART.
Stage 1 (embed + bridge, compiles into the apk; on-device route not yet run):
- vendored brouter-1.6.3-all.jar into the Android module (dexes cleanly) and
bundled the trekking/shortest profiles + lookups.dat as assets, copied to
files/brouter/profiles2 on launch
- MainActivity: a brouter_command.txt handler on the existing FileObserver
runs btools.router.RoutingEngine off the UI thread (recipe taken via javap
from ServerHandler) and returns the track as GPX
- Rust brouter_route command: same file bridge as GPS (request + command,
poll for the result), registered in the invoke handler
- frontend/js/router.js: BRouter.route(points, profile) -> GPX (parseRouteGPX)
- LICENSE-brouter (MIT notice) kept alongside the vendored engine
Segments (.rd5, ~13-23 MB per 5x5 tile) are delivered later (Stage 2, wired
download inside BalDav). IMPLEMENTATION_PLAN.md tracks the 5 stages.
2026-09-09 00:56:13 +03:00
// Offline routing via the embedded BRouter engine (Kotlin runs btools.router). Same
// file bridge as GPS: write the request + a "route:<id>" command where MainActivity's
// FileObserver watches, then poll for brouter_result_<id>.json. `lonlats` is a JSON
// array of [lon,lat] pairs; returns the Kotlin result JSON ({ok,gpx}|{ok:false,error}).
#[ tauri::command ]
2026-09-12 22:55:51 +03:00
fn brouter_route ( app : tauri ::AppHandle , lonlats : String , profile : String , nogos : Option < String > ) -> Result < String , String > {
BRouter: analysis + embed the offline routing engine (Stage 1)
Analysis in docs/brouter-findings.md: how BRouter knows roads (.rd5
segments), routes and reaches low elevation gain (profile uphill/downhill
cost + kinematics), the three call surfaces, and an evaluation of two ideas
— fill GPS gaps along roads, and offline waypoint route planning. License is
MIT; the engine has no desktop-only deps, so it embeds on Android ART.
Stage 1 (embed + bridge, compiles into the apk; on-device route not yet run):
- vendored brouter-1.6.3-all.jar into the Android module (dexes cleanly) and
bundled the trekking/shortest profiles + lookups.dat as assets, copied to
files/brouter/profiles2 on launch
- MainActivity: a brouter_command.txt handler on the existing FileObserver
runs btools.router.RoutingEngine off the UI thread (recipe taken via javap
from ServerHandler) and returns the track as GPX
- Rust brouter_route command: same file bridge as GPS (request + command,
poll for the result), registered in the invoke handler
- frontend/js/router.js: BRouter.route(points, profile) -> GPX (parseRouteGPX)
- LICENSE-brouter (MIT notice) kept alongside the vendored engine
Segments (.rd5, ~13-23 MB per 5x5 tile) are delivered later (Stage 2, wired
download inside BalDav). IMPLEMENTATION_PLAN.md tracks the 5 stages.
2026-09-09 00:56:13 +03:00
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
std ::fs ::create_dir_all ( & data_dir ) . ok ( ) ;
let id = std ::time ::SystemTime ::now ( )
. duration_since ( std ::time ::UNIX_EPOCH ) . map_err ( | e | e . to_string ( ) ) ?
. as_millis ( ) . to_string ( ) ;
2026-09-12 22:55:51 +03:00
// nogos is a JSON array string [{lat,lon,radius}] from JS; default to empty array.
let nogos_json = nogos . filter ( | s | ! s . trim ( ) . is_empty ( ) ) . unwrap_or_else ( | | " [] " . to_string ( ) ) ;
BRouter: analysis + embed the offline routing engine (Stage 1)
Analysis in docs/brouter-findings.md: how BRouter knows roads (.rd5
segments), routes and reaches low elevation gain (profile uphill/downhill
cost + kinematics), the three call surfaces, and an evaluation of two ideas
— fill GPS gaps along roads, and offline waypoint route planning. License is
MIT; the engine has no desktop-only deps, so it embeds on Android ART.
Stage 1 (embed + bridge, compiles into the apk; on-device route not yet run):
- vendored brouter-1.6.3-all.jar into the Android module (dexes cleanly) and
bundled the trekking/shortest profiles + lookups.dat as assets, copied to
files/brouter/profiles2 on launch
- MainActivity: a brouter_command.txt handler on the existing FileObserver
runs btools.router.RoutingEngine off the UI thread (recipe taken via javap
from ServerHandler) and returns the track as GPX
- Rust brouter_route command: same file bridge as GPS (request + command,
poll for the result), registered in the invoke handler
- frontend/js/router.js: BRouter.route(points, profile) -> GPX (parseRouteGPX)
- LICENSE-brouter (MIT notice) kept alongside the vendored engine
Segments (.rd5, ~13-23 MB per 5x5 tile) are delivered later (Stage 2, wired
download inside BalDav). IMPLEMENTATION_PLAN.md tracks the 5 stages.
2026-09-09 00:56:13 +03:00
let req = format! (
2026-09-12 22:55:51 +03:00
" {{ \" profile \" :{}, \" lonlats \" :{}, \" nogos \" :{}}} " ,
BRouter: analysis + embed the offline routing engine (Stage 1)
Analysis in docs/brouter-findings.md: how BRouter knows roads (.rd5
segments), routes and reaches low elevation gain (profile uphill/downhill
cost + kinematics), the three call surfaces, and an evaluation of two ideas
— fill GPS gaps along roads, and offline waypoint route planning. License is
MIT; the engine has no desktop-only deps, so it embeds on Android ART.
Stage 1 (embed + bridge, compiles into the apk; on-device route not yet run):
- vendored brouter-1.6.3-all.jar into the Android module (dexes cleanly) and
bundled the trekking/shortest profiles + lookups.dat as assets, copied to
files/brouter/profiles2 on launch
- MainActivity: a brouter_command.txt handler on the existing FileObserver
runs btools.router.RoutingEngine off the UI thread (recipe taken via javap
from ServerHandler) and returns the track as GPX
- Rust brouter_route command: same file bridge as GPS (request + command,
poll for the result), registered in the invoke handler
- frontend/js/router.js: BRouter.route(points, profile) -> GPX (parseRouteGPX)
- LICENSE-brouter (MIT notice) kept alongside the vendored engine
Segments (.rd5, ~13-23 MB per 5x5 tile) are delivered later (Stage 2, wired
download inside BalDav). IMPLEMENTATION_PLAN.md tracks the 5 stages.
2026-09-09 00:56:13 +03:00
serde_json ::to_string ( & profile ) . map_err ( | e | e . to_string ( ) ) ? ,
2026-09-12 22:55:51 +03:00
lonlats ,
nogos_json
BRouter: analysis + embed the offline routing engine (Stage 1)
Analysis in docs/brouter-findings.md: how BRouter knows roads (.rd5
segments), routes and reaches low elevation gain (profile uphill/downhill
cost + kinematics), the three call surfaces, and an evaluation of two ideas
— fill GPS gaps along roads, and offline waypoint route planning. License is
MIT; the engine has no desktop-only deps, so it embeds on Android ART.
Stage 1 (embed + bridge, compiles into the apk; on-device route not yet run):
- vendored brouter-1.6.3-all.jar into the Android module (dexes cleanly) and
bundled the trekking/shortest profiles + lookups.dat as assets, copied to
files/brouter/profiles2 on launch
- MainActivity: a brouter_command.txt handler on the existing FileObserver
runs btools.router.RoutingEngine off the UI thread (recipe taken via javap
from ServerHandler) and returns the track as GPX
- Rust brouter_route command: same file bridge as GPS (request + command,
poll for the result), registered in the invoke handler
- frontend/js/router.js: BRouter.route(points, profile) -> GPX (parseRouteGPX)
- LICENSE-brouter (MIT notice) kept alongside the vendored engine
Segments (.rd5, ~13-23 MB per 5x5 tile) are delivered later (Stage 2, wired
download inside BalDav). IMPLEMENTATION_PLAN.md tracks the 5 stages.
2026-09-09 00:56:13 +03:00
) ;
let result_name = format! ( " brouter_result_ {} .json " , id ) ;
// Write request + command to app_data_dir AND app root (where Kotlin watches).
let root = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | p | p . to_path_buf ( ) ) ;
let write_both = | name : & str , content : & str | {
let _ = std ::fs ::write ( data_dir . join ( name ) , content ) ;
if let Some ( r ) = & root { let _ = std ::fs ::write ( r . join ( name ) , content ) ; }
} ;
write_both ( " brouter_request.json " , & req ) ;
write_both ( " brouter_command.txt " , & format! ( " route: {} " , id ) ) ;
// Poll for the result (routing can take a few seconds; cap ~60s).
let mut candidates = vec! [ data_dir . join ( & result_name ) ] ;
if let Some ( r ) = & root { candidates . push ( r . join ( & result_name ) ) ; }
for _ in 0 .. 600 {
for p in & candidates {
if p . exists ( ) {
if let Ok ( s ) = std ::fs ::read_to_string ( p ) {
let _ = std ::fs ::remove_file ( p ) ;
return Ok ( s ) ;
}
}
}
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 100 ) ) ;
}
Err ( " brouter: timeout waiting for result " . into ( ) )
}
2026-09-09 01:11:20 +03:00
// Segment delivery (Stage 2). Kotlin owns the download so it lands in the exact dir the
// engine reads (files/brouter/segments4). These commands just relay via the file bridge.
fn brouter_write_cmd ( app : & tauri ::AppHandle , cmd : & str ) -> Result < ( ) , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
std ::fs ::create_dir_all ( & data_dir ) . ok ( ) ;
let _ = std ::fs ::write ( data_dir . join ( " brouter_command.txt " ) , cmd ) ;
if let Some ( r ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) {
let _ = std ::fs ::write ( r . join ( " brouter_command.txt " ) , cmd ) ;
}
Ok ( ( ) )
}
// Start downloading a .rd5 tile (e.g. "E35_N45"). Returns immediately; poll status.
#[ tauri::command ]
fn brouter_download_tile ( app : tauri ::AppHandle , tile : String ) -> Result < String , String > {
brouter_write_cmd ( & app , & format! ( " dltile: {} " , tile ) ) ? ;
Ok ( " started " . into ( ) )
}
// Read the download status Kotlin writes (brouter_dl_<tile>.json): downloading/done/error.
#[ tauri::command ]
fn brouter_download_status ( app : tauri ::AppHandle , tile : String ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let name = format! ( " brouter_dl_ {} .json " , tile ) ;
let mut candidates = vec! [ data_dir . join ( & name ) ] ;
if let Some ( r ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) { candidates . push ( r . join ( & name ) ) ; }
for p in & candidates {
if let Ok ( s ) = std ::fs ::read_to_string ( p ) { if ! s . trim ( ) . is_empty ( ) { return Ok ( s ) ; } }
}
Ok ( " {} " . into ( ) )
}
// List downloaded tiles (asks Kotlin to refresh brouter_tiles.json, then reads it).
#[ tauri::command ]
fn brouter_list_tiles ( app : tauri ::AppHandle ) -> Result < String , String > {
brouter_write_cmd ( & app , " listtiles " ) ? ;
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let mut candidates = vec! [ data_dir . join ( " brouter_tiles.json " ) ] ;
if let Some ( r ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) { candidates . push ( r . join ( " brouter_tiles.json " ) ) ; }
for _ in 0 .. 50 {
for p in & candidates {
if let Ok ( s ) = std ::fs ::read_to_string ( p ) { if ! s . trim ( ) . is_empty ( ) { return Ok ( s ) ; } }
}
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 100 ) ) ;
}
Ok ( " [] " . into ( ) )
}
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
// Read GPS points from service's JSONL file
#[ tauri::command ]
fn gps_service_read_points ( app : tauri ::AppHandle ) -> Result < String , String > {
GPS: safe on-device recovery, net-kcal model, poll/watchPosition dedup
- native_root(): pick app root by presence of gps_live/gps_points files.
app_data_dir() is the root directly on this Tauri; the old .parent().parent()
read the wrong dir, so background (screen-off) points were never imported.
- recoverFromFiles: match file->session by id/date, plus session id stamped
into the native file name (gps_points_<ms>_s<id>.jsonl) for exact binding
(gps_set_session_id -> Kotlin bindSessionIfPending). Proximity dedup +-2.5s,
recalc with noDelete (never runs the destructive glitch/>24h filter that
once wiped real rides), delete already-imported files. Progress bar UI.
- bike kcal is NET (daily balance already books BMR*activityLevel): pedaling =
max(physics power/efficiency, MET-net by speed), coasting (power<=0, the bike
would roll unpowered) = basal only. Elevation via cumulative hysteresis.
recomputeAllStats + "recompute elevation/kcal" menu refresh stored tracks
without touching points.
- live recording: the native file is the DB source of truth; watchPosition
only drives the live marker, so a fix is not stored twice with two timestamps.
- track list pagination, SAF local-file picker, tile regions from router.
2026-07-29 15:35:18 +03:00
let app_root = native_root ( & app ) ;
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// gps_live_points.jsonl now contains the FILENAME of the active points file
let ptr_path = app_root . join ( " gps_live_points.jsonl " ) ;
let pts_file = if ptr_path . exists ( ) {
let content = std ::fs ::read_to_string ( & ptr_path ) . unwrap_or_default ( ) . trim ( ) . to_string ( ) ;
if content . ends_with ( " .jsonl " ) {
// New format: pointer to session-specific file
app_root . join ( & content )
2026-05-16 10:49:55 +03:00
} else {
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// Old format: file IS the points data (backward compat)
return Ok ( content ) ;
2026-05-16 10:49:55 +03:00
}
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
} else {
return Ok ( String ::new ( ) ) ;
} ;
if pts_file . exists ( ) {
std ::fs ::read_to_string ( & pts_file ) . map_err ( | e | e . to_string ( ) )
Seed products DB + Settings screen + build fixes
GPS recording overhaul: inline screen (not modal), floating bar
on other screens, manual pause/resume, crash recovery via
localStorage + JSONL file, _processIncomingPoint extracted.
Settings: profile as button, GPS config, permissions, app info.
Products seed DB: 9299 products bundled, imported on first launch.
Build: version/hash in build.rs, signing, install command.
2026-05-15 14:36:22 +03:00
} else {
Ok ( String ::new ( ) )
}
}
2026-05-16 21:21:13 +03:00
#[ tauri::command ]
fn save_file ( app : tauri ::AppHandle , filename : String , content : String ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
std ::fs ::create_dir_all ( & data_dir ) . ok ( ) ;
// Write content to temp file for Kotlin to read
let content_path = data_dir . join ( " save_content.txt " ) ;
std ::fs ::write ( & content_path , & content ) . map_err ( | e | e . to_string ( ) ) ? ;
// Send save command to Kotlin (triggers SAF file picker on Android)
let cmd_path = data_dir . join ( " gps_command.txt " ) ;
std ::fs ::write ( & cmd_path , format! ( " save: {} " , filename ) ) . map_err ( | e | e . to_string ( ) ) ? ;
// Also write to app root for Kotlin FileObserver
if let Some ( root ) = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) {
let _ = std ::fs ::write ( root . join ( " save_content.txt " ) , & content ) ;
let _ = std ::fs ::write ( root . join ( " gps_command.txt " ) , format! ( " save: {} " , filename ) ) ;
}
Ok ( " save_requested " . to_string ( ) )
}
2026-09-14 10:38:29 +03:00
// Export a PNG (base64) via the file bridge → Kotlin decodes + SAF-saves ("save") or shares it
// via a chooser ("share"). Mirrors save_file but carries binary as base64.
#[ tauri::command ]
fn export_image ( app : tauri ::AppHandle , filename : String , base64 : String , action : String ) -> Result < String , String > {
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
std ::fs ::create_dir_all ( & data_dir ) . ok ( ) ;
let root = data_dir . parent ( ) . and_then ( | p | p . parent ( ) ) . map ( | p | p . to_path_buf ( ) ) ;
let write_both = | name : & str , content : & str | {
let _ = std ::fs ::write ( data_dir . join ( name ) , content ) ;
if let Some ( r ) = & root { let _ = std ::fs ::write ( r . join ( name ) , content ) ; }
} ;
write_both ( " export_image.b64 " , & base64 ) ;
write_both ( " gps_command.txt " , & format! ( " exportimg: {} : {} " , action , filename ) ) ;
Ok ( " export_requested " . to_string ( ) )
}
// Read any file and return its bytes base64-encoded (for loading a picked image into the WebView).
#[ tauri::command ]
fn read_file_base64 ( path : String ) -> Result < String , String > {
use base64 ::Engine ;
let bytes = std ::fs ::read ( & path ) . map_err ( | e | e . to_string ( ) ) ? ;
Ok ( base64 ::engine ::general_purpose ::STANDARD . encode ( & bytes ) )
}
2026-09-21 13:22:05 +03:00
// App root (holds baldav.db + point files + the command files Kotlin watches) and the DB path.
// MUST use native_root(): app_data_dir() nesting on Android is unreliable, so any ad-hoc
// app_data_dir().parent().parent() / db.parent() is wrong (measured: it lands in the
// unwritable /data/user and VACUUM INTO fails). native_root() scans candidates and returns the
// real writable app root that Kotlin's filesDir.parentFile also uses. Same as save points do.
2026-09-21 13:03:32 +03:00
fn db_and_root ( app : & tauri ::AppHandle ) -> Result < ( std ::path ::PathBuf , std ::path ::PathBuf ) , String > {
2026-09-21 13:22:05 +03:00
let root = native_root ( app ) ;
let db = root . join ( " baldav.db " ) ;
2026-09-21 13:03:32 +03:00
Ok ( ( db , root ) )
}
// Full backup: VACUUM the live DB into a clean copy in the app root, then tell Kotlin to
// SAF-save it as `filename`. VACUUM INTO is consistent even while the app holds the DB open.
#[ tauri::command ]
fn export_db ( app : tauri ::AppHandle , filename : String ) -> Result < String , String > {
2026-09-21 13:36:17 +03:00
let ( db_path , root ) = db_and_root ( & app ) ? ; // root = native_root: the writable app dir Kotlin watches
2026-09-21 13:03:32 +03:00
if ! db_path . exists ( ) { return Err ( " baldav.db не найдена " . into ( ) ) ; }
let out = root . join ( " export_db.tmp " ) ;
let _ = std ::fs ::remove_file ( & out ) ;
{
let conn = rusqlite ::Connection ::open ( & db_path ) . map_err ( | e | format! ( " open: {e} " ) ) ? ;
conn . execute ( " VACUUM INTO ?1 " , rusqlite ::params! [ out . to_str ( ) . unwrap_or ( " " ) ] ) . map_err ( | e | format! ( " vacuum: {e} " ) ) ? ;
}
let size = std ::fs ::metadata ( & out ) . map ( | m | m . len ( ) ) . unwrap_or ( 0 ) ;
2026-09-21 13:36:17 +03:00
let _ = std ::fs ::write ( root . join ( " gps_command.txt " ) , format! ( " exportdb: {} " , filename ) ) ;
2026-09-21 13:03:32 +03:00
Ok ( format! ( " {{ \" size \" : {} }} " , size ) )
}
// Full restore: validate the picked DB (picked_import.db) and stage it as pending_restore.db.
// Kotlin swaps it onto baldav.db on the next launch, BEFORE the app opens the DB. Returns ok;
// caller then restarts the app.
#[ tauri::command ]
fn import_db ( app : tauri ::AppHandle ) -> Result < String , String > {
2026-09-21 13:36:17 +03:00
let ( _db , root ) = db_and_root ( & app ) ? ; // root = native_root: Kotlin wrote picked_import.db here
let picked = root . join ( " picked_import.db " ) ;
2026-09-21 13:03:32 +03:00
if ! picked . exists ( ) { return Err ( " файл не выбран " . into ( ) ) ; }
{
let conn = rusqlite ::Connection ::open ( & picked ) . map_err ( | e | format! ( " не открыть файл: {e} " ) ) ? ;
let ok : String = conn . query_row ( " PRAGMA quick_check " , [ ] , | r | r . get ( 0 ) ) . map_err ( | e | format! ( " проверка: {e} " ) ) ? ;
if ok ! = " ok " { return Err ( format! ( " база повреждена: {ok} " ) ) ; }
let has : i64 = conn . query_row (
" SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('gps_sessions','products','profile') " ,
[ ] , | r | r . get ( 0 ) ) . map_err ( | e | format! ( " схема: {e} " ) ) ? ;
if has < 1 { return Err ( " это не похоже на базу BalDav " . into ( ) ) ; }
}
let pending = root . join ( " pending_restore.db " ) ;
std ::fs ::copy ( & picked , & pending ) . map_err ( | e | format! ( " подготовка: {e} " ) ) ? ;
let _ = std ::fs ::remove_file ( & picked ) ;
Ok ( " staged " . into ( ) )
}
// Ask Kotlin to restart the app process (used after import_db to load the restored DB).
#[ tauri::command ]
fn restart_app ( app : tauri ::AppHandle ) -> Result < String , String > {
2026-09-21 13:36:17 +03:00
let ( _db , root ) = db_and_root ( & app ) ? ; // root = native_root, the dir Kotlin's FileObserver watches
2026-09-21 13:03:32 +03:00
let _ = std ::fs ::write ( root . join ( " gps_command.txt " ) , " restartapp " ) ;
Ok ( " ok " . into ( ) )
}
Tile import, road map, orientation lock, CSP fix, tile error handling
- Tile import: pack_tiles.js (PNG→SQLite), Rust import_tiles + download_file
commands, JS importFromSqlite, UI in settings (file + router download)
- Road map: road_points table with 2m dedup, rebuild_roads + get_road_points
Rust commands (bypass Tauri SQL plugin 28K row limit), bike=blue walk=brown
- Orientation lock button (A/L) on recording map, hysteresis 65/35 degrees
- CSP fix: tile URL a.tile.openstreetmap.org (matches *.tile CSP pattern)
- Tile errors: done(null, tile) instead of Error to prevent crash cascade
- GPS placeholder: only show when state=waiting_gps (not on screen re-enter)
- ureq body size: use into_reader() to bypass 10MB limit for tile downloads
2026-06-26 16:41:14 +03:00
// Read tiles from a SQLite file in batches for import into IndexedDB
#[ tauri::command ]
fn import_tiles ( path : String , offset : u32 , batch_size : u32 ) -> Result < String , String > {
let db = rusqlite ::Connection ::open ( & path ) . map_err ( | e | format! ( " open: {e} " ) ) ? ;
let total : u32 = db . query_row ( " SELECT count(*) FROM tiles " , [ ] , | r | r . get ( 0 ) )
. map_err ( | e | format! ( " count: {e} " ) ) ? ;
let mut stmt = db . prepare ( " SELECT key, data FROM tiles LIMIT ?1 OFFSET ?2 " )
. map_err ( | e | format! ( " prepare: {e} " ) ) ? ;
let mut tiles = Vec ::new ( ) ;
let rows = stmt . query_map ( rusqlite ::params! [ batch_size , offset ] , | row | {
let key : String = row . get ( 0 ) ? ;
let data : Vec < u8 > = row . get ( 1 ) ? ;
Ok ( ( key , data ) )
} ) . map_err ( | e | format! ( " query: {e} " ) ) ? ;
for row in rows {
let ( key , data ) = row . map_err ( | e | format! ( " row: {e} " ) ) ? ;
use base64 ::Engine ;
let b64 = base64 ::engine ::general_purpose ::STANDARD . encode ( & data ) ;
tiles . push ( format! ( " [ \" {} \" , \" {} \" ] " , key , b64 ) ) ;
}
let has_more = offset + batch_size < total ;
Ok ( format! ( " {{ \" tiles \" :[ {} ], \" total \" : {} , \" has_more \" : {} }} " , tiles . join ( " , " ) , total , has_more ) )
}
2026-05-14 17:35:29 +03:00
#[ tauri::command ]
fn import_seed_products ( app : tauri ::AppHandle ) -> Result < String , String > {
// Find seed DB — try multiple locations
let data_dir = app . path ( ) . app_data_dir ( ) . map_err ( | e | e . to_string ( ) ) ? ;
let seed_in_data = data_dir . join ( " products_seed.db " ) ;
let seed_in_files = data_dir . join ( " files " ) . join ( " products_seed.db " ) ;
2026-05-21 00:06:50 +03:00
// Always try to refresh seed DB from bundled assets (resource_dir).
// This ensures app updates deliver new seed data even if an old copy exists.
let resource_dir = app . path ( ) . resource_dir ( ) . unwrap_or_default ( ) ;
let seed_resource = resource_dir . join ( " data " ) . join ( " products_seed.db " ) ;
if seed_resource . exists ( ) {
// Overwrite the local copy with the bundled one (may be newer)
let _ = std ::fs ::copy ( & seed_resource , & seed_in_data ) ;
}
2026-05-14 17:35:29 +03:00
let seed_path = if seed_in_data . exists ( ) {
seed_in_data
} else if seed_in_files . exists ( ) {
seed_in_files
} else {
2026-05-21 00:06:50 +03:00
return Err ( format! ( " Seed DB not found. Checked: {:?} , {:?} " , seed_in_data , seed_in_files ) ) ;
2026-05-14 17:35:29 +03:00
} ;
let seed_db = rusqlite ::Connection ::open ( & seed_path ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-05-30 15:33:59 +03:00
let seed_version : i64 = seed_db . query_row ( " SELECT version FROM seed_meta LIMIT 1 " , [ ] , | r | r . get ( 0 ) ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-05-14 17:35:29 +03:00
2026-05-21 00:06:50 +03:00
// tauri-plugin-sql may store DB in app_config_dir (desktop: ~/.config/...)
// or app_data_dir (desktop: ~/.local/share/...). Check both.
let config_dir = app . path ( ) . app_config_dir ( ) . unwrap_or_default ( ) ;
2026-06-01 17:21:36 +03:00
// tauri-plugin-sql stores DB in different locations depending on platform.
// Android: often in app root (/data/user/0/pkg/baldav.db), not in files/ subdir.
2026-05-14 17:35:29 +03:00
let main_path = {
2026-05-21 00:06:50 +03:00
let p_config = config_dir . join ( " baldav.db " ) ;
let p_data = data_dir . join ( " baldav.db " ) ;
let p_parent = data_dir . join ( " .. " ) . join ( " baldav.db " ) ;
2026-06-01 17:33:59 +03:00
// Android app root: THREE levels up from app_data_dir (files/dev.baldav.app/ → files/ → root/)
let p_root = data_dir . join ( " .. " ) . join ( " .. " ) . join ( " .. " ) . join ( " baldav.db " ) ;
2026-05-21 00:06:50 +03:00
if p_config . exists ( ) { p_config }
else if p_data . exists ( ) { p_data }
else if p_parent . exists ( ) { p_parent }
2026-06-01 17:21:36 +03:00
else if p_root . exists ( ) { p_root }
2026-05-21 00:06:50 +03:00
else { p_data }
2026-05-14 17:35:29 +03:00
} ;
let main_db = rusqlite ::Connection ::open ( & main_path ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-06-01 16:45:30 +03:00
// Ensure all columns exist in main DB
2026-05-21 00:06:50 +03:00
let extra_cols = [
" composition TEXT " , " name_lower TEXT " , " category TEXT " ,
" sodium_mg REAL " , " potassium_mg REAL " , " calcium_mg REAL " ,
" magnesium_mg REAL " , " phosphorus_mg REAL " , " iron_mg REAL " ,
" vit_a_mcg REAL " , " vit_b1_mg REAL " , " vit_b2_mg REAL " ,
" vit_b3_mg REAL " , " vit_c_mg REAL " , " vit_e_mg REAL " , " carotene_mcg REAL " ,
" nutrients_guessed INTEGER DEFAULT 0 " ,
2026-05-29 22:12:37 +03:00
" user_edited INTEGER DEFAULT 0 " ,
2026-05-21 00:06:50 +03:00
] ;
for col in & extra_cols {
let _ = main_db . execute ( & format! ( " ALTER TABLE products ADD COLUMN {} " , col ) , [ ] ) ;
}
2026-06-01 16:45:30 +03:00
// Dedup ALWAYS runs (even if seed version matches) — fixes legacy duplicates.
2026-06-01 21:25:23 +03:00
2026-06-01 16:45:30 +03:00
let _ = main_db . execute (
" CREATE INDEX IF NOT EXISTS idx_products_name_lower ON products(name_lower) " , [ ] ) ;
2026-06-01 14:51:38 +03:00
2026-06-01 14:58:15 +03:00
main_db . execute (
2026-06-01 14:51:38 +03:00
" UPDATE food_log SET product_id = (
SELECT MIN ( p2 . id ) FROM products p2
WHERE p2 . name_lower = ( SELECT p3 . name_lower FROM products p3 WHERE p3 . id = food_log . product_id )
AND p2 . source = ( SELECT p3 . source FROM products p3 WHERE p3 . id = food_log . product_id )
AND COALESCE ( p2 . store_id , ' ' ) = COALESCE ( ( SELECT p3 . store_id FROM products p3 WHERE p3 . id = food_log . product_id ) , ' ' )
) WHERE product_id NOT IN (
SELECT MIN ( id ) FROM products WHERE name_lower IS NOT NULL
GROUP BY name_lower , source , COALESCE ( store_id , ' ' )
2026-06-01 14:58:15 +03:00
) " , []).map_err(|e| format!( " dedup food_log : { } " , e))?;
2026-06-01 14:51:38 +03:00
2026-06-01 14:58:15 +03:00
main_db . execute (
" UPDATE recipe_ingredients SET product_id = (
2026-06-01 14:51:38 +03:00
SELECT MIN ( p2 . id ) FROM products p2
WHERE p2 . name_lower = ( SELECT p3 . name_lower FROM products p3 WHERE p3 . id = recipe_ingredients . product_id )
AND p2 . source = ( SELECT p3 . source FROM products p3 WHERE p3 . id = recipe_ingredients . product_id )
AND COALESCE ( p2 . store_id , ' ' ) = COALESCE ( ( SELECT p3 . store_id FROM products p3 WHERE p3 . id = recipe_ingredients . product_id ) , ' ' )
) WHERE product_id NOT IN (
SELECT MIN ( id ) FROM products WHERE name_lower IS NOT NULL
GROUP BY name_lower , source , COALESCE ( store_id , ' ' )
2026-06-01 14:58:15 +03:00
) " , []).map_err(|e| format!( " dedup recipe_ingredients : { } " , e))?;
2026-06-01 14:51:38 +03:00
2026-06-01 22:43:02 +03:00
// Reassign product_units (also has FK to products)
main_db . execute (
" DELETE FROM product_units WHERE product_id NOT IN (
SELECT MIN ( id ) FROM products WHERE name_lower IS NOT NULL
GROUP BY name_lower , source , COALESCE ( store_id , ' ' )
) " , []).map_err(|e| format!( " dedup product_units : { } " , e))?;
2026-06-01 14:58:15 +03:00
main_db . execute (
" DELETE FROM products WHERE name_lower IS NOT NULL
2026-06-01 14:51:38 +03:00
AND id NOT IN (
SELECT MIN ( id ) FROM products WHERE name_lower IS NOT NULL
GROUP BY name_lower , source , COALESCE ( store_id , ' ' )
2026-06-01 14:58:15 +03:00
) " , []).map_err(|e| format!( " dedup delete : { } " , e))?;
2026-06-01 14:51:38 +03:00
2026-06-01 16:45:30 +03:00
let _ = main_db . execute (
2026-06-01 14:51:38 +03:00
" CREATE UNIQUE INDEX IF NOT EXISTS idx_products_dedup
2026-06-01 14:58:15 +03:00
ON products ( name_lower , source , COALESCE ( store_id , ' ' ) )
2026-06-01 16:45:30 +03:00
WHERE name_lower IS NOT NULL " , []);
// Check seed version — skip import if already up to date
let current_version : i64 = main_db . query_row (
" SELECT COALESCE((SELECT value FROM profile WHERE key='seed_version'), '0') " ,
[ ] , | r | { let s : String = r . get ( 0 ) ? ; Ok ( s . parse ::< i64 > ( ) . unwrap_or ( 0 ) ) }
) . unwrap_or ( 0 ) ;
if current_version > = seed_version {
return Ok ( format! ( " up-to-date:v {} " , current_version ) ) ;
}
// ATTACH seed DB for import
let seed_str = seed_path . to_str ( ) . unwrap_or_default ( ) . to_string ( ) ;
main_db . execute ( " ATTACH DATABASE ?1 AS seed " , rusqlite ::params! [ seed_str ] )
. map_err ( | e | e . to_string ( ) ) ? ;
let total : usize = seed_db . query_row ( " SELECT COUNT(*) FROM products " , [ ] , | r | r . get ( 0 ) )
. map_err ( | e | e . to_string ( ) ) ? ;
main_db . execute ( " BEGIN " , [ ] ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-06-01 14:51:38 +03:00
Health-diet.ru products (24K), GPS session safety, build.sh upload fix
Products:
- health-diet.ru scraper (Tampermonkey) + import script
- 11469 products with 37 nutrients, Dodo 258, McDonalds 619, KFC 361, Ashan 2000
- Rust import_seed_products: dynamic column list, auto-create missing columns
- Seed version bumped to 10
GPS session safety:
- Separate gps_points_{ts}.jsonl per session, deleted only after DB save
- Toast on DB errors, NaN guard in finishGpsSession
Other:
- build.sh: router/VPS upload only with -u flag
- Orientation buttons bottom-right, weather via Yandex Pogoda
2026-06-28 23:17:51 +03:00
// Ensure all nutrient columns exist in main DB (seed may have columns main doesn't)
let seed_cols : Vec < String > = main_db . prepare ( " PRAGMA seed.table_info(products) " )
. and_then ( | mut s | s . query_map ( [ ] , | r | r . get ::< _ , String > ( 1 ) ) . and_then ( | rows | rows . collect ( ) ) )
. unwrap_or_default ( ) ;
let main_cols : Vec < String > = main_db . prepare ( " PRAGMA main.table_info(products) " )
. and_then ( | mut s | s . query_map ( [ ] , | r | r . get ::< _ , String > ( 1 ) ) . and_then ( | rows | rows . collect ( ) ) )
. unwrap_or_default ( ) ;
for col in & seed_cols {
if ! main_cols . contains ( col ) {
let _ = main_db . execute ( & format! ( " ALTER TABLE main.products ADD COLUMN {} REAL " , col ) , [ ] ) ;
}
}
// Bulk INSERT — copy ALL columns that exist in both seed and main
let shared : Vec < & String > = seed_cols . iter ( ) . filter ( | c | {
let mc : Vec < String > = main_db . prepare ( " PRAGMA main.table_info(products) " )
. and_then ( | mut s | s . query_map ( [ ] , | r | r . get ::< _ , String > ( 1 ) ) . and_then ( | rows | rows . collect ( ) ) )
. unwrap_or_default ( ) ;
mc . contains ( c )
} ) . collect ( ) ;
let cols_str = shared . iter ( ) . map ( | c | c . as_str ( ) ) . collect ::< Vec < _ > > ( ) . join ( " , " ) ;
let insert_sql = format! (
" INSERT OR IGNORE INTO main.products ({cols}) SELECT {cols} FROM seed.products " ,
cols = cols_str
) ;
let inserted = main_db . execute ( & insert_sql , [ ] ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-05-21 00:06:50 +03:00
2026-06-01 14:51:38 +03:00
// Seed index for fast UPDATE lookups
2026-05-30 16:24:47 +03:00
let _ = main_db . execute_batch (
2026-06-01 14:51:38 +03:00
" CREATE INDEX IF NOT EXISTS idx_seed_name_lower ON seed.products(name_lower); "
2026-05-30 16:24:47 +03:00
) ;
2026-05-29 22:33:12 +03:00
// Update KBJU + micronutrients for existing store products (seed already attached above)
// Sync nutrients_guessed flag
let _ = main_db . execute_batch (
2026-05-21 00:06:50 +03:00
" UPDATE main.products SET nutrients_guessed = 1
2026-05-23 18:39:28 +03:00
WHERE ( nutrients_guessed IS NULL OR nutrients_guessed = 0 )
2026-05-21 00:06:50 +03:00
AND name_lower IN ( SELECT s . name_lower FROM seed . products s WHERE s . nutrients_guessed = 1 ) "
) ;
2026-05-30 15:33:59 +03:00
// Update KBJU + micronutrients for store products that user hasn't manually edited.
// This ensures seed corrections (e.g. bread 268→243) flow through to app DB.
let _ = main_db . execute_batch (
" UPDATE main.products SET
kcal_per_100g = ( SELECT s . kcal_per_100g FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) ,
protein_per_100g = ( SELECT s . protein_per_100g FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) ,
fat_per_100g = ( SELECT s . fat_per_100g FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) ,
carbs_per_100g = ( SELECT s . carbs_per_100g FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) ,
fiber_per_100g = ( SELECT s . fiber_per_100g FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) ,
nutrients_guessed = COALESCE ( ( SELECT s . nutrients_guessed FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , nutrients_guessed ) ,
sodium_mg = COALESCE ( ( SELECT s . sodium_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , sodium_mg ) ,
potassium_mg = COALESCE ( ( SELECT s . potassium_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , potassium_mg ) ,
calcium_mg = COALESCE ( ( SELECT s . calcium_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , calcium_mg ) ,
magnesium_mg = COALESCE ( ( SELECT s . magnesium_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , magnesium_mg ) ,
phosphorus_mg = COALESCE ( ( SELECT s . phosphorus_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , phosphorus_mg ) ,
iron_mg = COALESCE ( ( SELECT s . iron_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , iron_mg ) ,
vit_a_mcg = COALESCE ( ( SELECT s . vit_a_mcg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , vit_a_mcg ) ,
vit_b1_mg = COALESCE ( ( SELECT s . vit_b1_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , vit_b1_mg ) ,
vit_b2_mg = COALESCE ( ( SELECT s . vit_b2_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , vit_b2_mg ) ,
vit_b3_mg = COALESCE ( ( SELECT s . vit_b3_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , vit_b3_mg ) ,
vit_c_mg = COALESCE ( ( SELECT s . vit_c_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , vit_c_mg ) ,
vit_e_mg = COALESCE ( ( SELECT s . vit_e_mg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , vit_e_mg ) ,
carotene_mcg = COALESCE ( ( SELECT s . carotene_mcg FROM seed . products s WHERE s . name_lower = main . products . name_lower LIMIT 1 ) , carotene_mcg )
WHERE ( main . products . user_edited IS NULL OR main . products . user_edited = 0 )
AND main . products . source IN ( ' magnit ' , ' 5 ka ' , ' kdv ' , ' ion ' )
AND main . products . name_lower IN ( SELECT s2 . name_lower FROM seed . products s2 ) "
2026-05-29 22:33:12 +03:00
) ;
// Update seed version AFTER all inserts+updates succeed
main_db . execute (
" INSERT OR REPLACE INTO profile (key, value) VALUES ('seed_version', ?1) " ,
[ seed_version . to_string ( ) ] ,
) . map_err ( | e | e . to_string ( ) ) ? ;
main_db . execute ( " COMMIT " , [ ] ) . map_err ( | e | e . to_string ( ) ) ? ;
2026-05-21 00:06:50 +03:00
2026-05-29 22:33:12 +03:00
let _ = main_db . execute ( " DETACH DATABASE seed " , [ ] ) ;
2026-05-21 00:06:50 +03:00
2026-05-14 17:35:29 +03:00
Ok ( format! ( " imported: {} : {} " , inserted , total ) )
}
2026-05-14 14:17:57 +03:00
#[ derive(Serialize) ]
struct SafeInsets {
top : i32 ,
bottom : i32 ,
left : i32 ,
right : i32 ,
}
#[ cfg(target_os = " android " ) ]
#[ tauri::command ]
fn get_safe_insets ( app : tauri ::AppHandle ) -> SafeInsets {
let data_dir = app . path ( ) . app_data_dir ( ) . unwrap_or_default ( ) ;
let insets_file = data_dir . join ( " safe_insets.json " ) ;
for _ in 0 .. 10 {
if let Ok ( content ) = std ::fs ::read_to_string ( & insets_file ) {
if let Ok ( insets ) = serde_json ::from_str ::< serde_json ::Value > ( & content ) {
return SafeInsets {
top : insets [ " top " ] . as_i64 ( ) . unwrap_or ( 0 ) as i32 ,
bottom : insets [ " bottom " ] . as_i64 ( ) . unwrap_or ( 0 ) as i32 ,
left : insets [ " left " ] . as_i64 ( ) . unwrap_or ( 0 ) as i32 ,
right : insets [ " right " ] . as_i64 ( ) . unwrap_or ( 0 ) as i32 ,
} ;
}
}
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 50 ) ) ;
}
SafeInsets { top : 0 , bottom : 0 , left : 0 , right : 0 }
}
#[ cfg(not(target_os = " android " )) ]
#[ tauri::command ]
fn get_safe_insets ( ) -> SafeInsets {
SafeInsets { top : 0 , bottom : 0 , left : 0 , right : 0 }
}
#[ cfg_attr(mobile, tauri::mobile_entry_point) ]
2026-05-13 10:21:24 +03:00
pub fn run ( ) {
2026-05-14 14:17:57 +03:00
let migrations : Vec < Migration > = vec! [
2026-05-13 10:21:24 +03:00
Migration {
version : 1 ,
description : " full schema v1 " ,
sql : r #"
PRAGMA foreign_keys = ON ;
CREATE TABLE IF NOT EXISTS profile (
key TEXT PRIMARY KEY ,
value TEXT
) ;
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY ,
name TEXT NOT NULL ,
brand TEXT ,
category TEXT ,
weight_g REAL ,
kcal_per_100g REAL ,
protein_per_100g REAL ,
fat_per_100g REAL ,
carbs_per_100g REAL ,
fiber_per_100g REAL ,
vit_a_mcg REAL ,
vit_c_mg REAL ,
vit_d_mcg REAL ,
vit_e_mg REAL ,
vit_k_mcg REAL ,
vit_b1_mg REAL ,
vit_b2_mg REAL ,
vit_b3_mg REAL ,
vit_b6_mg REAL ,
vit_b12_mcg REAL ,
folate_mcg REAL ,
calcium_mg REAL ,
iron_mg REAL ,
magnesium_mg REAL ,
zinc_mg REAL ,
copper_mg REAL ,
selenium_mcg REAL ,
potassium_mg REAL ,
sodium_mg REAL ,
source TEXT ,
store_id TEXT ,
image_path TEXT ,
created_at TEXT DEFAULT ( datetime ( ' now ',' localtime ' ) )
) ;
CREATE TABLE IF NOT EXISTS supplements (
id INTEGER PRIMARY KEY ,
name TEXT NOT NULL ,
daily_dose_g REAL ,
vit_a_mcg REAL ,
vit_c_mg REAL ,
vit_d_mcg REAL ,
vit_e_mg REAL ,
vit_k_mcg REAL ,
vit_b1_mg REAL ,
vit_b2_mg REAL ,
vit_b3_mg REAL ,
vit_b6_mg REAL ,
vit_b12_mcg REAL ,
folate_mcg REAL ,
calcium_mg REAL ,
iron_mg REAL ,
magnesium_mg REAL ,
zinc_mg REAL ,
copper_mg REAL ,
selenium_mcg REAL ,
potassium_mg REAL ,
sodium_mg REAL
) ;
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY ,
name TEXT NOT NULL ,
cooking_method TEXT ,
cooking_temp_c INTEGER ,
servings INTEGER DEFAULT 1 ,
notes TEXT
) ;
CREATE TABLE IF NOT EXISTS recipe_ingredients (
id INTEGER PRIMARY KEY ,
recipe_id INTEGER NOT NULL REFERENCES recipes ( id ) ON DELETE CASCADE ,
product_id INTEGER NOT NULL REFERENCES products ( id ) ,
amount_g REAL NOT NULL
) ;
CREATE TABLE IF NOT EXISTS weight_log (
id INTEGER PRIMARY KEY ,
date TEXT NOT NULL ,
time TEXT ,
weight_kg REAL NOT NULL
) ;
CREATE TABLE IF NOT EXISTS sleep_log (
id INTEGER PRIMARY KEY ,
date TEXT NOT NULL ,
bed_time TEXT ,
asleep_time TEXT ,
wake_time TEXT ,
nap_minutes INTEGER DEFAULT 0 ,
notes TEXT
) ;
CREATE TABLE IF NOT EXISTS food_log (
id INTEGER PRIMARY KEY ,
date TEXT NOT NULL ,
time TEXT ,
product_id INTEGER REFERENCES products ( id ) ,
recipe_id INTEGER REFERENCES recipes ( id ) ,
amount_g REAL NOT NULL ,
kcal REAL ,
protein_g REAL ,
fat_g REAL ,
carbs_g REAL ,
fiber_g REAL ,
note TEXT
) ;
CREATE TABLE IF NOT EXISTS supplement_log (
id INTEGER PRIMARY KEY ,
date TEXT NOT NULL ,
time TEXT ,
supplement_id INTEGER NOT NULL REFERENCES supplements ( id ) ,
dose_g REAL
) ;
CREATE TABLE IF NOT EXISTS gps_sessions (
id INTEGER PRIMARY KEY ,
date TEXT NOT NULL ,
start_time TEXT ,
end_time TEXT ,
activity_type TEXT ,
total_distance_m REAL ,
total_kcal REAL ,
avg_speed_kmh REAL ,
max_speed_kmh REAL ,
elevation_gain_m REAL ,
elevation_loss_m REAL ,
source TEXT
) ;
CREATE TABLE IF NOT EXISTS gps_points (
id INTEGER PRIMARY KEY ,
session_id INTEGER NOT NULL REFERENCES gps_sessions ( id ) ON DELETE CASCADE ,
ts TEXT NOT NULL ,
lat REAL NOT NULL ,
lon REAL NOT NULL ,
altitude_gps_m REAL ,
altitude_srtm_m REAL ,
speed_ms REAL ,
heart_rate INTEGER
) ;
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY ,
date TEXT NOT NULL ,
time TEXT ,
type TEXT ,
desc TEXT ,
minutes INTEGER ,
kcal REAL ,
gps_session_id INTEGER REFERENCES gps_sessions ( id )
) ;
CREATE INDEX IF NOT EXISTS idx_food_log_date ON food_log ( date ) ;
CREATE INDEX IF NOT EXISTS idx_activity_log_date ON activity_log ( date ) ;
CREATE INDEX IF NOT EXISTS idx_weight_log_date ON weight_log ( date ) ;
CREATE INDEX IF NOT EXISTS idx_sleep_log_date ON sleep_log ( date ) ;
CREATE INDEX IF NOT EXISTS idx_gps_points_sess ON gps_points ( session_id ) ;
CREATE INDEX IF NOT EXISTS idx_products_name ON products ( name COLLATE NOCASE ) ;
" #,
kind : MigrationKind ::Up ,
} ,
Migration {
version : 2 ,
description : " add composition column to products " ,
sql : " ALTER TABLE products ADD COLUMN composition TEXT; " ,
kind : MigrationKind ::Up ,
} ,
2026-05-14 14:17:57 +03:00
Migration {
version : 3 ,
description : " reserved " ,
sql : " SELECT 1; " ,
kind : MigrationKind ::Up ,
} ,
2026-05-13 10:21:24 +03:00
] ;
tauri ::Builder ::default ( )
. plugin (
tauri_plugin_sql ::Builder ::default ( )
. add_migrations ( " sqlite:baldav.db " , migrations )
. build ( ) ,
)
2026-10-26 21:08:22 +03:00
. invoke_handler ( tauri ::generate_handler! [ get_safe_insets , import_seed_products , import_tiles , download_file , get_points_filename , delete_points_file , gps_set_session_id , list_points_files , read_points_file , pick_file_open , get_picked_file , debug_native_paths , rebuild_roads , get_road_points , get_ride_graph , get_elev_correction , get_app_info , gps_service_command , set_hydration_config , set_live_config , send_sms , pick_contact , get_contact_result , gps_service_read_points , brouter_route , brouter_download_tile , brouter_download_status , brouter_list_tiles , save_file , export_image , read_file_base64 , export_db , import_db , restart_app , get_update_info , http_request , chzn_http , ble ::ble_scan , ble ::ble_scan_results , ble ::ble_connect , ble ::ble_disconnect , ble ::ble_get_hr , ble ::ble_is_connected , ble ::ble_check_bluetooth , ble ::ble_save_device , ble ::ble_clear_hr , ble ::ble_read_hr_log , ble ::ble_read_strap_uptime , ble ::ble_reset_strap_uptime , ble ::ble_request_enable , read_state_file ] )
2026-05-13 10:21:24 +03:00
. run ( tauri ::generate_context! ( ) )
. expect ( " error while running tauri application " ) ;
}