@cap-js/change-tracking 1.1.2 → 1.1.4
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/CHANGELOG.md +13 -1
- package/cds-plugin.js +229 -223
- package/lib/change-log.js +469 -506
- package/lib/entity-helper.js +187 -186
- package/lib/format-options.js +62 -62
- package/lib/localization.js +103 -95
- package/lib/template-processor.js +86 -82
- package/package.json +47 -39
package/lib/localization.js
CHANGED
|
@@ -1,127 +1,135 @@
|
|
|
1
|
-
const cds = require(
|
|
2
|
-
const LOG = cds.log(
|
|
3
|
-
const { formatOptions } = require(
|
|
4
|
-
const { getNameFromPathVal, getDBEntity, splitPath } = require(
|
|
1
|
+
const cds = require('@sap/cds/lib');
|
|
2
|
+
const LOG = cds.log('change-log');
|
|
3
|
+
const { formatOptions } = require('./format-options');
|
|
4
|
+
const { getNameFromPathVal, getDBEntity, splitPath } = require('./entity-helper');
|
|
5
5
|
|
|
6
6
|
const MODIF_I18N_MAP = {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
create: 'ChangeLog.modification.create',
|
|
8
|
+
update: 'ChangeLog.modification.update',
|
|
9
|
+
delete: 'ChangeLog.modification.delete'
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
const _localizeModification = function (change) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
if (change.modification && MODIF_I18N_MAP[change.modification]) {
|
|
14
|
+
change.modification = cds.i18n.labels.for(MODIF_I18N_MAP[change.modification]);
|
|
15
|
+
}
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
const _localizeDefaultObjectID = function (change) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
19
|
+
if (!change.objectID) {
|
|
20
|
+
change.objectID = change.entity ? change.entity : '';
|
|
21
|
+
}
|
|
22
|
+
if (change.objectID && change.serviceEntityPath && !change.parentObjectID && change.parentKey) {
|
|
23
|
+
const path = splitPath(change.serviceEntityPath);
|
|
24
|
+
const parentNodePathVal = path[path.length - 2];
|
|
25
|
+
const parentEntityName = getNameFromPathVal(parentNodePathVal);
|
|
26
|
+
const dbEntity = getDBEntity(parentEntityName);
|
|
27
|
+
try {
|
|
28
|
+
const labelI18nKey = getTranslationKey(dbEntity['@Common.Label'] || dbEntity['@title']);
|
|
29
|
+
change.parentObjectID = cds.i18n.labels.for(labelI18nKey) || labelI18nKey || dbEntity.name;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
LOG.error('Failed to localize parent object id', e);
|
|
32
|
+
throw new Error('Failed to localize parent object id', e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
const _localizeEntityType = function (change) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
38
|
+
if (change.entity) {
|
|
39
|
+
try {
|
|
40
|
+
const labelI18nKey = _getLabelI18nKeyOnEntity(change.serviceEntity);
|
|
41
|
+
change.entity = labelI18nKey || change.entity;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
LOG.error('Failed to localize entity type', e);
|
|
44
|
+
throw new Error('Failed to localize entity type', e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (change.serviceEntity) {
|
|
48
|
+
try {
|
|
49
|
+
const labelI18nKey = _getLabelI18nKeyOnEntity(change.serviceEntity);
|
|
50
|
+
change.serviceEntity = labelI18nKey || change.serviceEntity;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
LOG.error('Failed to localize service entity', e);
|
|
53
|
+
throw new Error('Failed to localize service entity', e);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
const getTranslationKey = (value) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
59
|
+
if (typeof value != 'string') return value;
|
|
60
|
+
const result = value.match(/(?<=\{@?(i18n>)).*(?=\})/g);
|
|
61
|
+
return result ? result[0] : value;
|
|
62
|
+
};
|
|
63
63
|
|
|
64
64
|
const _localizeAttribute = function (change) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
65
|
+
if (change.attribute && change.serviceEntity) {
|
|
66
|
+
const model = cds.context?.model ?? cds.model;
|
|
67
|
+
try {
|
|
68
|
+
const serviceEntity = model.definitions[change.serviceEntity];
|
|
69
|
+
if (!serviceEntity) {
|
|
70
|
+
LOG.warn(`Cannot localize the attribute ${change.attribute} of ${change.serviceEntity}, because the service entity is not defined in "cds.model.definitions".`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let labelI18nKey = _getLabelI18nKeyOnEntity(change.serviceEntity, change.attribute);
|
|
74
|
+
if (!labelI18nKey) {
|
|
75
|
+
const element = serviceEntity.elements[change.attribute];
|
|
76
|
+
if (!element) {
|
|
77
|
+
LOG.warn(`Cannot localize the attribute ${change.attribute} of ${change.serviceEntity}, because the attribute does not exist on the entity.`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (element.isAssociation) labelI18nKey = _getLabelI18nKeyOnEntity(element.target);
|
|
81
|
+
}
|
|
82
|
+
change.attribute = labelI18nKey || change.attribute;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
LOG.error('Failed to localize change attribute', e);
|
|
85
|
+
throw new Error('Failed to localize change attribute', e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
79
88
|
};
|
|
80
89
|
|
|
81
90
|
const _getLabelI18nKeyOnEntity = function (entityName, /** optinal */ attribute) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
const model = cds.context?.model ?? cds.model;
|
|
92
|
+
let def = model.definitions[entityName];
|
|
93
|
+
if (attribute) def = def?.elements[attribute];
|
|
94
|
+
if (!def) return '';
|
|
95
|
+
const i18nKey = getTranslationKey(def['@Common.Label'] || def['@title'] || def['@UI.HeaderInfo.TypeName']);
|
|
96
|
+
return cds.i18n.labels.for(i18nKey) || i18nKey;
|
|
87
97
|
};
|
|
88
98
|
|
|
89
99
|
const parseTime = (time, locale, options) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
const timeParts = time.split(':');
|
|
101
|
+
const date = new Date();
|
|
102
|
+
date.setHours(parseInt(timeParts[0], 10), parseInt(timeParts[1], 10), parseInt(timeParts[2], 10));
|
|
103
|
+
return date.toLocaleTimeString(locale, options);
|
|
94
104
|
};
|
|
95
105
|
|
|
96
106
|
const _localizeValue = (change, locale) => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
?? formatOptions[change.valueDataType]?.['en']
|
|
103
|
-
|
|
104
|
-
if (change.valueDataType === 'cds.Time') {
|
|
105
|
-
if (change.valueChangedFrom) change.valueChangedFrom = parseTime(change.valueChangedFrom, normalizedLocale, options);
|
|
106
|
-
if (change.valueChangedTo) change.valueChangedTo = parseTime(change.valueChangedTo, normalizedLocale, options);
|
|
107
|
-
} else {
|
|
108
|
-
const formatter = change.valueDataType === 'cds.Date' ? 'toLocaleDateString' : 'toLocaleString';
|
|
109
|
-
if (change.valueChangedFrom) change.valueChangedFrom = new Date(change.valueChangedFrom)[formatter](normalizedLocale, options);
|
|
110
|
-
if (change.valueChangedTo) change.valueChangedTo = new Date(change.valueChangedTo)[formatter](normalizedLocale, options);
|
|
111
|
-
}
|
|
107
|
+
if (change.valueDataType !== 'cds.Date' && change.valueDataType !== 'cds.DateTime' && change.valueDataType !== 'cds.Timestamp' && change.valueDataType !== 'cds.Time') {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const normalizedLocale = locale.replaceAll('_', '-');
|
|
111
|
+
const options = formatOptions[change.valueDataType]?.[normalizedLocale] ?? formatOptions[change.valueDataType]?.['en'];
|
|
112
112
|
|
|
113
|
+
if (change.valueDataType === 'cds.Time') {
|
|
114
|
+
if (change.valueChangedFrom) change.valueChangedFrom = parseTime(change.valueChangedFrom, normalizedLocale, options);
|
|
115
|
+
if (change.valueChangedTo) change.valueChangedTo = parseTime(change.valueChangedTo, normalizedLocale, options);
|
|
116
|
+
} else {
|
|
117
|
+
const formatter = change.valueDataType === 'cds.Date' ? 'toLocaleDateString' : 'toLocaleString';
|
|
118
|
+
if (change.valueChangedFrom) change.valueChangedFrom = new Date(change.valueChangedFrom)[formatter](normalizedLocale, options);
|
|
119
|
+
if (change.valueChangedTo) change.valueChangedTo = new Date(change.valueChangedTo)[formatter](normalizedLocale, options);
|
|
120
|
+
}
|
|
113
121
|
};
|
|
114
122
|
|
|
115
123
|
const localizeLogFields = function (data, locale) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
if (!locale) return;
|
|
125
|
+
for (const change of data) {
|
|
126
|
+
_localizeModification(change);
|
|
127
|
+
_localizeAttribute(change);
|
|
128
|
+
_localizeEntityType(change);
|
|
129
|
+
_localizeDefaultObjectID(change);
|
|
130
|
+
_localizeValue(change, locale);
|
|
131
|
+
}
|
|
124
132
|
};
|
|
125
133
|
module.exports = {
|
|
126
|
-
|
|
134
|
+
localizeLogFields
|
|
127
135
|
};
|
|
@@ -1,111 +1,115 @@
|
|
|
1
1
|
// Enhanced class based on cds v5.5.5 @sap/cds/libx/_runtime/common/utils/templateProcessor
|
|
2
2
|
|
|
3
|
-
const DELIMITER = require(
|
|
3
|
+
const DELIMITER = require('@sap/cds/libx/_runtime/common/utils/templateDelimiter');
|
|
4
4
|
|
|
5
5
|
const _formatRowContext = (tKey, keyNames, row) => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const keyValuePairs = keyNames.map((key) => `${key}=${row[key]}`);
|
|
7
|
+
const keyValuePairsSerialized = keyValuePairs.join(',');
|
|
8
|
+
return `${tKey}(${keyValuePairsSerialized})`;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
const _processElement = (processFn, row, key, elements, isRoot, pathSegments, picked = {}) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
const element = elements[key];
|
|
13
|
+
const { plain } = picked;
|
|
14
|
+
|
|
15
|
+
// do not change-track personal data
|
|
16
|
+
const isPersonalData = element && Object.keys(element).some((key) => key.startsWith('@PersonalData'));
|
|
17
|
+
if (plain && !isPersonalData) {
|
|
18
|
+
/**
|
|
19
|
+
* @type import('../../types/api').templateProcessorProcessFnArgs
|
|
20
|
+
*/
|
|
21
|
+
const elementInfo = { row, key, element, plain, isRoot, pathSegments };
|
|
22
|
+
processFn(elementInfo);
|
|
23
|
+
}
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
function retrieveFirstKey(target) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
const keys = [];
|
|
28
|
+
for (const key of Object.keys(target.keys)) {
|
|
29
|
+
if (target.keys[key].type !== 'cds.Association' && key !== 'IsActiveEntity') {
|
|
30
|
+
keys.push(key);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//Revisit: For test 3.5
|
|
35
|
+
return keys.filter((k) => !k.startsWith('up_'))[0];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const _processRow = (processFn, row, template, tKey, tValue, isRoot, pathOptions) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
39
|
+
const { template: subTemplate, picked } = tValue;
|
|
40
|
+
const key = tKey.split(DELIMITER).pop();
|
|
41
|
+
const { segments: pathSegments } = pathOptions;
|
|
42
|
+
|
|
43
|
+
if (!subTemplate && pathSegments) {
|
|
44
|
+
pathSegments.push(key);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_processElement(processFn, row, key, template.target.elements, isRoot, pathSegments, picked);
|
|
48
|
+
|
|
49
|
+
// process deep
|
|
50
|
+
if (subTemplate) {
|
|
51
|
+
let subRows = row && row[key];
|
|
52
|
+
|
|
53
|
+
subRows = Array.isArray(subRows) ? subRows : [subRows];
|
|
54
|
+
|
|
55
|
+
// Build entity path
|
|
56
|
+
subRows.forEach((subRow) => {
|
|
57
|
+
if (subRow && row && row._path) {
|
|
58
|
+
/** Enhancement by SME: Support CAP Change Histroy
|
|
59
|
+
* Construct path from root entity to current entity.
|
|
60
|
+
*/
|
|
61
|
+
const serviceNodeName = template.target.elements[key].target;
|
|
62
|
+
const serviceNode = template.target.elements[key]._target;
|
|
63
|
+
subRow._path = `${row._path}/${serviceNodeName}(${subRow[retrieveFirstKey(serviceNode)]})`;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
_processComplex(processFn, subRows, subTemplate, key, pathOptions);
|
|
68
|
+
}
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
const _processComplex = (processFn, rows, template, tKey, pathOptions) => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
if (rows.length === 0) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const segments = pathOptions.segments;
|
|
77
|
+
let keyNames;
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
if (row == null) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
const args = { processFn, row, template, isRoot: false, pathOptions };
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
if (pathOptions.includeKeyValues) {
|
|
87
|
+
keyNames = keyNames || (template.target.keys && Object.keys(template.target.keys)) || [];
|
|
88
|
+
pathOptions.rowKeysGenerator(keyNames, row, template);
|
|
89
|
+
const pathSegment = _formatRowContext(tKey, keyNames, { ...row, ...pathOptions.extraKeys });
|
|
90
|
+
args.pathOptions.segments = segments ? [...segments, pathSegment] : [pathSegment];
|
|
91
|
+
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
templateProcessor(args);
|
|
94
|
+
}
|
|
95
95
|
};
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* @param {import("../../types/api").TemplateProcessor} args
|
|
99
99
|
*/
|
|
100
100
|
const templateProcessor = ({ processFn, row, template, isRoot = true, pathOptions = {} }) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
const segments = pathOptions.segments && [...pathOptions.segments];
|
|
102
|
+
|
|
103
|
+
for (const [tKey, tValue] of template.elements) {
|
|
104
|
+
// When a managed association is marked with @changelog, both foreign key and assoc are in the template. Skip the association as only the foreign key has a change.
|
|
105
|
+
if (template.target.elements[tKey]?.type === 'cds.Association') {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (segments) {
|
|
109
|
+
pathOptions.segments = [...segments];
|
|
110
|
+
}
|
|
111
|
+
_processRow(processFn, row, template, tKey, tValue, isRoot, pathOptions);
|
|
112
|
+
}
|
|
109
113
|
};
|
|
110
114
|
|
|
111
|
-
module.exports = {templateProcessor, retrieveFirstKey};
|
|
115
|
+
module.exports = { templateProcessor, retrieveFirstKey };
|
package/package.json
CHANGED
|
@@ -1,41 +1,49 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
2
|
+
"name": "@cap-js/change-tracking",
|
|
3
|
+
"version": "1.1.4",
|
|
4
|
+
"description": "CDS plugin providing out-of-the box support for automatic capturing, storing, and viewing of the change records of modeled entities.",
|
|
5
|
+
"repository": "cap-js/change-tracking",
|
|
6
|
+
"author": "SAP SE (https://www.sap.com)",
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
|
+
"main": "cds-plugin.js",
|
|
9
|
+
"files": [
|
|
10
|
+
"lib",
|
|
11
|
+
"_i18n",
|
|
12
|
+
"index.cds",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"lint": "npx eslint .",
|
|
18
|
+
"test": "npx jest --silent",
|
|
19
|
+
"format": "npx -y prettier@3 . --write",
|
|
20
|
+
"format:check": "npx -y prettier@3 --check ."
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@sap/cds": ">=8.5"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@cap-js/attachments": "^2",
|
|
30
|
+
"@cap-js/cds-test": "*",
|
|
31
|
+
"@cap-js/change-tracking": "file:.",
|
|
32
|
+
"@cap-js/sqlite": "^1 || ^2",
|
|
33
|
+
"express": "^4"
|
|
34
|
+
},
|
|
35
|
+
"cds": {
|
|
36
|
+
"requires": {
|
|
37
|
+
"change-tracking": {
|
|
38
|
+
"model": "@cap-js/change-tracking",
|
|
39
|
+
"considerLocalizedValues": false
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"jest": {
|
|
44
|
+
"testTimeout": 10000
|
|
45
|
+
},
|
|
46
|
+
"workspaces": [
|
|
47
|
+
"tests/*"
|
|
48
|
+
]
|
|
41
49
|
}
|