first commit

This commit is contained in:
Fabio 2026-08-11 08:45:04 +02:00
commit 3b79c3ef68
363 changed files with 552621 additions and 0 deletions

1
.Fabio.last_event Normal file
View file

@ -0,0 +1 @@
1774133507738

41
.env Normal file
View file

@ -0,0 +1,41 @@
BASE_URL=https://prova.patachina.it
SERVER_PORT=4000
EMAIL=fabio@gmail.com
PASSWORD=master66
JWT_SECRET=123456789
JWT_EXPIRES=1h
# Dove si trova la cartella public (relativa alla root del progetto)
WEB_ROOT=public
# Percorso relativo di index.json dentro public/
INDEX_PATH=photos/index.json
# true = restituisce path assoluti nei record
PATH_FULL=true
# Logging
LOG_MODE=both # console | file | both
LOG_FILE=scan.log
LOG_DIR=public
LOG_VERBOSE=true
# POLLING_TIME in secondi
# Funzionamento:
# - POLLING_TIME = 0 → polling disattivato (solo WebSocket)
# - POLLING_TIME > 0 → esegue incrementalSync() ogni N secondi
# Perché esiste?
# - Serve come "rete di sicurezza" se il WebSocket cade,
# se la tab viene sospesa, o se qualche evento viene perso.
# - Google Photos, Dropbox e iCloud usano lo stesso approccio.
GALLERY_REFRESH_SECONDS=30
WS_PORT=4002
WS_HOST=0.0.0.0
PHOTO_RETENTION_DAYS=30
MAPBOX_PUBLIC_TOKEN=pk.eyJ1IjoicGlwcG9jYWwiLCJhIjoiY2xka28zdWVjMHBkZzNwbnoyMmlxYjA3MyJ9.9D5EB0SWydvAQZr-SdI64Q

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
thumbs/
db.json

BIN
IMG_0124.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
IMG_0125.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
IMG_20210602_100637.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
PIPPO.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

129
README.md Normal file
View file

@ -0,0 +1,129 @@
# Galleria con json-server e protetto con JWT S6J
## Installazione
clonare questa repo e installare tutte le dipendenze con `npm ci`
## Start/Stop servers
| Description | Script |
| ------------------------- | -------------------- |
| Start server senza auth | `npm start-no-auth` |
| Start server con auth | `npm run start` |
## Tools
| Description | Script |
| ------------------------------ | ------------------- |
| Generate user hashed passwords | `npm run hash` |
[json-server api reference](https://github.com/typicode/json-server)
## Come usarlo
clonare e poi installare con
```
npm ci
```
nel file .env ci sono tutti i dati da modificare
poi inserire in user.json user e password utilizzati per fare il login
la password da inserire è criptata e viene generata con npm run hash
il nome viene utilizzato come cartella da scansionare, si trova dentro photos
es:
```
name: Fabio
public/photos
└── Fabio
└── original
└── 2017Irlanda19-29ago
├── IMG_0092.JPG
├── IMG_0099.JPG
├── IMG_0100.JPG
```
poi dentro Fabio genererà thumbs con tutti i thumbs
- npm run start
- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto
dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione
basta farlo partire con npm run start-no-auth e le info si possono vedere con
ip:4000/photos
- npm start
---
#### FIT parse (utilizzo di python)
Per avere il parse dei fit installare python, pipx e i seguenyi packages
```
sudo apt install pipx
pipx ensurepath
pipx install fitdecode
```
usa per sapere dove è installato python
```
pipx list
```
in api_v1/scanner/parsers/parseFit.js va inserito il riferimento
in questo caso
```
async function parseFit(absPath) {
const frames = await new Promise((resolve, reject) => {
execFile(
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
["api_v1/scanner/parsers/parse_fit.py", absPath],
{ maxBuffer: 1024 * 1024 * 50 }, // 50 MB
```
Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/)
#### Interrogazioni manuali
per vedere il token
```
curl -s -X POST https://prova.patachina.it/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"fabio@gmail.com","password":"master66"}'
```
per salvare il token
```
TOKEN=$(curl -s -X POST https://prova.patachina.it/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"fabio@gmail.com","password":"master66"}' \
| jq -r '.token')
```
per interrogare le foto
```
curl -s "https://prova.patachina.it/photos/" \
-H "Authorization: Bearer $TOKEN"
```
per interrogare le attività
```
curl -s "https://prova.patachina.it/activities/" \
-H "Authorization: Bearer $TOKEN"
```

278
README_WS.md Normal file
View file

@ -0,0 +1,278 @@
Perfetto Fabio — ora ti preparo il diagramma del flusso completo, chiaro, leggibile e fedele al comportamento reale del tuo sistema.
È un diagramma architetturale, non un semplice schema: mostra tutto il ciclo di vita di una sessione WebSocket affidabile, con SQLite, retry, timeout, needfullsync e sync del client.
---
🟦 DIAGRAMMA DEL FLUSSO COMPLETO — WebSocket Affidabile + Sync
Lo divido in 6 sezioni:
1. Connessione iniziale
2. Autenticazione + session_id
3. Invio evento dal server
4. Ricezione evento dal client
5. Retry + Timeout + needfullsync
6. Riconnessione del client
---
🟩 1) CONNESSIONE INIZIALE
`
CLIENT (browser) SERVER (ws-server.js)
──────────────────────────────────────────────────────────────────────
startWebSocket()
Apre WebSocket ------------------------→ ws.on("connection")
crea ws object
ws.authenticated = false
`
---
🟩 2) AUTENTICAZIONE + SESSION_ID
`
CLIENT SERVER
──────────────────────────────────────────────────────────────────────
Invia:
{
type: "auth",
token: JWT,
session_id: <persistente>
}
↓ ws.on("message")
verifica JWT
cerca sessione in SQLite
se non esiste → createSession()
aggiorna last_ack
salva wsBySession[session_id] = ws
Riceve:
{
type: "auth_ok",
user: "...",
session_id: "...",
needfullsync: true/false
}
Se needfullsync = true → incrementalSync()
`
---
🟩 3) INVIO EVENTO DAL SERVER (broadcast affidabile)
`
SERVER
──────────────────────────────────────────────────────────────────────
broadcastToUserReliable(user, payload)
Genera event_id (UUID)
Per ogni sessione dellutente:
- Se ws attivo:
ws.send(payload + event_id)
addPendingEvent(eventid, sessionid)
- Se ws NON attivo:
setSessionNeedFullSync(session_id, true)
`
---
🟩 4) RICEZIONE EVENTO DAL CLIENT
`
CLIENT
──────────────────────────────────────────────────────────────────────
Riceve evento:
{
type: "added" | "removed" | ...,
id: "...",
event_id: "..."
}
Se event_id già in processedEvents:
- invia solo ACK
- ignora evento
- FINE
Se nuovo:
- applica evento (add/remove foto)
- aggiorna UI
- aggiunge event_id a processedEvents
- invia ACK:
{ type: "ack", event_id }
`
---
🟩 5) RETRY + TIMEOUT + NEEDFULLSYNC
`
SERVER (ogni 3 secondi)
──────────────────────────────────────────────────────────────────────
Legge da SQLite tutti gli eventi pendenti
con sent_at < now - 3000 ms
Per ogni evento:
Se retries < 40 (2 minuti):
- se ws attivo:
ws.send(payload)
updatePendingEventRetry()
- se ws NON attivo:
setSessionNeedFullSync(session_id, true)
deletePendingEvent(event_id)
Se retries >= 40:
- ws.terminate()
- wsBySession.delete(session_id)
- deleteSession(session_id) ← cancella queue
- (sessione verrà ricreata al prossimo auth)
`
---
🟩 6) RICONNESSIONE DEL CLIENT
`
CLIENT riapre la tab / si risveglia
──────────────────────────────────────────────────────────────────────
startWebSocket()
Invia auth con lo stesso session_id
SERVER:
- trova sessione in SQLite?
NO → la ricrea con needfullsync = true
SÌ → legge needfullsync
- invia authok con needfull_sync
CLIENT:
Se needfullsync = true:
incrementalSync()
Altrimenti:
continua con WS realtime
`
---
🟦 RISULTATO DEL FLUSSO
✔ Nessun evento perso
✔ Nessun evento duplicato
✔ Chrome Android non può più “morire in silenzio”
✔ Multidispositivo perfetto
✔ Queue pulita dopo 2 minuti
✔ Sync completo automatico quando serve
✔ WS leggero (ping solo ondemand)
✔ Stato persistente in SQLite
---
┌──────────────────────────┐ ┌──────────────────────────┐
│ CLIENT │ │ SERVER │
└─────────────┬────────────┘ └─────────────┬────────────┘
│ │
│ 1) Connessione WS │
├──────────────────────────────────────→│
│ │
│ 2) AUTH + session_id │
│ {type:auth, token, session_id} │
├──────────────────────────────────────→│
│ │
│ │ verifica JWT
│ │ cerca sessione in DB
│ │ crea/aggiorna sessione
│ │
│ 3) AUTH_OK │
│ ←──────────────────────────────────────┤
│ {need_full_sync:true/false} │
│ │
│ se need_full_sync → incrementalSync() │
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ 4) Ricezione evento WS │
│ {type:added/removed, event_id} │
│ ←──────────────────────────────────────┤
│ │
│ se event_id già visto → ACK │
│ se nuovo → applica evento │
│ │
│ ACK │
├──────────────────────────────────────→│
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ │ 5) Retry loop (ogni 3s)
│ │ cerca eventi pendenti
│ │
│ │ se retries < 40:
│ │ reinvia evento
│ │
│ │ se retries >= 40:
│ │ ws.terminate()
│ │ deleteSession()
│ │ (queue eliminata)
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ 6) Riconnessione client │
├──────────────────────────────────────→│
│ {auth, token, session_id} │
│ │
│ │ server vede sessione mancante
│ │ → need_full_sync = true
│ │
│ AUTH_OK │
│ ←──────────────────────────────────────┤
│ {need_full_sync:true} │
│ │
│ incrementalSync() │
│ │
──────────────┴────────────────────────────────────────┴──────────────
CLIENT → WS CONNECT
CLIENT → AUTH(token, session_id)
SERVER → AUTH_OK(need_full_sync)
EVENTO:
SERVER → EVENT(event_id)
CLIENT:
se nuovo → applica + ACK
se già visto → solo ACK
RETRY LOOP:
se retries < 40 reinvia
se retries >= 40 → chiudi WS + cancella sessione
RICONNESSIONE:
CLIENT → AUTH(session_id)
SERVER → AUTH_OK(need_full_sync=true)
CLIENT → incrementalSync()
______
AUTH → EVENTI → ACK → (retry finché vivo)
se client sparisce → timeout → cancella sessione
se torna → need_full_sync → incrementalSync

BIN
a.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

4
api_v1/admin_secret.json Normal file
View file

@ -0,0 +1,4 @@
{
"email": "admin@gmail.com",
"password": "master66"
}

35
api_v1/config.js Normal file
View file

@ -0,0 +1,35 @@
require('dotenv').config();
module.exports = {
BASE_URL: process.env.BASE_URL,
EMAIL: process.env.EMAIL,
PASSWORD: process.env.PASSWORD,
SEND_PHOTOS: (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true',
// Cartella public (root dei file statici)
WEB_ROOT: process.env.WEB_ROOT || 'public',
// PATH_FULL ora è un boolean, non un path
PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true',
// Estensioni supportate per FOTO/VIDEO
SUPPORTED_EXTS: new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.mp4', '.mov', '.m4v'
]),
// Estensioni supportate per ATTIVITÀ (FIT/GPX/TCX)
SUPPORTED_ACTIVITY_EXTS: new Set([
'.fit',
'.gpx',
'.tcx'
]),
// 🔥 NUOVO: intervallo refresh gallery (in secondi)
GALLERY_REFRESH_SECONDS: Number(process.env.GALLERY_REFRESH_SECONDS || 30),
// 🔥 PORTA SERVER
PORT: parseInt(process.env.SERVER_PORT || "4000", 10)
};

BIN
api_v1/database.sqlite Normal file

Binary file not shown.

117
api_v1/geo.js Normal file
View file

@ -0,0 +1,117 @@
const axios = require("axios");
async function loc(lng, lat) {
const primary = await place(lng, lat); // Geoapify
const fallback = await placePhoton(lng, lat); // Photon
if (!primary) return fallback;
if (!primary.city && fallback?.city) {
primary.city = fallback.city;
}
if (!primary.postcode && fallback?.postcode) {
primary.postcode = fallback.postcode;
}
if (!primary.address && fallback?.address) {
primary.address = fallback.address;
}
if (!primary.region && fallback?.region) {
primary.region = fallback.region;
}
if (!primary.country_code && fallback?.country_code) {
primary.country_code = fallback.country_code;
}
if (!primary.county && fallback?.county) {
primary.county = fallback.county;
}
if (!primary.county_code && fallback?.county_code) {
primary.county_code = fallback.county_code;
}
return primary;
}
function normCountryCode(v) {
if (!v) return undefined;
const s = String(v).trim().toUpperCase();
return s.length ? s : undefined;
}
async function place(lng, lat) {
const apiKey = "6dc7fb95a3b246cfa0f3bcef5ce9ed9a";
const url = `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lng}&apiKey=${apiKey}`;
try {
const r = await axios.get(url);
if (r.status !== 200) return undefined;
if (!r.data.features || r.data.features.length === 0) return undefined;
const k = r.data.features[0].properties;
return {
continent: k?.timezone?.name?.split("/")?.[0] || undefined,
// Country / Paese
country: k?.country || undefined,
country_code: normCountryCode(k?.country_code),
// County / contea
county: k?.county || undefined,
county_code: k?.county_code || undefined,
// Regione / stato amministrativo
region: k?.state || undefined,
postcode: k?.postcode || undefined,
city: k?.city || k?.town || k?.village || undefined,
address: k?.address_line1 || undefined,
timezone: k?.timezone?.name || undefined,
time: k?.timezone?.offset_STD || undefined,
};
} catch (err) {
return undefined;
}
}
async function placePhoton(lng, lat) {
try {
const url = `https://photon.patachina.it/reverse?lon=${lng}&lat=${lat}`;
const r = await axios.get(url);
if (!r.data || !r.data.features || r.data.features.length === 0) {
return undefined;
}
const p = r.data.features[0].properties;
return {
continent: undefined,
// Country / Paese
country: p.country || undefined,
country_code: normCountryCode(p.countrycode || p.country_code),
// County / contea, se disponibile
county: p.county || undefined,
county_code: p.county_code || undefined,
region: p.state || undefined,
postcode: p.postcode || undefined,
city: p.city || p.town || p.village || undefined,
address: p.street ? `${p.street} ${p.housenumber || ""}`.trim() : undefined,
timezone: undefined,
time: undefined,
};
} catch (err) {
return undefined;
}
}
module.exports = loc;

View file

@ -0,0 +1,64 @@
// api_v1/scanner/debugVideoDates.js
const path = require('path');
const { probeVideo } = require('./video');
async function debugVideo(absPath) {
console.log("🎥 File:", absPath);
const info = await probeVideo(absPath);
console.log("\n=== RAW probeVideo(info) ===\n");
console.dir(info, { depth: 6 });
// Estrazione campi data come nello scanner
const formatTags = info.format?.tags || {};
const stream0Tags = info.streams?.[0]?.tags || {};
const stream1Tags = info.streams?.[1]?.tags || {};
const creationFormat = formatTags.creation_time || null;
const creationStream0 = stream0Tags.creation_time || null;
const creationStream1 = stream1Tags.creation_time || null;
console.log("\n=== DATE CANDIDATE ===");
console.log("format.tags.creation_time :", creationFormat);
console.log("streams[0].tags.creation_time:", creationStream0);
console.log("streams[1].tags.creation_time:", creationStream1);
// Simulazione logica attuale dello scanner (ma esplicita)
let videoDate =
creationFormat ||
creationStream0 ||
creationStream1 ||
null;
console.log("\nScelta videoDate (prima di qualsiasi conversione):", videoDate);
if (videoDate) {
// Variante 1: SENZA conversione (quella che ti ho suggerito)
const takenAtRaw = videoDate;
// Variante 2: CON conversione (quella che ti sballa il giorno)
const takenAtIso = new Date(videoDate).toISOString();
console.log("\n=== CONFRONTO ===");
console.log("takenAtRaw (usato così com'è) :", takenAtRaw);
console.log("takenAtIso (new Date().toISOString):", takenAtIso);
} else {
console.log("\n⚠ Nessuna data trovata nei tag video.");
}
}
async function main() {
const arg = process.argv[2];
if (!arg) {
console.error("Uso: node debugVideoDates.js /percorso/al/video.mp4");
process.exit(1);
}
const absPath = path.resolve(arg);
await debugVideo(absPath);
}
main().catch(err => {
console.error("❌ Errore debugVideoDates:", err);
});

View file

@ -0,0 +1,73 @@
// api_v1/scanner/deleteFolder.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT } = require('../config');
module.exports = function createDeleteFolderFunctions(db, RETENTION_DAYS = 30) {
const createCleanupFunctions = require('./orphanCleanup');
const { deleteFromDB, deleteThumbsById } = createCleanupFunctions(db, RETENTION_DAYS);
/**
* Cancella una cartella per un utente:
* - soft delete immediato di tutte le foto
* - hard delete se retention scaduta
* - registra hard delete in deleted_hard
* - elimina thumbs solo per hard delete
*/
async function deleteFolderForUser(userName, folderName) {
// 1) Recupera tutti gli ID nel DB
const rows = await db('photos')
.where({ user: userName, cartella: folderName })
.select('id');
let softCount = 0;
let hardCount = 0;
// 2) Soft delete / Hard delete per ogni foto
for (const r of rows) {
const id = r.id;
const result = await deleteFromDB(id, userName);
if (result === true) {
// deleteFromDB decide se è soft o hard
// possiamo verificare se la foto esiste ancora nel DB
const stillExists = await db("photos")
.where({ id, user: userName })
.first();
if (stillExists) {
softCount++;
} else {
hardCount++;
}
}
}
// 3) Cancella la cartella thumbs residua (solo se hard delete totale)
const thumbsDir = path.join(
WEB_ROOT,
userName,
"thumbs",
folderName
);
try {
await fsp.rm(thumbsDir, { recursive: true, force: true });
console.log(`🧹 Rimossa cartella thumbs residua: ${thumbsDir}`);
} catch (err) {
console.log(`⚠️ Errore rimozione thumbs dir: ${thumbsDir}`, err);
}
return {
totalPhotos: rows.length,
softDeleted: softCount,
hardDeleted: hardCount,
removedThumbsDir: thumbsDir
};
}
return { deleteFolderForUser };
};

View file

@ -0,0 +1,19 @@
// scanner/deleteWithAuth.js
const { API_KEY } = require('../config');
module.exports = async function deleteWithAuth(url) {
// import dinamico compatibile con CommonJS
const fetch = (await import('node-fetch')).default;
const res = await fetch(url, {
method: 'DELETE',
headers: {
'x-api-key': API_KEY,
'Content-Type': 'application/json'
}
});
if (!res.ok) {
throw new Error(`DELETE failed: ${res.status}`);
}
};

View file

@ -0,0 +1,81 @@
// api_v1/scanner/elevation.js
// ---------------------------------------------------------
// Provider 1: OpenElevation
// ---------------------------------------------------------
async function tryOpenElevation(lat, lon) {
try {
const url = `https://api.open-elevation.com/api/v1/lookup?locations=${lat},${lon}`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
console.log("OpenElevation status:", res.status);
return null;
}
const text = await res.text();
// Se non è JSON → errore HTML → fallback
if (!text.startsWith('{')) {
console.log("OpenElevation non-JSON:", text.slice(0, 60));
return null;
}
const data = JSON.parse(text);
return data?.results?.[0]?.elevation ?? null;
} catch (err) {
console.log("Errore OpenElevation:", err.message);
return null;
}
}
// ---------------------------------------------------------
// Provider 2: OpenTopoData (fallback)
// ---------------------------------------------------------
async function tryOpenTopoData(lat, lon) {
try {
const url = `https://api.opentopodata.org/v1/eudem25m?locations=${lat},${lon}`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
console.log("OpenTopoData status:", res.status);
return null;
}
const text = await res.text();
if (!text.startsWith('{')) {
console.log("OpenTopoData non-JSON:", text.slice(0, 60));
return null;
}
const data = JSON.parse(text);
return data?.results?.[0]?.elevation ?? null;
} catch (err) {
console.log("Errore OpenTopoData:", err.message);
return null;
}
}
// ---------------------------------------------------------
// Funzione principale con fallback
// ---------------------------------------------------------
async function getElevation(lat, lon) {
// 1⃣ Prova OpenElevation
const elev1 = await tryOpenElevation(lat, lon);
if (elev1 !== null) return elev1;
console.log("⚠️ OpenElevation fallito → uso OpenTopoData");
// 2⃣ Prova OpenTopoData
const elev2 = await tryOpenTopoData(lat, lon);
if (elev2 !== null) return elev2;
console.log("❌ Nessun provider di elevazione disponibile");
return null;
}
module.exports = getElevation;

69
api_v1/scanner/gps.js Normal file
View file

