Skip to content

Commit b6cd212

Browse files
committed
feat(DX-5740): add Live Preview editable tags to entry JSON
Implement EditableTags (addEditableTags, addTags, getTag) aligned with JS entry-editable behavior, plus EditableTagsOptions. Expose via Utils. Add JUnit tests for tagging. Ignore sample-lp-demo/ in .gitignore for local demos. Made-with: Cursor
1 parent 37981cf commit b6cd212

File tree

5 files changed

+588
-0
lines changed

5 files changed

+588
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,9 @@ gradle-app.setting
260260

261261
sample/
262262

263+
# Local-only Spring Boot LP demo (not committed by default)
264+
sample-lp-demo/
265+
263266
# End of https://www.toptal.com/developers/gitignore/api/macos,code-java,java-web,maven,gradle,intellij,visualstudiocode,eclipse
264267
.idea/compiler.xml
265268
.idea/encodings.xml
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
package com.contentstack.utils;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONObject;
5+
6+
import java.util.ArrayList;
7+
import java.util.Comparator;
8+
import java.util.Iterator;
9+
import java.util.List;
10+
import java.util.Objects;
11+
12+
/**
13+
* Live Preview editable tags (CSLP) — parity with contentstack-utils-javascript
14+
* {@code entry-editable.ts}.
15+
*/
16+
public final class EditableTags {
17+
18+
/**
19+
* Variant / meta-key state threaded through {@link #getTag(Object, String, boolean, String, AppliedVariantsState)}.
20+
*/
21+
public static final class AppliedVariantsState {
22+
private final JSONObject appliedVariants;
23+
private final boolean shouldApplyVariant;
24+
private final String metaKey;
25+
26+
public AppliedVariantsState(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
27+
this.appliedVariants = appliedVariants;
28+
this.shouldApplyVariant = shouldApplyVariant;
29+
this.metaKey = metaKey != null ? metaKey : "";
30+
}
31+
32+
public JSONObject getAppliedVariants() {
33+
return appliedVariants;
34+
}
35+
36+
public boolean isShouldApplyVariant() {
37+
return shouldApplyVariant;
38+
}
39+
40+
public String getMetaKey() {
41+
return metaKey;
42+
}
43+
}
44+
45+
private EditableTags() {
46+
}
47+
48+
/**
49+
* Adds Contentstack Live Preview (CSLP) data tags to an entry for editable UIs.
50+
* Mutates the entry by attaching a {@code $} property with tag strings or objects
51+
* ({@code data-cslp} / {@code data-cslp-parent-field}) for each field.
52+
*
53+
* @param entry CDA-style entry JSON (must not be {@code null}); must contain {@code uid}
54+
* @param contentTypeUid content type UID (e.g. {@code blog_post})
55+
* @param tagsAsObject if {@code true}, tags are JSON objects; if {@code false}, {@code data-cslp=...} strings
56+
* @param locale locale code (default in overloads: {@code en-us})
57+
* @param options optional; controls locale casing (default lowercases locale)
58+
*/
59+
public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
60+
EditableTagsOptions options) {
61+
if (entry == null) {
62+
return;
63+
}
64+
boolean useLowerCaseLocale = true;
65+
if (options != null) {
66+
useLowerCaseLocale = options.isUseLowerCaseLocale();
67+
}
68+
String ct = contentTypeUid == null ? "" : contentTypeUid.toLowerCase();
69+
String loc = locale == null ? "en-us" : locale;
70+
if (useLowerCaseLocale) {
71+
loc = loc.toLowerCase();
72+
}
73+
JSONObject applied = entry.optJSONObject("_applied_variants");
74+
if (applied == null) {
75+
JSONObject system = entry.optJSONObject("system");
76+
if (system != null) {
77+
applied = system.optJSONObject("applied_variants");
78+
}
79+
}
80+
boolean shouldApply = applied != null;
81+
String uid = entry.optString("uid", "");
82+
String prefix = ct + "." + uid + "." + loc;
83+
AppliedVariantsState state = new AppliedVariantsState(applied, shouldApply, "");
84+
entry.put("$", getTag(entry, prefix, tagsAsObject, loc, state));
85+
}
86+
87+
/**
88+
* @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
89+
*/
90+
public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject) {
91+
addEditableTags(entry, contentTypeUid, tagsAsObject, "en-us", null);
92+
}
93+
94+
/**
95+
* @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)
96+
*/
97+
public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale) {
98+
addEditableTags(entry, contentTypeUid, tagsAsObject, locale, null);
99+
}
100+
101+
/**
102+
* Alias for {@link #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)} — matches JS
103+
* {@code addTags}.
104+
*/
105+
public static void addTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale,
106+
EditableTagsOptions options) {
107+
addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options);
108+
}
109+
110+
/**
111+
* Recursive tag map for the given content (entry object or array). Exposed for parity with JS tests.
112+
*
113+
* @param content {@link JSONObject}, {@link JSONArray}, or null
114+
* @param prefix path prefix ({@code contentTypeUid.entryUid.locale...})
115+
* @param tagsAsObject string vs object tag form
116+
* @param locale locale for reference entries
117+
* @param appliedVariants variant state
118+
* @return map of field keys to tag string or tag object
119+
*/
120+
public static JSONObject getTag(Object content, String prefix, boolean tagsAsObject, String locale,
121+
AppliedVariantsState appliedVariants) {
122+
if (content == null || JSONObject.NULL.equals(content)) {
123+
return new JSONObject();
124+
}
125+
if (content instanceof JSONArray) {
126+
return getTagForArray((JSONArray) content, prefix, tagsAsObject, locale, appliedVariants);
127+
}
128+
if (content instanceof JSONObject) {
129+
return getTagForJSONObject((JSONObject) content, prefix, tagsAsObject, locale, appliedVariants);
130+
}
131+
return new JSONObject();
132+
}
133+
134+
private static JSONObject getTagForJSONObject(JSONObject content, String prefix, boolean tagsAsObject,
135+
String locale, AppliedVariantsState appliedVariants) {
136+
JSONObject tags = new JSONObject();
137+
Iterator<String> keys = content.keys();
138+
while (keys.hasNext()) {
139+
String key = keys.next();
140+
handleKey(tags, key, content.opt(key), prefix, tagsAsObject, locale, appliedVariants);
141+
}
142+
return tags;
143+
}
144+
145+
private static JSONObject getTagForArray(JSONArray content, String prefix, boolean tagsAsObject, String locale,
146+
AppliedVariantsState appliedVariants) {
147+
JSONObject tags = new JSONObject();
148+
for (int i = 0; i < content.length(); i++) {
149+
String key = Integer.toString(i);
150+
handleKey(tags, key, content.opt(i), prefix, tagsAsObject, locale, appliedVariants);
151+
}
152+
return tags;
153+
}
154+
155+
/** One entry from {@code Object.entries} — same structure for {@link JSONObject} and {@link JSONArray}. */
156+
private static void handleKey(JSONObject tags, String key, Object value, String prefix, boolean tagsAsObject,
157+
String locale, AppliedVariantsState appliedVariants) {
158+
if ("$".equals(key)) {
159+
return;
160+
}
161+
boolean shouldApplyVariant = appliedVariants.isShouldApplyVariant();
162+
JSONObject applied = appliedVariants.getAppliedVariants();
163+
164+
String metaUid = metaUidFromValue(value);
165+
String metaKeyPrefix = appliedVariants.getMetaKey().isEmpty() ? "" : appliedVariants.getMetaKey() + ".";
166+
String updatedMetakey = shouldApplyVariant ? metaKeyPrefix + key : "";
167+
if (!metaUid.isEmpty() && !updatedMetakey.isEmpty()) {
168+
updatedMetakey = updatedMetakey + "." + metaUid;
169+
}
170+
171+
if (value instanceof JSONArray) {
172+
JSONArray arr = (JSONArray) value;
173+
for (int index = 0; index < arr.length(); index++) {
174+
Object obj = arr.opt(index);
175+
if (obj == null || JSONObject.NULL.equals(obj)) {
176+
continue;
177+
}
178+
String childKey = key + "__" + index;
179+
String parentKey = key + "__parent";
180+
metaUid = metaUidFromValue(obj);
181+
updatedMetakey = shouldApplyVariant ? metaKeyPrefix + key : "";
182+
if (!metaUid.isEmpty() && !updatedMetakey.isEmpty()) {
183+
updatedMetakey = updatedMetakey + "." + metaUid;
184+
}
185+
String indexPath = prefix + "." + key + "." + index;
186+
String fieldPath = prefix + "." + key;
187+
putTag(tags, childKey, indexPath, tagsAsObject, applied, shouldApplyVariant, updatedMetakey);
188+
putParentTag(tags, parentKey, fieldPath, tagsAsObject);
189+
if (obj instanceof JSONObject) {
190+
JSONObject jobj = (JSONObject) obj;
191+
if (jobj.has("_content_type_uid") && jobj.has("uid")) {
192+
JSONObject newApplied = jobj.optJSONObject("_applied_variants");
193+
if (newApplied == null) {
194+
JSONObject sys = jobj.optJSONObject("system");
195+
if (sys != null) {
196+
newApplied = sys.optJSONObject("applied_variants");
197+
}
198+
}
199+
boolean newShould = newApplied != null;
200+
String refLocale = jobj.has("locale") && !jobj.isNull("locale")
201+
? jobj.optString("locale", locale)
202+
: locale;
203+
String refPrefix = jobj.optString("_content_type_uid") + "." + jobj.optString("uid") + "."
204+
+ refLocale;
205+
jobj.put("$", getTag(jobj, refPrefix, tagsAsObject, locale,
206+
new AppliedVariantsState(newApplied, newShould, "")));
207+
} else {
208+
jobj.put("$", getTag(jobj, indexPath, tagsAsObject, locale,
209+
new AppliedVariantsState(applied, shouldApplyVariant, updatedMetakey)));
210+
}
211+
}
212+
}
213+
} else if (value instanceof JSONObject) {
214+
JSONObject valueObj = (JSONObject) value;
215+
valueObj.put("$", getTag(valueObj, prefix + "." + key, tagsAsObject, locale,
216+
new AppliedVariantsState(applied, shouldApplyVariant, updatedMetakey)));
217+
}
218+
219+
String fieldTagPath = prefix + "." + key;
220+
putTag(tags, key, fieldTagPath, tagsAsObject, applied, shouldApplyVariant, updatedMetakey);
221+
}
222+
223+
private static String metaUidFromValue(Object value) {
224+
if (!(value instanceof JSONObject)) {
225+
return "";
226+
}
227+
JSONObject jo = (JSONObject) value;
228+
JSONObject meta = jo.optJSONObject("_metadata");
229+
if (meta == null) {
230+
return "";
231+
}
232+
return meta.optString("uid", "");
233+
}
234+
235+
private static void putTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject,
236+
JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
237+
TagsPayload payload = new TagsPayload(appliedVariants, shouldApplyVariant, metaKey);
238+
if (tagsAsObject) {
239+
tags.put(key, getTagsValueAsObject(dataValue, payload));
240+
} else {
241+
tags.put(key, getTagsValueAsString(dataValue, payload));
242+
}
243+
}
244+
245+
private static void putParentTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject) {
246+
if (tagsAsObject) {
247+
tags.put(key, getParentTagsValueAsObject(dataValue));
248+
} else {
249+
tags.put(key, getParentTagsValueAsString(dataValue));
250+
}
251+
}
252+
253+
private static final class TagsPayload {
254+
private final JSONObject appliedVariants;
255+
private final boolean shouldApplyVariant;
256+
private final String metaKey;
257+
258+
private TagsPayload(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) {
259+
this.appliedVariants = appliedVariants;
260+
this.shouldApplyVariant = shouldApplyVariant;
261+
this.metaKey = metaKey != null ? metaKey : "";
262+
}
263+
}
264+
265+
static String applyVariantToDataValue(String dataValue, JSONObject appliedVariants, boolean shouldApplyVariant,
266+
String metaKey) {
267+
if (shouldApplyVariant && appliedVariants != null) {
268+
Object direct = appliedVariants.opt(metaKey);
269+
if (direct != null && !JSONObject.NULL.equals(direct)) {
270+
String variant = String.valueOf(direct);
271+
String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1);
272+
if (newDataValueArray.length > 1) {
273+
newDataValueArray[1] = newDataValueArray[1] + "_" + variant;
274+
return String.join(".", newDataValueArray);
275+
}
276+
}
277+
String parentVariantisedPath = getParentVariantisedPath(appliedVariants, metaKey);
278+
if (parentVariantisedPath != null && !parentVariantisedPath.isEmpty()) {
279+
Object v = appliedVariants.opt(parentVariantisedPath);
280+
if (v != null && !JSONObject.NULL.equals(v)) {
281+
String variant = String.valueOf(v);
282+
String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1);
283+
if (newDataValueArray.length > 1) {
284+
newDataValueArray[1] = newDataValueArray[1] + "_" + variant;
285+
return String.join(".", newDataValueArray);
286+
}
287+
}
288+
}
289+
}
290+
return dataValue;
291+
}
292+
293+
static String getParentVariantisedPath(JSONObject appliedVariants, String metaKey) {
294+
try {
295+
if (appliedVariants == null) {
296+
return "";
297+
}
298+
List<String> variantisedFieldPaths = new ArrayList<>(appliedVariants.keySet());
299+
variantisedFieldPaths.sort(Comparator.comparingInt(String::length).reversed());
300+
String[] childPathFragments = metaKey.split("\\.", -1);
301+
if (childPathFragments.length == 0 || variantisedFieldPaths.isEmpty()) {
302+
return "";
303+
}
304+
for (String path : variantisedFieldPaths) {
305+
String[] parentFragments = path.split("\\.", -1);
306+
if (parentFragments.length > childPathFragments.length) {
307+
continue;
308+
}
309+
boolean all = true;
310+
for (int i = 0; i < parentFragments.length; i++) {
311+
if (!Objects.equals(parentFragments[i], childPathFragments[i])) {
312+
all = false;
313+
break;
314+
}
315+
}
316+
if (all) {
317+
return path;
318+
}
319+
}
320+
return "";
321+
} catch (RuntimeException e) {
322+
return "";
323+
}
324+
}
325+
326+
private static JSONObject getTagsValueAsObject(String dataValue, TagsPayload payload) {
327+
String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant,
328+
payload.metaKey);
329+
JSONObject o = new JSONObject();
330+
o.put("data-cslp", resolved);
331+
return o;
332+
}
333+
334+
private static String getTagsValueAsString(String dataValue, TagsPayload payload) {
335+
String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant,
336+
payload.metaKey);
337+
return "data-cslp=" + resolved;
338+
}
339+
340+
private static JSONObject getParentTagsValueAsObject(String dataValue) {
341+
JSONObject o = new JSONObject();
342+
o.put("data-cslp-parent-field", dataValue);
343+
return o;
344+
}
345+
346+
private static String getParentTagsValueAsString(String dataValue) {
347+
return "data-cslp-parent-field=" + dataValue;
348+
}
349+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.contentstack.utils;
2+
3+
/**
4+
* Options for {@link Utils#addEditableTags(org.json.JSONObject, String, boolean, String, EditableTagsOptions)}.
5+
*/
6+
public final class EditableTagsOptions {
7+
8+
private boolean useLowerCaseLocale = true;
9+
10+
public EditableTagsOptions() {
11+
}
12+
13+
/**
14+
* When {@code true} (default), the locale string is lowercased to match the JavaScript Utils default.
15+
*
16+
* @return whether locale is normalized to lowercase
17+
*/
18+
public boolean isUseLowerCaseLocale() {
19+
return useLowerCaseLocale;
20+
}
21+
22+
/**
23+
* @param useLowerCaseLocale if {@code true}, locale is lowercased; if {@code false}, locale is left as-is
24+
* @return this instance for chaining
25+
*/
26+
public EditableTagsOptions setUseLowerCaseLocale(boolean useLowerCaseLocale) {
27+
this.useLowerCaseLocale = useLowerCaseLocale;
28+
return this;
29+
}
30+
}

0 commit comments

Comments
 (0)