mdv-live 0.1.15__py3-none-any.whl

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.

Potentially problematic release.


This version of mdv-live might be problematic. Click here for more details.

mdv/server.py ADDED
@@ -0,0 +1,842 @@
1
+ """
2
+ MDV - Markdown Viewer Server
3
+ ファイルツリー表示 + マークダウンプレビュー + ホットリロード
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import mimetypes
11
+ import re
12
+ import shutil
13
+ import socket
14
+ import webbrowser
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import List, Optional, Set
18
+
19
+ from markdown_it import MarkdownIt
20
+ from mdit_py_plugins.tasklists import tasklists_plugin
21
+ import uvicorn
22
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, HTTPException, Request, UploadFile, File, Form
23
+ from fastapi.responses import FileResponse as FastAPIFileResponse, StreamingResponse
24
+ from fastapi.staticfiles import StaticFiles
25
+ from watchdog.observers import Observer
26
+ from watchdog.events import FileSystemEventHandler
27
+
28
+ from .file_types import (
29
+ FILE_TYPES,
30
+ SUPPORTED_EXTENSIONS,
31
+ SKIP_DIRECTORIES,
32
+ SKIP_FILES,
33
+ get_file_type,
34
+ FileTypeInfo,
35
+ )
36
+ from .models import SaveFileRequest, CreateDirectoryRequest, MoveItemRequest
37
+
38
+
39
+ # === Application State ===
40
+
41
+ @dataclass
42
+ class AppState:
43
+ """アプリケーション状態を管理"""
44
+ root_path: Path = field(default_factory=Path.cwd)
45
+ connected_clients: Set[WebSocket] = field(default_factory=set)
46
+ current_watching_file: Optional[str] = None
47
+ observer: Optional[Observer] = None
48
+ event_handler: Optional["FileChangeHandler"] = None
49
+
50
+ def set_root_path(self, path: str | Path) -> None:
51
+ self.root_path = Path(path).resolve()
52
+
53
+ def add_client(self, client: WebSocket) -> None:
54
+ self.connected_clients.add(client)
55
+
56
+ def remove_client(self, client: WebSocket) -> None:
57
+ self.connected_clients.discard(client)
58
+
59
+ def set_watching_file(self, path: str) -> None:
60
+ self.current_watching_file = path
61
+
62
+
63
+ # シングルトンインスタンス
64
+ state = AppState()
65
+
66
+
67
+ # === File Change Handler ===
68
+
69
+ class FileChangeHandler(FileSystemEventHandler):
70
+ """ファイル変更を検知してWebSocketでクライアントに通知"""
71
+
72
+ def __init__(self) -> None:
73
+ super().__init__()
74
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
75
+
76
+ def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
77
+ self._loop = loop
78
+
79
+ def _handle_file_change(self, file_path: str) -> None:
80
+ """ファイル変更を処理"""
81
+ if not state.current_watching_file:
82
+ return
83
+ if not self._loop:
84
+ return
85
+
86
+ # パスを正規化して比較(watchdogとresolve()の形式が異なる場合がある)
87
+ try:
88
+ normalized_path = str(Path(file_path).resolve())
89
+ except Exception:
90
+ normalized_path = file_path
91
+
92
+ if normalized_path != state.current_watching_file:
93
+ return
94
+
95
+ asyncio.run_coroutine_threadsafe(
96
+ broadcast_file_update(normalized_path),
97
+ self._loop
98
+ )
99
+
100
+ def _handle_tree_change(self) -> None:
101
+ """ファイルツリーの変更を通知"""
102
+ if not self._loop:
103
+ return
104
+ asyncio.run_coroutine_threadsafe(
105
+ broadcast_tree_update(),
106
+ self._loop
107
+ )
108
+
109
+ def on_modified(self, event) -> None:
110
+ if event.is_directory:
111
+ return
112
+ self._handle_file_change(event.src_path)
113
+
114
+ def on_moved(self, event) -> None:
115
+ """ファイルのリネーム/移動を検知(アトミック書き込み対応)"""
116
+ if event.is_directory:
117
+ return
118
+ self._handle_file_change(event.dest_path)
119
+ self._handle_tree_change()
120
+
121
+ def on_created(self, event) -> None:
122
+ """ファイル/ディレクトリ作成を検知"""
123
+ if not event.is_directory:
124
+ # 一部のエディタはアトミック書き込みで delete→create を行うため
125
+ # ファイル作成時も内容更新をチェック
126
+ self._handle_file_change(event.src_path)
127
+ self._handle_tree_change()
128
+
129
+ def on_deleted(self, event) -> None:
130
+ """ファイル/ディレクトリ削除を検知"""
131
+ self._handle_tree_change()
132
+
133
+
134
+ # === Rendering Functions ===
135
+
136
+ def escape_html(text: str) -> str:
137
+ """HTMLエスケープ"""
138
+ return (
139
+ text
140
+ .replace("&", "&")
141
+ .replace("<", "&lt;")
142
+ .replace(">", "&gt;")
143
+ )
144
+
145
+
146
+ # YAMLフロントマターのパターン(ファイル先頭の---で囲まれた部分)
147
+ _FRONTMATTER_PATTERN = re.compile(r'^---\s*\n(.*?)\n---\s*(\n|$)', re.DOTALL)
148
+
149
+ # 見出し後のYAMLメタデータブロック(---で囲まれたkey: value形式)
150
+ # 例: # Title\n\n---\nname: foo\n---
151
+ _YAML_BLOCK_PATTERN = re.compile(
152
+ r'(^|\n)(#{1,6}\s+[^\n]+)\n+---\s*\n((?:[a-zA-Z_][a-zA-Z0-9_]*:\s*[^\n]*\n?)+)---\s*(\n|$)',
153
+ re.MULTILINE
154
+ )
155
+
156
+ # Mermaidコードブロックのパターン
157
+ _MERMAID_PATTERN = re.compile(r'```mermaid\s*\n(.*?)\n```', re.DOTALL)
158
+
159
+
160
+ def _preprocess_markdown(content: str) -> tuple[str, list[str]]:
161
+ """マークダウンの前処理(YAMLフロントマター変換、Mermaid保護)"""
162
+ # YAMLフロントマターをコードブロックに変換(ファイル先頭)
163
+ frontmatter_match = _FRONTMATTER_PATTERN.match(content)
164
+ if frontmatter_match:
165
+ frontmatter_content = frontmatter_match.group(1)
166
+ rest_of_content = content[frontmatter_match.end():]
167
+ content = f"```yaml\n{frontmatter_content}\n```\n{rest_of_content}"
168
+
169
+ # 見出し後のYAMLメタデータブロックをコードブロックに変換
170
+ def replace_yaml_block(match: re.Match) -> str:
171
+ prefix = match.group(1) # 先頭の改行または空文字
172
+ heading = match.group(2) # 見出し
173
+ yaml_content = match.group(3).rstrip('\n') # YAMLコンテンツ
174
+ suffix = match.group(4) # 末尾の改行または空文字
175
+ return f"{prefix}{heading}\n\n```yaml\n{yaml_content}\n```{suffix}"
176
+
177
+ content = _YAML_BLOCK_PATTERN.sub(replace_yaml_block, content)
178
+
179
+ # Mermaidコードブロックを保護
180
+ mermaid_blocks: list[str] = []
181
+
182
+ def replace_mermaid(match: re.Match) -> str:
183
+ mermaid_blocks.append(match.group(1))
184
+ return f"<!--MERMAID_PLACEHOLDER_{len(mermaid_blocks) - 1}-->"
185
+
186
+ content = _MERMAID_PATTERN.sub(replace_mermaid, content)
187
+
188
+ return content, mermaid_blocks
189
+
190
+
191
+ def _postprocess_markdown(html: str, mermaid_blocks: list[str]) -> str:
192
+ """マークダウンの後処理(Mermaid復元)"""
193
+ # Mermaidコードブロックを復元
194
+ for i, mermaid_code in enumerate(mermaid_blocks):
195
+ placeholder = f"<!--MERMAID_PLACEHOLDER_{i}-->"
196
+ escaped_code = escape_html(mermaid_code)
197
+ mermaid_html = f'<pre><code class="language-mermaid">{escaped_code}</code></pre>'
198
+ html = html.replace(f"<p>{placeholder}</p>", mermaid_html)
199
+ html = html.replace(placeholder, mermaid_html)
200
+
201
+ return html
202
+
203
+
204
+ # markdown-it-pyのインスタンスを作成(シングルトン)
205
+ _md_parser: Optional[MarkdownIt] = None
206
+
207
+
208
+ def _get_md_parser() -> MarkdownIt:
209
+ """markdown-it-pyパーサーを取得(遅延初期化)"""
210
+ global _md_parser
211
+ if _md_parser is None:
212
+ _md_parser = MarkdownIt("commonmark", {"html": True, "typographer": True})
213
+ _md_parser.enable("table")
214
+ _md_parser.enable("strikethrough")
215
+ _md_parser.use(tasklists_plugin)
216
+ return _md_parser
217
+
218
+
219
+ def render_markdown(content: str) -> str:
220
+ """マークダウンをHTMLに変換(markdown-it-py使用、行番号付き)"""
221
+ content, mermaid_blocks = _preprocess_markdown(content)
222
+ md = _get_md_parser()
223
+
224
+ # トークンを取得して data-line 属性を追加
225
+ tokens = md.parse(content)
226
+ for token in tokens:
227
+ if token.map and len(token.map) >= 1:
228
+ # _open トークンに data-line を追加
229
+ if token.attrs is None:
230
+ token.attrs = {}
231
+ token.attrs["data-line"] = str(token.map[0])
232
+
233
+ html = md.renderer.render(tokens, md.options, {})
234
+ return _postprocess_markdown(html, mermaid_blocks)
235
+
236
+
237
+ def render_code(content: str, lang: Optional[str] = None) -> str:
238
+ """コードをシンタックスハイライト用HTMLに変換"""
239
+ escaped = escape_html(content)
240
+ lang_class = f"language-{lang}" if lang else ""
241
+ return f'<pre><code class="{lang_class}">{escaped}</code></pre>'
242
+
243
+
244
+ def render_text(content: str) -> str:
245
+ """プレーンテキストをHTMLに変換"""
246
+ escaped = escape_html(content)
247
+ return f'<pre class="plain-text">{escaped}</pre>'
248
+
249
+
250
+ def render_file_content(content: str, file_info: FileTypeInfo) -> str:
251
+ """ファイルタイプに応じてコンテンツをレンダリング"""
252
+ if file_info.type == "markdown":
253
+ return render_markdown(content)
254
+ elif file_info.type == "code":
255
+ return render_code(content, file_info.lang)
256
+ else:
257
+ return render_text(content)
258
+
259
+
260
+ # === WebSocket Broadcasting ===
261
+
262
+ async def broadcast_tree_update() -> None:
263
+ """全クライアントにファイルツリー更新を通知"""
264
+ if not state.connected_clients:
265
+ return
266
+
267
+ try:
268
+ tree = get_file_tree(state.root_path)
269
+ message = json.dumps({
270
+ "type": "tree_update",
271
+ "tree": tree,
272
+ })
273
+
274
+ disconnected = []
275
+ for client in state.connected_clients:
276
+ try:
277
+ await client.send_text(message)
278
+ except Exception:
279
+ disconnected.append(client)
280
+
281
+ for client in disconnected:
282
+ state.remove_client(client)
283
+
284
+ except Exception as e:
285
+ print(f"Error broadcasting tree update: {e}")
286
+
287
+
288
+ async def broadcast_file_update(file_path: str) -> None:
289
+ """全クライアントにファイル更新を通知"""
290
+ if not state.connected_clients:
291
+ return
292
+
293
+ try:
294
+ path = Path(file_path)
295
+ file_info = get_file_type(path.suffix)
296
+
297
+ if not file_info:
298
+ return
299
+
300
+ # メッセージ作成
301
+ if file_info.type == "image":
302
+ message = {
303
+ "type": "file_update",
304
+ "path": file_path,
305
+ "fileType": "image",
306
+ "reload": True,
307
+ }
308
+ else:
309
+ content = path.read_text(encoding="utf-8")
310
+ html_content = render_file_content(content, file_info)
311
+ message = {
312
+ "type": "file_update",
313
+ "path": file_path,
314
+ "content": html_content,
315
+ "raw": content,
316
+ "fileType": file_info.type,
317
+ }
318
+
319
+ message_json = json.dumps(message)
320
+
321
+ # 全クライアントに送信
322
+ disconnected = []
323
+ for client in state.connected_clients:
324
+ try:
325
+ await client.send_text(message_json)
326
+ except Exception:
327
+ disconnected.append(client)
328
+
329
+ # 切断されたクライアントを削除
330
+ for client in disconnected:
331
+ state.remove_client(client)
332
+
333
+ except Exception as e:
334
+ print(f"Error broadcasting update: {e}")
335
+
336
+
337
+ # === File Tree ===
338
+
339
+ def get_file_tree(root: Path) -> list:
340
+ """ディレクトリツリーを取得(サポートするファイルタイプのみ)"""
341
+ items = []
342
+
343
+ try:
344
+ entries = sorted(
345
+ root.iterdir(),
346
+ key=lambda x: (not x.is_dir(), x.name.lower())
347
+ )
348
+ except PermissionError:
349
+ return items
350
+
351
+ for entry in entries:
352
+ # 特定ディレクトリをスキップ
353
+ if entry.name in SKIP_DIRECTORIES:
354
+ continue
355
+ # ゴミファイルをスキップ
356
+ if entry.name in SKIP_FILES:
357
+ continue
358
+
359
+ rel_path = str(entry.relative_to(state.root_path))
360
+
361
+ if entry.is_dir():
362
+ children = get_file_tree(entry)
363
+ # 空のディレクトリも表示
364
+ items.append({
365
+ "name": entry.name,
366
+ "path": rel_path,
367
+ "type": "directory",
368
+ "children": children,
369
+ })
370
+ elif entry.suffix.lower() in SUPPORTED_EXTENSIONS:
371
+ file_info = FILE_TYPES[entry.suffix.lower()]
372
+ items.append({
373
+ "name": entry.name,
374
+ "path": rel_path,
375
+ "type": "file",
376
+ "fileType": file_info.type,
377
+ "icon": file_info.icon,
378
+ "lang": file_info.lang,
379
+ })
380
+
381
+ return items
382
+
383
+
384
+ # === Security ===
385
+
386
+ def validate_path(requested_path: str) -> Path:
387
+ """
388
+ パスを検証してセキュアなPathオブジェクトを返す
389
+ 不正なパスの場合はHTTPExceptionを発生
390
+ """
391
+ file_path = state.root_path / requested_path
392
+
393
+ if not file_path.exists():
394
+ raise HTTPException(status_code=404, detail="File not found")
395
+
396
+ # ROOT_PATH外へのアクセスを防ぐ
397
+ try:
398
+ file_path.resolve().relative_to(state.root_path.resolve())
399
+ except ValueError:
400
+ raise HTTPException(status_code=403, detail="Access denied")
401
+
402
+ return file_path
403
+
404
+
405
+ def validate_path_for_write(requested_path: str) -> Path:
406
+ """
407
+ 書き込み用のパス検証(ファイルが存在しなくてもOK)
408
+ パストラバーサル防止 + ROOT_PATH内であることを確認
409
+ """
410
+ file_path = state.root_path / requested_path
411
+
412
+ # ROOT_PATH外へのアクセスを防ぐ
413
+ try:
414
+ file_path.resolve().relative_to(state.root_path.resolve())
415
+ except ValueError:
416
+ raise HTTPException(status_code=403, detail="Access denied")
417
+
418
+ return file_path
419
+
420
+
421
+ def sanitize_filename(filename: str) -> str:
422
+ """ファイル名をサニタイズ(パス区切り文字を除去)"""
423
+ return Path(filename).name
424
+
425
+
426
+ # === FastAPI Application ===
427
+
428
+ app = FastAPI(title="MDV - Markdown Viewer")
429
+
430
+
431
+ @app.on_event("startup")
432
+ async def startup_event() -> None:
433
+ """サーバー起動時にイベントループを設定"""
434
+ if state.event_handler:
435
+ loop = asyncio.get_running_loop()
436
+ state.event_handler.set_loop(loop)
437
+ print("✅ File watcher connected to event loop")
438
+
439
+
440
+ @app.get("/")
441
+ async def index() -> FastAPIFileResponse:
442
+ """メインページ"""
443
+ static_dir = Path(__file__).parent / "static"
444
+ return FastAPIFileResponse(static_dir / "index.html")
445
+
446
+
447
+ @app.get("/api/tree")
448
+ async def get_tree() -> list:
449
+ """ファイルツリーを取得"""
450
+ return get_file_tree(state.root_path)
451
+
452
+
453
+ @app.get("/api/info")
454
+ async def get_info() -> dict:
455
+ """サーバー情報を取得"""
456
+ return {
457
+ "rootPath": str(state.root_path),
458
+ "rootName": state.root_path.name or str(state.root_path)
459
+ }
460
+
461
+
462
+ @app.get("/api/file")
463
+ async def get_file(path: str = Query(...)) -> dict:
464
+ """ファイルを取得してレンダリング"""
465
+ file_path = validate_path(path)
466
+
467
+ if not file_path.is_file():
468
+ raise HTTPException(status_code=400, detail="Not a file")
469
+
470
+ file_info = get_file_type(file_path.suffix)
471
+ if not file_info:
472
+ raise HTTPException(status_code=400, detail="Unsupported file type")
473
+
474
+ # 監視対象を更新
475
+ state.set_watching_file(str(file_path.resolve()))
476
+
477
+ # 画像の場合
478
+ if file_info.type == "image":
479
+ return {
480
+ "path": path,
481
+ "name": file_path.name,
482
+ "fileType": file_info.type,
483
+ "imageUrl": f"/api/image?path={path}",
484
+ }
485
+
486
+ # PDFの場合
487
+ if file_info.type == "pdf":
488
+ return {
489
+ "path": path,
490
+ "name": file_path.name,
491
+ "fileType": file_info.type,
492
+ "pdfUrl": f"/api/pdf?path={path}",
493
+ }
494
+
495
+ # 動画の場合
496
+ if file_info.type == "video":
497
+ return {
498
+ "path": path,
499
+ "name": file_path.name,
500
+ "fileType": file_info.type,
501
+ "mediaUrl": f"/api/media?path={path}",
502
+ }
503
+
504
+ # 音声の場合
505
+ if file_info.type == "audio":
506
+ return {
507
+ "path": path,
508
+ "name": file_path.name,
509
+ "fileType": file_info.type,
510
+ "mediaUrl": f"/api/media?path={path}",
511
+ }
512
+
513
+ # テキスト系ファイルを読み込み
514
+ try:
515
+ content = file_path.read_text(encoding="utf-8")
516
+ except UnicodeDecodeError:
517
+ raise HTTPException(status_code=400, detail="Cannot read binary file as text")
518
+
519
+ html_content = render_file_content(content, file_info)
520
+
521
+ return {
522
+ "path": path,
523
+ "name": file_path.name,
524
+ "content": html_content,
525
+ "raw": content,
526
+ "fileType": file_info.type,
527
+ "lang": file_info.lang,
528
+ }
529
+
530
+
531
+ @app.get("/api/image")
532
+ async def get_image(path: str = Query(...)) -> FastAPIFileResponse:
533
+ """画像ファイルを返す"""
534
+ file_path = validate_path(path)
535
+
536
+ mime_type, _ = mimetypes.guess_type(str(file_path))
537
+ if not mime_type or not mime_type.startswith("image/"):
538
+ raise HTTPException(status_code=400, detail="Not an image file")
539
+
540
+ return FastAPIFileResponse(file_path, media_type=mime_type)
541
+
542
+
543
+ @app.get("/api/pdf")
544
+ async def get_pdf(path: str = Query(...)) -> FastAPIFileResponse:
545
+ """PDFファイルを返す"""
546
+ file_path = validate_path(path)
547
+
548
+ if not file_path.suffix.lower() == ".pdf":
549
+ raise HTTPException(status_code=400, detail="Not a PDF file")
550
+
551
+ return FastAPIFileResponse(file_path, media_type="application/pdf")
552
+
553
+
554
+ @app.post("/api/file")
555
+ async def save_file(request: SaveFileRequest) -> dict:
556
+ """ファイルを保存"""
557
+ file_path = validate_path(request.path)
558
+
559
+ if not file_path.is_file():
560
+ raise HTTPException(status_code=400, detail="Not a file")
561
+
562
+ file_info = get_file_type(file_path.suffix)
563
+ if not file_info or file_info.type == "image":
564
+ raise HTTPException(status_code=400, detail="Cannot edit this file type")
565
+
566
+ try:
567
+ file_path.write_text(request.content, encoding="utf-8")
568
+ return {"success": True, "path": request.path}
569
+ except Exception as e:
570
+ raise HTTPException(status_code=500, detail=f"Failed to save: {str(e)}")
571
+
572
+
573
+ @app.delete("/api/file")
574
+ async def delete_file(path: str = Query(...)) -> dict:
575
+ """ファイルまたはフォルダを削除"""
576
+ file_path = validate_path(path)
577
+
578
+ try:
579
+ if file_path.is_dir():
580
+ shutil.rmtree(file_path)
581
+ else:
582
+ file_path.unlink()
583
+ return {"success": True, "path": path}
584
+ except Exception as e:
585
+ raise HTTPException(status_code=500, detail=f"Failed to delete: {str(e)}")
586
+
587
+
588
+ @app.get("/api/download")
589
+ async def download_file(path: str = Query(...)) -> FastAPIFileResponse:
590
+ """ファイルをダウンロード(Content-Disposition: attachment)"""
591
+ file_path = validate_path(path)
592
+
593
+ if not file_path.is_file():
594
+ raise HTTPException(status_code=400, detail="Not a file")
595
+
596
+ mime_type, _ = mimetypes.guess_type(str(file_path))
597
+ return FastAPIFileResponse(
598
+ file_path,
599
+ media_type=mime_type or "application/octet-stream",
600
+ filename=file_path.name
601
+ )
602
+
603
+
604
+ @app.get("/api/media")
605
+ async def get_media(path: str = Query(...), request: Request = None) -> StreamingResponse:
606
+ """動画/音声ストリーミング(Range requests対応)"""
607
+ file_path = validate_path(path)
608
+
609
+ if not file_path.is_file():
610
+ raise HTTPException(status_code=400, detail="Not a file")
611
+
612
+ file_size = file_path.stat().st_size
613
+ mime_type, _ = mimetypes.guess_type(str(file_path))
614
+ mime_type = mime_type or "application/octet-stream"
615
+
616
+ range_header = request.headers.get("range") if request else None
617
+
618
+ if range_header:
619
+ # Range: bytes=0-1000 形式をパース
620
+ match = re.match(r"bytes=(\d*)-(\d*)", range_header)
621
+ if match:
622
+ start = int(match.group(1)) if match.group(1) else 0
623
+ end = int(match.group(2)) if match.group(2) else file_size - 1
624
+
625
+ if start >= file_size:
626
+ raise HTTPException(status_code=416, detail="Range not satisfiable")
627
+
628
+ end = min(end, file_size - 1)
629
+ content_length = end - start + 1
630
+
631
+ def stream_range():
632
+ with open(file_path, "rb") as f:
633
+ f.seek(start)
634
+ remaining = content_length
635
+ while remaining > 0:
636
+ chunk_size = min(1024 * 1024, remaining)
637
+ chunk = f.read(chunk_size)
638
+ if not chunk:
639
+ break
640
+ remaining -= len(chunk)
641
+ yield chunk
642
+
643
+ return StreamingResponse(
644
+ stream_range(),
645
+ status_code=206,
646
+ media_type=mime_type,
647
+ headers={
648
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
649
+ "Accept-Ranges": "bytes",
650
+ "Content-Length": str(content_length),
651
+ }
652
+ )
653
+
654
+ # Range指定なしの場合は全体を返す
655
+ def stream_file():
656
+ with open(file_path, "rb") as f:
657
+ while chunk := f.read(1024 * 1024):
658
+ yield chunk
659
+
660
+ return StreamingResponse(
661
+ stream_file(),
662
+ media_type=mime_type,
663
+ headers={
664
+ "Accept-Ranges": "bytes",
665
+ "Content-Length": str(file_size),
666
+ }
667
+ )
668
+
669
+
670
+ @app.post("/api/upload")
671
+ async def upload_files(
672
+ path: str = Form(""),
673
+ files: List[UploadFile] = File(...)
674
+ ) -> dict:
675
+ """ファイルをアップロード(複数ファイル対応)"""
676
+ target_dir = validate_path_for_write(path) if path else state.root_path
677
+
678
+ # ディレクトリが存在しない場合は作成
679
+ target_dir.mkdir(parents=True, exist_ok=True)
680
+
681
+ if not target_dir.is_dir():
682
+ raise HTTPException(status_code=400, detail="Target is not a directory")
683
+
684
+ uploaded = []
685
+ for file in files:
686
+ if not file.filename:
687
+ continue
688
+
689
+ filename = sanitize_filename(file.filename)
690
+ dest_path = target_dir / filename
691
+
692
+ try:
693
+ with open(dest_path, "wb") as f:
694
+ shutil.copyfileobj(file.file, f)
695
+ uploaded.append(filename)
696
+ except Exception as e:
697
+ raise HTTPException(status_code=500, detail=f"Failed to upload {filename}: {str(e)}")
698
+
699
+ return {"success": True, "uploaded": uploaded}
700
+
701
+
702
+ @app.post("/api/mkdir")
703
+ async def create_directory(request: CreateDirectoryRequest) -> dict:
704
+ """新規フォルダを作成"""
705
+ dir_path = validate_path_for_write(request.path)
706
+
707
+ if dir_path.exists():
708
+ raise HTTPException(status_code=400, detail="Directory already exists")
709
+
710
+ try:
711
+ dir_path.mkdir(parents=True, exist_ok=True)
712
+ return {"success": True, "path": request.path}
713
+ except Exception as e:
714
+ raise HTTPException(status_code=500, detail=f"Failed to create directory: {str(e)}")
715
+
716
+
717
+ @app.post("/api/move")
718
+ async def move_item(request: MoveItemRequest) -> dict:
719
+ """ファイル/フォルダを移動またはリネーム"""
720
+ source_path = validate_path(request.source)
721
+ dest_path = validate_path_for_write(request.destination)
722
+
723
+ if dest_path.exists():
724
+ raise HTTPException(status_code=400, detail="Destination already exists")
725
+
726
+ try:
727
+ shutil.move(str(source_path), str(dest_path))
728
+ return {"success": True, "source": request.source, "destination": request.destination}
729
+ except Exception as e:
730
+ raise HTTPException(status_code=500, detail=f"Failed to move: {str(e)}")
731
+
732
+
733
+ @app.websocket("/ws")
734
+ async def websocket_endpoint(websocket: WebSocket) -> None:
735
+ """WebSocket接続を管理"""
736
+ await websocket.accept()
737
+ state.add_client(websocket)
738
+
739
+ try:
740
+ while True:
741
+ data = await websocket.receive_text()
742
+ message = json.loads(data)
743
+
744
+ if message.get("type") == "watch":
745
+ file_path = state.root_path / message.get("path", "")
746
+ if file_path.exists():
747
+ state.set_watching_file(str(file_path.resolve()))
748
+
749
+ except WebSocketDisconnect:
750
+ state.remove_client(websocket)
751
+ except Exception as e:
752
+ print(f"WebSocket error: {e}")
753
+ state.remove_client(websocket)
754
+
755
+
756
+ # 静的ファイルをマウント
757
+ static_dir = Path(__file__).parent / "static"
758
+ if static_dir.exists():
759
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
760
+
761
+
762
+ # === Server Startup ===
763
+
764
+ def find_available_port(start_port: int, max_attempts: int = 100) -> int:
765
+ """利用可能なポートを探す"""
766
+ for offset in range(max_attempts):
767
+ port = start_port + offset
768
+ try:
769
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
770
+ s.bind(("localhost", port))
771
+ return port
772
+ except OSError:
773
+ continue
774
+
775
+ raise RuntimeError(
776
+ f"No available port found in range {start_port}-{start_port + max_attempts}"
777
+ )
778
+
779
+
780
+ def start_server(
781
+ root_path: str = ".",
782
+ port: int = 8642,
783
+ open_browser: bool = True,
784
+ initial_file: Optional[str] = None,
785
+ ) -> None:
786
+ """サーバーを起動"""
787
+ state.set_root_path(root_path)
788
+
789
+ if not state.root_path.exists():
790
+ print(f"Error: Path does not exist: {state.root_path}")
791
+ return
792
+
793
+ # 利用可能なポートを探す
794
+ try:
795
+ actual_port = find_available_port(port)
796
+ if actual_port != port:
797
+ print(f"⚠️ Port {port} is in use, using {actual_port} instead")
798
+ except RuntimeError as e:
799
+ print(f"Error: {e}")
800
+ return
801
+
802
+ print(f"📁 Serving: {state.root_path}")
803
+ print(f"🌐 URL: http://localhost:{actual_port}")
804
+
805
+ # ファイル監視を開始
806
+ state.event_handler = FileChangeHandler()
807
+ state.observer = Observer()
808
+ state.observer.schedule(
809
+ state.event_handler,
810
+ str(state.root_path),
811
+ recursive=True
812
+ )
813
+ state.observer.start()
814
+
815
+ # ブラウザを開く
816
+ if open_browser:
817
+ url = f"http://localhost:{actual_port}"
818
+ if initial_file:
819
+ from urllib.parse import quote
820
+ url += f"?file={quote(initial_file)}"
821
+ webbrowser.open(url)
822
+
823
+ # サーバー起動
824
+ try:
825
+ config = uvicorn.Config(
826
+ app,
827
+ host="0.0.0.0",
828
+ port=actual_port,
829
+ log_level="warning"
830
+ )
831
+ server = uvicorn.Server(config)
832
+ asyncio.run(server.serve())
833
+ except KeyboardInterrupt:
834
+ pass
835
+ finally:
836
+ if state.observer:
837
+ state.observer.stop()
838
+ state.observer.join()
839
+
840
+
841
+ if __name__ == "__main__":
842
+ start_server()