mirror of
https://github.com/nextcloud/server.git
synced 2026-05-22 10:06:37 -04:00
feat(recent-files): implement image grouping based on configuration
Signed-off-by: Cristian Scheid <cristianscheid@gmail.com>
This commit is contained in:
parent
60d71a99e2
commit
a967660432
9 changed files with 585 additions and 23 deletions
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
apps/files/src/components/FileEntryImageGroup.vue
Normal file
145
apps/files/src/components/FileEntryImageGroup.vue
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<tr
|
||||
class="files-list__row files-list__row--image-group"
|
||||
:class="{
|
||||
'files-list__row--image-group-expanded': source.expanded,
|
||||
'files-list__row--active': isSelected,
|
||||
}">
|
||||
<td class="files-list__row-checkbox" @click.stop>
|
||||
<NcCheckboxRadioSwitch
|
||||
:aria-label="t('files', 'Toggle selection for image group')"
|
||||
:modelValue="isSelected"
|
||||
:indeterminate="isPartiallySelected"
|
||||
@update:modelValue="onSelectionChange" />
|
||||
</td>
|
||||
|
||||
<td class="files-list__row-name" @click="$emit('toggle', source.source)">
|
||||
<span class="files-list__row-icon">
|
||||
<ImageMultipleIcon :size="20" />
|
||||
</span>
|
||||
|
||||
<span class="files-list__row-image-group-chevron">
|
||||
<ChevronRightIcon v-if="!source.expanded" :size="20" />
|
||||
<ChevronDownIcon v-else :size="20" />
|
||||
</span>
|
||||
|
||||
<span class="files-list__row-name-text">
|
||||
{{ n('files', '{count} image', '{count} images', source.images.length, { count: source.images.length }) }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td v-if="isMimeAvailable" class="files-list__row-mime" />
|
||||
<td v-if="isSizeAvailable" class="files-list__row-size" />
|
||||
<td v-if="isMtimeAvailable" class="files-list__row-mtime" />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { ImageGroupNode } from '../composables/useImageGrouping.ts'
|
||||
|
||||
import { n, t } from '@nextcloud/l10n'
|
||||
import { defineComponent } from 'vue'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
|
||||
import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue'
|
||||
import ChevronRightIcon from 'vue-material-design-icons/ChevronRight.vue'
|
||||
import ImageMultipleIcon from 'vue-material-design-icons/ImageMultiple.vue'
|
||||
import { useSelectionStore } from '../store/selection.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEntryImageGroup',
|
||||
|
||||
components: {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ImageMultipleIcon,
|
||||
NcCheckboxRadioSwitch,
|
||||
},
|
||||
|
||||
props: {
|
||||
source: {
|
||||
type: Object as PropType<ImageGroupNode>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
isMimeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isSizeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isMtimeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['toggle'],
|
||||
|
||||
setup() {
|
||||
const selectionStore = useSelectionStore()
|
||||
return { selectionStore, n, t }
|
||||
},
|
||||
|
||||
computed: {
|
||||
childSources() {
|
||||
return this.source.images.map((img) => img.source)
|
||||
},
|
||||
|
||||
isSelected() {
|
||||
return this.childSources.every((src) => this.selectionStore.selected.includes(src))
|
||||
},
|
||||
|
||||
isPartiallySelected() {
|
||||
return !this.isSelected && this.childSources.some((src) => this.selectionStore.selected.includes(src))
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onSelectionChange(selected: boolean) {
|
||||
const current = this.selectionStore.selected
|
||||
if (selected) {
|
||||
// select all children
|
||||
this.selectionStore.set([...new Set([...current, ...this.childSources])])
|
||||
} else {
|
||||
// unselect all children
|
||||
this.selectionStore.set(current.filter((src) => !this.childSources.includes(src)))
|
||||
}
|
||||
},
|
||||
|
||||
onRowClick() {
|
||||
this.onSelectionChange(!this.isSelected)
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.files-list__row--image-group {
|
||||
.files-list__row-name {
|
||||
cursor: pointer;
|
||||
* {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.files-list__row-image-group-chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
|
||||
.files-list__row-name-text {
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
92
apps/files/src/components/FileEntryWrapper.vue
Normal file
92
apps/files/src/components/FileEntryWrapper.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<FileEntryImageGroup
|
||||
v-if="isGroup"
|
||||
:source="source"
|
||||
:isMimeAvailable="isMimeAvailable"
|
||||
:isSizeAvailable="isSizeAvailable"
|
||||
:isMtimeAvailable="isMtimeAvailable"
|
||||
@toggle="onToggleGroup?.($event)" />
|
||||
|
||||
<component
|
||||
:is="entryComponent"
|
||||
v-else
|
||||
:source="source"
|
||||
:class="{ 'files-list__row--group-child': isGroupChild }"
|
||||
v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { GroupedNode } from '../composables/useImageGrouping.ts'
|
||||
|
||||
import { defineComponent } from 'vue'
|
||||
import FileEntry from './FileEntry.vue'
|
||||
import FileEntryGrid from './FileEntryGrid.vue'
|
||||
import FileEntryImageGroup from './FileEntryImageGroup.vue'
|
||||
import { isImageGroup } from '../composables/useImageGrouping.ts'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileEntryWrapper',
|
||||
|
||||
components: {
|
||||
FileEntry,
|
||||
FileEntryGrid,
|
||||
FileEntryImageGroup,
|
||||
},
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
source: {
|
||||
type: Object as PropType<GroupedNode>,
|
||||
required: true,
|
||||
},
|
||||
|
||||
gridMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isMimeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isSizeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
isMtimeAvailable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
onToggleGroup: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['toggle-group'],
|
||||
|
||||
computed: {
|
||||
isGroup(): boolean {
|
||||
return isImageGroup(this.source)
|
||||
},
|
||||
|
||||
isGroupChild(): boolean {
|
||||
return '_isGroupChild' in this.source
|
||||
},
|
||||
|
||||
entryComponent() {
|
||||
return this.gridMode ? FileEntryGrid : FileEntry
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<!--
|
||||
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { t } from '@nextcloud/l10n'
|
||||
import { NcFormBoxSwitch, NcInputField, NcSelect } from '@nextcloud/vue'
|
||||
import debounce from 'debounce'
|
||||
import { ref, watch } from 'vue'
|
||||
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
|
||||
import NcFormBox from '@nextcloud/vue/components/NcFormBox'
|
||||
import { useUserConfigStore } from '../../store/userconfig.ts'
|
||||
|
||||
const store = useUserConfigStore()
|
||||
|
||||
const availableMimetypes = [
|
||||
{ id: 'image/png', label: 'PNG' },
|
||||
{ id: 'image/jpeg', label: 'JPEG' },
|
||||
{ id: 'image/gif', label: 'GIF' },
|
||||
{ id: 'image/webp', label: 'WebP' },
|
||||
{ id: 'image/avif', label: 'AVIF' },
|
||||
{ id: 'image/heic', label: 'HEIC' },
|
||||
{ id: 'image/heif', label: 'HEIF' },
|
||||
]
|
||||
|
||||
const storedMimetypes = store.userConfig.recent_files_group_mimetypes
|
||||
const initialMimetypes = Array.isArray(storedMimetypes)
|
||||
? availableMimetypes.filter((m) => storedMimetypes.includes(m.id))
|
||||
: []
|
||||
|
||||
const selectedMimetypes = ref(initialMimetypes)
|
||||
|
||||
const debouncedUpdateMimetypes = debounce((value) => {
|
||||
store.update('recent_files_group_mimetypes', JSON.stringify(value.map((v) => v.id)))
|
||||
}, 500)
|
||||
|
||||
watch(selectedMimetypes, (value) => {
|
||||
debouncedUpdateMimetypes(value)
|
||||
})
|
||||
|
||||
const debouncedUpdateTimespan = debounce((value: number) => {
|
||||
store.update('recent_files_group_timespan_minutes', value)
|
||||
}, 500)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NcAppSettingsSection id="recent" :name="t('files', 'Recent view')">
|
||||
<NcFormBox>
|
||||
<NcFormBoxSwitch
|
||||
v-model="store.userConfig.group_recent_files_images"
|
||||
:label="t('files', 'Group image files')"
|
||||
@update:modelValue="store.update('group_recent_files_images', $event)" />
|
||||
<label>{{ t('files', 'Group these image types together') }}</label>
|
||||
<NcSelect
|
||||
v-model="selectedMimetypes"
|
||||
:options="availableMimetypes"
|
||||
labelOutside
|
||||
multiple />
|
||||
<NcInputField
|
||||
v-model="store.userConfig.recent_files_group_timespan_minutes"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:label="t('files', 'Time window in minutes to group files uploaded close together')"
|
||||
@update:modelValue="debouncedUpdateTimespan(Number($event))" />
|
||||
</NcFormBox>
|
||||
</NcAppSettingsSection>
|
||||
</template>
|
||||
|
|
@ -2,20 +2,23 @@
|
|||
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
-->
|
||||
|
||||
<template>
|
||||
<VirtualList
|
||||
ref="table"
|
||||
:data-component="userConfig.grid_view ? FileEntryGrid : FileEntry"
|
||||
:data-component="FileEntryWrapper"
|
||||
data-key="source"
|
||||
:data-sources="nodes"
|
||||
:grid-mode="userConfig.grid_view"
|
||||
:extra-props="{
|
||||
:data-sources="groupedNodes"
|
||||
:gridMode="userConfig.grid_view"
|
||||
:extraProps="{
|
||||
gridMode: userConfig.grid_view,
|
||||
isMimeAvailable,
|
||||
isMtimeAvailable,
|
||||
isSizeAvailable,
|
||||
nodes,
|
||||
onToggleGroup: toggleGroup,
|
||||
}"
|
||||
:scroll-to-index="scrollToIndex"
|
||||
:scrollToIndex="scrollToIndex"
|
||||
:caption="caption">
|
||||
<!-- eslint-disable-next-line vue/singleline-html-element-content-newline -- no space allowed as otherwise `:empty` css selector does not trigger! -->
|
||||
<template #filters><FileListFilterToSearch /><FileListFilterChips /></template>
|
||||
|
|
@ -25,8 +28,8 @@
|
|||
{{ n('files', '{count} selected', '{count} selected', selectedNodes.length, { count: selectedNodes.length }) }}
|
||||
</span>
|
||||
<FilesListTableHeaderActions
|
||||
:current-view="currentView"
|
||||
:selected-nodes="selectedNodes" />
|
||||
:currentView="currentView"
|
||||
:selectedNodes="selectedNodes" />
|
||||
</template>
|
||||
|
||||
<template #before>
|
||||
|
|
@ -34,8 +37,8 @@
|
|||
<FilesListHeader
|
||||
v-for="header in headers"
|
||||
:key="header.id"
|
||||
:current-folder="currentFolder"
|
||||
:current-view="currentView"
|
||||
:currentFolder="currentFolder"
|
||||
:currentView="currentView"
|
||||
:header="header" />
|
||||
</template>
|
||||
|
||||
|
|
@ -44,9 +47,9 @@
|
|||
<!-- Table header and sort buttons -->
|
||||
<FilesListTableHeader
|
||||
ref="thead"
|
||||
:is-mime-available="isMimeAvailable"
|
||||
:is-mtime-available="isMtimeAvailable"
|
||||
:is-size-available="isSizeAvailable"
|
||||
:isMimeAvailable="isMimeAvailable"
|
||||
:isMtimeAvailable="isMtimeAvailable"
|
||||
:isSizeAvailable="isSizeAvailable"
|
||||
:nodes="nodes" />
|
||||
</template>
|
||||
|
||||
|
|
@ -58,10 +61,10 @@
|
|||
<!-- Tfoot-->
|
||||
<template #footer>
|
||||
<FilesListTableFooter
|
||||
:current-view="currentView"
|
||||
:is-mime-available="isMimeAvailable"
|
||||
:is-mtime-available="isMtimeAvailable"
|
||||
:is-size-available="isSizeAvailable"
|
||||
:currentView="currentView"
|
||||
:isMimeAvailable="isMimeAvailable"
|
||||
:isMtimeAvailable="isMtimeAvailable"
|
||||
:isSizeAvailable="isSizeAvailable"
|
||||
:nodes="nodes"
|
||||
:summary="summary" />
|
||||
</template>
|
||||
|
|
@ -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<string>())
|
||||
|
||||
const groupingConfig = computed<ImageGroupingConfig>(() => {
|
||||
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;
|
||||
|
|
|
|||
138
apps/files/src/composables/useImageGrouping.ts
Normal file
138
apps/files/src/composables/useImageGrouping.ts
Normal file
|
|
@ -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<INode[]>,
|
||||
expandedGroups: Ref<Set<string>>,
|
||||
config: Ref<ImageGroupingConfig>,
|
||||
) {
|
||||
return computed<GroupedNode[]>(() => {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<NcAppSettingsDialog
|
||||
:legacy="false"
|
||||
:name="t('files', 'Files settings')"
|
||||
no-version
|
||||
noVersion
|
||||
:open="open"
|
||||
show-navigation
|
||||
showNavigation
|
||||
@update:open="emit('close')">
|
||||
<FilesAppSettingsGeneral />
|
||||
<FilesAppSettingsAppearance />
|
||||
<FilesAppSettingsLegacyApi />
|
||||
<FilesAppSettingsWarnings />
|
||||
<FilesAppSettingsWebDav />
|
||||
<FilesAppSettingsRecent />
|
||||
<FilesAppSettingsShortcuts />
|
||||
</NcAppSettingsDialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue