// This file is part of Moodle - http://moodle.org/
//
// Moodle 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, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
import Config from 'core/config';
import {getString} from 'core/str';
import {Reactive} from 'core/reactive';
import notification from 'core/notification';
import Exporter from 'core_courseformat/local/courseeditor/exporter';
import log from 'core/log';
import ajax from 'core/ajax';
import * as Storage from 'core/sessionstorage';
import {uploadFilesToCourse} from 'core_courseformat/local/courseeditor/fileuploader';
/**
* Main course editor module.
*
* All formats can register new components on this object to create new reactive
* UI components that watch the current course state.
*
* @module core_courseformat/local/courseeditor/courseeditor
* @class core_courseformat/local/courseeditor/courseeditor
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class extends Reactive {
/**
* The current state cache key
*
* The state cache is considered dirty if the state changes from the last page or
* if the page has editing mode on.
*
* @attribute stateKey
* @type number|null
* @default 1
* @package
*/
stateKey = 1;
/**
* The current page section return
* @attribute sectionReturn
* @type number
* @default null
*/
sectionReturn = null;
/**
* Set up the course editor when the page is ready.
*
* The course can only be loaded once per instance. Otherwise an error is thrown.
*
* The backend can inform the module of the current state key. This key changes every time some
* update in the course affect the current user state. Some examples are:
* - The course content has been edited
* - The user marks some activity as completed
* - The user collapses or uncollapses a section (it is stored as a user preference)
*
* @param {number} courseId course id
* @param {string} serverStateKey the current backend course cache reference
*/
async loadCourse(courseId, serverStateKey) {
if (this.courseId) {
throw new Error(`Cannot load ${courseId}, course already loaded with id ${this.courseId}`);
}
if (!serverStateKey) {
// The server state key is not provided, we use a invalid statekey to force reloading.
serverStateKey = `invalidStateKey_${Date.now()}`;
}
// Default view format setup.
this._editing = false;
this._supportscomponents = false;
this._fileHandlers = null;
this.courseId = courseId;
let stateData;
const storeStateKey = Storage.get(`course/${courseId}/stateKey`);
try {
// Check if the backend state key is the same we have in our session storage.
if (!this.isEditing && serverStateKey == storeStateKey) {
stateData = JSON.parse(Storage.get(`course/${courseId}/staticState`));
}
if (!stateData) {
stateData = await this.getServerCourseState();
}
} catch (error) {
log.error("EXCEPTION RAISED WHILE INIT COURSE EDITOR");
log.error(error);
return;
}
// The bulk editing only applies to the frontend and the state data is not created in the backend.
stateData.bulk = {
enabled: false,
selectedType: '',
selection: [],
};
this.setInitialState(stateData);
// In editing mode, the session cache is considered dirty always.
if (this.isEditing) {
this.stateKey = null;
} else {
// Check if the last state is the same as the cached one.
const newState = JSON.stringify(stateData);
const previousState = Storage.get(`course/${courseId}/staticState`);
if (previousState !== newState || storeStateKey !== serverStateKey) {
Storage.set(`course/${courseId}/staticState`, newState);
Storage.set(`course/${courseId}/stateKey`, stateData?.course?.statekey ?? serverStateKey);
}
this.stateKey = Storage.get(`course/${courseId}/stateKey`);
}
this._loadFileHandlers();
this._pageAnchorCmInfo = this._scanPageAnchorCmInfo();
}
/**
* Load the file hanlders promise.
*/
_loadFileHandlers() {
// Load the course file extensions.
this._fileHandlersPromise = new Promise((resolve) => {
if (!this.isEditing) {
resolve([]);
return;
}
// Check the cache.
const handlersCacheKey = `course/${this.courseId}/fileHandlers`;
const cacheValue = Storage.get(handlersCacheKey);
if (cacheValue) {
try {
const cachedHandlers = JSON.parse(cacheValue);
resolve(cachedHandlers);
return;
} catch (error) {
log.error("ERROR PARSING CACHED FILE HANDLERS");
}
}
// Call file handlers webservice.
ajax.call([{
methodname: 'core_courseformat_file_handlers',
args: {
courseid: this.courseId,
}
}])[0].then((handlers) => {
Storage.set(handlersCacheKey, JSON.stringify(handlers));
resolve(handlers);
return;
}).catch(error => {
log.error(error);
resolve([]);
return;
});
});
}
/**
* Setup the current view settings
*
* @param {Object} setup format, page and course settings
* @param {boolean} setup.editing if the page is in edit mode
* @param {boolean} setup.supportscomponents if the format supports components for content
* @param {string} setup.cacherev the backend cached state revision
* @param {Array} setup.overriddenStrings optional overridden strings
*/
setViewFormat(setup) {
this._editing = setup.editing ?? false;
this._supportscomponents = setup.supportscomponents ?? false;
const overriddenStrings = setup.overriddenStrings ?? [];
this._overriddenStrings = overriddenStrings.reduce(
(indexed, currentValue) => indexed.set(currentValue.key, currentValue),
new Map()
);
}
/**
* Execute a get string for a possible format overriden editor string.
*
* Return the proper getString promise for an editor string using the core_courseformat
* of the format_PLUGINNAME compoment depending on the current view format setup.
* @param {String} key the string key
* @param {string|undefined} param The param for variable expansion in the string.
* @returns {Promise<String>} a getString promise
*/
getFormatString(key, param) {
if (this._overriddenStrings.has(key)) {
const override = this._overriddenStrings.get(key);
return getString(key, override.component ?? 'core_courseformat', param);
}
// All format overridable strings are from core_courseformat lang file.
return getString(key, 'core_courseformat', param);
}
/**
* Load the current course state from the server.
*
* @returns {Object} the current course state
*/
async getServerCourseState() {
// Only logged users can get the course state. Filtering here will prevent unnecessary
// calls to the server and login page redirects. Especially for home activities with
// guest access.
if (Config.userId == 0) {
return {
course: {},
section: [],
cm: [],
};
}
const courseState = await ajax.call([{
methodname: 'core_courseformat_get_state',
args: {
courseid: this.courseId,
}
}])[0];
const stateData = JSON.parse(courseState);
return {
course: {},
section: [],
cm: [],
...stateData,
};
}
/**
* Return the current edit mode.
*
* Components should use this method to check if edit mode is active.
*
* @return {boolean} if edit is enabled
*/
get isEditing() {
return this._editing ?? false;
}
/**
* Return a data exporter to transform state part into mustache contexts.
*
* @return {Exporter} the exporter class
*/
getExporter() {
return new Exporter(this);
}
/**
* Return if the current course support components to refresh the content.
*
* @returns {boolean} if the current content support components
*/
get supportComponents() {
return this._supportscomponents ?? false;
}
/**
* Return the course file handlers promise.
* @returns {Promise} the promise for file handlers.
*/
async getFileHandlersPromise() {
return this._fileHandlersPromise ?? [];
}
/**
* Upload a file list to the course.
*
* This method is a wrapper to the course file uploader.
*
* @param {number} sectionId the section id
* @param {number} sectionNum the section number
* @param {Array} files and array of files
* @return {Promise} the file queue promise
*/
uploadFiles(sectionId, sectionNum, files) {
return uploadFilesToCourse(this.courseId, sectionId, sectionNum, files);
}
/**
* Get a value from the course editor static storage if any.
*
* The course editor static storage uses the sessionStorage to store values from the
* components. This is used to prevent unnecesary template loadings on every page. However,
* the storage does not work if no sessionStorage can be used (in debug mode for example),
* if the page is in editing mode or if the initial state change from the last page.
*
* @param {string} key the key to get
* @return {boolean|string} the storage value or false if cannot be loaded
*/
getStorageValue(key) {
if (this.isEditing || !this.stateKey) {
return false;
}
const dataJson = Storage.get(`course/${this.courseId}/${key}`);
if (!dataJson) {
return false;
}
// Check the stateKey.
try {
const data = JSON.parse(dataJson);
if (data?.stateKey !== this.stateKey) {
return false;
}
return data.value;
} catch (error) {
return false;
}
}
/**
* Stores a value into the course editor static storage if available
*
* @param {String} key the key to store
* @param {*} value the value to store (must be compatible with JSON,stringify)
* @returns {boolean} true if the value is stored
*/
setStorageValue(key, value) {
// Values cannot be stored on edit mode.
if (this.isEditing) {
return false;
}
const data = {
stateKey: this.stateKey,
value,
};
return Storage.set(`course/${this.courseId}/${key}`, JSON.stringify(data));
}
/**
* Convert a file dragging event into a proper dragging file list.
* @param {DataTransfer} dataTransfer the event to convert
* @return {Array} of file list info.
*/
getFilesDraggableData(dataTransfer) {
const exporter = this.getExporter();
return exporter.fileDraggableData(this.state, dataTransfer);
}
/**
* Dispatch a change in the state.
*
* Usually reactive modules throw an error directly to the components when something
* goes wrong. However, course editor can directly display a notification.
*
* @method dispatch
* @param {mixed} args any number of params the mutation needs.
*/
async dispatch(...args) {
try {
await super.dispatch(...args);
} catch (error) {
// Display error modal.
notification.exception(error);
// Force unlock all elements.
super.dispatch('unlockAll');
}
}
/**
* Calculate the cm info from the current page anchor.
*
* @returns {Object|null} the cm info or null if not found.
*/
_scanPageAnchorCmInfo() {
const anchor = new URL(window.location.href).hash;
if (!anchor.startsWith('#module-')) {
return null;
}
// The anchor is always #module-CMID.
const cmid = anchor.split('-')[1];
return this.stateManager.get('cm', parseInt(cmid));
}
/**
* Return the current page anchor cm info.
*/
getPageAnchorCmInfo() {
return this._pageAnchorCmInfo;
}
}