@startinblox/components-ds4go 1.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/.gitlab-ci.yml +57 -0
- package/.storybook/main.ts +17 -0
- package/.storybook/preview-head.html +8 -0
- package/.storybook/preview.ts +22 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/biome.json +50 -0
- package/cypress/component/solid-boilerplate.cy.ts +9 -0
- package/cypress/support/component-index.html +12 -0
- package/cypress/support/component.ts +17 -0
- package/cypress.config.ts +11 -0
- package/dist/components-ds4go.css +1 -0
- package/dist/index.js +1634 -0
- package/lit-localize.json +15 -0
- package/locales/en.xlf +28 -0
- package/package.json +93 -0
- package/postcss.config.js +8 -0
- package/src/component.d.ts +167 -0
- package/src/components/catalog/ds4go-fact-bundle-holder.ts +162 -0
- package/src/components/modal/ds4go-fact-bundle-modal.ts +82 -0
- package/src/components/solid-fact-bundle.ts +225 -0
- package/src/context.json +1 -0
- package/src/helpers/components/ResourceMapper.ts +450 -0
- package/src/helpers/components/componentObjectHandler.ts +22 -0
- package/src/helpers/components/componentObjectsHandler.ts +14 -0
- package/src/helpers/components/dspComponent.ts +243 -0
- package/src/helpers/components/orbitComponent.ts +273 -0
- package/src/helpers/components/setupCacheInvalidation.ts +44 -0
- package/src/helpers/components/setupCacheOnResourceReady.ts +39 -0
- package/src/helpers/components/setupComponentSubscriptions.ts +73 -0
- package/src/helpers/components/setupOnSaveReset.ts +20 -0
- package/src/helpers/datas/dataBuilder.ts +43 -0
- package/src/helpers/datas/filterGenerator.ts +29 -0
- package/src/helpers/datas/filterObjectByDateAfter.ts +80 -0
- package/src/helpers/datas/filterObjectById.ts +54 -0
- package/src/helpers/datas/filterObjectByInterval.ts +133 -0
- package/src/helpers/datas/filterObjectByNamedValue.ts +103 -0
- package/src/helpers/datas/filterObjectByType.ts +30 -0
- package/src/helpers/datas/filterObjectByValue.ts +81 -0
- package/src/helpers/datas/sort.ts +40 -0
- package/src/helpers/i18n/configureLocalization.ts +17 -0
- package/src/helpers/index.ts +43 -0
- package/src/helpers/mappings/dsp-mapping-config.ts +545 -0
- package/src/helpers/ui/formatDate.ts +18 -0
- package/src/helpers/ui/lipsum.ts +12 -0
- package/src/helpers/utils/requestNavigation.ts +12 -0
- package/src/helpers/utils/uniq.ts +6 -0
- package/src/index.ts +14 -0
- package/src/initializer.ts +11 -0
- package/src/mocks/orbit.mock.ts +33 -0
- package/src/mocks/user.mock.ts +67 -0
- package/src/styles/_helpers/flex.scss +39 -0
- package/src/styles/index.scss +14 -0
- package/src/styles/modal/ds4go-fact-bundle-modal.scss +89 -0
- package/tsconfig.json +36 -0
- package/vite.config.ts +48 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { formatDate, OrbitComponent, requestNavigation, sort } from "@helpers";
|
|
2
|
+
import { msg } from "@lit/localize";
|
|
3
|
+
import { Task } from "@lit/task";
|
|
4
|
+
import type { PropertiesPicker, Resource, SearchObject } from "@src/component";
|
|
5
|
+
import { css, html, nothing } from "lit";
|
|
6
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
7
|
+
|
|
8
|
+
@customElement("solid-fact-bundle")
|
|
9
|
+
export class SolidFactBundle extends OrbitComponent {
|
|
10
|
+
static styles = css`
|
|
11
|
+
.modal {
|
|
12
|
+
position: fixed;
|
|
13
|
+
top: 0;
|
|
14
|
+
left: 0;
|
|
15
|
+
right: 0;
|
|
16
|
+
bottom: 0;
|
|
17
|
+
background-color: rgba(0, 2, 49, 0.2);
|
|
18
|
+
z-index: 9999;
|
|
19
|
+
display: flex;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
align-items: center;
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
@property({ attribute: "header", type: String })
|
|
26
|
+
header?: string = "DS4GO Fact Bundling";
|
|
27
|
+
|
|
28
|
+
@state()
|
|
29
|
+
search: SearchObject[] = [];
|
|
30
|
+
|
|
31
|
+
@state()
|
|
32
|
+
resultCount = this.objects?.length || 0;
|
|
33
|
+
|
|
34
|
+
cherryPickedProperties: PropertiesPicker[] = [
|
|
35
|
+
{ key: "created_at", value: "created_at", cast: formatDate },
|
|
36
|
+
{ key: "updated_at", value: "updated_at", cast: formatDate },
|
|
37
|
+
{ key: "name", value: "name" },
|
|
38
|
+
{ key: "description", value: "description" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
async _responseAdaptator(response: Resource): Promise<Resource> {
|
|
42
|
+
if (response?._originalResource?.hasType("ds4go:FactBundle")) {
|
|
43
|
+
response.facts = await Promise.all(
|
|
44
|
+
(await response._originalResource["ldp:contains"]).map(
|
|
45
|
+
(fact: Resource) => {
|
|
46
|
+
return this._getProxyValue(fact["@id"], false, [
|
|
47
|
+
{ key: "updated_at", value: "updated_at", cast: formatDate },
|
|
48
|
+
{ key: "name", value: "name" },
|
|
49
|
+
{ key: "description", value: "description" },
|
|
50
|
+
{
|
|
51
|
+
key: "categories",
|
|
52
|
+
value: "categories",
|
|
53
|
+
expand: true,
|
|
54
|
+
cast: (c: Resource[]) => sort(c, "name"),
|
|
55
|
+
},
|
|
56
|
+
{ key: "author", value: "author" },
|
|
57
|
+
{ key: "link", value: "link" },
|
|
58
|
+
{ key: "enclosure", value: "enclosure" },
|
|
59
|
+
{ key: "medias", value: "medias", expand: true },
|
|
60
|
+
{ key: "url", value: "url" },
|
|
61
|
+
{ key: "file_size", value: "file_size" },
|
|
62
|
+
{ key: "file_type", value: "file_type" },
|
|
63
|
+
{ key: "width", value: "width" },
|
|
64
|
+
{ key: "height", value: "height" },
|
|
65
|
+
// Can't get raw review json, as store does not support datas without ids
|
|
66
|
+
// { key: "review", value: "review" },
|
|
67
|
+
]);
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
console.log(response.facts);
|
|
72
|
+
}
|
|
73
|
+
return Promise.resolve(response);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _afterAttach() {
|
|
77
|
+
this.menuComponent = document.querySelector(
|
|
78
|
+
`[uniq="${this.orbit?.getComponent("menu")?.uniq}"]`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_getResource = new Task(this, {
|
|
85
|
+
task: async ([dataSrc, objSrc]) => {
|
|
86
|
+
if (
|
|
87
|
+
(!dataSrc && !objSrc) ||
|
|
88
|
+
!this.orbit ||
|
|
89
|
+
(!this.noRouter &&
|
|
90
|
+
this.route &&
|
|
91
|
+
this.currentRoute &&
|
|
92
|
+
!this.route.startsWith(this.currentRoute))
|
|
93
|
+
)
|
|
94
|
+
return;
|
|
95
|
+
|
|
96
|
+
if (!this.menuComponent.ready) {
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
this.menuComponent.addEventListener("user-ready", () => {
|
|
99
|
+
resolve(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.displayFiltering = !this.component.parameters.disableFiltering;
|
|
105
|
+
|
|
106
|
+
if (!this.hasCachedDatas || this.oldDataSrc !== dataSrc) {
|
|
107
|
+
if (!dataSrc) return;
|
|
108
|
+
|
|
109
|
+
this.objects = (await this._getProxyValue(dataSrc)) as Resource[];
|
|
110
|
+
this.hasCachedDatas = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (this.oldDataSrc !== dataSrc) {
|
|
114
|
+
this.oldDataSrc = dataSrc;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!Array.isArray(this.objects)) {
|
|
118
|
+
this.objects = [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.object = this.objects.find((obj: Resource) => obj["@id"] === objSrc);
|
|
122
|
+
|
|
123
|
+
return sort(this.objects, "name", "asc");
|
|
124
|
+
},
|
|
125
|
+
args: () => [
|
|
126
|
+
this.defaultDataSrc,
|
|
127
|
+
this.dataSrc,
|
|
128
|
+
this.caching,
|
|
129
|
+
this.currentRoute,
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
_search(e: Event) {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
this.search = e.detail;
|
|
136
|
+
this.filterCount = this.search.filter((s) => s.name !== "search").length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_openModal(e: Event) {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
if (this.route) {
|
|
142
|
+
if ("use-id" in (this.component.routeAttributes || {})) {
|
|
143
|
+
requestNavigation(this.route, e.detail["@id"]);
|
|
144
|
+
} else {
|
|
145
|
+
const rdfType = e.detail["@type"]?.at(-1) ?? e.detail["@type"];
|
|
146
|
+
if (rdfType) {
|
|
147
|
+
const compatibleComponents = window.orbit?.components?.filter(
|
|
148
|
+
(c) => c?.routeAttributes?.["rdf-type"] === rdfType,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (compatibleComponents?.[0]?.route) {
|
|
152
|
+
requestNavigation(compatibleComponents[0]?.route, e.detail["@id"]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_closeModal(e: Event) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
if (this.route) requestNavigation(this.route, this.defaultDataSrc);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_closeModalFromBackground(e: Event) {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
if (this.route && e.target?.classList.contains("modal"))
|
|
167
|
+
requestNavigation(this.route, this.defaultDataSrc);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_resultCountUpdate(e: Event) {
|
|
171
|
+
this.resultCount = e.detail ?? 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
render() {
|
|
175
|
+
return (
|
|
176
|
+
this.gatekeeper() ||
|
|
177
|
+
this._getResource.render({
|
|
178
|
+
pending: () => html`<solid-loader></solid-loader>`,
|
|
179
|
+
error: (e) => {
|
|
180
|
+
console.warn("[solid-fact-bundle] Task error:", e);
|
|
181
|
+
return nothing;
|
|
182
|
+
},
|
|
183
|
+
complete: (datas) => {
|
|
184
|
+
return html`<tems-viewport>
|
|
185
|
+
<tems-header slot="header" heading=${this.header}>
|
|
186
|
+
<div slot="cta">
|
|
187
|
+
<tems-button
|
|
188
|
+
type="primary"
|
|
189
|
+
label=${msg("Create a bundle")}
|
|
190
|
+
></tems-button>
|
|
191
|
+
</div>
|
|
192
|
+
</tems-header>
|
|
193
|
+
<div slot="content">
|
|
194
|
+
<tems-catalog-filter-holder
|
|
195
|
+
.displayFiltering=${this.displayFiltering}
|
|
196
|
+
@search=${this._search}
|
|
197
|
+
.search=${this.search}
|
|
198
|
+
.objects=${datas}
|
|
199
|
+
.resultCount=${this.resultCount}
|
|
200
|
+
.filterCount=${this.filterCount}
|
|
201
|
+
></tems-catalog-filter-holder>
|
|
202
|
+
<ds4go-fact-bundle-holder
|
|
203
|
+
.objects=${datas}
|
|
204
|
+
.search=${this.search}
|
|
205
|
+
@clicked=${this._openModal}
|
|
206
|
+
@result-count=${this._resultCountUpdate}
|
|
207
|
+
></ds4go-fact-bundle-holder>
|
|
208
|
+
${this.object
|
|
209
|
+
? html`<div
|
|
210
|
+
class="modal"
|
|
211
|
+
@click=${this._closeModalFromBackground}
|
|
212
|
+
>
|
|
213
|
+
<ds4go-fact-bundle-modal
|
|
214
|
+
.object=${this.object}
|
|
215
|
+
@close=${this._closeModal}
|
|
216
|
+
></ds4go-fact-bundle-modal>
|
|
217
|
+
</div>`
|
|
218
|
+
: nothing}
|
|
219
|
+
</div>
|
|
220
|
+
</tems-viewport>`;
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/context.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resource Mapper Service
|
|
3
|
+
*
|
|
4
|
+
* Provides unified, configurable mapping from source data formats (FC self-descriptions,
|
|
5
|
+
* DSP datasets, etc.) to TEMS-compatible resource objects with LDP containers.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - Stores (sib-core): Fetch raw data
|
|
9
|
+
* - ResourceMapper (solid-tems): Transform raw data to TEMS format
|
|
10
|
+
* - Components (solid-tems): Configure mapper and render UI
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const mapper = new ResourceMapper(fcMappingConfig);
|
|
15
|
+
* const temsResource = mapper.map(sourceData, context);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// import type { Resource } from '@startinblox/core';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Types and Interfaces
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export interface ResourceMapperConfig {
|
|
26
|
+
/**
|
|
27
|
+
* Base field mappings (simple property extractions)
|
|
28
|
+
*/
|
|
29
|
+
baseFields: Record<string, FieldMapping>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* LDP Container field mappings (arrays wrapped in ldp:Container)
|
|
33
|
+
*/
|
|
34
|
+
containerFields?: Record<string, ContainerFieldMapping>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Nested object mappings
|
|
38
|
+
*/
|
|
39
|
+
nestedObjects?: Record<string, NestedObjectMapping>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Contract negotiation field mappings
|
|
43
|
+
*/
|
|
44
|
+
contractFields?: Record<string, FieldMapping>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Optional custom post-processing function
|
|
48
|
+
*/
|
|
49
|
+
postProcess?: (resource: Resource, source: any, context: MappingContext) => Resource;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FieldMapping {
|
|
53
|
+
/**
|
|
54
|
+
* Path to source value (array of keys to traverse)
|
|
55
|
+
*/
|
|
56
|
+
source: string[];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Optional transform function
|
|
60
|
+
*/
|
|
61
|
+
transform?: (value: any, source: any, context: MappingContext) => any;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional fallback if source is not found
|
|
65
|
+
*/
|
|
66
|
+
fallback?: string | ((source: any, context: MappingContext) => any);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Optional default value if source and fallback are not found
|
|
70
|
+
*/
|
|
71
|
+
defaultValue?: any;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ContainerFieldMapping {
|
|
75
|
+
/**
|
|
76
|
+
* Path to source array
|
|
77
|
+
*/
|
|
78
|
+
source: string[];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* LDP container type (e.g., 'ldp:Container')
|
|
82
|
+
*/
|
|
83
|
+
containerType: string;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Item type for contained objects (e.g., 'tems:Category')
|
|
87
|
+
*/
|
|
88
|
+
itemType: string;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Field mappings for each item
|
|
92
|
+
*/
|
|
93
|
+
itemFields: Record<string, (item: any, index: number, context: MappingContext) => any>;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Optional filter function to include/exclude items
|
|
97
|
+
*/
|
|
98
|
+
filter?: (item: any, index: number, context: MappingContext) => boolean;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Optional transform for the entire array before item mapping
|
|
102
|
+
*/
|
|
103
|
+
transform?: (value: any, source: any, context: MappingContext) => any[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface NestedObjectMapping {
|
|
107
|
+
/**
|
|
108
|
+
* Path to source object
|
|
109
|
+
*/
|
|
110
|
+
source: string[];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Field mappings for the nested object
|
|
114
|
+
*/
|
|
115
|
+
fields: Record<string, string[] | FieldMapping>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Optional type for the nested object
|
|
119
|
+
*/
|
|
120
|
+
type?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface MappingContext {
|
|
124
|
+
/**
|
|
125
|
+
* Base URL for generating @id values
|
|
126
|
+
*/
|
|
127
|
+
temsServiceBase?: string;
|
|
128
|
+
temsCategoryBase?: string;
|
|
129
|
+
temsImageBase?: string;
|
|
130
|
+
temsProviderBase?: string;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Additional context data (provider info, etc.)
|
|
134
|
+
*/
|
|
135
|
+
[key: string]: any;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// Transform Functions
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Strips URN prefixes from IDs
|
|
144
|
+
*/
|
|
145
|
+
export function stripUrnPrefix(value: string | undefined): string {
|
|
146
|
+
if (!value) return '';
|
|
147
|
+
return value
|
|
148
|
+
.replace(/^urn:uuid:/i, '')
|
|
149
|
+
.replace(/^urn:tems:/i, '');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Processes ODRL policy: strips URN prefixes and adds target field
|
|
154
|
+
*/
|
|
155
|
+
export function processPolicyTransform(
|
|
156
|
+
policy: any,
|
|
157
|
+
source: any,
|
|
158
|
+
_context: MappingContext
|
|
159
|
+
): any {
|
|
160
|
+
if (!policy) return null;
|
|
161
|
+
|
|
162
|
+
const processedPolicy = JSON.parse(JSON.stringify(policy)); // Deep clone
|
|
163
|
+
|
|
164
|
+
// Strip URN prefix from policy @id
|
|
165
|
+
if (processedPolicy['@id']) {
|
|
166
|
+
processedPolicy['@id'] = stripUrnPrefix(processedPolicy['@id']);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Add target field (asset ID)
|
|
170
|
+
const assetId = getNestedValue(source, ['@id']) || '';
|
|
171
|
+
processedPolicy['target'] = stripUrnPrefix(assetId);
|
|
172
|
+
|
|
173
|
+
return processedPolicy;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extracts first value from array or returns the value itself
|
|
178
|
+
*/
|
|
179
|
+
export function firstOrSelf(value: any): any {
|
|
180
|
+
return Array.isArray(value) && value.length > 0 ? value[0] : value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generates unique ID based on name
|
|
185
|
+
*/
|
|
186
|
+
export function generateIdFromName(name: string, base: string): string {
|
|
187
|
+
const slug = name
|
|
188
|
+
.toLowerCase()
|
|
189
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
190
|
+
.replace(/^-+|-+$/g, '');
|
|
191
|
+
return `${base}${slug}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// Helper Functions
|
|
196
|
+
// ============================================================================
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Gets nested value from object using path array
|
|
200
|
+
*/
|
|
201
|
+
export function getNestedValue(obj: any, path: string[]): any {
|
|
202
|
+
// If path is empty, return undefined (don't return the entire object)
|
|
203
|
+
// Empty paths are used when the field should be computed via transform or use defaultValue
|
|
204
|
+
if (path.length === 0) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let current = obj;
|
|
209
|
+
for (const key of path) {
|
|
210
|
+
if (current === null || current === undefined) {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
current = current[key];
|
|
214
|
+
}
|
|
215
|
+
return current;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Sets nested value in object using path array
|
|
220
|
+
*/
|
|
221
|
+
export function setNestedValue(obj: any, path: string[], value: any): void {
|
|
222
|
+
if (path.length === 0) return;
|
|
223
|
+
|
|
224
|
+
let current = obj;
|
|
225
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
226
|
+
const key = path[i];
|
|
227
|
+
if (!(key in current)) {
|
|
228
|
+
current[key] = {};
|
|
229
|
+
}
|
|
230
|
+
current = current[key];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
current[path[path.length - 1]] = value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================================
|
|
237
|
+
// ResourceMapper Class
|
|
238
|
+
// ============================================================================
|
|
239
|
+
|
|
240
|
+
export class ResourceMapper {
|
|
241
|
+
private config: ResourceMapperConfig;
|
|
242
|
+
|
|
243
|
+
constructor(config: ResourceMapperConfig) {
|
|
244
|
+
this.config = config;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Maps source data to TEMS-compatible resource
|
|
249
|
+
*/
|
|
250
|
+
map(source: any, context: MappingContext = {}): Resource {
|
|
251
|
+
const resource: Resource = {};
|
|
252
|
+
|
|
253
|
+
// Map base fields
|
|
254
|
+
for (const [destKey, mapping] of Object.entries(this.config.baseFields)) {
|
|
255
|
+
const value = this.mapField(source, mapping, context);
|
|
256
|
+
if (value !== undefined) {
|
|
257
|
+
resource[destKey] = value;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Map container fields
|
|
262
|
+
if (this.config.containerFields) {
|
|
263
|
+
for (const [destKey, mapping] of Object.entries(this.config.containerFields)) {
|
|
264
|
+
const container = this.mapContainerField(source, mapping, context);
|
|
265
|
+
if (container) {
|
|
266
|
+
resource[destKey] = container;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Map nested objects
|
|
272
|
+
if (this.config.nestedObjects) {
|
|
273
|
+
for (const [destKey, mapping] of Object.entries(this.config.nestedObjects)) {
|
|
274
|
+
const nestedObj = this.mapNestedObject(source, mapping, context);
|
|
275
|
+
if (nestedObj) {
|
|
276
|
+
resource[destKey] = nestedObj;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Map contract fields
|
|
282
|
+
if (this.config.contractFields) {
|
|
283
|
+
for (const [destKey, mapping] of Object.entries(this.config.contractFields)) {
|
|
284
|
+
const value = this.mapField(source, mapping, context);
|
|
285
|
+
if (value !== undefined) {
|
|
286
|
+
resource[destKey] = value;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Apply post-processing
|
|
292
|
+
if (this.config.postProcess) {
|
|
293
|
+
return this.config.postProcess(resource, source, context);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return resource;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Maps a single field
|
|
301
|
+
*/
|
|
302
|
+
private mapField(source: any, mapping: FieldMapping, context: MappingContext): any {
|
|
303
|
+
// Get source value
|
|
304
|
+
let value = getNestedValue(source, mapping.source);
|
|
305
|
+
|
|
306
|
+
// Apply transform
|
|
307
|
+
if (mapping.transform) {
|
|
308
|
+
value = mapping.transform(value, source, context);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Apply fallback
|
|
312
|
+
if (value === undefined || value === null || value === '') {
|
|
313
|
+
if (mapping.fallback) {
|
|
314
|
+
if (typeof mapping.fallback === 'function') {
|
|
315
|
+
value = mapping.fallback(source, context);
|
|
316
|
+
} else {
|
|
317
|
+
// Fallback is a path to another field
|
|
318
|
+
value = getNestedValue(source, [mapping.fallback]);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Apply default value
|
|
324
|
+
if (value === undefined || value === null || value === '') {
|
|
325
|
+
value = mapping.defaultValue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Maps a container field (array wrapped in LDP container)
|
|
333
|
+
*/
|
|
334
|
+
private mapContainerField(
|
|
335
|
+
source: any,
|
|
336
|
+
mapping: ContainerFieldMapping,
|
|
337
|
+
context: MappingContext
|
|
338
|
+
): any {
|
|
339
|
+
// Get source array
|
|
340
|
+
let sourceArray = getNestedValue(source, mapping.source);
|
|
341
|
+
|
|
342
|
+
if (!sourceArray) {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Apply array-level transform
|
|
347
|
+
if (mapping.transform) {
|
|
348
|
+
sourceArray = mapping.transform(sourceArray, source, context);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Ensure it's an array
|
|
352
|
+
if (!Array.isArray(sourceArray)) {
|
|
353
|
+
sourceArray = [sourceArray];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Filter items
|
|
357
|
+
if (mapping.filter) {
|
|
358
|
+
sourceArray = sourceArray.filter((item, index) =>
|
|
359
|
+
mapping.filter!(item, index, context)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Map items
|
|
364
|
+
const items = sourceArray.map((item, index) => {
|
|
365
|
+
const mappedItem: any = {
|
|
366
|
+
'@type': mapping.itemType,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
for (const [fieldKey, fieldFn] of Object.entries(mapping.itemFields)) {
|
|
370
|
+
const fieldValue = fieldFn(item, index, context);
|
|
371
|
+
if (fieldValue !== undefined) {
|
|
372
|
+
mappedItem[fieldKey] = fieldValue;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return mappedItem;
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// Return LDP container
|
|
380
|
+
return {
|
|
381
|
+
'@type': mapping.containerType,
|
|
382
|
+
'ldp:contains': items,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Maps a nested object
|
|
388
|
+
*/
|
|
389
|
+
private mapNestedObject(
|
|
390
|
+
source: any,
|
|
391
|
+
mapping: NestedObjectMapping,
|
|
392
|
+
context: MappingContext
|
|
393
|
+
): any {
|
|
394
|
+
// Get source object
|
|
395
|
+
const sourceObj = getNestedValue(source, mapping.source);
|
|
396
|
+
|
|
397
|
+
if (!sourceObj) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const nestedObj: any = {};
|
|
402
|
+
|
|
403
|
+
// Add type if specified
|
|
404
|
+
if (mapping.type) {
|
|
405
|
+
nestedObj['@type'] = mapping.type;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Map fields
|
|
409
|
+
for (const [destKey, fieldMapping] of Object.entries(mapping.fields)) {
|
|
410
|
+
let value: any;
|
|
411
|
+
|
|
412
|
+
if (Array.isArray(fieldMapping)) {
|
|
413
|
+
// Simple path mapping
|
|
414
|
+
value = getNestedValue(sourceObj, fieldMapping);
|
|
415
|
+
} else {
|
|
416
|
+
// Full FieldMapping
|
|
417
|
+
value = this.mapField(sourceObj, fieldMapping, context);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (value !== undefined) {
|
|
421
|
+
nestedObj[destKey] = value;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return Object.keys(nestedObj).length > (mapping.type ? 1 : 0) ? nestedObj : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Unwraps LDP containers (converts ldp:contains arrays to plain arrays)
|
|
430
|
+
*
|
|
431
|
+
* This is typically called by components after mapping to simplify data structure
|
|
432
|
+
* for rendering.
|
|
433
|
+
*/
|
|
434
|
+
static unwrapLdpContainers(resource: Resource): Resource {
|
|
435
|
+
const unwrapped: Resource = { ...resource };
|
|
436
|
+
|
|
437
|
+
for (const [key, value] of Object.entries(unwrapped)) {
|
|
438
|
+
if (
|
|
439
|
+
value &&
|
|
440
|
+
typeof value === 'object' &&
|
|
441
|
+
value['@type'] === 'ldp:Container' &&
|
|
442
|
+
value['ldp:contains']
|
|
443
|
+
) {
|
|
444
|
+
unwrapped[key] = value['ldp:contains'];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return unwrapped;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LimitedResource,
|
|
3
|
+
Resource,
|
|
4
|
+
} from "@src/component";
|
|
5
|
+
import { LitElement } from "lit";
|
|
6
|
+
|
|
7
|
+
export class ComponentObjectHandler extends LitElement {
|
|
8
|
+
object?: LimitedResource = {
|
|
9
|
+
"@id": ""
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
isType = (type: string, obj: Resource = (this.object as Resource)) => {
|
|
13
|
+
const typeValue = obj["@type"];
|
|
14
|
+
if (Array.isArray(typeValue)) {
|
|
15
|
+
return typeValue.includes(type);
|
|
16
|
+
}
|
|
17
|
+
if (typeof typeValue === "string") {
|
|
18
|
+
return typeValue === type;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Resource } from "@src/component";
|
|
2
|
+
import { LitElement } from "lit";
|
|
3
|
+
|
|
4
|
+
import { property } from "lit/decorators.js";
|
|
5
|
+
|
|
6
|
+
export class ComponentObjectsHandler extends LitElement {
|
|
7
|
+
@property({ attribute: false })
|
|
8
|
+
objects?: Resource[] = [];
|
|
9
|
+
|
|
10
|
+
hasType = (type: string, objs: Resource[] = this.objects as Resource[]) => {
|
|
11
|
+
const typeValue = new Set(objs.flatMap((o) => o["@type"]));
|
|
12
|
+
return typeValue.has(type);
|
|
13
|
+
};
|
|
14
|
+
}
|