]> source.dussan.org Git - nextcloud-server.git/commitdiff
Init vue comments tab
authorJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Sun, 4 Oct 2020 13:33:17 +0000 (15:33 +0200)
committerJohn Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
Tue, 20 Oct 2020 11:58:06 +0000 (13:58 +0200)
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
30 files changed:
apps/comments/composer/composer/autoload_classmap.php
apps/comments/composer/composer/autoload_static.php
apps/comments/lib/AppInfo/Application.php
apps/comments/lib/Event/LoadCommentsApp.php [new file with mode: 0644]
apps/comments/lib/Listener/LoadCommentsAppListener.php [new file with mode: 0644]
apps/comments/lib/Listener/LoadSidebarScripts.php
apps/comments/src/comments-app.js [new file with mode: 0644]
apps/comments/src/comments-tab.js [new file with mode: 0644]
apps/comments/src/components/Comment.vue [new file with mode: 0644]
apps/comments/src/components/Moment.vue [new file with mode: 0644]
apps/comments/src/mixins/CommentMixin.js [new file with mode: 0644]
apps/comments/src/services/CommentsInstance.js [new file with mode: 0644]
apps/comments/src/services/DavClient.js [new file with mode: 0644]
apps/comments/src/services/DeleteComment.js [new file with mode: 0644]
apps/comments/src/services/EditComment.js [new file with mode: 0644]
apps/comments/src/services/GetComments.js [new file with mode: 0644]
apps/comments/src/services/NewComment.js [new file with mode: 0644]
apps/comments/src/utils/cancelableRequest.js [new file with mode: 0644]
apps/comments/src/utils/davUtils.js [new file with mode: 0644]
apps/comments/src/utils/fileUtils.js [new file with mode: 0644]
apps/comments/src/utils/numberUtil.js [new file with mode: 0644]
apps/comments/src/views/Comments.vue [new file with mode: 0644]
apps/comments/webpack.js
apps/files/src/components/SidebarTab.vue
apps/files/src/models/Tab.js
apps/files/src/views/Sidebar.vue
core/Controller/AutoCompleteController.php
lib/private/Collaboration/Collaborators/UserPlugin.php
package-lock.json
package.json

index c4d8a9e331b91d785b35ebfbfbf529943b878394..b0485a5c4e3c5595a30d52180e567fe08a6c7240 100644 (file)
@@ -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',
index 72b37969ec0c4c01d3d82e19235717a9fecdc45e..bc69b25743d1342803b7fe8a34e310754ed1e3f5 100644 (file)
@@ -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',
index 4eb097ff0014319d483d4d98662479134d05a1a5..0f22cd309ec2ee98cdb22528dd909453f76fff16 100644 (file)
@@ -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;
@@ -70,6 +72,10 @@ class Application extends App implements IBootstrap {
                        LoadSidebar::class,
                        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 (file)
index 0000000..74ed93a
--- /dev/null
@@ -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 (file)
index 0000000..755bdae
--- /dev/null
@@ -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');
+       }
+}
index dfa7e511b1ffcae5dd3cf7e7495a2d1fe492903d..0b76d88363d9712b8c6169f0a0563c515913b313 100644 (file)
@@ -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 (file)
index 0000000..ced5577
--- /dev/null
@@ -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 (file)
index 0000000..50126dc
--- /dev/null
@@ -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 (file)
index 0000000..acacb15
--- /dev/null
@@ -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 (file)
index 0000000..a91ed8b
--- /dev/null
@@ -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 (file)
index 0000000..03f5db0
--- /dev/null
@@ -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 (file)
index 0000000..9eeea19
--- /dev/null
@@ -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 (file)
index 0000000..9fc67b5
--- /dev/null
@@ -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 (file)
index 0000000..d9954a5
--- /dev/null
@@ -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 (file)
index 0000000..fd6624c
--- /dev/null
@@ -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 (file)
index 0000000..a1ac890
--- /dev/null
@@ -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 (file)
index 0000000..96aee85
--- /dev/null
@@ -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 (file)
index 0000000..425e94a
--- /dev/null
@@ -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 (file)
index 0000000..b10b62e
--- /dev/null
@@ -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 (file)
index 0000000..298732c
--- /dev/null
@@ -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 (file)
index 0000000..018c34c
--- /dev/null
@@ -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 (file)
index 0000000..8c3ec66
--- /dev/null
@@ -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>
index 8244389aeacd78a1550aa2806b8650ba7aeff3bc..b9cba1ca218a5f883239c2d602da7a39be8e83cf 100644 (file)
@@ -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',
+       },
 }
index 1fc93486bc0044b6a5248adf716a93c0df996c40..bead5cad1ef917a76aef012776a2928e10d41266 100644 (file)
@@ -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>
index 2c587e5f70a743714b1e4dc5cb5cccde8bca2cd2..670c72e3a3a09d3c9b16137711fbffef689c02ca 100644 (file)
@@ -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
+       }
+
 }
index 664bc6f407517a17cc3fcf90181a3c5fc583f36e..0b3f2bb17416cc99d19523d1193d1defcfd4e442 100644 (file)
@@ -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>
index 56ad21f421cf32cb0390cafac391ccf052c80fe1..0a29d2fa15783504d5499eb0180529318110acbd 100644 (file)
@@ -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']
                                ];
                        }
                }
index 2d21c6a16f75b97bf6dcb37ee3f88332fa52e8b3..0b3a279182b9e58bc348e7acb025a11314e0692a 100644 (file)
@@ -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(),
index c60f22c98ec4b10e2d6b72f3f09e5db91ff46de6..15857a58db134396f43268c62435bb00ce177fd9 100644 (file)
         }
       }
     },
+    "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",
       "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",
         "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",
       "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",
       "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",
       "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",
       "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",
       "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",
         }
       }
     },
+    "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",
       "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",
         "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",
       "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": {
index 291fafe8d287bca30a38b25c058bdba084955820..ebecf31559e08a7dcb6418e4d3f7bb7d4d5f9c2c 100644 (file)
@@ -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",