Skip to main content
Glama
version-operations.ts15.2 kB
/** * Version Operations Module * Handles all AEM version management operations including creating, restoring, comparing, and deleting versions */ import { AxiosInstance } from 'axios'; import { IAEMConnector, ILogger, AEMConfig } from '../interfaces/index.js'; import { AEMOperationError, createAEMError, handleAEMHttpError, safeExecute, createSuccessResponse, AEM_ERROR_CODES, isValidContentPath } from '../error-handler.js'; export interface VersionInfo { name: string; label?: string; created: string; createdBy: string; comment?: string; isBaseVersion?: boolean; } export interface VersionHistoryResponse { success: boolean; operation: string; timestamp: string; data: { path: string; versions: VersionInfo[]; totalCount: number; baseVersion?: string; }; } export interface CreateVersionResponse { success: boolean; operation: string; timestamp: string; data: { path: string; versionName: string; label?: string; comment?: string; created: string; createdBy: string; }; } export interface RestoreVersionResponse { success: boolean; operation: string; timestamp: string; data: { path: string; restoredVersion: string; previousVersion?: string; restoredAt: string; restoredBy: string; }; } export interface CompareVersionsResponse { success: boolean; operation: string; timestamp: string; data: { path: string; version1: string; version2: string; differences: Array<{ property: string; type: 'added' | 'removed' | 'modified'; oldValue?: unknown; newValue?: unknown; }>; summary: { added: number; removed: number; modified: number; }; }; } export interface DeleteVersionResponse { success: boolean; operation: string; timestamp: string; data: { path: string; deletedVersion: string; deletedAt: string; deletedBy: string; }; } export class VersionOperations { constructor( private httpClient: AxiosInstance, private logger: ILogger, private config: AEMConfig ) {} /** * Get version history for a content path */ async getVersionHistory(path: string): Promise<VersionHistoryResponse> { return safeExecute<VersionHistoryResponse>(async () => { if (!isValidContentPath(path)) { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid content path: ${path}`, { path } ); } try { // Get version history using AEM's versioning API const response = await this.httpClient.get(`${path}.versionhistory.json`, { params: { ':depth': '2' } }); const versions: VersionInfo[] = []; let baseVersion: string | undefined; if (response.data && typeof response.data === 'object') { Object.entries(response.data).forEach(([key, value]: [string, any]) => { if (key === 'jcr:versionLabels') { // Handle version labels return; } if (value && typeof value === 'object' && value['jcr:frozenNode']) { const versionInfo: VersionInfo = { name: key, label: value['jcr:frozenNode']?.['jcr:versionLabel'], created: value['jcr:created'] || new Date().toISOString(), createdBy: value['jcr:createdBy'] || 'unknown', comment: value['jcr:versionComment'], isBaseVersion: value['jcr:isCheckedOut'] === false }; if (versionInfo.isBaseVersion) { baseVersion = key; } versions.push(versionInfo); } }); } // Sort versions by creation date (newest first) versions.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); this.logger.info(`Retrieved version history for path: ${path}`, { versionCount: versions.length, baseVersion }); return createSuccessResponse({ path, versions, totalCount: versions.length, baseVersion }, 'getVersionHistory') as VersionHistoryResponse; } catch (error: any) { throw handleAEMHttpError(error, 'getVersionHistory'); } }, 'getVersionHistory'); } /** * Create a new version of content */ async createVersion(path: string, label?: string, comment?: string): Promise<CreateVersionResponse> { return safeExecute<CreateVersionResponse>(async () => { if (!isValidContentPath(path)) { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid content path: ${path}`, { path } ); } try { // Check out the content first await this.checkOutContent(path); // Create version using AEM's versioning API const formData = new URLSearchParams(); formData.append('cmd', 'createVersion'); formData.append('path', path); if (label) { formData.append('label', label); } if (comment) { formData.append('comment', comment); } const response = await this.httpClient.post('/bin/wcm/versioning/createVersion', formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); // Check the content back in await this.checkInContent(path); const versionName = response.data?.versionName || `v${Date.now()}`; this.logger.info(`Created version for path: ${path}`, { versionName, label, comment }); return createSuccessResponse({ path, versionName, label, comment, created: new Date().toISOString(), createdBy: this.config.serviceUser.username }, 'createVersion') as CreateVersionResponse; } catch (error: any) { throw handleAEMHttpError(error, 'createVersion'); } }, 'createVersion'); } /** * Restore content to a specific version */ async restoreVersion(path: string, versionName: string): Promise<RestoreVersionResponse> { return safeExecute<RestoreVersionResponse>(async () => { if (!isValidContentPath(path)) { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid content path: ${path}`, { path } ); } if (!versionName || typeof versionName !== 'string') { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, 'Version name is required', { versionName } ); } try { // Get current version before restore const versionHistory = await this.getVersionHistory(path); const currentVersion = versionHistory.data.baseVersion; // Restore version using AEM's versioning API const formData = new URLSearchParams(); formData.append('cmd', 'restoreVersion'); formData.append('path', path); formData.append('version', versionName); await this.httpClient.post('/bin/wcm/versioning/restoreVersion', formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); this.logger.info(`Restored version for path: ${path}`, { versionName, previousVersion: currentVersion }); return createSuccessResponse({ path, restoredVersion: versionName, previousVersion: currentVersion, restoredAt: new Date().toISOString(), restoredBy: this.config.serviceUser.username }, 'restoreVersion') as RestoreVersionResponse; } catch (error: any) { throw handleAEMHttpError(error, 'restoreVersion'); } }, 'restoreVersion'); } /** * Compare two versions of content */ async compareVersions(path: string, version1: string, version2: string): Promise<CompareVersionsResponse> { return safeExecute<CompareVersionsResponse>(async () => { if (!isValidContentPath(path)) { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid content path: ${path}`, { path } ); } if (!version1 || !version2 || version1 === version2) { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, 'Two different version names are required for comparison', { version1, version2 } ); } try { // Get both versions const version1Response = await this.httpClient.get(`${path}.version.${version1}.json`, { params: { ':depth': '2' } }); const version2Response = await this.httpClient.get(`${path}.version.${version2}.json`, { params: { ':depth': '2' } }); // Compare the versions const differences = this.compareVersionData( version1Response.data, version2Response.data ); const summary = { added: differences.filter(d => d.type === 'added').length, removed: differences.filter(d => d.type === 'removed').length, modified: differences.filter(d => d.type === 'modified').length }; this.logger.info(`Compared versions for path: ${path}`, { version1, version2, differencesCount: differences.length, summary }); return createSuccessResponse({ path, version1, version2, differences, summary }, 'compareVersions') as CompareVersionsResponse; } catch (error: any) { throw handleAEMHttpError(error, 'compareVersions'); } }, 'compareVersions'); } /** * Delete a specific version */ async deleteVersion(path: string, versionName: string): Promise<DeleteVersionResponse> { return safeExecute<DeleteVersionResponse>(async () => { if (!isValidContentPath(path)) { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid content path: ${path}`, { path } ); } if (!versionName || typeof versionName !== 'string') { throw createAEMError( AEM_ERROR_CODES.INVALID_PARAMETERS, 'Version name is required', { versionName } ); } try { // Delete version using AEM's versioning API const formData = new URLSearchParams(); formData.append('cmd', 'deleteVersion'); formData.append('path', path); formData.append('version', versionName); await this.httpClient.post('/bin/wcm/versioning/deleteVersion', formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); this.logger.info(`Deleted version for path: ${path}`, { versionName }); return createSuccessResponse({ path, deletedVersion: versionName, deletedAt: new Date().toISOString(), deletedBy: this.config.serviceUser.username }, 'deleteVersion') as DeleteVersionResponse; } catch (error: any) { throw handleAEMHttpError(error, 'deleteVersion'); } }, 'deleteVersion'); } /** * Update undoChanges to use version operations */ async undoChanges(request: { jobId: string; path?: string }): Promise<{ success: boolean; operation: string; timestamp: string; data: { message: string; request: { jobId: string; path?: string }; versionInfo?: { restoredVersion: string; path: string; }; timestamp: string; }; }> { return safeExecute(async () => { const { jobId, path } = request; // If jobId looks like a version name, try to restore it if (path && (jobId.startsWith('v') || jobId.includes('.'))) { try { const restoreResult = await this.restoreVersion(path, jobId); return createSuccessResponse({ message: `Successfully restored version ${jobId} for path ${path}`, request, versionInfo: { restoredVersion: restoreResult.data.restoredVersion, path: restoreResult.data.path }, timestamp: new Date().toISOString() }, 'undoChanges'); } catch (error: any) { // If restore fails, fall back to original message } } // Fallback to original implementation return createSuccessResponse({ message: 'undoChanges requires a valid path and version name. Use version operations for proper rollback functionality.', request, timestamp: new Date().toISOString() }, 'undoChanges'); }, 'undoChanges'); } /** * Helper method to check out content */ private async checkOutContent(path: string): Promise<void> { const formData = new URLSearchParams(); formData.append('cmd', 'checkout'); formData.append('path', path); await this.httpClient.post('/bin/wcm/versioning/checkout', formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); } /** * Helper method to check in content */ private async checkInContent(path: string): Promise<void> { const formData = new URLSearchParams(); formData.append('cmd', 'checkin'); formData.append('path', path); await this.httpClient.post('/bin/wcm/versioning/checkin', formData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); } /** * Helper method to compare version data */ private compareVersionData(data1: any, data2: any, prefix = ''): Array<{ property: string; type: 'added' | 'removed' | 'modified'; oldValue?: unknown; newValue?: unknown; }> { const differences: Array<{ property: string; type: 'added' | 'removed' | 'modified'; oldValue?: unknown; newValue?: unknown; }> = []; const keys1 = new Set(Object.keys(data1 || {})); const keys2 = new Set(Object.keys(data2 || {})); // Check for added and modified properties for (const key of keys2) { const fullKey = prefix ? `${prefix}.${key}` : key; if (!keys1.has(key)) { differences.push({ property: fullKey, type: 'added', newValue: data2[key] }); } else if (JSON.stringify(data1[key]) !== JSON.stringify(data2[key])) { differences.push({ property: fullKey, type: 'modified', oldValue: data1[key], newValue: data2[key] }); } } // Check for removed properties for (const key of keys1) { if (!keys2.has(key)) { const fullKey = prefix ? `${prefix}.${key}` : key; differences.push({ property: fullKey, type: 'removed', oldValue: data1[key] }); } } return differences; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/indrasishbanerjee/aem-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server