feat: 탐정앱 증거자료 날짜 인식 + 타임라인 자동 등록 기능
사진/텍스트 문서에서 날짜를 인식(EXIF, 비전 LLM, 텍스트 LLM)해 타임라인에 등록하는 기능 추가 — 폴더별 일괄 인식, 이름 변경 + 원본 백업, 증거 파일 삭제 연동까지 포함. 코드 리뷰로 발견된 XSS, 동시 저장 데이터 유실, 날짜 접두사 중복, GIF 미지원 등 다수 버그 수정도 포함. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+768
-11
@@ -115,6 +115,18 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.dt-cl-del{background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);padding:1px 4px;opacity:0;transition:.12s;flex-shrink:0;}
|
||||
.dt-cl-item:hover .dt-cl-del{opacity:.5;}
|
||||
.dt-cl-del:hover{opacity:1 !important;color:#ef4444;}
|
||||
.dt-ev-group-title{font-size:11px;font-weight:700;color:var(--muted);margin:6px 0 2px;padding-left:2px;}
|
||||
.dt-ev-photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:6px;}
|
||||
.dt-ev-photo{position:relative;border-radius:7px;overflow:hidden;border:1px solid var(--line);aspect-ratio:1;background:var(--panel);}
|
||||
.dt-ev-photo img{width:100%;height:100%;object-fit:cover;display:block;}
|
||||
.dt-ev-photo .dt-ev-photo-del{position:absolute;top:2px;right:2px;background:rgba(0,0,0,.55);border:none;color:#fff;border-radius:5px;font-size:10px;padding:1px 4px;cursor:pointer;opacity:0;transition:.12s;}
|
||||
.dt-ev-photo:hover .dt-ev-photo-del{opacity:1;}
|
||||
.dt-ev-folder-header{display:flex;align-items:center;justify-content:space-between;padding:6px 4px 4px;margin-top:10px;border-top:1px solid var(--line);font-size:12px;font-weight:700;color:var(--text);cursor:pointer;user-select:none;}
|
||||
.dt-ev-folder-header .dt-cl-del{opacity:.6;font-size:10px;}
|
||||
.dt-ev-folder-header .dt-cl-del:hover{opacity:1;}
|
||||
.dt-ev-chevron{display:inline-block;width:11px;font-size:9px;color:var(--muted);}
|
||||
.dt-ev-folder-body{padding-left:6px;}
|
||||
.dt-ev-date-title{font-size:10px;font-weight:700;color:var(--muted);margin:6px 0 2px 2px;}
|
||||
.dt-cl-progress{display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
||||
.dt-cl-prog-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden;}
|
||||
.dt-cl-prog-fill{height:100%;background:#22c55e;border-radius:2px;transition:.3s;}
|
||||
@@ -333,6 +345,8 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<button class="dt-tab-btn active" onclick="switchTab('notes')" id="tab-notes">📝 메모</button>
|
||||
<button class="dt-tab-btn" onclick="switchTab('checklist')" id="tab-checklist">✅ 체크리스트</button>
|
||||
<button class="dt-tab-btn" onclick="switchTab('timeline')" id="tab-timeline">📅 타임라인</button>
|
||||
<button class="dt-tab-btn" onclick="switchTab('files')" id="tab-files">📎 증거자료</button>
|
||||
<button class="dt-tab-btn" onclick="switchTab('docs')" id="tab-docs">📋 제출서류</button>
|
||||
</div>
|
||||
|
||||
<div class="dt-tab-content">
|
||||
@@ -374,9 +388,41 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<input class="dt-tl-text-input" id="tl-input" type="text" placeholder="이벤트 내용 입력..." onkeydown="if(event.key==='Enter')addTimelineEvent()">
|
||||
<button class="dt-tl-add-btn" onclick="addTimelineEvent()">추가</button>
|
||||
</div>
|
||||
<div id="tl-suggestions"></div>
|
||||
<div class="dt-tl-list" id="tl-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files pane -->
|
||||
<div class="dt-tab-pane" id="pane-files">
|
||||
<div class="dt-cl-wrap">
|
||||
<div class="dt-cl-add-bar">
|
||||
<input type="file" id="file-input" style="display:none" multiple onchange="onFilesPicked(this.files)">
|
||||
<input type="file" id="folder-input" style="display:none" webkitdirectory directory multiple onchange="onFilesPicked(this.files)">
|
||||
<input type="file" id="folder-add-file-input" style="display:none" multiple onchange="onFolderAddFilesPicked(this.files)">
|
||||
<button class="dt-cl-add-btn" onclick="document.getElementById('file-input').click()">📎 증거자료 업로드</button>
|
||||
<button class="dt-cl-add-btn" onclick="document.getElementById('folder-input').click()">📁 폴더 업로드</button>
|
||||
<select id="ev-sort-mode" onchange="onEvidenceSortChange()" style="margin-left:auto;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:11px;color:var(--text);font-family:inherit;">
|
||||
<option value="category">분류별</option>
|
||||
<option value="date">날짜순</option>
|
||||
</select>
|
||||
<span id="file-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||||
</div>
|
||||
<div class="dt-cl-list" id="files-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submission documents pane -->
|
||||
<div class="dt-tab-pane" id="pane-docs">
|
||||
<div class="dt-cl-wrap">
|
||||
<div class="dt-cl-add-bar">
|
||||
<input type="file" id="doc-input" style="display:none" multiple onchange="onDocsPicked(this.files)">
|
||||
<button class="dt-cl-add-btn" onclick="document.getElementById('doc-input').click()">📋 서류 업로드</button>
|
||||
<span id="doc-upload-status" style="font-size:11px;color:var(--muted)"></span>
|
||||
</div>
|
||||
<div class="dt-cl-list" id="docs-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -573,10 +619,11 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<div class="dt-chat" id="chat-panel">
|
||||
<div class="dt-chat-hdr">
|
||||
<span class="dt-chat-label">🔍 탐정 AI</span>
|
||||
<span id="dt-chat-ctx-label" style="font-size:10px;color:var(--muted);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:110px" title="현재 대화 컨텍스트">일반</span>
|
||||
<select class="dt-model-sel" id="model-sel" onchange="saveModel()">
|
||||
<option value="">기본 모델</option>
|
||||
</select>
|
||||
<button class="dt-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
||||
<button class="dt-chat-clear" onclick="clearChat()" title="이 컨텍스트의 대화 지우기">🗑</button>
|
||||
</div>
|
||||
<div class="dt-msgs" id="chat-msgs"></div>
|
||||
<div class="dt-input-bar">
|
||||
@@ -588,6 +635,33 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Built-in text editor for evidence .txt/.md/.csv files -- avoids opening the
|
||||
raw file in a new tab, which garbles legacy EUC-KR/CP949 Korean documents
|
||||
under the browser's UTF-8 assumption. -->
|
||||
<div id="dt-text-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.45);z-index:9999;align-items:center;justify-content:center;padding:18px">
|
||||
<div style="width:min(720px,96vw);height:min(640px,90vh);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md,0 8px 30px rgba(0,0,0,.3));display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--line);flex-shrink:0">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span style="font-weight:800;font-size:13px;flex-shrink:0">📝</span>
|
||||
<span id="dt-te-filename" style="font-weight:700;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
|
||||
<select id="dt-te-encoding" onchange="dtTextEditorRedecode()" style="margin-left:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:7px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit">
|
||||
<option value="utf-8">UTF-8</option>
|
||||
<option value="euc-kr">EUC-KR (CP949)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="closeTextEditor()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1;flex-shrink:0">✕</button>
|
||||
</div>
|
||||
<textarea id="dt-te-textarea" style="flex:1;resize:none;border:0;outline:none;padding:14px 16px;font-family:'IBM Plex Mono',monospace;font-size:13px;line-height:1.6;background:var(--panel);color:var(--text)"></textarea>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0">
|
||||
<span id="dt-te-status" style="font-size:11px;color:var(--muted)"></span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button onclick="closeTextEditor()" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">닫기</button>
|
||||
<button onclick="saveTextEditor()" style="border:0;background:var(--brand);color:#fff;border-radius:8px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer">저장 (UTF-8)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
const TOKEN_KEY='smallclaw_token';
|
||||
@@ -638,12 +712,14 @@ function mobToggle(which){
|
||||
let activeTab='notes';
|
||||
function switchTab(tab){
|
||||
activeTab=tab;
|
||||
['notes','checklist','timeline'].forEach(t=>{
|
||||
['notes','checklist','timeline','files','docs'].forEach(t=>{
|
||||
document.getElementById('tab-'+t)?.classList.toggle('active',t===tab);
|
||||
document.getElementById('pane-'+t)?.classList.toggle('active',t===tab);
|
||||
});
|
||||
if(tab==='checklist')renderChecklist();
|
||||
if(tab==='timeline')renderTimeline();
|
||||
if(tab==='timeline'){renderTimeline();renderDateSuggestions();}
|
||||
if(tab==='files')loadFiles();
|
||||
if(tab==='docs')loadSubmissionDocs();
|
||||
}
|
||||
|
||||
// ── Cases ─────────────────────────────────────────────────────────────────────
|
||||
@@ -656,6 +732,7 @@ function genId(){return 'case_'+Math.random().toString(36).slice(2,10);}
|
||||
function normalizeCase(c){
|
||||
if(!c.checklist)c.checklist=[];
|
||||
if(!c.timeline)c.timeline=[];
|
||||
if(!c.dateSuggestions)c.dateSuggestions=[];
|
||||
if(!c.priority)c.priority='normal';
|
||||
return c;
|
||||
}
|
||||
@@ -668,9 +745,41 @@ async function loadCases(){
|
||||
renderCaseList();
|
||||
}
|
||||
|
||||
// timeline/dateSuggestions can be written server-side out-of-band, outside
|
||||
// this full-array save entirely — the background date-recognition pipeline
|
||||
// (routes-detective-dates.ts) writes those two fields directly to
|
||||
// detective-cases.json, and another browser tab on the same case could too.
|
||||
// A blind full-array PUT here would silently erase whatever a background
|
||||
// call or another tab already persisted between this tab's last load and
|
||||
// now. Before saving, merge in any timeline/dateSuggestions entries the
|
||||
// server has that this tab doesn't know about yet (union by id — local
|
||||
// entries always win on conflict, since a just-made local edit is the
|
||||
// freshest truth). This can't perfectly reconcile deletions made in another
|
||||
// tab, but silently resurrecting an already-deleted entry is a far smaller
|
||||
// problem than silently losing one that was just added.
|
||||
async function saveCases(){
|
||||
updateCurrentCaseFromDom();
|
||||
try{
|
||||
try{
|
||||
const r=await fetch('/api/detective/cases',{headers:authH()});
|
||||
if(r.ok){
|
||||
const d=await r.json();
|
||||
if(Array.isArray(d.cases)){
|
||||
for(const freshCase of d.cases){
|
||||
const localCase=cases.find(x=>x.id===freshCase.id);
|
||||
if(!localCase)continue;
|
||||
for(const field of['timeline','dateSuggestions']){
|
||||
const localIds=new Set((localCase[field]||[]).map(x=>x.id));
|
||||
const merged=[...(localCase[field]||[])];
|
||||
for(const item of(freshCase[field]||[])){
|
||||
if(!localIds.has(item.id))merged.push(item);
|
||||
}
|
||||
localCase[field]=merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}catch{}
|
||||
await fetch('/api/detective/cases',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({cases})});
|
||||
showSaveIndicator('저장됨');
|
||||
}catch{showSaveIndicator('저장 실패');}
|
||||
@@ -756,6 +865,9 @@ function selectCase(id){
|
||||
renderCaseList();
|
||||
if(activeTab==='checklist')renderChecklist();
|
||||
if(activeTab==='timeline')renderTimeline();
|
||||
if(activeTab==='files')loadFiles();
|
||||
if(activeTab==='docs')loadSubmissionDocs();
|
||||
ensureChatContextForCase();
|
||||
if(window.innerWidth<=1024)mobCloseAll();
|
||||
}
|
||||
|
||||
@@ -770,10 +882,12 @@ function newCase(){
|
||||
async function deleteCase(id){
|
||||
if(!confirm('이 사건을 삭제할까요?'))return;
|
||||
cases=cases.filter(c=>c.id!==id);
|
||||
fetch('/api/chat/sessions/'+encodeURIComponent('dt_case_'+id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
||||
if(currentCaseId===id){
|
||||
currentCaseId=null;
|
||||
document.getElementById('center-empty').style.display='';
|
||||
document.getElementById('center-case').style.display='none';
|
||||
ensureChatContextForCase();
|
||||
}
|
||||
await saveCases();
|
||||
renderCaseList();
|
||||
@@ -783,6 +897,8 @@ function onCaseNameChange(){
|
||||
const c=cases.find(x=>x.id===currentCaseId);
|
||||
if(c)c.title=document.getElementById('case-name-input').value;
|
||||
renderCaseList();
|
||||
const label=document.getElementById('dt-chat-ctx-label');
|
||||
if(label&&c)label.textContent=c.title||'제목 없음';
|
||||
}
|
||||
function onStatusChange(){
|
||||
const c=cases.find(x=>x.id===currentCaseId);
|
||||
@@ -890,8 +1006,12 @@ function renderTimeline(){
|
||||
<div class="dt-tl-line"><div class="dt-tl-dot"></div><div class="dt-tl-rod"></div></div>
|
||||
<div class="dt-tl-body">
|
||||
<div class="dt-tl-date-lbl">${escHtml(ev.date)}</div>
|
||||
<div class="dt-tl-txt">
|
||||
${escHtml(ev.text)}
|
||||
<div class="dt-tl-txt" style="display:flex;align-items:center;gap:8px">
|
||||
${ev.url?(DT_PHOTO_EXTS.test(ev.url)
|
||||
?`<a href="${escAttr(ev.url)}" target="_blank" title="원본 크기로 보기" style="flex-shrink:0"><img src="${escAttr(ev.url)}" loading="lazy" style="width:36px;height:36px;object-fit:cover;border-radius:5px;border:1px solid var(--line)"></a>`
|
||||
:`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(ev.relPath||'')}','${escJsAttr(ev.url)}','${escJsAttr((ev.relPath||'').split('/').pop())}')" title="문서 열기 (인코딩 자동 감지)" style="flex-shrink:0"><span style="display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:5px;border:1px solid var(--line);font-size:16px">📄</span></a>`
|
||||
):''}
|
||||
<span style="flex:1">${escHtml(ev.text)}</span>
|
||||
<button class="dt-tl-del" onclick="deleteEvent('${ev.id}')">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -910,12 +1030,614 @@ function addTimelineEvent(){
|
||||
renderTimeline();saveCases();
|
||||
}
|
||||
|
||||
function deleteEvent(id){
|
||||
async function deleteEvent(id){
|
||||
const c=currentCase();if(!c)return;
|
||||
const ev=(c.timeline||[]).find(x=>x.id===id);
|
||||
// Entries linked to an evidence file (photo/document added via date
|
||||
// recognition) get an extra choice: removing just the timeline entry
|
||||
// leaves the file in the evidence tab untouched; confirming also deletes
|
||||
// the working file (the untouched original still sits in 원본/ either way).
|
||||
if(ev&&ev.relPath&&confirm(`타임라인 항목을 삭제합니다.\n연결된 파일도 함께 삭제할까요?\n"${ev.relPath}"\n\n확인 = 파일도 삭제 (원본은 원본/ 폴더에 남음)\n취소 = 타임라인 항목만 삭제, 파일은 유지`)){
|
||||
try{
|
||||
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath:ev.relPath})});
|
||||
}catch{}
|
||||
if(activeTab==='files')loadFiles();
|
||||
}
|
||||
c.timeline=(c.timeline||[]).filter(x=>x.id!==id);
|
||||
renderTimeline();saveCases();
|
||||
}
|
||||
|
||||
// ── Date recognition (photos: EXIF → vision LLM; text docs: content read) ────
|
||||
// Runs automatically right after upload and can be re-run per file from the
|
||||
// evidence tab. Results land as pending suggestions here — approving one
|
||||
// copies it into the timeline, it never gets added silently.
|
||||
const DT_PHOTO_EXTS=/\.(jpe?g|png|gif|webp|bmp|heic|heif|tiff)$/i;
|
||||
const DT_TEXT_EDIT_EXTS=/\.(txt|md|csv)$/i;
|
||||
const DT_TIMELINE_EXTS=/\.(jpe?g|png|gif|webp|bmp|heic|heif|tiff|txt|md|csv)$/i;
|
||||
|
||||
async function requestDateSuggestion(relPath){
|
||||
if(!currentCaseId)return null;
|
||||
try{
|
||||
const r=await fetch('/api/detective/files/date-suggest',{
|
||||
method:'POST',headers:authH({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify({caseId:currentCaseId,relPath}),
|
||||
});
|
||||
const d=await r.json();
|
||||
if(!d.success||!d.suggestion)return null;
|
||||
const c=currentCase();
|
||||
if(c){
|
||||
if(!c.dateSuggestions)c.dateSuggestions=[];
|
||||
c.dateSuggestions=c.dateSuggestions.filter(s=>s.relPath!==relPath);
|
||||
c.dateSuggestions.push(d.suggestion);
|
||||
}
|
||||
return d.suggestion;
|
||||
}catch{return null;}
|
||||
}
|
||||
|
||||
async function dtManualDateSuggest(relPath,btn){
|
||||
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||||
const s=await requestDateSuggestion(relPath);
|
||||
if(btn){btn.disabled=false;btn.textContent='📅';}
|
||||
renderDateSuggestions();
|
||||
showSaveIndicator(s?(s.date?`날짜 인식: ${s.date}`:'날짜를 찾지 못했습니다'):'인식 실패');
|
||||
}
|
||||
|
||||
// Runs `worker` over `items` with at most `concurrency` in flight at once —
|
||||
// each date-recognition call is a slow (sometimes 60-90s) LLM round-trip, so
|
||||
// fully sequential batches turned a 20-photo folder into a 20-30 minute wait.
|
||||
// Different files never touch each other's state, so a small concurrency cap
|
||||
// is safe and gives a large speedup without hammering the LLM endpoint.
|
||||
async function runWithConcurrency(items,worker,concurrency){
|
||||
let next=0;
|
||||
async function run(){
|
||||
while(next<items.length){
|
||||
const i=next++;
|
||||
await worker(items[i],i);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:Math.min(concurrency,items.length)},run));
|
||||
}
|
||||
|
||||
// Recognizes every photo/text document in a folder at once, then hands off
|
||||
// to the Timeline tab's suggestion panel where all of them can be reviewed
|
||||
// and committed together with approveAllDateSuggestions() instead of one at
|
||||
// a time.
|
||||
async function dtFolderRecognize(folder,btn){
|
||||
const items=dtLastEvidenceFiles.filter(f=>f.folder===folder&&(f.category==='photo'||DT_TEXT_EDIT_EXTS.test(f.name)));
|
||||
if(!items.length){alert('이 폴더에 인식할 사진/문서가 없습니다.');return;}
|
||||
if(!confirm(`"${folder}" 폴더의 사진/문서 ${items.length}개에서 날짜를 인식합니다.\n(개수에 따라 시간이 걸릴 수 있습니다)`))return;
|
||||
const label=btn?btn.textContent:'';
|
||||
if(btn)btn.disabled=true;
|
||||
let progress=0;
|
||||
await runWithConcurrency(items,async(item)=>{
|
||||
await requestDateSuggestion(item.relPath);
|
||||
progress++;
|
||||
if(btn)btn.textContent=`인식 중... (${progress}/${items.length})`;
|
||||
},3);
|
||||
if(btn){btn.disabled=false;btn.textContent=label;}
|
||||
switchTab('timeline');
|
||||
showSaveIndicator(`${items.length}장 인식 완료 — 아래에서 확인 후 일괄 추가하세요`);
|
||||
}
|
||||
|
||||
const DT_SOURCE_LABELS={exif:'EXIF',vision:'AI 인식','vision+exif_year':'AI 인식(연도 추정)',text:'AI 인식(문서)'};
|
||||
|
||||
function renderDateSuggestions(){
|
||||
const box=document.getElementById('tl-suggestions');
|
||||
if(!box)return;
|
||||
const c=currentCase();
|
||||
const list=c?(c.dateSuggestions||[]):[];
|
||||
if(!list.length){box.innerHTML='';return;}
|
||||
box.innerHTML=`<div style="margin:10px 0;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--panel)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="font-size:11px;color:var(--muted);flex:1">📷📄 사진/문서에서 인식된 날짜 제안 (${list.length}) — 확인 후 추가하세요</span>
|
||||
<button class="dt-tl-add-btn" style="padding:2px 8px" id="sug-approve-all-btn" onclick="approveAllDateSuggestions()">날짜 있는 항목 전체 추가</button>
|
||||
</div>
|
||||
${list.map(s=>`
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;font-size:12px;border-top:1px solid var(--line)">
|
||||
${s.kind==='text'
|
||||
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(s.relPath)}','${escJsAttr(s.url||'')}','${escJsAttr(s.fileName)}')" title="문서 열기 (인코딩 자동 감지)" style="flex-shrink:0"><span style="display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:5px;border:1px solid var(--line);font-size:18px">📄</span></a>`
|
||||
:`<a href="${escAttr(s.url||'')}" target="_blank" title="원본 크기로 보기" style="flex-shrink:0"><img src="${escAttr(s.url||'')}" loading="lazy" style="width:44px;height:44px;object-fit:cover;border-radius:5px;border:1px solid var(--line)"></a>`}
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<span style="font-size:10px;color:var(--muted);word-break:break-all">${escHtml(s.fileName)}</span>
|
||||
<input type="text" id="sug-name-${s.id}" value="${escAttr(s.nameOverride||'')}" placeholder="새 파일 이름 (비우면 원본 이름 유지)" style="background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:3px 6px;font-size:12px;color:var(--text);font-family:inherit;width:100%">
|
||||
</div>
|
||||
<span style="color:var(--muted);font-size:10px;flex-shrink:0">${DT_SOURCE_LABELS[s.source]||s.source}</span>
|
||||
<input type="date" id="sug-date-${s.id}" value="${s.date?escAttr(s.date):''}" class="dt-tl-date" style="width:130px;flex-shrink:0">
|
||||
<button class="dt-tl-add-btn" style="padding:2px 8px" onclick="approveDateSuggestion('${s.id}')">타임라인에 추가</button>
|
||||
<button class="dt-tl-del" style="position:static;opacity:.6" onclick="rejectDateSuggestion('${s.id}')" title="제안만 지우기 (파일은 유지)">✕</button>
|
||||
<button class="dt-tl-del" style="position:static;opacity:.6" onclick="deleteFile('${escJsAttr(s.relPath)}')" title="파일 자체를 삭제">🗑</button>
|
||||
</div>
|
||||
${s.reason?`<div style="font-size:10px;color:var(--muted);margin:-2px 0 4px 52px">${escHtml(s.reason)}</div>`:''}
|
||||
`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Committing a suggestion actually touches the evidence file: renames it to
|
||||
// "<date>_<content-or-original-name>.<ext>" and copies the untouched original
|
||||
// into a 원본/ subfolder next to it, so a wrong AI guess never loses the
|
||||
// source file. All done server-side in one call — see commitPhotoToTimeline.
|
||||
async function commitDateSuggestion(caseId,relPath,date,label){
|
||||
const r=await fetch('/api/detective/files/add-to-timeline',{
|
||||
method:'POST',headers:authH({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify({caseId,relPath,date,label}),
|
||||
});
|
||||
const d=await r.json();
|
||||
if(!d.success)throw new Error(d.error||'add-to-timeline failed');
|
||||
return d;
|
||||
}
|
||||
|
||||
// Shared by approveDateSuggestion/approveAllDateSuggestions: commits one
|
||||
// suggestion server-side, then mirrors the exact id/text/relPath/url the
|
||||
// server persisted into the local timeline (never re-derive the icon/id
|
||||
// client-side — the server already knows whether this was a photo or a
|
||||
// text document, and re-guessing here is how the timeline icon used to
|
||||
// drift out of sync with what's actually on disk).
|
||||
async function commitOneSuggestion(c,s,date,name){
|
||||
const result=await commitDateSuggestion(currentCaseId,s.relPath,date,name);
|
||||
if(!c.timeline)c.timeline=[];
|
||||
c.timeline.push({id:result.id,date,text:result.text,relPath:result.relPath,url:result.url});
|
||||
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>x.id!==s.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function approveDateSuggestion(id){
|
||||
const c=currentCase();if(!c)return;
|
||||
const s=(c.dateSuggestions||[]).find(x=>x.id===id);
|
||||
if(!s)return;
|
||||
const date=document.getElementById('sug-date-'+id)?.value;
|
||||
if(!date){alert('날짜를 입력하세요.');return;}
|
||||
const name=document.getElementById('sug-name-'+id)?.value||'';
|
||||
try{
|
||||
await commitOneSuggestion(c,s,date,name);
|
||||
}catch(e){alert('타임라인 추가 실패: '+e.message);return;}
|
||||
renderTimeline();renderDateSuggestions();loadFiles();
|
||||
showSaveIndicator('타임라인에 추가됨');
|
||||
}
|
||||
|
||||
function rejectDateSuggestion(id){
|
||||
const c=currentCase();if(!c)return;
|
||||
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>x.id!==id);
|
||||
renderDateSuggestions();saveCases();
|
||||
}
|
||||
|
||||
// Commits every pending suggestion that currently has a date filled in (the
|
||||
// per-row input, editable before this runs). Runs with a small concurrency
|
||||
// cap rather than fully sequential or fully parallel — each commit is a slow
|
||||
// server round-trip (rename + backup + JSON write) but nothing stops two
|
||||
// different files' commits from happening at once, and fully serializing a
|
||||
// folder of 10+ suggestions made this take minutes for no reason.
|
||||
async function approveAllDateSuggestions(){
|
||||
const c=currentCase();if(!c)return;
|
||||
const list=[...(c.dateSuggestions||[])];
|
||||
if(!list.length)return;
|
||||
const btn=document.getElementById('sug-approve-all-btn');
|
||||
if(btn)btn.disabled=true;
|
||||
let done=0,failed=0,skipped=0;
|
||||
const CONCURRENCY=3;
|
||||
let next=0;
|
||||
async function worker(){
|
||||
while(next<list.length){
|
||||
const s=list[next++];
|
||||
const date=document.getElementById('sug-date-'+s.id)?.value;
|
||||
if(!date){skipped++;continue;}
|
||||
const name=document.getElementById('sug-name-'+s.id)?.value||'';
|
||||
try{
|
||||
await commitOneSuggestion(c,s,date,name);
|
||||
done++;
|
||||
}catch{failed++;}
|
||||
if(btn)btn.textContent=`추가 중... (${done+failed+skipped}/${list.length})`;
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length:Math.min(CONCURRENCY,list.length)},worker));
|
||||
renderTimeline();renderDateSuggestions();loadFiles();
|
||||
showSaveIndicator(`${done}건 추가${skipped?`, ${skipped}건 날짜 없어 건너뜀`:''}${failed?`, ${failed}건 실패`:''}`);
|
||||
}
|
||||
|
||||
// Direct "recognize" from the evidence tab itself — recognizes the date if
|
||||
// it hasn't been already, then hands off to the Timeline tab's suggestion
|
||||
// panel for review. It never commits by itself: renaming the file and
|
||||
// backing up the original only happens after the user explicitly checks the
|
||||
// date/name there and clicks "타임라인에 추가" — a native prompt() popup isn't
|
||||
// a real review step, so this button doesn't skip straight to committing.
|
||||
async function dtQuickAddToTimeline(relPath,btn){
|
||||
const c=currentCase();if(!c)return;
|
||||
if(btn){btn.disabled=true;btn.textContent='⏳';}
|
||||
let s=(c.dateSuggestions||[]).find(x=>x.relPath===relPath);
|
||||
if(!s)s=await requestDateSuggestion(relPath);
|
||||
if(btn){btn.disabled=false;btn.textContent='🕐+';}
|
||||
if(!s){alert('날짜 인식에 실패했습니다.');return;}
|
||||
switchTab('timeline');
|
||||
showSaveIndicator(s.date?`날짜 인식: ${s.date} — 아래에서 확인 후 추가하세요`:'날짜를 찾지 못했습니다 — 아래에서 직접 입력 후 추가하세요');
|
||||
}
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────────────
|
||||
function fmtFileSize(n){
|
||||
if(n<1024)return n+'B';
|
||||
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
|
||||
return (n/1024/1024).toFixed(1)+'MB';
|
||||
}
|
||||
|
||||
async function loadFiles(){
|
||||
const list=document.getElementById('files-list');
|
||||
if(!currentCaseId){list.innerHTML='';return;}
|
||||
list.innerHTML='<div class="dt-cl-empty">불러오는 중...</div>';
|
||||
try{
|
||||
const r=await fetch('/api/detective/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||||
const d=await r.json();
|
||||
const all=Array.isArray(d.files)?d.files:[];
|
||||
// Root-level documents live in the 제출서류 tab instead; documents
|
||||
// inside a folder upload stay put so that folder's structure is intact.
|
||||
renderFiles(all.filter(f=>f.folder||f.category!=='document'));
|
||||
}catch{list.innerHTML='<div class="dt-cl-empty">파일 목록을 불러오지 못했습니다.</div>';}
|
||||
}
|
||||
|
||||
async function loadSubmissionDocs(){
|
||||
const list=document.getElementById('docs-list');
|
||||
if(!currentCaseId){list.innerHTML='';return;}
|
||||
list.innerHTML='<div class="dt-cl-empty">불러오는 중...</div>';
|
||||
try{
|
||||
const r=await fetch('/api/detective/files?caseId='+encodeURIComponent(currentCaseId),{headers:authH()});
|
||||
const d=await r.json();
|
||||
const all=Array.isArray(d.files)?d.files:[];
|
||||
const docs=all.filter(f=>!f.folder&&f.category==='document').sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||||
if(!docs.length){list.innerHTML='<div class="dt-cl-empty">등록된 제출서류가 없습니다.</div>';return;}
|
||||
list.innerHTML=docs.map(f=>renderFileRow(f)).join('');
|
||||
}catch{list.innerHTML='<div class="dt-cl-empty">서류 목록을 불러오지 못했습니다.</div>';}
|
||||
}
|
||||
|
||||
async function onDocsPicked(fileList){
|
||||
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||||
const status=document.getElementById('doc-upload-status');
|
||||
const files=[...fileList];
|
||||
for(let i=0;i<files.length;i++){
|
||||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||||
try{await uploadOneFile(files[i]);}catch{}
|
||||
}
|
||||
status.textContent='';
|
||||
document.getElementById('doc-input').value='';
|
||||
loadSubmissionDocs();
|
||||
}
|
||||
|
||||
const EVIDENCE_CATEGORY_LABELS={photo:'📷 사진',video:'🎥 동영상',document:'📄 문서',other:'📦 기타'};
|
||||
const EVIDENCE_CATEGORY_ORDER=['photo','video','document','other'];
|
||||
|
||||
const EVIDENCE_CATEGORY_ICONS={photo:'📷',video:'🎥',document:'📄',other:'📦'};
|
||||
let evidenceSortMode='category'; // 'category' | 'date'
|
||||
|
||||
function onEvidenceSortChange(){
|
||||
evidenceSortMode=document.getElementById('ev-sort-mode').value;
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// name: display label. Root files show their bare name; files inside a
|
||||
// folder-upload show the path relative to that folder, so the original
|
||||
// structure stays visible instead of being flattened away.
|
||||
function renderFileRow(f,name){
|
||||
const isPdf=/\.pdf$/i.test(f.name);
|
||||
const isText=DT_TEXT_EDIT_EXTS.test(f.name);
|
||||
const icon=EVIDENCE_CATEGORY_ICONS[f.category]||'📦';
|
||||
const nameHtml=isText
|
||||
?`<a href="javascript:void(0)" onclick="openTextEditor('${escJsAttr(f.relPath)}','${escJsAttr(f.url)}','${escJsAttr(f.name)}')" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(name||f.name)}</a>`
|
||||
:`<a href="${escAttr(f.url)}" target="_blank" style="flex:1;color:var(--text);text-decoration:none;font-size:12px;word-break:break-all">${escHtml(name||f.name)}</a>`;
|
||||
return `<div class="dt-cl-item">
|
||||
<span style="flex-shrink:0">${icon}</span>
|
||||
${nameHtml}
|
||||
<span style="font-size:10px;color:var(--muted);flex-shrink:0">${fmtFileSize(f.size)}</span>
|
||||
${isPdf?`<a href="/pdf-editor-open?caseId=${encodeURIComponent(currentCaseId)}" target="_blank" class="dt-cl-del" style="opacity:.7" title="PDF 편집기에서 열기 (수정 후 다운로드하면 사건 폴더에 자동 저장됩니다)">✏️</a>`:''}
|
||||
${(f.category==='photo'||isText)?`<button class="dt-cl-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)">🕐+</button>
|
||||
<button class="dt-cl-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="날짜 인식">📅</button>`:''}
|
||||
<button class="dt-cl-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCategoryGroups(files){
|
||||
const groups={};
|
||||
files.forEach(f=>{(groups[f.category||'other']=groups[f.category||'other']||[]).push(f);});
|
||||
return EVIDENCE_CATEGORY_ORDER.filter(cat=>groups[cat]?.length).map(cat=>{
|
||||
const items=groups[cat];
|
||||
const title=`<div class="dt-ev-group-title">${EVIDENCE_CATEGORY_LABELS[cat]} (${items.length})</div>`;
|
||||
if(cat==='photo'){
|
||||
return title+`<div class="dt-ev-photo-grid">${items.map(f=>`
|
||||
<div class="dt-ev-photo">
|
||||
<a href="${escAttr(f.url)}" target="_blank" title="${escAttr(f.name)}"><img src="${escAttr(f.url)}" loading="lazy" alt="${escAttr(f.name)}"></a>
|
||||
<button class="dt-ev-photo-del" onclick="dtQuickAddToTimeline('${escJsAttr(f.relPath)}',this)" title="날짜 인식 (타임라인 탭에서 확인 후 추가)" style="right:50px">🕐+</button>
|
||||
<button class="dt-ev-photo-del" onclick="dtManualDateSuggest('${escJsAttr(f.relPath)}',this)" title="사진에서 날짜 인식" style="right:26px">📅</button>
|
||||
<button class="dt-ev-photo-del" onclick="deleteFile('${escJsAttr(f.relPath)}')" title="삭제">✕</button>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
return title+items.map(f=>renderFileRow(f)).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Folder uploads keep their original structure (no photo/video/document
|
||||
// splitting) but are grouped by the date each file was added. Order within
|
||||
// the folder (newest-first vs oldest-first) is per-folder, see dtGetFolderOrder.
|
||||
function renderFolderContents(items,order){
|
||||
const dateGroups={};
|
||||
items.forEach(f=>{
|
||||
const d=new Date(f.mtime);
|
||||
const key=isNaN(d)?'날짜 없음':d.toISOString().slice(0,10);
|
||||
(dateGroups[key]=dateGroups[key]||[]).push(f);
|
||||
});
|
||||
const dir=order==='asc'?1:-1;
|
||||
return Object.keys(dateGroups).sort((a,b)=>dir*b.localeCompare(a)).map(dateKey=>{
|
||||
const dItems=dateGroups[dateKey].sort((a,b)=>dir*(new Date(b.mtime)-new Date(a.mtime)));
|
||||
return `<div class="dt-ev-date-title">📅 ${dateKey} (${dItems.length})</div>`
|
||||
+dItems.map(f=>renderFileRow(f,f.relPath.slice(f.folder.length+1))).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Per-folder item order (newest/oldest first) and folder-list order
|
||||
// (name/date), both persisted in localStorage -- folder order per case,
|
||||
// list order globally since it's a display preference, not case data.
|
||||
function dtFolderOrderKey(folder){return 'dt_folder_order_'+(currentCaseId||'')+'_'+folder;}
|
||||
function dtGetFolderOrder(folder){return localStorage.getItem(dtFolderOrderKey(folder))||'desc';}
|
||||
function dtSetFolderOrder(folder,order){try{localStorage.setItem(dtFolderOrderKey(folder),order);}catch{}}
|
||||
function dtToggleFolderOrder(folder){
|
||||
dtSetFolderOrder(folder,dtGetFolderOrder(folder)==='desc'?'asc':'desc');
|
||||
renderFiles(dtLastEvidenceFiles);
|
||||
}
|
||||
|
||||
let folderSortMode=localStorage.getItem('dt_folder_sort_mode')||'name'; // 'name' | 'date'
|
||||
function onFolderSortChange(){
|
||||
folderSortMode=document.getElementById('folder-sort-mode').value;
|
||||
try{localStorage.setItem('dt_folder_sort_mode',folderSortMode);}catch{}
|
||||
renderFiles(dtLastEvidenceFiles);
|
||||
}
|
||||
function sortedFolderNames(folderMap){
|
||||
const names=Object.keys(folderMap);
|
||||
if(folderSortMode==='date'){
|
||||
const latest=folder=>Math.max(...folderMap[folder].map(f=>new Date(f.mtime).getTime()||0));
|
||||
return names.sort((a,b)=>latest(b)-latest(a));
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
// Remembers which folders are collapsed (per case, in localStorage) so
|
||||
// re-rendering the list -- after an upload, a tab switch, or even a full
|
||||
// page reload -- doesn't snap every folder back open.
|
||||
function dtCollapsedKey(){return 'dt_collapsed_'+(currentCaseId||'');}
|
||||
function dtLoadCollapsed(){
|
||||
try{return new Set(JSON.parse(localStorage.getItem(dtCollapsedKey())||'[]'));}catch{return new Set();}
|
||||
}
|
||||
function dtSaveCollapsed(set){
|
||||
try{localStorage.setItem(dtCollapsedKey(),JSON.stringify([...set]));}catch{}
|
||||
}
|
||||
|
||||
function dtToggleFolder(headerEl){
|
||||
const folder=headerEl.dataset.folder;
|
||||
const body=headerEl.nextElementSibling;
|
||||
const collapsed=body.style.display==='none';
|
||||
body.style.display=collapsed?'':'none';
|
||||
const chev=headerEl.querySelector('.dt-ev-chevron');
|
||||
if(chev)chev.textContent=collapsed?'▾':'▸';
|
||||
const set=dtLoadCollapsed();
|
||||
if(collapsed)set.delete(folder);else set.add(folder);
|
||||
dtSaveCollapsed(set);
|
||||
}
|
||||
|
||||
let dtLastEvidenceFiles=[];
|
||||
|
||||
function renderFiles(files){
|
||||
dtLastEvidenceFiles=files;
|
||||
const list=document.getElementById('files-list');
|
||||
if(!files.length){list.innerHTML='<div class="dt-cl-empty">등록된 증거자료가 없습니다.</div>';return;}
|
||||
|
||||
if(evidenceSortMode==='date'){
|
||||
const sorted=[...files].sort((a,b)=>new Date(b.mtime)-new Date(a.mtime));
|
||||
list.innerHTML=sorted.map(f=>renderFileRow(f,f.relPath)).join('');
|
||||
return;
|
||||
}
|
||||
|
||||
const rootFiles=files.filter(f=>!f.folder);
|
||||
const folderMap={};
|
||||
files.filter(f=>f.folder).forEach(f=>{(folderMap[f.folder]=folderMap[f.folder]||[]).push(f);});
|
||||
|
||||
let html=rootFiles.length?renderCategoryGroups(rootFiles):'';
|
||||
const collapsedSet=dtLoadCollapsed();
|
||||
const folderSortBar=Object.keys(folderMap).length?`<div style="display:flex;justify-content:flex-end;margin-top:8px">
|
||||
<select id="folder-sort-mode" onchange="onFolderSortChange()" style="background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:10px;color:var(--text);font-family:inherit;">
|
||||
<option value="name"${folderSortMode==='name'?' selected':''}>폴더: 이름순</option>
|
||||
<option value="date"${folderSortMode==='date'?' selected':''}>폴더: 최신순</option>
|
||||
</select>
|
||||
</div>`:'';
|
||||
html+=folderSortBar;
|
||||
sortedFolderNames(folderMap).forEach(folder=>{
|
||||
const items=folderMap[folder];
|
||||
const collapsed=collapsedSet.has(folder);
|
||||
const order=dtGetFolderOrder(folder);
|
||||
html+=`<div class="dt-ev-folder-header" data-folder="${escAttr(folder)}" onclick="dtToggleFolder(this)">
|
||||
<span><span class="dt-ev-chevron">${collapsed?'▸':'▾'}</span> 📁 ${escHtml(folder)} (${items.length})</span>
|
||||
<span>
|
||||
<button class="dt-cl-del" onclick="event.stopPropagation();dtAddFilesToFolder('${escJsAttr(folder)}')" title="이 폴더에 파일 추가" style="opacity:.6;margin-right:8px">+ 파일 추가</button>
|
||||
<button class="dt-cl-del" onclick="event.stopPropagation();dtFolderRecognize('${escJsAttr(folder)}',this)" title="폴더 내 사진 전체 날짜 인식 (타임라인 탭에서 확인 후 일괄 추가)" style="opacity:.6;margin-right:8px">🕐+ 폴더 인식</button>
|
||||
<button class="dt-cl-del" onclick="event.stopPropagation();dtToggleFolderOrder('${escJsAttr(folder)}')" title="폴더 내 파일 정렬 순서 전환" style="opacity:.6;margin-right:8px">${order==='asc'?'🔼 오래된순':'🔽 최신순'}</button>
|
||||
<button class="dt-cl-del" onclick="event.stopPropagation();deleteFolder('${escJsAttr(folder)}')" title="폴더 전체 삭제" style="opacity:.6">🗑 폴더 삭제</button>
|
||||
</span>
|
||||
</div><div class="dt-ev-folder-body"${collapsed?' style="display:none"':''}>`+renderFolderContents(items,order)+`</div>`;
|
||||
});
|
||||
list.innerHTML=html;
|
||||
}
|
||||
|
||||
async function onFilesPicked(fileList){
|
||||
if(!currentCaseId){alert('사건을 먼저 선택하세요.');return;}
|
||||
const status=document.getElementById('file-upload-status');
|
||||
const files=[...fileList];
|
||||
const recognizeRelPaths=[];
|
||||
for(let i=0;i<files.length;i++){
|
||||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||||
try{
|
||||
const d=await uploadOneFile(files[i]);
|
||||
if(DT_TIMELINE_EXTS.test(d.relPath))recognizeRelPaths.push(d.relPath);
|
||||
}catch{}
|
||||
}
|
||||
document.getElementById('file-input').value='';
|
||||
document.getElementById('folder-input').value='';
|
||||
loadFiles();
|
||||
let progress=0;
|
||||
await runWithConcurrency(recognizeRelPaths,async(relPath)=>{
|
||||
await requestDateSuggestion(relPath);
|
||||
progress++;
|
||||
status.textContent=`날짜 인식 중... (${progress}/${recognizeRelPaths.length})`;
|
||||
},3);
|
||||
status.textContent='';
|
||||
renderDateSuggestions();
|
||||
}
|
||||
|
||||
// Adds file(s) directly into an existing evidence folder, instead of the
|
||||
// only previous options (root upload, or a whole new folder via directory
|
||||
// picker). Reuses uploadOneFile with an explicit targetName so the server
|
||||
// places it under <folder>/<name> rather than at the root.
|
||||
let dtFolderAddTarget=null;
|
||||
function dtAddFilesToFolder(folder){
|
||||
dtFolderAddTarget=folder;
|
||||
document.getElementById('folder-add-file-input').click();
|
||||
}
|
||||
|
||||
async function onFolderAddFilesPicked(fileList){
|
||||
if(!currentCaseId||!dtFolderAddTarget)return;
|
||||
const folder=dtFolderAddTarget;
|
||||
const status=document.getElementById('file-upload-status');
|
||||
const files=[...fileList];
|
||||
const recognizeRelPaths=[];
|
||||
for(let i=0;i<files.length;i++){
|
||||
status.textContent=`업로드 중... (${i+1}/${files.length})`;
|
||||
try{
|
||||
const targetName=folder+'/'+files[i].name;
|
||||
const d=await uploadOneFile(files[i],targetName);
|
||||
if(DT_TIMELINE_EXTS.test(d.relPath))recognizeRelPaths.push(d.relPath);
|
||||
}catch{}
|
||||
}
|
||||
document.getElementById('folder-add-file-input').value='';
|
||||
dtFolderAddTarget=null;
|
||||
loadFiles();
|
||||
let progress=0;
|
||||
await runWithConcurrency(recognizeRelPaths,async(relPath)=>{
|
||||
await requestDateSuggestion(relPath);
|
||||
progress++;
|
||||
status.textContent=`날짜 인식 중... (${progress}/${recognizeRelPaths.length})`;
|
||||
},3);
|
||||
status.textContent='';
|
||||
renderDateSuggestions();
|
||||
}
|
||||
|
||||
function uploadOneFile(file,targetName){
|
||||
return new Promise((resolve,reject)=>{
|
||||
const fd=new FormData();
|
||||
// webkitRelativePath (folder uploads) keeps the subfolder as part of the
|
||||
// filename so files from different subfolders don't silently collide.
|
||||
// targetName overrides both when adding a file into a specific existing
|
||||
// folder (see dtAddFilesToFolder).
|
||||
const name=targetName||file.webkitRelativePath||file.name;
|
||||
fd.append('file',file,name);
|
||||
fetch('/api/detective/upload?caseId='+encodeURIComponent(currentCaseId),{
|
||||
method:'POST',headers:authH(),body:fd,
|
||||
}).then(r=>r.json()).then(d=>d.success?resolve(d):reject(d)).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Built-in text editor (.txt/.md/.csv) ─────────────────────────────────────
|
||||
// Old Korean documents are often EUC-KR/CP949, not UTF-8. Opening them via a
|
||||
// plain <a target=_blank> lets the browser guess UTF-8 and garble the text,
|
||||
// so this fetches the raw bytes and decodes them client-side instead, with a
|
||||
// manual encoding override in case auto-detect picks the wrong one.
|
||||
let dtTeState=null; // {caseId, relPath, name, bytes}
|
||||
|
||||
function decodeTextBytes(buf,encoding){
|
||||
if(encoding){
|
||||
try{return new TextDecoder(encoding).decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||||
}
|
||||
try{return new TextDecoder('utf-8',{fatal:true}).decode(buf);}catch{}
|
||||
try{return new TextDecoder('euc-kr').decode(buf);}catch{return new TextDecoder('utf-8').decode(buf);}
|
||||
}
|
||||
|
||||
async function openTextEditor(relPath,url,name){
|
||||
const modal=document.getElementById('dt-text-editor-modal');
|
||||
const ta=document.getElementById('dt-te-textarea');
|
||||
const status=document.getElementById('dt-te-status');
|
||||
document.getElementById('dt-te-filename').textContent=name;
|
||||
ta.value='불러오는 중...';
|
||||
status.textContent='';
|
||||
modal.style.display='flex';
|
||||
try{
|
||||
const r=await fetch(url,{headers:authH(),cache:'no-store'});
|
||||
const buf=await r.arrayBuffer();
|
||||
const isUtf8=(()=>{try{new TextDecoder('utf-8',{fatal:true}).decode(buf);return true;}catch{return false;}})();
|
||||
dtTeState={caseId:currentCaseId,relPath,name,bytes:buf};
|
||||
document.getElementById('dt-te-encoding').value=isUtf8?'utf-8':'euc-kr';
|
||||
ta.value=decodeTextBytes(buf,isUtf8?'utf-8':'euc-kr');
|
||||
status.textContent=isUtf8?'':'⚠️ UTF-8이 아닌 것으로 보여 EUC-KR로 자동 표시했습니다. 깨져 보이면 인코딩을 바꿔보세요.';
|
||||
}catch(e){
|
||||
ta.value='';
|
||||
status.textContent='파일을 불러오지 못했습니다: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function dtTextEditorRedecode(){
|
||||
if(!dtTeState)return;
|
||||
const enc=document.getElementById('dt-te-encoding').value;
|
||||
document.getElementById('dt-te-textarea').value=decodeTextBytes(dtTeState.bytes,enc);
|
||||
document.getElementById('dt-te-status').textContent='';
|
||||
}
|
||||
|
||||
function closeTextEditor(){
|
||||
document.getElementById('dt-text-editor-modal').style.display='none';
|
||||
dtTeState=null;
|
||||
}
|
||||
|
||||
async function saveTextEditor(){
|
||||
if(!dtTeState)return;
|
||||
const status=document.getElementById('dt-te-status');
|
||||
status.textContent='저장 중...';
|
||||
try{
|
||||
const content=document.getElementById('dt-te-textarea').value;
|
||||
const r=await fetch('/api/detective/files/content',{
|
||||
method:'PUT',headers:authH({'Content-Type':'application/json'}),
|
||||
body:JSON.stringify({caseId:dtTeState.caseId,relPath:dtTeState.relPath,content}),
|
||||
});
|
||||
const d=await r.json();
|
||||
if(!r.ok||!d.success)throw new Error(d.error||'save failed');
|
||||
status.textContent='저장됨 (UTF-8)';
|
||||
setTimeout(closeTextEditor,600);
|
||||
}catch(e){
|
||||
status.textContent='저장 실패: '+e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the timeline/suggestion panel in sync when a file is removed from the
|
||||
// evidence tab directly, so a delete there doesn't leave a dangling entry
|
||||
// with a broken thumbnail pointing at a file that no longer exists.
|
||||
function dtPruneTimelineForPath(matches){
|
||||
const c=currentCase();if(!c)return;
|
||||
const before=(c.timeline||[]).length+(c.dateSuggestions||[]).length;
|
||||
c.timeline=(c.timeline||[]).filter(x=>!x.relPath||!matches(x.relPath));
|
||||
c.dateSuggestions=(c.dateSuggestions||[]).filter(x=>!matches(x.relPath));
|
||||
if((c.timeline.length+c.dateSuggestions.length)!==before){renderTimeline();renderDateSuggestions();saveCases();}
|
||||
}
|
||||
|
||||
async function deleteFile(relPath){
|
||||
if(!currentCaseId)return;
|
||||
if(!confirm('이 파일을 삭제할까요?'))return;
|
||||
try{
|
||||
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath})});
|
||||
}catch{}
|
||||
dtPruneTimelineForPath(p=>p===relPath);
|
||||
if(activeTab==='docs')loadSubmissionDocs();else loadFiles();
|
||||
}
|
||||
|
||||
async function deleteFolder(folder){
|
||||
if(!currentCaseId)return;
|
||||
if(!confirm(`'${folder}' 폴더의 파일을 전부 삭제할까요? 되돌릴 수 없습니다.`))return;
|
||||
try{
|
||||
await fetch('/api/detective/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({caseId:currentCaseId,relPath:folder})});
|
||||
}catch{}
|
||||
dtPruneTimelineForPath(p=>p===folder||p.startsWith(folder+'/'));
|
||||
const set=dtLoadCollapsed();
|
||||
set.delete(folder);
|
||||
dtSaveCollapsed(set);
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// ── AI Actions ────────────────────────────────────────────────────────────────
|
||||
function askAiAboutCase(){
|
||||
const c=currentCase();
|
||||
@@ -1079,7 +1801,13 @@ function saveModel(){selectedModel=document.getElementById('model-sel').value;tr
|
||||
const APP_KEY='dt';
|
||||
let skillContext='';
|
||||
let _sessionId=null;
|
||||
function getSessionId(){return _sessionId||'dt_main';}
|
||||
// Each case gets its own AI conversation (derived straight from the case id,
|
||||
// no extra field to persist) so investigating one case never leaks context
|
||||
// into another. With no case selected, chat falls back to a general session.
|
||||
function getSessionId(){
|
||||
if(currentCaseId)return 'dt_case_'+currentCaseId;
|
||||
return _sessionId||'dt_main';
|
||||
}
|
||||
async function initSessionId(){
|
||||
try{const r=await fetch('/api/user/app-sessions',{headers:authH()});if(r.ok){const d=await r.json();if(d[APP_KEY]){_sessionId=d[APP_KEY];return;}}}catch{}
|
||||
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
||||
@@ -1088,6 +1816,7 @@ async function initSessionId(){
|
||||
|
||||
async function loadHistory(){
|
||||
const sid=getSessionId();
|
||||
document.getElementById('chat-msgs').innerHTML='';
|
||||
if(!sid||sid.endsWith('_main'))return;
|
||||
try{
|
||||
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
||||
@@ -1107,6 +1836,20 @@ async function loadHistory(){
|
||||
}catch{}
|
||||
}
|
||||
|
||||
// Switches the chat panel to whichever case is currently selected (or the
|
||||
// general session when none is) and reloads that context's own history --
|
||||
// this is what keeps each case's AI conversation separate from the others.
|
||||
let _chatCtxCaseId='__unset__';
|
||||
async function ensureChatContextForCase(){
|
||||
if(_chatCtxCaseId===currentCaseId)return;
|
||||
_chatCtxCaseId=currentCaseId;
|
||||
const c=cases.find(x=>x.id===currentCaseId);
|
||||
const label=document.getElementById('dt-chat-ctx-label');
|
||||
if(label)label.textContent=c?c.title||'제목 없음':'일반';
|
||||
await loadHistory();
|
||||
addMsg('system',c?`📁 '${escHtml(c.title||'제목 없음')}' 사건 컨텍스트입니다. 이 대화는 이 사건에서만 이어집니다.`:'🔍 일반 조사 컨텍스트입니다. (선택된 사건 없음)');
|
||||
}
|
||||
|
||||
const SKILL_CONTENT=`---
|
||||
name: Detective
|
||||
description: 공개 정보 기반 인물·기업·사건 조사 전문 어시스턴트. 사실 우선, 가설 수립, 증거 수집, 구조적 보고.
|
||||
@@ -1225,6 +1968,16 @@ function loadSkill(){
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||||
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||||
// For values interpolated into a single-quoted JS string literal INSIDE an
|
||||
// onclick="..." attribute (e.g. onclick="deleteFile('${escJsAttr(relPath)}')").
|
||||
// escAttr alone is NOT enough here: the browser HTML-decodes the attribute
|
||||
// value before treating it as JS, so an escAttr'd quote (') still becomes
|
||||
// a real ' by the time it reaches the JS parser and can break out of the
|
||||
// string literal (e.g. a filename like x'),alert(1),('y). This escapes the
|
||||
// backslash/quote for the JS-string layer first, then HTML-escapes the
|
||||
// result for the attribute layer, so both decoding steps land safely.
|
||||
function escJsAttr(s){return escAttr(String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n').replace(/\r/g,''));}
|
||||
|
||||
function addMsg(role,html){
|
||||
const msgs=document.getElementById('chat-msgs');
|
||||
@@ -1864,10 +2617,14 @@ async function callChatRaw(message){
|
||||
}
|
||||
|
||||
async function clearChat(){
|
||||
if(!confirm('대화를 지울까요?'))return;
|
||||
if(!confirm('이 컨텍스트의 대화를 지울까요?'))return;
|
||||
const sid=getSessionId();
|
||||
document.getElementById('chat-msgs').innerHTML='';
|
||||
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
||||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||||
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
||||
if(!currentCaseId){
|
||||
_sessionId='dt_'+Math.random().toString(36).slice(2,12);
|
||||
try{await fetch('/api/user/app-sessions',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({appKey:APP_KEY,sessionId:_sessionId})});}catch{}
|
||||
}
|
||||
addMsg('system','새 대화가 시작되었습니다. 조사 요청을 입력하세요.');
|
||||
}
|
||||
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
||||
@@ -1879,9 +2636,9 @@ function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrol
|
||||
// Set today as default timeline date
|
||||
document.getElementById('tl-date').value=new Date().toISOString().slice(0,10);
|
||||
await initSessionId();
|
||||
await loadHistory();
|
||||
loadSkill();
|
||||
await Promise.all([loadCases(),loadModels()]);
|
||||
await ensureChatContextForCase();
|
||||
addMsg('system','🔍 탐정 어시스턴트입니다. 인물·기업·사건 조사를 도와드립니다.');
|
||||
initAppVoice(sendMessage);
|
||||
document.getElementById('chat-input').focus();
|
||||
|
||||
Reference in New Issue
Block a user