import fs from 'fs'; import path from 'path'; import simpleGit from 'simple-git'; const DOCUMENT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.csv', '.json', '.yaml', '.yml']); const ASSET_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']); const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', '.idea', '.vscode', '__macosx']); const MAX_FILES = 1000; const MAX_FILE_BYTES = 10 * 1024 * 1024; export interface FolderImportResult { cancelled: boolean; sourceName?: string; destination?: string; imported: number; assets: number; skipped: number; failed: string[]; firstDocument?: string; } function safeSegment(value: string): string { const cleaned = value.normalize('NFC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').trim(); return cleaned || '未命名文件夹'; } function markdownFrontmatter(title: string, source: string): string { const now = new Date().toISOString(); return `---\ntitle: ${JSON.stringify(title)}\nauthor: HoloLake Importer\ncreatedAt: ${now}\nupdatedAt: ${now}\nsource: ${JSON.stringify(source)}\ntags:\n - imported\n---\n\n`; } function csvToMarkdown(input: string): string { const rows = input .split(/\r?\n/) .filter(Boolean) .map(line => line.split(',').map(cell => cell.trim().replace(/\|/g, '\\|'))); if (!rows.length) return '_空表格_\n'; const width = Math.max(...rows.map(row => row.length)); const padded = rows.map(row => [...row, ...Array(Math.max(0, width - row.length)).fill('')]); return [ `| ${padded[0].join(' | ')} |`, `| ${Array(width).fill('---').join(' | ')} |`, ...padded.slice(1).map(row => `| ${row.join(' | ')} |`), ].join('\n') + '\n'; } function convertDocument(sourcePath: string, relativePath: string): { targetRelative: string; content: string } { const extension = path.extname(sourcePath).toLowerCase(); const raw = fs.readFileSync(sourcePath, 'utf8'); const title = path.basename(sourcePath, extension); const targetRelative = extension === '.md' ? relativePath : relativePath.slice(0, -extension.length) + '.md'; if (extension === '.md' || extension === '.markdown') { return { targetRelative: targetRelative.replace(/\.markdown$/i, '.md'), content: raw, }; } if (extension === '.txt') { return { targetRelative, content: `${markdownFrontmatter(title, relativePath)}# ${title}\n\n${raw}\n` }; } if (extension === '.csv') { return { targetRelative, content: `${markdownFrontmatter(title, relativePath)}# ${title}\n\n${csvToMarkdown(raw)}` }; } const language = extension === '.json' ? 'json' : 'yaml'; return { targetRelative, content: `${markdownFrontmatter(title, relativePath)}# ${title}\n\n\`\`\`${language}\n${raw}\n\`\`\`\n`, }; } function walk(root: string, current: string, files: string[]): void { if (files.length >= MAX_FILES) return; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { if (entry.name.startsWith('._') || entry.name === '.DS_Store') continue; const fullPath = path.join(current, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { if (!entry.name.startsWith('.') && !IGNORED_DIRECTORIES.has(entry.name.toLowerCase())) { walk(root, fullPath, files); } continue; } files.push(fullPath); if (files.length >= MAX_FILES) return; } } export async function importKnowledgeFolder(sourcePath: string, repoPath: string): Promise { const sourceRoot = fs.realpathSync(sourcePath); const sourceName = safeSegment(path.basename(sourceRoot)); const docsRoot = path.join(repoPath, 'docs'); const importRoot = path.join(docsRoot, '导入'); let destinationName = sourceName; let destinationRoot = path.join(importRoot, destinationName); if (fs.existsSync(destinationRoot)) { const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 13); destinationName = `${sourceName}-${stamp}`; destinationRoot = path.join(importRoot, destinationName); } fs.mkdirSync(destinationRoot, { recursive: true }); const candidates: string[] = []; walk(sourceRoot, sourceRoot, candidates); const result: FolderImportResult = { cancelled: false, sourceName, destination: `导入/${destinationName}`, imported: 0, assets: 0, skipped: 0, failed: [], }; for (const sourceFile of candidates) { const relative = path.relative(sourceRoot, sourceFile); const safeRelative = relative.split(path.sep).map(safeSegment).join(path.sep); const extension = path.extname(sourceFile).toLowerCase(); try { if (fs.statSync(sourceFile).size > MAX_FILE_BYTES) { result.skipped += 1; continue; } if (DOCUMENT_EXTENSIONS.has(extension)) { const converted = convertDocument(sourceFile, safeRelative); const target = path.join(destinationRoot, converted.targetRelative); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, converted.content, 'utf8'); const docPath = path.posix.join('导入', destinationName, converted.targetRelative.split(path.sep).join('/')); result.firstDocument ||= docPath; result.imported += 1; } else if (ASSET_EXTENSIONS.has(extension)) { const target = path.join(destinationRoot, safeRelative); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(sourceFile, target); result.assets += 1; } else { result.skipped += 1; } } catch (error) { result.failed.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`); } } if (result.imported === 0 && result.assets === 0) { fs.rmSync(destinationRoot, { recursive: true, force: true }); return result; } const git = simpleGit(repoPath); await git.add(path.relative(repoPath, destinationRoot)); await git.commit(`import: 导入本地文件夹 ${sourceName}`); return result; }