diff options
author | skjnldsv <skjnldsv@protonmail.com> | 2024-10-17 12:50:58 +0200 |
---|---|---|
committer | skjnldsv <skjnldsv@protonmail.com> | 2024-10-29 09:08:30 +0100 |
commit | 405a267408603f0257da2451d515a47c4b42e5cf (patch) | |
tree | 32a59a768a4f5869b652050e8373baded958b4ba /apps | |
parent | 6601c50b6d04611ded3e289e66cc2a0decaea67d (diff) | |
download | nextcloud-server-405a267408603f0257da2451d515a47c4b42e5cf.tar.gz nextcloud-server-405a267408603f0257da2451d515a47c4b42e5cf.zip |
feat(systemtags): add bulk tagging action
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/systemtags/src/components/SystemTagPicker.vue | 294 | ||||
-rw-r--r-- | apps/systemtags/src/files_actions/bulkSystemTagsAction.ts | 47 | ||||
-rw-r--r-- | apps/systemtags/src/files_actions/inlineSystemTagsAction.ts | 13 | ||||
-rw-r--r-- | apps/systemtags/src/files_actions/openInFilesAction.ts | 10 | ||||
-rw-r--r-- | apps/systemtags/src/files_views/systemtagsView.ts | 4 | ||||
-rw-r--r-- | apps/systemtags/src/init.ts | 8 | ||||
-rw-r--r-- | apps/systemtags/src/services/logger.ts | 10 | ||||
-rw-r--r-- | apps/systemtags/src/utils.ts | 11 |
8 files changed, 379 insertions, 18 deletions
diff --git a/apps/systemtags/src/components/SystemTagPicker.vue b/apps/systemtags/src/components/SystemTagPicker.vue new file mode 100644 index 00000000000..28fff28be78 --- /dev/null +++ b/apps/systemtags/src/components/SystemTagPicker.vue @@ -0,0 +1,294 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcDialog data-cy-systemtags-picker + :name="t('systemtags', 'Manage tags')" + :open="opened" + class="systemtags-picker" + close-on-click-outside + out-transition + @update:open="onCancel"> + <!-- Search or create input --> + <div class="systemtags-picker__create"> + <NcTextField :value.sync="input" + :label="t('systemtags', 'Search or create tag')"> + <TagIcon :size="20" /> + </NcTextField> + <NcButton> + {{ t('systemtags', 'Create tag') }} + </NcButton> + </div> + + <!-- Tags list --> + <div class="systemtags-picker__tags"> + <NcCheckboxRadioSwitch v-for="tag in filteredTags" + :key="tag.id" + :label="tag.displayName" + :checked="isChecked(tag)" + :indeterminate="isIndeterminate(tag)" + :disabled="!tag.canAssign" + @update:checked="onCheckUpdate(tag, $event)"> + {{ formatTagName(tag) }} + </NcCheckboxRadioSwitch> + </div> + + <!-- Note --> + <div class="systemtags-picker__note"> + <NcNoteCard v-if="!hasChanges" type="info"> + {{ t('systemtags', 'Select or create tags to apply to all selected files') }} + </NcNoteCard> + <NcNoteCard v-else type="info"> + <span v-html="statusMessage" /> + </NcNoteCard> + </div> + + <template #actions> + <NcButton type="tertiary" @click="onCancel"> + {{ t('systemtags', 'Cancel') }} + </NcButton> + <NcButton :disabled="!hasChanges" @click="onSubmit"> + {{ t('systemtags', 'Apply changes') }} + </NcButton> + </template> + </NcDialog> +</template> + +<script lang="ts"> +import type { Node } from '@nextcloud/files' +import type { PropType } from 'vue' +import type { TagWithId } from '../types' + +import { defineComponent } from 'vue' +import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import TagIcon from 'vue-material-design-icons/Tag.vue' + +import logger from '../services/logger' +import { getNodeSystemTags } from '../utils' +import { showInfo } from '@nextcloud/dialogs' + +type TagListCount = { + string: number +} + +export default defineComponent({ + name: 'SystemTagPicker', + + components: { + NcButton, + NcCheckboxRadioSwitch, + NcDialog, + NcNoteCard, + NcTextField, + TagIcon, + }, + + props: { + nodes: { + type: Array as PropType<Node[]>, + required: true, + }, + + tags: { + type: Array as PropType<TagWithId[]>, + default: () => [], + }, + }, + + setup() { + return { + emit, + t, + } + }, + + data() { + return { + input: '', + opened: true, + tagList: {} as TagListCount, + + toAdd: [] as TagWithId[], + toRemove: [] as TagWithId[], + } + }, + + computed: { + filteredTags(): TagWithId[] { + if (this.input.trim() === '') { + return this.tags + } + + return this.tags + .filter(tag => tag.displayName.normalize().includes(this.input.normalize())) + }, + + hasChanges(): boolean { + return this.toAdd.length > 0 || this.toRemove.length > 0 + }, + + statusMessage(): string { + if (this.toAdd.length === 1 && this.toRemove.length === 1) { + return t('systemtags', '{tag1} will be set and {tag2} will be removed from {count} files.', { + tag1: this.toAdd[0].displayName, + tag2: this.toRemove[0].displayName, + count: this.nodes.length, + }) + } + + const tagsAdd = this.toAdd.map(tag => tag.displayName) + const lastTagAdd = tagsAdd.pop() as string + const tagsRemove = this.toRemove.map(tag => tag.displayName) + const lastTagRemove = tagsRemove.pop() as string + + const addStringSingular = t('systemtags', '{tag} will be set to {count} files.', { + tag: this.toAdd[0]?.displayName, + count: this.nodes.length, + }) + + const removeStringSingular = t('systemtags', '{tag} will be removed from {count} files.', { + tag: this.toRemove[0]?.displayName, + count: this.nodes.length, + }) + + const addStringPlural = t('systemtags', '{tags} and {lastTag} will be set to {count} files.', { + tags: tagsAdd.join(', '), + lastTag: lastTagAdd, + count: this.nodes.length, + }) + + const removeStringPlural = t('systemtags', '{tags} and {lastTag} will be removed from {count} files.', { + tags: tagsRemove.join(', '), + lastTag: lastTagRemove, + count: this.nodes.length, + }) + + // Singular + if (this.toAdd.length === 1 && this.toRemove.length === 0) { + return addStringSingular + } + if (this.toAdd.length === 0 && this.toRemove.length === 1) { + return removeStringSingular + } + + // Plural + if (this.toAdd.length > 1 && this.toRemove.length === 0) { + return addStringPlural + } + if (this.toAdd.length === 0 && this.toRemove.length > 1) { + return removeStringPlural + } + + // Mixed + if (this.toAdd.length > 1 && this.toRemove.length === 1) { + return `${addStringPlural}<br>${removeStringSingular}` + } + if (this.toAdd.length === 1 && this.toRemove.length > 1) { + return `${addStringSingular}<br>${removeStringPlural}` + } + + // Both plural + return `${addStringPlural}<br>${removeStringPlural}` + }, + }, + + beforeMount() { + // Efficient way of counting tags and their occurrences + this.tagList = this.nodes.reduce((acc: TagListCount, node: Node) => { + const tags = getNodeSystemTags(node) || [] + tags.forEach(tag => { + acc[tag] = (acc[tag] || 0) + 1 + }) + return acc + }, {} as TagListCount) as TagListCount + }, + + methods: { + formatTagName(tag: TagWithId): string { + if (tag.userVisible) { + return t('systemtags', '{displayName} (hidden)', { displayName: tag.displayName }) + } + + if (tag.userAssignable) { + return t('systemtags', '{displayName} (restricted)', { displayName: tag.displayName }) + } + + return tag.displayName + }, + + isChecked(tag: TagWithId): boolean { + return this.tagList[tag.displayName] === this.nodes.length + }, + + isIndeterminate(tag: TagWithId): boolean { + return this.tagList[tag.displayName] + && this.tagList[tag.displayName] !== 0 + && this.tagList[tag.displayName] !== this.nodes.length + }, + + onCheckUpdate(tag: TagWithId, checked: boolean) { + if (checked) { + this.toAdd.push(tag) + this.toRemove = this.toRemove.filter(search => search.id !== tag.id) + this.tagList[tag.displayName] = this.nodes.length + } else { + this.toRemove.push(tag) + this.toAdd = this.toAdd.filter(search => search.id !== tag.id) + this.tagList[tag.displayName] = 0 + } + }, + + onSubmit() { + logger.debug('onSubmit') + this.$emit('close', null) + }, + + onCancel() { + this.opened = false + showInfo(t('systemtags', 'File tags modification canceled')) + this.$emit('close', null) + }, + }, +}) +</script> + +<style scoped lang="scss"> +// Common sticky properties +.systemtags-picker__create, +.systemtags-picker__note { + position: sticky; + z-index: 9; + background-color: var(--color-main-background); +} + +.systemtags-picker__create { + display: flex; + top: 0; + gap: 8px; + padding-block-end: 8px; + align-items: flex-end; + + button { + flex-shrink: 0; + } +} + +.systemtags-picker__note { + bottom: 0; + padding-block: 8px; + + & > div { + margin: 0 !important; + } +} + +</style> diff --git a/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts b/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts new file mode 100644 index 00000000000..72cb49952fc --- /dev/null +++ b/apps/systemtags/src/files_actions/bulkSystemTagsAction.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { type Node } from '@nextcloud/files' + +import { defineAsyncComponent } from 'vue' +import { FileAction } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' +import TagMultipleSvg from '@mdi/svg/svg/tag-multiple.svg?raw' +import { getCurrentUser } from '@nextcloud/auth' + +import { spawnDialog } from '@nextcloud/dialogs' +import { fetchTags } from '../services/api' + +export const action = new FileAction({ + id: 'systemtags:bulk', + displayName: () => t('systemtags', 'Manage tags'), + iconSvgInline: () => TagMultipleSvg, + + enabled(nodes) { + // Only for multiple nodes + if (nodes.length <= 1) { + return false + } + + // If the user is not logged in, the action is not available + return getCurrentUser() !== null + }, + + async exec() { + return null + }, + + async execBatch(nodes: Node[]) { + const tags = await fetchTags() + const response = await new Promise<null|boolean>((resolve) => { + spawnDialog(defineAsyncComponent(() => import('../components/SystemTagPicker.vue')), { + nodes, + tags, + }, (status) => { + resolve(status as null|boolean) + }) + }) + return Array(nodes.length).fill(response) + }, +}) diff --git a/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts b/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts index 46b483129be..2a7ee3e7eb2 100644 --- a/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts +++ b/apps/systemtags/src/files_actions/inlineSystemTagsAction.ts @@ -4,19 +4,10 @@ */ import type { Node } from '@nextcloud/files' import { FileAction } from '@nextcloud/files' -import { translate as t } from '@nextcloud/l10n' +import { t } from '@nextcloud/l10n' import '../css/fileEntryInlineSystemTags.scss' - -const getNodeSystemTags = function(node: Node): string[] { - const tags = node.attributes?.['system-tags']?.['system-tag'] as string|string[]|undefined - - if (tags === undefined) { - return [] - } - - return [tags].flat() -} +import { getNodeSystemTags } from '../utils' const renderTag = function(tag: string, isMore = false): HTMLElement { const tagElement = document.createElement('li') diff --git a/apps/systemtags/src/files_actions/openInFilesAction.ts b/apps/systemtags/src/files_actions/openInFilesAction.ts index 695166bbcbd..806413de917 100644 --- a/apps/systemtags/src/files_actions/openInFilesAction.ts +++ b/apps/systemtags/src/files_actions/openInFilesAction.ts @@ -2,8 +2,12 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { translate as t } from '@nextcloud/l10n' -import { type Node, FileType, FileAction, DefaultType } from '@nextcloud/files' +import { type Node } from '@nextcloud/files' + +import { FileType, FileAction, DefaultType } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +import { systemTagsViewId } from '../files_views/systemtagsView' export const action = new FileAction({ id: 'systemtags:open-in-files', @@ -12,7 +16,7 @@ export const action = new FileAction({ enabled(nodes, view) { // Only for the system tags view - if (view.id !== 'tags') { + if (view.id !== systemTagsViewId) { return false } // Only for single nodes diff --git a/apps/systemtags/src/files_views/systemtagsView.ts b/apps/systemtags/src/files_views/systemtagsView.ts index 9012e5e8c6b..46e5af6c3c1 100644 --- a/apps/systemtags/src/files_views/systemtagsView.ts +++ b/apps/systemtags/src/files_views/systemtagsView.ts @@ -9,13 +9,15 @@ import { getContents } from '../services/systemtags.js' import svgTagMultiple from '@mdi/svg/svg/tag-multiple.svg?raw' +export const systemTagsViewId = 'tags' + /** * Register the system tags files view */ export function registerSystemTagsView() { const Navigation = getNavigation() Navigation.register(new View({ - id: 'tags', + id: systemTagsViewId, name: t('systemtags', 'Tags'), caption: t('systemtags', 'List of tags and their associated files and folders.'), diff --git a/apps/systemtags/src/init.ts b/apps/systemtags/src/init.ts index d0b0c4dd5da..54ba0d604ee 100644 --- a/apps/systemtags/src/init.ts +++ b/apps/systemtags/src/init.ts @@ -3,11 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { registerDavProperty, registerFileAction } from '@nextcloud/files' -import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction.js' -import { action as openInFilesAction } from './files_actions/openInFilesAction.js' -import { registerSystemTagsView } from './files_views/systemtagsView.js' +import { action as bulkSystemTagsAction } from './files_actions/bulkSystemTagsAction' +import { action as inlineSystemTagsAction } from './files_actions/inlineSystemTagsAction' +import { action as openInFilesAction } from './files_actions/openInFilesAction' +import { registerSystemTagsView } from './files_views/systemtagsView' registerDavProperty('nc:system-tags') +registerFileAction(bulkSystemTagsAction) registerFileAction(inlineSystemTagsAction) registerFileAction(openInFilesAction) diff --git a/apps/systemtags/src/services/logger.ts b/apps/systemtags/src/services/logger.ts new file mode 100644 index 00000000000..8cce9f5f92e --- /dev/null +++ b/apps/systemtags/src/services/logger.ts @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getLoggerBuilder } from '@nextcloud/logger' + +export default getLoggerBuilder() + .setApp('systemtags') + .detectUser() + .build() diff --git a/apps/systemtags/src/utils.ts b/apps/systemtags/src/utils.ts index c7e0dcbaa5b..456dc52ca84 100644 --- a/apps/systemtags/src/utils.ts +++ b/apps/systemtags/src/utils.ts @@ -8,6 +8,7 @@ import camelCase from 'camelcase' import type { DAVResultResponseProps } from 'webdav' import type { BaseTag, ServerTag, Tag, TagWithId } from './types.js' +import type { Node } from '@nextcloud/files' export const defaultBaseTag: BaseTag = { userVisible: true, @@ -55,3 +56,13 @@ export const formatTag = (initialTag: Tag | ServerTag): ServerTag => { return tag as unknown as ServerTag } + +export const getNodeSystemTags = function(node: Node): string[] { + const tags = node.attributes?.['system-tags']?.['system-tag'] as string|string[]|undefined + + if (tags === undefined) { + return [] + } + + return [tags].flat() +} |