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 (
+
+ -
+ {title}
+
+ {filterElements}
+
+ );
+});
+
+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 && }
);
};
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;