// api_v1/scanner/scanActivitySingle.js const processActivityFile = require('./processActivityFile'); // parser FIT/GPX/TCX const { sha256 } = require('./utils'); const { log } = require('./logger'); // Se vuoi forzare il ricalcolo anche di file invariati: // ACTIVITY_FORCE_REPARSE=1 node ... const FORCE_REPARSE = process.env.ACTIVITY_FORCE_REPARSE === '1'; // Normalizza location come per le foto function normCountryCode(value) { if (!value) return undefined; const s = String(value).trim().toUpperCase(); return s.length ? s : undefined; } function normalizeLocation(location) { if (!location) return null; const countryCode = normCountryCode( location.country_code || location.countryCode || location.isoCountryCode ); const countyCode = location.county_code || location.countyCode || undefined; return { ...location, country: location.country || undefined, country_code: countryCode, county: location.county || undefined, county_code: countyCode, region: location.region || undefined, city: location.city || undefined, postcode: location.postcode || undefined, address: location.address || undefined, timezone: location.timezone || undefined, time: location.time || undefined, }; } function serializeJson(value, fallback = null) { if (value == null) return fallback; try { return JSON.stringify(value); } catch { return fallback; } } async function scanActivitySingle(db, user, cartella, f, newActivities, prefix = '') { // const fileName = f.name; const fileName = f.file_name ?? f.name ?? f.relPath ?? 'unknown'; const st = f.stat; // --------------------------------------------------------- // ID e fastHash PRIMA del parsing pesante // Deve essere identico a processActivityFile: // sha256(`${userName}/${cartella}/${fileRelPath}`) // --------------------------------------------------------- const id = sha256(`${user}/${cartella}/${f.relPath}`); const fastHash = sha256(`${st.size}-${st.mtimeMs}`); // --------------------------------------------------------- // HARD DELETED → non reimportare attività cancellate // --------------------------------------------------------- const hardDeleted = await db('deleted_hard_activities') .where({ id }) .first(); if (hardDeleted) { log(`${prefix} ♻️ Activity restored: ${fileName}`); await db('deleted_hard_activities') .where({ id }) .del(); } // --------------------------------------------------------- // Recupero precedente usando id deterministico // --------------------------------------------------------- let prev = await db('activities') .select('id', 'size_bytes', 'last_modified_millis', 'fast_hash', 'path') .where({ id }) .first(); // --------------------------------------------------------- // PATH CAMBIATO = tratta come nuovo/modificato // --------------------------------------------------------- if (prev && prev.path !== f.path) { log(`${prefix} 🟢 Nuova/Modificata activity (path changed): ${fileName}`); prev = null; } // --------------------------------------------------------- // IDENTICO → SKIP IMMEDIATO, senza parsing FIT/GPX/TCX // --------------------------------------------------------- if (prev && prev.fast_hash === fastHash && !FORCE_REPARSE) { log(`${prefix} ⏭️ Activity invariata, skip parser: ${fileName}`); // Aggiorno solo dati leggeri. Non tocco date_modified_millis, // così non genero changes inutili nel progressive sync. await db('activities') .where({ id }) .update({ path: f.path, size_bytes: st.size, last_modified_millis: st.mtimeMs, last_scan_millis: Date.now(), fast_hash: fastHash, updated_at: new Date().toISOString(), }); return null; } // --------------------------------------------------------- // Solo qui facciamo parsing completo FIT/GPX/TCX // --------------------------------------------------------- const meta = await processActivityFile( user, cartella, f.relPath, f.absPath, f.ext, st ); meta.path = f.path; meta.location = normalizeLocation(meta.location); const trackPreviewJson = serializeJson(meta.track_preview || [], null); const tagsJson = serializeJson(meta.tags || [], null); const diagnosticsJson = serializeJson(meta.diagnostics || null, null); const locationJson = serializeJson(meta.location || null, null); // --------------------------------------------------------- // NUOVA ATTIVITÀ // --------------------------------------------------------- if (!prev) { log(`${prefix} 🟢 Nuova activity: ${fileName}`); await db('activities').insert({ id: meta.id, user, cartella, file_name: meta.file_name, file_extension: meta.file_extension, format: meta.format, display_name: meta.display_name, route_name: meta.route_name, title: meta.title, description: meta.description, notes: meta.notes, sport: meta.sport, activity_type: meta.activity_type, device_manufacturer: meta.device_manufacturer, device_model: meta.device_model, source_app: meta.source_app, start_time_millis: meta.start_time_millis, end_time_millis: meta.end_time_millis, duration_millis: meta.duration_millis, distance_meters: meta.distance_meters, elevation_gain_meters: meta.elevation_gain_meters, elevation_loss_meters: meta.elevation_loss_meters, min_elevation_meters: meta.min_elevation_meters, max_elevation_meters: meta.max_elevation_meters, file_distance_meters: meta.file_distance_meters, file_elevation_gain_meters: meta.file_elevation_gain_meters, computed_distance_meters: meta.computed_distance_meters, computed_elevation_gain_meters: meta.computed_elevation_gain_meters, average_speed_mps: meta.average_speed_mps, max_speed_mps: meta.max_speed_mps, average_pace_sec_per_km: meta.average_pace_sec_per_km, average_heart_rate: meta.average_heart_rate, max_heart_rate: meta.max_heart_rate, average_cadence: meta.average_cadence, max_cadence: meta.max_cadence, calories: meta.calories, power_avg: meta.power_avg, power_max: meta.power_max, point_count: meta.point_count, segment_count: meta.segment_count, lap_count: meta.lap_count, min_latitude: meta.min_latitude, max_latitude: meta.max_latitude, min_longitude: meta.min_longitude, max_longitude: meta.max_longitude, center_latitude: meta.center_latitude, center_longitude: meta.center_longitude, size_bytes: meta.size_bytes, last_modified_millis: meta.last_modified_millis, date_added_millis: meta.date_added_millis, date_modified_millis: meta.date_modified_millis, last_scan_millis: meta.last_scan_millis, is_favorite: meta.is_favorite, is_hidden: meta.is_hidden, rating: meta.rating, tags: tagsJson, track_preview: trackPreviewJson, scan_status: meta.scan_status, scan_error: meta.scan_error, path: meta.path, diagnostics: diagnosticsJson, location: locationJson, fast_hash: fastHash, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }); newActivities.push(meta); return meta; } // --------------------------------------------------------- // MODIFICATO oppure FORCE_REPARSE // --------------------------------------------------------- if (FORCE_REPARSE) { log(`${prefix} 🔁 Force reparse activity: ${fileName}`); } else { log(`${prefix} 🟠 Modificato activity: ${fileName}`); } await db('activities') .where({ id }) .update({ file_name: meta.file_name, file_extension: meta.file_extension, format: meta.format, display_name: meta.display_name, route_name: meta.route_name, title: meta.title, description: meta.description, notes: meta.notes, sport: meta.sport, activity_type: meta.activity_type, device_manufacturer: meta.device_manufacturer, device_model: meta.device_model, source_app: meta.source_app, start_time_millis: meta.start_time_millis, end_time_millis: meta.end_time_millis, duration_millis: meta.duration_millis, distance_meters: meta.distance_meters, elevation_gain_meters: meta.elevation_gain_meters, elevation_loss_meters: meta.elevation_loss_meters, min_elevation_meters: meta.min_elevation_meters, max_elevation_meters: meta.max_elevation_meters, file_distance_meters: meta.file_distance_meters, file_elevation_gain_meters: meta.file_elevation_gain_meters, computed_distance_meters: meta.computed_distance_meters, computed_elevation_gain_meters: meta.computed_elevation_gain_meters, average_speed_mps: meta.average_speed_mps, max_speed_mps: meta.max_speed_mps, average_pace_sec_per_km: meta.average_pace_sec_per_km, average_heart_rate: meta.average_heart_rate, max_heart_rate: meta.max_heart_rate, average_cadence: meta.average_cadence, max_cadence: meta.max_cadence, calories: meta.calories, power_avg: meta.power_avg, power_max: meta.power_max, point_count: meta.point_count, segment_count: meta.segment_count, lap_count: meta.lap_count, min_latitude: meta.min_latitude, max_latitude: meta.max_latitude, min_longitude: meta.min_longitude, max_longitude: meta.max_longitude, center_latitude: meta.center_latitude, center_longitude: meta.center_longitude, size_bytes: meta.size_bytes, last_modified_millis: meta.last_modified_millis, date_added_millis: meta.date_added_millis, date_modified_millis: meta.date_modified_millis, last_scan_millis: meta.last_scan_millis, is_favorite: meta.is_favorite, is_hidden: meta.is_hidden, rating: meta.rating, tags: tagsJson, track_preview: trackPreviewJson, scan_status: meta.scan_status, scan_error: meta.scan_error, path: meta.path, diagnostics: diagnosticsJson, location: locationJson, fast_hash: fastHash, updated_at: new Date().toISOString(), }); newActivities.push(meta); return meta; } module.exports = scanActivitySingle;