From 77e6f3aae5720d6fa7df98a56fb129a42ce93591 Mon Sep 17 00:00:00 2001 From: FinalDoom <677609+FinalDoom@users.noreply.github.com> Date: Tue, 14 Jan 2025 12:54:32 -0700 Subject: [PATCH] feat: add filter by filesystem location + bonus tooltip on overflowed filter names (#518) --- .../components/sidebar/LocationFilters.tsx | 62 ++++++++++++++ .../javascript/components/sidebar/Sidebar.tsx | 2 + .../components/sidebar/SidebarFilter.tsx | 85 +++++++++++++++---- client/src/javascript/i18n/strings/en.json | 1 + .../javascript/stores/TorrentFilterStore.ts | 19 ++++- client/src/javascript/stores/TorrentStore.ts | 9 +- client/src/javascript/util/filterTorrents.ts | 11 ++- .../src/sass/components/_sidebar-filter.scss | 16 ++++ server/.jest/rtorrent.setup.js | 4 +- server/services/taxonomyService.test.ts | 73 ++++++++++++++++ server/services/taxonomyService.ts | 46 +++++++++- shared/types/Taxonomy.ts | 9 ++ 12 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 client/src/javascript/components/sidebar/LocationFilters.tsx create mode 100644 server/services/taxonomyService.test.ts diff --git a/client/src/javascript/components/sidebar/LocationFilters.tsx b/client/src/javascript/components/sidebar/LocationFilters.tsx new file mode 100644 index 00000000..f5c97fd9 --- /dev/null +++ b/client/src/javascript/components/sidebar/LocationFilters.tsx @@ -0,0 +1,62 @@ +import {FC, KeyboardEvent, MouseEvent, ReactNode, TouchEvent} from 'react'; +import {observer} from 'mobx-react'; +import {useLingui} from '@lingui/react'; +import {LocationTreeNode} from '@shared/types/Taxonomy'; + +import SidebarFilter from './SidebarFilter'; +import TorrentFilterStore from '../../stores/TorrentFilterStore'; + +const buildLocationFilterTree = (location: LocationTreeNode): ReactNode => { + if (location.children.length === 1 && location.containedCount === location.children[0].containedCount) { + const onlyChild = location.children[0]; + const separator = onlyChild.fullPath.includes('/') ? '/' : '\\'; + return buildLocationFilterTree({ + ...onlyChild, + directoryName: location.directoryName + separator + onlyChild.directoryName, + }); + } + + const children = location.children.map(buildLocationFilterTree); + + return ( + + TorrentFilterStore.setLocationFilters(filter, event) + } + count={location.containedCount} + key={location.fullPath} + isActive={ + (location.fullPath === '' && !TorrentFilterStore.locationFilter.length) || + TorrentFilterStore.locationFilter.includes(location.fullPath) + } + name={location.directoryName} + slug={location.fullPath} + size={location.containedSize} + > + {(children.length && children) || undefined} + + ); +}; + +const LocationFilters: FC = observer(() => { + const {i18n} = useLingui(); + + if (TorrentFilterStore.taxonomy.locationTree.containedCount === 0) { + return null; + } + + const filterElements = buildLocationFilterTree(TorrentFilterStore.taxonomy.locationTree); + + const title = i18n._('filter.location.title'); + + return ( + + ); +}); + +export default LocationFilters; diff --git a/client/src/javascript/components/sidebar/Sidebar.tsx b/client/src/javascript/components/sidebar/Sidebar.tsx index aa747e80..b272adec 100644 --- a/client/src/javascript/components/sidebar/Sidebar.tsx +++ b/client/src/javascript/components/sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import {OverlayScrollbarsComponent} from 'overlayscrollbars-react'; import DiskUsage from './DiskUsage'; import FeedsButton from './FeedsButton'; import LogoutButton from './LogoutButton'; +import LocationFilters from './LocationFilters'; import NotificationsButton from './NotificationsButton'; import SearchBox from './SearchBox'; import SettingsButton from './SettingsButton'; @@ -44,6 +45,7 @@ const Sidebar: FC = () => ( +
diff --git a/client/src/javascript/components/sidebar/SidebarFilter.tsx b/client/src/javascript/components/sidebar/SidebarFilter.tsx index 49ce599f..b5b3e28a 100644 --- a/client/src/javascript/components/sidebar/SidebarFilter.tsx +++ b/client/src/javascript/components/sidebar/SidebarFilter.tsx @@ -1,11 +1,26 @@ import classnames from 'classnames'; -import {FC, ReactNode, KeyboardEvent, MouseEvent, TouchEvent} from 'react'; +import {createRef, FC, ReactNode, KeyboardEvent, MouseEvent, RefObject, TouchEvent, useEffect, useState} from 'react'; import {useLingui} from '@lingui/react'; +import {Start} from '@client/ui/icons'; import Badge from '../general/Badge'; import Size from '../general/Size'; +const useRefTextOverflowed = (ref: RefObject) => { + const [overflowed, setOverflowed] = useState(false); + + useEffect(() => { + if (ref.current) { + const {current} = ref; + setOverflowed(current.scrollWidth > current.clientWidth); + } + }, [ref, ref?.current?.scrollWidth, ref?.current?.clientWidth]); + + return overflowed; +}; + interface SidebarFilterProps { + children?: ReactNode[]; name: string; icon?: ReactNode; isActive: boolean; @@ -16,6 +31,7 @@ interface SidebarFilterProps { } const SidebarFilter: FC = ({ + children, name: _name, icon, isActive, @@ -24,11 +40,32 @@ const SidebarFilter: FC = ({ size, handleClick, }: SidebarFilterProps) => { + const nameSpanRef = createRef(); + const overflowed = useRefTextOverflowed(nameSpanRef); + const {i18n} = useLingui(); + const [expanded, setExpanded] = useState(false); + const classNames = classnames('sidebar-filter__item', { 'is-active': isActive, }); + const expanderClassNames = classnames('sidebar-filter__expander', { + 'is-active': isActive, + expanded: expanded, + }); + + const flexCss = children + ? { + display: 'flex', + } + : {}; + const focusCss = { + ':focus': { + outline: 'none', + WebkitTapHighlightColor: 'transparent', + }, + }; let name = _name; if (name === '') { @@ -48,23 +85,35 @@ const SidebarFilter: FC = ({ return (
  • - +
    + {children && ( + + )} + +
    + {children && expanded &&
      {children}
    }
  • ); }; diff --git a/client/src/javascript/i18n/strings/en.json b/client/src/javascript/i18n/strings/en.json index 9b7c4220..480dcdcc 100644 --- a/client/src/javascript/i18n/strings/en.json +++ b/client/src/javascript/i18n/strings/en.json @@ -139,6 +139,7 @@ "filter.status.seeding": "Seeding", "filter.status.stopped": "Stopped", "filter.status.title": "Filter by Status", + "filter.location.title": "Filter by Location", "filter.tag.title": "Filter by Tag", "filter.tracker.title": "Filter by Tracker", "filter.untagged": "Untagged", diff --git a/client/src/javascript/stores/TorrentFilterStore.ts b/client/src/javascript/stores/TorrentFilterStore.ts index 0201b6d4..f9c59e12 100644 --- a/client/src/javascript/stores/TorrentFilterStore.ts +++ b/client/src/javascript/stores/TorrentFilterStore.ts @@ -6,6 +6,7 @@ import type {Taxonomy} from '@shared/types/Taxonomy'; import torrentStatusMap, {TorrentStatus} from '@shared/constants/torrentStatusMap'; class TorrentFilterStore { + locationFilter: Array = []; searchFilter = ''; statusFilter: Array = []; tagFilter: Array = []; @@ -14,6 +15,7 @@ class TorrentFilterStore { filterTrigger = false; taxonomy: Taxonomy = { + locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0}, statusCounts: {}, tagCounts: {}, tagSizes: {}, @@ -22,7 +24,13 @@ class TorrentFilterStore { }; @computed get isFilterActive() { - return this.searchFilter !== '' || this.statusFilter.length || this.tagFilter.length || this.trackerFilter.length; + return ( + this.locationFilter.length || + this.searchFilter !== '' || + this.statusFilter.length || + this.tagFilter.length || + this.trackerFilter.length + ); } constructor() { @@ -30,6 +38,7 @@ class TorrentFilterStore { } clearAllFilters() { + this.locationFilter = []; this.searchFilter = ''; this.statusFilter = []; this.tagFilter = []; @@ -50,6 +59,12 @@ class TorrentFilterStore { this.filterTrigger = !this.filterTrigger; } + setLocationFilters(filter: string | '', event: KeyboardEvent | MouseEvent | TouchEvent) { + // keys: [] to disable shift-clicking as it doesn't make sense in a tree + this.computeFilters([], this.locationFilter, filter, event); + this.filterTrigger = !this.filterTrigger; + } + setStatusFilters(filter: TorrentStatus | '', event: KeyboardEvent | MouseEvent | TouchEvent) { this.computeFilters(torrentStatusMap, this.statusFilter, filter, event); this.filterTrigger = !this.filterTrigger; @@ -85,7 +100,7 @@ class TorrentFilterStore { ) { if (newFilter === ('' as T)) { currentFilters.splice(0); - } else if (event.shiftKey) { + } else if (event.shiftKey && keys.length) { if (currentFilters.length) { const lastKey = currentFilters[currentFilters.length - 1]; const lastKeyIndex = keys.indexOf(lastKey); diff --git a/client/src/javascript/stores/TorrentStore.ts b/client/src/javascript/stores/TorrentStore.ts index 742e63d2..6c38d2bf 100644 --- a/client/src/javascript/stores/TorrentStore.ts +++ b/client/src/javascript/stores/TorrentStore.ts @@ -24,10 +24,17 @@ class TorrentStore { } @computed get filteredTorrents(): Array { - const {searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore; + const {locationFilter, searchFilter, statusFilter, tagFilter, trackerFilter} = TorrentFilterStore; let filteredTorrents = Object.assign([], this.sortedTorrents) as Array; + if (locationFilter.length) { + filteredTorrents = filterTorrents(filteredTorrents, { + type: 'location', + filter: locationFilter, + }); + } + if (searchFilter !== '') { filteredTorrents = termMatch(filteredTorrents, (properties) => properties.name, searchFilter); } diff --git a/client/src/javascript/util/filterTorrents.ts b/client/src/javascript/util/filterTorrents.ts index b3d95f5d..5f37fc4e 100644 --- a/client/src/javascript/util/filterTorrents.ts +++ b/client/src/javascript/util/filterTorrents.ts @@ -1,6 +1,11 @@ import type {TorrentProperties} from '@shared/types/Torrent'; import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; +interface LocationFilter { + type: 'location'; + filter: string[]; +} + interface StatusFilter { type: 'status'; filter: TorrentStatus[]; @@ -18,9 +23,13 @@ interface TagFilter { function filterTorrents( torrentList: TorrentProperties[], - opts: StatusFilter | TrackerFilter | TagFilter, + opts: LocationFilter | StatusFilter | TrackerFilter | TagFilter, ): TorrentProperties[] { if (opts.filter.length) { + if (opts.type === 'location') { + return torrentList.filter((torrent) => opts.filter.some((directory) => torrent.directory.startsWith(directory))); + } + if (opts.type === 'status') { return torrentList.filter((torrent) => torrent.status.some((status) => opts.filter.includes(status))); } diff --git a/client/src/sass/components/_sidebar-filter.scss b/client/src/sass/components/_sidebar-filter.scss index 2efd9a03..525435c4 100644 --- a/client/src/sass/components/_sidebar-filter.scss +++ b/client/src/sass/components/_sidebar-filter.scss @@ -8,6 +8,7 @@ padding-top: 0; } + &__expander, &__item { @include themes.theme('color', 'sidebar-filter--foreground'); cursor: pointer; @@ -75,9 +76,24 @@ .size { margin-left: auto; + white-space: nowrap; } } + &__expander { + display: block; + width: 14px; + padding: 0 0 0 20px; + + &.expanded svg { + transform: rotate(90deg); + } + } + + &__nested { + margin-left: 8px; + } + .badge { @include themes.theme('background', 'sidebar-filter--count--background'); @include themes.theme('color', 'sidebar-filter--count--foreground'); diff --git a/server/.jest/rtorrent.setup.js b/server/.jest/rtorrent.setup.js index 16de9c0c..b869de9f 100644 --- a/server/.jest/rtorrent.setup.js +++ b/server/.jest/rtorrent.setup.js @@ -30,7 +30,9 @@ process.argv.push('--test'); process.argv.push('--assets', 'false'); afterAll((done) => { - process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString())); + if (fs.existsSync(`${temporaryRuntimeDirectory}/rtorrent.pid`)) { + process.kill(Number(fs.readFileSync(`${temporaryRuntimeDirectory}/rtorrent.pid`).toString())); + } if (process.env.CI !== 'true') { // TODO: This leads to test flakiness caused by ENOENT error // NeDB provides no method to close database connection diff --git a/server/services/taxonomyService.test.ts b/server/services/taxonomyService.test.ts new file mode 100644 index 00000000..5da66cde --- /dev/null +++ b/server/services/taxonomyService.test.ts @@ -0,0 +1,73 @@ +import {UserInDatabase} from '@shared/schema/Auth'; +import {LocationTreeNode} from '@shared/types/Taxonomy'; + +import TaxonomyService from '../../server/services/taxonomyService'; + +type LocationRecord = {[key: string]: LocationRecord | null}; +const toTreeNodes = (locations: LocationRecord, separator = '/', basePath = '') => + Object.keys(locations).reduce((parentNodes, locationKey) => { + const fullPath = locationKey !== '' ? basePath + separator + locationKey : locationKey; + const subLocations = locations[locationKey]; + if (subLocations) { + const parent = { + directoryName: locationKey, + fullPath: fullPath, + children: toTreeNodes(subLocations, separator, fullPath), + containedCount: 0, + containedSize: 0, + }; + for (const child of parent.children) { + parent.containedCount += child.containedCount; + parent.containedSize += child.containedSize; + } + parentNodes.push(parent); + } else { + parentNodes.push({ + directoryName: locationKey, + fullPath: fullPath, + children: [], + containedCount: '' !== locationKey ? 1 : 0, + containedSize: '' !== locationKey ? 10 : 0, + }); + } + return parentNodes; + }, [] as LocationTreeNode[]); + +describe('taxonomyService', () => { + describe('incrementLocationCountsAndSizes() - locationTree', () => { + for (const locationsAndExpected of [ + // No torrents + {locations: [] as string[], expected: toTreeNodes({'': null})[0]}, + // Single root + { + locations: ['/mnt/dir1/file1', '/mnt/dir1/file2', '/mnt/dir2/file3', '/mnt/file4'], + expected: toTreeNodes({ + '': { + mnt: {dir1: {file1: null, file2: null}, dir2: {file3: null}, file4: null}, + }, + })[0], + }, + // Multiple roots including overlapping case + { + locations: ['/mnt/file1', '/mnt/file2', '/mount/directory1/file3', '/Mount/directory2/file4'], + expected: toTreeNodes({ + '': { + mnt: {file1: null, file2: null}, + mount: {directory1: {file3: null}}, + Mount: {directory2: {file4: null}}, + }, + })[0], + }, + ]) { + const {locations, expected} = locationsAndExpected; + + it(`builds case-sensitive location tree correctly from ${locations}`, () => { + const taxonomyService = new TaxonomyService({} as UserInDatabase); + + for (const location of locations) taxonomyService.incrementLocationCountsAndSizes(location, 10); + + expect(taxonomyService.taxonomy.locationTree).toMatchObject(expected); + }); + } + }); +}); diff --git a/server/services/taxonomyService.ts b/server/services/taxonomyService.ts index bb34e030..2050dc69 100644 --- a/server/services/taxonomyService.ts +++ b/server/services/taxonomyService.ts @@ -1,9 +1,9 @@ +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; +import type {LocationTreeNode, Taxonomy} from '@shared/types/Taxonomy'; +import type {TorrentList, TorrentProperties} from '@shared/types/Torrent'; import jsonpatch, {Operation} from 'fast-json-patch'; -import type {TorrentStatus} from '../../shared/constants/torrentStatusMap'; import torrentStatusMap from '../../shared/constants/torrentStatusMap'; -import type {Taxonomy} from '../../shared/types/Taxonomy'; -import type {TorrentList, TorrentProperties} from '../../shared/types/Torrent'; import BaseService from './BaseService'; type TaxonomyServiceEvents = { @@ -12,6 +12,7 @@ type TaxonomyServiceEvents = { class TaxonomyService extends BaseService { taxonomy: Taxonomy = { + locationTree: {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0}, statusCounts: {'': 0}, tagCounts: {'': 0, untagged: 0}, tagSizes: {}, @@ -60,6 +61,7 @@ class TaxonomyService extends BaseService { handleProcessTorrentListStart = () => { this.lastTaxonomy = { + locationTree: {...this.taxonomy.locationTree}, statusCounts: {...this.taxonomy.statusCounts}, tagCounts: {...this.taxonomy.tagCounts}, tagSizes: {...this.taxonomy.tagSizes}, @@ -71,6 +73,7 @@ class TaxonomyService extends BaseService { this.taxonomy.statusCounts[status] = 0; }); + this.taxonomy.locationTree = {directoryName: '', fullPath: '', children: [], containedCount: 0, containedSize: 0}; this.taxonomy.statusCounts[''] = 0; this.taxonomy.tagCounts = {'': 0, untagged: 0}; this.taxonomy.tagSizes = {}; @@ -96,6 +99,7 @@ class TaxonomyService extends BaseService { }; handleProcessTorrent = (torrentProperties: TorrentProperties) => { + this.incrementLocationCountsAndSizes(torrentProperties.directory, torrentProperties.sizeBytes); this.incrementStatusCounts(torrentProperties.status); this.incrementTagCounts(torrentProperties.tags); this.incrementTagSizes(torrentProperties.tags, torrentProperties.sizeBytes); @@ -103,6 +107,42 @@ class TaxonomyService extends BaseService { this.incrementTrackerSizes(torrentProperties.trackerURIs, torrentProperties.sizeBytes); }; + incrementLocationCountsAndSizes( + directory: TorrentProperties['directory'], + sizeBytes: TorrentProperties['sizeBytes'], + ) { + const separator = directory.includes('/') ? '/' : '\\'; + + const countSizeAndBytesForHierarchy = (parent: LocationTreeNode, pathSplit: string[]) => { + const [nodeName, ...restOfPath] = pathSplit; + let nodeRoot = parent.children.find((treeNode) => treeNode.directoryName === nodeName); + if (!nodeRoot) { + nodeRoot = { + directoryName: nodeName, + fullPath: parent.fullPath + separator + nodeName, + children: [], + containedCount: 0, + containedSize: 0, + }; + parent.children.push(nodeRoot); + } + nodeRoot.containedCount += 1; + nodeRoot.containedSize += sizeBytes; + + if (restOfPath.length) { + countSizeAndBytesForHierarchy(nodeRoot, restOfPath); + } + }; + + const pathSplit = directory.startsWith(separator) + ? directory.split(separator).slice(1) + : directory.split(separator); + + countSizeAndBytesForHierarchy(this.taxonomy.locationTree, pathSplit); + this.taxonomy.locationTree.containedCount += 1; + this.taxonomy.locationTree.containedSize += sizeBytes; + } + incrementStatusCounts(statuses: Array) { statuses.forEach((status) => { this.taxonomy.statusCounts[status] += 1; diff --git a/shared/types/Taxonomy.ts b/shared/types/Taxonomy.ts index 388699d9..de3f459c 100644 --- a/shared/types/Taxonomy.ts +++ b/shared/types/Taxonomy.ts @@ -1,4 +1,13 @@ +export interface LocationTreeNode { + directoryName: string; + fullPath: string; + children: LocationTreeNode[]; + containedCount: number; + containedSize: number; +} + export interface Taxonomy { + locationTree: LocationTreeNode; statusCounts: Record; tagCounts: Record; tagSizes: Record;