Spaces:
Paused
Paused
| /** | |
| * 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<string> { | |
| // 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<string> { | |
| 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<string> { | |
| 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<T>( | |
| endpoint: string, | |
| options: RequestInit = {}, | |
| useV4: boolean = true | |
| ): Promise<T> { | |
| 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<ShowpadAsset[]> { | |
| // 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<void> { | |
| 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<IngestedEntity[]> { | |
| 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<boolean> { | |
| 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<Buffer> { | |
| 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<string, number> } { | |
| const categories: Record<string, number> = {}; | |
| 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; | |