tidyf 1.0.3 → 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,367 @@
1
+ /**
2
+ * Profile management for tidyf
3
+ *
4
+ * Handles CRUD operations for profiles stored in ~/.tidy/profiles/
5
+ * Each profile is a directory containing settings.json and optionally rules.md
6
+ */
7
+
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ readdirSync,
13
+ rmSync,
14
+ writeFileSync,
15
+ } from "fs";
16
+ import { join } from "path";
17
+ import { homedir } from "os";
18
+ import type { Profile, ProfileMetadata, ProfileExport } from "../types/profile.ts";
19
+ import type { TidyConfig } from "../types/config.ts";
20
+ import { getPreset } from "./presets.ts";
21
+
22
+ const CONFIG_DIR = ".tidy";
23
+ const PROFILES_DIR = "profiles";
24
+ const SETTINGS_FILE = "settings.json";
25
+ const RULES_FILE = "rules.md";
26
+
27
+ /**
28
+ * Get the profiles directory path
29
+ */
30
+ export function getProfilesDir(): string {
31
+ return join(homedir(), CONFIG_DIR, PROFILES_DIR);
32
+ }
33
+
34
+ /**
35
+ * Get a specific profile's directory path
36
+ */
37
+ export function getProfileDir(name: string): string {
38
+ return join(getProfilesDir(), name);
39
+ }
40
+
41
+ /**
42
+ * Get the path to a profile's settings.json
43
+ */
44
+ export function getProfileConfigPath(name: string): string {
45
+ return join(getProfileDir(name), SETTINGS_FILE);
46
+ }
47
+
48
+ /**
49
+ * Get the path to a profile's rules.md
50
+ */
51
+ export function getProfileRulesPath(name: string): string {
52
+ return join(getProfileDir(name), RULES_FILE);
53
+ }
54
+
55
+ /**
56
+ * Ensure profiles directory exists
57
+ */
58
+ export function ensureProfilesDir(): void {
59
+ const profilesDir = getProfilesDir();
60
+ if (!existsSync(profilesDir)) {
61
+ mkdirSync(profilesDir, { recursive: true });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Validate a profile name
67
+ * @returns Object with valid boolean and optional error message
68
+ */
69
+ export function validateProfileName(name: string): { valid: boolean; error?: string } {
70
+ // Must be non-empty and max 50 characters
71
+ if (!name || name.length === 0) {
72
+ return { valid: false, error: "Profile name cannot be empty" };
73
+ }
74
+ if (name.length > 50) {
75
+ return { valid: false, error: "Profile name must be 50 characters or less" };
76
+ }
77
+
78
+ // Alphanumeric, hyphens, underscores only
79
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
80
+ return {
81
+ valid: false,
82
+ error: "Profile name can only contain letters, numbers, hyphens, and underscores",
83
+ };
84
+ }
85
+
86
+ // Reserved names
87
+ const reserved = ["default", "global", "local", "none", "profiles"];
88
+ if (reserved.includes(name.toLowerCase())) {
89
+ return { valid: false, error: `"${name}" is a reserved name` };
90
+ }
91
+
92
+ return { valid: true };
93
+ }
94
+
95
+ /**
96
+ * Check if a profile exists
97
+ */
98
+ export function profileExists(name: string): boolean {
99
+ return existsSync(getProfileDir(name));
100
+ }
101
+
102
+ /**
103
+ * List all profiles with metadata
104
+ */
105
+ export function listProfiles(): ProfileMetadata[] {
106
+ const profilesDir = getProfilesDir();
107
+
108
+ if (!existsSync(profilesDir)) {
109
+ return [];
110
+ }
111
+
112
+ const profiles: ProfileMetadata[] = [];
113
+
114
+ try {
115
+ const entries = readdirSync(profilesDir, { withFileTypes: true });
116
+
117
+ for (const entry of entries) {
118
+ if (!entry.isDirectory()) continue;
119
+ if (entry.name.startsWith(".")) continue;
120
+
121
+ const profileConfigPath = getProfileConfigPath(entry.name);
122
+ const profileRulesPath = getProfileRulesPath(entry.name);
123
+
124
+ // Must have settings.json to be a valid profile
125
+ if (!existsSync(profileConfigPath)) continue;
126
+
127
+ try {
128
+ const content = readFileSync(profileConfigPath, "utf-8");
129
+ const profile: Profile = JSON.parse(content);
130
+
131
+ profiles.push({
132
+ name: entry.name,
133
+ description: profile.description,
134
+ createdAt: profile.createdAt,
135
+ modifiedAt: profile.modifiedAt,
136
+ hasCustomRules: existsSync(profileRulesPath),
137
+ });
138
+ } catch {
139
+ // Skip invalid profiles
140
+ }
141
+ }
142
+ } catch {
143
+ return [];
144
+ }
145
+
146
+ // Sort alphabetically by name
147
+ profiles.sort((a, b) => a.name.localeCompare(b.name));
148
+
149
+ return profiles;
150
+ }
151
+
152
+ /**
153
+ * Read a profile's configuration
154
+ */
155
+ export function readProfile(name: string): Profile | null {
156
+ const configPath = getProfileConfigPath(name);
157
+
158
+ if (!existsSync(configPath)) {
159
+ return null;
160
+ }
161
+
162
+ try {
163
+ const content = readFileSync(configPath, "utf-8");
164
+ const profile: Profile = JSON.parse(content);
165
+ // Ensure name matches directory name
166
+ profile.name = name;
167
+ return profile;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Read a profile's custom rules (if any)
175
+ */
176
+ export function readProfileRules(name: string): string | null {
177
+ const rulesPath = getProfileRulesPath(name);
178
+
179
+ if (!existsSync(rulesPath)) {
180
+ return null;
181
+ }
182
+
183
+ try {
184
+ return readFileSync(rulesPath, "utf-8");
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Write a profile's configuration
192
+ */
193
+ export function writeProfile(name: string, profile: Profile): void {
194
+ ensureProfilesDir();
195
+
196
+ const profileDir = getProfileDir(name);
197
+ if (!existsSync(profileDir)) {
198
+ mkdirSync(profileDir, { recursive: true });
199
+ }
200
+
201
+ // Update metadata
202
+ const now = new Date().toISOString();
203
+ if (!profile.createdAt) {
204
+ profile.createdAt = now;
205
+ }
206
+ profile.modifiedAt = now;
207
+ profile.name = name;
208
+
209
+ const configPath = getProfileConfigPath(name);
210
+ writeFileSync(configPath, JSON.stringify(profile, null, 2), "utf-8");
211
+ }
212
+
213
+ /**
214
+ * Write custom rules for a profile
215
+ */
216
+ export function writeProfileRules(name: string, rules: string): void {
217
+ ensureProfilesDir();
218
+
219
+ const profileDir = getProfileDir(name);
220
+ if (!existsSync(profileDir)) {
221
+ mkdirSync(profileDir, { recursive: true });
222
+ }
223
+
224
+ const rulesPath = getProfileRulesPath(name);
225
+ writeFileSync(rulesPath, rules, "utf-8");
226
+ }
227
+
228
+ /**
229
+ * Delete a profile
230
+ */
231
+ export function deleteProfile(name: string): void {
232
+ const profileDir = getProfileDir(name);
233
+
234
+ if (existsSync(profileDir)) {
235
+ rmSync(profileDir, { recursive: true, force: true });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Copy a profile to a new name
241
+ */
242
+ export function copyProfile(source: string, destination: string): void {
243
+ const sourceProfile = readProfile(source);
244
+ if (!sourceProfile) {
245
+ throw new Error(`Source profile "${source}" not found`);
246
+ }
247
+
248
+ if (profileExists(destination)) {
249
+ throw new Error(`Destination profile "${destination}" already exists`);
250
+ }
251
+
252
+ // Copy config with new name and reset timestamps
253
+ const newProfile: Profile = {
254
+ ...sourceProfile,
255
+ name: destination,
256
+ createdAt: new Date().toISOString(),
257
+ modifiedAt: new Date().toISOString(),
258
+ };
259
+
260
+ writeProfile(destination, newProfile);
261
+
262
+ // Copy rules if they exist
263
+ const sourceRules = readProfileRules(source);
264
+ if (sourceRules) {
265
+ writeProfileRules(destination, sourceRules);
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Export a profile for sharing
271
+ */
272
+ export function exportProfile(name: string): ProfileExport {
273
+ const profile = readProfile(name);
274
+ if (!profile) {
275
+ throw new Error(`Profile "${name}" not found`);
276
+ }
277
+
278
+ const rules = readProfileRules(name);
279
+
280
+ return {
281
+ version: "1.0",
282
+ exportedAt: new Date().toISOString(),
283
+ profile,
284
+ rules: rules || undefined,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Import a profile from an export
290
+ * @param data The exported profile data
291
+ * @param overrideName Optional name to use instead of the one in the export
292
+ * @returns The name of the imported profile
293
+ */
294
+ export function importProfile(data: ProfileExport, overrideName?: string): string {
295
+ const name = overrideName || data.profile.name;
296
+
297
+ const validation = validateProfileName(name);
298
+ if (!validation.valid) {
299
+ throw new Error(validation.error);
300
+ }
301
+
302
+ if (profileExists(name)) {
303
+ throw new Error(`Profile "${name}" already exists`);
304
+ }
305
+
306
+ // Import with updated timestamps
307
+ const profile: Profile = {
308
+ ...data.profile,
309
+ name,
310
+ createdAt: new Date().toISOString(),
311
+ modifiedAt: new Date().toISOString(),
312
+ };
313
+
314
+ writeProfile(name, profile);
315
+
316
+ // Import rules if present
317
+ if (data.rules) {
318
+ writeProfileRules(name, data.rules);
319
+ }
320
+
321
+ return name;
322
+ }
323
+
324
+ /**
325
+ * Get the config fields that should be merged from a profile
326
+ * (excludes metadata fields)
327
+ */
328
+ export function getProfileConfigFields(profile: Profile): Partial<TidyConfig> {
329
+ const { name, description, createdAt, modifiedAt, ...configFields } = profile;
330
+ return configFields;
331
+ }
332
+
333
+ /**
334
+ * Install a built-in preset as a profile
335
+ * @param presetName The name of the preset (developer, creative, student, downloads)
336
+ * @param profileName Optional custom name for the installed profile
337
+ * @returns The name of the installed profile
338
+ */
339
+ export function installPreset(presetName: string, profileName?: string): string {
340
+ const preset = getPreset(presetName);
341
+ if (!preset) {
342
+ throw new Error(`Preset "${presetName}" not found. Available: developer, creative, student, downloads`);
343
+ }
344
+
345
+ const name = profileName || presetName;
346
+
347
+ const validation = validateProfileName(name);
348
+ if (!validation.valid) {
349
+ throw new Error(validation.error);
350
+ }
351
+
352
+ if (profileExists(name)) {
353
+ throw new Error(`Profile "${name}" already exists. Use a different name or delete the existing profile.`);
354
+ }
355
+
356
+ const profile: Profile = {
357
+ ...preset.profile,
358
+ name,
359
+ createdAt: new Date().toISOString(),
360
+ modifiedAt: new Date().toISOString(),
361
+ };
362
+
363
+ writeProfile(name, profile);
364
+ writeProfileRules(name, preset.rules);
365
+
366
+ return name;
367
+ }
@@ -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
+ }