diff --git a/extension/extract.js b/extension/extract.js index 067d42f..3f9644e 100644 --- a/extension/extract.js +++ b/extension/extract.js @@ -32,13 +32,24 @@ // branch is anchored to 1-4 capitalized words instead of grabbing the sentence prefix. // Group 1 of each holds the candidate; the `d` flag gives its position for in-place wrapping. const QUOTED_RE = /["“]([^"”]{2,80})["”]|['‘]([^'’]{2,80})['’]/gd; - // A title-shaped run for the LP/EP branch: 1-4 tokens starting at a capital/quote, mirroring - // CAND_RE's connector set so multi-word titles ("Reign in Blood") survive. Bounded ({0,3}). - const TITLE_RUN = `["“'‘]?${TOKEN}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”'’]?`; + // A single word inside a title run (Title-Case/ALL-CAPS — or, for a digit-led run, a number). + const WORD = "[A-Za-z0-9'’.&\\-]*"; + // TITLE_RUN — a Title-Case-or-digit-led run of 1-4 tokens, mirroring CAND_RE's connector set so + // multi-word titles ("Reign in Blood") survive and bounded ({0,3}). Inner words are Title-Case + // tokens or connectors only, so trailing lowercase prose ("Money Store dropped" → "Money Store") + // is excluded. The lead also admits a DIGIT, so number-led titles ("1989 LP", "36 Chambers") + // survive; it does NOT admit a bare lowercase lead (keeps "their new LP" from leaking "new"). + const TITLE_RUN = `["“'‘]?[A-Z0-9]${WORD}(?:[ ]+(?:${TOKEN}|of|the|and|&|in|on|to|for|a|an)){0,3}["”'’]?`; + // LOWER_RUN — a fully lowercase-styled title (lead + inner words all lowercase/digit), bounded to + // 1-4 words, for stylized titles after an explicit album cue ("good kid maad city", "untitled"). + // A verb/article lead ("the album by Carcass", "the album is great") is captured here but then + // dropped by ALBUM_LEAD_STOP below, so this run never weakens the precision guard. + const LOWER_RUN = `[a-z0-9]${WORD}(?:[ ]+[a-z0-9]${WORD}){0,3}`; const ALBUM_CUE_RE = new RegExp( - // "the album X" — capture must START with a capital or opening quote so a cue immediately - // followed by a verb/article ("the album is great", "the album by Carcass") captures nothing. - `\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called\\s+|titled\\s+|named\\s+)?([A-Z"'“‘][A-Za-z0-9'’.&\\- ]{0,79}?)(?=[.,;:!?)\\]"”'’]|\\s+(?:is|was|by|and|on|from|which|that)\\b|$)` + + // "the album X" — bounded to a 1-4-token run (TITLE_RUN for a capital/digit-led title, LOWER_RUN + // for a lowercase-styled one), so "the album Money Store dropped" yields "Money Store", not the + // sentence tail. A verb/article lead is dropped by ALBUM_LEAD_STOP below. + `\\b(?:the )?(?:album|record|ep|lp)\\s+(?:called\\s+|titled\\s+|named\\s+)?(${TITLE_RUN}|${LOWER_RUN})\\b` + // "X LP/EP" — anchored title run (no left-anchor-less prefix grab). `|\\b(${TITLE_RUN})\\s+(?:LP|EP)\\b`, "gd" @@ -65,7 +76,32 @@ "some", "any", "good", "best", "my", "your", "his", "their", "out", "to", "one", "all", ]); - const TRAILING_PUNCT = /[.,;:!?’'")\]]+$/u; + // Trailing punctuation/quotes to strip off a captured run. Includes the curly close/open quotes + // (”“, U+201D/U+201C) so a title run that grabbed a trailing curly quote ("Reek of Putrefaction”") + // is trimmed symmetrically with the leading-quote trim; the span's index/length are adjusted to keep + // text.substr(index, length) === name. + const TRAILING_PUNCT = /[.,;:!?’'"”“)\]]+$/u; + + // True when `s` carries SENTENCE-ENDING punctuation (a clause/sentence break), so it reads as + // prose, not a title. `!`/`?` followed by whitespace/end always count. A `.`+whitespace/end counts + // UNLESS it is an abbreviation period — a 1-3-letter alpha token directly before it ("Mr.", "Sgt.", + // "St.", initials) — which a title legitimately carries ("Mr. Bungle", "Sgt. Pepper's"). All regexes + // are linear (no nesting/backtracking) and run only over the short quoted span. + function isSentenceEnding(s) { + if (/[!?](\s|$)/.test(s)) return true; // ! / ? — never an abbreviation + let i = s.indexOf("."); + while (i !== -1) { + const after = s[i + 1]; + if (after === undefined || /\s/.test(after)) { + // a period at end-of-string or before whitespace: sentence-ending unless it abbreviates a + // short alpha token (e.g. "Mr"/"Sgt"/"St"/"A") immediately to its left. + const m = s.slice(0, i).match(/([A-Za-z]+)$/); + if (!m || m[1].length > 3) return true; + } + i = s.indexOf(".", i + 1); + } + return false; + } function matchesIn(text) { const out = []; @@ -121,9 +157,13 @@ const name = text.slice(gi[0], gi[1]).trim(); const words = name.split(/\s+/); if (name.length < 2 || /^\d+$/.test(name)) continue; - // Reject prose quotes: a title doesn't carry sentence-ending punctuation mid-span and - // won't run long, and a lone interjection ("lol", "yeah") is a stopword, not a title. - if (/[.!?]/.test(name.slice(0, -1)) || words.length > 6) continue; + // Reject prose quotes: a title doesn't carry SENTENCE-ENDING punctuation mid-span (a `.!?` + // FOLLOWED by whitespace or end-of-string — "We hold these truths…self-evident.") and won't + // run long. An internal abbreviation period is NOT sentence-ending, so it survives — both the + // run-on-letter kind ("M.A.A.D City") and the short-token kind ("Mr. Bungle", "Sgt. Pepper's"), + // where a 1-3-char alpha token + period reads as an abbreviation, not a clause end. A lone + // interjection ("lol") is a stopword, not a title. + if (isSentenceEnding(name) || words.length > 8) continue; if (words.length === 1 && STOP.has(name[0].toUpperCase() + name.slice(1).toLowerCase())) continue; // index/length cover the inner text only (no quotes), so the wrapper links the title. push(name, gi[0], gi[1] - gi[0]); @@ -139,7 +179,10 @@ const lead = raw.match(/^["“'‘]+/); if (lead) { start += lead[0].length; raw = raw.slice(lead[0].length); } const name = raw.replace(TRAILING_PUNCT, "").trim(); - if (name.length < 2 || /^\d+$/.test(name)) continue; + // Keep `length < 2`, but admit a pure-number title here (unlike the bare-quote branch): an + // explicit album cue ("the album 1989", "1989 LP") is the precision signal that a number is a + // title, not noise — so number-led albums survive. + if (name.length < 2) continue; // A capital-led capture that is actually a verb/article ("Is great", "By Carcass") is no title. if (ALBUM_LEAD_STOP.has(name.split(/\s+/)[0].toLowerCase())) continue; push(name, start, name.length); diff --git a/extension/extract.test.cjs b/extension/extract.test.cjs index 4dbf121..e06cb6b 100644 --- a/extension/extract.test.cjs +++ b/extension/extract.test.cjs @@ -101,6 +101,78 @@ test("albumMatchesIn: tightened cues still map positions back to the text", () = } }); +// --- Recall rebalance: the a35dd95 precision tightening over-corrected. These assert the recovered +// recall (number/lowercase-led + abbreviation-period titles, bounded "the album X", no curly-quote +// leak) AND that the precision floor still holds (album path emits nothing on prose/filler). --- + +test("albumMatchesIn: recall — number-led titles via an explicit album cue", () => { + // The cue ("the album" / "LP") is the precision signal that justifies a number as a title. + assert.ok(albumMatchesIn("the album 1989").map((m) => m.name).includes("1989")); + assert.ok(albumMatchesIn("1989 LP").map((m) => m.name).includes("1989")); + assert.ok(albumMatchesIn("the album 36 Chambers").map((m) => m.name).includes("36 Chambers")); +}); + +test("albumMatchesIn: recall — lowercase-styled titles after a cue", () => { + // good kid maad city is fully lowercase — a stylized title, not prose; the cue admits it. + assert.ok( + albumMatchesIn("the album good kid maad city").map((m) => m.name).includes("good kid maad city") + ); +}); + +test("albumMatchesIn: recall — internal abbreviation periods survive in a quoted title", () => { + // A `.` mid-span is only sentence-ending when it follows a long word; "M.A.A.D"/"Mr."/"Sgt." abbreviate. + assert.ok(albumMatchesIn('"M.A.A.D City"').map((m) => m.name).includes("M.A.A.D City")); + assert.ok(albumMatchesIn('"Mr. Bungle"').map((m) => m.name).includes("Mr. Bungle")); + assert.ok( + albumMatchesIn('"Sgt. Pepper\'s Lonely Hearts Club Band"') + .map((m) => m.name) + .includes("Sgt. Pepper's Lonely Hearts Club Band") + ); +}); + +test("albumMatchesIn: recall — curly close-quote is trimmed off an LP/EP title run", () => { + // Separate the title from the “…” so the trailing curly quote, not an artist possessive, is the target. + const text = "their “Reek of Putrefaction” LP"; + const ms = albumMatchesIn(text); + const names = ms.map((m) => m.name); + assert.ok(names.includes("Reek of Putrefaction")); // exact string — no trailing ” + assert.ok(!names.some((n) => /[“”]/.test(n))); // no curly quote leaked into any candidate + // position mapping holds even after the leading-/trailing-quote trim shifts the span + for (const m of ms) assert.equal(text.substr(m.index, m.length), m.name); +}); + +test("albumMatchesIn: recall — 'the album X' is bounded to the title, not the trailing prose", () => { + // a35dd95 left "the album X" an unbounded lazy run; it now reuses the 1-4-token bound. + const names = albumMatchesIn("the album Money Store dropped").map((m) => m.name); + assert.ok(names.includes("Money Store")); // the title + assert.ok(!names.includes("Money Store dropped")); // not the title + trailing verb +}); + +test("albumMatchesIn: precision — album path still emits nothing on prose / filler", () => { + // Each of these must yield NO album candidate (the precision guards a35dd95 added must survive). + assert.equal(albumMatchesIn("I think their new LP is amazing").length, 0); // lowercase filler before LP + assert.equal(albumMatchesIn("the album by Carcass").length, 0); // verb/article left edge + assert.equal(albumMatchesIn("the album is great but I forgot the name").length, 0); // "is" lead + assert.equal(albumMatchesIn('"We hold these truths to be self-evident."').length, 0); // prose quote + assert.equal(albumMatchesIn('"lol"').length, 0); // lone interjection +}); + +test("albumMatchesIn: every rebalance candidate maps back to the source text", () => { + // Position-mapping invariant across the recall cases, including the curly-quote-trim shift. + const texts = [ + "the album 1989", + "1989 LP", + "the album good kid maad city", + '"M.A.A.D City"', + '"Mr. Bungle"', + "their “Reek of Putrefaction” LP", + "the album Money Store dropped", + ]; + for (const text of texts) { + for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name); + } +}); + test("isSafeHttpUrl accepts http/https and rejects javascript/data/garbage/empty", () => { // accepted: the only schemes a resolver link may inject as an href assert.ok(isSafeHttpUrl("https://open.spotify.com/artist/abc"));