@sinoia/hubdoc-tools 1.3.2 → 1.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sinoia/hubdoc-tools",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Professional command-line tool for HubDoc document management and bulk import/export",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "plugins",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
@@ -0,0 +1,518 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import {
5
+ DocumentSourcePlugin,
6
+ DocumentSource,
7
+ PluginConfig,
8
+ ScanResult,
9
+ PluginImportOptions,
10
+ PluginExportOptions,
11
+ ImportResult,
12
+ ExportResult
13
+ } from '../../src/types/plugins';
14
+
15
+ interface AlfrescoConfig extends PluginConfig {
16
+ baseUrl: string;
17
+ username: string;
18
+ password: string;
19
+ siteId?: string;
20
+ folderId?: string; // Starting folder ID, defaults to root
21
+ includeFolders?: boolean;
22
+ limit?: number;
23
+ }
24
+
25
+ interface AlfrescoNode {
26
+ id: string;
27
+ name: string;
28
+ nodeType: string;
29
+ isFolder: boolean;
30
+ isFile: boolean;
31
+ createdAt: string;
32
+ modifiedAt: string;
33
+ createdByUser: {
34
+ id: string;
35
+ displayName: string;
36
+ };
37
+ modifiedByUser: {
38
+ id: string;
39
+ displayName: string;
40
+ };
41
+ content?: {
42
+ mimeType: string;
43
+ sizeInBytes: number;
44
+ };
45
+ properties?: Record<string, any>;
46
+ path?: {
47
+ name: string;
48
+ isComplete: boolean;
49
+ elements: Array<{
50
+ id: string;
51
+ name: string;
52
+ }>;
53
+ };
54
+ }
55
+
56
+ export default class AlfrescoPlugin implements DocumentSourcePlugin {
57
+ readonly name = 'alfresco';
58
+ readonly version = '1.0.0';
59
+ readonly description = 'Alfresco ECM document source';
60
+ readonly supportedOperations = ['import', 'export', 'both'] as const;
61
+
62
+ private config?: AlfrescoConfig;
63
+ private apiClient?: AxiosInstance;
64
+ private readonly apiVersion = 'alfresco/versions/1';
65
+
66
+ async testConnection(config: PluginConfig): Promise<boolean> {
67
+ try {
68
+ const client = this.createApiClient(config as AlfrescoConfig);
69
+ const response = await client.get('/people/-me-');
70
+ return response.status === 200;
71
+ } catch (error: any) {
72
+ console.error(`Alfresco connection test failed: ${error.message}`);
73
+ return false;
74
+ }
75
+ }
76
+
77
+ async scan(config: PluginConfig, options?: PluginImportOptions): Promise<ScanResult> {
78
+ this.config = config as AlfrescoConfig;
79
+ this.apiClient = this.createApiClient(this.config);
80
+
81
+ const sources: DocumentSource[] = [];
82
+ const errors: string[] = [];
83
+ let totalSize = 0;
84
+
85
+ try {
86
+ const limit = (this.config as any).limit || (options as any)?.limit;
87
+ console.log(`🔍 Scanning Alfresco repository${limit ? ` (limit: ${limit})` : ''}...`);
88
+
89
+ // Determine starting node ID
90
+ let startingNodeId = this.config.folderId || '-root-';
91
+
92
+ // If siteId is provided, get the document library
93
+ if (this.config.siteId) {
94
+ startingNodeId = await this.getSiteDocumentLibraryId(this.config.siteId);
95
+ }
96
+
97
+ const nodes = await this.scanNode(startingNodeId);
98
+
99
+ let processedCount = 0;
100
+ for (const node of nodes) {
101
+ if (node.isFile && node.content) {
102
+ const source: DocumentSource = {
103
+ id: node.id,
104
+ name: node.name,
105
+ path: this.getNodePath(node),
106
+ size: node.content.sizeInBytes,
107
+ mimeType: node.content.mimeType,
108
+ lastModified: new Date(node.modifiedAt),
109
+ metadata: {
110
+ alfrescoId: node.id,
111
+ nodeType: node.nodeType,
112
+ createdAt: node.createdAt,
113
+ createdBy: node.createdByUser.displayName,
114
+ modifiedBy: node.modifiedByUser.displayName,
115
+ properties: node.properties
116
+ }
117
+ };
118
+
119
+ // Apply filters
120
+ if (this.shouldIncludeSource(source, options)) {
121
+ sources.push(source);
122
+ totalSize += source.size;
123
+ processedCount++;
124
+
125
+ // Check limit
126
+ if (limit && processedCount >= limit) {
127
+ console.log(`📏 Reached limit of ${limit} files`);
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ return {
135
+ sources,
136
+ totalCount: sources.length,
137
+ totalSize,
138
+ errors
139
+ };
140
+ } catch (error: any) {
141
+ return {
142
+ sources: [],
143
+ totalCount: 0,
144
+ totalSize: 0,
145
+ errors: [`Alfresco scan failed: ${error.message}`]
146
+ };
147
+ }
148
+ }
149
+
150
+ private async scanNode(nodeId: string, parentPath: string = ''): Promise<AlfrescoNode[]> {
151
+ if (!this.apiClient) throw new Error('API client not initialized');
152
+
153
+ const allNodes: AlfrescoNode[] = [];
154
+ let skipCount = 0;
155
+ const maxItems = 100;
156
+
157
+ try {
158
+ while (true) {
159
+ const response = await this.apiClient.get(`/nodes/${nodeId}/children`, {
160
+ params: {
161
+ skipCount,
162
+ maxItems,
163
+ include: 'properties,path',
164
+ fields: 'id,name,nodeType,isFolder,isFile,createdAt,modifiedAt,createdByUser,modifiedByUser,content,properties,path'
165
+ }
166
+ });
167
+
168
+ const nodes: AlfrescoNode[] = response.data.list.entries.map((entry: any) => {
169
+ const node = entry.entry;
170
+ // Add full path information
171
+ (node as any).fullPath = parentPath ? `${parentPath}/${node.name}` : node.name;
172
+ return node;
173
+ });
174
+
175
+ // Process files immediately
176
+ const files = nodes.filter(node => node.isFile);
177
+ allNodes.push(...files);
178
+
179
+ // Recursively process folders if includeFolders is enabled or not specified
180
+ if (this.config?.includeFolders !== false) {
181
+ const folders = nodes.filter(node => node.isFolder);
182
+ for (const folder of folders) {
183
+ const folderPath = parentPath ? `${parentPath}/${folder.name}` : folder.name;
184
+ const subNodes = await this.scanNode(folder.id, folderPath);
185
+ allNodes.push(...subNodes);
186
+ }
187
+ }
188
+
189
+ // Check if there are more items
190
+ if (nodes.length < maxItems) {
191
+ break;
192
+ }
193
+ skipCount += maxItems;
194
+ }
195
+
196
+ return allNodes;
197
+ } catch (error: any) {
198
+ if (error.response?.status === 404) {
199
+ console.warn(`Warning: Node not found: ${nodeId}`);
200
+ return [];
201
+ }
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ async import(
207
+ config: PluginConfig,
208
+ sources: DocumentSource[],
209
+ targetDir: string,
210
+ options?: PluginImportOptions
211
+ ): Promise<ImportResult[]> {
212
+ this.config = config as AlfrescoConfig;
213
+ this.apiClient = this.createApiClient(this.config);
214
+
215
+ const results: ImportResult[] = [];
216
+ const batchSize = options?.batchSize || 5;
217
+
218
+ // Process in batches to respect API limits
219
+ for (let i = 0; i < sources.length; i += batchSize) {
220
+ const batch = sources.slice(i, i + batchSize);
221
+
222
+ for (const source of batch) {
223
+ const result = await this.importSingle(source, targetDir);
224
+ results.push(result);
225
+
226
+ // Small delay to respect rate limits
227
+ await this.sleep(200);
228
+ }
229
+ }
230
+
231
+ return results;
232
+ }
233
+
234
+ private async importSingle(source: DocumentSource, targetDir: string): Promise<ImportResult> {
235
+ try {
236
+ if (!this.apiClient) throw new Error('API client not initialized');
237
+
238
+ const targetPath = path.join(targetDir, source.path);
239
+ const targetDirectory = path.dirname(targetPath);
240
+
241
+ await fs.ensureDir(targetDirectory);
242
+
243
+ // Download file content from Alfresco
244
+ const response = await this.apiClient.get(`/nodes/${source.id}/content`, {
245
+ responseType: 'stream'
246
+ });
247
+
248
+ const writer = fs.createWriteStream(targetPath);
249
+ response.data.pipe(writer);
250
+
251
+ return new Promise((resolve) => {
252
+ writer.on('finish', () => {
253
+ resolve({
254
+ success: true,
255
+ source,
256
+ localPath: targetPath,
257
+ bytesTransferred: source.size
258
+ });
259
+ });
260
+
261
+ writer.on('error', (error) => {
262
+ resolve({
263
+ success: false,
264
+ source,
265
+ error: error.message
266
+ });
267
+ });
268
+ });
269
+ } catch (error: any) {
270
+ return {
271
+ success: false,
272
+ source,
273
+ error: error.message
274
+ };
275
+ }
276
+ }
277
+
278
+ async export?(
279
+ config: PluginConfig,
280
+ localSources: DocumentSource[],
281
+ options?: PluginExportOptions
282
+ ): Promise<ExportResult[]> {
283
+ this.config = config as AlfrescoConfig;
284
+ this.apiClient = this.createApiClient(this.config);
285
+
286
+ const results: ExportResult[] = [];
287
+
288
+ // Determine target folder ID
289
+ let targetFolderId = this.config.folderId || '-root-';
290
+ if (this.config.siteId) {
291
+ targetFolderId = await this.getSiteDocumentLibraryId(this.config.siteId);
292
+ }
293
+
294
+ for (const source of localSources) {
295
+ try {
296
+ // Determine target folder for this document
297
+ let actualTargetFolderId = targetFolderId;
298
+
299
+ if (options?.preserveStructure && source.path.includes('/')) {
300
+ const folderPath = path.dirname(source.path);
301
+ actualTargetFolderId = await this.createFolderStructure(folderPath, targetFolderId);
302
+ }
303
+
304
+ // Read local file
305
+ const fileContent = await fs.readFile(source.id);
306
+ // Normalize filename to NFC to handle accented characters consistently across platforms
307
+ const fileName = (options?.preserveStructure ? path.basename(source.path) : source.name).normalize('NFC');
308
+
309
+ // Upload file to Alfresco
310
+ const FormData = require('form-data');
311
+ const form = new FormData();
312
+
313
+ form.append('filedata', fileContent, fileName);
314
+ form.append('name', fileName);
315
+ form.append('nodeType', 'cm:content');
316
+
317
+ const response = await this.apiClient!.post(`/nodes/${actualTargetFolderId}/children`, form, {
318
+ headers: {
319
+ ...form.getHeaders()
320
+ }
321
+ });
322
+
323
+ const targetPath = options?.preserveStructure ? source.path : source.name;
324
+
325
+ results.push({
326
+ success: true,
327
+ targetPath,
328
+ source,
329
+ bytesTransferred: source.size
330
+ });
331
+ } catch (error: any) {
332
+ results.push({
333
+ success: false,
334
+ targetPath: options?.targetPath || '',
335
+ source,
336
+ error: error.message
337
+ });
338
+ }
339
+ }
340
+
341
+ return results;
342
+ }
343
+
344
+ private async getSiteDocumentLibraryId(siteId: string): Promise<string> {
345
+ if (!this.apiClient) throw new Error('API client not initialized');
346
+
347
+ try {
348
+ const response = await this.apiClient.get(`/sites/${siteId}/containers`);
349
+ const containers = response.data.list.entries;
350
+
351
+ const documentLibrary = containers.find((container: any) =>
352
+ container.entry.folderId === 'documentLibrary'
353
+ );
354
+
355
+ if (!documentLibrary) {
356
+ throw new Error(`Document library not found for site: ${siteId}`);
357
+ }
358
+
359
+ return documentLibrary.entry.id;
360
+ } catch (error: any) {
361
+ throw new Error(`Failed to get document library for site ${siteId}: ${error.message}`);
362
+ }
363
+ }
364
+
365
+ private async createFolderStructure(folderPath: string, parentId: string): Promise<string> {
366
+ if (!this.apiClient) throw new Error('API client not initialized');
367
+
368
+ const parts = folderPath.split('/').filter(part => part.length > 0);
369
+ let currentParentId = parentId;
370
+
371
+ for (const folderName of parts) {
372
+ // Check if folder already exists
373
+ try {
374
+ const response = await this.apiClient.get(`/nodes/${currentParentId}/children`, {
375
+ params: {
376
+ where: `(name='${folderName}' AND nodeType='cm:folder')`,
377
+ maxItems: 1
378
+ }
379
+ });
380
+
381
+ const existingFolder = response.data.list.entries[0];
382
+
383
+ if (existingFolder) {
384
+ currentParentId = existingFolder.entry.id;
385
+ } else {
386
+ // Create new folder
387
+ const createResponse = await this.apiClient.post(`/nodes/${currentParentId}/children`, {
388
+ name: folderName,
389
+ nodeType: 'cm:folder'
390
+ });
391
+ currentParentId = createResponse.data.entry.id;
392
+ }
393
+ } catch (error: any) {
394
+ throw new Error(`Failed to create folder structure: ${error.message}`);
395
+ }
396
+ }
397
+
398
+ return currentParentId;
399
+ }
400
+
401
+ getConfigSchema(): Record<string, any> {
402
+ return {
403
+ type: 'object',
404
+ properties: {
405
+ baseUrl: {
406
+ type: 'string',
407
+ description: 'Alfresco base URL (e.g., http://localhost:8080/alfresco)',
408
+ required: true
409
+ },
410
+ username: {
411
+ type: 'string',
412
+ description: 'Alfresco username',
413
+ required: true
414
+ },
415
+ password: {
416
+ type: 'string',
417
+ description: 'Alfresco password',
418
+ required: true
419
+ },
420
+ siteId: {
421
+ type: 'string',
422
+ description: 'Alfresco site ID to scan (optional, uses repository root if not specified)',
423
+ required: false
424
+ },
425
+ folderId: {
426
+ type: 'string',
427
+ description: 'Starting folder ID (optional, uses site document library or root)',
428
+ required: false
429
+ },
430
+ includeFolders: {
431
+ type: 'boolean',
432
+ description: 'Include subfolders in scan (default: true)',
433
+ default: true
434
+ },
435
+ limit: {
436
+ type: 'number',
437
+ description: 'Maximum number of documents to scan (useful for testing)',
438
+ required: false
439
+ }
440
+ },
441
+ required: ['baseUrl', 'username', 'password']
442
+ };
443
+ }
444
+
445
+ async initialize(config: PluginConfig): Promise<void> {
446
+ this.config = config as AlfrescoConfig;
447
+
448
+ if (!this.config.baseUrl || !this.config.username || !this.config.password) {
449
+ throw new Error('Alfresco base URL, username, and password are required');
450
+ }
451
+
452
+ this.apiClient = this.createApiClient(this.config);
453
+ }
454
+
455
+ async destroy(): Promise<void> {
456
+ this.config = undefined;
457
+ this.apiClient = undefined;
458
+ }
459
+
460
+ private createApiClient(config: AlfrescoConfig): AxiosInstance {
461
+ const baseURL = `${config.baseUrl.replace(/\/$/, '')}/api/-default-/public/${this.apiVersion}`;
462
+
463
+ return axios.create({
464
+ baseURL,
465
+ auth: {
466
+ username: config.username,
467
+ password: config.password
468
+ },
469
+ headers: {
470
+ 'Content-Type': 'application/json'
471
+ },
472
+ timeout: 30000
473
+ });
474
+ }
475
+
476
+ private getNodePath(node: AlfrescoNode): string {
477
+ // Use the full path if available from scanning
478
+ if ((node as any).fullPath) {
479
+ return (node as any).fullPath;
480
+ }
481
+
482
+ // Construct path from path collection
483
+ if (node.path && node.path.isComplete) {
484
+ const pathParts = node.path.elements
485
+ .filter(element => element.name !== 'Company Home') // Skip root element
486
+ .map(element => element.name);
487
+ pathParts.push(node.name);
488
+ return pathParts.join('/');
489
+ }
490
+
491
+ return node.name;
492
+ }
493
+
494
+ private shouldIncludeSource(source: DocumentSource, options?: PluginImportOptions): boolean {
495
+ // Apply size filter
496
+ if (options?.filters?.maxSize && source.size > options.filters.maxSize) {
497
+ return false;
498
+ }
499
+
500
+ // Apply date range filter
501
+ if (options?.filters?.dateRange) {
502
+ const { from, to } = options.filters.dateRange;
503
+ if (from && source.lastModified < from) return false;
504
+ if (to && source.lastModified > to) return false;
505
+ }
506
+
507
+ // Apply MIME type filter
508
+ if (options?.filters?.mimeTypes && !options.filters.mimeTypes.includes(source.mimeType)) {
509
+ return false;
510
+ }
511
+
512
+ return true;
513
+ }
514
+
515
+ private sleep(ms: number): Promise<void> {
516
+ return new Promise(resolve => setTimeout(resolve, ms));
517
+ }
518
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "alfresco",
3
+ "version": "1.0.0",
4
+ "description": "Alfresco document source plugin",
5
+ "author": "HubDoc Tools",
6
+ "main": "index.ts",
7
+ "hubdocToolVersion": "^1.0.0",
8
+ "dependencies": {
9
+ "axios": "^1.5.0",
10
+ "fs-extra": "^11.1.0"
11
+ }
12
+ }