docuking-mcp 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/handlers/sync.js +170 -55
- package/index.js +8 -0
- package/lib/files.js +19 -4
- package/package.json +1 -1
package/handlers/sync.js
CHANGED
|
@@ -540,9 +540,10 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
540
540
|
console.error('[DocuKing] Sync 시작 알림 실패:', e.message);
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
-
// 서버에서 파일 해시 조회 (Git 스타일 동기화용)
|
|
543
|
+
// 서버에서 파일 해시 및 메타 정보 조회 (Git 스타일 동기화용)
|
|
544
544
|
let serverPathToHash = {};
|
|
545
545
|
let serverHashToPath = {};
|
|
546
|
+
let serverPathToMeta = {}; // source, type, deletedAt 정보 포함
|
|
546
547
|
let serverAllPaths = [];
|
|
547
548
|
try {
|
|
548
549
|
const hashResponse = await fetch(
|
|
@@ -557,6 +558,7 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
557
558
|
const hashData = await hashResponse.json();
|
|
558
559
|
serverPathToHash = hashData.pathToHash || {};
|
|
559
560
|
serverHashToPath = hashData.hashToPath || {};
|
|
561
|
+
serverPathToMeta = hashData.pathToMeta || {};
|
|
560
562
|
serverAllPaths = hashData.allPaths || [];
|
|
561
563
|
}
|
|
562
564
|
} catch (e) {
|
|
@@ -734,24 +736,48 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
734
736
|
}
|
|
735
737
|
}
|
|
736
738
|
|
|
739
|
+
// ========================================
|
|
737
740
|
// 4. 서버에만 있고 로컬에 없는 파일 soft-delete (deleted_at 기반)
|
|
741
|
+
// ========================================
|
|
738
742
|
// 단, 협업자 폴더(yy_Coworker_*)는 삭제하지 않음 (오너가 협업자 파일을 삭제하면 안됨)
|
|
739
|
-
//
|
|
740
|
-
// 3일 후 크론잡에서 hard delete 수행
|
|
743
|
+
// source: 'web' 파일은 MCP에서 삭제 시도하지 않음 (클라이언트에서 필터링)
|
|
741
744
|
// 중요: localPathsBeforePull 사용 (Pull 전에 수집한 목록)
|
|
742
745
|
// processedLocalPaths는 Pull로 내려받은 파일도 포함하므로 사용 금지
|
|
743
746
|
let deleted = 0;
|
|
744
747
|
const deletedFilePaths = [];
|
|
745
748
|
let protectedFiles = []; // source: 'web' 파일 (보호됨)
|
|
749
|
+
const webProtectedPaths = []; // MCP에서 미리 필터링한 웹 파일
|
|
750
|
+
|
|
746
751
|
if (serverAllPaths.length > 0 && !isCoworker) {
|
|
747
|
-
//
|
|
752
|
+
// 오너만 삭제 수행
|
|
748
753
|
// yy_Coworker_*로 시작하는 경로는 삭제 대상에서 제외
|
|
749
754
|
// localPathsBeforePull: Pull 전에 수집한 로컬 파일 목록 (좀비 파일 방지)
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
755
|
+
//
|
|
756
|
+
// ★ 핵심: source: 'web' 파일은 삭제 대상에서 미리 제외!
|
|
757
|
+
// 이렇게 하면 웹에서 생성한 파일이 절대 삭제되지 않음
|
|
758
|
+
const pathsToDelete = serverAllPaths.filter(p => {
|
|
759
|
+
// 1. 로컬에 있으면 제외 (삭제 대상 아님)
|
|
760
|
+
if (localPathsBeforePull.has(p)) return false;
|
|
761
|
+
|
|
762
|
+
// 2. 협업자 폴더는 제외
|
|
763
|
+
if (p.startsWith('yy_Coworker_')) return false;
|
|
764
|
+
|
|
765
|
+
// 3. source: 'web' 파일은 제외 (웹에서 생성된 파일 보호)
|
|
766
|
+
const meta = serverPathToMeta[p];
|
|
767
|
+
if (meta && meta.source === 'web') {
|
|
768
|
+
webProtectedPaths.push(p);
|
|
769
|
+
return false;
|
|
770
|
+
}
|
|
753
771
|
|
|
754
|
-
|
|
772
|
+
return true;
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
// 웹 보호 파일 로그
|
|
776
|
+
if (webProtectedPaths.length > 0) {
|
|
777
|
+
console.error(`[DocuKing] source:web 파일 ${webProtectedPaths.length}개 삭제에서 제외:`, webProtectedPaths.slice(0, 5));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.error(`[DocuKing] soft-delete 대상: ${pathsToDelete.length}개 (서버에만 있고 Pull 전 로컬에 없던 파일, source:local만)`);
|
|
755
781
|
|
|
756
782
|
if (pathsToDelete.length > 0) {
|
|
757
783
|
try {
|
|
@@ -774,15 +800,18 @@ docuking_init을 먼저 실행하세요.`,
|
|
|
774
800
|
deletedFilePaths.push(...(deleteResult.deletedPaths || []));
|
|
775
801
|
protectedFiles = deleteResult.protected || [];
|
|
776
802
|
|
|
777
|
-
// 보호된 파일이 있으면 로그 출력
|
|
803
|
+
// 보호된 파일이 있으면 로그 출력 (Backend에서 추가로 보호한 파일)
|
|
778
804
|
if (protectedFiles.length > 0) {
|
|
779
|
-
console.error(`[DocuKing] 보호된 파일 ${protectedFiles.length}
|
|
805
|
+
console.error(`[DocuKing] Backend에서 추가 보호된 파일 ${protectedFiles.length}개:`, protectedFiles.slice(0, 5));
|
|
780
806
|
}
|
|
781
807
|
}
|
|
782
808
|
} catch (e) {
|
|
783
809
|
console.error('[DocuKing] 파일 soft-delete 실패:', e.message);
|
|
784
810
|
}
|
|
785
811
|
}
|
|
812
|
+
|
|
813
|
+
// MCP에서 미리 제외한 웹 파일도 보호 목록에 추가 (결과 표시용)
|
|
814
|
+
protectedFiles = [...protectedFiles, ...webProtectedPaths];
|
|
786
815
|
}
|
|
787
816
|
|
|
788
817
|
// 5. 빈 폴더 생성
|
|
@@ -1018,54 +1047,79 @@ export async function handlePullInternal(args) {
|
|
|
1018
1047
|
fs.mkdirSync(mainFolderPath, { recursive: true });
|
|
1019
1048
|
}
|
|
1020
1049
|
|
|
1021
|
-
//
|
|
1022
|
-
|
|
1023
|
-
|
|
1050
|
+
// ========================================
|
|
1051
|
+
// 서버 메타정보 조회 (pathToMeta: source, type, deletedAt 포함)
|
|
1052
|
+
// ========================================
|
|
1053
|
+
let serverPathToHash = {};
|
|
1054
|
+
let serverHashToPath = {};
|
|
1055
|
+
let serverPathToMeta = {};
|
|
1056
|
+
let serverAllPaths = [];
|
|
1057
|
+
let deletedFiles = [];
|
|
1024
1058
|
|
|
1025
1059
|
try {
|
|
1026
|
-
const
|
|
1027
|
-
`${API_ENDPOINT}/files/
|
|
1060
|
+
const hashResponse = await fetch(
|
|
1061
|
+
`${API_ENDPOINT}/files/hashes-for-sync?projectId=${projectId}`,
|
|
1028
1062
|
{
|
|
1029
1063
|
headers: {
|
|
1030
1064
|
'Authorization': `Bearer ${apiKey}`,
|
|
1031
1065
|
},
|
|
1032
1066
|
}
|
|
1033
1067
|
);
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1068
|
+
if (hashResponse.ok) {
|
|
1069
|
+
const hashData = await hashResponse.json();
|
|
1070
|
+
serverPathToHash = hashData.pathToHash || {};
|
|
1071
|
+
serverHashToPath = hashData.hashToPath || {};
|
|
1072
|
+
serverPathToMeta = hashData.pathToMeta || {};
|
|
1073
|
+
serverAllPaths = hashData.allPaths || [];
|
|
1074
|
+
deletedFiles = hashData.deletedFiles || [];
|
|
1037
1075
|
}
|
|
1038
|
-
|
|
1039
|
-
const data = await response.json();
|
|
1040
|
-
files = flattenTree(data.tree || []);
|
|
1041
1076
|
} catch (e) {
|
|
1042
|
-
|
|
1077
|
+
console.error('[DocuKing] 서버 메타정보 조회 실패:', e.message);
|
|
1043
1078
|
}
|
|
1044
1079
|
|
|
1045
|
-
//
|
|
1080
|
+
// 파일 트리 조회 (폴더 포함)
|
|
1081
|
+
let files = [];
|
|
1046
1082
|
try {
|
|
1047
|
-
const
|
|
1048
|
-
`${API_ENDPOINT}/files/
|
|
1083
|
+
const response = await fetch(
|
|
1084
|
+
`${API_ENDPOINT}/files/tree?projectId=${projectId}`,
|
|
1049
1085
|
{
|
|
1050
1086
|
headers: {
|
|
1051
1087
|
'Authorization': `Bearer ${apiKey}`,
|
|
1052
1088
|
},
|
|
1053
1089
|
}
|
|
1054
1090
|
);
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1091
|
+
|
|
1092
|
+
if (!response.ok) {
|
|
1093
|
+
throw new Error(await response.text());
|
|
1058
1094
|
}
|
|
1095
|
+
|
|
1096
|
+
const data = await response.json();
|
|
1097
|
+
files = flattenTree(data.tree || [], '', true); // 폴더 포함
|
|
1059
1098
|
} catch (e) {
|
|
1060
|
-
|
|
1099
|
+
throw new Error(`파일 목록 조회 실패 - ${e.message}`);
|
|
1061
1100
|
}
|
|
1062
1101
|
|
|
1063
1102
|
if (filePath) {
|
|
1064
1103
|
files = files.filter(f => f.path === filePath || f.path.startsWith(filePath + '/'));
|
|
1065
1104
|
}
|
|
1066
1105
|
|
|
1067
|
-
|
|
1068
|
-
|
|
1106
|
+
// ========================================
|
|
1107
|
+
// 완전판 Pull 로직
|
|
1108
|
+
// 1. 폴더 먼저 정렬 (상위 폴더 → 하위 폴더)
|
|
1109
|
+
// 2. 파일 다운로드 (source:web 파일도 Pull)
|
|
1110
|
+
// 3. mark-as-pulled 호출 (source: web → local 변경)
|
|
1111
|
+
// ========================================
|
|
1112
|
+
|
|
1113
|
+
// 폴더를 먼저 정렬 (상위 폴더부터 생성해야 함)
|
|
1114
|
+
const sortedFiles = [...files].sort((a, b) => {
|
|
1115
|
+
// 폴더 먼저
|
|
1116
|
+
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
|
1117
|
+
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
|
1118
|
+
// 경로 깊이 순 (상위 먼저)
|
|
1119
|
+
return a.path.split('/').length - b.path.split('/').length;
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
if (sortedFiles.length === 0 && deletedFiles.length === 0) {
|
|
1069
1123
|
return {
|
|
1070
1124
|
text: 'Pull할 파일이 없습니다.',
|
|
1071
1125
|
total: 0,
|
|
@@ -1076,20 +1130,64 @@ export async function handlePullInternal(args) {
|
|
|
1076
1130
|
};
|
|
1077
1131
|
}
|
|
1078
1132
|
|
|
1079
|
-
// 파일 다운로드
|
|
1080
1133
|
const results = [];
|
|
1081
|
-
const total =
|
|
1134
|
+
const total = sortedFiles.length;
|
|
1082
1135
|
let current = 0;
|
|
1083
1136
|
let downloaded = 0; // 새로 다운로드
|
|
1084
1137
|
let updated = 0; // 업데이트 (변경됨)
|
|
1085
1138
|
let skipped = 0; // 스킵 (변경 없음)
|
|
1086
1139
|
let failed = 0; // 실패
|
|
1140
|
+
let foldersCreated = 0; // 생성된 폴더
|
|
1087
1141
|
|
|
1088
|
-
|
|
1142
|
+
// Pull 후 mark-as-pulled할 파일 목록 (source: web 파일들)
|
|
1143
|
+
const filesToMarkAsPulled = [];
|
|
1144
|
+
|
|
1145
|
+
for (const file of sortedFiles) {
|
|
1089
1146
|
current++;
|
|
1090
1147
|
const progress = `${current}/${total}`;
|
|
1091
1148
|
|
|
1149
|
+
// 서버 메타 정보 조회
|
|
1150
|
+
const serverMeta = serverPathToMeta[file.path];
|
|
1151
|
+
|
|
1152
|
+
// 서버 경로에 따라 로컬 저장 위치 결정
|
|
1153
|
+
let fullPath;
|
|
1154
|
+
if (file.path.startsWith('xx_') ||
|
|
1155
|
+
file.path.startsWith('yy_All_Docu/') ||
|
|
1156
|
+
file.path.startsWith('yy_All_Docu') ||
|
|
1157
|
+
file.path.startsWith('yy_Coworker_') ||
|
|
1158
|
+
file.path.startsWith('zz_ai_')) {
|
|
1159
|
+
fullPath = path.join(localPath, file.path);
|
|
1160
|
+
} else {
|
|
1161
|
+
fullPath = path.join(mainFolderPath, file.path);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1092
1164
|
try {
|
|
1165
|
+
// ========================================
|
|
1166
|
+
// 폴더 처리 (type === 'folder')
|
|
1167
|
+
// ========================================
|
|
1168
|
+
if (file.type === 'folder') {
|
|
1169
|
+
if (fs.existsSync(fullPath)) {
|
|
1170
|
+
// 이미 있음 → 스킵
|
|
1171
|
+
results.push({ type: 'skip', path: file.path, itemType: 'folder' });
|
|
1172
|
+
skipped++;
|
|
1173
|
+
} else {
|
|
1174
|
+
// 폴더 생성
|
|
1175
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
1176
|
+
results.push({ type: 'download', path: file.path, itemType: 'folder' });
|
|
1177
|
+
foldersCreated++;
|
|
1178
|
+
downloaded++;
|
|
1179
|
+
|
|
1180
|
+
// source: 'web' 폴더면 mark-as-pulled 대상
|
|
1181
|
+
if (serverMeta && serverMeta.source === 'web') {
|
|
1182
|
+
filesToMarkAsPulled.push(file.path);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ========================================
|
|
1189
|
+
// 파일 처리 (type === 'file')
|
|
1190
|
+
// ========================================
|
|
1093
1191
|
const response = await fetch(
|
|
1094
1192
|
`${API_ENDPOINT}/files?projectId=${projectId}&path=${encodeURIComponent(file.path)}`,
|
|
1095
1193
|
{
|
|
@@ -1107,25 +1205,6 @@ export async function handlePullInternal(args) {
|
|
|
1107
1205
|
|
|
1108
1206
|
const data = await response.json();
|
|
1109
1207
|
|
|
1110
|
-
// 서버 경로에 따라 로컬 저장 위치 결정
|
|
1111
|
-
// 프로젝트 루트 기준 절대경로 그대로 사용
|
|
1112
|
-
// - xx_Infra_Config/xxx → xx_Infra_Config/xxx (시스템/인프라)
|
|
1113
|
-
// - xx_Policy/xxx → xx_Policy/xxx (정책)
|
|
1114
|
-
// - yy_All_Docu/xxx → yy_All_Docu/xxx (오너 문서)
|
|
1115
|
-
// - yy_Coworker_xxx/yyy → yy_Coworker_xxx/yyy (협업자 폴더)
|
|
1116
|
-
// - zz_ai_xxx/yyy → zz_ai_xxx/yyy (AI 폴더)
|
|
1117
|
-
let fullPath;
|
|
1118
|
-
if (file.path.startsWith('xx_') ||
|
|
1119
|
-
file.path.startsWith('yy_All_Docu/') ||
|
|
1120
|
-
file.path.startsWith('yy_Coworker_') ||
|
|
1121
|
-
file.path.startsWith('zz_ai_')) {
|
|
1122
|
-
// 서버 경로 그대로 로컬에 저장
|
|
1123
|
-
fullPath = path.join(localPath, file.path);
|
|
1124
|
-
} else {
|
|
1125
|
-
// 기타 (구버전 호환): yy_All_Docu/ 안에 저장
|
|
1126
|
-
fullPath = path.join(mainFolderPath, file.path);
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
1208
|
// 인코딩에 따라 저장
|
|
1130
1209
|
const content = data.file?.content || data.content || '';
|
|
1131
1210
|
const encoding = data.file?.encoding || data.encoding || 'utf-8';
|
|
@@ -1145,6 +1224,13 @@ export async function handlePullInternal(args) {
|
|
|
1145
1224
|
|
|
1146
1225
|
if (localExists) {
|
|
1147
1226
|
try {
|
|
1227
|
+
const localStat = fs.statSync(fullPath);
|
|
1228
|
+
if (localStat.isDirectory()) {
|
|
1229
|
+
// 로컬에 같은 이름의 폴더가 있으면 건너뜀
|
|
1230
|
+
results.push({ type: 'skip', path: file.path, error: '같은 이름의 폴더 존재' });
|
|
1231
|
+
skipped++;
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1148
1234
|
const localBuffer = fs.readFileSync(fullPath);
|
|
1149
1235
|
localHash = crypto.createHash('sha256').update(localBuffer).digest('hex');
|
|
1150
1236
|
} catch (e) {
|
|
@@ -1171,20 +1257,49 @@ export async function handlePullInternal(args) {
|
|
|
1171
1257
|
}
|
|
1172
1258
|
|
|
1173
1259
|
if (localExists) {
|
|
1174
|
-
// 기존 파일 업데이트
|
|
1175
1260
|
results.push({ type: 'update', path: file.path });
|
|
1176
1261
|
updated++;
|
|
1177
1262
|
} else {
|
|
1178
|
-
// 새로 다운로드
|
|
1179
1263
|
results.push({ type: 'download', path: file.path });
|
|
1180
1264
|
downloaded++;
|
|
1181
1265
|
}
|
|
1266
|
+
|
|
1267
|
+
// source: 'web' 파일이면 mark-as-pulled 대상
|
|
1268
|
+
if (serverMeta && serverMeta.source === 'web') {
|
|
1269
|
+
filesToMarkAsPulled.push(file.path);
|
|
1270
|
+
}
|
|
1182
1271
|
} catch (e) {
|
|
1183
1272
|
results.push({ type: 'fail', path: file.path, error: e.message });
|
|
1184
1273
|
failed++;
|
|
1185
1274
|
}
|
|
1186
1275
|
}
|
|
1187
1276
|
|
|
1277
|
+
// ========================================
|
|
1278
|
+
// mark-as-pulled 호출 (source: 'web' → 'local' 변경)
|
|
1279
|
+
// Pull된 웹 파일/폴더는 이제 로컬이 관리권한을 가짐
|
|
1280
|
+
// ========================================
|
|
1281
|
+
if (filesToMarkAsPulled.length > 0) {
|
|
1282
|
+
try {
|
|
1283
|
+
const markResponse = await fetch(`${API_ENDPOINT}/files/mark-as-pulled`, {
|
|
1284
|
+
method: 'POST',
|
|
1285
|
+
headers: {
|
|
1286
|
+
'Content-Type': 'application/json',
|
|
1287
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
1288
|
+
},
|
|
1289
|
+
body: JSON.stringify({
|
|
1290
|
+
projectId,
|
|
1291
|
+
paths: filesToMarkAsPulled,
|
|
1292
|
+
}),
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
if (markResponse.ok) {
|
|
1296
|
+
console.error(`[DocuKing] mark-as-pulled 완료: ${filesToMarkAsPulled.length}개`);
|
|
1297
|
+
}
|
|
1298
|
+
} catch (e) {
|
|
1299
|
+
console.error('[DocuKing] mark-as-pulled 실패:', e.message);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1188
1303
|
// 상세 결과 구성 (Push 스타일)
|
|
1189
1304
|
let resultText = `✓ Pull 완료!
|
|
1190
1305
|
|
package/index.js
CHANGED
|
@@ -713,6 +713,13 @@ zz_ai_3_Plan/
|
|
|
713
713
|
- planId로 계획 찾기
|
|
714
714
|
- 완료 요약 및 산출물 기록
|
|
715
715
|
|
|
716
|
+
### 12. docuking_delete
|
|
717
|
+
서버에서 파일/폴더를 직접 삭제합니다.
|
|
718
|
+
- 로컬에서 삭제한 파일이 Push 후에도 서버에 남아있을 때
|
|
719
|
+
- 서버에만 존재하는 불필요한 파일 정리
|
|
720
|
+
- soft-delete 처리 (3일 후 영구 삭제)
|
|
721
|
+
- 권한: 오너는 자기 영역, 협업자는 자기 폴더만
|
|
722
|
+
|
|
716
723
|
## Git과의 유사성
|
|
717
724
|
|
|
718
725
|
| DocuKing | Git | 설명 |
|
|
@@ -737,6 +744,7 @@ zz_ai_3_Plan/
|
|
|
737
744
|
| "DocuKing에 올려줘" | docuking_push({ localPath, message: "..." }) |
|
|
738
745
|
| "DocuKing에서 가져와" | docuking_pull({ localPath }) |
|
|
739
746
|
| "DocuKing에 뭐 있어?" | docuking_list({ localPath }) |
|
|
747
|
+
| "서버에서 삭제해줘" | docuking_delete({ localPath, paths: [...] }) |
|
|
740
748
|
|
|
741
749
|
## 협업 핵심 원칙: 남의 제사상을 건드리지 않는다
|
|
742
750
|
|
package/lib/files.js
CHANGED
|
@@ -193,18 +193,33 @@ export function collectFiles(basePath, relativePath, results, excludedFiles = nu
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
* 트리 구조를 평탄화
|
|
196
|
+
* 트리 구조를 평탄화 (파일 + 폴더 모두 포함)
|
|
197
|
+
* @param {Array} tree - 트리 구조
|
|
198
|
+
* @param {string} prefix - 경로 접두어
|
|
199
|
+
* @param {boolean} includeEmpty - 빈 폴더도 포함 (기본: true)
|
|
200
|
+
* @returns {Array} - { path, name, type } 배열
|
|
197
201
|
*/
|
|
198
|
-
export function flattenTree(tree, prefix = '') {
|
|
202
|
+
export function flattenTree(tree, prefix = '', includeEmpty = true) {
|
|
199
203
|
const results = [];
|
|
200
204
|
|
|
201
205
|
for (const item of tree) {
|
|
202
206
|
const itemPath = prefix ? `${prefix}/${item.name}` : item.name;
|
|
203
207
|
|
|
204
208
|
if (item.type === 'file') {
|
|
205
|
-
|
|
209
|
+
// 파일
|
|
210
|
+
results.push({ path: itemPath, name: item.name, type: 'file' });
|
|
211
|
+
} else if (item.type === 'folder') {
|
|
212
|
+
// 폴더도 결과에 추가 (빈 폴더 동기화를 위해)
|
|
213
|
+
if (includeEmpty) {
|
|
214
|
+
results.push({ path: itemPath, name: item.name, type: 'folder' });
|
|
215
|
+
}
|
|
216
|
+
// 자식 탐색
|
|
217
|
+
if (item.children && item.children.length > 0) {
|
|
218
|
+
results.push(...flattenTree(item.children, itemPath, includeEmpty));
|
|
219
|
+
}
|
|
206
220
|
} else if (item.children) {
|
|
207
|
-
|
|
221
|
+
// 구버전 호환: type 없이 children만 있는 경우
|
|
222
|
+
results.push(...flattenTree(item.children, itemPath, includeEmpty));
|
|
208
223
|
}
|
|
209
224
|
}
|
|
210
225
|
|