summaryrefslogtreecommitdiffstats
path: root/core/src/OC
diff options
context:
space:
mode:
authorChristoph Wurst <christoph@winzerhof-wurst.at>2019-01-28 17:00:56 +0100
committerChristoph Wurst <christoph@winzerhof-wurst.at>2019-01-29 08:40:19 +0100
commitaa2ff51a122432d2aea376ca028179c5b5a70a33 (patch)
tree630d55417d09ac73a7ce7b262af135f722ebdd17 /core/src/OC
parent198a45ff75c64d38c1ccb6bd903ee55b446177b3 (diff)
downloadnextcloud-server-aa2ff51a122432d2aea376ca028179c5b5a70a33.tar.gz
nextcloud-server-aa2ff51a122432d2aea376ca028179c5b5a70a33.zip
Move OC.Contactsmenu and OC.Backbone to the server bundle
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Diffstat (limited to 'core/src/OC')
-rw-r--r--core/src/OC/backbone-webdav.js357
-rw-r--r--core/src/OC/contactsmenu.js480
-rw-r--r--core/src/OC/index.js12
3 files changed, 849 insertions, 0 deletions
diff --git a/core/src/OC/backbone-webdav.js b/core/src/OC/backbone-webdav.js
new file mode 100644
index 00000000000..f658532fa18
--- /dev/null
+++ b/core/src/OC/backbone-webdav.js
@@ -0,0 +1,357 @@
+/*
+ * Copyright (c) 2015
+ *
+ * This file is licensed under the Affero General Public License version 3
+ * or later.
+ *
+ * See the COPYING-README file.
+ *
+ */
+
+/**
+ * Webdav transport for Backbone.
+ *
+ * This makes it possible to use Webdav endpoints when
+ * working with Backbone models and collections.
+ *
+ * Requires the davclient.js library.
+ *
+ * Usage example:
+ *
+ * var PersonModel = OC.Backbone.Model.extend({
+ * // make it use the DAV transport
+ * sync: OC.Backbone.davSync,
+ *
+ * // DAV properties mapping
+ * davProperties: {
+ * 'id': '{http://example.com/ns}id',
+ * 'firstName': '{http://example.com/ns}first-name',
+ * 'lastName': '{http://example.com/ns}last-name',
+ * 'age': '{http://example.com/ns}age'
+ * },
+ *
+ * // additional parsing, if needed
+ * parse: function(props) {
+ * // additional parsing (DAV property values are always strings)
+ * props.age = parseInt(props.age, 10);
+ * return props;
+ * }
+ * });
+ *
+ * var PersonCollection = OC.Backbone.Collection.extend({
+ * // make it use the DAV transport
+ * sync: OC.Backbone.davSync,
+ *
+ * // use person model
+ * // note that davProperties will be inherited
+ * model: PersonModel,
+ *
+ * // DAV collection URL
+ * url: function() {
+ * return OC.linkToRemote('dav') + '/person/';
+ * },
+ * });
+ */
+
+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
+ * @return {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);
+ return;
+ }
+ } 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 function 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 function davSync (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/contactsmenu.js b/core/src/OC/contactsmenu.js
new file mode 100644
index 00000000000..88218830337
--- /dev/null
+++ b/core/src/OC/contactsmenu.js
@@ -0,0 +1,480 @@
+/* global Backbone, Handlebars, Promise, _ */
+
+/**
+ * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @author 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import $ from 'jquery';
+import {Collection, Model, View} from 'backbone';
+
+import OC from './index';
+
+/**
+ * @class Contact
+ */
+const Contact = Model.extend({
+ defaults: {
+ fullName: '',
+ lastMessage: '',
+ actions: [],
+ hasOneAction: false,
+ hasTwoActions: false,
+ hasManyActions: false
+ },
+
+ /**
+ * @returns {undefined}
+ */
+ initialize: function () {
+ // Add needed property for easier template rendering
+ if (this.get('actions').length === 0) {
+ this.set('hasOneAction', true);
+ } else if (this.get('actions').length === 1) {
+ this.set('hasTwoActions', true);
+ this.set('secondAction', this.get('actions')[0]);
+ } else {
+ this.set('hasManyActions', true);
+ }
+ }
+});
+
+/**
+ * @class ContactCollection
+ * @private
+ */
+const ContactCollection = Collection.extend({
+ model: Contact
+});
+
+/**
+ * @class ContactsListView
+ * @private
+ */
+const ContactsListView = View.extend({
+
+ /** @type {ContactCollection} */
+ _collection: undefined,
+
+ /** @type {array} */
+ _subViews: [],
+
+ /**
+ * @param {object} options
+ * @returns {undefined}
+ */
+ initialize: function (options) {
+ this._collection = options.collection;
+ },
+
+ /**
+ * @returns {self}
+ */
+ render: function () {
+ var self = this;
+ self.$el.html('');
+ self._subViews = [];
+
+ self._collection.forEach(function (contact) {
+ var item = new ContactsListItemView({
+ model: contact
+ });
+ item.render();
+ self.$el.append(item.$el);
+ item.on('toggle:actionmenu', self._onChildActionMenuToggle, self);
+ self._subViews.push(item);
+ });
+
+ return self;
+ },
+
+ /**
+ * Event callback to propagate opening (another) entry's action menu
+ *
+ * @param {type} $src
+ * @returns {undefined}
+ */
+ _onChildActionMenuToggle: function ($src) {
+ this._subViews.forEach(function (view) {
+ view.trigger('parent:toggle:actionmenu', $src);
+ });
+ }
+});
+
+/**
+ * @class ContactsListItemView
+ * @private
+ */
+const ContactsListItemView = View.extend({
+
+ /** @type {string} */
+ className: 'contact',
+
+ /** @type {undefined|function} */
+ _template: undefined,
+
+ /** @type {Contact} */
+ _model: undefined,
+
+ /** @type {boolean} */
+ _actionMenuShown: false,
+
+ events: {
+ 'click .icon-more': '_onToggleActionsMenu'
+ },
+
+ /**
+ * @param {object} data
+ * @returns {undefined}
+ */
+ template: function (data) {
+ return OC.ContactsMenu.Templates['contact'](data);
+ },
+
+ /**
+ * @param {object} options
+ * @returns {undefined}
+ */
+ initialize: function (options) {
+ this._model = options.model;
+ this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this);
+ },
+
+ /**
+ * @returns {self}
+ */
+ render: function () {
+ this.$el.html(this.template({
+ contact: this._model.toJSON()
+ }));
+ this.delegateEvents();
+
+ // Show placeholder if no avatar is available (avatar is rendered as img, not div)
+ this.$('div.avatar').imageplaceholder(this._model.get('fullName'));
+
+ // Show tooltip for top action
+ this.$('.top-action').tooltip({placement: 'left'});
+ // Show tooltip for second action
+ this.$('.second-action').tooltip({placement: 'left'});
+
+ return this;
+ },
+
+ /**
+ * Toggle the visibility of the action popover menu
+ *
+ * @private
+ * @returns {undefined}
+ */
+ _onToggleActionsMenu: function () {
+ this._actionMenuShown = !this._actionMenuShown;
+ if (this._actionMenuShown) {
+ this.$('.menu').show();
+ } else {
+ this.$('.menu').hide();
+ }
+ this.trigger('toggle:actionmenu', this.$el);
+ },
+
+ /**
+ * @private
+ * @argument {jQuery} $src
+ * @returns {undefined}
+ */
+ _onOtherActionMenuOpened: function ($src) {
+ if (this.$el.is($src)) {
+ // Ignore
+ return;
+ }
+ this._actionMenuShown = false;
+ this.$('.menu').hide();
+ }
+});
+
+/**
+ * @class ContactsMenuView
+ * @private
+ */
+const ContactsMenuView = View.extend({
+
+ /** @type {undefined|function} */
+ _loadingTemplate: undefined,
+
+ /** @type {undefined|function} */
+ _errorTemplate: undefined,
+
+ /** @type {undefined|function} */
+ _contentTemplate: undefined,
+
+ /** @type {undefined|function} */
+ _contactsTemplate: undefined,
+
+ /** @type {undefined|ContactCollection} */
+ _contacts: undefined,
+
+ /** @type {string} */
+ _searchTerm: '',
+
+ events: {
+ 'input #contactsmenu-search': '_onSearch'
+ },
+
+ /**
+ * @returns {undefined}
+ */
+ _onSearch: _.debounce(function (e) {
+ var searchTerm = this.$('#contactsmenu-search').val();
+ // IE11 triggers an 'input' event after the view has been rendered
+ // resulting in an endless loading loop. To prevent this, we remember
+ // the last search term to savely ignore some events
+ // See https://github.com/nextcloud/server/issues/5281
+ if (searchTerm !== this._searchTerm) {
+ this.trigger('search', this.$('#contactsmenu-search').val());
+ this._searchTerm = searchTerm;
+ }
+ }, 700),
+
+ /**
+ * @param {object} data
+ * @returns {string}
+ */
+ loadingTemplate: function (data) {
+ return OC.ContactsMenu.Templates['loading'](data);
+ },
+
+ /**
+ * @param {object} data
+ * @returns {string}
+ */
+ errorTemplate: function (data) {
+ return OC.ContactsMenu.Templates['error'](
+ _.extend({
+ couldNotLoadText: t('core', 'Could not load your contacts')
+ }, data)
+ );
+ },
+
+ /**
+ * @param {object} data
+ * @returns {string}
+ */
+ contentTemplate: function (data) {
+ return OC.ContactsMenu.Templates['menu'](
+ _.extend({
+ searchContactsText: t('core', 'Search contacts …')
+ }, data)
+ );
+ },
+
+ /**
+ * @param {object} data
+ * @returns {string}
+ */
+ contactsTemplate: function (data) {
+ return OC.ContactsMenu.Templates['list'](
+ _.extend({
+ noContactsFoundText: t('core', 'No contacts found'),
+ showAllContactsText: t('core', 'Show all contacts …')
+ }, data)
+ );
+ },
+
+ /**
+ * @param {object} options
+ * @returns {undefined}
+ */
+ initialize: function (options) {
+ this.options = options;
+ },
+
+ /**
+ * @param {string} text
+ * @returns {undefined}
+ */
+ showLoading: function (text) {
+ this.render();
+ this._contacts = undefined;
+ this.$('.content').html(this.loadingTemplate({
+ loadingText: text
+ }));
+ },
+
+ /**
+ * @returns {undefined}
+ */
+ showError: function () {
+ this.render();
+ this._contacts = undefined;
+ this.$('.content').html(this.errorTemplate());
+ },
+
+ /**
+ * @param {object} viewData
+ * @param {string} searchTerm
+ * @returns {undefined}
+ */
+ showContacts: function (viewData, searchTerm) {
+ this._contacts = viewData.contacts;
+ this.render({
+ contacts: viewData.contacts
+ });
+
+ var list = new ContactsListView({
+ collection: viewData.contacts
+ });
+ list.render();
+ this.$('.content').html(this.contactsTemplate({
+ contacts: viewData.contacts,
+ searchTerm: searchTerm,
+ contactsAppEnabled: viewData.contactsAppEnabled,
+ contactsAppURL: OC.generateUrl('/apps/contacts')
+ }));
+ this.$('#contactsmenu-contacts').html(list.$el);
+ },
+
+ /**
+ * @param {object} data
+ * @returns {self}
+ */
+ render: function (data) {
+ var searchVal = this.$('#contactsmenu-search').val();
+ this.$el.html(this.contentTemplate(data));
+
+ // Focus search
+ this.$('#contactsmenu-search').val(searchVal);
+ this.$('#contactsmenu-search').focus();
+ return this;
+ }
+
+});
+
+/**
+ * @param {Object} options
+ * @param {jQuery} options.el
+ * @param {jQuery} options.trigger
+ * @class ContactsMenu
+ * @memberOf OC
+ */
+const ContactsMenu = function (options) {
+ this.initialize(options);
+};
+
+ContactsMenu.prototype = {
+ /** @type {jQuery} */
+ $el: undefined,
+
+ /** @type {jQuery} */
+ _$trigger: undefined,
+
+ /** @type {ContactsMenuView} */
+ _view: undefined,
+
+ /** @type {Promise} */
+ _contactsPromise: undefined,
+
+ /**
+ * @param {Object} options
+ * @param {jQuery} options.el - the element to render the menu in
+ * @param {jQuery} options.trigger - the element to click on to open the menu
+ * @returns {undefined}
+ */
+ initialize: function (options) {
+ this.$el = options.el;
+ this._$trigger = options.trigger;
+
+ this._view = new ContactsMenuView({
+ el: this.$el
+ });
+ this._view.on('search', function (searchTerm) {
+ this._loadContacts(searchTerm);
+ }, this);
+
+ OC.registerMenu(this._$trigger, this.$el, function () {
+ this._toggleVisibility(true);
+ }.bind(this), true);
+ this.$el.on('beforeHide', function () {
+ this._toggleVisibility(false);
+ }.bind(this));
+ },
+
+ /**
+ * @private
+ * @param {boolean} show
+ * @returns {Promise}
+ */
+ _toggleVisibility: function (show) {
+ if (show) {
+ return this._loadContacts();
+ } else {
+ this.$el.html('');
+ return Promise.resolve();
+ }
+ },
+
+ /**
+ * @private
+ * @param {string|undefined} searchTerm
+ * @returns {Promise}
+ */
+ _getContacts: function (searchTerm) {
+ var url = OC.generateUrl('/contactsmenu/contacts');
+ return Promise.resolve($.ajax(url, {
+ method: 'POST',
+ data: {
+ filter: searchTerm
+ }
+ }));
+ },
+
+ /**
+ * @param {string|undefined} searchTerm
+ * @returns {undefined}
+ */
+ _loadContacts: function (searchTerm) {
+ var self = this;
+
+ if (!self._contactsPromise) {
+ self._contactsPromise = self._getContacts(searchTerm);
+ }
+
+ if (_.isUndefined(searchTerm) || searchTerm === '') {
+ self._view.showLoading(t('core', 'Loading your contacts …'));
+ } else {
+ self._view.showLoading(t('core', 'Looking for {term} …', {
+ term: searchTerm
+ }));
+ }
+ return self._contactsPromise.then(function (data) {
+ // Convert contact entries to Backbone collection
+ data.contacts = new ContactCollection(data.contacts);
+
+ self._view.showContacts(data, searchTerm);
+ }, function (e) {
+ self._view.showError();
+ console.error('There was an error loading your contacts', e);
+ }).then(function () {
+ // Delete promise, so that contacts are fetched again when the
+ // menu is opened the next time.
+ delete self._contactsPromise;
+ }).catch(console.error.bind(this));
+ }
+};
+
+export default ContactsMenu;
diff --git a/core/src/OC/index.js b/core/src/OC/index.js
index 6ae1dc12c90..f7f5018253c 100644
--- a/core/src/OC/index.js
+++ b/core/src/OC/index.js
@@ -19,9 +19,21 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
+import Backbone from 'backbone';
+
import Apps from './apps'
+import ContactsMenu from './contactsmenu';
+import {davCall, davSync} from './backbone-webdav';
+
+// Patch Backbone for DAV
+Object.assign(Backbone, {
+ davCall,
+ davSync,
+});
/** @namespace OC */
export default {
Apps,
+ Backbone,
+ ContactsMenu,
};