Files
ProdDash/modules/pco-plan/matching.js
T
Waters ChurchandClaude Fable 5.1 c73d0a3aac PCO Plan 1.6.0: the message document is matched by the plan's own name
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>
2026-09-22 13:06:53 -04:00

260 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};