/** * SHOWPAD ADAPTER - TDC Enterprise Content Integration * * Connects to Showpad API v4 to sync brand assets, presentations, * and marketing materials. Extracts images for reuse in presentations. * * Authentication: OAuth2 or Username/Password * Storage: Neo4j Graph Database (cloud) * * Created: 2025-12-16 */ import { DataSourceAdapter, IngestedEntity } from '../ingestion/DataIngestionEngine.js'; interface ShowpadConfig { subdomain: string; baseUrl: string; apiVersion: string; username?: string; password?: string; clientId?: string; clientSecret?: string; cacheTtl: number; } interface ShowpadAsset { id: string; name: string; description?: string; resourcetype: string; slug?: string; createdAt: string; updatedAt: string; fileSize?: number; mimeType?: string; downloadUrl?: string; previewUrl?: string; thumbnailUrl?: string; tags?: string[]; divisions?: string[]; isImage?: boolean; } interface ShowpadAuthResponse { access_token: string; token_type: string; expires_in: number; refresh_token?: string; scope?: string; } interface ExtractedImage { assetId: string; name: string; originalUrl: string; thumbnailUrl?: string; previewUrl?: string; mimeType: string; width?: number; height?: number; fileSize?: number; tags: string[]; category?: string; extractedAt: Date; } export class ShowpadAdapter implements DataSourceAdapter { name = 'Showpad'; type: 'other' = 'other'; private config: ShowpadConfig; private accessToken: string | null = null; private tokenExpiry: number = 0; private assets: ShowpadAsset[] = []; private extractedImages: ExtractedImage[] = []; private lastSync: number = 0; constructor() { this.config = { subdomain: process.env.SHOWPAD_SUBDOMAIN || 'tdcerhverv', baseUrl: process.env.SHOWPAD_BASE_URL || 'https://tdcerhverv.showpad.biz', apiVersion: process.env.SHOWPAD_API_VERSION || 'v4', username: process.env.SHOWPAD_USERNAME, password: process.env.SHOWPAD_PASSWORD, clientId: process.env.SHOWPAD_CLIENT_ID, clientSecret: process.env.SHOWPAD_CLIENT_SECRET, cacheTtl: parseInt(process.env.SHOWPAD_CACHE_TTL || '86400000', 10) }; } /** * Get API base URL */ private get apiUrl(): string { return `https://${this.config.subdomain}.api.showpad.com/${this.config.apiVersion}`; } /** * Get legacy API URL (for some endpoints) */ private get legacyApiUrl(): string { return `${this.config.baseUrl}/api/v3`; } /** * Authenticate with Showpad */ private async authenticate(): Promise { // Return cached token if still valid if (this.accessToken && Date.now() < this.tokenExpiry - 60000) { return this.accessToken; } console.log('[ShowpadAdapter] Authenticating...'); // Try OAuth2 client credentials first if (this.config.clientId && this.config.clientSecret) { return this.authenticateOAuth(); } // Fall back to username/password if (this.config.username && this.config.password) { return this.authenticateUserPassword(); } throw new Error('[ShowpadAdapter] No authentication credentials configured'); } /** * OAuth2 Client Credentials Flow */ private async authenticateOAuth(): Promise { const tokenUrl = `${this.legacyApiUrl}/oauth2/token`; const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: this.config.clientId!, client_secret: this.config.clientSecret! }); const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); if (!response.ok) { const error = await response.text(); throw new Error(`[ShowpadAdapter] OAuth failed: ${response.status} - ${error}`); } const data: ShowpadAuthResponse = await response.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + (data.expires_in * 1000); console.log('[ShowpadAdapter] OAuth2 authentication successful'); return this.accessToken; } /** * Username/Password Authentication (User Credentials Flow) */ private async authenticateUserPassword(): Promise { const tokenUrl = `${this.legacyApiUrl}/oauth2/token`; const params = new URLSearchParams({ grant_type: 'password', username: this.config.username!, password: this.config.password! }); // Add client credentials if available if (this.config.clientId && this.config.clientSecret) { params.append('client_id', this.config.clientId); params.append('client_secret', this.config.clientSecret); } const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }); if (!response.ok) { const error = await response.text(); throw new Error(`[ShowpadAdapter] Auth failed: ${response.status} - ${error}`); } const data: ShowpadAuthResponse = await response.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + (data.expires_in * 1000); console.log('[ShowpadAdapter] User/password authentication successful'); return this.accessToken; } /** * Make authenticated API request */ private async apiRequest( endpoint: string, options: RequestInit = {}, useV4: boolean = true ): Promise { const token = await this.authenticate(); const baseUrl = useV4 ? this.apiUrl : this.legacyApiUrl; const url = `${baseUrl}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Accept': 'application/json', ...options.headers } }); if (!response.ok) { const error = await response.text(); throw new Error(`[ShowpadAdapter] API error: ${response.status} - ${error}`); } return response.json(); } /** * Fetch all assets from Showpad */ async fetch(): Promise { // Return cached if fresh if (this.assets.length > 0 && Date.now() - this.lastSync < this.config.cacheTtl) { console.log(`[ShowpadAdapter] Using cached assets (${this.assets.length} items)`); return this.assets; } console.log('[ShowpadAdapter] Fetching assets from Showpad...'); const allAssets: ShowpadAsset[] = []; let offset = 0; const limit = 100; let hasMore = true; while (hasMore) { try { const response = await this.apiRequest<{ items: ShowpadAsset[]; meta?: { total?: number } }>( `/assets?limit=${limit}&offset=${offset}`, {}, true ); const items = response.items || []; allAssets.push(...items); console.log(`[ShowpadAdapter] Fetched ${items.length} assets (total: ${allAssets.length})`); hasMore = items.length === limit; offset += limit; // Rate limiting - be nice to the API await new Promise(resolve => setTimeout(resolve, 200)); } catch (error) { console.error('[ShowpadAdapter] Error fetching assets:', error); hasMore = false; } } this.assets = allAssets; this.lastSync = Date.now(); // Extract images await this.extractImages(); console.log(`[ShowpadAdapter] Total assets: ${this.assets.length}, Images: ${this.extractedImages.length}`); return this.assets; } /** * Extract images from assets for reuse */ private async extractImages(): Promise { this.extractedImages = []; const imageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']; const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']; for (const asset of this.assets) { const isImage = (asset.mimeType && imageTypes.includes(asset.mimeType)) || (asset.name && imageExtensions.some(ext => asset.name.toLowerCase().endsWith(ext))) || asset.resourcetype === 'image'; if (isImage) { this.extractedImages.push({ assetId: asset.id, name: asset.name, originalUrl: asset.downloadUrl || '', thumbnailUrl: asset.thumbnailUrl, previewUrl: asset.previewUrl, mimeType: asset.mimeType || 'image/unknown', fileSize: asset.fileSize, tags: asset.tags || [], category: this.categorizeImage(asset), extractedAt: new Date() }); } } console.log(`[ShowpadAdapter] Extracted ${this.extractedImages.length} images for reuse`); } /** * Categorize image based on name and tags */ private categorizeImage(asset: ShowpadAsset): string { const name = asset.name.toLowerCase(); const tags = (asset.tags || []).map(t => t.toLowerCase()); if (name.includes('logo') || tags.includes('logo')) return 'logo'; if (name.includes('icon') || tags.includes('icon')) return 'icon'; if (name.includes('banner') || tags.includes('banner')) return 'banner'; if (name.includes('photo') || tags.includes('photo')) return 'photo'; if (name.includes('diagram') || tags.includes('diagram')) return 'diagram'; if (name.includes('chart') || tags.includes('chart')) return 'chart'; if (name.includes('infographic') || tags.includes('infographic')) return 'infographic'; if (name.includes('background') || tags.includes('background')) return 'background'; return 'general'; } /** * Transform assets to ingestion entities */ async transform(raw: ShowpadAsset[]): Promise { return raw.map(asset => ({ id: `showpad-${asset.id}`, type: 'asset', source: 'Showpad', title: asset.name, content: asset.description || asset.name, metadata: { provider: 'Showpad', assetId: asset.id, resourceType: asset.resourcetype, mimeType: asset.mimeType, fileSize: asset.fileSize, downloadUrl: asset.downloadUrl, previewUrl: asset.previewUrl, thumbnailUrl: asset.thumbnailUrl, tags: asset.tags, divisions: asset.divisions, isImage: this.extractedImages.some(img => img.assetId === asset.id), updatedAt: asset.updatedAt }, timestamp: new Date(asset.updatedAt || asset.createdAt) })); } /** * Check if Showpad is configured and reachable */ async isAvailable(): Promise { try { // Check if we have credentials const hasOAuth = !!(this.config.clientId && this.config.clientSecret); const hasUserPass = !!(this.config.username && this.config.password); if (!hasOAuth && !hasUserPass) { console.warn('[ShowpadAdapter] No credentials configured'); return false; } // Try to authenticate await this.authenticate(); return true; } catch (error) { console.error('[ShowpadAdapter] Not available:', error); return false; } } /** * Get extracted images for presentation use */ getExtractedImages(): ExtractedImage[] { return this.extractedImages; } /** * Get images by category */ getImagesByCategory(category: string): ExtractedImage[] { return this.extractedImages.filter(img => img.category === category); } /** * Search images by tag or name */ searchImages(query: string): ExtractedImage[] { const q = query.toLowerCase(); return this.extractedImages.filter(img => img.name.toLowerCase().includes(q) || img.tags.some(tag => tag.toLowerCase().includes(q)) ); } /** * Download asset binary */ async downloadAsset(assetId: string): Promise { const asset = this.assets.find(a => a.id === assetId); if (!asset || !asset.downloadUrl) { throw new Error(`[ShowpadAdapter] Asset not found or no download URL: ${assetId}`); } const token = await this.authenticate(); const response = await fetch(asset.downloadUrl, { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error(`[ShowpadAdapter] Download failed: ${response.status}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } /** * Get sync status */ getStatus(): { lastSync: number; assetCount: number; imageCount: number; categories: Record } { const categories: Record = {}; for (const img of this.extractedImages) { categories[img.category || 'unknown'] = (categories[img.category || 'unknown'] || 0) + 1; } return { lastSync: this.lastSync, assetCount: this.assets.length, imageCount: this.extractedImages.length, categories }; } } // Singleton instance export const showpadAdapter = new ShowpadAdapter(); export default showpadAdapter;