Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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/
// @grant none
// @version 2.4.1
// @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 } = album;
const fileText = `${files} file${files === 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;
}
const albums = data.albums;
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

This comment has been minimized.

Copy link
Owner Author

@M-rcus 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