@playpilot/tpi 1.4.3 → 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playpilot/tpi",
3
- "version": "1.4.3",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
package/src/lib/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { authorize, getAuthToken, isEditorialModeEnabled } from './auth'
2
2
  import { apiBaseUrl } from './constants'
3
+ import { linkInjections } from './fakeData'
3
4
  import { stringToHash } from './hash'
4
5
  import { getPageMetaData } from './meta'
5
6
  import { getFullUrlPath } from './url'
@@ -49,9 +50,9 @@ export async function fetchLinkInjections(url, html, { hash = stringToHash(html)
49
50
  * The results return `injections_ready=false` while the injections are not yet ready.
50
51
  * @param {string} url URL of the given article
51
52
  * @param {string} html HTML to be crawled
52
- * @returns {Promise<LinkInjection[]>}
53
+ * @returns {Promise<LinkInjectionResponse>}
53
54
  */
54
- export async function pollLinkInjections(url, html, pollInterval = 3000, maxTries = 600) {
55
+ export async function pollLinkInjections(url, html, { requireCompletedResult = false, pollInterval = 3000, maxTries = 600 } = {}) {
55
56
  let hash = stringToHash(html)
56
57
  let currentTry = 0
57
58
 
@@ -69,35 +70,13 @@ export async function pollLinkInjections(url, html, pollInterval = 3000, maxTrie
69
70
  try {
70
71
  const response = await fetchLinkInjections(url, html, { hash })
71
72
 
72
- // Temporary solution to disallow pages that don't have link injections enabled.
73
- // Specifically checking against false to exclude responses without the property.
74
- // Should still return injections as expect in editorial mode.
75
- if (response.injections_enabled === false && !isEditorialModeEnabled()) {
76
- resolve([])
77
- return
78
- }
79
-
80
- // Temporary solution #2. If you're reading this weeks, months, or maybe even years from now, we've failed.
81
- // Injections might be re-generated when a hash changes but injections do exist for the same url.
82
- // In this case previous injections are returned while still generating new injections in the background.
83
- // We ignore the upcoming re-generation and use the current injections instead.
84
- if (response.injections_ready === false && response.link_injections?.length) {
85
- response.link_injections = insertRandomKeys(response.link_injections)
86
-
87
- resolve(response.link_injections || [])
88
- return
89
- }
90
-
91
- if (response.injections_ready) {
92
- if (response.link_injections) {
93
- response.link_injections = insertRandomKeys(response.link_injections)
94
- }
73
+ if (requireCompletedResult && (response.automation_enabled && response.ai_running)) throw new Error
95
74
 
96
- resolve(response.link_injections || [])
97
- return
98
- }
75
+ response.link_injections = insertRandomKeys(response.link_injections || [])
76
+ response.ai_injections = insertRandomKeys(response.ai_injections || [])
99
77
 
100
- throw new Error
78
+ resolve(response)
79
+ return
101
80
  } catch {
102
81
  currentTry++
103
82
 
@@ -123,7 +102,10 @@ export async function saveLinkInjections(linkInjections, html) {
123
102
  // @ts-ignore
124
103
  const selector = window.PlayPilotLinkInjections?.selector
125
104
 
126
- const newLinkInjections = linkInjections.map((/** @type {any} */linkInjection) => ({
105
+ // Only save manual injections, AI injections should be left intact.
106
+ const filteredLinkInjections = linkInjections.filter(i => i.manual)
107
+
108
+ const newLinkInjections = filteredLinkInjections.map((/** @type {any} */linkInjection) => ({
127
109
  sid: linkInjection.sid,
128
110
  title: linkInjection.title,
129
111
  sentence: linkInjection.sentence,
@@ -132,6 +114,7 @@ export async function saveLinkInjections(linkInjections, html) {
132
114
  after_article_style: linkInjection.after_article_style || null,
133
115
  in_text: linkInjection.in_text ?? true,
134
116
  inactive: !!linkInjection.inactive,
117
+ removed: !!linkInjection.removed,
135
118
  }))
136
119
 
137
120
  const response = await fetchLinkInjections(getFullUrlPath(), html, {
@@ -121,7 +121,7 @@ export const linkInjections = [{
121
121
  sentence: 'The movie is in a similar vein to successful films such as The Long Kiss Goodnight and Nobody',
122
122
  playpilot_url: 'https://playpilot.com/movie/example-2/',
123
123
  key: 'some-key-2',
124
- after_article: true,
124
+ after_article: false,
125
125
  title_details: title,
126
126
  }, {
127
127
  sid: '3',
@@ -131,6 +131,7 @@ export const linkInjections = [{
131
131
  key: 'some-key-3',
132
132
  after_article: true,
133
133
  title_details: title,
134
+ manual: false,
134
135
  }, {
135
136
  sid: '4',
136
137
  title: 'The Wheel of Time',
@@ -3,6 +3,7 @@ import TitlePopover from '../routes/components/TitlePopover.svelte'
3
3
  import AfterArticlePlaylinks from '../routes/components/AfterArticlePlaylinks.svelte'
4
4
  import { findTextNodeContaining, isNodeInLink, replaceStartingFrom } from './text'
5
5
  import { getLargestValueInArray } from './array'
6
+ import { decodeHtmlEntities } from './html'
6
7
 
7
8
  const keyDataAttribute = 'data-playpilot-injection-key'
8
9
  const keySelector = `[${keyDataAttribute}]`
@@ -74,25 +75,27 @@ export function getLinkInjectionsParentElement() {
74
75
  /**
75
76
  * Replace all found injections within all given elements on the page
76
77
  * @param {HTMLElement[]} elements
77
- * @param {LinkInjection[]} injections
78
78
  * @param {(LinkInjection: LinkInjection) => void} onclick
79
+ * @param {LinkInjectionTypes} injections
79
80
  * @returns {LinkInjection[]} Returns an array of injections with injections that failed to be inserted marked as `failed`.
80
81
  */
81
- export function injectLinksInDocument(elements, injections, onclick) {
82
+ export function injectLinksInDocument(elements, onclick, injections = { aiInjections: [], manualInjections: [] }) {
83
+ const mergedInjections = mergeInjectionTypes(injections)
84
+
82
85
  // Find injection in text content of all elements together, ignore potential HTML elements.
83
86
  // This is to filter out injections that can't be injected anyway.
84
87
  const fullText = elements.map(element => element.innerText).join(' ')
85
88
 
86
- // Filter out injections meant to be displayed only after the article, rather than in-text.
87
- // Also filter out injections that are marked as inactive
88
- const validInjections = injections.filter(i => i.in_text !== false && !i.inactive)
89
- const foundInjections = validInjections.filter(i => fullText.includes(i.sentence))
89
+ const validInjections = filterInvalidInTextInjections(mergedInjections)
90
+ const foundInjections = validInjections.filter(i => {
91
+ return decodeHtmlEntities(fullText).includes(decodeHtmlEntities(i.sentence))
92
+ })
90
93
 
91
94
  /** @type {LinkInjectionRanges} */
92
95
  const ranges = {}
93
96
 
94
97
  for (const injection of foundInjections) {
95
- const elementIndex = elements.findIndex(element => element.innerText.includes(injection.sentence))
98
+ const elementIndex = elements.findIndex(element => element.innerText.includes(decodeHtmlEntities(injection.sentence)))
96
99
  const element = elements[elementIndex]
97
100
 
98
101
  if (!element) continue
@@ -132,17 +135,20 @@ export function injectLinksInDocument(elements, injections, onclick) {
132
135
  addLinkInjectionEventListeners(validInjections, onclick)
133
136
  addCSSVariablesToLinks()
134
137
 
135
- const afterArticleInjections = injections.filter(i => i.after_article && !i.inactive)
138
+ const afterArticleInjections = filterInvalidAfterArticleInjections(mergedInjections)
136
139
  if (afterArticleInjections.length) insertAfterArticlePlaylinks(elements, afterArticleInjections, onclick)
137
140
 
138
- const sortedInjections = sortLinkInjectionsByRange(injections, ranges)
141
+ const sortedInjections = sortLinkInjectionsByRange(mergedInjections, ranges)
139
142
 
140
- return sortedInjections.map(injection => {
143
+ return sortedInjections.filter(i => i.title_details).map((injection, index) => {
141
144
  const failed = !injection.inactive && !injection.after_article && !document.querySelector(`[${keyDataAttribute}="${injection.key}"]`)
145
+ // Favour manual injections over AI injections
146
+ const duplicate = injection.duplicate ?? (!injection.manual && isAvailableAsManualInjection(injection, index, mergedInjections))
142
147
 
143
148
  return {
144
149
  ...injection,
145
- inactive: !!injection.inactive,
150
+ inactive: injection.inactive ?? false,
151
+ duplicate,
146
152
  failed,
147
153
  }
148
154
  })
@@ -278,6 +284,7 @@ function clearAfterArticlePlaylinks() {
278
284
 
279
285
  unmount(afterArticlePlaylinkInsertedComponent)
280
286
  document.querySelector('[data-playpilot-after-article-playlinks]')?.remove()
287
+ afterArticlePlaylinkInsertedComponent = null
281
288
  }
282
289
 
283
290
  /**
@@ -327,3 +334,87 @@ export function sortLinkInjectionsByRange(injections, ranges) {
327
334
  return (rangeA?.from || 0) - (rangeB?.from || 0)
328
335
  })
329
336
  }
337
+
338
+ /**
339
+ * Merge different injection types
340
+ * @param {LinkInjectionTypes} injections
341
+ * @returns {LinkInjection[]}
342
+ */
343
+ export function mergeInjectionTypes({ aiInjections, manualInjections }) {
344
+ return [...aiInjections, ...manualInjections.map(i => ({ ...i, manual: true }))]
345
+ }
346
+
347
+ /**
348
+ * Separate an array of flat injections into ai and manual arrays.
349
+ * @param {LinkInjection[]} injections
350
+ * @returns {LinkInjectionTypes}
351
+ */
352
+ export function separateLinkInjectionTypes(injections) {
353
+ return {
354
+ aiInjections: injections.filter(i => !i.manual),
355
+ manualInjections: injections.filter(i => i.manual),
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Returns whether or not an injection would be valid for any sort of injection, text or after_article
361
+ * @param {LinkInjection} injection
362
+ * @returns {boolean}
363
+ */
364
+ export function isValidInjection(injection) {
365
+ return !injection.inactive && !injection.removed && !injection.duplicate && !!injection.title_details
366
+ }
367
+
368
+ /**
369
+ * Filter links for in-text injections, removing after article, inactive, removed, duplicate, and items without title_details
370
+ * @param {LinkInjection[]} injections
371
+ * @returns {LinkInjection[]}
372
+ */
373
+ export function filterInvalidInTextInjections(injections) {
374
+ return filterRemovedInjections(injections).filter(i => i.in_text !== false && isValidInjection(i))
375
+ }
376
+
377
+ /**
378
+ * Filter links for after article injections, removing in-text only, inactive, removed, duplicate, and items without title_details
379
+ * @param {LinkInjection[]} injections
380
+ * @returns {LinkInjection[]}
381
+ */
382
+ export function filterInvalidAfterArticleInjections(injections) {
383
+ return filterRemovedInjections(injections).filter(i => i.after_article === true && isValidInjection(i))
384
+ }
385
+
386
+ /**
387
+ * Filter injections that were marked as removed or have an equivalent removed manual injections, soley based on the same sentence and title.
388
+ * @param {LinkInjection[]} injections
389
+ * @returns {LinkInjection[]}
390
+ */
391
+ export function filterRemovedInjections(injections) {
392
+ return injections.filter(injection => {
393
+ if (injection.removed) return false
394
+ if (injection.manual && !injection.removed) return true
395
+ return !injections.some(i => i.manual && i.removed && isEquivalentInjection(i, injection))
396
+ })
397
+ }
398
+
399
+ /**
400
+ * Return whether or not an injection is also available as manual injection
401
+ * @param {LinkInjection} injection
402
+ * @param {number} injectionIndex
403
+ * @param {LinkInjection[]} injections
404
+ * @returns {boolean}
405
+ */
406
+ export function isAvailableAsManualInjection(injection, injectionIndex, injections) {
407
+ return injections.some((i, index) => {
408
+ return injectionIndex !== index && i.manual && isEquivalentInjection(i, injection)
409
+ })
410
+ }
411
+
412
+ /**
413
+ * Returns whether or not 2 injections match in title and sentence
414
+ * @param {LinkInjection} injection1
415
+ * @param {LinkInjection} injection2
416
+ * @returns {boolean}
417
+ */
418
+ export function isEquivalentInjection(injection1, injection2) {
419
+ return injection1.title === injection2.title && injection1.sentence === injection2.sentence
420
+ }
package/src/lib/text.js CHANGED
@@ -58,4 +58,3 @@ export function replaceStartingFrom(text, search, replacement, startIndex) {
58
58
 
59
59
  return before + updatedAfter
60
60
  }
61
-
@@ -19,11 +19,13 @@
19
19
  if (browser) window.PlayPilotLinkInjections = { token: 'ZoAL14yqzevMyQiwckbvyetOkeIUeEDN', selector: 'article' }
20
20
  </script>
21
21
 
22
+ <meta property="article:modified_time" content="2025-05-15T20:00:00+00:00" />
23
+
22
24
  <div>
23
25
  {#key Math.random()}
24
26
  <article use:noClass>
25
27
  <h1 use:noClass>Some heading</h1>
26
- <time datetime="13:00">1 hour ago</time>
28
+ <time datetime="14:00">1 hour ago</time>
27
29
  <p use:noClass>Following the success of John M. Chu's 2018 romantic-comedy Crazy Rich Asians, Quan was inspired to return to acting. He first scored a supporting role in the Netflix movie Finding 'Ohana, before securing a starring role in the absurdist comedy-drama Everything Everywhere all At Once. A critical and commercial success, the film earned $143 million against a budget of $14-25 million, and saw Quan win the Academy Award for Best Supporting Actor. Following his win, Quan struggled to choose projects he was satisfied with, passing on an action-comedy three times, before finally taking his first leading role in it, following advice from Spielberg.</p>
28
30
  <p use:noClass>In an interview with Epire &amp; Magazine, Quan reveals he quested starring in Love Hurts, which sees him in the leading role of a former assassin turned successful realtor, whose past returns when his brother attempts to hunt him down. The movie is in a similar vein to successful films such as The Long Kiss Goodnight and Nobody, and Quan discussed how he was reluctant to take the part due to his conditioned beliefs about how an action hero should look. But he reveals that he changed his mind following a meeting with Spielberg, who convinced him to do it.</p>
29
31
  </article>
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  import { onMount } from 'svelte'
3
3
  import { pollLinkInjections } from '$lib/api'
4
- import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument } from '$lib/linkInjection'
4
+ import { clearLinkInjections, getLinkInjectionElements, getLinkInjectionsParentElement, injectLinksInDocument, separateLinkInjectionTypes } from '$lib/linkInjection'
5
5
  import { track } from '$lib/tracking'
6
6
  import { getFullUrlPath } from '$lib/url'
7
7
  import { TrackingEvent } from '$lib/enums/TrackingEvent'
@@ -15,12 +15,18 @@
15
15
  const htmlString = elements.map(p => p.outerHTML).join('')
16
16
  const isEditorialMode = isEditorialModeEnabled()
17
17
 
18
- /** @type {LinkInjection[]} */
19
- let linkInjections = $state([])
18
+ /** @type {LinkInjectionResponse | null} */
19
+ let response = $state(null)
20
20
  /** @type {LinkInjection | null} */
21
21
  let activeInjection = $state(null)
22
22
  let authorized = $state(false)
23
23
  let loading = $state(true)
24
+ /** @type {LinkInjection[]} */
25
+ let linkInjections = $state([])
26
+ let editor = $state()
27
+
28
+ // @ts-ignore It's ok if the response is empty
29
+ const { ai_injections: aiInjections = [], link_injections: manualInjections = [] } = $derived(response || {})
24
30
 
25
31
  // Rerender link injections when linkInjections change. This is only relevant for editiorial mode.
26
32
  $effect(() => {
@@ -38,24 +44,37 @@
38
44
  async function initialize() {
39
45
  if (isEditorialMode) authorized = await authorize()
40
46
 
41
- const response = await pollLinkInjections(getFullUrlPath(), htmlString) || []
42
- linkInjections = response.filter(i => i.title_details) // Filter out injections without titles
47
+ response = await pollLinkInjections(getFullUrlPath(), htmlString)
43
48
 
44
- inject()
49
+ inject({ aiInjections, manualInjections })
45
50
 
46
51
  loading = false
52
+
53
+ // A response was previous returned, but injections were still being generated in the backend.
54
+ // With this second request we wait until AI links are ready. We only do this in editorial
55
+ // so as not to suddenly insert new links while a user is reading the article.
56
+ if (!response?.ai_running) return
57
+ if (!isEditorialMode) return
58
+
59
+ const continuedResponse = await pollLinkInjections(getFullUrlPath(), htmlString, { requireCompletedResult: true })
60
+ editor.requestNewAIInjections(continuedResponse?.ai_injections || [])
47
61
  }
48
62
 
49
63
  function rerender() {
50
64
  clearLinkInjections()
51
- inject()
65
+ inject(separateLinkInjectionTypes(linkInjections))
52
66
  }
53
67
 
54
- function inject() {
55
- if (!linkInjections.length) return
68
+ /**
69
+ * @param {LinkInjectionTypes} injections
70
+ */
71
+ function inject(injections = { aiInjections, manualInjections }) {
72
+ if (!aiInjections.length && !manualInjections.length) return
56
73
 
57
- const filteredInjections = injectLinksInDocument(elements, linkInjections, setTarget)
58
- if (JSON.stringify(linkInjections) !== JSON.stringify(filteredInjections)) linkInjections = filteredInjections
74
+ // Get filtered injections as they are shown on the page.
75
+ // Only update state if it they are different from current injections.
76
+ const filteredInjections = injectLinksInDocument(elements, setTarget, injections)
77
+ if (JSON.stringify(filteredInjections) !== JSON.stringify(linkInjections)) linkInjections = filteredInjections
59
78
  }
60
79
 
61
80
  /** @param {LinkInjection} injection */
@@ -66,7 +85,7 @@
66
85
 
67
86
  <div class="playpilot-link-injections">
68
87
  {#if isEditorialMode && authorized}
69
- <Editor bind:linkInjections {htmlString} {loading} />
88
+ <Editor bind:linkInjections bind:this={editor} {htmlString} {loading} aiRunning={response?.ai_running && response?.automation_enabled} />
70
89
  {/if}
71
90
 
72
91
  {#if activeInjection && activeInjection.title_details}
@@ -79,13 +98,13 @@
79
98
  @import url('$lib/scss/variables.scss');
80
99
  @import url('$lib/scss/global.scss');
81
100
 
82
- .playpilot-link-injections :global {
83
- * {
101
+ .playpilot-link-injections {
102
+ :global(*) {
84
103
  box-sizing: border-box;
85
104
  }
86
105
 
87
- .playpilot-link-injections button,
88
- .playpilot-link-injections input {
106
+ :global(.playpilot-link-injections button),
107
+ :global(.playpilot-link-injections input) {
89
108
  transition: outline-offset 100ms;
90
109
 
91
110
  &:focus-visible,
@@ -0,0 +1,133 @@
1
+ <script>
2
+ import IconAi from '../Icons/IconAi.svelte'
3
+
4
+ /** @type {{ onadd: (injections: LinkInjection[]) => void }} */
5
+ let { onadd } = $props()
6
+
7
+ let running = $state(true)
8
+ /** @type {LinkInjection[]} */
9
+ let injectionsToBeInserted = $state([])
10
+ let dismissed = $state(false)
11
+
12
+ /**
13
+ * This is called from the Editor component when AI links are ready.
14
+ * From here we determine what to show the user. Either we show them an option
15
+ * to inject new links, or we tell them no new injections were found.
16
+ * @param {LinkInjection[]} injections
17
+ */
18
+ export function notifyUserOfNewState(injections) {
19
+ running = false
20
+ injectionsToBeInserted = injections
21
+ }
22
+ </script>
23
+
24
+ {#if !dismissed}
25
+ <div class="ai-indicator" class:running>
26
+ <div class="content">
27
+ <div class="icon">
28
+ <IconAi />
29
+ </div>
30
+
31
+ <div>
32
+ {#if running}
33
+ AI links are currently processing. You can add manual links while this is ongoing.
34
+ {:else if injectionsToBeInserted?.length}
35
+ AI links are ready.
36
+ <strong>{injectionsToBeInserted.length} New {injectionsToBeInserted.length > 1 ? 'links were' : 'link was'} found.</strong>
37
+ New links will be used next time you refresh the page, or you can insert them now.
38
+
39
+ <button class="button" onclick={() => { onadd(injectionsToBeInserted); dismissed = true }}>
40
+ Add AI links
41
+ </button>
42
+ {:else}
43
+ AI links finished running, but no new links were found.
44
+
45
+ <button class="button" onclick={() => dismissed = true}>Dismiss</button>
46
+ {/if}
47
+ </div>
48
+ </div>
49
+
50
+ <div class="border">
51
+ {#if running}
52
+ <div class="animator"></div>
53
+ {/if}
54
+ </div>
55
+ </div>
56
+ {/if}
57
+
58
+ <style lang="scss">
59
+ .ai-indicator {
60
+ position: relative;
61
+ margin: 0 margin(0.5);
62
+ }
63
+
64
+ .content {
65
+ position: relative;
66
+ display: flex;
67
+ gap: margin(0.5);
68
+ background: var(--playpilot-light);
69
+ padding: margin(0.5);
70
+ margin: 2px;
71
+ border-radius: 0.5rem;
72
+ font-size: 12px;
73
+ line-height: 1.5;
74
+ z-index: 1;
75
+ color: var(--playpilot-text-color-alt);
76
+ }
77
+
78
+ .icon {
79
+ color: var(--playpilot-green);
80
+ }
81
+
82
+ .border {
83
+ position: absolute;
84
+ top: 0;
85
+ right: 0;
86
+ bottom: 0;
87
+ left: 0;
88
+ border-radius: 0.5rem;
89
+ background: var(--playpilot-green);
90
+ overflow: hidden;
91
+ transition: background-color 500ms;
92
+
93
+ .running & {
94
+ background: var(--playpilot-light);
95
+ }
96
+ }
97
+
98
+ @keyframes rotate {
99
+ to {
100
+ rotate: 360deg;
101
+ }
102
+ }
103
+
104
+ .animator {
105
+ position: absolute;
106
+ left: 50%;
107
+ top: 50%;
108
+ transform: translateY(-50%);
109
+ width: 100%;
110
+ height: 20rem;
111
+ background: var(--playpilot-green);
112
+ transform-origin: left 50%;
113
+ animation: rotate 2000ms infinite linear;
114
+ filter: blur(5rem);
115
+ }
116
+
117
+ .button {
118
+ display: block;
119
+ appearance: none;
120
+ padding: 0 margin(0.25);
121
+ margin: margin(0.25) 0 0;
122
+ border: 1px solid currentColor;
123
+ border-radius: 0.25rem;
124
+ background: transparent;
125
+ color: var(--playpilot-green);
126
+ font-family: var(--playpilot-font-family);
127
+ cursor: pointer;
128
+
129
+ &:hover {
130
+ color: white;
131
+ }
132
+ }
133
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { fly, slide } from 'svelte/transition'
2
+ import { fade, fly, slide } from 'svelte/transition'
3
3
  import { flip } from 'svelte/animate'
4
4
  import EditorItem from './EditorItem.svelte'
5
5
  import DragHandle from './DragHandle.svelte'
@@ -8,9 +8,11 @@
8
8
  import RoundButton from '../RoundButton.svelte'
9
9
  import { saveLinkInjections } from '$lib/api'
10
10
  import { untrack } from 'svelte'
11
+ import AIIndicator from './AIIndicator.svelte'
12
+ import { isEquivalentInjection, isValidInjection } from '$lib/linkInjection';
11
13
 
12
- /** @type {{ linkInjections: LinkInjection[], htmlString?: string, loading?: boolean }} */
13
- let { linkInjections = $bindable(), htmlString = '', loading = false } = $props()
14
+ /** @type {{ linkInjections: LinkInjection[], htmlString?: string, loading?: boolean, aiRunning?: boolean }} */
15
+ let { linkInjections = $bindable(), htmlString = '', loading = false, aiRunning = false } = $props()
14
16
 
15
17
  const editorPositionKey = 'editor-position'
16
18
 
@@ -23,9 +25,11 @@
23
25
  let hasError = $state(false)
24
26
  let scrollDistance = $state(0)
25
27
  let initialStateString = $state('')
28
+ let aIIndicator = $state()
26
29
 
27
30
  const linkInjectionsString = $derived(JSON.stringify(linkInjections))
28
31
  const hasChanged = $derived(initialStateString !== linkInjectionsString)
32
+ const filteredInjections = $derived(linkInjections.filter(i => i.title_details && !i.removed && !i.duplicate))
29
33
 
30
34
  $effect(() => {
31
35
  if (!loading) untrack(() => initialStateString = linkInjectionsString)
@@ -57,7 +61,12 @@
57
61
  * @param {string} key
58
62
  */
59
63
  function removeInjection(key) {
60
- linkInjections = linkInjections.filter(i => i.key !== key)
64
+ const index = linkInjections.findIndex(i => i.key === key)
65
+
66
+ linkInjections[index].manual = true
67
+ linkInjections[index].removed = true
68
+
69
+ linkInjections = [...linkInjections]
61
70
  }
62
71
 
63
72
  /**
@@ -88,6 +97,20 @@
88
97
  const target = /** @type {HTMLElement} */ (event.target)
89
98
  scrollDistance = target.scrollTop
90
99
  }
100
+
101
+ /**
102
+ * This is called from outside when new AI links are ready.
103
+ * We only pass on links that do not already exist.
104
+ * @param {LinkInjection[]} injections
105
+ */
106
+ export function requestNewAIInjections(injections) {
107
+ const newInjections = injections.filter(injection => {
108
+ if (!isValidInjection(injection)) return
109
+ return !linkInjections.some((i) => isEquivalentInjection(injection, i))
110
+ })
111
+
112
+ aIIndicator.notifyUserOfNewState(newInjections)
113
+ }
91
114
  </script>
92
115
 
93
116
  <section class="editor playpilot-styled-scrollbar" class:panel-open={manualInjectionActive} class:loading bind:this={editorElement} {onscroll}>
@@ -113,6 +136,10 @@
113
136
  {/if}
114
137
  </header>
115
138
 
139
+ {#if !loading && aiRunning}
140
+ <AIIndicator bind:this={aIIndicator} onadd={(newInjections) => newInjections.forEach(i => linkInjections.push(i))} />
141
+ {/if}
142
+
116
143
  {#if !loading}
117
144
  {#if hasError}
118
145
  <div class="error" transition:slide|global={{ duration: 150 }}>
@@ -121,9 +148,12 @@
121
148
  {/if}
122
149
 
123
150
  <div class="items">
124
- {#each linkInjections as linkInjection, i (linkInjection.key)}
151
+ {#each filteredInjections as linkInjection (linkInjection.key)}
152
+ <!-- We want to bind to the original object, not the derived object, so we get the index of the injection in the original object by it's key -->
153
+ {@const index = linkInjections.findIndex(i => i.key === linkInjection.key)}
154
+
125
155
  <div animate:flip={{ duration: 300 }}>
126
- <EditorItem bind:linkInjection={linkInjections[i]} onremove={() => removeInjection(linkInjection.key)} onhighlight={scrollToItem} />
156
+ <EditorItem bind:linkInjection={linkInjections[index]} onremove={() => removeInjection(linkInjection.key)} onhighlight={scrollToItem} />
127
157
  </div>
128
158
  {:else}
129
159
  <div class="empty">No links available. Add links manually by clicking the + button and select text to add a link to.</div>
@@ -131,7 +161,7 @@
131
161
  </div>
132
162
 
133
163
  {#if hasChanged && linkInjections.length}
134
- <button class="save" disabled={saving} onclick={save} transition:fly={{ y: 70, opacity: 1, duration: 200 }}>
164
+ <button class="save" disabled={saving} onclick={save} in:fade={{ duration: 100 }}>
135
165
  {saving ? 'Saving...' : 'Save links'}
136
166
  </button>
137
167
  {/if}
@@ -168,8 +198,7 @@
168
198
  right: margin(1);
169
199
  width: 100%;
170
200
  max-width: margin(20);
171
- max-height: min(50vh, margin(50));
172
- min-height: margin(25);
201
+ height: min(50vh, margin(50));
173
202
  padding: margin(1);
174
203
  border-radius: margin(1.5);
175
204
  background: var(--playpilot-dark);
@@ -187,7 +216,7 @@
187
216
  }
188
217
 
189
218
  .loading {
190
- min-height: 0;
219
+ height: auto;
191
220
  border-radius: margin(2);
192
221
  margin-left: auto;
193
222
  padding-right: margin(0.5);
@@ -121,7 +121,7 @@
121
121
  <IconChevron {expanded} />
122
122
  </button>
123
123
 
124
- <Switch label="Visible" active={!linkInjection.inactive} onclick={(active) => linkInjection.inactive = !active}>
124
+ <Switch label="Visible" active={!linkInjection.inactive} onclick={(active) => { linkInjection.inactive = !active; linkInjection.manual = true }}>
125
125
  Visible
126
126
  </Switch>
127
127
  </div>
@@ -130,7 +130,7 @@
130
130
  {#if expanded}
131
131
  <div class="expanded" transition:slide={{ duration: 100 }}>
132
132
  <div class="label">Link URL</div>
133
- <TextInput bind:value={linkInjection.playpilot_url} label="Playlink URL" />
133
+ <TextInput bind:value={linkInjection.playpilot_url} oninput={() => linkInjection.manual = true} label="Playlink URL" />
134
134
 
135
135
  <div class="label offset">Layout options</div>
136
136
  <div class="type-select">
@@ -99,6 +99,7 @@
99
99
  playpilot_url: url,
100
100
  key: generateInjectionKey(selectedTitle.sid),
101
101
  title_details: selectedTitle,
102
+ manual: true,
102
103
  }
103
104
 
104
105
  onsave(linkInjection)