470 lines
13 KiB
JavaScript
470 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* organize.js - 文件整理工具
|
||
* 根据整理计划执行文件移动、重命名和删除操作
|
||
*/
|
||
|
||
const fs = require('fs-extra');
|
||
const path = require('path');
|
||
const { program } = require('commander');
|
||
const semver = require('semver');
|
||
|
||
// 分类规则定义
|
||
const CATEGORY_RULES = {
|
||
// 应用程序分类
|
||
'Applications': {
|
||
extensions: ['.app', '.dmg', '.pkg', '.exe'],
|
||
subcategories: {
|
||
'Development': ['code', 'visual studio', 'intellij', 'xcode', 'sublime', 'atom', 'vim', 'git', 'docker', 'terminal', 'iterm'],
|
||
'Office': ['word', 'excel', 'powerpoint', 'outlook', 'onenote', 'keynote', 'numbers', 'pages'],
|
||
'Design': ['photoshop', 'illustrator', 'sketch', 'figma', 'affinity', 'gimp', 'inkscape'],
|
||
'Communication': ['slack', 'discord', 'telegram', 'whatsapp', 'zoom', 'teams', 'skype'],
|
||
'Entertainment': ['spotify', 'music', 'vlc', 'netflix', 'steam', 'epic'],
|
||
'Utilities': ['finder', 'calculator', 'calendar', 'notes', 'reminders', 'activity monitor'],
|
||
}
|
||
},
|
||
|
||
// 文档分类
|
||
'Documents': {
|
||
extensions: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.rtf'],
|
||
subcategories: {
|
||
'PDFs': ['.pdf'],
|
||
'Word': ['.doc', '.docx'],
|
||
'Excel': ['.xls', '.xlsx'],
|
||
'PowerPoint': ['.ppt', '.pptx'],
|
||
'TextFiles': ['.txt', '.md', '.rtf'],
|
||
}
|
||
},
|
||
|
||
// 图片分类
|
||
'Images': {
|
||
extensions: ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.svg', '.webp', '.heic'],
|
||
subcategories: {
|
||
'Screenshots': ['screen shot', 'screenshot', 'capture'],
|
||
'Photos': ['.jpg', '.jpeg', '.heic'],
|
||
'Designs': ['.png', '.svg', '.psd'],
|
||
}
|
||
},
|
||
|
||
// 视频分类
|
||
'Videos': {
|
||
extensions: ['.mp4', '.mov', '.avi', '.mkv', '.wmv', '.flv', '.webm'],
|
||
},
|
||
|
||
// 音频分类
|
||
'Audio': {
|
||
extensions: ['.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg'],
|
||
},
|
||
|
||
// 压缩包分类
|
||
'Archives': {
|
||
extensions: ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2'],
|
||
},
|
||
|
||
// 代码项目分类
|
||
'CodeProjects': {
|
||
indicators: ['package.json', 'requirements.txt', 'Gemfile', 'pom.xml', '.git'],
|
||
},
|
||
};
|
||
|
||
/**
|
||
* 生成整理计划
|
||
*/
|
||
function generateOrganizePlan(files, targetPath) {
|
||
const plan = {
|
||
createdAt: new Date().toISOString(),
|
||
targetPath,
|
||
actions: [],
|
||
summary: {
|
||
total: files.length,
|
||
toMove: 0,
|
||
toDelete: 0,
|
||
toKeep: 0,
|
||
}
|
||
};
|
||
|
||
// 第一步:按类型分类
|
||
const categorized = categorizeFiles(files);
|
||
|
||
// 第二步:识别版本并标记旧版本
|
||
const versionGroups = identifyVersions(files);
|
||
|
||
// 生成移动操作
|
||
for (const [category, categoryFiles] of Object.entries(categorized)) {
|
||
for (const file of categoryFiles) {
|
||
// 检查是否是要删除的旧版本
|
||
const versionInfo = findVersionInfo(file, versionGroups);
|
||
if (versionInfo && !versionInfo.isLatest) {
|
||
plan.actions.push({
|
||
type: 'delete',
|
||
file: file.path,
|
||
reason: `旧版本 (${versionInfo.version}), 保留版本: ${versionInfo.latestVersion}`,
|
||
requiresConfirmation: true,
|
||
});
|
||
plan.summary.toDelete++;
|
||
} else {
|
||
// 移动到分类文件夹
|
||
const targetFolder = path.join(targetPath, category);
|
||
const newPath = path.join(targetFolder, file.filename);
|
||
|
||
plan.actions.push({
|
||
type: 'move',
|
||
file: file.path,
|
||
target: newPath,
|
||
category,
|
||
});
|
||
plan.summary.toMove++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return plan;
|
||
}
|
||
|
||
/**
|
||
* 文件分类
|
||
*/
|
||
function categorizeFiles(files) {
|
||
const categorized = {};
|
||
|
||
for (const file of files) {
|
||
let assigned = false;
|
||
|
||
// 遍历所有分类规则
|
||
for (const [category, rule] of Object.entries(CATEGORY_RULES)) {
|
||
if (rule.extensions && rule.extensions.includes(file.extension.toLowerCase())) {
|
||
// 检查是否有子分类
|
||
let subcategory = null;
|
||
if (rule.subcategories) {
|
||
subcategory = findSubcategory(file, rule.subcategories);
|
||
}
|
||
|
||
const categoryKey = subcategory ? `${category}/${subcategory}` : category;
|
||
|
||
if (!categorized[categoryKey]) {
|
||
categorized[categoryKey] = [];
|
||
}
|
||
categorized[categoryKey].push(file);
|
||
assigned = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 如果没有匹配的分类,放入"未分类"
|
||
if (!assigned) {
|
||
if (!categorized['Uncategorized']) {
|
||
categorized['Uncategorized'] = [];
|
||
}
|
||
categorized['Uncategorized'].push(file);
|
||
}
|
||
}
|
||
|
||
return categorized;
|
||
}
|
||
|
||
/**
|
||
* 查找子分类
|
||
*/
|
||
function findSubcategory(file, subcategories) {
|
||
const filenameLower = file.filename.toLowerCase();
|
||
const extensionLower = file.extension.toLowerCase();
|
||
|
||
for (const [subcategoryName, indicators] of Object.entries(subcategories)) {
|
||
for (const indicator of indicators) {
|
||
if (indicator.startsWith('.')) {
|
||
// 扩展名匹配
|
||
if (extensionLower === indicator.toLowerCase()) {
|
||
return subcategoryName;
|
||
}
|
||
} else {
|
||
// 关键词匹配
|
||
if (filenameLower.includes(indicator.toLowerCase())) {
|
||
return subcategoryName;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 识别版本
|
||
*/
|
||
function identifyVersions(files) {
|
||
const groups = {};
|
||
|
||
for (const file of files) {
|
||
if (!file.version) continue;
|
||
|
||
// 提取基础名称(去除版本号)
|
||
const baseName = file.basename
|
||
.replace(/[v_\s-]?\d+\.\d+(\.\d+)?/gi, '')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
if (!groups[baseName]) {
|
||
groups[baseName] = [];
|
||
}
|
||
|
||
groups[baseName].push({
|
||
file,
|
||
version: file.version,
|
||
});
|
||
}
|
||
|
||
// 对每组版本进行排序,找出最新版本
|
||
for (const [baseName, versions] of Object.entries(groups)) {
|
||
if (versions.length <= 1) continue;
|
||
|
||
// 使用 semver 排序
|
||
versions.sort((a, b) => {
|
||
try {
|
||
return semver.rcompare(semver.coerce(a.version), semver.coerce(b.version));
|
||
} catch {
|
||
// 如果无法解析,按字符串比较
|
||
return b.version.localeCompare(a.version);
|
||
}
|
||
});
|
||
|
||
// 标记最新版本
|
||
versions[0].isLatest = true;
|
||
const latestVersion = versions[0].version;
|
||
|
||
// 标记其他版本
|
||
for (let i = 1; i < versions.length; i++) {
|
||
versions[i].isLatest = false;
|
||
versions[i].latestVersion = latestVersion;
|
||
}
|
||
}
|
||
|
||
return groups;
|
||
}
|
||
|
||
/**
|
||
* 查找版本信息
|
||
*/
|
||
function findVersionInfo(file, versionGroups) {
|
||
if (!file.version) return null;
|
||
|
||
const baseName = file.basename
|
||
.replace(/[v_\s-]?\d+\.\d+(\.\d+)?/gi, '')
|
||
.trim()
|
||
.toLowerCase();
|
||
|
||
const group = versionGroups[baseName];
|
||
if (!group) return null;
|
||
|
||
return group.find(v => v.file.path === file.path);
|
||
}
|
||
|
||
/**
|
||
* 执行整理计划(dry-run 或实际执行)
|
||
*/
|
||
async function executePlan(plan, dryRun = true) {
|
||
console.log(`\n${dryRun ? '[DRY RUN 模式]' : '[执行模式]'} 开始整理...\n`);
|
||
|
||
const results = {
|
||
success: [],
|
||
failed: [],
|
||
skipped: [],
|
||
};
|
||
|
||
for (const action of plan.actions) {
|
||
try {
|
||
if (action.type === 'move') {
|
||
console.log(`移动: ${path.basename(action.file)} -> ${action.category}/`);
|
||
|
||
if (!dryRun) {
|
||
// 确保目标文件夹存在
|
||
await fs.ensureDir(path.dirname(action.target));
|
||
|
||
// 移动文件
|
||
await fs.move(action.file, action.target, { overwrite: false });
|
||
}
|
||
|
||
results.success.push(action);
|
||
|
||
} else if (action.type === 'delete') {
|
||
console.log(`删除: ${path.basename(action.file)} (${action.reason})`);
|
||
|
||
if (!dryRun) {
|
||
// 删除文件
|
||
await fs.remove(action.file);
|
||
}
|
||
|
||
results.success.push(action);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`失败: ${action.file}`, error.message);
|
||
results.failed.push({ action, error: error.message });
|
||
}
|
||
}
|
||
|
||
// 打印摘要
|
||
console.log(`\n=== 整理摘要 ===`);
|
||
console.log(`成功: ${results.success.length}`);
|
||
console.log(`失败: ${results.failed.length}`);
|
||
console.log(`跳过: ${results.skipped.length}`);
|
||
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 生成 Markdown 报告
|
||
*/
|
||
function generateMarkdownReport(plan, results, targetPath) {
|
||
const timestamp = new Date().toLocaleString('zh-CN');
|
||
const reportDate = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||
|
||
let markdown = `# 桌面整理报告\n\n`;
|
||
markdown += `**整理时间**: ${timestamp}\n`;
|
||
markdown += `**整理路径**: ${targetPath}\n\n`;
|
||
|
||
markdown += `## 整理概要\n\n`;
|
||
markdown += `- 总文件数: ${plan.summary.total}\n`;
|
||
markdown += `- 已移动文件: ${plan.summary.toMove}\n`;
|
||
markdown += `- 已删除文件: ${plan.summary.toDelete}\n`;
|
||
markdown += `- 保留文件: ${plan.summary.toKeep}\n\n`;
|
||
|
||
// 分类详情
|
||
markdown += `## 分类详情\n\n`;
|
||
const categories = {};
|
||
for (const action of plan.actions) {
|
||
if (action.type === 'move') {
|
||
if (!categories[action.category]) {
|
||
categories[action.category] = [];
|
||
}
|
||
categories[action.category].push(path.basename(action.file));
|
||
}
|
||
}
|
||
|
||
for (const [category, files] of Object.entries(categories)) {
|
||
markdown += `### ${category} (${files.length} 个)\n\n`;
|
||
for (const file of files.slice(0, 10)) {
|
||
markdown += `- ${file}\n`;
|
||
}
|
||
if (files.length > 10) {
|
||
markdown += `- ... 以及其他 ${files.length - 10} 个文件\n`;
|
||
}
|
||
markdown += `\n`;
|
||
}
|
||
|
||
// 版本去重记录
|
||
const deletions = plan.actions.filter(a => a.type === 'delete');
|
||
if (deletions.length > 0) {
|
||
markdown += `## 版本去重记录\n\n`;
|
||
markdown += `| 文件名 | 原因 |\n`;
|
||
markdown += `|-------|------|\n`;
|
||
for (const action of deletions) {
|
||
markdown += `| ${path.basename(action.file)} | ${action.reason} |\n`;
|
||
}
|
||
markdown += `\n`;
|
||
}
|
||
|
||
// 执行结果
|
||
if (results) {
|
||
markdown += `## 执行结果\n\n`;
|
||
markdown += `- 成功: ${results.success.length}\n`;
|
||
markdown += `- 失败: ${results.failed.length}\n`;
|
||
markdown += `- 跳过: ${results.skipped.length}\n\n`;
|
||
|
||
if (results.failed.length > 0) {
|
||
markdown += `### 失败的操作\n\n`;
|
||
for (const { action, error } of results.failed) {
|
||
markdown += `- ${path.basename(action.file)}: ${error}\n`;
|
||
}
|
||
markdown += `\n`;
|
||
}
|
||
}
|
||
|
||
markdown += `## 建议\n\n`;
|
||
markdown += `- 定期整理桌面,保持文件有序\n`;
|
||
markdown += `- 及时删除不需要的文件和旧版本软件\n`;
|
||
if (categories['Uncategorized'] && categories['Uncategorized'].length > 0) {
|
||
markdown += `- 检查"未分类"文件夹中的 ${categories['Uncategorized'].length} 个文件\n`;
|
||
}
|
||
|
||
return { markdown, filename: `整理报告_${reportDate}.md` };
|
||
}
|
||
|
||
/**
|
||
* 主函数
|
||
*/
|
||
async function main() {
|
||
program
|
||
.name('organize')
|
||
.description('根据整理计划执行文件整理操作')
|
||
.option('-s, --source <path>', '源目录路径')
|
||
.option('-p, --plan <file>', '整理计划 JSON 文件')
|
||
.option('-d, --dry-run', '仅模拟,不实际执行', false)
|
||
.option('-r, --report <file>', '生成 Markdown 报告')
|
||
.parse();
|
||
|
||
const options = program.opts();
|
||
|
||
if (!options.source) {
|
||
console.error('错误: 必须指定源目录路径 (-s 或 --source)');
|
||
process.exit(1);
|
||
}
|
||
|
||
const sourcePath = path.resolve(options.source);
|
||
|
||
try {
|
||
let plan;
|
||
|
||
// 如果提供了计划文件,加载它
|
||
if (options.plan) {
|
||
const planPath = path.resolve(options.plan);
|
||
plan = await fs.readJson(planPath);
|
||
} else {
|
||
// 否则,扫描目录并生成计划
|
||
const { scanDirectory, analyzeFiles } = require('./scan.js');
|
||
const files = await scanDirectory(sourcePath);
|
||
plan = generateOrganizePlan(files, sourcePath);
|
||
|
||
// 保存计划
|
||
const planPath = path.join(sourcePath, 'organize-plan.json');
|
||
await fs.writeJson(planPath, plan, { spaces: 2 });
|
||
console.log(`整理计划已保存到: ${planPath}\n`);
|
||
}
|
||
|
||
// 执行计划
|
||
const results = await executePlan(plan, options.dryRun);
|
||
|
||
// 生成报告
|
||
if (options.report || !options.dryRun) {
|
||
const { markdown, filename } = generateMarkdownReport(plan, results, sourcePath);
|
||
const reportPath = options.report
|
||
? path.resolve(options.report)
|
||
: path.join(sourcePath, filename);
|
||
|
||
await fs.writeFile(reportPath, markdown, 'utf8');
|
||
console.log(`\n报告已保存到: ${reportPath}`);
|
||
}
|
||
|
||
if (options.dryRun) {
|
||
console.log(`\n提示: 这是 DRY RUN 模式,没有实际修改文件。`);
|
||
console.log(`如需执行实际操作,请移除 --dry-run 参数。\n`);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('错误:', error.message);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
// 如果直接运行此脚本
|
||
if (require.main === module) {
|
||
main();
|
||
}
|
||
|
||
// 导出供其他模块使用
|
||
module.exports = {
|
||
generateOrganizePlan,
|
||
executePlan,
|
||
generateMarkdownReport,
|
||
};
|