clawmate 1.1.0 → 1.3.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.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * 스마트 파일 조작 시스템
3
+ *
4
+ * 텔레그램 또는 AI 명령으로 파일을 "펫이 직접 나르는" 방식으로 이동.
5
+ * 펫이 파일 위치로 점프 → 집어들기 → 대상 폴더로 이동 → 내려놓기 순서로
6
+ * 애니메이션과 실제 파일시스템 이동을 동시에 수행.
7
+ *
8
+ * 안전장치:
9
+ * - .exe/.dll/.sys 등 위험 확장자 제외
10
+ * - 100MB 이상 파일 제외
11
+ * - 모든 이동을 manifest에 기록 (undo 가능)
12
+ * - 진행 중 중단 시 이미 이동된 파일은 manifest에 기록되어 복원 가능
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { getDesktopPath } = require('./desktop-path');
18
+ const manifest = require('./manifest');
19
+ const { AUTO_CATEGORIES } = require('./file-command-parser');
20
+
21
+ // 안전장치 상수
22
+ const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
23
+ const EXCLUDED_EXTS = new Set([
24
+ '.exe', '.dll', '.sys', '.lnk', '.ini', '.bat', '.cmd',
25
+ '.ps1', '.msi', '.scr', '.com', '.pif', '.vbs', '.wsf',
26
+ ]);
27
+
28
+ // 파일 이동 간 딜레이 (ms) - 펫 애니메이션에 시간을 줌
29
+ const PER_FILE_DELAY = 2500;
30
+
31
+ /**
32
+ * 파일이 이동 가능한지 검증
33
+ * @param {string} filePath - 파일 전체 경로
34
+ * @returns {{ safe: boolean, reason?: string }}
35
+ */
36
+ function validateFile(filePath) {
37
+ const ext = path.extname(filePath).toLowerCase();
38
+ if (EXCLUDED_EXTS.has(ext)) {
39
+ return { safe: false, reason: `보호된 파일 유형 (${ext})` };
40
+ }
41
+
42
+ try {
43
+ const stat = fs.statSync(filePath);
44
+ if (stat.size > MAX_FILE_SIZE) {
45
+ return { safe: false, reason: `파일 크기 초과 (${Math.round(stat.size / 1024 / 1024)}MB > 100MB)` };
46
+ }
47
+ if (!stat.isFile()) {
48
+ return { safe: false, reason: '파일이 아님' };
49
+ }
50
+ } catch {
51
+ return { safe: false, reason: '파일 접근 불가' };
52
+ }
53
+
54
+ return { safe: true };
55
+ }
56
+
57
+ /**
58
+ * 소스 디렉토리에서 필터 조건에 맞는 파일 목록 조회
59
+ * @param {string} sourceDir - 소스 디렉토리 경로
60
+ * @param {string} filter - 확장자 필터 (예: ".md", "*")
61
+ * @returns {Array<{ name: string, path: string, ext: string, size: number }>}
62
+ */
63
+ function listFilteredFiles(sourceDir, filter) {
64
+ try {
65
+ const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
66
+ const files = [];
67
+
68
+ for (const entry of entries) {
69
+ if (!entry.isFile()) continue;
70
+ if (entry.name.startsWith('.')) continue;
71
+
72
+ const filePath = path.join(sourceDir, entry.name);
73
+ const ext = path.extname(entry.name).toLowerCase();
74
+
75
+ // 확장자 필터 적용
76
+ if (filter !== '*' && ext !== filter.toLowerCase()) continue;
77
+
78
+ // 안전 검증
79
+ const validation = validateFile(filePath);
80
+ if (!validation.safe) continue;
81
+
82
+ try {
83
+ const stat = fs.statSync(filePath);
84
+ files.push({
85
+ name: entry.name,
86
+ path: filePath,
87
+ ext,
88
+ size: stat.size,
89
+ });
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ return files;
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 자동 분류 모드: 파일을 확장자별 폴더로 분류
103
+ * @param {string} sourceDir - 소스 디렉토리
104
+ * @returns {Map<string, Array>} 카테고리명 → 파일 목록
105
+ */
106
+ function categorizeFiles(sourceDir) {
107
+ const files = listFilteredFiles(sourceDir, '*');
108
+ const categories = new Map();
109
+
110
+ for (const file of files) {
111
+ const category = AUTO_CATEGORIES[file.ext] || '기타';
112
+ if (!categories.has(category)) {
113
+ categories.set(category, []);
114
+ }
115
+ categories.get(category).push(file);
116
+ }
117
+
118
+ return categories;
119
+ }
120
+
121
+ /**
122
+ * 대상 폴더 생성 (없으면)
123
+ * @param {string} sourceDir - 소스 디렉토리 (대상 폴더의 부모)
124
+ * @param {string} targetName - 대상 폴더 이름
125
+ * @returns {string} 대상 폴더 전체 경로
126
+ */
127
+ function ensureTargetDir(sourceDir, targetName) {
128
+ const targetDir = path.join(sourceDir, targetName);
129
+ if (!fs.existsSync(targetDir)) {
130
+ fs.mkdirSync(targetDir, { recursive: true });
131
+ }
132
+ return targetDir;
133
+ }
134
+
135
+ /**
136
+ * 단일 파일 이동 실행 + manifest 기록
137
+ * @param {string} filePath - 원본 파일 경로
138
+ * @param {string} targetDir - 대상 디렉토리
139
+ * @returns {{ success: boolean, newPath?: string, error?: string, moveId?: string }}
140
+ */
141
+ function moveFileToTarget(filePath, targetDir) {
142
+ const fileName = path.basename(filePath);
143
+ let newPath = path.join(targetDir, fileName);
144
+
145
+ // 동일 이름 파일이 있으면 넘버링
146
+ if (fs.existsSync(newPath)) {
147
+ const ext = path.extname(fileName);
148
+ const base = path.basename(fileName, ext);
149
+ let counter = 1;
150
+ while (fs.existsSync(newPath)) {
151
+ newPath = path.join(targetDir, `${base} (${counter})${ext}`);
152
+ counter++;
153
+ }
154
+ }
155
+
156
+ try {
157
+ fs.renameSync(filePath, newPath);
158
+
159
+ // manifest에 기록 (undo 지원)
160
+ const entry = manifest.addEntry({
161
+ fileName,
162
+ originalPath: filePath,
163
+ newPath,
164
+ targetDir,
165
+ action: 'smart_move',
166
+ });
167
+
168
+ return { success: true, newPath, moveId: entry.id };
169
+ } catch (err) {
170
+ return { success: false, error: err.message };
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 스마트 파일 이동 되돌리기 (단일)
176
+ * @param {string} moveId - manifest 엔트리 ID
177
+ * @returns {{ success: boolean, error?: string }}
178
+ */
179
+ function undoSmartMove(moveId) {
180
+ const entries = manifest.getAll();
181
+ const entry = entries.find(e => e.id === moveId && e.action === 'smart_move');
182
+ if (!entry) {
183
+ return { success: false, error: '이동 기록을 찾을 수 없음' };
184
+ }
185
+ if (entry.restored) {
186
+ return { success: false, error: '이미 복원된 항목' };
187
+ }
188
+
189
+ try {
190
+ // 새 위치에서 원래 위치로 되돌리기
191
+ if (fs.existsSync(entry.newPath)) {
192
+ // 원래 위치에 같은 이름 파일이 있으면 충돌 방지
193
+ if (fs.existsSync(entry.originalPath)) {
194
+ return { success: false, error: '원래 위치에 동일 이름 파일이 존재' };
195
+ }
196
+ fs.renameSync(entry.newPath, entry.originalPath);
197
+ manifest.markRestored(moveId);
198
+ return { success: true };
199
+ }
200
+ return { success: false, error: '이동된 파일을 찾을 수 없음' };
201
+ } catch (err) {
202
+ return { success: false, error: err.message };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * 스마트 파일 이동 전체 되돌리기
208
+ * @returns {{ success: boolean, restoredCount: number, errors: string[] }}
209
+ */
210
+ function undoAllSmartMoves() {
211
+ const entries = manifest.getAll();
212
+ const smartMoves = entries.filter(e => e.action === 'smart_move' && !e.restored);
213
+ let restoredCount = 0;
214
+ const errors = [];
215
+
216
+ // 최신 이동부터 역순으로 복원
217
+ for (const entry of smartMoves.reverse()) {
218
+ const result = undoSmartMove(entry.id);
219
+ if (result.success) {
220
+ restoredCount++;
221
+ } else {
222
+ errors.push(`${entry.fileName}: ${result.error}`);
223
+ }
224
+ }
225
+
226
+ return { success: true, restoredCount, errors };
227
+ }
228
+
229
+ /**
230
+ * 스마트 파일 조작 실행 (전체 흐름)
231
+ *
232
+ * 콜백 함수를 통해 펫 애니메이션을 제어하면서 파일을 순차적으로 이동.
233
+ *
234
+ * @param {object} command - 파싱된 파일 명령
235
+ * - source: 소스 디렉토리 경로
236
+ * - filter: 확장자 필터 (예: ".md", "*")
237
+ * - target: 대상 폴더 이름 또는 "auto"
238
+ * - autoCategory: 자동 분류 여부
239
+ * @param {object} callbacks - 펫 애니메이션 콜백
240
+ * - onStart(totalFiles): 작업 시작 시
241
+ * - onPickUp(fileName, index): 파일 집어들 때
242
+ * - onDrop(fileName, targetName, index): 파일 내려놓을 때
243
+ * - onComplete(result): 작업 완료 시
244
+ * - onError(error): 오류 발생 시
245
+ * @returns {Promise<{ success: boolean, movedCount: number, errors: string[], moveIds: string[] }>}
246
+ */
247
+ async function executeSmartFileOp(command, callbacks = {}) {
248
+ const { source, filter, target, autoCategory } = command;
249
+
250
+ try {
251
+ // 자동 분류 모드
252
+ if (autoCategory) {
253
+ return await _executeAutoCategory(source, callbacks);
254
+ }
255
+
256
+ // 특정 대상 폴더로 이동
257
+ return await _executeTargetMove(source, filter, target, callbacks);
258
+ } catch (err) {
259
+ if (callbacks.onError) callbacks.onError(err.message);
260
+ return { success: false, movedCount: 0, errors: [err.message], moveIds: [] };
261
+ }
262
+ }
263
+
264
+ /**
265
+ * 자동 분류 실행
266
+ */
267
+ async function _executeAutoCategory(sourceDir, callbacks) {
268
+ const categories = categorizeFiles(sourceDir);
269
+ let totalFiles = 0;
270
+ for (const files of categories.values()) {
271
+ totalFiles += files.length;
272
+ }
273
+
274
+ if (totalFiles === 0) {
275
+ if (callbacks.onComplete) {
276
+ callbacks.onComplete({ success: true, movedCount: 0, errors: [], moveIds: [] });
277
+ }
278
+ return { success: true, movedCount: 0, errors: [], moveIds: [] };
279
+ }
280
+
281
+ if (callbacks.onStart) callbacks.onStart(totalFiles);
282
+
283
+ let movedCount = 0;
284
+ const errors = [];
285
+ const moveIds = [];
286
+ let fileIndex = 0;
287
+
288
+ for (const [category, files] of categories) {
289
+ // "기타" 카테고리에 파일이 적으면 건너뜀
290
+ if (category === '기타' && files.length <= 2) continue;
291
+
292
+ const targetDir = ensureTargetDir(sourceDir, category);
293
+
294
+ for (const file of files) {
295
+ if (callbacks.onPickUp) callbacks.onPickUp(file.name, fileIndex);
296
+ await _sleep(PER_FILE_DELAY / 2);
297
+
298
+ const result = moveFileToTarget(file.path, targetDir);
299
+ if (result.success) {
300
+ movedCount++;
301
+ moveIds.push(result.moveId);
302
+ if (callbacks.onDrop) callbacks.onDrop(file.name, category, fileIndex);
303
+ } else {
304
+ errors.push(`${file.name}: ${result.error}`);
305
+ }
306
+
307
+ fileIndex++;
308
+ await _sleep(PER_FILE_DELAY / 2);
309
+ }
310
+ }
311
+
312
+ const finalResult = { success: true, movedCount, errors, moveIds };
313
+ if (callbacks.onComplete) callbacks.onComplete(finalResult);
314
+ return finalResult;
315
+ }
316
+
317
+ /**
318
+ * 특정 대상 폴더로 이동 실행
319
+ */
320
+ async function _executeTargetMove(sourceDir, filter, targetName, callbacks) {
321
+ const files = listFilteredFiles(sourceDir, filter);
322
+
323
+ if (files.length === 0) {
324
+ if (callbacks.onComplete) {
325
+ callbacks.onComplete({ success: true, movedCount: 0, errors: [], moveIds: [] });
326
+ }
327
+ return { success: true, movedCount: 0, errors: [], moveIds: [] };
328
+ }
329
+
330
+ if (callbacks.onStart) callbacks.onStart(files.length);
331
+
332
+ const targetDir = ensureTargetDir(sourceDir, targetName);
333
+ let movedCount = 0;
334
+ const errors = [];
335
+ const moveIds = [];
336
+
337
+ for (let i = 0; i < files.length; i++) {
338
+ const file = files[i];
339
+
340
+ if (callbacks.onPickUp) callbacks.onPickUp(file.name, i);
341
+ await _sleep(PER_FILE_DELAY / 2);
342
+
343
+ const result = moveFileToTarget(file.path, targetDir);
344
+ if (result.success) {
345
+ movedCount++;
346
+ moveIds.push(result.moveId);
347
+ if (callbacks.onDrop) callbacks.onDrop(file.name, targetName, i);
348
+ } else {
349
+ errors.push(`${file.name}: ${result.error}`);
350
+ }
351
+
352
+ await _sleep(PER_FILE_DELAY / 2);
353
+ }
354
+
355
+ const finalResult = { success: true, movedCount, errors, moveIds };
356
+ if (callbacks.onComplete) callbacks.onComplete(finalResult);
357
+ return finalResult;
358
+ }
359
+
360
+ function _sleep(ms) {
361
+ return new Promise(resolve => setTimeout(resolve, ms));
362
+ }
363
+
364
+ module.exports = {
365
+ executeSmartFileOp,
366
+ listFilteredFiles,
367
+ categorizeFiles,
368
+ validateFile,
369
+ moveFileToTarget,
370
+ undoSmartMove,
371
+ undoAllSmartMoves,
372
+ ensureTargetDir,
373
+ };