diff --git a/apps/files/lib/Service/UserConfig.php b/apps/files/lib/Service/UserConfig.php index dcf30b7796d..106199fd7fe 100644 --- a/apps/files/lib/Service/UserConfig.php +++ b/apps/files/lib/Service/UserConfig.php @@ -79,6 +79,33 @@ class UserConfig { 'default' => true, 'allowed' => [true, false], ], + [ + // Whether to group images on recent files list or not + 'key' => 'group_recent_files_images', + 'default' => false, + 'allowed' => [true, false], + ], + [ + // Which image mime types to group in the recent files list + 'key' => 'recent_files_group_mimetypes', + 'default' => '', + 'allowed' => [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/heic', + 'image/heif', + ] + ], + [ + // Time window in minutes to group files uploaded close together in the recent files list + 'key' => 'recent_files_group_timespan_minutes', + 'default' => 2, + 'min' => 1, + 'max' => 999, + ], ]; protected ?IUser $user = null; @@ -118,7 +145,7 @@ class UserConfig { * Get the default config value for a given key * * @param string $key a valid config key - * @return string|bool + * @return string|bool|int */ private function getDefaultConfigValue(string $key) { foreach (self::ALLOWED_CONFIGS as $config) { @@ -146,7 +173,25 @@ class UserConfig { throw new \InvalidArgumentException('Unknown config key'); } - if (!in_array($value, $this->getAllowedConfigValues($key))) { + if (is_string($value) && str_starts_with($value, '[') && str_ends_with($value, ']')) { + $value = json_decode($value, true) ?? $value; + } + + $config = $this->getConfigDefinition($key); + + if (isset($config['min'], $config['max'])) { + if ((int)$value < $config['min'] || (int)$value > $config['max']) { + throw new \InvalidArgumentException('Invalid config value'); + } + } elseif (is_array($value)) { + $allowedValues = $this->getAllowedConfigValues($key); + foreach ($value as $v) { + if (!in_array($v, $allowedValues)) { + throw new \InvalidArgumentException('Invalid config value'); + } + } + $value = json_encode($value); + } elseif (!in_array($value, $this->getAllowedConfigValues($key))) { throw new \InvalidArgumentException('Invalid config value'); } @@ -174,9 +219,27 @@ class UserConfig { if (is_bool($this->getDefaultConfigValue($key)) && is_string($value)) { return $value === '1'; } + if (is_string($value) && str_starts_with($value, '[') && str_ends_with($value, ']')) { + $value = json_decode($value, true) ?? $value; + } return $value; }, $this->getAllowedConfigKeys()); return array_combine($this->getAllowedConfigKeys(), $userConfigs); } + + /** + * Get the config definition for a given key + * + * @param string $key + * @return array + */ + private function getConfigDefinition(string $key): array { + foreach (self::ALLOWED_CONFIGS as $config) { + if ($config['key'] === $key) { + return $config; + } + } + return []; + } } diff --git a/apps/files/src/components/FileEntryImageGroup.vue b/apps/files/src/components/FileEntryImageGroup.vue new file mode 100644 index 00000000000..5f4c5ea7bc1 --- /dev/null +++ b/apps/files/src/components/FileEntryImageGroup.vue @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + {{ n('files', '{count} image', '{count} images', source.images.length, { count: source.images.length }) }} + + + + + + + + + + + + diff --git a/apps/files/src/components/FileEntryWrapper.vue b/apps/files/src/components/FileEntryWrapper.vue new file mode 100644 index 00000000000..3e1ceadb591 --- /dev/null +++ b/apps/files/src/components/FileEntryWrapper.vue @@ -0,0 +1,92 @@ + + + + + + + + + diff --git a/apps/files/src/components/FilesAppSettings/FilesAppSettingsRecent.vue b/apps/files/src/components/FilesAppSettings/FilesAppSettingsRecent.vue new file mode 100644 index 00000000000..c7a06d8c3f1 --- /dev/null +++ b/apps/files/src/components/FilesAppSettings/FilesAppSettingsRecent.vue @@ -0,0 +1,69 @@ + + + + + + + + + {{ t('files', 'Group these image types together') }} + + + + + diff --git a/apps/files/src/components/FilesListVirtual.vue b/apps/files/src/components/FilesListVirtual.vue index 256f9361ec0..4129e55fe42 100644 --- a/apps/files/src/components/FilesListVirtual.vue +++ b/apps/files/src/components/FilesListVirtual.vue @@ -2,20 +2,23 @@ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - SPDX-License-Identifier: AGPL-3.0-or-later --> + @@ -25,8 +28,8 @@ {{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }} + :currentView="currentView" + :selectedNodes="selectedNodes" /> @@ -34,8 +37,8 @@ @@ -44,9 +47,9 @@ @@ -58,10 +61,10 @@ @@ -77,9 +80,10 @@ import { showError } from '@nextcloud/dialogs' import { FileType, Folder, getSidebar, Permission, View } from '@nextcloud/files' import { n, t } from '@nextcloud/l10n' import { useHotKey } from '@nextcloud/vue/composables/useHotKey' -import { computed, defineComponent } from 'vue' +import { computed, defineComponent, shallowRef } from 'vue' import FileEntry from './FileEntry.vue' import FileEntryGrid from './FileEntryGrid.vue' +import FileEntryWrapper from './FileEntryWrapper.vue' import FileListFilterChips from './FileListFilter/FileListFilterChips.vue' import FileListFilterToSearch from './FileListFilter/FileListFilterToSearch.vue' import FilesListHeader from './FilesListHeader.vue' @@ -90,6 +94,8 @@ import VirtualList from './VirtualList.vue' import { useEnabledFileActions } from '../composables/useFileActions.ts' import { useFileListHeaders } from '../composables/useFileListHeaders.ts' import { useFileListWidth } from '../composables/useFileListWidth.ts' +// eslint-disable-next-line perfectionist/sort-named-imports +import { type ImageGroupingConfig, useImageGrouping } from '../composables/useImageGrouping.ts' import { useRouteParameters } from '../composables/useRouteParameters.ts' import logger from '../logger.ts' import { useActiveStore } from '../store/active.ts' @@ -167,6 +173,42 @@ export default defineComponent({ return props.nodes.some((node: INode) => node.size !== undefined) }) + const expandedGroups = shallowRef(new Set()) + + const groupingConfig = computed(() => { + const isRecentView = props.currentView.id === 'recent' + const isGroupingEnabled = userConfigStore.userConfig.group_recent_files_images === true + const configuredMimetypes = userConfigStore.userConfig.recent_files_group_mimetypes + + const mimetypes = isRecentView && isGroupingEnabled && Array.isArray(configuredMimetypes) + ? configuredMimetypes + : [] + + return { + mimetypes, + timespanMinutes: Number(userConfigStore.userConfig.recent_files_group_timespan_minutes) || 2, + } + }) + + const groupedNodes = useImageGrouping( + computed(() => props.nodes), + expandedGroups, + groupingConfig, + ) + + /** + * + * @param groupKey + */ + function toggleGroup(groupKey: string) { + if (expandedGroups.value.has(groupKey)) { + expandedGroups.value.delete(groupKey) + } else { + expandedGroups.value.add(groupKey) + } + expandedGroups.value = new Set(expandedGroups.value) + } + return { fileId, headers: useFileListHeaders(), @@ -183,6 +225,10 @@ export default defineComponent({ n, t, + + groupedNodes, + toggleGroup, + FileEntryWrapper, } }, @@ -537,6 +583,10 @@ export default defineComponent({ } } + .files-list__row--group-child { + padding-inline-start: 12px; + } + // Before table and thead .files-list__before { display: flex; diff --git a/apps/files/src/composables/useImageGrouping.ts b/apps/files/src/composables/useImageGrouping.ts new file mode 100644 index 00000000000..e1e46e3da07 --- /dev/null +++ b/apps/files/src/composables/useImageGrouping.ts @@ -0,0 +1,138 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode } from '@nextcloud/files' + +// eslint-disable-next-line perfectionist/sort-named-imports +import { type Ref, computed } from 'vue' + +export interface ImageGroupNode { + _isImageGroup: true + // Stable key for VirtualList recycled pool + source: string + images: INode[] + expanded: boolean +} + +export type GroupedNode = INode | ImageGroupNode | (INode & { _isGroupChild: true }) + +/** + * + * @param node + */ +export function isImageGroup(node: GroupedNode): node is ImageGroupNode { + return '_isImageGroup' in node && node._isImageGroup === true +} + +export interface ImageGroupingConfig { + mimetypes: string[] + timespanMinutes: number +} + +/** + * + * @param node + */ +function getNodeTime(node: INode): number { + const uploadTime = (node.attributes?.upload_time as number) ?? 0 + const crtime = (node.attributes?.crtime as number) ?? 0 + const mtime = node.mtime ? Math.floor(node.mtime / 1000) : 0 + return Math.max(uploadTime, crtime, mtime) +} + +/** + * + * @param nodes + * @param expandedGroups + * @param config + */ +export function useImageGrouping( + nodes: Ref, + expandedGroups: Ref>, + config: Ref, +) { + return computed(() => { + const result: GroupedNode[] = [] + let i = 0 + + const { mimetypes, timespanMinutes } = config.value + const timespan = timespanMinutes * 60 + + if (mimetypes.length === 0) { + return nodes.value + } + + const isGroupable = (node: INode) => node.mime !== undefined && mimetypes.includes(node.mime) + + while (i < nodes.value.length) { + const node = nodes.value[i] + + if (!isGroupable(node)) { + result.push(node) + i++ + continue + } + + const groupStartTime = getNodeTime(node) + + // Look ahead: if any non-image falls within the timespan, don't group + const isContaminated = groupStartTime && nodes.value.slice(i + 1).some((next) => { + const nextTime = getNodeTime(next) + if (!nextTime) { + return false + } + if (Math.abs(nextTime - groupStartTime) > timespan) { + return false + } + return !isGroupable(next) + }) + + if (isContaminated) { + result.push(node) + i++ + continue + } + + // Start a new group from this image + const images: INode[] = [node] + i++ + + while (i < nodes.value.length) { + const next = nodes.value[i] + + if (!isGroupable(next)) { + break + } + + const nextTime = getNodeTime(next) + if (!groupStartTime || !nextTime || Math.abs(nextTime - groupStartTime) > timespan) { + break + } + + images.push(next) + i++ + } + + if (images.length === 1) { + result.push(images[0]) + continue + } + + const groupKey = `image-group-${images.map((n) => n.fileid).join('-')}` + result.push({ + _isImageGroup: true, + source: groupKey, + images, + expanded: expandedGroups.value.has(groupKey), + }) + + if (expandedGroups.value.has(groupKey)) { + result.push(...images.map((img) => Object.assign(Object.create(Object.getPrototypeOf(img)), img, { _isGroupChild: true }))) + } + } + + return result + }) +} diff --git a/apps/files/src/types.ts b/apps/files/src/types.ts index f87c0c4b8e7..43d6d89d513 100644 --- a/apps/files/src/types.ts +++ b/apps/files/src/types.ts @@ -51,7 +51,7 @@ export interface PathOptions { // User config store export interface UserConfig { - [key: string]: boolean | string | undefined + [key: string]: boolean | string | string[] | number | undefined crop_image_previews: boolean default_view: 'files' | 'personal' @@ -59,6 +59,9 @@ export interface UserConfig { grid_view: boolean sort_favorites_first: boolean sort_folders_first: boolean + group_recent_files_images: boolean + recent_files_group_mimetypes: string[] | string + recent_files_group_timespan_minutes: number show_files_extensions: boolean show_hidden: boolean diff --git a/apps/files/src/views/FilesAppSettings.vue b/apps/files/src/views/FilesAppSettings.vue index 7772b3d89c2..a79fc8e1c42 100644 --- a/apps/files/src/views/FilesAppSettings.vue +++ b/apps/files/src/views/FilesAppSettings.vue @@ -11,6 +11,7 @@ import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog' import FilesAppSettingsAppearance from '../components/FilesAppSettings/FilesAppSettingsAppearance.vue' import FilesAppSettingsGeneral from '../components/FilesAppSettings/FilesAppSettingsGeneral.vue' import FilesAppSettingsLegacyApi from '../components/FilesAppSettings/FilesAppSettingsLegacyApi.vue' +import FilesAppSettingsRecent from '../components/FilesAppSettings/FilesAppSettingsRecent.vue' import FilesAppSettingsShortcuts from '../components/FilesAppSettings/FilesAppSettingsShortcuts.vue' import FilesAppSettingsWarnings from '../components/FilesAppSettings/FilesAppSettingsWarnings.vue' import FilesAppSettingsWebDav from '../components/FilesAppSettings/FilesAppSettingsWebDav.vue' @@ -48,15 +49,16 @@ async function showKeyboardShortcuts() { + diff --git a/lib/private/Files/Cache/QuerySearchHelper.php b/lib/private/Files/Cache/QuerySearchHelper.php index 91a35cb63da..0f0b6f986f2 100644 --- a/lib/private/Files/Cache/QuerySearchHelper.php +++ b/lib/private/Files/Cache/QuerySearchHelper.php @@ -159,7 +159,7 @@ class QuerySearchHelper { || in_array('upload_time', $requestedFields) || in_array('last_activity', $orderFields); - $query = $builder->selectFileCache('file', $joinExtendedCache); + $query = $builder->selectFileCache('file', true); if (in_array('systemtag', $requestedFields)) { $this->equipQueryForSystemTags($query, $this->requireUser($searchQuery));