feat: 탐정앱 증거자료 날짜 인식 + 타임라인 자동 등록 기능

사진/텍스트 문서에서 날짜를 인식(EXIF, 비전 LLM, 텍스트 LLM)해 타임라인에
등록하는 기능 추가 — 폴더별 일괄 인식, 이름 변경 + 원본 백업, 증거 파일
삭제 연동까지 포함. 코드 리뷰로 발견된 XSS, 동시 저장 데이터 유실,
날짜 접두사 중복, GIF 미지원 등 다수 버그 수정도 포함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-07-03 00:07:20 +09:00
co-authored by Claude Sonnet 5
parent 28b230f1b8
commit 137351e0d8
3 changed files with 1493 additions and 12 deletions
+449
View File
@@ -0,0 +1,449 @@
/**
* routes-detective-dates.ts
* Detective app: recognize the date shown inside an evidence photo (EXIF
* DateTimeOriginal first, falling back to a vision-LLM read of any visible
* on-screen date — chat timestamps, receipt dates, etc.) and store it as a
* pending suggestion on the case for the user to approve into the timeline.
*/
import express from 'express';
import path from 'path';
import fs from 'fs';
import { spawn } from 'child_process';
import iconv from 'iconv-lite';
import { getUserWorkspace } from '../config/config';
import { getOllamaClient } from '../agents/ollama-client';
const PHOTO_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'heic', 'heif', 'bmp', 'tiff']);
const TEXT_DOC_EXTS = new Set(['txt', 'md', 'csv']);
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const MAX_TEXT_DOC_BYTES = 5 * 1024 * 1024; // reading is synchronous; cap so a huge file can't block the event loop
function extOf(relPath: string): string {
return path.extname(relPath).slice(1).toLowerCase();
}
// The regex alone can't catch "2025-13-45" (bad month/day) — round-trip
// through Date and check the parts survived, which a real calendar date does
// and a bogus one (invalid month/day, or JS's day-rollover) does not.
function isValidCalendarDate(date: string): boolean {
if (!DATE_RE.test(date)) return false;
const [y, m, d] = date.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
}
function isPathInsideDir(base: string, target: string): boolean {
const resolvedBase = path.resolve(base);
const resolvedTarget = path.resolve(target);
if (resolvedBase === resolvedTarget) return true;
const rel = path.relative(resolvedBase, resolvedTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function detectiveFilesDir(username: string, caseId: string): string {
return path.join(getUserWorkspace(username), 'detective', caseId);
}
function detectiveCasesPath(username: string): string {
return path.join(getUserWorkspace(username), '.smallclaw', 'detective-cases.json');
}
function readExifDate(imgPath: string): Promise<string | undefined> {
const script = `
import json
try:
import pi_heif; pi_heif.register_heif_opener()
except Exception:
pass
try:
from PIL import Image, ExifTags
img = Image.open(${JSON.stringify(imgPath)})
raw = img._getexif() or {}
exif = {ExifTags.TAGS.get(k, str(k)): v for k, v in raw.items() if k in ExifTags.TAGS}
dt = exif.get("DateTimeOriginal") or exif.get("DateTime")
print(json.dumps({"dt": str(dt) if dt else None}))
except Exception as e:
print(json.dumps({"dt": None, "error": str(e)}))
`;
return new Promise((resolve) => {
let out = '';
try {
const proc = spawn('python3', ['-c', script]);
proc.stdout.on('data', (d: Buffer) => { out += d.toString(); });
proc.on('close', () => {
try {
const parsed = JSON.parse(out.trim().split('\n').pop() || '{}');
const m = parsed.dt ? String(parsed.dt).match(/^(\d{4})[:-](\d{2})[:-](\d{2})/) : null;
resolve(m ? `${m[1]}-${m[2]}-${m[3]}` : undefined);
} catch { resolve(undefined); }
});
proc.on('error', () => resolve(undefined));
} catch { resolve(undefined); }
});
}
const MONTH_DAY_RE = /^\d{2}-\d{2}$/;
// KakaoTalk chat lists show "8월 15일" style dates with no year anywhere, so
// forcing the model to answer a full YYYY-MM-DD often produces a false null.
// month_day lets it report what it actually saw; the caller combines that
// with the file's EXIF year as a best guess, which the user can still edit.
// The same call also asks for a short content label (used to rename the file
// when its original name is a generic camera/screenshot name) — for maps,
// the model is told to name the pinned/marked location instead.
async function readVisionDate(imgPath: string): Promise<{ date?: string; monthDay?: string; reason?: string; label?: string }> {
const ext = path.extname(imgPath).slice(1).toLowerCase();
const mime = ext === 'jpg' ? 'jpeg' : ext;
const b64 = fs.readFileSync(imgPath).toString('base64');
const prompt = '이 이미지는 사건 증거자료(카카오톡 대화 캡처, 문자메시지, 영수증, 지도, 문서 사진 등)입니다. '
+ '이미지 안에 실제로 보이는 날짜(대화창 날짜 구분선, 메시지 타임스탬프, 영수증/문서에 인쇄된 날짜 등)를 찾고, '
+ '내용을 요약하는 짧은 한국어 라벨도 함께 만들어 다음 JSON 형식으로만 답하세요 (코드블록 없이 순수 JSON만): '
+ '{"date":"연도까지 정확히 보이면 YYYY-MM-DD, 아니면 null",'
+ '"month_day":"연도는 안 보이지만 월/일은 보이면 MM-DD, 아니면 null",'
+ '"reason":"어디서 어떤 날짜를 읽었는지 한 줄 설명",'
+ '"label":"이미지 내용을 요약하는 2~6단어 내외의 짧은 한국어 표현 (예: 카톡_엄마와의_대화, 영수증_스타벅스, 문자_협박메시지). 지도 화면이고 위치 마커/핀이 있으면 마커가 가리키는 장소명을 반드시 포함 (예: 지도_강남역_인근)"} '
+ '카카오톡 채팅 목록처럼 연도 없이 "8월 15일"만 보이는 경우 date는 null, month_day는 "08-15"로 답하세요. '
+ '날짜를 아무것도 찾을 수 없으면 date, month_day 둘 다 null로 답하되 label은 항상 채우세요.';
// Never throws — a timeout/network error from the cloud model must not take
// down the whole suggestion (readExifDate runs alongside this in
// Promise.all, and an EXIF-only fallback is still useful even when vision
// is unavailable).
try {
const ollama = getOllamaClient();
const result = await ollama.chatWithThinking(
[{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', image_url: { url: `data:image/${mime};base64,${b64}` } },
],
}],
'executor',
{ model: 'kimi-k2.6:cloud', num_predict: 1500, temperature: 0.1 },
);
const raw = String(result.message?.content || '').trim();
const m = raw.match(/\{[\s\S]*\}/);
const parsed = JSON.parse(m ? m[0] : raw);
const date = parsed?.date && isValidCalendarDate(parsed.date) ? parsed.date : undefined;
const monthDay = parsed?.month_day && MONTH_DAY_RE.test(parsed.month_day) ? parsed.month_day : undefined;
const label = parsed?.label ? String(parsed.label).trim().slice(0, 60) : undefined;
return { date, monthDay, reason: parsed?.reason ? String(parsed.reason) : undefined, label };
} catch {
return {};
}
}
// Old Korean text evidence is often EUC-KR, not UTF-8 (same issue the
// built-in text editor in detective-app.html handles client-side). Only the
// first MAX_TEXT_DOC_BYTES are ever read — readFileSync is synchronous and a
// huge accidental upload would otherwise block the event loop just to have
// its content truncated to 6000 chars a moment later anyway.
function decodeTextFile(filePath: string): string {
const size = fs.statSync(filePath).size;
const fd = fs.openSync(filePath, 'r');
let buf: Buffer;
try {
const len = Math.min(size, MAX_TEXT_DOC_BYTES);
buf = Buffer.alloc(len);
fs.readSync(fd, buf, 0, len, 0);
} finally {
fs.closeSync(fd);
}
try {
const s = buf.toString('utf-8');
if (!s.includes('�')) return s;
} catch {}
try { return iconv.decode(buf, 'euc-kr'); } catch { return buf.toString('utf-8'); }
}
// Text documents have no EXIF and nothing to "look at", so this reads the
// actual content and asks the model to find a date mentioned in the text
// (a letter/report date, a diary entry, an invoice date, etc.) the same way
// readVisionDate does for photos — same JSON contract, same date/month_day/
// reason/label fields, so the rest of the pipeline doesn't need to care
// whether a suggestion came from a photo or a document.
async function readTextDate(filePath: string): Promise<{ date?: string; monthDay?: string; reason?: string; label?: string }> {
const content = decodeTextFile(filePath).slice(0, 6000);
const prompt = '이 텍스트는 사건 증거자료 문서입니다. 내용 중에 실제로 언급된 날짜(작성일, 사건 발생일, 일기/메모의 날짜 등)를 찾고, '
+ '내용을 요약하는 짧은 한국어 라벨도 함께 만들어 다음 JSON 형식으로만 답하세요 (코드블록 없이 순수 JSON만): '
+ '{"date":"연도까지 정확히 확인되면 YYYY-MM-DD, 아니면 null",'
+ '"month_day":"연도는 불명확하지만 월/일은 확인되면 MM-DD, 아니면 null",'
+ '"reason":"어떤 문구에서 날짜를 읽었는지 한 줄 설명",'
+ '"label":"문서 내용을 요약하는 2~6단어 내외의 짧은 한국어 표현 (예: 메모_병원방문기록, 진술서_초안)"} '
+ '날짜를 아무것도 찾을 수 없으면 date, month_day 둘 다 null로 답하되 label은 항상 채우세요.\n\n--- 문서 내용 ---\n' + content;
// Never throws — see readVisionDate's comment on the same pattern.
try {
const ollama = getOllamaClient();
const result = await ollama.chatWithThinking(
[{ role: 'user', content: prompt }],
'executor',
{ model: 'kimi-k2.6:cloud', num_predict: 1500, temperature: 0.1 },
);
const raw = String(result.message?.content || '').trim();
const m = raw.match(/\{[\s\S]*\}/);
const parsed = JSON.parse(m ? m[0] : raw);
const date = parsed?.date && isValidCalendarDate(parsed.date) ? parsed.date : undefined;
const monthDay = parsed?.month_day && MONTH_DAY_RE.test(parsed.month_day) ? parsed.month_day : undefined;
const label = parsed?.label ? String(parsed.label).trim().slice(0, 60) : undefined;
return { date, monthDay, reason: parsed?.reason ? String(parsed.reason) : undefined, label };
} catch {
return {};
}
}
// Generic auto-generated camera/screenshot names carry no information, so the
// content label is used instead when renaming. A name the user (or the phone)
// already gave meaning to — like "엄마 8-7.jpg" — is left untouched.
function isGenericFileName(stem: string): boolean {
const s = stem.trim();
if (!s) return true;
const compact = s.replace(/[\s_\-.]+/g, '');
if (/^\d+$/.test(compact)) return true; // pure timestamp/number
if (/^[0-9a-f]{6,}$/i.test(compact)) return true; // hash-like
const genericPrefixes = /^(img|image|photo|pic|dsc|dcim|pxl|vid|video|mov|screenshot|scrshot|kakaotalk|kakao|scr|frame|snapshot|capture|save|received|inboundshare|스크린샷|캡처|사진|카카오톡)/i;
return genericPrefixes.test(compact);
}
function sanitizeFileNameSegment(seg: string): string {
const s = String(seg).replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, '_').trim();
if (!s || s === '.' || s === '..') return '_';
return s;
}
// A file already renamed by this pipeline starts with "YYYY-MM-DD_" (or "-",
// in case it was ever produced by a manually-typed name). Re-running date
// recognition/commit on it — e.g. the user re-triggers 🕐+ on an already
// processed file — must not stack another date prefix on top of the old one.
// The regex alone can't tell a pipeline-generated prefix apart from a user's
// own "2024-01-15-영수증.jpg"-style name, so it's only trusted when `relPath`
// is already a relPath the pipeline itself recorded (an existing timeline
// entry or a still-pending suggestion) — otherwise the name is left whole.
const EXISTING_DATE_PREFIX_RE = /^\d{4}-\d{2}-\d{2}[_-](.+)$/;
function stripExistingDatePrefix(stem: string, relPath: string, c: any): string {
const m = stem.match(EXISTING_DATE_PREFIX_RE);
if (!m) return stem;
const knownToUs = Array.isArray(c?.timeline) && c.timeline.some((t: any) => t.relPath === relPath)
|| Array.isArray(c?.dateSuggestions) && c.dateSuggestions.some((s: any) => s.relPath === relPath);
return knownToUs ? m[1] : stem;
}
interface DateSuggestion {
id: string;
relPath: string;
fileName: string;
url: string;
kind: 'photo' | 'text';
date: string | null;
source: 'exif' | 'vision' | 'vision+exif_year' | 'text';
reason: string | null;
label: string | null;
nameOverride: string;
createdAt: string;
}
function readCasesStore(casesPath: string): { cases: any[] } {
let store: { cases: any[] } = { cases: [] };
try { if (fs.existsSync(casesPath)) store = JSON.parse(fs.readFileSync(casesPath, 'utf-8')); } catch {}
return store;
}
async function suggestPhotoDate(username: string, caseId: string, relPath: string): Promise<DateSuggestion> {
const dir = detectiveFilesDir(username, caseId);
const segments = relPath.split('/').filter(Boolean);
const absPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, absPath) || !fs.existsSync(absPath)) throw new Error('file not found');
const isText = TEXT_DOC_EXTS.has(extOf(relPath));
const caseAtStart = (readCasesStore(detectiveCasesPath(username)).cases || []).find((x: any) => x.id === caseId);
let date: string | undefined;
let source: 'exif' | 'vision' | 'vision+exif_year' | 'text' = isText ? 'text' : 'vision';
let reason: string | undefined;
let contentLabel: string | undefined;
if (isText) {
const text = await readTextDate(absPath);
date = text.date;
reason = text.reason;
contentLabel = text.label;
// Text documents rarely have a year-less date the way KakaoTalk chat
// lists do, and there's no EXIF to guess a year from, so month_day alone
// isn't used here — it just falls through to no date if that's all there is.
} else {
// Vision first: for chat/message screenshots the file's own EXIF timestamp
// is when the screenshot was saved, not when the conversation happened, so
// the date printed inside the image is the one that actually matters. EXIF
// is only used as a fallback (plain photos with no date text) or to guess
// the year when the screenshot shows month/day but no year (KakaoTalk never
// shows the year in its chat list).
const [vision, exifDate] = await Promise.all([readVisionDate(absPath), readExifDate(absPath)]);
contentLabel = vision.label;
if (vision.date) {
date = vision.date;
reason = vision.reason;
} else if (vision.monthDay && exifDate) {
date = `${exifDate.slice(0, 4)}-${vision.monthDay}`;
source = 'vision+exif_year';
reason = `${vision.reason ? vision.reason + ' ' : ''}(연도는 이미지에 없어 파일 메타데이터의 연도로 추정 — 반드시 확인하세요)`.trim();
} else if (exifDate) {
date = exifDate;
source = 'exif';
reason = vision.reason;
} else {
reason = vision.reason;
}
}
// Default "what will the new name look like" shown to the user for review —
// a meaningful original name (e.g. "엄마 8-7.jpg") is kept as-is, a generic
// camera/screenshot name is replaced by the AI's content label. Either way
// it's just a prefill: the user can type over it before committing.
const originalStem = stripExistingDatePrefix(path.basename(relPath, path.extname(relPath)), relPath, caseAtStart);
const nameOverride = (isGenericFileName(originalStem) && contentLabel) ? contentLabel : originalStem;
const suggestion: DateSuggestion = {
id: 'sug_' + Math.random().toString(36).slice(2, 10),
relPath,
fileName: path.basename(relPath),
url: `/api/files/detective/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
kind: isText ? 'text' : 'photo',
date: date || null,
source,
reason: reason || null,
label: contentLabel || null,
nameOverride,
createdAt: new Date().toISOString(),
};
const casesPath = detectiveCasesPath(username);
const store = readCasesStore(casesPath);
const c = (store.cases || []).find((x: any) => x.id === caseId);
if (c) {
if (!Array.isArray(c.dateSuggestions)) c.dateSuggestions = [];
c.dateSuggestions = c.dateSuggestions.filter((s: any) => s.relPath !== relPath);
c.dateSuggestions.push(suggestion);
fs.mkdirSync(path.dirname(casesPath), { recursive: true });
const tmp = `${casesPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
fs.renameSync(tmp, casesPath);
}
return suggestion;
}
// Renames the photo to "<date>_<content-or-original-name>.<ext>", copies the
// untouched original into a "원본/" subfolder next to it (so a wrong guess
// never loses the source file), and records the date on the case timeline —
// all as one action, since the file only gets touched once the user commits.
async function commitPhotoToTimeline(
username: string, caseId: string, relPath: string, date: string, label?: string,
): Promise<{ relPath: string; name: string; url: string; text: string; id: string }> {
if (!isValidCalendarDate(date)) throw new Error('invalid date');
const dir = detectiveFilesDir(username, caseId);
const segments = relPath.split('/').filter(Boolean);
const absPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, absPath) || !fs.existsSync(absPath)) throw new Error('file not found');
const casesPath = detectiveCasesPath(username);
const store = readCasesStore(casesPath);
const c = (store.cases || []).find((x: any) => x.id === caseId);
const fileDir = path.dirname(absPath);
const folderRel = segments.slice(0, -1).join('/');
const originalName = segments[segments.length - 1];
const ext = path.extname(originalName);
const originalStem = stripExistingDatePrefix(path.basename(originalName, ext), relPath, c);
// `label` here is whatever the user left in the editable name field (it
// starts prefilled with a sensible default, see nameOverride above, but the
// caller may have edited or cleared it) — an explicit non-empty value always
// wins; empty means "no override, keep the original file name". Either way
// stripExistingDatePrefix above keeps a re-commit from stacking dates.
const core = label && label.trim()
? sanitizeFileNameSegment(stripExistingDatePrefix(label.trim(), relPath, c))
: sanitizeFileNameSegment(originalStem);
let newName = `${date}_${core}${ext}`;
let newAbsPath = path.join(fileDir, newName);
let n = 2;
while (fs.existsSync(newAbsPath) && newAbsPath !== absPath) {
newName = `${date}_${core}_${n}${ext}`;
newAbsPath = path.join(fileDir, newName);
n++;
}
const backupDir = path.join(fileDir, '원본');
fs.mkdirSync(backupDir, { recursive: true });
let backupPath = path.join(backupDir, originalName);
let bn = 2;
while (fs.existsSync(backupPath)) {
backupPath = path.join(backupDir, `${path.basename(originalName, ext)}_${bn}${ext}`);
bn++;
}
fs.copyFileSync(absPath, backupPath);
fs.renameSync(absPath, newAbsPath);
const newRelPath = (folderRel ? `${folderRel}/` : '') + newName;
const newUrl = `/api/files/detective/${caseId}/${newRelPath.split('/').map(encodeURIComponent).join('/')}`;
const icon = TEXT_DOC_EXTS.has(extOf(newName)) ? '📄' : '📷';
const text = `${icon} ${newName}`;
const id = 'tl_' + Math.random().toString(36).slice(2, 8);
if (c) {
if (!Array.isArray(c.timeline)) c.timeline = [];
c.timeline.push({ id, date, text, relPath: newRelPath, url: newUrl });
if (Array.isArray(c.dateSuggestions)) {
c.dateSuggestions = c.dateSuggestions.filter((s: any) => s.relPath !== relPath);
}
fs.mkdirSync(path.dirname(casesPath), { recursive: true });
const tmp = `${casesPath}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
fs.renameSync(tmp, casesPath);
}
return { relPath: newRelPath, name: newName, url: newUrl, text, id };
}
export function registerDetectiveDateRoutes(app: express.Application, getSessionUser: (req: express.Request) => any): void {
app.post('/api/detective/files/date-suggest', async (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
const caseId = String(req.body?.caseId || '');
const relPath = String(req.body?.relPath || '');
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some((s: string) => s === '..' || s === '.')) {
res.status(400).json({ error: 'invalid' }); return;
}
const ext = extOf(relPath);
if (!PHOTO_EXTS.has(ext) && !TEXT_DOC_EXTS.has(ext)) { res.status(400).json({ error: 'unsupported file type' }); return; }
try {
const suggestion = await suggestPhotoDate(session.username, caseId, relPath);
res.json({ success: true, suggestion });
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
app.post('/api/detective/files/add-to-timeline', async (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ error: 'Unauthorized' }); return; }
const caseId = String(req.body?.caseId || '');
const relPath = String(req.body?.relPath || '');
const date = String(req.body?.date || '');
const label = req.body?.label ? String(req.body.label) : undefined;
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some((s: string) => s === '..' || s === '.')) {
res.status(400).json({ error: 'invalid' }); return;
}
if (!isValidCalendarDate(date)) { res.status(400).json({ error: 'invalid date' }); return; }
try {
const result = await commitPhotoToTimeline(session.username, caseId, relPath, date, label);
res.json({ success: true, ...result });
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
}
+276 -1
View File
@@ -23,6 +23,7 @@ import { registerMusicRoutes } from './routes-music';
import { registerMCPRoutes } from './routes-mcp';
import { registerLanguageRoutes } from './routes-language';
import { registerDentalRoutes } from './routes-dental';
import { registerDetectiveDateRoutes } from './routes-detective-dates';
import {
getConfig,
getAgents,
@@ -9334,6 +9335,204 @@ app.put('/api/detective/cases', (req, res) => {
res.json({ success: true });
});
// ─── Per-case Detective File Attachments ─────────────────────────────────────
// Stored at <user-workspace>/detective/<caseId>/, served via /api/files/
function detectiveFilesDir(username: string, caseId: string): string {
return path.join(getUserWorkspace(username), 'detective', caseId);
}
function sanitizeDetectivePathSegment(seg: string): string {
const s = seg.replace(/[\x00-\x1f\x7f:*?"<>|]/g, '_').trim();
if (!s || s === '.' || s === '..') return '_';
return s;
}
// Folder uploads send a relative path (e.g. "2024/IMG001.jpg") as the
// filename. Each path segment is sanitized and the real directory structure
// is recreated under the case folder, so a whole subfolder can later be
// deleted as a unit instead of only file-by-file.
function resolveDetectiveUploadPath(baseDir: string, originalName: string): { relPath: string; absPath: string } {
const rawSegments = String(originalName || 'upload').split(/[\\/]+/).filter(Boolean);
const segments = (rawSegments.length ? rawSegments : ['upload']).map(sanitizeDetectivePathSegment);
const folderSegs = segments.slice(0, -1);
let fileSeg = segments[segments.length - 1] || 'upload';
const dir = path.join(baseDir, ...folderSegs);
fs.mkdirSync(dir, { recursive: true });
if (fs.existsSync(path.join(dir, fileSeg))) {
const ext = path.extname(fileSeg);
const stem = path.basename(fileSeg, ext);
fileSeg = `${stem}_${Date.now()}${ext}`;
}
const relPath = [...folderSegs, fileSeg].join('/');
return { relPath, absPath: path.join(dir, fileSeg) };
}
function walkDetectiveFiles(dir: string, relBase = ''): Array<{ relPath: string; name: string; size: number; mtime: string }> {
if (!fs.existsSync(dir)) return [];
const out: Array<{ relPath: string; name: string; size: number; mtime: string }> = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name === '원본') continue; // backups of renamed photos — kept on disk, hidden from the evidence list
const abs = path.join(dir, entry.name);
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
out.push(...walkDetectiveFiles(abs, rel));
} else if (entry.isFile()) {
const stat = fs.statSync(abs);
out.push({ relPath: rel, name: entry.name, size: stat.size, mtime: stat.mtime.toISOString() });
}
}
return out;
}
const DETECTIVE_FILE_CATEGORIES: Record<string, string> = {
jpg: 'photo', jpeg: 'photo', png: 'photo', gif: 'photo', webp: 'photo', bmp: 'photo', heic: 'photo', heif: 'photo', tiff: 'photo',
mp4: 'video', mov: 'video', avi: 'video', mkv: 'video', webm: 'video', m4v: 'video', wmv: 'video',
pdf: 'document', doc: 'document', docx: 'document', xls: 'document', xlsx: 'document', ppt: 'document', pptx: 'document', txt: 'document', hwp: 'document', hwpx: 'document',
};
function detectiveFileCategory(name: string): string {
const ext = path.extname(name).slice(1).toLowerCase();
return DETECTIVE_FILE_CATEGORIES[ext] || 'other';
}
app.get('/api/detective/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const caseId = String(req.query.caseId || '');
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) return res.status(400).json({ error: 'invalid caseId' });
const dir = detectiveFilesDir(session.username, caseId);
const files = walkDetectiveFiles(dir).map(e => {
const segs = e.relPath.split('/');
return {
name: e.name,
relPath: e.relPath,
folder: segs.length > 1 ? segs[0] : '',
size: e.size,
mtime: e.mtime,
url: `/api/files/detective/${caseId}/${segs.map(encodeURIComponent).join('/')}`,
category: detectiveFileCategory(e.name),
};
});
res.json({ files });
});
app.post('/api/detective/upload', (req, res) => {
const session = getSessionUser(req);
if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
const caseId = String(req.query.caseId || '');
if (!/^[a-zA-Z0-9_-]+$/.test(caseId)) { res.status(400).json({ success: false, error: 'invalid caseId' }); return; }
const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) {
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('binary');
const boundaryDelim = '--' + boundary;
let filename = 'upload.bin';
let filetype = 'application/octet-stream';
let fileData: Buffer | null = null;
const parts = raw.split(boundaryDelim);
for (const part of parts) {
if (!part || part.trim() === '--' || part.trim() === '') continue;
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
filename = raw8;
}
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
if (ctMatch) filetype = ctMatch[1].trim();
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
if (fileData.length > 50 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 50MB)' }); return; }
const dir = detectiveFilesDir(session.username, caseId);
fs.mkdirSync(dir, { recursive: true });
const { relPath, absPath } = resolveDetectiveUploadPath(dir, filename);
fs.writeFileSync(absPath, fileData);
res.json({
success: true,
name: path.basename(relPath),
relPath,
size: fileData.length,
type: filetype,
url: `/api/files/detective/${caseId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
category: detectiveFileCategory(relPath),
});
});
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
});
app.delete('/api/detective/files', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const caseId = String(req.body?.caseId || '');
// relPath may point at a single file or a whole subfolder (folder-level
// batch delete); name is kept as a fallback for the old root-file-only shape.
const relPath = String(req.body?.relPath || req.body?.name || '');
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
return res.status(400).json({ error: 'invalid' });
}
const dir = detectiveFilesDir(session.username, caseId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
try {
const stat = fs.statSync(targetPath);
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
else fs.unlinkSync(targetPath);
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
}
res.json({ success: true });
});
// Overwrites a text evidence file (.txt/.md/.csv) with UTF-8 content from the
// built-in text editor -- lets legacy EUC-KR/CP949 documents be normalized to
// UTF-8 on save instead of staying garbled forever.
app.put('/api/detective/files/content', (req, res) => {
const session = getSessionUser(req);
if (!session) return res.status(401).json({ error: 'Unauthorized' });
const caseId = String(req.body?.caseId || '');
const relPath = String(req.body?.relPath || '');
const content = req.body?.content;
const segments = relPath.split('/').filter(Boolean);
if (!/^[a-zA-Z0-9_-]+$/.test(caseId) || !segments.length || segments.some(s => s === '..' || s === '.') || typeof content !== 'string') {
return res.status(400).json({ error: 'invalid' });
}
const dir = detectiveFilesDir(session.username, caseId);
const targetPath = path.join(dir, ...segments);
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(targetPath)) return res.status(404).json({ error: 'File not found' });
try {
fs.writeFileSync(targetPath, content, 'utf-8');
const stat = fs.statSync(targetPath);
res.json({ success: true, size: stat.size, mtime: stat.mtime.toISOString() });
} catch (e: any) {
res.status(500).json({ error: String(e?.message || e) });
}
});
// ─── Per-user Investor Watchlist ─────────────────────────────────────────────
// Stored at <user-workspace>/.smallclaw/investor-watchlist.json
@@ -9543,6 +9742,77 @@ app.use('/wavacity', express.static(path.join(webUiPath, 'wavacity'), {
}
}));
// Entry point for opening the PDF editor scoped to a case: sets the
// dt_pdf_case cookie server-side, then redirects to a clean /pdf-editor/
// URL with no query string. (Stirling-PDF's root path has a bug where a
// query string on the bare context-path root causes a broken redirect —
// see /pdf-editor below — so the case id is never sent in that request.)
app.get('/pdf-editor-open', (req: express.Request, res: express.Response) => {
const session = getSessionUser(req);
if (!session) { res.redirect('/login.html'); return; }
const caseId = String(req.query.caseId || '');
if (/^[a-zA-Z0-9_-]+$/.test(caseId)) {
res.setHeader('Set-Cookie', `dt_pdf_case=${caseId}; Path=/pdf-editor; HttpOnly; SameSite=Lax; Max-Age=3600`);
}
res.redirect('/pdf-editor/');
});
// Stirling-PDF — self-hosted PDF editor (Docker, localhost:8090), reverse-proxied
// behind the gateway's own auth. Container runs with SERVER_SERVLET_CONTEXT_PATH=
// /pdf-editor so its self-referencing asset/API URLs match this mount point.
//
// Auto-save: the dt_pdf_case cookie (set by /pdf-editor-open above) ties this
// browser tab to a case. Any response that looks like a finished result (PDF
// content-type + a Content-Disposition filename, as opposed to preview/asset
// traffic) is teed into that case's folder in addition to being streamed to
// the browser as normal, so "download" in the editor also lands in the case.
app.use('/pdf-editor', (req: express.Request, res: express.Response) => {
const cookies = parseCookies(req);
const caseId = cookies['dt_pdf_case'] || '';
const session = getSessionUser(req);
const proxyReq = http.request({
hostname: '127.0.0.1',
port: 8090,
path: req.originalUrl,
method: req.method,
headers: { ...req.headers, host: '127.0.0.1:8090' },
}, (proxyRes) => {
const ct = String(proxyRes.headers['content-type'] || '');
const cd = String(proxyRes.headers['content-disposition'] || '');
const isResult = session && caseId && /^[a-zA-Z0-9_-]+$/.test(caseId)
&& ct.includes('application/pdf') && /filename=/i.test(cd);
if (!isResult) {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
return;
}
const chunks: Buffer[] = [];
proxyRes.on('data', (c: Buffer) => chunks.push(c));
proxyRes.on('end', () => {
const body = Buffer.concat(chunks);
try {
const dir = detectiveFilesDir(session!.username, caseId);
fs.mkdirSync(dir, { recursive: true });
let filename = 'edited.pdf';
const fnStar = cd.match(/filename\*=UTF-8''([^;]+)/i);
const fnPlain = cd.match(/filename="?([^";]+)"?/i);
if (fnStar) { try { filename = decodeURIComponent(fnStar[1]); } catch {} }
else if (fnPlain) filename = fnPlain[1];
const { relPath, absPath } = resolveDetectiveUploadPath(dir, filename);
fs.writeFileSync(absPath, body);
console.log(`[pdf-editor] saved result to case ${caseId}: ${relPath}`);
} catch (err) {
console.error('[pdf-editor] auto-save failed:', err);
}
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
res.end(body);
});
});
proxyReq.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'PDF editor unavailable' }); });
req.pipe(proxyReq);
});
app.use(express.static(webUiPath, { setHeaders: (res) => { res.setHeader('Cache-Control', 'no-cache'); } }));
// Serve code directory files for HTML preview (window.open)
@@ -9670,7 +9940,11 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
try { reqPath = decodeURIComponent(reqPath); } catch {}
console.log('[files] reqPath:', reqPath);
if (!reqPath) { res.status(400).json({ error: 'No file path provided' }); return; }
if (reqPath.includes('..')) { res.status(403).json({ error: 'Access denied' }); return; }
// Segment-based check — a filename that merely *contains* ".." (e.g. a
// sentence ending in a period right before the extension, "....txt") is not
// a traversal attempt; only an actual ".." path segment is. The isAllowed
// check below (on the resolved absolute path) is the real security boundary.
if (reqPath.split('/').some(seg => seg === '..')) { res.status(403).json({ error: 'Access denied' }); return; }
const user = (req as any).user;
const globalWorkspace = path.resolve(getConfig().getConfig().workspace?.path || process.cwd());
const workspacePath = user?.workspace || globalWorkspace;
@@ -13131,6 +13405,7 @@ app.get('/api/midi-download', async (req: any, res: any) => {
registerLanguageRoutes(app);
registerDentalRoutes(app);
registerDetectiveDateRoutes(app, getSessionUser);
// GET /api/excel/list — list xlsx files from user workspace
app.get('/api/excel/list', (req, res) => {
+768 -11
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function escAttr(s){return String(s||'').replace(/'/g,'&#39;').replace(/"/g,'&quot;');}
// 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 (&#39;) 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();