bindery-cli 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,44 @@
1
+ require "securerandom"
1
2
  require_relative "base"
2
3
 
3
4
  module Bindery
4
5
  module Generators
5
- # bindery generate book <id> <title> [--author] [--dynasty] [--category]
6
+ # bindery new book <title> [--author] [--dynasty] [--category] [--translator] [--isbn] [--id]
6
7
  class BookGenerator < Base
7
- def self.call(id, title, author:, dynasty:, category:, project_root: Project.root!)
8
+ # 根据书名自动生成书籍 ID(目录名):英文/数字转 slug,纯中文等回退为随机 ID
9
+ def self.auto_id(title)
10
+ slug = title.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
11
+ slug.empty? ? "book-#{SecureRandom.hex(4)}" : slug
12
+ end
13
+
14
+ def self.call(id, title, author:, dynasty:, category:, translator: "", isbn: "", project_root: Project.root!)
8
15
  dest = Project.books_dir(project_root) + id
9
16
  raise Bindery::Error, "书籍目录已存在: #{dest}" if dest.exist?
10
17
 
18
+ config = Project.config(project_root)
11
19
  puts "创建书籍: #{id}"
12
- new(dest).generate(id: id, title: title, author: author, dynasty: dynasty, category: category)
20
+ new(dest).generate(
21
+ id: id, title: title, author: author, dynasty: dynasty, category: category,
22
+ translator: translator, isbn: isbn,
23
+ lang: config["lang"] || "zh-CN",
24
+ publisher: config["publisher"] || "再读经典",
25
+ rights: config["rights"] || "公共领域"
26
+ )
13
27
  Bindery::Index.rebuild(project_root)
14
28
  puts ""
15
29
  puts "完成!接下来编辑 #{dest}/chapters/ 下的章节文件,然后:"
16
30
  puts " bindery build #{id}"
17
31
  end
18
32
 
19
- def generate(id:, title:, author:, dynasty:, category:)
33
+ def generate(id:, title:, author:, dynasty:, category:, translator:, isbn:, lang:, publisher:, rights:)
20
34
  empty_directory("chapters")
21
35
 
22
36
  template("book", "metadata.yaml", locals: {
23
37
  id: id, title: title, author: author, dynasty: dynasty, category: category,
38
+ translator: translator, isbn: isbn,
39
+ lang: lang, publisher: publisher, rights: rights,
24
40
  })
25
- template("book", "chapters/01-chapter.md", locals: { title: title })
41
+ template("book", "chapters/chapter-0001.md", locals: { title: title })
26
42
  end
27
43
  end
28
44
  end
@@ -0,0 +1,49 @@
1
+ require "fileutils"
2
+ require_relative "base"
3
+
4
+ module Bindery
5
+ module Generators
6
+ # bindery new chapter <book-id> <数量>
7
+ # 在指定书籍里批量创建章节文件,编号自动接续已有章节(chapter-0001.md 之后是 chapter-0002.md…)
8
+ class ChapterGenerator < Base
9
+ def self.call(book_id, count, project_root: Project.root!)
10
+ book = Bindery::Book.find(book_id, project_root)
11
+ count = count.to_i
12
+ raise Bindery::Error, "章节数量必须是正整数" unless count.positive?
13
+
14
+ chapters_dir = book.chapters_dir
15
+ FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
16
+
17
+ start = next_number(chapters_dir)
18
+ puts "创建章节: #{book.id}"
19
+ count.times do |i|
20
+ n = start + i
21
+ file = chapters_dir + format("chapter-%04d.md", n)
22
+ file.write("### 第#{n}章\n\n")
23
+ rel = begin
24
+ file.relative_path_from(Pathname.pwd)
25
+ rescue StandardError
26
+ file
27
+ end
28
+ puts " create #{rel}"
29
+ end
30
+ puts ""
31
+ puts "完成!已创建 #{count} 个章节(从第 #{start} 章开始)"
32
+ end
33
+
34
+ # 扫描已有章节文件名中的编号(chapter-0001),返回下一个可用编号
35
+ def self.next_number(chapters_dir)
36
+ max = 0
37
+ return max + 1 unless chapters_dir.directory?
38
+
39
+ chapters_dir.children.each do |f|
40
+ next unless f.file? && f.extname == ".md"
41
+
42
+ n = f.basename.to_s[/\Achapter-(\d+)/, 1]
43
+ max = n.to_i if n && n.to_i > max
44
+ end
45
+ max + 1
46
+ end
47
+ end
48
+ end
49
+ end
@@ -2,23 +2,29 @@ require_relative "base"
2
2
 
