@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/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
+ }