Files
UHD_Media_Tool/apps/web/app/components/files/model.ts
T
2026-09-28 02:09:39 +08:00

313 lines
12 KiB
TypeScript

// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
import type { StatusTone } from "~/components/console/status-badge.tsx";
// ─── 状态徽标 ────────────────────────────────────────────────────
export type StatusView = { label: string; tone: StatusTone; hint?: string };
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
if (e.escape) return { label: "链接逃逸", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
const s: FileStatus = e.status;
switch (s) {
case "temp":
return { label: "临时文件", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
case "downloading":
return {
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
tone: "neutral",
};
case "registered":
return { label: "已登记", tone: "success" };
case "seeding":
return e.download?.hnr === "unsatisfied"
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
: { label: "做种中", tone: "neutral" };
case "unidentified":
return { label: "待识别", tone: "warning" };
default:
return null;
}
}
export const FILTER_CHIPS = [
{ value: "", label: "全部" },
{ value: "video", label: "视频" },
{ value: "unidentified", label: "待识别" },
{ value: "registered", label: "已登记" },
{ value: "downloading", label: "下载中" },
{ value: "temp", label: "临时文件" },
] as const;
export const SORT_OPTIONS = [
{ value: "name", label: "名称" },
{ value: "mtime", label: "修改时间" },
{ value: "size", label: "大小" },
{ value: "status", label: "状态" },
] as const;
// ─── 禁用原因(菜单 tooltip 用) ───────────────────────────────────
export type FileOp =
| "open"
| "rename"
| "move"
| "copy"
| "organize"
| "identify"
| "scrape"
| "probe"
| "delete"
| "copyPath"
| "openMedia"
| "records";
/** 返回 null = 可用;否则为禁用原因 */
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
if (e.escape && op !== "copyPath") return "链接指向登记目录之外,已禁用";
// 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项)
const rootWritable = root.writable || root.kind === "download";
const seeding = e.status === "seeding" || e.status === "downloading";
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
switch (op) {
case "rename":
if (!rootWritable) return "只读根,不能改名";
if (seeding) return "做种/下载中,改名会让下载器找不到文件";
if (e.status === "temp") return "临时文件不能改名";
return null;
case "move":
if (!rootWritable) return "只读根,不能移出。可用“复制到…”";
if (hnrBlocked) return "做种中且 H&R 未满足,不能移动";
return null;
case "delete":
if (!rootWritable) return "只读根,不能删除";
if (seeding) return "纳管下载任务仍在使用,不能删除";
return null;
case "organize":
case "identify":
if (e.status === "temp") return "临时文件不能整理";
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录可识别/整理";
return null;
case "scrape":
return e.asset ? null : "未登记,先识别并整理入库";
case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "openMedia":
return e.asset ? null : "未关联作品";
case "records":
return e.asset || e.status === "registered" ? null : "没有整理记录";
default:
return null;
}
}
// ─── 选择模型(Shift 区间 / Ctrl 切换 / 全选) ─────────────────────
export type Selection = { keys: Set<string>; anchor: string | null; focus: string | null };
export const emptySelection = (): Selection => ({ keys: new Set(), anchor: null, focus: null });
/** 点击一行:plain 单选;ctrl 切换;shift 从锚点到当前的区间(与 ctrl 同按则并入) */
export function clickSelect(
sel: Selection,
order: string[],
key: string,
mod: { shift?: boolean; ctrl?: boolean },
): Selection {
if (mod.shift && sel.anchor && order.includes(sel.anchor)) {
const a = order.indexOf(sel.anchor);
const b = order.indexOf(key);
const [lo, hi] = a < b ? [a, b] : [b, a];
const range = order.slice(lo, hi + 1);
const keys = mod.ctrl ? new Set([...sel.keys, ...range]) : new Set(range);
return { keys, anchor: sel.anchor, focus: key };
}
if (mod.ctrl) {
const keys = new Set(sel.keys);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { keys, anchor: key, focus: key };
}
return { keys: new Set([key]), anchor: key, focus: key };
}
/** Space:切换焦点行的勾选 */
export function toggleSelect(sel: Selection, key: string): Selection {
const keys = new Set(sel.keys);
if (keys.has(key)) keys.delete(key);
else keys.add(key);
return { keys, anchor: key, focus: key };
}
export function selectAll(order: string[], sel: Selection): Selection {
return { keys: new Set(order), anchor: sel.anchor ?? order[0] ?? null, focus: sel.focus ?? order[0] ?? null };
}
/** ↑↓ 移动焦点;shift 时从锚点扩选 */
export function moveFocus(sel: Selection, order: string[], delta: number, shift: boolean): Selection {
if (order.length === 0) return sel;
const cur = sel.focus ? order.indexOf(sel.focus) : -1;
const next = Math.max(0, Math.min(order.length - 1, cur < 0 ? (delta > 0 ? 0 : order.length - 1) : cur + delta));
const key = order[next] as string;
if (shift) return clickSelect(sel.anchor ? sel : { ...sel, anchor: sel.focus ?? key }, order, key, { shift: true });
return { ...sel, focus: key };
}
/** 列表刷新后丢掉已不存在的键 */
export function pruneSelection(sel: Selection, order: string[]): Selection {
const present = new Set(order);
const keys = new Set([...sel.keys].filter((k) => present.has(k)));
return {
keys,
anchor: sel.anchor && present.has(sel.anchor) ? sel.anchor : null,
focus: sel.focus && present.has(sel.focus) ? sel.focus : null,
};
}
// ─── 路径工具(根内相对路径,"" = 根) ─────────────────────────────
export function joinRel(dir: string, name: string): string {
return dir ? `${dir}/${name}` : name;
}
export function parentRel(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? "" : path.slice(0, i);
}
export function baseName(path: string): string {
const i = path.lastIndexOf("/");
return i < 0 ? path : path.slice(i + 1);
}
export function segments(path: string): string[] {
return path ? path.split("/").filter(Boolean) : [];
}
/** 面包屑折叠:超过 max 段时保留首段(根)+ … + 最后 max-1 段 */
export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
if (items.length <= max) return items;
return [items[0] as T, null, ...items.slice(items.length - (max - 1))];
}
/** 名称校验(前端先挡一遍,服务端 sanitizeSegment 才是准绳) */
export function nameProblem(name: string): string | null {
const n = name.trim();
if (!n) return "名称不能为空";
if (n === "." || n === "..") return "不能用 . 或 ..";
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
if (n.length > 255) return "名称过长";
return null;
}
/** 改名时默认只选中主名(不含扩展名) */
export function stemRange(name: string, isDir: boolean): [number, number] {
if (isDir) return [0, name.length];
const i = name.lastIndexOf(".");
return [0, i > 0 ? i : name.length];
}
/** 选中项合计大小(目录不计) */
export function totalSize(entries: FileEntry[]): number {
return entries.reduce((s, e) => s + (e.type === "file" ? e.size : 0), 0);
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
}
/** 集号偏移:episodes 全体加 n(不小于 1) */
export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined {
if (!eps?.length) return eps;
return eps.map((e) => Math.max(1, e + n));
}
/** "3" / "3-5" / "3,4" → [3] / [3,4,5] / [3,4];非法返回 null */
export function parseEpisodes(text: string): number[] | null {
const t = text.trim();
if (!t) return [];
const out: number[] = [];
for (const part of t.split(/[,\s]+/)) {
const m = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(part);
if (!m) return null;
const a = Number(m[1]);
const b = m[2] ? Number(m[2]) : a;
if (b < a || b - a > 200) return null;
for (let i = a; i <= b; i++) out.push(i);
}
return out;
}
export function formatEpisodes(eps: number[] | undefined | null): string {
if (!eps?.length) return "";
const sorted = [...eps].sort((a, b) => a - b);
const first = sorted[0] as number;
const last = sorted[sorted.length - 1] as number;
if (sorted.length > 1 && last - first === sorted.length - 1) return `${first}-${last}`;
return sorted.join(",");
}
export function contentUnitLabel(season?: number | null, episodes?: number[] | null): string {
if (season == null) return "";
const s = `S${String(season).padStart(2, "0")}`;
if (!episodes?.length) return s;
const sorted = [...episodes].sort((a, b) => a - b);
const e = (n: number) => `E${String(n).padStart(2, "0")}`;
return sorted.length > 1
? `${s}${e(sorted[0] as number)}-${e(sorted[sorted.length - 1] as number)}`
: `${s}${e(sorted[0] as number)}`;
}
/** 前后路径对照:按 / 段取公共前缀,差异部分单独高亮 */
export function splitCommon(a: string, b: string): { common: string; a: string; b: string } {
const sa = a.split("/");
const sb = b.split("/");
let i = 0;
while (i < sa.length - 1 && i < sb.length - 1 && sa[i] === sb[i]) i++;
const common = i ? `${sa.slice(0, i).join("/")}/` : "";
return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") };
}
/** 识别建议名:电影 "标题 (年).ext",剧集 "标题 - S01E02.ext" */
export function suggestName(
cand: { title: string; year: number | null; kind: string },
parsed: { season: number | null; episodes: number[] },
currentName: string,
isDir: boolean,
): string {
const ext = isDir ? "" : (/\.[A-Za-z0-9]{1,6}$/.exec(currentName)?.[0] ?? "");
const base = `${cand.title}${cand.year ? ` (${cand.year})` : ""}`.replace(/[/\\:*?"<>|]/g, " ").trim();
if (isDir) return base;
if (cand.kind !== "movie" && parsed.season != null)
return `${base} - ${contentUnitLabel(parsed.season, parsed.episodes)}${ext}`;
return `${base}${ext}`;
}
/** 删除阻断原因文案;可通过勾选解除的返回 soft=true */
export function deleteBlockText(b: string | null): { text: string; soft: boolean } | null {
switch (b) {
case null:
return null;
case "download_active":
return { text: "纳管下载任务仍在做种或 H&R 未满足", soft: false };
case "is_root":
return { text: "不能删除根本身", soft: false };
case "needs_retire":
return { text: "含已登记资产,需勾选“同时退役资产”", soft: true };
case "needs_unbind":
return { text: "是作品/季的绑定目录,需勾选“解除路径绑定”", soft: true };
case "root_unavailable":
return { text: "根离线,不能确认文件状态", soft: false };
case "not_found":
return { text: "文件已不存在", soft: false };
case "escape":
return { text: "链接指向根外", soft: false };
default:
return { text: b, soft: false };
}
}