Add OCA.Files.Sidebar

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2019-05-23 17:03:04 +02:00 committed by Daniel Calviño Sánchez
parent ea6f423e2c
commit fd90af50d9
46 changed files with 4607 additions and 109 deletions

View File

@ -1,5 +1,8 @@
module.exports = {
plugins: ['@babel/plugin-syntax-dynamic-import'],
plugins: [
'@babel/plugin-syntax-dynamic-import',
['@babel/plugin-proposal-class-properties', { loose: true }]
],
presets: [
[
'@babel/preset-env',

View File

@ -30,6 +30,7 @@ lint-fix-watch:
clean:
rm -rf apps/accessibility/js/
rm -rf apps/comments/js/
rm -rf apps/files/js/dist/
rm -rf apps/files_sharing/js/dist/
rm -rf apps/files_trashbin/js/
rm -rf apps/files_versions/js/
@ -47,6 +48,7 @@ clean-dev:
clean-git: clean
git checkout -- apps/accessibility/js/
git checkout -- apps/comments/js/
git checkout -- apps/files/js/dist/
git checkout -- apps/files_sharing/js/dist/
git checkout -- apps/files_trashbin/js/
git checkout -- apps/files_versions/js/

View File

@ -104,7 +104,7 @@
actionHandler: function(fileName, context) {
context.$file.find('.action-comment').tooltip('hide')
// open sidebar in comments section
context.fileList.showDetailsView(fileName, 'commentsTabView')
context.fileList.showDetailsView(fileName, 'comments')
}
})

View File

@ -87,6 +87,7 @@
/* fit app list view heights */
.app-files #app-content > .viewcontainer {
min-height: 0%;
width: 100%;
}
.app-files #app-content {

View File

@ -704,6 +704,12 @@
}
context.fileList.do_delete(fileName, context.dir);
$('.tipsy').remove();
// close sidebar on delete
const path = context.dir + '/' + fileName
if (OCA.Files.Sidebar && OCA.Files.Sidebar.file === path) {
OCA.Files.Sidebar.file = undefined
}
}
});

View File

