aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorskjnldsv <skjnldsv@protonmail.com>2024-10-17 12:50:58 +0200
committerskjnldsv <skjnldsv@protonmail.com>2024-10-29 09:08:30 +0100
commit405a267408603f0257da2451d515a47c4b42e5cf (patch)
tree32a59a768a4f5869b652050e8373baded958b4ba /apps
parent6601c50b6d04611ded3e289e66cc2a0decaea67d (diff)
downloadnextcloud-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.vue294
-rw-r--r--apps/systemtags/src/files_actions/bulkSystemTagsAction.ts47
-rw-r--r--apps/systemtags/src/files_actions/inlineSystemTagsAction.ts13
-rw-r--r--apps/systemtags/src/files_actions/openInFilesAction.ts10
-rw-r--r--apps/systemtags/src/files_views/systemtagsView.ts4
-rw-r--r--apps/systemtags/src/init.ts8
-rw-r--r--apps/systemtags/src/services/logger.ts10
-rw-r--r--apps/systemtags/src/utils.ts11
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()
+}