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.
- package/dist/cli.js +18000 -15880
- package/dist/index.js +18450 -16419
- package/package.json +1 -1
- package/src/cli.ts +42 -14
- package/src/commands/config.ts +70 -1
- package/src/commands/organize.ts +679 -44
- package/src/commands/profile.ts +943 -0
- package/src/commands/undo.ts +139 -0
- package/src/commands/watch.ts +24 -2
- package/src/lib/config.ts +69 -0
- package/src/lib/history.ts +139 -0
- package/src/lib/opencode.ts +11 -5
- package/src/lib/presets.ts +257 -0
- package/src/lib/profiles.ts +367 -0
- package/src/types/organizer.ts +24 -0
- package/src/types/profile.ts +70 -0
- package/src/utils/files.ts +15 -1
|
@@ -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
|
+
}
|
package/src/types/organizer.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/files.ts
CHANGED
|
@@ -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
|
+
}
|