penguinmod-editor-2 / src /addons /settings-store.js
soiz1's picture
Upload 2891 files
6bcb42f verified
/**
* Copyright (C) 2021 Thomas Weber
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import addons from './generated/addon-manifests';
import upstreamMeta from './generated/upstream-meta.json';
import EventTargetShim from './event-target';
const SETTINGS_KEY = 'tw:addons';
const VERSION = 4;
const migrateSettings = settings => {
const oldVersion = settings._;
if (oldVersion === VERSION || !oldVersion) {
return settings;
}
// Migrate 1 -> 2
// tw-project-info is now block-count
// tw-interface-customization split into tw-remove-backpack and tw-remove-feedback
if (oldVersion < 2) {
const projectInfo = settings['tw-project-info'];
if (projectInfo && projectInfo.enabled) {
settings['block-count'] = {
enabled: true
};
}
const interfaceCustomization = settings['tw-interface-customization'];
if (interfaceCustomization && interfaceCustomization.enabled) {
if (interfaceCustomization.removeBackpack) {
settings['tw-remove-backpack'] = {
enabled: true
};
}
if (interfaceCustomization.removeFeedback) {
settings['tw-remove-feedback'] = {
enabled: true
};
}
}
}
// Migrate 2 -> 3
// The default value of hide-flyout's toggle setting changed from "hover" to "cathover"
// We want to keep the old default value for existing users.
if (oldVersion < 3) {
const hideFlyout = settings['hide-flyout'];
if (hideFlyout && hideFlyout.enabled && typeof hideFlyout.toggled === 'undefined') {
hideFlyout.toggle = 'hover';
}
}
// Migrate 3 -> 4
// editor-devtools was broken up into find-bar and middle-click-popup.
// If someone disabled editor-devtools, we want to keep these disabled.
if (oldVersion < 4) {
const editorDevtools = settings['editor-devtools'];
if (editorDevtools && editorDevtools.enabled === false) {
settings['find-bar'] = {
enabled: false
};
settings['middle-click-popup'] = {
enabled: false
};
}
}
return settings;
};
/**
* @template T
* @param {T|T[]} v A value
* @returns {T[]} The value if it is a list, otherwise a 1 item list
*/
const asArray = v => {
if (Array.isArray(v)) {
return v;
}
return [v];
};
class SettingsStore extends EventTargetShim {
constructor () {
super();
this.store = this.createEmptyStore();
this.remote = false;
}
/**
* @private
*/
createEmptyStore () {
const result = {};
for (const addonId of Object.keys(addons)) {
result[addonId] = {};
}
return result;
}
readLocalStorage () {
const base = this.store;
try {
const local = localStorage.getItem(SETTINGS_KEY);
if (local) {
let result = JSON.parse(local);
if (result && typeof result === 'object') {
result = migrateSettings(result);
for (const key of Object.keys(result)) {
if (base.hasOwnProperty(key)) {
const value = result[key];
if (value && typeof value === 'object') {
base[key] = value;
}
}
}
}
}
} catch (e) {
// ignore
}
this.store = base;
}
/**
* @private
*/
saveToLocalStorage () {
if (this.remote) {
return;
}
try {
const result = {
_: VERSION
};
for (const addonId of Object.keys(addons)) {
const data = this.getAddonStorage(addonId);
if (Object.keys(data).length > 0) {
result[addonId] = data;
}
}
localStorage.setItem(SETTINGS_KEY, JSON.stringify(result));
} catch (e) {
// ignore
}
}
/**
* @private
*/
getAddonStorage (addonId) {
if (this.store[addonId]) {
return this.store[addonId];
}
throw new Error(`Unknown addon store: ${addonId}`);
}
/**
* @private
*/
getAddonManifest (addonId) {
if (addons[addonId]) {
return addons[addonId];
}
throw new Error(`Unknown addon: ${addonId}`);
}
/**
* @private
*/
getAddonSettingObject (manifest, settingId) {
if (!manifest.settings) {
return null;
}
for (const setting of manifest.settings) {
if (setting.id === settingId) {
return setting;
}
}
return null;
}
getAddonEnabled (addonId) {
const manifest = this.getAddonManifest(addonId);
if (manifest.unsupported) {
return false;
}
const storage = this.getAddonStorage(addonId);
if (storage.hasOwnProperty('enabled')) {
return storage.enabled;
}
return !!manifest.enabledByDefault;
}
getAddonSetting (addonId, settingId) {
const storage = this.getAddonStorage(addonId);
const manifest = this.getAddonManifest(addonId);
const settingObject = this.getAddonSettingObject(manifest, settingId);
if (!settingObject) {
throw new Error(`Unknown setting: ${settingId}`);
}
if (storage.hasOwnProperty(settingId)) {
return storage[settingId];
}
return settingObject.default;
}
/**
* @private
*/
getDefaultSettings (addonId) {
const manifest = this.getAddonManifest(addonId);
const result = {};
for (const {id, default: value} of manifest.settings) {
result[id] = value;
}
return result;
}
setAddonEnabled (addonId, enabled) {
const storage = this.getAddonStorage(addonId);
const manifest = this.getAddonManifest(addonId);
const oldValue = this.getAddonEnabled(addonId);
if (enabled === null) {
enabled = !!manifest.enabledByDefault;
delete storage.enabled;
} else if (typeof enabled === 'boolean') {
storage.enabled = enabled;
} else {
throw new Error('Enabled value is invalid.');
}
this.saveToLocalStorage();
if (enabled !== oldValue) {
// Dynamic enable is always supported.
// Dynamic disable requires addon support.
const supportsDynamic = enabled ? true : !!manifest.dynamicDisable;
this.dispatchEvent(new CustomEvent('setting-changed', {
detail: {
addonId,
settingId: 'enabled',
reloadRequired: !supportsDynamic,
value: enabled
}
}));
}
}
setAddonSetting (addonId, settingId, value) {
const storage = this.getAddonStorage(addonId);
const manifest = this.getAddonManifest(addonId);
const settingObject = this.getAddonSettingObject(manifest, settingId);
const oldValue = this.getAddonSetting(addonId, settingId);
if (value === null) {
value = settingObject.default;
delete storage[settingId];
} else {
if (settingObject.type === 'boolean') {
if (typeof value !== 'boolean') {
throw new Error('Setting value is invalid.');
}
} else if (settingObject.type === 'integer') {
if (typeof value !== 'number') {
throw new Error('Setting value is invalid.');
}
} else if (settingObject.type === 'color') {
if (typeof value !== 'string') {
throw new Error('Color value is not a string.');
}
// Remove alpha channel from colors like #012345ff
// We don't support transparency yet, but settings imported from Scratch Addons
// might contain transparency.
if (value.length === 9) {
value = value.substring(0, 7);
}
if (!/^#[0-9a-f]{6}$/i.test(value)) {
throw new Error('Color value is invalid format.');
}
} else if (settingObject.type === 'select') {
if (!settingObject.potentialValues.some(potentialValue => potentialValue.id === value)) {
throw new Error('Setting value is invalid.');
}
} else {
throw new Error('Setting object is of unknown type');
}
storage[settingId] = value;
}
this.saveToLocalStorage();
if (value !== oldValue) {
this.dispatchEvent(new CustomEvent('setting-changed', {
detail: {
addonId,
settingId,
reloadRequired: !settingObject.dynamic,
value
}
}));
}
}
applyAddonPreset (addonId, presetId) {
const manifest = this.getAddonManifest(addonId);
for (const {id, values} of manifest.presets) {
if (id !== presetId) {
continue;
}
const settings = {
...this.getDefaultSettings(addonId),
...values
};
for (const key of Object.keys(settings)) {
this.setAddonSetting(addonId, key, settings[key]);
}
return;
}
throw new Error(`Unknown preset: ${presetId}`);
}
resetAllAddons () {
for (const addon of Object.keys(addons)) {
this.resetAddon(addon, true);
}
// In case resetAddon missed some properties, do a hard reset on storage.
this.store = this.createEmptyStore();
this.saveToLocalStorage();
}
resetAddon (addonId, resetEverything) {
const storage = this.getAddonStorage(addonId);
for (const setting of Object.keys(storage)) {
if (setting === 'enabled') {
if (resetEverything) {
this.setAddonEnabled(addonId, null);
}
continue;
}
try {
this.setAddonSetting(addonId, setting, null);
} catch (e) {
// ignore
}
}
}
parseUrlParameter (parameter) {
this.remote = true;
const enabled = parameter.split(',');
for (const id of Object.keys(addons)) {
this.setAddonEnabled(id, enabled.includes(id));
}
}
export ({theme}) {
const result = {
core: {
// Upstream property. We don't use this.
lightTheme: theme === 'light',
// Doesn't matter what we set this to
version: `v1.0.0-tw-${upstreamMeta.commit}`
},
addons: {}
};
for (const [addonId, manifest] of Object.entries(addons)) {
const enabled = this.getAddonEnabled(addonId);
const settings = {};
if (manifest.settings) {
for (const {id} of manifest.settings) {
settings[id] = this.getAddonSetting(addonId, id);
}
}
result.addons[addonId] = {
enabled,
settings
};
}
return result;
}
import (data) {
for (const [addonId, value] of Object.entries(data.addons)) {
if (!addons.hasOwnProperty(addonId)) {
continue;
}
const {enabled, settings} = value;
if (typeof enabled === 'boolean') {
this.setAddonEnabled(addonId, enabled);
}
for (const [settingId, settingValue] of Object.entries(settings)) {
try {
this.setAddonSetting(addonId, settingId, settingValue);
} catch (e) {
// ignore
}
}
}
}
setStoreWithVersionCheck ({version, store}) {
if (version !== upstreamMeta.commit) {
return;
}
this.setStore(store);
}
setStore (newStore) {
const oldStore = this.store;
for (const addonId of Object.keys(oldStore)) {
const oldSettings = oldStore[addonId];
const newSettings = newStore[addonId];
if (!newSettings || typeof newSettings !== 'object') {
continue;
}
if (JSON.stringify(oldSettings) !== JSON.stringify(newSettings)) {
const manifest = this.getAddonManifest(addonId);
// Dynamic enable is always supported.
const dynamicEnable = !oldSettings.enabled && newSettings.enabled;
// Dynamic disable requires addon support.
const dynamicDisable = !!manifest.dynamicDisable && oldSettings.enabled && !newSettings.enabled;
// Clone to avoid pass-by-reference issues
this.store[addonId] = JSON.parse(JSON.stringify(newSettings));
this.dispatchEvent(new CustomEvent('addon-changed', {
detail: {
addonId,
dynamicEnable,
dynamicDisable
}
}));
}
}
}
/**
* Evaluate an `if` value from addon.json.
* @param {string} addonId The ID of the addon.
* @param {unknown} condition Condition from addon.json
* @returns {boolean} True if the condition is met.
*/
evaluateCondition (addonId, condition) {
if (!condition) {
// No condition. Default to true.
return true;
}
if (condition.addonEnabled) {
// addonEnabled is an OR
const addonsToCheck = asArray(condition.addonEnabled);
if (addonsToCheck.every(id => !this.getAddonEnabled(id))) {
return false;
}
}
if (condition.settings) {
// settings is an AND
for (const [settingName, expectedValue] of Object.entries(condition.settings)) {
if (this.getAddonSetting(addonId, settingName) !== expectedValue) {
return false;
}
}
}
return true;
}
}
export default SettingsStore;