Add OCA.Files.Sidebar and migrate sharing (#15719)

Add OCA.Files.Sidebar and migrate sharing
This commit is contained in:
John Molakvoæ 2019-10-29 14:48:30 +01:00 committed by GitHub
commit f420ac94b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 7748 additions and 1999 deletions

View File

@ -1,11 +0,0 @@
module.exports = {
plugins: ['@babel/plugin-syntax-dynamic-import'],
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
};

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/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

@ -74,7 +74,7 @@ describe('OCA.Comments.FilesPlugin tests', function() {
expect(sidebarStub.calledOnce).toEqual(true);
expect(sidebarStub.lastCall.args[0]).toEqual('One.txt');
expect(sidebarStub.lastCall.args[1]).toEqual('commentsTabView');
expect(sidebarStub.lastCall.args[1]).toEqual('comments');
});
});
describe('elementToFile', function() {

View File

@ -33,6 +33,7 @@ return array(
'OCA\\Files\\Controller\\ApiController' => $baseDir . '/../lib/Controller/ApiController.php',
'OCA\\Files\\Controller\\ViewController' => $baseDir . '/../lib/Controller/ViewController.php',
'OCA\\Files\\Event\\LoadAdditionalScriptsEvent' => $baseDir . '/../lib/Event/LoadAdditionalScriptsEvent.php',
'OCA\\Files\\Event\\LoadSidebar' => $baseDir . '/../lib/Event/LoadSidebar.php',
'OCA\\Files\\Helper' => $baseDir . '/../lib/Helper.php',
'OCA\\Files\\Listener\\LegacyLoadAdditionalScriptsAdapter' => $baseDir . '/../lib/Listener/LegacyLoadAdditionalScriptsAdapter.php',
'OCA\\Files\\Service\\TagService' => $baseDir . '/../lib/Service/TagService.php',

View File

@ -48,6 +48,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Controller\\ApiController' => __DIR__ . '/..' . '/../lib/Controller/ApiController.php',
'OCA\\Files\\Controller\\ViewController' => __DIR__ . '/..' . '/../lib/Controller/ViewController.php',
'OCA\\Files\\Event\\LoadAdditionalScriptsEvent' => __DIR__ . '/..' . '/../lib/Event/LoadAdditionalScriptsEvent.php',
'OCA\\Files\\Event\\LoadSidebar' => __DIR__ . '/..' . '/../lib/Event/LoadSidebar.php',
'OCA\\Files\\Helper' => __DIR__ . '/..' . '/../lib/Helper.php',
'OCA\\Files\\Listener\\LegacyLoadAdditionalScriptsAdapter' => __DIR__ . '/..' . '/../lib/Listener/LegacyLoadAdditionalScriptsAdapter.php',
'OCA\\Files\\Service\\TagService' => __DIR__ . '/..' . '/../lib/Service/TagService.php',

View File

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

286
apps/files/js/dist/sidebar.js vendored Normal file

File diff suppressed because one or more lines are too long

1
apps/files/js/dist/sidebar.js.map vendored Normal file

File diff suppressed because one or more lines are too long

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,28 @@
* @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);
}
// this is the old (terrible) way of getting the context.
// don't use it anywhere else. Just provide the full path
// of the file to the sidebar service
var tr = this.findFileEl(fileName)
var model = this.getModelForFile(tr)
var path = model.attributes.path + '/' + model.attributes.name
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
OCA.Files.Sidebar.file = path.replace('//', '/')
},
/**
@ -1404,6 +1384,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 +3641,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 +3652,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

@ -9,6 +9,7 @@
* @author Thomas Müller <thomas.mueller@tmit.eu>
* @author Vincent Petry <pvince81@owncloud.com>
* @author Felix Nüsse <felix.nuesse@t-online.de>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0
*
@ -30,6 +31,7 @@ namespace OCA\Files\Controller;
use OCA\Files\Activity\Helper;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files\Event\LoadSidebar;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\RedirectResponse;
@ -269,6 +271,8 @@ class ViewController extends Controller {
$event = new LoadAdditionalScriptsEvent();
$this->eventDispatcher->dispatch(LoadAdditionalScriptsEvent::class, $event);
$this->eventDispatcher->dispatch(LoadSidebar::class, new LoadSidebar());
$params = [];
$params['usedSpacePercent'] = (int) $storageInfo['relative'];
$params['owner'] = $storageInfo['owner'];

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl>
*
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files\Event;
use OCP\EventDispatcher\Event;
class LoadSidebar extends Event {
}

View File

@ -0,0 +1,94 @@
<!--
- @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,
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)
}
}
},
beforeMount() {
this.setFileInfo(this.fileInfo)
},
mounted() {
// append the backbone element and set the FileInfo
this.component.$el.appendTo(this.$el)
},
beforeDestroy() {
this.component.remove()
},
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.name === view.name) > -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,358 @@
<!--
- @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:active="setActiveTab"
@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"
:dav-path="davPath"
: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}${this.file}`)
},
/**
* Current active tab handler
* @param {string} id the tab id to set as active
* @returns {string} the current active tab
*/
activeTab() {
return this.Sidebar.activeTab
},
/**
* 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
}
},
/**
* Set current active tab
*
* @param {string} id tab unique id
*/
setActiveTab(id) {
OCA.Files.Sidebar.activeTab = id
},
/**
* 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>`
})
// TODO: Obliterate as soon as possible and use events with new files app
// Terrible fallback for legacy files: toggle filelist as well
if (OCA.Files && OCA.Files.App && OCA.Files.App.fileList && OCA.Files.App.fileList.fileActions) {
OCA.Files.App.fileList.fileActions.triggerAction('Favorite', OCA.Files.App.fileList.getModelForFile(this.fileInfo.name), OCA.Files.App.fileList)
}
} 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>

View File

@ -748,36 +748,6 @@ describe('OCA.Files.FileList tests', function() {
expect(notificationStub.calledOnce).toEqual(true);
});
it('Shows renamed file details if rename ajax call suceeded', function() {
fileList.showDetailsView('One.txt');
expect($('#app-sidebar').hasClass('disappear')).toEqual(false);
expect(fileList._detailsView.getFileInfo().get('id')).toEqual(1);
expect(fileList._detailsView.getFileInfo().get('name')).toEqual('One.txt');
doRename();
deferredRename.resolve(201);
expect($('#app-sidebar').hasClass('disappear')).toEqual(false);
expect(fileList._detailsView.getFileInfo().get('id')).toEqual(1);
expect(fileList._detailsView.getFileInfo().get('name')).toEqual('Tu_after_three.txt');
});
it('Shows again file details if rename ajax call failed', function() {
fileList.showDetailsView('One.txt');
expect($('#app-sidebar').hasClass('disappear')).toEqual(false);
expect(fileList._detailsView.getFileInfo().get('id')).toEqual(1);
expect(fileList._detailsView.getFileInfo().get('name')).toEqual('One.txt');
doRename();
deferredRename.reject(403);
expect($('#app-sidebar').hasClass('disappear')).toEqual(false);
expect(fileList._detailsView.getFileInfo().get('id')).toEqual(1);
expect(fileList._detailsView.getFileInfo().get('name')).toEqual('One.txt');
});
it('Correctly updates file link after rename', function() {
var $tr;
doRename();
@ -2460,184 +2430,6 @@ describe('OCA.Files.FileList tests', function() {
});
});
});
describe('Details sidebar', function() {
beforeEach(function() {
fileList.setFiles(testFiles);
fileList.showDetailsView('Two.jpg');
});
describe('registering', function() {
var addTabStub;
var addDetailStub;
beforeEach(function() {
addTabStub = sinon.stub(OCA.Files.DetailsView.prototype, 'addTabView');
addDetailStub = sinon.stub(OCA.Files.DetailsView.prototype, 'addDetailView');
getDetailsStub = sinon.stub(OCA.Files.DetailsView.prototype, 'getDetailViews');
});
afterEach(function() {
addTabStub.restore();
addDetailStub.restore();
getDetailsStub.restore();
});
it('forward the registered views to the underlying DetailsView', function() {
fileList.destroy();
fileList = new OCA.Files.FileList($('#app-content-files'), {
detailsViewEnabled: true
});
fileList.registerTabView(new OCA.Files.DetailTabView());
fileList.registerDetailView(new OCA.Files.DetailFileInfoView());
expect(addTabStub.calledOnce).toEqual(true);
// twice because the filelist already registers one by default
expect(addDetailStub.calledTwice).toEqual(true);
});
it('forward getting the registered views to the underlying DetailsView', function() {
fileList.destroy();
fileList = new OCA.Files.FileList($('#app-content-files'), {
detailsViewEnabled: true
});
var expectedRegisteredDetailsView = [];
getDetailsStub.returns(expectedRegisteredDetailsView);
var registeredDetailViews = fileList.getRegisteredDetailViews();
expect(getDetailsStub.calledOnce).toEqual(true);
expect(registeredDetailViews).toEqual(expectedRegisteredDetailsView);
});
it('does not error when registering panels when not details view configured', function() {
fileList.destroy();
fileList = new OCA.Files.FileList($('#app-content-files'), {
detailsViewEnabled: false
});
fileList.registerTabView(new OCA.Files.DetailTabView());
fileList.registerDetailView(new OCA.Files.DetailFileInfoView());
expect(addTabStub.notCalled).toEqual(true);
expect(addDetailStub.notCalled).toEqual(true);
});
it('returns null when getting the registered views when not details view configured', function() {
fileList.destroy();
fileList = new OCA.Files.FileList($('#app-content-files'), {
detailsViewEnabled: false
});
var registeredDetailViews = fileList.getRegisteredDetailViews();
expect(getDetailsStub.notCalled).toEqual(true);
expect(registeredDetailViews).toBeNull();
});
});
it('triggers file action when clicking on row if no details view configured', function() {
fileList.destroy();
fileList = new OCA.Files.FileList($('#app-content-files'), {
detailsViewEnabled: false
});
var updateDetailsViewStub = sinon.stub(fileList, '_updateDetailsView');
var actionStub = sinon.stub();
fileList.setFiles(testFiles);
fileList.fileActions.register(
'text/plain',
'Test',
OC.PERMISSION_ALL,
function() {
// Specify icon for hitory button
return OC.imagePath('core','actions/history');
},
actionStub
);
fileList.fileActions.setDefault('text/plain', 'Test');
var $tr = fileList.findFileEl('One.txt');
$tr.find('td.filesize').click();
expect(actionStub.calledOnce).toEqual(true);
expect(updateDetailsViewStub.notCalled).toEqual(true);
updateDetailsViewStub.restore();
});
it('highlights current file when clicked and updates sidebar', function() {
fileList.fileActions.setDefault('text/plain', 'Test');
var $tr = fileList.findFileEl('One.txt');
$tr.find('td.filesize').click();
expect($tr.hasClass('highlighted')).toEqual(true);
expect(fileList._detailsView.getFileInfo().id).toEqual(1);
});
it('keeps the last highlighted file when clicking outside', function() {
var $tr = fileList.findFileEl('One.txt');
$tr.find('td.filesize').click();
fileList.$el.find('tfoot').click();
expect($tr.hasClass('highlighted')).toEqual(true);
expect(fileList._detailsView.getFileInfo().id).toEqual(1);
});
it('removes last highlighted file when selecting via checkbox', function() {
var $tr = fileList.findFileEl('One.txt');
// select
$tr.find('td.filesize').click();
$tr.find('input:checkbox').click();
expect($tr.hasClass('highlighted')).toEqual(false);
// deselect
$tr.find('td.filesize').click();
$tr.find('input:checkbox').click();
expect($tr.hasClass('highlighted')).toEqual(false);
expect(fileList._detailsView.getFileInfo()).toEqual(null);
});
it('removes last highlighted file when selecting all files via checkbox', function() {
var $tr = fileList.findFileEl('One.txt');
// select
$tr.find('td.filesize').click();
fileList.$el.find('.select-all.checkbox').click();
expect($tr.hasClass('highlighted')).toEqual(false);
// deselect
$tr.find('td.filesize').click();
fileList.$el.find('.select-all.checkbox').click();
expect($tr.hasClass('highlighted')).toEqual(false);
expect(fileList._detailsView.getFileInfo()).toEqual(null);
});
it('closes sidebar whenever the currently highlighted file was removed from the list', function() {
jQuery.fx.off = true;
var $tr = fileList.findFileEl('One.txt');
$tr.find('td.filesize').click();
expect($tr.hasClass('highlighted')).toEqual(true);
expect(fileList._detailsView.getFileInfo().id).toEqual(1);
expect($('#app-sidebar').hasClass('disappear')).toEqual(false);
fileList.remove('One.txt');
// sidebar is removed on close before being
expect($('#app-sidebar').length).toEqual(0);
jQuery.fx.off = false;
});
it('returns the currently selected model instance when calling getModelForFile', function() {
var $tr = fileList.findFileEl('One.txt');
$tr.find('td.filesize').click();
var model1 = fileList.getModelForFile('One.txt');
var model2 = fileList.getModelForFile('One.txt');
model1.set('test', true);
// it's the same model
expect(model2).toEqual(model1);
var model3 = fileList.getModelForFile($tr);
expect(model3).toEqual(model1);
});
it('closes the sidebar when switching folders', function() {
jQuery.fx.off = true;
var $tr = fileList.findFileEl('One.txt');
$tr.find('td.filesize').click();
expect($('#app-sidebar').hasClass('disappear')).toEqual(false);
fileList.changeDirectory('/another');
expect($('#app-sidebar').length).toEqual(0);
jQuery.fx.off = false;
});
});
describe('File actions', function() {
it('Clicking on a file name will trigger default action', function() {
var actionStub = sinon.stub();

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=0)}([function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}]);
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=67)}({67:function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}});
//# sourceMappingURL=collaboration.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
(window.webpackJsonpFilesSharing=window.webpackJsonpFilesSharing||[]).push([[5],{348:function(e,o,i){"use strict";i.r(o);var n=i(3),l=i(349),r=i(350),u=i(351),s=i.n(u),a={name:"CollaborationView",components:{CollectionList:i(103).a},computed:{fileId:function(){return this.$root.model&&this.$root.model.id?""+this.$root.model.id:null},filename:function(){return this.$root.model&&this.$root.model.name?""+this.$root.model.name:""}}},d=i(0),c=Object(d.a)(a,(function(){var t=this.$createElement,e=this._self._c||t;return this.fileId?e("CollectionList",{attrs:{id:this.fileId,type:"file",name:this.filename}}):this._e()}),[],!1,null,null,null).exports;i.d(o,"Vue",(function(){return n.default})),i.d(o,"View",(function(){return c})),
/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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/>.
*
*/
n.default.prototype.t=t,r.Tooltip.options.defaultHtml=!1,n.default.component("PopoverMenu",r.PopoverMenu),n.default.directive("ClickOutside",s.a),n.default.directive("Tooltip",r.Tooltip),n.default.use(l.a)}}]);
//# sourceMappingURL=files_sharing.5.js.map?v=8e6d26aadf8403a07b5b

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -154,7 +154,11 @@ class ShareAPIController extends OCSController {
'share_type' => $share->getShareType(),
'uid_owner' => $share->getSharedBy(),
'displayname_owner' => $sharedBy !== null ? $sharedBy->getDisplayName() : $share->getSharedBy(),
// recipient permissions
'permissions' => $share->getPermissions(),
// current user permissions on this share
'can_edit' => $this->canEditShare($share),
'can_delete' => $this->canDeleteShare($share),
'stime' => $share->getShareTime()->getTimestamp(),
'parent' => null,
'expiration' => null,

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,254 @@
<!--
- @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">
<template v-if="share.canEdit">
<!-- 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>
</template>
<ActionButton v-if="share.canDelete"
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,779 @@
<!--
- @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="share.canEdit">
<!-- 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>
<!-- external sharing via url (social...) -->
<ActionLink v-for="({icon, url, name}, index) in externalActions"
:key="index"
:href="url(shareLink)"
:icon="icon"
target="_blank">
{{ name }}
</ActionLink>
<ActionButton v-if="share.canDelete"
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,43 @@
/**
* @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'
import TabSections from './services/TabSections'
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() })
}
Object.assign(window.OCA.Sharing, { ShareTabSections: new TabSections() })
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,469 @@
/**
* @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))
}
// PERMISSIONS Shortcuts for the CURRENT USER
// ! the permissions above are the share settings,
// ! meaning the permissions for the recipient
/**
* Can the current user EDIT this share ?
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get canEdit() {
return this.#share.can_edit === true
}
/**
* Can the current user DELETE this share ?
*
* @returns {boolean}
* @readonly
* @memberof Share
*/
get canDelete() {
return this.#share.can_delete === true
}
// 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.icon && action.name && action.url) {
this.#state.actions.push(action)
return true
}
console.error(`Invalid action 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

@ -0,0 +1,49 @@
/**
* @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @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/>.
*
*/
/**
* Callback for adding two numbers.
*
* @callback registerSectionCallback
* @param {Element} el The DOM element where the section is rendered
* @param {FileInfo} fileInfo current file FileInfo
*/
export default class TabSections {
#sections;
constructor() {
this.#sections = []
}
/**
* @param {registerSectionCallback} section To be called to mount the section to the sharing sidebar
*/
registerSection(section) {
this.#sections.push(section)
}
getSections() {
return this.#sections
}
}

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,8 @@
}
})
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'))
// 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)
}
// 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)
// register share breadcrumbs component
var shareTab = new OCA.Sharing.ShareTabView('sharing', {order: -20})
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,334 @@
<!--
- @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" />
<!-- projects -->
<CollectionList v-if="fileInfo"
:id="`${fileInfo.id}`"
type="file"
:name="fileInfo.name" />
<!-- additionnal entries, use it with cautious -->
<div v-for="(section, index) in sections"
:ref="'section-' + index"
:key="index"
class="sharingTab__additionalContent">
<component :is="section($refs['section-'+index], fileInfo)" :file-info="fileInfo" />
</div>
</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 { CollectionList } from 'nextcloud-vue-collections'
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,
CollectionList,
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

@ -577,6 +577,8 @@ class ShareAPIControllerTest extends TestCase {
'displayname_file_owner' => 'ownerDisplay',
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
];
$data[] = [$share, $expected];
@ -623,6 +625,8 @@ class ShareAPIControllerTest extends TestCase {
'displayname_file_owner' => 'ownerDisplay',
'mimetype' => 'myFolderMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
];
$data[] = [$share, $expected];
@ -676,6 +680,8 @@ class ShareAPIControllerTest extends TestCase {
'displayname_file_owner' => 'ownerDisplay',
'mimetype' => 'myFolderMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
];
$data[] = [$share, $expected];
@ -3431,6 +3437,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
// User backend up
@ -3462,6 +3470,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [
['owner', $owner],
['initiator', $initiator],
@ -3509,6 +3519,53 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
$share = \OC::$server->getShareManager()->newShare();
$share->setShareType(\OCP\Share::SHARE_TYPE_USER)
->setSharedWith('recipient')
->setSharedBy('initiator')
->setShareOwner('currentUser')
->setPermissions(\OCP\Constants::PERMISSION_READ)
->setNode($file)
->setShareTime(new \DateTime('2000-01-01T00:01:02'))
->setTarget('myTarget')
->setNote('personal note')
->setId(42);
// User backend down
$result[] = [
[
'id' => 42,
'share_type' => \OCP\Share::SHARE_TYPE_USER,
'uid_owner' => 'initiator',
'displayname_owner' => 'initiator',
'permissions' => 1,
'stime' => 946684862,
'parent' => null,
'expiration' => null,
'token' => null,
'uid_file_owner' => 'currentUser',
'displayname_file_owner' => 'currentUser',
'note' => 'personal note',
'label' => null,
'path' => 'file',
'item_type' => 'file',
'storage_id' => 'storageId',
'storage' => 100,
'item_source' => 3,
'file_source' => 3,
'file_parent' => 1,
'file_target' => 'myTarget',
'share_with' => 'recipient',
'share_with_displayname' => 'recipient',
'mail_send' => 0,
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => true,
'can_delete' => true,
], $share, [], false
];
@ -3554,6 +3611,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3597,6 +3656,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3646,6 +3707,8 @@ class ShareAPIControllerTest extends TestCase {
'url' => 'myLink',
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3696,6 +3759,8 @@ class ShareAPIControllerTest extends TestCase {
'url' => 'myLink',
'mimetype' => 'myMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3739,6 +3804,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myFolderMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3785,6 +3852,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myFolderMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3829,6 +3898,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myFolderMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3873,6 +3944,8 @@ class ShareAPIControllerTest extends TestCase {
'mail_send' => 0,
'mimetype' => 'myFolderMimeType',
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3933,6 +4006,8 @@ class ShareAPIControllerTest extends TestCase {
'password' => 'password',
'send_password_by_talk' => false,
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -3979,6 +4054,8 @@ class ShareAPIControllerTest extends TestCase {
'password' => 'password',
'send_password_by_talk' => true,
'hide_download' => 0,
'can_edit' => false,
'can_delete' => false,
], $share, [], false
];
@ -4120,6 +4197,8 @@ class ShareAPIControllerTest extends TestCase {
'mimetype' => 'myMimeType',
'hide_download' => 0,
'label' => '',
'can_edit' => false,
'can_delete' => false,
], $share, false, []
];
@ -4163,6 +4242,8 @@ class ShareAPIControllerTest extends TestCase {
'mimetype' => 'myMimeType',
'hide_download' => 0,
'label' => '',
'can_edit' => false,
'can_delete' => false,
], $share, true, [
'share_with_displayname' => 'recipientRoomName'
]

View File

@ -234,198 +234,6 @@ describe('OCA.Sharing.Util tests', function() {
expect($tr.find('.action-share').length).toEqual(0);
});
});
describe('Share action', function() {
var shareTab;
function makeDummyShareItem(displayName) {
return {
share_with_displayname: displayName
};
}
beforeEach(function() {
// make it look like not the "All files" list
fileList.id = 'test';
shareTab = fileList._detailsView._tabViews[0];
});
afterEach(function() {
shareTab = null;
});
it('clicking share action opens sidebar and share tab', function() {
var showDetailsViewStub = sinon.stub(fileList, 'showDetailsView');
fileList.setFiles([{
id: 1,
type: 'file',
name: 'One.txt',
path: '/subdir',
mimetype: 'text/plain',
size: 12,
permissions: OC.PERMISSION_ALL,
etag: 'abc'
}]);
var $tr = fileList.$el.find('tr:first');
$tr.find('.action-share').click();
expect(showDetailsViewStub.calledOnce).toEqual(true);
expect(showDetailsViewStub.getCall(0).args[0]).toEqual('One.txt');
expect(showDetailsViewStub.getCall(0).args[1]).toEqual('shareTabView');
showDetailsViewStub.restore();
});
it('adds share icon after sharing a non-shared file', function() {
var $action, $tr;
OC.Share.statuses = {};
fileList.setFiles([{
id: 1,
type: 'file',
name: 'One.txt',
path: '/subdir',
mimetype: 'text/plain',
size: 12,
permissions: OC.PERMISSION_ALL,
etag: 'abc'
}]);
$action = fileList.$el.find('tbody tr:first .action-share');
$tr = fileList.$el.find('tr:first');
$tr.find('.action-share').click();
// simulate updating shares
shareTab._dialog.model.set({
shares: [
{share_with_displayname: 'User One', share_with: 'User One'},
{share_with_displayname: 'User Two', share_with: 'User Two'},
{share_with_displayname: 'Group One', share_with: 'Group One'},
{share_with_displayname: 'Group Two', share_with: 'Group Two'}
]
});
expect($action.text().trim()).toEqual('Shared with Group One Shared with Group Two Shared with User One Shared with User Two');
expect($action.find('.icon').hasClass('icon-shared')).toEqual(true);
expect($action.find('.icon').hasClass('icon-public')).toEqual(false);
});
it('updates share icon after updating shares of a file', function() {
var $action, $tr;
OC.Share.statuses = {1: {link: false, path: '/subdir'}};
fileList.setFiles([{
id: 1,
type: 'file',
name: 'One.txt',
path: '/subdir',
mimetype: 'text/plain',
size: 12,
permissions: OC.PERMISSION_ALL,
etag: 'abc'
}]);
$action = fileList.$el.find('tbody tr:first .action-share');
$tr = fileList.$el.find('tr:first');
$tr.find('.action-share').click();
// simulate updating shares
shareTab._dialog.model.set({
shares: [
{share_with_displayname: 'User One', share_with: 'User One'},
{share_with_displayname: 'User Two', share_with: 'User Two'},
{share_with_displayname: 'User Three', share_with: 'User Three'}
]
});
expect($action.text().trim()).toEqual('Shared with User One Shared with User Three Shared with User Two');
expect($action.find('.icon').hasClass('icon-shared')).toEqual(true);
expect($action.find('.icon').hasClass('icon-public')).toEqual(false);
});
it('removes share icon after removing all shares from a file', function() {
var $action, $tr;
OC.Share.statuses = {1: {link: false, path: '/subdir'}};
fileList.setFiles([{
id: 1,
type: 'file',
name: 'One.txt',
path: '/subdir',
mimetype: 'text/plain',
size: 12,
permissions: OC.PERMISSION_ALL,
etag: 'abc',
recipients: 'User One, User Two'
}]);
$action = fileList.$el.find('tbody tr:first .action-share');
$tr = fileList.$el.find('tr:first');
$tr.find('.action-share').click();
// simulate updating shares
shareTab._dialog.model.set({
shares: []
});
expect($tr.attr('data-share-recipient-data')).not.toBeDefined();
});
it('keep share text after updating reshare', function() {
var $action, $tr;
OC.Share.statuses = {1: {link: false, path: '/subdir'}};
fileList.setFiles([{
id: 1,
type: 'file',
name: 'One.txt',
path: '/subdir',
mimetype: 'text/plain',
size: 12,
permissions: OC.PERMISSION_ALL,
etag: 'abc',
shareOwner: 'User One',
shareOwnerId: 'User One'
}]);
$action = fileList.$el.find('tbody tr:first .action-share');
$tr = fileList.$el.find('tr:first');
$tr.find('.action-share').click();
// simulate updating shares
shareTab._dialog.model.set({
shares: [{share_with_displayname: 'User Two'}]
});
expect($action.find('>span').text().trim()).toEqual('Shared by User One');
expect($action.find('.icon').hasClass('icon-shared')).toEqual(false);
expect($action.find('.icon').hasClass('icon-public')).toEqual(false);
});
it('keep share text after unsharing reshare', function() {
var $action, $tr;
OC.Share.statuses = {1: {link: false, path: '/subdir'}};
fileList.setFiles([{
id: 1,
type: 'file',
name: 'One.txt',
path: '/subdir',
mimetype: 'text/plain',
size: 12,
permissions: OC.PERMISSION_ALL,
etag: 'abc',
shareOwner: 'User One',
shareOwnerId: 'User One',
recipients: 'User Two',
recipientData: {'User Two': 'User Two'}
}]);
$action = fileList.$el.find('tbody tr:first .action-share');
$tr = fileList.$el.find('tr:first');
$tr.find('.action-share').click();
// simulate updating shares
shareTab._dialog.model.set({
shares: []
});
expect($tr.attr('data-share-recipient-data')).not.toBeDefined();
expect($action.find('>span').text().trim()).toEqual('Shared by User One');
expect($action.find('.icon').hasClass('icon-shared')).toEqual(false);
expect($action.find('.icon').hasClass('icon-public')).toEqual(false);
});
});
describe('Excluded lists', function() {
function createListThenAttach(listId) {
var fileActions = new OCA.Files.FileActions();
@ -513,20 +321,5 @@ describe('OCA.Sharing.Util tests', function() {
afterEach(function() {
shareTabSpy.restore();
});
it('updates fileInfoModel when shares changed', function() {
var changeHandler = sinon.stub();
fileInfoModel.on('change', changeHandler);
shareTabSpy.getCall(0).returnValue.trigger('sharesChanged', shareModel);
expect(changeHandler.calledOnce).toEqual(true);
expect(changeHandler.getCall(0).args[0].changed).toEqual({
shareTypes: [
OC.Share.SHARE_TYPE_USER,
OC.Share.SHARE_TYPE_REMOTE
]
});
});
});
});

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -30,7 +30,8 @@
_rendered: false,
className: 'systemTagsInfoView hidden',
className: 'systemTagsInfoView',
name: 'systemTags',
/**
* @type OC.SystemTags.SystemTagsInputField
@ -123,11 +124,7 @@
var appliedTags = collection.map(modelToSelection)
self._inputView.setData(appliedTags)
if (appliedTags.length !== 0) {
self.show()
} else {
self.hide()
}
self.show()
}
})
}

View File

@ -45,7 +45,7 @@ describe('OCA.SystemTags.SystemTagsInfoView tests', function() {
var fetchStub = sinon.stub(OC.SystemTags.SystemTagsMappingCollection.prototype, 'fetch');
var setDataStub = sinon.stub(OC.SystemTags.SystemTagsInputField.prototype, 'setData');
expect(view.$el.hasClass('hidden')).toEqual(true);
expect(view.$el.hasClass('hidden')).toEqual(false);
view.setFileInfo({id: '123'});
expect(view.$el.find('input[name=tags]').length).toEqual(1);
@ -211,10 +211,10 @@ describe('OCA.SystemTags.SystemTagsInfoView tests', function() {
expect(view.isVisible()).toBeTruthy();
});
it('is not visible after rendering', function() {
it('is visible after rendering', function() {
view.render();
expect(view.isVisible()).toBeFalsy();
expect(view.isVisible()).toBeTruthy();
});
it('shows and hides the element', function() {
view.show();

14
babel.config.js Normal file
View File

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

View File

@ -22,7 +22,6 @@
$expectedFiles = [
'.',
'..',
'.babelrc.js',
'.codecov.yml',
'.drone.yml',
'.eslintrc.js',
@ -46,6 +45,7 @@ $expectedFiles = [
'autotest-external.sh',
'autotest-js.sh',
'autotest.sh',
'babel.config.js',
'build',
'CHANGELOG.md',
'CODE_OF_CONDUCT.md',

View File

@ -206,7 +206,7 @@ Feature: sharees
Then "exact groups" sharees returned is empty
Then "groups" sharees returned is empty
Then "exact remotes" sharees returned are
| test@localhost | 6 | test@localhost |
| test (localhost) | 6 | test@localhost |
Then "remotes" sharees returned is empty
Scenario: Remote sharee for calendars not allowed

View File

@ -206,7 +206,7 @@ Feature: sharees_provisioningapiv2
Then "exact groups" sharees returned is empty
Then "groups" sharees returned is empty
Then "exact remotes" sharees returned are
| test@localhost | 6 | test@localhost |
| test (localhost) | 6 | test@localhost |
Then "remotes" sharees returned is empty
Scenario: Remote sharee for calendars not allowed

View File

@ -69,8 +69,10 @@
}
}
.systemTagsInfoView,
.systemtags-select2-container {
width: 100%;
.select2-choices .select2-search-choice.select2-locked .label {
opacity: 0.5;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

26
core/js/dist/main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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"

View File

@ -57,11 +57,15 @@ class RemoteGroupPlugin implements ISearchPlugin {
$resultType = new SearchResultType('remote_groups');
if ($this->enabled && $this->cloudIdManager->isValidCloudId($search) && $offset === 0) {
list($remoteGroup, $serverUrl) = $this->splitGroupRemote($search);
$result['exact'][] = [
'label' => $search,
'label' => $remoteGroup . " ($serverUrl)",
'guid' => $remoteGroup,
'name' => $remoteGroup,
'value' => [
'shareType' => Share::SHARE_TYPE_REMOTE_GROUP,
'shareWith' => $search,
'server' => $serverUrl,
],
];
}
@ -71,4 +75,20 @@ class RemoteGroupPlugin implements ISearchPlugin {
return true;
}
/**
* split group and remote from federated cloud id
*
* @param string $address federated share address
* @return array [user, remoteURL]
* @throws \InvalidArgumentException
*/
public function splitGroupRemote($address) {
try {
$cloudId = $this->cloudIdManager->resolveCloudId($address);
return [$cloudId->getUser(), $cloudId->getRemote()];
} catch (\InvalidArgumentException $e) {
throw new \InvalidArgumentException('Invalid Federated Cloud ID', 0, $e);
}
}
}

View File

@ -152,10 +152,13 @@ class RemotePlugin implements ISearchPlugin {
$localUser = $this->userManager->get($remoteUser);
if ($localUser === null || $search !== $localUser->getCloudId()) {
$result['exact'][] = [
'label' => $search,
'label' => $remoteUser . " ($serverUrl)",
'uuid' => $remoteUser,
'name' => $remoteUser,
'value' => [
'shareType' => Share::SHARE_TYPE_REMOTE,
'shareWith' => $search,
'server' => $serverUrl,
],
];
}

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

@ -126,8 +126,7 @@ Feature: app-files-sharing-link
Given I am logged in
And I share the link for "welcome.txt"
When I protect the shared link with the password "abcdef"
Then I see that the working icon for password protect is shown
And I see that the working icon for password protect is eventually not shown
Then I see that the password protect is disabled while loading
And I see that the link share is password protected
# As Talk is not enabled in the acceptance tests of the server the checkbox
# is never shown.

View File

@ -19,15 +19,6 @@ Feature: app-files-tags
# When I open the input field for tags in the details view
# Then I see that the input field for tags in the details view is shown
Scenario: show the input field for tags in the details view after the sharing tab has loaded
Given I am logged in
And I open the details view for "welcome.txt"
And I see that the details view is open
And I open the "Sharing" tab in the details view
And I see that the "Sharing" tab in the details view is eventually loaded
When I open the input field for tags in the details view
Then I see that the input field for tags in the details view is shown
Scenario: create tags using the Administration settings
Given I am logged in as the admin
And I visit the settings page

View File

@ -98,7 +98,7 @@ class FilesAppContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function fileNameInDetailsView() {
return Locator::forThe()->css(".fileName")->
return Locator::forThe()->css(".app-sidebar-header__title")->
descendantOf(self::detailsView())->
describedAs("File name in details view in Files app");
}
@ -107,7 +107,7 @@ class FilesAppContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function favoriteActionInFileDetailsInDetailsView() {
return Locator::forThe()->css(".action-favorite")->
return Locator::forThe()->css(".app-sidebar-header__star")->
descendantOf(self::fileDetailsInDetailsView())->
describedAs("Favorite action in file details in details view in Files app");
}
@ -143,7 +143,7 @@ class FilesAppContext implements Context, ActorAwareInterface {
* @return Locator
*/
private static function fileDetailsInDetailsView() {
return Locator::forThe()->css(".file-details")->
return Locator::forThe()->css(".app-sidebar-header__desc")->
descendantOf(self::detailsView())->
describedAs("File details in details view in Files app");
}
@ -205,7 +205,7 @@ class FilesAppContext implements Context, ActorAwareInterface {
* @return Locator
*/
private static function tabHeadersInDetailsView() {
return Locator::forThe()->css(".tabHeaders")->
return Locator::forThe()->css(".app-sidebar-tabs__nav")->
descendantOf(self::detailsView())->
describedAs("Tab headers in details view in Files app");
}
@ -214,7 +214,7 @@ class FilesAppContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function tabInDetailsViewNamed($tabName) {
return Locator::forThe()->xpath("//div[@id=//*[contains(concat(' ', normalize-space(@class), ' '), ' tabHeader ') and normalize-space() = '$tabName']/@data-tabid]")->
return Locator::forThe()->xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' app-sidebar-tabs__content ')]/section[@aria-labelledby = '$tabName' and @role = 'tabpanel']")->
descendantOf(self::detailsView())->
describedAs("Tab named $tabName in details view in Files app");
}
@ -223,7 +223,7 @@ class FilesAppContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function loadingIconForTabInDetailsViewNamed($tabName) {
return Locator::forThe()->css(".loading")->
return Locator::forThe()->css(".icon-loading")->
descendantOf(self::tabInDetailsViewNamed($tabName))->
describedAs("Loading icon for tab named $tabName in details view in Files app");
}

View File

@ -31,7 +31,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function sharedByLabel() {
return Locator::forThe()->css(".reshare")->
return Locator::forThe()->css(".sharing-entry__reshare")->
descendantOf(FilesAppContext::detailsView())->
describedAs("Shared by label in the details view in Files app");
}
@ -40,16 +40,34 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function shareWithInput() {
return Locator::forThe()->css(".shareWithField")->
return Locator::forThe()->css(".sharing-input .multiselect__input")->
descendantOf(FilesAppContext::detailsView())->
describedAs("Share with input in the details view in Files app");
}
/**
* @return Locator
*/
public static function shareWithInputResults() {
return Locator::forThe()->css(".sharing-input .multiselect__content-wrapper")->
descendantOf(FilesAppContext::detailsView())->
describedAs("Share with input results list in the details view in Files app");
}
/**
* @return Locator
*/
public static function shareWithInputResult($result) {
return Locator::forThe()->xpath("//li[contains(concat(' ', normalize-space(@class), ' '), ' multiselect__element ')]//span[normalize-space() = '$result']/ancestor::li")->
descendantOf(self::shareWithInputResults())->
describedAs("Share with input result from the results list in the details view in Files app");
}
/**
* @return Locator
*/
public static function shareeList() {
return Locator::forThe()->css(".shareeListView")->
return Locator::forThe()->css(".sharing-sharee-list")->
descendantOf(FilesAppContext::detailsView())->
describedAs("Sharee list in the details view in Files app");
}
@ -60,7 +78,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
public static function sharedWithRow($sharedWithName) {
// "username" class is used for any type of share, not only for shares
// with users.
return Locator::forThe()->xpath("//span[contains(concat(' ', normalize-space(@class), ' '), ' username ') and normalize-space() = '$sharedWithName']/ancestor::li")->
return Locator::forThe()->xpath("//li[contains(concat(' ', normalize-space(@class), ' '), ' sharing-entry ')]//h5[normalize-space() = '$sharedWithName']/ancestor::li")->
descendantOf(self::shareeList())->
describedAs("Shared with $sharedWithName row in the details view in Files app");
}
@ -69,7 +87,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function shareWithMenuButton($sharedWithName) {
return Locator::forThe()->css(".share-menu > .icon")->
return Locator::forThe()->css(".sharing-entry__actions > .action-item__menutoggle")->
descendantOf(self::sharedWithRow($sharedWithName))->
describedAs("Share with $sharedWithName menu button in the details view in Files app");
}
@ -78,7 +96,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function shareWithMenu($sharedWithName) {
return Locator::forThe()->css(".share-menu > .menu")->
return Locator::forThe()->css(".sharing-entry__actions > .action-item__menu")->
descendantOf(self::sharedWithRow($sharedWithName))->
describedAs("Share with $sharedWithName menu in the details view in Files app");
}
@ -108,7 +126,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function shareLinkRow() {
return Locator::forThe()->css(".linkShareView .shareWithList:first-child")->
return Locator::forThe()->css(".sharing-link-list .sharing-entry__link:first-child")->
descendantOf(FilesAppContext::detailsView())->
describedAs("Share link row in the details view in Files app");
}
@ -119,7 +137,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
public static function shareLinkAddNewButton() {
// When there is no link share the "Add new share" item is shown instead
// of the menu button as a direct child of ".share-menu".
return Locator::forThe()->css(".share-menu > .new-share")->
return Locator::forThe()->css(".action-item.icon-add")->
descendantOf(self::shareLinkRow())->
describedAs("Add new share link button in the details view in Files app");
}
@ -128,7 +146,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function copyLinkButton() {
return Locator::forThe()->css("a.clipboard-button")->
return Locator::forThe()->css("a.sharing-entry__copy")->
descendantOf(self::shareLinkRow())->
describedAs("Copy link button in the details view in Files app");
}
@ -137,7 +155,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function shareLinkMenuButton() {
return Locator::forThe()->css(".share-menu > .icon")->
return Locator::forThe()->css(".sharing-entry__actions .action-item__menutoggle")->
descendantOf(self::shareLinkRow())->
describedAs("Share link menu button in the details view in Files app");
}
@ -146,7 +164,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function shareLinkMenu() {
return Locator::forThe()->css(".share-menu > .menu")->
return Locator::forThe()->css(".sharing-entry__actions .action-item__menu")->
descendantOf(self::shareLinkRow())->
describedAs("Share link menu in the details view in Files app");
}
@ -209,16 +227,16 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function passwordProtectField() {
return Locator::forThe()->css(".linkPassText")->descendantOf(self::shareLinkMenu())->
return Locator::forThe()->css(".share-link-password input.action-input__input")->descendantOf(self::shareLinkMenu())->
describedAs("Password protect field in the details view in Files app");
}
/**
* @return Locator
*/
public static function passwordProtectWorkingIcon() {
return Locator::forThe()->css(".linkPassMenu .icon-loading-small")->descendantOf(self::shareLinkMenu())->
describedAs("Password protect working icon in the details view in Files app");
public static function disabledPasswordProtectField() {
return Locator::forThe()->css(".share-link-password input.action-input__input[disabled]")->descendantOf(self::shareLinkMenu())->
describedAs("Disabled password protect field in the details view in Files app");
}
/**
@ -257,7 +275,12 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
public function iShareWith($fileName, $shareWithName) {
$this->actor->find(FileListContext::shareActionForFile(FilesAppContext::currentSectionMainView(), $fileName), 10)->click();
$this->actor->find(self::shareWithInput(), 5)->setValue($shareWithName . "\r");
$this->actor->find(self::shareWithInput(), 5)->setValue($shareWithName);
// "setValue()" ends sending a tab, which unfocuses the input and causes
// the results to be hidden, so the input needs to be clicked to show
// the results again.
$this->actor->find(self::shareWithInput())->click();
$this->actor->find(self::shareWithInputResult($shareWithName), 5)->click();
}
/**
@ -269,7 +292,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
// Clicking on the menu item copies the link to the clipboard, but it is
// not possible to access that value from the acceptance tests. Due to
// this the value of the attribute that holds the URL is used instead.
$this->actor->getSharedNotebook()["shared link"] = $this->actor->find(self::copyLinkButton(), 2)->getWrappedElement()->getAttribute("data-clipboard-text");
$this->actor->getSharedNotebook()["shared link"] = $this->actor->find(self::copyLinkButton(), 2)->getWrappedElement()->getAttribute("href");
}
/**
@ -412,21 +435,16 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
}
/**
* @Then I see that the working icon for password protect is shown
* @Then I see that the password protect is disabled while loading
*/
public function iSeeThatTheWorkingIconForPasswordProtectIsShown() {
PHPUnit_Framework_Assert::assertNotNull($this->actor->find(self::passwordProtectWorkingIcon(), 10));
}
public function iSeeThatThePasswordProtectIsDisabledWhileLoading() {
PHPUnit_Framework_Assert::assertNotNull($this->actor->find(self::disabledPasswordProtectField(), 10));
/**
* @Then I see that the working icon for password protect is eventually not shown
*/
public function iSeeThatTheWorkingIconForPasswordProtectIsEventuallyNotShown() {
if (!WaitFor::elementToBeEventuallyNotShown(
$this->actor,
self::passwordProtectWorkingIcon(),
self::disabledPasswordProtectField(),
$timeout = 10 * $this->actor->getFindTimeoutMultiplier())) {
PHPUnit_Framework_Assert::fail("The working icon for password protect is still shown after $timeout seconds");
PHPUnit_Framework_Assert::fail("The password protect field is still disabled after $timeout seconds");
}
}
@ -477,8 +495,7 @@ class FilesAppSharingContext implements Context, ActorAwareInterface {
public function iShareTheLinkForProtectedByThePassword($fileName, $password) {
$this->iShareTheLinkFor($fileName);
$this->iProtectTheSharedLinkWithThePassword($password);
$this->iSeeThatTheWorkingIconForPasswordProtectIsShown();
$this->iSeeThatTheWorkingIconForPasswordProtectIsEventuallyNotShown();
$this->iSeeThatThePasswordProtectIsDisabledWhileLoading();
}
private function showShareLinkMenuIfNeeded() {

View File

@ -152,7 +152,7 @@ class RemotePluginTest extends TestCase {
'test@remote',
[],
true,
['remotes' => [], 'exact' => ['remotes' => [['label' => 'test@remote', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote']]]]],
['remotes' => [], 'exact' => ['remotes' => [['label' => 'test (remote)', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote', 'server' => 'remote'], 'uuid' => 'test', 'name' => 'test']]]],
false,
true,
],
@ -160,7 +160,7 @@ class RemotePluginTest extends TestCase {
'test@remote',
[],
false,
['remotes' => [], 'exact' => ['remotes' => [['label' => 'test@remote', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote']]]]],
['remotes' => [], 'exact' => ['remotes' => [['label' => 'test (remote)', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote', 'server' => 'remote'], 'uuid' => 'test', 'name' => 'test']]]],
false,
true,
],
@ -238,7 +238,7 @@ class RemotePluginTest extends TestCase {
],
],
true,
['remotes' => [['name' => 'User @ Localhost', 'label' => 'User @ Localhost (username@localhost)', 'uuid' => 'uid', 'type' => '', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'username@localhost', 'server' => 'localhost']]], 'exact' => ['remotes' => [['label' => 'test@remote', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote']]]]],
['remotes' => [['name' => 'User @ Localhost', 'label' => 'User @ Localhost (username@localhost)', 'uuid' => 'uid', 'type' => '', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'username@localhost', 'server' => 'localhost']]], 'exact' => ['remotes' => [['label' => 'test (remote)', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote', 'server' => 'remote'], 'uuid' => 'test', 'name' => 'test']]]],
false,
true,
],
@ -264,7 +264,7 @@ class RemotePluginTest extends TestCase {
],
],
false,
['remotes' => [], 'exact' => ['remotes' => [['label' => 'test@remote', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote']]]]],
['remotes' => [], 'exact' => ['remotes' => [['label' => 'test (remote)', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'test@remote', 'server' => 'remote'], 'uuid' => 'test', 'name' => 'test']]]],
false,
true,
],
@ -370,7 +370,7 @@ class RemotePluginTest extends TestCase {
],
],
false,
['remotes' => [], 'exact' => ['remotes' => [['label' => 'user space@remote', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'user space@remote']]]]],
['remotes' => [], 'exact' => ['remotes' => [['label' => 'user space (remote)', 'value' => ['shareType' => Share::SHARE_TYPE_REMOTE, 'shareWith' => 'user space@remote', 'server' => 'remote'], 'uuid' => 'user space', 'name' => 'user space']]]],
false,
true,
],

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,
@ -80,7 +81,10 @@ module.exports = []
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
// automatically detect necessary packages to
// transpile in the node_modules folder
exclude: /node_modules(?!(\/|\\)(p-finally|p-limit|p-locate|p-queue|p-timeout|p-try)(\/|\\))/
},
{
test: /\.(png|jpg|gif)$/,