chocomint 1.0.2 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1e486a61190ec1d3548de09e65d74ea64d42a664c3d536c0e8013f61e6e2c1c
4
- data.tar.gz: b7e23eedd909b6d2dffaeaa341721e7ccbd3bc91a2556a87d2654991c9475443
3
+ metadata.gz: 691a0248c4e919afa5640c7398b3f1b06221c397ac2c1e4c73fb56c044174246
4
+ data.tar.gz: 5371ad706155faff73120e8064f6629b7a776099e7c66fc5f07a89bfec932799
5
5
  SHA512:
6
- metadata.gz: 5234597d4d58068f04609037aaf53d058b48a0ddfea9a12240cc64c84ace0371b5277aee1442dcea8e1b8239449a7da0e32f976aca1672f4da3b5dfa90ee6645
7
- data.tar.gz: e42a3f878d613ce09722db0929d745d3aaf3fdda93698eeb691203834063a9bf3090d10bf5aca20f7797391b76305885fcf89c63e6c03c67ae58580520a46704
6
+ metadata.gz: 75558e5f77a42aaaddaa2ffdfc5a509975cf7660ae5eaf346d9e5e24d45ce3873783f23e924b5516a6341db2f405e3ac1bd3688bf7677cc9fe0ec0074a5a352c
7
+ data.tar.gz: 0b43f17a5b4f916798db9107c3cef61d21f07da1299723a3feb1e31de43231f521bfc8e4bdfc560ff6dd1b65f9546819b61fb79e9263c90ef89796984cac48c9
data/README.md CHANGED
@@ -191,11 +191,37 @@ ruby bin/chocomint-server
191
191
  |----------------|------|
192
192
  | `GET /edit` | エディタ画面 |
193
193
  | `GET /edit/files` | `workspace/` のファイル一覧 (JSON) |
194
- | `GET /edit/file?path=` | ファイル内容 (JSON) |
194
+ | `GET /edit/file?path=` | ファイル内容 (JSON)。画像/PDF/動画/音声は `media` 種別だけ返す |
195
+ | `GET /edit/raw?path=` | 生ファイル配信(メディアプレビュー用。`Range` 対応で動画のシークも可) |
196
+ | `GET /edit/archive?path=` | アーカイブの第一階層エントリ + メタデータ(zip / tar / tar.gz / bz2 等) |
195
197
  | `POST /edit/save` | 人手編集の保存(`PathGuard` で `workspace/` 内に限定) |
196
198
  | `POST /edit/chat` | 自然言語指示で AI 編集(`Planner#run`。`expectations` に指示を渡すため意味的検証が効く) |
197
199
  | `GET /edit/assets/*` | Monaco / xterm.js のローカル同梱アセット |
198
200
 
201
+ ### メディアプレビュー
202
+
203
+ テキストファイルは Monaco で編集し、以下のメディアはタブ内でプレビューする
204
+ (`workspace/` 配下のみ・`PathGuard` で保護):
205
+
206
+ - **画像**: PNG / JPEG / GIF / WebP / BMP / ICO / AVIF / TIFF / SVG(ベクター)
207
+ - **文書**: PDF(ブラウザ内蔵ビューアで表示)
208
+ - **動画**: MP4 / WebM / MKV / MOV / AVI / OGV(`<video>`。ブラウザが対応する形式のみ再生可)
209
+ - **音声**: MP3 / WAV / OGG / M4A / AAC / FLAC / Opus(`<audio>`)
210
+
211
+ 動画・音声は `Range` リクエストに対応し、シークやストリーミング再生ができる。
212
+ ブラウザがコーデック非対応の形式(MKV の一部など)は再生できずメッセージを表示する。
213
+
214
+ ### アーカイブプレビュー
215
+
216
+ zip / tar 系アーカイブをクリックすると、**第一階層(トップレベル)のエントリと
217
+ メタデータ**(エントリ数・展開後サイズ・圧縮サイズ)をタブ内に表示する。展開は
218
+ サーバー側でメモリ上に行い、ディスクには書き出さない。
219
+
220
+ - **対応**: `.zip` / `.tar` / `.tar.gz`(`.tgz`)/ `.gz` / `.tar.bz2`(`.tbz2`)/ `.bz2`
221
+ - 配下のエントリは親ディレクトリに畳み、その配下件数を添えて表示する。
222
+ - `.zip` は中央ディレクトリを自前パースするため rubyzip 非依存。`.bz2` は純Ruby
223
+ 実装の `rbzip2` で展開する(ネイティブ `libbz2` 不要)。
224
+
199
225
  ### 追加依存
