Files
ridebus-status/server/feed.mjs
2026-10-13 02:24:39 +03:00

56 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { stageNames } from './incident-updates.mjs';
import { maintenanceEnd } from './maintenance.mjs';
export function xmlEscape(value) {
return String(value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, '').replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]);
}
export function feedOptions(params, services) {
if (Array.from(params.keys()).some(key => !['lang', 'services', 'types'].includes(key)) || ['lang', 'services', 'types'].some(key => params.getAll(key).length > 1)) throw new Error('Use lang, services and types only');
const locale = params.has('lang') ? params.get('lang') : 'ru';
if (!['ru', 'en'].includes(locale)) throw new Error('Language must be ru or en');
function list(key, allowed) {
const values = params.has(key) ? params.get(key).split(',') : allowed;
if (!values.length || values.some(value => !allowed.includes(value))) throw new Error(`Invalid ${key}`);
return [...new Set(values)].sort();
}
return { locale, serviceIds: list('services', services.map(service => service.id)), types: list('types', ['incidents', 'maintenance']) };
}
export function buildFeed(snapshot, options, publicUrl) {
const { locale, serviceIds, types } = options;
const ru = locale === 'ru';
const events = [];
const link = (view, id) => { const url = new URL('/', publicUrl); url.searchParams.set('view', view); url.searchParams.set('lang', locale); if (id) url.hash = id; return url.toString(); };
if (types.includes('incidents')) for (const incident of snapshot.incidents) {
if (incident.serviceId && !serviceIds.includes(incident.serviceId)) continue;
for (const update of incident.updates) {
const details = [update.source === 'monitor' ? (ru ? 'Автоматическая проверка.' : 'Automatic monitoring.') : (ru ? 'Сообщение команды.' : 'Team update.'), update.message[locale], incident.impact ? `${ru ? 'Влияние' : 'Impact'}: ${incident.impact[locale]}` : null].filter(Boolean).join('\n\n');
events.push({ id: `incident:${incident.id}:${incident.startedAt}:${update.id}`, time: update.createdAt, title: `${incident.title[locale]} — ${stageNames[update.stage][locale]}`, description: details, category: ru ? 'Инциденты' : 'Incidents', link: link('incidents', `incident-${incident.id}`) });
}
}
if (types.includes('maintenance')) for (const item of snapshot.maintenance) {
if (item.serviceIds.length && !item.serviceIds.some(id => serviceIds.includes(id))) continue;
const dates = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' });
const affected = item.serviceIds.length ? snapshot.services.filter(service => item.serviceIds.includes(service.id)).map(service => service.name[locale]).join(' · ') : (ru ? 'Все сервисы' : 'All services');
const description = `${item.message[locale]}\n\n${ru ? 'Плановое окно' : 'Scheduled window'}: ${dates.format(item.startsAt)} — ${dates.format(item.endsAt)} (UTC).\n${ru ? 'Затронутые сервисы' : 'Affected services'}: ${affected}.`;
function event(phase, time, title) {
if (time !== null && time <= snapshot.generatedAt) events.push({ id: `maintenance:${item.id}:${item.createdAt}:${phase}`, time, title: `${item.title[locale]} — ${title}`, description, category: ru ? 'Плановые работы' : 'Maintenance', link: link('maintenance', `maintenance-${item.id}`) });
}
event('announced', item.createdAt, ru ? 'Анонс работ' : 'Maintenance announced');
if (maintenanceEnd(item) > item.startsAt) event('started', item.startsAt, ru ? 'Плановое окно началось' : 'Scheduled window started');
if (maintenanceEnd(item) >= item.endsAt) event('ended', item.endsAt, ru ? 'Плановое окно закончилось' : 'Scheduled window ended');
event('cancelled', item.cancelledAt, ru ? 'Работы отменены' : 'Maintenance cancelled');
event('completed', item.completedAt, ru ? 'Завершено командой' : 'Completed by the team');
}
const visible = events.filter(event => event.time <= snapshot.generatedAt).sort((a, b) => b.time - a.time || b.id.localeCompare(a.id)).slice(0, 100);
const self = new URL('/feed.xml', publicUrl);
self.searchParams.set('lang', locale);
self.searchParams.set('services', serviceIds.join(','));
self.searchParams.set('types', types.join(','));
const tag = (name, value) => `<${name}>${xmlEscape(value)}</${name}>`;
// Descriptions are HTML in RSS readers: escape user text before XML encoding.
const descriptionHtml = value => value.split('\n\n').map(text => `<p>${xmlEscape(text).replaceAll('\n', '<br/>')}</p>`).join('');
const items = visible.map(event => `<item>${tag('title', event.title)}${tag('link', event.link)}<guid isPermaLink="false">${xmlEscape(event.id)}</guid>${tag('pubDate', new Date(event.time).toUTCString())}${tag('description', descriptionHtml(event.description))}${tag('category', event.category)}</item>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>${tag('title', ru ? 'RideBus — обновления сервисов' : 'RideBus — service updates')}${tag('link', link('incidents'))}${tag('description', ru ? 'Инциденты и плановые работы выбранных сервисов RideBus.' : 'Incidents and maintenance for your selected RideBus services.')}${tag('language', locale)}${tag('lastBuildDate', new Date(visible[0]?.time || snapshot.generatedAt).toUTCString())}<ttl>5</ttl><atom:link href="${xmlEscape(self.toString())}" rel="self" type="application/rss+xml"/>\n${items}\n</channel></rss>`;
}