/* * Copyright (C) 2019, 2020 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. * * 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 . */ import {Injectable} from '@angular/core'; import {SCSetting, SCSettingValue, SCSettingValues} from '@openstapps/core'; import deepMerge from 'deepmerge'; import {Subject} from 'rxjs'; import {ConfigProvider} from '../config/config.provider'; import {StorageProvider} from '../storage/storage.provider'; export const STORAGE_KEY_SETTINGS = 'settings'; export const STORAGE_KEY_SETTINGS_SEPARATOR = '.'; export const STORAGE_KEY_SETTING_VALUES = `${STORAGE_KEY_SETTINGS}${STORAGE_KEY_SETTINGS_SEPARATOR}values`; /** * Category structure of settings cache */ export interface CategoryWithSettings { /** * Category name */ category: string; /** * Settings that belong in this category */ settings: {[key: string]: SCSetting}; } /** * Structure of SettingsCache */ export interface SettingsCache { [key: string]: CategoryWithSettings; } /** * Structure with categories and its setting valueContainers for persistence */ export interface SettingValuesContainer { [key: string]: SettingValueContainer; } /** * Structure of a setting and its value */ export interface SettingValueContainer { [key: string]: SCSettingValue | SCSettingValue[] | undefined; } /** * Structure of the settings events */ export interface SettingsAction { /** * Data related to the action */ payload?: { /** * Setting category */ category: string; /** * Setting name */ name: string; /** * Setting value */ value: SCSettingValue | SCSettingValues; }; /** * Type of the settings action */ type: string; } /** * Provider for app settings */ @Injectable() export class SettingsProvider { /** * Source of settings actions */ private settingsActionSource = new Subject(); /** * Order of the setting categories */ categoriesOrder: string[]; /** * Settings actions observable */ settingsActionChanged$ = this.settingsActionSource.asObservable(); /** * Cache for the imported settings */ settingsCache: SettingsCache; /** * Return true if all given values are valid to possible values in given settingInput * * @param possibleValues Possible values * @param enteredValues Entered value */ public static checkMultipleChoiceValue( possibleValues: SCSettingValues | undefined, enteredValues: SCSettingValues, ): boolean { if (typeof possibleValues === 'undefined') { return false; } for (const value of enteredValues) { if (!possibleValues.includes(value)) { return false; } } return true; } /** * Returns true if given value is valid to possible values in given settingInput * * @param possibleValues Possible values * @param enteredValue Entered value */ public static checkSingleChoiceValue( possibleValues: SCSettingValues | undefined, enteredValue: SCSettingValue, ): boolean { if (typeof possibleValues === 'undefined') { return false; } return ( possibleValues !== undefined && Array.isArray(possibleValues) && possibleValues.includes(enteredValue) ); } /** * Validates value for given settings inputType. Returns true if value is valid. * * @param setting setting to check value against * @param value value to validate */ public static validateValue( setting: SCSetting, value: SCSettingValue | SCSettingValues, ): boolean { let isValueValid = false; switch (setting.inputType) { case 'number': if (typeof value === 'number') { isValueValid = true; } break; case 'multiple choice': isValueValid = !Array.isArray(value) ? false : SettingsProvider.checkMultipleChoiceValue(setting.values, value); break; case 'password': case 'text': if (typeof value === 'string') { isValueValid = true; } break; case 'single choice': isValueValid = Array.isArray(value) ? false : SettingsProvider.checkSingleChoiceValue(setting.values, value); break; default: } return isValueValid; } /** * * @param storage TODO * @param configProvider TODO */ constructor( private readonly storage: StorageProvider, private readonly configProvider: ConfigProvider, ) { this.categoriesOrder = []; this.settingsCache = {}; } /** * Add an Setting to the Cache if not exist and set undefined value to defaultValue * * @param setting Setting with categories, defaultValue, name, input type and valid values */ private addSetting(setting: SCSetting): void { if (!this.categoryExists(setting.categories[0])) { this.provideCategory(setting.categories[0]); } if (!this.settingExists(setting.categories[0], setting.name)) { if (setting.value === undefined) { setting.value = setting.defaultValue; } this.settingsCache[setting.categories[0]].settings[setting.name] = setting; } } /** * Returns all setting values from settingsCache in a SettingsValueContainer */ private getSettingValuesFromCache(): SettingValuesContainer { const settingValuesContainer: SettingValuesContainer = {}; // iterate through keys of categories for (const categoryKey of Object.keys(this.settingsCache)) { // iterate through keys of settingValueContainer for (const settingKey of Object.keys( this.settingsCache[categoryKey].settings, )) { if (typeof settingValuesContainer[categoryKey] === 'undefined') { settingValuesContainer[categoryKey] = {}; } settingValuesContainer[categoryKey][settingKey] = this.settingsCache[categoryKey].settings[settingKey].value; } } return settingValuesContainer; } /** * Add category if not exists * * @param category the category to provide */ private provideCategory(category: string): void { if (!this.categoryExists(category)) { if (!this.categoriesOrder.includes(category)) { this.categoriesOrder.push(category); } this.settingsCache[category] = { category: category, settings: {}, }; } } /** * Returns true if category exists * * @param category Category key name */ public categoryExists(category: string): boolean { return this.settingsCache[category] !== undefined; } /** * Returns copy of cached settings */ public async getCache(): Promise { await this.init(); return JSON.parse(JSON.stringify(this.settingsCache)); } /** * Returns an array with the order of categories */ public getCategoriesOrder(): string[] { return this.categoriesOrder; } /** * Returns copy of a setting if exist * * @param category the category of requested setting * @param name the name of requested setting * @throws Exception if setting is not provided */ public async getSetting(category: string, name: string): Promise { await this.init(); if (this.settingExists(category, name)) { // return a copy of the settings return JSON.parse( JSON.stringify(this.settingsCache[category].settings[name]), ); } throw new Error(`Setting "${name}" not provided`); } /** * Returns copy of a settings value if exist * * @param category the category of requested setting * @param name the name of requested setting * @throws Exception if setting is not provided */ public async getValue( category: string, name: string, ): Promise { await this.init(); if (this.settingExists(category, name)) { // return a copy of the settings value return JSON.parse( JSON.stringify(this.settingsCache[category].settings[name].value), ); } throw new Error(`Setting "${name}" not provided`); } /** * Initializes settings from config and stored values if exist */ public async init(): Promise { try { const settings: SCSetting[] = (await this.configProvider.getValue( 'settings', )) as SCSetting[]; for (const setting of settings) this.addSetting(setting); for (const category of Object.keys(this.settingsCache)) { if (!this.categoriesOrder.includes(category)) { this.categoriesOrder.push(category); } } } catch { this.settingsCache = {}; } if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) { // get setting values from StorageProvider into settingsCache const valuesContainer: SettingValuesContainer = await this.storage.get( STORAGE_KEY_SETTING_VALUES, ); // iterate through keys of categories for (const categoryKey of Object.keys(this.settingsCache)) { // iterate through setting keys of category for (const settingKey of Object.keys( this.settingsCache[categoryKey].settings, )) { // if saved setting value exists set it, otherwise set to default value if ( typeof valuesContainer[categoryKey] !== 'undefined' && typeof valuesContainer[categoryKey][settingKey] !== 'undefined' ) { this.settingsCache[categoryKey].settings[settingKey].value = valuesContainer[categoryKey][settingKey]; } else { this.settingsCache[categoryKey].settings[settingKey].value = this.settingsCache[categoryKey].settings[settingKey].defaultValue; } } } await this.saveSettingValues(); } } /** * Adds given setting and its category if not exist * * @param setting the setting to add */ public provideSetting(setting: SCSetting): void { this.addSetting(setting); } /** * Deletes saved values and reinitialising the settings */ public async reset(): Promise { await this.storage.put(STORAGE_KEY_SETTING_VALUES, {}); await this.init(); } /** * Sets values of all settings to defaultValue */ async resetDefault(): Promise { for (const catKey of Object.keys(this.settingsCache)) { for (const settingKey of Object.keys( this.settingsCache[catKey].settings, )) { const settingInput = this.settingsCache[catKey].settings[settingKey]; settingInput.value = settingInput.defaultValue; } } await this.saveSettingValues(); } /** * Saves cached settings in app storage */ public async saveSettingValues(): Promise { if (await this.storage.has(STORAGE_KEY_SETTING_VALUES)) { const savedSettingsValues: SettingValuesContainer = await this.storage.get( STORAGE_KEY_SETTING_VALUES, ); const cacheSettingsValues = this.getSettingValuesFromCache(); const mergedSettingValues = deepMerge( savedSettingsValues, cacheSettingsValues, ); await this.storage.put( STORAGE_KEY_SETTING_VALUES, mergedSettingValues, ); } else { await this.storage.put( STORAGE_KEY_SETTING_VALUES, this.getSettingValuesFromCache(), ); } } /** * Sets the order the given categories show up in the settings page * * @param categoryNames the order of the categories */ public setCategoriesOrder(categoryNames: string[]) { this.categoriesOrder = categoryNames; } /** * Sets a valid value of a setting and persists changes in storage. Also the changes get published bey Events * * @param category Category key name * @param name Setting key name * @param value Value to be set * @throws Exception if setting is not provided or value not valid to the settings inputType */ public async setSettingValue( category: string, name: string, value: SCSettingValue | SCSettingValues, ): Promise { await this.init(); if (this.settingExists(category, name)) { const setting: SCSetting = this.settingsCache[category].settings[name]; const isValueValid = SettingsProvider.validateValue(setting, value); if (isValueValid) { // set and persist new value this.settingsCache[category].settings[name].value = value; await this.saveSettingValues(); // publish setting changes this.settingsActionSource.next({ type: 'stapps.settings.changed', payload: {category, name, value}, }); } else { throw new Error(`Value "${value}" of type ${typeof value} is not valid for "${setting.inputType}" of "${category}.${name}". Ommiting change`); } } else { throw new Error(`Setting "${name}" doesn't exisits within "${category}"`); } } /** * Returns true if setting in category exists * * @param category Category key name * @param setting Setting key name */ public settingExists(category: string, setting: string): boolean { return ( this.categoryExists(category) && this.settingsCache[category].settings[setting] !== undefined ); } }