Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
- name: Prune pnpm store
run: pnpm store prune
- name: Install Dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile

- name: Build all plugins
run: |
Expand Down
4 changes: 4 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
fileignoreconfig:
- filename: pnpm-lock.yaml
checksum: 2f6edbc19377e3a857884f00e31c498b660cc4f64b46892aee8eecf2f1ca9978
- filename: packages/contentstack-query-export/src/core/query-executor.ts
checksum: a8d3688a519eb6a941bcdb22f41347df87539e7ab2413665b3193614dc40622d
- filename: packages/contentstack-asset-management/src/query-export/am-asset-query-exporter.ts
checksum: 19c19d237e1dbe339d024c92049e24493d30b133422d2a10173fe3718413370c
version: '1.0'
1 change: 1 addition & 0 deletions packages/contentstack-asset-management/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './types';
export * from './utils';
export * from './export';
export * from './import';
export * from './query-export';
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { resolve as pResolve } from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { log, handleAndLogError, configHandler } from '@contentstack/cli-utilities';

import type { CsAssetsQueryExportOptions, CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
import type { ExportContext } from '../types/export-types';
import ExportAssetTypes from '../export/asset-types';
import ExportFields from '../export/fields';
import { CSAssetsExportAdapter } from '../export/base';
import { getAssetItems, writeStreamToFile } from '../utils/export-helpers';
import { runInBatches } from '../utils/concurrent-batch';

const DEFAULT_ASSET_BATCH_SIZE = 100;
const SEARCH_PAGE_LIMIT = 50;

/**
* Query-based Contentstack Assets exporter.
* Exports only referenced asset UIDs from entries into the `spaces/` directory layout.
*/
export class CsAssetsQueryExporter {
private readonly options: CsAssetsQueryExportOptions;

constructor(options: CsAssetsQueryExportOptions) {
this.options = options;
}

async export(assetUIDs: string[]): Promise<void> {
const { linkedWorkspaces, exportDir, context } = this.options;

if (!assetUIDs.length) {
log.info('No asset UIDs to export for Contentstack Assets query export', context);
return;
}

if (!linkedWorkspaces.length) {
log.warn('No linked workspaces configured for Contentstack Assets query export', context);
return;
}

log.info(
`Starting Contentstack Assets query export (${assetUIDs.length} UID(s), ${linkedWorkspaces.length} space(s))`,
context,
);

const spacesRootPath = pResolve(exportDir, 'spaces');
await mkdir(spacesRootPath, { recursive: true });

const apiConfig: CSAssetsAPIConfig = {
baseURL: this.options.csAssetsUrl,
headers: { organization_uid: this.options.org_uid },
context,
};

const exportContext: ExportContext = {
spacesRootPath,
context,
securedAssets: this.options.securedAssets,
chunkFileSizeMb: this.options.chunkFileSizeMb,
apiConcurrency: this.options.apiConcurrency,
downloadAssetsConcurrency: this.options.downloadAssetsConcurrency,
};

const batchSize = this.options.assetBatchSize ?? DEFAULT_ASSET_BATCH_SIZE;

try {
await this.bootstrapSharedModules(apiConfig, exportContext, linkedWorkspaces[0].space_uid);

for (const workspace of linkedWorkspaces) {
try {
await this.exportWorkspaceAssets(apiConfig, exportContext, workspace, assetUIDs, batchSize);
} catch (err) {
handleAndLogError(
err,
{ ...(context as Record<string, unknown>), spaceUid: workspace.space_uid },
`Failed Contentstack Assets query export for space ${workspace.space_uid}`,
);
}
}

log.success('Contentstack Assets query export completed', context);
} catch (err) {
handleAndLogError(err, context as Record<string, unknown>, 'Contentstack Assets query export failed');
throw err;
}
}

private async bootstrapSharedModules(
apiConfig: CSAssetsAPIConfig,
exportContext: ExportContext,
firstSpaceUid: string,
): Promise<void> {
const sharedFieldsDir = pResolve(exportContext.spacesRootPath, 'fields');
const sharedAssetTypesDir = pResolve(exportContext.spacesRootPath, 'asset_types');
await mkdir(sharedFieldsDir, { recursive: true });
await mkdir(sharedAssetTypesDir, { recursive: true });

const exportAssetTypes = new ExportAssetTypes(apiConfig, exportContext);
const exportFields = new ExportFields(apiConfig, exportContext);
await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]);
}

