Monkey Storage

Useful library for dealing with the storage.

As of 2020-06-23. See the latest version.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/405831/819333/Monkey%20Storage.js

// ==UserScript==
// @name Monkey Storage
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 1.0.3
// @author rafaelgssa
// @description Useful library for dealing with the storage.
// @match *://*/*
// @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @require https://unpkg.com/uuid@latest/dist/umd/uuidv4.min.js
// @require https://greasyfork.org/scripts/405813-monkey-utils/code/Monkey%20Utils.js
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// ==/UserScript==

/* global MonkeyUtils, uuidv4 */

/**
 * @typedef {Object} MonkeyStorageLock
 * @property {string} uuid
 * @property {string} key
 * @property {number} [timeoutId]
 * @typedef {Object} MonkeyStorageLocked
 * @property {string} uuid
 * @property {number} timestamp
 * @typedef {() => Promise<void>} MonkeyStorageRelease
 * @typedef {{
 *   [K: string]: unknown,
 *   settings: Record<string, unknown>
 * }} MonkeyStorageValues
 */

// eslint-disable-next-line
const MonkeyStorage = (() => {
	let _id = '';

	/** @type {MonkeyStorageValues} */
	const _defaultValues = {
		settings: {},
	};

	/** @type {MonkeyStorageValues} */
	const _cache = {
		settings: {},
	};

	/**
	 * Initializes the storage.
	 * @param {string} id The ID to use for the local storage.
	 * @param {Partial<MonkeyStorageValues>} [defaultValues] Any default values to set.
	 * @returns {Promise<void>}
	 */
	const init = (id, defaultValues) => {
		_id = id;
		if (MonkeyUtils.isSet(defaultValues)) {
			for (const [key, value] of Object.entries(defaultValues)) {
				setDefaultValue(key, value);
			}
		}
		return _updateCache('settings');
	};

	/**
	 * Sets a default value.
	 * @param {string} key The key of the default value to set.
	 * @param {unknown} value The default value to set.
	 */
	const setDefaultValue = (key, value) => {
		_defaultValues[key] = value;
	};

	/**
	 * Sets a value in the storage.
	 * @param {string} key The key of the value to set.
	 * @param {unknown} value The value to set.
	 * @returns {Promise<void>}
	 */
	const setValue = async (key, value) => {
		const stringifiedValue = JSON.stringify(value);
		await GM.setValue(key, stringifiedValue);
		_cache[key] = value;
	};

	/**
	 * Gets a value from the cache.
	 * @param {string} key The key of the value to get.
	 * @param {boolean} [updateCache] Whether to update the cache with the storage or not.
	 * @returns {Promise<unknown>} The value.
	 */
	const getValue = async (key, updateCache = false) => {
		if (!MonkeyUtils.isSet(_cache[key]) || updateCache) {
			await _updateCache(key);
		}
		return _cache[key];
	};

	/**
	 * Updates a value in the cache with the storage.
	 * @param {string} key The key of the value to update.
	 * @returns {Promise<void>}
	 */
	const _updateCache = async (key) => {
		let value = await GM.getValue(key);
		if (typeof value === 'string') {
			try {
				value = JSON.parse(value);
			} catch (err) {
				// Value is already parsed, just ignore.
			}
		}
		_cache[key] = MonkeyUtils.isSet(value) ? value : _defaultValues[key];
	};

	/**
	 * Deletes a value from the storage.
	 * @param {string} key The key of the value to delete.
	 * @returns {Promise<void>}
	 */
	const deleteValue = async (key) => {
		await GM.deleteValue(key);
		delete _cache[key];
	};

	/**
	 * Sets a value in the local storage.
	 * @param {string} key The key of the value to set.
	 * @param {unknown} value The value to set.
	 */
	const setLocalValue = (key, value) => {
		const stringifiedValue = JSON.stringify(value);
		window.localStorage.setItem(`${_id}_${key}`, stringifiedValue);
		_cache[key] = value;
	};

	/**
	 * Gets a value from the cache.
	 * @param {string} key The key of the value to get.
	 * @param {boolean} [updateCache] Whether to update the cache with the local storage or not.
	 * @returns {unknown} The value.
	 */
	const getLocalValue = (key, updateCache = false) => {
		if (!MonkeyUtils.isSet(_cache[key]) || updateCache) {
			_updateLocalCache(key);
		}
		return _cache[key];
	};

	/**
	 * Updates a value in the cache with the local storage.
	 * @param {string} key The key of the value to update.
	 */
	const _updateLocalCache = (key) => {
		let value = window.localStorage.getItem(`${_id}_${key}`);
		if (typeof value === 'string') {
			try {
				value = JSON.parse(value);
			} catch (err) {
				// Value is already parsed, just ignore.
			}
		}
		_cache[key] = MonkeyUtils.isSet(value) ? value : _defaultValues[key];
	};

	/**
	 * Deletes a value from the local storage.
	 * @param {string} key The key of the value to delete.
	 */
	const deleteLocalValue = (key) => {
		window.localStorage.removeItem(`${_id}_${key}`);
		delete _cache[key];
	};

	/**
	 * Sets a default setting.
	 * @param {string} key The key of the default setting to set.
	 * @param {unknown} setting The default setting to set.
	 */
	const setDefaultSetting = (key, setting) => {
		_defaultValues.settings[key] = setting;
	};

	/**
	 * Sets a setting in the cache.
	 * @param {string} key The key of the setting to set.
	 * @param {unknown} setting The setting to set.
	 */
	const setSetting = (key, setting) => {
		_cache.settings[key] = setting;
	};

	/**
	 * Gets a setting from the cache.
	 * @param {string} key The key of the setting to get.
	 * @param {boolean} [updateCache] Whether to update the settings cache with the storage or not.
	 * @returns {Promise<unknown>} The setting.
	 */
	const getSetting = async (key, updateCache = false) => {
		if (isSettingsEmpty() || !MonkeyUtils.isSet(_cache.settings[key]) || updateCache) {
			await _updateCache('settings');
		}
		return _cache.settings[key];
	};

	/**
	 * Deletes a setting from the cache.
	 * @param {string} key The key of the setting to delete.
	 */
	const deleteSetting = (key) => {
		delete _cache.settings[key];
	};

	/**
	 * Saves the settings from the cache.
	 * @returns {Promise<void>}
	 */
	const saveSettings = () => {
		return setValue('settings', _cache.settings);
	};

	/**
	 * Checks if the settings cache is empty.
	 * @returns {boolean} Whether the settings cache is empty or not.
	 */
	const isSettingsEmpty = () => {
		return Object.keys(_cache.settings).length === 0;
	};

	/**
	 * Creates a lock in the local storage to prevent other tabs **in the same domain** from executing concurrently.
	 * @param {string} lockKey The key of the lock.
	 * @returns {Promise<MonkeyStorageRelease | undefined>} A function to release the lock, if successful.
	 */
	const createLock = async (lockKey) => {
		const uuid = uuidv4();
		const key = `lock_${lockKey}`;
		/** @type {MonkeyStorageLock} */
		const lock = { uuid, key };
		/** @type {MonkeyStorageLocked} */
		let locked;
		_logLockProgress('Trying to create', lock);
		locked = JSON.parse(localStorage.getItem(key) || '{}');
		if (locked.uuid && locked.uuid !== uuid && Date.now() - locked.timestamp <= 6000) {
			_logLockProgress('Failed to create', lock);
			return;
		}
		_logLockProgress('Preparing to create', lock);
		localStorage.setItem(key, `{ "uuid": "${uuid}", "timestamp": ${Date.now()} }`);
		await MonkeyUtils.sleep(3);
		locked = JSON.parse(localStorage.getItem(key) || '{}');
		if (!locked.uuid || locked.uuid !== uuid) {
			_logLockProgress('Failed to create', lock);
			return;
		}
		_logLockProgress('Created', lock);
		_reinforceLock(lock, 2);
		return () => _releaseLock(lock);
	};

	/**
	 * Keeps reinforcing a lock.
	 * @param {MonkeyStorageLock} lock The lock to reinforce.
	 * @param {number} frequency How frequently to reinforce the lock.
	 */
	const _reinforceLock = async (lock, frequency) => {
		const { uuid, key } = lock;
		_logLockProgress('Reinforcing', lock);
		localStorage.setItem(key, `{ "uuid": "${uuid}", "timestamp": ${Date.now()} }`);
		lock.timeoutId = window.setTimeout(_reinforceLock, frequency * 1000, lock, frequency);
	};

	/**
	 * Releases a lock.
	 * @param {MonkeyStorageLock} lock The lock to release.
	 * @returns {Promise<void>}
	 */
	const _releaseLock = async (lock) => {
		const { uuid, key, timeoutId } = lock;
		window.clearTimeout(timeoutId);
		/** @type {MonkeyStorageLocked} */
		const locked = JSON.parse(localStorage.getItem(key) || '{}');
		if (locked.uuid && locked.uuid === uuid) {
			localStorage.removeItem(key);
		}
		_logLockProgress('Released', lock);
	};

	/**
	 * @param {string} progress
	 * @param {MonkeyStorageLock} lock
	 */
	// eslint-disable-next-line
	const _logLockProgress = (progress, { uuid, key }) => {
		//console.log(`[${Date.now()}] ${progress} lock ${uuid}, ${key}`)
	};

	return {
		init,
		setDefaultValue,
		setValue,
		getValue,
		deleteValue,
		setLocalValue,
		getLocalValue,
		deleteLocalValue,
		setDefaultSetting,
		setSetting,
		getSetting,
		deleteSetting,
		saveSettings,
		isSettingsEmpty,
		createLock,
	};
})();