镜像自地址
https://github.com/lhly/cloud-drive-renamer.git
已同步 2026-10-21 14:04:01 +08:00
feat: support crash recovery tasks
这个提交包含在:
父节点
4750aca415
当前提交
a13175441c
@ -1,5 +1,5 @@
|
||||
import { PlatformAdapter } from '../types/platform';
|
||||
import { OperationState } from '../types/core';
|
||||
import { OperationState, Task } from '../types/core';
|
||||
import { storage } from '../utils/storage';
|
||||
import { logger } from '../utils/logger';
|
||||
import { BatchExecutor } from './executor';
|
||||
@ -46,6 +46,7 @@ export class CrashRecoveryManager {
|
||||
rule: state.rule,
|
||||
completed: state.completed,
|
||||
failed: state.failed,
|
||||
tasks: state.tasks,
|
||||
};
|
||||
|
||||
await storage.set(this.STORAGE_KEY, data);
|
||||
@ -76,15 +77,18 @@ export class CrashRecoveryManager {
|
||||
}
|
||||
|
||||
// 检查是否有未完成的文件
|
||||
const totalItems = (savedData.tasks && savedData.tasks.length > 0)
|
||||
? savedData.tasks.length
|
||||
: savedData.files.length;
|
||||
const totalCompleted = savedData.completed.length + savedData.failed.length;
|
||||
if (totalCompleted >= savedData.files.length) {
|
||||
if (totalCompleted >= totalItems) {
|
||||
await this.clearOperationState();
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('Recoverable operation found', {
|
||||
age: `${ageMinutes.toFixed(1)} minutes`,
|
||||
files: savedData.files.length,
|
||||
files: totalItems,
|
||||
completed: savedData.completed.length,
|
||||
failed: savedData.failed.length,
|
||||
});
|
||||
@ -111,7 +115,24 @@ export class CrashRecoveryManager {
|
||||
): Promise<void> {
|
||||
// 过滤出未完成的文件
|
||||
const completedSet = new Set([...savedState.completed, ...savedState.failed]);
|
||||
const pendingFiles = savedState.files.filter((_file: any, index: number) => !completedSet.has(index));
|
||||
const hasTasks = Array.isArray(savedState.tasks) && savedState.tasks.length > 0;
|
||||
const indexByFileId = new Map<string, number>();
|
||||
|
||||
let pendingFiles: any[] = [];
|
||||
let pendingTasks: Task[] | undefined;
|
||||
|
||||
if (hasTasks) {
|
||||
savedState.tasks!.forEach((task) => {
|
||||
indexByFileId.set(task.file.id, task.index);
|
||||
});
|
||||
pendingTasks = savedState.tasks!.filter((task) => !completedSet.has(task.index));
|
||||
pendingFiles = pendingTasks.map((task) => task.file);
|
||||
} else {
|
||||
savedState.files.forEach((file: any, index: number) => {
|
||||
indexByFileId.set(file.id, index);
|
||||
});
|
||||
pendingFiles = savedState.files.filter((_file: any, index: number) => !completedSet.has(index));
|
||||
}
|
||||
|
||||
if (pendingFiles.length === 0) {
|
||||
await this.clearOperationState();
|
||||
@ -129,21 +150,25 @@ export class CrashRecoveryManager {
|
||||
const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, {
|
||||
requestInterval: requestInterval ?? 800,
|
||||
maxConcurrent,
|
||||
tasks: pendingTasks,
|
||||
onProgress: (progress) => {
|
||||
// 转发进度事件
|
||||
onProgress?.(progress);
|
||||
|
||||
// 更新进度到存储
|
||||
const currentIndex = savedState.files.findIndex(
|
||||
(f: any) => f.id === pendingFiles[progress.completed - 1]?.id
|
||||
);
|
||||
if (currentIndex !== -1) {
|
||||
const isFailed = progress.failed > 0;
|
||||
if (isFailed) {
|
||||
this.markAsFailed(currentIndex);
|
||||
} else {
|
||||
this.markAsCompleted(currentIndex);
|
||||
}
|
||||
if (!progress.fileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = indexByFileId.get(progress.fileId);
|
||||
if (currentIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFailed = progress.status === 'failed';
|
||||
if (isFailed) {
|
||||
this.markAsFailed(currentIndex);
|
||||
} else {
|
||||
this.markAsCompleted(currentIndex);
|
||||
}
|
||||
},
|
||||
onComplete: async (results) => {
|
||||
@ -214,14 +239,17 @@ export class CrashRecoveryManager {
|
||||
* @returns 用户是否选择恢复
|
||||
*/
|
||||
showRecoveryDialog(savedState: OperationState): boolean {
|
||||
const totalItems = (savedState.tasks && savedState.tasks.length > 0)
|
||||
? savedState.tasks.length
|
||||
: savedState.files.length;
|
||||
const totalCompleted = savedState.completed.length + savedState.failed.length;
|
||||
const remaining = savedState.files.length - totalCompleted;
|
||||
const remaining = totalItems - totalCompleted;
|
||||
const ageMinutes = Math.floor((Date.now() - savedState.timestamp) / 60000);
|
||||
|
||||
const message =
|
||||
`检测到未完成的批量重命名操作:\n\n` +
|
||||
`平台: ${savedState.platform}\n` +
|
||||
`总文件数: ${savedState.files.length}\n` +
|
||||
`总文件数: ${totalItems}\n` +
|
||||
`已完成: ${savedState.completed.length}\n` +
|
||||
`已失败: ${savedState.failed.length}\n` +
|
||||
`待完成: ${remaining}\n` +
|
||||
|
||||
@ -58,6 +58,8 @@ export interface OperationState {
|
||||
completed: number[];
|
||||
/** 失败的索引 */
|
||||
failed: number[];
|
||||
/** 自定义任务列表(可选) */
|
||||
tasks?: Task[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,204 +1,102 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { CrashRecoveryManager } from '../../src/core/crash-recovery';
|
||||
import { OperationState } from '../../src/types/core';
|
||||
import { storage } from '../../src/utils/storage';
|
||||
|
||||
// Mock storage
|
||||
vi.mock('../../src/utils/storage', () => ({
|
||||
storage: {
|
||||
set: vi.fn(),
|
||||
get: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
vi.mock('../../src/rules/rule-factory', () => ({
|
||||
RuleFactory: { create: () => ({ execute: (name: string) => `rule_${name}` }) },
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../src/utils/logger', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
type StorageShape = Record<string, any>;
|
||||
|
||||
class MockAdapter {
|
||||
platform = 'quark' as const;
|
||||
renameCalls: string[] = [];
|
||||
|
||||
async renameFile(_id: string, newName: string) {
|
||||
this.renameCalls.push(newName);
|
||||
return { success: true, newName };
|
||||
}
|
||||
|
||||
async getSelectedFiles() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getAllFiles() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async checkNameConflict() {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getFileInfo(id: string) {
|
||||
return { id, name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 };
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return { platform: 'quark', requestInterval: 0, maxRetries: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
describe('CrashRecoveryManager', () => {
|
||||
let recovery: CrashRecoveryManager;
|
||||
let mockState: OperationState;
|
||||
let storageData: StorageShape;
|
||||
|
||||
beforeEach(() => {
|
||||
recovery = new CrashRecoveryManager();
|
||||
mockState = {
|
||||
timestamp: Date.now(),
|
||||
platform: 'quark',
|
||||
files: [
|
||||
{ id: '1', name: 'file1.txt' },
|
||||
{ id: '2', name: 'file2.txt' },
|
||||
{ id: '3', name: 'file3.txt' },
|
||||
],
|
||||
rule: { type: 'prefix', params: { prefix: 'test_' } },
|
||||
completed: [0],
|
||||
failed: [],
|
||||
storageData = {};
|
||||
|
||||
const storageLocal = {
|
||||
set: vi.fn(async (items: StorageShape) => {
|
||||
Object.assign(storageData, items);
|
||||
}),
|
||||
get: vi.fn(async (key: string | null) => {
|
||||
if (!key) return storageData;
|
||||
return { [key]: storageData[key] };
|
||||
}),
|
||||
remove: vi.fn(async (key: string) => {
|
||||
delete storageData[key];
|
||||
}),
|
||||
clear: vi.fn(async () => {
|
||||
storageData = {};
|
||||
}),
|
||||
};
|
||||
|
||||
// 重置mocks
|
||||
vi.clearAllMocks();
|
||||
global.chrome = {
|
||||
storage: { local: storageLocal },
|
||||
} as any;
|
||||
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(),
|
||||
length: 0,
|
||||
} as any;
|
||||
});
|
||||
|
||||
describe('saveOperationState', () => {
|
||||
it('应该保存操作状态', async () => {
|
||||
await recovery.saveOperationState(mockState);
|
||||
it('resumeOperation should use saved tasks when provided', async () => {
|
||||
const recovery = new CrashRecoveryManager();
|
||||
const adapter = new MockAdapter();
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
'rename_operation_state',
|
||||
expect.objectContaining({
|
||||
platform: 'quark',
|
||||
files: mockState.files,
|
||||
rule: mockState.rule,
|
||||
completed: [0],
|
||||
failed: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
await recovery.saveOperationState({
|
||||
platform: 'quark',
|
||||
files: [
|
||||
{ id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 },
|
||||
],
|
||||
rule: { type: 'prefix', params: { prefix: 'x_' } },
|
||||
completed: [],
|
||||
failed: [],
|
||||
tasks: [
|
||||
{
|
||||
file: { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 },
|
||||
newName: 'custom.txt',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
it('保存时应该包含时间戳', async () => {
|
||||
await recovery.saveOperationState(mockState);
|
||||
const saved = await recovery.checkRecoverableOperation();
|
||||
await recovery.resumeOperation(saved!, adapter as any);
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
'rename_operation_state',
|
||||
expect.objectContaining({
|
||||
timestamp: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRecoverableOperation', () => {
|
||||
it('没有保存状态时应该返回null', async () => {
|
||||
vi.mocked(storage.get).mockResolvedValue(null);
|
||||
|
||||
const result = await recovery.checkRecoverableOperation();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('状态超过30分钟应该返回null并清理', async () => {
|
||||
const oldState = {
|
||||
...mockState,
|
||||
timestamp: Date.now() - 31 * 60 * 1000, // 31分钟前
|
||||
};
|
||||
vi.mocked(storage.get).mockResolvedValue(oldState);
|
||||
|
||||
const result = await recovery.checkRecoverableOperation();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(storage.remove).toHaveBeenCalledWith('rename_operation_state');
|
||||
});
|
||||
|
||||
it('30分钟内的状态应该返回', async () => {
|
||||
const recentState = {
|
||||
...mockState,
|
||||
timestamp: Date.now() - 10 * 60 * 1000, // 10分钟前
|
||||
};
|
||||
vi.mocked(storage.get).mockResolvedValue(recentState);
|
||||
|
||||
const result = await recovery.checkRecoverableOperation();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.platform).toBe('quark');
|
||||
});
|
||||
|
||||
it('已完成的操作应该返回null并清理', async () => {
|
||||
const completedState = {
|
||||
...mockState,
|
||||
completed: [0, 1, 2], // 所有文件都完成
|
||||
};
|
||||
vi.mocked(storage.get).mockResolvedValue(completedState);
|
||||
|
||||
const result = await recovery.checkRecoverableOperation();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(storage.remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearOperationState', () => {
|
||||
it('应该清除保存的状态', async () => {
|
||||
await recovery.clearOperationState();
|
||||
|
||||
expect(storage.remove).toHaveBeenCalledWith('rename_operation_state');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPendingFiles', () => {
|
||||
it('应该返回未完成的文件', () => {
|
||||
const pendingFiles = recovery.getPendingFiles(mockState);
|
||||
|
||||
expect(pendingFiles.length).toBe(2); // file2和file3
|
||||
expect(pendingFiles[0].id).toBe('2');
|
||||
expect(pendingFiles[1].id).toBe('3');
|
||||
});
|
||||
|
||||
it('所有文件完成时应该返回空数组', () => {
|
||||
const completedState = {
|
||||
...mockState,
|
||||
completed: [0, 1, 2],
|
||||
};
|
||||
|
||||
const pendingFiles = recovery.getPendingFiles(completedState);
|
||||
|
||||
expect(pendingFiles.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsCompleted', () => {
|
||||
it('应该标记文件为已完成', async () => {
|
||||
vi.mocked(storage.get).mockResolvedValue(mockState);
|
||||
|
||||
await recovery.markAsCompleted(1);
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
'rename_operation_state',
|
||||
expect.objectContaining({
|
||||
completed: [0, 1],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsFailed', () => {
|
||||
it('应该标记文件为失败', async () => {
|
||||
vi.mocked(storage.get).mockResolvedValue(mockState);
|
||||
|
||||
await recovery.markAsFailed(1);
|
||||
|
||||
expect(storage.set).toHaveBeenCalledWith(
|
||||
'rename_operation_state',
|
||||
expect.objectContaining({
|
||||
failed: [1],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showRecoveryDialog', () => {
|
||||
it('应该显示包含操作信息的对话框', () => {
|
||||
// Mock confirm
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
const result = recovery.showRecoveryDialog(mockState);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
|
||||
// 检查对话框消息包含关键信息
|
||||
const message = confirmSpy.mock.calls[0][0];
|
||||
expect(message).toContain('quark');
|
||||
expect(message).toContain('3'); // 总文件数
|
||||
expect(message).toContain('1'); // 已完成
|
||||
expect(message).toContain('2'); // 待完成
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
expect(adapter.renameCalls[0]).toBe('custom.txt');
|
||||
});
|
||||
});
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户