From ddbafbef68a9a1fc770411e31f84e15486f142a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Marseault?= <larsluph@larsluph.dev> Date: Thu, 20 Mar 2025 11:28:21 +0100 Subject: [PATCH] feat(Content/Filter): Group extensions of same type (#2204) --- .../Dialogs/BulkRenameFilesDialog.vue | 3 +- .../Dialogs/ContentFilterDialog.vue | 69 +++++--- .../TorrentDetail/Content/ContentNode.vue | 3 +- src/constants/vuetorrent/FileIcon.ts | 167 ++++++++++++------ src/constants/vuetorrent/index.ts | 5 +- src/helpers/index.ts | 5 +- src/helpers/path.spec.ts | 47 ++++- src/helpers/path.ts | 10 ++ src/locales/en.json | 13 ++ 9 files changed, 243 insertions(+), 79 deletions(-) diff --git a/src/components/Dialogs/BulkRenameFilesDialog.vue b/src/components/Dialogs/BulkRenameFilesDialog.vue index 8cdc2268..c7a89de7 100644 --- a/src/components/Dialogs/BulkRenameFilesDialog.vue +++ b/src/components/Dialogs/BulkRenameFilesDialog.vue @@ -1,7 +1,8 @@ <script setup lang="ts"> import HistoryField from '@/components/Core/HistoryField.vue' import { useDialog } from '@/composables' -import { getFileIcon, HistoryKey } from '@/constants/vuetorrent' +import { HistoryKey } from '@/constants/vuetorrent' +import { getFileIcon } from '@/helpers' import { useContentStore } from '@/stores' import { TreeFolder, TreeNode } from '@/types/vuetorrent' import { computed, onMounted, reactive, readonly, ref, watch } from 'vue' diff --git a/src/components/Dialogs/ContentFilterDialog.vue b/src/components/Dialogs/ContentFilterDialog.vue index 565422a8..ec11b3c3 100644 --- a/src/components/Dialogs/ContentFilterDialog.vue +++ b/src/components/Dialogs/ContentFilterDialog.vue @@ -1,11 +1,10 @@ <script setup lang="ts"> -import { useDialog } from '@/composables' +import { useDialog, useI18nUtils } from '@/composables' import { FilePriority } from '@/constants/qbit' -import { comparators, formatData } from '@/helpers' -import { splitExt } from '@/helpers/path.ts' +import { FileType } from '@/constants/vuetorrent' +import { comparators, formatData, getExtType, splitExt } from '@/helpers' import { useContentStore, useVueTorrentStore } from '@/stores' import { computed, reactive } from 'vue' -import { useI18nUtils } from '@/composables' const props = defineProps<{ guid: string @@ -21,15 +20,35 @@ const sizeBoundaries = computed<[number, number]>(() => .map(file => file.size) .reduce((prev, curr) => [prev[0] === -1 || curr < prev[0] ? curr : prev[0], prev[1] === -1 || curr > prev[1] ? curr : prev[1]], [-1, -1]) ) -const fileExtensions = computed(() => Array.from(new Set<string>(contentStore.cachedFiles.map(file => splitExt(file.name)[1])).values())) -const extensionItems = computed(() => - fileExtensions.value - .map(ext => { - if (ext === '') return { title: t('common.none'), value: '' } - else return { title: `.${ext}`, value: ext } - }) - .sort((a, b) => comparators.text.asc(a.title, b.title)) + +const fileExtensionsByType = computed(() => + new Set<string>(contentStore.cachedFiles.map(file => splitExt(file.name)[1])).values().reduce( + (prev, ext) => { + const type = getExtType(ext) + if (Object.keys(prev).includes(type)) { + prev[type].push(ext) + } else { + prev[type] = [ext] + } + return prev + }, + {} as Record<FileType, string[]> + ) ) +const extensionItems = computed(() => + Object.entries(fileExtensionsByType.value) + .sort(([typeA, _1], [typeB, _2]) => { + if (typeA === FileType.UNKNOWN) return 1 + if (typeB === FileType.UNKNOWN) return -1 + return comparators.text.asc(typeA, typeB) + }) + .flatMap(([type, extensions]) => [ + { props: { header: t(`constants.file_type.${type}`) } }, + ...extensions.map(ext => ({ title: `.${ext}`, value: ext })), + { props: { divider: true } } + ]) +) + const priorityOptions = [ { title: t('constants.file_priority.unwanted'), @@ -91,30 +110,38 @@ function close() { <v-card> <v-card-title class="ios-margin"> <v-toolbar color="transparent"> - <v-toolbar-title>{{ $t('torrentDetail.content.filter.title') }}</v-toolbar-title> + <v-toolbar-title>{{ t('torrentDetail.content.filter.title') }}</v-toolbar-title> <v-btn icon="mdi-close" @click="close" /> </v-toolbar> </v-card-title> <v-card-text> <v-row> <v-col cols="4" class="d-flex align-center"> - {{ $t('torrentDetail.content.filter.extensions') }} + {{ t('torrentDetail.content.filter.extensions') }} </v-col> <v-col cols="8"> - <v-select v-model="filters.extensions" :items="extensionItems" :placeholder="$t('common.disabled')" persistent-placeholder multiple hide-details /> + <v-select v-model="filters.extensions" :items="extensionItems" :placeholder="t('common.disabled')" persistent-placeholder multiple hide-details> + <template #item="data"> + <v-list-subheader v-if="data.props.header"> + {{ data.props.header }} + </v-list-subheader> + <v-divider v-else-if="data.props.divider" /> + <v-list-item v-else v-bind="data.props" /> + </template> + </v-select> </v-col> </v-row> <v-row> <v-col cols="4" class="d-flex align-center"> - {{ $t('torrentDetail.content.filter.priority') }} + {{ t('torrentDetail.content.filter.priority') }} </v-col> <v-col cols="8"> - <v-select v-model="filters.priority" :items="priorityOptions" :placeholder="$t('common.disabled')" persistent-placeholder multiple hide-details /> + <v-select v-model="filters.priority" :items="priorityOptions" :placeholder="t('common.disabled')" persistent-placeholder multiple hide-details /> </v-col> </v-row> <v-row> <v-col cols="4" class="d-flex align-center"> - {{ $t('torrentDetail.content.filter.size') }} + {{ t('torrentDetail.content.filter.size') }} </v-col> <v-col cols="8"> <v-range-slider @@ -145,7 +172,7 @@ function close() { <v-row> <v-col cols="12"> {{ - $t('torrentDetail.content.filter.preview', { + t('torrentDetail.content.filter.preview', { count: filterPreview.length, total: contentStore.cachedFiles.length, size: formatData(filterPreviewSize, vuetorrentStore.useBinarySize) @@ -155,8 +182,8 @@ function close() { </v-row> </v-card-text> <v-card-actions> - <v-btn color="error" @click="exclude">{{ $t('torrentDetail.content.filter.exclude') }}</v-btn> - <v-btn color="success" @click="include">{{ $t('torrentDetail.content.filter.include') }}</v-btn> + <v-btn color="error" @click="exclude">{{ t('torrentDetail.content.filter.exclude') }}</v-btn> + <v-btn color="success" @click="include">{{ t('torrentDetail.content.filter.include') }}</v-btn> </v-card-actions> </v-card> </v-dialog> diff --git a/src/components/TorrentDetail/Content/ContentNode.vue b/src/components/TorrentDetail/Content/ContentNode.vue index 373dfd12..8ebe0478 100644 --- a/src/components/TorrentDetail/Content/ContentNode.vue +++ b/src/components/TorrentDetail/Content/ContentNode.vue @@ -1,8 +1,7 @@ <script setup lang="ts"> import { useI18nUtils } from '@/composables' import { FilePriority } from '@/constants/qbit' -import { getFileIcon } from '@/constants/vuetorrent' -import { doesCommand, formatData } from '@/helpers' +import { doesCommand, formatData, getFileIcon } from '@/helpers' import { useContentStore, useVueTorrentStore } from '@/stores' import { TreeNode } from '@/types/vuetorrent' import { storeToRefs } from 'pinia' diff --git a/src/constants/vuetorrent/FileIcon.ts b/src/constants/vuetorrent/FileIcon.ts index 442a19c4..ab7e7386 100644 --- a/src/constants/vuetorrent/FileIcon.ts +++ b/src/constants/vuetorrent/FileIcon.ts @@ -1,57 +1,122 @@ -enum FileIcon { - PDF = 'mdi-file-pdf-box', - IMAGE = 'mdi-file-image', - DOCUMENT = 'mdi-file-document', - INFORMATION = 'mdi-information-variant-box', - MUSIC = 'mdi-music', - VIDEO = 'mdi-movie', - SUBTITLE = 'mdi-subtitles', - ARCHIVE = 'mdi-zip-box-outline', - EXECUTABLE = 'mdi-application-brackets' +export enum FileType { + ARCHIVE = 'archive', + AUDIO = 'audio', + BOOK = 'book', + DOCUMENT = 'document', + EXECUTABLE = 'executable', + IMAGE = 'image', + INFORMATION = 'information', + SCRIPT = 'script', + SUBTITLE = 'subtitle', + VIDEO = 'video', + + UNKNOWN = 'unknown' } -export const typesMap: Record<string, FileIcon> = { - pdf: FileIcon.PDF, +export const typesMap: Record<FileType, string> = { + [FileType.ARCHIVE]: 'mdi-zip-box-outline', + [FileType.AUDIO]: 'mdi-audio', + [FileType.BOOK]: 'mdi-book-open-blank-variant', + [FileType.DOCUMENT]: 'mdi-file-document', + [FileType.EXECUTABLE]: 'mdi-application-brackets', + [FileType.IMAGE]: 'mdi-file-image', + [FileType.INFORMATION]: 'mdi-information-variant-box', + [FileType.SCRIPT]: 'mdi-script-text', + [FileType.SUBTITLE]: 'mdi-subtitles', + [FileType.VIDEO]: 'mdi-movie', - png: FileIcon.IMAGE, - jpg: FileIcon.IMAGE, - jpeg: FileIcon.IMAGE, - tiff: FileIcon.IMAGE, - - doc: FileIcon.DOCUMENT, - docx: FileIcon.DOCUMENT, - txt: FileIcon.DOCUMENT, - - nfo: FileIcon.INFORMATION, - - mp3: FileIcon.MUSIC, - wav: FileIcon.MUSIC, - flac: FileIcon.MUSIC, - - avi: FileIcon.VIDEO, - mp4: FileIcon.VIDEO, - mkv: FileIcon.VIDEO, - mov: FileIcon.VIDEO, - wmv: FileIcon.VIDEO, - - srt: FileIcon.SUBTITLE, - idx: FileIcon.SUBTITLE, - sub: FileIcon.SUBTITLE, - - rar: FileIcon.ARCHIVE, - zip: FileIcon.ARCHIVE, - gz: FileIcon.ARCHIVE, - '7z': FileIcon.ARCHIVE, - iso: FileIcon.ARCHIVE, - - exe: FileIcon.EXECUTABLE, - msi: FileIcon.EXECUTABLE, - dmg: FileIcon.EXECUTABLE, - deb: FileIcon.EXECUTABLE, - jar: FileIcon.EXECUTABLE + [FileType.UNKNOWN]: 'mdi-file' } -export function getFileIcon(filename: string) { - const type = filename.split('.').pop()?.toLowerCase() || '' - return typesMap[type] || 'mdi-file' +export const extMap: Record<string, FileType> = { + '7z': FileType.ARCHIVE, + bz2: FileType.ARCHIVE, + cab: FileType.ARCHIVE, + gz: FileType.ARCHIVE, + iso: FileType.ARCHIVE, + rar: FileType.ARCHIVE, + sfx: FileType.ARCHIVE, + tar: FileType.ARCHIVE, + tgz: FileType.ARCHIVE, + xz: FileType.ARCHIVE, + zip: FileType.ARCHIVE, + + alac: FileType.AUDIO, + flac: FileType.AUDIO, + mp3: FileType.AUDIO, + ogg: FileType.AUDIO, + wav: FileType.AUDIO, + wma: FileType.AUDIO, + + cb7: FileType.BOOK, + cbr: FileType.BOOK, + cbt: FileType.BOOK, + cbz: FileType.BOOK, + epub: FileType.BOOK, + mobi: FileType.BOOK, + + doc: FileType.DOCUMENT, + docx: FileType.DOCUMENT, + htm: FileType.DOCUMENT, + html: FileType.DOCUMENT, + pdf: FileType.DOCUMENT, + rtf: FileType.DOCUMENT, + txt: FileType.DOCUMENT, + xhtml: FileType.DOCUMENT, + + apk: FileType.EXECUTABLE, + app: FileType.EXECUTABLE, + bin: FileType.EXECUTABLE, + deb: FileType.EXECUTABLE, + dmg: FileType.EXECUTABLE, + exe: FileType.EXECUTABLE, + jar: FileType.EXECUTABLE, + msi: FileType.EXECUTABLE, + + avif: FileType.IMAGE, + bmp: FileType.IMAGE, + gif: FileType.IMAGE, + heif: FileType.IMAGE, + jfif: FileType.IMAGE, + jpeg: FileType.IMAGE, + jpg: FileType.IMAGE, + png: FileType.IMAGE, + svg: FileType.IMAGE, + tiff: FileType.IMAGE, + webp: FileType.IMAGE, + + nfo: FileType.INFORMATION, + + bat: FileType.SCRIPT, + c: FileType.SCRIPT, + cmd: FileType.SCRIPT, + com: FileType.SCRIPT, + cpp: FileType.SCRIPT, + cs: FileType.SCRIPT, + css: FileType.SCRIPT, + h: FileType.SCRIPT, + hpp: FileType.SCRIPT, + java: FileType.SCRIPT, + js: FileType.SCRIPT, + py: FileType.SCRIPT, + vbs: FileType.SCRIPT, + + idx: FileType.SUBTITLE, + srt: FileType.SUBTITLE, + sub: FileType.SUBTITLE, + + '3gp': FileType.VIDEO, + avi: FileType.VIDEO, + flv: FileType.VIDEO, + gifv: FileType.VIDEO, + m2ts: FileType.VIDEO, + m4v: FileType.VIDEO, + mkv: FileType.VIDEO, + mov: FileType.VIDEO, + mp4: FileType.VIDEO, + mpeg: FileType.VIDEO, + mpg: FileType.VIDEO, + mts: FileType.VIDEO, + ts: FileType.VIDEO, + wmv: FileType.VIDEO, } diff --git a/src/constants/vuetorrent/index.ts b/src/constants/vuetorrent/index.ts index 0b9ee34d..ff9e893b 100644 --- a/src/constants/vuetorrent/index.ts +++ b/src/constants/vuetorrent/index.ts @@ -5,7 +5,7 @@ import { DashboardDisplayMode } from './DashboardDisplayMode' import { DashboardProperty } from './DashboardProperty' import { DashboardPropertyType } from './DashboardPropertyType' import { FeedState } from './FeedState' -import { getFileIcon, typesMap } from './FileIcon' +import { FileType, extMap, typesMap } from './FileIcon' import { FilterType } from './FilterType' import { HistoryKey } from './HistoryKey' import { ThemeMode } from './ThemeMode' @@ -27,8 +27,9 @@ export { DashboardProperty, DashboardPropertyType, FeedState, - getFileIcon, + FileType, typesMap, + extMap, FilterType, HistoryKey, ThemeMode, diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 3cb3050d..aea95e13 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -3,7 +3,7 @@ import comparators, { Comparator, isObjectEqual } from './comparators' import { formatDataValue, formatDataUnit, formatData } from './data' import { QBIT_MAX_ETA, INFINITY_SYMBOL, formatEta, formatTimeMs, formatTimeSec, formatDuration } from './datetime' import { toPrecision, formatPercent } from './number' -import { basename } from './path' +import { basename, splitExt, getFileIcon, getExtType } from './path' import { formatSpeedValue, formatSpeedUnit, formatSpeed } from './speed' import { isWindows, isMac, doesCommand, openLink, downloadFile } from './system' import { titleCase, capitalize, extractHostname, getDomainBody, splitByUrl, containsUrl, isValidUri, codeToFlag } from './text' @@ -27,6 +27,9 @@ export { toPrecision, formatPercent, basename, + splitExt, + getFileIcon, + getExtType, formatSpeedValue, formatSpeedUnit, formatSpeed, diff --git a/src/helpers/path.spec.ts b/src/helpers/path.spec.ts index 111bcb0f..e9f0daf5 100644 --- a/src/helpers/path.spec.ts +++ b/src/helpers/path.spec.ts @@ -1,4 +1,5 @@ -import { basename, splitExt } from './path' +import { FileType } from '@/constants/vuetorrent' +import { basename, splitExt, getExtType, getFileIcon } from './path' describe('helpers/path/basename', () => { test('*NIX :: should return basename on files', () => { @@ -73,3 +74,47 @@ describe('helpers/path/splitExt', () => { expect(splitExt('.txt')).toEqual(['.txt', '']) }) }) + +describe('helpers/path/getExtType', () => { + it('should return the correct type for a known extension', () => { + expect(getExtType('txt')).toEqual(FileType.DOCUMENT) + }) + + it('should return unknown for an unknown extension', () => { + expect(getExtType('unknown')).toBe(FileType.UNKNOWN) + }) + + it('should return unknown for files without extension', () => { + expect(getExtType('file')).toBe(FileType.UNKNOWN) + }) + + it('should handle files starting with a dot', () => { + expect(getExtType('.hiddenfile')).toBe(FileType.UNKNOWN) + }) +}) + +describe('helpers/path/getFileIcon', () => { + it('should return the correct icon for a known file type', () => { + expect(getFileIcon('file.txt')).toEqual('mdi-file-document') + }) + + it('should return the default icon for an unknown file type', () => { + expect(getFileIcon('file.unknown')).toEqual('mdi-file') + }) + + it('should handle filenames with multiple dots correctly', () => { + expect(getFileIcon('my.file.name.txt')).toEqual('mdi-file-document') + }) + + it('should return the default icon for files without extension', () => { + expect(getFileIcon('file')).toEqual('mdi-file') + }) + + it('should handle files starting with a dot', () => { + expect(getFileIcon('.hiddenfile')).toEqual('mdi-file') + }) + + it('should treat files with only extension as hidden', () => { + expect(getFileIcon('.txt')).toEqual('mdi-file') + }) +}) diff --git a/src/helpers/path.ts b/src/helpers/path.ts index fa9044d3..39bc72f3 100644 --- a/src/helpers/path.ts +++ b/src/helpers/path.ts @@ -1,3 +1,5 @@ +import { extMap, FileType, typesMap } from '@/constants/vuetorrent' + export function basename(path: string | null | undefined) { if (!path) return '' @@ -17,3 +19,11 @@ export function splitExt(path: string | null | undefined): [string, string] { const ext = groups.pop()! return [groups.join('.'), ext] } + +export function getExtType(ext: string) { + return extMap[ext] || FileType.UNKNOWN +} + +export function getFileIcon(filename: string) { + return typesMap[getExtType(splitExt(filename)[1])] +} diff --git a/src/locales/en.json b/src/locales/en.json index f35078e3..bc3ab050 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -80,6 +80,19 @@ "normal": "Normal", "unwanted": "Unwanted" }, + "file_type": { + "archive": "Archives", + "audio": "Audio", + "book": "Books", + "document": "Documents", + "executable": "Executables", + "image": "Images", + "information": "Information", + "script": "Scripts", + "subtitle": "Subtitles", + "unknown": "Unknown", + "video": "Video" + }, "filter_type": { "conjunctive": "Conjunctive filtering (AND)", "disjunctive": "Disjunctive filtering (OR)"