tidyf 1.0.2 → 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.
@@ -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";
22
- import { scanDirectory } from "../lib/scanner.ts";
25
+ import { listProfiles, profileExists } from "../lib/profiles.ts";
26
+ import {
27
+ addMoveToHistory,
28
+ createHistoryEntry,
29
+ saveHistoryEntry,
30
+ } from "../lib/history.ts";
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
- let index = 0;
88
- for (const [category, props] of byCategory) {
89
- p.log.info(
90
- `${getCategoryIcon(category)} ${color.bold(category)} (${props.length} files)`,
91
- );
92
- for (const prop of props) {
93
- displayProposal(prop, index++);
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
- // Summary
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
- p.intro(color.bgGreen(color.black(" tidyf ")));
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
- // Resolve configuration
234
- const config = resolveConfig();
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
- p.log.info(`Source: ${color.cyan(sourcePath)}`);
253
- p.log.info(`Target: ${color.cyan(targetPath)}`);
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
- const spinner = p.spinner();
257
- spinner.start("Scanning directory...");
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
- const files = await scanDirectory(sourcePath, {
634
+ let files = await scanDirectory(sourcePath, {
260
635
  recursive: options.recursive,
261
636
  maxDepth: parseInt(options.depth || "1"),
262
637
  ignore: config.ignore,
@@ -264,38 +639,248 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
264
639
  maxContentSize: config.maxContentSize,
265
640
  });
266
641
 
267
- spinner.stop(`Found ${color.bold(String(files.length))} files`);
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
- p.outro(color.yellow("No files to organize"));
271
- return;
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
- p.log.info(
276
- `Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
277
- );
757
+ if (!isJsonMode) {
758
+ p.log.info(
759
+ `Total size: ${formatFileSize(files.reduce((sum, f) => sum + f.size, 0))}`,
760
+ );
761
+ }
762
+
763
+ // Scan existing folder structure in target directory
764
+ let existingFolders: string[] = [];
765
+ try {
766
+ existingFolders = await scanFolderStructure(targetPath, {
767
+ maxDepth: 3,
768
+ includeEmpty: false,
769
+ ignore: config.ignore,
770
+ });
771
+ if (!isJsonMode && existingFolders.length > 0) {
772
+ p.log.info(`Found ${color.bold(String(existingFolders.length))} existing folders in target`);
773
+ }
774
+ } catch {
775
+ // Target directory might not exist yet - OK
776
+ }
278
777
 
279
778
  // Analyze with AI
280
- spinner.start("Analyzing files with AI...");
779
+ if (!isJsonMode && spinner) {
780
+ spinner.start("Analyzing files with AI...");
781
+ }
281
782
 
282
783
  let proposal: OrganizationProposal;
283
784
  try {
284
- proposal = await analyzeFiles({
285
- files,
286
- targetDir: targetPath,
287
- model: parseModelString(options.model),
288
- });
289
- spinner.stop("Analysis complete");
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
+ }
290
841
  } catch (error: any) {
291
- spinner.stop("Analysis failed");
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
+ }
292
850
  p.cancel(error.message);
293
851
  cleanup();
294
852
  process.exit(1);
295
853
  }
296
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
+
297
877
  // Display proposals
298
- displayAllProposals(proposal);
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;
299
884
 
300
885
  // Dry run mode
301
886
  if (options.dryRun) {
@@ -316,7 +901,7 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
316
901
  while (!done) {
317
902
  if (options.yes) {
318
903
  // Auto-apply all
319
- await executeProposals(proposal.proposals);
904
+ await executeProposals(proposal.proposals, sourcePath, targetPath);
320
905
  done = true;
321
906
  } else {
322
907
  const action = await p.select({
@@ -327,6 +912,20 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
327
912
  label: `Apply all ${proposal.proposals.length} moves`,
328
913
  hint: "Organize files as proposed",
329
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
+ },
330
929
  {
331
930
  value: "view_details",
332
931
  label: "View file details",
@@ -357,10 +956,60 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
357
956
 
358
957
  switch (action) {
359
958
  case "apply_all":
360
- await executeProposals(proposal.proposals);
959
+ await executeProposals(proposal.proposals, sourcePath, targetPath);
361
960
  done = true;
362
961
  break;
363
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
+
364
1013
  case "view_details":
365
1014
  await viewProposalDetails(proposal.proposals);
366
1015
  break;
@@ -376,18 +1025,20 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
376
1025
  break;
377
1026
  }
378
1027
 
379
- spinner.start("Re-analyzing files with AI...");
1028
+ spinner!.start("Re-analyzing files with AI...");
380
1029
  try {
381
1030
  proposal = await analyzeFiles({
382
1031
  files,
383
1032
  targetDir: targetPath,
384
1033
  instructions: newInstructions || undefined,
385
1034
  model: parseModelString(options.model),
1035
+ existingFolders,
1036
+ profileName: options.profile,
386
1037
  });
387
- spinner.stop("Analysis complete");
1038
+ spinner!.stop("Analysis complete");
388
1039
  displayAllProposals(proposal);
389
1040
  } catch (error: any) {
390
- spinner.stop("Analysis failed");
1041
+ spinner!.stop("Analysis failed");
391
1042
  p.log.error(error.message);
392
1043
  }
393
1044
  break;
@@ -409,18 +1060,20 @@ export async function organizeCommand(options: OrganizeOptions): Promise<void> {
409
1060
  break;
410
1061
  }
411
1062
 
412
- spinner.start(`Re-analyzing with ${pickedModel.provider}/${pickedModel.model}...`);
1063
+ spinner!.start(`Re-analyzing with ${pickedModel.provider}/${pickedModel.model}...`);
413
1064
  try {
414
1065
  proposal = await analyzeFiles({
415
1066
  files,
416
1067
  targetDir: targetPath,
417
1068
  instructions: newInstructions || undefined,
418
1069
  model: pickedModel,
1070
+ existingFolders,
1071
+ profileName: options.profile,
419
1072
  });
420
- spinner.stop("Analysis complete");
1073
+ spinner!.stop("Analysis complete");
421
1074
  displayAllProposals(proposal);
422
1075
  } catch (error: any) {
423
- spinner.stop("Analysis failed");
1076
+ spinner!.stop("Analysis failed");
424
1077
  p.log.error(error.message);
425
1078
  }
426
1079
  break;