Refactor: Complete codebase reorganization and modernization
- Split server.js routes into modular files (server/routes/) - departures.js: Departure data endpoints - sites.js: Site search and nearby sites - config.js: Configuration endpoints - Reorganized file structure following Node.js best practices: - Moved sites-config.json to config/sites.json - Moved API_RESPONSE_DOCUMENTATION.md to docs/ - Moved raspberry-pi-setup.sh to scripts/ - Archived legacy files to archive/ directory - Updated all code references to new file locations - Added archive/ to .gitignore to exclude legacy files from repo - Updated README.md with new structure and organization - All functionality tested and working correctly Version: 1.2.0
This commit is contained in:
440
server.js
440
server.js
@@ -1,8 +1,17 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const url = require('url');
|
||||
// Load environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
const PORT = 3002;
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Route handlers
|
||||
const departuresRouter = require('./server/routes/departures');
|
||||
const sitesRouter = require('./server/routes/sites');
|
||||
const configRouter = require('./server/routes/config');
|
||||
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
// Default configuration
|
||||
let config = {
|
||||
@@ -19,8 +28,9 @@ let config = {
|
||||
function loadSitesConfig() {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync('sites-config.json')) {
|
||||
const configData = fs.readFileSync('sites-config.json', 'utf8');
|
||||
const configPath = path.join('config', 'sites.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configData = fs.readFileSync(configPath, 'utf8');
|
||||
const loadedConfig = JSON.parse(configData);
|
||||
|
||||
// Handle old format (array of sites)
|
||||
@@ -40,92 +50,6 @@ function loadSitesConfig() {
|
||||
// Load configuration on startup
|
||||
loadSitesConfig();
|
||||
|
||||
// Function to fetch data from the SL Transport API for a specific site
|
||||
function fetchDeparturesForSite(siteId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
|
||||
console.log(`Fetching data from: ${apiUrl}`);
|
||||
|
||||
https.get(apiUrl, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
console.log('Raw API response:', data.substring(0, 200) + '...');
|
||||
|
||||
try {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
console.log('Successfully parsed as regular JSON');
|
||||
resolve(parsedData);
|
||||
return;
|
||||
} catch (jsonError) {
|
||||
console.log('Not valid JSON, trying to fix format...');
|
||||
}
|
||||
|
||||
if (data.startsWith('departures":')) {
|
||||
data = '{' + data;
|
||||
} else if (data.includes('departures":')) {
|
||||
const startIndex = data.indexOf('departures":');
|
||||
if (startIndex > 0) {
|
||||
data = '{' + data.substring(startIndex);
|
||||
}
|
||||
}
|
||||
|
||||
data = data.replace(/}{\s*"/g, '},{"');
|
||||
data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,');
|
||||
data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']');
|
||||
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
console.log('Successfully parsed fixed JSON');
|
||||
|
||||
if (parsedData && parsedData.departures && parsedData.departures.length > 0) {
|
||||
// Log first departure to see structure
|
||||
console.log('Sample departure structure:', JSON.stringify(parsedData.departures[0], null, 2));
|
||||
|
||||
// Check for direction-related fields
|
||||
const sample = parsedData.departures[0];
|
||||
console.log('Direction fields:', {
|
||||
direction: sample.direction,
|
||||
directionText: sample.directionText,
|
||||
directionCode: sample.directionCode,
|
||||
journeyDirection: sample.journey?.direction,
|
||||
stopPoint: sample.stopPoint
|
||||
});
|
||||
}
|
||||
|
||||
resolve(parsedData);
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing fixed JSON:', parseError);
|
||||
resolve({
|
||||
departures: [],
|
||||
error: 'Failed to parse API response: ' + parseError.message,
|
||||
rawResponse: data.substring(0, 500) + '...'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing API response:', error);
|
||||
resolve({
|
||||
departures: [],
|
||||
error: 'Error processing API response: ' + error.message,
|
||||
rawResponse: data.substring(0, 500) + '...'
|
||||
});
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
console.error('Error fetching data from API:', error);
|
||||
resolve({
|
||||
departures: [],
|
||||
error: 'Error fetching data from API: ' + error.message
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
@@ -139,302 +63,88 @@ const server = http.createServer(async (req, res) => {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Function to fetch data from all enabled sites
|
||||
async function fetchAllDepartures() {
|
||||
const enabledSites = config.sites.filter(site => site.enabled);
|
||||
|
||||
if (enabledSites.length === 0) {
|
||||
return { sites: [], error: 'No enabled sites configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const sitesPromises = enabledSites.map(async (site) => {
|
||||
try {
|
||||
const departureData = await fetchDeparturesForSite(site.id);
|
||||
return {
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
data: departureData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching departures for site ${site.id}:`, error);
|
||||
return {
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(sitesPromises);
|
||||
return { sites: results };
|
||||
} catch (error) {
|
||||
console.error('Error fetching all departures:', error);
|
||||
return { sites: [], error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle API endpoints
|
||||
// Handle API endpoints - use route handlers
|
||||
if (parsedUrl.pathname === '/api/departures') {
|
||||
try {
|
||||
const data = await fetchAllDepartures();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('Error handling departures request:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
await departuresRouter.handleDepartures(req, res, config);
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/sites/search') {
|
||||
// Search for transit sites
|
||||
const query = parsedUrl.query.q;
|
||||
if (!query || query.length < 2) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Query must be at least 2 characters' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(query)}`;
|
||||
console.log(`Searching sites: ${searchUrl}`);
|
||||
|
||||
https.get(searchUrl, (apiRes) => {
|
||||
let data = '';
|
||||
|
||||
// Check for HTTP errors
|
||||
if (apiRes.statusCode < 200 || apiRes.statusCode >= 300) {
|
||||
console.error(`API returned status code: ${apiRes.statusCode}`);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `API returned status ${apiRes.statusCode}`, sites: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
apiRes.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
apiRes.on('end', () => {
|
||||
try {
|
||||
console.log('Raw API response:', data.substring(0, 500));
|
||||
const parsedData = JSON.parse(data);
|
||||
console.log('Parsed data:', JSON.stringify(parsedData).substring(0, 500));
|
||||
|
||||
// Handle different possible response formats
|
||||
let sites = [];
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
// Response is directly an array
|
||||
sites = parsedData.map(site => ({
|
||||
id: String(site.id || site.siteId || site.SiteId || ''),
|
||||
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
|
||||
lat: site.lat || site.latitude || site.Lat || site.Latitude || null,
|
||||
lon: site.lon || site.longitude || site.Lon || site.Longitude || null
|
||||
}));
|
||||
} else if (parsedData.sites && Array.isArray(parsedData.sites)) {
|
||||
// Response has a sites property
|
||||
sites = parsedData.sites.map(site => ({
|
||||
id: String(site.id || site.siteId || site.SiteId || ''),
|
||||
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
|
||||
lat: site.lat || site.latitude || site.Lat || site.Latitude || null,
|
||||
lon: site.lon || site.longitude || site.Lon || site.Longitude || null
|
||||
}));
|
||||
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
|
||||
// SL API format with ResponseData
|
||||
sites = parsedData.ResponseData.Result.map(site => ({
|
||||
id: String(site.SiteId || site.id || ''),
|
||||
name: site.Name || site.name || site.StopPointName || 'Unknown',
|
||||
lat: site.Lat || site.lat || site.Latitude || site.latitude || null,
|
||||
lon: site.Lon || site.lon || site.Longitude || site.longitude || null
|
||||
}));
|
||||
} else {
|
||||
console.log('Unexpected response format:', parsedData);
|
||||
sites = [];
|
||||
}
|
||||
|
||||
// Log sample site to see structure
|
||||
if (sites.length > 0) {
|
||||
console.log('Sample site structure:', JSON.stringify(sites[0], null, 2));
|
||||
const sitesWithCoords = sites.filter(s => s.lat && s.lon);
|
||||
console.log(`Found ${sites.length} sites, ${sitesWithCoords.length} with coordinates`);
|
||||
} else {
|
||||
console.log('No sites found');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sites }));
|
||||
} catch (error) {
|
||||
console.error('Error parsing site search response:', error);
|
||||
console.error('Response data:', data.substring(0, 500));
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Error parsing search results', details: error.message, sites: [] }));
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
console.error('Error searching sites:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Error searching sites', details: error.message, sites: [] }));
|
||||
});
|
||||
sitesRouter.handleSiteSearch(req, res, parsedUrl);
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/sites/nearby') {
|
||||
// Get nearby transit sites based on coordinates
|
||||
const lat = parseFloat(parsedUrl.query.lat);
|
||||
const lon = parseFloat(parsedUrl.query.lon);
|
||||
const radius = parseInt(parsedUrl.query.radius) || 5000; // Default 5km radius
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid latitude or longitude', sites: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a broader search to get sites, then filter by distance
|
||||
// For now, we'll search for common Stockholm area terms and filter
|
||||
const searchTerms = ['Stockholm', 'T-Centralen', 'Gamla Stan', 'Södermalm'];
|
||||
const allSites = [];
|
||||
let completedSearches = 0;
|
||||
|
||||
searchTerms.forEach(term => {
|
||||
const searchUrl = `https://transport.integration.sl.se/v1/sites?q=${encodeURIComponent(term)}`;
|
||||
|
||||
https.get(searchUrl, (apiRes) => {
|
||||
let data = '';
|
||||
|
||||
apiRes.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
apiRes.on('end', () => {
|
||||
try {
|
||||
const parsedData = JSON.parse(data);
|
||||
let sites = [];
|
||||
|
||||
if (Array.isArray(parsedData)) {
|
||||
sites = parsedData;
|
||||
} else if (parsedData.sites) {
|
||||
sites = parsedData.sites;
|
||||
} else if (parsedData.ResponseData && parsedData.ResponseData.Result) {
|
||||
sites = parsedData.ResponseData.Result;
|
||||
}
|
||||
|
||||
sites.forEach(site => {
|
||||
const siteLat = site.lat || site.latitude || site.Lat || site.Latitude;
|
||||
const siteLon = site.lon || site.longitude || site.Lon || site.Longitude;
|
||||
|
||||
if (siteLat && siteLon) {
|
||||
// Calculate distance (simple haversine approximation)
|
||||
const distance = Math.sqrt(
|
||||
Math.pow((lat - siteLat) * 111000, 2) +
|
||||
Math.pow((lon - siteLon) * 111000 * Math.cos(lat * Math.PI / 180), 2)
|
||||
);
|
||||
|
||||
if (distance <= radius) {
|
||||
allSites.push({
|
||||
id: String(site.id || site.siteId || site.SiteId || ''),
|
||||
name: site.name || site.siteName || site.Name || site.StopPointName || 'Unknown',
|
||||
lat: siteLat,
|
||||
lon: siteLon
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
completedSearches++;
|
||||
if (completedSearches === searchTerms.length) {
|
||||
// Remove duplicates
|
||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
||||
}
|
||||
} catch (error) {
|
||||
completedSearches++;
|
||||
if (completedSearches === searchTerms.length) {
|
||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
||||
}
|
||||
}
|
||||
});
|
||||
}).on('error', () => {
|
||||
completedSearches++;
|
||||
if (completedSearches === searchTerms.length) {
|
||||
const uniqueSites = Array.from(new Map(allSites.map(s => [s.id, s])).values());
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ sites: uniqueSites }));
|
||||
}
|
||||
});
|
||||
});
|
||||
sitesRouter.handleNearbySites(req, res, parsedUrl);
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/config') {
|
||||
// Return the current configuration
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(config));
|
||||
configRouter.handleGetConfig(req, res, config);
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/config/update' && req.method === 'POST') {
|
||||
// Update configuration
|
||||
let body = '';
|
||||
req.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const newConfig = JSON.parse(body);
|
||||
if (newConfig.sites) {
|
||||
config = newConfig;
|
||||
|
||||
// Save to file
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('sites-config.json', JSON.stringify(config, null, 2));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, message: 'Configuration updated' }));
|
||||
} else {
|
||||
throw new Error('Invalid configuration format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating configuration:', error);
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
configRouter.handleUpdateConfig(req, res, config);
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/config/client') {
|
||||
configRouter.handleClientConfig(req, res);
|
||||
}
|
||||
// Serve static files
|
||||
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
||||
const fs = require('fs');
|
||||
fs.readFile('index.html', (err, data) => {
|
||||
fs.readFile('index.html', 'utf8', (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Error loading index.html');
|
||||
return;
|
||||
}
|
||||
// Always inject API key and config into HTML (even if empty, so window vars exist)
|
||||
const apiKey = process.env.OPENWEATHERMAP_API_KEY || '';
|
||||
if (!data.includes('window.OPENWEATHERMAP_API_KEY')) {
|
||||
// Inject before closing </head> tag
|
||||
const configScript = `
|
||||
<script>
|
||||
window.OPENWEATHERMAP_API_KEY = '${apiKey.replace(/'/g, "\\'")}';
|
||||
window.DEFAULT_LOCATION = {
|
||||
latitude: ${parseFloat(process.env.DEFAULT_LATITUDE) || 59.3293},
|
||||
longitude: ${parseFloat(process.env.DEFAULT_LONGITUDE) || 18.0686},
|
||||
name: '${(process.env.DEFAULT_LOCATION_NAME || 'Stockholm').replace(/'/g, "\\'")}'
|
||||
};
|
||||
</script>`;
|
||||
data = data.replace('</head>', configScript + '\n</head>');
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(data);
|
||||
});
|
||||
}
|
||||
else if (/\.(js|css|png|jpg|jpeg|gif|ico)$/.test(parsedUrl.pathname)) {
|
||||
const fs = require('fs');
|
||||
const filePath = parsedUrl.pathname.substring(1);
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('File not found');
|
||||
return;
|
||||
}
|
||||
// Serve static files from public directory or root
|
||||
else if (/\.(js|css|png|jpg|jpeg|gif|ico|svg|json)$/.test(parsedUrl.pathname)) {
|
||||
let filePath = parsedUrl.pathname.substring(1); // Remove leading /
|
||||
|
||||
// Try public directory first, then root directory for backward compatibility
|
||||
const publicPath = path.join('public', filePath);
|
||||
const rootPath = filePath;
|
||||
|
||||
// Check if file exists in public directory first
|
||||
fs.access(publicPath, fs.constants.F_OK, (publicErr) => {
|
||||
const targetPath = publicErr ? rootPath : publicPath;
|
||||
|
||||
const ext = parsedUrl.pathname.split('.').pop();
|
||||
const contentType = {
|
||||
'js': 'text/javascript',
|
||||
'css': 'text/css',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'ico': 'image/x-icon'
|
||||
}[ext] || 'text/plain';
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
fs.readFile(targetPath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = parsedUrl.pathname.split('.').pop();
|
||||
const contentType = {
|
||||
'js': 'text/javascript',
|
||||
'css': 'text/css',
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'ico': 'image/x-icon',
|
||||
'svg': 'image/svg+xml',
|
||||
'json': 'application/json'
|
||||
}[ext] || 'text/plain';
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
|
||||
Reference in New Issue
Block a user