3
3
  module Bindery
4
4
  module Generators
5
- # bindery new <项目名>
6
- # 生成一整套项目骨架:books/、templates/(样式+字体说明)、output/、
5
+ # bindery init
6
+ # 在当前目录就地生成一整套项目骨架:books/、templates/(样式)、output/、
7
7
  # config/bindery.yml(项目标记文件)、README、.gitignore、GitHub Actions
8
8
  class ProjectGenerator < Base
9
- def self.call(project_name)
10
- dest = Pathname.pwd + project_name
11
- if dest.exist?
12
- raise Bindery::Error, "目录已存在: #{dest}"
9
+ def self.call(project_name = Pathname.pwd.basename.to_s)
10
+ dest = Pathname.pwd
11
+ if (dest + "config/bindery.yml").file?
12
+ raise Bindery::Error, "当前目录已经是 bindery 项目(已存在 config/bindery.yml)"
13
13
  end
14
14
 
15
- puts "创建项目: #{project_name}"
15
+ conflicts = ["books", "templates", "output", "README.md", ".gitignore",
16
+ ".github/workflows/build.yml"]
17
+ existing = conflicts.select { |f| (dest + f).exist? }
18
+ unless existing.empty?
19
+ raise Bindery::Error, "以下文件/目录已存在,为避免覆盖,无法初始化:#{existing.join('、')}"
20
+ end
21
+
22
+ puts "初始化项目: #{project_name}"
16
23
  new(dest).generate(project_name)
17
24
  puts ""
18
25
  puts "完成!接下来:"
19
- puts " cd #{project_name}"
20
- puts " bindery generate book lunyu \"论语\" --author \"孔子及弟子\""
21
- puts " bindery build lunyu"
26
+ puts " bindery new book \"论语\" --author \"孔子及弟子\" --dynasty \"先秦\""
27
+ puts " bindery build --all"
22
28
  end
23
29
 
24
30
  def generate(project_name)
@@ -1,6 +1,7 @@
1
- # 第一章
1
+ ### 第一章
2
2
 
3
3
  《<%= title %>》正文从这里开始……
4
4
 
5
+ 标题层级约定:书名 = h1(由 metadata 提供),卷名 = h2,章名 = h3。
5
6
  在 chapters/ 目录下按顺序添加更多章节文件(如 02-xxx.md, 03-xxx.md),
6
7
  文件名的字典序决定了它们在成书中的先后顺序。
@@ -1,9 +1,11 @@
1
1
  id: <%= id.inspect %>
2
2
  title: <%= title.inspect %>
3
3
  author: <%= author.inspect %>
4
- lang: zh-CN
4
+ translator: <%= translator.inspect %>
5
+ lang: <%= lang.inspect %>
5
6
  dynasty: <%= dynasty.inspect %>
6
7
  category: <%= category.inspect %>
7
- rights: 公共领域
8
- publisher: 再读经典
8
+ isbn: <%= isbn.inspect %>
9
+ rights: <%= rights.inspect %>
10
+ publisher: <%= publisher.inspect %>
9
11
  cover: cover.jpg
@@ -5,10 +5,22 @@
5
5
  ## 常用命令
6
6
 
