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>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
441cfe3882
commit
c73d0a3aac
@@ -2,10 +2,12 @@
|
||||
|
||||
/**
|
||||
* Title matching for the PCO Plan module — the one answer to "is this
|
||||
* ProPresenter document that Planning Center thing?", used twice:
|
||||
* 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), and
|
||||
* 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).
|
||||
*
|
||||
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
|
||||
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
|
||||
}
|
||||
|
||||
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue };
|
||||
/* ── 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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user