Page builder utils library

Utilities library to help build pages.

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.greasyfork.org/scripts/580181/1838058/Page%20builder%20utils%20library.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Page builder utils library
// @namespace    PageBuilderLib
// @description  Utilities library to help build pages.
// @author       kekekeKaj
// @version      2.0.0.3
// @grant        none
// ==/UserScript==  

// IMPORTANT: update version number when publishing!!!
function getPageBuilderLibInfo() {
    return 'Page Builder Lib v2.0.0.3';
}

// --- GENERIC BUILDERS ---

/**
 * @param {string} tagName The element tag.
 * @param {string} id Id for the element. Element will not have ID if not present.
 * @param {boolean} isInline Boolean flag indicating whether element should be displayed inline.
 * @returns {Element} The built element.
 */
function buildElement(tagName, id = null, isInline = false) {
    const element = document.createElement(tagName);
    if (id != null) {
        element.id = id;
    }

    if (isInline) {
        setInlineBlockDisplayStyle(element);
    } else {
        setBlockDisplayStyle(element);
    }

    return element;
}

function buildDivWithChildren(id, children) {
    const divElement = buildElement('div', id);
    children.forEach(child => divElement.appendChild(child));

    return divElement;
}

function buildBtn(id, labelTxt, clickFunc) {
    const buttonElement = buildElement('button', id, true);
    buttonElement.setAttribute('class', 'inputButton');
    buttonElement.innerText = labelTxt;
    
    if (clickFunc != null) {
        buttonElement.addEventListener("click", clickFunc);
    }

    return buttonElement;
}

function buildTextArea(id, width, height, defaultText = '') {
    let textAreaElement = buildElement('textarea', id);
    
    textAreaElement.cols = width;
    textAreaElement.rows = height;
    textAreaElement.className = 'textarea';
    textAreaElement.innerText = defaultText;

    return textAreaElement;
}

function setBlockDisplayStyle(element) {
    element.style.display = 'block';
    element.style.marginTop = getPageBuilderConsts().stdMargin;
}

function setInlineBlockDisplayStyle(element) {
    element.style.display = 'inline-block';
    element.style.marginRight = getPageBuilderConsts().stdMargin;
}

/**
 * Builds a selector element.
 * 
 * @param {string} id The ID to assign to the selector.
 * @param {string} defaultOptionText The default option text, e.g. "Select a melon"
 * @param {string[]} optionTexts The list of options texts.
 * @param callFunction the callback function for when the selection changes.
 * 
 * @returns the selector element.
 */
    function buildSelectorElement(id, defaultOptionText, optionTexts, callFunction) {
    let selectElement = buildElement('select', id, false); 

    selectElement.setAttribute('class', 'inputtext');

    if (defaultOptionText != null) {
        const defaultOption = document.createElement('option');
        defaultOption.setAttribute('selected', true);
        defaultOption.setAttribute('disabled', true);
        defaultOption.innerText = defaultOptionText;
        selectElement.appendChild(defaultOption);
    }

    for (let optionText of optionTexts) {
        const optionElement = document.createElement('option')
        optionElement.setAttribute('value', optionText);
        optionElement.innerHTML = optionText;

        selectElement.appendChild(optionElement);
    }

    if (callFunction != null) {
        selectElement.addEventListener('change', callFunction); 
    }

    return selectElement;
}

function buildLabel(labelTxt, isInline = false, isBold = true, id = null) {
    const labelElement = buildElement('label', id, isInline);

    if (isBold) {
        labelElement.style.fontWeight = 'bold';
    }

    labelElement.innerText = labelTxt;

    return labelElement;
}

function buildLabeledInputElement(inputId, labelText, inputWidth, isInline = true) {
    const labelElement = buildLabel(labelText, isInline);
    labelElement.style.width = '75px';
    const inputElement = buildElement('input', inputId, isInline);
    inputElement.setAttribute('size', `${inputWidth}`);

    return buildDivWithChildren(null, [ labelElement, inputElement ]);
}

