iBook/ibook_export_app.py

760 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sys
import os
import re
import datetime
from PyQt6.QtWidgets import (
QApplication, QWidget, QLabel, QMessageBox, QLineEdit, QFormLayout, QDialog, QDialogButtonBox, QSizePolicy
)
from PyQt6.QtGui import QIcon
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):
super().__init__(parent)
self.setWindowTitle("配置参数")
layout = QFormLayout(self)
self.inputs = {}
for attr in dir(config):
if attr.isupper():
val = getattr(config, attr)
inp = QLineEdit(str(val))
layout.addRow(attr, inp)
self.inputs[attr] = inp
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def get_config(self):
return {k: v.text() for k, v in self.inputs.items()}
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("iBook笔记专家")
if os.path.exists(config.APP_ICON):
self.setWindowIcon(QIcon(config.APP_ICON))
# ====== 数据准备 ======
try:
from exportbooknotes import sync_source_files
sync_source_files(config)
except Exception as e:
print(f"警告: 初始同步源数据失败: {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
if '-' in name:
name = name.split('-', 1)[0].strip()
self.assetid2name[assetid] = name
self.assetid2lastopen[assetid] = self.last_open_times.get(assetid, {}).get('last_open', 0)
self.sorted_assetids = sorted(self.assetid2name.keys(), key=lambda aid: self.assetid2lastopen[aid], reverse=True)
for aid in self.sorted_assetids:
self.listwidget.addItem(f"{self.assetid2name[aid]} [{self.assetid2lastopen[aid]}]")
# 更新左侧提示标签文本与样式(白色黑体,底色 #4c221b
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: #ffffff; font-family: 'STHeiti, Heiti SC, SimHei'; font-weight: bold; padding:4px 6px; border-radius:4px; }")
except Exception as _e_lbl:
print('信息: 设置导出提示标签样式失败:', _e_lbl)
# 调整导出 & 配置按钮为同一行 + 圆角胶囊风格
try:
from PyQt6.QtWidgets import QHBoxLayout, QWidget
if hasattr(self, 'export_btn') and hasattr(self, 'config_btn'):
# 如果还在原父布局中(垂直),则新建一行容器
parent_layout = self.export_btn.parentWidget().layout() if self.export_btn.parentWidget() else None
if parent_layout and self.export_btn in [parent_layout.itemAt(i).widget() for i in range(parent_layout.count()) if parent_layout.itemAt(i).widget()]:
# 创建水平布局并放置两个按钮
row_container = QWidget(self)
h = QHBoxLayout(row_container); h.setContentsMargins(0,0,0,0); h.setSpacing(12)
# 取出旧按钮(避免重复显示)
self.export_btn.setParent(row_container)
self.config_btn.setParent(row_container)
h.addWidget(self.export_btn)
h.addWidget(self.config_btn)
parent_layout.addWidget(row_container)
# 设置样式
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); }"
"QPushButton:hover { filter: brightness(1.08); }"
"QPushButton:pressed { filter: brightness(0.92); }"
"QPushButton:disabled { background:#888888; color:#dddddd; }"
)
# 仅作用于这两个按钮:分别附加 objectName 选择器
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 as _e_pill:
print('信息: 设置按钮行/样式失败:', _e_pill)
# ====== 信号 ======
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)
# ====== 封面标签 ======
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)
try:
from PyQt6.QtCore import Qt as _QtFP
lab.setFocusPolicy(_QtFP.FocusPolicy.NoFocus)
except Exception:
pass
# 封面高度是否弹性跟随文本区(默认 False: 固定算法,不扩张)
self.cover_elastic = False
# 调整文本区域弹性:宽度随可用空间扩展,高度优先扩展
try:
from PyQt6.QtWidgets import QSizePolicy as _QSP
sp = self.book_toc_textedit.sizePolicy()
sp.setHorizontalPolicy(_QSP.Policy.Expanding)
sp.setVerticalPolicy(_QSP.Policy.Expanding)
self.book_toc_textedit.setSizePolicy(sp)
# 给封面横排区域一个较小的最小高度,避免撑开
if hasattr(self, 'covers_layout') and self._cover_labels:
for lab in self._cover_labels:
lab.setMinimumHeight(10)
except Exception:
pass
self.cover_ratio = 1.2
self._export_tab_index = None
self.book_toc_textedit.setPlainText("书籍信息 / 简评")
# 状态 & 缓存
self._review_worker = None
self._current_bookname = None
self._active_workers = []
self._cover_pixmaps_original = []
# 恢复窗口尺寸
self._restore_window_geometry()
# 设置封面标签对齐
try:
from PyQt6.QtCore import Qt as _QtAlign
for lab in self._cover_labels:
lab.setAlignment(_QtAlign.AlignmentFlag.AlignHCenter | _QtAlign.AlignmentFlag.AlignTop)
except Exception:
pass
# 应用全局字体(含苹方支持)
try:
self._apply_global_font()
except Exception:
pass
# 初始封面 + 首本书信息 (及 AI 简评触发)
self._load_initial()
# 已读书籍网格
try:
self._populate_finished_books_grid()
except Exception as e:
print('警告: 已读书籍网格填充失败', e)
# 滚动区域策略 & 事件过滤
if hasattr(self, 'finished_scroll_area'):
from PyQt6.QtCore import Qt as _Qt
self.finished_scroll_area.setHorizontalScrollBarPolicy(_Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.finished_scroll_area.setVerticalScrollBarPolicy(_Qt.ScrollBarPolicy.ScrollBarAsNeeded)
try:
self.finished_scroll_area.viewport().installEventFilter(self)
except Exception as _e_vp:
print('信息: 安装 viewport 事件过滤器失败', _e_vp)
# Tab 切换监听 (C + A 方案)
from PyQt6.QtWidgets import QTabWidget
try:
tabs = self.findChildren(QTabWidget)
if tabs:
self._main_tab_widget = tabs[0]
self._main_tab_widget.currentChanged.connect(self._on_main_tab_changed)
self._detect_export_tab_index()
except Exception as _e_tab:
print('信息: 连接 tab 切换失败', _e_tab)
# 首次显示后再排一次
from PyQt6.QtCore import QTimer
QTimer.singleShot(80, self._relayout_finished_grid)
def eventFilter(self, obj, event):
from PyQt6.QtCore import QEvent
# 方案 C: 监听 finished_scroll_area 的 viewport Resize
try:
if hasattr(self, 'finished_scroll_area') and obj == self.finished_scroll_area.viewport():
if event.type() == QEvent.Type.Resize:
# 可选节流:仅当宽度真实变化较大时
new_w = obj.width()
last_w = getattr(self, '_finished_viewport_last_width', None)
if last_w is None or abs(new_w - last_w) > 8:
self._finished_viewport_last_width = new_w
self._relayout_finished_grid()
except Exception:
pass
# 原有 listwidget 回车导出逻辑
if obj == self.listwidget and event.type() == QEvent.Type.KeyPress:
if event.key() in (0x01000004, 0x01000005): # Return / Enter
self.export_notes()
return True
return super().eventFilter(obj, event)
def _load_initial(self):
"""启动时:
1. 显示前三本封面
2. 初始化文本区域为第一本书的基础信息(无简评内容,只留段落标题)
"""
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()
# 首本书信息
first_aid = self.sorted_assetids[0]
first_info = self.booksinfo.get(first_aid, {})
bookname_display = first_info.get('displayname') or first_info.get('itemname') or first_aid
author = first_info.get('author', '')
btype = first_info.get('type', '')
get_time = first_info.get('date', '')
self._base_info_cache = {
'bookname': bookname_display,
'author': author,
'type': btype,
'date': get_time
}
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:
html = self._build_book_html(review)
self.book_toc_textedit.setHtml(html)
else:
loading_html = self._build_book_html("简评获取中...")
self.book_toc_textedit.setHtml(loading_html)
prompt = f"{bookname_display} 400字书评 三段 简洁精炼"
worker = BookReviewWorker(bookname_display, prompt, json_path, parent=self)
worker.finished.connect(lambda bname, rev: self._on_review_finished(bname, rev))
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_main_tab_changed(self, index):
"""方案 A: 当切换到 '已读书籍' 标签时强制重排一次,并做一次短延迟的二次重排,避免初次显示宽度未稳定。"""
try:
if not hasattr(self, '_main_tab_widget'):
return
w = self._main_tab_widget.widget(index)
# 通过对象名或包含的 finished_scroll_area 判断(加括号避免优先级问题)
hit = False
if hasattr(self, 'finished_scroll_area'):
try:
if self.finished_scroll_area.isAncestorOf(w) or w.isAncestorOf(self.finished_scroll_area):
hit = True
except Exception:
pass
# 退化:检查对象名包含关键字
if not hit:
name = getattr(w, 'objectName', lambda: '')()
if 'finished' in name.lower():
hit = True
if hit:
# 立即重排
self._relayout_finished_grid()
from PyQt6.QtCore import QTimer
# 120ms 后再重排一次(宽度稳定后)
QTimer.singleShot(120, self._relayout_finished_grid)
except Exception as e:
print('tab 切换重排失败:', e)
def showEvent(self, event):
"""首次显示窗口后,再安排一次延迟重排,提升初始网格正确率。"""
try:
super().showEvent(event)
from PyQt6.QtCore import QTimer
QTimer.singleShot(80, self._relayout_finished_grid)
except Exception:
pass
def _detect_export_tab_index(self):
"""探测导出标签索引:优先使用 ui 中命名的 tab_export否则通过包含 listwidget 的页面推断。"""
if getattr(self, '_export_tab_index', None) is not None:
return
try:
if not hasattr(self, '_main_tab_widget'):
return
idx = -1
if hasattr(self, 'tab_export'):
idx = self._main_tab_widget.indexOf(self.tab_export)
if idx < 0:
from PyQt6.QtWidgets import QListWidget
for i in range(self._main_tab_widget.count()):
page = self._main_tab_widget.widget(i)
if page.findChild(QListWidget, 'listwidget') is not None:
idx = i
break
if idx < 0:
idx = 0 # 兜底
self._export_tab_index = idx
except Exception as e:
print('探测导出标签索引失败:', e)
def _switch_to_export_tab(self):
try:
if not hasattr(self, '_main_tab_widget'):
from PyQt6.QtWidgets import QTabWidget
tabs = self.findChildren(QTabWidget)
if tabs:
self._main_tab_widget = tabs[0]
if not hasattr(self, '_main_tab_widget'):
return
self._detect_export_tab_index()
if self._export_tab_index is None:
return
if self._main_tab_widget.currentIndex() != self._export_tab_index:
self._main_tab_widget.setCurrentIndex(self._export_tab_index)
except Exception as e:
print('切换导出标签失败:', e)
def _on_finished_cover_clicked(self, asset_id):
# 在主列表中选中对应书籍(若存在)
try:
if not hasattr(self, 'sorted_assetids'):
return
if asset_id not in self.sorted_assetids:
return
row = self.sorted_assetids.index(asset_id)
# 切换到“导出”标签(自动探测索引)
self._switch_to_export_tab()
self.listwidget.setCurrentRow(row)
# 确保可见并聚焦
try:
item = self.listwidget.item(row)
if item:
self.listwidget.scrollToItem(item)
self.listwidget.setFocus()
except Exception:
pass
# 触发 update_book_info 逻辑自动刷新右侧
except Exception as e:
print('点击已读书籍封面失败:', e)
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
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")
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:
# 不再清空:保持初始加载的封面;仅清空文本
self.book_toc_textedit.clear()
return
assetid = self.sorted_assetids[row]
book_info = self.booksinfo.get(assetid, {})
# 计算当前与后续两本
total = len(self.sorted_assetids)
indices = [(row + i) % total for i in range(min(3,total))]
self._cover_pixmaps_original = []
from PyQt6.QtGui import QPixmap
# 先清空所有标签
for lab in self._cover_labels:
lab.clear()
lab.setText("加载中")
for pos, aid_idx in enumerate(indices):
aid_show = self.sorted_assetids[aid_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):
lab = self._cover_labels[pos]
lab.setText("")
self._cover_pixmaps_original.append(None)
self._apply_cover_scale()
# 生成 HTML 信息基础部分
bookname_display = book_info.get('displayname', '') or book_info.get('itemname', '') or assetid
author = book_info.get('author', '')
btype = book_info.get('type', '')
get_time = book_info.get('date', '')
self._base_info_cache = {
'bookname': bookname_display,
'author': author,
'type': btype,
'date': get_time
}
import json
bookname = bookname_display
self._current_bookname = bookname
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)
if review:
html = self._build_book_html(review)
self.book_toc_textedit.setHtml(html)
else:
prompt = f"{bookname} 400字书评 三段 简洁精炼"
loading_html = self._build_book_html("简评获取中...")
self.book_toc_textedit.setHtml(loading_html)
worker = BookReviewWorker(bookname, prompt, json_path, parent=self)
# UI 更新
worker.finished.connect(lambda bname, review: self._on_review_finished(bname, review))
# 完成后从活动列表移除
worker.finished.connect(lambda _b, _r, w=worker: self._remove_worker(w))
self._review_worker = worker
self._active_workers.append(worker)
worker.start()
def _on_review_finished(self, bookname, review, base_text=None):
# base_text 兼容旧调用,可忽略
if bookname != self._current_bookname:
return
html = self._build_book_html(review)
self.book_toc_textedit.setHtml(html)
def _load_initial_covers(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()
except Exception as e:
print('初始封面加载失败:', e)
def resizeEvent(self, event):
# 窗口尺寸变化时重新计算封面大小
try:
self._apply_cover_scale()
self._relayout_finished_grid()
except Exception:
pass
# ---------------- 全局字体应用(含苹方别名) ----------------
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书评分段显示。"""
info = getattr(self, '_base_info_cache', {})
magenta = "#C71585" # 紫红色
def line(title, value):
return f"<p><span style='color:{magenta};font-weight:bold;'>{title}</span> {value}</p>"
# 书评分段处理
def review_lines(text):
# 按换行或两个以上空格分段
if not text:
return [""]
# 兼容多种分段格式
segments = [seg.strip() for seg in re.split(r'\n{2,}|\r{2,}|\n|\r|\s{2,}', text) if seg.strip()]
return [f"<p>{seg}</p>" for seg in segments]
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 _init_charts(self):
"""使用原生 Qt 组件渲染统计标签页四个图表(取代 matplotlib"""
try:
from charts import BarChartWidget, BubbleMetricsWidget, ScatterChartWidget
except Exception as e:
print('警告: 无法导入原生图表组件 charts.py:', e)
return
required = [
('frame_week', 'weekLayout'),
('frame_month', 'monthLayout'),
('frame_year', 'yearLayout'),
('frame_bubble', 'bubbleLayout'),
]
for attr, layout_name in required:
if not hasattr(self, attr) or not hasattr(self, layout_name):
print('信息: 缺少统计容器', attr)
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()
except Exception as e:
print('警告: 统计数据获取失败:', e)
return
if all(v == 0 for v in week_data + month_data + year_data):
for _, layout_name in required:
getattr(self, layout_name).addWidget(QLabel('暂无阅读数据'))
return
# 最近7天weekday 英文缩写索引0=今天)
today = datetime.date.today()
recent_days = [today - datetime.timedelta(days=i) for i in range(len(week_data))]
WEEK_ABBR = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
week_labels = [WEEK_ABBR[d.weekday()] for d in recent_days]
# 最近30天日期标签旋转45度防重叠
month_recent_days = [today - datetime.timedelta(days=i) for i in range(len(month_data))]
month_labels = [f"{d.month}{d.day}" for d in month_recent_days]
year_labels = [f'{i+1}' for i in range(12)]
year_hours = [round(m/60.0, 1) for m in year_data]
week_chart = BarChartWidget(week_data, title='', unit='分钟', labels=week_labels, value_format=lambda v: f'{int(v)}')
# 让横坐标先显示第30天数据即最近的日期在最左侧
month_chart = BarChartWidget(
month_data[::-1], # 反转数据
title='',
unit='分钟',
labels=month_labels[::-1], # 反转标签
value_format=lambda v: f'{int(v)}',
label_rotation=45
)
year_chart = ScatterChartWidget(year_hours, title='', unit='小时', labels=year_labels)
# 确保图表在网格中可弹性扩展
from PyQt6.QtWidgets import QSizePolicy
for wdg in (week_chart, month_chart, year_chart):
wdg.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.weekLayout.addWidget(week_chart)
self.monthLayout.addWidget(month_chart)
self.yearLayout.addWidget(year_chart)
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_total, 'h', '#5b6ee1'),
('月均', month_avg_hours, 'h', '#c9b2d9'),
('近7天', week_hours, 'h', '#f4b2c2'),
('日均', day_avg_minutes, 'm', '#b9b542'),
]
# 新增今年已读完书籍数量气泡单位book
try:
finished_books = self.manager.get_finished_books_this_year()
finished_count = len(finished_books)
bubble_metrics.append(('已读', finished_count, 'book', '#6aa84f'))
except Exception as e:
print('信息: 获取已读书籍数量失败:', e)
bubble_widget = BubbleMetricsWidget(bubble_metrics)
bubble_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.bubbleLayout.addWidget(bubble_widget)
# ---------------- 窗口尺寸持久化 ----------------
def _restore_window_geometry(self):
settings = QSettings('iBookTools', 'iBook笔记专家')
geo = settings.value('mainWindowGeometry')
if isinstance(geo, QByteArray) and not geo.isEmpty():
try:
self.restoreGeometry(geo)
return
except Exception:
pass
# 兼容旧版本仅存宽高
w = settings.value('windowWidth', type=int)
h = settings.value('windowHeight', type=int)
if w and h and w > 100 and h > 100:
self.resize(QSize(w, h))
else:
# 默认初始尺寸
self.resize(1500, 900)
def closeEvent(self, event):
try:
settings = QSettings('iBookTools', 'iBook笔记专家')
# 新格式:直接保存几何
settings.setValue('mainWindowGeometry', self.saveGeometry())
# 兼容旧字段
settings.setValue('windowWidth', self.width())
settings.setValue('windowHeight', self.height())
# 优雅等待所有后台线程结束(给最多 8 秒)
deadline = datetime.datetime.now() + datetime.timedelta(seconds=8)
for w in list(self._active_workers):
if w.isRunning():
remaining = (deadline - datetime.datetime.now()).total_seconds()
if remaining <= 0:
break
# wait 参数是毫秒
w.wait(int(min(remaining, 2) * 1000)) # 分批等待,避免一次性卡太久
except Exception:
pass
super().closeEvent(event)
def _remove_worker(self, worker):
try:
if worker in self._active_workers:
self._active_workers.remove(worker)
except Exception:
pass
# ------------------------------------------------
# ------------------------------------------------
if __name__ == "__main__":
app = QApplication(sys.argv)
# 设置应用程序名称和组织信息
app.setApplicationName("iBook笔记专家")
app.setApplicationDisplayName("iBook笔记专家")
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.show()
sys.exit(app.exec())