tidyf 1.0.3 → 1.1.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/dist/cli.js +18000 -15880
- package/dist/index.js +18450 -16419
- package/package.json +1 -1
- package/src/cli.ts +42 -14
- package/src/commands/config.ts +70 -1
- package/src/commands/organize.ts +679 -44
- package/src/commands/profile.ts +943 -0
- package/src/commands/undo.ts +139 -0
- package/src/commands/watch.ts +24 -2
- package/src/lib/config.ts +69 -0
- package/src/lib/history.ts +139 -0
- package/src/lib/opencode.ts +11 -5
- package/src/lib/presets.ts +257 -0
- package/src/lib/profiles.ts +367 -0
- package/src/types/organizer.ts +24 -0
- package/src/types/profile.ts +70 -0
- package/src/utils/files.ts +15 -1
package/src/commands/organize.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import * as p from "@clack/prompts";
|
|
13
|
+
import { existsSync } from "fs";
|
|
13
14
|
import { isAbsolute, resolve } from "path";
|
|
14
15
|
import color from "picocolors";
|
|
15
16
|
import {
|
|
@@ -17,16 +18,26 @@ import {
|
|
|
17
18
|
initGlobalConfig,
|
|
18
19
|
parseModelString,
|
|
19
20
|
resolveConfig,
|
|
21
|
+
resolveConfigWithProfile,
|
|
22
|
+
getRulesPromptWithProfile,
|
|
20
23
|
} from "../lib/config.ts";
|
|
21
24
|
import { analyzeFiles, cleanup } from "../lib/opencode.ts";
|
|
25
|
+
import { listProfiles, profileExists } from "../lib/profiles.ts";
|
|
26
|
+
import {
|
|
27
|
+
addMoveToHistory,
|
|
28
|
+
createHistoryEntry,
|
|
29
|
+
saveHistoryEntry,
|
|
30
|
+
} from "../lib/history.ts";
|
|
22
31
|
import { scanDirectory, scanFolderStructure } from "../lib/scanner.ts";
|
|
23
32
|
import type {
|
|
33
|
+
FileMetadata,
|
|
24
34
|
FileMoveProposal,
|
|
25
35
|
MoveResult,
|
|
26
36
|
OrganizationProposal,
|
|
27
37
|
OrganizeOptions,
|
|
38
|
+
DuplicateGroup,
|
|
28
39
|
} from "../types/organizer.ts";
|
|
29
|
-
import { formatFileSize, moveFile } from "../utils/files.ts";
|
|
40
|
+
import { formatFileSize, generateUniqueName, getFileStats, moveFile, computeFileHash } from "../utils/files.ts";
|
|
30
41
|
import {
|
|
31
42
|
getCategoryIcon,
|
|
32
43
|
getFileIcon,
|
|
@@ -60,10 +71,63 @@ function displayProposal(proposal: FileMoveProposal, index: number): void {
|
|
|
60
71
|
);
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Display file tree for proposals
|
|
76
|
+
*/
|
|
77
|
+
function displayFileTree(proposals: FileMoveProposal[]): void {
|
|
78
|
+
const tree = new Map<string, FileMoveProposal[]>();
|
|
79
|
+
|
|
80
|
+
for (const prop of proposals) {
|
|
81
|
+
const parts = prop.destination.split(/[/\\]/);
|
|
82
|
+
let currentPath = "";
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < parts.length; i++) {
|
|
85
|
+
const part = parts[i];
|
|
86
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
87
|
+
|
|
88
|
+
if (i === parts.length - 1) {
|
|
89
|
+
if (!tree.has(currentPath)) {
|
|
90
|
+
tree.set(currentPath, []);
|
|
91
|
+
}
|
|
92
|
+
tree.get(currentPath)!.push(prop);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sortedPaths = Array.from(tree.keys()).sort();
|
|
98
|
+
const indent = " ";
|
|
99
|
+
|
|
100
|
+
p.log.info(color.bold("Folder structure:"));
|
|
101
|
+
console.log();
|
|
102
|
+
|
|
103
|
+
for (const path of sortedPaths) {
|
|
104
|
+
const props = tree.get(path)!;
|
|
105
|
+
const depth = path.split(/[/\\]/).length - 1;
|
|
106
|
+
|
|
107
|
+
if (depth === 1) {
|
|
108
|
+
console.log(`${color.cyan("├─")} ${color.bold(path)}`);
|
|
109
|
+
} else {
|
|
110
|
+
const parentPath = path.split(/[/\\]/).slice(0, -1).join("/");
|
|
111
|
+
const parentProps = tree.get(parentPath);
|
|
112
|
+
if (!parentProps) {
|
|
113
|
+
console.log(`${"│ ".repeat(depth - 1)}├─ ${color.bold(path)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const prop of props) {
|
|
118
|
+
const icon = getFileIcon(prop.file.name);
|
|
119
|
+
const size = color.dim(`(${formatFileSize(prop.file.size)})`);
|
|
120
|
+
console.log(` ${"│ ".repeat(depth)} ${icon} ${prop.file.name} ${size}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log();
|
|
125
|
+
}
|
|
126
|
+
|
|
63
127
|
/**
|
|
64
128
|
* Display all proposals grouped by category
|
|
65
129
|
*/
|
|
66
|
-
function displayAllProposals(proposal: OrganizationProposal): void {
|
|
130
|
+
function displayAllProposals(proposal: OrganizationProposal, useTreeView: boolean = false): void {
|
|
67
131
|
p.log.info(
|
|
68
132
|
color.bold(
|
|
69
133
|
`\nProposed organization for ${proposal.proposals.length} files:\n`,
|
|
@@ -74,6 +138,11 @@ function displayAllProposals(proposal: OrganizationProposal): void {
|
|
|
74
138
|
p.log.message(color.dim(`Strategy: ${proposal.strategy}\n`));
|
|
75
139
|
}
|
|
76
140
|
|
|
141
|
+
// Show tree view for large file sets
|
|
142
|
+
if (useTreeView) {
|
|
143
|
+
displayFileTree(proposal.proposals);
|
|
144
|
+
}
|
|
145
|
+
|
|
77
146
|
// Group by category
|
|
78
147
|
const byCategory = new Map<string, FileMoveProposal[]>();
|
|
79
148
|
for (const prop of proposal.proposals) {
|
|
@@ -84,15 +153,17 @@ function displayAllProposals(proposal: OrganizationProposal): void {
|
|
|
84
153
|
byCategory.get(cat)!.push(prop);
|
|
85
154
|
}
|
|
86
155
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
156
|
+
if (!useTreeView) {
|
|
157
|
+
let index = 0;
|
|
158
|
+
for (const [category, props] of byCategory) {
|
|
159
|
+
p.log.info(
|
|
160
|
+
`${getCategoryIcon(category)} ${color.bold(category)} (${props.length} files)`,
|
|
161
|
+
);
|
|
162
|
+
for (const prop of props) {
|
|
163
|
+
displayProposal(prop, index++);
|
|
164
|
+
}
|
|
165
|
+
console.log();
|
|
94
166
|
}
|
|
95
|
-
console.log();
|
|
96
167
|
}
|
|
97
168
|
|
|
98
169
|
// Show uncategorized files if any
|
|
@@ -118,13 +189,76 @@ function displayAllProposals(proposal: OrganizationProposal): void {
|
|
|
118
189
|
}
|
|
119
190
|
}
|
|
120
191
|
|
|
192
|
+
/**
|
|
193
|
+
* Display conflict details for a proposal
|
|
194
|
+
*/
|
|
195
|
+
async function displayConflictDetails(proposal: FileMoveProposal): Promise<void> {
|
|
196
|
+
const sourceStats = await getFileStats(proposal.sourcePath);
|
|
197
|
+
const destStats = await getFileStats(proposal.destination);
|
|
198
|
+
|
|
199
|
+
console.log();
|
|
200
|
+
p.log.info(color.bold(`Conflict: ${proposal.file.name}`));
|
|
201
|
+
console.log();
|
|
202
|
+
|
|
203
|
+
p.log.message(color.bold("Source file (to be moved):"));
|
|
204
|
+
p.log.message(` Path: ${proposal.sourcePath}`);
|
|
205
|
+
p.log.message(` Size: ${sourceStats ? color.green(formatFileSize(sourceStats.size)) : color.red("Unknown")}`);
|
|
206
|
+
p.log.message(` Modified: ${sourceStats ? color.cyan(sourceStats.mtime.toLocaleString()) : color.red("Unknown")}`);
|
|
207
|
+
|
|
208
|
+
console.log();
|
|
209
|
+
p.log.message(color.bold("Destination file (existing):"));
|
|
210
|
+
p.log.message(` Path: ${proposal.destination}`);
|
|
211
|
+
p.log.message(` Size: ${destStats ? color.yellow(formatFileSize(destStats.size)) : color.red("Unknown")}`);
|
|
212
|
+
p.log.message(` Modified: ${destStats ? color.cyan(destStats.mtime.toLocaleString()) : color.red("Unknown")}`);
|
|
213
|
+
|
|
214
|
+
console.log();
|
|
215
|
+
if (sourceStats && destStats) {
|
|
216
|
+
const sizeDiff = sourceStats.size - destStats.size;
|
|
217
|
+
const timeDiff = sourceStats.mtime.getTime() - destStats.mtime.getTime();
|
|
218
|
+
|
|
219
|
+
p.log.info(color.bold("Comparison:"));
|
|
220
|
+
p.log.message(` Size difference: ${sizeDiff > 0 ? color.green("+" + formatFileSize(Math.abs(sizeDiff))) : sizeDiff < 0 ? color.red("-" + formatFileSize(Math.abs(sizeDiff))) : color.dim("Same size")}`);
|
|
221
|
+
p.log.message(` Age difference: ${timeDiff > 0 ? color.green("Source is newer") : timeDiff < 0 ? color.yellow("Destination is newer") : color.dim("Same age")}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Select specific files to move using multiselect
|
|
229
|
+
*/
|
|
230
|
+
async function selectFilesToMove(
|
|
231
|
+
proposals: FileMoveProposal[],
|
|
232
|
+
): Promise<number[]> {
|
|
233
|
+
const options = proposals.map((p, i) => ({
|
|
234
|
+
value: i,
|
|
235
|
+
label: p.file.name,
|
|
236
|
+
hint: `${p.category.name}${p.category.subcategory ? "/" + p.category.subcategory : ""} ${formatFileSize(p.file.size)}`,
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const selected = await p.multiselect({
|
|
240
|
+
message: "Select files to move (press Space to select/deselect, Enter to confirm):",
|
|
241
|
+
options,
|
|
242
|
+
required: false,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (p.isCancel(selected)) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return selected as number[];
|
|
250
|
+
}
|
|
251
|
+
|
|
121
252
|
/**
|
|
122
253
|
* Execute all proposals
|
|
123
254
|
*/
|
|
124
255
|
async function executeProposals(
|
|
125
256
|
proposals: FileMoveProposal[],
|
|
257
|
+
sourcePath: string,
|
|
258
|
+
targetPath: string,
|
|
126
259
|
): Promise<MoveResult[]> {
|
|
127
260
|
const results: MoveResult[] = [];
|
|
261
|
+
const historyEntry = createHistoryEntry(sourcePath, targetPath);
|
|
128
262
|
const s = p.spinner();
|
|
129
263
|
|
|
130
264
|
for (let i = 0; i < proposals.length; i++) {
|
|
@@ -139,6 +273,7 @@ async function executeProposals(
|
|
|
139
273
|
results.push(result);
|
|
140
274
|
|
|
141
275
|
if (result.status === "completed") {
|
|
276
|
+
addMoveToHistory(historyEntry, prop.sourcePath, prop.destination);
|
|
142
277
|
s.stop(
|
|
143
278
|
`${color.green("✓")} ${i + 1}/${proposals.length}: ${prop.file.name}`,
|
|
144
279
|
);
|
|
@@ -153,8 +288,13 @@ async function executeProposals(
|
|
|
153
288
|
}
|
|
154
289
|
}
|
|
155
290
|
|
|
156
|
-
//
|
|
291
|
+
// Save history if any moves were successful
|
|
157
292
|
const completed = results.filter((r) => r.status === "completed").length;
|
|
293
|
+
if (completed > 0) {
|
|
294
|
+
saveHistoryEntry(historyEntry);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Summary
|
|
158
298
|
const failed = results.filter((r) => r.status === "failed").length;
|
|
159
299
|
const skipped = results.filter((r) => r.status === "skipped").length;
|
|
160
300
|
|
|
@@ -167,6 +307,38 @@ async function executeProposals(
|
|
|
167
307
|
return results;
|
|
168
308
|
}
|
|
169
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Execute all proposals quietly (for JSON mode)
|
|
312
|
+
*/
|
|
313
|
+
async function executeProposalsQuiet(
|
|
314
|
+
proposals: FileMoveProposal[],
|
|
315
|
+
sourcePath: string,
|
|
316
|
+
targetPath: string,
|
|
317
|
+
): Promise<MoveResult[]> {
|
|
318
|
+
const results: MoveResult[] = [];
|
|
319
|
+
const historyEntry = createHistoryEntry(sourcePath, targetPath);
|
|
320
|
+
|
|
321
|
+
for (const prop of proposals) {
|
|
322
|
+
const result = await moveFile(prop.sourcePath, prop.destination, {
|
|
323
|
+
overwrite: false,
|
|
324
|
+
backup: false,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
results.push(result);
|
|
328
|
+
|
|
329
|
+
if (result.status === "completed") {
|
|
330
|
+
addMoveToHistory(historyEntry, prop.sourcePath, prop.destination);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const completed = results.filter((r) => r.status === "completed").length;
|
|
335
|
+
if (completed > 0) {
|
|
336
|
+
saveHistoryEntry(historyEntry);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return results;
|
|
340
|
+
}
|
|
341
|
+
|
|
170
342
|
/**
|
|
171
343
|
* View details of a specific proposal
|
|
172
344
|
*/
|
|
@@ -221,17 +393,143 @@ function resolvePath(inputPath: string): string {
|
|
|
221
393
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
222
394
|
}
|
|
223
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Convert proposal to JSON-serializable format
|
|
398
|
+
*/
|
|
399
|
+
function toJsonOutput(proposal: OrganizationProposal, results?: MoveResult[]): object {
|
|
400
|
+
return {
|
|
401
|
+
proposals: proposal.proposals.map(p => ({
|
|
402
|
+
source: p.sourcePath,
|
|
403
|
+
destination: p.destination,
|
|
404
|
+
file: {
|
|
405
|
+
name: p.file.name,
|
|
406
|
+
extension: p.file.extension,
|
|
407
|
+
size: p.file.size,
|
|
408
|
+
mimeType: p.file.mimeType,
|
|
409
|
+
hash: p.file.hash,
|
|
410
|
+
},
|
|
411
|
+
category: p.category,
|
|
412
|
+
conflictExists: p.conflictExists,
|
|
413
|
+
})),
|
|
414
|
+
strategy: proposal.strategy,
|
|
415
|
+
uncategorized: proposal.uncategorized.map(f => ({
|
|
416
|
+
name: f.name,
|
|
417
|
+
path: f.path,
|
|
418
|
+
size: f.size,
|
|
419
|
+
})),
|
|
420
|
+
duplicates: proposal.duplicates?.map(d => ({
|
|
421
|
+
hash: d.hash,
|
|
422
|
+
files: d.files.map(f => ({ name: f.name, path: f.path, size: f.size })),
|
|
423
|
+
wastedBytes: d.wastedBytes,
|
|
424
|
+
})),
|
|
425
|
+
analyzedAt: proposal.analyzedAt.toISOString(),
|
|
426
|
+
results: results?.map(r => ({
|
|
427
|
+
source: r.source,
|
|
428
|
+
destination: r.destination,
|
|
429
|
+
status: r.status,
|
|
430
|
+
error: r.error,
|
|
431
|
+
})),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Detect duplicate files by computing content hashes
|
|
437
|
+
*/
|
|
438
|
+
async function detectDuplicates(files: FileMetadata[]): Promise<DuplicateGroup[]> {
|
|
439
|
+
const hashMap = new Map<string, FileMetadata[]>();
|
|
440
|
+
|
|
441
|
+
for (const file of files) {
|
|
442
|
+
if (file.hash) {
|
|
443
|
+
const existing = hashMap.get(file.hash) || [];
|
|
444
|
+
existing.push(file);
|
|
445
|
+
hashMap.set(file.hash, existing);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const duplicates: DuplicateGroup[] = [];
|
|
450
|
+
for (const [hash, groupFiles] of hashMap) {
|
|
451
|
+
if (groupFiles.length > 1) {
|
|
452
|
+
const totalSize = groupFiles.reduce((sum, f) => sum + f.size, 0);
|
|
453
|
+
const wastedBytes = totalSize - groupFiles[0].size;
|
|
454
|
+
duplicates.push({ hash, files: groupFiles, wastedBytes });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return duplicates.sort((a, b) => b.wastedBytes - a.wastedBytes);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Display duplicate file groups
|
|
463
|
+
*/
|
|
464
|
+
function displayDuplicates(duplicates: DuplicateGroup[]): void {
|
|
465
|
+
if (duplicates.length === 0) return;
|
|
466
|
+
|
|
467
|
+
const totalWasted = duplicates.reduce((sum, d) => sum + d.wastedBytes, 0);
|
|
468
|
+
|
|
469
|
+
p.log.warn(color.yellow(`\n⚠ Found ${duplicates.length} duplicate groups (${formatFileSize(totalWasted)} wasted)`));
|
|
470
|
+
|
|
471
|
+
for (const group of duplicates.slice(0, 5)) {
|
|
472
|
+
p.log.message(`\n ${color.dim(group.hash.slice(0, 8))} - ${group.files.length} copies (${formatFileSize(group.wastedBytes)} wasted)`);
|
|
473
|
+
for (const file of group.files) {
|
|
474
|
+
p.log.message(` ${getFileIcon(file.name)} ${file.name} ${color.dim(`(${formatFileSize(file.size)})`)}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (duplicates.length > 5) {
|
|
479
|
+
p.log.message(color.dim(`\n ... and ${duplicates.length - 5} more duplicate groups`));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
224
483
|
/**
|
|
225
484
|
* Main organize command
|
|
226
485
|
*/
|
|
227
486
|
export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
228
|
-
|
|
487
|
+
const isJsonMode = options.json === true;
|
|
488
|
+
|
|
489
|
+
if (!isJsonMode) {
|
|
490
|
+
p.intro(color.bgGreen(color.black(" tidyf ")));
|
|
491
|
+
}
|
|
229
492
|
|
|
230
493
|
// Initialize global config if needed
|
|
231
494
|
initGlobalConfig();
|
|
232
495
|
|
|
233
|
-
//
|
|
234
|
-
|
|
496
|
+
// Validate and resolve profile if specified
|
|
497
|
+
if (options.profile) {
|
|
498
|
+
if (!profileExists(options.profile)) {
|
|
499
|
+
if (isJsonMode) {
|
|
500
|
+
console.log(JSON.stringify({ error: `Profile "${options.profile}" not found` }));
|
|
501
|
+
process.exit(1);
|
|
502
|
+
}
|
|
503
|
+
p.log.error(`Profile "${options.profile}" not found`);
|
|
504
|
+
const profiles = listProfiles();
|
|
505
|
+
if (profiles.length > 0) {
|
|
506
|
+
p.log.info("Available profiles:");
|
|
507
|
+
profiles.forEach((pr) => p.log.message(` - ${pr.name}`));
|
|
508
|
+
}
|
|
509
|
+
const create = await p.confirm({
|
|
510
|
+
message: `Create profile "${options.profile}"?`,
|
|
511
|
+
initialValue: false,
|
|
512
|
+
});
|
|
513
|
+
if (p.isCancel(create) || !create) {
|
|
514
|
+
p.outro("Canceled");
|
|
515
|
+
cleanup();
|
|
516
|
+
process.exit(0);
|
|
517
|
+
}
|
|
518
|
+
// Redirect to profile creation
|
|
519
|
+
const { profileCommand } = await import("./profile.ts");
|
|
520
|
+
await profileCommand({ action: "create", name: options.profile });
|
|
521
|
+
// Re-run organize with the now-existing profile
|
|
522
|
+
p.log.info("Profile created. Continuing with organization...");
|
|
523
|
+
}
|
|
524
|
+
if (!isJsonMode) {
|
|
525
|
+
p.log.info(`Profile: ${color.cyan(options.profile)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Resolve configuration (with profile if specified)
|
|
530
|
+
const config = options.profile
|
|
531
|
+
? resolveConfigWithProfile(options.profile)
|
|
532
|
+
: resolveConfig();
|
|
235
533
|
|
|
236
534
|
// Determine source directory
|
|
237
535
|
const sourcePath = resolvePath(
|
|
@@ -249,14 +547,91 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
249
547
|
"~/Documents/Organized",
|
|
250
548
|
);
|
|
251
549
|
|
|
252
|
-
|
|
253
|
-
|
|
550
|
+
if (!isJsonMode) {
|
|
551
|
+
p.log.info(`Source: ${color.cyan(sourcePath)}`);
|
|
552
|
+
p.log.info(`Target: ${color.cyan(targetPath)}`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Check if source directory exists
|
|
556
|
+
if (!existsSync(sourcePath)) {
|
|
557
|
+
if (isJsonMode) {
|
|
558
|
+
console.log(JSON.stringify({ error: `Directory does not exist: ${sourcePath}` }));
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
console.log();
|
|
562
|
+
p.log.error(color.red("Directory does not exist"));
|
|
563
|
+
|
|
564
|
+
console.log();
|
|
565
|
+
p.log.message(`${color.cyan("Missing directory:")} ${sourcePath}`);
|
|
566
|
+
|
|
567
|
+
console.log();
|
|
568
|
+
|
|
569
|
+
const action = await p.select({
|
|
570
|
+
message: "What would you like to do?",
|
|
571
|
+
options: [
|
|
572
|
+
{
|
|
573
|
+
value: "create",
|
|
574
|
+
label: "Create this directory",
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
value: "change_path",
|
|
578
|
+
label: "Choose a different directory",
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
value: "exit",
|
|
582
|
+
label: "Exit",
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
if (p.isCancel(action) || action === "exit") {
|
|
588
|
+
p.outro("Canceled");
|
|
589
|
+
cleanup();
|
|
590
|
+
process.exit(0);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (action === "create") {
|
|
594
|
+
const { mkdir } = await import("fs/promises");
|
|
595
|
+
try {
|
|
596
|
+
await mkdir(sourcePath, { recursive: true });
|
|
597
|
+
p.log.success(`Created directory: ${color.cyan(sourcePath)}`);
|
|
598
|
+
} catch (error: any) {
|
|
599
|
+
p.log.error(`Failed to create directory: ${error.message}`);
|
|
600
|
+
p.outro("Canceled");
|
|
601
|
+
cleanup();
|
|
602
|
+
process.exit(0);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (action === "change_path") {
|
|
607
|
+
console.log();
|
|
608
|
+
const newPath = await p.text({
|
|
609
|
+
message: "Enter directory path to scan:",
|
|
610
|
+
placeholder: "~/Downloads",
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
if (!p.isCancel(newPath) && newPath.trim()) {
|
|
614
|
+
await organizeCommand({
|
|
615
|
+
...options,
|
|
616
|
+
path: newPath.trim(),
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
} else {
|
|
620
|
+
p.outro("Canceled");
|
|
621
|
+
cleanup();
|
|
622
|
+
process.exit(0);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
254
626
|
|
|
255
627
|
// Scan directory
|
|
256
|
-
|
|
257
|
-
|
|
628
|
+
let spinner: ReturnType<typeof p.spinner> | null = null;
|
|
629
|
+
if (!isJsonMode) {
|
|
630
|
+
spinner = p.spinner();
|
|
631
|
+
spinner.start("Scanning directory...");
|
|
632
|
+
}
|
|
258
633
|
|
|
259
|
-
|
|
634
|
+
let files = await scanDirectory(sourcePath, {
|
|
260
635
|
recursive: options.recursive,
|
|
261
636
|
maxDepth: parseInt(options.depth || "1"),
|
|
262
637
|
ignore: config.ignore,
|
|
@@ -264,17 +639,126 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
264
639
|
maxContentSize: config.maxContentSize,
|
|
265
640
|
});
|
|
266
641
|
|
|
267
|
-
|
|
642
|
+
// Compute file hashes for duplicate detection if requested
|
|
643
|
+
if (options.detectDuplicates) {
|
|
644
|
+
for (const file of files) {
|
|
645
|
+
file.hash = await computeFileHash(file.path) || undefined;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (!isJsonMode && spinner) {
|
|
650
|
+
spinner.stop(`Found ${color.bold(String(files.length))} files`);
|
|
651
|
+
}
|
|
268
652
|
|
|
269
653
|
if (files.length === 0) {
|
|
270
|
-
|
|
271
|
-
|
|
654
|
+
if (isJsonMode) {
|
|
655
|
+
console.log(JSON.stringify({ proposals: [], strategy: "No files found", uncategorized: [], analyzedAt: new Date().toISOString() }));
|
|
656
|
+
cleanup();
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
console.log();
|
|
660
|
+
p.log.warn(color.yellow("No files to organize"));
|
|
661
|
+
|
|
662
|
+
console.log();
|
|
663
|
+
p.log.message(`${color.cyan("Scanned directory:")} ${sourcePath}`);
|
|
664
|
+
|
|
665
|
+
console.log();
|
|
666
|
+
p.log.info("Possible reasons:");
|
|
667
|
+
p.log.message(` • The directory is empty`);
|
|
668
|
+
p.log.message(` • All files are ignored by your ignore patterns`);
|
|
669
|
+
p.log.message(` • You're not scanning recursively and files are in subdirectories`);
|
|
670
|
+
|
|
671
|
+
if (config.ignore && config.ignore.length > 0) {
|
|
672
|
+
console.log();
|
|
673
|
+
p.log.info(`Active ignore patterns: ${color.dim(config.ignore.join(", "))}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
console.log();
|
|
677
|
+
|
|
678
|
+
const action = await p.select({
|
|
679
|
+
message: "What would you like to do?",
|
|
680
|
+
options: [
|
|
681
|
+
{
|
|
682
|
+
value: "scan_recursive",
|
|
683
|
+
label: "Scan recursively",
|
|
684
|
+
hint: !options.recursive ? "Include subdirectories" : "Already scanning recursively",
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
value: "change_path",
|
|
688
|
+
label: "Choose a different directory",
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
value: "edit_config",
|
|
692
|
+
label: "Edit configuration",
|
|
693
|
+
hint: "Modify ignore patterns and settings",
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
value: "exit",
|
|
697
|
+
label: "Exit",
|
|
698
|
+
},
|
|
699
|
+
],
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
if (p.isCancel(action) || action === "exit") {
|
|
703
|
+
p.outro("Nothing to do");
|
|
704
|
+
cleanup();
|
|
705
|
+
process.exit(0);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (action === "scan_recursive") {
|
|
709
|
+
console.log();
|
|
710
|
+
const newPath = await p.text({
|
|
711
|
+
message: "Enter directory path (or press Enter to use current):",
|
|
712
|
+
placeholder: sourcePath,
|
|
713
|
+
defaultValue: sourcePath,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
if (!p.isCancel(newPath) && newPath.trim()) {
|
|
717
|
+
await organizeCommand({
|
|
718
|
+
...options,
|
|
719
|
+
path: newPath.trim(),
|
|
720
|
+
recursive: true,
|
|
721
|
+
});
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (action === "change_path") {
|
|
727
|
+
console.log();
|
|
728
|
+
const newPath = await p.text({
|
|
729
|
+
message: "Enter directory path to scan:",
|
|
730
|
+
placeholder: "~/Downloads",
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
if (!p.isCancel(newPath) && newPath.trim()) {
|
|
734
|
+
await organizeCommand({
|
|
735
|
+
...options,
|
|
736
|
+
path: newPath.trim(),
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (action === "edit_config") {
|
|
743
|
+
console.log();
|
|
744
|
+
p.log.message(`Run ${color.cyan("tidyf config")} to open the configuration editor`);
|
|
745
|
+
console.log();
|
|
746
|
+
p.outro("Exiting...");
|
|
747
|
+
cleanup();
|
|
748
|
+
process.exit(0);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
p.outro("Nothing to do");
|
|
752
|
+
cleanup();
|
|
753
|
+
process.exit(0);
|
|
272
754
|
}
|
|
273
755
|
|
|
274
756
|
// Show file summary
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
757
|
+
if (!isJsonMode) {
|
|
758
|
+
p.log.info(
|
|
759
|
+
`Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
278
762
|
|
|
279
763
|
// Scan existing folder structure in target directory
|
|
280
764
|
let existingFolders: string[] = [];
|
|
@@ -284,7 +768,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
284
768
|
includeEmpty: false,
|
|
285
769
|
ignore: config.ignore,
|
|
286
770
|
});
|
|
287
|
-
if (existingFolders.length > 0) {
|
|
771
|
+
if (!isJsonMode && existingFolders.length > 0) {
|
|
288
772
|
p.log.info(`Found ${color.bold(String(existingFolders.length))} existing folders in target`);
|
|
289
773
|
}
|
|
290
774
|
} catch {
|
|
@@ -292,26 +776,111 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
292
776
|
}
|
|
293
777
|
|
|
294
778
|
// Analyze with AI
|
|
295
|
-
|
|
779
|
+
if (!isJsonMode && spinner) {
|
|
780
|
+
spinner.start("Analyzing files with AI...");
|
|
781
|
+
}
|
|
296
782
|
|
|
297
783
|
let proposal: OrganizationProposal;
|
|
298
784
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
785
|
+
const BATCH_SIZE = 50;
|
|
786
|
+
|
|
787
|
+
if (files.length > BATCH_SIZE) {
|
|
788
|
+
if (!isJsonMode) {
|
|
789
|
+
p.log.info(`Processing ${files.length} files in batches of ${BATCH_SIZE}...`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
let allProposals: FileMoveProposal[] = [];
|
|
793
|
+
let allUncategorized: FileMetadata[] = [];
|
|
794
|
+
let strategies: string[] = [];
|
|
795
|
+
|
|
796
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
797
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
798
|
+
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
799
|
+
const totalBatches = Math.ceil(files.length / BATCH_SIZE);
|
|
800
|
+
|
|
801
|
+
if (!isJsonMode && spinner) {
|
|
802
|
+
spinner.start(`Analyzing batch ${batchNum}/${totalBatches} (${batch.length} files)...`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const batchProposal = await analyzeFiles({
|
|
806
|
+
files: batch,
|
|
807
|
+
targetDir: targetPath,
|
|
808
|
+
model: parseModelString(options.model),
|
|
809
|
+
existingFolders,
|
|
810
|
+
profileName: options.profile,
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
allProposals = allProposals.concat(batchProposal.proposals);
|
|
814
|
+
allUncategorized = allUncategorized.concat(batchProposal.uncategorized);
|
|
815
|
+
if (batchProposal.strategy) {
|
|
816
|
+
strategies.push(batchProposal.strategy);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (!isJsonMode && spinner) {
|
|
821
|
+
spinner.stop("Analysis complete");
|
|
822
|
+
}
|
|
823
|
+
proposal = {
|
|
824
|
+
proposals: allProposals,
|
|
825
|
+
strategy: strategies.join("; "),
|
|
826
|
+
uncategorized: allUncategorized,
|
|
827
|
+
analyzedAt: new Date(),
|
|
828
|
+
};
|
|
829
|
+
} else {
|
|
830
|
+
proposal = await analyzeFiles({
|
|
831
|
+
files,
|
|
832
|
+
targetDir: targetPath,
|
|
833
|
+
model: parseModelString(options.model),
|
|
834
|
+
existingFolders,
|
|
835
|
+
profileName: options.profile,
|
|
836
|
+
});
|
|
837
|
+
if (!isJsonMode && spinner) {
|
|
838
|
+
spinner.stop("Analysis complete");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
306
841
|
} catch (error: any) {
|
|
307
|
-
|
|
842
|
+
if (isJsonMode) {
|
|
843
|
+
console.log(JSON.stringify({ error: error.message }));
|
|
844
|
+
cleanup();
|
|
845
|
+
process.exit(1);
|
|
846
|
+
}
|
|
847
|
+
if (spinner) {
|
|
848
|
+
spinner.stop("Analysis failed");
|
|
849
|
+
}
|
|
308
850
|
p.cancel(error.message);
|
|
309
851
|
cleanup();
|
|
310
852
|
process.exit(1);
|
|
311
853
|
}
|
|
312
854
|
|
|
855
|
+
// Detect duplicates if requested
|
|
856
|
+
if (options.detectDuplicates) {
|
|
857
|
+
const duplicates = await detectDuplicates(files);
|
|
858
|
+
proposal.duplicates = duplicates;
|
|
859
|
+
if (!isJsonMode && duplicates.length > 0) {
|
|
860
|
+
displayDuplicates(duplicates);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// JSON mode: output and exit
|
|
865
|
+
if (isJsonMode) {
|
|
866
|
+
if (options.dryRun || options.yes === false) {
|
|
867
|
+
console.log(JSON.stringify(toJsonOutput(proposal)));
|
|
868
|
+
} else {
|
|
869
|
+
// Execute moves and include results
|
|
870
|
+
const results = await executeProposalsQuiet(proposal.proposals, sourcePath, targetPath);
|
|
871
|
+
console.log(JSON.stringify(toJsonOutput(proposal, results)));
|
|
872
|
+
}
|
|
873
|
+
cleanup();
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
|
|
313
877
|
// Display proposals
|
|
314
|
-
|
|
878
|
+
const useTreeView = proposal.proposals.length >= 20;
|
|
879
|
+
displayAllProposals(proposal, useTreeView);
|
|
880
|
+
|
|
881
|
+
// Check for conflicts
|
|
882
|
+
const conflicts = proposal.proposals.filter((p) => p.conflictExists);
|
|
883
|
+
const hasConflicts = conflicts.length > 0;
|
|
315
884
|
|
|
316
885
|
// Dry run mode
|
|
317
886
|
if (options.dryRun) {
|
|
@@ -332,7 +901,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
332
901
|
while (!done) {
|
|
333
902
|
if (options.yes) {
|
|
334
903
|
// Auto-apply all
|
|
335
|
-
await executeProposals(proposal.proposals);
|
|
904
|
+
await executeProposals(proposal.proposals, sourcePath, targetPath);
|
|
336
905
|
done = true;
|
|
337
906
|
} else {
|
|
338
907
|
const action = await p.select({
|
|
@@ -343,6 +912,20 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
343
912
|
label: `Apply all ${proposal.proposals.length} moves`,
|
|
344
913
|
hint: "Organize files as proposed",
|
|
345
914
|
},
|
|
915
|
+
...(hasConflicts
|
|
916
|
+
? [
|
|
917
|
+
{
|
|
918
|
+
value: "resolve_conflicts" as const,
|
|
919
|
+
label: `Resolve ${conflicts.length} conflicts`,
|
|
920
|
+
hint: "Review and handle conflicting files",
|
|
921
|
+
},
|
|
922
|
+
]
|
|
923
|
+
: []),
|
|
924
|
+
{
|
|
925
|
+
value: "select_individual",
|
|
926
|
+
label: "Select specific files to move",
|
|
927
|
+
hint: "Choose which files to organize",
|
|
928
|
+
},
|
|
346
929
|
{
|
|
347
930
|
value: "view_details",
|
|
348
931
|
label: "View file details",
|
|
@@ -373,10 +956,60 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
373
956
|
|
|
374
957
|
switch (action) {
|
|
375
958
|
case "apply_all":
|
|
376
|
-
await executeProposals(proposal.proposals);
|
|
959
|
+
await executeProposals(proposal.proposals, sourcePath, targetPath);
|
|
377
960
|
done = true;
|
|
378
961
|
break;
|
|
379
962
|
|
|
963
|
+
case "resolve_conflicts": {
|
|
964
|
+
const conflictIndex = await p.select({
|
|
965
|
+
message: "Which conflict to view?",
|
|
966
|
+
options: conflicts.map((p, i) => ({
|
|
967
|
+
value: i,
|
|
968
|
+
label: p.file.name,
|
|
969
|
+
hint: formatFileSize(p.file.size),
|
|
970
|
+
})),
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
if (p.isCancel(conflictIndex)) {
|
|
974
|
+
break;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
await displayConflictDetails(conflicts[conflictIndex as number]);
|
|
978
|
+
|
|
979
|
+
const resolution = await p.select({
|
|
980
|
+
message: "How to resolve this conflict?",
|
|
981
|
+
options: [
|
|
982
|
+
{ value: "rename", label: "Rename (auto-generate new name)", hint: "Keep both files" },
|
|
983
|
+
{ value: "overwrite", label: "Overwrite", hint: "Replace destination file" },
|
|
984
|
+
{ value: "skip", label: "Skip", hint: "Don't move this file" },
|
|
985
|
+
{ value: "cancel", label: "Cancel", hint: "Return to main menu" },
|
|
986
|
+
],
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
if (p.isCancel(resolution) || resolution === "cancel") {
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const conflictProposal = conflicts[conflictIndex as number];
|
|
994
|
+
if (resolution === "overwrite") {
|
|
995
|
+
await executeProposals([{ ...conflictProposal, conflictExists: false }], sourcePath, targetPath);
|
|
996
|
+
} else if (resolution === "rename") {
|
|
997
|
+
const uniqueDest = await generateUniqueName(conflictProposal.destination);
|
|
998
|
+
await executeProposals([{ ...conflictProposal, destination: uniqueDest, conflictExists: false }], sourcePath, targetPath);
|
|
999
|
+
}
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
case "select_individual": {
|
|
1004
|
+
const selectedIndices = await selectFilesToMove(proposal.proposals);
|
|
1005
|
+
if (selectedIndices.length > 0) {
|
|
1006
|
+
const selectedProposals = selectedIndices.map((i: number) => proposal.proposals[i]);
|
|
1007
|
+
await executeProposals(selectedProposals, sourcePath, targetPath);
|
|
1008
|
+
done = true;
|
|
1009
|
+
}
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
380
1013
|
case "view_details":
|
|
381
1014
|
await viewProposalDetails(proposal.proposals);
|
|
382
1015
|
break;
|
|
@@ -392,7 +1025,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
392
1025
|
break;
|
|
393
1026
|
}
|
|
394
1027
|
|
|
395
|
-
spinner
|
|
1028
|
+
spinner!.start("Re-analyzing files with AI...");
|
|
396
1029
|
try {
|
|
397
1030
|
proposal = await analyzeFiles({
|
|
398
1031
|
files,
|
|
@@ -400,11 +1033,12 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
400
1033
|
instructions: newInstructions || undefined,
|
|
401
1034
|
model: parseModelString(options.model),
|
|
402
1035
|
existingFolders,
|
|
1036
|
+
profileName: options.profile,
|
|
403
1037
|
});
|
|
404
|
-
spinner
|
|
1038
|
+
spinner!.stop("Analysis complete");
|
|
405
1039
|
displayAllProposals(proposal);
|
|
406
1040
|
} catch (error: any) {
|
|
407
|
-
spinner
|
|
1041
|
+
spinner!.stop("Analysis failed");
|
|
408
1042
|
p.log.error(error.message);
|
|
409
1043
|
}
|
|
410
1044
|
break;
|
|
@@ -426,7 +1060,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
426
1060
|
break;
|
|
427
1061
|
}
|
|
428
1062
|
|
|
429
|
-
spinner
|
|
1063
|
+
spinner!.start(`Re-analyzing with ${pickedModel.provider}/${pickedModel.model}...`);
|
|
430
1064
|
try {
|
|
431
1065
|
proposal = await analyzeFiles({
|
|
432
1066
|
files,
|
|
@@ -434,11 +1068,12 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
|
|
|
434
1068
|
instructions: newInstructions || undefined,
|
|
435
1069
|
model: pickedModel,
|
|
436
1070
|
existingFolders,
|
|
1071
|
+
profileName: options.profile,
|
|
437
1072
|
});
|
|
438
|
-
spinner
|
|
1073
|
+
spinner!.stop("Analysis complete");
|
|
439
1074
|
displayAllProposals(proposal);
|
|
440
1075
|
} catch (error: any) {
|
|
441
|
-
spinner
|
|
1076
|
+
spinner!.stop("Analysis failed");
|
|
442
1077
|
p.log.error(error.message);
|
|
443
1078
|
}
|
|
444
1079
|
break;
|