@amirdaraee/namewise 0.3.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/.github/ISSUE_TEMPLATE/bug_report.yml +82 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +61 -0
- package/.github/workflows/auto-release.yml +78 -0
- package/.github/workflows/ci.yml +78 -0
- package/.github/workflows/publish.yml +43 -0
- package/.github/workflows/test.yml +37 -0
- package/CHANGELOG.md +128 -0
- package/LICENSE +21 -0
- package/README.md +251 -0
- package/dist/cli/commands.d.ts +3 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +19 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/rename.d.ts +2 -0
- package/dist/cli/rename.d.ts.map +1 -0
- package/dist/cli/rename.js +136 -0
- package/dist/cli/rename.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/excel-parser.d.ts +6 -0
- package/dist/parsers/excel-parser.d.ts.map +1 -0
- package/dist/parsers/excel-parser.js +42 -0
- package/dist/parsers/excel-parser.js.map +1 -0
- package/dist/parsers/factory.d.ts +7 -0
- package/dist/parsers/factory.d.ts.map +1 -0
- package/dist/parsers/factory.js +29 -0
- package/dist/parsers/factory.js.map +1 -0
- package/dist/parsers/pdf-parser.d.ts +7 -0
- package/dist/parsers/pdf-parser.d.ts.map +1 -0
- package/dist/parsers/pdf-parser.js +67 -0
- package/dist/parsers/pdf-parser.js.map +1 -0
- package/dist/parsers/text-parser.d.ts +6 -0
- package/dist/parsers/text-parser.d.ts.map +1 -0
- package/dist/parsers/text-parser.js +39 -0
- package/dist/parsers/text-parser.js.map +1 -0
- package/dist/parsers/word-parser.d.ts +6 -0
- package/dist/parsers/word-parser.d.ts.map +1 -0
- package/dist/parsers/word-parser.js +44 -0
- package/dist/parsers/word-parser.js.map +1 -0
- package/dist/services/ai-factory.d.ts +5 -0
- package/dist/services/ai-factory.d.ts.map +1 -0
- package/dist/services/ai-factory.js +15 -0
- package/dist/services/ai-factory.js.map +1 -0
- package/dist/services/claude-service.d.ts +9 -0
- package/dist/services/claude-service.d.ts.map +1 -0
- package/dist/services/claude-service.js +113 -0
- package/dist/services/claude-service.js.map +1 -0
- package/dist/services/file-renamer.d.ts +12 -0
- package/dist/services/file-renamer.d.ts.map +1 -0
- package/dist/services/file-renamer.js +99 -0
- package/dist/services/file-renamer.js.map +1 -0
- package/dist/services/openai-service.d.ts +9 -0
- package/dist/services/openai-service.d.ts.map +1 -0
- package/dist/services/openai-service.js +112 -0
- package/dist/services/openai-service.js.map +1 -0
- package/dist/types/index.d.ts +61 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/file-templates.d.ts +18 -0
- package/dist/utils/file-templates.d.ts.map +1 -0
- package/dist/utils/file-templates.js +232 -0
- package/dist/utils/file-templates.js.map +1 -0
- package/dist/utils/naming-conventions.d.ts +4 -0
- package/dist/utils/naming-conventions.d.ts.map +1 -0
- package/dist/utils/naming-conventions.js +55 -0
- package/dist/utils/naming-conventions.js.map +1 -0
- package/package.json +75 -0
- package/src/cli/commands.ts +20 -0
- package/src/cli/rename.ts +157 -0
- package/src/index.ts +17 -0
- package/src/parsers/excel-parser.ts +49 -0
- package/src/parsers/factory.ts +34 -0
- package/src/parsers/pdf-parser.ts +78 -0
- package/src/parsers/text-parser.ts +43 -0
- package/src/parsers/word-parser.ts +50 -0
- package/src/services/ai-factory.ts +16 -0
- package/src/services/claude-service.ts +114 -0
- package/src/services/file-renamer.ts +123 -0
- package/src/services/openai-service.ts +113 -0
- package/src/types/index.ts +71 -0
- package/src/types/pdf-extraction.d.ts +7 -0
- package/src/utils/file-templates.ts +275 -0
- package/src/utils/naming-conventions.ts +67 -0
- package/tests/data/empty-file.txt +0 -0
- package/tests/data/sample-markdown.md +9 -0
- package/tests/data/sample-pdf.pdf +0 -0
- package/tests/data/sample-text.txt +25 -0
- package/tests/integration/end-to-end.test.ts +209 -0
- package/tests/integration/workflow.test.ts +336 -0
- package/tests/mocks/mock-ai-service.ts +58 -0
- package/tests/unit/cli/commands.test.ts +163 -0
- package/tests/unit/parsers/factory.test.ts +100 -0
- package/tests/unit/parsers/pdf-parser.test.ts +63 -0
- package/tests/unit/parsers/text-parser.test.ts +85 -0
- package/tests/unit/services/ai-factory.test.ts +37 -0
- package/tests/unit/services/claude-service.test.ts +188 -0
- package/tests/unit/services/file-renamer.test.ts +299 -0
- package/tests/unit/services/openai-service.test.ts +196 -0
- package/tests/unit/utils/file-templates.test.ts +199 -0
- package/tests/unit/utils/naming-conventions.test.ts +88 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +30 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DocumentParser } from '../types/index.js';
|
|
2
|
+
import { PDFParser } from './pdf-parser.js';
|
|
3
|
+
import { WordParser } from './word-parser.js';
|
|
4
|
+
import { ExcelParser } from './excel-parser.js';
|
|
5
|
+
import { TextParser } from './text-parser.js';
|
|
6
|
+
|
|
7
|
+
export class DocumentParserFactory {
|
|
8
|
+
private parsers: DocumentParser[] = [
|
|
9
|
+
new PDFParser(),
|
|
10
|
+
new WordParser(),
|
|
11
|
+
new ExcelParser(),
|
|
12
|
+
new TextParser()
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
getParser(filePath: string): DocumentParser | null {
|
|
16
|
+
return this.parsers.find(parser => parser.supports(filePath)) || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getSupportedExtensions(): string[] {
|
|
20
|
+
const extensions = new Set<string>();
|
|
21
|
+
|
|
22
|
+
// Add known extensions based on parser implementations
|
|
23
|
+
extensions.add('.pdf');
|
|
24
|
+
extensions.add('.docx');
|
|
25
|
+
extensions.add('.doc');
|
|
26
|
+
extensions.add('.xlsx');
|
|
27
|
+
extensions.add('.xls');
|
|
28
|
+
extensions.add('.txt');
|
|
29
|
+
extensions.add('.md');
|
|
30
|
+
extensions.add('.rtf');
|
|
31
|
+
|
|
32
|
+
return Array.from(extensions);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { DocumentParser, ParseResult, DocumentMetadata } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
export class PDFParser implements DocumentParser {
|
|
6
|
+
supports(filePath: string): boolean {
|
|
7
|
+
return path.extname(filePath).toLowerCase() === '.pdf';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async parse(filePath: string): Promise<ParseResult> {
|
|
11
|
+
try {
|
|
12
|
+
// Dynamic import for pdf-extraction (default export)
|
|
13
|
+
const pdfExtraction = await import('pdf-extraction');
|
|
14
|
+
const extract = pdfExtraction.default;
|
|
15
|
+
|
|
16
|
+
const dataBuffer = fs.readFileSync(filePath);
|
|
17
|
+
const data = await extract(dataBuffer, {});
|
|
18
|
+
|
|
19
|
+
const content = data.text?.trim() || '';
|
|
20
|
+
|
|
21
|
+
// Extract PDF metadata if available
|
|
22
|
+
const metadata: DocumentMetadata = {};
|
|
23
|
+
|
|
24
|
+
// Cast data to any to access potentially existing metadata properties
|
|
25
|
+
const pdfData = data as any;
|
|
26
|
+
|
|
27
|
+
if (pdfData.meta) {
|
|
28
|
+
if (pdfData.meta.info) {
|
|
29
|
+
metadata.title = pdfData.meta.info.Title;
|
|
30
|
+
metadata.author = pdfData.meta.info.Author;
|
|
31
|
+
metadata.creator = pdfData.meta.info.Creator;
|
|
32
|
+
metadata.subject = pdfData.meta.info.Subject;
|
|
33
|
+
|
|
34
|
+
// Parse dates if available
|
|
35
|
+
if (pdfData.meta.info.CreationDate) {
|
|
36
|
+
metadata.creationDate = this.parseDate(pdfData.meta.info.CreationDate);
|
|
37
|
+
}
|
|
38
|
+
if (pdfData.meta.info.ModDate) {
|
|
39
|
+
metadata.modificationDate = this.parseDate(pdfData.meta.info.ModDate);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (pdfData.numpages) {
|
|
44
|
+
metadata.pages = pdfData.numpages;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Estimate word count from text content
|
|
49
|
+
if (content) {
|
|
50
|
+
metadata.wordCount = content.split(/\s+/).filter(word => word.length > 0).length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { content, metadata };
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(`Failed to parse PDF file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private parseDate(dateStr: string): Date | undefined {
|
|
60
|
+
try {
|
|
61
|
+
// PDF dates are in format: D:YYYYMMDDHHmmSSOHH'mm or D:YYYYMMDDHHMMSS
|
|
62
|
+
if (dateStr.startsWith('D:')) {
|
|
63
|
+
const datepart = dateStr.slice(2, 16); // YYYYMMDDHHMMSS
|
|
64
|
+
const year = parseInt(datepart.slice(0, 4));
|
|
65
|
+
const month = parseInt(datepart.slice(4, 6)) - 1; // Month is 0-based
|
|
66
|
+
const day = parseInt(datepart.slice(6, 8));
|
|
67
|
+
const hour = parseInt(datepart.slice(8, 10) || '0');
|
|
68
|
+
const minute = parseInt(datepart.slice(10, 12) || '0');
|
|
69
|
+
const second = parseInt(datepart.slice(12, 14) || '0');
|
|
70
|
+
|
|
71
|
+
return new Date(year, month, day, hour, minute, second);
|
|
72
|
+
}
|
|
73
|
+
return new Date(dateStr);
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { DocumentParser, ParseResult, DocumentMetadata } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
export class TextParser implements DocumentParser {
|
|
6
|
+
supports(filePath: string): boolean {
|
|
7
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
8
|
+
return ext === '.txt' || ext === '.md' || ext === '.rtf';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async parse(filePath: string): Promise<ParseResult> {
|
|
12
|
+
try {
|
|
13
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
14
|
+
const metadata: DocumentMetadata = {};
|
|
15
|
+
|
|
16
|
+
// Extract basic metadata from content
|
|
17
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
18
|
+
|
|
19
|
+
if (lines.length > 0) {
|
|
20
|
+
// For markdown files, look for title in first heading
|
|
21
|
+
if (path.extname(filePath).toLowerCase() === '.md') {
|
|
22
|
+
const firstLine = lines[0];
|
|
23
|
+
if (firstLine.startsWith('# ')) {
|
|
24
|
+
metadata.title = firstLine.substring(2).trim();
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
// For other text files, use first non-empty line as potential title
|
|
28
|
+
const firstNonEmptyLine = lines[0];
|
|
29
|
+
if (firstNonEmptyLine.length < 100 && !firstNonEmptyLine.endsWith('.')) {
|
|
30
|
+
metadata.title = firstNonEmptyLine.trim();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Word count
|
|
35
|
+
metadata.wordCount = content.split(/\s+/).filter(word => word.length > 0).length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { content, metadata };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(`Failed to parse text file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import mammoth from 'mammoth';
|
|
4
|
+
import { DocumentParser, ParseResult, DocumentMetadata } from '../types/index.js';
|
|
5
|
+
|
|
6
|
+
export class WordParser implements DocumentParser {
|
|
7
|
+
supports(filePath: string): boolean {
|
|
8
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
9
|
+
return ext === '.docx' || ext === '.doc';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async parse(filePath: string): Promise<ParseResult> {
|
|
13
|
+
try {
|
|
14
|
+
const buffer = fs.readFileSync(filePath);
|
|
15
|
+
|
|
16
|
+
// Extract text content
|
|
17
|
+
const textResult = await mammoth.extractRawText({ buffer });
|
|
18
|
+
const content = textResult.value.trim();
|
|
19
|
+
|
|
20
|
+
// Extract metadata
|
|
21
|
+
const metadata: DocumentMetadata = {};
|
|
22
|
+
|
|
23
|
+
// Estimate word count
|
|
24
|
+
if (content) {
|
|
25
|
+
metadata.wordCount = content.split(/\s+/).filter(word => word.length > 0).length;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Try to extract document properties for .docx files
|
|
29
|
+
if (path.extname(filePath).toLowerCase() === '.docx') {
|
|
30
|
+
try {
|
|
31
|
+
// For DOCX files, we could parse document.xml for metadata
|
|
32
|
+
// For now, we'll use basic analysis of the content
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
const firstNonEmptyLine = lines.find(line => line.trim().length > 0);
|
|
35
|
+
|
|
36
|
+
// If the first line looks like a title (short and not a sentence)
|
|
37
|
+
if (firstNonEmptyLine && firstNonEmptyLine.length < 100 && !firstNonEmptyLine.endsWith('.')) {
|
|
38
|
+
metadata.title = firstNonEmptyLine.trim();
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Ignore metadata extraction errors
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { content, metadata };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
throw new Error(`Failed to parse Word document: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AIProvider } from '../types/index.js';
|
|
2
|
+
import { ClaudeService } from './claude-service.js';
|
|
3
|
+
import { OpenAIService } from './openai-service.js';
|
|
4
|
+
|
|
5
|
+
export class AIServiceFactory {
|
|
6
|
+
static create(provider: 'claude' | 'openai', apiKey: string): AIProvider {
|
|
7
|
+
switch (provider) {
|
|
8
|
+
case 'claude':
|
|
9
|
+
return new ClaudeService(apiKey);
|
|
10
|
+
case 'openai':
|
|
11
|
+
return new OpenAIService(apiKey);
|
|
12
|
+
default:
|
|
13
|
+
throw new Error(`Unsupported AI provider: ${provider}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { AIProvider, FileInfo } from '../types/index.js';
|
|
3
|
+
import { applyNamingConvention, getNamingInstructions, NamingConvention } from '../utils/naming-conventions.js';
|
|
4
|
+
import { getTemplateInstructions, FileCategory } from '../utils/file-templates.js';
|
|
5
|
+
|
|
6
|
+
export class ClaudeService implements AIProvider {
|
|
7
|
+
name = 'Claude';
|
|
8
|
+
private client: Anthropic;
|
|
9
|
+
|
|
10
|
+
constructor(apiKey: string) {
|
|
11
|
+
this.client = new Anthropic({
|
|
12
|
+
apiKey: apiKey
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async generateFileName(content: string, originalName: string, namingConvention: string = 'kebab-case', category: string = 'general', fileInfo?: FileInfo): Promise<string> {
|
|
17
|
+
const convention = namingConvention as NamingConvention;
|
|
18
|
+
const fileCategory = category as FileCategory;
|
|
19
|
+
const namingInstructions = getNamingInstructions(convention);
|
|
20
|
+
const templateInstructions = getTemplateInstructions(fileCategory);
|
|
21
|
+
|
|
22
|
+
// Build comprehensive context from all metadata
|
|
23
|
+
let metadataContext = '';
|
|
24
|
+
if (fileInfo) {
|
|
25
|
+
metadataContext += `File Information:
|
|
26
|
+
- Original filename: ${originalName}
|
|
27
|
+
- File size: ${Math.round(fileInfo.size / 1024)}KB
|
|
28
|
+
- Created: ${fileInfo.createdAt.toLocaleDateString()}
|
|
29
|
+
- Modified: ${fileInfo.modifiedAt.toLocaleDateString()}
|
|
30
|
+
- Parent folder: ${fileInfo.parentFolder}
|
|
31
|
+
- Folder path: ${fileInfo.folderPath.join(' > ')}`;
|
|
32
|
+
|
|
33
|
+
if (fileInfo.documentMetadata) {
|
|
34
|
+
const meta = fileInfo.documentMetadata;
|
|
35
|
+
metadataContext += `
|
|
36
|
+
Document Properties:`;
|
|
37
|
+
if (meta.title) metadataContext += `\n- Title: ${meta.title}`;
|
|
38
|
+
if (meta.author) metadataContext += `\n- Author: ${meta.author}`;
|
|
39
|
+
if (meta.creator) metadataContext += `\n- Creator: ${meta.creator}`;
|
|
40
|
+
if (meta.subject) metadataContext += `\n- Subject: ${meta.subject}`;
|
|
41
|
+
if (meta.keywords?.length) metadataContext += `\n- Keywords: ${meta.keywords.join(', ')}`;
|
|
42
|
+
if (meta.creationDate) metadataContext += `\n- Created: ${meta.creationDate.toLocaleDateString()}`;
|
|
43
|
+
if (meta.modificationDate) metadataContext += `\n- Modified: ${meta.modificationDate.toLocaleDateString()}`;
|
|
44
|
+
if (meta.pages) metadataContext += `\n- Pages: ${meta.pages}`;
|
|
45
|
+
if (meta.wordCount) metadataContext += `\n- Word count: ${meta.wordCount}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const prompt = `Based on the following document information, generate a descriptive filename that captures the main topic/purpose of the document. The filename should be:
|
|
50
|
+
- Descriptive and meaningful
|
|
51
|
+
- Professional and clean
|
|
52
|
+
- Between 3-8 words
|
|
53
|
+
- ${namingInstructions}
|
|
54
|
+
- ${templateInstructions}
|
|
55
|
+
- Do not include file extension
|
|
56
|
+
- Do not include personal names, dates, or template variables - just the core content description
|
|
57
|
+
- Only use letters, numbers, and appropriate separators for the naming convention
|
|
58
|
+
- Use all available context (metadata, folder context, document properties) to create the most accurate filename
|
|
59
|
+
|
|
60
|
+
${metadataContext}
|
|
61
|
+
|
|
62
|
+
Document content (first 2000 characters):
|
|
63
|
+
${content.substring(0, 2000)}
|
|
64
|
+
|
|
65
|
+
Respond with only the core filename (without personal info or dates) using the specified naming convention, no explanation.`;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await this.client.messages.create({
|
|
69
|
+
model: 'claude-3-haiku-20240307',
|
|
70
|
+
max_tokens: 100,
|
|
71
|
+
messages: [
|
|
72
|
+
{
|
|
73
|
+
role: 'user',
|
|
74
|
+
content: prompt
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const suggestedName = response.content[0].type === 'text'
|
|
80
|
+
? response.content[0].text.trim()
|
|
81
|
+
: 'untitled-document';
|
|
82
|
+
|
|
83
|
+
// Apply naming convention and clean the suggested name
|
|
84
|
+
return this.sanitizeFileName(suggestedName, convention);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Claude API error:', error);
|
|
87
|
+
throw new Error(`Failed to generate filename with Claude: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private sanitizeFileName(name: string, convention: NamingConvention): string {
|
|
92
|
+
// Remove any potential file extensions from the suggestion
|
|
93
|
+
const nameWithoutExt = name.replace(/\.[^/.]+$/, '');
|
|
94
|
+
|
|
95
|
+
// Apply the naming convention
|
|
96
|
+
let cleaned = applyNamingConvention(nameWithoutExt, convention);
|
|
97
|
+
|
|
98
|
+
// Ensure it's not empty and not too long
|
|
99
|
+
if (!cleaned) {
|
|
100
|
+
cleaned = applyNamingConvention('untitled document', convention);
|
|
101
|
+
} else if (cleaned.length > 100) {
|
|
102
|
+
// Truncate while preserving naming convention structure
|
|
103
|
+
cleaned = cleaned.substring(0, 100);
|
|
104
|
+
// Clean up any broken separators at the end
|
|
105
|
+
if (convention === 'kebab-case') {
|
|
106
|
+
cleaned = cleaned.replace(/-[^-]*$/, '');
|
|
107
|
+
} else if (convention === 'snake_case') {
|
|
108
|
+
cleaned = cleaned.replace(/_[^_]*$/, '');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return cleaned;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { FileInfo, Config, RenameResult, AIProvider } from '../types/index.js';
|
|
4
|
+
import { DocumentParserFactory } from '../parsers/factory.js';
|
|
5
|
+
import { categorizeFile, applyTemplate } from '../utils/file-templates.js';
|
|
6
|
+
|
|
7
|
+
export class FileRenamer {
|
|
8
|
+
constructor(
|
|
9
|
+
private parserFactory: DocumentParserFactory,
|
|
10
|
+
private aiService: AIProvider,
|
|
11
|
+
private config: Config
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
async renameFiles(files: FileInfo[]): Promise<RenameResult[]> {
|
|
15
|
+
const results: RenameResult[] = [];
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < files.length; i++) {
|
|
18
|
+
const file = files[i];
|
|
19
|
+
// Use \r to overwrite the same line, show progress counter
|
|
20
|
+
process.stdout.write(`\rProcessing: ${file.name}... (${i + 1}/${files.length})`);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await this.renameFile(file);
|
|
24
|
+
results.push(result);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
results.push({
|
|
27
|
+
originalPath: file.path,
|
|
28
|
+
newPath: file.path,
|
|
29
|
+
suggestedName: file.name,
|
|
30
|
+
success: false,
|
|
31
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Clear the processing line and move to next line
|
|
37
|
+
process.stdout.write('\r' + ' '.repeat(80) + '\r');
|
|
38
|
+
|
|
39
|
+
return results;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async renameFile(file: FileInfo): Promise<RenameResult> {
|
|
43
|
+
// Check file size
|
|
44
|
+
if (file.size > this.config.maxFileSize) {
|
|
45
|
+
throw new Error(`File size (${Math.round(file.size / 1024 / 1024)}MB) exceeds maximum allowed size (${Math.round(this.config.maxFileSize / 1024 / 1024)}MB)`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get appropriate parser
|
|
49
|
+
const parser = this.parserFactory.getParser(file.path);
|
|
50
|
+
if (!parser) {
|
|
51
|
+
throw new Error(`No parser available for file type: ${file.extension}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract content and metadata
|
|
55
|
+
const parseResult = await parser.parse(file.path);
|
|
56
|
+
const content = parseResult.content;
|
|
57
|
+
if (!content || content.trim().length === 0) {
|
|
58
|
+
throw new Error('No content could be extracted from the file');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Update file info with extracted document metadata
|
|
62
|
+
file.documentMetadata = parseResult.metadata;
|
|
63
|
+
|
|
64
|
+
// Determine file category (use configured category or auto-categorize)
|
|
65
|
+
const fileCategory = this.config.templateOptions.category === 'auto'
|
|
66
|
+
? categorizeFile(file.path, content, file)
|
|
67
|
+
: this.config.templateOptions.category;
|
|
68
|
+
|
|
69
|
+
// Generate core filename using AI with all available metadata
|
|
70
|
+
const coreFileName = await this.aiService.generateFileName(
|
|
71
|
+
content,
|
|
72
|
+
file.name,
|
|
73
|
+
this.config.namingConvention,
|
|
74
|
+
fileCategory,
|
|
75
|
+
file // Pass the entire file info with all metadata
|
|
76
|
+
);
|
|
77
|
+
if (!coreFileName || coreFileName.trim().length === 0) {
|
|
78
|
+
throw new Error('AI service failed to generate a filename');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Apply template to include personal info, dates, etc.
|
|
82
|
+
const templatedName = applyTemplate(
|
|
83
|
+
coreFileName,
|
|
84
|
+
fileCategory,
|
|
85
|
+
this.config.templateOptions,
|
|
86
|
+
this.config.namingConvention
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Create new filename with original extension
|
|
90
|
+
const newFileName = `${templatedName}${file.extension}`;
|
|
91
|
+
const newPath = path.join(path.dirname(file.path), newFileName);
|
|
92
|
+
|
|
93
|
+
// Check if new filename would conflict with existing file
|
|
94
|
+
if (newPath !== file.path) {
|
|
95
|
+
await this.checkForConflicts(newPath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Perform the rename (or simulate if dry run)
|
|
99
|
+
if (!this.config.dryRun && newPath !== file.path) {
|
|
100
|
+
await fs.rename(file.path, newPath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
originalPath: file.path,
|
|
105
|
+
newPath,
|
|
106
|
+
suggestedName: newFileName,
|
|
107
|
+
success: true
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async checkForConflicts(newPath: string): Promise<void> {
|
|
112
|
+
try {
|
|
113
|
+
await fs.access(newPath);
|
|
114
|
+
// If we reach here, the file exists
|
|
115
|
+
throw new Error(`Target filename already exists: ${path.basename(newPath)}`);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// If the error is ENOENT (file doesn't exist), that's what we want
|
|
118
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import { AIProvider, FileInfo } from '../types/index.js';
|
|
3
|
+
import { applyNamingConvention, getNamingInstructions, NamingConvention } from '../utils/naming-conventions.js';
|
|
4
|
+
import { getTemplateInstructions, FileCategory } from '../utils/file-templates.js';
|
|
5
|
+
|
|
6
|
+
export class OpenAIService implements AIProvider {
|
|
7
|
+
name = 'OpenAI';
|
|
8
|
+
private client: OpenAI;
|
|
9
|
+
|
|
10
|
+
constructor(apiKey: string) {
|
|
11
|
+
this.client = new OpenAI({
|
|
12
|
+
apiKey: apiKey
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async generateFileName(content: string, originalName: string, namingConvention: string = 'kebab-case', category: string = 'general', fileInfo?: FileInfo): Promise<string> {
|
|
17
|
+
const convention = namingConvention as NamingConvention;
|
|
18
|
+
const fileCategory = category as FileCategory;
|
|
19
|
+
const namingInstructions = getNamingInstructions(convention);
|
|
20
|
+
const templateInstructions = getTemplateInstructions(fileCategory);
|
|
21
|
+
|
|
22
|
+
// Build comprehensive context from all metadata
|
|
23
|
+
let metadataContext = '';
|
|
24
|
+
if (fileInfo) {
|
|
25
|
+
metadataContext += `File Information:
|
|
26
|
+
- Original filename: ${originalName}
|
|
27
|
+
- File size: ${Math.round(fileInfo.size / 1024)}KB
|
|
28
|
+
- Created: ${fileInfo.createdAt.toLocaleDateString()}
|
|
29
|
+
- Modified: ${fileInfo.modifiedAt.toLocaleDateString()}
|
|
30
|
+
- Parent folder: ${fileInfo.parentFolder}
|
|
31
|
+
- Folder path: ${fileInfo.folderPath.join(' > ')}`;
|
|
32
|
+
|
|
33
|
+
if (fileInfo.documentMetadata) {
|
|
34
|
+
const meta = fileInfo.documentMetadata;
|
|
35
|
+
metadataContext += `
|
|
36
|
+
Document Properties:`;
|
|
37
|
+
if (meta.title) metadataContext += `\n- Title: ${meta.title}`;
|
|
38
|
+
if (meta.author) metadataContext += `\n- Author: ${meta.author}`;
|
|
39
|
+
if (meta.creator) metadataContext += `\n- Creator: ${meta.creator}`;
|
|
40
|
+
if (meta.subject) metadataContext += `\n- Subject: ${meta.subject}`;
|
|
41
|
+
if (meta.keywords?.length) metadataContext += `\n- Keywords: ${meta.keywords.join(', ')}`;
|
|
42
|
+
if (meta.creationDate) metadataContext += `\n- Created: ${meta.creationDate.toLocaleDateString()}`;
|
|
43
|
+
if (meta.modificationDate) metadataContext += `\n- Modified: ${meta.modificationDate.toLocaleDateString()}`;
|
|
44
|
+
if (meta.pages) metadataContext += `\n- Pages: ${meta.pages}`;
|
|
45
|
+
if (meta.wordCount) metadataContext += `\n- Word count: ${meta.wordCount}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const prompt = `Based on the following document information, generate a descriptive filename that captures the main topic/purpose of the document. The filename should be:
|
|
50
|
+
- Descriptive and meaningful
|
|
51
|
+
- Professional and clean
|
|
52
|
+
- Between 3-8 words
|
|
53
|
+
- ${namingInstructions}
|
|
54
|
+
- ${templateInstructions}
|
|
55
|
+
- Do not include file extension
|
|
56
|
+
- Do not include personal names, dates, or template variables - just the core content description
|
|
57
|
+
- Only use letters, numbers, and appropriate separators for the naming convention
|
|
58
|
+
- Use all available context (metadata, folder context, document properties) to create the most accurate filename
|
|
59
|
+
|
|
60
|
+
${metadataContext}
|
|
61
|
+
|
|
62
|
+
Document content (first 2000 characters):
|
|
63
|
+
${content.substring(0, 2000)}
|
|
64
|
+
|
|
65
|
+
Respond with only the core filename (without personal info or dates) using the specified naming convention, no explanation.`;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await this.client.chat.completions.create({
|
|
69
|
+
model: 'gpt-3.5-turbo',
|
|
70
|
+
messages: [
|
|
71
|
+
{
|
|
72
|
+
role: 'user',
|
|
73
|
+
content: prompt
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
max_tokens: 100,
|
|
77
|
+
temperature: 0.3
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const suggestedName = response.choices[0]?.message?.content?.trim() || 'untitled-document';
|
|
81
|
+
|
|
82
|
+
// Clean and validate the suggested name
|
|
83
|
+
return this.sanitizeFileName(suggestedName, convention);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('OpenAI API error:', error);
|
|
86
|
+
throw new Error(`Failed to generate filename with OpenAI: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private sanitizeFileName(name: string, convention: NamingConvention): string {
|
|
91
|
+
// Remove any potential file extensions from the suggestion
|
|
92
|
+
const nameWithoutExt = name.replace(/\.[^/.]+$/, '');
|
|
93
|
+
|
|
94
|
+
// Apply the naming convention
|
|
95
|
+
let cleaned = applyNamingConvention(nameWithoutExt, convention);
|
|
96
|
+
|
|
97
|
+
// Ensure it's not empty and not too long
|
|
98
|
+
if (!cleaned) {
|
|
99
|
+
cleaned = applyNamingConvention('untitled document', convention);
|
|
100
|
+
} else if (cleaned.length > 100) {
|
|
101
|
+
// Truncate while preserving naming convention structure
|
|
102
|
+
cleaned = cleaned.substring(0, 100);
|
|
103
|
+
// Clean up any broken separators at the end
|
|
104
|
+
if (convention === 'kebab-case') {
|
|
105
|
+
cleaned = cleaned.replace(/-[^-]*$/, '');
|
|
106
|
+
} else if (convention === 'snake_case') {
|
|
107
|
+
cleaned = cleaned.replace(/_[^_]*$/, '');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return cleaned;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export interface DocumentMetadata {
|
|
2
|
+
title?: string;
|
|
3
|
+
author?: string;
|
|
4
|
+
creator?: string;
|
|
5
|
+
subject?: string;
|
|
6
|
+
keywords?: string[];
|
|
7
|
+
creationDate?: Date;
|
|
8
|
+
modificationDate?: Date;
|
|
9
|
+
pages?: number;
|
|
10
|
+
wordCount?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FileInfo {
|
|
14
|
+
path: string;
|
|
15
|
+
name: string;
|
|
16
|
+
extension: string;
|
|
17
|
+
size: number;
|
|
18
|
+
content?: string;
|
|
19
|
+
// File system metadata
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
modifiedAt: Date;
|
|
22
|
+
accessedAt: Date;
|
|
23
|
+
// Context metadata
|
|
24
|
+
parentFolder: string;
|
|
25
|
+
folderPath: string[];
|
|
26
|
+
// Document metadata (extracted from file contents)
|
|
27
|
+
documentMetadata?: DocumentMetadata;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RenameResult {
|
|
31
|
+
originalPath: string;
|
|
32
|
+
newPath: string;
|
|
33
|
+
suggestedName: string;
|
|
34
|
+
success: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AIProvider {
|
|
39
|
+
name: string;
|
|
40
|
+
generateFileName: (content: string, originalName: string, namingConvention?: string, category?: string, fileInfo?: FileInfo) => Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type NamingConvention = 'kebab-case' | 'snake_case' | 'camelCase' | 'PascalCase' | 'lowercase' | 'UPPERCASE';
|
|
44
|
+
export type FileCategory = 'document' | 'movie' | 'music' | 'series' | 'photo' | 'book' | 'general' | 'auto';
|
|
45
|
+
export type DateFormat = 'YYYY-MM-DD' | 'YYYY' | 'YYYYMMDD' | 'none';
|
|
46
|
+
|
|
47
|
+
export interface TemplateOptions {
|
|
48
|
+
category: FileCategory;
|
|
49
|
+
personalName?: string;
|
|
50
|
+
dateFormat: DateFormat;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Config {
|
|
54
|
+
aiProvider: 'claude' | 'openai';
|
|
55
|
+
apiKey: string;
|
|
56
|
+
maxFileSize: number;
|
|
57
|
+
supportedExtensions: string[];
|
|
58
|
+
dryRun: boolean;
|
|
59
|
+
namingConvention: NamingConvention;
|
|
60
|
+
templateOptions: TemplateOptions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ParseResult {
|
|
64
|
+
content: string;
|
|
65
|
+
metadata?: DocumentMetadata;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DocumentParser {
|
|
69
|
+
supports: (filePath: string) => boolean;
|
|
70
|
+
parse: (filePath: string) => Promise<ParseResult>;
|
|
71
|
+
}
|