function buildSpinnerElement() {
    const spinnerElement = document.createElement('img');
    spinnerElement.height = '10';
    spinnerElement.width = '10';
    spinnerElement.src = '/images/xmlhttp-loader.gif';

    return spinnerElement;
}

function buildAnchorElement(url, text) {
    const anchorElement = document.createElement('a');
    anchorElement.href = url;
    anchorElement.innerText = text;

    return anchorElement;
}

function buildTickedIconElement() {
    const tickedIconElement = document.createElement('span');
    tickedIconElement.append(document.createTextNode('✅'));

    return tickedIconElement;
}

function buildStatusLabel(id, labelTxt = '') {
    const statusLabel = buildLabel(labelTxt, false, true, id);
    statusLabel.style.color = 'red';

    return statusLabel;
}

/**
 * Builds a set of label and value elements.
 * 
 * @param {string} labelText the text for the label.
 * @param {string} fieldId The ID to assign to the field (for accessing the value).
 * @returns the containing element.
 */
 function buildLabelAndFieldElement(labelText, fieldId, isInline = true) {
    const valueTag = isInline ? 'label' : 'p'
    const valueLabel = buildElement(valueTag, fieldId, isInline);
    
    let labelAndValueDiv = buildDivWithChildren(
        null, [ buildLabel(labelText, true), valueLabel ]);

    return labelAndValueDiv;
}

function buildLabelAndValueDisplayElement(labelText, value, isInline = true, isFormatted = false) {
    const valueTag = isInline ? 'label' : 'p'
    const valueLabel = buildElement(valueTag, null, isInline);
    if (isFormatted) {
        valueLabel.innerHTML = value;
    } else {
        valueLabel.innerText = value;
    }
    
    let labelAndValueDiv = buildDivWithChildren(
        null, [ buildLabel(labelText, true), valueLabel ]);

    return labelAndValueDiv;
}

function buildMsgPreviewPanelFromSpecs(specs) {
    const msgPreviewPanel = buildDivWithChildren(
        null, 
        [
            buildLabelAndFieldElement('To', specs.previewRecipientSpec.id),
            buildLabelAndFieldElement('Subject', specs.previewMsgSubjectSpec.id),
            buildLabelAndFieldElement('Message', specs.previewMsgBodySpec.id, false)
        ]);
    msgPreviewPanel.style.display = 'none';

    return msgPreviewPanel;
}

function buildRecipientOptions(reportedUser, reporter) {
    const constants = getPageBuilderConsts();
    return [
        reportedUser != null && reportedUser.trim().length > 0 ? 
            `${constants.reportedUserPrefix} ${reportedUser}` : '!UNKNOWN VICTIM!',
        reporter != null && reporter.trim().length > 0 ? 
            `${constants.reporterPrefix} ${reporter}` : '!UNKNOWN REPORTER!'
    ];
}

// --- BUILDER THAT USES SPECIFICATIONS ---

function buildLabeledInputFromSpecs(specs) {
    return buildLabeledInputElement(specs.id, specs.labelTxt, 50);
}

function buildSelectorFromSpec(spec) {
    return buildSelectorElement(
        spec.id, spec.defaultTxt, spec.optionTxts, spec.callFunc);
}

function buildTextAreaFromBlueprint(blueprint) {
    return buildTextArea(
        blueprint.id, blueprint.width, blueprint.height, blueprint.defaultTxt);
}

function buildButtonFromSpec(spec) {
    return buildBtn(spec.id, spec.labelTxt, spec.callFunc);
}

function buildMsgPanelFromSpecs(specs) {
    const templateSelector = buildSelectorFromSpec(specs.msgTemplateSelectorSpec);
    const recipientSelector = buildSelectorFromSpec(specs.msgRecipientSelectorSpec);

    const subjectPanel = buildLabeledInputFromSpecs(specs.msgSubjectSpec);
    const msgBodyLabel = buildLabel(specs.msgBodySpec.labelTxt, false);
    const msgBodyInput = buildTextAreaFromBlueprint(specs.msgBodySpec);

    const actionElements = [];
    specs.msgActionButtonsSpecs.forEach(btnSpec => {
        actionElements[actionElements.length] = buildButtonFromSpec(btnSpec)
    });
    const actionPanel = buildDivWithChildren(null, actionElements);

    const statusLabel = buildStatusLabel(specs.statusDisplaySpec.id, specs.statusDisplaySpec.labelTxt);
    
    const msgPreviewPanel = buildMsgPreviewPanelFromSpecs(specs);

    const msgPanel = buildDivWithChildren(
        specs.id,
        [
            templateSelector,
            recipientSelector,
            subjectPanel,
            msgBodyLabel,
            msgBodyInput,
            actionPanel,
            statusLabel,
            msgPreviewPanel
        ]
    )

    return msgPanel;
}

// --- SPECIFICATION RELATED FUNCTIONS ---

function draftMsgPanelSpecs() {
    return {
        msgPanelId: 'msgPanel',
        msgTemplateSelectorSpec: draftSelectorSpec(
            'msgTemplateSelector', 'Select a template', [], null),
        msgRecipientSelectorSpec: draftSelectorSpec('msgRecipient', 'Select a recipient', [], null),
        msgSubjectSpec: draftGenericControlSpec('msgSubject', 'Subject'),
        msgBodySpec: draftTextAreaSpec('msgBody', 'Message'),
        msgActionButtonsSpecs: [ 
            draftGenericControlSpec('previewBtn', 'Preview'),
            draftGenericControlSpec('sendBtn', 'Send!') ],
        statusDisplaySpec: draftGenericControlSpec('sendMsgStatus', '', null),
        previewRecipientSpec: draftGenericControlSpec('previewRecipient', 'Recipient:'),
        previewMsgSubjectSpec: draftGenericControlSpec('previewMsgSubject', 'Subject:'),
        previewMsgBodySpec: draftGenericControlSpec('previewMsgBody', 'Message body:'),
        sendPreviewedMsgButtonSpec: draftGenericControlSpec('sendPreviewedMessage', 'Send!')
    };
}

function draftGenericControlSpec(idStr, labelText = null, callFunction = null) {
    return {
        id: idStr,
        labelTxt: labelText,
        callFunc: callFunction
    };
}

function draftSelectorSpec(idStr, defaultOptionText, optionTexts, callFunction) {
    return {
        id: idStr,
        defaultTxt: defaultOptionText,
        optionTxts: optionTexts,
        callFunc: callFunction
    };
}

function draftTextAreaSpec(idStr, labelText, defaultText = '', boxWidth = 100, boxHeight = 20) {
    return {
        id: idStr,
        labelTxt: labelText,
        defaultTxt: defaultText,
        width: boxWidth,
        height: boxHeight
    }
}

// ----- EVENT RELATED PROCESSING -----

function msgTemplateSelected(specs, msgTemplateMap, reportType) {
    const msgSummary = document.getElementById(specs.msgTemplateSelectorSpec.id).value;
    const msgBody = msgTemplateMap.get(msgSummary);

    const constants = getPageBuilderConsts();
    const action = extractActionType(msgSummary);
    const recipientPrefix = identifyRecipient(constants.reportedUserPrefix, constants.reporterPrefix, action);

    selectRecipient(specs.msgRecipientSelectorSpec.id, recipientPrefix);
    document.getElementById(specs.msgSubjectSpec.id).value = 
        buildSubjectTemplate(action, reportType);
    document.getElementById(specs.msgBodySpec.id).value = msgBody;
}

