313 lines
13 KiB
Python
313 lines
13 KiB
Python
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())
|