// ==UserScript==
// @name Neopets - SDB ALL Quantity Buttons
// @namespace https://www.scriptneo.com/
// @version 1.0.0
// @description Adds an ALL button to each Safety Deposit Box quantity stepper and an ALL Quantities on Page bulk button.
// @author Scriptneo
// @license MIT
// @match *://www.neopets.com/safetydeposit.phtml*
// @match *://neopets.com/safetydeposit.phtml*
// @grant none
// @run-at document-idle
// @downloadURL https://www.scriptneo.com/scripts/download.php?id=48
// @updateURL https://www.scriptneo.com/scripts/download.php?id=48
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_ID = 'sdb-all-quantity-tools';
const PAGE_TOOLBAR_ID = `${SCRIPT_ID}-toolbar`;
const INLINE_BUTTON_CLASS = `${SCRIPT_ID}-inline`;
const STATUS_ID = `${SCRIPT_ID}-status`;
let scanQueued = false;
function addStyles() {
if (document.getElementById(`${SCRIPT_ID}-styles`)) return;
const style = document.createElement('style');
style.id = `${SCRIPT_ID}-styles`;
style.textContent = `
#${PAGE_TOOLBAR_ID} {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin: 12px 0;
padding: 10px 12px;
border: 1px solid rgba(72, 51, 13, 0.28);
border-radius: 8px;
background: rgba(255, 247, 224, 0.96);
box-sizing: border-box;
}
#${PAGE_TOOLBAR_ID} .sdb-all-page-btn,
.${INLINE_BUTTON_CLASS} {
border: 1px solid #7b4f10;
border-radius: 5px;
background: linear-gradient(#ffd75a, #efa91d);
color: #2d210d;
font-family: Arial, sans-serif;
font-weight: 700;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
#${PAGE_TOOLBAR_ID} .sdb-all-page-btn {
min-height: 36px;
padding: 7px 15px;
font-size: 13px;
}
.${INLINE_BUTTON_CLASS} {
min-width: 42px;
height: 30px;
margin-left: 5px;
padding: 0 7px;
font-size: 11px;
line-height: 28px;
vertical-align: middle;
}
#${PAGE_TOOLBAR_ID} .sdb-all-page-btn:hover,
.${INLINE_BUTTON_CLASS}:hover {
filter: brightness(1.05);
}
#${PAGE_TOOLBAR_ID} .sdb-all-page-btn:active,
.${INLINE_BUTTON_CLASS}:active {
transform: translateY(1px);
}
#${PAGE_TOOLBAR_ID} .sdb-all-page-btn:disabled,
.${INLINE_BUTTON_CLASS}:disabled {
cursor: not-allowed;
filter: grayscale(0.7);
opacity: 0.6;
}
#${STATUS_ID} {
flex-basis: 100%;
min-height: 18px;
color: #48330d;
font-family: Arial, sans-serif;
font-size: 12px;
font-weight: 600;
text-align: center;
}
.sdb-cell-stepper .${INLINE_BUTTON_CLASS} {
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (max-width: 700px) {
#${PAGE_TOOLBAR_ID} {
margin-left: 6px;
margin-right: 6px;
}
#${PAGE_TOOLBAR_ID} .sdb-all-page-btn {
width: 100%;
}
}
`;
document.head.appendChild(style);
}
function parseInteger(value) {
const match = String(value ?? '').replace(/,/g, '').match(/\d+/);
return match ? Number(match[0]) : 0;
}
function findInput(stepperArea) {
if (!stepperArea) return null;
return stepperArea.querySelector(
'input[type="number"], input[inputmode="numeric"], input[type="text"], input:not([type])'
);
}
function findMaximum(stepperArea, input) {
const candidates = [
input?.max,
input?.getAttribute('max'),
input?.getAttribute('aria-valuemax'),
stepperArea?.getAttribute('data-max'),
stepperArea?.querySelector('[aria-valuemax]')?.getAttribute('aria-valuemax'),
];
for (const candidate of candidates) {
const parsed = parseInteger(candidate);
if (parsed > 0) return parsed;
}
const row = stepperArea?.closest('tr');
const desktopQty = parseInteger(row?.querySelector('.sdb-qty-cell')?.textContent);
if (desktopQty > 0) return desktopQty;
const mobileQty = parseInteger(row?.querySelector('.sdb-item-qty-mobile')?.textContent);
if (mobileQty > 0) return mobileQty;
return 0;
}
function setInputValue(input, value) {
if (!input || !Number.isFinite(value) || value < 0) return false;
const nextValue = String(Math.floor(value));
const prototype = Object.getPrototypeOf(input);
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
|| Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
if (descriptor?.set) {
descriptor.set.call(input, nextValue);
} else {
input.value = nextValue;
}
// NpNumericStepper emits Vue's update:modelValue from its native input
// handler. Dispatch both events so the visible field and Vue state agree.
input.dispatchEvent(new InputEvent('input', {
bubbles: true,
inputType: 'insertText',
data: nextValue,
}));
input.dispatchEvent(new Event('change', { bubbles: true }));
return parseInteger(input.value) === Math.floor(value);
}
function getStepperAreaFromPlusButton(plusButton) {
return plusButton.closest('.sdb-cell-stepper')
|| plusButton.closest('.np-numeric-stepper')
|| plusButton.parentElement;
}
function setStepperToMaximum(stepperArea) {
const input = findInput(stepperArea);
const maximum = findMaximum(stepperArea, input);
if (!input || maximum <= 0) return false;
return setInputValue(input, maximum);
}
function findPlusButtons(root = document) {
return [...root.querySelectorAll('button.np-stepper-btn')].filter((button) => {
const label = `${button.textContent || ''} ${button.getAttribute('aria-label') || ''} ${button.title || ''}`
.trim()
.toLowerCase();
return label === '+'
|| label.includes('increase')
|| label.includes('increment')
|| (button.textContent || '').trim() === '+';
});
}
function addInlineAllButtons() {
for (const plusButton of findPlusButtons()) {
if (plusButton.dataset.sdbAllQuantityAttached === '1') continue;
const existing = plusButton.parentElement?.querySelector(`.${INLINE_BUTTON_CLASS}`);
if (existing) {
plusButton.dataset.sdbAllQuantityAttached = '1';
continue;
}
const allButton = document.createElement('button');
allButton.type = 'button';
allButton.className = INLINE_BUTTON_CLASS;
allButton.textContent = 'ALL';
allButton.title = 'Set this item to the full quantity in your Safety Deposit Box';
allButton.setAttribute('aria-label', 'Set this item quantity to all');
allButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const stepperArea = getStepperAreaFromPlusButton(plusButton);
const success = setStepperToMaximum(stepperArea);
if (!success) {
setStatus('Could not read the maximum quantity for this item.', true);
return;
}
const row = plusButton.closest('tr');
const itemName = row?.querySelector('.sdb-item-name')?.textContent?.trim();
setStatus(itemName ? `${itemName} set to ALL.` : 'Quantity set to ALL.');
});
plusButton.insertAdjacentElement('afterend', allButton);
plusButton.dataset.sdbAllQuantityAttached = '1';
}
}
function findPageSteppers() {
return [...document.querySelectorAll('.sdb-table tbody .sdb-cell-stepper')]
.filter((cell) => findInput(cell));
}
function selectAllVisibleRows() {
const masterCheckbox = document.querySelector(
'.sdb-table thead input.sdb-item-checkbox, .sdb-list-select-all input.sdb-item-checkbox'
);
if (masterCheckbox && !masterCheckbox.checked) {
masterCheckbox.click();
return;
}
// Fallback for future markup changes: select one checkbox per visible row.
for (const row of document.querySelectorAll('.sdb-table tbody tr')) {
const checkbox = row.querySelector(
'.sdb-col-select input.sdb-item-checkbox, input.sdb-checkbox--mobile'
);
if (checkbox && !checkbox.checked) checkbox.click();
}
}
async function setAllQuantitiesOnPage(button) {
button.disabled = true;
setStatus('Setting every visible item to its full quantity...');
try {
selectAllVisibleRows();
// Let Vue apply its selected-row default of 1 before replacing it
// with the true maximum quantity.
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
let changed = 0;
let failed = 0;
for (const stepperArea of findPageSteppers()) {
if (setStepperToMaximum(stepperArea)) changed += 1;
else failed += 1;
}
if (changed === 0) {
setStatus('No item quantity steppers were found. Switch to List view first.', true);
} else if (failed > 0) {
setStatus(`${changed} item quantities set to ALL; ${failed} could not be read.`, true);
} else {
setStatus(`${changed} item quantities set to ALL and selected. Choose a bulk action to continue.`);
}
} finally {
button.disabled = false;
queueScan();
}
}
function setStatus(message, isError = false) {
const status = document.getElementById(STATUS_ID);
if (!status) return;
status.textContent = message || '';
status.style.color = isError ? '#a31616' : '#48330d';
}
function addPageToolbar() {
if (document.getElementById(PAGE_TOOLBAR_ID)) return;
const table = document.querySelector('.sdb-table');
const headerBar = document.querySelector('.sdb-header-bar');
if (!table || !headerBar) return;
const toolbar = document.createElement('div');
toolbar.id = PAGE_TOOLBAR_ID;
const button = document.createElement('button');
button.type = 'button';
button.className = 'sdb-all-page-btn';
button.textContent = 'ALL Quantities on Page';
button.title = 'Select every visible item and set each Apply Quantity field to the full owned quantity';
button.addEventListener('click', () => setAllQuantitiesOnPage(button));
const status = document.createElement('div');
status.id = STATUS_ID;
status.setAttribute('aria-live', 'polite');
status.textContent = 'Sets all visible quantities to maximum; nothing is submitted until you confirm an action.';
toolbar.append(button, status);
headerBar.insertAdjacentElement('afterend', toolbar);
}
function scan() {
scanQueued = false;
if (!document.getElementById('sdb-vue-app')) return;
addStyles();
addPageToolbar();
addInlineAllButtons();
}
function queueScan() {
if (scanQueued) return;
scanQueued = true;
requestAnimationFrame(scan);
}
function start() {
addStyles();
queueScan();
const root = document.getElementById('sdb-vue-app') || document.body;
const observer = new MutationObserver(queueScan);
observer.observe(root, {
childList: true,
subtree: true,
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
})();