@terminalfour/terminalfour-js 1.0.2 → 1.1.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/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/models/content-item.js +13 -1
- package/dist/cjs/resources/content-resource.d.ts +7 -11
- package/dist/cjs/resources/content-resource.js +36 -79
- package/dist/cjs/resources/content-type-resource.d.ts +8 -8
- package/dist/cjs/resources/content-type-resource.js +25 -6
- package/dist/cjs/resources/list-resource.d.ts +2 -6
- package/dist/cjs/resources/list-resource.js +8 -6
- package/dist/cjs/resources/navigation-resource.d.ts +17 -0
- package/dist/cjs/resources/navigation-resource.js +13 -2
- package/dist/cjs/resources/page-layout-resource.d.ts +11 -0
- package/dist/cjs/resources/page-layout-resource.js +12 -2
- package/dist/cjs/section-ref.d.ts +3 -1
- package/dist/cjs/section-ref.js +6 -5
- package/dist/cjs/t4-client.d.ts +1 -0
- package/dist/cjs/t4-client.js +6 -1
- package/dist/cjs/utils.d.ts +39 -0
- package/dist/cjs/utils.js +37 -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/models/content-item.js +13 -1
- package/dist/esm/resources/content-resource.d.ts +7 -11
- package/dist/esm/resources/content-resource.js +37 -80
- package/dist/esm/resources/content-type-resource.d.ts +8 -8
- package/dist/esm/resources/content-type-resource.js +26 -7
- package/dist/esm/resources/list-resource.d.ts +2 -6
- package/dist/esm/resources/list-resource.js +9 -7
- package/dist/esm/resources/navigation-resource.d.ts +17 -0
- package/dist/esm/resources/navigation-resource.js +13 -2
- package/dist/esm/resources/page-layout-resource.d.ts +11 -0
- package/dist/esm/resources/page-layout-resource.js +13 -3
- package/dist/esm/section-ref.d.ts +3 -1
- package/dist/esm/section-ref.js +6 -5
- package/dist/esm/t4-client.d.ts +1 -0
- package/dist/esm/t4-client.js +6 -1
- package/dist/esm/utils.d.ts +39 -0
- package/dist/esm/utils.js +32 -0
- package/docs/content-types.md +20 -3
- package/docs/content.md +13 -0
- package/docs/error-handling.md +3 -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
|
@@ -1,35 +1,42 @@
|
|
|
1
|
-
import { resolveLanguage, toTimestamp, STATUS_CODES
|
|
1
|
+
import { resolveLanguage, toTimestamp, STATUS_CODES } 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) {
|
|
@@ -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[];
|
|
@@ -242,11 +238,15 @@ export declare class ContentTypeResource {
|
|
|
242
238
|
/**
|
|
243
239
|
* Existing fields to modify, matched by `name`. Only the provided
|
|
244
240
|
* properties are changed; omitted ones are left as-is. Use this to change
|
|
245
|
-
* an element's length (`maxSize`), description,
|
|
246
|
-
* flags
|
|
241
|
+
* an element's length (`maxSize`), description, its `required`/`shown`
|
|
242
|
+
* flags, or to rename it with `newName`. Throws if a named field does not
|
|
243
|
+
* exist on the content type. Renaming an element on a system content type
|
|
244
|
+
* is blocked (except the Section Meta Data and Extended User types).
|
|
247
245
|
*/
|
|
248
246
|
updateFields?: Array<{
|
|
249
247
|
name: string;
|
|
248
|
+
/** New name for the element. Renames are blocked on system content types. */
|
|
249
|
+
newName?: string;
|
|
250
250
|
maxSize?: number;
|
|
251
251
|
description?: string;
|
|
252
252
|
required?: boolean;
|
|
@@ -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 } 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
|
},
|
|
@@ -586,6 +586,7 @@ export class ContentType {
|
|
|
586
586
|
}
|
|
587
587
|
/** Persists current property values to the server via PUT. */
|
|
588
588
|
async save() {
|
|
589
|
+
assertGroupsValid(this.primaryGroup, this.sharedGroups);
|
|
589
590
|
const authLevel = String(AUTH_LEVEL_REVERSE[this.minUserLevel] ?? this._rawData.minAuthLevel ?? 2);
|
|
590
591
|
// Sync field changes back to raw contentTypeElements
|
|
591
592
|
let rawElements = (this._rawData.contentTypeElements ?? []);
|
|
@@ -653,8 +654,8 @@ export class ContentType {
|
|
|
653
654
|
minAuthLevel: authLevel,
|
|
654
655
|
workflow: String(this.workflow),
|
|
655
656
|
enableDirectEdit: this.directEdit,
|
|
656
|
-
sharedGroups: this.sharedGroups
|
|
657
|
-
primaryGroup:
|
|
657
|
+
sharedGroups: writeSharedGroups(this.sharedGroups),
|
|
658
|
+
primaryGroup: writePrimaryGroup(this.primaryGroup),
|
|
658
659
|
contentTypeElements: rawElements,
|
|
659
660
|
};
|
|
660
661
|
// Resolve useAsFilename → elementIdforFilename
|
|
@@ -679,6 +680,10 @@ export class ContentType {
|
|
|
679
680
|
path: `/contenttype/${this.id}`,
|
|
680
681
|
body: updated,
|
|
681
682
|
});
|
|
683
|
+
// The content type definition changed, so any cached copy (content type
|
|
684
|
+
// templates/definitions, element type maps, etc.) is now stale. Invalidate
|
|
685
|
+
// all caches via the global epoch so subsequent reads re-fetch.
|
|
686
|
+
invalidateAllCaches();
|
|
682
687
|
// Update raw data for next save
|
|
683
688
|
this._rawData = updated;
|
|
684
689
|
this._removedElementIds.clear();
|
|
@@ -818,6 +823,14 @@ export class ContentTypeResource {
|
|
|
818
823
|
field.required = change.required;
|
|
819
824
|
if (change.shown !== undefined)
|
|
820
825
|
field.shown = change.shown;
|
|
826
|
+
// Rename last: save() detects the change against the element's _elementId
|
|
827
|
+
// link and applies the system content type rename guard. Re-key the
|
|
828
|
+
// fields record so the returned model is addressable by the new name.
|
|
829
|
+
if (change.newName !== undefined && change.newName !== change.name) {
|
|
830
|
+
field.name = change.newName;
|
|
831
|
+
ct.fields[change.newName] = field;
|
|
832
|
+
delete ct.fields[change.name];
|
|
833
|
+
}
|
|
821
834
|
}
|
|
822
835
|
}
|
|
823
836
|
if (data.removeFields) {
|
|
@@ -854,6 +867,9 @@ export class ContentTypeResource {
|
|
|
854
867
|
method: 'DELETE',
|
|
855
868
|
path: `/contenttype/${id}`,
|
|
856
869
|
});
|
|
870
|
+
// A deleted content type may be cached; invalidate all caches so stale
|
|
871
|
+
// definitions/templates aren't served after the delete.
|
|
872
|
+
invalidateAllCaches();
|
|
857
873
|
}
|
|
858
874
|
/** Creates a new content type. */
|
|
859
875
|
async create(data) {
|
|
@@ -863,6 +879,7 @@ export class ContentTypeResource {
|
|
|
863
879
|
if (!data.elements?.length) {
|
|
864
880
|
throw new Error('Content type must have at least one element');
|
|
865
881
|
}
|
|
882
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
866
883
|
// Validate useAsFilename constraints
|
|
867
884
|
const filenameElements = data.elements.filter((el) => el.useAsFilename);
|
|
868
885
|
if (filenameElements.length > 1) {
|
|
@@ -981,11 +998,13 @@ export class ContentTypeResource {
|
|
|
981
998
|
warningMessage: '',
|
|
982
999
|
elementIdforFilename: elementIdForFilename,
|
|
983
1000
|
conditionals: [],
|
|
984
|
-
sharedGroups: (data.sharedGroups
|
|
985
|
-
primaryGroup:
|
|
1001
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
1002
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
986
1003
|
contentTypeElements,
|
|
987
1004
|
},
|
|
988
1005
|
});
|
|
1006
|
+
// New content type may affect cached lookups; invalidate for consistency.
|
|
1007
|
+
invalidateAllCaches();
|
|
989
1008
|
const editorMap = await this.getEditorMap();
|
|
990
1009
|
const result = mapContentType(raw, typeMap, editorMap);
|
|
991
1010
|
await this.resolveListNames([result.data]);
|
|
@@ -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 } 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);
|
|
@@ -56,6 +56,7 @@ export class List {
|
|
|
56
56
|
if (this.isForcedLanguage && this.isDefaultLanguage) {
|
|
57
57
|
throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
|
|
58
58
|
}
|
|
59
|
+
assertGroupsValid(this.primaryGroup, this.sharedGroups);
|
|
59
60
|
const items = Object.values(this.items).map((item, i) => ({
|
|
60
61
|
id: String(item._rawId ?? 0),
|
|
61
62
|
name: item.name,
|
|
@@ -70,8 +71,8 @@ export class List {
|
|
|
70
71
|
description: this.description,
|
|
71
72
|
isForcedLanguage: this.isForcedLanguage,
|
|
72
73
|
isDefaultLanguage: this.isDefaultLanguage,
|
|
73
|
-
primaryGroup:
|
|
74
|
-
sharedGroups: this.sharedGroups
|
|
74
|
+
primaryGroup: writePrimaryGroup(this.primaryGroup),
|
|
75
|
+
sharedGroups: writeSharedGroups(this.sharedGroups),
|
|
75
76
|
items,
|
|
76
77
|
};
|
|
77
78
|
await this._httpClient.request({
|
|
@@ -145,6 +146,7 @@ export class ListResource {
|
|
|
145
146
|
if (data.isForcedLanguage && data.isDefaultLanguage) {
|
|
146
147
|
throw new Error('isForcedLanguage and isDefaultLanguage cannot both be true');
|
|
147
148
|
}
|
|
149
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
148
150
|
const language = resolveLanguage(options?.language, this.defaultLanguage);
|
|
149
151
|
const items = (data.items ?? []).map((item, i) => ({
|
|
150
152
|
id: '0',
|
|
@@ -163,8 +165,8 @@ export class ListResource {
|
|
|
163
165
|
items,
|
|
164
166
|
isForcedLanguage: data.isForcedLanguage ?? false,
|
|
165
167
|
isDefaultLanguage: data.isDefaultLanguage ?? false,
|
|
166
|
-
sharedGroups: (data.sharedGroups
|
|
167
|
-
primaryGroup:
|
|
168
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
169
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
168
170
|
sortType: 0,
|
|
169
171
|
},
|
|
170
172
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpClient } from '../http-client.js';
|
|
2
|
+
import { RawPrimaryGroup } from '../utils.js';
|
|
2
3
|
/** SDK-friendly navigation type codes (consistent kebab-case) */
|
|
3
4
|
export type NavigationType = 'a-to-z' | 'breadcrumbs' | 'css-selector' | 'generate-file' | 'keyword-search' | 'language-switcher' | 'link-menu' | 'pagination' | 'previous-next-fulltext' | 'publish-to-one-file' | 'related-content' | 'related-section-branch' | 'return-to-index' | 'section-details' | 'section-iterator' | 'section-meta-info' | 'site-map' | 'top-content' | 'top-stories';
|
|
4
5
|
/** Human-readable type name mapping */
|
|
@@ -23,6 +24,10 @@ interface RawNavigationDetail {
|
|
|
23
24
|
isPreviewModeEnabled: boolean;
|
|
24
25
|
isCachingEnabled: boolean;
|
|
25
26
|
date?: string;
|
|
27
|
+
primaryGroup?: RawPrimaryGroup;
|
|
28
|
+
sharedGroups?: Array<{
|
|
29
|
+
id: number;
|
|
30
|
+
}>;
|
|
26
31
|
properties: Record<string, {
|
|
27
32
|
value?: string;
|
|
28
33
|
attribute: string;
|
|
@@ -42,6 +47,10 @@ export declare class NavigationObject {
|
|
|
42
47
|
enabled: boolean;
|
|
43
48
|
cachingEnabled: boolean;
|
|
44
49
|
previewEnabled: boolean;
|
|
50
|
+
/** Owning group ID. 0 = no primary group (Global). */
|
|
51
|
+
primaryGroup: number;
|
|
52
|
+
/** Group IDs this navigation object is shared with. */
|
|
53
|
+
sharedGroups: number[];
|
|
45
54
|
properties: Record<string, unknown>;
|
|
46
55
|
private readonly _httpClient;
|
|
47
56
|
private _rawData;
|
|
@@ -525,6 +534,10 @@ export interface CreateNavigationData {
|
|
|
525
534
|
description?: string;
|
|
526
535
|
enabled?: boolean;
|
|
527
536
|
previewEnabled?: boolean;
|
|
537
|
+
/** Owning group ID. 0 = no primary group (Global). Defaults to 0. */
|
|
538
|
+
primaryGroup?: number;
|
|
539
|
+
/** Group IDs to share this navigation object with. Defaults to none. */
|
|
540
|
+
sharedGroups?: number[];
|
|
528
541
|
properties?: A2ZProperties | BreadcrumbsProperties | CssSelectorProperties | GenerateFileProperties | LanguageSwitcherProperties | PaginationProperties | PreviousNextProperties | SectionIteratorProperties | RelatedSectionBranchProperties | ReturnToIndexProperties | SectionMetaInfoProperties | TopStoriesProperties | SiteMapProperties | SectionDetailsProperties | RelatedContentProperties | LinkMenuProperties | PublishToOneFileProperties | TopContentProperties | KeywordSearchProperties | Record<string, unknown>;
|
|
529
542
|
}
|
|
530
543
|
/**
|
|
@@ -538,6 +551,10 @@ export interface UpdateNavigationData {
|
|
|
538
551
|
enabled?: boolean;
|
|
539
552
|
previewEnabled?: boolean;
|
|
540
553
|
cachingEnabled?: boolean;
|
|
554
|
+
/** Owning group ID. 0 = no primary group (Global). */
|
|
555
|
+
primaryGroup?: number;
|
|
556
|
+
/** Group IDs to share this navigation object with. */
|
|
557
|
+
sharedGroups?: number[];
|
|
541
558
|
properties?: A2ZProperties | BreadcrumbsProperties | CssSelectorProperties | GenerateFileProperties | LanguageSwitcherProperties | PaginationProperties | PreviousNextProperties | SectionIteratorProperties | RelatedSectionBranchProperties | ReturnToIndexProperties | SectionMetaInfoProperties | TopStoriesProperties | SiteMapProperties | SectionDetailsProperties | RelatedContentProperties | LinkMenuProperties | PublishToOneFileProperties | TopContentProperties | KeywordSearchProperties | Record<string, unknown>;
|
|
542
559
|
}
|
|
543
560
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
|
|
1
2
|
/** Maps SDK type codes to API type codes */
|
|
2
3
|
const SDK_TO_API = {
|
|
3
4
|
'a-to-z': 'a2z',
|
|
@@ -985,6 +986,8 @@ export class NavigationObject {
|
|
|
985
986
|
this.enabled = raw.isEnabled;
|
|
986
987
|
this.cachingEnabled = raw.isCachingEnabled;
|
|
987
988
|
this.previewEnabled = raw.isPreviewModeEnabled;
|
|
989
|
+
this.primaryGroup = readPrimaryGroup(raw.primaryGroup);
|
|
990
|
+
this.sharedGroups = readSharedGroups(raw.sharedGroups);
|
|
988
991
|
// Convert properties to camelCase keys with string values
|
|
989
992
|
const originalKeys = [];
|
|
990
993
|
const rawCamelProps = {};
|
|
@@ -1000,6 +1003,7 @@ export class NavigationObject {
|
|
|
1000
1003
|
}
|
|
1001
1004
|
/** Persists current property values to the server via PUT. */
|
|
1002
1005
|
async save() {
|
|
1006
|
+
assertGroupsValid(this.primaryGroup, this.sharedGroups);
|
|
1003
1007
|
// Apply type-aware write transformation (coerce back to strings, derive hidden fields)
|
|
1004
1008
|
const camelStringProps = transformPropertiesWrite(this.type, this.properties);
|
|
1005
1009
|
// Rebuild properties in API format
|
|
@@ -1019,6 +1023,8 @@ export class NavigationObject {
|
|
|
1019
1023
|
isEnabled: this.enabled,
|
|
1020
1024
|
isCachingEnabled: this.cachingEnabled,
|
|
1021
1025
|
isPreviewModeEnabled: this.previewEnabled,
|
|
1026
|
+
primaryGroup: writePrimaryGroup(this.primaryGroup),
|
|
1027
|
+
sharedGroups: writeSharedGroups(this.sharedGroups),
|
|
1022
1028
|
properties: apiProperties,
|
|
1023
1029
|
};
|
|
1024
1030
|
await this._httpClient.request({
|
|
@@ -1235,6 +1241,7 @@ export class NavigationResource {
|
|
|
1235
1241
|
throw new Error('Navigation object type is required');
|
|
1236
1242
|
if (!NAVIGATION_TYPE_NAMES[data.type])
|
|
1237
1243
|
throw new Error(`Unknown navigation type "${data.type}"`);
|
|
1244
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
1238
1245
|
const apiType = SDK_TO_API[data.type];
|
|
1239
1246
|
const properties = await this.buildProperties(data.type, (data.properties ?? {}));
|
|
1240
1247
|
const body = {
|
|
@@ -1244,8 +1251,8 @@ export class NavigationResource {
|
|
|
1244
1251
|
name: data.name,
|
|
1245
1252
|
description: data.description ?? '',
|
|
1246
1253
|
navigationType: apiType,
|
|
1247
|
-
sharedGroups:
|
|
1248
|
-
primaryGroup:
|
|
1254
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
1255
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
1249
1256
|
properties,
|
|
1250
1257
|
};
|
|
1251
1258
|
// CSS Selector quirk: requires "section-name": "on" at the top level
|
|
@@ -1289,6 +1296,10 @@ export class NavigationResource {
|
|
|
1289
1296
|
nav.previewEnabled = data.previewEnabled;
|
|
1290
1297
|
if (data.cachingEnabled !== undefined)
|
|
1291
1298
|
nav.cachingEnabled = data.cachingEnabled;
|
|
1299
|
+
if (data.primaryGroup !== undefined)
|
|
1300
|
+
nav.primaryGroup = data.primaryGroup;
|
|
1301
|
+
if (data.sharedGroups !== undefined)
|
|
1302
|
+
nav.sharedGroups = data.sharedGroups;
|
|
1292
1303
|
// Merge properties rather than replace — callers pass only what changes
|
|
1293
1304
|
if (data.properties !== undefined) {
|
|
1294
1305
|
nav.properties = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpClient } from '../http-client.js';
|
|
2
|
+
import { RawPrimaryGroup } from '../utils.js';
|
|
2
3
|
/** Raw page layout detail from GET /pageLayout/{id} */
|
|
3
4
|
interface RawPageLayoutDetail {
|
|
4
5
|
id: number;
|
|
@@ -10,6 +11,10 @@ interface RawPageLayoutDetail {
|
|
|
10
11
|
fileExtension?: string;
|
|
11
12
|
syntaxType?: number;
|
|
12
13
|
layoutProcessor?: number;
|
|
14
|
+
primaryGroup?: RawPrimaryGroup;
|
|
15
|
+
sharedGroups?: Array<{
|
|
16
|
+
id: number;
|
|
17
|
+
}>;
|
|
13
18
|
[key: string]: unknown;
|
|
14
19
|
}
|
|
15
20
|
/** A page layout summary returned from list() */
|
|
@@ -28,6 +33,10 @@ export declare class PageLayout {
|
|
|
28
33
|
fileExtension: string;
|
|
29
34
|
syntax: string;
|
|
30
35
|
processor: string;
|
|
36
|
+
/** Owning group ID. 0 = no primary group (Global). */
|
|
37
|
+
primaryGroup: number;
|
|
38
|
+
/** Group IDs this page layout is shared with. */
|
|
39
|
+
sharedGroups: number[];
|
|
31
40
|
private readonly _httpClient;
|
|
32
41
|
private _rawData;
|
|
33
42
|
private _syntaxMap;
|
|
@@ -63,6 +72,8 @@ export declare class PageLayoutResource {
|
|
|
63
72
|
fileExtension?: string;
|
|
64
73
|
syntax?: string;
|
|
65
74
|
processor?: string;
|
|
75
|
+
primaryGroup?: number;
|
|
76
|
+
sharedGroups?: number[];
|
|
66
77
|
}): Promise<PageLayout>;
|
|
67
78
|
/** Creates a new page layout. */
|
|
68
79
|
create(data: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decodeHtmlEntities, DEFAULT_CACHE_TTL, getCacheEpoch } from '../utils.js';
|
|
1
|
+
import { decodeHtmlEntities, DEFAULT_CACHE_TTL, getCacheEpoch, readPrimaryGroup, readSharedGroups, writePrimaryGroup, writeSharedGroups, assertGroupsValid } from '../utils.js';
|
|
2
2
|
/** Friendly processor keys mapped to API names for page layouts */
|
|
3
3
|
const PAGE_PROCESSOR_MAP = {
|
|
4
4
|
't4-tags': 'T4 Tag Page',
|
|
@@ -19,6 +19,8 @@ export class PageLayout {
|
|
|
19
19
|
const procName = processorMap.get(raw.layoutProcessor ?? 0) ?? '';
|
|
20
20
|
const procEntry = Object.entries(PAGE_PROCESSOR_MAP).find(([, apiName]) => apiName === procName);
|
|
21
21
|
this.processor = procEntry ? procEntry[0] : procName || `unknown (${raw.layoutProcessor})`;
|
|
22
|
+
this.primaryGroup = readPrimaryGroup(raw.primaryGroup);
|
|
23
|
+
this.sharedGroups = readSharedGroups(raw.sharedGroups);
|
|
22
24
|
Object.defineProperty(this, '_httpClient', { value: httpClient, enumerable: false });
|
|
23
25
|
Object.defineProperty(this, '_rawData', { value: raw, enumerable: false, writable: true });
|
|
24
26
|
Object.defineProperty(this, '_syntaxMap', { value: syntaxMap, enumerable: false });
|
|
@@ -26,6 +28,7 @@ export class PageLayout {
|
|
|
26
28
|
}
|
|
27
29
|
/** Persists current property values to the server via PUT. */
|
|
28
30
|
async save() {
|
|
31
|
+
assertGroupsValid(this.primaryGroup, this.sharedGroups);
|
|
29
32
|
// Resolve syntax name to ID
|
|
30
33
|
let syntaxId = this._rawData.syntaxType;
|
|
31
34
|
if (this._syntaxMap) {
|
|
@@ -50,6 +53,8 @@ export class PageLayout {
|
|
|
50
53
|
fileExtension: this.fileExtension,
|
|
51
54
|
syntaxType: String(syntaxId),
|
|
52
55
|
layoutProcessor: String(processorId),
|
|
56
|
+
primaryGroup: writePrimaryGroup(this.primaryGroup),
|
|
57
|
+
sharedGroups: writeSharedGroups(this.sharedGroups),
|
|
53
58
|
};
|
|
54
59
|
await this._httpClient.request({
|
|
55
60
|
method: 'PUT',
|
|
@@ -132,6 +137,10 @@ export class PageLayoutResource {
|
|
|
132
137
|
layout.syntax = data.syntax;
|
|
133
138
|
if (data.processor !== undefined)
|
|
134
139
|
layout.processor = data.processor;
|
|
140
|
+
if (data.primaryGroup !== undefined)
|
|
141
|
+
layout.primaryGroup = data.primaryGroup;
|
|
142
|
+
if (data.sharedGroups !== undefined)
|
|
143
|
+
layout.sharedGroups = data.sharedGroups;
|
|
135
144
|
await layout.save();
|
|
136
145
|
return layout;
|
|
137
146
|
}
|
|
@@ -139,6 +148,7 @@ export class PageLayoutResource {
|
|
|
139
148
|
async create(data) {
|
|
140
149
|
if (!data.name?.trim())
|
|
141
150
|
throw new Error('Page layout name is required');
|
|
151
|
+
assertGroupsValid(data.primaryGroup ?? 0, data.sharedGroups);
|
|
142
152
|
const [syntaxMap, processorMap] = await Promise.all([
|
|
143
153
|
this.getSyntaxMap(),
|
|
144
154
|
this.getProcessorMap(),
|
|
@@ -192,8 +202,8 @@ export class PageLayoutResource {
|
|
|
192
202
|
fileExtension: extensionValue,
|
|
193
203
|
syntaxType: syntaxId,
|
|
194
204
|
layoutProcessor: processorId,
|
|
195
|
-
sharedGroups: (data.sharedGroups
|
|
196
|
-
primaryGroup:
|
|
205
|
+
sharedGroups: writeSharedGroups(data.sharedGroups),
|
|
206
|
+
primaryGroup: writePrimaryGroup(data.primaryGroup ?? 0),
|
|
197
207
|
},
|
|
198
208
|
});
|
|
199
209
|
return new PageLayout(raw, this.httpClient, syntaxMap, processorMap);
|
|
@@ -3,6 +3,7 @@ import { LanguageOption, SectionChannel, Owner, AddSectionData } from './types.j
|
|
|
3
3
|
import { ContentResource } from './resources/content-resource.js';
|
|
4
4
|
import { SectionItem } from './models/section-item.js';
|
|
5
5
|
import { MediaCreateFn } from './element-resolver.js';
|
|
6
|
+
import { ContentCache } from './content-cache.js';
|
|
6
7
|
/** A node in the section hierarchy tree */
|
|
7
8
|
export interface SectionTreeNode {
|
|
8
9
|
id: number;
|
|
@@ -23,7 +24,8 @@ export declare class SectionRef {
|
|
|
23
24
|
private readonly sectionId;
|
|
24
25
|
private readonly defaultLanguage;
|
|
25
26
|
private readonly mediaCreateFn;
|
|
26
|
-
|
|
27
|
+
private readonly cache;
|
|
28
|
+
constructor(httpClient: HttpClient, sectionId: number, defaultLanguage: string, mediaCreateFn?: MediaCreateFn | null, cache?: ContentCache);
|
|
27
29
|
/** Returns a mutable section object. Modify properties and call save() to persist. */
|
|
28
30
|
get(options?: LanguageOption): Promise<SectionItem>;
|
|
29
31
|
/** Returns the list of channels associated with this section. */
|
package/dist/esm/section-ref.js
CHANGED
|
@@ -53,12 +53,13 @@ async function resolveMetaDataTypeId(httpClient, sectionMetaDataType) {
|
|
|
53
53
|
* child section creation, and deletion — all from a single entry point.
|
|
54
54
|
*/
|
|
55
55
|
export class SectionRef {
|
|
56
|
-
constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn) {
|
|
56
|
+
constructor(httpClient, sectionId, defaultLanguage, mediaCreateFn, cache) {
|
|
57
57
|
this.httpClient = httpClient;
|
|
58
58
|
this.sectionId = sectionId;
|
|
59
59
|
this.defaultLanguage = defaultLanguage;
|
|
60
60
|
this.mediaCreateFn = mediaCreateFn ?? null;
|
|
61
|
-
this.
|
|
61
|
+
this.cache = cache;
|
|
62
|
+
this.content = new ContentResource(httpClient, sectionId, defaultLanguage, this.mediaCreateFn, this.cache);
|
|
62
63
|
}
|
|
63
64
|
// ── Section metadata ──
|
|
64
65
|
/** Returns a mutable section object. Modify properties and call save() to persist. */
|
|
@@ -73,7 +74,7 @@ export class SectionRef {
|
|
|
73
74
|
const meta = raw.metaData;
|
|
74
75
|
if (meta?.enabled && meta.id) {
|
|
75
76
|
try {
|
|
76
|
-
const metaContent = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
|
|
77
|
+
const metaContent = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
|
|
77
78
|
const item = await metaContent.get(meta.id, { language });
|
|
78
79
|
customFields = stripNameField(item.fields ?? null);
|
|
79
80
|
}
|
|
@@ -641,7 +642,7 @@ export class SectionRef {
|
|
|
641
642
|
if (metaContentTypeId) {
|
|
642
643
|
// Use a ContentResource on the new section to get full element resolution
|
|
643
644
|
// (list values, SS links, file uploads, etc.)
|
|
644
|
-
const metadataContentResource = new ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn);
|
|
645
|
+
const metadataContentResource = new ContentResource(this.httpClient, newSectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
|
|
645
646
|
// Create the metadata content item (ContentResource.create handles the full body)
|
|
646
647
|
const metaItem = await metadataContentResource.create({
|
|
647
648
|
type: metaContentTypeId,
|
|
@@ -730,7 +731,7 @@ export class SectionRef {
|
|
|
730
731
|
if (!metaDataTypeId) {
|
|
731
732
|
throw new Error('Cannot update customFields: no Section Meta Data content type is configured on this T4 instance');
|
|
732
733
|
}
|
|
733
|
-
const contentResource = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn);
|
|
734
|
+
const contentResource = new ContentResource(this.httpClient, this.sectionId, this.defaultLanguage, this.mediaCreateFn, this.cache);
|
|
734
735
|
const metaContentId = meta?.id ?? 0;
|
|
735
736
|
if (metaContentId > 0) {
|
|
736
737
|
await contentResource.update(metaContentId, { fields: data.customFields });
|
package/dist/esm/t4-client.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export declare class T4Client {
|
|
|
35
35
|
private readonly httpClient;
|
|
36
36
|
private readonly defaultLanguage;
|
|
37
37
|
private readonly mediaCreateFn;
|
|
38
|
+
private readonly contentCache;
|
|
38
39
|
constructor(config: T4ClientConfig);
|
|
39
40
|
/**
|
|
40
41
|
* Returns a section reference scoped to the given section ID.
|