@ -610,11 +610,11 @@
* @param {string} [tabId] optional tab id to select
*/
showDetailsView: function(fileName, tabId) {
console.warn('showDetailsView is deprecated! Use OCA.Files.Sidebar.activeTab. It will be removed in nextcloud 20.');
this._updateDetailsView(fileName);
if (tabId) {
this._detailsView.selectTab(tabId);
OCA.Files.Sidebar.activeTab = tabId;
}
OC.Apps.showAppSidebar(this._detailsView.$el);
},
/**
@ -623,48 +623,23 @@
* @param {string|OCA.Files.FileInfoModel} fileName file name from the current list or a FileInfoModel object
* @param {boolean} [show=true] whether to open the sidebar if it was closed
*/
_updateDetailsView: function(fileName, show) {
if (!this._detailsView) {
_updateDetailsView: function(fileName) {
if (!(OCA.Files && OCA.Files.Sidebar)) {
console.error('No sidebar available');
return;
}
// show defaults to true
show = _.isUndefined(show) || !!show;
var oldFileInfo = this._detailsView.getFileInfo();
if (oldFileInfo) {
// TODO: use more efficient way, maybe track the highlight
this.$fileList.children().filterAttr('data-id', '' + oldFileInfo.get('id')).removeClass('highlighted');
oldFileInfo.off('change', this._onSelectedModelChanged, this);
}
if (!fileName) {
this._detailsView.setFileInfo(null);
if (this._currentFileModel) {
this._currentFileModel.off();
}
this._currentFileModel = null;
OC.Apps.hideAppSidebar(this._detailsView.$el);
return;
OCA.Files.Sidebar.file = null
return
} else if (typeof fileName !== 'string') {
fileName = ''
}
if (show && this._detailsView.$el.hasClass('disappear')) {
OC.Apps.showAppSidebar(this._detailsView.$el);
}
if (fileName instanceof OCA.Files.FileInfoModel) {
var model = fileName;
} else {
var $tr = this.findFileEl(fileName);
var model = this.getModelForFile($tr);
$tr.addClass('highlighted');
}
this._currentFileModel = model;
this._replaceDetailsViewElementIfNeeded();
this._detailsView.setFileInfo(model);
this._detailsView.$el.scrollTop(0);
// open sidebar and set file
const dir = `${this.dirInfo.path}/${this.dirInfo.name}`
const path = `${dir}/${fileName}`
OCA.Files.Sidebar.file = path.replace('//', '/')
},
/**
@ -1404,6 +1379,13 @@
return OC.MimeType.getIconUrl('dir-external');
} else if (fileInfo.mountType !== undefined && fileInfo.mountType !== '') {
return OC.MimeType.getIconUrl('dir-' + fileInfo.mountType);
} else if (fileInfo.shareTypes && (
fileInfo.shareTypes.indexOf(OC.Share.SHARE_TYPE_LINK) > -1
|| fileInfo.shareTypes.indexOf(OC.Share.SHARE_TYPE_EMAIL) > -1)
) {
return OC.MimeType.getIconUrl('dir-public')
} else if (fileInfo.shareTypes && fileInfo.shareTypes.length > 0) {
return OC.MimeType.getIconUrl('dir-shared')
}
return OC.MimeType.getIconUrl('dir');
}
@ -3654,8 +3636,10 @@
* Register a tab view to be added to all views
*/
registerTabView: function(tabView) {
if (this._detailsView) {
this._detailsView.addTabView(tabView);
console.warn('registerTabView is deprecated! It will be removed in nextcloud 20.');
const name = tabView.getLabel()
if (name) {
OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab(name, tabView, true))
}
},
@ -3663,8 +3647,9 @@
* Register a detail view to be added to all views
*/
registerDetailView: function(detailView) {
if (this._detailsView) {
this._detailsView.addDetailView(detailView);
console.warn('registerDetailView is deprecated! It will be removed in nextcloud 20.');
if (detailView.el) {
OCA.Files.Sidebar.registerSecondaryView(detailView)
}
},

View File

@ -1,33 +1,34 @@
[
"dist/sidebar.js",
"app.js",
"templates.js",
"file-upload.js",
"newfilemenu.js",
"jquery.fileupload.js",
"jquery-visibility.js",
"fileinfomodel.js",
"filesummary.js",
"filemultiselectmenu.js",
"breadcrumb.js",
"filelist.js",
"search.js",
"favoritesfilelist.js",
"recentfilelist.js",
"tagsplugin.js",
"gotoplugin.js",
"favoritesplugin.js",
"recentplugin.js",
"detailfileinfoview.js",
"sidebarpreviewmanager.js",
"sidebarpreviewtext.js",
"detailtabview.js",
"semaphore.js",
"mainfileinfodetailview.js",
"operationprogressbar.js",
"detailsview.js",
"detailtabview.js",
"favoritesfilelist.js",
"favoritesplugin.js",
"file-upload.js",
"fileactions.js",
"fileactionsmenu.js",
"fileinfomodel.js",
"filelist.js",
"filemultiselectmenu.js",
"files.js",
"filesummary.js",
"gotoplugin.js",
"jquery-visibility.js",
"jquery.fileupload.js",
"keyboardshortcuts.js",
"navigation.js"
"mainfileinfodetailview.js",
"navigation.js",
"newfilemenu.js",
"operationprogressbar.js",
"recentfilelist.js",
"recentplugin.js",
"search.js",
"semaphore.js",
"sidebarpreviewmanager.js",
"sidebarpreviewtext.js",
"tagsplugin.js",
"templates.js"
]

View File

@ -0,0 +1,89 @@
<!--
- @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/>.
-
-->
<template>
<AppSidebarTab :icon="icon"
:name="name"
:active-tab="activeTab" />
</template>
<script>
import AppSidebarTab from 'nextcloud-vue/dist/Components/AppSidebarTab'
export default {
name: 'LegacyTab',
components: {
AppSidebarTab: AppSidebarTab
},
props: {
component: {
type: Object,
required: true
},
name: {
type: String,
default: '',
required: true
},
fileInfo: {
type: Object,
default: () => {},
required: true
}
},
computed: {
icon() {
return this.component.getIcon()
},
id() {
// copied from AppSidebarTab
return this.name.toLowerCase().replace(/ /g, '-')
},
order() {
return this.component.order
? this.component.order
: 0
},
// needed because AppSidebarTab also uses $parent.activeTab
activeTab() {
return this.$parent.activeTab
}
},
watch: {
activeTab(activeTab) {
if (activeTab === this.id && this.fileInfo) {
this.setFileInfo(this.fileInfo)
}
}
},
mounted() {
// append the backbone element and set the FileInfo
this.component.$el.appendTo(this.$el)
},
methods: {
setFileInfo(fileInfo) {
this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,59 @@
<!--
- @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/>.
-
-->
<template>
<div />
</template>
<script>
export default {
name: 'LegacyView',
props: {
component: {
type: Object,
required: true
},
fileInfo: {
type: Object,
default: () => {},
required: true
}
},
watch: {
fileInfo(fileInfo) {
// update the backbone model FileInfo
this.setFileInfo(fileInfo)
}
},
mounted() {
// append the backbone element and set the FileInfo
this.component.$el.replaceAll(this.$el)
this.setFileInfo(this.fileInfo)
},
methods: {
setFileInfo(fileInfo) {
this.component.setFileInfo(new OCA.Files.FileInfoModel(fileInfo))
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,59 @@
/**
* @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/>.
*
*/
export default class Tab {
#component;
#legacy;
#name;
/**
* Create a new tab instance
*
* @param {string} name the name of this tab
* @param {Object} component the vue component
* @param {boolean} [legacy] is this a legacy tab
*/
constructor(name, component, legacy) {
this.#name = name
this.#component = component
this.#legacy = legacy === true
if (this.#legacy) {
console.warn('Legacy tabs are deprecated! They will be removed in nextcloud 20.')
}
}
get name() {
return this.#name
}
get component() {
return this.#component
}
get isLegacyTab() {
return this.#legacy === true
}
}

View File

@ -0,0 +1,67 @@
/**
* @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 axios from '@nextcloud/axios'
export default async function(url) {
const response = await axios({
method: 'PROPFIND',
url,
data: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
xmlns:ocs="http://open-collaboration-services.org/ns">
<d:prop>
<d:getlastmodified />
<d:getetag />
<d:getcontenttype />
<d:resourcetype />
<oc:fileid />
<oc:permissions />
<oc:size />
<d:getcontentlength />
<nc:has-preview />
<nc:mount-type />
<nc:is-encrypted />
<ocs:share-permissions />
<oc:tags />
<oc:favorite />
<oc:comments-unread />
<oc:owner-id />
<oc:owner-display-name />
<oc:share-types />
</d:prop>
</d:propfind>`
})
// TODO: create new parser or use cdav-lib when available
const file = OCA.Files.App.fileList.filesClient._client.parseMultiStatus(response.data)
// TODO: create new parser or use cdav-lib when available
const fileInfo = OCA.Files.App.fileList.filesClient._parseFileInfo(file[0])
// TODO remove when no more legacy backbone is used
fileInfo.get = (key) => fileInfo[key]
fileInfo.isDirectory = () => fileInfo.mimetype === 'httpd/unix-directory'
return fileInfo
}

View File

@ -0,0 +1,109 @@
/**
* @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/>.
*
*/
export default class Sidebar {
#state;
#view;
constructor() {
// init empty state
this.#state = {}
// init default values
this.#state.tabs = []
this.#state.views = []
this.#state.file = ''
this.#state.activeTab = ''
console.debug('OCA.Files.Sidebar initialized')
}
/**
* Get the sidebar state
*
* @readonly
* @memberof Sidebar
* @returns {Object} the data state
*/
get state() {
return this.#state
}
/**
* @memberof Sidebar
* Register a new tab view
*
* @param {Object} tab a new unregistered tab
* @memberof Sidebar
* @returns {Boolean}
*/
registerTab(tab) {
const hasDuplicate = this.#state.tabs.findIndex(check => check.name === tab.name) > -1
if (!hasDuplicate) {
this.#state.tabs.push(tab)
return true
}
console.error(`An tab with the same name ${tab.name} already exists`, tab)
return false
}
registerSecondaryView(view) {
const hasDuplicate = this.#state.views.findIndex(check => check.cid === view.cid) > -1
if (!hasDuplicate) {
this.#state.views.push(view)
return true
}
console.error(`A similar view already exists`, view)
return false
}
/**
* Set the current sidebar file data
*
* @param {string} path the file path to load
* @memberof Sidebar
*/
set file(path) {
this.#state.file = path
}
/**
* Set the current sidebar file data
*
* @returns {String} the current opened file
* @memberof Sidebar
*/
get file() {
return this.#state.file
}
/**
* Set the current sidebar tab
*
* @param {string} id the tab unique id
* @memberof Sidebar
*/
set activeTab(id) {
this.#state.activeTab = id
}
}

59
apps/files/src/sidebar.js Normal file
View File

@ -0,0 +1,59 @@
/**
* @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 Vue from 'vue'
import SidebarView from './views/Sidebar.vue'
import Sidebar from './services/Sidebar'
import Tab from './models/Tab'
import VueClipboard from 'vue-clipboard2'
Vue.use(VueClipboard)
Vue.prototype.t = t
window.addEventListener('DOMContentLoaded', () => {
// Init Sidebar Service
if (window.OCA && window.OCA.Files) {
Object.assign(window.OCA.Files, { Sidebar: new Sidebar() })
Object.assign(window.OCA.Files.Sidebar, { Tab })
}
// Make sure we have a proper layout
if (document.getElementById('content')) {
// Make sure we have a mountpoint
if (!document.getElementById('app-sidebar')) {
var contentElement = document.getElementById('content')
var sidebarElement = document.createElement('div')
sidebarElement.id = 'app-sidebar'
contentElement.appendChild(sidebarElement)
}
}
// Init vue app
const AppSidebar = new Vue({
// eslint-disable-next-line vue/match-component-file-name
name: 'SidebarRoot',
render: h => h(SidebarView)
})
AppSidebar.$mount('#app-sidebar')
})

View File

@ -0,0 +1,345 @@
<!--
- @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/>.
-
-->
<template>
<AppSidebar
v-if="file"
ref="sidebar"
v-bind="appSidebar"
@close="onClose"
@update:starred="toggleStarred"
@[defaultActionListener].stop.prevent="onDefaultAction">
<!-- TODO: create a standard to allow multiple elements here? -->
<template v-if="fileInfo" #primary-actions>
<LegacyView v-for="view in views"
:key="view.cid"
:component="view"
:file-info="fileInfo" />
</template>
<!-- Error display -->
<div v-if="error" class="emptycontent">
<div class="icon-error" />
<h2>{{ error }}</h2>
</div>
<!-- If fileInfo fetch is complete, display tabs -->
<template v-for="tab in tabs" v-else-if="fileInfo">
<component
:is="tabComponent(tab).is"
v-if="canDisplay(tab)"
:key="tab.id"
:component="tabComponent(tab).component"
:name="tab.name"
:file-info="fileInfo" />
</template>
</AppSidebar>
</template>
<script>
import $ from 'jquery'
import axios from '@nextcloud/axios'
import AppSidebar from 'nextcloud-vue/dist/Components/AppSidebar'
import FileInfo from '../services/FileInfo'
import LegacyTab from '../components/LegacyTab'
import LegacyView from '../components/LegacyView'
export default {
name: 'Sidebar',
components: {
AppSidebar,
LegacyView
},
data() {
return {
// reactive state
Sidebar: OCA.Files.Sidebar.state,
error: null,
fileInfo: null,
starLoading: false
}
},
computed: {
/**
* Current filename
* This is bound to the Sidebar service and
* is used to load a new file
* @returns {string}
*/
file() {
return this.Sidebar.file
},
/**
* List of all the registered tabs
* @returns {Array}
*/
tabs() {
return this.Sidebar.tabs
},
/**
* List of all the registered views
* @returns {Array}
*/
views() {
return this.Sidebar.views
},
/**
* Current user dav root path
* @returns {string}
*/
davPath() {
const user = OC.getCurrentUser().uid
return OC.linkToRemote(`dav/files/${user}${encodeURIComponent(this.file)}`)
},
/**
* Current active tab handler
* @param {string} id the tab id to set as active
* @returns {string} the current active tab
*/
activeTab: {
get: function() {
return this.Sidebar.activeTab
},
set: function(id) {
OCA.Files.Sidebar.activeTab = id
}
},
/**
* Sidebar subtitle
* @returns {string}
*/
subtitle() {
return `${this.size}, ${this.time}`
},
/**
* File last modified formatted string
* @returns {string}
*/
time() {
return OC.Util.relativeModifiedDate(this.fileInfo.mtime)
},
/**
* File size formatted string
* @returns {string}
*/
size() {
return OC.Util.humanFileSize(this.fileInfo.size)
},
/**
* File background/figure to illustrate the sidebar header
* @returns {string}
*/
background() {
return this.getPreviewIfAny(this.fileInfo)
},
/**
* App sidebar v-binding object
*
* @returns {Object}
*/
appSidebar() {
if (this.fileInfo) {
return {
background: this.background,
active: this.activeTab,
class: { 'has-preview': this.fileInfo.hasPreview },
compact: !this.fileInfo.hasPreview,
'star-loading': this.starLoading,
starred: this.fileInfo.isFavourited,
subtitle: this.subtitle,
title: this.fileInfo.name
}
} else if (this.error) {
return {
key: 'error', // force key to re-render
subtitle: '',
title: ''
}
} else {
return {
class: 'icon-loading',
subtitle: '',
title: ''
}
}
},
/**
* Default action object for the current file
*
* @returns {Object}
*/
defaultAction() {
return this.fileInfo
&& OCA.Files && OCA.Files.App && OCA.Files.App.fileList
&& OCA.Files.App.fileList
.fileActions.getDefaultFileAction(this.fileInfo.mimetype, this.fileInfo.type, OC.PERMISSION_READ)
},
/**
* Dynamic header click listener to ensure
* nothing is listening for a click if there
* is no default action
*
* @returns {string|null}
*/
defaultActionListener() {
return this.defaultAction ? 'figure-click' : null
}
},
watch: {
// update the sidebar data
async file(curr, prev) {
this.resetData()
if (curr && curr.trim() !== '') {
try {
this.fileInfo = await FileInfo(this.davPath)
// adding this as fallback because other apps expect it
this.fileInfo.dir = this.file.split('/').slice(0, -1).join('/')
// DEPRECATED legacy views
// TODO: remove
this.views.forEach(view => {
view.setFileInfo(this.fileInfo)
})
this.$nextTick(() => {
if (this.$refs.sidebar) {
this.$refs.sidebar.updateTabs()
}
})
} catch (error) {
this.error = t('files', 'Error while loading the file data')
console.error('Error while loading the file data')
}
}
}
},
methods: {
/**
* Can this tab be displayed ?
*
* @param {Object} tab a registered tab
* @returns {boolean}
*/
canDisplay(tab) {
if (tab.isLegacyTab) {
return this.fileInfo && tab.component.canDisplay && tab.component.canDisplay(this.fileInfo)
}
// if the tab does not have an enabled method, we assume it's always available
return tab.enabled ? tab.enabled(this.fileInfo) : true
},
onClose() {
this.resetData()
OCA.Files.Sidebar.file = ''
},
resetData() {
this.error = null
this.fileInfo = null
this.$nextTick(() => {
if (this.$refs.sidebar) {
this.$refs.sidebar.updateTabs()
}
})
},
getPreviewIfAny(fileInfo) {
if (fileInfo.hasPreview) {
return OC.generateUrl(`/core/preview?fileId=${fileInfo.id}&x=${screen.width}&y=${screen.height}&a=true`)
}
return OCA.Files.App.fileList._getIconUrl(fileInfo)
},
tabComponent(tab) {
if (tab.isLegacyTab) {
return {
is: LegacyTab,
component: tab.component
}
}
return {
is: tab.component
}
},
/**
* Toggle favourite state
* TODO: better implementation
*
* @param {Boolean} state favourited or not
*/
async toggleStarred(state) {
try {
this.starLoading = true
await axios({
method: 'PROPPATCH',
url: this.davPath,
data: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
${state ? '<d:set>' : '<d:remove>'}
<d:prop>
<oc:favorite>1</oc:favorite>
</d:prop>
${state ? '</d:set>' : '</d:remove>'}
</d:propertyupdate>`
})
} catch (error) {
OC.Notification.showTemporary(t('files', 'Unable to change the favourite state of the file'))
console.error('Unable to change favourite state', error)
}
this.starLoading = false
},
onDefaultAction() {
if (this.defaultAction) {
// generate fake context
this.defaultAction.action(this.fileInfo.name, {
fileInfo: this.fileInfo,
dir: this.fileInfo.dir,
fileList: OCA.Files.App.fileList,
$file: $('body')
})
}
}
}
}
</script>
<style lang="scss" scoped>
#app-sidebar {
&.has-preview::v-deep .app-sidebar-header__figure {
background-size: cover;
}
}
</style>

13
apps/files/webpack.js Normal file
View File

@ -0,0 +1,13 @@
const path = require('path');
module.exports = {
entry: {
'sidebar': path.join(__dirname, 'src', 'sidebar.js'),
},
output: {
path: path.resolve(__dirname, './js/dist/'),
publicPath: '/js/',
filename: '[name].js',
chunkFilename: 'files.[id].js'
}
}

View File

@ -43,6 +43,7 @@ $eventDispatcher->addListener(
'OCA\Files::loadAdditionalScripts',
function() {
\OCP\Util::addScript('files_sharing', 'dist/additionalScripts');
\OCP\Util::addStyle('files_sharing', 'icons');
}
);
\OC::$server->getEventDispatcher()->addListener('\OCP\Collaboration\Resources::loadAdditionalScripts', function () {

View File

@ -0,0 +1,32 @@
/**
* @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/>.
*
*/
// This is the icons used in the sharing ui (multiselect)
.icon-room {
@include icon-color('app', 'spreed', $color-black);
}
.icon-circle {
@include icon-color('circles', 'circles', $color-black, 3, false);
}
.icon-guests {
@include icon-color('app', 'guests', $color-black);
}

View File

@ -33,6 +33,7 @@ $tmpl = new OCP\Template('files_sharing', 'list', '');
$tmpl->assign('showgridview', $showgridview && !$isIE);
OCP\Util::addScript('files_sharing', 'dist/files_sharing');
OCP\Util::addScript('files_sharing', 'dist/files_sharing_tab');
\OC::$server->getEventDispatcher()->dispatch('\OCP\Collaboration\Resources::loadAdditionalScripts');
$tmpl->printPage();

View File

@ -0,0 +1,249 @@
<!--
- @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/>.
-
-->
<template>
<li class="sharing-entry">
<Avatar class="sharing-entry__avatar"
:user="share.shareWith"
:display-name="share.shareWithDisplayName"
:url="share.shareWithAvatar" />
<div v-tooltip.auto="tooltip" class="sharing-entry__desc">
<h5>{{ title }}</h5>
</div>
<Actions menu-align="right" class="sharing-entry__actions">
<!-- edit permission -->
<ActionCheckbox
ref="canEdit"
:checked.sync="canEdit"
:value="permissionsEdit"
:disabled="saving">
{{ t('files_sharing', 'Allow editing') }}
</ActionCheckbox>
<!-- reshare permission -->
<ActionCheckbox
ref="canReshare"
:checked.sync="canReshare"
:value="permissionsShare"
:disabled="saving">
{{ t('files_sharing', 'Can reshare') }}
</ActionCheckbox>
<!-- expiration date -->
<ActionCheckbox :checked.sync="hasExpirationDate"
:disabled="config.isDefaultExpireDateEnforced || saving"
@uncheck="onExpirationDisable">
{{ config.isDefaultExpireDateEnforced
? t('files_sharing', 'Expiration date enforced')
: t('files_sharing', 'Set expiration date') }}
</ActionCheckbox>
<ActionInput v-if="hasExpirationDate"
ref="expireDate"
v-tooltip.auto="{
content: errors.expireDate,
show: errors.expireDate,
trigger: 'manual'
}"
:class="{ error: errors.expireDate}"
:disabled="saving"
:first-day-of-week="firstDay"
:lang="lang"
:value="share.expireDate"
icon="icon-calendar-dark"
type="date"
:not-before="dateTomorrow"
:not-after="dateMaxEnforced"
@update:value="onExpirationChange">
{{ t('files_sharing', 'Enter a date') }}
</ActionInput>
<!-- note -->
<template v-if="canHaveNote">
<ActionCheckbox
:checked.sync="hasNote"
:disabled="saving"
@uncheck="queueUpdate('note')">
{{ t('files_sharing', 'Note to recipient') }}
</ActionCheckbox>
<ActionTextEditable v-if="hasNote"
ref="note"
v-tooltip.auto="{
content: errors.note,
show: errors.note,
trigger: 'manual'
}"
:class="{ error: errors.note}"
:disabled="saving"
:value.sync="share.note"
icon="icon-edit"
@update:value="debounceQueueUpdate('note')" />
</template>
<ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete">
{{ t('files_sharing', 'Unshare') }}
</ActionButton>
</Actions>
</li>
</template>
<script>
import Avatar from 'nextcloud-vue/dist/Components/Avatar'
import Actions from 'nextcloud-vue/dist/Components/Actions'
import ActionButton from 'nextcloud-vue/dist/Components/ActionButton'
import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox'
import ActionInput from 'nextcloud-vue/dist/Components/ActionInput'
import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable'
import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
// eslint-disable-next-line no-unused-vars
import Share from '../models/Share'
import SharesMixin from '../mixins/SharesMixin'
export default {
name: 'SharingEntry',
components: {
Actions,
ActionButton,
ActionCheckbox,
ActionInput,
ActionTextEditable,
Avatar
},
directives: {
Tooltip
},
mixins: [SharesMixin],
data() {
return {
permissionsEdit: OC.PERMISSION_UPDATE,
permissionsRead: OC.PERMISSION_READ,
permissionsShare: OC.PERMISSION_SHARE
}
},
computed: {
title() {
let title = this.share.shareWithDisplayName
if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
title += ` (${t('files_sharing', 'group')})`
} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
title += ` (${t('files_sharing', 'conversation')})`
} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) {
title += ` (${t('files_sharing', 'remote')})`
} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) {
title += ` (${t('files_sharing', 'remote group')})`
} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) {
title += ` (${t('files_sharing', 'guest')})`
}
return title
},
tooltip() {
if (this.share.owner !== this.share.uidFileOwner) {
const data = {
// todo: strong or italic?
// but the t function escape any html from the data :/
user: this.share.shareWithDisplayName,
owner: this.share.owner
}
if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
return t('files_sharing', 'Shared with the group {user} by {owner}', data)
} else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
return t('files_sharing', 'Shared with the conversation {user} by {owner}', data)
}
return t('files_sharing', 'Shared with {user} by {owner}', data)
}
return null
},
canHaveNote() {
return this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE
&& this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
},
/**
* Can the sharee edit the shared file ?
*/
canEdit: {
get: function() {
return this.share.hasUpdatePermission
},
set: function(checked) {
this.updatePermissions(checked, this.canReshare)
}
},
/**
* Can the sharee reshare the file ?
*/
canReshare: {
get: function() {
return this.share.hasSharePermission
},
set: function(checked) {
this.updatePermissions(this.canEdit, checked)
}
}
},
methods: {
updatePermissions(isEditChecked, isReshareChecked) {
// calc permissions if checked
const permissions = this.permissionsRead
| (isEditChecked ? this.permissionsEdit : 0)
| (isReshareChecked ? this.permissionsShare : 0)
this.share.permissions = permissions
this.queueUpdate('permissions')
}
}
}
</script>
<style lang="scss" scoped>
.sharing-entry {
display: flex;
align-items: center;
height: 44px;
&__desc {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px;
line-height: 1.2em;
p {
color: var(--color-text-maxcontrast);
}
}
&__actions {
margin-left: auto;
}
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<SharingEntrySimple
class="sharing-entry__internal"
:title="t('files_sharing', 'Internal link')"
:subtitle="internalLinkSubtitle">
<template #avatar>
<div class="avatar-external icon-external-white" />
</template>
<ActionLink ref="copyButton"
:href="internalLink"
target="_blank"
:icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'"
@click.prevent="copyLink">
{{ clipboardTooltip }}
</ActionLink>
</SharingEntrySimple>
</template>
<script>
import { generateUrl } from '@nextcloud/router'
import ActionLink from 'nextcloud-vue/dist/Components/ActionLink'
import SharingEntrySimple from './SharingEntrySimple'
export default {
name: 'SharingEntryInternal',
components: {
ActionLink,
SharingEntrySimple
},
props: {
fileInfo: {
type: Object,
default: () => {},
required: true
}
},
data() {
return {
copied: false,
copySuccess: false
}
},
computed: {
/**
* Get the internal link to this file id
* @returns {string}
*/
internalLink() {
return window.location.protocol + '//' + window.location.host + generateUrl('/f/') + this.fileInfo.id
},
/**
* Clipboard v-tooltip message
* @returns {string}
*/
clipboardTooltip() {
if (this.copied) {
return this.copySuccess
? t('files_sharing', 'Link copied')
: t('files_sharing', 'Cannot copy, please copy the link manually')
}
return t('files_sharing', 'Copy to clipboard')
},
internalLinkSubtitle() {
if (this.fileInfo.type === 'dir') {
return t('files_sharing', 'Only works for users with access to this folder')
}
return t('files_sharing', 'Only works for users with access to this file')
}
},
methods: {
async copyLink() {
try {
await this.$copyText(this.internalLink)
// focus and show the tooltip
this.$refs.copyButton.$el.focus()
this.copySuccess = true
this.copied = true
} catch (error) {
this.copySuccess = false
this.copied = true
console.error(error)
} finally {
setTimeout(() => {
this.copySuccess = false
this.copied = false
}, 4000)
}
}
}
}
</script>
<style lang="scss" scoped>
.sharing-entry__internal {
.avatar-external {
width: 32px;
height: 32px;
line-height: 32px;
font-size: 18px;
background-color: var(--color-text-maxcontrast);
border-radius: 50%;
flex-shrink: 0;
}
.icon-checkmark-color {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,769 @@
<!--
- @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/>.
-
-->
<template>
<li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link">
<Avatar :is-no-user="true"
:class="isEmailShareType ? 'icon-mail-white' : 'icon-public-white'"
class="sharing-entry__avatar" />
<div class="sharing-entry__desc">
<h5>{{ title }}</h5>
</div>
<!-- clipboard -->
<Actions v-if="share && !isEmailShareType && share.token"
ref="copyButton"
class="sharing-entry__copy">
<ActionLink :href="shareLink"
target="_blank"
:icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'"
@click.stop.prevent="copyLink">
{{ clipboardTooltip }}
</ActionLink>
</Actions>
<!-- pending actions -->
<Actions v-if="!loading && (pendingPassword || pendingExpirationDate)"
class="sharing-entry__actions"
menu-align="right"
:open.sync="open"
@close="onNewLinkShare">
<!-- pending data menu -->
<ActionText v-if="errors.pending"
icon="icon-error"
:class="{ error: errors.pending}">
{{ errors.pending }}
</ActionText>
<ActionText v-else icon="icon-info">
{{ t('files_sharing', 'Please enter the following required information before creating the share') }}
</ActionText>
<!-- password -->
<ActionText v-if="pendingPassword" icon="icon-password">
{{ t('files_sharing', 'Password protection (enforced)') }}
</ActionText>
<ActionCheckbox v-else-if="config.enableLinkPasswordByDefault"
:checked.sync="isPasswordProtected"
:disabled="config.enforcePasswordForPublicLink || saving"
class="share-link-password-checkbox"
@uncheck="onPasswordDisable">
{{ t('files_sharing', 'Password protection') }}
</ActionCheckbox>
<ActionInput v-if="pendingPassword || share.password"
v-tooltip.auto="{
content: errors.password,
show: errors.password,
trigger: 'manual',
defaultContainer: '#app-sidebar'
}"
class="share-link-password"
:value.sync="share.password"
:disabled="saving"
:required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink"
:minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength"
icon=""
autocomplete="new-password"
@submit="onNewLinkShare">
{{ t('files_sharing', 'Enter a password') }}
</ActionInput>
<!-- expiration date -->
<ActionText v-if="pendingExpirationDate" icon="icon-calendar-dark">
{{ t('files_sharing', 'Expiration date (enforced)') }}
</ActionText>
<ActionInput v-if="pendingExpirationDate"
v-model="share.expireDate"
v-tooltip.auto="{
content: errors.expireDate,
show: errors.expireDate,
trigger: 'manual',
defaultContainer: '#app-sidebar'
}"
class="share-link-expire-date"
:disabled="saving"
:first-day-of-week="firstDay"
:lang="lang"
icon=""
type="date"
:not-before="dateTomorrow"
:not-after="dateMaxEnforced">
<!-- let's not submit when picked, the user
might want to still edit or copy the password -->
{{ t('files_sharing', 'Enter a date') }}
</ActionInput>
<ActionButton icon="icon-close" @click.prevent.stop="onCancel">
{{ t('files_sharing', 'Cancel') }}
</ActionButton>
</Actions>
<!-- actions -->
<Actions v-else-if="!loading"
class="sharing-entry__actions"
menu-align="right"
:open.sync="open"
@close="onPasswordSubmit">
<template v-if="share">
<template v-if="isShareOwner">
<!-- folder -->
<template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled">
<ActionRadio :checked="share.permissions === publicUploadRValue"
:value="publicUploadRValue"
:name="randomId"
:disabled="saving"
@change="togglePermissions">
{{ t('files_sharing', 'Read only') }}
</ActionRadio>
<ActionRadio :checked="share.permissions === publicUploadRWValue"
:value="publicUploadRWValue"
:disabled="saving"
:name="randomId"
@change="togglePermissions">
{{ t('files_sharing', 'Allow upload and editing') }}
</ActionRadio>
<ActionRadio :checked="share.permissions === publicUploadWValue"
:value="publicUploadWValue"
:disabled="saving"
:name="randomId"
class="sharing-entry__action--public-upload"
@change="togglePermissions">
{{ t('files_sharing', 'File drop (upload only)') }}
</ActionRadio>
</template>
<!-- file -->
<ActionCheckbox v-else
:checked.sync="canUpdate"
:disabled="saving"
@change="queueUpdate('permissions')">
{{ t('files_sharing', 'Allow editing') }}
</ActionCheckbox>
<ActionCheckbox
:checked.sync="share.hideDownload"
:disabled="saving"
@change="queueUpdate('hideDownload')">
{{ t('files_sharing', 'Hide download') }}
</ActionCheckbox>
<!-- password -->
<ActionCheckbox :checked.sync="isPasswordProtected"
:disabled="config.enforcePasswordForPublicLink || saving"
class="share-link-password-checkbox"
@uncheck="onPasswordDisable">
{{ config.enforcePasswordForPublicLink
? t('files_sharing', 'Password protection (enforced)')
: t('files_sharing', 'Password protect') }}
</ActionCheckbox>
<ActionInput v-if="isPasswordProtected"
ref="password"
v-tooltip.auto="{
content: errors.password,
show: errors.password,
trigger: 'manual',
defaultContainer: '#app-sidebar'
}"
class="share-link-password"
:class="{ error: errors.password}"
:disabled="saving"
:required="config.enforcePasswordForPublicLink"
:value="hasUnsavedPassword ? share.newPassword : '***************'"
icon="icon-password"
autocomplete="new-password"
:type="hasUnsavedPassword ? 'text': 'password'"
@update:value="onPasswordChange"
@submit="onPasswordSubmit">
{{ t('files_sharing', 'Enter a password') }}
</ActionInput>
<!-- expiration date -->
<ActionCheckbox :checked.sync="hasExpirationDate"
:disabled="config.isDefaultExpireDateEnforced || saving"
class="share-link-expire-date-checkbox"
@uncheck="onExpirationDisable">
{{ config.isDefaultExpireDateEnforced
? t('files_sharing', 'Expiration date (enforced)')
: t('files_sharing', 'Set expiration date') }}
</ActionCheckbox>
<ActionInput v-if="hasExpirationDate"
ref="expireDate"
v-tooltip.auto="{
content: errors.expireDate,
show: errors.expireDate,
trigger: 'manual',
defaultContainer: '#app-sidebar'
}"
class="share-link-expire-date"
:class="{ error: errors.expireDate}"
:disabled="saving"
:first-day-of-week="firstDay"
:lang="lang"
:value="share.expireDate"
icon="icon-calendar-dark"
type="date"
:not-before="dateTomorrow"
:not-after="dateMaxEnforced"
@update:value="onExpirationChange">
{{ t('files_sharing', 'Enter a date') }}
</ActionInput>
<!-- note -->
<ActionCheckbox :checked.sync="hasNote"
:disabled="saving"
@uncheck="queueUpdate('note')">
{{ t('files_sharing', 'Note to recipient') }}
</ActionCheckbox>
<ActionTextEditable v-if="hasNote"
ref="note"
v-tooltip.auto="{
content: errors.note,
show: errors.note,
trigger: 'manual',
defaultContainer: '#app-sidebar'
}"
:class="{ error: errors.note}"
:disabled="saving"
:value.sync="share.note"
icon="icon-edit"
@update:value="debounceQueueUpdate('note')" />
</template>
<components :is="action" v-for="(action, index) in externalActions" :key="index" />
<ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete">
{{ t('files_sharing', 'Delete share') }}
</ActionButton>
<ActionButton v-if="!isEmailShareType && canReshare"
class="new-share-link"
icon="icon-add"
@click.prevent.stop="onNewLinkShare">
{{ t('files_sharing', 'Add another link') }}
</ActionButton>
</template>
<!-- Create new share -->
<ActionButton v-else-if="canReshare"
class="new-share-link"
icon="icon-add"
@click.prevent.stop="onNewLinkShare">
{{ t('files_sharing', 'Create a new share link') }}
</ActionButton>
</Actions>
<!-- loading indicator to replace the menu -->
<div v-else class="icon-loading-small sharing-entry__loading" />
</li>
</template>
<script>
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import ActionButton from 'nextcloud-vue/dist/Components/ActionButton'
import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox'
import ActionRadio from 'nextcloud-vue/dist/Components/ActionRadio'
import ActionInput from 'nextcloud-vue/dist/Components/ActionInput'
import ActionText from 'nextcloud-vue/dist/Components/ActionText'
import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable'
import ActionLink from 'nextcloud-vue/dist/Components/ActionLink'
import Actions from 'nextcloud-vue/dist/Components/Actions'
import Avatar from 'nextcloud-vue/dist/Components/Avatar'
import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
import Share from '../models/Share'
import SharesMixin from '../mixins/SharesMixin'
const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
export default {
name: 'SharingEntryLink',
components: {
Actions,
ActionButton,
ActionCheckbox,
ActionRadio,
ActionInput,
ActionLink,
ActionText,
ActionTextEditable,
Avatar
},
directives: {
Tooltip
},
mixins: [SharesMixin],
props: {
canReshare: {
type: Boolean,
default: true
}
},
data() {
return {
copySuccess: true,
copied: false,
publicUploadRWValue: OC.PERMISSION_UPDATE | OC.PERMISSION_CREATE | OC.PERMISSION_READ | OC.PERMISSION_DELETE,
publicUploadRValue: OC.PERMISSION_READ,
publicUploadWValue: OC.PERMISSION_CREATE,
ExternalLinkActions: OCA.Sharing.ExternalLinkActions.state
}
},
computed: {
/**
* Generate a unique random id for this SharingEntryLink only
* This allows ActionRadios to have the same name prop
* but not to impact others SharingEntryLink
* @returns {string}
*/
randomId() {
return Math.random().toString(27).substr(2)
},
/**
* Link share label
* TODO: allow editing
* @returns {string}
*/
title() {
// if we have a valid existing share (not pending)
if (this.share && this.share.id) {
if (!this.isShareOwner && this.share.ownerDisplayName) {
return t('files_sharing', 'Shared via link by {initiator}', {
initiator: this.share.ownerDisplayName
})
}
if (this.share.label && this.share.label.trim() !== '') {
return this.share.label
}
if (this.isEmailShareType) {
return this.share.shareWith
}
}
return t('files_sharing', 'Share link')
},
/**
* Is the current share password protected ?
* @returns {boolean}
*/
isPasswordProtected: {
get: function() {
return this.config.enforcePasswordForPublicLink
|| !!this.share.password
},
set: async function(enabled) {
// TODO: directly save after generation to make sure the share is always protected
this.share.password = enabled ? await this.generatePassword() : ''
this.share.newPassword = this.share.password
}
},
/**
* Is the current share an email share ?
* @returns {boolean}
*/
isEmailShareType() {
return this.share
? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
: false
},
/**
* Pending data.
* If the share still doesn't have an id, it is not synced
* Therefore this is still not valid and requires user input
* @returns {boolean}
*/
pendingPassword() {
return this.config.enforcePasswordForPublicLink && this.share && !this.share.id
},
pendingExpirationDate() {
return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id
},
/**
* Can the recipient edit the file ?
* @returns {boolean}
*/
canUpdate: {
get: function() {
return this.share.hasUpdatePermission
},
set: function(enabled) {
this.share.permissions = enabled
? OC.PERMISSION_READ | OC.PERMISSION_UPDATE
: OC.PERMISSION_READ
}
},
// if newPassword exists, but is empty, it means
// the user deleted the original password
hasUnsavedPassword() {
return this.share.newPassword !== undefined
},
/**
* Is the current share a folder ?
* TODO: move to a proper FileInfo model?
* @returns {boolean}
*/
isFolder() {
return this.fileInfo.type === 'dir'
},
/**
* Does the current file/folder have create permissions
* TODO: move to a proper FileInfo model?
* @returns {boolean}
*/
fileHasCreatePermission() {
return !!(this.fileInfo.permissions & OC.PERMISSION_CREATE)
},
/**
* Return the public share link
* @returns {string}
*/
shareLink() {
return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token
},
/**
* Clipboard v-tooltip message
* @returns {string}
*/
clipboardTooltip() {
if (this.copied) {
return this.copySuccess
? t('files_sharing', 'Link copied')
: t('files_sharing', 'Cannot copy, please copy the link manually')
}
return t('files_sharing', 'Copy to clipboard')
},
/**
* External aditionnal actions for the menu
* @returns {Array}
*/
externalActions() {
return this.ExternalLinkActions.actions
},
isPasswordPolicyEnabled() {
return typeof this.config.passwordPolicy === 'object'
}
},
methods: {
/**
* Create a new share link and append it to the list
*/
async onNewLinkShare() {
const shareDefaults = {
share_type: OC.Share.SHARE_TYPE_LINK
}
if (this.config.isDefaultExpireDateEnforced) {
// default is empty string if not set
// expiration is the share object key, not expireDate
shareDefaults.expiration = this.config.defaultExpirationDateString
}
if (this.config.enableLinkPasswordByDefault) {
shareDefaults.password = await this.generatePassword()
}
// do not push yet if we need a password or an expiration date
if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) {
this.loading = true
// if a share already exists, pushing it
if (this.share && !this.share.id) {
if (this.checkShare(this.share)) {
await this.pushNewLinkShare(this.share, true)
return true
} else {
this.open = true
OC.Notification.showTemporary(t('files_sharing', 'Error, please enter proper password and/or expiration date'))
return false
}
}
// ELSE, show the pending popovermenu
// if password enforced, pre-fill with random one
if (this.config.enforcePasswordForPublicLink) {
shareDefaults.password = await this.generatePassword()
}
// create share & close menu
const share = new Share(shareDefaults)
const component = await new Promise(resolve => {
this.$emit('add:share', share, resolve)
})
// open the menu on the
// freshly created share component
this.open = false
this.loading = false
component.open = true
// Nothing enforced, creating share directly
} else {
const share = new Share(shareDefaults)
await this.pushNewLinkShare(share)
}
},
/**
* Push a new link share to the server
* And update or append to the list
* accordingly
*
* @param {Share} share the new share
* @param {boolean} [update=false] do we update the current share ?
*/
async pushNewLinkShare(share, update) {
try {
this.loading = true
this.errors = {}
const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
const newShare = await this.createShare({
path,
shareType: OC.Share.SHARE_TYPE_LINK,
password: share.password,
expireDate: share.expireDate
// we do not allow setting the publicUpload
// before the share creation.
// Todo: We also need to fix the createShare method in
// lib/Controller/ShareAPIController.php to allow file drop
// (currently not supported on create, only update)
})
this.open = false
console.debug('Link share created', newShare)
// if share already exists, copy link directly on next tick
let component
if (update) {
component = await new Promise(resolve => {
this.$emit('update:share', newShare, resolve)
})
} else {
// adding new share to the array and copying link to clipboard
// using promise so that we can copy link in the same click function
// and avoid firefox copy permissions issue
component = await new Promise(resolve => {
this.$emit('add:share', newShare, resolve)
})
}
// Execute the copy link method
// freshly created share component
// ! somehow does not works on firefox !
component.copyLink()
} catch ({ response }) {
const message = response.data.ocs.meta.message
if (message.match(/password/i)) {
this.onSyncError('password', message)
} else if (message.match(/date/i)) {
this.onSyncError('expireDate', message)
} else {
this.onSyncError('pending', message)
}
} finally {
this.loading = false
}
},
/**
* On permissions change
* @param {Event} event js event
*/
togglePermissions(event) {
const permissions = parseInt(event.target.value, 10)
this.share.permissions = permissions
this.queueUpdate('permissions')
},
/**
* Generate a valid policy password or
* request a valid password if password_policy
* is enabled
*
* @returns {string} a valid password
*/
async generatePassword() {
// password policy is enabled, let's request a pass
if (this.config.passwordPolicy.api && this.config.passwordPolicy.api.generate) {
try {
const request = await axios.get(this.config.passwordPolicy.api.generate)
if (request.data.ocs.data.password) {
return request.data.ocs.data.password
}
} catch (error) {
console.info('Error generating password from password_policy', error)
}
}
// generate password of 10 length based on passwordSet
return Array(10).fill(0)
.reduce((prev, curr) => {
prev += passwordSet.charAt(Math.floor(Math.random() * passwordSet.length))
return prev
}, '')
},
async copyLink() {
try {
await this.$copyText(this.shareLink)
// focus and show the tooltip
this.$refs.copyButton.$el.focus()
this.copySuccess = true
this.copied = true
} catch (error) {
this.copySuccess = false
this.copied = true
console.error(error)
} finally {
setTimeout(() => {
this.copySuccess = false
this.copied = false
}, 4000)
}
},
/**
* Update newPassword values
* of share. If password is set but not newPassword
* then the user did not changed the password
* If both co-exists, the password have changed and
* we show it in plain text.
* Then on submit (or menu close), we sync it.
* @param {string} password the changed password
*/
onPasswordChange(password) {
this.$set(this.share, 'newPassword', password)
},
/**
* Uncheck password protection
* We need this method because @update:checked
* is ran simultaneously as @uncheck, so
* so we cannot ensure data is up-to-date
*/
onPasswordDisable() {
this.share.password = ''
// reset password state after sync
this.$delete(this.share, 'newPassword')
// only update if valid share.
if (this.share.id) {
this.queueUpdate('password')
}
},
/**
* Menu have been closed or password has been submited.
* The only property that does not get
* synced automatically is the password
* So let's check if we have an unsaved
* password.
* expireDate is saved on datepicker pick
* or close.
*/
onPasswordSubmit() {
if (this.hasUnsavedPassword) {
this.share.password = this.share.newPassword
this.queueUpdate('password')
}
},
/**
* Cancel the share creation
* Used in the pending popover
*/
onCancel() {
// this.share already exists at this point,
// but is incomplete as not pushed to server
// YET. We can safely delete the share :)
this.$emit('remove:share', this.share)
}
}
}
</script>
<style lang="scss" scoped>
.sharing-entry {
display: flex;
align-items: center;
height: 44px;
&__desc {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px;
line-height: 1.2em;
}
&:not(.sharing-entry--share) &__actions {
.new-share-link {
border-top: 1px solid var(--color-border);
}
}
.sharing-entry__action--public-upload {
border-bottom: 1px solid var(--color-border);
}
&__loading {
width: 44px;
height: 44px;
margin: 0;
padding: 14px;
margin-left: auto;
}
// put menus to the left
// but only the first one
.action-item {
margin-left: auto;
~ .action-item,
~ .sharing-entry__loading {
margin-left: 0;
}
}
.icon-checkmark-color {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,97 @@
<!--
- @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/>.
-
-->
<template>
<li class="sharing-entry">
<slot name="avatar" />
<div v-tooltip="tooltip" class="sharing-entry__desc">
<h5>{{ title }}</h5>
<p v-if="subtitle">
{{ subtitle }}
</p>
</div>
<Actions v-if="$slots['default']" menu-align="right" class="sharing-entry__actions">
<slot />
</Actions>
</li>
</template>
<script>
import Actions from 'nextcloud-vue/dist/Components/Actions'
import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip'
export default {
name: 'SharingEntrySimple',
components: {
Actions
},
directives: {
Tooltip
},
props: {
title: {
type: String,
default: '',
required: true
},
tooltip: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
}
}
}
</script>
<style lang="scss" scoped>
.sharing-entry {
display: flex;
align-items: center;
height: 44px;
&__desc {
padding: 8px;
line-height: 1.2em;
position: relative;
flex: 1 1;
min-width: 0;
h5 {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: inherit;
}
p {
color: var(--color-text-maxcontrast);
}
}
&__actions {
margin-left: auto !important;
}
}
</style>

View File

@ -0,0 +1,444 @@
<!--
- @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/>.
-
-->
<template>
<Multiselect ref="multiselect"
class="sharing-input"
:disabled="!canReshare"
:hide-selected="true"
:internal-search="false"
:loading="loading"
:options="options"
:placeholder="inputPlaceholder"
:preselect-first="true"
:preserve-search="true"
:searchable="true"
:user-select="true"
@search-change="asyncFind"
@select="addShare">
<template #noOptions>
{{ t('files_sharing', 'No recommendations. Start typing.') }}
</template>
<template #noResult>
{{ noResultText }}
</template>
</Multiselect>
</template>
<script>
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
import Multiselect from 'nextcloud-vue/dist/Components/Multiselect'
import Config from '../services/ConfigService'
import Share from '../models/Share'
import ShareRequests from '../mixins/ShareRequests'
import ShareTypes from '../mixins/ShareTypes'
export default {
name: 'SharingInput',
components: {
Multiselect
},
mixins: [ShareTypes, ShareRequests],
props: {
shares: {
type: Array,
default: () => [],
required: true
},
linkShares: {
type: Array,
default: () => [],
required: true
},
fileInfo: {
type: Object,
default: () => {},
required: true
},
reshare: {
type: Share,
default: null
},
canReshare: {
type: Boolean,
required: true
}
},
data() {
return {
config: new Config(),
loading: false,
query: '',
recommendations: [],
ShareSearch: OCA.Sharing.ShareSearch.state,
suggestions: []
}
},
computed: {
/**
* Implement ShareSearch
* allows external appas to inject new
* results into the autocomplete dropdown
* Used for the guests app
*
* @returns {Array}
*/
externalResults() {
return this.ShareSearch.results
},
inputPlaceholder() {
const allowRemoteSharing = this.config.isRemoteShareAllowed
const allowMailSharing = this.config.isMailShareAllowed
if (!this.canReshare) {
return t('files_sharing', 'Resharing is not allowed')
}
if (!allowRemoteSharing && allowMailSharing) {
return t('files_sharing', 'Name or email address...')
}
if (allowRemoteSharing && !allowMailSharing) {
return t('files_sharing', 'Name or federated cloud ID...')
}
if (allowRemoteSharing && allowMailSharing) {
return t('files_sharing', 'Name, federated cloud ID or email address...')
}
return t('files_sharing', 'Name...')
},
isValidQuery() {
return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength
},
options() {
if (this.isValidQuery) {
return this.suggestions
}
return this.recommendations
},
noResultText() {
if (this.loading) {
return t('files_sharing', 'Searching...')
}
return t('files_sharing', 'No elements found.')
}
},
mounted() {
this.getRecommendations()
},
methods: {
async asyncFind(query, id) {
// save current query to check if we display
// recommendations or search results
this.query = query.trim()
if (this.isValidQuery) {
// start loading now to have proper ux feedback
// during the debounce
this.loading = true
await this.debounceGetSuggestions(query)
}
},
/**
* Get suggestions
*
* @param {string} search the search query
* @param {boolean} [lookup=false] search on lookup server
*/
async getSuggestions(search, lookup) {
this.loading = true
lookup = lookup || false
console.info(search, lookup)
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees', {
params: {
format: 'json',
itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file',
search,
lookup,
perPage: this.config.maxAutocompleteResults
}
})
if (request.data.ocs.meta.statuscode !== 100) {
console.error('Error fetching suggestions', request)
return
}
const data = request.data.ocs.data
const exact = request.data.ocs.data.exact
data.exact = [] // removing exact from general results
// flatten array of arrays
const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
.map(share => this.formatForMultiselect(share))
const suggestions = this.filterOutExistingShares(rawSuggestions)
.map(share => this.formatForMultiselect(share))
// lookup clickable entry
const lookupEntry = []
if (data.lookupEnabled) {
lookupEntry.push({
isNoUser: true,
displayName: t('files_sharing', 'Search globally'),
lookup: true
})
}
// if there is a condition specified, filter it
const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this))
this.suggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry)
this.loading = false
console.info('suggestions', this.suggestions)
},
/**
* Debounce getSuggestions
*
* @param {...*} args the arguments
*/
debounceGetSuggestions: debounce(function(...args) {
this.getSuggestions(...args)
}, 300),
/**
* Get the sharing recommendations
*/
async getRecommendations() {
this.loading = true
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees_recommended', {
params: {
format: 'json',
itemType: this.fileInfo.type
}
})
if (request.data.ocs.meta.statuscode !== 100) {
console.error('Error fetching recommendations', request)
return
}
const exact = request.data.ocs.data.exact
// flatten array of arrays
const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
this.recommendations = this.filterOutExistingShares(rawRecommendations)
.map(share => this.formatForMultiselect(share))
this.loading = false
console.info('recommendations', this.recommendations)
},
/**
* Filter out existing shares from
* the provided shares search results
*
* @param {Object[]} shares the array of shares object
* @returns {Object[]}
*/
filterOutExistingShares(shares) {
return shares.reduce((arr, share) => {
// only check proper objects
if (typeof share !== 'object') {
return arr
}
try {
// filter out current user
if (share.value.shareWith === getCurrentUser().uid) {
return arr
}
// filter out the owner of the share
if (this.reshare && share.value.shareWith === this.reshare.owner) {
return arr
}
// filter out existing mail shares
if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
const emails = this.linkShares.map(elem => elem.shareWith)
if (emails.indexOf(share.value.shareWith.trim()) !== -1) {
return arr
}
} else { // filter out existing shares
// creating an object of uid => type
const sharesObj = this.shares.reduce((obj, elem) => {
obj[elem.shareWith] = elem.type
return obj
}, {})
// if shareWith is the same and the share type too, ignore it
const key = share.value.shareWith.trim()
if (key in sharesObj
&& sharesObj[key] === share.value.shareType) {
return arr
}
}
// ALL GOOD
// let's add the suggestion
arr.push(share)
} catch {
return arr
}
return arr
}, [])
},
/**
* Get the icon based on the share type
* @param {number} type the share type
* @returns {string} the icon class
*/
shareTypeToIcon(type) {
switch (type) {
case this.SHARE_TYPES.SHARE_TYPE_GUEST:
// default is a user, other icons are here to differenciate
// themselves from it, so let's not display the user icon
// case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
// case this.SHARE_TYPES.SHARE_TYPE_USER:
return 'icon-user'
case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
case this.SHARE_TYPES.SHARE_TYPE_GROUP:
return 'icon-group'
case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
return 'icon-mail'
case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
return 'icon-circle'
case this.SHARE_TYPES.SHARE_TYPE_ROOM:
return 'icon-room'
default:
return ''
}
},
/**
* Format shares for the multiselect options
* @param {Object} result select entry item
* @returns {Object}
*/
formatForMultiselect(result) {
let desc
if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE
|| result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
) && result.value.server) {
desc = t('files_sharing', 'on {server}', { server: result.value.server })
} else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
desc = result.value.shareWith
}
return {
shareWith: result.value.shareWith,
shareType: result.value.shareType,
user: result.uuid || result.value.shareWith,
isNoUser: !result.uuid,
displayName: result.name || result.label,
desc,
icon: this.shareTypeToIcon(result.value.shareType)
}
},
/**
* Process the new share request
* @param {Object} value the multiselect option
*/
async addShare(value) {
if (value.lookup) {
return this.getSuggestions(this.query, true)
}
// handle externalResults from OCA.Sharing.ShareSearch
if (value.handler) {
const share = await value.handler(this)
this.$emit('add:share', new Share(share))
return true
}
this.loading = true
try {
const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
const share = await this.createShare({
path,
shareType: value.shareType,
shareWith: value.shareWith
})
this.$emit('add:share', share)
this.getRecommendations()
} catch (response) {
// focus back if any error
const input = this.$refs.multiselect.$el.querySelector('input')
if (input) {
input.focus()
}
this.query = value.shareWith
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss">
.sharing-input {
width: 100%;
margin: 10px 0;
// properly style the lookup entry
.multiselect__option {
span[lookup] {
.avatardiv {
background-image: var(--icon-search-fff);
background-repeat: no-repeat;
background-position: center;
background-color: var(--color-text-maxcontrast) !important;
div {
display: none;
}
}
}
}
}
</style>

View File

@ -0,0 +1,39 @@
/**
* @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 SharingTab from './views/SharingTab'
import ShareSearch from './services/ShareSearch'
import ExternalLinkActions from './services/ExternalLinkActions'
if (window.OCA && window.OCA.Sharing) {
Object.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() })
}
if (window.OCA && window.OCA.Sharing) {
Object.assign(window.OCA.Sharing, { ExternalLinkActions: new ExternalLinkActions() })
}
window.addEventListener('DOMContentLoaded', () => {
if (OCA.Files && OCA.Files.Sidebar) {
OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab('sharing', SharingTab))
}
})

View File

@ -0,0 +1,114 @@
/**
* @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/>.
*
*/
// TODO: remove when ie not supported
import 'url-search-params-polyfill'
import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import Share from '../models/Share'
const shareUrl = generateOcsUrl('apps/files_sharing/api/v1', 2) + 'shares'
const headers = {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}
export default {
methods: {
/**
* Create a new share
*
* @param {Object} data destructuring object
* @param {string} data.path path to the file/folder which should be shared
* @param {number} data.shareType 0 = user; 1 = group; 3 = public link; 6 = federated cloud share
* @param {string} data.shareWith user/group id with which the file should be shared (optional for shareType > 1)
* @param {boolean} [data.publicUpload=false] allow public upload to a public shared folder
* @param {string} [data.password] password to protect public link Share with
* @param {number} [data.permissions=31] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1)
* @param {boolean} [data.sendPasswordByTalk=false] send the password via a talk conversation
* @param {string} [data.expireDate=''] expire the shareautomatically after
* @param {string} [data.label=''] custom label
* @returns {Share} the new share
* @throws {Error}
*/
async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label }) {
try {
const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label })
if (!('ocs' in request.data)) {
throw request
}
return new Share(request.data.ocs.data)
} catch (error) {
console.error('Error while creating share', error)
OC.Notification.showTemporary(t('files_sharing', 'Error creating the share'), { type: 'error' })
throw error
}
},
/**
* Delete a share
*
* @param {number} id share id
* @throws {Error}
*/
async deleteShare(id) {
try {
const request = await axios.delete(shareUrl + `/${id}`)
if (!('ocs' in request.data)) {
throw request
}
return true
} catch (error) {
console.error('Error while deleting share', error)
OC.Notification.showTemporary(t('files_sharing', 'Error deleting the share'), { type: 'error' })
throw error
}
},
/**
* Update a share
*
* @param {number} id share id
* @param {Object} data destructuring object
* @param {string} data.property property to update
* @param {any} data.value value to set
*/
async updateShare(id, { property, value }) {
try {
// ocs api requires x-www-form-urlencoded
const data = new URLSearchParams()
data.append(property, value)
const request = await axios.put(shareUrl + `/${id}`, { [property]: value }, headers)
if (!('ocs' in request.data)) {
throw request
}
return true
} catch (error) {
console.error('Error while updating share', error)
OC.Notification.showTemporary(t('files_sharing', 'Error updating the share'), { type: 'error' })
const message = error.response.data.ocs.meta.message
throw new Error(`${property}, ${message}`)
}
}
}
}

View File

@ -0,0 +1,39 @@
/**
* @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/>.
*
*/
export default {
data() {
return {
SHARE_TYPES: {
SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER,
SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP,
SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK,
SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL,
SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE,
SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE,
SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST,
SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP,
SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM
}
}
}
}

View File

@ -0,0 +1,303 @@
/**
* @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 PQueue from 'p-queue'
import debounce from 'debounce'
import Share from '../models/Share'
import SharesRequests from './ShareRequests'
import ShareTypes from './ShareTypes'
import Config from '../services/ConfigService'
import { getCurrentUser } from '@nextcloud/auth'
export default {
mixins: [SharesRequests, ShareTypes],
props: {
fileInfo: {
type: Object,
default: () => {},
required: true
},
share: {
type: Share,
default: null
}
},
data() {
return {
config: new Config(),
// errors helpers
errors: {},
// component status toggles
loading: false,
saving: false,
open: false,
// concurrency management queue
// we want one queue per share
updateQueue: new PQueue({ concurrency: 1 }),
/**
* ! This allow vue to make the Share class state reactive
* ! do not remove it ot you'll lose all reactivity here
*/
reactiveState: this.share && this.share.state,
SHARE_TYPES: {
SHARE_TYPE_USER: OC.Share.SHARE_TYPE_USER,
SHARE_TYPE_GROUP: OC.Share.SHARE_TYPE_GROUP,
SHARE_TYPE_LINK: OC.Share.SHARE_TYPE_LINK,
SHARE_TYPE_EMAIL: OC.Share.SHARE_TYPE_EMAIL,
SHARE_TYPE_REMOTE: OC.Share.SHARE_TYPE_REMOTE,
SHARE_TYPE_CIRCLE: OC.Share.SHARE_TYPE_CIRCLE,
SHARE_TYPE_GUEST: OC.Share.SHARE_TYPE_GUEST,
SHARE_TYPE_REMOTE_GROUP: OC.Share.SHARE_TYPE_REMOTE_GROUP,
SHARE_TYPE_ROOM: OC.Share.SHARE_TYPE_ROOM
}
}
},
computed: {
/**
* Does the current share have an expiration date
* @returns {boolean}
*/
hasExpirationDate: {
get: function() {
return this.config.isDefaultExpireDateEnforced || !!this.share.expireDate
},
set: function(enabled) {
this.share.expireDate = enabled
? this.config.defaultExpirationDateString !== ''
? this.config.defaultExpirationDateString
: moment().format('YYYY-MM-DD')
: ''
}
},
/**
* Does the current share have a note
* @returns {boolean}
*/
hasNote: {
get: function() {
return !!this.share.note
},
set: function(enabled) {
this.share.note = enabled
? t('files_sharing', 'Enter a note for the share recipient')
: ''
}
},
dateTomorrow() {
return moment().add(1, 'days')
},
dateMaxEnforced() {
return this.config.isDefaultExpireDateEnforced
&& moment().add(1 + this.config.defaultExpireDate, 'days')
},
/**
* Datepicker lang values
* https://github.com/nextcloud/nextcloud-vue/pull/146
* TODO: have this in vue-components
*
* @returns {int}
*/
firstDay() {
return window.firstDay
? window.firstDay
: 0 // sunday as default
},
lang() {
// fallback to default in case of unavailable data
return {
days: window.dayNamesShort
? window.dayNamesShort // provided by nextcloud
: ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.'],
months: window.monthNamesShort
? window.monthNamesShort // provided by nextcloud
: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'],
placeholder: {
date: 'Select Date' // TODO: Translate
}
}
},
isShareOwner() {
return this.share && this.share.owner === getCurrentUser().uid
}
},
methods: {
/**
* Check if a share is valid before
* firing the request
*
* @param {Share} share the share to check
* @returns {Boolean}
*/
checkShare(share) {
if (share.password) {
if (typeof share.password !== 'string' || share.password.trim() === '') {
return false
}
}
if (share.expirationDate) {
const date = moment(share.expirationDate)
if (!date.isValid()) {
return false
}
}
return true
},
/**
* ActionInput can be a little tricky to work with.
* Since we expect a string and not a Date,
* we need to process the value here
*
* @param {Date} date js date to be parsed by moment.js
*/
onExpirationChange(date) {
// format to YYYY-MM-DD
const value = moment(date).format('YYYY-MM-DD')
this.share.expireDate = value
this.queueUpdate('expireDate')
},
/**
* Uncheck expire date
* We need this method because @update:checked
* is ran simultaneously as @uncheck, so
* so we cannot ensure data is up-to-date
*/
onExpirationDisable() {
this.share.expireDate = ''
this.queueUpdate('expireDate')
},
/**
* Delete share button handler
*/
async onDelete() {
try {
this.loading = true
this.open = false
await this.deleteShare(this.share.id)
console.debug('Share deleted', this.share.id)
this.$emit('remove:share', this.share)
} catch (error) {
// re-open menu if error
this.open = true
} finally {
this.loading = false
}
},
/**
* Send an update of the share to the queue
*
* @param {string} property the property to sync
*/
queueUpdate(property) {
if (this.share.id) {
// force value to string because that is what our
// share api controller accepts
const value = this.share[property].toString()
this.updateQueue.add(async() => {
this.saving = true
this.errors = {}
try {
await this.updateShare(this.share.id, {
property,
value
})
// clear any previous errors
this.$delete(this.errors, property)
// reset password state after sync
this.$delete(this.share, 'newPassword')
} catch ({ property, message }) {
this.onSyncError(property, message)
} finally {
this.saving = false
}
})
} else {
console.error('Cannot update share.', this.share, 'No valid id')
}
},
/**
* Manage sync errors
* @param {string} property the errored property, e.g. 'password'
* @param {string} message the error message
*/
onSyncError(property, message) {
// re-open menu if closed
this.open = true
switch (property) {
case 'password':
case 'pending':
case 'expireDate':
case 'note': {
// show error
this.$set(this.errors, property, message)
let propertyEl = this.$refs[property]
if (propertyEl) {
if (propertyEl.$el) {
propertyEl = propertyEl.$el
}
// focus if there is a focusable action element
const focusable = propertyEl.querySelector('.focusable')
if (focusable) {
focusable.focus()
}
}
break
}
}
},
/**
* Debounce queueUpdate to avoid requests spamming
* more importantly for text data
*
* @param {string} property the property to sync
*/
debounceQueueUpdate: debounce(function(property) {
this.queueUpdate(property)
}, 500)
}
}

View File

@ -0,0 +1,444 @@
/**
* @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/>.
*
*/
export default class Share {
#share;
/**
* Create the share object
*
* @param {Object} ocsData ocs request response
*/
constructor(ocsData) {
if (ocsData.ocs && ocsData.ocs.data && ocsData.ocs.data[0]) {
ocsData = ocsData.ocs.data[0]
}
// convert int into boolean
ocsData.hide_download = !!ocsData.hide_download
ocsData.mail_send = !!ocsData.mail_send
// store state
this.#share = ocsData
}
/**
* Get the share state
* ! used for reactivity purpose
* Do not remove. It allow vuejs to
* inject its watchers into the #share
* state and make the whole class reactive
*
* @returns {Object} the share raw state
* @readonly
* @memberof Sidebar
*/
get state() {
return this.#share
}
/**
* get the share id
*
* @returns {int}
* @readonly
* @memberof Share
*/
get id() {
return this.#share.id
}
/**
* Get the share type
*
* @returns {int}
* @readonly
* @memberof Share
*/
get type() {
return this.#share.share_type
}
/**
* Get the share permissions
* See OC.PERMISSION_* variables
*
* @returns {int}
* @readonly
* @memberof Share
*/
get permissions() {
return this.#share.permissions
}
/**
* Set the share permissions
* See OC.PERMISSION_* variables
*
* @param {int} permissions valid permission, See OC.PERMISSION_* variables
* @memberof Share
*/
set permissions(permissions) {
this.#share.permissions = permissions
}
// SHARE OWNER --------------------------------------------------
/**
* Get the share owner uid
*
* @returns {string}
* @readonly
* @memberof Share
*/
get owner() {
return this.#share.uid_owner
}
/**
* Get the share owner's display name
*
* @returns {string}
* @readonly
* @memberof Share
*/
get ownerDisplayName() {
return this.#share.displayname_owner
}
// SHARED WITH --------------------------------------------------
/**
* Get the share with entity uid
*
* @returns {string}
* @readonly
* @memberof Share
*/
get shareWith() {
return this.#share.share_with
}
/**
* Get the share with entity display name
* fallback to its uid if none
*
* @returns {string}
* @readonly
* @memberof Share
*/
get shareWithDisplayName() {
return this.#share.share_with_displayname
|| this.#share.share_with
}
/**
* Get the share with avatar if any
*
* @returns {string}
* @readonly
* @memberof Share
*/
get shareWithAvatar() {
return this.#share.share_with_avatar
}
// SHARED FILE OR FOLDER OWNER ----------------------------------
/**
* Get the shared item owner uid
*
* @returns {string}
* @readonly
* @memberof Share
*/
get uidFileOwner() {
return this.#share.uid_file_owner
}
/**
* Get the shared item display name
* fallback to its uid if none
*
* @returns {string}
* @readonly
* @memberof Share
*/
get displaynameFileOwner() {
return this.#share.displayname_file_owner
|| this.#share.uid_file_owner
}
// TIME DATA ----------------------------------------------------
/**
* Get the share creation timestamp
*
* @returns {int}
* @readonly
* @memberof Share
*/
get createdTime() {
return this.#share.stime
}
/**
* Get the expiration date as a string format
*
* @returns {string}
* @readonly
* @memberof Share
*/
get expireDate() {
return this.#share.expiration
}
/**
* Set the expiration date as a string format
* e.g. YYYY-MM-DD
*
* @param {string} date the share expiration date
* @memberof Share
*/
set expireDate(date) {
this.#share.expiration = date
}
// EXTRA DATA ---------------------------------------------------
/**
* Get the public share token
*
* @returns {string} the token
* @readonly
* @memberof Share
*/
get token() {
return this.#share.token
}
/**
* Get the share note if any
*
* @returns {string}
* @readonly
* @memberof Share
*/
get note() {
return this.#share.note
}
/**
* Set the share note if any
*
* @param {string} note the note
* @memberof Share
*/
set note(note) {
this.#share.note = note.trim()
}
/**
* Have a mail been sent
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get mailSend() {
return this.#share.mail_send === true
}
/**
* Hide the download button on public page
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get hideDownload() {
return this.#share.hide_download === true
}
/**
* Hide the download button on public page
*
* @param {boolean} state hide the button ?
* @memberof Share
*/
set hideDownload(state) {
this.#share.hide_download = state === true
}
/**
* Password protection of the share
*
* @returns {string}
* @readonly
* @memberof Share
*/
get password() {
return this.#share.password
}
/**
* Password protection of the share
*
* @param {string} password the share password
* @memberof Share
*/
set password(password) {
this.#share.password = password.trim()
}
// SHARED ITEM DATA ---------------------------------------------
/**
* Get the shared item absolute full path
*
* @returns {string}
* @readonly
* @memberof Share
*/
get path() {
return this.#share.path
}
/**
* Return the item type: file or folder
*
* @returns {string} 'folder' or 'file'
* @readonly
* @memberof Share
*/
get itemType() {
return this.#share.item_type
}
/**
* Get the shared item mimetype
*
* @returns {string}
* @readonly
* @memberof Share
*/
get mimetype() {
return this.#share.mimetype
}
/**
* Get the shared item id
*
* @returns {int}
* @readonly
* @memberof Share
*/
get fileSource() {
return this.#share.file_source
}
/**
* Get the target path on the receiving end
* e.g the file /xxx/aaa will be shared in
* the receiving root as /aaa, the fileTarget is /aaa
*
* @returns {string}
* @readonly
* @memberof Share
*/
get fileTarget() {
return this.#share.file_target
}
/**
* Get the parent folder id if any
*
* @returns {int}
* @readonly
* @memberof Share
*/
get fileParent() {
return this.#share.file_parent
}
// PERMISSIONS Shortcuts
/**
* Does this share have CREATE permissions
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get hasCreatePermission() {
return !!((this.permissions & OC.PERMISSION_CREATE))
}
/**
* Does this share have DELETE permissions
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get hasDeletePermission() {
return !!((this.permissions & OC.PERMISSION_DELETE))
}
/**
* Does this share have UPDATE permissions
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get hasUpdatePermission() {
return !!((this.permissions & OC.PERMISSION_UPDATE))
}
/**
* Does this share have SHARE permissions
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get hasSharePermission() {
return !!((this.permissions & OC.PERMISSION_SHARE))
}
// TODO: SORT THOSE PROPERTIES
get label() {
return this.#share.label
}
get parent() {
return this.#share.parent
}
get storageId() {
return this.#share.storage_id
}
get storage() {
return this.#share.storage
}
get itemSource() {
return this.#share.item_source
}
}

View File

@ -0,0 +1,223 @@
/**
* @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/>.
*
*/
export default class Config {
/**
* Is public upload allowed on link shares ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isPublicUploadEnabled() {
return document.getElementById('filestable')
&& document.getElementById('filestable').dataset.allowPublicUpload === 'yes'
}
/**
* Are link share allowed ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isShareWithLinkAllowed() {
return document.getElementById('allowShareWithLink')
&& document.getElementById('allowShareWithLink').value === 'yes'
}
/**
* Get the federated sharing documentation link
*
* @returns {string}
* @readonly
* @memberof Config
*/
get federatedShareDocLink() {
return OC.appConfig.core.federatedCloudShareDoc
}
/**
* Get the default expiration date as string
*
* @returns {string}
* @readonly
* @memberof Config
*/
get defaultExpirationDateString() {
let expireDateString = ''
if (this.isDefaultExpireDateEnabled) {
const date = window.moment.utc()
const expireAfterDays = this.defaultExpireDate
date.add(expireAfterDays, 'days')
expireDateString = date.format('YYYY-MM-DD')
}
return expireDateString
}
/**
* Are link shares password-enforced ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get enforcePasswordForPublicLink() {
return OC.appConfig.core.enforcePasswordForPublicLink === true
}
/**
* Is password asked by default on link shares ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get enableLinkPasswordByDefault() {
return OC.appConfig.core.enableLinkPasswordByDefault === true
}
/**
* Is link shares expiration enforced ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isDefaultExpireDateEnforced() {
return OC.appConfig.core.defaultExpireDateEnforced === true
}
/**
* Is there a default expiration date for new link shares ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isDefaultExpireDateEnabled() {
return OC.appConfig.core.defaultExpireDateEnabled === true
}
/**
* Are users on this server allowed to send shares to other servers ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isRemoteShareAllowed() {
return OC.appConfig.core.remoteShareAllowed === true
}
/**
* Is sharing my mail (link share) enabled ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isMailShareAllowed() {
return OC.appConfig.shareByMailEnabled !== undefined
}
/**
* Get the default days to expiration
*
* @returns {int}
* @readonly
* @memberof Config
*/
get defaultExpireDate() {
return OC.appConfig.core.defaultExpireDate
}
/**
* Is resharing allowed ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isResharingAllowed() {
return OC.appConfig.core.resharingAllowed === true
}
/**
* Is password enforced for mail shares ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get isPasswordForMailSharesRequired() {
return (OC.appConfig.shareByMail === undefined) ? false : OC.appConfig.shareByMail.enforcePasswordProtection === true
}
/**
* Is sharing with groups allowed ?
*
* @returns {boolean}
* @readonly
* @memberof Config
*/
get allowGroupSharing() {
return OC.appConfig.core.allowGroupSharing === true
}
/**
* Get the maximum results of a share search
*
* @returns {int}
* @readonly
* @memberof Config
*/
get maxAutocompleteResults() {
return parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 200
}
/**
* Get the minimal string length
* to initiate a share search
*
* @returns {int}
* @readonly
* @memberof Config
*/
get minSearchStringLength() {
return parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0
}
/**
* Get the password policy config
*
* @returns {Object}
* @readonly
* @memberof Config
*/
get passwordPolicy() {
const capabilities = OC.getCapabilities()
return capabilities.password_policy ? capabilities.password_policy : {}
}
}

View File

@ -0,0 +1,63 @@
/**
* @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/>.
*
*/
export default class ExternalLinkActions {
#state;
constructor() {
// init empty state
this.#state = {}
// init default values
this.#state.actions = []
console.debug('OCA.Sharing.ExternalLinkActions initialized')
}
/**
* Get the state
*
* @readonly
* @memberof ExternalLinkActions
* @returns {Object} the data state
*/
get state() {
return this.#state
}
/**
* Register a new action for the link share
* Mostly used by the social sharing app.
*
* @param {Object} action new action component to register
* @returns {boolean}
*/
registerAction(action) {
if (typeof action === 'object' && action.render && action.components) {
this.#state.actions.push(action)
return true
}
console.error(`Invalid action component provided`, action)
return false
}
}

View File

@ -0,0 +1,71 @@
/**
* @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/>.
*
*/
export default class ShareSearch {
#state;
constructor() {
// init empty state
this.#state = {}
// init default values
this.#state.results = []
console.debug('OCA.Sharing.ShareSearch initialized')
}
/**
* Get the state
*
* @readonly
* @memberof ShareSearch
* @returns {Object} the data state
*/
get state() {
return this.#state
}
/**
* Register a new result
* Mostly used by the guests app.
* We should consider deprecation and add results via php ?
*
* @param {Object} result entry to append
* @param {string} [result.user] entry user
* @param {string} result.displayName entry first line
* @param {string} [result.desc] entry second line
* @param {string} [result.icon] entry icon
* @param {function} result.handler function to run on entry selection
* @param {function} [result.condition] condition to add entry or not
* @returns {boolean}
*/
addNewResult(result) {
if (result.displayName.trim() !== ''
&& typeof result.handler === 'function') {
this.#state.results.push(result)
return true
}
console.error(`Invalid search result provided`, result)
return false
}
}

View File

@ -195,7 +195,7 @@
// do not open sidebar if permission is set and equal to 0
var permissions = parseInt(context.$file.data('share-permissions'), 10)
if (isNaN(permissions) || permissions > 0) {
fileList.showDetailsView(fileName, 'shareTabView')
fileList.showDetailsView(fileName, 'sharing')
}
},
render: function(actionSpec, isDefault, context) {
@ -209,37 +209,37 @@
}
})
var shareTab = new OCA.Sharing.ShareTabView('shareTabView', { order: -20 })
// detect changes and change the matching list entry
shareTab.on('sharesChanged', function(shareModel) {
var fileInfoModel = shareModel.fileInfoModel
var $tr = fileList.findFileEl(fileInfoModel.get('name'))
var shareTab = new OCA.Sharing.ShareTabView('sharing', {order: -20})
// // detect changes and change the matching list entry
// shareTab.on('sharesChanged', function(shareModel) {
// var fileInfoModel = shareModel.fileInfoModel
// var $tr = fileList.findFileEl(fileInfoModel.get('name'))
// We count email shares as link share
var hasLinkShares = shareModel.hasLinkShares()
shareModel.get('shares').forEach(function(share) {
if (share.share_type === OC.Share.SHARE_TYPE_EMAIL) {
hasLinkShares = true
}
})
// // We count email shares as link share
// var hasLinkShares = shareModel.hasLinkShares();
// shareModel.get('shares').forEach(function (share) {
// if (share.share_type === OC.Share.SHARE_TYPE_EMAIL) {
// hasLinkShares = true;
// }
// })
OCA.Sharing.Util._updateFileListDataAttributes(fileList, $tr, shareModel)
if (!OCA.Sharing.Util._updateFileActionIcon($tr, shareModel.hasUserShares(), hasLinkShares)) {
// remove icon, if applicable
OC.Share.markFileAsShared($tr, false, false)
}
// OCA.Sharing.Util._updateFileListDataAttributes(fileList, $tr, shareModel);
// if (!OCA.Sharing.Util._updateFileActionIcon($tr, shareModel.hasUserShares(), hasLinkShares)) {
// // remove icon, if applicable
// OC.Share.markFileAsShared($tr, false, false)
// }
// FIXME: this is too convoluted. We need to get rid of the above updates
// and only ever update the model and let the events take care of rerendering
fileInfoModel.set({
shareTypes: shareModel.getShareTypes(),
// in case markFileAsShared decided to change the icon,
// we need to modify the model
// (FIXME: yes, this is hacky)
icon: $tr.attr('data-icon')
})
})
fileList.registerTabView(shareTab)
// // FIXME: this is too convoluted. We need to get rid of the above updates
// // and only ever update the model and let the events take care of rerendering
// fileInfoModel.set({
// shareTypes: shareModel.getShareTypes(),
// // in case markFileAsShared decided to change the icon,
// // we need to modify the model
// // (FIXME: yes, this is hacky)
// icon: $tr.attr('data-icon')
// })
// })
// fileList.registerTabView(shareTab)
var breadCrumbSharingDetailView = new OCA.Sharing.ShareBreadCrumbView({ shareTab: shareTab })
fileList.registerBreadCrumbDetailView(breadCrumbSharingDetailView)

View File

@ -93,7 +93,7 @@
dirInfo: self._dirInfo
})
})
OCA.Files.App.fileList.showDetailsView(fileInfoModel, 'shareTabView')
OCA.Files.App.fileList.showDetailsView(fileInfoModel, 'sharing')
}
})

View File

@ -0,0 +1,86 @@
/**
* @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/>.
*
*/
/**
* Get the shared with me title
*
* @param {Share} share current share
* @returns {string} the title
*/
const shareWithTitle = function(share) {
if (share.type === OC.Share.type_GROUP) {
return t(
'files_sharing',
'Shared with you and the group {group} by {owner}',
{
group: share.shareWithDisplayName,
owner: share.ownerDisplayName
},
undefined,
{ escape: false }
)
} else if (share.type === OC.Share.type_CIRCLE) {
return t(
'files_sharing',
'Shared with you and {circle} by {owner}',
{
circle: share.shareWithDisplayName,
owner: share.ownerDisplayName
},
undefined,
{ escape: false }
)
} else if (share.type === OC.Share.type_ROOM) {
if (this.model.get('reshare').share_with_displayname) {
return t(
'files_sharing',
'Shared with you and the conversation {conversation} by {owner}',
{
conversation: share.shareWithDisplayName,
owner: share.ownerDisplayName
},
undefined,
{ escape: false }
)
} else {
return t(
'files_sharing',
'Shared with you in a conversation by {owner}',
{
owner: share.ownerDisplayName
},
undefined,
{ escape: false }
)
}
} else {
return t(
'files_sharing',
'Shared with you by {owner}',
{ owner: share.ownerDisplayName },
undefined,
{ escape: false }
)
}
}
export { shareWithTitle }

View File

@ -0,0 +1,141 @@
<!--
- @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/>.
-
-->
<template>
<ul class="sharing-link-list">
<!-- If no link shares, show the add link default entry -->
<SharingEntryLink v-if="!hasLinkShares && canReshare"
:can-reshare="canReshare"
:file-info="fileInfo"
@add:share="addShare" />
<!-- Else we display the list -->
<template v-if="hasShares">
<!-- using shares[index] to work with .sync -->
<SharingEntryLink v-for="(share, index) in shares"
:key="share.id"
:can-reshare="canReshare"
:share.sync="shares[index]"
:file-info="fileInfo"
@add:share="addShare(...arguments)"
@update:share="awaitForShare(...arguments)"
@remove:share="removeShare" />
</template>
</ul>
</template>
<script>
// eslint-disable-next-line no-unused-vars
import Share from '../models/Share'
import ShareTypes from '../mixins/ShareTypes'
import SharingEntryLink from '../components/SharingEntryLink'
export default {
name: 'SharingLinkList',
components: {
SharingEntryLink
},
mixins: [ShareTypes],
props: {
fileInfo: {
type: Object,
default: () => {},
required: true
},
shares: {
type: Array,
default: () => [],
required: true
},
canReshare: {
type: Boolean,
required: true
}
},
computed: {
/**
* Do we have link shares?
* Using this to still show the `new link share`
* button regardless of mail shares
*
* @returns {Array}
*/
hasLinkShares() {
return this.shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK).length > 0
},
/**
* Do we have any link or email shares?
*
* @returns {boolean}
*/
hasShares() {
return this.shares.length > 0
}
},
methods: {
/**
* Add a new share into the link shares list
* and return the newly created share component
*
* @param {Share} share the share to add to the array
* @param {Function} resolve a function to run after the share is added and its component initialized
*/
addShare(share, resolve) {
this.shares.unshift(share)
this.awaitForShare(share, resolve)
},
/**
* Await for next tick and render after the list updated
* Then resolve with the matched vue component of the
* provided share object
*
* @param {Share} share newly created share
* @param {Function} resolve a function to execute after
*/
awaitForShare(share, resolve) {
this.$nextTick(() => {
const newShare = this.$children.find(component => component.share === share)
if (newShare) {
resolve(newShare)
}
})
},
/**
* Remove a share from the shares list
*
* @param {Share} share the share to remove
*/
removeShare(share) {
const index = this.shares.findIndex(item => item === share)
this.shares.splice(index, 1)
}
}
}
</script>

View File

@ -0,0 +1,76 @@
<!--
- @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/>.
-
-->
<template>
<ul class="sharing-sharee-list">
<SharingEntry v-for="share in shares"
:key="share.id"
:file-info="fileInfo"
:share="share"
@remove:share="removeShare" />
</ul>
</template>
<script>
// eslint-disable-next-line no-unused-vars
import Share from '../models/Share'
import SharingEntry from '../components/SharingEntry'
export default {
name: 'SharingList',
components: {
SharingEntry
},
props: {
fileInfo: {
type: Object,
default: () => {},
required: true
},
shares: {
type: Array,
default: () => [],
required: true
}
},
computed: {
hasShares() {
return this.shares.length === 0
}
},
methods: {
/**
* Remove a share from the shares list
*
* @param {Share} share the share to remove
*/
removeShare(share) {
const index = this.shares.findIndex(item => item === share)
this.shares.splice(index, 1)
}
}
}
</script>

View File

@ -0,0 +1,318 @@
<!--
- @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/>.
-
-->
<template>
<Tab :icon="icon" :name="name" :class="{ 'icon-loading': loading }">
<!-- error message -->
<div v-if="error" class="emptycontent">
<div class="icon icon-error" />
<h2>{{ error }}</h2>
</div>
<!-- shares content -->
<template v-else>
<!-- shared with me information -->
<SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare">
<template #avatar>
<Avatar #avatar
:user="sharedWithMe.user"
:display-name="sharedWithMe.displayName"
class="sharing-entry__avatar"
tooltip-message="" />
</template>
</SharingEntrySimple>
<!-- add new share input -->
<SharingInput v-if="!loading"
:can-reshare="canReshare"
:file-info="fileInfo"
:link-shares="linkShares"
:reshare="reshare"
:shares="shares"
@add:share="addShare" />
<!-- link shares list -->
<SharingLinkList v-if="!loading"
:can-reshare="canReshare"
:file-info="fileInfo"
:shares="linkShares" />
<!-- other shares list -->
<SharingList v-if="!loading"
:shares="shares"
:file-info="fileInfo" />
<!-- internal link copy -->
<SharingEntryInternal :file-info="fileInfo" />
</template>
</Tab>
</template>
<script>
import { generateOcsUrl } from '@nextcloud/router'
import Tab from 'nextcloud-vue/dist/Components/AppSidebarTab'
import Avatar from 'nextcloud-vue/dist/Components/Avatar'
import axios from '@nextcloud/axios'
import { shareWithTitle } from '../utils/SharedWithMe'
import Share from '../models/Share'
import ShareTypes from '../mixins/ShareTypes'
import SharingEntryInternal from '../components/SharingEntryInternal'
import SharingEntrySimple from '../components/SharingEntrySimple'
import SharingInput from '../components/SharingInput'
import SharingLinkList from './SharingLinkList'
import SharingList from './SharingList'
export default {
name: 'SharingTab',
components: {
Avatar,
SharingEntryInternal,
SharingEntrySimple,
SharingInput,
SharingLinkList,
SharingList,
Tab
},
mixins: [ShareTypes],
props: {
fileInfo: {
type: Object,
default: () => {},
required: true
}
},
data() {
return {
error: '',
expirationInterval: null,
icon: 'icon-share',
loading: true,
name: t('files_sharing', 'Sharing'),
// reshare Share object
reshare: null,
sharedWithMe: {},
shares: [],
linkShares: [],
sections: OCA.Sharing.ShareTabSections.getSections()
}
},
computed: {
/**
* Needed to differenciate the tabs
* pulled from the AppSidebarTab component
*
* @returns {string}
*/
id() {
return this.name.toLowerCase().replace(/ /g, '-')
},
/**
* Returns the current active tab
* needed because AppSidebarTab also uses $parent.activeTab
*
* @returns {string}
*/
activeTab() {
return this.$parent.activeTab
},
/**
* Is this share shared with me?
*
* @returns {boolean}
*/
isSharedWithMe() {
return Object.keys(this.sharedWithMe).length > 0
},
canReshare() {
return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE)
|| !!(this.reshare && this.reshare.hasSharePermission)
}
},
watch: {
fileInfo() {
this.resetState()
this.getShares()
}
},
beforeMount() {
this.getShares()
},
methods: {
/**
* Get the existing shares infos
*/
async getShares() {
try {
this.loading = true
// init params
const shareUrl = generateOcsUrl('apps/files_sharing/api/v1', 2) + 'shares'
const format = 'json'
// TODO: replace with proper getFUllpath implementation of our own FileInfo model
const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
// fetch shares
const fetchShares = axios.get(shareUrl, {
params: {
format,
path,
reshares: true
}
})
const fetchSharedWithMe = axios.get(shareUrl, {
params: {
format,
path,
shared_with_me: true
}
})
// wait for data
const [shares, sharedWithMe] = await Promise.all([fetchShares, fetchSharedWithMe])
this.loading = false
// process results
this.processSharedWithMe(sharedWithMe)
this.processShares(shares)
} catch (error) {
this.error = t('files_sharing', 'Unable to load the shares list')
this.loading = false
console.error('Error loading the shares list', error)
}
},
/**
* Reset the current view to its default state
*/
resetState() {
clearInterval(this.expirationInterval)
this.loading = true
this.error = ''
this.sharedWithMe = {}
this.shares = []
},
/**
* Update sharedWithMe.subtitle with the appropriate
* expiration time left
*
* @param {Share} share the sharedWith Share object
*/
updateExpirationSubtitle(share) {
const expiration = moment(share.expireDate).unix()
this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', {
relativetime: OC.Util.relativeModifiedDate(expiration * 1000)
}))
// share have expired
if (moment().unix() > expiration) {
clearInterval(this.expirationInterval)
// TODO: clear ui if share is expired
this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'this share just expired.'))
}
},
/**
* Process the current shares data
* and init shares[]
*
* @param {Object} share the share ocs api request data
* @param {Object} share.data the request data
*/
processShares({ data }) {
if (data.ocs && data.ocs.data && data.ocs.data.length > 0) {
// create Share objects and sort by newest
const shares = data.ocs.data
.map(share => new Share(share))
.sort((a, b) => b.createdTime - a.createdTime)
this.linkShares = shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL)
this.shares = shares.filter(share => share.type !== this.SHARE_TYPES.SHARE_TYPE_LINK && share.type !== this.SHARE_TYPES.SHARE_TYPE_EMAIL)
}
},
/**
* Process the sharedWithMe share data
* and init sharedWithMe
*
* @param {Object} share the share ocs api request data
* @param {Object} share.data the request data
*/
processSharedWithMe({ data }) {
if (data.ocs && data.ocs.data && data.ocs.data[0]) {
const share = new Share(data)
const title = shareWithTitle(share)
const displayName = share.ownerDisplayName
const user = share.owner
this.sharedWithMe = {
displayName,
title,
user
}
this.reshare = share
// If we have an expiration date, use it as subtitle
// Refresh the status every 10s and clear if expired
if (share.expireDate && moment(share.expireDate).unix() > moment().unix()) {
// first update
this.updateExpirationSubtitle(share)
// interval update
this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share)
}
}
},
/**
* Insert share at top of arrays
*
* @param {Share} share the share to insert
*/
addShare(share) {
// only catching share type MAIL as link shares are added differently
// meaning: not from the ShareInput
if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
this.linkShares.unshift(share)
} else {
this.shares.unshift(share)
}
}
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -4,6 +4,7 @@ module.exports = {
entry: {
'additionalScripts': path.join(__dirname, 'src', 'additionalScripts.js'),
'files_sharing': path.join(__dirname, 'src', 'files_sharing.js'),
'files_sharing_tab': path.join(__dirname, 'src', 'files_sharing_tab.js'),
'collaboration': path.join(__dirname, 'src', 'collaborationresourceshandler.js'),
},
output: {

View File

@ -323,6 +323,13 @@
data.isEncrypted = false;
}
var isFavouritedProp = props['{' + Client.NS_OWNCLOUD + '}favorite'];
if (!_.isUndefined(isFavouritedProp)) {
data.isFavourited = isFavouritedProp === '1';
} else {
data.isFavourited = false;
}
var contentType = props[Client.PROPERTY_GETCONTENTTYPE];
if (!_.isUndefined(contentType)) {
data.mimetype = contentType;

View File

@ -0,0 +1,19 @@
// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
if (!Element.prototype.matches) {
Element.prototype.matches
= Element.prototype.msMatchesSelector
|| Element.prototype.webkitMatchesSelector
}
if (!Element.prototype.closest) {
Element.prototype.closest = function(s) {
var el = this
do {
if (el.matches(s)) return el
el = el.parentElement || el.parentNode
} while (el !== null && el.nodeType === 1)
return null
}
}

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
@ -20,4 +20,5 @@
*/
import './console'
import './closest'
import './windows-phone'

View File

@ -1,4 +1,4 @@
/*
/**
* @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
@ -20,8 +20,8 @@
*/
import $ from 'jquery'
import '@babel/polyfill'
import './Polyfill/index'
import '@babel/polyfill'
// If you remove the line below, tests won't pass
// eslint-disable-next-line no-unused-vars

View File

@ -27,7 +27,7 @@
<LoginForm
:username.sync="user"
:redirect-url="redirectUrl"
:directLogin="directLogin"
:direct-login="directLogin"
:messages="messages"
:errors="errors"
:throttle-delay="throttleDelay"

40
package-lock.json generated
View File

@ -359,6 +359,16 @@
"@babel/plugin-syntax-async-generators": "^7.2.0"
}
},
"@babel/plugin-proposal-class-properties": {
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz",
"integrity": "sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==",
"dev": true,
"requires": {
"@babel/helper-create-class-features-plugin": "^7.5.5",
"@babel/helper-plugin-utils": "^7.0.0"
}
},
"@babel/plugin-proposal-dynamic-import": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.5.0.tgz",
@ -2384,6 +2394,11 @@
"integrity": "sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=",
"dev": true
},
"debounce": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.0.tgz",
"integrity": "sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg=="
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -6085,8 +6100,7 @@
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
"dev": true
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-is-promise": {
"version": "2.1.0",
@ -6110,6 +6124,23 @@
"p-limit": "^2.0.0"
}
},
"p-queue": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.2.0.tgz",
"integrity": "sha512-B2LXNONcyn/G6uz2UBFsGjmSa0e/br3jznlzhEyCXg56c7VhEpiT2pZxGOfv32Q3FSyugAdys9KGpsv3kV+Sbg==",
"requires": {
"eventemitter3": "^4.0.0",
"p-timeout": "^3.1.0"
}
},
"p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"requires": {
"p-finally": "^1.0.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@ -8184,6 +8215,11 @@
}
}
},
"url-search-params-polyfill": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-7.0.0.tgz",
"integrity": "sha512-0SEH3s+wCNbxEE/rWUalN004ICNi23Q74Ksc0gS2kG8EXnbayxGOrV97JdwnIVPKZ75Xk0hvKXvtIC4xReLMgg=="
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",

View File

@ -25,6 +25,7 @@
"dependencies": {
"@babel/polyfill": "^7.6.0",
"@chenfengyuan/vue-qrcode": "^1.0.1",
"@nextcloud/auth": "^0.3.1",
"@nextcloud/axios": "^0.5.0",
"@nextcloud/event-bus": "^0.2.1",
"@nextcloud/initial-state": "^0.2.0",
@ -37,6 +38,7 @@
"clipboard": "^2.0.4",
"css-vars-ponyfill": "^2.1.2",
"davclient.js": "git+https://github.com/owncloud/davclient.js.git#0.2.1",
"debounce": "^1.2.0",
"dompurify": "^2.0.7",
"escape-html": "^1.0.3",
"handlebars": "^4.4.5",
@ -53,12 +55,14 @@
"nextcloud-router": "0.0.9",
"nextcloud-vue": "^0.12.7",
"nextcloud-vue-collections": "^0.6.0",
"p-queue": "^6.1.0",
"query-string": "^5.1.1",
"select2": "3.5.1",
"snap.js": "^2.0.9",
"strengthify": "git+https://github.com/MorrisJobke/strengthify.git#0.5.8",
"toastify-js": "^1.6.1",
"underscore": "^1.9.1",
"url-search-params-polyfill": "^7.0.0",
"v-tooltip": "^2.0.2",
"vue": "^2.6.10",
"vue-click-outside": "^1.0.7",
@ -72,6 +76,7 @@
},
"devDependencies": {
"@babel/core": "^7.6.4",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/preset-env": "^7.6.3",
"@nextcloud/browserslist-config": "^1.0.0",
@ -104,5 +109,8 @@
},
"browserslist": [
"extends @nextcloud/browserslist-config"
]
],
"engines": {
"node": ">=10.0.0"
}
}

View File

@ -3,10 +3,10 @@ const path = require('path')
const merge = require('webpack-merge')
const { VueLoaderPlugin } = require('vue-loader')
const core = require('./core/webpack')
const accessibility = require('./apps/accessibility/webpack')
const comments = require('./apps/comments/webpack')
const core = require('./core/webpack')
const files = require('./apps/files/webpack')
const files_sharing = require('./apps/files_sharing/webpack')
const files_trashbin = require('./apps/files_trashbin/webpack')
const files_versions = require('./apps/files_versions/webpack')
@ -18,14 +18,15 @@ const updatenotifications = require('./apps/updatenotification/webpack')
const workflowengine = require('./apps/workflowengine/webpack')
const modules = {
core,
settings,
accessibility,
comments,
core,
files,
files_sharing,
files_trashbin,
files_versions,
oauth2,
settings,
systemtags,
twofactor_backupscodes,
updatenotifications,