@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
JavaScript
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