Init vue comments tab

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-10-04 15:33:17 +02:00
parent 3d2024faf9
commit e7f5516b4d
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
30 changed files with 1642 additions and 19 deletions

View 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',

View 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',

View 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

View File

@ -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 {
}

View File

@ -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');
}
}

View 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');
}
}

View File

@ -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')

View File

@ -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)
}
})

View File

@ -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>

View File

@ -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>

View File

@ -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
}
},
},
}

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -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>`,
}))
}

View File

@ -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)
})
}

View File

@ -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)
}

View File

@ -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

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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>

View 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',
},
}

View 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>

View 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
}
}

View 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>

View 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']
];
}
}

View 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(),

111
package-lock.json generated
View File

@ -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": {

View 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",