diff --git a/app/(tabs)/search.tsx b/app/(tabs)/search.tsx index dc951df5..44f8ca0b 100644 --- a/app/(tabs)/search.tsx +++ b/app/(tabs)/search.tsx @@ -55,7 +55,7 @@ import { haptics } from '@/utils/haptics'; const ALL = 'all'; const ENABLED = 'enabled'; -type SortKey = 'seeders' | 'size' | 'name' | 'leechers'; +type SortKey = 'seeders' | 'size' | 'name' | 'leechers' | 'date'; const SORT_OPTIONS: Array<{ key: SortKey; @@ -66,6 +66,7 @@ const SORT_OPTIONS: Array<{ { key: 'leechers', labelKey: 'screens.search.sortLeechers', icon: 'arrow-down-outline' }, { key: 'size', labelKey: 'screens.search.sortSize', icon: 'cube-outline' }, { key: 'name', labelKey: 'screens.search.sortName', icon: 'text-outline' }, + { key: 'date', labelKey: 'screens.search.sortDate', icon: 'calendar-outline' }, ]; const TAG_MATCH_ATTEMPTS = 8; @@ -234,6 +235,13 @@ export default function SearchScreen() { const [pendingAddUrl, setPendingAddUrl] = useState(null); const [actionResult, setActionResult] = useState(null); + // pubDate is a qBit 5.0+ (WebAPI >= 2.11.0) field — hide the option on older + // servers rather than offering a sort that silently does nothing. + const visibleSortOptions = useMemo( + () => SORT_OPTIONS.filter((opt) => opt.key !== 'date' || features.supportsSearchPubDate), + [features.supportsSearchPubDate], + ); + // Load remembered plugin/category once at mount. The query text itself is // deliberately NOT restored — it should reset on a fresh app launch, and // React state already keeps it intact when just switching tabs within the @@ -388,6 +396,16 @@ export default function SearchScreen() { case 'name': cmp = (a.fileName || '').localeCompare(b.fileName || ''); break; + case 'date': { + // qBittorrent always sends pubDate, using -1 as the "plugin didn't + // report one" sentinel (same convention as nbLeechers) — never + // actually absent. Normalize any non-positive value to 0 so + // unknown dates group together instead of comparing as -1. + const aDate = a.pubDate && a.pubDate > 0 ? a.pubDate : 0; + const bDate = b.pubDate && b.pubDate > 0 ? b.pubDate : 0; + cmp = aDate - bDate; + break; + } } return sortDirection === 'asc' ? cmp : -cmp; }); @@ -1013,7 +1031,7 @@ export default function SearchScreen() { }, ]} > - {SORT_OPTIONS.map((opt) => { + {visibleSortOptions.map((opt) => { const isActive = sortBy === opt.key; return ( - { + const base = + defaultSavePath || t('screens.settings.categorySavePathPlaceholderDefault'); + const sep = isWindowsStylePath(base) ? '\\' : '/'; + return `${base}${sep}${editCategoryName || editingCategory} ${t('screens.settings.categorySavePathPlaceholderSuffix')}`; + })() } placeholderTextColor={colors.textSecondary} /> diff --git a/components/PathAutocompleteInput.tsx b/components/PathAutocompleteInput.tsx index ab8f930b..fec1713a 100644 --- a/components/PathAutocompleteInput.tsx +++ b/components/PathAutocompleteInput.tsx @@ -29,17 +29,42 @@ const BLUR_CLEAR_MS = 150; * root of whatever drive qBittorrent happens to be running on (typically C:) — * there's no API to enumerate other drives. But the endpoint does accept a * drive-letter path like "D:/" directly, so once a user types one, suggestions - * should still kick in instead of silently doing nothing (#180). + * should still kick in instead of silently doing nothing (#180). Accept either + * separator after the colon (`D:/` or `D:\`) since Windows users naturally + * type backslashes. */ -const WINDOWS_DRIVE_PATH = /^[A-Za-z]:\//; +const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[/\\]/; +/** A UNC network path, e.g. `\\nas\share\`. Windows-only; always backslash. */ +const UNC_PATH = /^\\\\/; + +/** + * True for paths that are unambiguously Windows-style (drive letter or UNC + * share) — the only case where `\` should be treated as a path separator. + * A bare `/`-rooted path (Linux/macOS host) never gets this treatment, so a + * literal backslash in a Linux directory name is never misread as a separator. + */ +export function isWindowsStylePath(text: string): boolean { + return WINDOWS_DRIVE_PATH.test(text) || UNC_PATH.test(text); +} + +/** Index of the last path separator, respecting `isWindowsStylePath`. */ +function lastSeparatorIndex(text: string): number { + return isWindowsStylePath(text) + ? Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\')) + : text.lastIndexOf('/'); +} /** * qBittorrent's getDirectoryContent returns absolute paths (QDirIterator::next), * not basenames. Strip to the final segment so we can filter and apply safely. + * On Windows the server returns entries with backslash separators (e.g. + * "F:\test folder") even when queried with a forward-slash dirPath, so both + * separators have to be recognized or the "basename" ends up being the whole + * backslash path (#180). */ function toBaseName(entry: string): string { - const trimmed = entry.replace(/\/+$/, ''); - const idx = trimmed.lastIndexOf('/'); + const trimmed = entry.replace(/[/\\]+$/, ''); + const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; } @@ -112,13 +137,13 @@ export function PathAutocompleteInput({ setSuggestions([]); return; } - const lastSlash = text.lastIndexOf('/'); - if ((!text.startsWith('/') && !WINDOWS_DRIVE_PATH.test(text)) || lastSlash < 0) { + const lastSep = lastSeparatorIndex(text); + if ((!text.startsWith('/') && !isWindowsStylePath(text)) || lastSep < 0) { setSuggestions([]); return; } - const parentDir = text.slice(0, lastSlash + 1); - const partial = text.slice(lastSlash + 1).toLowerCase(); + const parentDir = text.slice(0, lastSep + 1); + const partial = text.slice(lastSep + 1).toLowerCase(); const fetchId = ++fetchIdRef.current; debounceRef.current = setTimeout(async () => { try { @@ -146,7 +171,7 @@ export function PathAutocompleteInput({ const applySuggestion = (path: string) => { cancelPendingBlurClear(); - const newValue = `${path}/`; + const newValue = `${path}${isWindowsStylePath(path) ? '\\' : '/'}`; onChangeText(newValue); // Immediately list the directory just selected instead of leaving the // dropdown empty until the user types another character. @@ -196,7 +221,7 @@ export function PathAutocompleteInput({ applySuggestion(path)} + onPress={() => applySuggestion(path)} > {path} diff --git a/components/SearchResultRow.tsx b/components/SearchResultRow.tsx index 6db8a2ef..113babc0 100644 --- a/components/SearchResultRow.tsx +++ b/components/SearchResultRow.tsx @@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { useTheme } from '@/context/ThemeContext'; import { SearchResult } from '@/types/api'; -import { formatSize } from '@/utils/format'; +import { formatSize, formatDate } from '@/utils/format'; import { resultTrackerLabel } from '@/utils/searchResult'; import { spacing, borderRadius } from '@/constants/spacing'; import { typography } from '@/constants/typography'; @@ -85,13 +85,19 @@ export function SearchResultRow({ {/* Line 2: health dot + meta */} - + {/* numberOfLines=2, not 1: size/seeders/leechers/host/date can + overflow one line once a date is present — wrap instead of + silently truncating the date off the end. */} + {formatSize(result.fileSize)} {' · '} ↑{seeders} {' · '} ↓{leechers} {host ? ` · ${host}` : ''} + {/* qBittorrent sends -1 (not undefined) when the plugin didn't + report a date — only render when it's a real timestamp. */} + {result.pubDate && result.pubDate > 0 ? ` · ${formatDate(result.pubDate)}` : ''} {/* Chevron hints that the row expands */} { expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('D:/', 'dirs'); }); + it('normalizes backslash-separated Windows entries to forward-slash suggestions', async () => { + jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['F:\\test folder']); + + const onChangeText = jest.fn(); + await render( + , + ); + + fireEvent.changeText(screen.getByTestId('path-input'), 'F:/'); + + expect(await screen.findByText('F:/test folder')).toBeTruthy(); + expect(screen.queryByText('F:/F:\\test folder')).toBeNull(); + }); + + it('fetches suggestions for a Windows drive-letter path typed with backslashes', async () => { + jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['D:\\Downloads']); + + const onChangeText = jest.fn(); + await render( + , + ); + + fireEvent.changeText(screen.getByTestId('path-input'), 'D:\\Do'); + + expect(await screen.findByText('D:\\Downloads')).toBeTruthy(); + expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('D:\\', 'dirs'); + }); + + it('fetches suggestions for a UNC share path', async () => { + jest + .mocked(applicationApi.getDirectoryContent) + .mockResolvedValue(['\\\\nas\\share\\Downloads']); + + const onChangeText = jest.fn(); + await render( + , + ); + + fireEvent.changeText(screen.getByTestId('path-input'), '\\\\nas\\share\\Do'); + + expect(await screen.findByText('\\\\nas\\share\\Downloads')).toBeTruthy(); + expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('\\\\nas\\share\\', 'dirs'); + }); + + it('appends a backslash (not a forward slash) when a Windows-style suggestion is tapped', async () => { + jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['D:\\Downloads']); + + const onChangeText = jest.fn(); + await render( + , + ); + + fireEvent.changeText(screen.getByTestId('path-input'), 'D:\\Do'); + const suggestion = await screen.findByText('D:\\Downloads'); + + fireEvent(suggestion.parent!, 'press'); + + expect(onChangeText).toHaveBeenCalledWith('D:\\Downloads\\'); + }); + + it('never treats a literal backslash in a Linux path as a separator', async () => { + jest.mocked(applicationApi.getDirectoryContent).mockResolvedValue(['/data/weird']); + + const onChangeText = jest.fn(); + await render( + , + ); + + fireEvent.changeText(screen.getByTestId('path-input'), '/data/weird\\name'); + + // Give the debounced fetch a chance to have fired. + await new Promise((r) => setTimeout(r, 400)); + + expect(applicationApi.getDirectoryContent).toHaveBeenCalledWith('/data/', 'dirs'); + }); + it('immediately fetches the next directory level after a suggestion is tapped', async () => { jest .mocked(applicationApi.getDirectoryContent) @@ -102,7 +186,7 @@ describe('PathAutocompleteInput', () => { fireEvent.changeText(screen.getByTestId('path-input'), '/da'); const suggestion = await screen.findByText('/data'); - fireEvent(suggestion.parent!, 'pressIn'); + fireEvent(suggestion.parent!, 'press'); // applySuggestion calls onChangeText synchronously with the new value — // simulate the parent re-rendering with that value, as a real screen would. diff --git a/tests/utils/apiVersion.test.ts b/tests/utils/apiVersion.test.ts index 8d592b19..7c10262d 100644 --- a/tests/utils/apiVersion.test.ts +++ b/tests/utils/apiVersion.test.ts @@ -36,6 +36,7 @@ describe('getApiFeatures', () => { useAddStoppedEnabledPreference: true, useStoppedAddParam: true, supportsGetDirectoryContent: true, + supportsSearchPubDate: true, }); }); @@ -53,6 +54,7 @@ describe('getApiFeatures', () => { expect(features.useAddStoppedEnabledPreference).toBe(false); expect(features.useStoppedAddParam).toBe(false); expect(features.supportsGetDirectoryContent).toBe(false); + expect(features.supportsSearchPubDate).toBe(false); // ratio limit fields only require 2.8+ expect(features.hasRatioLimitFields).toBe(true); }); @@ -72,6 +74,7 @@ describe('getApiFeatures', () => { expect(features.useAddStoppedEnabledPreference).toBe(true); expect(features.useStoppedAddParam).toBe(true); expect(features.supportsGetDirectoryContent).toBe(true); + expect(features.supportsSearchPubDate).toBe(true); }); it('enables v5 features above major version 2 (e.g. 3.0.0)', () => { diff --git a/types/api.ts b/types/api.ts index e503fde3..66666175 100644 --- a/types/api.ts +++ b/types/api.ts @@ -400,6 +400,8 @@ export interface SearchResult { descrLink: string; /** Name of the plugin that produced this result. Absent on older servers. */ engineName?: string; + /** Unix timestamp (seconds) the torrent was published, if the plugin reported one (qBit 5.0+ / WebAPI >= 2.11.0). */ + pubDate?: number; } export interface SearchResultsResponse { diff --git a/utils/apiVersion.ts b/utils/apiVersion.ts index e6abd2a2..134572c8 100644 --- a/utils/apiVersion.ts +++ b/utils/apiVersion.ts @@ -35,6 +35,8 @@ export interface ApiFeatures { useStoppedAddParam: boolean; /** app/getDirectoryContent endpoint exists, for path autocomplete (WebAPI ≥ 2.11.0 / qBit 5.0). */ supportsGetDirectoryContent: boolean; + /** search/results includes a pubDate field per result, for sort-by-date (WebAPI ≥ 2.11.0 / qBit 5.0). */ + supportsSearchPubDate: boolean; } export function parseApiVersion(raw: string): ParsedVersion | null { @@ -66,6 +68,7 @@ const V5_FEATURES: ApiFeatures = { useAddStoppedEnabledPreference: true, useStoppedAddParam: true, supportsGetDirectoryContent: true, + supportsSearchPubDate: true, }; export function getApiFeatures(apiVersion: string | null): ApiFeatures { @@ -84,6 +87,7 @@ export function getApiFeatures(apiVersion: string | null): ApiFeatures { useAddStoppedEnabledPreference: isV5, useStoppedAddParam: isV5, supportsGetDirectoryContent: isV5, + supportsSearchPubDate: isV5, }; }