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:
53
CLAUDE.md
53
CLAUDE.md
@@ -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)
|
||||
|
||||
#### 배경
|
||||
|
||||
350
src/Infrastructure/Docs/DocBrowserService.cs
Normal file
350
src/Infrastructure/Docs/DocBrowserService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
172
src/Web/Controllers/DocsController.cs
Normal file
172
src/Web/Controllers/DocsController.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@
|
||||
"WorkingDirectory": "../../mcp-server"
|
||||
},
|
||||
"PromptsDirectory": "../../prompts",
|
||||
"DocBrowser": {
|
||||
"Root": "",
|
||||
"MaxTextBytes": 2097152,
|
||||
"MaxUploadBytes": 52428800
|
||||
},
|
||||
"Kb": {
|
||||
"QdrantUrl": "http://localhost:6333",
|
||||
"VectorSize": 768,
|
||||
|
||||
370
src/Web/wwwroot/css/docs.css
Normal file
370
src/Web/wwwroot/css/docs.css
Normal 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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
571
src/Web/wwwroot/js/docs.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user