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>
279 lines
9.9 KiB
TypeScript
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;
|
|
}
|