Skip to content

Instantly share code, notes, and snippets.

@M-rcus
Last active November 14, 2022 20:42
Show Gist options
  • Save M-rcus/dfea1ecd288cc3be5f493eb7f52e45e1 to your computer and use it in GitHub Desktop.
Save M-rcus/dfea1ecd288cc3be5f493eb7f52e45e1 to your computer and use it in GitHub Desktop.
Userscript to alphabetically sort the album select when uploading to CyberDrop (or other Lolisafe instances)
// ==UserScript==
// @name CyberDrop - Sort Album Select and More
// @namespace github.com/M-rcus
// @match https://cyberdrop.me/
// @match https://bunkr.is/
// @grant none
// @version 2.5.2
// @author Maarcus
// @description Automatically sorts the album select alphabetically once page loads on CyberDrop... and some other QoL changes.
// @downloadUrl https://gist.github.com/M-rcus/dfea1ecd288cc3be5f493eb7f52e45e1/raw/cyberdrop-album-select-sort.user.js
// @updateUrl https://gist.github.com/M-rcus/dfea1ecd288cc3be5f493eb7f52e45e1/raw/cyberdrop-album-select-sort.user.js
// ==/UserScript==
/*
* # Script features
* - Albums are sorted alphabetically in the list, making them easier to find
* - As of v2.2.1, alphabetical sort is case *insensitive*.
* - A 'refresh button' is added, which will... refresh your album list and album information on the upload page.
* - Bonus: The album selection dropdown should the album selection after refresh, as long as the album still exists.
* - Album information (amount of files in album, album title, album link) is shown between the "Select album" (dropdown) and "Drag files here" sections of the page.
* - 'Bypass Album Cache' checkbox that adds a random number at the end of the album URL as an attempt to bypass the album cache view.
* - It's useful for those albums that are high-traffic, but the OP wants to link to the album with the updated files.
* - 'Public album URL' checkbox for easily toggling the album privacy.
* - 'Last updated' info box, to see how recently the album was updated (files uploaded, description edited etc.)
*
* ## Minor bugs and issues
* 1. A bit slow on pageload, as it waits for the normal frontend to retrieve the albums and then sends another API request to retrieve album information.
*
* ## Other notes
* - The album title 'box' looks a bit scuffed, but it's intentional (kind of).
* - Before v2.2.1 the album link and the album title was the same thing, but sometimes I'd accidentally click it instead of "marking and copying" the album title so... I got fed up with the old functionality and changed it 😂
*/
/*
* You can in theory specify other `Lolisafe` instances as a `@match`.
* However, if the instance is customized in a certain way, this userscript will not work.
* I have only tested it on default instances and CyberDrop.me.
*
* It does NOT work properly on share.dmca.gripe, because they use an older version of Lolisafe.
*
* If you want to add multiple, just put another line below the existing @match
* Example:
* // @match https://cyberdrop.me/
* // @match https://zz.ht/
* // @match https://example.net/
*
* To make sure your @match changes are saved, use your userscript manager's own "Settings" page for your script:
*
* !! Caution, I do not take any responsibility if this script somehow wipes your whole account on other websites.
* !! While I highly doubt something like that is gonna happen in any case, I just wanna mention that.
* !! I know this works on CyberDrop, which is where I mostly use it. It will _probably_ work on other instances
* !! such as zz.ht, but again - no guarantees.
*
* 1. Click 'Edit' on the script (inside your userscript manager) after adding it.
* 2. Check the top of the page for a 'Settings' tab
* 3. Enter the URLs (of Lolisafe instances) you want the script to run on.
* 4. Save and refresh/open the relevant URLs.
*/
let _albums;
let homeDomain;
/**
* The fact I have to look up how to do this every time
* kind of pisses me off, but whatever.
*
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random#Getting_a_random_integer_between_two_values
*/
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min) + min); //The maximum is exclusive and the minimum is inclusive
}
/**
* Displays information about the currently selected album from the list.
* Triggers whenever the selection changes.
*/
async function albumSelectUpdate() {
const albumSelect = document.querySelector('#albumSelect');
const option = albumSelect.options[albumSelect.selectedIndex];
const albumId = parseInt(option.value, 10);
const album = _albums.find((x) => x.id === albumId);
/**
* No actual album selected.
*/
const bypassCacheDiv = document.querySelector('#bypassCache');
const albumPrivacyDiv = document.querySelector('#albumPrivacy');
const info = document.querySelector('#albumSelectInfo');
if (!album) {
/**
* Hide stuff since no album is selected.
*/
bypassCacheDiv.style.display = 'none';
albumPrivacyDiv.style.display = 'none';
info.innerHTML = '';
return;
}
/**
* Show the divs again.
*/
bypassCacheDiv.style.display = null;
albumPrivacyDiv.style.display = null;
const { files, editedAt, timestamp, uploads } = album;
const fileCount = files || uploads || 0;
const fileText = `${fileCount} file${fileCount === 1 ? '' : 's'}`;
const identifier = album.identifier;
/**
* Timestamps are specified in seconds, while JavaScript
* deals in milliseconds.
*/
const editTime = new Date((editedAt || timestamp) * 1000);
/**
* Special handler for certain sites. I think this one was caused by
* share.dmca.gripe, but the script doesn't work on that anymore (due to more drastic changes).
*
* This doesn't really hurt anyways though, so I'm leaving it as-is.
*/
const isFullUrl = identifier.startsWith('https://');
let identifierUrl = isFullUrl
? identifier
: `/a/${identifier}`;
/**
* Use `homeDomain` if it has been set via an albums re-fetch
*/
if (homeDomain && !isFullUrl) {
identifierUrl = homeDomain + identifierUrl;
}
const bypassCache = document.querySelector('#bypassCacheCheck');
if (bypassCache.checked) {
identifierUrl += `?${getRandomInt(1000, 10000)}`;
}
/**
* Make sure the album privacy checkbox corresponds
* with the (cached) value from the API.
*/
const albumPrivacy = document.querySelector('#albumPrivacyCheck');
albumPrivacy.checked = album.public;
albumPrivacy.setAttribute('data-album-id', album.id);
/**
* TODO: Dynamic styling.
* While I say that I only test this on CyberDrop, it would probably be nice if we
* didn't hardcode styling that might not match other sites (such as `zz.ht`).
*/
const editedTimeText = new Intl.DateTimeFormat('default', {dateStyle: 'long', timeStyle: 'medium'}).format(editTime);
const preStyle = `padding: 8px 12px;margin-bottom: 6px;background-color: #111111;`;
const codeStyle = `color: #d0d0d0;`;
info.innerHTML = `Currently ${fileText} in:`;
info.innerHTML += `<pre style="${preStyle}"><code style="${codeStyle}" id="infoAlbumName"></code></pre>`;
info.innerHTML += 'Last updated:';
info.innerHTML += `<pre style="${preStyle}"><code style="${codeStyle}">${editedTimeText}</code></pre>`;
info.innerHTML += `<a href="${identifierUrl}" target="_blank">Link to album</a>`;
/**
* Why the fuck does JavaScript not have a built-in way of
* HTML-encoding shit, so I don't have to do this janky workaround (or write a custom function...)
*/
const albumName = document.querySelector('#infoAlbumName');
albumName.textContent = album.name;
}
/**
* Update album privacy on checkbox toggle.
*/
async function updateAlbumPrivacy()
{
const apiUrl = '/api/albums/edit';
const albumPrivacy = document.querySelector('#albumPrivacyCheck');
const albumId = parseInt(albumPrivacy.getAttribute('data-album-id'), 10);
/**
* Array index of the album object,
* used for updating the object so the correct value displays after
* re-selecting a different album.
*
* Fixes a bug in v2.3.0
*/
const albumIdx = _albums.findIndex(x => x.id === albumId);
const album = _albums.find(x => x.id === albumId);
if (!album) {
albumPrivacy.checked = !albumPrivacy.checked;
return;
}
/**
* Disable the checkbox while we send a request.
*/
albumPrivacy.setAttribute('disabled', '1');
const { id, name, download, description } = album;
/**
* I don't think `.checked` can return anything other than a boolean,
* but just in case I'm guaranteeing that it's a boolean value...
*/
const public = albumPrivacy.checked ? true : false;
const albumBody = {
id,
name,
description,
public,
download,
requestLink: false,
};
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
token: localStorage.token,
},
body: JSON.stringify(albumBody),
});
const data = await response.json();
if (!data.success) {
albumPrivacy.checked = !albumPrivacy.checked;
/**
* TODO: Proper error message lol
*/
console.error('Unable to update album privacy');
}
}
catch (err) {
albumPrivacy.checked = !albumPrivacy.checked;
console.error(err);
}
/**
* Updates the album object in the local cache
* so that selecting a different album and then re-selecting
* doesn't show the wrong public status.
*
* Fixes a bug in v2.3.0
*/
_albums[albumIdx].public = albumPrivacy.checked;
albumPrivacy.removeAttribute('disabled');
}
/**
* Handle album list switching.
*/
async function registerSelectListener() {
const albumSelect = document.querySelector('#albumSelect');
const albumDiv = document.querySelector('#albumDiv');
albumDiv.insertAdjacentHTML('afterend', '<div id="albumSelectInfo" style="margin-bottom: 10px;"></div>');
albumSelect.addEventListener('change', albumSelectUpdate);
/**
* 'Bypass Cache' checkbox/handler
*/
const selectInfo = document.querySelector('#albumSelectInfo');
const bypassCacheAttr = "Modifies the URL slightly to bypass CyberDrop's album cache. Useful for high-traffic albums that are updated. May not work perfectly.";
const bypassCacheHtml = `<div id="bypassCache" style="margin-bottom: 10px; display: none;">
<div class="control" style="text-align: center;">
<input type="checkbox" id="bypassCacheCheck">
<attr title="${bypassCacheAttr}">
Bypass album cache?
</attr>
</div>
</div>`;
selectInfo.insertAdjacentHTML('afterend', bypassCacheHtml);
const bypassCache = document.querySelector('#bypassCache');
const bypassCacheCheck = document.querySelector('#bypassCacheCheck');
bypassCacheCheck.addEventListener('change', albumSelectUpdate);
const albumPrivacyAttr = 'Toggles the album settings and makes it public (checked) or private (unchecked)';
const albumPrivacyHtml = `<div id="albumPrivacy" style="margin-bottom: 10px; display: none;">
<div class="control" style="text-align: center;">
<input type="checkbox" id="albumPrivacyCheck">
<attr title="${albumPrivacyAttr}">
Public album URL?
</attr>
</div>
</div>`;
bypassCache.insertAdjacentHTML('afterend', albumPrivacyHtml);
const albumPrivacyCheck = document.querySelector('#albumPrivacyCheck');
albumPrivacyCheck.addEventListener('change', updateAlbumPrivacy);
}
/**
* Rewrite the HTML of the `<select>` list completely.
*
* @param {object} albums Expects a sorted album object from `sortAlbums()`.
*/
async function overwriteOptions(albums) {
const albumsHtml = albums.map(
(album) => {
const {id, name} = album;
const element = document.createElement('option');
element.setAttribute('value', id);
element.textContent = name;
/**
* Once again doing it this way out of pettiness
* to avoid using some ghetto `string.replace()`
* for HTML-encoding strings...
*/
return element.outerHTML;
},
);
const albumSelect = document.querySelector('#albumSelect');
albumSelect.innerHTML =
'<option value="" selected="">Upload to album</option>' +
albumsHtml.join('\n');
}
/**
* Sorts albums based on the album titles.
*
* @param {object} albums The albums to sort. Expects the API response for /api/albums.
*/
async function sortAlbums(albums) {
albums.sort((first, second) => {
const a = first.name.toLowerCase();
const b = second.name.toLowerCase();
if (a > b) {
return 1;
}
if (a < b) {
return -1;
}
return 0;
});
unsafeWindow._Meta_Albums = albums;
return albums;
}
/**
* Attempt to retrieve the albums from the API.
*
* @param {string} token Token from local storage that identifies the user.
*/
async function getAlbums(token) {
const options = {
headers: {
token,
},
};
const response = await fetch('/api/albums', options);
const data = await response.json();
if (!data.success) {
// Error so I'm just returning cuz lazy lol
return [];
}
/**
* Since `homeDomain` was included alongside albums,
* we use this instead.
*/
if (data.homeDomain) {
homeDomain = data.homeDomain;
}
let albums = data.albums;
let albumPage = 1;
while (data.count > albums.length) {
console.log(`Album count mismatch detected (likely due to more updated instance with pagination) - Fetching album offset: ${albumPage}`);
const newRequest = await fetch(`/api/albums/${albumPage}`, options);
const newData = await newRequest.json();
albums = [...albums, ...newData.albums];
albumPage++;
}
return albums;
}
/**
* Practically speaking just runs all the other functions in the correct order
* for getting the albums, 'parsing' the response, sorting the albums
* and then replacing the old list with the sorted one.
*
* Also used when hitting the "Refresh button" to... well, refresh.
*
* @param {object} albums Optionally specify albums, in which case the `getAlbums()` call is completely skipped.
*/
async function refreshAlbums(albums) {
if (!albums || typeof albums !== 'object') {
console.log('[RefreshAlbums] No `albums` parameter specified, so we try to request from the API.');
const token = localStorage.token;
if (!token) {
console.error('Cannot refresh albums because `token` is not in localStorage.',);
return;
}
albums = await getAlbums(token);
if (albums.length === 0) {
// No albums, not gonna bother sorting them.
console.log('No albums found.');
return;
}
}
/**
* Get the current option selected, if any.
*/
const albumSelect = document.querySelector('#albumSelect');
const option = albumSelect.options[albumSelect.selectedIndex];
const oldSelection = option.value;
await sortAlbums(albums);
_albums = albums;
await overwriteOptions(albums);
/**
* If there was no previous selection, then it should default to #1 and we return early.
*/
if (!oldSelection) {
return;
}
albumSelect.value = oldSelection;
await albumSelectUpdate();
}
/**
* Event handler for the refresh button.
*/
async function refreshBtnHandler() {
const albumSelect = document.querySelector('#albumSelect');
const refreshBtn = document.querySelector('#refreshAlbums');
albumSelect.setAttribute('disabled', '1');
refreshBtn.setAttribute('disabled', '1');
try {
await refreshAlbums();
}
catch (e) {
console.error('Unable to refresh albums...');
console.error(e);
}
albumSelect.removeAttribute('disabled');
refreshBtn.removeAttribute('disabled');
}
/**
* Adds the refresh button to the page on every page load.
*/
async function addRefreshButton() {
const albumDiv = document.querySelector('#albumDiv');
const refreshBtnHtml = `<div class="control">
<a id="refreshAlbums" class="button is-info is-outlined" title="Refresh albums">
<i class="icon-arrows-cw"></i>
</a>
</div>`;
albumDiv.insertAdjacentHTML('beforeend', refreshBtnHtml);
const refreshBtn = document.querySelector('#refreshAlbums');
refreshBtn.addEventListener('click', refreshBtnHandler);
}
/**
* Initialization function. Should only run once.
*/
async function init() {
/**
* `unsafeWindow.cdAlbums` is technically only available in CyberDrop.
* Other Lolisafe instances will fallback to fetching the albums,
* so it should be safe to deal with it this way.
*
* If other Lolisafe instances want to somehow add support for this userscript,
* modify the `page.fetchAlbums` function inside `public/js/home.js` and add the following
* after the `if (Array.isArray(...))` (to make sure the albums variable is valid):
* `window.cdAlbums = response.data.albums`
*
* If you have no idea how to do that, don't do it (or at least backup before you do it).
*/
await refreshAlbums(unsafeWindow.cdAlbums);
await registerSelectListener();
await addRefreshButton();
}
/**
* Triggered when the album list is updated.
* We add an `alreadyInit` check to make sure that it only runs once,
* though we probably could've just unregistered the MutationObserver (somehow).
* IDK.
*/
let alreadyInit = false;
async function handleInitialSelectUpdate() {
if (alreadyInit) {
return;
}
console.log('Initial change detected. Running init!');
alreadyInit = true;
await init();
}
// Let's register a listener for the select <option> changes.
// Thanks: https://stackoverflow.com/a/39445989
MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
const observer = new MutationObserver(async (mutations, observer) => {
await handleInitialSelectUpdate();
});
observer.observe(document.querySelector('#albumSelect'), {
subtree: true,
childList: true,
attributes: true,
});
@M-rcus
Copy link
Author

M-rcus commented Jul 14, 2020

Known bug in 2.1.1 (and earlier versions?):
- Sometimes albums show up twice in the list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment