// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { CoreApp } from '@services/app'; import { CoreCronDelegate } from '@services/cron'; import { CoreEvents } from '@singletons/events'; import { CoreFilepool } from '@services/filepool'; import { CoreSite } from '@classes/site'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreConstants } from '@/core/constants'; import { CoreConfig } from '@services/config'; import { CoreFilter } from '@features/filter/services/filter'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreCourse } from '@features/course/services/course'; import { makeSingleton, Translate } from '@singletons'; import { CoreError } from '@classes/errors/error'; /** * Object with space usage and cache entries that can be erased. */ export interface CoreSiteSpaceUsage { cacheEntries: number; // Number of cached entries that can be cleared. spaceUsage: number; // Space used in this site (total files + estimate of cache). } /** * Constants to define color schemes. */ export const enum CoreColorScheme { AUTO = 'auto', LIGHT = 'light', DARK = 'dark', } /** * Settings helper service. */ @Injectable({ providedIn: 'root' }) export class CoreSettingsHelperProvider { protected syncPromises: { [s: string]: Promise } = {}; protected prefersDark?: MediaQueryList; constructor() { if (!CoreConstants.CONFIG.forceColorScheme) { // Update color scheme when a user enters or leaves a site, or when the site info is updated. const applySiteScheme = (): void => { if (this.isColorSchemeDisabledInSite()) { // Dark mode is disabled, force light mode. this.setColorScheme(CoreColorScheme.LIGHT); } else { this.prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); // Reset color scheme settings. this.initColorScheme(); } }; CoreEvents.on(CoreEvents.LOGIN, applySiteScheme.bind(this)); CoreEvents.on(CoreEvents.SITE_UPDATED, applySiteScheme.bind(this)); CoreEvents.on(CoreEvents.LOGOUT, () => { // Reset color scheme settings. this.initColorScheme(); }); } } /** * Deletes files of a site and the tables that can be cleared. * * @param siteName Site Name. * @param siteId: Site ID. * @return Resolved with detailed new info when done. */ async deleteSiteStorage(siteName: string, siteId: string): Promise { const siteInfo: CoreSiteSpaceUsage = { cacheEntries: 0, spaceUsage: 0, }; siteName = await CoreFilter.instance.formatText(siteName, { clean: true, singleLine: true, filter: false }, [], siteId); const title = Translate.instance.instant('core.settings.deletesitefilestitle'); const message = Translate.instance.instant('core.settings.deletesitefiles', { sitename: siteName }); await CoreDomUtils.instance.showConfirm(message, title); const site = await CoreSites.instance.getSite(siteId); // Clear cache tables. const cleanSchemas = CoreSites.instance.getSiteTableSchemasToClear(site); const promises: Promise[] = cleanSchemas.map((name) => site.getDb().deleteRecords(name)); const filepoolService = CoreFilepool.instance; promises.push(site.deleteFolder().then(() => { filepoolService.clearAllPackagesStatus(siteId); filepoolService.clearFilepool(siteId); CoreCourse.instance.clearAllCoursesStatus(siteId); siteInfo.spaceUsage = 0; return; }).catch(async (error) => { if (error && error.code === FileError.NOT_FOUND_ERR) { // Not found, set size 0. filepoolService.clearAllPackagesStatus(siteId); siteInfo.spaceUsage = 0; } else { // Error, recalculate the site usage. CoreDomUtils.instance.showErrorModal('core.settings.errordeletesitefiles', true); siteInfo.spaceUsage = await site.getSpaceUsage(); } }).then(async () => { CoreEvents.trigger(CoreEvents.SITE_STORAGE_DELETED, {}, siteId); siteInfo.cacheEntries = await this.calcSiteClearRows(site); return; })); await Promise.all(promises); return siteInfo; } /** * Calculates each site's usage, and the total usage. * * @param siteId ID of the site. Current site if undefined. * @return Resolved with detailed info when done. */ async getSiteSpaceUsage(siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); // Get space usage. const siteInfo: CoreSiteSpaceUsage = { cacheEntries: 0, spaceUsage: 0, }; siteInfo.cacheEntries = await this.calcSiteClearRows(site); siteInfo.spaceUsage = await site.getTotalUsage(); return siteInfo; } /** * Calculate the number of rows to be deleted on a site. * * @param site Site object. * @return If there are rows to delete or not. */ protected async calcSiteClearRows(site: CoreSite): Promise { const clearTables = CoreSites.instance.getSiteTableSchemasToClear(site); let totalEntries = 0; await Promise.all(clearTables.map(async (name) => totalEntries = await site.getDb().countRecords(name) + totalEntries)); return totalEntries; } /** * Get a certain processor from a list of processors. * * @param processors List of processors. * @param name Name of the processor to get. * @param fallback True to return first processor if not found, false to not return any. Defaults to true. * @return Processor. * @deprecated since 3.9.5. This function has been moved to AddonNotificationsHelperProvider. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getProcessor(processors: unknown[], name: string, fallback: boolean = true): undefined { return; } /** * Return the components and notifications that have a certain processor. * * @param processorName Name of the processor to filter. * @param components Array of components. * @return Filtered components. * @deprecated since 3.9.5. This function has been moved to AddonNotificationsHelperProvider. */ getProcessorComponents(processorName: string, components: unknown[]): unknown[] { return components; } /** * Get the synchronization promise of a site. * * @param siteId ID of the site. * @return Sync promise or null if site is not being syncrhonized. */ getSiteSyncPromise(siteId: string): Promise | void { if (this.syncPromises[siteId]) { return this.syncPromises[siteId]; } } /** * Synchronize a site. * * @param syncOnlyOnWifi True to sync only on wifi, false otherwise. * @param siteId ID of the site to synchronize. * @return Promise resolved when synchronized, rejected if failure. */ async synchronizeSite(syncOnlyOnWifi: boolean, siteId: string): Promise { if (this.syncPromises[siteId]) { // There's already a sync ongoing for this site, return the promise. return this.syncPromises[siteId]; } const site = await CoreSites.instance.getSite(siteId); const hasSyncHandlers = CoreCronDelegate.instance.hasManualSyncHandlers(); if (site.isLoggedOut()) { // Cannot sync logged out sites. throw new CoreError(Translate.instance.instant('core.settings.cannotsyncloggedout')); } else if (hasSyncHandlers && !CoreApp.instance.isOnline()) { // We need connection to execute sync. throw new CoreError(Translate.instance.instant('core.settings.cannotsyncoffline')); } else if (hasSyncHandlers && syncOnlyOnWifi && CoreApp.instance.isNetworkAccessLimited()) { throw new CoreError(Translate.instance.instant('core.settings.cannotsyncwithoutwifi')); } const syncPromise = Promise.all([ // Invalidate all the site files so they are re-downloaded. CoreUtils.instance.ignoreErrors(CoreFilepool.instance.invalidateAllFiles(siteId)), // Invalidate and synchronize site data. site.invalidateWsCache(), this.checkSiteLocalMobile(site), CoreSites.instance.updateSiteInfo(site.getId()), CoreCronDelegate.instance.forceSyncExecution(site.getId()), // eslint-disable-next-line arrow-body-style ]).then(() => { return; }); this.syncPromises[siteId] = syncPromise; try { await syncPromise; } finally { delete this.syncPromises[siteId]; } } /** * Check if local_mobile was added to the site. * * @param site Site to check. * @return Promise resolved if no action needed. */ protected async checkSiteLocalMobile(site: CoreSite): Promise { try { // Check if local_mobile was installed in Moodle. await site.checkIfLocalMobileInstalledAndNotUsed(); } catch { // Not added, nothing to do. return; } // Local mobile was added. Throw invalid session to force reconnect and create a new token. CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, site.getId()); throw new CoreError(Translate.instance.instant('core.lostconnection')); } /** * Init Settings related to DOM. */ async initDomSettings(): Promise { // Set the font size based on user preference. const fontSize = await CoreConfig.instance.get(CoreConstants.SETTINGS_FONT_SIZE, CoreConstants.CONFIG.font_sizes[0]); this.setFontSize(fontSize); this.initColorScheme(); } /** * Init the color scheme. */ async initColorScheme(): Promise { if (CoreConstants.CONFIG.forceColorScheme) { this.setColorScheme(CoreConstants.CONFIG.forceColorScheme); } else { const scheme = await CoreConfig.instance.get(CoreConstants.SETTINGS_COLOR_SCHEME, CoreColorScheme.LIGHT); this.setColorScheme(scheme); } } /** * Check if color scheme is disabled in a site. * * @param siteId Site ID. If not defined, current site. * @return Promise resolved with whether color scheme is disabled. */ async isColorSchemeDisabled(siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); return this.isColorSchemeDisabledInSite(site); } /** * Check if color scheme is disabled in a site. * * @param site Site instance. If not defined, current site. * @return Whether color scheme is disabled. */ isColorSchemeDisabledInSite(site?: CoreSite): boolean { site = site || CoreSites.instance.getCurrentSite(); return site ? site.isFeatureDisabled('NoDelegate_DarkMode') : false; } /** * Set document default font size. * * @param fontSize Font size in percentage. */ setFontSize(fontSize: number): void { // @todo Since zoom is deprecated and fontSize is not working, we should do some research here. document.documentElement.style.zoom = fontSize + '%'; } /** * Set body color scheme. * * @param colorScheme Name of the color scheme. */ setColorScheme(colorScheme: CoreColorScheme): void { if (colorScheme == CoreColorScheme.AUTO && this.prefersDark) { // Listen for changes to the prefers-color-scheme media query. this.prefersDark.addEventListener('change', this.toggleDarkModeListener); this.toggleDarkMode(this.prefersDark.matches); } else { // Stop listening to changes. this.prefersDark?.removeEventListener('change', this.toggleDarkModeListener); this.toggleDarkMode(colorScheme == CoreColorScheme.DARK); } } /** * Listener function to toggle dark mode. * * @param e Event object. */ protected toggleDarkModeListener = (e: MediaQueryListEvent): void => { document.body.classList.toggle('dark', e.matches); }; /** * Toggles dark mode based on enabled boolean. * * @param enable True to enable dark mode, false to disable. */ protected toggleDarkMode(enable: boolean = false): void { document.body.classList.toggle('dark', enable); } } export class CoreSettingsHelper extends makeSingleton(CoreSettingsHelperProvider) {}