diff --git a/gulp/dev-config.js b/gulp/dev-config.js
new file mode 100644
index 000000000..b270bf51f
--- /dev/null
+++ b/gulp/dev-config.js
@@ -0,0 +1,69 @@
+// (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.
+
+const fs = require('fs');
+
+const DEV_CONFIG_FILE = '.moodleapp-dev-config';
+
+/**
+ * Class to read and write dev-config data from a file.
+ */
+class DevConfig {
+
+ constructor() {
+ this.loadFileData();
+ }
+
+ /**
+ * Get a setting.
+ *
+ * @param name Name of the setting to get.
+ * @param defaultValue Value to use if not found.
+ */
+ get(name, defaultValue) {
+ return typeof this.config[name] != 'undefined' ? this.config[name] : defaultValue;
+ }
+
+ /**
+ * Load file data to memory.
+ */
+ loadFileData() {
+ if (!fs.existsSync(DEV_CONFIG_FILE)) {
+ this.config = {};
+
+ return;
+ }
+
+ try {
+ this.config = JSON.parse(fs.readFileSync(DEV_CONFIG_FILE));
+ } catch (error) {
+ console.error('Error reading dev config file.', error);
+ this.config = {};
+ }
+ }
+
+ /**
+ * Save some settings.
+ *
+ * @param settings Object with the settings to save.
+ */
+ save(settings) {
+ this.config = Object.assign(this.config, settings);
+
+ // Save the data in the dev file.
+ fs.writeFileSync(DEV_CONFIG_FILE, JSON.stringify(this.config, null, 4));
+ }
+}
+
+module.exports = new DevConfig();
diff --git a/gulp/git.js b/gulp/git.js
new file mode 100644
index 000000000..f3f66298a
--- /dev/null
+++ b/gulp/git.js
@@ -0,0 +1,237 @@
+// (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.
+
+const exec = require('child_process').exec;
+const fs = require('fs');
+const DevConfig = require('./dev-config');
+const Utils = require('./utils');
+
+/**
+ * Class to run git commands.
+ */
+class Git {
+
+ /**
+ * Create a patch.
+ *
+ * @param range Show only commits in the specified revision range.
+ * @param saveTo Path to the file to save the patch to. If not defined, the patch contents will be returned.
+ * @return Promise resolved when done. If saveTo not provided, it will return the patch contents.
+ */
+ createPatch(range, saveTo) {
+ return new Promise((resolve, reject) => {
+ exec(`git format-patch ${range} --stdout`, (err, result) => {
+ if (err) {
+ reject(err || 'Cannot create patch.');
+ return;
+ }
+
+ if (!saveTo) {
+ resolve(result);
+ return;
+ }
+
+ // Save it to a file.
+ const directory = saveTo.substring(0, saveTo.lastIndexOf('/'));
+ if (directory && directory != '.' && directory != '..' && !fs.existsSync(directory)) {
+ fs.mkdirSync(directory);
+ }
+ fs.writeFileSync(saveTo, result);
+
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Get current branch.
+ *
+ * @return Promise resolved with the branch name.
+ */
+ getCurrentBranch() {
+ return new Promise((resolve, reject) => {
+ exec('git branch --show-current', (err, branch) => {
+ if (branch) {
+ resolve(branch.replace('\n', ''));
+ } else {
+ reject (err || 'Current branch not found.');
+ }
+ });
+ });
+ }
+
+ /**
+ * Get the HEAD commit for a certain branch.
+ *
+ * @param branch Name of the branch.
+ * @param branchData Parsed branch data. If not provided it will be calculated.
+ * @return HEAD commit.
+ */
+ async getHeadCommit(branch, branchData) {
+ if (!branchData) {
+ // Parse the branch to get the project and issue number.
+ branchData = Utils.parseBranch(branch);
+ }
+
+ // Loop over the last commits to find the first commit messages that doesn't belong to the issue.
+ const commitsString = await this.log(50, branch, '%s_____%H');
+ const commits = commitsString.split('\n');
+ commits.pop(); // Remove last element, it's an empty string.
+
+ for (let i = 0; i < commits.length; i++) {
+ const commit = commits[i];
+ const match = Utils.getIssueFromCommitMessage(commit) == branchData.issue;
+
+ if (i === 0 && !match) {
+ // Most recent commit doesn't belong to the issue. Stop looking.
+ break;
+ }
+
+ if (!match) {
+ // The commit does not match any more, we found it!
+ return commit.split('_____')[1];
+ }
+ }
+
+ // Couldn't find the commit using the commit names, get the last commit in the integration branch.
+ const remote = DevConfig.get('upstreamRemote', 'origin');
+ console.log(`Head commit not found using commit messages. Get last commit from ${remote}/integration`);
+ const hashes = await this.hashes(1, `${remote}/integration`);
+
+ return hashes[0];
+ }
+
+ /**
+ * Get the URL of a certain remote.
+ *
+ * @param remote Remote name.
+ * @return Promise resolved with the remote URL.
+ */
+ getRemoteUrl(remote) {
+ return new Promise((resolve, reject) => {
+ exec(`git remote get-url ${remote}`, (err, url) => {
+ if (url) {
+ resolve(url.replace('\n', ''));
+ } else {
+ reject (err || 'Remote not found.');
+ }
+ });
+ });
+ }
+
+ /**
+ * Return the latest hashes from git log.
+ *
+ * @param count Number of commits to display.
+ * @param range Show only commits in the specified revision range.
+ * @param format Pretty-print the contents of the commit logs in a given format.
+ * @return Promise resolved with the list of hashes.
+ */
+ async hashes(count, range, format) {
+ format = format || '%H';
+
+ const hashList = await this.log(count, range, format);
+
+ const hashes = hashList.split('\n');
+ hashes.pop(); // Remove last element, it's an empty string.
+
+ return hashes;
+ }
+
+ /**
+ * Calls the log command and returns the raw output.
+ *
+ * @param count Number of commits to display.
+ * @param range Show only commits in the specified revision range.
+ * @param format Pretty-print the contents of the commit logs in a given format.
+ * @param path Show only commits that are enough to explain how the files that match the specified paths came to be.
+ * @return Promise resolved with the result.
+ */
+ log(count, range, format, path) {
+ if (typeof count == 'undefined') {
+ count = 10;
+ }
+
+ let command = 'git log';
+
+ if (count > 0) {
+ command += ` -n ${count} `;
+ }
+ if (format) {
+ command += ` --format=${format} `;
+ }
+ if (range){
+ command += ` ${range} `;
+ }
+ if (path) {
+ command += ` -- ${path}`;
+ }
+
+ return new Promise((resolve, reject) => {
+ exec(command, (err, result, stderr) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve(result);
+ }
+ });
+ });
+ }
+
+ /**
+ * Return the latest titles of the commit messages.
+ *
+ * @param count Number of commits to display.
+ * @param range Show only commits in the specified revision range.
+ * @param path Show only commits that are enough to explain how the files that match the specified paths came to be.
+ * @return Promise resolved with the list of titles.
+ */
+ async messages(count, range, path) {
+ count = typeof count != 'undefined' ? count : 10;
+
+ const messageList = await this.log(count, range, '%s', path);
+
+ const messages = messageList.split('\n');
+ messages.pop(); // Remove last element, it's an empty string.
+
+ return messages;
+ }
+
+ /**
+ * Push a branch.
+ *
+ * @param remote Remote to use.
+ * @param branch Branch to push.
+ * @param force Whether to force the push.
+ * @return Promise resolved when done.
+ */
+ push(remote, branch, force) {
+ return new Promise((resolve, reject) => {
+ let command = `git push ${remote} ${branch}`;
+ if (force) {
+ command += ' -f';
+ }
+
+ exec(command, (err, result, stderr) => {
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+}
+
+module.exports = new Git();
diff --git a/gulp/jira.js b/gulp/jira.js
new file mode 100644
index 000000000..90aeaa1c7
--- /dev/null
+++ b/gulp/jira.js
@@ -0,0 +1,475 @@
+// (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.
+
+const exec = require('child_process').exec;
+const https = require('https');
+const inquirer = require('inquirer');
+const fs = require('fs');
+const request = require('request'); // This lib is deprecated, but it was the only way I found to make upload files work.
+const DevConfig = require('./dev-config');
+const Git = require('./git');
+const Url = require('./url');
+const Utils = require('./utils');
+
+const apiVersion = 2;
+
+/**
+ * Class to interact with Jira.
+ */
+class Jira {
+
+ /**
+ * Ask the password to the user.
+ *
+ * @return Promise resolved with the password.
+ */
+ async askPassword() {
+ const data = await inquirer.prompt([
+ {
+ type: 'password',
+ name: 'password',
+ message: `Please enter the password for the username ${this.username}.`,
+ },
+ ]);
+
+ return data.password;
+ }
+
+ /**
+ * Ask the user the tracker data.
+ *
+ * @return Promise resolved with the data, rejected if cannot get.
+ */
+ async askTrackerData() {
+ const data = await inquirer.prompt([
+ {
+ type: 'input',
+ name: 'url',
+ message: 'Please enter the tracker URL.',
+ default: 'https://tracker.moodle.org/',
+ },
+ {
+ type: 'input',
+ name: 'username',
+ message: 'Please enter your tracker username.',
+ },
+ ]);
+
+ DevConfig.save({
+ 'tracker.url': data.url,
+ 'tracker.username': data.username,
+ });
+
+ return data;
+ }
+
+ /**
+ * Build URL to perform requests to Jira.
+ *
+ * @param uri URI to add the the Jira URL.
+ * @return URL.
+ */
+ buildRequestUrl(uri) {
+ return Utils.concatenatePaths([this.url, this.uri, '/rest/api/', apiVersion, uri]);
+ }
+
+ /**
+ * Delete an attachment.
+ *
+ * @param attachmentId Attachment ID.
+ * @return Promise resolved when done.
+ */
+ async deleteAttachment(attachmentId) {
+ const response = await this.request(`attachment/${attachmentId}`, 'DELETE');
+
+ if (response.status != 204) {
+ throw new Error('Could not delete the attachment');
+ }
+ }
+
+ /**
+ * Load the issue info from jira server using a REST API call.
+ *
+ * @param key Key to identify the issue. E.g. MOBILE-1234.
+ * @param fields Fields to get.
+ * @return Promise resolved with the issue data.
+ */
+ async getIssue(key, fields) {
+ fields = fields || '*all,-comment';
+
+ await this.init(); // Initialize data if needed.
+
+ const response = await this.request(`issue/${key}`, 'GET', {'fields': fields, 'expand': 'names'});
+
+ if (response.status == 404) {
+ throw new Error('Issue could not be found.');
+ } else if (response.status != 200) {
+ throw new Error('The tracker is not available.')
+ }
+
+ const issue = response.data;
+ issue.named = {};
+
+ // Populate the named fields in a separate key. Allows us to easily find them without knowing the field ID.
+ const nameList = issue.names || {};
+ for (const fieldKey in issue.fields) {
+ if (nameList[fieldKey]) {
+ issue.named[nameList[fieldKey]] = issue.fields[fieldKey];
+ }
+ }
+
+ return issue
+ }
+
+ /**
+ * Load the version info from the jira server using a rest api call.
+ *
+ * @return Promise resolved when done.
+ */
+ async getServerInfo() {
+ const response = await this.request('serverInfo');
+
+ if (response.status != 200) {
+ throw new Error(`Unexpected response code: ${response.status}`, response);
+ }
+
+ this.version = response.data;
+ }
+
+ /**
+ * Get tracker data to push an issue.
+ *
+ * @return Promise resolved with the data.
+ */
+ async getTrackerData() {
+ // Check dev-config file first.
+ let data = this.getTrackerDataFromDevConfig();
+
+ if (data) {
+ console.log('Using tracker data from dev-config file');
+ return data;
+ }
+
+ // Try to use mdk now.
+ try {
+ data = await this.getTrackerDataFromMdk();
+
+ console.log('Using tracker data from mdk');
+
+ return data;
+ } catch (error) {
+ // MDK not available or not configured. Ask for the data.
+ const data = await this.askTrackerData();
+
+ data.fromInput = true;
+
+ return data;
+ }
+ }
+
+ /**
+ * Get tracker data from dev config file.
+ *
+ * @return Data, undefined if cannot get.
+ */
+ getTrackerDataFromDevConfig() {
+ const url = DevConfig.get('tracker.url');
+ const username = DevConfig.get('tracker.username');
+
+ if (url && username) {
+ return {
+ url,
+ username,
+ };
+ }
+ }
+
+ /**
+ * Get tracker URL and username from mdk.
+ *
+ * @return Promise resolved with the data, rejected if cannot get.
+ */
+ getTrackerDataFromMdk() {
+ return new Promise((resolve, reject) => {
+ exec('mdk config show tracker.url', (err, url) => {
+ if (!url) {
+ reject(err || 'URL not found.');
+ return;
+ }
+
+ exec('mdk config show tracker.username', (err, username) => {
+ if (username) {
+ resolve({
+ url: url.replace('\n', ''),
+ username: username.replace('\n', ''),
+ });
+ } else {
+ reject(err | 'Username not found.');
+ }
+ });
+ });
+ });
+ }
+
+ /**
+ * Initialize some data.
+ *
+ * @return Promise resolved when done.
+ */
+ async init() {
+ if (this.initialized) {
+ // Already initialized.
+ return;
+ }
+
+ // Get tracker URL and username.
+ const trackerData = await this.getTrackerData();
+
+ this.url = trackerData.url;
+ this.username = trackerData.username;
+
+ const parsed = Url.parse(this.url);
+ this.ssl = parsed.protocol == 'https';
+ this.host = parsed.domain;
+ this.uri = parsed.path;
+
+ // Get the password.
+ const keytar = require('keytar');
+
+ this.password = await keytar.getPassword('mdk-jira-password', this.username); // Use same service name as mdk.
+
+ if (!this.password) {
+ // Ask the user.
+ this.password = await this.askPassword();
+ }
+
+ while (!this.initialized) {
+ try {
+ await this.getServerInfo();
+
+ this.initialized = true;
+ keytar.setPassword('mdk-jira-password', this.username, this.password);
+ } catch (error) {
+ console.log('Error connecting to the server. Please make sure you entered the data correctly.', error);
+ if (trackerData.fromInput) {
+ // User entered the data manually, ask him again.
+ trackerData = await this.askTrackerData();
+
+ this.url = trackerData.url;
+ this.username = trackerData.username;
+ }
+
+ this.password = await this.askPassword();
+ }
+ }
+ }
+
+ /**
+ * Check if a certain issue could be a security issue.
+ *
+ * @param key Key to identify the issue. E.g. MOBILE-1234.
+ * @return Promise resolved with boolean: whether it's a security issue.
+ */
+ async isSecurityIssue(key) {
+ const issue = await this.getIssue(key, 'security');
+
+ return issue.fields && !!issue.fields.security;
+ }
+
+ /**
+ * Sends a request to the server and returns the data.
+ *
+ * @param uri URI to add the the Jira URL.
+ * @param method Method to use. Defaults to 'GET'.
+ * @param params Params to send as GET params (in the URL).
+ * @param data JSON string with the data to send as POST/PUT params.
+ * @param headers Headers to send.
+ * @return Promise resolved with the result.
+ */
+ request(uri, method, params, data, headers) {
+ uri = uri || '';
+ method = (method || 'GET').toUpperCase();
+ data = data || '';
+ params = params || {};
+ headers = headers || {};
+ headers['Content-Type'] = 'application/json';
+
+ return new Promise((resolve, reject) => {
+
+ // Build the request URL.
+ const url = Url.addParamsToUrl(this.buildRequestUrl(uri), params);
+
+ // Initialize the request.
+ const options = {
+ method: method,
+ auth: `${this.username}:${this.password}`,
+ headers: headers,
+ };
+ const request = https.request(url, options);
+
+ // Add data.
+ if (data) {
+ request.write(data);
+ }
+
+ // Treat response.
+ request.on('response', (response) => {
+ // Read the result.
+ let result = '';
+ response.on('data', (chunk) => {
+ result += chunk;
+ });
+ response.on('end', () => {
+ try {
+ result = JSON.parse(result);
+ } catch (error) {
+ // Leave it as text.
+ }
+
+ resolve({
+ status: response.statusCode,
+ data: result,
+ });
+ });
+ });
+
+ request.on('error', (e) => {
+ reject(e);
+ });
+
+ // Send the request.
+ request.end();
+ });
+ }
+
+ /**
+ * Sets a set of fields for a certain issue in Jira.
+ *
+ * @param key Key to identify the issue. E.g. MOBILE-1234.
+ * @param updates Object with the fields to update.
+ * @return Promise resolved when done.
+ */
+ async setCustomFields(key, updates) {
+ const issue = await this.getIssue(key);
+ const update = {'fields': {}};
+
+ // Detect which fields have changed.
+ for (const updateName in updates) {
+ const updateValue = updates[updateName];
+ const remoteValue = issue.named[updateName];
+
+ if (!remoteValue || remoteValue != updateValue) {
+ // Map the label of the field with the field code.
+ let fieldKey;
+ for (const key in issue.names) {
+ if (issue.names[key] == updateName) {
+ fieldKey = key;
+ break;
+ }
+ }
+
+ if (!fieldKey) {
+ throw new Error(`Could not find the field named ${updateName}.`);
+ }
+
+ update.fields[fieldKey] = updateValue;
+ }
+ }
+
+ if (!Object.keys(update.fields).length) {
+ // No fields to update.
+ console.log('No updates required.')
+ return;
+ }
+
+ const response = await this.request(`issue/${key}`, 'PUT', null, JSON.stringify(update));
+
+ if (response.status != 204) {
+ throw new Error(`Issue was not updated: ${response.status}`, response.data);
+ }
+
+ console.log('Issue updated successfully.');
+ }
+
+ /**
+ * Upload a new attachment to an issue.
+ *
+ * @param key Key to identify the issue. E.g. MOBILE-1234.
+ * @param filePath Path to the file to upload.
+ * @return Promise resolved when done.
+ */
+ async upload(key, filePath) {
+
+ const uri = `issue/${key}/attachments`;
+ const headers = {
+ 'X-Atlassian-Token': 'nocheck',
+ }
+
+ const response = await this.uploadFile(uri, 'file', filePath, headers);
+
+ if (response.status != 200) {
+ throw new Error('Could not upload file to Jira issue');
+ }
+
+ console.log('File successfully uploaded.')
+ }
+
+ /**
+ * Upload a file to Jira.
+ *
+ * @param uri URI to add the the Jira URL.
+ * @param fieldName Name of the form field where to put the file.
+ * @param filePath Path to the file.
+ * @param headers Headers.
+ * @return Promise resolved with the result.
+ */
+ async uploadFile(uri, fieldName, filePath, headers) {
+ uri = uri || '';
+ headers = headers || {};
+ headers['Content-Type'] = 'multipart/form-data';
+
+ return new Promise((resolve, reject) => {
+ // Add the file to the form data.
+ const formData = {};
+ formData[fieldName] = {
+ value: fs.createReadStream(filePath),
+ options: {
+ filename: filePath.substr(filePath.lastIndexOf('/') + 1),
+ contentType: 'multipart/form-data',
+ },
+ };
+
+ // Perform the request.
+ const options = {
+ url: this.buildRequestUrl(uri),
+ method: 'POST',
+ headers: headers,
+ auth: {
+ user: this.username,
+ pass: this.password,
+ },
+ formData: formData,
+ };
+
+ request(options, (err, httpResponse, body) => {
+ resolve({
+ status: httpResponse.statusCode,
+ data: body,
+ });
+ });
+ });
+ }
+}
+
+module.exports = new Jira();
diff --git a/gulp/task-build-lang.js b/gulp/task-build-lang.js
new file mode 100644
index 000000000..980bf52a4
--- /dev/null
+++ b/gulp/task-build-lang.js
@@ -0,0 +1,190 @@
+// (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.
+
+const gulp = require('gulp');
+const slash = require('gulp-slash');
+const clipEmptyFiles = require('gulp-clip-empty-files');
+const through = require('through');
+const bufferFrom = require('buffer-from');
+const File = require('vinyl');
+const pathLib = require('path');
+
+/**
+ * Task to build the language files into a single file per language.
+ */
+class BuildLangTask {
+
+ /**
+ * Copy a property from one object to another, adding a prefix to the key if needed.
+ *
+ * @param target Object to copy the properties to.
+ * @param source Object to copy the properties from.
+ * @param prefix Prefix to add to the keys.
+ */
+ addProperties(target, source, prefix) {
+ for (let property in source) {
+ target[prefix + property] = source[property];
+ }
+ }
+
+ /**
+ * Run the task.
+ *
+ * @param language Language to treat.
+ * @param langPaths Paths to the possible language files.
+ * @param done Function to call when done.
+ */
+ run(language, langPaths, done) {
+ const filename = language + '.json';
+ const data = {};
+ let firstFile = null;
+ const self = this;
+
+ const paths = langPaths.map((path) => {
+ if (path.slice(-1) != '/') {
+ path = path + '/';
+ }
+
+ return path + language + '.json';
+ });
+
+ gulp.src(paths, { allowEmpty: true })
+ .pipe(slash())
+ .pipe(clipEmptyFiles())
+ .pipe(through(function(file) {
+ if (!firstFile) {
+ firstFile = file;
+ }
+
+ return self.treatFile(file, data);
+ }, function() {
+ /* This implementation is based on gulp-jsoncombine module.
+ * https://github.com/reflog/gulp-jsoncombine */
+ if (firstFile) {
+ const joinedPath = pathLib.join(firstFile.base, language + '.json');
+
+ const joinedFile = new File({
+ cwd: firstFile.cwd,
+ base: firstFile.base,
+ path: joinedPath,
+ contents: self.treatMergedData(data),
+ });
+
+ this.emit('data', joinedFile);
+ }
+
+ this.emit('end');
+ }))
+ .pipe(gulp.dest(pathLib.join('./src/assets', 'lang')))
+ .on('end', done);
+ }
+
+ /**
+ * Treats a file to merge JSONs. This function is based on gulp-jsoncombine module.
+ * https://github.com/reflog/gulp-jsoncombine
+ *
+ * @param file File treated.
+ * @param data Object where to store the data.
+ */
+ treatFile(file, data) {
+ if (file.isNull() || file.isStream()) {
+ return; // ignore
+ }
+
+ try {
+ let path = file.path;
+ let length = 9;
+
+ let srcPos = path.lastIndexOf('/src/app/');
+ if (srcPos < 0) {
+ // It's probably a Windows environment.
+ srcPos = path.lastIndexOf('\\src\\app\\');
+ }
+ if (srcPos < 0) {
+ length = 5;
+ srcPos = path.lastIndexOf('/src/');
+ if (srcPos < 0) {
+ // It's probably a Windows environment.
+ srcPos = path.lastIndexOf('\\src\\');
+ }
+ }
+ path = path.substr(srcPos + length);
+
+ data[path] = JSON.parse(file.contents.toString());
+ } catch (err) {
+ console.log('Error parsing JSON: ' + err);
+ }
+ }
+
+ /**
+ * Treats the merged JSON data, adding prefixes depending on the component.
+ *
+ * @param data Merged data.
+ * @return Buffer with the treated data.
+ */
+ treatMergedData(data) {
+ const merged = {};
+ const mergedOrdered = {};
+
+ for (let filepath in data) {
+
+ const pathSplit = filepath.split(/[\/\\]/);
+ let prefix;
+
+ pathSplit.pop();
+
+ switch (pathSplit[0]) {
+ case 'lang':
+ prefix = 'core';
+ break;
+ case 'core':
+ if (pathSplit[1] == 'lang') {
+ // Not used right now.
+ prefix = 'core';
+ } else {
+ prefix = 'core.' + pathSplit[1];
+ }
+ break;
+ case 'addon':
+ // Remove final item 'lang'.
+ pathSplit.pop();
+ // Remove first item 'addon'.
+ pathSplit.shift();
+
+ // For subplugins. We'll use plugin_subfolder_subfolder2_...
+ // E.g. 'mod_assign_feedback_comments'.
+ prefix = 'addon.' + pathSplit.join('_');
+ break;
+ case 'assets':
+ prefix = 'assets.' + pathSplit[1];
+ break;
+ }
+
+ if (prefix) {
+ this.addProperties(merged, data[filepath], prefix + '.');
+ }
+ }
+
+
+
+ // Force ordering by string key.
+ Object.keys(merged).sort().forEach((key) => {
+ mergedOrdered[key] = merged[key];
+ });
+
+ return bufferFrom(JSON.stringify(mergedOrdered, null, 4));
+ }
+}
+
+module.exports = BuildLangTask;
diff --git a/gulp/task-push.js b/gulp/task-push.js
new file mode 100644
index 000000000..54d151dde
--- /dev/null
+++ b/gulp/task-push.js
@@ -0,0 +1,280 @@
+// (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.
+
+const gulp = require('gulp');
+const inquirer = require('inquirer');
+const DevConfig = require('./dev-config');
+const Git = require('./git');
+const Jira = require('./jira');
+const Utils = require('./utils');
+
+/**
+ * Task to push a git branch and update tracker issue.
+ */
+class PushTask {
+
+ /**
+ * Ask the user whether he wants to continue.
+ *
+ * @return Promise resolved with boolean: true if he wants to continue.
+ */
+ async askConfirmContinue() {
+ const answer = await inquirer.prompt([
+ {
+ type: 'input',
+ name: 'confirm',
+ message: 'Are you sure you want to continue?',
+ default: 'n',
+ },
+ ]);
+
+ return answer.confirm == 'y';
+ }
+
+ /**
+ * Push a patch to the tracker and remove the previous one.
+ *
+ * @param branch Branch name.
+ * @param branchData Parsed branch data.
+ * @param remote Remote used.
+ * @return Promise resolved when done.
+ */
+ async pushPatch(branch, branchData, remote) {
+ const headCommit = await Git.getHeadCommit(branch, branchData);
+
+ if (!headCommit) {
+ throw new Error('Head commit not resolved, abort pushing patch.');
+ }
+
+ // Create the patch file.
+ const fileName = branch + '.patch';
+ const tmpPatchPath = `./tmp/${fileName}`;
+
+ await Git.createPatch(`${headCommit}...${branch}`, tmpPatchPath);
+ console.log('Git patch created');
+
+ // Check if there is an attachment with same name in the issue.
+ const issue = await Jira.getIssue(branchData.issue, 'attachment');
+
+ let existingAttachmentId;
+ const attachments = (issue.fields && issue.fields.attachment) || [];
+ for (const i in attachments) {
+ if (attachments[i].filename == fileName) {
+ // Found an existing attachment with the same name, we keep track of it.
+ existingAttachmentId = attachments[i].id;
+ break
+ }
+ }
+
+ // Push the patch to the tracker.
+ console.log(`Uploading patch ${fileName} to the tracker...`);
+ await Jira.upload(branchData.issue, tmpPatchPath);
+
+ if (existingAttachmentId) {
+ // On success, deleting file that was there before.
+ try {
+ console.log('Deleting older patch...')
+ await Jira.deleteAttachment(existingAttachmentId);
+ } catch (error) {
+ console.log('Could not delete older attachment.');
+ }
+ }
+ }
+
+ /**
+ * Run the task.
+ *
+ * @param args Command line arguments.
+ * @param done Function to call when done.
+ */
+ async run(args, done) {
+ try {
+ const remote = args.remote || DevConfig.get('upstreamRemote', 'origin');
+ let branch = args.branch;
+ const force = !!args.force;
+
+ if (!branch) {
+ branch = await Git.getCurrentBranch();
+ }
+
+ if (!branch) {
+ throw new Error('Cannot determine the current branch. Please make sure youu aren\'t in detached HEAD state');
+ } else if (branch == 'HEAD') {
+ throw new Error('Cannot push HEAD branch');
+ }
+
+ // Parse the branch to get the project and issue number.
+ const branchData = Utils.parseBranch(branch);
+ const keepRunning = await this.validateCommitMessages(branchData);
+
+ if (!keepRunning) {
+ // Last commit not valid, stop.
+ console.log('Exiting...');
+ done();
+ return;
+ }
+
+ if (!args.patch) {
+ // Check if it's a security issue to force patch mode.
+ try {
+ args.patch = await Jira.isSecurityIssue(branchData.issue);
+
+ if (args.patch) {
+ console.log(`${branchData.issue} appears to be a security issue, switching to patch mode...`);
+ }
+ } catch (error) {
+ console.log(`Could not check if ${branchData.issue} is a security issue.`);
+ }
+ }
+
+ if (args.patch) {
+ // Create and upload a patch file.
+ await this.pushPatch(branch, branchData, remote);
+ } else {
+ // Push the branch.
+ console.log(`Pushing branch ${branch} to remote ${remote}...`);
+ await Git.push(remote, branch, force);
+
+ // Update tracker info.
+ console.log(`Branch pushed, update tracker info...`);
+ await this.updateTrackerGitInfo(branch, branchData, remote);
+ }
+ } catch (error) {
+ console.error(error);
+ }
+
+ done();
+ }
+
+ /**
+ * Update git info in the tracker issue.
+ *
+ * @param branch Branch name.
+ * @param branchData Parsed branch data.
+ * @param remote Remote used.
+ * @return Promise resolved when done.
+ */
+ async updateTrackerGitInfo(branch, branchData, remote) {
+ // Get the repository data for the project.
+ let repositoryUrl = DevConfig.get(branchData.project + '.repositoryUrl');
+ let diffUrlTemplate = DevConfig.get(branchData.project + '.diffUrlTemplate', '');
+
+ if (!repositoryUrl) {
+ // Calculate the repositoryUrl based on the remote URL.
+ repositoryUrl = await Git.getRemoteUrl(remote);
+ }
+
+ // Make sure the repository URL uses the regular format.
+ repositoryUrl = repositoryUrl.replace(/^(git@|git:\/\/)/, 'https://')
+ .replace(/\.git$/, '')
+ .replace('github.com:', 'github.com/');
+
+ if (!diffUrlTemplate) {
+ diffUrlTemplate = Utils.concatenatePaths([repositoryUrl, 'compare/%headcommit%...%branch%']);
+ }
+
+ // Now create the git URL for the repository.
+ const repositoryGitUrl = repositoryUrl.replace(/^https?:\/\//, 'git://') + '.git';
+
+ // Search HEAD commit to put in the diff URL.
+ console.log ('Searching for head commit...');
+ let headCommit = await Git.getHeadCommit(branch, branchData);
+
+ if (!headCommit) {
+ throw new Error('Head commit not resolved, aborting update of tracker fields');
+ }
+
+ headCommit = headCommit.substr(0, 10);
+ console.log(`Head commit resolved to ${headCommit}`);
+
+ // Calculate last properties needed.
+ const diffUrl = diffUrlTemplate.replace('%branch%', branch).replace('%headcommit%', headCommit);
+ const fieldRepositoryUrl = DevConfig.get('tracker.fieldnames.repositoryurl', 'Pull from Repository');
+ const fieldBranch = DevConfig.get('tracker.fieldnames.branch', 'Pull Master Branch');
+ const fieldDiffUrl = DevConfig.get('tracker.fieldnames.diffurl', 'Pull Master Diff URL');
+
+ // Update tracker fields.
+ const updates = {};
+ updates[fieldRepositoryUrl] = repositoryGitUrl;
+ updates[fieldBranch] = branch;
+ updates[fieldDiffUrl] = diffUrl;
+
+ console.log('Setting tracker fields...');
+ await Jira.setCustomFields(branchData.issue, updates);
+ }
+
+ /**
+ * Validate commit messages comparing them with the branch name.
+ *
+ * @param branchData Parsed branch data.
+ * @return True if value is ok or the user wants to continue anyway, false to stop.
+ */
+ async validateCommitMessages(branchData) {
+ const messages = await Git.messages(30);
+
+ let numConsecutive = 0;
+ let wrongCommitCandidate = null;
+
+ for (let i = 0; i < messages.length; i++) {
+ const message = messages[i];
+ const issue = Utils.getIssueFromCommitMessage(message);
+
+ if (!issue || issue != branchData.issue) {
+ if (i === 0) {
+ // Last commit is wrong, it shouldn't happen. Ask the user if he wants to continue.
+ if (!issue) {
+ console.log('The issue number could not be found in the last commit message.');
+ console.log(`Commit: ${message}`);
+ } else if (issue != branchData.issue) {
+ console.log('The issue number in the last commit does not match the branch being pushed to.');
+ console.log(`Branch: ${branchData.issue} vs. commit: ${issue}`);
+ }
+
+ return this.askConfirmContinue();
+ }
+
+ numConsecutive++;
+ if (numConsecutive > 2) {
+ // 3 consecutive commits with different branch, probably the branch commits are over. Everything OK.
+ return true;
+
+ // Don't treat a merge pull request commit as a wrong commit between right commits.
+ // The current push could be a quick fix after a merge.
+ } else if (!wrongCommitCandidate && message.indexOf('Merge pull request') == -1) {
+ wrongCommitCandidate = {
+ message: message,
+ issue: issue,
+ index: i,
+ };
+ }
+ } else if (wrongCommitCandidate) {
+ // We've found a commit with the branch name after a commit with a different branch. Probably wrong commit.
+ if (!wrongCommitCandidate.issue) {
+ console.log('The issue number could not be found in one of the commit messages.');
+ console.log(`Commit: ${wrongCommitCandidate.message}`);
+ } else {
+ console.log('The issue number in a certain commit does not match the branch being pushed to.');
+ console.log(`Branch: ${branchData.issue} vs. commit: ${wrongCommitCandidate.issue}`);
+ console.log(`Commit message: ${wrongCommitCandidate.message}`);
+ }
+
+ return this.askConfirmContinue();
+ }
+ }
+
+ return true;
+ }
+}
+
+module.exports = PushTask;
diff --git a/gulp/url.js b/gulp/url.js
new file mode 100644
index 000000000..8b46409f6
--- /dev/null
+++ b/gulp/url.js
@@ -0,0 +1,79 @@
+// (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.
+
+/**
+ * Class with helper functions for urls.
+ */
+class Url {
+
+ /**
+ * Add params to a URL.
+ *
+ * @param url URL to add the params to.
+ * @param params Object with the params to add.
+ * @return URL with params.
+ */
+ static addParamsToUrl(url, params) {
+ let separator = url.indexOf('?') != -1 ? '&' : '?';
+
+ for (const key in params) {
+ let value = params[key];
+
+ // Ignore objects.
+ if (typeof value != 'object') {
+ url += separator + key + '=' + value;
+ separator = '&';
+ }
+ }
+
+ return url;
+ }
+
+ /**
+ * Parse parts of a url, using an implicit protocol if it is missing from the url.
+ *
+ * @param url Url.
+ * @return Url parts.
+ */
+ static parse(url) {
+ // Parse url with regular expression taken from RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B.
+ const match = url.trim().match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/);
+
+ if (!match) {
+ return null;
+ }
+
+ const host = match[4] || '';
+
+ // Get the credentials and the port from the host.
+ const [domainAndPort, credentials] = host.split('@').reverse();
+ const [domain, port] = domainAndPort.split(':');
+ const [username, password] = credentials ? credentials.split(':') : [];
+
+ // Prepare parts replacing empty strings with undefined.
+ return {
+ protocol: match[2] || undefined,
+ domain: domain || undefined,
+ port: port || undefined,
+ credentials: credentials || undefined,
+ username: username || undefined,
+ password: password || undefined,
+ path: match[5] || undefined,
+ query: match[7] || undefined,
+ fragment: match[9] || undefined,
+ };
+ }
+}
+
+module.exports = Url;
diff --git a/gulp/utils.js b/gulp/utils.js
new file mode 100644
index 000000000..0ca11fa74
--- /dev/null
+++ b/gulp/utils.js
@@ -0,0 +1,119 @@
+// (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.
+
+const DevConfig = require('./dev-config');
+const DEFAULT_ISSUE_REGEX = '^(MOBILE)[-_]([0-9]+)';
+
+/**
+ * Class with some utility functions.
+ */
+class Utils {
+ /**
+ * Concatenate several paths, adding a slash between them if needed.
+ *
+ * @param paths List of paths.
+ * @return Concatenated path.
+ */
+ static concatenatePaths(paths) {
+ if (!paths.length) {
+ return '';
+ }
+
+ // Remove all slashes between paths.
+ for (let i = 0; i < paths.length; i++) {
+ if (!paths[i]) {
+ continue;
+ }
+
+ if (i === 0) {
+ paths[i] = String(paths[i]).replace(/\/+$/g, '');
+ } else if (i === paths.length - 1) {
+ paths[i] = String(paths[i]).replace(/^\/+/g, '');
+ } else {
+ paths[i] = String(paths[i]).replace(/^\/+|\/+$/g, '');
+ }
+ }
+
+ // Remove empty paths.
+ paths = paths.filter(path => !!path);
+
+ return paths.join('/');
+ }
+
+ /**
+ * Get command line arguments.
+ *
+ * @return Object with command line arguments.
+ */
+ static getCommandLineArguments() {
+
+ let args = {}, opt, thisOpt, curOpt;
+ for (let a = 0; a < process.argv.length; a++) {
+
+ thisOpt = process.argv[a].trim();
+ opt = thisOpt.replace(/^\-+/, '');
+
+ if (opt === thisOpt) {
+ // argument value
+ if (curOpt) {
+ args[curOpt] = opt;
+ }
+ curOpt = null;
+ }
+ else {
+ // Argument name.
+ curOpt = opt;
+ args[curOpt] = true;
+ }
+ }
+
+ return args;
+ }
+
+ /**
+ * Given a commit message, return the issue name (e.g. MOBILE-1234).
+ *
+ * @param commit Commit message.
+ * @return Issue name.
+ */
+ static getIssueFromCommitMessage(commit) {
+ const regex = new RegExp(DevConfig.get('wording.branchRegex', DEFAULT_ISSUE_REGEX), 'i');
+ const matches = commit.match(regex);
+
+ return matches && matches[0];
+ }
+
+ /**
+ * Parse a branch name to extract some data.
+ *
+ * @param branch Branch name to parse.
+ * @return Data.
+ */
+ static parseBranch(branch) {
+ const regex = new RegExp(DevConfig.get('wording.branchRegex', DEFAULT_ISSUE_REGEX), 'i');
+
+ const matches = branch.match(regex);
+ if (!matches || matches.length < 3) {
+ throw new Error(`Error parsing branch ${branch}`);
+ }
+
+ return {
+ issue: matches[0],
+ project: matches[1],
+ issueNumber: matches[2],
+ };
+ }
+}
+
+module.exports = Utils;
diff --git a/gulpfile.js b/gulpfile.js
new file mode 100644
index 000000000..7a08d6c88
--- /dev/null
+++ b/gulpfile.js
@@ -0,0 +1,48 @@
+// (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.
+
+const BuildLangTask = require('./gulp/task-build-lang');
+const PushTask = require('./gulp/task-push');
+const Utils = require('./gulp/utils');
+const gulp = require('gulp');
+
+const paths = {
+ lang: [
+ './src/app/lang/',
+ './src/app/core/**/lang/',
+ './src/app/addon/**/lang/',
+ './src/app/**/**/lang/',
+ './src/assets/countries/',
+ './src/assets/mimetypes/'
+ ]
+};
+
+const args = Utils.getCommandLineArguments();
+
+// Build the language files into a single file per language.
+gulp.task('lang', (done) => {
+ new BuildLangTask().run('en', paths.lang, done);
+});
+
+gulp.task('push', (done) => {
+ new PushTask().run(args, done);
+});
+
+gulp.task('default', gulp.parallel('lang'));
+
+gulp.task('watch', () => {
+ const langsPaths = paths.lang.map(path => path + 'en.json');
+
+ gulp.watch(langsPaths, { interval: 500 }, gulp.parallel('lang'));
+});
diff --git a/package.json b/package.json
index 80b676724..72bd72e26 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,8 @@
"test:ci": "jest -ci --runInBand --verbose",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
- "lint": "ng lint"
+ "lint": "ng lint",
+ "ionic:serve:before": "npx gulp"
},
"dependencies": {
"@angular/common": "~10.0.0",
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index dde487788..48afedd0e 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -12,11 +12,40 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import { Component } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
+import { CoreLangProvider } from '@services/lang';
+import { CoreEvents, CoreEventsProvider } from '@services/events';
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
-export class AppComponent { }
+export class AppComponent implements OnInit {
+
+ constructor(
+ private langProvider: CoreLangProvider,
+ ) {
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ CoreEvents.instance.on(CoreEventsProvider.LOGOUT, () => {
+ // Go to sites page when user is logged out.
+ // Due to DeepLinker, we need to use the ViewCtrl instead of name.
+ // Otherwise some pages are re-created when they shouldn't.
+ // TODO
+ // CoreApp.instance.getRootNavController().setRoot(CoreLoginSitesPage);
+
+ // Unload lang custom strings.
+ this.langProvider.clearCustomStrings();
+
+ // Remove version classes from body.
+ // TODO
+ // this.removeVersionClass();
+ });
+ }
+
+}
diff --git a/src/app/core/login/lang/en.json b/src/app/core/login/lang/en.json
new file mode 100644
index 000000000..e776074e5
--- /dev/null
+++ b/src/app/core/login/lang/en.json
@@ -0,0 +1,3 @@
+{
+ "yourenteredsite": "Connect to your site"
+}
diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts
index 74707b4fb..39ba3ce72 100644
--- a/src/app/core/login/login.module.ts
+++ b/src/app/core/login/login.module.ts
@@ -16,17 +16,18 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
import { CoreLoginRoutingModule } from './login-routing.module';
import { CoreLoginInitPage } from './pages/init/init.page';
import { CoreLoginSitePage } from './pages/site/site.page';
-
@NgModule({
imports: [
CommonModule,
IonicModule,
CoreLoginRoutingModule,
+ TranslateModule.forChild(),
],
declarations: [
CoreLoginInitPage,
diff --git a/src/app/core/login/pages/site/site.html b/src/app/core/login/pages/site/site.html
index 49d7a9823..e2beae84f 100644
--- a/src/app/core/login/pages/site/site.html
+++ b/src/app/core/login/pages/site/site.html
@@ -1,3 +1,3 @@
- Site page.
+ {{ 'core.login.yourenteredsite' | translate }}
diff --git a/src/app/services/lang.ts b/src/app/services/lang.ts
index 5a763614d..dc405bd31 100644
--- a/src/app/services/lang.ts
+++ b/src/app/services/lang.ts
@@ -246,7 +246,7 @@ export class CoreLangProvider {
protected async detectLanguage(): Promise {
// Get current language from config (user might have changed it).
try {
- return CoreConfig.instance.get('current_language');
+ return await CoreConfig.instance.get('current_language');
} catch (e) {
// Try will return, ignore errors here to avoid nesting.
}
diff --git a/src/assets/countries/en.json b/src/assets/countries/en.json
new file mode 100644
index 000000000..777763e84
--- /dev/null
+++ b/src/assets/countries/en.json
@@ -0,0 +1,251 @@
+{
+ "AD": "Andorra",
+ "AE": "United Arab Emirates",
+ "AF": "Afghanistan",
+ "AG": "Antigua and Barbuda",
+ "AI": "Anguilla",
+ "AL": "Albania",
+ "AM": "Armenia",
+ "AO": "Angola",
+ "AQ": "Antarctica",
+ "AR": "Argentina",
+ "AS": "American Samoa",
+ "AT": "Austria",
+ "AU": "Australia",
+ "AW": "Aruba",
+ "AX": "Åland Islands",
+ "AZ": "Azerbaijan",
+ "BA": "Bosnia and Herzegovina",
+ "BB": "Barbados",
+ "BD": "Bangladesh",
+ "BE": "Belgium",
+ "BF": "Burkina Faso",
+ "BG": "Bulgaria",
+ "BH": "Bahrain",
+ "BI": "Burundi",
+ "BJ": "Benin",
+ "BL": "Saint Barthélemy",
+ "BM": "Bermuda",
+ "BN": "Brunei Darussalam",
+ "BO": "Bolivia (Plurinational State of)",
+ "BQ": "Bonaire, Sint Eustatius and Saba",
+ "BR": "Brazil",
+ "BS": "Bahamas",
+ "BT": "Bhutan",
+ "BV": "Bouvet Island",
+ "BW": "Botswana",
+ "BY": "Belarus",
+ "BZ": "Belize",
+ "CA": "Canada",
+ "CC": "Cocos (Keeling) Islands",
+ "CD": "Congo (the Democratic Republic of the)",
+ "CF": "Central African Republic",
+ "CG": "Congo",
+ "CH": "Switzerland",
+ "CI": "Côte d'Ivoire",
+ "CK": "Cook Islands",
+ "CL": "Chile",
+ "CM": "Cameroon",
+ "CN": "China",
+ "CO": "Colombia",
+ "CR": "Costa Rica",
+ "CU": "Cuba",
+ "CV": "Cabo Verde",
+ "CW": "Curaçao",
+ "CX": "Christmas Island",
+ "CY": "Cyprus",
+ "CZ": "Czechia",
+ "DE": "Germany",
+ "DJ": "Djibouti",
+ "DK": "Denmark",
+ "DM": "Dominica",
+ "DO": "Dominican Republic",
+ "DZ": "Algeria",
+ "EC": "Ecuador",
+ "EE": "Estonia",
+ "EG": "Egypt",
+ "EH": "Western Sahara",
+ "ER": "Eritrea",
+ "ES": "Spain",
+ "ET": "Ethiopia",
+ "FI": "Finland",
+ "FJ": "Fiji",
+ "FK": "Falkland Islands (Malvinas)",
+ "FM": "Micronesia (Federated States of)",
+ "FO": "Faroe Islands",
+ "FR": "France",
+ "GA": "Gabon",
+ "GB": "United Kingdom",
+ "GD": "Grenada",
+ "GE": "Georgia",
+ "GF": "French Guiana",
+ "GG": "Guernsey",
+ "GH": "Ghana",
+ "GI": "Gibraltar",
+ "GL": "Greenland",
+ "GM": "Gambia",
+ "GN": "Guinea",
+ "GP": "Guadeloupe",
+ "GQ": "Equatorial Guinea",
+ "GR": "Greece",
+ "GS": "South Georgia and the South Sandwich Islands",
+ "GT": "Guatemala",
+ "GU": "Guam",
+ "GW": "Guinea-Bissau",
+ "GY": "Guyana",
+ "HK": "Hong Kong",
+ "HM": "Heard Island and McDonald Islands",
+ "HN": "Honduras",
+ "HR": "Croatia",
+ "HT": "Haiti",
+ "HU": "Hungary",
+ "ID": "Indonesia",
+ "IE": "Ireland",
+ "IL": "Israel",
+ "IM": "Isle of Man",
+ "IN": "India",
+ "IO": "British Indian Ocean Territory",
+ "IQ": "Iraq",
+ "IR": "Iran (Islamic Republic of)",
+ "IS": "Iceland",
+ "IT": "Italy",
+ "JE": "Jersey",
+ "JM": "Jamaica",
+ "JO": "Jordan",
+ "JP": "Japan",
+ "KE": "Kenya",
+ "KG": "Kyrgyzstan",
+ "KH": "Cambodia",
+ "KI": "Kiribati",
+ "KM": "Comoros",
+ "KN": "Saint Kitts and Nevis",
+ "KP": "Korea (the Democratic People's Republic of)",
+ "KR": "Korea (the Republic of)",
+ "KW": "Kuwait",
+ "KY": "Cayman Islands",
+ "KZ": "Kazakhstan",
+ "LA": "Lao People's Democratic Republic",
+ "LB": "Lebanon",
+ "LC": "Saint Lucia",
+ "LI": "Liechtenstein",
+ "LK": "Sri Lanka",
+ "LR": "Liberia",
+ "LS": "Lesotho",
+ "LT": "Lithuania",
+ "LU": "Luxembourg",
+ "LV": "Latvia",
+ "LY": "Libya",
+ "MA": "Morocco",
+ "MC": "Monaco",
+ "MD": "Moldova (the Republic of)",
+ "ME": "Montenegro",
+ "MF": "Saint Martin (French part)",
+ "MG": "Madagascar",
+ "MH": "Marshall Islands",
+ "MK": "North Macedonia",
+ "ML": "Mali",
+ "MM": "Myanmar",
+ "MN": "Mongolia",
+ "MO": "Macao",
+ "MP": "Northern Mariana Islands",
+ "MQ": "Martinique",
+ "MR": "Mauritania",
+ "MS": "Montserrat",
+ "MT": "Malta",
+ "MU": "Mauritius",
+ "MV": "Maldives",
+ "MW": "Malawi",
+ "MX": "Mexico",
+ "MY": "Malaysia",
+ "MZ": "Mozambique",
+ "NA": "Namibia",
+ "NC": "New Caledonia",
+ "NE": "Niger",
+ "NF": "Norfolk Island",
+ "NG": "Nigeria",
+ "NI": "Nicaragua",
+ "NL": "Netherlands",
+ "NO": "Norway",
+ "NP": "Nepal",
+ "NR": "Nauru",
+ "NU": "Niue",
+ "NZ": "New Zealand",
+ "OM": "Oman",
+ "PA": "Panama",
+ "PE": "Peru",
+ "PF": "French Polynesia",
+ "PG": "Papua New Guinea",
+ "PH": "Philippines",
+ "PK": "Pakistan",
+ "PL": "Poland",
+ "PM": "Saint Pierre and Miquelon",
+ "PN": "Pitcairn",
+ "PR": "Puerto Rico",
+ "PS": "Palestine, State of",
+ "PT": "Portugal",
+ "PW": "Palau",
+ "PY": "Paraguay",
+ "QA": "Qatar",
+ "RE": "Réunion",
+ "RO": "Romania",
+ "RS": "Serbia",
+ "RU": "Russian Federation",
+ "RW": "Rwanda",
+ "SA": "Saudi Arabia",
+ "SB": "Solomon Islands",
+ "SC": "Seychelles",
+ "SD": "Sudan",
+ "SE": "Sweden",
+ "SG": "Singapore",
+ "SH": "Saint Helena, Ascension and Tristan da Cunha",
+ "SI": "Slovenia",
+ "SJ": "Svalbard and Jan Mayen",
+ "SK": "Slovakia",
+ "SL": "Sierra Leone",
+ "SM": "San Marino",
+ "SN": "Senegal",
+ "SO": "Somalia",
+ "SR": "Suriname",
+ "SS": "South Sudan",
+ "ST": "Sao Tome and Principe",
+ "SV": "El Salvador",
+ "SX": "Sint Maarten (Dutch part)",
+ "SY": "Syrian Arab Republic",
+ "SZ": "Eswatini",
+ "TC": "Turks and Caicos Islands",
+ "TD": "Chad",
+ "TF": "French Southern Territories",
+ "TG": "Togo",
+ "TH": "Thailand",
+ "TJ": "Tajikistan",
+ "TK": "Tokelau",
+ "TL": "Timor-Leste",
+ "TM": "Turkmenistan",
+ "TN": "Tunisia",
+ "TO": "Tonga",
+ "TR": "Turkey",
+ "TT": "Trinidad and Tobago",
+ "TV": "Tuvalu",
+ "TW": "Taiwan",
+ "TZ": "Tanzania, the United Republic of",
+ "UA": "Ukraine",
+ "UG": "Uganda",
+ "UM": "United States Minor Outlying Islands",
+ "US": "United States",
+ "UY": "Uruguay",
+ "UZ": "Uzbekistan",
+ "VA": "Holy See",
+ "VC": "Saint Vincent and the Grenadines",
+ "VE": "Venezuela (Bolivarian Republic of)",
+ "VG": "Virgin Islands (British)",
+ "VI": "Virgin Islands (U.S.)",
+ "VN": "Viet Nam",
+ "VU": "Vanuatu",
+ "WF": "Wallis and Futuna",
+ "WS": "Samoa",
+ "YE": "Yemen",
+ "YT": "Mayotte",
+ "ZA": "South Africa",
+ "ZM": "Zambia",
+ "ZW": "Zimbabwe"
+}
\ No newline at end of file
diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json
new file mode 100644
index 000000000..5ada0eb7d
--- /dev/null
+++ b/src/assets/lang/en.json
@@ -0,0 +1,304 @@
+{
+ "assets.countries.AD": "Andorra",
+ "assets.countries.AE": "United Arab Emirates",
+ "assets.countries.AF": "Afghanistan",
+ "assets.countries.AG": "Antigua and Barbuda",
+ "assets.countries.AI": "Anguilla",
+ "assets.countries.AL": "Albania",
+ "assets.countries.AM": "Armenia",
+ "assets.countries.AO": "Angola",
+ "assets.countries.AQ": "Antarctica",
+ "assets.countries.AR": "Argentina",
+ "assets.countries.AS": "American Samoa",
+ "assets.countries.AT": "Austria",
+ "assets.countries.AU": "Australia",
+ "assets.countries.AW": "Aruba",
+ "assets.countries.AX": "Åland Islands",
+ "assets.countries.AZ": "Azerbaijan",
+ "assets.countries.BA": "Bosnia and Herzegovina",
+ "assets.countries.BB": "Barbados",
+ "assets.countries.BD": "Bangladesh",
+ "assets.countries.BE": "Belgium",
+ "assets.countries.BF": "Burkina Faso",
+ "assets.countries.BG": "Bulgaria",
+ "assets.countries.BH": "Bahrain",
+ "assets.countries.BI": "Burundi",
+ "assets.countries.BJ": "Benin",
+ "assets.countries.BL": "Saint Barthélemy",
+ "assets.countries.BM": "Bermuda",
+ "assets.countries.BN": "Brunei Darussalam",
+ "assets.countries.BO": "Bolivia (Plurinational State of)",
+ "assets.countries.BQ": "Bonaire, Sint Eustatius and Saba",
+ "assets.countries.BR": "Brazil",
+ "assets.countries.BS": "Bahamas",
+ "assets.countries.BT": "Bhutan",
+ "assets.countries.BV": "Bouvet Island",
+ "assets.countries.BW": "Botswana",
+ "assets.countries.BY": "Belarus",
+ "assets.countries.BZ": "Belize",
+ "assets.countries.CA": "Canada",
+ "assets.countries.CC": "Cocos (Keeling) Islands",
+ "assets.countries.CD": "Congo (the Democratic Republic of the)",
+ "assets.countries.CF": "Central African Republic",
+ "assets.countries.CG": "Congo",
+ "assets.countries.CH": "Switzerland",
+ "assets.countries.CI": "Côte d'Ivoire",
+ "assets.countries.CK": "Cook Islands",
+ "assets.countries.CL": "Chile",
+ "assets.countries.CM": "Cameroon",
+ "assets.countries.CN": "China",
+ "assets.countries.CO": "Colombia",
+ "assets.countries.CR": "Costa Rica",
+ "assets.countries.CU": "Cuba",
+ "assets.countries.CV": "Cabo Verde",
+ "assets.countries.CW": "Curaçao",
+ "assets.countries.CX": "Christmas Island",
+ "assets.countries.CY": "Cyprus",
+ "assets.countries.CZ": "Czechia",
+ "assets.countries.DE": "Germany",
+ "assets.countries.DJ": "Djibouti",
+ "assets.countries.DK": "Denmark",
+ "assets.countries.DM": "Dominica",
+ "assets.countries.DO": "Dominican Republic",
+ "assets.countries.DZ": "Algeria",
+ "assets.countries.EC": "Ecuador",
+ "assets.countries.EE": "Estonia",
+ "assets.countries.EG": "Egypt",
+ "assets.countries.EH": "Western Sahara",
+ "assets.countries.ER": "Eritrea",
+ "assets.countries.ES": "Spain",
+ "assets.countries.ET": "Ethiopia",
+ "assets.countries.FI": "Finland",
+ "assets.countries.FJ": "Fiji",
+ "assets.countries.FK": "Falkland Islands (Malvinas)",
+ "assets.countries.FM": "Micronesia (Federated States of)",
+ "assets.countries.FO": "Faroe Islands",
+ "assets.countries.FR": "France",
+ "assets.countries.GA": "Gabon",
+ "assets.countries.GB": "United Kingdom",
+ "assets.countries.GD": "Grenada",
+ "assets.countries.GE": "Georgia",
+ "assets.countries.GF": "French Guiana",
+ "assets.countries.GG": "Guernsey",
+ "assets.countries.GH": "Ghana",
+ "assets.countries.GI": "Gibraltar",
+ "assets.countries.GL": "Greenland",
+ "assets.countries.GM": "Gambia",
+ "assets.countries.GN": "Guinea",
+ "assets.countries.GP": "Guadeloupe",
+ "assets.countries.GQ": "Equatorial Guinea",
+ "assets.countries.GR": "Greece",
+ "assets.countries.GS": "South Georgia and the South Sandwich Islands",
+ "assets.countries.GT": "Guatemala",
+ "assets.countries.GU": "Guam",
+ "assets.countries.GW": "Guinea-Bissau",
+ "assets.countries.GY": "Guyana",
+ "assets.countries.HK": "Hong Kong",
+ "assets.countries.HM": "Heard Island and McDonald Islands",
+ "assets.countries.HN": "Honduras",
+ "assets.countries.HR": "Croatia",
+ "assets.countries.HT": "Haiti",
+ "assets.countries.HU": "Hungary",
+ "assets.countries.ID": "Indonesia",
+ "assets.countries.IE": "Ireland",
+ "assets.countries.IL": "Israel",
+ "assets.countries.IM": "Isle of Man",
+ "assets.countries.IN": "India",
+ "assets.countries.IO": "British Indian Ocean Territory",
+ "assets.countries.IQ": "Iraq",
+ "assets.countries.IR": "Iran (Islamic Republic of)",
+ "assets.countries.IS": "Iceland",
+ "assets.countries.IT": "Italy",
+ "assets.countries.JE": "Jersey",
+ "assets.countries.JM": "Jamaica",
+ "assets.countries.JO": "Jordan",
+ "assets.countries.JP": "Japan",
+ "assets.countries.KE": "Kenya",
+ "assets.countries.KG": "Kyrgyzstan",
+ "assets.countries.KH": "Cambodia",
+ "assets.countries.KI": "Kiribati",
+ "assets.countries.KM": "Comoros",
+ "assets.countries.KN": "Saint Kitts and Nevis",
+ "assets.countries.KP": "Korea (the Democratic People's Republic of)",
+ "assets.countries.KR": "Korea (the Republic of)",
+ "assets.countries.KW": "Kuwait",
+ "assets.countries.KY": "Cayman Islands",
+ "assets.countries.KZ": "Kazakhstan",
+ "assets.countries.LA": "Lao People's Democratic Republic",
+ "assets.countries.LB": "Lebanon",
+ "assets.countries.LC": "Saint Lucia",
+ "assets.countries.LI": "Liechtenstein",
+ "assets.countries.LK": "Sri Lanka",
+ "assets.countries.LR": "Liberia",
+ "assets.countries.LS": "Lesotho",
+ "assets.countries.LT": "Lithuania",
+ "assets.countries.LU": "Luxembourg",
+ "assets.countries.LV": "Latvia",
+ "assets.countries.LY": "Libya",
+ "assets.countries.MA": "Morocco",
+ "assets.countries.MC": "Monaco",
+ "assets.countries.MD": "Moldova (the Republic of)",
+ "assets.countries.ME": "Montenegro",
+ "assets.countries.MF": "Saint Martin (French part)",
+ "assets.countries.MG": "Madagascar",
+ "assets.countries.MH": "Marshall Islands",
+ "assets.countries.MK": "North Macedonia",
+ "assets.countries.ML": "Mali",
+ "assets.countries.MM": "Myanmar",
+ "assets.countries.MN": "Mongolia",
+ "assets.countries.MO": "Macao",
+ "assets.countries.MP": "Northern Mariana Islands",
+ "assets.countries.MQ": "Martinique",
+ "assets.countries.MR": "Mauritania",
+ "assets.countries.MS": "Montserrat",
+ "assets.countries.MT": "Malta",
+ "assets.countries.MU": "Mauritius",
+ "assets.countries.MV": "Maldives",
+ "assets.countries.MW": "Malawi",
+ "assets.countries.MX": "Mexico",
+ "assets.countries.MY": "Malaysia",
+ "assets.countries.MZ": "Mozambique",
+ "assets.countries.NA": "Namibia",
+ "assets.countries.NC": "New Caledonia",
+ "assets.countries.NE": "Niger",
+ "assets.countries.NF": "Norfolk Island",
+ "assets.countries.NG": "Nigeria",
+ "assets.countries.NI": "Nicaragua",
+ "assets.countries.NL": "Netherlands",
+ "assets.countries.NO": "Norway",
+ "assets.countries.NP": "Nepal",
+ "assets.countries.NR": "Nauru",
+ "assets.countries.NU": "Niue",
+ "assets.countries.NZ": "New Zealand",
+ "assets.countries.OM": "Oman",
+ "assets.countries.PA": "Panama",
+ "assets.countries.PE": "Peru",
+ "assets.countries.PF": "French Polynesia",
+ "assets.countries.PG": "Papua New Guinea",
+ "assets.countries.PH": "Philippines",
+ "assets.countries.PK": "Pakistan",
+ "assets.countries.PL": "Poland",
+ "assets.countries.PM": "Saint Pierre and Miquelon",
+ "assets.countries.PN": "Pitcairn",
+ "assets.countries.PR": "Puerto Rico",
+ "assets.countries.PS": "Palestine, State of",
+ "assets.countries.PT": "Portugal",
+ "assets.countries.PW": "Palau",
+ "assets.countries.PY": "Paraguay",
+ "assets.countries.QA": "Qatar",
+ "assets.countries.RE": "Réunion",
+ "assets.countries.RO": "Romania",
+ "assets.countries.RS": "Serbia",
+ "assets.countries.RU": "Russian Federation",
+ "assets.countries.RW": "Rwanda",
+ "assets.countries.SA": "Saudi Arabia",
+ "assets.countries.SB": "Solomon Islands",
+ "assets.countries.SC": "Seychelles",
+ "assets.countries.SD": "Sudan",
+ "assets.countries.SE": "Sweden",
+ "assets.countries.SG": "Singapore",
+ "assets.countries.SH": "Saint Helena, Ascension and Tristan da Cunha",
+ "assets.countries.SI": "Slovenia",
+ "assets.countries.SJ": "Svalbard and Jan Mayen",
+ "assets.countries.SK": "Slovakia",
+ "assets.countries.SL": "Sierra Leone",
+ "assets.countries.SM": "San Marino",
+ "assets.countries.SN": "Senegal",
+ "assets.countries.SO": "Somalia",
+ "assets.countries.SR": "Suriname",
+ "assets.countries.SS": "South Sudan",
+ "assets.countries.ST": "Sao Tome and Principe",
+ "assets.countries.SV": "El Salvador",
+ "assets.countries.SX": "Sint Maarten (Dutch part)",
+ "assets.countries.SY": "Syrian Arab Republic",
+ "assets.countries.SZ": "Eswatini",
+ "assets.countries.TC": "Turks and Caicos Islands",
+ "assets.countries.TD": "Chad",
+ "assets.countries.TF": "French Southern Territories",
+ "assets.countries.TG": "Togo",
+ "assets.countries.TH": "Thailand",
+ "assets.countries.TJ": "Tajikistan",
+ "assets.countries.TK": "Tokelau",
+ "assets.countries.TL": "Timor-Leste",
+ "assets.countries.TM": "Turkmenistan",
+ "assets.countries.TN": "Tunisia",
+ "assets.countries.TO": "Tonga",
+ "assets.countries.TR": "Turkey",
+ "assets.countries.TT": "Trinidad and Tobago",
+ "assets.countries.TV": "Tuvalu",
+ "assets.countries.TW": "Taiwan",
+ "assets.countries.TZ": "Tanzania, the United Republic of",
+ "assets.countries.UA": "Ukraine",
+ "assets.countries.UG": "Uganda",
+ "assets.countries.UM": "United States Minor Outlying Islands",
+ "assets.countries.US": "United States",
+ "assets.countries.UY": "Uruguay",
+ "assets.countries.UZ": "Uzbekistan",
+ "assets.countries.VA": "Holy See",
+ "assets.countries.VC": "Saint Vincent and the Grenadines",
+ "assets.countries.VE": "Venezuela (Bolivarian Republic of)",
+ "assets.countries.VG": "Virgin Islands (British)",
+ "assets.countries.VI": "Virgin Islands (U.S.)",
+ "assets.countries.VN": "Viet Nam",
+ "assets.countries.VU": "Vanuatu",
+ "assets.countries.WF": "Wallis and Futuna",
+ "assets.countries.WS": "Samoa",
+ "assets.countries.YE": "Yemen",
+ "assets.countries.YT": "Mayotte",
+ "assets.countries.ZA": "South Africa",
+ "assets.countries.ZM": "Zambia",
+ "assets.countries.ZW": "Zimbabwe",
+ "assets.mimetypes.application/epub_zip": "EPUB ebook",
+ "assets.mimetypes.application/msword": "Word document",
+ "assets.mimetypes.application/pdf": "PDF document",
+ "assets.mimetypes.application/vnd.moodle.backup": "Moodle backup",
+ "assets.mimetypes.application/vnd.ms-excel": "Excel spreadsheet",
+ "assets.mimetypes.application/vnd.ms-excel.sheet.macroEnabled.12": "Excel 2007 macro-enabled workbook",
+ "assets.mimetypes.application/vnd.ms-powerpoint": "Powerpoint presentation",
+ "assets.mimetypes.application/vnd.oasis.opendocument.spreadsheet": "OpenDocument Spreadsheet",
+ "assets.mimetypes.application/vnd.oasis.opendocument.spreadsheet-template": "OpenDocument Spreadsheet template",
+ "assets.mimetypes.application/vnd.oasis.opendocument.text": "OpenDocument Text document",
+ "assets.mimetypes.application/vnd.oasis.opendocument.text-template": "OpenDocument Text template",
+ "assets.mimetypes.application/vnd.oasis.opendocument.text-web": "OpenDocument Web page template",
+ "assets.mimetypes.application/vnd.openxmlformats-officedocument.presentationml.presentation": "Powerpoint 2007 presentation",
+ "assets.mimetypes.application/vnd.openxmlformats-officedocument.presentationml.slideshow": "Powerpoint 2007 slideshow",
+ "assets.mimetypes.application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel 2007 spreadsheet",
+ "assets.mimetypes.application/vnd.openxmlformats-officedocument.spreadsheetml.template": "Excel 2007 template",
+ "assets.mimetypes.application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word 2007 document",
+ "assets.mimetypes.application/x-iwork-keynote-sffkey": "iWork Keynote presentation",
+ "assets.mimetypes.application/x-iwork-numbers-sffnumbers": "iWork Numbers spreadsheet",
+ "assets.mimetypes.application/x-iwork-pages-sffpages": "iWork Pages document",
+ "assets.mimetypes.application/x-javascript": "JavaScript source",
+ "assets.mimetypes.application/x-mspublisher": "Publisher document",
+ "assets.mimetypes.application/x-shockwave-flash": "Flash animation",
+ "assets.mimetypes.application/xhtml_xml": "XHTML document",
+ "assets.mimetypes.archive": "Archive ({{$a.EXT}})",
+ "assets.mimetypes.audio": "Audio file ({{$a.EXT}})",
+ "assets.mimetypes.default": "{{$a.mimetype}}",
+ "assets.mimetypes.document/unknown": "File",
+ "assets.mimetypes.group:archive": "Archive files",
+ "assets.mimetypes.group:audio": "Audio files",
+ "assets.mimetypes.group:document": "Document files",
+ "assets.mimetypes.group:html_audio": "Audio files natively supported by browsers",
+ "assets.mimetypes.group:html_track": "HTML track files",
+ "assets.mimetypes.group:html_video": "Video files natively supported by browsers",
+ "assets.mimetypes.group:image": "Image files",
+ "assets.mimetypes.group:presentation": "Presentation files",
+ "assets.mimetypes.group:sourcecode": "Source code",
+ "assets.mimetypes.group:spreadsheet": "Spreadsheet files",
+ "assets.mimetypes.group:video": "Video files",
+ "assets.mimetypes.group:web_audio": "Audio files used on the web",
+ "assets.mimetypes.group:web_file": "Web files",
+ "assets.mimetypes.group:web_image": "Image files used on the web",
+ "assets.mimetypes.group:web_video": "Video files used on the web",
+ "assets.mimetypes.image": "Image ({{$a.MIMETYPE2}})",
+ "assets.mimetypes.image/vnd.microsoft.icon": "Windows icon",
+ "assets.mimetypes.text/css": "Cascading Style-Sheet",
+ "assets.mimetypes.text/csv": "Comma-separated values",
+ "assets.mimetypes.text/html": "HTML document",
+ "assets.mimetypes.text/plain": "Text file",
+ "assets.mimetypes.text/rtf": "RTF document",
+ "assets.mimetypes.text/vtt": "Web Video Text Track",
+ "assets.mimetypes.video": "Video file ({{$a.EXT}})",
+ "core.login.yourenteredsite": "Connect to your site"
+}
\ No newline at end of file
diff --git a/src/assets/mimetypes/en.json b/src/assets/mimetypes/en.json
new file mode 100644
index 000000000..f94347be4
--- /dev/null
+++ b/src/assets/mimetypes/en.json
@@ -0,0 +1,54 @@
+{
+ "application/epub_zip": "EPUB ebook",
+ "application/msword": "Word document",
+ "application/pdf": "PDF document",
+ "application/vnd.moodle.backup": "Moodle backup",
+ "application/vnd.ms-excel": "Excel spreadsheet",
+ "application/vnd.ms-excel.sheet.macroEnabled.12": "Excel 2007 macro-enabled workbook",
+ "application/vnd.ms-powerpoint": "Powerpoint presentation",
+ "application/vnd.oasis.opendocument.spreadsheet": "OpenDocument Spreadsheet",
+ "application/vnd.oasis.opendocument.spreadsheet-template": "OpenDocument Spreadsheet template",
+ "application/vnd.oasis.opendocument.text": "OpenDocument Text document",
+ "application/vnd.oasis.opendocument.text-template": "OpenDocument Text template",
+ "application/vnd.oasis.opendocument.text-web": "OpenDocument Web page template",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "Powerpoint 2007 presentation",
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow": "Powerpoint 2007 slideshow",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel 2007 spreadsheet",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.template": "Excel 2007 template",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word 2007 document",
+ "application/x-iwork-keynote-sffkey": "iWork Keynote presentation",
+ "application/x-iwork-numbers-sffnumbers": "iWork Numbers spreadsheet",
+ "application/x-iwork-pages-sffpages": "iWork Pages document",
+ "application/x-javascript": "JavaScript source",
+ "application/x-mspublisher": "Publisher document",
+ "application/x-shockwave-flash": "Flash animation",
+ "application/xhtml_xml": "XHTML document",
+ "archive": "Archive ({{$a.EXT}})",
+ "audio": "Audio file ({{$a.EXT}})",
+ "default": "{{$a.mimetype}}",
+ "document/unknown": "File",
+ "group:archive": "Archive files",
+ "group:audio": "Audio files",
+ "group:document": "Document files",
+ "group:html_audio": "Audio files natively supported by browsers",
+ "group:html_track": "HTML track files",
+ "group:html_video": "Video files natively supported by browsers",
+ "group:image": "Image files",
+ "group:presentation": "Presentation files",
+ "group:sourcecode": "Source code",
+ "group:spreadsheet": "Spreadsheet files",
+ "group:video": "Video files",
+ "group:web_audio": "Audio files used on the web",
+ "group:web_file": "Web files",
+ "group:web_image": "Image files used on the web",
+ "group:web_video": "Video files used on the web",
+ "image": "Image ({{$a.MIMETYPE2}})",
+ "image/vnd.microsoft.icon": "Windows icon",
+ "text/css": "Cascading Style-Sheet",
+ "text/csv": "Comma-separated values",
+ "text/html": "HTML document",
+ "text/plain": "Text file",
+ "text/rtf": "RTF document",
+ "text/vtt": "Web Video Text Track",
+ "video": "Video file ({{$a.EXT}})"
+}
\ No newline at end of file