diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index 019b032..c0c32fb 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -182,6 +182,9 @@ | `ble_save_device` | Сохранение привязанного устройства | | `import_tiles` | Чтение тайлового SQLite батчами (base64) | | `download_file` | Скачивание бинарного файла по URL (без лимита размера) | +| `export_db` | Бэкап всей БД: `VACUUM INTO` чистой копии в app-root -> команда `exportdb:` -> Kotlin SAF-сохранение (`saveDbLauncher`, стрим файла) | +| `import_db` | Восстановление: валидирует `picked_import.db` (`quick_check` + таблицы BalDav), кладёт в `pending_restore.db`; Kotlin `onCreate` свопит на `baldav.db` до открытия БД | +| `restart_app` | Команда `restartapp` -> Kotlin перезапускает процесс (применить восстановление) | | `rebuild_roads` | Перестройка road_points из всех треков (2м дедуп; пеший point (`walk=1`) → atype 'walk') | | `get_road_points` | Чтение road_points батчами (обход лимита SQL plugin) | | `get_ride_graph` | Граф проезженных дорог для роутинга по своим трекам: узлы на сетке ~20м (исключая `walk=1`), рёбра между последовательными точками сессии (вес = между узлами). `{nodes,edges}` | diff --git a/README.md b/README.md index 6335f06..5923dea 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ - **Роутинг сначала по проезженному** - публичные данные BRouter часто не совпадают с землёй (роутит по несуществующим дорогам). Поэтому планировщик для каждой дорожной ноги сначала пробует проложить путь **по дорогам, где ты реально ездил** (граф из своих треков: узлы на сетке ~20м, исключая пешие куски), и только если проезженного рядом нет или путь выходит сильно кривой - обращается к BRouter. Ридден-нога рисуется тонкой линией (без высот), BRouter-нога - оковкой с раскраской. Граф строится из `gps_points` командой `get_ride_graph`, кэшируется. - **Объединение поездок** - долгий тап по треку в истории включает выделение, тап отмечает ещё; когда выбрано 2+, кнопка **«Объединить»** склеивает их в одну тренировку (точки всех сессий переносятся в самую раннюю по времени, порядок по таймстампу, стата пересчитывается). Полезно, когда одна тренировка записалась несколькими кусками (например, отдельный заезд оказался в своей сессии - после склейки он попадёт в «Разбор повторов»). Разрыв между треками больше 5 минут - предупреждает, но не мешает. **Разные велосипеды склеить нельзя** - показывает предупреждение (передача/вес отличаются, мощность и каденс посчитались бы неверно); если это один велосипед, задай его трекам одинаково и склеивай. Обратимо: у каждой точки хранится исходная сессия (`gps_points.orig_session_id`). - **Разрезание трека** - в детали трека кнопка **«Разрезать трек»** включает режим: тап по точке на карте -> в её окошке кнопка **«✂ Разрезать здесь»** -> подтверждение -> второй кусок становится отдельной тренировкой (с тем же велосипедом; порядок точек по времени). Зачем: если часть трека проехана на другом велосипеде или другой передаче - разрежь в точке смены, назначь каждому куску свой велосипед (в детали: «Велосипед» + «Пересчитать»), и мощность/каденс посчитаются по правильной передаче на каждом куске. +- **Резервная копия всех данных** - в Настройках **Экспорт** сливает всю базу (`baldav.db`) в файл через SAF (`export_db` -> `VACUUM INTO` чистой сжатой копии, ~на треть меньше), **Импорт** выбирает файл, проверяет его (`PRAGMA quick_check` + наличие таблиц BalDav) и восстанавливает с ЗАМЕНОЙ: файл кладётся в `pending_restore.db`, приложение перезапускается, и Kotlin в `onCreate` (до открытия БД) свопит его на `baldav.db`. Секретов в базе нет. Зачем: переносить наработки между сборками (**debug <-> release** - разные подписи, переустановка стирает данные) без сторонних бэкаперов. Внутриприложенческой авто-установки APK намеренно нет - только экспорт/импорт данных. - **Разбор повторов (интервалы)** - катаешь один подъём несколько раз за поездку (один трек, без стоп/старт на каждом заезде) - деталь трека сама находит повторяемый участок по самоподобию (сетка ~10м, повторно посещённые ячейки, самый длинный эталонный проход) и в сворачиваемой секции «Разбор повторов» показывает таблицу заездов (номер, время, скорость, каденс, мощность) и главное число - **последний / первый**, с вердиктом: >=100% зелёный (можно завтра), 90-99% жёлтый (тяжёлая норма), <90% красный (два дня спокойно). Каждая точка проецируется на опорную линию (снос вбок в пределах ~30м, например при возне с седлом, не режет покрытие); время заезда - только секунды в движении (остановки вырезаются, скорость нормируется на длину участка). Найденный участок подсвечивается на карте. Только вело. `GPS.detectRepeats`; повторов нет (обычная поездка A-B) - так и пишет. - **Похвастаться (шэр-картинка)** - в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. В модалке предпросмотра галочками выбирается, что класть на картинку (форма трека, дистанция, время, средняя/макс. скорость, набор высоты, калории, дата; выбор запоминается), переключатель прозрачный/фото и кнопки **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках. - **Сегменты - сворачиваемо** - в детали трека список сегментов высот (climb/descent, до десятков штук) свёрнут в аккордеон (`
`, свёрнут по умолчанию, счётчик в заголовке); тап разворачивает/сворачивает. Кнопки внизу (Похвастаться, Экспорт, Пересчитать, Удалить) остаются доступны без долгого скролла. diff --git a/docs/session-logs/2026-09-21_db-backup.md b/docs/session-logs/2026-09-21_db-backup.md new file mode 100644 index 0000000..1388061 --- /dev/null +++ b/docs/session-logs/2026-09-21_db-backup.md @@ -0,0 +1,42 @@ +# Сессия 2026-09-21 (2) - экспорт/импорт всей БД (резервная копия) + +## Контекст +Смена сборки debug<->release стирает данные (разные подписи -> переустановка). Решили: +НЕ делать внутриприложенческую авто-установку APK, а сделать полный бэкап/восстановление БД. +Записано в память: `project_no_self_install`. Секретов в базе нет. + +## Сделано +- Rust (`lib.rs`): + - `db_and_root(app)` - резолв пути `baldav.db` + app-root (как в rebuild_roads). + - `export_db(filename)` - `VACUUM INTO` чистой копии в `app-root/export_db.tmp` (консистентно + при живом соединении, сжимает ~на треть), команда `exportdb:` в gps_command.txt. + - `import_db()` - валидирует `picked_import.db` (`PRAGMA quick_check`=='ok' + есть хоть одна + таблица из gps_sessions/products/profile), копирует в `pending_restore.db`, удаляет picked. + - `restart_app()` - команда `restartapp`. + - Все три в generate_handler. +- Kotlin (`MainActivity.kt`): + - `pendingDbFile` + `saveDbLauncher` (CreateDocument octet-stream, СТРИМ файла, не в память - + файл десятки МБ). + - Ветка `exportdb:` -> SAF-сохранение `export_db.tmp`. + - Ветка `restartapp` -> relaunch (getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK) + + `Runtime.exit(0)`. + - `onCreate` (сразу после appDataDir, ДО открытия БД): если есть `pending_restore.db` - + скопировать на `baldav.db`, удалить journal/wal/shm и pending. Тот же паттерн, что копия сида. +- JS (`main.js`): в Настройках блок «Резервная копия всех данных» с кнопками Экспорт/Импорт. + Экспорт -> `export_db`. Импорт -> confirm (заменит всё) -> `pickLocalFile` -> `import_db` -> + confirm перезапуска -> `restart_app`. Импорт только на Android. + +## Проверка +- JS синтаксис ок. Автономный тест `VACUUM INTO` на десктоп-БД: 53МБ -> 36МБ, quick_check ok, + сессии на месте (валидная сжатая копия). Ядро экспорта подтверждено. +- SAF-сохранение, своп при onCreate и рестарт - только на устройстве (Kotlin), десктоп не + воспроизводит. Проверка на телефоне: экспорт -> файл-бэкап валиден; импорт другого бэкапа -> + данные заменились после рестарта. (Сборка bump до 0.1.465.) + +## Замечания +- Сжатие gzip не делал: VACUUM INTO уже компактит; flate2 в зависимостях нет. Добавлю, если + нужно меньше. +- Схема-совместимость: миграции в db.js идемпотентны (ALTER ... IF NOT EXISTS), старый бэкап + подтянется вверх, новый в старое приложение - лишние колонки безвредны. + +## На роутере всё ещё 0.1.464 (build 464). Новый бэкап-билд (465) на роутер не выкатывал. diff --git a/frontend/js/main.js b/frontend/js/main.js index 1e45a6f..3c7e7cd 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -6861,6 +6861,16 @@ async function renderSettings(container) { +
+
+
Резервная копия всех данных
+
Экспорт всей базы в файл и восстановление из него - чтобы переносить наработки между версиями (debug/release) без потерь. Импорт заменяет ВСЁ и перезапускает приложение.
+
+
+ + +
+
Очистить продукты без магазина
@@ -7266,6 +7276,39 @@ async function renderSettings(container) { let sbgClear = container.querySelector("#s-share-bg-clear"); if (sbgClear) sbgClear.onclick = () => { localStorage.removeItem("baldav_share_bg"); showToast("Подложка сброшена"); }; + // Full-DB backup: export the whole database to a file (SAF save), and restore from one. + let expDbBtn = container.querySelector("#s-export-db"); + if (expDbBtn) expDbBtn.onclick = async () => { + let prev = expDbBtn.textContent; + expDbBtn.disabled = true; expDbBtn.textContent = "Готовлю..."; + try { + let filename = "baldav-backup-" + new Date().toISOString().slice(0, 10) + ".db"; + let res = await window.__TAURI__.core.invoke("export_db", { filename }); + let size = 0; try { size = JSON.parse(res).size || 0; } catch {} + showToast("Копия готова (" + (size / 1048576).toFixed(1) + " МБ) - выберите, куда сохранить"); + } catch (e) { appAlert("Экспорт не удался: " + (e.message || e)); } + expDbBtn.disabled = false; expDbBtn.textContent = prev; + }; + let impDbBtn = container.querySelector("#s-import-db"); + if (impDbBtn) impDbBtn.onclick = async () => { + if (!/Android/i.test(navigator.userAgent)) { appAlert("Импорт базы доступен только на устройстве."); return; } + if (!(await appConfirm("Импорт ЗАМЕНИТ все текущие данные (треки, питание, профиль, велосипеды) данными из файла. Несохранённое пропадёт.\n\nСделайте экспорт текущей базы, если она нужна. Продолжить?", "Выбрать файл"))) return; + let prev = impDbBtn.textContent; + impDbBtn.disabled = true; impDbBtn.textContent = "Выбор..."; + try { + let path = await pickLocalFile("*/*"); + if (!path) { showToast("Файл не выбран"); return; } + impDbBtn.textContent = "Проверка..."; + await window.__TAURI__.core.invoke("import_db"); // validates picked_import.db + stages it + if (await appConfirm("База проверена и готова. Приложение перезапустится, чтобы применить восстановление. Перезапустить сейчас?", "Перезапустить")) { + await window.__TAURI__.core.invoke("restart_app"); + } else { + showToast("Восстановление применится при следующем запуске"); + } + } catch (e) { appAlert("Импорт не удался: " + (e.message || e)); } + impDbBtn.disabled = false; impDbBtn.textContent = prev; + }; + // Download tiles from router — the list comes from the router's directory // listing, so a newly uploaded .db shows up without rebuilding the app. container.querySelector("#s-download-tiles").onclick = async () => { diff --git a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt index f69328f..e08170a 100644 --- a/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/dev/baldav/app/MainActivity.kt @@ -25,6 +25,7 @@ class MainActivity : TauriActivity() { private var commandObserver: FileObserver? = null private var pendingSaveContent: String? = null private var pendingImageBytes: ByteArray? = null + private var pendingDbFile: File? = null private var backWebView: WebView? = null // Disable WryActivity's generated back handler: its mWebView.goBack() does NOT @@ -78,6 +79,22 @@ class MainActivity : TauriActivity() { pendingImageBytes = null } + // SAF "create document" for the full DB backup — streams the staged file (can be tens of MB) + // straight to the picked location instead of loading it into memory. + private val saveDbLauncher = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/octet-stream") + ) { uri: Uri? -> + val src = pendingDbFile + if (uri != null && src != null && src.exists()) { + try { + contentResolver.openOutputStream(uri)?.use { out -> src.inputStream().use { it.copyTo(out, 1 shl 20) } } + Log.d("BalDav", "DB exported to $uri (${src.length()} bytes)") + src.delete() + } catch (e: Exception) { Log.e("BalDav", "Failed to export DB", e) } + } + pendingDbFile = null + } + // Share a PNG via the system chooser (write to cache, FileProvider content:// URI, ACTION_SEND). private fun shareImage(bytes: ByteArray, filename: String) { try { @@ -127,6 +144,22 @@ class MainActivity : TauriActivity() { val appDataDir = filesDir.parentFile ?: filesDir Log.d("BalDav", "App data dir: ${appDataDir.absolutePath}") + // Restore-on-launch: a full-DB import stages pending_restore.db; swap it onto baldav.db NOW, + // before the app opens the DB (tauri-plugin-sql opens it later, when db.js runs). Then clear + // any stale journal so the restored file is the single source of truth. + try { + val pending = File(appDataDir, "pending_restore.db") + if (pending.exists() && pending.length() > 0) { + val dbFile = File(appDataDir, "baldav.db") + pending.inputStream().use { i -> dbFile.outputStream().use { o -> i.copyTo(o, 1 shl 20) } } + File(appDataDir, "baldav.db-journal").delete() + File(appDataDir, "baldav.db-wal").delete() + File(appDataDir, "baldav.db-shm").delete() + pending.delete() + Log.d("BalDav", "Restored DB from pending_restore.db (${dbFile.length()} bytes)") + } + } catch (e: Exception) { Log.e("BalDav", "DB restore failed", e) } + // Create command files if not exist (FileObserver needs them) val cmdFile = File(appDataDir, "gps_command.txt") if (!cmdFile.exists()) cmdFile.createNewFile() @@ -515,6 +548,21 @@ class MainActivity : TauriActivity() { } } } + cmd.startsWith("exportdb:") -> { + // exportdb: — SAF-save the DB copy Rust wrote to export_db.tmp. + val filename = cmd.substringAfter("exportdb:").ifBlank { "baldav-backup.db" } + val f = File(appDataDir, "export_db.tmp") + if (f.exists()) { pendingDbFile = f; saveDbLauncher.launch(filename) } + else Log.e("BalDav", "exportdb: export_db.tmp missing") + } + cmd == "restartapp" -> { + // Relaunch the process so the staged DB (pending_restore.db) is swapped in onCreate. + Log.d("BalDav", "Restarting app (DB restore)") + val intent = packageManager.getLaunchIntentForPackage(packageName) + intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + Runtime.getRuntime().exit(0) + } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9466747..a7441e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -624,6 +624,73 @@ fn read_file_base64(path: String) -> Result { Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) } +// Resolve the main baldav.db path (same fallbacks as rebuild_roads/get_road_points) and the +// app root (where Kotlin's FileObserver watches). Root = data_dir/../.. on Android. +fn db_and_root(app: &tauri::AppHandle) -> Result<(std::path::PathBuf, std::path::PathBuf), String> { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + 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"); + let db = if p1.exists() { p1 } else if p2.exists() { p2.clone() } else { p3 }; + let root = data_dir.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf()).unwrap_or(data_dir.clone()); + 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 { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let (db_path, root) = db_and_root(&app)?; + 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); + let cmd = format!("exportdb:{}", filename); + let _ = std::fs::write(data_dir.join("gps_command.txt"), &cmd); + let _ = std::fs::write(root.join("gps_command.txt"), &cmd); + 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 { + let (_db, root) = db_and_root(&app)?; + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let picked = if root.join("picked_import.db").exists() { root.join("picked_import.db") } + else { data_dir.join("picked_import.db") }; + 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 { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let (_db, root) = db_and_root(&app)?; + let _ = std::fs::write(data_dir.join("gps_command.txt"), "restartapp"); + let _ = std::fs::write(root.join("gps_command.txt"), "restartapp"); + Ok("ok".into()) +} + // 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 { @@ -1088,7 +1155,7 @@ pub fn run() { .add_migrations("sqlite:baldav.db", migrations) .build(), ) - .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_app_info, gps_service_command, gps_service_read_points, brouter_route, brouter_download_tile, brouter_download_status, brouter_list_tiles, save_file, export_image, read_file_base64, 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]) + .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_app_info, gps_service_command, 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]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }