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,257 @@
1
+ /**
2
+ * Built-in profile presets for common use cases
3
+ */
4
+
5
+ import type { Profile } from "../types/profile.ts";
6
+
7
+ export interface PresetDefinition {
8
+ name: string;
9
+ description: string;
10
+ profile: Omit<Profile, "name" | "createdAt" | "modifiedAt">;
11
+ rules: string;
12
+ }
13
+
14
+ export const PRESET_DEFINITIONS: PresetDefinition[] = [
15
+ {
16
+ name: "developer",
17
+ description: "Organize source code, configs, and project files",
18
+ profile: {
19
+ description: "Developer-focused organization for code and configs",
20
+ defaultTarget: "~/Documents/Dev",
21
+ },
22
+ rules: `# Developer Profile Rules
23
+
24
+ You are organizing files for a software developer. Focus on project structure and code organization.
25
+
26
+ ## Categories
27
+
28
+ ### Code
29
+ - Source files: .ts, .tsx, .js, .jsx, .py, .go, .rs, .java, .c, .cpp, .swift, .kt
30
+ - Subcategorize by language family: JavaScript, Python, Go, Rust, Swift, etc.
31
+
32
+ ### Config
33
+ - Configuration files: .json, .yaml, .yml, .toml, .env, .ini
34
+ - Subcategorize: Project Config, Editor Config, CI/CD
35
+
36
+ ### Documentation
37
+ - README, CHANGELOG, LICENSE, .md files
38
+ - API docs, design docs
39
+
40
+ ### Data
41
+ - Database files, SQL scripts, seed data
42
+ - JSON/CSV data files
43
+
44
+ ### Scripts
45
+ - Shell scripts, batch files, automation scripts
46
+ - Build scripts, deployment scripts
47
+
48
+ ## Strategy
49
+
50
+ 1. **Group by project** - If filename contains project identifiers, keep files together
51
+ 2. **Separate configs from code** - Configs go to their own folder
52
+ 3. **Archive old files** - Anything with "old", "backup", or date suffixes
53
+
54
+ ## Output Format
55
+
56
+ Return JSON:
57
+ \`\`\`json
58
+ {
59
+ "proposals": [{ "file": "name", "destination": "Code/JavaScript/project", "category": {...} }],
60
+ "strategy": "...",
61
+ "uncategorized": []
62
+ }
63
+ \`\`\`
64
+ `,
65
+ },
66
+ {
67
+ name: "creative",
68
+ description: "Organize images, videos, design files, and media",
69
+ profile: {
70
+ description: "Creative workflow for designers and content creators",
71
+ defaultTarget: "~/Documents/Creative",
72
+ },
73
+ rules: `# Creative Profile Rules
74
+
75
+ You are organizing files for a designer or content creator. Focus on visual assets and media organization.
76
+
77
+ ## Categories
78
+
79
+ ### Images
80
+ - Photos: .jpg, .jpeg, .png, .heic, .raw, .cr2, .nef
81
+ - Graphics: .svg, .webp, .gif
82
+ - Subcategorize: Photos, Screenshots, Icons, Illustrations
83
+
84
+ ### Design
85
+ - Figma exports, Sketch files, PSD, AI
86
+ - Subcategorize by project or client name if detectable
87
+
88
+ ### Video
89
+ - .mp4, .mov, .avi, .mkv, .webm
90
+ - Subcategorize: Raw Footage, Exports, Clips
91
+
92
+ ### Audio
93
+ - .mp3, .wav, .aiff, .flac, .m4a
94
+ - Subcategorize: Music, SFX, Voiceover
95
+
96
+ ### Fonts
97
+ - .ttf, .otf, .woff, .woff2
98
+ - Keep in centralized Fonts folder
99
+
100
+ ### 3D
101
+ - .obj, .fbx, .blend, .gltf
102
+ - 3D models and assets
103
+
104
+ ## Strategy
105
+
106
+ 1. **Date-based for photos** - Organize photos by YYYY/MM if dates detected in filename
107
+ 2. **Project-based for design** - Group by client or project name
108
+ 3. **Keep exports together** - Files with "export", "final", "v2" stay in same project folder
109
+
110
+ ## Output Format
111
+
112
+ Return JSON:
113
+ \`\`\`json
114
+ {
115
+ "proposals": [{ "file": "name", "destination": "Images/Photos/2024/January", "category": {...} }],
116
+ "strategy": "...",
117
+ "uncategorized": []
118
+ }
119
+ \`\`\`
120
+ `,
121
+ },
122
+ {
123
+ name: "student",
124
+ description: "Organize documents, notes, and academic materials",
125
+ profile: {
126
+ description: "Academic organization for students and researchers",
127
+ defaultTarget: "~/Documents/School",
128
+ },
129
+ rules: `# Student Profile Rules
130
+
131
+ You are organizing files for a student. Focus on academic organization and study materials.
132
+
133
+ ## Categories
134
+
135
+ ### Notes
136
+ - Text files, markdown notes, OneNote exports
137
+ - Subcategorize by subject if detectable
138
+
139
+ ### Documents
140
+ - PDFs, Word docs, essays, reports
141
+ - Subcategorize: Assignments, Readings, Submissions
142
+
143
+ ### Slides
144
+ - PowerPoint, Keynote, Google Slides exports
145
+ - Lecture slides, presentations
146
+
147
+ ### Spreadsheets
148
+ - Excel, CSV, data analysis files
149
+ - Lab data, calculations
150
+
151
+ ### Textbooks
152
+ - E-books: .epub, .mobi, .pdf (large PDFs)
153
+ - Reference materials
154
+
155
+ ### Research
156
+ - Papers, citations, bibliography files
157
+ - Research data and notes
158
+
159
+ ## Strategy
160
+
161
+ 1. **Subject detection** - Look for subject names in filenames (math, history, physics, etc.)
162
+ 2. **Semester organization** - Group by term if dates/semesters detected
163
+ 3. **Assignment priority** - Files with "assignment", "hw", "lab" get special handling
164
+
165
+ ## Output Format
166
+
167
+ Return JSON:
168
+ \`\`\`json
169
+ {
170
+ "proposals": [{ "file": "name", "destination": "Notes/Physics/Chapter1", "category": {...} }],
171
+ "strategy": "...",
172
+ "uncategorized": []
173
+ }
174
+ \`\`\`
175
+ `,
176
+ },
177
+ {
178
+ name: "downloads",
179
+ description: "Aggressive cleanup for messy Downloads folders",
180
+ profile: {
181
+ description: "Fast cleanup for Downloads folder chaos",
182
+ defaultSource: "~/Downloads",
183
+ defaultTarget: "~/Documents/Organized",
184
+ },
185
+ rules: `# Downloads Cleanup Profile
186
+
187
+ You are aggressively cleaning a messy Downloads folder. Be decisive and organize everything.
188
+
189
+ ## Categories
190
+
191
+ ### Installers
192
+ - DMG, PKG, EXE, MSI, APP, DEB, RPM
193
+ - Move to Installers folder, suggest deletion after install
194
+
195
+ ### Archives
196
+ - ZIP, RAR, 7Z, TAR.GZ
197
+ - Keep in Archives, note if should be extracted
198
+
199
+ ### Documents
200
+ - PDF, DOCX, XLSX, PPTX, TXT
201
+ - Subcategorize: Receipts, Manuals, Forms, Other
202
+
203
+ ### Images
204
+ - All image formats
205
+ - Screenshots go to Screenshots subfolder
206
+
207
+ ### Videos
208
+ - All video formats
209
+ - Downloads, Clips, Tutorials
210
+
211
+ ### Audio
212
+ - All audio formats
213
+ - Music, Podcasts, Recordings
214
+
215
+ ### Code
216
+ - Source files, scripts, configs
217
+ - Move to Development folder
218
+
219
+ ### Temporary
220
+ - .tmp, .part, .crdownload, incomplete downloads
221
+ - Flag for deletion
222
+
223
+ ## Strategy
224
+
225
+ 1. **Be aggressive** - Everything gets categorized, nothing stays in Downloads
226
+ 2. **Detect duplicates** - Files like "file (1).pdf" are duplicates
227
+ 3. **Date cleanup** - Old files (>30 days) can go to Archive
228
+ 4. **Installers cleanup** - Suggest keeping only latest versions
229
+
230
+ ## Output Format
231
+
232
+ Return JSON:
233
+ \`\`\`json
234
+ {
235
+ "proposals": [{ "file": "name", "destination": "Documents/Receipts", "category": {...} }],
236
+ "strategy": "...",
237
+ "uncategorized": []
238
+ }
239
+ \`\`\`
240
+ `,
241
+ },
242
+ ];
243
+
244
+ export function getPresetNames(): string[] {
245
+ return PRESET_DEFINITIONS.map((p) => p.name);
246
+ }
247
+
248
+ export function getPreset(name: string): PresetDefinition | undefined {
249
+ return PRESET_DEFINITIONS.find((p) => p.name === name);
250
+ }
251
+
252
+ export function listPresets(): { name: string; description: string }[] {
253
+ return PRESET_DEFINITIONS.map((p) => ({
254
+ name: p.name,
255
+ description: p.description,
256
+ }));
257
+ }
@@ -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
+ }