From 4399e7b54f54f0d8e16f6e7d96a7d29e79b6f9d6 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:37:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E6=8B=A3=E5=8F=B0=EF=BC=9A=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=8B=BE=E9=80=89=E6=94=B6=E8=97=8F=E5=A4=B9=E7=9A=84?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=97=B6=E9=87=8D=E7=AE=97=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E6=94=B6=E8=97=8F=E8=AE=B0=E5=BD=95=E7=9A=84=E3=80=8C=E8=BF=98?= =?UTF-8?q?=E5=9C=A8=E5=88=AB=E5=A4=84=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 B站 把取消收藏过的视频收进或移出另一个勾选收藏夹后,B站 页面的「已取消收藏」跟着改;没读全的列表不算。 --- docs/DESIGN.md | 2 +- extension/triage/fav-base.js | 2 +- extension/triage/fav-folders.js | 1 + extension/triage/fav-writes.js | 35 ++++++++++++++++++++--------- extension/triage/triage-core.js | 22 +++++++++++++++++- extension/triage/triage.selftest.js | 35 +++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index ba817966..18a9d916 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -388,4 +388,4 @@ B站 页面上的 AI 判断照上表:值得留绿、可清理红、拿不准 - B站 自己已经写了「已看完」的封面(稍后再看),不再加「✓ 看完了」。每种页面都画淡的「看到 N%」。历史的「遮罩」样式换成角标,稍后再看和 UP 空间照设置。为什么:历史里每个视频都看过,遮罩会盖住每张封面;稍后再看和 UP 空间里很多没看过,遮罩只盖看完的那些。 - 「已取消收藏」和「AI 可清理」同是红(见「颜色」),但「已取消收藏」是实底白字、AI 判断是浅色底。为什么:一个是你做了的决定,一个是 AI 的建议。 - 已失效、AI 没判过的视频在 B站 页面写「已失效」,不带「AI」,同可清理的浅红底;AI 判过的照旧写「AI 可清理」。分拣台卡片不变。为什么:已失效是收藏夹快照里的事实,不是 AI 的建议。 -- 收藏夹页以外,「已取消收藏」只标已不在任何勾选收藏夹里的视频:取消收藏时分拣台按勾选收藏夹的缓存列表(稍后再看不算)算好它还在不在别处,记进这条决定,B站 页面只读决定。收藏夹页照旧标这个收藏夹自己的决定;以前的决定没有这一项,照旧显示。为什么:B站 页面每次读所有收藏夹的快照代价太大。 +- 收藏夹页以外,「已取消收藏」只标已不在任何勾选收藏夹里的视频:取消收藏时分拣台按勾选收藏夹的缓存列表(稍后再看不算)算好它还在不在别处,记进这条决定,以后分拣台每存下一个勾选收藏夹的完整列表就按新列表重算别的收藏夹的这一项(没读全的列表不存、不算),B站 页面只读决定。收藏夹页照旧标这个收藏夹自己的决定;以前的决定没有这一项,照旧显示。为什么:B站 页面每次读所有收藏夹的快照代价太大。 diff --git a/extension/triage/fav-base.js b/extension/triage/fav-base.js index 647d4841..8c64bd80 100644 --- a/extension/triage/fav-base.js +++ b/extension/triage/fav-base.js @@ -72,7 +72,7 @@ const { VERDICTS } = MdgTagCore; const newTagId = () => MdgTagCore.newTagId("t"); // The stored records' shapes and the export helpers (pure) live in triage-core.js. -const { refavorited, mergeFolderItems, updateRemoved, moveToRemoved, inferFrom, forgetLeft, noteLeft, keepInvalidInfo, idsChanged, mergeHead, unfavOnly, mdLinkText, oneLine, mdLink, wikiLink } = TriageCore; +const { refavorited, mergeFolderItems, updateRemoved, moveToRemoved, inferFrom, forgetLeft, noteLeft, keepInvalidInfo, idsChanged, mergeHead, refreshElsewhere, unfavOnly, mdLinkText, oneLine, mdLink, wikiLink } = TriageCore; // 收藏夹设置's number settings: [stored key, TriageUi.NUM_SETTINGS kind, its input in the dialog]. const NUM_FIELDS = [ diff --git a/extension/triage/fav-folders.js b/extension/triage/fav-folders.js index 4e616df0..72a4569e 100644 --- a/extension/triage/fav-folders.js +++ b/extension/triage/fav-folders.js @@ -469,6 +469,7 @@ function saveSnapshot(mediaId, items, ids = null) { [K.left]: left }); if (!saved) return; + await refreshElsewhereNow(mediaId); S.removedCount = Object.keys(removed).length; renderTop(); }); diff --git a/extension/triage/fav-writes.js b/extension/triage/fav-writes.js index ea681da9..6591b7b8 100644 --- a/extension/triage/fav-writes.js +++ b/extension/triage/fav-writes.js @@ -29,18 +29,20 @@ const keepStep = (bvids) => // Merged into the stored record, not written from memory: a batch running for this folder may have written it after // the open folder read it. function patchDecisions(mediaId, patch) { - return lockRecords(async () => { - const stored = await storeGet(K.decisions(mediaId), {}); - const maps = [stored, mediaId === S.mediaId && S.decisions, S.folderDecisions[mediaId]].filter(Boolean); - for (const d of maps) { - for (const [b, v] of Object.entries(patch)) { - if (v) d[b] = v; - else if (d === S.decisions) unfavGone(b); - else delete d[b]; - } + return lockRecords(() => patchDecisionsNow(mediaId, patch)); +} +// patchDecisions for a fn already running in lockRecords. +async function patchDecisionsNow(mediaId, patch) { + const stored = await storeGet(K.decisions(mediaId), {}); + const maps = [stored, mediaId === S.mediaId && S.decisions, S.folderDecisions[mediaId]].filter(Boolean); + for (const d of maps) { + for (const [b, v] of Object.entries(patch)) { + if (v) d[b] = v; + else if (d === S.decisions) unfavGone(b); + else delete d[b]; } - await storeSet(K.decisions(mediaId), unfavOnly(stored)); - }); + } + await storeSet(K.decisions(mediaId), unfavOnly(stored)); } // 保留 is one list for every folder; a null value deletes. The patch goes onto the page's copy at once and onto the stored // list by read-modify-write, so a 保留 another tab set meanwhile stays; the page then takes what was written. @@ -74,6 +76,17 @@ async function favedElsewhere(mediaId) { const got = await chrome.storage.local.get(others.map(K.snapshot)); return new Set(others.flatMap((id) => got[K.snapshot(id)]?.bvids || [])); } +// A chosen folder's fresh full list (saveSnapshot, already in lockRecords) may change where the other folders' 取消收藏 +// videos still are: favorited into it on B站, or taken out of it there. Their elsewhere follows the cached lists. +async function refreshElsewhereNow(mediaId) { + const ids = chosenOtherIds(); + const targets = ids.filter((id) => id !== String(mediaId)); + const sources = ids.filter((id) => id !== TOVIEW); + const got = await chrome.storage.local.get([...targets.map(K.decisions), ...sources.map(K.snapshot)]); + const lists = Object.fromEntries(sources.map((id) => [id, got[K.snapshot(id)]?.bvids || null])); + const changed = refreshElsewhere(Object.fromEntries(targets.map((id) => [id, got[K.decisions(id)]])), lists); + for (const [id, patch] of Object.entries(changed)) await patchDecisionsNow(id, patch); +} // 所有收藏夹: still in a chosen folder once it leaves the folders in `gone`. const stillIn = (it, gone) => it.folders.some((f) => f !== TOVIEW && !gone.includes(f)); const shortTitle = (it) => (it.title.length > 24 ? `${it.title.slice(0, 24)}…` : it.title); diff --git a/extension/triage/triage-core.js b/extension/triage/triage-core.js index 10ec74e5..104760c3 100644 --- a/extension/triage/triage-core.js +++ b/extension/triage/triage-core.js @@ -120,6 +120,26 @@ const old = new Set(oldIds); return ids.every((b) => old.has(b) || got.has(b)) ? items : null; } + // A 取消收藏's elsewhere (pure): still in a chosen folder other than its own. lists = { folderId: bvids, or null while + // the folder has no full list cached }, the chosen folders but 稍后再看; byFolder = { folderId: decisions }. Returns + // { folderId: { bvid: record } } of the records whose elsewhere changes. A video in no known list stays as recorded + // while some other folder's list is unknown: only full lists prove it left. + function refreshElsewhere(byFolder, lists) { + const sets = Object.entries(lists).map(([id, bvids]) => [id, bvids && new Set(bvids)]); + const out = {}; + for (const [f, d] of Object.entries(byFolder)) { + for (const [b, rec] of Object.entries(d || {})) { + if (rec?.action !== "unfav") continue; + const others = sets.filter(([id]) => id !== f); + const now = others.some(([, s]) => s?.has(b)) ? true : others.every(([, s]) => s) ? false : Boolean(rec.elsewhere); + if (now === Boolean(rec.elsewhere)) continue; + const next = { ...rec, elsewhere: true }; + if (!now) delete next.elsewhere; + (out[f] ||= {})[b] = next; + } + } + return out; + } const unfavOnly = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) => v?.action === "unfav")); function mdLinkText(s) { @@ -135,5 +155,5 @@ return `[[${target}|${oneLine(String(title ?? "").replace(/[|[\]]/g, " ")) || target}]]`; } - globalThis.TriageCore = { refavorited, mergeFolderItems, updateRemoved, moveToRemoved, inferFrom, forgetLeft, noteLeft, keepInvalidInfo, idsChanged, mergeHead, unfavOnly, mdLinkText, oneLine, mdLink, wikiLink }; + globalThis.TriageCore = { refavorited, mergeFolderItems, updateRemoved, moveToRemoved, inferFrom, forgetLeft, noteLeft, keepInvalidInfo, idsChanged, mergeHead, refreshElsewhere, unfavOnly, mdLinkText, oneLine, mdLink, wikiLink }; })(); diff --git a/extension/triage/triage.selftest.js b/extension/triage/triage.selftest.js index e1d15305..55fc3510 100644 --- a/extension/triage/triage.selftest.js +++ b/extension/triage/triage.selftest.js @@ -1983,6 +1983,41 @@ const unfavOnlyOf = (d) => Object.fromEntries(Object.entries(d).filter(([, v]) = assert.deepStrictEqual(plain(store[t.K.snapshot("A")].ids), ["BVhidden"], "an id the paged list leaves out stays in the check baseline"); assert.strictEqual(t.S.removedCount, 1); + // C39: a 取消收藏's elsewhere follows the other chosen folders' full lists; a folder with none yet cannot prove it left. + { + const un = (elsewhere) => ({ action: "unfav", at: 1, aid: 1, title: "t", ...(elsewhere && { elsewhere }) }); + const keep = { action: "keep", at: 1 }; + const rows = [ + ["favorited into another folder", { A: { BV1: un() } }, { A: ["BV9"], B: ["BV1"] }, { A: { BV1: un(true) } }], + ["taken out of the last other folder", { A: { BV1: un(true) } }, { A: [], B: [] }, { A: { BV1: un() } }], + ["its own folder does not count", { A: { BV1: un() } }, { A: ["BV1"], B: [] }, {}], + ["unchanged is not rewritten", { A: { BV1: un(true), BV2: un() } }, { B: ["BV1"] }, {}], + ["a folder with no full list keeps elsewhere", { A: { BV1: un(true) } }, { B: [], C: null }, {}], + ["…but one hit is enough", { A: { BV1: un() } }, { B: ["BV1"], C: null }, { A: { BV1: un(true) } }], + ["保留 untouched", { A: { BV1: keep } }, { B: ["BV1"] }, {}] + ]; + for (const [why, byFolder, lists, want] of rows) assert.deepStrictEqual(plain(t.refreshElsewhere(byFolder, lists)), want, why); + } + // C39: saveSnapshot refreshes the other folders' records (稍后再看's too), in storage and on the page; 稍后再看's list is no favorite. + { + const was = { store: structuredClone(store), S: { ...t.S } }; + for (const k of Object.keys(store)) delete store[k]; + t.S.folders = t.S.allFolders = [{ id: "A" }, { id: "B" }, { id: "toview" }]; + const un = (elsewhere) => ({ action: "unfav", at: 1, aid: 1, title: "t", ...(elsewhere && { elsewhere }) }); + store[t.K.snapshot("B")] = { bvids: ["BV2"] }; + store[t.K.snapshot("toview")] = { bvids: ["BV3"] }; + store[t.K.decisions("A")] = { BV1: un(), BV2: un(true), BV3: un() }; + store[t.K.decisions("toview")] = { BV1: un() }; + openFake("A", [], structuredClone(store[t.K.decisions("A")])); + await t.saveSnapshot("B", [item(1)]); + assert.deepStrictEqual(plain(store[t.K.decisions("A")]), { BV1: un(true), BV2: un(), BV3: un() }, "in B now, out of B now, only in 稍后再看"); + assert.deepStrictEqual(plain(store[t.K.decisions("toview")]), { BV1: un(true) }); + assert.deepStrictEqual(plain(t.S.decisions), plain(store[t.K.decisions("A")]), "the open folder's records follow"); + for (const k of Object.keys(store)) delete store[k]; + Object.assign(store, was.store); + Object.assign(t.S, was.S); + } + // 已出分拣范围 renders the normal card: a line under the title with the origin chip and when / why it left, the select box and 清理, no 保留 / 取消收藏. { openFake("removed", []);