7
7
  ```bash
8
- # 新建一本书
9
- bindery generate book <书籍ID> "<书名>" --author "<作者>"
10
- # 简写
11
- bindery g book <书籍ID> "<书名>"
8
+ # 新建一本书(ID 自动生成,可用 --id 指定;可带译者 / ISBN;--cover 自动生成封面)
9
+ bindery new book "<书名>" --author "<作者>" --translator "<译者>" --isbn "<ISBN>" --cover
10
+ # 一步到位建好章节(N = 正文章节数,封面/目录由构建自动生成)
11
+ bindery new book "<书名>" chapter 20 --author "<作者>"
12
+
13
+ # 卷式结构:每个卷在 EPUB 中独立成页(h2=卷名,h3=章名)
14
+ bindery new volume <书籍ID> "<卷名>" --chapters <数量>
15
+
16
+ # 单独批量创建章节(编号自动接续已有章节)
17
+ bindery new chapter <书籍ID> <数量>
18
+
19
+ # 按标题自动更新章节文件名(chapter-0001-标题.md)
20
+ bindery rename chapters <书籍ID>
21
+
22
+ # 单独生成封面
23
+ bindery cover <书籍ID>
12
24
 
13
25
  # 构建一本书
14
26
  bindery build <书籍ID>
@@ -16,8 +28,27 @@ bindery build <书籍ID>
16
28
  # 批量构建全部
17
29
  bindery build --all
18
30
 
31
+ # 构建前校验(不实际构建)
32
+ bindery validate <书籍ID>
33
+ bindery build <书籍ID> --check
34
+
35
+ # 构建后用 epubcheck 校验
36
+ bindery build <书籍ID> --epubcheck
37
+
19
38
  # 查看所有书籍状态
20
39
  bindery status
40
+
41
+ # 监听书籍目录,改动自动重建
42
+ bindery watch
43
+
44
+ # 本地启动书库站点
45
+ bindery serve
46
+
47
+ # 打开 Web 封面生成器(自动填充书籍信息)
48
+ bindery web
49
+
50
+ # 清理构建产物
51
+ bindery clean --all
21
52
  ```
22
53
 
23
54
  ## 目录结构
@@ -1,3 +1,2 @@
1
1
  /output/
2
- books.json
3
2
  .DS_Store
