'update'
This commit is contained in:
parent
1ba01e3c64
commit
4d033257fe
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:ibook}/ipad_app",
|
||||
"name": "Debug IpadReaderCLI (ipad_app)",
|
||||
"program": "${workspaceFolder:ibook}/ipad_app/.build/debug/IpadReaderCLI",
|
||||
"preLaunchTask": "swift: Build Debug IpadReaderCLI (ipad_app)"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:ibook}/ipad_app",
|
||||
"name": "Release IpadReaderCLI (ipad_app)",
|
||||
"program": "${workspaceFolder:ibook}/ipad_app/.build/release/IpadReaderCLI",
|
||||
"preLaunchTask": "swift: Build Release IpadReaderCLI (ipad_app)"
|
||||
}
|
||||
]
|
||||
}
|
1
TODO.md
1
TODO.md
|
@ -53,6 +53,7 @@
|
|||
- [ ] 添加简单单元测试框架 (pytest) 验证封面解析与导出核心逻辑
|
||||
- [ ] 封面区域添加标题(“当前/下一本/再下一本”)并可隐藏
|
||||
- [ ] 支持拖放本地 epub/ibooks 包,临时解析显示
|
||||
- [ ] 界面字体可选择,config中配置
|
||||
|
||||
## 4. 技术债 (Tech Debt)
|
||||
- [ ] `ibook_export_app.py` 体积过大(UI/业务/AI/封面解析混杂)→ 拆分模块:`cover_finder.py` / `ai_review.py` / `ui_main.py`
|
||||
|
|
Binary file not shown.
18
config.py
18
config.py
|
@ -42,3 +42,21 @@ READ_TIME_DAY = 60
|
|||
# 注意:为安全起见,生产或开源仓库不要直接硬编码真实密钥,建议只保留 os.environ 读取逻辑。
|
||||
DASHSCOPE_API_KEY = os.environ.get('DASHSCOPE_API_KEY', 'sk-2546da09b6d9471894aeb95278f96c11')
|
||||
|
||||
# ---------------- 字体配置 ----------------
|
||||
# 可选字体列表(用户可在配置对话框中输入其一);若系统不存在则按回退顺序尝试。
|
||||
FONT_CANDIDATES = [
|
||||
'PingFang SC', # macOS 系统中文苹方 (简体)
|
||||
'PingFang TC', # 繁体
|
||||
'PingFang HK',
|
||||
'PingFang', # 有些场景可能只暴露基名
|
||||
'Fira Code',
|
||||
'JetBrains Mono',
|
||||
'Comic Mono',
|
||||
'Input Sans',
|
||||
]
|
||||
|
||||
# 当前使用的主字体(默认首选苹方简体),字体大小默认
|
||||
FONT_FAMILY = os.environ.get('APP_FONT_FAMILY', 'PingFang SC')
|
||||
FONT_SIZE = int(os.environ.get('APP_FONT_SIZE', '14'))
|
||||
|
||||
|
||||
|
|
BIN
data/Books.plist
BIN
data/Books.plist
Binary file not shown.
Binary file not shown.
586
detaildesign.md
586
detaildesign.md
|
@ -1,586 +0,0 @@
|
|||
# iBooks 笔记专家 详细设计文档
|
||||
|
||||
> 版本: 1.1 (2025-09 重构整理)
|
||||
> 维护者: 项目开发组
|
||||
> 说明: 本文档统一重新编排章节,增加架构与 UML 部分,便于后续扩展与维护。
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.0 | 2025-08 | 初版文档 |
|
||||
| 1.1 | 2025-09 | 重组目录;新增模块拆分、UML、AI 简评与可视化章节整理 |
|
||||
|
||||
## 1. 概述
|
||||
|
||||
本工具用于从 macOS iBooks(Apple Books)应用的数据文件中提取用户的书籍笔记,并以 Markdown 格式导出。支持从 iBooks 的数据库和 plist 文件自动同步数据,支持交互式选择书籍导出,导出内容结构清晰,便于后续整理和阅读。
|
||||
支持按最近打开时间排序书籍,菜单显示书名与时间戳,导出流程高效。
|
||||
|
||||
---
|
||||
|
||||
## 2. 主要功能
|
||||
|
||||
- 自动同步 iBooks 数据库和书籍信息文件到本地 `./data` 目录。
|
||||
- 解析 iBooks 笔记数据库,构建结构化的 `booksnote` 数据。
|
||||
- 解析书籍元数据(如书名、路径等)。
|
||||
- 支持交互式模糊搜索选择要导出的书籍。
|
||||
- 按章节导出所选书籍的所有笔记,格式为 Markdown。
|
||||
- 书名中如含有“-xxxx”后缀,仅保留“-”前的主书名。
|
||||
- 书籍选择菜单按最近打开时间(last_open)降序排序,显示格式为“书名 [时间戳]”。
|
||||
|
||||
---
|
||||
|
||||
## 3. 主要数据结构
|
||||
|
||||
### 3.1 booksnote
|
||||
|
||||
```python
|
||||
booksnote = {
|
||||
assetid: { label_path: { uuid: {
|
||||
'creationdate': '2023/7/12',
|
||||
'filepos': None,
|
||||
'idref': '008.xhtml',
|
||||
'note': None,
|
||||
'selectedtext': '這就是宣傳的恐怖之處'
|
||||
}}}
|
||||
}
|
||||
```
|
||||
- `assetid`:书籍唯一标识
|
||||
- `label_path`:章节名
|
||||
- `uuid`:笔记唯一标识
|
||||
- 其余字段为笔记内容及元数据
|
||||
|
||||
---
|
||||
|
||||
## 4. 主要流程
|
||||
|
||||
### 4.1 数据同步
|
||||
|
||||
- 自动将 iBooks 的数据库和 plist 文件复制到本地 `data/` 目录,便于后续处理。
|
||||
|
||||
### 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 退出全屏逻辑。
|
||||
|
Binary file not shown.
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -18,7 +18,6 @@ from cover_mixin import CoverMixin
|
|||
from finished_books_mixin import FinishedBooksMixin
|
||||
|
||||
|
||||
|
||||
class ConfigDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
@ -111,6 +110,12 @@ class IBookExportApp(CoverMixin, FinishedBooksMixin, QWidget):
|
|||
# ====== 信号 ======
|
||||
self.export_btn.clicked.connect(self.export_notes)
|
||||
self.config_btn.clicked.connect(self.show_config)
|
||||
# iPad 数据包导出按钮
|
||||
if hasattr(self, 'ipad_export_btn'):
|
||||
try:
|
||||
self.ipad_export_btn.clicked.connect(self.export_ipad_bundle)
|
||||
except Exception:
|
||||
pass
|
||||
self.listwidget.currentRowChanged.connect(self.update_book_info)
|
||||
self.listwidget.installEventFilter(self)
|
||||
# ====== 封面标签 ======
|
||||
|
@ -159,6 +164,11 @@ class IBookExportApp(CoverMixin, FinishedBooksMixin, QWidget):
|
|||
lab.setAlignment(_QtAlign.AlignmentFlag.AlignHCenter | _QtAlign.AlignmentFlag.AlignTop)
|
||||
except Exception:
|
||||
pass
|
||||
# 应用全局字体(含苹方支持)
|
||||
try:
|
||||
self._apply_global_font()
|
||||
except Exception:
|
||||
pass
|
||||
# 初始封面 + 首本书信息 (及 AI 简评触发)
|
||||
self._load_initial()
|
||||
# 已读书籍网格
|
||||
|
@ -399,11 +409,28 @@ class IBookExportApp(CoverMixin, FinishedBooksMixin, QWidget):
|
|||
self.exporter.export_booksnote_to_md(selected_booksnote, selected_booksinfo, out_path)
|
||||
QMessageBox.information(self, "导出成功", f"已导出到:{out_path}")
|
||||
|
||||
def export_ipad_bundle(self):
|
||||
"""导出 iPad 数据包 ZIP。"""
|
||||
try:
|
||||
from ipad_bundle_export import export_ipad_bundle
|
||||
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
out_dir = getattr(config, 'EXPORT_NOTES_DIR', os.getcwd())
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, f'ipad_bundle_{ts}.zip')
|
||||
export_ipad_bundle(out_path, self.manager, self.exporter)
|
||||
QMessageBox.information(self, '完成', f'iPad 数据包已生成:\n{out_path}')
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, '错误', f'导出 iPad 数据包失败: {e}')
|
||||
|
||||
def show_config(self):
|
||||
dlg = ConfigDialog(self)
|
||||
if dlg.exec():
|
||||
dlg.get_config()
|
||||
QMessageBox.information(self, "提示", "配置已更新(仅本次运行有效)")
|
||||
try:
|
||||
self._apply_global_font()
|
||||
except Exception as e:
|
||||
print('字体刷新失败:', e)
|
||||
|
||||
def update_book_info(self, row):
|
||||
if row < 0:
|
||||
|
@ -521,7 +548,43 @@ class IBookExportApp(CoverMixin, FinishedBooksMixin, QWidget):
|
|||
self._relayout_finished_grid()
|
||||
except Exception:
|
||||
pass
|
||||
super().resizeEvent(event)
|
||||
|
||||
# ---------------- 全局字体应用(含苹方别名) ----------------
|
||||
def _apply_global_font(self):
|
||||
try:
|
||||
from PyQt6.QtGui import QFontDatabase, QFont
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
fam_cfg = getattr(config, 'FONT_FAMILY', None)
|
||||
size = int(getattr(config, 'FONT_SIZE', 14))
|
||||
candidates_cfg = list(getattr(config, 'FONT_CANDIDATES', []))
|
||||
# 如果用户直接输入 'PingFang' 统一展开具体系列供匹配
|
||||
extra_pingfang = ['PingFang SC','PingFang TC','PingFang HK']
|
||||
expanded = []
|
||||
if fam_cfg and fam_cfg.lower().startswith('pingfang'):
|
||||
expanded.extend(extra_pingfang)
|
||||
# 去重保持顺序
|
||||
seen = set()
|
||||
def add_seq(seq):
|
||||
for f in seq:
|
||||
if f and f not in seen:
|
||||
seen.add(f); expanded.append(f)
|
||||
add_seq([fam_cfg])
|
||||
add_seq(expanded)
|
||||
add_seq(candidates_cfg)
|
||||
available = set(QFontDatabase.families())
|
||||
chosen = None
|
||||
for f in expanded:
|
||||
if f in available:
|
||||
chosen = f; break
|
||||
if not chosen:
|
||||
print('[字体] 无可用字体,放弃')
|
||||
return
|
||||
font = QFont(chosen, size)
|
||||
QApplication.instance().setFont(font)
|
||||
self.setFont(font)
|
||||
print(f'[字体] 应用 {chosen} {size}px')
|
||||
except Exception as e:
|
||||
print('全局字体应用失败:', e)
|
||||
|
||||
def _build_book_html(self, review_text: str) -> str:
|
||||
"""构建包含加粗紫红色标题的 HTML 内容,AI书评分段显示。"""
|
||||
|
|
|
@ -16,6 +16,19 @@
|
|||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QTabWidget" name="tabWidget">
|
||||
<item>
|
||||
<widget class="QPushButton" name="ipad_export_btn">
|
||||
<property name="text">
|
||||
<string>导出iPad数据包</string>
|
||||
</property>
|
||||
<property name="minimumHeight">
|
||||
<number>36</number>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">QPushButton {background:#a676ff; color:#ffffff; border:none; padding:6px 24px; font-weight:600; border-radius:18px;} QPushButton:hover {background:#965ee8;} QPushButton:pressed {background:#7d46cf;}</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<property name="currentIndex">
|
||||
<number>1</number>
|
||||
</property>
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import sys
|
||||
import os
|
||||
import re
|
||||
import datetime
|
||||
# 改代码后续丢弃
|
||||
# 不使用matplotlib库来实现绘图,使用QT6自带的绘图功能
|
||||
|
||||
# --- IGNORE ---
|
||||
import sys, os, re, datetime, math
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QWidget, QVBoxLayout, QPushButton, QLabel, QListWidget,
|
||||
QFileDialog, QMessageBox, QLineEdit, QFormLayout, QDialog, QDialogButtonBox
|
||||
QApplication, QWidget, QLabel, QListWidget, QMessageBox, QLineEdit,
|
||||
QFormLayout, QDialog, QDialogButtonBox, QSizePolicy, QHBoxLayout
|
||||
)
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtGui import QIcon, QPixmap
|
||||
from PyQt6.QtCore import QSettings, QSize, QByteArray
|
||||
from PyQt6 import uic
|
||||
import config
|
||||
from exportbooknotes import BookNotesExporter
|
||||
from booklist_parse import BookListManager
|
||||
from review_worker import BookReviewWorker
|
||||
from cover_mixin import CoverMixin
|
||||
from finished_books_mixin import FinishedBooksMixin
|
||||
|
||||
|
||||
class ConfigDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
|
@ -25,288 +31,422 @@ class ConfigDialog(QDialog):
|
|||
layout.addRow(attr, inp)
|
||||
self.inputs[attr] = inp
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.accepted.connect(self._accept_and_apply)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def _accept_and_apply(self):
|
||||
# 写回 config 变量
|
||||
updated = self.get_config()
|
||||
for k, v in updated.items():
|
||||
if not k.isupper():
|
||||
continue
|
||||
old = getattr(config, k, None)
|
||||
if isinstance(old, int):
|
||||
try:
|
||||
setattr(config, k, int(v)); continue
|
||||
except Exception:
|
||||
pass
|
||||
setattr(config, k, v)
|
||||
if self.parent() and hasattr(self.parent(), '_apply_global_font'):
|
||||
try: self.parent()._apply_global_font()
|
||||
except Exception: pass
|
||||
self.accept()
|
||||
|
||||
def get_config(self):
|
||||
return {k: v.text() for k, v in self.inputs.items()}
|
||||
|
||||
class IBookExportApp(QWidget):
|
||||
|
||||
class IBookExportApp(CoverMixin, FinishedBooksMixin, QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# 加载 UI 文件
|
||||
ui_file = os.path.join(os.path.dirname(__file__), 'ibook_export_app.ui')
|
||||
uic.loadUi(ui_file, self)
|
||||
|
||||
# 设置窗口标题
|
||||
self.setWindowTitle("notesExporter")
|
||||
|
||||
# 设置窗口图标
|
||||
self.setWindowTitle("notesExporter (matplot)")
|
||||
if os.path.exists(config.APP_ICON):
|
||||
self.setWindowIcon(QIcon(config.APP_ICON))
|
||||
|
||||
# 启动前同步一次源数据到本地,确保后续 AnnotationManager 读取的是最新副本
|
||||
# 数据同步
|
||||
try:
|
||||
from exportbooknotes import sync_source_files
|
||||
sync_source_files(config)
|
||||
except Exception as e:
|
||||
print(f"警告: 初始同步源数据失败: {e}")
|
||||
|
||||
# 初始化数据
|
||||
print('警告: 初始同步源数据失败:', e)
|
||||
# 数据加载
|
||||
self.exporter = BookNotesExporter(config)
|
||||
self.manager = BookListManager(plist_path=config.LOCAL_BOOKS_PLIST, db_path=config.LOCAL_LIBRARY_DB)
|
||||
self.booksinfo = self.manager.get_books_info()
|
||||
self.last_open_times = self.manager.get_books_last_open()
|
||||
self.assetid2name = {}
|
||||
self.assetid2lastopen = {}
|
||||
for assetid, info in self.booksinfo.items():
|
||||
name = info.get('displayname') or info.get('itemname') or assetid
|
||||
self.assetid2name, self.assetid2lastopen = {}, {}
|
||||
for aid, info in self.booksinfo.items():
|
||||
name = info.get('displayname') or info.get('itemname') or aid
|
||||
if '-' in name:
|
||||
name = name.split('-', 1)[0].strip()
|
||||
self.assetid2name[assetid] = name
|
||||
ts = self.last_open_times.get(assetid, {}).get('last_open', 0)
|
||||
self.assetid2lastopen[assetid] = ts
|
||||
sorted_assetids = sorted(self.assetid2name.keys(), key=lambda aid: self.assetid2lastopen[aid], reverse=True)
|
||||
self.sorted_assetids = sorted_assetids
|
||||
|
||||
# 填充书籍列表
|
||||
for aid in sorted_assetids:
|
||||
self.assetid2name[aid] = name
|
||||
self.assetid2lastopen[aid] = self.last_open_times.get(aid, {}).get('last_open', 0)
|
||||
self.sorted_assetids = sorted(self.assetid2name.keys(), key=lambda a: self.assetid2lastopen[a], reverse=True)
|
||||
for aid in self.sorted_assetids:
|
||||
self.listwidget.addItem(f"{self.assetid2name[aid]} [{self.assetid2lastopen[aid]}]")
|
||||
|
||||
# 连接信号
|
||||
self.export_btn.clicked.connect(self.export_notes)
|
||||
self.config_btn.clicked.connect(self.show_config)
|
||||
|
||||
# 回车直接导出(使用事件过滤器)
|
||||
# 左侧提示标签
|
||||
try:
|
||||
total_books = len(self.sorted_assetids)
|
||||
if hasattr(self, 'label') and isinstance(self.label, QLabel):
|
||||
self.label.setText(f"请选择要导出的书籍【{total_books}本】:")
|
||||
self.label.setStyleSheet("QLabel { background-color:#4c221b;color:#fff;font-weight:bold;padding:4px 6px;border-radius:4px; }")
|
||||
except Exception: pass
|
||||
# 按钮胶囊样式
|
||||
try:
|
||||
if hasattr(self, 'export_btn') and hasattr(self, 'config_btn'):
|
||||
pill_css = (
|
||||
"QPushButton { border:none; color:#ffffff; padding:6px 22px; font-size:14px; font-weight:600; border-radius:22px; }"
|
||||
"QPushButton#export_btn { background: qlineargradient(spread:pad,x1:0,y1:0,x2:1,y2:0,stop:0 #63a9ff, stop:1 #388bff); }"
|
||||
"QPushButton#config_btn { background: qlineargradient(spread:pad,x1:0,y1:0,x2:1,y2:0,stop:0 #7ed957, stop:1 #4caf50); }"
|
||||
)
|
||||
self.export_btn.setObjectName('export_btn')
|
||||
self.config_btn.setObjectName('config_btn')
|
||||
self.export_btn.setStyleSheet(pill_css)
|
||||
self.config_btn.setStyleSheet(pill_css)
|
||||
except Exception: pass
|
||||
# 信号
|
||||
if hasattr(self, 'export_btn'):
|
||||
self.export_btn.clicked.connect(self.export_notes)
|
||||
if hasattr(self, 'config_btn'):
|
||||
self.config_btn.clicked.connect(self.show_config)
|
||||
if hasattr(self, 'ipad_export_btn'):
|
||||
try: self.ipad_export_btn.clicked.connect(self.export_ipad_bundle)
|
||||
except Exception: pass
|
||||
self.listwidget.currentRowChanged.connect(self.update_book_info)
|
||||
self.listwidget.installEventFilter(self)
|
||||
# 封面标签
|
||||
if all(hasattr(self, n) for n in ('cover_label_1','cover_label_2','cover_label_3')):
|
||||
self._cover_labels = [self.cover_label_1, self.cover_label_2, self.cover_label_3]
|
||||
else:
|
||||
self._cover_labels = [getattr(self,'book_cover_label', QLabel('封面', self))]
|
||||
for lab in self._cover_labels:
|
||||
lab.setMinimumWidth(180); lab.setMaximumWidth(180)
|
||||
lab.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||||
self.cover_ratio = 1.2
|
||||
self.book_toc_textedit.setPlainText("书籍信息 / 简评")
|
||||
self._review_worker = None
|
||||
self._current_bookname = None
|
||||
self._active_workers = []
|
||||
self._cover_pixmaps_original = []
|
||||
self._restore_window_geometry()
|
||||
self._load_initial()
|
||||
self._apply_global_font()
|
||||
|
||||
# 事件过滤:回车导出
|
||||
def eventFilter(self, obj, event):
|
||||
from PyQt6.QtCore import QEvent, Qt
|
||||
from PyQt6.QtCore import QEvent
|
||||
if obj == self.listwidget and event.type() == QEvent.Type.KeyPress:
|
||||
# 检查回车键(Enter/Return)
|
||||
if event.key() in (0x01000004, 0x01000005): # Qt.Key_Return, Qt.Key_Enter
|
||||
self.export_notes()
|
||||
return True
|
||||
if event.key() in (0x01000004, 0x01000005):
|
||||
self.export_notes(); return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def show_config(self):
|
||||
dlg = ConfigDialog(self)
|
||||
if dlg.exec():
|
||||
new_config = dlg.get_config()
|
||||
# 这里只是演示,实际可写入config.py或动态加载
|
||||
QMessageBox.information(self, "提示", "配置已更新(仅本次运行有效)")
|
||||
|
||||
# 导出
|
||||
def export_notes(self):
|
||||
idx = self.listwidget.currentRow()
|
||||
if idx < 0:
|
||||
QMessageBox.warning(self, "提示", "请先选择一本书")
|
||||
return
|
||||
assetid = self.sorted_assetids[idx]
|
||||
selected_booksnote = self.exporter.build_booksnote(bookid=assetid)
|
||||
selected_booksinfo = {assetid: self.booksinfo.get(assetid, {})}
|
||||
bookname = selected_booksinfo[assetid].get("displayname") or selected_booksinfo[assetid].get("itemname") or assetid
|
||||
QMessageBox.warning(self, '提示', '请先选择一本书'); return
|
||||
aid = self.sorted_assetids[idx]
|
||||
selected_booksnote = self.exporter.build_booksnote(bookid=aid)
|
||||
selected_booksinfo = {aid: self.booksinfo.get(aid, {})}
|
||||
bookname = selected_booksinfo[aid].get('displayname') or selected_booksinfo[aid].get('itemname') or aid
|
||||
ts = datetime.datetime.now().strftime('%m%d%H%M')
|
||||
shortname = re.split(r'[.::_\【\[\((]', bookname)[0].strip()
|
||||
export_dir = getattr(config, "EXPORT_NOTES_DIR", os.getcwd())
|
||||
if not os.path.exists(export_dir):
|
||||
os.makedirs(export_dir)
|
||||
out_path = os.path.join(export_dir, f"notes_{shortname}-{ts}.md")
|
||||
export_dir = getattr(config,'EXPORT_NOTES_DIR', os.getcwd())
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
out_path = os.path.join(export_dir, f'notes_{shortname}-{ts}.md')
|
||||
self.exporter.export_booksnote_to_md(selected_booksnote, selected_booksinfo, out_path)
|
||||
QMessageBox.information(self, "导出成功", f"已导出到:{out_path}")
|
||||
QMessageBox.information(self,'导出成功', f'已导出到:{out_path}')
|
||||
|
||||
# ---------------- 图表相关 -----------------
|
||||
# iPad 数据包
|
||||
def export_ipad_bundle(self):
|
||||
try:
|
||||
from ipad_bundle_export import export_ipad_bundle
|
||||
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
out_dir = getattr(config,'EXPORT_NOTES_DIR', os.getcwd())
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, f'ipad_bundle_{ts}.zip')
|
||||
export_ipad_bundle(out_path, self.manager, self.exporter)
|
||||
QMessageBox.information(self,'完成', f'iPad 数据包已生成:\n{out_path}')
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self,'错误', f'导出失败: {e}')
|
||||
|
||||
# 配置
|
||||
def show_config(self):
|
||||
dlg = ConfigDialog(self)
|
||||
if dlg.exec():
|
||||
QMessageBox.information(self,'提示','配置已更新(仅本次运行有效)')
|
||||
|
||||
# 初始封面 + 首本书信息
|
||||
def _load_initial(self):
|
||||
try:
|
||||
if not hasattr(self,'_cover_labels') or not self.sorted_assetids:
|
||||
return
|
||||
from PyQt6.QtGui import QPixmap
|
||||
self._cover_pixmaps_original = []
|
||||
first_indices = list(range(min(3, len(self.sorted_assetids))))
|
||||
for pos in range(3):
|
||||
if pos < len(first_indices):
|
||||
aid = self.sorted_assetids[first_indices[pos]]
|
||||
info = self.booksinfo.get(aid, {})
|
||||
cpath = self.find_book_cover(aid, info)
|
||||
if cpath:
|
||||
pm = QPixmap(cpath)
|
||||
if not pm.isNull():
|
||||
self._cover_pixmaps_original.append(pm)
|
||||
self._cover_labels[pos].setPixmap(pm); continue
|
||||
self._cover_pixmaps_original.append(None)
|
||||
self._cover_labels[pos].setText('无封面')
|
||||
else:
|
||||
self._cover_pixmaps_original.append(None)
|
||||
self._cover_labels[pos].setText('')
|
||||
self._apply_cover_scale()
|
||||
# 首本书信息 & AI 书评
|
||||
aid0 = self.sorted_assetids[0]
|
||||
info0 = self.booksinfo.get(aid0, {})
|
||||
bookname_display = info0.get('displayname') or info0.get('itemname') or aid0
|
||||
author = info0.get('author',''); btype = info0.get('type',''); datev = info0.get('date','')
|
||||
self._base_info_cache = {'bookname': bookname_display,'author':author,'type':btype,'date':datev}
|
||||
self._current_bookname = bookname_display
|
||||
import json
|
||||
json_path = os.path.join(os.path.dirname(__file__), 'bookintro.json')
|
||||
try:
|
||||
with open(json_path,'r',encoding='utf-8') as f: intro_dict = json.load(f)
|
||||
except Exception: intro_dict = {}
|
||||
review = intro_dict.get(bookname_display)
|
||||
if review:
|
||||
self.book_toc_textedit.setHtml(self._build_book_html(review))
|
||||
else:
|
||||
self.book_toc_textedit.setHtml(self._build_book_html('简评获取中...'))
|
||||
prompt = f"{bookname_display} 400字书评 三段 简洁精炼"
|
||||
worker = BookReviewWorker(bookname_display, prompt, json_path, parent=self)
|
||||
worker.finished.connect(lambda b,r: self._on_review_finished(b,r))
|
||||
worker.finished.connect(lambda _b,_r,w=worker: self._remove_worker(w))
|
||||
self._review_worker = worker; self._active_workers.append(worker); worker.start()
|
||||
except Exception as e:
|
||||
print('初始加载失败:', e)
|
||||
|
||||
# 更新书籍信息(列表选中)
|
||||
def update_book_info(self, row):
|
||||
try:
|
||||
if row < 0:
|
||||
self.book_toc_textedit.clear(); return
|
||||
aid = self.sorted_assetids[row]
|
||||
info = self.booksinfo.get(aid, {})
|
||||
total = len(self.sorted_assetids)
|
||||
indices = [(row + i) % total for i in range(min(3,total))]
|
||||
from PyQt6.QtGui import QPixmap
|
||||
self._cover_pixmaps_original = []
|
||||
for lab in self._cover_labels: lab.clear(); lab.setText('加载中')
|
||||
for pos, idx in enumerate(indices):
|
||||
aid_show = self.sorted_assetids[idx]; binfo = self.booksinfo.get(aid_show, {})
|
||||
cpath = self.find_book_cover(aid_show, binfo); label = self._cover_labels[pos]
|
||||
if cpath:
|
||||
pm = QPixmap(cpath)
|
||||
if not pm.isNull(): self._cover_pixmaps_original.append(pm); label.setText(''); continue
|
||||
self._cover_pixmaps_original.append(None); label.setText('无封面')
|
||||
for pos in range(len(indices),3): self._cover_labels[pos].setText('无'); self._cover_pixmaps_original.append(None)
|
||||
self._apply_cover_scale()
|
||||
bookname_display = info.get('displayname') or info.get('itemname') or aid
|
||||
author = info.get('author',''); btype = info.get('type',''); datev = info.get('date','')
|
||||
self._base_info_cache = {'bookname':bookname_display,'author':author,'type':btype,'date':datev}
|
||||
self._current_bookname = bookname_display
|
||||
import json
|
||||
json_path = os.path.join(os.path.dirname(__file__),'bookintro.json')
|
||||
try:
|
||||
with open(json_path,'r',encoding='utf-8') as f: intro_dict = json.load(f)
|
||||
except Exception: intro_dict = {}
|
||||
review = intro_dict.get(bookname_display)
|
||||
if review:
|
||||
self.book_toc_textedit.setHtml(self._build_book_html(review))
|
||||
else:
|
||||
prompt = f"{bookname_display} 400字书评 三段 简洁精炼"
|
||||
self.book_toc_textedit.setHtml(self._build_book_html('简评获取中...'))
|
||||
worker = BookReviewWorker(bookname_display, prompt, json_path, parent=self)
|
||||
worker.finished.connect(lambda b,r: self._on_review_finished(b,r))
|
||||
worker.finished.connect(lambda _b,_r,w=worker: self._remove_worker(w))
|
||||
self._review_worker = worker; self._active_workers.append(worker); worker.start()
|
||||
except Exception as e:
|
||||
print('更新书籍信息失败:', e)
|
||||
|
||||
def _on_review_finished(self, bookname, review):
|
||||
if bookname != getattr(self,'_current_bookname',None): return
|
||||
self.book_toc_textedit.setHtml(self._build_book_html(review))
|
||||
|
||||
def _remove_worker(self, worker):
|
||||
try:
|
||||
if worker in self._active_workers: self._active_workers.remove(worker)
|
||||
except Exception: pass
|
||||
|
||||
# HTML 构建
|
||||
def _build_book_html(self, review_text: str) -> str:
|
||||
info = getattr(self,'_base_info_cache',{})
|
||||
magenta = '#C71585'
|
||||
def line(t,v): return f"<p><span style='color:{magenta};font-weight:bold;'>{t}</span> {v}</p>"
|
||||
def review_lines(txt):
|
||||
if not txt: return ['']
|
||||
segs = [s.strip() for s in re.split(r'\n{2,}|\r{2,}|\n|\r|\s{2,}', txt) if s.strip()]
|
||||
return [f"<p>{s}</p>" for s in segs]
|
||||
parts = [
|
||||
line('书名:', f"{info.get('author','')} - {info.get('bookname','')}"),
|
||||
line('作者:', info.get('author','')),
|
||||
line('类型:', info.get('type','')),
|
||||
line('获取时间:', info.get('date','')),
|
||||
f"<span style='color:{magenta};font-weight:bold;'>书籍简评:</span>"
|
||||
]
|
||||
parts += review_lines(review_text)
|
||||
return ''.join(parts)
|
||||
|
||||
# 字体
|
||||
def _apply_global_font(self):
|
||||
try:
|
||||
from PyQt6.QtGui import QFontDatabase, QFont
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
fam_cfg = getattr(config,'FONT_FAMILY',None)
|
||||
size = int(getattr(config,'FONT_SIZE',14))
|
||||
cands = list(getattr(config,'FONT_CANDIDATES',[]))
|
||||
extra_pf = ['PingFang SC','PingFang TC','PingFang HK'] if fam_cfg and fam_cfg.lower().startswith('pingfang') else []
|
||||
ordered = []
|
||||
seen=set()
|
||||
for f in [fam_cfg]+extra_pf+cands:
|
||||
if f and f not in seen:
|
||||
seen.add(f); ordered.append(f)
|
||||
avail = set(QFontDatabase.families())
|
||||
chosen = None
|
||||
for f in ordered:
|
||||
if f in avail: chosen=f; break
|
||||
if not chosen:
|
||||
print('[字体] 未找到可用字体'); return
|
||||
font = QFont(chosen, size)
|
||||
QApplication.instance().setFont(font); self.setFont(font)
|
||||
print(f'[字体] 应用 {chosen} {size}px')
|
||||
except Exception as e:
|
||||
print('字体应用失败:', e)
|
||||
|
||||
# 窗口尺寸持久化
|
||||
def _restore_window_geometry(self):
|
||||
settings = QSettings('iBookTools','notesExporterMatplot')
|
||||
geo = settings.value('mainWindowGeometry')
|
||||
if isinstance(geo, QByteArray) and not geo.isEmpty():
|
||||
try: self.restoreGeometry(geo); return
|
||||
except Exception: pass
|
||||
self.resize(1500,900)
|
||||
|
||||
def closeEvent(self, event):
|
||||
try:
|
||||
settings = QSettings('iBookTools','notesExporterMatplot')
|
||||
settings.setValue('mainWindowGeometry', self.saveGeometry())
|
||||
except Exception: pass
|
||||
super().closeEvent(event)
|
||||
|
||||
# 覆盖尺寸变化(封面缩放)
|
||||
def resizeEvent(self, event):
|
||||
try: self._apply_cover_scale()
|
||||
except Exception: pass
|
||||
super().resizeEvent(event)
|
||||
|
||||
# ---------------- 图表(matplotlib 保留并加“已读”) -----------------
|
||||
def _init_charts(self):
|
||||
"""初始化并渲染统计标签页内的四个图表。若 matplotlib 不可用或 frame 不存在则忽略。"""
|
||||
# 延迟导入 matplotlib,避免无图形依赖时阻塞主功能
|
||||
try:
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
# 中文字体设置:尝试常见中文字体,找到即设置
|
||||
import matplotlib, json
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
# 优先使用苹方
|
||||
zh_pref = ['PingFang SC','PingFang','Heiti SC','STHeiti','Hiragino Sans GB','Songti SC','SimHei','Microsoft YaHei']
|
||||
try:
|
||||
import matplotlib
|
||||
from matplotlib import font_manager
|
||||
candidate_fonts = [
|
||||
'PingFang SC', 'Heiti SC', 'STHeiti', 'Hiragino Sans GB', 'Songti SC',
|
||||
'SimHei', 'SimSun', 'Microsoft YaHei', 'WenQuanYi Zen Hei'
|
||||
]
|
||||
available = set(f.name for f in font_manager.fontManager.ttflist)
|
||||
zh_font = None
|
||||
for f in candidate_fonts:
|
||||
if f in available:
|
||||
zh_font = f
|
||||
break
|
||||
if zh_font:
|
||||
matplotlib.rcParams['font.family'] = zh_font
|
||||
# 解决负号显示问题
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
except Exception as fe:
|
||||
print(f"信息: 中文字体配置失败: {fe}")
|
||||
except Exception as e: # matplotlib 可能未安装
|
||||
print(f"信息: 未加载统计图表(matplotlib不可用):{e}")
|
||||
return
|
||||
|
||||
# 检查 frame 是否存在
|
||||
required_frames = [
|
||||
('frame_week', 'weekLayout'),
|
||||
('frame_month', 'monthLayout'),
|
||||
('frame_year', 'yearLayout'),
|
||||
('frame_bubble', 'bubbleLayout'),
|
||||
]
|
||||
for attr, _ in required_frames:
|
||||
if not hasattr(self, attr):
|
||||
print("信息: 缺少统计容器", attr)
|
||||
return
|
||||
|
||||
# 获取数据
|
||||
try:
|
||||
week_data = self.manager.get_total_readtime(days=7) # 索引0=今天
|
||||
month_data = self.manager.get_total_readtime(days=30)
|
||||
year_data = self.manager.get_total_readtime12m() # 12个月
|
||||
year_total_minutes = self.manager.get_total_readtime_year()
|
||||
avail = set(f.name for f in font_manager.fontManager.ttflist)
|
||||
for nm in zh_pref:
|
||||
if nm in avail:
|
||||
matplotlib.rcParams['font.family'] = nm; break
|
||||
except Exception: pass
|
||||
except Exception as e:
|
||||
print(f"警告: 统计数据获取失败: {e}")
|
||||
print('信息: 未加载统计图表:', e); return
|
||||
frames = [('frame_week','weekLayout'),('frame_month','monthLayout'),('frame_year','yearLayout'),('frame_bubble','bubbleLayout')]
|
||||
if any(not hasattr(self,a) for a,_ in frames):
|
||||
print('信息: 缺少统计容器'); return
|
||||
try:
|
||||
week_data = self.manager.get_total_readtime(days=7)
|
||||
month_data = self.manager.get_total_readtime(days=30)
|
||||
year_data = self.manager.get_total_readtime12m()
|
||||
year_total_minutes = self.manager.get_total_readtime_year()
|
||||
finished_books = self.manager.get_finished_books_this_year()
|
||||
finished_count = len(finished_books)
|
||||
except Exception as e:
|
||||
print('统计数据获取失败:', e); return
|
||||
if all(v==0 for v in week_data+month_data+year_data):
|
||||
for _,layout in frames: getattr(self,layout).addWidget(QLabel('暂无阅读数据'))
|
||||
return
|
||||
|
||||
# 处理无数据情况
|
||||
if all(v == 0 for v in week_data + month_data + year_data):
|
||||
for frame_name, layout_name in required_frames:
|
||||
lbl = QLabel("暂无阅读数据")
|
||||
getattr(self, layout_name).addWidget(lbl)
|
||||
return
|
||||
|
||||
# 工具函数:添加图到 frame
|
||||
def add_figure(frame_layout, fig):
|
||||
canvas = FigureCanvas(fig)
|
||||
frame_layout.addWidget(canvas)
|
||||
return canvas
|
||||
|
||||
# 周图(最近7天) - 倒序显示使左侧为7天前? 按需求索引0=今天 -> 我们希望x轴从右到左还是左到右? 采用左=今天的一致性
|
||||
def plot_bar(data, title, xlabel_list):
|
||||
fig = Figure(figsize=(3.2, 2.4), tight_layout=True)
|
||||
ax = fig.add_subplot(111)
|
||||
bars = ax.bar(range(len(data)), data, color="#4c72b0")
|
||||
ax.set_title(title, fontsize=10)
|
||||
def add_fig(layout_name, fig):
|
||||
canvas = FigureCanvas(fig); getattr(self, layout_name).addWidget(canvas); return canvas
|
||||
def plot_bar(data, ylabel, color):
|
||||
fig = Figure(figsize=(3.2,2.4), tight_layout=True); ax = fig.add_subplot(111)
|
||||
bars = ax.bar(range(len(data)), data, color=color)
|
||||
ax.set_xticks(range(len(data)))
|
||||
ax.set_xticklabels(xlabel_list, rotation=0, fontsize=8)
|
||||
ax.set_ylabel("分钟", fontsize=8)
|
||||
# 在柱子顶部加简单数值(若非0)
|
||||
for rect, val in zip(bars, data):
|
||||
if val > 0:
|
||||
ax.text(rect.get_x() + rect.get_width()/2, rect.get_height(), str(val), ha='center', va='bottom', fontsize=7)
|
||||
return fig
|
||||
|
||||
# x 轴标签
|
||||
week_labels = ["今", "昨", "2", "3", "4", "5", "6"] # 索引0=今天
|
||||
month_labels = [str(i) for i in range(30)] # 0..29 天前
|
||||
year_labels = [f"{i+1}月" for i in range(12)]
|
||||
|
||||
# 绘制三个柱状图
|
||||
week_fig = plot_bar(week_data, "", week_labels)
|
||||
month_fig = plot_bar(month_data, "", month_labels)
|
||||
# 年数据转为小时用于展示
|
||||
year_hours_data = [round(m / 60.0, 1) for m in year_data]
|
||||
def plot_bar_hours(data, title, xlabel_list):
|
||||
fig = Figure(figsize=(3.2, 2.4), tight_layout=True)
|
||||
ax = fig.add_subplot(111)
|
||||
bars = ax.bar(range(len(data)), data, color="#8c6bb1")
|
||||
ax.set_title(title, fontsize=10)
|
||||
ax.set_xticks(range(len(data)))
|
||||
ax.set_xticklabels(xlabel_list, rotation=0, fontsize=8)
|
||||
ax.set_ylabel("小时", fontsize=8)
|
||||
for rect, val in zip(bars, data):
|
||||
if val > 0:
|
||||
ax.text(rect.get_x() + rect.get_width()/2, rect.get_height(), str(val), ha='center', va='bottom', fontsize=7)
|
||||
return fig
|
||||
year_fig = plot_bar_hours(year_hours_data, "", year_labels)
|
||||
|
||||
add_figure(self.weekLayout, week_fig)
|
||||
add_figure(self.monthLayout, month_fig)
|
||||
add_figure(self.yearLayout, year_fig)
|
||||
|
||||
# 气泡图数据计算
|
||||
import math
|
||||
# 全年阅读小时数
|
||||
year_hours = year_total_minutes / 60.0
|
||||
# 月均阅读小时数
|
||||
month_avg_hours = (sum(year_data) / 12.0) / 60.0 if year_data else 0
|
||||
# 近7天阅读小时数
|
||||
week_hours = sum(week_data) / 60.0
|
||||
# 日均阅读分钟数(最近30天)
|
||||
day_avg_minutes = (sum(month_data) / 30.0) if month_data else 0
|
||||
|
||||
ax.set_ylabel(ylabel, fontsize=8)
|
||||
for r,v in zip(bars,data):
|
||||
if v>0: ax.text(r.get_x()+r.get_width()/2, r.get_height(), str(int(v if ylabel=='分钟' else v)), ha='center', va='bottom', fontsize=7)
|
||||
return fig, ax
|
||||
week_labels = ['今','昨','2','3','4','5','6']
|
||||
month_labels = [str(i) for i in range(30)]
|
||||
year_labels = [f'{i+1}月' for i in range(12)]
|
||||
week_fig,_ = plot_bar(week_data,'分钟','#4c72b0'); add_fig('weekLayout', week_fig)
|
||||
month_fig,_ = plot_bar(month_data,'分钟','#4c72b0'); add_fig('monthLayout', month_fig)
|
||||
year_hours = [round(m/60.0,1) for m in year_data]
|
||||
year_fig = Figure(figsize=(3.2,2.4), tight_layout=True); ax_y = year_fig.add_subplot(111)
|
||||
bars = ax_y.bar(range(len(year_hours)), year_hours, color='#8c6bb1')
|
||||
ax_y.set_xticks(range(len(year_hours))); ax_y.set_ylabel('小时', fontsize=8)
|
||||
for r,v in zip(bars,year_hours):
|
||||
if v>0: ax_y.text(r.get_x()+r.get_width()/2, r.get_height(), v, ha='center', va='bottom', fontsize=7)
|
||||
add_fig('yearLayout', year_fig)
|
||||
# 气泡
|
||||
year_hours_total = year_total_minutes/60.0
|
||||
month_avg_hours = (sum(year_data)/12.0)/60.0 if year_data else 0
|
||||
week_hours = sum(week_data)/60.0
|
||||
day_avg_minutes = (sum(month_data)/30.0) if month_data else 0
|
||||
bubble_metrics = [
|
||||
("全年", year_hours, 'h', '#5b6ee1'),
|
||||
("月均", month_avg_hours, 'h', '#c9b2d9'),
|
||||
("近7天", week_hours, 'h', '#f4b2c2'),
|
||||
("日均", day_avg_minutes, 'm', '#b9b542'),
|
||||
('全年', year_hours_total, 'h', '#5b6ee1'),
|
||||
('月均', month_avg_hours, 'h', '#c9b2d9'),
|
||||
('近7天', week_hours, 'h', '#f4b2c2'),
|
||||
('日均', day_avg_minutes, 'm', '#b9b542'),
|
||||
('已读', finished_count, 'book', '#6aa84f'),
|
||||
]
|
||||
# 归一化确定半径(防止过大/过小)。将值全部转为分钟再归一化。
|
||||
minute_values = []
|
||||
for label, val, unit, color in bubble_metrics:
|
||||
if unit == 'h':
|
||||
minute_values.append(val * 60)
|
||||
else:
|
||||
minute_values.append(val)
|
||||
for label,val,unit,_ in bubble_metrics:
|
||||
minute_values.append(val*60 if unit=='h' else (val if unit!='book' else val*60))
|
||||
max_minutes = max(minute_values) if minute_values else 1
|
||||
radii = []
|
||||
radii=[]
|
||||
for mv in minute_values:
|
||||
# 半径在 [0.3, 1.0] 之间的平方放大到 marker size
|
||||
norm = mv / max_minutes if max_minutes > 0 else 0
|
||||
radii.append(0.3 + 0.7 * math.sqrt(norm))
|
||||
|
||||
fig_b = Figure(figsize=(3.6, 2.6), tight_layout=True)
|
||||
axb = fig_b.add_subplot(111)
|
||||
#axb.set_title("阅读指标气泡")
|
||||
axb.axis('off')
|
||||
# 采用归一化坐标使气泡左右均匀填充 (x 0~1)
|
||||
# 布局:最大在 0.2,另外两个上方/右方,一个在下方,形成视觉平衡
|
||||
label2pos = {
|
||||
'全年': (0.20, 0.00),
|
||||
'月均': (0.55, 0.52),
|
||||
'近7天': (0.85, 0.05),
|
||||
'日均': (0.55, -0.52)
|
||||
}
|
||||
# 若有新增指标则线性平铺
|
||||
if any(l not in label2pos for l, *_ in bubble_metrics):
|
||||
step = 1.0 / max(1, len(bubble_metrics)-1)
|
||||
label2pos = {m[0]: (i*step, 0.0) for i, m in enumerate(bubble_metrics)}
|
||||
|
||||
for (label, val, unit, color), r in zip(bubble_metrics, radii):
|
||||
x, y = label2pos.get(label, (0.5, 0.0))
|
||||
size = (r * 1150) ** 2 * 0.012
|
||||
axb.scatter(x, y, s=size, color=color, alpha=0.70, edgecolors='white', linewidths=1.0)
|
||||
if unit == 'h':
|
||||
text_val = f"{val:.0f} 小时" if val >= 10 else f"{val:.1f} 小时"
|
||||
norm = mv/max_minutes if max_minutes>0 else 0
|
||||
radii.append(0.3+0.7*math.sqrt(norm))
|
||||
fig_b = Figure(figsize=(3.6,2.6), tight_layout=True); axb = fig_b.add_subplot(111); axb.axis('off')
|
||||
label2pos = {'全年':(0.18,0.02),'月均':(0.60,0.55),'近7天':(0.90,0.05),'日均':(0.60,-0.55),'已读':(0.34,-0.45)}
|
||||
if any(l not in label2pos for l,_,_,_ in bubble_metrics):
|
||||
step = 1.0/max(1,len(bubble_metrics)-1); label2pos={m[0]:(i*step,0.0) for i,m in enumerate(bubble_metrics)}
|
||||
for (label,val,unit,color),r in zip(bubble_metrics,radii):
|
||||
x,y = label2pos.get(label,(0.5,0.0)); size=(r*1150)**2*0.012
|
||||
axb.scatter(x,y,s=size,color=color,alpha=0.70,edgecolors='white',linewidths=1.0)
|
||||
if unit=='h':
|
||||
text_val = f"{val:.0f} 小时" if val>=10 else f"{val:.1f} 小时"
|
||||
elif unit=='book':
|
||||
text_val = f"{int(val)} 本"
|
||||
else:
|
||||
text_val = f"{val:.0f} 分钟"
|
||||
axb.text(x, y, f"{text_val}\n{label}", ha='center', va='center', fontsize=11, color='white', weight='bold')
|
||||
axb.text(x,y,f"{text_val}\n{label}",ha='center',va='center',fontsize=11,color='white',weight='bold')
|
||||
axb.set_xlim(-0.02,1.02); axb.set_ylim(-0.95,0.95); axb.set_aspect('auto')
|
||||
add_fig('bubbleLayout', fig_b)
|
||||
|
||||
axb.set_xlim(-0.02, 1.02)
|
||||
axb.set_ylim(-0.95, 0.95)
|
||||
axb.set_aspect('auto')
|
||||
add_figure(self.bubbleLayout, fig_b)
|
||||
|
||||
# ------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# 设置应用程序名称和组织信息
|
||||
app.setApplicationName("notesExporter")
|
||||
app.setApplicationDisplayName("notesExporter")
|
||||
app.setOrganizationName("iBook Tools")
|
||||
|
||||
# 设置应用程序图标
|
||||
if os.path.exists(config.APP_ICON):
|
||||
app.setWindowIcon(QIcon(config.APP_ICON))
|
||||
|
||||
app.setApplicationName('notesExporterMatplot')
|
||||
app.setApplicationDisplayName('notesExporterMatplot')
|
||||
app.setOrganizationName('iBook Tools')
|
||||
if os.path.exists(config.APP_ICON): app.setWindowIcon(QIcon(config.APP_ICON))
|
||||
win = IBookExportApp()
|
||||
try:
|
||||
win._init_charts()
|
||||
except Exception:
|
||||
pass
|
||||
# 启动即全屏
|
||||
win.showFullScreen()
|
||||
try: win._init_charts()
|
||||
except Exception: pass
|
||||
win.show() # 不全屏,根据需求 3
|
||||
sys.exit(app.exec())
|
||||
|
|
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"": {
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/master.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/CLIMain.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/CLIMain.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/CLIMain.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/CLIMain~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/CLIMain.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/ContentView.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ContentView.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ContentView.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ContentView~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ContentView.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/IpadReaderApp.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/IpadReaderApp.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/IpadReaderApp.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/IpadReaderApp~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/IpadReaderApp.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Models/BundleModels.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleModels.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleModels.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleModels~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleModels.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Services/BundleImporter.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleImporter.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleImporter.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleImporter~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BundleImporter.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Services/ImageCache.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ImageCache.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ImageCache.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ImageCache~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/ImageCache.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/ViewModels/LibraryViewModel.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/LibraryViewModel.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/LibraryViewModel.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/LibraryViewModel~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/LibraryViewModel.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Views/BookDetailView.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookDetailView.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookDetailView.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookDetailView~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookDetailView.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Views/BookListView.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookListView.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookListView.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookListView~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BookListView.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Views/Components/BubbleMetricsView.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BubbleMetricsView.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BubbleMetricsView.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BubbleMetricsView~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/BubbleMetricsView.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Views/Components/EmptyStateView.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/EmptyStateView.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/EmptyStateView.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/EmptyStateView~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/EmptyStateView.swiftdeps"
|
||||
},
|
||||
"/Users/gavin/ibook/ipad_app/Sources/Views/StatsView.swift": {
|
||||
"dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/StatsView.d",
|
||||
"object": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/StatsView.swift.o",
|
||||
"swiftmodule": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/StatsView~partial.swiftmodule",
|
||||
"swift-dependencies": "/Users/gavin/ibook/ipad_app/.build/arm64-apple-macosx/debug/IpadReader.build/StatsView.swiftdeps"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/Users/gavin/ibook/ipad_app/Sources/CLIMain.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/ContentView.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/IpadReaderApp.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Models/BundleModels.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Services/BundleImporter.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Services/ImageCache.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/ViewModels/LibraryViewModel.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Views/BookDetailView.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Views/BookListView.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Views/Components/BubbleMetricsView.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Views/Components/EmptyStateView.swift
|
||||
/Users/gavin/ibook/ipad_app/Sources/Views/StatsView.swift
|
Binary file not shown.
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.get-task-allow</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue