Page builder utils library

Utilities library to help build pages.

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.org/scripts/580181/1838058/Page%20builder%20utils%20library.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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'
    }
}