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.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/dist/cli.js +19340 -0
- package/dist/fsevents-hj42pnne.node +0 -0
- package/dist/index.js +17617 -0
- package/package.json +58 -0
- package/src/cli.ts +63 -0
- package/src/commands/config.ts +630 -0
- package/src/commands/organize.ts +396 -0
- package/src/commands/watch.ts +302 -0
- package/src/index.ts +93 -0
- package/src/lib/config.ts +335 -0
- package/src/lib/opencode.ts +380 -0
- package/src/lib/scanner.ts +296 -0
- package/src/lib/watcher.ts +151 -0
- package/src/types/config.ts +69 -0
- package/src/types/organizer.ts +144 -0
- package/src/utils/files.ts +198 -0
- package/src/utils/icons.ts +195 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI integration for tidy using @opencode-ai/sdk
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as p from "@clack/prompts";
|
|
6
|
+
import {
|
|
7
|
+
createOpencode,
|
|
8
|
+
createOpencodeClient,
|
|
9
|
+
type OpencodeClient,
|
|
10
|
+
} from "@opencode-ai/sdk";
|
|
11
|
+
import { exec } from "child_process";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import color from "picocolors";
|
|
14
|
+
import { promisify } from "util";
|
|
15
|
+
import type { ModelSelection } from "../types/config.ts";
|
|
16
|
+
import type {
|
|
17
|
+
FileCategory,
|
|
18
|
+
FileMetadata,
|
|
19
|
+
FileMoveProposal,
|
|
20
|
+
OrganizationProposal,
|
|
21
|
+
} from "../types/organizer.ts";
|
|
22
|
+
import { fileExists } from "../utils/files.ts";
|
|
23
|
+
import { expandPath, getRulesPrompt, resolveConfig } from "./config.ts";
|
|
24
|
+
|
|
25
|
+
const execAsync = promisify(exec);
|
|
26
|
+
|
|
27
|
+
// Server state
|
|
28
|
+
let clientInstance: OpencodeClient | null = null;
|
|
29
|
+
let serverInstance: { close: () => void } | null = null;
|
|
30
|
+
let sessionId: string | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if opencode CLI is installed
|
|
34
|
+
*/
|
|
35
|
+
async function isOpencodeInstalled(): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
await execAsync("which opencode");
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if user is authenticated with opencode
|
|
46
|
+
*/
|
|
47
|
+
async function checkAuth(client: OpencodeClient): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
const config = await client.config.get();
|
|
50
|
+
return !!config;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get or create the Opencode client
|
|
58
|
+
* Tries to connect to existing server first, spawns new one if needed
|
|
59
|
+
*/
|
|
60
|
+
export async function getClient(): Promise<OpencodeClient> {
|
|
61
|
+
if (clientInstance) {
|
|
62
|
+
return clientInstance;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Try connecting to existing server first
|
|
66
|
+
try {
|
|
67
|
+
const client = createOpencodeClient({
|
|
68
|
+
baseUrl: "http://localhost:4096",
|
|
69
|
+
});
|
|
70
|
+
// Test connection
|
|
71
|
+
await client.config.get();
|
|
72
|
+
clientInstance = client;
|
|
73
|
+
return client;
|
|
74
|
+
} catch {
|
|
75
|
+
// No existing server, need to spawn one
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if opencode is installed
|
|
79
|
+
if (!(await isOpencodeInstalled())) {
|
|
80
|
+
p.log.error("OpenCode CLI is not installed");
|
|
81
|
+
p.log.info(
|
|
82
|
+
`Install it with: ${color.cyan("npm install -g opencode")} or ${color.cyan("brew install sst/tap/opencode")}`,
|
|
83
|
+
);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Spawn new server
|
|
88
|
+
try {
|
|
89
|
+
const opencode = await createOpencode({
|
|
90
|
+
timeout: 10000,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
clientInstance = opencode.client;
|
|
94
|
+
serverInstance = opencode.server;
|
|
95
|
+
|
|
96
|
+
// Check authentication
|
|
97
|
+
if (!(await checkAuth(opencode.client))) {
|
|
98
|
+
p.log.warn("Not authenticated with OpenCode");
|
|
99
|
+
p.log.info(`Run ${color.cyan("opencode auth")} to authenticate`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Clean up server on process exit
|
|
104
|
+
process.on("exit", () => {
|
|
105
|
+
serverInstance?.close();
|
|
106
|
+
});
|
|
107
|
+
process.on("SIGINT", () => {
|
|
108
|
+
serverInstance?.close();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
});
|
|
111
|
+
process.on("SIGTERM", () => {
|
|
112
|
+
serverInstance?.close();
|
|
113
|
+
process.exit(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return opencode.client;
|
|
117
|
+
} catch (error: any) {
|
|
118
|
+
p.log.error(`Failed to start OpenCode server: ${error.message}`);
|
|
119
|
+
p.log.info(`Make sure OpenCode is installed and configured correctly`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create a new session for file analysis
|
|
126
|
+
*/
|
|
127
|
+
export async function createSession(): Promise<string> {
|
|
128
|
+
const opencodeClient = await getClient();
|
|
129
|
+
const session = await opencodeClient.session.create();
|
|
130
|
+
sessionId = session.data?.id ?? "";
|
|
131
|
+
return sessionId;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get current session ID or create one
|
|
136
|
+
*/
|
|
137
|
+
export async function getSessionId(): Promise<string> {
|
|
138
|
+
if (!sessionId) {
|
|
139
|
+
return createSession();
|
|
140
|
+
}
|
|
141
|
+
return sessionId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Clean up resources
|
|
146
|
+
*/
|
|
147
|
+
export function cleanup(): void {
|
|
148
|
+
if (sessionId && clientInstance) {
|
|
149
|
+
// Attempt to abort/clean up the session
|
|
150
|
+
clientInstance.session.abort({ path: { id: sessionId } }).catch(() => {
|
|
151
|
+
// Ignore errors during cleanup
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
sessionId = null;
|
|
155
|
+
serverInstance?.close();
|
|
156
|
+
serverInstance = null;
|
|
157
|
+
clientInstance = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Options for analyzing files
|
|
162
|
+
*/
|
|
163
|
+
export interface AnalyzeFilesOptions {
|
|
164
|
+
/** Files to analyze */
|
|
165
|
+
files: FileMetadata[];
|
|
166
|
+
/** Target directory for organized files */
|
|
167
|
+
targetDir: string;
|
|
168
|
+
/** Additional instructions from user */
|
|
169
|
+
instructions?: string;
|
|
170
|
+
/** Model override */
|
|
171
|
+
model?: ModelSelection;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format file metadata for AI prompt
|
|
176
|
+
*/
|
|
177
|
+
function formatFilesForPrompt(files: FileMetadata[]): string {
|
|
178
|
+
const formatted = files.map((f) => ({
|
|
179
|
+
name: f.name,
|
|
180
|
+
extension: f.extension,
|
|
181
|
+
size: f.size,
|
|
182
|
+
mimeType: f.mimeType,
|
|
183
|
+
modifiedAt: f.modifiedAt.toISOString(),
|
|
184
|
+
contentPreview: f.contentPreview?.slice(0, 500), // Limit preview size
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
return JSON.stringify(formatted, null, 2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse AI response into OrganizationProposal
|
|
192
|
+
*/
|
|
193
|
+
function parseAIResponse(
|
|
194
|
+
response: string,
|
|
195
|
+
files: FileMetadata[],
|
|
196
|
+
targetDir: string,
|
|
197
|
+
): OrganizationProposal {
|
|
198
|
+
// Try to extract JSON from the response
|
|
199
|
+
let jsonStr = response;
|
|
200
|
+
|
|
201
|
+
// Try to find JSON in the response if wrapped in text
|
|
202
|
+
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
|
203
|
+
if (jsonMatch) {
|
|
204
|
+
jsonStr = jsonMatch[0];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(jsonStr);
|
|
209
|
+
|
|
210
|
+
// Build a map of files by name for lookup
|
|
211
|
+
const fileMap = new Map<string, FileMetadata>();
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
fileMap.set(file.name, file);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const proposals: FileMoveProposal[] = [];
|
|
217
|
+
const uncategorized: FileMetadata[] = [];
|
|
218
|
+
|
|
219
|
+
// Process proposals from AI
|
|
220
|
+
if (parsed.proposals && Array.isArray(parsed.proposals)) {
|
|
221
|
+
for (const p of parsed.proposals) {
|
|
222
|
+
const fileName = p.file || p.filename || p.name;
|
|
223
|
+
const file = fileMap.get(fileName);
|
|
224
|
+
|
|
225
|
+
if (!file) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Remove from map to track processed files
|
|
230
|
+
fileMap.delete(fileName);
|
|
231
|
+
|
|
232
|
+
const destination = join(
|
|
233
|
+
expandPath(targetDir),
|
|
234
|
+
p.destination || p.suggestedPath || "",
|
|
235
|
+
file.name,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const category: FileCategory = {
|
|
239
|
+
name: p.category?.name || "Other",
|
|
240
|
+
subcategory: p.category?.subcategory,
|
|
241
|
+
suggestedPath: p.destination || p.category?.suggestedPath || "",
|
|
242
|
+
confidence: p.category?.confidence || 0.5,
|
|
243
|
+
reasoning: p.category?.reasoning || p.reasoning || "",
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
proposals.push({
|
|
247
|
+
sourcePath: file.path,
|
|
248
|
+
file,
|
|
249
|
+
destination,
|
|
250
|
+
category,
|
|
251
|
+
conflictExists: false, // Will be checked later
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Process uncategorized files
|
|
257
|
+
if (parsed.uncategorized && Array.isArray(parsed.uncategorized)) {
|
|
258
|
+
for (const fileName of parsed.uncategorized) {
|
|
259
|
+
const file = fileMap.get(fileName);
|
|
260
|
+
if (file) {
|
|
261
|
+
uncategorized.push(file);
|
|
262
|
+
fileMap.delete(fileName);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Any remaining files in the map are also uncategorized
|
|
268
|
+
for (const file of fileMap.values()) {
|
|
269
|
+
uncategorized.push(file);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
proposals,
|
|
274
|
+
strategy: parsed.strategy || parsed.overall_reasoning || "",
|
|
275
|
+
uncategorized,
|
|
276
|
+
analyzedAt: new Date(),
|
|
277
|
+
};
|
|
278
|
+
} catch {
|
|
279
|
+
// If parsing fails, treat all files as uncategorized
|
|
280
|
+
return {
|
|
281
|
+
proposals: [],
|
|
282
|
+
strategy: "Failed to parse AI response",
|
|
283
|
+
uncategorized: [...files],
|
|
284
|
+
analyzedAt: new Date(),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Check for file conflicts in proposals
|
|
291
|
+
*/
|
|
292
|
+
export async function checkConflicts(
|
|
293
|
+
proposal: OrganizationProposal,
|
|
294
|
+
): Promise<OrganizationProposal> {
|
|
295
|
+
const updatedProposals = await Promise.all(
|
|
296
|
+
proposal.proposals.map(async (p) => ({
|
|
297
|
+
...p,
|
|
298
|
+
conflictExists: await fileExists(p.destination),
|
|
299
|
+
})),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
...proposal,
|
|
304
|
+
proposals: updatedProposals,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Analyze files using AI
|
|
310
|
+
*/
|
|
311
|
+
export async function analyzeFiles(
|
|
312
|
+
options: AnalyzeFilesOptions,
|
|
313
|
+
): Promise<OrganizationProposal> {
|
|
314
|
+
const { files, targetDir, instructions, model } = options;
|
|
315
|
+
|
|
316
|
+
if (files.length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
proposals: [],
|
|
319
|
+
strategy: "No files to analyze",
|
|
320
|
+
uncategorized: [],
|
|
321
|
+
analyzedAt: new Date(),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const opencodeClient = await getClient();
|
|
326
|
+
const sid = await getSessionId();
|
|
327
|
+
|
|
328
|
+
// Get configuration
|
|
329
|
+
const config = resolveConfig();
|
|
330
|
+
const rulesPrompt = getRulesPrompt();
|
|
331
|
+
|
|
332
|
+
// Build the prompt
|
|
333
|
+
const filesJson = formatFilesForPrompt(files);
|
|
334
|
+
|
|
335
|
+
const userPrompt = `
|
|
336
|
+
Analyze the following files and organize them according to the rules.
|
|
337
|
+
|
|
338
|
+
Target directory: ${targetDir}
|
|
339
|
+
|
|
340
|
+
${instructions ? `Additional instructions: ${instructions}\n` : ""}
|
|
341
|
+
Files to organize:
|
|
342
|
+
${filesJson}
|
|
343
|
+
|
|
344
|
+
Respond with ONLY a JSON object (no markdown code blocks) following the format specified in the rules.
|
|
345
|
+
`;
|
|
346
|
+
|
|
347
|
+
// Send the message and get response using session.prompt
|
|
348
|
+
const response = await opencodeClient.session.prompt({
|
|
349
|
+
path: { id: sid },
|
|
350
|
+
body: {
|
|
351
|
+
system: rulesPrompt,
|
|
352
|
+
model: {
|
|
353
|
+
providerID: model?.provider || config.organizer?.provider || "opencode",
|
|
354
|
+
modelID: model?.model || config.organizer?.model || "claude-sonnet-4-5",
|
|
355
|
+
},
|
|
356
|
+
parts: [{ type: "text", text: userPrompt }],
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// Extract the text content from the response
|
|
361
|
+
const parts = response.data?.parts || [];
|
|
362
|
+
const responseText = parts
|
|
363
|
+
.filter((p) => p.type === "text" && "text" in p)
|
|
364
|
+
.map((p) => (p as { type: "text"; text: string }).text)
|
|
365
|
+
.join("");
|
|
366
|
+
|
|
367
|
+
// Parse the response
|
|
368
|
+
const proposal = parseAIResponse(responseText, files, targetDir);
|
|
369
|
+
|
|
370
|
+
// Check for conflicts
|
|
371
|
+
return checkConflicts(proposal);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Get available models
|
|
376
|
+
*/
|
|
377
|
+
export async function getAvailableModels() {
|
|
378
|
+
const client = await getClient();
|
|
379
|
+
return client.config.providers();
|
|
380
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File scanning utilities for tidy
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdir, stat, readFile } from "fs/promises";
|
|
6
|
+
import { join, extname, basename } from "path";
|
|
7
|
+
import { lookup as lookupMimeType } from "mime-types";
|
|
8
|
+
import type { FileMetadata } from "../types/organizer.ts";
|
|
9
|
+
import { shouldIgnore } from "./config.ts";
|
|
10
|
+
import { isDirectory, isFile } from "../utils/files.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Options for scanning a directory
|
|
14
|
+
*/
|
|
15
|
+
export interface ScanOptions {
|
|
16
|
+
/** Scan subdirectories */
|
|
17
|
+
recursive?: boolean;
|
|
18
|
+
/** Max depth for recursive scan (0 = no limit) */
|
|
19
|
+
maxDepth?: number;
|
|
20
|
+
/** Patterns to ignore */
|
|
21
|
+
ignore?: string[];
|
|
22
|
+
/** Whether to read file content preview */
|
|
23
|
+
readContent?: boolean;
|
|
24
|
+
/** Max file size to read content (bytes) */
|
|
25
|
+
maxContentSize?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scan a directory and return metadata for all files
|
|
30
|
+
*/
|
|
31
|
+
export async function scanDirectory(
|
|
32
|
+
dirPath: string,
|
|
33
|
+
options: ScanOptions = {}
|
|
34
|
+
): Promise<FileMetadata[]> {
|
|
35
|
+
const {
|
|
36
|
+
recursive = false,
|
|
37
|
+
maxDepth = 1,
|
|
38
|
+
ignore = [],
|
|
39
|
+
readContent = false,
|
|
40
|
+
maxContentSize = 10240,
|
|
41
|
+
} = options;
|
|
42
|
+
|
|
43
|
+
return scanDirectoryInternal(dirPath, {
|
|
44
|
+
recursive,
|
|
45
|
+
maxDepth,
|
|
46
|
+
ignore,
|
|
47
|
+
readContent,
|
|
48
|
+
maxContentSize,
|
|
49
|
+
currentDepth: 0,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface InternalScanOptions extends ScanOptions {
|
|
54
|
+
currentDepth: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function scanDirectoryInternal(
|
|
58
|
+
dirPath: string,
|
|
59
|
+
options: InternalScanOptions
|
|
60
|
+
): Promise<FileMetadata[]> {
|
|
61
|
+
const files: FileMetadata[] = [];
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const fullPath = join(dirPath, entry.name);
|
|
68
|
+
|
|
69
|
+
// Check if should be ignored
|
|
70
|
+
if (shouldIgnore(entry.name, options.ignore || [])) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (entry.isFile()) {
|
|
75
|
+
const metadata = await getFileMetadata(fullPath, {
|
|
76
|
+
readContent: options.readContent,
|
|
77
|
+
maxContentSize: options.maxContentSize,
|
|
78
|
+
});
|
|
79
|
+
if (metadata) {
|
|
80
|
+
files.push(metadata);
|
|
81
|
+
}
|
|
82
|
+
} else if (
|
|
83
|
+
entry.isDirectory() &&
|
|
84
|
+
options.recursive &&
|
|
85
|
+
((options.maxDepth ?? 0) === 0 || options.currentDepth < (options.maxDepth ?? 0))
|
|
86
|
+
) {
|
|
87
|
+
// Recursively scan subdirectory
|
|
88
|
+
const subFiles = await scanDirectoryInternal(fullPath, {
|
|
89
|
+
...options,
|
|
90
|
+
currentDepth: options.currentDepth + 1,
|
|
91
|
+
});
|
|
92
|
+
files.push(...subFiles);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch (error: any) {
|
|
96
|
+
// Silently skip directories we can't read
|
|
97
|
+
console.error(`Warning: Could not scan ${dirPath}: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return files;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get metadata for a single file
|
|
105
|
+
*/
|
|
106
|
+
export async function getFileMetadata(
|
|
107
|
+
filePath: string,
|
|
108
|
+
options: { readContent?: boolean; maxContentSize?: number } = {}
|
|
109
|
+
): Promise<FileMetadata | null> {
|
|
110
|
+
try {
|
|
111
|
+
const stats = await stat(filePath);
|
|
112
|
+
|
|
113
|
+
if (!stats.isFile()) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const name = basename(filePath);
|
|
118
|
+
const extension = extname(name).slice(1).toLowerCase();
|
|
119
|
+
const mimeType = lookupMimeType(name) || undefined;
|
|
120
|
+
|
|
121
|
+
let contentPreview: string | undefined;
|
|
122
|
+
if (
|
|
123
|
+
options.readContent &&
|
|
124
|
+
stats.size <= (options.maxContentSize || 10240)
|
|
125
|
+
) {
|
|
126
|
+
contentPreview = await readFilePreview(
|
|
127
|
+
filePath,
|
|
128
|
+
options.maxContentSize || 10240,
|
|
129
|
+
mimeType
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
path: filePath,
|
|
135
|
+
name,
|
|
136
|
+
extension,
|
|
137
|
+
size: stats.size,
|
|
138
|
+
modifiedAt: stats.mtime,
|
|
139
|
+
createdAt: stats.birthtime,
|
|
140
|
+
mimeType,
|
|
141
|
+
contentPreview,
|
|
142
|
+
};
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read a preview of a file's content
|
|
150
|
+
*/
|
|
151
|
+
export async function readFilePreview(
|
|
152
|
+
filePath: string,
|
|
153
|
+
maxSize: number,
|
|
154
|
+
mimeType?: string
|
|
155
|
+
): Promise<string | undefined> {
|
|
156
|
+
try {
|
|
157
|
+
// Only read text-based files
|
|
158
|
+
if (!isTextFile(mimeType)) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const buffer = Buffer.alloc(maxSize);
|
|
163
|
+
const file = await readFile(filePath);
|
|
164
|
+
const bytesToRead = Math.min(file.length, maxSize);
|
|
165
|
+
file.copy(buffer, 0, 0, bytesToRead);
|
|
166
|
+
|
|
167
|
+
const content = buffer.toString("utf-8", 0, bytesToRead);
|
|
168
|
+
|
|
169
|
+
// Clean up the content - first 20 lines
|
|
170
|
+
const lines = content.split("\n").slice(0, 20);
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
} catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if a file is text-based (readable)
|
|
179
|
+
*/
|
|
180
|
+
function isTextFile(mimeType?: string): boolean {
|
|
181
|
+
if (!mimeType) return false;
|
|
182
|
+
|
|
183
|
+
const textMimeTypes = [
|
|
184
|
+
"text/",
|
|
185
|
+
"application/json",
|
|
186
|
+
"application/javascript",
|
|
187
|
+
"application/typescript",
|
|
188
|
+
"application/xml",
|
|
189
|
+
"application/x-yaml",
|
|
190
|
+
"application/x-sh",
|
|
191
|
+
"application/x-python",
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
return textMimeTypes.some(
|
|
195
|
+
(type) => mimeType.startsWith(type) || mimeType === type
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Get file category based on extension
|
|
201
|
+
*/
|
|
202
|
+
export function getFileCategory(extension: string): string {
|
|
203
|
+
const categories: Record<string, string[]> = {
|
|
204
|
+
Documents: [
|
|
205
|
+
"pdf",
|
|
206
|
+
"doc",
|
|
207
|
+
"docx",
|
|
208
|
+
"txt",
|
|
209
|
+
"rtf",
|
|
210
|
+
"odt",
|
|
211
|
+
"md",
|
|
212
|
+
"pages",
|
|
213
|
+
],
|
|
214
|
+
Spreadsheets: ["xls", "xlsx", "csv", "ods", "numbers"],
|
|
215
|
+
Presentations: ["ppt", "pptx", "key", "odp"],
|
|
216
|
+
Images: [
|
|
217
|
+
"jpg",
|
|
218
|
+
"jpeg",
|
|
219
|
+
"png",
|
|
220
|
+
"gif",
|
|
221
|
+
"svg",
|
|
222
|
+
"webp",
|
|
223
|
+
"heic",
|
|
224
|
+
"ico",
|
|
225
|
+
"bmp",
|
|
226
|
+
"tiff",
|
|
227
|
+
"psd",
|
|
228
|
+
"ai",
|
|
229
|
+
"sketch",
|
|
230
|
+
"fig",
|
|
231
|
+
],
|
|
232
|
+
Videos: ["mp4", "mov", "avi", "mkv", "webm", "wmv", "flv", "m4v"],
|
|
233
|
+
Audio: ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"],
|
|
234
|
+
Archives: ["zip", "rar", "7z", "tar", "gz", "bz2", "xz"],
|
|
235
|
+
Code: [
|
|
236
|
+
"ts",
|
|
237
|
+
"tsx",
|
|
238
|
+
"js",
|
|
239
|
+
"jsx",
|
|
240
|
+
"py",
|
|
241
|
+
"rb",
|
|
242
|
+
"go",
|
|
243
|
+
"rs",
|
|
244
|
+
"java",
|
|
245
|
+
"c",
|
|
246
|
+
"cpp",
|
|
247
|
+
"h",
|
|
248
|
+
"hpp",
|
|
249
|
+
"cs",
|
|
250
|
+
"swift",
|
|
251
|
+
"kt",
|
|
252
|
+
"php",
|
|
253
|
+
"html",
|
|
254
|
+
"css",
|
|
255
|
+
"scss",
|
|
256
|
+
"less",
|
|
257
|
+
"json",
|
|
258
|
+
"xml",
|
|
259
|
+
"yaml",
|
|
260
|
+
"yml",
|
|
261
|
+
"toml",
|
|
262
|
+
],
|
|
263
|
+
Applications: ["dmg", "pkg", "exe", "msi", "app", "apk", "ipa", "deb", "rpm"],
|
|
264
|
+
Ebooks: ["epub", "mobi", "azw", "azw3"],
|
|
265
|
+
Fonts: ["ttf", "otf", "woff", "woff2"],
|
|
266
|
+
Data: ["sql", "db", "sqlite"],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const ext = extension.toLowerCase();
|
|
270
|
+
for (const [category, extensions] of Object.entries(categories)) {
|
|
271
|
+
if (extensions.includes(ext)) {
|
|
272
|
+
return category;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return "Other";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Group files by their base category
|
|
281
|
+
*/
|
|
282
|
+
export function groupFilesByCategory(
|
|
283
|
+
files: FileMetadata[]
|
|
284
|
+
): Record<string, FileMetadata[]> {
|
|
285
|
+
const groups: Record<string, FileMetadata[]> = {};
|
|
286
|
+
|
|
287
|
+
for (const file of files) {
|
|
288
|
+
const category = getFileCategory(file.extension);
|
|
289
|
+
if (!groups[category]) {
|
|
290
|
+
groups[category] = [];
|
|
291
|
+
}
|
|
292
|
+
groups[category].push(file);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return groups;
|
|
296
|
+
}
|