feat: 작업실 파일 폴더 규칙 통일 — STL을 .scad와 같은 폴더에, 프로젝트별 제각각 구조 정돈
규칙(workshop-layout.ts 한 곳에 정의, 도구·스크립트가 공유): workshop/<id>/CAD/(.scad+STL 같은 폴더) · CAD/참고/(비인쇄 참고 모델) · CAD/다운로드/ · 출력/ · 사진/ · 영상/ · 조립설명서/ print3d/K2웹슬라이서/ · 모델검색/ · 일회성/ · profiles/ 인쇄용 STL은 .scad 옆에 두되 참고용은 CAD/참고/로 분리 유지(09-22 "참고용을 인쇄 대상으로 착각" 방지) - 이동 스크립트 scripts/migrate-workshop-layout.ts: 기본 dry-run, --apply(이동만·덮어쓰기 없음·충돌은 건너뛰고 보고), --undo <manifest>. .scad 경로 참조(조립 명세)와 문서 문구(CAD/print→CAD 등) 갱신, 빈 폴더만 정리. 멱등 - 도구가 같은 규칙으로 저장: print3d_model(모델→CAD/다운로드, G-code→출력, 프로젝트 없으면 print3d/일회성), model_download(CAD/다운로드 또는 print3d/모델검색), K2 웹 슬라이서(print3d/K2웹슬라이서). 옛 print3d/<프로젝트이름>/ 제거 - 채팅 프롬프트: STL은 .scad와 같은 폴더(옛 09-22 CAD/print 하위폴더 규칙 폐지), 프로젝트 폴더 규칙 안내 - 파일 탭: 권장 폴더를 비어 있어도 업로드 대상으로 표시하고 규칙 순서로 정렬 - 테스트 34건(규칙 표, 멱등, 충돌 미덮어쓰기, apply→undo 왕복 무손실, saveDirs, print3d 계획) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 작업실 프로젝트 파일 폴더 정돈 스크립트(2026-09-25).
|
||||
* npx tsx scripts/migrate-workshop-layout.ts [--user <이름>] # 계획만 출력(dry-run, 기본)
|
||||
* npx tsx scripts/migrate-workshop-layout.ts --apply [--user <이름>] # 실제 이동 + 프로젝트 데이터 경로 갱신
|
||||
* npx tsx scripts/migrate-workshop-layout.ts --undo <manifest.json> # 마지막 이동 되돌리기
|
||||
* 규칙은 src/gateway/routes/workshop-layout.ts. 이동만 하고 삭제/덮어쓰기는 하지 않는다(충돌은 건너뛰고 보고).
|
||||
* --apply는 되돌리기용 manifest를 <workspace>/.smallclaw/ 에 남긴다.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getConfig } from '../src/config/config';
|
||||
import { loadWorkshop, saveWorkshop } from '../src/gateway/routes/workshop-storage';
|
||||
import { planProjectMoves, planPrint3dMoves, pruneEmptyDirs, projectDir, renameTextPaths, MovePlan, MoveConflict } from '../src/gateway/routes/workshop-layout';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const apply = args.includes('--apply');
|
||||
const undoIdx = args.indexOf('--undo');
|
||||
const userIdx = args.indexOf('--user');
|
||||
|
||||
function workspaceOf(user: string): string {
|
||||
return path.join(getConfig().getConfigDir(), 'users', user, 'workspace');
|
||||
}
|
||||
|
||||
if (undoIdx >= 0) {
|
||||
const mf = JSON.parse(fs.readFileSync(args[undoIdx + 1], 'utf-8'));
|
||||
let n = 0;
|
||||
for (const m of [...mf.moves].reverse() as MovePlan[]) {
|
||||
const from = path.join(projectDir(mf.workspace, m.project), m.to);
|
||||
const to = path.join(projectDir(mf.workspace, m.project), m.from);
|
||||
if (!fs.existsSync(from) || fs.existsSync(to)) { console.log(`건너뜀: ${m.project}/${m.to}`); continue; }
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.renameSync(from, to); n++;
|
||||
}
|
||||
for (const m of [...(mf.print3d || [])].reverse() as { from: string; to: string }[]) {
|
||||
const from = path.join(mf.workspace, m.to), to = path.join(mf.workspace, m.from);
|
||||
if (!fs.existsSync(from) || fs.existsSync(to)) continue;
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true }); fs.renameSync(from, to); n++;
|
||||
}
|
||||
// 프로젝트 데이터(assembly.scad 경로)도 원복
|
||||
const data = loadWorkshop(mf.workspace);
|
||||
for (const p of data.projects) if (p.assembly && mf.scadRemap?.[p.id]) p.assembly.scad = mf.scadRemap[p.id].from;
|
||||
saveWorkshop(mf.workspace, data);
|
||||
console.log(`되돌림: 파일 ${n}개 (빈 폴더는 필요 시 다시 생성됨)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const users = userIdx >= 0 ? [args[userIdx + 1]] : fs.readdirSync(path.join(getConfig().getConfigDir(), 'users'));
|
||||
for (const user of users) {
|
||||
const ws = workspaceOf(user);
|
||||
if (!fs.existsSync(path.join(ws, '.smallclaw', 'workshop.json'))) continue;
|
||||
const data = loadWorkshop(ws);
|
||||
const allMoves: MovePlan[] = [];
|
||||
const allConflicts: MoveConflict[] = [];
|
||||
for (const p of data.projects) {
|
||||
if (!fs.existsSync(projectDir(ws, p.id))) continue;
|
||||
const { moves, conflicts } = planProjectMoves(ws, p.id);
|
||||
allMoves.push(...moves); allConflicts.push(...conflicts);
|
||||
}
|
||||
console.log(`\n=== 사용자 ${user}: 이동 ${allMoves.length}개, 충돌 ${allConflicts.length}개 ${apply ? '(적용)' : '(계획만 — dry-run)'}`);
|
||||
let cur = '';
|
||||
for (const m of allMoves) {
|
||||
if (m.project !== cur) { cur = m.project; console.log(`\n[${data.projects.find(p => p.id === cur)?.name ?? cur}] (${cur})`); }
|
||||
console.log(` ${m.from} → ${m.to}`);
|
||||
}
|
||||
for (const c of allConflicts) console.log(` ⚠ 충돌 건너뜀: ${c.project}/${c.from} → ${c.to} (${c.reason})`);
|
||||
const p3 = planPrint3dMoves(ws);
|
||||
if (p3.length) {
|
||||
console.log(`\n[워크스페이스 print3d/] 이동 ${p3.length}개`);
|
||||
for (const m of p3) console.log(` ${m.from} → ${m.to}`);
|
||||
}
|
||||
if (!apply || (!allMoves.length && !p3.length)) continue;
|
||||
|
||||
// 1) 파일 이동(같은 파일시스템 rename — 용량과 무관하게 즉시, 내용 그대로)
|
||||
const done: MovePlan[] = [];
|
||||
for (const m of allMoves) {
|
||||
const root = projectDir(ws, m.project);
|
||||
const to = path.join(root, m.to);
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.renameSync(path.join(root, m.from), to);
|
||||
done.push(m);
|
||||
}
|
||||
// 2) .scad 경로 참조(조립 명세) + 문서 문구 갱신
|
||||
const scadRemap: Record<string, { from: string; to: string }> = {};
|
||||
for (const p of data.projects) {
|
||||
if (p.assembly?.scad) {
|
||||
const prefix = `workshop/${p.id}/`;
|
||||
const mv = done.find(m => m.project === p.id && prefix + m.from === p.assembly!.scad);
|
||||
if (mv) { scadRemap[p.id] = { from: p.assembly.scad, to: prefix + mv.to }; p.assembly.scad = prefix + mv.to; }
|
||||
}
|
||||
if (p.description) p.description = renameTextPaths(p.description);
|
||||
if (p.notes) p.notes = renameTextPaths(p.notes);
|
||||
}
|
||||
saveWorkshop(ws, data); // 저장 때 이전 상태가 이력에 자동 보존된다
|
||||
// 3) 비게 된 폴더 정리(빈 폴더만)
|
||||
for (const p of data.projects) {
|
||||
const removed = fs.existsSync(projectDir(ws, p.id)) ? pruneEmptyDirs(projectDir(ws, p.id)) : [];
|
||||
if (removed.length) console.log(` 빈 폴더 정리 [${p.id}]: ${removed.join(', ')}`);
|
||||
}
|
||||
for (const m of p3) {
|
||||
const to = path.join(ws, m.to);
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.renameSync(path.join(ws, m.from), to);
|
||||
}
|
||||
const oldMS = path.join(ws, 'print3d', 'model-search');
|
||||
if (fs.existsSync(oldMS)) pruneEmptyDirs(oldMS), (fs.readdirSync(oldMS).length === 0 && fs.rmdirSync(oldMS));
|
||||
const manifest = path.join(ws, '.smallclaw', `workshop-layout-migration-${Date.now()}.json`);
|
||||
fs.writeFileSync(manifest, JSON.stringify({ workspace: ws, at: new Date().toISOString(), moves: done, print3d: p3, scadRemap }, null, 2));
|
||||
console.log(`\n완료 — 되돌리기: npx tsx scripts/migrate-workshop-layout.ts --undo ${manifest}`);
|
||||
}
|
||||
@@ -1112,7 +1112,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
|
||||
stl_url: { type: 'string', description: '(prepare, stl_path 대신) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — model_search로 찾아 model_download로 받거나, Printables/GitHub raw 등 직접 링크를 쓸 것.' },
|
||||
stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/model-search/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' },
|
||||
stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/모델검색/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' },
|
||||
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
|
||||
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
|
||||
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
|
||||
@@ -1148,7 +1148,7 @@ export function createBuildTools(isOrchestrationSkillEnabled: () => boolean) {
|
||||
properties: {
|
||||
site: { type: 'string', enum: ['printables', 'makerworld', 'thingiverse'], description: '(필수) 어느 사이트의 모델인지 — model_search 결과의 [site] 태그 값' },
|
||||
model_id: { type: 'string', description: '(필수) model_search 결과의 id. 모델 페이지 URL을 그대로 붙여넣어도 해석됨(예: printables.com/model/1138497, thingiverse.com/thing:2473)' },
|
||||
project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 print3d/<프로젝트명>/에 저장' },
|
||||
project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 workshop/<프로젝트id>/ 아래(모델은 CAD/다운로드/, G-code는 출력/)에 저장, 생략하면 print3d/일회성/' },
|
||||
filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -283,16 +283,12 @@ async function handleChat(
|
||||
};
|
||||
walk(projDir, 0);
|
||||
if (scadFiles.length > 0) {
|
||||
// 09-22: 사용자 지적 — scad_to_stl 결과가 CAD/ 바로 밑에 널브러지지 말고 print/
|
||||
// 서브폴더에 정리돼야 보기 좋다(이 프로젝트도, proj_scan_dental도 이미 그 관례를 씀
|
||||
// — CAD/print/=인쇄 대상, CAD/reference/=참고용 비인쇄 모델). 단, part 없이(전체
|
||||
// 조립본, 시각화용 — 고스트/모터 등 비인쇄 요소까지 다 포함돼 인쇄 대상이 아님)
|
||||
// 만드는 경우는 예외 — 그건 CAD/ 폴더 바로 밑에 저장(사용자 지시, 09-22).
|
||||
scadHint = ' scad_to_stl로 STL을 만들 때: part를 지정해서(부품 하나만) 만드는 ' +
|
||||
'거면 output_path는 특별한 지시가 없는 한 그 .scad 파일이 있는 폴더 밑 print/ ' +
|
||||
'서브폴더에 저장할 것(예: ".../CAD/x.scad" → ".../CAD/print/x.stl", 폴더 없으면 ' +
|
||||
'새로 생겨도 됨). part 없이(전체 조립본) 만드는 거면 대신 그 .scad 파일과 같은 ' +
|
||||
'폴더(CAD/ 바로 밑)에 저장할 것 — print/에 넣지 말 것(인쇄 대상이 아니라 시각화용).';
|
||||
// 09-25 폴더 규칙 통일(workshop-layout.ts): STL은 그 .scad와 "같은 폴더"에 나란히 저장한다(사용자 지시 —
|
||||
// 옛 09-22 규칙이던 CAD/print/ 하위폴더 분리는 폐지). 인쇄와 무관한 참고 모델만 CAD/참고/ 로 분리해
|
||||
// "참고용을 인쇄 대상으로 착각"하던 문제(09-22)를 계속 막는다.
|
||||
scadHint = ' scad_to_stl로 STL을 만들 때 output_path는 특별한 지시가 없는 한 그 .scad 파일과 "같은 폴더"에 ' +
|
||||
'저장할 것(예: ".../CAD/rig.scad" → 부품 하나는 ".../CAD/base_plate.stl", 전체 조립본은 ".../CAD/rig.stl"). ' +
|
||||
'print/ 같은 하위폴더를 새로 만들지 말 것. 인쇄 대상이 아닌 참고용 모델(구매 부품 형상 등)만 ".../CAD/참고/"에 둔다.';
|
||||
}
|
||||
if (scadFiles.length === 1) {
|
||||
scadHint += ` 이 프로젝트엔 .scad 파일이 "${scadFiles[0]}" 하나뿐이다 — 사용자가 ` +
|
||||
@@ -352,6 +348,7 @@ async function handleChat(
|
||||
} catch { /* noop */ }
|
||||
return `[작업실(workshop) 세션 — 지금 사용자가 보고 있는 프로젝트: "${proj.name}" (id: ${proj.id}). ` +
|
||||
`이 프로젝트의 파일(STL/scad/사진 등)은 워크스페이스 기준 workshop/${proj.id}/ 아래에만 있다. ` +
|
||||
`폴더 규칙: CAD/=.scad 원본과 그 STL(같은 폴더), CAD/참고/=비인쇄 참고 모델, 출력/=G-code, 사진/=사진·렌더 이미지, 영상/=영상. 새 파일은 이 규칙대로 저장할 것. ` +
|
||||
`파일을 찾거나 만들 때 이 프로젝트 폴더만 보고, 다른 프로젝트 폴더(workshop/<다른id>/)는 ` +
|
||||
`사용자가 명시적으로 그 프로젝트를 언급하지 않는 한 후보로 내놓지 말 것.` +
|
||||
`도구 없이는 대시보드에 아무것도 반영되지 않는다 — 메모 저장/부품 추가/작업 체크 같은 ` +
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import { PRINT3D_DIRS } from './workshop-layout';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -211,7 +212,8 @@ export function registerK2Routes(app: Express): void {
|
||||
const profValues = parseIni(fs.readFileSync(profPath, 'utf8'));
|
||||
const materialKey = String(profValues.filament_type || 'PLA').toUpperCase();
|
||||
|
||||
const outDir = path.join(workspacePath, 'print3d');
|
||||
// 웹 UI 슬라이싱 임시 파일은 print3d/K2웹슬라이서/ (프로젝트에 안 묶인 파일 — workshop-layout.ts)
|
||||
const outDir = path.join(workspacePath, PRINT3D_DIRS.k2Web);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const stamp = Date.now();
|
||||
const stlPath = path.join(outDir, `k2ui_${stamp}${modelExt}`);
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface AssemblyStep {
|
||||
tools: string[];
|
||||
}
|
||||
export interface Assembly {
|
||||
/** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/CAD/print/rig.scad") */
|
||||
/** 워크스페이스 기준 상대경로의 .scad (예: "workshop/proj_x/CAD/rig.scad") */
|
||||
scad: string;
|
||||
view: { rx: number; rz: number };
|
||||
/** 분해도 간격 배율(offset × 이 값). 기본 1 */
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* workshop-layout.ts
|
||||
* 작업실 프로젝트 파일 폴더 규칙(2026-09-25) — 프로젝트마다 제멋대로였던 폴더(CAD/print, stls/, 루트 나열, print3d/<프로젝트명>/ …)를
|
||||
* 하나의 규칙으로 통일한다. 도구(print3d_model/model_download/write_scad 안내)와 이동 스크립트가 같은 상수를 쓴다.
|
||||
*
|
||||
* workshop/<프로젝트id>/
|
||||
* CAD/ .scad 원본 + 그 .scad에서 뽑은 STL(같은 폴더에 나란히)
|
||||
* 참고/ 비인쇄 참고 모델(카메라·프로젝터 형상 등) — 인쇄용과 섞여 "인쇄 대상으로 착각"하던 문제(09-22) 방지
|
||||
* 다운로드/ model_download로 받은 외부 모델
|
||||
* <외부세트>/ 받아 온 모델 묶음(예: SO101/Follower)은 자기 이름 폴더 그대로
|
||||
* 출력/ 슬라이싱 결과 G-code
|
||||
* 사진/ 사진·렌더 이미지
|
||||
* 영상/ mp4 등
|
||||
* 조립설명서/ (자동 생성)
|
||||
* 개요/ (옛 자동 생성 readme — 그대로 둠)
|
||||
* print3d/ 프로젝트에 안 묶인 일회성 3D 파일
|
||||
* K2웹슬라이서/ 웹 UI에서 슬라이싱한 임시 파일
|
||||
* 모델검색/ 프로젝트 없이 받은 model_download 결과
|
||||
* 일회성/ 프로젝트 없이 print3d_model로 받아 슬라이싱한 파일
|
||||
* profiles/ 슬라이서 프로파일
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const LAYOUT = {
|
||||
cad: 'CAD',
|
||||
reference: 'CAD/참고',
|
||||
downloads: 'CAD/다운로드',
|
||||
out: '출력',
|
||||
photos: '사진',
|
||||
videos: '영상',
|
||||
manual: '조립설명서',
|
||||
overview: '개요',
|
||||
} as const;
|
||||
|
||||
export const PRINT3D_DIRS = { root: 'print3d', k2Web: 'print3d/K2웹슬라이서', modelSearch: 'print3d/모델검색', adhoc: 'print3d/일회성', profiles: 'print3d/profiles' } as const;
|
||||
|
||||
const CAD_EXT = /\.(scad|stl|3mf|step|stp|iges|igs|obj|dxf)$/i;
|
||||
const IMAGE_EXT = /\.(png|jpe?g|webp|gif|bmp|heic)$/i;
|
||||
const VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi)$/i;
|
||||
const GCODE_EXT = /\.(gcode|gco)$/i;
|
||||
|
||||
// 예전 관례로 만들어진 "정돈 대상" 폴더. 그 밖에 사용자가 직접 만든 폴더(예: "참고자료", "견적")는 건드리지 않는다.
|
||||
const LEGACY_TREES = new Set(['print', 'stls', 'stl', 'models', 'model', 'reference', 'renders']);
|
||||
|
||||
export function projectDir(workspace: string, projectId: string): string {
|
||||
return path.join(workspace, 'workshop', projectId);
|
||||
}
|
||||
export function cadDir(workspace: string, projectId: string): string {
|
||||
return path.join(projectDir(workspace, projectId), LAYOUT.cad);
|
||||
}
|
||||
|
||||
// 프로젝트 폴더 안 상대경로(/ 구분) → 규칙에 맞는 새 상대경로. 이미 규칙에 맞거나 손댈 대상이 아니면 null.
|
||||
export function targetForFile(rel: string): string | null {
|
||||
const segs = rel.split('/').filter(Boolean);
|
||||
if (segs.length === 0) return null;
|
||||
const name = segs[segs.length - 1];
|
||||
const dirs = segs.slice(0, -1);
|
||||
const top = dirs[0] ?? '';
|
||||
if (top.startsWith('.') || top === LAYOUT.manual || top === LAYOUT.overview) return null;
|
||||
|
||||
const kind = CAD_EXT.test(name) ? 'cad' : IMAGE_EXT.test(name) ? 'image' : VIDEO_EXT.test(name) ? 'video' : GCODE_EXT.test(name) ? 'gcode' : null;
|
||||
if (!kind) return null;
|
||||
|
||||
// 이미 규칙 폴더에 있는지(하위 폴더 포함) — 종류가 맞으면 그대로
|
||||
if (kind === 'cad' && top === LAYOUT.cad && !LEGACY_TREES.has(dirs[1] ?? '')) return null;
|
||||
if (kind === 'image' && top === LAYOUT.photos) return null;
|
||||
if (kind === 'video' && top === LAYOUT.videos) return null;
|
||||
if (kind === 'gcode' && top === LAYOUT.out) return null;
|
||||
|
||||
// 정돈 대상인가: 루트, CAD 바로 아래/그 안의 옛 하위폴더, 또는 옛 관례 트리(print/ stls/ reference/ …) 아래
|
||||
const inCadLegacy = top === LAYOUT.cad && (LEGACY_TREES.has(dirs[1] ?? '') || dirs.length === 1);
|
||||
const inLegacyTree = LEGACY_TREES.has(top);
|
||||
const atRoot = dirs.length === 0;
|
||||
if (!(atRoot || inCadLegacy || inLegacyTree)) return null;
|
||||
|
||||
if (kind === 'image') return `${LAYOUT.photos}/${name}`;
|
||||
if (kind === 'video') return `${LAYOUT.videos}/${name}`;
|
||||
if (kind === 'gcode') return `${LAYOUT.out}/${name}`;
|
||||
|
||||
// CAD 종류: 옛 트리 접두어를 벗기고 reference → 참고. print/ 는 "같이 저장" 원칙에 따라 접두어만 제거.
|
||||
let inner = top === LAYOUT.cad ? dirs.slice(1) : atRoot ? [] : dirs.slice();
|
||||
if (inner.length && inner[0] === 'print') inner = inner.slice(1);
|
||||
else if (inner.length && (inner[0] === 'reference' || inner[0] === 'renders')) inner = [LAYOUT.reference.split('/')[1], ...inner.slice(1)];
|
||||
else if (inner.length && ['stls', 'stl', 'models', 'model'].includes(inner[0])) inner = inner.slice(1);
|
||||
return [LAYOUT.cad, ...inner, name].join('/');
|
||||
}
|
||||
|
||||
export interface MovePlan { project: string; from: string; to: string }
|
||||
export interface MoveConflict { project: string; from: string; to: string; reason: string }
|
||||
|
||||
function listFiles(dir: string, base = ''): string[] {
|
||||
const out: string[] = [];
|
||||
let entries: fs.Dirent[] = [];
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const rel = base ? `${base}/${e.name}` : e.name;
|
||||
if (e.isDirectory()) out.push(...listFiles(path.join(dir, e.name), rel));
|
||||
else if (e.isFile()) out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 한 프로젝트 폴더의 이동 계획. 같은 도착지로 가는 파일이 둘이거나 도착지에 다른 파일이 이미 있으면 충돌로 빼고(덮어쓰지 않는다) 알린다.
|
||||
export function planProjectMoves(workspace: string, projectId: string): { moves: MovePlan[]; conflicts: MoveConflict[] } {
|
||||
const root = projectDir(workspace, projectId);
|
||||
const moves: MovePlan[] = [];
|
||||
const conflicts: MoveConflict[] = [];
|
||||
const claimed = new Map<string, string>();
|
||||
for (const rel of listFiles(root).sort()) {
|
||||
const to = targetForFile(rel);
|
||||
if (!to || to === rel) continue;
|
||||
const existing = path.join(root, to);
|
||||
if (fs.existsSync(existing)) { conflicts.push({ project: projectId, from: rel, to, reason: '도착 위치에 같은 이름 파일이 이미 있음' }); continue; }
|
||||
if (claimed.has(to)) { conflicts.push({ project: projectId, from: rel, to, reason: `같은 이름이 ${claimed.get(to)}에서도 옴` }); continue; }
|
||||
claimed.set(to, rel);
|
||||
moves.push({ project: projectId, from: rel, to });
|
||||
}
|
||||
return { moves, conflicts };
|
||||
}
|
||||
|
||||
// 이동 후 비게 된 폴더 정리(빈 폴더만 rmdir — 파일이 하나라도 있으면 남긴다).
|
||||
export function pruneEmptyDirs(root: string): string[] {
|
||||
const removed: string[] = [];
|
||||
const walk = (dir: string): boolean => {
|
||||
let empty = true;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) { if (!walk(path.join(dir, e.name))) empty = false; }
|
||||
else empty = false;
|
||||
}
|
||||
if (empty && dir !== root) { try { fs.rmdirSync(dir); removed.push(path.relative(root, dir)); return true; } catch { return false; } }
|
||||
return empty;
|
||||
};
|
||||
try { walk(root); } catch { /* noop */ }
|
||||
return removed;
|
||||
}
|
||||
|
||||
// 프로젝트 데이터의 문서 문구/경로 갱신: 옛 폴더 이름 → 새 이름(텍스트 안내용) 과 이동한 .scad 경로.
|
||||
export const TEXT_RENAMES: [RegExp, string][] = [
|
||||
[/CAD\/print\//g, 'CAD/'],
|
||||
[/CAD\/print\b/g, 'CAD'],
|
||||
[/CAD\/reference\//g, 'CAD/참고/'],
|
||||
[/CAD\/reference\b/g, 'CAD/참고'],
|
||||
[/CAD\/렌더/g, '사진'],
|
||||
];
|
||||
export function renameTextPaths(text: string): string {
|
||||
let t = text;
|
||||
for (const [re, rep] of TEXT_RENAMES) t = t.replace(re, rep);
|
||||
return t;
|
||||
}
|
||||
|
||||
// 워크스페이스 단위 print3d/ 정돈(프로젝트에 안 묶인 파일): 루트에 흩어진 k2ui_* → K2웹슬라이서/, model-search/ → 모델검색/,
|
||||
// 루트의 그 밖의 STL/3MF/G-code → 일회성/, 워크스페이스 루트의 k2_config.ini → print3d/profiles/.
|
||||
export function planPrint3dMoves(workspace: string): { from: string; to: string }[] {
|
||||
const out: { from: string; to: string }[] = [];
|
||||
const root = path.join(workspace, PRINT3D_DIRS.root);
|
||||
if (fs.existsSync(root)) {
|
||||
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (e.isFile() && /^k2ui_\d+\.(stl|3mf|gcode)$/i.test(e.name)) out.push({ from: `print3d/${e.name}`, to: `${PRINT3D_DIRS.k2Web}/${e.name}` });
|
||||
else if (e.isFile() && (CAD_EXT.test(e.name) || GCODE_EXT.test(e.name))) out.push({ from: `print3d/${e.name}`, to: `${PRINT3D_DIRS.adhoc}/${e.name}` });
|
||||
else if (e.isDirectory() && e.name === 'model-search') {
|
||||
for (const f of listFiles(path.join(root, e.name))) out.push({ from: `print3d/model-search/${f}`, to: `${PRINT3D_DIRS.modelSearch}/${f}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(path.join(workspace, 'k2_config.ini'))) out.push({ from: 'k2_config.ini', to: `${PRINT3D_DIRS.profiles}/k2_config.ini` });
|
||||
return out.filter(m => !fs.existsSync(path.join(workspace, m.to)));
|
||||
}
|
||||
|
||||
// 도구가 파일을 저장할 폴더를 정하는 단일 지점(print3d_model / model_download / K2 웹 슬라이서가 공유).
|
||||
export function saveDirs(workspace: string, projectId?: string): { modelDir: string; outDir: string; downloadDir: string } {
|
||||
if (projectId) {
|
||||
const root = projectDir(workspace, projectId);
|
||||
return { modelDir: path.join(root, LAYOUT.downloads), outDir: path.join(root, LAYOUT.out), downloadDir: path.join(root, LAYOUT.downloads) };
|
||||
}
|
||||
return {
|
||||
modelDir: path.join(workspace, PRINT3D_DIRS.adhoc), outDir: path.join(workspace, PRINT3D_DIRS.adhoc),
|
||||
downloadDir: path.join(workspace, PRINT3D_DIRS.modelSearch),
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { getConfig } from '../config/config.js';
|
||||
import { getVault } from '../security/vault.js';
|
||||
import { getWorkspacePath } from '../config/paths.js';
|
||||
import { isPathInsideDir } from './image.js';
|
||||
import { findWorkshopProjectName, sanitizeFolderName } from './print3d.js';
|
||||
import { findWorkshopProjectName } from './print3d.js';
|
||||
import { saveDirs } from '../gateway/routes/workshop-layout.js';
|
||||
|
||||
// 3D 모델 저장소 검색+다운로드 (2026-09-23) — Printables / MakerWorld / Thingiverse.
|
||||
// 세 곳 모두 공식 API가 없거나(Printables/MakerWorld) 응용토큰이 필요한(Thingiverse) 사이라
|
||||
@@ -430,7 +431,7 @@ export const modelDownloadTool = {
|
||||
properties: {
|
||||
site: { type: 'string', enum: ['printables', 'makerworld', 'thingiverse'], description: '(필수) 어느 사이트의 모델인지 — model_search 결과의 [site] 태그 값' },
|
||||
model_id: { type: 'string', description: '(필수) model_search 결과의 id. 모델 페이지 URL을 그대로 붙여넣어도 해석됨(예: printables.com/model/1138497, thingiverse.com/thing:2473)' },
|
||||
project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 print3d/<프로젝트명>/에 저장' },
|
||||
project_name: { type: 'string', description: '(선택) 작업실 프로젝트명(일부만 일치해도 됨) — 주면 workshop/<프로젝트id>/CAD/다운로드/에 저장(생략하면 print3d/모델검색/)' },
|
||||
filename: { type: 'string', description: '(선택) 저장 파일명. 기본은 <site>_<id>_<모델명>.<stl|3mf>' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -451,14 +452,14 @@ export const modelDownloadTool = {
|
||||
}
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const baseDir = path.join(workspacePath, 'print3d');
|
||||
// 저장 위치(workshop-layout.ts): 프로젝트가 있으면 workshop/<id>/CAD/다운로드/, 없으면 print3d/모델검색/
|
||||
const projectNameArg = String(args?.project_name || '').trim();
|
||||
let outDir = path.join(baseDir, 'model-search');
|
||||
let outDir = saveDirs(workspacePath).downloadDir;
|
||||
if (projectNameArg) {
|
||||
const found = findWorkshopProjectName(workspacePath, projectNameArg);
|
||||
if (!found.ok) return { success: false, error: found.error };
|
||||
outDir = path.join(baseDir, sanitizeFolderName(found.name));
|
||||
if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' };
|
||||
outDir = saveDirs(workspacePath, found.id).downloadDir;
|
||||
if (!isPathInsideDir(workspacePath, outDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' };
|
||||
}
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
|
||||
+20
-16
@@ -6,16 +6,17 @@ import { isPathInsideDir } from './image.js';
|
||||
import { loadWorkshop } from '../gateway/routes/workshop-storage.js';
|
||||
import { registerPrintJob } from '../gateway/workshop-print-watch.js';
|
||||
import { sessionProjectId } from './workshop-project.js';
|
||||
import { saveDirs } from '../gateway/routes/workshop-layout.js';
|
||||
import {
|
||||
K2_HOST, K2_MOONRAKER_PORT, PRUSA_SLICER_BIN, MATERIAL_PRESETS,
|
||||
buildBaseArgs, runPrusaSlicer, uploadToMoonraker, parsePrusaTime, fmtDuration,
|
||||
} from './k2-slicer-core.js';
|
||||
|
||||
// "작업실"(workshop_project) 프로젝트에 묶어서 받은 STL이면 print3d/<프로젝트명>/ 하위폴더에
|
||||
// 저장한다(2026-09-20) — project_name 없이 부르면 예전처럼 print3d/ 바로 아래(공용). 프로젝트
|
||||
// "작업실"(workshop_project) 프로젝트에 묶어서 받은 STL이면 workshop/<프로젝트id>/ 아래(모델은 CAD/다운로드/, G-code는 출력/)에
|
||||
// 저장한다(2026-09-20) → 09-25 폴더 규칙 통일(workshop-layout.ts)로 workshop/<id>/CAD/다운로드·출력/ 로 바뀜. project_name 없으면 print3d/일회성/. 프로젝트
|
||||
// 이름 매칭은 workshop-project.ts의 findProjectIndex와 동일한 부분일치 규칙을 그대로 따른다.
|
||||
// 09-23: model-search.ts(model_download)에서도 재사용하므로 export.
|
||||
export function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string } | { ok: false; error: string } {
|
||||
export function findWorkshopProjectName(workspacePath: string, query: string): { ok: true; name: string; id: string } | { ok: false; error: string } {
|
||||
const data = loadWorkshop(workspacePath);
|
||||
const q = query.trim().toLowerCase();
|
||||
const matches = data.projects.filter((pr) => pr.name.toLowerCase().includes(q));
|
||||
@@ -26,7 +27,7 @@ export function findWorkshopProjectName(workspacePath: string, query: string): {
|
||||
if (matches.length > 1) {
|
||||
return { ok: false, error: `"${query}"에 여러 프로젝트가 일치합니다: ${matches.map((p) => p.name).join(', ')} — 더 구체적으로 지정하세요.` };
|
||||
}
|
||||
return { ok: true, name: matches[0].name };
|
||||
return { ok: true, name: matches[0].name, id: matches[0].id };
|
||||
}
|
||||
|
||||
// 프로젝트 이름을 폴더 세그먼트로 — 슬래시/역슬래시/NUL만 막고 나머지(한글 포함)는 그대로 둔다.
|
||||
@@ -77,8 +78,8 @@ export const print3dModelTool = {
|
||||
properties: {
|
||||
action: { type: 'string', enum: ['prepare', 'start'], description: 'prepare: STL 다운로드+슬라이싱+프린터 업로드(출력은 시작 안 함). start: 실제 출력 시작(반드시 confirm=true, 사용자가 명시적으로 승인한 뒤에만 호출)' },
|
||||
stl_url: { type: 'string', description: '(prepare, stl_path 대신) STL 또는 3MF 파일 직접 다운로드 URL(http/https). URL이 .3mf로 끝나면 3MF로 처리(PrusaSlicer가 네이티브 지원). Thingiverse는 인증 없이 다운로드가 막혀있어 안 됨 — model_search로 찾아 model_download로 받거나, Printables/GitHub raw 등 직접 링크를 쓸 것.' },
|
||||
stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/model-search/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' },
|
||||
project_name: { type: 'string', description: '(prepare, 선택) 이 STL이 "작업실" 대시보드의 어느 프로젝트 부품인지(일부만 일치해도 됨, 예: "로봇"). 주면 print3d/<프로젝트명>/ 하위폴더에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 print3d/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' },
|
||||
stl_path: { type: 'string', description: '(prepare, stl_url 대신) 워크스페이스 기준 상대경로의 STL/3MF 파일(예: "print3d/모델검색/printables_1138497_mecanum.stl"). model_download로 받아둔 파일을 슬라이싱할 때 이걸 쓴다. stl_url과 동시에 주면 stl_path 우선.' },
|
||||
project_name: { type: 'string', description: '(prepare, 선택) 이 STL이 "작업실" 대시보드의 어느 프로젝트 부품인지(일부만 일치해도 됨, 예: "로봇"). 주면 workshop/<프로젝트id>/ 아래(모델은 CAD/다운로드/, G-code는 출력/)에 저장해 그 프로젝트 작업물끼리 묶는다. 생략하면 기존처럼 공용 print3d/ 폴더(프로젝트와 무관한 일회성 출력)에 저장 — 작업실 프로젝트와 상관없는 요청이면 비워둘 것.' },
|
||||
layer_height: { type: 'number', description: '(prepare, 선택) 레이어 높이 mm. 기본 0.2' },
|
||||
infill_percent: { type: 'number', description: '(prepare, 선택) 인필 밀도 %. 기본 15' },
|
||||
wall_count: { type: 'number', description: '(prepare, 선택) 벽 겹수. 기본 2' },
|
||||
@@ -159,11 +160,13 @@ export const print3dModelTool = {
|
||||
const wallCount = Number.isFinite(Number(args?.wall_count)) ? Math.max(1, Math.round(Number(args.wall_count))) : 2;
|
||||
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const baseDir = path.join(workspacePath, 'print3d');
|
||||
// 저장 위치 규칙(workshop-layout.ts): 프로젝트가 있으면 내려받은 모델 → workshop/<id>/CAD/다운로드/, 슬라이싱 결과 G-code →
|
||||
// workshop/<id>/출력/. 프로젝트가 없으면 print3d/일회성/(모델 파일 옆에 G-code를 두는 stl_path 경로는 그대로).
|
||||
const stamp = Date.now();
|
||||
const projectNameArg = String(args?.project_name || '').trim();
|
||||
let projectFolderName = '';
|
||||
let outDir = baseDir;
|
||||
let projectId = '';
|
||||
let { outDir, modelDir } = saveDirs(workspacePath); // outDir=G-code 저장 폴더, modelDir=URL로 받은 모델 저장 폴더
|
||||
let stlPath: string;
|
||||
if (stlPathArg) {
|
||||
const resolved = path.resolve(workspacePath, stlPathArg);
|
||||
@@ -177,9 +180,9 @@ export const print3dModelTool = {
|
||||
if (projectNameArg) {
|
||||
const found = findWorkshopProjectName(workspacePath, projectNameArg);
|
||||
if (!found.ok) return { success: false, error: found.error };
|
||||
projectFolderName = found.name;
|
||||
outDir = path.join(baseDir, sanitizeFolderName(projectFolderName));
|
||||
if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' };
|
||||
projectFolderName = found.name; projectId = found.id;
|
||||
outDir = saveDirs(workspacePath, found.id).outDir;
|
||||
if (!isPathInsideDir(workspacePath, outDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' };
|
||||
} else {
|
||||
outDir = path.dirname(resolved);
|
||||
}
|
||||
@@ -188,16 +191,17 @@ export const print3dModelTool = {
|
||||
if (projectNameArg) {
|
||||
const found = findWorkshopProjectName(workspacePath, projectNameArg);
|
||||
if (!found.ok) return { success: false, error: found.error };
|
||||
projectFolderName = found.name;
|
||||
outDir = path.join(baseDir, sanitizeFolderName(projectFolderName));
|
||||
if (!isPathInsideDir(baseDir, outDir)) return { success: false, error: '프로젝트 이름으로 만든 경로가 print3d 디렉토리 밖입니다.' };
|
||||
projectFolderName = found.name; projectId = found.id;
|
||||
({ outDir, modelDir } = saveDirs(workspacePath, found.id));
|
||||
if (!isPathInsideDir(workspacePath, outDir) || !isPathInsideDir(workspacePath, modelDir)) return { success: false, error: '프로젝트 폴더 경로가 워크스페이스 밖입니다.' };
|
||||
}
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
if (!fs.existsSync(modelDir)) fs.mkdirSync(modelDir, { recursive: true });
|
||||
// 2026-09-21: 3MF 지원 — URL 확장자가 .3mf면 그대로 3mf로 저장(PrusaSlicer CLI가 네이티브
|
||||
// 지원, 확장자로 자동판별). 그 외엔 기존처럼 .stl로 저장(URL에 확장자 없어도 안전한 기본값).
|
||||
const urlExt = (() => { try { return path.extname(new URL(stlUrl).pathname).toLowerCase(); } catch { return ''; } })();
|
||||
const modelExt = urlExt === '.3mf' ? '.3mf' : '.stl';
|
||||
stlPath = path.join(outDir, `model_${stamp}${modelExt}`);
|
||||
stlPath = path.join(modelDir, `model_${stamp}${modelExt}`);
|
||||
|
||||
const dl = await downloadStl(stlUrl, stlPath);
|
||||
if (!dl.ok) return { success: false, error: dl.detail };
|
||||
@@ -225,7 +229,7 @@ export const print3dModelTool = {
|
||||
|
||||
const timeStr = printTimeSec != null ? fmtDuration(printTimeSec) : '알 수 없음';
|
||||
const materialNote = material.verified ? '' : ' (⚠️ 온도값은 검증되지 않은 일반 추정치입니다)';
|
||||
const projectNote = projectFolderName ? `\n- 작업실 프로젝트: "${projectFolderName}" (print3d/${projectFolderName}/ 폴더에 저장)` : '';
|
||||
const projectNote = projectFolderName ? `\n- 작업실 프로젝트: "${projectFolderName}" (모델: workshop/${projectId}/CAD/다운로드/, G-code: workshop/${projectId}/출력/)` : '';
|
||||
return {
|
||||
success: true,
|
||||
stdout: `K2 콤보에 업로드 완료: **${gcodeFilename}**\n` +
|
||||
|
||||
@@ -138,7 +138,7 @@ export const workshopProjectTool = {
|
||||
query: { type: 'string', description: '(check_price 선택) part_name 대신 직접 검색어를 줄 때' },
|
||||
confirm: { type: 'boolean', description: '(delete_project 필수) 반드시 true — 사용자가 삭제를 명시적으로 요청한 경우에만' },
|
||||
ts: { type: 'number', description: '(restore_history 필수) list_history가 준 이력 시각(ts)' },
|
||||
scad_path: { type: 'string', description: '(scan_scad 선택) 워크스페이스 기준 상대경로의 .scad(예: "workshop/<프로젝트id>/CAD/print/rig.scad"). 생략하면 현재 조립 명세의 scad.' },
|
||||
scad_path: { type: 'string', description: '(scan_scad 선택) 워크스페이스 기준 상대경로의 .scad(예: "workshop/<프로젝트id>/CAD/rig.scad"). 생략하면 현재 조립 명세의 scad.' },
|
||||
assembly: {
|
||||
type: 'object',
|
||||
description: '(set_assembly 필수) 조립 설명서 명세 전체(부분 수정도 전체를 다시 넣을 것 — 먼저 get_assembly로 현재 값 확인). {scad, view:{rx,rz}, explode, parts:[{id,name,call,offset:[x,y,z],bom,qty}], steps:[{title,desc,partIds:[부품 id],fasteners:[{name,qty}],tools:[문자열]}]}. parts[].id는 짧은 영문/숫자(예: "base"), steps[].partIds가 참조한다. call은 .scad에 있는 조립 모듈 한 개의 호출("base_plate()", "camera(80)") — 따옴표/세미콜론 금지.',
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 작업실 파일 폴더 규칙(workshop-layout.ts) + 이동 스크립트(apply/undo) 테스트.
|
||||
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 { spawnSync } from 'child_process';
|
||||
import { targetForFile, planProjectMoves, pruneEmptyDirs, renameTextPaths, saveDirs, planPrint3dMoves } from '../src/gateway/routes/workshop-layout';
|
||||
|
||||
describe('targetForFile — 폴더 규칙', () => {
|
||||
const cases: [string, string | null][] = [
|
||||
// 인쇄용 STL은 .scad와 같은 폴더(CAD/)
|
||||
['CAD/print/base_plate.stl', 'CAD/base_plate.stl'],
|
||||
['CAD/print/scanner-rig.scad', 'CAD/scanner-rig.scad'],
|
||||
['print/base.stl', 'CAD/base.stl'],
|
||||
['robot.scad', 'CAD/robot.scad'],
|
||||
['robot.stl', 'CAD/robot.stl'],
|
||||
// 참고용은 분리 유지(09-22 "인쇄 대상으로 착각" 방지)
|
||||
['CAD/reference/cam_left.stl', 'CAD/참고/cam_left.stl'],
|
||||
['reference/x.stl', 'CAD/참고/x.stl'],
|
||||
// 받아 온 모델 묶음은 자기 폴더 유지, stls/ 접두어만 제거
|
||||
['stls/SO101/Leader/a.stl', 'CAD/SO101/Leader/a.stl'],
|
||||
// 이미지/영상/G-code
|
||||
['CAD/assembly.png', '사진/assembly.png'],
|
||||
['CAD/reference/assembly.png', '사진/assembly.png'],
|
||||
['iso.png', '사진/iso.png'],
|
||||
['robot_2min.mp4', '영상/robot_2min.mp4'],
|
||||
['out.gcode', '출력/out.gcode'],
|
||||
// 이미 규칙에 맞음 / 손대지 않는 것
|
||||
['CAD/base.stl', null],
|
||||
['CAD/참고/x.stl', null],
|
||||
['CAD/SO101/Leader/a.stl', null],
|
||||
['CAD/다운로드/m.stl', null],
|
||||
['사진/a.png', null],
|
||||
['영상/a.mp4', null],
|
||||
['출력/a.gcode', null],
|
||||
['조립설명서/step-01.png', null],
|
||||
['개요/readme.md', null],
|
||||
['.hidden/a.stl', null],
|
||||
['메모.txt', null],
|
||||
['data.csv', null],
|
||||
// 사용자가 직접 만든 폴더는 존중
|
||||
['견적/부품도.png', null],
|
||||
['참고자료/a.stl', null],
|
||||
];
|
||||
for (const [from, to] of cases) test(`${from} → ${to ?? '(그대로)'}`, () => assert.equal(targetForFile(from), to));
|
||||
test('멱등: 결과를 다시 넣으면 그대로', () => {
|
||||
for (const [, to] of cases) if (to) assert.equal(targetForFile(to), null, to);
|
||||
});
|
||||
});
|
||||
|
||||
describe('문서 문구 갱신', () => {
|
||||
test('옛 폴더 이름을 새 이름으로', () => {
|
||||
assert.equal(renameTextPaths('CAD/print/ 와 CAD/reference/ 그리고 CAD/print, CAD/reference, CAD/렌더'), 'CAD/ 와 CAD/참고/ 그리고 CAD, CAD/참고, 사진');
|
||||
});
|
||||
});
|
||||
|
||||
function tmpWorkspace(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'layout-'));
|
||||
const ws = path.join(dir, '.smallclaw', 'users', 'tester', 'workspace');
|
||||
const put = (rel: string, body = 'x') => { const p = path.join(ws, 'workshop', 'p1', rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); };
|
||||
put('CAD/print/rig.scad', 'scad-1'); put('CAD/print/a.stl', 'stl-a'); put('CAD/reference/cam.stl', 'ref'); put('CAD/reference/asm.png', 'png');
|
||||
put('demo.mp4', 'v'); put('stls/SO/x.stl', 'x'); put('개요/readme.md', 'r'); put('조립설명서/overview.png', 'o'); put('견적/a.png', 'keep');
|
||||
fs.mkdirSync(path.join(ws, '.smallclaw'), { recursive: true });
|
||||
fs.writeFileSync(path.join(ws, '.smallclaw', 'workshop.json'), JSON.stringify({
|
||||
activeProjectId: 'p1',
|
||||
projects: [{ id: 'p1', name: 'P', parts: [], phases: [], notes: 'CAD/print/ 에 있음', description: 'CAD/reference/ 는 참고용',
|
||||
assembly: { scad: 'workshop/p1/CAD/print/rig.scad', view: { rx: 60, rz: 35 }, explode: 1, autoRender: true, parts: [], steps: [] } }],
|
||||
}));
|
||||
return dir;
|
||||
}
|
||||
const tree = (root: string, base = ''): string[] => fs.readdirSync(path.join(root, base), { withFileTypes: true }).flatMap(e => {
|
||||
const rel = base ? `${base}/${e.name}` : e.name;
|
||||
return e.isDirectory() ? tree(root, rel) : [rel];
|
||||
}).sort();
|
||||
|
||||
describe('planProjectMoves / 이동 스크립트', () => {
|
||||
test('충돌은 덮어쓰지 않고 건너뛴다', () => {
|
||||
const dir = tmpWorkspace();
|
||||
const ws = path.join(dir, '.smallclaw/users/tester/workspace');
|
||||
fs.writeFileSync(path.join(ws, 'workshop/p1/CAD/a.stl'), 'existing'); // CAD/print/a.stl 의 도착지에 이미 존재
|
||||
const { moves, conflicts } = planProjectMoves(ws, 'p1');
|
||||
assert.ok(conflicts.some(c => c.from === 'CAD/print/a.stl'));
|
||||
assert.ok(!moves.some(m => m.from === 'CAD/print/a.stl'));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('pruneEmptyDirs: 빈 폴더만 지운다', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prune-'));
|
||||
fs.mkdirSync(path.join(dir, 'a/b'), { recursive: true }); fs.mkdirSync(path.join(dir, 'c'), { recursive: true }); fs.writeFileSync(path.join(dir, 'c/f.txt'), 'x');
|
||||
const removed = pruneEmptyDirs(dir);
|
||||
assert.deepEqual(removed.sort(), ['a', 'a/b']);
|
||||
assert.ok(fs.existsSync(path.join(dir, 'c/f.txt')));
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('dry-run은 아무것도 안 바꾸고, --apply로 이동+경로 갱신, --undo로 원복(내용 보존)', () => {
|
||||
const dir = tmpWorkspace();
|
||||
const ws = path.join(dir, '.smallclaw/users/tester/workspace');
|
||||
const proj = path.join(ws, 'workshop/p1');
|
||||
const before = tree(proj);
|
||||
const env = { ...process.env, SMALLCLAW_DATA_DIR: dir };
|
||||
const run = (...a: string[]) => spawnSync('npx', ['tsx', 'scripts/migrate-workshop-layout.ts', '--user', 'tester', ...a], { cwd: path.join(__dirname, '..'), env, encoding: 'utf-8' });
|
||||
|
||||
const dry = run();
|
||||
assert.match(dry.stdout, /dry-run/);
|
||||
assert.deepEqual(tree(proj), before, 'dry-run이 파일을 바꿈');
|
||||
|
||||
const app = run('--apply');
|
||||
assert.equal(app.status, 0, app.stderr);
|
||||
assert.deepEqual(tree(proj), [
|
||||
'CAD/SO/x.stl', 'CAD/a.stl', 'CAD/rig.scad', 'CAD/참고/cam.stl', '견적/a.png', '개요/readme.md', '사진/asm.png', '영상/demo.mp4', '조립설명서/overview.png',
|
||||
].sort());
|
||||
assert.equal(fs.readFileSync(path.join(proj, 'CAD/rig.scad'), 'utf-8'), 'scad-1'); // 내용 그대로
|
||||
const data = JSON.parse(fs.readFileSync(path.join(ws, '.smallclaw/workshop.json'), 'utf-8'));
|
||||
assert.equal(data.projects[0].assembly.scad, 'workshop/p1/CAD/rig.scad');
|
||||
assert.equal(data.projects[0].notes, 'CAD/ 에 있음');
|
||||
assert.equal(fs.existsSync(path.join(proj, 'CAD/print')), false, '빈 폴더 정리');
|
||||
assert.equal(fs.existsSync(path.join(proj, 'stls')), false);
|
||||
|
||||
const again = run('--apply'); // 멱등: 두 번째는 이동할 게 없다
|
||||
assert.match(again.stdout, /이동 0개/);
|
||||
|
||||
const manifest = fs.readdirSync(path.join(ws, '.smallclaw')).find(f => f.startsWith('workshop-layout-migration-'))!;
|
||||
const undo = run('--undo', path.join(ws, '.smallclaw', manifest));
|
||||
assert.match(undo.stdout, /되돌림/);
|
||||
assert.deepEqual(tree(proj), before);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(ws, '.smallclaw/workshop.json'), 'utf-8')).projects[0].assembly.scad, 'workshop/p1/CAD/print/rig.scad');
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('저장 폴더 결정(saveDirs) / print3d 정돈', () => {
|
||||
test('프로젝트가 있으면 모델→CAD/다운로드, G-code→출력 / 없으면 print3d 하위', () => {
|
||||
const p = saveDirs('/w', 'proj_a');
|
||||
assert.equal(p.modelDir, '/w/workshop/proj_a/CAD/다운로드');
|
||||
assert.equal(p.outDir, '/w/workshop/proj_a/출력');
|
||||
assert.equal(p.downloadDir, '/w/workshop/proj_a/CAD/다운로드');
|
||||
const n = saveDirs('/w');
|
||||
assert.equal(n.modelDir, '/w/print3d/일회성');
|
||||
assert.equal(n.outDir, '/w/print3d/일회성');
|
||||
assert.equal(n.downloadDir, '/w/print3d/모델검색');
|
||||
});
|
||||
|
||||
test('planPrint3dMoves: k2ui_*→K2웹슬라이서, model-search→모델검색, 루트 잔여→일회성, k2_config.ini→profiles, 기존 하위폴더/프로파일은 그대로', () => {
|
||||
const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'p3d-'));
|
||||
const put = (rel: string) => { const f = path.join(ws, rel); fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, 'x'); };
|
||||
put('print3d/k2ui_123.stl'); put('print3d/k2ui_123.gcode'); put('print3d/loose.stl'); put('print3d/model-search/a.stl');
|
||||
put('print3d/esp32_camera/case.stl'); put('print3d/profiles/__current.ini'); put('k2_config.ini');
|
||||
const m = planPrint3dMoves(ws).map(x => `${x.from} → ${x.to}`).sort();
|
||||
assert.deepEqual(m, [
|
||||
'k2_config.ini → print3d/profiles/k2_config.ini',
|
||||
'print3d/k2ui_123.gcode → print3d/K2웹슬라이서/k2ui_123.gcode',
|
||||
'print3d/k2ui_123.stl → print3d/K2웹슬라이서/k2ui_123.stl',
|
||||
'print3d/loose.stl → print3d/일회성/loose.stl',
|
||||
'print3d/model-search/a.stl → print3d/모델검색/a.stl',
|
||||
].sort());
|
||||
fs.rmSync(ws, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -1632,10 +1632,14 @@ async function loadFiles(){
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
// 프로젝트 파일 폴더 규칙(서버 workshop-layout.ts와 같은 이름): 비어 있어도 업로드 대상으로 고를 수 있게 권장 폴더를 항상 보여준다.
|
||||
const STD_FOLDERS=['CAD','CAD/참고','출력','사진','영상'];
|
||||
function folderRank(f){const i=STD_FOLDERS.indexOf(f);return i>=0?i:(f.startsWith('CAD/')?1.5:100);}
|
||||
function renderFolderSelect(){
|
||||
const sel=document.getElementById('upload-folder-select');
|
||||
const prev=sel.value;
|
||||
sel.innerHTML='<option value="">(폴더 없음)</option>'+filesState.folders.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}</option>`).join('');
|
||||
const all=[...new Set([...STD_FOLDERS,...filesState.folders])].filter(f=>f!=='조립설명서').sort((a,b)=>folderRank(a)-folderRank(b)||a.localeCompare(b));
|
||||
sel.innerHTML='<option value="">(폴더 없음)</option>'+all.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}${STD_FOLDERS.includes(f)&&!filesState.folders.includes(f)?' (권장)':''}</option>`).join('');
|
||||
if([...sel.options].some(o=>o.value===prev))sel.value=prev;
|
||||
}
|
||||
|
||||
@@ -1656,7 +1660,7 @@ function renderFiles(){
|
||||
}
|
||||
const keys=[...groups.keys()].sort((a,b)=>{
|
||||
if(a==='')return 1; if(b==='')return -1;
|
||||
return a.localeCompare(b);
|
||||
return folderRank(a)-folderRank(b)||a.localeCompare(b); // 규칙 폴더(CAD→참고→출력→사진→영상)를 먼저
|
||||
});
|
||||
container.innerHTML=keys.map(key=>{
|
||||
const files=groups.get(key)||[];
|
||||
|
||||
Reference in New Issue
Block a user