// ==UserScript==
// @author jvlflame
// @name arca.live enhancements
// @version 0.0.6
// @license MIT
// @include https://arca.live/*
// @include https://kioskloud.io/e/*
// @include https://nahida.live/mods/*
// @description Adds quality of life improvements for browinsg and downloading Genshin Impact mods from arca.live
// @namespace https://greasyfork.org/users/63118
// ==/UserScript==
let GLOBAL_STYLES = `
.vrow.column.head {
height: initial !important;
}
.vrow.column {
height: 135px !important;
}
.vrow.column.visited {
opacity: 0.4;
}
.vrow-inner {
padding-left: 115px;
}
.vrow-preview {
display: block !important;
top: 10px !important;
}
.notice.column {
height: 2.4rem !important;
}
.body .board-article .article-list .list-table a.vrow:visited {
color: inherit !important;
background-color: inherit !important;
}
.enh-action-bar {
display: flex;
padding: 0.5rem 0;
gap: 0.5rem;
}
`;
const CURRENT_URL = window.location.href;
const CURRENT_PAGE = window.location.pathname;
const IS_KIOSKLOUDIO = CURRENT_URL.includes('kioskloud.io');
const IS_ARCALIVE = CURRENT_URL.includes('arca.live');
const IS_NAHIDALIVE = CURRENT_URL.includes('nahida.live');
const IS_POST_PAGE = CURRENT_PAGE.match(/\/b\/.*\/\d+/g);
const IS_LIST_PAGE = !IS_POST_PAGE;
const CATEGORY = CURRENT_PAGE.match(/\/b\/(\w+)/g);
const BASE64_REGEX = /^[-A-Za-z0-9+\/]*={0,3}$/g;
const VISITED_POSTS = getPostsFromLocalStorage(CATEGORY);
const styleSheet = document.createElement("style");
styleSheet.type = "text/css";
styleSheet.innerText = GLOBAL_STYLES;
document.head.appendChild(styleSheet);
function getPostRows() {
const table = document.getElementsByClassName("list-table table");
// Get the list table row elements
const rows = table[0].querySelectorAll("a.vrow.column");
const postRows = [];
for (const row of rows) {
if (row.classList.contains("notice")) {
continue;
};
postRows.push(row);
}
return postRows;
}
function getPost(id, title, unixTimestamp) {
const dateTimeString = unixTimestamp ? new Date(unixTimestamp * 1000).toISOString() : new Date().toISOString();
const post = {
t: title,
v: dateTimeString
}
return {
id: id,
post: post
};
}
function getPostsFromLocalStorage(category) {
const posts = JSON.parse(localStorage.getItem(`visited-posts-${category}`));
return posts ? posts : {};
}
function isPostVisited(visitedPosts, id) {
return visitedPosts[id];
}
function appendPostToLocalStorage(post, category) {
const existingPosts = getPostsFromLocalStorage(category);
existingPosts[post.id] = post.post;
localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}
function appendPostsToLocalStorage(posts, category) {
const existingPosts = getPostsFromLocalStorage(category);
for (const post of posts) {
existingPosts[post.id] = post.post;
}
localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}
function removePostFromLocalStorage(post, category) {
const existingPosts = getPostsFromLocalStorage(category);
delete existingPosts[post.id]
localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}
function removePostsFromLocalStorage(posts, category) {
const existingPosts = getPostsFromLocalStorage(category);
for (const post of posts) {
delete existingPosts[post.id]
}
localStorage.setItem(`visited-posts-${category}`, JSON.stringify(existingPosts));
}
function migrateRecentArticles() {
if (!IS_ARCALIVE) {
return;
}
/* recent_articles
{
boardName: string;
slug: string;
articleId: number;
title: string;
regdateAt: number
}[]
*/
const isMigrated = localStorage.getItem('migrated-timestamp');
if (isMigrated) {
return;
}
const nativeVisitedPosts = localStorage.getItem('recent_articles');
if (!nativeVisitedPosts) {
return;
}
for (const visitedPost of JSON.parse(nativeVisitedPosts)) {
const post = getPost(visitedPost.articleId, visitedPost.title, visitedPost.regdateAt);
const category = `/b/${visitedPost.slug}`;
appendPostToLocalStorage(post, category);
}
localStorage.setItem('migrated-timestamp', new Date().toISOString());
}
function getAllPostsOnPage() {
const rows = getPostRows();
const posts = [];
for (const row of rows) {
const href = row.href;
const id = href.split(/\/b\/\w+\//)[1].split(/\?/)[0];
const titleElement = row.querySelectorAll(".title")[0];
const title = titleElement ? titleElement.outerText : '';
posts.push(getPost(id, title));
}
return posts;
}
function handleMarkPageAsRead() {
const posts = getAllPostsOnPage();
appendPostsToLocalStorage(posts, CATEGORY);
}
function handleMarkPageAsUnread() {
const posts = getAllPostsOnPage();
removePostsFromLocalStorage(posts, CATEGORY);
}
function createBtnElement(text, handler) {
const btnElement = document.createElement('button');
btnElement.classList.add("btn", "btn-sm", "btn-arca");
btnElement.textContent = text;
btnElement.onclick = handler;
return btnElement;
}
function createActionBarElement() {
const actionBarElement = document.createElement('div');
actionBarElement.classList.add('enh-action-bar');
return actionBarElement;
}
function createMarkPageAsReadBtn(parentElement) {
const btnElement = createBtnElement('Mark page as read', handleMarkPageAsRead)
parentElement.prepend(btnElement);
}
function createMarkPageAsUnreadBtn(parentElement) {
const btnElement = createBtnElement('Mark page as unread', handleMarkPageAsUnread)
parentElement.prepend(btnElement);
}
function createActionBar() {
const topActionBarElement = createActionBarElement();
const bottomActionBarElement = createActionBarElement();
const listElement = document.querySelector('.article-list');
listElement.prepend(topActionBarElement);
listElement.appendChild(bottomActionBarElement);
for (const parentElement of [topActionBarElement, bottomActionBarElement]) {
createMarkPageAsUnreadBtn(parentElement);
createMarkPageAsReadBtn(parentElement);
}
}
function addArcaRowEnhancements() {
const rows = getPostRows();
for (const row of rows) {
const previewElement = row.querySelectorAll('.vrow-preview');
const hasPreview = Boolean(previewElement[0])
if (!hasPreview) {
const dummyPreviewElement = document.createElement('div');
dummyPreviewElement.classList.add('vrow-preview');
row.appendChild(dummyPreviewElement);
}
const href = row.href;
// Remove the query string so it's easier to copy article id when archiving
const hrefWithoutQuery = href.split('?')[0];
row.setAttribute("href", hrefWithoutQuery);
const id = href.split(/\/b\/\w+\//)[1].split(/\?/)[0];
const titleElement = row.querySelectorAll(".title")[0];
const title = titleElement ? titleElement.outerText : '';
const isVisited = isPostVisited(VISITED_POSTS, id);
if (isVisited) {
row.classList.add("visited");
} else {
row.addEventListener("click", (e) => {
if (e.button === 2) return;
const post = getPost(id, title);
appendPostToLocalStorage(post, CATEGORY);
});
row.addEventListener("auxclick", () => {
const post = getPost(id, title);
appendPostToLocalStorage(post, CATEGORY);
});
}
}
}
if (IS_ARCALIVE && IS_LIST_PAGE) {
createActionBar();
addArcaRowEnhancements();
}
if (IS_ARCALIVE && IS_POST_PAGE) {
addArcaRowEnhancements();
const title = document.querySelectorAll(".title-row .title")[0].outerText;
const id = CURRENT_PAGE.split(/\/b\/\w+\//)[1].split(/\?/)[0];
const post = getPost(id, title);
// Attempt to automatically decode base64 links inside the article content
const articleContentElement = document.querySelector(".fr-view.article-content");
const textBlocks = articleContentElement.querySelectorAll("p");
for (const text of textBlocks) {
const innerText = text.innerText;
const isBase64 = innerText.match(BASE64_REGEX);
if (isBase64) {
const decoded = atob(innerText);
const linkElement = document.createElement('a');
const brElement = document.createElement('br');
linkElement.setAttribute('href', decoded);
linkElement.textContent += decoded;
text.appendChild(brElement);
text.appendChild(linkElement);
}
}
if (isPostVisited(VISITED_POSTS, id)) {
return;
};
appendPostToLocalStorage(post, CATEGORY);
}
/* nahida.live doesn't use a form for submit, but rather uses a value prop to read the password input. Any changes to the input value does not reflect on the value which prevents us from setting the value programatically
if (IS_NAHIDALIVE) {
const DEFAULT_PASSWORD = localStorage.getItem('default-password');
const passwordInputElement = document.querySelector('input[placeholder="Password"]');
const defaultPasswordInputElement = document.createElement('input');
defaultPasswordInputElement.type = 'text';
defaultPasswordInputElement.placeholder = 'Enter default password';
defaultPasswordInputElement.classList.add('flex', 'h-10', 'w-full', 'rounded-md', 'border', 'border-input', 'bg-background', 'ring-offset-background', 'file:border-0', 'file:bg-transparent', 'file:text-sm', 'file:font-medium', 'placeholder:text-muted-foreground', 'focus-visible:outline-none', 'focus-visible:ring-2', 'focus-visible:ring-ring', 'focus-visible:ring-offset-2', 'disabled:cursor-not-allowed', 'disabled:opacity-50', 'px-2', 'py-1', 'mb-2', 'max-w-[210px]', 'text-base');
defaultPasswordInputElement.style.position = 'absolute';
defaultPasswordInputElement.style.height = '50px';
defaultPasswordInputElement.style.top = '0.375rem';
defaultPasswordInputElement.style.left = '6.5rem';
defaultPasswordInputElement.style.zIndex = '500';
defaultPasswordInputElement.value = DEFAULT_PASSWORD;
const bodyElement = document.querySelector('body');
bodyElement.appendChild(defaultPasswordInputElement);
defaultPasswordInputElement.addEventListener("input", (e) => {
localStorage.setItem('default-password', e.currentTarget.value || '');
})
if (DEFAULT_PASSWORD) {
passwordInputElement.value = DEFAULT_PASSWORD;
const submitBtnElement = document.querySelector('section').querySelector('button')
submitBtnElement.click();
}
}
*/
if (IS_KIOSKLOUDIO) {
const DEFAULT_PASSWORD = localStorage.getItem('default-password');
const passwordInputElement = document.querySelector('.swal2-input');
const autoSubmitToggleElement = document.createElement('button');
const defaultPasswordInputElement = document.createElement('input');
defaultPasswordInputElement.type = 'text';
defaultPasswordInputElement.placeholder = 'Enter default password';
defaultPasswordInputElement.classList.add('swal2-input');
defaultPasswordInputElement.value = DEFAULT_PASSWORD;
passwordInputElement.after(defaultPasswordInputElement);
defaultPasswordInputElement.addEventListener("input", (e) => {
localStorage.setItem('default-password', e.currentTarget.value || '');
})
if (DEFAULT_PASSWORD) {
passwordInputElement.value = DEFAULT_PASSWORD;
const submitBtnElement = document.querySelector('.swal2-actions').querySelector('.swal2-confirm');
submitBtnElement.click();
}
}
migrateRecentArticles();