Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,21 @@
- YouTube 和 B站 分 P(P2 起)在摘读记录或侧边栏写入 Obsidian 时一样:没带视频页的笔记(视频页不在当前标签页、从记录打开的对话),后台先找开着这一 P 的标签页(同一个视频的别的 P 不算,稍后再看页正放着这一 P 的算),找到就读那里的笔记,写完整的视频笔记,路径和在视频页上写的一样。找不到、也还没有笔记时,先问「视频页没有打开」:「打开视频页再写」(默认,后台开一个不抢焦点的标签页,先静音再载入视频页,不会出声,读到笔记后写,读完不管读没读到都关掉它,本来就开着的不关;读不到就红字写原因、什么都不写:B站 打开的是别的 P(它会跳到上次看的那 P)或别的视频时写「打开的视频页不是这个视频的 P2」,页面没给出笔记写「读取视频失败:视频页没有给出笔记」,不写「请先点「刷新」」,那个页面上没有「刷新」)、「只写 AI 笔记」、「取消」。B站 P1 不用打开视频页就能生成笔记,不问。为什么:和网页同一个问题,直接写【AI笔记】,之后在视频页写又会多一篇。
- 写入 Obsidian 在每个入口都一样(弹窗、侧边栏、摘读记录页、「逐个视频笔记」):写过一次的视频一直写到那篇笔记,文件名带日期也不另写一篇;笔记已存在时弹窗、侧边栏和摘读记录页都先问「整篇覆盖」「只更新 AI 问答」或「取消」,取消就什么都不写;B站 视频已失效时库里那篇重建不了,选「整篇覆盖」红字写「视频已失效,整篇覆盖无法重建」、什么都不写,不改写成单独的 AI 笔记(同名的【AI笔记】可能是用户改过的),「只更新 AI 问答」照常;确认框开着时换了对话或视频也不写,写「对话已切换,未写入 Obsidian。」「视频已切换,未写入 Obsidian。」,不算失败、不用红字(侧边栏的复制和下载也一样:「对话已切换,未复制。」「对话已切换,未下载。」);「逐个视频笔记」不问,按「覆盖已有笔记」决定。关掉「附带 AI 问答」时,侧边栏和摘读记录页照样写视频笔记(网页笔记),只是不带 AI 问答,也不另写一篇 AI 笔记;「视频页没有打开」「网页没有打开」里不给「只写 AI 笔记」;已有笔记又没开页面时,没有能改的,直接写「AI 问答没有变化」。为什么:这个开关只管笔记里带不带对话,不该让「写入 Obsidian」写不出视频笔记(2026-10-22 用户定,以前关掉时只写单独的 AI 笔记)。摘读记录页写的是这个视频最新的一段对话,和侧边栏一样。为什么:同一种情况每个入口都一样。
- 「复制 Markdown」「下载 .md」「写入 Obsidian」三个按钮在每个入口给的是同一篇笔记:写入 Obsidian 写的那篇视频笔记或网页笔记,「AI 问答」一段按「附带 AI 问答」设置。哪段对话:侧边栏用屏幕上这段(用户正看着的),摘读记录页、弹窗和视频页用这个视频或网页最新的一段,和侧边栏打开这个视频时接上的是同一段:同一个网页(网址不算 #,旧版存的 `url:` 键也算)、同一个视频的同一 P(cid 对上,或第几 P 对上:换过 cid 的、存的时候还不知道 cid 的都算),取最新的(`MdgSites.isConversationOf`)。为什么:以前笔记只认 cid 完全相同的那段,侧边栏还认同一 P 的,两边会给出不同的对话。页面没开、笔记又要页面才能生成时(YouTube、B站 分 P、网页,没有标签页开着它),复制和下载也问「视频页没有打开」「网页没有打开」,按钮名跟着动作:「打开视频页再复制」「只复制 AI 笔记」,下载同理;关掉「附带 AI 问答」时不给「只…AI 笔记」。开着 Obsidian、库里已经有这篇笔记时,不开页面也能导出:就是库里那篇换上这次的 AI 问答,和新生成的一样:关掉「附带 AI 问答」时库里那段旧问答也去掉,封面换回图片原地址(写入时记下的)。摘读记录页选中多条打包下载时不弹框、不开标签页:要页面才能生成的那几条放【AI笔记】,状态行写「N 条没打开页面,放的是 AI 笔记」;生成失败的(B站 读不到这类)也放【AI笔记】,另写一句。为什么:三个按钮排在一行,以前复制和下载给的是只有对话的【AI笔记】,和写入的不是一篇(2026-10-22 用户定)。
- 三个出口的结果在每个入口说同一句(`MdgErrors.TEXT`,经 `MdgNoteActions`):复制是「已复制 Markdown」;下载一篇是「已下载 .md」(弹窗、侧边栏、摘读记录页、「一篇摘录」都是这句,以前弹窗写「已下载 .md」、摘读记录页写「已下载 1 条记录」、侧边栏什么都不写),打包多条的 .zip 照旧写条数;写入是后台拼的那句。在「网页没有打开」「视频页没有打开」里取消写「已取消复制」「已取消下载」「已取消写入 Obsidian」,不写「原笔记未被覆盖」:这时还没有笔记;只有「该笔记已存在」里取消才写「已取消写入 Obsidian,原笔记未被覆盖。」。笔记里什么都没有(没有对话、AI 判断和备注)时按动作说:「没有可复制的内容」「没有可下载的内容」「没有可写入 Obsidian 的内容」,红字、不给重试。关掉「附带 AI 问答」时「该笔记已存在」说「「附带 AI 问答」已关。」,不说「没有 AI 问答可更新」:对话可能是有的。「只写 AI 笔记」的说明写「单独写一篇」,不用「存」(术语表)。为什么:同一种情况每个入口一样。
- 三个出口的结果在每个入口说同一句(`MdgErrors.TEXT`,经 `MdgNoteActions`;复制和下载做成了时按钮上写「已复制」「已下载」,这句给读屏,见「通用交互」的提示位置):复制是「已复制 Markdown」;下载一篇是「已下载 .md」(弹窗、侧边栏、摘读记录页、「一篇摘录」都是这句,以前弹窗写「已下载 .md」、摘读记录页写「已下载 1 条记录」、侧边栏什么都不写),打包多条的 .zip 照旧写条数;写入是后台拼的那句。在「网页没有打开」「视频页没有打开」里取消写「已取消复制」「已取消下载」「已取消写入 Obsidian」,不写「原笔记未被覆盖」:这时还没有笔记;只有「该笔记已存在」里取消才写「已取消写入 Obsidian,原笔记未被覆盖。」。笔记里什么都没有(没有对话、AI 判断和备注)时按动作说:「没有可复制的内容」「没有可下载的内容」「没有可写入 Obsidian 的内容」,红字、不给重试。关掉「附带 AI 问答」时「该笔记已存在」说「「附带 AI 问答」已关。」,不说「没有 AI 问答可更新」:对话可能是有的。「只写 AI 笔记」的说明写「单独写一篇」,不用「存」(术语表)。为什么:同一种情况每个入口一样。

## 二、产品决策

### 通用交互

- 一个按钮开一个弹窗,弹窗标题和按钮同名,不在弹窗里放切换。「标签管理」只管标签,「✦ AI 打标签」只管 AI 打标签;要从一个去另一个,用文字链接。
- 按钮点了没用时就灰掉,并且要说明原因:写在同一行,或者写进 tooltip 和 `aria-description`。不要做成能点、点了却弹「没有东西」。例外:没配 AI 时的 AI 按钮、没登录或断网时的刷新照样能点,因为点了会给出「去设置 / 去登录」,比灰掉更有用。AI 按钮不因为没配平台而隐藏:分拣台的弹横幅;专注模式和弹窗的「AI 总结」打开侧边栏,侧边栏给「去设置页添加平台」。唯一的例外是播放器上的 ✦ AI 按钮:没配平台时不显示,因为它出现在每个视频上,没用 AI 的人不该被打扰;打开专注模式说明正想读这个视频,那里的 AI 按钮照常显示。「标签管理」的「添加」和「分组管理」的「新建」在名字空着、重名或标签到上限时灰掉写原因,边输入边更新,回车也照它(和侧边栏快捷追问的「添加」一样);收藏夹的标签到上限时,标签面板不列「新建「…」」,列表空着时写上限那一句。
- 一次操作只出一处提示,按下表放(2026-11-14 用户定)。同一种情况每个入口一样:复制和下载在每个入口都写在按钮上,不只改一个入口。

| 情况 | 提示放哪 | 例子 |
|---|---|---|
| 点了一个看得见的按钮,结果只是「做完了」 | 写在这个按钮上(「已复制」「已下载」),约 1.5 秒后变回原样;按钮宽度不变,读屏念完整的那句 | 复制 Markdown、下载 .md、下载字幕 |
| 一次性成功,但没有对应按钮、或由快捷键触发、或一次处理多条、或结果文字带额外信息(写到了哪个路径、只更新了 AI 问答、带警告) | 入口自己的几秒后消失的提示:摘读记录和分拣台用底部提示条,侧边栏用它的提示,弹窗没有提示条所以留在状态行,视频页用它的页面提示 | 写入 Obsidian 的结果、备注已保存、已删除、设置已保存、已授权、已下载 N 条记录、「已取消复制」这类没做但不算失败的 |
| 进行中、失败、需要用户接着做事的提醒 | 留在原来固定的位置,不自动消失 | 正在打包…、保存失败[去设置]、「AI 打标签已完成,按 I…」 |
- 「去设置」「打开设置页」在每个入口(弹窗、视频页、分拣台、关注、侧边栏、摘读记录页)都切到已经开着的设置页,没开才新开一个(`chrome.runtime.openOptionsPage`)。为什么:同一种操作每个入口都一样,点几次也不会攒出一排设置页。
- 要跑几分钟的任务(标题粗看、字幕细看、关注刷新)除了第 2 行替代「刷新」的进度胶囊,还在第 3 行右端用主按钮写「暂停粗看 N/M」「暂停细看 N/M」「暂停刷新 N/M」,正在处理的那张卡片标「…中」(分析中…、查投稿中…)。胶囊写「标题粗看中 N/M」「字幕细看中 N/M」,重做时写「按新标准重新粗看 N/M」「按新标准重新细看 N/M」;在别的收藏夹里跑完时提示「「X」标题粗看完成 N 个」,暂停或出错停下时写「「X」字幕细看已暂停,完成 N 个」「…出错停下,完成 N 个」,两步一样。几秒就完的(收藏夹刷新)只用胶囊。为什么:长任务要注意力,要一眼看到进度;短的不该打扰。
- 进度胶囊和「暂停粗看 N/M」这类按钮上的 N 是已经做完的个数,文字和进度条用同一个数。等限流时每个胶囊都写「‹进度› · B站限流卡住,m:ss 后重试」(AI 平台限流写「AI 平台限流卡住」),进度、收藏夹名和进度条照旧留着,到时重发同一个请求。暂停、停止和切换收藏夹(核对、加载「所有收藏夹」)在请求之间的间隔和倒计时里立即生效;正在发的那个请求照样等它回来,算进做完的个数。为什么:卡住时也要看得出跑到哪了;各处一个说法。
Expand Down Expand Up @@ -303,7 +310,7 @@ AI 打标签的建议在应用或放弃前一直存着,刷新或重开分拣

- 头部和卡片照分拣台:三行合成一张白卡,第 2 行放状态、第 3 行放标签,开头写行名「状态」「标签」;两行各有一个「全部」,只清空自己那一行。数量照分拣台「筛选和计数」算:第 2 行每组的数量不算本组自己的选择,但算别的组、标签和搜索;标签的数量不算标签行自己的选择,但算第 2 行和搜索;各行的「全部」是清空这一行以后的数。星标是第 2 行的一个状态片。「未打标签」「已打标签」和收藏夹、关注一样放在标签行开头、「全部」后面,按记录自己的标签算(分拣台的只读标签不算),2026-11-10 用户定;「✦ AI 刚打的 ×」有的时候放在第 2 行最后。2026-10-22 用户定
- 搜索和分拣台一样按空格分词,每个词都要出现在这条记录里(标题、作者、标签、AI 总结、备注、对话),可以在不同地方。2026-10-22 用户定
- 搜索框在第 1 行右边,提示文字写搜索范围(「在「摘读记录」 · 星标里搜」),开着搜索或筛选时旁边写「N 个结果」,按 / 跳到搜索框。状态文字(「已复制 Markdown」、出错的红字)在第 1 行的计数后面。2026-10-22 用户定
- 搜索框在第 1 行右边,提示文字写搜索范围(「在「摘读记录」 · 星标里搜」),开着搜索或筛选时旁边写「N 个结果」,按 / 跳到搜索框。2026-10-22 用户定。状态文字在第 1 行的计数后面,只写进行中(「正在写入 Obsidian…」)、出错的红字和要接着做的提醒;复制、下载写在按钮上,其他一次性结果(写入 Obsidian 的结果、备注已保存、已删除……)在底部提示条(见「通用交互」的提示位置)。2026-11-14 用户定,以前「已复制 Markdown」也写在状态文字里,过后一直挂着
- 批量操作在选中栏,和分拣台一样有选中才出现:「已选中 N 个 · 清空选中」,选中的被搜索或筛选隐藏时照分拣台写「另有 M 个被筛选隐藏」「都被筛选隐藏」。「全选」在第 2 行右边。2026-10-22 用户定
- 「清空全部 AI 对话」不进选中栏:它不看选中,放在第 1 行搜索框右边,红字无框,和搜索框之间一道细竖线;加了星标的照旧不删。为什么:选中栏里的按钮都对着选中,放一个对着全部的会让人以为只删选中的。2026-10-22 用户定
- 卡片没有勾选框,和分拣台一样点卡片选中或取消,Shift 点选中一段,拖选文字不选中。标题、「查看 N 轮问答」、按钮、自己的标签和备注各管各的,问答里的链接照常打开;分拣台的只读标签不是控件,点它也算点卡片。2026-10-30 用户定
Expand Down
41 changes: 39 additions & 2 deletions extension/download.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Extension-page helpers (need document): saving text as a file, and the 该笔记已存在 dialog of the side panel and
// 摘读记录 (the video page draws its own copy: content.js confirmOverwriteNote).
// Extension-page helpers (need document): saving text as a file, the 该笔记已存在 dialog of the side panel and
// 摘读记录 (the video page draws its own copy: content.js confirmOverwriteNote), and a button's 已复制 / 已下载.
(() => {
if (globalThis.MdgDownload) {
return;
Expand Down Expand Up @@ -134,3 +134,40 @@

globalThis.MdgOverwriteDialog = Object.freeze({ choose, choosePage });
})();

(() => {
if (globalThis.MdgFlash) {
return;
}

const MS = 1500;
const timers = new WeakMap();
const lives = new WeakMap();

// A copy or download button says word (已复制, 已下载) for MS. tokens.css draws the word over the button's own content
// ([data-flash]), which stays in place: the width does not move, and nothing is put back, so a second click while it
// shows only restarts the time. A screen reader does not read a button's text changing, so text, the whole sentence,
// goes to a hidden live region. False when the button is gone (redrawn) or busy: nothing shown, the entry shows text.
function show(button, word, text) {
if (!button?.isConnected || button.getAttribute("aria-busy") === "true") return false;
button.dataset.flash = word;
clearTimeout(timers.get(button));
timers.set(button, setTimeout(() => delete button.dataset.flash, MS));
// Inside an open modal dialog the rest of the page is inert, so the region goes in the dialog.
const host = button.closest?.("dialog") ?? document.body;
let live = lives.get(host);
if (!live?.isConnected) {
live = document.createElement("div");
live.className = "visually-hidden";
live.setAttribute("role", "status");
host.append(live);
lives.set(host, live);
}
// Emptied, then filled on the next turn: a live region added or set to the same words just now is not announced.
live.textContent = "";
setTimeout(() => (live.textContent = text), 50);
return true;
}

globalThis.MdgFlash = Object.freeze({ show });
})();
46 changes: 46 additions & 0 deletions extension/download.selftest.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,52 @@ const D = ctx.MdgOverwriteDialog;
const text = (d) => d.innerHTML.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
const last = () => dialogs[dialogs.length - 1];

// MdgFlash: a copy or download button says 已复制 for a moment, on a clock the test drives.
{
let now = 0;
const timers = new Map();
let nextId = 1;
const tick = (ms) => {
now += ms;
for (const [id, t] of [...timers].sort((a, b) => a[1].at - b[1].at)) if (t.at <= now && timers.delete(id)) t.fn();
};
const added = [];
const doc = {
body: { append: (n) => added.push(Object.assign(n, { isConnected: true })) },
createElement: () => ({ attrs: {}, textContent: "", setAttribute(k, v) { this.attrs[k] = v; } })
};
const fctx = vm.createContext({ document: doc, setTimeout: (fn, ms) => (timers.set(nextId, { fn, at: now + ms }), nextId++), clearTimeout: (id) => timers.delete(id) });
fctx.globalThis = fctx;
vm.runInContext(read("download.js"), fctx);
const { show } = fctx.MdgFlash;
const button = (attrs = {}) => ({ isConnected: true, dataset: {}, getAttribute: (k) => attrs[k] ?? null });
const b = button();
assert.strictEqual(show(b, "已复制", "已复制 Markdown"), true);
assert.strictEqual(b.dataset.flash, "已复制");
tick(50);
const live = added[0];
assert.deepStrictEqual([added.length, live.attrs.role, live.textContent], [1, "status", "已复制 Markdown"], "a screen reader hears the whole sentence");
// A second click while it shows: the time starts again, and the word never becomes the button's own content.
tick(1000);
show(b, "已复制", "已复制 Markdown");
tick(1000);
assert.strictEqual(b.dataset.flash, "已复制", "still showing 1 s after the second click");
tick(500);
assert.strictEqual(b.dataset.flash, undefined, "back to its own content");
assert.strictEqual(added.length, 1, "one live region for the page");
// Busy (setBusy's 「…中」) or redrawn away: nothing shown, false, so the entry shows the sentence its own way.
const busy = button({ "aria-busy": "true" });
const gone = Object.assign(button(), { isConnected: false });
assert.deepStrictEqual([show(busy, "已下载", "已下载 .md"), busy.dataset.flash, show(gone, "已下载", "已下载 .md"), show(null, "已下载", "x")], [false, undefined, false, false]);
// A busy button that is idle again flashes.
assert.strictEqual(show(button({ "aria-busy": "false" }), "已下载", "已下载 .md"), true);
// In an open modal dialog (一篇摘录) the page behind is inert, so the live region goes inside the dialog.
const inDialog = [];
const dialog = { append: (n) => inDialog.push(Object.assign(n, { isConnected: true })) };
show(Object.assign(button(), { closest: () => dialog }), "已复制", "已复制 Markdown");
assert.deepStrictEqual([inDialog.length, added.length], [1, 1]);
}

(async () => {
// Three kinds of note, one set of words; the default (autofocus) button is never 整篇覆盖.
D.choose("a.md", { hasAiSection: true });
Expand Down
4 changes: 4 additions & 0 deletions extension/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@
copyMd: "复制 Markdown",
downloadMd: "下载 .md",
writeObsidian: "写入 Obsidian",
// What a copy or download button says for a moment after it worked (MdgFlash); the whole sentence is TEXT.copied /
// TEXT.downloaded.
flashCopied: "已复制",
flashDownloaded: "已下载",
toSettings: "去设置",
// PAGE_GRANT's button (action grantPage): the side panel and the popup ask for every http(s) site (grantPages).
grantPage: "允许读取网页",
Expand Down
Loading
Loading