diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 11b2b4fb..8b801a8f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -99,7 +99,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣台后胶囊仍写「标签建议待确认」;建议里用到的标签被删了,这份建议就丢掉。 - 还没有标签、指令框也空着时,打开「✦ AI 打标签」指令框里预填「看看这些视频(UP 主)的内容,新建 3–5 个标签分好类……」,不写指令也能直接运行。个数跟着这次能新建的名额走(新建上限和收藏夹剩下的名额取小的):不到 5 个就写到名额为止,0 个不预填;没改过的预填下次打开会按新名额重写。为什么:第一次用的人不知道该写什么;指令要的比名额多,多出来的标签会被丢掉。 - 没有字幕只有三种情况,每种一句话,视频页、专注模式、弹窗、侧边栏、发给 AI 的说明、分拣台和笔记都用这一句(`MdgErrors.subtitleMissing`):「视频没有字幕」「字幕抓取失败:原因」「未登录 B站,拿不到字幕」。没登录是单独的一种状态,不算抓取失败。能刷新的页面(视频页、专注模式、侧边栏)在没登录那句后面再加「登录 B站后刷新网页」;笔记只写状态,不写怎么操作。为什么:同一种情况每个入口都一样;笔记留在库里,操作指引过后就没用了。 -- 细看卡片的信息行写这次按什么判断,用上面那句加「,按简介和评论判断」;用了时间可能对不上的字幕时写「字幕时间轴到 X,视频长 Y,字幕时间可能对不上」。 +- 分拣台细看没有字幕的卡片,在 AI 判断那一行最前面放灰色标记「无字幕」「未登录无字幕」或「字幕抓取失败」,悬停写上面那句加「,按简介和评论判断」;信息行不再说一遍。用了时间可能对不上的字幕时信息行写「字幕时间轴到 X,视频长 Y,字幕时间可能对不上」。粗看不读字幕,没有这个标记。 - 标题粗看、字幕细看和 ✦ AI 打标签(两种模式,摘读记录也一样,它没有横幅,红字和「去设置」「授权」写在第 1 行的状态文字里)遇到 AI 平台的问题立刻停下,页面弹红色横幅「去设置」:API Key 不对、余额不足或额度用完(不少平台也回 429,按返回内容分开)、接口地址或模型名不对、连不上、同一个请求连续两次超时;AI 平台限流照常琥珀倒计时,连续等 5 次仍限流也停下。只和这一个视频(粗看是这一次请求的那批)有关的错(AI 回复格式不对、AI 回复里没有这个视频、输出被截断、字幕取不到)只标这些卡片「分析失败:原因」,接着跑;粗看失败的留在「未分析」,细看失败的留在「粗看完成」,都排在最后,组头写「分析失败 N」和「全部重试」(失败的比一批多时写「重试下一批 N 个」,有选中时写「重试选中的 N 个」),卡片上有「重试」。「标题粗看下一批」「字幕细看下一批」不自动重发失败的。卡片上的「重试」和整批一样是一次运行:有胶囊和完成行,粗看和细看都一样;别的 AI 步骤在跑时它灰掉写原因(「标题粗看还在跑,等它完成」)。停下的「去设置」横幅在暂停时也留着,粗看和细看一样,只有跑完才消失。侧边栏和设置页的「测试连接」的 AI 错误用同样的中文说法(缺这个地址的访问权限时写「未授权访问 …,授权后重试」,并给授权按钮;设置页新加、还没保存的平台点「测试连接」时,先在这次点击里请求这个地址的访问权限再测试,拒绝了就写这一句并给「授权」,2026-10-14 用户定先测试再保存;设置页每一行的「测试连接」在这一行的接口地址或模型空着时灰掉,写「先填接口地址」「先填模型」或「先填接口地址和模型」;Obsidian 一节的「测试连接」同样在地址或 Key 空着时灰掉,写「先填 Local REST API 地址」「先填 Local REST API Key」或「先填 Local REST API 地址和 Key」;接口地址、模型名和 API Key 有没有填只由后台的 `aiProviderProblem` 查一处,侧边栏、测试连接和分拣台都写「AI 平台的模型名没填」这类话、给「去设置」,分拣台遇到就停下,不让每个视频各失败一次;平台用 HTTP 200 回错误(余额不足、限流)时测试连接也按回复内容报错,不写「连接成功」),同一套错误(Key 不对或没填、余额不足、接口地址或模型名不对或没填、连不上、超时)都在「重试」前加「去设置」,和分拣台停下并给「去设置」的是同一套(都按 `errors.js` 的 `MdgErrors.remedies` 认:侧边栏直接用它,分拣台和摘读记录经 `triage/runs.js` 的 `aiSettingsCode` / `aiEnds`;「授权」只给后台的 `AI_ACCESS`,AI 平台回的同样文字不给,每个入口一样);回答流里报的错按同样的规则分,每分钟配额用完算限流,不算余额不足;侧边栏的回答一个字都没收到(模型只出了思考内容,或平台回的是一段错误 JSON)时报错并给「重试」(「AI 平台没有返回回答」或按那段错误分类),不画空白气泡;回答到一半 60 秒没有新内容写「回复中断:60 秒没有新内容」(`MdgErrors.aiStalled`),回答到一半连接断了写「回复中断:连接断了」,都只给「重试」,改设置没用(平台已经回了,不是地址不对);分拣台的回复(不是流)里带错误时也按这条认。AI 平台超时只有一句「AI 平台 N 秒没有回应」(`MdgErrors.aiTimeout`),N 是各自的上限:侧边栏等第一段回复 90 秒,分拣台 120 秒(开思考 240 秒),设置页「测试连接」30 秒。为什么:这类错误后面每个视频都会照样失败,白等。 - 导出的「逐个视频笔记」也要跑十几分钟,但它是从弹窗开的,所以只用进度胶囊(「写入笔记 N/M」,下载 .zip 时写「生成笔记 N/M」,按钮「停止」),不占第 3 行的主按钮。关掉弹窗、切换收藏夹都不停,重新打开弹窗看进度和失败列表;它和改 B站 的批量操作在跑时,关闭或刷新分拣台前浏览器先问一句;跑完胶囊留绿色结果行(「逐个视频笔记完成:写入 N 篇 · 失败 M 篇」,下载 .zip 时写「下载 N 篇」)。B站限流时和粗看一样变琥珀倒计时,到时重试同一个视频。字幕抓取失败(字幕列表或字幕文件断网、超时、链接过期,不是限流)时照样写笔记,和视频页一样在笔记开头写「字幕抓取失败:原因,以下为简介与热门评论。」(没登录时写「未登录 B站,拿不到字幕,以下为简介与热门评论。」;「以下为」只列笔记里有的简介、热门评论,都没有就不写);这篇、没登录的那篇和字幕时间可能对不上的那篇在失败列表下面用琥珀色列出(「标题:字幕抓取失败:原因。」「标题:未登录 B站,拿不到字幕。」「标题:字幕时间轴到 X,视频长 Y…」),不算失败。摘读记录页和侧边栏写这篇笔记时同样照写,提示行开头带同一句。为什么:同一种情况每个入口都一样;字幕拿不到,简介和评论仍有用。Obsidian 不能用(没配置、没打开、API Key 不对、地址不对)时开跑前先查一次,跑到一半遇到也立刻停下:弹窗里红字写原因,页面上弹红色横幅;没配置、API Key 不对或地址不对时两处都给「去设置」,没打开(连不上)时只写「连不上 Obsidian,请确认它已打开并启用了 Local REST API」,不给「去设置」。为什么:一个视频限流或 Obsidian 不通,后面每个视频都会照样失败,白等十几分钟。其他写入 Obsidian 的地方(弹窗、视频页、摘读记录、侧边栏、「一篇摘录」)出错也用同一套原因、红字;没配置、API Key 不对或地址不对时在红字旁给「去设置」(弹窗写在状态行,视频页写在页面提示里,侧边栏写在提示条里并一直留着,摘读记录写在状态文字里),点了才打开设置页,不自动跳;没打开时只显示红字,不给「去设置」。弹窗开着时在设置页关掉了写入 Obsidian,再点「写入 Obsidian」也是「写入 Obsidian 失败:写入 Obsidian 未启用」并给「去设置」,视频页和网页一样。为什么:每个入口一样,先看清原因,再自己决定去不去设置页;Obsidian 没打开,设置页里改什么都没用。 - 读 B站 被风控(HTTP 412、HTTP 429 或风控码)时不马上重试,也不换备用接口再问,直接提示「B站限流,请稍后再试」,视频页、侧边栏和分拣台都用这一句(YouTube 的字幕被限流时同样写「YouTube 限流,请稍后再试」,不露 429);限流不算字幕抓取失败,视频页不写进笔记,红字提示并给「重试」;断网、超时这类一时的错误照常重试。为什么:风控时马上重试只会被拦得更久。 @@ -137,29 +137,32 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 第 1 行 标题 · 数量 在 B站打开↗ 今天 HH:MM 刷新过 排序 ▾ ≡ 第 2 行 步骤页签 / [UP 主 | 视频投稿] [搜索…] [↻ 刷新] [⇩ 导出] 第 3 行 状态 状态片(见下) [☐ 全选 N 个] [✎ 判断标准] [主按钮] -第 4 行 标签 自定义标签 … [标签管理] [✦ AI 打标签] +第 4 行 标签 全部 | 未打标签 已打标签 | 自定义标签 … [标签管理] [✦ AI 打标签] 左栏底部 ⚙ 收藏夹设置 / 关注设置 收藏夹 -第 3 行 状态 全部 | 值得留 可清理 拿不准 已保留 | 未打标签 已打标签 | 看完了 | 已失效 | ✦ AI 刚打的 × - (所有收藏夹:全部 | 重复收藏 | 值得留 …) +第 2 行 全部 | 未分析 → 粗看完成 → 细看完成 +第 3 行 状态 全部 | 已保留 | 待决定 (值得留 可清理 拿不准) | 无字幕 | 看完了 | 已失效 | ✦ AI 刚打的 × + (所有收藏夹:全部 | 重复收藏 | 已保留 …) 左栏 收藏夹列表 关注 · UP 主 -第 3 行 状态 全部 | 活跃 慢更 断更 待查 没投过稿 未查 | 未打标签 已打标签 | ✦ AI 刚打的 × +第 3 行 状态 全部 | 活跃 慢更 断更 待查 没投过稿 未查 | ✦ AI 刚打的 × 左栏 全部 · 默认分组 · ★ 特别关注 · 自己建的分组… · [分组管理] · 已出分拣范围 关注 · 视频投稿 -第 3 行 状态 全部 | UP 未打标签 UP 已打标签 +第 3 行 (没有,只在刷新时放「暂停刷新」) +第 4 行 标签 全部 | UP 未打标签 UP 已打标签 | 自定义标签 … ``` 行的样子、间距和窄屏排法见「三、视觉设计 · 分拣台」。 -- 收藏夹和关注的第 3 行都有「未打标签 / 已打标签」,排在最后一个固定组后面、条件片前面(收藏夹在「按 AI 判断」后面,「未分析」页签没有这组时紧跟「全部」或「重复收藏」)。收藏夹按卡片上显示的标签算:「所有收藏夹」里只算视频所在收藏夹的标签。 -- 第 3 行用细竖线分组。组内单选,再点一次已选中的就取消;组和组之间是「并且」。点「全部」只清空第 3 行,不碰第 4 行的标签和左栏。 -- 「未分析」没有 AI 判断,不画这一组(不是 0,是不可能有)。「已保留」放在 AI 判断组最后,只在「阅览全部」出现。 +- 收藏夹和关注的第 4 行开头是自己的「全部」,接着「未打标签 / 已打标签」(视频投稿按视频的 UP 算,写「UP 未打标签 / UP 已打标签」),再是标签。收藏夹按卡片上显示的标签算:「所有收藏夹」里只算视频所在收藏夹的标签。2026-11-10 用户定:以前这组在第 3 行。为什么:有没有标签是你自己打的,和标签放一行。 +- 第 3、4 行用细竖线分组。组内单选(标签片多选,「并且」),再点一次已选中的就取消;组和组之间、两行之间都是「并且」。第 3 行的「全部」只清空第 3 行,第 4 行的「全部」只清空第 4 行(有没有标签和标签片),都不碰左栏。两个「全部」样子一样,所在行什么都没开时是按下的。2026-11-10 用户定。 +- 收藏夹第 3 行的「已保留」「待决定」和三个 AI 判断是同一个单选。保留过的视频只算「已保留」,不再算 AI 判断,和卡片一致(保留过的卡片不显示 AI 判断)。「待决定」是还没保留的视频,三个 AI 判断是它的细分,套在它后面的细框里,个数加起来等于它(「全部」和「未分析」里还有 AI 没看过的,所以会少)。「未分析」没有 AI 判断,只有「已保留」「待决定」。2026-11-10 用户定。为什么:人工判断过就不需要 AI 判断;上一版两组能叠加,「已保留 + 可清理」这种组合没有意义。框而不用括号或箭头:箭头在页签行表示 AI 往前走,括号像文字。 +- 第 3 行按 AI 判断的批量按钮(「取消收藏(AI:可清理)N 个」这类)只算待决定的视频。 - 「待查」的全称「慢更或断更 · 待查」写在 tooltip 里。 -- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放在第 3 行最后,平时的布局不会因为它们出现或消失而挪动。例外:「重复收藏」(在几个勾选的收藏夹里都有的视频)只在「所有收藏夹」里有,单独一组放在「全部」后面;和已失效一样,数量为 0 时隐藏(开着时照样显示)。「视频投稿」还没有观看进度,所以没有「看完了」。 +- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放在第 3 行最后,平时的布局不会因为它们出现或消失而挪动。例外:「无字幕」紧跟在 AI 判断那组后面,它说的是这个判断只凭简介和评论(2026-11-10 用户定)。例外:「重复收藏」(在几个勾选的收藏夹里都有的视频)只在「所有收藏夹」里有,单独一组放在「全部」后面;和已失效一样,数量为 0 时隐藏(开着时照样显示)。「视频投稿」还没有观看进度,所以没有「看完了」。 - 「全选」放在第 3 行右边按钮组的最前面,条件片出现或消失时它不动。「视频投稿」没有全选。「所有收藏夹」和具体收藏夹一样有全选,选的是列出的视频,不分收藏夹。选中栏里不放全选。第 3 行没有「多选」开关:点卡片就是选中(见下面的「卡片」)。 - 排序放在第 1 行右边。「视频投稿」的排序只有「更新时间」(默认,B站 给的顺序,新的在前)和「随机」。「更新时间」没有方向按钮:视频投稿往下一直能加载,没有最旧的一头可以倒过来。「随机」打乱已加载的视频;之后滚动加载的每一页单独打乱,接在最下面,已经显示的卡片不动。「随机」时方向按钮的位置换成「↻」(悬停写「再随机一次」),点了重新打乱全部已加载的并回到顶部;为什么:下拉框里再选一次「随机」不算改动,没有别的办法重新打乱。换回「更新时间」恢复 B站 的顺序,不重新读取。两个页签的排序都随视图记住。「已出分拣范围」时标题写「已出分拣范围 · N」。 - 搜索框放在第 2 行,两种模式各记各的搜索词。提示文字(和读屏用的 aria-label)要写出搜索范围,比如收藏夹的「在「想学的技能」 · 未分析里搜」、关注的「在「硬核科普」 · 活跃里搜」。开了搜索或任何筛选时,搜索框旁边显示「N 个结果」。 @@ -180,8 +183,13 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 #### 筛选和计数 -- 第 3 行每组的数量不算本组自己的选择,但算别的组、第 4 行、搜索和左栏。固定的组数量为 0 时变淡,不隐藏;左栏也一样。条件片(看完了、已失效、AI 刚打的)数量为 0 时隐藏,开着的时候照样显示,方便关掉。「全部」的数量是清空第 3 行以后的。 +- 第 3 行每组的数量不算本组自己的选择,但算别的组、第 4 行、搜索和左栏。固定的组数量为 0 时变淡,不隐藏;左栏也一样。条件片(无字幕、看完了、已失效、AI 刚打的)数量为 0 时隐藏,开着的时候照样显示,方便关掉。「全部」的数量是清空第 3 行以后的。 - 标签行(第 4 行):只列当前页签里有视频(或 UP 主)带着的标签,打开的标签照样显示。每个标签带数量:收藏夹数当前页签的视频,关注的「UP 主」数 UP 主,「视频投稿」数视频。多选是「并且」,数量不算第 4 行自己的选择。摘读记录的标签行一样,只列列出的记录带着的标签和打开的标签。 +- 页签行第一个是「全部」(以前叫「阅览全部」,排在最后),后面是未分析、粗看完成、细看完成,只看 AI 做到了哪一步。三行的「全部」上下对齐:页签本来就从第 3、4 行第一个按钮的位置开始,左边是收起头部的按钮,所以不加行名。页签和第 3、4 行用同一种按钮(没选中的没有底色,选中的实心),不再是文字加粉色下划线;「全部」后面一道竖线,三个步骤之间留「→」。为什么:三行看起来是同一种筛选更好懂;箭头说明 AI 只往前走,不会退回上一步。2026-11-10 用户定。保留不换页签:保留的视频留在它 AI 做到的那一步。取消收藏的视频不在任何页签里(「全部」也没有),去「已出分拣范围」;刚取消的还能按 U 撤销。2026-11-10 用户定:去掉「处理完成」。为什么:以前保留就进处理完成,只做过标题粗看就保留的视频再也不能字幕细看,只剩一句 20 字以内的粗看理由。 +- 「字幕细看下一批」和「按新标准重新细看下一批」跳过已保留的视频(省 AI 的钱);选中的照跑,保留没保留都一样。下一批只剩已保留的时按钮灰掉,写「已保留的视频不进下一批,选中后可以细看」。2026-11-10 用户定。 +- 批量取消收藏:第 3 行按 AI 判断的按钮只取待决定的视频;选中的已保留视频照样能批量取消收藏(筛「已保留」后全选再删)。2026-11-10 用户定。为什么:不选中时一键按 AI 判断删,不该连你明确保留的一起删;选中就是明确的意思。 +- 卡片 AI 判断那一行有一个 AI 按钮,按 AI 做到哪一步给,决定过的卡片也有:粗看完成给「✦ 字幕细看」,细看完成给「✦ 重新细看」。未分析的卡片没有,也没有再粗看一次的按钮。为什么:粗看一次请求带 20 个标题才省,单个跑不划算,用「标题粗看选中的 N 个」就行;同一个标题再粗看结果一样,判断标准改了有「按新标准重新粗看」。按钮和卡片的「重试」一样只跑这一个,进度胶囊和完成提示照常;别的 AI 步骤在跑时灰掉写原因。分析失败的卡片只有「重试」。2026-11-10 用户定。 +- 「✦ 重新细看」点了直接跳过缓存再细看这一个,不问侧重。新结果回来之前旧结果留着,失败也留着。为什么:判断标准在提示词里排在默认说明前面,整个收藏夹要换摘要写法就改判断标准再「按新标准重新细看」;只关心这一个视频的某个方面,用卡片上的「问 AI」。每个视频各存一份侧重,重跑时带不带它又成了新问题。2026-11-10 用户定(试过「侧重」输入框后删掉)。 - 「标题粗看」和「字幕细看」点一次跑一批:没有选中时按钮写「标题粗看下一批 N 个」「字幕细看下一批 N 个」;有选中时只跑选中的,写「标题粗看选中的 N 个」「字幕细看选中的 N 个」,选中的比一批多时写「标题粗看选中的前 N 个」「字幕细看选中的前 N 个」。按钮留在第 3 行原位,不进选中栏。开跑时选中不变,跑完离开这个页签的卡片才离开选中。一批多少在收藏夹设置里:「标题粗看每批数量」默认 100、最多 1000(粗看用的信息少),「字幕细看每批数量」默认 10、最多 50;都取整数,超出范围改回范围内,留空用默认值。粗看一次 AI 请求固定带 20 个标题,不是设置。「按新标准重新粗看 / 细看」和失败的「重试」按同样的规则:选中的(只算要重做的)优先,否则下一批,按钮写「按新标准重新粗看下一批 N 个」「按新标准重新细看选中的 N 个」这类。有选中、但选中的都不能跑这一步(已失效、分析失败、已经做过…,或都被筛选隐藏)时,按钮不改跑下一批:照样写「标题粗看选中的 N 个」这类(N 是选中的个数),灰掉并写「选中的都被筛选隐藏了」或「选中的都不能标题粗看」「选中的都不能字幕细看」「选中的都不用按新标准重新粗看」「选中的都不能重试」。为什么:有选中时跑了没选的,会让人以为跑的是选中的。失败组头的「全部重试 / 重试选中的 N 个」随点选马上变,写的就是点了会跑的。改了判断标准后,只要「粗看完成」里有按旧标准粗看的就弹「判断标准改了」,不管筛选:正文写总数(被筛掉一部分时补「当前筛选下列出 M 个」),按钮和第 3 行的「按新标准重新粗看」同一批、同样的字,当前筛选下没有可跑的就灰掉写原因。标题粗看或字幕细看正在跑时不弹,改成提示「有 N 个视频是按旧标准粗看的,等字幕细看跑完,点「按新标准重新粗看」就能更新」。为什么:那时按钮点不了,弹一个只能取消的框没用。 - 「全选」「标题粗看下一批 N 个」里的 N 只算你现在看得见的,也就是经过搜索和筛选之后剩下的。被筛掉的视频不受影响,它们的选中状态也保留。选中只属于当前页签:换页签清空选中。跑一步、保留、取消收藏、移动 / 复制都不改选中,好让你看到刚做过的是哪些;只有因此离开这个页签的视频才离开选中。关注也一样:取消关注、重新关注、移到别的分组以后,不在左栏这一项(或「已出分拣范围」这个页签)里的 UP 主才离开选中;换左栏的项不清空选中,没列出的算「被筛选隐藏」。摘读记录页的搜索也照这条:搜不到的保留选中,选中栏的「下载选中的 N 个 .md」「删除选中的 N 个的 AI 对话」的 N 是列出的选中个数,点了只处理它们;选中的都被搜索或筛选隐藏时 N 是选中的个数,选中栏的按钮都灰掉写「选中的都被筛选隐藏了」。「清空全部 AI 对话」以后,只剩 AI 总结或备注、还列着的视频照样选中,删掉的才离开选中。 - 列表整个重画时(刷新读到新视频、粗看结果回来、取消收藏后…)按卡片保持位置,不按像素:视野里最上面那张卡片留在原来的高度,上面多一张或少一张,眼前的卡片都不挪;列表在最顶上时留在顶上,新加的卡片看得见。收藏夹、关注的 UP 主和视频投稿都一样(`TriageUi.keepScroll`)。换的是列出哪些卡片时(页签、左栏、第 3 行、第 4 行、搜索、排序)回到顶部,从第一个结果看起。 @@ -210,16 +218,17 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 - 删除分组一定确认,写明里面的 UP 主还关注着、不在别的分组的回到默认分组,删除后不能按 U 撤销;删除时清掉这个模式里改分组的撤销步骤。 - B站 确认成功后才改本地的分组数据。超时报「不确定 B站 是否已改,刷新后看一下」,不改本地、不记撤销。 - 关注改 B站 的操作(取消关注、重新关注、改分组和它们的撤销)一个一个发,间隔 1 秒,进度在胶囊里(「取消关注中 3/10」)。B站限流时胶囊变琥珀倒计时(「取消关注中 3/10 · B站限流卡住,1:30 后重试」),到时重试同一个。 -- 改 B站 的批量写在胶囊上都有「停止」:收藏夹的批量取消收藏、移动 / 复制、批量重新收藏(包括 U 撤销批量取消收藏),关注的上一条那些。点了以后按钮变成灰的「停止中」,原因写「当前这一批做完就停止」;正在发的那一批等 B站 回来、算进做完的个数,然后停下,提示「已停止…:完成 N 个,未完成 M 个」。已做完的保留,能撤销的照常按 U 撤销;撤销本身被停止时,没撤完的再按 U 接着撤销(换了收藏夹就留在它的最近取消收藏里)。「✦ AI 打标签」和「摘录或笔记」弹窗里的「停止」也一样:按下后变成灰的「停止中」,原因和胶囊同一处来(AI 打标签也是「当前这一批做完就停止」,逐个视频笔记是「当前视频做完就停止」),不另写一句。 +- 改 B站 的批量写在胶囊上都有「停止」:收藏夹的批量取消收藏、移动 / 复制、批量重新收藏(包括 U 撤销批量取消收藏),关注的上一条那些。点了以后按钮变成灰的「停止中」,原因写「当前这一批做完就停止」;正在发的那一批等 B站 回来、算进做完的个数,然后停下,提示「已停止…:完成 N 个,未完成 M 个」。已做完的保留,能撤销的照常按 U 撤销;撤销本身被停止时,没撤完的再按 U 接着撤销(换了收藏夹就留在「已出分拣范围」的「最近取消收藏」里)。「✦ AI 打标签」和「摘录或笔记」弹窗里的「停止」也一样:按下后变成灰的「停止中」,原因和胶囊同一处来(AI 打标签也是「当前这一批做完就停止」,逐个视频笔记是「当前视频做完就停止」),不另写一句。 - 改 B站 时发现没登录(收藏夹和关注都一样),除了操作失败的提示,还弹「去登录」横幅。 #### 定下来的决定(改之前先问用户) - 第 2 行是流程和视图,第 3 行是状态(系统算出来的判断),第 4 行是标签(你自己建的),左栏只放范围。为什么:状态和标签不是一个层级,放在一行就分不清。 - 关注的「活跃 / 慢更 / 断更…」和收藏夹的「值得留 / 可清理 / 拿不准」是同一层,都是对这一条的判断;它们不对应「未分析 / 粗看 / 细看」,那是分拣进度。 -- 收藏夹和关注都有「未打标签 / 已打标签」。为什么:要能只挑出没标签的,再用 ✦ AI 打标签补上。以前收藏夹没有,理由是收藏夹的标签只是收藏夹里的细分,但这样就挑不出漏打的视频。 +- 收藏夹和关注都有「未打标签 / 已打标签」,在第 4 行。为什么:要能只挑出没标签的,再用 ✦ AI 打标签补上。以前收藏夹没有,理由是收藏夹的标签只是收藏夹里的细分,但这样就挑不出漏打的视频。 +- 「已出分拣范围」的「全部」和「已取消收藏」页签下面列「最近取消收藏」:每个勾选收藏夹的取消收藏记录,新的在前,最多 50 条,每条写从哪个收藏夹取消的,「重新收藏」放回那个收藏夹;整批取消收藏的有一行「这批全部重新收藏」。2026-11-10:以前在「处理完成」里,那个页签去掉后挪到这里。 - 第 3 行组内单选,组和组同时生效。 -- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放第 3 行最后,数量为 0 时隐藏;固定的组为 0 时变淡。为什么:平时的排版不跳动。 +- 只是有时才有的条件片(看完了、已失效、AI 刚打的)放第 3 行最后,数量为 0 时隐藏(「无字幕」也是条件片,但紧跟 AI 判断);固定的组为 0 时变淡。为什么:平时的排版不跳动。 - 「待查」一直显示,0 时变淡。为什么:以前它一消失,后面的片就跟着挪位。 - 「全选」放在右边按钮组最前面,是三态勾选。为什么:条件片出现或消失时它不动。 - 多选不用开关:点卡片就是选中,点标题才是播放。为什么:分拣时选比看多,第一次选不用去找藏着的勾选框。 @@ -262,8 +271,8 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 ``` 白卡 第 1 行 摘读记录 N 个视频、M 个网页 · 对话 n/500 状态文字 [搜索… N 个结果] ┊ 清空全部 AI 对话 -第 2 行 状态 全部 N │ ★ 星标 N │ 未打标签 N 已打标签 N │ ✦ AI 刚打的 × [☐ 全选 N 个] -第 3 行 标签 自定义标签 … [标签管理] [✦ AI 打标签] [⚙] +第 2 行 状态 全部 N │ ★ 星标 N │ ✦ AI 刚打的 × [☐ 全选 N 个] +第 3 行 标签 全部 N │ 未打标签 N 已打标签 N │ 自定义标签 … [标签管理] [✦ AI 打标签] [⚙] 卡片 ☆ 标题 [✦ 继续问] [复制 Markdown] B站 · 时间 · N 段对话 [下载 .md] [写入 Obsidian] @@ -279,7 +288,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 #### 定下来的决定(改之前先问用户) -- 头部和卡片照分拣台:三行合成一张白卡,第 2 行放状态、第 3 行放标签,开头写行名「状态」「标签」;「全部」只清空第 2 行,不碰标签。数量照分拣台「筛选和计数」算:第 2 行每组的数量不算本组自己的选择,但算别的组、标签和搜索;标签的数量不算标签行自己的选择,但算第 2 行和搜索;「全部」是清空第 2 行以后的数。星标是第 2 行的一个状态片,「未打标签」「已打标签」和收藏夹、关注一样跟在后面,按记录自己的标签算(分拣台的只读标签不算);「✦ AI 刚打的 ×」有的时候放在第 2 行最后。2026-10-22 用户定 +- 头部和卡片照分拣台:三行合成一张白卡,第 2 行放状态、第 3 行放标签,开头写行名「状态」「标签」;两行各有一个「全部」,只清空自己那一行。数量照分拣台「筛选和计数」算:第 2 行每组的数量不算本组自己的选择,但算别的组、标签和搜索;标签的数量不算标签行自己的选择,但算第 2 行和搜索;各行的「全部」是清空这一行以后的数。星标是第 2 行的一个状态片。「未打标签」「已打标签」和收藏夹、关注一样放在标签行开头、「全部」后面,按记录自己的标签算(分拣台的只读标签不算),2026-11-10 用户定;「✦ AI 刚打的 ×」有的时候放在第 2 行最后。2026-10-22 用户定 - 搜索和分拣台一样按空格分词,每个词都要出现在这条记录里(标题、作者、标签、AI 总结、备注、对话),可以在不同地方。2026-10-22 用户定 - 搜索框在第 1 行右边,提示文字写搜索范围(「在「摘读记录」 · 星标里搜」),开着搜索或筛选时旁边写「N 个结果」,按 / 跳到搜索框。状态文字(「已复制 Markdown」、出错的红字)在第 1 行的计数后面。2026-10-22 用户定 - 批量操作在选中栏,和分拣台一样有选中才出现:「已选中 N 个 · 清空选中」,选中的被搜索或筛选隐藏时照分拣台写「另有 M 个被筛选隐藏」「都被筛选隐藏」。「全选」在第 2 行右边。2026-10-22 用户定 @@ -314,7 +323,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣 | 颜色 | token | 用在哪 | |---|---|---| -| 粉 | `--accent` | 你现在在哪、看到哪:当前页签、当前筛选、左栏的当前项(左边一道粉线,字仍是 `--text`)、正在播放的那张卡片;封面上的观看进度条和「✓ 看完了」 | +| 粉 | `--accent` | 你现在在哪、看到哪:当前筛选、左栏的当前项(左边一道粉线,字仍是 `--text`)、正在播放的那张卡片;封面上的观看进度条和「✓ 看完了」 | | 蓝(实心) | `--action` | 下一步该做什么。每个页面只能有一个实心蓝按钮。第一次用时的入口也是它:收藏夹的「选择收藏夹」、关注的「刷新」 | | 浅蓝(蓝框 + 浅蓝底 + 蓝字) | `--action-border` / `--action-bg` / `--link` | 正常在跑的进度胶囊。它可以和实心蓝按钮同时出现:长任务在跑时,进度和「下一步」一样要你注意,而且那个蓝按钮本身就变成了「暂停粗看 N/M」这样的进度 | | 蓝框 + 蓝底 | `--action` | 已选中的卡片(卡片上没有勾) | @@ -356,7 +365,7 @@ B站 页面上的 AI 判断照上表:值得留绿、可清理红、拿不准 - 第 3、4 行开头是行名「状态」「标签」:浅灰小字(12px、`--faint`),不是胶囊也不是按钮,宽度固定,所以两行第一个胶囊上下对齐,换行的胶囊也缩进对齐。760 及以下照样和第一个胶囊在同一行。 - 四行合成一张白卡(`--surface` 底、`--border` 描边、`--radius-lg` 圆角),卡片放在灰色页面底上,上面留 14px(600 及以下 8px)。卡里左右各留 16px。第 1 行一行写完:封面(原比例 16:10,40px 高)、名字 · 数量、在 B站打开、读取时间。第 2 行 40px 高,页签 13px,数量用等宽字;搜索框和按钮 28px 高。第 3、4 行上下各留 8px,列表上面也留 8px,第 2 行和第 3 行下面各有一道 `--border` 细线(1024 及以下第 2 行是两行,细线画在第 3 行上面)。卡里不再加色带或框。 -- 第 3 行和第 4 行右边的按钮要右对齐。有左栏时(宽于 760),筛选片按组换行,一组放不下才在组内换行,右边按钮钉在第一行右上。没有左栏时(760 及以下),筛选片占满整行,按钮另起一行靠右,正好在第 4 行按钮上方。 +- 第 3 行和第 4 行右边的按钮要右对齐。第 3 行的筛选片和右边按钮并排放得下就同一行;放不下时按钮整组另起一行靠右,筛选片留在一行,不再被挤成一组一行(不用横向滑动:滑走的筛选片和按钮看不见,鼠标也不好滑)。筛选片自己一行都放不下时才按组换行。2026-11-10 用户定。 - 1024 宽时,第 2 行的搜索框和按钮换到页签下面一行。720 宽时没有左栏:左栏变成顶栏的下拉框(选项带数量),⚙ 放到第 2 行最后。 - 头部收起:第 2 行最前面有一个按钮,展开时是 ▴「收起头部」,收起时是 ▾「展开头部」,只有它收起和展开头部。收起后是一条细栏,只剩「名字 · 数量」、第 2 行的页签和右边的搜索 / 刷新 / 导出,没有封面,细栏自己是一张圆角白卡,上面留的空和展开时一样。滚动、回到顶上、焦点进到头部都不改它;切换收藏夹、页签和模式也不改。两种模式各记各的,重开分拣台后照旧;切到另一种模式时,它的收起状态当场生效,和重开分拣台一样不播动画。列表滚动后头部下沿出现阴影(展开时在第 4 行下面,收起时在细栏下面),滚回顶上就消失,说明卡片在头部底下;阴影不改收起状态。列表从头部底下穿过,收起和展开只是平移和淡出,卡片不跳;内置播放框的上沿跟着头部的下沿走。收起和展开用 0.15 秒,系统设了减少动态效果就不动画。760 及以下一样。选中栏和弹窗不管头部收没收,窗口矮也不特殊处理。不设快捷键:它是偶尔才改的布局开关,不是分拣时一直要按的。 - 内置播放框(点卡片后在右边打开的 B站 页面)没有顶栏。从上往下是:收藏夹的「视频标签」行、「UP 标签」行,然后是 B站 页面。关闭是播放框右上角骑在边框上的圆形 ×(`#viewerCloseBtn`,一半在框外,tooltip「关闭播放(Esc)」),或者按 Esc。标签行右边留出空,× 不压住标签。760 及以下内置播放框占掉列表的位置,× 还在同一个角上。 diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md index 91b8df88..bc01ee0a 100644 --- a/docs/TRIAGE.md +++ b/docs/TRIAGE.md @@ -16,19 +16,23 @@ - **标签**属于各自的收藏夹,每个收藏夹默认最多 10 个(收藏夹设置里可改,1–50),一个视频可以打多个。标签那一行的「标签管理」里改名、写说明、删除,**✦ AI 打标签** 用一句话让 AI 给一批视频打标签(每次默认最多新建 5 个,收藏夹设置里可改),你检查后再应用。改标签和应用都能按 U 撤销,你之后又改过的视频不动。 - 两层互不影响:打标签不算处理,AI 判断也不改你的标签。 -## 四个步骤 +## 全部和三个步骤 -步骤表示 AI 看到了哪一步。AI 判断是每个步骤里的筛选。 +页签行开头是「全部」,后面三个页签只表示 AI 看到了哪一步,和你决定了没有无关。 | 步骤 | 里面是什么 | 你做什么 | |---|---|---| | 未分析 | AI 还没看过的 | 点 **✦ 标题粗看下一批**。AI 只看标题和简介,一次跑 100 个(最多 1000),每次请求带 20 个标题,便宜 | | 粗看完成 | AI 看过标题的 | 拿不准的点 **✦ 字幕细看下一批**(读字幕,每批默认 10 个,最多 50)。有把握的可以直接保留或取消收藏 | | 细看完成 | AI 读过字幕的,判断最可靠 | 看 AI 总结和要点,一个个或批量保留、取消收藏 | -| 处理完成 | 你处理过的 | 后悔了可以重新收藏,整批取消收藏的可以整批重新收藏 | -- **保留**只在 MoonDigest 里标记,B站收藏夹不变。**取消收藏**会真的在 B站取消,按 U 可以撤销。 -- **阅览全部**:整个收藏夹一页看完,同样能按 AI 判断筛选。 +- **全部**:整个收藏夹一页看完。 +- **保留**只在 MoonDigest 里标记,B站收藏夹不变,视频留在原来的页签。**取消收藏**会真的在 B站取消,按 U 可以撤销;取消收藏的视频不在这些页签里,去「已出分拣范围」。 +- 第 3 行(状态)的「已保留」是你保留过的;「待决定」是还没保留的,后面框里的「值得留 / 可清理 / 拿不准」是 AI 对它们的判断。一次选一个。保留过的视频不再按 AI 判断分。第 4 行(标签)开头是它自己的「全部」和「未打标签 / 已打标签」。两行的「全部」各清各的。 +- 「字幕细看下一批」跳过已保留的视频,省 AI 的钱;想细看已保留的,选中再点。 +- 第 3 行按 AI 判断的批量按钮(如「取消收藏(AI:可清理)」)只动待决定的视频,不碰你保留过的;想取消收藏已保留的,选中再点「取消收藏选中的 N 个」。 +- 每张卡片 AI 判断那一行有一个按钮,只跑这一个视频:粗看过的是 **✦ 字幕细看**,细看过的是 **✦ 重新细看**。重新细看跳过缓存再跑一次,用在某次摘要明显不好时;想让整个收藏夹换个写法,改判断标准再「按新标准重新细看」,只想问这一个视频,按 Q 问 AI。 +- 细看时视频没有字幕,AI 按简介和热门评论判断,卡片上标灰色的「无字幕」(没登录 B站时是「未登录无字幕」,字幕下载失败是「字幕抓取失败」,悬停看原因)。第 3 行的「无字幕」筛选能把它们挑出来。 - 粗看、细看、批量取消收藏和移动在切换收藏夹后继续跑,进度显示在进度胶囊里。关掉分拣台页面就暂停。 - 标题粗看和字幕细看有选中时只跑选中的(最多一批);选中的都不能跑时按钮灰掉,不改跑下一批。AI 回复出错的视频标「分析失败」排在最后,可以逐个或整批重试;Key、余额、连不上这类 AI 平台的问题会停下并提示去设置。 - 收藏夹设置里能调请求间隔、两步的每批数量和输出上限。分拣台用的是 DeepSeek、智谱或 Kimi 时还有「开启思考」开关:关着快、省;开着判断更细,但慢得多、token 多几倍。 @@ -36,7 +40,7 @@ ## 选中和批量操作 - 点卡片(标题和按钮以外的地方)或按 X 选中,再点一次取消;或勾「状态」行的「全选 N 个」(再勾一次取消)。点标题是在分拣台里播放。Shift 点一张选中从上一次点的那张到这张的一段;按 Esc 或「清空选中」全部取消。⌘ / Ctrl 点标题照样在 B站打开。关注的 UP 主卡片也一样。 -- 选中只属于当前这一页,切到别的步骤或阅览就清空。跑一步或批量操作以后选中不变,只有因此离开这一页的才不再选中。被搜索或筛选藏起来的选中不算进操作,选中条上会写「另有 N 个被筛选隐藏」(都被藏起来时写「已选中 N 个」和「都被筛选隐藏」);选中的都被藏起来时,按钮照样对着选中、写选中的个数,灰掉并写「选中的都被筛选隐藏了」。 +- 选中只属于当前这一页,切到别的页签就清空。跑一步或批量操作以后选中不变,只有因此离开这一页的才不再选中。被搜索或筛选藏起来的选中不算进操作,选中条上会写「另有 N 个被筛选隐藏」(都被藏起来时写「已选中 N 个」和「都被筛选隐藏」);选中的都被藏起来时,按钮照样对着选中、写选中的个数,灰掉并写「选中的都被筛选隐藏了」。 - 选中后可以保留、取消收藏,或 **移动 / 复制** 到你 B站账号里的任意收藏夹,也可以新建一个(可设私密)。 - 移到没勾选的收藏夹时,视频进「已出分拣范围」并标出去向。之后在收藏夹设置里勾选那个收藏夹,它们会自动回来。 - **「导出」菜单**:「摘录或笔记…」的范围选当前筛选、选中或整个收藏夹(待播不参与导出),格式选一篇摘录(带每个视频的备注)或逐个视频笔记(字幕加 AI 总结),可以复制、下载或写入 Obsidian。也能导出 JSON 完整备份(不含密钥和待播)和当前收藏夹的 CSV 表格。 @@ -51,7 +55,7 @@ - **所有收藏夹**:把勾选的收藏夹合在一起看和搜索。第一次要逐个加载,之后只核对有变化的。AI 粗看、细看和 AI 打标签也能在这里做:每个视频按它排在最前的收藏夹的判断标准判断,标签按收藏夹分开打。批量保留和批量取消收藏也能在这里做,取消收藏时勾选从哪些收藏夹取消。 - **稍后再看**:和收藏夹一样分拣,卡片上标出在 B站看到了哪里。 -- **已出分拣范围**:视频离开了你勾选的所有收藏夹(在 B站取消收藏、移到没勾选的收藏夹、收藏夹被删、或你取消勾选),它的 AI 判断、AI 总结、备注和标签先放这里,不会悄悄丢掉。打开时逐个问 B站这个视频还在哪些收藏夹里(和 B站收藏按钮弹出的是同一份信息,不读没勾选收藏夹的内容),上方页签分「全部」「已取消收藏」「在未勾选收藏夹」「已失效」。也可以按 AI 判断、标签、看完了筛选,选中后收藏到任意收藏夹,或导出后清理。 +- **已出分拣范围**:视频离开了你勾选的所有收藏夹(在 B站取消收藏、移到没勾选的收藏夹、收藏夹被删、或你取消勾选),它的 AI 判断、AI 总结、备注和标签先放这里,不会悄悄丢掉。打开时逐个问 B站这个视频还在哪些收藏夹里(和 B站收藏按钮弹出的是同一份信息,不读没勾选收藏夹的内容),上方页签分「全部」「已取消收藏」「在未勾选收藏夹」「已失效」。也可以按 AI 判断、标签、看完了筛选,选中后收藏到任意收藏夹,或导出后清理。「全部」和「已取消收藏」下面有「最近取消收藏」:每个勾选收藏夹最近取消收藏的视频,点「重新收藏」放回原来的收藏夹,整批取消收藏的可以「这批全部重新收藏」。 - **从 B站刷新**:打开收藏夹时核对 B站的变化,只补有差别的部分。点「刷新」重新读取整个收藏夹。刷新结果列出新增、来自其他收藏夹、已移除和已失效的视频。 - **已失效**:B站把视频换成占位信息时,保留原来的标题、封面、UP 主和简介。被 B站隐藏的失效视频进「已出分拣范围」,标「已失效(B站已隐藏)」。有失效视频时出现「已失效 N」筛选,配合全选可以一次处理。 - 同时开两个分拣台标签页也不会互相覆盖。弹窗和设置页的分拣台入口会先切到已经打开的那个;摘读记录的入口(弹窗、侧边栏、设置页)也一样。 diff --git a/extension/history/history.js b/extension/history/history.js index 7aa9154d..b92e4594 100644 --- a/extension/history/history.js +++ b/extension/history/history.js @@ -31,7 +31,7 @@ const unmarked = new Map(); // has no U). A tag edit leaves the tag filters at once, as on the 分拣台; U brings the record back. const starEdited = new Set(); let starFilter = false; -let tagState = ""; // 第 2 行 "" | "untagged" | "tagged", on the record's own tags (the tag row's), as the 分拣台's +let tagState = ""; // 标签行 "" | "untagged" | "tagged", on the record's own tags (the tag row's), as the 分拣台's const tagFilter = new Set(); const selected = new Set(); let pickAnchor = null; // the last picked key: Shift picks the run from it @@ -416,10 +416,11 @@ function renderHead(all, groups) { const recent = UI.aiRecentChip({ attrs: 'data-f="recent"', xAttrs: 'data-f="recent-done"', n: visibleGroups({ recent: false }, all).filter((g) => recentKeys.has(readingKey(g))).length, on: recentFilter, who: "记录", check: RECENT_CHECK }); const anyTag = visibleGroups({ tags: [] }, all); const tagN = visibleGroups({ tagged: "" }, all); - els.stateRow.innerHTML = UI.labeledRow("状态", UI.stateGroup("全部", UI.filterBtn('data-f="all"', "全部", visibleGroups({ star: false, recent: false, tagged: "" }, all).length, !starFilter && !recentFilter && !tagState)) + UI.stateGroup("星标", UI.filterBtn('data-f="star"', "★ 星标", stars, starFilter)) + UI.tagStateGroup((k) => `data-f="tagstate:${k}"`, (k) => tagN.filter((g) => (g.mark?.tags?.length > 0) === (k === "tagged")).length, tagState) + recent); + els.stateRow.innerHTML = UI.labeledRow("状态", UI.stateGroup("全部", UI.filterBtn('data-f="all"', "全部", visibleGroups({ star: false, recent: false }, all).length, !starFilter && !recentFilter)) + UI.stateGroup("星标", UI.filterBtn('data-f="star"', "★ 星标", stars, starFilter)) + recent); // As the 分拣台's row 4: only the tags the listed records carry, and the ones turned on (so they can be turned off). const carrying = (id) => anyTag.filter((g) => g.mark?.tags?.includes(id)).length; - els.tagRow.innerHTML = UI.labeledRow("标签", marks.tags.filter((t) => carrying(t.id) || tagFilter.has(t.id)).map((t) => UI.tagFilterChip(`data-f="tag:${esc(t.id)}"`, { name: t.name, color: t.color, n: carrying(t.id), on: tagFilter.has(t.id), what: "记录" })).join("")); + const tagHead = UI.tagRowHead({ allAttrs: 'data-f="tagall"', allN: visibleGroups({ tagged: "", tags: [] }, all).length, on: Boolean(tagState || tagFilter.size), attrs: (k) => `data-f="tagstate:${k}"`, n: (k) => tagN.filter((g) => (g.mark?.tags?.length > 0) === (k === "tagged")).length, cur: tagState }); + els.tagRow.innerHTML = UI.labeledRow("标签", tagHead + marks.tags.filter((t) => carrying(t.id) || tagFilter.has(t.id)).map((t) => UI.tagFilterChip(`data-f="tag:${esc(t.id)}"`, { name: t.name, color: t.color, n: carrying(t.id), on: tagFilter.has(t.id), what: "记录" })).join("")); // 「选中的 N 个」 counts records, as the dialog's 选中 does. const picked = aiRecords("selected").size || recordsOf(all.filter((g) => selected.has(g.key))).size; els.tagActs.innerHTML = UI.tagButtons({ manageAttrs: 'data-f="manage"', aiAttrs: 'data-f="ai"', who: "记录", running: aiRunning(), pending: Boolean(AI.proposal), picked, aiReason: aiReason(all) }) + SETTINGS_BTN; @@ -902,9 +903,10 @@ els.filters.addEventListener("click", (event) => { if (f === "settings") return void openSettings(); if (f === "recent-done") return void clearRecent(); if (f === "select-all") UI.toggleAll(visibleGroups().map((g) => g.key), selected); - else if (f === "all") { - starFilter = recentFilter = false; + else if (f === "all") starFilter = recentFilter = false; + else if (f === "tagall") { tagState = ""; + tagFilter.clear(); } else if (f?.startsWith("tagstate:")) tagState = tagState === f.slice(9) ? "" : f.slice(9); else if (f === "recent") recentFilter = !recentFilter; else if (f === "star") starFilter = !starFilter; diff --git a/extension/history/history.selftest.js b/extension/history/history.selftest.js index 4b7737df..81d3d5a4 100644 --- a/extension/history/history.selftest.js +++ b/extension/history/history.selftest.js @@ -231,7 +231,7 @@ assert.ok(/未打标签 \d+未打标签 \d+已打标签'), els.stateRow.innerHTML); + assert.ok(!entry("untagged") && els.tagRow.innerHTML.includes('aria-pressed="true">已打标签'), els.tagRow.innerHTML); + await filter("tagall"); + assert.ok(entry("untagged") && !els.tagRow.innerHTML.includes('aria-pressed="true">已打标签') && /data-f="tagall" aria-pressed="true">全部 /.test(els.tagRow.innerHTML), els.tagRow.innerHTML); await search("读过"); assert.ok(entry(videoKey) && entry(pageKey), html); await search("分拣标签"); diff --git a/extension/triage/dev/mock-chrome.js b/extension/triage/dev/mock-chrome.js index fa6acf28..03c41e0d 100644 --- a/extension/triage/dev/mock-chrome.js +++ b/extension/triage/dev/mock-chrome.js @@ -104,6 +104,17 @@ [gone[4].bvid]: { item: gone[4], at: now - day, from: [f("1001", "稍后-AI", now - 6 * day), f("1002", "学习", now - day)] }, [gone[5].bvid]: { item: gone[5], at: now - 3600000, from: [f("1001", "稍后-AI", now - 8 * day), f("1002", "学习", now - 4 * day), f("1003", "默认收藏夹", now - 3600000)] } }; + // 稍后-AI: a 保留'd video with only 粗看 (粗看完成, 「✦ 字幕细看」 on its card), 细看 without a subtitle (no subtitle at + // all, and hidden from a logged-out user). + const coarse = (it, verdict, reason) => (store[`triage_title_${it.bvid}`] = { verdict, reason, confidence: "high" }); + const fine = (it, verdict, reason, extra = {}) => { + coarse(it, verdict, reason); + store[`triage_analysis_${it.bvid}`] = { bvid: it.bvid, status: "done", source: "subtitle", oneLiner: `${it.title.slice(0, 12)}:先讲原理再给示例。`, points: ["讲清了基本概念", "给出可运行的示例", "总结了常见误区"], verdict, reason, model: "mock-model", analyzedAt: now - day, ...extra }; + }; + coarse(ai[0], "keep", "标题显示为系统教程"); + store.triage_kept = { [ai[0].bvid]: { action: "keep", at: now - day } }; + fine(ai[3], "unsure", "只有简介和评论,拿不准", { source: "meta" }); + fine(ai[4], "keep", "原理讲得清楚", { source: "meta", needLogin: true }); } // ?demo swaps in the README screenshot data: B站-style titles and covers, every step filled, tags, 待播, notes and diff --git a/extension/triage/fav-ai.js b/extension/triage/fav-ai.js index d39561b7..5db46fcb 100644 --- a/extension/triage/fav-ai.js +++ b/extension/triage/fav-ai.js @@ -12,7 +12,7 @@ const { TITLES_PER_REQUEST } = TriageUi; // What 标题粗看 counts and sends: the first coarseBatchSize() 未分析 videos listed now (search and filters applied), // not the hidden ones. A selection wins, like 细看's nextBatch. -const coarseOpen = () => S.items.filter((it) => stageOf(it) === "none" && !S.coarseFailed.has(it.bvid) && passFilter(it)); +const coarseOpen = () => S.items.filter((it) => inStage(it, "none") && !S.coarseFailed.has(it.bvid) && passFilter(it)); const stage1Pending = () => pickBatch(coarseOpen(), coarseBatchSize(), "none"); // list: 未分析 by default; 重新粗看 passes the cards whose 粗看 used an older 判断标准. Their old results stay on the @@ -32,7 +32,7 @@ async function runStage1(list = stage1Pending(), redo = false) { }; // In its own folder the live step decides (a card may have been sorted meanwhile); elsewhere only the result does. const pending = () => - list.filter((it) => !S.coarseFailed.has(it.bvid) && !it.invalid && due(it) && (S.mediaId !== folder || ["none", "coarse"].includes(stageOf(it)))); + list.filter((it) => !S.coarseFailed.has(it.bvid) && !it.invalid && due(it) && (S.mediaId !== folder || (["none", "coarse"].includes(stageOf(it)) && !isUnfaved(it)))); const run = (S.stage1 = { mediaId: folder }); const name = runName("coarse", redo); const running = runs.run("stage1", { @@ -91,19 +91,22 @@ function otherFolderToast(run, name, r) { // ---------- AI stage 2: subtitle group ---------- // group: a 细看 run, which judges by its own items (the open folder's list is empty while a folder loads, and may be -// another folder's); 已处理 is read only in its own folder. +// another folder's). 保留 does not matter (the next batch leaves 已保留 out on its own: skipKept); 取消收藏 in its own +// folder (read only there) does, the video has left. const needsAnalysis = (b, group = null) => { const own = !group || group.mediaId === String(S.mediaId); const it = group ? group.items.get(b) : S.itemMap.get(b); const a = S.analyses[b]; - return it && !it.invalid && !(own && isProcessed(b)) && a?.status !== "done" && a?.status !== "error"; + return it && !it.invalid && !(own && isUnfaved(it)) && a?.status !== "done" && a?.status !== "error"; }; -// redo: 细看 these again under the current criteria; each keeps its old result until the new one arrives. -function startGroup(bvids, redo = false) { +// redo: 细看 these again under the current criteria; each keeps its old result until the new one arrives. name: the +// run's own name (a card's 重新细看). +function startGroup(bvids, redo = false, name = "") { if (!bvids.length || S.group) return; S.group = { bvids, + name, redo: redo ? new Set(bvids) : null, mediaId: String(S.mediaId), items: new Map(bvids.map((b) => [b, S.itemMap.get(b)])), @@ -125,7 +128,7 @@ async function analyzeOne(bvid, force, { crit, ctx }) { async function runGroup() { const group = S.group; - const name = runName("fine", Boolean(group.redo)); + const name = group.name || runName("fine", Boolean(group.redo)); const running = runs.run("group", { total: group.bvids.filter((b) => groupPending(group, b)).length, gap: S.settings.triageIntervalSec * 1000, @@ -197,6 +200,21 @@ function retry(bvid) { }); } +// A card's own AI button, whatever the user decided: 字幕细看 after 粗看, 重新细看 (bypassing the cache) after 细看. None +// before 粗看: 标题粗看 is cheap only in batches of 20 titles, so it stays a batch step. No 重新粗看: the same title gives the +// same answer, and 按新标准重新粗看 covers a changed 判断标准. A failed card has 重试 instead; an invalid one has no step left. +const REFINE = "重新细看"; +const CARD_STEP = { fine: STEP_NAME.fine, refine: REFINE }; +const cardStep = (it) => (S.mediaId === REMOVED || it.invalid || verdictOf(it).failed ? "" : { coarse: "fine", fine: "refine" }[stageOf(it)] || ""); +// A run of one through the step's own runner, as the card's 重试. +function runCardStep(bvid) { + const it = S.itemMap.get(bvid); + const step = it && cardStep(it); + if (!step || aiBusyReason()) return; + if (step === "fine") return startJudging(CARD_STEP.fine, "细看", [it], () => startGroup([bvid])); + startJudging(REFINE, REFINE, [it], () => startGroup([bvid], true, REFINE)); +} + // ---------- AI 打标签 (tag-dialogs.js) ---------- // The scopes of ✦ AI 打标签 and 摘录或笔记, drawn 「名字 · N 个」 in both. const SCOPE_LABEL = { ...UI.scopeLabel, analyzed: "细看过的", all: "整个收藏夹" }; diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index b3f4e57f..a5e2ffee 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -10,15 +10,14 @@ const THROTTLES = { AI_THROTTLED: MdgRuns.AI_HOLD }; const SYNC_MIN_GAP_MS = 60 * 1000; -// Progress tabs: how far a video has been looked at. The AI class is a filter inside a tab, never a tab. -// 阅览 sits apart after them. +// Progress tabs: how far the AI has looked at a video, whatever the user decided. The AI class and the decision are +// filters inside a tab, never a tab. 全部 sits apart before them. const STAGES = [ ["none", "未分析"], ["coarse", "粗看完成"], - ["fine", "细看完成"], - ["done", "处理完成"] + ["fine", "细看完成"] ]; -const READ_TAB = "阅览全部"; +const READ_TAB = "全部"; const NEXT_BATCH = "下一批"; // a step's button and the cards it will send // The two AI steps' names, and a run's name: the step, or its redo under a changed 判断标准 (按新标准重新粗看). const STEP_NAME = { coarse: MdgErrors.LABEL.coarseRead, fine: MdgErrors.LABEL.fineRead }; @@ -62,10 +61,11 @@ const UNDO = globalThis.TriageUi.UNDO; // 待播's remove: the list's button, E on a card in it, the card's ✓ 待播. const BASKET_REMOVE = "从待播移除"; const BASKET_REMOVED = `已${BASKET_REMOVE}${UNDO}`; -const RECENT_UNFAV = "最近取消收藏"; // 处理完成's list of the unfavorited, where each can be 重新收藏 +const RECENT_UNFAV = "最近取消收藏"; // 已出分拣范围's list of every chosen folder's 取消收藏 records, each can be 重新收藏 +const RECENT_UNFAV_AT = `「${REMOVED_NAME}」的「${RECENT_UNFAV}」`; // 取消收藏 or 保留 on a video already unfavorited: its card's buttons and the D / S keys say the same. const UNFAVED = "已经取消收藏了"; -const KEEP_UNFAVED = `${UNFAVED},要保留先在「${RECENT_UNFAV}」里${REFAV}`; +const KEEP_UNFAVED = `${UNFAVED},要保留先在${RECENT_UNFAV_AT}里${REFAV}`; const TOVIEW = "toview"; // 稍后再看, listed by triage-bg as one more folder // The fixed AI classes, shown as chips inside 粗看完成 and 细看完成. // The ids are the badge color classes too. @@ -104,7 +104,7 @@ const S = { kept: {}, removedCount: 0, folderIntro: {}, // { [mediaId]: Bilibili folder intro }, read with the folder list - folderDecisions: {}, // 所有收藏夹 only: { [mediaId]: that folder's decisions } + folderDecisions: {}, // 所有收藏夹 and 已出分拣范围 only: { [mediaId]: that folder's decisions } loadAll: null, // 所有收藏夹 only: { lists: { [mediaId]: { items, at } }, check, checkTotal, queue, phase (LOAD_ALL_STEPS), error, partial } tags: [], folderCriteria: {}, @@ -112,8 +112,9 @@ const S = { basket: [], notes: {}, finishedFilter: false, // 看完了 + noSubFilter: false, // 无字幕 dupFilter: false, // 所有收藏夹 only: 重复收藏 - tagState: "", // row 3: "" | "untagged" | "tagged" + tagState: "", // row 4: "" | "untagged" | "tagged" aiRecent: {}, sortBy: {}, // triage_sort aiRecentFilter: false, // AI 刚打的 @@ -130,7 +131,7 @@ const S = { thinkingToggle: false }, tab: "none", - classFilter: { coarse: "all", fine: "all", read: "all" }, // each tab keeps its own AI-class chip; a folder switch resets them + classFilter: { none: "all", coarse: "all", fine: "all", read: "all" }, // each tab's one pick of 已保留 / 待决定 / an AI verdict; a folder switch resets them tagFilter: new Set(), query: "", focused: "", @@ -219,8 +220,8 @@ const judgeOf = (it) => { // current one), so after the criteria change the old results can be redone. const isStale = (r, crit = criteria()) => Boolean(r) && (r.criteria ?? crit) !== crit; const judged = () => S.mediaId && S.mediaId !== REMOVED; -const staleCoarse = () => (judged() ? S.items.filter((it) => !it.invalid && stageOf(it) === "coarse" && isStale(S.titleRes[it.bvid], criteriaFor(homeOf(it)))) : []); -const staleFine = () => (judged() ? S.items.filter((it) => !it.invalid && stageOf(it) === "fine" && isStale(S.analyses[it.bvid], criteriaFor(homeOf(it)))) : []); +const staleCoarse = () => (judged() ? S.items.filter((it) => !it.invalid && inStage(it, "coarse") && isStale(S.titleRes[it.bvid], criteriaFor(homeOf(it)))) : []); +const staleFine = () => (judged() ? S.items.filter((it) => !it.invalid && inStage(it, "fine") && isStale(S.analyses[it.bvid], criteriaFor(homeOf(it)))) : []); const $ = (id) => document.getElementById(id); @@ -498,8 +499,10 @@ function tagIdsOf(bvid) { return t && (!folders || folders.includes(t.folder)); }); } -// Only 取消收藏 / 保留 finish a video; tags and notes never do. +// Only 取消收藏 / 保留 are decisions; tags and notes never are. const isProcessed = (bvid) => Boolean(S.decisions[bvid]); +const isKept = (it) => S.decisions[it.bvid]?.action === "keep"; +const isUnfaved = (it) => S.decisions[it.bvid]?.action === "unfav"; // 粗看 and 细看 runs keep going in their own folder (mediaId) after another one opens; these are the open folder's. const ownStage1 = () => Boolean(runs.get("stage1")) && S.stage1.mediaId === String(S.mediaId); @@ -548,15 +551,21 @@ function kindOf(it) { return at ? "out" : it.inFolder === null ? "unfav" : ""; } +// A 细看 that had no subtitle and judged from the intro and comments (粗看 never reads subtitles). +const noSubtitle = (it) => S.analyses[it.bvid]?.status === "done" && S.analyses[it.bvid].source === "meta"; + // 重复收藏: in more than one chosen folder (所有收藏夹 only; elsewhere items have no .folders). const isDup = (it) => it.folders?.length > 1; // skip leaves one filter group out, so that group's own counts never hide its siblings: a row-3 group ("seen", "recent", -// "kind", "tagged"), "states" (all of row 3) or "tags" (row 4). 已出分拣范围's kind is its tab, never skipped. +// "kind"), "states" (all of row 3), a row-4 group ("tagged", "tags") or "tagrow" (all of row 4). 已出分拣范围's kind is +// its tab, never skipped. +const ROW4 = new Set(["tagged", "tags"]); function passFilter(it, skip = "") { - const on = (g) => skip !== g && (skip !== "states" || g === "tags"); + const on = (g) => skip !== g && !(skip === (ROW4.has(g) ? "tagrow" : "states")); if (on("dup") && S.dupFilter && !isDup(it)) return false; if (on("seen") && S.finishedFilter && !isFinished(it)) return false; + if (on("nosub") && S.noSubFilter && !noSubtitle(it)) return false; if (on("recent") && S.aiRecentFilter && !aiRecentSet().has(it.bvid)) return false; if ((S.mediaId === REMOVED || on("kind")) && S.kindFilter && kindOf(it) !== S.kindFilter) return false; if (on("tagged") && S.tagState && (tagIdsOf(it.bvid).length > 0) !== (S.tagState === "tagged")) return false; @@ -571,25 +580,27 @@ function passFilter(it, skip = "") { let queryMemo = ["", []]; const queryWords = () => (queryMemo[0] === S.query ? queryMemo[1] : (queryMemo = [S.query, S.query.toLowerCase().split(/\s+/).filter(Boolean)])[1]); -// Which AI steps have run, not what they said. Invalid videos (可清理) can never be 细看'd, so they stop at 粗看; -// a failed 细看 stays where its 粗看 put it. +// Which AI steps have run, not what they said nor what the user decided. Invalid videos (可清理) can never be 细看'd, +// so they stop at 粗看; a failed 细看 stays where its 粗看 put it. function stageOf(it) { const b = it.bvid; - if (isProcessed(b)) return "done"; if (S.analyses[b]?.status === "done") return "fine"; if (it.invalid || VERDICTS[S.titleRes[b]?.verdict]) return "coarse"; return "none"; } +// A tab's videos before rows 3 and 4. 全部 (and 已出分拣范围) is every video, whatever its step. A video unfavorited +// here is in no tab: it stays in S.items only so U can undo it, and 已出分拣范围 lists it. +const inStage = (it, tab) => !isUnfaved(it) && (tab === "read" || stageOf(it) === tab); function inTab(it, tab) { - // 阅览 (and 已出分拣范围) is every video of the folder, whatever its step. - if (tab !== "read" && stageOf(it) !== tab) return false; - const f = S.classFilter[tab]; - return !f || f === "all" || classOf(it) === f; + if (!inStage(it, tab)) return false; + return inClass(it, S.classFilter[tab]); } +const inClass = (it, f) => !f || f === "all" || (f === "undecided" ? !isKept(it) : classOf(it) === f); -// The filter chip a card falls under: the user's decision once there is one (as on the card), else the AI verdict. -const classOf = (it) => (isProcessed(it.bvid) ? (S.decisions[it.bvid].action === "keep" ? "kept" : "unfav") : verdictOf(it).verdict); +// The row-3 chip a card falls under: once kept it leaves the AI verdicts, as its card no longer shows one. +const UNDECIDED = "待决定"; +const classOf = (it) => (isKept(it) ? "kept" : verdictOf(it).verdict); const failedAnalysis = (b) => S.analyses[b]?.status === "error"; // A 粗看 that failed on a card still without one: it stays in 未分析 with the failure and a retry, like a failed 细看. @@ -675,26 +686,30 @@ const stage1Label = (batch) => batchLabel(STEP_NAME.coarse, batch, coarseOpen(), const groupLabel = (batch) => batchLabel(STEP_NAME.fine, batch, fineOpen(), "coarse"); // Why it is disabled when the selection has nothing it can run (why: 「选中的都不能字幕细看」 and the like). const pickReason = (why, batch, tab) => (!picking(tab) || batch.length ? "" : pickedShown() ? why : UI.HIDDEN_ALL); +// 细看's next batch leaves 已保留 out to save AI cost; a selection runs whatever was decided. +const skipKept = (open, tab) => (picking(tab) ? open : open.filter((it) => !isKept(it))); // What 按新标准重新粗看 / 细看 can send: the stale ones its tab lists now (search and filters applied), from any tab // (the 判断标准 dialog offers 重新粗看 too); redoState takes one run's batch of them. const redoCoarseOpen = () => staleCoarse().filter((it) => inTab(it, "coarse") && passFilter(it)); -const redoFineOpen = () => staleFine().filter((it) => inTab(it, "fine") && passFilter(it)); +const redoFineListed = () => staleFine().filter((it) => inTab(it, "fine") && passFilter(it)); +const redoFineOpen = () => skipKept(redoFineListed(), "fine"); // 按新标准重新粗看 / 细看's batch, label and why it is disabled: row 3's button and the 「判断标准改了」 dialog's. -const redoState = (verb, open, cap, tab) => { +// onlyKept: the stale ones listed are all 已保留, which a next batch leaves out. +const redoState = (verb, open, cap, tab, onlyKept = false) => { const batch = pickBatch(open, cap, tab); - const why = aiBusyReason() || pickReason(`选中的都不用按新标准重新${verb}`, batch, tab) || (batch.length ? "" : `当前筛选下没有要重新${verb}的视频`); + const why = aiBusyReason() || pickReason(`选中的都不用按新标准重新${verb}`, batch, tab) || (batch.length ? "" : onlyKept ? KEPT_NOT_NEXT : `当前筛选下没有要重新${verb}的视频`); return { batch, label: batchLabel(`按新标准重新${verb}`, batch, open, tab), why, verb }; }; // One step's redo, as row 3's button, the 「判断标准改了」 dialog and their click all take it. -const redoOf = (step) => (step === "coarse" ? redoState("粗看", redoCoarseOpen(), coarseBatchSize(), "coarse") : redoState("细看", redoFineOpen(), fineBatchSize(), "fine")); +const redoOf = (step) => (step === "coarse" ? redoState("粗看", redoCoarseOpen(), coarseBatchSize(), "coarse") : redoState("细看", redoFineOpen(), fineBatchSize(), "fine", redoFineListed().length > 0)); // The selection belongs to the open tab (switching tabs clears it). A filter may hide part of it: every action and count // takes only what is listed, and the selection bar says how many are hidden. const visibleSelected = () => selectedIn(visibleItems()); function stageCounts() { - const c = { none: 0, coarse: 0, fine: 0, done: 0, read: 0 }; + const c = { none: 0, coarse: 0, fine: 0, read: 0 }; for (const it of S.items) { - if (!passFilter(it)) continue; + if (isUnfaved(it) || !passFilter(it)) continue; c[stageOf(it)]++; c.read++; } @@ -703,8 +718,10 @@ function stageCounts() { // The earliest step that still has videos. const currentStage = (c) => STAGES.find(([k]) => c[k])?.[0] || "none"; -// The 细看 batch: the first fineBatchSize() selected cards of 粗看完成, otherwise its first fineBatchSize(), 拿不准 / low confidence first. +// The 细看 batch: the first fineBatchSize() selected cards of 粗看完成, otherwise its first fineBatchSize() not 已保留, +// 拿不准 / low confidence first. const fineOpen = () => S.items.filter((it) => inTab(it, "coarse") && passFilter(it) && needsAnalysis(it.bvid)).sort((x, y) => unsureFirst(x) - unsureFirst(y)); +const fineBatch = () => pickBatch(skipKept(fineOpen(), "coarse"), fineBatchSize(), "coarse"); function nextBatch() { - return pickBatch(fineOpen(), fineBatchSize(), "coarse").map((it) => it.bvid); + return fineBatch().map((it) => it.bvid); } diff --git a/extension/triage/fav-export.js b/extension/triage/fav-export.js index d20b7b41..9a4d4133 100644 --- a/extension/triage/fav-export.js +++ b/extension/triage/fav-export.js @@ -12,7 +12,7 @@ function writeScopeItems(scope = el.writeScope.value, notes = el.writeFormat?.va return notes ? list.filter((it) => !it.invalid) : list; } -// The 阅览 tab opens it preset; a running notes export keeps its own choice. +// The 全部 tab opens it preset; a running notes export keeps its own choice. function openWrite({ scope, format } = {}) { if (!writeRunning() && scope) [el.writeScope.value, el.writeFormat.value] = [scope, format]; // A notes run, running, finished or stopped by Obsidian, keeps its progress and failures here until the next export. diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index 79c4acf9..9853bc4d 100644 --- a/extension/triage/fav-folders.js +++ b/extension/triage/fav-folders.js @@ -224,7 +224,7 @@ async function openFolder(mediaId) { S.tagFilter.clear(); S.finishedFilter = S.dupFilter = false; S.tagState = ""; - S.aiRecentFilter = false; + S.aiRecentFilter = S.noSubFilter = false; S.kindFilter = ""; S.undo = agentSteps(); S.focused = ""; @@ -244,7 +244,7 @@ async function openFolder(mediaId) { // A failed first read (limit, offline) still leaves the last folder's step and chips behind; a later folder owns the page. if (!ok && !run.alive) return; S.tab = removed ? "read" : currentStage(stageCounts()); - S.classFilter = { coarse: "all", fine: "all", read: "all" }; + S.classFilter = { none: "all", coarse: "all", fine: "all", read: "all" }; S.focused = visibleItems()[0]?.bvid || ""; render(); } @@ -485,6 +485,8 @@ async function openRemoved(run = S.view.current) { if (!run.alive) return false; const rec = got[K.removed] || {}; const decisionsByFolder = Object.fromEntries(keys.map((k) => [k.slice(KEYS.triageDecisionsPrefix.length), got[k]])); + // 最近取消收藏 lists the chosen folders' 取消收藏 records. + for (const f of S.folders) S.folderDecisions[String(f.id)] = decisionsByFolder[String(f.id)] || {}; setItems(Object.values(rec) .sort((x, y) => y.at - x.at) .map(({ item, at, movedTo, hidden, inFolder, from }) => ({ ...item, removedAt: at, movedTo, hidden, inFolder, from: from || inferFrom(decisionsByFolder, item.bvid, folderName) }))); @@ -590,7 +592,7 @@ async function cleanRemoved(list) { if (!list.length) return; if (favBusyReason()) return toast(favBusyReason(), true); const one = list.length === 1 ? `《${shortTitle(list[0])}》` : `这 ${list.length} 个视频`; - const body = `

