fix: 작업실 앱 전수 검토 결함 일괄 수정 — 채팅 저장 덮어쓰기·업로드 크래시·XSS 외
- 저장: saveDebounce 미해제로 채팅 도구 저장 내용이 새로고침 때 화면 옛 상태로 덮이던 버그. 서버 동기 스냅샷 기반 dirty(바뀐 프로젝트만 PUT), 실패 시 재시도, 무변경 PUT 제거 - 업로드: 파일명 %(100%.stl)로 decodeURIComponent가 'end' 콜백에서 던져 프로세스가 죽던 버그 + 수신 중 크기 제한, 콜백 예외 응답 - XSS: escAttr가 따옴표만 처리해 부품/프로젝트/파일명이 innerHTML에서 실행되던 것, 항목 id 화이트리스트, 링크 href safeUrl - 헛보고 가드: JS \b가 한글에 안 먹어 "저장했어/했다"가 통과하던 정규식, 명사 없는 후속 확인 질문 - 도구: 정확일치 우선 검색, 중복 생성/추가 방지, 세션(ws_<id>) 프로젝트 기본 대상 - 프로젝트 삭제 시 첨부파일·채팅 세션 정리, 손상 JSON 백업(.corrupt), 옛 전체문서 PUT 제거, PUT 본문 검증, 백그라운드 탭 장비 폴링 중지, 전송 중 프로젝트 전환 잠금 - 회귀 테스트 16건 추가(프런트 저장 로직은 vm 시뮬레이션) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1415,7 +1415,7 @@ print(json.dumps({'slides': slides, 'total': len(prs.slides)}, ensure_ascii=Fals
|
||||
// browser/desktop, memory, workflow, presentation) keep their own branches.
|
||||
const tool = getToolRegistry().get(name);
|
||||
if (!tool) return { name, args, result: `Unknown tool: ${name}`, error: true };
|
||||
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath });
|
||||
const tr = await tool.execute({ ...args, _workspace: workspacePath, _workspacePath: workspacePath, ...(name === 'workshop_project' ? { _sessionId: sessionId } : {}) });
|
||||
const _resultText = tr.stdout || tr.error || '';
|
||||
const _hasImageMd = /!\[[^\]]*\]\(\/api\/files\/[^)]+\)/.test(_resultText);
|
||||
// 작업실(ws_) 세션 크로스 프로젝트 경고 — list/python_eval 등 default 분기로 오는 모든
|
||||
|
||||
@@ -378,14 +378,20 @@ export function claimsMessageSent(text: string): boolean {
|
||||
// get만 하고 add_note 없이 저장 보고). 프롬프트 규칙(b327eef)으로도 안 잡혀 가드로 승격.
|
||||
export function isWorkshopMutationRequest(message: string): boolean {
|
||||
const m = String(message || '');
|
||||
return /(메모|개요|부품|작업|단계|프로젝트|작업실|readme)/i.test(m)
|
||||
&& /(저장|추가|등록|업데이트|수정|삭제|옮기|정리|바꿔|변경|넣어|체크)/.test(m);
|
||||
// ① 대상 명사 + 변경 동사("메모 저장해줘", "부품 추가")
|
||||
if (/(메모|개요|부품|작업|단계|프로젝트|작업실|readme)/i.test(m)
|
||||
&& /(저장|추가|등록|업데이트|수정|삭제|옮기|정리|바꿔|변경|넣어|체크)/.test(m)) return true;
|
||||
// ② 명사 없는 후속 확인 질문("저장 했니?", "등록됐어?") — 09-24 실측: 이 턴의 답변
|
||||
// "저장 완료했습니다"가 명사가 없다는 이유로 가드를 통과했다.
|
||||
return /(저장|등록|반영|추가|업데이트|수정)\s*(했|됐|되었)\s*(니|나|냐|어\s*\?|어요\s*\?|습니까|지\s*\?)/.test(m);
|
||||
}
|
||||
|
||||
export function claimsWorkshopSaved(text: string): boolean {
|
||||
const s = String(text || '');
|
||||
// 종결접미사(습니다/어요/어/다) 필수 — "저장했너?"/"저장했던" 같은 질문형·수식형은 안 걸린다.
|
||||
return /(저장|등록|추가|업데이트|수정|반영)(했|됐|되었)(습니다|어요|어\b|다\b)|저장\s*완료|반영\s*완료|저장해\s?드렸습니다|넣어\s?드렸습니다|등록\s*완료/.test(s)
|
||||
// 종결접미사 필수 — "저장했너?"/"저장했던"/"저장했다는" 같은 질문형·수식형은 안 걸린다.
|
||||
// 끝 경계는 \b가 아니라 (?![가-힣?]): JS의 \b는 한글을 단어문자로 안 봐서 "저장했어."
|
||||
// "저장했다."가 통째로 빠져나갔다(09-24 검토에서 재현).
|
||||
return /(저장|등록|추가|업데이트|수정|반영)\s?(했|됐|되었)(습니다|어요|어|다|네요)(?![가-힣??])|저장\s*완료|반영\s*완료|저장해\s?드렸습니다|넣어\s?드렸습니다|등록\s*완료/.test(s)
|
||||
|| /\b(saved (it|to the)|has been saved|was saved)\b/i.test(s);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,24 @@ import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
import { loadWorkshop, saveWorkshop, upsertProject, removeProject } from './workshop-storage';
|
||||
import { loadWorkshop, saveWorkshop, upsertProject, removeProject, normalizeProjectInput } from './workshop-storage';
|
||||
import { clearHistory } from '../session';
|
||||
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
|
||||
|
||||
const APP_TYPE = 'workshop';
|
||||
|
||||
// multipart filename 디코딩. 옛 코드는 decodeURIComponent를 그냥 불러 "100%.stl" 같은
|
||||
// 파일명에서 URIError가 났고, 그 위치가 req 'end' 콜백이라 게이트웨이 프로세스가 죽었다
|
||||
// (2026-09-24 검토에서 재현). RFC5987(filename*=UTF-8''%..)일 때만 퍼센트 디코딩하고,
|
||||
// 실패하면 원문을 쓴다. 일반 filename="..."은 바이너리→UTF-8 복원.
|
||||
export function decodeUploadFilename(raw: string): string {
|
||||
try {
|
||||
const decoded = decodeURIComponent(raw);
|
||||
if (decoded !== raw) return decoded;
|
||||
} catch { /* 퍼센트가 리터럴인 이름 — 아래에서 그대로 처리 */ }
|
||||
return Buffer.from(raw, 'binary').toString('utf-8');
|
||||
}
|
||||
|
||||
// walkCaseFiles만으로는 파일이 하나도 없는 빈 폴더가 안 보이므로, 디렉토리 구조를 따로 순회.
|
||||
function listCaseFolders(dir: string, relBase = ''): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
@@ -43,16 +56,8 @@ export function registerWorkshopRoutes(
|
||||
res.json(loadWorkshop(getUserWorkspace(session.username)));
|
||||
});
|
||||
|
||||
app.put('/api/workshop/data', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const { projects, activeProjectId } = req.body || {};
|
||||
if (!Array.isArray(projects)) {
|
||||
return res.status(400).json({ error: 'projects array required' });
|
||||
}
|
||||
saveWorkshop(getUserWorkspace(session.username), { projects, activeProjectId: String(activeProjectId || '') });
|
||||
res.json({ success: true });
|
||||
});
|
||||
// (옛 전체문서 PUT /api/workshop/data는 last-write-wins로 다른 프로젝트 편집을 통째로 덮어써서
|
||||
// 2026-09-24 제거 — 저장은 아래 프로젝트 단위 API만 쓴다.)
|
||||
|
||||
// ── 프로젝트 단위 저장 API(2026-09-24) ─────────────────────────────────────
|
||||
// 예전의 전체문서 PUT(last-write-wins)은 대시보드 저장과 채팅 도구 저장이 겹치면
|
||||
@@ -64,14 +69,11 @@ export function registerWorkshopRoutes(
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const id = String(req.params.id || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'invalid projectId' });
|
||||
const incoming = req.body || {};
|
||||
if (incoming.id !== id) return res.status(400).json({ error: 'body.id must match :id' });
|
||||
if (!Array.isArray(incoming.parts) || !Array.isArray(incoming.phases) || !String(incoming.name || '').trim()) {
|
||||
return res.status(400).json({ error: 'name/parts/phases required' });
|
||||
}
|
||||
const checked = normalizeProjectInput(req.body, id);
|
||||
if (!checked.ok) return res.status(400).json({ error: checked.error });
|
||||
const workspace = getUserWorkspace(session.username);
|
||||
const data = loadWorkshop(workspace);
|
||||
upsertProject(data, { ...incoming, id });
|
||||
upsertProject(data, checked.project);
|
||||
saveWorkshop(workspace, data);
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -85,7 +87,15 @@ export function registerWorkshopRoutes(
|
||||
const data = loadWorkshop(workspace);
|
||||
if (!removeProject(data, id)) return res.status(404).json({ error: 'not found' });
|
||||
saveWorkshop(workspace, data);
|
||||
res.json({ success: true });
|
||||
// 고아 정리: 첨부파일 폴더(workshop/<id>/)와 이 프로젝트 전용 채팅 세션(ws_<id>).
|
||||
// 삭제 확인창이 "되돌릴 수 없습니다"라고 하므로 실제로도 남기지 않는다.
|
||||
let filesRemoved = true;
|
||||
try {
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, id);
|
||||
if (isPathInsideDir(path.join(workspace, APP_TYPE), dir) && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch { filesRemoved = false; }
|
||||
try { clearHistory(`ws_${id}`, session.username); } catch { /* 세션이 없으면 무시 */ }
|
||||
res.json({ success: true, filesRemoved });
|
||||
});
|
||||
|
||||
// 활성 프로젝트 포인터만 단독 저장 — 포인터는 값 하나라 last-write-wins로 충분하다.
|
||||
@@ -153,61 +163,82 @@ export function registerWorkshopRoutes(
|
||||
const boundary = contentType.split('boundary=')[1];
|
||||
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
|
||||
|
||||
// 100MB 제한을 body를 다 받은 뒤가 아니라 받는 도중에 건다 — 안 그러면 거대한 요청이
|
||||
// 바이너리 문자열 변환까지 거치며 수백 MB를 잡아먹는다(멀티파트 오버헤드 1MB 여유).
|
||||
const MAX_BODY = 100 * 1024 * 1024 + 1024 * 1024;
|
||||
const declared = Number(req.headers['content-length'] || 0);
|
||||
if (declared > MAX_BODY) { res.status(413).json({ success: false, error: 'File too large (max 100MB)' }); 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;
|
||||
let received = 0;
|
||||
let aborted = false;
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
if (aborted) return;
|
||||
received += chunk.length;
|
||||
if (received > MAX_BODY) {
|
||||
aborted = true;
|
||||
chunks.length = 0;
|
||||
res.status(413).json({ success: false, error: 'File too large (max 100MB)' });
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (aborted) return;
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString('binary');
|
||||
const boundaryDelim = '--' + boundary;
|
||||
|
||||
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
|
||||
if (fileData.length > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; }
|
||||
let filename = 'upload.bin';
|
||||
let filetype = 'application/octet-stream';
|
||||
let fileData: Buffer | null = null;
|
||||
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const targetName = folder ? `${folder}/${filename}` : filename;
|
||||
const { relPath, absPath } = resolveUploadPath(dir, targetName);
|
||||
fs.writeFileSync(absPath, fileData);
|
||||
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;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
name: path.basename(relPath),
|
||||
relPath,
|
||||
size: fileData.length,
|
||||
type: filetype,
|
||||
url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
category: fileCategory(relPath),
|
||||
});
|
||||
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||
?? header.match(/filename="([^"]+)"/);
|
||||
if (fnMatch) {
|
||||
filename = decodeUploadFilename(fnMatch[1]);
|
||||
}
|
||||
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 > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; }
|
||||
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const targetName = folder ? `${folder}/${filename}` : filename;
|
||||
const { relPath, absPath } = resolveUploadPath(dir, targetName);
|
||||
fs.writeFileSync(absPath, fileData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
name: path.basename(relPath),
|
||||
relPath,
|
||||
size: fileData.length,
|
||||
type: filetype,
|
||||
url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
category: fileCategory(relPath),
|
||||
});
|
||||
} catch (e: any) {
|
||||
// 'end' 콜백 안의 예외는 express가 못 잡아 프로세스가 죽는다 — 반드시 여기서 응답.
|
||||
if (!res.headersSent) res.status(500).json({ success: false, error: String(e?.message || e) });
|
||||
}
|
||||
});
|
||||
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||
});
|
||||
|
||||
@@ -73,6 +73,55 @@ export function removeProject(data: WorkshopData, id: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 프로젝트 PUT 본문 검증·정규화(순수함수). 필수 필드가 없으면 에러 문자열을, 통과하면
|
||||
// 누락된 선택 필드(notes/description/tasks/links)를 채운 프로젝트를 돌려준다. 검증이
|
||||
// 없으면 notes가 없는 본문이 저장돼 이후 채팅 도구의 project.notes.trim()이 죽는다.
|
||||
export function normalizeProjectInput(incoming: any, id: string): { ok: true; project: WorkshopProject } | { ok: false; error: string } {
|
||||
if (!incoming || typeof incoming !== 'object') return { ok: false, error: 'body required' };
|
||||
if (incoming.id !== id) return { ok: false, error: 'body.id must match :id' };
|
||||
if (!Array.isArray(incoming.parts) || !Array.isArray(incoming.phases) || !String(incoming.name || '').trim()) {
|
||||
return { ok: false, error: 'name/parts/phases required' };
|
||||
}
|
||||
if (incoming.notes != null && typeof incoming.notes !== 'string') return { ok: false, error: 'notes must be string' };
|
||||
if (incoming.description != null && typeof incoming.description !== 'string') return { ok: false, error: 'description must be string' };
|
||||
for (const ph of incoming.phases) {
|
||||
if (!ph || typeof ph !== 'object' || (ph.tasks != null && !Array.isArray(ph.tasks))) return { ok: false, error: 'phases[].tasks must be array' };
|
||||
}
|
||||
for (const pt of incoming.parts) {
|
||||
if (!pt || typeof pt !== 'object' || (pt.links != null && !Array.isArray(pt.links))) return { ok: false, error: 'parts[].links must be array' };
|
||||
}
|
||||
// 항목 id는 클라이언트가 onclick="fn('<id>')"에 그대로 끼워 넣는다 — 안전한 문자만 허용하고
|
||||
// 아니면 새 id로 교체한다(따옴표가 든 id로 인라인 핸들러에 코드가 주입되는 것 방지).
|
||||
const safeId = (v: any, prefix: string) => (typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : genWorkshopId(prefix));
|
||||
const project: WorkshopProject = {
|
||||
...incoming,
|
||||
id,
|
||||
notes: incoming.notes ?? '',
|
||||
description: incoming.description ?? '',
|
||||
phases: incoming.phases.map((ph: any) => ({
|
||||
...ph,
|
||||
id: safeId(ph.id, 'ph'),
|
||||
tasks: (ph.tasks ?? []).map((t: any) => ({ ...t, id: safeId(t?.id, 't') })),
|
||||
})),
|
||||
parts: incoming.parts.map((pt: any) => ({
|
||||
...pt,
|
||||
id: safeId(pt.id, 'p'),
|
||||
links: (pt.links ?? []).map((l: any) => ({ ...l, id: safeId(l?.id, 'lk') })),
|
||||
})),
|
||||
};
|
||||
return { ok: true, project };
|
||||
}
|
||||
|
||||
// 이름 검색 공통 규칙: 정확히 같은 이름이 있으면 그것(대소문자 무시)을 우선하고, 없을 때만
|
||||
// 부분일치. "모터"와 "모터드라이버"가 함께 있어도 "모터"가 정확히 지정된다.
|
||||
export function matchByName<T>(items: T[], query: string, nameOf: (t: T) => string): { index: number }[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const all = items.map((t, index) => ({ index, name: nameOf(t).toLowerCase() }));
|
||||
const exact = all.filter(x => x.name === q);
|
||||
if (exact.length) return exact.map(x => ({ index: x.index }));
|
||||
return all.filter(x => x.name.includes(q)).map(x => ({ index: x.index }));
|
||||
}
|
||||
|
||||
export function workshopPath(workspaceRoot: string): string {
|
||||
return path.join(workspaceRoot, '.smallclaw', 'workshop.json');
|
||||
}
|
||||
@@ -144,6 +193,9 @@ export function loadWorkshop(workspaceRoot: string): WorkshopData {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
||||
} catch {
|
||||
// 손상된 파일을 시드로 조용히 대체하면 다음 저장이 원본을 덮어써 영구 유실된다 —
|
||||
// 먼저 .corrupt-<ts>로 옮겨 보존한다(config.json 3회 손상 사고와 같은 클래스).
|
||||
try { fs.copyFileSync(p, `${p}.corrupt-${Date.now()}`); } catch { /* 백업 실패해도 서비스는 계속 */ }
|
||||
return defaultWorkshopData();
|
||||
}
|
||||
}
|
||||
@@ -213,11 +265,11 @@ export function projectToReadme(project: WorkshopProject): string {
|
||||
lines.push('## 작업');
|
||||
for (const ph of project.phases) {
|
||||
lines.push(`### ${ph.name}`);
|
||||
for (const t of ph.tasks) {
|
||||
for (const t of ph.tasks || []) {
|
||||
lines.push(`- [${t.done ? 'x' : ' '}] ${t.text}`);
|
||||
}
|
||||
}
|
||||
if (project.notes.trim()) {
|
||||
if (String(project.notes || '').trim()) {
|
||||
lines.push('');
|
||||
lines.push('## 메모');
|
||||
lines.push(project.notes);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getWorkspacePath } from '../config/paths.js';
|
||||
import {
|
||||
loadWorkshop, saveWorkshop, genWorkshopId,
|
||||
WorkshopData, WorkshopProject, WorkshopPart, WorkshopPhase,
|
||||
budgetSummary, projectToReadme, fmtWon,
|
||||
budgetSummary, projectToReadme, fmtWon, matchByName,
|
||||
} from '../gateway/routes/workshop-storage.js';
|
||||
|
||||
// "작업실"(여러 메이커 프로젝트 관리 대시보드) 전용 채팅도구. 원래 로봇 프로젝트 하나만
|
||||
@@ -23,49 +23,51 @@ function ensureLinks(p: WorkshopPart): NonNullable<WorkshopPart['links']> {
|
||||
}
|
||||
|
||||
function findProjectIndex(data: WorkshopData, query: string): { ok: true; index: number } | { ok: false; error: string } {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = data.projects.map((pr, i) => ({ pr, i })).filter(({ pr }) => pr.name.toLowerCase().includes(q));
|
||||
const matches = matchByName(data.projects, query, pr => pr.name).map(m => ({ pr: data.projects[m.index], i: m.index }));
|
||||
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 프로젝트가 없습니다. 현재 프로젝트: ${data.projects.map(p => p.name).join(', ')}` };
|
||||
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 프로젝트가 일치합니다: ${matches.map(m => m.pr.name).join(', ')} — 더 구체적으로 지정하세요.` };
|
||||
return { ok: true, index: matches[0].i };
|
||||
}
|
||||
|
||||
// project_name이 주어지면 그 프로젝트를, 없으면 현재 활성 프로젝트를 대상으로 삼는다.
|
||||
function resolveProject(data: WorkshopData, projectNameArg: any): { ok: true; project: WorkshopProject } | { ok: false; error: string } {
|
||||
// project_name이 주어지면 그 프로젝트를, 없으면 이 채팅 세션(ws_<projectId>)의 프로젝트를,
|
||||
// 그것도 없으면 전역 활성 프로젝트를 대상으로 삼는다. 전역 활성 포인터만 쓰면 탭 두 개로
|
||||
// 다른 프로젝트를 볼 때 한쪽 채팅이 다른 프로젝트를 수정하는 사고가 난다(09-24 검토).
|
||||
export function sessionProjectId(sessionId: any): string {
|
||||
const m = /^ws_(.+)$/.exec(String(sessionId || ''));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
function resolveProject(data: WorkshopData, projectNameArg: any, sessionId?: any): { ok: true; project: WorkshopProject } | { ok: false; error: string } {
|
||||
if (projectNameArg) {
|
||||
const found = findProjectIndex(data, String(projectNameArg));
|
||||
if (!found.ok) return found;
|
||||
return { ok: true, project: data.projects[found.index] };
|
||||
}
|
||||
const sid = sessionProjectId(sessionId);
|
||||
const sessionProj = sid ? data.projects.find(p => p.id === sid) : undefined;
|
||||
if (sessionProj) return { ok: true, project: sessionProj };
|
||||
const active = data.projects.find(p => p.id === data.activeProjectId) || data.projects[0];
|
||||
if (!active) return { ok: false, error: '작업실에 프로젝트가 하나도 없습니다. create_project로 먼저 만드세요.' };
|
||||
return { ok: true, project: active };
|
||||
}
|
||||
|
||||
function findPartIndex(project: WorkshopProject, query: string): { ok: true; index: number } | { ok: false; error: string } {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = project.parts.map((p, i) => ({ p, i })).filter(({ p }) => p.name.toLowerCase().includes(q));
|
||||
const matches = matchByName(project.parts, query, p => p.name).map(m => ({ p: project.parts[m.index], i: m.index }));
|
||||
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 부품이 없습니다. 현재 부품(${project.name}): ${project.parts.map(p => p.name).join(', ')}` };
|
||||
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 부품이 일치합니다: ${matches.map(m => m.p.name).join(', ')} — 더 구체적으로 지정하세요.` };
|
||||
return { ok: true, index: matches[0].i };
|
||||
}
|
||||
|
||||
function findPhaseIndex(project: WorkshopProject, query: string): { ok: true; index: number } | { ok: false; error: string } {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = project.phases.map((ph, i) => ({ ph, i })).filter(({ ph }) => ph.name.toLowerCase().includes(q));
|
||||
const matches = matchByName(project.phases, query, ph => ph.name).map(m => ({ ph: project.phases[m.index], i: m.index }));
|
||||
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 단계가 없습니다. 현재 단계: ${project.phases.map(p => p.name).join(', ')}` };
|
||||
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 단계가 일치합니다: ${matches.map(m => m.ph.name).join(', ')} — 더 구체적으로 지정하세요.` };
|
||||
return { ok: true, index: matches[0].i };
|
||||
}
|
||||
|
||||
function findTaskLocation(project: WorkshopProject, query: string): { ok: true; phaseIndex: number; taskIndex: number } | { ok: false; error: string } {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches: { phaseIndex: number; taskIndex: number; text: string }[] = [];
|
||||
project.phases.forEach((ph, pi) => {
|
||||
ph.tasks.forEach((t, ti) => {
|
||||
if (t.text.toLowerCase().includes(q)) matches.push({ phaseIndex: pi, taskIndex: ti, text: t.text });
|
||||
});
|
||||
});
|
||||
const flat: { phaseIndex: number; taskIndex: number; text: string }[] = [];
|
||||
project.phases.forEach((ph, pi) => ph.tasks.forEach((t, ti) => flat.push({ phaseIndex: pi, taskIndex: ti, text: t.text })));
|
||||
const matches = matchByName(flat, query, f => f.text).map(m => flat[m.index]);
|
||||
if (matches.length === 0) return { ok: false, error: `"${query}"와 일치하는 작업이 없습니다.` };
|
||||
if (matches.length > 1) return { ok: false, error: `"${query}"에 여러 작업이 일치합니다: ${matches.map(m => m.text).join(', ')} — 더 구체적으로 지정하세요.` };
|
||||
return { ok: true, phaseIndex: matches[0].phaseIndex, taskIndex: matches[0].taskIndex };
|
||||
@@ -118,6 +120,13 @@ export const workshopProjectTool = {
|
||||
if (action === 'create_project') {
|
||||
const name = String(args?.project_name || '').trim();
|
||||
if (!name) return { success: false, error: 'project_name이 필요합니다.' };
|
||||
// 같은 이름이 둘이 되면 이후 project_name으로 어느 쪽도 지정할 수 없다(모델 재시도로 잘 생김).
|
||||
const dup = data.projects.find(p => p.name.trim().toLowerCase() === name.toLowerCase());
|
||||
if (dup) {
|
||||
data.activeProjectId = dup.id;
|
||||
saveWorkshop(workspaceRoot, data);
|
||||
return { success: true, stdout: `이미 같은 이름의 프로젝트가 있어 새로 만들지 않고 그 프로젝트로 전환함: ${dup.name}` };
|
||||
}
|
||||
const project: WorkshopProject = { id: genWorkshopId('proj'), name, parts: [], phases: [], notes: '' };
|
||||
data.projects.push(project);
|
||||
data.activeProjectId = project.id;
|
||||
@@ -133,7 +142,7 @@ export const workshopProjectTool = {
|
||||
return { success: true, stdout: `활성 프로젝트 전환됨: ${data.projects[found.index].name}` };
|
||||
}
|
||||
|
||||
const resolved = resolveProject(data, args?.project_name);
|
||||
const resolved = resolveProject(data, args?.project_name, args?._sessionId);
|
||||
if (!resolved.ok) return { success: false, error: resolved.error };
|
||||
const project = resolved.project;
|
||||
|
||||
@@ -158,6 +167,10 @@ export const workshopProjectTool = {
|
||||
if (action === 'add_part') {
|
||||
const name = String(args?.name || '').trim();
|
||||
if (!name) return { success: false, error: 'name이 필요합니다.' };
|
||||
const existing = project.parts.find(p => p.name.trim().toLowerCase() === name.toLowerCase());
|
||||
if (existing) {
|
||||
return { success: true, stdout: `[${project.name}] 이미 같은 이름의 부품이 등록돼 있어 추가하지 않음: ${existing.name} (${existing.status}, ${existing.qty}개). 수량/가격/상태를 바꾸려면 update_part를 쓸 것.` };
|
||||
}
|
||||
const status = STATUSES.includes(args?.status) ? args.status : '검토중';
|
||||
const part: WorkshopPart = {
|
||||
id: genWorkshopId('p'),
|
||||
@@ -177,7 +190,7 @@ export const workshopProjectTool = {
|
||||
const found = findPartIndex(project, String(args?.part_name || ''));
|
||||
if (!found.ok) return { success: false, error: found.error };
|
||||
const p = project.parts[found.index];
|
||||
if (args?.name != null) p.name = String(args.name);
|
||||
if (args?.name != null && String(args.name).trim()) p.name = String(args.name).trim();
|
||||
if (args?.qty != null && Number.isFinite(Number(args.qty))) p.qty = Math.max(0, Number(args.qty));
|
||||
if (args?.unit_price != null && Number.isFinite(Number(args.unit_price))) p.unitPrice = Math.max(0, Number(args.unit_price));
|
||||
if (STATUSES.includes(args?.status)) p.status = args.status;
|
||||
@@ -200,6 +213,9 @@ export const workshopProjectTool = {
|
||||
const text = String(args?.task_text || '').trim();
|
||||
if (!text) return { success: false, error: 'task_text가 필요합니다.' };
|
||||
const phase: WorkshopPhase = project.phases[foundPhase.index];
|
||||
if (phase.tasks.some(t => t.text.trim().toLowerCase() === text.toLowerCase())) {
|
||||
return { success: true, stdout: `[${project.name}] "${phase.name}"에 같은 작업이 이미 있어 추가하지 않음: ${text}` };
|
||||
}
|
||||
phase.tasks.push({ id: genWorkshopId('t'), text, done: false });
|
||||
saveWorkshop(workspaceRoot, data);
|
||||
return { success: true, stdout: `[${project.name}] "${phase.name}"에 작업 추가됨: ${text}` };
|
||||
@@ -218,6 +234,9 @@ export const workshopProjectTool = {
|
||||
if (action === 'add_phase') {
|
||||
const name = String(args?.new_phase_name || '').trim();
|
||||
if (!name) return { success: false, error: 'new_phase_name이 필요합니다.' };
|
||||
if (project.phases.some(ph => ph.name.trim().toLowerCase() === name.toLowerCase())) {
|
||||
return { success: true, stdout: `[${project.name}] 같은 이름의 단계가 이미 있어 추가하지 않음: ${name}` };
|
||||
}
|
||||
project.phases.push({ id: genWorkshopId('ph'), name, tasks: [] });
|
||||
saveWorkshop(workspaceRoot, data);
|
||||
return { success: true, stdout: `[${project.name}] 단계 추가됨: ${name}` };
|
||||
@@ -226,7 +245,11 @@ export const workshopProjectTool = {
|
||||
if (action === 'add_note') {
|
||||
const text = String(args?.note_text || '').trim();
|
||||
if (!text) return { success: false, error: 'note_text가 필요합니다.' };
|
||||
project.notes = project.notes.trim() ? `${project.notes.trim()}\n${text}` : text;
|
||||
const curNotes = String(project.notes || '');
|
||||
if (curNotes.split('\n').some(l => l.trim() === text)) {
|
||||
return { success: true, stdout: `[${project.name}] 같은 메모 줄이 이미 있어 추가하지 않음: ${text}` };
|
||||
}
|
||||
project.notes = String(project.notes || '').trim() ? `${String(project.notes).trim()}\n${text}` : text;
|
||||
saveWorkshop(workspaceRoot, data);
|
||||
return { success: true, stdout: `[${project.name}] 메모 추가됨: ${text}` };
|
||||
}
|
||||
@@ -249,8 +272,7 @@ export const workshopProjectTool = {
|
||||
if (!found.ok) return { success: false, error: found.error };
|
||||
const p = project.parts[found.index];
|
||||
const links = ensureLinks(p);
|
||||
const q = String(args?.link_label || '').trim().toLowerCase();
|
||||
const matches = links.filter(l => l.label.toLowerCase().includes(q));
|
||||
const matches = matchByName(links, String(args?.link_label || ''), l => l.label).map(m => links[m.index]);
|
||||
if (matches.length === 0) return { success: false, error: `"${p.name}"에 "${args?.link_label}"와 일치하는 링크가 없습니다. 현재 링크: ${links.map(l => l.label).join(', ') || '없음'}` };
|
||||
if (matches.length > 1) return { success: false, error: `여러 링크가 일치합니다: ${matches.map(l => l.label).join(', ')} — 더 구체적으로 지정하세요.` };
|
||||
p.links = links.filter(l => l.id !== matches[0].id);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// 작업실 앱 전수 검토(2026-09-24)에서 발견·수정한 결함들의 회귀 테스트.
|
||||
// 서버측(저장소/도구/업로드 파일명/가드 정규식) + 프런트엔드 저장 로직(vm 시뮬레이션).
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import vm from 'vm';
|
||||
import {
|
||||
loadWorkshop, saveWorkshop, workshopPath, matchByName, normalizeProjectInput, WorkshopData,
|
||||
} from '../src/gateway/routes/workshop-storage';
|
||||
import { decodeUploadFilename } from '../src/gateway/routes/routes-workshop';
|
||||
import { workshopProjectTool, sessionProjectId } from '../src/tools/workshop-project';
|
||||
import { claimsWorkshopSaved, isWorkshopMutationRequest } from '../src/gateway/guards/prompt-gates';
|
||||
|
||||
function tmpWorkspace(data?: WorkshopData): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-test-'));
|
||||
if (data) saveWorkshop(dir, data);
|
||||
return dir;
|
||||
}
|
||||
const proj = (id: string, name: string, extra: any = {}) => ({ id, name, parts: [], phases: [], notes: '', ...extra });
|
||||
const run = (ws: string, args: any) => workshopProjectTool.execute({ ...args, _workspacePath: ws });
|
||||
|
||||
describe('헛보고 가드 정규식 — 한글 종결형', () => {
|
||||
test('반말/평서 종결도 클레임으로 잡는다(옛 \\b 버그)', () => {
|
||||
for (const s of ['저장했습니다.', '저장했어요', '저장했어.', '저장했다.', '추가했다', '수정됐어', '메모에 저장했어'])
|
||||
assert.equal(claimsWorkshopSaved(s), true, s);
|
||||
});
|
||||
test('질문형·수식형·미래형은 클레임이 아니다', () => {
|
||||
for (const s of ['저장했너?', '저장했어?', '저장했던 메모', '저장했다는 얘기', '저장하겠습니다', '저장했다가 지웠다'])
|
||||
assert.equal(claimsWorkshopSaved(s), false, s);
|
||||
});
|
||||
test('명사 없는 후속 확인 질문도 저장 요청 계열로 본다', () => {
|
||||
assert.equal(isWorkshopMutationRequest('저장 했니?'), true);
|
||||
assert.equal(isWorkshopMutationRequest('등록됐어?'), true);
|
||||
assert.equal(isWorkshopMutationRequest('오늘 날씨 어때'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('업로드 파일명 디코딩 — % 포함 이름이 프로세스를 죽이지 않는다', () => {
|
||||
test('리터럴 %', () => {
|
||||
assert.equal(decodeUploadFilename('100%.stl'), '100%.stl');
|
||||
assert.equal(decodeUploadFilename('50%_infill.stl'), '50%_infill.stl');
|
||||
});
|
||||
test('RFC5987 퍼센트 인코딩과 평문', () => {
|
||||
assert.equal(decodeUploadFilename('%ED%95%9C%EA%B8%80.stl'), '한글.stl');
|
||||
assert.equal(decodeUploadFilename('a b.stl'), 'a b.stl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('저장소', () => {
|
||||
test('matchByName: 정확일치 우선, 없으면 부분일치', () => {
|
||||
const items = ['모터', '모터드라이버', '서보'];
|
||||
assert.deepEqual(matchByName(items, '모터', s => s), [{ index: 0 }]);
|
||||
assert.deepEqual(matchByName(items, '드라이버', s => s), [{ index: 1 }]);
|
||||
assert.deepEqual(matchByName(items, '모', s => s).length, 2);
|
||||
assert.deepEqual(matchByName(items, '없음', s => s), []);
|
||||
});
|
||||
|
||||
test('손상된 workshop.json은 시드로 대체하되 원본을 .corrupt로 보존', () => {
|
||||
const ws = tmpWorkspace();
|
||||
fs.mkdirSync(path.dirname(workshopPath(ws)), { recursive: true });
|
||||
fs.writeFileSync(workshopPath(ws), '{"projects":[{"id":"x","name":"소중한 데이터"', 'utf-8');
|
||||
const data = loadWorkshop(ws);
|
||||
assert.equal(data.projects[0].id, 'robot');
|
||||
const dir = path.dirname(workshopPath(ws));
|
||||
const backups = fs.readdirSync(dir).filter(f => f.includes('.corrupt-'));
|
||||
assert.equal(backups.length, 1);
|
||||
assert.ok(fs.readFileSync(path.join(dir, backups[0]), 'utf-8').includes('소중한 데이터'));
|
||||
});
|
||||
|
||||
test('normalizeProjectInput: 누락 필드 보정 + 잘못된 타입/id 거부·교체', () => {
|
||||
const ok = normalizeProjectInput({ id: 'p', name: 'A', parts: [{ id: "x'); alert(1);//", name: 'n' }], phases: [{ id: 'ph1', name: 's' }] }, 'p');
|
||||
assert.ok(ok.ok);
|
||||
if (ok.ok) {
|
||||
assert.equal(ok.project.notes, '');
|
||||
assert.deepEqual(ok.project.phases[0].tasks, []);
|
||||
assert.deepEqual(ok.project.parts[0].links, []);
|
||||
assert.match(ok.project.parts[0].id, /^p_[a-z0-9]+$/); // 위험한 id는 교체
|
||||
assert.equal(ok.project.phases[0].id, 'ph1');
|
||||
}
|
||||
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [], notes: 5 }, 'p').ok, false);
|
||||
assert.equal(normalizeProjectInput({ id: 'p', name: 'A', parts: [], phases: [{ tasks: 'x' }] }, 'p').ok, false);
|
||||
assert.equal(normalizeProjectInput({ id: 'other', name: 'A', parts: [], phases: [] }, 'p').ok, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workshop_project 도구', () => {
|
||||
test('정확한 이름이 있으면 부분일치 모호 오류 없이 그 부품을 수정', async () => {
|
||||
const ws = tmpWorkspace({
|
||||
activeProjectId: 'a',
|
||||
projects: [proj('a', 'A', { parts: [
|
||||
{ id: 'p1', name: '모터', qty: 1, unitPrice: 100, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p2', name: '모터드라이버', qty: 1, unitPrice: 200, status: '검토중', memo: '', links: [] },
|
||||
] })],
|
||||
});
|
||||
const r = await run(ws, { action: 'update_part', part_name: '모터', qty: 4 });
|
||||
assert.equal(r.success, true, r.error);
|
||||
const d = loadWorkshop(ws);
|
||||
assert.equal(d.projects[0].parts[0].qty, 4);
|
||||
assert.equal(d.projects[0].parts[1].qty, 1);
|
||||
});
|
||||
|
||||
test('project_name 없으면 세션(ws_<id>)의 프로젝트가 대상 — 전역 활성 포인터 무시', async () => {
|
||||
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A'), proj('b', 'B')] });
|
||||
assert.equal(sessionProjectId('ws_b'), 'b');
|
||||
const r = await run(ws, { action: 'add_note', note_text: 'B에 쓰는 메모', _sessionId: 'ws_b' });
|
||||
assert.equal(r.success, true);
|
||||
const d = loadWorkshop(ws);
|
||||
assert.equal(d.projects[1].notes, 'B에 쓰는 메모');
|
||||
assert.equal(d.projects[0].notes, '');
|
||||
// 세션 정보가 없으면 옛 동작(활성 프로젝트)
|
||||
await run(ws, { action: 'add_note', note_text: 'A 메모' });
|
||||
assert.equal(loadWorkshop(ws).projects[0].notes, 'A 메모');
|
||||
});
|
||||
|
||||
test('create_project 같은 이름 중복 생성 안 함(전환만)', async () => {
|
||||
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', '로봇팔'), proj('b', '기타')] });
|
||||
const r = await run(ws, { action: 'create_project', project_name: '로봇팔' });
|
||||
assert.equal(r.success, true);
|
||||
const d = loadWorkshop(ws);
|
||||
assert.equal(d.projects.length, 2);
|
||||
assert.equal(d.activeProjectId, 'a');
|
||||
});
|
||||
|
||||
test('add_part/add_note/add_task/add_phase 중복은 추가하지 않고 성공(가드 재시도가 중복을 못 만든다)', async () => {
|
||||
const ws = tmpWorkspace({ activeProjectId: 'a', projects: [proj('a', 'A', { phases: [{ id: 'ph', name: '준비', tasks: [] }] })] });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
assert.equal((await run(ws, { action: 'add_part', name: '608ZZ', qty: 4 })).success, true);
|
||||
assert.equal((await run(ws, { action: 'add_note', note_text: '메모 한 줄' })).success, true);
|
||||
assert.equal((await run(ws, { action: 'add_task', phase_name: '준비', task_text: '출력' })).success, true);
|
||||
assert.equal((await run(ws, { action: 'add_phase', new_phase_name: '조립' })).success, true);
|
||||
}
|
||||
const p = loadWorkshop(ws).projects[0];
|
||||
assert.equal(p.parts.length, 1);
|
||||
assert.equal(p.notes, '메모 한 줄');
|
||||
assert.equal(p.phases.length, 2);
|
||||
assert.equal(p.phases[0].tasks.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 프런트엔드 저장 로직(workshop-app.html 상태/저장 구간을 vm으로 실행) ──────────────
|
||||
describe('workshop-app.html 저장 로직', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '../web-ui/html/workshop-app.html'), 'utf-8').split('\n');
|
||||
const start = html.findIndex(l => l.startsWith('function escAttr'));
|
||||
const from = html.findIndex(l => l.startsWith('let workshopData='));
|
||||
const to = html.findIndex(l => l.startsWith('// ── Tabs'));
|
||||
const escHtmlLine = html.find(l => l.startsWith('function escHtml('))!;
|
||||
|
||||
function boot(server: any) {
|
||||
const puts: any[] = [];
|
||||
const el: any = { textContent: '', value: '', classList: { contains: () => false }, innerHTML: '' };
|
||||
const ctx: any = {
|
||||
fetch: async (url: string, opt: any = {}) => {
|
||||
if (url === '/api/workshop/data') return { ok: true, json: async () => JSON.parse(JSON.stringify(server)) };
|
||||
const m = url.match(/^\/api\/workshop\/project\/(.+)$/);
|
||||
if (m && opt.method === 'PUT') {
|
||||
const p = JSON.parse(opt.body); puts.push(p);
|
||||
const i = server.projects.findIndex((x: any) => x.id === p.id);
|
||||
if (i >= 0) server.projects[i] = p; else server.projects.push(p);
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
},
|
||||
document: { getElementById: () => el }, window: { addEventListener() {} }, setTimeout, clearTimeout, JSON, console,
|
||||
authH: () => ({}), renderParts() {}, renderPhases() {}, updateBudget() {}, refreshChatForProject() {}, clearStlSelection() {},
|
||||
loadFiles() {}, expandedParts: new Set(), stlSelection: new Set(),
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext([escHtmlLine, ...html.slice(start, start + 1), ...html.slice(from, to),
|
||||
'this.api={loadWorkshopData,softRefreshWorkshop,setNotes:(v)=>{notes=v;scheduleSave();},escAttr};'].join('\n'), ctx);
|
||||
return { api: ctx.api, puts };
|
||||
}
|
||||
const mkServer = () => ({ projects: [proj('p1', 'A')], activeProjectId: 'p1' });
|
||||
|
||||
test('대시보드 편집 후 채팅 도구가 서버에 쓴 내용이 새로고침에 덮이지 않는다(핵심 회귀)', async () => {
|
||||
const server: any = mkServer();
|
||||
const { api } = boot(server);
|
||||
await api.loadWorkshopData();
|
||||
api.setNotes('사용자 메모');
|
||||
await new Promise(r => setTimeout(r, 900)); // debounce 발동 → 저장
|
||||
server.projects[0].notes += '\n[채팅 도구가 추가한 줄]';
|
||||
await api.softRefreshWorkshop();
|
||||
assert.equal(server.projects[0].notes, '사용자 메모\n[채팅 도구가 추가한 줄]');
|
||||
});
|
||||
|
||||
test('바뀐 게 없으면 PUT하지 않는다', async () => {
|
||||
const server: any = mkServer();
|
||||
const { api, puts } = boot(server);
|
||||
await api.loadWorkshopData();
|
||||
await api.softRefreshWorkshop();
|
||||
assert.equal(puts.length, 0);
|
||||
});
|
||||
|
||||
test('저장 대기 중인 편집은 새로고침 전에 먼저 flush된다', async () => {
|
||||
const server: any = mkServer();
|
||||
const { api } = boot(server);
|
||||
await api.loadWorkshopData();
|
||||
api.setNotes('쓰는 중'); // 600ms 타이머 대기 상태
|
||||
await api.softRefreshWorkshop();
|
||||
assert.equal(server.projects[0].notes, '쓰는 중');
|
||||
});
|
||||
|
||||
test('escAttr는 텍스트 노드용 < > & 도 이스케이프(XSS)', () => {
|
||||
const { api } = boot(mkServer());
|
||||
assert.equal(api.escAttr('<img src=x onerror=alert(1)>'), '<img src=x onerror=alert(1)>');
|
||||
assert.equal(api.escAttr('A&B "q"'), 'A&B "q"');
|
||||
assert.equal(api.escAttr(0), '0');
|
||||
});
|
||||
});
|
||||
@@ -332,7 +332,9 @@ async function checkAuth(){
|
||||
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||||
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}const tb=document.getElementById('theme-btn');if(tb)tb.textContent=n==='light'?'☀️':'🌙';}
|
||||
|
||||
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
||||
// 속성값·텍스트 노드 양쪽에 쓰인다 — 예전엔 따옴표만 이스케이프해서 텍스트 노드에 들어간
|
||||
// 부품/프로젝트/파일 이름의 <img onerror=...> 가 그대로 실행됐다(09-24 검토). & < > 도 처리.
|
||||
function escAttr(s){return escHtml(s);}
|
||||
// onclick="fn('...')" 안의 JS 문자열 컨텍스트용 — escAttr의 HTML 엔티티(')는
|
||||
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
|
||||
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
|
||||
@@ -356,6 +358,25 @@ let saveDebounce=null;
|
||||
// 동시에 저장해도 한쪽 편집이 통째로 사라지는 일이 없다. 어디가 더러운지는
|
||||
// dirtyProjects로 추적하고 saveProject()가 그 대상만 순차 전송한다.
|
||||
const dirtyProjects=new Set();
|
||||
// 서버와 마지막으로 동기화된 프로젝트 JSON(id→문자열). 실제로 바뀐 프로젝트만 dirty로 잡기
|
||||
// 위한 기준 — 이게 없으면 saveProject가 매번 현재 프로젝트를 무조건 PUT해서, 화면이 옛
|
||||
// 상태일 때 채팅 도구가 서버에 쓴 내용을 덮어썼다(09-24 검토에서 재현).
|
||||
const savedJson=new Map();
|
||||
function normalizeProjects(list){
|
||||
for(const p of list){
|
||||
p.notes=typeof p.notes==='string'?p.notes:'';
|
||||
p.description=typeof p.description==='string'?p.description:'';
|
||||
if(!Array.isArray(p.parts))p.parts=[];
|
||||
if(!Array.isArray(p.phases))p.phases=[];
|
||||
for(const ph of p.phases)if(!Array.isArray(ph.tasks))ph.tasks=[];
|
||||
for(const pt of p.parts)if(!Array.isArray(pt.links))pt.links=[];
|
||||
}
|
||||
return list;
|
||||
}
|
||||
function snapshotProjects(){
|
||||
savedJson.clear();
|
||||
for(const p of workshopData.projects)savedJson.set(p.id,JSON.stringify(p));
|
||||
}
|
||||
|
||||
function currentProject(){
|
||||
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
|
||||
@@ -388,7 +409,7 @@ function syncLocalToProject(){
|
||||
proj.phases=phases;
|
||||
proj.notes=notes;
|
||||
proj.description=overviewDesc;
|
||||
dirtyProjects.add(proj.id);
|
||||
if(savedJson.get(proj.id)!==JSON.stringify(proj))dirtyProjects.add(proj.id);
|
||||
}
|
||||
|
||||
function renderProjectSelector(){
|
||||
@@ -445,7 +466,7 @@ function deleteProjectPrompt(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
|
||||
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 되돌릴 수 없습니다.`))return;
|
||||
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 부품/작업/메모와 첨부파일, 이 프로젝트의 채팅 기록이 모두 지워지며 되돌릴 수 없습니다.`))return;
|
||||
dirtyProjects.delete(proj.id);
|
||||
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
|
||||
workshopData.activeProjectId=workshopData.projects[0].id;
|
||||
@@ -466,7 +487,7 @@ async function loadWorkshopData(){
|
||||
const r=await fetch('/api/workshop/data',{headers:authH()});
|
||||
if(r.ok){
|
||||
const d=await r.json();
|
||||
workshopData.projects=Array.isArray(d.projects)?d.projects:[];
|
||||
workshopData.projects=normalizeProjects(Array.isArray(d.projects)?d.projects:[]);
|
||||
workshopData.activeProjectId=d.activeProjectId||(workshopData.projects[0]?.id||'');
|
||||
}
|
||||
}catch{}
|
||||
@@ -474,7 +495,9 @@ async function loadWorkshopData(){
|
||||
const id=genId('proj');
|
||||
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:'',description:''}];
|
||||
workshopData.activeProjectId=id;
|
||||
dirtyProjects.add(id); // 서버에 아직 없는 자리표시 프로젝트 — 첫 저장 때 서버에도 만든다
|
||||
}
|
||||
snapshotProjects();
|
||||
loadLocalFromProject();
|
||||
refreshAllPanes();
|
||||
}
|
||||
@@ -483,6 +506,9 @@ function scheduleSave(){
|
||||
clearTimeout(saveDebounce);
|
||||
saveDebounce=setTimeout(saveProject,600);
|
||||
}
|
||||
// 타이머가 발동하면 saveDebounce를 반드시 비운다 — 옛 timeout id가 남아 있으면 "저장 대기
|
||||
// 중"으로 오판해서, 채팅 응답 후 softRefreshWorkshop이 서버 값을 받기 전에 화면의 옛 상태를
|
||||
// 먼저 PUT해 채팅이 저장한 내용을 지웠다.
|
||||
|
||||
// 탭 닫기/새로고침 시 대기 중인 저장을 즉시 flush — 600ms debounce가 끝나기 전에
|
||||
// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어
|
||||
@@ -514,12 +540,13 @@ async function softRefreshWorkshop(){
|
||||
const r=await fetch('/api/workshop/data',{headers:authH()});
|
||||
if(!r.ok)return;
|
||||
const d=await r.json();
|
||||
const newProjects=Array.isArray(d.projects)?d.projects:[];
|
||||
const newProjects=normalizeProjects(Array.isArray(d.projects)?d.projects:[]);
|
||||
const newActive=d.activeProjectId||(newProjects[0]?.id||'');
|
||||
if(JSON.stringify(newProjects)===JSON.stringify(workshopData.projects)&&newActive===workshopData.activeProjectId)return;
|
||||
const activeChanged=newActive!==workshopData.activeProjectId;
|
||||
workshopData.projects=newProjects;
|
||||
workshopData.activeProjectId=newActive;
|
||||
snapshotProjects();
|
||||
loadLocalFromProject();
|
||||
const liveIds=new Set(parts.map(p=>p.id));
|
||||
for(const id of [...expandedParts])if(!liveIds.has(id))expandedParts.delete(id);
|
||||
@@ -544,6 +571,7 @@ async function softRefreshWorkshop(){
|
||||
}
|
||||
|
||||
async function saveProject(){
|
||||
saveDebounce=null;
|
||||
syncLocalToProject();
|
||||
const ids=[...dirtyProjects];
|
||||
dirtyProjects.clear();
|
||||
@@ -553,11 +581,17 @@ async function saveProject(){
|
||||
for(const id of ids){
|
||||
const p=workshopData.projects.find(x=>x.id===id);
|
||||
if(!p)continue; // 이번 턴에 삭제된 프로젝트 — deleteProjectPrompt가 서버 DELETE로 처리
|
||||
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p)});
|
||||
const body=JSON.stringify(p);
|
||||
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body});
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
savedJson.set(id,body);
|
||||
}
|
||||
showSaveIndicator('저장됨');
|
||||
}catch{showSaveIndicator('저장 실패');}
|
||||
}catch{
|
||||
// 실패한 프로젝트는 dirty로 되돌려 다음 저장에서 재시도한다(안 그러면 조용히 유실).
|
||||
for(const id of ids)if(savedJson.get(id)!==JSON.stringify(workshopData.projects.find(x=>x.id===id)))dirtyProjects.add(id);
|
||||
showSaveIndicator('저장 실패');
|
||||
}
|
||||
}
|
||||
|
||||
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
|
||||
@@ -629,7 +663,7 @@ function startEquipTimers(){
|
||||
if(equipTimer)return;
|
||||
refreshK2Widget();
|
||||
refreshMillWidget();
|
||||
equipTimer=setInterval(()=>{refreshK2Widget();refreshMillWidget();},20000);
|
||||
equipTimer=setInterval(()=>{if(document.hidden)return;refreshK2Widget();refreshMillWidget();},20000);
|
||||
}
|
||||
function stopEquipTimers(){
|
||||
clearInterval(equipTimer);
|
||||
@@ -638,6 +672,11 @@ function stopEquipTimers(){
|
||||
stopMillLive();
|
||||
}
|
||||
|
||||
// 백그라운드 탭에선 폴링을 멈췄다가 다시 보이면 즉시 한 번 갱신한다.
|
||||
document.addEventListener('visibilitychange',()=>{
|
||||
if(!document.hidden&&equipTimer){refreshK2Widget();refreshMillWidget();}
|
||||
});
|
||||
|
||||
function refreshMillWidget(){
|
||||
if(millLive)return; // 라이브 중엔 스냅샷 폴링 건너뜀 — 실시간 비디오가 이미 떠있고 간섭·낭비 방지
|
||||
const img=document.getElementById('mill-frame');
|
||||
@@ -947,7 +986,8 @@ function updateLink(partId,linkId,field,value){
|
||||
a.className='rb-link-open'; a.target='_blank'; a.rel='noopener'; a.textContent='↗';
|
||||
row.querySelector('.rb-del-btn').before(a);
|
||||
}
|
||||
a.href=value;
|
||||
const su=safeUrl(value);
|
||||
if(su)a.href=su;else a.removeAttribute('href'); // javascript: 등은 링크로 만들지 않는다
|
||||
}else if(a){
|
||||
a.remove();
|
||||
}
|
||||
@@ -1396,9 +1436,12 @@ function chatGreeting(){
|
||||
|
||||
// 프로젝트 전환(수동/생성/삭제) 시 채팅창을 그 프로젝트의 세션 기록으로 리셋한다 —
|
||||
// 세션ID가 프로젝트별로 분리돼 있으므로 여기서 지우고 다시 당겨오는 것으로 충분하다.
|
||||
let _chatRefreshSeq=0;
|
||||
async function refreshChatForProject(){
|
||||
const seq=++_chatRefreshSeq;
|
||||
document.getElementById('chat-msgs').innerHTML='';
|
||||
await loadHistory();
|
||||
if(seq!==_chatRefreshSeq)return; // 그 사이 다른 프로젝트로 또 전환됨 — 인사말 중복 방지
|
||||
addMsg('system',chatGreeting());
|
||||
}
|
||||
|
||||
@@ -1411,9 +1454,15 @@ async function sendMessage(){
|
||||
await callChat(msg);
|
||||
}
|
||||
|
||||
function setProjectControlsBusy(busy){
|
||||
for(const el of document.querySelectorAll('#project-select,.rb-hdr .hdr-btn:not(#theme-btn)'))el.disabled=busy;
|
||||
}
|
||||
|
||||
async function callChat(message){
|
||||
const btn=document.getElementById('send-btn');
|
||||
btn.disabled=true;btn.textContent='...';
|
||||
// 응답을 받는 동안 프로젝트를 바꾸면 채팅창이 지워져 진행 중이던 답변이 안 보인다 — 잠근다.
|
||||
setProjectControlsBusy(true);
|
||||
const msgs=document.getElementById('chat-msgs');
|
||||
const typingDiv=document.createElement('div');typingDiv.className='rb-msg assistant';
|
||||
typingDiv.innerHTML='<div class="rb-typing"><div class="rb-dot"></div><div class="rb-dot"></div><div class="rb-dot"></div></div>';
|
||||
@@ -1448,7 +1497,7 @@ async function callChat(message){
|
||||
}catch(e){
|
||||
typingDiv.remove();
|
||||
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
|
||||
}finally{btn.disabled=false;btn.textContent='전송';}
|
||||
}finally{btn.disabled=false;btn.textContent='전송';setProjectControlsBusy(false);}
|
||||
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
|
||||
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
|
||||
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.
|
||||
|
||||
Reference in New Issue
Block a user