` / txt 末尾空行后)。 - 导出选项:封面、章节间分页、生成目录、包含书名与作者;「包含书名与作者」的默认值在设置里。 ### AI 校对与一键排版 - 可选任意 Harness 已配置 provider 与模型(`llm.listProviders()` + `listModels()`,默认取当前 agent 默认模型)。 下拉永远有选项:若当前默认模型不在 `listModels()` 返回里(部分 provider 不暴露列表),会把它作为「(当前默认)」补进第一项。 - 校对按 4000 字分块串行请求,定位方式为「模型返回原文 → `indexOf` 对齐 + 块偏移」,并用 `text.slice(start,end)===original` 二次校验,对不齐的建议直接丢弃。 - **模型失败自动回退(R34)**:一次校对按「设置里选的模型 → Harness 当前默认 → 列表其余」逐个尝试, 只有全部失败才停。中途失败会在面板上写一行「换用 X 重试…」,最终换了模型时提示「原模型没有响应,已改用 X」, 并把实际使用的模型记进结果(`usedModel` / `failovers`)。用户主动取消(`err.code === 'aborted'`)**不会** 触发换模型——取消是意图,不是故障。修改建议栏与投稿助手共用同一套逻辑。 - **注意**:`llm.stream()` 不能传顶层 `system` 字段(宿主 pi 适配器会对字符串 `content` 调 `.some(...)` 崩溃)。本插件把系统提示词折进 user 消息的 text block。 - 建议逐条勾选,支持单条接受 / 拒绝 / 全部接受 / 全部拒绝;**先预览 diff 再应用**,`applyEdits` 从右往左写入,不覆盖未接受的原文。 - 一键排版:统一省略号与破折号、中英文标点、引号、段落两字全角缩进、空行折叠、章节标题格式、对话分段。同样先出 diff 预览,`changed === 0` 时禁用应用。 ### 修改建议栏(R12 ② 新增) 右下坞 `✦ 修改建议` 打开一块**与正文左右并列**的评论栏(`NW.Advice`,默认宽 336px,可拖宽 260–600px, 开关与宽度都记在 `localStorage`)。它不是替换工具,而是「第二读者」: - 点「开始审读」后,Host 把当前章按 4000 字分块送模型(`lib/ai.js` 的 `revise()`),要求返回 `{quote, issue, advice, kind}` 四元组,`kind` 限 节奏 / 用词 / 视角 / 画面 / 对话 五类,每章最多 8 条。 - 面板顶部一排「重点」分段控件(全部 / 节奏 / 用词 / 视角 / 画面 / 对话),换重点会重新审读。 - 每条建议显示「原句 → 问题 → 怎么改」;**点一条会把编辑器滚到那一段并高亮**(`setHighlights`), 面板底部有「重新审读」与「取消高亮」。切章自动清空,避免建议引用屏幕上已经不在的文字。 - **对照阅读(R23)**:打开建议栏会**自动收起左侧目录**——三栏并排时纸只剩 ~550px,建议与原文就会变成 上下堆叠;关掉建议栏时**只在原本是打开的情况下**恢复目录,不会把用户手动隐藏的目录又翻出来。 点一条建议时,原文那句由新 API `centerRange(start, end)` **精确滚到屏幕上下中间**(不再用比例近似), 建议卡也滚到同一条水平带(实测两条中线差 0–20px)。 - **自动宽度(R25)**:栏宽不再是固定的 336px,而是 `flex:1 1 300px` 吃掉正文用不完的宽度、**紧贴纸面** (宽屏实测 560px,只剩页边距 16px 的死白);同时纸面停止生长、钉在设定页宽,两块作为整体居中。 拖左边缘的把手可手动定宽(300–640px),**双击或按 Home 回到自动**;自动模式的 560px 上限不会钳制 手动宽度(`.nw-suggest[data-manual="1"]` 把上限放到 640px),否则拖过 560 会看起来毫无反应。 窄面板(≤780px)下自动宽度收到 `min(320px,42%)`,不抢正文。 - **双向同色下划线(R23)**:建议卡里的引用不再用左侧竖条,改成 2px 品牌色下划线;原文对应句同步换成 同一根下划线(底色从 55% 降到 30%,保证字读得清)。两边都取 `--nw-brand` 而不是宿主 alias,避免色差。 - 定位方式与校对一致:模型给的 `quote` 用 `indexOf` 回原文对齐,对不齐的条目标「原文已改动,定位不到」, 仍然显示意见但不做高亮——宁可少给一次跳转,也不跳到错的段落。 - **模型回退(R34)**:与校对同一条路径(`NW.ai.reviseSafe`)。审读失败先自动换候选模型重试,全失败才在栏内 弹「换个模型重试」选择器;错误直接显示在栏里,不会每失败一次就弹一次 toast。 ### 投稿助手(R8 三步 + 智能推荐,R12 直达预览) 右下坞 `✉ 投稿` 打开三步向导(`NW.dialogs.Submit`,宽 820px):**先选小说 → 再选期刊 → 邮件预览**。 选定期刊点「下一步」就直接落到预览页:进入第 3 步的 effect 会立刻跑 `submit.prepare`, 模型按该刊征稿要求写好收件人、主题、正文与附件,作者只需要**审阅并改**,不用再点「生成」。 1. **① 选小说**:小说列表(默认选中当前正在写的那本),右侧列出各章字数;可按「整本 / 当前章 / 自定义勾选章节」定稿。 换了小说,体裁推荐与邮箱判定都会跟着重算。 2. **② 选期刊**:期刊库 **79 家**(人民文学 / 收获 / 十月 / 花城 / 当代 / 钟山 / 作家 / 上海文学 / 天涯 / 长江文艺 / 山花 / 芙蓉 / 江南 / 清明 / 北京文学 / 萌芽 / 西湖 …),其中 **58 家带公开收录的投稿邮箱**。 顶部一条**智能推荐**(最多 6 家)。**R27 ③ 起这条推荐由大模型判断**,不再是算术打分: - Host 先用规则排序筛出 12–30 家的候选清单,再把**稿件开头 2400 字**连同每家的体裁、字数区间、 标签、征稿要求一起交给模型;模型逐家给出 `fit`(0–100)、一句 `verdict`、两三句 `reason`, 以及 `quality`(**够格 / 略欠 / 尚不足**)。 - 判断依据按重要性排序:题材与趣味 → 篇幅 → 水准(以该刊层级衡量)→ 栏目;提示词里明确写了 「不要因为有邮箱、字数够就给高分」和「水准不足就如实说不足」。所以它敢说「这稿还不够格」—— 规则打分永远说不出口。 - 卡面标题会如实写**「大模型读过开头,按契合度与水准推荐」**;模型不可用时自动退回规则排序, 标题改回「为你推荐(按字数、体裁与题材匹配)」并附一句原因,**投稿流程不会因此中断**。 - 每行显示期刊名 + 一枚质量徽章,下面一行是模型给的理由。规则分仍保留在返回体的 `rules` 字段里, 只是不再主导排序。 - 右侧详情同时列出**该刊登记的全部收稿邮箱**,命中的那一条标「本稿用这个」并带 `data-on` 高亮。 **R16 起每行还带一枚期刊等级徽章**(国家级 / 省级 / 市级 / 其他),详情面板第一行就是「期刊等级」, 作者判断投稿难度时不用再自己去查。等级由标签推导,属于粗略参考,具体以期刊官方说明为准。 **全部邮箱 `verified:false`**,界面上标「待核对」——请以期刊官方最新征稿启事为准。 3. **③ 邮件预览**:`submit.prepare` 由 Host 生成主题与正文、把稿件调整成期刊要求的格式, 返回 `changes`(章标题 / 空行 / 标点 / 缩进逐条说明)与 `attachments`(按期刊要求生成的附件)。 **收件人、主题、正文、附件名、署名全部可在这里改**,附件行给出「预览 / 下载 / 移除」, 点「查看全文」以只读层展示完整邮件(不截断)。**这一步不会发信。** 4. 点「确认投稿」才真正 `submit.send`,随后写一条投稿记录(`submissions.json`,最近 50 条)。 - **邮件正文不复述征稿要求**(R11):遵守要求体现在信本身(主题格式、附件数量与命名、是否粘贴正文), 在正文里逐条抄录「贵刊要求:1…2…3…」会像模板群发,编辑一眼就能看出来。R11 删掉了 `handleSubmitPrepare` 里「模型漏了哪条就补一条进正文」的逻辑,也把兜底信的 「贵刊的征稿要求我已逐条对照」改写成一句「投稿前已按贵刊要求核对格式与字数」; `SUBMIT_SYSTEM_PROMPT` 里也加了一条硬约束禁止复述。 - **投稿附件是「稿件」而不是网页导出**(R10):docx 走 `manuscript` 模式——页眉右对齐 `书名 · 作者:X`(楷体 9pt 灰)、 正文行距放宽到 1.75 倍并在每章前分页,方便审稿人直接在纸上批注;附件行可下载与编辑部完全一致的字节。 - **安全阀**:只有 ≤ 4000 字才把正文交给模型调整;返回的章数必须一致、每章长度在 0.7×–1.4× 之间,否则丢弃改回纯规则排版;AI 不可用时自动退回规则排版并给出 warning。 - 未配置 SMTP 时点「确认投稿」会留在原页并提示「去配置邮箱」,不会真的发信失败。 - **模型回退(R34)**:`submit.prepare` 失败后会依次换模型重试(顺序同校对),全部失败时弹选择器让你指定 一个模型再试;选择器标题是「AI 投稿」,逐条列出失败原因(最多 6 条)。 ### 投稿管理(R5 新增) `NW.dialogs.SubmitManager`,入口在首页顶部的「投稿管理」按钮(R35 ③ 之前是页脚的「快捷入口」卡)。 按状态筛选(全部 / 已投稿 / 等待回复 / 已过稿 / 已退稿 / 已发表 / 已撤回),每行显示 **小说名 · 期刊 · 投稿日期 · 距今 N 天 · 状态徽章**,可原地改状态(`submit.update`,原子重写 `submissions.json`), 也可「重新投稿」——回填该期刊重新打开向导第 1 步。状态是本机记账,不会联网、不解析回信。 ### 期刊库(R5 新增,R11 支持增删改查) `NW.dialogs.Journals`,入口在首页顶部的「期刊库」按钮(R35 ③ 之前是页脚的「快捷入口」卡)。 79 行列表 + 搜索 + 「只看待核对」;每行显示名称、邮箱(未收录则写「未收录邮箱」)、字数区间与「已核对 / 待核对」徽章; 行内编辑邮箱 / 官网 / 字数区间 / 要求,保存时只写 delta,填了邮箱会自动置 `verified:true`。 内置条目的 `tags / subject 模板 / attachments / paperOnly / responseDays / minWords / maxWords` 也一并落库。 **R11 补齐了「增」和「删」**: - **新增**:工具栏「新增期刊」→ 先填期刊名(其余字段同编辑表单),保存时由 `lib/journals.js` 的 `makeId()` 按名称生成 slug(重名自动加 `-2`),整条作为自定义条目落库。 - **删除**:行内「删除」按钮或编辑表单底部「删除这家期刊」,二次确认后写**墓碑**(`{id, deleted:true}`)。 墓碑是必须的 —— 内置 79 家来自代码里的 `TABLE`,如果只是从 overrides 里移除,下一次 `merge()` 会把它们**复活**, 用户会看到「删了又回来」。 - **改 / 查**:原有能力不变;自定义条目可改全部字段,内置条目只存与内置值不同的字段(`diffFromBuiltin`)。 - 多邮箱仍按「期刊 + 体裁 / 字数区间」分列,编辑器里一行一条:`邮箱 | 体裁 | 字数 | 备注`。 **R16 新增水平等级**:每家期刊多一个 `tier`,取值 `national` / `provincial` / `municipal` / `other` (国家级 / 省级 / 市级 / 其他)。**它不是新加的数据列** —— 79 行内置表每行 12 个位置参数,再加一列既容易漏又难维护, 所以 `tier` 由原有的「国家级 / 省级 / 市级」标签推导(`tierOfTags()`),没标签的一律算「其他」。 当前分布:国家级 6 家、省级 33 家、市级 19 家、其他 10 家。用户仍可在编辑表单里逐刊覆盖(`diffFromBuiltin` 只持久化真正改过的值,否则会把本应跟随标签的推导值冻结住)。 投稿向导里,期刊列表每行带一枚等级徽章,详情面板第一行就是「期刊等级」—— 作者判断一家刊最容易看的就是这个。 ### 数据与隐私 IndexedDB `novel-writer` v2:`novels` / `chapters` / `versions` / `trash` / `settings` / `notes`(R10 新增灵感便签)。 - 自动保存 + 每章保留最近 50 个版本快照(60s 节流 + 手动「立即快照」)。 - 删除进回收站,可单条恢复或清空;删除小说连带其章节与版本。 - 备份:本地 JSON 全量导出 / 恢复(单事务 `clear + put`,失败回滚),恢复后编辑器自动重新载入。 **锁定与加密(两层)**: - 浏览器内:PBKDF2-SHA256(210000 次)派生密钥,正文以 AES-256-GCM 逐段加密落盘(`enc:v1:` 前缀)。 - 有密码 + 冷密钥(刷新 / 重启 DSH)= 强制锁定,**不信任 localStorage 的解锁标记**。 - 存在密码但密钥已冷时,`saveChapterContent` 拒绝任何对密文的覆盖(明文、空串都拒),杜绝「解密失败 → 空串回写 → 正文永久清空」。 - 改密码 / 清密码前先 `decryptAll()` 用旧密钥还原,再重新加密或明文落盘;**改密码必须先验旧密码**(`NW.crypto.changePassword(old,next)`),旧密码错则零状态变更。设置面板是「当前密码 / 新密码 / 清除密码」三个独立输入框。 - 离开超时自动锁定(默认 15 分钟)、`Ctrl+Shift+L` 手动锁定。 - **磁盘镜像加密**:设置 →「自动保存与恢复」里打开「加密保存」,`library.mirror` 会写 `<书名>.docx.nwe` + `library.json.nwe`, 格式 `NWENC1` + base64(`iv[12] || tag[16] || ciphertext`),AES-256-GCM,密钥由 Host `secrets/key.bin` 经 **HKDF-SHA256**(info `novel-writer-mirror`)派生——派生密钥不落盘。 **R16** 起文件格式**只有 `.nwe` 一种**:明文副本的开关、按钮与文案已全部移除(R15 的 `plain` 设置位也从 `settings.json` 里清掉),需要临时导出可读副本时仍在 `
`:投稿回信会逐字引用稿件,重排会破坏编辑所指的换行。
读完后首页的「未读」会刷新 —— 邮件已被服务器标为已读,卡片上那个点会消失。
- 零依赖 IMAP 客户端 `lib/imap.js`(只用 `node:net` / `node:tls`):993 直连 TLS,143 强制 STARTTLS;
自签证书先记录 `certBypassed` 再降级(与 SMTP 同一套错误码与中文提示)。
- **默认沿用发信账号**:`useSmtp` 为真且没填主机时,把 `smtp.qq.com` 推成 `imap.qq.com`、`smtp-mail.*` 推成 `imap-mail.*`,
大多数作者只有一个邮箱,不必再学一套服务器地址。
- **「已关闭」与「连不上」分开显示**:`enabled:false` 时宿主根本不发 IMAP 请求,首页说「收件箱已关闭」并给去设置的入口;
认证失败则原样显示服务器返回的那句话 + 授权码建议,绝不把失败画成「没有回信」。
- **网易 `Unsafe Login`(R11 修复)**:163 / 126 会拒绝没有声明客户端身份的会话 ——
`LOGIN` 成功之后 `SELECT INBOX` 直接回 `NO SELECT Unsafe Login. Please contact kefu@188.com for help`。
旧代码把这段文本里的 `login` 匹配成「认证失败」,把人引去查密码,方向完全错了。
现在 `Session.identify()` 在 `LOGIN` 之后、任何 `SELECT` 之前发送 RFC 2971 的
`ID ("name" "novel-writer" "version" "1.2.0" …)`;命令失败只记 `idStatus` 不抛(ID 是可选命令),
但 `command()` 里新增了 `unsafe login` 的专门分支,命中时返回 `imap-id-required` 而不是 `imap-auth-failed`,
提示语也改成「这是网易对未声明身份客户端的封锁,插件已自动握手;仍失败请把本客户端加入白名单或改用 QQ / Gmail」。
### 邮件服务器自愈(R5)
`lib/smtp.js` 的 `diagnose()` 在「测试连接」时按 **(465 SSL) → (587 STARTTLS) → (25 明文)** 顺序试三种形态,
一处连不通自动换下一个;遇到认证失败立刻停(端口已通,换端口没用)。失败信息统一过 `hintFor()` 翻译成可操作建议
(「多数邮箱不允许用登录密码发信:请先在网页端开启 SMTP,再用生成的授权码」等),并把尝试矩阵回传成
「已尝试 SSL 465✗ / STARTTLS 587✓」显示在设置里。
## 3. 验收状态
> **第三十八轮(R38)**:桌面「上次同步」显示、配对面板内联、手机字号与卡顿、书级同步标记、白天黑夜主题。
> 按要求仍不做真机回归,只跑离线脚本、语法、客户端构建与 APK 重打:
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check` 全量(`lib/*.js` / `client/src/*.js` / `scripts/*.mjs`) | 除 `00-boot.js`、`99-end.js`(bundle 包裹片段,天生不能单独 parse)外全部 exit 0 |
> | **宿主离线测试** | `node scripts/verify-host.mjs` | **174 项,失败 0**(`peerSyncSummary` 只在 LAN 端点与 `peer.apply` 里写入,未加新用例) |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts → 916180 字节**,`verify-client-load: ok` |
> | 语言表 | 扫本轮新增中文串对 EN 词典 | 新增 6 条(`最近一次同步 {0}`、`配对码 {0} 秒后失效`、`未命名设备`、`解除`、`正在读取…`、`最近一次收到手机改动 {0}`)已补 |
> | **APK 重打** | `node tools/build-apk.mjs` | aapt2 → javac → d8 → zipalign → apksigner 全过,**545577 B**(`Skin`、`values-night/` 已进包) |
>
> 没做的:真机上看白天 / 黑夜两套配色(尤其 `AlertDialog` 与 `EditText` 的默认文字色)、A- / A+ 的实际手感、
> 书架同步标签在「电脑端改过、手机还没同步」时的表现、内联配对面板在窄窗口下的换行、`recreate()` 之后
> 编辑器滚动位置(会回到章节开头)。
> **第三十七轮(R37)**:全插件审计(Host / 客户端 / Android)。按要求不做真机回归,只跑离线脚本、语法、
> 客户端构建与 APK 重打:
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check`(`lib/{lansync,secrets,smtp,imap,ai}` 与 `client/src/{10-db,40-ui,50-shelf,60-editor,90-app,95-dialogs,07-locale}`) | 全部 exit 0;Java 由构建里的 `javac` 兜底 |
> | **宿主离线测试** | `node scripts/verify-host.mjs` | **165 → 174 项,失败 0**(原脚本因 `lib/cloud.js` 早已删除而启动即崩) |
> | **合并与串行化** | 同上第 [9] 节新增用例 | 墓碑双向胜负、同毫秒平局归活本、规则 5 洗白、并发 `push` 三本不丢书 |
> | **docx 契约** | `node scripts/verify-docs.mjs` | **44 项全过**(两条过期断言从 12pt/480 改到 14pt/560) |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts → 880561 字节**,`verify-client-load: ok` |
> | 语言表 | 扫本轮新增中文串对 EN 词典 | 新增 5 条(快照失败、校对/排版失效护栏、`当前章`)已补 |
> | **APK 重打** | `node tools/build-apk.mjs` | aapt2 → javac → d8 → zipalign → apksigner 全过,**537385 B** |
>
> 没做的:真机同步与界面回归(Esc 栈、校对护栏、配对轮询、截图级别的版面)、SMTP / IMAP 的真实收发
> (超时与 `EXPUNGE` 的改动只经代码审查)。
> **第三十六轮(R36)**:手机显示密文的问题。按要求「不要做太多测试」,只做了语法、构建、同步缓存层的
> 小脚本冒烟,**没有真机同步**(手机端 Java 未改,APK 不需要重打):
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check`(`lib/lansync.js` / `lib/library.js` / `lib/secrets.js` / `10-db` / `90-app` / `95-dialogs` / `07-locale`) | 全部 exit 0 |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts → 871476 字节**,`verify-client-load: ok (id=@local/novel-writer, inject=["slots"])` |
> | **同步缓存落盘** | 临时脚本(已删)在临时 `DSH_HOME` 里直调 `pushLibrary` | 文件以 `NWENC1` 开头、**不含正文**;`readSyncState()` 读回「第一章」 |
> | **旧明文缓存兼容** | 同上,手写旧格式 `sync-state.json` | 照常读出「旧文件」 |
> | **密文洗白(合并规则 5)** | 同上,镜像放 `enc:v1:AAAA`、同时间戳推明文 | `stats.repaired 1`,结果正文「正文」,落盘仍是密文 |
> | **坏容器退化** | 同上,`NWENC1` 后面塞乱码 | `readSyncState()` 返回 0 本,不抛异常 |
> | 密钥竞争修复 | 同上,全新 `DSH_HOME` 直接派生 | 修 `readStore` 前首次派生必失败(`key.bin..tmp` 残留),改为先建密钥再读储后成功 |
>
> 没做的:真机「加密 + 同步」走一遍(推明文与写回加密都只经代码审查与缓存层冒烟)、
> 手机端 SharedPreferences 明文的提示文案在真机上的显示。
> **第三十五轮(R35)**:扫码取景、一键导出全部、首页去掉快捷入口卡三件事。按要求「不要做太多测试」,
> 只做语法、构建、bundle 加载校验、导出接口冒烟与 APK 重打,**没有在真机上对着二维码扫一次**,
> 全量导出的「点设置里的按钮」也只走了 Host 接口那一层:
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check`(`lib/routes.js` + `30-io` / `55-welcome` / `90-app` / `95-dialogs` / `07-locale`);Java 由构建里的 `javac` 兜底 | 全部 exit 0 |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts → 869202 字节**,`verify-client-load: ok (id=@local/novel-writer, inject=["slots"])` |
> | 语言表 | 临时脚本扫 `NW.t('…')` 字面量对 EN 词典(已删) | literal keys **125**,缺失 **0**(一键导出的 4 条新字串已补) |
> | **`export.all` 冒烟** | 临时脚本直调 Host(已删),txt | 3 本(含两本同名)→ zip **388 B**,条目 `重叠.txt / 重叠 (2).txt / 第三本.txt`,`count 3`、`failed []` |
> | 同上,docx | 同上 | 2 本 → zip **7465 B**,条目 `甲书.docx / 乙书.docx`,内层含 `word/document.xml`;`export.build` 单本导出不受影响 |
> | **APK 重打** | `node tools/build-apk.mjs` | aapt2 → javac → d8 → zipalign → apksigner 全过,`dist/精神空间.apk` **533289 B** |
> | APK 结构 | `unzip -l` + `dexdump` | `classes.dex` **514820 B**;dex 里有 `displayOrientation` / `bestPreviewSize` 两个新方法 |
>
> 没做的:真机扫码(预览朝向与四向解码只经代码审查)、设置里「导出全部…」的实际点击与下载落盘、
> 首页删掉卡片后的版面回归。`aapt2 dump badging` 没再跑——权限与 versionCode 本轮未变。
> **第三十四轮(R34)**:手机同步 + 手机 App + 模型自动回退 + 两处 bug 修复,收尾时又把**手机配对改成手填
> 「电脑地址 + 配对码」**(扫码降为备选)。按要求「不要做太多测试」,只做语法、构建、bundle 加载校验、
> APK 打包与静态检查,**没有做真机同步、也没有逐条点界面**:
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check`(`lib/routes.js`、`lib/lansync.js`、`lib/lansync-server.js`、`lib/qr.js`、7 个改动的 client part) | 全部 exit 0 |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts → 866020 字节**,`verify-client-load: ok (id=@local/novel-writer, inject=["slots"])` |
> | 语言表 | 临时脚本扫 `NW.t('…')` 字面量对 EN 词典 | literal keys **114**,缺失 **0**(手填配对的 7 条新字串已补) |
> | **APK 打包** | `node tools/build-apk.mjs` | aapt2 compile/link → javac → d8 → zipalign → apksigner 全过,`dist/精神空间.apk` **533289 B** |
> | APK 元数据 | `aapt2 dump badging` | `package='com.novelwriter.app' versionCode='1' versionName='1.0.0'`,minSdk 24 / target 34;权限 INTERNET / ACCESS_NETWORK_STATE / ACCESS_WIFI_STATE / CAMERA / POST_NOTIFICATIONS |
> | APK 结构 | `unzip -l` + `dexdump` | `classes.dex` 513364 B + `META-INF/NOVELWRI.{SF,RSA}` + `resources.arsc`;dex 里有 `PairActivity` / `Pairing` 两个新类 |
> | 模型回退 | 代码路径审查(无自动化) | 三处调用点都走 `withModelFallback`;`aborted` 不重试 |
>
> 墓碑胜负与 `pair.info` 端口这两处修复只经代码审查确认,**没有跑双端同步的回归**;
> 手填配对的新界面同理,**没有在真机上走完「填两行 → 配对成功」**。
> **第三十三轮(R33)**:只改了 client(左上角胶囊去掉中间的「目录开合」、首页同时展开目录、左缘把手恢复),
> 按要求「不要测试」,只做语法、构建与 bundle 加载校验:
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check`(`client/src/60-editor.js`、`client/src/90-app.js`)+ 全仓 31 个模块 | exit 0,0 失败 |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts / 808211 bytes**,`verify-client-load: ok` |
> | 残留 prop 扫描 | `Select-String 'onToggleShelf'` | bundle 与源码均 0 命中(旧 prop 已彻底移除) |
> | 类名 / 语言表 | 静态审计脚本 | 渲染类 296 / 缺样式 0;语言键 141 / 缺 0 |
>
> **第二十七轮(R27)**:按要求「不要测试」,只做构建、语法校验、bundle 加载校验与静态断言;
> 另外为定位「按钮与便签打架」做了几次几何测量(只读,不改数据),并在活 Host 上直接调了一次推荐接口。
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 语法 | `node --check`(`lib/ai.js`、`lib/routes.js`、7 个改动过的 client part) | 全部 exit 0 |
> | 构建 + 加载校验 | `node scripts/build-client.mjs` | **20 parts / 775355 bytes**,`ok (id=@local/novel-writer, inject=["slots"])` |
> | **角落按钮与便签不重叠** | CDP 读 rect(便签关/开两态) | `acts` 恒为 `{x:294,y:10,w:92,h:28}`;开卡片后 `card {x:294,y:92,w:320}`、`page {x:643}`、`surfacePad 348px`;四组 overlap 全 `false` |
> | 窄面板释放车道 | `Emulation.setDeviceMetricsOverride width:700` | `padLeft 0px`、`acts {x:14}`、`card {x:14,y:92,w:320}` |
> | 便签折叠把手可见 | CDP 读 `.nw-notes-tab` | `{label:"收起", opacity:"0.72", x:14, y:52, w:70, h:32}` |
> | **大模型推荐(无节选)** | `POST journals.recommend` | `by:"rules"`、6 条 |
> | **大模型推荐(带节选)** | 同上 + 209 字节选 | `by:"model"`、**6334ms**、6 条带 `fit/verdict/reason/quality`,例如读者·原创版 `62 / 略欠`、百花园 `58 / 略欠`、北极光 `尚不足` |
> | 客户端推荐卡文案 | 投稿向导第 2 步截图 | 标题为「大模型读过开头,按契合度与水准推荐」,每行带 `略欠 / 尚不足` 徽章 |
>
> 没有跑 CDP GUI 套件、没有点完整投稿流程。
>
> ### 第十六轮(R16)的历史验收(保留)
>
> 本轮只做构建、语法校验、bundle 加载校验、bundle 静态断言,
> 以及宿主接口的连通性冒烟;界面上的点击验收由用户在真实 Harness 里自行完成。
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 每个 client part 语法 | `node --check client/src/*.js`(`00-boot`/`99-end` 为 factory 包装,单独 check 必然失败,属预期) | 18 个真实 part 全部 exit 0 |
> | Host 模块语法 | `node --check index.js` + 12 个 `lib/*.js` | 全部 exit 0 |
> | 构建产物 + 加载校验 | `node scripts/build-client.mjs`(内含 `verify-client-load.mjs`) | **20 parts / 720680 字符**,`ok (id=@local/novel-writer, inject=["slots"])` |
> | **R16 静态断言** | `node %TEMP%\nw-cdp\audit-r16.mjs`(扫 bundle) | 19 项全过:无明文开关 / 无「解密出明文副本」/ 首页按钮无文字 / `nw-surface-acts` / 便签无头尾栏 / `HOME_LIMIT = 3` / 收件箱删除键 / 标点开关 / `bookWords` / 无 `nw-shelf-import` / 等级徽章 |
> | 字数口径双端一致 | `POST submit.prepare`(`countPunctuation` true / false) | `他来了。他走了。` → **8 字** / **6 字**,邮件主题里的字数同步变化 |
> | 期刊等级分布 | `POST journals.list` | `{national:6, provincial:33, municipal:19, other:10}`,人民文学 = `national` |
> | 收件箱删除路由 | `POST inbox.delete {uid:0}` | 200 + `bad-request`「缺少要删除的邮件编号(uid)」 |
> | `plain` 字段清理 | `POST library.get` + 读 `settings.json` | 返回体已无 `plain`;磁盘上 `plain:true` 已删除,剩余键 `lastMirrorAt,encrypt,dir,journals,mirror,smtp,imap` |
> | selftest | `POST /novel-writer/api/selftest` | 全项 ok(webServer / docx / epub / secrets / ai / journals / smtp) |
>
> 除此之外**没有**跑 CDP GUI 套件、没有点界面。
>
> ### R13 的历史验收(保留)
>
> | 检查 | 命令 / 方式 | 结果 |
> | --- | --- | --- |
> | 方言覆盖率 | 扫全部 `NW.t(...)` / `tr(...)` 字面量 vs EN 词典 | 95 个 key,缺失 **0** |
> | 样式覆盖率 | 扫全部 `className: 'nw-…'` / `class="nw-…"` vs CSS 文件 | 242 个渲染类,未加样式 **0** |
> | **容器查询落点** | `Select-String -Pattern '@container'` | 9 处:`05-css`(声明容器)、`45-dialogs-css`×2、`55-welcome`、`60-editor`×2、`97-theme`、`98b-responsive`×3 |
> | **视口断点残留** | `Select-String -Pattern '@media'` | 只剩 `prefers-reduced-motion`×6、`hover:none`×1,以及 `97-theme` 里包在 `@supports not (container-type)` 里的那一个 `max-width:900px` 兜底;常规宽度断点 **0** |
> | **构建闸门拦过一次** | `build-client` | 抓到 `97-theme.js` 注释里的反引号(第四次同类事故),修后 20 parts 通过 |
>
> ### 为什么多了一个 `verify-client-load.mjs`
>
> `node --check` 只证明文件能解析,**不证明 factory 能跑**。R9 就踩过:某段 CSS 模板字符串的注释里出现了
> 一个反引号,提前闭合了模板串 —— 语法依然合法,但 bundle 一被求值就抛 `ReferenceError`,整条模块图断掉,
> 面板直接从界面上消失。R12 又踩了同一个坑(`55-welcome.js` 的注释里写了 `` `.nw-welcome-sub` ``),
> 这次是构建闸门当场拦下的。所以 `scripts/build-client.mjs` 写完文件后会**自动跑一次加载校验**,
> 失败则构建以 exit 1 结束。
**已经明确会变的东西**(自测时先知道,免得当成 bug):
| 变了什么 | 现在在哪 |
| --- | --- |
| 顶部工具栏整条没有 | 侧栏 topbar 六个图标 + 右下浮动坞(只剩 AI 校对 / 一键排版 / 修改建议 / 投稿) |
| 打开插件先看到首页 | 首页四张卡(含收件箱);点章节 / 继续写作才进编辑器 |
| 设置不再是长滚动 | 左导航 + 右内容,右栏自己滚 |
| 投稿向导是三步 | 选小说 → 选期刊 → 邮件预览(预览页所有字段都能改) |
| **选定期刊就出邮件(R12)** | 点「下一步」直接落在预览页,不再需要手动点「按期刊要求生成」 |
| 主题九套 | 设置·外观,色块两行(含 R10 新增「杂志」) |
| **云备份没有了** | 改成「自动保存与恢复」:选本地文件夹,改动即写加密 `.docx.nwe` |
| **保存默认加密** | 设置·导出与文件里可以关,关掉后写的是明文 `.docx` |
| 图标换了 | 全部内联 SVG,跟随 `color` |
| **插件叫「精神空间」** | 侧栏按钮、锁屏标题、首页标题都换了;包名/entryId 仍是 `novel-writer` |
| **灵感便签是悬浮卡片(R13)** | 写作页右上角 `✦` 点开一张纸片,浮在正文上方,可随手记 |
| **便签自动保存且按章独立(R14)** | 停笔 600ms 自动落盘,输入框随文字自动变高,切章换内容 |
| **光标可调** | 设置·外观:粗细 / 颜色 / 闪烁 |
| **首页按钮在纸上(R12)** | 写作页左上角 `⌂ 首页`;沉浸模式下也还在(那是唯一的出口提示) |
| **修改建议是并排的(R12)** | 坞里 `✦ 修改建议` 在正文右侧开一列,正文列相应收窄 |
| **收件箱能点开(R12)** | 首页收件箱每一行都是按钮,点开是「邮件」弹窗 |
| **点开即已读(R14)** | 取完正文再 `+FLAGS (\Seen)`,首页未读数随之刷新 |
| **短篇不会被强行分章(R14)** | 单章作品导出 / 投稿排版时不出现「一、新章节」;导出面板可手动勾「不分章节」 |
### 历史验收数字(R2 / R3,仍可复跑但选择器部分已失效)
仓库内三套脚本(无需浏览器):
| 脚本 | 覆盖 | R2 时结果 |
| --- | --- | --- |
| `node scripts/verify-docs.mjs` | zip / docx / epub / pdf / 导入解析(含 python-docx 外部校验) | 44 / 44 |
| `node scripts/verify-host.mjs` | secrets / routes / ai / library(含假 DAV、MKCOL 自愈、密钥单飞) | 261 / 261 |
| `node scripts/verify-e2e.mjs` | 活 Host 端到端:ping、四格式导出、导入、models、真实模型校对、library、selftest(端口自动探测) | 46 / 46 |
> **这三套脚本在 R9 之后已经过期,不要再当成回归基线**:`lib/cloud.js` 已被删除,
> `verify-host.mjs` 里所有 cloud 用例、`verify-e2e.mjs` 的 WebDAV 全链路都会失败——那是功能被移除的结果,
> 不是回归。要恢复可用状态,得先删掉这两个脚本里的 cloud 段落。
> **R3 / R5 / R9 的「不执行测试」一致**:只做语法 / 构建 / 加载 / 接口冒烟,没有跑集成测试。
真实浏览器 GUI 套件(Chrome + CDP,真实构建产物 + 活 Host API)在 R2 时全绿,仅作历史记录:
| 套件 | 覆盖 | R2 结果 |
| --- | --- | --- |
| A/B/C | 渲染布局、新建小说章节、输入落库、刷新恢复 | 23 / 23 |
| D | 导出 docx / pdf / epub / txt(校验 magic 与体积) | 16 / 16 |
| E | txt / md 导入自动分章 | 11 / 11 |
| F/G | AI 校对逐条接受、一键排版预览应用 | 18 / 18 |
| H/I | 锁定生命周期、密文落盘、重启不复锁、错误密码 | 27 / 27 |
| 打字机 | 长文居中误差、手动滚动抑制、恢复跟随(含沉浸态) | 6 / 6 + 8 / 8 |
| 云备份(**功能已删**) | WebDAV 登录 → 上传 → 列表 → 恢复 → 编辑器重新载入 | 19 / 19 |
| J/K/L/M/N | 目录管理/回收站、版本历史、分栏拖拽、快捷键与查找、主题 | 43 / 43 |
| 并发写 / 跨章切换 | 设置项同 tick 双改、连点建章 order、多章并发、切章不串正文 | 12 / 12 + 16 / 16 |
| 视觉与沉浸 / 交互专项 | 主题外壳同色族、沉浸 9 项、对话框关闭、保存目录、改密码验旧密码 | 35 / 35 + 30 / 30 |
**注意**:R3 改了工具栏结构(导入/导出/校对/排版入口全部搬家)、主题 id、侧栏组件与编辑器光标绘制,
R9 又删了云备份面板并把投稿向导重排成三步,上述 GUI 套件的选择器大部分已经失效,不能直接当回归用。
---
## 4. 架构
```
novel-writer/
├─ index.js Host 入口:ctx.inject(['webServer']) 延迟注册路由,异常不拖垮宿主
├─ cordis.patch.yml `- insert: id: novel-writer, name: @local/novel-writer`
├─ lib/ Host(Node 内置模块,零依赖)
│ ├─ routes.js /novel-writer/api/* 分派(55 个方法),恒 HTTP 200 {ok,data|error}
│ ├─ secrets.js key.bin + store.enc(AES-256-GCM,原子写、单飞防并发铸钥)
│ ├─ ai.js listModels / proofread / revise / chatOnce(system 折进 user block)
│ ├─ library.js 保存目录:settings.json + docx 镜像 + NWENC1 加密镜像
│ │ readSnapshot / readSnapshotText(解密后交给客户端)/ pickFolder / decryptTo
│ ├─ journals.js 内置 79 家文学期刊 + 用户覆盖;rank() 按体裁/字数/风格排序推荐
│ ├─ smtp.js 纯 net/tls 发信:AUTH PLAIN/LOGIN、STARTTLS、multipart + base64 附件
│ ├─ imap.js 纯 net/tls 收信:993 TLS / 143 STARTTLS、RFC2971 ID、BODY.PEEK 头部+摘要
│ │ readMessage(PEEK 取全文 + MIME/base64/QP/charset 解码 + htmlToText)
│ ├─ zip.js zipSync / unzipSync(自算 CRC32,zlib deflateRaw)
│ ├─ docx.js buildDocx / parseDocx(含 appendText 独立段落)
│ ├─ epub.js buildEpub / parseEpub(含 appendText 追加 )
│ ├─ pdf.js docxToPdf(officeToPdf → libreoffice-kit 回退)
│ ├─ import-parse.js parseImport(txt/md/docx/epub,GB18030 回退)
│ ├─ lansync.js 手机同步核心:配对 token / Ed25519 身份 / 共享密钥签名 /
│ │ mergeLibraries(按书合并,墓碑胜负)/ UDP 41287 发现信标 /
│ │ 宿主侧镜像 sync-state.json(NWENC1,本机密钥加密)
│ ├─ lansync-server.js 0.0.0.0 上的最小 HTTP 监听(默认 41288):ping / pair.handshake /
│ │ pair.accept / sync,别的一概不接
│ └─ qr.js 纯 JS QR 编码(无依赖),输出配对二维码 SVG
├─ client/
│ ├─ client.js 构建产物(勿手改,20 parts 拼接 → 916180 字节落盘)
│ └─ src/ 00→99 按文件名字典序拼接
│ ├─ 00-boot / 99-end __ModuleLoader__ 封装与 apply 导出
│ ├─ 05-css 设计 token 层(16/14/13px、圆角、控件高度、间距、阴影)
│ ├─ 07-locale 中文 key → 英文词典
│ ├─ 10-db IndexedDB 仓储(加密透明化、写串行化、默认设置)
│ ├─ 15-crypto PBKDF2 + AES-GCM,跨重启锁定、verifyPassword / changePassword
│ ├─ 20-format 字数/排版/diff/分章/改稿应用
│ ├─ 30-io fetch 封装:api/io/ai/library/journals/submit/inbox
│ ├─ 40-ui 按钮/输入/下拉/分段/开关/对话框/toast 原子组件
│ ├─ 42-icons 内联 SVG 图标集(48 个,24×24 统一描边)
│ ├─ 45-dialogs-css 对话框层样式(含八主题色块、折叠区、diff、三步投稿向导)
│ ├─ 50-shelf 小说章节树(可折叠、"⋯" 菜单、导入入口、40×40 页脚)
│ ├─ 55-welcome 默认欢迎页(创作中小说/统计/投稿记录/收件箱)
│ ├─ 60-editor 叠层编辑器 + 打字机 + 粗光标 + 首页按钮 + 沉浸写作面 + 88px 坞留白
│ ├─ 90-app App 状态机(view = home | write)+ slot 注册 + 浮动坞 + 修改建议栏
│ ├─ 95-dialogs 设置(两栏 7 分区)/自动保存与恢复/手机配对/校对/排版/投稿/投稿管理/期刊/读信/锁定/版本/回收站
│ ├─ 96-motion 动效 token 与 keyframes(nw-fade-up / toast-in / slide / pop)
│ ├─ 97-theme 精修层(焦点环/模态/空态/滑块/色块/响应式兜底)
│ ├─ 98-shell-theme 按 data-theme 重映射 --dsw-alias-*,让外壳跟随阅读主题
│ └─ 98b-responsive 三档容器查询(≤980 / ≤780 / ≤560),按面板宽度而非窗口宽度排版
├─ locale/{zh,en}.json 侧栏与面板标题(「精神空间」/ Mindspace)
├─ icon.svg 侧栏图标
├─ setup-github.cmd 双击即可:存 Token → 建仓 → 上传(走 api.github.com,不碰 github.com)
├─ .tools/ 便携版 Git 2.56 与 GitHub CLI 2.103(被 .gitignore 忽略,不进仓库)
├─ docs/CONTRACT.md 全项目接口契约(文件归属、方法表、命名空间)
├─ docs/CONTRACT-R3.md 第三轮改造契约(主题/光标/坞/侧栏/导出/投稿/加密)
├─ docs/CONTRACT-R5.md 第五轮改造契约(期刊库/投稿四步/八主题/欢迎页/图标)
├─ scripts/ build-client(含加载闸门)+ verify-client-load + 其余 verify
├─ app/ Android 客户端(手写 Java,无 Gradle):书架 / 章节编辑 / 配对(手填为主)/ LAN 客户端 / 前台同步
│ ├─ AndroidManifest.xml minSdk 24(Android 7.0)/ target 34;INTERNET、CAMERA、POST_NOTIFICATIONS
│ ├─ src/com/novelwriter/app/
│ │ ├─ MainActivity 书架(新建 / 重命名 / 删除书 / 手动同步 / 配对状态 / 解除配对)
│ │ ├─ EditActivity 章列表 + 纯文本正文编辑(新建 / 重命名 / 删除章)
│ │ ├─ PairActivity 配对表单:手填「电脑地址 + 配对码」(也认整行 `nw1:…`),默认入口
│ │ ├─ Pairing 唯一的配对实现:`LanClient.accept` + 落盘 + 拉起同步,失败按类型给话
│ │ ├─ ScanActivity 扫码(备选)取 `nw1:host:port:token`,右侧就是「手动输入地址」
│ │ ├─ Discovery UDP 41287 广播发现;LanClient 是 LAN HTTP 客户端
│ │ ├─ SyncEngine push / pull + 合并;Library / Store 是本机书库
│ │ ├─ Skin 白天 / 黑夜:改 uiMode 后再 createConfigurationContext
│ │ └─ SyncService / BootReceiver 前台同步服务与开机自启
│ └─ res/ 图标(五档 mipmap)、values/ 与 values-night/ 两套配色
├─ tools/ 构建与发布脚本
│ ├─ build-apk.mjs aapt2 → javac → d8 → zipalign → apksigner(无 Gradle)
│ ├─ zip-append.mjs 把 classes.dex 追加进 APK(自写 zip 目录重写)
│ ├─ setup-android.ps1 下载 build-tools 34 / platform 34 / 便携 JDK
│ └─ publish-github.mjs 经 api.github.com 上传(git push 在本机不稳定)
└─ dist/ 构建产物:精神空间.apk(0.52 MB)与 .idsig
```
客户端与宿主的唯一通道是 `POST /novel-writer/api/`,恒定 HTTP 200,
body 为 `{ok:true,data}` 或 `{ok:false,error:{code,message}}`,错误绝不静默。
---
## 5. 已知限制
1. **期刊邮箱与投稿要求需要你自己核对**:内置 **79 家**文学期刊,其中 **58 家**已填入从公开征稿汇总页采集的投稿邮箱,但**全部** `verified:false`、界面上标「待核对」。本插件**不联网抓取征稿启事**(避免把过期或错误信息当权威),邮箱可能已变更,请务必以官方最新征稿启事为准;在「选期刊 → 管理」里改一次就存进 `DSH_HOME/novel-writer/settings.json`。
2. **投稿邮件需要自备 SMTP**:没有内置发信服务。Gmail / QQ / 163 等都要先在设置 →「投稿」里填主机、端口、TLS、用户名与**授权码**(不是登录密码)。未配置时预览页会拦下来,不会静默失败。连接诊断会依次试 **465(SSL) → 587(STARTTLS) → 25(明文)**,并在失败时回报每一步的具体错误。
3. **云备份已彻底删除**(R9):`lib/cloud.js` 整个文件移除,`NW.cloud` 与备份面板的 WebDAV / GitHub Gist / 文件夹三种后端一并撤掉。现在唯一的异地保存方式是「选一个同步盘文件夹(OneDrive / 坚果云同步目录 / iCloud Drive 本地目录)当保存目录」——插件自己不再联网。
4. **PDF 导出依赖运行时探测**:无 LibreOffice kit 时 `/ping` 的 `capabilities.pdf` 为 `false`,导出对话框会自动隐藏 PDF 选项。
5. 编辑器是纯文本 / Markdown 源文编辑,不是所见即所得;加粗斜体以 Markdown 记号插入。查找为字面量匹配、大小写不敏感、单次最多 5000 处命中,替换不支持正则。
6. `chmod 0600` 在 Windows 上是 advisory 语义,密钥文件保护依赖 `DSH_HOME` 目录权限。
7. **自动保存是「全量覆盖」而不是增量**:每次触发(停止输入 1.2s 或切章)都重写整库快照。几百章的长篇单次写盘约几百 KB,可接受;但保存目录若放在机械盘或网络盘上会有可感知的延迟。
8. 单章正文整串读写,数千万字级别单章会有明显内存开销(长篇建议按章拆分,这也是目录树的默认形态)。
9. **保存目录只增不删**:`library.mirror` 不删除「上次写过、这次书单里没有」的旧文件(避免误删用户手改过的文件)。同名小说靠 `(2)` 后缀去重,顺序可能随书单顺序漂移。**R16 起文件格式只有 `.nwe`**(加密时 `.docx.nwe`,不加密时 `.docx`),不再有加密/明文两套并存;切换加密后残留的旧文件不会被自动清理,需要手动删。
10. **自动保存的密钥就是宿主 secrets 的 `key.bin`**:删掉 `DSH_HOME/novel-writer/secrets/` 之后,之前写出的 `.nwe` 再也解不开(这是加密的正常代价)。要换机,请连 `secrets/` 一起拷。
11. **沉浸模式的全屏依赖浏览器 `requestFullscreen`**:桌面版 WebView 里可能被策略拒绝(此时沉浸本身仍工作,只是不占满显示器)。
12. **九套主题都会重映射 `--dsw-alias-*`**(作用域限定 `.nw-root`)。这是让外壳跟随主题的必要手段,但宿主后续改 token 语义时 `98-shell-theme.js` 需要同步。
13. 编辑器渲染依赖真实浏览器排版(字体度量、滚动容器)。用无头 / 假 DOM 时打字机居中与粗光标位置无法验证。
14. **R3 起的改动均没有跑自动化测试**(用户要求「改完由我自己测试」),所以 `journals` / `smtp` / `imap` / `submit` / `library` / 导出 `appendText` / 九套主题 / 浮动坞 / 粗光标 / 灵感便签 / 落盘 docx / 欢迎页 / 收件箱这些新代码路径,只经过了 `node --check`、`build-client`(含加载闸门)、以及宿主接口的连通性冒烟,**没有逐条功能回归**。
15. **SMTP / IMAP 只实现了明文 / STARTTLS 与 AUTH PLAIN/LOGIN**:OAuth2(Gmail 现代登录、Outlook)不支持,必须改用应用专用密码;服务端 15–20 秒墙钟超时,超时即失败不重试。
16. **投稿记录只存在本机** `DSH_HOME/novel-writer/submissions.json`(最近 50 条),R5 已加入状态流转(`sent / pending / accepted / rejected / published / withdrawn`),但**不与期刊系统对接**——真正送达仍然是一封你确认后发出的邮件,状态需要你自己在「投稿管理」里更新。
17. **收件箱列表只读最近 12 封的头部与 32KB 摘要**;点开一封才会用 `BODY.PEEK[]` 拉全文,附件不下载、不回信、不删除。**R14 起点开即标已读**(取完正文补 `+FLAGS (\Seen)`),所以未读点会随阅读消失;想只看不标可传 `markSeen:false`。被墙或服务器限流时首页会显示服务器原话而不是「没有回信」。Gmail 需要先在网页端开启 IMAP 并生成应用专用密码。
18. **163 / 126 的 `Unsafe Login` 可能仍然存在**:R11 已补 RFC 2971 `ID` 握手(`name=novel-writer`),但网易的白名单是按客户端名审核的,握手之后仍可能被拒。此时错误码是 `imap-id-required`(不再是「密码错」),请按提示把客户端加入白名单,或换 QQ / Gmail 收信。QQ 邮箱的 IMAP 需要在网页端「设置 → 账户」里手动开启。
19. **导出会跳过没有正文的章节**:只写了标题、正文还空着的章节(大纲占位)在 docx / epub / txt 里不会出现,否则会留下「一整页只有一个标题」或者干脆一页空白。这一条在 R11 定下,先前版本会把空章也排进去。
20. **期刊「删除」是写墓碑而不是物理删除**:内置 79 家来自代码里的常量表,若只从用户覆盖层移除,下次 `merge()` 会把它们复活。墓碑存在 `settings.json` 的 `journals` 键里,跟着 `DSH_HOME` 走;换机不拷这个文件,删除会「复原」。
21. **GitHub 上传需要你自己跑一次 `setup-github.cmd` 并粘一次 Token**:本机到 `github.com` 的连接间歇性中断(`api.github.com` 一直正常),所以浏览器 OAuth 设备流和 `git push` 都会随机失败。脚本因此改走 REST API,只访问 `api.github.com`;若某次仍失败,网络恢复后重跑即可(本地提交已保存)。仓库名默认 `novel-writer`,想换就改脚本里的 `REPO=`。
22. **R27 的期刊推荐是「模型意见」而不是「录用概率」**:模型只读稿件**开头 2400 字**(声音、趣味、语言水准在这一段基本能看出来,情节不能),所以它擅长判断「调性合不合」,不擅长判断「后半程会不会崩」。`fit` 分数仅供排序参考,卡面刻意只显示「够格 / 略欠 / 尚不足」三档而不显示数字——一个分数会被当成承诺。模型不可用、超时或返回无法解析时自动退回规则排序,并在卡面注明原因。想换模型去 设置 →「写作」→「校对默认模型」,推荐与校对共用这一项。
23. **修改建议栏给的是意见,不是自动改稿**:它不会替你改写正文,也不会把建议写进文件;点一条只是把正文滚到那一段并高亮。模型每章最多返回 8 条、按 4000 字分块串行处理,所以很长的章会慢(一次审读十几到几十秒),且**同一章不同次审读结果会不一样**——这是生成式模型的常态,不是 bug。定位靠模型回引原文后 `indexOf` 对齐,作者随后手改过的段落会显示「原文已改动,定位不到」。
24. **「首页」按钮是编辑器的一部分**,不跟随滚动;它在 `.nw-page` 之外、`.nw-surface` 之内绝对定位在左上角,因此换主题时会跟着变颜色,但不会与正文重叠(正文左右有 48px 内边距)。
25. **响应式的度量单位是「面板宽度」不是「窗口宽度」**:`.nw-root` 声明了 `container-type:inline-size`,三档断点写的是 `@container nw (max-width: …)`。所以**把宿主侧栏拉开、或把 Harness 窗口调窄,插件会换档;把窗口调宽但宿主右栏占着,插件不会换档**——这是有意的,插件在 shell 里就是一列。用不支持容器查询的旧引擎时,`97-theme.js` 里留了一份 `@media (max-width:900px)` 的兜底,但 780 / 560 两档会失效。
26. **窄面板(≤560px)下侧栏与修改建议栏改成浮层**:它们不再占正文宽度,而是盖在正文上,进去后要再点一次按钮才收起(不像宽面板里是并排常驻)。侧栏浮层的 `z-index` 故意高于浮动坞,否则窄面板里侧栏底部的按钮会被坞压住、点不到。
27. **灵感便签是绝对定位的悬浮卡**,不参与布局,所以它**不会把正文挤开**;代价是面板很矮时(窗口高度 < 约 600px 且侧栏展开)卡片可能压住正文顶部,此时正文仍可滚动、卡片可随时 `Esc` 收起。**R14 起一章一份**(按 `chapterId` 存),自动保存、随文字自动变高(上限 420px),清空即删除 —— 仍然**不支持一屏多张便签**,这是 R10 起就有的取舍(用户要的是「随手记一页」而不是笔记列表)。
28. **保存目录里的文件格式只有 `.nwe` 一种**(R16 起):加密版是 `.docx.nwe`,不加密时是 `.docx`,两者不会并存。R15 那个「同时写一份明文」的开关已经删除,`settings.json` 里的 `plain` 字段也清掉了 —— 如果旧目录里还留着 R15 时期生成的明文 `.docx`,下一次自动保存会把它当作遗留文件删掉。
29. **期刊等级是从标签推导的,不是独立数据**:`tier` 取「国家级 / 省级 / 市级」标签,没标签的一律算「其他」,所以它只是投稿难度的粗略参考;用户可以逐刊覆盖,覆盖值会写进 `settings.json` 的 `journals` 覆盖表,但一旦覆盖就不再跟随标签变化。
30. **「统计字数包含标点符号」是全局开关**:关掉后状态栏、首页书籍总字数、投稿预览里的字数会同时扣掉标点(`\p{P}` 与 `\p{S}`),但**已经存进 IndexedDB 的 `wordCount` 字段不会重算**——旧章节要等下次保存才更新,书籍总字数因此可能在切换开关后短暂滞后。
31. **收件箱删除是「标记 + 清空」两步**:先 `UID STORE +FLAGS (\Deleted)`,再优先 `UID EXPUNGE `(RFC 4315);**只有**在 `CAPABILITY` 里看不到 `UIDPLUS` 时才回落整箱 `EXPUNGE`(R37 之前是「失败了就回落」,一次无关的错误会把全邮箱其它已删除邮件一起清掉)。若两步都失败,返回 `expunged:false`,界面会提示「服务器只把邮件标记为已删除,需要在你自己的邮箱里再清一次」。
32. **手机同步只在同一个局域网内成立**:没有云端、没有中继,手机与电脑必须在同一 Wi-Fi / 同一网段。桌面侧要同时满足
「DSH 在跑」「插件启用」「写作页没被锁」三个条件,浏览器每 6 秒才去问一次宿主镜像——关掉页面或锁屏期间手机推来的改动
只停在 Host 镜像里,重新打开并解锁后才会落进 IndexedDB。LAN 端口(默认 41288)是**明文 HTTP、没有 TLS**,
只适合可信的家庭 / 办公网络,**绝不要把端口映射到公网**;Windows 防火墙第一次询问时请放行「专用网络」,
UDP 41287 的发现广播被防火墙拦掉也不影响扫码配对。
33. **清空回收站会把墓碑一起清掉**:桌面端的删除墓碑是从 `trash` 里 `kind === 'novel'` 的条目派生的,
「清空回收站」把整张表 `clear()`,于是这台电脑不再记得「这本书是我删的」。此时一台在删除期间离线、
仍持有旧书的手机下次同步会把书**原样推回来**。想彻底删掉一本书,最好在两端都删一次,或者删完先同步、
等手机也删掉之后再清空回收站。
34. **手机端是明文存储**:R36 之后「正文加密」和「手机同步」可以同时用,代价是加密的边界只到手机为止——
桌面推出去前解密,手机落地就是纯文本(`SharedPreferences` 里没有加密),所以手机丢失或被人解锁,
书稿就是明文。真在意这点就别配对手机,或者只在桌面端写作。
35. **APK 是 debug 签名的旁加载包**:`dist/精神空间.apk` 用固定的 `debug.keystore`(`storepass`/`keypass` 都是
`android`)签名,没有上架任何商店、没有自动更新,升级要手动装新包;**换签名必须先卸载旧版**,配对信息也会随之丢失。
构建依赖 `.tools/android/` 里下载的 SDK 与便携 JDK(`tools/setup-android.ps1` 可补齐),不是一台机器都能直接出包。
36. **手机端只是「能看能改」的第二块屏幕**:只有书架与纯文本章节编辑(新建 / 重命名 / 删除 / 改正文),
**没有**版本历史、校对、一键排版、导出、投稿、灵感便签、加密与锁定;后台同步受系统电池优化策略影响,
被系统掐掉时只能回到 App 手动点「同步」。
37. **配对默认靠手抄两行,扫码只是备选**:桌面面板印的是「电脑地址」与 12 位「配对码」,
手填比扫码慢,而且配对码 **5 分钟**就失效——抄到一半过期只能在电脑上重新生成再抄一次。
之所以把它当默认路径,是因为手机相机对着这块面板对焦不稳,扫十次里总有一两次读不出来;
两行字则永远读得出来,还能「复制这两行」再发给手机。地址填错时 App 会提示「连不上 X:Y」,
配对码填错会提示「配对码不对,或者已经过期」,两种失败都分得清。
38. **全量导出是「一次性 zip」,不是增量备份**:`export.all` 在 Host 内存里把每本书按默认格式渲染完、
整个打成压缩包再一次性下发,所以书多、图多(封面)时内存与体积都会顶上去,慢的时候浏览器只看到
「正在导出…」。它不是版本历史、也不是可增量恢复的备份;要备份数据库请用设置里的「导出 JSON / 恢复」。
失败是「整批里去掉这一本」而不是重试,最后的 toast 只报数量(「已导出 N 部,M 部失败」),
没有单独的失败清单面板,想知道是哪一本失败得去看 Host 日志或再单独导一次。
39. **扫码取景按设备挑尺寸,仍可能拉伸**:预览档位是从 `getSupportedPreviewSizes()` 里挑「最接近 16:9」的一档,
遇到只给 4:3 的机型,竖屏下画面还是会被轻微拉伸(二维码仍能解,只是取景框看起来不方);
四向解码是「先原位、再转三次」,慢机器上连续失败帧的耗时会上去。
40. **手机端删章节仍然不会同步到电脑**:手机版只给整本书写墓碑(书级 LWW),章节级没有墓碑,
所以在手机上删掉的章,电脑下一次推送会让它复活(电脑那侧的章还在)。R37 只修了「删书能同步」,
章级墓碑要改协议与两侧合并逻辑,本轮没做。真要在手机上删章并且让电脑也删,请在电脑端再删一次。
41. **手机端整库 JSON 仍是同步序列化**:R39 把编辑器的保存挪到了 `Store.updateInBackground`
的单线程队列(解析 → 改 → 序列化都在后台,主线程只收结果),但书架 `render()` 与同步线程仍各自
整库解析一次;几十万字的库在低端机上仍可能掉帧。扫码回调把相机的复用 buffer 直接丢给解码线程,
下一帧可能把它覆写(偶尔解不出,重扫即可)。
42. **`Store` 遇到损坏的库 JSON 会回落到空库并照常保存**:`SharedPreferences` 里的 `library` 解析失败时,
手机按「空书架」启动,随后任何一次编辑都会把空库写回去,原来的书就只剩电脑那一份(同步会重新拉回来,
前提是电脑还留着)。没有做备份文件轮转。
43. **IMAP 的 8bit / 7bit 正文仍按 UTF-8 解**:base64 与 quoted-printable 会按 `charset` 正确解码,
但服务器直接给 8bit 的 GB2312 / GBK 老邮件(多见于国内老系统)会显示成乱码 —— 修它要让
`parseFullBody` 收原始 `Buffer` 而不是已经 `toString('utf8')` 过的字符串,属重构,本轮没做。
44. **离线测试脚本只覆盖宿主**:`scripts/verify-host.mjs` 现在跑 174 项(ping / models / proofread /
export.build / secrets / library / selftest / lansync 合并),**云备份那一整套用例随 R9 一起删掉了**,
客户端界面(Esc 栈、校对护栏、配对轮询、内联配对面板)与 Java 侧没有任何自动化覆盖;
`scripts/verify-e2e.mjs` 需要先在跑的宿主(`NOVEL_WRITER_BASE` 可指定端口)。
45. **手机主题是手动开关,不跟系统深色模式**:☀ / ☾ 只写 `Skin` 自己的偏好,读系统 `uiMode` 只在首次
没有记录时借用一次;系统在日落时自动切深色时,App 不会跟着变。切换走 `recreate()`,编辑器会回到
章节开头(正文不丢,`onPause` 里已经 `flush()`),长章里换主题等于重新找到刚才那一行。
46. **书架的「已同步」是本地时间戳推断**:它记的是「这台手机上次同步成功时,这本书的 `updatedAt`」,
所以电脑端之后又改过这本书、而手机还没再同步时,手机仍会显示「已同步」——它只回答「我这边有没有
未推出去的改动」,不回答「电脑那边有没有更新的稿子」。真要知道,点一次「同步」。
---
## 6. 许可
Copyright (C) 2025 Jokii-Joy
本项目以 **GNU General Public License v3.0 or later** 发布,全文见仓库根目录的 [LICENSE](../LICENSE)。
可自由使用、修改、分发;分发修改后的版本须同样以 GPL-3.0-or-later 开放源代码并保留版权声明。
本程序不提供任何担保。
47. **手机编辑器的滚动改成了「外层 ScrollView + 不滚动的 EditText」**(R39):长章拖动不再让 EditText
自己重排,光标跟随由 `keepCaretVisible()` 手工做(120ms 节流,只在光标离开可视区中间带时才滚)。
代价是没有了 EditText 自带的「拖到边缘自动滚动」,光标在屏幕外时只能用手指滚。输入法保留
`NO_SUGGESTIONS`(关掉拼写检查),所以没有候选词联想,这是为长章打字不掉帧的有意取舍。