@purespectrum1/purescore-client 0.7.0 → 0.9.8
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/README.md +135 -3
- package/lib/application/auto-init.d.ts +6 -3
- package/lib/application/auto-init.js +29 -20
- package/lib/application/device-identifier.d.ts +18 -1
- package/lib/application/device-identifier.js +43 -5
- package/lib/domain/errors/index.d.ts +1 -0
- package/lib/domain/errors/index.js +17 -0
- package/lib/domain/errors/transaction-rejected-error.d.ts +7 -0
- package/lib/domain/errors/transaction-rejected-error.js +14 -0
- package/lib/domain/models/crosscutting/device-intelligence.d.ts +3 -3
- package/lib/domain/models/crosscutting/device-intelligence.js +3 -3
- package/lib/domain/models/crosscutting/index.d.ts +1 -0
- package/lib/domain/models/crosscutting/index.js +1 -0
- package/lib/domain/models/crosscutting/verisoul.d.ts +19 -0
- package/lib/domain/models/crosscutting/verisoul.js +25 -0
- package/lib/domain/protocols/services/device-fingerprint/navigator.d.ts +1 -0
- package/lib/domain/protocols/services/device-identifier.d.ts +6 -6
- package/lib/domain/protocols/services/interaction-activity.d.ts +25 -0
- package/lib/domain/protocols/services/interaction-activity.js +2 -0
- package/lib/domain/protocols/services/single-script-injection/auto-init.d.ts +8 -8
- package/lib/domain/protocols/services/verisoul.d.ts +23 -0
- package/lib/domain/protocols/services/verisoul.js +2 -0
- package/lib/domain/services/device-fingerprint/index.d.ts +1 -0
- package/lib/domain/services/device-fingerprint/index.js +1 -0
- package/lib/domain/services/device-fingerprint/navigator.d.ts +2 -0
- package/lib/domain/services/device-fingerprint/navigator.js +7 -0
- package/lib/domain/services/device-fingerprint/webgl.d.ts +5 -0
- package/lib/domain/services/device-fingerprint/webgl.js +29 -0
- package/lib/domain/services/dom-mutation-tracker.d.ts +19 -0
- package/lib/domain/services/dom-mutation-tracker.js +47 -0
- package/lib/domain/services/interaction-activity.d.ts +43 -0
- package/lib/domain/services/interaction-activity.js +189 -0
- package/lib/domain/services/single-script-injection/checkpoint-scheduler.d.ts +1 -1
- package/lib/domain/services/single-script-injection/checkpoint-scheduler.js +3 -3
- package/lib/domain/services/single-script-injection/response-delivery.d.ts +3 -3
- package/lib/domain/services/single-script-injection/response-delivery.js +3 -3
- package/lib/domain/services/single-script-injection/schedule-store.d.ts +17 -7
- package/lib/domain/services/single-script-injection/schedule-store.js +11 -10
- package/lib/domain/services/snapshot-store.d.ts +1 -0
- package/lib/domain/services/snapshot-store.js +3 -0
- package/lib/domain/services/translation-detector.d.ts +20 -0
- package/lib/domain/services/translation-detector.js +119 -0
- package/lib/domain/services/verisoul.d.ts +27 -0
- package/lib/domain/services/verisoul.js +96 -0
- package/lib/index.d.ts +2 -1
- package/lib/index.js +2 -2
- package/package.json +1 -1
|
@@ -14,17 +14,18 @@ exports.LOI_CHECKPOINT_FRACTIONS = [1 / 3, 2 / 3, 1, 2];
|
|
|
14
14
|
/** Most calls a transaction can make: the initial one plus each LOI fraction. */
|
|
15
15
|
exports.CHECKPOINT_COUNT = 1 + exports.LOI_CHECKPOINT_FRACTIONS.length;
|
|
16
16
|
/**
|
|
17
|
-
* Owns the `ps_di_schedule_<
|
|
18
|
-
* outstanding on a later page load.
|
|
19
|
-
*
|
|
17
|
+
* Owns the `ps_di_schedule_<projectId>_<supplierId>_<supplierMemberId|emailHash>`
|
|
18
|
+
* record: which checkpoints are still outstanding on a later page load. Parts
|
|
19
|
+
* that were not provided are left out of the key. Storage is injected, so this
|
|
20
|
+
* holds only the schedule rules, not where the record lives.
|
|
20
21
|
*/
|
|
21
22
|
class ScheduleStore {
|
|
22
|
-
constructor(
|
|
23
|
-
this.surveyId = surveyId;
|
|
23
|
+
constructor({ projectId, supplierId, supplierMemberId, emailHash }, storage) {
|
|
24
24
|
this.storage = storage;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
this.projectId = projectId;
|
|
26
|
+
this.key = ['ps_di_schedule', projectId, supplierId, supplierMemberId || emailHash]
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join('_');
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
30
31
|
* Checkpoint 1 fires `INITIAL_CHECKPOINT_DELAY_MS` in regardless; the rest are
|
|
@@ -78,7 +79,7 @@ class ScheduleStore {
|
|
|
78
79
|
const startedAt = this.now();
|
|
79
80
|
const checkpoints = ScheduleStore.buildCheckpoints(startedAt, null);
|
|
80
81
|
const record = {
|
|
81
|
-
|
|
82
|
+
projectId: this.projectId,
|
|
82
83
|
loi: null,
|
|
83
84
|
startedAt,
|
|
84
85
|
checkpoints,
|
|
@@ -119,7 +120,7 @@ class ScheduleStore {
|
|
|
119
120
|
return record;
|
|
120
121
|
}
|
|
121
122
|
/**
|
|
122
|
-
* Stores the latest
|
|
123
|
+
* Stores the latest PureScore-Intelligence response alongside the schedule so it
|
|
123
124
|
* can be replayed on the next page load.
|
|
124
125
|
*/
|
|
125
126
|
storeResponse(response) {
|
|
@@ -14,6 +14,7 @@ export declare class SnapshotStore<T extends object> {
|
|
|
14
14
|
constructor(key: string, getData: () => T, storage: GetStorage & SetStorage & RemoveStorage, intervalMs?: number);
|
|
15
15
|
start(): void;
|
|
16
16
|
stop(): void;
|
|
17
|
+
save(): void;
|
|
17
18
|
read(): T | null;
|
|
18
19
|
clear(): void;
|
|
19
20
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface TranslationListener {
|
|
2
|
+
onDetected: () => void;
|
|
3
|
+
onSample: (sample: string) => void;
|
|
4
|
+
}
|
|
5
|
+
export declare class TranslationDetector {
|
|
6
|
+
private attributeObserver;
|
|
7
|
+
private textObserver;
|
|
8
|
+
private initialLang;
|
|
9
|
+
private markSeen;
|
|
10
|
+
private detected;
|
|
11
|
+
private sample;
|
|
12
|
+
start(listener: TranslationListener): void;
|
|
13
|
+
stop(): void;
|
|
14
|
+
private handleAttributes;
|
|
15
|
+
private isTranslationMark;
|
|
16
|
+
private handleText;
|
|
17
|
+
private translatedLength;
|
|
18
|
+
private extendSample;
|
|
19
|
+
private readableText;
|
|
20
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TranslationDetector = void 0;
|
|
4
|
+
const MIN_TRANSLATED_TEXT_LENGTH = 4;
|
|
5
|
+
const MAX_TRANSLATED_TEXT_LENGTH = 256;
|
|
6
|
+
const GOOGLE_CLASS_PREFIX = 'translate';
|
|
7
|
+
const GOOGLE_CLASS_MARK = 'translated';
|
|
8
|
+
const EDGE_ATTRIBUTES = new Set(['_msthash', '_msttexthash']);
|
|
9
|
+
const NON_CONTENT_PARENTS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE']);
|
|
10
|
+
const TAG_LIKE = /<[^>]*>/g;
|
|
11
|
+
const WHITESPACE = /\s+/g;
|
|
12
|
+
class TranslationDetector {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.attributeObserver = null;
|
|
15
|
+
this.textObserver = null;
|
|
16
|
+
this.initialLang = null;
|
|
17
|
+
this.markSeen = false;
|
|
18
|
+
this.detected = false;
|
|
19
|
+
this.sample = '';
|
|
20
|
+
}
|
|
21
|
+
start(listener) {
|
|
22
|
+
if (this.attributeObserver || typeof document === 'undefined' || typeof MutationObserver === 'undefined')
|
|
23
|
+
return;
|
|
24
|
+
const root = document.documentElement;
|
|
25
|
+
if (!root)
|
|
26
|
+
return;
|
|
27
|
+
const targets = [root];
|
|
28
|
+
const title = document.getElementsByTagName('title')[0];
|
|
29
|
+
if (title)
|
|
30
|
+
targets.push(title);
|
|
31
|
+
this.initialLang = root.getAttribute('lang');
|
|
32
|
+
this.markSeen = false;
|
|
33
|
+
this.detected = false;
|
|
34
|
+
this.sample = '';
|
|
35
|
+
this.attributeObserver = new MutationObserver(mutations => this.handleAttributes(mutations));
|
|
36
|
+
this.textObserver = new MutationObserver(mutations => this.handleText(mutations, listener));
|
|
37
|
+
for (const target of targets) {
|
|
38
|
+
this.attributeObserver.observe(target, { attributes: true, attributeOldValue: true });
|
|
39
|
+
this.textObserver.observe(target, { characterData: true, characterDataOldValue: true, subtree: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
var _a, _b;
|
|
44
|
+
(_a = this.attributeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
|
|
45
|
+
(_b = this.textObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
|
|
46
|
+
this.attributeObserver = null;
|
|
47
|
+
this.textObserver = null;
|
|
48
|
+
this.markSeen = false;
|
|
49
|
+
}
|
|
50
|
+
handleAttributes(mutations) {
|
|
51
|
+
for (const mutation of mutations) {
|
|
52
|
+
if (mutation.type === 'attributes' && this.isTranslationMark(mutation)) {
|
|
53
|
+
this.markSeen = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
isTranslationMark(mutation) {
|
|
59
|
+
const target = mutation.target;
|
|
60
|
+
if (!(target instanceof HTMLElement))
|
|
61
|
+
return false;
|
|
62
|
+
const name = mutation.attributeName;
|
|
63
|
+
const className = typeof target.className === 'string' ? target.className : '';
|
|
64
|
+
const google = name === 'class' && (className.startsWith(GOOGLE_CLASS_PREFIX) || className.includes(GOOGLE_CLASS_MARK));
|
|
65
|
+
const edge = name !== null && EDGE_ATTRIBUTES.has(name);
|
|
66
|
+
const safari = name === 'lang' && target === document.documentElement && target.getAttribute('lang') !== this.initialLang;
|
|
67
|
+
return google || edge || safari;
|
|
68
|
+
}
|
|
69
|
+
handleText(mutations, listener) {
|
|
70
|
+
var _a;
|
|
71
|
+
if (!this.markSeen)
|
|
72
|
+
return;
|
|
73
|
+
const changed = mutations.filter(mutation => mutation.target instanceof CharacterData && mutation.oldValue !== mutation.target.data);
|
|
74
|
+
if (!this.detected && this.translatedLength(changed) > MIN_TRANSLATED_TEXT_LENGTH) {
|
|
75
|
+
this.detected = true;
|
|
76
|
+
(_a = this.attributeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
|
|
77
|
+
listener.onDetected();
|
|
78
|
+
}
|
|
79
|
+
if (!this.detected)
|
|
80
|
+
return;
|
|
81
|
+
const sample = this.extendSample(changed);
|
|
82
|
+
if (sample !== this.sample) {
|
|
83
|
+
this.sample = sample;
|
|
84
|
+
listener.onSample(sample);
|
|
85
|
+
}
|
|
86
|
+
if (this.sample.length >= MAX_TRANSLATED_TEXT_LENGTH)
|
|
87
|
+
this.stop();
|
|
88
|
+
}
|
|
89
|
+
translatedLength(changed) {
|
|
90
|
+
const texts = [];
|
|
91
|
+
let length = 0;
|
|
92
|
+
for (const mutation of changed) {
|
|
93
|
+
const data = mutation.target.data;
|
|
94
|
+
texts.push(data);
|
|
95
|
+
length += data.length;
|
|
96
|
+
if (length >= MAX_TRANSLATED_TEXT_LENGTH)
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
return texts.join(' ').length;
|
|
100
|
+
}
|
|
101
|
+
extendSample(changed) {
|
|
102
|
+
const parts = this.sample ? [this.sample] : [];
|
|
103
|
+
for (const mutation of changed) {
|
|
104
|
+
const text = this.readableText(mutation.target);
|
|
105
|
+
if (text)
|
|
106
|
+
parts.push(text);
|
|
107
|
+
}
|
|
108
|
+
return parts.join(' ').slice(0, MAX_TRANSLATED_TEXT_LENGTH);
|
|
109
|
+
}
|
|
110
|
+
readableText(node) {
|
|
111
|
+
var _a, _b, _c;
|
|
112
|
+
if (node.nodeType !== Node.TEXT_NODE)
|
|
113
|
+
return '';
|
|
114
|
+
if (NON_CONTENT_PARENTS.has((_b = (_a = node.parentElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ''))
|
|
115
|
+
return '';
|
|
116
|
+
return ((_c = node.textContent) !== null && _c !== void 0 ? _c : '').replace(TAG_LIKE, ' ').replace(WHITESPACE, ' ').trim();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
exports.TranslationDetector = TranslationDetector;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { VerisoulEnvConfig } from '../models/crosscutting';
|
|
2
|
+
import { VerisoulSessionProvider } from '../protocols/services/verisoul';
|
|
3
|
+
/**
|
|
4
|
+
* How long to wait for the SDK to load, and then for it to hand back a session,
|
|
5
|
+
* before giving up. Bounded so an ad-blocked or slow SDK only costs the first
|
|
6
|
+
* checkpoint a few seconds instead of stalling it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const VERISOUL_TIMEOUT_MS = 5000;
|
|
9
|
+
/**
|
|
10
|
+
* Loads the Verisoul SDK into the page and reads the session id off it.
|
|
11
|
+
*
|
|
12
|
+
* The script is injected once per page: a tag already carrying the same `src`
|
|
13
|
+
* (from an earlier bundle instance, or the page itself) is reused rather than
|
|
14
|
+
* duplicated.
|
|
15
|
+
*/
|
|
16
|
+
export declare class VerisoulService implements VerisoulSessionProvider {
|
|
17
|
+
private readonly config;
|
|
18
|
+
private readonly timeoutMs;
|
|
19
|
+
private ready?;
|
|
20
|
+
constructor(config: VerisoulEnvConfig, timeoutMs?: number);
|
|
21
|
+
/** Starts loading the SDK. Safe to call more than once. */
|
|
22
|
+
load(): void;
|
|
23
|
+
getSessionId(): Promise<string | undefined>;
|
|
24
|
+
private sdk;
|
|
25
|
+
private inject;
|
|
26
|
+
private withTimeout;
|
|
27
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.VerisoulService = exports.VERISOUL_TIMEOUT_MS = void 0;
|
|
13
|
+
/**
|
|
14
|
+
* How long to wait for the SDK to load, and then for it to hand back a session,
|
|
15
|
+
* before giving up. Bounded so an ad-blocked or slow SDK only costs the first
|
|
16
|
+
* checkpoint a few seconds instead of stalling it.
|
|
17
|
+
*/
|
|
18
|
+
exports.VERISOUL_TIMEOUT_MS = 5000;
|
|
19
|
+
/**
|
|
20
|
+
* Loads the Verisoul SDK into the page and reads the session id off it.
|
|
21
|
+
*
|
|
22
|
+
* The script is injected once per page: a tag already carrying the same `src`
|
|
23
|
+
* (from an earlier bundle instance, or the page itself) is reused rather than
|
|
24
|
+
* duplicated.
|
|
25
|
+
*/
|
|
26
|
+
class VerisoulService {
|
|
27
|
+
constructor(config, timeoutMs = exports.VERISOUL_TIMEOUT_MS) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
}
|
|
31
|
+
/** Starts loading the SDK. Safe to call more than once. */
|
|
32
|
+
load() {
|
|
33
|
+
this.sdk();
|
|
34
|
+
}
|
|
35
|
+
getSessionId() {
|
|
36
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
37
|
+
try {
|
|
38
|
+
const verisoul = yield this.sdk();
|
|
39
|
+
if (!verisoul)
|
|
40
|
+
return undefined;
|
|
41
|
+
const session = yield this.withTimeout(verisoul.session());
|
|
42
|
+
const sessionId = session === null || session === void 0 ? void 0 : session.session_id;
|
|
43
|
+
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
console.error('Error getting Verisoul session id:', error);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
sdk() {
|
|
52
|
+
if (!this.ready) {
|
|
53
|
+
this.ready = this.withTimeout(this.inject()).catch(error => {
|
|
54
|
+
console.error('Error loading Verisoul SDK:', error);
|
|
55
|
+
// Let a later call try again rather than caching the failure for the page.
|
|
56
|
+
this.ready = undefined;
|
|
57
|
+
return undefined;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return this.ready;
|
|
61
|
+
}
|
|
62
|
+
inject() {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
65
|
+
reject(new Error('Verisoul needs a browser environment'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (window.Verisoul) {
|
|
69
|
+
resolve(window.Verisoul);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let script = document.querySelector(`script[src="${this.config.sdkUrl}"]`);
|
|
73
|
+
if (!script) {
|
|
74
|
+
script = document.createElement('script');
|
|
75
|
+
script.src = this.config.sdkUrl;
|
|
76
|
+
script.async = true;
|
|
77
|
+
script.setAttribute('verisoul-project-id', this.config.projectId);
|
|
78
|
+
(document.head || document.documentElement).appendChild(script);
|
|
79
|
+
}
|
|
80
|
+
script.addEventListener('load', () => {
|
|
81
|
+
if (window.Verisoul)
|
|
82
|
+
resolve(window.Verisoul);
|
|
83
|
+
else
|
|
84
|
+
reject(new Error('Verisoul SDK loaded but window.Verisoul is missing'));
|
|
85
|
+
});
|
|
86
|
+
script.addEventListener('error', () => reject(new Error('Failed to load the Verisoul SDK')));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
withTimeout(promise) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timer = setTimeout(() => reject(new Error(`Verisoul timed out after ${this.timeoutMs}ms`)), this.timeoutMs);
|
|
92
|
+
promise.then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.VerisoulService = VerisoulService;
|
package/lib/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Two responsibilities and nothing else: re-export what consumers are allowed to
|
|
5
5
|
* reach, and run the self-initializing script tag. Everything with behaviour
|
|
6
|
-
* lives behind a layer — `application` for the
|
|
6
|
+
* lives behind a layer — `application` for the Purescore-Client and the
|
|
7
7
|
* bootstrap that drives it, `domain` for the services they are built from,
|
|
8
8
|
* `validation` for the rules.
|
|
9
9
|
*/
|
|
@@ -17,3 +17,4 @@ export { DeviceIdentifier };
|
|
|
17
17
|
export type { DeviceIdentifierOptions, DeviceIdentifierConfig, DeviceIdentifierMetadata, TrackDetails, TrackDetailsContext, TransactionDetails, CreateTransaction, } from './domain/protocols/services/device-identifier';
|
|
18
18
|
export type { DisplayChecks, DisplayChecksInput, Language, NavigatorUABrandVersion, UADataValues, NavigatorUAData, NetworkInformation, UAParserDeviceType, NavigationTiming, NetworkTimes, } from './domain/protocols/services/device-fingerprint';
|
|
19
19
|
export type { AutoInitConfig, ScheduleRecord, ScheduleCheckpoint, } from './domain/protocols/services/single-script-injection';
|
|
20
|
+
export type { ClipboardActivityType, ClipboardActivity, TabSwitch, InteractionActivity, } from './domain/protocols/services/interaction-activity';
|
package/lib/index.js
CHANGED
|
@@ -6,7 +6,7 @@ exports.DeviceIdentifier = void 0;
|
|
|
6
6
|
*
|
|
7
7
|
* Two responsibilities and nothing else: re-export what consumers are allowed to
|
|
8
8
|
* reach, and run the self-initializing script tag. Everything with behaviour
|
|
9
|
-
* lives behind a layer — `application` for the
|
|
9
|
+
* lives behind a layer — `application` for the Purescore-Client and the
|
|
10
10
|
* bootstrap that drives it, `domain` for the services they are built from,
|
|
11
11
|
* `validation` for the rules.
|
|
12
12
|
*/
|
|
@@ -18,7 +18,7 @@ const auto_init_1 = require("./application/auto-init");
|
|
|
18
18
|
*
|
|
19
19
|
* When the tag that loaded this bundle carries `data-auto-init="true"`, the
|
|
20
20
|
* bundle bootstraps itself from its own `data-*` attributes and drives the three
|
|
21
|
-
*
|
|
21
|
+
* PureScore-Intelligence calls off an LOI-derived schedule — the buyer writes no
|
|
22
22
|
* bootstrap code and no `trackDetails` call at all.
|
|
23
23
|
*
|
|
24
24
|
* Without that attribute this is a no-op, which is what keeps the existing
|