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

676 lines 26.3 kB
/** * Confluence Publisher Module * Part of ADPA (Automated Document Processing Assistant) v2.1.3 * * Handles publishing generated documents to Confluence Cloud * Features: * - API-based publishing to Confluence spaces * - OAuth 2.0 (3LO) and API token authentication * - Automatic page creation and updates * - Hierarchical document organization * - Metadata preservation and tagging * - Error handling and retry logic */ import axios from 'axios'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; import { ConfluenceOAuth2 } from './ConfluenceOAuth2.js'; /** * Main Confluence Publisher class * Handles all interactions with Confluence Cloud API */ export class ConfluencePublisher { client = axios.create(); // Initialize with default client config; oauth2Handler; constructor(config) { this.config = config; if (config.authMethod === 'oauth2') { if (!config.oauth2) { throw new Error('OAuth2 configuration is required when authMethod is oauth2'); } this.oauth2Handler = new ConfluenceOAuth2(config.oauth2); // Initialize client asynchronously - will be set up during first API call this.client = this.createPlaceholderClient(); } else { this.initializeApiTokenClient(); } } /** * Create a placeholder client for OAuth2 initialization */ createPlaceholderClient() { return axios.create({ baseURL: 'https://api.atlassian.com', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); } /** * Initialize or refresh OAuth2 client */ async ensureOAuth2Client() { if (!this.oauth2Handler) { throw new Error('OAuth2 handler not initialized'); } // Check if we need to authenticate if (!this.oauth2Handler.isAuthorized()) { throw new Error('OAuth2 authentication required. Please run the OAuth2 login flow first.'); } // Get valid access token (handles refresh automatically) const accessToken = await this.oauth2Handler.getValidAccessToken(); console.log('🔍 Debug - OAuth2 Token:'); console.log(' Token length:', accessToken?.length || 'undefined'); console.log(' Token start:', accessToken?.substring(0, 20) || 'undefined'); // Get accessible resources to find cloud ID if not set if (!this.config.cloudId) { const resources = await this.oauth2Handler.getAccessibleResources(); const confluenceResource = resources.find(r => r.scopes.some(scope => scope.includes(':confluence')) || // Check for any confluence-related scope r.scopes.includes('read:confluence-content.all') || r.scopes.includes('read:confluence-space.summary') // Check for classic scopes ); if (!confluenceResource) { throw new Error('No Confluence resources found. Please check your OAuth2 permissions and ensure your app is configured with Confluence API scopes.'); } this.config.cloudId = confluenceResource.id; } // Determine API endpoint based on scopes const hasGranularScopes = this.config.oauth2?.scopes?.includes('read:content:confluence') || this.config.oauth2?.scopes?.includes('write:content:confluence'); const baseURL = hasGranularScopes ? `https://api.atlassian.com/ex/confluence/${this.config.cloudId}/v2` // New API v2 for granular scopes : `${this.config.baseUrl}/wiki/rest/api`; // Classic API for classic scopes // Create or update authenticated Axios client using OAuth 2.0 Bearer token this.client = axios.create({ baseURL, headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Atlassian-Token': 'nocheck' // Add this header which might be required }, timeout: 30000 }); } /** * Initialize client for API token authentication (legacy) */ initializeApiTokenClient() { if (!this.config.baseUrl || !this.config.email || !this.config.apiToken) { throw new Error('baseUrl, email, and apiToken are required for API token authentication'); } const baseURL = this.config.cloudId && this.config.baseUrl === 'https://api.atlassian.com' ? `https://api.atlassian.com/ex/confluence/${this.config.cloudId}/rest/api` : `${this.config.baseUrl}/wiki/rest/api`; this.client = axios.create({ baseURL, auth: { username: this.config.email, password: this.config.apiToken }, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); } /** * Ensure client is properly configured before making API calls */ async ensureClient() { if (this.config.authMethod === 'oauth2') { await this.ensureOAuth2Client(); } // API token client is initialized in constructor } /** * Start OAuth2 authentication flow (for OAuth2 method) * @returns Promise with OAuth2 tokens */ async startOAuth2Login() { if (this.config.authMethod !== 'oauth2' || !this.oauth2Handler) { return { success: false, error: 'OAuth2 authentication is not configured' }; } try { const tokens = await this.oauth2Handler.startAuthorizationFlow(); return { success: true, tokens }; } catch (error) { return { success: false, error: error.message }; } } /** * Check if OAuth2 is authorized * @returns Boolean indicating authorization status */ isOAuth2Authorized() { if (this.config.authMethod !== 'oauth2' || !this.oauth2Handler) { return false; } return this.oauth2Handler.isAuthorized(); } /** * Test connectivity to Confluence API * @returns Promise with connection status */ async testConnection() { try { // Ensure client is properly configured await this.ensureClient(); console.log('🔍 Debug - Configuration:'); console.log(' Auth Method:', this.config.authMethod); console.log(' Cloud ID:', this.config.cloudId); console.log(' Base URL:', this.client.defaults.baseURL); const authHeader = this.client.defaults.headers['Authorization'] || this.client.defaults.headers.common['Authorization']; console.log(' Authorization Header:', typeof authHeader === 'string' ? authHeader.substring(0, 20) + '...' : authHeader); console.log(' All Headers:', JSON.stringify(this.client.defaults.headers, null, 2)); // Test connection with spaces list endpoint to avoid specific space requirements const response = await this.client.get('/space', { params: { limit: 10 } }); return { success: true, userInfo: { availableSpaces: response.data.results?.length || 0, spaces: response.data.results?.map((space) => ({ key: space.key, name: space.name, type: space.type })) || [], configuredSpace: this.config.spaceKey }, cloudId: this.config.cloudId }; } catch (error) { console.log('🔍 Debug - Error details:'); console.log(' Status:', error.response?.status); console.log(' Message:', error.response?.data?.message || error.message); console.log(' Data:', JSON.stringify(error.response?.data, null, 2)); return { success: false, error: error.response?.data?.message || error.message }; } } /** * Get space information * @param spaceKey Space key to query * @returns Space information or null if not found */ async getSpace(spaceKey) { try { await this.ensureClient(); const key = spaceKey || this.config.spaceKey; const response = await this.client.get(`/space/${key}`); return response.data; } catch (error) { if (error.response?.status === 404) { return null; } throw error; } } /** * Create a new page in Confluence * @param page Page data to create * @returns Created page information */ async createPage(page) { try { await this.ensureClient(); const pageData = { type: 'page', title: page.title, space: { key: page.spaceKey || this.config.spaceKey }, body: { storage: { value: this.convertMarkdownToConfluence(page.content), representation: 'storage' } }, ...(page.parentId && { ancestors: [{ id: page.parentId }] }) }; const response = await this.client.post('/content', pageData); const createdPage = response.data; // Add labels if provided if (page.labels && page.labels.length > 0) { await this.addLabelsToPage(createdPage.id, page.labels); } // Generate page URL based on authentication method const pageUrl = this.generatePageUrl(createdPage._links.webui); return { success: true, pageId: createdPage.id, pageUrl, title: page.title }; } catch (error) { return { success: false, error: error.response?.data?.message || error.message, title: page.title }; } } /** * Update an existing page in Confluence * @param pageId Page ID to update * @param page Updated page data * @returns Update result */ async updatePage(pageId, page) { try { await this.ensureClient(); // Get current page version const currentPage = await this.client.get(`/content/${pageId}`); const currentVersion = currentPage.data.version.number; const pageData = { type: 'page', title: page.title, body: { storage: { value: this.convertMarkdownToConfluence(page.content), representation: 'storage' } }, version: { number: currentVersion + 1 } }; const response = await this.client.put(`/content/${pageId}`, pageData); const updatedPage = response.data; // Update labels if provided if (page.labels && page.labels.length > 0) { await this.replaceLabelsOnPage(pageId, page.labels); } // Generate page URL based on authentication method const pageUrl = this.generatePageUrl(updatedPage._links.webui); return { success: true, pageId: updatedPage.id, pageUrl, title: page.title }; } catch (error) { return { success: false, error: error.response?.data?.message || error.message, title: page.title }; } } /** * Find page by title in the configured space * @param title Page title to search for * @returns Page data or null if not found */ async findPageByTitle(title) { try { await this.ensureClient(); const response = await this.client.get('/content', { params: { title: title, spaceKey: this.config.spaceKey, expand: 'version' } }); const results = response.data.results; return results.length > 0 ? results[0] : null; } catch (error) { console.error('Error finding page:', error.message); return null; } } /** * Publish or update a page (create if doesn't exist, update if it does) * @param page Page data to publish * @returns Publish result */ async publishPage(page) { try { // Check if page already exists const existingPage = await this.findPageByTitle(page.title); if (existingPage) { // Update existing page return await this.updatePage(existingPage.id, page); } else { // Create new page return await this.createPage(page); } } catch (error) { return { success: false, error: error.message, title: page.title }; } } /** * Publish multiple documents from a directory * @param documentsPath Path to generated documents * @param options Publishing options * @returns Array of publish results */ async publishDocuments(documentsPath, options = {}) { const results = []; if (!existsSync(documentsPath)) { throw new Error(`Documents path does not exist: ${documentsPath}`); } // Get parent page ID if specified let parentPageId; if (options.parentPageTitle) { const parentPage = await this.findPageByTitle(options.parentPageTitle); parentPageId = parentPage?.id; } // Process markdown files in the directory const fs = require('fs'); const files = fs.readdirSync(documentsPath); for (const file of files) { if (file.endsWith('.md')) { const filePath = join(documentsPath, file); const content = readFileSync(filePath, 'utf-8'); // Extract title from filename (remove .md extension) const title = file.replace('.md', '').replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); // Prepare labels const labels = []; if (options.labelPrefix) { labels.push(`${options.labelPrefix}-generated`); } labels.push('adpa-generated', 'auto-generated'); const page = { title: title, content: content, spaceKey: this.config.spaceKey, parentId: parentPageId, labels: labels }; const result = await this.publishPage(page); results.push(result); // Add delay between requests to avoid rate limiting await this.delay(1000); } } return results; } /** * Add labels to a page * @param pageId Page ID * @param labels Array of label names */ async addLabelsToPage(pageId, labels) { try { for (const label of labels) { await this.client.post(`/content/${pageId}/label`, { name: label }); } } catch (error) { console.warn('Error adding labels:', error.message); } } /** * Replace all labels on a page * @param pageId Page ID * @param labels New labels array */ async replaceLabelsOnPage(pageId, labels) { try { // Get current labels const currentLabels = await this.client.get(`/content/${pageId}/label`); // Remove existing labels for (const label of currentLabels.data.results) { await this.client.delete(`/content/${pageId}/label/${label.name}`); } // Add new labels await this.addLabelsToPage(pageId, labels); } catch (error) { console.warn('Error replacing labels:', error.message); } } /** * Convert Markdown content to Confluence Storage Format * Basic conversion - can be enhanced for more complex formatting * @param markdown Markdown content * @returns Confluence storage format HTML */ convertMarkdownToConfluence(markdown) { // Basic markdown to Confluence conversion let confluence = markdown; // Headers confluence = confluence.replace(/^### (.*$)/gm, '<h3>$1</h3>'); confluence = confluence.replace(/^## (.*$)/gm, '<h2>$1</h2>'); confluence = confluence.replace(/^# (.*$)/gm, '<h1>$1</h1>'); // Bold and italic confluence = confluence.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); confluence = confluence.replace(/\*(.*?)\*/g, '<em>$1</em>'); // Code blocks confluence = confluence.replace(/```([\s\S]*?)```/g, '<ac:structured-macro ac:name="code"><ac:plain-text-body><![CDATA[$1]]></ac:plain-text-body></ac:structured-macro>'); // Inline code confluence = confluence.replace(/`([^`]+)`/g, '<code>$1</code>'); // Lists confluence = confluence.replace(/^\* (.*$)/gm, '<li>$1</li>'); confluence = confluence.replace(/^- (.*$)/gm, '<li>$1</li>'); confluence = confluence.replace(/^(\d+)\. (.*$)/gm, '<li>$2</li>'); // Wrap lists in ul/ol tags confluence = confluence.replace(/(<li>.*?<\/li>)/gs, (match) => { return `<ul>${match}</ul>`; }); // Line breaks confluence = confluence.replace(/\n\n/g, '<br/><br/>'); confluence = confluence.replace(/\n/g, '<br/>'); return confluence; } /** * Utility method to add delays between API calls * @param ms Milliseconds to delay */ delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Generate page URL based on authentication method * @param webuiPath Web UI path from Confluence response * @returns Full page URL */ generatePageUrl(webuiPath) { if (this.config.authMethod === 'oauth2' && this.config.cloudId) { // For OAuth2, construct URL using cloud ID const baseUrl = `https://api.atlassian.com/ex/confluence/${this.config.cloudId}`; return `${baseUrl}${webuiPath}`.replace('/rest/api', ''); } else if (this.config.baseUrl) { // For API token, use the original base URL return `${this.config.baseUrl}/wiki${webuiPath}`; } else { // Fallback return webuiPath; } } /** * Get publishing statistics * @returns Statistics object */ async getPublishingStats() { try { await this.ensureClient(); const spaceInfo = await this.getSpace(); const response = await this.client.get('/content', { params: { spaceKey: this.config.spaceKey, limit: 1000 } }); return { totalPages: response.data.size, spaceInfo: spaceInfo, lastUpdated: new Date().toISOString() }; } catch (error) { throw new Error(`Failed to get publishing stats: ${error.message}`); } } /** * Detect if we need Cloud API and try to get Cloud ID automatically (legacy API token method) * @returns Promise with detection result */ async detectAndConfigureCloudApi() { try { // Only works for API token authentication if (this.config.authMethod !== 'api-token' || !this.config.baseUrl || !this.config.email || !this.config.apiToken) { return { success: false, error: 'Auto-detection only works with API token authentication' }; } // First, try the traditional domain-based API const response = await this.client.get('/user/current'); if (response.status === 200) { return { success: true }; } // If traditional API fails, try to get Cloud ID if (this.config.baseUrl.includes('.atlassian.net')) { // Extract domain from baseUrl const domain = this.config.baseUrl.replace('https://', '').replace('.atlassian.net', ''); // Try to get accessible resources using basic auth try { const response = await axios.get('https://api.atlassian.com/oauth/token/accessible-resources', { auth: { username: this.config.email, password: this.config.apiToken }, headers: { 'Accept': 'application/json' } }); // Find the matching site const sites = response.data; const matchingSite = sites.find((site) => site.url.includes(domain) || site.name.toLowerCase().includes(domain.toLowerCase())); if (matchingSite) { // Update configuration to use Cloud API this.config.cloudId = matchingSite.id; // Recreate client with Cloud API URL this.client = axios.create({ baseURL: `https://api.atlassian.com/ex/confluence/${matchingSite.id}/rest/api`, auth: { username: this.config.email, password: this.config.apiToken }, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 30000 }); return { success: true, cloudId: matchingSite.id }; } } catch (cloudError) { // OAuth endpoint might not work with basic auth return { success: false, error: `Cloud ID detection failed: ${cloudError.message}. You may need to configure OAuth 2.0 or provide the Cloud ID manually.` }; } } return { success: false, error: 'Could not detect Cloud ID automatically. Please configure OAuth 2.0 or provide Cloud ID manually.' }; } catch (error) { return { success: false, error: error.message }; } } } /** * Factory function to create ConfluencePublisher instance * @param config Confluence configuration * @returns ConfluencePublisher instance */ export function createConfluencePublisher(config) { return new ConfluencePublisher(config); } /** * Utility function to validate Confluence configuration * @param config Configuration to validate * @returns Validation result */ export function validateConfluenceConfig(config) { const errors = []; if (!config.authMethod) { errors.push('authMethod is required (oauth2 or api-token)'); } else if (!['oauth2', 'api-token'].includes(config.authMethod)) { errors.push('authMethod must be "oauth2" or "api-token"'); } if (!config.spaceKey) { errors.push('spaceKey is required'); } if (config.authMethod === 'oauth2') { if (!config.oauth2) { errors.push('oauth2 configuration is required when authMethod is oauth2'); } else { if (!config.oauth2.clientId) { errors.push('oauth2.clientId is required'); } if (!config.oauth2.clientSecret) { errors.push('oauth2.clientSecret is required'); } if (!config.oauth2.redirectUri) { errors.push('oauth2.redirectUri is required'); } if (!config.oauth2.scopes || config.oauth2.scopes.length === 0) { errors.push('oauth2.scopes is required'); } } } else if (config.authMethod === 'api-token') { if (!config.baseUrl) { errors.push('baseUrl is required for API token authentication'); } else if (!config.baseUrl.startsWith('https://')) { errors.push('baseUrl must be a valid HTTPS URL'); } if (!config.email) { errors.push('email is required for API token authentication'); } else if (!config.email.includes('@')) { errors.push('email must be a valid email address'); } if (!config.apiToken) { errors.push('apiToken is required for API token authentication'); } } return { valid: errors.length === 0, errors }; } //# sourceMappingURL=ConfluencePublisher.js.map