@@ -0,0 +1,62 @@
1
+ require "fileutils"
2
+ require_relative "base"
3
+
4
+ module Bindery
5
+ module Generators
6
+ # bindery new volume <book-id> <卷名> [--chapters N]
7
+ # 在书籍下创建一个平铺卷文件(卷01-学而.md),卷结构由标题层级定义:
8
+ # ## 卷名(h2)
9
+ # ### 第1章(h3)
10
+ class VolumeGenerator < Base
11
+ def self.call(book_id, volume_name, chapters: 0, project_root: Project.root!)
12
+ book = Bindery::Book.find(book_id, project_root)
13
+ volume_name = volume_name.to_s.strip
14
+ raise Bindery::Error, "卷名不能为空" if volume_name.empty?
15
+
16
+ chapters_dir = book.chapters_dir
17
+ FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
18
+
19
+ n = next_volume_number(chapters_dir)
20
+ file = chapters_dir + format("卷%02d-%s.md", n, volume_name)
21
+ raise Bindery::Error, "卷文件已存在: #{file}" if file.exist?
22
+
23
+ chapters = chapters.to_i
24
+ raise Bindery::Error, "章节数必须是正整数" if chapters.negative?
25
+
26
+ content = +"## #{volume_name}\n\n"
27
+ chapters.times do |i|
28
+ content << "### 第#{i + 1}章\n\n"
29
+ end
30
+ file.write(content)
31
+
32
+ puts "创建卷: #{volume_name}"
33
+ say_rel(file)
34
+ puts ""
35
+ summary = chapters.positive? ? ",含 #{chapters} 章" : ""
36
+ puts "完成!已创建卷 #{format('%02d', n)}(#{volume_name})#{summary}"
37
+ end
38
+
39
+ def self.next_volume_number(chapters_dir)
40
+ max = 0
41
+ return max + 1 unless chapters_dir.directory?
42
+
43
+ chapters_dir.children.each do |f|
44
+ next unless f.file? && f.extname == ".md"
45
+
46
+ m = f.basename.to_s[/\A卷(\d+)/, 1]
47
+ max = m.to_i if m && m.to_i > max
48
+ end
49
+ max + 1
50
+ end
51
+
52
+ def self.say_rel(path, trailing: "")
53
+ rel = begin
54
+ path.relative_path_from(Pathname.pwd)
55
+ rescue StandardError
56
+ path
57
+ end
58
+ puts " create #{rel}#{trailing}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,64 @@
1
+ module Bindery
2
+ # 读取 JPEG/PNG 图片尺寸(纯标准库实现,不依赖第三方 gem)
3
+ module Image
4
+ module_function
5
+
6
+ # 返回 [width, height];无法识别格式时返回 nil
7
+ def dimensions(path)
8
+ return nil unless path.file?
9
+
10
+ head = path.binread(24)
11
+ if png?(head)
12
+ width = head.byteslice(16, 4).unpack1("N")
13
+ height = head.byteslice(20, 4).unpack1("N")
14
+ [width, height]
15
+ elsif jpeg?(head)
16
+ jpeg_dimensions(path)
17
+ end
18
+ rescue StandardError
19
+ nil
20
+ end
21
+
22
+ def png?(head)
23
+ head.byteslice(0, 8) == "\x89PNG\r\n\x1a\n".b
24
+ end
25
+
26
+ def jpeg?(head)
27
+ head.byteslice(0, 2) == "\xFF\xD8".b
28
+ end
29
+
30
+ def jpeg_dimensions(path)
31
+ File.open(path, "rb") do |f|
32
+ f.read(2) # SOI
33
+ loop do
34
+ marker = f.read(2)
35
+ return nil if marker.nil? || marker.bytesize < 2
36
+ return nil unless marker.getbyte(0) == 0xFF
37
+
38
+ code = marker.getbyte(1)
39
+ # 跳过 0xFF 填充字节
40
+ while code == 0xFF
41
+ next_byte = f.read(1)
42
+ return nil if next_byte.nil?
43
+ code = next_byte.getbyte(0)
44
+ end
45
+
46
+ # SOF 段携带图片尺寸
47
+ if (0xC0..0xCF).cover?(code) && ![0xC4, 0xC8, 0xCC].include?(code)
48
+ f.read(2) # length
49
+ f.read(1) # precision
50
+ height = f.read(2).unpack1("n")
51
+ width = f.read(2).unpack1("n")
52
+ return [width, height]
53
+ else
54
+ length = f.read(2).unpack1("n")
55
+ return nil if length.nil? || length < 2
56
+ f.read(length - 2)
57
+ end
58
+ end
59
+ end
60
+ rescue StandardError
61
+ nil
62
+ end
63
+ end
64
+ end
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require "yaml"
2
3
 
3
4
  module Bindery
4
5
  # 负责在目录树中定位 bindery 项目根目录(含 config/bindery.yml 标记文件的目录)
@@ -21,7 +22,7 @@ module Bindery
21
22
  root(start_dir) || raise(
22
23
  Bindery::ProjectNotFoundError,
23
24
  "找不到 bindery 项目(未发现 #{MARKER})。请在项目目录内执行," \
24
- "或先用 `bindery new <项目名>` 创建一个新项目。"
25
+ "或先用 `bindery init` 初始化一个项目。"
25
26
  )
26
27
  end
27
28
 
@@ -40,5 +41,14 @@ module Bindery
40
41
  def index_file(project_root = root!)
41
42
  project_root + "books.json"
42
43
  end
44
+
45
+ # 读取项目配置 config/bindery.yml(即项目标记文件本身),返回 Hash
46
+ # 文件缺失或解析失败时返回空 Hash,由调用方提供默认值
47
+ def config(project_root = root!)
48
+ path = project_root + MARKER
49
+ return {} unless path.file?
50
+
51
+ YAML.safe_load(path.read, permitted_classes: [Symbol]) || {}
52
+ end
43
53
  end
