/**
* 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;
}
}