private async exportWorkspaceAssets(
apiConfig: CSAssetsAPIConfig,
exportContext: ExportContext,
workspace: LinkedWorkspace,
assetUIDs: string[],
batchSize: number,
): Promise<void> {
const { branchName, context } = this.options;
const workspaceExporter = new QueryExportWorkspaceAdapter(apiConfig, exportContext);
await workspaceExporter.start(workspace, assetUIDs, branchName || 'main', batchSize);
log.debug(`Contentstack Assets query export finished for space ${workspace.space_uid}`, context);
}
}

/**
* Per-space export: search by UID, write metadata/files, download binaries.
*/
class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
async start(
workspace: LinkedWorkspace,
assetUIDs: string[],
branchName: string,
uidBatchSize: number,
): Promise<void> {
await this.init();

const spaceDir = pResolve(this.exportContext.spacesRootPath, workspace.space_uid);
await mkdir(spaceDir, { recursive: true });

const spaceResponse = await this.getSpace(workspace.space_uid);
const space = spaceResponse.space;
const metadata = {
...space,
workspace_uid: workspace.uid,
is_default: workspace.is_default,
branch: branchName,
};
await writeFile(pResolve(spaceDir, 'metadata.json'), JSON.stringify(metadata, null, 2));

const assetsDir = pResolve(spaceDir, 'assets');
await mkdir(assetsDir, { recursive: true });

const spaceRef = { space_uid: workspace.space_uid, workspace: workspace.uid };
const assetItems = await this.searchAllAssets(assetUIDs, spaceRef, uidBatchSize);

const folders = assetItems.filter((item) => (item as { is_dir?: boolean }).is_dir === true);
const files = assetItems.filter((item) => (item as { is_dir?: boolean }).is_dir !== true);

await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2));

await this.writeItemsToChunkedJson(
assetsDir,
'assets.json',
'assets',
['uid', 'url', 'filename', 'file_name', 'parent_uid'],
files,
);

await this.downloadAssets(files, assetsDir, workspace.space_uid);
}

private async searchAllAssets(
assetUIDs: string[],
spaceRef: { space_uid: string; workspace: string },
uidBatchSize: number,
): Promise<Array<Record<string, unknown>>> {
const seen = new Set<string>();
const results: Array<Record<string, unknown>> = [];

for (let i = 0; i < assetUIDs.length; i += uidBatchSize) {
const uidBatch = assetUIDs.slice(i, i + uidBatchSize);
let skip = 0;
let pageItems: unknown[];

do {
const response = await this.searchAssets({
assetUIDs: uidBatch,
spaces: [spaceRef],
skip,
limit: SEARCH_PAGE_LIMIT,
});
pageItems = getAssetItems(response);
if (pageItems.length === 0 && Array.isArray((response as { assets?: unknown[] }).assets)) {
pageItems = (response as { assets: unknown[] }).assets;
}

for (const item of pageItems) {
const record = item as Record<string, unknown>;
const key = String(record.uid ?? record.asset_id ?? record._uid ?? '');
if (key && !seen.has(key)) {
seen.add(key);
results.push(record);
}
}

skip += pageItems.length;
} while (pageItems.length === SEARCH_PAGE_LIMIT);
}

return results;
}

