414 lines
14 KiB
JavaScript
414 lines
14 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Service Times — reading the church website.
|
||
*
|
||
* Pure functions, no I/O, so every shape below can be checked without the
|
||
* network (parse.test.js). The server entry does the fetching.
|
||
*
|
||
* Every campus page is built from the same page-builder blocks: a "Times"
|
||
* heading, the day in bold, then the service times under it, ending before
|
||
* the "Get Involved" heading. Bounding the slice that way is what keeps the
|
||
* footer's main-office hours out of the service list — and some campuses
|
||
* (Apollo Beach) carry their own "Office Hours" block inside the Times
|
||
* section, which is why STOP_HEADINGS ends the walk early as well.
|
||
*
|
||
* The four shapes seen in the wild, all handled by walkSection():
|
||
*
|
||
* Woonsocket / Apollo Beach Sunday → 9:00a, 11:00a
|
||
* 9:00a & 11:00a
|
||
* North Attleboro Sunday → 7:30a, 9:30a, 11:30a
|
||
* 7:30a<br>9:30a<br>11:30a
|
||
* Orlando Sundays → Welcome 10:00a,
|
||
* Welcome 10:00am Start 10:30a
|
||
* Start 10:30am
|
||
*/
|
||
|
||
const WEEKDAYS = [
|
||
'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday',
|
||
];
|
||
|
||
/** Headings that end the service list wherever they turn up in the section. */
|
||
const STOP_HEADINGS = /^(office\s*hours?|get\s*involved|location\s*pastor|contact|address|events?)\b/i;
|
||
|
||
/** A time: "9:00a", "11:30am", "7a", "10:00 a.m." — never "1 person". */
|
||
const TIME_RE = /(\d{1,2})(?::(\d{2}))?\s*([ap])\.?\s*(?:m\.?)?(?![a-z])/gi;
|
||
|
||
/** "9:00a-4:30p" — an hours range, not two services. */
|
||
const RANGE_RE = /\d\s*(?::\d{2})?\s*[ap]\.?m?\.?\s*[-–—]\s*\d/i;
|
||
|
||
const NAMED_ENTITIES = {
|
||
amp: '&', nbsp: ' ', quot: '"', apos: "'", lt: '<', gt: '>',
|
||
ndash: '–', mdash: '—', hellip: '…', middot: '·', bull: '•',
|
||
rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
|
||
};
|
||
|
||
function fromCodePoint(n) {
|
||
try {
|
||
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function decodeEntities(input) {
|
||
return String(input == null ? '' : input)
|
||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => fromCodePoint(parseInt(hex, 16)))
|
||
.replace(/&#(\d+);/g, (_, dec) => fromCodePoint(parseInt(dec, 10)))
|
||
.replace(/&([a-z]+);/gi, (whole, name) => {
|
||
const value = NAMED_ENTITIES[name.toLowerCase()];
|
||
return value === undefined ? whole : value;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* HTML → the lines a reader would see. Block ends and <br> become line
|
||
* breaks so "7:30a<br>9:30a" stays two services rather than one run-on.
|
||
*/
|
||
function htmlToLines(html) {
|
||
return String(html == null ? '' : html)
|
||
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||
.replace(/<br\s*\/?>/gi, '\n')
|
||
.replace(/<\/(p|div|li|ul|ol|h[1-6]|td|tr|span|strong|b|em|i|a)\s*>/gi, '\n')
|
||
.replace(/<[^>]*>/g, ' ')
|
||
.split('\n')
|
||
.map((line) => decodeEntities(line).replace(/[ ]/g, ' ').replace(/\s+/g, ' ').trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
/** Title Case a slug: "north-attleboro" → "North Attleboro". */
|
||
function nameFromSlug(slug) {
|
||
return String(slug || '')
|
||
.split(/[-_/]+/)
|
||
.filter(Boolean)
|
||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||
.join(' ');
|
||
}
|
||
|
||
function slugify(name) {
|
||
return String(name == null ? '' : name)
|
||
.toLowerCase()
|
||
.normalize('NFKD')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
/** A weekday heading — "Sunday", "Sundays", "SUNDAY" — and nothing else. */
|
||
function dayFromLine(line) {
|
||
const cleaned = String(line || '').toLowerCase().replace(/[^a-z]+/g, '');
|
||
if (!cleaned) return '';
|
||
for (const day of WEEKDAYS) {
|
||
if (cleaned === day || cleaned === `${day}s`) {
|
||
return day.charAt(0).toUpperCase() + day.slice(1);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function pad2(n) {
|
||
return String(n).padStart(2, '0');
|
||
}
|
||
|
||
/** "9:00a" → { hour24: 9, minute: 0, text: "9:00a" }; 12a/12p handled. */
|
||
function toService(hourRaw, minuteRaw, meridiemRaw) {
|
||
const hour12 = Number(hourRaw);
|
||
const minute = Number(minuteRaw || 0);
|
||
const pm = String(meridiemRaw || '').toLowerCase() === 'p';
|
||
if (!Number.isFinite(hour12) || hour12 < 1 || hour12 > 12) return null;
|
||
if (!Number.isFinite(minute) || minute > 59) return null;
|
||
const hour24 = pm ? (hour12 === 12 ? 12 : hour12 + 12) : hour12 === 12 ? 0 : hour12;
|
||
return {
|
||
hour24,
|
||
minute,
|
||
minutes: hour24 * 60 + minute,
|
||
text: `${hour12}:${pad2(minute)}${pm ? 'p' : 'a'}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Times on one line, with the words in front of the first one as a label
|
||
* ("Welcome 10:00am" → label "Welcome"). A line holding an hours range
|
||
* ("9:00a-4:30p") is office hours, not services, and yields nothing.
|
||
*/
|
||
function timesFromLine(line) {
|
||
const text = String(line || '');
|
||
if (RANGE_RE.test(text)) return [];
|
||
TIME_RE.lastIndex = 0;
|
||
const found = [];
|
||
let first = -1;
|
||
let match = TIME_RE.exec(text);
|
||
while (match) {
|
||
const service = toService(match[1], match[2], match[3]);
|
||
if (service) {
|
||
if (first < 0) first = match.index;
|
||
found.push(service);
|
||
}
|
||
match = TIME_RE.exec(text);
|
||
}
|
||
if (!found.length) return [];
|
||
const label = first > 0
|
||
? text.slice(0, first).replace(/[\s|,;:&/–—-]+$/, '').trim()
|
||
: '';
|
||
// A label belongs to a single time ("Start 10:30am"); a list of times on
|
||
// one line ("9:00a & 11:00a") shares none.
|
||
return found.map((service) => ({ ...service, label: found.length === 1 ? label : '' }));
|
||
}
|
||
|
||
/**
|
||
* Walk the lines of a Times section into services, stopping at the first
|
||
* stop-heading. Lines before any weekday heading still count — a campus
|
||
* that lists bare times under "Times" is read as Sunday.
|
||
*/
|
||
function walkSection(lines, { defaultDay = 'Sunday' } = {}) {
|
||
const services = [];
|
||
let day = '';
|
||
for (const line of lines) {
|
||
if (STOP_HEADINGS.test(line)) break;
|
||
const heading = dayFromLine(line);
|
||
if (heading) {
|
||
day = heading;
|
||
continue;
|
||
}
|
||
for (const service of timesFromLine(line)) {
|
||
services.push({ ...service, day: day || defaultDay });
|
||
}
|
||
}
|
||
return services;
|
||
}
|
||
|
||
/** Give every service a stable id — marks are keyed on it, so it must not drift. */
|
||
function withIds(services) {
|
||
const seen = new Map();
|
||
return services.map((service) => {
|
||
const base = [
|
||
pad2(service.hour24) + pad2(service.minute),
|
||
slugify(service.label),
|
||
].filter(Boolean).join('-');
|
||
const count = (seen.get(base) || 0) + 1;
|
||
seen.set(base, count);
|
||
return { ...service, id: count === 1 ? base : `${base}-${count}` };
|
||
});
|
||
}
|
||
|
||
/** Drop exact duplicates (same day + time + label) while keeping order. */
|
||
function dedupe(services) {
|
||
const seen = new Set();
|
||
return services.filter((service) => {
|
||
const key = `${service.day}|${service.minutes}|${service.label.toLowerCase()}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The slice of a campus page between the "Times" heading and whatever ends
|
||
* it. Returns '' when the page has no Times heading, which the caller
|
||
* reports rather than guessing at.
|
||
*/
|
||
function timesSection(html) {
|
||
const text = String(html == null ? '' : html);
|
||
const start = /<([a-z0-9]+)\b[^>]*>\s*times\s*<\/\1\s*>/i.exec(text)
|
||
|| />\s*times\s*</i.exec(text);
|
||
if (!start) return '';
|
||
const from = start.index + start[0].length;
|
||
const rest = text.slice(from);
|
||
const end = /<([a-z0-9]+)\b[^>]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest)
|
||
|| />\s*get\s+involved\s*</i.exec(rest);
|
||
return end ? rest.slice(0, end.index) : rest.slice(0, 20000);
|
||
}
|
||
|
||
/**
|
||
* One campus page → { slug, name, day, services, error }. `error` is set
|
||
* (and services empty) when the page no longer looks the way this parser
|
||
* expects, so the admin health line can say the site changed rather than
|
||
* quietly showing a campus with no services.
|
||
*/
|
||
function parseCampusPage(html, { slug = '', name = '' } = {}) {
|
||
const campusSlug = slugify(slug);
|
||
const campusName = name || nameFromSlug(campusSlug);
|
||
const section = timesSection(html);
|
||
if (!section) {
|
||
return { slug: campusSlug, name: campusName, services: [], error: 'No "Times" section on the page' };
|
||
}
|
||
const services = withIds(dedupe(walkSection(htmlToLines(section))));
|
||
if (!services.length) {
|
||
return { slug: campusSlug, name: campusName, services: [], error: 'No service times under "Times"' };
|
||
}
|
||
return { slug: campusSlug, name: campusName, services, error: '' };
|
||
}
|
||
|
||
/**
|
||
* Campus page slugs from a sitemap and/or any page's links. The sitemap is
|
||
* the fuller list — it carries campuses the navigation does not link yet.
|
||
*/
|
||
function discoverCampusSlugs(...sources) {
|
||
const slugs = new Set();
|
||
for (const source of sources) {
|
||
const text = String(source == null ? '' : source);
|
||
const re = /\/locations\/([a-z0-9][a-z0-9-]*)\/?/gi;
|
||
let match = re.exec(text);
|
||
while (match) {
|
||
const slug = slugify(match[1]);
|
||
if (slug && slug !== 'locations') slugs.add(slug);
|
||
match = re.exec(text);
|
||
}
|
||
}
|
||
return [...slugs].sort();
|
||
}
|
||
|
||
/**
|
||
* The admin "Manual campuses" text: one campus per line,
|
||
*
|
||
* Name | Day | 9:00a, 11:00a
|
||
*
|
||
* Day is optional and defaults to Sunday: "Plymouth | 9:00a & 11:00a".
|
||
* A line the parser cannot read is returned in `errors` so the admin page
|
||
* can say which line, rather than the campus silently not appearing.
|
||
*/
|
||
function parseManualCampuses(text) {
|
||
const campuses = [];
|
||
const errors = [];
|
||
const lines = String(text == null ? '' : text).split(/\r?\n/);
|
||
lines.forEach((raw, index) => {
|
||
const line = raw.trim();
|
||
if (!line || line.startsWith('#')) return;
|
||
const parts = line.split('|').map((part) => part.trim());
|
||
const name = parts.shift() || '';
|
||
if (!name) return;
|
||
if (!parts.length) {
|
||
errors.push(`Line ${index + 1}: "${line}" has no times`);
|
||
return;
|
||
}
|
||
let day = '';
|
||
if (parts.length > 1) {
|
||
day = dayFromLine(parts[0]) || '';
|
||
if (day) parts.shift();
|
||
}
|
||
const services = withIds(dedupe(
|
||
walkSection(parts.join('\n').split('\n'), { defaultDay: day || 'Sunday' })
|
||
));
|
||
if (!services.length) {
|
||
errors.push(`Line ${index + 1}: no times found in "${line}"`);
|
||
return;
|
||
}
|
||
campuses.push({ slug: slugify(name), name, services, manual: true, error: '' });
|
||
});
|
||
return { campuses, errors };
|
||
}
|
||
|
||
/**
|
||
* Website campuses and manual ones into the one list the tiles draw.
|
||
*
|
||
* The website wins: a manual campus is a stopgap for a site that has not
|
||
* caught up, so as soon as the site publishes that campus (same slug) its
|
||
* times take over and the manual entry is marked `supersededBy` — the admin
|
||
* page says so, and the operator can delete the line.
|
||
*/
|
||
function mergeCampuses(siteCampuses, manualCampuses) {
|
||
const merged = [];
|
||
const bySlug = new Map();
|
||
for (const campus of siteCampuses || []) {
|
||
if (!campus || !campus.slug) continue;
|
||
const entry = { ...campus, manual: false, superseded: false };
|
||
bySlug.set(campus.slug, entry);
|
||
merged.push(entry);
|
||
}
|
||
for (const campus of manualCampuses || []) {
|
||
if (!campus || !campus.slug) continue;
|
||
const fromSite = bySlug.get(campus.slug);
|
||
if (fromSite) {
|
||
// The site has it now — its times are the ones that matter.
|
||
if (fromSite.services.length) {
|
||
fromSite.supersededManual = true;
|
||
continue;
|
||
}
|
||
// The page exists but gave us nothing usable; the manual line still helps.
|
||
fromSite.services = campus.services;
|
||
fromSite.manual = true;
|
||
fromSite.error = '';
|
||
continue;
|
||
}
|
||
const entry = { ...campus, superseded: false };
|
||
bySlug.set(campus.slug, entry);
|
||
merged.push(entry);
|
||
}
|
||
return merged;
|
||
}
|
||
|
||
/**
|
||
* The admin "Resi sources" text: one campus per line,
|
||
*
|
||
* woonsocket = north-attleboro
|
||
*
|
||
* Names work as well as slugs — both sides are slugified, so an operator can
|
||
* write what they say out loud. "->", ":" and "→" read as "=".
|
||
*/
|
||
function parseFeeds(text) {
|
||
const feeds = new Map();
|
||
const errors = [];
|
||
String(text == null ? '' : text).split(/\r?\n/).forEach((raw, index) => {
|
||
const line = raw.trim();
|
||
if (!line || line.startsWith('#')) return;
|
||
const match = /^(.+?)\s*(?:=|->|:|→)\s*(.+)$/.exec(line);
|
||
if (!match) {
|
||
errors.push(`Line ${index + 1}: "${line}" is not "campus = broadcast campus"`);
|
||
return;
|
||
}
|
||
const from = slugify(match[1]);
|
||
const to = slugify(match[2]);
|
||
if (!from || !to) {
|
||
errors.push(`Line ${index + 1}: "${line}" is missing a campus`);
|
||
return;
|
||
}
|
||
feeds.set(from, to);
|
||
});
|
||
return { feeds, errors };
|
||
}
|
||
|
||
/**
|
||
* Give every campus its production role: which ones originate the broadcast,
|
||
* which one each of the others takes its resi from, and — the question the
|
||
* board exists to answer — who is riding on each origin.
|
||
*
|
||
* A resi campus pointed at a campus that is not on the board keeps the
|
||
* pointer and is flagged (`sourceMissing`) rather than quietly reading as
|
||
* unassigned: a typo in the admin text has to be visible, not invisible.
|
||
*/
|
||
function assignRoles(campuses, { broadcast = [], feeds = new Map() } = {}) {
|
||
const origins = new Set([...broadcast].map((slug) => slugify(slug)).filter(Boolean));
|
||
const shaped = (campuses || []).map((campus) => {
|
||
const role = origins.has(campus.slug) ? 'broadcast' : 'resi';
|
||
const source = role === 'broadcast' ? '' : feeds.get(campus.slug) || '';
|
||
return { ...campus, role, source, sourceName: '', sourceMissing: false, dependents: [] };
|
||
});
|
||
const bySlug = new Map(shaped.map((campus) => [campus.slug, campus]));
|
||
for (const campus of shaped) {
|
||
if (!campus.source) continue;
|
||
const origin = bySlug.get(campus.source);
|
||
campus.sourceName = origin ? origin.name : campus.source;
|
||
campus.sourceMissing = !origin;
|
||
if (origin) origin.dependents.push({ slug: campus.slug, name: campus.name });
|
||
}
|
||
return shaped;
|
||
}
|
||
|
||
module.exports = {
|
||
WEEKDAYS,
|
||
decodeEntities,
|
||
htmlToLines,
|
||
nameFromSlug,
|
||
slugify,
|
||
dayFromLine,
|
||
timesFromLine,
|
||
walkSection,
|
||
timesSection,
|
||
parseCampusPage,
|
||
discoverCampusSlugs,
|
||
parseManualCampuses,
|
||
mergeCampuses,
|
||
parseFeeds,
|
||
assignRoles,
|
||
};
|