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])|(?"']/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)}`; // Descriptions are HTML in RSS readers: escape user text before XML encoding. const descriptionHtml = value => value.split('\n\n').map(text => `

${xmlEscape(text).replaceAll('\n', '
')}

`).join(''); const items = visible.map(event => `${tag('title', event.title)}${tag('link', event.link)}${xmlEscape(event.id)}${tag('pubDate', new Date(event.time).toUTCString())}${tag('description', descriptionHtml(event.description))}${tag('category', event.category)}`).join('\n'); return `\n${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())}5\n${items}\n`; }