diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e4589c94..6b54005f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,7 +10,7 @@ B站 页面上的颜色是 `tokens.css` 的第三份拷贝:`content.css` 里 - 打标签面板只有一个:`extension/triage/tag-picker.js`。例外:B站 页面上的「+」标签面板(`badges.js`)在封闭的 shadow DOM 里,用不了 tag-picker.js 和扩展页的样式,样子和键盘照它另写一份;标签的纯函数(颜色、标签名清洗、新标签 id、下一个颜色、面板的行、编号、顺序和方向键)放在 `extension/tag-core.js`(`MdgTagCore`)里,`badges.js`、`shared.js`、`follow.js` 和 `fav-*.js` 都用它,manifest 和 `triage.html` 都在它们之前加载。`tag-core.selftest.js` 会查别处有没有再写一份。「标签管理」和「✦ AI 打标签」弹窗也各只有一个:`extension/triage/tag-dialogs.js`;关注的「分组管理」也用「标签管理」这个弹窗,adapter 换掉标题、文字和每行的样子。模式打开它们时只传一个 adapter,里面是单位(视频 / UP 主 / 记录)、上限和数据,文字、布局和确认页的计数(改动数、按标签汇总、「用在 N 个」)都在弹窗里。摘读记录页也用这三个(打标签面板、「标签管理」、「✦ AI 打标签」):页面里只放空的 ``,「✦ AI 打标签」的结构由 `tag-dialogs.js` 写进去,两个页面共用一份;摘读记录没有 T,确认页和「AI 刚打的」提示里「不对的按 T 改」那半句由 adapter 的 `check` 换掉。 - 标签的数据操作只有一份:`extension/triage/tag-store.js`(`MdgTagStore`),`triage.html` 和 `history.html` 都加载它。新建、改名、换颜色、改说明、删除(确认框的个数)、给条目加减标签、上限和原因、AI 的新建名额(`room` / `noRoom`)、U 的步骤(`pushStep` / `undoLast`,收藏夹的保留和改 B站 的步骤也走这两个)、「✦ AI 打标签」的批次(`runAi`)和「应用」(`apply`:先写,写成了才清建议、标「AI 刚打的」、压 U;写失败留着建议、红字说)都在里面。每个页面 `MdgTagStore.create(adapter)` 一次,adapter 只说不一样的:数据在哪(三种后端:收藏夹 `local`,在 `MdgLimits.keyLock(triage_tags)` 的锁里现读存储再改、经页面自己的 `store` 写回,页面里那份只是副本,后台替 Agent 写标签(`agent-writes.js`)也用这把锁;关注 `follow`,`MdgLimits.updateFollow` 的锁;摘读记录 `marks`,`MdgLimits.updateReadingMarks`,星标也走它的 `update`)、条目(哪些 `live`;关注的已取消关注算进个数 `uses`,U 不改他们 `restorable`)、作用域(收藏夹按收藏夹,`scoped`)、上限和超了怎么办(`hardCap`:摘读记录给出「摘读记录的标签」,超了不建并在确认页说;收藏夹和关注不给,照建)、名词。删掉一个标签(标签管理、U 删掉没人用的新标签)都调页面的 `tagGone`,收藏夹在别的标签页删了标签时(`followShared`)也走它;关注的后端在删标签的同一次写里把它从已取消关注 UP 主的 `follow_unfollowed` 记录上去掉。`conventions.selftest.js` 的 5c 查页面文件不直接写标签键、不自建撤销栈。B站 页面(`badges.js`)和后台不用它,用 `tag-core.js` 的 `applyUpTag`;Agent 经 MCP 打的标签由 `agent-writes.js` 在上面那把锁里写。 - 标签和这些弹窗的样式只有一份:`extension/triage/tags.css`(标签片、打标签面板、「标签管理」「✦ AI 打标签」和它们所在的 ``),`triage.html` 和 `history/history.html` 都在自己的样式表之后加载它。`` 的规则写成 `dialog:where(:not(.mdg-overwrite))`,和原来的 `dialog` 一样具体,又不碰 `tokens.css` 管的「该笔记已存在」。挪进来时分拣台每个元素的计算样式一项不差(页面、三个弹窗和分拣台的其他弹窗逐个核对过);摘读记录的打标签面板和「标签管理」因此和分拣台一样了。 -- 摘读记录页的头部和卡片用分拣台的样子:`history.html` 在 `history.css` 之前加载 `triage/triage.css`,头部的行名、筛选片、`.act-btn`、全选、搜索框、选中栏、`.card`、`.badge` 和空状态都用它的规则和 `TriageUi` 的标记(`labeledRow`、`stateGroup`、`filterBtn`、`aiRecentChip`、`tagFilterChip`、`tagButtons`、`gearBtn`、`selectAllBox`、`selbar`、`actBtn`、`searchBox`、`bindSearch`、`resultCount`、`cardTagChip`、`emptyState`、`noMatch`);`history.css` 只写页面的排法、头部白卡和卡片里的列。分拣台的头部靠四行各自的 `::before` 拼成白卡(列表从底下滚过),摘读记录是窗口滚动、头部不收起,所以白卡就是一个 `
` 的边框,不用那套拼法。改 `triage.css` 里这些规则时两个页面一起看。 +- 摘读记录页的头部和卡片用分拣台的样子:`history.html` 在 `history.css` 之前加载 `triage/triage.css`,头部的行名、筛选片、`.act-btn`、全选、搜索框、选中栏、`.card`、`.badge` 和空状态都用它的规则和 `TriageUi` 的标记(`labeledRow`、`stateGroup`、`filterBtn`、`aiRecentChip`、`tagFilterChip`、`tagButtons`、`gearBtn`、`selectAllBox`、`selbar`、`actBtn`、`searchBox`、`bindSearch`、`resultCount`、`cardTagChip`、`emptyState`、`noMatch`),底部提示条也是同一份 `TriageUi.toast`(`#toast`,样式 `.toast`;收藏夹的 `toast` 和关注借的 `MoonTriage.toast` 都转给它);`history.css` 只写页面的排法、头部白卡和卡片里的列。分拣台的头部靠四行各自的 `::before` 拼成白卡(列表从底下滚过),摘读记录是窗口滚动、头部不收起,所以白卡就是一个 `
` 的边框,不用那套拼法。改 `triage.css` 里这些规则时两个页面一起看。 - 所有输入框和快捷键都要走 `extension/typing.js`(`MdgTyping`): - 回车和 Esc 的处理函数,开头先写 `if (MdgTyping.composing(e)) return;`。 - 全局快捷键,开头写 `if (composing(e) || typingIn(e)) return;`。 @@ -35,7 +35,7 @@ B站 页面上的颜色是 `tokens.css` 的第三份拷贝:`content.css` 里 - 写入 Obsidian 只有一个写入者:后台的 `extension/obsidian.js`(`background.js` 在 `triage-bg.js` 之前 `importScripts` 它)。读写库(Local REST API)、错误分类(`obsidianError`)、把封面存进库、记住每个视频写到哪篇笔记都在这里,页面不碰库。视频页、侧边栏、摘读记录页和「逐个视频笔记」都发同一条消息 `write-video-note { ref, mode?, conversation?, page? }`,同名函数 `writeVideoNote` 走一条流程:设置(没开、没配置、Key 不对是 `OBSIDIAN`,没打开是 `OBSIDIAN_DOWN`)→ 路径(先用记住的路径,没有再用 `MdgNote.videoNotePath`。记住的路径按 `MdgSites.buildContextKey` 存,键里带 cid,也记下分 P 和存进库的封面(`cover { url, link }`);「逐个视频笔记」只知道 BV,按 BV 和分 P 找到 P1 的那篇(`recordedNoteKey`,只按 BV 前缀会找成同一个视频 P2 的笔记;只有视频键按前缀找,到 `|` 为止,网页键 `page:…/post` 不会找到 `page:…/post-2`),所以不勾「覆盖已有笔记」时只更新 AI 问答、不再读 B站,结果里也不带字幕提示;记分 P 之前写的那篇读一次 B站 后补上。AI 笔记用 `MdgNote.conversationNotePath`)→ 正文(有 `page` 用 `page.markdown`,结果开头的字幕提示用视频页给的 `page.warning`,和后台生成时同一句:`MdgNote.noteSubtitleWarning`;没有就用 `triageBuildNote` 生成 B站 P1 笔记;YouTube、分 P 没带 `page` 时先找开着的视频页(见本条末尾),都没有时记住的笔记还在就照样问,回复带 `fullReason`,确认框把「整篇覆盖」灰掉、原因写「整篇覆盖要先打开视频页」,只能「只更新 AI 问答」;没有记住的笔记时先回 `askPage`;选了「只写 AI 笔记」或已失效、没有记住笔记的视频时用户自己写的对话,写成单独的 AI 笔记;已失效、有记住的笔记时和没开页面一样带 `fullReason`,`full` 报「视频已失效,整篇覆盖无法重建」、什么都不写;关了「附带 AI 问答」时不写单独的 AI 笔记,`askPage` 带 `own: false` 不给这个选项)→ 读一次库 → 回 `ask { path, hasAiSection, aiOff? }`(`aiOff`:关了「附带 AI 问答」,确认框照实说,不说没有 AI 问答)、`ok { path, kind, text }` 或 `failed { error, code }`。`mode` 不给时笔记已存在就回 `ask`;`full` 整篇写(AI 问答段换成这次的对话、封面存进库、一次 PUT、记住路径);`ai` 只换 AI 问答段,笔记不存在时整篇写。结果那句话(字幕提示 + 已写入 Obsidian / 已更新 AI 问答 + 路径)只在这里拼。取消靠结构:页面收到 `ask`、用户取消,就不发第二条,什么都不写。「逐个视频笔记」不带对话,按「覆盖已有笔记」发 `full` 或 `ai`,所以从不问。侧边栏每次回答完发 `sync-video-note-ai`,只更新记住路径的那篇,从不新建;回来的「已更新 AI 问答:路径」和笔记已不在时的「Obsidian 里的视频笔记已不存在,未写入」也在这里拼(笔记 404 时先探测一次库根,地址对才算笔记不在、忘掉记住的路径,地址错报地址错),侧边栏照显示 `text`。`obsidian-sync.selftest.js` 用假的 Local REST API 测这条流程,并查页面里没有别的库请求、`resolveFolderTemplate(` 只在 `note.js`、`obsidian.js` 和 `triage-export` 里。开跑前的检查(`triageObsidianCheck`)和设置页的「测试连接」共用一个探测 `probeObsidian`,回答不是带服务名(`service`)的 JSON 的地址不算 Local REST API;「一篇摘录」和索引(`triage-export`)直接写。它们都用 `obsidian.js` 的读写和错误分类,所以 404 在哪条路上都是 `OBSIDIAN`。网页和后台生成不了笔记的视频(YouTube、B站 分 P)的 `write-video-note` 没带 `page` 时(摘读记录、侧边栏里页面不在当前标签页),`writeVideoNote` 先用 `background.js` 的 `tabNote` 在开着的标签页里找:网页比网址(不算 #),经 `popupPageNote` 读正文;视频比站点、视频 ID 和第几 P,稍后再看页按 oid 对 cid(`MdgSites.sameContext`,侧边栏和摘读记录的「继续问」也用它),向那个标签页的主框架发 `popup-refresh`,回来的 `markdown`、`cover`、`noteWarning` 当 `page`,`noteRef`(内容脚本的 `noteRef()`,视频页自己写时也用它)换掉传来的 `ref`,所以路径和记住路径的键(带 cid)都和在视频页上写的一样。找不到又没有记过的笔记,回 `{ askPage: true, url, video? }`(视频的 `url` 是 `MdgSites.videoUrl`),调用方(经 `MdgNoteActions`)用 `MdgOverwriteDialog.choosePage(url, video)` 问过后带 `pageChoice` 再发:`open` 由后台开一个不抢焦点的标签页读完再写(视频页先开空白页,静音和载入在同一次 `tabs.update` 里,不会先出声),新开的视频页内容脚本还没就绪时重试,读不到笔记就报原因;这个新开的标签页读完就关(成功失败都关,`finally`),本来开着的不关;`own` 照旧写【AI笔记】。复制 Markdown 和下载 .md(侧边栏、摘读记录页)发 `export-note { ref, conversation, page?, pageChoice? }`,`obsidian.js` 的 `exportNote` 和 `writeVideoNote` 共用 `resolveNote`(先读标签页、选对话、选目标笔记、要不要 `askPage` 都在这里),只是不写库:回 `{ filename, markdown }`,和 `full` 写进去的一样(封面不存进库,留远程地址);Obsidian 没开或连不上也能导出,读不到记住的笔记就当没有。记住的笔记在、又没有页面时,回库里那篇按新导出的规则改:AI 问答段按「附带 AI 问答」换上这次的或去掉(`withAi`,写入也用它),封面链接按记住的 `cover` 换回原地址(`unlinkCover`)。`pageChoice: "own"` 在关掉「附带 AI 问答」时也收(摘读记录页打包时每条都带它,回 `noPage: true` 的是没打开页面的【AI笔记】)。确认框 `MdgOverwriteDialog.choosePage(url, video, own, verb)` 的 `verb` 是「写」「复制」「下载」。复制在对话框和开标签页之后才写剪贴板:扩展页面里 `navigator.clipboard.writeText` 只要页面有焦点就能写,不要求刚点过(无头 Chrome 里用没有用户激活的合成点击,过了对话框和后台开标签页照样写进剪贴板)。 - 处处都用的纯函数和常量在 `extension/common.js`(`MdgCommon`):本地日期(`localDate`,导出文件名、笔记的文件名和 `created`、分拣台的 `fmtDate`、侧边栏的对话时间都用它,`withTime` 加时分)、文件名清理(`fileName`:Obsidian 不认的字符换成 `_`、空白合成一个空格、去掉开头的点(Obsidian 不收点开头的文件,文件夹模板的每一层也去,`resolveFolderTemplate`)、最多 120 字(按字符数,不会切开 emoji),笔记、弹窗下载、分拣台导出都用它)、时分秒解析(`parseTimestamp`,不合法回 -1,调用方决定怎么算)、等一会儿(`sleep`,弹窗、侧边栏、后台和 `MdgSites.waitTabLoaded` 用;`content.js` 的 `sleep` 留着,测试要替换它)、计数文字解析(`parseCount`,YouTube 和 B站动态的「1.2K」「12.5万」「1,234」)、扩展页面路径(`PAGES`,给 `chrome.runtime.getURL`;html 里的 `` 写相对自己的路径)、YouTube 字幕语言(`SUBTITLE_LANGS`,设置页的下拉项由它生成)、请求专注模式的网址参数(`READER_PARAM`)、侧边栏聊天端口名(`CHAT_PORT`)。只放不依赖别的文件、没有状态和副作用、至少两个文件用的东西:要读站点表的(视频上下文转网址 `MdgSites.videoUrl`)留在 `sites.js`,只有一个文件用的留在那个文件。它是每个运行环境第一个加载的文件:每个页面的第一个 `