commit c1ecce97190ccc11addb795fa33b1c16fb3d2210 Author: kai Date: Wed Mar 25 13:20:39 2026 +0800 Initial commit 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 0000000..6bfcd3a Binary files /dev/null and b/__pycache__/db.cpython-314.pyc differ diff --git a/__pycache__/web.cpython-314.pyc b/__pycache__/web.cpython-314.pyc new file mode 100644 index 0000000..5ebfe70 Binary files /dev/null and b/__pycache__/web.cpython-314.pyc differ 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}} +
+ + + + + + 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