Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions e2e/mcp/api/assets/operation.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,31 +567,57 @@ export class CorrectComponent extends Component {
});

describe('asset-rename', () => {
test('should rename asset by newName in the same directory', async () => {
const sourceName = generateTestFileName('rename-source', 'txt');
const targetName = generateTestFileName('rename-target', 'txt');
const sourceUrl = `${context.testRootUrl}/${sourceName}`;
const targetPath = join(context.testRootPath, targetName);

await context.mcpClient.callTool('assets-create-asset', {
options: {
target: sourceUrl,
content: TEST_ASSET_CONTENTS.text,
},
});

const result = await context.mcpClient.callTool('assets-rename-asset', {
source: sourceUrl,
newName: targetName,
options: {},
});

expect(result.code).toBe(200);
validateAssetMoved(join(context.testRootPath, sourceName), targetPath);
expect(readFileSync(targetPath, 'utf8')).toEqual(TEST_ASSET_CONTENTS.text);
});

test('should handle renaming to existing name', async () => {
const name1 = `rename-exist-1-${generateTestId()}`;
const name2 = `rename-exist-2-${generateTestId()}`;
const name1 = generateTestFileName('rename-exist-1', 'txt');
const name2 = generateTestFileName('rename-exist-2', 'txt');
const sourceUrl = `${context.testRootUrl}/${name1}`;

// 创建两个资源
await context.mcpClient.callTool('assets-create-asset', {
options: {
target: `${context.testRootUrl}/${name1}`,
target: sourceUrl,
content: 'rename-source',
},
});

await context.mcpClient.callTool('assets-create-asset', {
options: {
target: `${context.testRootUrl}/${name2}`,
content: 'rename-target',
},
});

// 尝试重命名到已存在的名称
const result = await context.mcpClient.callTool('assets-rename-asset', {
source: `${context.testRootUrl}/${name1}`,
target: `${context.testRootUrl}/${name2}`,
source: sourceUrl,
newName: name2,
options: {},
});

// 应该失败或使用 rename 选项
expect(result.code).not.toBe(200);
});
});
Expand Down
10 changes: 6 additions & 4 deletions src/api/assets/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import {
TSaveAssetResult,
TRefreshDirResult,
SchemaBaseName,
SchemaAssetNewName,
TBaseName,
TAssetNewName,
SchemaRefreshDirResult,
SchemaCreateAssetByTypeOptions,
TCreateAssetByTypeOptions,
Expand Down Expand Up @@ -563,11 +565,11 @@ export class AssetsApi {
*/
@tool('assets-rename-asset')
@title('Rename Asset') // 重命名资源
@description('Rename the specified asset file. Supports renaming files and folders, with options to overwrite or automatically rename.') // 重命名指定的资源文件。支持重命名文件和文件夹,可选择是否覆盖或自动重命名
@description('Rename the specified asset in its current directory. The source can be a URL, UUID, or path. The newName parameter only changes the asset name and does not move it across directories; use moveAsset for moving. For file assets, include the extension in newName. Supports overwrite or automatic rename on conflicts.') // 在资源当前目录内重命名指定资源。source 支持 URL、UUID 或路径。newName 仅修改名称,不负责跨目录移动;如需移动请使用 moveAsset。文件资源请在 newName 中包含后缀名。支持冲突时覆盖或自动重命名
@result(SchemaAssetInfoResult)
async renameAsset(
@param(SchemaUrlOrUUIDOrPath) source: TDirOrDbPath,
@param(SchemaUrlOrUUIDOrPath) target: TDirOrDbPath,
@param(SchemaUrlOrUUIDOrPath) source: TUrlOrUUIDOrPath,
@param(SchemaAssetNewName) newName: TAssetNewName,
@param(SchemaAssetRenameOptions) options: TAssetRenameOptions = {}
): Promise<CommonResultType<TAssetInfoResult>> {
const code: HttpStatusCode = COMMON_STATUS.SUCCESS;
Expand All @@ -577,7 +579,7 @@ export class AssetsApi {
};

try {
ret.data = await assetManager.renameAsset(source, target, options);
ret.data = await assetManager.renameAsset(source, newName, options);
} catch (e) {
ret.code = COMMON_STATUS.FAIL;
console.error('rename asset fail:', e instanceof Error ? e.message : String(e));
Expand Down
9 changes: 8 additions & 1 deletion src/api/assets/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ export const SchemaQueryAssetsOption = z.object({
export const SchemaSupportCreateType = z.enum(SUPPORT_CREATE_TYPES as any).describe('Supported asset handler types for creation'); // 支持创建的资源处理器类型
export const SchemaTargetPath = z.string().min(1).describe('Target path, asset will be created or imported to this path'); // 目标路径,资源将被创建或导入到此路径
export const SchemaBaseName = z.string().min(1).describe('Base name, asset will be created or imported to this name'); // 基础名称,资源将被创建或导入到此名称
export const SchemaAssetNewName = z.string()
.min(1)
.refine((value) => value !== '.' && value !== '..' && !value.startsWith('db://') && !/[\\/]/.test(value), {
message: 'New name must be a single file or directory name, not a path',
})
.describe('New asset name in the current directory. For file assets, include the file extension.'); // 新的资源名称,仅用于当前目录下重命名;文件资源需要包含后缀名
export const SchemaAssetOperationOption = z.object({
overwrite: z.boolean().optional().describe('Whether to force overwrite existing files, default false'), // 是否强制覆盖已存在的文件,默认 false
rename: z.boolean().optional().describe('Whether to automatically rename conflicting files, default false'), // 是否自动重命名冲突文件,默认 false
Expand Down Expand Up @@ -214,6 +220,7 @@ export type TDataKeys = z.infer<typeof SchemaDataKeys>;
export type TQueryAssetsOption = z.infer<typeof SchemaQueryAssetsOption> | undefined;
export type TSupportCreateType = z.infer<typeof SchemaSupportCreateType>;
export type TTargetPath = z.infer<typeof SchemaTargetPath>;
export type TAssetNewName = z.infer<typeof SchemaAssetNewName>;
export type TAssetOperationOption = z.infer<typeof SchemaAssetOperationOption> | undefined;
export type TSourcePath = z.infer<typeof SchemaSourcePath>;
export type TAssetData = z.infer<typeof SchemaAssetData>;
Expand Down Expand Up @@ -279,4 +286,4 @@ export const SchemaAssetConfig = z.object({
}).describe('Asset configuration information'); // 资源配置信息

export const SchemaAssetConfigMapResult = z.record(z.string(), SchemaAssetConfig).describe('Asset configuration map, key is asset handler name, value is corresponding configuration information'); // 资源配置映射表,键为资源处理器名称,值为对应的配置信息
export type TAssetConfigMapResult = z.infer<typeof SchemaAssetConfigMapResult>;
export type TAssetConfigMapResult = z.infer<typeof SchemaAssetConfigMapResult>;
42 changes: 28 additions & 14 deletions src/core/assets/manager/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { refresh, reimport, queryUrl, Asset } from '@cocos/asset-db';
import { copy, move, remove, rename, existsSync } from 'fs-extra';
import { isAbsolute, dirname, basename, join, relative, extname } from 'path';
import { isAbsolute, dirname, join, relative, extname } from 'path';
import { IMoveOptions } from '../@types/private';
import { IAsset, CreateAssetOptions, IExportOptions, IExportData, CreateAssetByTypeOptions, ICreateMenuInfo } from '../@types/protected';
import { AssetOperationOption, AssetUserDataMap, IAssetInfo, IAssetMeta, ISupportCreateType } from '../@types/public';
Expand Down Expand Up @@ -51,6 +51,24 @@ class AssetOperation extends EventEmitter {
return path;
}

_checkRenameNewName(asset: IAsset, newName: string) {
if (!newName || newName === '.' || newName === '..') {
throw new Error('newName must be a single file or directory name');
}

if (
newName.startsWith('db://')
|| isAbsolute(newName)
|| /[\\/]/.test(newName)
) {
throw new Error('newName must be a single file or directory name');
}

if (!asset.isDirectory() && !extname(newName)) {
throw new Error('newName must include file extension');
}
}

async saveAssetMeta(uuid: string, meta: IAssetMeta, asset?: IAsset) {
// 不能为数组
if (
Expand Down Expand Up @@ -360,35 +378,31 @@ class AssetOperation extends EventEmitter {
/**
* 重命名某个资源
* @param source
* @param target
* @param newName
*/
async renameAsset(source: string, target: string, option?: AssetOperationOption) {
return await assetDBManager.addTask(this._renameAsset.bind(this), [source, target, option]);
async renameAsset(source: string, newName: string, option?: AssetOperationOption) {
return await assetDBManager.addTask(this._renameAsset.bind(this), [source, newName, option]);
}

private async _renameAsset(source: string, target: string, option?: AssetOperationOption) {
console.debug(`start rename asset from ${source} -> ${target}...`);
private async _renameAsset(source: string, newName: string, option?: AssetOperationOption) {
console.debug(`start rename asset from ${source} -> ${newName}...`);
const asset = assetQuery.queryAsset(source);
if (!asset) {
throw new Error(`asset in source file ${source} not exists`);
}
this._checkReadonly(asset);
source = asset.source;
this._checkExists(source);
if (target.startsWith('db://')) {
target = url2path(target);
}
this._checkRenameNewName(asset, newName);

let target = join(dirname(source), newName);
target = this._checkOverwrite(target, option);
// 源地址不能被目标地址包含,也不能相等
if (target.startsWith(join(source, '/'))) {
throw new Error(`${i18n.t('assets.rename_asset.fail.parent')} \nsource: ${source}\ntarget: ${target}`);
}

const uri = {
basename: basename(target),
dirname: dirname(target),
};
const temp = join(uri.dirname, '.rename_temp');
const temp = join(dirname(target), '.rename_temp');

// 改到临时路径,然后刷新,删除原来的缓存
await rename(source + '.meta', temp + '.meta');
Expand Down
108 changes: 108 additions & 0 deletions src/core/assets/test/operation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,114 @@ describe('测试 db 的操作接口', function () {
// });
// });

describe('rename-asset', () => {
it('使用 url 重命名普通资源时,第二个参数只接收 newName', async function () {
const sourceName = `${name}_rename-file.txt`;
const targetName = `${name}_rename-file-next.txt`;
const sourcePath = join(databasePath, sourceName);
const targetPath = join(databasePath, targetName);

await assetManager.createAsset({
target: sourcePath,
content: 'rename-file',
overwrite: true,
});

await assetManager.renameAsset(`${TestGlobalEnv.testRootUrl}/${sourceName}`, targetName);

expect(existsSync(sourcePath)).toStrictEqual(false);
expect(existsSync(`${sourcePath}.meta`)).toStrictEqual(false);
expect(existsSync(targetPath)).toStrictEqual(true);
expect(existsSync(`${targetPath}.meta`)).toStrictEqual(true);
expect(readFileSync(targetPath, 'utf8')).toStrictEqual('rename-file');
});

it('使用 uuid 重命名文件夹时,newName 保持在原目录下', async function () {
const sourceName = `${name}_rename-folder`;
const targetName = `${name}_rename-folder-next`;
const sourcePath = join(databasePath, sourceName);
const targetPath = join(databasePath, targetName);

const asset = await assetManager.createAsset({
target: sourcePath,
});

await assetManager.renameAsset(asset!.uuid, targetName);

expect(existsSync(sourcePath)).toStrictEqual(false);
expect(existsSync(`${sourcePath}.meta`)).toStrictEqual(false);
expect(existsSync(targetPath)).toStrictEqual(true);
expect(existsSync(`${targetPath}.meta`)).toStrictEqual(true);
expect(statSync(targetPath).isDirectory()).toStrictEqual(true);
});

it('使用绝对路径重命名时,rename 选项会在名称冲突后自动生成新名称', async function () {
const sourceName = `${name}_rename-conflict-source.txt`;
const targetName = `${name}_rename-conflict-target.txt`;
const autoRenamedName = `${name}_rename-conflict-target-001.txt`;
const sourcePath = join(databasePath, sourceName);
const targetPath = join(databasePath, targetName);
const autoRenamedPath = join(databasePath, autoRenamedName);

await assetManager.createAsset({
target: sourcePath,
content: 'source-content',
overwrite: true,
});
await assetManager.createAsset({
target: targetPath,
content: 'target-content',
overwrite: true,
});

await assetManager.renameAsset(sourcePath, targetName, {
rename: true,
});

expect(existsSync(sourcePath)).toStrictEqual(false);
expect(existsSync(targetPath)).toStrictEqual(true);
expect(readFileSync(targetPath, 'utf8')).toStrictEqual('target-content');
expect(existsSync(autoRenamedPath)).toStrictEqual(true);
expect(readFileSync(autoRenamedPath, 'utf8')).toStrictEqual('source-content');
});

it('重命名文件时 newName 必须带后缀名', async function () {
const sourceName = `${name}_rename-no-ext.txt`;
const sourcePath = join(databasePath, sourceName);

await assetManager.createAsset({
target: sourcePath,
content: 'rename-no-ext',
overwrite: true,
});

await expect(assetManager.renameAsset(sourcePath, `${name}_rename-no-ext-next`)).rejects.toThrow(
'newName must include file extension'
);

expect(existsSync(sourcePath)).toStrictEqual(true);
expect(readFileSync(sourcePath, 'utf8')).toStrictEqual('rename-no-ext');
});

it('newName 包含路径分隔符时应该失败', async function () {
const sourceName = `${name}_rename-invalid.txt`;
const sourcePath = join(databasePath, sourceName);

await assetManager.createAsset({
target: sourcePath,
content: 'rename-invalid',
overwrite: true,
});

await expect(assetManager.renameAsset(sourcePath, `nested/${name}_rename-invalid-next.txt`)).rejects.toThrow(
'newName must be a single file or directory name'
);

expect(existsSync(sourcePath)).toStrictEqual(true);
expect(readFileSync(sourcePath, 'utf8')).toStrictEqual('rename-invalid');
});
});

describe('delete-asset', () => {
describe('删除文件夹', function () {

Expand Down
4 changes: 2 additions & 2 deletions src/lib/assets/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,10 @@ export async function querySortedPlugins(
*/
export async function renameAsset(
source: string,
target: string,
newName: string,
options: AssetOperationOption = {}
): Promise<any> {
return await assetManager.renameAsset(source, target, options);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

接口级别的修改,需要改 sdk 支持 PinK 的版本号,这个 pr 要先挂着,等一下 PinK 适配好了,再合并

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按照约定,pr 名称加上 [break] 前缀即可

return await assetManager.renameAsset(source, newName, options);
}

/**
Expand Down
Loading