Merge pull request #40475 from nextcloud/feat/f2v/systemtags

pull/39720/head
John Molakvoæ 2 years ago committed by GitHub
commit 085568b75c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      apps/dav/lib/SystemTag/SystemTagsInUseCollection.php
  2. 14
      apps/files/src/actions/downloadAction.ts
  3. 7
      apps/files/src/components/FileEntry.vue
  4. 29
      apps/files/src/services/Favorites.ts
  5. 2
      apps/files/src/services/Files.ts
  6. 29
      apps/files/src/services/Recent.ts
  7. 9
      apps/files/src/views/FilesList.vue
  8. 3
      apps/files/src/views/Sidebar.vue
  9. 1
      apps/systemtags/composer/composer/autoload_classmap.php
  10. 1
      apps/systemtags/composer/composer/autoload_static.php
  11. 15
      apps/systemtags/lib/AppInfo/Application.php
  12. 40
      apps/systemtags/lib/Capabilities.php
  13. 131
      apps/systemtags/src/app.js
  14. 22
      apps/systemtags/src/css/systemtagsfilelist.scss
  15. 25
      apps/systemtags/src/init.ts
  16. 8
      apps/systemtags/src/services/api.ts
  17. 97
      apps/systemtags/src/services/systemtags.ts
  18. 355
      apps/systemtags/src/systemtagsfilelist.js
  19. 240
      apps/systemtags/tests/js/systemtagsfilelistSpec.js
  20. 4
      dist/core-common.js
  21. 2
      dist/core-common.js.map
  22. 4
      dist/files-main.js
  23. 2
      dist/files-main.js.map
  24. 4
      dist/files-sidebar.js
  25. 2
      dist/files-sidebar.js.map
  26. 3
      dist/systemtags-init.js
  27. 31
      dist/systemtags-init.js.LICENSE.txt
  28. 1
      dist/systemtags-init.js.map
  29. 3
      dist/systemtags-systemtags.js
  30. 1
      dist/systemtags-systemtags.js.map
  31. 1
      tests/karma.config.js
  32. 2
      webpack.modules.js

@ -99,8 +99,8 @@ class SystemTagsInUseCollection extends SimpleCollection {
$tag = new SystemTag((string)$tagData['id'], $tagData['name'], (bool)$tagData['visibility'], (bool)$tagData['editable']);
// read only, so we can submit the isAdmin parameter as false generally
$node = new SystemTagNode($tag, $user, false, $this->systemTagManager);
$node->setNumberOfFiles($tagData['number_files']);
$node->setReferenceFileId($tagData['ref_file_id']);
$node->setNumberOfFiles((int) $tagData['number_files']);
$node->setReferenceFileId((int) $tagData['ref_file_id']);
$children[] = $node;
}
return $children;

