Search
The search handler returns assets matching a text query.
Called when the user types in the search bar in the CI HUB plugin UI.
Inputs
| Parameter | Type | Description |
|---|---|---|
requestData.query.query | string | Search text entered by the user |
requestData.query.page | string | Current page number |
requestData.query.size | string | Requested page size |
requestData.query.filters | string[] | Selected filter option IDs |
requestData.query.parentId | string | Folder scope, limits search to a subfolder |
requestData.body.dataBase64 | string | Base64-encoded image for similarity/visual search (optional) |
Response
return send({ assets, folders, filters, more })| Field | Type | Description |
|---|---|---|
assets | Asset[] | Matching assets |
folders | Folder[] | Subfolders relevant to the query |
filters | Filter[] | Filter definitions with options and active state |
more | number | undefined | Next page token. Present when more assets exist. undefined = last page |
The extractAssetsData Pattern
Every integration needs a function that maps remote API objects to the CI HUB Asset shape. By convention this function is called extractAssetData (single) or extractAssetsData (batch). It is not an SDK export. You write it in your integration. Both search and getFolder reuse it.
Asset Object Fields
Every field the mapping function should produce:
| Field | Type | Purpose |
|---|---|---|
id | string | Unique identifier from the remote system |
name | string | Display name. Sanitize with .replace(/[/\\:*?"<>|]/g, '_'). Creative tools reject special characters in file names |
downloadUrl | string | null | URL to fetch the file binary. Append URL suffixes as needed. null if download is not permitted |
thumbnailUrl | string | null | Preview image URL. For InDesign/IDMS files, CI HUB can generate thumbnails server-side via getThumbnailUrl(). Pass the source system thumbnail or an empty string |
mimeType | string | Derived from file extension or the remote API. Fall back to 'application/octet-stream' |
xSizePx / ySizePx | number | Pixel dimensions (width / height) |
fileSize | number | Size in bytes |
created / modified | number | Unix millisecond timestamps. Convert ISO date strings with new Date(dateString).getTime() |
version | number | Version number. Default to 1 when the platform doesn't support versioning |
values | Array<{id, name, type, value}> | Metadata entries shown in the asset detail panel. See Custom Metadata |
conversions | Array<{id, name, url, extension}> | Available renditions/derivatives of the asset |
capabilities | AssetCapabilities | Per-asset permissions. Spread defaultAssetCapabilities and override what the platform supports |
keywords | string[] | Tags, displayed as chips in the plugin UI |
states | string[] | Workflow / lifecycle state labels |
assetDetailsExternalUrl | string | null | "Open in source system" link |
downloadHashFileAttributes | string | JSON.stringify({ fileSize, modified }). Used by CI HUB for change detection |
URL Suffixes
Append these to downloadUrl or thumbnailUrl to control how CI HUB fetches the file:
$NO_AUTH$: URL is publicly accessible, skip auth headers$AUTH_PAYLOAD_BEARER$: attach the stored token as a Bearer header$FORCE_DOWNLOAD$: force download instead of streaming
Full list in the Search Schema.
Pagination
more controls asset pagination. Return a page token (typically page + 1) when more assets exist; return undefined on the last page.
For subfolders: return all matching folders in a single response. Don't paginate folders.
canAddAsset in Search
If the platform allows uploading assets without a folder context (e.g. a flat asset library), set canAddAsset: true in the search result's capabilities:
capabilities: { canAddAsset: true } This shows the upload button in the plugin UI even when not inside a folder.
Patterns
Example - extractAssetData
Maps a single remote API object to the CI HUB Asset shape. Derives the name, thumbnail, MIME type, conversions, states, and metadata values from the remote item.
const stateProps = [
{ id: 'watermarked', name: 'Watermarked' },
{ id: 'limited', name: 'Limited' },
{ id: 'archive', name: 'Archived' },
{ id: 'isPublic', name: 'Public' }
]
const getValues = item => stateProps.
filter(prop => item[prop.id]).
map(prop => ({ id: prop.id, name: prop.name, type: 'TEXT', value: '✓' })).
concat(Object.entries(item).
filter(([ key, value ]) => key.startsWith('property_') && value).
map(([ key, value ]) => ({ id: key, name: `${key[9].toUpperCase()}${key.substring(10)}`, type: 'TEXT', value: Array.isArray(value) ? value.join(', ') : value })))
const extractAssetData = (locals, item) => {
let name = (item.name || '').replace(/[/\\:*?"<>|]/g, '_')
const currentExtension = (path.extname(name) || '').toLowerCase().replace('.', '')
if (item.extension && currentExtension !== `${item.extension}`.toLowerCase()) {
name += `.${item.extension}`
}
const thumbnailVideoUrl = item && item.videoPreviewURLs && item.videoPreviewURLs.length && item.videoPreviewURLs[0]
const downloadUrl = getDownloadUrl(locals, item.id)
const thumbnailUrl = getThumbnailUrl(downloadUrl, name, [ 'indd', 'idms' ], item.thumbnails.thul && `${item.thumbnails.thul}$NO_AUTH$`)
const fileSize = item.fileSize
const modified = new Date(item.dateModified).getTime()
return {
id: item.id,
name,
parentPath: '/Assets',
fileSize,
xSizePx: item.width,
ySizePx: item.height,
created: new Date(item.dateCreated).getTime(),
modified,
mimeType: mime.lookup(name) || 'application/octet-stream',
version: item.version || 1,
caption: item.description && item.description.trim() || undefined,
copyright: item.copyright && item.copyright.trim() || undefined,
title: item.name,
thumbnailUrl,
thumbnailVideoUrl,
downloadUrl,
downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
lowresUrl: !thumbnailVideoUrl ? getDownloadUrl(locals, item.id, '?lowres=1') : undefined,
assetDetailsExternalUrl: getAssetDetailsExternalUrl(locals, item.id),
conversions: getConversions(item),
keywords: item.tags,
states: stateProps.filter(entry => item[entry.id]).map(entry => entry.name),
capabilities: defaultAssetCapabilities,
values: getValues(item)
}
}Example - extractAssetsData
Batch variant that maps an array of remote items, merges per-asset permissions with defaultAssetCapabilities, and filters out broken items.
const extractAssetsData = (locals, items, overrideCapabilities = {}, useFileName = false) => items.map((item) => {
const expired = item.expiresAt ? new Date(item.expiresAt).getTime() < Date.now() : false
const [ key, fallback ] = useFileName ? [ 'title', 'filename' ] : [ 'filename', 'title' ]
const assetName = item[key] || item[fallback]
const downloadUrl = item.downloadUrl?.concat('$NO_AUTH$') ?? getDownloadUrl(locals)
const thumbnailUrl = item.thumbnailUrl?.concat('$NO_AUTH$')
const extension = item.extension || mimeTypes.extension(item.filename)
const fileSize = item.size
const modified = new Date(item.modifiedAt).getTime()
const readableId = getHumanReadableId(item.id.toString())
const canDownload = !expired && (item.currentUserPermissions?.canDownload ?? true)
const capabilities = {
canUpdateAsset: !expired && (item.currentUserPermissions?.canEdit || false),
canDeleteAsset: !expired && (item.currentUserPermissions?.canDelete || false)
}
try {
const asset = {
id: item.id.toString(),
name: assetName,
downloadUrl: canDownload ? downloadUrl : null,
thumbnailUrl: thumbnailUrl,
mimeType: mimeTypes.lookup(extension) || mimeTypes.lookup(assetName) || 'application/octet-stream',
title: item.title,
xSizePx: item.width,
ySizePx: item.height,
fileSize,
created: new Date(item.createdAt).getTime(),
modified,
version: 1,
values: getValues(item),
conversions: expired ? [] : getConversions(item, extension, thumbnailUrl),
keywords: item.tags?.map((tag) => tag.value).filter((tag) => tag),
states: [ item.workflowTask?.status?.name, item.licenses?.map((license) => license.title), expired ? 'Expired' : '' ].flat().filter((state) => state),
capabilities: { ...defaultAssetCapabilities, ...capabilities, ...overrideCapabilities },
downloadHashFileAttributes: JSON.stringify({ fileSize, modified }),
assetDetailsExternalUrl: readableId ? new URL(`/screen/${readableId}`, locals.adapterData.resourceEndpoint).toString() : null
}
return asset
} catch {
return undefined
}
}).filter((item) => item)Example - Search Handler
Parses query parameters, calls the remote API, maps results through extractAssetData, builds filters, and determines pagination.
search: async (locals, requestData) => {
const query = requestData.query.query || ''
const page = Number.parseInt(requestData.query.more || 0, 10)
const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('IntegrationName.maxQuerySize'))
const activeFilters = requestData.query.filters || []
try {
const result = await getAssets(locals, null, query, page, size, activeFilters)
return sendResult(null, result)
} catch (err) {
return sendError(err, 400)
}
}
// getAssets builds the full result object:
const getAssets = async (locals, collection, search, page, size, activeFilters = []) => {
const capabilities = Object.assign({}, defaultFolderCapabilities, {
canAddAsset: true,
canDeleteFolder: collection !== ''
})
const result = { folders: [], assets: [], filters: [], capabilities, totalAssetsCount: 0 }
const query = {
limit: size,
page: page + 1,
count: true,
total: true,
orderBy: 'dateCreated desc'
}
const assets = await callApi(locals, 'GET', '/api/v4/media/', query)
result.totalAssetsCount = assets.count.total
// Pagination: check if more assets exist
if (page * size + assets.media.length < result.totalAssetsCount) {
result.more = page + 1
}
// Map each remote item to CI HUB Asset
for (const item of assets.media) {
const asset = extractAssetData(locals, item)
result.assets.push(asset)
}
return result
}Example - Search Handler
Integration with filter parsing, folder scoping, and two search paths (library search vs. brand-wide search).
search: async (locals, requestData) => {
const { brandId } = locals.adapterData
const isFileNameSearch = requestData.query.type === 'fileName'
const query = (requestData.query.query || '').replace(isFileNameSearch ? /^"|"$/g : '', '')
const activeFilters = requestData.query.filters || []
const activeFiltersJson = parseOptions(activeFilters)
const rFolderId = (requestData.query.parentId || '').toString()
const folderId = folderIdStartsWith(rFolderId) ? undefined : rFolderId
const more = parseInt((requestData.query.more || '1'), 10) || 1
const size = Math.min(parseInt((requestData.query.size || '25'), 10), config.get('IntegrationName.maxQuerySize'))
const result = {
assets: [],
folders: [],
filters: [],
more: undefined,
capabilities: {},
totalAssetsCount: 0
}
try {
const searchQuery = {
...folderId ? { inFolder: { id: folderId } } : {},
search: query
}
const assets = await callApi(locals, {
query: queries.getSearchAssetsQuery,
variables: { libraryId, query: searchQuery, page: more, limit: size }
})
result.assets = extractAssetsData(locals, assets.data.library.assets.items)
result.more = assets.data.library.assets.hasNextPage ? more + 1 : undefined
result.totalAssetsCount = assets.data.library.assets.total
result.filters = getFilters(assets.data, activeFiltersJson)
return send(result)
} catch (error) {
return sendError(`Error searching: ${error}`, 400)
}
}