@ -0,0 +1,69 @@
const { exec } = require('child_process');
// -----------------------------------------------------------------------------
// FOTO: GPS da ExifReader
// -----------------------------------------------------------------------------
function extractGpsFromExif(tags) {
if (!tags?.gps) return null;
const lat = tags.gps.Latitude;
const lng = tags.gps.Longitude;
const alt = tags.gps.Altitude;
if (lat == null || lng == null) return null;
return {
lat: Number(lat),
lng: Number(lng),
alt: alt != null ? Number(alt) : null
};
}
// -----------------------------------------------------------------------------
// VIDEO: GPS via exiftool (VERSIONE ORIGINALE CHE FUNZIONA)
// -----------------------------------------------------------------------------
function extractGpsWithExiftool(videoPath) {
//console.log(videoPath);
return new Promise((resolve) => {
const cmd = `exiftool -n -G1 -a -gps:all -quicktime:all -user:all "${videoPath}"`;
exec(cmd, (err, stdout) => {
if (err || !stdout) return resolve(null);
// 1) GPS Coordinates : <lat> <lng>
const userData = stdout.match(/GPS Coordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
if (userData) {
return resolve({
lat: Number(userData[1]),
lng: Number(userData[2]),
alt: null
});
}
// 2) GPSLatitude / GPSLongitude
const lat1 = stdout.match(/GPSLatitude\s*:\s*([0-9.\-]+)/i);
const lng1 = stdout.match(/GPSLongitude\s*:\s*([0-9.\-]+)/i);
if (lat1 && lng1) {
return resolve({
lat: Number(lat1[1]),
lng: Number(lng1[1]),
alt: null
});
}
// 3) GPSCoordinates : <lat> <lng>
const coords = stdout.match(/GPSCoordinates\s*:\s*([0-9.\-]+)\s+([0-9.\-]+)/i);
if (coords) {
return resolve({
lat: Number(coords[1]),
lng: Number(coords[2]),
alt: null
});
}
resolve(null);
});
});
}
module.exports = { extractGpsFromExif, extractGpsWithExiftool };

43
api_v1/scanner/logger.js Normal file
View file

@ -0,0 +1,43 @@
// scanner/logger.js
const fs = require('fs');
const path = require('path');
const LOG_MODE = process.env.LOG_MODE || "console"; // console | file | both
const LOG_DIR = process.env.LOG_DIR || null;
const LOG_FILE = process.env.LOG_FILE || "scan.log";
let stream = null;
function resolveLogPath() {
if (LOG_DIR) {
return path.resolve(__dirname, "..", "..", LOG_DIR, LOG_FILE);
}
return path.resolve(__dirname, "..", "..", LOG_FILE);
}
if (LOG_MODE === "file" || LOG_MODE === "both") {
const logPath = resolveLogPath();
// crea la directory se non esiste
fs.mkdirSync(path.dirname(logPath), { recursive: true });
stream = fs.createWriteStream(logPath, { flags: "a" });
}
function ts() {
return new Date().toISOString().replace("T", " ").split(".")[0];
}
function log(message) {
const line = `${ts()} ${message}\n`;
if (LOG_MODE === "console" || LOG_MODE === "both") {
process.stdout.write(line);
}
if (LOG_MODE === "file" || LOG_MODE === "both") {
stream.write(line);
}
}
module.exports = { log };

View file

@ -0,0 +1,112 @@
// api_v1/scanner/orphanCleanup.js
const fsp = require('fs/promises');
const path = require('path');
module.exports = function createCleanupFunctions(db, RETENTION_DAYS = 30) {
// 1) Recupera gli ID dal DB per una cartella specifica
async function buildIdsListForFolder(userName, cartella) {
try {
const rows = await db("photos")
.select("id")
.where({ user: userName, cartella });
return rows.map(r => r.id);
} catch (err) {
console.error("Errore buildIdsListForFolder:", err);
return [];
}
}
// 2) Rimuove un ID dalla lista (invariati)
function removeIdFromList(idsIndex, id) {
return idsIndex.filter(x => x !== id);
}
// 3) Cancella thumbs dal filesystem
async function deleteThumbsById(id) {
const rec = await db('photos').where({ id }).first();
if (!rec) return false;
const thumbs = [rec.thub1, rec.thub2].filter(Boolean);
let deleted = false;
for (const t of thumbs) {
const abs = path.resolve(__dirname, '..', '..', 'public', t);
// 🔍 Log del path reale
console.log(` 📁 Tentativo cancellazione thumb: ${abs}`);
try {
await fsp.rm(abs, { force: true });
console.log(` 🔴 Thumb eliminato: ${abs}`);
deleted = true;
} catch {}
}
return deleted;
}
// 4) Soft delete + Hard delete con retention
async function deleteFromDB(id, userName) {
const now = new Date();
const nowIso = now.toISOString();
// Recupera record attuale
const rec = await db("photos").where({ id, user: userName }).first();
if (!rec) {
console.log(`⚠️ deleteFromDB: foto ${id} non trovata`);
return false;
}
// Se NON era già soft-deleted → SOFT DELETE
if (!rec.deleted_at) {
await db("photos")
.where({ id, user: userName })
.update({
deleted_at: nowIso,
updated_at: nowIso
});
console.log(`🟡 Soft delete → id=${id}`);
return true;
}
// Se ERA già soft-deleted → controlla retention
const deletedAt = new Date(rec.deleted_at);
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
const shouldHardDelete = now - deletedAt > retentionMs;
if (!shouldHardDelete) {
console.log(`🟡 Soft delete già presente → id=${id} (in retention)`);
return true;
}
// 🔥 HARD DELETE
console.log(`🔴 HARD DELETE → id=${id}`);
// 1) Elimina thumbs
await deleteThumbsById(id);
// 2) Elimina record dal DB
await db("photos").where({ id, user: userName }).del();
// 3) Registra hard delete per progressive sync
await db("deleted_hard").insert({
id,
user: userName,
deleted_at: nowIso
});
return true;
}
return {
buildIdsListForFolder,
removeIdFromList,
deleteThumbsById,
deleteFromDB
};
};

View file

@ -0,0 +1,100 @@
// api_v1/scanner/parseActivityFile.js
const path = require('path');
// Parser specifici
const parseFit = require('./parsers/parseFit');
const parseGpx = require('./parsers/parseGpx');
const parseTcx = require('./parsers/parseTcx');
// ---------------------------------------------------------
// parseActivityFile → rileva tipo e chiama parser specifico
// ---------------------------------------------------------
async function parseActivityFile(absPath) {
const ext = path.extname(absPath).toLowerCase();
let parsed;
if (ext === '.fit') {
parsed = await parseFit(absPath);
} else if (ext === '.gpx') {
parsed = await parseGpx(absPath);
} else if (ext === '.tcx') {
parsed = await parseTcx(absPath);
} else {
throw new Error(`Formato attività non supportato: ${ext}`);
}
// ---------------------------------------------------------
// Normalizzazione campi base
// ---------------------------------------------------------
const fileName = path.basename(absPath);
const fileExtension = ext.replace('.', '');
return {
file_name: fileName,
file_extension: fileExtension,
format: parsed.format || fileExtension.toUpperCase(),
display_name: parsed.display_name || fileName,
route_name: parsed.route_name || null,
title: parsed.title || null,
description: parsed.description || null,
notes: parsed.notes || null,
sport: parsed.sport || null,
activity_type: parsed.activity_type || null,
device_manufacturer: parsed.device_manufacturer || null,
device_model: parsed.device_model || null,
source_app: parsed.source_app || null,
start_time_millis: parsed.start_time_millis ?? null,
end_time_millis: parsed.end_time_millis ?? null,
duration_millis: parsed.duration_millis ?? null,
distance_meters: parsed.distance_meters ?? null,
elevation_gain_meters: parsed.elevation_gain_meters ?? null,
elevation_loss_meters: parsed.elevation_loss_meters ?? null,
min_elevation_meters: parsed.min_elevation_meters ?? null,
max_elevation_meters: parsed.max_elevation_meters ?? null,
file_distance_meters: parsed.file_distance_meters ?? null,
file_elevation_gain_meters: parsed.file_elevation_gain_meters ?? null,
computed_distance_meters: parsed.computed_distance_meters ?? null,
computed_elevation_gain_meters: parsed.computed_elevation_gain_meters ?? null,
average_speed_mps: parsed.average_speed_mps ?? null,
max_speed_mps: parsed.max_speed_mps ?? null,
average_pace_sec_per_km: parsed.average_pace_sec_per_km ?? null,
average_heart_rate: parsed.average_heart_rate ?? null,
max_heart_rate: parsed.max_heart_rate ?? null,
average_cadence: parsed.average_cadence ?? null,
max_cadence: parsed.max_cadence ?? null,
calories: parsed.calories ?? null,
power_avg: parsed.power_avg ?? null,
power_max: parsed.power_max ?? null,
point_count: parsed.point_count ?? 0,
segment_count: parsed.segment_count ?? 0,
lap_count: parsed.lap_count ?? 0,
min_latitude: parsed.min_latitude ?? null,
max_latitude: parsed.max_latitude ?? null,
min_longitude: parsed.min_longitude ?? null,
max_longitude: parsed.max_longitude ?? null,
center_latitude: parsed.center_latitude ?? null,
center_longitude: parsed.center_longitude ?? null,
tags: parsed.tags || [],
// IMPORTANTISSIMO: preview percorso per thumb gallery
track_preview: parsed.track_preview || [],
diagnostics: parsed.diagnostics || null,
};
}
module.exports = parseActivityFile;

View file

@ -0,0 +1,277 @@
// api_v1/scanner/parsers/parseFit.js
const { execFile } = require("child_process");
// FIT position_lat / position_long sono in semicircles.
// Conversione ufficiale: degrees = semicircles * 180 / 2^31
function fitSemicirclesToDegrees(value) {
if (value == null) return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return n * (180 / 2147483648);
}
function buildTrackPreviewFromFitRecords(records, maxPoints = 300) {
if (!Array.isArray(records)) return [];
const points = [];
for (const r of records) {
const f = r.fields || {};
const rawLat =
f.position_lat ??
f.latitude ??
f.lat;
const rawLng =
f.position_long ??
f.position_lon ??
f.longitude ??
f.lng ??
f.lon;
const lat = fitSemicirclesToDegrees(rawLat);
const lng = fitSemicirclesToDegrees(rawLng);
const altRaw =
f.altitude ??
f.enhanced_altitude;
const alt = altRaw != null ? Number(altRaw) : null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const point = { lat, lng };
if (Number.isFinite(alt)) {
point.alt = alt;
}
points.push(point);
}
if (points.length <= maxPoints) return points;
const step = Math.ceil(points.length / maxPoints);
const preview = [];
for (let i = 0; i < points.length; i += step) {
preview.push(points[i]);
}
const last = points[points.length - 1];
const currentLast = preview[preview.length - 1];
if (
!currentLast ||
currentLast.lat !== last.lat ||
currentLast.lng !== last.lng
) {
preview.push(last);
}
return preview;
}
async function parseFit(absPath) {
const frames = await new Promise((resolve, reject) => {
execFile(
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
["api_v1/scanner/parsers/parse_fit.py", absPath],
{ maxBuffer: 1024 * 1024 * 50 },
(err, stdout) => {
if (err) return reject(err);
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(new Error("Errore nel parsing JSON FIT: " + e.message));
}
}
);
});
// -------------------------------
// 1. Trova i frame principali
// -------------------------------
const sessions = frames.filter(f => f.name === "session");
const laps = frames.filter(f => f.name === "lap");
const records = frames.filter(f => f.name === "record");
const fileId = frames.find(f => f.name === "file_id");
const session = sessions[0]?.fields || {};
const file = fileId?.fields || {};
// -------------------------------
// 2. Tempi
// -------------------------------
const startTime = session.start_time || session.start_time_local || null;
const endTime = session.total_timer_time && startTime
? new Date(new Date(startTime).getTime() + session.total_timer_time * 1000)
: null;
const startMillis = startTime ? new Date(startTime).getTime() : null;
const endMillis = endTime ? endTime.getTime() : null;
const durationMillis = session.total_timer_time
? session.total_timer_time * 1000
: endMillis && startMillis
? endMillis - startMillis
: null;
// -------------------------------
// 3. Distanza / Elevazione
// -------------------------------
const distance = session.total_distance || null;
const elevGain = session.total_ascent || null;
const elevLoss = session.total_descent || null;
// -------------------------------
// 4. HR / Cadenza / Velocità
// -------------------------------
const avgHr = session.avg_heart_rate || null;
const maxHr = session.max_heart_rate || null;
const avgCad = session.avg_cadence || null;
const maxCad = session.max_cadence || null;
const avgSpeed = session.avg_speed || null;
const maxSpeed = session.max_speed || null;
// -------------------------------
// 5. Bounding box GPS
// -------------------------------
let minLat = null;
let maxLat = null;
let minLon = null;
let maxLon = null;
let minElev = null;
let maxElev = null;
for (const r of records) {
const f = r.fields || {};
const lat = fitSemicirclesToDegrees(f.position_lat);
const lon = fitSemicirclesToDegrees(f.position_long);
const eleRaw =
f.altitude ??
f.enhanced_altitude;
const ele = eleRaw != null ? Number(eleRaw) : null;
if (Number.isFinite(lat) && Number.isFinite(lon)) {
minLat = minLat == null ? lat : Math.min(minLat, lat);
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
minLon = minLon == null ? lon : Math.min(minLon, lon);
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
}
if (Number.isFinite(ele)) {
minElev = minElev == null ? ele : Math.min(minElev, ele);
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
}
}
const centerLat =
minLat != null && maxLat != null ? (minLat + maxLat) / 2 : null;
const centerLon =
minLon != null && maxLon != null ? (minLon + maxLon) / 2 : null;
// -------------------------------
// 6. Track preview per gallery
// -------------------------------
const trackPreview = buildTrackPreviewFromFitRecords(records, 300);
// Log utile temporaneo. Puoi rimuoverlo dopo il test.
console.log("[parseFit] track preview", {
file: absPath,
records: records.length,
preview: trackPreview.length,
first: trackPreview[0],
minLat,
maxLat,
minLon,
maxLon,
});
// -------------------------------
// 7. Costruzione oggetto attività finale
// -------------------------------
return {
format: "FIT",
display_name: session.sport || null,
route_name: session.sport || null,
title: session.sport || null,
description: null,
notes: null,
sport: session.sport || null,
activity_type: session.sub_sport || null,
device_manufacturer: file.manufacturer || null,
device_model: file.product || null,
source_app: null,
start_time_millis: startMillis,
end_time_millis: endMillis,
duration_millis: durationMillis,
distance_meters: distance,
elevation_gain_meters: elevGain,
elevation_loss_meters: elevLoss,
min_elevation_meters: minElev,
max_elevation_meters: maxElev,
file_distance_meters: distance,
file_elevation_gain_meters: elevGain,
computed_distance_meters: distance,
computed_elevation_gain_meters: elevGain,
average_speed_mps: avgSpeed,
max_speed_mps: maxSpeed,
average_pace_sec_per_km: avgSpeed ? 1000 / avgSpeed : null,
average_heart_rate: avgHr,
max_heart_rate: maxHr,
average_cadence: avgCad,
max_cadence: maxCad,
calories: session.total_calories || null,
power_avg: session.avg_power || null,
power_max: session.max_power || null,
point_count: records.length,
segment_count: laps.length,
lap_count: laps.length,
min_latitude: minLat,
max_latitude: maxLat,
min_longitude: minLon,
max_longitude: maxLon,
center_latitude: centerLat,
center_longitude: centerLon,
// Preview ridotta del percorso per i thumb della gallery
track_preview: trackPreview,
tags: [],
diagnostics: {
source: "FIT",
sessions: sessions.length,
laps: laps.length,
records: records.length,
track_preview_points: trackPreview.length,
coordinates_converted_from_semicircles: true,
},
};
}
module.exports = parseFit;

View file

@ -0,0 +1,204 @@
// api_v1/scanner/parsers/parseGpx.js
const fsp = require('fs/promises');
const xml2js = require('xml2js'); // npm install xml2js
function toMillis(date) {
if (!date) return null;
const d = new Date(date);
return isNaN(d.getTime()) ? null : d.getTime();
}
function buildTrackPreviewFromGpxPoints(points, maxPoints = 300) {
if (!Array.isArray(points)) return [];
const clean = [];
for (const p of points) {
const lat = p.$?.lat != null ? Number(p.$.lat) : null;
const lng = p.$?.lon != null ? Number(p.$.lon) : null;
const alt = p.ele != null ? Number(p.ele) : null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const point = { lat, lng };
if (Number.isFinite(alt)) {
point.alt = alt;
}
clean.push(point);
}
if (clean.length <= maxPoints) return clean;
const step = Math.ceil(clean.length / maxPoints);
const preview = [];
for (let i = 0; i < clean.length; i += step) {
preview.push(clean[i]);
}
const last = clean[clean.length - 1];
const currentLast = preview[preview.length - 1];
if (
!currentLast ||
currentLast.lat !== last.lat ||
currentLast.lng !== last.lng
) {
preview.push(last);
}
return preview;
}
async function parseGpx(absPath) {
const xml = await fsp.readFile(absPath, 'utf8');
const parser = new xml2js.Parser({ explicitArray: false });
const gpx = await parser.parseStringPromise(xml);
const root = gpx.gpx || gpx;
const trk = Array.isArray(root.trk) ? root.trk[0] : root.trk;
const name = trk?.name || null;
const segments = trk?.trkseg
? (Array.isArray(trk.trkseg) ? trk.trkseg : [trk.trkseg])
: [];
const points = [];
for (const seg of segments) {
const segPts = seg.trkpt
? (Array.isArray(seg.trkpt) ? seg.trkpt : [seg.trkpt])
: [];
points.push(...segPts);
}
let minLat = null;
let maxLat = null;
let minLon = null;
let maxLon = null;
let minElev = null;
let maxElev = null;
let startTime = null;
let endTime = null;
let distance = null;
for (const p of points) {
const lat = p.$?.lat != null ? Number(p.$.lat) : null;
const lon = p.$?.lon != null ? Number(p.$.lon) : null;
const ele = p.ele != null ? Number(p.ele) : null;
const time = p.time || null;
if (Number.isFinite(lat) && Number.isFinite(lon)) {
minLat = minLat == null ? lat : Math.min(minLat, lat);
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
minLon = minLon == null ? lon : Math.min(minLon, lon);
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
}
if (Number.isFinite(ele)) {
minElev = minElev == null ? ele : Math.min(minElev, ele);
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
}
if (time) {
if (!startTime) startTime = time;
endTime = time;
}
}
const start_time_millis = toMillis(startTime);
const end_time_millis = toMillis(endTime);
const duration_millis =
start_time_millis && end_time_millis
? end_time_millis - start_time_millis
: null;
const centerLat =
minLat != null && maxLat != null
? (minLat + maxLat) / 2
: null;
const centerLon =
minLon != null && maxLon != null
? (minLon + maxLon) / 2
: null;
const trackPreview = buildTrackPreviewFromGpxPoints(points, 300);
return {
format: 'GPX',
display_name: name,
route_name: name,
title: name,
description: root.metadata?.desc || null,
notes: null,
sport: root.metadata?.type || null,
activity_type: null,
device_manufacturer: root.metadata?.author?.name || null,
device_model: null,
source_app: root.metadata?.author?.name || null,
start_time_millis,
end_time_millis,
duration_millis,
distance_meters: distance,
elevation_gain_meters: null,
elevation_loss_meters: null,
min_elevation_meters: minElev,
max_elevation_meters: maxElev,
file_distance_meters: distance,
file_elevation_gain_meters: null,
computed_distance_meters: distance,
computed_elevation_gain_meters: null,
average_speed_mps: null,
max_speed_mps: null,
average_pace_sec_per_km: null,
average_heart_rate: null,
max_heart_rate: null,
average_cadence: null,
max_cadence: null,
calories: null,
power_avg: null,
power_max: null,
point_count: points.length,
segment_count: segments.length,
lap_count: 0,
min_latitude: minLat,
max_latitude: maxLat,
min_longitude: minLon,
max_longitude: maxLon,
center_latitude: centerLat,
center_longitude: centerLon,
// Preview ridotta del percorso per i thumb della gallery
track_preview: trackPreview,
tags: [],
diagnostics: {
source: 'GPX',
segments: segments.length,
points: points.length,
track_preview_points: trackPreview.length
}
};
}
module.exports = parseGpx;

View file

@ -0,0 +1,298 @@
// api_v1/scanner/parsers/parseTcx.js
const fsp = require('fs/promises');
const xml2js = require('xml2js');
function toMillis(date) {
if (!date) return null;
const d = new Date(date);
return isNaN(d.getTime()) ? null : d.getTime();
}
function buildTrackPreviewFromTcxPoints(points, maxPoints = 300) {
if (!Array.isArray(points)) return [];
const clean = [];
for (const p of points) {
const pos = p.Position || {};
const lat = pos.LatitudeDegrees != null
? Number(pos.LatitudeDegrees)
: null;
const lng = pos.LongitudeDegrees != null
? Number(pos.LongitudeDegrees)
: null;
const alt = p.AltitudeMeters != null
? Number(p.AltitudeMeters)
: null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const point = { lat, lng };
if (Number.isFinite(alt)) {
point.alt = alt;
}
clean.push(point);
}
if (clean.length <= maxPoints) return clean;
const step = Math.ceil(clean.length / maxPoints);
const preview = [];
for (let i = 0; i < clean.length; i += step) {
preview.push(clean[i]);
}
const last = clean[clean.length - 1];
const currentLast = preview[preview.length - 1];
if (
!currentLast ||
currentLast.lat !== last.lat ||
currentLast.lng !== last.lng
) {
preview.push(last);
}
return preview;
}
async function parseTcx(absPath) {
const xml = await fsp.readFile(absPath, 'utf8');
const parser = new xml2js.Parser({ explicitArray: false });
const tcx = await parser.parseStringPromise(xml);
const root = tcx.TrainingCenterDatabase || tcx;
const activities = root.Activities?.Activity
? (
Array.isArray(root.Activities.Activity)
? root.Activities.Activity
: [root.Activities.Activity]
)
: [];
const activity = activities[0] || {};
const sport = activity.$?.Sport || null;
const laps = activity.Lap
? (
Array.isArray(activity.Lap)
? activity.Lap
: [activity.Lap]
)
: [];
const tracks = [];
for (const lap of laps) {
const track = lap.Track
? (
Array.isArray(lap.Track)
? lap.Track
: [lap.Track]
)
: [];
tracks.push(...track);
}
const points = [];
for (const t of tracks) {
const tp = t.Trackpoint
? (
Array.isArray(t.Trackpoint)
? t.Trackpoint
: [t.Trackpoint]
)
: [];
points.push(...tp);
}
let minLat = null;
let maxLat = null;
let minLon = null;
let maxLon = null;
let minElev = null;
let maxElev = null;
let startTime = null;
let endTime = null;
let totalDistance = 0;
let totalCalories = 0;
let avgHr = null;
let maxHr = null;
for (const lap of laps) {
if (!startTime && lap.StartTime) {
startTime = lap.StartTime;
}
if (lap.TotalTimeSeconds && lap.StartTime) {
const lapEnd = new Date(lap.StartTime);
lapEnd.setSeconds(lapEnd.getSeconds() + Number(lap.TotalTimeSeconds));
endTime = lapEnd.toISOString();
}
if (lap.DistanceMeters) {
totalDistance += Number(lap.DistanceMeters);
}
if (lap.Calories) {
totalCalories += Number(lap.Calories);
}
const lapAvgHr = lap.AverageHeartRateBpm?.Value
? Number(lap.AverageHeartRateBpm.Value)
: null;
const lapMaxHr = lap.MaximumHeartRateBpm?.Value
? Number(lap.MaximumHeartRateBpm.Value)
: null;
if (Number.isFinite(lapAvgHr)) {
avgHr = avgHr == null ? lapAvgHr : (avgHr + lapAvgHr) / 2;
}
if (Number.isFinite(lapMaxHr)) {
maxHr = maxHr == null ? lapMaxHr : Math.max(maxHr, lapMaxHr);
}
}
for (const p of points) {
const pos = p.Position || {};
const lat = pos.LatitudeDegrees != null
? Number(pos.LatitudeDegrees)
: null;
const lon = pos.LongitudeDegrees != null
? Number(pos.LongitudeDegrees)
: null;
const ele = p.AltitudeMeters != null
? Number(p.AltitudeMeters)
: null;
const time = p.Time || null;
if (Number.isFinite(lat) && Number.isFinite(lon)) {
minLat = minLat == null ? lat : Math.min(minLat, lat);
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
minLon = minLon == null ? lon : Math.min(minLon, lon);
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
}
if (Number.isFinite(ele)) {
minElev = minElev == null ? ele : Math.min(minElev, ele);
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
}
if (time) {
if (!startTime) startTime = time;
endTime = time;
}
}
const start_time_millis = toMillis(startTime);
const end_time_millis = toMillis(endTime);
const duration_millis =
start_time_millis && end_time_millis
? end_time_millis - start_time_millis
: null;
const centerLat =
minLat != null && maxLat != null
? (minLat + maxLat) / 2
: null;
const centerLon =
minLon != null && maxLon != null
? (minLon + maxLon) / 2
: null;
const trackPreview = buildTrackPreviewFromTcxPoints(points, 300);
return {
format: 'TCX',
display_name: activity.Notes || null,
route_name: activity.Notes || null,
title: activity.Notes || null,
description: null,
notes: activity.Notes || null,
sport,
activity_type: null,
device_manufacturer: root.Author?.Name || null,
device_model: root.Author?.Build?.Version?.VersionMajor || null,
source_app: root.Author?.Name || null,
start_time_millis,
end_time_millis,
duration_millis,
distance_meters: totalDistance || null,
elevation_gain_meters: null,
elevation_loss_meters: null,
min_elevation_meters: minElev,
max_elevation_meters: maxElev,
file_distance_meters: totalDistance || null,
file_elevation_gain_meters: null,
computed_distance_meters: totalDistance || null,
computed_elevation_gain_meters: null,
average_speed_mps: null,
max_speed_mps: null,
average_pace_sec_per_km: null,
average_heart_rate: avgHr,
max_heart_rate: maxHr,
average_cadence: null,
max_cadence: null,
calories: totalCalories || null,
power_avg: null,
power_max: null,
point_count: points.length,
segment_count: tracks.length,
lap_count: laps.length,
min_latitude: minLat,
max_latitude: maxLat,
min_longitude: minLon,
max_longitude: maxLon,
center_latitude: centerLat,
center_longitude: centerLon,
// Preview ridotta del percorso per i thumb della gallery
track_preview: trackPreview,
tags: [],
diagnostics: {
source: 'TCX',
laps: laps.length,
tracks: tracks.length,
points: points.length,
track_preview_points: trackPreview.length
}
};
}
module.exports = parseTcx;