private async downloadAssets(
items: Array<Record<string, unknown>>,
assetsDir: string,
spaceUid: string,
): Promise<void> {
const downloadable = items.filter((asset) => Boolean(asset.url && (asset.uid ?? asset._uid)));
if (downloadable.length === 0) {
log.debug(`No downloadable assets for space ${spaceUid}`, this.exportContext.context);
return;
}

const filesDir = pResolve(assetsDir, 'files');
await mkdir(filesDir, { recursive: true });

const securedAssets = this.exportContext.securedAssets ?? false;
const authtoken = securedAssets ? configHandler.get('authtoken') : null;

await runInBatches(downloadable, this.downloadAssetsBatchConcurrency, async (asset) => {
const uid = String(asset.uid ?? asset._uid);
const url = String(asset.url);
const filename = String(asset.filename ?? asset.file_name ?? 'asset');
try {
const separator = url.includes('?') ? '&' : '?';
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
const response = await fetch(downloadUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = response.body;
if (!body) throw new Error('No response body');
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
const assetFolderPath = pResolve(filesDir, uid);
await mkdir(assetFolderPath, { recursive: true });
await writeStreamToFile(nodeStream, pResolve(assetFolderPath, filename));
} catch (e) {
log.debug(`Failed to download asset ${uid} in space ${spaceUid}: ${e}`, this.exportContext.context);
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CsAssetsQueryExporter } from './cs-assets-query-exporter';
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,28 @@ export type CSAssetsAPIConfig = {
* Adapter interface for Contentstack Assets API calls.
* Used by export and (future) import.
*/
/** Space + workspace pair for Contentstack Assets search API. */
export type SearchSpaceRef = {
space_uid: string;
workspace: string;
};

/** Parameters for POST /api/search (asset query export). */
export type SearchAssetsParams = {
assetUIDs: string[];
spaces: SearchSpaceRef[];
skip?: number;
limit?: number;
};

/** Response shape from POST /api/search for assets. */
export type SearchAssetsResponse = {
count?: number;
assets?: unknown[];
items?: unknown[];
folders?: unknown[];
};

export interface ICSAssetsAdapter {
init(): Promise<void>;
listSpaces(): Promise<SpacesListResponse>;
Expand All @@ -127,8 +149,26 @@ export interface ICSAssetsAdapter {
getWorkspaceAssets(spaceUid: string, workspaceUid?: string): Promise<unknown>;
getWorkspaceFolders(spaceUid: string, workspaceUid?: string): Promise<unknown>;
getWorkspaceAssetTypes(spaceUid: string): Promise<AssetTypesResponse>;
searchAssets(params: SearchAssetsParams): Promise<SearchAssetsResponse>;
}

/** Options for query-based Contentstack Assets export (referenced assets from entries). */
export type CsAssetsQueryExportOptions = {
linkedWorkspaces: LinkedWorkspace[];
exportDir: string;
branchName: string;
csAssetsUrl: string;
org_uid: string;
apiKey?: string;
context?: Record<string, unknown>;
securedAssets?: boolean;
chunkFileSizeMb?: number;
apiConcurrency?: number;
downloadAssetsConcurrency?: number;
/** Max UIDs per search request ($in batch). */
assetBatchSize?: number;
};

/**
* Options for exporting space structure (used by export app after fetching linked workspaces).
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,41 @@ import type {
CreateSpacePayload,
FieldsResponse,
ICSAssetsAdapter,
SearchAssetsParams,
SearchAssetsResponse,
Space,
SpaceResponse,
SpacesListResponse,
} from '../types/cs-assets-api';

/** Default fields requested from POST /api/search for asset export. */
export const DEFAULT_SEARCH_ASSET_FIELDS = [
'asset_id',
'uid',
'title',
'file_name',
'description',
'parent_uid',
'is_dir',
'dimensions',
'file_size',
'content_type',
'asset_type',
'url',
'tags',
'created_at',
'updated_at',
'created_by',
'updated_by',
'path',
'locale',
'space_uid',
'version',
'publish_details',
'ACL',
'_asset_scan_status',
] as const;

export class CSAssetsAdapter implements ICSAssetsAdapter {
private readonly config: CSAssetsAPIConfig;
private readonly apiClient: HttpClient;
Expand Down Expand Up @@ -223,6 +253,30 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
return result;
}

/**
* POST /api/search — query assets by UID within linked spaces (Contentstack Assets query export).
*/
async searchAssets(params: SearchAssetsParams): Promise<SearchAssetsResponse> {
await this.init();
const { assetUIDs, spaces, skip = 0, limit = 50 } = params;
if (!assetUIDs.length) {
return { count: 0, assets: [] };
}
const body = {
query: { uid: { $in: assetUIDs } },
skip,
limit,
object_type: 'asset',
fields: [...DEFAULT_SEARCH_ASSET_FIELDS],
spaces,
};
log.debug(
`Searching assets (skip=${skip}, limit=${limit}, uids=${assetUIDs.length}, spaces=${spaces.length})`,
this.config.context,
);
return this.postJson<SearchAssetsResponse>('/api/search', body);
}

// ---------------------------------------------------------------------------
// POST helpers
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading