@terminalfour/terminalfour-js 1.0.3 → 1.1.1
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/dist/cjs/content-cache.d.ts +38 -0
- package/dist/cjs/content-cache.js +42 -0
- package/dist/cjs/element-resolver.d.ts +16 -0
- package/dist/cjs/element-resolver.js +77 -1
- package/dist/cjs/handlebars.js +1 -0
- package/dist/cjs/models/content-item.js +14 -1
- package/dist/cjs/models/media-category-item.js +2 -0
- package/dist/cjs/models/media-item.js +1 -0
- package/dist/cjs/models/section-item.js +1 -0
- package/dist/cjs/resources/content-resource.d.ts +7 -11
- package/dist/cjs/resources/content-resource.js +38 -79
- package/dist/cjs/resources/content-type-resource.d.ts +2 -6
- package/dist/cjs/resources/content-type-resource.js +20 -6
- package/dist/cjs/resources/group-resource.js +1 -0
- package/dist/cjs/resources/list-resource.d.ts +2 -6
- package/dist/cjs/resources/list-resource.js +9 -6
- package/dist/cjs/resources/media-resource.js +9 -2
- package/dist/cjs/resources/media-type-resource.js +1 -0
- package/dist/cjs/resources/navigation-resource.d.ts +17 -0
- package/dist/cjs/resources/navigation-resource.js +14 -2
- package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
- package/dist/cjs/resources/page-layout-resource.js +13 -2
- package/dist/cjs/section-ref.d.ts +3 -1
- package/dist/cjs/section-ref.js +11 -5
- package/dist/cjs/t4-client.d.ts +1 -0
- package/dist/cjs/t4-client.js +6 -1
- package/dist/cjs/utils.d.ts +56 -0
- package/dist/cjs/utils.js +66 -0
- package/dist/esm/content-cache.d.ts +38 -0
- package/dist/esm/content-cache.js +38 -0
- package/dist/esm/element-resolver.d.ts +16 -0
- package/dist/esm/element-resolver.js +77 -1
- package/dist/esm/handlebars.js +2 -1
- package/dist/esm/models/content-item.js +15 -2
- package/dist/esm/models/media-category-item.js +2 -0
- package/dist/esm/models/media-item.js +2 -1
- package/dist/esm/models/section-item.js +2 -1
- package/dist/esm/resources/content-resource.d.ts +7 -11
- package/dist/esm/resources/content-resource.js +39 -80
- package/dist/esm/resources/content-type-resource.d.ts +2 -6
- package/dist/esm/resources/content-type-resource.js +21 -7
- package/dist/esm/resources/group-resource.js +2 -1
- package/dist/esm/resources/list-resource.d.ts +2 -6
- package/dist/esm/resources/list-resource.js +10 -7
- package/dist/esm/resources/media-resource.js +10 -3
- package/dist/esm/resources/media-type-resource.js +2 -1
- package/dist/esm/resources/navigation-resource.d.ts +17 -0
- package/dist/esm/resources/navigation-resource.js +14 -2
- package/dist/esm/resources/page-layout-resource.d.ts +11 -0
- package/dist/esm/resources/page-layout-resource.js +14 -3
- package/dist/esm/section-ref.d.ts +3 -1
- package/dist/esm/section-ref.js +12 -6
- package/dist/esm/t4-client.d.ts +1 -0
- package/dist/esm/t4-client.js +6 -1
- package/dist/esm/utils.d.ts +56 -0
- package/dist/esm/utils.js +59 -0
- package/docs/content-types.md +3 -3
- package/docs/content.md +13 -0
- package/docs/error-handling.md +29 -1
- package/docs/getting-started.md +4 -0
- package/docs/lists.md +2 -2
- package/docs/navigation.md +30 -1
- package/docs/page-layouts.md +26 -0
- package/package.json +1 -1
|
@@ -79,11 +79,87 @@ export class ElementResolver {
|
|
|
79
79
|
case 'Keyword Selector':
|
|
80
80
|
return this.resolveKeywordSelector(value, element.listId, language);
|
|
81
81
|
case 'Repeater':
|
|
82
|
-
return value; // handled separately in buildElements
|
|
82
|
+
return value; // handled separately in buildElements / buildRepeaterValue
|
|
83
83
|
default:
|
|
84
84
|
return value;
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Builds the T4 elements map from developer-friendly field names and values.
|
|
89
|
+
* Resolves list values, dates, repeaters, etc. automatically.
|
|
90
|
+
*
|
|
91
|
+
* Shared by both write paths: `ContentResource` (create/update) and
|
|
92
|
+
* `ContentItem.save()`. Keeping a single implementation here prevents the two
|
|
93
|
+
* paths from drifting — the reason repeaters previously only resolved on one
|
|
94
|
+
* of them.
|
|
95
|
+
*/
|
|
96
|
+
async buildElements(fields, elements, name, language, sectionId, context) {
|
|
97
|
+
const result = {};
|
|
98
|
+
// Name element
|
|
99
|
+
const nameEl = elements.find((el) => el.name.toLowerCase() === 'name');
|
|
100
|
+
if (nameEl) {
|
|
101
|
+
result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
|
|
102
|
+
}
|
|
103
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
104
|
+
const fieldLower = fieldName.toLowerCase();
|
|
105
|
+
const element = elements.find((el) => el.name.toLowerCase() === fieldLower
|
|
106
|
+
|| (el.alias && el.alias.toLowerCase() === fieldLower));
|
|
107
|
+
if (!element) {
|
|
108
|
+
const validNames = elements
|
|
109
|
+
.filter((el) => el.name.toLowerCase() !== 'name')
|
|
110
|
+
.map((el) => `"${el.alias || el.name}"`)
|
|
111
|
+
.join(', ');
|
|
112
|
+
throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
|
|
113
|
+
}
|
|
114
|
+
const key = `${element.name}#${element.id}:${element.type}`;
|
|
115
|
+
// Repeater — special handling (no maxSize validation)
|
|
116
|
+
const typeName = await this.typeRegistry.getNameById(element.type);
|
|
117
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
118
|
+
result[key] = await this.buildRepeaterValue(value, element, language, sectionId);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const resolved = await this.resolveValue(value, element, language, elements, context);
|
|
122
|
+
// Validate maxSize on the resolved value (what actually gets sent to the API)
|
|
123
|
+
if (element.maxSize) {
|
|
124
|
+
const resolvedStr = String(resolved ?? '');
|
|
125
|
+
if (resolvedStr.length > element.maxSize) {
|
|
126
|
+
const friendlyName = element.alias || element.name;
|
|
127
|
+
throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
result[key] = resolved;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Builds a repeater value array from developer-friendly input.
|
|
136
|
+
* Each repeater item gets its own element key resolution using the
|
|
137
|
+
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
138
|
+
*/
|
|
139
|
+
async buildRepeaterValue(items, element, language, sectionId) {
|
|
140
|
+
const config = element.contentTypeElementConfiguration;
|
|
141
|
+
const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
|
|
142
|
+
if (!repeaterElements || repeaterElements.length === 0)
|
|
143
|
+
return items;
|
|
144
|
+
const result = [];
|
|
145
|
+
for (const item of items) {
|
|
146
|
+
const repeaterId = -Math.floor(Math.random() * 100000);
|
|
147
|
+
// Repeater items use their own repeaterId as fromContentId for SS links
|
|
148
|
+
const repeaterContext = {
|
|
149
|
+
fromSectionId: sectionId,
|
|
150
|
+
fromContentId: repeaterId,
|
|
151
|
+
};
|
|
152
|
+
const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, sectionId, repeaterContext);
|
|
153
|
+
result.push({
|
|
154
|
+
repeaterId,
|
|
155
|
+
repeaterContent: {
|
|
156
|
+
name: item.name,
|
|
157
|
+
elements,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
87
163
|
// ── List fetching ──
|
|
88
164
|
async getList(listId, language) {
|
|
89
165
|
const cached = this.listCache.get(listId);
|
package/dist/esm/handlebars.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DEFAULT_CACHE_TTL, getCacheEpoch } from './utils.js';
|
|
1
|
+
import { DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from './utils.js';
|
|
2
2
|
/**
|
|
3
3
|
* A mutable Handlebars item (helper or partial).
|
|
4
4
|
* Modify `name` and/or `code`, then call `save()` to persist.
|
|
@@ -22,6 +22,7 @@ export class HandlebarsItem {
|
|
|
22
22
|
* Always saves with approved status.
|
|
23
23
|
*/
|
|
24
24
|
async save() {
|
|
25
|
+
assertRequired(this.name, 'Name');
|
|
25
26
|
// Update elements with current name and code
|
|
26
27
|
const elements = { ...this._rawDTO.elements };
|
|
27
28
|
for (const key of Object.keys(elements)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatFileSize, parseElementKey, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
|
|
1
|
+
import { formatFileSize, parseElementKey, mapStatus, flattenGroups, STATUS_CODES, AUTH_LEVEL_MAP, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from '../utils.js';
|
|
2
2
|
/** Symbol used to restrict _init() access to the factory function in this module */
|
|
3
3
|
const INIT = Symbol('ContentItem.init');
|
|
4
4
|
/**
|
|
@@ -497,6 +497,7 @@ export class ContentItem {
|
|
|
497
497
|
* save and approve in one step.
|
|
498
498
|
*/
|
|
499
499
|
async save() {
|
|
500
|
+
assertRequired(this.name, 'Content name');
|
|
500
501
|
// Start from the original raw elements (correct API format)
|
|
501
502
|
const rawElements = { ...this._rawDTO.elements };
|
|
502
503
|
// Re-resolve dirty fields through the ElementResolver so friendly values
|
|
@@ -526,7 +527,19 @@ export class ContentItem {
|
|
|
526
527
|
}
|
|
527
528
|
let resolved;
|
|
528
529
|
if (templateEl && this._resolver) {
|
|
529
|
-
|
|
530
|
+
// Repeater fields need the same dedicated resolution ContentResource
|
|
531
|
+
// uses on create/update — each item's sub-fields resolved into element
|
|
532
|
+
// keys and wrapped in { repeaterId, repeaterContent }. resolveValue()
|
|
533
|
+
// passes repeater arrays through untouched, so branch here explicitly.
|
|
534
|
+
const typeName = this._typeRegistry
|
|
535
|
+
? await this._typeRegistry.getNameById(templateEl.type)
|
|
536
|
+
: null;
|
|
537
|
+
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
538
|
+
resolved = await this._resolver.buildRepeaterValue(value, templateEl, this.language, this._sectionId);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
resolved = await this._resolver.resolveValue(value, templateEl, this.language, this._templateElements ?? undefined, context);
|
|
542
|
+
}
|
|
530
543
|
}
|
|
531
544
|
else {
|
|
532
545
|
resolved = value;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertRequired } from '../utils.js';
|
|
1
2
|
/**
|
|
2
3
|
* A mutable media category object. Modify properties and call save() to persist.
|
|
3
4
|
*/
|
|
@@ -14,6 +15,7 @@ export class MediaCategoryItem {
|
|
|
14
15
|
}
|
|
15
16
|
/** Persists current property values to the server via PUT. */
|
|
16
17
|
async save() {
|
|
18
|
+
assertRequired(this.name, 'Media category name');
|
|
17
19
|
const updated = {
|
|
18
20
|
...this._rawData,
|
|
19
21
|
name: this.name,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatFileSize, parseElementKey, STATUS_MAP, resolveFileToBlob, deriveFilename } from '../utils.js';
|
|
1
|
+
import { formatFileSize, parseElementKey, STATUS_MAP, resolveFileToBlob, deriveFilename, assertRequired } from '../utils.js';
|
|
2
2
|
/** Extension → syntax type mapping for non-binary media */
|
|
3
3
|
const EXTENSION_SYNTAX_MAP = {
|
|
4
4
|
js: 1,
|
|
@@ -77,6 +77,7 @@ export class MediaItem {
|
|
|
77
77
|
* If `content` has changed on non-binary media, the version is bumped.
|
|
78
78
|
*/
|
|
79
79
|
async save() {
|
|
80
|
+
assertRequired(this.name, 'Media name');
|
|
80
81
|
const categoryId = this.categories[0];
|
|
81
82
|
if (!categoryId) {
|
|
82
83
|
throw new Error('Cannot save media item — no category assigned.');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mapStatus, STATUS_CODES } from '../utils.js';
|
|
1
|
+
import { mapStatus, STATUS_CODES, assertRequired } from '../utils.js';
|
|
2
2
|
import { ContentResource } from '../resources/content-resource.js';
|
|
3
3
|
/**
|
|
4
4
|
* A mutable section object. Modify properties and call save() to persist.
|
|
@@ -31,6 +31,7 @@ export class SectionItem {
|
|
|
31
31
|
}
|
|
32
32
|
/** Persists current property values to the server via PUT. */
|
|
33
33
|
async save() {
|
|
34
|
+
assertRequired(this.name, 'Section name');
|
|
34
35
|
const statusCode = STATUS_CODES[this.status] ?? Number(this._rawData.status) ?? 0;
|
|
35
36
|
const updated = {
|
|
36
37
|
...this._rawData,
|
|
@@ -2,6 +2,7 @@ import { HttpClient } from '../http-client.js';
|
|
|
2
2
|
import { LanguageOption, CreateContentData, UpdateContentData } from '../types.js';
|
|
3
3
|
import { ContentItem } from '../models/content-item.js';
|
|
4
4
|
import { MediaCreateFn } from '../element-resolver.js';
|
|
5
|
+
import { ContentCache } from '../content-cache.js';
|
|
5
6
|
/**
|
|
6
7
|
* Section-scoped resource for content CRUD operations.
|
|
7
8
|
* All requests are scoped to the section ID provided at construction time.
|
|
@@ -10,23 +11,18 @@ export declare class ContentResource {
|
|
|
10
11
|
private readonly httpClient;
|
|
11
12
|
private readonly sectionId;
|
|
12
13
|
private readonly defaultLanguage;
|
|
13
|
-
private readonly
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null);
|
|
14
|
+
private readonly cache;
|
|
15
|
+
constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
|
|
16
|
+
private get resolver();
|
|
17
|
+
private get typeRegistry();
|
|
18
18
|
private getTemplate;
|
|
19
|
+
/** Fetches the section-independent content type definition, cached by content type ID. */
|
|
20
|
+
private getContentTypeDefinition;
|
|
19
21
|
/**
|
|
20
22
|
* Builds the T4 elements map from developer-friendly field names and values.
|
|
21
23
|
* Resolves list values, dates, repeaters, etc. automatically.
|
|
22
24
|
*/
|
|
23
25
|
private buildElements;
|
|
24
|
-
/**
|
|
25
|
-
* Builds repeater value array from developer-friendly input.
|
|
26
|
-
* Each repeater item gets its own element key resolution using the
|
|
27
|
-
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
28
|
-
*/
|
|
29
|
-
private buildRepeaterValue;
|
|
30
26
|
/** Lists all content items in this section. */
|
|
31
27
|
list(options?: LanguageOption): Promise<ContentItem[]>;
|
|
32
28
|
/** Retrieves a single content item by ID. */
|
|
@@ -1,35 +1,42 @@
|
|
|
1
|
-
import { resolveLanguage, toTimestamp, STATUS_CODES,
|
|
1
|
+
import { resolveLanguage, toTimestamp, STATUS_CODES, assertRequired, assertNotEmptyIfPresent } from '../utils.js';
|
|
2
2
|
import { createContentItem } from '../models/content-item.js';
|
|
3
|
-
import {
|
|
4
|
-
import { TypeRegistry } from '../type-registry.js';
|
|
3
|
+
import { ContentCache } from '../content-cache.js';
|
|
5
4
|
/**
|
|
6
5
|
* Section-scoped resource for content CRUD operations.
|
|
7
6
|
* All requests are scoped to the section ID provided at construction time.
|
|
8
7
|
*/
|
|
9
8
|
export class ContentResource {
|
|
10
|
-
constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
|
|
11
|
-
/** Cache of content type templates keyed by content type ID */
|
|
12
|
-
this.templateCache = new TtlMap();
|
|
9
|
+
constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
|
|
13
10
|
this.httpClient = httpClient;
|
|
14
11
|
this.sectionId = sectionId;
|
|
15
12
|
this.defaultLanguage = defaultLanguage;
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
// A shared cache is threaded in by T4Client so the element TypeRegistry and
|
|
14
|
+
// content type templates survive across sections during a traversal. When
|
|
15
|
+
// constructed standalone (e.g. in tests), fall back to a private cache so
|
|
16
|
+
// behaviour is unchanged.
|
|
17
|
+
this.cache = cache ?? new ContentCache(httpClient, defaultLanguage, mediaCreateFn);
|
|
18
|
+
}
|
|
19
|
+
get resolver() {
|
|
20
|
+
return this.cache.resolver;
|
|
21
|
+
}
|
|
22
|
+
get typeRegistry() {
|
|
23
|
+
return this.cache.typeRegistry;
|
|
18
24
|
}
|
|
19
25
|
async getTemplate(contentTypeId) {
|
|
20
|
-
const
|
|
26
|
+
const sectionKey = `${contentTypeId}:${this.sectionId}`;
|
|
27
|
+
const cached = this.cache.sectionTemplates.get(sectionKey);
|
|
21
28
|
if (cached)
|
|
22
29
|
return cached;
|
|
23
|
-
//
|
|
30
|
+
// The section template (channels etc.) is section-specific; the content type
|
|
31
|
+
// definition (alias, listId, repeater config) is instance-wide. Fetch each
|
|
32
|
+
// through its own shared cache so revisiting a section, or reusing a content
|
|
33
|
+
// type across sections, avoids re-fetching.
|
|
24
34
|
const [template, rawContentType] = await Promise.all([
|
|
25
35
|
this.httpClient.request({
|
|
26
36
|
method: 'GET',
|
|
27
37
|
path: `/content/type/${contentTypeId}/${this.sectionId}`,
|
|
28
38
|
}),
|
|
29
|
-
this.
|
|
30
|
-
method: 'GET',
|
|
31
|
-
path: `/contenttype/${contentTypeId}`,
|
|
32
|
-
}),
|
|
39
|
+
this.getContentTypeDefinition(contentTypeId),
|
|
33
40
|
]);
|
|
34
41
|
// Merge alias and contentTypeElementConfiguration from the full content type
|
|
35
42
|
for (const templateEl of template.contentType.contentTypeElements) {
|
|
@@ -44,79 +51,29 @@ export class ContentResource {
|
|
|
44
51
|
}
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
|
-
this.
|
|
54
|
+
this.cache.sectionTemplates.set(sectionKey, template);
|
|
48
55
|
return template;
|
|
49
56
|
}
|
|
57
|
+
/** Fetches the section-independent content type definition, cached by content type ID. */
|
|
58
|
+
async getContentTypeDefinition(contentTypeId) {
|
|
59
|
+
const cached = this.cache.contentTypeDefinitions.get(contentTypeId);
|
|
60
|
+
if (cached)
|
|
61
|
+
return cached;
|
|
62
|
+
const rawContentType = await this.httpClient.request({
|
|
63
|
+
method: 'GET',
|
|
64
|
+
path: `/contenttype/${contentTypeId}`,
|
|
65
|
+
});
|
|
66
|
+
this.cache.contentTypeDefinitions.set(contentTypeId, rawContentType);
|
|
67
|
+
return rawContentType;
|
|
68
|
+
}
|
|
50
69
|
/**
|
|
51
70
|
* Builds the T4 elements map from developer-friendly field names and values.
|
|
52
71
|
* Resolves list values, dates, repeaters, etc. automatically.
|
|
53
72
|
*/
|
|
54
73
|
async buildElements(fields, elements, name, language, context) {
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
if (nameEl) {
|
|
59
|
-
result[`${nameEl.name}#${nameEl.id}:${nameEl.type}`] = name;
|
|
60
|
-
}
|
|
61
|
-
for (const [fieldName, value] of Object.entries(fields)) {
|
|
62
|
-
const fieldLower = fieldName.toLowerCase();
|
|
63
|
-
const element = elements.find((el) => el.name.toLowerCase() === fieldLower
|
|
64
|
-
|| (el.alias && el.alias.toLowerCase() === fieldLower));
|
|
65
|
-
if (!element) {
|
|
66
|
-
const validNames = elements
|
|
67
|
-
.filter((el) => el.name.toLowerCase() !== 'name')
|
|
68
|
-
.map((el) => `"${el.alias || el.name}"`)
|
|
69
|
-
.join(', ');
|
|
70
|
-
throw new Error(`Unknown field "${fieldName}" on this content type. Valid fields are: ${validNames}`);
|
|
71
|
-
}
|
|
72
|
-
const key = `${element.name}#${element.id}:${element.type}`;
|
|
73
|
-
// Repeater — special handling (no maxSize validation)
|
|
74
|
-
const typeName = await this.typeRegistry.getNameById(element.type);
|
|
75
|
-
if (typeName === 'Repeater' && Array.isArray(value)) {
|
|
76
|
-
result[key] = await this.buildRepeaterValue(value, element, language);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
const resolved = await this.resolver.resolveValue(value, element, language, elements, context);
|
|
80
|
-
// Validate maxSize on the resolved value (what actually gets sent to the API)
|
|
81
|
-
if (element.maxSize) {
|
|
82
|
-
const resolvedStr = String(resolved ?? '');
|
|
83
|
-
if (resolvedStr.length > element.maxSize) {
|
|
84
|
-
const friendlyName = element.alias || element.name;
|
|
85
|
-
throw new Error(`Field "${friendlyName}" exceeds max size: ${resolvedStr.length} characters (max ${element.maxSize})`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
result[key] = resolved;
|
|
89
|
-
}
|
|
90
|
-
return result;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Builds repeater value array from developer-friendly input.
|
|
94
|
-
* Each repeater item gets its own element key resolution using the
|
|
95
|
-
* repeater's sub-content-type elements from contentTypeElementConfiguration.
|
|
96
|
-
*/
|
|
97
|
-
async buildRepeaterValue(items, element, language) {
|
|
98
|
-
const config = element.contentTypeElementConfiguration;
|
|
99
|
-
const repeaterElements = config?.contentTypeDTO?.contentTypeElements;
|
|
100
|
-
if (!repeaterElements || repeaterElements.length === 0)
|
|
101
|
-
return items;
|
|
102
|
-
const result = [];
|
|
103
|
-
for (const item of items) {
|
|
104
|
-
const repeaterId = -Math.floor(Math.random() * 100000);
|
|
105
|
-
// Repeater items use their own repeaterId as fromContentId for SS links
|
|
106
|
-
const repeaterContext = {
|
|
107
|
-
fromSectionId: this.sectionId,
|
|
108
|
-
fromContentId: repeaterId,
|
|
109
|
-
};
|
|
110
|
-
const elements = await this.buildElements(item.fields, repeaterElements, item.name, language, repeaterContext);
|
|
111
|
-
result.push({
|
|
112
|
-
repeaterId,
|
|
113
|
-
repeaterContent: {
|
|
114
|
-
name: item.name,
|
|
115
|
-
elements,
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
return result;
|
|
74
|
+
// Delegates to the shared implementation on ElementResolver so this path
|
|
75
|
+
// and ContentItem.save() resolve fields (repeaters included) identically.
|
|
76
|
+
return this.resolver.buildElements(fields, elements, name, language, this.sectionId, context);
|
|
120
77
|
}
|
|
121
78
|
/** Lists all content items in this section. */
|
|
122
79
|
async list(options) {
|
|
@@ -140,6 +97,7 @@ export class ContentResource {
|
|
|
140
97
|
}
|
|
141
98
|
/** Creates a new content item in this section. */
|
|
142
99
|
async create(data, options) {
|
|
100
|
+
assertRequired(data.name, 'Content name');
|
|
143
101
|
const language = resolveLanguage(options?.language, this.defaultLanguage);
|
|
144
102
|
const template = await this.getTemplate(data.type);
|
|
145
103
|
const contentId = -Math.floor(Math.random() * 1000000);
|
|
@@ -175,6 +133,7 @@ export class ContentResource {
|
|
|
175
133
|
}
|
|
176
134
|
/** Updates an existing content item's fields. */
|
|
177
135
|
async update(id, data, options) {
|
|
136
|
+
assertNotEmptyIfPresent(data.name, 'Content name');
|
|
178
137
|
const language = resolveLanguage(options?.language, this.defaultLanguage);
|
|
179
138
|
// 1. Fetch the existing content (full body needed for the POST)
|
|
180
139
|
const existing = await this.httpClient.request({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HttpClient } from '../http-client.js';
|
|
2
2
|
import { ContentTypeData, ContentTypeFieldDef } from '../types.js';
|
|
3
|
+
import { RawPrimaryGroup } from '../utils.js';
|
|
3
4
|
/** Raw content type element from the API response */
|
|
4
5
|
interface ApiContentTypeElement {
|
|
5
6
|
id?: number;
|
|
@@ -40,12 +41,7 @@ interface ApiContentType {
|
|
|
40
41
|
sharedGroups?: Array<{
|
|
41
42
|
id: number;
|
|
42
43
|
}>;
|
|
43
|
-
primaryGroup?:
|
|
44
|
-
id: number | null;
|
|
45
|
-
group?: {
|
|
46
|
-
id: number;
|
|
47
|
-
};
|
|
48
|
-
};
|
|
44
|
+
primaryGroup?: RawPrimaryGroup;
|
|
49
45
|
enableDirectEdit?: boolean;
|
|
50
46
|
elementIdforFilename?: number;
|
|
51
47
|
contentTypeElements?: ApiContentTypeElement[];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeHtmlEntities, AUTH_LEVEL_MAP, AUTH_LEVEL_REVERSE, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
|
|
1
|
+
import { decodeHtmlEntities, AUTH_LEVEL_MAP, AUTH_LEVEL_REVERSE, debugWarn, DEFAULT_CACHE_TTL, getCacheEpoch, invalidateAllCaches, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired, assertNotEmptyIfPresent } from '../utils.js';
|
|
2
2
|
/** The content type category ID that marks a system content type. */
|
|
3
3
|
const SYSTEM_CONTENT_TYPE = 30;
|
|
4
4
|
function resolveTypeName(type, typeMap) {
|
|
@@ -56,8 +56,8 @@ function mapContentType(raw, typeMap, editorMap) {
|
|
|
56
56
|
description: decodeHtmlEntities(raw.description ?? ''),
|
|
57
57
|
minUserLevel: AUTH_LEVEL_MAP[raw.minAuthLevel ?? 2] ?? `unknown (${raw.minAuthLevel})`,
|
|
58
58
|
workflow: raw.workflow ?? 0,
|
|
59
|
-
sharedGroups: (raw.sharedGroups
|
|
60
|
-
primaryGroup: raw.primaryGroup
|
|
59
|
+
sharedGroups: readSharedGroups(raw.sharedGroups),
|
|
60
|
+
primaryGroup: readPrimaryGroup(raw.primaryGroup),
|
|
61
61
|
directEdit: raw.enableDirectEdit ?? true,
|
|
62
62
|
fields: fieldsRecord,
|
|
63
63
|
},
|
|
@@ -245,6 +245,7 @@ export class ContentType {
|
|
|
245
245
|
return new Layout(raw, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
|
|
246
246
|
},
|
|
247
247
|
update: async (layoutName, updateData) => {
|
|
248
|
+
assertNotEmptyIfPresent(updateData.name, 'Layout name');
|
|
248
249
|
const all = await fetchLayouts();
|
|
249
250
|
const match = all.find((l) => l.name === layoutName);
|
|
250
251
|
if (!match)
|
|
@@ -285,6 +286,7 @@ export class ContentType {
|
|
|
285
286
|
return new Layout(response, httpClient, contentTypeId, layoutElements, fetchLayouts, resolveSyntaxId, resolveProcessorId, resolveExtension);
|
|
286
287
|
},
|
|
287
288
|
create: async (createData) => {
|
|
289
|
+
assertRequired(createData.name, 'Layout name');
|
|
288
290
|
// Check name uniqueness
|
|
289
291
|
const existing = await fetchLayouts();
|
|
290
292
|
if (existing.some((l) => l.name === createData.name)) {
|
|
@@ -586,6 +588,8 @@ export class ContentType {
|
|
|
586
588
|
}
|
|
587
589
|
/** Persists current property values to the server via PUT. */
|
|
588
590
|
async save() {
|
|
591
|
+
assertRequired(this.name, 'Content type name');
|
|
592
|
+
assertGroupsValid(this.primaryGroup, this.sharedGroups);
|
|
589
593
|
const authLevel = String(AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
|
|
590
594
|
// Sync field changes back to raw contentTypeElements
|
|
591
595
|
let rawElements = (this._rawData.contentTypeElements ?? []);
|
|
@@ -653,8 +657,8 @@ export class ContentType {
|
|
|
653
657
|
minAuthLevel: authLevel,
|
|
654
658
|
workflow: String(this.workflow),
|
|
655
659
|
enableDirectEdit: this.directEdit,
|
|
656
|
-
sharedGroups: this.sharedGroups
|
|
657
|
-
primaryGroup:
|
|
660
|
+
sharedGroups: writeSharedGroups(this.sharedGroups),
|
|
661
|
+
primaryGroup: writePrimaryGroup(this.primaryGroup),
|
|
658
662
|
contentTypeElements: rawElements,
|
|
659
663
|
};
|
|
660
664
|
// Resolve useAsFilename → elementIdforFilename
|
|
@@ -679,6 +683,10 @@ export class ContentType {
|
|
|
679
683
|
path: `/contenttype/${this.id}`,
|
|
680
684
|
body: updated,
|
|
681
685
|
});
|
|
686
|
+
// The content type definition changed, so any cached copy (content type
|
|
687
|
+
// templates/definitions, element type maps, etc.) is now stale. Invalidate
|
|
688
|
+
// all caches via the global epoch so subsequent reads re-fetch.
|
|
689
|
+
invalidateAllCaches();
|
|
682
690
|
// Update raw data for next save
|
|
683
691
|
this._rawData = updated;
|
|
684
692
|
this._removedElementIds.clear();
|
|
@@ -862,6 +870,9 @@ export class ContentTypeResource {
|
|
|
862
870
|
method: 'DELETE',
|
|
863
871
|
path: `/contenttype/${id}`,
|
|
864
872
|
});
|
|
873
|
+
// A deleted content type may be cached; invalidate all caches so stale
|
|
874
|
+
// definitions/templates aren't served after the delete.
|
|
875
|
+
invalidateAllCaches();
|
|
865
876
|
}
|
|
866
877
|
/** Creates a new content type. */
|
|
867
878
|
async create(data) {
|
|
@@ -871,6 +882,7 @@ export class ContentTypeResource {
|
|
|
871
882
|
if (!data.elements?.length) {
|
|
872
883
|
throw new Error('Content type must have at least one element');
|
|
873
884
|
}
|
|
885
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
874
886
|
// Validate useAsFilename constraints
|
|
875
887
|
const filenameElements = data.elements.filter((el) => el.useAsFilename);
|
|
876
888
|
if (filenameElements.length > 1) {
|
|
@@ -989,11 +1001,13 @@ export class ContentTypeResource {
|
|
|
989
1001
|
warningMessage: '',
|
|
990
1002
|
elementIdforFilename: elementIdForFilename,
|
|
991
1003
|
conditionals: [],
|
|
992
|
-
sharedGroups: (data.sharedGroups
|
|
993
|
-
primaryGroup:
|
|
1004
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
1005
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
994
1006
|
contentTypeElements,
|
|
995
1007
|
},
|
|
996
1008
|
});
|
|
1009
|
+
// New content type may affect cached lookups; invalidate for consistency.
|
|
1010
|
+
invalidateAllCaches();
|
|
997
1011
|
const editorMap = await this.getEditorMap();
|
|
998
1012
|
const result = mapContentType(raw, typeMap, editorMap);
|
|
999
1013
|
await this.resolveListNames([result.data]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AUTH_LEVEL_MAP } from '../utils.js';
|
|
1
|
+
import { AUTH_LEVEL_MAP, assertRequired } from '../utils.js';
|
|
2
2
|
/** A mutable group object. Modify properties and call save() to persist. */
|
|
3
3
|
export class Group {
|
|
4
4
|
constructor(raw, httpClient) {
|
|
@@ -40,6 +40,7 @@ export class Group {
|
|
|
40
40
|
}
|
|
41
41
|
/** Persists current property values to the server via PUT. */
|
|
42
42
|
async save() {
|
|
43
|
+
assertRequired(this.name, 'Group name');
|
|
43
44
|
// Start from existing raw members
|
|
44
45
|
let rawMembers = [...(this._rawData.members ?? [])];
|
|
45
46
|
// Remove members
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { HttpClient } from '../http-client.js';
|
|
2
2
|
import { LanguageOption } from '../types.js';
|
|
3
|
+
import { RawPrimaryGroup } from '../utils.js';
|
|
3
4
|
/** Raw list item from GET /list/{id}/{language} */
|
|
4
5
|
interface RawListItem {
|
|
5
6
|
id: number;
|
|
@@ -18,12 +19,7 @@ interface RawListDetail {
|
|
|
18
19
|
language: string;
|
|
19
20
|
isForcedLanguage?: boolean;
|
|
20
21
|
isDefaultLanguage?: boolean;
|
|
21
|
-
primaryGroup?:
|
|
22
|
-
id: number | null;
|
|
23
|
-
group?: {
|
|
24
|
-
id: number;
|
|
25
|
-
};
|
|
26
|
-
};
|
|
22
|
+
primaryGroup?: RawPrimaryGroup;
|
|
27
23
|
sharedGroups?: Array<{
|
|
28
24
|
id: number;
|
|
29
25
|
}>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveLanguage } from '../utils.js';
|
|
2
|
-
import { decodeHtmlEntities } from '../utils.js';
|
|
2
|
+
import { decodeHtmlEntities, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid, assertRequired } from '../utils.js';
|
|
3
3
|
/** A mutable list object. Modify properties and call save() to persist. */
|
|
4
4
|
export class List {
|
|
5
5
|
constructor(raw, httpClient, language) {
|
|
@@ -8,8 +8,8 @@ export class List {
|
|
|
8
8
|
this.description = decodeHtmlEntities(raw.description ?? '');
|
|
9
9
|
this.isForcedLanguage = raw.isForcedLanguage ?? false;
|
|
10
10
|
this.isDefaultLanguage = raw.isDefaultLanguage ?? false;
|
|
11
|
-
this.primaryGroup = raw.primaryGroup
|
|
12
|
-
this.sharedGroups = (raw.sharedGroups
|
|
11
|
+
this.primaryGroup = readPrimaryGroup(raw.primaryGroup);
|
|
12
|
+
this.sharedGroups = readSharedGroups(raw.sharedGroups);
|
|
13
13
|
this.items = {};
|
|
14
14
|
for (const item of (raw.items ?? []).sort((a, b) => a.sequence - b.sequence)) {
|
|
15
15
|
const friendlyName = decodeHtmlEntities(item.name);
|
|
@@ -53,9 +53,11 @@ export class List {
|
|
|
53
53
|
}
|
|
54
54
|
/** Persists current property values to the server via PUT. */
|
|
55
55
|
async save() {
|
|
56
|
+
assertRequired(this.name, 'List name');
|
|
56
57
|
if (this.isForcedLanguage && this.isDefaultLanguage) {
|
|
57
58
|
throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
|
|
58
59
|
}
|
|
60
|
+
assertGroupsValid(this.primaryGroup, this.sharedGroups);
|
|
59
61
|
const items = Object.values(this.items).map((item, i) => ({
|
|
60
62
|
id: String(item._rawId ?? 0),
|
|
61
63
|
name: item.name,
|
|
@@ -70,8 +72,8 @@ export class List {
|
|
|
70
72
|
description: this.description,
|
|
71
73
|
isForcedLanguage: this.isForcedLanguage,
|
|
72
74
|
isDefaultLanguage: this.isDefaultLanguage,
|
|
73
|
-
primaryGroup:
|
|
74
|
-
sharedGroups: this.sharedGroups
|
|
75
|
+
primaryGroup: writePrimaryGroup(this.primaryGroup),
|
|
76
|
+
sharedGroups: writeSharedGroups(this.sharedGroups),
|
|
75
77
|
items,
|
|
76
78
|
};
|
|
77
79
|
await this._httpClient.request({
|
|
@@ -145,6 +147,7 @@ export class ListResource {
|
|
|
145
147
|
if (data.isForcedLanguage && data.isDefaultLanguage) {
|
|
146
148
|
throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
|
|
147
149
|
}
|
|
150
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
148
151
|
const language = resolveLanguage(options?.language, this.defaultLanguage);
|
|
149
152
|
const items = (data.items ?? []).map((item, i) => ({
|
|
150
153
|
id: '0',
|
|
@@ -163,8 +166,8 @@ export class ListResource {
|
|
|
163
166
|
items,
|
|
164
167
|
isForcedLanguage: data.isForcedLanguage ?? false,
|
|
165
168
|
isDefaultLanguage: data.isDefaultLanguage ?? false,
|
|
166
|
-
sharedGroups: (data.sharedGroups
|
|
167
|
-
primaryGroup:
|
|
169
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
170
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
168
171
|
sortType: 0,
|
|
169
172
|
},
|
|
170
173
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MediaItem } from '../models/media-item.js';
|
|
2
|
-
import { resolveLanguage, resolveFileToBlob, deriveFilename, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
|
|
2
|
+
import { resolveLanguage, resolveFileToBlob, deriveFilename, DEFAULT_CACHE_TTL, getCacheEpoch, assertRequired } from '../utils.js';
|
|
3
3
|
/** Extension → syntax type mapping for non-binary media */
|
|
4
4
|
const EXTENSION_SYNTAX_MAP = {
|
|
5
5
|
js: 1,
|
|
@@ -50,10 +50,17 @@ export class MediaResource {
|
|
|
50
50
|
* Returns the new media item.
|
|
51
51
|
*/
|
|
52
52
|
async create(data) {
|
|
53
|
-
|
|
54
|
-
throw new Error('Media name is required');
|
|
53
|
+
assertRequired(data.name, 'Media name');
|
|
55
54
|
if (!data.category)
|
|
56
55
|
throw new Error('Media category is required');
|
|
56
|
+
// A media item is meaningless without its file/binary. Guard the common
|
|
57
|
+
// empty cases: missing, or an empty { file } wrapper.
|
|
58
|
+
const fileValue = data.file && typeof data.file === 'object' && !(data.file instanceof Blob) && 'file' in data.file
|
|
59
|
+
? data.file.file
|
|
60
|
+
: data.file;
|
|
61
|
+
if (fileValue === undefined || fileValue === null || fileValue === '') {
|
|
62
|
+
throw new Error('Media file is required');
|
|
63
|
+
}
|
|
57
64
|
const language = data.language ?? 'smxx';
|
|
58
65
|
const filename = deriveFilename(data.file);
|
|
59
66
|
const ext = getExtension(filename);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatFileSize, parseFileSize } from '../utils.js';
|
|
1
|
+
import { formatFileSize, parseFileSize, assertRequired } from '../utils.js';
|
|
2
2
|
function mapFromDetail(raw) {
|
|
3
3
|
const layouts = (raw.formatters ?? []).map((f) => ({
|
|
4
4
|
name: f.mediaLayout,
|
|
@@ -65,6 +65,7 @@ export class MediaType {
|
|
|
65
65
|
}
|
|
66
66
|
/** Persists current property values to the server via PUT. */
|
|
67
67
|
async save() {
|
|
68
|
+
assertRequired(this.name, 'Media type name');
|
|
68
69
|
// Sync defaultLayout into the layouts array
|
|
69
70
|
if (this.defaultLayout) {
|
|
70
71
|
for (const layout of this.layouts) {
|