perfectionist-plugins 1.0.0 → 1.0.2
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 +2 -14
- package/ban-blocker.js +552 -617
- package/hide-unowned-skins.js +578 -640
- package/package.json +1 -1
- package/perfectionist-utils.js +755 -819
package/hide-unowned-skins.js
CHANGED
|
@@ -1,640 +1,578 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
function
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
let
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
function
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
ownedSkinIds
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
]);
|
|
580
|
-
sessionMisses = 0;
|
|
581
|
-
updateForSession(session, selection);
|
|
582
|
-
} catch {
|
|
583
|
-
sessionMisses++;
|
|
584
|
-
if (sessionMisses >= 3) resetState();
|
|
585
|
-
} finally {
|
|
586
|
-
pollInFlight = false;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
function onEnable(utils) {
|
|
591
|
-
api = utils;
|
|
592
|
-
filterActive = true;
|
|
593
|
-
removeStyle = api.addStyle(STYLE_ID, CSS);
|
|
594
|
-
|
|
595
|
-
// The client re-creates carousel items and dots on selection changes; re-apply before the next paint.
|
|
596
|
-
// document.body can still be null at Pengu boot, so observe the document element.
|
|
597
|
-
renderObserver = new MutationObserver(() => applyAllGuarded());
|
|
598
|
-
renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
|
|
599
|
-
|
|
600
|
-
pollTimer = setInterval(pollSession, SESSION_POLL_MS);
|
|
601
|
-
pollSession();
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function onDisable() {
|
|
605
|
-
clearInterval(pollTimer);
|
|
606
|
-
pollTimer = null;
|
|
607
|
-
renderObserver?.disconnect();
|
|
608
|
-
removeStyle?.();
|
|
609
|
-
renderObserver = null;
|
|
610
|
-
removeStyle = null;
|
|
611
|
-
filterActive = false;
|
|
612
|
-
|
|
613
|
-
resetState();
|
|
614
|
-
document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));
|
|
615
|
-
api = null;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
/** Pengu entry point. Registers with the Perfectionism core. */
|
|
619
|
-
export function init() {
|
|
620
|
-
// Installed before the client's bundles create their sockets and send their first requests.
|
|
621
|
-
installSourceFilter();
|
|
622
|
-
|
|
623
|
-
const registerWith = (utils) => {
|
|
624
|
-
utils.registerPlugin({
|
|
625
|
-
id: PLUGIN_ID,
|
|
626
|
-
name: "Hide Unowned Skins",
|
|
627
|
-
description: "In champion select, only shows the skins you own for the picked champion.",
|
|
628
|
-
version,
|
|
629
|
-
defaultEnabled: true,
|
|
630
|
-
onEnable,
|
|
631
|
-
onDisable,
|
|
632
|
-
});
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
if (window.PerfectionistUtils) {
|
|
636
|
-
registerWith(window.PerfectionistUtils);
|
|
637
|
-
} else {
|
|
638
|
-
addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail), { once: true });
|
|
639
|
-
}
|
|
640
|
-
}
|
|
1
|
+
export const version = "2.1.1";
|
|
2
|
+
|
|
3
|
+
const PLUGIN_ID = "hide-unowned-skins";
|
|
4
|
+
const STYLE_ID = "perfectionist-hide-unowned-skins-style";
|
|
5
|
+
const HIDDEN_CLASS = "perf-unowned-skin-hidden";
|
|
6
|
+
const SKIN_ITEM_SELECTOR = ".skin-selection-item";
|
|
7
|
+
const DOT_SELECTOR = ".skin-selection-indicator-selector";
|
|
8
|
+
const SESSION_URI = "/lol-champ-select/v1/session";
|
|
9
|
+
const SELECTION_URI = "/lol-champ-select/v1/session/my-selection";
|
|
10
|
+
const CAROUSEL_URI = "/lol-champ-select/v1/skin-carousel-skins";
|
|
11
|
+
const CURRENT_SUMMONER_URI = "/lol-summoner/v1/current-summoner";
|
|
12
|
+
const SUMMONER_TTL = 10 * 60 * 1000;
|
|
13
|
+
const INVENTORY_TTL = 60 * 1000;
|
|
14
|
+
const SESSION_POLL_MS = 1000;
|
|
15
|
+
const RETRY_MS = 5000;
|
|
16
|
+
const STATE_KEY = "perfectionist-plugins:state";
|
|
17
|
+
|
|
18
|
+
const CSS = `
|
|
19
|
+
.${HIDDEN_CLASS} { display: none !important; margin: 0 !important; padding: 0 !important; border: none !important; }
|
|
20
|
+
[data-perf-dots] { justify-content: center !important; }
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
const OBSERVER_OPTIONS = {
|
|
24
|
+
childList: true,
|
|
25
|
+
subtree: true,
|
|
26
|
+
attributes: true,
|
|
27
|
+
attributeFilter: ["class", "style"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let filterActive = true;
|
|
31
|
+
let rawFetch = null;
|
|
32
|
+
let clientSkinOrder = [];
|
|
33
|
+
|
|
34
|
+
function readDefaultEnabled() {
|
|
35
|
+
try {
|
|
36
|
+
const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
|
|
37
|
+
return saved.enabled?.[PLUGIN_ID] !== false;
|
|
38
|
+
} catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isOwnedSkin(skin) {
|
|
44
|
+
if (!skin || typeof skin !== "object") return true;
|
|
45
|
+
const id = typeof skin.id === "number" ? skin.id : null;
|
|
46
|
+
if (id === null) return true;
|
|
47
|
+
if (id % 1000 === 0) return true;
|
|
48
|
+
const ownership = skin.ownership;
|
|
49
|
+
if (ownership && typeof ownership.owned === "boolean") return ownership.owned;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function filterSkinList(list) {
|
|
54
|
+
if (!Array.isArray(list)) return null;
|
|
55
|
+
const kept = list.filter(isOwnedSkin);
|
|
56
|
+
return kept.length === list.length ? null : kept;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function recordClientOrder(list) {
|
|
60
|
+
if (Array.isArray(list)) {
|
|
61
|
+
clientSkinOrder = list.map((skin) => (skin && typeof skin.id === "number" ? skin.id : null)).filter((id) => id !== null);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rewriteWebSocketFrame(text) {
|
|
66
|
+
if (!filterActive || typeof text !== "string") return null;
|
|
67
|
+
if (text.indexOf("skin-carousel-skins") === -1) return null;
|
|
68
|
+
let frame;
|
|
69
|
+
try {
|
|
70
|
+
frame = JSON.parse(text);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(frame) || frame.length < 3) return null;
|
|
75
|
+
const payload = frame[2];
|
|
76
|
+
if (!payload || typeof payload !== "object" || typeof payload.uri !== "string") return null;
|
|
77
|
+
if (payload.uri.split("?")[0] !== CAROUSEL_URI) return null;
|
|
78
|
+
const kept = filterSkinList(payload.data);
|
|
79
|
+
recordClientOrder(kept ?? payload.data);
|
|
80
|
+
if (!kept) return null;
|
|
81
|
+
return JSON.stringify([frame[0], frame[1], Object.assign({}, payload, { data: kept })]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function wrapMessageListener(listener) {
|
|
85
|
+
if (typeof listener !== "function" || listener.__perfSkinWrap) return listener;
|
|
86
|
+
const wrapped = function (event) {
|
|
87
|
+
if (event && typeof event.data === "string") {
|
|
88
|
+
const rewritten = rewriteWebSocketFrame(event.data);
|
|
89
|
+
if (rewritten !== null) {
|
|
90
|
+
const fresh = new MessageEvent("message", {
|
|
91
|
+
data: rewritten,
|
|
92
|
+
origin: event.origin,
|
|
93
|
+
lastEventId: event.lastEventId,
|
|
94
|
+
ports: event.ports ? [...event.ports] : [],
|
|
95
|
+
source: event.source ?? null,
|
|
96
|
+
});
|
|
97
|
+
return listener.call(this, fresh);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return listener.call(this, event);
|
|
101
|
+
};
|
|
102
|
+
wrapped.__perfSkinWrap = true;
|
|
103
|
+
wrapped.__perfOriginal = listener;
|
|
104
|
+
return wrapped;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function installWebSocketFilter() {
|
|
108
|
+
const proto = window.WebSocket && window.WebSocket.prototype;
|
|
109
|
+
if (!proto || proto.addEventListener.__perfSkinFilter) return;
|
|
110
|
+
|
|
111
|
+
const origAdd = proto.addEventListener;
|
|
112
|
+
const origRemove = proto.removeEventListener;
|
|
113
|
+
const origDispatch = proto.dispatchEvent;
|
|
114
|
+
|
|
115
|
+
proto.addEventListener = function (type, listener, options) {
|
|
116
|
+
return origAdd.call(this, type, type === "message" ? wrapMessageListener(listener) : listener, options);
|
|
117
|
+
};
|
|
118
|
+
proto.removeEventListener = function (type, listener, options) {
|
|
119
|
+
return origRemove.call(this, type, (listener && listener.__perfOriginal) || listener, options);
|
|
120
|
+
};
|
|
121
|
+
proto.dispatchEvent = function (event) {
|
|
122
|
+
if (event && event.type === "message" && typeof event.data === "string") {
|
|
123
|
+
const rewritten = rewriteWebSocketFrame(event.data);
|
|
124
|
+
if (rewritten !== null) {
|
|
125
|
+
return origDispatch.call(
|
|
126
|
+
this,
|
|
127
|
+
new MessageEvent("message", {
|
|
128
|
+
data: rewritten,
|
|
129
|
+
origin: event.origin,
|
|
130
|
+
lastEventId: event.lastEventId,
|
|
131
|
+
ports: event.ports ? [...event.ports] : [],
|
|
132
|
+
source: event.source ?? null,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return origDispatch.call(this, event);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, "onmessage");
|
|
141
|
+
if (descriptor && descriptor.set && descriptor.get) {
|
|
142
|
+
Object.defineProperty(proto, "onmessage", {
|
|
143
|
+
configurable: true,
|
|
144
|
+
enumerable: descriptor.enumerable,
|
|
145
|
+
get: descriptor.get,
|
|
146
|
+
set(value) {
|
|
147
|
+
descriptor.set.call(this, typeof value === "function" ? wrapMessageListener(value) : value);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
proto.addEventListener.__perfSkinFilter = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function installFetchFilter() {
|
|
156
|
+
const original = window.fetch;
|
|
157
|
+
if (typeof original !== "function" || original.__perfSkinFilter) return;
|
|
158
|
+
if (!rawFetch) rawFetch = original;
|
|
159
|
+
|
|
160
|
+
const wrapped = function (input, init) {
|
|
161
|
+
const url = typeof input === "string" ? input : (input && input.url) || "";
|
|
162
|
+
const promise = original.call(this, input, init);
|
|
163
|
+
if (!filterActive || url.split("?")[0].indexOf(CAROUSEL_URI) === -1) return promise;
|
|
164
|
+
return promise.then((response) => {
|
|
165
|
+
if (!response || !response.ok) return response;
|
|
166
|
+
return response
|
|
167
|
+
.clone()
|
|
168
|
+
.json()
|
|
169
|
+
.catch(() => null)
|
|
170
|
+
.then((json) => {
|
|
171
|
+
const kept = filterSkinList(json);
|
|
172
|
+
recordClientOrder(kept ?? json);
|
|
173
|
+
if (!kept) return response;
|
|
174
|
+
return new Response(JSON.stringify(kept), {
|
|
175
|
+
status: response.status,
|
|
176
|
+
statusText: response.statusText,
|
|
177
|
+
headers: response.headers,
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
wrapped.__perfSkinFilter = true;
|
|
183
|
+
window.fetch = wrapped;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function installXhrFilter() {
|
|
187
|
+
const proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
|
|
188
|
+
if (!proto || proto.open.__perfSkinFilter) return;
|
|
189
|
+
|
|
190
|
+
const origOpen = proto.open;
|
|
191
|
+
const origSend = proto.send;
|
|
192
|
+
const textDescriptor = Object.getOwnPropertyDescriptor(proto, "responseText");
|
|
193
|
+
const responseDescriptor = Object.getOwnPropertyDescriptor(proto, "response");
|
|
194
|
+
|
|
195
|
+
proto.open = function (method, url, ...rest) {
|
|
196
|
+
this.__perfSkinUrl = typeof url === "string" ? url : "";
|
|
197
|
+
return origOpen.call(this, method, url, ...rest);
|
|
198
|
+
};
|
|
199
|
+
proto.open.__perfSkinFilter = true;
|
|
200
|
+
|
|
201
|
+
proto.send = function (...args) {
|
|
202
|
+
const url = this.__perfSkinUrl || "";
|
|
203
|
+
if (textDescriptor && textDescriptor.get && url.split("?")[0].indexOf(CAROUSEL_URI) !== -1) {
|
|
204
|
+
const xhr = this;
|
|
205
|
+
const cache = { raw: null, filtered: null };
|
|
206
|
+
const filteredText = () => {
|
|
207
|
+
const raw = textDescriptor.get.call(xhr);
|
|
208
|
+
if (!filterActive) return raw;
|
|
209
|
+
if (cache.filtered === null || cache.raw !== raw) {
|
|
210
|
+
cache.raw = raw;
|
|
211
|
+
cache.filtered = raw;
|
|
212
|
+
try {
|
|
213
|
+
const json = JSON.parse(raw);
|
|
214
|
+
const kept = filterSkinList(json);
|
|
215
|
+
recordClientOrder(kept ?? json);
|
|
216
|
+
if (kept) cache.filtered = JSON.stringify(kept);
|
|
217
|
+
} catch {
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return cache.filtered;
|
|
221
|
+
};
|
|
222
|
+
Object.defineProperty(xhr, "responseText", { configurable: true, get: filteredText });
|
|
223
|
+
if (responseDescriptor && responseDescriptor.get) {
|
|
224
|
+
Object.defineProperty(xhr, "response", {
|
|
225
|
+
configurable: true,
|
|
226
|
+
get() {
|
|
227
|
+
return xhr.responseType === "" || xhr.responseType === "text" ? filteredText() : responseDescriptor.get.call(xhr);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return origSend.apply(this, args);
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function installSourceFilter() {
|
|
237
|
+
try {
|
|
238
|
+
filterActive = readDefaultEnabled();
|
|
239
|
+
installWebSocketFilter();
|
|
240
|
+
installFetchFilter();
|
|
241
|
+
installXhrFilter();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
logError("could not install the source filter.", error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function ensureSourceFilter() {
|
|
248
|
+
try {
|
|
249
|
+
installWebSocketFilter();
|
|
250
|
+
installFetchFilter();
|
|
251
|
+
installXhrFilter();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
logError("could not refresh the source filter.", error);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let api = null;
|
|
258
|
+
let summonerId = null;
|
|
259
|
+
let ownedSkinIds = null;
|
|
260
|
+
let currentChampionId = null;
|
|
261
|
+
let carouselOrder = [];
|
|
262
|
+
let selectedSkinId = null;
|
|
263
|
+
let failure = null;
|
|
264
|
+
let requestToken = 0;
|
|
265
|
+
let pollTimer = null;
|
|
266
|
+
let pollInFlight = false;
|
|
267
|
+
let selectInFlight = false;
|
|
268
|
+
let sessionMisses = 0;
|
|
269
|
+
let championMisses = 0;
|
|
270
|
+
let renderObserver = null;
|
|
271
|
+
const repackOriginals = new Map();
|
|
272
|
+
let removeStyle = null;
|
|
273
|
+
|
|
274
|
+
function logError(scope, error) {
|
|
275
|
+
console.error(`[Perfectionist] Hide Unowned Skins: ${scope}`, error);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function readSkinId(item) {
|
|
279
|
+
const html = item.innerHTML;
|
|
280
|
+
const numbered = html.match(/Skins\/Skin(\d+)\//i) ?? html.match(/Skin(\d+)\//);
|
|
281
|
+
if (numbered) return currentChampionId * 1000 + Number(numbered[1]);
|
|
282
|
+
if (/Skins\/Base\//i.test(html)) return currentChampionId * 1000;
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function hideTargetFor(item) {
|
|
287
|
+
let target = item;
|
|
288
|
+
for (let depth = 0; depth < 2; depth++) {
|
|
289
|
+
const parent = target.parentElement;
|
|
290
|
+
if (!parent || parent.children.length !== 1) break;
|
|
291
|
+
target = parent;
|
|
292
|
+
}
|
|
293
|
+
return target;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function repackVisibleItems(items) {
|
|
297
|
+
const visible = items.filter((item) => !item.classList.contains(HIDDEN_CLASS));
|
|
298
|
+
if (visible.length < 2) return;
|
|
299
|
+
|
|
300
|
+
const selectedIndex = items.findIndex((item) => item.classList.contains("skin-selection-item-selected"));
|
|
301
|
+
const anchor = selectedIndex >= 0 ? items[selectedIndex] : visible[Math.floor(visible.length / 2)];
|
|
302
|
+
const anchorDomIndex = items.indexOf(anchor);
|
|
303
|
+
|
|
304
|
+
const widthOf = (item) => item.getBoundingClientRect().width || (item === anchor ? 117 : 73);
|
|
305
|
+
const toPx = (item) => {
|
|
306
|
+
const raw = item.style.left || "0";
|
|
307
|
+
if (raw.endsWith("px")) return parseFloat(raw);
|
|
308
|
+
const box = item.parentElement?.getBoundingClientRect().width || 468;
|
|
309
|
+
return (parseFloat(raw) / 100) * box;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const anchorLeft = toPx(anchor);
|
|
313
|
+
let rightX = anchorLeft + widthOf(anchor);
|
|
314
|
+
let leftX = anchorLeft;
|
|
315
|
+
|
|
316
|
+
const setLeft = (item, x) => {
|
|
317
|
+
const want = `${x}px`;
|
|
318
|
+
if (item.style.getPropertyValue("left") !== want || item.style.getPropertyPriority("left") !== "important") {
|
|
319
|
+
if (!repackOriginals.has(item)) {
|
|
320
|
+
repackOriginals.set(item, {
|
|
321
|
+
value: item.style.getPropertyValue("left"),
|
|
322
|
+
priority: item.style.getPropertyPriority("left"),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
item.style.setProperty("left", want, "important");
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
items.forEach((item, index) => {
|
|
330
|
+
if (item === anchor || item.classList.contains(HIDDEN_CLASS)) return;
|
|
331
|
+
|
|
332
|
+
const isRight = index > anchorDomIndex;
|
|
333
|
+
const w = widthOf(item);
|
|
334
|
+
if (isRight) {
|
|
335
|
+
setLeft(item, rightX);
|
|
336
|
+
rightX += w;
|
|
337
|
+
} else {
|
|
338
|
+
leftX -= w;
|
|
339
|
+
setLeft(item, leftX);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function restoreRepack() {
|
|
345
|
+
if (repackOriginals.size === 0) return;
|
|
346
|
+
for (const [item, original] of repackOriginals) {
|
|
347
|
+
if (original.value) item.style.setProperty("left", original.value, original.priority || undefined);
|
|
348
|
+
else item.style.removeProperty("left");
|
|
349
|
+
}
|
|
350
|
+
repackOriginals.clear();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function applyAll() {
|
|
354
|
+
const items = [...document.querySelectorAll(SKIN_ITEM_SELECTOR)];
|
|
355
|
+
let hidden = 0;
|
|
356
|
+
items.forEach((item) => {
|
|
357
|
+
const skinId = currentChampionId === null ? null : readSkinId(item);
|
|
358
|
+
const hide = ownedSkinIds !== null && skinId !== null && !ownedSkinIds.has(skinId);
|
|
359
|
+
if (hide) hidden++;
|
|
360
|
+
item.classList.toggle(HIDDEN_CLASS, hide);
|
|
361
|
+
hideTargetFor(item).classList.toggle(HIDDEN_CLASS, hide);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const dots = [...document.querySelectorAll(DOT_SELECTOR)];
|
|
365
|
+
let dotsHidden = 0;
|
|
366
|
+
const dotsMapped = carouselOrder.length > 0 && dots.length === carouselOrder.length;
|
|
367
|
+
dots.forEach((dot, index) => {
|
|
368
|
+
const skinId = dotsMapped ? carouselOrder[index] : undefined;
|
|
369
|
+
const hide = ownedSkinIds !== null && skinId !== undefined && !ownedSkinIds.has(skinId);
|
|
370
|
+
if (hide) dotsHidden++;
|
|
371
|
+
dot.classList.toggle(HIDDEN_CLASS, hide);
|
|
372
|
+
});
|
|
373
|
+
const dotList = dots[0]?.parentElement;
|
|
374
|
+
if (dotList) {
|
|
375
|
+
if (dotsHidden > 0 && !dotList.hasAttribute("data-perf-dots")) dotList.setAttribute("data-perf-dots", "");
|
|
376
|
+
else if (dotsHidden === 0 && dotList.hasAttribute("data-perf-dots")) dotList.removeAttribute("data-perf-dots");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (ownedSkinIds !== null && hidden > 0) repackVisibleItems(items);
|
|
380
|
+
else if (hidden === 0) restoreRepack();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function applyAllGuarded() {
|
|
384
|
+
renderObserver?.disconnect();
|
|
385
|
+
try {
|
|
386
|
+
applyAll();
|
|
387
|
+
} finally {
|
|
388
|
+
renderObserver?.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function loadOwnedSkins(championId) {
|
|
393
|
+
if (summonerId === null) {
|
|
394
|
+
const summoner = await api.lcu.get(CURRENT_SUMMONER_URI, { ttl: SUMMONER_TTL });
|
|
395
|
+
summonerId = summoner.summonerId;
|
|
396
|
+
}
|
|
397
|
+
const skins = await api.lcu.get(
|
|
398
|
+
`/lol-champions/v1/inventories/${summonerId}/champions/${championId}/skins`,
|
|
399
|
+
{ ttl: INVENTORY_TTL },
|
|
400
|
+
);
|
|
401
|
+
if (!Array.isArray(skins) || skins.length === 0) {
|
|
402
|
+
throw new Error(`no skin inventory returned for champion ${championId}`);
|
|
403
|
+
}
|
|
404
|
+
const order = skins
|
|
405
|
+
.map((skin) => skin?.id)
|
|
406
|
+
.filter((id) => typeof id === "number")
|
|
407
|
+
.sort((a, b) => a - b);
|
|
408
|
+
return { owned: new Set(skins.filter((skin) => skin.ownership?.owned).map((skin) => skin.id)), order };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function loadCarouselOrder() {
|
|
412
|
+
if (clientSkinOrder.length > 0) return [...clientSkinOrder];
|
|
413
|
+
try {
|
|
414
|
+
let skins;
|
|
415
|
+
if (rawFetch) {
|
|
416
|
+
const response = await rawFetch(CAROUSEL_URI, { headers: { Accept: "application/json" } });
|
|
417
|
+
skins = response.ok ? await response.json() : null;
|
|
418
|
+
} else {
|
|
419
|
+
skins = await api.lcu.get(CAROUSEL_URI);
|
|
420
|
+
}
|
|
421
|
+
if (!Array.isArray(skins)) return [];
|
|
422
|
+
return skins.map((skin) => skin?.id).filter((id) => typeof id === "number");
|
|
423
|
+
} catch {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function nextOwnedSkin(owned, fromId, direction) {
|
|
429
|
+
const ids = [...owned].sort((a, b) => a - b);
|
|
430
|
+
if (ids.length === 0) return null;
|
|
431
|
+
if (direction > 0) return ids.find((id) => id > fromId) ?? ids[0];
|
|
432
|
+
return [...ids].reverse().find((id) => id < fromId) ?? ids[ids.length - 1];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function selectSkin(skinId) {
|
|
436
|
+
selectedSkinId = skinId;
|
|
437
|
+
selectInFlight = true;
|
|
438
|
+
try {
|
|
439
|
+
await api.lcu.send("PATCH", SELECTION_URI, { selectedSkinId: skinId });
|
|
440
|
+
} finally {
|
|
441
|
+
selectInFlight = false;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function resetState() {
|
|
446
|
+
requestToken++;
|
|
447
|
+
ownedSkinIds = null;
|
|
448
|
+
currentChampionId = null;
|
|
449
|
+
selectedSkinId = null;
|
|
450
|
+
carouselOrder = [];
|
|
451
|
+
clientSkinOrder = [];
|
|
452
|
+
failure = null;
|
|
453
|
+
sessionMisses = 0;
|
|
454
|
+
championMisses = 0;
|
|
455
|
+
applyAll();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function loadChampion(championId) {
|
|
459
|
+
currentChampionId = championId;
|
|
460
|
+
ownedSkinIds = null;
|
|
461
|
+
failure = null;
|
|
462
|
+
clientSkinOrder = [];
|
|
463
|
+
const token = ++requestToken;
|
|
464
|
+
|
|
465
|
+
Promise.all([loadOwnedSkins(championId), loadCarouselOrder()]).then(
|
|
466
|
+
([{ owned, order: inventoryOrder }, carouselIds]) => {
|
|
467
|
+
if (token !== requestToken) return;
|
|
468
|
+
ownedSkinIds = owned;
|
|
469
|
+
carouselOrder = carouselIds.length > 0 ? carouselIds : inventoryOrder;
|
|
470
|
+
applyAll();
|
|
471
|
+
},
|
|
472
|
+
(error) => {
|
|
473
|
+
if (token !== requestToken) return;
|
|
474
|
+
logError(`could not load owned skins for champion ${championId}.`, error);
|
|
475
|
+
failure = { championId, message: error.message, retryAt: Date.now() + RETRY_MS };
|
|
476
|
+
currentChampionId = null;
|
|
477
|
+
},
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function updateForSession(session, selection) {
|
|
482
|
+
const me = session?.myTeam?.find((player) => player.cellId === session.localPlayerCellId);
|
|
483
|
+
const championId = me?.championId || me?.championPickIntent || null;
|
|
484
|
+
|
|
485
|
+
if (!championId) {
|
|
486
|
+
championMisses++;
|
|
487
|
+
if (championMisses >= 2) resetState();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
championMisses = 0;
|
|
491
|
+
|
|
492
|
+
if (championId !== currentChampionId) {
|
|
493
|
+
if (failure?.championId === championId && Date.now() < failure.retryAt) return;
|
|
494
|
+
loadChampion(championId);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (ownedSkinIds === null) return;
|
|
498
|
+
|
|
499
|
+
const serverSkinId = selection?.selectedSkinId || me.selectedSkinId || null;
|
|
500
|
+
const belongsToChampion = serverSkinId !== null && Math.floor(serverSkinId / 1000) === championId;
|
|
501
|
+
if (!selectInFlight && belongsToChampion) selectedSkinId = serverSkinId;
|
|
502
|
+
|
|
503
|
+
if (!selectInFlight && belongsToChampion && !ownedSkinIds.has(serverSkinId)) {
|
|
504
|
+
const fallback = nextOwnedSkin(ownedSkinIds, 0, 1);
|
|
505
|
+
if (fallback !== null) {
|
|
506
|
+
selectSkin(fallback).catch((error) => logError("could not correct the selected skin.", error));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
applyAll();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function pollSession() {
|
|
514
|
+
if (pollInFlight || !api) return;
|
|
515
|
+
ensureSourceFilter();
|
|
516
|
+
pollInFlight = true;
|
|
517
|
+
try {
|
|
518
|
+
const [session, selection] = await Promise.all([
|
|
519
|
+
api.lcu.get(SESSION_URI),
|
|
520
|
+
api.lcu.get(SELECTION_URI).catch(() => null),
|
|
521
|
+
]);
|
|
522
|
+
sessionMisses = 0;
|
|
523
|
+
updateForSession(session, selection);
|
|
524
|
+
} catch {
|
|
525
|
+
sessionMisses++;
|
|
526
|
+
if (sessionMisses >= 3) resetState();
|
|
527
|
+
} finally {
|
|
528
|
+
pollInFlight = false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function onEnable(utils) {
|
|
533
|
+
api = utils;
|
|
534
|
+
filterActive = true;
|
|
535
|
+
removeStyle = api.addStyle(STYLE_ID, CSS);
|
|
536
|
+
|
|
537
|
+
renderObserver = new MutationObserver(() => applyAllGuarded());
|
|
538
|
+
renderObserver.observe(document.documentElement ?? document, OBSERVER_OPTIONS);
|
|
539
|
+
|
|
540
|
+
pollTimer = setInterval(pollSession, SESSION_POLL_MS);
|
|
541
|
+
pollSession();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function onDisable() {
|
|
545
|
+
clearInterval(pollTimer);
|
|
546
|
+
pollTimer = null;
|
|
547
|
+
renderObserver?.disconnect();
|
|
548
|
+
removeStyle?.();
|
|
549
|
+
renderObserver = null;
|
|
550
|
+
removeStyle = null;
|
|
551
|
+
filterActive = false;
|
|
552
|
+
|
|
553
|
+
resetState();
|
|
554
|
+
document.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((item) => item.classList.remove(HIDDEN_CLASS));
|
|
555
|
+
api = null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function init() {
|
|
559
|
+
installSourceFilter();
|
|
560
|
+
|
|
561
|
+
const registerWith = (utils) => {
|
|
562
|
+
utils.registerPlugin({
|
|
563
|
+
id: PLUGIN_ID,
|
|
564
|
+
name: "Hide Unowned Skins",
|
|
565
|
+
description: "In champion select, only shows the skins you own for the picked champion.",
|
|
566
|
+
version,
|
|
567
|
+
defaultEnabled: true,
|
|
568
|
+
onEnable,
|
|
569
|
+
onDisable,
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
if (window.PerfectionistUtils) {
|
|
574
|
+
registerWith(window.PerfectionistUtils);
|
|
575
|
+
} else {
|
|
576
|
+
addEventListener("perfectionist:utils-ready", (event) => registerWith(event.detail), { once: true });
|
|
577
|
+
}
|
|
578
|
+
}
|