diff --git a/.drone.yml b/.drone.yml index 7d05716d9..b179c56ae 100644 --- a/.drone.yml +++ b/.drone.yml @@ -134,6 +134,7 @@ pipeline: - cd ../server/ - ./occ app:enable $APP_NAME - cd apps/$APP_NAME + - composer install - phpunit -c tests/phpunit.xml --coverage-clover build/php-unit.coverage.xml - phpunit -c tests/phpunit.integration.xml --coverage-clover build/php-integration.coverage.xml when: @@ -153,6 +154,7 @@ pipeline: - php occ app:enable deck - cd apps/$APP_NAME # Run phpunit tests + - composer install - phpunit -c tests/phpunit.xml --coverage-clover build/php-unit.coverage.xml - phpunit -c tests/phpunit.integration.xml --coverage-clover build/php-integration.coverage.xml when: @@ -171,6 +173,7 @@ pipeline: - cd ../server/ - php occ app:enable deck - cd apps/$APP_NAME + - composer install - phpunit -c tests/phpunit.xml --coverage-clover build/php-unit.coverage.xml - phpunit -c tests/phpunit.integration.xml --coverage-clover build/php-integration.coverage.xml when: @@ -189,6 +192,7 @@ pipeline: - cd ../server/ - php occ app:enable deck - cd apps/$APP_NAME + - composer install - phpunit -c tests/phpunit.xml --coverage-clover build/php-unit.coverage.xml - phpunit -c tests/phpunit.integration.xml --coverage-clover build/php-integration.coverage.xml when: diff --git a/.eslintrc.yml b/.eslintrc.yml index 3e973a590..fed0e4eb0 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -6,6 +6,7 @@ extends: env: browser: true amd: true + es6: true globals: global: false diff --git a/.travis.yml b/.travis.yml index f2375f490..47139649c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,6 +24,7 @@ before_script: - cd apps/deck script: + - composer install - make test-unit after_success: diff --git a/Makefile b/Makefile index 7fc68f9ec..f0021202e 100644 --- a/Makefile +++ b/Makefile @@ -20,12 +20,15 @@ clean-build: clean-dist: rm -rf js/node_modules -install-deps: +install-deps: install-deps-js + composer install + +install-deps-js: cd js && npm install -build: build-js +build: install-deps build-js -build-js: install-deps +build-js: install-deps-js cd js && npm run build build-js-dev: install-deps diff --git a/appinfo/app.php b/appinfo/app.php index e7b8bf9b3..4f8c51333 100644 --- a/appinfo/app.php +++ b/appinfo/app.php @@ -21,6 +21,12 @@ * */ +if ((@include_once __DIR__ . '/../vendor/autoload.php')===false) { + throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); +} + $app = new \OCA\Deck\AppInfo\Application(); $app->registerNavigationEntry(); -$app->registerNotifications(); \ No newline at end of file +$app->registerNotifications(); + +\OC_Util::addStyle('deck', 'activity'); diff --git a/appinfo/autoload.php b/appinfo/autoload.php index 39ded13b0..4459d8394 100644 --- a/appinfo/autoload.php +++ b/appinfo/autoload.php @@ -5,20 +5,20 @@ * @author Julius Härtl * * @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 . - * + * */ namespace OCA\Deck\AppInfo; @@ -28,4 +28,4 @@ use OCP\AppFramework\App; /** * Additional autoloader registration, e.g. registering composer autoloaders */ -// require_once __DIR__ . '/../vendor/autoload.php'; \ No newline at end of file +require_once __DIR__ . '/../vendor/autoload.php'; diff --git a/appinfo/info.xml b/appinfo/info.xml index 8da927459..e6994c1f3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -14,7 +14,7 @@ - 🚀 Get your project organized - 0.5.0-dev2 + 0.5.0-dev3 agpl Julius Härtl Deck @@ -40,4 +40,15 @@ OCA\Deck\Command\UserExport + + + OCA\Deck\Activity\Setting + + + OCA\Deck\Activity\Filter + + + OCA\Deck\Activity\DeckProvider + + diff --git a/composer.json b/composer.json index 73377ab9a..0e799ded1 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,11 @@ "email": "jus@bitgrid.net" } ], - "require": {}, + "require": { + "cogpowered/finediff": "0.3.*" + }, "require-dev": { + "roave/security-advisories": "dev-master", "christophwurst/nextcloud": "^13.0", "jakub-onderka/php-parallel-lint": "^1.0.0" } diff --git a/css/activity.css b/css/activity.css new file mode 100644 index 000000000..843406f4c --- /dev/null +++ b/css/activity.css @@ -0,0 +1,23 @@ +.activitymessage .visualdiff ins { + background-color: rgba(70, 186, 97, 0.2); + text-decoration: none; +} + +.activitymessage .visualdiff del { + background-color: rgba(233, 50, 45, 0.2); + text-decoration: none; +} + +.activityTabView .avatardiv-container { + display: inline-block; + bottom: -3px; + margin-left: 3px; +} + +.activityTabView .avatar-name-wrapper { + font-weight: bold; +} + +.activityTabView .activitysubject a { + font-weight: bold; +} diff --git a/css/icons.scss b/css/icons.scss index c4a7791fb..1e8b25048 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -38,7 +38,7 @@ } .icon-badge { - background-image: url('../../../core/img/places/calendar-dark.svg'); + background-image: url('../img/calendar-dark.svg'); } .icon-toggle-compact-collapsed { diff --git a/css/style.scss b/css/style.scss index 1a57ab7de..4f0e03b1e 100644 --- a/css/style.scss +++ b/css/style.scss @@ -1258,7 +1258,6 @@ input.input-inline { .tab { height: 100%; - overflow: scroll; } } diff --git a/img/calendar-dark.svg b/img/calendar-dark.svg new file mode 100644 index 000000000..4ea05fefe --- /dev/null +++ b/img/calendar-dark.svg @@ -0,0 +1 @@ + diff --git a/js/app/App.js b/js/app/App.js index 1a77c69cc..f47a34fc3 100644 --- a/js/app/App.js +++ b/js/app/App.js @@ -4,20 +4,20 @@ * @author Julius Härtl * * @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 . - * + * */ /* global angular */ @@ -48,13 +48,15 @@ import ngsortable from 'ng-sortable'; import md from 'angular-markdown-it'; import nganimate from 'angular-animate'; import 'angular-file-upload'; +import ngInfiniteScroll from 'ng-infinite-scroll'; var app = angular.module('Deck', [ ngsanitize, uirouter, angularuiselect, ngsortable, md, nganimate, - 'angularFileUpload' + 'angularFileUpload', + ngInfiniteScroll ]); export default app; diff --git a/js/controller/ActivityController.js b/js/controller/ActivityController.js new file mode 100644 index 000000000..77ebeaef6 --- /dev/null +++ b/js/controller/ActivityController.js @@ -0,0 +1,99 @@ +/* + * @copyright Copyright (c) 2018 Julius Härtl + * + * @author Julius Härtl + * + * @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 . + * + */ + +/* global OC OCA */ + +class ActivityController { + constructor ($scope, CardService, ActivityService) { + 'ngInject'; + this.cardservice = CardService; + this.activityservice = ActivityService; + this.$scope = $scope; + this.type = ''; + this.loading = false; + + const self = this; + this.$scope.$watch(function () { + return self.element.id; + }, function (params) { + if (self.getData(self.element.id).length === 0) { + self.loading = true; + self.fetchUntilResults(); + } + self.activityservice.fetchNewerActivities(self.type, self.element.id).then(function () {}); + }, true); + } + + getData(id) { + return this.activityservice.getData(this.type, id); + } + + parseMessage(subject, parameters) { + OCA.Activity.RichObjectStringParser._userLocalTemplate = ' {{ name }}'; + return OCA.Activity.RichObjectStringParser.parseMessage(subject, parameters); + } + + fetchUntilResults () { + const self = this; + let dataLengthBefore = self.getData(self.element.id).length; + let _executeFetch = function() { + let promise = self.activityservice.fetchMoreActivities(self.type, self.element.id); + promise.then(function (data) { + let dataLengthAfter = self.getData(self.element.id).length; + if (data !== null && (dataLengthAfter <= dataLengthBefore || dataLengthAfter < 5)) { + _executeFetch(); + } else { + self.loading = false; + } + }, function () { + self.loading = false; + self.$scope.$apply(); + }); + + }; + _executeFetch(); + } + + page() { + if (!this.activityservice.since[this.type][this.element.id].finished) { + this.loading = true; + this.fetchUntilResults(); + } else { + this.loading = false; + } + } + + loadingNewer() { + return this.activityservice.runningNewer; + } + +} + +let activityComponent = { + templateUrl: OC.linkTo('deck', 'templates/part.card.activity.html'), + controller: ActivityController, + bindings: { + type: '@', + element: '=' + } +}; +export default activityComponent; diff --git a/js/controller/BoardController.js b/js/controller/BoardController.js index 34a0c4d64..722c34d56 100644 --- a/js/controller/BoardController.js +++ b/js/controller/BoardController.js @@ -434,6 +434,14 @@ app.controller('BoardController', function ($rootScope, $scope, $stateParams, St }; }; + $scope.colorValue = function(color) { + const re = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/; + if (re.test(color)) { + return color; + } + return ''; + }; + $scope.attachmentCount = function(card) { if (Array.isArray(card.attachments)) { return card.attachments.filter((obj) => obj.deletedAt === 0).length; diff --git a/js/directive/avatar.js b/js/directive/avatar.js index 79cd41a85..8f20ab3e2 100644 --- a/js/directive/avatar.js +++ b/js/directive/avatar.js @@ -4,20 +4,20 @@ * @author Julius Härtl * * @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 . - * + * */ import app from '../app/App.js'; @@ -32,6 +32,10 @@ app.directive('avatar', function() { link: function(scope, element, attr){ scope.uid = attr.displayname; scope.displayname = attr.displayname; + scope.size = attr.size; + if (typeof scope.size === 'undefined') { + scope.size = 32; + } var value = attr.user; var avatardiv = $(element).find('.avatardiv'); if(typeof attr.contactsmenu !== 'undefined' && attr.contactsmenu !== 'false') { @@ -44,8 +48,8 @@ app.directive('avatar', function() { placement: 'top' }); } - avatardiv.avatar(value, 32, false, false, false, attr.displayname); + avatardiv.avatar(value, scope.size, false, false, false, attr.displayname); }, controller: function () {} }; -}); \ No newline at end of file +}); diff --git a/js/directive/bindHtmlCompile.js b/js/directive/bindHtmlCompile.js new file mode 100644 index 000000000..dbaa2d5e7 --- /dev/null +++ b/js/directive/bindHtmlCompile.js @@ -0,0 +1,38 @@ +/* + * @copyright Copyright (c) 2018 Julius Härtl + * + * @author Julius Härtl + * + * @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 . + * + */ +import app from '../app/App.js'; + +app.directive('bindHtmlCompile', function ($compile) { + 'use strict'; + + return { + restrict: 'A', + link: function (scope, element, attrs) { + scope.$watch(function () { + return scope.$eval(attrs.bindHtmlCompile); + }, function (value) { + element.html(value); + $compile(element.contents())(scope); + }); + } + }; +}); diff --git a/js/init.js b/js/init.js index 3cbcbedb7..0609fcc04 100644 --- a/js/init.js +++ b/js/init.js @@ -14,9 +14,11 @@ import './app/Run.js'; import ListController from 'controller/ListController.js'; import attachmentListComponent from './controller/AttachmentController.js'; +import activityComponent from './controller/ActivityController.js'; app.controller('ListController', ListController); app.component('attachmentListComponent', attachmentListComponent); +app.component('activityComponent', activityComponent); // require all the js files from subdirectories diff --git a/js/package-lock.json b/js/package-lock.json index e3009410d..42e9a5b62 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -2970,8 +2970,7 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "aproba": { "version": "1.2.0", @@ -2992,14 +2991,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3014,20 +3011,17 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -3144,8 +3138,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -3157,7 +3150,6 @@ "version": "1.0.0", "bundled": true, "dev": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3172,7 +3164,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3180,14 +3171,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -3206,7 +3195,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -3287,8 +3275,7 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "object-assign": { "version": "4.1.1", @@ -3300,7 +3287,6 @@ "version": "1.4.0", "bundled": true, "dev": true, - "optional": true, "requires": { "wrappy": "1" } @@ -3386,8 +3372,7 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "safer-buffer": { "version": "2.1.2", @@ -3423,7 +3408,6 @@ "version": "1.0.2", "bundled": true, "dev": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3443,7 +3427,6 @@ "version": "3.0.1", "bundled": true, "dev": true, - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3487,14 +3470,12 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true, - "optional": true + "dev": true } } }, @@ -4917,6 +4898,11 @@ "integrity": "sha512-vdqTKI9GBIYcAEbFAcpKPErKINfPF5zIuz3/niBfq8WUZjpT2tytLlFVrBgWdOtqI4uaA/Rb6No0hux39XXDuw==", "dev": true }, + "ng-infinite-scroll": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ng-infinite-scroll/-/ng-infinite-scroll-1.3.0.tgz", + "integrity": "sha1-wumNj9E0sFJaTSz1jJXZtYN1URI=" + }, "ng-sortable": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ng-sortable/-/ng-sortable-1.3.8.tgz", diff --git a/js/package.json b/js/package.json index 5a20b4b4d..0b11f9b98 100644 --- a/js/package.json +++ b/js/package.json @@ -17,6 +17,7 @@ "babel-polyfill": "^6.26.0", "markdown-it": "^8.4.2", "markdown-it-link-target": "^1.0.2", + "ng-infinite-scroll": "^1.3.0", "ng-sortable": "^1.3.8", "ui-select": "^0.19.8" }, diff --git a/js/service/ActivityService.js b/js/service/ActivityService.js new file mode 100644 index 000000000..514057edd --- /dev/null +++ b/js/service/ActivityService.js @@ -0,0 +1,187 @@ +/* + * @copyright Copyright (c) 2018 Julius Härtl + * + * @author Julius Härtl + * + * @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 . + * + */ + +import app from '../app/App.js'; + +const DECK_ACTIVITY_TYPE_BOARD = 'deck_board'; +const DECK_ACTIVITY_TYPE_CARD = 'deck_card'; + +/* global OC oc_requesttoken */ +class ActivityService { + + constructor ($rootScope, $filter, $http, $q) { + this.running = false; + this.runningNewer = false; + this.$filter = $filter; + this.$http = $http; + this.$q = $q; + this.data = {}; + this.data[DECK_ACTIVITY_TYPE_BOARD] = {}; + this.data[DECK_ACTIVITY_TYPE_CARD] = {}; + this.since = { + deck_card: { + + }, + deck_board: { + + }, + }; + } + + static getUrl(type, id, since) { + if (type === DECK_ACTIVITY_TYPE_CARD) { + return OC.linkToOCS('apps/activity/api/v2/activity', 2) + 'filter?format=json&object_type=deck_card&object_id=' + id + '&limit=50&since=' + since; + } + if (type === DECK_ACTIVITY_TYPE_BOARD) { + return OC.linkToOCS('apps/activity/api/v2/activity', 2) + 'deck?format=json&limit=50&since=' + since; + } + } + + fetchCardActivities(type, id, since) { + this.running = true; + + this.checkData(type, id); + const self = this; + return this.$http.get(ActivityService.getUrl(type, id, since)).then(function (response) { + const objects = response.data.ocs.data; + + for (let index in objects) { + if (objects.hasOwnProperty(index)) { + let item = objects[index]; + self.addItem(type, id, item); + if (item.activity_id > self.since[type][id].latest) { + self.since[type][id].latest = item.activity_id; + } + } + } + self.data[type][id].sort(function(a, b) { + return b.activity_id - a.activity_id; + }); + self.since[type][id].oldest = response.headers('X-Activity-Last-Given'); + self.running = false; + return response; + }, function (error) { + if (error.status === 304) { + self.since[type][id].finished = true; + } + self.running = false; + }); + } + fetchMoreActivities(type, id) { + this.checkData(type, id); + if (this.running === true) { + return this.runningPromise; + } + if (!this.since[type][id].finished) { + this.runningPromise = this.fetchCardActivities(type, id, this.since[type][id].oldest); + return this.runningPromise; + } + return Promise.reject(); + } + checkData(type, id) { + if (!Array.isArray(this.data[type][id])) { + this.data[type][id] = []; + } + if (typeof this.since[type][id] === 'undefined') { + this.since[type][id] = { + latest: 0, + oldestCatchedUp: false, + oldest: '0', + finished: false, + }; + } + } + + addItem(type, id, item) { + const existingEntry = this.data[type][id].findIndex((entry) => { return entry.activity_id === item.activity_id; }); + if (existingEntry !== -1) { + return; + } + /** check if the fetched item from all deck activities is actually related */ + const isUnrelatedBoard = (item.object_type === DECK_ACTIVITY_TYPE_BOARD && item.object_id !== id); + const isUnrelatedCard = (item.object_type === DECK_ACTIVITY_TYPE_CARD && item.subject_rich[1].board && item.subject_rich[1].board.id !== id); + if (type === DECK_ACTIVITY_TYPE_BOARD && (isUnrelatedBoard || isUnrelatedCard)) { + return; + } + item.timestamp = new Date(item.datetime).getTime(); + this.data[type][id].push(item); + } + + /** + * Fetch newer activities starting from the latest ones that are in cache + * + * @param type + * @param id + */ + fetchNewerActivities(type, id) { + if (this.since[type][id].latest === 0) { + return Promise.resolve(); + } + let self = this; + return this.fetchNewer(type, id).then(function() { + return self.fetchNewerActivities(type, id); + }); + } + + fetchNewer(type, id) { + const deferred = this.$q.defer(); + this.running = true; + this.runningNewer = true; + const self = this; + this.$http.get(ActivityService.getUrl(type, id, this.since[type][id].latest) + '&sort=asc').then(function (response) { + let objects = response.data.ocs.data; + + let data = []; + for (let index in objects) { + if (objects.hasOwnProperty(index)) { + let item = objects[index]; + self.addItem(type, id, item); + } + } + self.data[type][id].sort(function(a, b) { + return b.activity_id - a.activity_id; + }); + self.since[type][id].latest = response.headers('X-Activity-Last-Given'); + self.data[type][id] = data.concat(self.data[type][id]); + self.running = false; + self.runningNewer = false; + deferred.resolve(objects); + }, function (error) { + self.runningNewer = false; + self.running = false; + }); + return deferred.promise; + } + + getData(type, id) { + if (!Array.isArray(this.data[type][id])) { + return []; + } + return this.data[type][id]; + } + +} + +app.service('ActivityService', ActivityService); + +export default ActivityService; +export {DECK_ACTIVITY_TYPE_BOARD, DECK_ACTIVITY_TYPE_CARD}; diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php new file mode 100644 index 000000000..5f3e1b03d --- /dev/null +++ b/lib/Activity/ActivityManager.php @@ -0,0 +1,481 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use InvalidArgumentException; +use OCA\Deck\Db\Acl; +use OCA\Deck\Db\AclMapper; +use OCA\Deck\Db\AssignedUsers; +use OCA\Deck\Db\Attachment; +use OCA\Deck\Db\AttachmentMapper; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\Stack; +use OCA\Deck\Db\StackMapper; +use OCA\Deck\Service\PermissionService; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\IL10N; +use OCP\IUser; + +class ActivityManager { + + private $manager; + private $userId; + private $permissionService; + private $boardMapper; + private $cardMapper; + private $attachmentMapper; + private $aclMapper; + private $stackMapper; + private $l10n; + + const DECK_OBJECT_BOARD = 'deck_board'; + const DECK_OBJECT_CARD = 'deck_card'; + + const SUBJECT_BOARD_CREATE = 'board_create'; + const SUBJECT_BOARD_UPDATE = 'board_update'; + const SUBJECT_BOARD_UPDATE_TITLE = 'board_update_title'; + const SUBJECT_BOARD_UPDATE_ARCHIVED = 'board_update_archived'; + const SUBJECT_BOARD_DELETE = 'board_delete'; + const SUBJECT_BOARD_RESTORE = 'board_restore'; + const SUBJECT_BOARD_SHARE = 'board_share'; + const SUBJECT_BOARD_UNSHARE = 'board_unshare'; + + const SUBJECT_STACK_CREATE = 'stack_create'; + const SUBJECT_STACK_UPDATE = 'stack_update'; + const SUBJECT_STACK_UPDATE_TITLE = 'stack_update_title'; + const SUBJECT_STACK_UPDATE_ORDER = 'stack_update_order'; + const SUBJECT_STACK_DELETE = 'stack_delete'; + + const SUBJECT_CARD_CREATE = 'card_create'; + const SUBJECT_CARD_DELETE = 'card_delete'; + const SUBJECT_CARD_RESTORE = 'card_restore'; + const SUBJECT_CARD_UPDATE = 'card_update'; + const SUBJECT_CARD_UPDATE_TITLE = 'card_update_title'; + const SUBJECT_CARD_UPDATE_DESCRIPTION = 'card_update_description'; + const SUBJECT_CARD_UPDATE_DUEDATE = 'card_update_duedate'; + const SUBJECT_CARD_UPDATE_ARCHIVE = 'card_update_archive'; + const SUBJECT_CARD_UPDATE_UNARCHIVE = 'card_update_unarchive'; + const SUBJECT_CARD_UPDATE_STACKID = 'card_update_stackId'; + const SUBJECT_CARD_USER_ASSIGN = 'card_user_assign'; + const SUBJECT_CARD_USER_UNASSIGN = 'card_user_unassign'; + + const SUBJECT_ATTACHMENT_CREATE = 'attachment_create'; + const SUBJECT_ATTACHMENT_UPDATE = 'attachment_update'; + const SUBJECT_ATTACHMENT_DELETE = 'attachment_delete'; + const SUBJECT_ATTACHMENT_RESTORE = 'attachment_restore'; + + const SUBJECT_LABEL_CREATE = 'label_create'; + const SUBJECT_LABEL_UPDATE = 'label_update'; + const SUBJECT_LABEL_DELETE = 'label_delete'; + const SUBJECT_LABEL_ASSIGN = 'label_assign'; + const SUBJECT_LABEL_UNASSING = 'label_unassign'; + + public function __construct( + IManager $manager, + PermissionService $permissionsService, + BoardMapper $boardMapper, + CardMapper $cardMapper, + StackMapper $stackMapper, + AttachmentMapper $attachmentMapper, + AclMapper $aclMapper, + IL10N $l10n, + $userId + ) { + $this->manager = $manager; + $this->permissionService = $permissionsService; + $this->boardMapper = $boardMapper; + $this->cardMapper = $cardMapper; + $this->stackMapper = $stackMapper; + $this->attachmentMapper = $attachmentMapper; + $this->aclMapper = $aclMapper; + $this->l10n = $l10n; + $this->userId = $userId; + } + + /** + * @param $subjectIdentifier + * @param array $subjectParams + * @param bool $ownActivity + * @return string + */ + public function getActivityFormat($subjectIdentifier, $subjectParams = [], $ownActivity = false) { + $subject = ''; + switch ($subjectIdentifier) { + case self::SUBJECT_BOARD_CREATE: + $subject = $ownActivity ? $this->l10n->t('You have created a new board {board}'): $this->l10n->t('{user} has created a new board {board}'); + break; + case self::SUBJECT_BOARD_DELETE: + $subject = $ownActivity ? $this->l10n->t('You have deleted the board {board}') : $this->l10n->t('{user} has deleted the board {board}'); + break; + case self::SUBJECT_BOARD_RESTORE: + $subject = $ownActivity ? $this->l10n->t('You have restored the board {board}') : $this->l10n->t('{user} has restored the board {board}'); + break; + case self::SUBJECT_BOARD_SHARE: + $subject = $ownActivity ? $this->l10n->t('You have shared the board {board} with {acl}') : $this->l10n->t('{user} has shared the board {board} with {sharee}'); + break; + case self::SUBJECT_BOARD_UNSHARE: + $subject = $ownActivity ? $this->l10n->t('You have removed {acl} from the board {board}') : $this->l10n->t('{user} has removed {acl} from the board {board}'); + break; + + case self::SUBJECT_BOARD_UPDATE_TITLE: + $subject = $ownActivity ? $this->l10n->t('You have renamed the board {before} to {board}') : $this->l10n->t('{user} has has renamed the board {before} to {board}'); + break; + case self::SUBJECT_BOARD_UPDATE_ARCHIVED: + if (isset($subjectParams['after']) && $subjectParams['after']) { + $subject = $ownActivity ? $this->l10n->t('You have archived the board {board}') : $this->l10n->t('{user} has archived the board {before}'); + } else { + $subject = $ownActivity ? $this->l10n->t('You have unarchived the board {board}') : $this->l10n->t('{user} has unarchived the board {before}'); + } + break; + + case self::SUBJECT_STACK_CREATE: + $subject = $ownActivity ? $this->l10n->t('You have created a new stack {stack} on {board}') : $this->l10n->t('{user} has created a new stack {stack} on {board}'); + break; + case self::SUBJECT_STACK_UPDATE: + $subject = $ownActivity ? $this->l10n->t('You have created a new stack {stack} on {board}') : $this->l10n->t('{user} has created a new stack {stack} on {board}'); + break; + case self::SUBJECT_STACK_UPDATE_TITLE: + $subject = $ownActivity ? $this->l10n->t('You have renamed a new stack {before} to {stack} on {board}') : $this->l10n->t('{user} has renamed a new stack {before} to {stack} on {board}'); + break; + case self::SUBJECT_STACK_DELETE: + $subject = $ownActivity ? $this->l10n->t('You have deleted {stack} on {board}') : $this->l10n->t('{user} has deleted {stack} on {board}'); + break; + case self::SUBJECT_CARD_CREATE: + $subject = $ownActivity ? $this->l10n->t('You have created {card} in {stack} on {board}') : $this->l10n->t('{user} has created {card} in {stack} on {board}'); + break; + case self::SUBJECT_CARD_DELETE: + $subject = $ownActivity ? $this->l10n->t('You have deleted {card} in {stack} on {board}') : $this->l10n->t('{user} has deleted {card} in {stack} on {board}'); + break; + case self::SUBJECT_CARD_UPDATE_TITLE: + $subject = $ownActivity ? $this->l10n->t('You have renamed the card {before} to {card}') : $this->l10n->t('{user} has renamed the card {before} to {card}'); + break; + case self::SUBJECT_CARD_UPDATE_DESCRIPTION: + if (!isset($subjectParams['before'])) { + $subject = $ownActivity ? $this->l10n->t('You have added a description to {card} in {stack} on {board}') : $this->l10n->t('{user} has added a description to {card} in {stack} on {board}'); + } else { + $subject = $ownActivity ? $this->l10n->t('You have updated the description of {card} in {stack} on {board}') : $this->l10n->t('{user} has updated the description {card} in {stack} on {board}'); + } + break; + case self::SUBJECT_CARD_UPDATE_ARCHIVE: + $subject = $ownActivity ? $this->l10n->t('You have archived {card} in {stack} on {board}') : $this->l10n->t('{user} has archived {card} in {stack} on {board}'); + break; + case self::SUBJECT_CARD_UPDATE_UNARCHIVE: + $subject = $ownActivity ? $this->l10n->t('You have unarchived {card} in {stack} on {board}') : $this->l10n->t('{user} has unarchived {card} in {stack} on {board}'); + break; + case self::SUBJECT_CARD_UPDATE_DUEDATE: + if (!isset($subjectParams['after'])) { + $subject = $ownActivity ? $this->l10n->t('You have removed the due date of {card}') : $this->l10n->t('{user} has removed the due date of {card}'); + } else if (isset($subjectParams['before']) && !isset($subjectParams['after'])) { + $subject = $ownActivity ? $this->l10n->t('You have set the due date of {card} to {after}') : $this->l10n->t('{user} has set the due date of {card} to {after}'); + } else { + $subject = $ownActivity ? $this->l10n->t('You have updated the due date of {card} to {after}') : $this->l10n->t('{user} has updated the due date of {card} to {after}'); + } + + break; + case self::SUBJECT_LABEL_ASSIGN: + $subject = $ownActivity ? $this->l10n->t('You have added the label {label} to {card} in {stack} on {board}') : $this->l10n->t('{user} has added the label {label} to {card} in {stack} on {board}'); + break; + case self::SUBJECT_LABEL_UNASSING: + $subject = $ownActivity ? $this->l10n->t('You have removed the label {label} from {card} in {stack} on {board}') : $this->l10n->t('{user} has removed the label {label} from {card} in {stack} on {board}'); + break; + case self::SUBJECT_CARD_USER_ASSIGN: + $subject = $ownActivity ? $this->l10n->t('You have assigned {assigneduser} to {card} on {board}') : $this->l10n->t('{user} has assigned {assigneduser} to {card} on {board}'); + break; + case self::SUBJECT_CARD_USER_UNASSIGN: + $subject = $ownActivity ? $this->l10n->t('You have unassigned {assigneduser} from {card} on {board}') : $this->l10n->t('{user} has unassigned {assigneduser} from {card} on {board}'); + break; + case self::SUBJECT_CARD_UPDATE_STACKID: + $subject = $ownActivity ? $this->l10n->t('You have moved the card {card} from {stackBefore} to {stack}') : $this->l10n->t('{user} has moved the card {card} from {stackBefore} to {stack}'); + break; + case self::SUBJECT_ATTACHMENT_CREATE: + $subject = $ownActivity ? $this->l10n->t('You have added the attachment {attachment} to {card}') : $this->l10n->t('{user} has added the attachment {attachment} to {card}'); + break; + case self::SUBJECT_ATTACHMENT_UPDATE: + $subject = $ownActivity ? $this->l10n->t('You have updated the attachment {attachment} on {card}') : $this->l10n->t('{user} has updated the attachment {attachment} to {card}'); + break; + case self::SUBJECT_ATTACHMENT_DELETE: + $subject = $ownActivity ? $this->l10n->t('You have deleted the attachment {attachment} from {card}') : $this->l10n->t('{user} has deleted the attachment {attachment} to {card}'); + break; + case self::SUBJECT_ATTACHMENT_RESTORE: + $subject = $ownActivity ? $this->l10n->t('You have restored the attachment {attachment} to {card}') : $this->l10n->t('{user} has restored the attachment {attachment} to {card}'); + break; + default: + break; + } + return $subject; + } + + public function triggerEvent($objectType, $entity, $subject, $additionalParams = []) { + try { + $event = $this->createEvent($objectType, $entity, $subject, $additionalParams); + $this->sendToUsers($event); + } catch (\Exception $e) { + // Ignore exception for undefined activities on update events + } + } + + /** + * + * @param $objectType + * @param ChangeSet $changeSet + * @param $subject + * @throws \Exception + */ + public function triggerUpdateEvents($objectType, ChangeSet $changeSet, $subject) { + $previousEntity = $changeSet->getBefore(); + $entity = $changeSet->getAfter(); + $events = []; + if ($previousEntity !== null) { + foreach ($entity->getUpdatedFields() as $field => $value) { + $getter = 'get' . ucfirst($field); + $subject = $subject . '_' . $field; + $changes = [ + 'before' => $previousEntity->$getter(), + 'after' => $entity->$getter() + ]; + if ($changes['before'] !== $changes['after']) { + try { + $event = $this->createEvent($objectType, $entity, $subject, $changes); + $events[] = $event; + } catch (\Exception $e) { + // Ignore exception for undefined activities on update events + } + } + } + } else { + try { + $events = [$this->createEvent($objectType, $entity, $subject)]; + } catch (\Exception $e) { + // Ignore exception for undefined activities on update events + } + } + foreach ($events as $event) { + $this->sendToUsers($event); + } + } + + /** + * @param $objectType + * @param $entity + * @param $subject + * @param array $additionalParams + * @return IEvent + * @throws \Exception + */ + private function createEvent($objectType, $entity, $subject, $additionalParams = []) { + try { + $object = $this->findObjectForEntity($objectType, $entity); + } catch (DoesNotExistException $e) { + } catch (MultipleObjectsReturnedException $e) { + \OC::$server->getLogger()->error('Could not create activity entry for ' . $subject . '. Entity not found.', $entity); + return null; + } + + /** + * Automatically fetch related details for subject parameters + * depending on the subject + */ + $subjectParams = []; + $message = null; + switch ($subject) { + // No need to enhance parameters since entity already contains the required data + case self::SUBJECT_BOARD_CREATE: + case self::SUBJECT_BOARD_UPDATE_TITLE: + case self::SUBJECT_BOARD_UPDATE_ARCHIVED: + case self::SUBJECT_BOARD_DELETE: + case self::SUBJECT_BOARD_RESTORE: + // Not defined as there is no activity for + // case self::SUBJECT_BOARD_UPDATE_COLOR + break; + + case self::SUBJECT_STACK_CREATE: + case self::SUBJECT_STACK_UPDATE: + case self::SUBJECT_STACK_UPDATE_TITLE: + case self::SUBJECT_STACK_UPDATE_ORDER: + case self::SUBJECT_STACK_DELETE: + $subjectParams = $this->findDetailsForStack($entity->getId()); + break; + + case self::SUBJECT_CARD_CREATE: + case self::SUBJECT_CARD_DELETE: + case self::SUBJECT_CARD_UPDATE_ARCHIVE: + case self::SUBJECT_CARD_UPDATE_UNARCHIVE: + case self::SUBJECT_CARD_UPDATE_TITLE: + case self::SUBJECT_CARD_UPDATE_DESCRIPTION: + case self::SUBJECT_CARD_UPDATE_DUEDATE: + case self::SUBJECT_CARD_UPDATE_STACKID: + case self::SUBJECT_LABEL_ASSIGN: + case self::SUBJECT_LABEL_UNASSING: + case self::SUBJECT_CARD_USER_ASSIGN: + case self::SUBJECT_CARD_USER_UNASSIGN: + $subjectParams = $this->findDetailsForCard($entity->getId()); + $object = $entity; + break; + case self::SUBJECT_ATTACHMENT_CREATE: + case self::SUBJECT_ATTACHMENT_UPDATE: + case self::SUBJECT_ATTACHMENT_DELETE: + case self::SUBJECT_ATTACHMENT_RESTORE: + $subjectParams = $this->findDetailsForAttachment($entity->getId()); + $object = $subjectParams['card']; + break; + case self::SUBJECT_BOARD_SHARE: + case self::SUBJECT_BOARD_UNSHARE: + $subjectParams = $this->findDetailsForAcl($entity->getId()); + break; + default: + throw new \Exception('Unknown subject for activity.'); + break; + } + + if ($subject === self::SUBJECT_CARD_UPDATE_DESCRIPTION){ + $subjectParams['diff'] = true; + } + if ($subject === self::SUBJECT_CARD_UPDATE_STACKID) { + $subjectParams['stackBefore'] = $this->stackMapper->find($additionalParams['before']); + } + + $event = $this->manager->generateEvent(); + $event->setApp('deck') + ->setType('deck') + ->setAuthor($this->userId) + ->setObject($objectType, (int)$object->getId(), $object->getTitle()) + ->setSubject($subject, array_merge($subjectParams, $additionalParams)) + ->setTimestamp(time()); + + if ($message !== null) { + $event->setMessage($message); + } + + return $event; + } + + /** + * Publish activity to all users that are part of the board of a given object + * + * @param IEvent $event + */ + private function sendToUsers(IEvent $event) { + switch ($event->getObjectType()) { + case self::DECK_OBJECT_BOARD: + $mapper = $this->boardMapper; + break; + case self::DECK_OBJECT_CARD: + $mapper = $this->cardMapper; + break; + } + $boardId = $mapper->findBoardId($event->getObjectId()); + /** @var IUser $user */ + foreach ($this->permissionService->findUsers($boardId) as $user) { + $event->setAffectedUser($user->getUID()); + /** @noinspection DisconnectedForeachInstructionInspection */ + $this->manager->publish($event); + } + } + + /** + * @param $objectType + * @param $entity + * @return null|\OCA\Deck\Db\RelationalEntity|\OCP\AppFramework\Db\Entity + * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + */ + private function findObjectForEntity($objectType, $entity) { + $className = \get_class($entity); + $objectId = null; + if ($objectType === self::DECK_OBJECT_CARD) { + switch ($className) { + case Card::class: + $objectId = $entity->getId(); + break; + case Attachment::class: + case Label::class: + case AssignedUsers::class: + $objectId = $entity->getCardId(); + break; + default: + throw new InvalidArgumentException('No entity relation present for '. $className . ' to ' . $objectType); + } + return $this->cardMapper->find($objectId); + } + if ($objectType === self::DECK_OBJECT_BOARD) { + switch ($className) { + case Board::class: + $objectId = $entity->getId(); + break; + case Label::class: + case Stack::class: + case Acl::class: + $objectId = $entity->getBoardId(); + break; + default: + throw new InvalidArgumentException('No entity relation present for '. $className . ' to ' . $objectType); + } + return $this->boardMapper->find($objectId); + } + throw new InvalidArgumentException('No entity relation present for '. $className . ' to ' . $objectType); + } + + private function findDetailsForStack($stackId) { + $stack = $this->stackMapper->find($stackId); + $board = $this->boardMapper->find($stack->getBoardId()); + return [ + 'stack' => $stack, + 'board' => $board + ]; + } + + private function findDetailsForCard($cardId) { + $card = $this->cardMapper->find($cardId); + $stack = $this->stackMapper->find($card->getStackId()); + $board = $this->boardMapper->find($stack->getBoardId()); + return [ + 'card' => $card, + 'stack' => $stack, + 'board' => $board + ]; + } + + private function findDetailsForAttachment($attachmentId) { + $attachment = $this->attachmentMapper->find($attachmentId); + $data = $this->findDetailsForCard($attachment->getCardId()); + return array_merge($data, ['attachment' => $attachment]); + } + + private function findDetailsForAcl($aclId) { + $acl = $this->aclMapper->find($aclId); + $board = $this->boardMapper->find($acl->getBoardId()); + return [ + 'acl' => $acl, + 'board' => $board + ]; + } + +} diff --git a/lib/Activity/ChangeSet.php b/lib/Activity/ChangeSet.php new file mode 100644 index 000000000..9040192a4 --- /dev/null +++ b/lib/Activity/ChangeSet.php @@ -0,0 +1,90 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + + +class ChangeSet implements \JsonSerializable { + + private $before; + private $after; + private $diff = false; + + public function __construct($before = null, $after = null) { + if ($before !== null) { + $this->setBefore($before); + } + if ($after !== null) { + $this->setAfter($after); + } + } + + public function enableDiff() { + $this->diff = true; + } + + public function getDiff() { + return $this->diff; + } + + public function setBefore($before) { + if (is_object($before)) { + $this->before = clone $before; + } else { + $this->before = $before; + } + } + + public function setAfter($after) { + if (is_object($after)) { + $this->after = clone $after; + } else { + $this->after = $after; + } + } + + public function getBefore() { + return $this->before; + } + + public function getAfter() { + return $this->after; + } + + /** + * Specify data which should be serialized to JSON + * + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + * @since 5.4.0 + */ + public function jsonSerialize() { + return [ + 'before' => $this->getBefore(), + 'after' => $this->getAfter(), + 'diff' => $this->getDiff(), + 'type' => get_class($this->before) + ]; + } +} diff --git a/lib/Activity/DeckProvider.php b/lib/Activity/DeckProvider.php new file mode 100644 index 000000000..e8ff3e070 --- /dev/null +++ b/lib/Activity/DeckProvider.php @@ -0,0 +1,265 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + + +use cogpowered\FineDiff\Diff; +use OCA\Deck\Db\Acl; +use OCP\Activity\IEvent; +use OCP\Activity\IProvider; +use OCP\IURLGenerator; +use OCP\IUserManager; + +class DeckProvider implements IProvider { + + /** @var string */ + private $userId; + /** @var IURLGenerator */ + private $urlGenerator; + /** @var ActivityManager */ + private $activityManager; + /** @var IUserManager */ + private $userManager; + + public function __construct(IURLGenerator $urlGenerator, ActivityManager $activityManager, IUserManager $userManager, $userId) { + $this->userId = $userId; + $this->urlGenerator = $urlGenerator; + $this->activityManager = $activityManager; + $this->userManager = $userManager; + } + + /** + * @param string $language The language which should be used for translating, e.g. "en" + * @param IEvent $event The current event which should be parsed + * @param IEvent|null $previousEvent A potential previous event which you can combine with the current one. + * To do so, simply use setChildEvent($previousEvent) after setting the + * combined subject on the current event. + * @return IEvent + * @throws \InvalidArgumentException Should be thrown if your provider does not know this event + * @since 11.0.0 + */ + public function parse($language, IEvent $event, IEvent $previousEvent = null) { + if ($event->getApp() !== 'deck') { + throw new \InvalidArgumentException(); + } + + $event = $this->getIcon($event); + + $subjectIdentifier = $event->getSubject(); + $subjectParams = $event->getSubjectParameters(); + $ownActivity = ($event->getAuthor() === $this->userId); + + /** + * Map stored parameter objects to rich string types + */ + $board = null; + if ($event->getObjectType() === ActivityManager::DECK_OBJECT_BOARD) { + $board = [ + 'type' => 'highlight', + 'id' => $event->getObjectId(), + 'name' => $event->getObjectName(), + 'link' => $this->deckUrl('/board/' . $event->getObjectId()), + ]; + } + + $card = null; + if ($event->getObjectType() === ActivityManager::DECK_OBJECT_CARD) { + $card = [ + 'type' => 'highlight', + 'id' => $event->getObjectId(), + 'name' => $event->getObjectName(), + ]; + + if (array_key_exists('board', $subjectParams)) { + $archivedParam = $subjectParams['card']['archived'] ? 'archived' : ''; + $card['link'] = $this->deckUrl('/board/' . $subjectParams['board']['id'] . '/' . $archivedParam . '/card/' . $event->getObjectId()); + } + } + + $author = $event->getAuthor(); + $user = $this->userManager->get($author); + $params = [ + 'board' => $board, + 'card' => $card, + 'user' => [ + 'type' => 'user', + 'id' => $author, + 'name' => $user !== null ? $user->getDisplayName() : $author + ] + ]; + + $params = $this->parseParamForBoard('board', $subjectParams, $params); + $params = $this->parseParamForStack('stack', $subjectParams, $params); + $params = $this->parseParamForStack('stackBefore', $subjectParams, $params); + $params = $this->parseParamForAttachment('attachment', $subjectParams, $params); + $params = $this->parseParamForLabel($subjectParams, $params); + $params = $this->parseParamForAssignedUser($subjectParams, $params); + $params = $this->parseParamForAcl($subjectParams, $params); + $params = $this->parseParamForChanges($subjectParams, $params, $event); + + try { + $subject = $this->activityManager->getActivityFormat($subjectIdentifier, $subjectParams, $ownActivity); + $event->setParsedSubject($subject); + $event->setRichSubject($subject, $params); + } catch (\Exception $e) { + } + return $event; + } + + private function getIcon(IEvent $event) { + $event->setIcon($this->urlGenerator->imagePath('deck', 'deck-dark.svg')); + if (strpos($event->getSubject(), '_update') !== false) { + $event->setIcon($this->urlGenerator->imagePath('files', 'change.svg')); + } + if (strpos($event->getSubject(), '_create') !== false) { + $event->setIcon($this->urlGenerator->imagePath('files', 'add-color.svg')); + } + if (strpos($event->getSubject(), '_delete') !== false) { + $event->setIcon($this->urlGenerator->imagePath('files', 'delete-color.svg')); + } + if (strpos($event->getSubject(), 'archive') !== false) { + $event->setIcon($this->urlGenerator->imagePath('deck', 'archive.svg')); + } + if (strpos($event->getSubject(), '_restore') !== false) { + $event->setIcon($this->urlGenerator->imagePath('core', 'actions/history.svg')); + } + if (strpos($event->getSubject(), 'attachment_') !== false) { + $event->setIcon($this->urlGenerator->imagePath('core', 'places/files.svg')); + } + return $event; + } + + private function parseParamForBoard($paramName, $subjectParams, $params) { + if (array_key_exists($paramName, $subjectParams)) { + $params[$paramName] = [ + 'type' => 'highlight', + 'id' => $subjectParams[$paramName]['id'], + 'name' => $subjectParams[$paramName]['title'], + 'link' => $this->deckUrl('/board/' . $subjectParams[$paramName]['id'] . '/'), + ]; + } + return $params; + } + private function parseParamForStack($paramName, $subjectParams, $params) { + if (array_key_exists($paramName, $subjectParams)) { + $params[$paramName] = [ + 'type' => 'highlight', + 'id' => $subjectParams[$paramName]['id'], + 'name' => $subjectParams[$paramName]['title'], + ]; + } + return $params; + } + + private function parseParamForAttachment($paramName, $subjectParams, $params) { + if (array_key_exists($paramName, $subjectParams)) { + $params[$paramName] = [ + 'type' => 'highlight', + 'id' => $subjectParams[$paramName]['id'], + 'name' => $subjectParams[$paramName]['data'], + 'link' => $this->urlGenerator->linkToRoute('deck.attachment.display', ['cardId' => $subjectParams['card']['id'], 'attachmentId' => $subjectParams['attachment']['id']]), + ]; + } + return $params; + } + + private function parseParamForAssignedUser($subjectParams, $params) { + if (array_key_exists('assigneduser', $subjectParams)) { + $user = $this->userManager->get($subjectParams['assigneduser']); + $params['assigneduser'] = [ + 'type' => 'user', + 'id' => $subjectParams['assigneduser'], + 'name' => $user !== null ? $user->getDisplayName() : $subjectParams['assigneduser'] + ]; + } + return $params; + } + + private function parseParamForLabel($subjectParams, $params) { + if (array_key_exists('label', $subjectParams)) { + $params['label'] = [ + 'type' => 'highlight', + 'id' => $subjectParams['label']['id'], + 'name' => $subjectParams['label']['title'] + ]; + } + return $params; + } + + private function parseParamForAcl($subjectParams, $params) { + if (array_key_exists('acl', $subjectParams)) { + if ($subjectParams['acl']['type'] === Acl::PERMISSION_TYPE_USER) { + $user = $this->userManager->get($subjectParams['acl']['participant']); + $params['acl'] = [ + 'type' => 'user', + 'id' => $subjectParams['acl']['participant'], + 'name' => $user !== null ? $user->getDisplayName() : $subjectParams['acl']['participant'] + ]; + } else { + $params['acl'] = [ + 'type' => 'highlight', + 'id' => $subjectParams['acl']['participant'], + 'name' => $subjectParams['acl']['participant'] + ]; + } + } + return $params; + } + + /** + * Add diff to message if the subject parameter 'diff' is set, otherwise + * the changed values are added to before/after + * + * @param $subjectParams + * @param $params + * @return mixed + */ + private function parseParamForChanges($subjectParams, $params, $event) { + if (array_key_exists('diff', $subjectParams) && $subjectParams['diff']) { + $diff = new Diff(); + $event->setMessage($subjectParams['after']); + $event->setParsedMessage('
' . $diff->render($subjectParams['before'], $subjectParams['after']) . '
'); + return $params; + } + if (array_key_exists('before', $subjectParams)) { + $params['before'] = [ + 'type' => 'highlight', + 'id' => $subjectParams['before'], + 'name' => $subjectParams['before'] + ]; + } + if (array_key_exists('after', $subjectParams)) { + $params['after'] = [ + 'type' => 'highlight', + 'id' => $subjectParams['after'], + 'name' => $subjectParams['after'] + ]; + } + return $params; + } + + public function deckUrl($endpoint) { + return $this->urlGenerator->linkToRoute('deck.page.index') . '#!' . $endpoint; + } +} diff --git a/lib/Activity/Filter.php b/lib/Activity/Filter.php new file mode 100644 index 000000000..d4209b7cc --- /dev/null +++ b/lib/Activity/Filter.php @@ -0,0 +1,92 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use OCP\IL10N; +use OCP\IURLGenerator; + +class Filter implements \OCP\Activity\IFilter { + + private $l10n; + private $urlGenerator; + + public function __construct( + IL10N $l10n, + IURLGenerator $urlGenerator + ) { + $this->l10n = $l10n; + $this->urlGenerator = $urlGenerator; + } + + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'deck'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return $this->l10n->t('Deck'); + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority() { + return 90; + } + + /** + * @return string Full URL to an icon, empty string when none is given + * @since 11.0.0 + */ + public function getIcon() { + return $this->urlGenerator->imagePath('deck', 'deck-dark.svg'); + } + + /** + * @param string[] $types + * @return string[] An array of allowed apps from which activities should be displayed + * @since 11.0.0 + */ + public function filterTypes(array $types) { + return $types; + } + + /** + * @return string[] An array of allowed apps from which activities should be displayed + * @since 11.0.0 + */ + public function allowedApps() { + return ['deck']; + } +} diff --git a/lib/Activity/Setting.php b/lib/Activity/Setting.php new file mode 100644 index 000000000..12080f263 --- /dev/null +++ b/lib/Activity/Setting.php @@ -0,0 +1,86 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + + +class Setting implements \OCP\Activity\ISetting { + + /** + * @return string Lowercase a-z and underscore only identifier + * @since 11.0.0 + */ + public function getIdentifier() { + return 'deck'; + } + + /** + * @return string A translated string + * @since 11.0.0 + */ + public function getName() { + return 'Deck'; + } + + /** + * @return int whether the filter should be rather on the top or bottom of + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. + * @since 11.0.0 + */ + public function getPriority() { + return 90; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function canChangeStream() { + return true; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledStream() { + return true; + } + + /** + * @return bool True when the option can be changed for the mail + * @since 11.0.0 + */ + public function canChangeMail() { + return true; + } + + /** + * @return bool True when the option can be changed for the stream + * @since 11.0.0 + */ + public function isDefaultEnabledMail() { + return false; + } +} diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index e3f8c4898..8c7a81307 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -24,7 +24,9 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; use OCA\Deck\AppInfo\Application; +use OCA\Deck\BadRequestException; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Attachment; use OCA\Deck\Db\AttachmentMapper; @@ -52,6 +54,8 @@ class AttachmentService { private $cache; /** @var IL10N */ private $l10n; + /** @var ActivityManager */ + private $activityManager; /** * AttachmentService constructor. @@ -65,7 +69,7 @@ class AttachmentService { * @param IL10N $l10n * @throws \OCP\AppFramework\QueryException */ - public function __construct(AttachmentMapper $attachmentMapper, CardMapper $cardMapper, PermissionService $permissionService, Application $application, ICacheFactory $cacheFactory, $userId, IL10N $l10n) { + public function __construct(AttachmentMapper $attachmentMapper, CardMapper $cardMapper, PermissionService $permissionService, Application $application, ICacheFactory $cacheFactory, $userId, IL10N $l10n, ActivityManager $activityManager) { $this->attachmentMapper = $attachmentMapper; $this->cardMapper = $cardMapper; $this->permissionService = $permissionService; @@ -73,6 +77,7 @@ class AttachmentService { $this->application = $application; $this->cache = $cacheFactory->createDistributed('deck-card-attachments-'); $this->l10n = $l10n; + $this->activityManager = $activityManager; // Register shipped attachment services // TODO: move this to a plugin based approach once we have different types of attachments @@ -168,7 +173,7 @@ class AttachmentService { } if ($data === false || $data === null) { - throw new BadRequestException('data must be provided'); + //throw new BadRequestException('data must be provided'); } $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT); @@ -200,6 +205,7 @@ class AttachmentService { } catch (InvalidAttachmentType $e) { // just store the data } + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_CREATE); return $attachment; } @@ -261,7 +267,7 @@ class AttachmentService { } if ($data === false || $data === null) { - throw new BadRequestException('data must be provided'); + //throw new BadRequestException('data must be provided'); } $this->permissionService->checkPermission($this->cardMapper, $cardId, Acl::PERMISSION_EDIT); @@ -284,6 +290,7 @@ class AttachmentService { } catch (InvalidAttachmentType $e) { // just store the data } + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_UPDATE); return $attachment; } @@ -317,13 +324,16 @@ class AttachmentService { $service = $this->getService($attachment->getType()); if ($service->allowUndo()) { $service->markAsDeleted($attachment); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_DELETE); return $this->attachmentMapper->update($attachment); } $service->delete($attachment); } catch (InvalidAttachmentType $e) { // just delete without further action } - return $this->attachmentMapper->delete($attachment); + $attachment = $this->attachmentMapper->delete($attachment); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_DELETE); + return $attachment; } public function restore($cardId, $attachmentId) { @@ -344,10 +354,12 @@ class AttachmentService { $service = $this->getService($attachment->getType()); if ($service->allowUndo()) { $attachment->setDeletedAt(0); - return $this->attachmentMapper->update($attachment); + $attachment = $this->attachmentMapper->update($attachment); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_RESTORE); + return $attachment; } } catch (InvalidAttachmentType $e) { } throw new NoPermissionException('Restore is not allowed.'); } -} \ No newline at end of file +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 51c59c8b3..b421a46d8 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -23,6 +23,8 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; +use OCA\Deck\Activity\ChangeSet; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AclMapper; use OCA\Deck\Db\AssignedUsersMapper; @@ -51,6 +53,7 @@ class BoardService { private $userManager; private $groupManager; private $userId; + private $activityManager; public function __construct( BoardMapper $boardMapper, @@ -62,6 +65,7 @@ class BoardService { AssignedUsersMapper $assignedUsersMapper, IUserManager $userManager, IGroupManager $groupManager, + ActivityManager $activityManager, $userId ) { $this->boardMapper = $boardMapper; @@ -73,6 +77,7 @@ class BoardService { $this->assignedUsersMapper = $assignedUsersMapper; $this->userManager = $userManager; $this->groupManager = $groupManager; + $this->activityManager = $activityManager; $this->userId = $userId; } @@ -244,7 +249,7 @@ class BoardService { $board->setTitle($title); $board->setOwner($userId); $board->setColor($color); - $new_board = $this->boardMapper->insert($board); + $new_board = $this->boardMapper->insert($board); // create new labels $default_labels = [ @@ -270,6 +275,7 @@ class BoardService { 'PERMISSION_MANAGE' => $permissions[Acl::PERMISSION_MANAGE], 'PERMISSION_SHARE' => $permissions[Acl::PERMISSION_SHARE] ]); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $new_board, ActivityManager::SUBJECT_BOARD_CREATE); return $new_board; } @@ -291,7 +297,8 @@ class BoardService { $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); $board = $this->find($id); $board->setDeletedAt(time()); - $this->boardMapper->update($board); + $board = $this->boardMapper->update($board); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $board, ActivityManager::SUBJECT_BOARD_DELETE); return $board; } @@ -311,7 +318,9 @@ class BoardService { $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_READ); $board = $this->find($id); $board->setDeletedAt(0); - return $this->boardMapper->update($board); + $board = $this->boardMapper->update($board); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $board, ActivityManager::SUBJECT_BOARD_RESTORE); + return $board; } /** @@ -322,7 +331,7 @@ class BoardService { * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException */ - public function deleteForce($id) { + public function deleteForce($id) { if (is_numeric($id) === false) { throw new BadRequestException('id must be a number'); } @@ -363,11 +372,15 @@ class BoardService { $this->permissionService->checkPermission($this->boardMapper, $id, Acl::PERMISSION_MANAGE); $board = $this->find($id); + $changes = new ChangeSet($board); $board->setTitle($title); $board->setColor($color); $board->setArchived($archived); + $changes->setAfter($board); + $this->boardMapper->update($board); // operate on clone so we can check for updated fields $this->boardMapper->mapOwner($board); - return $this->boardMapper->update($board); + $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_BOARD, $changes, ActivityManager::SUBJECT_BOARD_UPDATE); + return $board; } @@ -421,6 +434,7 @@ class BoardService { $this->notificationHelper->sendBoardShared($boardId, $acl); $newAcl = $this->aclMapper->insert($acl); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $newAcl, ActivityManager::SUBJECT_BOARD_SHARE); $this->boardMapper->mapAcl($newAcl); return $newAcl; } @@ -488,6 +502,7 @@ class BoardService { $this->assignedUsersMapper->delete($assignement); } } + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $acl, ActivityManager::SUBJECT_BOARD_UNSHARE); return $this->aclMapper->delete($acl); } diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index f1b76fc0a..721ebe165 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -23,6 +23,8 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; +use OCA\Deck\Activity\ChangeSet; use OCA\Deck\Db\AssignedUsers; use OCA\Deck\Db\AssignedUsersMapper; use OCA\Deck\Db\Card; @@ -48,17 +50,19 @@ class CardService { private $assignedUsersMapper; private $attachmentService; private $currentUser; + private $activityManager; public function __construct( CardMapper $cardMapper, StackMapper $stackMapper, BoardMapper $boardMapper, LabelMapper $labelMapper, - PermissionService $permissionService, + PermissionService $permissionService, BoardService $boardService, NotificationHelper $notificationHelper, - AssignedUsersMapper $assignedUsersMapper, + AssignedUsersMapper $assignedUsersMapper, AttachmentService $attachmentService, + ActivityManager $activityManager, $userId ) { $this->cardMapper = $cardMapper; @@ -70,6 +74,7 @@ class CardService { $this->notificationHelper = $notificationHelper; $this->assignedUsersMapper = $assignedUsersMapper; $this->attachmentService = $attachmentService; + $this->activityManager = $activityManager; $this->currentUser = $userId; } @@ -157,7 +162,9 @@ class CardService { $card->setType($type); $card->setOrder($order); $card->setOwner($owner); - return $this->cardMapper->insert($card); + $card = $this->cardMapper->insert($card); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_CREATE); + return $card; } /** @@ -182,6 +189,7 @@ class CardService { $card = $this->cardMapper->find($id); $card->setDeletedAt(time()); $this->cardMapper->update($card); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_DELETE); return $card; } @@ -204,7 +212,7 @@ class CardService { public function update($id, $title, $stackId, $type, $order = 0, $description = '', $owner, $duedate = null, $deletedAt) { if (is_numeric($id) === false) { - throw new BadRequestException('card id must be a number'); + throw new BadRequestException('card id must be a number'); } if ($title === false || $title === null) { @@ -231,6 +239,7 @@ class CardService { if ($card->getArchived()) { throw new StatusException('Operation not allowed. This card is archived.'); } + $changes = new ChangeSet($card); $card->setTitle($title); $card->setStackId($stackId); $card->setType($type); @@ -239,7 +248,10 @@ class CardService { $card->setDescription($description); $card->setDuedate($duedate); $card->setDeletedAt($deletedAt); - return $this->cardMapper->update($card); + $changes->setAfter($card); + $card = $this->cardMapper->update($card); + $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_CARD, $changes, ActivityManager::SUBJECT_CARD_UPDATE); + return $card; } /** @@ -350,7 +362,9 @@ class CardService { } $card = $this->cardMapper->find($id); $card->setArchived(true); - return $this->cardMapper->update($card); + $newCard = $this->cardMapper->update($card); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_ARCHIVE); + return $newCard; } /** @@ -374,7 +388,9 @@ class CardService { } $card = $this->cardMapper->find($id); $card->setArchived(false); - return $this->cardMapper->update($card); + $newCard = $this->cardMapper->update($card); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $newCard, ActivityManager::SUBJECT_CARD_UPDATE_UNARCHIVE); + return $newCard; } /** @@ -404,7 +420,9 @@ class CardService { if ($card->getArchived()) { throw new StatusException('Operation not allowed. This card is archived.'); } + $label = $this->labelMapper->find($labelId); $this->cardMapper->assignLabel($cardId, $labelId); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_ASSIGN, ['label' => $label]); } /** @@ -434,7 +452,9 @@ class CardService { if ($card->getArchived()) { throw new StatusException('Operation not allowed. This card is archived.'); } + $label = $this->labelMapper->find($labelId); $this->cardMapper->removeLabel($cardId, $labelId); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_LABEL_UNASSING, ['label' => $label]); } /** @@ -462,17 +482,19 @@ class CardService { return false; } } + $card = $this->cardMapper->find($cardId); if ($userId !== $this->currentUser) { /* Notifyuser about the card assignment */ - $card = $this->cardMapper->find($cardId); $this->notificationHelper->sendCardAssigned($card, $userId); } $assignment = new AssignedUsers(); $assignment->setCardId($cardId); $assignment->setParticipant($userId); - return $this->assignedUsersMapper->insert($assignment); + $assignment = $this->assignedUsersMapper->insert($assignment); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_ASSIGN, ['assigneduser' => $userId]); + return $assignment; } /** @@ -498,7 +520,10 @@ class CardService { $assignments = $this->assignedUsersMapper->find($cardId); foreach ($assignments as $assignment) { if ($assignment->getParticipant() === $userId) { - return $this->assignedUsersMapper->delete($assignment); + $assignment = $this->assignedUsersMapper->delete($assignment); + $card = $this->cardMapper->find($cardId); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_UNASSIGN, ['assigneduser' => $userId]); + return $assignment; } } throw new NotFoundException('No assignment for ' . $userId . 'found.'); diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index f2bdbaff8..0947986c4 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -23,6 +23,8 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; +use OCA\Deck\Activity\ChangeSet; use OCA\Deck\Db\Acl; use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\BoardMapper; @@ -45,6 +47,7 @@ class StackService { private $cardService; private $assignedUsersMapper; private $attachmentService; + private $activityManager; public function __construct( StackMapper $stackMapper, @@ -55,7 +58,8 @@ class StackService { BoardService $boardService, CardService $cardService, AssignedUsersMapper $assignedUsersMapper, - AttachmentService $attachmentService + AttachmentService $attachmentService, + ActivityManager $activityManager ) { $this->stackMapper = $stackMapper; $this->boardMapper = $boardMapper; @@ -66,10 +70,11 @@ class StackService { $this->cardService = $cardService; $this->assignedUsersMapper = $assignedUsersMapper; $this->attachmentService = $attachmentService; + $this->activityManager = $activityManager; } private function enrichStackWithCards($stack) { - $cards = $this->cardMapper->findAll($stack->id); + $cards = $this->cardMapper->findAll($stack->getId()); if(is_null($cards)) { return; @@ -195,8 +200,9 @@ class StackService { $stack->setTitle($title); $stack->setBoardId($boardId); $stack->setOrder($order); - return $this->stackMapper->insert($stack); - + $stack = $this->stackMapper->insert($stack); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_CREATE); + return $stack; } /** @@ -217,10 +223,11 @@ class StackService { $stack = $this->stackMapper->find($id); $stack->setDeletedAt(time()); - $this->stackMapper->update($stack); + $stack = $this->stackMapper->update($stack); + + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $stack, ActivityManager::SUBJECT_STACK_DELETE); $this->enrichStackWithCards($stack); - return $stack; } @@ -260,11 +267,15 @@ class StackService { throw new StatusException('Operation not allowed. This board is archived.'); } $stack = $this->stackMapper->find($id); + $changes = new ChangeSet($stack); $stack->setTitle($title); $stack->setBoardId($boardId); $stack->setOrder($order); $stack->setDeletedAt($deletedAt); - return $this->stackMapper->update($stack); + $changes->setAfter($stack); + $stack = $this->stackMapper->update($stack); + $this->activityManager->triggerUpdateEvents(ActivityManager::DECK_OBJECT_BOARD, $changes, ActivityManager::SUBJECT_STACK_UPDATE); + return $stack; } /** diff --git a/templates/main.php b/templates/main.php index 636c1107b..7221882e9 100644 --- a/templates/main.php +++ b/templates/main.php @@ -23,6 +23,9 @@ use OCP\Util; +Util::addScript('activity', 'richObjectStringParser'); +Util::addStyle('activity', 'style'); + Util::addStyle('deck', '../js/build/vendor'); Util::addScript('deck', 'build/vendor'); diff --git a/templates/part.board.headerControls.php b/templates/part.board.headerControls.php index 1a6c4e184..375cf9bb2 100644 --- a/templates/part.board.headerControls.php +++ b/templates/part.board.headerControls.php @@ -1,11 +1,11 @@
- +
diff --git a/templates/part.board.sidebarView.php b/templates/part.board.sidebarView.php index 6fa652929..369e71649 100644 --- a/templates/part.board.sidebarView.php +++ b/templates/part.board.sidebarView.php @@ -16,6 +16,8 @@
  • t('Tags')); ?>
  • t('Deleted Stacks')); ?>
  • t('Deleted Cards')); ?>
  • +
  • t('Activity')); ?>
  • +
    @@ -147,4 +149,10 @@
    + +
    + +
    + +
    diff --git a/templates/part.card.activity.html b/templates/part.card.activity.html new file mode 100644 index 000000000..1c8a7198f --- /dev/null +++ b/templates/part.card.activity.html @@ -0,0 +1,15 @@ +
      +
    • +
    • +
      + +
      +
      + {{ activity.timestamp/1000 | relativeDateFilter }} +
      +
    • + +
    • +
    diff --git a/templates/part.card.php b/templates/part.card.php index a21f4735b..6358b72ad 100644 --- a/templates/part.card.php +++ b/templates/part.card.php @@ -90,8 +90,10 @@
    t('Saved')); ?> @@ -128,4 +130,10 @@ ng-if="!description()">t('Add a card description…')); ?>
    + +
    + +
    + + diff --git a/templates/part.navigation.php b/templates/part.navigation.php index bc4095863..48ec0e9b7 100644 --- a/templates/part.navigation.php +++ b/templates/part.navigation.php @@ -41,7 +41,7 @@
    @@ -59,7 +59,7 @@

    diff --git a/tests/unit/Activity/ActivityManagerTest.php b/tests/unit/Activity/ActivityManagerTest.php new file mode 100644 index 000000000..55bc36a22 --- /dev/null +++ b/tests/unit/Activity/ActivityManagerTest.php @@ -0,0 +1,344 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use OCA\Deck\Db\AclMapper; +use OCA\Deck\Db\AssignedUsers; +use OCA\Deck\Db\Attachment; +use OCA\Deck\Db\AttachmentMapper; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\Stack; +use OCA\Deck\Db\StackMapper; +use OCA\Deck\Service\PermissionService; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\IL10N; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + +class ActivityManagerTest extends TestCase { + + /** @var ActivityManager */ + private $activityManager; + /** @var IManager|MockObject */ + private $manager; + /** @var PermissionService|MockObject */ + private $permissionService; + /** @var BoardMapper|MockObject */ + private $boardMapper; + /** @var CardMapper|MockObject */ + private $cardMapper; + /** @var StackMapper|MockObject */ + private $stackMapper; + /** @var AttachmentMapper|MockObject */ + private $attachmentMapper; + /** @var AclMapper|MockObject */ + private $aclMapper; + /** @var IL10N|MockObject */ + private $l10n; + /** @var string */ + private $userId = 'admin'; + + public function setUp() { + $this->manager = $this->createMock(IManager::class); + $this->permissionService = $this->createMock(PermissionService::class); + $this->boardMapper = $this->createMock(BoardMapper::class); + $this->cardMapper = $this->createMock(CardMapper::class); + $this->stackMapper = $this->createMock(StackMapper::class); + $this->attachmentMapper = $this->createMock(AttachmentMapper::class); + $this->aclMapper = $this->createMock(AclMapper::class); + $this->l10n = $this->createMock(IL10N::class); + $this->activityManager = new ActivityManager( + $this->manager, + $this->permissionService, + $this->boardMapper, + $this->cardMapper, + $this->stackMapper, + $this->attachmentMapper, + $this->aclMapper, + $this->l10n, + $this->userId + ); + } + + public function testGetActivityFormatOwn() { + $managerClass = new \ReflectionClass(ActivityManager::class); + $this->l10n->expects($this->any()) + ->method('t') + ->will($this->returnCallback(function ($s) { return $s; })); + + foreach ($managerClass->getConstants() as $constant => $value) { + if (strpos($constant, 'SUBJECT') === 0) { + $format = $this->activityManager->getActivityFormat($value, [], false); + if ($format !== '') { + $this->assertContains('{user}', $format); + } else { + /** @noinspection ForgottenDebugOutputInspection */ + print_r('No activity string found for '. $constant . PHP_EOL); + } + $format = $this->activityManager->getActivityFormat($value, [], true); + if ($format !== '') { + $this->assertStringStartsWith('You', $format); + } else { + /** @noinspection ForgottenDebugOutputInspection */ + print_r('No own activity string found for '. $constant . PHP_EOL); + } + } + } + + } + + public function testCreateEvent() { + $board = new Board(); + $this->boardMapper->expects($this->once()) + ->method('find') + ->willReturn($board); + $event = $this->createMock(IEvent::class); + $this->manager->expects($this->once()) + ->method('generateEvent') + ->willReturn($event); + $event->expects($this->once())->method('setApp')->willReturn($event); + $event->expects($this->once())->method('setType')->willReturn($event); + $event->expects($this->once())->method('setAuthor')->willReturn($event); + $event->expects($this->once())->method('setObject')->willReturn($event); + $event->expects($this->once())->method('setSubject')->willReturn($event); + $event->expects($this->once())->method('setTimestamp')->willReturn($event); + $actual = $this->invokePrivate($this->activityManager, 'createEvent', [ + ActivityManager::DECK_OBJECT_BOARD, + $board, + ActivityManager::SUBJECT_BOARD_CREATE + ]); + $this->assertEquals($event, $actual); + } + + public function dataSendToUsers() { + return [ + [ActivityManager::DECK_OBJECT_BOARD], + [ActivityManager::DECK_OBJECT_CARD], + ]; + } + + private function mockUser($uid) { + $user = $this->createMock(IUser::class); + $user->expects($this->any()) + ->method('getUID') + ->willReturn($uid); + return $user; + } + /** + * @dataProvider dataSendToUsers + */ + public function testSendToUser($objectType) { + $users = [ + $this->mockUser('user1'), + $this->mockUser('user2'), + ]; + $event = $this->createMock(IEvent::class); + $event->expects($this->at(0)) + ->method('getObjectType') + ->willReturn($objectType); + $event->expects($this->at(0)) + ->method('getObjectId') + ->willReturn(1); + $event->expects($this->at(2)) + ->method('setAffectedUser') + ->with('user1'); + $event->expects($this->at(3)) + ->method('setAffectedUser') + ->with('user2'); + $mapper = null; + switch ($objectType) { + case ActivityManager::DECK_OBJECT_BOARD: + $mapper = $this->boardMapper; + break; + case ActivityManager::DECK_OBJECT_CARD: + $mapper = $this->cardMapper; + break; + } + $mapper->expects($this->once()) + ->method('findBoardId') + ->willReturn(123); + $this->permissionService->expects($this->once()) + ->method('findUsers') + ->willReturn($users); + $this->manager->expects($this->at(0)) + ->method('publish') + ->with($event); + $this->manager->expects($this->at(1)) + ->method('publish') + ->with($event); + $this->invokePrivate($this->activityManager, 'sendToUsers', [$event]); + } + + public function dataFindObjectForEntity() { + $board = new Board(); + $board->setId(1); + $stack = new Stack(); + $stack->setBoardId(1); + $card = new Card(); + $card->setId(3); + $attachment = new Attachment(); + $attachment->setCardId(3); + $label = new Label(); + $label->setCardId(3); + $label->setBoardId(1); + $assignedUser = new AssignedUsers(); + $assignedUser->setCardId(3); + + return [ + [ActivityManager::DECK_OBJECT_BOARD, $board], + [ActivityManager::DECK_OBJECT_BOARD, $stack], + [ActivityManager::DECK_OBJECT_BOARD, $label], + [ActivityManager::DECK_OBJECT_CARD, $card], + [ActivityManager::DECK_OBJECT_CARD, $attachment], + [ActivityManager::DECK_OBJECT_CARD, $assignedUser], + [ActivityManager::DECK_OBJECT_CARD, $label], + ]; + } + + /** + * @param $objectType + * @param $entity + * @dataProvider dataFindObjectForEntity + */ + public function testFindObjectForEntity($objectType, $entity) { + $board = new Board(); + $board->setId(1); + $card = new Card(); + $card->setId(3); + $expected = null; + if ($objectType === ActivityManager::DECK_OBJECT_BOARD) { + $this->boardMapper->expects($this->once()) + ->method('find') + ->with(1) + ->willReturn($board); + $expected = $board; + } + if ($objectType === ActivityManager::DECK_OBJECT_CARD) { + $this->cardMapper->expects($this->once()) + ->method('find') + ->with(3) + ->willReturn($card); + $expected = $card; + } + $actual = $this->invokePrivate($this->activityManager, 'findObjectForEntity', [$objectType, $entity]); + $this->assertEquals($expected, $actual); + } + + public function testFindDetailsForStack() { + $stack = new Stack(); + $stack->setId(123); + $stack->setBoardId(999); + $board = new Board(); + $board->setId(999); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($stack); + $this->boardMapper->expects($this->once())->method('find') + ->with(999) + ->willReturn($board); + $this->assertEquals([ + 'stack' => $stack, + 'board' => $board + ], $this->invokePrivate($this->activityManager, 'findDetailsForStack', [123])); + } + + + public function testFindDetailsForCard() { + $card = new Card(); + $card->setId(555); + $card->setStackId(123); + $stack = new Stack(); + $stack->setId(123); + $stack->setBoardId(999); + $board = new Board(); + $board->setId(999); + $this->cardMapper->expects($this->once()) + ->method('find') + ->with(555) + ->willReturn($card); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($stack); + $this->boardMapper->expects($this->once())->method('find') + ->with(999) + ->willReturn($board); + $this->assertEquals([ + 'stack' => $stack, + 'board' => $board, + 'card' => $card + ], $this->invokePrivate($this->activityManager, 'findDetailsForCard', [555])); + } + + public function testFindDetailsForAttachment() { + $attachment = new Attachment(); + $attachment->setId(777); + $attachment->setCardId(555); + $card = new Card(); + $card->setId(555); + $card->setStackId(123); + $stack = new Stack(); + $stack->setId(123); + $stack->setBoardId(999); + $board = new Board(); + $board->setId(999); + $this->attachmentMapper->expects($this->once()) + ->method('find') + ->with(777) + ->willReturn($attachment); + $this->cardMapper->expects($this->once()) + ->method('find') + ->with(555) + ->willReturn($card); + $this->stackMapper->expects($this->once()) + ->method('find') + ->with(123) + ->willReturn($stack); + $this->boardMapper->expects($this->once())->method('find') + ->with(999) + ->willReturn($board); + $this->assertEquals([ + 'stack' => $stack, + 'board' => $board, + 'card' => $card, + 'attachment' => $attachment + ], $this->invokePrivate($this->activityManager, 'findDetailsForAttachment', [777])); + } + + public function invokePrivate(&$object, $methodName, array $parameters = array()) + { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + return $method->invokeArgs($object, $parameters); + } + +} diff --git a/tests/unit/Activity/ChangeSetTest.php b/tests/unit/Activity/ChangeSetTest.php new file mode 100644 index 000000000..0ae843d34 --- /dev/null +++ b/tests/unit/Activity/ChangeSetTest.php @@ -0,0 +1,58 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use PHPUnit\Framework\TestCase; + + +class ChangeSetTest extends TestCase { + + public function setUp() { + } + + public function testChangeSetScalar() { + $changeSet = new ChangeSet('A', 'B'); + $this->assertEquals('A', $changeSet->getBefore()); + $this->assertEquals('B', $changeSet->getAfter()); + $this->assertFalse($changeSet->getDiff()); + $changeSet->enableDiff(); + $this->assertTrue($changeSet->getDiff()); + } + + public function testChangeSetObject() { + $a = new \stdClass; + $a->data = 'A'; + $b = new \stdClass; + $b->data = 'B'; + $changeSet = new ChangeSet($a, $b); + $this->assertEquals('A', $changeSet->getBefore()->data); + $this->assertEquals('B', $changeSet->getAfter()->data); + $this->assertNotSame($a, $changeSet->getBefore()); + $this->assertNotSame($b, $changeSet->getAfter()); + $this->assertFalse($changeSet->getDiff()); + $changeSet->enableDiff(); + $this->assertTrue($changeSet->getDiff()); + } + +} diff --git a/tests/unit/Activity/DeckProviderTest.php b/tests/unit/Activity/DeckProviderTest.php new file mode 100644 index 000000000..81a023585 --- /dev/null +++ b/tests/unit/Activity/DeckProviderTest.php @@ -0,0 +1,454 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use OC\Activity\Event; +use OCA\Deck\Db\Acl; +use OCP\Activity\IEvent; +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\RichObjectStrings\IValidator; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + +class DeckProviderTest extends TestCase { + + /** @var DeckProvider */ + private $provider; + + /** @var IURLGenerator|MockObject */ + private $urlGenerator; + + /** @var ActivityManager|MockObject */ + private $activityManager; + + /** @var IUserManager|MockObject */ + private $userManager; + + /** @var string */ + private $userId = 'admin'; + + public function setUp() { + $this->l10n = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->activityManager = $this->createMock(ActivityManager::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->provider = new DeckProvider($this->urlGenerator, $this->activityManager, $this->userManager, $this->userId); + } + + private function mockEvent($objectType, $objectId, $objectName, $subject, $subjectParameters = []) { + $data = []; + $event = $this->createMock(IEvent::class); + $event->expects($this->any())->method('getApp')->willReturn('deck'); + $event->expects($this->any())->method('getSubject')->willReturn($subject); + $event->expects($this->any())->method('getSubjectParameters')->willReturn($subjectParameters); + $event->expects($this->any())->method('getObjectType')->willReturn($objectType); + $event->expects($this->any())->method('getObjectId')->willReturn($objectId); + $event->expects($this->any())->method('getObjectName')->willReturn($objectName); + $event->expects($this->any())->method('getAuthor')->willReturn('admin'); + $event->expects($this->any())->method('getMessage')->willReturn(''); + $event->expects($this->any())->method('setIcon')->will($this->returnCallback(function($icon) use (&$data) { + $data['icon'] = $icon; + })); + $event->expects($this->any())->method('getIcon')->will( + $this->returnCallback(function() use (&$data) { + return array_key_exists('icon', $data) ? $data['icon'] : 'noicon'; + }) + ); + return $event; + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testParseFailureApp() { + $event = $this->createMock(IEvent::class); + $event->expects($this->once())->method('getApp')->willReturn('notdeck'); + $this->provider->parse('en_US', $event, $event); + } + + public function dataEventIcons() { + return [ + [ActivityManager::SUBJECT_LABEL_ASSIGN, 'deck', 'deck-dark.svg'], + [ActivityManager::SUBJECT_CARD_CREATE, 'files', 'add-color.svg'], + [ActivityManager::SUBJECT_CARD_UPDATE, 'files', 'change.svg'], + [ActivityManager::SUBJECT_CARD_DELETE, 'files', 'delete-color.svg'], + [ActivityManager::SUBJECT_CARD_UPDATE_ARCHIVE, 'deck', 'archive.svg'], + [ActivityManager::SUBJECT_CARD_RESTORE, 'core', 'actions/history.svg'], + [ActivityManager::SUBJECT_ATTACHMENT_UPDATE, 'core', 'places/files.svg'], + ]; + } + /** + * @dataProvider dataEventIcons + * @param $subject + * @param $icon + */ + public function testEventIcons($subject, $app, $icon) { + $event = $this->mockEvent( + ActivityManager::DECK_OBJECT_BOARD, 1, 'Board', + $subject); + $this->urlGenerator->expects($this->any()) + ->method('imagePath') + ->will($this->returnCallback(function($a, $i) { + return $a . '/' . $i; + })); + $this->provider->parse('en_US', $event); + $this->assertEquals($app . '/' . $icon, $event->getIcon()); + } + + public function testDeckUrl() { + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->with('deck.page.index') + ->willReturn('http://localhost/index.php/apps/deck/'); + $this->assertEquals( + 'http://localhost/index.php/apps/deck/#!board/1/card/1', + $this->provider->deckUrl('board/1/card/1') + ); + } + + public function testParseObjectTypeBoard() { + $this->urlGenerator->expects($this->any()) + ->method('imagePath') + ->will($this->returnCallback(function($a, $i) { + return $a . '/' . $i; + })); + $this->activityManager->expects($this->once()) + ->method('getActivityFormat') + ->willReturn('test string {board}'); + $user = $this->createMock(IUser::class); + $user->expects($this->any())->method('getDisplayName')->willReturn('Administrator'); + $this->userManager->expects($this->any()) + ->method('get') + ->willReturn($user); + + $richValidator = $this->createMock(IValidator::class); + $event = new Event($richValidator); + + $event->setApp('deck'); + $event->setSubject(ActivityManager::SUBJECT_BOARD_CREATE); + $event->setAffectedUser($this->userId); + $event->setAuthor($this->userId); + $event->setObject(ActivityManager::DECK_OBJECT_BOARD, 1, 'Board'); + + $this->provider->parse('en_US', $event); + $data = [ + 'board' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'Board', + 'link' => '#!/board/1', + ], + 'card' => null, + 'user' => [ + 'type' => 'user', + 'id' => 'admin', + 'name' => 'Administrator', + ] + ]; + $this->assertEquals($data, $event->getRichSubjectParameters()); + } + + public function testParseObjectTypeCard() { + $this->urlGenerator->expects($this->any()) + ->method('imagePath') + ->will($this->returnCallback(function($a, $i) { + return $a . '/' . $i; + })); + $this->activityManager->expects($this->once()) + ->method('getActivityFormat') + ->willReturn('test string {board}'); + $user = $this->createMock(IUser::class); + $user->expects($this->any())->method('getDisplayName')->willReturn('Administrator'); + $this->userManager->expects($this->any()) + ->method('get') + ->willReturn($user); + + $richValidator = $this->createMock(IValidator::class); + $event = new Event($richValidator); + + $event->setApp('deck'); + $event->setSubject(ActivityManager::SUBJECT_CARD_CREATE); + $event->setAffectedUser($this->userId); + $event->setAuthor($this->userId); + $event->setObject(ActivityManager::DECK_OBJECT_CARD, 1, 'Card'); + + $this->provider->parse('en_US', $event); + $data = [ + 'board' => null, + 'card' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'Card', + ], + 'user' => [ + 'type' => 'user', + 'id' => 'admin', + 'name' => 'Administrator', + ] + ]; + $this->assertEquals($data, $event->getRichSubjectParameters()); + $this->assertEquals('test string {board}', $event->getParsedSubject()); + $this->assertEquals('test string {board}', $event->getRichSubject()); + $this->assertEquals('', $event->getMessage()); + + } + + public function testParseObjectTypeCardWithDiff() { + $this->urlGenerator->expects($this->any()) + ->method('imagePath') + ->will($this->returnCallback(function($a, $i) { + return $a . '/' . $i; + })); + $this->activityManager->expects($this->once()) + ->method('getActivityFormat') + ->willReturn('test string {board}'); + $user = $this->createMock(IUser::class); + $user->expects($this->any())->method('getDisplayName')->willReturn('Administrator'); + $this->userManager->expects($this->any()) + ->method('get') + ->willReturn($user); + + $richValidator = $this->createMock(IValidator::class); + $event = new Event($richValidator); + + $event->setApp('deck'); + $event->setSubject(ActivityManager::SUBJECT_CARD_UPDATE_DESCRIPTION, [ + 'before' => 'ABC', + 'after' => 'BCD', + 'diff' => true, + ]); + $event->setAffectedUser($this->userId); + $event->setAuthor($this->userId); + $event->setObject(ActivityManager::DECK_OBJECT_CARD, 1, 'Card'); + $event->setMessage('BCD'); + + $this->provider->parse('en_US', $event); + $data = [ + 'board' => null, + 'card' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'Card', + ], + 'user' => [ + 'type' => 'user', + 'id' => 'admin', + 'name' => 'Administrator', + ], + ]; + $this->assertEquals($data, $event->getRichSubjectParameters()); + $this->assertEquals('test string {board}', $event->getParsedSubject()); + $this->assertEquals('test string {board}', $event->getRichSubject()); + $this->assertEquals('BCD', $event->getMessage()); + $this->assertEquals('
    ABCD
    ', $event->getParsedMessage()); + } + + public function testParseParamForBoard() { + $params = []; + $subjectParams = [ + 'board' => [ + 'id' => 1, + 'title' => 'Board name', + ], + ]; + $expected = [ + 'board' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'Board name', + 'link' => '#!/board/1/', + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForBoard', ['board', $subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForStack() { + $params = []; + $subjectParams = [ + 'stack' => [ + 'id' => 1, + 'title' => 'Stack name', + ], + ]; + $expected = [ + 'stack' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'Stack name', + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForStack', ['stack', $subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForAttachment() { + $this->urlGenerator->expects($this->once()) + ->method('linkToRoute') + ->willReturn('/link/to/attachment'); + $params = []; + $subjectParams = [ + 'attachment' => [ + 'id' => 1, + 'data' => 'File name', + ], + 'card' => [ + 'id' => 1, + ] + ]; + $expected = [ + 'attachment' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'File name', + 'link' => '/link/to/attachment', + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForAttachment', ['attachment', $subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForAssignedUser() { + $user = $this->createMock(IUser::class); + $user->expects($this->once()) + ->method('getDisplayName') + ->willReturn('User 1'); + $this->userManager->expects($this->once()) + ->method('get') + ->willReturn($user); + $params = []; + $subjectParams = [ + 'assigneduser' => 'user1', + ]; + $expected = [ + 'assigneduser' => [ + 'type' => 'user', + 'id' => 'user1', + 'name' => 'User 1' + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForAssignedUser', [$subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForLabel() { + $params = []; + $subjectParams = [ + 'label' => [ + 'id' => 1, + 'title' => 'Label title', + ], + ]; + $expected = [ + 'label' => [ + 'type' => 'highlight', + 'id' => 1, + 'name' => 'Label title' + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForLabel', [$subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForAclUser() { + $user = $this->createMock(IUser::class); + $user->expects($this->once()) + ->method('getDisplayName') + ->willReturn('User 1'); + $this->userManager->expects($this->once()) + ->method('get') + ->willReturn($user); + $params = []; + $subjectParams = [ + 'acl' => [ + 'id' => 1, + 'type' => Acl::PERMISSION_TYPE_USER, + 'participant' => 'user1' + ], + ]; + $expected = [ + 'acl' => [ + 'type' => 'user', + 'id' => 'user1', + 'name' => 'User 1' + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForAcl', [$subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForAclGroup() { + $params = []; + $subjectParams = [ + 'acl' => [ + 'id' => 1, + 'type' => Acl::PERMISSION_TYPE_GROUP, + 'participant' => 'group' + ], + ]; + $expected = [ + 'acl' => [ + 'type' => 'highlight', + 'id' => 'group', + 'name' => 'group' + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForAcl', [$subjectParams, $params]); + $this->assertEquals($expected, $actual); + } + + public function testParseParamForChanges() { + $event = $this->createMock(IEvent::class); + $params = []; + $subjectParams = [ + 'before' => 'ABC', + 'after' => 'BCD' + ]; + $expected = [ + 'before' => [ + 'type' => 'highlight', + 'id' => 'ABC', + 'name' => 'ABC' + ], + 'after' => [ + 'type' => 'highlight', + 'id' => 'BCD', + 'name' => 'BCD' + ], + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForChanges', [$subjectParams, $params, $event]); + $this->assertEquals($expected, $actual); + } + + public function invokePrivate(&$object, $methodName, array $parameters = array()) + { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + return $method->invokeArgs($object, $parameters); + } +} diff --git a/tests/unit/Activity/FilterTest.php b/tests/unit/Activity/FilterTest.php new file mode 100644 index 000000000..d3e6b82c5 --- /dev/null +++ b/tests/unit/Activity/FilterTest.php @@ -0,0 +1,81 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use OCP\IL10N; +use OCP\IURLGenerator; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + +class FilterTest extends TestCase { + + /** @var Filter */ + private $filter; + + /** @var IL10N|MockObject */ + private $l10n; + + /** @var IURLGenerator|MockObject */ + private $urlGenerator; + + public function setUp() { + $this->l10n = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->filter = new Filter($this->l10n, $this->urlGenerator); + } + + public function testGetIdentifier() { + $this->assertEquals('deck', $this->filter->getIdentifier()); + } + + public function testGetName() { + $this->l10n->expects($this->once()) + ->method('t') + ->with('Deck') + ->willReturn('Deck'); + $this->assertEquals('Deck', $this->filter->getName()); + } + + public function testGetPriority() { + $this->assertEquals(90, $this->filter->getPriority()); + } + + public function testGetIcon() { + $this->urlGenerator->expects($this->once()) + ->method('imagePath') + ->with('deck', 'deck-dark.svg') + ->willReturn('http://localhost/apps/deck/img/deck-dark.svg'); + $this->assertEquals('http://localhost/apps/deck/img/deck-dark.svg', $this->filter->getIcon()); + } + + public function testFilterTypes() { + $data = ['deck_board', 'deck_card']; + $this->assertEquals($data, $this->filter->filterTypes($data)); + } + + public function testAllowedApps() { + $this->assertEquals(['deck'], $this->filter->allowedApps()); + } + +} diff --git a/tests/unit/Activity/SettingTest.php b/tests/unit/Activity/SettingTest.php new file mode 100644 index 000000000..956d5c1d5 --- /dev/null +++ b/tests/unit/Activity/SettingTest.php @@ -0,0 +1,65 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use PHPUnit\Framework\TestCase; + +class SettingTest extends TestCase { + + /** @var Setting */ + private $setting; + + public function setUp() { + $this->setting = new Setting(); + } + + public function testGetIdentifier() { + $this->assertEquals('deck', $this->setting->getIdentifier()); + } + + public function testGetName() { + $this->assertEquals('Deck', $this->setting->getName()); + } + + public function testGetPriority() { + $this->assertEquals(90, $this->setting->getPriority()); + } + + public function testCanChangeStream() { + $this->assertTrue($this->setting->canChangeStream()); + } + + public function testIsDefaultEnabledStream() { + $this->assertTrue($this->setting->isDefaultEnabledStream()); + } + + public function testCanChangeMail() { + $this->assertTrue($this->setting->canChangeMail()); + } + + public function testIsDefaultEnabledMail() { + $this->assertFalse($this->setting->isDefaultEnabledMail()); + } + +} diff --git a/tests/unit/Service/AttachmentServiceTest.php b/tests/unit/Service/AttachmentServiceTest.php index fc387de21..b1f205c66 100644 --- a/tests/unit/Service/AttachmentServiceTest.php +++ b/tests/unit/Service/AttachmentServiceTest.php @@ -24,6 +24,7 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; use OCA\Deck\AppInfo\Application; use OCA\Deck\Db\Acl; use OCA\Deck\Db\Attachment; @@ -65,6 +66,8 @@ class AttachmentServiceTest extends TestCase { private $attachmentService; /** @var MockObject */ private $attachmentServiceImpl; + /** @var ActivityManager */ + private $activityManager; private $appContainer; /** ICache */ private $cache; @@ -85,7 +88,8 @@ class AttachmentServiceTest extends TestCase { $this->permissionService = $this->createMock(PermissionService::class); $this->application = $this->createMock(Application::class); $this->cacheFactory = $this->createMock(ICacheFactory::class); - + $this->activityManager = $this->createMock(ActivityManager::class); + $this->cache = $this->createMock(ICache::class); $this->cacheFactory->expects($this->any())->method('createDistributed')->willReturn($this->cache); @@ -96,7 +100,7 @@ class AttachmentServiceTest extends TestCase { $this->l10n = $this->createMock(IL10N::class); - $this->attachmentService = new AttachmentService($this->attachmentMapper, $this->cardMapper, $this->permissionService, $this->application, $this->cacheFactory, $this->userId, $this->l10n); + $this->attachmentService = new AttachmentService($this->attachmentMapper, $this->cardMapper, $this->permissionService, $this->application, $this->cacheFactory, $this->userId, $this->l10n, $this->activityManager); } public function testRegisterAttachmentService() { @@ -108,7 +112,7 @@ class AttachmentServiceTest extends TestCase { $application->expects($this->any()) ->method('getContainer') ->willReturn($appContainer); - $attachmentService = new AttachmentService($this->attachmentMapper, $this->cardMapper, $this->permissionService, $application, $this->cacheFactory, $this->userId, $this->l10n); + $attachmentService = new AttachmentService($this->attachmentMapper, $this->cardMapper, $this->permissionService, $application, $this->cacheFactory, $this->userId, $this->l10n, $this->activityManager); $attachmentService->registerAttachmentService('custom', MyAttachmentService::class); $this->assertEquals($fileServiceMock, $attachmentService->getService('deck_file')); $this->assertEquals(MyAttachmentService::class, get_class($attachmentService->getService('custom'))); @@ -126,7 +130,7 @@ class AttachmentServiceTest extends TestCase { $application->expects($this->any()) ->method('getContainer') ->willReturn($appContainer); - $attachmentService = new AttachmentService($this->attachmentMapper, $this->cardMapper, $this->permissionService, $application, $this->cacheFactory, $this->userId, $this->l10n); + $attachmentService = new AttachmentService($this->attachmentMapper, $this->cardMapper, $this->permissionService, $application, $this->cacheFactory, $this->userId, $this->l10n, $this->activityManager); $attachmentService->registerAttachmentService('custom', MyAttachmentService::class); $attachmentService->getService('deck_file_invalid'); } @@ -367,4 +371,4 @@ class AttachmentServiceTest extends TestCase { $actual = $this->attachmentService->restore(123, 1); } -} \ No newline at end of file +} diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 5f9d5082d..a5c33530d 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -24,6 +24,7 @@ namespace OCA\Deck\Service; use OC\L10N\L10N; +use OCA\Deck\Activity\ActivityManager; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AclMapper; use OCA\Deck\Db\AssignedUsers; @@ -59,8 +60,10 @@ class BoardServiceTest extends TestCase { private $userManager; /** @var IUserManager */ private $groupManager; + /** @var ActivityManager */ + private $activityManager; - private $userId = 'admin'; + private $userId = 'admin'; public function setUp() { parent::setUp(); @@ -72,7 +75,8 @@ class BoardServiceTest extends TestCase { $this->notificationHelper = $this->createMock(NotificationHelper::class); $this->assignedUsersMapper = $this->createMock(AssignedUsersMapper::class); $this->userManager = $this->createMock(IUserManager::class); - $this->groupManager = $this->createMock(IGroupManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->activityManager = $this->createMock(ActivityManager::class); $this->service = new BoardService( $this->boardMapper, @@ -84,11 +88,12 @@ class BoardServiceTest extends TestCase { $this->assignedUsersMapper, $this->userManager, $this->groupManager, + $this->activityManager, $this->userId ); $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('admin'); + $user->method('getUID')->willReturn('admin'); } public function testFindAll() { @@ -186,7 +191,12 @@ class BoardServiceTest extends TestCase { ->willReturn([ 'admin' => 'admin', ]); - $this->assertEquals($board, $this->service->delete(123)); + $boardDeleted = clone $board; + $board->setDeletedAt(1); + $this->boardMapper->expects($this->once()) + ->method('update') + ->willReturn($boardDeleted); + $this->assertEquals($boardDeleted, $this->service->delete(123)); } public function testAddAcl() { @@ -268,4 +278,4 @@ class BoardServiceTest extends TestCase { ->willReturn(true); $this->assertTrue($this->service->deleteAcl(123)); } -} \ No newline at end of file +} diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index b00c5889c..bb72bcfc6 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -24,6 +24,7 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; use OCA\Deck\Db\AssignedUsers; use OCA\Deck\Db\AssignedUsersMapper; use OCA\Deck\Db\Card; @@ -34,6 +35,7 @@ use OCA\Deck\Db\LabelMapper; use OCA\Deck\NotFoundException; use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\StatusException; +use OCP\Activity\IEvent; use Test\TestCase; class CardServiceTest extends TestCase { @@ -55,7 +57,10 @@ class CardServiceTest extends TestCase { /** @var LabelMapper|\PHPUnit\Framework\MockObject\MockObject */ private $labelMapper; private $boardMapper; + /** @var AttachmentService|\PHPUnit\Framework\MockObject\MockObject */ private $attachmentService; + /** @var ActivityManager|\PHPUnit\Framework\MockObject\MockObject */ + private $activityManager; public function setUp() { parent::setUp(); @@ -68,7 +73,8 @@ class CardServiceTest extends TestCase { $this->notificationHelper = $this->createMock(NotificationHelper::class); $this->assignedUsersMapper = $this->createMock(AssignedUsersMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); - $this->cardService = new CardService( + $this->activityManager = $this->createMock(ActivityManager::class); + $this->cardService = new CardService( $this->cardMapper, $this->stackMapper, $this->boardMapper, @@ -78,10 +84,23 @@ class CardServiceTest extends TestCase { $this->notificationHelper, $this->assignedUsersMapper, $this->attachmentService, + $this->activityManager, 'user1' ); } + public function mockActivity($type, $object, $subject) { + // ActivityManager::DECK_OBJECT_BOARD, $newAcl, ActivityManager::SUBJECT_BOARD_SHARE + $event = $this->createMock(IEvent::class); + $this->activityManager->expects($this->once()) + ->method('createEvent') + ->with($type, $object, $subject) + ->willReturn($event); + $this->activityManager->expects($this->once()) + ->method('sendToUsers') + ->with($event); + } + public function testFind() { $card = new Card(); $card->setId(1337); @@ -263,7 +282,7 @@ class CardServiceTest extends TestCase { $card = new Card(); $card->setArchived(true); $this->cardMapper->expects($this->once())->method('find')->willReturn($card); - $this->cardMapper->expects($this->never())->method('removeLabel'); + $this->cardMapper->expects($this->never())->method('removeLabel'); $this->expectException(StatusException::class); $this->cardService->removeLabel(123, 999); } @@ -321,9 +340,9 @@ class CardServiceTest extends TestCase { /** * @expectException \OCA\Deck\NotFoundException - * - * - * + * + * + * */ public function testUnassignUserNotExisting() { $assignment = new AssignedUsers(); @@ -337,7 +356,7 @@ class CardServiceTest extends TestCase { ->with(123) ->willReturn($assignments); $this->expectException(NotFoundException::class); - $actual = $this->cardService->unassignUser(123, 'user'); + $actual = $this->cardService->unassignUser(123, 'user'); } diff --git a/tests/unit/Service/StackServiceTest.php b/tests/unit/Service/StackServiceTest.php index e013de63d..1d436afa2 100644 --- a/tests/unit/Service/StackServiceTest.php +++ b/tests/unit/Service/StackServiceTest.php @@ -25,6 +25,7 @@ namespace OCA\Deck\Service; +use OCA\Deck\Activity\ActivityManager; use OCA\Deck\Db\AssignedUsersMapper; use OCA\Deck\Db\Card; use OCA\Deck\Db\CardMapper; @@ -63,6 +64,8 @@ class StackServiceTest extends TestCase { private $boardService; /** @var CardService|\PHPUnit\Framework\MockObject\MockObject */ private $cardService; + /** @var ActivityManager|\PHPUnit\Framework\MockObject\MockObject */ + private $activityManager; public function setUp() { parent::setUp(); @@ -75,18 +78,19 @@ class StackServiceTest extends TestCase { $this->assignedUsersMapper = $this->createMock(AssignedUsersMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->labelMapper = $this->createMock(LabelMapper::class); + $this->activityManager = $this->createMock(ActivityManager::class); $this->stackService = new StackService( $this->stackMapper, - $this->boardMapper, - $this->cardMapper, - $this->labelMapper, - + $this->boardMapper, + $this->cardMapper, + $this->labelMapper, $this->permissionService, $this->boardService, $this->cardService, $this->assignedUsersMapper, - $this->attachmentService + $this->attachmentService, + $this->activityManager ); } @@ -176,14 +180,16 @@ class StackServiceTest extends TestCase { } public function testDelete() { - $this->permissionService->expects($this->once())->method('checkPermission'); - $stackToBeDeleted = new Stack(); - $stackToBeDeleted->setId(1); - $this->stackMapper->expects($this->once())->method('find')->willReturn($stackToBeDeleted); - $this->stackMapper->expects($this->once())->method('update'); - $this->stackService->delete(123); - $this->assertTrue($stackToBeDeleted->getDeletedAt() <= time(), "deletedAt is in the past"); - } + $this->permissionService->expects($this->once())->method('checkPermission'); + $stackToBeDeleted = new Stack(); + $stackToBeDeleted->setId(1); + $this->stackMapper->expects($this->once())->method('find')->willReturn($stackToBeDeleted); + $this->stackMapper->expects($this->once())->method('update')->willReturn($stackToBeDeleted); + $this->stackService->delete(123); + $this->assertTrue($stackToBeDeleted->getDeletedAt() <= time(), "deletedAt is in the past"); + $this->assertTrue($stackToBeDeleted->getDeletedAt() > 0, "deletedAt is set"); + + } public function testUpdate() { $this->permissionService->expects($this->once())->method('checkPermission');