diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 2825232b8..af8507e76 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -895,7 +895,8 @@ export declare interface ThumbnailInfo { value: string; } export declare type ThumbnailSize = 'large' | 'small' | 'middle' | 'origin'; -export declare function updateAssetUserData(urlOrUuidOrPath: string, path: string, value: any): Promise; +export declare function updateAssetUserData(urlOrUuidOrPath: string, userData: Record): Promise; +export declare function updateAssetUserDataByPath(urlOrUuidOrPath: string, path: string, value: any): Promise; export declare function updateDefaultUserData(handler: string, path: string, value: any): Promise; export declare interface Vec2 { x: number; @@ -7066,6 +7067,7 @@ export declare namespace Assets { updateDefaultUserData, queryAssetUserDataConfig, updateAssetUserData, + updateAssetUserDataByPath, queryAssetConfigMap, queryPropertySchema, queryThumbnailHandlers, @@ -8454,7 +8456,8 @@ export declare interface ThumbnailInfo { } export declare type ThumbnailSize = 'large' | 'small' | 'middle' | 'origin'; export declare function unregister(): Promise; -export declare function updateAssetUserData(urlOrUuidOrPath: string, path: string, value: any): Promise; +export declare function updateAssetUserData(urlOrUuidOrPath: string, userData: Record): Promise; +export declare function updateAssetUserDataByPath(urlOrUuidOrPath: string, path: string, value: any): Promise; export declare function updateDefaultUserData(handler: string, path: string, value: any): Promise; export declare interface Vec2 { x: number; diff --git a/src/api/assets/assets.ts b/src/api/assets/assets.ts index 54822262d..bca5a8d78 100644 --- a/src/api/assets/assets.ts +++ b/src/api/assets/assets.ts @@ -69,9 +69,11 @@ import { TAssetMoveOptions, TAssetRenameOptions, TUserDataHandler, + SchemaUpdateAssetUserData, SchemaUpdateAssetUserDataPath, SchemaUpdateAssetUserDataValue, SchemaUpdateAssetUserDataResult, + TUpdateAssetUserData, TUpdateAssetUserDataPath, TUpdateAssetUserDataValue, TUpdateAssetUserDataResult, @@ -960,9 +962,41 @@ export class AssetsApi { */ @tool('assets-update-asset-user-data') @title('Update Asset User Data') // 更新资源用户数据 - @description('Update the userData of the specified asset via path and value. urlOrUuidOrPath accepts an asset URL, UUID, file path, or sub asset UUID in parentUuid@subMetaId format.') // 更新指定资源的用户数据配置。通过路径和值来精确更新资源的用户数据,支持嵌套路径访问。 + @description('Replace the complete userData object of the specified asset in one save. urlOrUuidOrPath accepts an asset URL, UUID, file path, or sub asset UUID in parentUuid@subMetaId format.') // 一次性整体替换指定资源的 userData,支持父资源与 parentUuid@subMetaId 子资源 UUID。 @result(SchemaUpdateAssetUserDataResult) async updateAssetUserData( + @param(SchemaUrlOrUUIDOrPath) urlOrUuidOrPath: TUrlOrUUIDOrPath, + @param(SchemaUpdateAssetUserData) userData: TUpdateAssetUserData + ): Promise> { + const code: HttpStatusCode = COMMON_STATUS.SUCCESS; + const ret: CommonResultType = { + code: code, + data: null, + }; + + try { + ret.data = await assetManager.updateUserData(urlOrUuidOrPath, userData); + if (!ret.data) { + ret.code = COMMON_STATUS.NOT_FOUND; + ret.reason = `❌Asset can not be found: ${urlOrUuidOrPath}. Please refresh asset db and try again.`; + } + } catch (e) { + ret.code = getCommonErrorStatus(e); + console.error('update asset user data fail:', e instanceof Error ? e.message : String(e)); + ret.reason = e instanceof Error ? e.message : String(e); + } + + return ret; + } + + /** + * Update Asset User Data By Path // 按路径更新资源用户数据 + */ + @tool('assets-update-asset-user-data-by-path') + @title('Update Asset User Data By Path') // 按路径更新资源用户数据 + @description('Update a single path in the userData of the specified asset. urlOrUuidOrPath accepts an asset URL, UUID, file path, or sub asset UUID in parentUuid@subMetaId format.') // 通过路径和值精确更新指定资源 userData 的单个字段,支持父资源与 parentUuid@subMetaId 子资源 UUID。 + @result(SchemaUpdateAssetUserDataResult) + async updateAssetUserDataByPath( @param(SchemaUrlOrUUIDOrPath) urlOrUuidOrPath: TUrlOrUUIDOrPath, @param(SchemaUpdateAssetUserDataPath) path: TUpdateAssetUserDataPath, @param(SchemaUpdateAssetUserDataValue) value: TUpdateAssetUserDataValue @@ -974,14 +1008,14 @@ export class AssetsApi { }; try { - ret.data = await assetManager.updateUserData(urlOrUuidOrPath, path, value); + ret.data = await assetManager.updateUserDataByPath(urlOrUuidOrPath, path, value); if (!ret.data) { ret.code = COMMON_STATUS.NOT_FOUND; ret.reason = `❌Asset can not be found: ${urlOrUuidOrPath}. Please refresh asset db and try again.`; } } catch (e) { ret.code = getCommonErrorStatus(e); - console.error('update asset user data fail:', e instanceof Error ? e.message : String(e)); + console.error('update asset user data by path fail:', e instanceof Error ? e.message : String(e)); ret.reason = e instanceof Error ? e.message : String(e); } diff --git a/src/api/assets/schema.ts b/src/api/assets/schema.ts index ec64aaded..85e0ec3d1 100644 --- a/src/api/assets/schema.ts +++ b/src/api/assets/schema.ts @@ -303,6 +303,9 @@ export type TUpdateUserDataOptions = z.infer export type TUserDataHandler = z.infer; // Update Asset User Data related Schema // Update Asset User Data 相关 Schema +export const SchemaUpdateAssetUserData = z.record(z.string(), z.any()).describe('Complete asset userData object to replace the existing userData'); // 用于整体替换现有 userData 的完整资源 userData 对象 +export type TUpdateAssetUserData = z.infer; + export const SchemaUpdateAssetUserDataPath = z.string().min(1).describe('User data path, separated by dots, e.g. "texture.wrapMode"'); // 用户数据路径,使用点号分隔,如 "texture.wrapMode" export type TUpdateAssetUserDataPath = z.infer; diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index 8ac195f23..ac0107f46 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -48,6 +48,7 @@ class AssetManager extends EventEmitter { outputExportData = assetOperation.outputExportData.bind(assetOperation); createAssetByType = assetOperation.createAssetByType.bind(assetOperation); updateUserData = assetOperation.updateUserData.bind(assetOperation); + updateUserDataByPath = assetOperation.updateUserDataByPath.bind(assetOperation); querySerializedData = serializedData.querySerializedData; saveSerializedData = serializedData.saveSerializedData; @@ -362,6 +363,7 @@ export interface TypedAssetManager extends EventEmitter { outputExportData: typeof assetOperation.outputExportData; createAssetByType: typeof assetOperation.createAssetByType; updateUserData: typeof assetOperation.updateUserData; + updateUserDataByPath: typeof assetOperation.updateUserDataByPath; querySerializedData: typeof serializedData.querySerializedData; saveSerializedData: typeof serializedData.saveSerializedData; diff --git a/src/core/assets/manager/operation.ts b/src/core/assets/manager/operation.ts index f3a88b51b..1de93198f 100644 --- a/src/core/assets/manager/operation.ts +++ b/src/core/assets/manager/operation.ts @@ -274,15 +274,48 @@ class AssetOperation extends EventEmitter { await asset._assetDB.reimport(asset.uuid); } - async updateUserData(uuidOrURLOrPath: string, path: string, value: any): Promise { + async updateUserData(uuidOrURLOrPath: string, userData: AssetUserDataMap[T]): Promise { + if (!isRecord(userData)) { + throw new Error('userData must be an object'); + } + + const asset = assetQuery.queryAsset(uuidOrURLOrPath); + if (!asset) { + console.error(`can not find asset ${uuidOrURLOrPath}`); + return; + } + + if (!isRecord(asset.meta.userData)) { + asset.meta.userData = {} as AssetUserDataMap[T]; + } + const currentUserData = asset.meta.userData as Record; + for (const key of Object.keys(currentUserData)) { + delete currentUserData[key]; + } + Object.assign(currentUserData, lodash.cloneDeep(userData)); + asset.meta.userData = currentUserData as AssetUserDataMap[T]; + await asset.save(); + await asset._assetDB.reimport(asset.uuid); + return asset?.meta.userData as AssetUserDataMap[T]; + } + + async updateUserDataByPath(uuidOrURLOrPath: string, path: string, value: any): Promise { + if (!path) { + throw new Error('path must not be empty. Use updateUserData to replace the complete userData object'); + } + const asset = assetQuery.queryAsset(uuidOrURLOrPath); if (!asset) { console.error(`can not find asset ${uuidOrURLOrPath}`); return; } + if (!isRecord(asset.meta.userData)) { + asset.meta.userData = {} as AssetUserDataMap[T]; + } lodash.set(asset?.meta.userData, path, value); await asset.save(); - return asset?.meta.userData; + await asset._assetDB.reimport(asset.uuid); + return asset?.meta.userData as AssetUserDataMap[T]; } async saveAsset(uuidOrURLOrPath: string, content: string | Buffer) { diff --git a/src/core/assets/test/operation-filesystem-bridge.test.ts b/src/core/assets/test/operation-filesystem-bridge.test.ts index fc44d5a0e..8553594f8 100644 --- a/src/core/assets/test/operation-filesystem-bridge.test.ts +++ b/src/core/assets/test/operation-filesystem-bridge.test.ts @@ -166,7 +166,7 @@ describe('asset operation filesystem bridge', () => { jest.restoreAllMocks(); }); - it('updateUserData updates sub asset userData through composite uuid without reimport', async () => { + it('updateUserData replaces sub asset userData through composite uuid with one reimport', async () => { const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); const reimport = jest.fn(); const subAsset = { @@ -174,6 +174,7 @@ describe('asset operation filesystem bridge', () => { meta: { userData: { minfilter: 'linear', + obsolete: true, }, }, save: jest.fn().mockResolvedValue(true), @@ -183,7 +184,41 @@ describe('asset operation filesystem bridge', () => { }; mockQueryAsset.mockReturnValue(subAsset); + const userData = { + minfilter: 'nearest', + wrapMode: 'clamp', + }; const result = await assetOperation.updateUserData( + '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', + userData, + ); + + expect(mockQueryAsset).toHaveBeenCalledWith('6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a'); + expect(subAsset.meta.userData).toEqual(userData); + expect(subAsset.save).toHaveBeenCalledTimes(1); + expect(reimport).toHaveBeenCalledTimes(1); + expect(reimport).toHaveBeenCalledWith('6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a'); + expect(result).toBe(subAsset.meta.userData); + }); + + it('updateUserDataByPath updates sub asset userData through composite uuid with one reimport', async () => { + const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); + const reimport = jest.fn(); + const subAsset = { + uuid: '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', + meta: { + userData: { + minfilter: 'linear', + }, + }, + save: jest.fn().mockResolvedValue(true), + _assetDB: { + reimport, + }, + }; + mockQueryAsset.mockReturnValue(subAsset); + + const result = await assetOperation.updateUserDataByPath( '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', 'minfilter', 'nearest', @@ -194,10 +229,22 @@ describe('asset operation filesystem bridge', () => { minfilter: 'nearest', }); expect(subAsset.save).toHaveBeenCalledTimes(1); - expect(reimport).not.toHaveBeenCalled(); + expect(reimport).toHaveBeenCalledTimes(1); + expect(reimport).toHaveBeenCalledWith('6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a'); expect(result).toBe(subAsset.meta.userData); }); + it('updateUserDataByPath rejects empty path instead of replacing complete userData', async () => { + const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); + + await expect(assetOperation.updateUserDataByPath( + '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', + '', + { minfilter: 'nearest' }, + )).rejects.toThrow('path must not be empty'); + expect(mockQueryAsset).not.toHaveBeenCalled(); + }); + it('renameAsset should delegate rename steps to filesystem bridge', async () => { const { assetOperation } = require('../manager/operation') as typeof import('../manager/operation'); const source = 'D:/project/assets/source.txt'; diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index 1df826929..19f6285c4 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -344,11 +344,21 @@ export async function queryAssetUserDataConfig( * Update Asset User Data // 更新资源用户数据 */ export async function updateAssetUserData( + urlOrUuidOrPath: string, + userData: Record +): Promise { + return await assetManager.updateUserData(urlOrUuidOrPath, userData); +} + +/** + * Update Asset User Data By Path // 按路径更新资源用户数据 + */ +export async function updateAssetUserDataByPath( urlOrUuidOrPath: string, path: string, value: any ): Promise { - return await assetManager.updateUserData(urlOrUuidOrPath, path, value); + return await assetManager.updateUserDataByPath(urlOrUuidOrPath, path, value); } /** diff --git a/tests/assets-update-asset-user-data-api.test.ts b/tests/assets-update-asset-user-data-api.test.ts index 5b944523a..e50ad8181 100644 --- a/tests/assets-update-asset-user-data-api.test.ts +++ b/tests/assets-update-asset-user-data-api.test.ts @@ -1,9 +1,11 @@ const mockUpdateUserData = jest.fn(); +const mockUpdateUserDataByPath = jest.fn(); jest.mock('../src/core/assets', () => ({ assetDBManager: {}, assetManager: { updateUserData: (...args: unknown[]) => mockUpdateUserData(...args), + updateUserDataByPath: (...args: unknown[]) => mockUpdateUserDataByPath(...args), }, })); @@ -16,6 +18,7 @@ import { AssetsApi } from '../src/api/assets/assets'; describe('assets-update-asset-user-data api', () => { beforeEach(() => { mockUpdateUserData.mockReset(); + mockUpdateUserDataByPath.mockReset(); }); it('does not register a separate meta userData update tool', () => { @@ -30,11 +33,31 @@ describe('assets-update-asset-user-data api', () => { expect(schema!.parse('6FA5FBAD0D324B6395D824507665775C@6C48A')).toBe('6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a'); }); - it('delegates sub asset UUID updates to assetManager.updateUserData', async () => { - const updatedUserData = { minfilter: 'nearest' }; + it('delegates complete userData replacement to assetManager.updateUserData', async () => { + const userData = { minfilter: 'nearest', wrapMode: 'clamp' }; + const updatedUserData = { ...userData }; mockUpdateUserData.mockResolvedValue(updatedUserData); const result = await new AssetsApi().updateAssetUserData( + '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', + userData, + ); + + expect(result).toEqual({ + code: COMMON_STATUS.SUCCESS, + data: updatedUserData, + }); + expect(mockUpdateUserData).toHaveBeenCalledWith( + '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', + userData, + ); + }); + + it('delegates path updates to assetManager.updateUserDataByPath', async () => { + const updatedUserData = { minfilter: 'nearest' }; + mockUpdateUserDataByPath.mockResolvedValue(updatedUserData); + + const result = await new AssetsApi().updateAssetUserDataByPath( '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', 'minfilter', 'nearest', @@ -44,7 +67,7 @@ describe('assets-update-asset-user-data api', () => { code: COMMON_STATUS.SUCCESS, data: updatedUserData, }); - expect(mockUpdateUserData).toHaveBeenCalledWith( + expect(mockUpdateUserDataByPath).toHaveBeenCalledWith( '6fa5fbad-0d32-4b63-95d8-24507665775c@6c48a', 'minfilter', 'nearest', diff --git a/tests/lib/assets-api.test.ts b/tests/lib/assets-api.test.ts index 2fa265d46..e643a99e3 100644 --- a/tests/lib/assets-api.test.ts +++ b/tests/lib/assets-api.test.ts @@ -14,14 +14,14 @@ describe('lib assets api', () => { expect((Assets as { updateAssetMetaUserData?: unknown }).updateAssetMetaUserData).toBeUndefined(); }); - it('updateAssetUserData delegates sub asset uuid to assetManager', async () => { - const result = { minfilter: 'nearest' }; + it('updateAssetUserData delegates complete userData replacement to assetManager', async () => { + const userData = { minfilter: 'nearest', wrapMode: 'clamp' }; + const result = { ...userData }; const spy = jest.spyOn(assetManager, 'updateUserData').mockResolvedValue(result); const updateAssetUserData = (Assets as { updateAssetUserData?: ( urlOrUuidOrPath: string, - path: string, - value: unknown + userData: Record ) => Promise; }).updateAssetUserData; @@ -31,7 +31,28 @@ describe('lib assets api', () => { throw new Error('updateAssetUserData is not exposed from lib/assets/assets'); } - await expect(updateAssetUserData('parent-uuid@6c48a', 'minfilter', 'nearest')).resolves.toBe(result); + await expect(updateAssetUserData('parent-uuid@6c48a', userData)).resolves.toBe(result); + expect(spy).toHaveBeenCalledWith('parent-uuid@6c48a', userData); + }); + + it('updateAssetUserDataByPath delegates path updates to assetManager', async () => { + const result = { minfilter: 'nearest' }; + const spy = jest.spyOn(assetManager, 'updateUserDataByPath').mockResolvedValue(result); + const updateAssetUserDataByPath = (Assets as { + updateAssetUserDataByPath?: ( + urlOrUuidOrPath: string, + path: string, + value: unknown + ) => Promise; + }).updateAssetUserDataByPath; + + expect(updateAssetUserDataByPath).toEqual(expect.any(Function)); + + if (!updateAssetUserDataByPath) { + throw new Error('updateAssetUserDataByPath is not exposed from lib/assets/assets'); + } + + await expect(updateAssetUserDataByPath('parent-uuid@6c48a', 'minfilter', 'nearest')).resolves.toBe(result); expect(spy).toHaveBeenCalledWith('parent-uuid@6c48a', 'minfilter', 'nearest'); });