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.
@@ -3,12 +3,114 @@
3
3
  */
4
4
 
5
5
  import { readdir, stat, readFile } from "fs/promises";
6
- import { join, extname, basename } from "path";
6
+ import { join, extname, basename, relative } from "path";
7
7
  import { lookup as lookupMimeType } from "mime-types";
8
8
  import type { FileMetadata } from "../types/organizer.ts";
9
9
  import { shouldIgnore } from "./config.ts";
10
10
  import { isDirectory, isFile } from "../utils/files.ts";
11
11
 
12
+ /**
13
+ * Options for scanning folder structure
14
+ */
15
+ export interface ScanFolderOptions {
16
+ /** Maximum depth to scan (default: 3) */
17
+ maxDepth?: number;
18
+ /** Include empty folders (default: false) */
19
+ includeEmpty?: boolean;
20
+ /** Patterns to ignore */
21
+ ignore?: string[];
22
+ /** Maximum number of folders to return (default: 100) */
23
+ maxFolders?: number;
24
+ }
25
+
26
+ /**
27
+ * Scan a directory and return existing folder structure as relative paths
28
+ * Used to inform AI about existing organization
29
+ */
30
+ export async function scanFolderStructure(
31
+ dirPath: string,
32
+ options: ScanFolderOptions = {}
33
+ ): Promise<string[]> {
34
+ const {
35
+ maxDepth = 3,
36
+ includeEmpty = false,
37
+ ignore = [],
38
+ maxFolders = 100,
39
+ } = options;
40
+
41
+ const folders: string[] = [];
42
+
43
+ await scanFoldersInternal(dirPath, dirPath, 0, maxDepth, includeEmpty, ignore, folders, maxFolders);
44
+
45
+ // Sort by depth (shallower first) then alphabetically
46
+ folders.sort((a, b) => {
47
+ const depthA = a.split("/").length;
48
+ const depthB = b.split("/").length;
49
+ if (depthA !== depthB) return depthA - depthB;
50
+ return a.localeCompare(b);
51
+ });
52
+
53
+ return folders.slice(0, maxFolders);
54
+ }
55
+
56
+ async function scanFoldersInternal(
57
+ basePath: string,
58
+ currentPath: string,
59
+ currentDepth: number,
60
+ maxDepth: number,
61
+ includeEmpty: boolean,
62
+ ignore: string[],
63
+ folders: string[],
64
+ maxFolders: number
65
+ ): Promise<void> {
66
+ if (currentDepth >= maxDepth || folders.length >= maxFolders) {
67
+ return;
68
+ }
69
+
70
+ try {
71
+ const entries = await readdir(currentPath, { withFileTypes: true });
72
+
73
+ for (const entry of entries) {
74
+ if (folders.length >= maxFolders) break;
75
+
76
+ if (!entry.isDirectory()) continue;
77
+
78
+ // Skip hidden folders and ignored patterns
79
+ if (entry.name.startsWith(".")) continue;
80
+ if (shouldIgnore(entry.name, ignore)) continue;
81
+
82
+ const fullPath = join(currentPath, entry.name);
83
+ const relativePath = relative(basePath, fullPath);
84
+
85
+ // Check if folder has contents (if we care about empty folders)
86
+ if (!includeEmpty) {
87
+ try {
88
+ const contents = await readdir(fullPath);
89
+ if (contents.length === 0) continue;
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ folders.push(relativePath);
96
+
97
+ // Recursively scan subdirectories
98
+ await scanFoldersInternal(
99
+ basePath,
100
+ fullPath,
101
+ currentDepth + 1,
102
+ maxDepth,
103
+ includeEmpty,
104
+ ignore,
105
+ folders,
106
+ maxFolders
107
+ );
108
+ }
109
+ } catch {
110
+ // Silently skip directories we can't read
111
+ }
112
+ }
113
+
12
114
  /**
13
115
  * Options for scanning a directory
14
116
  */
@@ -22,6 +22,8 @@ export interface FileMetadata {
22
22
  mimeType?: string;
23
23
  /** Optional content preview (first N bytes/lines) */
24
24
  contentPreview?: string;
25
+ /** Content hash for duplicate detection */
26
+ hash?: string;
25
27
  }
26
28
 
27
29
  /**
@@ -56,6 +58,18 @@ export interface FileMoveProposal {
56
58
  conflictExists: boolean;
57
59
  }
58
60
 
61
+ /**
62
+ * A group of duplicate files (same content hash)
63
+ */
64
+ export interface DuplicateGroup {
65
+ /** Content hash shared by all files */
66
+ hash: string;
67
+ /** Files with identical content */
68
+ files: FileMetadata[];
69
+ /** Total wasted space (all but one copy) */
70
+ wastedBytes: number;
71
+ }
72
+
59
73
  /**
60
74
  * Result of AI analysis
61
75
  */
@@ -68,6 +82,8 @@ export interface OrganizationProposal {
68
82
  uncategorized: FileMetadata[];
69
83
  /** Timestamp of analysis */
70
84
  analyzedAt: Date;
85
+ /** Detected duplicate file groups */
86
+ duplicates?: DuplicateGroup[];
71
87
  }
72
88
 
73
89
  /**
@@ -88,6 +104,12 @@ export interface OrganizeOptions {
88
104
  target?: string;
89
105
  /** Model override */
90
106
  model?: string;
107
+ /** Profile name to use */
108
+ profile?: string;
109
+ /** Output JSON instead of interactive UI */
110
+ json?: boolean;
111
+ /** Detect duplicate files by content hash */
112
+ detectDuplicates?: boolean;
91
113
  }
92
114
 
93
115
  /**
@@ -104,6 +126,8 @@ export interface WatchOptions {
104
126
  queue?: boolean;
105
127
  /** Model override */
106
128
  model?: string;
129
+ /** Profile name to use */
130
+ profile?: string;
107
131
  }
