Files
transmission/web/useTorrentFilters.ts
T
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

279 lines
9.9 KiB
TypeScript

import type { Torrent } from './shared';
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { STATUS } from './shared';
// Filtering and sorting for the torrent list, with every knob in the query string.
//
// The nav panel writes these params and the table panel reads them; they never talk to each other. That is
// the whole reason the state lives in the URL rather than in a channel — two sibling panels, one address,
// and a filtered view you can bookmark or send to yourself.
export const STATUS_FILTERS = [
{ id: 'all', label: 'All' },
{ id: 'active', label: 'Active' },
{ id: 'downloading', label: 'Downloading' },
{ id: 'seeding', label: 'Seeding' },
{ id: 'paused', label: 'Paused' },
{ id: 'finished', label: 'Finished' },
{ id: 'checking', label: 'Verifying' },
{ id: 'error', label: 'Error' },
] as const;
export type StatusFilterId = (typeof STATUS_FILTERS)[number]['id'];
const isStatusFilter = (v: string | null): v is StatusFilterId => !!v && STATUS_FILTERS.some((f) => f.id === v);
export type SortKey =
| 'name'
| 'queuePosition'
| 'addedDate'
| 'doneDate'
| 'activityDate'
| 'percentDone'
| 'totalSize'
| 'rateDownload'
| 'rateUpload'
| 'uploadRatio'
| 'status'
| 'eta'
| 'downloadedEver'
| 'uploadedEver'
| 'peersSendingToUs'
| 'peersGettingFromUs'
| 'secondsSeeding'
| 'downloadDir'
| 'officerTrackerHost'
| 'id';
const DEFAULT_SORT: SortKey = 'queuePosition';
/** Param names, in one place so the nav, the table header and the screen guard cannot drift. */
const P = {
q: 'q',
status: 'status',
label: 'label',
tracker: 'tracker',
dir: 'dir',
error: 'err',
sort: 'sort',
desc: 'desc',
selected: 'selected',
} as const;
export type TorrentFilters = {
q: string;
status: StatusFilterId;
label: string | null;
tracker: string | null;
downloadDir: string | null;
errorOnly: boolean;
sort: SortKey;
desc: boolean;
/** Any of the narrowing filters (not sort, not selection) is engaged. */
isFiltered: boolean;
};
export function useTorrentFilters() {
const [params, setParams] = useSearchParams();
const rawStatus = params.get(P.status);
const filters = useMemo<TorrentFilters>(() => {
const q = params.get(P.q) ?? '';
const status = isStatusFilter(rawStatus) ? rawStatus : 'all';
const label = params.get(P.label);
const tracker = params.get(P.tracker);
const downloadDir = params.get(P.dir);
const errorOnly = params.get(P.error) === '1';
return {
q,
status,
label,
tracker,
downloadDir,
errorOnly,
sort: (params.get(P.sort) as SortKey) || DEFAULT_SORT,
desc: params.get(P.desc) === '1',
isFiltered: !!q || status !== 'all' || !!label || !!tracker || !!downloadDir || errorOnly,
};
// params is a fresh URLSearchParams object on every render, so depend on the values instead of the
// object — otherwise every render produces a new filters object and re-sorts the whole list.
}, [
params.get(P.q),
rawStatus,
params.get(P.label),
params.get(P.tracker),
params.get(P.dir),
params.get(P.error),
params.get(P.sort),
params.get(P.desc),
]);
/**
* Writes go through the functional form of setSearchParams so concurrent updates (typing in the search
* box while a filter menu closes) compose instead of clobbering each other. `replace` keeps the back
* button useful: forty keystrokes should not be forty history entries.
*/
const patch = useCallback(
(next: Partial<Record<keyof typeof P, string | null>>, replace = true) => {
setParams(
(prev) => {
const out = new URLSearchParams(prev);
for (const [key, value] of Object.entries(next)) {
const param = P[key as keyof typeof P];
if (value === null || value === '') out.delete(param);
else out.set(param, value);
}
return out;
},
{ replace },
);
},
[setParams],
);
const setSearch = useCallback((q: string) => patch({ q: q || null }), [patch]);
const setStatus = useCallback((s: StatusFilterId) => patch({ status: s === 'all' ? null : s }), [patch]);
/** Menu entries toggle: clicking the one already applied clears it. Saves a separate "clear" affordance. */
const toggle = useCallback(
(key: 'label' | 'tracker' | 'dir' | 'error', value: string, current: string | null) =>
patch({ [key]: current === value ? null : value }),
[patch],
);
const clearFilters = useCallback(
() => patch({ q: null, status: null, label: null, tracker: null, dir: null, error: null }, false),
[patch],
);
/**
* Sorting toggles direction when you pick the column that is already sorted. New columns start ascending,
* except the ones where "most" is the interesting end — nobody opens a torrent list to see the slowest.
*/
const setSort = useCallback(
(key: SortKey, currentSort: SortKey, currentDesc: boolean) => {
if (key === currentSort) return patch({ sort: key, desc: currentDesc ? null : '1' });
return patch({ sort: key, desc: DESC_BY_DEFAULT.has(key) ? '1' : null });
},
[patch],
);
const selectedId = Number(params.get(P.selected));
const selected = Number.isFinite(selectedId) && selectedId > 0 ? selectedId : null;
const setSelected = useCallback((id: number | null) => patch({ selected: id ? String(id) : null }), [patch]);
return { filters, setSearch, setStatus, toggle, clearFilters, setSort, selected, setSelected };
}
const DESC_BY_DEFAULT = new Set<SortKey>([
'addedDate',
'doneDate',
'activityDate',
'rateDownload',
'rateUpload',
'uploadRatio',
'totalSize',
'downloadedEver',
'uploadedEver',
'peersSendingToUs',
'peersGettingFromUs',
'secondsSeeding',
'percentDone',
]);
// ── Predicates ────────────────────────────────────────────────────────────────────────────────────
function matchesStatus(t: Torrent, filter: StatusFilterId): boolean {
switch (filter) {
case 'all':
return true;
// "Active" means moving bytes right now, which is not the same as "not stopped" — a seeding torrent
// with no peers is running but idle, and lumping it in here makes the filter useless.
case 'active':
return t.rateDownload > 0 || t.rateUpload > 0;
case 'downloading':
return t.status === STATUS.Download || t.status === STATUS.DownloadWait;
case 'seeding':
return t.status === STATUS.Seed || t.status === STATUS.SeedWait;
case 'paused':
return t.status === STATUS.Stopped;
case 'finished':
return t.percentDone >= 1;
case 'checking':
return t.status === STATUS.Check || t.status === STATUS.CheckWait;
case 'error':
return t.error !== 0;
}
}
const compare = (a: Torrent, b: Torrent, key: SortKey): number => {
const av = a[key as keyof Torrent];
const bv = b[key as keyof Torrent];
if (typeof av === 'string' || typeof bv === 'string') {
return String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true, sensitivity: 'base' });
}
return Number(av ?? 0) - Number(bv ?? 0);
};
/**
* Applies the filters and the sort. Pure, and memoised by the caller — 43 torrents is nothing but the list
* re-renders every 5 seconds forever, and this also runs on every keystroke in the search box.
*/
export function selectTorrents(torrents: Torrent[], filters: TorrentFilters): Torrent[] {
const needle = filters.q.trim().toLowerCase();
const out = torrents.filter((t) => {
if (!matchesStatus(t, filters.status)) return false;
if (filters.errorOnly && t.error === 0) return false;
if (filters.label && !t.labels.includes(filters.label)) return false;
if (filters.tracker && t.officerTrackerHost !== filters.tracker) return false;
if (filters.downloadDir && t.downloadDir !== filters.downloadDir) return false;
if (needle && !t.name.toLowerCase().includes(needle) && !t.labels.some((l) => l.toLowerCase().includes(needle)))
return false;
return true;
});
out.sort((a, b) => {
const primary = compare(a, b, filters.sort);
// Ties on a coarse key (status, priority, a rate of zero) would otherwise reshuffle on every poll,
// because Array.sort is only stable with respect to the input order and the input is a fresh fetch.
return (filters.desc ? -primary : primary) || a.id - b.id;
});
return out;
}
/** The distinct values the nav offers as filters, each with how many torrents carry it. */
export type FacetEntry = { value: string; count: number };
export function buildFacets(torrents: Torrent[]) {
const labels = new Map<string, number>();
const trackers = new Map<string, number>();
const dirs = new Map<string, number>();
let errors = 0;
for (const t of torrents) {
for (const label of t.labels) labels.set(label, (labels.get(label) ?? 0) + 1);
if (t.officerTrackerHost) trackers.set(t.officerTrackerHost, (trackers.get(t.officerTrackerHost) ?? 0) + 1);
if (t.downloadDir) dirs.set(t.downloadDir, (dirs.get(t.downloadDir) ?? 0) + 1);
if (t.error !== 0) errors += 1;
}
const toSorted = (m: Map<string, number>): FacetEntry[] =>
[...m.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
return { labels: toSorted(labels), trackers: toSorted(trackers), dirs: toSorted(dirs), errors };
}
/** Per-status counts for the nav badges, computed in one pass rather than eight filters. */
export function countByStatus(torrents: Torrent[]): Record<StatusFilterId, number> {
const counts = Object.fromEntries(STATUS_FILTERS.map((f) => [f.id, 0])) as Record<StatusFilterId, number>;
for (const t of torrents) {
for (const f of STATUS_FILTERS) if (matchesStatus(t, f.id)) counts[f.id] += 1;
}
return counts;
}