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>
159 lines
6.4 KiB
TypeScript
159 lines
6.4 KiB
TypeScript
import type { Torrent } from './shared';
|
|
import { STATUS } from './shared';
|
|
|
|
// Display formatting for the Transmission panels. One implementation each, because a torrent list shows the
|
|
// same quantity in a column, a tooltip and a detail row, and three near-identical formatters drift.
|
|
|
|
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
|
|
/** Binary (1024-based) sizes, matching what Transmission's own clients show. */
|
|
export function formatSize(bytes: number | undefined | null): string {
|
|
if (bytes == null || !Number.isFinite(bytes)) return '—';
|
|
if (bytes <= 0) return '0 B';
|
|
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), SIZE_UNITS.length - 1);
|
|
const value = bytes / 1024 ** i;
|
|
return `${value.toFixed(i === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${SIZE_UNITS[i]}`;
|
|
}
|
|
|
|
/** Zero renders blank, not "0 B/s" — a list where every idle row shouts zero is unreadable. */
|
|
export function formatSpeed(bytesPerSecond: number | undefined | null): string {
|
|
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
|
|
return `${formatSize(bytesPerSecond)}/s`;
|
|
}
|
|
|
|
/**
|
|
* Transmission's ETA sentinels: -1 means "not available", -2 means "unknown". Both are ordinary states for a
|
|
* stopped or seeding torrent, so neither is an error worth surfacing.
|
|
*/
|
|
export function formatEta(seconds: number | undefined | null): string {
|
|
if (seconds == null || seconds < 0) return '∞';
|
|
return formatDuration(seconds);
|
|
}
|
|
|
|
export function formatDuration(seconds: number | undefined | null): string {
|
|
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
|
|
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
|
|
const d = Math.floor(seconds / 86400);
|
|
const h = Math.floor((seconds % 86400) / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
|
|
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
|
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
/**
|
|
* Ratio, with the two conventions every torrent client shares: nothing uploaded yet reads as a dash, and
|
|
* anything uploaded against a zero download is infinite rather than a division blow-up.
|
|
*/
|
|
export function formatRatio(ratio: number | undefined | null): string {
|
|
if (ratio == null) return '—';
|
|
if (ratio < 0) return '∞';
|
|
return ratio.toFixed(2);
|
|
}
|
|
|
|
/** Transmission timestamps are unix seconds, and 0 means "never" rather than 1970. */
|
|
export function formatDate(unixSeconds: number | undefined | null): string {
|
|
if (!unixSeconds || unixSeconds <= 0) return '—';
|
|
const d = new Date(unixSeconds * 1000);
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
/**
|
|
* Progress, floored — never rounded. `toFixed` rounds, so 99.97% printed as "100.0%" next to a bar that
|
|
* is still blue, which reads as a stuck torrent rather than a nearly-finished one. Only an actually
|
|
* complete fraction may show a hundred, and it shows it as a flat "100%" so the two can't be confused.
|
|
*/
|
|
export function formatPercent(fraction: number | undefined | null): string {
|
|
if (fraction == null) return '—';
|
|
if (fraction >= 1) return '100%';
|
|
return `${(Math.floor(fraction * 1000) / 10).toFixed(1)}%`;
|
|
}
|
|
|
|
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type StatusTone = 'stopped' | 'downloading' | 'seeding' | 'checking' | 'queued' | 'error';
|
|
|
|
export function statusTone(torrent: Torrent): StatusTone {
|
|
if (torrent.error !== 0) return 'error';
|
|
switch (torrent.status) {
|
|
case STATUS.Download:
|
|
return 'downloading';
|
|
case STATUS.Seed:
|
|
return 'seeding';
|
|
case STATUS.Check:
|
|
case STATUS.CheckWait:
|
|
return 'checking';
|
|
case STATUS.DownloadWait:
|
|
case STATUS.SeedWait:
|
|
return 'queued';
|
|
default:
|
|
return 'stopped';
|
|
}
|
|
}
|
|
|
|
export function statusLabel(torrent: Torrent): string {
|
|
if (torrent.error !== 0) return 'Error';
|
|
switch (torrent.status) {
|
|
case STATUS.Stopped:
|
|
// A stopped torrent that never finished is paused; one that did is simply done and not seeding.
|
|
return torrent.percentDone >= 1 ? 'Finished' : 'Paused';
|
|
case STATUS.CheckWait:
|
|
return 'Queued to verify';
|
|
case STATUS.Check:
|
|
return 'Verifying';
|
|
case STATUS.DownloadWait:
|
|
return 'Queued to download';
|
|
case STATUS.Download:
|
|
// Transmission reports "downloading" while it is still fetching the metadata of a magnet link, which
|
|
// is a materially different state — there is nothing to download yet.
|
|
return torrent.metadataPercentComplete < 1 ? 'Fetching metadata' : 'Downloading';
|
|
case STATUS.SeedWait:
|
|
return 'Queued to seed';
|
|
case STATUS.Seed:
|
|
return 'Seeding';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
|
|
/** Tailwind classes per tone, kept together so the list, the pill and the detail header cannot disagree. */
|
|
export const TONE_CLASSES: Record<StatusTone, { text: string; bg: string; dot: string }> = {
|
|
stopped: { text: 'text-muted-foreground', bg: 'bg-muted', dot: 'bg-muted-foreground' },
|
|
downloading: { text: 'text-info', bg: 'bg-info/15', dot: 'bg-info' },
|
|
seeding: { text: 'text-success', bg: 'bg-success/15', dot: 'bg-success' },
|
|
checking: { text: 'text-busy-ink', bg: 'bg-busy/15', dot: 'bg-busy' },
|
|
queued: { text: 'text-warning', bg: 'bg-warning/15', dot: 'bg-warning' },
|
|
error: { text: 'text-destructive', bg: 'bg-destructive/10', dot: 'bg-destructive' },
|
|
};
|
|
|
|
export const PRIORITY_LABELS: Record<number, string> = { [-1]: 'Low', 0: 'Normal', 1: 'High' };
|
|
|
|
/** Peer flag characters, spelled out for the peers table tooltip (rpc-spec.md §3.2). */
|
|
export const PEER_FLAGS: Record<string, string> = {
|
|
O: 'Optimistic unchoke',
|
|
D: 'Downloading from peer',
|
|
d: 'We would download but peer is choking us',
|
|
U: 'Uploading to peer',
|
|
u: 'Peer would download but we are choking',
|
|
K: 'Peer unchoked us but we are not interested',
|
|
'?': 'We unchoked peer but it is not interested',
|
|
E: 'Encrypted connection',
|
|
H: 'Peer discovered through DHT',
|
|
X: 'Peer discovered through PEX',
|
|
I: 'Peer is incoming',
|
|
T: 'Peer is over uTP',
|
|
};
|
|
|
|
export function describePeerFlags(flagStr: string): string {
|
|
return (
|
|
flagStr
|
|
.split('')
|
|
.map((c) => PEER_FLAGS[c])
|
|
.filter(Boolean)
|
|
.join('\n') || flagStr
|
|
);
|
|
}
|