]> source.dussan.org Git - nextcloud-server.git/commitdiff
feat(files): Implement files list filters for name, modified time and type
authorFerdinand Thiessen <opensource@fthiessen.de>
Fri, 7 Jun 2024 12:14:55 +0000 (14:14 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Thu, 25 Jul 2024 17:33:23 +0000 (19:33 +0200)
Co-authored-by: John Molakvoæ <skjnldsv@users.noreply.github.com>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
22 files changed:
apps/files/src/components/FileListFilter/FileListFilter.vue [new file with mode: 0644]
apps/files/src/components/FileListFilter/FileListFilterModified.vue [new file with mode: 0644]
apps/files/src/components/FileListFilter/FileListFilterType.vue [new file with mode: 0644]
apps/files/src/components/FileListFilters.vue [new file with mode: 0644]
apps/files/src/components/FilesListFilter/FilesListFilters.vue [deleted file]
apps/files/src/components/FilesListVirtual.vue
apps/files/src/composables/useFilenameFilter.ts [new file with mode: 0644]
apps/files/src/composables/useFilesFilter.ts [deleted file]
apps/files/src/eventbus.d.ts
apps/files/src/filters/FilenameFilter.ts [new file with mode: 0644]
apps/files/src/filters/HiddenFilesFilter.ts [new file with mode: 0644]
apps/files/src/filters/ModifiedFilter.ts [new file with mode: 0644]
apps/files/src/filters/TypeFilter.ts [new file with mode: 0644]
apps/files/src/init.ts
apps/files/src/main.ts
apps/files/src/store/filters.ts [new file with mode: 0644]
apps/files/src/types.ts
apps/files/src/views/FilesList.vue
apps/files/src/views/Navigation.vue
core/src/views/UnifiedSearch.vue
package-lock.json
package.json

diff --git a/apps/files/src/components/FileListFilter/FileListFilter.vue b/apps/files/src/components/FileListFilter/FileListFilter.vue
new file mode 100644 (file)
index 0000000..447ae7a
--- /dev/null
@@ -0,0 +1,53 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <NcActions force-menu
+               :type="isActive ? 'secondary' : 'tertiary'"
+               :menu-name="filterName">
+               <template #icon>
+                       <slot name="icon" />
+               </template>
+               <slot />
+
+               <template v-if="isActive">
+                       <NcActionSeparator />
+                       <NcActionButton class="files-list-filter__clear-button"
+                               close-after-click
+                               @click="$emit('reset-filter')">
+                               {{ t('files', 'Clear filter') }}
+                       </NcActionButton>
+               </template>
+       </NcActions>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
+
+defineProps<{
+       isActive: boolean
+       filterName: string
+}>()
+
+defineEmits<{
+       (event: 'reset-filter'): void
+}>()
+</script>
+
+<style scoped>
+.files-list-filter__clear-button :deep(.action-button__text) {
+       color: var(--color-error-text);
+}
+
+:deep(.button-vue) {
+       font-weight: normal !important;
+
+       * {
+               font-weight: normal !important;
+       }
+}
+</style>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterModified.vue b/apps/files/src/components/FileListFilter/FileListFilterModified.vue
new file mode 100644 (file)
index 0000000..a69e178
--- /dev/null
@@ -0,0 +1,107 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <FileListFilter :is-active="isActive"
+               :filter-name="t('files', 'Modified')"
+               @reset-filter="resetFilter">
+               <template #icon>
+                       <NcIconSvgWrapper :path="mdiCalendarRange" />
+               </template>
+               <NcActionButton v-for="preset of timePresets"
+                       :key="preset.id"
+                       type="radio"
+                       close-after-click
+                       :model-value.sync="selectedOption"
+                       :value="preset.id">
+                       {{ preset.label }}
+               </NcActionButton>
+               <!-- TODO: Custom time range -->
+       </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITimePreset } from '../../filters/ModifiedFilter.ts'
+
+import { mdiCalendarRange } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+       components: {
+               FileListFilter,
+               NcActionButton,
+               NcIconSvgWrapper,
+       },
+
+       props: {
+               timePresets: {
+                       type: Array as PropType<ITimePreset[]>,
+                       required: true,
+               },
+       },
+
+       setup() {
+               return {
+                       // icons used in template
+                       mdiCalendarRange,
+               }
+       },
+
+       data() {
+               return {
+                       selectedOption: null as string | null,
+                       timeRangeEnd: null as number | null,
+                       timeRangeStart: null as number | null,
+               }
+       },
+
+       computed: {
+               /**
+                * Is the filter currently active
+                */
+               isActive() {
+                       return this.selectedOption !== null
+               },
+
+               currentPreset() {
+                       return this.timePresets.find(({ id }) => id === this.selectedOption) ?? null
+               },
+       },
+
+       watch: {
+               selectedOption() {
+                       if (this.selectedOption === null) {
+                               this.$emit('update:preset')
+                       } else {
+                               const preset = this.currentPreset
+                               this.$emit('update:preset', preset)
+                       }
+               },
+       },
+
+       methods: {
+               t,
+
+               resetFilter() {
+                       this.selectedOption = null
+                       this.timeRangeEnd = null
+                       this.timeRangeStart = null
+               },
+       },
+})
+</script>
+
+<style scoped lang="scss">
+.files-list-filter-time {
+       &__clear-button :deep(.action-button__text) {
+               color: var(--color-error-text);
+       }
+}
+</style>
diff --git a/apps/files/src/components/FileListFilter/FileListFilterType.vue b/apps/files/src/components/FileListFilter/FileListFilterType.vue
new file mode 100644 (file)
index 0000000..a2a7034
--- /dev/null
@@ -0,0 +1,110 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <FileListFilter class="file-list-filter-type"
+               :is-active="isActive"
+               :filter-name="t('files', 'Type')"
+               @reset-filter="resetFilter">
+               <template #icon>
+                       <NcIconSvgWrapper :path="mdiFile" />
+               </template>
+               <NcActionButton v-for="fileType of typePresets"
+                       :key="fileType.id"
+                       type="checkbox"
+                       :model-value="selectedOptions.includes(fileType)"
+                       @click="toggleOption(fileType)">
+                       <template #icon>
+                               <NcIconSvgWrapper :svg="fileType.icon" />
+                       </template>
+                       {{ fileType.label }}
+               </NcActionButton>
+       </FileListFilter>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { ITypePreset } from '../../filters/TypeFilter.ts'
+
+import { mdiFile } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
+import FileListFilter from './FileListFilter.vue'
+
+export default defineComponent({
+       name: 'FileListFilterType',
+
+       components: {
+               FileListFilter,
+               NcActionButton,
+               NcIconSvgWrapper,
+       },
+
+       props: {
+               typePresets: {
+                       type: Array as PropType<ITypePreset[]>,
+                       required: true,
+               },
+       },
+
+       setup() {
+               return {
+                       mdiFile,
+                       t,
+               }
+       },
+
+       data() {
+               return {
+                       selectedOptions: [] as ITypePreset[],
+               }
+       },
+
+       computed: {
+               isActive() {
+                       return this.selectedOptions.length > 0
+               },
+       },
+
+       watch: {
+               selectedOptions(newValue, oldValue) {
+                       if (this.selectedOptions.length === 0) {
+                               if (oldValue.length !== 0) {
+                                       this.$emit('update:preset')
+                               }
+                       } else {
+                               this.$emit('update:preset', this.selectedOptions)
+                       }
+               },
+       },
+
+       methods: {
+               resetFilter() {
+                       this.selectedOptions = []
+               },
+
+               /**
+                * Toggle option from selected option
+                * @param option The option to toggle
+                */
+               toggleOption(option: ITypePreset) {
+                       const idx = this.selectedOptions.indexOf(option)
+                       if (idx !== -1) {
+                               this.selectedOptions.splice(idx, 1)
+                       } else {
+                               this.selectedOptions.push(option)
+                       }
+               },
+       },
+})
+</script>
+
+<style>
+.file-list-filter-type {
+       max-width: 220px;
+}
+</style>
diff --git a/apps/files/src/components/FileListFilters.vue b/apps/files/src/components/FileListFilters.vue
new file mode 100644 (file)
index 0000000..20c9179
--- /dev/null
@@ -0,0 +1,65 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+       <div class="file-list-filters">
+               <div class="file-list-filters__filter">
+                       <span v-for="filter of visualFilters"
+                               :key="filter.id"
+                               ref="filterElements" />
+               </div>
+               <ul v-if="activeChips.length > 0" class="file-list-filters__active" :aria-label="t('files', 'Active filters')">
+                       <li v-for="(chip, index) of activeChips" :key="index">
+                               <NcChip :icon-svg="chip.icon"
+                                       :text="chip.text"
+                                       @close="chip.onclick" />
+                       </li>
+               </ul>
+       </div>
+</template>
+
+<script setup lang="ts">
+import { t } from '@nextcloud/l10n'
+import { computed, ref, watchEffect } from 'vue'
+import { useFiltersStore } from '../store/filters.ts'
+
+import NcChip from '@nextcloud/vue/dist/Components/NcChip.js'
+
+const filterStore = useFiltersStore()
+const visualFilters = computed(() => filterStore.filtersWithUI)
+const activeChips = computed(() => filterStore.activeChips)
+
+const filterElements = ref<HTMLElement[]>([])
+watchEffect(() => {
+       filterElements.value
+               .forEach((el, index) => visualFilters.value[index].mount(el))
+})
+</script>
+
+<style scoped lang="scss">
+.file-list-filters {
+       display: flex;
+       flex-direction: column;
+       gap: var(--default-grid-baseline);
+       height: 100%;
+       width: 100%;
+
+       &__filter {
+               display: flex;
+               align-items: start;
+               justify-content: start;
+               gap: calc(var(--default-grid-baseline, 4px) * 2);
+
+               > * {
+                       flex: 0 1 fit-content;
+               }
+       }
+
+       &__active {
+               display: flex;
+               flex-direction: row;
+               gap: calc(var(--default-grid-baseline, 4px) * 2);
+       }
+}
+</style>
diff --git a/apps/files/src/components/FilesListFilter/FilesListFilters.vue b/apps/files/src/components/FilesListFilter/FilesListFilters.vue
deleted file mode 100644 (file)
index b7752e9..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<template>
-       <Fragment>
-       </Fragment>
-</template>
-
-<script lang="ts">
-import type { View } from '@nextcloud/files'
-import type { PropType } from 'vue'
-
-import { Fragment } from 'vue-frag'
-import { defineComponent } from 'vue'
-
-export default defineComponent({
-
-       props: {
-               currentView: {
-                       type: Object as PropType<View>,
-                       required: true,
-               },
-       },
-
-       watch: {
-               currentView() {
-                       // Reset all filters on view change
-                       const components = this.$refs.filters as { resetFilter: () => void }[] | undefined
-                       components?.forEach((component) => component.resetFilter())
-               },
-       },
-})
-</script>
index b1f619fb9714960b0e0533412baacfdc09fc0d6b..5fd22d825dad4d4bff13e68c52a7a6511f8c8656 100644 (file)
@@ -17,7 +17,7 @@
                :scroll-to-index="scrollToIndex"
                :caption="caption">
                <template #filters>
