diff --git a/src/connectors/kadanza/connector.ts b/src/connectors/kadanza/connector.ts new file mode 100644 index 0000000..55d0016 --- /dev/null +++ b/src/connectors/kadanza/connector.ts @@ -0,0 +1,365 @@ +import { Connector, Media } from '@chili-publish/studio-connectors'; + +interface DamMedia { + id: number; + name: string; + thumbnail: string; + format: string; + width: number; + height: number; + size: number; + assetHash: string; + tenantHash: string; + fileName: string; + title: string; + createdBy: { + id: number; + userName: string; + firstName: string; + lastName: string; + } +} + +interface DamMediaPage { + 'hydra:totalItems': number; + 'hydra:itemsPerPage': number; + 'hydra:currentPage': number; + 'hydra:totalPages': number; + 'hydra:member': Array; +} + +interface AssetId { + id: string; + name: string; + assetHash: string; + tenantHash: string; + thumbnail: string; + extension: string; +} + +interface DAMCustomMetadata { + id: number; + name: string; + label: string; + type: string; + defaultValue: string; + required: boolean; + default: boolean; + readOnly: boolean; + visible: boolean; + sorting: boolean; + sort: number; + dropdownOptions: object; + title: string; + text: string; + deletedAt: string; + filterable: boolean; + sortable: boolean; + attributeName: string; +} + +interface DAMCustomMetadataPage { + 'hydra:totalItems': number; + 'hydra:itemsPerPage': number; + 'hydra:currentPage': number; + 'hydra:totalPages': number; + 'hydra:member': Array; +} + +export default class DamConnector implements Media.MediaConnector { + runtime: Connector.ConnectorRuntimeContext; + + constructor(runtime: Connector.ConnectorRuntimeContext) { + this.runtime = runtime; + } + + async detail( + id: string, + context: Connector.Dictionary + ): Promise { + const assetId: AssetId = JSON.parse(id); + const damMedia = await this._getDamMediaById(assetId.id); + const metadata = await this._getCustomMetadata(); + + return this._getMediaDetailFromDamMedia(damMedia, metadata); + } + + async query( + options: Connector.QueryOptions, + context: Connector.Dictionary + ): Promise { + this._logError( + `query options: sortOrder ${options?.sortOrder} sortBy ${options?.sortBy} collection ${options?.collection} filter ${options?.filter} pageToken ${options?.pageToken} pageSize ${options?.pageSize}` + ); + + this._logError( + `context: categoryGroup ${context?.categoryGroup} category ${context?.category} searchQuery ${context?.searchQuery}` + ); + + const currentPage = Number(options.pageToken) || 1; + const pageSize = Number(options.pageSize) || 15; + this._logError( + `currentPage: ${currentPage} pageSize: ${pageSize}` + ); + let queryEndpoint = `${this._getBaseMediaUrl()}/api/assets?page=${currentPage}&pageSize=${pageSize}`; + + // Check if a specific categoryGroup is provided in the settings (context) + if (context?.categoryGroup) { + queryEndpoint += `&categoryGroup=${context.categoryGroup}`; + } + + // Check if a specific category is provided in the settings (context) + if (context?.category) { + queryEndpoint += `&category=${context.category}`; + } + + const filter = options?.filter + ? options?.filter + : undefined; + + let searchQuery = '&search=format:(png OR jpeg OR pdf OR mp4 OR gif)'; + if (filter && filter.length > 0) { + const stringifiedFilter = filter.toString().trim(); + + let id; + try { + id = JSON.parse(stringifiedFilter).id; + this._logError( + `ID ${id}` + ); + } catch (e) { + // filter is not JSON + } + + if (id) { + this._logError( + `Filtering query by _id: ${id}` + ); + + searchQuery += `AND _id: ${id}`; + } else if (stringifiedFilter && context?.searchQuery) { + this._logError( + `Filtering query by ${stringifiedFilter} in ${context.searchQuery}` + ); + + const searchInput = context.searchQuery.toString().replace('', stringifiedFilter); + searchQuery += `AND ${searchInput}`; + } + } + queryEndpoint += searchQuery; + + this._logError(`Query: endpoint ${encodeURI(queryEndpoint)}`); + + const result = await this.runtime.fetch(encodeURI(queryEndpoint), { + method: 'GET', + headers: this._getHeaders(), + }); + + if (result.status / 200 != 1) { + this._logError(`Query fetch failed.`); + throw new Error(`Query failed ${result.status} ${result.statusText}`); + } + + const assetsPage: DamMediaPage = JSON.parse(result.text); + const page = assetsPage['hydra:currentPage']; + + const metadata = await this._getCustomMetadata(); + + const nextPage = Number(assetsPage['hydra:currentPage']) < Number(assetsPage['hydra:totalPages']) ? Number(assetsPage['hydra:currentPage']) + 1 : ''; + this._logError(`nextPage: ${nextPage}`); + + return { + pageSize: pageSize, + data: assetsPage['hydra:member'].map((a: DamMedia) => + this._getMediaDetailFromDamMedia(a, metadata) + ), + links: { + nextPage: nextPage.toString(), + }, + }; + } + + async download( + id: string, + previewType: Media.DownloadType, + intent: Media.DownloadIntent, + context: Connector.Dictionary + ): Promise { + this._logError(`Download: id ${id}, previewType ${previewType}`); + + // Temporary commented until issue with >= 1 await statements is resolved + // const detail = await this._getDamMediaById(id); + + // Extract all details from stringified id + const detail: AssetId = JSON.parse(id); + + const baseUrl = this._getBaseMediaUrl(); + let downloadEndpoint = `${baseUrl}`; + const thumbnail = detail.thumbnail; + const original = `/${detail.tenantHash}/${detail.assetHash}/${encodeURIComponent(detail.name)}/original`; + const format = detail.extension.toLowerCase(); + + switch (previewType) { + case 'fullres': + case 'original': + downloadEndpoint += original; + break; + + case 'highres': + if (['png', 'jpeg'].includes(format)) { + downloadEndpoint += original; + } else { + downloadEndpoint += thumbnail; + } + break; + + case 'thumbnail': + case 'mediumres': + default: + if (!thumbnail) { + throw new Error(`Thumbnail not available for asset with id ${detail.id}`); + } + + downloadEndpoint += thumbnail; + break; + } + + this._logError(`Download: endpoint ${downloadEndpoint}`); + + const result = await this.runtime.fetch(downloadEndpoint, { + method: 'GET', + headers: this._getHeaders(), + }); + + this._logError( + `Download: result status ${result.status} ${result.statusText}.` + ); + + if (result.status / 200 != 1) { + this._logError( + `Download: fetch failed for media with id ${id} and previewType ${previewType}` + ); + throw new Error(`Download failed ${result.status} ${result.statusText}`); + } + + this._logError( + `Download: result array buffer id, bytes: ${ + (result.arrayBuffer.id, result.arrayBuffer.bytes) + }` + ); + + return result.arrayBuffer; + } + + getConfigurationOptions(): Connector.ConnectorConfigValue[] | null { + return [ + { + name: 'categoryGroup', + displayName: 'Category group (entrypoint) ID', + type: 'text', + }, + { + name: 'category', + displayName: 'Category ID', + type: 'text', + }, + { + name: 'searchQuery', + displayName: 'Search query', + type: 'text', + }, + ]; + } + + getCapabilities(): Media.MediaConnectorCapabilities { + return { + query: true, + detail: true, + filtering: true, + metadata: true, + }; + } + + _getBaseMediaUrl() { + return this.runtime.options['BASE_URL']; + } + + _getDebug() { + return this.runtime.options['DEBUG']; + } + + _getHeaders() { + return { + 'Accept': 'application/json', + }; + } + + _getMediaDetailFromDamMedia(damMedia: DamMedia, customMetadata: DAMCustomMetadataPage): Media.MediaDetail { + const assetId: AssetId = { + id: damMedia.id.toString(), + name: damMedia.name, + assetHash: damMedia.assetHash, + tenantHash: damMedia.tenantHash, + thumbnail: damMedia.thumbnail, + extension: damMedia.format, + }; + + return { + // We save all information required for 'download` under id to avoid details call + id: JSON.stringify(assetId), + name: damMedia.name, + relativePath: 'Media', + type: 0, + metaData: this._getMetadataFromDamMedia(damMedia, customMetadata), + extension: damMedia.format, + width: damMedia.width, + height: damMedia.height, + }; + } + + _getMetadataFromDamMedia(damMedia: DamMedia, customMetadata: DAMCustomMetadataPage): Connector.Dictionary { + const attributeNames: Array = customMetadata['hydra:member'].map((m) => m.attributeName); + + return Object.fromEntries(attributeNames.filter((a) => ['string', 'number'].includes(typeof damMedia[a])).map((a) => [a, damMedia[a].toString()] )); + } + + _logError(err: string) { + if (this._getDebug()) { + this.runtime.logError(err); + } + } + + private async _getDamMediaById(id: string) { + const detailEndpoint = `${this._getBaseMediaUrl()}/api/assets/${id}`; + + const result = await this.runtime.fetch(detailEndpoint, { + method: 'GET', + headers: this._getHeaders(), + }); + + if (result.status / 200 != 1) { + this._logError(`Detail fetch failed for media with id ${id}`); + throw new Error(`Detail failed ${result.status} ${result.statusText}`); + } + + const damMedia: DamMedia = JSON.parse(result.text); + return damMedia; + } + + private async _getCustomMetadata() { + const customMetadataEndpoint = `${this._getBaseMediaUrl()}/api/custom-metadata`; + + const result = await this.runtime.fetch(customMetadataEndpoint, { + method: 'GET', + headers: this._getHeaders(), + }); + + if (result.status / 200 != 1) { + this._logError(`Custom metadata fetch failed.`); + throw new Error(`Custom metadata fetch failed: ${result.status} ${result.statusText}`); + } + + const customMetadata: DAMCustomMetadataPage = JSON.parse(result.text); + return customMetadata; + } +} diff --git a/src/connectors/kadanza/package.json b/src/connectors/kadanza/package.json new file mode 100644 index 0000000..f3dc41c --- /dev/null +++ b/src/connectors/kadanza/package.json @@ -0,0 +1,32 @@ +{ + "name": "kadanza", + "description": "Kadanza DAM", + "version": "0.0.1", + "author": { + "name": "Kadanza", + "email": "hello@kadanza.com", + "url": "https://www.kadanza.com/" + }, + "config": { + "connectorName": "Kadanza DAM", + "iconUrl": "https://s3.eu-central-1.amazonaws.com/static.kadanza.io/logo/Logo_Kadanza_GraFx_Connector_Icon.svg", + "type": "media", + "options": {"BASE_URL": null, "DEBUG": false}, + "mappings": {}, + "supportedAuth": ["oAuth2ResourceOwnerPassword", "oAuth2AuthorizationCode"] + }, + "private": true, + "license": "MIT", + "main": "out/connector.js", + "dependencies": { + "typescript": "^5.2.2", + "@chili-publish/studio-connectors": "^1.15" + }, + "scripts": { + "build": "yarn connector-cli build", + "test": "yarn connector-cli test -t tests.json && yarn connector-cli stress" + }, + "devDependencies": { + "@chili-publish/connector-cli": "^1.6" + } +} \ No newline at end of file diff --git a/src/connectors/kadanza/readme.md b/src/connectors/kadanza/readme.md new file mode 100644 index 0000000..5eec8ba --- /dev/null +++ b/src/connectors/kadanza/readme.md @@ -0,0 +1,82 @@ +## Publish +``` +connector-cli publish \ + -e {ENVIRONMENT} \ + -b https://{ENVIRONMENT}.chili-publish.online/grafx \ + -n "Kadanza DAM Media Connector v0.0.1" \ + --proxyOption.allowedDomains "*.kadanza.io" \ + -ro BASE_URL="https://dam2.kadanza.io" \ + --connectorId={CONNECTOR_ID} +``` + +Enable debug messages +``` +connector-cli publish \ + -e {ENVIRONMENT} \ + -b https://{ENVIRONMENT}.chili-publish.online/grafx \ + -n "Kadanza DAM Media Connector v0.0.1" \ + --proxyOption.allowedDomains "*.kadanza.io" \ + -ro BASE_URL="https://dam2.kadanza.io" \ + -ro DEBUG=true \ + --connectorId={CONNECTOR_ID} +``` + +## Set authenticator + +### Server +``` +connector-cli set-auth \ + --connectorId {CONNECTOR_ID} \ + -e {ENVIRONMENT} \ + -b https://{ENVIRONMENT}.chili-publish.online/grafx \ + -au server \ + -at oAuth2ResourceOwnerPassword \ + --auth-data-file oauth-resource-owner.json +``` + +### Browser +``` +connector-cli set-auth \ + --connectorId {CONNECTOR_ID} \ + -e {ENVIRONMENT} \ + -b https://{ENVIRONMENT}.chili-publish.online/grafx \ + -au browser \ + -at oAuth2AuthorizationCode \ + --auth-data-file oauth-authorization-code.json +``` + +## Authorization setup + +### Create the authentication json files +https://docs.chiligrafx.com/GraFx-Developers/connectors/authorization-for-connectors/ + +The ones needed +- oAuth2ResourceOwnerPassword +- oAuth2AuthorizationCode + +### Examples + +oauth-resource-owner.json +``` +{ + "clientId": "{CLIENT_ID}", + "clientSecret": "{CLIENT_SECRET}", + "username": "{FUSIONAUTH_USERNAME}", + "password": "{FUSIONAUTH_PASSWORD}", + "tokenEndpoint": "https://idp.kadanza.io/oauth2/token" +} +``` + +oauth-authorization-code.json +``` +{ + "clientId": "{CLIENT_ID}", + "clientSecret": "{CLIENT_SECRET}", + "scope": "", + "authorizationServerMetadata": { + "authorization_endpoint": "https://idp.kadanza.io/oauth2/authorize", + "token_endpoint": "https://idp.kadanza.io/oauth2/token", + "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"] + } +} +``` diff --git a/src/connectors/kadanza/tests.json b/src/connectors/kadanza/tests.json new file mode 100644 index 0000000..d77dc55 --- /dev/null +++ b/src/connectors/kadanza/tests.json @@ -0,0 +1,27 @@ +{ + "setup": { + "runtime_options": { + "BASE_URL": "https://localhost:3000" + } + }, + "tests": [ + { + "name": "test1", + "method": "download", + "arguments": { + "id": "id", + "url": "url", + "options": {} + }, + "asserts": { + "fetch": [ + { + "url": "url", + "method": "GET", + "count": 1 + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/connectors/kadanza/tsconfig.json b/src/connectors/kadanza/tsconfig.json new file mode 100644 index 0000000..45031db --- /dev/null +++ b/src/connectors/kadanza/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["ES2020"], + "noEmitHelpers": true, + "module": "ES2020", + "outDir": "out", + "target": "ES2020", + "moduleResolution": "Node", + "preserveConstEnums": false, + "esModuleInterop": false, + "removeComments": true, + "declaration": false + }, + "include": ["connector.ts"] +}