# 改代码后续丢弃 # 不使用matplotlib库来实现绘图,使用QT6自带的绘图功能 # --- IGNORE --- import sys, os, re, datetime, math from PyQt6.QtWidgets import ( QApplication, QWidget, QLabel, QMessageBox, QLineEdit, QFormLayout, QDialog, QDialogButtonBox, QSizePolicy ) from PyQt6.QtGui import QIcon from PyQt6.QtCore import QSettings, 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_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(CoverMixin, FinishedBooksMixin, QWidget): def __init__(self): super().__init__() ui_file = os.path.join(os.path.dirname(__file__), 'ibook_export_app.ui') uic.loadUi(ui_file, self) self.setWindowTitle("notesExporter (matplot)") 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('警告: 初始同步源数据失败:', 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 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[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]}]") # 左侧提示标签 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 if obj == self.listwidget and event.type() == QEvent.Type.KeyPress: if event.key() in (0x01000004, 0x01000005): self.export_notes(); return True return super().eventFilter(obj, event) # 导出 def export_notes(self): idx = self.listwidget.currentRow() if idx < 0: 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()) 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}') # 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"

{t} {v}

" 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"

{s}

" 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"书籍简评:" ] 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): 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: from matplotlib import font_manager 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('信息: 未加载统计图表:', 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 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_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_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,_ 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=[] for mv in minute_values: 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.set_xlim(-0.02,1.02); axb.set_ylim(-0.95,0.95); axb.set_aspect('auto') add_fig('bubbleLayout', fig_b) if __name__ == '__main__': app = QApplication(sys.argv) 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.show() # 不全屏,根据需求 3 sys.exit(app.exec())