From c1ecce97190ccc11addb795fa33b1c16fb3d2210 Mon Sep 17 00:00:00 2001 From: kai Date: Wed, 25 Mar 2026 13:20:39 +0800 Subject: [PATCH] Initial commit --- README.md | 53 +++++ __pycache__/db.cpython-314.pyc | Bin 0 -> 7748 bytes __pycache__/web.cpython-314.pyc | Bin 0 -> 8906 bytes bookshelf.py | 154 +++++++++++++++ db.py | 153 ++++++++++++++ static/style.css | 341 ++++++++++++++++++++++++++++++++ templates/index.html | 150 ++++++++++++++ web.py | 141 +++++++++++++ 8 files changed, 992 insertions(+) create mode 100644 README.md create mode 100644 __pycache__/db.cpython-314.pyc create mode 100644 __pycache__/web.cpython-314.pyc create mode 100644 bookshelf.py create mode 100644 db.py create mode 100644 static/style.css create mode 100644 templates/index.html create mode 100644 web.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..0aa6f40 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# 📚 Ecliptic Pathfinder — 个人图书阅读管理 + +轻量级命令行图书管理工具,零依赖,纯 Python 标准库。 + +## 快速开始 + +```bash +# 添加书目 +python bookshelf.py add "深度工作" --author "卡尔·纽波特" --format paper --status reading --tags "效率,自我管理" + +# 列出所有书 +python bookshelf.py list + +# 按状态/格式/标签筛选 +python bookshelf.py list --status reading +python bookshelf.py list --format ebook +python bookshelf.py list --tag "哲学" + +# 更新书目 +python bookshelf.py update 1 --status read --rating 5 --finish-date 2026-03-25 + +# 删除书目 +python bookshelf.py delete 1 + +# 生成展示网页 +python bookshelf.py build +# 然后用浏览器打开 output/index.html +``` + +## 完整字段 + +| 参数 | 说明 | 示例 | +|------|------|------| +| `title` (必填) | 书名 | `"深度工作"` | +| `--author` | 作者 | `"卡尔·纽波特"` | +| `--translator` | 译者 | `"宋伟"` | +| `--publisher` | 出版社 | `"后浪出版"` | +| `--pub-date` | 出版日期 | `"2017-09"` | +| `--cover-url` | 封面 URL | `"https://..."` | +| `--format` | `paper` / `ebook` | `paper` | +| `--status` | `to-read` / `reading` / `read` | `reading` | +| `--rating` | 个人评分 1-5 | `5` | +| `--douban-score` | 豆瓣评分 | `7.9` | +| `--goodreads-score` | GR 评分 | `4.18` | +| `--tags` | 逗号分隔标签 | `"哲学,认知科学"` | +| `--notes` | 简评 | `"值得反复阅读"` | +| `--start-date` | 开始日期 | `"2026-03-01"` | +| `--finish-date` | 完成日期 | `"2026-03-20"` | + +## 数据存储 + +- 数据库:`books.db`(SQLite,自动创建) +- 网页输出:`output/index.html`(静态 HTML,浏览器直接打开) diff --git a/__pycache__/db.cpython-314.pyc b/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bfcd3a0154c96ea6217d92d2e76a51538d2fc03 GIT binary patch literal 7748 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 literal 0 HcmV?d00001 diff --git a/__pycache__/web.cpython-314.pyc b/__pycache__/web.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ebfe7074803d33806f406cc6b2a52cd1fa188fd GIT binary patch literal 8906 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 WBk@gtN#BFx=5Jb5mAF+L_WuGc(=Mg} literal 0 HcmV?d00001 diff --git a/bookshelf.py b/bookshelf.py new file mode 100644 index 0000000..61f38d3 --- /dev/null +++ b/bookshelf.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""bookshelf — 图书阅读管理 CLI。""" + +import argparse +import sys +import db +import web + + +STATUS_CHOICES = ["to-read", "reading", "read"] +FORMAT_CHOICES = ["paper", "ebook"] + + +def cmd_add(args): + db.init_db() + book_id = db.add_book( + args.title, + author=args.author, + translator=args.translator, + publisher=args.publisher, + pub_date=args.pub_date, + cover_url=args.cover_url, + format=args.format, + status=args.status, + rating=args.rating, + douban_score=args.douban_score, + goodreads_score=args.goodreads_score, + tags=args.tags or "", + notes=args.notes, + start_date=args.start_date, + finish_date=args.finish_date, + ) + print(f"✅ 已添加:《{args.title}》(ID: {book_id})") + + +def cmd_list(args): + db.init_db() + books = db.list_books(status=args.status, format=args.format, tag=args.tag) + if not books: + print("📚 暂无书目。") + return + for b in books: + status_icon = {"to-read": "📖", "reading": "📗", "read": "✅"}.get(b["status"], "📕") + fmt_icon = "📱" if b["format"] == "ebook" else "📕" + rating_str = f" ⭐{b['rating']}" if b["rating"] else "" + tags_str = f" [{b['tags']}]" if b["tags"] else "" + print(f" {status_icon} [{b['id']:>3}] 《{b['title']}》— {b['author'] or '未知'}" + f" {fmt_icon}{rating_str}{tags_str}") + + +def cmd_update(args): + db.init_db() + book = db.get_book(args.id) + if not book: + print(f"❌ 未找到 ID 为 {args.id} 的书目。") + sys.exit(1) + updates = {} + for field in ["title", "author", "translator", "publisher", "pub_date", + "cover_url", "format", "status", "rating", "douban_score", + "goodreads_score", "tags", "notes", "start_date", "finish_date"]: + val = getattr(args, field.replace("-", "_"), None) + if val is not None: + updates[field] = val + if not updates: + print("⚠️ 未指定任何要更新的字段。") + return + db.update_book(args.id, **updates) + print(f"✅ 已更新:《{book['title']}》(ID: {args.id})") + + +def cmd_delete(args): + db.init_db() + book = db.get_book(args.id) + if not book: + print(f"❌ 未找到 ID 为 {args.id} 的书目。") + sys.exit(1) + db.delete_book(args.id) + print(f"🗑️ 已删除:《{book['title']}》(ID: {args.id})") + + +def cmd_build(args): + db.init_db() + output_path = web.build() + print(f"🌐 网页已生成:{output_path}") + + +def main(): + parser = argparse.ArgumentParser( + prog="bookshelf", + description="📚 个人图书阅读管理工具", + ) + sub = parser.add_subparsers(dest="command", required=True) + + # --- add --- + p_add = sub.add_parser("add", help="添加书目") + p_add.add_argument("title", help="书名") + p_add.add_argument("--author", help="作者") + p_add.add_argument("--translator", help="译者") + p_add.add_argument("--publisher", help="出版社") + p_add.add_argument("--pub-date", help="出版日期") + p_add.add_argument("--cover-url", help="封面图片 URL") + p_add.add_argument("--format", choices=FORMAT_CHOICES, default="paper", help="格式") + p_add.add_argument("--status", choices=STATUS_CHOICES, default="to-read", help="阅读状态") + p_add.add_argument("--rating", type=int, choices=range(1, 6), help="个人评分 1-5") + p_add.add_argument("--douban-score", type=float, help="豆瓣评分") + p_add.add_argument("--goodreads-score", type=float, help="Goodreads 评分") + p_add.add_argument("--tags", help="阅读主题标签,逗号分隔") + p_add.add_argument("--notes", help="笔记/简评") + p_add.add_argument("--start-date", help="开始阅读日期") + p_add.add_argument("--finish-date", help="完成阅读日期") + p_add.set_defaults(func=cmd_add) + + # --- list --- + p_list = sub.add_parser("list", help="列出书目") + p_list.add_argument("--status", choices=STATUS_CHOICES, help="按状态筛选") + p_list.add_argument("--format", choices=FORMAT_CHOICES, help="按格式筛选") + p_list.add_argument("--tag", help="按标签筛选") + p_list.set_defaults(func=cmd_list) + + # --- update --- + p_upd = sub.add_parser("update", help="更新书目") + p_upd.add_argument("id", type=int, help="书目 ID") + p_upd.add_argument("--title", help="书名") + p_upd.add_argument("--author", help="作者") + p_upd.add_argument("--translator", help="译者") + p_upd.add_argument("--publisher", help="出版社") + p_upd.add_argument("--pub-date", help="出版日期") + p_upd.add_argument("--cover-url", help="封面图片 URL") + p_upd.add_argument("--format", choices=FORMAT_CHOICES, help="格式") + p_upd.add_argument("--status", choices=STATUS_CHOICES, help="阅读状态") + p_upd.add_argument("--rating", type=int, choices=range(1, 6), help="个人评分 1-5") + p_upd.add_argument("--douban-score", type=float, help="豆瓣评分") + p_upd.add_argument("--goodreads-score", type=float, help="Goodreads 评分") + p_upd.add_argument("--tags", help="阅读主题标签,逗号分隔") + p_upd.add_argument("--notes", help="笔记/简评") + p_upd.add_argument("--start-date", help="开始阅读日期") + p_upd.add_argument("--finish-date", help="完成阅读日期") + p_upd.set_defaults(func=cmd_update) + + # --- delete --- + p_del = sub.add_parser("delete", help="删除书目") + p_del.add_argument("id", type=int, help="书目 ID") + p_del.set_defaults(func=cmd_delete) + + # --- build --- + p_build = sub.add_parser("build", help="生成展示网页") + p_build.set_defaults(func=cmd_build) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/db.py b/db.py new file mode 100644 index 0000000..69eb02d --- /dev/null +++ b/db.py @@ -0,0 +1,153 @@ +"""数据库操作层 — 封装所有 SQLite 操作。""" + +import sqlite3 +import os +from datetime import datetime + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "books.db") + + +def _connect(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db(): + """初始化数据库和表结构。""" + conn = _connect() + conn.execute(""" + CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT, + translator TEXT, + publisher TEXT, + pub_date TEXT, + cover_url TEXT, + format TEXT DEFAULT 'paper', + status TEXT DEFAULT 'to-read', + rating INTEGER, + douban_score REAL, + goodreads_score REAL, + tags TEXT DEFAULT '', + notes TEXT, + start_date TEXT, + finish_date TEXT, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')) + ) + """) + conn.commit() + conn.close() + + +def add_book(title, *, author=None, translator=None, publisher=None, + pub_date=None, cover_url=None, format="paper", status="to-read", + rating=None, douban_score=None, goodreads_score=None, + tags="", notes=None, start_date=None, finish_date=None): + """添加一本书,返回新书 ID。""" + conn = _connect() + cur = conn.execute(""" + INSERT INTO books (title, author, translator, publisher, pub_date, + cover_url, format, status, rating, douban_score, + goodreads_score, tags, notes, start_date, finish_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (title, author, translator, publisher, pub_date, cover_url, + format, status, rating, douban_score, goodreads_score, + tags, notes, start_date, finish_date)) + conn.commit() + book_id = cur.lastrowid + conn.close() + return book_id + + +def update_book(book_id, **kwargs): + """更新书目字段。只更新传入的字段。""" + if not kwargs: + return + allowed = { + "title", "author", "translator", "publisher", "pub_date", + "cover_url", "format", "status", "rating", "douban_score", + "goodreads_score", "tags", "notes", "start_date", "finish_date", + } + fields = {k: v for k, v in kwargs.items() if k in allowed} + if not fields: + return + fields["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + set_clause = ", ".join(f"{k} = ?" for k in fields) + values = list(fields.values()) + [book_id] + conn = _connect() + conn.execute(f"UPDATE books SET {set_clause} WHERE id = ?", values) + conn.commit() + conn.close() + + +def delete_book(book_id): + """删除一本书。""" + conn = _connect() + conn.execute("DELETE FROM books WHERE id = ?", (book_id,)) + conn.commit() + conn.close() + + +def get_book(book_id): + """获取单本书详情,返回 dict 或 None。""" + conn = _connect() + row = conn.execute("SELECT * FROM books WHERE id = ?", (book_id,)).fetchone() + conn.close() + return dict(row) if row else None + + +def list_books(*, status=None, format=None, tag=None): + """查询书目列表,支持按状态/格式/标签过滤。""" + conn = _connect() + query = "SELECT * FROM books WHERE 1=1" + params = [] + if status: + query += " AND status = ?" + params.append(status) + if format: + query += " AND format = ?" + params.append(format) + if tag: + # 逗号分隔的 tags 字段中模糊匹配 + query += " AND (',' || tags || ',' LIKE ?)" + params.append(f"%,{tag},%") + query += " ORDER BY updated_at DESC" + rows = conn.execute(query, params).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_stats(): + """获取统计数据。""" + conn = _connect() + total = conn.execute("SELECT COUNT(*) FROM books").fetchone()[0] + by_status = {} + for row in conn.execute("SELECT status, COUNT(*) as cnt FROM books GROUP BY status"): + by_status[row["status"]] = row["cnt"] + by_format = {} + for row in conn.execute("SELECT format, COUNT(*) as cnt FROM books GROUP BY format"): + by_format[row["format"]] = row["cnt"] + conn.close() + return { + "total": total, + "by_status": by_status, + "by_format": by_format, + } + + +def get_all_tags(): + """获取所有不重复的标签列表。""" + conn = _connect() + rows = conn.execute("SELECT tags FROM books WHERE tags != ''").fetchall() + conn.close() + tag_set = set() + for row in rows: + for t in row["tags"].split(","): + t = t.strip() + if t: + tag_set.add(t) + return sorted(tag_set) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..13392b6 --- /dev/null +++ b/static/style.css @@ -0,0 +1,341 @@ +/* ======================================== + 我的书架 — 暗色主题样式 + ======================================== */ + +:root { + --bg-primary: #0f1117; + --bg-secondary: #1a1d2e; + --bg-card: #222639; + --bg-card-hover: #2a2f48; + --text-primary: #e8eaf0; + --text-secondary: #9ca3b8; + --text-muted: #6b7394; + --accent: #7c6aef; + --accent-glow: rgba(124, 106, 239, 0.3); + --accent-light: #a78bfa; + --reading: #34d399; + --read: #60a5fa; + --to-read: #fbbf24; + --paper: #f97316; + --ebook: #06b6d4; + --border: rgba(255, 255, 255, 0.06); + --radius: 16px; + --radius-sm: 10px; + --shadow: 0 4px 24px rgba(0, 0, 0, 0.4); + --transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +/* Hero */ +.hero { + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%); + padding: 3rem 1.5rem 2.5rem; + text-align: center; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at 30% 50%, rgba(124, 106, 239, 0.15) 0%, transparent 60%), + radial-gradient(circle at 80% 30%, rgba(167, 139, 250, 0.1) 0%, transparent 50%); +} + +.hero-inner { + position: relative; + z-index: 1; +} + +.hero h1 { + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.4rem; + background: linear-gradient(to right, #e0e7ff, #c4b5fd); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.hero-sub { + color: rgba(255,255,255,0.5); + font-size: 1rem; + font-weight: 300; +} + +/* Stats Bar */ +.stats-bar { + display: flex; + justify-content: center; + gap: 0.5rem; + padding: 1.2rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.6rem 1.2rem; + border-radius: var(--radius-sm); + background: var(--bg-card); + min-width: 80px; + transition: transform var(--transition); +} + +.stat:hover { + transform: translateY(-2px); +} + +.stat-num { + font-size: 1.6rem; + font-weight: 700; + line-height: 1.2; +} + +.stat-label { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat--reading .stat-num { color: var(--reading); } +.stat--read .stat-num { color: var(--read); } +.stat--to-read .stat-num { color: var(--to-read); } +.stat--paper .stat-num { color: var(--paper); } +.stat--ebook .stat-num { color: var(--ebook); } + +/* Navigation */ +.view-nav { + display: flex; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + background: var(--bg-primary); +} + +.view-btn, .filter-btn { + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-secondary); + padding: 0.5rem 1.2rem; + border-radius: 999px; + font-size: 0.9rem; + cursor: pointer; + transition: all var(--transition); + font-family: inherit; +} + +.view-btn:hover, .filter-btn:hover { + background: var(--bg-card-hover); + color: var(--text-primary); +} + +.view-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: 0 0 16px var(--accent-glow); +} + +.filter-btn.active { + background: rgba(124, 106, 239, 0.15); + border-color: var(--accent); + color: var(--accent-light); +} + +.filter-nav { + display: flex; + justify-content: center; + gap: 0.4rem; + padding: 0 1rem 1rem; + flex-wrap: wrap; +} + +.filter-nav.hidden { + display: none; +} + +/* Book Grid */ +.book-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.2rem; + padding: 1.5rem; + max-width: 1400px; + margin: 0 auto; +} + +/* Book Card */ +.book-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: all var(--transition); + display: flex; + flex-direction: column; +} + +.book-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow); + border-color: rgba(124, 106, 239, 0.2); +} + +.card-cover { + width: 100%; + height: 200px; + object-fit: cover; + display: block; +} + +.card-cover--placeholder { + height: 160px; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card-hover)); +} + +.card-body { + padding: 1rem 1.2rem 1.2rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; +} + +.card-title { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.4; + color: var(--text-primary); +} + +.card-meta { + font-size: 0.82rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.card-rating.personal { + font-size: 0.95rem; + color: var(--to-read); + letter-spacing: 0.08em; +} + +.card-scores { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; +} + +.score { + font-size: 0.78rem; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-weight: 500; +} + +.score.douban { + background: rgba(0, 180, 0, 0.1); + color: #4ade80; + border: 1px solid rgba(0, 180, 0, 0.15); +} + +.score.goodreads { + background: rgba(96, 165, 250, 0.1); + color: #60a5fa; + border: 1px solid rgba(96, 165, 250, 0.15); +} + +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.tag { + font-size: 0.72rem; + padding: 0.15rem 0.5rem; + background: rgba(124, 106, 239, 0.1); + color: var(--accent-light); + border-radius: 999px; + border: 1px solid rgba(124, 106, 239, 0.15); +} + +.card-notes { + font-size: 0.82rem; + color: var(--text-muted); + font-style: italic; + border-left: 2px solid var(--accent); + padding-left: 0.6rem; +} + +.card-dates { + font-size: 0.75rem; + color: var(--text-muted); +} + +.card-format { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: auto; + padding-top: 0.4rem; + border-top: 1px solid var(--border); +} + +/* Footer */ +.footer { + text-align: center; + padding: 2rem; + color: var(--text-muted); + font-size: 0.8rem; + border-top: 1px solid var(--border); + margin-top: 2rem; +} + +/* Responsive */ +@media (max-width: 640px) { + .hero h1 { font-size: 1.8rem; } + .stats-bar { gap: 0.3rem; padding: 0.8rem 0.5rem; } + .stat { min-width: 60px; padding: 0.4rem 0.6rem; } + .stat-num { font-size: 1.2rem; } + .book-grid { grid-template-columns: 1fr; padding: 1rem; } +} + +/* Animations */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +.book-card { + animation: fadeIn 0.4s ease both; +} + +.book-card:nth-child(2) { animation-delay: 0.05s; } +.book-card:nth-child(3) { animation-delay: 0.1s; } +.book-card:nth-child(4) { animation-delay: 0.15s; } +.book-card:nth-child(5) { animation-delay: 0.2s; } +.book-card:nth-child(6) { animation-delay: 0.25s; } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..023934e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,150 @@ + + + + + + 我的书架 — Ecliptic Pathfinder + + + + + + + +
+
+

