UNPKG

@myea/aem-mcp-handler

Version:

Advanced AEM MCP request handler with intelligent search, multi-locale support, and comprehensive content management capabilities

1,145 lines 80 kB
import axios from "axios"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import dotenv from "dotenv"; import { getAEMConfig, isValidContentPath, isValidComponentType, isValidLocale } from './aem-config.js'; import { AEMOperationError, createAEMError, handleAEMHttpError, safeExecute, validateComponentOperation, createSuccessResponse, AEM_ERROR_CODES } from './error-handler.js'; dotenv.config(); // Get current directory for config file const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export class AEMConnector { config; auth; aemConfig; constructor() { // Load configuration synchronously this.config = this.loadConfig(); this.aemConfig = getAEMConfig(); // Use environment variables as overrides this.auth = { username: process.env.AEM_SERVICE_USER || this.config.aem.serviceUser.username, password: process.env.AEM_SERVICE_PASSWORD || this.config.aem.serviceUser.password, }; // Override host if provided in environment if (process.env.AEM_HOST) { this.config.aem.host = process.env.AEM_HOST; this.config.aem.author = process.env.AEM_HOST; } } loadConfig() { try { const configPath = join(__dirname, "../config.json"); // Note: This would need to be sync in a real implementation // For now, return default config return { aem: { host: process.env.AEM_HOST || "http://localhost:4502", author: process.env.AEM_HOST || "http://localhost:4502", publish: "http://localhost:4503", serviceUser: { username: "admin", password: "admin" }, endpoints: { content: "/content", dam: "/content/dam", query: "/bin/querybuilder.json", crxde: "/crx/de", jcr: "" } }, mcp: { name: "AEM MCP Server", version: "1.0.0" } }; } catch (error) { // Default configuration if config file doesn't exist return { aem: { host: process.env.AEM_HOST || "http://localhost:4502", author: process.env.AEM_HOST || "http://localhost:4502", publish: "http://localhost:4503", serviceUser: { username: "admin", password: "admin" }, endpoints: { content: "/content", dam: "/content/dam", query: "/bin/querybuilder.json", crxde: "/crx/de", jcr: "" } }, mcp: { name: "AEM MCP Server", version: "1.0.0" } }; } } /** * Create authenticated axios instance for AEM requests */ createAxiosInstance() { return axios.create({ baseURL: this.config.aem.host, auth: this.auth, timeout: 30000, headers: { "Content-Type": "application/json", "Accept": "application/json", }, }); } /** * Test AEM connectivity using standard AEM login endpoint */ async testConnection() { try { console.log("Testing AEM connection to:", this.config.aem.host); const client = this.createAxiosInstance(); // Test with standard AEM login endpoint const response = await client.get('/libs/granite/core/content/login.html', { timeout: 5000, validateStatus: (status) => status < 500, // Allow redirects and auth challenges }); console.log("✅ AEM connection successful! Status:", response.status); return true; } catch (error) { console.error("❌ AEM connection failed:", error.message); if (error.response) { console.error(" Status:", error.response.status); console.error(" URL:", error.config?.url); } return false; } } // All the AEM operation methods with enhanced error handling... async validateComponent(request) { return safeExecute(async () => { // Validate parameters using centralized validation validateComponentOperation(request.locale, request.page_path, request.component, request.props); // Validate against configuration if (!isValidLocale(request.locale, this.aemConfig)) { throw createAEMError(AEM_ERROR_CODES.INVALID_LOCALE, `Locale '${request.locale}' is not supported`, { locale: request.locale, allowedLocales: this.aemConfig.validation.allowedLocales }); } if (!isValidContentPath(request.page_path, this.aemConfig)) { throw createAEMError(AEM_ERROR_CODES.INVALID_PATH, `Path '${request.page_path}' is not within allowed content roots`, { path: request.page_path, allowedRoots: Object.values(this.aemConfig.contentPaths) }); } if (!isValidComponentType(request.component, this.aemConfig)) { throw createAEMError(AEM_ERROR_CODES.INVALID_COMPONENT_TYPE, `Component type '${request.component}' is not allowed`, { component: request.component, allowedTypes: this.aemConfig.components.allowedTypes }); } const client = this.createAxiosInstance(); // Check if page exists first const response = await client.get(`${request.page_path}.json`, { params: { ':depth': '2' }, timeout: this.aemConfig.queries.timeoutMs }); // Validate component properties against page structure const validation = this.validateComponentProps(response.data, request.component, request.props); return createSuccessResponse({ message: "Component validation completed successfully", pageData: response.data, component: request.component, locale: request.locale, validation: validation, configUsed: { allowedLocales: this.aemConfig.validation.allowedLocales, allowedComponents: this.aemConfig.components.allowedTypes } }, 'validateComponent'); }, 'validateComponent'); } validateComponentProps(pageData, componentType, props) { // Add component-specific validation logic here const warnings = []; const errors = []; // Basic property validation if (componentType === 'text' && !props.text && !props.richText) { warnings.push('Text component should have text or richText property'); } if (componentType === 'image' && !props.fileReference && !props.src) { errors.push('Image component requires fileReference or src property'); } return { valid: errors.length === 0, errors, warnings, componentType, propsValidated: Object.keys(props).length }; } async updateComponent(request) { return safeExecute(async () => { // Validate input parameters if (!request.componentPath || typeof request.componentPath !== 'string') { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Component path is required and must be a string'); } if (!request.properties || typeof request.properties !== 'object') { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Properties are required and must be an object'); } // Validate path is within allowed content roots if (!isValidContentPath(request.componentPath, this.aemConfig)) { throw createAEMError(AEM_ERROR_CODES.INVALID_PATH, `Component path '${request.componentPath}' is not within allowed content roots`, { path: request.componentPath, allowedRoots: Object.values(this.aemConfig.contentPaths) }); } const client = this.createAxiosInstance(); // Check if component exists before updating try { await client.get(`${request.componentPath}.json`); } catch (error) { if (error.response?.status === 404) { throw createAEMError(AEM_ERROR_CODES.COMPONENT_NOT_FOUND, `Component not found at path: ${request.componentPath}`, { componentPath: request.componentPath }); } throw handleAEMHttpError(error, 'updateComponent'); } // Store original values for rollback capability const originalResponse = await client.get(`${request.componentPath}.json`); const originalProperties = originalResponse.data; // Convert properties to form data format const formData = new URLSearchParams(); Object.entries(request.properties).forEach(([key, value]) => { if (value === null || value === undefined) { // Handle property deletion with @Delete formData.append(`${key}@Delete`, ''); } else if (Array.isArray(value)) { // Handle array values value.forEach((item, index) => { formData.append(`${key}`, item.toString()); }); } else if (typeof value === 'object') { // Handle nested objects formData.append(key, JSON.stringify(value)); } else { // Handle primitive values formData.append(key, value.toString()); } }); const response = await client.post(request.componentPath, formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, timeout: this.aemConfig.queries.timeoutMs }); // Verify update was successful const verificationResponse = await client.get(`${request.componentPath}.json`); const updatedProperties = verificationResponse.data; return createSuccessResponse({ message: "Component updated successfully", path: request.componentPath, properties: request.properties, originalProperties: originalProperties, updatedProperties: updatedProperties, response: response.data, verification: { success: true, propertiesChanged: Object.keys(request.properties).length, timestamp: new Date().toISOString() } }, 'updateComponent'); }, 'updateComponent', 2); // Allow 2 retries for updates } async undoChanges(request) { return { success: false, message: "Undo functionality requires custom implementation. Use AEM's built-in version history instead.", suggestion: "Navigate to Tools > Workflow > Archive to view change history", jobId: request.job_id }; } extractComponents(data, basePath = "") { const components = []; const processNode = (node, nodePath) => { if (!node || typeof node !== 'object') return; // Check if current node is a component if (node['sling:resourceType']?.startsWith('wknd/components/')) { components.push({ path: nodePath, resourceType: node['sling:resourceType'], primaryType: node['jcr:primaryType'] || 'nt:unstructured', properties: { // Common properties 'jcr:title': node['jcr:title'], 'text': node['text'], 'textIsRich': node['textIsRich'], 'fileReference': node['fileReference'], 'type': node['type'], // Component specific properties 'fragmentVariationPath': node['fragmentVariationPath'], // Include all original properties ...node } }); } // Process child nodes Object.entries(node).forEach(([key, value]) => { if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) { const childPath = nodePath ? `${nodePath}/${key}` : key; processNode(value, childPath); } }); }; // Start processing from root if (data['jcr:content']) { // If we're at the page root, start from jcr:content processNode(data['jcr:content'], 'jcr:content'); } else { // Otherwise process from current node processNode(data, basePath); } return components; } async scanPageComponents(pagePath) { try { const client = this.createAxiosInstance(); const response = await client.get(`${pagePath}.infinity.json`); const components = this.extractComponents(response.data); // Group components by type const componentsByType = components.reduce((acc, comp) => { const type = comp.resourceType; if (!acc[type]) { acc[type] = []; } acc[type].push({ pagePath: pagePath, componentPath: comp.path, resourceType: type, currentProperties: comp.properties }); return acc; }, {}); return { success: true, pagePath: pagePath, components: components, componentsByType: componentsByType, totalComponents: components.length }; } catch (error) { console.error(`Failed to scan page ${pagePath}:`, error); throw new Error(`Page scan failed: ${error.message}`); } } async fetchSites() { try { const client = this.createAxiosInstance(); const response = await client.get('/content.json', { params: { ':depth': '2' } }); const sites = this.extractSites(response.data); return { success: true, sites: sites, totalCount: sites.length }; } catch (error) { throw new Error(`Failed to fetch sites: ${error.message}`); } } async fetchLanguageMasters(site) { try { const client = this.createAxiosInstance(); const response = await client.get(`/content/${site}.json`, { params: { ':depth': '3' } }); const languageMasters = this.extractLanguageMasters(response.data); return { success: true, site: site, languageMasters: languageMasters }; } catch (error) { throw new Error(`Failed to fetch language masters: ${error.message}`); } } async fetchAvailableLocales(site, languageMasterPath) { try { const client = this.createAxiosInstance(); const response = await client.get(`${languageMasterPath}.json`, { params: { ':depth': '2' } }); const locales = this.extractLocales(response.data); return { success: true, site: site, languageMasterPath: languageMasterPath, availableLocales: locales }; } catch (error) { throw new Error(`Failed to fetch available locales: ${error.message}`); } } async replicateAndPublish(selectedLocales, componentData, localizedOverrides) { return safeExecute(async () => { // Validate input parameters if (!Array.isArray(selectedLocales) || selectedLocales.length === 0) { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Selected locales must be a non-empty array'); } if (!componentData) { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Component data is required for replication'); } // Validate all locales are allowed const invalidLocales = selectedLocales.filter(locale => !isValidLocale(locale, this.aemConfig)); if (invalidLocales.length > 0) { throw createAEMError(AEM_ERROR_CODES.INVALID_LOCALE, `Invalid locales detected: ${invalidLocales.join(', ')}`, { invalidLocales, allowedLocales: this.aemConfig.validation.allowedLocales }); } const results = []; const errors = []; // Process each locale with proper error handling for (const locale of selectedLocales) { try { console.log(`Processing replication for locale: ${locale}`); // Simulate replication with actual AEM API calls const client = this.createAxiosInstance(); // Get locale-specific overrides const localeOverrides = localizedOverrides?.[locale] || {}; const mergedData = { ...componentData, ...localeOverrides }; // Here you would make actual replication API calls // For now, we'll simulate with validation const replicationResult = await this.simulateReplication(locale, mergedData, client); results.push({ locale, success: true, result: replicationResult, timestamp: new Date().toISOString() }); } catch (error) { const replicationError = error instanceof AEMOperationError ? error : handleAEMHttpError(error, `replication-${locale}`); console.error(`Replication failed for locale ${locale}:`, replicationError.message); errors.push({ locale, error: { code: replicationError.code, message: replicationError.message, details: replicationError.details } }); // Continue with other locales even if one fails results.push({ locale, success: false, error: replicationError.message, timestamp: new Date().toISOString() }); } } const successCount = results.filter(r => r.success).length; const failureCount = results.filter(r => !r.success).length; if (failureCount > 0 && successCount === 0) { throw createAEMError(AEM_ERROR_CODES.REPLICATION_FAILED, 'All replication attempts failed', { errors, results }); } return createSuccessResponse({ message: `Replication completed: ${successCount} successful, ${failureCount} failed`, summary: { totalLocales: selectedLocales.length, successful: successCount, failed: failureCount, locales: selectedLocales }, results, errors: errors.length > 0 ? errors : undefined, componentData, localizedOverrides, publisherUrls: this.aemConfig.replication.publisherUrls }, 'replicateAndPublish'); }, 'replicateAndPublish', 1); // No retries for replication to avoid duplicate content } async simulateReplication(locale, componentData, client) { // Simulate replication API call - replace with actual AEM replication API // For example: POST to /bin/replicate.json try { // Check if target path exists if (componentData.path) { await client.get(`${componentData.path}.json`); } return { status: 'success', locale: locale, replicationId: `repl_${Date.now()}_${locale}`, publisherUrls: this.aemConfig.replication.publisherUrls, agent: this.aemConfig.replication.defaultReplicationAgent }; } catch (error) { throw createAEMError(AEM_ERROR_CODES.REPLICATION_FAILED, `Replication simulation failed for locale ${locale}`, { locale, error: error.message }); } } async executeJCRQuery(query, limit = 20) { return safeExecute(async () => { // Validate input parameters if (!query || typeof query !== 'string') { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Query is required and must be a string'); } if (query.trim().length === 0) { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Query cannot be empty'); } // Validate and constrain limit const validatedLimit = Math.min(Math.max(1, limit || this.aemConfig.queries.defaultLimit), this.aemConfig.queries.maxLimit); // Security: Basic query validation to prevent potentially harmful queries const securityChecks = this.validateQuerySecurity(query); if (!securityChecks.safe) { throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Query contains potentially unsafe patterns: ${securityChecks.issues.join(', ')}`, { query, issues: securityChecks.issues }); } const client = this.createAxiosInstance(); const response = await client.get('/bin/querybuilder.json', { params: { path: this.aemConfig.contentPaths.sitesRoot, type: 'cq:Page', fulltext: query, 'p.limit': validatedLimit }, timeout: this.aemConfig.queries.timeoutMs }); return createSuccessResponse({ query: query, results: response.data.hits || [], total: response.data.total || 0, limit: validatedLimit, searchPath: this.aemConfig.contentPaths.sitesRoot, executionTime: response.headers['x-response-time'] || 'unknown', queryValidation: securityChecks }, 'executeJCRQuery'); }, 'executeJCRQuery'); } validateQuerySecurity(query) { const issues = []; const lowercaseQuery = query.toLowerCase(); // Check for potentially dangerous patterns const dangerousPatterns = [ { pattern: /\bdrop\b/, issue: 'DROP statements not allowed' }, { pattern: /\bdelete\b/, issue: 'DELETE statements not allowed' }, { pattern: /\bupdate\b/, issue: 'UPDATE statements not allowed' }, { pattern: /\binsert\b/, issue: 'INSERT statements not allowed' }, { pattern: /\bexec\b/, issue: 'EXEC statements not allowed' }, { pattern: /\bscript\b/, issue: 'Script execution not allowed' }, { pattern: /\.\.\//g, issue: 'Path traversal patterns not allowed' }, { pattern: /<script/i, issue: 'Script tags not allowed' } ]; for (const { pattern, issue } of dangerousPatterns) { if (pattern.test(lowercaseQuery)) { issues.push(issue); } } // Check query length if (query.length > 1000) { issues.push('Query too long (max 1000 characters)'); } return { safe: issues.length === 0, issues }; } async getNodeContent(path, depth = 1) { try { const client = this.createAxiosInstance(); const response = await client.get(`${path}.json`, { params: { ':depth': depth } }); return { success: true, path: path, depth: depth, content: response.data }; } catch (error) { throw new Error(`Get node content failed: ${error.message}`); } } async searchContent(params) { try { const client = this.createAxiosInstance(); // Enhanced search with fallback strategies let results = null; let searchMethod = 'querybuilder'; try { // Strategy 1: QueryBuilder search (primary) const queryParams = { 'p.limit': params.limit || 20 }; if (params.path) queryParams.path = params.path; if (params.type) queryParams.type = params.type; if (params.fulltext) queryParams.fulltext = params.fulltext; if (params.property && params["property.value"]) { queryParams[`property`] = params.property; queryParams[`property.value`] = params["property.value"]; } const response = await client.get('/bin/querybuilder.json', { params: queryParams }); results = response.data; } catch (primaryError) { console.warn('Primary QueryBuilder search failed, trying JCR SQL2:', primaryError); // Strategy 2: JCR SQL2 search (fallback) if (params.fulltext && params.path) { try { const sql2Query = `SELECT * FROM [cq:Page] AS page WHERE ISDESCENDANTNODE(page, '${params.path}') AND CONTAINS(*, '${params.fulltext}') ORDER BY [jcr:score] DESC`; const jcrResults = await this.executeJCRQuery(sql2Query, params.limit || 20); // Convert JCR results to QueryBuilder format results = { success: true, results: jcrResults.results || [], total: jcrResults.results?.length || 0, sql: sql2Query }; searchMethod = 'jcr-sql2'; } catch (fallbackError) { console.warn('Fallback JCR search also failed:', fallbackError); throw primaryError; // Throw original error } } else { throw primaryError; } } return { success: true, searchMethod, parameters: params, results: results.hits || results.results || [], total: results.total || (results.results?.length) || 0, rawResponse: results }; } catch (error) { throw new Error(`Content search failed: ${error.message}`); } } async getAssetMetadata(assetPath) { try { const client = this.createAxiosInstance(); const response = await client.get(`${assetPath}.json`); const metadata = response.data['jcr:content']?.metadata || {}; return { success: true, assetPath: assetPath, metadata: metadata, fullData: response.data }; } catch (error) { throw new Error(`Get asset metadata failed: ${error.message}`); } } async listChildren(path, depth = 1) { try { const client = this.createAxiosInstance(); const response = await client.get(`${path}.json`, { params: { depth } }); const children = []; if (response.data && typeof response.data === 'object') { Object.entries(response.data).forEach(([key, value]) => { if (key.startsWith('jcr:') || key.startsWith('sling:') || key.startsWith('cq:')) { return; } if (value && typeof value === 'object') { children.push({ name: key, path: `${path}/${key}`, primaryType: value['jcr:primaryType'] || 'unknown', title: value['jcr:content']?.['jcr:title'] || value['jcr:title'] || key }); } }); } return children; } catch (error) { throw new Error(`List children failed: ${error.message}`); } } async getPageProperties(pagePath) { try { const client = this.createAxiosInstance(); const response = await client.get(`${pagePath}/jcr:content.json`); const content = response.data; const properties = { title: content['jcr:title'], description: content['jcr:description'], template: content['cq:template'], lastModified: content['cq:lastModified'], lastModifiedBy: content['cq:lastModifiedBy'], created: content['jcr:created'], createdBy: content['jcr:createdBy'], primaryType: content['jcr:primaryType'], resourceType: content['sling:resourceType'], tags: content['cq:tags'] || [], properties: content }; return properties; } catch (error) { throw new Error(`Get page properties failed: ${error.message}`); } } // Helper methods extractSites(data) { const sites = []; if (data && typeof data === 'object') { Object.entries(data).forEach(([key, value]) => { if (key.startsWith('jcr:') || key.startsWith('sling:')) { return; } if (value && typeof value === 'object') { if (value['jcr:content']) { sites.push({ name: key, path: `/content/${key}`, title: value['jcr:content']['jcr:title'] || key, template: value['jcr:content']['cq:template'], lastModified: value['jcr:content']['cq:lastModified'] }); } } }); } return sites; } extractLanguageMasters(data) { const masters = []; if (data && typeof data === 'object') { Object.entries(data).forEach(([key, value]) => { if (key.startsWith('jcr:') || key.startsWith('sling:')) { return; } if (value && typeof value === 'object' && value['jcr:content']) { masters.push({ name: key, path: `/content/${key}`, title: value['jcr:content']['jcr:title'] || key, language: value['jcr:content']['jcr:language'] || 'en' }); } }); } return masters; } extractLocales(data) { const locales = []; if (data && typeof data === 'object') { Object.entries(data).forEach(([key, value]) => { if (key.startsWith('jcr:') || key.startsWith('sling:')) { return; } if (value && typeof value === 'object') { locales.push({ name: key, title: value['jcr:content']?.['jcr:title'] || key, language: value['jcr:content']?.['jcr:language'] || key }); } }); } return locales; } /** * Get list of pages under a site root with optional depth * Enhanced version of listChildren that filters to pages only */ async listPages(siteRoot, depth = 1, limit = 20) { const children = await this.listChildren(siteRoot, depth); // Filter to only pages (nodes with jcr:content) const pages = children .filter(child => child.primaryType === 'cq:Page') .slice(0, limit); return { success: true, siteRoot: siteRoot, pages: pages.map(page => ({ ...page, type: 'page' })), pageCount: pages.length, totalChildrenScanned: children.length }; } async bulkUpdateComponents(request) { try { const components = []; const errors = []; // Scan each page pattern for components for (const pattern of request.pagePatterns) { try { const scanResult = await this.scanPageComponents(pattern); if (scanResult.success && scanResult.components) { // Filter components by requested types const matchingComponents = scanResult.components.filter((comp) => request.componentTypes.includes(comp.resourceType)); components.push(...matchingComponents); } } catch (error) { console.error(`Failed to scan page pattern ${pattern}:`, error); errors.push({ componentPath: pattern, error: `Failed to scan page: ${error.message}` }); } } // Perform updates let successCount = 0; let failureCount = 0; for (const component of components) { try { const updateResult = await this.updateComponent({ componentPath: component.path, properties: request.propertyUpdates }); if (updateResult.success) { successCount++; } else { failureCount++; errors.push({ componentPath: component.path, error: updateResult.message || 'Update failed' }); } } catch (error) { failureCount++; errors.push({ componentPath: component.path, error: error.message }); } } return { success: failureCount === 0, componentsUpdated: successCount, failedUpdates: failureCount, errors, summary: { pagePatterns: request.pagePatterns, componentTypes: request.componentTypes, propertiesUpdated: request.propertyUpdates } }; } catch (error) { throw new Error(`Bulk update failed: ${error.message}`); } } /** * @deprecated Use getAllTextContent instead for better performance and structure */ async getPageTextContent(pagePath) { // Redirect to the enhanced getAllTextContent function return this.getAllTextContent(pagePath); } extractTextContent(data, basePath = "") { const textContent = []; const processNode = (node, nodePath) => { if (!node || typeof node !== 'object') return; // Check if node has any text-related content const resourceType = node['sling:resourceType']; if (resourceType) { const content = {}; let hasContent = false; // Check for various text fields if (node['jcr:title']) { content.title = node['jcr:title']; hasContent = true; } if (node['text']) { content.text = node['text']; hasContent = true; } if (node['jcr:description']) { content.description = node['jcr:description']; hasContent = true; } if (node['alt']) { content.altText = node['alt']; hasContent = true; } if (node['caption']) { content.caption = node['caption']; hasContent = true; } if (node['heading']) { content.heading = node['heading']; hasContent = true; } // If any text content was found, add to results if (hasContent || resourceType.includes('text') || resourceType.includes('title')) { textContent.push({ componentType: resourceType, path: nodePath, content, properties: { ...node, textIsRich: node['textIsRich'], type: node['type'] } }); } } // Process child nodes Object.entries(node).forEach(([key, value]) => { if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) { const childPath = nodePath ? `${nodePath}/${key}` : key; processNode(value, childPath); } }); }; // Start processing from root if (data['jcr:content']) { processNode(data['jcr:content'], 'jcr:content'); } else { processNode(data, basePath); } return textContent; } async getAllTextContent(pagePath) { try { const client = this.createAxiosInstance(); const response = await client.get(`${pagePath}.infinity.json`); const textComponents = []; const processNode = (node, currentPath) => { if (!node || typeof node !== 'object') return; const resourceType = node['sling:resourceType']; if (resourceType) { // Check for any text-related content const hasText = node.text || node['jcr:title'] || node['jcr:description'] || node.heading || node.caption || node.alt; if (hasText || resourceType.includes('text') || resourceType.includes('title')) { textComponents.push({ path: currentPath, type: resourceType, content: { text: node.text || null, title: node['jcr:title'] || null, description: node['jcr:description'] || null, richText: node.textIsRich === 'true', heading: node.heading || null }, location: currentPath.split('/jcr:content/')[1] || 'root' }); } } // Process child nodes Object.entries(node).forEach(([key, value]) => { if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) { const childPath = currentPath ? `${currentPath}/${key}` : key; processNode(value, childPath); } }); }; processNode(response.data, pagePath); // Group by component type const groupedByType = textComponents.reduce((acc, item) => { const type = item.type; if (!acc[type]) { acc[type] = []; } acc[type].push(item); return acc; }, {}); return { success: true, pagePath, textComponents, groupedByType, totalComponents: textComponents.length, summary: { titles: textComponents.filter(c => c.type.includes('title')).length, textAreas: textComponents.filter(c => c.type.includes('text')).length, richTextComponents: textComponents.filter(c => c.content.richText).length } }; } catch (error) { console.error(`Failed to get text content for ${pagePath}:`, error); throw new Error(`Get text content failed: ${error.message}`); } } extractImages(data, basePath = "", xfPath) { const images = []; const processNode = (node, nodePath) => { if (!node || typeof node !== 'object') return; // Check for image components if (node['sling:resourceType']?.includes('image') || node['fileReference'] || node['file'] || (node['type'] === 'asset' && node['mimeType']?.startsWith('image/'))) { images.push({ componentType: node['sling:resourceType'] || 'unknown', path: nodePath, content: { fileReference: node['fileReference'], alt: node['alt'] || node['altText'], title: node['jcr:title'] || node['title'], caption: node['caption'], link: node['linkURL'], src: node['src'] || node['file'] }, xfPath: xfPath, properties: { ...node } }); } // Check for Experience Fragment references if (node['sling:resourceType']?.includes('experience-fragment')) { const xfReference = node['fragmentVariationPath']; if (xfReference) { // Store the XF path for context const currentXfPath = xfReference; // Process XF content recursively when we find it if (node['jcr:content']) { const xfImages = this.extractImages(node['jcr:content'], 'jcr:content', currentXfPath); images.push(...xfImages); } } } // Process child nodes Object.entries(node).forEach(([key, value]) => { if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) { const childPath = nodePath ? `${nodePath}/${key}` : key; processNode(value, childPath); } }); }; // Start processing from root if (data['jcr:content']) { processNode(data['jcr:content'], 'jcr:content'); } else { processNode(data, basePath); } return images; } async getPageImages(pagePath) { try { const client = this.createAxiosInstance(); // Get the page content with all nested nodes const response = await client.get(`${pagePath}.infinity.json`); // Extract all images, including those in XFs const images = this.extractImages(response.data); // Group images by their container (main page or specific XF) const groupedImages = images.reduce((acc, img) => { const container = img.xfPath || 'main'; if (!acc[container]) { acc[container] = []; } acc[container].push(img); return acc; }, {}); return { success: true, pagePath, images, groupedImages, totalImages: images.length }; } catch (error) { console.error(`Failed to get page images from ${pagePath}:`, error); throw new Error(`Failed to get page images: ${error.message}`); } } async fetchFragmentContent(fragmentPath, retryCount = 0) { try { const client = this.createAxiosInstance(); const response = await client.get(`${fragmentPath}.infinity.json`); return response.data; } catch (error) { console.error(`Failed to fetch fragment content: ${fragmentPath}`, error.message); // Retry logic for 404s (sometimes XFs take time to load) if (error.response?.status === 404 && retryCount < 2) { console.error(`Retrying XF fetch for ${fragmentPath}, attempt ${retryCount + 1}`); await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second return this.fetchFragmentContent(fragmentPath, retryCount + 1); } return null; } } async extractPageContent(data, basePath = "") { const content = { mainContent: { components: [], images: [], text: [] }, experienceFragments: [], contentFragments: [] }; const processNode = async (node, nodePath) => { if (!node || typeof node !== 'object') return; const resourceType = node['sling:resourceType']; if (!resourceType) { // Process child nodes if no resource type for (const [key, value] of Object.entries(node)) { if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) { const childPath = nodePath ? `${nodePath}/${key}` : key; await processNode(value, childPath); } } return; } // Handle Experience Fragments if (resourceType.includes('experience-fragment')) { const xfPath = node['fragmentVariationPath']; if (xfPath) { console.error(`[AEM Connector] Processing XF at path: ${xfPath}`); const xfContent = await this.fetchFragmentContent(xfPath); if (xfContent) { content.experienceFragments.push({ path: nodePath, fragmentVariationPath: xfPath, title: node['jcr:title'] || xfContent?.['jcr:content']?.['jcr:title'], type: 'experience-fragment', content: xfContent }); // Process XF content recursively if it exists if (xfContent?.['jcr:content']) { console.error(`[AEM Connector] Processing XF content for: ${xfPath}`); await processNode(xfContent['jcr:content'], `${xfPath}/jcr:content`); } } else { console.warn(`[AEM Connector] Failed to fetch XF content for: ${xfPath}`); } } } // Handle Content Fragments else if (resourceType.includes('content-fragment')) { const cfPath = node['fragmentPath']; if (cfPath) { console.error(`[AEM Connector] Processing CF