200
226
 
201
227
  - `ffi` — Windows の擬似コンソール (ConPTY) を FFI で叩く(プリビルド gem、DevKit 不要)。
@@ -41,5 +41,6 @@ AI エディタ (`/edit`) をオフラインで動作させるため、以下を
41
41
  | webrick | Ruby / BSD-2-Clause | https://github.com/ruby/webrick |
42
42
  | ffi | BSD-3-Clause | https://github.com/ffi/ffi |
43
43
  | websocket-driver | Apache-2.0 | https://github.com/faye/websocket-driver-ruby |
44
+ | rbzip2 | BSD-3-Clause | https://github.com/koraktor/rbzip2 |
44
45
 
45
46
  上記はいずれも再配布・商用利用を許容する寛容なライセンスである。
@@ -0,0 +1,323 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "zlib"
5
+ require "stringio"
6
+ require "rubygems/package"
7
+
8
+ module Chocomint
9
+ # /edit AI エディタのアーカイブプレビュー用ハンドラ (Server に include して使う)。
10
+ #
11
+ # 提供機能:
12
+ # GET /edit/archive?path=foo.zip アーカイブの第一階層エントリ + メタデータ (JSON)
13
+ #
14
+ # 対応形式: .zip / .tar / .tar.gz (.tgz) / .gz / .tar.bz2 (.tbz2) / .bz2
15
+ # いずれも展開はメモリ上で行い、ディスクには書き出さない。tar/zip はエントリを
16
+ # ストリーム走査し、第一階層 (トップレベル) だけに畳んで返す。
17
+ #
18
+ # 対応形式は拡張子でしか判定しない (中身の magic は見ない)。ファイルアクセスは
19
+ # すべて @path_guard 経由で workspace 内に制限する。
20
+ module EditArchiveHandlers
21
+ # 一覧をブラウザで扱える範囲に抑えるためのエントリ数上限 (走査自体の安全弁)。
22
+ # tar は全走査するため、巨大アーカイブでも到達したら打ち切って capped を立てる。
23
+ MAX_ARCHIVE_ENTRIES = 20_000
24
+
25
+ # bzip2 展開に許す入力サイズ上限 (純Ruby 実装は重いので極端に巨大な .bz2 は弾く)。
26
+ MAX_BZIP2_INPUT_BYTES = 64 * 1024 * 1024
27
+
28
+ def handle_edit_archive(req, res)
29
+ return edit_method_guard(res) unless req.request_method == "GET"
30
+
31
+ path = req.query["path"].to_s
32
+ return edit_json(res, 400, "error" => "path required") if path.empty?
33
+
34
+ abs = @path_guard.resolve(path)
35
+ return edit_json(res, 404, "error" => "no such file") unless File.file?(abs)
36
+
37
+ kind = archive_kind(abs)
38
+ return edit_json(res, 415, "error" => "unsupported archive format") unless kind
39
+
40
+ info = inspect_archive(abs, kind)
41
+ edit_json(res, 200, { "path" => path }.merge(info))
42
+ rescue Chocomint::PathAccessError => e
43
+ edit_json(res, 403, "error" => e.message)
44
+ rescue ArchiveError => e
45
+ # 壊れている/未対応の内部構造など。UI 向けにメッセージを返す (500 にはしない)。
46
+ edit_json(res, 422, "error" => e.message)
47
+ rescue Chocomint::Error => e
48
+ edit_json(res, 500, "error" => e.message)
49
+ end
50
+
51
+ # アーカイブ処理中の想定内エラー (壊れたデータ・展開失敗など)。
52
+ class ArchiveError < StandardError; end
53
+
54
+ # ---- 形式判定 -----------------------------------------------------------
55
+
56
+ # 拡張子からアーカイブ形式を判定して種別文字列を返す (非対応は nil)。
57
+ # 種別: "zip" / "tar" / "tar.gz" / "tar.bz2" / "gz" / "bz2"
58
+ # 二重拡張子 (.tar.gz 等) を単一拡張子 (.gz) より優先する。
59
+ def archive_kind(abs)
60
+ lower = File.basename(abs).downcase
61
+ return "tar.gz" if lower.end_with?(".tar.gz")
62
+ return "tar.gz" if lower.end_with?(".tgz")
63
+ return "tar.bz2" if lower.end_with?(".tar.bz2")
64
+ return "tar.bz2" if lower.end_with?(".tbz2") || lower.end_with?(".tbz")
65
+ return "zip" if lower.end_with?(".zip")
66
+ return "tar" if lower.end_with?(".tar")
67
+ return "gz" if lower.end_with?(".gz")
68
+ return "bz2" if lower.end_with?(".bz2")
69
+
70
+ nil
71
+ end
72
+
73
+ # フロントがメディア/アーカイブを区別できるよう、handle_edit_file からも使う判定。
74
+ def archive?(abs)
75
+ !archive_kind(abs).nil?
76
+ end
77
+
78
+ # ---- 検査 (種別ごとにディスパッチ) --------------------------------------
79
+
80
+ def inspect_archive(abs, kind)
81
+ case kind
82
+ when "zip" then inspect_zip(abs)
83
+ when "tar" then inspect_tar(File.binread(abs))
84
+ when "tar.gz" then inspect_tar(gunzip(File.binread(abs)))
85
+ when "tar.bz2" then inspect_tar(bunzip2(abs))
86
+ when "gz" then inspect_single_compressed(abs, "gzip", strip_ext: ".gz")
87
+ when "bz2" then inspect_single_compressed(abs, "bzip2", strip_ext: ".bz2")
88
+ else
89
+ raise ArchiveError, "unsupported archive format"
90
+ end
91
+ end
92
+
93
+ # ---- tar の走査 ---------------------------------------------------------
94
+ #
95
+ # 展開済み tar バイト列を走査し、第一階層に畳んだ entries とメタデータを返す。
96
+ def inspect_tar(tar_bytes)
97
+ dirs = {} # トップレベルのディレクトリ名 => 配下エントリ数
98
+ files = [] # トップレベルの直下ファイル { name, size }
99
+ total = 0
100
+ total_size = 0
101
+ capped = false
102
+
103
+ Gem::Package::TarReader.new(StringIO.new(tar_bytes)) do |reader|
104
+ reader.each do |entry|
105
+ name = entry.full_name.to_s
106
+ next if name.empty? || name == "./"
107
+
108
+ total += 1
109
+ if total > MAX_ARCHIVE_ENTRIES
110
+ capped = true
111
+ break
112
+ end
113
+
114
+ rel = normalize_entry_name(name)
115
+ next if rel.empty?
116
+
117
+ size = entry.header.size.to_i
118
+ total_size += size unless entry.directory?
119
+ accumulate_top_level(rel, entry.directory?, size, dirs, files)
120
+ end
121
+ end
122
+
123
+ build_result(dirs, files, total, total_size, capped)
124
+ rescue Gem::Package::TarInvalidError, Zlib::GzipFile::Error => e
125
+ raise ArchiveError, "tar を読み取れませんでした: #{e.message}"
126
+ end
127
+
128
+ # ---- zip の走査 (中央ディレクトリを自前パース) --------------------------
129
+ #
130
+ # 標準ライブラリだけで済ませるため rubyzip には依存せず、ZIP の End Of Central
131
+ # Directory (EOCD) → Central Directory を辿ってエントリ名とサイズを読む。
132
+ # 実データ (ローカルファイルヘッダ) は読まないので大きな zip でも軽い。
133
+ def inspect_zip(abs)
134
+ raw = File.binread(abs)
135
+ cd = zip_central_directory(raw)
136
+
137
+ dirs = {}
138
+ files = []
139
+ total = 0
140
+ total_size = 0
141
+ capped = false
142
+
143
+ cd.each do |ent|
144
+ total += 1
145
+ if total > MAX_ARCHIVE_ENTRIES
146
+ capped = true
147
+ break
148
+ end
149
+ rel = normalize_entry_name(ent[:name])
150
+ next if rel.empty?
151
+
152
+ total_size += ent[:size] unless ent[:dir]
153
+ accumulate_top_level(rel, ent[:dir], ent[:size], dirs, files)
154
+ end
155
+
156
+ build_result(dirs, files, total, total_size, capped)
157
+ end
158
+
159
+ # ZIP の中央ディレクトリを読み、各エントリの { name, size, dir } を返す。
160
+ def zip_central_directory(raw)
161
+ eocd = find_eocd(raw)
162
+ raise ArchiveError, "zip の終端レコードが見つかりません" unless eocd
163
+
164
+ count = raw[eocd + 10, 2].unpack1("v")
165
+ offset = raw[eocd + 16, 4].unpack1("V")
166
+ entries = []
167
+ pos = offset
168
+
169
+ count.times do
170
+ break if pos + 46 > raw.bytesize
171
+ sig = raw[pos, 4]
172
+ break unless sig == "PK\x01\x02".b # Central Directory File Header
173
+
174
+ usize = raw[pos + 24, 4].unpack1("V") # uncompressed size
175
+ name_len = raw[pos + 28, 2].unpack1("v")
176
+ extra_len = raw[pos + 30, 2].unpack1("v")
177
+ comment_len = raw[pos + 32, 2].unpack1("v")
178
+ name = raw[pos + 46, name_len].to_s
179
+ name = zip_decode_name(name)
180
+ entries << { name: name, size: usize, dir: name.end_with?("/") }
181
+ pos += 46 + name_len + extra_len + comment_len
182
+ end
183
+
184
+ entries
185
+ end
186
+
187
+ # EOCD (End Of Central Directory, シグネチャ PK\x05\x06) を末尾から探す。
188
+ # ZIP コメントは最大 65535 バイトなので、末尾 (22 + 65535) だけ後方走査すれば十分。
189
+ def find_eocd(raw)
190
+ sig = "PK\x05\x06".b
191
+ min = [raw.bytesize - (22 + 0xFFFF), 0].max
192
+ i = raw.bytesize - 22
193
+ while i >= min
194
+ return i if raw[i, 4] == sig
195
+
196
+ i -= 1
197
+ end
198
+ nil
199
+ end
200
+
201
+ # zip のエントリ名を文字列に整える。UTF-8 として妥当ならそれ、駄目なら置換して壊さない。
202
+ def zip_decode_name(name)
203
+ s = name.dup.force_encoding("UTF-8")
204
+ s.valid_encoding? ? s : s.scrub("?")
205
+ end
206
+
207
+ # ---- 単体圧縮ファイル (.gz / .bz2、tar でない) --------------------------
208
+ #
209
+ # tar を含まない単なる圧縮ファイルは、展開後の 1 ファイルとして扱う。
210
+ # 中身の全展開は避け、メタデータ (圧縮/展開サイズ・推定名) だけを返す。
211
+ def inspect_single_compressed(abs, algorithm, strip_ext:)
212
+ compressed_size = File.size(abs)
213
+ inner_name = File.basename(abs)
214
+ inner_name = inner_name[0...-strip_ext.length] if inner_name.downcase.end_with?(strip_ext)
215
+
216
+ uncompressed_size =
217
+ case algorithm
218
+ when "gzip" then gzip_uncompressed_size(abs)
219
+ when "bzip2" then bunzip2(abs).bytesize
220
+ end
221
+
222
+ {
223
+ "archive" => algorithm == "gzip" ? "gz" : "bz2",
224
+ "single" => true,
225
+ "entries" => [{ "name" => inner_name, "dir" => false, "size" => uncompressed_size }],
226
+ "total_entries" => 1,
227
+ "total_size" => uncompressed_size,
228
+ "compressed_size" => compressed_size,
229
+ "capped" => false
230
+ }
231
+ end
232
+
233
+ # ---- 展開ヘルパ ---------------------------------------------------------
234
+
235
+ def gunzip(bytes)
236
+ Zlib::GzipReader.new(StringIO.new(bytes)).read
237
+ rescue Zlib::GzipFile::Error => e
238
+ raise ArchiveError, "gzip を展開できませんでした: #{e.message}"
239
+ end
240
+
241
+ # gzip 末尾 4 バイトの ISIZE (展開後サイズ mod 2^32)。4GB 未満の実用ケース向け。
242
+ def gzip_uncompressed_size(abs)
243
+ tail = File.open(abs, "rb") { |f| f.seek(-4, IO::SEEK_END); f.read(4) }
244
+ tail ? tail.unpack1("V") : nil
245
+ rescue SystemCallError
246
+ nil
247
+ end
248
+
249
+ # bzip2 を純Ruby (rbzip2) で展開する。ネイティブ libbz2 は不要。
250
+ def bunzip2(abs)
251
+ require "rbzip2"
252
+ raw = File.binread(abs)
253
+ if raw.bytesize > MAX_BZIP2_INPUT_BYTES
254
+ raise ArchiveError, "bzip2 ファイルが大きすぎます (上限 #{MAX_BZIP2_INPUT_BYTES / (1024 * 1024)}MB)"
255
+ end
256
+
257
+ RBzip2.default_adapter::Decompressor.new(StringIO.new(raw)).read
258
+ rescue LoadError
259
+ raise ArchiveError, "bzip2 の展開には rbzip2 gem が必要です"
260
+ rescue ArchiveError
261
+ raise
262
+ rescue StandardError => e
263
+ raise ArchiveError, "bzip2 を展開できませんでした: #{e.message}"
264
+ end
265
+
266
+ # ---- 集計の共通処理 -----------------------------------------------------
267
+
268
+ # エントリ名を正規化する ("./" 前置や重複スラッシュを除く。先頭は相対に統一)。
269
+ def normalize_entry_name(name)
270
+ s = name.tr("\\", "/")
271
+ s = s.sub(%r{\A\./}, "")
272
+ s = s.sub(%r{\A/+}, "")
273
+ s.gsub(%r{/+}, "/")
274
+ end
275
+
276
+ # 正規化済みエントリを「第一階層」に畳んで dirs / files に足し込む。
277
+ # "src/main.rb" → トップレベル dir "src" を登録し配下件数 +1
278
+ # "README.md" → トップレベル直下 file "README.md"
279
+ # "src/" → dir "src" を登録するだけ (ディレクトリ自身なので件数は増やさない)
280
+ # "emptydir/" → dir "emptydir" を件数 0 で登録 (空ディレクトリ)
281
+ def accumulate_top_level(rel, is_dir, size, dirs, files)
282
+ # 末尾スラッシュはディレクトリ自身のエントリ。区切りとしての "/" と区別する。
283
+ trailing_dir = rel.end_with?("/")
284
+ trimmed = trailing_dir ? rel.chomp("/") : rel
285
+ slash = trimmed.index("/")
286
+
287
+ if slash.nil?
288
+ # トップレベル直下のエントリ (ファイル or ディレクトリ自身)。
289
+ if is_dir || trailing_dir
290
+ dirs[trimmed] ||= 0 # 件数は増やさない (自身の登録だけ)。
291
+ else
292
+ files << { name: trimmed, size: size }
293
+ end
294
+ else
295
+ # 配下エントリ → トップレベルのディレクトリに畳んで件数を +1。
296
+ top = trimmed[0...slash]
297
+ dirs[top] ||= 0
298
+ dirs[top] += 1
299
+ end
300
+ end
301
+
302
+ # dirs / files から JSON 用の結果ハッシュを組み立てる。
303
+ # entries はディレクトリ→ファイルの順、各名前順 (一覧の並びに揃える)。
304
+ def build_result(dirs, files, total, total_size, capped)
305
+ dir_entries = dirs.keys.sort.map do |name|
306
+ { "name" => name, "dir" => true, "child_count" => dirs[name] }
307
+ end
308
+ # 同名ファイルの重複は稀だが、名前順で安定させる。
309
+ file_entries = files.uniq { |f| f[:name] }
310
+ .sort_by { |f| f[:name] }
311
+ .map { |f| { "name" => f[:name], "dir" => false, "size" => f[:size] } }
312
+
313
+ {
314
+ "archive" => "tar_or_zip",
315
+ "single" => false,
316
+ "entries" => dir_entries + file_entries,
317
+ "total_entries" => total,
318
+ "total_size" => total_size,
319
+ "capped" => capped
320
+ }
321
+ end
322
+ end
323
+ end