aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/comments/composer/composer/autoload_classmap.php2
-rw-r--r--apps/comments/composer/composer/autoload_static.php2
-rw-r--r--apps/comments/lib/AppInfo/Application.php6
-rw-r--r--apps/comments/lib/Event/LoadCommentsApp.php35
-rw-r--r--apps/comments/lib/Listener/LoadCommentsAppListener.php55
-rw-r--r--apps/comments/lib/Listener/LoadSidebarScripts.php13
-rw-r--r--apps/comments/src/comments-app.js32
-rw-r--r--apps/comments/src/comments-tab.js58
-rw-r--r--apps/comments/src/components/Comment.vue295
-rw-r--r--apps/comments/src/components/Moment.vue31
-rw-r--r--apps/comments/src/mixins/CommentMixin.js117
-rw-r--r--apps/comments/src/services/CommentsInstance.js69
-rw-r--r--apps/comments/src/services/DavClient.js37
-rw-r--r--apps/comments/src/services/DeleteComment.js37
-rw-r--r--apps/comments/src/services/EditComment.js49
-rw-r--r--apps/comments/src/services/GetComments.js80
-rw-r--r--apps/comments/src/services/NewComment.js60
-rw-r--r--apps/comments/src/utils/cancelableRequest.js62
-rw-r--r--apps/comments/src/utils/davUtils.js29
-rw-r--r--apps/comments/src/utils/fileUtils.js122
-rw-r--r--apps/comments/src/utils/numberUtil.js30
-rw-r--r--apps/comments/src/views/Comments.vue264
-rw-r--r--apps/comments/webpack.js14
-rw-r--r--apps/files/src/components/SidebarTab.vue8
-rw-r--r--apps/files/src/models/Tab.js15
-rw-r--r--apps/files/src/views/Sidebar.vue1
-rw-r--r--core/Controller/AutoCompleteController.php17
-rw-r--r--lib/private/Collaboration/Collaborators/UserPlugin.php6
-rw-r--r--package-lock.json111
-rw-r--r--package.json4
30 files changed, 1642 insertions, 19 deletions
diff --git a/apps/comments/composer/composer/autoload_classmap.php b/apps/comments/composer/composer/autoload_classmap.php
index c4d8a9e331b..b0485a5c4e3 100644
--- a/apps/comments/composer/composer/autoload_classmap.php
+++ b/apps/comments/composer/composer/autoload_classmap.php
@@ -15,9 +15,11 @@ return array(
'OCA\\Comments\\Collaboration\\CommentersSorter' => $baseDir . '/../lib/Collaboration/CommentersSorter.php',
'OCA\\Comments\\Controller\\Notifications' => $baseDir . '/../lib/Controller/Notifications.php',
'OCA\\Comments\\EventHandler' => $baseDir . '/../lib/EventHandler.php',
+ 'OCA\\Comments\\Event\\LoadCommentsApp' => $baseDir . '/../lib/Event/LoadCommentsApp.php',
'OCA\\Comments\\JSSettingsHelper' => $baseDir . '/../lib/JSSettingsHelper.php',
'OCA\\Comments\\Listener\\CommentsEntityEventListener' => $baseDir . '/../lib/Listener/CommentsEntityEventListener.php',
'OCA\\Comments\\Listener\\LoadAdditionalScripts' => $baseDir . '/../lib/Listener/LoadAdditionalScripts.php',
+ 'OCA\\Comments\\Listener\\LoadCommentsAppListener' => $baseDir . '/../lib/Listener/LoadCommentsAppListener.php',
'OCA\\Comments\\Listener\\LoadSidebarScripts' => $baseDir . '/../lib/Listener/LoadSidebarScripts.php',
'OCA\\Comments\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php',
'OCA\\Comments\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
diff --git a/apps/comments/composer/composer/autoload_static.php b/apps/comments/composer/composer/autoload_static.php
index 72b37969ec0..bc69b25743d 100644
--- a/apps/comments/composer/composer/autoload_static.php
+++ b/apps/comments/composer/composer/autoload_static.php
@@ -30,9 +30,11 @@ class ComposerStaticInitComments
'OCA\\Comments\\Collaboration\\CommentersSorter' => __DIR__ . '/..' . '/../lib/Collaboration/CommentersSorter.php',
'OCA\\Comments\\Controller\\Notifications' => __DIR__ . '/..' . '/../lib/Controller/Notifications.php',
'OCA\\Comments\\EventHandler' => __DIR__ . '/..' . '/../lib/EventHandler.php',
+ 'OCA\\Comments\\Event\\LoadCommentsApp' => __DIR__ . '/..' . '/../lib/Event/LoadCommentsApp.php',
'OCA\\Comments\\JSSettingsHelper' => __DIR__ . '/..' . '/../lib/JSSettingsHelper.php',
'OCA\\Comments\\Listener\\CommentsEntityEventListener' => __DIR__ . '/..' . '/../lib/Listener/CommentsEntityEventListener.php',
'OCA\\Comments\\Listener\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalScripts.php',
+ 'OCA\\Comments\\Listener\\LoadCommentsAppListener' => __DIR__ . '/..' . '/../lib/Listener/LoadCommentsAppListener.php',
'OCA\\Comments\\Listener\\LoadSidebarScripts' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarScripts.php',
'OCA\\Comments\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php',
'OCA\\Comments\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
diff --git a/apps/comments/lib/AppInfo/Application.php b/apps/comments/lib/AppInfo/Application.php
index 4eb097ff001..0f22cd309ec 100644
--- a/apps/comments/lib/AppInfo/Application.php
+++ b/apps/comments/lib/AppInfo/Application.php
@@ -30,6 +30,8 @@ namespace OCA\Comments\AppInfo;
use Closure;
use OCA\Comments\Capabilities;
use OCA\Comments\Controller\Notifications;
+use OCA\Comments\Event\LoadCommentsApp;
+use OCA\Comments\Listener\LoadCommentsAppListener;
use OCA\Comments\EventHandler;
use OCA\Comments\JSSettingsHelper;
use OCA\Comments\Listener\CommentsEntityEventListener;
@@ -71,6 +73,10 @@ class Application extends App implements IBootstrap {
LoadSidebarScripts::class
);
$context->registerEventListener(
+ LoadCommentsApp::class,
+ LoadCommentsAppListener::class
+ );
+ $context->registerEventListener(
CommentsEntityEvent::EVENT_ENTITY,
CommentsEntityEventListener::class
);
diff --git a/apps/comments/lib/Event/LoadCommentsApp.php b/apps/comments/lib/Event/LoadCommentsApp.php
new file mode 100644
index 00000000000..74ed93ad447
--- /dev/null
+++ b/apps/comments/lib/Event/LoadCommentsApp.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2020, John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+namespace OCA\Comments\Event;
+
+use OCP\EventDispatcher\Event;
+
+/**
+ * This event is used to load and init the comments app
+ *
+ * @since 21.0.0
+ */
+class LoadCommentsApp extends Event {
+}
diff --git a/apps/comments/lib/Listener/LoadCommentsAppListener.php b/apps/comments/lib/Listener/LoadCommentsAppListener.php
new file mode 100644
index 00000000000..755bdaee1ba
--- /dev/null
+++ b/apps/comments/lib/Listener/LoadCommentsAppListener.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2020, John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Comments\Listener;
+
+use OCA\Comments\AppInfo\Application;
+use OCA\Comments\Event\LoadCommentsApp;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Comments\IComment;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Util;
+
+class LoadCommentsAppListener implements IEventListener {
+
+ /** @var IInitialState */
+ private $initialStateService;
+
+ public function __construct(IInitialState $initialStateService) {
+ $this->initialStateService = $initialStateService;
+ }
+
+ public function handle(Event $event): void {
+ if (!($event instanceof LoadCommentsApp)) {
+ return;
+ }
+
+ $this->initialStateService->provideInitialState('max-message-length', IComment::MAX_MESSAGE_LENGTH);
+
+ Util::addScript(Application::APP_ID, 'comments-app');
+ }
+}
diff --git a/apps/comments/lib/Listener/LoadSidebarScripts.php b/apps/comments/lib/Listener/LoadSidebarScripts.php
index dfa7e511b1f..0b76d88363d 100644
--- a/apps/comments/lib/Listener/LoadSidebarScripts.php
+++ b/apps/comments/lib/Listener/LoadSidebarScripts.php
@@ -28,19 +28,32 @@ declare(strict_types=1);
namespace OCA\Comments\Listener;
use OCA\Comments\AppInfo\Application;
+use OCA\Comments\Event\LoadCommentsApp;
use OCA\Files\Event\LoadSidebar;
use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventDispatcher;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
class LoadSidebarScripts implements IEventListener {
+
+ /** @var IEventDispatcher */
+ private $eventDispatcher;
+
+ public function __construct(IEventDispatcher $eventDispatcher) {
+ $this->eventDispatcher = $eventDispatcher;
+ }
+
public function handle(Event $event): void {
if (!($event instanceof LoadSidebar)) {
return;
}
+ $this->eventDispatcher->dispatchTyped(new LoadCommentsApp());
+
// TODO: make sure to only include the sidebar script when
// we properly split it between files list and sidebar
Util::addScript(Application::APP_ID, 'comments');
+ Util::addScript(Application::APP_ID, 'comments-tab');
}
}
diff --git a/apps/comments/src/comments-app.js b/apps/comments/src/comments-app.js
new file mode 100644
index 00000000000..ced5577d5f8
--- /dev/null
+++ b/apps/comments/src/comments-app.js
@@ -0,0 +1,32 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import CommentsInstance from './services/CommentsInstance'
+
+// Init Comments
+if (window.OCA && !window.OCA.Comments) {
+ Object.assign(window.OCA, { Comments: {} })
+}
+
+// Init Comments App view
+Object.assign(window.OCA.Comments, { View: CommentsInstance })
+console.debug('OCA.Comments.View initialized')
diff --git a/apps/comments/src/comments-tab.js b/apps/comments/src/comments-tab.js
new file mode 100644
index 00000000000..50126dc8d2d
--- /dev/null
+++ b/apps/comments/src/comments-tab.js
@@ -0,0 +1,58 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+// Init Comments tab component
+let TabInstance = null
+const commentTab = new OCA.Files.Sidebar.Tab({
+ id: 'comments',
+ name: t('comments', 'Comments'),
+ icon: 'icon-comment',
+
+ async mount(el, fileInfo, context) {
+ if (TabInstance) {
+ TabInstance.$destroy()
+ }
+ TabInstance = new OCA.Comments.View('files', {
+ // Better integration with vue parent component
+ parent: context,
+ })
+ // Only mount after we have all the info we need
+ await TabInstance.update(fileInfo.id)
+ TabInstance.$mount(el)
+ },
+ update(fileInfo) {
+ TabInstance.update(fileInfo.id)
+ },
+ destroy() {
+ TabInstance.$destroy()
+ TabInstance = null
+ },
+ scrollBottomReached() {
+ TabInstance.onScrollBottomReached()
+ },
+})
+
+window.addEventListener('DOMContentLoaded', function() {
+ if (OCA.Files && OCA.Files.Sidebar) {
+ OCA.Files.Sidebar.registerTab(commentTab)
+ }
+})
diff --git a/apps/comments/src/components/Comment.vue b/apps/comments/src/components/Comment.vue
new file mode 100644
index 00000000000..acacb156f75
--- /dev/null
+++ b/apps/comments/src/components/Comment.vue
@@ -0,0 +1,295 @@
+<!--
+ - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+<template>
+ <div v-show="!deleted"
+ :class="{'comment--loading': loading}"
+ class="comment">
+ <!-- Comment header toolbar -->
+ <div class="comment__header">
+ <!-- Author -->
+ <Avatar class="comment__avatar"
+ :display-name="actorDisplayName"
+ :user="actorId"
+ :size="32" />
+ <span class="comment__author">{{ actorDisplayName }}</span>
+
+ <!-- Comment actions,
+ show if we have a message id and current user is author -->
+ <Actions v-if="isOwnComment && id && !loading" class="comment__actions">
+ <template v-if="!editing">
+ <ActionButton
+ :close-after-click="true"
+ icon="icon-rename"
+ @click="onEdit">
+ {{ t('comments', 'Edit comment') }}
+ </ActionButton>
+ <ActionSeparator />
+ <ActionButton
+ :close-after-click="true"
+ icon="icon-delete"
+ @click="onDeleteWithUndo">
+ {{ t('comments', 'Delete comment') }}
+ </ActionButton>
+ </template>
+
+ <ActionButton v-else
+ icon="icon-close"
+ @click="onEditCancel">
+ {{ t('comments', 'Cancel edit') }}
+ </ActionButton>
+ </Actions>
+
+ <!-- Show loading if we're editing or deleting, not on new ones -->
+ <div v-if="id && loading" class="comment_loading icon-loading-small" />
+
+ <!-- Relative time to the comment creation -->
+ <Moment v-else-if="creationDateTime" class="comment__timestamp" :timestamp="timestamp" />
+ </div>
+
+ <!-- Message editor -->
+ <div class="comment__message" v-if="editor || editing">
+ <RichContenteditable v-model="localMessage" :auto-complete="autoComplete" :contenteditable="!loading" />
+ <input v-tooltip="t('comments', 'Post comment')"
+ :class="loading ? 'icon-loading-small' :'icon-confirm'"
+ class="comment__submit"
+ type="submit"
+ :disabled="isEmptyMessage"
+ value=""
+ @click="onSubmit">
+ </div>
+
+ <!-- Message content -->
+ <!-- The html is escaped and sanitized before rendering -->
+ <!-- eslint-disable-next-line vue/no-v-html-->
+ <div v-else class="comment__message" v-html="renderedContent" />
+ </div>
+</template>
+
+<script>
+import { getCurrentUser } from '@nextcloud/auth'
+import moment from 'moment'
+
+import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
+import Actions from '@nextcloud/vue/dist/Components/Actions'
+import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
+import Avatar from '@nextcloud/vue/dist/Components/Avatar'
+import RichContenteditable from '@nextcloud/vue/dist/Components/RichContenteditable'
+import RichEditorMixin from '@nextcloud/vue/dist/Mixins/richEditor'
+
+import Moment from './Moment'
+import CommentMixin from '../mixins/CommentMixin'
+
+export default {
+ name: 'Comment',
+
+ components: {
+ ActionButton,
+ Actions,
+ ActionSeparator,
+ Avatar,
+ Moment,
+ RichContenteditable,
+ },
+ mixins: [RichEditorMixin, CommentMixin],
+
+ inheritAttrs: false,
+
+ props: {
+ source: {
+ type: Object,
+ default: () => ({}),
+ },
+ actorDisplayName: {
+ type: String,
+ required: true,
+ },
+ actorId: {
+ type: String,
+ required: true,
+ },
+ creationDateTime: {
+ type: String,
+ default: null,
+ },
+
+ /**
+ * Force the editor display
+ */
+ editor: {
+ type: Boolean,
+ default: false,
+ },
+
+ /**
+ * Provide the autocompletion data
+ */
+ autoComplete: {
+ type: Function,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ // Only change data locally and update the original
+ // parent data when the request is sent and resolved
+ localMessage: '',
+ }
+ },
+
+ computed: {
+
+ /**
+ * Is the current user the author of this comment
+ * @returns {boolean}
+ */
+ isOwnComment() {
+ return getCurrentUser().uid === this.actorId
+ },
+
+ /**
+ * Rendered content as html string
+ * @returns {string}
+ */
+ renderedContent() {
+ if (this.isEmptyMessage) {
+ return ''
+ }
+ return this.renderContent(this.localMessage)
+ },
+
+ isEmptyMessage() {
+ return !this.localMessage || this.localMessage.trim() === ''
+ },
+
+ timestamp() {
+ // seconds, not milliseconds
+ return parseInt(moment(this.creationDateTime).format('x'), 10) / 1000
+ },
+ },
+
+ watch: {
+ // If the data change, update the local value
+ message(message) {
+ this.updateLocalMessage(message)
+ },
+ },
+
+ beforeMount() {
+ // Init localMessage
+ this.updateLocalMessage(this.message)
+ },
+
+ methods: {
+ /**
+ * Update local Message on outer change
+ * @param {string} message the message to set
+ */
+ updateLocalMessage(message) {
+ this.localMessage = message.toString()
+ },
+
+ /**
+ * Dispatch message between edit and create
+ */
+ onSubmit() {
+ if (this.editor) {
+ this.onNewComment(this.localMessage)
+ return
+ }
+ this.onEditComment(this.localMessage)
+ },
+ },
+
+}
+</script>
+
+<style lang="scss" scoped>
+$comment-padding: 10px;
+
+.comment {
+ position: relative;
+ padding: $comment-padding 0 $comment-padding * 1.5;
+
+ &__header {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ padding: $comment-padding / 2 0;
+ }
+
+ &__author,
+ &__actions {
+ margin-left: $comment-padding !important;
+ }
+
+ &__author {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--color-text-maxcontrast);
+ }
+
+ &_loading,
+ &__timestamp {
+ margin-left: auto;
+ color: var(--color-text-maxcontrast);
+ }
+
+ &__message {
+ position: relative;
+ // Avatar size, align with author name
+ padding-left: 32px + $comment-padding;
+ }
+
+ &__submit {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: 44px;
+ height: 44px;
+ // Align with input border
+ margin: 1px;
+ cursor: pointer;
+ opacity: .7;
+ border: none;
+ background-color: transparent !important;
+
+ &:disabled {
+ cursor: not-allowed;
+ opacity: .5;
+ }
+
+ &:focus,
+ &:hover {
+ opacity: 1;
+ }
+ }
+}
+
+.rich-contenteditable__input {
+ margin: 0;
+ padding: $comment-padding;
+ min-height: 44px;
+}
+
+</style>
diff --git a/apps/comments/src/components/Moment.vue b/apps/comments/src/components/Moment.vue
new file mode 100644
index 00000000000..a91ed8b9ce6
--- /dev/null
+++ b/apps/comments/src/components/Moment.vue
@@ -0,0 +1,31 @@
+<!-- TODO: Move to vue components -->
+
+<template>
+ <span class="live-relative-timestamp" :data-timestamp="timestamp * 1000" :title="title">{{ formatted }}</span>
+</template>
+
+<script>
+import moment from '@nextcloud/moment'
+
+export default {
+ name: 'Moment',
+ props: {
+ timestamp: {
+ type: Number,
+ required: true,
+ },
+ format: {
+ type: String,
+ default: 'LLL',
+ },
+ },
+ computed: {
+ title() {
+ return moment.unix(this.timestamp).format(this.format)
+ },
+ formatted() {
+ return moment.unix(this.timestamp).fromNow()
+ },
+ },
+}
+</script>
diff --git a/apps/comments/src/mixins/CommentMixin.js b/apps/comments/src/mixins/CommentMixin.js
new file mode 100644
index 00000000000..03f5db0846f
--- /dev/null
+++ b/apps/comments/src/mixins/CommentMixin.js
@@ -0,0 +1,117 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import NewComment from '../services/NewComment'
+import DeleteComment from '../services/DeleteComment'
+import EditComment from '../services/EditComment'
+import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
+
+export default {
+ props: {
+ id: {
+ type: Number,
+ default: null,
+ },
+ message: {
+ // GenFileInfo can convert message as numbers if they doesn't contains text
+ type: [String, Number],
+ default: '',
+ },
+ ressourceId: {
+ type: [String, Number],
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ deleted: false,
+ editing: false,
+ loading: false,
+ }
+ },
+
+ methods: {
+ // EDITION
+ onEdit() {
+ this.editing = true
+ },
+ onEditCancel() {
+ this.editing = false
+ // Restore original value
+ this.updateLocalMessage(this.message)
+ },
+ async onEditComment(message) {
+ this.loading = true
+ try {
+ await EditComment(this.commentsType, this.ressourceId, this.id, message)
+ this.logger.debug('Comment edited', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id, message })
+ this.$emit('update:message', message)
+ this.editing = false
+ } catch (error) {
+ showError(t('comments', 'An error occurred while trying to edit the comment'))
+ console.error(error)
+ } finally {
+ this.loading = false
+ }
+ },
+
+ // DELETION
+ onDeleteWithUndo() {
+ this.deleted = true
+ const timeOutDelete = setTimeout(this.onDelete, TOAST_UNDO_TIMEOUT)
+ showUndo(t('comments', 'Comment deleted'), () => {
+ clearTimeout(timeOutDelete)
+ this.deleted = false
+ })
+ },
+ async onDelete() {
+ try {
+ await DeleteComment(this.commentsType, this.ressourceId, this.id)
+ this.logger.debug('Comment deleted', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id })
+ this.$emit('delete', this.id)
+ } catch (error) {
+ showError(t('comments', 'An error occurred while trying to delete the comment'))
+ console.error(error)
+ this.deleted = false
+ }
+ },
+
+ // CREATION
+ async onNewComment(message) {
+ this.loading = true
+ try {
+ const newComment = await NewComment(this.commentsType, this.ressourceId, message)
+ this.logger.debug('New comment posted', { commentsType: this.commentsType, ressourceId: this.ressourceId, newComment })
+ this.$emit('new', newComment)
+ // Clear old content
+ this.$emit('update:message', '')
+ this.localMessage = ''
+ } catch (error) {
+ showError(t('comments', 'An error occurred while trying to create the comment'))
+ console.error(error)
+ } finally {
+ this.loading = false
+ }
+ },
+ },
+}
diff --git a/apps/comments/src/services/CommentsInstance.js b/apps/comments/src/services/CommentsInstance.js
new file mode 100644
index 00000000000..9eeea198760
--- /dev/null
+++ b/apps/comments/src/services/CommentsInstance.js
@@ -0,0 +1,69 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import CommentsApp from '../views/Comments'
+import Vue from 'vue'
+
+const logger = getLoggerBuilder()
+ .setApp('comments')
+ .detectUser()
+ .build()
+
+// Add translates functions
+Vue.mixin({
+ data() {
+ return {
+ logger,
+ }
+ },
+ methods: {
+ t,
+ n,
+ },
+})
+
+export default class CommentInstance {
+
+ /**
+ * Initialize a new Comments instance for the desired type
+ *
+ * @param {string} commentsType the comments endpoint type
+ * @param {Object} options the vue options (propsData, parent, el...)
+ */
+ constructor(commentsType = 'files', options) {
+ // Add comments type as a global mixin
+ Vue.mixin({
+ data() {
+ return {
+ commentsType,
+ }
+ },
+ })
+
+ // Init Comments component
+ const View = Vue.extend(CommentsApp)
+ return new View(options)
+ }
+
+}
diff --git a/apps/comments/src/services/DavClient.js b/apps/comments/src/services/DavClient.js
new file mode 100644
index 00000000000..9fc67b52c98
--- /dev/null
+++ b/apps/comments/src/services/DavClient.js
@@ -0,0 +1,37 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import webdav from 'webdav'
+import axios from '@nextcloud/axios'
+import { getRootPath } from '../utils/davUtils'
+
+// Add this so the server knows it is an request from the browser
+axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
+
+// force our axios
+const patcher = webdav.getPatcher()
+patcher.patch('request', axios)
+
+// init webdav client
+const client = webdav.createClient(getRootPath())
+
+export default client
diff --git a/apps/comments/src/services/DeleteComment.js b/apps/comments/src/services/DeleteComment.js
new file mode 100644
index 00000000000..d9954a5603e
--- /dev/null
+++ b/apps/comments/src/services/DeleteComment.js
@@ -0,0 +1,37 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import client from './DavClient'
+
+/**
+ * Delete a comment
+ *
+ * @param {string} commentsType the ressource type
+ * @param {number} ressourceId the ressource ID
+ * @param {number} commentId the comment iD
+ */
+export default async function(commentsType, ressourceId, commentId) {
+ const commentPath = ['', commentsType, ressourceId, commentId].join('/')
+
+ // Fetch newly created comment data
+ await client.deleteFile(commentPath)
+}
diff --git a/apps/comments/src/services/EditComment.js b/apps/comments/src/services/EditComment.js
new file mode 100644
index 00000000000..fd6624c7da8
--- /dev/null
+++ b/apps/comments/src/services/EditComment.js
@@ -0,0 +1,49 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import client from './DavClient'
+
+/**
+ * Edit an existing comment
+ *
+ * @param {string} commentsType the ressource type
+ * @param {number} ressourceId the ressource ID
+ * @param {number} commentId the comment iD
+ * @param {string} message the message content
+ */
+export default async function(commentsType, ressourceId, commentId, message) {
+ const commentPath = ['', commentsType, ressourceId, commentId].join('/')
+
+ return await client.customRequest(commentPath, Object.assign({
+ method: 'PROPPATCH',
+ data: `<?xml version="1.0"?>
+ <d:propertyupdate
+ xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns">
+ <d:set>
+ <d:prop>
+ <oc:message>${message}</oc:message>
+ </d:prop>
+ </d:set>
+ </d:propertyupdate>`,
+ }))
+}
diff --git a/apps/comments/src/services/GetComments.js b/apps/comments/src/services/GetComments.js
new file mode 100644
index 00000000000..a1ac89069ee
--- /dev/null
+++ b/apps/comments/src/services/GetComments.js
@@ -0,0 +1,80 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { parseXML, prepareFileFromProps } from 'webdav/dist/node/interface/dav'
+import { processResponsePayload } from 'webdav/dist/node/response'
+import client from './DavClient'
+import { genFileInfo } from '../utils/fileUtils'
+
+export const DEFAULT_LIMIT = 5
+/**
+ * Retrieve the comments list
+ *
+ * @param {Object} data destructuring object
+ * @param {string} data.commentsType the ressource type
+ * @param {number} data.ressourceId the ressource ID
+ * @param {Object} [options] optional options for axios
+ * @returns {Object[]} the comments list
+ */
+export default async function({ commentsType, ressourceId }, options = {}) {
+ let response = null
+ const ressourcePath = ['', commentsType, ressourceId].join('/')
+
+ return await client.customRequest(ressourcePath, Object.assign({
+ method: 'REPORT',
+ data: `<?xml version="1.0"?>
+ <oc:filter-comments
+ xmlns:d="DAV:"
+ xmlns:oc="http://owncloud.org/ns"
+ xmlns:nc="http://nextcloud.org/ns"
+ xmlns:ocs="http://open-collaboration-services.org/ns">
+ <oc:limit>${DEFAULT_LIMIT}</oc:limit>
+ <oc:offset>${options.offset || 0}</oc:offset>
+ </oc:filter-comments>`,
+ }, options))
+ // See example on how it's done normaly
+ // https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/stat.js#L19
+ // Waiting for proper REPORT integration https://github.com/perry-mitchell/webdav-client/issues/207
+ .then(res => {
+ response = res
+ return res.data
+ })
+ .then(parseXML)
+ .then(xml => processMultistatus(xml, true))
+ .then(comments => processResponsePayload(response, comments, true))
+ .then(response => response.data.map(genFileInfo))
+}
+
+// https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/directoryContents.js#L32
+function processMultistatus(result, isDetailed = false) {
+ // Extract the response items (directory contents)
+ const {
+ multistatus: { response: responseItems },
+ } = result
+ return responseItems.map(item => {
+ // Each item should contain a stat object
+ const {
+ propstat: { prop: props },
+ } = item
+ return prepareFileFromProps(props, props.id.toString(), isDetailed)
+ })
+}
diff --git a/apps/comments/src/services/NewComment.js b/apps/comments/src/services/NewComment.js
new file mode 100644
index 00000000000..96aee85e010
--- /dev/null
+++ b/apps/comments/src/services/NewComment.js
@@ -0,0 +1,60 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { genFileInfo } from '../utils/fileUtils'
+import { getCurrentUser } from '@nextcloud/auth'
+import { getRootPath } from '../utils/davUtils'
+import axios from '@nextcloud/axios'
+import client from './DavClient'
+
+/**
+ * Retrieve the comments list
+ *
+ * @param {string} commentsType the ressource type
+ * @param {number} ressourceId the ressource ID
+ * @param {string} message the message
+ * @returns {Object} the new comment
+ */
+export default async function(commentsType, ressourceId, message) {
+ const ressourcePath = ['', commentsType, ressourceId].join('/')
+
+ const response = await axios.post(getRootPath() + ressourcePath, {
+ actorDisplayName: getCurrentUser().displayName,
+ actorId: getCurrentUser().uid,
+ actorType: 'users',
+ creationDateTime: (new Date()).toUTCString(),
+ message,
+ objectType: 'files',
+ verb: 'comment',
+ })
+
+ // Retrieve comment id from ressource location
+ const commentId = parseInt(response.headers['content-location'].split('/').pop())
+ const commentPath = ressourcePath + '/' + commentId
+
+ // Fetch newly created comment data
+ const comment = await client.stat(commentPath, {
+ details: true,
+ })
+
+ return genFileInfo(comment)
+}
diff --git a/apps/comments/src/utils/cancelableRequest.js b/apps/comments/src/utils/cancelableRequest.js
new file mode 100644
index 00000000000..425e94a787a
--- /dev/null
+++ b/apps/comments/src/utils/cancelableRequest.js
@@ -0,0 +1,62 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import axios from '@nextcloud/axios'
+
+/**
+ * Create a cancel token
+ * @returns {CancelTokenSource}
+ */
+const createCancelToken = () => axios.CancelToken.source()
+
+/**
+ * Creates a cancelable axios 'request object'.
+ *
+ * @param {function} request the axios promise request
+ * @returns {Object}
+ */
+const cancelableRequest = function(request) {
+ /**
+ * Generate an axios cancel token
+ */
+ const cancelToken = createCancelToken()
+
+ /**
+ * Execute the request
+ *
+ * @param {string} url the url to send the request to
+ * @param {Object} [options] optional config for the request
+ */
+ const fetch = async function(url, options) {
+ return request(
+ url,
+ Object.assign({ cancelToken: cancelToken.token }, options)
+ )
+ }
+
+ return {
+ request: fetch,
+ cancel: cancelToken.cancel,
+ }
+}
+
+export default cancelableRequest
diff --git a/apps/comments/src/utils/davUtils.js b/apps/comments/src/utils/davUtils.js
new file mode 100644
index 00000000000..b10b62e4f34
--- /dev/null
+++ b/apps/comments/src/utils/davUtils.js
@@ -0,0 +1,29 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { generateRemoteUrl } from '@nextcloud/router'
+
+const getRootPath = function() {
+ return generateRemoteUrl('dav/comments')
+}
+
+export { getRootPath }
diff --git a/apps/comments/src/utils/fileUtils.js b/apps/comments/src/utils/fileUtils.js
new file mode 100644
index 00000000000..298732c8af0
--- /dev/null
+++ b/apps/comments/src/utils/fileUtils.js
@@ -0,0 +1,122 @@
+/**
+ * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+import camelcase from 'camelcase'
+import { isNumber } from './numberUtil'
+
+/**
+ * Get an url encoded path
+ *
+ * @param {String} path the full path
+ * @returns {string} url encoded file path
+ */
+const encodeFilePath = function(path) {
+ const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
+ let relativePath = ''
+ pathSections.forEach((section) => {
+ if (section !== '') {
+ relativePath += '/' + encodeURIComponent(section)
+ }
+ })
+ return relativePath
+}
+
+/**
+ * Extract dir and name from file path
+ *
+ * @param {String} path the full path
+ * @returns {String[]} [dirPath, fileName]
+ */
+const extractFilePaths = function(path) {
+ const pathSections = path.split('/')
+ const fileName = pathSections[pathSections.length - 1]
+ const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
+ return [dirPath, fileName]
+}
+
+/**
+ * Sorting comparison function
+ *
+ * @param {Object} fileInfo1 file 1 fileinfo
+ * @param {Object} fileInfo2 file 2 fileinfo
+ * @param {string} key key to sort with
+ * @param {boolean} [asc=true] sort ascending?
+ * @returns {number}
+ */
+const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) {
+
+ if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
+ return -1
+ } else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
+ return 1
+ }
+
+ // if this is a number, let's sort by integer
+ if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
+ return Number(fileInfo1[key]) - Number(fileInfo2[key])
+ }
+
+ // else we sort by string, so let's sort directories first
+ if (fileInfo1.type === 'directory' && fileInfo2.type !== 'directory') {
+ return -1
+ } else if (fileInfo1.type !== 'directory' && fileInfo2.type === 'directory') {
+ return 1
+ }
+
+ // finally sort by name
+ return asc
+ ? fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage())
+ : -fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage())
+}
+
+/**
+ * Generate a fileinfo object based on the full dav properties
+ * It will flatten everything and put all keys to camelCase
+ *
+ * @param {Object} obj the object
+ * @returns {Object}
+ */
+const genFileInfo = function(obj) {
+ const fileInfo = {}
+
+ Object.keys(obj).forEach(key => {
+ const data = obj[key]
+
+ // flatten object if any
+ if (!!data && typeof data === 'object' && !Array.isArray(data)) {
+ Object.assign(fileInfo, genFileInfo(data))
+ } else {
+ // format key and add it to the fileInfo
+ if (data === 'false') {
+ fileInfo[camelcase(key)] = false
+ } else if (data === 'true') {
+ fileInfo[camelcase(key)] = true
+ } else {
+ fileInfo[camelcase(key)] = isNumber(data)
+ ? Number(data)
+ : data
+ }
+ }
+ })
+ return fileInfo
+}
+
+export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo }
diff --git a/apps/comments/src/utils/numberUtil.js b/apps/comments/src/utils/numberUtil.js
new file mode 100644
index 00000000000..018c34c49e8
--- /dev/null
+++ b/apps/comments/src/utils/numberUtil.js
@@ -0,0 +1,30 @@
+/**
+ * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+const isNumber = function(num) {
+ if (!num) {
+ return false
+ }
+ return Number(num).toString() === num.toString()
+}
+
+export { isNumber }
diff --git a/apps/comments/src/views/Comments.vue b/apps/comments/src/views/Comments.vue
new file mode 100644
index 00000000000..8c3ec66c323
--- /dev/null
+++ b/apps/comments/src/views/Comments.vue
@@ -0,0 +1,264 @@
+<!--
+ - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @author John Molakvoæ <skjnldsv@protonmail.com>
+ -
+ - @license GNU AGPL version 3 or any later version
+ -
+ - This program is free software: you can redistribute it and/or modify
+ - it under the terms of the GNU Affero General Public License as
+ - published by the Free Software Foundation, either version 3 of the
+ - License, or (at your option) any later version.
+ -
+ - This program is distributed in the hope that it will be useful,
+ - but WITHOUT ANY WARRANTY; without even the implied warranty of
+ - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ - GNU Affero General Public License for more details.
+ -
+ - You should have received a copy of the GNU Affero General Public License
+ - along with this program. If not, see <http://www.gnu.org/licenses/>.
+ -
+ -->
+
+<template>
+ <div class="comments" :class="{ 'icon-loading': isFirstLoading }">
+ <!-- Editor -->
+ <Comment v-bind="editorData"
+ :auto-complete="autoComplete"
+ :editor="true"
+ :ressource-id="ressourceId"
+ class="comments__writer"
+ @new="onNewComment" />
+
+ <template v-if="!isFirstLoading">
+ <EmptyContent v-if="!hasComments && done" icon="icon-comment">
+ {{ t('comments', 'No comments yet, start the conversation!') }}
+ </EmptyContent>
+
+ <!-- Comments -->
+ <Comment v-for="comment in comments"
+ v-else
+ :key="comment.id"
+ v-bind="comment"
+ :auto-complete="autoComplete"
+ :ressource-id="ressourceId"
+ :message.sync="comment.message"
+ class="comments__list"
+ @delete="onDelete" />
+
+ <!-- Loading more message -->
+ <div v-if="loading && !isFirstLoading" class="comments__info icon-loading" />
+
+ <div v-else-if="hasComments && done" class="comments__info">
+ {{ t('comments', 'No more messages') }}
+ </div>
+
+ <!-- Error message -->
+ <EmptyContent v-else-if="error" class="comments__error" icon="icon-error">
+ {{ error }}
+ <template #desc>
+ <button icon="icon-history" @click="getComments">
+ {{ t('comments', 'Retry') }}
+ </button>
+ </template>
+ </EmptyContent>
+ </template>
+ </div>
+</template>
+
+<script>
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import axios from '@nextcloud/axios'
+import VTooltip from 'v-tooltip'
+import Vue from 'vue'
+
+import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
+
+import Comment from '../components/Comment'
+import getComments, { DEFAULT_LIMIT } from '../services/GetComments'
+import cancelableRequest from '../utils/cancelableRequest'
+
+Vue.use(VTooltip)
+
+export default {
+ name: 'Comments',
+
+ components: {
+ // Avatar,
+ Comment,
+ EmptyContent,
+ },
+
+ data() {
+ return {
+ error: '',
+ loading: false,
+ done: false,
+
+ ressourceId: null,
+ offset: 0,
+ comments: [],
+
+ cancelRequest: () => {},
+
+ editorData: {
+ actorDisplayName: getCurrentUser().displayName,
+ actorId: getCurrentUser().uid,
+ key: 'editor',
+ },
+
+ Comment,
+ }
+ },
+
+ computed: {
+ hasComments() {
+ return this.comments.length > 0
+ },
+ isFirstLoading() {
+ return this.loading && this.offset === 0
+ },
+ },
+
+ methods: {
+ /**
+ * Update current ressourceId and fetch new data
+ * @param {Number} ressourceId the current ressourceId (fileId...)
+ */
+ async update(ressourceId) {
+ this.ressourceId = ressourceId
+ this.resetState()
+ this.getComments()
+ },
+
+ /**
+ * Ran when the bottom of the tab is reached
+ */
+ onScrollBottomReached() {
+ /**
+ * Do not fetch more if we:
+ * - are showing an error
+ * - already fetched everything
+ * - are currently loading
+ */
+ if (this.error || this.done || this.loading) {
+ return
+ }
+ this.getComments()
+ },
+
+ /**
+ * Get the existing shares infos
+ */
+ async getComments() {
+ // Cancel any ongoing request
+ this.cancelRequest('cancel')
+
+ try {
+ this.loading = true
+ this.error = ''
+
+ // Init cancellable request
+ const { request, cancel } = cancelableRequest(getComments)
+ this.cancelRequest = cancel
+
+ // Fetch comments
+ const comments = await request({
+ commentsType: this.commentsType,
+ ressourceId: this.ressourceId,
+ }, { offset: this.offset })
+
+ this.logger.debug(`Processed ${comments.length} comments`, { comments })
+
+ // We received less than the requested amount,
+ // we're done fetching comments
+ if (comments.length < DEFAULT_LIMIT) {
+ this.done = true
+ }
+
+ // Insert results
+ this.comments.push(...comments)
+
+ // Increase offset for next fetch
+ this.offset += DEFAULT_LIMIT
+ } catch (error) {
+ if (error.message === 'cancel') {
+ return
+ }
+ // Reverting offset
+ this.error = t('comments', 'Unable to load the comments list')
+ console.error('Error loading the comments list', error)
+ } finally {
+ this.loading = false
+ }
+ },
+
+ /**
+ * Autocomplete @mentions
+ * @param {string} search the query
+ * @param {Function} callback the callback to process the results with
+ */
+ async autoComplete(search, callback) {
+ const results = await axios.get(generateOcsUrl('core', 2) + 'autocomplete/get', {
+ params: {
+ search,
+ itemType: 'files',
+ itemId: this.ressourceId,
+ sorter: 'commenters|share-recipients',
+ limit: OC.appConfig?.comments?.maxAutoCompleteResults || 25,
+ },
+ })
+ return callback(results.data.ocs.data)
+ },
+
+ /**
+ * Add newly created comment to the list
+ * @param {Object} comment the new comment
+ */
+ onNewComment(comment) {
+ this.comments.unshift(comment)
+ },
+
+ /**
+ * Remove deleted comment from the list
+ * @param {number} id the deleted comment
+ */
+ onDelete(id) {
+ const index = this.comments.findIndex(comment => comment.id === id)
+ if (index > -1) {
+ this.comments.splice(index, 1)
+ } else {
+ console.error('Could not find the deleted comment in the list', id)
+ }
+ },
+
+ /**
+ * Reset the current view to its default state
+ */
+ resetState() {
+ this.error = ''
+ this.loading = false
+ this.done = false
+ this.offset = 0
+ this.comments = []
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.comments {
+ // Do not add emptycontent top margin
+ &__error{
+ margin-top: 0;
+ }
+
+ &__info {
+ height: 60px;
+ color: var(--color-text-maxcontrast);
+ text-align: center;
+ line-height: 60px;
+ }
+}
+</style>
diff --git a/apps/comments/webpack.js b/apps/comments/webpack.js
index 8244389aeac..b9cba1ca218 100644
--- a/apps/comments/webpack.js
+++ b/apps/comments/webpack.js
@@ -1,14 +1,18 @@
const path = require('path')
module.exports = {
- entry: path.join(__dirname, 'src', 'comments.js'),
+ entry: {
+ comments: path.join(__dirname, 'src', 'comments.js'),
+ 'comments-app': path.join(__dirname, 'src', 'comments-app.js'),
+ 'comments-tab': path.join(__dirname, 'src', 'comments-tab.js'),
+ },
output: {
path: path.resolve(__dirname, './js'),
publicPath: '/js/',
- filename: 'comments.js',
- jsonpFunction: 'webpackJsonpComments'
+ filename: '[name].js',
+ jsonpFunction: 'webpackJsonpComments',
},
externals: {
- jquery: 'jQuery'
- }
+ jquery: 'jQuery',
+ },
}
diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue
index 1fc93486bc0..bead5cad1ef 100644
--- a/apps/files/src/components/SidebarTab.vue
+++ b/apps/files/src/components/SidebarTab.vue
@@ -25,7 +25,8 @@
:id="id"
ref="tab"
:name="name"
- :icon="icon">
+ :icon="icon"
+ @bottomReached="onScrollBottomReached">
<!-- Fallback loading -->
<EmptyContent v-if="loading" icon="icon-loading" />
@@ -83,6 +84,10 @@ export default {
type: Function,
required: true,
},
+ onScrollBottomReached: {
+ type: Function,
+ default: () => {},
+ },
},
data() {
@@ -120,6 +125,5 @@ export default {
// unmount the tab
await this.onDestroy()
},
-
}
</script>
diff --git a/apps/files/src/models/Tab.js b/apps/files/src/models/Tab.js
index 2c587e5f70a..670c72e3a3a 100644
--- a/apps/files/src/models/Tab.js
+++ b/apps/files/src/models/Tab.js
@@ -29,6 +29,7 @@ export default class Tab {
#update
#destroy
#enabled
+ #scrollBottomReached
/**
* Create a new tab instance
@@ -41,11 +42,15 @@ export default class Tab {
* @param {Function} options.update function to update the tab
* @param {Function} options.destroy function to destroy the tab
* @param {Function} [options.enabled] define conditions whether this tab is active. Must returns a boolean
+ * @param {Function} [options.scrollBottomReached] executed when the tab is scrolled to the bottom
*/
- constructor({ id, name, icon, mount, update, destroy, enabled } = {}) {
+ constructor({ id, name, icon, mount, update, destroy, enabled, scrollBottomReached } = {}) {
if (enabled === undefined) {
enabled = () => true
}
+ if (scrollBottomReached === undefined) {
+ scrollBottomReached = () => {}
+ }
// Sanity checks
if (typeof id !== 'string' || id.trim() === '') {
@@ -69,6 +74,9 @@ export default class Tab {
if (typeof enabled !== 'function') {
throw new Error('The enabled argument should be a function')
}
+ if (typeof scrollBottomReached !== 'function') {
+ throw new Error('The scrollBottomReached argument should be a function')
+ }
this.#id = id
this.#name = name
@@ -77,6 +85,7 @@ export default class Tab {
this.#update = update
this.#destroy = destroy
this.#enabled = enabled
+ this.#scrollBottomReached = scrollBottomReached
}
@@ -108,4 +117,8 @@ export default class Tab {
return this.#enabled
}
+ get scrollBottomReached() {
+ return this.#scrollBottomReached
+ }
+
}
diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue
index 664bc6f4075..0b3f2bb1741 100644
--- a/apps/files/src/views/Sidebar.vue
+++ b/apps/files/src/views/Sidebar.vue
@@ -69,6 +69,7 @@
:on-mount="tab.mount"
:on-update="tab.update"
:on-destroy="tab.destroy"
+ :on-scroll-bottom-reached="tab.scrollBottomReached"
:file-info="fileInfo" />
</template>
</AppSidebar>
diff --git a/core/Controller/AutoCompleteController.php b/core/Controller/AutoCompleteController.php
index 56ad21f421c..0a29d2fa157 100644
--- a/core/Controller/AutoCompleteController.php
+++ b/core/Controller/AutoCompleteController.php
@@ -41,18 +41,18 @@ use OCP\Share\IShare;
class AutoCompleteController extends Controller {
/** @var ISearch */
private $collaboratorSearch;
+
/** @var IManager */
private $autoCompleteManager;
+
/** @var IEventDispatcher */
private $dispatcher;
- public function __construct(
- string $appName,
- IRequest $request,
- ISearch $collaboratorSearch,
- IManager $autoCompleteManager,
- IEventDispatcher $dispatcher
- ) {
+ public function __construct(string $appName,
+ IRequest $request,
+ ISearch $collaboratorSearch,
+ IManager $autoCompleteManager,
+ IEventDispatcher $dispatcher) {
parent::__construct($appName, $request);
$this->collaboratorSearch = $collaboratorSearch;
@@ -114,7 +114,10 @@ class AutoCompleteController extends Controller {
$output[] = [
'id' => (string) $result['value']['shareWith'],
'label' => $result['label'],
+ 'icon' => $result['icon'],
'source' => $type,
+ 'status' => $result['status'],
+ 'subline' => $result['subline']
];
}
}
diff --git a/lib/private/Collaboration/Collaborators/UserPlugin.php b/lib/private/Collaboration/Collaborators/UserPlugin.php
index 2d21c6a16f7..0b3a279182b 100644
--- a/lib/private/Collaboration/Collaborators/UserPlugin.php
+++ b/lib/private/Collaboration/Collaborators/UserPlugin.php
@@ -156,6 +156,8 @@ class UserPlugin implements ISearchPlugin {
}
$result['exact'][] = [
'label' => $userDisplayName,
+ 'subline' => $status['message'],
+ 'icon' => 'icon-user',
'value' => [
'shareType' => IShare::TYPE_USER,
'shareWith' => $uid,
@@ -178,6 +180,8 @@ class UserPlugin implements ISearchPlugin {
if ($addToWideResults) {
$result['wide'][] = [
'label' => $userDisplayName,
+ 'subline' => $status['message'],
+ 'icon' => 'icon-user',
'value' => [
'shareType' => IShare::TYPE_USER,
'shareWith' => $uid,
@@ -217,6 +221,8 @@ class UserPlugin implements ISearchPlugin {
$result['exact'][] = [
'label' => $user->getDisplayName(),
+ 'icon' => 'icon-user',
+ 'subline' => $status['message'],
'value' => [
'shareType' => IShare::TYPE_USER,
'shareWith' => $user->getUID(),
diff --git a/package-lock.json b/package-lock.json
index c60f22c98ec..15857a58db1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2354,6 +2354,11 @@
}
}
},
+ "base-64": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
+ "integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs="
+ },
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
@@ -2692,9 +2697,9 @@
"dev": true
},
"camelcase": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
- "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz",
+ "integrity": "sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w=="
},
"camelcase-keys": {
"version": "2.1.0",
@@ -3159,6 +3164,12 @@
"semver": "^6.3.0"
},
"dependencies": {
+ "camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true
+ },
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@@ -4431,6 +4442,11 @@
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
},
+ "fast-xml-parser": {
+ "version": "3.17.4",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.4.tgz",
+ "integrity": "sha512-qudnQuyYBgnvzf5Lj/yxMcf4L9NcVWihXJg7CiU1L+oUCq8MUnFEfH2/nXR/W5uq+yvUN1h7z6s7vs2v1WkL1A=="
+ },
"fastparse": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz",
@@ -5187,6 +5203,11 @@
"integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==",
"dev": true
},
+ "hot-patcher": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-0.5.0.tgz",
+ "integrity": "sha512-2Uu2W0s8+dnqXzdlg0MRsRzPoDCs1wVjOGSyMRRaMzLDX4bgHw6xDYKccsWafXPPxQpkQfEjgW6+17pwcg60bw=="
+ },
"html-encoding-sniffer": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
@@ -6658,6 +6679,11 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
+ "nested-property": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-1.0.4.tgz",
+ "integrity": "sha512-6fNIumJJUyP3rkB4FyVYCYpdW+PKUCaxRWRGLLf0kv/RKoG4mbTvInedA9x3zOyuOmOkGudKuAtPSI+dnhwj2g=="
+ },
"nextcloud-vue-collections": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/nextcloud-vue-collections/-/nextcloud-vue-collections-0.8.1.tgz",
@@ -7286,6 +7312,11 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
},
+ "path-posix": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz",
+ "integrity": "sha1-BrJhE/Vr6rBCVFojv6iAA8ysJg8="
+ },
"path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
@@ -7656,6 +7687,11 @@
"integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
"dev": true
},
+ "querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
+ },
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -9340,6 +9376,15 @@
}
}
},
+ "url-parse": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz",
+ "integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==",
+ "requires": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
"url-search-params-polyfill": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-8.1.0.tgz",
@@ -9566,6 +9611,11 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
+ "vue-virtual-scroll-list": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.1.tgz",
+ "integrity": "sha512-2p0bvcmUIMet5tln+cOKt/XjNvgP+ebq9bBD+gquK2rivsSSAFHeqQidzMO3wPFfxWeTB1JpoSzkyL9nzZ9yfA=="
+ },
"vue-virtual-scroller": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.0.10.tgz",
@@ -9759,6 +9809,54 @@
"chokidar": "^2.1.8"
}
},
+ "webdav": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/webdav/-/webdav-3.3.0.tgz",
+ "integrity": "sha512-wTfLNbeK1++T1ooL/ZJaUTJGb5NUuO4zAwuTShNPbzN0mRMRIaoZYG7sI5TtyH1uqOPIOW5ZGTtZiBypLG86KQ==",
+ "requires": {
+ "axios": "^0.19.2",
+ "base-64": "^0.1.0",
+ "fast-xml-parser": "^3.16.0",
+ "he": "^1.2.0",
+ "hot-patcher": "^0.5.0",
+ "minimatch": "^3.0.4",
+ "nested-property": "^1.0.4",
+ "path-posix": "^1.0.0",
+ "url-join": "^4.0.1",
+ "url-parse": "^1.4.7"
+ },
+ "dependencies": {
+ "axios": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
+ "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
+ "requires": {
+ "follow-redirects": "1.5.10"
+ }
+ },
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "follow-redirects": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz",
+ "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==",
+ "requires": {
+ "debug": "=3.1.0"
+ }
+ },
+ "url-join": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
+ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="
+ }
+ }
+ },
"webidl-conversions": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
@@ -10066,6 +10164,13 @@
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="
+ }
}
},
"yargs-unparser": {
diff --git a/package.json b/package.json
index 291fafe8d28..ebecf31559e 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
"backbone": "^1.4.0",
"blueimp-md5": "^2.18.0",
"bootstrap": "^4.5.2",
+ "camelcase": "^6.0.0",
"clipboard": "^2.0.6",
"core-js": "^3.6.5",
"css-vars-ponyfill": "^2.3.2",
@@ -85,7 +86,8 @@
"vue-router": "^3.4.7",
"vuedraggable": "^2.24.2",
"vuex": "^3.5.1",
- "vuex-router-sync": "^5.0.0"
+ "vuex-router-sync": "^5.0.0",
+ "webdav": "^3.3.0"
},
"devDependencies": {
"@babel/core": "^7.11.6",