Ylilauta: Mikään ei järjesty

Palauttaa vihertekstin, postausten numero-id:t, vastauslistat ja aikaleimat

// ==UserScript==
// @name           Ylilauta: Mikään ei järjesty
// @name:en        Ylilauta: Nothing will be organized
// @description    Palauttaa vihertekstin, postausten numero-id:t, vastauslistat ja aikaleimat
// @description:en Restores some imageboard-like features to the Finnish forum, ylilauta.org
// @version        2.2b
// @match          https://ylilauta.org/*
// @grant          GM_addStyle
// @run-at         document-start
// @license        MIT
// @namespace https://greasyfork.org/users/1285509
// ==/UserScript==

'use strict';

// --------------- ASETUKSET ---------------
// Muuttaa aikaleiman ulkonäköä
// - 'short'	5 t
// - 'long'		6.5.2024 klo 10.37.23
// - 'both'		6.5.2024 10.37.23 (5 t)
// - 'bothAlt'	6.5.2024 10.37.23 • 5 t
const timeStyle = 'both';

// Näytetäänkö alotuspostauksessa vastauslista
// - 'none'		ei näytetä
// - 'all'		näytetään kaikki
// - 'maxX'		Jos viittauksia on alle X, näytetään kaikki, muuten vain muissa langoissa olevat
const displayOPReplies = 'max10';

// Näytetäänkö id uudella tyylillä (GqBMs) vai vanhalla (248858134).
// Viemällä hiiren vastauslinkin päälle näet id:n toisessa muodossa
const useNewIds = false;
// --------------- --------- ---------------

const OP_REF = 'AP';
const REPLIES = 'Vastaukset:';

let styleSheet;
try {
    styleSheet = GM_addStyle(`
        .postpreview .ref[data-post-id="0"] { text-decoration: underline double; }
        .quote { color: var(--ch-green); &.blue { color: var(--ch-blue); }}
        .post.id-fixed :is(.ref, .post-meta .time) {
            &::before, &::after { content: none !important; }
        }
    `).sheet;
} catch (e) {
    console.warn('GM_addStyle is not supported by the current userscript extension', e);
}

// Edited from https://github.com/base62/base62.js
const CHARSET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
function encode(int) {
    if (!(int > 1e7 && int < 1e10)) return int;

    var res = '';
    while (int > 0) {
        res = CHARSET[int % 62] + res;
        int = Math.floor(int / 62);
    }
    return res;
}
function decode(str) {
    var res = 0,
        length = str.length,
        i,
        char;
    if (length > 6 || length < 4) return str;

    for (i = 0; i < length; i += 1) {
        char = str.charCodeAt(i);
        if (char < 58) {
            char = char - 48;
        } else if (char < 91) {
            char = char - 55;
        } else {
            char = char - 61;
        }
        res += char * Math.pow(62, length - i - 1);
    }
    return res;
}

function addReplyToPost(post, ids) {
    if (!ids || ids.length === 0) return;

    let rElm = post.querySelector('.post-replies');
    let btn = rElm?.querySelector('button');
    if (!rElm) {
        rElm = document.createElement('footer');
        rElm.classList = 'post-replies';
        btn = document.createElement('button');
        btn.classList = 'text-button';
        btn.textContent = REPLIES;
        rElm.dataset.postId = btn.dataset.postId = post.dataset.postIdNew;
        btn.dataset.action = 'Post.expandReplies';
        rElm.append(btn);
    }

    // Get new replies, make sure they are in base62
    const oldIds = new Set(btn.dataset.replies?.split(',').map((id) => encode(id)));
    const newIds = new Set(
        ids
            .filter((id) => !oldIds.has(id))
            .map((id) => encode(id))
            .sort()
    );

    // Add links to new replies
    newIds.forEach((id) => {
        const ref = document.createElement('a');
        ref.classList = 'ref';
        ref.href = `/post/${id}`;
        ref.dataset.postId = id;
        ref.append(`≫${useNewIds ? id : decode(id)}`);
        rElm.append(ref);
    });

    if (!rElm.parentElement) post.querySelector('.post-message, .post > :last-child').after(rElm);

    // Update data-replies attributes
    const newData = [...oldIds, ...newIds].join(',');
    if (newData) rElm.dataset.replies = btn.dataset.replies = newData;
    return rElm;
}