删除${esc(one)}的 AI 判断、AI 总结、备注、分拣标签、保留标记、待播记录和取消收藏记录,无法撤销。摘读记录里的星标和标签会保留。删除后不能再从「处理完成」重新收藏。
${UI.CLEAN_BACKUP.ask}

`; + const body = `

删除${esc(one)}的 AI 判断、AI 总结、备注、分拣标签、保留标记、待播记录和取消收藏记录,无法撤销。摘读记录里的星标和标签会保留。删除后不能再从「${RECENT_UNFAV}」重新收藏。
${UI.CLEAN_BACKUP.ask}

`; if (!(await askConfirm(`清理${one}?`, body, `清理 ${list.length} 个`, { danger: true }))) return; return lockRecords(async () => { const bvids = list.map((it) => it.bvid); @@ -688,7 +690,7 @@ function rebuildAll() { const items = mergeFolderItems(lists); const decisions = {}; for (const it of items) if (S.kept[it.bvid]) decisions[it.bvid] = S.kept[it.bvid]; - // Keep videos unfavorited from every folder this session, so they sit in 处理完成 and U can undo. + // Keep videos unfavorited from every folder this session, so U can undo (no tab lists them). const have = new Set(items.map((it) => it.bvid)); for (const it of S.items) { if (!have.has(it.bvid) && S.decisions[it.bvid]?.action === "unfav") { diff --git a/extension/triage/fav-page.js b/extension/triage/fav-page.js index 16fcca4d..1f78f9f9 100644 --- a/extension/triage/fav-page.js +++ b/extension/triage/fav-page.js @@ -153,12 +153,14 @@ function followShared(changes) { else delete S.analyses[b]; changed = true; } - // 取消收藏 records: the open folder's, as openFolder builds them; in 所有收藏夹 those of the loaded folders. + // 取消收藏 records: the open folder's, as openFolder builds them; in 所有收藏夹 those of the loaded folders; in + // 已出分拣范围 every chosen folder's (最近取消收藏). for (const [key, c] of Object.entries(changes)) { const id = /^triage_decisions_(.+)$/.exec(key)?.[1]; if (!id || ownEcho(key, c)) continue; const d = c.newValue || {}; if (id === String(S.mediaId)) S.decisions = { ...S.kept, ...d }; + else if (S.mediaId === REMOVED && S.folders.some((f) => String(f.id) === id)) S.folderDecisions[id] = d; else if (S.loadAll && S.folderDecisions[id]) { S.folderDecisions[id] = d; rebuildAll(); @@ -370,7 +372,7 @@ function bindEvents() { if (!f || !passFilter(f)) S.focused = ""; fromTop(); }); - // Row 3: one pick per group, a second click clears it; 全部 clears the whole row (not row 4's tags). + // Row 3: one pick per group, a second click clears it; 全部 clears the whole row (not row 4). el.classFilter.addEventListener("click", (e) => { const b = e.target.closest("button"); if (!b) return; @@ -379,14 +381,20 @@ function bindEvents() { else if (b.matches("[data-states-all]")) pickState("all"); else if (b.dataset.classFilter) pickState("class", b.dataset.classFilter); else if (b.matches("[data-finishedfilter]")) pickState("seen"); + else if (b.matches("[data-nosubfilter]")) pickState("nosub"); else if (b.matches("[data-dupfilter]")) pickState("dup"); - else if (b.dataset.tagstate) pickState("tagged", b.dataset.tagstate); else if (b.dataset.kindfilter) pickState("kind", b.dataset.kindfilter); else if (b.matches("[data-airecent]")) pickState("recent"); else return; fromTop(); }); + // Row 4: its 全部 clears row 4 only (not row 3); 未打标签 / 已打标签 one pick; the tags AND together. el.tagFilter.addEventListener("click", (e) => { + const state = e.target.closest("[data-tagstate], [data-tagrow-all]"); + if (state) { + pickState(state.dataset.tagstate ? "tagged" : "tagrow", state.dataset.tagstate); + return fromTop(); + } const btn = e.target.closest("[data-tagfilter]"); if (!btn) return; const ids = btn.dataset.tagfilter.split(","); @@ -418,7 +426,7 @@ function bindEvents() { const batch = nextBatch(); const items = batch.map((b) => S.itemMap.get(b)); startJudging(groupLabel(items), "细看", items, () => startGroup(batch)); - } else if (act === "batch-unfav") fav.unfavMany(batchList(btn.dataset.verdict || null)); + } else if (act === "batch-unfav") fav.unfavMany(batchList(btn.dataset.verdict || null, "unfav")); else if (act === "batch-keep") batchKeep(batchList(btn.dataset.verdict || null)); else if (act === "criteria") openCriteria(); else if (act === "write") stopWrite(); @@ -455,9 +463,9 @@ function bindEvents() { if (e.target.closest("[data-pick-folders]")) return openSettings(false, true); if (e.target.closest("[data-refresh]")) return el.refreshBtn.click(); const refav = e.target.closest("[data-refav]"); - if (refav) return fav.refav(refav.dataset.refav); + if (refav) return fav.refav(refav.dataset.refav, refav.dataset.folder); const refavAll = e.target.closest("[data-refav-batch]"); - if (refavAll) return fav.refavBatch(Number(refavAll.dataset.refavBatch)); + if (refavAll) return fav.refavBatch(Number(refavAll.dataset.refavBatch), refavAll.dataset.folder); const picked = e.target.closest(".card[data-bvid]"); if (picked && UI.cardPickClick(e)) { e.preventDefault(); @@ -789,6 +797,7 @@ function cardAction(act, bvid) { else if (act === "tag") openPicker(bvid, `.card[data-bvid="${CSS.escape(bvid)}"] [data-act="tag"]`); else if (act === "basket") toggleBasket(bvid); else if (act === "retry") retry(bvid); + else if (act === "ai-step") runCardStep(bvid); else if (act === "note") { S.noteOpen.add(bvid); renderList(); diff --git a/extension/triage/fav-render.js b/extension/triage/fav-render.js index 37b86e09..54cb9f9a 100644 --- a/extension/triage/fav-render.js +++ b/extension/triage/fav-render.js @@ -69,7 +69,7 @@ function renderFolderHead() { const explain = S.mediaId === REMOVED && `离开了你勾选的所有收藏夹,AI 判断、AI 总结、备注和标签都还留着,${UI.CLEAN_BACKUP.meta}`; const meta = UI.headMeta([inFolderView() && UI.syncedText(S.readAt[S.mediaId]), invalid && `${invalid} 个已失效`, explain], S.syncError); const thumb = inFolderView() && S.mediaId !== TOVIEW ? folderThumb(S.mediaId, el.folderSelect.querySelector?.(`option[value="${S.mediaId}"]`)?.dataset.cover) : ""; - el.folderHead.innerHTML = `${thumb}

