iBook/ibook_export_app_matplot.py

453 lines
22 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.

# 改代码后续丢弃
# 不使用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"<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):
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())