ligarb 0.4.0 → 0.6.0
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/assets/review.css +682 -0
- data/assets/review.js +684 -0
- data/assets/serve.js +97 -0
- data/assets/style.css +103 -0
- data/lib/ligarb/asset_manager.rb +17 -2
- data/lib/ligarb/builder.rb +176 -1
- data/lib/ligarb/chapter.rb +32 -4
- data/lib/ligarb/claude_runner.rb +313 -0
- data/lib/ligarb/cli.rb +207 -9
- data/lib/ligarb/config.rb +25 -1
- data/lib/ligarb/initializer.rb +20 -0
- data/lib/ligarb/inotify.rb +75 -0
- data/lib/ligarb/review_store.rb +133 -0
- data/lib/ligarb/server.rb +1218 -0
- data/lib/ligarb/template.rb +7 -1
- data/lib/ligarb/version.rb +1 -1
- data/lib/ligarb/writer.rb +96 -18
- data/templates/book.html.erb +226 -32
- metadata +36 -1
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "json"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "review_store"
|
|
9
|
+
require_relative "claude_runner"
|
|
10
|
+
|
|
11
|
+
module Ligarb
|
|
12
|
+
class Server
|
|
13
|
+
INJECTED_ASSETS = %w[serve.js review.js review.css].freeze
|
|
14
|
+
|
|
15
|
+
BookEntry = Struct.new(:slug, :config, :config_path, :build_dir, :store, :claude, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
def initialize(config_paths, port: 3000, multi: false)
|
|
18
|
+
@port = port
|
|
19
|
+
@assets_dir = File.join(File.dirname(__FILE__), "..", "..", "assets")
|
|
20
|
+
@sse_clients = [] # [[slug, queue], ...]
|
|
21
|
+
@sse_mutex = Mutex.new
|
|
22
|
+
@write_jobs = {} # slug => { title:, status:, error: }
|
|
23
|
+
@write_mutex = Mutex.new
|
|
24
|
+
|
|
25
|
+
@books = {}
|
|
26
|
+
config_paths.each do |cp|
|
|
27
|
+
config = Config.new(cp)
|
|
28
|
+
slug = File.basename(config.base_dir)
|
|
29
|
+
abort "Error: duplicate book slug '#{slug}' — use distinct directory names" if @books.key?(slug)
|
|
30
|
+
@books[slug] = BookEntry.new(
|
|
31
|
+
slug: slug,
|
|
32
|
+
config: config,
|
|
33
|
+
config_path: File.expand_path(cp),
|
|
34
|
+
build_dir: config.output_path,
|
|
35
|
+
store: ReviewStore.new(config.base_dir),
|
|
36
|
+
claude: ClaudeRunner.new(config)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@multi = multi || @books.size > 1
|
|
41
|
+
|
|
42
|
+
@operation_queue = Queue.new
|
|
43
|
+
@operation_thread = Thread.new { operation_worker }
|
|
44
|
+
@operation_thread.abort_on_exception = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def start
|
|
48
|
+
require_relative "builder"
|
|
49
|
+
@books.each_value do |book|
|
|
50
|
+
puts "Building #{book.config.title}..."
|
|
51
|
+
Builder.new(book.config_path).build
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
server = WEBrick::HTTPServer.new(
|
|
55
|
+
Port: @port,
|
|
56
|
+
BindAddress: "127.0.0.1",
|
|
57
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO),
|
|
58
|
+
AccessLog: [[File.open(File::NULL, "w"), WEBrick::AccessLog::COMMON_LOG_FORMAT]],
|
|
59
|
+
RequestBodyMaxSize: 50 * 1024 * 1024
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
server.mount_proc("/_ligarb/") { |req, res| handle_api(req, res) }
|
|
63
|
+
server.mount_proc("/") { |req, res| handle_static(req, res) }
|
|
64
|
+
|
|
65
|
+
trap("INT") { Thread.new { close_sse_clients; server.shutdown } }
|
|
66
|
+
trap("TERM") { Thread.new { close_sse_clients; server.shutdown } }
|
|
67
|
+
|
|
68
|
+
@books.each_value { |book| start_build_watcher(book) }
|
|
69
|
+
|
|
70
|
+
if @multi
|
|
71
|
+
puts "Serving #{@books.size} books at http://localhost:#{@port}"
|
|
72
|
+
@books.each_value { |b| puts " /#{b.slug}/ — #{b.config.title}" }
|
|
73
|
+
else
|
|
74
|
+
book = @books.values.first
|
|
75
|
+
puts "Serving #{book.config.title} at http://localhost:#{@port}"
|
|
76
|
+
puts " Build directory: #{book.build_dir}"
|
|
77
|
+
end
|
|
78
|
+
puts " Press Ctrl+C to stop"
|
|
79
|
+
|
|
80
|
+
server.start
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# ── Operation Queue ──
|
|
86
|
+
|
|
87
|
+
def operation_worker
|
|
88
|
+
loop do
|
|
89
|
+
job = @operation_queue.pop
|
|
90
|
+
job.call
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def enqueue_sync(&block)
|
|
95
|
+
result_queue = Queue.new
|
|
96
|
+
@operation_queue.push(-> {
|
|
97
|
+
result_queue.push(block.call)
|
|
98
|
+
})
|
|
99
|
+
result_queue.pop
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def enqueue_async(&block)
|
|
103
|
+
@operation_queue.push(-> { block.call })
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ── SSE (Server-Sent Events) ──
|
|
107
|
+
|
|
108
|
+
def close_sse_clients
|
|
109
|
+
@sse_mutex.synchronize do
|
|
110
|
+
@sse_clients.each { |_, q| q.push(:close) rescue nil }
|
|
111
|
+
@sse_clients.clear
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def sse_broadcast(event, data, slug: nil)
|
|
116
|
+
json = JSON.generate(data)
|
|
117
|
+
message = "event: #{event}\ndata: #{json}\n\n"
|
|
118
|
+
@sse_mutex.synchronize do
|
|
119
|
+
@sse_clients.reject! do |client_slug, queue|
|
|
120
|
+
next false if slug && client_slug != slug
|
|
121
|
+
begin
|
|
122
|
+
queue.push(message, true)
|
|
123
|
+
false
|
|
124
|
+
rescue ThreadError
|
|
125
|
+
true # queue full, drop client
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle_sse(book_slug, _req, res)
|
|
132
|
+
res["Content-Type"] = "text/event-stream"
|
|
133
|
+
res["Cache-Control"] = "no-cache"
|
|
134
|
+
res["Connection"] = "keep-alive"
|
|
135
|
+
res["X-Accel-Buffering"] = "no"
|
|
136
|
+
res.chunked = true
|
|
137
|
+
|
|
138
|
+
queue = SizedQueue.new(50)
|
|
139
|
+
@sse_mutex.synchronize { @sse_clients << [book_slug, queue] }
|
|
140
|
+
|
|
141
|
+
res.body = proc { |socket|
|
|
142
|
+
begin
|
|
143
|
+
socket.write("retry: 3000\n\n")
|
|
144
|
+
loop do
|
|
145
|
+
msg = queue.pop
|
|
146
|
+
break if msg == :close
|
|
147
|
+
socket.write(msg)
|
|
148
|
+
end
|
|
149
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
150
|
+
# Client disconnected
|
|
151
|
+
ensure
|
|
152
|
+
@sse_mutex.synchronize { @sse_clients.delete_if { |_, q| q == queue } }
|
|
153
|
+
end
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ── Build watcher ──
|
|
158
|
+
|
|
159
|
+
def start_build_watcher(book)
|
|
160
|
+
html_path = File.join(book.build_dir, "index.html")
|
|
161
|
+
|
|
162
|
+
if use_inotify?
|
|
163
|
+
log "Watching #{html_path} with inotify"
|
|
164
|
+
Thread.new do
|
|
165
|
+
Inotify.watch_file(html_path) do
|
|
166
|
+
log "Build updated: #{book.slug} (inotify)"
|
|
167
|
+
sse_broadcast("build_updated", { mtime: File.mtime(html_path).to_i }, slug: book.slug)
|
|
168
|
+
end
|
|
169
|
+
rescue => e
|
|
170
|
+
log "inotify watcher error: #{e.message}, falling back to polling"
|
|
171
|
+
start_mtime_poller(book)
|
|
172
|
+
end
|
|
173
|
+
else
|
|
174
|
+
start_mtime_poller(book)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def start_mtime_poller(book)
|
|
179
|
+
html_path = File.join(book.build_dir, "index.html")
|
|
180
|
+
log "Watching #{html_path} with polling"
|
|
181
|
+
Thread.new do
|
|
182
|
+
last_mtime = File.exist?(html_path) ? File.mtime(html_path).to_i : 0
|
|
183
|
+
loop do
|
|
184
|
+
sleep 2
|
|
185
|
+
current = File.exist?(html_path) ? File.mtime(html_path).to_i : 0
|
|
186
|
+
if current > last_mtime
|
|
187
|
+
last_mtime = current
|
|
188
|
+
sse_broadcast("build_updated", { mtime: current }, slug: book.slug)
|
|
189
|
+
end
|
|
190
|
+
rescue => e
|
|
191
|
+
log "mtime watcher error: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def use_inotify?
|
|
197
|
+
return @use_inotify if defined?(@use_inotify)
|
|
198
|
+
@use_inotify = begin
|
|
199
|
+
require_relative "inotify"
|
|
200
|
+
true
|
|
201
|
+
rescue Fiddle::DLError, LoadError
|
|
202
|
+
false
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# ── Static file serving ──
|
|
207
|
+
|
|
208
|
+
def handle_static(req, res)
|
|
209
|
+
path = req.path
|
|
210
|
+
|
|
211
|
+
unless @multi
|
|
212
|
+
# Single-book mode (original behavior)
|
|
213
|
+
if path == "/" || path == "/index.html"
|
|
214
|
+
serve_injected_html(@books.values.first, res)
|
|
215
|
+
return
|
|
216
|
+
end
|
|
217
|
+
serve_static_file(@books.values.first.build_dir, path, res)
|
|
218
|
+
return
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Multi-book mode
|
|
222
|
+
if path == "/" || path == "/index.html"
|
|
223
|
+
serve_index_page(res)
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# /<slug>/...
|
|
228
|
+
if path =~ %r{^/([^/]+)(/.*)?$}
|
|
229
|
+
book = @books[$1]
|
|
230
|
+
if book
|
|
231
|
+
sub_path = $2 || "/"
|
|
232
|
+
if sub_path == "/" || sub_path == "/index.html"
|
|
233
|
+
serve_injected_html(book, res)
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
serve_static_file(book.build_dir, sub_path, res)
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
res.status = 404
|
|
242
|
+
res.body = "Not Found"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def serve_static_file(build_dir, path, res)
|
|
246
|
+
file_path = File.join(build_dir, path)
|
|
247
|
+
file_path = File.realpath(file_path) rescue nil
|
|
248
|
+
|
|
249
|
+
real_build_dir = File.realpath(build_dir)
|
|
250
|
+
if file_path && (file_path.start_with?(real_build_dir + "/") || file_path == real_build_dir) && File.file?(file_path)
|
|
251
|
+
res.body = File.binread(file_path)
|
|
252
|
+
res["Content-Type"] = mime_type(file_path)
|
|
253
|
+
else
|
|
254
|
+
res.status = 404
|
|
255
|
+
res.body = "Not Found"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def serve_injected_html(book, res)
|
|
260
|
+
html_path = File.join(book.build_dir, "index.html")
|
|
261
|
+
html = File.read(html_path)
|
|
262
|
+
|
|
263
|
+
css_tag = %(<link rel="stylesheet" href="/_ligarb/assets/review.css">)
|
|
264
|
+
html.sub!("</head>", "#{css_tag}\n</head>")
|
|
265
|
+
|
|
266
|
+
api_base = @multi ? "/_ligarb/#{book.slug}" : "/_ligarb"
|
|
267
|
+
page_base = @multi ? "/#{book.slug}/" : "/"
|
|
268
|
+
config_tag = %(<script>window._ligarbAPI='#{escape_js_string(api_base)}';window._ligarbBase='#{escape_js_string(page_base)}';</script>)
|
|
269
|
+
|
|
270
|
+
js_tags = [config_tag] + %w[serve.js review.js].map { |f|
|
|
271
|
+
%(<script src="/_ligarb/assets/#{f}"></script>)
|
|
272
|
+
}
|
|
273
|
+
html.sub!("</body>", "#{js_tags.join("\n")}\n</body>")
|
|
274
|
+
|
|
275
|
+
res.body = html
|
|
276
|
+
res["Content-Type"] = "text/html; charset=utf-8"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def serve_index_page(res)
|
|
280
|
+
books_data = @books.values.sort_by { |b| b.config.title }.map { |book|
|
|
281
|
+
html_path = File.join(book.build_dir, "index.html")
|
|
282
|
+
mtime = File.exist?(html_path) ? File.mtime(html_path).strftime("%Y-%m-%d %H:%M") : nil
|
|
283
|
+
{
|
|
284
|
+
slug: book.slug,
|
|
285
|
+
title: book.config.title,
|
|
286
|
+
author: book.config.author.to_s,
|
|
287
|
+
updated_at: mtime,
|
|
288
|
+
toc: build_toc(book)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
books_json = JSON.generate(books_data)
|
|
292
|
+
write_jobs_json = JSON.generate(write_jobs_data)
|
|
293
|
+
|
|
294
|
+
html = <<~'HTML'
|
|
295
|
+
<!DOCTYPE html>
|
|
296
|
+
<html lang="en">
|
|
297
|
+
<head>
|
|
298
|
+
<meta charset="utf-8">
|
|
299
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
300
|
+
<title>ligarb librarium</title>
|
|
301
|
+
<style>
|
|
302
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
303
|
+
body { font-family: system-ui, -apple-system, sans-serif; color: #333; height: 100vh; display: flex; flex-direction: column; }
|
|
304
|
+
.idx-header { padding: 14px 20px; border-bottom: 1px solid #e0e0e0; font-size: 20px; font-weight: 600; flex-shrink: 0; }
|
|
305
|
+
.idx-container { display: flex; flex: 1; overflow: hidden; }
|
|
306
|
+
.idx-books { width: 280px; border-right: 1px solid #e0e0e0; overflow-y: auto; padding: 8px; flex-shrink: 0; display: flex; flex-direction: column; }
|
|
307
|
+
.idx-books-list { flex: 1; }
|
|
308
|
+
.idx-book { padding: 12px 16px; border-radius: 6px; cursor: pointer; transition: background 0.15s; }
|
|
309
|
+
.idx-book:hover { background: #f6f8fa; }
|
|
310
|
+
.idx-book.active { background: #eff6ff; border-left: 3px solid #2563eb; padding-left: 13px; }
|
|
311
|
+
.idx-book-title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
|
312
|
+
.idx-book-author { font-size: 13px; color: #666; margin-top: 2px; }
|
|
313
|
+
.idx-book-updated { font-size: 12px; color: #999; margin-top: 2px; }
|
|
314
|
+
.idx-badge { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 10px; white-space: nowrap; }
|
|
315
|
+
.idx-badge-writing { background: #fff3e0; color: #e65100; animation: idx-pulse 1.5s ease-in-out infinite; }
|
|
316
|
+
.idx-badge-done { background: #e8f5e9; color: #2e7d32; }
|
|
317
|
+
.idx-badge-error { background: #ffebee; color: #c62828; }
|
|
318
|
+
@keyframes idx-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
319
|
+
.idx-write-btn { display: block; margin: 8px; padding: 10px; border: 2px dashed #ccc; border-radius: 6px; background: none; color: #666; font-size: 14px; cursor: pointer; transition: all 0.15s; text-align: center; flex-shrink: 0; }
|
|
320
|
+
.idx-write-btn:hover { border-color: #2563eb; color: #2563eb; background: #f0f6ff; }
|
|
321
|
+
.idx-toc { flex: 1; overflow-y: auto; padding: 20px 28px; }
|
|
322
|
+
.idx-toc-empty { color: #999; font-size: 15px; padding: 60px 0; text-align: center; }
|
|
323
|
+
.idx-toc-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; }
|
|
324
|
+
.idx-toc-title a { color: #2563eb; text-decoration: none; }
|
|
325
|
+
.idx-toc-title a:hover { text-decoration: underline; }
|
|
326
|
+
.idx-toc-part { font-size: 12px; font-weight: 700; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 18px; margin-bottom: 6px; padding-left: 4px; }
|
|
327
|
+
.idx-toc-ch { display: block; padding: 5px 12px; border-radius: 4px; text-decoration: none; color: #333; font-size: 14px; line-height: 1.5; transition: background 0.15s; }
|
|
328
|
+
.idx-toc-ch:hover { background: #f6f8fa; }
|
|
329
|
+
.idx-toc-cover { color: #666; font-style: italic; }
|
|
330
|
+
.idx-form { max-width: 480px; }
|
|
331
|
+
.idx-form h2 { font-size: 18px; margin-bottom: 16px; }
|
|
332
|
+
.idx-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #555; }
|
|
333
|
+
.idx-form input, .idx-form textarea { width: 100%; padding: 8px 10px; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; font-family: inherit; margin-bottom: 12px; }
|
|
334
|
+
.idx-form input:focus, .idx-form textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.15); }
|
|
335
|
+
.idx-form textarea { resize: vertical; min-height: 80px; }
|
|
336
|
+
.idx-form-actions { display: flex; gap: 8px; margin-top: 4px; }
|
|
337
|
+
.idx-form-submit { padding: 8px 20px; background: #2563eb; color: #fff; border: none; border-radius: 4px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
|
338
|
+
.idx-form-submit:hover { background: #1d4ed8; }
|
|
339
|
+
.idx-form-submit:disabled { background: #93c5fd; cursor: not-allowed; }
|
|
340
|
+
.idx-form-cancel { padding: 8px 16px; background: none; border: 1px solid #d0d0d0; border-radius: 4px; font-size: 14px; cursor: pointer; color: #666; }
|
|
341
|
+
.idx-form-cancel:hover { background: #f6f8fa; }
|
|
342
|
+
.idx-form-error { color: #c62828; font-size: 13px; margin-bottom: 8px; }
|
|
343
|
+
.idx-dropzone { border: 2px dashed #d0d0d0; border-radius: 6px; padding: 16px; text-align: center; margin-bottom: 12px; transition: border-color 0.15s, background 0.15s; }
|
|
344
|
+
.idx-dropzone-active { border-color: #2563eb; background: #f0f6ff; }
|
|
345
|
+
.idx-dropzone-text { font-size: 13px; color: #888; }
|
|
346
|
+
.idx-dropzone-text a { color: #2563eb; text-decoration: none; cursor: pointer; }
|
|
347
|
+
.idx-dropzone-text a:hover { text-decoration: underline; }
|
|
348
|
+
.idx-file-names { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
|
349
|
+
.idx-file-tag { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; background: #eff6ff; border-radius: 4px; font-size: 12px; color: #333; }
|
|
350
|
+
.idx-file-tag a { color: #999; text-decoration: none; font-size: 14px; }
|
|
351
|
+
.idx-file-tag a:hover { color: #c62828; }
|
|
352
|
+
</style>
|
|
353
|
+
</head>
|
|
354
|
+
<body>
|
|
355
|
+
<div class="idx-header">Machinascripta</div>
|
|
356
|
+
<div class="idx-container">
|
|
357
|
+
<div class="idx-books">
|
|
358
|
+
<div class="idx-books-list" id="idx-books"></div>
|
|
359
|
+
<button class="idx-write-btn" id="idx-write-btn">+ Write a new book</button>
|
|
360
|
+
</div>
|
|
361
|
+
<div class="idx-toc" id="idx-toc">
|
|
362
|
+
<div class="idx-toc-empty">Select a book to view its table of contents</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
<script>
|
|
366
|
+
var books = __BOOKS_JSON__;
|
|
367
|
+
var writeJobs = __WRITE_JOBS_JSON__;
|
|
368
|
+
var listEl = document.getElementById('idx-books');
|
|
369
|
+
var tocEl = document.getElementById('idx-toc');
|
|
370
|
+
var activeEl = null;
|
|
371
|
+
var jobElements = {};
|
|
372
|
+
|
|
373
|
+
function renderBooks() {
|
|
374
|
+
listEl.innerHTML = '';
|
|
375
|
+
books.forEach(function(book) {
|
|
376
|
+
var el = createBookEl(book);
|
|
377
|
+
listEl.appendChild(el);
|
|
378
|
+
});
|
|
379
|
+
writeJobs.forEach(function(job) {
|
|
380
|
+
if (job.status !== 'done' && !books.find(function(b) { return b.slug === job.slug; })) {
|
|
381
|
+
var el = createJobEl(job);
|
|
382
|
+
jobElements[job.slug] = el;
|
|
383
|
+
listEl.appendChild(el);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
if (books.length === 1 && writeJobs.length === 0) listEl.children[0].click();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function createBookEl(book) {
|
|
390
|
+
var el = document.createElement('div');
|
|
391
|
+
el.className = 'idx-book';
|
|
392
|
+
el.dataset.slug = book.slug;
|
|
393
|
+
var badge = '';
|
|
394
|
+
var job = writeJobs.find(function(j) { return j.slug === book.slug && j.status === 'done'; });
|
|
395
|
+
if (job) badge = ' <span class="idx-badge idx-badge-done">New!</span>';
|
|
396
|
+
el.innerHTML = '<div class="idx-book-title">' + esc(book.title) + badge + '</div>' +
|
|
397
|
+
(book.author ? '<div class="idx-book-author">' + esc(book.author) + '</div>' : '') +
|
|
398
|
+
(book.updated_at ? '<div class="idx-book-updated">' + esc(book.updated_at) + '</div>' : '');
|
|
399
|
+
el.addEventListener('click', function() {
|
|
400
|
+
if (activeEl) activeEl.classList.remove('active');
|
|
401
|
+
el.classList.add('active');
|
|
402
|
+
activeEl = el;
|
|
403
|
+
showToc(book);
|
|
404
|
+
});
|
|
405
|
+
return el;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function createJobEl(job) {
|
|
409
|
+
var el = document.createElement('div');
|
|
410
|
+
el.className = 'idx-book';
|
|
411
|
+
el.dataset.slug = job.slug;
|
|
412
|
+
var badgeClass = job.status === 'writing' ? 'idx-badge-writing' : (job.status === 'error' ? 'idx-badge-error' : 'idx-badge-done');
|
|
413
|
+
var badgeText = job.status === 'writing' ? 'Writing\u2026' : (job.status === 'error' ? 'Error' : 'New!');
|
|
414
|
+
el.innerHTML = '<div class="idx-book-title">' + esc(job.title) + ' <span class="idx-badge ' + badgeClass + '">' + badgeText + '</span></div>';
|
|
415
|
+
if (job.status === 'error') {
|
|
416
|
+
el.addEventListener('click', function() {
|
|
417
|
+
if (activeEl) activeEl.classList.remove('active');
|
|
418
|
+
el.classList.add('active');
|
|
419
|
+
activeEl = el;
|
|
420
|
+
tocEl.innerHTML = '<div class="idx-form"><h2>' + esc(job.title) + '</h2><div class="idx-form-error">Error: ' + esc(job.error || 'Unknown error') + '</div></div>';
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
return el;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
renderBooks();
|
|
427
|
+
|
|
428
|
+
// Write form
|
|
429
|
+
document.getElementById('idx-write-btn').addEventListener('click', function() {
|
|
430
|
+
if (activeEl) activeEl.classList.remove('active');
|
|
431
|
+
activeEl = null;
|
|
432
|
+
showWriteForm();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
function showWriteForm() {
|
|
436
|
+
tocEl.innerHTML =
|
|
437
|
+
'<div class="idx-form">' +
|
|
438
|
+
'<h2>Write a new book</h2>' +
|
|
439
|
+
'<div class="idx-form-error" id="idx-form-err" style="display:none"></div>' +
|
|
440
|
+
'<label for="wf-dir">Directory *</label>' +
|
|
441
|
+
'<input id="wf-dir" placeholder="ruby-intro">' +
|
|
442
|
+
'<label for="wf-title">Title *</label>' +
|
|
443
|
+
'<input id="wf-title" placeholder="Ruby入門">' +
|
|
444
|
+
'<label for="wf-lang">Language</label>' +
|
|
445
|
+
'<input id="wf-lang" value="ja">' +
|
|
446
|
+
'<label for="wf-audience">Audience</label>' +
|
|
447
|
+
'<input id="wf-audience" placeholder="初心者">' +
|
|
448
|
+
'<label for="wf-notes">Notes</label>' +
|
|
449
|
+
'<textarea id="wf-notes" placeholder="5章くらいで"></textarea>' +
|
|
450
|
+
'<label>Reference Files</label>' +
|
|
451
|
+
'<div class="idx-dropzone" id="wf-dropzone">' +
|
|
452
|
+
'<input type="file" id="wf-files" multiple style="display:none">' +
|
|
453
|
+
'<span class="idx-dropzone-text">Drop files here or <a href="#" id="wf-browse">browse</a></span>' +
|
|
454
|
+
'<div class="idx-file-names" id="wf-file-names"></div>' +
|
|
455
|
+
'</div>' +
|
|
456
|
+
'<div class="idx-form-actions">' +
|
|
457
|
+
'<button class="idx-form-submit" id="wf-submit">Start Writing</button>' +
|
|
458
|
+
'<button class="idx-form-cancel" id="wf-cancel">Cancel</button>' +
|
|
459
|
+
'</div></div>';
|
|
460
|
+
|
|
461
|
+
document.getElementById('wf-cancel').addEventListener('click', function() {
|
|
462
|
+
tocEl.innerHTML = '<div class="idx-toc-empty">Select a book to view its table of contents</div>';
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
var wfPendingFiles = [];
|
|
466
|
+
var wfDropzone = document.getElementById('wf-dropzone');
|
|
467
|
+
var wfFileInput = document.getElementById('wf-files');
|
|
468
|
+
var wfFileNames = document.getElementById('wf-file-names');
|
|
469
|
+
|
|
470
|
+
document.getElementById('wf-browse').addEventListener('click', function(e) {
|
|
471
|
+
e.preventDefault();
|
|
472
|
+
wfFileInput.click();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
wfFileInput.addEventListener('change', function() {
|
|
476
|
+
addPendingFiles(wfFileInput.files, wfPendingFiles, wfFileNames);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
wfDropzone.addEventListener('dragover', function(e) {
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
wfDropzone.classList.add('idx-dropzone-active');
|
|
482
|
+
});
|
|
483
|
+
wfDropzone.addEventListener('dragleave', function() {
|
|
484
|
+
wfDropzone.classList.remove('idx-dropzone-active');
|
|
485
|
+
});
|
|
486
|
+
wfDropzone.addEventListener('drop', function(e) {
|
|
487
|
+
e.preventDefault();
|
|
488
|
+
wfDropzone.classList.remove('idx-dropzone-active');
|
|
489
|
+
addPendingFiles(e.dataTransfer.files, wfPendingFiles, wfFileNames);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
document.getElementById('wf-submit').addEventListener('click', function() {
|
|
493
|
+
var dir = document.getElementById('wf-dir').value.trim();
|
|
494
|
+
var title = document.getElementById('wf-title').value.trim();
|
|
495
|
+
var errEl = document.getElementById('idx-form-err');
|
|
496
|
+
errEl.style.display = 'none';
|
|
497
|
+
|
|
498
|
+
if (!dir || !title) {
|
|
499
|
+
errEl.textContent = 'Directory and Title are required.';
|
|
500
|
+
errEl.style.display = 'block';
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (/[\/\\]/.test(dir) || dir.startsWith('.')) {
|
|
504
|
+
errEl.textContent = 'Invalid directory name.';
|
|
505
|
+
errEl.style.display = 'block';
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
var btn = document.getElementById('wf-submit');
|
|
510
|
+
btn.disabled = true;
|
|
511
|
+
btn.textContent = 'Starting\u2026';
|
|
512
|
+
|
|
513
|
+
readFilesAsBase64(wfPendingFiles).then(function(filesData) {
|
|
514
|
+
var payload = {
|
|
515
|
+
directory: dir,
|
|
516
|
+
title: title,
|
|
517
|
+
language: document.getElementById('wf-lang').value.trim() || 'ja',
|
|
518
|
+
audience: document.getElementById('wf-audience').value.trim(),
|
|
519
|
+
notes: document.getElementById('wf-notes').value.trim()
|
|
520
|
+
};
|
|
521
|
+
if (filesData.length > 0) payload.files = filesData;
|
|
522
|
+
|
|
523
|
+
return fetch('/_ligarb/write', {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'Content-Type': 'application/json' },
|
|
526
|
+
body: JSON.stringify(payload)
|
|
527
|
+
});
|
|
528
|
+
}).then(function(r) { return r.json().then(function(d) { return { ok: r.ok, data: d }; }); })
|
|
529
|
+
.then(function(result) {
|
|
530
|
+
if (!result.ok) {
|
|
531
|
+
errEl.textContent = result.data.error || 'Request failed';
|
|
532
|
+
errEl.style.display = 'block';
|
|
533
|
+
btn.disabled = false;
|
|
534
|
+
btn.textContent = 'Start Writing';
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
var job = { slug: result.data.slug, title: result.data.title, status: 'writing' };
|
|
538
|
+
writeJobs.push(job);
|
|
539
|
+
var el = createJobEl(job);
|
|
540
|
+
jobElements[job.slug] = el;
|
|
541
|
+
listEl.appendChild(el);
|
|
542
|
+
tocEl.innerHTML = '<div class="idx-toc-empty">Writing "' + esc(title) + '"\u2026 This may take a few minutes.</div>';
|
|
543
|
+
})
|
|
544
|
+
.catch(function(e) {
|
|
545
|
+
errEl.textContent = 'Network error: ' + e.message;
|
|
546
|
+
errEl.style.display = 'block';
|
|
547
|
+
btn.disabled = false;
|
|
548
|
+
btn.textContent = 'Start Writing';
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function showToc(book) {
|
|
554
|
+
var h = '<div class="idx-toc-title"><a href="/' + book.slug + '/" target="_blank">' + esc(book.title) + ' →</a></div>';
|
|
555
|
+
book.toc.forEach(function(e) {
|
|
556
|
+
if (e.type === 'part') {
|
|
557
|
+
h += '<div class="idx-toc-part">' + esc(e.title) + '</div>';
|
|
558
|
+
} else if (e.type === 'appendix_header') {
|
|
559
|
+
h += '<div class="idx-toc-part">' + esc(e.label) + '</div>';
|
|
560
|
+
} else if (e.type === 'cover') {
|
|
561
|
+
h += '<a class="idx-toc-ch idx-toc-cover" href="/' + book.slug + '/#' + e.slug + '" target="_blank">' + esc(e.title) + '</a>';
|
|
562
|
+
} else {
|
|
563
|
+
h += '<a class="idx-toc-ch" href="/' + book.slug + '/#' + e.slug + '" target="_blank">' + esc(e.title) + '</a>';
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
tocEl.innerHTML = h;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function esc(s) {
|
|
570
|
+
var d = document.createElement('div');
|
|
571
|
+
d.textContent = s || '';
|
|
572
|
+
return d.innerHTML;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function addPendingFiles(fileList, pending, namesEl) {
|
|
576
|
+
for (var i = 0; i < fileList.length; i++) {
|
|
577
|
+
pending.push(fileList[i]);
|
|
578
|
+
}
|
|
579
|
+
renderFileNames(pending, namesEl);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function renderFileNames(pending, namesEl) {
|
|
583
|
+
namesEl.innerHTML = '';
|
|
584
|
+
pending.forEach(function(f, i) {
|
|
585
|
+
var span = document.createElement('span');
|
|
586
|
+
span.className = 'idx-file-tag';
|
|
587
|
+
span.innerHTML = esc(f.name) + ' <a href="#" data-idx="' + i + '">×</a>';
|
|
588
|
+
span.querySelector('a').addEventListener('click', function(e) {
|
|
589
|
+
e.preventDefault();
|
|
590
|
+
pending.splice(parseInt(this.dataset.idx), 1);
|
|
591
|
+
renderFileNames(pending, namesEl);
|
|
592
|
+
});
|
|
593
|
+
namesEl.appendChild(span);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function readFilesAsBase64(files) {
|
|
598
|
+
if (!files || files.length === 0) return Promise.resolve([]);
|
|
599
|
+
var promises = files.map(function(f) {
|
|
600
|
+
return new Promise(function(resolve) {
|
|
601
|
+
var reader = new FileReader();
|
|
602
|
+
reader.onload = function() {
|
|
603
|
+
var base64 = reader.result.split(',')[1] || '';
|
|
604
|
+
resolve({ name: f.name, data: base64 });
|
|
605
|
+
};
|
|
606
|
+
reader.readAsDataURL(f);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
return Promise.all(promises);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// SSE for write updates
|
|
613
|
+
var evtSource = new EventSource('/_ligarb/events');
|
|
614
|
+
evtSource.addEventListener('write_updated', function(e) {
|
|
615
|
+
var jobs = JSON.parse(e.data);
|
|
616
|
+
writeJobs = jobs;
|
|
617
|
+
|
|
618
|
+
// Reload page to get full book data when a job completes
|
|
619
|
+
var hasDone = jobs.find(function(j) { return j.status === 'done'; });
|
|
620
|
+
if (hasDone) {
|
|
621
|
+
// Fetch fresh books data to get TOC etc.
|
|
622
|
+
location.reload();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Update existing job elements
|
|
627
|
+
jobs.forEach(function(job) {
|
|
628
|
+
var existing = jobElements[job.slug];
|
|
629
|
+
if (existing) {
|
|
630
|
+
var badgeClass = job.status === 'writing' ? 'idx-badge-writing' : (job.status === 'error' ? 'idx-badge-error' : 'idx-badge-done');
|
|
631
|
+
var badgeText = job.status === 'writing' ? 'Writing\u2026' : (job.status === 'error' ? 'Error' : 'New!');
|
|
632
|
+
existing.querySelector('.idx-book-title').innerHTML = esc(job.title) + ' <span class="idx-badge ' + badgeClass + '">' + badgeText + '</span>';
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
</script>
|
|
637
|
+
</body>
|
|
638
|
+
</html>
|
|
639
|
+
HTML
|
|
640
|
+
|
|
641
|
+
# Escape </ to prevent script injection inside <script> tag
|
|
642
|
+
html = html.sub("__BOOKS_JSON__", books_json.gsub("</", '<\/'))
|
|
643
|
+
html = html.sub("__WRITE_JOBS_JSON__", write_jobs_json.gsub("</", '<\/'))
|
|
644
|
+
res.body = html
|
|
645
|
+
res["Content-Type"] = "text/html; charset=utf-8"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def build_toc(book)
|
|
649
|
+
toc = []
|
|
650
|
+
chapter_num = 0
|
|
651
|
+
appendix_idx = 0
|
|
652
|
+
|
|
653
|
+
book.config.structure.each do |entry|
|
|
654
|
+
case entry.type
|
|
655
|
+
when :cover
|
|
656
|
+
title = first_heading(entry.path) || "Cover"
|
|
657
|
+
slug = file_slug(entry.path)
|
|
658
|
+
toc << { type: "cover", title: title, slug: slug }
|
|
659
|
+
when :chapter
|
|
660
|
+
chapter_num += 1
|
|
661
|
+
title = first_heading(entry.path) || File.basename(entry.path, ".md")
|
|
662
|
+
slug = file_slug(entry.path)
|
|
663
|
+
prefix = book.config.chapter_numbers ? "#{chapter_num}. " : ""
|
|
664
|
+
toc << { type: "chapter", title: "#{prefix}#{title}", slug: slug }
|
|
665
|
+
when :part
|
|
666
|
+
part_title = first_heading(entry.path) || "Part"
|
|
667
|
+
toc << { type: "part", title: part_title }
|
|
668
|
+
(entry.children || []).each do |ch|
|
|
669
|
+
chapter_num += 1
|
|
670
|
+
title = first_heading(ch.path) || File.basename(ch.path, ".md")
|
|
671
|
+
slug = file_slug(ch.path)
|
|
672
|
+
prefix = book.config.chapter_numbers ? "#{chapter_num}. " : ""
|
|
673
|
+
toc << { type: "chapter", title: "#{prefix}#{title}", slug: slug }
|
|
674
|
+
end
|
|
675
|
+
when :appendix_group
|
|
676
|
+
toc << { type: "appendix_header", label: book.config.appendix_label }
|
|
677
|
+
(entry.children || []).each_with_index do |ch, i|
|
|
678
|
+
title = first_heading(ch.path) || File.basename(ch.path, ".md")
|
|
679
|
+
slug = file_slug(ch.path)
|
|
680
|
+
letter = ("A".ord + appendix_idx + i).chr
|
|
681
|
+
toc << { type: "chapter", title: "#{book.config.appendix_label} #{letter}. #{title}", slug: slug }
|
|
682
|
+
end
|
|
683
|
+
appendix_idx += (entry.children || []).size
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
toc
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def first_heading(path)
|
|
691
|
+
return nil unless path && File.exist?(path)
|
|
692
|
+
File.foreach(path) do |line|
|
|
693
|
+
return $1.strip if line =~ /\A#\s+(.+)/
|
|
694
|
+
end
|
|
695
|
+
nil
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def file_slug(path)
|
|
699
|
+
File.basename(path, ".md").gsub(/[^a-zA-Z0-9_-]/, "-")
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# ── API routing ──
|
|
703
|
+
|
|
704
|
+
def csrf_safe?(req)
|
|
705
|
+
return true if req.request_method == "GET"
|
|
706
|
+
|
|
707
|
+
origin = req["Origin"]
|
|
708
|
+
if origin && !origin.empty?
|
|
709
|
+
return origin == "http://localhost:#{@port}" ||
|
|
710
|
+
origin == "http://127.0.0.1:#{@port}"
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
referer = req["Referer"]
|
|
714
|
+
if referer && !referer.empty?
|
|
715
|
+
uri = URI.parse(referer) rescue nil
|
|
716
|
+
return uri && %w[localhost 127.0.0.1].include?(uri.host) && uri.port == @port
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
false
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def handle_api(req, res)
|
|
723
|
+
unless csrf_safe?(req)
|
|
724
|
+
res.status = 403
|
|
725
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
726
|
+
res.body = JSON.generate({ error: "CSRF check failed: request must originate from localhost" })
|
|
727
|
+
return
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
method = req.request_method
|
|
731
|
+
path = req.path.sub(%r{^/_ligarb}, "")
|
|
732
|
+
|
|
733
|
+
# Shared assets (both modes)
|
|
734
|
+
if method == "GET" && path =~ %r{^/assets/(.+)$}
|
|
735
|
+
serve_asset($1, res)
|
|
736
|
+
return
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Global write API (multi-book mode)
|
|
740
|
+
if @multi
|
|
741
|
+
if method == "GET" && path == "/events"
|
|
742
|
+
handle_sse(nil, req, res)
|
|
743
|
+
return
|
|
744
|
+
end
|
|
745
|
+
if method == "POST" && path == "/write"
|
|
746
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
747
|
+
begin
|
|
748
|
+
api_start_write(req, res)
|
|
749
|
+
rescue => e
|
|
750
|
+
res.status = 500
|
|
751
|
+
res.body = JSON.generate({ error: e.message })
|
|
752
|
+
end
|
|
753
|
+
return
|
|
754
|
+
end
|
|
755
|
+
if method == "GET" && path == "/write/status"
|
|
756
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
757
|
+
api_write_status(res)
|
|
758
|
+
return
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# Resolve book
|
|
763
|
+
if @multi
|
|
764
|
+
unless path =~ %r{^/([^/]+)(/.*)?$}
|
|
765
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
766
|
+
not_found(res)
|
|
767
|
+
return
|
|
768
|
+
end
|
|
769
|
+
book = @books[$1]
|
|
770
|
+
unless book
|
|
771
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
772
|
+
not_found(res)
|
|
773
|
+
return
|
|
774
|
+
end
|
|
775
|
+
api_path = $2 || "/"
|
|
776
|
+
else
|
|
777
|
+
book = @books.values.first
|
|
778
|
+
api_path = path
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# SSE endpoint
|
|
782
|
+
if method == "GET" && api_path == "/events"
|
|
783
|
+
handle_sse(book.slug, req, res)
|
|
784
|
+
return
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
res["Content-Type"] = "application/json; charset=utf-8"
|
|
788
|
+
|
|
789
|
+
begin
|
|
790
|
+
if method == "GET" && api_path == "/status"
|
|
791
|
+
api_status(book, res)
|
|
792
|
+
elsif method == "GET" && api_path == "/reviews"
|
|
793
|
+
api_list_reviews(book, res)
|
|
794
|
+
elsif method == "POST" && api_path == "/reviews"
|
|
795
|
+
api_create_review(book, req, res)
|
|
796
|
+
elsif method == "GET" && api_path =~ %r{^/reviews/([0-9a-f-]+)$}
|
|
797
|
+
api_get_review(book, $1, res)
|
|
798
|
+
elsif method == "POST" && api_path =~ %r{^/reviews/([0-9a-f-]+)/messages$}
|
|
799
|
+
api_add_message(book, $1, req, res)
|
|
800
|
+
elsif method == "POST" && api_path =~ %r{^/reviews/([0-9a-f-]+)/approve$}
|
|
801
|
+
api_approve(book, $1, res)
|
|
802
|
+
elsif method == "POST" && api_path =~ %r{^/reviews/([0-9a-f-]+)/close$}
|
|
803
|
+
api_delete_review(book, $1, res)
|
|
804
|
+
else
|
|
805
|
+
not_found(res)
|
|
806
|
+
end
|
|
807
|
+
rescue => e
|
|
808
|
+
res.status = 500
|
|
809
|
+
res.body = JSON.generate({ error: e.message })
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# ── API handlers ──
|
|
814
|
+
|
|
815
|
+
def api_status(book, res)
|
|
816
|
+
html_path = File.join(book.build_dir, "index.html")
|
|
817
|
+
mtime = File.exist?(html_path) ? File.mtime(html_path).to_i : 0
|
|
818
|
+
res.body = JSON.generate({ mtime: mtime })
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def api_list_reviews(book, res)
|
|
822
|
+
res.body = JSON.generate(book.store.list)
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
def api_get_review(book, id, res)
|
|
826
|
+
review = book.store.get(id)
|
|
827
|
+
if review
|
|
828
|
+
res.body = JSON.generate(review)
|
|
829
|
+
else
|
|
830
|
+
not_found(res)
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def api_create_review(book, req, res)
|
|
835
|
+
body = parse_body(req)
|
|
836
|
+
|
|
837
|
+
context = body["context"] || {}
|
|
838
|
+
message = body["message"]
|
|
839
|
+
|
|
840
|
+
unless message && !message.strip.empty?
|
|
841
|
+
res.status = 400
|
|
842
|
+
res.body = JSON.generate({ error: "message is required" })
|
|
843
|
+
return
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
context["source_file"] = resolve_source_file(book.config, context["chapter_slug"])
|
|
847
|
+
|
|
848
|
+
saved = save_uploaded_files(body["files"], book.config.base_dir)
|
|
849
|
+
context["uploaded_files"] = saved unless saved.empty?
|
|
850
|
+
|
|
851
|
+
review = book.store.create(context: context, message: message)
|
|
852
|
+
start_claude_review(book, review["id"])
|
|
853
|
+
|
|
854
|
+
res.status = 201
|
|
855
|
+
res.body = JSON.generate(review)
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def api_add_message(book, id, req, res)
|
|
859
|
+
review = book.store.get(id)
|
|
860
|
+
unless review
|
|
861
|
+
not_found(res)
|
|
862
|
+
return
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
body = parse_body(req)
|
|
866
|
+
message = body["message"]
|
|
867
|
+
|
|
868
|
+
unless message && !message.strip.empty?
|
|
869
|
+
res.status = 400
|
|
870
|
+
res.body = JSON.generate({ error: "message is required" })
|
|
871
|
+
return
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
saved = save_uploaded_files(body["files"], book.config.base_dir)
|
|
875
|
+
book.store.update_context_files(id, saved) unless saved.empty?
|
|
876
|
+
|
|
877
|
+
book.store.add_message(id, role: "user", content: message)
|
|
878
|
+
start_claude_review(book, id)
|
|
879
|
+
|
|
880
|
+
review = book.store.get(id)
|
|
881
|
+
res.body = JSON.generate(review)
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def api_approve(book, id, res)
|
|
885
|
+
review = book.store.get(id)
|
|
886
|
+
unless review
|
|
887
|
+
not_found(res)
|
|
888
|
+
return
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
log "Approve: applying patches for review #{id}"
|
|
892
|
+
|
|
893
|
+
result = enqueue_sync { book.claude.apply_patches(review) }
|
|
894
|
+
|
|
895
|
+
if result["error"] && result["error"].include?("rebuild failed")
|
|
896
|
+
# Build failed — feed the error back to Claude for a retry
|
|
897
|
+
log "Approve: build failed, requesting fix from Claude"
|
|
898
|
+
error_msg = result["error"]
|
|
899
|
+
book.store.add_message(id, role: "assistant", content: "Error: #{error_msg}")
|
|
900
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
901
|
+
|
|
902
|
+
retry_msg = "The patches were applied but the build failed with this error:\n\n" \
|
|
903
|
+
"#{error_msg}\n\n" \
|
|
904
|
+
"Please provide corrected patches that fix this issue."
|
|
905
|
+
book.store.add_message(id, role: "user", content: retry_msg)
|
|
906
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
907
|
+
|
|
908
|
+
# Re-run Claude with the updated conversation
|
|
909
|
+
review = book.store.get(id)
|
|
910
|
+
prompt = book.claude.review_prompt(review)
|
|
911
|
+
claude_result = book.claude.run(prompt)
|
|
912
|
+
|
|
913
|
+
if claude_result["error"]
|
|
914
|
+
log "Approve: retry Claude error: #{claude_result["error"]}"
|
|
915
|
+
book.store.add_message(id, role: "assistant", content: "Error: #{claude_result["error"]}")
|
|
916
|
+
book.store.update_status(id, "open")
|
|
917
|
+
else
|
|
918
|
+
log "Approve: Claude provided fix, retrying patches"
|
|
919
|
+
book.store.add_message(id, role: "assistant", content: claude_result["text"])
|
|
920
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
921
|
+
|
|
922
|
+
# Retry applying patches with the new response
|
|
923
|
+
review = book.store.get(id)
|
|
924
|
+
retry_result = enqueue_sync { book.claude.apply_patches(review) }
|
|
925
|
+
|
|
926
|
+
if retry_result["error"]
|
|
927
|
+
log "Approve: retry failed: #{retry_result["error"]}"
|
|
928
|
+
book.store.add_message(id, role: "assistant", content: "Retry failed: #{retry_result["error"]}")
|
|
929
|
+
book.store.update_status(id, "open")
|
|
930
|
+
else
|
|
931
|
+
log "Approve: retry succeeded: #{retry_result["text"]}"
|
|
932
|
+
book.store.add_message(id, role: "assistant", content: retry_result["text"])
|
|
933
|
+
book.store.update_status(id, "applied")
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
elsif result["error"]
|
|
937
|
+
log "Approve: error: #{result["error"]}"
|
|
938
|
+
book.store.add_message(id, role: "assistant", content: "Error: #{result["error"]}")
|
|
939
|
+
book.store.update_status(id, "open")
|
|
940
|
+
else
|
|
941
|
+
log "Approve: #{result["text"]}"
|
|
942
|
+
book.store.add_message(id, role: "assistant", content: result["text"])
|
|
943
|
+
book.store.update_status(id, "applied")
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
947
|
+
review = book.store.get(id)
|
|
948
|
+
res.body = JSON.generate(review)
|
|
949
|
+
end
|
|
950
|
+
|
|
951
|
+
def api_delete_review(book, id, res)
|
|
952
|
+
review = book.store.get(id)
|
|
953
|
+
unless review
|
|
954
|
+
not_found(res)
|
|
955
|
+
return
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
book.store.update_status(id, "closed")
|
|
959
|
+
sse_broadcast("review_updated", { id: id }, slug: book.slug)
|
|
960
|
+
|
|
961
|
+
review = book.store.get(id)
|
|
962
|
+
res.body = JSON.generate(review)
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def serve_asset(filename, res)
|
|
966
|
+
unless INJECTED_ASSETS.include?(filename)
|
|
967
|
+
not_found(res)
|
|
968
|
+
return
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
path = File.join(@assets_dir, filename)
|
|
972
|
+
unless File.exist?(path)
|
|
973
|
+
not_found(res)
|
|
974
|
+
return
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
res.body = File.read(path)
|
|
978
|
+
res["Content-Type"] = mime_type(path)
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def start_claude_review(book, review_id)
|
|
982
|
+
Thread.new do
|
|
983
|
+
log "Review: starting Claude review for #{review_id}"
|
|
984
|
+
begin
|
|
985
|
+
review = book.store.get(review_id)
|
|
986
|
+
next unless review
|
|
987
|
+
|
|
988
|
+
case book.claude.installed?
|
|
989
|
+
when :not_found
|
|
990
|
+
book.store.add_message(review_id, role: "assistant",
|
|
991
|
+
content: "Error: 'claude' command not found. Install Claude Code to enable AI reviews.")
|
|
992
|
+
next
|
|
993
|
+
when :version_failed
|
|
994
|
+
book.store.add_message(review_id, role: "assistant",
|
|
995
|
+
content: "Error: 'claude' command was found but failed to run. Check your Claude Code installation.")
|
|
996
|
+
next
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
prompt = book.claude.review_prompt(review)
|
|
1000
|
+
result = book.claude.run(prompt)
|
|
1001
|
+
|
|
1002
|
+
if result["error"]
|
|
1003
|
+
log "Review: Claude error: #{result["error"]}"
|
|
1004
|
+
book.store.add_message(review_id, role: "assistant", content: "Error: #{result["error"]}")
|
|
1005
|
+
else
|
|
1006
|
+
log "Review: Claude responded"
|
|
1007
|
+
book.store.add_message(review_id, role: "assistant", content: result["text"])
|
|
1008
|
+
end
|
|
1009
|
+
rescue => e
|
|
1010
|
+
log "Review: exception: #{e.message}"
|
|
1011
|
+
book.store.add_message(review_id, role: "assistant", content: "Error: #{e.message}")
|
|
1012
|
+
ensure
|
|
1013
|
+
sse_broadcast("review_updated", { id: review_id }, slug: book.slug)
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# ── Write API ──
|
|
1019
|
+
|
|
1020
|
+
def api_start_write(req, res)
|
|
1021
|
+
body = parse_body(req)
|
|
1022
|
+
directory = body["directory"].to_s.strip
|
|
1023
|
+
title = body["title"].to_s.strip
|
|
1024
|
+
|
|
1025
|
+
if directory.empty? || title.empty?
|
|
1026
|
+
res.status = 400
|
|
1027
|
+
res.body = JSON.generate({ error: "directory and title are required" })
|
|
1028
|
+
return
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
if directory.include?("/") || directory.include?("\\") || directory.start_with?(".")
|
|
1032
|
+
res.status = 400
|
|
1033
|
+
res.body = JSON.generate({ error: "invalid directory name" })
|
|
1034
|
+
return
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
if @books.key?(directory)
|
|
1038
|
+
res.status = 409
|
|
1039
|
+
res.body = JSON.generate({ error: "a book with slug '#{directory}' already exists" })
|
|
1040
|
+
return
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
target_dir = File.join(Dir.pwd, directory)
|
|
1044
|
+
if Dir.exist?(target_dir)
|
|
1045
|
+
res.status = 409
|
|
1046
|
+
res.body = JSON.generate({ error: "directory '#{directory}' already exists" })
|
|
1047
|
+
return
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
@write_mutex.synchronize do
|
|
1051
|
+
if @write_jobs.key?(directory)
|
|
1052
|
+
res.status = 409
|
|
1053
|
+
res.body = JSON.generate({ error: "a write job for '#{directory}' is already running" })
|
|
1054
|
+
return
|
|
1055
|
+
end
|
|
1056
|
+
@write_jobs[directory] = { title: title, status: "writing", error: nil }
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
# Write brief.yml
|
|
1060
|
+
FileUtils.mkdir_p(target_dir)
|
|
1061
|
+
brief_path = File.join(target_dir, "brief.yml")
|
|
1062
|
+
brief_data = { "title" => title, "language" => body["language"] || "ja" }
|
|
1063
|
+
brief_data["audience"] = body["audience"] if body["audience"] && !body["audience"].to_s.strip.empty?
|
|
1064
|
+
brief_data["notes"] = body["notes"] if body["notes"] && !body["notes"].to_s.strip.empty?
|
|
1065
|
+
|
|
1066
|
+
saved = save_uploaded_files(body["files"], target_dir)
|
|
1067
|
+
if saved.any?
|
|
1068
|
+
brief_data["sources"] = saved.map { |f| { "path" => f["path"], "label" => f["label"] } }
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
File.write(brief_path, YAML.dump(brief_data))
|
|
1072
|
+
|
|
1073
|
+
sse_broadcast("write_updated", write_jobs_data, slug: nil)
|
|
1074
|
+
|
|
1075
|
+
# Run write job via operation queue (async)
|
|
1076
|
+
enqueue_async do
|
|
1077
|
+
begin
|
|
1078
|
+
log "Write: starting for '#{directory}' (title: #{title})"
|
|
1079
|
+
require_relative "writer"
|
|
1080
|
+
writer = Writer.new(brief_path, no_build: true)
|
|
1081
|
+
writer.run
|
|
1082
|
+
|
|
1083
|
+
book_yml_path = File.join(target_dir, "book.yml")
|
|
1084
|
+
require_relative "builder"
|
|
1085
|
+
Builder.new(book_yml_path).build
|
|
1086
|
+
|
|
1087
|
+
# Git init + commit for the generated book
|
|
1088
|
+
unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL, chdir: target_dir)
|
|
1089
|
+
gitignore = File.join(target_dir, ".gitignore")
|
|
1090
|
+
File.write(gitignore, "# Generated by ligarb - remove lines as needed\nbuild/\n.ligarb/\n") unless File.exist?(gitignore)
|
|
1091
|
+
system("git", "init", chdir: target_dir, out: File::NULL, err: File::NULL)
|
|
1092
|
+
end
|
|
1093
|
+
system("git", "add", "-A", chdir: target_dir, out: File::NULL, err: File::NULL)
|
|
1094
|
+
system("git", "commit", "-m", "[ligarb] Generate book: #{title}",
|
|
1095
|
+
chdir: target_dir, out: File::NULL, err: File::NULL)
|
|
1096
|
+
|
|
1097
|
+
# Register the new book
|
|
1098
|
+
config = Config.new(book_yml_path)
|
|
1099
|
+
book = BookEntry.new(
|
|
1100
|
+
slug: directory,
|
|
1101
|
+
config: config,
|
|
1102
|
+
config_path: File.expand_path(book_yml_path),
|
|
1103
|
+
build_dir: config.output_path,
|
|
1104
|
+
store: ReviewStore.new(config.base_dir),
|
|
1105
|
+
claude: ClaudeRunner.new(config)
|
|
1106
|
+
)
|
|
1107
|
+
@books[directory] = book
|
|
1108
|
+
start_build_watcher(book)
|
|
1109
|
+
|
|
1110
|
+
@write_mutex.synchronize { @write_jobs[directory][:status] = "done" }
|
|
1111
|
+
log "Write: completed for '#{directory}'"
|
|
1112
|
+
rescue => e
|
|
1113
|
+
@write_mutex.synchronize do
|
|
1114
|
+
@write_jobs[directory][:status] = "error"
|
|
1115
|
+
@write_jobs[directory][:error] = e.message
|
|
1116
|
+
end
|
|
1117
|
+
log "Write: error for '#{directory}': #{e.message}"
|
|
1118
|
+
ensure
|
|
1119
|
+
sse_broadcast("write_updated", write_jobs_data, slug: nil)
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
res.status = 201
|
|
1124
|
+
res.body = JSON.generate({ slug: directory, title: title, status: "writing" })
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
def api_write_status(res)
|
|
1128
|
+
res.body = JSON.generate(write_jobs_data)
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
def write_jobs_data
|
|
1132
|
+
@write_mutex.synchronize do
|
|
1133
|
+
@write_jobs.map { |slug, job| { slug: slug, title: job[:title], status: job[:status], error: job[:error] } }
|
|
1134
|
+
end
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
# ── Helpers ──
|
|
1138
|
+
|
|
1139
|
+
MAX_UPLOAD_FILE_SIZE = 10 * 1024 * 1024 # 10MB per file
|
|
1140
|
+
|
|
1141
|
+
def save_uploaded_files(files_array, base_dir)
|
|
1142
|
+
return [] unless files_array.is_a?(Array) && !files_array.empty?
|
|
1143
|
+
|
|
1144
|
+
upload_dir = File.join(base_dir, ".ligarb", "uploads")
|
|
1145
|
+
FileUtils.mkdir_p(upload_dir)
|
|
1146
|
+
|
|
1147
|
+
files_array.filter_map do |file|
|
|
1148
|
+
name = file["name"].to_s.strip
|
|
1149
|
+
data = file["data"].to_s
|
|
1150
|
+
next if name.empty? || data.empty?
|
|
1151
|
+
|
|
1152
|
+
decoded = Base64.decode64(data)
|
|
1153
|
+
if decoded.bytesize > MAX_UPLOAD_FILE_SIZE
|
|
1154
|
+
log "Upload rejected: #{name} exceeds #{MAX_UPLOAD_FILE_SIZE} bytes"
|
|
1155
|
+
next
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
# Sanitize filename
|
|
1159
|
+
safe_name = File.basename(name).gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
1160
|
+
short_id = SecureRandom.hex(4)
|
|
1161
|
+
filename = "#{short_id}-#{safe_name}"
|
|
1162
|
+
dest = File.join(upload_dir, filename)
|
|
1163
|
+
|
|
1164
|
+
File.binwrite(dest, decoded)
|
|
1165
|
+
{ "path" => File.expand_path(dest), "label" => name }
|
|
1166
|
+
end
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
def resolve_source_file(config, chapter_slug)
|
|
1170
|
+
return nil unless chapter_slug
|
|
1171
|
+
|
|
1172
|
+
config.all_file_paths.find { |path|
|
|
1173
|
+
slug = File.basename(path, ".md").gsub(/[^a-zA-Z0-9_-]/, "-")
|
|
1174
|
+
slug == chapter_slug
|
|
1175
|
+
}
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
def parse_body(req)
|
|
1179
|
+
JSON.parse(req.body || "{}")
|
|
1180
|
+
rescue JSON::ParserError
|
|
1181
|
+
{}
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
def not_found(res)
|
|
1185
|
+
res.status = 404
|
|
1186
|
+
res.body = JSON.generate({ error: "not found" })
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
def log(msg)
|
|
1190
|
+
$stderr.puts "[ligarb #{Time.now.strftime('%H:%M:%S')}] #{msg}"
|
|
1191
|
+
end
|
|
1192
|
+
|
|
1193
|
+
def escape_html(str)
|
|
1194
|
+
str.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1197
|
+
def escape_js_string(str)
|
|
1198
|
+
str.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'").gsub('"', '\\\\"')
|
|
1199
|
+
.gsub("\n", "\\n").gsub("\r", "\\r").gsub("</", '<\\/')
|
|
1200
|
+
end
|
|
1201
|
+
|
|
1202
|
+
def mime_type(path)
|
|
1203
|
+
case File.extname(path).downcase
|
|
1204
|
+
when ".html" then "text/html; charset=utf-8"
|
|
1205
|
+
when ".css" then "text/css; charset=utf-8"
|
|
1206
|
+
when ".js" then "application/javascript; charset=utf-8"
|
|
1207
|
+
when ".json" then "application/json; charset=utf-8"
|
|
1208
|
+
when ".png" then "image/png"
|
|
1209
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
1210
|
+
when ".gif" then "image/gif"
|
|
1211
|
+
when ".svg" then "image/svg+xml"
|
|
1212
|
+
when ".woff" then "font/woff"
|
|
1213
|
+
when ".woff2" then "font/woff2"
|
|
1214
|
+
else "application/octet-stream"
|
|
1215
|
+
end
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
end
|