44
54
  end
@@ -0,0 +1,57 @@
1
+ module Bindery
2
+ # 根据章节 md 里的首个标题,自动把文件名更新为 chapter-xxxx-标题.md
3
+ module Renamer
4
+ module_function
5
+
6
+ # 返回重命名的文件数
7
+ def rename_chapters(book)
8
+ dir = book.chapters_dir
9
+ return 0 unless dir.directory?
10
+
11
+ renamed = 0
12
+ dir.children.select { |f| f.file? && f.extname == ".md" }.sort.each do |f|
13
+ base = f.basename.to_s
14
+ m = base.match(/\A(chapter-\d+)(?:-.*)?\.md\z/)
15
+ next unless m
16
+
17
+ title = extract_title(f)
18
+ next if title.nil? || title.empty?
19
+
20
+ safe = sanitize(title)
21
+ new_name = "#{m[1]}-#{safe}.md"
22
+ next if new_name == base
23
+
24
+ new_path = f.dirname + new_name
25
+ raise Bindery::Error, "目标文件已存在: #{new_path}" if new_path.exist?
26
+
27
+ File.rename(f, new_path)
28
+ renamed += 1
29
+ puts " rename #{base} -> #{new_name}"
30
+ end
31
+ renamed
32
+ end
33
+
34
+ # 取文件里的首个 ATX 标题文本
35
+ def extract_title(path)
36
+ path.each_line do |line|
37
+ next unless line =~ /^(\#{1,6})\s+(.+)$/
38
+
39
+ title = Regexp.last_match(2).sub(/\s*#+\s*$/, "").strip
40
+ return title unless title.empty?
41
+ end
42
+ nil
43
+ end
44
+
45
+ # 清理标题里的非法文件名字符,跨平台安全(Windows 也兼容)
46
+ def sanitize(text)
47
+ s = text
48
+ .gsub(/[\\\/:*?"<>|\r\n\t]/, "-")
49
+ .gsub(/\s+/, "-")
50
+ .gsub(/-+/, "-")
51
+ .gsub(/\A-+|-\z/, "")
52
+ .gsub(/\A\.+|\.+\z/, "")
53
+ s = "章节" if s.empty?
54
+ s[0, 40]
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,238 @@
1
+ require "socket"
2
+ require "uri"
3
+ require "json"
4
+ require "yaml"
5
+ require "base64"
6
+ require "pathname"
7
+
8
+ module Bindery
9
+ # 极简静态服务器:展示书库并提供 EPUB 下载。
10
+ # 仅用标准库 socket 实现(Ruby 3.0 起 WEBrick 已移出标准库)。
11
+ class Server
12
+ MIME = {
13
+ ".html" => "text/html; charset=utf-8",
14
+ ".json" => "application/json; charset=utf-8",
15
+ ".epub" => "application/epub+zip",
16
+ ".css" => "text/css; charset=utf-8",
17
+ ".jpg" => "image/jpeg",
18
+ ".jpeg" => "image/jpeg",
19
+ ".png" => "image/png",
20
+ ".svg" => "image/svg+xml",
21
+ }.freeze
22
+
23
+ # 封面编辑器页面(随 gem 一起打包)
24
+ ASSETS_DIR = Pathname.new(__dir__) + "assets"
25
+
26
+ def initialize(project_root, host: "127.0.0.1", port: 8000)
27
+ @project_root = project_root
28
+ @host = host
29
+ @port = port
30
+ end
31
+
32
+ def start
33
+ server = TCPServer.new(@host, @port)
34
+ puts "服务已启动: http://#{@host}:#{@port} (Ctrl-C 退出)"
35
+ loop do
36
+ client = server.accept
37
+ begin
38
+ respond(client)
39
+ rescue StandardError => e
40
+ warn "请求处理失败: #{e.message}"
41
+ ensure
42
+ begin
43
+ client.close
44
+ rescue StandardError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ rescue Interrupt
50
+ puts "\n服务已停止"
51
+ ensure
52
+ begin
53
+ server.close
54
+ rescue StandardError
55
+ nil
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def respond(client)
62
+ request_line = client.gets
63
+ return unless request_line
64
+
65
+ method, raw_path, = request_line.split(" ")
66
+ # 读取请求头,POST 需要 Content-Length
67
+ content_length = 0
68
+ while (line = client.gets)
69
+ break if line.nil? || line == "\r\n" || line == "\n"
70
+ if line =~ /\AContent-Length:\s*(\d+)/i
71
+ content_length = Regexp.last_match(1).to_i
72
+ end
73
+ end
74
+
75
+ path = (URI::DEFAULT_PARSER.unescape(raw_path.split("?").first) rescue raw_path)
76
+ path = "/" if path.nil? || path.empty?
77
+ path = path.gsub("..", "") # 防目录穿越
78
+
79
+ if method == "POST" && path == "/cover/save"
80
+ save_cover(client, content_length)
81
+ return
82
+ end
83
+ return unless method == "GET" || method == "HEAD"
84
+
85
+ case path
86
+ when "/", "/index.html"
87
+ body = index_html
88
+ send_response(client, 200, MIME[".html"], body, method)
89
+ when "/books.json"
90
+ serve_file(client, Project.index_file(@project_root), MIME[".json"], method)
91
+ when "/cover"
92
+ serve_file(client, ASSETS_DIR + "index.html", MIME[".html"], method)
93
+ when %r{\A/epub/([\w\-.]+\.epub)\z}
94
+ serve_file(client, Project.output_dir(@project_root) + "epub" + Regexp.last_match(1), MIME[".epub"], method)
95
+ when %r{\A/cover/([\w\-]+)\z}
96
+ serve_cover(client, Regexp.last_match(1), method)
97
+ else
98
+ not_found(client, method)
99
+ end
100
+ end
101
+
102
+ def index_html
103
+ index_file = Project.index_file(@project_root)
104
+ data = if index_file.file?
105
+ JSON.parse(index_file.read)
106
+ else
107
+ Bindery::Index.rebuild(@project_root)
108
+ end
109
+ books = data["books"] || []
110
+
111
+ rows = books.map do |b|
112
+ id = escape_html(b["id"].to_s)
113
+ title = escape_html(b["title"].to_s)
114
+ author = escape_html(b["author"].to_s)
115
+ epub_link = if b["epub"]
116
+ %(<a href="/epub/#{id}.epub" download>下载 EPUB</a>)
117
+ else
118
+ "<em>未构建</em>"
119
+ end
120
+ cover = %(<img src="/cover/#{id}" class="cover" alt="">)
121
+ "<li class=\"book\">#{cover}<div class=\"info\"><h2>#{title}</h2><p class=\"author\">#{author}</p>#{epub_link}</div></li>"
122
+ end.join("\n")
123
+
124
+ <<~HTML
125
+ <!doctype html>
126
+ <html lang="zh-CN">
127
+ <head>
128
+ <meta charset="utf-8">
129
+ <meta name="viewport" content="width=device-width, initial-scale=1">
130
+ <title>#{escape_html(@project_root.basename.to_s)} · 书库</title>
131
+ <style>
132
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2em auto; padding: 0 1em; color: #222; }
133
+ h1 { font-size: 1.6em; border-bottom: 1px solid #eee; padding-bottom: .4em; }
134
+ ul { list-style: none; padding: 0; }
135
+ .book { display: flex; gap: 1em; padding: 1em 0; border-bottom: 1px solid #f0f0f0; }
136
+ .cover { width: 64px; height: 102px; object-fit: cover; background: #f5f5f5; border-radius: 4px; }
137
+ .info h2 { margin: 0; font-size: 1.1em; }
138
+ .author { color: #666; margin: .3em 0 .5em; }
139
+ a { color: #b45309; }
140
+ em { color: #999; }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <h1>#{escape_html(@project_root.basename.to_s)} · 书库</h1>
145
+ <p>共 #{books.size} 本书</p>
146
+ <ul>#{rows}</ul>
147
+ </body>
148
+ </html>
149
+ HTML
150
+ end
151
+
152
+ def serve_cover(client, id, method)
153
+ book_dir = Project.books_dir(@project_root) + id
154
+ return not_found(client, method) unless book_dir.directory?
155
+
156
+ name = "cover.jpg"
157
+ meta_file = book_dir + "metadata.yaml"
158
+ if meta_file.file?
159
+ begin
160
+ meta = YAML.safe_load(meta_file.read, permitted_classes: [Symbol]) || {}
161
+ name = meta["cover"] || "cover.jpg"
162
+ rescue StandardError
163
+ nil
164
+ end
165
+ end
166
+
167
+ path = book_dir + name
168
+ path = (book_dir + "cover.jpg") unless path.file?
169
+ path = (book_dir + "cover.png") unless path.file?
170
+
171
+ serve_file(client, path, nil, method)
172
+ end
173
+
174
+ # 保存封面编辑器生成的 PNG 到书籍目录,并更新 metadata 的 cover 字段
175
+ def save_cover(client, content_length)
176
+ body = client.read(content_length).to_s
177
+ return send_json(client, 400, { ok: false, error: "请求体为空" }) if body.empty?
178
+
179
+ data = JSON.parse(body)
180
+ book_id = data["book"].to_s
181
+ data_url = data["data"].to_s
182
+
183
+ book_dir = Project.books_dir(@project_root) + book_id
184
+ return send_json(client, 404, { ok: false, error: "书籍不存在: #{book_id}" }) unless book_dir.directory?
185
+
186
+ b64 = data_url.sub(/\Adata:image\/\w+;base64,/, "")
187
+ png = Base64.decode64(b64)
188
+ path = book_dir + "cover.png"
189
+ path.binwrite(png)
190
+
191
+ meta_file = book_dir + "metadata.yaml"
192
+ if meta_file.file?
193
+ text = meta_file.read
194
+ text = if text =~ /^cover:.*$/
195
+ text.sub(/^cover:.*$/, "cover: cover.png")
196
+ else
197
+ text.chomp + "\ncover: cover.png\n"
198
+ end
199
+ meta_file.write(text)
200
+ end
201
+
202
+ send_json(client, 200, { ok: true, path: "books/#{book_id}/cover.png" })
203
+ rescue JSON::ParserError => e
204
+ send_json(client, 400, { ok: false, error: "JSON 解析失败: #{e.message}" })
205
+ end
206
+
207
+ def send_json(client, status, hash)
208
+ send_response(client, status, MIME[".json"], JSON.generate(hash), "POST")
209
+ end
210
+
211
+ def serve_file(client, path, content_type, method)
212
+ return not_found(client, method) unless path.file?
213
+
214
+ body = path.binread
215
+ content_type ||= MIME[path.extname.downcase] || "application/octet-stream"
216
+ send_response(client, 200, content_type, body, method)
217
+ end
218
+
219
+ def not_found(client, method)
220
+ body = "<h1>404 Not Found</h1>"
221
+ send_response(client, 404, MIME[".html"], body, method)
222
+ end
223
+
224
+ def send_response(client, status, content_type, body, method)
225
+ reason = { 200 => "OK", 404 => "Not Found" }[status] || "OK"
226
+ headers = "HTTP/1.1 #{status} #{reason}\r\n" \
227
+ "Content-Type: #{content_type}\r\n" \
228
+ "Content-Length: #{body.bytesize}\r\n" \
229
+ "Connection: close\r\n\r\n"
230
+ client.write(headers)
231
+ client.write(body) unless method == "HEAD"
232
+ end
233
+
234
+ def escape_html(str)
235
+ str.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
236
+ end
237
+ end
238
+ end