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,
|
|||
|
|
};
|