feat(files): add systemtags view

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
pull/40475/head
John Molakvoæ (skjnldsv) 8 months ago
parent e0c778f769
commit 2845319187
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF

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

@ -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()
@ -373,7 +382,8 @@ export default Vue.extend({
this.pathsStore.addPath({ service: currentView.id, fileid: node.fileid, path: join(dir, node.basename) })
})
} catch (error) {
logger.error('Error while fetching content', { error })
throw error
// logger.error('Error while fetching content', { error })
} finally {
this.loading = false
}

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

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

@ -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: 'systemtags',
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,98 @@
/**
* @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'
let tagsCache = [] as TagWithId[]
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
tagsCache = await fetchTags()
if (path === '/') {
return {
folder: new Folder({
id: 0,
source: generateRemoteUrl('dav/systemtags'),
owner: getCurrentUser()?.uid as string,
root: '/systemtags',
}),
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
})()

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