This commit is contained in:
douboer
2025-09-07 12:39:28 +08:00
parent 1ba01e3c64
commit 4d033257fe
5714 changed files with 15866 additions and 1032 deletions

770
readme.md
View File

@@ -1,230 +1,586 @@
# iBooks 笔记专家 详细设计文档
# iBooks笔记导出
> 版本: 1.1 (2025-09 重构整理)
> 维护者: 项目开发组
> 说明: 本文档统一重新编排章节,增加架构与 UML 部分,便于后续扩展与维护。
## epub结构
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2025-08 | 初版文档 |
| 1.1 | 2025-09 | 重组目录新增模块拆分、UML、AI 简评与可视化章节整理 |
sqlite3 ~/Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation_v10312011_1727_local.sqlite
sqlite> .tables
**ZAEANNOTATION** Z_METADATA Z_PRIMARYKEY
ZBCCLOUDSYNCVERSIONS Z_MODELCACHE
## 1. 概述
**ZAEANNOTATION**表:
| 字段名 | 值 | 含义描述推测 |
| ---- | ---- | ---- |
| Z_PK | 4662 | 可能是该条记录在数据库中的主键Primary Key用于唯一标识这条批注数据 |
| Z_ENT | 1 | 可能代表实体类型Entity Type用于区分数据库中不同类型的记录此处可能标识为“批注”类实体 |
| Z_OPT | 1 | 可能是版本号或优化字段Optimization用于记录数据的更新次数或状态标识 |
| ZANNOTATIONDELETED | 0 | 标识批注是否被删除0表示未删除1可能表示已删除 |
| ZANNOTATIONISUNDERLINE | 1 | 标识批注是否为下划线样式1表示该批注是下划线标注0可能表示非下划线 |
| ZANNOTATIONSTYLE | 0 | 可能表示批注的样式属性如颜色、粗细等0可能对应默认样式 |
| ZANNOTATIONTYPE | 2 | 表示批注的类型可能用于区分是下划线、高亮、注释等不同类型2此处可能对应下划线批注 |
| ZPLABSOLUTEPHYSICALLOCATION | 0 | 可能是批注在文档中的绝对物理位置信息,具体含义需结合文档存储格式判断 |
| ZPLLOCATIONRANGEEND | 0 | 批注内容在文档中的位置范围终点,可能是字符偏移量或段落索引等 |
| ZPLLOCATIONRANGESTART | 9 | 批注内容在文档中的位置范围起点,可能是字符偏移量或段落索引等,与终点配合确定批注选中的文本范围 |
| **ZANNOTATIONCREATIONDATE** | 774064827.1 | 批注的创建时间,可能是时间戳格式(需转换为具体日期时间) |
| ZANNOTATIONMODIFICATIONDATE | 774064829.2 | 批注的最后修改时间,时间戳格式,记录批注内容或属性的更新时间 |
| **ZANNOTATIONASSETID** | 768E1CD0B3086166F791683869B12425 | 可能是该批注所属文档的唯一标识Asset ID用于**关联批注对应的电子书或文档** |
| ZANNOTATIONCREATORIDENTIFIER | com~apple~iBooks | 标识创建该批注的应用程序此处表明是苹果的iBooks应用创建的批注 |
| **ZANNOTATIONLOCATION** | epubcfi(/6/20[id155]!/4/412/1,:0,:97) | 采用EPUB格式的CFIContent Fragment Identifier**精确标识批注在电子书文档中的位置** |
| ZANNOTATIONNOTE | | 批注的备注内容,此处为空表示该批注没有额外添加的文字备注 |
| **ZANNOTATIONREPRESENTATIVETEXT** | 在听完这个充满暴力、腐败和表里不一的悲剧故事之后,希维格和我都已精疲力尽。我们不知道到底谁比较可恶:是罪不可逭的席格和犹太人防卫联盟,还是恶意侵犯席格的宪法基本人权、并且否认曾许下承诺的政府官员。 | 可能是批注所关联的代表性文本,通常是包含批注选中内容的上下文文本 |
| **ZANNOTATIONSELECTEDTEXT** | 在听完这个充满暴力、腐败和表里不一的悲剧故事之后,希维格和我都已精疲力尽。我们不知道到底谁比较可恶:是罪不可逭的席格和犹太人防卫联盟,还是恶意侵犯席格的宪法基本人权、并且否认曾许下承诺的政府官员 | 被用户选中并添加批注(下划线)的具体文本内容,**优先使用该字段作为内容定位依据**。 |
| ZANNOTATIONUUID | 7097EAE0-0EDB-4552-9AE0-FA6AB390B90B | 批注的唯一标识符UUID用于在系统中唯一标识这条批注避免重复 |
| ZFUTUREPROOFING1 | | 预留字段,可能用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING10 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING11 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING12 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING2 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING3 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING4 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING5 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING6 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING7 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING8 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZFUTUREPROOFING9 | | 预留字段,用于未来功能扩展,目前未使用 |
| ZPLSTORAGEUUID | 774064829.2 | 可能是与存储相关的唯一标识符,用于关联批注在存储系统中的位置或状态 |
| ZPLUSERDATA | | 可能用于存储与用户相关的自定义数据,此处为空表示无额外用户数据 |
本工具用于从 macOS iBooksApple Books应用的数据文件中提取用户的书籍笔记并以 Markdown 格式导出。支持从 iBooks 的数据库和 plist 文件自动同步数据,支持交互式选择书籍导出,导出内容结构清晰,便于后续整理和阅读。
支持按最近打开时间排序书籍,菜单显示书名与时间戳,导出流程高效。
---
## 2. 主要功能
## 笔记数据
### 书籍元数据
PLIST_PATH = os.path.expanduser("~/Library/Containers/com.apple.BKAgentService/Data/Documents/iBooks/Books/Books.plist")
- 自动同步 iBooks 数据库和书籍信息文件到本地 `./data` 目录。
- 解析 iBooks 笔记数据库,构建结构化的 `booksnote` 数据
- 解析书籍元数据(如书名、路径等)。
- 支持交互式模糊搜索选择要导出的书籍。
- 按章节导出所选书籍的所有笔记,格式为 Markdown。
- 书名中如含有“-xxxx”后缀仅保留“-”前的主书名。
- 书籍选择菜单按最近打开时间last_open降序排序显示格式为“书名 [时间戳]”。
{
"Books" => [
---
……
603 => {
"artistName" => "(美)德肖维茨"
"BKAllocatedSize" => 1388544
"BKBookType" => "epub"
"BKDisplayName" => "最好的辩护-德肖维茨"
"BKGeneratedItemId" => "768E1CD0B3086166F791683869B12425"
"BKGenerationCount" => 1
"BKInsertionDate" => 773817769
"BKPercentComplete" => 1
"book-info" => {
"package-file-hash" => "768E1CD0B3086166F791683869B12425"
}
"genre" => "法律"
"isPreview" => 0
"itemName" => "最好的辩护"
"path" => "/Users/gavin/Library/Mobile Documents/iCloud~com~apple~iBooks/Documents/最好的辩护.epub"
"sourcePath" => "/Users/gavin/Library/Containers/com.apple.iBooksX/Data/Library/Caches/Inbox/最好的辩护-德肖维茨.epub"
"updateDate" => 2025-07-10 05:22:49 +0000
}
……
]
### 笔记数据库
DB_PATH = os.path.expanduser("~/Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation_v10312011_1727_local.sqlite")
## books
~/Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite
gavin@GavinsMAC BKLibrary % sqlite3 ~/Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite
sqlite> .tables
ZBCCLOUDSYNCVERSIONS ZBKJALISCOSTATUS Z_MODELCACHE
ZBKCOLLECTION **ZBKLIBRARYASSET** Z_PRIMARYKEY
**ZBKCOLLECTIONMEMBER** Z_METADATA
## 问题
### 20250729
从epubcfi如epubcfi(/6/20[id155]!/4/412/1,:0,:97)去反推定位内容,没法实现,浪费时间。
**解决思路**
1. 基本信息
- 笔记数据在这个表中:~/Library/Containers/com.apple.iBooksX/Data/Documents/AEAnnotation/AEAnnotation_v10312011_1727_local.sqlite
- 书籍清单在这个表中:~/Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite
- 书籍元数据在这里:~/Library/Containers/com.apple.BKAgentService/Data/Documents/iBooks/Books/Books.plist
- 书籍在这个directory中(epub为文件夹非压缩文件)~/Library/Mobile Documents/iCloud~com~apple~iBooks/Documents
2. copy以上前三个文件到./data目录下分别命名为AEAnnotation.sqlite、BKLibrary.sqlite、Books.plist
BKLibrary.sqlite暂不使用使用Books.plist
3. 解析Books.plist存在名为booksinfo的defaultdict(dict)中。booksinfo数据结构如下
booksinfo = {
"768E1CD0B3086166F791683869B12425": {
"BKDisplayName" : "最好的辩护-德肖维茨",
"artistName" : "(美)德肖维茨",
"BKBookType" : "epub",
"BKGeneratedItemId" : "768E1CD0B3086166F791683869B12425",
"itemName" : "最好的辩护",
"path" : "/Users/gavin/Library/Mobile Documents/iCloud~com~apple~iBooks/Documents/最好的辩护.epub"
}
……
}
其中768E1CD0B3086166F791683869B12425为BKGeneratedItemId。
4. 查询AEAnnotation.sqlite获取ZANNOTATIONCREATIONDATE ZANNOTATIONASSETID ZANNOTATIONLOCATION ZANNOTATIONSELECTEDTEXT ZANNOTATIONUUID
如:
ZANNOTATIONCREATIONDATE 746202137.8 #需要转化为2025/7/30这样的格式
ZANNOTATIONASSETID 5F2C1C40566C61A2267907E621A664A4
ZANNOTATIONLOCATION epubcfi(/6/98[id61]!/4[1CQAE0-902f5dcbb4a04a858d573ec5ee66e862]/58/1,:28,:226) #需要获取idref = id61filepos=1CQAE0-902f5dcbb4a04a858d573ec5ee66e862
ZANNOTATIONSELECTEDTEXT 这才是对人而言的自由
ZANNOTATIONUUID 0456103A-7161-451A-B762-1305ECBECBDB
并把数据存到名为annotations的defaultdict(dict)中。annotations的数据结构如下
annotations = {
'CB9A605DCD687C4FA544DD4BCCD00D43': {
'326CA2CF-3298-45D5-A93F-440E6F2A0B33': {
'creationdate': '2023/7/12',
'filepos': None,
'idref': '008.xhtml',
'note': None,
'selectedtext': '這就是宣傳的恐怖之處'},
'2BD61C93-D1F1-4553-90CB-043A6E06DBA1': {
'creationdate': '2023/7/12',
'filepos': None,
'idref': '008.xhtml',
'note': None,
'selectedtext': '應該有一些系統性的表達機制'},
}
……
}
5. 需要导出的书名作为参数,比如"最好的辩护"书名做从booksinfo中做模糊查询如匹配到多本在shell中提示选择哪一本书。
从booksinfo获取本书的uuid和这本书epub的路径path进而从annotations中获取这本书的笔记。
6. 依据这本书的路径path解析epub文件夹下的文件。
假定epub目录为epubdirxx.opf(package.opf|standard.opf|content.opf)和toc.ncx文件可能的位置在
epubdir/content.opf epubdir/toc.ncx
epubdir/OEBPS/content.opf epubdir/OEBPS/toc.ncx
epubdir/EPUB/package.opf epubdir/EPUB/toc.ncx
epubdir/item/standard.opf epubdir/item/toc.ncx
- 解析opf文件存入default(dict)数据结构contentopf:
contentopf = {
"768E1CD0B3086166F791683869B12425": {
id163 : index_split_000.html, # idref: ref
id162 : index_split_001.html,
id161 : index_split_002.html,
id160 : index_split_003.html,
id159 : index_split_004.html,
id158 : index_split_005.html
……
}
}
- 解析toc.ncx文件存入default(dict)数据结构contenttoc:
contenttoc =
{
"768E1CD0B3086166F791683869B12425": {
{ 'num_1': {'filepos': None, 'label': '\n最好的辩护\n', 'ref': 'index_split_000.html'},
'num_100': { 'filepos': None, 'label': '\n第八章父亲的罪\n',
'num_101': { 'filepos': 'filepos698889', 'label': '\n没有父亲的生活\n', 'ref': 'index_split_016.html'},
'num_102': {'filepos': 'filepos706518', 'label': '\n越狱\n', 'ref': 'index_split_016.html'},
……
}
}
}
7. 使用annotationdata.py获取annotations把annotations扩充label_path
通过annotations的assetid从bookinfo获取path路径在path下找到.opf和.ncx使用opf_parse.py和toc_parse.py获取label_path
## 3. 主要数据结构
### 3.1 booksnote
```python
booksnote = {
assetid: { label_path: { uuid: {
assetid: { label_path: { uuid: {
'creationdate': '2023/7/12',
'filepos': None,
'idref': '008.xhtml',
'note': None,
'selectedtext': '這就是宣傳的恐怖之處'
}}}}
}}}
}
```
- `assetid`:书籍唯一标识
- `label_path`:章节名
- `uuid`:笔记唯一标识
- 其余字段为笔记内容及元数据
---
## 4. 主要流程
应该从epubcfi中的id155去找到html文件在从笔记内容去匹配对应文件中的位置。
需要有个函数吧html的数据结构dict化很快可以从内容找到title。
### 4.1 数据同步
从epubcfi的ZANNOTATIONASSETID找到bookid
从ZANNOTATIONASSETID 依据bookid => epub路径
解析book到dict中。
解析content.opf中的[id,href]
关联idtitle章节内容
=> bookdict = [id,title,[内容]]
从ZAEANNOTATION的笔记内容遍历bookdict找到内容找到title
最后按booknametitle归并笔记
- 自动将 iBooks 的数据库和 plist 文件复制到本地 `data/` 目录,便于后续处理。
### 202500906
1. 增加QTUI
2. 增加统计和展示
统计
- 周活跃 - 30天每天的阅读时长柱状图
某本书根据每条笔记note中ZANNOTATIONCREATIONDATE如果某天没有note则阅读时间为0如果只有一条note阅读时间为READ_TIME_DAY=60(在config.py中配置)如果note超过1条计算第一条和最后一条的时间差作为阅读时长。放在readtime30d这个list中
- 月活跃 - 30天每天的阅读时长柱状图
每本书ZANNOTATIONCREATIONDATE落在30天前到今天的天数*60min(60mins为每天阅读时间可配置
- 已阅读的书籍: 每本平均阅读时长。所有书籍:总阅读时长,年阅读时长,年平均每日阅读时长,累计阅读天数。用气泡图表示。
如果已读完表ZBKLIBRARYASSET的ZISFINISHED字段为1
表ZBKLIBRARYASSET的ZDATEFINISHED读完时间可以统计今年读完的书籍
*表ZBKLIBRARYASSET的(ZDATEFINISHED - ZCREATIONDATE),得出书本创建到读完的阅读周期,并非真正的阅读时间
表ZAEANNOTATION的ZANNOTATIONCREATIONDATE找出最早的一条批注创建时间(ZDATEFINISHED-ZANNOTATIONCREATIONDATE)大约周期read\_days。近似的用read_days*60min(60mins为每天阅读时间可配置
- 本年度已阅读书籍封面图片橱窗6xn展示图片西面注上书名支持把6xn导出成一张图片
读完时间依据ZISFINISHED为1ZDATEFINISHED计算是否今年读完。
封面图片通过在IBOOKS_BOOKS_DIR对应的书籍下查找*cover*.jpg/png/jpeg文件如果没有查找cover*html文件解析该文件获取图片路径(相对于html文件的路径)。如:
<image width="890" height="1186" xlink:href="../Images/data-url-image.jpeg"/>,取出../Images/data-url-image.jpeg
<img src="Image00007.jpg" />取出Image00007.jpg
### 4.2 构建 booksnote
- 通过 `get_annotations` 解析 SQLite 笔记数据库,获取所有笔记。
- 通过 `parse_books_plist` 解析书籍元数据,获取书名、路径等信息。
- 遍历每本书的所有笔记结合OPF、NCX文件和HTML 文件,定位章节名。
- 若无法通过目录文件定位章节,则尝试通过笔记选中文本在 HTML 文件中查找章节,否则标记为“未找到章节”
### 4.3 交互式选择书籍
- 读取 Books.plist 获取所有书籍元数据。
- 读取 BKLibrary.sqlite获取每本书的最近打开时间last_open苹果时间戳基准2001-01-01
- 生成书名列表(优先 `displayname`,其次 `itemname`,否则用 `assetid`),并去除“-xxxx”后缀
- 按 last_open 时间戳降序排列,菜单显示“书名 [时间戳]”,时间戳为 last_open 字段。
- 使用 InquirerPy 提供模糊搜索交互界面,供用户选择要导出的书籍
### 4.4 导出 Markdown
- 仅导出用户选择的书籍。
- Markdown 格式如下:
```
# 笔记导出 2025-08-06 12:00
## 书名
### 章节名
选中文本
> 笔记内容
```
- 每条笔记独立分行,章节分组。
---
## 5. 关键函数说明
### 5.1 build_booksnote
- 输入:注释数据库路径、书籍 plist 路径
- 输出:结构化的 booksnote 字典
- 逻辑:遍历所有笔记,结合书籍元数据和目录信息,归类到章节下
### 5.2 export_booksnote_to_md
- 输入booksnote、booksinfo、导出路径
- 输出Markdown 字符串,并写入文件
- 逻辑:遍历每本书、每个章节、每条笔记,按格式输出
---
## 6. 交互与用户体验
- 通过命令行交互,用户可模糊搜索并选择要导出的书籍。
- 若无可导出的笔记,程序自动退出并提示。
- 导出后,显示导出文件路径和书名。
---
## 7. 代码片段示例
### 7.1 书名处理逻辑
```python
name = info.get('displayname') or info.get('itemname') or assetid
# 如果书名中包含“-”,只取“-”前面的部分
if '-' in name: name = name.split('-', 1)[0].strip()
```
### 7.2 交互式选择与排序
```python
from booklist_parse import get_books_last_open
last_open_times = get_books_last_open('data/BKLibrary.sqlite')
for assetid, info in booksinfo.items():
...
ts = last_open_times.get(assetid, {}).get('last_open', 0)
assetid2lastopen[assetid] = ts
sorted_assetids = sorted(assetid2name.keys(), key=lambda aid: assetid2lastopen[aid], reverse=True)
choices = [f"{assetid2name[aid]} [{assetid2lastopen[aid]}]" for aid in sorted_assetids]
answer = inquirer.fuzzy(
message="请选择要导出的书名(支持模糊搜索):",
choices=choices,
multiselect=False,
instruction="上下键选择,输入可模糊筛选,回车确定"
).execute()
```
---
## 8. 依赖说明
- Python 3
- 主要依赖库:`InquirerPy`, `bs4`, `shutil`, `os`, `datetime`, `sqlite3`
- 需有 iBooks 数据库、plist 文件和 BKLibrary.sqlite 的本地访问权限
---
## 9. 目录结构
- `data/`:存放同步下来的数据库和 plist 文件(含 AEAnnotation.sqlite、Books.plist、BKLibrary.sqlite 等)
- `notes/`:导出的 Markdown 文件
- `examples/`epub 示例文件夹
---
## 10. 主要代码文件说明(细化)
### 10.1 exportbooknotes.py
- 采用 OOP 设计,核心类为 `BookNotesExporter`
- `build_booksnote(bookid=None)`:构建结构化笔记数据。
- `export_booksnote_to_md(booksnote, booksinfo, out_path=None)`:导出为 Markdown。
- `find_file_by_ext`、`get_toc_tree` 等辅助方法。
- 数据同步:自动复制 iBooks 数据库和元数据到本地。
- 菜单交互:按最近打开时间戳排序,显示“书名 [时间戳]”,支持模糊搜索。
- 只处理用户选中书籍的笔记,按章节分组导出 Markdown。
- 依赖核心解析模块,负责主流程调度。
### 10.2 annotationdata.py
- OOP 设计,核心类为 `AnnotationManager`
- `get_annotations(bookid=None)`:返回所有或指定 assetid 的笔记。
- `parse_location(location)`:静态方法,解析定位信息。
- 解析 AEAnnotation.sqlite提取所有或指定 assetid 的笔记。
- 支持苹果时间戳转换,结构化输出。
### 10.3 booklist_parse.py
- OOP 设计,核心类为 `BookListManager`
- `get_books_info()`:获取书籍元数据。
- `get_books_last_open()`:获取每本书的最近打开时间。
- 解析 Books.plist获取书籍元数据书名、作者、路径、时间等
- 解析 BKLibrary.sqlite获取每本书的最近打开时间。
### 10.4 opf_parse.py
- OOP 设计,核心类为 `OPFParser`
- `parse_opf(filepath)`:静态方法,返回 id->href 映射。
- 解析 epub 的 OPF 文件获取章节与文件映射关系idref -> href
### 10.5 toc_parse.py
- OOP 设计,核心类为 `TOCParser`
- `parse_navpoints(navpoints)`:递归解析 navPoint 节点。
- `find_label_path(node, ref, filepos, path)`:查找章节路径。
- `find_section_by_selectedtext(html_path, selectedtext)`:通过选中文本定位章节标题。
- `parse_html_title(html_path)`:解析 html 文件标题。
- 解析 NCX 目录文件,递归构建章节树结构。
### 10.6 backup/booksnote.py
- 历史/备份脚本,辅助数据迁移或格式转换。
---
## 11. 扩展与维护建议
- 可扩展支持多本书批量导出
- 可增加导出格式(如 HTML、PDF
- 可优化章节定位算法,提升准确率
- 可增加 GUI 交互界面
---
如需进一步细化某一部分设计,请告知!
---
## 12. GUI 架构与模块调用关系2025 拆分后更新)
### 12.1 模块职责概览
| 模块 | 主要类/函数 | 职责 | 关键依赖 |
|------|-------------|------|----------|
| `ibook_export_app.py` | `IBookExportApp` | GUI 入口,组合各 Mixin组织信号/槽,生命周期管理 | `CoverMixin`, `FinishedBooksMixin`, `BookReviewWorker`, `BookListManager`, `BookNotesExporter` |
| `cover_mixin.py` | `CoverMixin` | 解析、查找、缩放显示封面 | `config` (目录), Qt Widgets |
| `finished_books_mixin.py` | `FinishedBooksMixin` | 已读书籍网格数据装载与自适应布局、点击跳转 | `BookListManager.get_finished_books_this_year`, `CoverMixin.find_book_cover` |
| `review_worker.py` | `BookReviewWorker` | 后台线程获取书籍 AI 简评并写入 `bookintro.json` | `ai_interface.DashScopeChatClient` |
| `booklist_parse.py` | `BookListManager` | 书籍基础元数据、阅读统计、已读书籍列表 | `annotationdata.AnnotationManager`, `config` |
| `annotationdata.py` | `AnnotationManager` | 解析笔记 SQLite返回结构化笔记 | SQLite DB |
| `exportbooknotes.py` | `BookNotesExporter` | 构建/导出 Markdown 笔记 | `annotationdata`, `toc_parse`, `opf_parse` |
| `toc_parse.py` | `TOCParser` | 解析 NCX/HTML 标题,定位章节路径 | 文件系统/HTML |
| `opf_parse.py` | `OPFParser` | 解析 OPF 获取 manifest 映射 | OPF XML |
| `charts.py` | 图表组件 | 周 / 月 / 年 / 气泡指标可视化 | `BookListManager` 汇总数据 |
### 12.2 运行时对象关系(简化 UML 文本表示)
```
IBookExportApp
├── BookListManager (数据/统计)
├── BookNotesExporter (导出)
├── [Composition via Mixin] CoverMixin
├── [Composition via Mixin] FinishedBooksMixin
├── 0..n BookReviewWorker (异步 AI 简评线程池 _active_workers)
└── charts.* Widgets (按需创建)
```
`IBookExportApp` 通过多继承获得封面与已读网格功能:
1. 封面查找 -> `CoverMixin.find_book_cover`
2. 网格构建 -> `FinishedBooksMixin._populate_finished_books_grid`
3. AI 简评 -> `BookReviewWorker` 发起,完成后回调 `_on_review_finished`
### 12.3 启动序列Startup Sequence
1. 用户运行 `ibook_export_app.py` → 创建 `QApplication`
2. `IBookExportApp.__init__`
- 加载 `.ui`
- `sync_source_files(config)`(复制原始库到 `data/`
- 构建 `BookListManager` → 加载 plist / 统计数据
- 构建书籍列表(按 last_open 排序)填充 `QListWidget`
- 初始化封面标签 & `_load_initial()`(前三本封面 + 首本 AI 简评启动)
- `_populate_finished_books_grid()`(已读网格初填)
- 安装事件过滤器(视口 Resize + Tab 切换策略 C + A
- 安排延迟 `_relayout_finished_grid()` 确保初次布局正确
3. 主窗口显示;用户可交互。
### 12.4 书籍切换流程Selecting a Book
1. 用户在列表中选中条目 → `currentRowChanged` → `update_book_info(row)`
2. 刷新右侧三张封面(当前 + 后两本轮播预览)
3. 构建基础信息 `_base_info_cache`
4. 若 `bookintro.json` 已有简评 → 直接渲染;否则启动新 `BookReviewWorker` → 占位“简评获取中...”
5. 线程完成 → 通过信号调用 `_on_review_finished` → 更新 HTML。
### 12.5 AI 简评线程生命周期
1. 实例化 `BookReviewWorker(bookname, prompt, json_path)`
2. 连接 `finished` 信号到:
- `_on_review_finished`
- `_remove_worker`(清理活动线程列表)
3. `worker.start()` → 线程内部:调用 `DashScopeChatClient.ask()`;写入/更新 `bookintro.json`;发送 `finished` 信号。
4. 主线程根据 `_current_bookname` 校验是否仍是当前书,防止串写。
### 12.6 已读书籍网格刷新逻辑
事件触发:
1. 程序启动初次调用 `_populate_finished_books_grid()`
2. Tab 切换到“已读书籍”标签 → `_on_main_tab_changed()` → 若命中,立即 `_relayout_finished_grid()` 并延迟一次;(后续可扩展为定期重新查询)
3. (可选待扩展)外部刷新按钮调用 `_populate_finished_books_grid()`
数据来源:`BookListManager.get_finished_books_this_year()`
- 查询本地 `BKLibrary.sqlite` 中 `ZISFINISHED=1 AND ZDATEFINISHED NOT NULL`
- 将 Apple epoch(2001) 秒转为 `datetime`,过滤 `year==当前年`
- 返回列表后排序(时间倒序)
### 12.7 封面加载与缩放流程
1. `_load_initial()` / `update_book_info()` 内调用 `find_book_cover()` 获取路径
2. 原图 QPixmap 存入 `_cover_pixmaps_original`
3. `_apply_cover_scale()` 使用当前 `cover_ratio`(默认 1.2)和弹性策略计算目标高度
4. 固定宽 180px受硬上限 400px 与(可选)文本区 45% 限制
5. 非弹性模式忽略文本高度,仅 ratio + 硬上限。
### 12.8 导出流程Export Notes
1. 用户点击“导出”按钮 → `export_notes()`
2. 取当前行 assetid → `BookNotesExporter.build_booksnote(bookid)`
3. 组装单书字典 → `export_booksnote_to_md()` 输出 Markdown 到 `notes/` 目录
4. 弹窗提示路径。
### 12.9 统计图表流程
1. 启动后调用 `_init_charts()`(懒加载)
2. 获取周 / 月 / 年聚合数据及总指标
3. 构造原生自绘组件 `BarChartWidget` / `ScatterChartWidget` / `BubbleMetricsWidget`
4. 添加到对应 Layout。
### 12.10 关键调用关系(摘要)
```
update_book_info -> find_book_cover (CoverMixin)
update_book_info -> BookReviewWorker.start -> _on_review_finished
_populate_finished_books_grid (FinishedBooksMixin) -> manager.get_finished_books_this_year
_on_finished_cover_clicked -> _switch_to_export_tab -> listwidget.setCurrentRow -> update_book_info
export_notes -> BookNotesExporter.build_booksnote -> export_booksnote_to_md
_init_charts -> manager.get_total_readtime* 系列函数
```
### 12.11 数据流摘要
```
iBooks 原始文件 -> sync_source_files -> data/*.sqlite / Books.plist
└─ BookListManager 载入 -> booksinfo / open_times / 阅读统计
├─ IBookExportApp 构建主列表
├─ FinishedBooksMixin 查询已读 -> 网格
└─ charts.py 生成可视化
注释数据库 -> AnnotationManager -> 笔记结构 -> BookNotesExporter.build_booksnote -> Markdown
AI 请求 -> BookReviewWorker -> DashScopeChatClient.ask -> bookintro.json -> _on_review_finished 渲染
```
### 12.12 扩展点与建议
1. 已读书籍:增加“显示全部年份 / 仅今年”开关;提供手动“刷新”按钮。
2. 封面缓存:引入 LRU (assetid -> QPixmap) 降低重复磁盘扫描。
3. AI 简评:加速策略(本地缓存 TTL、批量预取前 N 本)。
4. 异步任务:统一线程池/任务队列,避免过多 QThread 分散管理。
5. 测试建议:
- 单元:`BookListManager.get_finished_books_this_year` 年份过滤、无行时返回。
- 单元:封面查找:构造临时目录含多个候选文件。
- 集成:启动后模拟选择书籍 → 断言 `_current_bookname` 及 HTML 含字段。
6. 性能:大书量时(>1000列表初始化可用分页或懒加载。
7. 打包:后续可用 `PyInstaller`将可执行与资源icons、ui整合。
### 12.13 风险与缓解
| 风险 | 描述 | 缓解 |
|------|------|------|
| 数据不同步 | data/ 下 sqlite 未更新导致已读缺失 | 提供“重新同步”按钮;比对文件修改时间 |
| AI 接口失败 | 网络/配额问题 | 回退本地提示文本;重试按钮 |
| UI 首次网格错排 | 视口宽度未稳定 | 采用双阶段(立即+延迟)重排 & Resize 监听 |
| 线程未回收 | 多次切换书籍产生积压 | 维护 `_active_workers` 列表并在完成回收 |
---
(本节为 2025-09 结构化重构新增)
## 13. UML 图Mermaid
> 注:使用 Mermaid 语法,支持在支持渲染的 Markdown 查看。类之间仅展示主要依赖/调用,非完整字段集合。
### 13.1 类图(核心模块)
```mermaid
classDiagram
class IBookExportApp {
+_load_initial()
+update_book_info(row)
+export_notes()
+_on_finished_cover_clicked(asset_id)
+_on_main_tab_changed(index)
}
class CoverMixin {
+find_book_cover(assetid, info)
+_apply_cover_scale()
+set_cover_ratio(r, force)
}
class FinishedBooksMixin {
+_populate_finished_books_grid()
+_relayout_finished_grid()
}
class BookReviewWorker {
+run()
+finished(book, review)
}
class BookListManager {
+get_books_info()
+get_books_last_open()
+get_finished_books_this_year()
+get_total_readtime(days)
}
class BookNotesExporter {
+build_booksnote(bookid)
+export_booksnote_to_md(note, info, path)
}
class AnnotationManager {
+get_annotations(bookid)
}
class DashScopeChatClient {
+ask(prompt)
}
class BarChartWidget
class BubbleMetricsWidget
class ScatterChartWidget
IBookExportApp --> CoverMixin : mixin
IBookExportApp --> FinishedBooksMixin : mixin
IBookExportApp --> BookListManager : uses
IBookExportApp --> BookNotesExporter : uses
IBookExportApp --> BookReviewWorker : creates
BookReviewWorker --> DashScopeChatClient : uses
BookListManager --> AnnotationManager : uses
IBookExportApp --> BarChartWidget : creates
IBookExportApp --> BubbleMetricsWidget : creates
IBookExportApp --> ScatterChartWidget : creates
FinishedBooksMixin --> BookListManager : query finished
CoverMixin --> config : paths
```
### 13.2 时序图:应用启动
```mermaid
sequenceDiagram
participant User
participant App as IBookExportApp
participant Sync as sync_source_files
participant BLM as BookListManager
participant Exporter as BookNotesExporter
User->>App: 启动应用
App->>Sync: 同步原始数据
App->>BLM: 构造 & 加载 booksinfo
App->>Exporter: 初始化导出器
App->>App: _load_initial() (封面/首书)
App->>App: _populate_finished_books_grid()
App->>App: 延迟 _relayout_finished_grid()
App-->>User: 主界面显示
```
### 13.3 时序图:选择书籍 + AI 简评
```mermaid
sequenceDiagram
participant User
participant App as IBookExportApp
participant Cover as CoverMixin
participant Worker as BookReviewWorker
participant Chat as DashScopeChatClient
User->>App: 列表选择书籍
App->>Cover: find_book_cover()
Cover-->>App: 封面路径/None
App->>App: update_book_info() 刷新封面/HTML
alt 本地已有简评
App-->>User: 显示简评
else 无简评
App->>Worker: 创建并 start()
Worker->>Chat: ask(prompt)
Chat-->>Worker: review 文本
Worker-->>App: finished(book, review)
App-->>User: 渲染书评
end
```
### 13.4 时序图:导出 Markdown
```mermaid
sequenceDiagram
participant User
participant App as IBookExportApp
participant Exporter as BookNotesExporter
User->>App: 点击 导出
App->>Exporter: build_booksnote(bookid)
Exporter-->>App: 笔记结构
App->>Exporter: export_booksnote_to_md()
Exporter-->>App: 输出路径
App-->>User: 弹窗提示成功
```
### 13.5 时序图:已读书籍网格刷新
```mermaid
sequenceDiagram
participant User
participant App as IBookExportApp
participant FB as FinishedBooksMixin
participant BLM as BookListManager
User->>App: 切换到 已读书籍 Tab
App->>FB: _populate_finished_books_grid() (必要时)
FB->>BLM: get_finished_books_this_year()
BLM-->>FB: 已读列表
FB->>FB: _relayout_finished_grid()
FB-->>User: 网格显示
```
### 13.6 时序图:点击已读封面跳转
```mermaid
sequenceDiagram
participant User
participant FB as FinishedBooksMixin
participant App as IBookExportApp
User->>FB: 点击封面 QLabel
FB->>App: _on_finished_cover_clicked(asset_id)
App->>App: _switch_to_export_tab()
App->>App: listwidget.setCurrentRow()
App->>App: update_book_info()
App-->>User: 显示书籍 & 简评/加载中
```
---
## 14. 书籍阅读时长统计与可视化
### 14.1 阅读时长统计逻辑
1. `readtime30d`每本书最近30天每天的阅读时长分钟索引0为今天索引29为30天前。
2. `readtime12m`每本书今年每月的累计阅读时长分钟索引0为1月索引11为12月。统计逻辑为遍历今年每一天按月累计。
3. `readtime_year`:每本书今年总阅读时长(分钟),为`readtime12m`各月之和。
4. 支持无笔记但当天有打开书籍时,阅读时长设为`READ_TIME_OPEN_DAY`config.py配置默认30分钟
5. 多条笔记时统计相邻笔记时间差仅累加小于3小时的部分更真实反映实际阅读行为。
### 14.2 全局统计函数
- `get_total_readtime_year()`:返回全年所有书的累计阅读时间(分钟)。
- `get_total_readtime12m()`返回全年所有书的月度累计阅读时间长度12的列表单位分钟
- `get_total_readtime(days=30)`返回最近days天每天所有书籍的总阅读时间分钟索引0为今天。
#### 设计说明
- 所有统计均以“分钟”为单位,便于可视化和分析。
- 年度统计遍历今年每一天,保证月度和年度数据完整。
- 统计逻辑与实际阅读行为高度贴合,支持无笔记但有打开书籍的场景。
---
### 14.3 可视化设计(统计标签页)
**布局**:统计页使用 2x2 宫格:
- 左上frame_bubble综合指标气泡图。
- 右上frame_year全年 12 个月阅读时长柱状图。
- 左下 (frame_week):最近 7 天阅读时长柱状图索引0=今天)。
- 右下 (frame_month):最近 30 天阅读时长柱状图索引0=今天)。
**数据来源**
- 周图:`get_total_readtime(days=7)` 结果列表(单位:分钟)。
- 月图:`get_total_readtime(days=30)` 结果列表(单位:分钟)。
- 年图:`get_total_readtime12m()` 返回长度 12 列表(分钟)。
- 综合:
* 全年阅读小时数 = `get_total_readtime_year() / 60`向下取整或保留1位小数
* 月均阅读小时数 = `(sum(month_list) / 12) / 60`。
* 近7天阅读小时数 = `sum(week_list) / 60`。
* 日均阅读分钟数 = `sum(month_list[:30 或 recent30]) / 30`使用最近30天合计除以30
**气泡图**
- 使用 5 个气泡新增“已读”分别表示全年累计、月均、近7天、日均、今年已读完书籍数量。
- 半径 r ~ sqrt(value_normalized) 以减弱大值差异;先将不同单位映射到统一“分钟等价”尺度:`h -> *60``m -> 原值``book -> *60`(约定 1 本折算 1 小时,确保视觉上不至过小)。
- 默认布局4 指标布局在十字形当包含“已读”且数量≥5 时采用菱形 + 中心分布(中间放“已读”)。
- 颜色:全年(#5b6ee1)、月均(#c9b2d9)、近7天(#f4b2c2)、日均(#b9b542)、已读(#6aa84f)。
- 文本格式:`值 + 单位\n标签`;单位规则:`h` 显示“x.x 小时 / x 小时”,`m` 显示“x 分钟”,`book` 显示“x 本书”。
- 归一化约束:自动检测重叠,通过求最小非重叠缩放系数 S使圆之间保留最小 6px 间距。
**渲染技术**
- 使用原生 Qt 自绘组件QWidget + QPainter实现柱状图与气泡图文件 `charts.py`。
- 优势:减少第三方依赖(移除 matplotlib启动更快、打包体积更小自绘可精细掌控布局与样式。
- 结构:
* `BarChartWidget`:通用柱状图组件,支持数值标签、自适应缩放、单位显示。
* `BubbleMetricsWidget`:四指标气泡图,按归一化后的平方根缩放半径,支持动态指标扩展。
- 刷新策略:当前初始化时构建;若后续增加刷新按钮,可对组件调用 setData/setMetrics 后 update()。
**更新策略**
1. 启动时已调用 `sync_source_files`,再构建 `BookListManager`。
2. 通过管理器获取三类聚合数据。
3. 生成 numpy 数组(可选)并绘制。
4. 若无数据(全 0显示占位提示“暂无阅读数据”。
**异常处理**
- 捕获绘图异常ImportError/RuntimeError在 frame 中放置 QLabel 显示错误信息而不是抛出。
**后续扩展**
- 柱状图支持堆叠 / 渐变填充、鼠标 hover tooltip。
- 气泡图支持动画过渡或改为雷达/仪表盘形式;“已读”气泡可按年份切换(未来提供年份选择器)。
- 增加刷新按钮与 Esc 退出全屏逻辑。