📚 我的书架

+

阅读是最好的投资

+
+
+ + +
+
+ {{TOTAL}} + 总计 +
+
+ {{READING}} + 在读 +
+
+ {{READ}} + 已读 +
+
+ {{TO_READ}} + 待读 +
+
+ {{PAPER}} + 纸质 +
+
+ {{EBOOK}} + 电子 +
+
+ + + + + + + + + + + + + + +
+ {{BOOK_CARDS}} +
+ +
+

Ecliptic Pathfinder · 生成于

+
+ + + + diff --git a/web.py b/web.py new file mode 100644 index 0000000..10ded19 --- /dev/null +++ b/web.py @@ -0,0 +1,141 @@ +"""网页生成器 — 读取数据库,生成静态 HTML 展示页面。""" + +import os +import json +import db + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE_PATH = os.path.join(BASE_DIR, "templates", "index.html") +STYLE_PATH = os.path.join(BASE_DIR, "static", "style.css") +OUTPUT_DIR = os.path.join(BASE_DIR, "output") +OUTPUT_PATH = os.path.join(OUTPUT_DIR, "index.html") + + +def _escape(text): + """简单的 HTML 转义。""" + if not text: + return "" + return (str(text) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """)) + + +def _render_book_card(book): + """渲染单本书的 HTML 卡片。""" + cover = book.get("cover_url") or "" + cover_html = (f'封面' + if cover + else '
📖
') + + rating_html = "" + if book.get("rating"): + stars = "★" * book["rating"] + "☆" * (5 - book["rating"]) + rating_html = f'
{stars}
' + + scores = [] + if book.get("douban_score"): + scores.append(f'豆瓣 {book["douban_score"]}') + if book.get("goodreads_score"): + scores.append(f'GR {book["goodreads_score"]}') + scores_html = f'
{"".join(scores)}
' if scores else "" + + tags_html = "" + if book.get("tags"): + tag_spans = "".join( + f'{_escape(t.strip())}' + for t in book["tags"].split(",") if t.strip() + ) + tags_html = f'
{tag_spans}
' + + meta_parts = [] + if book.get("author"): + meta_parts.append(f'{_escape(book["author"])}') + if book.get("translator"): + meta_parts.append(f'译: {_escape(book["translator"])}') + if book.get("publisher"): + meta_parts.append(f'{_escape(book["publisher"])}') + if book.get("pub_date"): + meta_parts.append(f'{_escape(book["pub_date"])}') + meta_html = f'
{" · ".join(meta_parts)}
' if meta_parts else "" + + notes_html = "" + if book.get("notes"): + notes_html = f'
{_escape(book["notes"])}
' + + dates = [] + if book.get("start_date"): + dates.append(f'开始: {_escape(book["start_date"])}') + if book.get("finish_date"): + dates.append(f'完成: {_escape(book["finish_date"])}') + dates_html = f'
{" · ".join(dates)}
' if dates else "" + + format_badge = "📱 电子书" if book.get("format") == "ebook" else "📕 纸质书" + + return f""" +
+ {cover_html} +
+

{_escape(book.get('title', ''))}

+ {meta_html} + {rating_html} + {scores_html} + {tags_html} + {notes_html} + {dates_html} +
{format_badge}
+
+
""" + + +def build(): + """生成静态 HTML 页面,返回输出路径。""" + os.makedirs(OUTPUT_DIR, exist_ok=True) + + books = db.list_books() + stats = db.get_stats() + all_tags = db.get_all_tags() + + cards_html = "\n".join(_render_book_card(b) for b in books) + + # 读取 CSS + with open(STYLE_PATH, "r", encoding="utf-8") as f: + css = f.read() + + # 读取模板并填充 + with open(TEMPLATE_PATH, "r", encoding="utf-8") as f: + template = f.read() + + # 统计数据 + total = stats["total"] + reading = stats["by_status"].get("reading", 0) + read = stats["by_status"].get("read", 0) + to_read = stats["by_status"].get("to-read", 0) + paper = stats["by_format"].get("paper", 0) + ebook = stats["by_format"].get("ebook", 0) + + # 标签按钮 + tag_buttons = "".join( + f'' + for t in all_tags + ) + + html = (template + .replace("{{CSS}}", css) + .replace("{{TOTAL}}", str(total)) + .replace("{{READING}}", str(reading)) + .replace("{{READ}}", str(read)) + .replace("{{TO_READ}}", str(to_read)) + .replace("{{PAPER}}", str(paper)) + .replace("{{EBOOK}}", str(ebook)) + .replace("{{TAG_BUTTONS}}", tag_buttons) + .replace("{{BOOK_CARDS}}", cards_html)) + + with open(OUTPUT_PATH, "w", encoding="utf-8") as f: + f.write(html) + + # 复制封面图片不处理(直接使用 URL) + return OUTPUT_PATH