@playpilot/tpi 8.29.11 → 8.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/editorial.mount.js +9 -9
- package/dist/link-injections.js +1 -1
- package/dist/mount.js +7 -7
- package/package.json +1 -1
- package/src/lib/injection.ts +479 -479
- package/src/lib/types/global.d.ts +1 -0
- package/src/routes/components/Explore/Routes/ExploreModal.svelte +107 -107
- package/src/routes/components/Modals/TitlesReelModal.svelte +3 -3
- package/src/routes/components/Rails/TitlesRail.svelte +1 -1
- package/src/routes/components/YouTubeEmbed.svelte +61 -20
- package/src/routes/components/YouTubeEmbedBackground.svelte +1 -0
- package/src/routes/components/YouTubeEmbedOverlay.svelte +1 -1
- package/src/routes/elements/+page.svelte +12 -0
- package/src/tests/routes/components/YouTubeEmbed.test.js +109 -33
- package/src/tests/routes/components/YouTubeEmbedBackground.test.js +2 -3
- package/src/tests/routes/components/YouTubeEmbedOverlay.test.js +2 -3
package/src/lib/injection.ts
CHANGED
|
@@ -1,479 +1,479 @@
|
|
|
1
|
-
import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getIndexOfPhraseInElement, getIndexOfPhraseInBoundary, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom, findSurroundingPhrases } from './text'
|
|
2
|
-
import type { LinkInjection, LinkInjectionTypes } from './types/injection'
|
|
3
|
-
import { getNumberOfOccurrencesInArray } from './array'
|
|
4
|
-
import { destroyAllModals, openModalForInjectedLink } from './modal'
|
|
5
|
-
import { clearCurrentlyHoveredInjection, destroyLinkPopover, destroyLinkPopoverOnMouseleave, isPopoverActive, openPopoverForInjectedLink } from './popover'
|
|
6
|
-
import { clearAfterArticlePlaylinks, insertAfterArticlePlaylinks } from './afterArticle'
|
|
7
|
-
import { clearInTextDisclaimer, insertInTextDisclaimer } from './disclaimer'
|
|
8
|
-
import { exploreModalUrl, participantUrl, titleUrl } from './routes'
|
|
9
|
-
import { clearInTextWidgets, insertInTextWidgets } from './inTextWidgets'
|
|
10
|
-
import { encodeHtmlEntities } from './html'
|
|
11
|
-
|
|
12
|
-
export const keyDataAttribute = 'data-playpilot-injection-key'
|
|
13
|
-
export const keySelector = `[${keyDataAttribute}]`
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Replace all found injections within all given elements on the page
|
|
17
|
-
* @returns Returns an array of injections with injections that failed to be inserted marked as `failed`.
|
|
18
|
-
*/
|
|
19
|
-
export function injectLinksInDocument(elements: HTMLElement[], injections: LinkInjectionTypes = { aiInjections: [], manualInjections: [] }): LinkInjection[] {
|
|
20
|
-
clearLinkInjections()
|
|
21
|
-
removePlayPilotTitleLinks()
|
|
22
|
-
|
|
23
|
-
const mergedInjections = mergeInjectionTypes(injections)
|
|
24
|
-
if (!mergedInjections.length) return []
|
|
25
|
-
|
|
26
|
-
// Find injection in text content of all elements together, ignore potential HTML elements.
|
|
27
|
-
// This is to filter out injections that can't be injected anyway.
|
|
28
|
-
const fullText = cleanPhrase(elements.map(element => element.innerText).join(' '))
|
|
29
|
-
|
|
30
|
-
const validInjections = filterInvalidInTextInjections(mergedInjections)
|
|
31
|
-
const foundInjections = validInjections.filter(i => fullText.includes(cleanPhrase(i.sentence)))
|
|
32
|
-
|
|
33
|
-
const failedMessages: Record<string, string> = {}
|
|
34
|
-
|
|
35
|
-
let injectionIndex = -1 // This index is used in Option 3 below.
|
|
36
|
-
for (const injection of sortInjections(foundInjections)) {
|
|
37
|
-
injectionIndex++
|
|
38
|
-
|
|
39
|
-
const elementIndex = elements.findIndex(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
|
|
40
|
-
const element = elements[elementIndex]
|
|
41
|
-
|
|
42
|
-
if (!element) continue
|
|
43
|
-
|
|
44
|
-
const nodeContainingText = findTextNodeContaining(injection.title, element, ['A'])
|
|
45
|
-
|
|
46
|
-
// Ignore if the found injection has no node or if it is inside a link.
|
|
47
|
-
if (!nodeContainingText?.nodeValue || isNodeInLink(nodeContainingText)) {
|
|
48
|
-
// We check once more where the text was found, this time without ignoring links
|
|
49
|
-
// so we can determine if the failure was due to it being in a link
|
|
50
|
-
const linkNodeContainingText = findTextNodeContaining(injection.title, element)
|
|
51
|
-
if (linkNodeContainingText && isNodeInLink(linkNodeContainingText)) {
|
|
52
|
-
failedMessages[injection.key] = 'Given text is already inside of a link.'
|
|
53
|
-
continue
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const { injectionElement, linkElement } = createLinkInjectionElement(injection)
|
|
58
|
-
|
|
59
|
-
let replacementIndex = -1
|
|
60
|
-
let hasBeenReplaced = false
|
|
61
|
-
|
|
62
|
-
// !! Option 1 - Simple replacements
|
|
63
|
-
// Check if there is only one occurance in the element, in which case the replacement is simple
|
|
64
|
-
// It's important that we check against the phrase without cleanPhrase(), as we need to check if an
|
|
65
|
-
// element might contain attributes that contain the phrase.
|
|
66
|
-
const numberOfHtmlMatches = findNumberOfMatchesInString(element.innerHTML, injection.title)
|
|
67
|
-
if (numberOfHtmlMatches === 1) replacementIndex = element.innerHTML.indexOf(injection.title)
|
|
68
|
-
|
|
69
|
-
// !! Option 2 - Replace by phrase_before and phrase_after
|
|
70
|
-
// If multiple or no occurences were found, we use the phrases before and after the injection to find
|
|
71
|
-
// the location of the correct title. This helps with multiple occurrences of the same phrase, but also
|
|
72
|
-
// with text that is broken up by html elements.
|
|
73
|
-
const { phrase_before, phrase_after } = getPhrasesSurroundingInjection(element, injection)
|
|
74
|
-
|
|
75
|
-
if (replacementIndex === -1 && (phrase_before || phrase_after)) {
|
|
76
|
-
// The before and after phrase are combined to see if the sentence contains the match exactly.
|
|
77
|
-
// This is a fairly simple comparison that will fail on special characters, html tags, or inconsistencies
|
|
78
|
-
const fullPhrase = [phrase_before, injection.title, phrase_after].filter(Boolean).join(' ')
|
|
79
|
-
|
|
80
|
-
replacementIndex = element.innerHTML.indexOf(fullPhrase)
|
|
81
|
-
|
|
82
|
-
// If we reach this point the match wasn't straight forward and we need to replace whatever is between phrase_before and phrase_after fully.
|
|
83
|
-
// We insert the html here separately from below, where it's done with replacementIndex because we need to capture all html
|
|
84
|
-
// that may have been inside of the match.
|
|
85
|
-
if (replacementIndex === -1) {
|
|
86
|
-
let match = findShortestMatchBetweenPhrases(element.innerHTML, injection.title, phrase_before || '', phrase_after || '')
|
|
87
|
-
|
|
88
|
-
if (match) {
|
|
89
|
-
// This is a crude way of checking if a match contains HTML elements
|
|
90
|
-
// If the title is directly inside of an element we discard the rest.
|
|
91
|
-
// This happens sometimes in lists that consists of nothing but titles and may contain special characters
|
|
92
|
-
if (match.match.includes('>') && match.match.includes('<')) {
|
|
93
|
-
const encodedTitle = encodeHtmlEntities(injection.title)
|
|
94
|
-
const matchInsideHTMLIndex = match.match.search(new RegExp(`>\s*${encodedTitle}\s*<`)) + 1
|
|
95
|
-
|
|
96
|
-
if (matchInsideHTMLIndex > 0) {
|
|
97
|
-
match = { match: match.match.slice(matchInsideHTMLIndex, matchInsideHTMLIndex + encodedTitle.length), index: matchInsideHTMLIndex }
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const { leadingSpaces, trailingSpaces } = getNumberOfLeadingAndTrailingSpaces(match.match)
|
|
102
|
-
|
|
103
|
-
linkElement.innerHTML = match.match.trim()
|
|
104
|
-
|
|
105
|
-
const startIndex = match.index + leadingSpaces
|
|
106
|
-
const endIndex = match.index + match.match.length - trailingSpaces
|
|
107
|
-
|
|
108
|
-
// Stop if the content already contains a link, which we identify soley by if the contain string contains a href attribute
|
|
109
|
-
if (element.innerHTML.slice(startIndex, endIndex).includes('href=')) continue
|
|
110
|
-
|
|
111
|
-
if (replaceIfSafeInjection(element.innerHTML, injection.title, element, injectionElement, startIndex, endIndex)) {
|
|
112
|
-
replacementIndex = match.index
|
|
113
|
-
hasBeenReplaced = true
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// !! Option 3 - Replace by title only, taking previous injections into account
|
|
120
|
-
// If no occurences were found previously, we check the element from start to finish only checking for the title,
|
|
121
|
-
// without considering phrase_before and phrase_after. Here we check the index of multiple different types of matches
|
|
122
|
-
// and go with the highest index between them all, assuming that is always going to be the correct phrase.
|
|
123
|
-
if (replacementIndex === -1 && nodeContainingText?.nodeValue) {
|
|
124
|
-
// Start searching for injection from either the value, the sentence, or the occurrence. This prevents injecting into
|
|
125
|
-
// text in an element earlier than the sentence started. A element might contain many sentences, after all.
|
|
126
|
-
const valueIndex = element.innerHTML.indexOf(nodeContainingText.nodeValue)
|
|
127
|
-
const sentenceIndex = element.innerHTML.indexOf(injection.sentence)
|
|
128
|
-
|
|
129
|
-
// Sentences are often broken up by HTML elements. When that happens the index of the sentence can't be found in the HTML.
|
|
130
|
-
// In that case we can attempt to search for part of the sentence instead, and hope for the best. We grab the first 20 characters
|
|
131
|
-
// of the sentence and look for that instead. This will not work if sentence is broken up within those first characters,
|
|
132
|
-
// if the injections is right at the start of the sentence, or the element contains multiple matches for that same slice.
|
|
133
|
-
// But it's something to fall back on regardless.
|
|
134
|
-
const startOfSentenceIndex = sentenceIndex === -1 ? element.innerHTML.indexOf(injection.sentence.slice(0, 20)) : -1
|
|
135
|
-
|
|
136
|
-
// Similar to the start of the sentence, we look first just the first word of the sentence. This is only relevant if the start
|
|
137
|
-
// of the sentence is broken up by HTML is less characters than is required for startOfSentenceIndex.
|
|
138
|
-
// This only works if the first word occurs only once, as otherwise we might match on the incorrect part of the sentence.
|
|
139
|
-
const firstWordOfSentence = injection.sentence.split(' ')[0]
|
|
140
|
-
const firstWordOccurrences = findNumberOfMatchesInString(element.innerHTML, firstWordOfSentence)
|
|
141
|
-
const firstWordIndex = firstWordOccurrences === 1 ? element.innerHTML.indexOf(firstWordOfSentence) : -1
|
|
142
|
-
|
|
143
|
-
// Starting from occurence happens when a sentence might include multiple occurences of the same phrase.
|
|
144
|
-
// We might still prefer the valueIndex if that is higher than this value.
|
|
145
|
-
|
|
146
|
-
// A sentence might include multiple matches for the same phrase. We get the number of previous occurences in order to get the index
|
|
147
|
-
// of the match we actually want to get.
|
|
148
|
-
const indexOfOccurrence = getNumberOfOccurrencesInArray<LinkInjection>(foundInjections.slice(0, injectionIndex + 1), injection, ['title', 'sentence']) - 1
|
|
149
|
-
const indexInSentence = getIndexOfPhraseInElement(nodeContainingText.nodeValue, element, indexOfOccurrence)
|
|
150
|
-
|
|
151
|
-
replacementIndex = Math.max(valueIndex, startOfSentenceIndex, indexInSentence, firstWordIndex, sentenceIndex, 0)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (replacementIndex === -1) continue
|
|
155
|
-
if (hasBeenReplaced) continue
|
|
156
|
-
|
|
157
|
-
if (!replaceIfSafeInjection(element.innerHTML, injection.title, element, injectionElement, replacementIndex)) {
|
|
158
|
-
// If all else fails, we try one more time with a word boundary. If it has gotten to this point it likely means an
|
|
159
|
-
// injection tried to match into another that match partially. For example in the sentence:
|
|
160
|
-
// "Some Movie: The Sequel is a follow up to Some Movie", there are two matches for "Some Movie". But because the first
|
|
161
|
-
// match contains the second, the second will also try to inject itself into the first. In this case we try and find it
|
|
162
|
-
// again using a word boundary. This will fail in cases such as "Some Movie 2 is a follow up to Some Movie" because
|
|
163
|
-
// the word boundary still the first one.
|
|
164
|
-
const wordBoundaryMatchIndex = getIndexOfPhraseInBoundary(injection.title, element.innerHTML)
|
|
165
|
-
|
|
166
|
-
if (wordBoundaryMatchIndex > -1) {
|
|
167
|
-
replaceIfSafeInjection(element.innerHTML, injection.title, element, injectionElement, wordBoundaryMatchIndex)
|
|
168
|
-
} else {
|
|
169
|
-
failedMessages[injection.key] = 'Injection would have lead to broken HTML.'
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
addLinkInjectionEventListeners(foundInjections)
|
|
175
|
-
addCSSVariablesToLinks()
|
|
176
|
-
|
|
177
|
-
const afterArticleInjections = filterInvalidAfterArticleInjections(mergedInjections)
|
|
178
|
-
if (afterArticleInjections.length) insertAfterArticlePlaylinks(elements, afterArticleInjections)
|
|
179
|
-
|
|
180
|
-
// Attempt to insert in text disclaimer if at least 1 injection is found in the article.
|
|
181
|
-
// The function itself will decide whether or not it should actually insert the component based on the config.
|
|
182
|
-
if (document.querySelector(keySelector)) insertInTextDisclaimer(elements)
|
|
183
|
-
|
|
184
|
-
insertInTextWidgets(foundInjections)
|
|
185
|
-
|
|
186
|
-
return mergedInjections.filter(injection => hasValidTypeData(injection)).map((injection, index) => {
|
|
187
|
-
const hasManualEquivalent = !injection.manual && isAvailableAsManualInjection(injection, index, mergedInjections)
|
|
188
|
-
const duplicate = injection.duplicate ?? hasManualEquivalent
|
|
189
|
-
|
|
190
|
-
if (duplicate) failedMessages[injection.key] = hasManualEquivalent ? 'Injection was manually removed.' : 'Injection was marked as duplicate.'
|
|
191
|
-
|
|
192
|
-
const matchingElement = document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
|
|
193
|
-
const failed = isValidPlaylinkType(injection) && !injection.inactive && !injection.removed && !injection.after_article && !matchingElement
|
|
194
|
-
const anyElementContainsSentence = failed && elements.some(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
|
|
195
|
-
const failedMessage =
|
|
196
|
-
!failed ? '' :
|
|
197
|
-
failedMessages[injection.key] ||
|
|
198
|
-
(!anyElementContainsSentence ? 'Given sentence was not found in the article.' : 'The link failed to inject for unknown reasons.')
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
...injection,
|
|
202
|
-
inactive: injection.inactive ?? false,
|
|
203
|
-
duplicate,
|
|
204
|
-
failed,
|
|
205
|
-
failed_message: failedMessage,
|
|
206
|
-
matchingElement,
|
|
207
|
-
}
|
|
208
|
-
})
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function createLinkInjectionElement(injection: LinkInjection): { injectionElement: HTMLSpanElement, linkElement: HTMLAnchorElement } {
|
|
212
|
-
// Create a wrapper in which the link will be placed. This wrapper exists as a parent for the popover
|
|
213
|
-
// so that it is not directly inside of the link.
|
|
214
|
-
const injectionElement = document.createElement('span')
|
|
215
|
-
injectionElement.dataset.playpilotInjectionKey = injection.key
|
|
216
|
-
|
|
217
|
-
const openInExplore = !!window.PlayPilotLinkInjections?.config?.open_tpi_links_in_explore
|
|
218
|
-
|
|
219
|
-
const sid = injection.title_details?.sid || injection.participant_details?.sid
|
|
220
|
-
const href = openInExplore ? exploreModalUrl(sid!) : (injection.type === 'title' ? titleUrl(injection.title_details!) : participantUrl(injection.participant_details!))
|
|
221
|
-
|
|
222
|
-
const linkElement = document.createElement('a')
|
|
223
|
-
linkElement.innerText = injection.title
|
|
224
|
-
linkElement.href = href
|
|
225
|
-
linkElement.target = openInExplore ? '' : '_blank'
|
|
226
|
-
linkElement.rel = 'noopener nofollow noreferrer'
|
|
227
|
-
|
|
228
|
-
if (injection.type === 'title') linkElement.dataset.playpilotPosterUrl = injection.title_details?.standing_poster
|
|
229
|
-
|
|
230
|
-
injectionElement.insertAdjacentElement('beforeend', linkElement)
|
|
231
|
-
|
|
232
|
-
return { injectionElement, linkElement }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* In some coses injections lead to broken HTML. The reason for this varies and is hard to figure out. In any case, we
|
|
237
|
-
* should never serve broken HTML.
|
|
238
|
-
* Before replacing the HTML, we apply the the replacement to a dummy element. From here we check if the length of the text has
|
|
239
|
-
* increased. If it did, something went wrong. What exactly is hard to say, we just know something didn't go as intended and
|
|
240
|
-
* we should not inject.
|
|
241
|
-
*
|
|
242
|
-
* No tests exists for this function because writing tests would require finding a scenario in which things break. If we knew
|
|
243
|
-
* when things break we'd fix it instead.
|
|
244
|
-
*/
|
|
245
|
-
function replaceIfSafeInjection(originalHtml: string, phrase: string, sentenceElement: HTMLElement, injectionElement: HTMLElement, replacementIndex: number, endIndex: number = -1): boolean {
|
|
246
|
-
const getNumberOfEmptyLinksAndInjections = (element: HTMLElement): number => Array.from(element.querySelectorAll<HTMLElement>(`a, ${keySelector}`)).filter(a => !a.innerText).length
|
|
247
|
-
|
|
248
|
-
const dummyElement = document.createElement('div')
|
|
249
|
-
dummyElement.innerHTML = originalHtml
|
|
250
|
-
|
|
251
|
-
const originalNumberOfEmptyLinks = getNumberOfEmptyLinksAndInjections(dummyElement)
|
|
252
|
-
const originalText = dummyElement.innerText
|
|
253
|
-
|
|
254
|
-
// If an endIndex is given we can replace using replaceBetween. This is the case when injections come from using `phrase_before` and `phrase_after`.
|
|
255
|
-
if (endIndex > 0) {
|
|
256
|
-
dummyElement.innerHTML = replaceBetween(originalHtml, injectionElement.outerHTML, replacementIndex, endIndex)
|
|
257
|
-
} else {
|
|
258
|
-
dummyElement.innerHTML = replaceStartingFrom(originalHtml, phrase, injectionElement.outerHTML, replacementIndex)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// If the text has changed at all, something probably went wrong as the new text is supposed to be the same as the old.
|
|
262
|
-
if (Math.abs(dummyElement.innerText.length - originalText.length) > 1) return false
|
|
263
|
-
// One of our links pushed out an existing link on the page. When this happens the original link is emptied (because a link inserted into a link, and that is invalid)
|
|
264
|
-
// Not sure of the exact circumstances this might happen in, but it has happened on DigitalSpy.
|
|
265
|
-
// If the parent key contains no text something broke. This can happen when a link tries to injection partially into another link, leaving the closing link tag dangling.
|
|
266
|
-
if (originalNumberOfEmptyLinks != getNumberOfEmptyLinksAndInjections(dummyElement)) return false
|
|
267
|
-
|
|
268
|
-
sentenceElement.innerHTML = dummyElement.innerHTML
|
|
269
|
-
|
|
270
|
-
return true
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* phrase_before and phrase_after are set for manual injections to better get their placement within a sentence.
|
|
275
|
-
* This helps when an injection contains multiple injections of the same word and it also helps when an injection
|
|
276
|
-
* is broken up by html elements. For instance, DigitalSpy sometimes uses multiple styling tags on the same phrase.
|
|
277
|
-
* Something like `<strong>phr</strong><strong>ase</strong.
|
|
278
|
-
*
|
|
279
|
-
* Secondary to manual injections we can also get the phrase_before and phrase_after for ai injections.
|
|
280
|
-
* This only works if the injection occurs only once. In this case it is only used when the phrase is broken up
|
|
281
|
-
* by html elements, like before.
|
|
282
|
-
*/
|
|
283
|
-
function getPhrasesSurroundingInjection(element: HTMLElement, injection: LinkInjection): { phrase_before: string | null | undefined, phrase_after: string | null | undefined } {
|
|
284
|
-
const { phrase_before, phrase_after } = injection
|
|
285
|
-
|
|
286
|
-
if (phrase_before || phrase_after) return { phrase_before, phrase_after }
|
|
287
|
-
|
|
288
|
-
// Get the number of occurrences of the same phrase in the element. We can only use the before and after
|
|
289
|
-
// phrases if there is only 1 occurrence, as we can't guarentee we injection into the expected phrase otherwise.
|
|
290
|
-
const numberOfTextMatches = findNumberOfMatchesInString(element.innerText, injection.title)
|
|
291
|
-
|
|
292
|
-
if (numberOfTextMatches !== 1) return { phrase_before: null, phrase_after: null }
|
|
293
|
-
|
|
294
|
-
const startIndex = element.innerText.indexOf(injection.title)
|
|
295
|
-
const surroundingPhrases = findSurroundingPhrases(element, startIndex, startIndex + injection.title.length)
|
|
296
|
-
|
|
297
|
-
return { phrase_before: surroundingPhrases.before, phrase_after: surroundingPhrases.after }
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Add all used CSS variables to a data attribute. This data attribute is then used for selectors that for each
|
|
302
|
-
* individual CSS variable. This is done this way so that CSS variables are only set when they are used.
|
|
303
|
-
* Using the variables straight up or with a fallback value would not allow them to use their default page styling.
|
|
304
|
-
*/
|
|
305
|
-
function addCSSVariablesToLinks(): void {
|
|
306
|
-
const createdLinkElements = Array.from(document.querySelectorAll(`${keySelector} a`)) as HTMLElement[]
|
|
307
|
-
|
|
308
|
-
const variables = [
|
|
309
|
-
'--playpilot-injection-text-color',
|
|
310
|
-
'--playpilot-injection-text-color-hover',
|
|
311
|
-
'--playpilot-injection-font-weight',
|
|
312
|
-
'--playpilot-injection-font-weight-headings',
|
|
313
|
-
'--playpilot-injection-text-decoration',
|
|
314
|
-
'--playpilot-injection-text-decoration-hover',
|
|
315
|
-
'--playpilot-injection-background-color',
|
|
316
|
-
'--playpilot-injection-background-color-hover',
|
|
317
|
-
]
|
|
318
|
-
|
|
319
|
-
for (const element of createdLinkElements) {
|
|
320
|
-
const style = getComputedStyle(element)
|
|
321
|
-
|
|
322
|
-
for (const value of variables) {
|
|
323
|
-
if (!style.getPropertyValue(value)) continue
|
|
324
|
-
|
|
325
|
-
element.dataset.usedCssVariables = `${element.dataset.usedCssVariables || ''} ${value}`
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
|
|
331
|
-
window.addEventListener('mousemove', destroyLinkPopoverOnMouseleave)
|
|
332
|
-
|
|
333
|
-
window.addEventListener('click', (event) => {
|
|
334
|
-
if (window.PlayPilotLinkInjections?.config?.open_tpi_links_in_explore) return
|
|
335
|
-
|
|
336
|
-
openModalForInjectedLink(event, injections)
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
const createdInjectionElements = document.querySelectorAll<HTMLElement>(keySelector)
|
|
340
|
-
|
|
341
|
-
// Open and close popover on mouseenter/mouseleave
|
|
342
|
-
createdInjectionElements.forEach((injectionElement) => {
|
|
343
|
-
const key = injectionElement.dataset.playpilotInjectionKey
|
|
344
|
-
const injection = injections.find(injection => key === injection.key)
|
|
345
|
-
|
|
346
|
-
if (!injection) return
|
|
347
|
-
|
|
348
|
-
injectionElement.addEventListener('mouseenter', (event) => {
|
|
349
|
-
if (!isPopoverActive()) openPopoverForInjectedLink(event, injection)
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
injectionElement.addEventListener('mouseleave', clearCurrentlyHoveredInjection)
|
|
353
|
-
})
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
export function clearLinkInjections(): void {
|
|
357
|
-
const elements = document.querySelectorAll(keySelector)
|
|
358
|
-
|
|
359
|
-
elements.forEach((element) => clearLinkInjection(element.getAttribute(keyDataAttribute) || ''))
|
|
360
|
-
|
|
361
|
-
clearAfterArticlePlaylinks()
|
|
362
|
-
clearInTextDisclaimer()
|
|
363
|
-
clearInTextWidgets()
|
|
364
|
-
|
|
365
|
-
if (!elements.length) return
|
|
366
|
-
|
|
367
|
-
destroyAllModals(false)
|
|
368
|
-
destroyLinkPopover(false)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
export function clearLinkInjection(key: string): void {
|
|
372
|
-
const element: HTMLAnchorElement | null = document.querySelector(`[${keyDataAttribute}="${key}"]`)
|
|
373
|
-
if (!element) return
|
|
374
|
-
|
|
375
|
-
const playpilotElements = element.querySelectorAll('[data-playpilot-element]')
|
|
376
|
-
playpilotElements.forEach(element => element.remove())
|
|
377
|
-
|
|
378
|
-
const linkContent = element.querySelector('a')?.innerHTML
|
|
379
|
-
element.outerHTML = linkContent || ''
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* DigitalSpy tends to copy paste existing links via their CMS. They copy over injected links into new articles.
|
|
384
|
-
* Injections won't run on those links because they already are links.
|
|
385
|
-
* As a simple fix, we clear all title links to PlayPilot pages before injecting.
|
|
386
|
-
*/
|
|
387
|
-
export function removePlayPilotTitleLinks(): void {
|
|
388
|
-
const playPilotLinks = document.querySelectorAll<HTMLAnchorElement>('a[href*="playpilot.com"]')
|
|
389
|
-
|
|
390
|
-
playPilotLinks.forEach(link => {
|
|
391
|
-
if (link.closest(keySelector)) return
|
|
392
|
-
if (!(/\/movie|show\//).test(link.href)) return
|
|
393
|
-
|
|
394
|
-
link.outerHTML = link.innerHTML
|
|
395
|
-
})
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
export function mergeInjectionTypes({ aiInjections, manualInjections }: LinkInjectionTypes): LinkInjection[] {
|
|
399
|
-
return [...manualInjections.map(i => ({ ...i, manual: true })), ...aiInjections]
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
export function separateLinkInjectionTypes(injections: LinkInjection[]): LinkInjectionTypes {
|
|
403
|
-
return {
|
|
404
|
-
aiInjections: injections.filter(i => !i.manual),
|
|
405
|
-
manualInjections: injections.filter(i => i.manual),
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export function isValidInjection(injection: LinkInjection): boolean {
|
|
410
|
-
return !injection.inactive && !injection.removed && !injection.duplicate && hasValidTypeData(injection) && isValidPlaylinkType(injection)
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* An injection can be of various playlink types, when all are false equivalent, the link is not valid.
|
|
415
|
-
* It should be treated similar to an inactive playlink in this case.
|
|
416
|
-
*/
|
|
417
|
-
export function isValidPlaylinkType(injection: LinkInjection): boolean {
|
|
418
|
-
if (injection.in_text || injection.in_text === undefined) return true
|
|
419
|
-
return !!injection.after_article
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export function filterInvalidInTextInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
423
|
-
return filterRemovedAndInactiveInjections(injections).filter(i => i.in_text !== false && isValidInjection(i))
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
export function filterInvalidAfterArticleInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
427
|
-
return filterRemovedAndInactiveInjections(injections).filter(i => i.after_article === true && isValidInjection(i))
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Filter injections that were marked as removed or inactive or have an equivalent removed or inactive manual injections, soley based on the same sentence and title.
|
|
432
|
-
*/
|
|
433
|
-
export function filterRemovedAndInactiveInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
434
|
-
return injections.filter(injection => {
|
|
435
|
-
if (injection.removed || injection.inactive) return false
|
|
436
|
-
if (injection.manual && (!injection.removed && !injection.inactive)) return true
|
|
437
|
-
|
|
438
|
-
return !injections.some(i => i.manual && (i.removed || i.inactive) && isEquivalentInjection(i, injection))
|
|
439
|
-
})
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Injections are sorted first by their sentence and second by their title length. The first sorting, sentence, is so that injections remain
|
|
444
|
-
* roughly grouped. The second sorting is to prevent shorter titles from merging into longer ones. For instance;
|
|
445
|
-
* "Shrek 2 is the sequel to Shrek". If "Shrek" appears as an injection before Shrek 2, it will incorrect inject into "Shrek 2".
|
|
446
|
-
* By placing the longer titles first, we prevent that.
|
|
447
|
-
*/
|
|
448
|
-
export function sortInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
449
|
-
return injections.slice().sort((a, b) => {
|
|
450
|
-
if (a.sentence < b.sentence) return -1
|
|
451
|
-
if (a.sentence > b.sentence) return 1
|
|
452
|
-
|
|
453
|
-
const difference = b.title.length - a.title.length
|
|
454
|
-
if (difference !== 0) return difference
|
|
455
|
-
|
|
456
|
-
return 0
|
|
457
|
-
})
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
export function isAvailableAsManualInjection(injection: LinkInjection, injectionIndex: number, injections: LinkInjection[]): boolean {
|
|
461
|
-
return injections.some((i, index) => {
|
|
462
|
-
return injectionIndex !== index && i.manual && isEquivalentInjection(i, injection)
|
|
463
|
-
})
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
export function isEquivalentInjection(injection1: LinkInjection, injection2: LinkInjection): boolean {
|
|
467
|
-
return injection1.title === injection2.title && cleanPhrase(injection1.sentence) === cleanPhrase(injection2.sentence)
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
export function hasValidTypeData(injection: LinkInjection): boolean {
|
|
471
|
-
if (injection.type === 'title' && !!injection.title_details) return true
|
|
472
|
-
if (injection.type === 'participant' && !!injection.participant_details) return true
|
|
473
|
-
|
|
474
|
-
return false
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
export function getPlayPilotWrapperElement(): Element {
|
|
478
|
-
return document.querySelector('[data-playpilot-link-injections]') || document.body
|
|
479
|
-
}
|
|
1
|
+
import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getIndexOfPhraseInElement, getIndexOfPhraseInBoundary, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom, findSurroundingPhrases } from './text'
|
|
2
|
+
import type { LinkInjection, LinkInjectionTypes } from './types/injection'
|
|
3
|
+
import { getNumberOfOccurrencesInArray } from './array'
|
|
4
|
+
import { destroyAllModals, openModalForInjectedLink } from './modal'
|
|
5
|
+
import { clearCurrentlyHoveredInjection, destroyLinkPopover, destroyLinkPopoverOnMouseleave, isPopoverActive, openPopoverForInjectedLink } from './popover'
|
|
6
|
+
import { clearAfterArticlePlaylinks, insertAfterArticlePlaylinks } from './afterArticle'
|
|
7
|
+
import { clearInTextDisclaimer, insertInTextDisclaimer } from './disclaimer'
|
|
8
|
+
import { exploreModalUrl, participantUrl, titleUrl } from './routes'
|
|
9
|
+
import { clearInTextWidgets, insertInTextWidgets } from './inTextWidgets'
|
|
10
|
+
import { encodeHtmlEntities } from './html'
|
|
11
|
+
|
|
12
|
+
export const keyDataAttribute = 'data-playpilot-injection-key'
|
|
13
|
+
export const keySelector = `[${keyDataAttribute}]`
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Replace all found injections within all given elements on the page
|
|
17
|
+
* @returns Returns an array of injections with injections that failed to be inserted marked as `failed`.
|
|
18
|
+
*/
|
|
19
|
+
export function injectLinksInDocument(elements: HTMLElement[], injections: LinkInjectionTypes = { aiInjections: [], manualInjections: [] }): LinkInjection[] {
|
|
20
|
+
clearLinkInjections()
|
|
21
|
+
removePlayPilotTitleLinks()
|
|
22
|
+
|
|
23
|
+
const mergedInjections = mergeInjectionTypes(injections)
|
|
24
|
+
if (!mergedInjections.length) return []
|
|
25
|
+
|
|
26
|
+
// Find injection in text content of all elements together, ignore potential HTML elements.
|
|
27
|
+
// This is to filter out injections that can't be injected anyway.
|
|
28
|
+
const fullText = cleanPhrase(elements.map(element => element.innerText).join(' '))
|
|
29
|
+
|
|
30
|
+
const validInjections = filterInvalidInTextInjections(mergedInjections)
|
|
31
|
+
const foundInjections = validInjections.filter(i => fullText.includes(cleanPhrase(i.sentence)))
|
|
32
|
+
|
|
33
|
+
const failedMessages: Record<string, string> = {}
|
|
34
|
+
|
|
35
|
+
let injectionIndex = -1 // This index is used in Option 3 below.
|
|
36
|
+
for (const injection of sortInjections(foundInjections)) {
|
|
37
|
+
injectionIndex++
|
|
38
|
+
|
|
39
|
+
const elementIndex = elements.findIndex(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
|
|
40
|
+
const element = elements[elementIndex]
|
|
41
|
+
|
|
42
|
+
if (!element) continue
|
|
43
|
+
|
|
44
|
+
const nodeContainingText = findTextNodeContaining(injection.title, element, ['A'])
|
|
45
|
+
|
|
46
|
+
// Ignore if the found injection has no node or if it is inside a link.
|
|
47
|
+
if (!nodeContainingText?.nodeValue || isNodeInLink(nodeContainingText)) {
|
|
48
|
+
// We check once more where the text was found, this time without ignoring links
|
|
49
|
+
// so we can determine if the failure was due to it being in a link
|
|
50
|
+
const linkNodeContainingText = findTextNodeContaining(injection.title, element)
|
|
51
|
+
if (linkNodeContainingText && isNodeInLink(linkNodeContainingText)) {
|
|
52
|
+
failedMessages[injection.key] = 'Given text is already inside of a link.'
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { injectionElement, linkElement } = createLinkInjectionElement(injection)
|
|
58
|
+
|
|
59
|
+
let replacementIndex = -1
|
|
60
|
+
let hasBeenReplaced = false
|
|
61
|
+
|
|
62
|
+
// !! Option 1 - Simple replacements
|
|
63
|
+
// Check if there is only one occurance in the element, in which case the replacement is simple
|
|
64
|
+
// It's important that we check against the phrase without cleanPhrase(), as we need to check if an
|
|
65
|
+
// element might contain attributes that contain the phrase.
|
|
66
|
+
const numberOfHtmlMatches = findNumberOfMatchesInString(element.innerHTML, injection.title)
|
|
67
|
+
if (numberOfHtmlMatches === 1) replacementIndex = element.innerHTML.indexOf(injection.title)
|
|
68
|
+
|
|
69
|
+
// !! Option 2 - Replace by phrase_before and phrase_after
|
|
70
|
+
// If multiple or no occurences were found, we use the phrases before and after the injection to find
|
|
71
|
+
// the location of the correct title. This helps with multiple occurrences of the same phrase, but also
|
|
72
|
+
// with text that is broken up by html elements.
|
|
73
|
+
const { phrase_before, phrase_after } = getPhrasesSurroundingInjection(element, injection)
|
|
74
|
+
|
|
75
|
+
if (replacementIndex === -1 && (phrase_before || phrase_after)) {
|
|
76
|
+
// The before and after phrase are combined to see if the sentence contains the match exactly.
|
|
77
|
+
// This is a fairly simple comparison that will fail on special characters, html tags, or inconsistencies
|
|
78
|
+
const fullPhrase = [phrase_before, injection.title, phrase_after].filter(Boolean).join(' ')
|
|
79
|
+
|
|
80
|
+
replacementIndex = element.innerHTML.indexOf(fullPhrase)
|
|
81
|
+
|
|
82
|
+
// If we reach this point the match wasn't straight forward and we need to replace whatever is between phrase_before and phrase_after fully.
|
|
83
|
+
// We insert the html here separately from below, where it's done with replacementIndex because we need to capture all html
|
|
84
|
+
// that may have been inside of the match.
|
|
85
|
+
if (replacementIndex === -1) {
|
|
86
|
+
let match = findShortestMatchBetweenPhrases(element.innerHTML, injection.title, phrase_before || '', phrase_after || '')
|
|
87
|
+
|
|
88
|
+
if (match) {
|
|
89
|
+
// This is a crude way of checking if a match contains HTML elements
|
|
90
|
+
// If the title is directly inside of an element we discard the rest.
|
|
91
|
+
// This happens sometimes in lists that consists of nothing but titles and may contain special characters
|
|
92
|
+
if (match.match.includes('>') && match.match.includes('<')) {
|
|
93
|
+
const encodedTitle = encodeHtmlEntities(injection.title)
|
|
94
|
+
const matchInsideHTMLIndex = match.match.search(new RegExp(`>\s*${encodedTitle}\s*<`)) + 1
|
|
95
|
+
|
|
96
|
+
if (matchInsideHTMLIndex > 0) {
|
|
97
|
+
match = { match: match.match.slice(matchInsideHTMLIndex, matchInsideHTMLIndex + encodedTitle.length), index: matchInsideHTMLIndex }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { leadingSpaces, trailingSpaces } = getNumberOfLeadingAndTrailingSpaces(match.match)
|
|
102
|
+
|
|
103
|
+
linkElement.innerHTML = match.match.trim()
|
|
104
|
+
|
|
105
|
+
const startIndex = match.index + leadingSpaces
|
|
106
|
+
const endIndex = match.index + match.match.length - trailingSpaces
|
|
107
|
+
|
|
108
|
+
// Stop if the content already contains a link, which we identify soley by if the contain string contains a href attribute
|
|
109
|
+
if (element.innerHTML.slice(startIndex, endIndex).includes('href=')) continue
|
|
110
|
+
|
|
111
|
+
if (replaceIfSafeInjection(element.innerHTML, injection.title, element, injectionElement, startIndex, endIndex)) {
|
|
112
|
+
replacementIndex = match.index
|
|
113
|
+
hasBeenReplaced = true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// !! Option 3 - Replace by title only, taking previous injections into account
|
|
120
|
+
// If no occurences were found previously, we check the element from start to finish only checking for the title,
|
|
121
|
+
// without considering phrase_before and phrase_after. Here we check the index of multiple different types of matches
|
|
122
|
+
// and go with the highest index between them all, assuming that is always going to be the correct phrase.
|
|
123
|
+
if (replacementIndex === -1 && nodeContainingText?.nodeValue) {
|
|
124
|
+
// Start searching for injection from either the value, the sentence, or the occurrence. This prevents injecting into
|
|
125
|
+
// text in an element earlier than the sentence started. A element might contain many sentences, after all.
|
|
126
|
+
const valueIndex = element.innerHTML.indexOf(nodeContainingText.nodeValue)
|
|
127
|
+
const sentenceIndex = element.innerHTML.indexOf(injection.sentence)
|
|
128
|
+
|
|
129
|
+
// Sentences are often broken up by HTML elements. When that happens the index of the sentence can't be found in the HTML.
|
|
130
|
+
// In that case we can attempt to search for part of the sentence instead, and hope for the best. We grab the first 20 characters
|
|
131
|
+
// of the sentence and look for that instead. This will not work if sentence is broken up within those first characters,
|
|
132
|
+
// if the injections is right at the start of the sentence, or the element contains multiple matches for that same slice.
|
|
133
|
+
// But it's something to fall back on regardless.
|
|
134
|
+
const startOfSentenceIndex = sentenceIndex === -1 ? element.innerHTML.indexOf(injection.sentence.slice(0, 20)) : -1
|
|
135
|
+
|
|
136
|
+
// Similar to the start of the sentence, we look first just the first word of the sentence. This is only relevant if the start
|
|
137
|
+
// of the sentence is broken up by HTML is less characters than is required for startOfSentenceIndex.
|
|
138
|
+
// This only works if the first word occurs only once, as otherwise we might match on the incorrect part of the sentence.
|
|
139
|
+
const firstWordOfSentence = injection.sentence.split(' ')[0]
|
|
140
|
+
const firstWordOccurrences = findNumberOfMatchesInString(element.innerHTML, firstWordOfSentence)
|
|
141
|
+
const firstWordIndex = firstWordOccurrences === 1 ? element.innerHTML.indexOf(firstWordOfSentence) : -1
|
|
142
|
+
|
|
143
|
+
// Starting from occurence happens when a sentence might include multiple occurences of the same phrase.
|
|
144
|
+
// We might still prefer the valueIndex if that is higher than this value.
|
|
145
|
+
|
|
146
|
+
// A sentence might include multiple matches for the same phrase. We get the number of previous occurences in order to get the index
|
|
147
|
+
// of the match we actually want to get.
|
|
148
|
+
const indexOfOccurrence = getNumberOfOccurrencesInArray<LinkInjection>(foundInjections.slice(0, injectionIndex + 1), injection, ['title', 'sentence']) - 1
|
|
149
|
+
const indexInSentence = getIndexOfPhraseInElement(nodeContainingText.nodeValue, element, indexOfOccurrence)
|
|
150
|
+
|
|
151
|
+
replacementIndex = Math.max(valueIndex, startOfSentenceIndex, indexInSentence, firstWordIndex, sentenceIndex, 0)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (replacementIndex === -1) continue
|
|
155
|
+
if (hasBeenReplaced) continue
|
|
156
|
+
|
|
157
|
+
if (!replaceIfSafeInjection(element.innerHTML, injection.title, element, injectionElement, replacementIndex)) {
|
|
158
|
+
// If all else fails, we try one more time with a word boundary. If it has gotten to this point it likely means an
|
|
159
|
+
// injection tried to match into another that match partially. For example in the sentence:
|
|
160
|
+
// "Some Movie: The Sequel is a follow up to Some Movie", there are two matches for "Some Movie". But because the first
|
|
161
|
+
// match contains the second, the second will also try to inject itself into the first. In this case we try and find it
|
|
162
|
+
// again using a word boundary. This will fail in cases such as "Some Movie 2 is a follow up to Some Movie" because
|
|
163
|
+
// the word boundary still the first one.
|
|
164
|
+
const wordBoundaryMatchIndex = getIndexOfPhraseInBoundary(injection.title, element.innerHTML)
|
|
165
|
+
|
|
166
|
+
if (wordBoundaryMatchIndex > -1) {
|
|
167
|
+
replaceIfSafeInjection(element.innerHTML, injection.title, element, injectionElement, wordBoundaryMatchIndex)
|
|
168
|
+
} else {
|
|
169
|
+
failedMessages[injection.key] = 'Injection would have lead to broken HTML.'
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
addLinkInjectionEventListeners(foundInjections)
|
|
175
|
+
addCSSVariablesToLinks()
|
|
176
|
+
|
|
177
|
+
const afterArticleInjections = filterInvalidAfterArticleInjections(mergedInjections)
|
|
178
|
+
if (afterArticleInjections.length) insertAfterArticlePlaylinks(elements, afterArticleInjections)
|
|
179
|
+
|
|
180
|
+
// Attempt to insert in text disclaimer if at least 1 injection is found in the article.
|
|
181
|
+
// The function itself will decide whether or not it should actually insert the component based on the config.
|
|
182
|
+
if (document.querySelector(keySelector)) insertInTextDisclaimer(elements)
|
|
183
|
+
|
|
184
|
+
insertInTextWidgets(foundInjections)
|
|
185
|
+
|
|
186
|
+
return mergedInjections.filter(injection => hasValidTypeData(injection)).map((injection, index) => {
|
|
187
|
+
const hasManualEquivalent = !injection.manual && isAvailableAsManualInjection(injection, index, mergedInjections)
|
|
188
|
+
const duplicate = injection.duplicate ?? hasManualEquivalent
|
|
189
|
+
|
|
190
|
+
if (duplicate) failedMessages[injection.key] = hasManualEquivalent ? 'Injection was manually removed.' : 'Injection was marked as duplicate.'
|
|
191
|
+
|
|
192
|
+
const matchingElement = document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
|
|
193
|
+
const failed = isValidPlaylinkType(injection) && !injection.inactive && !injection.removed && !injection.after_article && !matchingElement
|
|
194
|
+
const anyElementContainsSentence = failed && elements.some(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
|
|
195
|
+
const failedMessage =
|
|
196
|
+
!failed ? '' :
|
|
197
|
+
failedMessages[injection.key] ||
|
|
198
|
+
(!anyElementContainsSentence ? 'Given sentence was not found in the article.' : 'The link failed to inject for unknown reasons.')
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...injection,
|
|
202
|
+
inactive: injection.inactive ?? false,
|
|
203
|
+
duplicate,
|
|
204
|
+
failed,
|
|
205
|
+
failed_message: failedMessage,
|
|
206
|
+
matchingElement,
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function createLinkInjectionElement(injection: LinkInjection): { injectionElement: HTMLSpanElement, linkElement: HTMLAnchorElement } {
|
|
212
|
+
// Create a wrapper in which the link will be placed. This wrapper exists as a parent for the popover
|
|
213
|
+
// so that it is not directly inside of the link.
|
|
214
|
+
const injectionElement = document.createElement('span')
|
|
215
|
+
injectionElement.dataset.playpilotInjectionKey = injection.key
|
|
216
|
+
|
|
217
|
+
const openInExplore = !!window.PlayPilotLinkInjections?.config?.open_tpi_links_in_explore
|
|
218
|
+
|
|
219
|
+
const sid = injection.title_details?.sid || injection.participant_details?.sid
|
|
220
|
+
const href = openInExplore ? exploreModalUrl(sid!) : (injection.type === 'title' ? titleUrl(injection.title_details!) : participantUrl(injection.participant_details!))
|
|
221
|
+
|
|
222
|
+
const linkElement = document.createElement('a')
|
|
223
|
+
linkElement.innerText = injection.title
|
|
224
|
+
linkElement.href = href
|
|
225
|
+
linkElement.target = openInExplore ? '' : '_blank'
|
|
226
|
+
linkElement.rel = 'noopener nofollow noreferrer'
|
|
227
|
+
|
|
228
|
+
if (injection.type === 'title') linkElement.dataset.playpilotPosterUrl = injection.title_details?.standing_poster
|
|
229
|
+
|
|
230
|
+
injectionElement.insertAdjacentElement('beforeend', linkElement)
|
|
231
|
+
|
|
232
|
+
return { injectionElement, linkElement }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* In some coses injections lead to broken HTML. The reason for this varies and is hard to figure out. In any case, we
|
|
237
|
+
* should never serve broken HTML.
|
|
238
|
+
* Before replacing the HTML, we apply the the replacement to a dummy element. From here we check if the length of the text has
|
|
239
|
+
* increased. If it did, something went wrong. What exactly is hard to say, we just know something didn't go as intended and
|
|
240
|
+
* we should not inject.
|
|
241
|
+
*
|
|
242
|
+
* No tests exists for this function because writing tests would require finding a scenario in which things break. If we knew
|
|
243
|
+
* when things break we'd fix it instead.
|
|
244
|
+
*/
|
|
245
|
+
function replaceIfSafeInjection(originalHtml: string, phrase: string, sentenceElement: HTMLElement, injectionElement: HTMLElement, replacementIndex: number, endIndex: number = -1): boolean {
|
|
246
|
+
const getNumberOfEmptyLinksAndInjections = (element: HTMLElement): number => Array.from(element.querySelectorAll<HTMLElement>(`a, ${keySelector}`)).filter(a => !a.innerText).length
|
|
247
|
+
|
|
248
|
+
const dummyElement = document.createElement('div')
|
|
249
|
+
dummyElement.innerHTML = originalHtml
|
|
250
|
+
|
|
251
|
+
const originalNumberOfEmptyLinks = getNumberOfEmptyLinksAndInjections(dummyElement)
|
|
252
|
+
const originalText = dummyElement.innerText
|
|
253
|
+
|
|
254
|
+
// If an endIndex is given we can replace using replaceBetween. This is the case when injections come from using `phrase_before` and `phrase_after`.
|
|
255
|
+
if (endIndex > 0) {
|
|
256
|
+
dummyElement.innerHTML = replaceBetween(originalHtml, injectionElement.outerHTML, replacementIndex, endIndex)
|
|
257
|
+
} else {
|
|
258
|
+
dummyElement.innerHTML = replaceStartingFrom(originalHtml, phrase, injectionElement.outerHTML, replacementIndex)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// If the text has changed at all, something probably went wrong as the new text is supposed to be the same as the old.
|
|
262
|
+
if (Math.abs(dummyElement.innerText.length - originalText.length) > 1) return false
|
|
263
|
+
// One of our links pushed out an existing link on the page. When this happens the original link is emptied (because a link inserted into a link, and that is invalid)
|
|
264
|
+
// Not sure of the exact circumstances this might happen in, but it has happened on DigitalSpy.
|
|
265
|
+
// If the parent key contains no text something broke. This can happen when a link tries to injection partially into another link, leaving the closing link tag dangling.
|
|
266
|
+
if (originalNumberOfEmptyLinks != getNumberOfEmptyLinksAndInjections(dummyElement)) return false
|
|
267
|
+
|
|
268
|
+
sentenceElement.innerHTML = dummyElement.innerHTML
|
|
269
|
+
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* phrase_before and phrase_after are set for manual injections to better get their placement within a sentence.
|
|
275
|
+
* This helps when an injection contains multiple injections of the same word and it also helps when an injection
|
|
276
|
+
* is broken up by html elements. For instance, DigitalSpy sometimes uses multiple styling tags on the same phrase.
|
|
277
|
+
* Something like `<strong>phr</strong><strong>ase</strong.
|
|
278
|
+
*
|
|
279
|
+
* Secondary to manual injections we can also get the phrase_before and phrase_after for ai injections.
|
|
280
|
+
* This only works if the injection occurs only once. In this case it is only used when the phrase is broken up
|
|
281
|
+
* by html elements, like before.
|
|
282
|
+
*/
|
|
283
|
+
function getPhrasesSurroundingInjection(element: HTMLElement, injection: LinkInjection): { phrase_before: string | null | undefined, phrase_after: string | null | undefined } {
|
|
284
|
+
const { phrase_before, phrase_after } = injection
|
|
285
|
+
|
|
286
|
+
if (phrase_before || phrase_after) return { phrase_before, phrase_after }
|
|
287
|
+
|
|
288
|
+
// Get the number of occurrences of the same phrase in the element. We can only use the before and after
|
|
289
|
+
// phrases if there is only 1 occurrence, as we can't guarentee we injection into the expected phrase otherwise.
|
|
290
|
+
const numberOfTextMatches = findNumberOfMatchesInString(element.innerText, injection.title)
|
|
291
|
+
|
|
292
|
+
if (numberOfTextMatches !== 1) return { phrase_before: null, phrase_after: null }
|
|
293
|
+
|
|
294
|
+
const startIndex = element.innerText.indexOf(injection.title)
|
|
295
|
+
const surroundingPhrases = findSurroundingPhrases(element, startIndex, startIndex + injection.title.length)
|
|
296
|
+
|
|
297
|
+
return { phrase_before: surroundingPhrases.before, phrase_after: surroundingPhrases.after }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Add all used CSS variables to a data attribute. This data attribute is then used for selectors that for each
|
|
302
|
+
* individual CSS variable. This is done this way so that CSS variables are only set when they are used.
|
|
303
|
+
* Using the variables straight up or with a fallback value would not allow them to use their default page styling.
|
|
304
|
+
*/
|
|
305
|
+
function addCSSVariablesToLinks(): void {
|
|
306
|
+
const createdLinkElements = Array.from(document.querySelectorAll(`${keySelector} a`)) as HTMLElement[]
|
|
307
|
+
|
|
308
|
+
const variables = [
|
|
309
|
+
'--playpilot-injection-text-color',
|
|
310
|
+
'--playpilot-injection-text-color-hover',
|
|
311
|
+
'--playpilot-injection-font-weight',
|
|
312
|
+
'--playpilot-injection-font-weight-headings',
|
|
313
|
+
'--playpilot-injection-text-decoration',
|
|
314
|
+
'--playpilot-injection-text-decoration-hover',
|
|
315
|
+
'--playpilot-injection-background-color',
|
|
316
|
+
'--playpilot-injection-background-color-hover',
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
for (const element of createdLinkElements) {
|
|
320
|
+
const style = getComputedStyle(element)
|
|
321
|
+
|
|
322
|
+
for (const value of variables) {
|
|
323
|
+
if (!style.getPropertyValue(value)) continue
|
|
324
|
+
|
|
325
|
+
element.dataset.usedCssVariables = `${element.dataset.usedCssVariables || ''} ${value}`
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function addLinkInjectionEventListeners(injections: LinkInjection[]): void {
|
|
331
|
+
window.addEventListener('mousemove', destroyLinkPopoverOnMouseleave)
|
|
332
|
+
|
|
333
|
+
window.addEventListener('click', (event) => {
|
|
334
|
+
if (window.PlayPilotLinkInjections?.config?.open_tpi_links_in_explore) return
|
|
335
|
+
|
|
336
|
+
openModalForInjectedLink(event, injections)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const createdInjectionElements = document.querySelectorAll<HTMLElement>(keySelector)
|
|
340
|
+
|
|
341
|
+
// Open and close popover on mouseenter/mouseleave
|
|
342
|
+
createdInjectionElements.forEach((injectionElement) => {
|
|
343
|
+
const key = injectionElement.dataset.playpilotInjectionKey
|
|
344
|
+
const injection = injections.find(injection => key === injection.key)
|
|
345
|
+
|
|
346
|
+
if (!injection) return
|
|
347
|
+
|
|
348
|
+
injectionElement.addEventListener('mouseenter', (event) => {
|
|
349
|
+
if (!isPopoverActive()) openPopoverForInjectedLink(event, injection)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
injectionElement.addEventListener('mouseleave', clearCurrentlyHoveredInjection)
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function clearLinkInjections(): void {
|
|
357
|
+
const elements = document.querySelectorAll(keySelector)
|
|
358
|
+
|
|
359
|
+
elements.forEach((element) => clearLinkInjection(element.getAttribute(keyDataAttribute) || ''))
|
|
360
|
+
|
|
361
|
+
clearAfterArticlePlaylinks()
|
|
362
|
+
clearInTextDisclaimer()
|
|
363
|
+
clearInTextWidgets()
|
|
364
|
+
|
|
365
|
+
if (!elements.length) return
|
|
366
|
+
|
|
367
|
+
destroyAllModals(false)
|
|
368
|
+
destroyLinkPopover(false)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function clearLinkInjection(key: string): void {
|
|
372
|
+
const element: HTMLAnchorElement | null = document.querySelector(`[${keyDataAttribute}="${key}"]`)
|
|
373
|
+
if (!element) return
|
|
374
|
+
|
|
375
|
+
const playpilotElements = element.querySelectorAll('[data-playpilot-element]')
|
|
376
|
+
playpilotElements.forEach(element => element.remove())
|
|
377
|
+
|
|
378
|
+
const linkContent = element.querySelector('a')?.innerHTML
|
|
379
|
+
element.outerHTML = linkContent || ''
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* DigitalSpy tends to copy paste existing links via their CMS. They copy over injected links into new articles.
|
|
384
|
+
* Injections won't run on those links because they already are links.
|
|
385
|
+
* As a simple fix, we clear all title links to PlayPilot pages before injecting.
|
|
386
|
+
*/
|
|
387
|
+
export function removePlayPilotTitleLinks(): void {
|
|
388
|
+
const playPilotLinks = document.querySelectorAll<HTMLAnchorElement>('a[href*="playpilot.com"]')
|
|
389
|
+
|
|
390
|
+
playPilotLinks.forEach(link => {
|
|
391
|
+
if (link.closest(keySelector)) return
|
|
392
|
+
if (!(/\/movie|show\//).test(link.href)) return
|
|
393
|
+
|
|
394
|
+
link.outerHTML = link.innerHTML
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function mergeInjectionTypes({ aiInjections, manualInjections }: LinkInjectionTypes): LinkInjection[] {
|
|
399
|
+
return [...manualInjections.map(i => ({ ...i, manual: true })), ...aiInjections]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function separateLinkInjectionTypes(injections: LinkInjection[]): LinkInjectionTypes {
|
|
403
|
+
return {
|
|
404
|
+
aiInjections: injections.filter(i => !i.manual),
|
|
405
|
+
manualInjections: injections.filter(i => i.manual),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function isValidInjection(injection: LinkInjection): boolean {
|
|
410
|
+
return !injection.inactive && !injection.removed && !injection.duplicate && hasValidTypeData(injection) && isValidPlaylinkType(injection)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* An injection can be of various playlink types, when all are false equivalent, the link is not valid.
|
|
415
|
+
* It should be treated similar to an inactive playlink in this case.
|
|
416
|
+
*/
|
|
417
|
+
export function isValidPlaylinkType(injection: LinkInjection): boolean {
|
|
418
|
+
if (injection.in_text || injection.in_text === undefined) return true
|
|
419
|
+
return !!injection.after_article
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function filterInvalidInTextInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
423
|
+
return filterRemovedAndInactiveInjections(injections).filter(i => i.in_text !== false && isValidInjection(i))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function filterInvalidAfterArticleInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
427
|
+
return filterRemovedAndInactiveInjections(injections).filter(i => i.after_article === true && isValidInjection(i))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Filter injections that were marked as removed or inactive or have an equivalent removed or inactive manual injections, soley based on the same sentence and title.
|
|
432
|
+
*/
|
|
433
|
+
export function filterRemovedAndInactiveInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
434
|
+
return injections.filter(injection => {
|
|
435
|
+
if (injection.removed || injection.inactive) return false
|
|
436
|
+
if (injection.manual && (!injection.removed && !injection.inactive)) return true
|
|
437
|
+
|
|
438
|
+
return !injections.some(i => i.manual && (i.removed || i.inactive) && isEquivalentInjection(i, injection))
|
|
439
|
+
})
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Injections are sorted first by their sentence and second by their title length. The first sorting, sentence, is so that injections remain
|
|
444
|
+
* roughly grouped. The second sorting is to prevent shorter titles from merging into longer ones. For instance;
|
|
445
|
+
* "Shrek 2 is the sequel to Shrek". If "Shrek" appears as an injection before Shrek 2, it will incorrect inject into "Shrek 2".
|
|
446
|
+
* By placing the longer titles first, we prevent that.
|
|
447
|
+
*/
|
|
448
|
+
export function sortInjections(injections: LinkInjection[]): LinkInjection[] {
|
|
449
|
+
return injections.slice().sort((a, b) => {
|
|
450
|
+
if (a.sentence < b.sentence) return -1
|
|
451
|
+
if (a.sentence > b.sentence) return 1
|
|
452
|
+
|
|
453
|
+
const difference = b.title.length - a.title.length
|
|
454
|
+
if (difference !== 0) return difference
|
|
455
|
+
|
|
456
|
+
return 0
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function isAvailableAsManualInjection(injection: LinkInjection, injectionIndex: number, injections: LinkInjection[]): boolean {
|
|
461
|
+
return injections.some((i, index) => {
|
|
462
|
+
return injectionIndex !== index && i.manual && isEquivalentInjection(i, injection)
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function isEquivalentInjection(injection1: LinkInjection, injection2: LinkInjection): boolean {
|
|
467
|
+
return injection1.title === injection2.title && cleanPhrase(injection1.sentence) === cleanPhrase(injection2.sentence)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function hasValidTypeData(injection: LinkInjection): boolean {
|
|
471
|
+
if (injection.type === 'title' && !!injection.title_details) return true
|
|
472
|
+
if (injection.type === 'participant' && !!injection.participant_details) return true
|
|
473
|
+
|
|
474
|
+
return false
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function getPlayPilotWrapperElement(): Element {
|
|
478
|
+
return document.querySelector('[data-playpilot-link-injections]') || document.body
|
|
479
|
+
}
|