// Fetches and appends list of replies to OP
const onOPRepliesClick = (e) => loadOPReplies(e.currentTarget.closest('.post'));
async function loadOPReplies(post) {
    if (displayOPReplies === 'none') return;
    if (!document.documentElement.classList.contains('page-thread')) return;
    const data = new FormData();
    data.append('post_id', post.dataset.postIdNew);

    // Get X-Csrf-Token from inline script that imports App.js
    for (const script of document.scripts) {
        const key = /\bnew\s+\w+\(["']\w*["'],\s*["'](\w+)["']/.exec(script.innerHTML)?.[1];
        if (!key) continue;

        const res = await fetch('https://ylilauta.org/api/community/post/replies', {
            method: 'POST',
            body: data,
            headers: { 'X-Csrf-Token': key },
        });
        if (!res.ok) throw new Error(`fetching OP replies: ${res.status}`);

        const json = await res.json();
        const all = !(json.ids.length > +displayOPReplies.split('max')[1]);
        const rElm = addReplyToPost(
            post,
            all ? json.ids : json.ids.filter((id) => !document.getElementById(`post-${id}`))
        );
        if (rElm) rElm.addEventListener('click', onOPRepliesClick);
        return;
    }
    throw new Error('loading OP replies: X-Csrf-Token was not found in scripts');
}

// Fixes refs and green/bluetext in a post's message
function processPostMessage(postMessage, opId) {
    if (!postMessage) return;
    const newMessage = document.createDocumentFragment();
    [...postMessage.childNodes].forEach((node) => {
        if (node.nodeType === Node.TEXT_NODE) {
            const lines = node.nodeValue.split('\n');
            lines.forEach((line, i) => {
                const res = /^([><])[^>].*$/.exec(line);
                if (res) {
                    const [match, type] = res;
                    const span = document.createElement('span');
                    if (type === '<') {
                        span.classList = 'quote blue';
                        if (!styleSheet) span.style.color = 'var(--ch-blue)';
                    } else {
                        span.classList = 'quote';
                        if (!styleSheet) span.style.color = 'var(--ch-green)';
                    }
                    span.textContent = match;
                    newMessage.append(span);
                } else {
                    newMessage.append(line);
                }
                // Add a line break if it's not the last line
                if (i < lines.length - 1) {
                    newMessage.append('\n');
                }
            });
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.classList.contains('ref')) {
                // Style reply links
                node.classList.remove('enabled'); // Re-enables post-preview event listeners
                const refId = node.dataset.postId;
                const isOP = refId === opId;
                node.replaceChildren(`≫${isOP ? OP_REF : useNewIds ? refId : decode(refId)}`);
            } else if (node.classList.contains('post-ref')) {
                const refId = node.dataset.postId;
                const isOP = refId === opId;
                const ref = document.createElement('a');
                ref.classList = 'ref';
                ref.href = `/post/${refId}`;
                ref.dataset.postId = refId;
                ref.append(`≫${isOP ? OP_REF : useNewIds ? refId : decode(refId)}`);
                newMessage.append(ref);
                return;
            }

            newMessage.append(node);
        } else {
            newMessage.append(node);
        }
    });
    newMessage.normalize();
    postMessage.replaceChildren(newMessage);
}

function processPost(post) {
    post.dataset.postIdNew = encode(post.dataset.postId);
    if (!post.classList.contains('id-fixed')) {
        const postMessage = post.querySelector('.post-message, .post .message');
        const opId = post.closest('.thread')?.querySelector('.op-post')?.dataset.postIdNew;
        processPostMessage(postMessage, opId);

        // Add post id/reply link before timestamp
        const time = post.querySelector('.post-meta .time');
        if (!time) return;
        const id = document.createElement('a');
        id.classList = 'post-id';
        id.href = `/post/${post.dataset.postIdNew}`;
        id.dataset.action = 'Post.reply';
        id.dataset.postId = post.dataset.postIdNew;
        id.title = useNewIds ? post.dataset.postId : post.dataset.postIdNew;
        id.style.userSelect = 'initial';
        id.append(useNewIds ? post.dataset.postIdNew : post.dataset.postId);
        time.before(id, '•');

        // Add timestamp styling
        if (timeStyle !== 'short') {
            [time.textContent, time.title] = [time.title, time.textContent];
            time.removeAttribute('data-timestamp');

            if (timeStyle.startsWith('both')) time.textContent = time.textContent.replace('klo ', '');
            if (timeStyle === 'both') time.append(` (${time.title})`);
            else if (timeStyle === 'bothAlt')
                time.after('•', Object.assign(document.createElement('span'), { textContent: time.title }));
        }

        post.classList.add('id-fixed');
    }

    // Fix replies list
    const old = post.querySelector('.post > .post-replies');
    if (!old && post.classList.contains('op-post')) return loadOPReplies(post);
    const replyIds = old?.dataset.replies.split(',');
    old?.remove();
    addReplyToPost(post, replyIds);
}

// Update reply lists of posts parents
function processPostParents(post) {
    post.querySelectorAll('.post-message .ref').forEach((ref) => {
        const rTo = document.getElementById(`post-${ref.dataset.postId}`);
        const rOP = displayOPReplies === 'all' && rTo?.querySelector('.op-post > .post-replies');
        if (rOP?.dataset?.replies && post.dataset.postIdNew) rOP.dataset.replies += `,${post.dataset.postIdNew}`;
        if (rTo) processPost(rTo);
    });
}

// Update id refs in forms to base62
const isPostForm = (form) => ['post-edit', 'post-form'].some((c) => form.classList.contains(c));
function onSubmit(e) {
    if (!isPostForm(e.target)) return;
    const msg = e.target.elements?.message;
    if (msg) msg.value = msg.value.replaceAll(/(?<=>>)\d+\b/g, encode);
}
window.addEventListener('submit', onSubmit, true);

window.addEventListener('DOMContentLoaded', () => {
    document.querySelectorAll('.post').forEach(processPost);
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            mutation.addedNodes.forEach((node) => {
                if (node.nodeType !== Node.ELEMENT_NODE) return;
                if (node.classList.contains('post')) {
                    processPost(node);
                    if (!node.parentElement?.classList.contains('postpreview')) processPostParents(node);
                } else if (['thread', 'post-replies-expanded'].some((c) => node.classList.contains(c))) {
                    node.querySelectorAll('.post').forEach(processPost);
                } else if (styleSheet && node.classList.contains('postpreview')) {
                    const newSel = styleSheet.cssRules[0]?.selectorText.replace(
                        /(?<=id=")\w+/,
                        encode(/opener-(\d+)/.exec(node.classList)?.[1])
                    );
                    if (newSel) styleSheet.cssRules[0].selectorText = newSel;
                } else if (!useNewIds && isPostForm(node)) {
                    const t = node.querySelector('textarea[name="message"]');
                    if (!t) return;
                    t.value = t.value.replaceAll(/(?<=>>)[0-9A-Za-z]+\b/g, decode);
                    if (!node.classList.contains('post-edit'))
                        t.value = t.value.replace(/^([\s\S]*(>>\d+\b)[\s\S]*?)\s*\2(\s*)$/, '$1$3');
                }
            });
            // Update reply lists when a post is deleted
            mutation.removedNodes.forEach((node) => {
                if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('post')) processPostParents(node);
            });
        });
    });
    observer.observe(document.body, { childList: true, subtree: true });
});