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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Undo command - revert file organization operations
3
+ */
4
+
5
+ import * as p from "@clack/prompts";
6
+ import { existsSync } from "fs";
7
+ import { rename, mkdir } from "fs/promises";
8
+ import { dirname } from "path";
9
+ import color from "picocolors";
10
+ import { initGlobalConfig } from "../lib/config.ts";
11
+ import { cleanup } from "../lib/opencode.ts";
12
+ import {
13
+ deleteHistoryEntry,
14
+ getHistoryEntry,
15
+ getRecentHistory,
16
+ type HistoryEntry,
17
+ } from "../lib/history.ts";
18
+
19
+ /**
20
+ * Select a history entry to undo
21
+ */
22
+ async function selectHistoryEntry(): Promise<HistoryEntry | null> {
23
+ const history = getRecentHistory();
24
+
25
+ if (history.length === 0) {
26
+ p.log.warn("No history to undo");
27
+ return null;
28
+ }
29
+
30
+ const options = history.map((entry) => ({
31
+ value: entry.id,
32
+ label: `${new Date(entry.timestamp).toLocaleString()}`,
33
+ hint: `${entry.moves.length} files moved`,
34
+ }));
35
+
36
+ const selectedId = await p.select({
37
+ message: "Which operation to undo?",
38
+ options,
39
+ });
40
+
41
+ if (p.isCancel(selectedId)) {
42
+ return null;
43
+ }
44
+
45
+ return getHistoryEntry(selectedId as string);
46
+ }
47
+
48
+ /**
49
+ * Undo a file move
50
+ */
51
+ async function undoMove(source: string, destination: string): Promise<boolean> {
52
+ try {
53
+ // Ensure source directory exists (in case it was deleted)
54
+ await mkdir(dirname(source), { recursive: true });
55
+
56
+ await rename(destination, source);
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Main undo command
65
+ */
66
+ export async function undoCommand(): Promise<void> {
67
+ p.intro(color.bgRed(color.black(" tidyf undo ")));
68
+
69
+ initGlobalConfig();
70
+
71
+ const entry = await selectHistoryEntry();
72
+
73
+ if (!entry) {
74
+ p.outro(color.yellow("Nothing to undo"));
75
+ cleanup();
76
+ return;
77
+ }
78
+
79
+ console.log();
80
+ p.log.info(color.bold("Operation details:"));
81
+ p.log.message(` Timestamp: ${new Date(entry.timestamp).toLocaleString()}`);
82
+ p.log.message(` Source: ${entry.source}`);
83
+ p.log.message(` Target: ${entry.target}`);
84
+ p.log.message(` Files moved: ${entry.moves.length}`);
85
+
86
+ const confirm = await p.confirm({
87
+ message: `Undo this operation? This will move ${entry.moves.length} files back to ${entry.source}`,
88
+ initialValue: false,
89
+ });
90
+
91
+ if (p.isCancel(confirm) || !confirm) {
92
+ p.outro(color.yellow("Cancelled"));
93
+ cleanup();
94
+ return;
95
+ }
96
+
97
+ const s = p.spinner();
98
+ s.start("Undoing file moves...");
99
+
100
+ let successCount = 0;
101
+ let failCount = 0;
102
+
103
+ for (const move of entry.moves) {
104
+ const destExists = existsSync(move.destination);
105
+
106
+ if (!destExists) {
107
+ p.log.warn(` Skipped: ${move.destination} (already moved/deleted)`);
108
+ failCount++;
109
+ continue;
110
+ }
111
+
112
+ if (await undoMove(move.source, move.destination)) {
113
+ successCount++;
114
+ } else {
115
+ failCount++;
116
+ }
117
+ }
118
+
119
+ s.stop("Undo complete");
120
+
121
+ p.log.success(`\nMoved ${successCount} files back to ${color.cyan(entry.source)}`);
122
+
123
+ if (failCount > 0) {
124
+ p.log.warn(`${failCount} files could not be moved`);
125
+ }
126
+
127
+ const deleteFromHistory = await p.confirm({
128
+ message: "Remove this operation from history?",
129
+ initialValue: true,
130
+ });
131
+
132
+ if (p.isCancel(deleteFromHistory) || deleteFromHistory) {
133
+ deleteHistoryEntry(entry.id);
134
+ p.log.success("Removed from history");
135
+ }
136
+
137
+ p.outro(color.green("Done!"));
138
+ cleanup();
139
+ }
@@ -9,9 +9,11 @@ import {
9
9
  initGlobalConfig,
10
10
  parseModelString,
11
11
  resolveConfig,
12
+ resolveConfigWithProfile,
12
13
  } from "../lib/config.ts";
13
14
  import { analyzeFiles, cleanup } from "../lib/opencode.ts";
14
- import { getFileMetadata } from "../lib/scanner.ts";
15
+ import { listProfiles, profileExists } from "../lib/profiles.ts";
16
+ import { getFileMetadata, scanFolderStructure } from "../lib/scanner.ts";
15
17
  import { createWatcher, type FileWatcher } from "../lib/watcher.ts";
16
18
  import type {
17
19
  FileMetadata,
@@ -23,6 +25,41 @@ import type {
23
25
  import { formatFileSize, moveFile } from "../utils/files.ts";
24
26
  import { getCategoryIcon, getFileIcon } from "../utils/icons.ts";
25
27
 
28
+ // Folder cache for watch mode to avoid re-scanning on every file event
29
+ let folderCache: string[] = [];
30
+ let lastFolderScan = 0;
31
+ const FOLDER_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
32
+
33
+ /**
34
+ * Get existing folders with caching
35
+ */
36
+ async function getExistingFolders(
37
+ targetPath: string,
38
+ ignore: string[] = [],
39
+ ): Promise<string[]> {
40
+ const now = Date.now();
41
+ if (now - lastFolderScan > FOLDER_CACHE_TTL) {
42
+ try {
43
+ folderCache = await scanFolderStructure(targetPath, {
44
+ maxDepth: 3,
45
+ includeEmpty: false,
46
+ ignore,
47
+ });
48
+ lastFolderScan = now;
49
+ } catch {
50
+ // Target directory might not exist yet - use cached or empty
51
+ }
52
+ }
53
+ return folderCache;
54
+ }
55
+
56
+ /**
57
+ * Invalidate folder cache (call after successful file moves)
58
+ */
59
+ function invalidateFolderCache(): void {
60
+ lastFolderScan = 0;
61
+ }
62
+
26
63
  /**
27
64
  * Display a proposal briefly for watch mode
28
65
  */
@@ -57,6 +94,11 @@ async function executeProposalsSilent(
57
94
  }
58
95
  }
59
96
 
97
+ // Invalidate folder cache since new folders may have been created
98
+ if (success > 0) {
99
+ invalidateFolderCache();
100
+ }
101
+
60
102
  return { success, failed };
61
103
  }
62
104
 
@@ -112,8 +154,26 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
112
154
  // Initialize global config if needed
113
155
  initGlobalConfig();
114
156
 
115
- // Resolve configuration
116
- const config = resolveConfig();
157
+ // Validate and resolve profile if specified
158
+ if (options.profile) {
159
+ if (!profileExists(options.profile)) {
160
+ p.log.error(`Profile "${options.profile}" not found`);
161
+ const profiles = listProfiles();
162
+ if (profiles.length > 0) {
163
+ p.log.info("Available profiles:");
164
+ profiles.forEach((pr) => p.log.message(` - ${pr.name}`));
165
+ }
166
+ p.outro("Canceled");
167
+ cleanup();
168
+ process.exit(1);
169
+ }
170
+ p.log.info(`Profile: ${color.cyan(options.profile)}`);
171
+ }
172
+
173
+ // Resolve configuration (with profile if specified)
174
+ const config = options.profile
175
+ ? resolveConfigWithProfile(options.profile)
176
+ : resolveConfig();
117
177
 
118
178
  // Determine paths to watch
119
179
  let watchPaths: string[];
@@ -207,10 +267,13 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
207
267
 
208
268
  let proposal: OrganizationProposal;
209
269
  try {
270
+ const existingFolders = await getExistingFolders(targetPath, config.ignore);
210
271
  proposal = await analyzeFiles({
211
272
  files,
212
273
  targetDir: targetPath,
213
274
  model: parseModelString(options.model),
275
+ existingFolders,
276
+ profileName: options.profile,
214
277
  });
215
278
  s.stop("Analysis complete");
216
279
  } catch (error: any) {
@@ -276,10 +339,13 @@ export async function watchCommand(options: WatchOptions): Promise<void> {
276
339
  s.start("Analyzing queued files...");
277
340
 
278
341
  try {
342
+ const existingFolders = await getExistingFolders(targetPath, config.ignore);
279
343
  const proposal = await analyzeFiles({
280
344
  files: reviewQueue,
281
345
  targetDir: targetPath,
282
346
  model: parseModelString(options.model),
347
+ existingFolders,
348
+ profileName: options.profile,
283
349
  });
284
350
  s.stop("Analysis complete");
285
351
 
package/src/lib/config.ts CHANGED
@@ -8,6 +8,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
8
  import { join, dirname } from "path";
9
9
  import { homedir } from "os";
10
10
  import type { TidyConfig, ModelSelection } from "../types/config.ts";
11
+ import {
12
+ profileExists,
13
+ readProfile,
14
+ readProfileRules,
15
+ getProfileConfigFields,
16
+ } from "./profiles.ts";
11
17
 
12
18
  const CONFIG_DIR = ".tidy";
13
19
  const SETTINGS_FILE = "settings.json";
@@ -94,6 +100,20 @@ You are an AI assistant that organizes files from a download folder. Analyze eac
94
100
  5. Installer files go to Applications/Installers
95
101
  6. Compressed files stay as Archives unless clearly part of another category
96
102
 
103
+ ## Existing Folder Preferences
104
+
105
+ When existing folders are provided in the prompt:
106
+
107
+ 1. **STRONGLY PREFER existing folders** - Use them when the file fits the category
108
+ 2. **Match naming conventions** - If "Screenshots" exists, don't create "Screen Captures"
109
+ 3. **Extend existing hierarchy** - OK to create subfolders under existing folders
110
+ 4. **Only create new top-level folders** when no existing category applies
111
+
112
+ Examples:
113
+ - If "Images/Screenshots" exists, use it for screenshot files
114
+ - If "Documents/Work" exists but no "Reports" subfolder, create "Documents/Work/Reports"
115
+ - If only "Documents" exists, prefer "Documents/Receipts" over top-level "Receipts"
116
+
97
117
  ## Output Format
98
118
 
99
119
  Return JSON with this exact structure:
@@ -333,3 +353,66 @@ export function getDefaultConfig(): TidyConfig {
333
353
  export function getDefaultRules(): string {
334
354
  return DEFAULT_RULES;
335
355
  }
356
+
357
+ /**
358
+ * Resolve the effective configuration with profile support
359
+ * Resolution order: defaults → global → profile → local
360
+ */
361
+ export function resolveConfigWithProfile(
362
+ profileName?: string,
363
+ basePath: string = process.cwd(),
364
+ ): TidyConfig {
365
+ // Start with defaults
366
+ const config: TidyConfig = { ...DEFAULT_CONFIG };
367
+
368
+ // Merge global config
369
+ const globalConfig = readConfig(getGlobalConfigPath());
370
+ Object.assign(config, globalConfig);
371
+
372
+ // Merge profile config (if specified and exists)
373
+ if (profileName && profileExists(profileName)) {
374
+ const profile = readProfile(profileName);
375
+ if (profile) {
376
+ const profileConfig = getProfileConfigFields(profile);
377
+ Object.assign(config, profileConfig);
378
+ }
379
+ }
380
+
381
+ // Merge local config (takes precedence)
382
+ const localConfig = readConfig(getLocalConfigPath(basePath));
383
+ Object.assign(config, localConfig);
384
+
385
+ return config;
386
+ }
387
+
388
+ /**
389
+ * Get the rules prompt with profile support
390
+ * Resolution order: local → profile → global → default
391
+ */
392
+ export function getRulesPromptWithProfile(
393
+ profileName?: string,
394
+ basePath: string = process.cwd(),
395
+ ): string {
396
+ // Try local rules first (always highest priority)
397
+ const localRules = readRules(getLocalRulesPath(basePath));
398
+ if (localRules) {
399
+ return localRules;
400
+ }
401
+
402
+ // Try profile rules (if specified and exists)
403
+ if (profileName && profileExists(profileName)) {
404
+ const profileRules = readProfileRules(profileName);
405
+ if (profileRules) {
406
+ return profileRules;
407
+ }
408
+ }
409
+
410
+ // Fall back to global rules
411
+ const globalRules = readRules(getGlobalRulesPath());
412
+ if (globalRules) {
413
+ return globalRules;
414
+ }
415
+
416
+ // Return default rules
417
+ return DEFAULT_RULES;
418
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * History management for file operations
3
+ *
4
+ * Logs file moves to ~/.tidy/history.json for undo functionality
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { homedir } from "os";
10
+ import { ensureDirectory } from "../utils/files.ts";
11
+
12
+ const HISTORY_DIR = ".tidy";
13
+ const HISTORY_FILE = "history.json";
14
+
15
+ /**
16
+ * Get the history file path
17
+ */
18
+ function getHistoryPath(): string {
19
+ return join(homedir(), HISTORY_DIR, HISTORY_FILE);
20
+ }
21
+
22
+ /**
23
+ * History entry for a file operation
24
+ */
25
+ export interface HistoryEntry {
26
+ /** Unique ID for this operation */
27
+ id: string;
28
+ /** Timestamp of operation */
29
+ timestamp: string;
30
+ /** Source directory */
31
+ source: string;
32
+ /** Target directory */
33
+ target: string;
34
+ /** Files that were moved */
35
+ moves: Array<{
36
+ source: string;
37
+ destination: string;
38
+ timestamp: string;
39
+ }>;
40
+ }
41
+
42
+ /**
43
+ * Read history from file
44
+ */
45
+ export function readHistory(): HistoryEntry[] {
46
+ const historyPath = getHistoryPath();
47
+
48
+ if (!existsSync(historyPath)) {
49
+ return [];
50
+ }
51
+
52
+ try {
53
+ const content = readFileSync(historyPath, "utf-8");
54
+ return JSON.parse(content);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Write history to file
62
+ */
63
+ function writeHistory(history: HistoryEntry[]): void {
64
+ const historyPath = getHistoryPath();
65
+ ensureDirectory(dirname(historyPath));
66
+ writeFileSync(historyPath, JSON.stringify(history, null, 2), "utf-8");
67
+ }
68
+
69
+ /**
70
+ * Create a new history entry
71
+ */
72
+ export function createHistoryEntry(
73
+ source: string,
74
+ target: string,
75
+ ): HistoryEntry {
76
+ return {
77
+ id: Date.now().toString(),
78
+ timestamp: new Date().toISOString(),
79
+ source,
80
+ target,
81
+ moves: [],
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Add a file move to history entry
87
+ */
88
+ export function addMoveToHistory(
89
+ entry: HistoryEntry,
90
+ source: string,
91
+ destination: string,
92
+ ): void {
93
+ entry.moves.push({
94
+ source,
95
+ destination,
96
+ timestamp: new Date().toISOString(),
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Save a history entry
102
+ */
103
+ export function saveHistoryEntry(entry: HistoryEntry): void {
104
+ const history = readHistory();
105
+ history.unshift(entry);
106
+ writeHistory(history);
107
+ }
108
+
109
+ /**
110
+ * Get history entry by ID
111
+ */
112
+ export function getHistoryEntry(id: string): HistoryEntry | null {
113
+ const history = readHistory();
114
+ return history.find((entry) => entry.id === id) || null;
115
+ }
116
+
117
+ /**
118
+ * Get the last N history entries
119
+ */
120
+ export function getRecentHistory(limit: number = 10): HistoryEntry[] {
121
+ const history = readHistory();
122
+ return history.slice(0, limit);
123
+ }
124
+
125
+ /**
126
+ * Delete history entry by ID
127
+ */
128
+ export function deleteHistoryEntry(id: string): void {
129
+ const history = readHistory();
130
+ const filtered = history.filter((entry) => entry.id !== id);
131
+ writeHistory(filtered);
132
+ }
133
+
134
+ /**
135
+ * Clear all history
136
+ */
137
+ export function clearHistory(): void {
138
+ writeHistory([]);
139
+ }
@@ -20,7 +20,11 @@ import type {
20
20
  OrganizationProposal,
21
21
  } from "../types/organizer.ts";
22
22
  import { fileExists } from "../utils/files.ts";
23
- import { expandPath, getRulesPrompt, resolveConfig } from "./config.ts";
23
+ import {
24
+ expandPath,
25
+ getRulesPromptWithProfile,
26
+ resolveConfigWithProfile,
27
+ } from "./config.ts";
24
28
 
25
29
  const execAsync = promisify(exec);
26
30
 
@@ -169,6 +173,10 @@ export interface AnalyzeFilesOptions {
169
173
  instructions?: string;
170
174
  /** Model override */
171
175
  model?: ModelSelection;
176
+ /** Existing folders in target directory for consistency */
177
+ existingFolders?: string[];
178
+ /** Profile name to use for config and rules */
179
+ profileName?: string;
172
180
  }
173
181
 
174
182
  /**
@@ -311,7 +319,7 @@ export async function checkConflicts(
311
319
  export async function analyzeFiles(
312
320
  options: AnalyzeFilesOptions,
313
321
  ): Promise<OrganizationProposal> {
314
- const { files, targetDir, instructions, model } = options;
322
+ const { files, targetDir, instructions, model, existingFolders, profileName } = options;
315
323
 
316
324
  if (files.length === 0) {
317
325
  return {
@@ -325,18 +333,28 @@ export async function analyzeFiles(
325
333
  const opencodeClient = await getClient();
326
334
  const sid = await getSessionId();
327
335
 
328
- // Get configuration
329
- const config = resolveConfig();
330
- const rulesPrompt = getRulesPrompt();
336
+ // Get configuration with profile awareness
337
+ const config = resolveConfigWithProfile(profileName);
338
+ const rulesPrompt = getRulesPromptWithProfile(profileName);
331
339
 
332
340
  // Build the prompt
333
341
  const filesJson = formatFilesForPrompt(files);
334
342
 
343
+ // Build existing folders section if available
344
+ const existingFoldersSection = existingFolders?.length
345
+ ? `
346
+ EXISTING FOLDERS in target directory:
347
+ ${existingFolders.join("\n")}
348
+
349
+ IMPORTANT: Prefer using these existing folders when appropriate. Only create new folders when no suitable existing folder matches the file's category.
350
+ `
351
+ : "";
352
+
335
353
  const userPrompt = `
336
354
  Analyze the following files and organize them according to the rules.
337
355
 
338
356
  Target directory: ${targetDir}
339
-
357
+ ${existingFoldersSection}
340
358
  ${instructions ? `Additional instructions: ${instructions}\n` : ""}
341
359
  Files to organize:
342
360
  ${filesJson}