Ylilauta: Mikään ei järjesty

Palauttaa postausten numero-id:t, vastauslistat ja aikaleimat

// ==UserScript==
// @name           Ylilauta: Mikään ei järjesty
// @name:en        Ylilauta: Nothing will be organized
// @description    Palauttaa postausten numero-id:t, vastauslistat ja aikaleimat
// @description:en Restores some imageboard-like features to the Finnish forum, ylilauta.org
// @version        3.1.1
// @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';

// --------------- --------- ---------------

const LANG = document.documentElement.lang;
const OP_REF = LANG === 'en' ? 'OP' : 'AP';
const REPLIES = LANG === 'en' ? 'Replies:' : 'Vastaukset:';
const YOU = LANG === 'en' ? ' (You)' : ' (Sinä)';

try {
    GM_addStyle(`
        /* Compatibility with Custom CSS */
        .post.id-fixed :is(.ref, .post-meta .time) {
            &::before, &::after {
                content: none !important;
            }
        }
    `);
} catch (e) {
    console.warn('GM_addStyle is not supported by the current userscript extension', e);
}

// base62 ids are no longer used by ylis
const useNewIds = true;
const encode = (int) => int;
const decode = (str) => str;
/*
// 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 addRefLink(element, refId, isOP = false) {
    const isOwn = document.getElementById(`post-${refId}`)?.classList.contains('own');

    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)}`);
    if (isOwn) ref.append(YOU);
    element.append(ref);
}

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

    let repliesElm = post.querySelector('.post-replies');
    let repliesBtn = repliesElm?.querySelector('button');
    if (!repliesElm) {
        repliesElm = document.createElement('footer');
        repliesElm.classList = 'post-replies';

        repliesBtn = document.createElement('button');
        repliesBtn.classList = 'text-button';
        repliesBtn.textContent = REPLIES;
        repliesBtn.dataset.action = 'Post.expandReplies';

        repliesElm.dataset.postId = repliesBtn.dataset.postId = post.dataset.postIdNew;
        repliesElm.append(repliesBtn);
    }

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

    // Add links to new replies
    newIds.forEach((id) => addRefLink(repliesElm, id));

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

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

// 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 ids = json.ids.map((id) => id.toString());
        const all = !(ids.length > +displayOPReplies.split('max')[1]);
        const rElm = addReplyToPost(post, all ? ids : 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');
}

function processPostRefs(postMessage, opId) {
    if (!postMessage) return;
    postMessage.querySelectorAll('.post-ref').forEach((refNode) => {
        const newRefNode = document.createDocumentFragment();
        const refId = refNode.dataset.postId;
        addRefLink(newRefNode, refId, refId === opId);
        refNode.replaceWith(newRefNode);
    });

    postMessage.querySelectorAll('.ref').forEach((refNode) => {
        refNode.classList.remove('enabled'); // Re-enables post-preview event listeners
        const refId = refNode.dataset.postId;
        const isOP = refId === opId;
        const isOwn = document.getElementById(`post-${refId}`)?.classList.contains('own');
        refNode.replaceChildren(`≫${isOP ? OP_REF : useNewIds ? refId : decode(refId)}`);
        if (isOwn) refNode.append(YOU);
    });
}

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

        // 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') {
                const span = document.createElement('span');
                span.textContent = time.title;
                time.after('•', span);
            }
        }
    }

    // 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) {
    const postId = encode(post.dataset.postId);

    // Fix incorrect replies added by sopsy's code
    post.querySelectorAll('.post-message .post-ref .ref').forEach((ref) => {
        const rTo = document.getElementById(`post-${ref.dataset.postId}`);
        const rToReplies = rTo.querySelector('.post > .post-replies');
        if (!rToReplies?.dataset.replies) return;

        rToReplies.dataset.replies = rToReplies.dataset.replies.replaceAll(new RegExp(`,?${postId}`, 'g'), '');
        processPost(rTo);
    });

    // Update reply lists
    post.querySelectorAll('.post-message > :is(.ref, .post-ref)').forEach((ref) => {
        const rTo = document.getElementById(`post-${ref.dataset.postId}`);
        if (!rTo) return;

        // Add the reply directly when ylis hasn't fucked up the button
        // Or if the reply is to OP (ylis doesn't add replies to it)
        const rToReplies = rTo.querySelector('.post > .post-replies');
        if (
            !rToReplies?.dataset.replies?.includes(postId) &&
            (displayOPReplies === 'all' || !rTo.classList.contains('op-post'))
        ) {
            return addReplyToPost(rTo, [postId]);
        }

        // Fix the replies button
        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, .op-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')) {
                    if (!node.parentElement?.classList.contains('postpreview')) processPostParents(node);
                    processPost(node);
                } else if (['thread', 'post-replies-expanded'].some((c) => node.classList.contains(c))) {
                    node.querySelectorAll('.post').forEach(processPost);
                    /*
                } else if (!useNewIds && isPostForm(node)) {
                    const msg = node.querySelector('textarea[name="message"]');
                    if (!msg) return;

                    // Convert refs from base-62
                    msg.value = msg.value.replaceAll(/(?<=>>)[0-9A-Za-z]+\b/g, decode);
                    if (!node.classList.contains('post-edit')) {
                        // Remove duplicate refs from the end
                        msg.value = msg.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 });
});