View file

@ -0,0 +1,38 @@
# parse_fit.py
import fitdecode
import json
import sys
from datetime import datetime
path = sys.argv[1]
def to_json_safe(value):
# Converte datetime → stringa ISO
if isinstance(value, datetime):
return value.isoformat()
# Converte liste/tuple ricorsivamente
if isinstance(value, (list, tuple)):
return [to_json_safe(v) for v in value]
# Converte dict ricorsivamente
if isinstance(value, dict):
return {k: to_json_safe(v) for k, v in value.items()}
# Tutto il resto viene restituito così com'è
return value
frames = []
with fitdecode.FitReader(path) as fit:
for frame in fit:
if isinstance(frame, fitdecode.FitDataMessage):
msg = {
"name": frame.name,
"fields": {}
}
for field in frame.fields:
msg["fields"][field.name] = to_json_safe(field.value)
frames.append(msg)
print(json.dumps(frames))

View file

@ -0,0 +1,24 @@
// scanner/postWithAuth.js
// Versione locale: niente HTTP, niente token, solo DB
module.exports = function createPostToDB(db) {
/**
* Inserisce o aggiorna un record nel DB SQLite
* (sostituisce completamente axios.post)
*/
async function postToDB(record) {
if (!record || !record.id) {
throw new Error("Record non valido");
}
await db('photos')
.insert(record)
.onConflict('id')
.merge();
return true;
}
return postToDB;
};

View file

@ -0,0 +1,190 @@
// api_v1/scanner/processActivityFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { log } = require('./logger');
const parseActivityFile = require('./parseActivityFile'); // 🔥 parser FIT/GPX/TCX
const loc = require('../geo.js');
const getElevation = require('./elevation');
const { WEB_ROOT, PATH_FULL } = require('../config');
// ---------------------------------------------------------
// processActivityFile → equivalente di processFile per le attività
// ---------------------------------------------------------
async function processActivityFile(userName, cartella, fileRelPath, absPath, ext, st) {
// 🔥 PARSING FIT/GPX/TCX
const parsed = await parseActivityFile(absPath);
// parsed contiene:
// {
// file_name,
// file_extension,
// format,
// display_name,
// route_name,
// title,
// description,
// notes,
// sport,
// activity_type,
// device_manufacturer,
// device_model,
// source_app,
// start_time_millis,
// end_time_millis,
// duration_millis,
// distance_meters,
// elevation_gain_meters,
// elevation_loss_meters,
// min_elevation_meters,
// max_elevation_meters,
// file_distance_meters,
// file_elevation_gain_meters,
// computed_distance_meters,
// computed_elevation_gain_meters,
// average_speed_mps,
// max_speed_mps,
// average_pace_sec_per_km,
// average_heart_rate,
// max_heart_rate,
// average_cadence,
// max_cadence,
// calories,
// power_avg,
// power_max,
// point_count,
// segment_count,
// lap_count,
// min_latitude,
// max_latitude,
// min_longitude,
// max_longitude,
// center_latitude,
// center_longitude,
// tags,
// diagnostics
// }
// ---------------------------------------------------------
// GPS → se manca altitudine, la calcoliamo
// ---------------------------------------------------------
let gps = null;
if (parsed.center_latitude != null && parsed.center_longitude != null) {
gps = {
lat: parsed.center_latitude,
lng: parsed.center_longitude,
alt: parsed.min_elevation_meters ?? null
};
if (gps.alt == null) {
gps.alt = await getElevation(gps.lat, gps.lng);
}
}
// ---------------------------------------------------------
// LOCATION → come per le foto
// ---------------------------------------------------------
const location = gps ? await loc(gps.lng, gps.lat) : null;
// ---------------------------------------------------------
// PATH FULL / RELATIVI
// ---------------------------------------------------------
const relPath = fileRelPath;
const fullPath = PATH_FULL
? path.posix.join('/activities', userName, cartella, fileRelPath)
: relPath;
// ---------------------------------------------------------
// ID deterministico
// ---------------------------------------------------------
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
// ---------------------------------------------------------
// COSTRUZIONE META (identico a normalizeActivity)
// ---------------------------------------------------------
return {
id,
user: userName,
cartella,
taken_at: new Date(parsed.start_time_millis).toISOString(),
file_name: parsed.file_name,
file_extension: parsed.file_extension,
format: parsed.format,
display_name: parsed.display_name,
route_name: parsed.route_name,
title: parsed.title,
description: parsed.description,
notes: parsed.notes,
sport: parsed.sport,
activity_type: parsed.activity_type,
device_manufacturer: parsed.device_manufacturer,
device_model: parsed.device_model,
source_app: parsed.source_app,
start_time_millis: parsed.start_time_millis,
end_time_millis: parsed.end_time_millis,
duration_millis: parsed.duration_millis,
distance_meters: parsed.distance_meters,
elevation_gain_meters: parsed.elevation_gain_meters,
elevation_loss_meters: parsed.elevation_loss_meters,
min_elevation_meters: parsed.min_elevation_meters,
max_elevation_meters: parsed.max_elevation_meters,
file_distance_meters: parsed.file_distance_meters,
file_elevation_gain_meters: parsed.file_elevation_gain_meters,
computed_distance_meters: parsed.computed_distance_meters,
computed_elevation_gain_meters: parsed.computed_elevation_gain_meters,
average_speed_mps: parsed.average_speed_mps,
max_speed_mps: parsed.max_speed_mps,
average_pace_sec_per_km: parsed.average_pace_sec_per_km,
average_heart_rate: parsed.average_heart_rate,
max_heart_rate: parsed.max_heart_rate,
average_cadence: parsed.average_cadence,
max_cadence: parsed.max_cadence,
calories: parsed.calories,
power_avg: parsed.power_avg,
power_max: parsed.power_max,
point_count: parsed.point_count,
segment_count: parsed.segment_count,
lap_count: parsed.lap_count,
min_latitude: parsed.min_latitude,
max_latitude: parsed.max_latitude,
min_longitude: parsed.min_longitude,
max_longitude: parsed.max_longitude,
center_latitude: parsed.center_latitude,
center_longitude: parsed.center_longitude,
size_bytes: st.size,
last_modified_millis: st.mtimeMs,
date_added_millis: Date.now(),
date_modified_millis: Date.now(),
last_scan_millis: Date.now(),
is_favorite: false,
is_hidden: false,
rating: 0,
tags: parsed.tags || [],
track_preview: parsed.track_preview || [],
scan_status: "ok",
scan_error: null,
diagnostics: parsed.diagnostics || null,
gps,
location,
path: fullPath
};
}
module.exports = processActivityFile;

View file

