mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-10-15 07:21:58 +00:00
4.0.0 Beta 1
This commit is contained in:
parent
c2688646af
commit
262757a20d
187 changed files with 22878 additions and 38882 deletions
287
src/settings/context.js
Normal file
287
src/settings/context.js
Normal file
|
@ -0,0 +1,287 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Settings Contexts
|
||||
// ============================================================================
|
||||
|
||||
import {EventEmitter} from 'utilities/events';
|
||||
import {has, get as getter, array_equals} from 'utilities/object';
|
||||
|
||||
|
||||
/**
|
||||
* The SettingsContext class provides a context through which to read
|
||||
* settings values in addition to emitting events when settings values
|
||||
* are changed.
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
export default class SettingsContext extends EventEmitter {
|
||||
constructor(manager, context) {
|
||||
super();
|
||||
|
||||
if ( manager instanceof SettingsContext ) {
|
||||
this.parent = manager;
|
||||
this.manager = manager.manager;
|
||||
|
||||
this.parent.on('context_changed', this._rebuildContext, this);
|
||||
|
||||
} else {
|
||||
this.parent = null;
|
||||
this.manager = manager;
|
||||
}
|
||||
|
||||
this.manager.__contexts.push(this);
|
||||
this._context = context || {};
|
||||
|
||||
this.__cache = new Map;
|
||||
this.__meta = new Map;
|
||||
this.__profiles = [];
|
||||
this.order = [];
|
||||
|
||||
this._rebuildContext();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if ( this.parent )
|
||||
this.parent.off('context_changed', this._rebuildContext, this);
|
||||
|
||||
for(const profile of this.__profiles)
|
||||
profile.off('changed', this._onChanged, this);
|
||||
|
||||
const contexts = this.manager.__contexts,
|
||||
idx = contexts.indexOf(this);
|
||||
|
||||
if ( idx !== -1 )
|
||||
contexts.splice(idx, 1);
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// State Construction
|
||||
// ========================================================================
|
||||
|
||||
_rebuildContext() {
|
||||
this.__context = this.parent ?
|
||||
Object.assign({}, this.parent._context, this._context) :
|
||||
this._context;
|
||||
|
||||
// Make sure we re-build the cache. Dependency hell.
|
||||
if ( ! this.selectProfiles() )
|
||||
this.rebuildCache();
|
||||
|
||||
this.emit('context_changed');
|
||||
}
|
||||
|
||||
|
||||
selectProfiles() {
|
||||
const new_profiles = [],
|
||||
order = this.order = [];
|
||||
for(const profile of this.manager.__profiles)
|
||||
if ( profile.matches(this.__context) ) {
|
||||
new_profiles.push(profile);
|
||||
order.push(profile.id);
|
||||
}
|
||||
|
||||
if ( array_equals(this.__profiles, new_profiles) )
|
||||
return false;
|
||||
|
||||
const changed_ids = new Set;
|
||||
|
||||
for(const profile of this.__profiles)
|
||||
if ( ! new_profiles.includes(profile) ) {
|
||||
profile.off('changed', this._onChanged, this);
|
||||
changed_ids.add(profile.id);
|
||||
}
|
||||
|
||||
for(const profile of new_profiles)
|
||||
if ( ! this.__profiles.includes(profile) ) {
|
||||
profile.on('changed', this._onChanged, this);
|
||||
changed_ids.add(profile.id);
|
||||
}
|
||||
|
||||
this.__profiles = new_profiles;
|
||||
this.emit('profiles_changed');
|
||||
this.rebuildCache(changed_ids);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
rebuildCache() {
|
||||
const old_cache = this.__cache,
|
||||
old_meta = this.__meta,
|
||||
meta = this.__meta = new Map;
|
||||
|
||||
this.__cache = new Map;
|
||||
|
||||
// TODO: Limit the values we recalculate to ones affected by the change
|
||||
// that happened to the profiles. This is harder because of setting
|
||||
// dependencies.
|
||||
|
||||
for(const [key, old_value] of old_cache) {
|
||||
const new_value = this.get(key),
|
||||
new_m = meta.get(key),
|
||||
old_m = old_meta.get(key),
|
||||
new_uses = new_m ? new_m.uses : null,
|
||||
old_uses = old_m ? old_m.uses : null;
|
||||
|
||||
if ( new_value !== old_value ) {
|
||||
this.emit('changed', key, new_value, old_value);
|
||||
this.emit(`changed:${key}`, new_value, old_value);
|
||||
}
|
||||
|
||||
if ( new_uses !== old_uses ) {
|
||||
this.emit('uses_changed', key, new_uses, old_uses);
|
||||
this.emit(`uses_changed:${key}`, new_uses, old_uses);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Context Control
|
||||
// ========================================================================
|
||||
|
||||
context(context) {
|
||||
return new SettingsContext(this, context);
|
||||
}
|
||||
|
||||
|
||||
updateContext(context) {
|
||||
let changed = false;
|
||||
|
||||
for(const key in context)
|
||||
if ( has(context, key) && context[key] !== this._context[key] ) {
|
||||
this._context[key] = context[key];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if ( changed )
|
||||
this._rebuildContext();
|
||||
}
|
||||
|
||||
|
||||
setContext(context) {
|
||||
this._context = context;
|
||||
this._rebuildContext();
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Data Access
|
||||
// ========================================================================
|
||||
|
||||
_onChanged(key) {
|
||||
this._update(key, key, []);
|
||||
}
|
||||
|
||||
_update(key, initial, visited) {
|
||||
if ( ! this.__cache.has(key) )
|
||||
return;
|
||||
|
||||
else if ( visited.includes(key) )
|
||||
throw new Error(`cyclic dependent chain when updating setting "${initial}"`);
|
||||
|
||||
visited.push(key);
|
||||
|
||||
const old_value = this.__cache.get(key),
|
||||
old_meta = this.__meta.get(key),
|
||||
new_value = this._get(key, key, []),
|
||||
new_meta = this.__meta.get(key),
|
||||
|
||||
old_uses = old_meta ? old_meta.uses : null,
|
||||
new_uses = new_meta ? new_meta.uses : null;
|
||||
|
||||
if ( old_uses !== new_uses ) {
|
||||
this.emit('uses_changed', key, new_uses, old_uses);
|
||||
this.emit(`uses_changed:${key}`, new_uses, old_uses);
|
||||
}
|
||||
|
||||
if ( old_value === new_value )
|
||||
return;
|
||||
|
||||
this.emit('changed', key, new_value, old_value);
|
||||
this.emit(`changed:${key}`, new_value, old_value);
|
||||
|
||||
const definition = this.manager.definitions.get(key);
|
||||
if ( definition && definition.required_by )
|
||||
for(const req_key of definition.required_by)
|
||||
if ( ! req_key.startsWith('context.') )
|
||||
this._update(req_key, initial, Array.from(visited));
|
||||
}
|
||||
|
||||
|
||||
_get(key, initial, visited) {
|
||||
if ( visited.includes(key) )
|
||||
throw new Error(`cyclic dependency when resolving setting "${initial}"`);
|
||||
|
||||
visited.push(key);
|
||||
|
||||
const definition = this.manager.definitions.get(key),
|
||||
raw_value = this._getRaw(key),
|
||||
meta = {
|
||||
uses: raw_value ? raw_value[1].id : null
|
||||
};
|
||||
|
||||
let value = raw_value ? raw_value[0] : undefined;
|
||||
|
||||
if ( definition ) {
|
||||
if ( Array.isArray(definition) )
|
||||
throw new Error(`non-existent setting "${key}" required when resolving setting "${initial}"`);
|
||||
|
||||
if ( meta.uses === null ) {
|
||||
const def_default = definition.default;
|
||||
if ( typeof def_default === 'function' )
|
||||
value = def_default(this);
|
||||
else
|
||||
value = def_default;
|
||||
}
|
||||
|
||||
if ( definition.requires )
|
||||
for(const req_key of definition.requires)
|
||||
if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key) )
|
||||
this._get(req_key, initial, Array.from(visited));
|
||||
|
||||
if ( definition.process )
|
||||
value = definition.process(this, value, meta);
|
||||
}
|
||||
|
||||
this.__cache.set(key, value);
|
||||
this.__meta.set(key, meta);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
_getRaw(key) {
|
||||
for(const profile of this.__profiles)
|
||||
if ( profile.has(key) )
|
||||
return [profile.get(key), profile]
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Data Access
|
||||
// ========================================================================
|
||||
|
||||
update(key) {
|
||||
this._update(key, key, []);
|
||||
}
|
||||
|
||||
get(key) {
|
||||
if ( key.startsWith('context.') )
|
||||
return getter(key.slice(8), this.__context);
|
||||
|
||||
if ( this.__cache.has(key) )
|
||||
return this.__cache.get(key);
|
||||
|
||||
return this._get(key, key, []);
|
||||
}
|
||||
|
||||
uses(key) {
|
||||
if ( key.startsWith('context.') )
|
||||
return null;
|
||||
|
||||
if ( ! this.__meta.has(key) )
|
||||
this._get(key, key, []);
|
||||
|
||||
return this.__meta.get(key).uses;
|
||||
}
|
||||
}
|
452
src/settings/index.js
Normal file
452
src/settings/index.js
Normal file
|
@ -0,0 +1,452 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Settings System
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import {has} from 'utilities/object';
|
||||
|
||||
import {CloudStorageProvider, LocalStorageProvider} from './providers';
|
||||
import SettingsProfile from './profile';
|
||||
import SettingsContext from './context';
|
||||
import MigrationManager from './migration';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SettingsManager
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The SettingsManager module creates all the necessary class instances
|
||||
* required for the settings system to operate, facilitates communication
|
||||
* and discovery, and emits events for other modules to react to.
|
||||
* @extends Module
|
||||
*/
|
||||
export default class SettingsManager extends Module {
|
||||
/**
|
||||
* Create a SettingsManager module.
|
||||
*/
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// State
|
||||
this.__contexts = [];
|
||||
this.__profiles = [];
|
||||
this.__profile_ids = {};
|
||||
|
||||
this.ui_structures = new Map;
|
||||
this.definitions = new Map;
|
||||
|
||||
// Create our provider as early as possible.
|
||||
const provider = this.provider = this._createProvider();
|
||||
this.log.info(`Using Provider: ${provider.constructor.name}`);
|
||||
provider.on('changed', this._onProviderChange, this);
|
||||
|
||||
|
||||
this.migrations = new MigrationManager(this);
|
||||
|
||||
|
||||
// Also create the main context as early as possible.
|
||||
this.main_context = new SettingsContext(this);
|
||||
|
||||
this.main_context.on('changed', (key, new_value, old_value) => {
|
||||
this.emit(`:changed:${key}`, new_value, old_value);
|
||||
});
|
||||
|
||||
this.main_context.on('uses_changed', (key, new_uses, old_uses) => {
|
||||
this.emit(`:uses_changed:${key}`, new_uses, old_uses);
|
||||
});
|
||||
|
||||
|
||||
// Don't wait around to be required.
|
||||
this._start_time = performance.now();
|
||||
this.enable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the SettingsManager instance should be enabled.
|
||||
*/
|
||||
async onEnable() {
|
||||
// Before we do anything else, make sure the provider is ready.
|
||||
await this.provider.awaitReady();
|
||||
|
||||
// Load profiles, but don't run any events because we haven't done
|
||||
// migrations yet.
|
||||
this.loadProfiles(true);
|
||||
|
||||
// Handle migrations.
|
||||
await this.migrations.process('core');
|
||||
|
||||
// Now we can tell our context(s) about the profiles we have.
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
|
||||
const duration = performance.now() - this._start_time;
|
||||
this.log.info(`Initialization complete after ${duration.toFixed(5)}ms -- Values: ${this.provider.size} -- Profiles: ${this.__profiles.length}`)
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Provider Interaction
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Evaluate the environment that FFZ is running in and then decide which
|
||||
* provider should be used to retrieve and store settings.
|
||||
*/
|
||||
_createProvider() {
|
||||
// If the loader has reported support for cloud settings...
|
||||
if ( document.body.classList.contains('ffz-cloud-storage') )
|
||||
return new CloudStorageProvider(this);
|
||||
|
||||
// Fallback
|
||||
return new LocalStorageProvider(this);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* React to a setting that has changed elsewhere. Generally, this is
|
||||
* the result of a setting being changed in another tab or, when cloud
|
||||
* settings are enabled, on another computer.
|
||||
*/
|
||||
_onProviderChange(key, new_value, deleted) {
|
||||
// If profiles have changed, reload our profiles.
|
||||
if ( key === 'profiles' )
|
||||
return this.loadProfiles();
|
||||
|
||||
|
||||
// If we're still here, it means an individual setting was changed.
|
||||
// Look up the profile it belongs to and emit a changed event from
|
||||
// that profile, thus notifying any contexts or UI instances.
|
||||
const idx = key.indexOf(':');
|
||||
if ( idx === -1 )
|
||||
return;
|
||||
|
||||
const profile = this.__profile_ids[key.slice(0, idx)],
|
||||
s_key = key.slice(idx + 1);
|
||||
|
||||
if ( profile )
|
||||
profile.emit('changed', s_key, new_value, deleted);
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Profile Management
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get an existing {@link SettingsProfile} instance.
|
||||
* @param {number} id - The id of the profile.
|
||||
*/
|
||||
profile(id) {
|
||||
return this.__profile_ids[id] || null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build {@link SettingsProfile} instances for all of the profiles
|
||||
* defined in storage, re-using existing instances when possible.
|
||||
*/
|
||||
loadProfiles(suppress_events) {
|
||||
const old_profile_ids = this.__profile_ids,
|
||||
old_profiles = this.__profiles,
|
||||
|
||||
profile_ids = this.__profile_ids = {},
|
||||
profiles = this.__profiles = [],
|
||||
|
||||
// Create a set of actual IDs with a map from the profiles
|
||||
// list rather than just getting the keys from the ID map
|
||||
// because the ID map is an object and coerces its strings
|
||||
// to keys.
|
||||
old_ids = new Set(old_profiles.map(x => x.id));
|
||||
|
||||
let changed = false,
|
||||
moved_ids = new Set,
|
||||
new_ids = new Set,
|
||||
changed_ids = new Set;
|
||||
|
||||
const raw_profiles = this.provider.get('profiles', [
|
||||
SettingsProfile.Moderation,
|
||||
SettingsProfile.Default
|
||||
]);
|
||||
|
||||
for(const profile_data of raw_profiles) {
|
||||
const id = profile_data.id,
|
||||
old_profile = old_profile_ids[id],
|
||||
old_slot_id = parseInt(old_profiles[profiles.length] || -1, 10);
|
||||
|
||||
old_ids.delete(id);
|
||||
|
||||
if ( old_slot_id !== id ) {
|
||||
moved_ids.add(old_slot_id);
|
||||
moved_ids.add(id);
|
||||
}
|
||||
|
||||
// TODO: Better method for checking if the profile data has changed.
|
||||
if ( old_profile && JSON.stringify(old_profile.data) === JSON.stringify(profile_data) ) {
|
||||
// Did the order change?
|
||||
if ( old_profiles[profiles.length] !== old_profile )
|
||||
changed = true;
|
||||
|
||||
profiles.push(profile_ids[id] = old_profile);
|
||||
continue;
|
||||
}
|
||||
|
||||
const new_profile = profiles.push(profile_ids[id] = new SettingsProfile(this, profile_data));
|
||||
if ( old_profile ) {
|
||||
// Move all the listeners over.
|
||||
new_profile.__listeners = old_profile.__listeners;
|
||||
old_profile.__listeners = {};
|
||||
|
||||
changed_ids.add(id);
|
||||
|
||||
} else
|
||||
new_ids.add(id);
|
||||
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if ( ! changed && ! old_ids.size || suppress_events )
|
||||
return;
|
||||
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
|
||||
for(const id of new_ids)
|
||||
this.emit(':profile-created', profile_ids[id]);
|
||||
|
||||
for(const id of changed_ids)
|
||||
this.emit(':profile-changed', profile_ids[id]);
|
||||
|
||||
if ( moved_ids.size )
|
||||
this.emit(':profiles-reordered');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new profile and return the {@link SettingsProfile} instance
|
||||
* representing it.
|
||||
* @returns {SettingsProfile}
|
||||
*/
|
||||
createProfile(options) {
|
||||
let i = 0;
|
||||
while( this.__profile_ids[i] )
|
||||
i++;
|
||||
|
||||
options = options || {};
|
||||
options.id = i;
|
||||
|
||||
if ( ! options.name )
|
||||
options.name = `Unnamed Profile ${i}`;
|
||||
|
||||
const profile = this.__profile_ids[i] = new SettingsProfile(this, options);
|
||||
|
||||
this.__profiles.unshift(profile);
|
||||
|
||||
this._saveProfiles();
|
||||
this.emit(':profile-created', profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete a profile.
|
||||
* @param {number|SettingsProfile} id - The profile to delete
|
||||
*/
|
||||
deleteProfile(id) {
|
||||
if ( typeof id === 'object' && id.id )
|
||||
id = id.id;
|
||||
|
||||
const profile = this.__profile_ids[id];
|
||||
if ( ! profile )
|
||||
return;
|
||||
|
||||
if ( profile.id === 0 )
|
||||
throw new Error('cannot delete default profile');
|
||||
|
||||
profile.clear();
|
||||
this.__profile_ids[id] = null;
|
||||
|
||||
const idx = this.__profiles.indexOf(profile);
|
||||
if ( idx !== -1 )
|
||||
this.__profiles.splice(idx, 1);
|
||||
|
||||
this._saveProfiles();
|
||||
this.emit(':profile-deleted', profile);
|
||||
}
|
||||
|
||||
|
||||
moveProfile(id, index) {
|
||||
if ( typeof id === 'object' && id.id )
|
||||
id = id.id;
|
||||
|
||||
const profile = this.__profile_ids[id];
|
||||
if ( ! profile )
|
||||
return;
|
||||
|
||||
const profiles = this.__profiles,
|
||||
idx = profiles.indexOf(profile);
|
||||
if ( idx === index )
|
||||
return;
|
||||
|
||||
profiles.splice(index, 0, ...profiles.splice(idx, 1));
|
||||
|
||||
this._saveProfiles();
|
||||
this.emit(':profiles-reordered');
|
||||
}
|
||||
|
||||
|
||||
saveProfile(id) {
|
||||
if ( typeof id === 'object' && id.id )
|
||||
id = id.id;
|
||||
|
||||
const profile = this.__profile_ids[id];
|
||||
if ( ! profile )
|
||||
return;
|
||||
|
||||
this._saveProfiles();
|
||||
this.emit(':profile-changed', profile);
|
||||
}
|
||||
|
||||
|
||||
_saveProfiles() {
|
||||
this.provider.set('profiles', this.__profiles.map(prof => prof.data));
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Context Helpers
|
||||
// ========================================================================
|
||||
|
||||
context(env) { return this.main_context.context(env) }
|
||||
get(key) { return this.main_context.get(key) }
|
||||
uses(key) { return this.main_context.uses(key) }
|
||||
update(key) { return this.main_context.update(key) }
|
||||
|
||||
updateContext(context) { return this.main_context.updateContext(context) }
|
||||
setContext(context) { return this.main_context.setContext(context) }
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Definitions
|
||||
// ========================================================================
|
||||
|
||||
add(key, definition) {
|
||||
if ( typeof key === 'object' ) {
|
||||
for(const k in key)
|
||||
if ( has(key, k) )
|
||||
this.add(k, key[k]);
|
||||
return;
|
||||
}
|
||||
|
||||
const old_definition = this.definitions.get(key),
|
||||
required_by = old_definition ?
|
||||
(Array.isArray(old_definition) ? old_definition : old_definition.required_by) : [];
|
||||
|
||||
definition.required_by = required_by;
|
||||
definition.requires = definition.requires || [];
|
||||
|
||||
for(const req_key of definition.requires) {
|
||||
const req = this.definitions.get(req_key);
|
||||
if ( ! req )
|
||||
this.definitions.set(req_key, [key]);
|
||||
else if ( Array.isArray(req) )
|
||||
req.push(key);
|
||||
else
|
||||
req.required_by.push(key);
|
||||
}
|
||||
|
||||
|
||||
if ( definition.ui ) {
|
||||
const ui = definition.ui;
|
||||
ui.path_tokens = ui.path_tokens ?
|
||||
format_path_tokens(ui.path_tokens) :
|
||||
ui.path ?
|
||||
parse_path(ui.path) :
|
||||
undefined;
|
||||
|
||||
if ( ! ui.key && ui.title )
|
||||
ui.key = ui.title.toSnakeCase();
|
||||
}
|
||||
|
||||
if ( definition.changed )
|
||||
this.on(`:changed:${key}`, definition.changed);
|
||||
|
||||
this.definitions.set(key, definition);
|
||||
this.emit(':added-definition', key, definition);
|
||||
}
|
||||
|
||||
|
||||
addUI(key, definition) {
|
||||
if ( typeof key === 'object' ) {
|
||||
for(const k in key)
|
||||
if ( has(key, k) )
|
||||
this.add(k, key[k]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! definition.ui )
|
||||
definition = {ui: definition};
|
||||
|
||||
const ui = definition.ui;
|
||||
ui.path_tokens = ui.path_tokens ?
|
||||
format_path_tokens(ui.path_tokens) :
|
||||
ui.path ?
|
||||
parse_path(ui.path) :
|
||||
undefined;
|
||||
|
||||
if ( ! ui.key && ui.title )
|
||||
ui.key = ui.title.toSnakeCase();
|
||||
|
||||
this.ui_structures.set(key, definition);
|
||||
this.emit(':added-definition', key, definition);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g;
|
||||
|
||||
export function parse_path(path) {
|
||||
const tokens = [];
|
||||
let match;
|
||||
|
||||
while((match = PATH_SPLITTER.exec(path))) {
|
||||
const page = match[1] === '>>',
|
||||
tab = match[1] === '~>',
|
||||
title = match[2].trim(),
|
||||
key = title.toSnakeCase(),
|
||||
options = match[3],
|
||||
|
||||
opts = { key, title, page, tab };
|
||||
|
||||
if ( options )
|
||||
Object.assign(opts, JSON.parse(options));
|
||||
|
||||
tokens.push(opts);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
|
||||
export function format_path_tokens(tokens) {
|
||||
for(let i=0, l = tokens.length; i < l; i++) {
|
||||
const token = tokens[i];
|
||||
if ( typeof token === 'string' ) {
|
||||
tokens[i] = {
|
||||
key: token.toSnakeCase(),
|
||||
title: token
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! token.key )
|
||||
token.key = token.title.toSnakeCase();
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
16
src/settings/migration.js
Normal file
16
src/settings/migration.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Settings Migrations
|
||||
// ============================================================================
|
||||
|
||||
export default class MigrationManager {
|
||||
constructor(manager) {
|
||||
this.manager = manager;
|
||||
this.provider = manager.provider;
|
||||
}
|
||||
|
||||
process(key) {
|
||||
return false;
|
||||
}
|
||||
}
|
178
src/settings/profile.js
Normal file
178
src/settings/profile.js
Normal file
|
@ -0,0 +1,178 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Settings Profiles
|
||||
// ============================================================================
|
||||
|
||||
import {EventEmitter} from 'utilities/events';
|
||||
import {has, filter_match} from 'utilities/object';
|
||||
|
||||
|
||||
/**
|
||||
* Instances of SettingsProfile are used for getting and setting raw settings
|
||||
* values, enumeration, and emit events when the raw settings are changed.
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
export default class SettingsProfile extends EventEmitter {
|
||||
constructor(manager, data) {
|
||||
super();
|
||||
|
||||
this.manager = manager;
|
||||
this.provider = manager.provider;
|
||||
|
||||
this.data = data;
|
||||
this.prefix = `p:${this.id}:`;
|
||||
}
|
||||
|
||||
get data() {
|
||||
return {
|
||||
id: this.id,
|
||||
parent: this.parent,
|
||||
|
||||
name: this.name,
|
||||
i18n_key: this.i18n_key,
|
||||
|
||||
description: this.description,
|
||||
desc_i18n_key: this.desc_i18n_key,
|
||||
|
||||
context: this.context
|
||||
}
|
||||
}
|
||||
|
||||
set data(val) {
|
||||
if ( typeof val !== 'object' )
|
||||
throw new TypeError('data must be an object');
|
||||
|
||||
for(const key in val)
|
||||
if ( has(val, key) )
|
||||
this[key] = val[key];
|
||||
}
|
||||
|
||||
matches(context) {
|
||||
// If we don't have any specific context, then we work!
|
||||
if ( ! this.context )
|
||||
return true;
|
||||
|
||||
// If we do have context and didn't get any, then we don't!
|
||||
else if ( ! context )
|
||||
return false;
|
||||
|
||||
// Got context? Have context? One-sided deep comparison time.
|
||||
// Let's go for a walk!
|
||||
|
||||
return filter_match(this.context, context);
|
||||
}
|
||||
|
||||
|
||||
save() {
|
||||
this.manager.saveProfile(this.id);
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Context
|
||||
// ========================================================================
|
||||
|
||||
updateContext(context) {
|
||||
if ( this.id === 0 )
|
||||
throw new Error('cannot set context of default profile');
|
||||
|
||||
this.context = Object.assign(this.context || {}, context);
|
||||
this.manager._saveProfiles();
|
||||
}
|
||||
|
||||
setContext(context) {
|
||||
if ( this.id === 0 )
|
||||
throw new Error('cannot set context of default profile');
|
||||
|
||||
this.context = context;
|
||||
this.manager._saveProfiles();
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Setting Access
|
||||
// ========================================================================
|
||||
|
||||
get(key, default_value) {
|
||||
return this.provider.get(this.prefix + key, default_value);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this.provider.set(this.prefix + key, value);
|
||||
this.emit('changed', key, value);
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this.provider.delete(this.prefix + key);
|
||||
this.emit('changed', key, undefined, true);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.provider.has(this.prefix + key);
|
||||
}
|
||||
|
||||
keys() {
|
||||
const out = [],
|
||||
p = this.prefix,
|
||||
len = p.length;
|
||||
|
||||
for(const key of this.provider.keys())
|
||||
if ( key.startsWith(p) )
|
||||
out.push(key.slice(len));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
clear() {
|
||||
const p = this.prefix,
|
||||
len = p.length;
|
||||
for(const key of this.provider.keys())
|
||||
if ( key.startsWith(p) ) {
|
||||
this.provider.delete(key);
|
||||
this.emit('changed', key.slice(len), undefined, true);
|
||||
}
|
||||
}
|
||||
|
||||
*entries() {
|
||||
const p = this.prefix,
|
||||
len = p.length;
|
||||
|
||||
for(const key of this.provider.keys())
|
||||
if ( key.startsWith(p) )
|
||||
yield [key.slice(len), this.provider.get(key)];
|
||||
}
|
||||
|
||||
get size() {
|
||||
const p = this.prefix;
|
||||
let count = 0;
|
||||
|
||||
for(const key of this.provider.keys())
|
||||
if ( key.startsWith(p) )
|
||||
count++;
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SettingsProfile.Default = {
|
||||
id: 0,
|
||||
name: 'Default Profile',
|
||||
i18n_key: 'setting.profiles.default',
|
||||
|
||||
description: 'Settings that apply everywhere on Twitch.'
|
||||
}
|
||||
|
||||
|
||||
SettingsProfile.Moderation = {
|
||||
id: 1,
|
||||
name: 'Moderation',
|
||||
i18n_key: 'setting.profiles.moderation',
|
||||
|
||||
description: 'Settings that apply when you are a moderator of the current channel.',
|
||||
|
||||
context: {
|
||||
moderator: true
|
||||
}
|
||||
}
|
301
src/settings/providers.js
Normal file
301
src/settings/providers.js
Normal file
|
@ -0,0 +1,301 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Settings Providers
|
||||
// ============================================================================
|
||||
|
||||
import {EventEmitter} from 'utilities/events';
|
||||
import {has} from 'utilities/object';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SettingsProvider
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base class for providers for the settings system. A provider is in charge
|
||||
* of reading and writing values from storage as well as sending events to
|
||||
* the {@link SettingsManager} when a value is changed remotely.
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
export class SettingsProvider extends EventEmitter {
|
||||
/**
|
||||
* Create a new SettingsProvider
|
||||
* @param {SettingsManager} manager - The manager that owns this provider.
|
||||
*/
|
||||
constructor(manager) {
|
||||
super();
|
||||
|
||||
this.manager = manager;
|
||||
this.disabled = false;
|
||||
}
|
||||
|
||||
awaitReady() {
|
||||
if ( this.ready )
|
||||
return Promise.resolve();
|
||||
|
||||
return Promise.reject(new Error('Not Implemented'));
|
||||
}
|
||||
|
||||
get(key, default_value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
|
||||
set(key, value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
|
||||
delete(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
|
||||
clear() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
|
||||
|
||||
has(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars
|
||||
|
||||
keys() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
|
||||
entries() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
|
||||
get size() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// LocalStorage
|
||||
// ============================================================================
|
||||
|
||||
export class LocalStorageProvider extends SettingsProvider {
|
||||
constructor(manager, prefix) {
|
||||
super(manager);
|
||||
this.prefix = prefix = prefix == null ? 'FFZ:setting:' : prefix;
|
||||
|
||||
const cache = this._cached = new Map,
|
||||
len = prefix.length;
|
||||
|
||||
for(const key in localStorage)
|
||||
if ( has(localStorage, key) && key.startsWith(prefix) ) {
|
||||
const val = localStorage.getItem(key);
|
||||
try {
|
||||
cache.set(key.slice(len), JSON.parse(val));
|
||||
} catch(err) {
|
||||
this.manager.log.warn(`unable to parse value for ${key}`, val);
|
||||
}
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
|
||||
this._boundHandleStorage = this.handleStorage.bind(this);
|
||||
window.addEventListener('storage', this._boundHandleStorage);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disable();
|
||||
this._cached.clear();
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.disabled = true;
|
||||
|
||||
if ( this._boundHandleStorage ) {
|
||||
window.removeEventListener('storage', this._boundHandleStorage);
|
||||
this._boundHandleStorage = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
handleStorage(event) {
|
||||
if ( this.disabled )
|
||||
return;
|
||||
|
||||
this.manager.log.debug('storage event', event);
|
||||
if ( event.storageArea !== localStorage )
|
||||
return;
|
||||
|
||||
if ( event.key.startsWith(this.prefix) ) {
|
||||
// If value is null, the key was deleted.
|
||||
const key = event.key.slice(this.prefix.length);
|
||||
let val = event.newValue;
|
||||
|
||||
if ( val === null ) {
|
||||
this._cached.delete(key);
|
||||
this.emit('changed', key, undefined, true);
|
||||
|
||||
} else {
|
||||
val = JSON.parse(val);
|
||||
this._cached.set(key, val);
|
||||
this.emit('changed', key, val, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
get(key, default_value) {
|
||||
return this._cached.has(key) ?
|
||||
this._cached.get(key) :
|
||||
default_value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this._cached.set(key, value);
|
||||
localStorage.setItem(this.prefix + key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this._cached.delete(key);
|
||||
localStorage.removeItem(this.prefix + key);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this._cached.has(key);
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this._cached.keys();
|
||||
}
|
||||
|
||||
clear() {
|
||||
for(const key of this._cached.keys())
|
||||
localStorage.removeItem(this.prefix + key);
|
||||
|
||||
this._cached.clear();
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this._cached.entries();
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._cached.size;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class CloudStorageProvider extends SettingsProvider {
|
||||
constructor(manager) {
|
||||
super(manager);
|
||||
|
||||
this._cached = new Map;
|
||||
this.ready = false;
|
||||
this._ready_wait = null;
|
||||
|
||||
this._boundHandleStorage = this.handleStorage.bind(this);
|
||||
window.addEventListener('message', this._boundHandleStorage);
|
||||
this._send('get_all');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disable();
|
||||
this._cached.clear();
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.disabled = true;
|
||||
|
||||
if ( this._boundHandleStorage ) {
|
||||
window.removeEventListener('message', this._boundHandleStorage)
|
||||
this._boundHandleStorage = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
awaitReady() {
|
||||
if ( this.ready )
|
||||
return Promise.resolve();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const waiters = this._ready_wait = this._ready_wait || [];
|
||||
waiters.push([resolve, reject]);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Communication
|
||||
// ========================================================================
|
||||
|
||||
handleStorage(event) {
|
||||
if ( event.source !== window || ! event.data || ! event.data.ffz )
|
||||
return;
|
||||
|
||||
const cmd = event.data.cmd,
|
||||
data = event.data.data;
|
||||
|
||||
if ( cmd === 'all_values' ) {
|
||||
const old_keys = new Set(this._cached.keys());
|
||||
|
||||
for(const key in data)
|
||||
if ( has(data, key) ) {
|
||||
const val = data[key];
|
||||
old_keys.delete(key);
|
||||
this._cached.set(key, val);
|
||||
if ( this.ready )
|
||||
this.emit('changed', key, val);
|
||||
}
|
||||
|
||||
for(const key of old_keys) {
|
||||
this._cached.delete(key);
|
||||
if ( this.ready )
|
||||
this.emit('changed', key, undefined, true);
|
||||
}
|
||||
|
||||
this.ready = true;
|
||||
if ( this._ready_wait ) {
|
||||
for(const resolve of this._ready_wait)
|
||||
resolve();
|
||||
this._ready_wait = null;
|
||||
}
|
||||
|
||||
} else if ( cmd === 'changed' ) {
|
||||
this._cached.set(data.key, data.value);
|
||||
this.emit('changed', data.key, data.value);
|
||||
|
||||
} else if ( cmd === 'deleted' ) {
|
||||
this._cached.delete(data);
|
||||
this.emit('changed', data, undefined, true);
|
||||
|
||||
} else {
|
||||
this.manager.log.info('unknown storage event', event);
|
||||
}
|
||||
}
|
||||
|
||||
_send(cmd, data) { // eslint-disable-line class-methods-use-this
|
||||
window.postMessage({
|
||||
ffz: true,
|
||||
cmd,
|
||||
data
|
||||
}, location.origin);
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Data Access
|
||||
// ========================================================================
|
||||
|
||||
get(key, default_value) {
|
||||
return this._cached.has(key) ?
|
||||
this._cached.get(key) :
|
||||
default_value;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
this._cached.set(key, value);
|
||||
this._send('set', {key, value});
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
this._cached.delete(key);
|
||||
this._send('delete', key);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this._cached.has(key);
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this._cached.keys();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._cached.clear();
|
||||
this._send('clear');
|
||||
}
|
||||
|
||||
entries() {
|
||||
return this._cached.entries();
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._cached.size;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue