PageSourceSearch

https://my.calcs.quest/js/main.js?t=1790988134

js calcs.quest collected 2026-10-03 23:19:41 UTC 76,057 bytes, 1,527 lines download raw bytes

1/**
2 * String.prototype.replaceAll() polyfill
3 * https://gomakethings.com/how-to-replace-a-section-of-a-string-with-another-one-with-vanilla-js/
4 * @author Chris Ferdinandi
5 * @license MIT
6 */
7if (!String.prototype.replaceAll) {
8    String.prototype.replaceAll = function(str, newStr) {
9        // If a regex pattern
10        if (Object.prototype.toString.call(str).toLowerCase() === '[object regexp]') {
11            return this.replace(str, newStr);
12        }
13        // If a string
14        return this.replace(new RegExp(str, 'g'), newStr);
15    }
16}
17
18const isMobile = (/Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent));
19
20function hideMyDiv(el, type, d) {
21    const sel = "#" + type + "-content";
22    if (d) el.parent().siblings(sel).hide();
23    else el.parent().siblings(sel).slideUp(150);
24    el.removeClass("active");
25    el.find('span').html('▼');
26}
27
28function showMyDiv(el, type, d) {
29    const sel = "#" + type + "-content";
30    if (d) el.parent().siblings(sel).show();
31    else el.parent().siblings(sel).slideDown(150);
32    el.addClass("active");
33    el.find('span').html('▲');
34}
35
36function ajaxSignup() {
37    // abort any pending request
38    if (signUpRequest)
39        signUpRequest.abort();
40
41    const formData = $("#signup-form").eq(0).serializeArray();
42    formData.push({name: 'csrf_token', value: window['CSRFToken']});
43
44    var signUpRequest = $.ajax({
45        url: "/signup_handler.php",
46        type: "post",
47        data: formData
48    });
49
50    signUpRequest.done(function (response, textStatus, jqXHR) {
51        if (response.indexOf("successful") > 0) {
52            $("#signup-callback-div").removeClass("alert-error").addClass("alert-success");
53            setTimeout(function () {
54                hideMyDiv($('#signup-trigger'), "signup");
55            }, 1500);
56        } else {
57            $("#signup-callback-div").addClass("alert-error");
58        }
59        $("#signup-callback-div").show();
60        $("#signup-callback-text").html(response);
61    });
62
63    signUpRequest.fail(function (jqXHR, textStatus, errorThrown) {
64        $("#signup-callback-div").addClass("alert-error").show().text(JSON.parse(jqXHR.responseText || "Erreur"));
65    });
66
67    signUpRequest.always(function () {
68        try {
69            if (window.turnstile && document.querySelector('.cf-turnstile')) {
70                turnstile.reset(document.querySelector('.cf-turnstile'));
71            }
72        } catch(e) {}
73        document.getElementById('cf-turnstile-response').value = '';
74    });
75}
76
77function ajaxLogin(isLogout) {
78
79    if (typeof(isLogout) !== "boolean") {
80        isLogout = false;
81    }
82
83    // abort any pending request
84    if (loginRequest)
85        loginRequest.abort();
86
87    const formData = $("#login-form").eq(0).serializeArray();
88    formData.push({name: 'csrf_token', value: window['CSRFToken']});
89
90    var loginRequest = $.ajax({
91        url: "/login_handler.php",
92        type: "post",
93        data: formData
94    });
95
96    const redirectAfter = function() {
97      if (window.location.href.indexOf("login_error") > -1) {
98          window.location = 'https://my.calcs.quest/collectors/';
99      } else {
100          location.reload();
101      }
102    };
103
104    loginRequest.done(function (response, textStatus, jqXHR) {
105        console.log(response);
106        if (response.indexOf("successfully") > 0) {
107            $("#login-callback-div").removeClass("alert-warning").addClass("alert-success").text("Success");
108            setTimeout(redirectAfter, 1000);
109        } else if (response.includes("Disconnected")) {
110            redirectAfter();
111        } else {
112            $("#login-callback-div").addClass("alert-error");
113        }
114        $("#login-callback-div").show();
115        $("#login-callback-text").html(response);
116    });
117
118    loginRequest.fail(function (jqXHR, textStatus, errorThrown) {
119        if (isLogout) {
120            redirectAfter();
121        } else {
122            $("#login-callback-div").addClass("alert-error").show().text(JSON.parse(jqXHR.responseText || "Erreur"));
123        }
124    });
125}
126function ajaxLogout()
127{
128    ajaxLogin(true /* isLogout */);
129}
130
131function ajaxProfileEdit(theData, context, okCallback, failCallback)
132{
133    const dataSerialized = theData.eq(0).serializeArray();
134    dataSerialized.push({name: 'profileField', value: theData.attr("rel")});
135    dataSerialized.push({name: 'targetProfileUID', value: window._targetProfileUID});
136    dataSerialized.push({name: 'csrf_token', value: window['CSRFToken']});
137
138    $.ajax({
139        url: "/profileEdit_handler.php",
140        type: "post",
141        data: dataSerialized
142    }).done(function (response, textStatus, jqXHR) {
143        okCallback && okCallback("Updated successfully");
144        backToNormalDisplay(context, true, response);
145    }).fail(function (jqXHR, textStatus, errorThrown) {
146        const msg = `Server error: ${jqXHR.responseJSON || jqXHR.responseText}`;
147        failCallback && failCallback(msg);
148        backToNormalDisplay(context, false, msg);
149    });
150}
151
152function backToNormalDisplay($this, isGood, message) {
153    if ($this) {
154        $this.html("<span class='glyphicon glyphicon-pencil'></span>").removeData('editing').toggleClass("btn-primary");
155        const el = $this.siblings().eq(0);
156        el.replaceWith($("<span />", { text: (message.indexOf("OK") < 0) ? el.attr("oldData") : el.val() }));
157        if (!isGood) {
158            alert("Error! " + (message || ""));
159            console.log('error', message);
160        } else {
161            // todo : popover right avec 'message'
162            console.log("popover serveur ok mais avec message : " + message);
163        }
164    }
165    setTimeout(() => { document.location.reload(); }, 100);
166}
167
168function select2CalcThumbs(selection, container) {
169    const isVariantSelect = container?.id?.startsWith("select2-variantsSelect") ?? false;
170    if ($(selection?.element).is("[aria-disabled='true']") ||
171        (!isVariantSelect && window.currentlySelectedBrand && $(container).is('li[role="option"]') && !selection.text.startsWith(window.currentlySelectedBrand)))
172    {
173        return null;
174    }
175    if (!selection.id && !selection?.element?.dataset?.image) {
176        return selection.text;
177    }
178    const isProto = (!!selection.isproto) || ('1' === (selection.element ? selection.element.dataset.isproto : '0'));
179    const year = selection.year || (selection.element ? selection.element.dataset.year : '');
180    if (!selection.origText) {
181        selection.origText = selection.text;
182        if (selection.text.toLowerCase().includes("plus")) {
183            selection.text += ` ${selection.origText.replaceAll(/\s+plus\b/gi, ' +')},${selection.origText.replaceAll(/\s+plus\b/gi, '+')}`;
184        }
185        if (selection.text.toLowerCase().includes("silver edition")) {
186            selection.text += ` ${selection.origText.replaceAll(/\bsilver edition\b/gi, ' SE')},${selection.origText.replaceAll(/\s+silver edition\b/gi, 'SE')}`;
187        }
188        if (selection.altNames && selection.altNames.length) {
189            selection.text += ` ${selection.altNames.toString()}`;
190        }
191    }
192    const origName = selection.origText;
193    const img = selection.image || (selection.element ? selection.element.dataset.image : null);
194    const thumb = $('<span class="select2-item-thumb-wrapper">').attr('title', origName);
195    thumb.toggleClass('empty', !img).append(img
196        ? $('<img class="select2-item-thumb" alt="thumbnail">').attr('src', img)
197        : $('<span class="select2-item-thumb">'));
198    const label = $('<span class="select2-item-label-wrapper">').attr('title', origName).text(origName);
199    if (selection.dates) {
200        label.attr({ 'data-reldate': selection.dates[0], 'data-discdate': selection.dates[1] });
201    }
202    if (selection.text !== origName) {
203        label.append($('<span>').hide().text(selection.text.slice(origName.length)));
204    }
205    let result = thumb.add(label).add($('<span>').css('flex-grow', 1));
206    if (isProto) {
207        result = result.add($('<span class="select2-item-proto-label-wrapper label label-primary" title="Prototype">').text('Proto'));
208    }
209    if (year && year.length) {
210        result = result.add($('<span class="select2-item-year-label-wrapper">').text(year));
211    }
212    return result;
213}
214
215/**
216 * Keep exported member text literal in formula-enabled spreadsheet clients.
217 */
218function neutralizeSpreadsheetFormula(value) {
219    return typeof value === 'string' && /^[\s\u0000-\u001f\u007f-\u009f]*[=+\-@]/u.test(value) ? "'" + value : value;
220}
221
222function prepareClipboardCell(value) {
223    // Tabs and line breaks inside a field must not create additional clipboard cells.
224    return neutralizeSpreadsheetFormula(typeof value === 'string' ? value.replace(/[\t\r\n\u2028\u2029]/g, ' ') : value);
225}
226
227function prepareSpreadsheetTextExport(data, forClipboard = false) {
228    const prepareCell = forClipboard ? prepareClipboardCell : neutralizeSpreadsheetFormula;
229    data.header = data.header.map(prepareCell);
230    data.body = data.body.map(row => row.map(prepareCell));
231    if (data.footer) {
232        data.footer = data.footer.map(prepareCell);
233    }
234}
235
236/**
237 * Format timestamps to relative time (e.g., "2 hours ago")
238 */
239function formatTimestampsNicely() {
240    document.querySelectorAll('.msgTime[data-ts]').forEach(function(timeElement) {
241        const timestamp = parseInt(timeElement.getAttribute('data-ts'));
242        if (timestamp) {
243            const date = new Date(timestamp * 1000);
244            const now = new Date();
245            const diffMs = now - date;
246            const diffSecs = Math.floor(diffMs / 1000);
247            const diffMins = Math.floor(diffSecs / 60);
248            const diffHours = Math.floor(diffMins / 60);
249            const diffDays = Math.floor(diffHours / 24);
250
251            let timeString;
252            if (diffSecs < 60) {
253                timeString = 'just now';
254            } else if (diffMins < 60) {
255                timeString = diffMins + ' minute' + (diffMins !== 1 ? 's' : '') + ' ago';
256            } else if (diffHours < 24) {
257                timeString = diffHours + ' hour' + (diffHours !== 1 ? 's' : '') + ' ago';
258            } else if (diffDays < 7) {
259                timeString = diffDays + ' day' + (diffDays !== 1 ? 's' : '') + ' ago';
260            } else {
261                const options = { year: 'numeric', month: 'short', day: 'numeric' };
262                timeString = date.toLocaleDateString(undefined, options);
263            }
264
265            timeElement.textContent = timeString;
266            timeElement.title = date.toLocaleString();
267        }
268    });
269}
270
271$(document).ready(function ()
272{
273    const itemType = (document.location.href.includes('/accessories') || (new URL(document.location)).searchParams.get('itemType') === 'accessory') ? 'accessory' : 'calc';
274    const calcType = (window._calcType || (new URL(document.location)).searchParams.get('subtype') || 'GRAPH').toUpperCase() === 'DOTM' ? 'DOTM' : 'GRAPH';
275    const profileContextQuery = itemType === 'calc' && calcType === 'DOTM' ? 'subtype=dotm' : `itemType=${itemType}`;
276    const darkModeStyleURL = $("#darkModeStyle").data('url');
277    const toggleStyle = function(wantsDarkMode) {
278        if (wantsDarkMode) {
279            $("#darkModeStyle").attr('media', 'screen').html(`@import "${darkModeStyleURL}" screen`);
280            localStorage && localStorage.setItem('dark_mode', "1");
281            document.cookie = 'dark_mode=1; expires=' + new Date(2147483647 * 1000).toUTCString();
282        } else {
283            $("#darkModeStyle").attr('media', 'not-all');
284            localStorage && localStorage.setItem('dark_mode', "0");
285            document.cookie = 'dark_mode=0; expires=' + new Date(2147483647 * 1000).toUTCString();
286        }
287    }
288    $("#darkModeDisabler").on('click', function() { toggleStyle(false); });
289    $("#darkModeEnabler").on('click', function() { toggleStyle(true); });
290    if (localStorage) {
291        const darkModeOverride = localStorage.getItem('dark_mode');
292        if (darkModeOverride) { toggleStyle(darkModeOverride === '1'); }
293    }
294
295    let tooltipsDisabled = false;
296    if (!localStorage || !(tooltipsDisabled = (localStorage.getItem('disableTooltips') === 'true')))
297    {
298        $("body").tooltip({selector:'.hasTooltip', container: 'body'});
299        $("nav").tooltip({selector:'.hasLocalTooltip'});
300        $("#profileTable,#setsContainer,#feedItemsContainer,#itemPageDisplay,#collecStats").tooltip({selector:'.hasHTMLTooltip', container: 'body', html:true});
301        $("#userProfileMetadataContainer.verified").tooltip({container:'#userProfileMetadataContainer'});
302    }
303    $("#disableTooltipsCheckbox").prop('checked', tooltipsDisabled).on('change', function() {
304        localStorage && localStorage.setItem('disableTooltips', this.checked);
305    }).parent().tooltip({container: 'body'});
306
307    const applyScientificButtonsVisibility = function() {
308        const shouldHideScientificButtons = localStorage && localStorage.getItem('hideScientificButtons') === 'true';
309        $(".scientific-calc-context-button:not(.active)").toggle(!shouldHideScientificButtons);
310    };
311    $("#hideScientificButtonsCheckbox").prop('checked', localStorage && localStorage.getItem('hideScientificButtons') === 'true').on('change', function() {
312        localStorage && localStorage.setItem('hideScientificButtons', this.checked);
313        applyScientificButtonsVisibility();
314    });
315    applyScientificButtonsVisibility();
316
317    let optionDisplayHostedForSomeoneElse = true;
318    if (localStorage) {
319        if (localStorage.getItem('optionDisplayHostedForSomeoneElse') === 'false') {
320            optionDisplayHostedForSomeoneElse = false;
321        }
322        $("#optionDisplayItemsHostedForSomeoneElse").prop('checked', optionDisplayHostedForSomeoneElse).on('change', function() {
323            localStorage.setItem('optionDisplayHostedForSomeoneElse', this.checked);
324        })
325    }
326
327    if (!isMobile && itemType === 'calc' && /^\/(profile|user|u)\b/.test(document.location.pathname))
328    {
329        $('#profileTable').popover({
330            selector: 'tr td:first-child > div.hasPopover',
331            title: function () { return '<b>' + $(this).find('span.calcName').text() + '</b> <small>' + $(this).find('span.calcName').data('group') + '</small>'; },
332            content: function () { const key = 'item_' + $(this).parent().data('itemid'); return userItemData && (key in userItemData) ? userItemData[key]['overall_descr'] : ''; },
333            html: true,
334            container: 'body',
335            trigger: 'hover focus',
336            placement: $(document).width() < 1650 ? 'right' : 'left',
337            template: '<div class="popover itemInfo" role="tooltip"><div class="arrow"></div><h3 class="popover-title"></h3><img src="" alt=""/><div class="popover-content"></div></div>'
338        }).on('inserted.bs.popover', function (e) {
339            const target = $(e.target);
340            const key = 'item_' + target.parent().data('itemid');
341            if (userItemData && (key in userItemData)) {
342                const popover = $("div.popover#" + target.attr('aria-describedby'));
343                popover.find('img').last().attr('src', userItemData[key]['thumb']);
344            }
345        });
346    }
347
348    $('#login-trigger').click(function (e) {
349        e.stopPropagation();
350        hideMyDiv($('#signup-trigger'), "signup", 1);
351        if ($(this).hasClass('active')) hideMyDiv($(this), "login");
352        else showMyDiv($(this), "login");
353    });
354
355    $('#signup-trigger').click(function (e) {
356        e.stopPropagation();
357        hideMyDiv($('#login-trigger'), "login", 1);
358        if ($(this).hasClass('active')) hideMyDiv($(this), "signup");
359        else showMyDiv($(this), "signup");
360    });
361
362    $('#moncompte-trigger').click(function (e) {
363        e.stopPropagation();
364    });
365
366    $("#oauth-signup-form").on("submit", (ev) => {
367        if (!confirm('This will create your account - make sure you do not already have one, if so, log in first! Continue?')) {
368            ev.stopPropagation();
369            ev.preventDefault();
370        }
371    });
372
373    $(document).click(function (e) {
374        if (!($('#login-trigger, #signup-trigger').hasClass('active'))) { return; }
375        if (e.target && $(e.target).parents("#login").index() < 0) {
376            hideMyDiv($('#login-trigger'), "login");
377            hideMyDiv($('#signup-trigger'), "signup");
378            e.preventDefault();
379            e.stopPropagation();
380        }
381    });
382
383    /**************************************/
384
385    $(".editProfileFieldButton").click(function (e) {
386        const $this = $(this);
387        let el;
388        if ($this.data('editing') !== '1')
389        {
390            $this.html("Save").data('editing', '1').attr('type', 'submit').toggleClass("btn-primary");
391            el = $this.siblings().eq(0);
392            const tmp = $("<input />", {
393                val: el.data('textval') ? JSON.parse(el.data('textval')) : el.text(),
394                oldData: el.text(),
395                rel: $this.parent().prev().attr('id'),
396                name: 'profileValue',
397                type: el.data('opt') === 'num' ? 'number' : 'text',
398                maxlength: $this.parent().prev().attr('id') === 'bio' ? 150 : null
399            });
400            el.replaceWith(tmp);
401            tmp.focus();
402        } else {
403            el = $this.siblings().eq(0);
404            if (el.attr("oldData") != el.val()) {
405                ajaxProfileEdit(el, $this);
406            } else {
407                backToNormalDisplay($this, true, "No update.");
408            }
409        }
410        e.preventDefault();
411        e.stopPropagation();
412    });
413
414    $("#titleSelect, #profileVisibilitySelect").on('change', function(e) {
415        ajaxProfileEdit($('<input />', { val: this.value, rel: $(this).attr('name'), name: 'profileValue' }), null, alert, alert);
416        e.stopPropagation();
417        e.preventDefault();
418    });
419
420    /**************************************/
421
422    $("#signup-submit").click(ajaxSignup);
423    $("#login-submit").click(ajaxLogin);
424    $("#logout-button").click(ajaxLogout);
425
426    $("#signup-form, #login-form").submit(function (e) {
427        return false; // ajax takes over.
428    });
429
430    /**************************************/
431
432    const isValidHttpUrl = function(string) {
433        let url;
434        try {
435            url = new URL(string);
436        } catch (_) {
437            return false;
438        }
439        return url.protocol === "http:" || url.protocol === "https:";
440    }
441
442    let isEditing = false;
443
444    /*
445     * From https://datatables.net/forums/discussion/51536/hide-empty-columns-in-responsive-datatables
446     * check all cells of given datatable and hide each column containing only empty cells
447     * ATTENTION: this will only work if responsive-property in datatables is set to true
448     */
449    const hideEmptyColumns = function(selector) {
450        const emptyColumnsIndexes = []; // store index of empty columns here
451        // check each column separately for empty cells
452        $(selector).find('th').each(function(i) {
453            if ($(this).index() <= 7) return; // special case for us...
454            // get all cells for current column
455            const cells = $(this).parents('table').find('tr td:nth-child(' + (i + 1) + ')');
456            let emptyCells = 0;
457
458            cells.each(function(cell) {
459                // increase emptyCells if current cell is empty, trim string to remove possible space
459s in cell
460                if ($(this).html().trim() === '') {
461                    emptyCells++;
462                }
463            });
464
465            // if all cells are empty push current column to emptyColumns
466            if (emptyCells === $(cells).length) {
467                emptyColumnsIndexes.push($(this).index());
468            }
469        });
470
471        // only make changes if there are columns to hide
472        if (emptyColumnsIndexes.length > 0) {
473            const dt = $(selector).DataTable();
474            dt.columns(emptyColumnsIndexes).visible(false);
475            if (isMobile) {
476                dt.columns.adjust().responsive.rebuild();
477                dt.columns.adjust().responsive.recalc();
478            } else {
479                dt.columns.adjust();
480            }
481        }
482    }
483
484    let dtSearch = (myFilter) => {}; // will be replaced when ready
485
486    const setupDataTable = function() {
487        const theDOMTable = $('table.makedatatable');
488        const isCollectorsTable = theDOMTable.data('label') === 'collectorsTable';
489        const isProfileTable = theDOMTable.data('label') === 'profileTable';
490        const isLatestTable = theDOMTable.data('label') === 'latestTable';
491        const isSearchTable = theDOMTable.data('label') === 'searchTable';
492        const isAchievementsTable = theDOMTable.data('label') === 'achievementsTable';
493        const isSearchOrProfile = (isSearchTable || isProfileTable);
494        const isCollectionTable = (isSearchTable || isProfileTable || isLatestTable);
495        const needAdvancedFiltering = isSearchOrProfile;
496        const collectionColumnDefs = isCollectionTable ? [
497            {
498                targets: [0],
499                orderData: [1, 3, 6, 8]
500            }
501        ] : [];
502        const profileColumnDefs = isProfileTable ? [
503            {
504                targets: 'export-only',
505                visible: false,
506                searchable: false,
507                orderable: false,
508                searchPanes: { show: false },
509            },
510        ] : [];
511        const searchOrProfileColumnDefs = isSearchOrProfile ? [
512            ...(isProfileTable ? [{
513                targets: [12],
514                searchBuilderType: 'date'
515            }] : []),
516            {
517                targets: isProfileTable ? [0,8,10,11,12,13,14] : [0,8,9,10,11],
518                searchPanes: { show: false },
519            },
520            {
521                targets: isProfileTable ? [1,5,6,7,2,3,4,9] : [1,5,6,7,2,3,4,12],
522                searchPanes: { show: true },
523            },
524        ] : [];
525        $.fn.DataTable.ext.pager.numbers_length = 5;
526
527        const howManyItemsHostedForSomeoneElse = theDOMTable.find("tr[data-userishosternotowner]").length;
528        const table = theDOMTable.DataTable({
529            retrieve: true,
530            pageLength: (isCollectorsTable || isSearchTable) ? 50 : 100,
531            dom: "<'row'<'col-sm-3'l><'col-sm-6'p><'col-sm-3'f>>" + "<'row'<'col-xs-12 col-sm-12 col-md-12'tr>>" + "<'row'<'col-sm-5'i><'col-sm-7'p>>",
532            colReorder: false,
533            responsive: (isMobile && isAchievementsTable) ? {
534                details: {
535                    display: $.fn.dataTable.Responsive.display.childRowImmediate,
536                        type: 'none',
537                        target: ''
538                }
539            } : isMobile,
540            scrollX: !(isMobile && isAchievementsTable),
541            order: isCollectorsTable ? [[ 3, 'desc' ]]
542                 : (isLatestTable ? [[ 12 , 'desc' ]]
543                 : (isAchievementsTable ? [[ 7, 'desc' ], [ 2, 'desc' ], [ 0 , 'asc' ], [ 1 , 'asc' ]]
544                 : [[ 0, 'asc' ], [ 6 , 'desc' ], [ 5 , 'desc' ], [ 7 , 'desc' ], [ 8 , 'desc' ]])),
545            buttons: [
546                { extend: 'colvis', columns: ':not(.export-only)' },
547                { extend: 'copy', title: null, customize: text => prepareClipboardCell(document.title) + '\n\n' + text, exportOptions: { customizeData: data => prepareSpreadsheetTextExport(data, true) } },
548                { extend: 'csv', exportOptions: { customizeData: prepareSpreadsheetTextExport } },
549                'excel',
550                { extend: 'print', title: () =>
550 $('<div>').text(document.title).html().replaceAll('*', '&#42;') },
551            ],
552            searchHighlight: true,
553            searchBuilder: (needAdvancedFiltering) ? {
554                columns: [1,2,3,4,5,6,7,9,10,12],
555            } : null,
556            columnDefs: [
557                ...collectionColumnDefs,
558                ...profileColumnDefs,
559                ...searchOrProfileColumnDefs,
560            ],
561            preDrawCallback: function (settings) {
562                const needPaginate = (new $.fn.dataTable.Api(settings)).page.info().pages > 1;
563                $(this).closest('.dataTables_wrapper').find('.dataTables_paginate,.dataTables_length').toggle(needPaginate);
564            },
565            initComplete: function() {
566                const dt = this;
567                const filterInputBox = $('div.dataTables_filter input[type=search]');
568                dtSearch = function(myFilter) {
569                    if (myFilter && myFilter.length) {
570                        const origFilterValue = myFilter;
571                        const regexMatches = myFilter.match(/^\/(.*)\/i?$/)
572                        if (regexMatches) {
573                            myFilter = regexMatches[1];
574                        }
575                        const needCI = !regexMatches || origFilterValue.endsWith('/i');
576                        dt.api().search(myFilter.trim(), !!regexMatches, !regexMatches, needCI).draw();
577                        filterInputBox.val(origFilterValue);
578                    } else {
579                        dt.api().search('').draw();
580                    }
581                }
582                if (isProfileTable && $("#tableEditBtn").length === 0) {
583                    hideEmptyColumns(this); // only hide if we can't edit
584                }
585
586                if (typeof(URLSearchParams) !== 'undefined')
587                {
588                    const urlParams = (new URL(document.location)).searchParams;
589                    const myFilter = (urlParams.get('filter') || '').trim();
590                    const itemId = (urlParams.get('itemid') || '').trim();
591
592                    if (myFilter.length || itemId.length) {
593                        dtSearch(myFilter.length ? myFilter : `id=${itemId}`);
594                        $('#profileTable_filter').fadeOut(150).fadeIn(150).fadeOut(150).fadeIn(150).fadeOut(150).fadeIn(150).fadeOut(150).fadeIn(150);
595                    }
596                }
597
598                filterInputBox.off().on('click keyup', function(e) {
599                    dtSearch(this.value);
600                    // If search field is changed while in edit mode, apply edit stuff to new search results
601                    isEditing && $("#tableEditBtn").click();
602                });
603
604                const dtTableTogglesAbove = $("#tableContainer > div.dataTables_wrapper > div:first-child > div:first-child");
605
606                dtTableTogglesAbove.prepend('<div id="toggleRowDetailsDiv" class="checkbox"><label><input id="toggleRowDetailsInput" type="checkbox"> Toggle all collapsed/expanded details</label></div>');
607                document.getElementById("toggleRowDetailsInput").addEventListener('change', (e) => {
608                    dt.api().rows().every(function (){
609                        const tr = $(this.node());
610                        if (e.currentTarget.checked ^ tr.is('.dt-hasChild.parent')) {
611                            tr.find("td:first").click();
612                        }
613                    });
614                });
615
616                // Owner/hoster toggling thing
617                if (howManyItemsHostedForSomeoneElse > 0) {
618                    dtTableTogglesAbove
619                        .prepend('<div class="checkbox">' +
620                                     `<label><input id="hostingOption" type="checkbox" name="displayHostedForSomeoneElse" value="displayHostedForSomeoneElse" checked> Include the ${howManyItemsHostedForSomeoneElse} hosted for someone else</label>` +
621                                 '</div>');
622
623                    const checkbox = document.getElementById("hostingOption");
624                    $.fn.dataTable.ext.search.push((settings, searchData) => {
625                        if (settings.nTable.id !== 'profileTable'){
626                            return true;
627                        }
628                        const displayHostedForSomeoneElse = checkbox.checked;
629                        const isHostedForSomeoneElse = searchData[12]?.includes("Hosted for") ?? false;
630                        return (!isHostedForSomeoneElse) || displayHostedForSomeoneElse;
631                    });
632                    checkbox.addEventListener('change', () => { dt.api().search(filterInputBox.val()).draw(); });
633                    if (optionDisplayHostedForSomeoneElse === false) {
634                        checkbox.checked = false;
635                        dt.api().search(filterInputBox.val()).draw();
636                    }
637                }
638
639                theDOMTable.toggleClass("notReponsiveCollapsed", !theDOMTable.hasClass("collapsed"));
640
641                new $.fn.dataTable.FixedHeader(dt, {
642                    headerOffset: $("nav.navbar").outerHeight()
643                });
644
645                setTimeout(() => dt.api().columns.adjust(), 400);
646            }
647        });
648
649        table.on('responsive-resize', (e, datatable, columns) => {
650            const count = columns.reduce((a, b) => b === false ? a + 1 : a, 0 );
651            theDOMTable.toggleClass("notReponsiveCollapsed", count === 0);
652        });
653
654        table.on('draw', function() {
655            if (!localStorage || !(localStorage.getItem('disableTooltips') === 'true')) {
656                theDOMTable.find('.hasTooltip').tooltip('destroy').tooltip({container: 'body'});
657            }
658        });
659
660        table.on('column-visibility.dt', function (e, settings, column, state) {
661            if (isMobile) {
662                table.columns.adjust().responsive.rebuild();
663                table.columns.adjust().responsive.recalc();
664            } else {
665                table.columns.adjust();
666            }
667        });
668
669        table.buttons().container().appendTo($('#afterTable'));
670        $('#afterTable a.btn').addClass('btn-sm');
671
672        if (/^\/(profile|u|user|latest|search)\b/.test(document.location.pathname)) {
673            // Thumbnail toggler
674            $("#profileTable_wrapper > div:first-child > div:first-child").prepend('<div id="thumbTogglerInputWrapper" class="checkbox"><label><input id="thumbTogglerInput" type="checkbox" checked> Show thumbnails</label></div>');
675            $('#thumbTogglerInput').on('change', function() {
676                $('#profileTable').toggleClass('noCalcimg');
677                localStorage && localStorage.setItem('want_calc_thumbnails', this.checked);
678            });
679            if (localStorage && localStorage.getItem('want_calc_thumbnails') === 'false') {
680                $('#profileTable').addClass('noCalcimg');
681                $('#thumbTogglerInput').attr('checked', false);
682            }
683            if (needAdvancedFiltering) {
684                let advFiltersInited = false;
685                let advSearchInited = false;
686                $(`#profileTable_wrapper > div:first-child > div:last-child`).prepend('<div style="display:block;text-align: right"><div id="advFiltersTogglerInputWrapper" class="checkbox"><label><input id="advFiltersTogglerInput" type="checkbox"> Show advanced filters</label></div><div id="advSearchTogglerInputWrapper" class="checkbox"><label><input id="advSearchTogglerInput" type="checkbox"> Show advanced search</label></div></div>');
687                $('#advFiltersTogglerInput').on('change', function() {
688                    if (advFiltersInited) {
689                        $("div.dtsp-panesContainer").toggle(this.checked);
690                    } else {
691                        new $.fn.dataTable.SearchPanesST(table, {
692                            cascadePanes: true,
693                            viewTotal: true,
694                            // TODO: maybe check what to do with xxx + preDrawCallback to at least display/hide a loading overlay...
695                            // dtOpts: {
696                            //     preDrawCallback: function() {
697                            //         console.log(this, 'draw finished')
698                            //     },
699                            // },
700                        });
701                        table.searchPanes.container().prependTo(table.table().container());
702                        table.searchPanes.resizePanes();
703                        advFiltersInited = true;
704                    }
705                });
706                $('#advSearchTogglerInput').on('change', function() {
707                    if (advSearchInited) {
708                        $("div.dtsb-searchBuilder").toggle(this.checked);
709                    } else {
710                        new $.fn.dataTable.SearchBuilder(table, {});
711                        table.searchBuilder.container().prependTo(table.table().container());
712                        advSearchInited = true;
713                    }
714                });
715            }
716        }
717
718        // inline-edit
719        let editables = [];
720        let previousTablePageLen = -1;
721        const renderPriceEditableValue = function(td, value) {
722            const setTitle = td.dataset.pricedSetTitle || '';
723            const setUrl = td.dataset.pricedSetUrl || '';
724            const setIndicator = setTitle
725                ? `<a href="${escapeHtml(setUrl || '#')}" class="price-set-indicator hasTooltip" title="${escapeHtml(setTitle)}" onclick="event.stopPropagation();" aria-label="Part of priced set"><i class="fa-solid fa-object-group"></i></a>`
726                : '';
727
728            const sym = td.dataset.currencySymbol || '';
729            const code = td.dataset.currencyCode || '';
730            const pos = td.dataset.currencySymbPos || 'prefix';
731            const hasValue = value !== null && typeof value !== 'undefined' && String(value) !== '';
732            let priceHtml = '';
733
734            if (hasValue && sym) {
735                let v = String(value);
736                if (v.endsWith('.00')) { v = v.slice(0, -3); }
737                const nbsp = '&#8239;';
738                const amountHtml = (pos === 'suffix') ? `${escapeHtml(v)}${nbsp}${escapeHtml(sym)}` : `${escapeHtml(sym)}${nbsp}${escapeHtml(v)}`;
739                priceHtml = `<span title='${escapeHtml(code)}'>${amountHtml}</span>`;
740            } else if (hasValue) {
741                priceHtml = escapeHtml(String(value));
742            }
743
744            td.innerHTML = setIndicator
745                ? `<span class="price-cell-value">${priceHtml}</span>${setIndicator}`
746                : priceHtml;
747        };
748        $("#tableEditBtn").on('click', function()
749        {
750            previousTablePageLen = table.page.len() || -1;
751            table.page.len(-1).draw(); // show all
752            $(".removeCalcBtn, .transferItemBtn").removeClass('hide');
753            if (editables.length) {
754                editables.forEach(element => $(element).editable('enable'));
755            } else {
756              let currentlyEditedOriginalSource = '';
757              $(table.cells().nodes()).filter('.editable').each(function(idx, el){
758                 if ($(el).hasClass("priceEditable")) {
759                    $(el).off('click.priceSetConfirm').on('click.priceSetConfirm', function(e) {
760                        if (this.dataset.pricedSetTitle && !$(this).data('pricedSetQuickeditConfirmed')) {
761                            const keepEditing = confirm(`${this.dataset.pricedSetTitle}\n\nThis item is already part of a priced set. Setting an item-specific price will not change the set price. Continue editing this item price?`);
762                            if (!keepEditing) {
763                                e.preventDefault();
764                                e.stopImmediatePropagation();
765                                return false;
766                            }
767                            $(this).data('pricedSetQuickeditConfirmed', true);
768                        }
769                    });
770                 }
771                 editables.push($(el).editable({
772                    url: '/collectionEdit_handler.php',
773                    pk: function () {
774                        return $("#profileTable thead tr th").eq(table.cell( this ).index().column).data('colname');
775                    },
776                    params: function(p) {
777                        p.csrf_token = window.CSRFToken;
778                        p.user_id = window._targetProfileUID;
779                        p.action = 'edit';
780                        p.row_id = $(this).closest('tr').data('rowid');
781                        if (typeof(p.value) === 'object' && p.value.length === 0) {
782                            p.value = '';
783                        }
784                        p.item_type = $("#addItemForm input[name='item_type']").val();
785                        p.subtype = $("#addItemForm input[name='subtype']").val() || calcType.toLowerCase();
786                        return p;
787                    },
788                    source: function() {
789                        if ($(this).hasClass("factorySelect")) {
790                            return allFactories.map(function(obj){ return { value: obj.id, text: obj.code }; });
791                        } else if ($(this).hasClass("conditionSelect")) {
792                            return [{value:'', text:''}].concat(allConditions);
793                        } else if ($(this).hasClass("extrasList")) {
794                            const isCdRom = $(this).attr('data-item-group') === 'CD-ROM';
795                            const extras = itemType === 'accessory'
796                                ? allExtras.filter(extra => extra !== 'Faceplate(s)' && (!isCdRom || extra !== 'CD(s)'))
797                                : allExtras;
798                            return extras.map(function(txt){ return { value: txt, text: txt }; });
799                        } else if ($(this).hasClass("variantSelect")) {
800                            currentlyEditedOriginalSource = $(this).html();
801                            const itemID = +$(this).data('itemid');
802                            const data = allItemVariants[itemID]?.map(arr => ({ value: arr[0], text: arr[1] })) ?? [];
803                            if (data.length === 0) {
804                                data.push({ value: '', text: '(no variant available)' });
805                            } else if (!itemsThatNeedVariants[itemID]) {
806                                data.push({ value: '', text: '(not a variant)' });
807                            }
808                            return data;
809                        } else {
810                            return null;
811                        }
812                    },
813                    display: ($(el).hasClass("photoURL") ? function(value) {
814                        this.innerHTML = (!value.length || !isValidHttpUrl(value)) ? '' : `<a href="${escapeHtml(value)}" target='_blank'><span class='glyphicon glyphicon-picture'></span></a>`;
815                    } : ($(el).hasClass("variantSelect") ? function(value, choices, responseValue) {
816                        this.innerHTML = currentlyEditedOriginalSource;
817                        if (responseValue) {
818                            this.dataset.variantid = responseValue;
819                            $(this).find('span.inlineVariantName').text((choices.filter(el => el.value === +responseValue)[0]?.text ?? '?'));
820                        } else if (!value && el.dataset.itemid in allItemVariants) {
821                            this.dataset.variantid = undefined;
822                            $(this).find('span.inlineVariantName').html(`<span class='editable-empty'>Choose a variant...</span>`);
823                        }
824                    } : ($(el).hasClass("priceEditable") ? function(value) {
825                        renderPriceEditableValue(this, value);
826                    } : null))),
827                    validate: ($(el).hasClass("photoURL") ? function(value) {
828                        if (value && value.length && !isValidHttpUrl(value)) { return 'Invalid URL'; }
829                    } : null),
830                    success: ($(el).hasClass("priceEditable") ? function(response, newValue) {
831                        // After amount saved, if a currency was chosen/changed, also save it
832                        const $td = $(this);
833                        const pendingCur = $td.data('pendingCurrencyId');
834                        if (typeof pendingCur !== 'undefined' && String(pendingCur) !== String(this.dataset.currencyId || '')) {
835                            const rowId = $td.closest('tr').data('rowid');
836                            $.ajax({
837                                url: '/collectionEdit_handler.php',
838                                type: 'post',
839                                data: { csrf_token: window.CSRFToken, user_id: window._targetProfileUID, action: 'edit', item_type: $("#addItemForm input[name='item_type']").val(), subtype: $("#addItemForm input[name='subtype']").val() || calcType.toLowerCase(), row_id: rowId, pk: 'price_currency_id', value: pendingCur },
840                                success: () => {
841                                    const cur = (window.allCurrencies || []).find(c => String(c.id) === String(pendingCur));
842                                    if (cur) {
843                                        this.dataset.currencyId = String(cur.id);
844                                        $td.data('currency-id', String(cur.id)); // Keep jQuery data cache in sync for future reads
845                                        this.dataset.currencyCode = cur.code || '';
846                                        this.dataset.currencySymbol = cur.symbol || '';
847                                        this.dataset.currencySymbPos = cur.symbol_position || 'prefix';
848                                        // re-render with new currency
849                                        const v = (newValue === null || typeof newValue === 'undefined') ? '' : String(newValue);
850                                        renderPriceEditableValue(this, v);
851                                    }
852                                    $td.removeData('pendingCurrencyId');
853                                }
854                            });
855                        }
856                    } : null)
857                 }).on('shown', function() {
858                    const popup = $("div.popover.editable-popup");
859                    popup.css('visibility', 'hidden').detach().appendTo($('#profileTable_wrapper'));
860                    if ($(`td.editable.hwRevTextInput[aria-describedby='${popup.attr('id')}']`).length) {
861                        popup.find('input').attr('list', 'hwRevInputList');
862                    }
863                    setTimeout(function(){
864                        popup.css('top', $("#thumbTogglerInput")[0].checked ? '+=15px' : '+=8px').css('visibility', 'visible');
865                        popup.find('input').focus();
866                    }, 20);
867                    // If price cell, add currency selector and wire saving
868                    if ($(this).hasClass('priceEditable')) {
869                        const $td = $(this);
870                        // Use live dataset first (jQuery .data caches the initial data-* and may be stale)
871                        const curId = $td[0].dataset.currencyId || $td.data('currency-id') || String(defaultCurrencyID);
872                        const $inputContainer = popup.find('.editable-input');
873                        const $sel = $('<select class="form-control input-sm" style="position: relative; z-index: 101"></select>');
874                        (window.allCurrencies || []).forEach(function(c){
875                            const opt = $(`<option></option>`).attr('value', c.id).text(`${c.code} (${c.symbol})`);
876                            if (String(c.id) === String(curId || '')) { opt.attr('selected', 'selected'); }
877                            $sel.append(opt);
878                        });
879                        $inputContainer.append($sel);
880                        // remember original amount to detect whether amount changed
881                        try { $td.data('originalAmount', (popup.find('input').val() || '')); } catch(e) {}
882                        // track selection for later save
883                        $td.data('pendingCurrencyId', $sel.val());
884                        $sel.on('change', function(){
885                            $td.data('pendingCurrencyId', this.value);
886                        });
887                        // ensure currency saves even if amount unchanged
888                        popup.find('.editable-submit').off('click.price').on('click.price', async function(){
889                            const pending = $td.data('pendingCurrencyId');
890                            if (typeof pending === 'undefined' || String(pending) === String($td[0].dataset.currencyId || '')) { return; }
891                            // If amount changed, let the x-editable success handler handle the currency save once
892                            const currVal = (popup.find('input').val() || '');
893                            const origVal = ($td.data('originalAmount') || '');
894                            if (String(currVal) !== String(origVal)) { return; }
895                            const rowId = $td.closest('tr').data('rowid');
896                            $.ajax({
897                                url: '/collectionEdit_handler.php',
898                                type: 'post',
899                                data: { csrf_token: window.CSRFToken, user_id: window._targetProfileUID, action: 'edit', item_type: $("#addItemForm input[name='item_type']").val(), subtype: $("#addItemForm input[name='subtype']").val() || calcType.toLowerCase(), row_i
899d: rowId, pk: 'price_currency_id', value: pending },
900                                success: () => {
901                                    const cur = (window.allCurrencies || []).find(c => String(c.id) === String(pending));
902                                    if (cur) {
903                                        $td[0].dataset.currencyId = String(cur.id);
904                                        $td.data('currency-id', String(cur.id)); // Keep jQuery data cache in sync for future reads
905                                        $td[0].dataset.currencyCode = cur.code || '';
906                                        $td[0].dataset.currencySymbol = cur.symbol || '';
907                                        $td[0].dataset.currencySymbPos = cur.symbol_position || 'prefix';
908                                        // Re-render using current input amount (even if unchanged)
909                                        const val = popup.find('input').val() || '';
910                                        renderPriceEditableValue($td[0], val);
911                                    }
912                                    $td.removeData('pendingCurrencyId');
913                                    $td.removeData('originalAmount');
914                                }
915                            });
916                        });
917                    }
918                 }));
919              });
920            }
921            isEditing = true;
922            $("#tableEditBtn").addClass('hide');
923            $("#tableEditBtnDone").removeClass('hide');
924        });
925
926        $("#tableEditBtnDone").on('click', function(e) {
927            e.stopPropagation();
928            $(".removeCalcBtn, .transferItemBtn").addClass('hide');
929            $("#tableEditBtn").removeClass('hide');
930            $("#tableEditBtnDone").addClass('hide');
931            const oldFilter = $('div.dataTables_filter input[type=search]').val();
932            dtSearch('');
933            $( table.cells().nodes() ).editable('disable');
934            table.page.len(previousTablePageLen).draw();
935            isEditing = false;
936            dtSearch(oldFilter);
937        });
938
939        $("#profileTable").on("click", ".removeCalcBtn", function() {
940            if (confirm(`Are you sure you want to delete that ${itemType}?`))
941            {
942                const row = $(this).closest('tr');
943                $.ajax({
944                    url: "/collectionEdit_handler.php",
945                    type: "post",
946                    data: {
947                        csrf_token: window.CSRFToken,
948                        user_id: window._targetProfileUID,
949                        action: 'delete',
950                        row_id: row.data('rowid'),
951                        item_type: itemType,
952                        subtype: calcType.toLowerCase()
953                    }
954                }).done(function (response, textStatus, jqXHR)
955                {
956                    row.fadeOut();
957                    $("#collecStats").load(`/u/${window._targetProfileUID}?${profileContextQuery} #collecStats`);
958                }).fail(function (jqXHR, textStatus, errorThrown)
959                {
960                    alert("There was an error deleting this item...")
961                    console.log("Error", textStatus, errorThrown)
962                });
963            }
964        });
965
966        $("#profileTable").on("click", ".transferItemBtn", function() {
967            const itemId = $(this).data('item-id');
968            $("#transferItemId").val(itemId).data('child-item-count', 0);
969            $("#transferToUser").val('');
970            $("#transferMessage").val('');
971            $("#transferChildItemsNotice").hide().text('');
972            $("#confirmTransferBtn").prop('disabled', true).text('Checking...');
973            $("#transferModal").modal('show');
974
975            $.ajax({
976                url: "/transfer_handler.php",
977                type: "post",
978                data: {
979                    csrf_token: window.CSRFToken,
980                    action: 'get_child_count',
981                    user_item_id: itemId
982                }
983            }).done(function(response) {
984                const childItemCount = response.success ? Number(response.child_item_count || 0) : 0;
985                $("#transferItemId").data('child-item-count', Number.isFinite(c
985hildItemCount) ? childItemCount : 0);
986                if (childItemCount > 0) {
987                    const childItemLabel = childItemCount === 1 ? 'child item' : 'child items';
988                    $("#transferChildItemsNotice")
989                        .text(`This item has ${childItemCount} ${childItemLabel}. ${childItemCount === 1 ? 'It' : 'They'} will be transferred at the same time. You can detach ${childItemCount === 1 ? 'it' : 'them'} from the Sets page before transferring.`)
990                        .show();
991                }
992            }).fail(function(jqXHR, textStatus, errorThrown) {
993                alert("There was an error checking child items for this transfer.");
994                console.log("Error", textStatus, errorThrown);
995                $("#transferModal").modal('hide');
996            }).always(function() {
997                $("#confirmTransferBtn").prop('disabled', false).text('Transfer');
998            });
999        });
1000
1001        $("#confirmTransferBtn").on("click", function() {
1002            const itemId = $("#transferItemId").val();
1003            const toUser = $("#transferToUser").val().trim();
1004            const message = $("#transferMessage").val().trim();
1005
1006            if (!toUser) {
1007                alert("Please enter a username to transfer to.");
1008                return;
1009            }
1010
1011            const childItemCount = Number($("#transferItemId").data('child-item-count') || 0);
1012            const childItemText = childItemCount > 0
1013                ? `\n\nThis will also transfer ${childItemCount} child item${childItemCount === 1 ? '' : 's'} at the same time. You can detach ${childItemCount === 1 ? 'it' : 'them'} from the Sets page before transferring.`
1014                : '';
1015            if (!confirm("Transfer this item to " + toUser + "?" + childItemText)) {
1016                return;
1017            }
1018
1019            $.ajax({
1020                url: "/transfer_handler.php",
1021                type: "post",
1022                data: {
1023                    csrf_token: window.CSRFToken,
1024                    action: 'initiate',
1025                    user_item_id: itemId,
1026                    to_user: toUser,
1027                    message: message
1028                }
1029            }).done(function(response) {
1030                if (response.success) {
1031                    alert(response.message);
1032                    $("#transferModal").modal('hide');
1033                    if (response.message.includes("transferred successfully")) {
1034                        location.reload();
1035                    }
1036                } else {
1037                    alert(response.message || "There was an error initiating the transfer.");
1038                }
1039            }).fail(function(jqXHR, textStatus, errorThrown) {
1040                alert("There was an error initiating the transfer.");
1041                console.log("Error", textStatus, errorThrown);
1042            });
1043        });
1044    };
1045    setupDataTable();
1046
1047
1048    /**************************************/
1049
1050    const checkProtoChoiceAndToggleWarning = function(callerEl) {
1051        setTimeout(() => {
1052            const isProto = (callerEl.querySelector(`[value="${callerEl.value}"]`).dataset.isproto === '1') || ($("span.select2 .select2-selection__rendered > span.select2-item-proto-label-wrapper.label").length > 0);
1053            $("input[name='is_prototype'][value='0']").attr('disabled', isProto);
1054            $(`input[name='is_prototype'][value='${isProto ? 1 : 0}']`).click();
1055        }, 50);
1056    }
1057
1058    const variantSelect = $('#variantsSelect');
1059    const variantSelectFormRow = $('#variantSelectFormRow');
1060    const parentItemFormRow = $('#parentItemFormRow');
1061    const parentUserItemSelect = $('#parentUserItemSelect');
1062    const itemIdentificationSection = $('#itemIdentificationSection');
1063    const accessoryRestrictedExtraInputs = $('#addItemForm input[name="extras[]"]').filter((_, element) => {
1064        return element.value === 'CD(s)' || element.value === 'Faceplate(s)';
1065    });
1066
1067    const faceplateExtraInput = $('input[name="extras[]"][value="Faceplate(s)"]');
1068    faceplateExtraInput.prop('disabled', true);
1069    let parentItemAvailabilityRequest = null;
1070
1071    const getSelectedModelGroup = function() {
1072        const selectedItemId = $('#calcsSelect').val() || '';
1073        return ($('#calcsSelect').select2('data')[0]?.itemGroup || window.itemGroupsById?.[selectedItemId] || '').toString();
1074    };
1075
1076    const setItemIdentificationVisible = function(visible) {
1077        if (!itemIdentificationSection.length) {
1078            return;
1079        }
1080        itemIdentificationSection.toggleClass('hide', !visible);
1081        if (!visible) {
1082            itemIdentificationSection.find('input[type="text"], input
1082:not([type]), textarea').val('');
1083            itemIdentificationSection.find('select').val('');
1084            itemIdentificationSection.find('#newTISerialInfo,#tiFactoryWarning,#tiDatestampWarning,#tiHwRevWarning').hide().text('');
1085            $('#tiSpecificInfo,#ti85SpecificInfo,#tiSpecificImage,#casioSerialInfo').hide();
1086        }
1087    };
1088
1089    const resetParentUserItemSelect = function() {
1090        if (!parentUserItemSelect.length) {
1091            return;
1092        }
1093        parentUserItemSelect.val(null).trigger('change');
1094        parentUserItemSelect.prop('disabled', true);
1095        parentItemFormRow.addClass('hide');
1096
1097        if (parentItemAvailabilityRequest) {
1098            parentItemAvailabilityRequest.abort();
1099        }
1100
1101        const accessoryId = $('#calcsSelect').val() || '';
1102        if (!accessoryId) {
1103            return;
1104        }
1105        if (variantSelect.prop('required') && !variantSelect.val()) {
1106            return;
1107        }
1108
1109        const request = $.ajax({
1110            url: '/collectionEdit_handler.php',
1111            type: 'POST',
1112            dataType: 'json',
1113            data: {
1114                csrf_token: window.CSRFToken,
1115                user_id: window._targetProfileUID,
1116                action: 'getCompatibleParentItems',
1117                item_type: 'accessory',
1118                accessory_id: accessoryId,
1119                acc_variant_id: variantSelect.val() || '',
1120                term: ''
1121            }
1122        });
1123        parentItemAvailabilityRequest = request;
1124
1125        request.done(function(data) {
1126            if (parentItemAvailabilityRequest !== request) {
1127                return;
1128            }
1129            const hasCompatibleParents = (data.results || []).length > 0;
1130            parentUserItemSelect.prop('disabled', !hasCompatibleParents);
1131            parentItemFormRow.toggleClass('hide', !hasCompatibleParents);
1132        }).fail(function(xhr, status) {
1133            if (parentItemAvailabilityRequest !== request) {
1134                return;
1135            }
1136            if (status !== 'abort') {
1137                parentUserItemSelect.prop('disabled', true);
1138                parentItemFormRow.addClass('hide');
1139            }
1140        }).always(function() {
1141            if (parentItemAvailabilityRequest === request) {
1142                parentItemAvailabilityRequest = null;
1143            }
1144        });
1145    };
1146
1147    if (parentUserItemSelect.length) {
1148        parentUserItemSelect.select2({
1149            placeholder: 'No parent item',
1150            allowClear: true,
1151            dropdownParent: $('#addCalcModal'),
1152            templateResult: select2CalcThumbs,
1153            templateSelection: select2CalcThumbs,
1154            ajax: {
1155                url: '/collectionEdit_handler.php',
1156                type: 'POST',
1157                dataType: 'json',
1158                delay: 250,
1159                data: function(params) {
1160                    return {
1161                        csrf_token: window.CSRFToken,
1162                        user_id: window._targetProfileUID,
1163                        action: 'getCompatibleParentItems',
1164                        item_type: 'accessory',
1165                        accessory_id: $('#calcsSelect').val() || '',
1166                        acc_variant_id: variantSelect.val() || '',
1167                        term: params.term || ''
1168                    };
1169                },
1170                processResults: function(data) {
1171                    return { results: data.results || [] };
1172                }
1173            }
1174        });
1175        resetParentUserItemSelect();
1176    }
1177
1178    $('#calcsSelect').on('change', function() {
1179        variantSelect.prop("disabled", true);
1180        variantSelect.html("");
1181        variantSelect.append($("<option/>", {value: "", text: 'Standard (not a variant) / Other (=> comment)'}));
1182
1183        $('#datestampInput').trigger('keyup');
1184
1185        const variantsForCalc = allItemVariants[this.value] || [];
1186        let hasOnlyBaseVariants = variantsForCalc.length >= 2; // may get toggled later.
1187        if (variantsForCalc.length === 0) {
1188            variantSelectFormRow.addClass('hide');
1189        } else {
1190            variantSelectFormRow.removeClass('hide');
1191            variantSelect.prop("disabled", false);
1192            $.each(variantsForCalc, function (_, value) {
1193                const year = value[3] ? (value[3].match(/\(([12][09]\d\d)\)/) || [])[1] : '';
1194                variantSelect.append($("<option/>", { value: value[0], text: value[1], 'data-image': value[2], 'data-year': year, 'data-isproto': value[4] ? '1' : '0', 'data-isbase': value[5] }));
1195                if (value[5] === 0) { hasOnlyBaseVariants = false; }
1196            });
1197
1198            const calcNeedsVariantSelect = hasOnlyBaseVariants || itemsThatNeedVariants[this.value];
1199            variantSelect.prop("required", calcNeedsVariantSelect);
1200
1201            variantSelect.select2({
1202                placeholder: calcNeedsVariantSelect ? "Choose a variant" : "Standard (not a variant) / Other (=> comment)",
1203                templateResult: select2CalcThumbs,
1204                templateSelection: select2CalcThumbs,
1205                dropdownParent: $('#addCalcModal'),
1206                allowClear: true,
1207                closeOnSelect: true
1208            });
1209            if (calcNeedsVariantSelect) {
1210                setTimeout(() => { variantSelectFormRow.fadeOut(100).fadeIn(100).fadeOut(100).fadeIn(100); }, 200);
1211            }
1212        }
1213
1214        checkProtoChoiceAndToggleWarning(this);
1215
1216        const selectedCalcName = this.querySelector("option:checked").innerText || '';
1217        const wizardRow = $('#wizardPromptRow');
1218        const wizardLink = $('#wizardPromptLink');
1219        faceplateExtraInput.prop('disabled', true);
1220        $('#tiSpecificInfo,#ti85SpecificInfo,#tiSpecificImage,#casioSerialInfo').hide();
1221        if (/^(TI|Vernier)/.test(selectedCalcName)) {
1222            $('#tiSpecificInfo,#tiSpecificImage').css('display', 'inline-block');
1223            if (selectedCalcName === 'TI-85') {
1224                $('#ti85SpecificInfo').css('display', 'inline-block');
1225            } else if (/TI-84 Plus (C )?Silver Edition/.test(selectedCalcName)) {
1226                faceplateExtraInput.prop('disabled', false);
1227            }
1228        } else if (selectedCalcName.startsWith("Casio")) {
1229            $('#casioSerialInfo').css('display', 'inline-block');
1230        }
1231
1232        const isCdRom = itemType === 'accessory' && getSelectedModelGroup() === 'CD-ROM';
1233        setItemIdentificationVisible(!isCdRom);
1234        accessoryRestrictedExtraInputs.each((_, element) => {
1235            const input = $(element);
1236            const shouldHide = itemType === 'accessory'
1237                && (element.value === 'Faceplate(s)' || (isCdRom && element.value === 'CD(s)'));
1238            input.closest('label').toggleClass('hide', shouldHide);
1239            if (shouldHide) {
1240                input.prop('checked', false).prop('disabled', true);
1241            } else if (element.value === 'CD(s)') {
1242                input.prop('disabled', false);
1243            }
1244        });
1245
1246        if (wizardRow.length && wizardLink.length) {
1247            const showWizard = !!window.itemWizardTypes?.[this.value];
1248            if (showWizard) {
1249                const variantId = $('#variantsSelect').val() || '';
1250                const calcId = this.value || '';
1251                wizardLink.attr('href', `/calc_wizard.php?user_id=${window._targetProfileUID}&calc_id=${calcId}&calc_variant_id=${variantId}`);
1252                wizardRow.removeClass('hide');
1253            } else {
1254                wizardRow.addClass('hide');
1255            }
1256        }
1257        resetParentUserItemSelect();
1258    });
1259
1260    variantSelect.on('change', function() {
1261        checkProtoChoiceAndToggleWarning(this);
1262        const wizardRow = $('#wizardPromptRow');
1263        const wizardLink = $('#wizardPromptLink');
1264        if (wizardRow.length && wizardLink.length && !wizardRow.hasClass('hide')) {
1265            const calcId = $('#calcsSelect').val() || '';
1266            const variantId = $('#variantsSelect').val() || '';
1267            wizardLink.attr('href', `/calc_wizard.php?user_id=${window._targetProfileUID}&calc_id=${calcId}&calc_variant_id=${variantId}`);
1268        }
1269        resetParentUserItemSelect();
1270    });
1271
1272    const getDateOfWeek = function(w, y) {
1273        return new Date(y, 0, (1 + (w - 1) * 7)); // 1st of January + 7 days for each week
1274    }
1275
1276    $('#serialInput').on('keyup', function(e) {
1277        const serial = $(this).val().trim().toUpperCase();
1278        const parts = [];
1279        for (const matches of serial.matchAll(/\b([A-H])(?:([1-9])(T)|(?:([0-4][0-9])|(5[0-2]))([0-9T]))([0-9A-F]{5})\b/ig))
1280        {
1281            for (const match of matches)
1282            {
1283                // each part can only be up to 5 chars, so the extra check makes sure we don't add the whole part being caught for instance with other serials.
1284                if (match && match !== serial && match.length <= 5) {
1285                    parts.push(match)
1286                }
1287            }
1288        }
1289        if (parts.length)
1290        {
1291            const [ yearCode, weekNum, unk, id ] = parts;
1292            const year = 2019 + (yearCode.charCodeAt(0) - 'A'.charCodeAt(0));
1293            const theDate = getDateOfWeek(weekNum, year);
1294            $('#newTISerialInfo').html(`This TI-WalMart serial format means: assembled during week <b>${weekNum}</b> of <b>${year}</b> (<i>${theDate.toLocaleString('default', { month: "long", day: "numeric" })}</i>), line/~model <b>${unk}</b>, and calc has ID <b>${id}</b>.`).show();
1295        } else {
1296            $('#newTISerialInfo').html('').hide();
1297        }
1298    });
1299
1300    // Function to check if the combination of factory, datecode, and hardware revision is valid
1301    function validateCalcDataInRange() {
1302        const $factoryWarning = $('#tiFactoryWarning');
1303        const $dsWarning = $('#tiDatestampWarning');
1304        const $hwRevWarning = $('#tiHwRevWarning');
1305
1306        // Clear all warnings initially
1307        $factoryWarning.css('display', 'none').text('');
1308        $dsWarning.css('display', 'none').text('');
1309        $hwRevWarning.css('display', 'none').text('');
1310
1311        const ds = $('#datestampInput').val().trim().toUpperCase();
1312        const factoryCode = $('#factorySelect option:enabled:selected').text().split(' ')[0] || '';
1313        const hwRev = $('#hwRevInput').val().trim().toUpperCase();
1314        const selectedCalcName = $('#calcsSelect option:enabled:selected').text() || '';
1315
1316        // Basic datecode validation
1317        if (ds.length > 0) {
1318            if (!/^\d{4}$/.test(ds)) {
1319                $dsWarning.css('display', 'inline-block').text('⚠️ This should be 4 digits');
1320                return;
1321            }
1322            if (!/^(0[1-9]|1[0-2])/.test(ds)) {
1323                $dsWarning.css('display', 'inline-block').text('⚠️ Make sure this is the correct month');
1324                return;
1325            }
1326            // TODO: in 2030, update me :D
1327            const regYear = new RegExp('(9[0-9]|[0-1][0-9]|2[0-' + (new Date().getFullYear() % 10) + '])$');
1328            if (!regYear.test(ds)) {
1329                $dsWarning.css('display', 'inline-block').text('⚠️ Make sure this is the correct year');
1330                return;
1331            }
1332            const selectedCalcDates = $('#calcsSelect').select2('data')[0]?.dates ?? null;
1333            if (selectedCalcDates) {
1334                const calcYear = +(ds.substring(2, 4));
1335                const calcFullYear = calcYear + ((calcYear < 70) ? 2000 : 1900);
1336                if (selectedCalcDates[0]) {
1337                    const releaseYear = +selectedCalcDates[0].substring(0, 4);
1338                    if (calcFullYear < releaseYear-1) {
1339                        $dsWarning.css('display', 'inline-block').text(`⚠️ This (${calcFullYear}) seems to be before release (${releaseYear})`);
1340                        return;
1341                    }
1342                }
1343                if (selectedCalcDates[1]) {
1344                    const discYear = +selectedCalcDates[1].substring(0, 4);
1345                    if (calcFullYear > discYear) {
1346                        $dsWarning.css('display', 'inline-block').text(`⚠️ This (${calcFullYear}) seems to be after discontinuation (${discYear})`);
1347                        return;
1348                    }
1349                }
1350            }
1351        }
1352
1353        // Check if the combination exists in calcSheetData
1354        if (typeof calcSheetData !== 'undefined' && calcSheetData && selectedCalcName) {
1355            try {
1356                // Find the calculator data
1357                const adjustedCalcName = selectedCalcName.replace(/[&()]/g, '').trim();
1358                const calcData = calcSheetData[adjustedCalcName];
1359                if (!calcData || !calcData.byFactory) {
1360                    // No data for this calculator or invalid data structure
1361                    return;
1362                }
1363
1364                // Check if the factory exists for this calculator (if factory is selected)
1365                if (factoryCode) {
1366                    const factoryData = calcData.byFactory[factoryCode];
1367                    if (!factoryData) {
1368                        // Get list of available factories
1369                        const availableFactories = Object.keys(calcData.byFactory).filter(f => f !== '?').join(', ');
1370                        if (availableFactories) {
1371                            $factoryWarning.css('display', 'inline-block').text(`⚠️ Are you sure it is Factory ${factoryCode}? For this model, we only know about ${availableFactories}`);
1372                        }
1373                        return;
1374                    }
1375
1376                    // If hardware revision is also provided, check if it's valid for this factory
1377                    if (hwRev) {
1378                        // Get the selected variant and prototype status
1379                        const selectedVariant = $('#variantsSelect').data('select2') ? ($('#variantsSelect').select2('data')[0]?.text || '') : '';
1380                        const isViewScreenVariant = selectedVariant === 'ViewScreen' || selectedVariant === 'VB';
1381                        const isProtoSelected = $("input[name=is_prototype]:checked").val() === '1';
1382
1383                        // Filter available revisions based on the selected variant and prototype status
1384                        const availableRevs = Object.keys(factoryData)
1385                            .filter(r => {
1386                                return  !((/__(vsc|vb)$/i.test(r) && !isViewScreenVariant)
1387                                    || (!/__(vsc|vb)$/i.test(r) && isViewScreenVariant)
1388                                    || ( /EVT|DVT|PVT|PROTO|dev/i.test(r) && !isProtoSelected)
1389                                    || (!/EVT|DVT|PVT|PROTO|dev/i.test(r) && isProtoSelected));
1390                            });
1391
1392                        const revisedHwRev = hwRev.toUpperCase() + (isViewScreenVariant ? (selectedVariant === 'ViewScreen' ? '__vsc' : '__vb') 
1392: '');
1393
1394                        // Check if the hardware revision exists for this factory
1395                        if (!availableRevs.includes(revisedHwRev)) {
1396                            let msg = `⚠️ Are you sure it is revision ${hwRev}? It seems unknown to us for this ${isProtoSelected ? 'prototype ' : ''}${isViewScreenVariant ? `${selectedVariant} ` : ''}model by factory ${factoryCode}.`;
1397                            if (availableRevs) {
1398                                msg += ` We only know ${availableRevs.map((r) => r.replace(/(__vsc|__vb)$/, '')).join(', ')}`;
1399                            }
1400                            $hwRevWarning.css('display', 'inline-block').text(msg);
1401                            return;
1402                        }
1403
1404                        // If datecode is also provided, check if it falls within the known ranges
1405                        if (ds.length > 0) {
1406                            // Convert datecode to YYYY-MM format for comparison
1407                            const calcYear = +(ds.substring(2, 4));
1408                            const calcFullYear = calcYear + ((calcYear < 70) ? 2000 : 1900);
1409                            const calcMonth = +(ds.substring(0, 2));
1410                            const dateToCheck = `${calcFullYear}-${calcMonth.toString().padStart(2, '0')}`;
1411
1412                            // Check if the datecode falls within any of the date ranges for this revision
1413                            const revData = factoryData[revisedHwRev] ?? [];
1414                            let isInRange = false;
1415                            for (const range of revData) {
1416                                if (dateToCheck >= range.dateStart && dateToCheck <= range.dateEnd) {
1417                                    isInRange = true;
1418                                    break;
1419                                }
1420                            }
1421
1422                            if (!isInRange) {
1423                                // Format date ranges for display
1424                                const dateRanges = revData.map(range => `${range.dateStart} to ${range.dateEnd}`).join(', ');
1425                                if (dateRanges) {
1426                                    $dsWarning.css('display', 'inline-block').text(`⚠️ Please make sure ${ds} (${dateToCheck}) is correct. We only know (so far) about [${dateRanges}] for factory ${factoryCode} rev ${hwRev}, for this ${isProtoSelected ? 'prototype ' : ''}${isViewScreenVariant ? `${selectedVariant} ` : ''} model.`);
1427                                }
1428                            } else {
1429                                $hwRevWarning.css('display', 'inline-block').html(`👍 This combination of information falls within our known data.`);
1430                            }
1431                        }
1432                    }
1433                }
1434            } catch (error) {
1435                console.error("Error in calculator data validation:", error);
1436            }
1437        }
1438    }
1439
1440    $('#datestampInput').on('keyup', validateCalcDataInRange);
1441    $('#factorySelect').on('change', validateCalcDataInRange);
1442    $('#hwRevInput').on('keyup', validateCalcDataInRange);
1443    $('#calcsSelect').on('change', function() {
1444        $('#datestampInput').trigger('keyup');
1445        validateCalcDataInRange();
1446    });
1447
1448    $('#addCalcModal').on('show.bs.modal', function() {
1449        if (isEditing) {
1450            $("#tableEditBtnDone").click();
1451        }
1452    });
1453
1454    $('#addItemForm').submit(function(e) {
1455        const isItemCalculator = !document.location.href.includes("accessories");
1456        const itemCondition = $("#conditionSelect").val();
1457        const isItemProto = $("input[name=is_prototype]:checked").val() === '1';
1458        if (isItemCalculator && !isItemProto && (itemCondition === null || itemCondition !== 'New in box')) {
1459            if (!$("#serialInput").val()) {
1460                if (!confirm("Warning: you did not fill some fields (like the serial number), are you sure you want to submit it as-is? (note that all these elements are not actually 'secret', so there are no privacy concerns to be had - to the contrary they may later help you, if it gets stolen...)")) {
1461                    e.preventDefault();
1462                    e.stopImmediatePropagation();
1463                    return false;
1464                }
1465            }
1466        }
1467        const submitBtn = $('#addItemForm button[type=submit]');
1468        submitBtn.prop('disabled', true).text("Adding...");
1469        e.preventDefault();
1470        const manufName = $("#calcsSelect").find(":selected").text().split(/\b/g)[0] || '';
1471        const dataToSubmit = $("#addItemForm").serialize() + `&manuf=${manufName}`;
1472        $.post("/collectionEdit_handler.php", dataToSubmit, function() {
1473            confetti && confetti({ particleCount: 100, spread: 70, origin: { y: 0.8 }, zIndex:9999 });
1474            if (resetCalcsSelect2Form) {
1475                resetCalcsSelect2Form();
1476                $('#variantsSelect').prop("disabled", true).val("");
1477            }
1478            submitBtn.removeClass('btn-primary').addClass('btn-success').text('Success!').prop('disabled', true);
1479            $("#statsAndTableContainer").load(`/u/${window._targetProfileUID}?${profileContextQuery} #statsAndTableContainer`, setupDataTable);
1480        }).fail(function(err) {
1481            console.log("error: ", err);
1482            alert(`Error while adding the ${itemType} :(`);
1483        }).always(function() {
1484            setTimeout(function() { submitBtn.prop('disabled', false).removeClass('btn-success').addClass('btn-primary').text(`Add ${itemType}`); }, 1000);
1485        });
1486    });
1487
1488    if (/^\/(collectors|profile|u|user|latest|achievements|search)\b/.test(document.location.pathname))
1489    {
1490        window.addEventListener('keydown', (e) => {
1491            // check if ctrl (or cmd on macOS) + F is pressed
1492            if ((e.ctrlKey || e.metaKey) && e.keyCode === 70)
1493            {
1494                $("div.dataTables_filter input[type=search]").focus();
1495                e.stopPropagation();
1496                e.preventDefault();
1497                return false;
1498            }
1499        });
1500    }
1501
1502    {
1503        const $dropdown = $(".dropdown");
1504        const $dropdownToggle = $(".dropdown-toggle");
1505        const $dropdownMenu = $(".dropdown-menu");
1506        $(window).on("load resize", function () {
1507            if (this.matchMedia("(min-width: 768px)").matches) {
1508                $dropdown.hover(
1509                    function () {
1510                        const $this = $(this);
1511                        $this.addClass("open");
1512                        $this.find($dropdownToggle).attr("aria-expanded", "true");
1513                        $this.find($dropdownMenu).addClass("open");
1514                    },
1515                    function () {
1516                        const $this = $(this);
1517                        $this.removeClass("open");
1518                        $this.find($dropdownToggle).attr("aria-expanded", "false");
1519                        $this.find($dropdownMenu).removeClass("open");
1520                    }
1521                );
1522            } else {
1523                $dropdown.off("mouseenter mouseleave");
1524            }
1525        });
1526    }
1527});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.