Migra dal codice salvato in locale al codice condiviso
This commit is contained in:
258
plugin/tools/crypt.js
Normal file
258
plugin/tools/crypt.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Modulo di crittografia centralizzato per MEB Plugin
|
||||
* Supporta AES-256-GCM per file sensibili e log CSV
|
||||
*
|
||||
* BEST PRACTICES SICUREZZA ENTERPRISE:
|
||||
* 1. La MASTER_KEY dovrebbe essere in variabile d'ambiente (process.env.MEB_MASTER_KEY)
|
||||
* 2. In produzione usare AWS KMS, HashiCorp Vault, o Azure Key Vault
|
||||
* 3. Rotazione periodica delle chiavi (ogni 90 giorni)
|
||||
* 4. Separazione chiavi: una per users, una per logs_references, una per log files
|
||||
* 5. Audit log di ogni accesso ai file sensibili
|
||||
*
|
||||
* GENERAZIONE CHIAVE SICURA:
|
||||
* Esegui nel terminale: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
* Poi imposta: export MEB_MASTER_KEY="la_chiave_generata"
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const fs = require('fs');
|
||||
|
||||
|
||||
const MASTER_KEY_HEX = process.env.CRYPTOKEY || null;
|
||||
const TOKEN_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||
const specialCharset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*_+-=';
|
||||
|
||||
|
||||
/**
|
||||
* Ottiene la chiave master (32 byte per AES-256)
|
||||
* @returns {Buffer} Chiave di 32 byte
|
||||
*/
|
||||
function getMasterKey() {
|
||||
if (!MASTER_KEY_HEX) {
|
||||
throw new Error("MASTER_KEY non definita. Imposta MEB_MASTER_KEY nelle variabili d'ambiente.");
|
||||
}
|
||||
const key = Buffer.from(MASTER_KEY_HEX, 'hex');
|
||||
if (key.length !== 32) {
|
||||
throw new Error("MASTER_KEY deve essere di 32 byte (64 caratteri hex).");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizza qualsiasi chiave custom a 32 byte Buffer per AES-256.
|
||||
* Accetta chiavi di qualsiasi lunghezza/formato.
|
||||
* @param {string|Buffer|null} customKey - Chiave custom o null per usare master key
|
||||
* @returns {Buffer} Chiave di 32 byte
|
||||
*/
|
||||
function normalizeKey(customKey) {
|
||||
if (!customKey) return getMasterKey();
|
||||
|
||||
if (typeof customKey === 'string') {
|
||||
// Se è hex di 64 caratteri, convertilo direttamente
|
||||
if (/^[0-9a-fA-F]{64}$/.test(customKey)) {
|
||||
return Buffer.from(customKey, 'hex');
|
||||
}
|
||||
// Altrimenti hash SHA-256 per ottenere 32 byte
|
||||
return crypto.createHash('sha256').update(customKey, 'utf8').digest();
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(customKey)) {
|
||||
if (customKey.length === 32) return customKey;
|
||||
return crypto.createHash('sha256').update(customKey).digest();
|
||||
}
|
||||
|
||||
throw new Error("customKey deve essere una stringa o un Buffer");
|
||||
}
|
||||
|
||||
// ==================== GENERAZIONE TOKEN ====================
|
||||
|
||||
/**
|
||||
* Genera un token esadecimale casuale UNICO ogni volta
|
||||
* @param {number} bytes - Numero di byte (default 24 = 48 caratteri hex)
|
||||
* @returns {string} Token esadecimale unico
|
||||
*/
|
||||
function generateToken(bytes = 24) {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un token leggibile (senza caratteri ambigui come 0/O, 1/l/I)
|
||||
* Più facile da comunicare verbalmente
|
||||
* @param {number} length - Lunghezza del token (default 32)
|
||||
* @returns {string} Token alfanumerico leggibile
|
||||
*/
|
||||
function generateReadableToken(length = 32) {
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += TOKEN_CHARSET[bytes[i] % TOKEN_CHARSET.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un token con caratteri speciali (più sicuro per chiavi sensibili)
|
||||
* @param {number} length - Lunghezza del token (default 64)
|
||||
* @returns {string} Token con caratteri speciali
|
||||
*/
|
||||
function generateSecureToken(length = 64) {
|
||||
|
||||
const bytes = crypto.randomBytes(length);
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += specialCharset[bytes[i] % specialCharset.length];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== CRITTOGRAFIA OGGETTI JSON (per file sensibili) ====================
|
||||
|
||||
/**
|
||||
* Cripta un oggetto JSON in Buffer binario (AES-256-GCM)
|
||||
* Usato per telegram_users.json e logs_references.json
|
||||
* @param {object} obj - Oggetto da criptare
|
||||
* @param {string|Buffer|null} customKey - Chiave custom (opzionale)
|
||||
* @returns {Buffer} Dati criptati [IV(12) + TAG(16) + CIPHERTEXT]
|
||||
*/
|
||||
// DISABILITATO: salviamo in chiaro
|
||||
function encrypt(obj, customKey = null) {
|
||||
const plaintext = Buffer.from(JSON.stringify(obj), 'utf8');
|
||||
return plaintext; // ritorna direttamente il contenuto in chiaro
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta un Buffer in oggetto JSON
|
||||
* @param {Buffer} buffer - Dati criptati
|
||||
* @param {string|Buffer|null} customKey - Chiave custom (opzionale)
|
||||
* @returns {object} Oggetto decriptato (array vuoto se fallisce)
|
||||
*/
|
||||
// DISABILITATO: leggiamo direttamente in chiaro
|
||||
function decrypt(buffer, customKey = null) {
|
||||
try {
|
||||
if (!buffer) return [];
|
||||
const content = buffer.toString('utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
console.error('[decrypt] Errore:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CRITTOGRAFIA FILE LOG CSV ====================
|
||||
|
||||
/**
|
||||
* Cripta un file CSV/testo sul disco
|
||||
* @param {string} filePath - Percorso del file
|
||||
* @param {string|Buffer|null} customKey - Chiave custom (qualsiasi lunghezza)
|
||||
* @returns {boolean} True se successo
|
||||
*/
|
||||
// DISABILITATO: i file log rimangono sempre in chiaro
|
||||
function encryptLog(filePath, customKey = null) {
|
||||
try {
|
||||
// Non fare nulla, lascia il file in chiaro
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[encryptLog] Errore:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta un file CSV/testo e lo riscrive sul disco
|
||||
* @param {string} filePath - Percorso del file criptato
|
||||
* @param {string|Buffer|null} customKey - Chiave custom
|
||||
* @returns {string|null} Contenuto decriptato o null se errore
|
||||
*/
|
||||
// DISABILITATO: i file sono già in chiaro
|
||||
function decryptLog(filePath, customKey = null) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return content; // ritorna contenuto in chiaro senza modifiche
|
||||
} catch (error) {
|
||||
console.error('[decryptLog] Errore:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decripta un file log e restituisce il contenuto SENZA modificare il file
|
||||
* @param {string} filePath - Percorso del file criptato
|
||||
* @param {string|Buffer|null} customKey - Chiave custom
|
||||
* @returns {string|null} Contenuto decriptato o null se errore
|
||||
*/
|
||||
// DISABILITATO: i file sono già in chiaro
|
||||
function decryptLogToMemory(filePath, customKey = null) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
console.error('[decryptLogToMemory] Errore:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GESTIONE FILE SENSIBILI (telegram_users, logs_references) ====================
|
||||
|
||||
/**
|
||||
* Carica e decripta un file JSON sensibile
|
||||
* Gestisce automaticamente file in chiaro (migrazione) e criptati
|
||||
* @param {string} filePath - Percorso del file
|
||||
* @param {object} defaultValue - Valore di default se file non esiste
|
||||
* @returns {object} Dati decriptati
|
||||
*/
|
||||
function loadSecureFile(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return defaultValue;
|
||||
}
|
||||
const content = fs.readFileSync(filePath, 'utf8').trim();
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.error(`[loadSecureFile] JSON non valido in ${filePath}:`, e.message);
|
||||
return defaultValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[loadSecureFile] Errore caricamento ${filePath}:`, error.message);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cripta e salva un file JSON sensibile
|
||||
* @param {string} filePath - Percorso del file
|
||||
* @param {object} data - Dati da salvare
|
||||
* @returns {boolean} True se successo
|
||||
*/
|
||||
function saveSecureFile(filePath, data) {
|
||||
try {
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[saveSecureFile] Errore salvataggio ${filePath}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// Generazione token
|
||||
generateToken,
|
||||
generateReadableToken,
|
||||
generateSecureToken,
|
||||
|
||||
// Crittografia oggetti JSON
|
||||
encrypt,
|
||||
decrypt,
|
||||
|
||||
// Crittografia file log
|
||||
encryptLog,
|
||||
decryptLog,
|
||||
decryptLogToMemory,
|
||||
|
||||
// Gestione file sensibili
|
||||
loadSecureFile,
|
||||
saveSecureFile,
|
||||
|
||||
// Utility
|
||||
normalizeKey
|
||||
};
|
||||
219
plugin/tools/logRecorder.js
Normal file
219
plugin/tools/logRecorder.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* logRecorder.js - Gestione registrazione dati separata
|
||||
* Centralizza tutte le funzioni di logging del dataset
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { datasetInit, appendData } = require('../datasetModels/datasetCore');
|
||||
|
||||
let app = null;
|
||||
let recordingInterval = null;
|
||||
let isRecording = false;
|
||||
|
||||
// Stato condiviso della registrazione
|
||||
const recordingState = {
|
||||
active: false,
|
||||
startTime: null,
|
||||
entryCount: 0,
|
||||
currentFile: null,
|
||||
stream: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Inizializza il recorder con l'istanza di SignalK app
|
||||
*/
|
||||
function init(signalkApp) {
|
||||
app = signalkApp;
|
||||
console.log('[LogRecorder] Inizializzato');
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccoglie i dati dai sensori SignalK
|
||||
*/
|
||||
function collectSensorData() {
|
||||
const getSK = (p) => {
|
||||
const v = app.getSelfPath(p);
|
||||
return v && v.value !== undefined && v.value !== null ? v.value : null;
|
||||
};
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
// Posizione
|
||||
latitude: getSK('navigation.position')?.latitude ?? null,
|
||||
longitude: getSK('navigation.position')?.longitude ?? null,
|
||||
speed: getSK('navigation.speedOverGround'),
|
||||
heading: getSK('navigation.headingTrue'),
|
||||
|
||||
// Batteria Trazione
|
||||
traction_voltage: getSK('electrical.batteries.traction.Voltage'),
|
||||
traction_current: getSK('electrical.batteries.traction.current'),
|
||||
traction_soc: getSK('electrical.batteries.traction.stateOfCharge'),
|
||||
traction_temperature: getSK('electrical.batteries.traction.temperature'),
|
||||
traction_power: getSK('electrical.batteries.traction.power'),
|
||||
|
||||
// Batteria Servizio
|
||||
service_voltage: getSK('electrical.batteries.service.Voltage'),
|
||||
service_current: getSK('electrical.batteries.service.current'),
|
||||
service_soc: getSK('electrical.batteries.service.stateOfCharge'),
|
||||
service_temperature: getSK('electrical.batteries.service.temperature'),
|
||||
|
||||
// Meteo (da OpenMeteo condiviso)
|
||||
temperature: getSK('meb.temperature'),
|
||||
windSpeed: getSK('meb.appleWindSpeed'),
|
||||
windDirection: getSK('meb.appleWindDirection'),
|
||||
|
||||
// Onde
|
||||
waveHeight: getSK('meb.waves.waveHeight'),
|
||||
wavePeriod: getSK('meb.waves.wavePeriod'),
|
||||
waveDirection: getSK('meb.waves.waveDirection')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuovo file di log
|
||||
*/
|
||||
function createNewLogFile() {
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'latitude', 'longitude', 'speed', 'heading',
|
||||
'traction_voltage', 'traction_current', 'traction_soc', 'traction_temperature', 'traction_power',
|
||||
'service_voltage', 'service_current', 'service_soc', 'service_temperature',
|
||||
'temperature', 'windSpeed', 'windDirection',
|
||||
'waveHeight', 'wavePeriod', 'waveDirection'
|
||||
];
|
||||
|
||||
const result = datasetInit(headers);
|
||||
if (result) {
|
||||
recordingState.currentFile = result.fileName;
|
||||
recordingState.stream = result.stream;
|
||||
console.log(`[LogRecorder] Nuovo file: ${result.fileName}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrive una riga di dati nel log
|
||||
*/
|
||||
function writeLogEntry(data) {
|
||||
if (!recordingState.stream) return false;
|
||||
|
||||
const values = [
|
||||
data.timestamp,
|
||||
data.latitude, data.longitude, data.speed, data.heading,
|
||||
data.traction_voltage, data.traction_current, data.traction_soc, data.traction_temperature, data.traction_power,
|
||||
data.service_voltage, data.service_current, data.service_soc, data.service_temperature,
|
||||
data.temperature, data.windSpeed, data.windDirection,
|
||||
data.waveHeight, data.wavePeriod, data.waveDirection
|
||||
];
|
||||
|
||||
appendData(values);
|
||||
recordingState.entryCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avvia la registrazione
|
||||
* @param {number} intervalMs - Intervallo in millisecondi (default 2000)
|
||||
*/
|
||||
function startRecording(intervalMs = 2000) {
|
||||
if (isRecording) {
|
||||
console.log('[LogRecorder] Registrazione già attiva');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
console.error('[LogRecorder] App non inizializzata');
|
||||
return false;
|
||||
}
|
||||
|
||||
const fileResult = createNewLogFile();
|
||||
if (!fileResult) {
|
||||
console.error('[LogRecorder] Impossibile creare file di log');
|
||||
return false;
|
||||
}
|
||||
|
||||
recordingState.active = true;
|
||||
recordingState.startTime = Date.now();
|
||||
recordingState.entryCount = 0;
|
||||
isRecording = true;
|
||||
|
||||
recordingInterval = setInterval(() => {
|
||||
const data = collectSensorData();
|
||||
writeLogEntry(data);
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`[LogRecorder] Registrazione avviata (ogni ${intervalMs}ms)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferma la registrazione
|
||||
*/
|
||||
function stopRecording() {
|
||||
if (!isRecording) {
|
||||
console.log('[LogRecorder] Nessuna registrazione attiva');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (recordingInterval) {
|
||||
clearInterval(recordingInterval);
|
||||
recordingInterval = null;
|
||||
}
|
||||
|
||||
if (recordingState.stream) {
|
||||
recordingState.stream.end();
|
||||
}
|
||||
|
||||
const duration = Date.now() - recordingState.startTime;
|
||||
console.log(`[LogRecorder] Registrazione fermata. Durata: ${Math.round(duration / 1000)}s, Entries: ${recordingState.entryCount}`);
|
||||
|
||||
recordingState.active = false;
|
||||
recordingState.stream = null;
|
||||
isRecording = false;
|
||||
|
||||
return {
|
||||
duration,
|
||||
entries: recordingState.entryCount,
|
||||
file: recordingState.currentFile
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Riavvia la registrazione (nuovo file)
|
||||
*/
|
||||
function restartRecording(intervalMs = 2000) {
|
||||
stopRecording();
|
||||
return startRecording(intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ottiene lo stato corrente della registrazione
|
||||
*/
|
||||
function getStatus() {
|
||||
return {
|
||||
isRecording,
|
||||
active: recordingState.active,
|
||||
startTime: recordingState.startTime,
|
||||
entryCount: recordingState.entryCount,
|
||||
currentFile: recordingState.currentFile,
|
||||
runningTime: isRecording ? Date.now() - recordingState.startTime : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se la registrazione è attiva
|
||||
*/
|
||||
function isActive() {
|
||||
return isRecording;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
restartRecording,
|
||||
getStatus,
|
||||
isActive,
|
||||
collectSensorData
|
||||
};
|
||||
35
plugin/tools/map.handler.js
Normal file
35
plugin/tools/map.handler.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
module.exports = function(app, settings) {
|
||||
// Serve mappa
|
||||
app.get('/meb/map', (req, res) => {
|
||||
const filePath = path.join(__dirname, "public", "map.html");
|
||||
fs.readFile(filePath, "utf8", (err, html) => {
|
||||
if (err) {
|
||||
res.status(500).send("Errore nel caricamento della mappa");
|
||||
return;
|
||||
}
|
||||
const token = settings?.mapboxKey ?? "";
|
||||
const finalHtml = html.replace("{{MAPBOX_KEY}}", token);
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.send(finalHtml);
|
||||
});
|
||||
});
|
||||
|
||||
// WebSocket forward: posizione in tempo reale
|
||||
let lastPosition = null;
|
||||
|
||||
app.streambundle.getSelfStream("navigation.position").onValue(pos => {
|
||||
lastPosition = pos;
|
||||
});
|
||||
|
||||
// Endpoint JSON per marker barca (se vuoi usarlo invece del WS SignalK)
|
||||
app.get('/meb/map/boat', (req, res) => {
|
||||
if (!lastPosition) {
|
||||
res.json({ error: "No position data available" });
|
||||
return;
|
||||
}
|
||||
res.json(lastPosition);
|
||||
});
|
||||
}
|
||||
262
plugin/tools/public/map.html
Normal file
262
plugin/tools/public/map.html
Normal file
@@ -0,0 +1,262 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Mappa Meteo SignalK</title>
|
||||
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.js"></script>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.2.0/mapbox-gl.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
transform-origin: center center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: white;
|
||||
background-color: rgba(60, 60, 60, 0.85);
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.25;
|
||||
min-width: 270px;
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div id="map"></div>
|
||||
<div id="infoBox" class="info">
|
||||
Caricamento dati...
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// MAPBOX TOKEN
|
||||
mapboxgl.accessToken =
|
||||
"pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ";
|
||||
|
||||
// Centro predefinito
|
||||
const defaultCenter = [9.19, 44.41];
|
||||
|
||||
// MAPPA
|
||||
const map = new mapboxgl.Map({
|
||||
container: "map",
|
||||
style: "mapbox://styles/mapbox/streets-v12",
|
||||
center: defaultCenter,
|
||||
zoom: 15
|
||||
});
|
||||
|
||||
// FUNZIONE CALCOLO DESTINAZIONE VETTORE
|
||||
function destinationPoint([lon, lat], bearingDeg, distanceMeters) {
|
||||
const R = 6371000;
|
||||
const brng = bearingDeg * Math.PI / 180;
|
||||
const d = distanceMeters;
|
||||
|
||||
const lat1 = lat * Math.PI / 180;
|
||||
const lon1 = lon * Math.PI / 180;
|
||||
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(d / R) +
|
||||
Math.cos(lat1) * Math.sin(d / R) * Math.cos(brng)
|
||||
);
|
||||
|
||||
const lon2 = lon1 + Math.atan2(
|
||||
Math.sin(brng) * Math.sin(d / R) * Math.cos(lat1),
|
||||
Math.cos(d / R) - Math.sin(lat1) * Math.sin(lat2)
|
||||
);
|
||||
|
||||
return [ lon2 * 180/Math.PI, lat2 * 180/Math.PI ];
|
||||
}
|
||||
|
||||
// QUANDO LA MAPPA È PRONTA
|
||||
map.on("load", () => {
|
||||
map.addSource("waveVector", {
|
||||
type: "geojson",
|
||||
data: { type: "Feature", geometry: { type: "LineString", coordinates: [defaultCenter, defaultCenter] }}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "waveVectorLine",
|
||||
type: "line",
|
||||
source: "waveVector",
|
||||
paint: { "line-color": "#0000ff", "line-width": 6 }
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "waveLabelText",
|
||||
type: "symbol",
|
||||
source: "waveVector",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"text-field": "Direzione Onde",
|
||||
"text-size": 30,
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#0000ff",
|
||||
"text-halo-color": "white",
|
||||
"text-halo-width": 4
|
||||
}
|
||||
});
|
||||
|
||||
map.addSource("windVector", {
|
||||
type: "geojson",
|
||||
data: { type: "Feature", geometry: { type: "LineString", coordinates: [defaultCenter, defaultCenter] }}
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "windVectorLine",
|
||||
type: "line",
|
||||
source: "windVector",
|
||||
paint: { "line-color": "lime", "line-width": 6 }
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: "windLabelText",
|
||||
type: "symbol",
|
||||
source: "windVector",
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"text-field": "Direzione Vento",
|
||||
"text-size": 30,
|
||||
"text-allow-overlap": true
|
||||
},
|
||||
paint: {
|
||||
"text-color": "lime",
|
||||
"text-halo-color": "black",
|
||||
"text-halo-width": 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// MARKER BARCA
|
||||
const boatEl = document.createElement("div");
|
||||
boatEl.className = "icon";
|
||||
boatEl.innerHTML = "⛵";
|
||||
boatEl.style.fontSize = "60px";
|
||||
const boatMarker = new mapboxgl.Marker({ element: boatEl }).setLngLat(defaultCenter).addTo(map);
|
||||
|
||||
// FRECCE
|
||||
const arrowEl = document.createElement("div");
|
||||
arrowEl.className = "icon";
|
||||
arrowEl.innerHTML = `<svg width="80" height="80" viewBox="0 0 100 100"><g id="arrowGroup" transform="rotate(0 50 50)"><polygon points="50,0 90,100 50,75 10,100" fill="blue" /></g></svg>`;
|
||||
const arrowMarker = new mapboxgl.Marker({ element: arrowEl }).setLngLat(defaultCenter).addTo(map);
|
||||
|
||||
const windEl = document.createElement("div");
|
||||
windEl.className = "icon";
|
||||
windEl.innerHTML = `<svg width="80" height="80" viewBox="0 0 100 100"><g id="windArrowGroup" transform="rotate(0 50 50)"><polygon points="50,0 90,100 50,75 10,100" fill="lime" /></g></svg>`;
|
||||
const windMarker = new mapboxgl.Marker({ element: windEl }).setLngLat(defaultCenter).addTo(map);
|
||||
|
||||
// SIGNALK VARIABILI
|
||||
let position=null, waveDir=null, waveHeight=null, wavePeriod=null, windDir=null, windSpeed=null, temperature=null;
|
||||
|
||||
// WebSocket SK
|
||||
const ws = new WebSocket(`ws://${location.host}/signalk/v1/stream?subscribe=all`);
|
||||
|
||||
ws.onmessage = msg => {
|
||||
const data = JSON.parse(msg.data);
|
||||
if (!data.updates) return;
|
||||
|
||||
data.updates.forEach(u => {
|
||||
u.values?.forEach(v => {
|
||||
|
||||
if (v.path === "navigation.position") position = [v.value.longitude, v.value.latitude];
|
||||
|
||||
if (v.path === "meb.waves.direction") waveDir = v.value;
|
||||
if (v.path === "meb.waves.height") waveHeight = v.value;
|
||||
if (v.path === "meb.waves.period") wavePeriod = v.value;
|
||||
|
||||
if (v.path === "meb.wind.direction") windDir = v.value;
|
||||
if (v.path === "environment.wind.speedTrue") windSpeed = v.value;
|
||||
|
||||
if (v.path === "environment.outside.temperature") temperature = v.value;
|
||||
});
|
||||
});
|
||||
|
||||
updateMap();
|
||||
};
|
||||
|
||||
// UPDATE MAP
|
||||
function updateMap() {
|
||||
if (!position) return;
|
||||
|
||||
boatMarker.setLngLat(position);
|
||||
|
||||
if (waveDir !== null) {
|
||||
const endPoint = destinationPoint(position, waveDir, 1000);
|
||||
map.getSource("waveVector").setData({
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: [position, endPoint] }
|
||||
});
|
||||
arrowMarker.setLngLat(endPoint);
|
||||
document.getElementById("arrowGroup").setAttribute("transform", `rotate(${waveDir} 50 50)`);
|
||||
}
|
||||
|
||||
if (windDir !== null) {
|
||||
const windHeading = (windDir + 180) % 360;
|
||||
const windEndPoint = destinationPoint(position, windHeading, 1000);
|
||||
map.getSource("windVector").setData({
|
||||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: [position, windEndPoint] }
|
||||
});
|
||||
windMarker.setLngLat(windEndPoint);
|
||||
document.getElementById("windArrowGroup").setAttribute("transform", `rotate(${windHeading} 50 50)`);
|
||||
}
|
||||
|
||||
updateInfoBox();
|
||||
map.easeTo({ center: position });
|
||||
}
|
||||
|
||||
// UPDATE INFO BOX
|
||||
function updateInfoBox() {
|
||||
const box = document.getElementById("infoBox");
|
||||
if (!position) return;
|
||||
|
||||
const lat = position[1].toFixed(5);
|
||||
const lon = position[0].toFixed(5);
|
||||
|
||||
const waveDeg = waveDir !== null ? `${waveDir.toFixed(0)}°` : "Need Request";
|
||||
const heightM = waveHeight !== null ? `${waveHeight.toFixed(2)} m` : "Need Request";
|
||||
const periodS = wavePeriod !== null ? `${wavePeriod.toFixed(1)} s` : "Need Request";
|
||||
|
||||
const windDeg = windDir !== null ? `${windDir.toFixed(0)}°` : "Need Request";
|
||||
const windKMH = windSpeed !== null ? `${(windSpeed*3.6).toFixed(1)} km/h` : "Need Request";
|
||||
const tempC = temperature !== null ? `${(temperature - 273.15).toFixed(1)} °C` : "Need Request";
|
||||
box.innerHTML = `
|
||||
<b>Direzione Onde:</b> ${waveDeg}<br>
|
||||
<b>Altezza Onda:</b> ${heightM}<br>
|
||||
<b>Periodo Onda:</b> ${periodS}<br>
|
||||
<b>Direzione Vento:</b> ${windDeg}<br>
|
||||
<b>Intensità Vento:</b> ${windKMH}<br>
|
||||
<b>Temperatura:</b> ${tempC}<br>
|
||||
<b>Lat:</b> ${lat} | <b>Lon:</b> ${lon}
|
||||
`;
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
74
plugin/tools/publisher.js
Normal file
74
plugin/tools/publisher.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* publisher.js - Pubblica dati su SignalK
|
||||
*/
|
||||
|
||||
/**
|
||||
* Genera valori SignalK da un oggetto dati
|
||||
* @param {Object} data - Dati da convertire
|
||||
* @param {string} prefix - Prefisso per i path SignalK
|
||||
* @returns {Array} Array di valori SignalK
|
||||
*/
|
||||
function generateValues(data, prefix = "meb") {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = [];
|
||||
|
||||
function traverse(obj, pathParts) {
|
||||
for (const key in obj) {
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
||||
|
||||
const val = obj[key];
|
||||
if (val === undefined || val === null) continue;
|
||||
|
||||
const newPath = [...pathParts, key];
|
||||
|
||||
if (typeof val === "object" && !Array.isArray(val)) {
|
||||
traverse(val, newPath);
|
||||
} else if (!Array.isArray(val)) {
|
||||
// Ignora array, pubblica solo valori primitivi
|
||||
values.push({
|
||||
path: newPath.join("."),
|
||||
value: val,
|
||||
meta: { displayName: key },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(data, [prefix]);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pubblica dati meteo su SignalK
|
||||
* @param {Object} app - Istanza app SignalK
|
||||
* @param {Object} weatherData - Dati meteo da pubblicare
|
||||
* @param {Object} settings - Impostazioni plugin
|
||||
*/
|
||||
function publishWeatherData(app, weatherData, settings) {
|
||||
if (!app || !weatherData) {
|
||||
console.warn('[Publisher] App o dati non disponibili');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = generateValues(weatherData);
|
||||
|
||||
if (values.length === 0) {
|
||||
console.debug('[Publisher] Nessun valore da pubblicare');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`📤 Pubblicazione ${values.length} valori SignalK`);
|
||||
|
||||
try {
|
||||
app.handleMessage("meb", {
|
||||
updates: [{ values }],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Publisher] Errore pubblicazione:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publish: publishWeatherData };
|
||||
321
plugin/tools/routes.js
Normal file
321
plugin/tools/routes.js
Normal file
@@ -0,0 +1,321 @@
|
||||
function setupRoutes(router, lastCallRef, app) {
|
||||
router.get("/ping", async (req, res) => {
|
||||
try {
|
||||
const text = lastCallRef.current || "pong";
|
||||
res.status(200).sendFile(__dirname + "/steering_support/helm_steering_destra.html");
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/helm_steering_destro", (req, res) => {
|
||||
try {
|
||||
res.status(200).sendFile(__dirname + "/steering_support/helm_steering_destro.html");
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tools", (req, res) => {
|
||||
try {
|
||||
const path = require("path");
|
||||
const filePath = path.join(__dirname, "..", "public", "decrypt_tool.html");
|
||||
res.status(200).sendFile(filePath);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// LOGS DATASETS
|
||||
router.post("/dataset/start", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const result = app.datasetControl.start();
|
||||
res.json({ success: result, message: result ? "Registrazione avviata" : "Registrazione già in corso" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dataset/stop", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const result = app.datasetControl.stop();
|
||||
res.json({ success: result, message: result ? "Registrazione fermata" : "Nessuna registrazione in corso" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/dataset/restart", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const result = app.datasetControl.restart();
|
||||
res.json({ success: result, message: "Registrazione riavviata" });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dataset/status", (req, res) => {
|
||||
try {
|
||||
if (!app.datasetControl) {
|
||||
return res.status(503).json({ error: "Dataset control non disponibile" });
|
||||
}
|
||||
const status = app.datasetControl.getStatus();
|
||||
res.json(status);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/dataset/files", (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logsDirectory = path.join(__dirname, '..', 'datasetModels', 'saved_datas');
|
||||
|
||||
if (!fs.existsSync(logsDirectory)) {
|
||||
return res.json({ files: [], count: 0 });
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(logsDirectory);
|
||||
const files = items
|
||||
.filter(item => {
|
||||
const fullPath = path.join(logsDirectory, item);
|
||||
return fs.statSync(fullPath).isFile();
|
||||
})
|
||||
.map(file => {
|
||||
const fullPath = path.join(logsDirectory, file);
|
||||
const stats = fs.statSync(fullPath);
|
||||
return {
|
||||
name: file,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
|
||||
res.json({ files, count: files.length });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== GRAPHS API ====================
|
||||
const graphsCore = require('../datasetModels/graphsCore.js');
|
||||
|
||||
// Serve la pagina HTML dei grafici
|
||||
router.get("/graphs", (req, res) => {
|
||||
try {
|
||||
const path = require("path");
|
||||
const filePath = path.join(__dirname, "..", "public", "graphs.html");
|
||||
res.status(200).sendFile(filePath);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// API per ottenere dati grafici
|
||||
router.get("/api/graphs", (req, res) => {
|
||||
try {
|
||||
const hours = parseInt(req.query.hours) || 24;
|
||||
const data = graphsCore.getAllGraphsData(hours);
|
||||
|
||||
// Aggiungi valori attuali dalla cache condivisa
|
||||
const sharedData = graphsCore.getSharedWeatherData();
|
||||
data.current = {
|
||||
temperature: sharedData.forecast?.temperature,
|
||||
windSpeed: sharedData.forecast?.windSpeed,
|
||||
waveHeight: sharedData.waves?.waveHeight,
|
||||
humidity: sharedData.forecast?.humidity
|
||||
};
|
||||
|
||||
// Aggiungi unità di misura
|
||||
data.units = graphsCore.getUnits();
|
||||
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// API per statistiche archivio
|
||||
router.get("/api/graphs/stats", (req, res) => {
|
||||
try {
|
||||
const stats = graphsCore.getArchiveStats();
|
||||
res.json(stats);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function getOpenApiSpec() {
|
||||
return {
|
||||
openapi: "3.0.0",
|
||||
info: { title: "MebWeather API Portal", version: "1.0.0" },
|
||||
servers: [{ url: "/plugins/meb-weather" }],
|
||||
paths: {
|
||||
"/ping": {
|
||||
get: {
|
||||
summary: "Called /ping route",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OK",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/meb/suggestion": {
|
||||
get: {
|
||||
summary: "Pagina di test MEB Suggestion",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OK",
|
||||
content: {
|
||||
"text/html": {
|
||||
schema: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/start": {
|
||||
post: {
|
||||
summary: "Avvia la registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registrazione avviata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/stop": {
|
||||
post: {
|
||||
summary: "Ferma la registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registrazione fermata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/restart": {
|
||||
post: {
|
||||
summary: "Riavvia la registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Registrazione riavviata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/status": {
|
||||
get: {
|
||||
summary: "Ottieni lo stato della registrazione dataset",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Stato corrente",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
isRecording: { type: "boolean" },
|
||||
recordCount: { type: "number" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/dataset/files": {
|
||||
get: {
|
||||
summary: "Ottieni la lista dei file log salvati",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Lista file log",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
files: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
size: { type: "number" },
|
||||
created: { type: "string" },
|
||||
modified: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
count: { type: "number" }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { setupRoutes, getOpenApiSpec };
|
||||
78
plugin/tools/utils.js
Normal file
78
plugin/tools/utils.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
|
||||
//Ottieni il percodi dal nome di una cartella, se questa non esiste, viene creata
|
||||
function getDirectory(directoryName) {
|
||||
const directoryPath = path.resolve(__dirname, directoryName);
|
||||
|
||||
if (!fs.existsSync(directoryPath)) {
|
||||
fs.mkdirSync(directoryPath, { recursive: true });
|
||||
} else {
|
||||
return directoryPath;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrivi un file con
|
||||
* @param {string} fileName - Il nome del file.
|
||||
* @param {string} extension - L'estensione
|
||||
* @param {string} content - Il contenuto del file.
|
||||
* @param {string} inDirectory - Il percorso in cui scrivere il file. Se non viene specificato, il file verrà aggiunto alla cartella principale del server.
|
||||
*
|
||||
* 🧠 Esempio d’uso
|
||||
* (async () => {
|
||||
* await writeFileToFolder("data", "prova.json", JSON.stringify({ name: "Giuseppe", age: 17 }, null, 2));
|
||||
* })();
|
||||
*
|
||||
*/
|
||||
async function write(fileName, extension, content, inDirectory) {
|
||||
try {
|
||||
const directoryPath = inDirectory ? getDirectory(inDirectory) : path.resolve(__dirname, '..');
|
||||
fs.mkdirSync(directoryPath, {recursive: true});
|
||||
|
||||
const filePath = path.join(directoryPath, `${fileName}.${extension}`);
|
||||
await fs.writeFileSync(filePath, content, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(`Error writing file ${fileName}.${extension}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
//Funzione per ottenere la data nel formato dd/mm/yyyy hh:mm
|
||||
function getDate(isoString) {
|
||||
const date = new Date(isoString);
|
||||
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Funzione per ottenere il tempo relativo ("2 ore fa", "tra 4 ore")
|
||||
function relativeData(isoString) {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = date - now; // differenza in millisecondi
|
||||
const diffSec = Math.round(diffMs / 1000);
|
||||
const diffMin = Math.round(diffSec / 60);
|
||||
const diffHr = Math.round(diffMin / 60);
|
||||
const diffDay = Math.round(diffHr / 24);
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
|
||||
|
||||
if (Math.abs(diffSec) < 60) return rtf.format(diffSec, "second");
|
||||
if (Math.abs(diffMin) < 60) return rtf.format(diffMin, "minute");
|
||||
if (Math.abs(diffHr) < 24) return rtf.format(diffHr, "hour");
|
||||
return rtf.format(diffDay, "day");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDirectory,
|
||||
write,
|
||||
getDate,
|
||||
relativeData,
|
||||
}
|
||||
Reference in New Issue
Block a user