// ==UserScript==
// @name Tiedostonimet takas
// @name:en Filenames back
// @description Lisää tiedostonimet takaisin laudalle
// @description:en Brings back filenames to the Finnish imageboard, ylilauta.org
// @version 2.1.0
// @match *://ylilauta.org/*
// @grant GM_addStyle
// @icon https://static.ylilauta.org/img/seal_of_ylilauta-icon.svg
// @license MIT
// @namespace https://greasyfork.org/users/1285509
// ==/UserScript==
(function () {
'use strict';
// --------------------
// ASETUKSET | SETTINGS
// true = lisää tiedostonimen perään sen tyypin ja korvaa välilyönnit alaviivoilla
// false = näyttää pelkän tiedostonimen
const showFiletype = true;
// Näyttää tiedostonimen perässä tiedostokoon kun tiedosto on laajennettuna
const showFileSize = true;
// Näyttää tiedostonimen perässä sen leveyden ja korkeuden kun tiedosto on laajennettuna
const showFileDimensions = true;
// Lisää etuliitteen tiedostonimeen
const filePrefix = '';
// Muuttaa tiedostonimen sijaintia
// true = tiedostonimi postauksen yläpalkissa timestampin vieressä
// false = tiedostonimi tiedoston yläpuolella
const placeInMeta = true;
// Mahdollistaa tiedoston lataamisen sen alkuperäisellä nimellä
// true = alt-click tiedostonimen linkin kohdalla lataa tiedoston sen haetulla nimellä
// right-click -> save link as tallentaa tiedoston sen todellisella nimellä
// false = alt-click ei lataa tiedostoa
// right-click -> save link as tallentaa tiedoston sen todellisella nimellä
const altClickToDownload = true;
// Viestin toiminnot -> 'Tallenna tiedosto' lataa tiedoston sen haetulla nimellä
const overwriteSaveFile = true;
// Korvaa avif tiedostopäätteet näytetyssä tiedostonimessä ja muuttaa ladatun
// tiedoston kyseiseen formaattiin toiminnoissa altClickToDownload ja overwriteSaveFile.
// Tiedoston lataaminen muilla kuin kyseisillä tavoilla käyttää yhä avif formaattia.
// '' = tyyppiä ei korvata
// 'png', 'jpg', 'webp', 'bmp' = tiedosto muutetaan kyseiseen muotoon
const convertAvif = '';
// Laatu häviöllistä pakkausta tukeville formaateille väliltä [0.0, 1.0]
// '' = Käytetään selaimen oletusarvoa (yleensä 0.92 jpeg, 0.8 webp)
// Toimii vain jos convertAvif = true ja joko altClickToDownload tai overwriteSaveFile
const convertQuality = '';
// Tallentaa haetut tiedostonimet selaimen sivustokohtaiseen sessionStorageen
// true = Nopeuttaa aiemmin haettujen tiedostonimien lisäystä esim. sivun
// uudelleenlatauksen jälkeen kunhan pysyt samalla välilehdellä.
// false = tiedostonimiä ei tallenneta selaimeen
//
// sessionStorage tyhjenee joka tapauksessa kun välilehden sulkee
// https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage
const useSessionStorage = true;
// Väliaikainen teksti tiedostoa ladatessa tai sen epäonnistuessa
const titleLoading = "Ladataan tiedostonimeä...";
const titleError = "Tiedostonimeä ei löytynyt";
// Säätää maksimiyritysten määrää ja alkuperäistä viivästystä epäonnistuneen
// tiedostonimen hakuyrityksen jälkeen. Viivästys kaksinkertaistuu jokaisen
// epäonnistuneen yrityksen jälkeen. Tällä yritetään kiertää rate limittiä
// (jokainen tiedostonimi on saatavilla vain omalla sivullaan).
const retryInitialDelay = 2000; //ms
const retryMaxAttempts = 5;
// --------------------
// Add custom styling
GM_addStyle(`
.file-data {
max-width: fit-content;
display: grid;
grid-auto-flow: column;
grid-gap: 5px;
align-items: center;
a {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.post > .file-data {
margin: 0 10px 5px;
}
.post-meta .file-data {
flex: 1 40%;
* {
color: inherit;
}
}
.file-data-unknown .file-data-link {
font-style: italic;
}
.file-download-container {
display: grid;
font-size: 20px;
color: inherit !important;
transition: opacity 0.75s;
* {
display: grid;
grid-column: 1;
grid-row: 1;
color: var(--button-hover-text-color) !important;
pointer-events: none;
}
.file-download-background {
border-radius: 3px;
}
.file-download-progress {
color: var(--c-sec) !important;
z-index: 1;
height: 0;
}
.file-download-abort {
font-size: 14px !important;
padding: 3px;
-webkit-text-stroke: 1px;
z-index: 2;
opacity: 0;
}
&.file-download-error .file-download-background {
color: var(--ch-red) !important;
}
&:not(.file-download-error):hover {
.file-download-background,
.file-download-progress {
opacity: 0.5;
}
.file-download-abort {
opacity: 1;
}
}
}
`);
// Load previously fetched data if enabled and available
const cachedFileData = (useSessionStorage ? JSON.parse(sessionStorage.getItem('file-data') ?? '{}') : {});
if (Object.keys(cachedFileData).length > 0) {
console.info('Loaded file data from sessionStorage for %i files', Object.keys(cachedFileData).length);
}
// Saves fetched data in sessionStorage to persist after page reload
function saveFileDataToSessionStorage() {
if (useSessionStorage) {
const fileIds = Object.keys(cachedFileData);
// Ignore dimensions, no need to save them
const whitelist = ['title', 'fileType', 'fileSize'].concat(fileIds);
sessionStorage.setItem('file-data', JSON.stringify(cachedFileData, whitelist));
}
}
class HTTPError extends Error {
constructor(url, statusCode) {
super(`${statusCode} while requesting ${url}`);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.url = url;
}
}
function convertAvifToType(imageSrc, toType) {
return new Promise((resolve, reject) => {
const supportedTypes = ['png', 'jpg', 'jpeg', 'webp', 'bmp'];
if (!supportedTypes.includes(toType)) {
return reject(new Error(
`convertAvif has an invalid filetype: ${toType}. Has to be one of ${supportedTypes.join(', ')}`
));
}
const img = new Image();
img.onload = convert;
img.onerror = () => reject(new Error(`Loading image ${img.src}`));
img.src = imageSrc;
function convert() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
// Convert fileType to the correct media type
let imageFormat = 'image/';
switch (toType) {
case 'jpg':
imageFormat += 'jpeg';
break;
default:
imageFormat += toType;
}
// Get quality for lossy compression
let quality = parseFloat(convertQuality.toString().replace(',', '.'));
if (quality < 0 || quality > 1) {
return reject(new Error(`Invalid convertQuality: ${quality}. Must be in the range 0-1`));
}
canvas.toBlob(resolve, imageFormat, quality);
canvas.remove();
}
});
}
let saveAsRequest;
async function saveAs(fileId, fileData, onprogress = () => { }) {
const fileType = (convertAvif ? fileData.fileType.replace('avif', convertAvif) : fileData.fileType);
const filename = `${fileData.title}.${fileType}`;
const downloadUrl = `https://ylilauta.org/file/download/${fileId}`;
try {
const fileBlob = await new Promise((resolve, reject) => {
if (fileType !== fileData.fileType) {
// Convert the image's file format
resolve(convertAvifToType(downloadUrl, fileType));
} else {
const req = new XMLHttpRequest();
req.responseType = 'blob';
req.onprogress = (e) => onprogress(e.loaded / e.total);
req.onerror = () => reject(new Error(`requesting ${downloadUrl}`));
req.onabort = () => resolve();
req.onload = () => {
if (req.status >= 200 && req.status < 300) resolve(req.response);
else reject(new HTTPError(downloadUrl, req.status));
};
req.open('GET', downloadUrl);
req.send();
saveAsRequest = req;
}
});
// Check if the request was aborted
if (saveAsRequest?.readyState === XMLHttpRequest.UNSENT) return;
// Download the blob with the original filename
const objectUrl = window.URL.createObjectURL(fileBlob);
const dummyLink = document.createElement('a');
dummyLink.href = objectUrl;
dummyLink.download = filename;
dummyLink.click();
window.URL.revokeObjectURL(objectUrl);
dummyLink.remove();
onprogress(1);
} finally {
saveAsRequest = undefined;
}
}
async function fetchFileData(fileId) {
const data = cachedFileData[fileId] = (cachedFileData[fileId] ?? {});
if (data.title !== undefined) return data;
const fileSrc = `https://ylilauta.org/file/download/${fileId}`;
const response = await fetch(fileSrc, { method: 'HEAD' });
if (!response.ok) throw new HTTPError(fileSrc, response.status);
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition.match(/filename=\"([^"]*)\"/)[1];
const [title, fileType] = filename.split('.');
const fileSize = +response.headers.get('Content-Length');
[data.title, data.fileType, data.fileSize] = [title, fileType, fileSize];
return data;
}
// Fetches a file's data with retries in case of failure due to rate limiting
async function fetchFileDataWithRetry(fileId, maxAttempts = retryMaxAttempts, delay = retryInitialDelay) {
try {
const data = await fetchFileData(fileId);
return data;
} catch (error) {
if (error instanceof HTTPError && error.statusCode >= 500) {
if (maxAttempts === 0) {
throw new Error(`Max attempts to fetch file data reached. Last attempt resulted in ${error}`);
}
console.log(`Attempt to fetch file data failed with ${error}. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return fetchFileDataWithRetry(fileId, maxAttempts - 1, delay * 2);
} else {
throw error;
}
}
}
// https://stackoverflow.com/a/18650828
function formatBytes(bytes) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toPrecision(3)).toLocaleString()} ${sizes[i]}`;
}
// Displays cachedFileData for the given post
// Returns true if successful
function displayFileData(post) {
// Check that the post has a file, and we have at least the filename
const file = post?.querySelector(`.file[data-file-id]`);
const data = cachedFileData[file?.dataset.fileId];
if (data?.title === undefined) return false;
// Load the previously created elements
const fileDataElement = post.querySelector('.file-data');
const fileLinkElement = fileDataElement.querySelector('.file-data-link');
const fileExtraElement = fileDataElement.querySelector('.file-data-extra');
fileExtraElement.textContent = '';
// Add filename
const fileType = convertAvif ? data.fileType.replace('avif', convertAvif) : data.fileType;
const fullTitle = `${filePrefix}${data.title.replace(/\s/g, '_')}.${fileType}`;
fileLinkElement.textContent = fileLinkElement.title = (showFiletype ? fullTitle : data.title);
// Remove the placeholder class
fileDataElement.classList.remove('file-data-unknown');
// Add filesize and dimensions if enabled. These are only shown when file is expanded, to
// hide the fact that dimensions are only loaded after a file has been expanded at least once.
if (!file.classList.contains('preview')) {
const extraData = [];
if (showFileSize && data.fileSize) extraData.push(`${formatBytes(data.fileSize)}`);
if (showFileDimensions && data.dimensions) extraData.push(`${data.dimensions[0]}×${data.dimensions[1]}`);
if (extraData.length) {
fileExtraElement.textContent = ` (${extraData.join(', ')})`;
}
}
return true;
}
// An array of { post, fileId } to request data for
// Elements from the end of the queue are processed first
const queue = [];
// Processes data requests in queue one at a time
// Should only be called if queue is not empty
let queueRunning = false;
async function processQueue() {
if (queueRunning) return;
if (queue.length === 0) {
// Queue cleared!
saveFileDataToSessionStorage();
return;
}
queueRunning = true;
const { post, fileId } = queue.pop();
const postId = post.dataset.postId;
try {
await fetchFileDataWithRetry(fileId);
displayFileData(post);
} catch (error) {
console.error(`Error fetching or displaying data for file ${fileId} in post ${postId}: ${error}`);
// Show the error in the link's title
const link = post.querySelector('.file-data .file-data-link');
if (link) {
link.textContent = titleError;
link.title = error;
}
} finally {
queueRunning = false;
processQueue();
}
}
// Moves the post to top of queue, if it is currently in queue
function prioritizePost(post) {
const postIndex = queue.findIndex((item) => item.post === post);
if (postIndex < 0) return;
queue.push(queue.splice(postIndex, 1)[0]);
console.log('Pushed post to the top of the queue, should be processed next:\n', post);
}
// Adds a timeout to other promises when used with Promise.race
function promiseTimeout(duration = 5000) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Timed out after ${duration}ms`)), duration);
});
}
// Promise wrapper for MutationObserver with timeout
function promiseObserve(element, options, check) {
let observer;
return Promise.race([
new Promise((resolve) => {
observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
check(mutation, resolve);
}
});
observer.observe(element, options);
}),
promiseTimeout()
])
.finally(() => observer.disconnect());
}
// Detects and calls processPosts on posts added when clicking the Expand replies button
async function onExpandRepliesClick(e) {
const post = e.currentTarget.closest('.post');
if (post.dataset.replyExpandLoading === 'true') return;
try {
const posts = await promiseObserve(post, { childList: true }, (mutation, resolve) => {
for (const addedNode of mutation.addedNodes) {
if (addedNode.classList.contains('replies')) {
resolve([...addedNode.querySelectorAll('.post[data-post-id]')]);
}
}
for (const removedNode of mutation.removedNodes) {
if (removedNode.classList.contains('replies')) {
resolve([]);
}
}
});
processPosts(posts);
} catch (error) {
console.error('Failed to detect any changes after expandReplies was clicked:', error);
}
}
// Gets the dimensions of an image or video after it is loaded
async function loadFileDimensions(media, fileId) {
if (cachedFileData[fileId]?.dimensions) return cachedFileData[fileId].dimensions;
function tryLoadDimensions(el) {
return ((el.videoWidth || el.videoHeight) && [el.videoWidth, el.videoHeight])
|| ((el.naturalWidth || el.naturalHeight) && [el.naturalWidth, el.naturalHeight]);
}
try {
if (!fileId) throw new Error('fileId missing on file');
const data = cachedFileData[fileId] = (cachedFileData[fileId] ?? {});
await Promise.race([
tryLoadDimensions(media) ? data : new Promise((resolve, reject) => {
media.addEventListener(media.tagName === 'VIDEO' ? 'loadedmetadata' : 'load', () => resolve(data));
media.addEventListener('error', () => reject(new Error(`Loading image or video ${media.src}`)));
}),
promiseTimeout()
]);
return data.dimensions = (tryLoadDimensions(media) || data.dimensions);
} catch (error) {
console.error(`Loading file ${fileId} dimensions failed:`, error);
}
}
// Detect expanding file
async function onFileClick(e) {
if (e.ctrlKey || e.metaKey) return;
const file = e.currentTarget;
const post = file.closest('.post');
// Prioritize loading data for the file if waiting in queue
prioritizePost(post);
try {
const opt = file.classList.contains('preview')
? { childList: true, subtree: true }
: { attributeFilter: ['class'] };
const media = await promiseObserve(file, opt, (mutation, resolve) => {
if (mutation.type === 'childList') {
for (const addedNode of mutation.addedNodes) {
if (addedNode.matches('.full-img, video')) {
resolve(addedNode);
}
}
} else if (file.classList.contains('preview')) {
resolve();
}
});
if (media) await loadFileDimensions(media, file.dataset.fileId);
} catch (error) {
console.error('Failed to detect changes to class .preview on file after it was clicked:', error);
} finally {
displayFileData(post);
}
}
// Save a file with its original filename. Fetches the file's title if it isn't in cache
let downloadActive = false;
async function saveFileWithOriginalName(post, fileId) {
if (downloadActive) {
console.log('Already downloading a file, skipping download for file', fileId);
return;
}
downloadActive = true;
// Add a progress indicator
const downloadContainerElement = document.createElement('a');
downloadContainerElement.className = 'file-download-container';
const downloadBackgroundElement = document.createElement('a');
downloadBackgroundElement.className = 'icon-download2 file-download-background';
const downloadProgressElement = document.createElement('a');
downloadProgressElement.className = 'icon-download2 file-download-progress';
const downloadCancelElement = document.createElement('a');
downloadCancelElement.className = 'icon-cross file-download-abort';
// Allow aborting the download by clicking the indicator
downloadContainerElement.addEventListener('click', () => saveAsRequest?.abort());
downloadContainerElement.append(downloadBackgroundElement, downloadProgressElement, downloadCancelElement);
post.querySelector('.file-data').append(downloadContainerElement);
try {
// Load or fetch the file's data
const data = cachedFileData[fileId]?.title
? cachedFileData[fileId]
: await fetchFileDataWithRetry(fileId);
// Display data if it wasn't in cache
displayFileData(post);
let progressHeight = 0;
await saveAs(fileId, data, (progress) => {
const pPercentage = Math.floor(progress * 100) + '%';
// Animate the indicator
downloadBackgroundElement.style.background
= `linear-gradient(var(--button-hover-bg-color) ${pPercentage}, transparent ${pPercentage})`;
if (progressHeight >= 100) progressHeight = 0;
else progressHeight += 5;
downloadProgressElement.style.height = progressHeight + '%';
});
// Disable cancel animation on hover
downloadContainerElement.style.pointerEvents = 'none';
} catch (error) {
console.error('Error saving file:', error);
// Show the error in the indicator's title
downloadContainerElement.title = error;
downloadContainerElement.classList.add('file-download-error');
} finally {
downloadActive = false;
downloadProgressElement.style.height = 0;
// Fade out the indicator
setTimeout(() => downloadContainerElement.style.opacity = 0, 4000);
setTimeout(() => downloadContainerElement.remove(), 4750);
}
}
// Detect alt-clicking a file link
function onFileLinkClick(e) {
const fileLink = e.currentTarget;
const post = fileLink.closest('.post');
if (!e.altKey) {
// Opening the file page, pause video unless opening in a background tab
if (!e.ctrlKey) post.querySelector('.file video')?.pause();
return;
}
saveFileWithOriginalName(post, fileLink.dataset.fileId);
e.preventDefault();
}
// Detect opening the post menu to overwrite 'Save file' button's behaviour
async function onPostMenuButtonClick(e) {
const postMenuButton = e.currentTarget;
if (postMenuButton.classList.contains('active')) return;
try {
const dropdown = await promiseObserve(document.body, { childList: true }, (mutation, resolve) => {
for (const addedNode of mutation.addedNodes) {
if (addedNode.classList.contains('dropdown')) resolve(addedNode);
}
});
const downloadLink = await promiseObserve(dropdown, { childList: true }, (_, resolve) => {
const downloadLink = dropdown.querySelector('a[download][href]');
if (downloadLink) resolve(downloadLink);
});
const post = postMenuButton.closest('.post');
const fileLink = post.querySelector('.file-data-link');
const fileId = fileLink.dataset.fileId;
// Overwrite the behaviour
downloadLink.removeAttribute('href');
downloadLink.addEventListener('click', () => saveFileWithOriginalName(post, fileId));
} catch (error) {
console.error('Failed to detect Save file button in post menu after it was opened:', error);
}
}
// Main function to add posts with files to queue for requesting data,
// and observing elements when necessary to react to the following actions:
// - The 'Expand replies' button is clicked -> Load data for the replies added under the post
// - A file is expanded -> Prioritize loading data for the file
// -> The file's dimensions can be checked and added to data
// -> Update the file-data elements by calling displayFileData
// - altClickToDownload: File link is clicked -> Download if holding alt
// - overwriteSaveFile: Post menu is clicked -> Overwrite behaviour for the save file button
function processPosts(posts) {
for (const post of posts.reverse()) {
try {
const repliesButton = post.querySelector('button[data-action="Post.expandReplies"]');
repliesButton?.addEventListener('click', onExpandRepliesClick);
// No further actions needed for posts without a valid file
const file = post.querySelector('.file[data-file-id][data-file-src]');
if (!file) continue;
file.addEventListener('click', onFileClick);
// Add elements to the post for holding the file's data
const fileId = file.dataset.fileId;
const fileSrc = file.dataset.fileSrc;
const fileDataElement = document.createElement('span');
fileDataElement.className = 'file-data file-data-unknown';
if (!placeInMeta) file.before(fileDataElement);
else post.querySelector('.post-meta .time').after('•', fileDataElement);
const fileLinkElement = document.createElement('a');
fileLinkElement.className = 'file-data-link';
fileLinkElement.target = '_blank';
fileLinkElement.href = fileSrc;
fileLinkElement.textContent = titleLoading;
fileLinkElement.dataset.fileId = fileId;
if (altClickToDownload) fileLinkElement.addEventListener('click', onFileLinkClick);
if (overwriteSaveFile) {
const menuButton = post.querySelector('button[data-action="Post.menu"');
menuButton?.addEventListener('click', onPostMenuButtonClick);
}
const fileExtraElement = document.createElement('span');
fileExtraElement.className = 'file-data-extra';
fileDataElement.append(fileLinkElement, fileExtraElement);
// Test if the file already has data in our cache to avoid being stuck in queue
if (displayFileData(post)) continue;
// This is a new file, add to queue for fetching the data
queue.push({ post, fileId });
} catch (error) {
console.error(`Error processing post ${post.dataset.postId}:`, error);
}
}
// If a new file was encountered, start processing the queue
if (!queueRunning && queue.length > 0) processQueue();
}
{
// Apply to posts on initial page load
let posts = [...document.querySelectorAll('.post[data-post-id]')];
// Process posts from current position first
const anchorId = window.location.hash.substring(1);
const anchorIndex = posts.indexOf(anchorId && document.getElementById(anchorId));
// Move posts from anchorIndex onward to the beginning of the array
posts = (anchorIndex > 0) ? posts.splice(anchorIndex).concat(posts) : posts;
processPosts(posts);
// Apply to new posts after initial page load
window.addEventListener('new-posts-loaded', (e) => {
for (const threadId in e.detail) {
const thread = document.querySelector(`.thread[data-thread-id="${threadId}"]`);
if (!thread) continue;
// Filter to posts with a valid postId. This prevents loading
// filenames on the frontpage with display types other than 'New replies'.
const validPosts = e.detail[threadId].filter(post => post.hasAttribute('data-post-id'));
processPosts(validPosts);
}
});
}
})();