UNPKG

adpa-enterprise-framework-automation

Version:

Modular, standards-compliant Node.js/TypeScript automation framework for enterprise requirements, project, and data management. Provides CLI and API for BABOK v3, PMBOK 7th Edition, and DMBOK 2.0 (in progress). Production-ready Express.js API with TypeSpe

763 lines • 29.3 kB
/** * SharePoint Publisher Module * Part of ADPA (Advanced Document Processing Assistant) v2.1.3 * * Handles publishing generated documents to SharePoint Online sites and document libraries * using Microsoft Graph API for secure, enterprise-grade document management. * * Features: * - Microsoft Graph API integration * - OAuth 2.0 and Service Principal authentication * - Batch document upload with retry logic * - SharePoint metadata management * - Folder structure creation and management * - Version control and conflict resolution * - Enterprise security and compliance * * References: * - Microsoft Graph API: https://docs.microsoft.com/en-us/graph/api/overview * - SharePoint REST API: https://docs.microsoft.com/en-us/sharepoint/dev/sp-add-ins/working-with-folders-and-files-with-rest */ import axios from 'axios'; import { existsSync, readFileSync } from 'fs'; import { join, dirname, basename } from 'path'; import { Client } from '@microsoft/microsoft-graph-client'; import { SharePointOAuth2 } from './SharePointOAuth2.js'; /** * Helper function to create OAuth2Config with complete parameters */ const createOAuth2Config = (config) => { if (!config.oauth2) { throw new Error('OAuth2 configuration is missing'); } return { ...config.oauth2, tenantId: config.tenantId, clientId: config.clientId }; }; /** * Custom authentication provider for Microsoft Graph Client */ class GraphAuthProvider { oauth2Handler; constructor(oauth2Handler) { this.oauth2Handler = oauth2Handler; } async getAccessToken() { return await this.oauth2Handler.getValidAccessToken(); } } /** * Main SharePoint Publisher class * Handles all interactions with SharePoint Online via Microsoft Graph API */ export class SharePointPublisher { config; oauth2Handler; graphClient; axiosClient; siteId; driveId; constructor(config) { this.config = config; // Initialize OAuth2 handler if using OAuth2 if (config.authMethod === 'oauth2' && config.oauth2) { this.oauth2Handler = new SharePointOAuth2(createOAuth2Config(config)); } // Initialize axios client for direct Graph API calls this.axiosClient = axios.create({ baseURL: 'https://graph.microsoft.com/v1.0', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 60000 // 60 second timeout }); // Set up request interceptor for authentication this.axiosClient.interceptors.request.use(async (config) => { const token = await this.getAccessToken(); config.headers['Authorization'] = `Bearer ${token}`; return config; }); // Set up response interceptor for error handling this.axiosClient.interceptors.response.use((response) => response, async (error) => { if (error.response?.status === 401) { console.log('šŸ”„ Token expired, refreshing...'); // Token refresh is handled automatically by OAuth2 handler return this.axiosClient.request(error.config); } return Promise.reject(error); }); } /** * Initialize SharePoint connection and get site/drive information */ async initialize() { try { console.log('šŸ”§ Initializing SharePoint connection...'); // Initialize Graph client if using OAuth2 if (this.oauth2Handler) { const authProvider = new GraphAuthProvider(this.oauth2Handler); this.graphClient = Client.initWithMiddleware({ authProvider }); } // Get site ID from site URL await this.resolveSiteId(); // Get drive ID for the document library await this.resolveDriveId(); console.log('āœ… SharePoint connection initialized'); console.log(` Site ID: ${this.siteId}`); console.log(` Drive ID: ${this.driveId}`); console.log(` Document Library: ${this.config.documentLibrary}`); } catch (error) { console.error('āŒ Failed to initialize SharePoint connection:', error.message); throw error; } } /** * Test connection to SharePoint and verify permissions */ async testConnection() { try { console.log('šŸ” Testing SharePoint connection...'); // Ensure we're initialized if (!this.siteId) { await this.initialize(); } // Test site access const siteResponse = await this.axiosClient.get(`/sites/${this.siteId}`); const siteInfo = siteResponse.data; // Test drive access const driveResponse = await this.axiosClient.get(`/sites/${this.siteId}/drives/${this.driveId}`); const driveInfo = driveResponse.data; // Test write permissions by checking if we can list items const itemsResponse = await this.axiosClient.get(`/sites/${this.siteId}/drives/${this.driveId}/root/children`); console.log('āœ… SharePoint connection test successful'); return { success: true, siteInfo: { id: siteInfo.id, displayName: siteInfo.displayName, webUrl: siteInfo.webUrl, description: siteInfo.description }, driveInfo: { id: driveInfo.id, name: driveInfo.name, driveType: driveInfo.driveType, quota: driveInfo.quota, itemCount: itemsResponse.data.value?.length || 0 } }; } catch (error) { console.error('āŒ SharePoint connection test failed:', error.message); return { success: false, error: error.response?.data?.error?.message || error.message }; } } /** * Start OAuth2 authentication flow */ async startOAuth2Login() { if (!this.oauth2Handler) { return { success: false, error: 'OAuth2 not configured for this publisher instance' }; } try { await this.oauth2Handler.startInteractiveFlow(); return { success: true }; } catch (error) { return { success: false, error: error.message }; } } /** * Check if user is authenticated */ async isAuthenticated() { if (!this.oauth2Handler) { // For service principal auth, always return true if properly configured return !!(this.config.clientId && this.config.clientSecret && this.config.tenantId); } return await this.oauth2Handler.isAuthenticated(); } /** * Publish a single document to SharePoint */ async publishDocument(document, options = {}) { try { console.log(`šŸ“¤ Publishing document: ${document.fileName}`); // Ensure we're initialized if (!this.siteId || !this.driveId) { await this.initialize(); } // Create folder structure if needed const folderPath = this.buildFolderPath(document.folderPath, options.parentFolderPath); if (folderPath && options.createFolders !== false) { await this.ensureFolderExists(folderPath); } // Convert content based on file extension const fileContent = this.prepareFileContent(document); // Upload file const uploadResult = await this.uploadFile(document.fileName, fileContent, folderPath, options.overwriteExisting !== false); // Add metadata if configured if (options.addMetadata !== false && document.metadata) { await this.addMetadata(uploadResult.id, document.metadata); } console.log(`āœ… Successfully published: ${document.fileName}`); return { success: true, driveItemId: uploadResult.id, webUrl: uploadResult.webUrl, fileName: document.fileName, size: uploadResult.size, lastModified: uploadResult.lastModifiedDateTime }; } catch (error) { console.error(`āŒ Failed to publish ${document.fileName}:`, error.message); return { success: false, error: error.response?.data?.error?.message || error.message, fileName: document.fileName }; } } /** * Publish multiple documents from a directory */ async publishDocuments(documentsPath, options = {}) { const results = []; if (!existsSync(documentsPath)) { throw new Error(`Documents path does not exist: ${documentsPath}`); } console.log(`šŸ“ Publishing documents from: ${documentsPath}`); // Get all markdown files const files = this.getMarkdownFiles(documentsPath); console.log(`šŸ“„ Found ${files.length} documents to publish`); // Process files with concurrency control const maxConcurrency = options.maxConcurrency || 3; const semaphore = new Array(maxConcurrency).fill(Promise.resolve()); let processedCount = 0; const processFile = async (filePath) => { try { const content = readFileSync(filePath, 'utf-8'); const fileName = basename(filePath); const folderPath = this.getFolderPathFromFile(filePath, documentsPath); const document = { title: this.extractTitleFromContent(content) || fileName.replace('.md', ''), content: content, fileName: fileName.replace('.md', '.md'), // Keep as markdown or convert to .docx folderPath: folderPath, metadata: this.generateMetadata(content, fileName), tags: options.tagPrefix ? [`${options.tagPrefix}-generated`] : ['adpa-generated'] }; const result = await this.publishDocument(document, options); processedCount++; if (options.progressCallback) { options.progressCallback(processedCount, files.length); } // Add delay between uploads to avoid throttling await this.delay(1000); return result; } catch (error) { return { success: false, error: error.message, fileName: basename(filePath) }; } }; // Process files with concurrency control const promises = files.map((filePath, index) => { return semaphore[index % maxConcurrency].then(() => processFile(filePath)); }); const fileResults = await Promise.all(promises); results.push(...fileResults); // Summary const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); console.log(`\nšŸ“Š Publishing Summary:`); console.log(` Total: ${results.length}`); console.log(` Successful: ${successful.length} āœ…`); console.log(` Failed: ${failed.length} āŒ`); if (failed.length > 0) { console.log('\nāŒ Failed uploads:'); failed.forEach(result => { console.log(` • ${result.fileName}: ${result.error}`); }); } return results; } /** * Create a folder in SharePoint document library */ async createFolder(folderPath) { try { const folderName = basename(folderPath); const parentPath = dirname(folderPath); let parentId = 'root'; if (parentPath && parentPath !== '.' && parentPath !== '/') { const parentItem = await this.getDriveItem(parentPath); parentId = parentItem.id; } const folderData = { name: folderName, folder: {}, '@microsoft.graph.conflictBehavior': 'rename' }; const response = await this.axiosClient.post(`/sites/${this.siteId}/drives/${this.driveId}/items/${parentId}/children`, folderData); console.log(`šŸ“ Created folder: ${folderPath}`); return response.data; } catch (error) { console.error(`āŒ Failed to create folder ${folderPath}:`, error.message); throw error; } } /** * Get drive item by path */ async getDriveItem(itemPath) { try { const encodedPath = encodeURIComponent(itemPath); const response = await this.axiosClient.get(`/sites/${this.siteId}/drives/${this.driveId}/root:/${encodedPath}`); return response.data; } catch (error) { if (error.response?.status === 404) { throw new Error(`Item not found: ${itemPath}`); } throw error; } } /** * List items in a folder */ async listFolderItems(folderPath = '') { try { let endpoint; if (folderPath) { const encodedPath = encodeURIComponent(folderPath); endpoint = `/sites/${this.siteId}/drives/${this.driveId}/root:/${encodedPath}:/children`; } else { endpoint = `/sites/${this.siteId}/drives/${this.driveId}/root/children`; } const response = await this.axiosClient.get(endpoint); return response.data.value || []; } catch (error) { console.error(`āŒ Failed to list folder items for ${folderPath}:`, error.message); throw error; } } /** * Delete a drive item */ async deleteItem(itemId) { try { await this.axiosClient.delete(`/sites/${this.siteId}/drives/${this.driveId}/items/${itemId}`); console.log(`šŸ—‘ļø Deleted item: ${itemId}`); } catch (error) { console.error(`āŒ Failed to delete item ${itemId}:`, error.message); throw error; } } /** * Get access token for authentication */ async getAccessToken() { if (this.oauth2Handler) { return await this.oauth2Handler.getValidAccessToken(); } // For service principal authentication if (this.config.authMethod === 'service-principal') { return await this.getServicePrincipalToken(); } throw new Error('No authentication method configured'); } /** * Get service principal access token */ async getServicePrincipalToken() { try { const tokenEndpoint = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`; const params = new URLSearchParams({ client_id: this.config.clientId, client_secret: this.config.clientSecret, scope: 'https://graph.microsoft.com/.default', grant_type: 'client_credentials' }); const response = await axios.post(tokenEndpoint, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); return response.data.access_token; } catch (error) { console.error('āŒ Failed to get service principal token:', error.message); throw error; } } /** * Resolve SharePoint site ID from site URL */ async resolveSiteId() { try { // Extract hostname and site path from URL const url = new URL(this.config.siteUrl); const hostname = url.hostname; const sitePath = url.pathname; let endpoint; if (sitePath === '/' || sitePath === '') { // Root site endpoint = `/sites/${hostname}`; } else { // Site collection const siteRelativeUrl = sitePath.replace(/^\//, '').replace(/\/$/, ''); endpoint = `/sites/${hostname}:/${siteRelativeUrl}`; } const response = await this.axiosClient.get(endpoint); this.siteId = response.data.id; console.log(`āœ… Resolved site ID: ${this.siteId}`); } catch (error) { console.error('āŒ Failed to resolve site ID:', error.message); throw new Error(`Cannot resolve SharePoint site: ${this.config.siteUrl}`); } } /** * Resolve drive ID for the document library */ async resolveDriveId() { try { // Get all drives for the site const response = await this.axiosClient.get(`/sites/${this.siteId}/drives`); const drives = response.data.value; // Find the drive matching our document library name const targetDrive = drives.find((drive) => drive.name === this.config.documentLibrary || drive.name === `${this.config.documentLibrary} Documents` || drive.webUrl.includes(this.config.documentLibrary.replace(' ', '%20'))); if (!targetDrive) { // If not found, use the default drive const defaultDriveResponse = await this.axiosClient.get(`/sites/${this.siteId}/drive`); this.driveId = defaultDriveResponse.data.id; console.log(`āš ļø Document library '${this.config.documentLibrary}' not found, using default drive`); } else { this.driveId = targetDrive.id; console.log(`āœ… Found document library: ${targetDrive.name}`); } } catch (error) { console.error('āŒ Failed to resolve drive ID:', error.message); throw error; } } /** * Upload file to SharePoint */ async uploadFile(fileName, content, folderPath, overwrite = true) { try { let uploadPath = fileName; if (folderPath) { uploadPath = `${folderPath}/${fileName}`; } const fileSize = Buffer.isBuffer(content) ? content.length : Buffer.byteLength(content, 'utf8'); // Use simple upload for files < 4MB, session upload for larger files if (fileSize < 4 * 1024 * 1024) { return await this.simpleUpload(uploadPath, content, overwrite); } else { return await this.sessionUpload(uploadPath, content, overwrite); } } catch (error) { console.error(`āŒ Upload failed for ${fileName}:`, error.message); throw error; } } /** * Simple upload for smaller files */ async simpleUpload(uploadPath, content, overwrite) { const encodedPath = encodeURIComponent(uploadPath); const conflictBehavior = overwrite ? 'replace' : 'rename'; const endpoint = `/sites/${this.siteId}/drives/${this.driveId}/root:/${encodedPath}:/content?@microsoft.graph.conflictBehavior=${conflictBehavior}`; const response = await this.axiosClient.put(endpoint, content, { headers: { 'Content-Type': 'application/octet-stream' } }); return response.data; } /** * Session upload for larger files */ async sessionUpload(uploadPath, content, overwrite) { // Create upload session const encodedPath = encodeURIComponent(uploadPath); const sessionEndpoint = `/sites/${this.siteId}/drives/${this.driveId}/root:/${encodedPath}:/createUploadSession`; const sessionData = { item: { '@microsoft.graph.conflictBehavior': overwrite ? 'replace' : 'rename' } }; const sessionResponse = await this.axiosClient.post(sessionEndpoint, sessionData); const uploadUrl = sessionResponse.data.uploadUrl; // Upload content in chunks const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); const chunkSize = 320 * 1024; // 320KB chunks const totalSize = buffer.length; for (let start = 0; start < totalSize; start += chunkSize) { const end = Math.min(start + chunkSize, totalSize); const chunk = buffer.slice(start, end); const headers = { 'Content-Range': `bytes ${start}-${end - 1}/${totalSize}`, 'Content-Length': chunk.length.toString() }; const chunkResponse = await axios.put(uploadUrl, chunk, { headers }); if (chunkResponse.status === 201 || chunkResponse.status === 200) { return chunkResponse.data; } } throw new Error('Upload session completed but no final response received'); } /** * Add metadata to a SharePoint item */ async addMetadata(itemId, metadata) { try { const listItemEndpoint = `/sites/${this.siteId}/drives/${this.driveId}/items/${itemId}/listItem`; const updateData = { fields: metadata }; await this.axiosClient.patch(listItemEndpoint, updateData); console.log(`āœ… Added metadata to item: ${itemId}`); } catch (error) { console.warn(`āš ļø Failed to add metadata to ${itemId}:`, error.message); // Don't throw error as upload was successful } } /** * Ensure folder exists, create if necessary */ async ensureFolderExists(folderPath) { try { await this.getDriveItem(folderPath); console.log(`šŸ“ Folder exists: ${folderPath}`); } catch (error) { if (error.message.includes('not found')) { // Create folder structure recursively const pathParts = folderPath.split('/').filter(part => part); let currentPath = ''; for (const part of pathParts) { const parentPath = currentPath; currentPath = currentPath ? `${currentPath}/${part}` : part; try { await this.getDriveItem(currentPath); } catch (error) { await this.createFolder(currentPath); } } } else { throw error; } } } /** * Build complete folder path */ buildFolderPath(documentFolderPath, parentFolderPath) { const parts = []; if (this.config.rootFolderPath) { parts.push(this.config.rootFolderPath); } if (parentFolderPath) { parts.push(parentFolderPath); } if (documentFolderPath) { parts.push(documentFolderPath); } return parts.join('/'); } /** * Prepare file content for upload */ prepareFileContent(document) { // For now, keep as markdown. Future enhancement: convert to Word document return Buffer.from(document.content, 'utf8'); } /** * Get all markdown files from directory recursively */ getMarkdownFiles(dirPath) { const files = []; const fs = require('fs'); const walk = (currentPath) => { const items = fs.readdirSync(currentPath); for (const item of items) { const fullPath = join(currentPath, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { walk(fullPath); } else if (item.endsWith('.md')) { files.push(fullPath); } } }; walk(dirPath); return files; } /** * Get folder path from file path relative to base directory */ getFolderPathFromFile(filePath, baseDir) { const relativePath = filePath.replace(baseDir, '').replace(/\\/g, '/'); const folderPath = dirname(relativePath); return folderPath === '.' || folderPath === '/' ? '' : folderPath.replace(/^\//, ''); } /** * Extract title from markdown content */ extractTitleFromContent(content) { const titleMatch = content.match(/^#\s+(.+)$/m); return titleMatch ? titleMatch[1].trim() : null; } /** * Generate SharePoint metadata from document content */ generateMetadata(content, fileName) { const metadata = { Title: this.extractTitleFromContent(content) || fileName.replace('.md', ''), ContentType: 'Document', DocumentCategory: this.inferDocumentCategory(fileName), DocumentVersion: '1.0', Generated: 'ADPA v2.1.3' }; // Extract PMBOK references if present const pmbokMatch = content.match(/PMBOK[:\s]+([^\n]+)/i); if (pmbokMatch) { metadata.PMBOKReference = pmbokMatch[1].trim(); } return metadata; } /** * Infer document category from file name/path */ inferDocumentCategory(fileName) { const lowerName = fileName.toLowerCase(); if (lowerName.includes('charter')) return 'Project Charter'; if (lowerName.includes('scope')) return 'Scope Management'; if (lowerName.includes('risk')) return 'Risk Management'; if (lowerName.includes('quality')) return 'Quality Management'; if (lowerName.includes('stakeholder')) return 'Stakeholder Management'; if (lowerName.includes('communication')) return 'Communication Management'; if (lowerName.includes('procurement')) return 'Procurement Management'; if (lowerName.includes('schedule')) return 'Schedule Management'; if (lowerName.includes('cost')) return 'Cost Management'; if (lowerName.includes('resource')) return 'Resource Management'; if (lowerName.includes('integration')) return 'Integration Management'; if (lowerName.includes('technical')) return 'Technical Analysis'; if (lowerName.includes('architecture')) return 'Technical Design'; return 'General Documentation'; } /** * Add delay between operations */ delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } /** * Factory function to create SharePointPublisher instance */ export function createSharePointPublisher(config) { return new SharePointPublisher(config); } /** * Validate SharePoint configuration */ export function validateSharePointConfig(config) { const errors = []; // Required fields if (!config.tenantId) errors.push('tenantId is required'); if (!config.clientId) errors.push('clientId is required'); if (!config.siteUrl) errors.push('siteUrl is required'); if (!config.documentLibrary) errors.push('documentLibrary is required'); // Auth method validation if (!['oauth2', 'service-principal', 'certificate'].includes(config.authMethod)) { errors.push('authMethod must be oauth2, service-principal, or certificate'); } // OAuth2 specific validation if (config.authMethod === 'oauth2') { if (!config.oauth2) { errors.push('oauth2 configuration is required when authMethod is oauth2'); } else { if (!config.oauth2.redirectUri) errors.push('oauth2.redirectUri is required'); if (!config.oauth2.scopes || config.oauth2.scopes.length === 0) { errors.push('oauth2.scopes array is required and must not be empty'); } } } // Service Principal specific validation if (config.authMethod === 'service-principal') { if (!config.clientSecret) errors.push('clientSecret is required for service principal auth'); } // Certificate specific validation if (config.authMethod === 'certificate') { if (!config.certificatePath) errors.push('certificatePath is required for certificate auth'); } // URL validation try { new URL(config.siteUrl); } catch { errors.push('siteUrl must be a valid URL'); } return { valid: errors.length === 0, errors }; } //# sourceMappingURL=SharePointPublisher.js.map