"""iPad 数据包导出 生成一个 ZIP,包含 iPad 离线展示所需的全部 JSON 与封面缩略图。 结构: books_meta.json annotations.json stats.json bookintro.json (若存在) covers/.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)