108
132
 
109
133
  /**
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Profile types for tidyf
3
+ *
4
+ * Profiles allow users to create named configuration presets
5
+ * that bundle source/target paths, AI model preferences, ignore patterns,
6
+ * and optionally custom organization rules.
7
+ */
8
+
9
+ import type { TidyConfig } from "./config.ts";
10
+
11
+ /**
12
+ * Profile configuration - extends TidyConfig with metadata
13
+ * Profiles inherit from global config, only overriding specified fields.
14
+ */
15
+ export interface Profile extends Partial<TidyConfig> {
16
+ /** Profile name (directory name) */
17
+ name: string;
18
+ /** Human-readable description */
19
+ description?: string;
20
+ /** When the profile was created */
21
+ createdAt?: string;
22
+ /** When the profile was last modified */
23
+ modifiedAt?: string;
24
+ }
25
+
26
+ /**
27
+ * Profile metadata for listing (without full config)
28
+ */
29
+ export interface ProfileMetadata {
30
+ /** Profile name */
31
+ name: string;
32
+ /** Human-readable description */
33
+ description?: string;
34
+ /** When the profile was created */
35
+ createdAt?: string;
36
+ /** When the profile was last modified */
37
+ modifiedAt?: string;
38
+ /** Whether profile has custom rules.md */
39
+ hasCustomRules: boolean;
40
+ }
41
+
42
+ /**
43
+ * Options for profile command
44
+ */
45
+ export interface ProfileCommandOptions {
46
+ /** Subcommand: list, create, edit, delete, show, copy, export, import */
47
+ action?: string;
48
+ /** Profile name for operations */
49
+ name?: string;
50
+ /** Additional arguments (e.g., destination for copy) */
51
+ args?: string[];
52
+ /** Create from current effective config */
53
+ fromCurrent?: boolean;
54
+ /** Force operation without confirmation */
55
+ force?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Export format for profiles (for sharing)
60
+ */
61
+ export interface ProfileExport {
62
+ /** Export format version */
63
+ version: string;
64
+ /** When the export was created */
65
+ exportedAt: string;
66
+ /** The profile configuration */
67
+ profile: Profile;
68
+ /** Optional custom rules content */
69
+ rules?: string;
70
+ }
@@ -2,7 +2,8 @@
2
2
  * File system utilities for tidy
3
3
  */
4
4
 
5
- import { rename, mkdir, access, copyFile, unlink, stat } from "fs/promises";
5
+ import { rename, mkdir, access, copyFile, unlink, stat, readFile } from "fs/promises";
6
+ import { createHash } from "crypto";
6
7
  import { dirname, join, basename, extname } from "path";
7
8
  import { existsSync } from "fs";
8
9
  import type { MoveResult, MoveStatus } from "../types/organizer.ts";
@@ -196,3 +197,16 @@ export async function isFile(path: string): Promise<boolean> {
196
197
  return false;
197
198
  }
198
199
  }
200
+
201
+ /**
202
+ * Compute a hash of file contents for duplicate detection
203
+ * Uses MD5 for speed (not cryptographic security)
204
+ */
205
+ export async function computeFileHash(filePath: string): Promise<string | null> {
206
+ try {
207
+ const content = await readFile(filePath);
208
+ return createHash("md5").update(content).digest("hex");
209
+ } catch {
210
+ return null;
211
+ }
212
+ }