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
This commit is contained in:
4aiman 2026-06-26 16:41:14 +03:00
commit 184b86b18c
8 changed files with 561 additions and 32 deletions

View file

@ -1072,6 +1072,25 @@ select {
position: absolute;
inset: 0;
}
.rec-orient-lock {
position: absolute;
top: 8px;
left: 8px;
z-index: 1000;
width: 32px;
height: 32px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(30, 30, 30, 0.8);
color: var(--on-bg);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.rec-orient-lock.locked {
background: rgba(187, 134, 252, 0.4);
border-color: var(--primary);
}
.gps-placeholder {
position: absolute;
inset: 0;

View file

@ -1119,11 +1119,83 @@ window.DB.getAllReservesDates = getAllReservesDates;
window.DB.getDaysWithFood = getDaysWithFood;
window.DB.getDailyWaterMl = getDailyWaterMl;
// All GPS tracks as arrays of [lat, lon] grouped by session_id.
// minSpeedMs filters out stopped/slow points. minPoints filters out tiny sessions.
// Fetches per-session to avoid Tauri SQL plugin row limit.
// Road points — all GPS points merged into one flat table.
// Rebuild: INSERT...SELECT runs entirely in SQLite (no JS iteration, no ID issues).
// Reading: paginated to avoid Tauri SQL plugin row limit (~28K).
async function rebuildRoads() {
const invoke = window.__TAURI__.core.invoke;
const raw = await invoke("rebuild_roads", { minSpeedMs: 0.5 });
const resp = JSON.parse(raw);
console.log("[rebuildRoads]", resp);
return resp.road_points;
}
// Read road points via Rust command (Tauri SQL plugin has ~28K row limit)
async function getRoadPoints() {
const invoke = window.__TAURI__.core.invoke;
const BATCH = 10000;
let offset = 0;
const all = [];
while (true) {
const raw = await invoke("get_road_points", { offset, batchSize: BATCH });
const resp = JSON.parse(raw);
for (const p of resp.points) all.push({ lat: p[0], lon: p[1], atype: p[2] || "b" });
console.log(`[getRoadPoints] batch offset=${offset} got=${resp.points.length} total_db=${resp.total}`);
if (!resp.has_more) break;
offset += BATCH;
}
console.log(`[getRoadPoints] total=${all.length}`);
return all;
}
window.DB.rebuildRoads = rebuildRoads;
window.DB.getRoadPoints = getRoadPoints;
// JS-side migrations — safe for any DB state (checks before altering)
async function runMigrations() {
// Disable WAL — all writes go directly to main DB file (no -wal/-shm files)
await execute("PRAGMA journal_mode=DELETE");
// Sync columns: sync_id (UUID), updated_at (ISO timestamp), sync_dirty (0/1)
const syncTables = ["food_log","weight_log","supplement_log","sleep_log","activity_log",
"gps_sessions","recipes","recipe_ingredients","supplements"];
for (const tbl of syncTables) {
const cols = (await select(`PRAGMA table_info(${tbl})`)).map(r => r.name);
if (!cols.includes("sync_id")) {
await execute(`ALTER TABLE ${tbl} ADD COLUMN sync_id TEXT`);
await execute(`ALTER TABLE ${tbl} ADD COLUMN updated_at TEXT`);
await execute(`ALTER TABLE ${tbl} ADD COLUMN sync_dirty INTEGER DEFAULT 0`);
// Backfill sync_id for existing rows
await execute(`UPDATE ${tbl} SET sync_id = lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(2)) || '-' || hex(randomblob(6))) WHERE sync_id IS NULL`);
await execute(`UPDATE ${tbl} SET updated_at = datetime('now') WHERE updated_at IS NULL`);
}
// Triggers: auto-set sync_id, updated_at, sync_dirty on INSERT/UPDATE
await execute(`CREATE TRIGGER IF NOT EXISTS ${tbl}_sync_insert
AFTER INSERT ON ${tbl} WHEN NEW.sync_id IS NULL
BEGIN UPDATE ${tbl} SET
sync_id = lower(hex(randomblob(4))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(6))),
updated_at = datetime('now'), sync_dirty = 1
WHERE rowid = NEW.rowid; END`);
await execute(`CREATE TRIGGER IF NOT EXISTS ${tbl}_sync_update
AFTER UPDATE ON ${tbl} WHEN NEW.sync_dirty != 0 OR OLD.sync_dirty = 0
BEGIN UPDATE ${tbl} SET
updated_at = datetime('now'), sync_dirty = 1
WHERE rowid = NEW.rowid AND sync_dirty = 0; END`);
// First sync: mark all existing rows as dirty so they get pushed
const dirtyCount = (await select(`SELECT COUNT(*) as c FROM ${tbl} WHERE sync_dirty = 1`))[0].c;
if (dirtyCount === 0) {
const total = (await select(`SELECT COUNT(*) as c FROM ${tbl}`))[0].c;
if (total > 0) {
await execute(`UPDATE ${tbl} SET sync_dirty = 1`);
console.log(`[Sync] Marked ${total} rows in ${tbl} as dirty for first sync`);
}
}
}
// Batch column checks — one PRAGMA per table instead of N separate IPC calls
const productCols = (await select("PRAGMA table_info(products)")).map(r => r.name);
const addProdCol = async (col, type) => { if (!productCols.includes(col)) await execute(`ALTER TABLE products ADD COLUMN ${col} ${type}`); };
@ -1463,6 +1535,15 @@ async function runMigrations() {
reserves TEXT
)`);
// Road points — deduplicated points from all GPS tracks
await execute(`CREATE TABLE IF NOT EXISTS road_points (
id INTEGER PRIMARY KEY,
lat REAL NOT NULL,
lon REAL NOT NULL
)`);
// Spatial index for fast 2m proximity checks
await execute(`CREATE INDEX IF NOT EXISTS idx_road_lat_lon ON road_points(lat, lon)`);
// Water content column for hydration tracking
if (!productCols.includes("water_pct")) {
await execute("ALTER TABLE products ADD COLUMN water_pct REAL");

View file

@ -2743,9 +2743,7 @@ function renderRecordingInline(container) {
<div class="rec-screen">
<div class="rec-map-wrap">
<div id="rec-map" class="rec-map"></div>
<div id="gps-placeholder" class="gps-placeholder">
<div class="gps-placeholder-text">Поиск GPS...</div>
</div>
${GPS.recording.state === "waiting_gps" ? '<div id="gps-placeholder" class="gps-placeholder"><div class="gps-placeholder-text">Поиск GPS...</div></div>' : ''}
</div>
<div class="rec-panel">
<div class="rec-hr-row" id="rec-hr-row" style="display:none">
@ -2794,34 +2792,54 @@ function renderRecordingInline(container) {
// Compass heading + landscape detection from device orientation
let currentLandscape = null; // null | 'right' | 'left'
let orientationLocked = false; // lock button prevents landscape switching
const recScreen = document.querySelector('.rec-screen');
// Lock button — top-left corner of the map
const lockBtn = document.createElement("button");
lockBtn.className = "rec-orient-lock";
lockBtn.textContent = "A";
lockBtn.title = "Авто/Заблокировать ориентацию";
lockBtn.onclick = () => {
orientationLocked = !orientationLocked;
lockBtn.textContent = orientationLocked ? "L" : "A";
lockBtn.classList.toggle("locked", orientationLocked);
};
mapEl.parentElement.appendChild(lockBtn);
function handleOrientation(e) {
// Compass heading
const heading = e.webkitCompassHeading || e.alpha || 0;
const gamma = e.gamma || 0;
// Landscape detection: |gamma| > 50 = phone held sideways
const threshold = 50;
let newLandscape = null;
if (gamma > threshold) newLandscape = 'right';
else if (gamma < -threshold) newLandscape = 'left';
if (newLandscape !== currentLandscape) {
currentLandscape = newLandscape;
if (recScreen) {
recScreen.classList.remove('landscape-right', 'landscape-left');
if (newLandscape) recScreen.classList.add('landscape-' + newLandscape);
// Landscape detection with hysteresis: enter at 65, exit at 35
if (!orientationLocked) {
const enterThreshold = 65;
const exitThreshold = 35;
let newLandscape = currentLandscape;
if (currentLandscape === null) {
// Currently portrait — need strong tilt to enter landscape
if (gamma > enterThreshold) newLandscape = 'right';
else if (gamma < -enterThreshold) newLandscape = 'left';
} else if (currentLandscape === 'right') {
// Currently landscape-right — need low tilt to exit
if (gamma < exitThreshold) newLandscape = null;
} else if (currentLandscape === 'left') {
if (gamma > -exitThreshold) newLandscape = null;
}
if (newLandscape !== currentLandscape) {
currentLandscape = newLandscape;
if (recScreen) {
recScreen.classList.remove('landscape-right', 'landscape-left');
if (newLandscape) recScreen.classList.add('landscape-' + newLandscape);
}
const nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = newLandscape ? 'none' : '';
setTimeout(() => map.invalidateSize(), 100);
}
// Hide/show bottom nav — it's outside rec-screen, CSS can't reach it
const nav = document.querySelector('.bottom-nav');
if (nav) nav.style.display = newLandscape ? 'none' : '';
// Leaflet needs to recalculate container size after CSS change
setTimeout(() => map.invalidateSize(), 100);
}
// Set map bearing: compass heading + compensation for CSS viewport rotation
// CSS rotate(-90deg) for landscape-right, rotate(90deg) for landscape-left
// The CSS rotation rotates the visual output, so the map bearing must compensate
// Map bearing always updates regardless of orientation lock
let bearingOffset = 0;
if (currentLandscape === 'right') bearingOffset = 90;
else if (currentLandscape === 'left') bearingOffset = -90;
@ -4347,6 +4365,33 @@ async function renderSettings(container) {
</div>
<button class="settings-toggle ${TileCache.offlineMode ? "on" : ""}" id="s-offline-toggle"></button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Импорт тайлов</div>
<div class="settings-item-desc">Загрузить .db файл с тайлами региона</div>
</div>
<button class="btn-secondary" id="s-import-tiles" style="padding:6px 12px;font-size:13px;">Файл</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Скачать с роутера</div>
<div class="settings-item-desc">LAN: 192.168.1.1</div>
</div>
<button class="btn-secondary" id="s-download-tiles" style="padding:6px 12px;font-size:13px;">Регионы</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Кэш тайлов</div>
<div class="settings-item-desc" id="s-tile-count">...</div>
</div>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Просмотр дорог</div>
<div class="settings-item-desc">Все проезженные маршруты на карте</div>
</div>
<button class="btn-secondary" id="s-roads-map" style="padding:6px 12px;font-size:13px;">Открыть</button>
</div>
</div>
<div class="settings-section">
@ -4558,6 +4603,110 @@ async function renderSettings(container) {
offToggle.classList.toggle("on", newVal);
};
// Tile cache count
TileCache.count().then(n => {
const el = container.querySelector("#s-tile-count");
if (el) el.textContent = `${n} тайлов в кэше`;
});
// Import tiles from SQLite file
// User pushes .db to app dir: adb push tiles_region.db /data/user/0/dev.baldav.app/files/
container.querySelector("#s-import-tiles").onclick = async () => {
const btn = container.querySelector("#s-import-tiles");
// Prompt for path — user can type full path or just filename in app files dir
let filePath = prompt("Путь к файлу тайлов (.db).\nНа Android: скопируйте файл через\nadb push file.db /data/user/0/dev.baldav.app/files/\nи введите имя файла:");
if (!filePath) return;
// If just a filename, resolve relative to app files dir
if (!filePath.includes("/")) {
try {
const appDir = await window.__TAURI__.path.appDataDir();
// appDataDir on Android = /data/user/0/pkg/files/dev.baldav.app/
// files dir = /data/user/0/pkg/files/
// Try both locations
const candidates = [
appDir + "/" + filePath,
appDir + "/../../files/" + filePath,
appDir + "/../" + filePath,
];
filePath = candidates[0]; // try appDataDir first
} catch {
// desktop: use as-is
}
}
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = "0%...";
try {
const count = await TileCache.importFromSqlite(filePath, (done, total) => {
const pct = Math.round(done / total * 100);
btn.textContent = `${pct}% (${done}/${total})`;
});
btn.textContent = origText;
btn.disabled = false;
showToast(`Импортировано ${count} тайлов`);
const el = container.querySelector("#s-tile-count");
TileCache.count().then(n => { if (el) el.textContent = `${n} тайлов в кэше`; });
} catch (e) {
btn.textContent = origText;
btn.disabled = false;
showToast("Ошибка: " + String(e));
}
};
// Download tiles from router
container.querySelector("#s-download-tiles").onclick = async () => {
const regions = [
["novocherkassk", "region"],
["rostov_city", "Rostov-on-Don"],
["rostov_oblast", "Rostov Oblast"],
["moscow_city", "Moscow"],
["moscow_oblast", "Moscow Oblast"],
["spb", "Saint Petersburg"],
["krasnodar_city", "Krasnodar"],
["krasnodar_krai", "Krasnodar Krai"],
["kazan", "Kazan"],
["novosibirsk", "Novosibirsk"],
["ekaterinburg", "Ekaterinburg"],
];
const list = regions.map((r, i) => `${i + 1}. ${r[1]}`).join("\n");
const choice = prompt("Выберите регион (номер):\n" + list);
if (!choice) return;
const idx = parseInt(choice) - 1;
if (idx < 0 || idx >= regions.length) { showToast("Неверный номер"); return; }
const regionKey = regions[idx][0];
const regionName = regions[idx][1];
const url = `http://192.168.1.1:88/baldav/tiles/tiles_${regionKey}.db`;
const invoke = window.__TAURI__.core.invoke;
const btn = container.querySelector("#s-download-tiles");
btn.disabled = true;
btn.textContent = "Скачивание...";
try {
const appDir = await window.__TAURI__.path.appDataDir();
const dest = appDir + `/tiles_${regionKey}.db`;
const bytes = await invoke("download_file", { url, dest });
const sizeMB = (parseInt(bytes) / 1024 / 1024).toFixed(1);
btn.textContent = `Импорт (${sizeMB} MB)...`;
const count = await TileCache.importFromSqlite(dest, (done, total) => {
const pct = Math.round(done / total * 100);
btn.textContent = `${pct}% (${done}/${total})`;
});
btn.textContent = "Регионы";
btn.disabled = false;
showToast(`${regionName}: ${count} тайлов импортировано`);
TileCache.count().then(n => {
const el = container.querySelector("#s-tile-count");
if (el) el.textContent = `${n} тайлов в кэше`;
});
} catch (e) {
btn.textContent = "Регионы";
btn.disabled = false;
showToast("Ошибка: " + String(e));
}
};
// Roads map
container.querySelector("#s-roads-map").onclick = () => showRoadsMap();
// GPS config inputs
const gpsInputs = ["s-min-speed", "s-min-acc", "s-min-dist", "s-max-speed", "s-gap-threshold", "s-air-drag", "s-air-density", "s-pedal-eff", "s-alt-smooth", "s-walk-max-speed", "s-walk-min-speed"];
for (const id of gpsInputs) {
@ -5447,6 +5596,101 @@ function formatDate(iso) {
.toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" });
}
async function showRoadsMap() {
const container = document.querySelector(".screen-content");
container.innerHTML = `
<div style="display:flex;flex-direction:column;height:100%;">
<div style="display:flex;align-items:center;padding:8px 12px;padding-top:calc(8px + var(--safe-area-top));background:var(--surface);border-bottom:1px solid var(--border);gap:8px;">
<button id="roads-back" class="btn-secondary" style="padding:6px 12px;">Назад</button>
<span style="flex:1;font-weight:600;">Дороги</span>
<span id="roads-info" style="font-size:12px;color:var(--muted);"></span>
<button id="roads-rebuild" class="btn-secondary" style="padding:6px 12px;font-size:12px;">Перестроить</button>
</div>
<div id="roads-map" style="flex:1;"></div>
</div>`;
document.querySelector("#roads-back").onclick = () => renderScreen("settings");
const mapEl = document.querySelector("#roads-map");
L.Icon.Default.imagePath = "lib/";
const map = L.map(mapEl, { attributionControl: false }).setView([47.42, 40.08], 13);
addZoomDisplay(map);
const CachedLayer = TileCache.createLayer();
new CachedLayer({ maxZoom: 19 }).addTo(map);
const infoEl = document.querySelector("#roads-info");
infoEl.textContent = "Загрузка...";
// Rebuild button — always visible
const rebuildBtn = document.querySelector("#roads-rebuild");
async function doRebuild() {
rebuildBtn.disabled = true;
rebuildBtn.textContent = "...";
infoEl.textContent = "Построение (может занять минуту)...";
try {
const count = await DB.rebuildRoads();
infoEl.textContent = `${count} точек`;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
showRoadsMap();
} catch (e) {
infoEl.textContent = "Ошибка: " + e;
rebuildBtn.textContent = "Перестроить";
rebuildBtn.disabled = false;
}
}
rebuildBtn.onclick = doRebuild;
let pts;
try {
pts = await DB.getRoadPoints();
} catch (e) {
// Table missing or schema mismatch — need rebuild
infoEl.textContent = "Нужно построить дороги";
return;
}
if (pts.length === 0) {
infoEl.textContent = "Нет дорог — нажмите Перестроить";
return;
}
// Split points into segments by gap (>100m) and activity type
const segments = []; // { coords: [[lat,lon],...], atype: "b"|"w" }
let seg = [];
let segType = pts[0]?.atype || "b";
for (let i = 0; i < pts.length; i++) {
const newType = pts[i].atype || "b";
if (seg.length > 0) {
const prev = seg[seg.length - 1];
const dlat = (pts[i].lat - prev[0]) * 111000;
const dlon = (pts[i].lon - prev[1]) * 111000 * Math.cos(pts[i].lat * Math.PI / 180);
if (dlat * dlat + dlon * dlon > 10000 || newType !== segType) {
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
seg = [];
segType = newType;
}
}
seg.push([pts[i].lat, pts[i].lon]);
}
if (seg.length >= 2) segments.push({ coords: seg, atype: segType });
// Draw: bike = blue, walk = brown
const allCoords = [];
for (const s of segments) {
const color = s.atype === "w" ? "#8B5E3C" : "#2266aa";
L.polyline(s.coords, { color, weight: 4, opacity: 1 }).addTo(map);
allCoords.push(...s.coords);
}
if (allCoords.length > 0) {
map.fitBounds(L.latLngBounds(allCoords).pad(0.05));
}
const bikeSegs = segments.filter(s => s.atype !== "w").length;
const walkSegs = segments.filter(s => s.atype === "w").length;
infoEl.textContent = `${pts.length} точек (вело: ${bikeSegs}, пешие: ${walkSegs})`;
}
function addZoomDisplay(map) {
const ctrl = L.control({ position: "bottomright" });
ctrl.onAdd = () => {

View file

@ -103,7 +103,7 @@ TileCache.downloadArea = async function(bounds, zoomMin, zoomMax, onProgress) {
const existing = await getTile(key);
if (existing) { done++; if (onProgress) onProgress(done, tiles.length); return; }
const url = `https://tile.openstreetmap.org/${key}.png`;
const url = `https://a.tile.openstreetmap.org/${key}.png`;
try {
const resp = await fetch(url);
if (resp.ok) {
@ -141,18 +141,18 @@ TileCache.createLayer = function() {
} else {
// Fetch from network
tile.crossOrigin = "";
tile.src = `https://tile.openstreetmap.org/${key}.png`;
tile.src = `https://a.tile.openstreetmap.org/${key}.png`;
tile.onload = () => {
fetch(tile.src).then(r => r.blob()).then(b => putTile(key, b)).catch(() => {});
done(null, tile);
};
tile.onerror = () => done(new Error("tile failed"), tile);
tile.onerror = () => done(null, tile);
}
}).catch(() => {
if (TileCache.offlineMode) { done(null, tile); return; }
tile.src = `https://tile.openstreetmap.org/${key}.png`;
tile.src = `https://a.tile.openstreetmap.org/${key}.png`;
tile.onload = () => done(null, tile);
tile.onerror = () => done(new Error("tile failed"), tile);
tile.onerror = () => done(null, tile);
});
return tile;
@ -174,5 +174,36 @@ TileCache.loadOfflineMode = function() {
TileCache.loadOfflineMode();
// Import tiles from a SQLite file (packed by tools/pack_tiles.js) into IndexedDB
// path: filesystem path to the .db file
// onProgress(done, total): callback
TileCache.importFromSqlite = async function(path, onProgress) {
const invoke = window.__TAURI__.core.invoke;
const BATCH = 100;
let offset = 0;
let total = 0;
let imported = 0;
while (true) {
const raw = await invoke("import_tiles", { path, offset, batchSize: BATCH });
const resp = JSON.parse(raw);
total = resp.total;
for (const [key, b64] of resp.tiles) {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
await putTile(key, new Blob([bytes], { type: "image/png" }));
imported++;
}
if (onProgress) onProgress(imported, total);
if (!resp.has_more) break;
offset += BATCH;
}
return imported;
};
window.TileCache = TileCache;
console.log("[tilecache.js] window.TileCache ready, offline:", TileCache.offlineMode);

3
src-tauri/Cargo.lock generated
View file

@ -139,8 +139,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "baldav"
version = "0.1.270"
version = "0.1.279"
dependencies = [
"base64 0.22.1",
"btleplug",
"lazy_static",
"rusqlite",

View file

@ -1,6 +1,6 @@
[package]
name = "baldav"
version = "0.1.270"
version = "0.1.279"
edition = "2021"
[lib]
@ -19,6 +19,7 @@ rusqlite = { version = "0.31", features = ["bundled"] }
lazy_static = "1"
ureq = "3"
base64 = "0.22"
[target.'cfg(not(target_os = "android"))'.dependencies]
btleplug = { version = "0.11", features = ["serde"] }

View file

@ -7,6 +7,134 @@ use serde::Serialize;
#[allow(unused, dead_code)]
mod ble;
// Simple HTTP proxy for sync requests (WebView blocks mixed http/https content)
#[tauri::command]
fn http_request(url: String, method: String, body: Option<String>) -> Result<String, String> {
let mut resp = match method.to_uppercase().as_str() {
"POST" => {
let b = body.unwrap_or_default();
ureq::post(&url)
.header("Content-Type", "application/json")
.send(b.as_bytes())
.map_err(|e| e.to_string())?
}
_ => ureq::get(&url).call().map_err(|e| e.to_string())?,
};
resp.body_mut().read_to_string().map_err(|e| e.to_string())
}
// 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();
db.execute("CREATE TABLE road_points (id INTEGER PRIMARY KEY, lat REAL NOT NULL, lon REAL NOT NULL, atype TEXT)", [])
.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}"))?;
// 2m in degrees: lat ~0.000018, lon ~0.000027 at 47N
let d_lat: f64 = 0.000018;
let d_lon: f64 = 0.000027;
let mut read_stmt = db.prepare(
"SELECT p.lat, p.lon, s.activity_type FROM gps_points p
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}"))?;
let points: Vec<(f64, f64, String)> = 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()))
}).map_err(|e| format!("query: {e}"))?
.filter_map(|r| r.ok())
.collect();
drop(read_stmt);
db.execute("BEGIN", []).map_err(|e| format!("begin: {e}"))?;
let mut inserted = 0u32;
for (lat, lon, atype) in &points {
let exists: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM road_points WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4)",
rusqlite::params![lat - d_lat, lat + d_lat, lon - d_lon, lon + d_lon],
|r| r.get(0),
).unwrap_or(false);
if !exists {
db.execute(
"INSERT INTO road_points (lat, lon, atype) VALUES (?1, ?2, ?3)",
rusqlite::params![lat, lon, atype],
).map_err(|e| format!("insert: {e}"))?;
inserted += 1;
}
}
db.execute("COMMIT", []).map_err(|e| format!("commit: {e}"))?;
Ok(format!("{{\"total_points\":{},\"road_points\":{}}}", points.len(), inserted))
}
// 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);
let mut stmt = db.prepare(
"SELECT lat, lon, COALESCE(atype,'bike') FROM road_points ORDER BY id LIMIT ?1 OFFSET ?2"
).map_err(|e| format!("prepare: {e}"))?;
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)?;
Ok((lat, lon, atype))
}).map_err(|e| format!("query: {e}"))?;
for row in rows {
let (lat, lon, atype) = row.map_err(|e| format!("row: {e}"))?;
let t = if atype == "walk" { "w" } else { "b" };
points.push(format!("[{:.7},{:.7},\"{}\"]", lat, lon, t));
}
let has_more = offset + batch_size < total;
Ok(format!("{{\"points\":[{}],\"total\":{},\"has_more\":{}}}", points.join(","), total, has_more))
}
#[derive(Serialize)]
struct AppInfo {
version: String,
@ -100,6 +228,30 @@ fn save_file(app: tauri::AppHandle, filename: String, content: String) -> Result
Ok("save_requested".to_string())
}
// 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))
}
#[tauri::command]
fn import_seed_products(app: tauri::AppHandle) -> Result<String, String> {
// Find seed DB — try multiple locations
@ -533,7 +685,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, 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])
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, 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])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/tauri-v2/refs/heads/main/tooling/cli/schema.json",
"productName": "BalDav",
"version": "0.1.270",
"version": "0.1.279",
"identifier": "dev.baldav.app",
"build": {
"frontendDist": "../frontend"