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 adapter 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 adapter. 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('exampleAdapter1.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
SDK-based adapter 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('exampleAdapter2.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)
}
}