152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
"""iPad 数据包导出
|
||
生成一个 ZIP,包含 iPad 离线展示所需的全部 JSON 与封面缩略图。
|
||
|
||
结构:
|
||
books_meta.json
|
||
annotations.json
|
||
stats.json
|
||
bookintro.json (若存在)
|
||
covers/<assetid>.jpg
|
||
|
||
调用:
|
||
from ipad_bundle_export import export_ipad_bundle
|
||
export_ipad_bundle(output_zip_path, manager, exporter)
|
||
|
||
依赖:现有 BookListManager / AnnotationManager / BookNotesExporter / config
|
||
"""
|
||
from __future__ import annotations
|
||
import os, json, zipfile, io, datetime
|
||
from typing import Dict, Any
|
||
import config
|
||
from annotationdata import AnnotationManager
|
||
from booklist_parse import BookListManager
|
||
from exportbooknotes import BookNotesExporter
|
||
|
||
try:
|
||
from PIL import Image # 可选压缩
|
||
_HAS_PIL = True
|
||
except Exception:
|
||
_HAS_PIL = False
|
||
|
||
def _ensure_cover_thumbnail(src_path: str, max_side: int = 512) -> bytes:
|
||
"""生成封面缩略图二进制数据(JPEG)。失败时返回空字节。"""
|
||
if not src_path or not os.path.exists(src_path):
|
||
return b''
|
||
if not _HAS_PIL:
|
||
try:
|
||
with open(src_path, 'rb') as f:
|
||
return f.read()
|
||
except Exception:
|
||
return b''
|
||
try:
|
||
with Image.open(src_path) as im:
|
||
im = im.convert('RGB')
|
||
w, h = im.size
|
||
scale = max_side / float(max(w, h)) if max(w, h) > max_side else 1.0
|
||
if scale < 1:
|
||
new_size = (int(w*scale), int(h*scale))
|
||
im = im.resize(new_size)
|
||
bio = io.BytesIO()
|
||
im.save(bio, format='JPEG', quality=85)
|
||
return bio.getvalue()
|
||
except Exception:
|
||
return b''
|
||
|
||
def build_books_meta(manager: BookListManager) -> list[Dict[str, Any]]:
|
||
booksinfo = manager.get_books_info()
|
||
last_open = manager.get_books_last_open()
|
||
finished = manager.get_finished_books_this_year()
|
||
finished_set = {fid for fid, *_ in finished}
|
||
meta = []
|
||
for assetid, info in booksinfo.items():
|
||
meta.append({
|
||
'id': assetid,
|
||
'title': info.get('displayname') or info.get('itemname') or assetid,
|
||
'author': info.get('author',''),
|
||
'type': info.get('type',''),
|
||
'last_open': last_open.get(assetid,{}).get('last_open'),
|
||
'readtime30d': info.get('readtime30d', []),
|
||
'readtime12m': info.get('readtime12m', []),
|
||
'readtime_year': info.get('readtime_year', 0),
|
||
'is_finished_this_year': assetid in finished_set,
|
||
})
|
||
return meta
|
||
|
||
def build_annotations(bookid: str | None = None) -> Dict[str, Any]:
|
||
ann = AnnotationManager(config.LOCAL_ANNOTATION_DB).get_annotations(bookid=bookid)
|
||
out = {}
|
||
for aid, notes in ann.items():
|
||
rows = []
|
||
for uuid, rec in notes.items():
|
||
rows.append({
|
||
'uuid': uuid,
|
||
'creationdate': rec.get('creationdate'),
|
||
'idref': rec.get('idref'),
|
||
'filepos': rec.get('filepos'),
|
||
'selected': rec.get('selectedtext'),
|
||
'note': rec.get('note')
|
||
})
|
||
out[aid] = rows
|
||
return out
|
||
|
||
def build_stats(manager: BookListManager) -> Dict[str, Any]:
|
||
try:
|
||
week = manager.get_total_readtime(days=7)
|
||
month = manager.get_total_readtime(days=30)
|
||
year12 = manager.get_total_readtime12m()
|
||
year_total = manager.get_total_readtime_year()
|
||
finished = manager.get_finished_books_this_year()
|
||
stats = {
|
||
'generated_at': datetime.datetime.utcnow().isoformat()+'Z',
|
||
'global': {
|
||
'year_total_minutes': year_total,
|
||
'month_avg_minutes': int(sum(year12)/12) if year12 else 0,
|
||
'week_total_minutes': int(sum(week)),
|
||
'day_avg_minutes': int(sum(month)/30) if month else 0,
|
||
'finished_books_count': len(finished)
|
||
}
|
||
}
|
||
return stats
|
||
except Exception as e:
|
||
return {'error': str(e)}
|
||
|
||
def export_ipad_bundle(output_zip: str, manager: BookListManager | None = None, exporter: BookNotesExporter | None = None) -> str:
|
||
"""生成 iPad 数据包 ZIP 并返回路径。"""
|
||
manager = manager or BookListManager(plist_path=config.LOCAL_BOOKS_PLIST, db_path=config.LOCAL_LIBRARY_DB)
|
||
exporter = exporter or BookNotesExporter(config)
|
||
books_meta = build_books_meta(manager)
|
||
annotations = build_annotations() # 全量
|
||
stats = build_stats(manager)
|
||
intro_path = os.path.join(os.path.dirname(__file__), 'bookintro.json')
|
||
try:
|
||
with open(intro_path,'r',encoding='utf-8') as f:
|
||
bookintro = json.load(f)
|
||
except Exception:
|
||
bookintro = {}
|
||
# 准备封面
|
||
from cover_mixin import CoverMixin
|
||
cm = CoverMixin()
|
||
booksinfo = manager.get_books_info()
|
||
cover_blobs = {}
|
||
for assetid, info in booksinfo.items():
|
||
try:
|
||
cover_path = cm.find_book_cover(assetid, info) # 需要 config 路径
|
||
blob = _ensure_cover_thumbnail(cover_path) if cover_path else b''
|
||
if blob:
|
||
cover_blobs[assetid] = blob
|
||
except Exception:
|
||
pass
|
||
os.makedirs(os.path.dirname(output_zip) or '.', exist_ok=True)
|
||
with zipfile.ZipFile(output_zip, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||
zf.writestr('books_meta.json', json.dumps(books_meta, ensure_ascii=False, indent=2))
|
||
zf.writestr('annotations.json', json.dumps(annotations, ensure_ascii=False))
|
||
zf.writestr('stats.json', json.dumps(stats, ensure_ascii=False, indent=2))
|
||
zf.writestr('bookintro.json', json.dumps(bookintro, ensure_ascii=False, indent=2))
|
||
for aid, blob in cover_blobs.items():
|
||
zf.writestr(f'covers/{aid}.jpg', blob)
|
||
return output_zip
|
||
|
||
if __name__ == '__main__':
|
||
out = export_ipad_bundle('ipad_bundle.zip')
|
||
print('已生成', out)
|