iBook/ibook_export_app_matplot.py

313 lines
13 KiB
Python
Raw 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, QVBoxLayout, QPushButton, QLabel, QListWidget,
QFileDialog, QMessageBox, QLineEdit, QFormLayout, QDialog, QDialogButtonBox
)
from PyQt6.QtGui import QIcon
from PyQt6 import uic
import config
from exportbooknotes import BookNotesExporter
from booklist_parse import BookListManager
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(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")
# 设置窗口图标
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}")
# 初始化数据
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
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.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)
# 回车直接导出(使用事件过滤器)
self.listwidget.installEventFilter(self)
def eventFilter(self, obj, event):
from PyQt6.QtCore import QEvent, Qt
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
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
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 _init_charts(self):
"""初始化并渲染统计标签页内的四个图表。若 matplotlib 不可用或 frame 不存在则忽略。"""
# 延迟导入 matplotlib避免无图形依赖时阻塞主功能
try:
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
# 中文字体设置:尝试常见中文字体,找到即设置
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()
except Exception as e:
print(f"警告: 统计数据获取失败: {e}")
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)
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
bubble_metrics = [
("全年", year_hours, 'h', '#5b6ee1'),
("月均", month_avg_hours, 'h', '#c9b2d9'),
("近7天", week_hours, 'h', '#f4b2c2'),
("日均", day_avg_minutes, 'm', '#b9b542'),
]
# 归一化确定半径(防止过大/过小)。将值全部转为分钟再归一化。
minute_values = []
for label, val, unit, color in bubble_metrics:
if unit == 'h':
minute_values.append(val * 60)
else:
minute_values.append(val)
max_minutes = max(minute_values) if minute_values else 1
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} 小时"
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.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__":
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))
win = IBookExportApp()
try:
win._init_charts()
except Exception:
pass
# 启动即全屏
win.showFullScreen()
sys.exit(app.exec())