${UI.titleHtml(folderName(S.mediaId), S.items.length)}

${meta}
`; + el.folderHead.innerHTML = `${thumb}

${UI.titleHtml(folderName(S.mediaId), S.items.filter((it) => !isUnfaved(it)).length)}

${meta}
`; } // What is running, in one place on every tab: runs' pill (PILL's order). done/total draws a bar, act puts a button on @@ -100,27 +100,28 @@ function tick() { function renderTabs() { const c = stageCounts(); - // 处理完成 says what 保留 and 取消收藏 did (one only marks the video here, the other changed B站): in its tooltip, with - // the dot while it is open. - const tip = (key) => (key === "done" ? DONE_TIP : ""); const tab = (key, label, cls, n) => - ``; + ``; const steps = STAGES.map(([key, label]) => tab(key, label, c[key] ? "step" : "step zero", c[key])); // Say what the box searches: the open folder (or 所有收藏夹 / 已出分拣范围) and the tab you are on. const tabName = Object.fromEntries([...STAGES, ["read", READ_TAB]])[S.tab]; UI.setSearchScope(el.searchInput, folderName(S.mediaId), tabName); // 已出分拣范围 has no steps; its tabs are why the videos left (kindOf), 全部 first for the ones not checked yet. const kindTabs = () => UI.kindTabs("data-kindtab", [["", "全部"], ...KINDS].map(([kind, label]) => [kind, label, kind ? S.items.filter((it) => kindOf(it) === kind).length : S.items.length]), S.kindFilter); + // 全部 leads, right above row 3's and row 4's 全部 (2026-11-10 用户定). el.tabs.innerHTML = S.mediaId === REMOVED ? kindTabs() : - steps.join(``) + `` + tab("read", READ_TAB, "read-tab", c.read); + tab("read", READ_TAB, "read-tab", c.read) + `` + steps.join(``); - // Row 4: only the tags the tab on screen has (or a filter that is on, so it can be turned off), each with how many - // videos listed here carry it (row 3 and search applied, row 4's own picks left out). - const here = S.mediaId === REMOVED || S.tab === "read" ? S.items : S.items.filter((it) => stageOf(it) === S.tab); + // Row 4: its own 全部 and 未打标签 / 已打标签, then only the tags the tab on screen has (or a filter that is on, so it + // can be turned off), each with how many videos listed here carry it (row 3 and search applied, the group's own pick + // left out). + const here = S.items.filter((it) => inStage(it, S.tab)); const hereTags = new Set(here.flatMap((it) => tagIdsOf(it.bvid))); const chips = tagChips().filter((c) => c.ids.some((id) => hereTags.has(id) || S.tagFilter.has(id))); - const counted = here.filter((it) => inTab(it, S.tab) && passFilter(it, "tags")).map((it) => tagIdsOf(it.bvid)); - el.tagFilter.innerHTML = UI.labeledRow("标签", chips + const cnt = (skip) => here.filter((it) => inTab(it, S.tab) && passFilter(it, skip)); + const counted = cnt("tags").map((it) => tagIdsOf(it.bvid)); + const head = UI.tagRowHead({ allAttrs: "data-tagrow-all", allN: cnt("tagrow").length, on: tagRowOn(), attrs: (k) => `data-tagstate="${k}"`, n: (k) => cnt("tagged").filter((it) => (tagIdsOf(it.bvid).length > 0) === (k === "tagged")).length, cur: S.tagState }); + el.tagFilter.innerHTML = UI.labeledRow("标签", head + chips .map((c) => UI.tagFilterChip(`data-tagfilter="${esc(c.ids.join(","))}"`, { name: c.name, color: c.color, on: c.ids.some((id) => S.tagFilter.has(id)), n: counted.filter((ids) => c.ids.some((id) => ids.includes(id))).length, what: "视频" })) .join("")); renderTagButtons(); @@ -170,28 +171,32 @@ function criteriaBtn() { } // Whether anything in row 3 is on (全部 is pressed when not). -const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.tagState || S.finishedFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter)); +const statesOn = (t = S.tab) => (S.classFilter[t] && S.classFilter[t] !== "all") || Boolean(S.finishedFilter || S.noSubFilter || S.dupFilter || S.aiRecentFilter || (S.mediaId !== REMOVED && S.kindFilter)); +// Whether row 4 narrows the list (its 全部 is pressed when not). +const tagRowOn = () => Boolean(S.tagState || S.tagFilter.size); // Row 3: what the system knows about each video, in groups split by a thin rule. One pick per group (a second click // clears it), groups AND together, and each group counts with its own pick left out. 全部 clears row 3 only. The fixed // groups come first (a 0 stays, dimmed); the chips that exist only sometimes (看完了, 已失效, AI 刚打的) go last and // leave at 0, so their coming and going moves nothing else. function stateRowHtml(t = S.tab) { const removed = S.mediaId === REMOVED; - const base = removed || t === "read" ? S.items : S.items.filter((it) => stageOf(it) === t); + const base = S.items.filter((it) => inStage(it, t)); const cf = S.classFilter[t]; - const inClass = (it) => !cf || cf === "all" || classOf(it) === cf; - const cnt = (skip, fn) => base.filter((it) => (skip === "class" || skip === "states" || inClass(it)) && passFilter(it, skip) && fn(it)).length; + const cnt = (skip, fn) => base.filter((it) => (skip === "class" || skip === "states" || inClass(it, cf)) && passFilter(it, skip) && fn(it)).length; const group = UI.stateGroup; const groups = [group("全部", UI.filterBtn("data-states-all", "全部", cnt("states", () => true), !statesOn(t)))]; // 所有收藏夹 only, right after 全部 (asked for there); like 已失效 it leaves at 0 unless on. const dupN = S.mediaId === ALL ? cnt("dup", isDup) : 0; if (dupN || S.dupFilter) groups.push(group("重复收藏", UI.filterBtn('data-dupfilter title="只看同时在两个以上收藏夹里的视频"', "重复收藏", dupN, S.dupFilter))); - // 未分析 has no AI verdict to filter by. 阅览 also holds decided videos: they leave the AI classes for 已保留. - if (t !== "none") { - const classes = [...Object.entries(VERDICTS), ...(t === "read" ? [["kept", ACTION_LABEL.keep]] : [])]; - groups.push(group("按 AI 判断筛选", classes.map(([k, label]) => UI.filterBtn(`data-class-filter="${k}"`, label, cnt("class", (it) => classOf(it) === k), cf === k)).join(""))); - } - groups.push(UI.tagStateGroup((k) => `data-tagstate="${k}"`, (k) => cnt("tagged", (it) => (tagIdsOf(it.bvid).length > 0) === (k === "tagged")), S.tagState)); + // One pick in row 3's first groups. A kept video has no AI verdict here, so the verdicts split 待决定 and sit inside its + // group, boxed (.sub). 未分析 has no verdicts. + const chip = (k, label, tip = "") => UI.filterBtn(`data-class-filter="${k}"${tip ? ` title="${esc(tip)}"` : ""}`, label, cnt("class", (it) => inClass(it, k)), cf === k); + const verdicts = t === "none" ? "" : `${Object.entries(VERDICTS).map(([k, label]) => chip(k, label)).join("")}`; + groups.push(group(ACTION_LABEL.keep, chip("kept", ACTION_LABEL.keep, KEEP_TIP))); + groups.push(group(UNDECIDED, chip("undecided", UNDECIDED, "还没保留的视频,后面按 AI 判断再分") + verdicts)); + // Right after the verdicts it qualifies: judged from the intro and comments only. + const noSubN = cnt("nosub", noSubtitle); + if (noSubN || S.noSubFilter) groups.push(group(NO_SUB, UI.filterBtn(`data-nosubfilter title="只看字幕细看时没有字幕、按简介和评论判断的视频"`, NO_SUB, noSubN, S.noSubFilter))); const seenN = cnt("seen", isFinished); if ((S.seenCfg.mark && seenN) || S.finishedFilter) groups.push(group("看完了", UI.filterBtn('data-finishedfilter title="只看 B站历史记录里看完了的视频"', "看完了", seenN, S.finishedFilter))); // 已出分拣范围 has 已失效 as a tab instead. @@ -208,12 +213,15 @@ function pickState(group, value = "") { const t = S.tab; if (group === "all") { S.classFilter[t] = "all"; - S.finishedFilter = S.dupFilter = S.aiRecentFilter = false; - S.tagState = ""; + S.finishedFilter = S.dupFilter = S.aiRecentFilter = S.noSubFilter = false; if (S.mediaId !== REMOVED) S.kindFilter = ""; + } else if (group === "tagrow") { + S.tagState = ""; + S.tagFilter.clear(); } else if (group === "class") S.classFilter[t] = S.classFilter[t] === value ? "all" : value; else if (group === "kind") S.kindFilter = S.kindFilter === value ? "" : value; else if (group === "seen") S.finishedFilter = !S.finishedFilter; + else if (group === "nosub") S.noSubFilter = !S.noSubFilter; else if (group === "dup") S.dupFilter = !S.dupFilter; else if (group === "tagged") S.tagState = S.tagState === value ? "" : value; else if (group === "recent") S.aiRecentFilter = !S.aiRecentFilter; @@ -225,22 +233,25 @@ function renderListHeader(list) { // running a step or a batch keeps the rest selected, so you see what you just ran (DESIGN 筛选和计数). for (const b of [...S.selected]) { const it = S.itemMap.get(b); - if (!it || (t !== "read" && stageOf(it) !== t)) S.selected.delete(b); + if (!it || !inStage(it, t)) S.selected.delete(b); } const all = S.mediaId === ALL; // Search, row 3 or row 4 on: how many the tab lists. - el.searchCount.textContent = UI.resultCount(S.query.trim() || statesOn(t) || S.tagFilter.size, list.length); + el.searchCount.textContent = UI.resultCount(S.query.trim() || statesOn(t) || tagRowOn(), list.length); const { run: favRun, busy: favBusy } = fav.state(); // none: why the selection's buttons are off (UI.selbar: filters hide the whole selection); they count all of it then. const batchBtn = (route, verdict = "", none = "") => { const run = favRun?.job === "unfav" ? favRun : null; if (route === "unfav" && run && !runWhere(run)) return headBtn("batch-unfav", runLine(run), "danger", "正在取消收藏", "", "", true); // A selection none of which this can take still counts the selection (as 粗看's), disabled with the reason. - const can = none ? S.selected.size : batchList(verdict || null).length; + const can = none ? S.selected.size : batchList(verdict || null, route).length; const n = can || (verdict ? 0 : selectedIn(visibleItems()).length); const verb = route === "unfav" ? "取消收藏" : "保留"; // One batch 取消收藏 at a time: while another folder's runs, this one waits. - const reason = none || (!can ? (verdict ? `这里没有 AI 判断为${VERDICTS[verdict]}、还没处理的视频` : "选中的都已经处理过了") + // Nothing left because a running B站 write still has them: say so, not that they are decided. + const waiting = can ? "" : (S.selected.size ? selectedIn(visibleItems()) : visibleItems()).map((it) => W.pending(it.bvid)).find(Boolean); + const empty = waiting ? UI.busyText(waiting) : verdict ? `这里没有 AI 判断为${VERDICTS[verdict]}、待决定的视频` : route === "unfav" ? "选中的都已取消收藏" : "选中的都已保留"; + const reason = none || (!can ? empty : route === "unfav" && run ? UI.busyText(`「${folderName(run.mediaId)}」的${UNFAV_MANY}`) : route === "unfav" ? UI.busyText(favBusy) : ""); return headBtn(`batch-${route}`, verdict ? `${verb}(AI:${VERDICTS[verdict]})${n} 个` : `${verb}选中的 ${n} 个`, route === "unfav" ? "danger" : "", reason, verdict, route === "keep" ? KEEP_TIP : ""); @@ -257,10 +268,10 @@ function renderListHeader(list) { const groupBtn = (cls) => { const g = ownGroup() && runs.get("group"); if (g) return headBtn("group", `${PILL_BTN.group[g.stopping ? 2 : 1]} ${g.done}/${g.total}`, "primary", g.stopping ? PILL_BTN.group[3] : ""); - const batch = pickBatch(fineOpen(), fineBatchSize(), "coarse"); + const batch = fineBatch(); const label = groupLabel(batch); // inTab applies the AI-class chip, which is a filter too. - const none = S.items.some((it) => stageOf(it) === "coarse" && needsAnalysis(it.bvid)) ? "当前筛选下没有要细看的视频" : "没有要细看的视频"; + const none = fineOpen().length ? KEPT_NOT_NEXT : S.items.some((it) => inStage(it, "coarse") && needsAnalysis(it.bvid)) ? "当前筛选下没有要细看的视频" : "没有要细看的视频"; return headBtn("group", label, cls, aiBusyReason() || pickReason("选中的都不能字幕细看", batch, "coarse") || (batch.length ? "" : none), "", "", false, true); }; // Pausing waits for the current batch or video; until then the button says so and takes no second click. @@ -276,7 +287,7 @@ function renderListHeader(list) { // A selection, listed or not, decides alone: no AI-verdict batches beside it (all hidden: its buttons say why). const picked = S.selected.size; const f = S.classFilter[t]; - // html is what this step does (阅览 mixes 粗看 guesses with 细看 conclusions: no class-wide batch, only the selection); + // html is what this step does (全部 mixes 粗看 guesses with 细看 conclusions: no class-wide batch, only the selection); // the selection's buttons go to its floating bar (selActs). let html = ""; // 所有收藏夹 has no one 判断标准 to show: its runs ask for each folder's first (askAllCriteria). @@ -287,7 +298,7 @@ function renderListHeader(list) { if (ownStage1()) html += stage1Pause(); else { const pending = stage1Pending(); - const none = S.items.some((it) => stageOf(it) === "none" && !S.coarseFailed.has(it.bvid)) ? "当前筛选下没有要粗看的视频" : "没有要粗看的视频"; + const none = S.items.some((it) => inStage(it, "none") && !S.coarseFailed.has(it.bvid)) ? "当前筛选下没有要粗看的视频" : "没有要粗看的视频"; html += headBtn("stage1", stage1Label(pending), "primary", aiBusyReason() || pickReason("选中的都不能标题粗看", pending, "none") || (pending.length ? "" : none), "", "", false, true); } } else if (t === "coarse") { @@ -302,8 +313,8 @@ function renderListHeader(list) { const staleN = staleFine().length; if (staleN) html += critBtn + redoBtn("redo-fine", "fine", staleN); if (!picked) { - if (f === "all" || f === "drop") html += batchBtn("unfav", "drop"); - if (f === "all" || f === "keep") html += batchBtn("keep", "keep"); + if (f === "all" || f === "undecided" || f === "drop") html += batchBtn("unfav", "drop"); + if (f === "all" || f === "undecided" || f === "keep") html += batchBtn("keep", "keep"); } } else if (t === "read" && S.mediaId === REMOVED) { const c = S.removedCheck; @@ -329,9 +340,11 @@ function renderListHeader(list) { renderTagButtons(); } +// 已出分拣范围's 最近取消收藏: every chosen folder's 取消收藏 records, newest first, as [bvid, record, folder]. A video +// still in another chosen folder is listed too: 重新收藏 puts it back into the folder it left. function recentUnfavs() { - return Object.entries(S.decisions) - .filter(([b, d]) => d.action === "unfav" && d.aid && !S.itemMap.has(b)) + return Object.entries(S.folderDecisions) + .flatMap(([f, map]) => Object.entries(map || {}).filter(([, d]) => d.action === "unfav" && d.aid).map(([b, d]) => [b, d, f])) .sort((x, y) => y[1].at - x[1].at) .slice(0, MdgLimits.TRIAGE_RECENT_UNFAV); } @@ -341,21 +354,22 @@ function recentUnfavHtml() { if (!list.length) return ""; const { busy, pending } = fav.state(); const wait = UI.busyText(busy); + const from = (f) => `${esc(`「${folderName(f)}」`)}`; const rows = list - .map(([b, d]) => { + .map(([b, d, f]) => { const title = esc(d.title || b); - return `
  • ${title}${esc(fmtTime(d.at))} - ${pending(b) ? `` : ``}
  • `; + return `
  • ${title}${from(f)}${esc(fmtTime(d.at))} + ${pending(b) ? `` : ``}
  • `; }) .join(""); - // One line per batch 取消收藏 of two or more still listed, newest first. - const batches = [...new Set(list.map(([, d]) => d.batch).filter(Boolean))] - .map((batch) => ({ batch, n: unfavBatchItems(batch).length })) + // One line per batch 取消收藏 of two or more still listed, newest first (a batch is one folder's, 所有收藏夹's one per folder). + const batches = [...new Map(list.filter(([, d]) => d.batch).map(([, d, f]) => [`${f}:${d.batch}`, { batch: d.batch, f }])).values()] + .map((x) => ({ ...x, n: unfavBatchItems(x.batch, x.f).length })) .filter((x) => x.n > 1); const batchRows = batches - .map(({ batch, n }) => { - const going = unfavBatchItems(batch).some((it) => pending(it.bvid)); - return `

    ${esc(fmtTime(batch))} ${UNFAV_MANY} ${n} 个${going ? `` : ``}

    `; + .map(({ batch, f, n }) => { + const going = unfavBatchItems(batch, f).some((it) => pending(it.bvid)); + return `

    ${esc(fmtTime(batch))} ${UNFAV_MANY} ${n} 个${from(f)}${going ? `` : ``}

    `; }) .join(""); return `

    ${RECENT_UNFAV} ${list.length}

    ${batchRows}
    `; @@ -369,10 +383,10 @@ window.addEventListener("beforeunload", (e) => { e.returnValue = true; }); -// The records one batch 取消收藏 left in the open folder, all of them (the list shows only the latest). -const unfavBatchItems = (batch) => - Object.entries(S.decisions) - .filter(([b, d]) => d.action === "unfav" && d.batch === batch && d.aid && !S.itemMap.has(b)) +// The records one batch 取消收藏 left in folder f, all of them (the list shows only the latest). +const unfavBatchItems = (batch, f) => + Object.entries(S.folderDecisions[f] || {}) + .filter(([, d]) => d.action === "unfav" && d.batch === batch && d.aid) .map(([bvid, d]) => favRef({ bvid, ...d })); let listDeferred = false; @@ -384,7 +398,7 @@ function renderList() { listDeferred = false; const list = visibleItems(); renderListHeader(list); - const recent = S.tab === "done" ? recentUnfavHtml() : ""; + const recent = S.mediaId === REMOVED && (!S.kindFilter || S.kindFilter === "unfav") ? recentUnfavHtml() : ""; if (!S.items.length) { const empty = S.mediaId === REMOVED ? `

    ${noVideosText()}

    ` : S.loadAll?.queue.length ? loadingHtml() : S.readError ? UI.emptyState("无法读取这个收藏夹", S.readError, REFRESH_EMPTY) @@ -393,10 +407,10 @@ function renderList() { return; } if (!list.length) { - const empty = { none: "没有未分析的视频", coarse: "没有粗看完成的视频", fine: "没有细看完成的视频", done: "还没有处理过的视频" }; + const empty = { none: "没有未分析的视频", coarse: "没有粗看完成的视频", fine: "没有细看完成的视频" }; // 已出分拣范围's kind is its tab (statesOn leaves it out), not a filter. const kind = S.mediaId === REMOVED && KINDS.find(([k]) => k === S.kindFilter)?.[1]; - const text = UI.noMatch(S.query, statesOn() || S.tagFilter.size, "视频") || (kind ? `没有${kind}的视频` : empty[S.tab]) || "这里没有视频"; + const text = UI.noMatch(S.query, statesOn() || tagRowOn(), "视频") || (kind ? `没有${kind}的视频` : empty[S.tab]) || "这里没有视频"; el.list.innerHTML = `

    ${text}

    ${recent}`; return; } @@ -414,7 +428,7 @@ function renderList() { marked = new Set(bvids); word = ownGroup() ? "本批" : bvids.some((b) => S.selected.has(b)) ? "已选中" : NEXT_BATCH; } - const expanded = S.tab === "fine" || S.tab === "read" || S.tab === "done"; + const expanded = S.tab === "fine" || S.tab === "read"; const failedList = list.filter((it) => failedIn(S.tab, it)); const failed = failedList.length; const failedHead = failedHeadHtml(failedList); @@ -444,16 +458,25 @@ const verdictBadge = (b, v, low = v.low) => ? `${ANALYZING}` : `${VERDICTS[v.verdict] ? `AI` : ""}${esc(verdictLabel(v.verdict))}${low ? " · 低置信" : ""}`; const KEEP_TIP = `保留标记${MdgErrors.TEXT.localOnly}`; -const DONE_TIP = `已保留:${KEEP_TIP}。\n已取消收藏:已从 B站收藏夹移走,最近的操作可按 U 撤销。`; +// 字幕细看下一批 has none to send but 已保留 ones. +const KEPT_NOT_NEXT = `已保留的视频不进${NEXT_BATCH},选中后可以细看`; // A verdict an AI tool wrote over MCP (agent-writes.js), on the card's meta line in place of the basis. const AGENT_VERDICT = "Agent 判断"; -// What a 细看 judged from, on the card's meta line, in the video page's words: no subtitle (and why), or a subtitle -// whose time range may not match the video. Nothing for a matching subtitle. +// What a 细看 judged from, on the card's meta line, in the video page's words: a subtitle whose time range may not match +// the video. Nothing for a matching subtitle; no subtitle is the verdict row's 无字幕 badge (noSubBadge). function basisText(a) { if (a.source === "agent") return AGENT_VERDICT; - if (a.source !== "meta") return a.subtitleWarning ? esc(a.subtitleWarning.replace(/。$/, "")) : ""; - return esc(`${MdgErrors.subtitleMissing({ subtitleNeedLogin: a.needLogin, subtitleFailure: a.subtitleFailure })},按简介和评论判断`); + return a.source !== "meta" && a.subtitleWarning ? esc(a.subtitleWarning.replace(/。$/, "")) : ""; +} +// The 无字幕 chip, and the badge a 细看 card without a subtitle leads its verdict row with (why on hover). +const NO_SUB = "无字幕"; +function noSubBadge(it) { + if (!noSubtitle(it)) return ""; + const a = S.analyses[it.bvid]; + const label = a.needLogin ? "未登录无字幕" : a.subtitleFailure ? "字幕抓取失败" : NO_SUB; + const why = `${MdgErrors.subtitleMissing({ subtitleNeedLogin: a.needLogin, subtitleFailure: a.subtitleFailure })},按简介和评论判断`; + return `${label}`; } function cardHtml(it, expanded, mark) { const b = it.bvid; @@ -490,6 +513,8 @@ function cardHtml(it, expanded, mark) { const failed = v.failed ? `${ANALYSIS_FAILED}:${esc(v.failed)}` : ""; + const step = cardStep(it); + const stepBtn = step ? `` : ""; const chips = tagIdsOf(b) .map((id) => tagById(id)) @@ -507,7 +532,7 @@ function cardHtml(it, expanded, mark) { ${left ? `
    ${left}
    ` : ""}
    ${meta.join(" · ")}
    ${body.join("")} -
    ${verdict}${esc(v.reason)}${failed}
    +
    ${noSubBadge(it)}${verdict}${esc(v.reason)}${failed}${stepBtn}
    ${chips ? `
    ${chips}
    ` : ""} ${noteHtml}
    diff --git a/extension/triage/fav-tags.js b/extension/triage/fav-tags.js index 3e8dec3e..a4b3adb1 100644 --- a/extension/triage/fav-tags.js +++ b/extension/triage/fav-tags.js @@ -164,7 +164,7 @@ async function saveCriteria() { const shown = open.length < stale ? `(当前筛选下列出 ${open.length} 个)` : ""; const ok = await askConfirm( "判断标准改了", - `

    「粗看完成」里有 ${stale} 个视频是按旧标准粗看的${shown},要按新标准重新粗看吗?

    已细看和已处理的视频不动;细看完成页可以另外按新标准重新细看。

    `, + `

    「粗看完成」里有 ${stale} 个视频是按旧标准粗看的${shown},要按新标准重新粗看吗?

    已细看的视频不动;细看完成页可以另外按新标准重新细看。

    `, label, { reason: why } ); diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index 7d1b9785..9ba2f27c 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -196,26 +196,26 @@ function cutText(error, stopped, { n = null, left = null, there = "" } = {}) { if (stopped) return `${stopWords("撤销", { done: n ?? 0, total: (n ?? 0) + (left ?? 1) })},${there ? `未完成的在${there}里` : "可再按 U 撤销"}`; return ""; } -// U on a batch 取消收藏 in one folder: back into it. Elsewhere the ones left stay under its 最近取消收藏. +// U on a batch 取消收藏 in one folder: back into it. Elsewhere the ones left stay in 已出分拣范围's 最近取消收藏. function unfavManyStep(mediaId, items) { return favStep(items.length, refavAsk(items.length), async () => { const { n, rest, error, stopped } = await refavInto(mediaId, items); const here = S.mediaId === mediaId; const where = here ? "" : `「${folderName(mediaId)}」`; - const cut = cutText(error, stopped, { n, left: rest.length, there: here ? "" : `它的${RECENT_UNFAV}` }); + const cut = cutText(error, stopped, { n, left: rest.length, there: here ? "" : RECENT_UNFAV_AT }); if (cut && here) pushUndo(unfavManyStep(mediaId, rest)); toast(cut ? `${where}${cut}` : `${where}已重新收藏 ${n} 个`, Boolean(error)); return redraw(); }); } // U on 所有收藏夹's batch 取消收藏: each video back in every folder it left (gone: folder → [{ bvid, aid, ogv }]). Elsewhere -// the ones left stay under each folder's 最近取消收藏. +// the ones left stay in 已出分拣范围's 最近取消收藏. function goneStep(items, gone) { return favStep(items.length, refavAsk(items.length), async () => { const { rest, error, stopped } = await refavGone(gone); const here = S.mediaId === ALL; const left = items.filter((x) => Object.values(rest).some((list) => list.some((y) => y.bvid === x.bvid))); - const cut = cutText(error, stopped, { n: items.length - left.length, left: left.length, there: here ? "" : `各收藏夹的${RECENT_UNFAV}` }); + const cut = cutText(error, stopped, { n: items.length - left.length, left: left.length, there: here ? "" : RECENT_UNFAV_AT }); if (cut && here) pushUndo(goneStep(left, rest)); toast(cut ? `${here ? "" : `「${folderName(ALL)}」`}${cut}` : `已重新收藏 ${items.length} 个`, Boolean(error)); return redraw(); @@ -352,15 +352,15 @@ const fav = { pushUndo(goneStep(videos.map(favRef), gone)); tell(`已从 ${Object.keys(gone).length} 个收藏夹取消收藏 ${videos.length} 个视频${UNDO}`, `${UNDO}已完成的`); } else if (done && all) { - tell(`已从 ${Object.keys(gone).length} 个收藏夹取消收藏,可在各收藏夹的${RECENT_UNFAV}里${REFAV}`, `,已完成的可在各收藏夹的${RECENT_UNFAV}里${REFAV}`); + tell(`已从 ${Object.keys(gone).length} 个收藏夹取消收藏,可在${RECENT_UNFAV_AT}里${REFAV}`, `,已完成的可在${RECENT_UNFAV_AT}里${REFAV}`); } else if (done && S.mediaId === mediaId) { // By folder, not by view: the records are the folder's, so back in it after a switch the batch is still undoable. const went = new Set(r.keys); pushUndo(unfavManyStep(mediaId, list.filter((it) => went.has(it.bvid)).map(favRef))); tell(`${KIND_NAME.unfav} ${done} 个${UNDO}`, `${UNDO}已完成的`); } else if (done) { - // Elsewhere they are saved under their folder and listed in its 最近取消收藏, where they can be re-favorited. - tell(`「${folderName(mediaId)}」已取消收藏 ${done} 个,可在它的${RECENT_UNFAV}里${REFAV}`, `,已完成的可在它的${RECENT_UNFAV}里${REFAV}`); + // Elsewhere they are saved under their folder and listed in 已出分拣范围's 最近取消收藏, where they can be re-favorited. + tell(`「${folderName(mediaId)}」已取消收藏 ${done} 个,可在${RECENT_UNFAV_AT}里${REFAV}`, `,已完成的可在${RECENT_UNFAV_AT}里${REFAV}`); } render(); }, @@ -443,29 +443,29 @@ const fav = { render(); }, - // 最近取消收藏's 重新收藏 of one video. - async refav(bvid) { - const d = S.decisions[bvid]; + // 最近取消收藏's 重新收藏 of one video, into the folder it left (mediaId). Back in a chosen folder, it leaves 已出分拣范围. + async refav(bvid, mediaId) { + const d = S.folderDecisions[mediaId]?.[bvid]; if (!d?.aid || W.busy()) return; - const mediaId = S.mediaId; - const { error } = await refavInto(String(mediaId), [favRef({ bvid, ...d })]); + const { error } = await refavInto(mediaId, [favRef({ bvid, ...d })]); if (error) return toast(`重新收藏失败:${error}`, true); - if (mediaId !== S.mediaId) return; - toast(`已重新收藏《${d.title || bvid}》`); - quickSync({ force: true }); + toast(`已重新收藏《${d.title || bvid}》到「${folderName(mediaId)}」`); + if (S.mediaId === REMOVED) await dropRemoved([bvid]); + render(); }, - // 最近取消收藏's 这批全部重新收藏: every record one batch 取消收藏 left here. - async refavBatch(batch) { - const items = unfavBatchItems(batch); + // 最近取消收藏's 这批全部重新收藏: every record one batch 取消收藏 left in mediaId. + async refavBatch(batch, mediaId) { + const items = unfavBatchItems(batch, mediaId); if (!items.length || W.busy()) return; - const mediaId = String(S.mediaId); const { n, rest, error, stopped } = await refavInto(mediaId, items); - const where = S.mediaId === mediaId ? "" : `「${folderName(mediaId)}」`; + const where = `「${folderName(mediaId)}」`; if (error) toast(`${where}${cutShort(REFAV, n, error, `,剩余 ${rest.length} 个可再点${MdgErrors.LABEL.retry}`)}`, true); else if (stopped) toast(`${where}${stopWords(REFAV, { done: n, total: items.length })}`); else toast(`${where}已重新收藏这批 ${n} 个`); - if (!where) quickSync({ force: true }); + const back = new Set(rest.map((it) => it.bvid)); + if (S.mediaId === REMOVED) await dropRemoved(items.map((it) => it.bvid).filter((b) => !back.has(b))); + render(); }, // What the page draws and checks: busy (the running batch's name, "" when none), run (it, once planned: favRun), @@ -572,9 +572,11 @@ function undo() { // Batch buttons act on the selected cards listed (none when filters hide them all), otherwise on every card with this // verdict (none without one). -// Not processed, nor waiting in a running B站 write (a batch 取消收藏 or 移动 will decide it). -function batchList(verdict) { - const list = visibleItems().filter((it) => !isProcessed(it.bvid) && !W.pending(it.bvid)); +// Undecided videos only, except that a selected 保留 one can still be 取消收藏'd: a verdict-wide 取消收藏 never takes what +// you kept. Neither takes one waiting in a running B站 write. +function batchList(verdict, route = "keep") { + const open = (b) => (route === "unfav" && S.selected.size ? S.decisions[b]?.action !== "unfav" : !isProcessed(b)); + const list = visibleItems().filter((it) => open(it.bvid) && !W.pending(it.bvid)); return S.selected.size ? selectedIn(list) : verdict == null ? [] : list.filter((it) => verdictOf(it).verdict === verdict); } diff --git a/extension/triage/follow-core.js b/extension/triage/follow-core.js index 68005aa7..4c64fe5f 100644 --- a/extension/triage/follow-core.js +++ b/extension/triage/follow-core.js @@ -114,10 +114,10 @@ // only for a move. [[id, name]]. const groupTargets = (D, from, how) => [...(how === "move" ? [0] : []), SPECIAL, ...(D.groups || []).map((g) => g.id)].filter((id) => id !== from).map((id) => [id, groupName(id, D)]); - // Row 3's 未打标签 / 已打标签 (f.tagState) and row 4's tags (f.tags, AND) on an UP's tag ids. skip leaves one out - // ("tagged" or "tags"), for that group's own counts. + // Row 4: 未打标签 / 已打标签 (f.tagState) and the tags (f.tags, AND) on an UP's tag ids. skip leaves one out ("tagged" + // or "tags"), for that group's own counts, or both ("tagrow"), for row 4's 全部. const tagPass = (ids, f, skip = "") => - (skip === "tagged" || !f.tagState || (ids.length > 0) === (f.tagState === "tagged")) && (skip === "tags" || [...(f.tags || [])].every((id) => ids.includes(id))); + (skip === "tagged" || skip === "tagrow" || !f.tagState || (ids.length > 0) === (f.tagState === "tagged")) && (skip === "tags" || skip === "tagrow" || [...(f.tags || [])].every((id) => ids.includes(id))); // The UPs of a left-column item, before 更新状态 / search: the follow list without 已出分拣范围; that item is the // unfollowed (newest first), then the followed that are closed or cleared. @@ -137,9 +137,9 @@ }; // What the list shows: f = { side, q, sort, dir, kind (已出分拣范围's tab), source: "" | "bili" | "app" (已取消关注), and row 3: status, - // tagState, recent (Set while 「AI 刚打的」 is on), recentAll (the whole batch, for its count); row 4: tags }. - // Row 3's groups AND together and each counts with its own pick left out: counts[status], tagged / untagged, recent, - // source[bili | app] (已取消关注's row 3 group), "" (row 3 cleared) and tags[id] (row 4's picks left out). Search and the + // recent (Set while 「AI 刚打的」 is on), recentAll (the whole batch, for its count); row 4: tagState, tags }. + // Each group ANDs with the rest and counts with its own pick left out: counts[status], recent, source[bili | app] + // (已取消关注's row 3 group), "" (row 3 cleared), tagged / untagged, tags[id] and tagAll (row 4 cleared). Search and the // sidebar apply to all. have = the tags the item (and 已出分拣范围's tab) carries before search and row 3: row 4 lists them. function visibleUps(D, rows, f) { const words = searchWords(f.q); @@ -152,7 +152,7 @@ const u = rows.get(m); return (skip === "status" || !f.status || u.status === f.status) && (skip === "source" || !f.source || u.gone?.source === f.source) && (skip === "recent" || !f.recent || f.recent.has(m)) && tagPass(u.tagIds, f, skip); }; - const counts = { "": base.filter((m) => tagPass(rows.get(m).tagIds, { tags: f.tags })).length, tagged: 0, untagged: 0, recent: 0, source: { bili: 0, app: 0 }, tags: {}, have: [...new Set(here.flatMap((m) => rows.get(m).tagIds))] }; + const counts = { "": base.filter((m) => tagPass(rows.get(m).tagIds, f)).length, tagAll: base.filter((m) => pass(m, "tagrow")).length, tagged: 0, untagged: 0, recent: 0, source: { bili: 0, app: 0 }, tags: {}, have: [...new Set(here.flatMap((m) => rows.get(m).tagIds))] }; for (const m of base) { const u = rows.get(m); if (pass(m, "status")) counts[u.status] = (counts[u.status] || 0) + 1; @@ -218,12 +218,12 @@ } // A feed video under f = { side, q, tagState, tags } (rows 3 and 4 go by the video's UP); skip as in tagPass. const feedPass = (it, D, f, skip = "") => feedMatch(it, D, f.side) && hasWords(`${it.title}\n${it.name}`, searchWords(f.q)) && tagPass(liveTags(it.mid, D), f, skip); - // Row 3's counts for 视频投稿, as visibleUps's: "" (row 3 cleared), tagged / untagged (by the UP), tags[id], have. + // Row 4's counts for 视频投稿, as visibleUps's: tagAll (row 4 cleared), tagged / untagged (by the UP), tags[id], have. function feedCounts(items, D, f) { - const c = { "": 0, tagged: 0, untagged: 0, tags: {}, have: [...new Set(items.filter((it) => feedMatch(it, D, f.side)).flatMap((it) => liveTags(it.mid, D)))] }; + const c = { tagAll: 0, tagged: 0, untagged: 0, tags: {}, have: [...new Set(items.filter((it) => feedMatch(it, D, f.side)).flatMap((it) => liveTags(it.mid, D)))] }; for (const it of items) { const ids = liveTags(it.mid, D); - if (feedPass(it, D, { ...f, tagState: "" })) c[""]++; + if (feedPass(it, D, f, "tagrow")) c.tagAll++; if (feedPass(it, D, f, "tagged")) c[ids.length ? "tagged" : "untagged"]++; if (feedPass(it, D, f, "tags")) for (const id of ids) c.tags[id] = (c.tags[id] || 0) + 1; } diff --git a/extension/triage/follow.js b/extension/triage/follow.js index 884c309d..ac32d654 100644 --- a/extension/triage/follow.js +++ b/extension/triage/follow.js @@ -72,7 +72,7 @@ const F = { anchor: "", // the UP a Shift+click picks from: the last one picked recentFilter: false, - tagState: "", // row 3: "" | "untagged" | "tagged" (in 视频投稿 the video's UP) + tagState: "", // row 4: "" | "untagged" | "tagged" (in 视频投稿 the video's UP) tags: new Set(), // row 4: tag ids, AND; both tabs loaded: false, order: null, // the UP 主 cards' order (mids) kept while a refresh runs (renderUps), null otherwise @@ -412,23 +412,21 @@ const sideSelect = () => ``; -// Row 3's 未打标签 / 已打标签 (prefix 「UP 」 in 视频投稿, where it goes by the video's UP). -const tagStateGroup = (counts, prefix = "") => - UI.tagStateGroup((k) => `data-fw-tagstate="${k}"`, (k) => counts[k], F.tagState, prefix); -// Row 3 of UP 主: 全部 | 更新状态 | 未打标签 已打标签 | ✦ AI 刚打的 (only while there is one). 待查 always shows. +// Row 3 of UP 主: 全部 | 更新状态 | ✦ AI 刚打的 (only while there is one). 待查 always shows. function stateRowUps(counts) { - const anyOn = F.status || F.tagState || F.recentFilter; + const anyOn = F.status || F.recentFilter; const out = [ UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, counts[""], !anyOn)), - UI.stateGroup("更新状态", STATUS.slice(1).map(([id, label]) => UI.filterBtn(`data-status="${id}"${id === "stale" ? ` title="慢更或断更 · 待查:${cfg.slowDays} 天里没在视频投稿里出现,查完投稿才知道是哪种"` : ""}`, label, counts[id] || 0, F.status === id)).join("")), - tagStateGroup(counts) + UI.stateGroup("更新状态", STATUS.slice(1).map(([id, label]) => UI.filterBtn(`data-status="${id}"${id === "stale" ? ` title="慢更或断更 · 待查:${cfg.slowDays} 天里没在视频投稿里出现,查完投稿才知道是哪种"` : ""}`, label, counts[id] || 0, F.status === id)).join("")) ]; out.push(UI.aiRecentChip({ attrs: 'data-fw="recent"', xAttrs: 'data-fw="recent-done"', n: counts.recent, on: F.recentFilter, who: WHO })); return out.join(""); } -// Row 4: the tags UP 主 (or videos) on this tab have, or that are on, as 收藏夹's; each counts the listed ones -// (0, dimmed, when row 3 or search leave none); several = AND. -const tagRowHtml = ({ tags: counts, have }) => +// Row 4, as 收藏夹's: its own 全部 and 未打标签 / 已打标签 (prefix 「UP 」 in 视频投稿, where it goes by the video's UP), +// then the tags UP 主 (or videos) on this tab have, or that are on; each counts the listed ones (0, dimmed, when row 3 or +// search leave none); several = AND. +const tagRowHtml = ({ tags: counts, have, tagAll, tagged, untagged }) => + UI.tagRowHead({ allAttrs: "data-fw-tagall", allN: tagAll, on: F.tagState || F.tags.size, attrs: (k) => `data-fw-tagstate="${k}"`, n: (k) => ({ tagged, untagged })[k], cur: F.tagState, prefix: F.tab === "feed" ? "UP " : "" }) + D.tags .filter((t) => have.includes(t.id) || F.tags.has(t.id)) .map((t) => UI.tagFilterChip(`data-fw-tag="${esc(t.id)}"`, { name: t.name, color: t.color, on: F.tags.has(t.id), n: counts[t.id] || 0, what: F.tab === "feed" ? `${WHO}的视频` : WHO })) @@ -448,11 +446,10 @@ function renderUps() { shown = list; // Switching between groups keeps the selection (into or out of 已出分拣范围 clears it); the ones not listed count as 被筛选隐藏. for (const m of [...F.sel]) if (!rows.has(m)) F.sel.delete(m); - // 已出分拣范围's row 3: 全部 | 在哪取消关注 (on 全部 and 已取消关注) | 未打标签 已打标签. + // 已出分拣范围's row 3: 全部 | 在哪取消关注 (on 全部 and 已取消关注). const seg = out - ? [UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, counts[""], !F.source && !F.tagState)), - (F.kind === "" || F.kind === "gone") && UI.stateGroup("在哪取消关注", Object.entries(SOURCE).map(([id, label]) => UI.filterBtn(`data-source="${id}"`, label, counts.source[id], F.source === id)).join("")), - tagStateGroup(counts)].filter(Boolean).join("") + ? [UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, counts[""], !F.source)), + (F.kind === "" || F.kind === "gone") && UI.stateGroup("在哪取消关注", Object.entries(SOURCE).map(([id, label]) => UI.filterBtn(`data-source="${id}"`, label, counts.source[id], F.source === id)).join(""))].filter(Boolean).join("") : stateRowUps(counts); // 全选 heads the right-hand buttons, as in 收藏夹: it acts on the UP 主 listed now. 已出分拣范围 adds 清理, as 收藏夹's. const shows = Boolean(D.list || out); @@ -630,12 +627,13 @@ function renderFeed() { E.sort.innerHTML = UI.sortControl({ sorts: FEED_SORTS, sort: F.fsort, selectAttr: 'data-fw="fsort"', btn: F.fsort === "random" && { attrs: 'data-fw="reshuffle"', label: "再随机一次", icon: "↻" } }); E.tools.hidden = false; renderAiButton(); - // Row 3: 全部 | UP 未打标签 UP 已打标签; no count before the first page arrives. Row 4 counts videos. No 全选 here. + // No row 3 here: its only group, UP 未打标签 / UP 已打标签, is row 4's. Row 4 counts videos, none before the first page + // arrives. No 全选 here. const c = feedCounts(items, D, feedF()); const n = (k) => (items.length ? c[k] : null); - E.bar.innerHTML = UI.labeledRow("状态", UI.stateGroup(ALL, UI.filterBtn("data-fw-all", ALL, n(""), !F.tagState)) + tagStateGroup({ tagged: n("tagged"), untagged: n("untagged") }, "UP ")); + E.bar.innerHTML = ""; E.barR.innerHTML = runSlot(); - E.tagRow.innerHTML = UI.labeledRow("标签", tagRowHtml(c)); + E.tagRow.innerHTML = UI.labeledRow("标签", tagRowHtml({ ...c, tagAll: n("tagAll"), tagged: n("tagged"), untagged: n("untagged") })); const list = feedList(items, D, feedF(), pick.keep); E.qCount.textContent = UI.resultCount(F.fq.trim() || filtersOn(), list.length); const restore = UI.keepScroll(E.list, ".fw-video[data-bvid]"); @@ -1430,19 +1428,21 @@ main.addEventListener("click", async (e) => { } const s = t.closest("[data-side]"); if (s) return pickSide(s.dataset.side); - // Row 3: one pick per group, a second click clears it; 全部 clears the row (not row 4's tags). Row 4: AND. + // Row 3: one pick per group, a second click clears it; 全部 clears the row (not row 4). Row 4: its own 全部 clears it + // (not row 3); 未打标签 / 已打标签 one pick; the tags AND. const st = t.closest("[data-status]"); if (st) { F.status = F.status === st.dataset.status ? "" : st.dataset.status; return fromTop(); } if (t.closest("[data-fw-all]")) { - // 视频投稿's row 3 is only 未打标签 / 已打标签: UP 主's 更新状态 and AI 刚打的 stay. + F.status = F.source = ""; + F.recentFilter = false; + return fromTop(); + } + if (t.closest("[data-fw-tagall]")) { F.tagState = ""; - if (F.tab !== "feed") { - F.status = F.source = ""; - F.recentFilter = false; - } + F.tags.clear(); return fromTop(); } const ts = t.closest("[data-fw-tagstate]"); diff --git a/extension/triage/follow.selftest.js b/extension/triage/follow.selftest.js index 182139a8..b4283e35 100644 --- a/extension/triage/follow.selftest.js +++ b/extension/triage/follow.selftest.js @@ -97,7 +97,11 @@ const base = () => ({ list: null, last: null, content: {}, people: {}, tags: [], assert.deepStrictEqual(v({ q: "原理" }).list, ["2"], "search reads the sign too"); const c = v({ status: "active" }); assert.deepStrictEqual(c.list, ["1", "3"]); - assert.deepStrictEqual(c.counts, { "": 4, active: 2, slow: 1, stale: 1, tagged: 1, untagged: 1, recent: 0, source: { bili: 0, app: 0 }, tags: { t1: 1 }, have: ["t1"] }, "status counts ignore the status pick; the other groups apply it"); + assert.deepStrictEqual(c.counts, { "": 4, tagAll: 2, active: 2, slow: 1, stale: 1, tagged: 1, untagged: 1, recent: 0, source: { bili: 0, app: 0 }, tags: { t1: 1 }, have: ["t1"] }, "status counts ignore the status pick; the other groups apply it"); + // 2026-11-10: 未打标签 / 已打标签 is row 4's. Row 3's 全部 ("") counts with row 4 applied; row 4's (tagAll) with row 3 + // applied and row 4 cleared. + const r4 = v({ status: "active", tagState: "tagged", tags: new Set(["t1"]) }).counts; + assert.deepStrictEqual([r4[""], r4.tagAll], [2, 2]); // 已出分拣范围: 在哪取消关注 is a row 3 group. 全部 counts with it cleared; its chips count with search and row 4 applied. const out = v({ side: "out", source: "app" }); assert.deepStrictEqual([out.list, out.counts[""], out.counts.source], [["8"], 2, { bili: 1, app: 1 }]); @@ -474,7 +478,8 @@ assert.ok(source.includes("for (const m of [...F.sel]) if (!rows.has(m)) F.sel.d assert.deepStrictEqual(r.list.sort(), ["1", "2"], "活跃 AND 已打标签"); assert.deepStrictEqual([r.counts.active, r.counts.slow, r.counts.stale || 0], [2, 1, 0], "更新状态 counts keep 已打标签, drop their own pick"); assert.deepStrictEqual([r.counts.tagged, r.counts.untagged], [2, 0], "未打标签 / 已打标签 keep 活跃, drop their own pick"); - assert.deepStrictEqual([r.counts[""], r.counts.recent], [5, 1], "全部 = row 3 cleared; AI 刚打的 counted under the other picks"); + assert.deepStrictEqual([r.counts[""], r.counts.recent], [3, 1], "row 3's 全部 = row 3 cleared, row 4's 已打标签 applied; AI 刚打的 counted under the other picks"); + assert.strictEqual(r.counts.tagAll, 2, "row 4's 全部 = row 4 cleared, 活跃 applied"); assert.deepStrictEqual(r.counts.tags, { a: 2, b: 1 }, "row 4 counts the listed UP 主"); r = v({ tags: new Set(["a", "b"]), recentAll: all }); assert.deepStrictEqual(r.list, ["2"], "row 4: AND"); @@ -490,7 +495,7 @@ assert.ok(source.includes("for (const m of [...F.sel]) if (!rows.has(m)) F.sel.d // 视频投稿: row 3 by the video's UP; counts as above. const items = [{ bvid: "x", mid: "1", title: "甲" }, { bvid: "y", mid: "2", title: "乙" }, { bvid: "z", mid: "4", title: "丙" }]; const c = plain(t.feedCounts(items, D, { side: "all", q: "", tagState: "tagged", tags: new Set(["b"]) })); - assert.deepStrictEqual(c, { "": 1, tagged: 1, untagged: 0, tags: { a: 2, b: 1 }, have: ["a", "b"] }); + assert.deepStrictEqual(c, { tagAll: 3, tagged: 1, untagged: 0, tags: { a: 2, b: 1 }, have: ["a", "b"] }, "视频投稿 has no row 3: row 4's 全部 counts with row 4 cleared"); } // B12: the sidebar item is remembered with the rest of the view and checked against the data on load. @@ -662,7 +667,7 @@ const p0Throttled = () => vm.runInContext("MdgErrors.TEXT.biliThrottled", ctx); } // P1 (DESIGN 分拣台): row 4 lists the tags the tab's UP 主 / videos carry, row 3 aside (0 when it leaves none of them), - // as 收藏夹's; into or out of 已出分拣范围 clears 更新状态 and AI 刚打的 too; 视频投稿's 全部 clears only its own row 3; a + // as 收藏夹's; into or out of 已出分拣范围 clears 更新状态 and AI 刚打的 too; row 4's 全部 clears row 4 only and row 3's row 3 only; a // card click makes the card current. { const p = await followPage({ store: page({ follow_tags: [{ id: "t1", name: "科普", color: "#111" }], follow_tag_map: { u1: ["t1"] } }), handlers: { "follow-feed": () => feed } }); @@ -683,9 +688,19 @@ const p0Throttled = () => vm.runInContext("MdgErrors.TEXT.biliThrottled", ctx); F.tagState = "untagged"; p.FW.render(); assert.ok(E.tagRow.innerHTML.includes(chip), `视频投稿: ${E.tagRow.innerHTML}`); + assert.ok(E.bar.innerHTML === "" && /^标签<\/span>')); assert.strictEqual(count(PS.follow, /UI\.labeledRow\("标签", /g), 2, "关注 row 4, UP 主 and 视频投稿"); assert.strictEqual(count(PS.triage, /row-label/g), 0, "分拣台 draws no label of its own"); } diff --git a/extension/triage/triage.css b/extension/triage/triage.css index 2e84a4ff..f60ba62e 100644 --- a/extension/triage/triage.css +++ b/extension/triage/triage.css @@ -251,30 +251,38 @@ select { height: 28px; max-width: 240px; } .step-actions button { height: 28px; padding: 0 12px; } .step-actions .primary { border: 0; } -/* ---------- steps: text tabs, a pink underline on the open one, counts faint; 阅览 sits apart on the right ---------- */ +/* ---------- steps: pills like row 3's (2026-11-10 用户定), 全部 first and ruled off, the AI steps joined by → (the AI + only moves forward) ---------- */ .tabs:empty { display: none; } -/* Row 2: the tabs keep their underline; search and the mode's buttons sit on the right of the same line. */ +/* Row 2: the tabs; search and the mode's buttons sit on the right of the same line. */ .tabrow { display: flex; flex-wrap: wrap; align-items: center; column-gap: 24px; } .row-tools { display: flex; align-items: center; gap: 2px; margin-left: auto; padding: 6px 0; } /* While it reads, 刷新 gives its place to the progress pill: one thing on screen, not two. */ .row-tools:has(.activity:not([hidden])) .act-btn[aria-busy="true"] { display: none; } -.tabs { display: flex; flex: 0 1 auto; min-width: 0; align-items: center; gap: 20px; } -.tabs button { position: relative; display: flex; align-items: center; gap: 6px; height: 40px; padding: 0; border: 0; border-radius: 0; background: none; color: var(--text-2); font-size: var(--fs-sm); } -.tabs button:hover { background: none; color: var(--text); } -.tabs button[aria-selected="true"] { color: var(--text); font-weight: 600; } -.tabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: 2px; background: var(--accent); } -.tabs .count { color: var(--faint); font: 11px var(--font-mono); } -.tabs .step.zero:not([aria-selected="true"]) { color: var(--faint); } -.tabs .arrow { margin: 0 -12px; color: var(--border-strong); font-size: var(--fs-xs); } -.tabs .tab-sep { flex: 0 0 4px; height: 16px; border-left: 1px solid var(--border-strong); } +.tabs { display: flex; flex: 0 1 auto; min-width: 0; min-height: 40px; align-items: center; gap: 2px; } +.tabs button { display: flex; align-items: center; gap: 4px; height: 26px; padding: 0 12px; border: 0; border-radius: var(--radius-pill); background: none; color: var(--text-2); font-variant-numeric: tabular-nums; } +.tabs button:hover { background: var(--field-bg); } +.tabs button[aria-selected="true"] { background: var(--text); color: var(--bg); font-weight: 600; } +/* The three rows' 全部 count different things (the folder, the tab), so their pills differ in width: one min width, text + left, keeps the rules after them in a column up to three digits. */ +#tabs [data-tab="read"], :is(.state-row, .tagfilter) > .seg[aria-label="全部"] > button { min-width: 78px; justify-content: flex-start; text-align: left; } +.tabs .step.zero:not([aria-selected="true"]) { opacity: 0.5; } +.tabs .arrow { margin: 0 2px; color: var(--faint); font-size: var(--fs-xs); } +.tabs .tab-sep { flex: 0 0 1px; height: 16px; margin: 0 6px; border-left: 1px solid var(--border-strong); } /* ---------- row 3: state pills in groups | 全选 and the step's buttons. Row 4: my tags | 标签管理 ✦ AI 打标签 ---------- Both rows: filters on the left, their buttons top-right, so the two right ends line up. */ -.stagebar, .tagbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 16px; row-gap: 8px; } +.tagbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 16px; row-gap: 8px; } +/* Row 3 keeps its pills on one line: when pills and buttons do not fit side by side, the buttons drop to their own line + at the right instead of squeezing the pills into a column. */ +.stagebar { display: flex; flex-wrap: wrap; align-items: flex-start; column-gap: 16px; row-gap: 8px; } +.stagebar .class-filter { flex: 0 1 auto; } /* Each row reads as one line left to right: more room between rows 2, 3 and 4, and a thin rule under row 3 as under row 2 (the tabs). */ .stagebar, .tagbar { padding-top: 8px; padding-bottom: 8px; } -.stagebar .list-header { grid-column: 2; margin-left: 0; } +/* 关注's row 3 with nothing in it (视频投稿 outside a refresh): no empty band. */ +.fw-bar:has(#fwBar:empty):has(#fwRunSlot:empty) { display: none; } +.stagebar .list-header { flex: none; max-width: 100%; margin-left: auto; } .stagebar .step-actions { flex-wrap: nowrap; } .stagebar .class-filter, .tagbar .tagfilter { min-height: 28px; } .tagbar > :last-child { grid-column: 2; } @@ -289,16 +297,20 @@ select { height: 28px; max-width: 240px; } .state-row { display: flex; flex-wrap: wrap; align-items: center; row-gap: 8px; min-width: 0; } .state-row > .seg { position: relative; flex: none; flex-wrap: wrap; row-gap: 8px; max-width: 100%; } .state-row > .seg:not(:last-child) { margin-right: 17px; } -.state-row > .seg:not(:last-child)::after { content: ""; position: absolute; top: 5px; right: -9px; height: 16px; border-left: 1px solid var(--border-strong); } +.state-row > .seg:not(:last-child)::after, .tagfilter > .seg::after { content: ""; position: absolute; top: 5px; right: -9px; height: 16px; border-left: 1px solid var(--border-strong); } +/* The tag row's head (全部, 未打标签 / 已打标签) is state, not tags: row 3's unfilled pills, each group ruled off from + what follows (the tags, 4px apart, so 13px + 4px = row 3's 17px). */ +.tagfilter > .seg { position: relative; flex: none; margin-right: 13px; } +.tagfilter > .seg:last-child::after { display: none; } .seg button.st-danger:not([aria-pressed="true"]) { color: var(--danger); } /* 「AI 刚打的」 and its × (不再标出,标签不变) read as one pill. */ +/* 待决定's AI verdicts: a faint box right after it, so they read as its parts rather than a group of their own. */ +.state-row .sub { display: inline-flex; gap: 2px; margin-left: 2px; padding: 0 1px; border: 1px solid var(--border); border-radius: var(--radius-pill); } .state-row .ai-recent { gap: 0; } .state-row .ai-recent > button[aria-pressed] { border-radius: var(--radius-pill) 0 0 var(--radius-pill); } .state-row .ai-recent > .ai-recent-x { height: 26px; padding: 0 9px 0 6px; border: 0; border-left: 1px solid var(--surface); border-radius: 0 var(--radius-pill) var(--radius-pill) 0; background: var(--field-bg); color: var(--muted); } -/* No sidebar: row 3's pills take the whole line; its buttons go under them at the right, just above row 4's. */ +/* Too narrow even for the buttons alone: they wrap among themselves. */ @media (max-width: 760px) { - .stagebar { grid-template-columns: minmax(0, 1fr); } - .stagebar .list-header { grid-column: 1; justify-self: end; } .stagebar .step-actions { flex-wrap: wrap; } } /* 全选 (shared.js selectAllBox): a checkbox drawn in a button. Empty box, blue bar = some, blue ✓ = all listed. */ @@ -313,9 +325,9 @@ select { height: 28px; max-width: 240px; } .seg button:hover { background: var(--border); } .seg button[aria-pressed="true"] { background: var(--text); color: var(--bg); font-weight: 600; } /* Row 3's pills sit on the white card with no fill; only the picked one is solid. */ -.state-row .seg { gap: 2px; } -.state-row .seg button:not([aria-pressed="true"]), .state-row .ai-recent > .ai-recent-x { background: none; } -.state-row .seg button:not([aria-pressed="true"]):hover, .state-row .ai-recent > .ai-recent-x:hover { background: var(--field-bg); } +:is(.state-row, .tagfilter) .seg { gap: 2px; } +:is(.state-row, .tagfilter) .seg button:not([aria-pressed="true"]), .state-row .ai-recent > .ai-recent-x { background: none; } +:is(.state-row, .tagfilter) .seg button:not([aria-pressed="true"]):hover, .state-row .ai-recent > .ai-recent-x:hover { background: var(--field-bg); } /* A count of 0 stays, dimmed; only chips that exist for a condition (看完了, 已失效, AI 刚打的) leave at 0. */ .seg button.zero:not([aria-pressed="true"]), .side-item.zero:not(.on) { opacity: 0.5; } .tagfilter { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding-top: 1px; } @@ -660,10 +672,9 @@ dialog:has(.settings-ai:not([hidden])) { width: min(800px, calc(100% - 32px)); } .tabs button, .tabs .arrow { flex: none; } /* The fold toggle and the tabs share a line; search and the buttons go under it, the search takes what is left. The rule under that line spans the whole row, under the toggle too: the row draws it (top: the tabs' 40px), and the tabs - keep a clear 1px border so the line stays as tall. z-index -1 keeps it under the selected tab's underline. */ + keep a clear 1px border so the line stays as tall. */ .tabrow .tabs { flex: 1 1 0; border-bottom: 1px solid transparent; } .tabrow::after { content: ""; position: absolute; left: 0; right: 0; top: 40px; z-index: -1; height: 1px; background: var(--border); } - .tabs button[aria-selected="true"]::after { bottom: -1px; } .row-tools { width: 100%; padding: 8px 0; } /* Row 2 is two lines here and its rule sits under the tabs: row 3 gets its own above it. */ .stagebar { border-top: 1px solid var(--border); } diff --git a/extension/triage/triage.html b/extension/triage/triage.html index be0544fa..40d613f5 100644 --- a/extension/triage/triage.html +++ b/extension/triage/triage.html @@ -301,8 +301,8 @@

    标签管理

    -

    四步分拣

    +

    三步分拣

    1. 未分析:点「标题粗看下一批」,AI 只看标题和简介,快、省。
    2. -
    3. 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面;有把握的也可以直接按 AI 判断批量取消收藏或保留。
    4. -
    5. 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。
    6. -
    7. 处理完成:取消收藏或保留过的视频;最近取消收藏的可以重新收藏。
    8. +
    9. 粗看完成:点「字幕细看下一批」读字幕,给出 AI 总结和要点,拿不准和低置信的排在前面,已保留的不进下一批;有把握的也可以直接按 AI 判断批量取消收藏或保留。
    10. +
    11. 细看完成:按 AI 判断批量取消收藏或保留,也可以一个个按 D / S。卡片上的「✦ 重新细看」把这一个再细看一次。
    +

    页签只看 AI 做到了哪一步。保留的视频留在原来的页签,用「已保留 / 待决定」筛选;卡片上的「✦ 字幕细看」「✦ 重新细看」只跑这一个。取消收藏的视频去「已出分拣范围」,那里的「最近取消收藏」可以重新收藏。

    分拣分两层:第一层是收藏夹的判断标准,AI 按它给出 AI 判断(值得留 / 可清理 / 拿不准),决定留不留;第二层是你的标签,按每个标签的说明给视频分类,决定怎么找。
    判断标准在「判断标准」按钮里写,标签说明在「标签管理」里写。

    AI 判断是每步里的筛选。判断标准不写时,AI 从收藏夹名和简介推测用途。
    每个收藏夹最多 个标签:按 T 逐个打,按 I 让 AI 打标签(AI 每次最多新建 个)。两个上限都在收藏夹设置里改。
    AI 只在本页打开时运行。

    待播:分拣时按 E(或卡片上的「+ 待播」)把想先看或要核对的视频加入待播,可以跨收藏夹,按加入先后排。已加入的卡片显示「✓ 待播」,再按 E 或点它从待播移除。
    点一条在内置播放框里播放,点「移除」从待播移除,「清空」一次移除全部。这几种移除都可以按 U 撤销。
    不影响收藏和处理结果。

    diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index 02bdfacd..334628db 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -243,18 +243,42 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain(t.S.decisions), {}, "folder B's decisions are untouched"); assert.strictEqual(t.S.undo.length, 0, "folder B's undo stack gets no entry for folder A"); - // 最近取消收藏 re-favorites a whole batch in one go (all 45, though it lists only the latest), a single one alone. - openFake("A", [], structuredClone(savedA)); - t.S.decisions.BVsolo = { action: "unfav", at: 1, aid: 7, title: "单个" }; - assert.ok(t.recentUnfavHtml().includes(`data-refav-batch="${savedA.BV0.batch}"`) && t.recentUnfavHtml().includes("批量取消收藏 45 个")); + // R3: 处理完成 is gone; 已出分拣范围's 最近取消收藏 lists every chosen folder's records and re-favorites a whole batch + // into the folder it left in one go (all 45, though it lists only the latest), a single one alone. + const openRecent = (folderDecisions) => { + openFake("removed", []); + t.S.folderDecisions = folderDecisions; + for (const [f, d] of Object.entries(folderDecisions)) store[t.K.decisions(f)] = structuredClone(d); + }; + openRecent({ A: { ...structuredClone(savedA), BVsolo: { action: "unfav", at: 1, aid: 7, title: "单个" } } }); + assert.ok(t.recentUnfavHtml().includes(`data-refav-batch="${savedA.BV0.batch}" data-folder="A"`) && t.recentUnfavHtml().includes("批量取消收藏 45 个")); const refavAids = []; - handlers["triage-refav"] = (m) => (refavAids.push(m.aid), { ok: true }); - await t.fav.refavBatch(savedA.BV0.batch); + handlers["triage-refav"] = (m) => (refavAids.push([m.mediaId, m.aid]), { ok: true }); + await t.fav.refavBatch(savedA.BV0.batch, "A"); assert.strictEqual(refavAids.length, 45, "the batch, not the single one"); - assert.deepStrictEqual(Object.keys(store[t.K.decisions("A")]), [], "the stored records are gone"); - assert.ok(t.S.decisions.BVsolo && toasts.at(-1) === "已重新收藏这批 45 个", toasts.at(-1)); + assert.ok(refavAids.every(([f]) => f === "A"), "back into the folder it left"); + assert.deepStrictEqual(Object.keys(store[t.K.decisions("A")]), ["BVsolo"], "the batch's stored records are gone"); + assert.ok(t.S.folderDecisions.A.BVsolo && toasts.at(-1) === "「A」已重新收藏这批 45 个", toasts.at(-1)); assert.ok(!t.recentUnfavHtml().includes("data-refav-batch"), "no batch line once it is re-favorited"); store[t.K.decisions("A")] = structuredClone(savedA); + // A folder no longer draws it (its tabs list no unfavorited video); 已出分拣范围 draws it on 全部 and 已取消收藏 only. + { + t.el.list.querySelectorAll = () => []; + openFake("A", [item(1)], structuredClone(savedA)); + t.S.tab = "read"; + t.renderList(); + assert.ok(!t.el.list.innerHTML.includes("最近取消收藏"), "no 最近取消收藏 in a folder"); + openRecent({ A: structuredClone(savedA) }); + t.S.items = [item(1)]; + t.S.itemMap = new Map([["BV1", t.S.items[0]]]); + t.S.tab = "read"; + for (const [kind, shown] of [["", true], ["unfav", true], ["out", false]]) { + t.S.kindFilter = kind; + t.renderList(); + assert.strictEqual(t.el.list.innerHTML.includes("最近取消收藏"), shown, kind); + } + t.S.kindFilter = ""; + } // Back in the folder mid-run with a record read before the batch's last write: nothing written earlier is lost. openFake("A2", many); @@ -268,10 +292,10 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(t.S.undo.length, 1, "back in its folder, the batch is undoable with U"); // U7: after a reload the unfavorited videos are listed and re-favorited into their own folder. - openFake("A", many.slice(40), savedA); + openRecent({ A: structuredClone(savedA) }); const recent = t.recentUnfavs(); - assert.strictEqual(recent.length, 40); - assert.ok(t.recentUnfavHtml().includes('data-refav="BV0"')); + assert.strictEqual(recent.length, 45); + assert.ok(t.recentUnfavHtml().includes('data-refav="BV0" data-folder="A"')); handlers["triage-refav"] = () => ({ ok: true }); t.syncFolder = async () => true; // Waits out the settle window (__TRIAGE_SETTLE_MS) and drops a read deferred meanwhile. @@ -280,14 +304,14 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = vm.runInContext("clearTimeout(deferredRead?.timer); deferredRead = null;", ctx); }; await settled(); - await t.fav.refav("BV7"); + await t.fav.refav("BV7", "A"); assert.deepStrictEqual(plain(sent.at(-1)), { type: "triage-refav", mediaId: "A", aid: 1007 }); - assert.ok(!("BV7" in store[t.K.decisions("A")]), "re-favorited video leaves the recent list"); + assert.ok(!("BV7" in store[t.K.decisions("A")]) && !t.recentUnfavHtml().includes('data-refav="BV7"'), "re-favorited video leaves the recent list"); // X10: the folder counts as written, so the read after it waits for B站 to catch up. assert.ok(t.fav.state().writing("A"), "the read after a 重新收藏 waits for B站 to catch up"); await settled(); const capped = Object.fromEntries(Array.from({ length: 60 }, (_, i) => [`BVx${i}`, { action: "unfav", at: i, aid: i, title: "x" }])); - openFake("C", [], capped); + openRecent({ C: capped }); assert.strictEqual(t.recentUnfavs().length, 50, "the recent list is capped"); assert.strictEqual(t.recentUnfavs()[0][0], "BVx59", "newest first"); @@ -322,11 +346,11 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = }; const limitedOnce = (log) => (m) => (log.push(m.aid || m.aids), log.length === 1 ? { ok: false, code: "THROTTLED", error: "412" } : { ok: true }); const refavs = []; - openFake("R", [], { BV60: { action: "unfav", at: 1, aid: 1060, title: "视频60" } }); + openRecent({ R: { BV60: { action: "unfav", at: 1, aid: 1060, title: "视频60" } } }); handlers["triage-refav"] = limitedOnce(refavs); - await t.fav.refav("BV60"); + await t.fav.refav("BV60", "R"); assert.deepStrictEqual(plain(refavs), [1060, 1060]); - assert.ok(!("BV60" in store[t.K.decisions("R")]) && toasts.at(-1) === "已重新收藏《视频60》", toasts.at(-1)); + assert.ok(!("BV60" in store[t.K.decisions("R")]) && toasts.at(-1) === "已重新收藏《视频60》到「R」", toasts.at(-1)); const moves = []; t.S.allFolders = t.S.folders = [{ id: "R", title: "源" }, { id: "T", title: "目标" }]; t.S.included = ["R", "T"]; @@ -338,7 +362,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain(moves), [[1061, 1062], [1061, 1062]]); assert.deepStrictEqual(plain([...t.S.selected]), ["BV61", "BV62"], "copied, still listed: they stay selected"); assert.strictEqual(toasts.at(-1), "已复制 2 个到「目标」"); - assert.deepStrictEqual(pills, ["重新收藏中 0/1 · B站限流卡住,0:01 后重试", "复制到「目标」0/2 · B站限流卡住,0:01 后重试"]); + assert.deepStrictEqual(pills, ["重新收藏中 0/1(R) · B站限流卡住,0:01 后重试", "复制到「目标」0/2 · B站限流卡住,0:01 后重试"]); Object.assign(t.S, saved); t.askTransfer = realAsk; t.renderStatus = realStatus; @@ -404,9 +428,9 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = handlers["triage-unfav"] = () => ({ ok: true }); await t.fav.unfav("BV2"); await t.fav.unfavMany([three[0]]); - Object.assign(t.S.decisions, { BV8: { action: "unfav", at: 2, aid: 1008, title: "视频8" }, BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } }); + t.S.folderDecisions = { D: { BV8: { action: "unfav", at: 2, aid: 1008, title: "视频8" }, BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } } }; const h = holdNext("triage-refav"); - const running = t.fav.refav("BV8"); + const running = t.fav.refav("BV8", "D"); await ticks(); let asked = 0; const savedAsk = t.askConfirm; @@ -414,7 +438,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = const [sentBefore, toastsBefore] = [sent.length, toasts.length]; await t.fav.unfavMany([three[2]]); await t.fav.transfer([three[2]]); - await t.fav.refav("BV9"); + await t.fav.refav("BV9", "D"); assert.deepStrictEqual([asked, sent.length, toasts.length], [0, sentBefore, toastsBefore], "nothing asked, sent or told"); await t.undo(); assert.deepStrictEqual([asked, sent.length, t.S.undo.length, toasts.at(-1)], [0, sentBefore, 2, "重新收藏还在跑,等它完成再撤销"]); @@ -426,7 +450,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = await t.undo(); assert.deepStrictEqual([t.S.undo.length, toasts.at(-1)], [1, "已重新收藏 1 个"]); const h2 = holdNext("triage-refav"); - const again = t.fav.refav("BV9"); + const again = t.fav.refav("BV9", "D"); await ticks(); await t.undo(); assert.deepStrictEqual([t.S.undo.length, toasts.at(-1)], [1, "重新收藏还在跑,等它完成再撤销"]); @@ -444,7 +468,8 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = const running = t.fav.unfavMany(five.slice(0, 3)); await ticks(); const sentBefore = sent.length; - await t.fav.refav("BV9"); + t.S.folderDecisions = { H: { BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } } }; + await t.fav.refav("BV9", "H"); await t.fav.unfav("BV43"); assert.strictEqual(sent.length, sentBefore, "nothing sent"); assert.ok(/data-refav="BV9"[^>]* disabled title="批量取消收藏还在跑,等它完成"/.test(t.recentUnfavHtml()), t.recentUnfavHtml()); @@ -504,6 +529,48 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = delete t.S.analyses.BV1; t.S.undo = []; } + { + // P4: a card's own AI button goes by its AI depth, whatever was decided: none before 粗看 (a batch step only), then + // 字幕细看, then 重新细看; never a second 粗看. A failed card has only 重试; while another AI step runs the button is off with why. + openFake("R4", [item(1)], { BV1: { action: "keep", at: 1 } }); + Object.assign(t.S, { titleRes: {}, analyses: {} }); + const card = () => t.cardHtml(item(1), false, ""); + const btn = () => /data-act="ai-step"[^>]*>(?:]*><\/span>)?([^<]+).*data-act="ai-step"/.test(card().replace(/\n/g, "")), "in the verdict row"); + const old = { bvid: "BV1", status: "done", verdict: "keep", reason: "旧理由", oneLiner: "旧总结" }; + t.S.analyses.BV1 = old; + assert.strictEqual(btn(), "重新细看"); + assert.ok(!card().includes("重新粗看") && !card().includes(">标题粗看<"), "no re-粗看 on a card"); + t.S.analyses.BV1 = { bvid: "BV1", status: "error", error: "字幕取不到" }; + assert.ok(btn() === "" && card().includes('data-act="retry"'), "a failed card: 重试 only"); + t.S.analyses.BV1 = old; + t.S.group = { bvids: ["x"], mediaId: "other" }; + assert.ok(/data-act="ai-step"[^>]* disabled title="字幕细看还在跑,等它完成"/.test(card()), card()); + t.S.group = null; + // A kept card with only 粗看 runs its 字幕细看 as a run of one. + delete t.S.analyses.BV1; + const asked = []; + handlers["triage-analyze"] = (m) => (asked.push(plain(m)), { ok: true, data: { bvid: m.bvid, status: "done", verdict: "keep", oneLiner: "细看过" } }); + t.cardAction("ai-step", "BV1"); + await until(() => !t.S.group); + assert.deepStrictEqual([asked.length, asked[0].force, t.S.analyses.BV1.oneLiner], [1, false, "细看过"]); + // 重新细看 runs right away with force (no 侧重 box, 2026-11-10 用户定); the old result stays until the new one arrives. + t.S.analyses.BV1 = old; + let release; + handlers["triage-analyze"] = (m) => (asked.push(plain(m)), new Promise((r) => (release = () => r({ ok: true, data: { bvid: m.bvid, status: "done", verdict: "keep", oneLiner: "新总结" } })))); + t.cardAction("ai-step", "BV1"); + await until(() => release); + assert.ok(!card().includes(" !t.S.group); + assert.deepStrictEqual([t.S.analyses.BV1.oneLiner, t.activityState().text], ["新总结", "重新细看完成 1 个"]); + Object.assign(t.S, { titleRes: {}, analyses: {}, decisions: {}, undo: [] }); + } // C39: a 取消收藏 that leaves the video in another chosen folder (by its cached list, 稍后再看 aside) records elsewhere, // so B站 pages other than a favorites page leave 已取消收藏 off; checked against the folder it started in, even when @@ -554,7 +621,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(asked[2], "取消收藏 3 个"); assert.deepStrictEqual(calls, [["W1", [1060, 1061], "取消收藏中「夹1」 0/4"], ["W2", [1061, 1062], "取消收藏中「夹2」 2/4"]]); assert.deepStrictEqual(plain(vids.map((it) => it.folders)), [[], [], ["W3"]]); - assert.deepStrictEqual(plain(Object.keys(t.S.decisions)), ["BV60", "BV61"], "out of every folder: 处理完成"); + assert.deepStrictEqual(plain(Object.keys(t.S.decisions)), ["BV60", "BV61"], "out of every folder: unfavorited, in no tab"); assert.deepStrictEqual([Object.keys(store[t.K.decisions("W1")]), Object.keys(store[t.K.decisions("W2")])], [["BV60", "BV61"], ["BV61", "BV62"]]); // C39: W1's BV61 and W2's BV62 left the video in a chosen folder (W2, W3); W2's BV61 was its last. const els = (f, b) => store[t.K.decisions(f)][b].elsewhere; @@ -582,11 +649,11 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain([...t.S.selected]), ["BV61", "BV62"], "阅览 still lists them: 保留 keeps them selected"); // K1: none of the selection left to keep: the count is still the selection's, off with why. t.renderListHeader(t.visibleItems()); - assert.ok(/aria-label="保留选中的 2 个"[^>]*disabled title="选中的都已经处理过了"/.test(t.el.favSel.innerHTML), t.el.favSel.innerHTML); + assert.ok(/aria-label="保留选中的 2 个"[^>]*disabled title="选中的都已保留"/.test(t.el.favSel.innerHTML), t.el.favSel.innerHTML); await t.undo(); assert.ok(!t.S.kept.BV61 && !t.S.kept.BV62); // U cut short by a failure: still in 所有收藏夹, the rest is the next U; after another folder opened, the rest stays - // in each folder's 最近取消收藏 and the open folder gets no step. + // in 已出分拣范围's 最近取消收藏 and the open folder gets no step. for (const away of [false, true]) { const again = [{ ...item(63), folders: ["W1"] }, { ...item(64), folders: ["W2"] }]; openFake("all", again); @@ -594,7 +661,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = await t.fav.unfavMany(again); handlers["triage-refav"] = (m) => (m.aid === 1064 ? { ok: false, error: "网络错误" } : (away && openFake("E", []), { ok: true })); await t.undo(); - const want = away ? ["「所有收藏夹」撤销中断(已重新收藏 1 个,剩余 1 个在各收藏夹的最近取消收藏里):网络错误", 0] : ["撤销中断(已重新收藏 1 个,剩余 1 个可再按 U 重试):网络错误", 1]; + const want = away ? ["「所有收藏夹」撤销中断(已重新收藏 1 个,剩余 1 个在「已出分拣范围」的「最近取消收藏」里):网络错误", 0] : ["撤销中断(已重新收藏 1 个,剩余 1 个可再按 U 重试):网络错误", 1]; assert.deepStrictEqual([toasts.at(-1), t.S.undo.length], want, away ? "elsewhere: told only" : "here: U again"); } // No folder checked: the dialog's button is off and says why; it is on again for the next dialog. @@ -638,18 +705,27 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(btn(1), ''); t.S.basket = kept; } - // F27: a 细看 judged without subtitles says so on the card, and why (the login, a failed download); a subtitle that may - // not match the video gets the video page's warning. + // F27 / P6: a 细看 judged without subtitles says so once, in a gray badge leading the verdict row (before the AI verdict + // or the decision), and why on hover (the login, a failed download); a subtitle that may not match the video gets the + // video page's warning on the meta line. { const meta = () => t.cardHtml(item(1), false, "").match(/
    ([^]*?)<\/div>/)[1]; + const row = () => t.cardHtml(item(1), false, "").match(/
    ([^]*?)<\/div>/)[1]; t.S.analyses.BV1 = { status: "done", source: "subtitle", verdict: "keep" }; - assert.ok(!meta().includes("字幕")); + assert.ok(!meta().includes("字幕") && !row().includes("字幕")); t.S.analyses.BV1 = { status: "done", source: "meta", verdict: "keep" }; - assert.ok(meta().includes("视频没有字幕,按简介和评论判断"), meta()); + assert.ok(row().startsWith('无字幕未登录无字幕'), row()); t.S.analyses.BV1 = { status: "done", source: "meta", verdict: "keep", subtitleFailure: "网络断了" }; - assert.ok(meta().includes("字幕抓取失败:网络断了,按简介和评论判断") && !meta().includes("没有字幕"), meta()); + assert.ok(row().startsWith('字幕抓取失败'), row()); + t.S.decisions.BV1 = { action: "keep", at: 1 }; + assert.ok(row().startsWith(' Object.fromEntries(Object.entries(d).filter(([, v]) = t.S.itemMap.set("BV4", t.S.items[3]); t.S.basket.push({ bvid: "BV4" }); t.S.selected.clear(); - t.S.selected.add("BV2"); // kept, so not listed in 未分析: a selection hidden there is left out + t.S.selected.add("BV2"); // kept, so 待决定 hides it: a selection hidden there is left out t.S.selected.add("BV3"); Object.assign(t.S, { tab: "none", titleRes: {}, analyses: { BVgone: { status: "done", oneLiner: "一句话", points: ["要点"] } }, notes: { BV3: { text: " 我的笔记 " } }, videoTags: {} }); const scope = (s) => plain(t.writeScopeItems(s, true).map((it) => it.bvid)); + assert.deepStrictEqual(scope("filter"), ["BV1", "BV2", "BV3"], "kept, BV2 is still in 未分析: the tabs go by AI depth"); + t.S.classFilter.none = "undecided"; assert.deepStrictEqual([scope("selected"), scope("all"), scope("filter")], [["BV3"], ["BV1", "BV2", "BV3"], ["BV1", "BV3"]]); + t.S.classFilter.none = "all"; + t.S.selected.delete("BV2"); // A filter hides part of the selection: the bar counts only what is listed, and the hidden one stays selected. t.S.selected.add("BV1"); t.S.query = "视频3"; @@ -709,11 +789,16 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = const hiddenAll = sight(); assert.ok(hiddenAll.includes("已选中 2 个都被筛选隐藏") && hiddenAll.includes('aria-label="AI 打标签选中的 2 个 (I)"'), hiddenAll); t.S.query = ""; - // The selection belongs to the tab: the kept BV2 is not in 未分析, so it leaves the selection, and so does one kept now. + // The selection belongs to the tab: 保留 leaves a video in the tab of its AI depth, so it stays selected; an + // unfavorited one leaves every tab, and the selection. await t.keepOne("BV1"); t.renderListHeader(t.visibleItems()); + assert.deepStrictEqual(plain([...t.S.selected]), ["BV3", "BV1"]); + assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("已选中 2 个") && !(t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("被筛选隐藏"), (t.el.listHeader.innerHTML + t.el.favSel.innerHTML)); + t.S.decisions.BV1 = { action: "unfav", at: 1 }; + t.renderListHeader(t.visibleItems()); assert.deepStrictEqual(plain([...t.S.selected]), ["BV3"]); - assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("已选中 1 个") && !(t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("被筛选隐藏"), (t.el.listHeader.innerHTML + t.el.favSel.innerHTML)); + t.S.decisions.BV1 = t.S.kept.BV1; delete t.S.kept.BV1; assert.deepStrictEqual(plain(t.writeScopeItems("all", false).map((it) => it.bvid)), ["BV1", "BV2", "BV3", "BV4"]); // 一篇摘录 carries what each video's own note carries (MdgNote.triageExtras), read from storage as the note is: the @@ -808,7 +893,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(t.verdictOf({ bvid: "BVnone", invalid: true }).reason, "视频已失效"); // Progress tabs follow which AI steps ran: no 粗看 → 未分析, a 粗看 class or invalid → 粗看完成, - // a done 细看 → 细看完成, processed → 处理完成. Class and confidence never move a card. + // a done 细看 → 细看完成. Class, confidence and the user's decision never move a card. Object.assign(t.S, { analyses: {}, titleRes: {}, decisions: {}, videoTags: {}, tags: [] }); const st = (patch = {}, it = v) => { Object.assign(t.S, patch); @@ -822,15 +907,23 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.strictEqual(st({ analyses: { BVv: { status: "error" } }, titleRes: { BVv: { verdict: "unsure" } } }), "coarse", "a failed 细看 stays in 粗看完成 with its retry button"); assert.strictEqual(st({ analyses: {}, titleRes: {} }, { ...v, invalid: true }), "coarse"); assert.strictEqual(t.verdictOf({ ...v, invalid: true }).verdict, "drop", "invalid videos count as 可以删"); - assert.strictEqual(st({ decisions: { BVv: { action: "keep" } } }), "done"); + assert.strictEqual(st({ decisions: { BVv: { action: "keep" } }, titleRes: { BVv: { verdict: "keep" } } }), "coarse", "保留 leaves a 粗看'd video in 粗看完成"); assert.strictEqual(st({ decisions: {} }, { ...v, invalid: true, bvid: "BVv" }), "coarse"); - // A1: only 取消收藏 / 保留 move a video to 处理完成; tags (T, 批量打标签) and notes do not. + // 处理完成 is gone: four tabs, three of them AI depth. + assert.deepStrictEqual(plain(vm.runInContext("STAGES", ctx).map(([k]) => k)), ["none", "coarse", "fine"]); + assert.ok(!/处理完成/.test(source), "no page code says 处理完成"); + // A1: tags (T, 批量打标签) and notes move no video either. const tagged = { tags: [{ id: "t1", name: "x" }], videoTags: { BVv: ["t1"] }, notes: { BVv: { text: "备注", updatedAt: 1 } } }; assert.strictEqual(st({ ...tagged, titleRes: {} }), "none", "a tagged, noted video without 粗看 stays in 未分析"); assert.strictEqual(st({ titleRes: { BVv: { verdict: "unsure", confidence: "high" } } }), "coarse", "tagged 待定 stays in 粗看完成"); assert.strictEqual(st({ analyses: { BVv: { status: "done", verdict: "keep" } } }), "fine", "tagged after 细看 stays in 细看完成"); - assert.strictEqual(st({ decisions: { BVv: { action: "unfav" } } }), "done"); - Object.assign(t.S, { videoTags: {}, tags: [], notes: {} }); + // An unfavorited video is in no tab (已出分拣范围 has it); 全部 leaves it out too. + t.S.decisions = { BVv: { action: "unfav" } }; + assert.strictEqual(t.stageOf(v), "fine"); + assert.ok(["none", "coarse", "fine", "read"].every((tab) => !t.inTab(v, tab)), "unfavorited: in no tab"); + t.S.decisions = { BVv: { action: "keep" } }; + assert.ok(t.inTab(v, "fine") && t.inTab(v, "read"), "kept: in the tab of its AI depth and in 全部"); + Object.assign(t.S, { videoTags: {}, tags: [], notes: {}, decisions: {} }); // 细看 batch: first fineBatchSize() open cards of 粗看完成, or the selected ones; 细看完成 buttons follow the selection too. const pool = Array.from({ length: 12 }, (_, i) => item(200 + i)); @@ -897,10 +990,53 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain(t.batchList("keep").map((it) => it.bvid)), ["BV200"]); assert.deepStrictEqual(plain(t.batchList("drop").map((it) => it.bvid)), ["BV200"]); await t.batchKeep(t.batchList("keep")); - assert.strictEqual(t.stageOf(pool[0]), "done", "batch 保留 moves it to 处理完成"); + assert.strictEqual(t.stageOf(pool[0]), "fine", "batch 保留 leaves it in 细看完成"); + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), [], "kept, it leaves the AI's 拿不准"); + t.S.classFilter.fine = "kept"; + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV200"], "and is under 已保留"); + t.S.selected.clear(); + assert.deepStrictEqual([t.batchList("keep").length, t.batchList("unsure").length], [0, 0], "verdict 保留 batches take only undecided videos"); + assert.strictEqual(t.batchList("unsure", "unfav").length, 0, "a verdict-wide 取消收藏 leaves kept videos alone"); + t.S.selected.add("BV200"); + assert.deepStrictEqual(plain(t.batchList("unsure", "unfav").map((it) => it.bvid)), ["BV200"], "a selected kept video can be 取消收藏'd in a batch"); + t.S.selected.clear(); + // 已保留, 待决定 and the AI verdicts are one pick: a kept video has no verdict here, the verdicts split 待决定. + t.S.analyses.BV201 = { status: "done", verdict: "drop" }; + t.S.analyses.BV202 = { status: "done", verdict: "drop" }; + t.S.decisions.BV201 = { action: "keep", at: 1 }; + t.S.classFilter.fine = "drop"; + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV202"], "可清理 lists only the undecided one"); + t.S.classFilter.fine = "undecided"; + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV202"]); + t.S.classFilter.fine = "all"; + t.renderListHeader(t.visibleItems()); + const row = t.el.classFilter.innerHTML; + for (const part of [">待决定 1<", ">已保留 2<", ">可清理 1<", ">拿不准 0<", 'data-class-filter="kept" title="保留标记']) assert.ok(row.includes(part), part); + assert.ok(row.indexOf("已保留") < row.indexOf("待决定") && row.indexOf("待决定") < row.indexOf('class="sub"') && row.indexOf('class="sub"') < row.indexOf("值得留"), "已保留, then 待决定 with its verdicts boxed after it"); + assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("取消收藏(AI:可清理)1 个"), "the batch 取消收藏 leaves the kept 可清理 one out"); + t.pickState("class", "kept"); + assert.strictEqual(t.S.classFilter.fine, "kept"); + t.pickState("class", "drop"); + assert.strictEqual(t.S.classFilter.fine, "drop", "a verdict replaces 已保留"); + t.pickState("all"); + assert.strictEqual(t.S.classFilter.fine, "all", "全部 clears it"); delete store[t.K.kept]; Object.assign(t.S, { analyses: {}, decisions: {}, kept: {}, classFilter: { coarse: "all", fine: "all" } }); t.S.selected.clear(); + // P3: a kept video with only 粗看 sits in 粗看完成; 字幕细看下一批 leaves 已保留 out (AI cost), a selection runs it. + t.S.tab = "coarse"; + t.S.titleRes = Object.fromEntries(pool.map((it) => [it.bvid, { verdict: "unsure", confidence: "high" }])); + t.S.decisions = { BV200: { action: "keep", at: 1 } }; + assert.ok(t.inTab(pool[0], "coarse") && vm.runInContext("needsAnalysis", ctx)("BV200"), "kept after 粗看: in 粗看完成 and still 细看-able"); + assert.ok(!t.nextBatch().includes("BV200") && t.nextBatch().length === 10, "下一批 skips 已保留"); + t.S.selected.add("BV200"); + assert.deepStrictEqual(plain(t.nextBatch()), ["BV200"], "selected, a kept video is 细看'd"); + t.S.selected.clear(); + t.S.decisions = Object.fromEntries(pool.map((it) => [it.bvid, { action: "keep", at: 1 }])); + assert.deepStrictEqual(plain(t.nextBatch()), []); + t.renderListHeader(t.visibleItems()); + assert.ok(t.el.listHeader.innerHTML.includes('disabled title="已保留的视频不进下一批,选中后可以细看"'), t.el.listHeader.innerHTML); + Object.assign(t.S, { decisions: {}, titleRes: {} }); // 粗看完成 chips: per-class counts in the tab; a chip narrows the next batch, a selection still wins; each tab keeps its chip. t.S.tab = "coarse"; t.S.titleRes = Object.fromEntries(pool.map((it, i) => [it.bvid, i < 6 ? { verdict: "unsure", confidence: "high" } : { verdict: i % 2 ? "keep" : "drop", confidence: "low" }])); @@ -1084,6 +1220,14 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = t.S.classFilter.fine = "all"; t.renderListHeader(t.visibleItems()); assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).includes("按新标准重新细看下一批 1 个")); + // P3: its next batch leaves 已保留 out too; selected, a kept one is redone. + t.S.decisions = { BV203: { action: "keep", at: 1 } }; + assert.deepStrictEqual(plain(vm.runInContext('redoOf("fine")', ctx).batch), [], "下一批 skips the kept stale one"); + assert.strictEqual(vm.runInContext('redoOf("fine")', ctx).why, vm.runInContext("KEPT_NOT_NEXT", ctx), "only kept ones left: it says so, not that the filter hides them"); + t.S.selected = new Set(["BV203"]); + assert.deepStrictEqual(plain(vm.runInContext('redoOf("fine")', ctx).batch.map((it) => it.bvid)), ["BV203"]); + t.S.selected.clear(); + t.S.decisions = {}; // F1: like the step's own button, a selection of stale ones wins. G9: a selection with none stale does not fall back // to the next batch: the button stays the selection's, disabled with why. t.S.selected = new Set(["BV204"]); @@ -1898,7 +2042,7 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(Object.keys(store[t.K.kept]), ["BV1"]); assert.ok(!store[t.K.decisions("A")], "保留 writes no folder record"); t.S.decisions = { ...t.S.kept, ...store[t.K.decisions("B")] }; - assert.strictEqual(t.stageOf(item(1)), "done", "kept in A is kept in B"); + assert.strictEqual(t.S.decisions.BV1?.action, "keep", "kept in A is kept in B"); await t.undo(); await flush(); assert.deepStrictEqual(plain(store[t.K.kept]), {}); @@ -2077,11 +2221,11 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = delete t.S.titleRes.BV1; } - // 处理完成 keeps what 细看 found: the one-liner and the points stay on the card. + // A kept card in 细看完成 keeps what 细看 found: the one-liner and the points stay on the card. { const saved = { tab: t.S.tab, analyses: t.S.analyses }; openFake("A", [item(1)], { BV1: { action: "keep" } }); - Object.assign(t.S, { tab: "done", analyses: { BV1: { status: "done", verdict: "keep", reason: "讲得清楚", oneLiner: "一句话", points: ["要点甲", "要点乙"] } } }); + Object.assign(t.S, { tab: "fine", analyses: { BV1: { status: "done", verdict: "keep", reason: "讲得清楚", oneLiner: "一句话", points: ["要点甲", "要点乙"] } } }); t.renderList(); for (const part of ["一句话", "要点甲", "要点乙", "讲得清楚", "已保留"]) assert.ok(t.el.list.innerHTML.includes(part), part); assert.ok(!t.el.list.innerHTML.includes("值得留"), "the decision replaces the verdict label"); @@ -2094,17 +2238,17 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = openFake("A", [item(1)]); } - // 阅览: a decided video leaves the AI classes for 已保留, the same word its card shows. + // 全部: a kept video is under 已保留 only, the same word its card shows. { const saved = { tab: t.S.tab, titleRes: t.S.titleRes, classFilter: t.S.classFilter }; openFake("A", [item(1), item(2)], { BV1: { action: "keep" } }); Object.assign(t.S, { tab: "read", titleRes: { BV1: { verdict: "keep" }, BV2: { verdict: "keep" } }, classFilter: { coarse: "all", fine: "all", read: "all" } }); t.renderListHeader(t.visibleItems()); - for (const part of [">全部 2<", ">值得留 1<", ">已保留 1<"]) assert.ok(t.el.classFilter.innerHTML.includes(part), part); + for (const part of [">全部 2<", ">值得留 1<", ">已保留 1<", ">待决定 1<"]) assert.ok(t.el.classFilter.innerHTML.includes(part), part); t.S.classFilter.read = "kept"; assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV1"]); t.S.classFilter.read = "keep"; - assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV2"]); + assert.deepStrictEqual(plain(t.visibleItems().map((it) => it.bvid)), ["BV2"], "a kept video leaves the AI verdicts"); Object.assign(t.S, saved); openFake("A", [item(1)]); } @@ -2448,9 +2592,9 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = const writeRun = hang("write"); assert.strictEqual(leave(), true, "notes export"); await writeRun(); - t.S.decisions.BV9 = { action: "unfav", at: 1, aid: 1009, title: "视频9" }; + t.S.folderDecisions = { K: { BV9: { action: "unfav", at: 1, aid: 1009, title: "视频9" } } }; const h = holdNext("triage-refav"); - const running = t.fav.refav("BV9"); + const running = t.fav.refav("BV9", "K"); await ticks(); assert.strictEqual(leave(), true, "a 重新收藏 running"); h.release(); @@ -2504,8 +2648,10 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = t.el.folderSelect = { value: "", querySelector: () => null }; store.triage_snapshot_2 = { bvids: ["BV2"], ids: ["BV2"], items: [item(2)], intro: "简介" }; handlers["triage-folder-ids"] = () => ({ ok: true, data: { bvids: ["BV2"] } }); + t.S.noSubFilter = true; await t.openFolder("2"); await new Promise((r) => setImmediate(r)); + assert.strictEqual(t.S.noSubFilter, false, "another folder starts without the 无字幕 filter"); assert.deepStrictEqual([synced, plain(t.S.items.map((it) => it.bvid)), t.S.folderIntro["2"]], [0, ["BV2"], "简介"]); handlers["triage-folder-ids"] = () => ({ ok: true, data: { bvids: ["BV2", "BV8"] } }); await t.openFolder("2"); @@ -3236,23 +3382,35 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = t.S.selected.clear(); const groups = () => [...row3().matchAll(/role="group" aria-label="([^"]+)"/g)].map((m) => m[1]); const has = (...parts) => parts.forEach((p) => assert.ok(row3().includes(p), p)); - assert.deepStrictEqual(groups(), ["全部", "按 AI 判断筛选", "按有没有标签", "已失效"], "fixed groups, then 已失效 (conditional) last"); + assert.deepStrictEqual(groups(), ["全部", "已保留", "待决定", "待决定里按 AI 判断", "已失效"], "fixed groups, then 已失效 (conditional) last"); has(">全部 5<", ">值得留 2<", ">可清理 2<", ">拿不准 1<", ">已失效 1<"); assert.ok(row3().startsWith('状态'), "row 3 starts with its name"); t.renderTabs(); assert.ok(t.el.tagFilter.innerHTML.startsWith('标签'), "row 4 starts with its name"); assert.ok((t.el.listHeader.innerHTML + t.el.favSel.innerHTML).startsWith('