diff options
Diffstat (limited to 'core/src/OC')
31 files changed, 3169 insertions, 0 deletions
diff --git a/core/src/OC/admin.js b/core/src/OC/admin.js new file mode 100644 index 00000000000..d29e4cf676b --- /dev/null +++ b/core/src/OC/admin.js @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const isAdmin = !!window._oc_isadmin + +/** + * Returns whether the current user is an administrator + * + * @return {boolean} true if the user is an admin, false otherwise + * @since 9.0.0 + */ +export const isUserAdmin = () => isAdmin diff --git a/core/src/OC/appconfig.js b/core/src/OC/appconfig.js new file mode 100644 index 00000000000..350ffc3f21c --- /dev/null +++ b/core/src/OC/appconfig.js @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable */ + import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig.js' + +export const appConfig = window.oc_appconfig || {} + +/** + * @namespace + * @deprecated 16.0.0 Use OCP.AppConfig instead + */ +export const AppConfig = { + /** + * @deprecated Use OCP.AppConfig.getValue() instead + */ + getValue: function(app, key, defaultValue, callback) { + getValue(app, key, defaultValue, { + success: callback + }) + }, + + /** + * @deprecated Use OCP.AppConfig.setValue() instead + */ + setValue: function(app, key, value) { + setValue(app, key, value) + }, + + /** + * @deprecated Use OCP.AppConfig.getApps() instead + */ + getApps: function(callback) { + getApps({ + success: callback + }) + }, + + /** + * @deprecated Use OCP.AppConfig.getKeys() instead + */ + getKeys: function(app, callback) { + getKeys(app, { + success: callback + }) + }, + + /** + * @deprecated Use OCP.AppConfig.deleteKey() instead + */ + deleteKey: function(app, key) { + deleteKey(app, key) + } + +} diff --git a/core/src/OC/apps.js b/core/src/OC/apps.js new file mode 100644 index 00000000000..dec2b94bfbb --- /dev/null +++ b/core/src/OC/apps.js @@ -0,0 +1,120 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import $ from 'jquery' + +let dynamicSlideToggleEnabled = false + +const Apps = { + enableDynamicSlideToggle() { + dynamicSlideToggleEnabled = true + }, +} + +/** + * Shows the #app-sidebar and add .with-app-sidebar to subsequent siblings + * + * @param {object} [$el] sidebar element to show, defaults to $('#app-sidebar') + */ +Apps.showAppSidebar = function($el) { + const $appSidebar = $el || $('#app-sidebar') + $appSidebar.removeClass('disappear').show() + $('#app-content').trigger(new $.Event('appresized')) +} + +/** + * Shows the #app-sidebar and removes .with-app-sidebar from subsequent + * siblings + * + * @param {object} [$el] sidebar element to hide, defaults to $('#app-sidebar') + */ +Apps.hideAppSidebar = function($el) { + const $appSidebar = $el || $('#app-sidebar') + $appSidebar.hide().addClass('disappear') + $('#app-content').trigger(new $.Event('appresized')) +} + +/** + * Provides a way to slide down a target area through a button and slide it + * up if the user clicks somewhere else. Used for the news app settings and + * add new field. + * + * Usage: + * <button data-apps-slide-toggle=".slide-area">slide</button> + * <div class=".slide-area" class="hidden">I'm sliding up</div> + */ +export const registerAppsSlideToggle = () => { + let buttons = $('[data-apps-slide-toggle]') + + if (buttons.length === 0) { + $('#app-navigation').addClass('without-app-settings') + } + + $(document).click(function(event) { + + if (dynamicSlideToggleEnabled) { + buttons = $('[data-apps-slide-toggle]') + } + + buttons.each(function(index, button) { + + const areaSelector = $(button).data('apps-slide-toggle') + const area = $(areaSelector) + + /** + * + */ + function hideArea() { + area.slideUp(OC.menuSpeed * 4, function() { + area.trigger(new $.Event('hide')) + }) + area.removeClass('opened') + $(button).removeClass('opened') + $(button).attr('aria-expanded', 'false') + } + + /** + * + */ + function showArea() { + area.slideDown(OC.menuSpeed * 4, function() { + area.trigger(new $.Event('show')) + }) + area.addClass('opened') + $(button).addClass('opened') + $(button).attr('aria-expanded', 'true') + const input = $(areaSelector + ' [autofocus]') + if (input.length === 1) { + input.focus() + } + } + + // do nothing if the area is animated + if (!area.is(':animated')) { + + // button toggles the area + if ($(button).is($(event.target).closest('[data-apps-slide-toggle]'))) { + if (area.is(':visible')) { + hideArea() + } else { + showArea() + } + + // all other areas that have not been clicked but are open + // should be slid up + } else { + const closest = $(event.target).closest(areaSelector) + if (area.is(':visible') && closest[0] !== area[0]) { + hideArea() + } + } + } + }) + + }) +} + +export default Apps diff --git a/core/src/OC/appswebroots.js b/core/src/OC/appswebroots.js new file mode 100644 index 00000000000..debbd2084bf --- /dev/null +++ b/core/src/OC/appswebroots.js @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const appswebroots = (window._oc_appswebroots !== undefined) ? window._oc_appswebroots : false + +export default appswebroots diff --git a/core/src/OC/backbone-webdav.js b/core/src/OC/backbone-webdav.js new file mode 100644 index 00000000000..318c50e8ee5 --- /dev/null +++ b/core/src/OC/backbone-webdav.js @@ -0,0 +1,308 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable */ +import _ from 'underscore' +import { dav } from 'davclient.js' + +const methodMap = { + create: 'POST', + update: 'PROPPATCH', + patch: 'PROPPATCH', + delete: 'DELETE', + read: 'PROPFIND' +} + +// Throw an error when a URL is needed, and none is supplied. +function urlError() { + throw new Error('A "url" property or function must be specified') +} + +/** + * Convert a single propfind result to JSON + * + * @param {Object} result + * @param {Object} davProperties properties mapping + */ +function parsePropFindResult(result, davProperties) { + if (_.isArray(result)) { + return _.map(result, function(subResult) { + return parsePropFindResult(subResult, davProperties) + }) + } + var props = { + href: result.href + } + + _.each(result.propStat, function(propStat) { + if (propStat.status !== 'HTTP/1.1 200 OK') { + return + } + + for (var key in propStat.properties) { + var propKey = key + if (key in davProperties) { + propKey = davProperties[key] + } + props[propKey] = propStat.properties[key] + } + }) + + if (!props.id) { + // parse id from href + props.id = parseIdFromLocation(props.href) + } + + return props +} + +/** + * Parse ID from location + * + * @param {string} url url + * @returns {string} id + */ +function parseIdFromLocation(url) { + var queryPos = url.indexOf('?') + if (queryPos > 0) { + url = url.substr(0, queryPos) + } + + var parts = url.split('/') + var result + do { + result = parts[parts.length - 1] + parts.pop() + // note: first result can be empty when there is a trailing slash, + // so we take the part before that + } while (!result && parts.length > 0) + + return result +} + +function isSuccessStatus(status) { + return status >= 200 && status <= 299 +} + +function convertModelAttributesToDavProperties(attrs, davProperties) { + var props = {} + var key + for (key in attrs) { + var changedProp = davProperties[key] + var value = attrs[key] + if (!changedProp) { + console.warn('No matching DAV property for property "' + key) + changedProp = key + } + if (_.isBoolean(value) || _.isNumber(value)) { + // convert to string + value = '' + value + } + props[changedProp] = value + } + return props +} + +function callPropFind(client, options, model, headers) { + return client.propFind( + options.url, + _.values(options.davProperties) || [], + options.depth, + headers + ).then(function(response) { + if (isSuccessStatus(response.status)) { + if (_.isFunction(options.success)) { + var propsMapping = _.invert(options.davProperties) + var results = parsePropFindResult(response.body, propsMapping) + if (options.depth > 0) { + // discard root entry + results.shift() + } + + options.success(results) + + } + } else if (_.isFunction(options.error)) { + options.error(response) + } + }) +} + +function callPropPatch(client, options, model, headers) { + return client.propPatch( + options.url, + convertModelAttributesToDavProperties(model.changed, options.davProperties), + headers + ).then(function(result) { + if (isSuccessStatus(result.status)) { + if (_.isFunction(options.success)) { + // pass the object's own values because the server + // does not return the updated model + options.success(model.toJSON()) + } + } else if (_.isFunction(options.error)) { + options.error(result) + } + }) + +} + +function callMkCol(client, options, model, headers) { + // call MKCOL without data, followed by PROPPATCH + return client.request( + options.type, + options.url, + headers, + null + ).then(function(result) { + if (!isSuccessStatus(result.status)) { + if (_.isFunction(options.error)) { + options.error(result) + } + return + } + + callPropPatch(client, options, model, headers) + }) +} + +function callMethod(client, options, model, headers) { + headers['Content-Type'] = 'application/json' + return client.request( + options.type, + options.url, + headers, + options.data + ).then(function(result) { + if (!isSuccessStatus(result.status)) { + if (_.isFunction(options.error)) { + options.error(result) + } + return + } + + if (_.isFunction(options.success)) { + if (options.type === 'PUT' || options.type === 'POST' || options.type === 'MKCOL') { + // pass the object's own values because the server + // does not return anything + var responseJson = result.body || model.toJSON() + var locationHeader = result.xhr.getResponseHeader('Content-Location') + if (options.type === 'POST' && locationHeader) { + responseJson.id = parseIdFromLocation(locationHeader) + } + options.success(responseJson) + return + } + // if multi-status, parse + if (result.status === 207) { + var propsMapping = _.invert(options.davProperties) + options.success(parsePropFindResult(result.body, propsMapping)) + } else { + options.success(result.body) + } + } + }) +} + +export const davCall = (options, model) => { + var client = new dav.Client({ + baseUrl: options.url, + xmlNamespaces: _.extend({ + 'DAV:': 'd', + 'http://owncloud.org/ns': 'oc' + }, options.xmlNamespaces || {}) + }) + client.resolveUrl = function() { + return options.url + } + var headers = _.extend({ + 'X-Requested-With': 'XMLHttpRequest', + 'requesttoken': OC.requestToken + }, options.headers) + if (options.type === 'PROPFIND') { + return callPropFind(client, options, model, headers) + } else if (options.type === 'PROPPATCH') { + return callPropPatch(client, options, model, headers) + } else if (options.type === 'MKCOL') { + return callMkCol(client, options, model, headers) + } else { + return callMethod(client, options, model, headers) + } +} + +/** + * DAV transport + */ +export const davSync = Backbone => (method, model, options) => { + var params = { type: methodMap[method] || method } + var isCollection = (model instanceof Backbone.Collection) + + if (method === 'update') { + // if a model has an inner collection, it must define an + // attribute "hasInnerCollection" that evaluates to true + if (model.hasInnerCollection) { + // if the model itself is a Webdav collection, use MKCOL + params.type = 'MKCOL' + } else if (model.usePUT || (model.collection && model.collection.usePUT)) { + // use PUT instead of PROPPATCH + params.type = 'PUT' + } + } + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError() + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.data = JSON.stringify(options.attrs || model.toJSON(options)) + } + + // Don't process data on a non-GET request. + if (params.type !== 'PROPFIND') { + params.processData = false + } + + if (params.type === 'PROPFIND' || params.type === 'PROPPATCH') { + var davProperties = model.davProperties + if (!davProperties && model.model) { + // use dav properties from model in case of collection + davProperties = model.model.prototype.davProperties + } + if (davProperties) { + if (_.isFunction(davProperties)) { + params.davProperties = davProperties.call(model) + } else { + params.davProperties = davProperties + } + } + + params.davProperties = _.extend(params.davProperties || {}, options.davProperties) + + if (_.isUndefined(options.depth)) { + if (isCollection) { + options.depth = 1 + } else { + options.depth = 0 + } + } + } + + // Pass along `textStatus` and `errorThrown` from jQuery. + var error = options.error + options.error = function(xhr, textStatus, errorThrown) { + options.textStatus = textStatus + options.errorThrown = errorThrown + if (error) { + error.call(options.context, xhr, textStatus, errorThrown) + } + } + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.davCall(_.extend(params, options), model) + model.trigger('request', model, xhr, options) + return xhr +} diff --git a/core/src/OC/backbone.js b/core/src/OC/backbone.js new file mode 100644 index 00000000000..08520e278f6 --- /dev/null +++ b/core/src/OC/backbone.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import VendorBackbone from 'backbone' +import { davCall, davSync } from './backbone-webdav.js' + +const Backbone = VendorBackbone.noConflict() + +// Patch Backbone for DAV +Object.assign(Backbone, { + davCall, + davSync: davSync(Backbone), +}) + +export default Backbone diff --git a/core/src/OC/capabilities.js b/core/src/OC/capabilities.js new file mode 100644 index 00000000000..10623229625 --- /dev/null +++ b/core/src/OC/capabilities.js @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities' + +/** + * Returns the capabilities + * + * @return {Array} capabilities + * + * @since 14.0.0 + */ +export const getCapabilities = () => { + OC.debug && console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities') + return realGetCapabilities() +} diff --git a/core/src/OC/config.js b/core/src/OC/config.js new file mode 100644 index 00000000000..c47df61f6e6 --- /dev/null +++ b/core/src/OC/config.js @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const config = window._oc_config || {} + +export default config diff --git a/core/src/OC/constants.js b/core/src/OC/constants.js new file mode 100644 index 00000000000..5298107e94d --- /dev/null +++ b/core/src/OC/constants.js @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const coreApps = ['', 'admin', 'log', 'core/search', 'core', '3rdparty'] +export const menuSpeed = 50 +export const PERMISSION_NONE = 0 +export const PERMISSION_CREATE = 4 +export const PERMISSION_READ = 1 +export const PERMISSION_UPDATE = 2 +export const PERMISSION_DELETE = 8 +export const PERMISSION_SHARE = 16 +export const PERMISSION_ALL = 31 +export const TAG_FAVORITE = '_$!<Favorite>!$_' diff --git a/core/src/OC/currentuser.js b/core/src/OC/currentuser.js new file mode 100644 index 00000000000..a022698eab0 --- /dev/null +++ b/core/src/OC/currentuser.js @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const rawUid = document + .getElementsByTagName('head')[0] + .getAttribute('data-user') +const displayName = document + .getElementsByTagName('head')[0] + .getAttribute('data-user-displayname') + +export const currentUser = rawUid !== undefined ? rawUid : false + +export const getCurrentUser = () => { + return { + uid: currentUser, + displayName, + } +} diff --git a/core/src/OC/debug.js b/core/src/OC/debug.js new file mode 100644 index 00000000000..52a9ef28145 --- /dev/null +++ b/core/src/OC/debug.js @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const base = window._oc_debug + +export const debug = base diff --git a/core/src/OC/dialogs.js b/core/src/OC/dialogs.js new file mode 100644 index 00000000000..5c6934e67a2 --- /dev/null +++ b/core/src/OC/dialogs.js @@ -0,0 +1,789 @@ +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable */ +import _ from 'underscore' +import $ from 'jquery' + +import IconMove from '@mdi/svg/svg/folder-move.svg?raw' +import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw' + +import OC from './index.js' +import { DialogBuilder, FilePickerType, getFilePickerBuilder, spawnDialog } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { basename } from 'path' +import { defineAsyncComponent } from 'vue' + +/** + * this class to ease the usage of jquery dialogs + */ +const Dialogs = { + // dialog button types + /** @deprecated use `@nextcloud/dialogs` */ + YES_NO_BUTTONS: 70, + /** @deprecated use `@nextcloud/dialogs` */ + OK_BUTTONS: 71, + + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ + FILEPICKER_TYPE_CHOOSE: 1, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ + FILEPICKER_TYPE_MOVE: 2, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ + FILEPICKER_TYPE_COPY: 3, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ + FILEPICKER_TYPE_COPY_MOVE: 4, + /** @deprecated use FilePickerType from `@nextcloud/dialogs` */ + FILEPICKER_TYPE_CUSTOM: 5, + + /** + * displays alert dialog + * @param {string} text content of dialog + * @param {string} title dialog title + * @param {function} callback which will be triggered when user presses OK + * @param {boolean} [modal] make the dialog modal + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + alert: function(text, title, callback, modal) { + this.message( + text, + title, + 'alert', + Dialogs.OK_BUTTON, + callback, + modal + ) + }, + + /** + * displays info dialog + * @param {string} text content of dialog + * @param {string} title dialog title + * @param {function} callback which will be triggered when user presses OK + * @param {boolean} [modal] make the dialog modal + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + info: function(text, title, callback, modal) { + this.message(text, title, 'info', Dialogs.OK_BUTTON, callback, modal) + }, + + /** + * displays confirmation dialog + * @param {string} text content of dialog + * @param {string} title dialog title + * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) + * @param {boolean} [modal] make the dialog modal + * @returns {Promise} + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + confirm: function(text, title, callback, modal) { + return this.message( + text, + title, + 'notice', + Dialogs.YES_NO_BUTTONS, + callback, + modal + ) + }, + /** + * displays confirmation dialog + * @param {string} text content of dialog + * @param {string} title dialog title + * @param {(number|{type: number, confirm: string, cancel: string, confirmClasses: string})} buttons text content of buttons + * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) + * @param {boolean} [modal] make the dialog modal + * @returns {Promise} + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + confirmDestructive: function(text, title, buttons = Dialogs.OK_BUTTONS, callback = () => {}, modal) { + return (new DialogBuilder()) + .setName(title) + .setText(text) + .setButtons( + buttons === Dialogs.OK_BUTTONS + ? [ + { + label: t('core', 'Yes'), + type: 'error', + callback: () => { + callback.clicked = true + callback(true) + }, + } + ] + : Dialogs._getLegacyButtons(buttons, callback) + ) + .build() + .show() + .then(() => { + if (!callback.clicked) { + callback(false) + } + }) + }, + /** + * displays confirmation dialog + * @param {string} text content of dialog + * @param {string} title dialog title + * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) + * @param {boolean} [modal] make the dialog modal + * @returns {Promise} + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + confirmHtml: function(text, title, callback, modal) { + return (new DialogBuilder()) + .setName(title) + .setText('') + .setButtons([ + { + label: t('core', 'No'), + callback: () => {}, + }, + { + label: t('core', 'Yes'), + type: 'primary', + callback: () => { + callback.clicked = true + callback(true) + }, + }, + ]) + .build() + .setHTML(text) + .show() + .then(() => { + if (!callback.clicked) { + callback(false) + } + }) + }, + /** + * displays prompt dialog + * @param {string} text content of dialog + * @param {string} title dialog title + * @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively) + * @param {boolean} [modal] make the dialog modal + * @param {string} name name of the input field + * @param {boolean} password whether the input should be a password input + * @returns {Promise} + * + * @deprecated Use NcDialog from `@nextcloud/vue` instead + */ + prompt: function(text, title, callback, modal, name, password) { + return new Promise((resolve) => { + spawnDialog( + defineAsyncComponent(() => import('../components/LegacyDialogPrompt.vue')), + { + text, + name: title, + callback, + inputName: name, + isPassword: !!password + }, + (...args) => { + callback(...args) + resolve() + }, + ) + }) + }, + + /** + * Legacy wrapper to the new Vue based filepicker from `@nextcloud/dialogs` + * + * Prefer to use the Vue filepicker directly instead. + * + * In order to pick several types of mime types they need to be passed as an + * array of strings. + * + * When no mime type filter is given only files can be selected. In order to + * be able to select both files and folders "['*', 'httpd/unix-directory']" + * should be used instead. + * + * @param {string} title dialog title + * @param {Function} callback which will be triggered when user presses Choose + * @param {boolean} [multiselect] whether it should be possible to select multiple files + * @param {string[]} [mimetype] mimetype to filter by - directories will always be included + * @param {boolean} [_modal] do not use + * @param {string} [type] Type of file picker : Choose, copy, move, copy and move + * @param {string} [path] path to the folder that the the file can be picket from + * @param {object} [options] additonal options that need to be set + * @param {Function} [options.filter] filter function for advanced filtering + * @param {boolean} [options.allowDirectoryChooser] Allow to select directories + * @deprecated since 27.1.0 use the filepicker from `@nextcloud/dialogs` instead + */ + filepicker(title, callback, multiselect = false, mimetype = undefined, _modal = undefined, type = FilePickerType.Choose, path = undefined, options = undefined) { + + /** + * Create legacy callback wrapper to support old filepicker syntax + * @param fn The original callback + * @param type The file picker type which was used to pick the file(s) + */ + const legacyCallback = (fn, type) => { + const getPath = (node) => { + const root = node?.root || '' + let path = node?.path || '' + // TODO: Fix this in @nextcloud/files + if (path.startsWith(root)) { + path = path.slice(root.length) || '/' + } + return path + } + + if (multiselect) { + return (nodes) => fn(nodes.map(getPath), type) + } else { + return (nodes) => fn(getPath(nodes[0]), type) + } + } + + /** + * Coverting a Node into a legacy file info to support the OC.dialogs.filepicker filter function + * @param node The node to convert + */ + const nodeToLegacyFile = (node) => ({ + id: node.fileid || null, + path: node.path, + mimetype: node.mime || null, + mtime: node.mtime?.getTime() || null, + permissions: node.permissions, + name: node.attributes?.displayName || node.basename, + etag: node.attributes?.etag || null, + hasPreview: node.attributes?.hasPreview || null, + mountType: node.attributes?.mountType || null, + quotaAvailableBytes: node.attributes?.quotaAvailableBytes || null, + icon: null, + sharePermissions: null, + }) + + const builder = getFilePickerBuilder(title) + + // Setup buttons + if (type === this.FILEPICKER_TYPE_CUSTOM) { + (options.buttons || []).forEach((button) => { + builder.addButton({ + callback: legacyCallback(callback, button.type), + label: button.text, + type: button.defaultButton ? 'primary' : 'secondary', + }) + }) + } else { + builder.setButtonFactory((nodes, path) => { + const buttons = [] + const [node] = nodes + const target = node?.displayname || node?.basename || basename(path) + + if (type === FilePickerType.Choose) { + buttons.push({ + callback: legacyCallback(callback, FilePickerType.Choose), + label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'), + type: 'primary', + }) + } + if (type === FilePickerType.CopyMove || type === FilePickerType.Copy) { + buttons.push({ + callback: legacyCallback(callback, FilePickerType.Copy), + label: target ? t('core', 'Copy to {target}', { target }) : t('core', 'Copy'), + type: 'primary', + icon: IconCopy, + }) + } + if (type === FilePickerType.Move || type === FilePickerType.CopyMove) { + buttons.push({ + callback: legacyCallback(callback, FilePickerType.Move), + label: target ? t('core', 'Move to {target}', { target }) : t('core', 'Move'), + type: type === FilePickerType.Move ? 'primary' : 'secondary', + icon: IconMove, + }) + } + return buttons + }) + } + + if (mimetype) { + builder.setMimeTypeFilter(typeof mimetype === 'string' ? [mimetype] : (mimetype || [])) + } + if (typeof options?.filter === 'function') { + builder.setFilter((node) => options.filter(nodeToLegacyFile(node))) + } + builder.allowDirectories(options?.allowDirectoryChooser === true || mimetype?.includes('httpd/unix-directory') || false) + .setMultiSelect(multiselect) + .startAt(path) + .build() + .pick() + }, + + /** + * Displays raw dialog + * You better use a wrapper instead ... + * + * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog + */ + message: function(content, title, dialogType, buttons, callback = () => {}, modal, allowHtml) { + const builder = (new DialogBuilder()) + .setName(title) + .setText(allowHtml ? '' : content) + .setButtons(Dialogs._getLegacyButtons(buttons, callback)) + + switch (dialogType) { + case 'alert': + builder.setSeverity('warning') + break + case 'notice': + builder.setSeverity('info') + break + default: + break + } + + const dialog = builder.build() + + if (allowHtml) { + dialog.setHTML(content) + } + + return dialog.show().then(() => { + if(!callback._clicked) { + callback(false) + } + }) + }, + + /** + * Helper for legacy API + * @deprecated + */ + _getLegacyButtons(buttons, callback) { + const buttonList = [] + + switch (typeof buttons === 'object' ? buttons.type : buttons) { + case Dialogs.YES_NO_BUTTONS: + buttonList.push({ + label: buttons?.cancel ?? t('core', 'No'), + callback: () => { + callback._clicked = true + callback(false) + }, + }) + buttonList.push({ + label: buttons?.confirm ?? t('core', 'Yes'), + type: 'primary', + callback: () => { + callback._clicked = true + callback(true) + }, + }) + break + case Dialogs.OK_BUTTONS: + buttonList.push({ + label: buttons?.confirm ?? t('core', 'OK'), + type: 'primary', + callback: () => { + callback._clicked = true + callback(true) + }, + }) + break + default: + console.error('Invalid call to OC.dialogs') + break + } + return buttonList + }, + + _fileexistsshown: false, + /** + * Displays file exists dialog + * @param {object} data upload object + * @param {object} original file with name, size and mtime + * @param {object} replacement file with name, size and mtime + * @param {object} controller with onCancel, onSkip, onReplace and onRename methods + * @returns {Promise} jquery promise that resolves after the dialog template was loaded + * + * @deprecated 29.0.0 Use openConflictPicker from the @nextcloud/upload package instead + */ + fileexists: function(data, original, replacement, controller) { + var self = this + var dialogDeferred = new $.Deferred() + + var getCroppedPreview = function(file) { + var deferred = new $.Deferred() + // Only process image files. + var type = file.type && file.type.split('/').shift() + if (window.FileReader && type === 'image') { + var reader = new FileReader() + reader.onload = function(e) { + var blob = new Blob([e.target.result]) + window.URL = window.URL || window.webkitURL + var originalUrl = window.URL.createObjectURL(blob) + var image = new Image() + image.src = originalUrl + image.onload = function() { + var url = crop(image) + deferred.resolve(url) + } + } + reader.readAsArrayBuffer(file) + } else { + deferred.reject() + } + return deferred + } + + var crop = function(img) { + var canvas = document.createElement('canvas') + var targetSize = 96 + var width = img.width + var height = img.height + var x; var y; var size + + // Calculate the width and height, constraining the proportions + if (width > height) { + y = 0 + x = (width - height) / 2 + } else { + y = (height - width) / 2 + x = 0 + } + size = Math.min(width, height) + + // Set canvas size to the cropped area + canvas.width = size + canvas.height = size + var ctx = canvas.getContext('2d') + ctx.drawImage(img, x, y, size, size, 0, 0, size, size) + + // Resize the canvas to match the destination (right size uses 96px) + resampleHermite(canvas, size, size, targetSize, targetSize) + + return canvas.toDataURL('image/png', 0.7) + } + + /** + * Fast image resize/resample using Hermite filter with JavaScript. + * + * @author: ViliusL + * + * @param {*} canvas + * @param {number} W + * @param {number} H + * @param {number} W2 + * @param {number} H2 + */ + var resampleHermite = function(canvas, W, H, W2, H2) { + W2 = Math.round(W2) + H2 = Math.round(H2) + var img = canvas.getContext('2d').getImageData(0, 0, W, H) + var img2 = canvas.getContext('2d').getImageData(0, 0, W2, H2) + var data = img.data + var data2 = img2.data + var ratio_w = W / W2 + var ratio_h = H / H2 + var ratio_w_half = Math.ceil(ratio_w / 2) + var ratio_h_half = Math.ceil(ratio_h / 2) + + for (var j = 0; j < H2; j++) { + for (var i = 0; i < W2; i++) { + var x2 = (i + j * W2) * 4 + var weight = 0 + var weights = 0 + var weights_alpha = 0 + var gx_r = 0 + var gx_g = 0 + var gx_b = 0 + var gx_a = 0 + var center_y = (j + 0.5) * ratio_h + for (var yy = Math.floor(j * ratio_h); yy < (j + 1) * ratio_h; yy++) { + var dy = Math.abs(center_y - (yy + 0.5)) / ratio_h_half + var center_x = (i + 0.5) * ratio_w + var w0 = dy * dy // pre-calc part of w + for (var xx = Math.floor(i * ratio_w); xx < (i + 1) * ratio_w; xx++) { + var dx = Math.abs(center_x - (xx + 0.5)) / ratio_w_half + var w = Math.sqrt(w0 + dx * dx) + if (w >= -1 && w <= 1) { + // hermite filter + weight = 2 * w * w * w - 3 * w * w + 1 + if (weight > 0) { + dx = 4 * (xx + yy * W) + // alpha + gx_a += weight * data[dx + 3] + weights_alpha += weight + // colors + if (data[dx + 3] < 255) { weight = weight * data[dx + 3] / 250 } + gx_r += weight * data[dx] + gx_g += weight * data[dx + 1] + gx_b += weight * data[dx + 2] + weights += weight + } + } + } + } + data2[x2] = gx_r / weights + data2[x2 + 1] = gx_g / weights + data2[x2 + 2] = gx_b / weights + data2[x2 + 3] = gx_a / weights_alpha + } + } + canvas.getContext('2d').clearRect(0, 0, Math.max(W, W2), Math.max(H, H2)) + canvas.width = W2 + canvas.height = H2 + canvas.getContext('2d').putImageData(img2, 0, 0) + } + + var addConflict = function($conflicts, original, replacement) { + + var $conflict = $conflicts.find('.template').clone().removeClass('template').addClass('conflict') + var $originalDiv = $conflict.find('.original') + var $replacementDiv = $conflict.find('.replacement') + + $conflict.data('data', data) + + $conflict.find('.filename').text(original.name) + $originalDiv.find('.size').text(OC.Util.humanFileSize(original.size)) + $originalDiv.find('.mtime').text(OC.Util.formatDate(original.mtime)) + // ie sucks + if (replacement.size && replacement.lastModified) { + $replacementDiv.find('.size').text(OC.Util.humanFileSize(replacement.size)) + $replacementDiv.find('.mtime').text(OC.Util.formatDate(replacement.lastModified)) + } + var path = original.directory + '/' + original.name + var urlSpec = { + file: path, + x: 96, + y: 96, + c: original.etag, + forceIcon: 0 + } + var previewpath = Files.generatePreviewUrl(urlSpec) + // Escaping single quotes + previewpath = previewpath.replace(/'/g, '%27') + $originalDiv.find('.icon').css({ 'background-image': "url('" + previewpath + "')" }) + getCroppedPreview(replacement).then( + function(path) { + $replacementDiv.find('.icon').css('background-image', 'url(' + path + ')') + }, function() { + path = OC.MimeType.getIconUrl(replacement.type) + $replacementDiv.find('.icon').css('background-image', 'url(' + path + ')') + } + ) + // connect checkboxes with labels + var checkboxId = $conflicts.find('.conflict').length + $originalDiv.find('input:checkbox').attr('id', 'checkbox_original_' + checkboxId) + $replacementDiv.find('input:checkbox').attr('id', 'checkbox_replacement_' + checkboxId) + + $conflicts.append($conflict) + + // set more recent mtime bold + // ie sucks + if (replacement.lastModified > original.mtime) { + $replacementDiv.find('.mtime').css('font-weight', 'bold') + } else if (replacement.lastModified < original.mtime) { + $originalDiv.find('.mtime').css('font-weight', 'bold') + } else { + // TODO add to same mtime collection? + } + + // set bigger size bold + if (replacement.size && replacement.size > original.size) { + $replacementDiv.find('.size').css('font-weight', 'bold') + } else if (replacement.size && replacement.size < original.size) { + $originalDiv.find('.size').css('font-weight', 'bold') + } else { + // TODO add to same size collection? + } + + // TODO show skip action for files with same size and mtime in bottom row + + // always keep readonly files + + if (original.status === 'readonly') { + $originalDiv + .addClass('readonly') + .find('input[type="checkbox"]') + .prop('checked', true) + .prop('disabled', true) + $originalDiv.find('.message') + .text(t('core', 'read-only')) + } + } + // var selection = controller.getSelection(data.originalFiles); + // if (selection.defaultAction) { + // controller[selection.defaultAction](data); + // } else { + var dialogName = 'oc-dialog-fileexists-content' + var dialogId = '#' + dialogName + if (this._fileexistsshown) { + // add conflict + + var $conflicts = $(dialogId + ' .conflicts') + addConflict($conflicts, original, replacement) + + var count = $(dialogId + ' .conflict').length + var title = n('core', + '{count} file conflict', + '{count} file conflicts', + count, + { count: count } + ) + $(dialogId).parent().children('.oc-dialog-title').text(title) + + // recalculate dimensions + $(window).trigger('resize') + dialogDeferred.resolve() + } else { + // create dialog + this._fileexistsshown = true + $.when(this._getFileExistsTemplate()).then(function($tmpl) { + var title = t('core', 'One file conflict') + var $dlg = $tmpl.octemplate({ + dialog_name: dialogName, + title: title, + type: 'fileexists', + + allnewfiles: t('core', 'New Files'), + allexistingfiles: t('core', 'Already existing files'), + + why: t('core', 'Which files do you want to keep?'), + what: t('core', 'If you select both versions, the copied file will have a number added to its name.') + }) + $('body').append($dlg) + + if (original && replacement) { + var $conflicts = $dlg.find('.conflicts') + addConflict($conflicts, original, replacement) + } + + var buttonlist = [{ + text: t('core', 'Cancel'), + classes: 'cancel', + click: function() { + if (typeof controller.onCancel !== 'undefined') { + controller.onCancel(data) + } + $(dialogId).ocdialog('close') + } + }, + { + text: t('core', 'Continue'), + classes: 'continue', + click: function() { + if (typeof controller.onContinue !== 'undefined') { + controller.onContinue($(dialogId + ' .conflict')) + } + $(dialogId).ocdialog('close') + } + }] + + $(dialogId).ocdialog({ + width: 500, + closeOnEscape: true, + modal: true, + buttons: buttonlist, + closeButton: null, + close: function() { + self._fileexistsshown = false + try { + $(this).ocdialog('destroy').remove() + } catch (e) { + // ignore + } + } + }) + + $(dialogId).css('height', 'auto') + + var $primaryButton = $dlg.closest('.oc-dialog').find('button.continue') + $primaryButton.prop('disabled', true) + + function updatePrimaryButton() { + var checkedCount = $dlg.find('.conflicts .checkbox:checked').length + $primaryButton.prop('disabled', checkedCount === 0) + } + + // add checkbox toggling actions + $(dialogId).find('.allnewfiles').on('click', function() { + var $checkboxes = $(dialogId).find('.conflict .replacement input[type="checkbox"]') + $checkboxes.prop('checked', $(this).prop('checked')) + }) + $(dialogId).find('.allexistingfiles').on('click', function() { + var $checkboxes = $(dialogId).find('.conflict .original:not(.readonly) input[type="checkbox"]') + $checkboxes.prop('checked', $(this).prop('checked')) + }) + $(dialogId).find('.conflicts').on('click', '.replacement,.original:not(.readonly)', function() { + var $checkbox = $(this).find('input[type="checkbox"]') + $checkbox.prop('checked', !$checkbox.prop('checked')) + }) + $(dialogId).find('.conflicts').on('click', '.replacement input[type="checkbox"],.original:not(.readonly) input[type="checkbox"]', function() { + var $checkbox = $(this) + $checkbox.prop('checked', !$checkbox.prop('checked')) + }) + + // update counters + $(dialogId).on('click', '.replacement,.allnewfiles', function() { + var count = $(dialogId).find('.conflict .replacement input[type="checkbox"]:checked').length + if (count === $(dialogId + ' .conflict').length) { + $(dialogId).find('.allnewfiles').prop('checked', true) + $(dialogId).find('.allnewfiles + .count').text(t('core', '(all selected)')) + } else if (count > 0) { + $(dialogId).find('.allnewfiles').prop('checked', false) + $(dialogId).find('.allnewfiles + .count').text(t('core', '({count} selected)', { count: count })) + } else { + $(dialogId).find('.allnewfiles').prop('checked', false) + $(dialogId).find('.allnewfiles + .count').text('') + } + updatePrimaryButton() + }) + $(dialogId).on('click', '.original,.allexistingfiles', function() { + var count = $(dialogId).find('.conflict .original input[type="checkbox"]:checked').length + if (count === $(dialogId + ' .conflict').length) { + $(dialogId).find('.allexistingfiles').prop('checked', true) + $(dialogId).find('.allexistingfiles + .count').text(t('core', '(all selected)')) + } else if (count > 0) { + $(dialogId).find('.allexistingfiles').prop('checked', false) + $(dialogId).find('.allexistingfiles + .count') + .text(t('core', '({count} selected)', { count: count })) + } else { + $(dialogId).find('.allexistingfiles').prop('checked', false) + $(dialogId).find('.allexistingfiles + .count').text('') + } + updatePrimaryButton() + }) + + dialogDeferred.resolve() + }) + .fail(function() { + dialogDeferred.reject() + alert(t('core', 'Error loading file exists template')) + }) + } + // } + return dialogDeferred.promise() + }, + + _getFileExistsTemplate: function() { + var defer = $.Deferred() + if (!this.$fileexistsTemplate) { + var self = this + $.get(OC.filePath('core', 'templates/legacy', 'fileexists.html'), function(tmpl) { + self.$fileexistsTemplate = $(tmpl) + defer.resolve(self.$fileexistsTemplate) + }) + .fail(function() { + defer.reject() + }) + } else { + defer.resolve(this.$fileexistsTemplate) + } + return defer.promise() + }, +} + +export default Dialogs diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js new file mode 100644 index 00000000000..090c351c057 --- /dev/null +++ b/core/src/OC/eventsource.js @@ -0,0 +1,145 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable */ +import $ from 'jquery' + +import { getRequestToken } from './requesttoken.ts' + +/** + * Create a new event source + * @param {string} src + * @param {object} [data] to be send as GET + * + * @constructs OCEventSource + */ +const OCEventSource = function(src, data) { + var dataStr = '' + var name + var joinChar + this.typelessListeners = [] + this.closed = false + this.listeners = {} + if (data) { + for (name in data) { + dataStr += name + '=' + encodeURIComponent(data[name]) + '&' + } + } + dataStr += 'requesttoken=' + encodeURIComponent(getRequestToken()) + if (!this.useFallBack && typeof EventSource !== 'undefined') { + joinChar = '&' + if (src.indexOf('?') === -1) { + joinChar = '?' + } + this.source = new EventSource(src + joinChar + dataStr) + this.source.onmessage = function(e) { + for (var i = 0; i < this.typelessListeners.length; i++) { + this.typelessListeners[i](JSON.parse(e.data)) + } + }.bind(this) + } else { + var iframeId = 'oc_eventsource_iframe_' + OCEventSource.iframeCount + OCEventSource.fallBackSources[OCEventSource.iframeCount] = this + this.iframe = $('<iframe></iframe>') + this.iframe.attr('id', iframeId) + this.iframe.hide() + + joinChar = '&' + if (src.indexOf('?') === -1) { + joinChar = '?' + } + this.iframe.attr('src', src + joinChar + 'fallback=true&fallback_id=' + OCEventSource.iframeCount + '&' + dataStr) + $('body').append(this.iframe) + this.useFallBack = true + OCEventSource.iframeCount++ + } + // add close listener + this.listen('__internal__', function(data) { + if (data === 'close') { + this.close() + } + }.bind(this)) +} +OCEventSource.fallBackSources = [] +OCEventSource.iframeCount = 0// number of fallback iframes +OCEventSource.fallBackCallBack = function(id, type, data) { + OCEventSource.fallBackSources[id].fallBackCallBack(type, data) +} +OCEventSource.prototype = { + typelessListeners: [], + iframe: null, + listeners: {}, // only for fallback + useFallBack: false, + /** + * Fallback callback for browsers that don't have the + * native EventSource object. + * + * Calls the registered listeners. + * + * @private + * @param {String} type event type + * @param {Object} data received data + */ + fallBackCallBack: function(type, data) { + var i + // ignore messages that might appear after closing + if (this.closed) { + return + } + if (type) { + if (typeof this.listeners.done !== 'undefined') { + for (i = 0; i < this.listeners[type].length; i++) { + this.listeners[type][i](data) + } + } + } else { + for (i = 0; i < this.typelessListeners.length; i++) { + this.typelessListeners[i](data) + } + } + }, + lastLength: 0, // for fallback + /** + * Listen to a given type of events. + * + * @param {String} type event type + * @param {Function} callback event callback + */ + listen: function(type, callback) { + if (callback && callback.call) { + + if (type) { + if (this.useFallBack) { + if (!this.listeners[type]) { + this.listeners[type] = [] + } + this.listeners[type].push(callback) + } else { + this.source.addEventListener(type, function(e) { + if (typeof e.data !== 'undefined') { + callback(JSON.parse(e.data)) + } else { + callback('') + } + }, false) + } + } else { + this.typelessListeners.push(callback) + } + } + }, + /** + * Closes this event source. + */ + close: function() { + this.closed = true + if (typeof this.source !== 'undefined') { + this.source.close() + } + } +} + +export default OCEventSource diff --git a/core/src/OC/get_set.js b/core/src/OC/get_set.js new file mode 100644 index 00000000000..0c909ad04fd --- /dev/null +++ b/core/src/OC/get_set.js @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const get = context => name => { + const namespaces = name.split('.') + const tail = namespaces.pop() + + for (let i = 0; i < namespaces.length; i++) { + context = context[namespaces[i]] + if (!context) { + return false + } + } + return context[tail] +} + +/** + * Set a variable by name + * + * @param {string} context context + * @return {Function} setter + * @deprecated 19.0.0 use https://lodash.com/docs#set + */ +export const set = context => (name, value) => { + const namespaces = name.split('.') + const tail = namespaces.pop() + + for (let i = 0; i < namespaces.length; i++) { + if (!context[namespaces[i]]) { + context[namespaces[i]] = {} + } + context = context[namespaces[i]] + } + context[tail] = value + return value +} diff --git a/core/src/OC/host.js b/core/src/OC/host.js new file mode 100644 index 00000000000..75c7d63804b --- /dev/null +++ b/core/src/OC/host.js @@ -0,0 +1,42 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const getProtocol = () => window.location.protocol.split(':')[0] + +/** + * Returns the host used to access this Nextcloud instance + * Host is sometimes the same as the hostname but now always. + * + * Examples: + * http://example.com => example.com + * https://example.com => example.com + * http://example.com:8080 => example.com:8080 + * + * @return {string} host + * + * @since 8.2.0 + * @deprecated 17.0.0 use window.location.host directly + */ +export const getHost = () => window.location.host + +/** + * Returns the hostname used to access this Nextcloud instance + * The hostname is always stripped of the port + * + * @return {string} hostname + * @since 9.0.0 + * @deprecated 17.0.0 use window.location.hostname directly + */ +export const getHostName = () => window.location.hostname + +/** + * Returns the port number used to access this Nextcloud instance + * + * @return {number} port number + * + * @since 8.2.0 + * @deprecated 17.0.0 use window.location.port directly + */ +export const getPort = () => window.location.port diff --git a/core/src/OC/index.js b/core/src/OC/index.js new file mode 100644 index 00000000000..5afc941b396 --- /dev/null +++ b/core/src/OC/index.js @@ -0,0 +1,294 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { subscribe } from '@nextcloud/event-bus' + +import { + ajaxConnectionLostHandler, + processAjaxError, + registerXHRForErrorProcessing, +} from './xhr-error.js' +import Apps from './apps.js' +import { AppConfig, appConfig } from './appconfig.js' +import appswebroots from './appswebroots.js' +import Backbone from './backbone.js' +import { + basename, + dirname, + encodePath, + isSamePath, + joinPaths, +} from '@nextcloud/paths' +import { + build as buildQueryString, + parse as parseQueryString, +} from './query-string.js' +import Config from './config.js' +import { + coreApps, + menuSpeed, + PERMISSION_ALL, + PERMISSION_CREATE, + PERMISSION_DELETE, + PERMISSION_NONE, + PERMISSION_READ, + PERMISSION_SHARE, + PERMISSION_UPDATE, + TAG_FAVORITE, +} from './constants.js' +import { currentUser, getCurrentUser } from './currentuser.js' +import Dialogs from './dialogs.js' +import EventSource from './eventsource.js' +import { get, set } from './get_set.js' +import { getCapabilities } from './capabilities.js' +import { + getHost, + getHostName, + getPort, + getProtocol, +} from './host.js' +import { getRequestToken } from './requesttoken.ts' +import { + hideMenus, + registerMenu, + showMenu, + unregisterMenu, +} from './menu.js' +import { isUserAdmin } from './admin.js' +import L10N from './l10n.js' +import { + getCanonicalLocale, + getLanguage, + getLocale, +} from '@nextcloud/l10n' + +import { + generateUrl, + generateFilePath, + generateOcsUrl, + generateRemoteUrl, + getRootUrl, + imagePath, + linkTo, +} from '@nextcloud/router' + +import { + linkToRemoteBase, +} from './routing.js' +import msg from './msg.js' +import Notification from './notification.js' +import PasswordConfirmation from './password-confirmation.js' +import Plugins from './plugins.js' +import { theme } from './theme.js' +import Util from './util.js' +import { debug } from './debug.js' +import { redirect, reload } from './navigation.js' +import webroot from './webroot.js' + +/** @namespace OC */ +export default { + /* + * Constants + */ + coreApps, + menuSpeed, + PERMISSION_ALL, + PERMISSION_CREATE, + PERMISSION_DELETE, + PERMISSION_NONE, + PERMISSION_READ, + PERMISSION_SHARE, + PERMISSION_UPDATE, + TAG_FAVORITE, + + /* + * Deprecated helpers to be removed + */ + /** + * Check if a user file is allowed to be handled. + * + * @param {string} file to check + * @return {boolean} + * @deprecated 17.0.0 + */ + fileIsBlacklisted: file => !!(file.match(Config.blacklist_files_regex)), + Apps, + AppConfig, + appConfig, + appswebroots, + Backbone, + config: Config, + /** + * Currently logged in user or null if none + * + * @type {string} + * @deprecated use `getCurrentUser` from https://www.npmjs.com/package/@nextcloud/auth + */ + currentUser, + dialogs: Dialogs, + EventSource, + /** + * Returns the currently logged in user or null if there is no logged in + * user (public page mode) + * + * @since 9.0.0 + * @deprecated 19.0.0 use `getCurrentUser` from https://www.npmjs.com/package/@nextcloud/auth + */ + getCurrentUser, + isUserAdmin, + L10N, + + /** + * Ajax error handlers + * + * @todo remove from here and keep internally -> requires new tests + */ + _ajaxConnectionLostHandler: ajaxConnectionLostHandler, + _processAjaxError: processAjaxError, + registerXHRForErrorProcessing, + + /** + * Capabilities + * + * @type {Array} + * @deprecated 20.0.0 use @nextcloud/capabilities instead + */ + getCapabilities, + + /* + * Legacy menu helpers + */ + hideMenus, + registerMenu, + showMenu, + unregisterMenu, + + /* + * Path helpers + */ + /** + * @deprecated 18.0.0 use https://www.npmjs.com/package/@nextcloud/paths + */ + basename, + /** + * @deprecated 18.0.0 use https://www.npmjs.com/package/@nextcloud/paths + */ + encodePath, + /** + * @deprecated 18.0.0 use https://www.npmjs.com/package/@nextcloud/paths + */ + dirname, + /** + * @deprecated 18.0.0 use https://www.npmjs.com/package/@nextcloud/paths + */ + isSamePath, + /** + * @deprecated 18.0.0 use https://www.npmjs.com/package/@nextcloud/paths + */ + joinPaths, + + /** + * Host (url) helpers + */ + getHost, + getHostName, + getPort, + getProtocol, + + /** + * @deprecated 20.0.0 use `getCanonicalLocale` from https://www.npmjs.com/package/@nextcloud/l10n + */ + getCanonicalLocale, + /** + * @deprecated 26.0.0 use `getLocale` from https://www.npmjs.com/package/@nextcloud/l10n + */ + getLocale, + /** + * @deprecated 26.0.0 use `getLanguage` from https://www.npmjs.com/package/@nextcloud/l10n + */ + getLanguage, + + /** + * Query string helpers + */ + buildQueryString, + parseQueryString, + + msg, + Notification, + /** + * @deprecated 28.0.0 use methods from '@nextcloud/password-confirmation' + */ + PasswordConfirmation, + Plugins, + theme, + Util, + debug, + /** + * @deprecated 19.0.0 use `generateFilePath` from https://www.npmjs.com/package/@nextcloud/router + */ + filePath: generateFilePath, + /** + * @deprecated 19.0.0 use `generateUrl` from https://www.npmjs.com/package/@nextcloud/router + */ + generateUrl, + /** + * @deprecated 19.0.0 use https://lodash.com/docs#get + */ + get: get(window), + /** + * @deprecated 19.0.0 use https://lodash.com/docs#set + */ + set: set(window), + /** + * @deprecated 19.0.0 use `getRootUrl` from https://www.npmjs.com/package/@nextcloud/router + */ + getRootPath: getRootUrl, + /** + * @deprecated 19.0.0 use `imagePath` from https://www.npmjs.com/package/@nextcloud/router + */ + imagePath, + redirect, + reload, + requestToken: getRequestToken(), + /** + * @deprecated 19.0.0 use `linkTo` from https://www.npmjs.com/package/@nextcloud/router + */ + linkTo, + /** + * @param {string} service service name + * @param {number} version OCS API version + * @return {string} OCS API base path + * @deprecated 19.0.0 use `generateOcsUrl` from https://www.npmjs.com/package/@nextcloud/router + */ + linkToOCS: (service, version) => { + return generateOcsUrl(service, {}, { + ocsVersion: version || 1, + }) + '/' + }, + /** + * @deprecated 19.0.0 use `generateRemoteUrl` from https://www.npmjs.com/package/@nextcloud/router + */ + linkToRemote: generateRemoteUrl, + linkToRemoteBase, + /** + * Relative path to Nextcloud root. + * For example: "/nextcloud" + * + * @type {string} + * + * @deprecated 19.0.0 use `getRootUrl` from https://www.npmjs.com/package/@nextcloud/router + * @see OC#getRootPath + */ + webroot, +} + +// Keep the request token prop in sync +subscribe('csrf-token-update', e => { + OC.requestToken = e.token + + // Logging might help debug (Sentry) issues + console.info('OC.requestToken changed', e.token) +}) diff --git a/core/src/OC/l10n.js b/core/src/OC/l10n.js new file mode 100644 index 00000000000..02f912d6a99 --- /dev/null +++ b/core/src/OC/l10n.js @@ -0,0 +1,90 @@ +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2014 ownCloud, Inc. + * SPDX-FileCopyrightText: 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Handlebars from 'handlebars' +import { + loadTranslations, + translate, + translatePlural, + register, + unregister, +} from '@nextcloud/l10n' + +/** + * L10N namespace with localization functions. + * + * @namespace OC.L10n + * @deprecated 26.0.0 use https://www.npmjs.com/package/@nextcloud/l10n + */ +const L10n = { + + /** + * Load an app's translation bundle if not loaded already. + * + * @deprecated 26.0.0 use `loadTranslations` from https://www.npmjs.com/package/@nextcloud/l10n + * + * @param {string} appName name of the app + * @param {Function} callback callback to be called when + * the translations are loaded + * @return {Promise} promise + */ + load: loadTranslations, + + /** + * Register an app's translation bundle. + * + * @deprecated 26.0.0 use `register` from https://www.npmjs.com/package/@nextcloud/l10 + * + * @param {string} appName name of the app + * @param {Record<string, string>} bundle bundle + */ + register, + + /** + * @private + * @deprecated 26.0.0 use `unregister` from https://www.npmjs.com/package/@nextcloud/l10n + */ + _unregister: unregister, + + /** + * Translate a string + * + * @deprecated 26.0.0 use `translate` from https://www.npmjs.com/package/@nextcloud/l10n + * + * @param {string} app the id of the app for which to translate the string + * @param {string} text the string to translate + * @param {object} [vars] map of placeholder key to value + * @param {number} [count] number to replace %n with + * @param {Array} [options] options array + * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) + * @param {boolean} [options.sanitize=true] enable/disable sanitization (by default enabled) + * @return {string} + */ + translate, + + /** + * Translate a plural string + * + * @deprecated 26.0.0 use `translatePlural` from https://www.npmjs.com/package/@nextcloud/l10n + * + * @param {string} app the id of the app for which to translate the string + * @param {string} textSingular the string to translate for exactly one object + * @param {string} textPlural the string to translate for n objects + * @param {number} count number to determine whether to use singular or plural + * @param {object} [vars] map of placeholder key to value + * @param {Array} [options] options array + * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled) + * @return {string} Translated string + */ + translatePlural, +} + +export default L10n + +Handlebars.registerHelper('t', function(app, text) { + return translate(app, text) +}) diff --git a/core/src/OC/menu.js b/core/src/OC/menu.js new file mode 100644 index 00000000000..4b4eb658592 --- /dev/null +++ b/core/src/OC/menu.js @@ -0,0 +1,125 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import _ from 'underscore' +/** @typedef {import('jquery')} jQuery */ +import $ from 'jquery' + +import { menuSpeed } from './constants.js' + +export let currentMenu = null +export let currentMenuToggle = null + +/** + * For menu toggling + * + * @param {jQuery} $toggle the toggle element + * @param {jQuery} $menuEl the menu container element + * @param {Function | undefined} toggle callback invoked everytime the menu is opened + * @param {boolean} headerMenu is this a top right header menu? + * @return {void} + */ +export const registerMenu = function($toggle, $menuEl, toggle, headerMenu) { + $menuEl.addClass('menu') + const isClickableElement = $toggle.prop('tagName') === 'A' || $toggle.prop('tagName') === 'BUTTON' + + // On link and button, the enter key trigger a click event + // Only use the click to avoid two fired events + $toggle.on(isClickableElement ? 'click.menu' : 'click.menu keyup.menu', function(event) { + // prevent the link event (append anchor to URL) + event.preventDefault() + + // allow enter key as a trigger + if (event.key && event.key !== 'Enter') { + return + } + + if ($menuEl.is(currentMenu)) { + hideMenus() + return + } else if (currentMenu) { + // another menu was open? + // close it + hideMenus() + } + + if (headerMenu === true) { + $menuEl.parent().addClass('openedMenu') + } + + // Set menu to expanded + $toggle.attr('aria-expanded', true) + + $menuEl.slideToggle(menuSpeed, toggle) + currentMenu = $menuEl + currentMenuToggle = $toggle + }) +} + +/** + * Unregister a previously registered menu + * + * @param {jQuery} $toggle the toggle element + * @param {jQuery} $menuEl the menu container element + */ +export const unregisterMenu = ($toggle, $menuEl) => { + // close menu if opened + if ($menuEl.is(currentMenu)) { + hideMenus() + } + $toggle.off('click.menu').removeClass('menutoggle') + $menuEl.removeClass('menu') +} + +/** + * Hides any open menus + * + * @param {Function} complete callback when the hiding animation is done + */ +export const hideMenus = function(complete) { + if (currentMenu) { + const lastMenu = currentMenu + currentMenu.trigger(new $.Event('beforeHide')) + currentMenu.slideUp(menuSpeed, function() { + lastMenu.trigger(new $.Event('afterHide')) + if (complete) { + complete.apply(this, arguments) + } + }) + } + + // Set menu to closed + $('.menutoggle').attr('aria-expanded', false) + if (currentMenuToggle) { + currentMenuToggle.attr('aria-expanded', false) + } + + $('.openedMenu').removeClass('openedMenu') + currentMenu = null + currentMenuToggle = null +} + +/** + * Shows a given element as menu + * + * @param {object} [$toggle] menu toggle + * @param {object} $menuEl menu element + * @param {Function} complete callback when the showing animation is done + */ +export const showMenu = ($toggle, $menuEl, complete) => { + if ($menuEl.is(currentMenu)) { + return + } + hideMenus() + currentMenu = $menuEl + currentMenuToggle = $toggle + $menuEl.trigger(new $.Event('beforeShow')) + $menuEl.show() + $menuEl.trigger(new $.Event('afterShow')) + // no animation + if (_.isFunction(complete)) { + complete() + } +} diff --git a/core/src/OC/msg.js b/core/src/OC/msg.js new file mode 100644 index 00000000000..655631a03ff --- /dev/null +++ b/core/src/OC/msg.js @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import $ from 'jquery' + +/** + * A little class to manage a status field for a "saving" process. + * It can be used to display a starting message (e.g. "Saving...") and then + * replace it with a green success message or a red error message. + * + * @namespace OC.msg + */ +export default { + /** + * Displayes a "Saving..." message in the given message placeholder + * + * @param {object} selector Placeholder to display the message in + */ + startSaving(selector) { + this.startAction(selector, t('core', 'Saving …')) + }, + + /** + * Displayes a custom message in the given message placeholder + * + * @param {object} selector Placeholder to display the message in + * @param {string} message Plain text message to display (no HTML allowed) + */ + startAction(selector, message) { + $(selector).text(message) + .removeClass('success') + .removeClass('error') + .stop(true, true) + .show() + }, + + /** + * Displayes an success/error message in the given selector + * + * @param {object} selector Placeholder to display the message in + * @param {object} response Response of the server + * @param {object} response.data Data of the servers response + * @param {string} response.data.message Plain text message to display (no HTML allowed) + * @param {string} response.status is being used to decide whether the message + * is displayed as an error/success + */ + finishedSaving(selector, response) { + this.finishedAction(selector, response) + }, + + /** + * Displayes an success/error message in the given selector + * + * @param {object} selector Placeholder to display the message in + * @param {object} response Response of the server + * @param {object} response.data Data of the servers response + * @param {string} response.data.message Plain text message to display (no HTML allowed) + * @param {string} response.status is being used to decide whether the message + * is displayed as an error/success + */ + finishedAction(selector, response) { + if (response.status === 'success') { + this.finishedSuccess(selector, response.data.message) + } else { + this.finishedError(selector, response.data.message) + } + }, + + /** + * Displayes an success message in the given selector + * + * @param {object} selector Placeholder to display the message in + * @param {string} message Plain text success message to display (no HTML allowed) + */ + finishedSuccess(selector, message) { + $(selector).text(message) + .addClass('success') + .removeClass('error') + .stop(true, true) + .delay(3000) + .fadeOut(900) + .show() + }, + + /** + * Displayes an error message in the given selector + * + * @param {object} selector Placeholder to display the message in + * @param {string} message Plain text error message to display (no HTML allowed) + */ + finishedError(selector, message) { + $(selector).text(message) + .addClass('error') + .removeClass('success') + .show() + }, +} diff --git a/core/src/OC/navigation.js b/core/src/OC/navigation.js new file mode 100644 index 00000000000..b279b9a60f3 --- /dev/null +++ b/core/src/OC/navigation.js @@ -0,0 +1,13 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const redirect = targetURL => { window.location = targetURL } + +/** + * Reloads the current page + * + * @deprecated 17.0.0 use window.location.reload directly + */ +export const reload = () => { window.location.reload() } diff --git a/core/src/OC/notification.js b/core/src/OC/notification.js new file mode 100644 index 00000000000..b658f4163bb --- /dev/null +++ b/core/src/OC/notification.js @@ -0,0 +1,164 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import _ from 'underscore' +/** @typedef {import('jquery')} jQuery */ +import $ from 'jquery' +import { showMessage, TOAST_DEFAULT_TIMEOUT, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs' + +/** + * @todo Write documentation + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package instead + * @namespace OC.Notification + */ +export default { + + updatableNotification: null, + + getDefaultNotificationFunction: null, + + /** + * @param {Function} callback callback function + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + setDefault(callback) { + this.getDefaultNotificationFunction = callback + }, + + /** + * Hides a notification. + * + * If a row is given, only hide that one. + * If no row is given, hide all notifications. + * + * @param {jQuery} [$row] notification row + * @param {Function} [callback] callback + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + hide($row, callback) { + if (_.isFunction($row)) { + // first arg is the callback + callback = $row + $row = undefined + } + + if (!$row) { + console.error('Missing argument $row in OC.Notification.hide() call, caller needs to be adjusted to only dismiss its own notification') + return + } + + // remove the row directly + $row.each(function() { + if ($(this)[0].toastify) { + $(this)[0].toastify.hideToast() + } else { + console.error('cannot hide toast because object is not set') + } + if (this === this.updatableNotification) { + this.updatableNotification = null + } + }) + if (callback) { + callback.call() + } + if (this.getDefaultNotificationFunction) { + this.getDefaultNotificationFunction() + } + }, + + /** + * Shows a notification as HTML without being sanitized before. + * If you pass unsanitized user input this may lead to a XSS vulnerability. + * Consider using show() instead of showHTML() + * + * @param {string} html Message to display + * @param {object} [options] options + * @param {string} [options.type] notification type + * @param {number} [options.timeout] timeout value, defaults to 0 (permanent) + * @return {jQuery} jQuery element for notification row + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + showHtml(html, options) { + options = options || {} + options.isHTML = true + options.timeout = (!options.timeout) ? TOAST_PERMANENT_TIMEOUT : options.timeout + const toast = showMessage(html, options) + toast.toastElement.toastify = toast + return $(toast.toastElement) + }, + + /** + * Shows a sanitized notification + * + * @param {string} text Message to display + * @param {object} [options] options + * @param {string} [options.type] notification type + * @param {number} [options.timeout] timeout value, defaults to 0 (permanent) + * @return {jQuery} jQuery element for notification row + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + show(text, options) { + const escapeHTML = function(text) { + return text.toString() + .split('&').join('&') + .split('<').join('<') + .split('>').join('>') + .split('"').join('"') + .split('\'').join(''') + } + + options = options || {} + options.timeout = (!options.timeout) ? TOAST_PERMANENT_TIMEOUT : options.timeout + const toast = showMessage(escapeHTML(text), options) + toast.toastElement.toastify = toast + return $(toast.toastElement) + }, + + /** + * Updates (replaces) a sanitized notification. + * + * @param {string} text Message to display + * @return {jQuery} JQuery element for notification row + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + showUpdate(text) { + if (this.updatableNotification) { + this.updatableNotification.hideToast() + } + this.updatableNotification = showMessage(text, { timeout: TOAST_PERMANENT_TIMEOUT }) + this.updatableNotification.toastElement.toastify = this.updatableNotification + return $(this.updatableNotification.toastElement) + }, + + /** + * Shows a notification that disappears after x seconds, default is + * 7 seconds + * + * @param {string} text Message to show + * @param {Array} [options] options array + * @param {number} [options.timeout] timeout in seconds, if this is 0 it will show the message permanently + * @param {boolean} [options.isHTML] an indicator for HTML notifications (true) or text (false) + * @param {string} [options.type] notification type + * @return {jQuery} the toast element + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + showTemporary(text, options) { + options = options || {} + options.timeout = options.timeout || TOAST_DEFAULT_TIMEOUT + const toast = showMessage(text, options) + toast.toastElement.toastify = toast + return $(toast.toastElement) + }, + + /** + * Returns whether a notification is hidden. + * + * @return {boolean} + * @deprecated 17.0.0 use the `@nextcloud/dialogs` package + */ + isHidden() { + return !$('#content').find('.toastify').length + }, +} diff --git a/core/src/OC/password-confirmation.js b/core/src/OC/password-confirmation.js new file mode 100644 index 00000000000..621f7a0695f --- /dev/null +++ b/core/src/OC/password-confirmation.js @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { confirmPassword, isPasswordConfirmationRequired } from '@nextcloud/password-confirmation' +import '@nextcloud/password-confirmation/dist/style.css' + +/** + * @namespace OC.PasswordConfirmation + */ +export default { + + requiresPasswordConfirmation() { + return isPasswordConfirmationRequired() + }, + + /** + * @param {Function} callback success callback function + * @param {object} options options currently not used by confirmPassword + * @param {Function} rejectCallback error callback function + */ + requirePasswordConfirmation(callback, options, rejectCallback) { + confirmPassword().then(callback, rejectCallback) + }, +} diff --git a/core/src/OC/plugins.js b/core/src/OC/plugins.js new file mode 100644 index 00000000000..8212fc0b4ee --- /dev/null +++ b/core/src/OC/plugins.js @@ -0,0 +1,70 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export default { + + /** + * @type {Array.<OC.Plugin>} + */ + _plugins: {}, + + /** + * Register plugin + * + * @param {string} targetName app name / class name to hook into + * @param {OC.Plugin} plugin plugin + */ + register(targetName, plugin) { + let plugins = this._plugins[targetName] + if (!plugins) { + plugins = this._plugins[targetName] = [] + } + plugins.push(plugin) + }, + + /** + * Returns all plugin registered to the given target + * name / app name / class name. + * + * @param {string} targetName app name / class name to hook into + * @return {Array.<OC.Plugin>} array of plugins + */ + getPlugins(targetName) { + return this._plugins[targetName] || [] + }, + + /** + * Call attach() on all plugins registered to the given target name. + * + * @param {string} targetName app name / class name + * @param {object} targetObject to be extended + * @param {object} [options] options + */ + attach(targetName, targetObject, options) { + const plugins = this.getPlugins(targetName) + for (let i = 0; i < plugins.length; i++) { + if (plugins[i].attach) { + plugins[i].attach(targetObject, options) + } + } + }, + + /** + * Call detach() on all plugins registered to the given target name. + * + * @param {string} targetName app name / class name + * @param {object} targetObject to be extended + * @param {object} [options] options + */ + detach(targetName, targetObject, options) { + const plugins = this.getPlugins(targetName) + for (let i = 0; i < plugins.length; i++) { + if (plugins[i].detach) { + plugins[i].detach(targetObject, options) + } + } + }, + +} diff --git a/core/src/OC/query-string.js b/core/src/OC/query-string.js new file mode 100644 index 00000000000..df0f366133a --- /dev/null +++ b/core/src/OC/query-string.js @@ -0,0 +1,75 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import $ from 'jquery' + +/** + * Parses a URL query string into a JS map + * + * @param {string} queryString query string in the format param1=1234¶m2=abcde¶m3=xyz + * @return {Record<string, string>} map containing key/values matching the URL parameters + */ +export const parse = queryString => { + let pos + let components + const result = {} + let key + if (!queryString) { + return null + } + pos = queryString.indexOf('?') + if (pos >= 0) { + queryString = queryString.substr(pos + 1) + } + const parts = queryString.replace(/\+/g, '%20').split('&') + for (let i = 0; i < parts.length; i++) { + // split on first equal sign + const part = parts[i] + pos = part.indexOf('=') + if (pos >= 0) { + components = [ + part.substr(0, pos), + part.substr(pos + 1), + ] + } else { + // key only + components = [part] + } + if (!components.length) { + continue + } + key = decodeURIComponent(components[0]) + if (!key) { + continue + } + // if equal sign was there, return string + if (components.length > 1) { + result[key] = decodeURIComponent(components[1]) + } else { + // no equal sign => null value + result[key] = null + } + } + return result +} + +/** + * Builds a URL query from a JS map. + * + * @param {Record<string, string>} params map containing key/values matching the URL parameters + * @return {string} String containing a URL query (without question) mark + */ +export const build = params => { + if (!params) { + return '' + } + return $.map(params, function(value, key) { + let s = encodeURIComponent(key) + if (value !== null && typeof (value) !== 'undefined') { + s += '=' + encodeURIComponent(value) + } + return s + }).join('&') +} diff --git a/core/src/OC/requesttoken.ts b/core/src/OC/requesttoken.ts new file mode 100644 index 00000000000..8ecf0b3de7e --- /dev/null +++ b/core/src/OC/requesttoken.ts @@ -0,0 +1,49 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { emit } from '@nextcloud/event-bus' +import { generateUrl } from '@nextcloud/router' + +/** + * Get the current CSRF token. + */ +export function getRequestToken(): string { + return document.head.dataset.requesttoken! +} + +/** + * Set a new CSRF token (e.g. because of session refresh). + * This also emits an event bus event for the updated token. + * + * @param token - The new token + * @fires Error - If the passed token is not a potential valid token + */ +export function setRequestToken(token: string): void { + if (!token || typeof token !== 'string') { + throw new Error('Invalid CSRF token given', { cause: { token } }) + } + + document.head.dataset.requesttoken = token + emit('csrf-token-update', { token }) +} + +/** + * Fetch the request token from the API. + * This does also set it on the current context, see `setRequestToken`. + * + * @fires Error - If the request failed + */ +export async function fetchRequestToken(): Promise<string> { + const url = generateUrl('/csrftoken') + + const response = await fetch(url) + if (!response.ok) { + throw new Error('Could not fetch CSRF token from API', { cause: response }) + } + + const { token } = await response.json() + setRequestToken(token) + return token +} diff --git a/core/src/OC/routing.js b/core/src/OC/routing.js new file mode 100644 index 00000000000..4b81714d6f0 --- /dev/null +++ b/core/src/OC/routing.js @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { + getRootUrl as realGetRootUrl, +} from '@nextcloud/router' + +/** + * Creates a relative url for remote use + * + * @param {string} service id + * @return {string} the url + */ +export const linkToRemoteBase = service => { + return realGetRootUrl() + '/remote.php/' + service +} diff --git a/core/src/OC/theme.js b/core/src/OC/theme.js new file mode 100644 index 00000000000..af45c37de7e --- /dev/null +++ b/core/src/OC/theme.js @@ -0,0 +1,6 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const theme = window._theme || {} diff --git a/core/src/OC/util-history.js b/core/src/OC/util-history.js new file mode 100644 index 00000000000..7ecd0e098c6 --- /dev/null +++ b/core/src/OC/util-history.js @@ -0,0 +1,168 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import _ from 'underscore' +import OC from './index.js' + +/** + * Utility class for the history API, + * includes fallback to using the URL hash when + * the browser doesn't support the history API. + * + * @namespace OC.Util.History + */ +export default { + + _handlers: [], + + /** + * Push the current URL parameters to the history stack + * and change the visible URL. + * Note: this includes a workaround for IE8/IE9 that uses + * the hash part instead of the search part. + * + * @param {object | string} params to append to the URL, can be either a string + * or a map + * @param {string} [url] URL to be used, otherwise the current URL will be used, + * using the params as query string + * @param {boolean} [replace] whether to replace instead of pushing + */ + _pushState(params, url, replace) { + let strParams + if (typeof (params) === 'string') { + strParams = params + } else { + strParams = OC.buildQueryString(params) + } + + if (window.history.pushState) { + url = url || location.pathname + '?' + strParams + // Workaround for bug with SVG and window.history.pushState on Firefox < 51 + // https://bugzilla.mozilla.org/show_bug.cgi?id=652991 + const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 + if (isFirefox && parseInt(navigator.userAgent.split('/').pop()) < 51) { + const patterns = document.querySelectorAll('[fill^="url(#"], [stroke^="url(#"], [filter^="url(#invert"]') + for (let i = 0, ii = patterns.length, pattern; i < ii; i++) { + pattern = patterns[i] + // eslint-disable-next-line no-self-assign + pattern.style.fill = pattern.style.fill + // eslint-disable-next-line no-self-assign + pattern.style.stroke = pattern.style.stroke + pattern.removeAttribute('filter') + pattern.setAttribute('filter', 'url(#invert)') + } + } + if (replace) { + window.history.replaceState(params, '', url) + } else { + window.history.pushState(params, '', url) + } + } else { + // use URL hash for IE8 + window.location.hash = '?' + strParams + // inhibit next onhashchange that just added itself + // to the event queue + this._cancelPop = true + } + }, + + /** + * Push the current URL parameters to the history stack + * and change the visible URL. + * Note: this includes a workaround for IE8/IE9 that uses + * the hash part instead of the search part. + * + * @param {object | string} params to append to the URL, can be either a string or a map + * @param {string} [url] URL to be used, otherwise the current URL will be used, using the params as query string + */ + pushState(params, url) { + this._pushState(params, url, false) + }, + + /** + * Push the current URL parameters to the history stack + * and change the visible URL. + * Note: this includes a workaround for IE8/IE9 that uses + * the hash part instead of the search part. + * + * @param {object | string} params to append to the URL, can be either a string + * or a map + * @param {string} [url] URL to be used, otherwise the current URL will be used, + * using the params as query string + */ + replaceState(params, url) { + this._pushState(params, url, true) + }, + + /** + * Add a popstate handler + * + * @param {Function} handler handler + */ + addOnPopStateHandler(handler) { + this._handlers.push(handler) + }, + + /** + * Parse a query string from the hash part of the URL. + * (workaround for IE8 / IE9) + * + * @return {string} + */ + _parseHashQuery() { + const hash = window.location.hash + const pos = hash.indexOf('?') + if (pos >= 0) { + return hash.substr(pos + 1) + } + if (hash.length) { + // remove hash sign + return hash.substr(1) + } + return '' + }, + + _decodeQuery(query) { + return query.replace(/\+/g, ' ') + }, + + /** + * Parse the query/search part of the URL. + * Also try and parse it from the URL hash (for IE8) + * + * @return {object} map of parameters + */ + parseUrlQuery() { + const query = this._parseHashQuery() + let params + // try and parse from URL hash first + if (query) { + params = OC.parseQueryString(this._decodeQuery(query)) + } + // else read from query attributes + params = _.extend(params || {}, OC.parseQueryString(this._decodeQuery(location.search))) + return params || {} + }, + + _onPopState(e) { + if (this._cancelPop) { + this._cancelPop = false + return + } + let params + if (!this._handlers.length) { + return + } + params = (e && e.state) + if (_.isString(params)) { + params = OC.parseQueryString(params) + } else if (!params) { + params = this.parseUrlQuery() || {} + } + for (let i = 0; i < this._handlers.length; i++) { + this._handlers[i](params) + } + }, +} diff --git a/core/src/OC/util.js b/core/src/OC/util.js new file mode 100644 index 00000000000..c46d9a141b1 --- /dev/null +++ b/core/src/OC/util.js @@ -0,0 +1,244 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import moment from 'moment' + +import History from './util-history.js' +import OC from './index.js' +import { formatFileSize as humanFileSize } from '@nextcloud/files' + +/** + * @param {any} t - + */ +function chunkify(t) { + // Adapted from http://my.opera.com/GreyWyvern/blog/show.dml/1671288 + const tz = [] + let x = 0 + let y = -1 + let n = 0 + let c + + while (x < t.length) { + c = t.charAt(x) + // only include the dot in strings + const m = ((!n && c === '.') || (c >= '0' && c <= '9')) + if (m !== n) { + // next chunk + y++ + tz[y] = '' + n = m + } + tz[y] += c + x++ + } + return tz +} + +/** + * Utility functions + * + * @namespace OC.Util + */ +export default { + + History, + + /** + * @deprecated use https://nextcloud.github.io/nextcloud-files/functions/formatFileSize.html + */ + humanFileSize, + + /** + * Returns a file size in bytes from a humanly readable string + * Makes 2kB to 2048. + * Inspired by computerFileSize in helper.php + * + * @param {string} string file size in human-readable format + * @return {number} or null if string could not be parsed + * + * + */ + computerFileSize(string) { + if (typeof string !== 'string') { + return null + } + + const s = string.toLowerCase().trim() + let bytes = null + + const bytesArray = { + b: 1, + k: 1024, + kb: 1024, + mb: 1024 * 1024, + m: 1024 * 1024, + gb: 1024 * 1024 * 1024, + g: 1024 * 1024 * 1024, + tb: 1024 * 1024 * 1024 * 1024, + t: 1024 * 1024 * 1024 * 1024, + pb: 1024 * 1024 * 1024 * 1024 * 1024, + p: 1024 * 1024 * 1024 * 1024 * 1024, + } + + const matches = s.match(/^[\s+]?([0-9]*)(\.([0-9]+))?( +)?([kmgtp]?b?)$/i) + if (matches !== null) { + bytes = parseFloat(s) + if (!isFinite(bytes)) { + return null + } + } else { + return null + } + if (matches[5]) { + bytes = bytes * bytesArray[matches[5]] + } + + bytes = Math.round(bytes) + return bytes + }, + + /** + * @param {string|number} timestamp timestamp + * @param {string} format date format, see momentjs docs + * @return {string} timestamp formatted as requested + */ + formatDate(timestamp, format) { + if (window.TESTING === undefined) { + OC.debug && console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment') + } + format = format || 'LLL' + return moment(timestamp).format(format) + }, + + /** + * @param {string|number} timestamp timestamp + * @return {string} human readable difference from now + */ + relativeModifiedDate(timestamp) { + if (window.TESTING === undefined) { + OC.debug && console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment') + } + const diff = moment().diff(moment(timestamp)) + if (diff >= 0 && diff < 45000) { + return t('core', 'seconds ago') + } + return moment(timestamp).fromNow() + }, + + /** + * Returns the width of a generic browser scrollbar + * + * @return {number} width of scrollbar + */ + getScrollBarWidth() { + if (this._scrollBarWidth) { + return this._scrollBarWidth + } + + const inner = document.createElement('p') + inner.style.width = '100%' + inner.style.height = '200px' + + const outer = document.createElement('div') + outer.style.position = 'absolute' + outer.style.top = '0px' + outer.style.left = '0px' + outer.style.visibility = 'hidden' + outer.style.width = '200px' + outer.style.height = '150px' + outer.style.overflow = 'hidden' + outer.appendChild(inner) + + document.body.appendChild(outer) + const w1 = inner.offsetWidth + outer.style.overflow = 'scroll' + let w2 = inner.offsetWidth + if (w1 === w2) { + w2 = outer.clientWidth + } + + document.body.removeChild(outer) + + this._scrollBarWidth = (w1 - w2) + + return this._scrollBarWidth + }, + + /** + * Remove the time component from a given date + * + * @param {Date} date date + * @return {Date} date with stripped time + */ + stripTime(date) { + // FIXME: likely to break when crossing DST + // would be better to use a library like momentJS + return new Date(date.getFullYear(), date.getMonth(), date.getDate()) + }, + + /** + * Compare two strings to provide a natural sort + * + * @param {string} a first string to compare + * @param {string} b second string to compare + * @return {number} -1 if b comes before a, 1 if a comes before b + * or 0 if the strings are identical + */ + naturalSortCompare(a, b) { + let x + const aa = chunkify(a) + const bb = chunkify(b) + + for (x = 0; aa[x] && bb[x]; x++) { + if (aa[x] !== bb[x]) { + const aNum = Number(aa[x]); const bNum = Number(bb[x]) + // note: == is correct here + /* eslint-disable-next-line */ + if (aNum == aa[x] && bNum == bb[x]) { + return aNum - bNum + } else { + // Note: This locale setting isn't supported by all browsers but for the ones + // that do there will be more consistency between client-server sorting + return aa[x].localeCompare(bb[x], OC.getLanguage()) + } + } + } + return aa.length - bb.length + }, + + /** + * Calls the callback in a given interval until it returns true + * + * @param {Function} callback function to call on success + * @param {number} interval in milliseconds + */ + waitFor(callback, interval) { + const internalCallback = function() { + if (callback() !== true) { + setTimeout(internalCallback, interval) + } + } + + internalCallback() + }, + + /** + * Checks if a cookie with the given name is present and is set to the provided value. + * + * @param {string} name name of the cookie + * @param {string} value value of the cookie + * @return {boolean} true if the cookie with the given name has the given value + */ + isCookieSetToValue(name, value) { + const cookies = document.cookie.split(';') + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].split('=') + if (cookie[0].trim() === name && cookie[1].trim() === value) { + return true + } + } + return false + }, +} diff --git a/core/src/OC/webroot.js b/core/src/OC/webroot.js new file mode 100644 index 00000000000..cbe5a6190e1 --- /dev/null +++ b/core/src/OC/webroot.js @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +let webroot = window._oc_webroot + +if (typeof webroot === 'undefined') { + webroot = location.pathname + const pos = webroot.indexOf('/index.php/') + if (pos !== -1) { + webroot = webroot.substr(0, pos) + } else { + webroot = webroot.substr(0, webroot.lastIndexOf('/')) + } +} + +export default webroot diff --git a/core/src/OC/xhr-error.js b/core/src/OC/xhr-error.js new file mode 100644 index 00000000000..233aaf60350 --- /dev/null +++ b/core/src/OC/xhr-error.js @@ -0,0 +1,102 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import _ from 'underscore' +import $ from 'jquery' + +import OC from './index.js' +import Notification from './notification.js' +import { getCurrentUser } from '@nextcloud/auth' +import { showWarning } from '@nextcloud/dialogs' + +/** + * Warn users that the connection to the server was lost temporarily + * + * This function is throttled to prevent stacked notifications. + * After 7sec the first notification is gone, then we can show another one + * if necessary. + */ +export const ajaxConnectionLostHandler = _.throttle(() => { + showWarning(t('core', 'Connection to server lost')) +}, 7 * 1000, { trailing: false }) + +/** + * Process ajax error, redirects to main page + * if an error/auth error status was returned. + * + * @param {XMLHttpRequest} xhr xhr request + */ +export const processAjaxError = xhr => { + // purposefully aborted request ? + // OC._userIsNavigatingAway needed to distinguish Ajax calls cancelled by navigating away + // from calls cancelled by failed cross-domain Ajax due to SSO redirect + if (xhr.status === 0 && (xhr.statusText === 'abort' || xhr.statusText === 'timeout' || OC._reloadCalled)) { + return + } + + if ([302, 303, 307, 401].includes(xhr.status) && getCurrentUser()) { + // sometimes "beforeunload" happens later, so need to defer the reload a bit + setTimeout(function() { + if (!OC._userIsNavigatingAway && !OC._reloadCalled) { + let timer = 0 + const seconds = 5 + const interval = setInterval(function() { + Notification.showUpdate(n('core', 'Problem loading page, reloading in %n second', 'Problem loading page, reloading in %n seconds', seconds - timer)) + if (timer >= seconds) { + clearInterval(interval) + OC.reload() + } + timer++ + }, 1000, // 1 second interval + ) + + // only call reload once + OC._reloadCalled = true + } + }, 100) + } else if (xhr.status === 0) { + // Connection lost (e.g. WiFi disconnected or server is down) + setTimeout(function() { + if (!OC._userIsNavigatingAway && !OC._reloadCalled) { + // TODO: call method above directly + OC._ajaxConnectionLostHandler() + } + }, 100) + } +} + +/** + * Registers XmlHttpRequest object for global error processing. + * + * This means that if this XHR object returns 401 or session timeout errors, + * the current page will automatically be reloaded. + * + * @param {XMLHttpRequest} xhr xhr request + */ +export const registerXHRForErrorProcessing = xhr => { + const loadCallback = () => { + if (xhr.readyState !== 4) { + return + } + + if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) { + return + } + + // fire jquery global ajax error handler + $(document).trigger(new $.Event('ajaxError'), xhr) + } + + const errorCallback = () => { + // fire jquery global ajax error handler + $(document).trigger(new $.Event('ajaxError'), xhr) + } + + if (xhr.addEventListener) { + xhr.addEventListener('load', loadCallback) + xhr.addEventListener('error', errorCallback) + } + +} |