'use strict'; /** * Title matching for the PCO Plan module — the one answer to "is this * ProPresenter document that Planning Center thing?", used three ways: * * - mapping the document ProPresenter has live to a plan item (the * highlight and the item runtimes) — placeDocument(), below, * - recognising the message document, which is named after the sermon * and never after its plan item — the message rule, below, and * - recognising the documents an admin picked as the service start / end * cues (see service-timing.js). * * A ProPresenter name and a Planning Center title rarely agree letter for * letter ("This Is The Day - [ Full ]" vs "This Is The Day"), so both sides * are tidied the way propresenter-core.cleanItemName does (tags in brackets, * leading date codes and dash separators dropped), lower-cased, stripped of * punctuation and compared: * * 1.00 identical after tidying "Post-Service Loop" ≈ "Post Service Loop" * 0.85 one contains the other "Welcome" ≈ "Welcome & Announcements" * Dice 2·shared words ÷ all words "Pre Service Loop" vs "Post Service Loop" → 0.67 * * Filler words ("the", "of", "and"…) are ignored. The thresholds: * * MATCH_THRESHOLD 0.6 plan items — two of three words is enough, because * the item that scores best *and* is next in the * plan wins, so a near miss lands on the right row. * TRIGGER_THRESHOLD 0.75 service cues — a cue starts or freezes the service * clock, so one wrong word must not fire it: the * pre-service loop (0.67 against "Post Service Loop") * stays a non-match, while a renamed * "Post-Service Loop", a tagged "Post Service Loop * [Full]" (1.0) or a shortened "Post Service" (0.85) * still trigger. */ const MATCH_THRESHOLD = 0.6; const TRIGGER_THRESHOLD = 0.75; /** Same title tidying as propresenter-core.cleanItemName (drops [tags], date codes). */ function cleanTitle(raw) { const original = String(raw || '').trim(); let s = original; s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); s = s.replace(/\s[-–—]+\s/g, ' '); s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); s = s.replace(/\s{2,}/g, ' ').trim(); return s || original; } function normalize(raw) { return cleanTitle(raw) .toLowerCase() .replace(/&/g, ' and ') .replace(/[’'`]/g, '') .replace(/[^a-z0-9]+/g, ' ') .trim(); } const STOPWORDS = new Set(['the', 'a', 'an', 'of', 'and', 'to', 'in', 'is', 'my', 'your', 'our']); function tokens(norm) { return new Set(norm.split(' ').filter((t) => t && !STOPWORDS.has(t))); } /** 0..1 similarity between two titles. */ function similarity(a, b) { const na = normalize(a); const nb = normalize(b); if (!na || !nb) return 0; if (na === nb) return 1; if (na.length >= 4 && nb.length >= 4 && (na.includes(nb) || nb.includes(na))) return 0.85; const ta = tokens(na); const tb = tokens(nb); if (!ta.size || !tb.size) return 0; let common = 0; for (const t of ta) if (tb.has(t)) common += 1; return (2 * common) / (ta.size + tb.size); // Dice coefficient } /** Does a live ProPresenter document count as the cue document an admin picked? */ function isCue(liveName, cueDocument) { return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD; } /* ── the message rule ────────────────────────────────────────────────── A sermon's ProPresenter document is named after the sermon, never after the plan item that stands for it ("Message", "Resi Message"), so no name comparison can place it — and the previous song stayed "current" through a whole message. What the document's name does carry is the plan's own identity. Waters names them "YYMMDD Series Title - Part #": the plan's date, its series title and its title. A live document that reads like the plan is the plan's message, and lands on the plan's message item, however that item happens to be titled. */ const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i; const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i; /** Undated, the name must read like "Series Title Part #" at least this closely… */ const MESSAGE_THRESHOLD = 0.75; /** …dated with this plan's date, it need only resemble the series or the title. */ const DATED_MESSAGE_THRESHOLD = 0.6; /** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */ function leadingDateCode(raw) { const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || '')); if (!m) return null; const code = m[1]; const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2)); const mo = Number(code.slice(-4, -2)); const d = Number(code.slice(-2)); if (mo < 1 || mo > 12 || d < 1 || d > 31) return null; return { y, m: mo, d }; } /** The local calendar day of an epoch-ms instant, in the same shape. */ function dayOf(ms) { const t = new Date(Number(ms)); if (!Number.isFinite(t.getTime())) return null; return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() }; } function sameDay(a, b) { return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d; } /** * Does this live document read like the plan itself — "260927 Where is Your * Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated * 27 Sept 2026? Returns { score, dated } or null. * * A date code naming another day is a firm no: last week's sermon is not * this plan's message. A date code naming the plan's day is strong evidence, * so the rest need only resemble the series or the title. Without a date * the name must read like series + title together — and carry the title * when the plan has both, so a series bumper ("Where is Your Faith?") is * not taken for the sermon. */ function messageDocumentScore(name, plan) { const series = String(plan?.seriesTitle || '').trim(); const title = String(plan?.title || '').trim(); if (!series && !title) return null; const code = leadingDateCode(name); const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null; if (code && planDay && !sameDay(code, planDay)) return null; const dated = Boolean(code && planDay); const full = [series, title].filter(Boolean).join(' '); const fullScore = similarity(name, full); if (dated) { const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0); return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null; } if (fullScore < MESSAGE_THRESHOLD) return null; if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null; return { score: fullScore, dated: false }; } /** * The plan item that stands for the message: an item titled with the word * ("Message", "Resi Message" — a bare "Message" first), else the first item * under a header so titled (a "Sermon" section whose one item is the sermon's * own title). Null when the plan has no such slot. */ function messageItemOf(items) { let best = null; let inSection = false; for (const it of Array.isArray(items) ? items : []) { if (!it) continue; const title = String(it.title || ''); if (it.itemType === 'header') { inSection = MESSAGE_WORDS.test(title); continue; } const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0; if (rank && (!best || rank > best.rank)) best = { item: it, rank }; if (rank === 3) break; } return best ? best.item : null; } /** * Does ProPresenter's playlist mirror the plan item for item? ProPresenter * builds a Planning Center playlist that way — every plan item, headers * included, each flagged as PCO — until an operator adds something of their * own (a song tag, say), which shifts every position after it. Same length, * headers in the same places and every item PCO-flagged is the test. */ function playlistMirrorsPlan(ppItems, items) { if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false; return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header')); } /** Best plan item by title, the ones ahead of the current item winning ties. */ function bestByName(name, items, all, ctx) { const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId); const hint = ctx?.hint; const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null; let best = null; for (let i = 0; i < items.length; i += 1) { const item = items[i]; const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0); if (score < MATCH_THRESHOLD) continue; // Distance ahead of the current item (wrapping so items behind rank last). const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length; const atPosition = Boolean(wanted && item.id === wanted.id); const candidate = { item, score, dist, atPosition }; if (!best || score > best.score + 0.001) { best = candidate; continue; } if (Math.abs(score - best.score) > 0.001) continue; if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate; } return best ? { item: best.item, score: best.score, via: 'name' } : null; } /** * Where a live ProPresenter document sits in the plan: { item, score, via } * or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional) * is { index, mirror } — the document's position in the ProPresenter * playlist and whether that playlist mirrors the plan (playlistMirrorsPlan). * * By name first: the item (or song) title most like the document's name, * the ones ahead of the current item winning ties — a near miss lands on the * right row. Then the message rule: a document that reads like the plan * itself lands on the plan's message item. It overrides a name hit that is * anything short of identical — a song called "Faith" must not catch * "260927 Where is Your Faith? - Part 6" — outright when the document is * dated with the plan's day, and when it reads better otherwise. Position * is never a match on its own: it only breaks a tie between equally good * names, and only while the playlist mirrors the plan. */ function placeDocument(name, ctx) { const all = Array.isArray(ctx?.items) ? ctx.items : []; const items = all.filter((it) => it && it.itemType !== 'header'); if (!items.length) return null; const byName = bestByName(name, items, all, ctx); const asMessage = messageDocumentScore(name, ctx?.plan); const messageItem = asMessage ? messageItemOf(all) : null; if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) { return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' }; } return byName; } module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, MESSAGE_THRESHOLD, cleanTitle, normalize, similarity, isCue, leadingDateCode, messageDocumentScore, messageItemOf, playlistMirrorsPlan, placeDocument, };