// ==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 });
});