Userstyle World - Auto Sync UserStyles with Selection UI

Automatically sync userstyles by visiting the mirror URL with a selection UI

// ==UserScript==
// @name         Userstyle World - Auto Sync UserStyles with Selection UI
// @namespace    typpi.online
// @version      2.0
// @description  Automatically sync userstyles by visiting the mirror URL with a selection UI
// @author       Nick2bad4u
// @license      Unlicense
// @homepageURL  https://github.com/Nick2bad4u/UserStyles
// @supportURL   https://github.com/Nick2bad4u/UserStyles/issues
// @icon         https://www.google.com/s2/favicons?sz=64&domain=userstyles.world
// @match        *://userstyles.world/*
// @grant        none
// ==/UserScript==

(function () {
	'use strict';

	/**
	 * @constant {string} SYNC_STATUS_ID - The ID of the element displaying the sync status.
	 */
	const SYNC_STATUS_ID = 'sync-status';
	/**
	 * @constant {string} UI_CONTAINER_ID - The ID of the UI container element.
	 */
	const UI_CONTAINER_ID = 'sync-ui-container';
	/**
	 * @const {string} MINIMIZE_BUTTON_TEXT_COLLAPSE - The text used to represent the collapse action on a minimize button.
	 */
	const MINIMIZE_BUTTON_TEXT_COLLAPSE = '-';
	/**
	 * @const {string} MINIMIZE_BUTTON_TEXT_EXPAND - The text used to represent the expand button when a section is minimized.
	 */
	const MINIMIZE_BUTTON_TEXT_EXPAND = '+';
	/**
	 * @const {string} SELECT_ALL_BUTTON_TEXT_SELECT - The text for the "Select All" button.
	 */
	const SELECT_ALL_BUTTON_TEXT_SELECT = 'Select All';
	/**
	 * @const {string} SELECT_ALL_BUTTON_TEXT_DESELECT - Text for a button that deselects all items.
	 */
	const SELECT_ALL_BUTTON_TEXT_DESELECT = 'Deselect All';

	/**
	 * Extracts style IDs from the page.
	 * @returns {string[]} An array of style IDs.
	 */
	function getStyleIDs() {
		return Array.from(document.querySelectorAll('a.card-header.thumbnail'))
			.map((link) => link.getAttribute('href'))
			.map((href) => href.match(/\/style\/(\d+)\//))
			.filter(Boolean) // Remove null matches
			.map((match) => match[1]);
	}

	/**
	 * Visits the mirror URL for a given style ID.
	 * @param {string} styleID The style ID to visit.
	 * @returns {Promise<void>} A promise that resolves when the mirror URL is visited successfully, or rejects if an error occurs.
	 */
	async function visitMirrorURL(styleID) {
		const mirrorURL = `https://userstyles.world/mirror/${styleID}`;
		try {
			const response = await fetch(mirrorURL);
			if (!response.ok) {
				throw new Error(`Failed to visit ${mirrorURL}: ${response.status} ${response.statusText}`);
			}
			updateStatus(`Successfully visited mirror URL for style ID: ${styleID}`);
		} catch (error) {
			updateStatus(`Error visiting mirror URL for style ID: ${styleID}. Error: ${error.message}`);
			console.error(`Error visiting ${mirrorURL}:`, error);
		}
	}

	/**
	 * Updates the status message in the UI.
	 * @param {string} message The message to display.
	 */
	function updateStatus(message) {
		const statusElement = document.getElementById(SYNC_STATUS_ID);
		if (statusElement) {
			statusElement.textContent = message;
		}
	}

	/**
	 * Creates a user interface for selecting and syncing styles.
	 *
	 * @param {string[]} styleIDs An array of style IDs to be displayed as selectable options.
	 *
	 * @returns {void} This function does not return a value. It creates and appends a UI container to the document body.
	 *
	 * @description
	 * This function dynamically generates a UI container with checkboxes for each style ID provided.
	 * It includes features such as:
	 *  - A title bar with a minimize/expand button.
	 *  - Checkboxes for selecting individual styles.
	 *  - Shift-click functionality for selecting multiple checkboxes at once.
	 *  - A "Select All" button to toggle the selection of all styles.
	 *  - A "Sync Selected Styles" button to initiate the syncing process for selected styles.
	 *  - A status display to provide feedback on the syncing process.
	 *
	 * The UI is appended to the document body as a fixed element, allowing users to interact with it regardless of page scrolling.
	 *
	 * @fires syncButton.onclick - When the "Sync Selected Styles" button is clicked, it triggers the syncing process for the selected style IDs.
	 * @fires selectAllButton.onclick - When the "Select All" button is clicked, it toggles the selection state of all checkboxes.
	 * @fires checkbox.onclick - When a checkbox is clicked, it updates the visibility of the "Sync Selected Styles" button based on whether any checkboxes are selected.  It also handles shift-click selection.
	 * @fires minimizeButton.onclick - When the minimize button is clicked, it collapses or expands the form.
	 */
	function createUI(styleIDs) {
		const container = document.createElement('div');
		container.id = UI_CONTAINER_ID;
		Object.assign(container.style, {
			position: 'fixed',
			bottom: '10px',
			right: '10px',
			width: '250px',
			backgroundColor: '#000',
			border: '1px solid #5a4ebc',
			borderRadius: '5px',
			padding: '15px',
			boxShadow: '0 2px 10px #0000001a',
			zIndex: '1000',
			maxHeight: '50vh',
			overflowY: 'auto',
			color: '#fff',
		});

		const titleContainer = document.createElement('div');
		Object.assign(titleContainer.style, {
			display: 'flex',
			justifyContent: 'space-between',
			alignItems: 'center',
		});
		container.appendChild(titleContainer);

		const title = document.createElement('h3');
		title.textContent = 'Select Styles to Sync';
		Object.assign(title.style, {
			marginBottom: '10px',
			fontSize: '16px',
			fontWeight: 'bold',
		});
		titleContainer.appendChild(title);

		const minimizeButton = document.createElement('button');
		minimizeButton.textContent = MINIMIZE_BUTTON_TEXT_COLLAPSE;
		Object.assign(minimizeButton.style, {
			background: 'none',
			border: 'none',
			cursor: 'pointer',
			fontSize: '16px',
			marginBottom: '10px',
			color: '#fff',
		});
		titleContainer.appendChild(minimizeButton);

		let isMinimized = false;
		let lastCheckedCheckbox = null;

		const form = document.createElement('form');
		form.style.display = 'none';

		const checkboxes = styleIDs.map((styleID) => {
			const label = document.createElement('label');
			Object.assign(label.style, {
				display: 'flex',
				alignItems: 'center',
				marginBottom: '8px',
			});

			const checkbox = document.createElement('input');
			checkbox.type = 'checkbox';
			checkbox.value = styleID;
			Object.assign(checkbox.style, {
				marginRight: '10px',
				opacity: '1',
			});

			label.appendChild(checkbox);
			label.appendChild(document.createTextNode(` Style ID: ${styleID}`));
			form.appendChild(label);

			checkbox.addEventListener('click', (event) => {
				if (event.shiftKey && lastCheckedCheckbox !== null) {
					const currentIndex = checkboxes.indexOf(checkbox);
					const lastIndex = checkboxes.indexOf(lastCheckedCheckbox);
					const start = Math.min(currentIndex, lastIndex);
					const end = Math.max(currentIndex, lastIndex);

					for (let i = start; i <= end; i++) {
						checkboxes[i].checked = lastCheckedCheckbox.checked;
					}
				}
				lastCheckedCheckbox = checkbox;
				syncButton.style.display = checkboxes.some((checkbox) => checkbox.checked) ? 'block' : 'none';
			});

			return checkbox;
		});

		const selectAllButton = document.createElement('button');
		selectAllButton.textContent = SELECT_ALL_BUTTON_TEXT_SELECT;
		selectAllButton.type = 'button';
		Object.assign(selectAllButton.style, {
			width: '100%',
			padding: '10px',
			backgroundColor: '#28a745',
			color: '#fff',
			border: 'none',
			borderRadius: '5px',
			cursor: 'pointer',
			fontSize: '14px',
			marginBottom: '10px',
			display: 'none',
		});

		selectAllButton.onclick = () => {
			const allChecked = checkboxes.every((checkbox) => checkbox.checked);
			checkboxes.forEach((checkbox) => (checkbox.checked = !allChecked));
			selectAllButton.textContent = allChecked ? SELECT_ALL_BUTTON_TEXT_SELECT : SELECT_ALL_BUTTON_TEXT_DESELECT;
			syncButton.style.display = checkboxes.some((checkbox) => checkbox.checked) ? 'block' : 'none';
		};
		container.appendChild(selectAllButton);

		const syncButton = document.createElement('button');
		syncButton.textContent = 'Sync Selected Styles';
		syncButton.type = 'button';
		Object.assign(syncButton.style, {
			width: '100%',
			padding: '10px',
			backgroundColor: '#007bff',
			color: '#fff',
			border: 'none',
			borderRadius: '5px',
			cursor: 'pointer',
			fontSize: '14px',
			marginTop: '10px',
			display: 'none',
		});
		syncButton.addEventListener('mouseover', () => {
			syncButton.style.backgroundColor = '#0056b3';
		});
		syncButton.addEventListener('mouseout', () => {
			syncButton.style.backgroundColor = '#007bff';
		});

		syncButton.onclick = () => {
			const selectedIDs = checkboxes.filter((checkbox) => checkbox.checked).map((checkbox) => checkbox.value);

			if (selectedIDs.length > 0) {
				updateStatus('Syncing selected styles...');
				const promises = selectedIDs.map(async (styleID) => {
					const mirrorURL = `https://userstyles.world/mirror/${styleID}`;
					updateStatus(`Syncing from: ${mirrorURL}`);
					await visitMirrorURL(styleID);
				});
				Promise.all(promises).then(() => {
					updateStatus(`Syncing complete for styles: ${selectedIDs.join(', ')}`);
				});
			} else {
				updateStatus('No styles selected for syncing.');
			}
		};

		const status = document.createElement('div');
		status.id = SYNC_STATUS_ID;
		Object.assign(status.style, {
			marginBottom: '10px',
			fontSize: '12px',
			color: '#fff',
		});
		container.insertBefore(status, titleContainer);

		container.appendChild(syncButton);
		container.appendChild(form);
		document.body.appendChild(container);

		/**
		 * Toggles the visibility of the form and certain buttons based on the `isMinimized` state.
		 * When minimized, the form, selectAllButton, and status are hidden, and the syncButton is shown only if any checkboxes are checked.
		 * When not minimized, the form, selectAllButton, and status are shown, and the syncButton is hidden.
		 * The minimizeButton's text content is also updated to reflect the current state.
		 */
		function toggleMinimize() {
			isMinimized = !isMinimized;
			form.style.display = isMinimized ? 'none' : 'block';
			selectAllButton.style.display = isMinimized ? 'none' : 'block';
			syncButton.style.display = isMinimized && checkboxes.some((checkbox) => checkbox.checked) ? 'block' : 'none';
			status.style.display = isMinimized ? 'none' : 'block';
			minimizeButton.textContent = isMinimized ? MINIMIZE_BUTTON_TEXT_EXPAND : MINIMIZE_BUTTON_TEXT_COLLAPSE;
		}

		minimizeButton.onclick = toggleMinimize;
		toggleMinimize(); // Initialize to minimized state
	}

	/**
	 * @function main
	 * @description This is the main function that initializes the script. It retrieves style IDs from the page,
	 * and if style IDs are found, it creates the user interface. If no style IDs are found, it logs a warning
	 * to the console and updates the status message.
	 * @returns {void}
	 */
	function main() {
		const styleIDs = getStyleIDs();
		if (styleIDs.length > 0) {
			createUI(styleIDs);
		} else {
			console.warn('No style IDs found on the page.');
			updateStatus('No style IDs found on the page.');
		}
	}

	// Execute main function after the page is fully loaded
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', main);
	} else {
		main();
	}
})();