@ -0,0 +1,206 @@
const path = require('path');
const fsp = require('fs/promises');
const ExifReader = require('exifreader');
const sharp = require('sharp');
const { sha256, inferMimeFromExt, parseExifDateUtc } = require('./utils');
const { extractGpsFromExif, extractGpsWithExiftool } = require('./gps');
const { createVideoThumbnail, createThumbnails } = require('./thumbs');
const { probeVideo } = require('./video');
const loc = require('../geo.js');
const { WEB_ROOT, PATH_FULL } = require('../config');
//const { getElevation } = require('./elevation');
const getElevation = require('./elevation');
async function processFile(userName, cartella, fileRelPath, absPath, ext, st) {
const isVideo = ['.mp4', '.mov', '.m4v'].includes(ext);
const thumbBase = path.join(
WEB_ROOT,
'photos',
userName,
'thumbs',
cartella,
path.dirname(fileRelPath)
);
await fsp.mkdir(thumbBase, { recursive: true });
// --- Nome file + estensione (dal path ASSOLUTO, sempre corretto) ---
const parsed = path.parse(absPath);
const baseName = parsed.name; // IMG_0249
const extName = parsed.ext; // .JPG
const absThumbMin = path.join(thumbBase, `${baseName}_min.jpg`);
const absThumbAvg = path.join(thumbBase, `${baseName}_avg.jpg`);
if (isVideo) {
await createVideoThumbnail(absPath, absThumbMin, absThumbAvg);
} else {
await createThumbnails(absPath, absThumbMin, absThumbAvg);
}
// --- EXIF ---
let tags = {};
try {
tags = await ExifReader.load(absPath, { expanded: true });
} catch {}
let timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
let takenAtIso = parseExifDateUtc(timeRaw);
// Fallback per i video
if (isVideo) {
const info = await probeVideo(absPath);
// Cerca la data nei punti standard
const creationFormat = info.format?.tags?.creation_time || null;
const creationStream0 = info.streams?.[0]?.tags?.creation_time || null;
const creationStream1 = info.streams?.[1]?.tags?.creation_time || null;
// Scegli la prima disponibile
const videoDate =
creationFormat ||
creationStream0 ||
creationStream1 ||
null;
if (videoDate) {
// NON convertire, NON usare new Date()
takenAtIso = videoDate;
timeRaw = videoDate;
} else {
// Fallback finale: mtime del file
const fallback = new Date(st.mtimeMs).toISOString();
takenAtIso = fallback;
timeRaw = fallback;
}
}
// --- GPS ---
let gps = null;
if (isVideo) {
// i video usano exiftool
gps = await extractGpsWithExiftool(absPath);
} else {
// le foto usano exifreader
gps = extractGpsFromExif(tags);
}
// --- ALTITUDINE DA SERVIZIO ESTERNO SE MANCANTE ---
if (gps && gps.lat && gps.lng) {
if (gps.alt == null) {
gps.alt = await getElevation(gps.lat, gps.lng);
}
}
// --- DIMENSIONI & ROTAZIONE ---
let width = null, height = null, duration = null, duration_ms = null, rotation = 0;
if (isVideo) {
const info = await probeVideo(absPath);
const stream = info.streams?.find(s => s.width && s.height);
if (stream) {
width = stream.width;
height = stream.height;
rotation = 0;
if (stream?.tags?.rotate) {
rotation = Number(stream.tags.rotate);
}
const sdl = stream?.side_data_list;
if (sdl && Array.isArray(sdl)) {
const rotEntry = sdl.find(d => d.rotation !== undefined);
if (rotEntry) rotation = Number(rotEntry.rotation);
const matrixEntry = sdl.find(d => typeof d.displaymatrix === 'string');
if (matrixEntry) {
const match = matrixEntry.displaymatrix.match(/rotation of ([\-0-9]+) degrees/i);
if (match) rotation = Number(match[1]);
}
}
rotation = ((rotation % 360) + 360) % 360;
}
duration = info.format?.duration || null;
duration_ms = duration ? Math.round(duration * 1000) : null;
} else {
try {
const meta = await sharp(absPath).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
try {
const raw =
tags?.exif?.Orientation?.value ??
tags?.image?.Orientation?.value ??
tags?.ifd0?.Orientation?.value ??
null;
const val = Array.isArray(raw) ? raw[0] : raw;
const map = { 1: 0, 3: 180, 6: 90, 8: 270 };
rotation = map[val] ?? 0;
} catch {}
}
const mime_type = inferMimeFromExt(ext);
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
const location = gps ? await loc(gps.lng, gps.lat) : null;
//
// --- GESTIONE PATH FULL / RELATIVI ---
//
const relPath = fileRelPath;
const relThub1 = fileRelPath.replace(/\.[^.]+$/, '_min.jpg');
const relThub2 = fileRelPath.replace(/\.[^.]+$/, '_avg.jpg');
const fullPath = PATH_FULL
? path.posix.join('/photos', userName, cartella, fileRelPath)
: relPath;
const fullThub1 = PATH_FULL
? path.posix.join('/photos', userName, 'thumbs', cartella, relThub1)
: relThub1;
const fullThub2 = PATH_FULL
? path.posix.join('/photos', userName, 'thumbs', cartella, relThub2)
: relThub2;
return {
id,
user: userName,
cartella,
name: baseName + extName, // 👈 SEMPRE CORRETTO
path: fullPath,
thub1: fullThub1,
thub2: fullThub2,
gps,
data: timeRaw,
taken_at: takenAtIso,
mime_type,
width,
height,
rotation,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
duration_ms: isVideo ? duration_ms : null,
location
};
}
module.exports = processFile;

View file

@ -0,0 +1,97 @@
// api_v1/scanner/recordChange.js
const { log } = require('./logger');
const wss = require('../../ws-server'); // IMPORTANTE
async function recordChange(db, photo_id, user, change_type) {
const timestamp = new Date().toISOString();
// -----------------------------------------------------
// DEDUPLICA SOLO PER "removed"
// -----------------------------------------------------
if (change_type === 'removed') {
const already = await db('photo_changes')
.where({ photo_id, user, change_type: 'removed' })
.first();
if (already) {
log(`📘 [CHANGES] removed già registrato → ${photo_id}, ma invio comunque WS`);
// -----------------------------------------------------
// 🔥 INVIA WS ANCHE SE GIÀ REGISTRATO
// -----------------------------------------------------
console.log("WS → invio evento (USER) [removed già registrato]");
try {
wss.broadcastToUser(user, {
type: "removed",
id: photo_id
});
console.log("WS → OK USER (removed già registrato)");
} catch (e) {
console.error("WS → ERRORE USER (removed già registrato):", e);
}
console.log("WS → invio evento (ADMIN) [removed già registrato]");
try {
wss.broadcastToAdmins({
type: "removed",
id: photo_id,
user
});
console.log("WS → OK ADMIN (removed già registrato)");
} catch (e) {
console.error("WS → ERRORE ADMIN (removed già registrato):", e);
}
return;
}
}
// -----------------------------------------------------
// SALVA NEL DB (added o primo removed)
// -----------------------------------------------------
await db("photo_changes").insert({
photo_id,
user,
change_type,
timestamp
});
log(`📘 [CHANGES] ${change_type}${photo_id}`);
// -----------------------------------------------------
// LOG PRIMA DEL BROADCAST
// -----------------------------------------------------
console.log("WS → invio evento:", { user, change_type, photo_id });
// -----------------------------------------------------
// 🔥 BROADCAST A USER
// -----------------------------------------------------
console.log("WS → invio evento (USER)");
try {
wss.broadcastToUser(user, {
type: change_type,
id: photo_id
});
console.log("WS → OK USER");
} catch (e) {
console.error("WS → ERRORE USER:", e);
}
// -----------------------------------------------------
// 🔥 BROADCAST A ADMIN
// -----------------------------------------------------
console.log("WS → invio evento (ADMIN)");
try {
wss.broadcastToAdmins({
type: change_type,
id: photo_id,
user
});
console.log("WS → OK ADMIN");
} catch (e) {
console.error("WS → ERRORE ADMIN:", e);
}
}
module.exports = recordChange;

View file

@ -0,0 +1,160 @@
// api_v1/scanner/scanActivitiesUser.js
const path = require('path');
const fsp = require('fs/promises');
const scanActivity = require('./scanActivity');
const { log } = require('./logger');
const { WEB_ROOT, SUPPORTED_ACTIVITY_EXTS } = require('../config');
const writeJsonAtomic = require('./writeJsonAtomic');
// ---------------------------------------------------------
// ETA CALCULATOR
// ---------------------------------------------------------
function computeETA(startTime, current, total) {
if (current === 0) return 'calcolo...';
const elapsed = (Date.now() - startTime) / 1000;
const rate = current / elapsed;
const remaining = (total - current) / rate;
const m = Math.floor(remaining / 60);
const s = Math.floor(remaining % 60);
return `${m}m ${s}s`;
}
// ---------------------------------------------------------
// PRIMA PASSATA: conta TUTTI i file reali ricorsivamente
// ---------------------------------------------------------
async function countActivityFilesUser(rootDir) {
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_ACTIVITY_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// SECONDA PASSATA: scansiona SOLO le cartelle vere
// ---------------------------------------------------------
async function scanActivitiesUser(userName, db) {
log(`🔵 Inizio scan ATTIVITÀ per user=${userName}`);
const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities');
const userDir = path.join(activitiesRoot, userName, 'original');
const statusPath = path.join(activitiesRoot, 'scan_status.json');
let entries = [];
try {
entries = await fsp.readdir(userDir, { withFileTypes: true });
} catch {
log(`❌ Nessuna directory attività per utente ${userName}`);
return [];
}
// Filtra SOLO cartelle vere dentro "original"
const folders = entries
.filter(e => e.isDirectory())
.map(e => e.name);
// ---------------------------------------------------------
// RIMOZIONE CARTELLE CANCELLATE DAL FILESYSTEM
// ---------------------------------------------------------
const createDeleteFolderFunctions = require('./deleteFolder');
const { deleteFolderForUser } = createDeleteFolderFunctions(db);
const dbFolders = await db('activities')
.where({ user: userName })
.distinct('cartella')
.pluck('cartella');
for (const dbFolder of dbFolders) {
if (!folders.includes(dbFolder)) {
log(`🗑️ Cartella attività rimossa dal filesystem: ${dbFolder}`);
await deleteFolderForUser(userName, dbFolder);
}
}
// PRIMA PASSATA: conta i file reali
const TOTAL_FILES = await countActivityFilesUser(userDir);
const CURRENT = { value: 0 };
const start = Date.now();
log(`📊 Attività totali da scansionare: ${TOTAL_FILES}`);
// Stato iniziale
await writeJsonAtomic(statusPath, {
current: 0,
total: TOTAL_FILES,
percent: 0,
eta: 'calcolo...'
});
const allNewActivities = [];
// SECONDA PASSATA: UNA SOLA SCANSIONE PER CARTELLA
for (const cartella of folders) {
log(`📁 Scan cartella attività: ${cartella}`);
const newActivities = await scanActivity(
cartella,
userName,
db,
CURRENT,
TOTAL_FILES,
start,
async () => {
await writeJsonAtomic(statusPath, {
current: CURRENT.value,
total: TOTAL_FILES,
percent: TOTAL_FILES > 0
? Math.round((CURRENT.value / TOTAL_FILES) * 100)
: 100,
eta: computeETA(start, CURRENT.value, TOTAL_FILES)
});
}
);
if (newActivities?.length) {
allNewActivities.push(...newActivities);
}
}
// Stato finale
await writeJsonAtomic(statusPath, {
current: TOTAL_FILES,
total: TOTAL_FILES,
percent: 100,
eta: '0m 0s'
});
log(`🟣 Scan ATTIVITÀ COMPLETATO per user=${userName}. Nuove attività: ${allNewActivities.length}`);
return allNewActivities;
}
module.exports = scanActivitiesUser;

View file

@ -0,0 +1,114 @@
// api_v1/scanner/scanActivity.js
const path = require('path');
const { log } = require('./logger');
const scanActivityCartella = require('./scanActivityCartella');
const postWithAuth = require('./postWithAuth');
const createCleanupFunctions = require('./orphanCleanup');
const writeJsonAtomic = require('./writeJsonAtomic');
const {
WEB_ROOT,
SEND_PHOTOS,
BASE_URL
} = require('../config');
// ---------------------------------------------------------
// FORMAT TIME
// ---------------------------------------------------------
function formatTime(ms) {
const sec = Math.floor(ms / 1000);
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
// ---------------------------------------------------------
// UPDATE STATUS FILE
// ---------------------------------------------------------
async function updateStatusFile(CURRENT, TOTAL_FILES, start) {
const now = Date.now();
const elapsedMs = now - start;
const avg = elapsedMs / Math.max(CURRENT.value, 1);
const remainingMs = (TOTAL_FILES - CURRENT.value) * avg;
const percent = TOTAL_FILES > 0
? Number(((CURRENT.value / TOTAL_FILES) * 100).toFixed(2))
: 100;
const status = {
current: CURRENT.value,
total: TOTAL_FILES,
percent,
eta: formatTime(remainingMs),
elapsed: formatTime(elapsedMs)
};
const statusPath = path.resolve(
__dirname,
'..',
'..',
'public/activities/scan_status.json'
);
await writeJsonAtomic(statusPath, status);
}
// ---------------------------------------------------------
// SCAN UNA SOLA CARTELLA DI ATTIVITÀ
// ---------------------------------------------------------
async function scanActivity(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
const newActivities = [];
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities');
const userDir = path.join(activitiesRoot, userName, 'original');
const cartella = dir;
const absCartella = path.join(userDir, cartella);
log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`);
await scanActivityCartella(
db,
userName,
cartella,
absCartella,
newActivities,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress
);
// ---------------------------------------------------------
// INVIO AL SERVER REMOTO
// ---------------------------------------------------------
if (SEND_PHOTOS && BASE_URL && newActivities.length > 0) {
log(`📤 [SEND START] newActivities=${newActivities.length}`);
for (const a of newActivities) {
try {
a.user = userName;
await postWithAuth(`${BASE_URL}/activities`, a);
log(`📥 [SENT TO SERVER] ${a.file_name}`);
} catch (err) {
log(`❌ [SERVER SENT ERROR] ${a.file_name}${err.message}`);
}
}
} else {
log(`⚠️ [NO SEND] newActivities=${newActivities.length}`);
}
log('📌 [DEBUG] newActivities:', JSON.stringify(newActivities, null, 2));
return newActivities;
}
module.exports = scanActivity;

View file

@ -0,0 +1,62 @@
// api_v1/scanner/scanActivityCartella.js
const scanActivityFiles = require('./scanActivityFiles');
const scanActivitySingle = require('./scanActivitySingle'); // 🔥 da creare
const { log } = require('./logger');
async function scanActivityCartella(
db,
user,
cartella,
absCartella,
newActivities,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress
) {
log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`);
// Recupera gli ID già presenti nel DB
const rows = await db('activities')
.where({ user, cartella })
.select('id');
const idsSet = new Set(rows.map(r => r.id));
// Scansiona i file della cartella
for await (const f of scanActivityFiles(user, cartella, absCartella, db)) {
// Aggiorna il contatore
CURRENT.value++;
// Aggiorna stato avanzamento (vecchio sistema)
await updateStatusFile(CURRENT, TOTAL_FILES, start);
// Aggiorna stato avanzamento (nuovo sistema)
if (onProgress) {
await onProgress();
}
// Processa il file FIT/GPX/TCX
const meta = await scanActivitySingle(db, user, cartella, f, newActivities);
log("📌 [DEBUG] meta:", JSON.stringify(meta, null, 2));
log("📌 [DEBUG] newActivities:", JSON.stringify(newActivities, null, 2));
// Anche se identico, NON è orfano
idsSet.delete(f.id);
}
// Gestione orfani
for (const orphanId of idsSet) {
log(`🔴 [ORPHAN ACTIVITY] ${orphanId}`);
await deleteThumbsById(orphanId);
await deleteFromDB(orphanId, user);
}
}
module.exports = scanActivityCartella;

View file

@ -0,0 +1,43 @@
// api_v1/scanner/scanActivityFiles.js
const fsp = require('fs/promises');
const path = require('path');
const SUPPORTED_ACTIVITY_EXTS = new Set(['.fit', '.gpx', '.tcx']);
async function* scanActivityFiles(user, cartella, absCartella, db) {
let entries = [];
try {
entries = await fsp.readdir(absCartella, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (!e.isFile()) continue;
const ext = path.extname(e.name).toLowerCase();
if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) continue;
const absPath = path.join(absCartella, e.name);
// 🔥 MANCAVA QUESTO
let st;
try {
st = await fsp.stat(absPath);
} catch {
continue;
}
yield {
id: `${cartella}/${e.name}`,
file_name: e.name,
relPath: e.name, // 🔥 serve per processActivityFile
absPath,
ext,
stat: st,
path: `/activities/${user}/original/${cartella}/${e.name}` // 🔥 come le foto
};
}
}
module.exports = scanActivityFiles;

View file

@ -0,0 +1,332 @@
// 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;

210
api_v1/scanner/scanAuto.js Normal file
View file

@ -0,0 +1,210 @@
const path = require('path');
const fsp = require('fs/promises');
const scanCartella = require('./scanCartella');
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const createCleanupFunctions = require('./orphanCleanup');
const { WEB_ROOT } = require('../config');
const { log } = require('./logger');
// ---------------------------------------------------------
// NORMALIZZAZIONE PATH (toglie /app/...)
// ---------------------------------------------------------
function normalizeRelPath(user, dirPath, fileName) {
if (!dirPath) return "";
let rel = dirPath.replace(/\\/g, "/");
// Rimuove tutto fino a /photos/
rel = rel.replace(/^.*\/photos\//, "");
// Ora rel = "Fabio/original/2017Irlanda/.../"
const prefix = `${user}/original/`;
if (rel.startsWith(prefix)) {
rel = rel.slice(prefix.length);
}
if (rel.startsWith("/")) rel = rel.slice(1);
// Aggiunge il file
if (fileName) {
rel = `${rel}/${fileName}`.replace(/\/+/g, "/");
}
return rel; // es: "2017Irlanda19-29ago/IMG_0120.JPG"
}
// ---------------------------------------------------------
// CALCOLO ID IDENTICO AL VECCHIO SCAN
// ---------------------------------------------------------
function computeId(user, relPath) {
const parts = relPath.split('/').filter(Boolean);
const cartella = parts[0];
const fileRelPath = parts.slice(1).join('/');
const id = sha256(`${user}/${cartella}/${fileRelPath}`);
return { id, cartella, fileRelPath };
}
// ---------------------------------------------------------
// 1) ADD FILE
// ---------------------------------------------------------
async function handleAddFile(user, dirPath, fileName, db) {
const relPath = normalizeRelPath(user, dirPath, fileName);
log(`🟦 [ADD] user=${user} file=${relPath}`);
// 🔥 Ricostruzione identica al vecchio scanPhoto
const parts = relPath.split('/');
const cartella = parts[0]; // es: "2017Irlanda19-29ago"
const fileRelPath = parts.slice(1).join('/'); // es: "IMG_0116.JPG"
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const absPath = path.join(photosRoot, user, 'original', cartella, fileRelPath);
const ext = path.extname(fileName).toLowerCase();
const st = await fsp.stat(absPath);
// 🔥 FIX: processFile deve ricevere fileRelPath completo di "original/"
const meta = await processFile(
user,
cartella,
path.posix.join('original', cartella, fileRelPath),
absPath,
ext,
st
);
// Inserimento identico al vecchio scanPhoto
const row = {
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms ?? null,
taken_at: meta.taken_at ?? null,
data: meta.data ?? null,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null
};
await db('photos')
.insert(row)
.onConflict('id')
.merge();
log(`🟢 [ADD] Inserito/aggiornato id=${meta.id}`);
return row;
}
// ---------------------------------------------------------
// 2) DELETE FILE
// ---------------------------------------------------------
async function handleDeleteFile(user, dirPath, fileName, db) {
const relPath = normalizeRelPath(user, dirPath, fileName);
log(`🟥 [DEL] user=${user} file=${relPath}`);
const { id } = computeId(user, relPath);
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
await deleteThumbsById(id);
await deleteFromDB(id, user);
log(`🔴 [DEL] File eliminato id=${id}`);
return { deleted: id };
}
// ---------------------------------------------------------
// 3) ADD DIRECTORY
// ---------------------------------------------------------
async function handleAddDir(user, dir, db) {
log(`🟦 [ADD_DIR] user=${user} dir=${dir}`);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const absDir = path.join(photosRoot, user, 'original', dir);
log(`📁 [ADD_DIR] absDir=${absDir}`);
let results = [];
for await (const f of scanCartella(user, dir, absDir, db)) {
log(`📄 [ADD_DIR] File trovato: ${f.relPath}`);
const meta = await processFile(
user,
dir,
f.relPath,
f.absPath,
f.ext,
f.stat
);
await db('photos').insert(meta).onConflict('id').merge();
log(`🟢 [ADD_DIR] Inserito/aggiornato id=${meta.id} name=${meta.name}`);
results.push(meta);
}
log(`🟣 [ADD_DIR] Completato. Files=${results.length}`);
return results;
}
// ---------------------------------------------------------
// 4) DELETE DIRECTORY
// ---------------------------------------------------------
async function handleDeleteDir(user, dir, db) {
log(`🟥 [DEL_DIR] user=${user} dir=${dir}`);
const rows = await db('photos')
.where({ user, cartella: dir })
.select('id');
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
log(`📁 [DEL_DIR] Trovati ${rows.length} file da eliminare`);
for (const r of rows) {
log(`🗑️ [DEL_DIR] Eliminazione id=${r.id}`);
await deleteThumbsById(r.id);
await deleteFromDB(r.id, user);
}
log(`🔴 [DEL_DIR] Cartella eliminata. Totale file=${rows.length}`);
return { deleted: rows.length };
}
module.exports = {
handleAddFile,
handleDeleteFile,
handleAddDir,
handleDeleteDir
};

View file

@ -0,0 +1,68 @@
// api_v1/scanner/scanCartella.js
const path = require('path');
const fsp = require('fs/promises');
const fs = require('fs');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* Scansiona ricorsivamente la cartella:
* /photos/<user>/original/<cartella>/
*
* Restituisce (yield) i metadati dei file trovati.
*/
async function* scanCartella(userName, cartella, absCartella, db) {
async function* walk(currentAbs, relPath = '') {
let entries = [];
try {
entries = await fsp.readdir(currentAbs, { withFileTypes: true });
} catch {
return;
}
console.log(entries);
for (const e of entries) {
const absPath = path.join(currentAbs, e.name);
if (e.isDirectory()) {
yield* walk(absPath, path.join(relPath, e.name));
continue;
}
const ext = path.extname(e.name).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) continue;
const fileRelPath = relPath ? `${relPath}/${e.name}` : e.name;
// ID deterministico basato sul percorso
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
let st;
try {
st = await fsp.stat(absPath);
} catch {
continue;
}
log(`📂 scanCartella → user=${userName} cartella=${cartella} relPath=${fileRelPath} id=${id}`);
yield {
id,
user: userName,
cartella,
name: e.name,
relPath: fileRelPath,
absPath,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${fileRelPath}`
};
}
}
yield* walk(absCartella);
}
module.exports = scanCartella;

233
api_v1/scanner/scanFile.js Normal file
View file

@ -0,0 +1,233 @@
// api_v1/scanner/scanFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFileEntry(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
let st;
try {
st = await fsp.stat(absFile);
} catch {
log(`⛔ Impossibile leggere stat per ${absFile}`);
return null;
}
const id = sha256(`${userName}/${cartella}/${relPath}`);
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
async function scanFile(userName, cartella, absFile, db) {
const f = await scanFile(user, cart, absFile);
const fileName = f.name;
const id = f.id;
const st = f.stat;
let prev = await db("photos")
.select("id", "size_bytes", "mtimeMs", "_indexHash", "fast_hash", "path")
.where({ id })
.first();
if (prev && LOG_VERBOSE) {
log(`${prefix} ⚪ Invariato: ${fileName}`);
}
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// INSERT CORRETTO (senza colonna gps)
await db("photos").insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
_indexHash: meta._indexHash,
fast_hash: fastHash
});
await db('photo_changes').insert({
photo_id: meta.id,
user,
change_type: 'added',
timestamp: new Date().toISOString()
});
newFiles.push(meta);
//log(`🟢 [PUSH newFiles] id=${meta.id} path=${meta.path}`);
continue;
}
// ---------------------------------------------------------
// FAST-SIZE-SKIP
// ---------------------------------------------------------
if (prev.size_bytes === st.size) {
//log(`🔵 [FAST-SIZE-SKIP] id=${id}`);
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash
});
continue;
}
// ---------------------------------------------------------
// FAST-HASH-SKIP
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
//log(`🔵 [FAST-HASH-SKIP] id=${id}`);
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs
});
continue;
}
// ---------------------------------------------------------
// MODIFICATO
// ---------------------------------------------------------
//log(`🟠 [FULL-SCAN] id=${id}`);
log(`${prefix} 🟠 Nuovo/Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos")
.insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
_indexHash: meta._indexHash,
fast_hash: fastHash
})
.onConflict("id")
.merge();
await db('photo_changes').insert({
photo_id: meta.id,
user,
change_type: 'updated',
timestamp: new Date().toISOString()
});
newFiles.push(meta);
log(`${prefix} 🟠 Nuovo/Modificato al server ${fileName}`);
}
}
}
module.exports = scanFile;

View file

@ -0,0 +1,53 @@
// api_v1/scanner/scanFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFile(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
let st;
try {
st = await fsp.stat(absFile);
} catch {
log(`⛔ Impossibile leggere stat per ${absFile}`);
return null;
}
const id = sha256(`${userName}/${cartella}/${relPath}`);
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
module.exports = scanFile;

View file

@ -0,0 +1,55 @@
// api_v1/scanner/scanFileEntry.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFile(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
// ID deterministico (funziona anche se il file non esiste)
const id = sha256(`${userName}/${cartella}/${relPath}`);
let st = null;
try {
st = await fsp.stat(absFile);
} catch {
// File NON esiste → caso DEL
log(`⛔ Impossibile leggere stat per ${absFile} (file mancante)`);
// Continuiamo comunque: l'ID è valido e serve per DEL
}
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st, // può essere null
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
module.exports = scanFile;

View file

@ -0,0 +1,76 @@
// api_v1/scanner/scanNewCartella.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const scanPhoto = require('./scanPhoto');
// ---------------------------------------------------------
// Conta ricorsivamente i file in /photos/<user>/original/<folder>/
// ---------------------------------------------------------
async function countFilesInUserFolder(userName, folderName) {
const rootDir = path.join(WEB_ROOT, userName, "original", folderName);
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// Crea thumbs/<folder> e scansiona la cartella come fa /scan
// ---------------------------------------------------------
async function scanNewCartella(userName, folderName, db) {
// 1) crea la cartella root in thumbs
const thumbsDir = path.join(WEB_ROOT, userName, "thumbs", folderName);
await fsp.mkdir(thumbsDir, { recursive: true });
// 2) conta i file ricorsivamente
const TOTAL_FILES = await countFilesInUserFolder(userName, folderName);
// 3) esegui lo scan della cartella
const CURRENT = { value: 0 };
const start = Date.now();
// ⭐ newFiles conterrà i meta delle foto nuove
const newFiles = await scanPhoto(
folderName,
userName,
db,
CURRENT,
TOTAL_FILES,
start
);
return {
ok: true,
folder: folderName,
totalFiles: TOTAL_FILES,
newFiles
};
}
module.exports = {
scanNewCartella,
countFilesInUserFolder
};

111
api_v1/scanner/scanPhoto.js Normal file
View file

@ -0,0 +1,111 @@
// api_v1/scanner/scanPhoto.js
const path = require('path');
const { log } = require('./logger');
const scanPhotoCartella = require('./scanPhotoCartella');
const postWithAuth = require('./postWithAuth');
const createCleanupFunctions = require('./orphanCleanup');
const writeJsonAtomic = require('./writeJsonAtomic');
const {
WEB_ROOT,
SEND_PHOTOS,
BASE_URL
} = require('../config');
// ---------------------------------------------------------
// FORMAT TIME
// ---------------------------------------------------------
function formatTime(ms) {
const sec = Math.floor(ms / 1000);
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
// ---------------------------------------------------------
// UPDATE STATUS FILE
// ---------------------------------------------------------
async function updateStatusFile(CURRENT, TOTAL_FILES, start) {
const now = Date.now();
const elapsedMs = now - start;
const avg = elapsedMs / Math.max(CURRENT.value, 1);
const remainingMs = (TOTAL_FILES - CURRENT.value) * avg;
const percent = TOTAL_FILES > 0
? Number(((CURRENT.value / TOTAL_FILES) * 100).toFixed(2))
: 100;
const status = {
current: CURRENT.value,
total: TOTAL_FILES,
percent,
eta: formatTime(remainingMs),
elapsed: formatTime(elapsedMs)
};
const statusPath = path.resolve(
__dirname,
'..',
'..',
'public/photos/scan_status.json'
);
await writeJsonAtomic(statusPath, status);
}
// ---------------------------------------------------------
// SCAN UNA SOLA CARTELLA
// ---------------------------------------------------------
async function scanPhoto(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
const newFiles = [];
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
const cartella = dir;
const absCartella = path.join(userDir, cartella);
log(`📁 [SCAN CARTELLA] ${cartella}`);
await scanPhotoCartella(
db,
userName,
cartella,
absCartella,
newFiles,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress
);
// ---------------------------------------------------------
// INVIO AL SERVER REMOTO
// ---------------------------------------------------------
if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) {
log(`📤 [SEND START] newFiles=${newFiles.length}`);
for (const p of newFiles) {
try {
p.user = userName;
await postWithAuth(`${BASE_URL}/photos`, p);
log(`📥 [SENT TO SERVER] ${p.name}`);
} catch (err) {
log(`❌ [SERVER SENT ERROR] ${p.name}${err.message}`);
}
}
} else {
log(`⚠️ [NO SEND] newFiles=${newFiles.length}`);
}
return newFiles;
}
module.exports = scanPhoto;

View file

@ -0,0 +1,62 @@
// api_v1/scanner/scanPhotoCartella.js
const scanCartella = require('./scanCartella');
const scanPhotoSingle = require('./scanPhotoSingle');
const { log } = require('./logger');
async function scanPhotoCartella(
db,
user,
cartella,
absCartella,
newFiles,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress // 🔥 AGGIUNTO
) {
log(`📁 Scan cartella: ${cartella}`);
// Recupera gli ID già presenti nel DB
const rows = await db('photos')
.where({ user, cartella })
.select('id');
const idsSet = new Set(rows.map(r => r.id));
// Scansiona i file della cartella
for await (const f of scanCartella(user, cartella, absCartella, db)) {
// Aggiorna il contatore
CURRENT.value++;
// 🔥 Aggiorna stato avanzamento (vecchio sistema)
await updateStatusFile(CURRENT, TOTAL_FILES, start);
// 🔥 Aggiorna stato avanzamento (nuovo sistema)
if (onProgress) {
await onProgress();
}
// Processa il file
const meta = await scanPhotoSingle(db, user, cartella, f, newFiles);
// Anche se identico, NON è orfano
idsSet.delete(f.id);
}
// Gestione orfani
for (const orphanId of idsSet) {
log(`🔴 Orfano: ${orphanId}`);
await deleteThumbsById(orphanId);
await deleteFromDB(orphanId, user);
}
}
module.exports = scanPhotoCartella;

View file

@ -0,0 +1,206 @@
// api_v1/scanner/scanPhotoSingle.js
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const { log } = require('./logger');
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,
// Paese
country: location.country || undefined,
country_code: countryCode,
// County / contea
county: location.county || undefined,
county_code: countyCode,
// Campi già esistenti
region: location.region || undefined,
city: location.city || undefined,
postcode: location.postcode || undefined,
address: location.address || undefined,
timezone: location.timezone || undefined,
time: location.time || undefined,
};
}
async function scanPhotoSingle(db, user, cartella, f, newFiles, prefix = '') {
const fileName = f.name;
const id = f.id;
const st = f.stat;
// Recupero precedente SENZA contentHash
let prev = await db('photos')
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
.where({ id })
.first();
// Hash veloce basato su size + mtime
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuovo: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// Normalizza location prima di salvarla e inviarla
meta.location = normalizeLocation(meta.location);
log(
`🌍 [LOCATION NEW] ${fileName} ` +
`location=${JSON.stringify(meta.location)}`
);
await db('photos').insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
// Qui ora il JSON può contenere:
// country, country_code, county, county_code, region, city, ecc.
location: meta.location ? JSON.stringify(meta.location) : null,
fast_hash: fastHash,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
newFiles.push(meta);
return meta;
}
// ---------------------------------------------------------
// IDENTICO (fastHash uguale)
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
await db('photos')
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash,
// Anche un file identico aggiorna updated_at
updated_at: new Date().toISOString(),
});
return null;
}
// ---------------------------------------------------------
// MODIFICATO (fastHash diverso)
// ---------------------------------------------------------
log(`${prefix} 🟠 Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// Normalizza location prima di salvarla e inviarla
meta.location = normalizeLocation(meta.location);
log(
`🌍 [LOCATION UPDATED] ${fileName} ` +
`location=${JSON.stringify(meta.location)}`
);
await db('photos')
.where({ id })
.update({
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
// Qui ora il JSON può contenere:
// country, country_code, county, county_code, region, city, ecc.
location: meta.location ? JSON.stringify(meta.location) : null,
fast_hash: fastHash,
updated_at: new Date().toISOString(),
});
newFiles.push(meta);
return meta;
}
module.exports = scanPhotoSingle;

View file

@ -0,0 +1,161 @@
// api_v1/scanner/scanPhotosUser.js
const path = require('path');
const fsp = require('fs/promises');
const scanPhoto = require('./scanPhoto');
const { log } = require('./logger');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const writeJsonAtomic = require('./writeJsonAtomic');
// ---------------------------------------------------------
// ETA CALCULATOR
// ---------------------------------------------------------
function computeETA(startTime, current, total) {
if (current === 0) return 'calcolo...';
const elapsed = (Date.now() - startTime) / 1000;
const rate = current / elapsed;
const remaining = (total - current) / rate;
const m = Math.floor(remaining / 60);
const s = Math.floor(remaining % 60);
return `${m}m ${s}s`;
}
// ---------------------------------------------------------
// PRIMA PASSATA: conta TUTTI i file reali ricorsivamente
// ---------------------------------------------------------
async function countFilesUser(rootDir) {
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// SECONDA PASSATA: scansiona SOLO le cartelle vere
// ---------------------------------------------------------
async function scanPhotosUser(userName, db) {
log(`🔵 Inizio scan TUTTE le cartelle per user=${userName}`);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
const statusPath = path.join(photosRoot, 'scan_status.json');
let entries = [];
try {
entries = await fsp.readdir(userDir, { withFileTypes: true });
} catch {
log(`❌ Nessuna directory per utente ${userName}`);
return [];
}
// Filtra SOLO cartelle vere dentro "original"
const folders = entries
.filter(e => e.isDirectory())
.map(e => e.name);
// ---------------------------------------------------------
// RIMOZIONE CARTELLE CANCELLATE DAL FILESYSTEM
// ---------------------------------------------------------
const createDeleteFolderFunctions = require('./deleteFolder');
const { deleteFolderForUser } = createDeleteFolderFunctions(db);
const dbFolders = await db('photos')
.where({ user: userName })
.distinct('cartella')
.pluck('cartella');
for (const dbFolder of dbFolders) {
if (!folders.includes(dbFolder)) {
log(`🗑️ Cartella rimossa dal filesystem: ${dbFolder}`);
await deleteFolderForUser(userName, dbFolder);
}
}
// PRIMA PASSATA: conta i file reali
const TOTAL_FILES = await countFilesUser(userDir);
const CURRENT = { value: 0 };
const start = Date.now();
log(`📊 File totali da scansionare: ${TOTAL_FILES}`);
// Stato iniziale
await writeJsonAtomic(statusPath, {
current: 0,
total: TOTAL_FILES,
percent: 0,
eta: 'calcolo...'
});
const allNewFiles = [];
// SECONDA PASSATA: UNA SOLA SCANSIONE PER CARTELLA
for (const cartella of folders) {
log(`📁 Scan cartella utente: ${cartella}`);
const newFiles = await scanPhoto(
cartella,
userName,
db,
CURRENT,
TOTAL_FILES,
start,
async () => {
// Callback di aggiornamento progresso
await writeJsonAtomic(statusPath, {
current: CURRENT.value,
total: TOTAL_FILES,
percent: TOTAL_FILES > 0
? Math.round((CURRENT.value / TOTAL_FILES) * 100)
: 100,
eta: computeETA(start, CURRENT.value, TOTAL_FILES)
});
}
);
if (newFiles?.length) {
allNewFiles.push(...newFiles);
}
}
// Stato finale
await writeJsonAtomic(statusPath, {
current: TOTAL_FILES,
total: TOTAL_FILES,
percent: 100,
eta: '0m 0s'
});
log(`🟣 Scan COMPLETATO per user=${userName}. Nuovi file totali: ${allNewFiles.length}`);
return allNewFiles;
}
module.exports = scanPhotosUser;

View file

@ -0,0 +1,25 @@
// api_v1/scanner/scanUser.js
const scanPhotosUser = require('./scanPhotosUser');
const scanActivitiesUser = require('./scanActivitiesUser');
const { log } = require('./logger');
async function scanUser(userName, db) {
log(`🔵 Inizio scan COMPLETO per user=${userName}`);
// 🔥 SCAN FOTO
const newPhotos = await scanPhotosUser(userName, db);
// 🔥 SCAN ATTIVITÀ
const newActivities = await scanActivitiesUser(userName, db);
log(`🟣 Scan COMPLETATO per user=${userName}`);
log(` 📸 Nuove foto: ${newPhotos.length}`);
log(` 🏃‍♂️ Nuove attività: ${newActivities.length}`);
return {
photos: newPhotos,
activities: newActivities
};
}
module.exports = scanUser;

31
api_v1/scanner/thumbs.js Normal file
View file

@ -0,0 +1,31 @@
const sharp = require('sharp');
const { exec } = require('child_process');
function createVideoThumbnail(videoPath, thumbMinPath, thumbAvgPath) {
return new Promise((resolve) => {
const cmd = `
ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" &&
ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}"
`;
exec(cmd, () => resolve());
});
}
async function createThumbnails(filePath, thumbMinPath, thumbAvgPath) {
try {
await sharp(filePath)
.resize({ width: 100, height: 100, fit: 'inside', withoutEnlargement: true })
.withMetadata()
.toFile(thumbMinPath);
await sharp(filePath)
.resize({ width: 400, withoutEnlargement: true })
.withMetadata()
.toFile(thumbAvgPath);
} catch (err) {
console.error('Errore creazione thumbnails:', err.message, filePath);
}
}
module.exports = { createVideoThumbnail, createThumbnails };

41
api_v1/scanner/utils.js Normal file
View file

@ -0,0 +1,41 @@
const path = require('path');
const crypto = require('crypto');
function toPosix(p) {
return p.split(path.sep).join('/');
}
function sha256(s) {
return crypto.createHash('sha256').update(s).digest('hex');
}
function inferMimeFromExt(ext) {
switch (ext.toLowerCase()) {
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.png': return 'image/png';
case '.webp': return 'image/webp';
case '.heic':
case '.heif': return 'image/heic';
case '.mp4': return 'video/mp4';
case '.mov': return 'video/quicktime';
case '.m4v': return 'video/x-m4v';
default: return 'application/octet-stream';
}
}
function parseExifDateUtc(s) {
if (!s) return null;
const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
const m = re.exec(s);
if (!m) return null;
const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6]));
return dt.toISOString();
}
module.exports = {
toPosix,
sha256,
inferMimeFromExt,
parseExifDateUtc
};

17
api_v1/scanner/video.js Normal file
View file

@ -0,0 +1,17 @@
const { exec } = require('child_process');
function probeVideo(videoPath) {
return new Promise((resolve) => {
const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`;
exec(cmd, (err, stdout) => {
if (err) return resolve({});
try {
resolve(JSON.parse(stdout));
} catch {
resolve({});
}
});
});
}
module.exports = { probeVideo };

View file

