Page builder utils library

Utilities library to help build pages.

Dit script moet niet direct worden geïnstalleerd - het is een bibliotheek voor andere scripts om op te nemen met de meta-richtlijn // @require https://update.greasyfork.org/scripts/580181/1838058/Page%20builder%20utils%20library.js

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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'
    }
}