Optimize landscape layout: 4-column grid, transport icons, improved sizing and spacing
This commit is contained in:
454
server.js
454
server.js
@@ -12,13 +12,6 @@ let config = {
|
||||
name: 'Ambassaderna',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
rssFeeds: [
|
||||
{
|
||||
name: "Travel Alerts",
|
||||
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -47,225 +40,6 @@ function loadSitesConfig() {
|
||||
// Load configuration on startup
|
||||
loadSitesConfig();
|
||||
|
||||
// Feed templates for different formats
|
||||
const feedTemplates = {
|
||||
rss2: {
|
||||
detect: (data) => data.includes('<rss version="2.0"'),
|
||||
itemPath: 'item',
|
||||
titlePath: 'title',
|
||||
descPath: 'description',
|
||||
datePath: 'pubDate'
|
||||
},
|
||||
atom: {
|
||||
detect: (data) => data.includes('<feed xmlns="http://www.w3.org/2005/Atom"'),
|
||||
itemPath: 'entry',
|
||||
titlePath: 'title',
|
||||
descPath: 'content',
|
||||
datePath: 'updated'
|
||||
},
|
||||
rss1: {
|
||||
detect: (data) => data.includes('<rdf:RDF'),
|
||||
itemPath: 'item',
|
||||
titlePath: 'title',
|
||||
descPath: 'description',
|
||||
datePath: 'dc:date'
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to detect feed type
|
||||
function detectFeedType(data) {
|
||||
for (const [type, template] of Object.entries(feedTemplates)) {
|
||||
if (template.detect(data)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
return 'rss2'; // Default to RSS 2.0
|
||||
}
|
||||
|
||||
// Helper function to clean HTML content
|
||||
function cleanHtmlContent(content) {
|
||||
if (!content) return '';
|
||||
|
||||
// Remove CDATA if present
|
||||
content = content.replace(/<!\[CDATA\[(.*?)\]\]>/gs, '$1');
|
||||
|
||||
// Remove HTML tags
|
||||
content = content.replace(/<[^>]+>/g, ' ');
|
||||
|
||||
// Convert common HTML entities
|
||||
content = content
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
|
||||
.replace(/&#x([A-Fa-f0-9]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
||||
|
||||
// Remove extra whitespace
|
||||
content = content.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Get first sentence if content is too long
|
||||
if (content.length > 200) {
|
||||
const match = content.match(/^.*?[.!?](?:\s|$)/);
|
||||
if (match) {
|
||||
content = match[0].trim();
|
||||
} else {
|
||||
content = content.substring(0, 197) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// Function to extract content based on template
|
||||
function extractContent(content, template) {
|
||||
const itemRegex = new RegExp(`<${template.itemPath}>([\\\s\\\S]*?)<\/${template.itemPath}>`, 'g');
|
||||
const titleRegex = new RegExp(`<${template.titlePath}>([\\\s\\\S]*?)<\/${template.titlePath}>`);
|
||||
const descRegex = new RegExp(`<${template.descPath}>([\\\s\\\S]*?)<\/${template.descPath}>`);
|
||||
const dateRegex = new RegExp(`<${template.datePath}>([\\\s\\\S]*?)<\/${template.datePath}>`);
|
||||
const linkRegex = /<link[^>]*?>([\s\S]*?)<\/link>/;
|
||||
|
||||
const items = [];
|
||||
let match;
|
||||
|
||||
while ((match = itemRegex.exec(content)) !== null) {
|
||||
const itemContent = match[1];
|
||||
|
||||
const titleMatch = titleRegex.exec(itemContent);
|
||||
const descMatch = descRegex.exec(itemContent);
|
||||
const dateMatch = dateRegex.exec(itemContent);
|
||||
const linkMatch = linkRegex.exec(itemContent);
|
||||
|
||||
const title = cleanHtmlContent(titleMatch ? titleMatch[1] : '');
|
||||
const description = cleanHtmlContent(descMatch ? descMatch[1] : '');
|
||||
|
||||
// Create display text from title and optionally description
|
||||
let displayText = title;
|
||||
if (description && description !== title) {
|
||||
displayText = `${title}: ${description}`;
|
||||
}
|
||||
|
||||
items.push({
|
||||
title,
|
||||
displayText,
|
||||
link: linkMatch ? linkMatch[1].trim() : '',
|
||||
description,
|
||||
pubDate: dateMatch ? dateMatch[1] : '',
|
||||
feedUrl: null // Will be set by caller
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// Function to fetch RSS feed data from a single URL
|
||||
async function fetchSingleRssFeed(feedUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`Fetching RSS feed from: ${feedUrl}`);
|
||||
|
||||
const request = https.get(feedUrl, (res) => {
|
||||
console.log(`RSS feed response status: ${res.statusCode}`);
|
||||
console.log('RSS feed response headers:', res.headers);
|
||||
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// Log first part of response
|
||||
console.log('RSS feed raw response (first 500 chars):', data.substring(0, 500));
|
||||
|
||||
// Detect feed type and get appropriate template
|
||||
const feedType = detectFeedType(data);
|
||||
const template = feedTemplates[feedType];
|
||||
console.log(`Detected feed type: ${feedType}`);
|
||||
|
||||
// Extract and process items
|
||||
const items = extractContent(data, template);
|
||||
console.log(`Extracted ${items.length} items from feed`);
|
||||
|
||||
// Add feed URL to each item
|
||||
items.forEach(item => {
|
||||
item.feedUrl = feedUrl;
|
||||
});
|
||||
|
||||
resolve(items);
|
||||
} catch (error) {
|
||||
console.error('Error processing RSS feed:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
resolve([
|
||||
{
|
||||
title: 'Error processing RSS feed',
|
||||
link: feedUrl,
|
||||
feedUrl: feedUrl,
|
||||
error: error.message
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
console.error('Error fetching RSS feed:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
resolve([
|
||||
{
|
||||
title: 'Error fetching RSS feed',
|
||||
link: feedUrl,
|
||||
feedUrl: feedUrl,
|
||||
error: error.message
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
// Set a timeout of 10 seconds
|
||||
request.setTimeout(10000, () => {
|
||||
console.error('RSS feed request timed out:', feedUrl);
|
||||
request.destroy();
|
||||
resolve([
|
||||
{
|
||||
title: 'RSS feed request timed out',
|
||||
link: feedUrl,
|
||||
feedUrl: feedUrl,
|
||||
error: 'Request timed out after 10 seconds'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Function to fetch all enabled RSS feeds
|
||||
async function fetchRssFeed() {
|
||||
try {
|
||||
const enabledFeeds = config.rssFeeds.filter(feed => feed.enabled);
|
||||
const feedPromises = enabledFeeds.map(feed => fetchSingleRssFeed(feed.url));
|
||||
|
||||
const allItems = await Promise.all(feedPromises);
|
||||
|
||||
// Flatten array and sort by date (if available)
|
||||
const items = allItems.flat().sort((a, b) => {
|
||||
const dateA = new Date(a.pubDate || 0);
|
||||
const dateB = new Date(b.pubDate || 0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return { items };
|
||||
} catch (error) {
|
||||
console.error('Error fetching RSS feeds:', error);
|
||||
return {
|
||||
items: [{
|
||||
title: 'Error fetching RSS feeds',
|
||||
link: '#',
|
||||
error: error.message
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Function to fetch data from the SL Transport API for a specific site
|
||||
function fetchDeparturesForSite(siteId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -309,11 +83,19 @@ function fetchDeparturesForSite(siteId) {
|
||||
const parsedData = JSON.parse(data);
|
||||
console.log('Successfully parsed fixed JSON');
|
||||
|
||||
if (parsedData && parsedData.departures) {
|
||||
const line7Departures = parsedData.departures.filter(d => d.line && d.line.designation === '7');
|
||||
if (line7Departures.length > 0) {
|
||||
console.log('Line 7 details:', JSON.stringify(line7Departures[0], null, 2));
|
||||
}
|
||||
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);
|
||||
@@ -405,6 +187,184 @@ const server = http.createServer(async (req, res) => {
|
||||
res.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
}
|
||||
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: [] }));
|
||||
});
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/config') {
|
||||
// Return the current configuration
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
@@ -420,7 +380,7 @@ const server = http.createServer(async (req, res) => {
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const newConfig = JSON.parse(body);
|
||||
if (newConfig.sites && newConfig.rssFeeds) {
|
||||
if (newConfig.sites) {
|
||||
config = newConfig;
|
||||
|
||||
// Save to file
|
||||
@@ -439,36 +399,6 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (parsedUrl.pathname === '/api/rss') {
|
||||
try {
|
||||
// If URL is provided, fetch single feed, otherwise fetch all enabled feeds
|
||||
const feedUrl = parsedUrl.query.url;
|
||||
console.log('RSS request received for URL:', feedUrl);
|
||||
|
||||
let data;
|
||||
if (feedUrl) {
|
||||
console.log('Fetching single RSS feed:', feedUrl);
|
||||
const items = await fetchSingleRssFeed(feedUrl);
|
||||
data = { items };
|
||||
console.log(`Fetched ${items.length} items from feed`);
|
||||
} else {
|
||||
console.log('Fetching all enabled RSS feeds');
|
||||
data = await fetchRssFeed();
|
||||
console.log(`Fetched ${data.items.length} total items from all feeds`);
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
console.log('RSS response sent successfully');
|
||||
} catch (error) {
|
||||
console.error('Error handling RSS request:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
}));
|
||||
}
|
||||
}
|
||||
// Serve static files
|
||||
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
||||
const fs = require('fs');
|
||||
|
||||
Reference in New Issue
Block a user