-                       <FilesListFilters :current-view="currentView" />
+                       <FileListFilters />
                </template>
 
                <template v-if="!isNoneSelected" #header-overlay>
@@ -82,13 +82,13 @@ import filesListWidthMixin from '../mixins/filesListWidth.ts'
 import VirtualList from './VirtualList.vue'
 import logger from '../logger.js'
 import FilesListTableHeaderActions from './FilesListTableHeaderActions.vue'
-import FilesListFilters from './FilesListFilter/FilesListFilters.vue'
+import FileListFilters from './FileListFilters.vue'
 
 export default defineComponent({
        name: 'FilesListVirtual',
 
        components: {
-               FilesListFilters,
+               FileListFilters,
                FilesListHeader,
                FilesListTableFooter,
                FilesListTableHeader,
@@ -325,10 +325,16 @@ export default defineComponent({
        --clickable-area: var(--default-clickable-area);
        --icon-preview-size: 32px;
 
+       --fixed-top-position: var(--default-clickable-area);
+
        overflow: auto;
        height: 100%;
        will-change: scroll-position;
 
+       &:has(.file-list-filters__active) {
+               --fixed-top-position: calc(var(--default-clickable-area) + var(--default-grid-baseline) + var(--clickable-area-small));
+       }
+
        & :deep() {
                // Table head, body and footer
                tbody {
@@ -371,28 +377,20 @@ export default defineComponent({
                }
 
                .files-list__filters {
-                       display: flex;
-                       align-items: baseline;
-                       justify-content: start;
-                       gap: calc(var(--default-grid-baseline, 4px) * 2);
                        // Pinned on top when scrolling above table header
                        position: sticky;
                        top: 0;
                        // fix size and background
                        background-color: var(--color-main-background);
                        padding-inline: var(--row-height) var(--default-grid-baseline, 4px);
-                       height: var(--row-height);
+                       height: var(--fixed-top-position);
                        width: 100%;
-
-                       > * {
-                               flex: 0 1 fit-content;
-                       }
                }
 
                .files-list__thead-overlay {
                        // Pinned on top when scrolling
                        position: sticky;
-                       top: var(--row-height);
+                       top: var(--fixed-top-position);
                        // Save space for a row checkbox
                        margin-left: var(--row-height);
                        // More than .files-list__thead
@@ -421,7 +419,7 @@ export default defineComponent({
                        // Pinned on top when scrolling
                        position: sticky;
                        z-index: 10;
-                       top: var(--row-height);
+                       top: var(--fixed-top-position);
                }
 
                // Table footer
diff --git a/apps/files/src/composables/useFilenameFilter.ts b/apps/files/src/composables/useFilenameFilter.ts
new file mode 100644 (file)
index 0000000..54c16f3
--- /dev/null
@@ -0,0 +1,47 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { registerFileListFilter, unregisterFileListFilter } from '@nextcloud/files'
+import { watchThrottled } from '@vueuse/core'
+import { onMounted, onUnmounted, ref } from 'vue'
+import { FilenameFilter } from '../filters/FilenameFilter'
+
+/**
+ * This is for the `Navigation` component to provide a filename filter
+ */
+export function useFilenameFilter() {
+       const searchQuery = ref('')
+       const filenameFilter = new FilenameFilter()
+
+       /**
+        * Updating the search query ref from the filter
+        * @param event The update:query event
+        */
+       function updateQuery(event: CustomEvent) {
+               if (event.type === 'update:query') {
+                       searchQuery.value = event.detail
+                       event.stopPropagation()
+               }
+       }
+
+       onMounted(() => {
+               filenameFilter.addEventListener('update:query', updateQuery)
+               registerFileListFilter(filenameFilter)
+       })
+       onUnmounted(() => {
+               filenameFilter.removeEventListener('update:query', updateQuery)
+               unregisterFileListFilter(filenameFilter.id)
+       })
+
+       // Update the query on the filter, but throttle to max. every 800ms
+       // This will debounce the filter refresh
+       watchThrottled(searchQuery, () => {
+               filenameFilter.updateQuery(searchQuery.value)
+       }, { throttle: 800 })
+
+       return {
+               searchQuery,
+       }
+}
diff --git a/apps/files/src/composables/useFilesFilter.ts b/apps/files/src/composables/useFilesFilter.ts
deleted file mode 100644 (file)
index a8a0c9e..0000000
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import type { FilesFilter } from '../types'
-
-import { inject } from 'vue'
-
-/**
- * Provide functions for adding and deleting filters injected by the files list
- */
-export default function() {
-       const addFilter = inject<(filter: FilesFilter) => void>('files:filter:add', () => {})
-       const deleteFilter = inject<(id: string) => void>('files:filter:delete', () => {})
-
-       return {
-               addFilter,
-               deleteFilter,
-       }
-}
index 8d57d82c034dc10d5c75e2d9ad298539ad74e388..db90c40eeaef28e16ca89e7f3cbf86d2e9dcf611 100644 (file)
@@ -2,14 +2,19 @@
  * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  * SPDX-License-Identifier: AGPL-3.0-or-later
  */
-import type { Node } from '@nextcloud/files'
+import type { IFileListFilter, Node } from '@nextcloud/files'
 
 declare module '@nextcloud/event-bus' {
        export interface NextcloudEvents {
                // mapping of 'event name' => 'event type'
+               'files:config:updated': { key: string, value: unknown }
+
                'files:favorites:removed': Node
                'files:favorites:added': Node
                'files:node:renamed': Node
+
+               'files:filter:added': IFileListFilter
+               'files:filter:removed': string
        }
 }
 
diff --git a/apps/files/src/filters/FilenameFilter.ts b/apps/files/src/filters/FilenameFilter.ts
new file mode 100644 (file)
index 0000000..32df078
--- /dev/null
@@ -0,0 +1,53 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter } from '@nextcloud/files'
+
+/**
+ * Simple file list filter controlled by the Navigation search box
+ */
+export class FilenameFilter extends FileListFilter {
+
+       private searchQuery = ''
+
+       constructor() {
+               super('files:filename', 5)
+               subscribe('files:navigation:changed', () => this.updateQuery(''))
+       }
+
+       public filter(nodes: INode[]): INode[] {
+               const queryParts = this.searchQuery.toLocaleLowerCase().split(' ').filter(Boolean)
+               return nodes.filter((node) => {
+                       const displayname = node.displayname.toLocaleLowerCase()
+                       return queryParts.every((part) => displayname.includes(part))
+               })
+       }
+
+       public updateQuery(query: string) {
+               query = (query || '').trim()
+
+               // Only if the query is different we update the filter to prevent re-computing all nodes
+               if (query !== this.searchQuery) {
+                       this.searchQuery = query
+                       this.filterUpdated()
+
+                       const chips: IFileListFilterChip[] = []
+                       if (query !== '') {
+                               chips.push({
+                                       text: query,
+                                       onclick: () => {
+                                               this.updateQuery('')
+                                       },
+                               })
+                       }
+                       this.updateChips(chips)
+                       // Emit the new query as it might have come not from the Navigation
+                       this.dispatchTypedEvent('update:query', new CustomEvent('update:query', { detail: query }))
+               }
+       }
+
+}
diff --git a/apps/files/src/filters/HiddenFilesFilter.ts b/apps/files/src/filters/HiddenFilesFilter.ts
new file mode 100644 (file)
index 0000000..51d90b2
--- /dev/null
@@ -0,0 +1,42 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { INode } from '@nextcloud/files'
+import type { UserConfig } from '../types'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+
+class HiddenFilesFilter extends FileListFilter {
+
+       private showHidden?: boolean
+
+       constructor() {
+               super('files:hidden', 0)
+               this.showHidden = loadState<UserConfig>('files', 'config', { show_hidden: false }).show_hidden
+
+               subscribe('files:config:updated', ({ key, value }) => {
+                       if (key === 'show_hidden') {
+                               this.showHidden = Boolean(value)
+                               this.filterUpdated()
+                       }
+               })
+       }
+
+       public filter(nodes: INode[]): INode[] {
+               if (this.showHidden) {
+                       return nodes
+               }
+               return nodes.filter((node) => (node.attributes.hidden !== true && !node.basename.startsWith('.')))
+       }
+
+}
+
+/**
+ * Register a file list filter to only show hidden files if enabled by user config
+ */
+export function registerHiddenFilesFilter() {
+       registerFileListFilter(new HiddenFilesFilter())
+}
diff --git a/apps/files/src/filters/ModifiedFilter.ts b/apps/files/src/filters/ModifiedFilter.ts
new file mode 100644 (file)
index 0000000..63563f2
--- /dev/null
@@ -0,0 +1,112 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import FileListFilterModified from '../components/FileListFilter/FileListFilterModified.vue'
+
+import calendarSvg from '@mdi/svg/svg/calendar.svg?raw'
+
+export interface ITimePreset {
+       id: string,
+       label: string,
+       filter: (time: number) => boolean
+}
+
+const startOfToday = () => (new Date()).setHours(0, 0, 0, 0)
+
+/**
+ * Available presets
+ */
+const timePresets: ITimePreset[] = [
+       {
+               id: 'today',
+               label: t('files', 'Today'),
+               filter: (time: number) => time > startOfToday(),
+       },
+       {
+               id: 'last-7',
+               label: t('files', 'Last 7 days'),
+               filter: (time: number) => time > (startOfToday() - (7 * 24 * 60 * 60 * 1000)),
+       },
+       {
+               id: 'last-30',
+               label: t('files', 'Last 30 days'),
+               filter: (time: number) => time > (startOfToday() - (30 * 24 * 60 * 60 * 1000)),
+       },
+       {
+               id: 'this-year',
+               label: t('files', 'This year ({year})', { year: (new Date()).getFullYear() }),
+               filter: (time: number) => time > (new Date(startOfToday())).setMonth(0, 1),
+       },
+       {
+               id: 'last-year',
+               label: t('files', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }),
+               filter: (time: number) => (time > (new Date(startOfToday())).setFullYear((new Date()).getFullYear() - 1, 0, 1)) && (time < (new Date(startOfToday())).setMonth(0, 1)),
+       },
+] as const
+
+class ModifiedFilter extends FileListFilter {
+
+       private currentInstance?: Vue
+       private currentPreset?: ITimePreset
+
+       constructor() {
+               super('files:modified', 50)
+               subscribe('files:navigation:changed', () => this.setPreset())
+       }
+
+       public mount(el: HTMLElement) {
+               if (this.currentInstance) {
+                       this.currentInstance.$destroy()
+               }
+
+               const View = Vue.extend(FileListFilterModified as never)
+               this.currentInstance = new View({
+                       propsData: {
+                               timePresets,
+                       },
+                       el,
+               })
+                       .$on('update:preset', this.setPreset.bind(this))
+                       .$mount()
+       }
+
+       public filter(nodes: INode[]): INode[] {
+               if (!this.currentPreset) {
+                       return nodes
+               }
+
+               return nodes.filter((node) => node.mtime === undefined || this.currentPreset!.filter(node.mtime.getTime()))
+       }
+
+       public setPreset(preset?: ITimePreset) {
+               this.currentPreset = preset
+               this.filterUpdated()
+
+               const chips: IFileListFilterChip[] = []
+               if (preset) {
+                       chips.push({
+                               icon: calendarSvg,
+                               text: preset.label,
+                               onclick: () => this.setPreset(),
+                       })
+               } else {
+                       (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
+               }
+               this.updateChips(chips)
+       }
+
+}
+
+/**
+ * Register the file list filter by modification date
+ */
+export function registerModifiedFilter() {
+       registerFileListFilter(new ModifiedFilter())
+}
diff --git a/apps/files/src/filters/TypeFilter.ts b/apps/files/src/filters/TypeFilter.ts
new file mode 100644 (file)
index 0000000..d35671f
--- /dev/null
@@ -0,0 +1,169 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+import Vue from 'vue'
+import FileListFilterType from '../components/FileListFilter/FileListFilterType.vue'
+
+// TODO: Create a modern replacement for OC.MimeType...
+import svgDocument from '@mdi/svg/svg/file-document.svg?raw'
+import svgSpreadsheet from '@mdi/svg/svg/file-table-box.svg?raw'
+import svgPresentation from '@mdi/svg/svg/file-presentation-box.svg?raw'
+import svgPDF from '@mdi/svg/svg/file-pdf-box.svg?raw'
+import svgFolder from '@mdi/svg/svg/folder.svg?raw'
+import svgAudio from '@mdi/svg/svg/music.svg?raw'
+import svgImage from '@mdi/svg/svg/image.svg?raw'
+import svgMovie from '@mdi/svg/svg/movie.svg?raw'
+
+export interface ITypePreset {
+       id: string
+       label: string
+       icon: string
+       mime: string[]
+}
+
+const colorize = (svg: string, color: string) => {
+       return svg.replace('<path ', `<path fill="${color}" `)
+}
+
+/**
+ * Available presets
+ */
+const getTypePresets = async () => [
+       {
+               id: 'document',
+               label: t('files', 'Documents'),
+               icon: colorize(svgDocument, '#49abea'),
+               mime: ['x-office/document'],
+       },
+       {
+               id: 'spreadsheet',
+               label: t('files', 'Spreadsheets'),
+               icon: colorize(svgSpreadsheet, '#9abd4e'),
+               mime: ['x-office/spreadsheet'],
+       },
+       {
+               id: 'presentation',
+               label: t('files', 'Presentations'),
+               icon: colorize(svgPresentation, '#f0965f'),
+               mime: ['x-office/presentation'],
+       },
+       {
+               id: 'pdf',
+               label: t('files', 'PDFs'),
+               icon: colorize(svgPDF, '#dc5047'),
+               mime: ['application/pdf'],
+       },
+       {
+               id: 'folder',
+               label: t('files', 'Folders'),
+               icon: colorize(svgFolder, window.getComputedStyle(document.body).getPropertyValue('--color-primary-element')),
+               mime: ['httpd/unix-directory'],
+       },
+       {
+               id: 'audio',
+               label: t('files', 'Audio'),
+               icon: svgAudio,
+               mime: ['audio'],
+       },
+       {
+               id: 'image',
+               label: t('files', 'Pictures and images'),
+               icon: svgImage,
+               mime: ['image'],
+       },
+       {
+               id: 'video',
+               label: t('files', 'Videos'),
+               icon: svgMovie,
+               mime: ['video'],
+       },
+] as ITypePreset[]
+
+class TypeFilter extends FileListFilter {
+
+       private currentInstance?: Vue
+       private currentPresets?: ITypePreset[]
+       private allPresets?: ITypePreset[]
+
+       constructor() {
+               super('files:type', 10)
+               subscribe('files:navigation:changed', () => this.setPreset())
+       }
+
+       public async mount(el: HTMLElement) {
+               // We need to defer this as on init script this is not available:
+               if (this.allPresets === undefined) {
+                       this.allPresets = await getTypePresets()
+               }
+
+               if (this.currentInstance) {
+                       this.currentInstance.$destroy()
+               }
+
+               const View = Vue.extend(FileListFilterType as never)
+               this.currentInstance = new View({
+                       propsData: {
+                               typePresets: this.allPresets!,
+                       },
+                       el,
+               })
+                       .$on('update:preset', this.setPreset.bind(this))
+                       .$mount()
+       }
+
+       public filter(nodes: INode[]): INode[] {
+               if (!this.currentPresets || this.currentPresets.length === 0) {
+                       return nodes
+               }
+
+               const mimeList = this.currentPresets.reduce((previous: string[], current) => [...previous, ...current.mime], [] as string[])
+               return nodes.filter((node) => {
+                       if (!node.mime) {
+                               return false
+                       }
+                       const mime = node.mime.toLowerCase()
+
+                       if (mimeList.includes(mime)) {
+                               return true
+                       } else if (mimeList.includes(window.OC.MimeTypeList.aliases[mime])) {
+                               return true
+                       } else if (mimeList.includes(mime.split('/')[0])) {
+                               return true
+                       }
+                       return false
+               })
+       }
+
+       public setPreset(presets?: ITypePreset[]) {
+               this.currentPresets = presets
+               this.filterUpdated()
+
+               const chips: IFileListFilterChip[] = []
+               if (presets && presets.length > 0) {
+                       for (const preset of presets) {
+                               chips.push({
+                                       icon: preset.icon,
+                                       text: preset.label,
+                                       onclick: () => this.setPreset(presets.filter(({ id }) => id !== preset.id)),
+                               })
+                       }
+               } else {
+                       (this.currentInstance as { resetFilter: () => void } | undefined)?.resetFilter()
+               }
+               this.updateChips(chips)
+       }
+
+}
+
+/**
+ * Register the file list filter by file type
+ */
+export function registerTypeFilter() {
+       registerFileListFilter(new TypeFilter())
+}
index 25bcc1072f0f4d7cd1b45b3b8db54e317f1d49b0..4266453a4a3096f61adf8385398cc21b3647ffb7 100644 (file)
@@ -14,6 +14,11 @@ import { action as openInFilesAction } from './actions/openInFilesAction'
 import { action as renameAction } from './actions/renameAction'
 import { action as sidebarAction } from './actions/sidebarAction'
 import { action as viewInFolderAction } from './actions/viewInFolderAction'
+
+import { registerHiddenFilesFilter } from './filters/HiddenFilesFilter.ts'
+import { registerTypeFilter } from './filters/TypeFilter.ts'
+import { registerModifiedFilter } from './filters/ModifiedFilter.ts'
+
 import { entry as newFolderEntry } from './newMenu/newFolder.ts'
 import { entry as newTemplatesFolder } from './newMenu/newTemplatesFolder.ts'
 import { registerTemplateEntries } from './newMenu/newFromTemplate.ts'
@@ -49,6 +54,11 @@ registerFilesView()
 registerRecentView()
 registerPersonalFilesView()
 
+// Register file list filters
+registerHiddenFilesFilter()
+registerTypeFilter()
+registerModifiedFilter()
+
 // Register preview service worker
 registerPreviewServiceWorker()
 
index 62c4894d1e4b2f36f87536cf39068b1979dcbb23..cac0cf25b6d0851136a9ec53f119e108705e772d 100644 (file)
@@ -38,7 +38,7 @@ Object.assign(window.OCP.Files, { Router })
 Vue.use(PiniaVuePlugin)
 
 // Init Navigation Service
-// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a oberserver
+// This only works with Vue 2 - with Vue 3 this will not modify the source but return just a observer
 const Navigation = Vue.observable(getNavigation())
 Vue.prototype.$navigation = Navigation
 
diff --git a/apps/files/src/store/filters.ts b/apps/files/src/store/filters.ts
new file mode 100644 (file)
index 0000000..abc1273
--- /dev/null
@@ -0,0 +1,78 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FilterUpdateChipsEvent, IFileListFilter, IFileListFilterChip } from '@nextcloud/files'
+import { subscribe } from '@nextcloud/event-bus'
+import { getFileListFilters } from '@nextcloud/files'
+import { defineStore } from 'pinia'
+import logger from '../logger'
+
+export const useFiltersStore = defineStore('keyboard', {
+       state: () => ({
+               chips: {} as Record<string, IFileListFilterChip[]>,
+               filters: [] as IFileListFilter[],
+               filtersChanged: false,
+       }),
+
+       getters: {
+               /**
+                * Currently active filter chips
+                * @param state Internal state
+                */
+               activeChips(state): IFileListFilterChip[] {
+                       return Object.values(state.chips).flat()
+               },
+
+               /**
+                * Filters sorted by order
+                * @param state Internal state
+                */
+               sortedFilters(state): IFileListFilter[] {
+                       return state.filters.sort((a, b) => a.order - b.order)
+               },
+
+               /**
+                * All filters that provide a UI for visual controlling the filter state
+                */
+               filtersWithUI(): Required<IFileListFilter>[] {
+                       return this.sortedFilters.filter((filter) => 'mount' in filter) as Required<IFileListFilter>[]
+               },
+       },
+
+       actions: {
+               addFilter(filter: IFileListFilter) {
+                       filter.addEventListener('update:chips', this.onFilterUpdateChips)
+                       filter.addEventListener('update:filter', this.onFilterUpdate)
+                       this.filters.push(filter)
+                       logger.debug('New file list filter registered', { id: filter.id })
+               },
+
+               removeFilter(filterId: string) {
+                       const index = this.filters.findIndex(({ id }) => id === filterId)
+                       if (index > -1) {
+                               const [filter] = this.filters.splice(index, 1)
+                               filter.removeEventListener('update:chips', this.onFilterUpdateChips)
+                               filter.removeEventListener('update:filter', this.onFilterUpdate)
+                               logger.debug('Files list filter unregistered', { id: filterId })
+                       }
+               },
+
+               onFilterUpdate() {
+                       this.filtersChanged = true
+               },
+
+               onFilterUpdateChips(event: FilterUpdateChipsEvent) {
+                       const id = (event.target as IFileListFilter).id
+                       this.chips = { ...this.chips, [id]: [...event.detail] }
+               },
+
+               init() {
+                       subscribe('files:filter:added', this.addFilter)
+                       subscribe('files:filter:removed', this.removeFilter)
+                       for (const filter of getFileListFilters()) {
+                               this.addFilter(filter)
+                       }
+               },
+       },
+})
index c5f6a15d6ef7dd6b225d31d40e82f6b96b14a9e7..9e1ba0496970591ea462ff758dadff9e4cc9d882 100644 (file)
@@ -105,17 +105,3 @@ export interface TemplateFile {
        ratio?: number
        templates?: Record<string, unknown>[]
 }
-
-export interface FilesFilter {
-       /**
-        * ID of the filter
-        */
-       id: string
-
-       /**
-        * Filter function callback
-        * @param node Node to check
-        * @return True if keep the file false to remove from list
-        */
-       filter: (node: Node) => boolean
-}
index 19774292e24774c3c528b86d057b20fe3ad91184..c125d907a8e93c9d7359c490244361ff9bdc8ed9 100644 (file)
 </template>
 
 <script lang="ts">
-import type { ContentsWithRoot } from '@nextcloud/files'
+import type { ContentsWithRoot, INode } from '@nextcloud/files'
 import type { Upload } from '@nextcloud/upload'
 import type { CancelablePromise } from 'cancelable-promise'
 import type { ComponentPublicInstance } from 'vue'
 import type { Route } from 'vue-router'
-import type { FilesFilter, UserConfig } from '../types.ts'
+import type { UserConfig } from '../types.ts'
 
 import { getCapabilities } from '@nextcloud/capabilities'
 import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
@@ -143,6 +143,7 @@ import ViewGridIcon from 'vue-material-design-icons/ViewGrid.vue'
 import { action as sidebarAction } from '../actions/sidebarAction.ts'
 import { useNavigation } from '../composables/useNavigation.ts'
 import { useFilesStore } from '../store/files.ts'
+import { useFiltersStore } from '../store/filters.ts'
 import { usePathsStore } from '../store/paths.ts'
 import { useSelectionStore } from '../store/selection.ts'
 import { useUploaderStore } from '../store/uploader.ts'
@@ -182,22 +183,9 @@ export default defineComponent({
                filesSortingMixin,
        ],
 
-       provide() {
-               return {
-                       'files:filter:add': (filter: FilesFilter) => {
-                               this.$set(this.filters, filter.id, filter)
-                               logger.debug('File list filter updated', { filters: [...Object.keys(this.filters)] })
-                       },
-
-                       'files:filter:delete': (id: string) => {
-                               this.$delete(this.filters, id)
-                               logger.debug('File list filter removed', { filter: id, filters: [...Object.keys(this.filters)] })
-                       },
-               }
-       },
-
        setup() {
                const filesStore = useFilesStore()
+               const filtersStore = useFiltersStore()
                const pathsStore = usePathsStore()
                const selectionStore = useSelectionStore()
                const uploaderStore = useUploaderStore()
@@ -213,6 +201,7 @@ export default defineComponent({
                        t,
 
                        filesStore,
+                       filtersStore,
                        pathsStore,
                        selectionStore,
                        uploaderStore,
@@ -228,10 +217,11 @@ export default defineComponent({
 
        data() {
                return {
-                       filters: {} as Record<string, FilesFilter>, // TODO: With Vue3 use Map
                        loading: true,
                        promise: null as CancelablePromise<ContentsWithRoot> | Promise<ContentsWithRoot> | null,
 
+                       dirContentsFiltered: [] as INode[],
+
                        unsubscribeStoreCallback: () => {},
                }
        },
@@ -298,13 +288,6 @@ export default defineComponent({
                        return (this.currentFolder?._children || [])
                                .map(this.getNode)
                                .filter((node: Node) => !!node)
-                               .filter(this.filterHidden)
-               },
-
-               dirContentsFiltered(): Node[] {
-                       const filters = [...Object.values(this.filters)]
-                       return this.dirContents
-                               .filter((node: Node) => filters.every((filter: FilesFilter) => filter.filter(node)))
                },
 
                /**
@@ -416,6 +399,10 @@ export default defineComponent({
                        return isSharingEnabled
                                && this.currentFolder && (this.currentFolder.permissions & Permission.SHARE) !== 0
                },
+
+               filtersChanged() {
+                       return this.filtersStore.filtersChanged
+               },
        },
 
        watch: {
@@ -448,10 +435,20 @@ export default defineComponent({
                dirContents(contents) {
                        logger.debug('Directory contents changed', { view: this.currentView, folder: this.currentFolder, contents })
                        emit('files:list:updated', { view: this.currentView, folder: this.currentFolder, contents })
+                       // Also refresh the filtered content
+                       this.filterDirContent()
+               },
+
+               filtersChanged() {
+                       if (this.filtersChanged) {
+                               this.filterDirContent()
+                               this.filtersStore.filtersChanged = false
+                       }
                },
        },
 
        mounted() {
+               this.filtersStore.init()
                this.fetchContent()
 
                subscribe('files:node:deleted', this.onNodeDeleted)
@@ -533,15 +530,6 @@ export default defineComponent({
                        return this.filesStore.getNode(fileId)
                },
 
-               /**
-                * Whether the node should be shown
-                * @param node The node to check
-                */
-               filterHidden(node: Node) {
-                       const showHidden = this.userConfigStore?.userConfig.show_hidden
-                       return showHidden || (node.attributes.hidden !== true && !node.basename.startsWith('.'))
-               },
-
                /**
                 * Handle the node deleted event to reset open file
                 * @param node The deleted node
@@ -651,9 +639,18 @@ export default defineComponent({
                        }
                        sidebarAction.exec(this.currentFolder, this.currentView!, this.currentFolder.path)
                },
+
                toggleGridView() {
                        this.userConfigStore.update('grid_view', !this.userConfig.grid_view)
                },
+
+               filterDirContent() {
+                       let nodes = this.dirContents
+                       for (const filter of this.filtersStore.sortedFilters) {
+                               nodes = filter.filter(nodes)
+                       }
+                       this.dirContentsFiltered = nodes
+               },
        },
 })
 </script>
index b69c6d5f7f2b153222df3d14c65d4cddc1d75f24..51430dd54b272184f199595022d48fb3b38a44be 100644 (file)
@@ -5,6 +5,9 @@
 <template>
        <NcAppNavigation data-cy-files-navigation
                :aria-label="t('files', 'Files')">
+               <template #search>
+                       <NcAppNavigationSearch v-model="searchQuery" :label="t('files', 'Filter filenames…')" />
+               </template>
                <template #list>
                        <NcAppNavigationItem v-for="view in parentViews"
                                :key="view.id"
 import type { View } from '@nextcloud/files'
 
 import { emit } from '@nextcloud/event-bus'
-import { translate as t } from '@nextcloud/l10n'
+import { t } from '@nextcloud/l10n'
 import { defineComponent } from 'vue'
 
 import IconCog from 'vue-material-design-icons/Cog.vue'
 import NcAppNavigation from '@nextcloud/vue/dist/Components/NcAppNavigation.js'
 import NcAppNavigationItem from '@nextcloud/vue/dist/Components/NcAppNavigationItem.js'
+import NcAppNavigationSearch from '@nextcloud/vue/dist/Components/NcAppNavigationSearch.js'
 import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
 import NavigationQuota from '../components/NavigationQuota.vue'
 import SettingsModal from './Settings.vue'
 
 import { useNavigation } from '../composables/useNavigation'
+import { useFilenameFilter } from '../composables/useFilenameFilter'
+import { useFiltersStore } from '../store/filters.ts'
 import { useViewConfigStore } from '../store/viewConfig.ts'
 import logger from '../logger.js'
 
@@ -84,18 +90,24 @@ export default defineComponent({
                NavigationQuota,
                NcAppNavigation,
                NcAppNavigationItem,
+               NcAppNavigationSearch,
                NcIconSvgWrapper,
                SettingsModal,
        },
 
        setup() {
+               const filtersStore = useFiltersStore()
                const viewConfigStore = useViewConfigStore()
                const { currentView, views } = useNavigation()
+               const { searchQuery } = useFilenameFilter()
 
                return {
                        currentView,
+                       searchQuery,
+                       t,
                        views,
 
+                       filtersStore,
                        viewConfigStore,
                }
        },
@@ -160,8 +172,6 @@ export default defineComponent({
        },
 
        methods: {
-               t,
-
                /**
                 * Only use exact route matching on routes with child views
                 * Because if a view does not have children (like the files view) then multiple routes might be matched for it
index a07860c7e795b763926979aa862adfd841401562..fc1d456247c7ba88a4ca3e008e5d11accbc06124 100644 (file)
@@ -80,7 +80,7 @@ export default defineComponent({
                 */
                supportsLocalSearch() {
                        // TODO: Make this an API
-                       const providerPaths = ['/settings/users', '/apps/files', '/apps/deck', '/settings/apps']
+                       const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps']
                        return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
                },
        },
index 2ad74615b5ab32d380f5b7f5ab15771562141beb..ddb5565b477e6c8a21915aef387bbd009507e2cd 100644 (file)
@@ -20,7 +20,7 @@
         "@nextcloud/capabilities": "^1.2.0",
         "@nextcloud/dialogs": "^5.3.5",
         "@nextcloud/event-bus": "^3.3.1",
-        "@nextcloud/files": "^3.6.0",
+        "@nextcloud/files": "^3.7.0",
         "@nextcloud/initial-state": "^2.2.0",
         "@nextcloud/l10n": "^3.1.0",
         "@nextcloud/logger": "^3.0.2",
@@ -30,7 +30,7 @@
         "@nextcloud/router": "^3.0.0",
         "@nextcloud/sharing": "^0.2.2",
         "@nextcloud/upload": "^1.4.1",
-        "@nextcloud/vue": "^8.14.0",
+        "@nextcloud/vue": "^8.15.0",
         "@simplewebauthn/browser": "^10.0.0",
         "@skjnldsv/sanitize-svg": "^1.0.2",
         "@vueuse/components": "^10.11.0",
       }
     },
     "node_modules/@nextcloud/files": {
-      "version": "3.6.0",
-      "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.6.0.tgz",
-      "integrity": "sha512-/3kzEJ1TsCgjkSVhjdI+FnF0c2rvYtiTAQPoNqkNQYFa7Vbor+XPuypBQIJZFMDMzEgUexAL4QuQT3YmeSfBAA==",
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.7.0.tgz",
+      "integrity": "sha512-u7Hwt7/13empViLvwHPQk1AnKjhDYf7tkXeCLaO6e03am2uqBlYwc3iUS4cZye5CuaEeJeW251jPUGTtRXjjWQ==",
       "dependencies": {
         "@nextcloud/auth": "^2.3.0",
         "@nextcloud/capabilities": "^1.2.0",
         "@nextcloud/l10n": "^3.1.0",
         "@nextcloud/logger": "^3.0.2",
-        "@nextcloud/paths": "^2.1.0",
+        "@nextcloud/paths": "^2.2.0",
         "@nextcloud/router": "^3.0.1",
         "@nextcloud/sharing": "^0.2.2",
         "cancelable-promise": "^4.3.1",
         "is-svg": "^5.0.1",
+        "typedoc-plugin-missing-exports": "^3.0.0",
         "typescript-event-target": "^1.1.1",
         "webdav": "^5.6.0"
       },
       }
     },
     "node_modules/@nextcloud/paths": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-2.1.0.tgz",
-      "integrity": "sha512-8wX0gqwez0bTuAS8A0OEiqbbp0ZsqLr07zSErmS6OYhh9KZcSt/kO6lQV5tnrFqIqJVsxwz4kHUjtZXh6DSf9Q==",
-      "dependencies": {
-        "core-js": "^3.6.4"
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@nextcloud/paths/-/paths-2.2.0.tgz",
+      "integrity": "sha512-G2KZmDgI3Lt3zFWz2EZ9opVda7nfQN7TU4LaplMRSSJEd8nILX5lYc8fl01oDf3DiUKAtLwXHuPdJhVuHoN+Tg==",
+      "engines": {
+        "node": "^20.0.0",
+        "npm": "^9.0.0 || ^10.0.0"
       }
     },
     "node_modules/@nextcloud/router": {
       }
     },
     "node_modules/@nextcloud/vue": {
-      "version": "8.14.0",
-      "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.14.0.tgz",
-      "integrity": "sha512-hB3dG7tZWpItC74PfbTLW02754qYXFDH+h7Ksq6b7e8WlhnKLWrhNGKhSpNDt9/g+vb5bSIOxbiDZIJZ63hAuQ==",
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-8.15.0.tgz",
+      "integrity": "sha512-Yxf7bIzKV3vCDJDZo99dSLpfe9wMh0hTvmlov5B8V+ZX/foq+O/EcvPivbJmesjIi6LKg+z4K53d7tU2izAPSg==",
       "dependencies": {
         "@floating-ui/dom": "^1.1.0",
         "@linusborg/vue-simple-portal": "^0.1.5",
         "@nextcloud/l10n": "^3.0.1",
         "@nextcloud/logger": "^3.0.1",
         "@nextcloud/router": "^3.0.0",
+        "@nextcloud/sharing": "^0.2.2",
         "@nextcloud/timezones": "^0.1.1",
         "@nextcloud/vue-select": "^3.25.0",
         "@vueuse/components": "^10.9.0",
       "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
       "dev": true
     },
+    "node_modules/@shikijs/core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.11.1.tgz",
+      "integrity": "sha512-Qsn8h15SWgv5TDRoDmiHNzdQO2BxDe86Yq6vIHf5T0cCvmfmccJKIzHtep8bQO9HMBZYCtCBzaXdd1MnxZBPSg==",
+      "peer": true,
+      "dependencies": {
+        "@types/hast": "^3.0.4"
+      }
+    },
     "node_modules/@sideway/address": {
       "version": "4.1.5",
       "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
         "yallist": "^3.0.2"
       }
     },
+    "node_modules/lunr": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
+      "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
+      "peer": true
+    },
     "node_modules/lz-string": {
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
       "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="
     },
     "node_modules/minimatch": {
-      "version": "9.0.4",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz",
-      "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==",
-      "dev": true,
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
       "dependencies": {
         "brace-expansion": "^2.0.1"
       },
         "node": ">=6"
       }
     },
+    "node_modules/punycode.js": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+      "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+      "peer": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/puppeteer": {
       "version": "22.13.0",
       "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.13.0.tgz",
         "node": ">=4"
       }
     },
+    "node_modules/shiki": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.11.1.tgz",
+      "integrity": "sha512-VHD3Q0EBXaaa245jqayBe5zQyMQUdXBFjmGr9MpDaDpAKRMYn7Ff00DM5MLk26UyKjnml3yQ0O2HNX7PtYVNFQ==",
+      "peer": true,
+      "dependencies": {
+        "@shikijs/core": "1.11.1",
+        "@types/hast": "^3.0.4"
+      }
+    },
     "node_modules/side-channel": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz",
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/typedoc": {
+      "version": "0.26.5",
+      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.5.tgz",
+      "integrity": "sha512-Vn9YKdjKtDZqSk+by7beZ+xzkkr8T8CYoiasqyt4TTRFy5+UHzL/mF/o4wGBjRF+rlWQHDb0t6xCpA3JNL5phg==",
+      "peer": true,
+      "dependencies": {
+        "lunr": "^2.3.9",
+        "markdown-it": "^14.1.0",
+        "minimatch": "^9.0.5",
+        "shiki": "^1.9.1",
+        "yaml": "^2.4.5"
+      },
+      "bin": {
+        "typedoc": "bin/typedoc"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x"
+      }
+    },
+    "node_modules/typedoc-plugin-missing-exports": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/typedoc-plugin-missing-exports/-/typedoc-plugin-missing-exports-3.0.0.tgz",
+      "integrity": "sha512-R7D8fYrK34mBFZSlF1EqJxfqiUSlQSmyrCiQgTQD52nNm6+kUtqwiaqaNkuJ2rA2wBgWFecUA8JzHT7x2r7ePg==",
+      "peerDependencies": {
+        "typedoc": "0.26.x"
+      }
+    },
+    "node_modules/typedoc/node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "peer": true
+    },
+    "node_modules/typedoc/node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "peer": true,
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/typedoc/node_modules/linkify-it": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+      "peer": true,
+      "dependencies": {
+        "uc.micro": "^2.0.0"
+      }
+    },
+    "node_modules/typedoc/node_modules/markdown-it": {
+      "version": "14.1.0",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+      "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+      "peer": true,
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "^4.4.0",
+        "linkify-it": "^5.0.0",
+        "mdurl": "^2.0.0",
+        "punycode.js": "^2.3.1",
+        "uc.micro": "^2.1.0"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.mjs"
+      }
+    },
+    "node_modules/typedoc/node_modules/mdurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+      "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+      "peer": true
+    },
+    "node_modules/typedoc/node_modules/uc.micro": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+      "peer": true
+    },
     "node_modules/typescript": {
       "version": "5.5.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
       "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
-      "devOptional": true,
       "bin": {
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "dev": true
     },
+    "node_modules/yaml": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
+      "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
+      "peer": true,
+      "bin": {
+        "yaml": "bin.mjs"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
     "node_modules/yargs": {
       "version": "17.7.2",
       "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
index 0420a00a77779a2532a269e9236a7b910f149be1..7c9888ae492f6e250dc23fc379286c5d5e7c459c 100644 (file)
@@ -48,7 +48,7 @@
     "@nextcloud/capabilities": "^1.2.0",
     "@nextcloud/dialogs": "^5.3.5",
     "@nextcloud/event-bus": "^3.3.1",
-    "@nextcloud/files": "^3.6.0",
+    "@nextcloud/files": "^3.7.0",
     "@nextcloud/initial-state": "^2.2.0",
     "@nextcloud/l10n": "^3.1.0",
     "@nextcloud/logger": "^3.0.2",
@@ -58,7 +58,7 @@
     "@nextcloud/router": "^3.0.0",
     "@nextcloud/sharing": "^0.2.2",
     "@nextcloud/upload": "^1.4.1",
-    "@nextcloud/vue": "^8.14.0",
+    "@nextcloud/vue": "^8.15.0",
     "@simplewebauthn/browser": "^10.0.0",
     "@skjnldsv/sanitize-svg": "^1.0.2",
     "@vueuse/components": "^10.11.0",