function msgActionBtnClicked(specs, csrfToken, appendix, modSettings, isSendAction = false) {
    const msgDetails = buildMsgDetails(specs, appendix, modSettings);
    const statusDisplay = document.getElementById(specs.statusDisplaySpec.id);
    
    try {
        validateMsgDetails(msgDetails, specs.statusDisplaySpec.id);
        statusDisplay.innerText = '';
    } catch(errorMsg) {
        statusDisplay.innerText = errorMsg; 
        return;
    }

    const msgBody = buildMsgBody(msgDetails);
    if (isSendAction) {
        sendMsg(
            msgDetails.recipient, msgDetails.subject, msgBody, 
            specs.statusDisplaySpec.id, csrfToken);
    } else {
        previewMsg(msgDetails.recipient, msgDetails.subject, msgBody, specs);
    }
}

function buildMsgDetails(specs, appendix, modSettings) {
    const msgDetails = {
        recipient: getRecipientNameFromSelector(specs.msgRecipientSelectorSpec.id),
        moderator: modSettings.modName,
        moderatorTitle: modSettings.modTitle,
        subject: document.getElementById(specs.msgSubjectSpec.id).value,
        msg: document.getElementById(specs.msgBodySpec.id).value,
        appendix: appendix
    }

    return msgDetails;
}

function getRecipientNameFromSelector(selectorId) {
    const recipientSelectorVal = document.getElementById(selectorId).value;

    const tokens = recipientSelectorVal.split(" ");
    if (tokens.length != 2) {
        console.warn('Recipient name is of unusual format: ' + recipientSelectorVal);
        return null;
    }

    return tokens[1];
}

function validateMsgDetails(msgDetails) {
    if (msgDetails.recipient == null || msgDetails.recipient.trim().length < 1) {
        throw 'Unknown recipient - account may have been deleted.';
    }

    if (msgDetails.subject == null || msgDetails.subject.trim().length < 1) {
        throw 'subject is empty';
    }

    if (msgDetails.msg == null || msgDetails.msg.trim().length < 1) {
        throw 'message is empty';
    }
}

function buildMsgBody(msgDetails) {
    return `Hello ${msgDetails.recipient},

        ${msgDetails.msg}

        ${msgDetails.moderator}
        ${msgDetails.moderatorTitle}

        ${msgDetails.appendix}
        `;
}

function previewMsg(recipient, subject, msgBody, msgPanelSpecs) {
    document.getElementById(msgPanelSpecs.previewRecipientSpec.id).innerText = recipient;
    document.getElementById(msgPanelSpecs.previewMsgSubjectSpec.id).innerText = subject;
    document.getElementById(msgPanelSpecs.previewMsgBodySpec.id).innerText = msgBody;

    const previewPanel = document.getElementById(msgPanelSpecs.previewRecipientSpec.id).parentElement.parentElement;
    previewPanel.style.display = '';
}

/**
 * Function for sending a messager to a user.
 * 
 * @param {*} msgDetails the object containing the message details
 */
function sendMsg(recipient, subject, msgBody, statusDisplayId, csrfToken) {
    const spinner = buildSpinnerElement();
    document.getElementById(statusDisplayId).innerHTML = 
        'Sending PM... ' + spinner.outerHTML;
    const data = buildSendMessageData(subject, msgBody, csrfToken);

    postMsgReq(
        recipient,
        data,
        (responseTxt) => updateNotice(getSendMsgResultFromResponseTxt(responseTxt), statusDisplayId),
        (error) => {
                console.error(error);
                updateNotice(appendInboxLink('Send probably failed. Please try again.'), statusDisplayId);
            });
}

function postMsgReq(recipient, data, successCallback, errorCallback) {
    fetch(
        'https://myanimelist.net/mymessages.php?go=send&toname=' + recipient,
        {
            method: "POST",
            body:  new URLSearchParams(data),
        })
    .then(response => { 
            if (response.ok) {
                return response.text();
            }
        
            throw new Error('Request failed for URL: ' + response.url);
        })
    .then(successCallback)
    .catch(errorCallback);
}

