mirror of https://github.com/nextcloud/server.git
feat(files): add systemtags view
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>pull/40475/head
parent
e0c778f769
commit
2845319187
@ -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()
|
||||
})
|
||||
})
|
@ -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
|
||||
})()
|
Loading…
Reference in New Issue