'update'
This commit is contained in:
151
ipad_bundle_export.py
Normal file
151
ipad_bundle_export.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user