Kraft102's picture
Update backend source
34367da verified
/**
* 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;