feat: 작업실 저장 개편 — 전체문서 PUT을 프로젝트 단위 PUT으로 + LOW 2건
저장 개편(리뷰 #4 근본치료): - PUT /api/workshop/project/:id — 서버가 디스크의 현재 문서에 해당 프로젝트만 upsert. 대시보드 저장과 채팅 도구 저장이 겹쳐도 서로 다른 프로젝트 편집이 통째로 사라지지 않음. DELETE /api/workshop/project/:id, PUT /api/workshop/active(포인터 단독) 신설. 옛 전체문서 PUT은 스테일 탭 호환으로 유지 - 클라이언트: dirtyProjects 추적 → saveProject는 바뀐 프로젝트만 순차 PUT, 활성 포인터는 putActive() 즉시 저장, 프로젝트 삭제는 DELETE 직접 호출, beforeunload flush도 프로젝트 단위 keepalive PUT으로 전환 - upsertProject/removeProject를 순수함수로 추출, 단위테스트 4건 LOW 2건: - loadFiles에 요청 일련번호 — 빠른 프로젝트 전환 시 옛 응답이 나중에 도착해 이전 프로젝트 파일을 새 판에 그리는 레이스 제거 - 슬라이서 인계 ?load=를 JSON 배열 인코딩으로 전환(파일명에 쉼표 있으면 옛 쉼표 join이 깨지던 문제). 슬라이서 쪽은 JSON 우선, 옛 형식 폴백 유지 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
import { loadWorkshop, saveWorkshop } from './workshop-storage';
|
||||
import { loadWorkshop, saveWorkshop, upsertProject, removeProject } from './workshop-storage';
|
||||
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
|
||||
|
||||
const APP_TYPE = 'workshop';
|
||||
@@ -54,6 +54,55 @@ export function registerWorkshopRoutes(
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── 프로젝트 단위 저장 API(2026-09-24) ─────────────────────────────────────
|
||||
// 예전의 전체문서 PUT(last-write-wins)은 대시보드 저장과 채팅 도구 저장이 겹치면
|
||||
// 한쪽 편집이 통째로 사라졌다. 편집 단위는 프로젝트 하나이므로 이제 프로젝트만
|
||||
// 부분 저장한다 — 서버가 디스크의 현재 문서를 읽어 해당 프로젝트만 upsert하므로
|
||||
// 다른 프로젝트(다른 작성자가 방금 저장한 것 포함)는 절대 덮이지 않는다.
|
||||
app.put('/api/workshop/project/:id', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
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 workspace = getUserWorkspace(session.username);
|
||||
const data = loadWorkshop(workspace);
|
||||
upsertProject(data, { ...incoming, id });
|
||||
saveWorkshop(workspace, data);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete('/api/workshop/project/:id', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
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 workspace = getUserWorkspace(session.username);
|
||||
const data = loadWorkshop(workspace);
|
||||
if (!removeProject(data, id)) return res.status(404).json({ error: 'not found' });
|
||||
saveWorkshop(workspace, data);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// 활성 프로젝트 포인터만 단독 저장 — 포인터는 값 하나라 last-write-wins로 충분하다.
|
||||
app.put('/api/workshop/active', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const workspace = getUserWorkspace(session.username);
|
||||
const data = loadWorkshop(workspace);
|
||||
const wanted = String(req.body?.activeProjectId || '');
|
||||
if (wanted && !data.projects.some(p => p.id === wanted)) {
|
||||
return res.status(400).json({ error: 'unknown activeProjectId' });
|
||||
}
|
||||
data.activeProjectId = wanted || data.projects[0]?.id || '';
|
||||
saveWorkshop(workspace, data);
|
||||
res.json({ success: true, activeProjectId: data.activeProjectId });
|
||||
});
|
||||
|
||||
app.get('/api/workshop/files', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
@@ -50,6 +50,29 @@ export interface WorkshopData {
|
||||
activeProjectId: string;
|
||||
}
|
||||
|
||||
// 프로젝트 단위 저장 API(routes-workshop.ts)의 핵심 로직 — 순수함수로 뽑아 단위테스트한다.
|
||||
// 대시보드의 편집 단위는 프로젝트 하나이므로 서버는 디스크의 현재 문서에 해당 프로젝트만
|
||||
// upsert한다. 다른 프로젝트는 건드리지 않으므로 채팅 도구와 대시보드가 동시에 저장해도
|
||||
// 서로 다른 프로젝트 편집이 통째로 사라지는 일(옛 전체문서 PUT의 last-write-wins)이 없다.
|
||||
export function upsertProject(data: WorkshopData, project: WorkshopProject): void {
|
||||
const idx = data.projects.findIndex(p => p.id === project.id);
|
||||
if (idx >= 0) data.projects[idx] = project;
|
||||
else data.projects.push(project);
|
||||
if (!data.activeProjectId || !data.projects.some(p => p.id === data.activeProjectId)) {
|
||||
data.activeProjectId = data.projects[0]?.id || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 삭제. 활성 포인터가 삭제된 프로젝트를 가리키면 첫 프로젝트로 되돌린다.
|
||||
// 대상이 없으면 false(라우트가 404로 응답).
|
||||
export function removeProject(data: WorkshopData, id: string): boolean {
|
||||
const before = data.projects.length;
|
||||
data.projects = data.projects.filter(p => p.id !== id);
|
||||
if (data.projects.length === before) return false;
|
||||
if (data.activeProjectId === id) data.activeProjectId = data.projects[0]?.id || '';
|
||||
return true;
|
||||
}
|
||||
|
||||
export function workshopPath(workspaceRoot: string): string {
|
||||
return path.join(workspaceRoot, '.smallclaw', 'workshop.json');
|
||||
}
|
||||
|
||||
@@ -36,4 +36,43 @@ test('projectToReadme: 개요 섹션은 description이 있을 때만', () => {
|
||||
test('projectToReadme: 부품 링크를 들여쓰기로 실음', () => {
|
||||
const md = projectToReadme(sampleProject());
|
||||
assert.ok(md.includes(' - 구매처: https://example.com'));
|
||||
});
|
||||
});
|
||||
// 프로젝트 단위 저장 API(2026-09-24)의 upsert/remove — 서버는 디스크의 현재 문서에
|
||||
// 해당 프로젝트만 반영하므로 다른 프로젝트는 절대 덮이지 않아야 한다.
|
||||
import { upsertProject, removeProject, WorkshopData } from '../src/gateway/routes/workshop-storage.js';
|
||||
|
||||
function sampleData(): WorkshopData {
|
||||
const a = sampleProject({ id: 'proj_a', name: 'A' });
|
||||
const b = sampleProject({ id: 'proj_b', name: 'B' });
|
||||
return { projects: [a, b], activeProjectId: 'proj_a' };
|
||||
}
|
||||
|
||||
test('upsertProject: 기존 프로젝트는 교체, 나머지는 보존', () => {
|
||||
const data = sampleData();
|
||||
const edited = sampleProject({ id: 'proj_b', name: 'B-수정됨', notes: '채팅 도구가 저장' });
|
||||
upsertProject(data, edited);
|
||||
assert.equal(data.projects.length, 2);
|
||||
assert.equal(data.projects[1].name, 'B-수정됨');
|
||||
// 다른 프로젝트(A)의 내용은 그대로 — 전체문서 PUT이던 시절엔 여기가 유실됐다
|
||||
assert.equal(data.projects[0].name, 'A');
|
||||
assert.equal(data.projects[0].parts[0].name, 'STS3215 서보');
|
||||
});
|
||||
|
||||
test('upsertProject: 없는 프로젝트는 추가, 활성 포인터가 무효하면 복구', () => {
|
||||
const data = sampleData();
|
||||
data.activeProjectId = 'proj_gone';
|
||||
upsertProject(data, sampleProject({ id: 'proj_new', name: '새것' }));
|
||||
assert.equal(data.projects.length, 3);
|
||||
assert.equal(data.activeProjectId, 'proj_a');
|
||||
});
|
||||
|
||||
test('removeProject: 삭제되면 true, 활성 포인터가 가리키던 프로젝트면 첫 프로젝트로', () => {
|
||||
const data = sampleData();
|
||||
assert.equal(removeProject(data, 'proj_a'), true);
|
||||
assert.equal(data.projects.length, 1);
|
||||
assert.equal(data.activeProjectId, 'proj_b');
|
||||
});
|
||||
|
||||
test('removeProject: 없는 프로젝트면 false(404 응답용)', () => {
|
||||
assert.equal(removeProject(sampleData(), 'proj_none'), false);
|
||||
});
|
||||
|
||||
@@ -331,6 +331,11 @@ let notes='';
|
||||
let overviewDesc='';
|
||||
let saveTimer=null;
|
||||
let saveDebounce=null;
|
||||
// 저장 개편(2026-09-24): 전체문서 PUT 대신 바뀐 프로젝트만 개별 PUT한다 — 서버가
|
||||
// 디스크의 현재 문서에 해당 프로젝트만 upsert하므로, 채팅 도구가 다른 프로젝트를
|
||||
// 동시에 저장해도 한쪽 편집이 통째로 사라지는 일이 없다. 어디가 더러운지는
|
||||
// dirtyProjects로 추적하고 saveProject()가 그 대상만 순차 전송한다.
|
||||
const dirtyProjects=new Set();
|
||||
|
||||
function currentProject(){
|
||||
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
|
||||
@@ -363,6 +368,7 @@ function syncLocalToProject(){
|
||||
proj.phases=phases;
|
||||
proj.notes=notes;
|
||||
proj.description=overviewDesc;
|
||||
dirtyProjects.add(proj.id);
|
||||
}
|
||||
|
||||
function renderProjectSelector(){
|
||||
@@ -390,6 +396,7 @@ function switchProject(id){
|
||||
clearStlSelection();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
putActive();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
@@ -404,10 +411,12 @@ function createProjectPrompt(){
|
||||
const id=genId('proj');
|
||||
workshopData.projects.push({id,name,parts:[],phases:[],notes:'',description:''});
|
||||
workshopData.activeProjectId=id;
|
||||
dirtyProjects.add(id); // 새 프로젝트도 서버에 upsert돼야 함 — 직전 프로젝트는 sync가 dirty 마킹
|
||||
loadLocalFromProject();
|
||||
clearStlSelection();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
putActive();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
@@ -417,12 +426,17 @@ function deleteProjectPrompt(){
|
||||
if(!proj)return;
|
||||
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');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;
|
||||
// 삭제는 프로젝트 단위 저장 API로 직접 반영 — 저장(debounce)이 못 건드리는
|
||||
// 이미 사라진 프로젝트를 PUT하면 404 소음만 생긴다.
|
||||
fetch('/api/workshop/project/'+encodeURIComponent(proj.id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
||||
loadLocalFromProject();
|
||||
clearStlSelection();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
putActive();
|
||||
refreshChatForProject();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
@@ -454,11 +468,17 @@ function scheduleSave(){
|
||||
// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어
|
||||
// keepalive:true로 브라우저에 전송을 보장받는다. beforeunload(닫기 직전)와
|
||||
// pagehide(실제 내비게이션 확정) 둘 다에 걸고, saveDebounce 널 처리로 이중 실행을 막는다.
|
||||
// 저장 개편에 맞춰 더러운 프로젝트마다 개별 upsert로 보낸다(응답을 기다리지 않음).
|
||||
function flushPendingSave(){
|
||||
if(!saveDebounce)return;
|
||||
clearTimeout(saveDebounce);saveDebounce=null;
|
||||
syncLocalToProject();
|
||||
fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId}),keepalive:true});
|
||||
for(const id of dirtyProjects){
|
||||
const p=workshopData.projects.find(x=>x.id===id);
|
||||
if(!p)continue;
|
||||
fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p),keepalive:true});
|
||||
}
|
||||
dirtyProjects.clear();
|
||||
}
|
||||
window.addEventListener('beforeunload',flushPendingSave);
|
||||
window.addEventListener('pagehide',flushPendingSave);
|
||||
@@ -505,12 +525,28 @@ async function softRefreshWorkshop(){
|
||||
|
||||
async function saveProject(){
|
||||
syncLocalToProject();
|
||||
const ids=[...dirtyProjects];
|
||||
dirtyProjects.clear();
|
||||
if(!ids.length)return;
|
||||
try{
|
||||
await fetch('/api/workshop/data',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projects:workshopData.projects,activeProjectId:workshopData.activeProjectId})});
|
||||
// 바뀐 프로젝트만 개별 upsert — 다른 프로젝트는 서버 디스크 값 그대로 보존.
|
||||
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)});
|
||||
if(!r.ok)throw new Error('HTTP '+r.status);
|
||||
}
|
||||
showSaveIndicator('저장됨');
|
||||
}catch{showSaveIndicator('저장 실패');}
|
||||
}
|
||||
|
||||
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
|
||||
async function putActive(){
|
||||
try{
|
||||
await fetch('/api/workshop/active',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({activeProjectId:workshopData.activeProjectId})});
|
||||
}catch{}
|
||||
}
|
||||
|
||||
function showSaveIndicator(msg){
|
||||
const el=document.getElementById('save-indicator');
|
||||
if(!el)return;
|
||||
@@ -874,19 +910,24 @@ function deleteTask(phaseId,taskId){
|
||||
const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'};
|
||||
let filesState={files:[],folders:[]};
|
||||
let stlSelection=new Set(); // 슬라이서로 보낼 STL relPath 다중선택(09-22, K2 슬라이서 연동)
|
||||
// 파일 목록 요청 일련번호 — 빠른 프로젝트 전환 시 오래된 응답이 나중에 도착해
|
||||
// 이전 프로젝트 파일 목록을 새 판에 그리는 레이스를 막는다(응답 도착 시점 검사).
|
||||
let filesLoadSeq=0;
|
||||
|
||||
async function loadFiles(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const seq=++filesLoadSeq;
|
||||
const container=document.getElementById('files-list-container');
|
||||
try{
|
||||
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(!r.ok)throw new Error('불러오기 실패');
|
||||
const d=await r.json();
|
||||
if(seq!==filesLoadSeq)return; // 도착 사이에 더 새로운 요청이 갔다 — 옛 프로젝트 데이터라 폐기
|
||||
filesState.files=Array.isArray(d.files)?d.files:[];
|
||||
filesState.folders=Array.isArray(d.folders)?d.folders:[];
|
||||
}catch{
|
||||
container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
|
||||
if(seq===filesLoadSeq)container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
|
||||
return;
|
||||
}
|
||||
// 삭제된 파일이 남아있지 않게 선택 상태 정리.
|
||||
@@ -1005,7 +1046,9 @@ function openSlicerForSelected(){
|
||||
if(!paths.length)return;
|
||||
const w=1320,h=860;
|
||||
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
|
||||
window.open('/html/slicer-app.html?load='+encodeURIComponent(paths.join(',')),'k2-slicer',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
|
||||
// 쉼표가 파일명에 있으면 join(',') 구분자와 겹쳐 슬라이서 쪽 split(',')에서 깨지므로
|
||||
// JSON 배열로 인코딩한다(슬라이서 쪽은 JSON 우선, 옛 쉼표 형식도 폴백 해석).
|
||||
window.open('/html/slicer-app.html?load='+encodeURIComponent(JSON.stringify(paths)),'k2-slicer',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
|
||||
}
|
||||
|
||||
function fmtFileSize(n){
|
||||
|
||||
@@ -1144,13 +1144,20 @@
|
||||
}
|
||||
|
||||
// 다른 앱(작업실 파일 탭 등)에서 워크스페이스 STL을 바로 슬라이서 판에 얹어주도록
|
||||
// ?load=<워크스페이스 상대경로>[,경로2,...] 지원(09-22, cad-editor-view.js의 ?path=
|
||||
// 패턴과 동일한 취지). /api/cad/file?path= 로 다운로드해 loadModel()에 그대로 넣는다 —
|
||||
// ?load=<경로들> 지원(09-22, cad-editor-view.js의 ?path= 패턴과 동일한 취지).
|
||||
// 경로 구분은 JSON 배열 우선(2026-09-24) — 파일명에 쉼표가 있으면 옛 쉼표 join이
|
||||
// 깨지므로. JSON이 아니면 예전 형식(쉼표 join)으로 폴백 해석해 옛 링크도 유지.
|
||||
// /api/cad/file?path= 로 다운로드해 loadModel()에 그대로 넣는다 —
|
||||
// 파일 input change 핸들러(1013줄 근처)와 배치 로직을 공유.
|
||||
async function loadFromUrlParams() {
|
||||
const loadParam = new URLSearchParams(location.search).get('load');
|
||||
if (!loadParam) return;
|
||||
const paths = loadParam.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
let paths;
|
||||
try {
|
||||
const parsed = JSON.parse(loadParam);
|
||||
paths = Array.isArray(parsed) ? parsed.map(String) : null;
|
||||
} catch { paths = null; }
|
||||
if (!paths) paths = loadParam.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (!paths.length) return;
|
||||
setInfo(`📂 서버에서 ${paths.length}개 파일 불러오는 중…`);
|
||||
let placed = 0;
|
||||
|
||||
Reference in New Issue
Block a user