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.
- package/README.md +11 -0
- package/dist/cli.js +20300 -16019
- package/dist/index.js +18473 -16331
- package/package.json +57 -56
- package/src/cli.ts +51 -15
- package/src/commands/config.ts +70 -1
- package/src/commands/organize.ts +696 -43
- package/src/commands/profile.ts +943 -0
- package/src/commands/undo.ts +139 -0
- package/src/commands/watch.ts +69 -3
- package/src/lib/config.ts +83 -0
- package/src/lib/history.ts +139 -0
- package/src/lib/opencode.ts +24 -6
- package/src/lib/presets.ts +257 -0
- package/src/lib/profiles.ts +367 -0
- package/src/lib/scanner.ts +103 -1
- 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";
|
|
22
|
-
import {
|
|
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
|
-
|
|
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,38 +639,248 @@ 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
|
+
}
|
|
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
|
-
|
|
779
|
+
if (!isJsonMode && spinner) {
|
|
780
|
+
spinner.start("Analyzing files with AI...");
|
|
781
|
+
}
|
|
281
782
|
|
|
282
783
|
let proposal: OrganizationProposal;
|
|
283
784
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1038
|
+
spinner!.stop("Analysis complete");
|
|
388
1039
|
displayAllProposals(proposal);
|
|
389
1040
|
} catch (error: any) {
|
|
390
|
-
spinner
|
|
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
|
|
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
|
|
1073
|
+
spinner!.stop("Analysis complete");
|
|
421
1074
|
displayAllProposals(proposal);
|
|
422
1075
|
} catch (error: any) {
|
|
423
|
-
spinner
|
|
1076
|
+
spinner!.stop("Analysis failed");
|
|
424
1077
|
p.log.error(error.message);
|
|
425
1078
|
}
|
|
426
1079
|
break;
|