The current item followed the live ProPresenter document by title alone, and a sermon's document is named after the sermon — Waters names them "YYMMDD Series Title - Part #" — never after its plan item. So the last song stayed current through the whole message: North Attleboro on 20 Sept spent 65 minutes on one song in two of its three services. A live document that reads like the plan itself — its date code, series title and part — now lands on the plan's message item: an item titled Message, Sermon or Teaching, else the first item under such a header. A date code naming another day is a firm no, so last week's sermon stays unplaced. Position in the ProPresenter playlist breaks a tie between equal names, only while the playlist mirrors the plan item for item, and never places a document on its own. The log now says what it never said: every feed drop and reconnect, which item the message document will land on, what placed each item and how, and a live document that is not in the plan. matching.test.js: 27 checks built from both campuses' real 27 Sept plans and ProPresenter playlists. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
260 lines
11 KiB
JavaScript
260 lines
11 KiB
JavaScript
'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,
|
||
};
|