@ -0,0 +1,17 @@
// api_v1/scanner/writeJsonAtomic.js
const path = require('path');
const fsp = require('fs/promises');
async function writeJsonAtomic(filePath, data) {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random()
.toString(16)
.slice(2)}.tmp`;
const json = JSON.stringify(data, null, 2);
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(tmpPath, json, 'utf8');
await fsp.rename(tmpPath, filePath);
}
module.exports = writeJsonAtomic;

51
api_v1/tools.js Normal file
View file

@ -0,0 +1,51 @@
/**
* Required libraries
*/
const bcrypt = require('bcrypt')
const readLine = require('readline')
const async = require('async')
// Password hash method
const hashPassword = plain => bcrypt.hashSync(plain, 8)
// Ask user password method
function askPassword(question) {
return new Promise((resolve, reject) => {
const rl = readLine.createInterface({
input: process.stdin,
output: process.stdout
})
rl.question(question, answer => {
rl.close()
resolve(answer)
})
})
}
// Generate hash password method
async function generateHash() {
try {
console.log('**********************************')
console.log('** Password hash script **')
console.log('**********************************')
const passwordAnswer = await askPassword(
'Please give me a password to hash: '
)
if (passwordAnswer != '') {
const hashedPassword = hashPassword(passwordAnswer)
const compare = bcrypt.compareSync(passwordAnswer, hashedPassword)
await console.log('Hashed password:', hashedPassword)
await console.log('Valdiation:', compare)
} else {
console.log('You need write something. Script aborted!')
}
} catch (err) {
console.log(err)
return process.exit(1)
}
}
generateHash()

22
api_v1/users.json Normal file
View file

@ -0,0 +1,22 @@
{
"users": [
{
"id": 1,
"name": "Admin",
"email": "admin@gmail.com",
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
},
{
"id": 2,
"name": "Fabio",
"email": "fabio@gmail.com",
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
},
{
"id": 3,
"name": "Jessica",
"email": "jessie@libero.it",
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3."
}
]
}

0
aves.db Normal file
View file

1
b/a.jpg Normal file
View file

@ -0,0 +1 @@
prova

19
cast.sh Normal file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
SERVER="https://prova.patachina.it"
EMAIL="fabio@gmail.com"
PASS="master66"
ENTRY_ID="751d8a15766bf2686bc87e60b33b4a8c38befe454428e6a5a82b00f26026c0e0"
TOKEN=$(curl -s -X POST "$SERVER/auth/login" -H "Content-Type: application/json" -d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .token)
echo "JWT: $TOKEN"
RESP=$(curl -s -X POST "$SERVER/share/create" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "{\"entryId\":\"$ENTRY_ID\",\"ttlSeconds\":600,\"oneTime\":false}")
echo "create response: $RESP"
URL=$(echo "$RESP" | jq -r .url)
echo "share url: $URL"
echo "download full"
curl -v -L "$URL" -o /tmp/test.jpg
echo "test range"
curl -v -L -H "Range: bytes=0-1023" "$URL" -o /tmp/part.bin

58
cast1.sh Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# CONFIGURA QUI
SERVER="https://prova.patachina.it"
EMAIL="fabio@gmail.com"
PASS="master66"
ENTRY_ID="751d8a15766bf2686bc87e60b33b4a8c38befe454428e6a5a82b00f26026c0e0"
TTL=600
# FILE temporanei
OUT_FULL="/tmp/share_test_full.jpg"
OUT_PART="/tmp/share_test_part.bin"
RESP_JSON="/tmp/share_create_resp.json"
echo "1) Login..."
TOKEN=$(curl -s -X POST "$SERVER/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .token)
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "Login fallito. Controlla credenziali."
exit 1
fi
echo "JWT ottenuto."
echo "2) Creo share per entryId $ENTRY_ID (ttl=${TTL}s)..."
curl -s -X POST "$SERVER/share/create" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"entryId\":\"$ENTRY_ID\",\"ttlSeconds\":$TTL,\"oneTime\":false}" \
-o "$RESP_JSON"
cat "$RESP_JSON" | jq .
URL=$(jq -r .url < "$RESP_JSON")
EXPIRES_AT=$(jq -r .expiresAt < "$RESP_JSON")
if [ -z "$URL" ] || [ "$URL" = "null" ]; then
echo "Creazione share fallita. Output:"
cat "$RESP_JSON"
exit 1
fi
echo "Share creato: $URL"
echo "Scadenza: $EXPIRES_AT"
echo "3) Scarico file completo (seguo redirect e TLS)..."
curl -v -L "$URL" -o "$OUT_FULL"
echo "Salvato in $OUT_FULL (size: $(stat -c%s "$OUT_FULL") bytes)"
echo "4) Test Range bytes=0-1023..."
curl -v -L -H "Range: bytes=0-1023" "$URL" -o "$OUT_PART"
echo "Salvato in $OUT_PART (size: $(stat -c%s "$OUT_PART") bytes)"
echo "5) Intestazioni HEAD (senza body)"
curl -I -L "$URL" || true
echo "Fatto. Controlla i file in /tmp e i log della tua app se necessario."

202
come funziona.md Normal file
View file

@ -0,0 +1,202 @@
Perfetto Fabio — ti preparo **la guida completa, chiara e pratica** per integrare il tuo **viewer Flutter Android** con il **WebSocket del tuo server Node.js**, così come funziona oggi nel tuo backend.
Ti spiego:
1. **Come si autentica il client Flutter**
2. **Come si connette al WebSocket**
3. **Quali messaggi riceve** (formato esatto)
4. **Come deve rispondere**
5. **Come integrare tutto nel viewer**
Tutto basato sul tuo backend reale.
---
# ⭐ 1. COME SI AUTENTICA IL CLIENT FLUTTER
Il tuo server usa JWT.
### Passi:
1. Flutter fa login via HTTP:
```
POST /auth/login
{
"email": "...",
"password": "..."
}
```
2. Il server risponde:
```json
{
"token": "<JWT>",
"name": "Fabio"
}
```
3. Flutter salva il token.
4. **Ogni chiamata HTTP successiva** deve avere:
```
Authorization: Bearer <JWT>
```
---
# ⭐ 2. COME SI CONNETTE AL WEBSOCKET
Il tuo server WebSocket accetta connessioni così:
```
ws://<server>:<port>/?token=<JWT>
```
Quindi in Flutter:
```dart
import 'package:web_socket_channel/web_socket_channel.dart';
final channel = WebSocketChannel.connect(
Uri.parse("ws://192.168.1.10:4000/?token=$jwt"),
);
```
### Importante:
- Devi passare **lo stesso JWT** usato per le API HTTP.
- Il server autentica il token e associa la connessione allutente.
---
# ⭐ 3. QUALI MESSAGGI RICEVE FLUTTER (FORMATO ESATTO)
Il tuo backend invia questi eventi:
### 📌 Quando una foto viene aggiunta
```json
{
"type": "added",
"id": "12345"
}
```
### 📌 Quando una foto viene rimossa (DEL)
```json
{
"type": "removed",
"id": "12345"
}
```
### 📌 Quando una cartella viene aggiunta (ADD_DIR)
```json
{
"type": "add_dir",
"folder": "Vacanze/2024"
}
```
### 📌 Quando una cartella viene rimossa (DEL_DIR)
```json
{
"type": "del_dir",
"folder": "Vacanze/2024"
}
```
### 📌 Quando una foto viene soft-deleted / restored
```json
{
"type": "updated",
"id": "12345",
"deleted_at": "2026-03-31T10:00:00Z" // oppure null
}
```
---
# ⭐ 4. COME FLUTTER DEVE GESTIRE I MESSAGGI
Esempio completo:
```dart
channel.stream.listen((message) {
final data = jsonDecode(message);
switch (data["type"]) {
case "added":
// Scarica la foto via /photos/:id
fetchPhoto(data["id"]);
break;
case "removed":
// Rimuovi la foto dalla UI
removePhotoFromUI(data["id"]);
break;
case "updated":
// Aggiorna soft delete
updatePhotoState(data["id"], data["deleted_at"]);
break;
case "add_dir":
// Aggiorna lista cartelle
addFolderToUI(data["folder"]);
break;
case "del_dir":
// Rimuovi cartella dalla UI
removeFolderFromUI(data["folder"]);
break;
}
});
```
---
# ⭐ 5. COME DEVE RISPONDERE FLUTTER AL SERVER
Il tuo server **non richiede risposte** dal client WebSocket.
È **one-way**:
- Il server invia eventi
- Il client li riceve
- Il client aggiorna la UI
Flutter **non deve inviare nulla** al WebSocket.
---
# ⭐ 6. COME INTEGRARE TUTTO NEL VIEWER
### Al login:
- fai login HTTP
- salva il JWT
- apri il WebSocket
### Allavvio del viewer:
1. Carica tutte le foto via:
```
GET /photos
```
2. Avvia il WebSocket
3. Applica in tempo reale:
- added → aggiungi foto
- removed → rimuovi foto
- updated → aggiorna stato
- add_dir → aggiungi cartella
- del_dir → rimuovi cartella
### Dopo 2 minuti (quando WS non è più affidabile):
- usa progressive sync:
```
GET /photos/changes?since=<timestamp>
GET /photos/deleted_hard?since=<timestamp>
```
---

209
db/activities.js Normal file
View file

@ -0,0 +1,209 @@
// db/activities.js
const knex = require('./knex');
/* ============================================================
INSERT OR UPDATE ACTIVITY
============================================================ */
async function insertOrUpdateActivity(row) {
const exists = await knex('activities').where({ id: row.id }).first();
if (!exists) {
await knex('activities').insert({
id: row.id,
user: row.user,
cartella: row.cartella,
file_name: row.file_name,
file_extension: row.file_extension,
format: row.format,
display_name: row.display_name,
route_name: row.route_name,
title: row.title,
description: row.description,
notes: row.notes,
sport: row.sport,
activity_type: row.activity_type,
device_manufacturer: row.device_manufacturer,
device_model: row.device_model,
source_app: row.source_app,
start_time_millis: row.start_time_millis,
end_time_millis: row.end_time_millis,
duration_millis: row.duration_millis,
distance_meters: row.distance_meters,
elevation_gain_meters: row.elevation_gain_meters,
elevation_loss_meters: row.elevation_loss_meters,
min_elevation_meters: row.min_elevation_meters,
max_elevation_meters: row.max_elevation_meters,
file_distance_meters: row.file_distance_meters,
file_elevation_gain_meters: row.file_elevation_gain_meters,
computed_distance_meters: row.computed_distance_meters,
computed_elevation_gain_meters: row.computed_elevation_gain_meters,
average_speed_mps: row.average_speed_mps,
max_speed_mps: row.max_speed_mps,
average_pace_sec_per_km: row.average_pace_sec_per_km,
average_heart_rate: row.average_heart_rate,
max_heart_rate: row.max_heart_rate,
average_cadence: row.average_cadence,
max_cadence: row.max_cadence,
calories: row.calories,
power_avg: row.power_avg,
power_max: row.power_max,
point_count: row.point_count,
segment_count: row.segment_count,
lap_count: row.lap_count,
min_latitude: row.min_latitude,
max_latitude: row.max_latitude,
min_longitude: row.min_longitude,
max_longitude: row.max_longitude,
center_latitude: row.center_latitude,
center_longitude: row.center_longitude,
size_bytes: row.size_bytes,
last_modified_millis: row.last_modified_millis,
date_added_millis: row.date_added_millis,
date_modified_millis: row.date_modified_millis,
last_scan_millis: row.last_scan_millis,
is_favorite: row.is_favorite,
is_hidden: row.is_hidden,
rating: row.rating,
tags: row.tags ? JSON.stringify(row.tags) : null,
scan_status: row.scan_status,
scan_error: row.scan_error,
diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : null,
location: row.location ? JSON.stringify(row.location) : null,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
} else {
await knex('activities').where({ id: row.id }).update({
// aggiorna solo i campi che possono cambiare
file_name: row.file_name || exists.file_name,
file_extension: row.file_extension || exists.file_extension,
format: row.format || exists.format,
display_name: row.display_name || exists.display_name,
route_name: row.route_name || exists.route_name,
title: row.title || exists.title,
description: row.description || exists.description,
notes: row.notes || exists.notes,
sport: row.sport || exists.sport,
activity_type: row.activity_type || exists.activity_type,
device_manufacturer: row.device_manufacturer || exists.device_manufacturer,
device_model: row.device_model || exists.device_model,
source_app: row.source_app || exists.source_app,
start_time_millis: row.start_time_millis || exists.start_time_millis,
end_time_millis: row.end_time_millis || exists.end_time_millis,
duration_millis: row.duration_millis || exists.duration_millis,
distance_meters: row.distance_meters || exists.distance_meters,
elevation_gain_meters: row.elevation_gain_meters || exists.elevation_gain_meters,
elevation_loss_meters: row.elevation_loss_meters || exists.elevation_loss_meters,
min_elevation_meters: row.min_elevation_meters || exists.min_elevation_meters,
max_elevation_meters: row.max_elevation_meters || exists.max_elevation_meters,
file_distance_meters: row.file_distance_meters || exists.file_distance_meters,
file_elevation_gain_meters: row.file_elevation_gain_meters || exists.file_elevation_gain_meters,
computed_distance_meters: row.computed_distance_meters || exists.computed_distance_meters,
computed_elevation_gain_meters: row.computed_elevation_gain_meters || exists.computed_elevation_gain_meters,
average_speed_mps: row.average_speed_mps || exists.average_speed_mps,
max_speed_mps: row.max_speed_mps || exists.max_speed_mps,
average_pace_sec_per_km: row.average_pace_sec_per_km || exists.average_pace_sec_per_km,
average_heart_rate: row.average_heart_rate || exists.average_heart_rate,
max_heart_rate: row.max_heart_rate || exists.max_heart_rate,
average_cadence: row.average_cadence || exists.average_cadence,
max_cadence: row.max_cadence || exists.max_cadence,
calories: row.calories || exists.calories,
power_avg: row.power_avg || exists.power_avg,
power_max: row.power_max || exists.power_max,
point_count: row.point_count || exists.point_count,
segment_count: row.segment_count || exists.segment_count,
lap_count: row.lap_count || exists.lap_count,
min_latitude: row.min_latitude || exists.min_latitude,
max_latitude: row.max_latitude || exists.max_latitude,
min_longitude: row.min_longitude || exists.min_longitude,
max_longitude: row.max_longitude || exists.max_longitude,
center_latitude: row.center_latitude || exists.center_latitude,
center_longitude: row.center_longitude || exists.center_longitude,
size_bytes: row.size_bytes || exists.size_bytes,
last_modified_millis: row.last_modified_millis || exists.last_modified_millis,
date_added_millis: row.date_added_millis || exists.date_added_millis,
date_modified_millis: row.date_modified_millis || exists.date_modified_millis,
last_scan_millis: row.last_scan_millis || exists.last_scan_millis,
is_favorite: row.is_favorite ?? exists.is_favorite,
is_hidden: row.is_hidden ?? exists.is_hidden,
rating: row.rating ?? exists.rating,
tags: row.tags ? JSON.stringify(row.tags) : exists.tags,
scan_status: row.scan_status || exists.scan_status,
scan_error: row.scan_error || exists.scan_error,
diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : exists.diagnostics,
location: row.location ? JSON.stringify(row.location) : exists.location,
updated_at: knex.fn.now()
});
}
}
/* ============================================================
HARD DELETE
============================================================ */
async function markActivityDeletedHard(id, user) {
await knex('activities').where({ id, user }).del();
await knex('deleted_hard_activities')
.insert({ id, user, deleted_at: knex.fn.now() })
.catch(() => {});
}
/* ============================================================
CHANGES SINCE
============================================================ */
async function getActivitiesSince(user, sinceIso, limit = 1000) {
return knex('activities')
.where({ user })
.andWhere('updated_at', '>', sinceIso)
.orderBy('updated_at', 'asc')
.limit(limit)
.select('*');
}
/* ============================================================
DELETED HARD SINCE
============================================================ */
async function getDeletedHardActivitiesSince(user, sinceIso) {
return knex('deleted_hard_activities')
.where({ user })
.andWhere('deleted_at', '>', sinceIso)
.select('id', 'deleted_at');
}
/* ============================================================
EXPORT
============================================================ */
module.exports = {
insertOrUpdateActivity,
markActivityDeletedHard,
getActivitiesSince,
getDeletedHardActivitiesSince
};

142
db/dbWs.js Normal file
View file

@ -0,0 +1,142 @@
// db/dbWs.js
console.log(">>> dbWs.js CARICATO:", __filename);
const knex = require("./knex");
// ===============================
// SESSIONI
// ===============================
async function upsertSession(session_id, user, device_id) {
const now = Date.now();
const existing = await knex("ws_sessions")
.where({ session_id, device_id })
.first();
if (!existing) {
await knex("ws_sessions").insert({
session_id,
user,
device_id,
connected_at: now,
last_ack: now,
last_sync: null,
need_full_sync: false
});
} else {
await knex("ws_sessions")
.where({ session_id, device_id })
.update({
user,
connected_at: now,
last_ack: now
});
}
}
async function getDevicesForSession(session_id) {
const rows = await knex('ws_sessions')
.where({ session_id })
.select('device_id');
return rows.map(r => r.device_id);
}
async function getSession(session_id, device_id) {
return knex("ws_sessions")
.where({ session_id, device_id })
.first();
}
async function updateSessionAck(session_id, device_id) {
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ last_ack: Date.now() });
}
async function setSessionNeedFullSync(session_id, device_id, value) {
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ need_full_sync: value });
}
async function clearNeedFullSync(session_id, device_id) {
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ need_full_sync: false });
}
async function getSessionsByUser(user) {
return knex("ws_sessions").where({ user });
}
async function deleteSession(session_id, device_id) {
await knex("ws_sessions").where({ session_id, device_id }).del();
await knex("pending_events").where({ session_id, device_id }).del();
}
// ===============================
// PENDING EVENTS (nuovo modello)
// ===============================
async function insertPendingEvent({ user_id, session_id, device_id, event_id, payload, created_at }) {
return knex("pending_events").insert({
user_id,
session_id,
device_id,
event_id,
payload,
created_at
});
}
async function deletePendingEvent({ user_id, session_id, device_id, event_id }) {
return knex("pending_events")
.where({ user_id, session_id, device_id, event_id })
.delete();
}
async function getPendingEvents(user_id, session_id, device_id) {
return knex("pending_events")
.where({ user_id, session_id, device_id })
.orderBy("created_at", "asc");
}
async function deleteAllSessions() {
console.log(">>> deleteAllSessions() elimino tutte le sessioni WS");
return knex("ws_sessions").del();
}
async function deleteAllPendingEvents() {
console.log(">>> deleteAllPendingEvents() elimino tutti i pending events");
return knex("pending_events").del();
}
async function setLastSync(session_id, device_id, timestamp = null) {
const ts = timestamp || Date.now();
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ last_sync: ts });
}
// ===============================
// EXPORT
// ===============================
module.exports = {
upsertSession,
getSession,
updateSessionAck,
setSessionNeedFullSync,
clearNeedFullSync,
deleteSession,
getSessionsByUser,
insertPendingEvent,
deletePendingEvent,
getPendingEvents,
getDevicesForSession,
setLastSync,
deleteAllSessions,
deleteAllPendingEvents
};

290
db/init.js Normal file
View file

@ -0,0 +1,290 @@
// db/init.js
const db = require('./knex');
/* ============================================================
FOTO
============================================================ */
async function ensurePhotos() {
const exists = await db.schema.hasTable('photos');
if (!exists) {
await db.schema.createTable('photos', (t) => {
t.string('id').primary();
t.string('user');
t.string('cartella');
t.string('name');
t.string('path');
t.string('thub1');
t.string('thub2');
t.string('mime_type');
t.integer('width');
t.integer('height');
t.integer('rotation');
t.integer('size_bytes');
t.integer('mtimeMs');
t.integer('duration_ms');
t.string('taken_at');
t.string('data');
t.float('lat');
t.float('lon');
t.float('alt');
t.text('location');
t.string('_indexHash');
t.string('fast_hash');
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
t.datetime('deleted_at').nullable();
});
console.log("✔ photos creata");
} else {
const cols = [
'thub1','thub2','fast_hash','created_at','updated_at','deleted_at'
];
for (const col of cols) {
const existsCol = await db.schema.hasColumn('photos', col);
if (!existsCol) {
await db.schema.alterTable('photos', (t) => {
if (col === 'created_at' || col === 'updated_at')
t.datetime(col).defaultTo(db.fn.now());
else if (col === 'deleted_at')
t.datetime(col).nullable();
else
t.string(col);
});
console.log(`✔ aggiunta colonna photos.${col}`);
}
}
}
}
/* ============================================================
FOTO deleted_hard
============================================================ */
async function ensureDeletedHard() {
const exists = await db.schema.hasTable('deleted_hard');
if (!exists) {
await db.schema.createTable('deleted_hard', (t) => {
t.string('id').primary();
t.string('user');
t.datetime('deleted_at').defaultTo(db.fn.now());
});
console.log("✔ deleted_hard creata");
}
}
/* ============================================================
WS
============================================================ */
async function ensureWsSessions() {
const exists = await db.schema.hasTable('ws_sessions');
if (!exists) {
await db.schema.createTable('ws_sessions', (t) => {
t.string('session_id').notNullable();
t.string('device_id').notNullable();
t.string('user').notNullable();
t.integer('connected_at').notNullable();
t.integer('last_ack').notNullable();
t.integer('last_sync').nullable();
t.boolean('need_full_sync').notNullable().defaultTo(false);
t.primary(['session_id', 'device_id']);
});
console.log("✔ ws_sessions creata");
} else {
const cols = [
{ name: 'device_id', type: 'string' },
{ name: 'last_sync', type: 'integer' }
];
for (const c of cols) {
const existsCol = await db.schema.hasColumn('ws_sessions', c.name);
if (!existsCol) {
await db.schema.alterTable('ws_sessions', (t) => {
if (c.type === 'string') t.string(c.name).notNullable().defaultTo('default');
if (c.type === 'integer') t.integer(c.name).nullable();
});
console.log(`✔ aggiunta colonna ws_sessions.${c.name}`);
}
}
}
}
async function ensureWsPending() {
const exists = await db.schema.hasTable('ws_pending_events');
if (!exists) {
await db.schema.createTable('ws_pending_events', (t) => {
t.string('event_id').primary();
t.string('session_id').notNullable();
t.text('payload').notNullable();
t.integer('sent_at').notNullable();
t.integer('retries').notNullable().defaultTo(0);
});
console.log("✔ ws_pending_events creata");
}
}
async function ensurePendingEvents() {
const exists = await db.schema.hasTable('pending_events');
if (!exists) {
await db.schema.createTable('pending_events', (t) => {
t.increments('id').primary();
t.string('user_id').notNullable();
t.string('session_id').notNullable();
t.string('device_id').notNullable();
t.string('event_id').notNullable();
t.text('payload').notNullable();
t.integer('created_at').notNullable();
t.unique(['user_id', 'session_id', 'device_id', 'event_id']);
});
console.log("✔ pending_events creata");
}
}
/* ============================================================
🏃 ATTIVITÀ nuova tabella
============================================================ */
async function ensureActivities() {
const exists = await db.schema.hasTable('activities');
if (!exists) {
await db.schema.createTable('activities', (t) => {
t.string('id').primary();
t.string('user');
t.string('cartella');
t.string('file_name');
t.string('file_extension');
t.string('format');
t.string('display_name');
t.string('route_name');
t.string('title');
t.string('description');
t.string('notes');
t.string('sport');
t.string('activity_type');
t.string('device_manufacturer');
t.string('device_model');
t.string('source_app');
t.bigInteger('start_time_millis');
t.bigInteger('end_time_millis');
t.bigInteger('duration_millis');
t.float('distance_meters');
t.float('elevation_gain_meters');
t.float('elevation_loss_meters');
t.float('min_elevation_meters');
t.float('max_elevation_meters');
t.float('file_distance_meters');
t.float('file_elevation_gain_meters');
t.float('computed_distance_meters');
t.float('computed_elevation_gain_meters');
t.float('average_speed_mps');
t.float('max_speed_mps');
t.float('average_pace_sec_per_km');
t.integer('average_heart_rate');
t.integer('max_heart_rate');
t.integer('average_cadence');
t.integer('max_cadence');
t.integer('calories');
t.integer('power_avg');
t.integer('power_max');
t.integer('point_count');
t.integer('segment_count');
t.integer('lap_count');
t.float('min_latitude');
t.float('max_latitude');
t.float('min_longitude');
t.float('max_longitude');
t.float('center_latitude');
t.float('center_longitude');
t.bigInteger('size_bytes');
t.bigInteger('last_modified_millis');
t.bigInteger('date_added_millis');
t.bigInteger('date_modified_millis');
t.bigInteger('last_scan_millis');
t.boolean('is_favorite');
t.boolean('is_hidden');
t.integer('rating');
t.text('tags');
t.text('track_preview');
t.string('scan_status');
t.text('scan_error');
t.string('path');
t.text('diagnostics');
t.text('location');
t.string('fast_hash');
t.datetime('deleted_at').nullable();
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
});
console.log("✔ activities creata");
}
}
/* ============================================================
ATTIVITÀ deleted_hard
============================================================ */
async function ensureDeletedHardActivities() {
const exists = await db.schema.hasTable('deleted_hard_activities');
if (!exists) {
await db.schema.createTable('deleted_hard_activities', (t) => {
t.string('id').primary();
t.string('user');
t.datetime('deleted_at').defaultTo(db.fn.now());
});
console.log("✔ deleted_hard_activities creata");
}
}
/* ============================================================
ATTIVITÀ sync_done
============================================================ */
async function ensureSyncDoneActivities() {
const exists = await db.schema.hasTable('sync_done_activities');
if (!exists) {
await db.schema.createTable('sync_done_activities', (t) => {
t.string('session_id');
t.string('device_id');
t.bigInteger('last_sync');
});
console.log("✔ sync_done_activities creata");
}
}
/* ============================================================
INIT
============================================================ */
async function init() {
try {
console.log("🔧 init DB...");
await ensurePhotos();
await ensureDeletedHard();
await ensureActivities();
await ensureDeletedHardActivities();
await ensureSyncDoneActivities();
await ensureWsSessions();
await ensureWsPending();
await ensurePendingEvents();
console.log("✅ init completato");
process.exit(0);
} catch (err) {
console.error("❌ errore init:", err);
process.exit(1);
}
}
init();

12
db/knex.js Normal file
View file

@ -0,0 +1,12 @@
// db/knex.js
const knex = require('knex');
const db = knex({
client: 'better-sqlite3',
connection: {
filename: './api_v1/database.sqlite'
},
useNullAsDefault: true
});
console.log("📌 DB PATH:", require('path').resolve('./api_v1/database.sqlite'));
module.exports = db;

73
db/photos.js Normal file
View file

@ -0,0 +1,73 @@
// db/photos.js
const knex = require('./knex');
async function insertOrUpdatePhoto(row) {
// row deve contenere almeno id, user, cartella, name, path, thumb1, thumb2, fast_hash, mtimeMs, etc.
const exists = await knex('photos').where({ id: row.id }).first();
if (!exists) {
await knex('photos').insert({
id: row.id,
user: row.user,
cartella: row.cartella,
name: row.name,
path: row.path,
thumb1: row.thumb1 || null,
thumb2: row.thumb2 || null,
mime_type: row.mime_type || null,
width: row.width || null,
height: row.height || null,
rotation: row.rotation || null,
size_bytes: row.size_bytes || null,
mtimeMs: row.mtimeMs || null,
taken_at: row.taken_at || null,
fast_hash: row.fast_hash || null,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
} else {
await knex('photos').where({ id: row.id }).update({
path: row.path,
thumb1: row.thumb1 || exists.thumb1,
thumb2: row.thumb2 || exists.thumb2,
mime_type: row.mime_type || exists.mime_type,
width: row.width || exists.width,
height: row.height || exists.height,
rotation: row.rotation || exists.rotation,
size_bytes: row.size_bytes || exists.size_bytes,
mtimeMs: row.mtimeMs || exists.mtimeMs,
taken_at: row.taken_at || exists.taken_at,
fast_hash: row.fast_hash || exists.fast_hash,
updated_at: knex.fn.now(),
deleted_at: null
});
}
}
async function markPhotoDeletedHard(id, user) {
await knex('photos').where({ id, user }).del();
await knex('deleted_hard').insert({ id, user, deleted_at: knex.fn.now() }).catch(() => {});
}
async function getPhotosSince(user, sinceIso, limit = 1000) {
// ritorna foto aggiornate dopo sinceIso (ISO string)
return knex('photos')
.where({ user })
.andWhere('updated_at', '>', sinceIso)
.orderBy('updated_at', 'asc')
.limit(limit)
.select('*');
}
async function getDeletedHardSince(user, sinceIso) {
return knex('deleted_hard')
.where({ user })
.andWhere('deleted_at', '>', sinceIso)
.select('id', 'deleted_at');
}
module.exports = {
insertOrUpdatePhoto,
markPhotoDeletedHard,
getPhotosSince,
getDeletedHardSince
};

22
file_lungo.md Normal file
View file

@ -0,0 +1,22 @@
prettier --write public/js/map/mapboxMarkers.js
wc -l public/js/map/mapboxMarkers.js
sed -n '1,500p' public/js/map/mapboxMarkers.js
sed -n '501,1000p' public/js/map/mapboxMarkers.js
sed -n '1001,1600p' public/js/map/mapboxMarkers.js
pervstampare a video una funzione scritta come
function myfunction { .... } nel file
questa funzione conta le parentesi graffe
awk '
/^[[:space:]]*function[[:space:]]+myFunction[[:space:]]*\{/ {
p=1
n=0
}
p {
print
n += gsub(/\{/, "{")
n -= gsub(/\}/, "}")
if (n == 0) exit
}
' file

40
find-esm.js Normal file
View file

@ -0,0 +1,40 @@
const fs = require("fs");
const path = require("path");
function scan(dir) {
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const full = path.join(dir, item.name);
if (item.isDirectory()) {
scan(full);
continue;
}
// 1) File .mjs → ESM sicuro
if (full.endsWith(".mjs")) {
console.log("⚠️ FILE .mjs (ESM):", full);
continue;
}
// 2) File .js → controlliamo se contiene import/export
if (full.endsWith(".js")) {
const content = fs.readFileSync(full, "utf8");
if (/^\s*import\s/m.test(content)) {
console.log("⚠️ IMPORT trovato:", full);
}
if (/^\s*export\s/m.test(content)) {
console.log("⚠️ EXPORT trovato:", full);
}
if (/import\.meta\.url/.test(content)) {
console.log("⚠️ import.meta.url trovato:", full);
}
}
}
}
console.log("🔍 Scansione in corso...");
scan(process.cwd());
console.log("✔️ Scansione completata.");

27
generate_token.js Normal file
View file

@ -0,0 +1,27 @@
// generate_token.js
require("dotenv").config();
const jwt = require("jsonwebtoken");
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
console.error("❌ ERRORE: JWT_SECRET non definito nel .env");
process.exit(1);
}
const user = process.argv[2];
if (!user) {
console.error("Uso: node generate_token.js <nome_utente>");
process.exit(1);
}
const token = jwt.sign(
{ name: user },
JWT_SECRET,
{ expiresIn: "365d" } // opzionale
);
console.log(`\n🔑 Token generato per utente "${user}":\n`);
console.log(token);
console.log("\n✔ Copialo nel watcher.\n");

View file

@ -0,0 +1,158 @@
// helpers/normalizeActivity.js
function safeJsonParse(value, fallback) {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
function normalizeActivity(a) {
// ============================
// GPS compatibile con /photos
// ============================
const gps = (a.center_latitude != null && a.center_longitude != null)
? {
lat: a.center_latitude,
lng: a.center_longitude,
alt: a.min_elevation_meters ?? null
}
: null;
// ============================
// JSON fields
// ============================
const location = safeJsonParse(a.location, null);
const tags = safeJsonParse(a.tags, []);
const diagnostics = safeJsonParse(a.diagnostics, null);
// Preview percorso per thumbnails attività
const track_preview = safeJsonParse(a.track_preview, []);
// ============================
// Path stile /photos
// ============================
const path = `/activities/${a.user}/original/${a.cartella}/${a.file_name}`;
// ============================
// MIME type
// ============================
const mime_type = a.format || "application/octet-stream";
// ============================
// mtimeMs compatibile progressiveSync
// ============================
const mtimeMs =
a.last_modified_millis ??
a.date_modified_millis ??
a.date_added_millis ??
Date.now();
return {
// ============================
// Campi base come /photos
// ============================
id: a.id,
user: a.user,
cartella: a.cartella,
name: a.file_name,
path,
mime_type,
size_bytes: a.size_bytes,
mtimeMs,
taken_at: a.start_time_millis
? new Date(Number(a.start_time_millis)).toISOString()
: null,
data: a.start_time_millis
? new Date(Number(a.start_time_millis)).toISOString()
: null,
location,
gps,
// ============================
// Campi speciali delle activity
// ============================
file_name: a.file_name,
file_extension: a.file_extension,
format: a.format,
display_name: a.display_name,
route_name: a.route_name,
title: a.title,
description: a.description,
notes: a.notes,
sport: a.sport,
activity_type: a.activity_type,
device_manufacturer: a.device_manufacturer,
device_model: a.device_model,
source_app: a.source_app,
start_time_millis: a.start_time_millis,
end_time_millis: a.end_time_millis,
duration_millis: a.duration_millis,
distance_meters: a.distance_meters,
elevation_gain_meters: a.elevation_gain_meters,
elevation_loss_meters: a.elevation_loss_meters,
min_elevation_meters: a.min_elevation_meters,
max_elevation_meters: a.max_elevation_meters,
file_distance_meters: a.file_distance_meters,
file_elevation_gain_meters: a.file_elevation_gain_meters,
computed_distance_meters: a.computed_distance_meters,
computed_elevation_gain_meters: a.computed_elevation_gain_meters,
average_speed_mps: a.average_speed_mps,
max_speed_mps: a.max_speed_mps,
average_pace_sec_per_km: a.average_pace_sec_per_km,
average_heart_rate: a.average_heart_rate,
max_heart_rate: a.max_heart_rate,
average_cadence: a.average_cadence,
max_cadence: a.max_cadence,
calories: a.calories,
power_avg: a.power_avg,
power_max: a.power_max,
point_count: a.point_count,
segment_count: a.segment_count,
lap_count: a.lap_count,
min_latitude: a.min_latitude,
max_latitude: a.max_latitude,
min_longitude: a.min_longitude,
max_longitude: a.max_longitude,
center_latitude: a.center_latitude,
center_longitude: a.center_longitude,
last_modified_millis: a.last_modified_millis,
date_added_millis: a.date_added_millis,
date_modified_millis: a.date_modified_millis,
last_scan_millis: a.last_scan_millis,
is_favorite: !!a.is_favorite,
is_hidden: !!a.is_hidden,
rating: a.rating,
tags,
// Preview percorso per thumbnails FIT/GPX/TCX
track_preview,
scan_status: a.scan_status,
scan_error: a.scan_error,
// Importante per soft delete in gallery
deleted_at: a.deleted_at || null,
diagnostics
};
}
module.exports = normalizeActivity;

155
map.md Normal file
View file

@ -0,0 +1,155 @@
Sì. Ora che mi hai passato tutte e 3 le parti, possiamo dividerlo davvero in modo funzionale, senza cambiare il comportamento attuale.
Ti consiglio di non fare semplicemente 3 file da ~500 righe, perché tra poco avrai anche le attività e torneremmo allo stesso problema.
Struttura che farei
public/js/map/
├── mapboxMarkers.js ← ENTRY POINT / API pubblica
├── mapboxState.js ← stato condiviso della mappa
├── mapboxUtils.js ← funzioni comuni
├── photoMarkers.js ← marker foto singole + spiderfy
├── photoClusters.js ← cluster foto HTML + smart zoom
├── mapboxSources.js ← sources/layers Mapbox
└── activityMarkers.js ← attività, da sviluppare
In questo modo avremo una separazione molto più pulita:
File Responsabilità
mapboxMarkers.js Facciata/API pubblica
mapboxState.js mapboxMap, foto, attività, routes, Map dei marker
mapboxUtils.js distanza, placeholder, funzioni comuni
photoMarkers.js foto singole e spiderfy
photoClusters.js cluster fotografici, collage, smart zoom
mapboxSources.js addSource, addLayer, aggiornamento GeoJSON
activityMarkers.js nuova gestione attività
E soprattutto eliminiamo una cosa importante
Nel codice che mi hai mandato ci sono due sistemi di cluster HTML:
updatePhotoClusterMarkers()
e
updatePhotoClusterHtmlMarkers()
che fanno sostanzialmente la stessa cosa ma in modo leggermente diverso.
Questo è uno dei motivi per cui il codice sta diventando difficile da mantenere.
Inoltre nella seconda implementazione c'è questa chiamata:
handlePhotoClusterClick(clusterFeature);
mentre la funzione è definita come:
function handlePhotoClusterClick(clusterId, center)
Quindi abbiamo anche una piccola incoerenza che conviene sistemare durante la divisione.
Il pallino blu di Mapbox
La modifica che hai fatto:
"circle-opacity": id === "photos" ? 0 : 0.9,
"circle-stroke-opacity": id === "photos" ? 0 : 1,
la manterrei.
Quindi:
Mapbox continua a creare/renderizzare il cluster;
queryRenderedFeatures() continua a trovarlo;
il cerchio blu di Mapbox non viene mostrato;
sopra ci mettiamo il nostro cluster HTML con le miniature.
Questo è importante perché se rimuoviamo completamente il layer photos-clusters, il sistema di cluster HTML che hai costruito perde il riferimento ai cluster visibili.
---
Punto fondamentale per le attività
Non farei:
if (id === "photos") {
...
}
if (id === "activities") {
...
}
dappertutto.
Meglio avere un sistema generico:
Mapbox
├── Photos
│ ├── source
│ ├── clusters
│ ├── markers
│ └── spiderfy
└── Activities
├── source
├── clusters
└── markers
Così in futuro possiamo aggiungere, per esempio:
activityMarkers.js
activityClusters.js
senza toccare il sistema delle foto.
---
Quindi la versione finale sarà concettualmente così
// mapboxMarkers.js
import { setMap } from "./mapboxSources.js";
import {
setPhotos,
clearPhotos,
} from "./photoMarkers.js";
import {
setActivities,
clearActivities,
} from "./activityMarkers.js";
import {
setRoutes,
} from "./mapboxSources.js";
window.MapboxMarkers = {
setMap,
setPhotos,
setActivities,
setRoutes,
};
e tutti i dettagli rimangono nei rispettivi moduli.
Non cambierei per ora la logica dei cluster/spiderfy più del necessario. Prima facciamo una separazione 1:1 del comportamento attuale, così non rischiamo di perdere i cluster che finalmente funzionano; poi possiamo migliorare il codice internamente.
L'unica cosa da verificare nel progetto è che questi file vengano caricati come ES modules, cioè con:
<script type="module" src="/js/map/mapboxMarkers.js"></script>
Se il tuo progetto è già impostato così, possiamo procedere direttamente con questa struttura.

18
middleware/hls.js Normal file
View file

@ -0,0 +1,18 @@
const express = require('express');
const path = require('path');
module.exports = express.static(
path.join(__dirname, '..', 'public/hls'),
{
setHeaders: (res, filePath) => {
if (filePath.endsWith('.m3u8')) {
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
}
if (filePath.endsWith('.ts')) {
res.setHeader('Content-Type', 'video/mp2t');
}
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cache-Control', 'public, max-age=3600');
}
}
);

22
middleware/jwt.js Normal file
View file

@ -0,0 +1,22 @@
const { verifyToken, isRevoked } = require('../utils/jwtHelpers');
const jwtMiddleware = (req, res, next) => {
const auth = req.headers.authorization || '';
const [scheme, token] = auth.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ status: 401, message: 'Bad authorization header' });
}
if (isRevoked(token)) {
return res.status(401).json({ status: 401, message: 'Token revoked' });
}
try {
req.user = verifyToken(token);
next();
} catch {
return res.status(401).json({ status: 401, message: 'Error: access_token is not valid' });
}
};
module.exports = { jwtMiddleware };

28
middleware/static.js Normal file
View file

@ -0,0 +1,28 @@
const express = require('express');
const path = require('path');
const PUBLIC_ROOT = path.join(__dirname, '..', 'public');
module.exports = express.static(PUBLIC_ROOT, {
etag: true,
lastModified: true,
cacheControl: true,
setHeaders(res, filePath) {
const normalizedPath = filePath
.replaceAll('\\', '/')
.toLowerCase();
const isThumbnail =
normalizedPath.includes('/photos/') &&
normalizedPath.includes('/thumbs/') &&
/\.(jpg|jpeg|png|webp|gif)$/i.test(normalizedPath);
if (isThumbnail) {
res.setHeader(
'Cache-Control',
'public, max-age=86400'
);
}
}
});

5
middleware/static.js.ok Normal file
View file

@ -0,0 +1,5 @@
const express = require('express');
const path = require('path');
module.exports = express.static(path.join(__dirname, '..', 'public'));

2531
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"type": "commonjs",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.3.1",
"exifreader": "^4.37.0",
"express": "^5.2.1",
"fit-file-parser": "^3.1.3",
"jsonwebtoken": "^9.0.3",
"knex": "^3.1.0",
"mime-types": "^3.0.2",
"rbush": "^4.0.1",
"sharp": "^0.34.5",
"uuid": "^14.0.0",
"ws": "^8.21.0",
"xml2js": "^0.6.2"
}
}

BIN
part.bin Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

40
print_function.sh Executable file
View file

@ -0,0 +1,40 @@
#!/bin/bash
# Estrae e stampa una funzione da un file.
#
# UTILIZZO:
# ./print_function.sh <nome_funzione> <file>
#
# ESEMPIO:
# ./print_function.sh myFunction myfile
#
# La funzione viene stampata sullo standard output.
if [ "$#" -ne 2 ]; then
echo "Uso: $0 <nome_funzione> <file>"
exit 1
fi
FUNCTION="$1"
FILE="$2"
awk -v func="$FUNCTION" '
/^[[:space:]]*function[[:space:]]+/ {
if ($0 ~ "^[[:space:]]*function[[:space:]]+" func "[[:space:]]*\\{") {
found=1
braces=0
}
}
found {
print
braces += gsub(/\{/, "{")
braces -= gsub(/\}/, "}")
if (braces == 0) {
exit
}
}
' "$FILE"

696
public/README.md Normal file
View file

@ -0,0 +1,696 @@
Certo Fabio — ora che tutto il backend e il frontend sono finalmente coerenti, ti preparo un README tecnico completo, scritto come si deve, chiaro, professionale e utile sia per te che per chiunque metterà mano al progetto in futuro.
È pensato per essere messo direttamente in README.md alla radice del progetto.
---
📸 PhotoGallery — README Tecnico Completo
Una piattaforma completa per la gestione di foto multiutente, con:
- autenticazione JWT
- scansione automatica delle cartelle
- thumbnails generate lato server
- sincronizzazione incrementale
- WebSocket realtime
- frontend modulare e reattivo
- mappa globale stile Google Photos
- pannello info EXIF + geolocalizzazione
- bottom sheet multifoto
- modal fullscreen con navigazione
---
🚀 1. Architettura generale
`
backend/
server.js
api_v1/
scanner/
config.js
...
db/
knex.js
routes/
photos.js
public/
index.html
login.html
css/
js/
auth.js
login.js
logout.js
config.js
api.js
state.js
sync.js
gallery.js
modal.js
infoPanel.js
mapGlobal.js
bottomSheet.js
optionsSheet.js
`
---
🔐 2. Autenticazione
Login
POST /auth/login
Richiede:
`json
{ "email": "...", "password": "..." }
`
Risponde:
`json
{ "token": "JWT...", "name": "Fabio" }
`
Il token contiene:
`json
{
"id": 1,
"email": "fabio@example.com",
"name": "Fabio",
"exp": 1234567890
}
`
Logout
POST /auth/logout
Il token viene inserito in denylist.
Middleware
Tutte le rotte (eccetto /auth/*) richiedono:
`
Authorization: Bearer <token>
`
Il middleware aggiunge automaticamente:
`
req.query.user = [req.user.name, "Common"]
`
Quindi il frontend non deve mai passare user=.
---
🗂️ 3. Configurazione
GET /config restituisce:
`json
{
"baseUrl": "https://…",
"pathFull": false,
"galleryRefreshSeconds": 30
}
`
Il frontend usa:
- baseUrl per costruire URL assoluti
- pathFull per capire se i path sono già completi
- galleryRefreshSeconds per il polling
---
🖼️ 4. Foto: API principali
Tutte le foto
GET /photos?user[]=Fabio&user[]=Common
(aggiunto automaticamente dal middleware)
Foto per ID
GET /photos/byIds?id=123
Cambiamenti incrementali
GET /photos/changes?since=<ISO>
Restituisce:
`json
{
"changes": [
{ "photoid": 123, "changetype": "added", "timestamp": "..." },
{ "photoid": 456, "changetype": "removed", "timestamp": "..." }
]
}
`
---
🔄 5. Sincronizzazione
Il frontend implementa:
Full load
- chiamato al primo avvio
- scarica tutte le foto
- salva in localStorage
- aggiorna lastSync
Incremental sync
- usa /photos/changes
- applica solo differenze
- aggiorna la gallery
- aggiorna lastSync
WebSocket realtime
Il server invia:
`
{ type: "added", id }
{ type: "removed", id }
{ type: "add_dir", folder }
{ type: "del_dir", folder }
`
Il frontend aggiorna:
- stato locale
- gallery
- mappa
---
🗺️ 6. Mappa globale
Basata su Leaflet + MarkerCluster.
- clustering automatico
- collage thumbnails nei cluster
- click cluster → bottom sheet
- click foto → modal
- refresh automatico quando cambia la gallery
---
🪟 7. Modal foto/video
Funzionalità:
- navigazione ← →
- click ai bordi
- tastiera
- preload ±3 foto
- supporto video (mp4/webm/mov)
- pannello info integrato
---
8. Info Panel
Mostra:
- nome
- data
- dimensioni
- peso
- MIME
- cartella
- EXIF GPS
- reverse geocoding (se presente)
- mappa dedicata
Autorefresh quando cambi foto nel modal.
---
📚 9. Bottom Sheet
Usato per:
- mostrare gruppi di foto (cluster mappa)
- navigazione rapida
- apertura modal
---
⚙️ 10. Options Sheet
Gestisce:
- ordinamento (asc/desc)
- raggruppamento (auto/day/month/year)
- filtri (folder/location/type)
Aggiorna la gallery in tempo reale.
---
🧠 11. Stato locale
Gestito in state.js:
- localPhotos[]
- lastSync
- cache in localStorage
- funzioni: add/remove/update
---
🧩 12. Costruzione URL media
Tutto passa da:
`js
mediaUrlsFromPhoto(photo)
`
Restituisce:
`js
{ original, preview }
`
Supporta:
- path relativi
- path assoluti
- baseUrl dinamico
- cartella utente
---
🧪 13. Test endtoend consigliato
1. Vai su /login
2. Inserisci email/password
3. Redirect a /
4. Full load → gallery appare
5. Apri una foto → modal
6. Apri info → mappa + EXIF
7. Apri mappa globale → cluster
8. Click cluster → bottom sheet
9. Click foto → modal
10. Cancella una foto dal filesystem → WS → frontend aggiorna
11. Logout → redirect a login
---
🛠️ 14. Requisiti
- Node.js 18+
- SQLite3
- ffmpeg (per thumbnails video)
- ImageMagick (per thumbnails foto)
---
▶️ 15. Avvio
`bash
npm install
node server.js
`
Server su:
`
http://localhost:4000
`
---
🎯 16. TODO / miglioramenti futuri
- virtualizzazione gallery (migliaia di foto)
- ricerca per testo
- timeline orizzontale
- upload drag&drop
- editing EXIF
- preferiti / album
---
💬 17. Contatti
Progetto sviluppato da Fabio
Backend + frontend completamente integrati.
---
Perfetto Fabio — ti preparo il diagramma architetturale completo, chiaro, leggibile e fedele al tuo backend e al tuo frontend attuale.
Niente fronzoli, niente astrazioni inutili: solo ciò che serve davvero per capire come gira tutto.
---
🧩 DIAGRAMMA ARCHITETTURALE — PhotoGallery System
1) Vista generale (HighLevel)
`
┌──────────────────────────┐
│ FRONTEND │
│ (public/, JS modules) │
└─────────────┬────────────┘
│ HTTP + WS
┌─────────────▼────────────┐
│ BACKEND │
│ (Node + Express) │
└─────────────┬────────────┘
│ DB queries
┌─────────────▼────────────┐
│ SQLite DB │
│ (tabella photos) │
└───────────────────────────┘
`
---
2) Dettaglio Frontend (moduli JS)
`
┌──────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ index.html / login.html │
│ │ │
│ ▼ │
│ auth.js ←→ login.js ←→ logout.js │
│ │ │
│ ▼ │
│ config.js → api.js → state.js → sync.js │
│ │ │ │ │
│ │ │ └── WebSocket │
│ ▼ ▼ │
│ gallery.js ←→ modal.js ←→ infoPanel.js │
│ │ │ │
│ ▼ ▼ │
│ mapGlobal.js ←→ bottomSheet.js ←→ optionsSheet.js │
└──────────────────────────────────────────────────────────────┘
`
Flusso principale:
1. auth.js controlla token → se valido, mostra app
2. config.js carica /config
3. sync.js fa full load → incremental sync → WebSocket
4. state.js mantiene foto locali
5. gallery.js renderizza
6. modal.js apre foto/video
7. infoPanel.js mostra EXIF + mappa
8. mapGlobal.js mostra mappa globale
9. bottomSheet.js mostra strip foto
10. optionsSheet.js gestisce filtri/ordinamento
---
3) Dettaglio Backend (server.js)
`
┌──────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ server.js │
│ │ │
│ ├── STATIC: serve public/ │
│ ├── /config │
│ ├── /auth/login │
│ ├── /auth/logout │
│ │ │
│ ├── JWT middleware │
│ │ ├── verify token │
│ │ ├── denylist │
│ │ └── req.user = { id, email, name } │
│ │ │
│ ├── GET middleware (user filtering) │
│ │ └── req.query.user = [req.user.name, "Common"] │
│ │ │
│ ├── /scan (Admin → tutti, User → solo se stesso) │
│ ├── /apiv1/autoscan (ADD, DEL, ADDDIR, DELDIR) │
│ │ └── WebSocket broadcast │
│ │ │
│ ├── /photos (router SQLite) │
│ │ ├── /photos │
│ │ ├── /photos/byIds │
│ │ └── /photos/changes │
│ │ │
│ ├── /files (serve file statici sicuri) │
│ ├── /initDB /initDBuser │
│ │ │
│ └── ws-server.js (WebSocket) │
└──────────────────────────────────────────────────────────────┘
`
---
4) Flusso completo di una foto (endtoend)
`
[1] File aggiunto nel filesystem
[2] scan_auto → type="ADD"
├── scanFile()
├── scanPhotoSingle()
├── INSERT in DB
├── genera thumbnails
└── WS: { type:"added", id }
[3] Frontend riceve WS
├── getPhotoById(id)
├── addPhotoLocal()
├── refreshGallery()
└── redrawPhotoMarkers()
`
---
5) Flusso login → gallery
`
login.html
login.js → AppAuth.login(email, password)
POST /auth/login
token JWT salvato
redirect → /
index.html
auth.js.isLoggedIn() → OK
config.js → GET /config
sync.js.fullLoad()
GET /photos
state.js.setLocalPhotos()
gallery.js.renderGallery()
mapGlobal.js.redrawPhotoMarkers()
`
---
6) Diagramma WebSocket
`
┌──────────────┐ WS ┌──────────────┐
│ Backend │ ───────────→ │ Frontend │
└──────────────┘ └──────────────┘
│ │
│ added → { id } │
│────────────────────────────────▶│ addPhotoLocal()
│ │ refreshGallery()
│ │ redrawPhotoMarkers()
│ │
│ removed → { id } │
│────────────────────────────────▶│ removePhotoLocal()
│ │ refreshGallery()
│ │ redrawPhotoMarkers()
│ │
│ adddir / deldir │
│────────────────────────────────▶│ incrementalSync()
`
---
7) Diagramma Database
`
┌──────────────────────────────┐
│ photos │
├──────────────────────────────┤
│ id (PK) │
│ user │
│ name │
│ path │
│ cartella │
│ mime_type │
│ width │
│ height │
│ size_bytes │
│ taken_at │
│ gps_lat │
│ gps_lng │
│ gps_alt │
│ location_json │
│ created_at │
└──────────────────────────────┘
`
---
8) Diagramma URL media
`
BASE_URL/photos/<user>/<type>/<cartella>/<file>
Esempi:
original:
https://server/photos/Fabio/original/2024/IMG_001.jpg
thumbs:
https://server/photos/Fabio/thumbs/2024/IMG001thub2.jpg
`
---
9) Diagramma dei moduli JS (dipendenze)
`
auth.js
login.js → logout.js
index.html
config.js → api.js → state.js → sync.js → WebSocket
↓ ↓ ↓
gallery.js ← modal.js ← infoPanel.js
mapGlobal.js ← bottomSheet.js ← optionsSheet.js
`
---
Utente Browser/Frontend Backend (Express) DB (SQLite)
│ │ │ │
│ 1. Apre /login │ │ │
├──────────────────────────▶│ │ │
│ │ │ │
│ │ 2. Inserisce email/password │ │
│ ├────────────────────────────────▶│ POST /auth/login │
│ │ │ │
│ │ │ 3. Verifica utente │
│ │ │ bcrypt.compare() │
│ │ ├────────────────────────▶│
│ │ │ │
│ │ │ 4. Genera JWT │
│ │ │ createToken() │
│ │ │ │
│ │ 5. Riceve token │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 6. Salva token (localStorage) │ │
│ │ 7. Redirect → / │ │
├──────────────────────────▶│ │ │
│ │ │ │
│ │ 8. GET /config │ │
│ ├────────────────────────────────▶│ │
│ │ │ │
│ │ 9. Riceve config │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 10. Full Sync │ │
│ │ GET /photos │ │
│ ├────────────────────────────────▶│ │
│ │ │ 11. Middleware JWT │
│ │ │ req.user = {name,...} │
│ │ │ │
│ │ │ 12. Filtra per utente │
│ │ │ req.query.user=[Fabio,Common]
│ │ │ │
│ │ ├────────────────────────▶│ SELECT * FROM photos WHERE user IN (...)
│ │ │ │
│ │ 13. Riceve lista foto │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 14. state.setLocalPhotos() │ │
│ │ 15. gallery.render() │ │
│ │ 16. mapGlobal.redrawMarkers() │ │
│ │ │ │
│ │ 17. Apre WebSocket │ │
│ ├────────────────────────────────▶│ ws-server │
│ │ │ │
│ │ │ │
│ │ 18. Incremental Sync (polling) │ │
│ │ GET /photos/changes?since=... │ │
│ ├────────────────────────────────▶│ │
│ │ │ │
│ │ 19. Riceve changes │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 20. Applica differenze │ │
│ │ state.add/remove/update │ │
│ │ gallery.refresh() │ │
│ │ mapGlobal.redrawMarkers() │ │
│ │ │ │
│ │ │ │
│ │ 21. Evento reale: file aggiunto │ │
│ │ │ scan_auto → ADD │
│ │ │ INSERT in DB │
│ │ ├────────────────────────▶│
│ │ │ │
│ │ │ 22. WS broadcast │
│ ◀─────────────────────────────────┤ {type:"added", id} │
│ │ │ │
│ │ 23. getPhotoById(id) │ │
│ ├────────────────────────────────▶│ /photos/byIds │
│ │ │ │
│ │ 24. Riceve foto │ │
│ ◀─────────────────────────────────┤ │
│ │ │ │
│ │ 25. state.addPhotoLocal() │ │
│ │ 26. gallery.refresh() │ │
│ │ 27. mapGlobal.redrawMarkers() │ │
│ │ │ │
│ │ │ │
│ 28. Clic su foto │ │ │
├──────────────────────────▶│ modal.open(photo) │ │
│ │ infoPanel.render(photo) │ │
│ │ │ │
│ │ │ │
│ 29. Logout │ │ │
├──────────────────────────▶│ AppAuth.logout() │ │
│ ├────────────────────────────────▶│ POST /auth/logout │
│ │ │ addToDenylist(token) │
│ │ │ │
│ │ 30. clearTokens() │ │
│ │ 31. redirect → /login │ │
└───────────────────────────┴─────────────────────────────────┴─────────────────────────┘

10
public/a Normal file
View file

@ -0,0 +1,10 @@
- async function login(username, password) {
+ async function login(email, password) {
const res = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ username, password })
+ body: JSON.stringify({ email, password })
});
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
{
"current": 8,
"total": 8,
"percent": 100,
"eta": "0m 0s"
}

143
public/admin.html Normal file
View file

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Photo & Activity Manager</title>
<link rel="stylesheet" href="/css/admin.css">
<style>
#progressContainer {
width: 100%;
background: #ddd;
height: 25px;
border-radius: 5px;
margin-top: 20px;
overflow: hidden;
}
#progressBar {
height: 100%;
width: 0%;
background: #4caf50;
transition: width 0.3s linear;
}
#scanInfo {
font-family: monospace;
margin-top: 10px;
font-size: 16px;
}
#changesBox {
margin-top: 20px;
padding: 10px;
border: 1px solid #aaa;
border-radius: 5px;
background: #f7f7f7;
width: 350px;
}
/* Sezioni */
.section {
margin-top: 40px;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
background: #fafafa;
}
.section h2 {
margin-top: 0;
}
</style>
</head>
<body>
<div id="app" style="padding:20px;">
<!-- =============================== -->
<!-- 📷 SEZIONE FOTO (identica alla tua) -->
<!-- =============================== -->
<div class="section">
<h2>Gestione Foto</h2>
<button id="btnScan">Scansiona Foto</button>
<button id="btnResetDB">Reset DB</button>
<button id="btnReadDBUser">Leggi DB</button>
<button id="btnDeletePhoto">Cancella Foto per ID</button>
<button id="btnFindIdIndex">Cerca ID in index.json</button>
<button id="btnToggleSoft">Toggle Soft Delete (via ID)</button>
<div id="toggleResult"></div>
<button id="btnResetDBuser">Reset DB Utente</button>
<label for="userSelect"><b>Utenti:</b></label>
<select id="userSelect" style="margin-left:10px; margin-bottom:10px;"></select>
<button id="btnSearchPhotoById">Cerca Foto (nuovo /byIds)</button>
<button id="btnBack">Torna alla galleria</button>
<div id="changesBox">
<h4>Controlla /photos/changes</h4>
<label>Since (data/ora):</label><br>
<input type="datetime-local" id="sinceInput" style="width: 100%; margin-top:5px;"><br><br>
<button id="btnShowDBChanges">Mostra cambiamenti DB</button>
<button id="btnShowHardDeleted">Mostra Hard Deleted</button>
</div>
<div id="progressContainer">
<div id="progressBar"></div>
</div>
<div id="scanInfo">
<div id="scanProgress"></div>
<div id="scanEta"></div>
</div>
<pre id="out"></pre>
</div>
<!-- =============================== -->
<!-- 🏃 SEZIONE ATTIVITÀ (nuova) -->
<!-- =============================== -->
<div class="section">
<h2>Gestione Attività</h2>
<button id="btnScanActivities">Scansiona Attività</button>
<button id="btnResetActivitiesDB">Reset DB Attività</button>
<button id="btnReadActivitiesDB">Leggi DB Attività</button>
<button id="btnDeleteActivity">Cancella Attività per ID</button>
<button id="btnToggleSoftActivity">Toggle Soft Delete Attività (via ID)</button>
<div style="margin-top:10px;">
<label><b>Utenti:</b></label>
<select id="userSelectActivities" style="margin-left:10px; margin-bottom:10px;"></select>
</div>
<button id="btnSearchActivityById">Cerca Attività (nuovo /byIds)</button>
<div id="changesBoxActivities" style="margin-top:20px;">
<h4>Controlla /activities/changes</h4>
<label>Since (data/ora):</label><br>
<input type="datetime-local" id="sinceInputActivities" style="width: 100%; margin-top:5px;"><br><br>
<button id="btnShowActivitiesChanges">Mostra cambiamenti DB</button>
<button id="btnShowActivitiesHardDeleted">Mostra Hard Deleted</button>
</div>
<pre id="outActivities"></pre>
</div>
</div>
<!-- JS modulari -->
<script src="/js/admin/config.js"></script>
<script src="/js/admin/api.js"></script>
<script src="/js/admin/cacheSync.js"></script>
<script src="/js/admin/db.js"></script>
<script src="/js/admin/scan.js"></script>
<script src="/js/admin/ui.js"></script>
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
</body>
</html>

View file

@ -0,0 +1,243 @@
/* public/css/activityModal.css */
/* ===============================
OVERLAY ATTIVITÀ
=============================== */
#activityModal {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
display: none;
margin: 0;
padding: 0;
overflow: hidden;
background: rgba(0, 0, 0, 0.92);
z-index: 12000;
}
#activityModal.open {
display: block;
}
/* ===============================
CONTENITORE
=============================== */
#activityModalContent {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#activityModal .activity-modal-wrapper {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* ===============================
MAPPA LEAFLET
=============================== */
#activityModal #activityMap {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
background: #d8d8d8;
z-index: 1;
}
#activityModal .leaflet-container {
width: 100%;
height: 100%;
}
/* Protezione tile da eventuali regole globali sulle immagini */
#activityModal img.leaflet-tile {
width: 256px !important;
height: 256px !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
padding: 0 !important;
object-fit: initial !important;
filter: none !important;
background: transparent !important;
}
/* ===============================
PULSANTE INFO ATTIVITÀ
=============================== */
#activityInfoBtn {
position: fixed;
top: calc(60px + env(safe-area-inset-top));
right: calc(12px + env(safe-area-inset-right));
width: 44px;
height: 44px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: #0033aa;
background: #eef4ff;
border: 1px solid #d0d8ff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
cursor: pointer;
font-size: 21px;
line-height: 1;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
z-index: 13000;
}
#activityInfoBtn:hover,
#activityInfoBtn.active {
background: #dfe9ff;
border-color: #9fb4ff;
}
#activityInfoBtn:active {
transform: translateY(1px);
}
#activityInfoBtn:focus-visible {
outline: 2px solid #4c9ffe;
outline-offset: 2px;
}
/* ===============================
INFORMAZIONI ATTIVITÀ SULLA MAPPA
=============================== */
#activityModal .activity-modal-info {
position: absolute;
left: max(12px, env(safe-area-inset-left));
right: max(12px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
padding: 12px 16px;
color: #fff;
background: rgba(0, 0, 0, 0.76);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 12500;
}
#activityModal .activity-modal-title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
line-height: 1.25;
}
#activityModal .activity-modal-meta {
margin-top: 2px;
font-size: 14px;
line-height: 1.35;
opacity: 0.95;
}
/* ===============================
CHIUSURA ACTIVITY MODAL
Usa lo stesso stile .modal-close delle foto,
ma con z-index più alto della mappa.
=============================== */
#activityModalClose.modal-close {
z-index: 16000 !important;
}
/* ===============================
INFO PANEL SOPRA LA MAPPA
=============================== */
#infoPanel.open,
#infoPanel[data-open="1"] {
z-index: 14000 !important;
}
/* ===============================
MOBILE
=============================== */
@media (max-width: 768px) {
#activityModal .activity-modal-info {
left: max(8px, env(safe-area-inset-left));
right: max(8px, env(safe-area-inset-right));
bottom: max(8px, env(safe-area-inset-bottom));
padding: 10px 12px;
}
#activityModal .activity-modal-title {
font-size: 15px;
}
#activityModal .activity-modal-meta {
font-size: 13px;
}
#activityInfoBtn {
top: calc(58px + env(safe-area-inset-top));
right: calc(10px + env(safe-area-inset-right));
}
}

95
public/css/admin.css Normal file
View file

@ -0,0 +1,95 @@
/* ===============================
ADMIN layout base
=============================== */
body {
font-family: Arial, sans-serif;
background: #fafafa;
margin: 0;
padding: 0;
}
#app {
padding: 20px;
}
h2 {
margin-bottom: 20px;
color: #333;
}
/* ===============================
Pulsanti
=============================== */
button {
padding: 10px 14px;
margin: 5px 5px 5px 0;
border: none;
border-radius: 6px;
background: #1976d2;
color: white;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #125a9c;
}
/* ===============================
Box cambiamenti
=============================== */
#changesBox {
margin-top: 20px;
padding: 10px;
border: 1px solid #aaa;
border-radius: 5px;
background: #f7f7f7;
width: 350px;
}
#changesBox h4 {
margin-top: 0;
}
/* ===============================
Output
=============================== */
pre#out {
margin-top: 20px;
padding: 15px;
background: #eee;
border-radius: 6px;
max-height: 400px;
overflow: auto;
font-size: 14px;
}
/* ===============================
Progress bar
=============================== */
#progressContainer {
width: 100%;
background: #ddd;
height: 25px;
border-radius: 5px;
margin-top: 20px;
overflow: hidden;
}
#progressBar {
height: 100%;
width: 0%;
background: #4caf50;
transition: width 0.3s linear;
}
#scanInfo {
font-family: monospace;
margin-top: 10px;
font-size: 16px;
}

55
public/css/base.css Normal file
View file

@ -0,0 +1,55 @@
:root { --header-h: 60px; }
/* =========================================================
MODALITÀ PHOTOS / ACTIVITIES (toggle globale)
========================================================= */
body.photos {
--tile-bg: #ffffff;
--tile-border: transparent;
--tile-thumb-size: auto;
--tile-label-color: #000;
--panel-bg: #ffffff;
--panel-title-color: #000;
--modal-bg: #000000;
}
body.activities {
--tile-bg: #f5f7ff;
--tile-border: #d0d8ff;
--tile-thumb-size: 64px;
--tile-label-color: #0033aa;
--panel-bg: #eef4ff;
--panel-title-color: #0056d6;
--modal-bg: #eef4ff;
}
/* Safe-area iOS */
@supports (top: env(safe-area-inset-top)) {
:root { --safe-top: env(safe-area-inset-top); }
}
@supports not (top: env(safe-area-inset-top)) {
:root { --safe-top: 0px; }
}
body {
font-family: sans-serif;
margin: 0;
padding: 0;
background: #fafafa;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;
}

348
public/css/bottomSheet.css Normal file
View file

@ -0,0 +1,348 @@
/* =========================================
Variabili globali
========================================= */
:root {
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
}
/* =========================================
MAPPA GLOBALE (contenitore sotto lheader)
========================================= */
.global-map {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
width: 100%;
display: none; /* visibile solo con .open */
z-index: 10; /* sotto a bottom-sheet (9999) e modal (10000) */
background: #000; /* evita flash bianco durante init */
}
.global-map.open {
display: block;
}
/* Leaflet riempie il contenitore */
.global-map .leaflet-container {
width: 100%;
height: 100%;
}
/* Marker immagine (miniatura) */
.leaflet-marker-icon.photo-marker {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
border: 2px solid rgba(255,255,255,0.9);
background: #fff;
}
/* Nascondi la gallery quando la mappa è aperta */
.gallery.hidden {
display: none !important;
}
/* =========================================
BOTTOM SHEET struttura base comune
========================================= */
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(6px);
border-top: 1px solid #ddd;
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
display: none; /* diventa flex con .open */
flex-direction: column;
z-index: 9999; /* molto alto: il modal starà sopra (10000) */
}
/* =========================================================
MODALITÀ ACTIVITIES override minimale
========================================================= */
/* Strip attività */
body.activities .photo-strip {
height: 120px; /* più compatta */
background: var(--panel-bg);
}
/* Contenitore elementi */
body.activities #sheetGallery,
body.activities .sheet-gallery {
gap: 8px;
padding: 10px;
background: var(--panel-bg);
}
/* Tile attività */
body.activities .sheet-item {
width: 80px;
height: 80px;
border-radius: 10px;
background: var(--tile-bg);
border: 1px solid var(--tile-border);
box-shadow: none;
display: flex;
align-items: center;
justify-content: center;
}
/* Icona/mappa attività */
body.activities .sheet-item img {
width: var(--tile-thumb-size);
height: var(--tile-thumb-size);
object-fit: contain;
filter: grayscale(40%);
padding: 8px;
}
/* _-----------------*/
.activity-sheet-route {
width: 80px;
height: 80px;
overflow: hidden;
}
.activity-sheet-route svg {
width: 100%;
height: 100%;
}
/* Nascondi miniature foto */
body.activities .sheet-thumb {
display: none !important;
}
/* Nascondi layout foto */
body.activities .sheet-item img[style*="object-fit: cover"] {
object-fit: contain !important;
}
/* Header della sheet più coerente */
body.activities .sheet-header::before {
background: #007bff;
}
/* Opzioni sheet coerenti */
body.activities #optionsSheet h3 {
color: var(--panel-title-color);
}
body.activities .sheet-btn {
background: #eef4ff;
border: 1px solid #d0d8ff;
color: #0033aa;
}
body.activities .sheet-btn:hover {
background: #e0eaff;
}
.bottom-sheet.open {
display: flex;
}
/* Maniglia superiore */
.sheet-header {
height: 16px;
display: flex;
justify-content: center;
align-items: center;
}
.sheet-header::before {
content: "";
width: 40px;
height: 4px;
background: #bbb;
border-radius: 4px;
}
/* =========================================
BOTTOM SHEET FOTO (strip bassa come nel vecchio)
========================================= */
.photo-strip {
height: 140px; /* altezza originale della strip */
overflow-y: hidden; /* niente scroll verticale */
overflow-x: auto; /* scroll orizzontale per le foto */
}
/* Contenitore elementi della strip — compatibile con id e class */
#sheetGallery,
.sheet-gallery {
display: flex;
flex-direction: row;
overflow-x: auto;
padding: 10px;
gap: 10px;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
}
/* Singolo elemento della strip */
.sheet-item {
width: 90px;
height: 90px;
border-radius: 10px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
background: #eee;
scroll-snap-align: start;
}
/* Miniatura della foto nella strip */
.sheet-thumb,
.sheet-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 8px; /* alias; la .sheet-item ha già 10px */
}
/* =========================================
BOTTOM SHEET OPZIONI () menu grande
========================================= */
.options-sheet {
height: auto;
max-height: 80vh;
overflow-y: auto;
}
.sheet-content {
padding: 20px;
}
.sheet-btn {
width: 100%;
padding: 12px;
margin-bottom: 8px;
text-align: left;
background: #f5f5f5;
border: none;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
}
.sheet-btn:hover {
background: #e8e8e8;
}
#optionsSheet h3 {
margin-top: 20px;
margin-bottom: 10px;
font-size: 16px;
color: #444;
}
/* =========================================
OVERLAY per chiusura sheet/option
========================================= */
.sheet-overlay {
position: fixed;
inset: 0;
background: transparent;
display: none;
z-index: 80;
pointer-events: none;
}
.sheet-overlay.open {
display: block;
pointer-events: none;
}
.bottom-sheet {
pointer-events: auto;
}
/* =========================================
MODAL sopra allo sheet
========================================= */
.modal.open {
z-index: 10000 !important; /* sopra al bottom sheet (9999) */
}
/* =========================================
Piccoli affinamenti facoltativi
========================================= */
/* scrollbar sottile solo per la strip (opzionale) */
#sheetGallery::-webkit-scrollbar,
.sheet-gallery::-webkit-scrollbar {
height: 8px;
}
#sheetGallery::-webkit-scrollbar-thumb,
.sheet-gallery::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.25);
border-radius: 4px;
}
.sheet-overlay {
background: transparent !important;
pointer-events: none !important;
}
.sheet-overlay.open {
background: transparent !important;
pointer-events: none !important;
}
/* ===============================
THUMB TRACCIA ATTIVITÀ BOTTOM SHEET
=============================== */
.activity-sheet-item {
width: 110px;
height: 110px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 4px;
}
.activity-sheet-route {
width: 90px;
height: 70px;
flex-shrink: 0;
overflow: hidden;
}
.activity-sheet-route svg.activity-route-svg {
width: 100%;
height: 100%;
display: block;
}
.activity-sheet-label {
margin-top: 4px;
width: 100%;
font-size: 11px;
text-align: center;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.activity-sheet-route .activity-route-placeholder {
font-size: 10px;
text-align: center;
}

View file

@ -0,0 +1,269 @@
/* =========================================
Variabili globali
========================================= */
:root {
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
}
/* =========================================
MAPPA GLOBALE (contenitore sotto lheader)
========================================= */
.global-map {
position: fixed;
top: var(--header-height);
left: 0;
right: 0;
bottom: 0;
width: 100%;
display: none; /* visibile solo con .open */
z-index: 10; /* sotto a bottom-sheet (9999) e modal (10000) */
background: #000; /* evita flash bianco durante init */
}
.global-map.open {
display: block;
}
/* Leaflet riempie il contenitore */
.global-map .leaflet-container {
width: 100%;
height: 100%;
}
/* Marker immagine (miniatura) */
.leaflet-marker-icon.photo-marker {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
border: 2px solid rgba(255,255,255,0.9);
background: #fff;
}
/* Nascondi la gallery quando la mappa è aperta */
.gallery.hidden {
display: none !important;
}
/* =========================================
BOTTOM SHEET — struttura base comune
========================================= */
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(6px);
border-top: 1px solid #ddd;
box-shadow: 0 -2px 10px rgba(0,0,0,0.15);
display: none; /* diventa flex con .open */
flex-direction: column;
z-index: 9999; /* molto alto: il modal starà sopra (10000) */
}
/* =========================================================
MODALITÀ ACTIVITIES — override minimale
========================================================= */
/* Strip attività */
body.activities .photo-strip {
height: 120px; /* più compatta */
background: var(--panel-bg);
}
/* Contenitore elementi */
body.activities #sheetGallery,
body.activities .sheet-gallery {
gap: 8px;
padding: 10px;
background: var(--panel-bg);
}
/* Tile attività */
body.activities .sheet-item {
width: 80px;
height: 80px;
border-radius: 10px;
background: var(--tile-bg);
border: 1px solid var(--tile-border);
box-shadow: none;
display: flex;
align-items: center;
justify-content: center;
}
/* Icona/mappa attività */
body.activities .sheet-item img {
width: var(--tile-thumb-size);
height: var(--tile-thumb-size);
object-fit: contain;
filter: grayscale(40%);
padding: 8px;
}
/* Nascondi miniature foto */
body.activities .sheet-thumb {
display: none !important;
}
/* Nascondi layout foto */
body.activities .sheet-item img[style*="object-fit: cover"] {
object-fit: contain !important;
}
/* Header della sheet più coerente */
body.activities .sheet-header::before {
background: #007bff;
}
/* Opzioni sheet coerenti */
body.activities #optionsSheet h3 {
color: var(--panel-title-color);
}
body.activities .sheet-btn {
background: #eef4ff;
border: 1px solid #d0d8ff;
color: #0033aa;
}
body.activities .sheet-btn:hover {
background: #e0eaff;
}
.bottom-sheet.open {
display: flex;
}
/* Maniglia superiore */
.sheet-header {
height: 16px;
display: flex;
justify-content: center;
align-items: center;
}
.sheet-header::before {
content: "";
width: 40px;
height: 4px;
background: #bbb;
border-radius: 4px;
}
/* =========================================
BOTTOM SHEET FOTO (strip bassa come nel vecchio)
========================================= */
.photo-strip {
height: 140px; /* altezza originale della strip */
overflow-y: hidden; /* niente scroll verticale */
overflow-x: auto; /* scroll orizzontale per le foto */
}
/* Contenitore elementi della strip — compatibile con id e class */
#sheetGallery,
.sheet-gallery {
display: flex;
flex-direction: row;
overflow-x: auto;
padding: 10px;
gap: 10px;
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
}
/* Singolo elemento della strip */
.sheet-item {
width: 90px;
height: 90px;
border-radius: 10px;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
background: #eee;
scroll-snap-align: start;
}
/* Miniatura della foto nella strip */
.sheet-thumb,
.sheet-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 8px; /* alias; la .sheet-item ha già 10px */
}
/* =========================================
BOTTOM SHEET OPZIONI (⋮) — menu grande
========================================= */
.options-sheet {
height: auto;
max-height: 80vh;
overflow-y: auto;
}
.sheet-content {
padding: 20px;
}
.sheet-btn {
width: 100%;
padding: 12px;
margin-bottom: 8px;
text-align: left;
background: #f5f5f5;
border: none;
border-radius: 8px;
font-size: 15px;
cursor: pointer;
}
.sheet-btn:hover {
background: #e8e8e8;
}
#optionsSheet h3 {
margin-top: 20px;
margin-bottom: 10px;
font-size: 16px;
color: #444;
}
/* =========================================
OVERLAY per chiusura sheet/option
========================================= */
.sheet-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.0); /* invisibile ma cliccabile */
display: none;
z-index: 80; /* appena sotto il bottom sheet */
}
.sheet-overlay.open {
display: block;
}
/* =========================================
MODAL sopra allo sheet
========================================= */
.modal.open {
z-index: 10000 !important; /* sopra al bottom sheet (9999) */
}
/* =========================================
Piccoli affinamenti facoltativi
========================================= */
/* scrollbar sottile solo per la strip (opzionale) */
#sheetGallery::-webkit-scrollbar,
.sheet-gallery::-webkit-scrollbar {
height: 8px;
}
#sheetGallery::-webkit-scrollbar-thumb,
.sheet-gallery::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.25);
border-radius: 4px;
}

253
public/css/gallery.css Normal file
View file

@ -0,0 +1,253 @@
.gallery {
display: block;
padding: 6px;
}
.gallery-section-title {
font-size: 18px;
font-weight: 600;
margin: 18px 6px 6px;
color: #444;
}
.gallery-section {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 4px;
padding: 0 4px;
}
/* ===============================
FOTO / VIDEO THUMBS
=============================== */
.thumb {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
position: relative;
cursor: pointer;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.play-icon {
position: absolute;
bottom: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.55);
color: white;
padding: 2px 4px;
border-radius: 4px;
font-size: 11px;
}
/* ===============================
SOFT DELETE FOTO + ATTIVITÀ
=============================== */
.soft-deleted {
position: relative;
opacity: 0.55;
}
.soft-deleted::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 70%;
height: 3px;
background: rgba(255, 0, 0, 0.85);
transform: translate(-50%, -50%) rotate(45deg);
pointer-events: none;
z-index: 20;
}
.soft-deleted::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 70%;
height: 3px;
background: rgba(255, 0, 0, 0.85);
transform: translate(-50%, -50%) rotate(-45deg);
pointer-events: none;
z-index: 20;
}
/* ===============================
MODALITÀ ACTIVITIES
=============================== */
body.activities .gallery-section-title {
color: #0033aa;
}
/* Tile attività */
.activity-tile {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 8px;
overflow: hidden;
background: #111827;
position: relative;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
display: flex;
flex-direction: column;
}
/* Area percorso */
.activity-route-thumb {
width: 100%;
flex: 1 1 auto;
min-height: 0;
background: #0f172a;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.activity-route-thumb.loading {
background: linear-gradient(135deg, #111827, #1f2937);
}
.activity-route-thumb.empty {
background: linear-gradient(135deg, #111827, #1f2937);
}
/* Placeholder caricamento / fallback */
.activity-route-placeholder {
color: rgba(255, 255, 255, 0.72);
font-size: 11px;
line-height: 1.2;
text-align: center;
padding: 8px;
}
/* SVG percorso */
.activity-route-svg {
width: 100%;
height: 100%;
display: block;
}
.activity-route-bg {
fill: #0f172a;
}
.activity-route-line-shadow {
fill: none;
stroke: rgba(0, 0, 0, 0.5);
stroke-width: 7;
stroke-linecap: round;
stroke-linejoin: round;
}
.activity-route-line {
fill: none;
stroke: #38bdf8;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.activity-route-start {
fill: #22c55e;
stroke: white;
stroke-width: 1.6;
}
.activity-route-end {
fill: #ef4444;
stroke: white;
stroke-width: 1.6;
}
/* Label attività */
.activity-label {
flex: 0 0 auto;
padding: 6px 7px 1px;
font-size: 12px;
line-height: 1.15;
color: white;
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Metadati attività */
.activity-meta {
flex: 0 0 auto;
padding: 0 7px 6px;
font-size: 10.5px;
line-height: 1.15;
color: rgba(255, 255, 255, 0.78);
background: rgba(0, 0, 0, 0.52);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Icona fallback, se usata */
.activity-icon {
width: 48px;
height: 48px;
object-fit: contain;
opacity: 0.9;
}
/* Niente play-icon per attività */
body.activities .play-icon {
display: none;
}
/* ===============================
RESPONSIVE / THUMB ATTIVITÀ PIÙ LEGGIBILI
=============================== */
body.activities .gallery-section {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
/* Su schermi più larghi, attività un po' più grandi */
@media (min-width: 700px) {
body.activities .gallery-section {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
}
/* Su schermi molto piccoli, resta compatto */
@media (max-width: 420px) {
.gallery-section {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
body.activities .gallery-section {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
}
.activity-label {
font-size: 11.5px;
}
.activity-meta {
font-size: 10px;
}
}

136
public/css/header.css Normal file
View file

@ -0,0 +1,136 @@
/* ===============================
Header compatto
=============================== */
header {
padding: 4px 10px; /* era 10px 15px */
background: #333;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
/* Titolo più piccolo e senza margini extra */
header h1 {
font-size: 18px; /* ridotto */
line-height: 1.1;
margin: 0;
}
/* Contenitore bottoni in alto a destra */
.top-buttons {
display: flex;
gap: 6px; /* era 10px */
}
/* Bottoni icona più compatti */
.icon-btn {
background: none;
border: none;
font-size: 18px; /* era 22px */
padding: 3px 6px; /* era 6px 10px */
cursor: pointer;
border-radius: 6px;
color: white;
line-height: 1;
min-height: 32px; /* tap target minimo desktop */
min-width: 32px;
}
.icon-btn:hover {
background: rgba(255,255,255,0.15);
}
/* Logout rotondo: riduciamo la “bolla” */
.icon-btn.logout-btn {
--size: 28px; /* era 36px */
width: var(--size);
height: var(--size);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* PNG del logout in scala con lheader */
.logout-icon {
width: 18px; /* era 22px */
height: 18px;
display: block;
filter: brightness(0) invert(1);
image-rendering: -webkit-optimize-contrast;
}
/* ===============================
Visibilità Logout robusta
=============================== */
/* Base: nascosto (prima del login o se non autenticato) */
#logoutBtn {
display: none;
}
/* Quando autenticato, mostra il bottone coerente con gli altri icon-btn */
body.authenticated #logoutBtn {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ===============================
Toggle Photos / Activities
=============================== */
.mode-toggle {
display: flex;
gap: 8px;
margin-left: 20px;
}
.mode-toggle button {
padding: 6px 12px;
border: 1px solid #ccc;
background: #f5f5f5;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #333;
}
.mode-toggle button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.mode-toggle {
background: transparent;
color: #fff;
border: 1px solid #fff;
border-radius: 10px;
padding: 8px 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.mode-toggle:hover {
background: #111;
}
.mode-toggle:active {
transform: scale(0.96);
}

140
public/css/infoPanel.css Normal file
View file

@ -0,0 +1,140 @@
/* ===============================
Variabili (scala tipografica pannello info)
Modifica qui per regolare tutto il pannello
=============================== */
:root {
--info-font: 14px; /* base testo pannello (prima ~16px) */
--info-line: 1.4; /* interlinea per migliorare leggibilità */
--info-heading: 15px; /* dimensione titoli h3 nel pannello */
--info-h3-mt: 6px; /* margin-top h3 */
--info-h3-mb: 10px; /* margin-bottom h3 */
--info-row-gap: 8px; /* spazio verticale tra righe (era 10px) */
--info-label-w: 100px; /* larghezza colonna etichette (era 110px) */
--info-map-h: 220px; /* altezza mappa (era 250px) */
--info-map-mt: 15px; /* spazio sopra la mappa */
--info-spacer-h: 16px; /* altezza degli spacer */
}
/* ===============================
PANNELLO INFO
=============================== */
.info-panel {
position: fixed;
top: 0;
right: 0;
width: 320px;
height: 100%;
background: #fff;
padding: 16px;
box-shadow: -2px 0 6px rgba(0,0,0,0.25);
overflow-y: auto;
z-index: 10000;
transform: translateX(100%);
transition: transform 0.3s ease;
/* Scala tipografica via variabili */
font-size: var(--info-font);
line-height: var(--info-line);
}
.info-panel.open {
transform: translateX(0);
}
/* Titoli un filo più compatti */
.info-panel h3 {
font-size: var(--info-heading);
margin: var(--info-h3-mt) 0 var(--info-h3-mb);
}
/* Righe e label */
.info-row {
margin-bottom: var(--info-row-gap);
}
.info-row b {
display: inline-block;
width: var(--info-label-w);
}
/* Mappa nel pannello */
.info-map {
width: 100%;
height: var(--info-map-h);
margin-top: var(--info-map-mt);
border-radius: 6px;
overflow: hidden;
border: 1px solid #ccc;
}
/* Spacer verticali */
.info-spacer {
height: var(--info-spacer-h);
}
/* ===============================
(Opzionale) Mobile: un filo più grande < 480px
Decommenta se vuoi mantenere leggibilità maggiore su schermi piccoli
=============================== */
/*
@media (max-width: 480px) {
.info-panel { font-size: 15px; }
.info-panel h3 { font-size: 16px; }
}
*/
/* =========================================================
MODALITÀ ACTIVITIES override minimale
========================================================= */
/* Sfondo pannello attività */
body.activities .info-panel {
background: var(--panel-bg);
}
/* Titoli attività */
body.activities .info-panel h3 {
color: var(--panel-title-color);
}
/* Righe attività: più compatte e leggibili */
body.activities .info-row {
margin-bottom: calc(var(--info-row-gap) - 2px);
}
body.activities .info-row b {
color: #0033aa;
font-weight: 600;
}
/* Mappa attività */
body.activities .info-map {
border-color: #007bff;
background: #eef4ff;
}
/* Spacers più leggeri */
body.activities .info-spacer {
background: transparent;
}
.info-close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
font-size: 24px;
line-height: 1;
cursor: pointer;
}

27
public/css/login.css Normal file
View file

@ -0,0 +1,27 @@
.login-modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 20000;
}
.login-box {
background: white;
padding: 20px;
border-radius: 12px;
width: 280px;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-error {
color: red;
font-size: 14px;
min-height: 18px;
}

View file

@ -0,0 +1,176 @@
/* ===============================
MODALITÀ ACTIVITIES override
=============================== */
body.activities .global-map {
border-top: 3px solid #007bff;
}
/* Nascondi elementi foto */
body.activities .photo-marker,
body.activities .photo-cluster,
body.activities .gp-cluster,
body.activities .cluster-back,
body.activities .cluster-front,
body.activities .cluster-collage {
display: none !important;
}
/* ===============================
MARKER ATTIVITÀ
=============================== */
.activity-start-marker {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border: 3px solid #2b8cff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
font-size: 20px;
}
.activity-marker-run { border-color: #2b8cff; }
.activity-marker-bike { border-color: #3cb44b; }
.activity-marker-walk { border-color: #f58231; }
.activity-marker-generic { border-color: #911eb4; }
/* ===============================
CLUSTER ATTIVITÀ
=============================== */
.activity-cluster {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #2b8cff;
color: #fff;
border: 3px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
font-size: 15px;
font-weight: 700;
}
/* ===============================
POPUP ATTIVITÀ
=============================== */
.activity-map-popup {
min-width: 180px;
}
.activity-map-popup strong {
display: block;
margin-bottom: 4px;
}
.activity-popup-open {
margin-top: 8px;
padding: 6px 10px;
background: #2b8cff;
color: #fff;
border: 0;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.activity-popup-open:hover {
background: #126fd1;
}
/* ===============================
ACTIVITIES thumbs rettangolari
=============================== */
/*
body.activities .activity-start-marker {
width: 48px;
height: 48px;
border-radius: 10px;
overflow: hidden;
background: #ffffff;
border: 3px solid #2b8cff;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}*/
body.activities .activity-start-marker {
width: 30px;
height: 30px;
border-radius: 50%; /* cerchio perfetto */
overflow: hidden; /* ritaglia eventuali contenuti */
background: #ffffff; /* interno bianco */
border: 3px solid #2b8cff; /* bordo blu */
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px; /* emoji/icone centrata */
font-weight: 700;
color: #2b8cff; /* colore coerente */
position: relative;
z-index: 0;
}
/* ===============================
ACTIVITIES cluster rettangolari
=============================== */
body.activities .activity-cluster {
width: 30px;
height: 30px;
border-radius: 50%; /* cerchio perfetto */
overflow: hidden; /* ritaglia eventuali contenuti */
background: #2b8cff; /* colore attività */
border: 3px solid rgba(255,255,255,0.9); /* bordo bianco identico ai cluster foto */
box-shadow: 0 2px 8px rgba(0,0,0,0.35); /* stessa ombra dei cluster foto */
display: flex;
align-items: center;
justify-content: center;
font-size: 13px; /* numero centrato */
font-weight: 700;
color: #fff;
position: relative;
z-index: 0;
}

View file

@ -0,0 +1,152 @@
/* ===============================
MODALITÀ ACTIVITIES — override
=============================== */
body.activities .global-map {
border-top: 3px solid #007bff;
}
/* Nascondi elementi foto */
body.activities .photo-marker,
body.activities .photo-cluster,
body.activities .gp-cluster,
body.activities .cluster-back,
body.activities .cluster-front,
body.activities .cluster-collage {
display: none !important;
}
/* ===============================
MARKER ATTIVITÀ
=============================== */
.activity-start-marker {
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border: 3px solid #2b8cff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
font-size: 20px;
}
.activity-marker-run { border-color: #2b8cff; }
.activity-marker-bike { border-color: #3cb44b; }
.activity-marker-walk { border-color: #f58231; }
.activity-marker-generic { border-color: #911eb4; }
/* ===============================
CLUSTER ATTIVITÀ
=============================== */
.activity-cluster {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: #2b8cff;
color: #fff;
border: 3px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
font-size: 15px;
font-weight: 700;
}
/* ===============================
POPUP ATTIVITÀ
=============================== */
.activity-map-popup {
min-width: 180px;
}
.activity-map-popup strong {
display: block;
margin-bottom: 4px;
}
.activity-popup-open {
margin-top: 8px;
padding: 6px 10px;
background: #2b8cff;
color: #fff;
border: 0;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.activity-popup-open:hover {
background: #126fd1;
}
/* ===============================
ACTIVITIES — thumbs rettangolari
=============================== */
body.activities .activity-start-marker {
width: 48px;
height: 48px;
border-radius: 10px;
overflow: hidden;
background: #ffffff;
border: 3px solid #2b8cff;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
/* ===============================
ACTIVITIES — cluster rettangolari
=============================== */
body.activities .activity-cluster {
width: 30px;
height: 30px;
border-radius: 50%; /* cerchio perfetto */
overflow: hidden; /* ritaglia eventuali contenuti */
background: #2b8cff; /* colore attività */
border: 3px solid rgba(255,255,255,0.9); /* bordo bianco identico ai cluster foto */
box-shadow: 0 2px 8px rgba(0,0,0,0.35); /* stessa ombra dei cluster foto */
display: flex;
align-items: center;
justify-content: center;
font-size: 13px; /* numero centrato */
font-weight: 700;
color: #fff;
position: relative;
z-index: 0;
}

119
public/css/map-base.css Normal file
View file

@ -0,0 +1,119 @@
/* ===============================
MAPPA GLOBALE (BASE)
=============================== */
.global-map {
position: fixed;
inset: 0;
top: calc(var(--header-h, 60px) + var(--safe-top, 0px));
bottom: 0;
width: 100%;
height: calc(100% - var(--header-h, 60px));
display: none;
z-index: 1000;
}
/* Mappa attiva */
.global-map.open {
display: block;
}
/* ===============================
LEAFLET
=============================== */
#globalMap,
#globalMap .leaflet-container {
width: 100%;
height: 100%;
}
/* ===============================
MAPBOX TERRAIN 3D
=============================== */
#terrainMap {
display: none;
z-index: 1100;
}
#terrainMap.open {
display: block;
}
#terrainMap .mapboxgl-canvas {
width: 100%;
height: 100%;
}
/* ===============================
GALLERY
=============================== */
.gallery.hidden {
display: none;
}
/* ===============================
TOGGLE TRACCE ATTIVITÀ
=============================== */
.map-toggle-routes {
position: absolute;
top: 12px;
left: 56px;
z-index: 1200;
min-height: 36px;
padding: 7px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
color: #111;
border: 1px solid #cfcfcf;
border-radius: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.map-toggle-routes.active {
background: #2b8cff;
color: #fff;
border-color: #2b8cff;
}
#terrainMap {
position: fixed;
top: 60px;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: calc(100vh - 60px);
display: none;
z-index: 2000;
}
#terrainMap.open {
display: block;
}

152
public/css/map-photos.css Normal file
View file

@ -0,0 +1,152 @@
/* ===============================
MARKER FOTO
=============================== */
.photo-marker {
width: 46px;
height: 46px;
border-radius: 10px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
box-sizing: border-box; /* ← evita ingrandimenti */
}
.photo-marker img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
/* Leaflet deve rispettare la dimensione */
.leaflet-marker-icon.photo-marker {
width: 46px !important;
height: 46px !important;
}
/* ===============================
CLUSTER FOTO (rettangolare)
=============================== */
.photo-cluster {
width: 56px;
height: 56px;
position: relative;
border-radius: 12px;
overflow: visible;
}
.cluster-back {
position: absolute;
top: 6px;
left: 6px;
width: 48px;
height: 48px;
border-radius: 10px;
object-fit: cover;
opacity: 0.5;
filter: blur(1px);
transform: scale(0.95);
}
.cluster-front {
position: absolute;
top: 0;
left: 0;
width: 48px;
height: 48px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
}
/* ===============================
MARKER CLUSTER FOTO (Leaflet)
=============================== */
.marker-cluster-wrapper {
background: transparent;
border: 0;
}
/* Wrapper Leaflet — fondamentale per far uscire il badge */
.marker-cluster {
overflow: visible !important; /* ← SENZA QUESTA il badge viene tagliato */
}
.gp-cluster {
position: relative;
border-radius: 50%; /* cluster foto circolare */
overflow: visible !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
border: 3px solid rgba(255,255,255,0.85);
transition: width .12s, height .12s, font-size .12s;
z-index: 0; /* stacking context */
}
.gp-cluster .cluster-collage {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(2,1fr);
grid-template-rows: repeat(2,1fr);
border-radius: 50%; /* ← arrotonda il collage */
overflow: hidden; /* ← ritaglia gli angoli delle foto */
z-index: 1; /* collage sotto */
}
/* Ogni quadratino del collage deve ritagliare l'immagine */
.gp-cluster .cluster-collage div {
position: relative;
overflow: hidden; /* ← fondamentale per il crop */
}
/* L'immagine deve riempire e essere centrata */
.gp-cluster .cluster-collage div img {
width: 100%;
height: 100%;
object-fit: cover; /* ← riempie e taglia */
object-position: center; /* ← centra */
display: block;
}
/* ===============================
BADGE NUMERO mezzo fuori dal cerchio
=============================== */
.gp-cluster .gp-count {
position: absolute;
right: -4px; /* metà fuori */
top: -4px; /* metà fuori */
z-index: 10; /* sopra tutto */
background: rgba(0,0,0,0.55);
padding: 4px 7px;
border-radius: 12px;
color: #fff;
font-weight: 700;
font-size: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-marker-icon.marker-cluster-wrapper {
overflow: visible !important;
}
.gp-cluster.cluster-sm .gp-count { font-size: 11px; }
.gp-cluster.cluster-md .gp-count { font-size: 13px; }
.gp-cluster.cluster-lg .gp-count { font-size: 15px; }
.gp-cluster.cluster-xl .gp-count { font-size: 17px; }

Some files were not shown because too many files have changed in this diff Show more