tidyf 1.0.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,151 @@
1
+ /**
2
+ * File watcher for tidy
3
+ *
4
+ * Uses chokidar to watch directories for new files and emits batched events
5
+ */
6
+
7
+ import chokidar from "chokidar";
8
+ import { EventEmitter } from "events";
9
+ import type { WatchEvent } from "../types/organizer.ts";
10
+
11
+ /**
12
+ * Options for the file watcher
13
+ */
14
+ export interface WatcherOptions {
15
+ /** Debounce delay in milliseconds */
16
+ delay?: number;
17
+ /** Patterns to ignore */
18
+ ignore?: string[];
19
+ /** Whether to watch subdirectories */
20
+ recursive?: boolean;
21
+ }
22
+
23
+ /**
24
+ * File watcher that batches events and emits after debounce
25
+ */
26
+ export class FileWatcher extends EventEmitter {
27
+ private watcher: chokidar.FSWatcher | null = null;
28
+ private debounceTimer: NodeJS.Timeout | null = null;
29
+ private pendingFiles: Map<string, WatchEvent> = new Map();
30
+ private isRunning = false;
31
+
32
+ constructor(private options: WatcherOptions = {}) {
33
+ super();
34
+ this.options.delay = this.options.delay ?? 3000;
35
+ }
36
+
37
+ /**
38
+ * Start watching the specified paths
39
+ */
40
+ start(paths: string[]): void {
41
+ if (this.isRunning) {
42
+ return;
43
+ }
44
+
45
+ this.isRunning = true;
46
+
47
+ // Build ignore patterns for chokidar
48
+ const ignored = [
49
+ /(^|[\/\\])\../, // Hidden files
50
+ "**/node_modules/**",
51
+ "**/.git/**",
52
+ ...(this.options.ignore || []),
53
+ ];
54
+
55
+ this.watcher = chokidar.watch(paths, {
56
+ persistent: true,
57
+ ignoreInitial: true,
58
+ ignored,
59
+ depth: this.options.recursive ? undefined : 0,
60
+ awaitWriteFinish: {
61
+ stabilityThreshold: 2000,
62
+ pollInterval: 100,
63
+ },
64
+ });
65
+
66
+ this.watcher.on("add", (path) => this.handleEvent("add", path));
67
+ this.watcher.on("change", (path) => this.handleEvent("change", path));
68
+ this.watcher.on("error", (error) => this.emit("error", error));
69
+
70
+ this.watcher.on("ready", () => {
71
+ this.emit("ready", paths);
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Handle a file event
77
+ */
78
+ private handleEvent(type: "add" | "change", path: string): void {
79
+ // Add to pending files
80
+ this.pendingFiles.set(path, {
81
+ type,
82
+ path,
83
+ timestamp: new Date(),
84
+ });
85
+
86
+ // Reset debounce timer
87
+ if (this.debounceTimer) {
88
+ clearTimeout(this.debounceTimer);
89
+ }
90
+
91
+ this.debounceTimer = setTimeout(() => {
92
+ this.flushPendingFiles();
93
+ }, this.options.delay);
94
+ }
95
+
96
+ /**
97
+ * Flush pending files and emit batch event
98
+ */
99
+ private flushPendingFiles(): void {
100
+ if (this.pendingFiles.size === 0) {
101
+ return;
102
+ }
103
+
104
+ const files = Array.from(this.pendingFiles.values());
105
+ this.pendingFiles.clear();
106
+
107
+ this.emit("files", files);
108
+ }
109
+
110
+ /**
111
+ * Stop watching
112
+ */
113
+ stop(): void {
114
+ if (this.debounceTimer) {
115
+ clearTimeout(this.debounceTimer);
116
+ this.debounceTimer = null;
117
+ }
118
+
119
+ // Flush any remaining files
120
+ this.flushPendingFiles();
121
+
122
+ if (this.watcher) {
123
+ this.watcher.close();
124
+ this.watcher = null;
125
+ }
126
+
127
+ this.isRunning = false;
128
+ this.emit("stop");
129
+ }
130
+
131
+ /**
132
+ * Check if watcher is running
133
+ */
134
+ get running(): boolean {
135
+ return this.isRunning;
136
+ }
137
+
138
+ /**
139
+ * Get number of pending files
140
+ */
141
+ get pendingCount(): number {
142
+ return this.pendingFiles.size;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Create a new file watcher
148
+ */
149
+ export function createWatcher(options?: WatcherOptions): FileWatcher {
150
+ return new FileWatcher(options);
151
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Configuration types for tidy
3
+ */
4
+
5
+ /**
6
+ * Model selection for AI operations
7
+ */
8
+ export interface ModelSelection {
9
+ provider: string;
10
+ model: string;
11
+ }
12
+
13
+ /**
14
+ * Folder rule configuration
15
+ */
16
+ export interface FolderRule {
17
+ /** Source folder patterns (glob or paths) */
18
+ sources: string[];
19
+ /** Target base directory */
20
+ target: string;
21
+ /** Whether to watch this folder */
22
+ watch?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Category rule for organizing files
27
+ */
28
+ export interface CategoryRule {
29
+ /** Category name */
30
+ name: string;
31
+ /** File extensions that belong to this category */
32
+ extensions?: string[];
33
+ /** MIME type patterns */
34
+ mimeTypes?: string[];
35
+ /** Subfolder name */
36
+ subfolder: string;
37
+ }
38
+
39
+ /**
40
+ * Main configuration interface
41
+ */
42
+ export interface TidyConfig {
43
+ /** Model for file analysis */
44
+ organizer?: ModelSelection;
45
+ /** Default source directory */
46
+ defaultSource?: string;
47
+ /** Default target directory */
48
+ defaultTarget?: string;
49
+ /** Whether watch mode is enabled by default */
50
+ watchEnabled?: boolean;
51
+ /** Folder rules */
52
+ folders?: FolderRule[];
53
+ /** Category rules (hints for AI) */
54
+ categories?: CategoryRule[];
55
+ /** Files/patterns to ignore */
56
+ ignore?: string[];
57
+ /** Whether to read file content for better categorization */
58
+ readContent?: boolean;
59
+ /** Max file size to read content (in bytes) */
60
+ maxContentSize?: number;
61
+ }
62
+
63
+ /**
64
+ * Options for config command
65
+ */
66
+ export interface ConfigOptions {
67
+ /** Whether to configure locally (current directory) */
68
+ local?: boolean;
69
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Core types for the file organizer
3
+ */
4
+
5
+ /**
6
+ * Metadata about a file to be organized
7
+ */
8
+ export interface FileMetadata {
9
+ /** Full path to the file */
10
+ path: string;
11
+ /** File name without path */
12
+ name: string;
13
+ /** File extension (without dot) */
14
+ extension: string;
15
+ /** File size in bytes */
16
+ size: number;
17
+ /** Last modified timestamp */
18
+ modifiedAt: Date;
19
+ /** Created timestamp */
20
+ createdAt: Date;
21
+ /** MIME type if detectable */
22
+ mimeType?: string;
23
+ /** Optional content preview (first N bytes/lines) */
24
+ contentPreview?: string;
25
+ }
26
+
27
+ /**
28
+ * AI-suggested category for a file
29
+ */
30
+ export interface FileCategory {
31
+ /** Category name (e.g., "Documents", "Images", "Projects") */
32
+ name: string;
33
+ /** Subcategory if applicable (e.g., "Work", "Personal") */
34
+ subcategory?: string;
35
+ /** Suggested subfolder path relative to target */
36
+ suggestedPath: string;
37
+ /** Confidence score 0-1 */
38
+ confidence: number;
39
+ /** AI's reasoning for this categorization */
40
+ reasoning: string;
41
+ }
42
+
43
+ /**
44
+ * A proposed file move operation
45
+ */
46
+ export interface FileMoveProposal {
47
+ /** Original file path */
48
+ sourcePath: string;
49
+ /** Source file metadata */
50
+ file: FileMetadata;
51
+ /** Proposed destination path (full path) */
52
+ destination: string;
53
+ /** Category information */
54
+ category: FileCategory;
55
+ /** Whether file already exists at destination */
56
+ conflictExists: boolean;
57
+ }
58
+
59
+ /**
60
+ * Result of AI analysis
61
+ */
62
+ export interface OrganizationProposal {
63
+ /** List of proposed moves */
64
+ proposals: FileMoveProposal[];
65
+ /** Overall strategy explanation */
66
+ strategy: string;
67
+ /** Files that couldn't be categorized */
68
+ uncategorized: FileMetadata[];
69
+ /** Timestamp of analysis */
70
+ analyzedAt: Date;
71
+ }
72
+
73
+ /**
74
+ * Options for organize command
75
+ */
76
+ export interface OrganizeOptions {
77
+ /** Directory to scan */
78
+ path?: string;
79
+ /** Preview without executing */
80
+ dryRun?: boolean;
81
+ /** Skip confirmations */
82
+ yes?: boolean;
83
+ /** Scan subdirectories */
84
+ recursive?: boolean;
85
+ /** Max depth for recursive scan */
86
+ depth?: string;
87
+ /** Target directory for organized files */
88
+ target?: string;
89
+ /** Model override */
90
+ model?: string;
91
+ }
92
+
93
+ /**
94
+ * Options for watch command
95
+ */
96
+ export interface WatchOptions {
97
+ /** Directories to watch */
98
+ paths?: string[];
99
+ /** Debounce delay in ms */
100
+ delay?: string;
101
+ /** Auto-apply without confirmation */
102
+ auto?: boolean;
103
+ /** Queue for review instead of auto-apply */
104
+ queue?: boolean;
105
+ /** Model override */
106
+ model?: string;
107
+ }
108
+
109
+ /**
110
+ * Watch event for a new file
111
+ */
112
+ export interface WatchEvent {
113
+ /** Event type */
114
+ type: "add" | "change";
115
+ /** File path */
116
+ path: string;
117
+ /** Timestamp */
118
+ timestamp: Date;
119
+ }
120
+
121
+ /**
122
+ * Status of a file move operation
123
+ */
124
+ export type MoveStatus =
125
+ | "pending"
126
+ | "moving"
127
+ | "completed"
128
+ | "failed"
129
+ | "skipped"
130
+ | "conflict";
131
+
132
+ /**
133
+ * Result of a file move operation
134
+ */
135
+ export interface MoveResult {
136
+ /** Source path */
137
+ source: string;
138
+ /** Destination path */
139
+ destination: string;
140
+ /** Move status */
141
+ status: MoveStatus;
142
+ /** Error message if failed */
143
+ error?: string;
144
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * File system utilities for tidy
3
+ */
4
+
5
+ import { rename, mkdir, access, copyFile, unlink, stat } from "fs/promises";
6
+ import { dirname, join, basename, extname } from "path";
7
+ import { existsSync } from "fs";
8
+ import type { MoveResult, MoveStatus } from "../types/organizer.ts";
9
+
10
+ /**
11
+ * Check if a file exists
12
+ */
13
+ export async function fileExists(filePath: string): Promise<boolean> {
14
+ try {
15
+ await access(filePath);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Ensure a directory exists, creating it if necessary
24
+ */
25
+ export async function ensureDirectory(dirPath: string): Promise<void> {
26
+ if (!existsSync(dirPath)) {
27
+ await mkdir(dirPath, { recursive: true });
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Generate a unique filename by appending a number if the file already exists
33
+ */
34
+ export async function generateUniqueName(
35
+ destination: string
36
+ ): Promise<string> {
37
+ if (!(await fileExists(destination))) {
38
+ return destination;
39
+ }
40
+
41
+ const dir = dirname(destination);
42
+ const ext = extname(destination);
43
+ const base = basename(destination, ext);
44
+
45
+ let counter = 1;
46
+ let newPath: string;
47
+
48
+ do {
49
+ newPath = join(dir, `${base} (${counter})${ext}`);
50
+ counter++;
51
+ } while (await fileExists(newPath));
52
+
53
+ return newPath;
54
+ }
55
+
56
+ /**
57
+ * Conflict resolution strategy
58
+ */
59
+ export type ConflictStrategy = "rename" | "overwrite" | "skip";
60
+
61
+ /**
62
+ * Resolve a destination path conflict
63
+ */
64
+ export async function resolveConflict(
65
+ destination: string,
66
+ strategy: ConflictStrategy
67
+ ): Promise<{ path: string; status: MoveStatus }> {
68
+ const exists = await fileExists(destination);
69
+
70
+ if (!exists) {
71
+ return { path: destination, status: "pending" };
72
+ }
73
+
74
+ switch (strategy) {
75
+ case "rename":
76
+ return { path: await generateUniqueName(destination), status: "pending" };
77
+ case "overwrite":
78
+ return { path: destination, status: "pending" };
79
+ case "skip":
80
+ return { path: destination, status: "skipped" };
81
+ default:
82
+ return { path: destination, status: "conflict" };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Move a file from source to destination
88
+ */
89
+ export async function moveFile(
90
+ source: string,
91
+ destination: string,
92
+ options: {
93
+ overwrite?: boolean;
94
+ backup?: boolean;
95
+ } = {}
96
+ ): Promise<MoveResult> {
97
+ try {
98
+ // Ensure the destination directory exists
99
+ await ensureDirectory(dirname(destination));
100
+
101
+ // Check if destination exists
102
+ const destExists = await fileExists(destination);
103
+
104
+ if (destExists) {
105
+ if (options.backup) {
106
+ // Create a backup of the existing file
107
+ const backupPath = await generateUniqueName(destination + ".backup");
108
+ await copyFile(destination, backupPath);
109
+ }
110
+
111
+ if (!options.overwrite) {
112
+ // Generate a unique name
113
+ destination = await generateUniqueName(destination);
114
+ }
115
+ }
116
+
117
+ // Try to rename (move) the file
118
+ try {
119
+ await rename(source, destination);
120
+ } catch (error: any) {
121
+ // If rename fails (cross-device), fall back to copy + delete
122
+ if (error.code === "EXDEV") {
123
+ await copyFile(source, destination);
124
+ await unlink(source);
125
+ } else {
126
+ throw error;
127
+ }
128
+ }
129
+
130
+ return {
131
+ source,
132
+ destination,
133
+ status: "completed",
134
+ };
135
+ } catch (error: any) {
136
+ return {
137
+ source,
138
+ destination,
139
+ status: "failed",
140
+ error: error.message,
141
+ };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get file stats safely
147
+ */
148
+ export async function getFileStats(
149
+ filePath: string
150
+ ): Promise<{ size: number; mtime: Date; birthtime: Date } | null> {
151
+ try {
152
+ const stats = await stat(filePath);
153
+ return {
154
+ size: stats.size,
155
+ mtime: stats.mtime,
156
+ birthtime: stats.birthtime,
157
+ };
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Format file size for display
165
+ */
166
+ export function formatFileSize(bytes: number): string {
167
+ if (bytes === 0) return "0 B";
168
+
169
+ const units = ["B", "KB", "MB", "GB", "TB"];
170
+ const k = 1024;
171
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
172
+
173
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${units[i]}`;
174
+ }
175
+
176
+ /**
177
+ * Check if a path is a directory
178
+ */
179
+ export async function isDirectory(path: string): Promise<boolean> {
180
+ try {
181
+ const stats = await stat(path);
182
+ return stats.isDirectory();
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Check if a path is a file
190
+ */
191
+ export async function isFile(path: string): Promise<boolean> {
192
+ try {
193
+ const stats = await stat(path);
194
+ return stats.isFile();
195
+ } catch {
196
+ return false;
197
+ }
198
+ }