Files
toor 2d3c322e34 feat(油猴): 行内推送按钮 + 已推送画廊标灰
需求两条:
  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 才跑。
2026-10-25 00:16:03 +08:00

184 lines
4.8 KiB
Go

package core
// 推送历史测试:核心是「任务删了,灰标不该跟着消失」
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"eh-downloader-wails/internal/eh"
)
func TestPushedStoreMarkAndPersist(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "pushed.json")
s := NewPushedStore(p)
s.Mark("111", "aaa", "T1")
s.Mark("222", "bbb", "T2")
// 重复推送同一个,只更新时间不重复添加
s.Mark("111", "aaa", "T1 又推了一次")
s.Flush()
if got := s.Count(); got != 2 {
t.Fatalf("应有 2 条,实际 %d", got)
}
// 重新打开,验证真的落盘了
s2 := NewPushedStore(p)
if got := s2.Count(); got != 2 {
t.Fatalf("重开后应有 2 条,实际 %d", got)
}
gids := map[string]string{}
for _, e := range s2.Entries() {
gids[e.GID] = e.Title
}
if gids["111"] != "T1 又推了一次" {
t.Errorf("重复推送应更新标题,实际 %q", gids["111"])
}
if gids["222"] != "T2" {
t.Errorf("222 标题不对: %q", gids["222"])
}
}
func TestPushedStoreMarkBatch(t *testing.T) {
dir := t.TempDir()
s := NewPushedStore(filepath.Join(dir, "pushed.json"))
s.MarkBatch([]PushedEntry{
{GID: "1", Token: "a", Title: "A"},
{GID: "2", Token: "b", Title: "B"},
{GID: "1", Token: "a", Title: "A2"}, // 重复
})
if got := s.Count(); got != 2 {
t.Fatalf("批量后应有 2 条,实际 %d", got)
}
}
// 关键场景:任务被「清除已完成」删掉后,推送历史必须还在
func TestPushedSurvivesTaskDeletion(t *testing.T) {
dir := t.TempDir()
store := NewStore(filepath.Join(dir, "state.json"))
pushed := NewPushedStore(filepath.Join(dir, "pushed.json"))
// 模拟油猴推送
store.AddTasks([]eh.GalleryRef{{GID: "111", Token: "aaa", Raw: "u", Title: "T1"}})
pushed.Mark("111", "aaa", "T1")
// 任务完成
task := store.FindTask("111", "aaa")
if task == nil {
t.Fatal("任务没了")
}
task.Status = "done"
store.UpsertTask(*task)
// 清除已完成 —— 任务从列表消失
n, gids := store.ClearFinishedWithGIDs()
if n != 1 {
t.Fatalf("应清掉 1 个,实际 %d", n)
}
if len(gids) != 1 || gids[0] != "111" {
t.Fatalf("应返回 gid 111,实际 %v", gids)
}
if store.CountDone() != 0 {
t.Error("任务应已清空")
}
// 但推送历史还在 —— 灰标不会消失
if pushed.Count() != 1 {
t.Fatalf("推送历史应还剩 1 条,实际 %d", pushed.Count())
}
entries := pushed.Entries()
if entries[0].GID != "111" {
t.Errorf("gid 不对: %s", entries[0].GID)
}
}
func TestPushedMarkCompleted(t *testing.T) {
dir := t.TempDir()
s := NewPushedStore(filepath.Join(dir, "pushed.json"))
s.Mark("111", "aaa", "T1")
s.MarkCompleted("111")
entries := s.Entries()
if len(entries) != 1 || !entries[0].Completed {
t.Fatalf("应标记为已完成: %+v", entries)
}
}
func TestPushedStoreClear(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "pushed.json")
s := NewPushedStore(p)
s.Mark("111", "aaa", "T1")
s.Mark("222", "bbb", "T2")
s.Clear()
s.Flush()
if got := s.Count(); got != 0 {
t.Fatalf("清空后应为 0,实际 %d", got)
}
// 文件应该被写成空数组,而不是残留旧数据
raw, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
var st PushedState
if err := json.Unmarshal(raw, &st); err != nil {
t.Fatalf("文件应还是合法 JSON: %v", err)
}
if len(st.Entries) != 0 {
t.Errorf("文件里应为空,实际 %d 条", len(st.Entries))
}
}
// 桥口 /pushed 接口
func TestBridgePushedEndpoint(t *testing.T) {
dir := t.TempDir()
store := NewStore(filepath.Join(dir, "state.json"))
pushed := NewPushedStore(filepath.Join(dir, "pushed.json"))
base := startTestBridgeWith(t, store, pushed)
// 通过 /add 推一个
body := `{"gid":"777","token":"ccc","url":"https://e-hentai.org/g/777/ccc/","title":"T777"}`
req, _ := http.NewRequest("POST", base+"/add", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-EHDL-Token", BridgeToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
// /pushed 里能查到
code, raw := getWithToken(t, base+"/pushed", BridgeToken)
if code != http.StatusOK {
t.Fatalf("应 200,实际 %d", code)
}
var j struct {
OK bool `json:"ok"`
Count int `json:"count"`
Items []struct {
GID string `json:"gid"`
Title string `json:"title"`
} `json:"items"`
}
if err := json.Unmarshal(raw, &j); err != nil {
t.Fatalf("解析失败: %v (%s)", err, raw)
}
if j.Count != 1 || len(j.Items) != 1 || j.Items[0].GID != "777" {
t.Fatalf("/pushed 应返回 777,实际 %s", raw)
}
if j.Items[0].Title != "T777" {
t.Errorf("标题不对: %q", j.Items[0].Title)
}
// 错 token -> 403
code, _ = getWithToken(t, base+"/pushed", "bad")
if code != http.StatusForbidden {
t.Errorf("错 token 应 403,实际 %d", code)
}
}