aem-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/.nvmrc +1 -0
- package/LICENSE +22 -0
- package/NOTICE.md +7 -0
- package/README.md +194 -0
- package/dist/aem/aem.config.js +49 -0
- package/dist/aem/aem.connector.js +858 -0
- package/dist/aem/aem.errors.js +133 -0
- package/dist/config.js +11 -0
- package/dist/explorer/api.explorer.js +25 -0
- package/dist/explorer/api.spec.js +79 -0
- package/dist/index.js +3 -0
- package/dist/mcp/mcp.aem-handler.js +152 -0
- package/dist/mcp/mcp.server-handler.js +71 -0
- package/dist/mcp/mcp.server.js +44 -0
- package/dist/mcp/mcp.tools.js +388 -0
- package/dist/mcp/mcp.transports.js +1 -0
- package/dist/server/app.auth.js +28 -0
- package/dist/server/app.server.js +82 -0
- package/package.json +51 -0
- package/src/aem/aem.config.ts +79 -0
- package/src/aem/aem.connector.ts +902 -0
- package/src/aem/aem.errors.ts +152 -0
- package/src/config.ts +12 -0
- package/src/explorer/api.explorer.ts +30 -0
- package/src/explorer/api.spec.ts +79 -0
- package/src/index.ts +4 -0
- package/src/mcp/mcp.aem-handler.ts +158 -0
- package/src/mcp/mcp.server-handler.ts +73 -0
- package/src/mcp/mcp.server.ts +51 -0
- package/src/mcp/mcp.tools.ts +397 -0
- package/src/mcp/mcp.transports.ts +5 -0
- package/src/server/app.auth.ts +32 -0
- package/src/server/app.server.ts +94 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
import { getAEMConfig, isValidContentPath, isValidComponentType, isValidLocale } from './aem.config.js';
|
|
4
|
+
import { createAEMError, handleAEMHttpError, safeExecute, validateComponentOperation, createSuccessResponse, AEM_ERROR_CODES } from './aem.errors.js';
|
|
5
|
+
dotenv.config();
|
|
6
|
+
export class AEMConnector {
|
|
7
|
+
config;
|
|
8
|
+
auth;
|
|
9
|
+
aemConfig;
|
|
10
|
+
constructor() {
|
|
11
|
+
this.config = this.loadConfig();
|
|
12
|
+
this.aemConfig = getAEMConfig();
|
|
13
|
+
this.auth = {
|
|
14
|
+
username: process.env.AEM_SERVICE_USER || this.config.aem.serviceUser.username,
|
|
15
|
+
password: process.env.AEM_SERVICE_PASSWORD || this.config.aem.serviceUser.password,
|
|
16
|
+
};
|
|
17
|
+
if (process.env.AEM_HOST) {
|
|
18
|
+
this.config.aem.host = process.env.AEM_HOST;
|
|
19
|
+
this.config.aem.author = process.env.AEM_HOST;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
loadConfig() {
|
|
23
|
+
return {
|
|
24
|
+
aem: {
|
|
25
|
+
host: process.env.AEM_HOST || 'http://localhost:4502',
|
|
26
|
+
author: process.env.AEM_HOST || 'http://localhost:4502',
|
|
27
|
+
publish: 'http://localhost:4503',
|
|
28
|
+
serviceUser: {
|
|
29
|
+
username: 'admin',
|
|
30
|
+
password: 'admin',
|
|
31
|
+
},
|
|
32
|
+
endpoints: {
|
|
33
|
+
content: '/content',
|
|
34
|
+
dam: '/content/dam',
|
|
35
|
+
query: '/bin/querybuilder.json',
|
|
36
|
+
crxde: '/crx/de',
|
|
37
|
+
jcr: '',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
mcp: {
|
|
41
|
+
name: 'AEM MCP Server',
|
|
42
|
+
version: '1.0.0',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
createAxiosInstance() {
|
|
47
|
+
return axios.create({
|
|
48
|
+
baseURL: this.config.aem.host,
|
|
49
|
+
auth: this.auth,
|
|
50
|
+
timeout: 30000,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
'Accept': 'application/json',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async testConnection() {
|
|
58
|
+
try {
|
|
59
|
+
// eslint-disable-next-line no-console
|
|
60
|
+
console.log('Testing AEM connection to:', this.config.aem.host);
|
|
61
|
+
const client = this.createAxiosInstance();
|
|
62
|
+
const response = await client.get('/libs/granite/core/content/login.html', {
|
|
63
|
+
timeout: 5000,
|
|
64
|
+
validateStatus: (status) => status < 500,
|
|
65
|
+
});
|
|
66
|
+
// eslint-disable-next-line no-console
|
|
67
|
+
console.log('✅ AEM connection successful! Status:', response.status);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
// eslint-disable-next-line no-console
|
|
72
|
+
console.error('❌ AEM connection failed:', error.message);
|
|
73
|
+
if (error.response) {
|
|
74
|
+
// eslint-disable-next-line no-console
|
|
75
|
+
console.error(' Status:', error.response.status);
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.error(' URL:', error.config?.url);
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// ... All AEM operation methods (validateComponent, updateComponent, undoChanges, scanPageComponents, fetchSites, fetchLanguageMasters, fetchAvailableLocales, replicateAndPublish, executeJCRQuery, getNodeContent, searchContent, getAssetMetadata, listChildren, getPageProperties, listPages, bulkUpdateComponents, getPageTextContent, getAllTextContent, getPageImages, updateImagePath, getPageContent, createPage, deletePage, createComponent, deleteComponent, unpublishContent, activatePage, deactivatePage, uploadAsset, updateAsset, deleteAsset, getTemplates, getTemplateStructure) ...
|
|
83
|
+
// For brevity, only a few methods are shown here. The full implementation should include all methods as in the original JS.
|
|
84
|
+
async validateComponent(request) {
|
|
85
|
+
return safeExecute(async () => {
|
|
86
|
+
const locale = request.locale;
|
|
87
|
+
const pagePath = request.pagePath || request.page_path;
|
|
88
|
+
const component = request.component;
|
|
89
|
+
const props = request.props;
|
|
90
|
+
validateComponentOperation(locale, pagePath, component, props);
|
|
91
|
+
if (!isValidLocale(locale, this.aemConfig)) {
|
|
92
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_LOCALE, `Locale '${locale}' is not supported`, { locale, allowedLocales: this.aemConfig.validation.allowedLocales });
|
|
93
|
+
}
|
|
94
|
+
if (!isValidContentPath(pagePath, this.aemConfig)) {
|
|
95
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PATH, `Path '${pagePath}' is not within allowed content roots`, { path: pagePath, allowedRoots: Object.values(this.aemConfig.contentPaths) });
|
|
96
|
+
}
|
|
97
|
+
if (!isValidComponentType(component, this.aemConfig)) {
|
|
98
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_COMPONENT_TYPE, `Component type '${component}' is not allowed`, { component, allowedTypes: this.aemConfig.components.allowedTypes });
|
|
99
|
+
}
|
|
100
|
+
const client = this.createAxiosInstance();
|
|
101
|
+
const response = await client.get(`${pagePath}.json`, {
|
|
102
|
+
params: { ':depth': '2' },
|
|
103
|
+
timeout: this.aemConfig.queries.timeoutMs,
|
|
104
|
+
});
|
|
105
|
+
const validation = this.validateComponentProps(response.data, component, props);
|
|
106
|
+
return createSuccessResponse({
|
|
107
|
+
message: 'Component validation completed successfully',
|
|
108
|
+
pageData: response.data,
|
|
109
|
+
component,
|
|
110
|
+
locale,
|
|
111
|
+
validation,
|
|
112
|
+
configUsed: {
|
|
113
|
+
allowedLocales: this.aemConfig.validation.allowedLocales,
|
|
114
|
+
allowedComponents: this.aemConfig.components.allowedTypes,
|
|
115
|
+
},
|
|
116
|
+
}, 'validateComponent');
|
|
117
|
+
}, 'validateComponent');
|
|
118
|
+
}
|
|
119
|
+
validateComponentProps(pageData, componentType, props) {
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const errors = [];
|
|
122
|
+
if (componentType === 'text' && !props.text && !props.richText) {
|
|
123
|
+
warnings.push('Text component should have text or richText property');
|
|
124
|
+
}
|
|
125
|
+
if (componentType === 'image' && !props.fileReference && !props.src) {
|
|
126
|
+
errors.push('Image component requires fileReference or src property');
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
valid: errors.length === 0,
|
|
130
|
+
errors,
|
|
131
|
+
warnings,
|
|
132
|
+
componentType,
|
|
133
|
+
propsValidated: Object.keys(props).length,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async updateComponent(request) {
|
|
137
|
+
return safeExecute(async () => {
|
|
138
|
+
if (!request.componentPath || typeof request.componentPath !== 'string') {
|
|
139
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Component path is required and must be a string');
|
|
140
|
+
}
|
|
141
|
+
if (!request.properties || typeof request.properties !== 'object') {
|
|
142
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Properties are required and must be an object');
|
|
143
|
+
}
|
|
144
|
+
if (!isValidContentPath(request.componentPath, this.aemConfig)) {
|
|
145
|
+
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) });
|
|
146
|
+
}
|
|
147
|
+
const client = this.createAxiosInstance();
|
|
148
|
+
try {
|
|
149
|
+
await client.get(`${request.componentPath}.json`);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.response?.status === 404) {
|
|
153
|
+
throw createAEMError(AEM_ERROR_CODES.COMPONENT_NOT_FOUND, `Component not found at path: ${request.componentPath}`, { componentPath: request.componentPath });
|
|
154
|
+
}
|
|
155
|
+
throw handleAEMHttpError(error, 'updateComponent');
|
|
156
|
+
}
|
|
157
|
+
const formData = new URLSearchParams();
|
|
158
|
+
Object.entries(request.properties).forEach(([key, value]) => {
|
|
159
|
+
if (value === null || value === undefined) {
|
|
160
|
+
formData.append(`${key}@Delete`, '');
|
|
161
|
+
}
|
|
162
|
+
else if (Array.isArray(value)) {
|
|
163
|
+
value.forEach((item) => {
|
|
164
|
+
formData.append(`${key}`, item.toString());
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
else if (typeof value === 'object') {
|
|
168
|
+
formData.append(key, JSON.stringify(value));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
formData.append(key, value.toString());
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
const response = await client.post(request.componentPath, formData, {
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
177
|
+
'Accept': 'application/json',
|
|
178
|
+
},
|
|
179
|
+
timeout: this.aemConfig.queries.timeoutMs,
|
|
180
|
+
});
|
|
181
|
+
const verificationResponse = await client.get(`${request.componentPath}.json`);
|
|
182
|
+
return createSuccessResponse({
|
|
183
|
+
message: 'Component updated successfully',
|
|
184
|
+
path: request.componentPath,
|
|
185
|
+
properties: request.properties,
|
|
186
|
+
updatedProperties: verificationResponse.data,
|
|
187
|
+
response: response.data,
|
|
188
|
+
verification: {
|
|
189
|
+
success: true,
|
|
190
|
+
propertiesChanged: Object.keys(request.properties).length,
|
|
191
|
+
timestamp: new Date().toISOString(),
|
|
192
|
+
},
|
|
193
|
+
}, 'updateComponent');
|
|
194
|
+
}, 'updateComponent');
|
|
195
|
+
}
|
|
196
|
+
async undoChanges(request) {
|
|
197
|
+
// Not implemented: AEM MCP does not support undo/rollback. Use AEM version history.
|
|
198
|
+
return createSuccessResponse({
|
|
199
|
+
message: 'undoChanges is not implemented. Please use AEM version history for undo/rollback.',
|
|
200
|
+
request,
|
|
201
|
+
timestamp: new Date().toISOString(),
|
|
202
|
+
}, 'undoChanges');
|
|
203
|
+
}
|
|
204
|
+
async scanPageComponents(pagePath) {
|
|
205
|
+
return safeExecute(async () => {
|
|
206
|
+
const client = this.createAxiosInstance();
|
|
207
|
+
const response = await client.get(`${pagePath}.infinity.json`);
|
|
208
|
+
// Extraction logic as in the original JS
|
|
209
|
+
const components = [];
|
|
210
|
+
const processNode = (node, nodePath) => {
|
|
211
|
+
if (!node || typeof node !== 'object')
|
|
212
|
+
return;
|
|
213
|
+
if (node['sling:resourceType']) {
|
|
214
|
+
components.push({
|
|
215
|
+
path: nodePath,
|
|
216
|
+
resourceType: node['sling:resourceType'],
|
|
217
|
+
properties: { ...node },
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
Object.entries(node).forEach(([key, value]) => {
|
|
221
|
+
if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) {
|
|
222
|
+
const childPath = nodePath ? `${nodePath}/${key}` : key;
|
|
223
|
+
processNode(value, childPath);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
if (response.data['jcr:content']) {
|
|
228
|
+
processNode(response.data['jcr:content'], 'jcr:content');
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
processNode(response.data, pagePath);
|
|
232
|
+
}
|
|
233
|
+
return createSuccessResponse({
|
|
234
|
+
pagePath,
|
|
235
|
+
components,
|
|
236
|
+
totalComponents: components.length,
|
|
237
|
+
}, 'scanPageComponents');
|
|
238
|
+
}, 'scanPageComponents');
|
|
239
|
+
}
|
|
240
|
+
async fetchSites() {
|
|
241
|
+
return safeExecute(async () => {
|
|
242
|
+
const client = this.createAxiosInstance();
|
|
243
|
+
const response = await client.get('/content.json', { params: { ':depth': '2' } });
|
|
244
|
+
const sites = [];
|
|
245
|
+
Object.entries(response.data).forEach(([key, value]) => {
|
|
246
|
+
if (key.startsWith('jcr:') || key.startsWith('sling:'))
|
|
247
|
+
return;
|
|
248
|
+
if (value && typeof value === 'object' && value['jcr:content']) {
|
|
249
|
+
sites.push({
|
|
250
|
+
name: key,
|
|
251
|
+
path: `/content/${key}`,
|
|
252
|
+
title: value['jcr:content']['jcr:title'] || key,
|
|
253
|
+
template: value['jcr:content']['cq:template'],
|
|
254
|
+
lastModified: value['jcr:content']['cq:lastModified'],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
return createSuccessResponse({
|
|
259
|
+
sites,
|
|
260
|
+
totalCount: sites.length,
|
|
261
|
+
}, 'fetchSites');
|
|
262
|
+
}, 'fetchSites');
|
|
263
|
+
}
|
|
264
|
+
async fetchLanguageMasters(site) {
|
|
265
|
+
return safeExecute(async () => {
|
|
266
|
+
const client = this.createAxiosInstance();
|
|
267
|
+
const response = await client.get(`/content/${site}.json`, { params: { ':depth': '3' } });
|
|
268
|
+
const masters = [];
|
|
269
|
+
Object.entries(response.data).forEach(([key, value]) => {
|
|
270
|
+
if (key.startsWith('jcr:') || key.startsWith('sling:'))
|
|
271
|
+
return;
|
|
272
|
+
if (value && typeof value === 'object' && value['jcr:content']) {
|
|
273
|
+
masters.push({
|
|
274
|
+
name: key,
|
|
275
|
+
path: `/content/${key}`,
|
|
276
|
+
title: value['jcr:content']['jcr:title'] || key,
|
|
277
|
+
language: value['jcr:content']['jcr:language'] || 'en',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return createSuccessResponse({
|
|
282
|
+
site,
|
|
283
|
+
languageMasters: masters,
|
|
284
|
+
}, 'fetchLanguageMasters');
|
|
285
|
+
}, 'fetchLanguageMasters');
|
|
286
|
+
}
|
|
287
|
+
async fetchAvailableLocales(site, languageMasterPath) {
|
|
288
|
+
return safeExecute(async () => {
|
|
289
|
+
const client = this.createAxiosInstance();
|
|
290
|
+
const response = await client.get(`${languageMasterPath}.json`, { params: { ':depth': '2' } });
|
|
291
|
+
const locales = [];
|
|
292
|
+
Object.entries(response.data).forEach(([key, value]) => {
|
|
293
|
+
if (key.startsWith('jcr:') || key.startsWith('sling:'))
|
|
294
|
+
return;
|
|
295
|
+
if (value && typeof value === 'object') {
|
|
296
|
+
locales.push({
|
|
297
|
+
name: key,
|
|
298
|
+
title: value['jcr:content']?.['jcr:title'] || key,
|
|
299
|
+
language: value['jcr:content']?.['jcr:language'] || key,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
return createSuccessResponse({
|
|
304
|
+
site,
|
|
305
|
+
languageMasterPath,
|
|
306
|
+
availableLocales: locales,
|
|
307
|
+
}, 'fetchAvailableLocales');
|
|
308
|
+
}, 'fetchAvailableLocales');
|
|
309
|
+
}
|
|
310
|
+
async replicateAndPublish(selectedLocales, componentData, localizedOverrides) {
|
|
311
|
+
// Simulate replication logic for now
|
|
312
|
+
return safeExecute(async () => {
|
|
313
|
+
return createSuccessResponse({
|
|
314
|
+
message: 'Replication simulated',
|
|
315
|
+
selectedLocales,
|
|
316
|
+
componentData,
|
|
317
|
+
localizedOverrides,
|
|
318
|
+
}, 'replicateAndPublish');
|
|
319
|
+
}, 'replicateAndPublish');
|
|
320
|
+
}
|
|
321
|
+
async getAllTextContent(pagePath) {
|
|
322
|
+
return safeExecute(async () => {
|
|
323
|
+
const client = this.createAxiosInstance();
|
|
324
|
+
const response = await client.get(`${pagePath}.infinity.json`);
|
|
325
|
+
const textContent = [];
|
|
326
|
+
const processNode = (node, nodePath) => {
|
|
327
|
+
if (!node || typeof node !== 'object')
|
|
328
|
+
return;
|
|
329
|
+
if (node['text'] || node['jcr:title'] || node['jcr:description']) {
|
|
330
|
+
textContent.push({
|
|
331
|
+
path: nodePath,
|
|
332
|
+
title: node['jcr:title'],
|
|
333
|
+
text: node['text'],
|
|
334
|
+
description: node['jcr:description'],
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
Object.entries(node).forEach(([key, value]) => {
|
|
338
|
+
if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) {
|
|
339
|
+
const childPath = nodePath ? `${nodePath}/${key}` : key;
|
|
340
|
+
processNode(value, childPath);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
if (response.data['jcr:content']) {
|
|
345
|
+
processNode(response.data['jcr:content'], 'jcr:content');
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
processNode(response.data, pagePath);
|
|
349
|
+
}
|
|
350
|
+
return createSuccessResponse({
|
|
351
|
+
pagePath,
|
|
352
|
+
textContent,
|
|
353
|
+
}, 'getAllTextContent');
|
|
354
|
+
}, 'getAllTextContent');
|
|
355
|
+
}
|
|
356
|
+
async getPageTextContent(pagePath) {
|
|
357
|
+
return safeExecute(async () => {
|
|
358
|
+
return this.getAllTextContent(pagePath); // Alias for now
|
|
359
|
+
}, 'getPageTextContent');
|
|
360
|
+
}
|
|
361
|
+
async getPageImages(pagePath) {
|
|
362
|
+
return safeExecute(async () => {
|
|
363
|
+
const client = this.createAxiosInstance();
|
|
364
|
+
const response = await client.get(`${pagePath}.infinity.json`);
|
|
365
|
+
const images = [];
|
|
366
|
+
const processNode = (node, nodePath) => {
|
|
367
|
+
if (!node || typeof node !== 'object')
|
|
368
|
+
return;
|
|
369
|
+
if (node['fileReference'] || node['src']) {
|
|
370
|
+
images.push({
|
|
371
|
+
path: nodePath,
|
|
372
|
+
fileReference: node['fileReference'],
|
|
373
|
+
src: node['src'],
|
|
374
|
+
alt: node['alt'] || node['altText'],
|
|
375
|
+
title: node['jcr:title'] || node['title'],
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
Object.entries(node).forEach(([key, value]) => {
|
|
379
|
+
if (typeof value === 'object' && value !== null && !key.startsWith('rep:') && !key.startsWith('oak:')) {
|
|
380
|
+
const childPath = nodePath ? `${nodePath}/${key}` : key;
|
|
381
|
+
processNode(value, childPath);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
if (response.data['jcr:content']) {
|
|
386
|
+
processNode(response.data['jcr:content'], 'jcr:content');
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
processNode(response.data, pagePath);
|
|
390
|
+
}
|
|
391
|
+
return createSuccessResponse({
|
|
392
|
+
pagePath,
|
|
393
|
+
images,
|
|
394
|
+
}, 'getPageImages');
|
|
395
|
+
}, 'getPageImages');
|
|
396
|
+
}
|
|
397
|
+
async updateImagePath(componentPath, newImagePath) {
|
|
398
|
+
return safeExecute(async () => {
|
|
399
|
+
return this.updateComponent({ componentPath, properties: { fileReference: newImagePath } });
|
|
400
|
+
}, 'updateImagePath');
|
|
401
|
+
}
|
|
402
|
+
async getPageContent(pagePath) {
|
|
403
|
+
return safeExecute(async () => {
|
|
404
|
+
const client = this.createAxiosInstance();
|
|
405
|
+
const response = await client.get(`${pagePath}.infinity.json`);
|
|
406
|
+
return createSuccessResponse({
|
|
407
|
+
pagePath,
|
|
408
|
+
content: response.data,
|
|
409
|
+
}, 'getPageContent');
|
|
410
|
+
}, 'getPageContent');
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* List direct or nested children under a path using .json with depth param.
|
|
414
|
+
* Filters out system keys. Returns array of { name, path, primaryType, title }.
|
|
415
|
+
*/
|
|
416
|
+
async listChildren(path, depth = 1) {
|
|
417
|
+
// Patch: Use QueryBuilder to list direct children cq:Page nodes under the given path
|
|
418
|
+
return safeExecute(async () => {
|
|
419
|
+
const client = this.createAxiosInstance();
|
|
420
|
+
const response = await client.get(this.config.aem.endpoints.query, {
|
|
421
|
+
params: {
|
|
422
|
+
path,
|
|
423
|
+
type: 'cq:Page',
|
|
424
|
+
'p.nodedepth': 1,
|
|
425
|
+
'p.limit': 1000,
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
const children = (response.data.hits || []).map((hit) => ({
|
|
429
|
+
name: hit.name,
|
|
430
|
+
path: hit.path,
|
|
431
|
+
primaryType: 'cq:Page',
|
|
432
|
+
title: hit.title || hit.name,
|
|
433
|
+
}));
|
|
434
|
+
return children;
|
|
435
|
+
}, 'listChildren');
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* List all cq:Page nodes under a site root, up to a given depth and limit.
|
|
439
|
+
*/
|
|
440
|
+
async listPages(siteRoot, depth = 1, limit = 20) {
|
|
441
|
+
// Patch: Use QueryBuilder to find cq:Page nodes under siteRoot up to depth
|
|
442
|
+
return safeExecute(async () => {
|
|
443
|
+
const client = this.createAxiosInstance();
|
|
444
|
+
const response = await client.get(this.config.aem.endpoints.query, {
|
|
445
|
+
params: {
|
|
446
|
+
path: siteRoot,
|
|
447
|
+
type: 'cq:Page',
|
|
448
|
+
'p.nodedepth': depth,
|
|
449
|
+
'p.limit': limit,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
const pages = (response.data.hits || []).map((hit) => ({
|
|
453
|
+
name: hit.name,
|
|
454
|
+
path: hit.path,
|
|
455
|
+
primaryType: 'cq:Page',
|
|
456
|
+
title: hit.title || hit.name,
|
|
457
|
+
type: 'page',
|
|
458
|
+
}));
|
|
459
|
+
return {
|
|
460
|
+
success: true,
|
|
461
|
+
siteRoot,
|
|
462
|
+
pages,
|
|
463
|
+
pageCount: pages.length,
|
|
464
|
+
totalChildrenScanned: response.data.hits ? response.data.hits.length : 0,
|
|
465
|
+
};
|
|
466
|
+
}, 'listPages');
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Execute a QueryBuilder fulltext search for cq:Page nodes, with security validation.
|
|
470
|
+
* Note: This is NOT a true JCR SQL2 executor. It wraps QueryBuilder and only supports fulltext queries.
|
|
471
|
+
*/
|
|
472
|
+
async executeJCRQuery(query, limit = 20) {
|
|
473
|
+
return safeExecute(async () => {
|
|
474
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
475
|
+
throw new Error('Query is required and must be a non-empty string. Note: Only QueryBuilder fulltext is supported, not JCR SQL2.');
|
|
476
|
+
}
|
|
477
|
+
// Basic security validation
|
|
478
|
+
const lower = query.toLowerCase();
|
|
479
|
+
if (/drop|delete|update|insert|exec|script|\.|<script/i.test(lower) || query.length > 1000) {
|
|
480
|
+
throw new Error('Query contains potentially unsafe patterns or is too long');
|
|
481
|
+
}
|
|
482
|
+
const client = this.createAxiosInstance();
|
|
483
|
+
const response = await client.get('/bin/querybuilder.json', {
|
|
484
|
+
params: {
|
|
485
|
+
path: '/content',
|
|
486
|
+
type: 'cq:Page',
|
|
487
|
+
fulltext: query,
|
|
488
|
+
'p.limit': limit
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
return {
|
|
492
|
+
query,
|
|
493
|
+
results: response.data.hits || [],
|
|
494
|
+
total: response.data.total || 0,
|
|
495
|
+
limit
|
|
496
|
+
};
|
|
497
|
+
}, 'executeJCRQuery');
|
|
498
|
+
}
|
|
499
|
+
async getPageProperties(pagePath) {
|
|
500
|
+
return safeExecute(async () => {
|
|
501
|
+
const client = this.createAxiosInstance();
|
|
502
|
+
const response = await client.get(`${pagePath}/jcr:content.json`);
|
|
503
|
+
const content = response.data;
|
|
504
|
+
const properties = {
|
|
505
|
+
title: content['jcr:title'],
|
|
506
|
+
description: content['jcr:description'],
|
|
507
|
+
template: content['cq:template'],
|
|
508
|
+
lastModified: content['cq:lastModified'],
|
|
509
|
+
lastModifiedBy: content['cq:lastModifiedBy'],
|
|
510
|
+
created: content['jcr:created'],
|
|
511
|
+
createdBy: content['jcr:createdBy'],
|
|
512
|
+
primaryType: content['jcr:primaryType'],
|
|
513
|
+
resourceType: content['sling:resourceType'],
|
|
514
|
+
tags: content['cq:tags'] || [],
|
|
515
|
+
properties: content,
|
|
516
|
+
};
|
|
517
|
+
return properties;
|
|
518
|
+
}, 'getPageProperties');
|
|
519
|
+
}
|
|
520
|
+
async searchContent(params) {
|
|
521
|
+
return safeExecute(async () => {
|
|
522
|
+
const client = this.createAxiosInstance();
|
|
523
|
+
const response = await client.get(this.config.aem.endpoints.query, { params });
|
|
524
|
+
return createSuccessResponse({
|
|
525
|
+
params,
|
|
526
|
+
results: response.data.hits || [],
|
|
527
|
+
total: response.data.total || 0,
|
|
528
|
+
rawResponse: response.data,
|
|
529
|
+
}, 'searchContent');
|
|
530
|
+
}, 'searchContent');
|
|
531
|
+
}
|
|
532
|
+
async getAssetMetadata(assetPath) {
|
|
533
|
+
return safeExecute(async () => {
|
|
534
|
+
const client = this.createAxiosInstance();
|
|
535
|
+
const response = await client.get(`${assetPath}.json`);
|
|
536
|
+
const metadata = response.data['jcr:content']?.metadata || {};
|
|
537
|
+
return createSuccessResponse({
|
|
538
|
+
assetPath,
|
|
539
|
+
metadata,
|
|
540
|
+
fullData: response.data,
|
|
541
|
+
}, 'getAssetMetadata');
|
|
542
|
+
}, 'getAssetMetadata');
|
|
543
|
+
}
|
|
544
|
+
async createPage(request) {
|
|
545
|
+
return safeExecute(async () => {
|
|
546
|
+
const { parentPath, title, template, name, properties } = request;
|
|
547
|
+
if (!isValidContentPath(parentPath, this.aemConfig)) {
|
|
548
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid parent path: ${String(parentPath)}`, { parentPath });
|
|
549
|
+
}
|
|
550
|
+
const pageName = name || title.replace(/\s+/g, '-').toLowerCase();
|
|
551
|
+
const newPagePath = `${parentPath}/${pageName}`;
|
|
552
|
+
const client = this.createAxiosInstance();
|
|
553
|
+
await client.post(newPagePath, {
|
|
554
|
+
'jcr:primaryType': 'cq:Page',
|
|
555
|
+
'jcr:title': title,
|
|
556
|
+
'cq:template': template,
|
|
557
|
+
...properties,
|
|
558
|
+
});
|
|
559
|
+
return createSuccessResponse({
|
|
560
|
+
success: true,
|
|
561
|
+
pagePath: newPagePath,
|
|
562
|
+
title,
|
|
563
|
+
template,
|
|
564
|
+
properties,
|
|
565
|
+
timestamp: new Date().toISOString(),
|
|
566
|
+
}, 'createPage');
|
|
567
|
+
}, 'createPage');
|
|
568
|
+
}
|
|
569
|
+
async deletePage(request) {
|
|
570
|
+
return safeExecute(async () => {
|
|
571
|
+
const { pagePath, force = false } = request;
|
|
572
|
+
if (!isValidContentPath(pagePath, this.aemConfig)) {
|
|
573
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid page path: ${String(pagePath)}`, { pagePath });
|
|
574
|
+
}
|
|
575
|
+
const client = this.createAxiosInstance();
|
|
576
|
+
let deleted = false;
|
|
577
|
+
try {
|
|
578
|
+
await client.delete(pagePath);
|
|
579
|
+
deleted = true;
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
if (err.response && err.response.status === 405) {
|
|
583
|
+
try {
|
|
584
|
+
await client.post('/bin/wcmcommand', {
|
|
585
|
+
cmd: 'deletePage',
|
|
586
|
+
path: pagePath,
|
|
587
|
+
force: force.toString(),
|
|
588
|
+
});
|
|
589
|
+
deleted = true;
|
|
590
|
+
}
|
|
591
|
+
catch (postErr) {
|
|
592
|
+
// Fallback to Sling POST servlet
|
|
593
|
+
try {
|
|
594
|
+
await client.post(pagePath, { ':operation': 'delete' });
|
|
595
|
+
deleted = true;
|
|
596
|
+
}
|
|
597
|
+
catch (slingErr) {
|
|
598
|
+
console.error('Sling POST delete failed:', slingErr.response?.status, slingErr.response?.data);
|
|
599
|
+
throw slingErr;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
console.error('DELETE failed:', err.response?.status, err.response?.data);
|
|
605
|
+
throw err;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return createSuccessResponse({
|
|
609
|
+
success: deleted,
|
|
610
|
+
deletedPath: pagePath,
|
|
611
|
+
timestamp: new Date().toISOString(),
|
|
612
|
+
}, 'deletePage');
|
|
613
|
+
}, 'deletePage');
|
|
614
|
+
}
|
|
615
|
+
async createComponent(request) {
|
|
616
|
+
return safeExecute(async () => {
|
|
617
|
+
const { pagePath, componentType, resourceType, properties = {}, name } = request;
|
|
618
|
+
if (!isValidContentPath(pagePath, this.aemConfig)) {
|
|
619
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid page path: ${String(pagePath)}`, { pagePath });
|
|
620
|
+
}
|
|
621
|
+
if (!isValidComponentType(componentType, this.aemConfig)) {
|
|
622
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid component type: ${componentType}`, { componentType });
|
|
623
|
+
}
|
|
624
|
+
const componentName = name || `${componentType}_${Date.now()}`;
|
|
625
|
+
const componentPath = `${pagePath}/jcr:content/${componentName}`;
|
|
626
|
+
const client = this.createAxiosInstance();
|
|
627
|
+
await client.post(componentPath, {
|
|
628
|
+
'jcr:primaryType': 'nt:unstructured',
|
|
629
|
+
'sling:resourceType': resourceType,
|
|
630
|
+
...properties,
|
|
631
|
+
':operation': 'import',
|
|
632
|
+
':contentType': 'json',
|
|
633
|
+
':replace': 'true',
|
|
634
|
+
});
|
|
635
|
+
return createSuccessResponse({
|
|
636
|
+
success: true,
|
|
637
|
+
componentPath,
|
|
638
|
+
componentType,
|
|
639
|
+
resourceType,
|
|
640
|
+
properties,
|
|
641
|
+
timestamp: new Date().toISOString(),
|
|
642
|
+
}, 'createComponent');
|
|
643
|
+
}, 'createComponent');
|
|
644
|
+
}
|
|
645
|
+
async deleteComponent(request) {
|
|
646
|
+
return safeExecute(async () => {
|
|
647
|
+
const { componentPath, force = false } = request;
|
|
648
|
+
if (!isValidContentPath(componentPath, this.aemConfig)) {
|
|
649
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid component path: ${String(componentPath)}`, { componentPath });
|
|
650
|
+
}
|
|
651
|
+
const client = this.createAxiosInstance();
|
|
652
|
+
let deleted = false;
|
|
653
|
+
try {
|
|
654
|
+
await client.delete(componentPath);
|
|
655
|
+
deleted = true;
|
|
656
|
+
}
|
|
657
|
+
catch (err) {
|
|
658
|
+
if (err.response && err.response.status === 405) {
|
|
659
|
+
try {
|
|
660
|
+
await client.post(componentPath, { ':operation': 'delete' });
|
|
661
|
+
deleted = true;
|
|
662
|
+
}
|
|
663
|
+
catch (slingErr) {
|
|
664
|
+
console.error('Sling POST delete failed:', slingErr.response?.status, slingErr.response?.data);
|
|
665
|
+
throw slingErr;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
console.error('DELETE failed:', err.response?.status, err.response?.data);
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return createSuccessResponse({
|
|
674
|
+
success: deleted,
|
|
675
|
+
deletedPath: componentPath,
|
|
676
|
+
timestamp: new Date().toISOString(),
|
|
677
|
+
}, 'deleteComponent');
|
|
678
|
+
}, 'deleteComponent');
|
|
679
|
+
}
|
|
680
|
+
async unpublishContent(request) {
|
|
681
|
+
return safeExecute(async () => {
|
|
682
|
+
const { contentPaths, unpublishTree = false } = request;
|
|
683
|
+
if (!contentPaths || (Array.isArray(contentPaths) && contentPaths.length === 0)) {
|
|
684
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, 'Content paths array is required and cannot be empty', { contentPaths });
|
|
685
|
+
}
|
|
686
|
+
const client = this.createAxiosInstance();
|
|
687
|
+
await client.post('/etc/replication/agents.author/publish/jcr:content.queue.json', {
|
|
688
|
+
cmd: 'Deactivate',
|
|
689
|
+
path: contentPaths,
|
|
690
|
+
ignoredeactivated: false,
|
|
691
|
+
onlymodified: false,
|
|
692
|
+
});
|
|
693
|
+
return createSuccessResponse({
|
|
694
|
+
success: true,
|
|
695
|
+
unpublishedPaths: contentPaths,
|
|
696
|
+
unpublishTree,
|
|
697
|
+
timestamp: new Date().toISOString(),
|
|
698
|
+
}, 'unpublishContent');
|
|
699
|
+
}, 'unpublishContent');
|
|
700
|
+
}
|
|
701
|
+
async activatePage(request) {
|
|
702
|
+
return safeExecute(async () => {
|
|
703
|
+
const { pagePath, activateTree = false } = request;
|
|
704
|
+
if (!isValidContentPath(pagePath, this.aemConfig)) {
|
|
705
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid page path: ${String(pagePath)}`, { pagePath });
|
|
706
|
+
}
|
|
707
|
+
const client = this.createAxiosInstance();
|
|
708
|
+
await client.post('/etc/replication/agents.author/publish/jcr:content.queue.json', {
|
|
709
|
+
cmd: 'Activate',
|
|
710
|
+
path: pagePath,
|
|
711
|
+
ignoredeactivated: false,
|
|
712
|
+
onlymodified: false,
|
|
713
|
+
});
|
|
714
|
+
return createSuccessResponse({
|
|
715
|
+
success: true,
|
|
716
|
+
activatedPath: pagePath,
|
|
717
|
+
activateTree,
|
|
718
|
+
timestamp: new Date().toISOString(),
|
|
719
|
+
}, 'activatePage');
|
|
720
|
+
}, 'activatePage');
|
|
721
|
+
}
|
|
722
|
+
async deactivatePage(request) {
|
|
723
|
+
return safeExecute(async () => {
|
|
724
|
+
const { pagePath, deactivateTree = false } = request;
|
|
725
|
+
if (!isValidContentPath(pagePath, this.aemConfig)) {
|
|
726
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid page path: ${String(pagePath)}`, { pagePath });
|
|
727
|
+
}
|
|
728
|
+
const client = this.createAxiosInstance();
|
|
729
|
+
await client.post('/etc/replication/agents.author/publish/jcr:content.queue.json', {
|
|
730
|
+
cmd: 'Deactivate',
|
|
731
|
+
path: pagePath,
|
|
732
|
+
ignoredeactivated: false,
|
|
733
|
+
onlymodified: false,
|
|
734
|
+
});
|
|
735
|
+
return createSuccessResponse({
|
|
736
|
+
success: true,
|
|
737
|
+
deactivatedPath: pagePath,
|
|
738
|
+
deactivateTree,
|
|
739
|
+
timestamp: new Date().toISOString(),
|
|
740
|
+
}, 'deactivatePage');
|
|
741
|
+
}, 'deactivatePage');
|
|
742
|
+
}
|
|
743
|
+
async uploadAsset(request) {
|
|
744
|
+
return safeExecute(async () => {
|
|
745
|
+
const { parentPath, fileName, fileContent, mimeType, metadata = {} } = request;
|
|
746
|
+
if (!isValidContentPath(parentPath, this.aemConfig)) {
|
|
747
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid parent path: ${String(parentPath)}`, { parentPath });
|
|
748
|
+
}
|
|
749
|
+
const assetPath = `${parentPath}/${fileName}`;
|
|
750
|
+
const formData = new URLSearchParams();
|
|
751
|
+
if (typeof fileContent === 'string') {
|
|
752
|
+
formData.append('file', fileContent);
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
formData.append('file', fileContent.toString());
|
|
756
|
+
}
|
|
757
|
+
formData.append('fileName', fileName);
|
|
758
|
+
if (mimeType) {
|
|
759
|
+
formData.append('mimeType', mimeType);
|
|
760
|
+
}
|
|
761
|
+
Object.entries(metadata).forEach(([key, value]) => {
|
|
762
|
+
formData.append(`./jcr:content/metadata/${key}`, String(value));
|
|
763
|
+
});
|
|
764
|
+
const client = this.createAxiosInstance();
|
|
765
|
+
await client.post(assetPath, formData, {
|
|
766
|
+
headers: {
|
|
767
|
+
'Content-Type': 'multipart/form-data',
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
return createSuccessResponse({
|
|
771
|
+
success: true,
|
|
772
|
+
assetPath,
|
|
773
|
+
fileName,
|
|
774
|
+
mimeType,
|
|
775
|
+
metadata,
|
|
776
|
+
timestamp: new Date().toISOString(),
|
|
777
|
+
}, 'uploadAsset');
|
|
778
|
+
}, 'uploadAsset');
|
|
779
|
+
}
|
|
780
|
+
async updateAsset(request) {
|
|
781
|
+
return safeExecute(async () => {
|
|
782
|
+
const { assetPath, metadata, fileContent, mimeType } = request;
|
|
783
|
+
if (!isValidContentPath(assetPath, this.aemConfig)) {
|
|
784
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid asset path: ${String(assetPath)}`, { assetPath });
|
|
785
|
+
}
|
|
786
|
+
const updateData = {};
|
|
787
|
+
if (fileContent) {
|
|
788
|
+
updateData.file = fileContent;
|
|
789
|
+
if (mimeType) {
|
|
790
|
+
updateData.mimeType = mimeType;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (metadata) {
|
|
794
|
+
Object.entries(metadata).forEach(([key, value]) => {
|
|
795
|
+
updateData[`./jcr:content/metadata/${key}`] = value;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
const client = this.createAxiosInstance();
|
|
799
|
+
await client.post(assetPath, updateData);
|
|
800
|
+
return createSuccessResponse({
|
|
801
|
+
success: true,
|
|
802
|
+
assetPath,
|
|
803
|
+
updatedMetadata: metadata,
|
|
804
|
+
timestamp: new Date().toISOString(),
|
|
805
|
+
}, 'updateAsset');
|
|
806
|
+
}, 'updateAsset');
|
|
807
|
+
}
|
|
808
|
+
async deleteAsset(request) {
|
|
809
|
+
return safeExecute(async () => {
|
|
810
|
+
const { assetPath, force = false } = request;
|
|
811
|
+
if (!isValidContentPath(assetPath, this.aemConfig)) {
|
|
812
|
+
throw createAEMError(AEM_ERROR_CODES.INVALID_PARAMETERS, `Invalid asset path: ${String(assetPath)}`, { assetPath });
|
|
813
|
+
}
|
|
814
|
+
const client = this.createAxiosInstance();
|
|
815
|
+
await client.delete(assetPath);
|
|
816
|
+
return createSuccessResponse({
|
|
817
|
+
success: true,
|
|
818
|
+
deletedPath: assetPath,
|
|
819
|
+
force,
|
|
820
|
+
timestamp: new Date().toISOString(),
|
|
821
|
+
}, 'deleteAsset');
|
|
822
|
+
}, 'deleteAsset');
|
|
823
|
+
}
|
|
824
|
+
async getTemplates(sitePath) {
|
|
825
|
+
return safeExecute(async () => {
|
|
826
|
+
const client = this.createAxiosInstance();
|
|
827
|
+
const response = await client.get(`${sitePath}.json`);
|
|
828
|
+
return createSuccessResponse({
|
|
829
|
+
sitePath,
|
|
830
|
+
templates: response.data,
|
|
831
|
+
}, 'getTemplates');
|
|
832
|
+
}, 'getTemplates');
|
|
833
|
+
}
|
|
834
|
+
async getTemplateStructure(templatePath) {
|
|
835
|
+
return safeExecute(async () => {
|
|
836
|
+
const client = this.createAxiosInstance();
|
|
837
|
+
const response = await client.get(`${templatePath}.json`);
|
|
838
|
+
return createSuccessResponse({
|
|
839
|
+
templatePath,
|
|
840
|
+
structure: response.data,
|
|
841
|
+
}, 'getTemplateStructure');
|
|
842
|
+
}, 'getTemplateStructure');
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Legacy: Get JCR node content as raw JSON for a given path and depth.
|
|
846
|
+
*/
|
|
847
|
+
async getNodeContent(path, depth = 1) {
|
|
848
|
+
return safeExecute(async () => {
|
|
849
|
+
const client = this.createAxiosInstance();
|
|
850
|
+
const response = await client.get(`${path}.json`, { params: { depth } });
|
|
851
|
+
return {
|
|
852
|
+
path,
|
|
853
|
+
depth,
|
|
854
|
+
content: response.data,
|
|
855
|
+
};
|
|
856
|
+
}, 'getNodeContent');
|
|
857
|
+
}
|
|
858
|
+
}
|