@freelygive/canvas-jsonapi 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/package.json +30 -0
- package/src/client.js +181 -0
- package/src/create.js +120 -0
- package/src/delete.js +68 -0
- package/src/entity.js +233 -0
- package/src/get.js +95 -0
- package/src/index.js +121 -0
- package/src/list.js +197 -0
- package/src/update.js +140 -0
- package/src/upload-document.js +125 -0
- package/src/upload-image.js +122 -0
- package/src/upload-video.js +127 -0
- package/src/utils.js +147 -0
- package/src/uuid.js +10 -0
- package/src/validate.js +298 -0
- package/src/whoami.js +34 -0
package/src/entity.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared entity utilities for content management scripts.
|
|
3
|
+
*/
|
|
4
|
+
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
5
|
+
|
|
6
|
+
import { authenticatedFetch, getEntityUrl } from './client.js';
|
|
7
|
+
import { saveContent } from './utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the label field name for a given entity type.
|
|
11
|
+
* @param {string} type - The entity type
|
|
12
|
+
* @returns {string} The field name to use as label
|
|
13
|
+
*/
|
|
14
|
+
export function getLabelField(type) {
|
|
15
|
+
if (type.startsWith('media--')) {
|
|
16
|
+
return 'name';
|
|
17
|
+
} else if (type.startsWith('file--')) {
|
|
18
|
+
return 'filename';
|
|
19
|
+
} else if (type.startsWith('taxonomy_term--')) {
|
|
20
|
+
return 'name';
|
|
21
|
+
}
|
|
22
|
+
return 'title';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse component inputs from JSON strings to objects for easier editing.
|
|
27
|
+
* @param {object} data - The JSON:API response data
|
|
28
|
+
* @returns {object} Data with parsed component inputs
|
|
29
|
+
*/
|
|
30
|
+
export function parseComponentInputs(data) {
|
|
31
|
+
const components = data?.data?.attributes?.components;
|
|
32
|
+
if (Array.isArray(components)) {
|
|
33
|
+
for (const component of components) {
|
|
34
|
+
if (typeof component.inputs === 'string') {
|
|
35
|
+
try {
|
|
36
|
+
component.inputs = JSON.parse(component.inputs);
|
|
37
|
+
} catch {
|
|
38
|
+
// Keep as string if not valid JSON
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stringify component inputs if they are objects.
|
|
48
|
+
* Preserves strings that are already stringified.
|
|
49
|
+
* @param {object} attributes - The attributes object containing components
|
|
50
|
+
* @returns {object} Attributes with stringified component inputs
|
|
51
|
+
*/
|
|
52
|
+
export function stringifyComponentInputs(attributes) {
|
|
53
|
+
if (!attributes) return attributes;
|
|
54
|
+
|
|
55
|
+
const result = { ...attributes };
|
|
56
|
+
if (Array.isArray(result.components)) {
|
|
57
|
+
result.components = result.components.map((component) => {
|
|
58
|
+
const clean = { ...component };
|
|
59
|
+
delete clean.component_version;
|
|
60
|
+
if (clean.inputs && typeof clean.inputs !== 'string') {
|
|
61
|
+
return { ...clean, inputs: JSON.stringify(clean.inputs) };
|
|
62
|
+
}
|
|
63
|
+
return clean;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract thumbnail URL from media entity with included file.
|
|
71
|
+
* @param {object} result - The JSON:API response
|
|
72
|
+
* @returns {string|null} The thumbnail URL or null
|
|
73
|
+
*/
|
|
74
|
+
export function getThumbnailUrl(result) {
|
|
75
|
+
const included = result.included || [];
|
|
76
|
+
for (const item of included) {
|
|
77
|
+
if (item.type === 'file--file' && item.links?.thumbnail?.href) {
|
|
78
|
+
return item.links.thumbnail.href;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract file URL from media entity with included file.
|
|
86
|
+
* @param {object} result - The JSON:API response
|
|
87
|
+
* @returns {string|null} The file URL or null
|
|
88
|
+
*/
|
|
89
|
+
export function getFileUrl(result) {
|
|
90
|
+
const included = result.included || [];
|
|
91
|
+
for (const item of included) {
|
|
92
|
+
if (item.type === 'file--file' && item.attributes?.uri?.url) {
|
|
93
|
+
return item.attributes.uri.url;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract media internal ID from the response.
|
|
101
|
+
* The internal ID is in resourceVersion query param of the self link.
|
|
102
|
+
* @param {object} result - The JSON:API response
|
|
103
|
+
* @returns {string|null} The media internal ID or null
|
|
104
|
+
*/
|
|
105
|
+
export function getMediaInternalId(result) {
|
|
106
|
+
const selfHref = result.data?.links?.self?.href;
|
|
107
|
+
if (selfHref) {
|
|
108
|
+
const match = selfHref.match(/resourceVersion=id%3A(\d+)/);
|
|
109
|
+
if (match) {
|
|
110
|
+
return match[1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fetch an entity from the API.
|
|
118
|
+
* @param {string} type - The entity type
|
|
119
|
+
* @param {string} uuid - The entity UUID
|
|
120
|
+
* @param {string[]} include - Fields to include
|
|
121
|
+
* @returns {Promise<object>} The entity data
|
|
122
|
+
*/
|
|
123
|
+
export async function fetchEntity(type, uuid, include = []) {
|
|
124
|
+
const params = new DrupalJsonApiParams();
|
|
125
|
+
|
|
126
|
+
// For media entities, automatically include the file field to get file links
|
|
127
|
+
let includeFields = [...include];
|
|
128
|
+
if (type === 'media--image' && !include.includes('media_image')) {
|
|
129
|
+
includeFields.push('media_image');
|
|
130
|
+
} else if (type === 'media--document' && !include.includes('media_file')) {
|
|
131
|
+
includeFields.push('media_file');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (includeFields.length > 0) {
|
|
135
|
+
params.addInclude(includeFields);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const queryString = params.getQueryString();
|
|
139
|
+
// Get the correct endpoint URL from the API root mapping
|
|
140
|
+
let url = await getEntityUrl(type, uuid);
|
|
141
|
+
if (queryString) {
|
|
142
|
+
url += `?${queryString}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const response = await authenticatedFetch(url);
|
|
146
|
+
const contentType = response.headers.get('content-type') || '';
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
!contentType.includes('application/vnd.api+json') &&
|
|
150
|
+
!contentType.includes('application/json')
|
|
151
|
+
) {
|
|
152
|
+
const text = await response.text();
|
|
153
|
+
throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = await response.json();
|
|
157
|
+
|
|
158
|
+
if (!response.ok || result.errors) {
|
|
159
|
+
throw new Error(JSON.stringify(result.errors || result, null, 2));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return parseComponentInputs(result);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Fetch an entity, save it locally, and display a summary.
|
|
167
|
+
* @param {string} type - The entity type
|
|
168
|
+
* @param {string} uuid - The entity UUID
|
|
169
|
+
* @param {string[]} include - Fields to include
|
|
170
|
+
* @returns {Promise<{filePath: string, entity: object}>} The saved file path and entity
|
|
171
|
+
*/
|
|
172
|
+
export async function fetchAndSaveEntity(type, uuid, include = []) {
|
|
173
|
+
const entity = await fetchEntity(type, uuid, include);
|
|
174
|
+
|
|
175
|
+
const labelField = getLabelField(type);
|
|
176
|
+
const label = entity.data?.attributes?.[labelField] || '(untitled)';
|
|
177
|
+
const filePath = saveContent(type, uuid, entity, label);
|
|
178
|
+
|
|
179
|
+
console.log(`Saved: ${label}`);
|
|
180
|
+
console.log(` File: ${filePath}`);
|
|
181
|
+
|
|
182
|
+
if (type === 'media--image') {
|
|
183
|
+
const thumbnail = getThumbnailUrl(entity);
|
|
184
|
+
if (thumbnail) {
|
|
185
|
+
console.log(` Thumbnail: ${thumbnail}`);
|
|
186
|
+
}
|
|
187
|
+
const internalId = getMediaInternalId(entity);
|
|
188
|
+
if (internalId) {
|
|
189
|
+
console.log(` target_id: ${internalId}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { filePath, entity };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Display entity summary after create/update.
|
|
198
|
+
* @param {string} action - The action performed (Created, Updated)
|
|
199
|
+
* @param {string} type - The entity type
|
|
200
|
+
* @param {string} uuid - The entity UUID
|
|
201
|
+
* @param {object} entity - The entity data
|
|
202
|
+
* @param {string} filePath - The saved file path (optional)
|
|
203
|
+
*/
|
|
204
|
+
export function displayEntitySummary(action, type, uuid, entity, filePath) {
|
|
205
|
+
const labelField = getLabelField(type);
|
|
206
|
+
const label = entity.data?.attributes?.[labelField] || '(untitled)';
|
|
207
|
+
|
|
208
|
+
console.log(`${action}: ${label}`);
|
|
209
|
+
console.log(` UUID: ${uuid}`);
|
|
210
|
+
if (filePath) {
|
|
211
|
+
console.log(` File: ${filePath}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (type === 'media--image') {
|
|
215
|
+
const thumbnail = getThumbnailUrl(entity);
|
|
216
|
+
if (thumbnail) {
|
|
217
|
+
console.log(` Thumbnail: ${thumbnail}`);
|
|
218
|
+
}
|
|
219
|
+
const internalId = getMediaInternalId(entity);
|
|
220
|
+
if (internalId) {
|
|
221
|
+
console.log(` target_id: ${internalId}`);
|
|
222
|
+
}
|
|
223
|
+
} else if (type === 'media--document') {
|
|
224
|
+
const fileUrl = getFileUrl(entity);
|
|
225
|
+
if (fileUrl) {
|
|
226
|
+
console.log(` File URL: ${fileUrl}`);
|
|
227
|
+
}
|
|
228
|
+
const internalId = getMediaInternalId(entity);
|
|
229
|
+
if (internalId) {
|
|
230
|
+
console.log(` target_id: ${internalId}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/get.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
/**
|
|
3
|
+
* Fetches content items and saves them locally.
|
|
4
|
+
*/
|
|
5
|
+
import { fetchAndSaveEntity } from './entity.js';
|
|
6
|
+
import { parseContentPath } from './utils.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a string looks like a content file path.
|
|
10
|
+
* @param {string} str
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function isContentPath(str) {
|
|
14
|
+
return str.endsWith('.json') && str.includes('/');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(args) {
|
|
18
|
+
const result = { type: null, uuids: [], include: [] };
|
|
19
|
+
let i = 0;
|
|
20
|
+
|
|
21
|
+
// Check if the first arg is a file path
|
|
22
|
+
if (args.length > 0 && isContentPath(args[0])) {
|
|
23
|
+
const { type, uuid } = parseContentPath(args[0]);
|
|
24
|
+
result.type = type;
|
|
25
|
+
result.uuids.push(uuid);
|
|
26
|
+
i++;
|
|
27
|
+
// Allow additional file paths
|
|
28
|
+
while (i < args.length && !args[i].startsWith('--')) {
|
|
29
|
+
if (isContentPath(args[i])) {
|
|
30
|
+
result.uuids.push(parseContentPath(args[i]).uuid);
|
|
31
|
+
} else {
|
|
32
|
+
result.uuids.push(args[i]);
|
|
33
|
+
}
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
// First positional arg is type
|
|
38
|
+
if (args.length > 0 && !args[0].startsWith('--')) {
|
|
39
|
+
result.type = args[0];
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Collect UUIDs until we hit a flag
|
|
44
|
+
while (i < args.length && !args[i].startsWith('--')) {
|
|
45
|
+
result.uuids.push(args[i]);
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Parse remaining args for --include
|
|
51
|
+
while (i < args.length) {
|
|
52
|
+
if (args[i] === '--include' && i + 1 < args.length) {
|
|
53
|
+
result.include = args[i + 1].split(',');
|
|
54
|
+
i += 2;
|
|
55
|
+
} else {
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function getContent(args) {
|
|
64
|
+
const parsed = parseArgs(args);
|
|
65
|
+
|
|
66
|
+
if (!parsed.type || parsed.uuids.length === 0) {
|
|
67
|
+
console.error(
|
|
68
|
+
'Usage: canvas-jsonapi get <type> <uuid> [<uuid>...] [--include <fields>]',
|
|
69
|
+
);
|
|
70
|
+
console.error(' canvas-jsonapi get <file-path> [<file-path>...]');
|
|
71
|
+
console.error('Example: canvas-jsonapi get page abc-123-def');
|
|
72
|
+
console.error('Example: canvas-jsonapi get page abc-123 def-456 ghi-789');
|
|
73
|
+
console.error(
|
|
74
|
+
'Example: canvas-jsonapi get page abc-123-def --include image,owner',
|
|
75
|
+
);
|
|
76
|
+
console.error(
|
|
77
|
+
'Example: canvas-jsonapi get content/page--landing/abc-123.my-page.json',
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let hasError = false;
|
|
83
|
+
for (const uuid of parsed.uuids) {
|
|
84
|
+
try {
|
|
85
|
+
await fetchAndSaveEntity(parsed.type, uuid, parsed.include);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(`Error fetching ${parsed.type}/${uuid}:`, error.message);
|
|
88
|
+
hasError = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (hasError) {
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global process */
|
|
3
|
+
/**
|
|
4
|
+
* Unified content management CLI for Drupal Canvas / Acquia Source.
|
|
5
|
+
*
|
|
6
|
+
* Usage: canvas-jsonapi <command> [args...]
|
|
7
|
+
*
|
|
8
|
+
* Commands:
|
|
9
|
+
* list <type> List content items (use --types to discover types)
|
|
10
|
+
* get <type> <uuid> [<uuid>...] Fetch and save content items
|
|
11
|
+
* get <file-path> [<file-path>...] Refresh local content from file path
|
|
12
|
+
* create <file-path> Create content from JSON file
|
|
13
|
+
* update <file-path> Update content from JSON file
|
|
14
|
+
* delete <type> <uuid> [<uuid>...] Delete content items
|
|
15
|
+
* upload-image <file-path> [alt] Upload image and create media entity
|
|
16
|
+
* upload-video <file-path> [name] Upload video and create media entity
|
|
17
|
+
* upload-document <file-path> [name] Upload document and create media entity
|
|
18
|
+
* uuid [count] Generate random UUIDs
|
|
19
|
+
* whoami Show OAuth authentication status
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const command = process.argv[2];
|
|
23
|
+
const args = process.argv.slice(3);
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
switch (command) {
|
|
27
|
+
case 'list': {
|
|
28
|
+
const { listContent } = await import('./list.js');
|
|
29
|
+
await listContent(args[0]);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
case 'get': {
|
|
33
|
+
const { getContent } = await import('./get.js');
|
|
34
|
+
await getContent(args);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case 'create': {
|
|
38
|
+
const { createContent } = await import('./create.js');
|
|
39
|
+
await createContent(args[0]);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'update': {
|
|
43
|
+
const { updateContent } = await import('./update.js');
|
|
44
|
+
await updateContent(args[0]);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case 'delete': {
|
|
48
|
+
const { deleteContent } = await import('./delete.js');
|
|
49
|
+
await deleteContent(args);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case 'upload-image': {
|
|
53
|
+
const { uploadImage } = await import('./upload-image.js');
|
|
54
|
+
await uploadImage(args[0], args[1]);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'upload-video': {
|
|
58
|
+
const { uploadVideo } = await import('./upload-video.js');
|
|
59
|
+
await uploadVideo(args[0], args[1]);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'upload-document': {
|
|
63
|
+
const { uploadDocument } = await import('./upload-document.js');
|
|
64
|
+
await uploadDocument(args[0], args[1]);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'uuid': {
|
|
68
|
+
const { generateUuids } = await import('./uuid.js');
|
|
69
|
+
generateUuids(args[0] ? parseInt(args[0], 10) : 1);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
case 'whoami': {
|
|
73
|
+
const { whoami } = await import('./whoami.js');
|
|
74
|
+
await whoami();
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
console.error('Usage: canvas-jsonapi <command> [args...]');
|
|
79
|
+
console.error('');
|
|
80
|
+
console.error('Commands:');
|
|
81
|
+
console.error(
|
|
82
|
+
' list <type> List content items (use --types to discover types)',
|
|
83
|
+
);
|
|
84
|
+
console.error(
|
|
85
|
+
' get <type> <uuid> [<uuid>...] Fetch and save content items',
|
|
86
|
+
);
|
|
87
|
+
console.error(
|
|
88
|
+
' get <file-path> [<file-path>...] Refresh local content from path',
|
|
89
|
+
);
|
|
90
|
+
console.error(
|
|
91
|
+
' create <file-path> Create content from JSON file',
|
|
92
|
+
);
|
|
93
|
+
console.error(
|
|
94
|
+
' update <file-path> Update content from JSON file',
|
|
95
|
+
);
|
|
96
|
+
console.error(
|
|
97
|
+
' delete <type> <uuid> [<uuid>...] Delete content items',
|
|
98
|
+
);
|
|
99
|
+
console.error(
|
|
100
|
+
' upload-image <file-path> [alt] Upload image and create media entity',
|
|
101
|
+
);
|
|
102
|
+
console.error(
|
|
103
|
+
' upload-video <file-path> [name] Upload video and create media entity',
|
|
104
|
+
);
|
|
105
|
+
console.error(
|
|
106
|
+
' upload-document <file-path> [name] Upload document and create media entity',
|
|
107
|
+
);
|
|
108
|
+
console.error(
|
|
109
|
+
' uuid [count] Generate random UUIDs',
|
|
110
|
+
);
|
|
111
|
+
console.error(
|
|
112
|
+
' whoami Show OAuth authentication status',
|
|
113
|
+
);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
main().catch((error) => {
|
|
119
|
+
console.error(error.message);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
package/src/list.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/* global process */
|
|
2
|
+
/**
|
|
3
|
+
* Lists content items of a specific type from Acquia Source.
|
|
4
|
+
*/
|
|
5
|
+
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
authenticatedFetch,
|
|
9
|
+
getApiBaseUrl,
|
|
10
|
+
getEntityEndpoint,
|
|
11
|
+
} from './client.js';
|
|
12
|
+
|
|
13
|
+
async function listTypes() {
|
|
14
|
+
try {
|
|
15
|
+
// Fetch the API index to get available types
|
|
16
|
+
const response = await authenticatedFetch(getApiBaseUrl());
|
|
17
|
+
const contentType = response.headers.get('content-type') || '';
|
|
18
|
+
|
|
19
|
+
// Handle non-JSON responses
|
|
20
|
+
if (
|
|
21
|
+
!contentType.includes('application/vnd.api+json') &&
|
|
22
|
+
!contentType.includes('application/json')
|
|
23
|
+
) {
|
|
24
|
+
const text = await response.text();
|
|
25
|
+
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
|
26
|
+
console.error(text.slice(0, 500));
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const body = await response.json();
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
|
34
|
+
console.error(JSON.stringify(body, null, 2));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log('Available types:');
|
|
39
|
+
Object.keys(body?.links || {})
|
|
40
|
+
.filter((key) => key !== 'self')
|
|
41
|
+
.sort()
|
|
42
|
+
.forEach((key) => console.log(` ${key}`));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error('Error fetching types:', error.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get the file relationship field name for a media type, if applicable.
|
|
51
|
+
* @param {string} type - The entity type
|
|
52
|
+
* @returns {string|null} The file relationship field name or null
|
|
53
|
+
*/
|
|
54
|
+
function getMediaFileField(type) {
|
|
55
|
+
if (type === 'media--image') return 'media_image';
|
|
56
|
+
if (type === 'media--document') return 'media_file';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a map from media entity ID to filename from JSON:API included data.
|
|
62
|
+
* @param {object[]} included - The included array from JSON:API response
|
|
63
|
+
* @param {object[]} items - The data items from the response
|
|
64
|
+
* @param {string} fileField - The relationship field name
|
|
65
|
+
* @returns {Map<string, string>} Map of media entity ID to filename
|
|
66
|
+
*/
|
|
67
|
+
function buildFilenameMap(included, items, fileField) {
|
|
68
|
+
const map = new Map();
|
|
69
|
+
if (!included || !fileField) return map;
|
|
70
|
+
|
|
71
|
+
// Build a lookup from file UUID to filename
|
|
72
|
+
const fileLookup = new Map();
|
|
73
|
+
for (const inc of included) {
|
|
74
|
+
if (inc.type === 'file--file' && inc.attributes?.filename) {
|
|
75
|
+
fileLookup.set(inc.id, inc.attributes.filename);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Map each media item to its file's filename
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
const fileRef = item.relationships?.[fileField]?.data;
|
|
82
|
+
if (fileRef?.id && fileLookup.has(fileRef.id)) {
|
|
83
|
+
map.set(item.id, fileLookup.get(fileRef.id));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return map;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function listContent(type) {
|
|
91
|
+
if (!type) {
|
|
92
|
+
console.error('Usage: canvas-jsonapi list <type>');
|
|
93
|
+
console.error('Example: canvas-jsonapi list page');
|
|
94
|
+
console.error('');
|
|
95
|
+
console.error('To discover available types, use:');
|
|
96
|
+
console.error(' canvas-jsonapi list --types');
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Special case: list available types
|
|
101
|
+
if (type === '--types') {
|
|
102
|
+
await listTypes();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Determine the label field based on entity type
|
|
108
|
+
let labelField = 'title';
|
|
109
|
+
if (type.startsWith('media--')) {
|
|
110
|
+
labelField = 'name';
|
|
111
|
+
} else if (type.startsWith('file--')) {
|
|
112
|
+
labelField = 'filename';
|
|
113
|
+
} else if (type.startsWith('taxonomy_term--')) {
|
|
114
|
+
labelField = 'name';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check if we should include file info for media types
|
|
118
|
+
const fileField = getMediaFileField(type);
|
|
119
|
+
|
|
120
|
+
// Build query to get essential fields only
|
|
121
|
+
const params = new DrupalJsonApiParams();
|
|
122
|
+
const fields = [labelField];
|
|
123
|
+
if (fileField) {
|
|
124
|
+
// Include the relationship field so it appears in the response
|
|
125
|
+
fields.push(fileField);
|
|
126
|
+
params.addInclude([fileField]);
|
|
127
|
+
params.addFields('file--file', ['filename']);
|
|
128
|
+
}
|
|
129
|
+
params.addFields(type, fields);
|
|
130
|
+
params.addPageLimit(50);
|
|
131
|
+
|
|
132
|
+
const queryString = params.getQueryString();
|
|
133
|
+
// Get the correct endpoint URL from the API root mapping
|
|
134
|
+
const endpoint = await getEntityEndpoint(type);
|
|
135
|
+
let url = `${endpoint}?${queryString}`;
|
|
136
|
+
|
|
137
|
+
const allItems = [];
|
|
138
|
+
let allIncluded = [];
|
|
139
|
+
|
|
140
|
+
// Paginate through all results
|
|
141
|
+
while (url) {
|
|
142
|
+
const response = await authenticatedFetch(url);
|
|
143
|
+
const contentType = response.headers.get('content-type') || '';
|
|
144
|
+
|
|
145
|
+
// Handle non-JSON responses
|
|
146
|
+
if (
|
|
147
|
+
!contentType.includes('application/vnd.api+json') &&
|
|
148
|
+
!contentType.includes('application/json')
|
|
149
|
+
) {
|
|
150
|
+
const text = await response.text();
|
|
151
|
+
console.error(`HTTP ${response.status}: ${response.statusText}`);
|
|
152
|
+
console.error(text.slice(0, 500));
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const result = await response.json();
|
|
157
|
+
|
|
158
|
+
// Check for errors in response
|
|
159
|
+
if (!response.ok || result.errors) {
|
|
160
|
+
console.error(`Error listing ${type}:`);
|
|
161
|
+
console.error(JSON.stringify(result, null, 2));
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
allItems.push(...(result.data || []));
|
|
166
|
+
if (result.included) {
|
|
167
|
+
allIncluded.push(...result.included);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Follow next link for pagination
|
|
171
|
+
url = result.links?.next?.href || null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build filename map for media types
|
|
175
|
+
const filenameMap = buildFilenameMap(allIncluded, allItems, fileField);
|
|
176
|
+
|
|
177
|
+
// Output summary: uuid - label [filename]
|
|
178
|
+
if (allItems.length === 0) {
|
|
179
|
+
console.log('No items found.');
|
|
180
|
+
} else {
|
|
181
|
+
for (const item of allItems) {
|
|
182
|
+
const label = item.attributes?.[labelField] || '(untitled)';
|
|
183
|
+
const filename = filenameMap.get(item.id);
|
|
184
|
+
if (filename) {
|
|
185
|
+
const title = filename === label ? '' : ` (Title: ${label})`;
|
|
186
|
+
console.log(`${item.id} ${filename}${title}`);
|
|
187
|
+
} else {
|
|
188
|
+
console.log(`${item.id} ${label}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
console.log(`\n${allItems.length} item(s)`);
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`Error listing ${type}:`, error.message);
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
}
|