56 lines
5.8 KiB
JavaScript
56 lines
5.8 KiB
JavaScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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>`;
|
||
}
|