feat: 문서 탐색기 (Tab 16) — 프로젝트 폴더 트리 탐색 + txt/md/pdf 뷰어

프로젝트 폴더 전체를 안전하게 탐색하고 문서를 보고 편집하는 웹 UI.

- DocBrowserService: 루트 자동탐색(.git/.sln), 경로이탈·심볼릭·제외디렉토리
  ·민감파일 가드, 목록/읽기/원본/쓰기/이름변경/삭제/폴더/업로드
- DocsController(/api/docs): config·tree·text·raw(공개) / 변경계열은 KB admin 토큰
- 뷰어: md(marked+DOMPurify+hljs+KaTeX+mermaid) / pdf(원본 iframe) / txt(pre) / 그 외 다운로드
- 인라인 편집(md 실시간 분할 미리보기) + 관리(새폴더/업로드/이름변경/삭제)
- 사이드바 nav 스크롤 수정(.nav overflow), 헤더 컴팩트화로 문서 영역 확보

주의: 프론트 라이브러리(marked/mermaid/katex 등)는 wwwroot/lib/(.gitignore)라
미추적 — 별도 처리 필요.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-24 06:32:19 +09:00
parent ce72e25f0e
commit 9cc359b803
10 changed files with 1574 additions and 1 deletions

View File

@@ -7,6 +7,59 @@
## 완료된 작업
### 문서 탐색기 (Tab 16) 구현 (2026-05-24)
#### 배경
프로젝트 폴더의 문서를 Web UI에서 직접 보고 관리. 트리는 **프로젝트 폴더 전체**를 탐색,
뷰어는 **txt · md · pdf** 3종(그 외 다운로드), **보기 + 관리 + 인라인 편집** 범위.
사용자 결정: PDF=원본 그대로(iframe), Excel=제외, md 렌더=marked+코드강조+KaTeX+mermaid,
뷰어 바탕=흰색.
#### 구현 내역
| # | 항목 | 핵심 |
|---|------|------|
| 1 | 안전 파일트리 | 루트 자동탐색(.git/*.sln) → 상대경로만 취급, `Path.GetFullPath` 정규화 후 루트이탈·심볼릭이탈 차단. 제외 디렉토리(.git/bin/obj/node_modules/storage…)·민감파일(*.pfx/.env/appsettings*.json…) 숨김+차단 |
| 2 | 뷰어 디스패치 | `md`=marked→DOMPurify→hljs→KaTeX→mermaid / `pdf`=원본 iframe(`Content-Type: application/pdf`) / `txt`=pre / 그 외=다운로드. md 라이브러리는 첫 진입 시 지연로딩 |
| 3 | 인라인 편집 | admin이면 `✎ 편집` → textarea(md는 실시간 분할 미리보기) → `PUT /api/docs/text` 저장 |
| 4 | 관리 | 새 폴더·업로드·이름변경·재귀삭제 — KB admin 토큰(X-Kb-Token) 재사용 |
| 5 | UI | 다크 트리(지연확장·필터·노드별 hover 액션) + **흰색 종이 뷰어**, 상대링크 클릭 시 탐색기 내 이동, 토스트 |
#### 수정 파일
| 파일 | 변경 요약 |
|------|----------|
| `src/Infrastructure/Docs/DocBrowserService.cs` (신규) | 루트결정·`SafeResolve`(이탈/심볼릭/제외/민감 가드)·List/ReadText(바이너리·BOM·크기상한)/OpenRaw(MIME)/WriteText/Rename/Delete/MakeDir/SaveUploadAsync |
| `src/Web/Controllers/DocsController.cs` (신규) | `/api/docs` — config·tree·text·raw(공개) / PUT text·rename·mkdir·upload·DELETE(admin, `IKbAuthService.ValidateAsync`) |
| `src/Web/Program.cs` | `DocBrowserService` 싱글톤 등록 |
| `src/Web/appsettings.json` | `DocBrowser` 섹션(Root="", MaxTextBytes=2MB, MaxUploadBytes=50MB) |
| `src/Web/wwwroot/index.html` | nav-item(16 문서 탐색기), `#pane-docs`(트리+뷰어), docs.css 링크, docs.js 스크립트 |
| `src/Web/wwwroot/js/app.js` | 탭 전환부 `if (tab === 'docs') docsInit()` |
| `src/Web/wwwroot/js/docs.js` (신규) | 트리(지연확장·필터·관리액션 위임), 뷰어 디스패치, md 렌더 파이프라인, 지연 라이브러리 로더, 인라인 에디터, 관리(mkdir/upload/rename/delete), KB 토큰 재사용 잠금해제, 토스트 |
| `src/Web/wwwroot/css/docs.css` (신규) | 다크 트리 + 흰색 종이 뷰어, GitHub-라이트 마크다운 타이포, 편집 분할 |
| `src/Web/wwwroot/lib/` (신규) | marked@12, dompurify@3.1.6, highlight.js@11.10(+github 테마), katex@0.16.11(js/css/auto-render + woff2 20종), mermaid@10.9.1 — 전부 로컬 번들 |
#### 설계 결정
| 항목 | 결정 |
|------|------|
| 적용 환경 | **소스 트리가 있는 개발 환경 전용** (배포본 `/opt/ExperionCrawler`엔 소스 없음). 루트는 `DocBrowser:Root`로 재설정 가능 |
| 인증 | 조회=공개, 변경계열=KB admin 토큰 재사용(별도 비번 없음). 프론트는 `sessionStorage.kbToken` 공유 |
| md 라이브러리 로딩 | 페이지 로드 시점이 아닌 **md 첫 열람 시 지연로딩**(mermaid 3.3MB 등 무게 회피) |
| XSS | marked 출력은 DOMPurify 살균. pdf/원본은 iframe·MIME 한정 |
| download 파라미터 | ASP.NET bool 바인딩은 `true/false`만 허용 — 프론트 `download=true` 사용(초기 `download=1`은 400, 수정함) |
| 뷰어 대상 | 우선 txt/md/pdf만. `DOCS_TEXT_EXT`/`DOCS_MD_EXT` 상수로 추후 확장 용이 |
#### 빌드/검증 (라이브 :5000)
- `dotnet build` 경고 0 / 에러 0, `node -c docs.js/app.js` OK, 정적자산 11종 200 서빙
- 루트 자동탐색 → 프로젝트 루트, 트리 `.git/bin/obj` 제외, 경로이탈·민감파일(appsettings/.env)·`.git` 진입 차단
- md/txt 읽기·하위트리·PDF inline(`application/pdf`)·다운로드 첨부 정상
- 미인증 PUT/DELETE → 401, admin 로그인 후 mkdir/쓰기/읽기/이름변경/재귀삭제 정상, `.env` 쓰기 차단, 정리 확인
#### 잔여
- 브라우저 실물 렌더(트리 클릭·md/mermaid/KaTeX·편집 UI)는 미확인 — 사용자 `Ctrl+F5` 후 탭 진입으로 확인 필요
- 검증 위해 기존 `dotnet run`(터미널)을 백그라운드 새 빌드로 교체 기동
### Phase 7 + Phase 5 후순위 일괄 구현 (2026-05-14)
#### 배경

View File

@@ -0,0 +1,350 @@
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
namespace ExperionCrawler.Infrastructure.Docs;
/// <summary>디렉토리/파일 한 항목.</summary>
public sealed record DocEntry(string Name, string RelPath, bool IsDir, long Size, DateTime ModifiedUtc, string Ext);
/// <summary>텍스트 읽기 결과.</summary>
public sealed record DocTextResult(string Text, bool Truncated, long Size, string Ext);
/// <summary>원본 스트림 + 메타.</summary>
public sealed record DocRawResult(Stream Stream, string ContentType, string FileName, string Ext);
/// <summary>경로 이탈·제외·미존재 등 안전 위반.</summary>
public sealed class DocBrowserException : Exception
{
public DocBrowserException(string message) : base(message) { }
}
/// <summary>
/// 프로젝트 폴더 트리를 안전하게 탐색/편집하기 위한 서비스.
/// 모든 경로는 루트 기준 상대경로로만 다루며, 루트 이탈·제외 디렉토리·민감 파일은 차단한다.
/// 루트는 DocBrowser:Root 설정 또는 (.git/*.sln 자동탐색)으로 결정. 소스 트리가 있는 개발 환경 전용.
/// </summary>
public sealed class DocBrowserService
{
private readonly string _root;
private readonly long _maxTextBytes;
private readonly long _maxUploadBytes;
// 트리에서 통째로 감출 디렉토리 이름 (대소문자 무시)
private static readonly HashSet<string> ExcludedDirs = new(StringComparer.OrdinalIgnoreCase)
{
".git", ".hg", ".svn", "bin", "obj", "node_modules", ".vs", ".vscode", ".idea",
".venv", "venv", "env", "__pycache__", ".pytest_cache", ".mypy_cache",
"dist", "build", "packages", "TestResults", "storage", "data", ".claude",
};
// 숨기고 읽기/서빙/편집 모두 차단할 민감 파일 패턴
private static readonly string[] SensitiveGlobs =
{
"*.pfx", "*.p12", "*.pem", "*.key", "*.crt", "*.cer",
".env", ".env.*", "appsettings*.json", "secrets.json", "*.user", "*.suo",
"id_rsa", "id_rsa.*", "*.pyc",
};
public DocBrowserService(IConfiguration config)
{
_root = ResolveRoot(config["DocBrowser:Root"]);
_maxTextBytes = ParseLong(config["DocBrowser:MaxTextBytes"], 2L * 1024 * 1024); // 2 MB
_maxUploadBytes = ParseLong(config["DocBrowser:MaxUploadBytes"], 50L * 1024 * 1024); // 50 MB
}
public string Root => _root;
public long MaxTextBytes => _maxTextBytes;
public long MaxUploadBytes => _maxUploadBytes;
// ── 루트 결정 ───────────────────────────────────────────────
private static string ResolveRoot(string? configured)
{
if (!string.IsNullOrWhiteSpace(configured))
{
var p = Path.IsPathRooted(configured)
? configured
: Path.Combine(Directory.GetCurrentDirectory(), configured);
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(p));
}
// 자동: 현재 디렉토리에서 위로 올라가며 .git 또는 *.sln 보유 디렉토리를 루트로
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (dir != null)
{
if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || dir.GetFiles("*.sln").Length > 0)
return Path.TrimEndingDirectorySeparator(dir.FullName);
dir = dir.Parent;
}
return Path.TrimEndingDirectorySeparator(Directory.GetCurrentDirectory());
}
private static long ParseLong(string? s, long fallback)
=> long.TryParse(s, out var v) && v > 0 ? v : fallback;
// ── 경로 안전 ───────────────────────────────────────────────
/// <summary>상대경로를 절대경로로 변환. 루트 이탈·제외 디렉토리·민감 파일이면 예외.</summary>
public string SafeResolve(string? relPath, bool mustExist = false)
{
var rel = (relPath ?? "").Replace('\\', '/').Trim();
if (rel.Contains('\0')) throw new DocBrowserException("잘못된 경로");
rel = rel.TrimStart('/');
if (rel is "." or "./") rel = "";
var candidate = Path.GetFullPath(Path.Combine(_root, rel));
candidate = Path.TrimEndingDirectorySeparator(candidate);
if (!IsWithinRoot(candidate))
throw new DocBrowserException("루트 밖 경로 접근 차단");
// 심볼릭 링크가 루트 밖을 가리키면 차단 (존재할 때만 검사)
var real = ResolveFinalTarget(candidate);
if (real != null && !IsWithinRoot(real))
throw new DocBrowserException("루트 밖 링크 접근 차단");
// 루트~대상 사이 모든 세그먼트 검사
var relToRoot = Path.GetRelativePath(_root, candidate);
if (relToRoot != ".")
{
var segs = relToRoot.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < segs.Length; i++)
{
var seg = segs[i];
bool isLast = i == segs.Length - 1;
// 마지막이 아니면 반드시 디렉토리 → 디렉토리 제외 검사
if (!isLast && ExcludedDirs.Contains(seg))
throw new DocBrowserException($"제외된 디렉토리: {seg}");
// 마지막 세그먼트: 디렉토리면 dir-제외, 파일이면 민감 검사
if (isLast)
{
if (ExcludedDirs.Contains(seg) && Directory.Exists(candidate))
throw new DocBrowserException($"제외된 디렉토리: {seg}");
if (IsSensitiveFile(seg))
throw new DocBrowserException("민감 파일 접근 차단");
}
}
}
if (mustExist && !File.Exists(candidate) && !Directory.Exists(candidate))
throw new DocBrowserException("대상이 존재하지 않습니다");
return candidate;
}
private bool IsWithinRoot(string absPath)
{
if (string.Equals(absPath, _root, PathComparison)) return true;
return absPath.StartsWith(_root + Path.DirectorySeparatorChar, PathComparison);
}
private static string? ResolveFinalTarget(string path)
{
try
{
FileSystemInfo? info = Directory.Exists(path) ? new DirectoryInfo(path)
: File.Exists(path) ? new FileInfo(path) : null;
if (info == null) return null;
var target = info.ResolveLinkTarget(returnFinalTarget: true);
return target == null ? null : Path.TrimEndingDirectorySeparator(target.FullName);
}
catch { return null; }
}
private static StringComparison PathComparison =>
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
public bool IsExcludedDir(string name) => ExcludedDirs.Contains(name);
public bool IsSensitiveFile(string name)
{
foreach (var g in SensitiveGlobs)
if (WildcardMatch(name, g)) return true;
return false;
}
private static bool WildcardMatch(string name, string pattern)
{
var rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
return Regex.IsMatch(name, rx, RegexOptions.IgnoreCase);
}
/// <summary>절대경로 → 루트 기준 상대경로(슬래시). 루트 자신은 "".</summary>
public string ToRel(string absPath)
{
var rel = Path.GetRelativePath(_root, absPath);
return rel == "." ? "" : rel.Replace('\\', '/');
}
// ── 목록 ────────────────────────────────────────────────────
public List<DocEntry> List(string? relPath)
{
var abs = SafeResolve(relPath, mustExist: true);
if (!Directory.Exists(abs)) throw new DocBrowserException("디렉토리가 아닙니다");
var result = new List<DocEntry>();
var di = new DirectoryInfo(abs);
foreach (var sub in di.EnumerateDirectories())
{
if (ExcludedDirs.Contains(sub.Name)) continue;
if ((sub.Attributes & FileAttributes.System) != 0) continue;
result.Add(new DocEntry(sub.Name, ToRel(sub.FullName), true, 0, sub.LastWriteTimeUtc, ""));
}
foreach (var f in di.EnumerateFiles())
{
if (IsSensitiveFile(f.Name)) continue;
if ((f.Attributes & FileAttributes.System) != 0) continue;
result.Add(new DocEntry(f.Name, ToRel(f.FullName), false, f.Length,
f.LastWriteTimeUtc, f.Extension.TrimStart('.').ToLowerInvariant()));
}
return result
.OrderByDescending(e => e.IsDir)
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
// ── 텍스트 읽기 ─────────────────────────────────────────────
public DocTextResult ReadText(string? relPath)
{
var abs = SafeResolve(relPath, mustExist: true);
if (!File.Exists(abs)) throw new DocBrowserException("파일이 아닙니다");
var fi = new FileInfo(abs);
var cap = (int)Math.Min(fi.Length, _maxTextBytes);
var buf = new byte[cap];
int total = 0;
using (var fs = fi.OpenRead())
{
int r;
while (total < cap && (r = fs.Read(buf, total, cap - total)) > 0) total += r;
}
// 바이너리 휴리스틱: 앞부분에 NUL 이 있으면 텍스트 아님
int scan = Math.Min(total, 8000);
for (int i = 0; i < scan; i++)
if (buf[i] == 0) throw new DocBrowserException("바이너리 파일은 텍스트로 볼 수 없습니다");
var text = DecodeUtf8(buf, total);
return new DocTextResult(text, fi.Length > _maxTextBytes, fi.Length,
fi.Extension.TrimStart('.').ToLowerInvariant());
}
private static string DecodeUtf8(byte[] buf, int len)
{
// UTF-8 BOM 제거
int start = (len >= 3 && buf[0] == 0xEF && buf[1] == 0xBB && buf[2] == 0xBF) ? 3 : 0;
return new UTF8Encoding(false, false).GetString(buf, start, len - start);
}
// ── 원본 스트림 ─────────────────────────────────────────────
public DocRawResult OpenRaw(string? relPath)
{
var abs = SafeResolve(relPath, mustExist: true);
if (!File.Exists(abs)) throw new DocBrowserException("파일이 아닙니다");
var ext = Path.GetExtension(abs).TrimStart('.').ToLowerInvariant();
var stream = new FileStream(abs, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, true);
return new DocRawResult(stream, ContentType(ext), Path.GetFileName(abs), ext);
}
public static string ContentType(string ext) => ext switch
{
"pdf" => "application/pdf",
"txt" or "log" or "md" or "markdown" or "csv" or "ini" or "conf" or "cfg" => "text/plain; charset=utf-8",
"json" => "application/json; charset=utf-8",
"xml" => "application/xml; charset=utf-8",
"html" or "htm" => "text/html; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" or "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "application/octet-stream",
};
// ── 변경 작업 (admin) ──────────────────────────────────────
public void WriteText(string? relPath, string content)
{
var abs = SafeResolve(relPath);
if (Directory.Exists(abs)) throw new DocBrowserException("디렉토리에는 쓸 수 없습니다");
var bytes = new UTF8Encoding(false).GetBytes(content ?? "");
if (bytes.LongLength > _maxTextBytes) throw new DocBrowserException("저장 한도를 초과했습니다");
var parent = Path.GetDirectoryName(abs);
if (parent == null || !Directory.Exists(parent)) throw new DocBrowserException("상위 폴더가 없습니다");
File.WriteAllBytes(abs, bytes);
}
public string Rename(string? fromRel, string? toRel)
{
var from = SafeResolve(fromRel, mustExist: true);
var to = SafeResolve(toRel);
if (File.Exists(to) || Directory.Exists(to)) throw new DocBrowserException("대상이 이미 존재합니다");
var parent = Path.GetDirectoryName(to);
if (parent == null || !Directory.Exists(parent)) throw new DocBrowserException("대상 상위 폴더가 없습니다");
if (Directory.Exists(from)) Directory.Move(from, to);
else if (File.Exists(from)) File.Move(from, to);
else throw new DocBrowserException("원본이 없습니다");
return ToRel(to);
}
public void Delete(string? relPath, bool recursive)
{
var abs = SafeResolve(relPath, mustExist: true);
if (string.Equals(abs, _root, PathComparison)) throw new DocBrowserException("루트는 삭제할 수 없습니다");
if (Directory.Exists(abs))
{
if (!recursive && Directory.EnumerateFileSystemEntries(abs).Any())
throw new DocBrowserException("폴더가 비어있지 않습니다 (recursive 필요)");
Directory.Delete(abs, recursive);
}
else if (File.Exists(abs))
{
File.Delete(abs);
}
}
public string MakeDir(string? relPath)
{
var abs = SafeResolve(relPath);
if (File.Exists(abs)) throw new DocBrowserException("같은 이름의 파일이 있습니다");
Directory.CreateDirectory(abs);
return ToRel(abs);
}
public async Task<string> SaveUploadAsync(string? dirRel, string fileName, Stream input, CancellationToken ct)
{
var safeName = Path.GetFileName(fileName ?? "");
if (string.IsNullOrWhiteSpace(safeName)) throw new DocBrowserException("파일명이 필요합니다");
if (IsSensitiveFile(safeName)) throw new DocBrowserException("이 형식은 업로드할 수 없습니다");
var dirAbs = SafeResolve(dirRel, mustExist: true);
if (!Directory.Exists(dirAbs)) throw new DocBrowserException("대상 폴더가 없습니다");
var abs = SafeResolve(Path.Combine(ToRel(dirAbs), safeName));
if (File.Exists(abs)) throw new DocBrowserException("같은 이름의 파일이 이미 있습니다");
using var fs = new FileStream(abs, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, true);
var buffer = new byte[64 * 1024];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer.AsMemory(0, buffer.Length), ct)) > 0)
{
total += read;
if (total > _maxUploadBytes)
{
fs.Close();
try { File.Delete(abs); } catch { }
throw new DocBrowserException("업로드 한도를 초과했습니다");
}
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
}
return ToRel(abs);
}
}

View File

@@ -0,0 +1,172 @@
using ExperionCrawler.Infrastructure.Docs;
using ExperionCrawler.Infrastructure.Kb;
using Microsoft.AspNetCore.Mvc;
namespace ExperionCrawler.Web.Controllers;
/// <summary>
/// 프로젝트 폴더 문서 탐색기 API. 조회는 공개, 변경 작업(편집/이름변경/삭제/폴더/업로드)은
/// KB admin 토큰(X-Kb-Token) 필요. 경로 안전·제외 규칙은 DocBrowserService 가 강제한다.
/// </summary>
[ApiController]
[Route("api/docs")]
public class DocsController : ControllerBase
{
private readonly DocBrowserService _docs;
private readonly IKbAuthService _auth;
private readonly ILogger<DocsController> _logger;
public DocsController(DocBrowserService docs, IKbAuthService auth, ILogger<DocsController> logger)
{
_docs = docs;
_auth = auth;
_logger = logger;
}
private Task<bool> IsAdminAsync(CancellationToken ct)
=> _auth.ValidateAsync(Request.Headers["X-Kb-Token"].ToString(), ct);
private IActionResult Fail(string error) => Ok(new { success = false, error });
// ── 메타/설정 ───────────────────────────────────────────────
[HttpGet("config")]
public async Task<IActionResult> Config(CancellationToken ct)
=> Ok(new
{
success = true,
root = _docs.Root,
canManage = await IsAdminAsync(ct),
maxTextBytes = _docs.MaxTextBytes,
maxUploadBytes = _docs.MaxUploadBytes,
});
// ── 디렉토리 목록 ───────────────────────────────────────────
[HttpGet("tree")]
public IActionResult Tree([FromQuery] string? path)
{
try
{
var entries = _docs.List(path).Select(e => new
{
name = e.Name,
path = e.RelPath,
type = e.IsDir ? "dir" : "file",
size = e.Size,
mtime = e.ModifiedUtc,
ext = e.Ext,
});
return Ok(new { success = true, path = path ?? "", entries });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
// ── 텍스트 읽기 ─────────────────────────────────────────────
[HttpGet("text")]
public IActionResult Text([FromQuery] string path)
{
try
{
var r = _docs.ReadText(path);
return Ok(new { success = true, text = r.Text, truncated = r.Truncated, size = r.Size, ext = r.Ext });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
// ── 원본 스트림 (pdf iframe / 다운로드) ─────────────────────
[HttpGet("raw")]
public IActionResult Raw([FromQuery] string path, [FromQuery] bool download = false)
{
try
{
var r = _docs.OpenRaw(path);
if (download)
return File(r.Stream, "application/octet-stream", r.FileName);
return File(r.Stream, r.ContentType); // inline (pdf 등)
}
catch (DocBrowserException ex) { return NotFound(new { success = false, error = ex.Message }); }
}
// ── 텍스트 저장 (admin) ─────────────────────────────────────
public sealed record WriteRequest(string Path, string Content);
[HttpPut("text")]
[RequestSizeLimit(8_000_000)]
public async Task<IActionResult> WriteText([FromBody] WriteRequest req, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required");
try
{
_docs.WriteText(req.Path, req.Content ?? "");
_logger.LogInformation("[Docs] 저장 {Path}", req.Path);
return Ok(new { success = true });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
// ── 이름변경/이동 (admin) ───────────────────────────────────
public sealed record RenameRequest(string From, string To);
[HttpPost("rename")]
public async Task<IActionResult> Rename([FromBody] RenameRequest req, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (req == null || string.IsNullOrEmpty(req.From) || string.IsNullOrEmpty(req.To))
return Fail("from/to required");
try
{
var newRel = _docs.Rename(req.From, req.To);
_logger.LogInformation("[Docs] 이름변경 {From} → {To}", req.From, newRel);
return Ok(new { success = true, path = newRel });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
// ── 새 폴더 (admin) ─────────────────────────────────────────
public sealed record MkdirRequest(string Path);
[HttpPost("mkdir")]
public async Task<IActionResult> Mkdir([FromBody] MkdirRequest req, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (req == null || string.IsNullOrEmpty(req.Path)) return Fail("path required");
try
{
var rel = _docs.MakeDir(req.Path);
return Ok(new { success = true, path = rel });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
// ── 삭제 (admin) ────────────────────────────────────────────
[HttpDelete]
public async Task<IActionResult> Delete([FromQuery] string path, [FromQuery] bool recursive, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (string.IsNullOrEmpty(path)) return Fail("path required");
try
{
_docs.Delete(path, recursive);
_logger.LogInformation("[Docs] 삭제 {Path} (recursive={R})", path, recursive);
return Ok(new { success = true });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
// ── 업로드 (admin) ──────────────────────────────────────────
[HttpPost("upload")]
[RequestSizeLimit(100_000_000)]
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string? path, CancellationToken ct)
{
if (!await IsAdminAsync(ct)) return Unauthorized(new { success = false, error = "unauthorized" });
if (file == null || file.Length == 0) return Fail("file required");
try
{
await using var stream = file.OpenReadStream();
var rel = await _docs.SaveUploadAsync(path, file.FileName, stream, ct);
_logger.LogInformation("[Docs] 업로드 {Path} ({Size} bytes)", rel, file.Length);
return Ok(new { success = true, path = rel });
}
catch (DocBrowserException ex) { return Fail(ex.Message); }
}
}

View File

@@ -140,6 +140,7 @@ builder.Services.AddSingleton<KbQdrantClient>();
builder.Services.AddSingleton<KbStorageService>();
builder.Services.AddSingleton<KbEmbeddingClient>();
builder.Services.AddScoped<IKbAuthService, KbAuthService>();
builder.Services.AddSingleton<ExperionCrawler.Infrastructure.Docs.DocBrowserService>();
builder.Services.AddHostedService<KbStartupService>();
builder.Services.AddHostedService<KbIngestWorker>();

View File

@@ -51,6 +51,11 @@
"WorkingDirectory": "../../mcp-server"
},
"PromptsDirectory": "../../prompts",
"DocBrowser": {
"Root": "",
"MaxTextBytes": 2097152,
"MaxUploadBytes": 52428800
},
"Kb": {
"QdrantUrl": "http://localhost:6333",
"VectorSize": 768,

View File

@@ -0,0 +1,370 @@
/* ════════════════════════════════════════════════════════════
문서 탐색기 (Tab 16) — 다크 트리 + 흰색 종이 뷰어
════════════════════════════════════════════════════════════ */
/* 헤더 컴팩트화: 제목 위로 + 부제목을 제목 옆으로 → 문서 영역 확보 */
#pane-docs .pane-hdr { margin-bottom: 10px; }
#pane-docs .pane-hdr > div:first-child {
display: flex;
align-items: baseline;
gap: 14px;
flex-wrap: wrap;
}
#pane-docs .pane-hdr h1 { margin-bottom: 0; }
#pane-docs .pane-hdr p { margin-bottom: 0; }
.docs-layout {
display: flex;
gap: 0;
height: calc(100vh - 116px);
min-height: 560px;
border: 1px solid var(--bd);
border-radius: var(--r);
overflow: hidden;
background: var(--s1);
}
/* ── 좌: 파일 트리 패널 (다크) ─────────────────────────────── */
.docs-tree-panel {
width: 300px;
flex-shrink: 0;
background: var(--s2);
border-right: 1px solid var(--bd);
display: flex;
flex-direction: column;
min-height: 0;
}
.docs-tree-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--bd);
}
.docs-tree-toolbar .docs-filter {
flex: 1;
min-width: 0;
height: 30px;
font-size: 12px;
padding: 4px 8px;
}
.docs-root-line {
padding: 5px 10px;
font-size: 10px;
color: var(--t2);
border-bottom: 1px solid var(--bd);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl; /* 긴 경로는 앞쪽을 ... 으로 */
text-align: left;
}
.docs-tree {
flex: 1;
overflow: auto;
padding: 6px 4px;
min-height: 0;
}
.docs-admin-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--bd);
}
.docs-admin-state { font-size: 11px; color: var(--t1); }
/* ── 트리 노드 ─────────────────────────────────────────────── */
.docs-ul, .docs-children { list-style: none; margin: 0; padding: 0; }
.docs-li { margin: 0; }
.docs-node {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 5px;
cursor: pointer;
color: var(--t1);
font-size: 12.5px;
user-select: none;
white-space: nowrap;
}
.docs-node:hover { background: var(--s4); color: var(--t0); }
.docs-node.active { background: var(--ag); color: var(--blu); }
.docs-node-ic {
flex-shrink: 0;
width: 16px;
text-align: center;
font-size: 11px;
transition: transform .15s ease;
}
.docs-node.is-dir > .docs-node-ic { color: var(--t2); }
.docs-node.is-dir.open > .docs-node-ic { transform: rotate(90deg); }
.docs-node-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-node-size {
flex-shrink: 0;
font-family: var(--fm);
font-size: 9.5px;
color: var(--t2);
}
.docs-node-act { display: none; flex-shrink: 0; gap: 2px; }
.docs-tree.can-manage .docs-node:hover .docs-node-act { display: flex; }
.docs-node-act button {
background: transparent;
border: none;
color: var(--t2);
cursor: pointer;
font-size: 11px;
padding: 0 3px;
border-radius: 4px;
}
.docs-node-act button:hover { color: var(--t0); background: rgba(255,255,255,.08); }
.docs-act-delete:hover { color: var(--red) !important; }
.docs-loading, .docs-error { padding: 8px 12px; font-size: 12px; color: var(--t2); }
.docs-error { color: var(--red); white-space: pre-wrap; }
/* ── 우: 뷰어 패널 ─────────────────────────────────────────── */
.docs-viewer-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.docs-viewer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 14px;
background: var(--s2);
border-bottom: 1px solid var(--bd);
}
.docs-cur-path {
font-size: 11.5px;
color: var(--t1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
.docs-viewer-actions { display: flex; gap: 6px; flex-shrink: 0; }
.docs-viewer-actions a.btn-b, .docs-viewer-actions a.btn-a { text-decoration: none; }
/* ── 뷰어 본문: 흰색 종이 ──────────────────────────────────── */
.docs-viewer {
flex: 1;
overflow: auto;
background: #ffffff;
color: #24292f;
min-height: 0;
}
.docs-viewer.mode-pdf { overflow: hidden; }
.docs-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #8b949e;
font-size: 14px;
}
.docs-trunc {
background: #fff8e6;
color: #7a5b00;
border-bottom: 1px solid #f0d98c;
padding: 8px 16px;
font-size: 12px;
}
/* 텍스트 뷰어 */
.docs-text-pre {
margin: 0;
padding: 20px 24px;
font-family: var(--fm);
font-size: 13px;
line-height: 1.65;
color: #24292f;
white-space: pre-wrap;
word-break: break-word;
}
/* PDF 뷰어 */
.docs-pdf-frame { width: 100%; height: 100%; border: 0; background: #fff; }
/* 미지원 */
.docs-unsupported {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #57606a;
text-align: center;
}
.docs-unsupported-ic { font-size: 48px; }
.docs-unsupported .docs-muted { color: #8b949e; font-size: 12px; }
.docs-unsupported a { margin-top: 8px; text-decoration: none; }
/* ── 마크다운 본문 (GitHub-라이트 타이포) ──────────────────── */
.md-body {
padding: 28px 36px;
max-width: 960px;
margin: 0 auto;
color: #24292f;
font-size: 15px;
line-height: 1.7;
word-wrap: break-word;
}
.md-body > *:first-child { margin-top: 0; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 {
margin: 26px 0 14px;
font-weight: 700;
line-height: 1.3;
color: #1f2328;
}
.md-body h1 { font-size: 1.9em; padding-bottom: .3em; border-bottom: 1px solid #d8dee4; }
.md-body h2 { font-size: 1.5em; padding-bottom: .3em; border-bottom: 1px solid #d8dee4; }
.md-body h3 { font-size: 1.25em; }
.md-body h4 { font-size: 1.05em; }
.md-body p { margin: 0 0 14px; }
.md-body a { color: #0969da; text-decoration: none; }
.md-body a:hover { text-decoration: underline; }
.md-body ul, .md-body ol { margin: 0 0 14px; padding-left: 2em; }
.md-body li { margin: 3px 0; }
.md-body li > p { margin: 4px 0; }
.md-body blockquote {
margin: 0 0 14px;
padding: 2px 16px;
color: #57606a;
border-left: 4px solid #d0d7de;
}
.md-body hr { height: 1px; border: 0; background: #d8dee4; margin: 24px 0; }
.md-body img { max-width: 100%; }
/* 인라인 코드 */
.md-body code {
font-family: var(--fm);
font-size: 85%;
background: rgba(175,184,193,.2);
padding: .2em .4em;
border-radius: 6px;
}
/* 코드블록 (highlight.js github 테마가 색 담당) */
.md-body pre {
margin: 0 0 16px;
padding: 14px 16px;
background: #f6f8fa;
border: 1px solid #e4e8ec;
border-radius: 8px;
overflow: auto;
line-height: 1.5;
}
.md-body pre code {
background: transparent;
padding: 0;
font-size: 13px;
border-radius: 0;
}
/* GFM 표 */
.md-body table {
border-collapse: collapse;
margin: 0 0 16px;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
}
.md-body th, .md-body td {
border: 1px solid #d0d7de;
padding: 6px 13px;
}
.md-body th { background: #f6f8fa; font-weight: 700; }
.md-body tr:nth-child(2n) td { background: #f6f8fa; }
/* 체크박스 리스트 */
.md-body input[type="checkbox"] { margin-right: 6px; }
/* mermaid */
.md-body .mermaid { margin: 0 0 16px; text-align: center; }
.md-body .mermaid svg { max-width: 100%; height: auto; }
/* ── 편집 모드 ─────────────────────────────────────────────── */
.docs-viewer.mode-edit { background: #ffffff; }
.docs-edit-wrap {
display: flex;
height: 100%;
min-height: 0;
}
.docs-edit-ta {
flex: 1;
border: 0;
outline: none;
resize: none;
padding: 20px 24px;
font-family: var(--fm);
font-size: 13px;
line-height: 1.65;
color: #24292f;
background: #ffffff;
min-width: 0;
}
.docs-viewer.mode-edit.is-md .docs-edit-ta {
border-right: 1px solid #d8dee4;
flex: 0 0 50%;
}
.docs-edit-prev {
flex: 1;
overflow: auto;
padding: 20px 28px;
max-width: none;
margin: 0;
background: #fbfcfd;
min-width: 0;
}
/* ── 토스트 ────────────────────────────────────────────────── */
.docs-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--s3);
color: var(--t0);
border: 1px solid var(--bd2);
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
opacity: 0;
pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
z-index: 1000;
}
.docs-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* 좁은 화면 */
@media (max-width: 900px) {
.docs-tree-panel { width: 200px; }
.docs-viewer.mode-edit.is-md .docs-edit-ta { flex: 0 0 100%; }
.docs-edit-prev { display: none; }
}

View File

@@ -68,10 +68,11 @@ html, body { height: 100%; background: var(--s0); color: var(--t1); font-family:
}
/* nav */
.nav { list-style: none; padding: 14px 10px; flex: 1; display: flex; flex-direction: column; gap: 3px; }
.nav { list-style: none; padding: 14px 10px; flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
flex-shrink: 0;
padding: 10px 12px; border-radius: var(--r);
cursor: pointer; transition: all var(--tr);
color: var(--t2); font-size: 13px; font-weight: 600;

View File

@@ -6,6 +6,7 @@
<title>ExperionCrawler</title>
<link rel="stylesheet" href="/css/style.css"/>
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
<link rel="stylesheet" href="/css/docs.css"/>
</head>
<body>
<div class="shell">
@@ -92,6 +93,10 @@
<span class="ni">15</span>
<span class="nl">OPC UA Write</span>
</li>
<li class="nav-item" data-tab="docs">
<span class="ni">16</span>
<span class="nl">문서 탐색기</span>
</li>
</ul>
<div class="sb-foot">
@@ -1623,6 +1628,49 @@
<div id="wr-log" class="logbox hidden"></div>
</section>
<!-- ══════════════════════════════════════════════════════
16 문서 탐색기
═══════════════════════════════════════════════════════ -->
<section class="pane" id="pane-docs">
<header class="pane-hdr">
<div>
<h1>문서 탐색기</h1>
<p>프로젝트 폴더의 문서를 직접 보고 편집합니다. 뷰어: txt · md · pdf · 그 외 다운로드.</p>
</div>
<div class="pane-tag">DOCS</div>
</header>
<div class="docs-layout">
<!-- 좌: 파일 트리 -->
<aside class="docs-tree-panel">
<div class="docs-tree-toolbar">
<button class="btn-b btn-sm" onclick="docsRefresh()" title="새로고침"></button>
<input id="docs-filter" class="inp docs-filter" placeholder="이름 필터…" oninput="docsApplyFilter()"/>
<button class="btn-b btn-sm docs-admin-only" id="docs-mkdir-btn" onclick="docsMkdirPrompt()" title="새 폴더" hidden>📁+</button>
<button class="btn-b btn-sm docs-admin-only" id="docs-upload-btn" onclick="docsUploadPrompt()" title="업로드" hidden></button>
<input type="file" id="docs-upload-input" hidden onchange="docsUploadDo()"/>
</div>
<div class="docs-root-line" title="탐색 루트"><span id="docs-root-label" class="mono"></span></div>
<div id="docs-tree" class="docs-tree"></div>
<div class="docs-admin-bar">
<span id="docs-admin-state" class="docs-admin-state">🔒 읽기 전용</span>
<button class="btn-b btn-sm" id="docs-unlock-btn" onclick="docsUnlock()">관리 잠금해제</button>
</div>
</aside>
<!-- 우: 뷰어 -->
<div class="docs-viewer-panel">
<div class="docs-viewer-bar">
<span id="docs-cur-path" class="docs-cur-path mono">파일을 선택하세요</span>
<div class="docs-viewer-actions" id="docs-viewer-actions"></div>
</div>
<div id="docs-viewer" class="docs-viewer">
<div class="docs-empty">← 왼쪽에서 문서를 선택하세요</div>
</div>
</div>
</div>
</section>
</main>
</div>
</div>
@@ -1708,5 +1756,6 @@
<script src="/lib/uPlot.iife.min.js"></script>
<script src="/js/xlsx.full.min.js"></script>
<script src="/js/app.js"></script>
<script src="/js/docs.js"></script>
</body>
</html>

View File

@@ -15,6 +15,7 @@ document.querySelectorAll('.nav-item').forEach(item => {
if (tab === 'opcsvr') srvLoad();
if (tab === 't2s') t2sInitMode();
if (tab === 'fast') fastSessionsLoad();
if (tab === 'docs') docsInit();
});
});

571
src/Web/wwwroot/js/docs.js Normal file
View File

@@ -0,0 +1,571 @@
/* ════════════════════════════════════════════════════════════════
문서 탐색기 (Tab 16) — 프로젝트 폴더 트리 탐색 + txt/md/pdf 뷰어 + 인라인 편집/관리
백엔드: /api/docs/* (변경 작업은 X-Kb-Token admin)
esc() / kbToken 은 app.js(선로딩) 전역 재사용
════════════════════════════════════════════════════════════════ */
const DOCS_TEXT_EXT = ['txt', 'log', 'text'];
const DOCS_MD_EXT = ['md', 'markdown'];
const docsState = {
inited: false,
canManage: false,
root: '',
curFile: null, // { path, ext }
editing: false,
};
/* ── 진입 ──────────────────────────────────────────────────── */
async function docsInit() {
await docsLoadConfig();
if (!docsState.inited) {
docsState.inited = true;
document.getElementById('docs-tree').addEventListener('click', docsTreeClick);
await docsRefresh();
}
}
async function docsLoadConfig() {
try {
const r = await fetch('/api/docs/config', { headers: docsHeaders() });
const d = await r.json();
docsState.canManage = !!d.canManage;
docsState.root = d.root || '';
document.getElementById('docs-root-label').textContent = d.root || '';
docsUpdateAdminUi();
} catch (e) { /* noop */ }
}
/* ── 인증 헤더 (KB admin 토큰 재사용) ───────────────────────── */
function docsToken() { return sessionStorage.getItem('kbToken') || ''; }
function docsHeaders(extra) {
const h = extra || {};
const t = docsToken();
if (t) h['X-Kb-Token'] = t;
return h;
}
/* ── 관리 UI 토글 ──────────────────────────────────────────── */
function docsUpdateAdminUi() {
const on = docsState.canManage;
document.getElementById('docs-mkdir-btn').hidden = !on;
document.getElementById('docs-upload-btn').hidden = !on;
document.getElementById('docs-tree').classList.toggle('can-manage', on);
document.getElementById('docs-admin-state').textContent = on ? '🔓 관리 가능' : '🔒 읽기 전용';
document.getElementById('docs-unlock-btn').textContent = on ? '잠금' : '관리 잠금해제';
if (docsState.curFile) docsRenderViewerActions();
}
async function docsUnlock() {
if (docsState.canManage) {
try { await fetch('/api/kb/auth/logout', { method: 'POST', headers: docsHeaders() }); } catch (e) {}
sessionStorage.removeItem('kbToken');
try { kbToken = ''; } catch (e) {}
await docsLoadConfig();
return;
}
const pw = prompt('관리자 비밀번호');
if (!pw) return;
try {
const r = await fetch('/api/kb/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
const d = await r.json();
if (!d.success) { alert('로그인 실패: ' + (d.error || '')); return; }
sessionStorage.setItem('kbToken', d.token);
try { kbToken = d.token; } catch (e) {}
await docsLoadConfig();
docsToast('관리 잠금해제됨');
} catch (e) { alert('로그인 오류: ' + e.message); }
}
/* ── 트리 ──────────────────────────────────────────────────── */
async function docsFetchTree(path) {
const r = await fetch('/api/docs/tree?path=' + encodeURIComponent(path || ''), { headers: docsHeaders() });
const d = await r.json();
if (!d.success) throw new Error(d.error || '목록 조회 실패');
return d.entries;
}
async function docsRefresh() {
const host = document.getElementById('docs-tree');
host.innerHTML = '<div class="docs-loading">불러오는 중…</div>';
try {
const entries = await docsFetchTree('');
const ul = document.createElement('ul');
ul.className = 'docs-ul';
docsRenderInto(ul, entries, 0);
host.innerHTML = '';
host.appendChild(ul);
docsApplyFilter();
} catch (e) {
host.innerHTML = '<div class="docs-error">' + esc(e.message) + '</div>';
}
}
function docsRenderInto(ul, entries, depth) {
for (const e of entries) {
const li = document.createElement('li');
li.className = 'docs-li';
li.appendChild(docsNodeRow(e, depth));
if (e.type === 'dir') {
const kids = document.createElement('ul');
kids.className = 'docs-children';
kids.hidden = true;
li.appendChild(kids);
}
ul.appendChild(li);
}
}
function docsNodeRow(entry, depth) {
const row = document.createElement('div');
row.className = 'docs-node ' + (entry.type === 'dir' ? 'is-dir' : 'is-file');
row.style.paddingLeft = (8 + depth * 14) + 'px';
row.dataset.path = entry.path;
row.dataset.type = entry.type;
row.dataset.name = (entry.name || '').toLowerCase();
row.dataset.ext = entry.ext || '';
row.dataset.depth = depth;
const ic = entry.type === 'dir' ? '▸' : docsFileIcon(entry.ext);
const size = entry.type === 'file' ? `<span class="docs-node-size">${docsFmtSize(entry.size)}</span>` : '';
const acts = `<span class="docs-node-act">
<button class="docs-act-rename" title="이름변경">✎</button>
<button class="docs-act-delete" title="삭제">🗑</button>
</span>`;
row.innerHTML =
`<span class="docs-node-ic">${ic}</span>` +
`<span class="docs-node-name">${esc(entry.name)}</span>` +
size + acts;
return row;
}
async function docsTreeClick(ev) {
const renameBtn = ev.target.closest('.docs-act-rename');
const deleteBtn = ev.target.closest('.docs-act-delete');
const row = ev.target.closest('.docs-node');
if (!row) return;
if (renameBtn) { ev.stopPropagation(); return docsRenamePrompt(row.dataset.path); }
if (deleteBtn) { ev.stopPropagation(); return docsDeletePrompt(row.dataset.path, row.dataset.type); }
if (row.dataset.type === 'dir') return docsToggleDir(row);
// 파일
document.querySelectorAll('#docs-tree .docs-node.active').forEach(n => n.classList.remove('active'));
row.classList.add('active');
docsOpenPath(row.dataset.path);
}
async function docsToggleDir(row) {
const li = row.parentElement;
const kids = li.querySelector(':scope > .docs-children');
if (!kids) return;
if (kids.hidden) {
if (kids.dataset.loaded !== '1') {
kids.innerHTML = '<li class="docs-loading">…</li>';
try {
const entries = await docsFetchTree(row.dataset.path);
kids.innerHTML = '';
docsRenderInto(kids, entries, (+row.dataset.depth) + 1);
kids.dataset.loaded = '1';
docsApplyFilter();
} catch (e) {
kids.innerHTML = '<li class="docs-error">' + esc(e.message) + '</li>';
}
}
kids.hidden = false;
row.classList.add('open');
} else {
kids.hidden = true;
row.classList.remove('open');
}
}
/* ── 파일 열기 / 뷰어 디스패치 ─────────────────────────────── */
function docsOpenPath(path) {
const ext = (path.split('.').pop() || '').toLowerCase();
docsState.curFile = { path, ext };
docsState.editing = false;
document.getElementById('docs-cur-path').textContent = path;
docsRenderViewerActions();
if (DOCS_MD_EXT.includes(ext)) docsViewMarkdown(path);
else if (ext === 'pdf') docsViewPdf(path);
else if (DOCS_TEXT_EXT.includes(ext)) docsViewText(path);
else docsViewUnsupported(path, ext);
}
async function docsFetchText(path) {
const r = await fetch('/api/docs/text?path=' + encodeURIComponent(path), { headers: docsHeaders() });
const d = await r.json();
if (!d.success) throw new Error(d.error || '파일 읽기 실패');
return d;
}
function docsViewer() { return document.getElementById('docs-viewer'); }
function docsViewerError(err) {
docsViewer().className = 'docs-viewer';
docsViewer().innerHTML = '<div class="docs-error">' + esc(err.message || String(err)) + '</div>';
}
function docsTruncBanner(size) {
const b = document.createElement('div');
b.className = 'docs-trunc';
b.textContent = `⚠ 파일이 커서 일부만 표시합니다 (전체 ${docsFmtSize(size)}). 다운로드로 전체 확인하세요.`;
return b;
}
async function docsViewText(path) {
const v = docsViewer();
v.className = 'docs-viewer mode-text';
v.innerHTML = '<div class="docs-loading">불러오는 중…</div>';
try {
const d = await docsFetchText(path);
v.innerHTML = '';
if (d.truncated) v.appendChild(docsTruncBanner(d.size));
const pre = document.createElement('pre');
pre.className = 'docs-text-pre';
pre.textContent = d.text;
v.appendChild(pre);
} catch (e) { docsViewerError(e); }
}
function docsViewPdf(path) {
const v = docsViewer();
v.className = 'docs-viewer mode-pdf';
const src = '/api/docs/raw?path=' + encodeURIComponent(path);
v.innerHTML = `<iframe class="docs-pdf-frame" src="${src}"></iframe>`;
}
async function docsViewMarkdown(path) {
const v = docsViewer();
v.className = 'docs-viewer mode-md';
v.innerHTML = '<div class="docs-loading">렌더링 준비 중…</div>';
try {
await docsEnsureMdLibs();
const d = await docsFetchText(path);
v.innerHTML = '';
if (d.truncated) v.appendChild(docsTruncBanner(d.size));
const body = document.createElement('div');
body.className = 'md-body';
v.appendChild(body);
docsRenderMarkdownInto(body, d.text);
} catch (e) { docsViewerError(e); }
}
function docsViewUnsupported(path, ext) {
const v = docsViewer();
v.className = 'docs-viewer mode-unsupported';
v.innerHTML = `<div class="docs-unsupported">
<div class="docs-unsupported-ic">${docsFileIcon(ext)}</div>
<p><b>.${esc(ext || '?')}</b> 형식은 미리보기를 지원하지 않습니다.</p>
<p class="docs-muted">지원 뷰어: txt · md · pdf</p>
<a class="btn-a btn-sm" href="/api/docs/raw?path=${encodeURIComponent(path)}&download=true">⬇ 다운로드</a>
</div>`;
}
/* ── 마크다운 렌더 파이프라인 ──────────────────────────────── */
function docsRenderMarkdownInto(el, text) {
const rawHtml = marked.parse(text, { gfm: true, breaks: false });
el.innerHTML = DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['details', 'summary'], ADD_ATTR: ['target'] });
docsExternalLinks(el);
docsExtractMermaid(el); // mermaid 코드블록 → div.mermaid (highlight 전)
docsHighlight(el);
docsRenderMath(el);
docsRunMermaid(el);
}
function docsExternalLinks(el) {
el.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href') || '';
if (/^https?:/i.test(href)) {
a.target = '_blank'; a.rel = 'noopener noreferrer';
} else if (href && !href.startsWith('#') && docsState.curFile) {
// 상대 링크 → 탐색기 내에서 열기
const rel = docsResolveRel(docsState.curFile.path, href);
if (rel != null) {
a.addEventListener('click', (ev) => { ev.preventDefault(); docsOpenPath(rel); });
}
}
});
}
function docsResolveRel(basePath, href) {
try {
const clean = href.split('#')[0].split('?')[0];
if (!clean) return null;
const baseDir = basePath.includes('/') ? basePath.slice(0, basePath.lastIndexOf('/')) : '';
const parts = (baseDir ? baseDir.split('/') : []).concat(clean.split('/'));
const stack = [];
for (const p of parts) {
if (p === '' || p === '.') continue;
if (p === '..') stack.pop();
else stack.push(p);
}
return stack.join('/');
} catch (e) { return null; }
}
function docsExtractMermaid(el) {
el.querySelectorAll('code.language-mermaid').forEach(code => {
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = code.textContent;
(code.closest('pre') || code).replaceWith(div);
});
}
function docsHighlight(el) {
if (typeof hljs === 'undefined') return;
el.querySelectorAll('pre code').forEach(c => { try { hljs.highlightElement(c); } catch (e) {} });
}
function docsRenderMath(el) {
if (typeof renderMathInElement === 'undefined') return;
try {
renderMathInElement(el, {
delimiters: [
{ left: '$$', right: '$$', display: true },
{ left: '$', right: '$', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '\\(', right: '\\)', display: false },
],
throwOnError: false,
});
} catch (e) {}
}
async function docsRunMermaid(el) {
const nodes = el.querySelectorAll('.mermaid');
if (!nodes.length || typeof mermaid === 'undefined') return;
try { await mermaid.run({ nodes }); } catch (e) { /* 실패 시 원문 텍스트 유지 */ }
}
/* ── 라이브러리 lazy 로더 (md 첫 진입 시 1회) ──────────────── */
let _docsMdLibs = null;
function docsLoadScript(src) {
return new Promise((res, rej) => {
const s = document.createElement('script');
s.src = src; s.onload = res; s.onerror = () => rej(new Error('스크립트 로드 실패: ' + src));
document.head.appendChild(s);
});
}
function docsLoadCss(href) {
return new Promise((res) => {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = href; l.onload = res; l.onerror = res;
document.head.appendChild(l);
});
}
function docsEnsureMdLibs() {
if (_docsMdLibs) return _docsMdLibs;
_docsMdLibs = (async () => {
docsLoadCss('/lib/highlight-github.min.css');
docsLoadCss('/lib/katex/katex.min.css');
await docsLoadScript('/lib/marked.min.js');
await docsLoadScript('/lib/purify.min.js');
await docsLoadScript('/lib/highlight.min.js');
await docsLoadScript('/lib/katex/katex.min.js');
await docsLoadScript('/lib/katex/auto-render.min.js');
await docsLoadScript('/lib/mermaid.min.js');
try { mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict' }); } catch (e) {}
})();
return _docsMdLibs;
}
/* ── 뷰어 툴바 액션 ────────────────────────────────────────── */
function docsRenderViewerActions() {
const host = document.getElementById('docs-viewer-actions');
const f = docsState.curFile;
if (!f) { host.innerHTML = ''; return; }
const isText = DOCS_TEXT_EXT.includes(f.ext) || DOCS_MD_EXT.includes(f.ext);
let h = '';
if (docsState.editing) {
h += `<button class="btn-a btn-sm" onclick="docsEditSave()">💾 저장</button>`;
h += `<button class="btn-b btn-sm" onclick="docsEditCancel()">취소</button>`;
} else {
if (isText && docsState.canManage) h += `<button class="btn-b btn-sm" onclick="docsEditStart()">✎ 편집</button>`;
h += `<a class="btn-b btn-sm" href="/api/docs/raw?path=${encodeURIComponent(f.path)}&download=true">⬇ 다운로드</a>`;
}
host.innerHTML = h;
}
/* ── 인라인 편집 ───────────────────────────────────────────── */
async function docsEditStart() {
const f = docsState.curFile;
if (!f || !docsState.canManage) return;
let d;
try { d = await docsFetchText(f.path); } catch (e) { alert(e.message); return; }
docsState.editing = true;
docsRenderViewerActions();
const isMd = DOCS_MD_EXT.includes(f.ext);
const v = docsViewer();
v.className = 'docs-viewer mode-edit' + (isMd ? ' is-md' : '');
v.innerHTML =
`<div class="docs-edit-wrap">
<textarea id="docs-edit-ta" class="docs-edit-ta" spellcheck="false"></textarea>
${isMd ? '<div id="docs-edit-prev" class="md-body docs-edit-prev"></div>' : ''}
</div>`;
const ta = document.getElementById('docs-edit-ta');
ta.value = d.text;
if (isMd) {
await docsEnsureMdLibs();
const prev = document.getElementById('docs-edit-prev');
const upd = docsDebounce(() => docsRenderMarkdownInto(prev, ta.value), 250);
ta.addEventListener('input', upd);
docsRenderMarkdownInto(prev, ta.value);
}
}
async function docsEditSave() {
const f = docsState.curFile;
const ta = document.getElementById('docs-edit-ta');
if (!f || !ta) return;
try {
const r = await fetch('/api/docs/text', {
method: 'PUT',
headers: docsHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path: f.path, content: ta.value }),
});
const d = await r.json();
if (!d.success) { alert('저장 실패: ' + (d.error || r.status)); return; }
docsState.editing = false;
docsOpenPath(f.path);
docsToast('저장됨');
} catch (e) { alert('저장 오류: ' + e.message); }
}
function docsEditCancel() {
docsState.editing = false;
if (docsState.curFile) docsOpenPath(docsState.curFile.path);
}
/* ── 관리: 새 폴더 / 업로드 / 이름변경 / 삭제 ──────────────── */
async function docsMkdirPrompt() {
const path = prompt('새 폴더 경로 (루트 기준, 예: docs/notes)');
if (!path) return;
try {
const r = await fetch('/api/docs/mkdir', {
method: 'POST', headers: docsHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ path }),
});
const d = await r.json();
if (!d.success) { alert('실패: ' + (d.error || '')); return; }
await docsRefresh();
docsToast('폴더 생성됨');
} catch (e) { alert(e.message); }
}
function docsUploadPrompt() {
document.getElementById('docs-upload-input').click();
}
async function docsUploadDo() {
const input = document.getElementById('docs-upload-input');
const file = input.files && input.files[0];
if (!file) return;
const dir = prompt('업로드할 대상 폴더 (루트 기준, 비우면 루트)', '') || '';
const fd = new FormData();
fd.append('file', file);
fd.append('path', dir);
try {
const r = await fetch('/api/docs/upload', { method: 'POST', headers: docsHeaders(), body: fd });
const d = await r.json();
input.value = '';
if (!d.success) { alert('업로드 실패: ' + (d.error || '')); return; }
await docsRefresh();
docsToast('업로드됨: ' + d.path);
} catch (e) { input.value = ''; alert(e.message); }
}
async function docsRenamePrompt(path) {
if (!docsState.canManage) return;
const next = prompt('새 경로 (루트 기준)', path);
if (!next || next === path) return;
try {
const r = await fetch('/api/docs/rename', {
method: 'POST', headers: docsHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ from: path, to: next }),
});
const d = await r.json();
if (!d.success) { alert('실패: ' + (d.error || '')); return; }
await docsRefresh();
docsToast('이름 변경됨');
} catch (e) { alert(e.message); }
}
async function docsDeletePrompt(path, type) {
if (!docsState.canManage) return;
const isDir = type === 'dir';
const msg = isDir
? `폴더와 그 안의 모든 내용을 삭제합니다:\n${path}\n\n되돌릴 수 없습니다. 계속할까요?`
: `파일을 삭제합니다:\n${path}\n\n계속할까요?`;
if (!confirm(msg)) return;
try {
const url = '/api/docs?path=' + encodeURIComponent(path) + (isDir ? '&recursive=true' : '');
const r = await fetch(url, { method: 'DELETE', headers: docsHeaders() });
const d = await r.json();
if (!d.success) { alert('삭제 실패: ' + (d.error || '')); return; }
if (docsState.curFile && docsState.curFile.path === path) {
docsState.curFile = null;
document.getElementById('docs-cur-path').textContent = '파일을 선택하세요';
document.getElementById('docs-viewer-actions').innerHTML = '';
docsViewer().className = 'docs-viewer';
docsViewer().innerHTML = '<div class="docs-empty">← 왼쪽에서 문서를 선택하세요</div>';
}
await docsRefresh();
docsToast('삭제됨');
} catch (e) { alert(e.message); }
}
/* ── 필터 ──────────────────────────────────────────────────── */
function docsApplyFilter() {
const q = (document.getElementById('docs-filter').value || '').toLowerCase().trim();
document.querySelectorAll('#docs-tree .docs-li').forEach(li => {
const row = li.querySelector(':scope > .docs-node');
if (!row) return;
if (row.dataset.type === 'dir') { li.style.display = ''; return; }
li.style.display = (!q || row.dataset.name.includes(q)) ? '' : 'none';
});
}
/* ── 유틸 ──────────────────────────────────────────────────── */
function docsFmtSize(n) {
if (n == null) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function docsFileIcon(ext) {
ext = (ext || '').toLowerCase();
if (DOCS_MD_EXT.includes(ext)) return '📝';
if (ext === 'pdf') return '📕';
if (DOCS_TEXT_EXT.includes(ext)) return '📄';
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) return '🖼';
if (['xlsx', 'xls', 'csv'].includes(ext)) return '📊';
if (['docx', 'doc'].includes(ext)) return '📘';
if (['json', 'xml', 'yaml', 'yml'].includes(ext)) return '🔧';
if (['dxf', 'dwg'].includes(ext)) return '📐';
return '📄';
}
function docsDebounce(fn, ms) {
let t;
return function (...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), ms); };
}
let _docsToastTimer = null;
function docsToast(msg) {
let el = document.getElementById('docs-toast');
if (!el) {
el = document.createElement('div');
el.id = 'docs-toast';
el.className = 'docs-toast';
document.body.appendChild(el);
}
el.textContent = msg;
el.classList.add('show');
clearTimeout(_docsToastTimer);
_docsToastTimer = setTimeout(() => el.classList.remove('show'), 2200);
}