/** * ───────────────────────────────────────────────────────────────────────────── * Sauvegarde JSON (portage de `data/backup/BackupManager.kt`). * * ⚠️ COMPATIBILITÉ BIDIRECTIONNELLE avec les exports de l'app Android : * format BackupData v2 — * * { * "version": 2, * "exportedAt": 1749..., * "treatments": [ Treatment... (champs Gson/Kotlin, cf data/models.js) ], * "doseLogs": [ DoseLog... ], * "labResults": [ LabResult... ], * "tConfig": { "base": 6.0, "floor": 0.2, "k": 0.19 }, * "settings": { "language": ..., "autoCalibrate": ..., * "alertE2High": ..., "alertE2Low": ..., * "alertTHigh": ..., "alertTLow": ... } ← optionnel (v2) * } * * RÉTROCOMPATIBILITÉ : les backups v1 (SANS `settings`) restent parsables * (settings = null) et importables — l'import ne vérifie pas strictement la * version (miroir du comportement Gson côté Android). `changelog_seen_version` * est volontairement EXCLU des settings d'export (pas une donnée utile à * restaurer — cf BackupManager.kt). * * PUR (DOM-free) → testable en Node (web/tests/backup.test.js, y compris * round-trip avec un vrai export Android si local-test-data/ est présent). * ───────────────────────────────────────────────────────────────────────────── */ /** * Construit le JSON de sauvegarde (BackupData v2, mêmes noms de champs que * Gson). * * @param {object} collections { treatments, doseLogs, labResults } (store.exportAll) * @param {object} tConfig { base, floor, k } * @param {object|null} [userSettings=null] Réglages à embarquer (v2) — * SEULS les champs du schéma UserSettings Android sont sérialisés (champs * plats), jamais les clés web extra (chartTimezone, alertNotifiedState…) : * un backup importé dans l'app Android ne doit pas contenir de clés * inconnues (Gson les ignorerait, mais autant rester stricts). * @returns {string} JSON */ export function buildBackupJson(collections, tConfig, userSettings = null) { const data = { version: 2, exportedAt: Date.now(), treatments: collections.treatments, doseLogs: collections.doseLogs, labResults: collections.labResults, tConfig: { base: tConfig.base, floor: tConfig.floor, k: tConfig.k, }, }; if (userSettings) { // Champs PLATS volontairement (miroir UserSettings.kt — Gson lit par // réflexion côté Android : pas de nested inconnu) data.settings = { language: userSettings.language ?? null, autoCalibrate: userSettings.autoCalibrate ?? null, alertE2High: userSettings.alertE2High ?? null, alertE2Low: userSettings.alertE2Low ?? null, alertTHigh: userSettings.alertTHigh ?? null, alertTLow: userSettings.alertTLow ?? null, // v1.13.0 : cible de creux (champs optionnels — rétrocompatibles) troughTargetLow: userSettings.troughTargetLow ?? null, troughTargetHigh: userSettings.troughTargetHigh ?? null, }; } return JSON.stringify(data); } /** * Parse un backup (Android v1 ou v2, ou export web) en structure validée. * * Les listes manquantes deviennent vides, les types numériques sont * normalisés (Gson accepte des entiers pour des Double) — l'import ne * vérifie pas strictement la version, comme côté Android. * * @param {string} json * @returns {{version:number, exportedAt:number|null, * treatments:object[], doseLogs:object[], labResults:object[], * tConfig:{base:number,floor:number,k:number}, * settings:object|null}} * @throws {Error} si le JSON est illisible ou n'a pas la forme d'un backup */ export function parseBackupJson(json) { let raw; try { raw = JSON.parse(json); } catch (e) { throw new Error(`JSON illisible : ${e.message}`); } if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error('Le fichier ne contient pas un backup HormoneTrack'); } if (!('treatments' in raw) && !('doseLogs' in raw) && !('labResults' in raw)) { throw new Error('Aucune donnée de backup reconnue (treatments/doseLogs/labResults absents)'); } return { version: typeof raw.version === 'number' ? raw.version : 1, exportedAt: typeof raw.exportedAt === 'number' ? raw.exportedAt : null, treatments: Array.isArray(raw.treatments) ? raw.treatments : [], doseLogs: Array.isArray(raw.doseLogs) ? raw.doseLogs : [], labResults: Array.isArray(raw.labResults) ? raw.labResults : [], // Défauts TConfig.kt si absent (backup tronqué) — comme Gson // reconstruirait TConfig() par défaut tConfig: { base: numOrNull(raw.tConfig && raw.tConfig.base) ?? 6.0, floor: numOrNull(raw.tConfig && raw.tConfig.floor) ?? 0.2, k: numOrNull(raw.tConfig && raw.tConfig.k) ?? 0.19, }, // settings absent = backup v1 → null (l'appelant ne touche pas aux réglages) settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null, }; } /** @private Convertit en nombre si possible, sinon null. */ function numOrNull(v) { const n = typeof v === 'string' ? Number(v) : v; return typeof n === 'number' && Number.isFinite(n) ? n : null; } /** * Nom de fichier d'export (miroir d'ExportFileNames.backupFileName Android) : * `hormonetrack-backup-YYYYMMDD.json` — pattern SANS heure. * * Le helper Android existait parce qu'un pattern horaire passé à LocalDate * crashait l'app (#48) ; côté web, `new Date()` porte tout — le helper reste * centralisé par discipline (une seule source pour le nom). * * @param {Date} [now=new Date()] * @returns {string} */ export function backupFileName(now = new Date()) { const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); return `hormonetrack-backup-${y}${m}${d}.json`; } /** * Nom de fichier d'export des logs de diagnostic (miroir * ExportFileNames.diagnosticLogFileName) : `hormonetrack-logs-YYYYMMDD-HHmm.txt` * — pattern AVEC heure (le fichier Android documente pourquoi : bug #48). * * @param {Date} [now=new Date()] * @returns {string} */ export function diagnosticLogFileName(now = new Date()) { const y = now.getFullYear(); const mo = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); const h = String(now.getHours()).padStart(2, '0'); const mi = String(now.getMinutes()).padStart(2, '0'); return `hormonetrack-logs-${y}${mo}${d}-${h}${mi}.txt`; }