@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/update.js ADDED
@@ -0,0 +1,140 @@
1
+ /* global process */
2
+ /**
3
+ * Updates a content item from a local JSON file.
4
+ */
5
+ import { authenticatedFetch, getEntityUrl } from './client.js';
6
+ import {
7
+ displayEntitySummary,
8
+ fetchEntity,
9
+ getLabelField,
10
+ stringifyComponentInputs,
11
+ } from './entity.js';
12
+ import { readContent, saveContent } from './utils.js';
13
+ import {
14
+ formatValidationErrors,
15
+ validateContentComponents,
16
+ } from './validate.js';
17
+
18
+ export async function updateContent(filePath) {
19
+ if (!filePath) {
20
+ console.error('Usage: canvas-jsonapi update <file-path>');
21
+ console.error('Example: canvas-jsonapi update content/page/abc-123.json');
22
+ process.exit(1);
23
+ }
24
+
25
+ try {
26
+ // Read local file
27
+ const data = readContent(filePath);
28
+
29
+ // Extract type and UUID from the data itself (more reliable)
30
+ const type = data.data?.type || data.type;
31
+ const uuid = data.data?.id || data.id;
32
+
33
+ if (!type || !uuid) {
34
+ throw new Error('File must contain data.type and data.id fields');
35
+ }
36
+
37
+ // Validate component inputs before sending to server
38
+ const validation = validateContentComponents(data);
39
+ if (!validation.valid) {
40
+ console.error(formatValidationErrors(validation.errors));
41
+ process.exit(1);
42
+ }
43
+ if (validation.warnings?.length > 0) {
44
+ for (const warning of validation.warnings) {
45
+ console.warn(`Warning: ${warning}`);
46
+ }
47
+ }
48
+
49
+ // Prepare update body - ensure proper JSON:API format
50
+ const attributes = data.data?.attributes || data.attributes;
51
+
52
+ // Strip read-only fields that the API won't accept on PATCH
53
+ const readOnlyFields = [
54
+ 'changed',
55
+ 'created',
56
+ 'revision_created',
57
+ 'default_langcode',
58
+ 'revision_translation_affected',
59
+ 'metatags',
60
+ 'path',
61
+ ];
62
+ const writableAttributes = { ...attributes };
63
+ for (const field of readOnlyFields) {
64
+ delete writableAttributes[field];
65
+ }
66
+
67
+ const updateBody = {
68
+ data: {
69
+ type: type,
70
+ id: uuid,
71
+ attributes: stringifyComponentInputs(writableAttributes),
72
+ },
73
+ };
74
+
75
+ // Include writable relationships (e.g., brands, categories, banner_image)
76
+ const relationships = data.data?.relationships || data.relationships;
77
+ if (relationships) {
78
+ const readOnlyRelationships = [
79
+ 'node_type',
80
+ 'revision_uid',
81
+ 'uid',
82
+ 'vid',
83
+ 'revision_user',
84
+ 'parent',
85
+ 'owner',
86
+ ];
87
+ const writableRelationships = {};
88
+ for (const [key, value] of Object.entries(relationships)) {
89
+ if (!readOnlyRelationships.includes(key) && value?.data !== undefined) {
90
+ writableRelationships[key] = { data: value.data };
91
+ }
92
+ }
93
+ if (Object.keys(writableRelationships).length > 0) {
94
+ updateBody.data.relationships = writableRelationships;
95
+ }
96
+ }
97
+
98
+ // Send update request using the correct endpoint from API root
99
+ const url = await getEntityUrl(type, uuid);
100
+ const response = await authenticatedFetch(url, {
101
+ method: 'PATCH',
102
+ headers: { 'Content-Type': 'application/vnd.api+json' },
103
+ body: JSON.stringify(updateBody),
104
+ });
105
+ const contentType = response.headers.get('content-type') || '';
106
+
107
+ // Handle non-JSON responses
108
+ if (
109
+ !contentType.includes('application/vnd.api+json') &&
110
+ !contentType.includes('application/json')
111
+ ) {
112
+ const text = await response.text();
113
+ console.error(`HTTP ${response.status}: ${response.statusText}`);
114
+ console.error(text.slice(0, 500));
115
+ process.exit(1);
116
+ }
117
+
118
+ const result = await response.json();
119
+
120
+ // Check for errors in response
121
+ if (!response.ok || result.errors) {
122
+ console.error(`Error updating ${type}/${uuid}:`);
123
+ console.error(JSON.stringify(result, null, 2));
124
+ process.exit(1);
125
+ }
126
+
127
+ // Fetch the full entity to get complete data
128
+ const fullEntity = await fetchEntity(type, uuid);
129
+
130
+ // Save the updated entity with label
131
+ const labelField = getLabelField(type);
132
+ const label = fullEntity.data?.attributes?.[labelField] || '(untitled)';
133
+ const savedPath = saveContent(type, uuid, fullEntity, label);
134
+
135
+ displayEntitySummary('Updated', type, uuid, fullEntity, savedPath);
136
+ } catch (error) {
137
+ console.error(`Error updating from ${filePath}:`, error.message);
138
+ process.exit(1);
139
+ }
140
+ }
@@ -0,0 +1,125 @@
1
+ /* global process */
2
+ /**
3
+ * Uploads a document file and creates a media entity.
4
+ */
5
+ import { readFileSync } from 'fs';
6
+ import { basename } from 'path';
7
+
8
+ import { authenticatedFetch, getApiBaseUrl } from './client.js';
9
+ import { displayEntitySummary, fetchEntity } from './entity.js';
10
+ import { saveContent } from './utils.js';
11
+
12
+ export async function uploadDocument(filePath, name) {
13
+ if (!filePath) {
14
+ console.error('Usage: canvas-jsonapi upload-document <file-path> [name]');
15
+ console.error(
16
+ 'Example: canvas-jsonapi upload-document fonts/AbsaraSans.woff2 "AbsaraSans Regular"',
17
+ );
18
+ process.exit(1);
19
+ }
20
+
21
+ try {
22
+ const filename = basename(filePath);
23
+ const fileData = readFileSync(filePath);
24
+ const mediaName = name || filename;
25
+
26
+ console.log(`Uploading: ${filename}`);
27
+
28
+ // Step 1: Upload the file binary
29
+ const fileUploadUrl = `${getApiBaseUrl()}/media/document/media_file`;
30
+ const fileResponse = await authenticatedFetch(fileUploadUrl, {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/octet-stream',
34
+ 'Content-Disposition': `file; filename="${filename}"`,
35
+ Accept: 'application/vnd.api+json',
36
+ },
37
+ body: fileData,
38
+ });
39
+
40
+ if (!fileResponse.ok) {
41
+ const text = await fileResponse.text();
42
+ console.error(`File upload failed: ${fileResponse.status}`);
43
+ console.error(text.slice(0, 1000));
44
+ process.exit(1);
45
+ }
46
+
47
+ const fileResult = await fileResponse.json();
48
+ const fileId = fileResult.data?.id;
49
+ const fileUrl = fileResult.data?.attributes?.uri?.url;
50
+
51
+ if (!fileId) {
52
+ throw new Error('No file ID returned from upload');
53
+ }
54
+
55
+ // Step 2: Create media entity
56
+ const mediaUrl = `${getApiBaseUrl()}/media/document`;
57
+ const mediaBody = {
58
+ data: {
59
+ type: 'media--document',
60
+ attributes: {
61
+ name: mediaName,
62
+ status: true,
63
+ },
64
+ relationships: {
65
+ media_file: {
66
+ data: {
67
+ type: 'file--file',
68
+ id: fileId,
69
+ },
70
+ },
71
+ },
72
+ },
73
+ };
74
+
75
+ const mediaResponse = await authenticatedFetch(mediaUrl, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/vnd.api+json',
79
+ Accept: 'application/vnd.api+json',
80
+ },
81
+ body: JSON.stringify(mediaBody),
82
+ });
83
+
84
+ if (!mediaResponse.ok) {
85
+ const text = await mediaResponse.text();
86
+ console.error(`Media creation failed: ${mediaResponse.status}`);
87
+ console.error(text.slice(0, 1000));
88
+ process.exit(1);
89
+ }
90
+
91
+ const mediaResult = await mediaResponse.json();
92
+ const mediaUuid = mediaResult.data?.id;
93
+
94
+ if (!mediaUuid) {
95
+ throw new Error('No media UUID returned from API');
96
+ }
97
+
98
+ // Fetch the full media entity with file includes
99
+ const fullEntity = await fetchEntity('media--document', mediaUuid);
100
+
101
+ // Save locally with filename as label
102
+ const savedPath = saveContent(
103
+ 'media--document',
104
+ mediaUuid,
105
+ fullEntity,
106
+ mediaName,
107
+ );
108
+
109
+ displayEntitySummary(
110
+ 'Uploaded',
111
+ 'media--document',
112
+ mediaUuid,
113
+ fullEntity,
114
+ savedPath,
115
+ );
116
+
117
+ // Return the file URL for programmatic use
118
+ const entityFileUrl =
119
+ fullEntity?.included?.[0]?.attributes?.uri?.url || fileUrl;
120
+ return { uuid: mediaUuid, fileUrl: entityFileUrl };
121
+ } catch (error) {
122
+ console.error(`Error uploading ${filePath}:`, error.message);
123
+ process.exit(1);
124
+ }
125
+ }
@@ -0,0 +1,122 @@
1
+ /* global process */
2
+ /**
3
+ * Uploads an image file and creates a media entity.
4
+ */
5
+ import { readFileSync } from 'fs';
6
+ import { basename } from 'path';
7
+
8
+ import { authenticatedFetch, getApiBaseUrl } from './client.js';
9
+ import { displayEntitySummary, fetchEntity } from './entity.js';
10
+ import { saveContent } from './utils.js';
11
+
12
+ export async function uploadImage(filePath, altText) {
13
+ if (!filePath) {
14
+ console.error('Usage: canvas-jsonapi upload-image <file-path> [alt-text]');
15
+ console.error(
16
+ 'Example: canvas-jsonapi upload-image images/photo.jpg "Photo description"',
17
+ );
18
+ process.exit(1);
19
+ }
20
+
21
+ try {
22
+ const filename = basename(filePath);
23
+ const fileData = readFileSync(filePath);
24
+ const alt = altText || filename;
25
+
26
+ console.log(`Uploading: ${filename}`);
27
+
28
+ // Step 1: Upload the file binary
29
+ const fileUploadUrl = `${getApiBaseUrl()}/media/image/media_image`;
30
+ const fileResponse = await authenticatedFetch(fileUploadUrl, {
31
+ method: 'POST',
32
+ headers: {
33
+ 'Content-Type': 'application/octet-stream',
34
+ 'Content-Disposition': `file; filename="${filename}"`,
35
+ Accept: 'application/vnd.api+json',
36
+ },
37
+ body: fileData,
38
+ });
39
+
40
+ if (!fileResponse.ok) {
41
+ const text = await fileResponse.text();
42
+ console.error(`File upload failed: ${fileResponse.status}`);
43
+ console.error(text.slice(0, 1000));
44
+ process.exit(1);
45
+ }
46
+
47
+ const fileResult = await fileResponse.json();
48
+ const fileId = fileResult.data?.id;
49
+
50
+ if (!fileId) {
51
+ throw new Error('No file ID returned from upload');
52
+ }
53
+
54
+ // Step 2: Create media entity
55
+ const mediaUrl = `${getApiBaseUrl()}/media/image`;
56
+ const mediaBody = {
57
+ data: {
58
+ type: 'media--image',
59
+ attributes: {
60
+ name: filename,
61
+ status: true,
62
+ },
63
+ relationships: {
64
+ media_image: {
65
+ data: {
66
+ type: 'file--file',
67
+ id: fileId,
68
+ meta: {
69
+ alt: alt,
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ const mediaResponse = await authenticatedFetch(mediaUrl, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/vnd.api+json',
81
+ Accept: 'application/vnd.api+json',
82
+ },
83
+ body: JSON.stringify(mediaBody),
84
+ });
85
+
86
+ if (!mediaResponse.ok) {
87
+ const text = await mediaResponse.text();
88
+ console.error(`Media creation failed: ${mediaResponse.status}`);
89
+ console.error(text.slice(0, 1000));
90
+ process.exit(1);
91
+ }
92
+
93
+ const mediaResult = await mediaResponse.json();
94
+ const mediaUuid = mediaResult.data?.id;
95
+
96
+ if (!mediaUuid) {
97
+ throw new Error('No media UUID returned from API');
98
+ }
99
+
100
+ // Fetch the full media entity with file includes
101
+ const fullEntity = await fetchEntity('media--image', mediaUuid);
102
+
103
+ // Save locally with filename as label
104
+ const savedPath = saveContent(
105
+ 'media--image',
106
+ mediaUuid,
107
+ fullEntity,
108
+ filename,
109
+ );
110
+
111
+ displayEntitySummary(
112
+ 'Uploaded',
113
+ 'media--image',
114
+ mediaUuid,
115
+ fullEntity,
116
+ savedPath,
117
+ );
118
+ } catch (error) {
119
+ console.error(`Error uploading ${filePath}:`, error.message);
120
+ process.exit(1);
121
+ }
122
+ }
@@ -0,0 +1,127 @@
1
+ /* global process */
2
+ /**
3
+ * Uploads a video file and creates a media entity.
4
+ */
5
+ import { readFileSync } from 'fs';
6
+ import { basename } from 'path';
7
+
8
+ import { authenticatedFetch, getApiBaseUrl } from './client.js';
9
+ import { displayEntitySummary, fetchEntity } from './entity.js';
10
+ import { saveContent } from './utils.js';
11
+
12
+ export async function uploadVideo(filePath, name) {
13
+ if (!filePath) {
14
+ console.error('Usage: canvas-jsonapi upload-video <file-path> [name]');
15
+ console.error(
16
+ 'Example: canvas-jsonapi upload-video videos/hero.mp4 "Hero background video"',
17
+ );
18
+ process.exit(1);
19
+ }
20
+
21
+ try {
22
+ const filename = basename(filePath);
23
+ const fileData = readFileSync(filePath);
24
+ const mediaName = name || filename;
25
+
26
+ console.log(
27
+ `Uploading: ${filename} (${(fileData.length / 1024 / 1024).toFixed(1)} MB)`,
28
+ );
29
+
30
+ // Step 1: Upload the file binary
31
+ const fileUploadUrl = `${getApiBaseUrl()}/media/video/media_video_file`;
32
+ const fileResponse = await authenticatedFetch(fileUploadUrl, {
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/octet-stream',
36
+ 'Content-Disposition': `file; filename="${filename}"`,
37
+ Accept: 'application/vnd.api+json',
38
+ },
39
+ body: fileData,
40
+ });
41
+
42
+ if (!fileResponse.ok) {
43
+ const text = await fileResponse.text();
44
+ console.error(`File upload failed: ${fileResponse.status}`);
45
+ console.error(text.slice(0, 1000));
46
+ process.exit(1);
47
+ }
48
+
49
+ const fileResult = await fileResponse.json();
50
+ const fileId = fileResult.data?.id;
51
+ const fileUrl = fileResult.data?.attributes?.uri?.url;
52
+
53
+ if (!fileId) {
54
+ throw new Error('No file ID returned from upload');
55
+ }
56
+
57
+ // Step 2: Create media entity
58
+ const mediaUrl = `${getApiBaseUrl()}/media/video`;
59
+ const mediaBody = {
60
+ data: {
61
+ type: 'media--video',
62
+ attributes: {
63
+ name: mediaName,
64
+ status: true,
65
+ },
66
+ relationships: {
67
+ media_video_file: {
68
+ data: {
69
+ type: 'file--file',
70
+ id: fileId,
71
+ },
72
+ },
73
+ },
74
+ },
75
+ };
76
+
77
+ const mediaResponse = await authenticatedFetch(mediaUrl, {
78
+ method: 'POST',
79
+ headers: {
80
+ 'Content-Type': 'application/vnd.api+json',
81
+ Accept: 'application/vnd.api+json',
82
+ },
83
+ body: JSON.stringify(mediaBody),
84
+ });
85
+
86
+ if (!mediaResponse.ok) {
87
+ const text = await mediaResponse.text();
88
+ console.error(`Media creation failed: ${mediaResponse.status}`);
89
+ console.error(text.slice(0, 1000));
90
+ process.exit(1);
91
+ }
92
+
93
+ const mediaResult = await mediaResponse.json();
94
+ const mediaUuid = mediaResult.data?.id;
95
+
96
+ if (!mediaUuid) {
97
+ throw new Error('No media UUID returned from API');
98
+ }
99
+
100
+ // Fetch the full media entity with file includes
101
+ const fullEntity = await fetchEntity('media--video', mediaUuid);
102
+
103
+ // Save locally
104
+ const savedPath = saveContent(
105
+ 'media--video',
106
+ mediaUuid,
107
+ fullEntity,
108
+ mediaName,
109
+ );
110
+
111
+ displayEntitySummary(
112
+ 'Uploaded',
113
+ 'media--video',
114
+ mediaUuid,
115
+ fullEntity,
116
+ savedPath,
117
+ );
118
+
119
+ // Return the file URL for programmatic use
120
+ const entityFileUrl =
121
+ fullEntity?.included?.[0]?.attributes?.uri?.url || fileUrl;
122
+ return { uuid: mediaUuid, fileUrl: entityFileUrl };
123
+ } catch (error) {
124
+ console.error(`Error uploading ${filePath}:`, error.message);
125
+ process.exit(1);
126
+ }
127
+ }
package/src/utils.js ADDED
@@ -0,0 +1,147 @@
1
+ /* global process */
2
+ /**
3
+ * Common utility functions for content management scripts.
4
+ */
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from 'fs';
13
+ import { dirname, join } from 'path';
14
+
15
+ export const CONTENT_DIR = join(process.cwd(), 'content');
16
+
17
+ /**
18
+ * Converts a string to a safe filename slug
19
+ * @param {string} str - String to slugify
20
+ * @returns {string} Safe filename string
21
+ */
22
+ export function slugify(str) {
23
+ return str
24
+ .toLowerCase()
25
+ .normalize('NFD')
26
+ .replace(/[\u0300-\u036f]/g, '') // Remove diacritics
27
+ .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
28
+ .replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
29
+ .slice(0, 50); // Limit length
30
+ }
31
+
32
+ /**
33
+ * Ensures a directory exists, creating it recursively if needed
34
+ * @param {string} dirPath - Directory path to ensure
35
+ */
36
+ export function ensureDir(dirPath) {
37
+ if (!existsSync(dirPath)) {
38
+ mkdirSync(dirPath, { recursive: true });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Gets the file path for a content item
44
+ * @param {string} type - Entity type (e.g., "page--landing")
45
+ * @param {string} uuid - Content UUID
46
+ * @param {string} [label] - Optional label to append to filename
47
+ * @returns {string} Full file path
48
+ */
49
+ export function getContentPath(type, uuid, label) {
50
+ const filename = label ? `${uuid}.${slugify(label)}.json` : `${uuid}.json`;
51
+ return join(CONTENT_DIR, type, filename);
52
+ }
53
+
54
+ /**
55
+ * Gets the directory path for a content type
56
+ * @param {string} type - Entity type (e.g., "page--landing")
57
+ * @returns {string} Directory path
58
+ */
59
+ export function getTypeDir(type) {
60
+ return join(CONTENT_DIR, type);
61
+ }
62
+
63
+ /**
64
+ * Finds a content file by UUID (ignoring the label part of filename)
65
+ * @param {string} type - Entity type
66
+ * @param {string} uuid - Content UUID
67
+ * @returns {string|null} Full file path or null if not found
68
+ */
69
+ export function findContentByUuid(type, uuid) {
70
+ const typeDir = getTypeDir(type);
71
+ if (!existsSync(typeDir)) {
72
+ return null;
73
+ }
74
+ const files = readdirSync(typeDir);
75
+ const match = files.find(
76
+ (f) => f.startsWith(`${uuid}.`) && f.endsWith('.json'),
77
+ );
78
+ return match ? join(typeDir, match) : null;
79
+ }
80
+
81
+ /**
82
+ * Saves content data to a local JSON file
83
+ * @param {string} type - Entity type
84
+ * @param {string} uuid - Content UUID
85
+ * @param {object} data - Content data to save
86
+ * @param {string} [label] - Optional label to append to filename
87
+ * @returns {string} Path to saved file
88
+ */
89
+ export function saveContent(type, uuid, data, label) {
90
+ // Remove old file if it exists with a different name
91
+ const existingFile = findContentByUuid(type, uuid);
92
+ const newFilePath = getContentPath(type, uuid, label);
93
+
94
+ if (existingFile && existingFile !== newFilePath) {
95
+ try {
96
+ unlinkSync(existingFile);
97
+ } catch {
98
+ // Ignore errors removing old file
99
+ }
100
+ }
101
+
102
+ ensureDir(dirname(newFilePath));
103
+ writeFileSync(newFilePath, JSON.stringify(data, null, 2));
104
+ return newFilePath;
105
+ }
106
+
107
+ /**
108
+ * Reads content data from a local JSON file
109
+ * @param {string} filePath - Path to JSON file
110
+ * @returns {object} Parsed content data
111
+ */
112
+ export function readContent(filePath) {
113
+ if (!existsSync(filePath)) {
114
+ throw new Error(`File not found: ${filePath}`);
115
+ }
116
+ const raw = readFileSync(filePath, 'utf-8');
117
+ return JSON.parse(raw);
118
+ }
119
+
120
+ /**
121
+ * Extracts type and UUID from a file path
122
+ * @param {string} filePath - Path to content file
123
+ * @returns {{type: string, uuid: string}} Extracted type and UUID
124
+ */
125
+ export function parseContentPath(filePath) {
126
+ // Match pattern: content/<type>/<uuid>.<label>.json or content/<type>/<uuid>.json
127
+ const match = filePath.match(
128
+ /content\/([^/]+)\/([a-f0-9-]+)(?:\.[^/]+)?\.json$/i,
129
+ );
130
+ if (!match) {
131
+ throw new Error(`Invalid content file path: ${filePath}`);
132
+ }
133
+ return {
134
+ type: match[1],
135
+ uuid: match[2],
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Generates a temporary filename for new content
141
+ * @param {string} type - Entity type
142
+ * @returns {string} Temporary file path with placeholder UUID
143
+ */
144
+ export function getTempContentPath(type) {
145
+ const timestamp = Date.now();
146
+ return getContentPath(type, `new-${timestamp}`);
147
+ }
package/src/uuid.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Generates random UUID v4 values.
3
+ */
4
+ import { randomUUID } from 'crypto';
5
+
6
+ export function generateUuids(count = 1) {
7
+ for (let i = 0; i < count; i++) {
8
+ console.log(randomUUID());
9
+ }
10
+ }