bindery-cli 0.2.0 → 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 +9 -7
- data/lib/bindery/assets/index.html +687 -0
- data/lib/bindery/book.rb +2 -25
- data/lib/bindery/builder.rb +15 -48
- data/lib/bindery/cli.rb +63 -3
- data/lib/bindery/cover.rb +1 -1
- data/lib/bindery/generators/book_generator.rb +1 -1
- data/lib/bindery/generators/chapter_generator.rb +6 -6
- data/lib/bindery/generators/templates/book/chapters/{01-chapter.md.erb → chapter-0001.md.erb} +2 -1
- data/lib/bindery/generators/templates/project/README.md.erb +6 -0
- data/lib/bindery/generators/volume_generator.rb +16 -20
- data/lib/bindery/renamer.rb +57 -0
- data/lib/bindery/server.rb +56 -3
- data/lib/bindery/validator.rb +3 -3
- data/lib/bindery/version.rb +1 -1
- data/lib/bindery.rb +1 -0
- metadata +4 -2
data/lib/bindery/book.rb
CHANGED
|
@@ -57,38 +57,15 @@ module Bindery
|
|
|
57
57
|
dir + "chapters"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
# 平铺的章节文件(直接在 chapters/
|
|
60
|
+
# 平铺的章节文件(直接在 chapters/ 下,按文件名排序)
|
|
61
61
|
def chapters
|
|
62
62
|
return [] unless chapters_dir.directory?
|
|
63
63
|
|
|
64
64
|
chapters_dir.children.select { |f| f.file? && f.extname == ".md" }.sort
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
# 卷目录(chapters/ 下的子目录,按文件名排序)
|
|
68
|
-
def volumes
|
|
69
|
-
return [] unless chapters_dir.directory?
|
|
70
|
-
|
|
71
|
-
chapters_dir.children.select(&:directory?).sort
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def has_volumes?
|
|
75
|
-
!volumes.empty?
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# 某个卷目录下的章节文件
|
|
79
|
-
def volume_chapters(vol_dir)
|
|
80
|
-
return [] unless vol_dir.directory?
|
|
81
|
-
|
|
82
|
-
vol_dir.children.select { |f| f.file? && f.extname == ".md" }.sort
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# 所有章节(平铺 + 各卷内),供统计 / 校验使用
|
|
86
|
-
def all_chapters
|
|
87
|
-
(chapters + volumes.flat_map { |v| volume_chapters(v) }).sort_by(&:to_s)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
67
|
def valid?
|
|
91
|
-
metadata_file.file? &&
|
|
68
|
+
metadata_file.file? && chapters.any?
|
|
92
69
|
end
|
|
93
70
|
|
|
94
71
|
def epub_output(project_root = Project.root!)
|
data/lib/bindery/builder.rb
CHANGED
|
@@ -17,12 +17,8 @@ module Bindery
|
|
|
17
17
|
|
|
18
18
|
# 返回 true/false 表示是否成功;详细错误信息可通过 #last_error 获取
|
|
19
19
|
def build_epub
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@last_error = "#{@book.id} 的卷目录下没有任何 .md 文件"
|
|
23
|
-
return false
|
|
24
|
-
end
|
|
25
|
-
elsif @book.chapters.empty?
|
|
20
|
+
chapters = @book.chapters
|
|
21
|
+
if chapters.empty?
|
|
26
22
|
@last_error = "#{@book.id} 的 chapters/ 目录下没有任何 .md 文件"
|
|
27
23
|
return false
|
|
28
24
|
end
|
|
@@ -31,23 +27,17 @@ module Bindery
|
|
|
31
27
|
ensure_pandoc!
|
|
32
28
|
|
|
33
29
|
Dir.mktmpdir("bindery-#{@book.id}-") do |tmp|
|
|
34
|
-
|
|
35
|
-
merged = merge_volumes(tmp)
|
|
36
|
-
split_level = 2
|
|
37
|
-
else
|
|
38
|
-
merged = merge_chapters(@book.chapters, tmp)
|
|
39
|
-
split_level = nil
|
|
40
|
-
end
|
|
41
|
-
|
|
30
|
+
merged = merge_chapters(chapters, tmp)
|
|
42
31
|
meta_file = write_pandoc_metadata(tmp, meta)
|
|
43
32
|
|
|
44
33
|
out_dir = Project.output_dir(@project_root) + "epub"
|
|
45
34
|
FileUtils.mkdir_p(out_dir)
|
|
46
35
|
out_path = out_dir + "#{@book.id}.epub"
|
|
47
36
|
|
|
37
|
+
# 按内容中最顶层的标题级别拆分:h1=书名 / h2=卷 / h3=章
|
|
38
|
+
split_level = top_heading_level(merged)
|
|
48
39
|
cmd = ["pandoc", merged.to_s, "-o", out_path.to_s,
|
|
49
|
-
"--metadata-file", meta_file.to_s, "--toc"]
|
|
50
|
-
cmd += ["--split-level", split_level.to_s] if split_level
|
|
40
|
+
"--metadata-file", meta_file.to_s, "--toc", "--split-level", split_level.to_s]
|
|
51
41
|
|
|
52
42
|
css = Project.templates_dir(@project_root) + "style.css"
|
|
53
43
|
cmd += ["--css", css.to_s] if css.file?
|
|
@@ -96,39 +86,16 @@ module Bindery
|
|
|
96
86
|
path
|
|
97
87
|
end
|
|
98
88
|
|
|
99
|
-
#
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
out.puts if i.positive?
|
|
105
|
-
name = vol_dir.basename.to_s.sub(/\A\d+-/, "")
|
|
106
|
-
out.puts "## #{name}"
|
|
107
|
-
out.puts
|
|
108
|
-
@book.volume_chapters(vol_dir).each do |chapter|
|
|
109
|
-
out.puts demote_headings(chapter.read, 2)
|
|
110
|
-
out.puts
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
path
|
|
115
|
-
end
|
|
89
|
+
# 返回合并后内容中最顶层的标题级别(1..6),无标题时返回 1
|
|
90
|
+
def top_heading_level(merged)
|
|
91
|
+
level = nil
|
|
92
|
+
merged.each_line do |line|
|
|
93
|
+
next unless line =~ /^(\#{1,6})\s/
|
|
116
94
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if line =~ /^\s*(`{3,}|~{3,})/
|
|
122
|
-
in_fence = !in_fence
|
|
123
|
-
line
|
|
124
|
-
elsif in_fence
|
|
125
|
-
line
|
|
126
|
-
elsif line =~ /^(\#{1,6})(\s|$)/
|
|
127
|
-
("#" * levels) + line
|
|
128
|
-
else
|
|
129
|
-
line
|
|
130
|
-
end
|
|
131
|
-
end.join
|
|
95
|
+
l = Regexp.last_match(1).length
|
|
96
|
+
level = l if level.nil? || l < level
|
|
97
|
+
end
|
|
98
|
+
level || 1
|
|
132
99
|
end
|
|
133
100
|
|
|
134
101
|
def write_pandoc_metadata(tmp_dir, meta)
|
data/lib/bindery/cli.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "optparse"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "rbconfig"
|
|
3
4
|
|
|
4
5
|
module Bindery
|
|
5
6
|
# 命令行入口:解析子命令并分发
|
|
@@ -24,10 +25,14 @@ module Bindery
|
|
|
24
25
|
cmd_validate(rest)
|
|
25
26
|
when "cover"
|
|
26
27
|
cmd_cover(rest)
|
|
28
|
+
when "rename"
|
|
29
|
+
cmd_rename(rest)
|
|
27
30
|
when "watch"
|
|
28
31
|
cmd_watch(rest)
|
|
29
32
|
when "serve"
|
|
30
33
|
cmd_serve(rest)
|
|
34
|
+
when "web"
|
|
35
|
+
cmd_web(rest)
|
|
31
36
|
when "status"
|
|
32
37
|
cmd_status(rest)
|
|
33
38
|
when "version", "-v", "--version"
|
|
@@ -117,6 +122,19 @@ module Bindery
|
|
|
117
122
|
puts "已生成封面: #{path}"
|
|
118
123
|
end
|
|
119
124
|
|
|
125
|
+
# ---------- bindery rename chapters <book-id> ----------
|
|
126
|
+
def cmd_rename(args)
|
|
127
|
+
type, book_id = args
|
|
128
|
+
if type != "chapters" || book_id.nil?
|
|
129
|
+
warn "用法: bindery rename chapters <book-id>"
|
|
130
|
+
exit 1
|
|
131
|
+
end
|
|
132
|
+
project_root = Project.root!
|
|
133
|
+
book = Bindery::Book.find(book_id, project_root)
|
|
134
|
+
count = Bindery::Renamer.rename_chapters(book)
|
|
135
|
+
puts "重命名完成:更新 #{count} 个文件"
|
|
136
|
+
end
|
|
137
|
+
|
|
120
138
|
def cmd_new_book(args)
|
|
121
139
|
options = { author: "佚名", dynasty: "", category: "", translator: "", isbn: "", id: nil, cover: false }
|
|
122
140
|
parser = OptionParser.new do |o|
|
|
@@ -193,7 +211,7 @@ module Bindery
|
|
|
193
211
|
|
|
194
212
|
if options[:dry_run]
|
|
195
213
|
books.each do |book|
|
|
196
|
-
puts "[#{book.id}] 将构建 → #{book.epub_output(project_root)}(#{book.
|
|
214
|
+
puts "[#{book.id}] 将构建 → #{book.epub_output(project_root)}(#{book.chapters.size} 个章节)"
|
|
197
215
|
end
|
|
198
216
|
return
|
|
199
217
|
end
|
|
@@ -345,6 +363,46 @@ module Bindery
|
|
|
345
363
|
Bindery::Server.new(project_root, host: options[:host], port: options[:port]).start
|
|
346
364
|
end
|
|
347
365
|
|
|
366
|
+
# ---------- bindery web [--port N] [--host H] ----------
|
|
367
|
+
def cmd_web(args)
|
|
368
|
+
options = { port: 8000, host: "127.0.0.1" }
|
|
369
|
+
parser = OptionParser.new do |o|
|
|
370
|
+
o.on("--port N", Integer) { |v| options[:port] = v }
|
|
371
|
+
o.on("--host H") { |v| options[:host] = v }
|
|
372
|
+
end
|
|
373
|
+
parser.parse(args)
|
|
374
|
+
|
|
375
|
+
project_root = Project.root!
|
|
376
|
+
Index.rebuild(project_root)
|
|
377
|
+
|
|
378
|
+
url = "http://#{options[:host]}:#{options[:port]}/cover"
|
|
379
|
+
server = Bindery::Server.new(project_root, host: options[:host], port: options[:port])
|
|
380
|
+
thread = Thread.new do
|
|
381
|
+
begin
|
|
382
|
+
server.start
|
|
383
|
+
rescue StandardError => e
|
|
384
|
+
warn "服务启动失败: #{e.message}"
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
sleep 0.5 # 等服务就绪
|
|
388
|
+
open_browser(url)
|
|
389
|
+
puts "封面生成器已打开: #{url}(Ctrl-C 退出)"
|
|
390
|
+
thread.join
|
|
391
|
+
rescue Interrupt
|
|
392
|
+
puts "\n已退出"
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def open_browser(url)
|
|
396
|
+
case RbConfig::CONFIG["host_os"]
|
|
397
|
+
when /darwin/
|
|
398
|
+
system("open", url)
|
|
399
|
+
when /mswin|mingw|cygwin/
|
|
400
|
+
system("cmd", "/c", "start", "", url)
|
|
401
|
+
else
|
|
402
|
+
system("xdg-open", url)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
348
406
|
# ---------- bindery status ----------
|
|
349
407
|
def cmd_status(_args)
|
|
350
408
|
project_root = Project.root!
|
|
@@ -359,7 +417,7 @@ module Bindery
|
|
|
359
417
|
books.each do |book|
|
|
360
418
|
printf(
|
|
361
419
|
"%-16s %-16s %-6d %-4s %-6s %-16s\n",
|
|
362
|
-
book.id, book.title, book.
|
|
420
|
+
book.id, book.title, book.chapters.size,
|
|
363
421
|
book.cover? ? "✅" : "—",
|
|
364
422
|
book.built?(project_root) ? "✅" : "—",
|
|
365
423
|
book.built_at(project_root)&.strftime("%m-%d %H:%M") || "—"
|
|
@@ -380,8 +438,9 @@ module Bindery
|
|
|
380
438
|
选项: --id ID(可选) --author NAME --dynasty 朝代 --category 分类
|
|
381
439
|
--translator 译者 --isbn ISBN --cover 自动生成封面
|
|
382
440
|
bindery new chapter <book-id> <数量> 批量创建章节(编号自动接续)
|
|
383
|
-
bindery new volume <book-id> <卷名> [--chapters N]
|
|
441
|
+
bindery new volume <book-id> <卷名> [--chapters N] 新建卷(平铺 md,h2=卷名/h3=章名)
|
|
384
442
|
bindery cover <book-id> 自动生成装饰性封面
|
|
443
|
+
bindery rename chapters <book-id> 按标题自动更新章节文件名
|
|
385
444
|
bindery build <id> 构建单本书的 epub
|
|
386
445
|
bindery build --all 批量构建所有书
|
|
387
446
|
bindery build <id> --check 构建前校验(不实际构建)
|
|
@@ -393,6 +452,7 @@ module Bindery
|
|
|
393
452
|
bindery clean [--all] 清理构建产物(--all 连 books.json 一起删)
|
|
394
453
|
bindery watch [--interval 秒] 监听 books/ 自动重建
|
|
395
454
|
bindery serve [--port N] [--host H] 启动本地书库站点
|
|
455
|
+
bindery web [--port N] 打开 Web 封面生成器
|
|
396
456
|
bindery status 查看所有书籍状态
|
|
397
457
|
bindery version 查看版本号
|
|
398
458
|
HELP
|
data/lib/bindery/cover.rb
CHANGED
|
@@ -38,7 +38,7 @@ module Bindery
|
|
|
38
38
|
translator: translator, isbn: isbn,
|
|
39
39
|
lang: lang, publisher: publisher, rights: rights,
|
|
40
40
|
})
|
|
41
|
-
template("book", "chapters/
|
|
41
|
+
template("book", "chapters/chapter-0001.md", locals: { title: title })
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
end
|
|
@@ -4,7 +4,7 @@ require_relative "base"
|
|
|
4
4
|
module Bindery
|
|
5
5
|
module Generators
|
|
6
6
|
# bindery new chapter <book-id> <数量>
|
|
7
|
-
# 在指定书籍里批量创建章节文件,编号自动接续已有章节(
|
|
7
|
+
# 在指定书籍里批量创建章节文件,编号自动接续已有章节(chapter-0001.md 之后是 chapter-0002.md…)
|
|
8
8
|
class ChapterGenerator < Base
|
|
9
9
|
def self.call(book_id, count, project_root: Project.root!)
|
|
10
10
|
book = Bindery::Book.find(book_id, project_root)
|
|
@@ -18,8 +18,8 @@ module Bindery
|
|
|
18
18
|
puts "创建章节: #{book.id}"
|
|
19
19
|
count.times do |i|
|
|
20
20
|
n = start + i
|
|
21
|
-
file = chapters_dir + format("
|
|
22
|
-
file.write("
|
|
21
|
+
file = chapters_dir + format("chapter-%04d.md", n)
|
|
22
|
+
file.write("### 第#{n}章\n\n")
|
|
23
23
|
rel = begin
|
|
24
24
|
file.relative_path_from(Pathname.pwd)
|
|
25
25
|
rescue StandardError
|
|
@@ -31,7 +31,7 @@ module Bindery
|
|
|
31
31
|
puts "完成!已创建 #{count} 个章节(从第 #{start} 章开始)"
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
#
|
|
34
|
+
# 扫描已有章节文件名中的编号(chapter-0001),返回下一个可用编号
|
|
35
35
|
def self.next_number(chapters_dir)
|
|
36
36
|
max = 0
|
|
37
37
|
return max + 1 unless chapters_dir.directory?
|
|
@@ -39,8 +39,8 @@ module Bindery
|
|
|
39
39
|
chapters_dir.children.each do |f|
|
|
40
40
|
next unless f.file? && f.extname == ".md"
|
|
41
41
|
|
|
42
|
-
n = f.basename.to_s[/\
|
|
43
|
-
max = n if n > max
|
|
42
|
+
n = f.basename.to_s[/\Achapter-(\d+)/, 1]
|
|
43
|
+
max = n.to_i if n && n.to_i > max
|
|
44
44
|
end
|
|
45
45
|
max + 1
|
|
46
46
|
end
|
|
@@ -16,6 +16,9 @@ bindery new volume <书籍ID> "<卷名>" --chapters <数量>
|
|
|
16
16
|
# 单独批量创建章节(编号自动接续已有章节)
|
|
17
17
|
bindery new chapter <书籍ID> <数量>
|
|
18
18
|
|
|
19
|
+
# 按标题自动更新章节文件名(chapter-0001-标题.md)
|
|
20
|
+
bindery rename chapters <书籍ID>
|
|
21
|
+
|
|
19
22
|
# 单独生成封面
|
|
20
23
|
bindery cover <书籍ID>
|
|
21
24
|
|
|
@@ -41,6 +44,9 @@ bindery watch
|
|
|
41
44
|
# 本地启动书库站点
|
|
42
45
|
bindery serve
|
|
43
46
|
|
|
47
|
+
# 打开 Web 封面生成器(自动填充书籍信息)
|
|
48
|
+
bindery web
|
|
49
|
+
|
|
44
50
|
# 清理构建产物
|
|
45
51
|
bindery clean --all
|
|
46
52
|
```
|
|
@@ -4,37 +4,33 @@ require_relative "base"
|
|
|
4
4
|
module Bindery
|
|
5
5
|
module Generators
|
|
6
6
|
# bindery new volume <book-id> <卷名> [--chapters N]
|
|
7
|
-
#
|
|
7
|
+
# 在书籍下创建一个平铺卷文件(卷01-学而.md),卷结构由标题层级定义:
|
|
8
|
+
# ## 卷名(h2)
|
|
9
|
+
# ### 第1章(h3)
|
|
8
10
|
class VolumeGenerator < Base
|
|
9
11
|
def self.call(book_id, volume_name, chapters: 0, project_root: Project.root!)
|
|
10
12
|
book = Bindery::Book.find(book_id, project_root)
|
|
11
13
|
volume_name = volume_name.to_s.strip
|
|
12
14
|
raise Bindery::Error, "卷名不能为空" if volume_name.empty?
|
|
13
15
|
|
|
14
|
-
if book.volumes.empty? && book.chapters.any?
|
|
15
|
-
warn "注意:chapters/ 下已有平铺章节文件(#{book.chapters.size} 个),卷式书构建时会忽略它们"
|
|
16
|
-
end
|
|
17
|
-
|
|
18
16
|
chapters_dir = book.chapters_dir
|
|
19
17
|
FileUtils.mkdir_p(chapters_dir) unless chapters_dir.directory?
|
|
20
18
|
|
|
21
19
|
n = next_volume_number(chapters_dir)
|
|
22
|
-
|
|
23
|
-
raise Bindery::Error, "
|
|
20
|
+
file = chapters_dir + format("卷%02d-%s.md", n, volume_name)
|
|
21
|
+
raise Bindery::Error, "卷文件已存在: #{file}" if file.exist?
|
|
24
22
|
|
|
25
23
|
chapters = chapters.to_i
|
|
26
24
|
raise Bindery::Error, "章节数必须是正整数" if chapters.negative?
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
puts "创建卷: #{volume_name}"
|
|
30
|
-
say_rel(vol_dir, trailing: "/")
|
|
31
|
-
|
|
26
|
+
content = +"## #{volume_name}\n\n"
|
|
32
27
|
chapters.times do |i|
|
|
33
|
-
|
|
34
|
-
file.write("# 第#{i + 1}章\n\n")
|
|
35
|
-
say_rel(file)
|
|
28
|
+
content << "### 第#{i + 1}章\n\n"
|
|
36
29
|
end
|
|
30
|
+
file.write(content)
|
|
37
31
|
|
|
32
|
+
puts "创建卷: #{volume_name}"
|
|
33
|
+
say_rel(file)
|
|
38
34
|
puts ""
|
|
39
35
|
summary = chapters.positive? ? ",含 #{chapters} 章" : ""
|
|
40
36
|
puts "完成!已创建卷 #{format('%02d', n)}(#{volume_name})#{summary}"
|
|
@@ -42,13 +38,13 @@ module Bindery
|
|
|
42
38
|
|
|
43
39
|
def self.next_volume_number(chapters_dir)
|
|
44
40
|
max = 0
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
return max + 1 unless chapters_dir.directory?
|
|
42
|
+
|
|
43
|
+
chapters_dir.children.each do |f|
|
|
44
|
+
next unless f.file? && f.extname == ".md"
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end
|
|
46
|
+
m = f.basename.to_s[/\A卷(\d+)/, 1]
|
|
47
|
+
max = m.to_i if m && m.to_i > max
|
|
52
48
|
end
|
|
53
49
|
max + 1
|
|
54
50
|
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
|
data/lib/bindery/server.rb
CHANGED
|
@@ -2,6 +2,8 @@ require "socket"
|
|
|
2
2
|
require "uri"
|
|
3
3
|
require "json"
|
|
4
4
|
require "yaml"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "pathname"
|
|
5
7
|
|
|
6
8
|
module Bindery
|
|
7
9
|
# 极简静态服务器:展示书库并提供 EPUB 下载。
|
|
@@ -18,6 +20,9 @@ module Bindery
|
|
|
18
20
|
".svg" => "image/svg+xml",
|
|
19
21
|
}.freeze
|
|
20
22
|
|
|
23
|
+
# 封面编辑器页面(随 gem 一起打包)
|
|
24
|
+
ASSETS_DIR = Pathname.new(__dir__) + "assets"
|
|
25
|
+
|
|
21
26
|
def initialize(project_root, host: "127.0.0.1", port: 8000)
|
|
22
27
|
@project_root = project_root
|
|
23
28
|
@host = host
|
|
@@ -58,22 +63,33 @@ module Bindery
|
|
|
58
63
|
return unless request_line
|
|
59
64
|
|
|
60
65
|
method, raw_path, = request_line.split(" ")
|
|
61
|
-
#
|
|
66
|
+
# 读取请求头,POST 需要 Content-Length
|
|
67
|
+
content_length = 0
|
|
62
68
|
while (line = client.gets)
|
|
63
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
|
|
64
73
|
end
|
|
65
|
-
return unless method == "GET" || method == "HEAD"
|
|
66
74
|
|
|
67
75
|
path = (URI::DEFAULT_PARSER.unescape(raw_path.split("?").first) rescue raw_path)
|
|
68
76
|
path = "/" if path.nil? || path.empty?
|
|
69
77
|
path = path.gsub("..", "") # 防目录穿越
|
|
70
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
|
+
|
|
71
85
|
case path
|
|
72
86
|
when "/", "/index.html"
|
|
73
87
|
body = index_html
|
|
74
88
|
send_response(client, 200, MIME[".html"], body, method)
|
|
75
89
|
when "/books.json"
|
|
76
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)
|
|
77
93
|
when %r{\A/epub/([\w\-.]+\.epub)\z}
|
|
78
94
|
serve_file(client, Project.output_dir(@project_root) + "epub" + Regexp.last_match(1), MIME[".epub"], method)
|
|
79
95
|
when %r{\A/cover/([\w\-]+)\z}
|
|
@@ -117,7 +133,7 @@ module Bindery
|
|
|
117
133
|
h1 { font-size: 1.6em; border-bottom: 1px solid #eee; padding-bottom: .4em; }
|
|
118
134
|
ul { list-style: none; padding: 0; }
|
|
119
135
|
.book { display: flex; gap: 1em; padding: 1em 0; border-bottom: 1px solid #f0f0f0; }
|
|
120
|
-
.cover { width: 64px; height:
|
|
136
|
+
.cover { width: 64px; height: 102px; object-fit: cover; background: #f5f5f5; border-radius: 4px; }
|
|
121
137
|
.info h2 { margin: 0; font-size: 1.1em; }
|
|
122
138
|
.author { color: #666; margin: .3em 0 .5em; }
|
|
123
139
|
a { color: #b45309; }
|
|
@@ -155,6 +171,43 @@ module Bindery
|
|
|
155
171
|
serve_file(client, path, nil, method)
|
|
156
172
|
end
|
|
157
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
|
+
|
|
158
211
|
def serve_file(client, path, content_type, method)
|
|
159
212
|
return not_found(client, method) unless path.file?
|
|
160
213
|
|
data/lib/bindery/validator.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Bindery
|
|
|
20
20
|
issues << "缺少 metadata.yaml"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
issues << "chapters/ 目录下没有任何 .md 文件" if book.
|
|
23
|
+
issues << "chapters/ 目录下没有任何 .md 文件" if book.chapters.empty?
|
|
24
24
|
issues << "未安装 pandoc(构建前请先安装)" unless pandoc_installed?
|
|
25
25
|
|
|
26
26
|
issues
|
|
@@ -37,8 +37,8 @@ module Bindery
|
|
|
37
37
|
dims = Bindery::Image.dimensions(cover)
|
|
38
38
|
if dims.nil?
|
|
39
39
|
warns << "封面不是有效的 JPEG/PNG 图片"
|
|
40
|
-
elsif dims[0] < 600 || dims[1] <
|
|
41
|
-
warns << "封面尺寸偏小(#{dims[0]}×#{dims[1]},建议至少 600×
|
|
40
|
+
elsif dims[0] < 600 || dims[1] < 960
|
|
41
|
+
warns << "封面尺寸偏小(#{dims[0]}×#{dims[1]},建议至少 600×960,比例 1:1.6)"
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
data/lib/bindery/version.rb
CHANGED
data/lib/bindery.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "bindery/builder"
|
|
|
6
6
|
require_relative "bindery/image"
|
|
7
7
|
require_relative "bindery/cover"
|
|
8
8
|
require_relative "bindery/validator"
|
|
9
|
+
require_relative "bindery/renamer"
|
|
9
10
|
require_relative "bindery/index"
|
|
10
11
|
require_relative "bindery/server"
|
|
11
12
|
require_relative "bindery/generators/project_generator"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bindery-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Billow Wang
|
|
@@ -24,6 +24,7 @@ files:
|
|
|
24
24
|
- README.md
|
|
25
25
|
- exe/bindery
|
|
26
26
|
- lib/bindery.rb
|
|
27
|
+
- lib/bindery/assets/index.html
|
|
27
28
|
- lib/bindery/book.rb
|
|
28
29
|
- lib/bindery/builder.rb
|
|
29
30
|
- lib/bindery/cli.rb
|
|
@@ -33,7 +34,7 @@ files:
|
|
|
33
34
|
- lib/bindery/generators/book_generator.rb
|
|
34
35
|
- lib/bindery/generators/chapter_generator.rb
|
|
35
36
|
- lib/bindery/generators/project_generator.rb
|
|
36
|
-
- lib/bindery/generators/templates/book/chapters/
|
|
37
|
+
- lib/bindery/generators/templates/book/chapters/chapter-0001.md.erb
|
|
37
38
|
- lib/bindery/generators/templates/book/metadata.yaml.erb
|
|
38
39
|
- lib/bindery/generators/templates/project/README.md.erb
|
|
39
40
|
- lib/bindery/generators/templates/project/config/bindery.yml.erb
|
|
@@ -44,6 +45,7 @@ files:
|
|
|
44
45
|
- lib/bindery/image.rb
|
|
45
46
|
- lib/bindery/index.rb
|
|
46
47
|
- lib/bindery/project.rb
|
|
48
|
+
- lib/bindery/renamer.rb
|
|
47
49
|
- lib/bindery/server.rb
|
|
48
50
|
- lib/bindery/validator.rb
|
|
49
51
|
- lib/bindery/version.rb
|