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:
kim
2026-09-24 07:38:53 +09:00
co-authored by Claude Code
parent f42a09b038
commit eb516e2b74
5 changed files with 170 additions and 9 deletions
+47 -4
View File
@@ -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){