1515 */
1616import * as path from 'node:path' ;
1717import { Logger } from '@salesforce/core/logger' ;
18- import { isString } from '@salesforce/ts-types' ;
18+ import { isString , JsonMap } from '@salesforce/ts-types' ;
1919import fs from 'graceful-fs' ;
20- import { ConvertOutputConfig } from '../convert/types' ;
21- import { MetadataConverter } from '../convert/metadataConverter' ;
22- import { ComponentSet } from '../collections/componentSet' ;
23- import { ZipTreeContainer } from '../resolve/treeContainers' ;
20+ import { XMLBuilder } from 'fast-xml-parser' ;
21+ import { XML_DECL } from '../common' ;
22+ import { ConvertOutputConfig , MetadataConverter } from '../convert' ;
23+ import { ComponentSet } from '../collections' ;
24+ import { ZipTreeContainer } from '../resolve' ;
2425import { SourceComponent , SourceComponentWithContent } from '../resolve/sourceComponent' ;
2526import { fnJoin } from '../utils/path' ;
26- import { ComponentStatus , FileResponse , FileResponseSuccess , PackageOption , PackageOptions } from './types' ;
27+ import { correctComments , handleSpecialEntities } from '../convert/streams' ;
28+ import {
29+ BotVersionFilter ,
30+ ComponentStatus ,
31+ FileResponse ,
32+ FileResponseSuccess ,
33+ PackageOption ,
34+ PackageOptions ,
35+ } from './types' ;
2736import { MetadataApiRetrieveOptions } from './types' ;
2837
2938export const extract = async ( {
@@ -38,7 +47,7 @@ export const extract = async ({
3847 mainComponents ?: ComponentSet ;
3948} ) : Promise < { componentSet : ComponentSet ; partialDeleteFileResponses : FileResponse [ ] } > => {
4049 const components : SourceComponent [ ] = [ ] ;
41- const { merge, output, registry } = options ;
50+ const { merge, output, registry, botVersionFilters } = options ;
4251 const converter = new MetadataConverter ( registry ) ;
4352 const tree = await ZipTreeContainer . create ( zip ) ;
4453
@@ -64,13 +73,47 @@ export const extract = async ({
6473 type : 'directory' ,
6574 outputDirectory : pkg . outputDir ,
6675 } ;
67- const retrievedComponents = ComponentSet . fromSource ( {
76+ let retrievedComponents = ComponentSet . fromSource ( {
6877 fsPaths : [ pkg . zipTreeLocation ] ,
6978 registry,
7079 tree,
7180 } )
7281 . getSourceComponents ( )
7382 . toArray ( ) ;
83+
84+ // Filter BotVersion components and GenAiPlannerBundle components right after retrieval
85+ // This is needed when rootTypesWithDependencies is used, as it will retrieve all BotVersions
86+ // and GenAiPlannerBundles regardless of what's in the manifest.
87+ // Early exit: only process if there are Bot or GenAiPlannerBundle components
88+ const hasRelevantComponents = retrievedComponents . some (
89+ ( comp ) => comp . type . name === 'Bot' || comp . type . name === 'GenAiPlannerBundle'
90+ ) ;
91+
92+ if ( hasRelevantComponents ) {
93+ // If botVersionFilters is undefined, default to 'highest' for all Bot components
94+ let filtersToUse = botVersionFilters && Array . isArray ( botVersionFilters ) ? botVersionFilters : undefined ;
95+ if ( ! filtersToUse || filtersToUse . length === 0 ) {
96+ // No filters specified - default to 'highest' for all Bot components
97+ const allBotNames = new Set < string > ( ) ;
98+ for ( const comp of retrievedComponents ) {
99+ if ( comp . type . name === 'Bot' ) {
100+ allBotNames . add ( comp . fullName ) ;
101+ }
102+ }
103+ if ( allBotNames . size > 0 ) {
104+ filtersToUse = Array . from ( allBotNames ) . map ( ( botName ) => ( {
105+ botName,
106+ versionFilter : 'highest' ,
107+ } ) ) ;
108+ }
109+ }
110+
111+ if ( filtersToUse && filtersToUse . length > 0 ) {
112+ // eslint-disable-next-line no-await-in-loop
113+ retrievedComponents = await filterAgentComponents ( retrievedComponents , filtersToUse ) ;
114+ }
115+ }
116+
74117 if ( merge ) {
75118 partialDeleteFileResponses . push (
76119 ...handlePartialDeleteMerges ( { retrievedComponents, tree, mainComponents, logger } )
@@ -204,3 +247,281 @@ const deleteFilePath =
204247
205248 return fr ;
206249 } ;
250+
251+ /**
252+ * Extracts version number from BotVersion fullName.
253+ * BotVersion fullName can be in formats like "v0", "v1", "v2" or "0", "1", "2"
254+ *
255+ * @internal Exported for testing purposes
256+ */
257+ export function extractVersionNumber ( fullName : string ) : number | null {
258+ // Match patterns like "v0", "v1", "v2" or just "0", "1", "2"
259+ const versionMatch = fullName . match ( / ^ v ? ( \d + ) $ / ) ;
260+ if ( versionMatch ) {
261+ return parseInt ( versionMatch [ 1 ] , 10 ) ;
262+ }
263+ return null ;
264+ }
265+
266+ /**
267+ * Determines if a version number matches the filter criteria.
268+ * Shared logic for both Bot and GenAiPlannerBundle filtering.
269+ *
270+ * @param versionNum The version number to check
271+ * @param versionFilter The filter criteria ('all', 'highest', or specific number)
272+ * @param highestVersion The highest version number (required when filter is 'highest')
273+ * @returns true if the version should be kept, false otherwise
274+ * @internal Exported for testing purposes
275+ */
276+ export function versionMatchesFilter (
277+ versionNum : number ,
278+ versionFilter : 'all' | 'highest' | number ,
279+ highestVersion ?: number
280+ ) : boolean {
281+ if ( versionFilter === 'all' ) {
282+ return true ;
283+ }
284+ if ( versionFilter === 'highest' ) {
285+ return highestVersion !== undefined && versionNum === highestVersion ;
286+ }
287+ // Specific version number
288+ return versionNum === versionFilter ;
289+ }
290+
291+ /**
292+ * Filters BotVersion entries from a Bot XML based on version filter criteria.
293+ *
294+ * @internal Exported for testing purposes
295+ */
296+ export function filterBotVersionEntries (
297+ botVersions : Array < { fullName ?: string } > ,
298+ versionFilter : 'all' | 'highest' | number
299+ ) : Array < { fullName ?: string } > {
300+ if ( versionFilter === 'all' ) {
301+ return botVersions ;
302+ }
303+
304+ // Extract version numbers and find highest if needed
305+ const versionsWithNumbers : Array < { version : { fullName ?: string } ; versionNum : number ; index : number } > = [ ] ;
306+ let highestVersion = - 1 ;
307+
308+ for ( let i = 0 ; i < botVersions . length ; i ++ ) {
309+ const version = botVersions [ i ] ;
310+ if ( version ?. fullName ) {
311+ const versionNum = extractVersionNumber ( version . fullName ) ;
312+ if ( versionNum !== null ) {
313+ versionsWithNumbers . push ( { version, versionNum, index : i } ) ;
314+ if ( versionNum > highestVersion ) {
315+ highestVersion = versionNum ;
316+ }
317+ }
318+ }
319+ }
320+
321+ // Filter using shared logic
322+ return versionsWithNumbers
323+ . filter ( ( { versionNum } ) => versionMatchesFilter ( versionNum , versionFilter , highestVersion ) )
324+ . map ( ( { version } ) => version ) ;
325+ }
326+
327+ /**
328+ * Filters Bot and GenAiPlannerBundle components based on botVersionFilters.
329+ * For Bot components: modifies XML to filter BotVersion entries.
330+ * For GenAiPlannerBundle components: removes components that don't match filter criteria.
331+ *
332+ * @param components Retrieved source components
333+ * @param botVersionFilters Version filter rules for bots
334+ * @returns Components with filtered BotVersion entries and GenAiPlannerBundle components
335+ * @internal Exported for testing purposes
336+ */
337+ // WeakMap to store normalized Bot XML structures for components that have been filtered
338+ // This allows us to return the normalized structure when parseXml is called
339+ const normalizedBotXmlMap = new WeakMap < SourceComponent , JsonMap > ( ) ;
340+
341+ export async function filterAgentComponents (
342+ components : SourceComponent [ ] ,
343+ botVersionFilters : BotVersionFilter [ ]
344+ ) : Promise < SourceComponent [ ] > {
345+ const filterMap = new Map < string , BotVersionFilter > ( ) ;
346+ for ( const filter of botVersionFilters ) {
347+ const botFilter : BotVersionFilter = filter ;
348+ filterMap . set ( botFilter . botName , botFilter ) ;
349+ }
350+
351+ // Pre-compute which bots need 'highest' filtering
352+ const botsNeedingHighest = new Set < string > ( ) ;
353+ for ( const filter of botVersionFilters ) {
354+ const botFilter : BotVersionFilter = filter ;
355+ if ( botFilter . versionFilter === 'highest' ) {
356+ botsNeedingHighest . add ( botFilter . botName ) ;
357+ }
358+ }
359+
360+ // Single pass: pre-compute highest versions, collect Bot components for async processing,
361+ // and collect GenAiPlannerBundle components for filtering
362+ const highestVersions = new Map < string , number > ( ) ;
363+ const botComponents : SourceComponent [ ] = [ ] ;
364+ const genAiPlannerBundles : SourceComponent [ ] = [ ] ;
365+ const filtered : SourceComponent [ ] = [ ] ;
366+
367+ for ( const comp of components ) {
368+ if ( comp . type . name === 'Bot' ) {
369+ // Collect Bot components for async processing
370+ botComponents . push ( comp ) ;
371+ // Include in result (will be modified in place)
372+ filtered . push ( comp ) ;
373+ } else if ( comp . type . name === 'GenAiPlannerBundle' ) {
374+ // Collect for filtering after we know highest versions
375+ genAiPlannerBundles . push ( comp ) ;
376+ // Pre-compute highest versions
377+ const nameMatch = comp . fullName . match ( / ^ ( .+ ) _ v ( \d + ) $ / ) ;
378+ if ( nameMatch ) {
379+ const botName = nameMatch [ 1 ] ;
380+ const versionNum = parseInt ( nameMatch [ 2 ] , 10 ) ;
381+ if ( botsNeedingHighest . has ( botName ) ) {
382+ const currentHighest = highestVersions . get ( botName ) ?? - 1 ;
383+ if ( versionNum > currentHighest ) {
384+ highestVersions . set ( botName , versionNum ) ;
385+ }
386+ }
387+ }
388+ } else {
389+ // Not a Bot or GenAiPlannerBundle, keep it
390+ filtered . push ( comp ) ;
391+ }
392+ }
393+
394+ // Filter GenAiPlannerBundle components now that we have final highest versions
395+ for ( const comp of genAiPlannerBundles ) {
396+ const nameMatch = comp . fullName . match ( / ^ ( .+ ) _ v ( \d + ) $ / ) ;
397+ if ( nameMatch ) {
398+ const botName = nameMatch [ 1 ] ;
399+ const versionNum = parseInt ( nameMatch [ 2 ] , 10 ) ;
400+ const matchingFilter = filterMap . get ( botName ) ;
401+ if ( matchingFilter ) {
402+ const highestVersion = matchingFilter . versionFilter === 'highest' ? highestVersions . get ( botName ) : undefined ;
403+ const shouldKeep = versionMatchesFilter ( versionNum , matchingFilter . versionFilter , highestVersion ) ;
404+ if ( shouldKeep ) {
405+ filtered . push ( comp ) ;
406+ }
407+ } else {
408+ // No filter for this bot, keep all GenAiPlannerBundles
409+ filtered . push ( comp ) ;
410+ }
411+ } else {
412+ // Name doesn't match expected pattern, keep it
413+ filtered . push ( comp ) ;
414+ }
415+ }
416+
417+ // Process Bot components in parallel (XML parsing is async)
418+ const botPromises = botComponents . map ( async ( comp ) => {
419+ const matchingFilter = filterMap . get ( comp . fullName ) ;
420+ if ( matchingFilter && comp . xml ) {
421+ try {
422+ // Parse the Bot XML to get BotVersion entries
423+ const botXml = await comp . parseXml < {
424+ Bot ?: { botVersions ?: Array < { fullName ?: string } > | { fullName ?: string | string [ ] } } ;
425+ } > ( ) ;
426+ const rawBotVersions = botXml . Bot ?. botVersions ;
427+
428+ // Normalize the structure: XMLParser may group multiple <fullName> elements into { fullName: ['v1', 'v2'] }
429+ // but we need [{ fullName: 'v1' }, { fullName: 'v2' }] format
430+ let normalizedBotVersions : Array < { fullName ?: string } > = [ ] ;
431+ if ( rawBotVersions ) {
432+ if ( Array . isArray ( rawBotVersions ) ) {
433+ // Already in the correct format
434+ normalizedBotVersions = rawBotVersions ;
435+ } else if ( typeof rawBotVersions === 'object' && 'fullName' in rawBotVersions ) {
436+ // XMLParser grouped format: { fullName: ['v1', 'v2'] }
437+ const fullNameValue = rawBotVersions . fullName ;
438+ if ( Array . isArray ( fullNameValue ) ) {
439+ normalizedBotVersions = fullNameValue . map ( ( fn ) => ( { fullName : fn } ) ) ;
440+ } else if ( typeof fullNameValue === 'string' ) {
441+ normalizedBotVersions = [ { fullName : fullNameValue } ] ;
442+ }
443+ }
444+ }
445+
446+ if ( normalizedBotVersions . length > 0 ) {
447+ const filteredVersions = filterBotVersionEntries ( normalizedBotVersions , matchingFilter . versionFilter ) ;
448+
449+ // Extract fullNames and reconstruct the object in the correct format
450+ const fullNames = filteredVersions . map ( ( v ) => v . fullName ) . filter ( ( f ) : f is string => ! ! f ) ;
451+
452+ // Reconstruct Bot XML with filtered versions
453+ // We manually construct the botVersions section to avoid XMLParser grouping
454+ if ( botXml . Bot ) {
455+ // Update the component's cached XML content
456+ // Build XML string using XMLBuilder, but manually construct botVersions section
457+ // to avoid XMLParser grouping multiple <fullName> elements
458+ const builder = new XMLBuilder ( {
459+ format : true ,
460+ indentBy : ' ' ,
461+ ignoreAttributes : false ,
462+ } ) ;
463+
464+ // Build XML with botVersions structure
465+ // XMLBuilder creates multiple <fullName> elements, XMLParser groups them into { fullName: ['v1', 'v2'] }
466+ // The transformer expects [{ fullName: 'v1' }, { fullName: 'v2' }]
467+ // We need to normalize this when the XML is parsed, but we can't modify the transformer
468+ // So we store a normalized version in pathContentMap and intercept parseXml calls
469+ const botWithVersions = {
470+ Bot : {
471+ ...botXml . Bot ,
472+ botVersions :
473+ fullNames . length > 0 ? { fullName : fullNames . length === 1 ? fullNames [ 0 ] : fullNames } : undefined ,
474+ } ,
475+ } ;
476+ const builtXml = String ( builder . build ( botWithVersions ) ) ;
477+ const xmlContent = correctComments ( XML_DECL . concat ( handleSpecialEntities ( builtXml ) ) ) ;
478+
479+ // Store normalized structure for later parsing
480+ // We'll intercept parseXml to return the normalized structure
481+ const normalizedBotVersionsForXml = fullNames . map ( ( fn ) => ( { fullName : fn } ) ) ;
482+ const normalizedBotXml = {
483+ ...botXml ,
484+ Bot : {
485+ ...botXml . Bot ,
486+ botVersions : normalizedBotVersionsForXml ,
487+ } ,
488+ } ;
489+
490+ // Store both the XML content and the normalized structure
491+ if ( comp . pathContentMap && comp . xml ) {
492+ comp . pathContentMap . set ( comp . xml , xmlContent ) ;
493+ // Store normalized structure in WeakMap for this component
494+ normalizedBotXmlMap . set ( comp , normalizedBotXml as JsonMap ) ;
495+
496+ // Intercept parseXml to return normalized structure for Bot components
497+ const originalParseXml = comp . parseXml . bind ( comp ) ;
498+ comp . parseXml = async < T extends JsonMap > ( xmlFilePath ?: string ) : Promise < T > => {
499+ const xml = xmlFilePath ?? comp . xml ;
500+ if ( xml === comp . xml ) {
501+ const normalized = normalizedBotXmlMap . get ( comp ) ;
502+ if ( normalized ) {
503+ // Return normalized structure for this Bot component
504+ return normalized as T ;
505+ }
506+ }
507+ // For other cases, use original parseXml
508+ return originalParseXml < T > ( xmlFilePath ) ;
509+ } ;
510+ }
511+
512+ if ( comp . pathContentMap && comp . xml ) {
513+ comp . pathContentMap . set ( comp . xml , xmlContent ) ;
514+ }
515+ }
516+ }
517+ } catch ( error ) {
518+ // Continue with unfiltered component if there's an error
519+ }
520+ }
521+ return comp ;
522+ } ) ;
523+
524+ await Promise . all ( botPromises ) ;
525+
526+ return filtered ;
527+ }
0 commit comments