'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
9:30a
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
become line * breaks so "7:30a
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(//g, ' ') .replace(//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*]*>\s*get\s+involved\s*<\/\1\s*>/i.exec(rest) || />\s*get\s+involved\s* { 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, };