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.
- checksums.yaml +4 -4
- data/README.md +46 -13
- data/lib/bindery/assets/index.html +687 -0
- data/lib/bindery/book.rb +10 -0
- data/lib/bindery/builder.rb +36 -5
- data/lib/bindery/cli.rb +326 -32
- data/lib/bindery/cover.rb +135 -0
- data/lib/bindery/generators/book_generator.rb +21 -5
- data/lib/bindery/generators/chapter_generator.rb +49 -0
- data/lib/bindery/generators/project_generator.rb +16 -10
- data/lib/bindery/generators/templates/book/chapters/{01-chapter.md.erb → chapter-0001.md.erb} +2 -1
- data/lib/bindery/generators/templates/book/metadata.yaml.erb +5 -3
- data/lib/bindery/generators/templates/project/README.md.erb +35 -4
- data/lib/bindery/generators/templates/project/gitignore +0 -1
- data/lib/bindery/generators/volume_generator.rb +62 -0
- data/lib/bindery/image.rb +64 -0
- data/lib/bindery/project.rb +11 -1
- data/lib/bindery/renamer.rb +57 -0
- data/lib/bindery/server.rb +238 -0
- data/lib/bindery/validator.rb +52 -0
- data/lib/bindery/version.rb +1 -1
- data/lib/bindery.rb +7 -0
- metadata +15 -7
|
@@ -1,28 +1,44 @@
|
|
|
1
|
+
require "securerandom"
|
|
1
2
|
require_relative "base"
|
|
2
3
|
|
|
3
4
|
module Bindery
|
|
4
5
|
module Generators
|
|
5
|
-
# bindery
|
|
6
|
+
# bindery new book <title> [--author] [--dynasty] [--category] [--translator] [--isbn] [--id]
|
|
6
7
|
class BookGenerator < Base
|
|
7
|
-
|
|
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(
|
|
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/
|
|
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
|
|
6
|
-
#
|
|
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
|
|
11
|
-
if dest.
|
|
12
|
-
raise Bindery::Error, "
|
|
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
|
-
|
|
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 "
|
|
20
|
-
puts " bindery
|
|
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,9 +1,11 @@
|
|
|
1
1
|
id: <%= id.inspect %>
|
|
2
2
|
title: <%= title.inspect %>
|
|
3
3
|
author: <%= author.inspect %>
|
|
4
|
-
|
|
4
|
+
translator: <%= translator.inspect %>
|
|
5
|
+
lang: <%= lang.inspect %>
|
|
5
6
|
dynasty: <%= dynasty.inspect %>
|
|
6
7
|
category: <%= category.inspect %>
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
10
|
-
#
|
|
11
|
-
bindery
|
|
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
|
## 目录结构
|
|
@@ -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
|
data/lib/bindery/project.rb
CHANGED
|
@@ -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
|
|
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("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|