function updateNotice(formattedMsg, statusDisplayId) {
    document.getElementById(statusDisplayId).innerHTML = formattedMsg;
}

function getSendMsgResultFromResponseTxt(responseText) {
    if (/Successfully sent/.test(responseText)) {
        return appendInboxLink('Sent!');
    }

    if (/You may only have 75/.test(responseText)) {
        return appendInboxLink('Inbox is full!');
    } 
    
    if (/You may only have 100/.test(responseText)) {
        return appendInboxLink('Sent Box is full!'); 
    }

    return appendInboxLink('Send probably failed. Please try again.'); 
}

function appendInboxLink(message) {
    return `${message} (<a href="https://myanimelist.net/mymessages.php?go=sent">Check Sent</a>)`;
}

function buildSendMessageData(msgSubject, msgBody, csrfToken) {
    return {
        subject: msgSubject,
        message: msgBody,
        sendmessage: 'Sending...',
        action_type: 'sendmessage',
        ignore_recaptcha: 1,
        csrf_token: csrfToken
    }
}

/**
 * Function for figuring the action type from the stock message description text.
 * @param {String} actionStr 
 * @returns a string representing the action type, or an empty string if it can't figure it out.
 */
function extractActionType(actionStr) {
    const constants = getPageBuilderConsts();
    const actionStrPrefixes = [
        constants.removalAction,
        constants.editAction,
        constants.rejectAction,
        constants.sendAction,
        constants.previewAction,
        constants.featureAction
    ];

    for (const prefix of actionStrPrefixes) {
        if (actionStr.toLowerCase().startsWith(prefix)) {
            return prefix;
        }
    }

    return "";
}

function identifyRecipient(reportedUserIdentifier, reporterIdentifier, action) {
    const constants = getPageBuilderConsts();
    if ([constants.editAction, constants.removalAction, constants.featureAction].includes(action)) {
        return reportedUserIdentifier;
    }

    if (action === constants.rejectAction) {
        return reporterIdentifier;
    }

    return null;
}

function selectRecipient(selectorId, optionTextPrefix) {
    const selector = document.getElementById(selectorId);
    for (let child of selector.children) {
        if (child.tagName.toLowerCase() === 'option' && 
                child.innerText.startsWith(optionTextPrefix)) {
            selector.value = child.value;
            return;
        }
    }
    console.warn('Cannot find recipient');
}

function buildSubjectTemplate(actionTypeStr, contentType) {
    if (actionTypeStr == null || contentType == null) {
        console.warn('Cannot build subject template');
        return ''
    }

    const constants = getPageBuilderConsts();

    switch(actionTypeStr) {
        case constants.rejectAction: return "Report rejected";
        case constants.editAction: return `${contentType} edited`;
        case constants.removalAction: return `${contentType} removed`;
        case constants.featureAction: return `Your ${contentType} has been featured!`;

        default:
            console.warn('Cannot build subject template');
            return '';
    }
}

function buildNormalHeader(headerTxt) {
    const headerElement = buildElement('div');
    headerElement.className = 'normal_header';
    headerElement.style.textAlign = 'left';
    headerElement.innerText = headerTxt;

    return headerElement;
}

// TODO: CHECK IF ALL NEEDED
function getPageBuilderConsts() {
    return {
        removalAction: "remove",
        editAction: "edit",
        rejectAction: "reject",
        sendAction: "send",
        featureAction: "feature",
        previewAction: "preview",

        stackLink: 'stack',
        profileLink: 'profile',
        unknownLink: 'unknown',

        msgSentResult: 'sent',
        unknownResult: 'unknown',
        inboxFullResult: 'inboxFull',
        sentBoxFullResult: 'sentBoxFull',
        emptyFieldsResult: 'emptyFields',

        reportedUserPrefix: '[victim]',
        reporterPrefix: '[reporter]',

        stackType: 'stack',
        recType: 'rec',
        reviewType: 'review',

        stdMargin: '5px'
    }
}