@playpilot/tpi 5.2.1 → 5.3.1
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/link-injections.js +9 -9
- package/events.md +60 -0
- package/package.json +1 -1
- package/src/lib/api.ts +3 -0
- package/src/lib/array.ts +11 -0
- package/src/lib/enums/TrackingEvent.ts +3 -0
- package/src/lib/linkInjection.ts +23 -5
- package/src/lib/text.ts +27 -1
- package/src/lib/tracking.ts +8 -0
- package/src/lib/types/global.d.ts +1 -0
- package/src/lib/types/script.d.ts +2 -0
- package/src/lib/types/title.d.ts +1 -1
- package/src/main.ts +36 -1
- package/src/routes/+layout.svelte +3 -0
- package/src/routes/+page.svelte +2 -2
- package/src/routes/components/TitleModal.svelte +6 -0
- package/src/routes/components/TitlePopover.svelte +6 -1
- package/src/tests/lib/array.test.js +51 -1
- package/src/tests/lib/linkInjection.test.js +18 -0
- package/src/tests/lib/text.test.js +46 -1
- package/src/tests/routes/components/TitleModal.test.js +16 -1
- package/src/tests/routes/components/TitlePopover.test.js +17 -1
package/events.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
This document highlights the purpose of each tracking event.
|
|
2
|
+
|
|
3
|
+
## Payload
|
|
4
|
+
|
|
5
|
+
All events share a common payload:
|
|
6
|
+
|
|
7
|
+
- `url`: The URL of the related article. This is a URL with only the protocol, base url, and pathname. No parameters are included
|
|
8
|
+
- `organization_sid`: The sid for the related organization
|
|
9
|
+
- `domain_sid`: The sid for the related domain
|
|
10
|
+
|
|
11
|
+
Events related to titles share an additional set of data (referred to below as `Title`):
|
|
12
|
+
|
|
13
|
+
- `original_title`
|
|
14
|
+
- `title_sid`
|
|
15
|
+
- `title_type`: "movie" or "series"
|
|
16
|
+
- `providers`: An array of provider names
|
|
17
|
+
|
|
18
|
+
Events may have additional data in their payload.
|
|
19
|
+
|
|
20
|
+
### General
|
|
21
|
+
Event | Action | Info | Payload
|
|
22
|
+
--- | --- | --- | ---
|
|
23
|
+
`ali_article_page_view` | _Fires any time an article is visited_ | This event will fire right after all data is fetched and will fire regardless of if there are injections or not | It will fire even on pages where injections are disabled. | -
|
|
24
|
+
`ali_links_injected` | _Fires as long as any injections are injected into the article_ | Includes an object with the number of injections for this article with `manual` and `ai` as two separate numbers. | `manual` (number of manual injection), `ai` (number of ai injections)
|
|
25
|
+
|
|
26
|
+
### Modal
|
|
27
|
+
Event | Action | Info | Payload
|
|
28
|
+
--- | --- | --- | ---
|
|
29
|
+
`ali_title_modal_view` | _Fires any time a title modal is viewed_ | The title modal opens when clicking an injection both on desktop and mobile | `Title`
|
|
30
|
+
`ali_title_modal_scroll` | _Fires the first time a user scrolls inside of a titel modal._ | | `Title`
|
|
31
|
+
`ali_title_modal_playlink_click` | _Fires any time a playlink is clicked inside of a title modal_ | Includes data on which playlink was clicked. | `Title`, `playlink` (name of the clicked playlink)
|
|
32
|
+
`ali_title_modal_save_click` | _Currently unused, there is no save functionality._ | | `Title`
|
|
33
|
+
|
|
34
|
+
### Popover
|
|
35
|
+
Event | Action | Info | Payload
|
|
36
|
+
--- | --- | --- | ---
|
|
37
|
+
`ali_title_popover_view` | _Fires any time a title popover is viewed_ | The title popover opens when hovering an injection. This will only occur on desktop. | `Title`
|
|
38
|
+
`ali_title_popover_save_click` | _Currently unused, there is no save functionality._ | | `Title`
|
|
39
|
+
`ali_title_popover_playlink_click` | _Fires any time a playlink is clicked inside of a title popover_ | Includes data on which playlink was clicked. | `Title`, `playlink` (name of the clicked playlink)
|
|
40
|
+
|
|
41
|
+
### After Article
|
|
42
|
+
Event | Action | Info | Payload
|
|
43
|
+
--- | --- | --- | ---
|
|
44
|
+
`ali_after_article_playlink_click` | _Fires any time a playlink is clicked inside of an after article block_ | This block will only appear injections are configured for after article, which no one is currently using. Includes data on which playlink was clicked. | `Title`, `playlink` (name of the clicked playlink)
|
|
45
|
+
`ali_after_article_modal_button_click` | _Fires any time an after article modal button is clicked_ | This button will only appear in after article blocks when configured, which, which no one is currently using | `Title`
|
|
46
|
+
|
|
47
|
+
### Data
|
|
48
|
+
Event | Action | Info | Payload
|
|
49
|
+
--- | --- | --- | ---
|
|
50
|
+
`ali_injection_failed` | _Fires only inside of the Editor for each injection that failed_ | Only includes visible failures, for instance, it will ignore failures because of already existing links. If a user is shown a message about a failed injection, this event will fire. Includes data on the phrase and sentence for the injection. | `Title`, `phrase`, `sentence`
|
|
51
|
+
`ali_injection_count` | _Fires the first time the Editor is shown_ | This logs the total amount of injections, the total amount of failed and manual injections, and the total amount of successful injections. | `total` (number of failed + successsful injections), `failed_automatic`, `failed_manual`, `final_injected` (number of successful injections)
|
|
52
|
+
`ali_fetch_config_failed` | _Fires whenever the config object failed to fetch_ | When this happens, injections are aborted.
|
|
53
|
+
`ali_auth_failed` | _Fires whenever authentication for the Editor fails._
|
|
54
|
+
|
|
55
|
+
### Reporting
|
|
56
|
+
Event | Action | Info | Payload
|
|
57
|
+
--- | --- | --- | ---
|
|
58
|
+
`ali_manual_report` | _Fires only through manual action when reporting issues with an injection via the Editor._ | | `Title`, `report_reason`, `sid` (of injection), `title` (of injection), `sentence`, `failed` (true or false), `failed_message` (reason for failure as given in the editor), `manual` (true or false)
|
|
59
|
+
`ali_editor_error` | _Fires whenever an error occurs within the Editor._ | | `Title`, `phrase`, `sentence`
|
|
60
|
+
`ali_injection_error` | _Fires whenever an error occurs during injection_ | This includes fetching the injections as well as actually injecting itself. Does not include fetching of the config object. | `message` (error message as given by the browser)
|
package/package.json
CHANGED
package/src/lib/api.ts
CHANGED
|
@@ -51,6 +51,9 @@ export async function fetchLinkInjections(
|
|
|
51
51
|
|
|
52
52
|
const parsed = await response.json()
|
|
53
53
|
|
|
54
|
+
// This is used when debugging (using window.PlayPilotLinkInjections.debug())
|
|
55
|
+
window.PlayPilotLinkInjections.last_successful_fetch = parsed
|
|
56
|
+
|
|
54
57
|
return parsed
|
|
55
58
|
}
|
|
56
59
|
|
package/src/lib/array.ts
CHANGED
|
@@ -11,3 +11,14 @@ export function getLargestValueInArray(array: number[]): number {
|
|
|
11
11
|
|
|
12
12
|
return largest
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the number of previous occurances in an array of objects based on a set of matching keys.
|
|
17
|
+
* For instance, in an array [{ id: 1, key: value }, { id: 2, key: value }], you might only want
|
|
18
|
+
* to match on the "key" key, rather than on the full object. An array is passed as keys to match
|
|
19
|
+
* on multiple keys.
|
|
20
|
+
*/
|
|
21
|
+
export function getNumberOfOccurrencesInArray<T>(array: T[], item: T, keys: string[]): number {
|
|
22
|
+
// @ts-ignore The key here used is a bit ambiguous
|
|
23
|
+
return array.filter(i => keys.every(key => i[key] === item[key])).length
|
|
24
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
/** @see /events.md */
|
|
1
2
|
export const TrackingEvent = Object.freeze({
|
|
2
3
|
ArticlePageView: 'ali_article_page_view',
|
|
3
4
|
ArticleInjected: 'ali_links_injected',
|
|
4
5
|
|
|
5
6
|
TitleModalView: 'ali_title_modal_view',
|
|
7
|
+
TitleModalClose: 'ali_title_modal_close',
|
|
6
8
|
TitleModalScroll: 'ali_title_modal_scroll',
|
|
7
9
|
TitleModalPlaylinkClick: 'ali_title_modal_playlink_click',
|
|
8
10
|
TitleModalSaveClick: 'ali_title_modal_save_click',
|
|
9
11
|
|
|
10
12
|
TitlePopoverView: 'ali_title_popover_view',
|
|
13
|
+
TitlePopoverClose: 'ali_title_popover_close',
|
|
11
14
|
TitlePopoverSaveClick: 'ali_title_popover_save_click',
|
|
12
15
|
TitlePopoverPlaylinkClick: 'ali_title_popover_playlink_click',
|
|
13
16
|
|
package/src/lib/linkInjection.ts
CHANGED
|
@@ -2,11 +2,12 @@ import { mount, unmount } from 'svelte'
|
|
|
2
2
|
import TitleModal from '../routes/components/TitleModal.svelte'
|
|
3
3
|
import TitlePopover from '../routes/components/TitlePopover.svelte'
|
|
4
4
|
import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
|
|
5
|
-
import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom } from './text'
|
|
5
|
+
import { cleanPhrase, findNumberOfMatchesInString, findShortestMatchBetweenPhrases, findTextNodeContaining, getIndexOfPhraseWithSentenceBoundary, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceBetween, replaceStartingFrom } from './text'
|
|
6
6
|
import type { LinkInjection, LinkInjectionTypes } from './types/injection'
|
|
7
7
|
import { isHoldingSpecialKey } from './event'
|
|
8
8
|
import { playFallbackViewTransition } from './viewTransition'
|
|
9
9
|
import { prefersReducedMotion } from 'svelte/motion'
|
|
10
|
+
import { getNumberOfOccurrencesInArray } from './array'
|
|
10
11
|
|
|
11
12
|
const keyDataAttribute = 'data-playpilot-injection-key'
|
|
12
13
|
const keySelector = `[${keyDataAttribute}]`
|
|
@@ -103,6 +104,10 @@ export function getLinkInjectionsParentElement(): HTMLElement {
|
|
|
103
104
|
return document.querySelector('article') || document.querySelector('main') || document.body
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
export function getPageText(elements: HTMLElement[]): string {
|
|
108
|
+
return elements.map(element => element.innerText).join('\n\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
106
111
|
/**
|
|
107
112
|
* Replace all found injections within all given elements on the page
|
|
108
113
|
* @returns Returns an array of injections with injections that failed to be inserted marked as `failed`.
|
|
@@ -124,7 +129,12 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
|
|
|
124
129
|
|
|
125
130
|
const failedMessages: Record<string, string> = {}
|
|
126
131
|
|
|
132
|
+
// Start at -1 so that we can add +1 right at the top of the loop, making it start at 0.
|
|
133
|
+
// This index is used in Option 3 below.
|
|
134
|
+
let injectionIndex = -1
|
|
127
135
|
for (const injection of foundInjections) {
|
|
136
|
+
injectionIndex++
|
|
137
|
+
|
|
128
138
|
const elementIndex = elements.findIndex(element => cleanPhrase(element.innerText).includes(cleanPhrase(injection.sentence)))
|
|
129
139
|
const element = elements[elementIndex]
|
|
130
140
|
|
|
@@ -155,7 +165,7 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
|
|
|
155
165
|
if (numberOfMatches === 1) replacementIndex = element.innerHTML.indexOf(injection.title)
|
|
156
166
|
|
|
157
167
|
// !! Option 2 - Replace by phrase_before and phrase_after
|
|
158
|
-
// If multiple or no
|
|
168
|
+
// If multiple or no occurences were found, we use phrase_before and phrase_after to refine the search
|
|
159
169
|
if (replacementIndex === -1 && (phrase_before || phrase_after)) {
|
|
160
170
|
// The before and after phrase are combined to see if the sentence contains the match exactly.
|
|
161
171
|
// This is a fairly simple comparison that will fail on special characters, html tags, or inconsistencies
|
|
@@ -185,15 +195,23 @@ export function injectLinksInDocument(elements: HTMLElement[], injections: LinkI
|
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
// !! Option 3 - Replace by title only, taking previous injections into account
|
|
188
|
-
// If no
|
|
198
|
+
// If no occurences were found previously, we check the element from start to finish only checking for the title,
|
|
189
199
|
// without considering phrase_before and phrase_after
|
|
190
200
|
if (replacementIndex === -1 && nodeContainingText?.nodeValue) {
|
|
191
|
-
// Start searching for injection from either the value or the
|
|
201
|
+
// Start searching for injection from either the value, the sentence, or the occurrence. This prevents injecting into
|
|
192
202
|
// text in an element earlier than the sentence started. A element might contain many sentences, after all.
|
|
193
203
|
const valueIndex = element.innerHTML.indexOf(nodeContainingText.nodeValue)
|
|
194
204
|
const sentenceIndex = element.innerHTML.indexOf(injection.sentence)
|
|
195
205
|
|
|
196
|
-
|
|
206
|
+
// Starting from occurence happens when a sentence might include multiple occurences of the same phrase.
|
|
207
|
+
// We might still prefer the valueIndex if that is higher than this value.
|
|
208
|
+
|
|
209
|
+
// A sentence might include multiple matches for the same phrase. We get the number of previous occurences in order to get the index
|
|
210
|
+
// of the match we actually want to get.
|
|
211
|
+
const indexOfOccurrence = getNumberOfOccurrencesInArray<LinkInjection>(foundInjections.slice(0, injectionIndex + 1), injection, ['title', 'sentence']) - 1
|
|
212
|
+
const indexInSentence = getIndexOfPhraseWithSentenceBoundary(nodeContainingText.nodeValue, element.innerHTML, indexOfOccurrence)
|
|
213
|
+
|
|
214
|
+
replacementIndex = Math.max(valueIndex, indexInSentence, sentenceIndex, 0)
|
|
197
215
|
}
|
|
198
216
|
|
|
199
217
|
if (replacementIndex === -1) continue
|
package/src/lib/text.ts
CHANGED
|
@@ -131,7 +131,7 @@ export function findNumberOfMatchesInString(text: string, phrase: string): numbe
|
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
133
|
* Finds all matches between two phrases. For example "A sentence with a start and end phrase". Is "start" and "end" are given
|
|
134
|
-
* as the `start` and `end` properties, this will match on ` and `. This does not currently account for multiple
|
|
134
|
+
* as the `start` and `end` properties, this will match on ` and `. This does not currently account for multiple occurrences
|
|
135
135
|
* of the end phrase. For example "A sentence with a start and end phrase and another end" will only return 1 value, despite
|
|
136
136
|
* there being 2 end phrases with content between. While that might make sense, we don't currently need that as we always
|
|
137
137
|
* filter by the shortest length anyway.
|
|
@@ -178,3 +178,29 @@ export function getNumberOfLeadingAndTrailingSpaces(text: string): { leadingSpac
|
|
|
178
178
|
trailingSpaces: text.match(/\s*$/)?.[0].length || 0
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get the index of a phrase inside of text based on a specific word boundary. Returns either the first match
|
|
184
|
+
* or the index of the given occurrence.
|
|
185
|
+
*/
|
|
186
|
+
export function getIndexOfPhraseWithSentenceBoundary(phrase: string, sentence: string, occurrence = 0): number {
|
|
187
|
+
const disallowedBoundaryCharacters = ['-', '_', '/']
|
|
188
|
+
const matches = []
|
|
189
|
+
|
|
190
|
+
let index = sentence.indexOf(phrase)
|
|
191
|
+
while (index !== -1) {
|
|
192
|
+
const leadingCharacter = sentence[index - 1]
|
|
193
|
+
const disallowedLeadingCharacter = disallowedBoundaryCharacters.includes(leadingCharacter)
|
|
194
|
+
|
|
195
|
+
const trailingCharacter = sentence[index + phrase.length]
|
|
196
|
+
const disallowedTrailingCharacter = disallowedBoundaryCharacters.includes(trailingCharacter)
|
|
197
|
+
|
|
198
|
+
if (!disallowedLeadingCharacter && !disallowedTrailingCharacter) {
|
|
199
|
+
matches.push(index)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
index = sentence.indexOf(phrase, index + 1)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return matches[occurrence] ?? -1
|
|
206
|
+
}
|
package/src/lib/tracking.ts
CHANGED
|
@@ -36,6 +36,14 @@ export async function track(event: string, title: TitleData | null = null, paylo
|
|
|
36
36
|
source: 'ali',
|
|
37
37
|
})),
|
|
38
38
|
})
|
|
39
|
+
|
|
40
|
+
pushEventToWindow({ event, payload })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Save this event to the window object. Used when calling .debug() */
|
|
44
|
+
function pushEventToWindow(data: { event: string, payload: Record<string, any> }) {
|
|
45
|
+
if (!window.PlayPilotLinkInjections.tracked_events) window.PlayPilotLinkInjections.tracked_events = []
|
|
46
|
+
window.PlayPilotLinkInjections.tracked_events?.push(data)
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/**
|
|
@@ -7,4 +7,6 @@ export type ScriptConfig = {
|
|
|
7
7
|
after_article_selector?: string
|
|
8
8
|
after_article_insert_position?: InsertPosition | ''
|
|
9
9
|
language?: string | null
|
|
10
|
+
last_successful_fetch?: LinkInjectionResponse | null
|
|
11
|
+
tracked_events?: { event: string, payload: Record<string, any> }[]
|
|
10
12
|
}
|
package/src/lib/types/title.d.ts
CHANGED
package/src/main.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mount } from 'svelte'
|
|
2
2
|
import App from './routes/+page.svelte'
|
|
3
|
-
import { clearLinkInjections } from '$lib/linkInjection'
|
|
3
|
+
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, getPageText } from '$lib/linkInjection'
|
|
4
|
+
import { getPageMetaData } from '$lib/meta'
|
|
4
5
|
|
|
5
6
|
window.PlayPilotLinkInjections = {
|
|
6
7
|
token: '',
|
|
@@ -11,6 +12,8 @@ window.PlayPilotLinkInjections = {
|
|
|
11
12
|
language: null,
|
|
12
13
|
organization_sid: null,
|
|
13
14
|
domain_sid: null,
|
|
15
|
+
last_successful_fetch: null,
|
|
16
|
+
tracked_events: [],
|
|
14
17
|
app: null,
|
|
15
18
|
|
|
16
19
|
initialize(config = { token: '', selector: '', after_article_selector: '', after_article_insert_position: '', language: null, organization_sid: null, domain_sid: null, editorial_token: '' }): void {
|
|
@@ -48,6 +51,38 @@ window.PlayPilotLinkInjections = {
|
|
|
48
51
|
|
|
49
52
|
clearLinkInjections()
|
|
50
53
|
},
|
|
54
|
+
|
|
55
|
+
debug(): void {
|
|
56
|
+
const parentElement = getLinkInjectionsParentElement()
|
|
57
|
+
const elements = getLinkInjectionElements(parentElement)
|
|
58
|
+
|
|
59
|
+
console.group('PlayPilot Link Injection Debug')
|
|
60
|
+
console.groupCollapsed('Config')
|
|
61
|
+
console.table(Object.entries(this))
|
|
62
|
+
console.groupEnd()
|
|
63
|
+
|
|
64
|
+
console.groupCollapsed('Elements')
|
|
65
|
+
console.log('Parent element', parentElement)
|
|
66
|
+
console.log('Valid elements', elements)
|
|
67
|
+
console.groupEnd()
|
|
68
|
+
|
|
69
|
+
console.groupCollapsed('Last fetch')
|
|
70
|
+
console.log(this.last_successful_fetch)
|
|
71
|
+
console.groupEnd()
|
|
72
|
+
|
|
73
|
+
console.groupCollapsed('Meta')
|
|
74
|
+
console.log(getPageMetaData())
|
|
75
|
+
console.groupEnd()
|
|
76
|
+
|
|
77
|
+
console.groupCollapsed('Page text')
|
|
78
|
+
console.log(getPageText(elements))
|
|
79
|
+
console.groupEnd()
|
|
80
|
+
|
|
81
|
+
console.groupCollapsed('Tracked events')
|
|
82
|
+
console.log(this.tracked_events)
|
|
83
|
+
console.groupEnd()
|
|
84
|
+
console.groupEnd()
|
|
85
|
+
}
|
|
51
86
|
}
|
|
52
87
|
|
|
53
88
|
export default window.PlayPilotLinkInjections
|
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
<h2>A matching link is already present</h2>
|
|
50
50
|
<p>Following their post-credits scene in <a href="/">John Wick</a>, in a new John Wick spinoff.</p>
|
|
51
51
|
|
|
52
|
+
<h2>Repeat injections <small>(Exact example from DigitalSpy)</small></h2>
|
|
53
|
+
<p>Season 2 is set to star <em>Taskmaster</em> season 2 runner-up Romesh Ranganathan, season 4's Mel Giedroyc, season 16 winner Sam Campbell, and future <em>Taskmaster</em> season 20 star Maisie Adam.</p>
|
|
54
|
+
|
|
52
55
|
<h2>Mixed and breaking elements <small>(DigitalSpy.com tends to do this)</small></h2>
|
|
53
56
|
<p>The following bold word is broken by <strong>multi</strong><strong>ple</strong> elements, but visually looks like one. An element might also only be par<em>tially</em> styled. Or a full title like "The Lord of the Rings: <em>The Two Towers</em>" might only have part styled.</p>
|
|
54
57
|
|
package/src/routes/+page.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
3
|
import { fetchConfig, pollLinkInjections } from '$lib/api'
|
|
4
|
-
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/linkInjection'
|
|
4
|
+
import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, getPageText, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/linkInjection'
|
|
5
5
|
import { setTrackingSids, track } from '$lib/tracking'
|
|
6
6
|
import { getFullUrlPath } from '$lib/url'
|
|
7
7
|
import { isCrawler } from '$lib/crawler'
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// @ts-ignore It's ok if the response is empty
|
|
27
27
|
const { ai_injections: aiInjections = [], manual_injections: manualInjections = [] } = $derived(response || {})
|
|
28
28
|
|
|
29
|
-
const pageText = $derived(elements
|
|
29
|
+
const pageText = $derived(getPageText(elements))
|
|
30
30
|
|
|
31
31
|
// Rerender link injections when linkInjections change. This is only relevant for editiorial mode.
|
|
32
32
|
$effect(() => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { TrackingEvent } from '$lib/enums/TrackingEvent'
|
|
3
3
|
import { track } from '$lib/tracking'
|
|
4
4
|
import type { TitleData } from '$lib/types/title'
|
|
5
|
+
import { onMount } from 'svelte'
|
|
5
6
|
import Modal from './Modal.svelte'
|
|
6
7
|
import Title from './Title.svelte'
|
|
7
8
|
|
|
@@ -16,6 +17,11 @@
|
|
|
16
17
|
|
|
17
18
|
let hasTrackedScrolling = false
|
|
18
19
|
|
|
20
|
+
onMount(() => {
|
|
21
|
+
const openTimestamp = Date.now()
|
|
22
|
+
return () => track(TrackingEvent.TitleModalClose, title, { time_spent: Date.now() - openTimestamp })
|
|
23
|
+
})
|
|
24
|
+
|
|
19
25
|
function onscroll(): void {
|
|
20
26
|
if (hasTrackedScrolling) return
|
|
21
27
|
|
|
@@ -18,7 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
track(TrackingEvent.TitlePopoverView, title)
|
|
20
20
|
|
|
21
|
-
onMount(
|
|
21
|
+
onMount(() => {
|
|
22
|
+
setOffset()
|
|
23
|
+
|
|
24
|
+
const openTimestamp = Date.now()
|
|
25
|
+
return () => track(TrackingEvent.TitlePopoverClose, title, { time_spent: Date.now() - openTimestamp })
|
|
26
|
+
})
|
|
22
27
|
|
|
23
28
|
/**
|
|
24
29
|
* An element can be split up over multiple lines, giving it multiple ClientRects.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { getLargestValueInArray } from '$lib/array'
|
|
2
|
+
import { getLargestValueInArray, getNumberOfOccurrencesInArray } from '$lib/array'
|
|
3
3
|
|
|
4
4
|
describe('array.js', () => {
|
|
5
5
|
describe('getLargestValueInArray', () => {
|
|
@@ -11,4 +11,54 @@ describe('array.js', () => {
|
|
|
11
11
|
expect(getLargestValueInArray([])).toBe(0)
|
|
12
12
|
})
|
|
13
13
|
})
|
|
14
|
+
|
|
15
|
+
describe('getNumberOfOccurrencesInArray', () => {
|
|
16
|
+
it('Should return 0 if array is empty', () => {
|
|
17
|
+
const item = { id: 1, key: 'value' }
|
|
18
|
+
|
|
19
|
+
expect(getNumberOfOccurrencesInArray([], item, ['key'])).toBe(0)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('Should counts occurrences based on a single key', () => {
|
|
23
|
+
const array = [
|
|
24
|
+
{ id: 1, key: 'value' },
|
|
25
|
+
{ id: 2, key: 'value' },
|
|
26
|
+
{ id: 3, key: 'other' },
|
|
27
|
+
]
|
|
28
|
+
const item = { id: 0, key: 'value' }
|
|
29
|
+
|
|
30
|
+
expect(getNumberOfOccurrencesInArray(array, item, ['key'])).toBe(2)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('Should count occurrences based on multiple keys', () => {
|
|
34
|
+
const array = [
|
|
35
|
+
{ id: 1, key: 'value', thing: 'A' },
|
|
36
|
+
{ id: 2, key: 'value', thing: 'B' },
|
|
37
|
+
{ id: 3, key: 'value', thing: 'A' },
|
|
38
|
+
]
|
|
39
|
+
const item = { id: 0, key: 'value', thing: 'A' }
|
|
40
|
+
|
|
41
|
+
expect(getNumberOfOccurrencesInArray(array, item, ['key', 'thing'])).toBe(2)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('Should return 0 if no items match on given keys', () => {
|
|
45
|
+
const array = [
|
|
46
|
+
{ id: 1, key: 'value', thing: 'A' },
|
|
47
|
+
{ id: 2, key: 'value', thing: 'B' },
|
|
48
|
+
]
|
|
49
|
+
const item = { id: 0, key: 'value', thing: 'C' }
|
|
50
|
+
|
|
51
|
+
expect(getNumberOfOccurrencesInArray(array, item, ['key', 'thing'])).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('Should ignore keys not listed in the keys array', () => {
|
|
55
|
+
const array = [
|
|
56
|
+
{ id: 1, key: 'value', thing: 'A' },
|
|
57
|
+
{ id: 1, key: 'value', thing: 'B' },
|
|
58
|
+
]
|
|
59
|
+
const item = { id: 1, key: 'value', thing: 'C' }
|
|
60
|
+
|
|
61
|
+
expect(getNumberOfOccurrencesInArray(array, item, ['id', 'key'])).toBe(2)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
14
64
|
})
|
|
@@ -505,6 +505,24 @@ describe('linkInjection.js', () => {
|
|
|
505
505
|
expect(links[1].dataset.playpilotInjectionKey).toBe(linkInjections[1].key)
|
|
506
506
|
})
|
|
507
507
|
|
|
508
|
+
it('Should inject links of the same phrase when multiple are present, but ignore injectios into links that contain the same phrase', () => {
|
|
509
|
+
document.body.innerHTML = '<p>First phrase, <a href="/phrase">second</a> phrase</p>'
|
|
510
|
+
|
|
511
|
+
const elements = Array.from(document.querySelectorAll('p'))
|
|
512
|
+
|
|
513
|
+
const sentence = 'First phrase, second phrase'
|
|
514
|
+
const linkInjections = [
|
|
515
|
+
generateInjection(sentence, 'phrase'),
|
|
516
|
+
generateInjection(sentence, 'phrase'),
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
injectLinksInDocument(elements, { aiInjections: linkInjections, manualInjections: [] })
|
|
520
|
+
|
|
521
|
+
const links = /** @type {HTMLAnchorElement[]} */ (Array.from(document.querySelectorAll('a')))
|
|
522
|
+
|
|
523
|
+
expect(links).toHaveLength(3)
|
|
524
|
+
})
|
|
525
|
+
|
|
508
526
|
it('Should not inject injections with in_text set to false', () => {
|
|
509
527
|
document.body.innerHTML = '<p>This is a sentence with an injection.</p>'
|
|
510
528
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { cleanPhrase, findAllMatchesBetweenPhrases, findShortestMatchBetweenPhrases, findSurroundingPhrases, findTextNodeContaining, getFirstNumberOfWordsInString, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceStartingFrom, reverseString, truncateAroundPhrase } from '$lib/text'
|
|
2
|
+
import { cleanPhrase, findAllMatchesBetweenPhrases, findShortestMatchBetweenPhrases, findSurroundingPhrases, findTextNodeContaining, getFirstNumberOfWordsInString, getIndexOfPhraseWithSentenceBoundary, getNumberOfLeadingAndTrailingSpaces, isNodeInLink, replaceStartingFrom, reverseString, truncateAroundPhrase } from '$lib/text'
|
|
3
3
|
|
|
4
4
|
describe('text.js', () => {
|
|
5
5
|
beforeEach(() => {
|
|
@@ -346,4 +346,49 @@ describe('text.js', () => {
|
|
|
346
346
|
expect(reverseString('')).toBe('')
|
|
347
347
|
})
|
|
348
348
|
})
|
|
349
|
+
|
|
350
|
+
describe('getIndexOfPhraseWithSentenceBoundary', () => {
|
|
351
|
+
it('Should return index of a single phrase occurrence', () => {
|
|
352
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'Some phrase in a sentence')).toBe(5)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('Should return -1 if phrase is not found', () => {
|
|
356
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'Some sentence without a match')).toBe(-1)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('Should return the index of the given occurrence', () => {
|
|
360
|
+
const sentence = 'Some phrase, another phrase, and one more phrase'
|
|
361
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', sentence, 0)).toBe(5)
|
|
362
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', sentence, 1)).toBe(21)
|
|
363
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', sentence, 2)).toBe(42)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('Should not match when phrase is preceded by a disallowed boundary character', () => {
|
|
367
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'pre-phrase test')).toBe(-1)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('Should not match when phrase is followed by a disallowed boundary character', () => {
|
|
371
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'This is phrase_test')).toBe(-1)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('Should match when phrase is at the start of the sentence', () => {
|
|
375
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'phrase at the start')).toBe(0)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('Should match when phrase is at the end of the sentence', () => {
|
|
379
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'This is a phrase')).toBe(10)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('Should match when phrase is surrounded by spaces', () => {
|
|
383
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'A phrase inside')).toBe(2)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('Should handle multiple disallowed boundary characters correctly', () => {
|
|
387
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'phrase/phrase_phrase-phrase phrase')).toBe(28)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('Should return -1 when occurrence index is out of range', () => {
|
|
391
|
+
expect(getIndexOfPhraseWithSentenceBoundary('phrase', 'phrase phrase phrase', 5)).toBe(-1)
|
|
392
|
+
})
|
|
393
|
+
})
|
|
349
394
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fireEvent, render } from '@testing-library/svelte'
|
|
2
|
-
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import TitleModal from '../../../routes/components/TitleModal.svelte'
|
|
5
5
|
import { title } from '$lib/fakeData'
|
|
@@ -15,6 +15,10 @@ describe('TitleModal.svelte', () => {
|
|
|
15
15
|
vi.resetAllMocks()
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.useRealTimers()
|
|
20
|
+
})
|
|
21
|
+
|
|
18
22
|
const onclose = vi.fn()
|
|
19
23
|
|
|
20
24
|
it('Should call track function when rendered', () => {
|
|
@@ -30,4 +34,15 @@ describe('TitleModal.svelte', () => {
|
|
|
30
34
|
|
|
31
35
|
expect(track).toHaveBeenCalledWith(TrackingEvent.TitleModalScroll, title)
|
|
32
36
|
})
|
|
37
|
+
|
|
38
|
+
it('Should call track function with time_spent when destroyed', async () => {
|
|
39
|
+
vi.useFakeTimers()
|
|
40
|
+
|
|
41
|
+
const { unmount } = render(TitleModal, { onclose, title })
|
|
42
|
+
|
|
43
|
+
vi.advanceTimersByTime(200)
|
|
44
|
+
unmount()
|
|
45
|
+
|
|
46
|
+
expect(track).toHaveBeenCalledWith(TrackingEvent.TitleModalClose, title, { time_spent: 200 })
|
|
47
|
+
})
|
|
33
48
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { render } from '@testing-library/svelte'
|
|
2
|
-
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import TitlePopover from '../../../routes/components/TitlePopover.svelte'
|
|
5
5
|
import { title } from '$lib/fakeData'
|
|
@@ -15,10 +15,26 @@ describe('TitlePopover.svelte', () => {
|
|
|
15
15
|
vi.resetAllMocks()
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.useRealTimers()
|
|
20
|
+
})
|
|
21
|
+
|
|
18
22
|
it('Should call track function when rendered', () => {
|
|
19
23
|
const event = new MouseEvent('mouseenter')
|
|
20
24
|
render(TitlePopover, { event, title })
|
|
21
25
|
|
|
22
26
|
expect(track).toHaveBeenCalledWith(TrackingEvent.TitlePopoverView, title)
|
|
23
27
|
})
|
|
28
|
+
|
|
29
|
+
it('Should call track function with time_spent when destroyed', async () => {
|
|
30
|
+
vi.useFakeTimers()
|
|
31
|
+
|
|
32
|
+
const event = new MouseEvent('mouseenter')
|
|
33
|
+
const { unmount } = render(TitlePopover, { event, title })
|
|
34
|
+
|
|
35
|
+
vi.advanceTimersByTime(200)
|
|
36
|
+
unmount()
|
|
37
|
+
|
|
38
|
+
expect(track).toHaveBeenCalledWith(TrackingEvent.TitlePopoverClose, title, { time_spent: 200 })
|
|
39
|
+
})
|
|
24
40
|
})
|