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:
@@ -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){
|
||||
|
||||
Reference in New Issue
Block a user