first commit
This commit is contained in:
commit
3b79c3ef68
363 changed files with 552621 additions and 0 deletions
1
.Fabio.last_event
Normal file
1
.Fabio.last_event
Normal file
|
|
@ -0,0 +1 @@
|
|||
1774133507738
|
||||
41
.env
Normal file
41
.env
Normal 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
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
thumbs/
|
||||
db.json
|
||||
BIN
IMG_0124.JPG
Normal file
BIN
IMG_0124.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
IMG_0125.JPG
Normal file
BIN
IMG_0125.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
IMG_20210602_100637.jpg
Normal file
BIN
IMG_20210602_100637.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
BIN
PIPPO.JPG
Normal file
BIN
PIPPO.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 MiB |
129
README.md
Normal file
129
README.md
Normal 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
278
README_WS.md
Normal 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 dell’utente:
|
||||
- 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”
|
||||
|
||||
✔ Multi‑dispositivo perfetto
|
||||
|
||||
✔ Queue pulita dopo 2 minuti
|
||||
|
||||
✔ Sync completo automatico quando serve
|
||||
|
||||
✔ WS leggero (ping solo on‑demand)
|
||||
|
||||
✔ 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
BIN
a.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
4
api_v1/admin_secret.json
Normal file
4
api_v1/admin_secret.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"email": "admin@gmail.com",
|
||||
"password": "master66"
|
||||
}
|
||||
35
api_v1/config.js
Normal file
35
api_v1/config.js
Normal 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
BIN
api_v1/database.sqlite
Normal file
Binary file not shown.
117
api_v1/geo.js
Normal file
117
api_v1/geo.js
Normal 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;
|
||||
64
api_v1/scanner/debugVideoDates.js
Normal file
64
api_v1/scanner/debugVideoDates.js
Normal 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);
|
||||
});
|
||||
73
api_v1/scanner/deleteFolder.js
Normal file
73
api_v1/scanner/deleteFolder.js
Normal 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 };
|
||||
};
|
||||
19
api_v1/scanner/deleteWithAuth.js
Normal file
19
api_v1/scanner/deleteWithAuth.js
Normal 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}`);
|
||||
}
|
||||
};
|
||||
81
api_v1/scanner/elevation.js
Normal file
81
api_v1/scanner/elevation.js
Normal 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
69
api_v1/scanner/gps.js
Normal 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
43
api_v1/scanner/logger.js
Normal 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 };
|
||||
112
api_v1/scanner/orphanCleanup.js
Normal file
112
api_v1/scanner/orphanCleanup.js
Normal 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
|
||||
};
|
||||
};
|
||||
100
api_v1/scanner/parseActivityFile.js
Normal file
100
api_v1/scanner/parseActivityFile.js
Normal 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;
|
||||
277
api_v1/scanner/parsers/parseFit.js
Normal file
277
api_v1/scanner/parsers/parseFit.js
Normal 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;
|
||||
204
api_v1/scanner/parsers/parseGpx.js
Normal file
204
api_v1/scanner/parsers/parseGpx.js
Normal 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;
|
||||
298
api_v1/scanner/parsers/parseTcx.js
Normal file
298
api_v1/scanner/parsers/parseTcx.js
Normal 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;
|
||||
38
api_v1/scanner/parsers/parse_fit.py
Normal file
38
api_v1/scanner/parsers/parse_fit.py
Normal 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))
|
||||
24
api_v1/scanner/postWithAuth.js
Normal file
24
api_v1/scanner/postWithAuth.js
Normal 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;
|
||||
};
|
||||
190
api_v1/scanner/processActivityFile.js
Normal file
190
api_v1/scanner/processActivityFile.js
Normal 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;
|
||||
206
api_v1/scanner/processFile.js
Normal file
206
api_v1/scanner/processFile.js
Normal 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;
|
||||
97
api_v1/scanner/recordChange.js
Normal file
97
api_v1/scanner/recordChange.js
Normal 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;
|
||||
160
api_v1/scanner/scanActivitiesUser.js
Normal file
160
api_v1/scanner/scanActivitiesUser.js
Normal 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;
|
||||
114
api_v1/scanner/scanActivity.js
Normal file
114
api_v1/scanner/scanActivity.js
Normal 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;
|
||||
62
api_v1/scanner/scanActivityCartella.js
Normal file
62
api_v1/scanner/scanActivityCartella.js
Normal 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;
|
||||
43
api_v1/scanner/scanActivityFiles.js
Normal file
43
api_v1/scanner/scanActivityFiles.js
Normal 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;
|
||||
332
api_v1/scanner/scanActivitySingle.js
Normal file
332
api_v1/scanner/scanActivitySingle.js
Normal 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
210
api_v1/scanner/scanAuto.js
Normal 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
|
||||
};
|
||||
68
api_v1/scanner/scanCartella.js
Normal file
68
api_v1/scanner/scanCartella.js
Normal 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
233
api_v1/scanner/scanFile.js
Normal 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;
|
||||
53
api_v1/scanner/scanFile1.js
Normal file
53
api_v1/scanner/scanFile1.js
Normal 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;
|
||||
55
api_v1/scanner/scanFileEntry.js
Normal file
55
api_v1/scanner/scanFileEntry.js
Normal 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;
|
||||
76
api_v1/scanner/scanNewCartella.js
Normal file
76
api_v1/scanner/scanNewCartella.js
Normal 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
111
api_v1/scanner/scanPhoto.js
Normal 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;
|
||||
62
api_v1/scanner/scanPhotoCartella.js
Normal file
62
api_v1/scanner/scanPhotoCartella.js
Normal 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;
|
||||
206
api_v1/scanner/scanPhotoSingle.js
Normal file
206
api_v1/scanner/scanPhotoSingle.js
Normal 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;
|
||||
161
api_v1/scanner/scanPhotosUser.js
Normal file
161
api_v1/scanner/scanPhotosUser.js
Normal 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;
|
||||
25
api_v1/scanner/scanUser.js
Normal file
25
api_v1/scanner/scanUser.js
Normal 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
31
api_v1/scanner/thumbs.js
Normal 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
41
api_v1/scanner/utils.js
Normal 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
17
api_v1/scanner/video.js
Normal 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 };
|
||||
17
api_v1/scanner/writeJsonAtomic.js
Normal file
17
api_v1/scanner/writeJsonAtomic.js
Normal 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
51
api_v1/tools.js
Normal 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
22
api_v1/users.json
Normal 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
0
aves.db
Normal file
1
b/a.jpg
Normal file
1
b/a.jpg
Normal file
|
|
@ -0,0 +1 @@
|
|||
prova
|
||||
19
cast.sh
Normal file
19
cast.sh
Normal 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
58
cast1.sh
Normal 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
202
come funziona.md
Normal 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 all’utente.
|
||||
|
||||
---
|
||||
|
||||
# ⭐ 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
|
||||
|
||||
### All’avvio 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
209
db/activities.js
Normal 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
142
db/dbWs.js
Normal 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
290
db/init.js
Normal 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
12
db/knex.js
Normal 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
73
db/photos.js
Normal 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
22
file_lungo.md
Normal 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
40
find-esm.js
Normal 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
27
generate_token.js
Normal 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");
|
||||
158
helpers/normalizeActivity.js
Normal file
158
helpers/normalizeActivity.js
Normal 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
155
map.md
Normal 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
18
middleware/hls.js
Normal 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
22
middleware/jwt.js
Normal 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
28
middleware/static.js
Normal 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
5
middleware/static.js.ok
Normal 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
2531
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
package.json
Normal file
23
package.json
Normal 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
BIN
part.bin
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
40
print_function.sh
Executable file
40
print_function.sh
Executable 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
696
public/README.md
Normal 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 multi‑utente, con:
|
||||
|
||||
- autenticazione JWT
|
||||
- scansione automatica delle cartelle
|
||||
- thumbnails generate lato server
|
||||
- sincronizzazione incrementale
|
||||
- WebSocket real‑time
|
||||
- frontend modulare e reattivo
|
||||
- mappa globale stile Google Photos
|
||||
- pannello info EXIF + geolocalizzazione
|
||||
- bottom sheet multi‑foto
|
||||
- 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 real‑time
|
||||
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
|
||||
|
||||
Auto‑refresh 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 end‑to‑end 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 (High‑Level)
|
||||
|
||||
`
|
||||
┌──────────────────────────┐
|
||||
│ 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 (end‑to‑end)
|
||||
|
||||
`
|
||||
[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
10
public/a
Normal 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 })
|
||||
});
|
||||
}
|
||||
BIN
public/activities/Fabio/original/prime/Zepp20260425083513.fit
Normal file
BIN
public/activities/Fabio/original/prime/Zepp20260425083513.fit
Normal file
Binary file not shown.
BIN
public/activities/Fabio/original/prime/Zepp20260510092621.fit
Normal file
BIN
public/activities/Fabio/original/prime/Zepp20260510092621.fit
Normal file
Binary file not shown.
BIN
public/activities/Fabio/original/prime/Zepp20260511082828.fit
Normal file
BIN
public/activities/Fabio/original/prime/Zepp20260511082828.fit
Normal file
Binary file not shown.
138101
public/activities/Fabio/original/prime/Zepp20260607180610.tcx
Normal file
138101
public/activities/Fabio/original/prime/Zepp20260607180610.tcx
Normal file
File diff suppressed because it is too large
Load diff
298031
public/activities/Fabio/original/prime/a.gpx
Normal file
298031
public/activities/Fabio/original/prime/a.gpx
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/activities/Fabio/original/try/Zepp20260512085905.fit
Normal file
BIN
public/activities/Fabio/original/try/Zepp20260512085905.fit
Normal file
Binary file not shown.
BIN
public/activities/Fabio/original/try/Zepp20260513075314.fit
Normal file
BIN
public/activities/Fabio/original/try/Zepp20260513075314.fit
Normal file
Binary file not shown.
BIN
public/activities/Fabio/original/try/Zepp20260514083242.fit
Normal file
BIN
public/activities/Fabio/original/try/Zepp20260514083242.fit
Normal file
Binary file not shown.
6
public/activities/scan_status.json
Normal file
6
public/activities/scan_status.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"current": 8,
|
||||
"total": 8,
|
||||
"percent": 100,
|
||||
"eta": "0m 0s"
|
||||
}
|
||||
143
public/admin.html
Normal file
143
public/admin.html
Normal 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>
|
||||
243
public/css/activityModal.css
Normal file
243
public/css/activityModal.css
Normal 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
95
public/css/admin.css
Normal 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
55
public/css/base.css
Normal 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
348
public/css/bottomSheet.css
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
/* =========================================
|
||||
Variabili globali
|
||||
========================================= */
|
||||
:root {
|
||||
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
MAPPA GLOBALE (contenitore sotto l’header)
|
||||
========================================= */
|
||||
.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;
|
||||
}
|
||||
269
public/css/bottomSheet.css.ok
Normal file
269
public/css/bottomSheet.css.ok
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/* =========================================
|
||||
Variabili globali
|
||||
========================================= */
|
||||
:root {
|
||||
--header-height: 60px; /* cambia se il tuo header è più alto/basso */
|
||||
}
|
||||
|
||||
/* =========================================
|
||||
MAPPA GLOBALE (contenitore sotto l’header)
|
||||
========================================= */
|
||||
.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
253
public/css/gallery.css
Normal 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
136
public/css/header.css
Normal 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 l’header */
|
||||
.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
140
public/css/infoPanel.css
Normal 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
27
public/css/login.css
Normal 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;
|
||||
}
|
||||
|
||||
176
public/css/map-activities.css
Normal file
176
public/css/map-activities.css
Normal 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;
|
||||
}
|
||||
|
||||
152
public/css/map-activities.css.ok
Normal file
152
public/css/map-activities.css.ok
Normal 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
119
public/css/map-base.css
Normal 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
152
public/css/map-photos.css
Normal 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
Loading…
Reference in a new issue