Skip to content

Commit e6631ba

Browse files
authored
Merge branch 'v2-dev' into DX-8739
2 parents f8ac0d1 + e8b9a0a commit e6631ba

46 files changed

Lines changed: 1475 additions & 490 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/unit-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: Prune pnpm store
2626
run: pnpm store prune
2727
- name: Install Dependencies
28-
run: pnpm install --frozen-lockfile
28+
run: pnpm install --no-frozen-lockfile
2929

3030
- name: Build all plugins
3131
run: |

packages/contentstack-apps-cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,4 @@
110110
"app:deploy": "APDP"
111111
}
112112
}
113-
}
113+
}

packages/contentstack-asset-management/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ export * from './types';
33
export * from './utils';
44
export * from './export';
55
export * from './import';
6+
export * from './query-export';
67
export * from './import-setup';
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { resolve as pResolve } from 'node:path';
2+
import { mkdir, writeFile } from 'node:fs/promises';
3+
import { Readable } from 'node:stream';
4+
import { log, handleAndLogError, configHandler } from '@contentstack/cli-utilities';
5+
6+
import type { CsAssetsQueryExportOptions, CSAssetsAPIConfig, LinkedWorkspace } from '../types/cs-assets-api';
7+
import type { ExportContext } from '../types/export-types';
8+
import ExportAssetTypes from '../export/asset-types';
9+
import ExportFields from '../export/fields';
10+
import { CSAssetsExportAdapter } from '../export/base';
11+
import { getAssetItems, writeStreamToFile } from '../utils/export-helpers';
12+
import { runInBatches } from '../utils/concurrent-batch';
13+
14+
const DEFAULT_ASSET_BATCH_SIZE = 100;
15+
const SEARCH_PAGE_LIMIT = 100;
16+
17+
/**
18+
* Query-based Contentstack Assets exporter.
19+
* Exports only referenced asset UIDs from entries into the `spaces/` directory layout.
20+
*/
21+
export class CsAssetsQueryExporter {
22+
private readonly options: CsAssetsQueryExportOptions;
23+
24+
constructor(options: CsAssetsQueryExportOptions) {
25+
this.options = options;
26+
}
27+
28+
async export(assetUIDs: string[]): Promise<void> {
29+
const { linkedWorkspaces, exportDir, context } = this.options;
30+
31+
if (!assetUIDs.length) {
32+
log.info('No asset UIDs to export for Contentstack Assets query export', context);
33+
return;
34+
}
35+
36+
if (!linkedWorkspaces.length) {
37+
log.warn('No linked workspaces configured for Contentstack Assets query export', context);
38+
return;
39+
}
40+
41+
log.info(
42+
`Starting Contentstack Assets query export (${assetUIDs.length} UID(s), ${linkedWorkspaces.length} space(s))`,
43+
context,
44+
);
45+
46+
const spacesRootPath = pResolve(exportDir, 'spaces');
47+
await mkdir(spacesRootPath, { recursive: true });
48+
49+
const apiConfig: CSAssetsAPIConfig = {
50+
baseURL: this.options.csAssetsUrl,
51+
headers: { organization_uid: this.options.org_uid },
52+
context,
53+
};
54+
55+
const exportContext: ExportContext = {
56+
spacesRootPath,
57+
context,
58+
securedAssets: this.options.securedAssets,
59+
chunkFileSizeMb: this.options.chunkFileSizeMb,
60+
apiConcurrency: this.options.apiConcurrency,
61+
downloadAssetsConcurrency: this.options.downloadAssetsConcurrency,
62+
};
63+
64+
const batchSize = this.options.assetBatchSize ?? DEFAULT_ASSET_BATCH_SIZE;
65+
66+
try {
67+
await this.bootstrapSharedModules(apiConfig, exportContext, linkedWorkspaces[0].space_uid);
68+
69+
for (const workspace of linkedWorkspaces) {
70+
try {
71+
await this.exportWorkspaceAssets(apiConfig, exportContext, workspace, assetUIDs, batchSize);
72+
} catch (err) {
73+
handleAndLogError(
74+
err,
75+
{ ...(context as Record<string, unknown>), spaceUid: workspace.space_uid },
76+
`Failed Contentstack Assets query export for space ${workspace.space_uid}`,
77+
);
78+
}
79+
}
80+
81+
log.success('Contentstack Assets query export completed', context);
82+
} catch (err) {
83+
handleAndLogError(err, context as Record<string, unknown>, 'Contentstack Assets query export failed');
84+
throw err;
85+
}
86+
}
87+
88+
private async bootstrapSharedModules(
89+
apiConfig: CSAssetsAPIConfig,
90+
exportContext: ExportContext,
91+
firstSpaceUid: string,
92+
): Promise<void> {
93+
const sharedFieldsDir = pResolve(exportContext.spacesRootPath, 'fields');
94+
const sharedAssetTypesDir = pResolve(exportContext.spacesRootPath, 'asset_types');
95+
await mkdir(sharedFieldsDir, { recursive: true });
96+
await mkdir(sharedAssetTypesDir, { recursive: true });
97+
98+
const exportAssetTypes = new ExportAssetTypes(apiConfig, exportContext);
99+
const exportFields = new ExportFields(apiConfig, exportContext);
100+
await Promise.all([exportAssetTypes.start(firstSpaceUid), exportFields.start(firstSpaceUid)]);
101+
}
102+
103+
private async exportWorkspaceAssets(
104+
apiConfig: CSAssetsAPIConfig,
105+
exportContext: ExportContext,
106+
workspace: LinkedWorkspace,
107+
assetUIDs: string[],
108+
batchSize: number,
109+
): Promise<void> {
110+
const { branchName, context } = this.options;
111+
const workspaceExporter = new QueryExportWorkspaceAdapter(apiConfig, exportContext);
112+
await workspaceExporter.start(workspace, assetUIDs, branchName || 'main', batchSize);
113+
log.debug(`Contentstack Assets query export finished for space ${workspace.space_uid}`, context);
114+
}
115+
}
116+
117+
/**
118+
* Per-space export: search by UID, write metadata/files, download binaries.
119+
*/
120+
class QueryExportWorkspaceAdapter extends CSAssetsExportAdapter {
121+
async start(
122+
workspace: LinkedWorkspace,
123+
assetUIDs: string[],
124+
branchName: string,
125+
uidBatchSize: number,
126+
): Promise<void> {
127+
await this.init();
128+
129+
const spaceDir = pResolve(this.exportContext.spacesRootPath, workspace.space_uid);
130+
await mkdir(spaceDir, { recursive: true });
131+
132+
const spaceResponse = await this.getSpace(workspace.space_uid);
133+
const space = spaceResponse.space;
134+
const metadata = {
135+
...space,
136+
workspace_uid: workspace.uid,
137+
is_default: workspace.is_default,
138+
branch: branchName,
139+
};
140+
await writeFile(pResolve(spaceDir, 'metadata.json'), JSON.stringify(metadata, null, 2));
141+
142+
const assetsDir = pResolve(spaceDir, 'assets');
143+
await mkdir(assetsDir, { recursive: true });
144+
145+
const spaceRef = { space_uid: workspace.space_uid, workspace: workspace.uid };
146+
const assetItems = await this.searchAllAssets(assetUIDs, spaceRef, uidBatchSize);
147+
148+
const folders = assetItems.filter((item) => (item as { is_dir?: boolean }).is_dir === true);
149+
const files = assetItems.filter((item) => (item as { is_dir?: boolean }).is_dir !== true);
150+
151+
await writeFile(pResolve(assetsDir, 'folders.json'), JSON.stringify(folders, null, 2));
152+
153+
await this.writeItemsToChunkedJson(
154+
assetsDir,
155+
'assets.json',
156+
'assets',
157+
['uid', 'url', 'filename', 'file_name', 'parent_uid'],
158+
files,
159+
);
160+
161+
await this.downloadAssets(files, assetsDir, workspace.space_uid);
162+
}
163+
164+
private async searchAllAssets(
165+
assetUIDs: string[],
166+
spaceRef: { space_uid: string; workspace: string },
167+
uidBatchSize: number,
168+
): Promise<Array<Record<string, unknown>>> {
169+
const seen = new Set<string>();
170+
const results: Array<Record<string, unknown>> = [];
171+
172+
for (let i = 0; i < assetUIDs.length; i += uidBatchSize) {
173+
const uidBatch = assetUIDs.slice(i, i + uidBatchSize);
174+
let skip = 0;
175+
let pageItems: unknown[];
176+
177+
do {
178+
const response = await this.searchAssets({
179+
assetUIDs: uidBatch,
180+
spaces: [spaceRef],
181+
skip,
182+
limit: SEARCH_PAGE_LIMIT,
183+
});
184+
pageItems = getAssetItems(response);
185+
186+
if (pageItems.length === 0 && skip === 0) {
187+
log.warn(
188+
`Search returned 0 assets in space ${spaceRef.space_uid} for UID(s): [${uidBatch.join(', ')}]`,
189+
this.exportContext.context,
190+
);
191+
}
192+
193+
for (const item of pageItems) {
194+
const record = item as Record<string, unknown>;
195+
const key = String(record.uid ?? record.asset_id ?? record._uid ?? '');
196+
if (key && !seen.has(key)) {
197+
seen.add(key);
198+
results.push(record);
199+
}
200+
}
201+
202+
skip += pageItems.length;
203+
} while (pageItems.length === SEARCH_PAGE_LIMIT);
204+
}
205+
206+
return results;
207+
}
208+
209+
private async downloadAssets(
210+
items: Array<Record<string, unknown>>,
211+
assetsDir: string,
212+
spaceUid: string,
213+
): Promise<void> {
214+
const downloadable = items.filter((asset) => Boolean(asset.url && (asset.uid ?? asset._uid)));
215+
if (downloadable.length === 0) {
216+
log.debug(`No downloadable assets for space ${spaceUid}`, this.exportContext.context);
217+
return;
218+
}
219+
220+
const filesDir = pResolve(assetsDir, 'files');
221+
await mkdir(filesDir, { recursive: true });
222+
223+
const securedAssets = this.exportContext.securedAssets ?? false;
224+
const authtoken = securedAssets ? configHandler.get('authtoken') : null;
225+
226+
await runInBatches(downloadable, this.downloadAssetsBatchConcurrency, async (asset) => {
227+
const uid = String(asset.uid ?? asset._uid);
228+
const url = String(asset.url);
229+
const filename = String(asset.filename ?? asset.file_name ?? 'asset');
230+
try {
231+
const separator = url.includes('?') ? '&' : '?';
232+
const downloadUrl = securedAssets && authtoken ? `${url}${separator}authtoken=${authtoken}` : url;
233+
const response = await fetch(downloadUrl);
234+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
235+
const body = response.body;
236+
if (!body) throw new Error('No response body');
237+
const nodeStream = Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]);
238+
const assetFolderPath = pResolve(filesDir, uid);
239+
await mkdir(assetFolderPath, { recursive: true });
240+
await writeStreamToFile(nodeStream, pResolve(assetFolderPath, filename));
241+
} catch (e) {
242+
log.debug(`Failed to download asset ${uid} in space ${spaceUid}: ${e}`, this.exportContext.context);
243+
}
244+
});
245+
}
246+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { CsAssetsQueryExporter } from './cs-assets-query-exporter';

