@sv443-network/userutils 6.2.0 → 7.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/CHANGELOG.md +24 -0
- package/README.md +330 -139
- package/dist/index.global.js +253 -72
- package/dist/index.js +251 -70
- package/dist/index.mjs +250 -70
- package/dist/lib/DataStore.d.ts +46 -17
- package/dist/lib/DataStoreSerializer.d.ts +44 -0
- package/dist/lib/SelectorObserver.d.ts +10 -5
- package/dist/lib/dom.d.ts +8 -16
- package/dist/lib/index.d.ts +9 -7
- package/dist/lib/math.d.ts +0 -8
- package/dist/lib/misc.d.ts +19 -18
- package/dist/lib/translation.d.ts +1 -1
- package/dist/lib/types.d.ts +17 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- #MARKER Description -->
|
|
4
4
|
## UserUtils
|
|
5
|
-
Zero-dependency library with various utilities for userscripts - register listeners for when CSS selectors exist, intercept events,
|
|
5
|
+
Zero-dependency library with various utilities for userscripts - register listeners for when CSS selectors exist, intercept events, create persistent & synchronous data stores, modify the DOM more easily and more.
|
|
6
6
|
|
|
7
7
|
Contains builtin TypeScript declarations. Fully web compatible and supports ESM and CJS imports and global declaration.
|
|
8
8
|
If you like using this library, please consider [supporting the development ❤️](https://github.com/sponsors/Sv443)
|
|
@@ -26,47 +26,48 @@ View the documentation of previous major releases:
|
|
|
26
26
|
- [**License**](#license)
|
|
27
27
|
- [**Features**](#features)
|
|
28
28
|
- [**DOM:**](#dom)
|
|
29
|
-
- [SelectorObserver](#selectorobserver) - class that manages listeners that are called when selectors are found in the DOM
|
|
30
|
-
- [getUnsafeWindow()](#getunsafewindow) - get the unsafeWindow object or fall back to the regular window object
|
|
31
|
-
- [
|
|
32
|
-
- [
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
- [
|
|
36
|
-
- [
|
|
37
|
-
- [
|
|
38
|
-
- [
|
|
39
|
-
- [
|
|
40
|
-
- [getSiblingsFrame()](#getsiblingsframe) - returns a frame of an element's siblings, with a given alignment and size
|
|
29
|
+
- [`SelectorObserver`](#selectorobserver) - class that manages listeners that are called when selectors are found in the DOM
|
|
30
|
+
- [`getUnsafeWindow()`](#getunsafewindow) - get the unsafeWindow object or fall back to the regular window object
|
|
31
|
+
- [`addParent()`](#addparent) - add a parent element around another element
|
|
32
|
+
- [`addGlobalStyle()`](#addglobalstyle) - add a global style to the page
|
|
33
|
+
- [`preloadImages()`](#preloadimages) - preload images into the browser cache for faster loading later on
|
|
34
|
+
- [`openInNewTab()`](#openinnewtab) - open a link in a new tab
|
|
35
|
+
- [`interceptEvent()`](#interceptevent) - conditionally intercepts events registered by `addEventListener()` on any given EventTarget object
|
|
36
|
+
- [`interceptWindowEvent()`](#interceptwindowevent) - conditionally intercepts events registered by `addEventListener()` on the window object
|
|
37
|
+
- [`isScrollable()`](#isscrollable) - check if an element has a horizontal or vertical scroll bar
|
|
38
|
+
- [`observeElementProp()`](#observeelementprop) - observe changes to an element's property that can't be observed with MutationObserver
|
|
39
|
+
- [`getSiblingsFrame()`](#getsiblingsframe) - returns a frame of an element's siblings, with a given alignment and size
|
|
41
40
|
- [**Math:**](#math)
|
|
42
|
-
- [clamp()](#clamp) - constrain a number between a min and max value
|
|
43
|
-
- [mapRange()](#maprange) - map a number from one range to the same spot in another range
|
|
44
|
-
- [randRange()](#randrange) - generate a random number between a min and max boundary
|
|
45
|
-
- [randomId()](#randomid) - generate a random ID of a given length and radix
|
|
41
|
+
- [`clamp()`](#clamp) - constrain a number between a min and max value
|
|
42
|
+
- [`mapRange()`](#maprange) - map a number from one range to the same spot in another range
|
|
43
|
+
- [`randRange()`](#randrange) - generate a random number between a min and max boundary
|
|
46
44
|
- [**Misc:**](#misc)
|
|
47
|
-
- [DataStore](#datastore) - class that manages a sync & async persistent JSON database, including data migration
|
|
48
|
-
- [
|
|
49
|
-
- [
|
|
50
|
-
- [
|
|
51
|
-
- [
|
|
52
|
-
- [
|
|
53
|
-
- [
|
|
54
|
-
- [
|
|
45
|
+
- [`DataStore`](#datastore) - class that manages a hybrid sync & async persistent JSON database, including data migration
|
|
46
|
+
- [`DataStoreSerializer`](#datastoreserializer) - class for importing & exporting data of multiple DataStore instances, including compression, checksumming and running migrations
|
|
47
|
+
- [`autoPlural()`](#autoplural) - automatically pluralize a string
|
|
48
|
+
- [`pauseFor()`](#pausefor) - pause the execution of a function for a given amount of time
|
|
49
|
+
- [`debounce()`](#debounce) - call a function only once in a series of calls, after or before a given timeout
|
|
50
|
+
- [`fetchAdvanced()`](#fetchadvanced) - wrapper around the fetch API with a timeout option
|
|
51
|
+
- [`insertValues()`](#insertvalues) - insert values into a string at specified placeholders
|
|
52
|
+
- [`compress()`](#compress) - compress a string with Gzip or Deflate
|
|
53
|
+
- [`decompress()`](#decompress) - decompress a previously compressed string
|
|
54
|
+
- [`computeHash()`](#computehash) - compute the hash / checksum of a string or ArrayBuffer
|
|
55
|
+
- [`randomId()`](#randomid) - generate a random ID of a given length and radix
|
|
55
56
|
- [**Arrays:**](#arrays)
|
|
56
|
-
- [randomItem()](#randomitem) - returns a random item from an array
|
|
57
|
-
- [randomItemIndex()](#randomitemindex) - returns a tuple of a random item and its index from an array
|
|
58
|
-
- [takeRandomItem()](#takerandomitem) - returns a random item from an array and mutates it to remove the item
|
|
59
|
-
- [randomizeArray()](#randomizearray) - returns a copy of the array with its items in a random order
|
|
57
|
+
- [`randomItem()`](#randomitem) - returns a random item from an array
|
|
58
|
+
- [`randomItemIndex()`](#randomitemindex) - returns a tuple of a random item and its index from an array
|
|
59
|
+
- [`takeRandomItem()`](#takerandomitem) - returns a random item from an array and mutates it to remove the item
|
|
60
|
+
- [`randomizeArray()`](#randomizearray) - returns a copy of the array with its items in a random order
|
|
60
61
|
- [**Translation:**](#translation)
|
|
61
|
-
- [tr()](#tr) - simple translation of a string to another language
|
|
62
|
+
- [`tr()`](#tr) - simple translation of a string to another language
|
|
62
63
|
- [tr.addLanguage()](#traddlanguage) - add a language and its translations
|
|
63
64
|
- [tr.setLanguage()](#trsetlanguage) - set the currently active language for translations
|
|
64
65
|
- [tr.getLanguage()](#trgetlanguage) - returns the currently active language
|
|
65
66
|
- [**Utility types for TypeScript:**](#utility-types)
|
|
66
|
-
- [Stringifiable](#stringifiable) - any value that is a string or can be converted to one (implicitly or explicitly)
|
|
67
|
-
- [NonEmptyArray](#nonemptyarray) - any array that should have at least one item
|
|
68
|
-
- [NonEmptyString](#nonemptystring) - any string that should have at least one character
|
|
69
|
-
- [LooseUnion](#looseunion) - a union that gives autocomplete in the IDE but also allows any other value of the same type
|
|
67
|
+
- [`Stringifiable`](#stringifiable) - any value that is a string or can be converted to one (implicitly or explicitly)
|
|
68
|
+
- [`NonEmptyArray`](#nonemptyarray) - any array that should have at least one item
|
|
69
|
+
- [`NonEmptyString`](#nonemptystring) - any string that should have at least one character
|
|
70
|
+
- [`LooseUnion`](#looseunion) - a union that gives autocomplete in the IDE but also allows any other value of the same type
|
|
70
71
|
|
|
71
72
|
<br><br>
|
|
72
73
|
|
|
@@ -80,7 +81,7 @@ View the documentation of previous major releases:
|
|
|
80
81
|
```ts
|
|
81
82
|
import { addGlobalStyle } from "@sv443-network/userutils";
|
|
82
83
|
|
|
83
|
-
// or just import everything (not recommended because
|
|
84
|
+
// or just import everything (not recommended because of worse treeshaking support):
|
|
84
85
|
|
|
85
86
|
import * as UserUtils from "@sv443-network/userutils";
|
|
86
87
|
```
|
|
@@ -145,12 +146,13 @@ new SelectorObserver(baseElementSelector: string, options?: SelectorObserverOpti
|
|
|
145
146
|
```
|
|
146
147
|
|
|
147
148
|
A class that manages listeners that are called when elements at given selectors are found in the DOM.
|
|
148
|
-
|
|
149
|
+
It is useful for userscripts that need to wait for elements to be added to the DOM at an indeterminate point in time before they can be interacted with.
|
|
150
|
+
By default, it uses the MutationObserver API to observe for any element changes, and as such is highly customizable, but can also be configured to run on a fixed interval.
|
|
149
151
|
|
|
150
152
|
The constructor takes a `baseElement`, which is a parent of the elements you want to observe.
|
|
151
153
|
If a selector string is passed instead, it will be used to find the element.
|
|
152
|
-
If you want to observe the entire document, you can pass `document.body`
|
|
153
|
-
|
|
154
|
+
If you want to observe the entire document, you can pass `document.body` - ⚠️ you should only use this to initialize other SelectorObserver instances, and never run continuous listeners on this instance, as the performance impact can be massive!
|
|
155
|
+
|
|
154
156
|
The `options` parameter is optional and will be passed to the MutationObserver that is used internally.
|
|
155
157
|
The MutationObserver options present by default are `{ childList: true, subtree: true }` - you may see the [MutationObserver.observe() documentation](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options) for more information and a list of options.
|
|
156
158
|
For example, if you want to trigger the listeners when certain attributes change, pass `{ attributeFilter: ["class", "data-my-attribute"] }`
|
|
@@ -159,6 +161,8 @@ Additionally, there are the following extra options:
|
|
|
159
161
|
- `disableOnNoListeners` - whether to disable the SelectorObserver when there are no listeners left (defaults to false)
|
|
160
162
|
- `enableOnAddListener` - whether to enable the SelectorObserver when a new listener is added (defaults to true)
|
|
161
163
|
- `defaultDebounce` - if set to a number, this debounce will be applied to every listener that doesn't have a custom debounce set (defaults to 0)
|
|
164
|
+
- `defaultDebounceEdge` - can be set to "falling" (default) or "rising", to call the function at (rising) on the very first call and subsequent times after the given debounce time or (falling) the very last call after the debounce time passed with no new calls - [see `debounce()` for more info and a diagram](#debounce)
|
|
165
|
+
- `checkInterval` - if set to a number, the checks will be run on interval instead of on mutation events - in that case all MutationObserverInit props will be ignored
|
|
162
166
|
|
|
163
167
|
⚠️ Make sure to call `enable()` to actually start observing. This will need to be done after the DOM has loaded (when using `@run-at document-end` or after `DOMContentLoaded` has fired) **and** as soon as the `baseElement` or `baseElementSelector` is available.
|
|
164
168
|
|
|
@@ -177,16 +181,19 @@ The listener will be called immediately if the selector already exists in the DO
|
|
|
177
181
|
> This will also include elements that were already found in a previous listener call.
|
|
178
182
|
> If set to false (default), querySelector() will be used and only the first matching element will be returned.
|
|
179
183
|
|
|
180
|
-
> If `options.continuous` is set to true,
|
|
184
|
+
> If `options.continuous` is set to true, this listener will not be deregistered after it was called once (defaults to false).
|
|
181
185
|
>
|
|
182
|
-
> ⚠️ You should keep usage of this option to a minimum, as it will cause
|
|
186
|
+
> ⚠️ You should keep usage of this option to a minimum, as it will cause this listener to be called every time the selector is *checked for and found* and this can stack up quite quickly.
|
|
183
187
|
> ⚠️ You should try to only use this option on SelectorObserver instances that are scoped really low in the DOM tree to prevent as many selector checks as possible from being triggered.
|
|
184
188
|
> ⚠️ I also recommend always setting a debounce time (see constructor or below) if you use this option.
|
|
185
189
|
|
|
186
|
-
> If `options.debounce` is set to a number above 0,
|
|
187
|
-
> E.g. if the debounce time is set to 200 and the selector is found twice within 100ms, only the last call of
|
|
190
|
+
> If `options.debounce` is set to a number above 0, this listener will be debounced by that amount of milliseconds (defaults to 0).
|
|
191
|
+
> E.g. if the debounce time is set to 200 and the selector is found twice within 100ms, only the last call of this listener will be executed.
|
|
192
|
+
|
|
193
|
+
> `options.debounceEdge` is set to "falling" by default, which means the debounce timer will start after the last call of this listener.
|
|
194
|
+
> If set to "rising", the debounce timer will start after the first call of this listener.
|
|
188
195
|
|
|
189
|
-
> When using TypeScript, the generic `TElement` can be used to specify the type of the element(s) that
|
|
196
|
+
> When using TypeScript, the generic `TElement` can be used to specify the type of the element(s) that this listener will return.
|
|
190
197
|
> It will default to HTMLElement if left undefined.
|
|
191
198
|
|
|
192
199
|
<br>
|
|
@@ -262,6 +269,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
262
269
|
attributeFilter: ["class", "style", "data-whatever"],
|
|
263
270
|
// debounce all listeners by 100ms unless specified otherwise:
|
|
264
271
|
defaultDebounce: 100,
|
|
272
|
+
// "rising" means listeners are called immediately and use the debounce as a timeout between subsequent calls - see the debounce() function for a better explanation
|
|
273
|
+
defaultDebounceEdge: "rising",
|
|
274
|
+
// other settings from the MutationObserver API can be set here too - see https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options
|
|
265
275
|
});
|
|
266
276
|
|
|
267
277
|
barObserver.addListener("#my-element", {
|
|
@@ -273,6 +283,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
273
283
|
barObserver.addListener("#my-other-element", {
|
|
274
284
|
// set the debounce higher than provided by the defaultDebounce property:
|
|
275
285
|
debounce: 250,
|
|
286
|
+
// adjust the debounceEdge back to the default "falling" for this specific listener:
|
|
287
|
+
debounceEdge: "falling",
|
|
276
288
|
listener: (element) => {
|
|
277
289
|
console.log("Other element's attributes changed:", element);
|
|
278
290
|
},
|
|
@@ -286,7 +298,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
286
298
|
const bazObserver = new SelectorObserver(document.body);
|
|
287
299
|
|
|
288
300
|
// for TypeScript, specify that input elements are returned by the listener:
|
|
289
|
-
bazObserver.addListener<HTMLInputElement>("input", {
|
|
301
|
+
const unsubscribe = bazObserver.addListener<HTMLInputElement>("input", {
|
|
290
302
|
all: true, // use querySelectorAll() instead of querySelector()
|
|
291
303
|
continuous: true, // don't remove the listener after it was called once
|
|
292
304
|
debounce: 50, // debounce the listener by 50ms
|
|
@@ -298,6 +310,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
298
310
|
|
|
299
311
|
bazObserver.enable();
|
|
300
312
|
|
|
313
|
+
window.addEventListener("something", () => {
|
|
314
|
+
// remove the listener after the event "something" was dispatched:
|
|
315
|
+
unsubscribe();
|
|
316
|
+
});
|
|
317
|
+
|
|
301
318
|
|
|
302
319
|
// use a different element as the base:
|
|
303
320
|
|
|
@@ -431,7 +448,7 @@ getUnsafeWindow(): Window
|
|
|
431
448
|
```
|
|
432
449
|
|
|
433
450
|
Returns the unsafeWindow object or falls back to the regular window object if the `@grant unsafeWindow` is not given.
|
|
434
|
-
Userscripts are sandboxed and do not have access to the regular window object, so this function is useful for websites that reject some events that were dispatched by the userscript.
|
|
451
|
+
Userscripts are sandboxed and do not have access to the regular window object, so this function is useful for websites that reject some events that were dispatched by the userscript, or userscripts that need to interact with other userscripts, and more.
|
|
435
452
|
|
|
436
453
|
<details><summary><b>Example - click to view</b></summary>
|
|
437
454
|
|
|
@@ -454,34 +471,6 @@ document.body.dispatchEvent(mouseEvent);
|
|
|
454
471
|
|
|
455
472
|
<br>
|
|
456
473
|
|
|
457
|
-
### insertAfter()
|
|
458
|
-
Usage:
|
|
459
|
-
```ts
|
|
460
|
-
insertAfter(beforeElement: Element, afterElement: Element): Element
|
|
461
|
-
```
|
|
462
|
-
|
|
463
|
-
Inserts the element passed as `afterElement` as a sibling after the passed `beforeElement`.
|
|
464
|
-
The passed `afterElement` will be returned.
|
|
465
|
-
|
|
466
|
-
⚠️ This function needs to be run after the DOM has loaded (when using `@run-at document-end` or after `DOMContentLoaded` has fired).
|
|
467
|
-
|
|
468
|
-
<details><summary><b>Example - click to view</b></summary>
|
|
469
|
-
|
|
470
|
-
```ts
|
|
471
|
-
import { insertAfter } from "@sv443-network/userutils";
|
|
472
|
-
|
|
473
|
-
// insert a <div> as a sibling next to an element
|
|
474
|
-
const beforeElement = document.querySelector("#before");
|
|
475
|
-
const afterElement = document.createElement("div");
|
|
476
|
-
afterElement.innerText = "After";
|
|
477
|
-
|
|
478
|
-
insertAfter(beforeElement, afterElement);
|
|
479
|
-
```
|
|
480
|
-
|
|
481
|
-
</details>
|
|
482
|
-
|
|
483
|
-
<br>
|
|
484
|
-
|
|
485
474
|
### addParent()
|
|
486
475
|
Usage:
|
|
487
476
|
```ts
|
|
@@ -541,7 +530,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
541
530
|
### preloadImages()
|
|
542
531
|
Usage:
|
|
543
532
|
```ts
|
|
544
|
-
preloadImages(urls: string[], rejects?: boolean): Promise<
|
|
533
|
+
preloadImages(urls: string[], rejects?: boolean): Promise<Array<PromiseSettledResult<HTMLImageElement>>>
|
|
545
534
|
```
|
|
546
535
|
|
|
547
536
|
Preloads images into browser cache by creating an invisible `<img>` element for each URL passed.
|
|
@@ -573,14 +562,14 @@ preloadImages([
|
|
|
573
562
|
### openInNewTab()
|
|
574
563
|
Usage:
|
|
575
564
|
```ts
|
|
576
|
-
openInNewTab(url: string): void
|
|
565
|
+
openInNewTab(url: string, background?: boolean): void
|
|
577
566
|
```
|
|
578
567
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
This function has to be run in response to a user interaction event, else the browser might reject it.
|
|
568
|
+
Tries to use `GM.openInTab` to open the given URL in a new tab, or as a fallback if the grant is not given, creates an invisible anchor element and clicks it.
|
|
569
|
+
If `background` is set to true, the tab will be opened in the background. Leave `undefined` to use the browser's default behavior.
|
|
582
570
|
|
|
583
|
-
⚠️
|
|
571
|
+
⚠️ Needs the `@grant GM.openInTab` directive, otherwise only the fallback behavior will be used and the warning below is extra important:
|
|
572
|
+
⚠️ For the fallback to work, this function needs to be run in response to a user interaction event, else the browser might reject it.
|
|
584
573
|
|
|
585
574
|
<details><summary><b>Example - click to view</b></summary>
|
|
586
575
|
|
|
@@ -588,7 +577,8 @@ This function has to be run in response to a user interaction event, else the br
|
|
|
588
577
|
import { openInNewTab } from "@sv443-network/userutils";
|
|
589
578
|
|
|
590
579
|
document.querySelector("#my-button").addEventListener("click", () => {
|
|
591
|
-
|
|
580
|
+
// open in background:
|
|
581
|
+
openInNewTab("https://example.org/", true);
|
|
592
582
|
});
|
|
593
583
|
```
|
|
594
584
|
|
|
@@ -945,36 +935,6 @@ randRange(10); // 7
|
|
|
945
935
|
|
|
946
936
|
</details>
|
|
947
937
|
|
|
948
|
-
<br>
|
|
949
|
-
|
|
950
|
-
### randomId()
|
|
951
|
-
Usage:
|
|
952
|
-
```ts
|
|
953
|
-
randomId(length?: number, radix?: number): string
|
|
954
|
-
```
|
|
955
|
-
|
|
956
|
-
Generates a cryptographically strong random ID of a given length and [radix (base).](https://en.wikipedia.org/wiki/Radix)
|
|
957
|
-
Uses the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) for generating the random numbers.
|
|
958
|
-
⚠️ This is not intended for generating encryption keys, only for generating IDs with a decent amount of entropy!
|
|
959
|
-
|
|
960
|
-
The default length is 16 and the default radix is 16 (hexadecimal).
|
|
961
|
-
You may change the radix to get digits from different numerical systems.
|
|
962
|
-
Use 2 for binary, 8 for octal, 10 for decimal, 16 for hexadecimal and 36 for alphanumeric.
|
|
963
|
-
|
|
964
|
-
<details><summary><b>Example - click to view</b></summary>
|
|
965
|
-
|
|
966
|
-
```ts
|
|
967
|
-
import { randomId } from "@sv443-network/userutils";
|
|
968
|
-
|
|
969
|
-
randomId(); // "1bda419a73629d4f" (length 16, radix 16)
|
|
970
|
-
randomId(10); // "f86cd354a4" (length 10, radix 16)
|
|
971
|
-
randomId(10, 2); // "1010001101" (length 10, radix 2)
|
|
972
|
-
randomId(10, 10); // "0183428506" (length 10, radix 10)
|
|
973
|
-
randomId(10, 36); // "z46jfpa37r" (length 10, radix 36)
|
|
974
|
-
```
|
|
975
|
-
|
|
976
|
-
</details>
|
|
977
|
-
|
|
978
938
|
<br><br>
|
|
979
939
|
|
|
980
940
|
<!-- #SECTION Misc -->
|
|
@@ -986,12 +946,17 @@ Usage:
|
|
|
986
946
|
new DataStore(options: DataStoreOptions)
|
|
987
947
|
```
|
|
988
948
|
|
|
989
|
-
A class that manages a sync & async JSON database that is persistently saved to and loaded from GM storage.
|
|
949
|
+
A class that manages a sync & async JSON database that is persistently saved to and loaded from GM storage, localStorage or sessionStorage.
|
|
990
950
|
Also supports automatic migration of outdated data formats via provided migration functions.
|
|
991
951
|
You may create as many instances as you like as long as they have different IDs.
|
|
992
952
|
|
|
993
|
-
|
|
994
|
-
|
|
953
|
+
The class' internal methods are all declared as protected, so you can extend this class and override them if you need to add your own functionality, like changing the location data is stored.
|
|
954
|
+
|
|
955
|
+
If you have multiple DataStore instances and you want to be able to easily and safely export and import their data, take a look at the [DataStoreSerializer](#datastoreserializer) class.
|
|
956
|
+
It combines the data of multiple DataStore instances into a single object that can be exported and imported as a whole by the end user.
|
|
957
|
+
|
|
958
|
+
⚠️ The data is stored as a JSON string, so only JSON-compatible data can be used. Circular structures and complex objects will throw an error on load and save or cause otherwise unexpected behavior.
|
|
959
|
+
⚠️ The directives `@grant GM.getValue` and `@grant GM.setValue` are required if the storageMethod is left as the default of `"GM"`
|
|
995
960
|
|
|
996
961
|
The options object has the following properties:
|
|
997
962
|
| Property | Description |
|
|
@@ -1000,8 +965,9 @@ The options object has the following properties:
|
|
|
1000
965
|
| `defaultData` | The default data to use if no data is saved in persistent storage yet. Until the data is loaded from persistent storage, this will be the data returned by `getData()`. For TypeScript, the type of the data passed here is what will be used for all other methods of the instance. |
|
|
1001
966
|
| `formatVersion` | An incremental version of the data format. If the format of the data is changed in any way, this number should be incremented, in which case all necessary functions of the migrations dictionary will be run consecutively. Never decrement this number or skip numbers. |
|
|
1002
967
|
| `migrations?` | (Optional) A dictionary of functions that can be used to migrate data from older versions of the data to newer ones. The keys of the dictionary should be the format version that the functions can migrate to, from the previous whole integer value. The values should be functions that take the data in the old format and return the data in the new format. The functions will be run in order from the oldest to the newest version. If the current format version is not in the dictionary, no migrations will be run. |
|
|
1003
|
-
| `
|
|
1004
|
-
| `
|
|
968
|
+
| `storageMethod?` | (Optional) The method that is used to store the data. Can be `"GM"` (default), `"localStorage"` or `"sessionStorage"`. If you want to store the data in a different way, you can override the methods of the DataStore class. |
|
|
969
|
+
| `encodeData?` | (Optional, but required when `decodeData` is set) Function that encodes the data before saving - you can use [compress()](#compress) here to save space at the cost of a little bit of performance |
|
|
970
|
+
| `decodeData?` | (Optional, but required when `encodeData` is set) Function that decodes the data when loading - you can use [decompress()](#decompress) here to decode data that was previously compressed with [compress()](#compress) |
|
|
1005
971
|
|
|
1006
972
|
<br>
|
|
1007
973
|
|
|
@@ -1022,10 +988,19 @@ Writes the given data synchronously to the internal cache and asynchronously to
|
|
|
1022
988
|
Writes the default data given in `options.defaultData` synchronously to the internal cache and asynchronously to persistent storage.
|
|
1023
989
|
|
|
1024
990
|
`deleteData(): Promise<void>`
|
|
1025
|
-
Fully deletes the data from persistent storage.
|
|
991
|
+
Fully deletes the data from persistent storage only.
|
|
1026
992
|
The internal cache will be left untouched, so any subsequent calls to `getData()` will return the data that was last loaded.
|
|
1027
993
|
If `loadData()` or `setData()` are called after this, the persistent storage will be populated with the value of `options.defaultData` again.
|
|
994
|
+
This is why you should either immediately repopulate the cache and persistent storage or the page should probably be reloaded or closed after this method is called.
|
|
1028
995
|
⚠️ If you want to use this method, the additional directive `@grant GM.deleteValue` is required.
|
|
996
|
+
|
|
997
|
+
`runMigrations(oldData: any, oldFmtVer: number, resetOnError?: boolean): Promise<TData>`
|
|
998
|
+
Runs all necessary migration functions to migrate the given `oldData` to the latest format.
|
|
999
|
+
If `resetOnError` is set to `false`, the migration will be aborted if an error is thrown and no data will be committed. If it is set to `true` (default) and an error is encountered, it will be suppressed and the `defaultData` will be saved to persistent storage and returned.
|
|
1000
|
+
|
|
1001
|
+
`encodingEnabled(): boolean`
|
|
1002
|
+
Returns `true` if both `options.encodeData` and `options.decodeData` are set, else `false`.
|
|
1003
|
+
Uses TypeScript's type guard notation for easier use in conditional statements.
|
|
1029
1004
|
|
|
1030
1005
|
<br>
|
|
1031
1006
|
|
|
@@ -1042,16 +1017,16 @@ interface MyConfig {
|
|
|
1042
1017
|
qux: string;
|
|
1043
1018
|
}
|
|
1044
1019
|
|
|
1045
|
-
/** Default data */
|
|
1020
|
+
/** Default data returned by getData() calls until setData() is used and also fallback data if something goes wrong */
|
|
1046
1021
|
const defaultData: MyConfig = {
|
|
1047
1022
|
foo: "hello",
|
|
1048
1023
|
bar: 42,
|
|
1049
1024
|
baz: "xyz",
|
|
1050
1025
|
qux: "something",
|
|
1051
1026
|
};
|
|
1052
|
-
/** If any properties are added to, removed from or renamed in MyConfig, increment this number */
|
|
1027
|
+
/** If any properties are added to, removed from, or renamed in the MyConfig type, increment this number */
|
|
1053
1028
|
const formatVersion = 2;
|
|
1054
|
-
/**
|
|
1029
|
+
/** These are functions that migrate outdated data to the latest format - make sure a function exists for every previously used formatVersion and that no numbers are skipped! */
|
|
1055
1030
|
const migrations = {
|
|
1056
1031
|
// migrate from format version 0 to 1
|
|
1057
1032
|
1: (oldData: Record<string, unknown>) => {
|
|
@@ -1063,8 +1038,8 @@ const migrations = {
|
|
|
1063
1038
|
},
|
|
1064
1039
|
// asynchronously migrate from format version 1 to 2
|
|
1065
1040
|
2: async (oldData: Record<string, unknown>) => {
|
|
1066
|
-
// arbitrary async
|
|
1067
|
-
const qux =
|
|
1041
|
+
// using arbitrary async operations for the new format:
|
|
1042
|
+
const qux = await grabQuxDataAsync();
|
|
1068
1043
|
return {
|
|
1069
1044
|
foo: oldData.foo,
|
|
1070
1045
|
bar: oldData.bar,
|
|
@@ -1074,32 +1049,38 @@ const migrations = {
|
|
|
1074
1049
|
},
|
|
1075
1050
|
};
|
|
1076
1051
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1052
|
+
// You probably want to export this instance (or helper functions) so you can use it anywhere in your script:
|
|
1053
|
+
export const manager = new DataStore({
|
|
1054
|
+
/** A unique ID for this instance - choose wisely as changing it is not supported and will result in data loss! */
|
|
1079
1055
|
id: "my-userscript-config",
|
|
1080
|
-
/** Default
|
|
1056
|
+
/** Default, initial and fallback data */
|
|
1081
1057
|
defaultData,
|
|
1082
1058
|
/** The current version of the data format */
|
|
1083
1059
|
formatVersion,
|
|
1084
|
-
/** Data format migration functions */
|
|
1060
|
+
/** Data format migration functions called when the formatVersion is increased */
|
|
1085
1061
|
migrations,
|
|
1062
|
+
/**
|
|
1063
|
+
* Where the data should be stored.
|
|
1064
|
+
* For example, you could use `"sessionStorage"` to make the data be automatically deleted after the browser session is finished, or use `"localStorage"` if you don't have access to GM storage for some reason.
|
|
1065
|
+
*/
|
|
1066
|
+
storageMethod: "localStorage",
|
|
1086
1067
|
|
|
1087
1068
|
// Compression example:
|
|
1088
|
-
// Adding
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
|
|
1092
|
-
/**
|
|
1093
|
-
encodeData: (data) => compress(data, "deflate
|
|
1094
|
-
/**
|
|
1095
|
-
decodeData: (data) => decompress(data, "deflate
|
|
1069
|
+
// Adding the following will save space at the cost of a little bit of performance (only for the initial loading and every time new data is saved)
|
|
1070
|
+
// Feel free to use your own functions here, as long as they take in the stringified JSON and return another string, either synchronously or asynchronously
|
|
1071
|
+
// Either both of these properties or none of them should be set
|
|
1072
|
+
|
|
1073
|
+
/** Compresses the data using the "deflate" algorithm and digests it as a string */
|
|
1074
|
+
encodeData: (data) => compress(data, "deflate", "string"),
|
|
1075
|
+
/** Decompresses the "deflate" encoded data as a string */
|
|
1076
|
+
decodeData: (data) => decompress(data, "deflate", "string"),
|
|
1096
1077
|
});
|
|
1097
1078
|
|
|
1098
1079
|
/** Entrypoint of the userscript */
|
|
1099
1080
|
async function init() {
|
|
1100
1081
|
// wait for the data to be loaded from persistent storage
|
|
1101
1082
|
// if no data was saved in persistent storage before or getData() is called before loadData(), the value of options.defaultData will be returned
|
|
1102
|
-
// if the previously saved data needs to be migrated to a newer version, it will happen
|
|
1083
|
+
// if the previously saved data needs to be migrated to a newer version, it will happen inside this function call
|
|
1103
1084
|
const configData = await manager.loadData();
|
|
1104
1085
|
|
|
1105
1086
|
console.log(configData.foo); // "hello"
|
|
@@ -1122,6 +1103,149 @@ init();
|
|
|
1122
1103
|
|
|
1123
1104
|
</details>
|
|
1124
1105
|
|
|
1106
|
+
<br>
|
|
1107
|
+
|
|
1108
|
+
### DataStoreSerializer
|
|
1109
|
+
Usage:
|
|
1110
|
+
```ts
|
|
1111
|
+
new DataStoreSerializer(stores: DataStore[], options: DataStoreSerializerOptions)
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
A class that manages serializing and deserializing (exporting and importing) one to infinite DataStore instances.
|
|
1115
|
+
The serialized data is a JSON string that can be saved to a file, copied to the clipboard, or stored in any other way.
|
|
1116
|
+
Each DataStore instance's settings like data encoding are respected and saved next to the exported data.
|
|
1117
|
+
Also, by default a checksum is calculated and importing data with a mismatching checksum will throw an error.
|
|
1118
|
+
|
|
1119
|
+
The class' internal methods are all declared as protected, so you can extend this class and override them if you need to add your own functionality.
|
|
1120
|
+
|
|
1121
|
+
⚠️ Needs to run in a secure context (HTTPS) due to the use of the SubtleCrypto API.
|
|
1122
|
+
|
|
1123
|
+
The options object has the following properties:
|
|
1124
|
+
| Property | Description |
|
|
1125
|
+
| :-- | :-- |
|
|
1126
|
+
| `addChecksum?` | (Optional) If set to `true` (default), a SHA-256 checksum will be calculated and saved with the serialized data. If set to `false`, no checksum will be calculated and saved. |
|
|
1127
|
+
| `ensureIntegrity?` | (Optional) If set to `true` (default), the checksum will be checked when importing data and an error will be thrown if it doesn't match. If set to `false`, the checksum will not be checked and no error will be thrown. If no checksum property exists on the imported data (for example because it wasn't enabled in a previous data format version), the checksum check will be skipped regardless of this setting. |
|
|
1128
|
+
|
|
1129
|
+
<br>
|
|
1130
|
+
|
|
1131
|
+
#### Methods:
|
|
1132
|
+
`constructor(stores: DataStore[], options?: DataStoreSerializerOptions)`
|
|
1133
|
+
Creates a new DataStoreSerializer instance with the given DataStore instances and options.
|
|
1134
|
+
If no options are passed, the defaults will be used.
|
|
1135
|
+
|
|
1136
|
+
`serialize(): Promise<string>`
|
|
1137
|
+
Serializes all DataStore instances passed in the constructor and returns the serialized data as a JSON string.
|
|
1138
|
+
<details><summary>Click to view the structure of the returned data.</summary>
|
|
1139
|
+
|
|
1140
|
+
```jsonc
|
|
1141
|
+
[
|
|
1142
|
+
{
|
|
1143
|
+
"id": "foo-data", // the ID property given to the DataStore instance
|
|
1144
|
+
"data": "eJyrVkrKTFeyUkrOKM1LLy1WqgUAMvAF6g==", // serialized data (may be compressed / encoded or not)
|
|
1145
|
+
"formatVersion": 2, // the format version of the data
|
|
1146
|
+
"encoded": true, // only set to true if both encodeData and decodeData are set in the DataStore instance
|
|
1147
|
+
"checksum": "420deadbeef69", // property will be missing if addChecksum is set to false
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
// ...
|
|
1151
|
+
}
|
|
1152
|
+
]
|
|
1153
|
+
```
|
|
1154
|
+
</details>
|
|
1155
|
+
|
|
1156
|
+
`deserialize(data: string): Promise<void>`
|
|
1157
|
+
Deserializes the given JSON string and imports the data into the DataStore instances.
|
|
1158
|
+
In the process of importing the data, the migrations will be run, if the `formatVersion` property is lower than the one set on the DataStore instance.
|
|
1159
|
+
|
|
1160
|
+
If `ensureIntegrity` is set to `true` and the checksum doesn't match, an error will be thrown.
|
|
1161
|
+
If `ensureIntegrity` is set to `false`, the checksum check will be skipped entirely.
|
|
1162
|
+
If the `checksum` property is missing on the imported data, the checksum check will also be skipped.
|
|
1163
|
+
If `encoded` is set to `true`, the data will be decoded using the `decodeData` function set on the DataStore instance.
|
|
1164
|
+
|
|
1165
|
+
<br>
|
|
1166
|
+
|
|
1167
|
+
<details><summary><b>Example - click to view</b></summary>
|
|
1168
|
+
|
|
1169
|
+
```ts
|
|
1170
|
+
import { DataStore, DataStoreSerializer, compress, decompress } from "@sv443-network/userutils";
|
|
1171
|
+
|
|
1172
|
+
/** This store doesn't have migrations to run and also has no encodeData and decodeData functions */
|
|
1173
|
+
const fooStore = new DataStore({
|
|
1174
|
+
id: "foo-data",
|
|
1175
|
+
defaultData: {
|
|
1176
|
+
foo: "hello",
|
|
1177
|
+
},
|
|
1178
|
+
formatVersion: 1,
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
/** This store has migrations to run and also has encodeData and decodeData functions */
|
|
1182
|
+
const barStore = new DataStore({
|
|
1183
|
+
id: "bar-data",
|
|
1184
|
+
defaultData: {
|
|
1185
|
+
foo: "hello",
|
|
1186
|
+
},
|
|
1187
|
+
formatVersion: 2,
|
|
1188
|
+
migrations: {
|
|
1189
|
+
2: (oldData) => ({
|
|
1190
|
+
...oldData,
|
|
1191
|
+
bar: "world",
|
|
1192
|
+
}),
|
|
1193
|
+
},
|
|
1194
|
+
encodeData: (data) => compress(data, "deflate", "string"),
|
|
1195
|
+
decodeData: (data) => decompress(data, "deflate", "string"),
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
const serializer = new DataStoreSerializer([fooStore, barStore], {
|
|
1199
|
+
addChecksum: true,
|
|
1200
|
+
ensureIntegrity: true,
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
async function exportMyDataPls() {
|
|
1204
|
+
const serializedData = await serializer.serialize();
|
|
1205
|
+
// create file and download it:
|
|
1206
|
+
const blob = new Blob([serializedData], { type: "application/json" });
|
|
1207
|
+
const url = URL.createObjectURL(blob);
|
|
1208
|
+
const a = document.createElement("a");
|
|
1209
|
+
a.href = url;
|
|
1210
|
+
a.download = `data_export-${new Date().toISOString()}.json`;
|
|
1211
|
+
a.click();
|
|
1212
|
+
a.remove();
|
|
1213
|
+
|
|
1214
|
+
// This function exports an object like this:
|
|
1215
|
+
// [
|
|
1216
|
+
// {
|
|
1217
|
+
// "id": "foo-data",
|
|
1218
|
+
// "data": "{\"foo\":\"hello\"}", // not compressed or encoded because encodeData and decodeData are not set
|
|
1219
|
+
// "formatVersion": 1,
|
|
1220
|
+
// "encoded": false,
|
|
1221
|
+
// "checksum": "420deadbeef69"
|
|
1222
|
+
// },
|
|
1223
|
+
// {
|
|
1224
|
+
// "id": "bar-data",
|
|
1225
|
+
// "data": "eJyrVkrKTFeyUkrOKM1LLy1WqgUAMvAF6g==", // compressed because encodeData and decodeData are set
|
|
1226
|
+
// "formatVersion": 2,
|
|
1227
|
+
// "encoded": true,
|
|
1228
|
+
// "checksum": "69beefdead420"
|
|
1229
|
+
// }
|
|
1230
|
+
// ]
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
async function importMyDataPls() {
|
|
1234
|
+
// grab the data from the file by using the system file picker or any other method
|
|
1235
|
+
const data = await getDataFromSomewhere();
|
|
1236
|
+
|
|
1237
|
+
try {
|
|
1238
|
+
// import the data
|
|
1239
|
+
await serializer.deserialize(data);
|
|
1240
|
+
}
|
|
1241
|
+
catch(err) {
|
|
1242
|
+
console.error(err);
|
|
1243
|
+
alert(`Data import failed: ${err}`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
```
|
|
1247
|
+
</details>
|
|
1248
|
+
|
|
1125
1249
|
<br><br>
|
|
1126
1250
|
|
|
1127
1251
|
### autoPlural()
|
|
@@ -1356,6 +1480,73 @@ console.log(decompressed); // "Hello, World!"
|
|
|
1356
1480
|
|
|
1357
1481
|
</details>
|
|
1358
1482
|
|
|
1483
|
+
<br>
|
|
1484
|
+
|
|
1485
|
+
### computeHash()
|
|
1486
|
+
Usage:
|
|
1487
|
+
```ts
|
|
1488
|
+
computeHash(input: string | ArrayBuffer, algorithm?: string): Promise<string>
|
|
1489
|
+
```
|
|
1490
|
+
|
|
1491
|
+
Computes a hash / checksum of a string or ArrayBuffer using the specified algorithm ("SHA-256" by default).
|
|
1492
|
+
The algorithm must be supported by the [SubtleCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest).
|
|
1493
|
+
|
|
1494
|
+
⚠️ This function needs to be called in a secure context (HTTPS) due to the use of the SubtleCrypto API.
|
|
1495
|
+
⚠️ If you use this for cryptography, make sure to use a secure algorithm (under no circumstances use SHA-1) and to [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) your input data.
|
|
1496
|
+
|
|
1497
|
+
<details><summary><b>Example - click to view</b></summary>
|
|
1498
|
+
|
|
1499
|
+
```ts
|
|
1500
|
+
import { computeHash } from "@sv443-network/userutils";
|
|
1501
|
+
|
|
1502
|
+
async function run() {
|
|
1503
|
+
const hash1 = await computeHash("Hello, World!");
|
|
1504
|
+
const hash2 = await computeHash("Hello, World!");
|
|
1505
|
+
|
|
1506
|
+
console.log(hash1); // dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
|
|
1507
|
+
console.log(hash1 === hash2); // true (same input = same output)
|
|
1508
|
+
|
|
1509
|
+
const hash3 = await computeHash("Hello, world!"); // lowercase "w"
|
|
1510
|
+
console.log(hash3); // 315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
run();
|
|
1514
|
+
```
|
|
1515
|
+
</details>
|
|
1516
|
+
|
|
1517
|
+
<br>
|
|
1518
|
+
|
|
1519
|
+
### randomId()
|
|
1520
|
+
Usage:
|
|
1521
|
+
```ts
|
|
1522
|
+
randomId(length?: number, radix?: number, enhancedEntropy?: boolean): string
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
Generates a random ID of a given length and [radix (base).](https://en.wikipedia.org/wiki/Radix)
|
|
1526
|
+
|
|
1527
|
+
The default length is 16 and the default radix is 16 (hexadecimal).
|
|
1528
|
+
You may change the radix to get digits from different numerical systems.
|
|
1529
|
+
Use 2 for binary, 8 for octal, 10 for decimal, 16 for hexadecimal and 36 for alphanumeric.
|
|
1530
|
+
|
|
1531
|
+
If `enhancedEntropy` is set to true (false by default), the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) is used for generating the random numbers.
|
|
1532
|
+
Note that this takes MUCH longer, but the generated IDs will have a higher entropy.
|
|
1533
|
+
|
|
1534
|
+
⚠️ Not suitable for generating anything related to cryptography! Use [SubtleCrypto's `generateKey()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey) for that instead.
|
|
1535
|
+
|
|
1536
|
+
<details><summary><b>Example - click to view</b></summary>
|
|
1537
|
+
|
|
1538
|
+
```ts
|
|
1539
|
+
import { randomId } from "@sv443-network/userutils";
|
|
1540
|
+
|
|
1541
|
+
randomId(); // "1bda419a73629d4f" (length 16, radix 16)
|
|
1542
|
+
randomId(10); // "f86cd354a4" (length 10, radix 16)
|
|
1543
|
+
randomId(10, 2); // "1010001101" (length 10, radix 2)
|
|
1544
|
+
randomId(10, 10); // "0183428506" (length 10, radix 10)
|
|
1545
|
+
randomId(10, 36); // "z46jfpa37r" (length 10, radix 36)
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
</details>
|
|
1549
|
+
|
|
1359
1550
|
<br><br>
|
|
1360
1551
|
|
|
1361
1552
|
<!-- #SECTION Arrays -->
|
|
@@ -1642,8 +1833,9 @@ They don't alter the runtime behavior of the code, but they can be used to make
|
|
|
1642
1833
|
|
|
1643
1834
|
### Stringifiable
|
|
1644
1835
|
This type describes any value that either is a string itself or can be converted to a string.
|
|
1645
|
-
To be considered stringifiable, the object needs to have a `toString()` method that returns a string
|
|
1646
|
-
|
|
1836
|
+
To be considered stringifiable, the object needs to have a `toString()` method that returns a string.
|
|
1837
|
+
Most primitives have this method, but something like undefined or null does not (they can only be used in the `String()` constructor or string interpolation).
|
|
1838
|
+
Having this method allows not just explicit conversion by calling it, but also implicit conversion by passing it into the `String()` constructor or by interpolating it in a template string.
|
|
1647
1839
|
|
|
1648
1840
|
<details><summary><b>Example - click to view</b></summary>
|
|
1649
1841
|
|
|
@@ -1651,7 +1843,7 @@ This method allows not just explicit conversion by calling it, but also implicit
|
|
|
1651
1843
|
import type { Stringifiable } from "@sv443-network/userutils";
|
|
1652
1844
|
|
|
1653
1845
|
function logSomething(value: Stringifiable) {
|
|
1654
|
-
console.log(`Log: ${value}`); // implicit conversion
|
|
1846
|
+
console.log(`Log: ${value}`); // implicit conversion to a string
|
|
1655
1847
|
}
|
|
1656
1848
|
|
|
1657
1849
|
const fooObject = {
|
|
@@ -1665,11 +1857,10 @@ const barObject = {
|
|
|
1665
1857
|
logSomething("foo"); // "Log: foo"
|
|
1666
1858
|
logSomething(42); // "Log: 42"
|
|
1667
1859
|
logSomething(true); // "Log: true"
|
|
1668
|
-
logSomething({}); // "Log: [object Object]"
|
|
1669
1860
|
logSomething(Symbol(1)); // "Log: Symbol(1)"
|
|
1670
1861
|
logSomething(fooObject); // "Log: hello world"
|
|
1671
1862
|
|
|
1672
|
-
logSomething(barObject); // Type
|
|
1863
|
+
logSomething(barObject); // Type error
|
|
1673
1864
|
```
|
|
1674
1865
|
|
|
1675
1866
|
</details>
|