@ -47,7 +47,19 @@ export const action = new FileAction({
iconSvgInline: () => ArrowDownSvg,
enabled(nodes: Node[]) {
return nodes.length > 0 && nodes
if (nodes.length === 0) {
return false
}
// We can download direct dav files. But if we have
// some folders, we need to use the /apps/files/ajax/download.php
// endpoint, which only supports user root folder.
if (nodes.some(node => node.type === FileType.Folder)
&& nodes.some(node => !node.root?.startsWith('/files'))) {
return false
}
return nodes
.map(node => node.permissions)
.every(permission => (permission & Permission.READ) !== 0)
},

@ -190,6 +190,7 @@ import AccountGroupIcon from 'vue-material-design-icons/AccountGroup.vue'
import FileIcon from 'vue-material-design-icons/File.vue'
import FolderIcon from 'vue-material-design-icons/Folder.vue'
import KeyIcon from 'vue-material-design-icons/Key.vue'
import TagIcon from 'vue-material-design-icons/Tag.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import NetworkIcon from 'vue-material-design-icons/Network.vue'
import AccountPlusIcon from 'vue-material-design-icons/AccountPlus.vue'
@ -237,6 +238,7 @@ export default Vue.extend({
NcLoadingIcon,
NcTextField,
NetworkIcon,
TagIcon,
},
props: {
@ -381,6 +383,11 @@ export default Vue.extend({
return KeyIcon
}
// System tags
if (this.source?.attributes?.['is-tag']) {
return TagIcon
}
// Link and mail shared folders
const shareTypes = Object.values(this.source?.attributes?.['share-types'] || {}).flat() as number[]
if (shareTypes.some(type => type === ShareType.SHARE_TYPE_LINK || type === ShareType.SHARE_TYPE_EMAIL)) {

@ -28,6 +28,7 @@ import { getCurrentUser } from '@nextcloud/auth'
import { getClient, rootPath } from './WebdavClient'
import { getDavNameSpaces, getDavProperties, getDefaultPropfind } from './DavProperties'
import { resultToNode } from './Files'
const client = getClient()
@ -47,34 +48,6 @@ interface ResponseProps extends DAVResultResponseProps {
size: number,
}
const resultToNode = function(node: FileStat): File | Folder {
const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string
const nodeData = {
id: props?.fileid as number || 0,
source: generateRemoteUrl('dav' + rootPath + node.filename),
mtime: new Date(node.lastmod),
mime: node.mime as string,
size: props?.size as number || 0,
permissions,
owner,
root: rootPath,
attributes: {
...node,
...props,
hasPreview: props?.['has-preview'],
},
}
delete nodeData.attributes.props
return node.type === 'file'
? new File(nodeData)
: new Folder(nodeData)
}
export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
const propfindPayload = getDefaultPropfind()

@ -40,7 +40,7 @@ interface ResponseProps extends DAVResultResponseProps {
size: number,
}
const resultToNode = function(node: FileStat): File | Folder {
export const resultToNode = function(node: FileStat): File | Folder {
const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string

@ -28,6 +28,7 @@ import { getCurrentUser } from '@nextcloud/auth'
import { getClient, rootPath } from './WebdavClient'
import { getDavNameSpaces, getDavProperties } from './DavProperties'
import { resultToNode } from './Files'
const client = getClient(generateRemoteUrl('dav'))
@ -94,34 +95,6 @@ interface ResponseProps extends DAVResultResponseProps {
size: number,
}
const resultToNode = function(node: FileStat): File | Folder {
const props = node.props as ResponseProps
const permissions = davParsePermissions(props?.permissions)
const owner = getCurrentUser()?.uid as string
const nodeData = {
id: props?.fileid as number || 0,
source: generateRemoteUrl('dav' + node.filename),
mtime: new Date(node.lastmod),
mime: node.mime as string,
size: props?.size as number || 0,
permissions,
owner,
root: rootPath,
attributes: {
...node,
...props,
hasPreview: props?.['has-preview'],
},
}
delete nodeData.attributes.props
return node.type === 'file'
? new File(nodeData)
: new Folder(nodeData)
}
export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
const contentsResponse = await client.getDirectoryContents(path, {
details: true,

@ -328,12 +328,21 @@ export default Vue.extend({
},
},
mounted() {
this.fetchContent()
},
methods: {
async fetchContent() {
this.loading = true
const dir = this.dir
const currentView = this.currentView
if (!currentView) {
logger.debug('The current view doesn\'t exists or is not ready.', { currentView })
return
}
// If we have a cancellable promise ongoing, cancel it
if (typeof this.promise?.cancel === 'function') {
this.promise.cancel()

@ -91,6 +91,7 @@
import { emit } from '@nextcloud/event-bus'
import { encodePath } from '@nextcloud/paths'
import { File, Folder } from '@nextcloud/files'
import { getCapabilities } from '@nextcloud/capabilities'
import { getCurrentUser } from '@nextcloud/auth'
import { Type as ShareTypes } from '@nextcloud/sharing'
import $ from 'jquery'
@ -299,7 +300,7 @@ export default {
},
isSystemTagsEnabled() {
return OCA && 'SystemTags' in OCA
return getCapabilities()?.systemtags?.enabled === true
},
},
created() {

@ -11,6 +11,7 @@ return array(
'OCA\\SystemTags\\Activity\\Provider' => $baseDir . '/../lib/Activity/Provider.php',
'OCA\\SystemTags\\Activity\\Setting' => $baseDir . '/../lib/Activity/Setting.php',
'OCA\\SystemTags\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\SystemTags\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
'OCA\\SystemTags\\Controller\\LastUsedController' => $baseDir . '/../lib/Controller/LastUsedController.php',
'OCA\\SystemTags\\Search\\TagSearchProvider' => $baseDir . '/../lib/Search/TagSearchProvider.php',
'OCA\\SystemTags\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',

@ -26,6 +26,7 @@ class ComposerStaticInitSystemTags
'OCA\\SystemTags\\Activity\\Provider' => __DIR__ . '/..' . '/../lib/Activity/Provider.php',
'OCA\\SystemTags\\Activity\\Setting' => __DIR__ . '/..' . '/../lib/Activity/Setting.php',
'OCA\\SystemTags\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\SystemTags\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
'OCA\\SystemTags\\Controller\\LastUsedController' => __DIR__ . '/..' . '/../lib/Controller/LastUsedController.php',
'OCA\\SystemTags\\Search\\TagSearchProvider' => __DIR__ . '/..' . '/../lib/Search/TagSearchProvider.php',
'OCA\\SystemTags\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',

@ -28,6 +28,7 @@ namespace OCA\SystemTags\AppInfo;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\SystemTags\Search\TagSearchProvider;
use OCA\SystemTags\Activity\Listener;
use OCA\SystemTags\Capabilities;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@ -45,6 +46,7 @@ class Application extends App implements IBootstrap {
public function register(IRegistrationContext $context): void {
$context->registerSearchProvider(TagSearchProvider::class);
$context->registerCapability(Capabilities::class);
}
public function boot(IBootContext $context): void {
@ -56,7 +58,7 @@ class Application extends App implements IBootstrap {
LoadAdditionalScriptsEvent::class,
function () {
\OCP\Util::addScript('core', 'systemtags');
\OCP\Util::addScript(self::APP_ID, 'systemtags');
\OCP\Util::addInitScript(self::APP_ID, 'init');
}
);
@ -77,16 +79,5 @@ class Application extends App implements IBootstrap {
$dispatcher->addListener(MapperEvent::EVENT_ASSIGN, $mapperListener);
$dispatcher->addListener(MapperEvent::EVENT_UNASSIGN, $mapperListener);
});
\OCA\Files\App::getNavigationManager()->add(function () {
$l = \OC::$server->getL10N(self::APP_ID);
return [
'id' => 'systemtagsfilter',
'appname' => self::APP_ID,
'script' => 'list.php',
'order' => 25,
'name' => $l->t('Tags'),
];
});
}
}

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\SystemTags;
use OCP\Capabilities\ICapability;
class Capabilities implements ICapability {
/**
* @return array{systemtags: array{enabled: true}}
*/
public function getCapabilities() {
$capabilities = [
'systemtags' => [
'enabled' => true,
]
];
return $capabilities;
}
}

@ -1,131 +0,0 @@
/**
* Copyright (c) 2015 Vincent Petry <pvince81@owncloud.com>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Daniel Calviño Sánchez <danxuliu@gmail.com>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Vincent Petry <vincent@nextcloud.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
(function() {
if (!OCA.SystemTags) {
/**
* @namespace
*/
OCA.SystemTags = {}
}
OCA.SystemTags.App = {
initFileList($el) {
if (this._fileList) {
return this._fileList
}
const tagsParam = (new URL(window.location.href)).searchParams.get('tags')
const initialTags = tagsParam ? tagsParam.split(',').map(parseInt) : []
this._fileList = new OCA.SystemTags.FileList(
$el,
{
id: 'systemtags',
fileActions: this._createFileActions(),
config: OCA.Files.App.getFilesConfig(),
// The file list is created when a "show" event is handled,
// so it should be marked as "shown" like it would have been
// done if handling the event with the file list already
// created.
shown: true,
systemTagIds: initialTags,
}
)
this._fileList.appName = t('systemtags', 'Tags')
return this._fileList
},
removeFileList() {
if (this._fileList) {
this._fileList.$fileList.empty()
}
},
_createFileActions() {
// inherit file actions from the files app
const fileActions = new OCA.Files.FileActions()
// note: not merging the legacy actions because legacy apps are not
// compatible with the sharing overview and need to be adapted first
fileActions.registerDefaultActions()
fileActions.merge(OCA.Files.fileActions)
if (!this._globalActionsInitialized) {
// in case actions are registered later
this._onActionsUpdated = _.bind(this._onActionsUpdated, this)
OCA.Files.fileActions.on('setDefault.app-systemtags', this._onActionsUpdated)
OCA.Files.fileActions.on('registerAction.app-systemtags', this._onActionsUpdated)
this._globalActionsInitialized = true
}
// when the user clicks on a folder, redirect to the corresponding
// folder in the files app instead of opening it directly
fileActions.register('dir', 'Open', OC.PERMISSION_READ, '', function(filename, context) {
OCA.Files.App.setActiveView('files', { silent: true })
OCA.Files.App.fileList.changeDirectory(OC.joinPaths(context.$file.attr('data-path'), filename), true, true)
})
fileActions.setDefault('dir', 'Open')
return fileActions
},
_onActionsUpdated(ev) {
if (!this._fileList) {
return
}
if (ev.action) {
this._fileList.fileActions.registerAction(ev.action)
} else if (ev.defaultAction) {
this._fileList.fileActions.setDefault(
ev.defaultAction.mime,
ev.defaultAction.name
)
}
},
/**
* Destroy the app
*/
destroy() {
OCA.Files.fileActions.off('setDefault.app-systemtags', this._onActionsUpdated)
OCA.Files.fileActions.off('registerAction.app-systemtags', this._onActionsUpdated)
this.removeFileList()
this._fileList = null
delete this._globalActionsInitialized
},
}
})()
window.addEventListener('DOMContentLoaded', function() {
$('#app-content-systemtagsfilter').on('show', function(e) {
OCA.SystemTags.App.initFileList($(e.target))
})
$('#app-content-systemtagsfilter').on('hide', function() {
OCA.SystemTags.App.removeFileList()
})
})

@ -1,22 +0,0 @@
/*
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
#app-content-systemtagsfilter .select2-container {
width: 30%;
margin-left: 10px;
}
#app-sidebar .app-sidebar-header__action .tag-label {
cursor: pointer;
padding: 13px 0;
display: flex;
color: var(--color-text-light);
position: relative;
margin-top: -20px;
}

@ -20,10 +20,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import './actions/inlineSystemTagsAction.js'
import './app.js'
import './systemtagsfilelist.js'
import './css/systemtagsfilelist.scss'
import './actions/inlineSystemTagsAction.ts'
import { translate as t } from '@nextcloud/l10n'
import { Column, Node, View, getNavigation } from '@nextcloud/files'
import TagMultipleSvg from '@mdi/svg/svg/tag-multiple.svg?raw'
window.OCA.SystemTags = OCA.SystemTags
import { getContents } from './services/systemtags.js'
const Navigation = getNavigation()
Navigation.register(new View({
id: 'tags',
name: t('systemtags', 'Tags'),
caption: t('systemtags', 'List of tags and their associated files and folders.'),
emptyTitle: t('systemtags', 'No tags found'),
emptyCaption: t('systemtags', 'Tags you have created will show up here.'),
icon: TagMultipleSvg,
order: 25,
getContents,
}))

@ -19,19 +19,17 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { ServerTag, Tag, TagWithId } from '../types.js'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import { davClient } from './davClient.js'
import { formatTag, parseIdFromLocation, parseTags } from '../utils.js'
import { formatTag, parseIdFromLocation, parseTags } from '../utils'
import { logger } from '../logger.js'
import type { FileStat, ResponseDataDetailed } from 'webdav'
import type { ServerTag, Tag, TagWithId } from '../types.js'
const fetchTagsBody = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>

@ -0,0 +1,97 @@
/**
* @copyright Copyright (c) 2023 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* 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 type { FileStat, ResponseDataDetailed } from 'webdav'
import type { TagWithId } from '../types'
import { Folder, type ContentsWithRoot, Permission, getDavNameSpaces, getDavProperties } from '@nextcloud/files'
import { generateRemoteUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { fetchTags } from './api'
import { getClient } from '../../../files/src/services/WebdavClient'
import { resultToNode } from '../../../files/src/services/Files'
const formatReportPayload = (tagId: number) => `<?xml version="1.0"?>
<oc:filter-files ${getDavNameSpaces()}>
<d:prop>
${getDavProperties()}
</d:prop>
<oc:filter-rules>
<oc:systemtag>${tagId}</oc:systemtag>
</oc:filter-rules>
</oc:filter-files>`
const tagToNode = function(tag: TagWithId): Folder {
return new Folder({
id: tag.id,
source: generateRemoteUrl('dav/systemtags/' + tag.id),
owner: getCurrentUser()?.uid as string,
root: '/systemtags',
permissions: Permission.READ,
attributes: {
...tag,
'is-tag': true,
},
})
}
export const getContents = async (path = '/'): Promise<ContentsWithRoot> => {
// List tags in the root
const tagsCache = (await fetchTags()).filter(tag => tag.userVisible) as TagWithId[]
if (path === '/') {
return {
folder: new Folder({
id: 0,
source: generateRemoteUrl('dav/systemtags'),
owner: getCurrentUser()?.uid as string,
root: '/systemtags',
permissions: Permission.NONE,
}),
contents: tagsCache.map(tagToNode),
}
}
const tagId = parseInt(path.replace('/', ''), 10)
const tag = tagsCache.find(tag => tag.id === tagId)
if (!tag) {
throw new Error('Tag not found')
}
const folder = tagToNode(tag)
const contentsResponse = await getClient().getDirectoryContents('/', {
details: true,
// Only filter favorites if we're at the root
data: formatReportPayload(tagId),
headers: {
// Patched in WebdavClient.ts
method: 'REPORT',
},
}) as ResponseDataDetailed<FileStat[]>
return {
folder,
contents: contentsResponse.data.map(resultToNode),
}
}

@ -1,355 +0,0 @@
/**
* Copyright (c) 2016 Vincent Petry <pvince81@owncloud.com>
*
* @author Joas Schilling <coding@schilljs.com>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Vincent Petry <vincent@nextcloud.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
(function() {
/**
* @class OCA.SystemTags.FileList
* @augments OCA.Files.FileList
*
* @classdesc SystemTags file list.
* Contains a list of files filtered by system tags.
*
* @param {object} $el container element with existing markup for the .files-controls and a table
* @param {Array} [options] map of options, see other parameters
* @param {Array.<string>} [options.systemTagIds] array of system tag ids to
* filter by
*/
const FileList = function($el, options) {
this.initialize($el, options)
}
FileList.prototype = _.extend(
{},
OCA.Files.FileList.prototype,
/** @lends OCA.SystemTags.FileList.prototype */ {
id: 'systemtagsfilter',
appName: t('systemtags', 'Tagged files'),
/**
* Array of system tag ids to filter by
*
* @type {Array.<string>}
*/
_systemTagIds: [],
_lastUsedTags: [],
_clientSideSort: true,
_allowSelection: false,
_filterField: null,
/**
* @private
* @param {object} $el container element
* @param {object} [options] map of options, see other parameters
*/
initialize($el, options) {
OCA.Files.FileList.prototype.initialize.apply(this, arguments)
if (this.initialized) {
return
}
if (options && options.systemTagIds) {
this._systemTagIds = options.systemTagIds
}
OC.Plugins.attach('OCA.SystemTags.FileList', this)
const $controls = this.$el.find('.files-controls').empty()
_.defer(_.bind(this._getLastUsedTags, this))
this._initFilterField($controls)
},
destroy() {
this.$filterField.remove()
OCA.Files.FileList.prototype.destroy.apply(this, arguments)
},
_getLastUsedTags() {
const self = this
$.ajax({
type: 'GET',
url: OC.generateUrl('/apps/systemtags/lastused'),
success(response) {
self._lastUsedTags = response
},
})
},
_initFilterField($container) {
const self = this
this.$filterField = $('<input type="hidden" name="tags"/>')
this.$filterField.val(this._systemTagIds.join(','))
$container.append(this.$filterField)
this.$filterField.select2({
placeholder: t('systemtags', 'Select tags to filter by'),
allowClear: false,
multiple: true,
toggleSelect: true,
separator: ',',
query: _.bind(this._queryTagsAutocomplete, this),
id(tag) {
return tag.id
},
initSelection(element, callback) {
const val = $(element)
.val()
.trim()
if (val) {
const tagIds = val.split(',')
const tags = []
OC.SystemTags.collection.fetch({
success() {
_.each(tagIds, function(tagId) {
const tag = OC.SystemTags.collection.get(
tagId
)
if (!_.isUndefined(tag)) {
tags.push(tag.toJSON())
}
})
callback(tags)
self._onTagsChanged({ target: element })
},
})
} else {
// eslint-disable-next-line n/no-callback-literal
callback([])
}
},
formatResult(tag) {
return OC.SystemTags.getDescriptiveTag(tag)
},
formatSelection(tag) {
return OC.SystemTags.getDescriptiveTag(tag).outerHTML
},
sortResults(results) {
results.sort(function(a, b) {
const aLastUsed = self._lastUsedTags.indexOf(a.id)
const bLastUsed = self._lastUsedTags.indexOf(b.id)
if (aLastUsed !== bLastUsed) {
if (bLastUsed === -1) {
return -1
}
if (aLastUsed === -1) {
return 1
}
return aLastUsed < bLastUsed ? -1 : 1
}
// Both not found
return OC.Util.naturalSortCompare(a.name, b.name)
})
return results
},
escapeMarkup(m) {
// prevent double markup escape
return m
},
formatNoMatches() {
return t('systemtags', 'No tags found')
},
})
this.$filterField.parent().children('.select2-container').attr('aria-expanded', 'false')
this.$filterField.on('select2-open', () => {
this.$filterField.parent().children('.select2-container').attr('aria-expanded', 'true')
})
this.$filterField.on('select2-close', () => {
this.$filterField.parent().children('.select2-container').attr('aria-expanded', 'false')
})
this.$filterField.on(
'change',
_.bind(this._onTagsChanged, this)
)
return this.$filterField
},
/**
* Autocomplete function for dropdown results
*
* @param {object} query select2 query object
*/
_queryTagsAutocomplete(query) {
OC.SystemTags.collection.fetch({
success() {
const results = OC.SystemTags.collection.filterByName(
query.term
)
query.callback({
results: _.invoke(results, 'toJSON'),
})
},
})
},
/**
* Event handler for when the URL changed
*
* @param {Event} e the urlchanged event
*/
_onUrlChanged(e) {
if (e.dir) {
const tags = _.filter(e.dir.split('/'), function(val) {
return val.trim() !== ''
})
this.$filterField.select2('val', tags || [])
this._systemTagIds = tags
this.reload()
}
},
_onTagsChanged(ev) {
const val = $(ev.target)
.val()
.trim()
if (val !== '') {
this._systemTagIds = val.split(',')
} else {
this._systemTagIds = []
}
this.$el.trigger(
$.Event('changeDirectory', {
dir: this._systemTagIds.join('/'),
})
)
this.reload()
},
updateEmptyContent() {
const dir = this.getCurrentDirectory()
if (dir === '/') {
// root has special permissions
if (!this._systemTagIds.length) {
// no tags selected
this.$el
.find('.emptyfilelist.emptycontent')
.html(
'<div class="icon-systemtags"></div>'
+ '<h2>'
+ t(
'systemtags',
'Please select tags to filter by'
)
+ '</h2>'
)
} else {
// tags selected but no results
this.$el
.find('.emptyfilelist.emptycontent')
.html(
'<div class="icon-systemtags"></div>'
+ '<h2>'
+ t(
'systemtags',
'No files found for the selected tags'
)
+ '</h2>'
)
}
this.$el
.find('.emptyfilelist.emptycontent')
.toggleClass('hidden', !this.isEmpty)
this.$el
.find('.files-filestable thead th')
.toggleClass('hidden', this.isEmpty)
} else {
OCA.Files.FileList.prototype.updateEmptyContent.apply(
this,
arguments
)
}
},
getDirectoryPermissions() {
return OC.PERMISSION_READ | OC.PERMISSION_DELETE
},
updateStorageStatistics() {
// no op because it doesn't have
// storage info like free space / used space
},
reload() {
// there is only root
this._setCurrentDir('/', false)
if (!this._systemTagIds.length) {
// don't reload
this.updateEmptyContent()
this.setFiles([])
return $.Deferred().resolve()
}
this._selectedFiles = {}
this._selectionSummary.clear()
if (this._currentFileModel) {
this._currentFileModel.off()
}
this._currentFileModel = null
this.$el.find('.select-all').prop('checked', false)
this.showMask()
this._reloadCall = this.filesClient.getFilteredFiles(
{
systemTagIds: this._systemTagIds,
},
{
properties: this._getWebdavProperties(),
}
)
if (this._detailsView) {
// close sidebar
this._updateDetailsView(null)
}
const callBack = this.reloadCallback.bind(this)
return this._reloadCall.then(callBack, callBack)
},
reloadCallback(status, result) {
if (result) {
// prepend empty dir info because original handler
result.unshift({})
}
return OCA.Files.FileList.prototype.reloadCallback.call(
this,
status,
result
)
},
}
)
OCA.SystemTags.FileList = FileList
})()

@ -1,240 +0,0 @@
/**
* Copyright (c) 2016 Vincent Petry <pvince81@owncloud.com>
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Vincent Petry <vincent@nextcloud.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/
describe('OCA.SystemTags.FileList tests', function() {
var FileInfo = OC.Files.FileInfo;
var fileList;
beforeEach(function() {
// init parameters and test table elements
$('#testArea').append(
'<div id="app-content">' +
// init horrible parameters
'<input type="hidden" id="permissions" value="31"></input>' +
'<div class="files-controls"></div>' +
// dummy table
// TODO: at some point this will be rendered by the fileList class itself!
'<table class="files-filestable">' +
'<thead><tr>' +
'<th class="hidden column-name">' +
'<input type="checkbox" id="select_all_files" class="select-all">' +
'<a class="name columntitle" data-sort="name"><span>Name</span><span class="sort-indicator"></span></a>' +
'<span class="selectedActions hidden"></span>' +
'</th>' +
'<th class="hidden column-mtime">' +
'<a class="columntitle" data-sort="mtime"><span class="sort-indicator"></span></a>' +
'</th>' +
'</tr></thead>' +
'<tbody class="files-fileList"></tbody>' +
'<tfoot></tfoot>' +
'</table>' +
'<div class="emptyfilelist emptycontent">Empty content message</div>' +
'</div>'
);
});
afterEach(function() {
fileList.destroy();
fileList = undefined;
});
describe('filter field', function() {
var select2Stub, oldCollection, fetchTagsStub;
var $tagsField;
beforeEach(function() {
fetchTagsStub = sinon.stub(OC.SystemTags.SystemTagsCollection.prototype, 'fetch');
select2Stub = sinon.stub($.fn, 'select2');
oldCollection = OC.SystemTags.collection;
OC.SystemTags.collection = new OC.SystemTags.SystemTagsCollection([
{
id: '123',
name: 'abc'
},
{
id: '456',
name: 'def'
}
]);
fileList = new OCA.SystemTags.FileList(
$('#app-content'), {
systemTagIds: []
}
);
$tagsField = fileList.$el.find('[name=tags]');
});
afterEach(function() {
select2Stub.restore();
fetchTagsStub.restore();
OC.SystemTags.collection = oldCollection;
});
it('inits select2 on filter field', function() {
expect(select2Stub.calledOnce).toEqual(true);
});
it('uses global system tags collection', function() {
var callback = sinon.stub();
var opts = select2Stub.firstCall.args[0];
$tagsField.val('123');
opts.initSelection($tagsField, callback);
expect(callback.notCalled).toEqual(true);
expect(fetchTagsStub.calledOnce).toEqual(true);
fetchTagsStub.yieldTo('success', fetchTagsStub.thisValues[0]);
expect(callback.calledOnce).toEqual(true);
expect(callback.lastCall.args[0]).toEqual([
OC.SystemTags.collection.get('123').toJSON()
]);
});
it('fetches tag list from the global collection', function() {
var callback = sinon.stub();
var opts = select2Stub.firstCall.args[0];
$tagsField.val('123');
opts.query({
term: 'de',
callback: callback
});
expect(fetchTagsStub.calledOnce).toEqual(true);
expect(callback.notCalled).toEqual(true);
fetchTagsStub.yieldTo('success', fetchTagsStub.thisValues[0]);
expect(callback.calledOnce).toEqual(true);
expect(callback.lastCall.args[0]).toEqual({
results: [
OC.SystemTags.collection.get('456').toJSON()
]
});
});
it('reloads file list after selection', function() {
var reloadStub = sinon.stub(fileList, 'reload');
$tagsField.val('456,123').change();
expect(reloadStub.calledOnce).toEqual(true);
reloadStub.restore();
});
it('updates URL after selection', function() {
var handler = sinon.stub();
fileList.$el.on('changeDirectory', handler);
$tagsField.val('456,123').change();
expect(handler.calledOnce).toEqual(true);
expect(handler.lastCall.args[0].dir).toEqual('456/123');
});
it('updates tag selection when url changed', function() {
fileList.$el.trigger(new $.Event('urlChanged', {dir: '456/123'}));
expect(select2Stub.lastCall.args[0]).toEqual('val');
expect(select2Stub.lastCall.args[1]).toEqual(['456', '123']);
});
});
describe('loading results', function() {
var getFilteredFilesSpec, requestDeferred;
beforeEach(function() {
requestDeferred = new $.Deferred();
getFilteredFilesSpec = sinon.stub(OC.Files.Client.prototype, 'getFilteredFiles')
.returns(requestDeferred.promise());
});
afterEach(function() {
getFilteredFilesSpec.restore();
});
it('renders empty message when no tags were set', function() {
fileList = new OCA.SystemTags.FileList(
$('#app-content'), {
systemTagIds: []
}
);
fileList.reload();
expect(fileList.$el.find('.emptyfilelist.emptycontent').hasClass('hidden')).toEqual(false);
expect(getFilteredFilesSpec.notCalled).toEqual(true);
});
it('render files', function(done) {
fileList = new OCA.SystemTags.FileList(
$('#app-content'), {
systemTagIds: ['123', '456']
}
);
var reloading = fileList.reload();
expect(getFilteredFilesSpec.calledOnce).toEqual(true);
expect(getFilteredFilesSpec.lastCall.args[0].systemTagIds).toEqual(['123', '456']);
var testFiles = [new FileInfo({
id: 1,
type: 'file',
name: 'One.txt',
mimetype: 'text/plain',
mtime: 123456789,
size: 12,
etag: 'abc',
permissions: OC.PERMISSION_ALL
}), new FileInfo({
id: 2,
type: 'file',
name: 'Two.jpg',
mimetype: 'image/jpeg',
mtime: 234567890,
size: 12049,
etag: 'def',
permissions: OC.PERMISSION_ALL
}), new FileInfo({
id: 3,
type: 'file',
name: 'Three.pdf',
mimetype: 'application/pdf',
mtime: 234560000,
size: 58009,
etag: '123',
permissions: OC.PERMISSION_ALL
}), new FileInfo({
id: 4,
type: 'dir',
name: 'somedir',
mimetype: 'httpd/unix-directory',
mtime: 134560000,
size: 250,
etag: '456',
permissions: OC.PERMISSION_ALL
})];
requestDeferred.resolve(207, testFiles);
return reloading.then(function() {
expect(fileList.$el.find('.emptyfilelist.emptycontent').hasClass('hidden')).toEqual(true);
expect(fileList.$el.find('tbody>tr').length).toEqual(4);
}).then(done, done);
});
});
});

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

File diff suppressed because one or more lines are too long

@ -1,10 +1,9 @@
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl>
* @copyright 2023 Christopher Ng <chrng8@gmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
@ -15,7 +14,7 @@
*
* 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
* 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
@ -44,27 +43,3 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* Copyright (c) 2016 Vincent Petry <pvince81@owncloud.com>
*
* @author Joas Schilling <coding@schilljs.com>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Vincent Petry <vincent@nextcloud.com>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/

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

@ -65,7 +65,6 @@ module.exports = function(config) {
],
testFiles: ['apps/files_sharing/tests/js/*.js']
},
'systemtags',
'files_trashbin',
];
}

@ -99,7 +99,7 @@ module.exports = {
'vue-settings-admin-sharebymail': path.join(__dirname, 'apps/sharebymail/src', 'main-admin.js'),
},
systemtags: {
systemtags: path.join(__dirname, 'apps/systemtags/src', 'systemtags.js'),
init: path.join(__dirname, 'apps/systemtags/src', 'init.ts'),
},
theming: {
'personal-theming': path.join(__dirname, 'apps/theming/src', 'personal-settings.js'),

Loading…
Cancel
Save