需求两条:
1) 列表页的复选框改成按钮,点一下直接推送到工具
2) 推送过但没点进去的画廊,标题也要变灰(像浏览过的),
这样刷新后还认得出哪些推过
一、行内推送按钮
复选框 + 底部「批量推送」两步走太啰嗦,列表页每行改成「📤 推送」
胶囊按钮,点一下直接调 /add。按钮三态:
📤 推送 -> ⏳ 推送中(禁用) -> ✓ 已推送(变绿)
底部悬浮条保留,改为「全部推送」+「重置状态」+ 本页计数。
二、标灰::visited 伪造不了,只能自己记
E-Hentai 的灰色来自自带样式
a:visited .glink, a:active .glink { color:#8F6063 }
但 :visited 是浏览器**受保护**伪类:脚本查 matches(':visited')
永远返回 false,加 class 也触发不了站点的原生规则。
所以改成自己记一份「推送过」的清单,再照抄站点的色值手动染色,
观感与原生 :visited 一致(hover 恢复站点的 #8F4701)。
三、清单存哪:工具端为主,浏览器端为辅
新增 internal/core/pushed.go —— PushedStore 独立于 Store,
单独存 %APPDATA%\EH Downloader\pushed.json。
为什么单独一个文件:state.json 里的 tasks 是「当前要下载的东西」,
用户点「清除已完成」就删掉了;但「这个画廊我推送过」是历史事实,
删了任务不该忘掉。所以推送历史与任务列表解耦。
新增桥口 GET /pushed 返回推送历史(与 GET /tasks 语义不同:
后者是当前任务列表,会被清除)。
油猴脚本侧:
- localStorage(ehdl_pushed_gids)先渲染,刷新后立刻可见,
工具不在线也能用
- 工具在线时用 /pushed 校正一次(localStorage 可能被清,
工具才是权威),以工具为准整体覆盖
- GET /tasks 保留给需要看当前任务列表的场合
四、附带:state.json 事故的恢复工具
tools/recoverstate —— 修复被写坏的 state.json:
1) token 先从下载目录的 .ehdl.json 清单里找
2) 找不到的用 E-Hentai 的 ?next=<gid> 定位法去站点抓
(该 URL 会返回以 gid 开头的 25 条列表,正则取 token,
offset 0..5 轮着试)
3) 抓不到的说明画廊在站点上已被删除
-dry 预演不写盘,写盘前先备份原文件。
配套回归测试:
- internal/core/pushed_test.go 推送历史读写、任务删除后仍保留
- internal/core/recover_e2e_test.go 恢复出的任务能真下载(71/71 页)
- internal/eh/recover_verify_test.go 恢复出的 token 能解析画廊
- app_test.go 点「清除已完成」后灰标不消失
- internal/core/bridge_test.go /tasks 与 /pushed 的 token 校验
后两者需要能连 e-hentai.org,设 EH_TEST_PROXY 才跑。
271 lines
7.4 KiB
TypeScript
271 lines
7.4 KiB
TypeScript
export namespace core {
|
|
|
|
export class FailedItem {
|
|
page: number;
|
|
file: string;
|
|
error: string;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new FailedItem(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.page = source["page"];
|
|
this.file = source["file"];
|
|
this.error = source["error"];
|
|
}
|
|
}
|
|
export class PushedEntry {
|
|
gid: string;
|
|
token?: string;
|
|
title?: string;
|
|
pushedAt: number;
|
|
completed?: boolean;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new PushedEntry(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.gid = source["gid"];
|
|
this.token = source["token"];
|
|
this.title = source["title"];
|
|
this.pushedAt = source["pushedAt"];
|
|
this.completed = source["completed"];
|
|
}
|
|
}
|
|
export class Settings {
|
|
downloadRoot: string;
|
|
proxy: string;
|
|
proxyMode: string;
|
|
galleryConcurrency: number;
|
|
pageConcurrency: number;
|
|
requestIntervalMs: number;
|
|
timeout: number;
|
|
xres: string;
|
|
bridgePort: number;
|
|
dirTemplate: string;
|
|
_probed: boolean;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new Settings(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.downloadRoot = source["downloadRoot"];
|
|
this.proxy = source["proxy"];
|
|
this.proxyMode = source["proxyMode"];
|
|
this.galleryConcurrency = source["galleryConcurrency"];
|
|
this.pageConcurrency = source["pageConcurrency"];
|
|
this.requestIntervalMs = source["requestIntervalMs"];
|
|
this.timeout = source["timeout"];
|
|
this.xres = source["xres"];
|
|
this.bridgePort = source["bridgePort"];
|
|
this.dirTemplate = source["dirTemplate"];
|
|
this._probed = source["_probed"];
|
|
}
|
|
}
|
|
export class Task {
|
|
gid: string;
|
|
token: string;
|
|
raw: string;
|
|
title: string;
|
|
status: string;
|
|
done: number;
|
|
failed: number;
|
|
totalPages: number;
|
|
error: string;
|
|
dir: string;
|
|
pages: Record<string, string>;
|
|
failedList: FailedItem[];
|
|
selected: boolean;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new Task(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.gid = source["gid"];
|
|
this.token = source["token"];
|
|
this.raw = source["raw"];
|
|
this.title = source["title"];
|
|
this.status = source["status"];
|
|
this.done = source["done"];
|
|
this.failed = source["failed"];
|
|
this.totalPages = source["totalPages"];
|
|
this.error = source["error"];
|
|
this.dir = source["dir"];
|
|
this.pages = source["pages"];
|
|
this.failedList = this.convertValues(source["failedList"], FailedItem);
|
|
this.selected = source["selected"];
|
|
this.createdAt = source["createdAt"];
|
|
this.updatedAt = source["updatedAt"];
|
|
}
|
|
|
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
|
if (!a) {
|
|
return a;
|
|
}
|
|
if (a.slice && a.map) {
|
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
|
} else if ("object" === typeof a) {
|
|
if (asMap) {
|
|
for (const key of Object.keys(a)) {
|
|
a[key] = new classs(a[key]);
|
|
}
|
|
return a;
|
|
}
|
|
return new classs(a);
|
|
}
|
|
return a;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
export namespace main {
|
|
|
|
export class AddTasksResult {
|
|
added: number;
|
|
skipped: number;
|
|
warning: string;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new AddTasksResult(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.added = source["added"];
|
|
this.skipped = source["skipped"];
|
|
this.warning = source["warning"];
|
|
}
|
|
}
|
|
export class DirPreviewResult {
|
|
ok: boolean;
|
|
path: string;
|
|
error: string;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new DirPreviewResult(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.ok = source["ok"];
|
|
this.path = source["path"];
|
|
this.error = source["error"];
|
|
}
|
|
}
|
|
export class EngineInfo {
|
|
running: boolean;
|
|
queued: number;
|
|
galleryConcurrency: number;
|
|
pageConcurrency: number;
|
|
maxConcurrent: number;
|
|
requestIntervalMs: number;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new EngineInfo(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.running = source["running"];
|
|
this.queued = source["queued"];
|
|
this.galleryConcurrency = source["galleryConcurrency"];
|
|
this.pageConcurrency = source["pageConcurrency"];
|
|
this.maxConcurrent = source["maxConcurrent"];
|
|
this.requestIntervalMs = source["requestIntervalMs"];
|
|
}
|
|
}
|
|
export class PushedHistoryResult {
|
|
count: number;
|
|
items: core.PushedEntry[];
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new PushedHistoryResult(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.count = source["count"];
|
|
this.items = this.convertValues(source["items"], core.PushedEntry);
|
|
}
|
|
|
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
|
if (!a) {
|
|
return a;
|
|
}
|
|
if (a.slice && a.map) {
|
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
|
} else if ("object" === typeof a) {
|
|
if (asMap) {
|
|
for (const key of Object.keys(a)) {
|
|
a[key] = new classs(a[key]);
|
|
}
|
|
return a;
|
|
}
|
|
return new classs(a);
|
|
}
|
|
return a;
|
|
}
|
|
}
|
|
export class ScanManifestsResult {
|
|
found: number;
|
|
adopted: number;
|
|
root: string;
|
|
note: string;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new ScanManifestsResult(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.found = source["found"];
|
|
this.adopted = source["adopted"];
|
|
this.root = source["root"];
|
|
this.note = source["note"];
|
|
}
|
|
}
|
|
export class StartResult {
|
|
started: boolean;
|
|
reason: string;
|
|
count: number;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new StartResult(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.started = source["started"];
|
|
this.reason = source["reason"];
|
|
this.count = source["count"];
|
|
}
|
|
}
|
|
export class StatusResult {
|
|
running: boolean;
|
|
queued: number;
|
|
|
|
static createFrom(source: any = {}) {
|
|
return new StatusResult(source);
|
|
}
|
|
|
|
constructor(source: any = {}) {
|
|
if ('string' === typeof source) source = JSON.parse(source);
|
|
this.running = source["running"];
|
|
this.queued = source["queued"];
|
|
}
|
|
}
|
|
|
|
}
|
|
|