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())