packages/contentstack-asset-management/src/types/cs-assets-api.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ export type BulkMoveAssetsResponse = {
141141
* Adapter interface for Contentstack Assets API calls.
142142
* Used by export and (future) import.
143143
*/
144+
/** Space + workspace pair for Contentstack Assets search API. */
145+
export type SearchSpaceRef = {
146+
space_uid: string;
147+
workspace: string;
148+
};
149+
150+
/** Parameters for POST /api/search (asset query export). */
151+
export type SearchAssetsParams = {
152+
assetUIDs: string[];
153+
spaces: SearchSpaceRef[];
154+
skip?: number;
155+
limit?: number;
156+
};
157+
158+
/** Response shape from POST /api/search for assets. */
159+
export type SearchAssetsResponse = {
160+
count?: number;
161+
relation?: string;
162+
assets?: unknown[];
163+
items?: unknown[];
164+
results?: unknown[];
165+
folders?: unknown[];
166+
};
167+
144168
export interface ICSAssetsAdapter {
145169
init(): Promise<void>;
146170
listSpaces(): Promise<SpacesListResponse>;
@@ -149,6 +173,7 @@ export interface ICSAssetsAdapter {
149173
getWorkspaceAssets(spaceUid: string, workspaceUid?: string): Promise<unknown>;
150174
getWorkspaceFolders(spaceUid: string, workspaceUid?: string): Promise<unknown>;
151175
getWorkspaceAssetTypes(spaceUid: string): Promise<AssetTypesResponse>;
176+
searchAssets(params: SearchAssetsParams): Promise<SearchAssetsResponse>;
152177
bulkDeleteAssets(
153178
spaceUid: string,
154179
workspaceUid: string | undefined,
@@ -161,6 +186,23 @@ export interface ICSAssetsAdapter {
161186
): Promise<BulkMoveAssetsResponse>;
162187
}
163188

189+
/** Options for query-based Contentstack Assets export (referenced assets from entries). */
190+
export type CsAssetsQueryExportOptions = {
191+
linkedWorkspaces: LinkedWorkspace[];
192+
exportDir: string;
193+
branchName: string;
194+
csAssetsUrl: string;
195+
org_uid: string;
196+
apiKey?: string;
197+
context?: Record<string, unknown>;
198+
securedAssets?: boolean;
199+
chunkFileSizeMb?: number;
200+
apiConcurrency?: number;
201+
downloadAssetsConcurrency?: number;
202+
/** Max UIDs per search request ($in batch). */
203+
assetBatchSize?: number;
204+
};
205+
164206
/**
165207
* Options for exporting space structure (used by export app after fetching linked workspaces).
166208
*/

0 commit comments

Comments
 (0)