diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 0000000..f5c1f87 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,5 @@ +# Cursor (optional) + +**Cursor** users: start at **[AGENTS.md](../../AGENTS.md)**. All conventions live in **`skills/*/SKILL.md`**. + +This folder only points contributors to **`AGENTS.md`** so editor-specific config does not duplicate the canonical docs. diff --git a/.gitignore b/.gitignore index e21cc92..9fdd99d 100644 --- a/.gitignore +++ b/.gitignore @@ -260,6 +260,9 @@ gradle-app.setting sample/ +# Local-only Spring Boot LP demo (not committed by default) +sample-lp-demo/ + # End of https://www.toptal.com/developers/gitignore/api/macos,code-java,java-web,maven,gradle,intellij,visualstudiocode,eclipse .idea/compiler.xml .idea/encodings.xml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..95bc73e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# Contentstack Utils (Java) – Agent guide + +**Universal entry point** for contributors and AI agents. Detailed conventions live in **`skills/*/SKILL.md`**. + +## What this repo is + +| Field | Detail | +|--------|--------| +| **Name:** | [contentstack-utils-java](https://github.com/contentstack/contentstack-utils-java) — Maven `com.contentstack.sdk:utils` | +| **Purpose:** | Library for rendering **Rich Text Editor (RTE)** content and **embedded items** from Contentstack entry JSON (REST/CDA-style with `_embedded_items`) and GraphQL-shaped responses. Consumed by the [Contentstack Java Delivery SDK](https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/java) and apps that already hold entry JSON. | +| **Out of scope (if any):** | **No HTTP client** in this package: no stack API calls, tokens, or `includeEmbeddedItems()` — those belong to the Delivery SDK or your app. Optional **`sample/`** wires the SDK + Utils for manual testing only. | + +## Tech stack (at a glance) + +| Area | Details | +|------|---------| +| **Language** | Java **17** — `maven-compiler-plugin` `17` in root `pom.xml` (legacy `1.8` properties in `pom.xml` are not authoritative). | +| **Build** | **Maven** — root `pom.xml`; optional module `sample/pom.xml`. | +| **Tests** | **JUnit 4**, Maven **Surefire** (`src/test/java/com/contentstack/utils/**/*.java`). Surefire **`testFailureIgnore`** is `true` — check `target/surefire-reports/`. | +| **Lint / coverage** | No Checkstyle/Spotless in repo — match existing style. **JaCoCo** (`target/site/jacoco/` after `mvn test`). | +| **Other** | JSON: `org.json`, `json-simple` (provided). HTML: **Jsoup**. `spring-web` compile dependency — not a public REST client API for this module. **Snyk** on PRs (`.github/workflows/sca-scan.yml`). | + +## Commands (quick reference) + +| Command type | Command | +|--------------|---------| +| **Build** | `mvn clean compile` | +| **Test** | `mvn clean test` | +| **Lint** | *(none configured — rely on IDE and code review)* | + +| Optional | Command / location | +|----------|---------------------| +| Single test class | `mvn test -Dtest=UtilTests` | +| Javadoc | `mvn javadoc:javadoc` | +| Sample (after `mvn install` with skips if needed) | `mvn -f sample/pom.xml compile` | +| **CI** | Java **17** publish: `.github/workflows/maven-publish.yml` · SCA: `.github/workflows/sca-scan.yml` · branch rules: `.github/workflows/check-branch.yml` | + +## Where the documentation lives: skills + +| Skill | Path | What it covers | +|-------|------|----------------| +| **Development workflow** | [`skills/dev-workflow/SKILL.md`](skills/dev-workflow/SKILL.md) | Branches, CI, build/test commands, PR expectations, optional TDD. | +| **Java (language & layout)** | [`skills/java/SKILL.md`](skills/java/SKILL.md) | Java 17, `com.contentstack.utils` packages, naming, JSON/Jsoup, dependencies. | +| **Contentstack Utils API** | [`skills/contentstack-utils-java/SKILL.md`](skills/contentstack-utils-java/SKILL.md) | Public API: `Utils`, `GQL`, `DefaultOption`, JSON contracts, RTE/embedded boundaries. | +| **Testing** | [`skills/testing/SKILL.md`](skills/testing/SKILL.md) | JUnit 4, fixtures, Surefire/JaCoCo, offline tests vs `sample/`. | +| **Code review** | [`skills/code-review/SKILL.md`](skills/code-review/SKILL.md) | PR checklist, optional Blocker/Major/Minor. | +| **Framework (build & tooling)** | [`skills/framework/SKILL.md`](skills/framework/SKILL.md) | Maven plugins, publishing, GPG, Central, `sample/` dependency hygiene. | + +An index with **when to use** hints is in [`skills/README.md`](skills/README.md). + +## Using Cursor (optional) + +If you use **Cursor**, [`.cursor/rules/README.md`](.cursor/rules/README.md) only points to **`AGENTS.md`** — same docs as everyone else. diff --git a/Changelog.md b/Changelog.md index 3bae033..ae598d0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,13 @@ # Changelog A brief description of what changes project contains + +## Apr 20, 2026 + +#### v1.5.0 + +- Enhancement: Live Preview Editable tags + ## Mar 23, 2026 #### v1.4.0 diff --git a/pom.xml b/pom.xml index 3f15ef1..c643cf7 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 com.contentstack.sdk utils - 1.4.0 + 1.5.0 jar Contentstack-utils Java Utils SDK for Contentstack Content Delivery API, Contentstack is a headless CMS diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..c6774a7 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,16 @@ +# Skills – Contentstack Utils (Java) + +Source of truth for detailed guidance. Read **`AGENTS.md`** first, then open the skill that matches your task. + +## When to use which skill + +| Skill folder | Use when | +|--------------|----------| +| **dev-workflow** | Branches, CI, how to run build/test, PR expectations, optional TDD. | +| **java** | Java 17 conventions, package layout under `com.contentstack.utils`, JSON/Jsoup, Javadoc, dependency discipline. | +| **contentstack-utils-java** | Changing `Utils`, `GQL`, `DefaultOption`, callbacks, embedded/RTE behavior or JSON contracts. | +| **testing** | Writing or refactoring tests, fixtures, Surefire/JaCoCo, offline tests. | +| **code-review** | Reviewing a PR or self-review before merge. | +| **framework** | Editing `pom.xml`, plugins, release signing, Maven Central, or `sample/` dependency versions. | + +Each folder contains **`SKILL.md`** with YAML frontmatter (`name`, `description`). diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 0000000..8f3e938 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,49 @@ +--- +name: code-review +description: PR checklist and optional Blocker/Major/Minor — use when reviewing or before submitting a PR +--- + +# Code review – Contentstack Utils (Java) + +## When to use + +- Reviewing another contributor’s pull request. +- Self-review before merge. +- Auditing API, security, or test coverage for a change set. + +## Instructions + +### API design and stability + +- [ ] **Public API:** New or changed methods on `Utils`, `GQL`, `DefaultOption`, or `interfaces` are necessary, Javadoc’d, and safe for `com.contentstack.sdk:utils` consumers. +- [ ] **Backward compatibility:** Breaking changes only with major version / **`Changelog.md`** plan. +- [ ] **Naming:** Consistent with existing Utils and RTE/embedded terminology. + +### Error handling and robustness + +- [ ] **JSON:** Missing keys / `_embedded_items` behave predictably; no accidental NPEs or silent semantic changes. +- [ ] **Null safety:** `JSONObject` / `JSONArray` access follows existing `opt*` / `has` patterns. + +### Dependencies and security + +- [ ] **Dependencies:** `pom.xml` changes are justified; consider downstream Java SDK consumers. +- [ ] **SCA:** Snyk / team process (`.github/workflows/sca-scan.yml`) — address or defer with a ticket. + +### Testing + +- [ ] **Coverage:** New behavior has tests and fixtures under `src/test/java` / `src/test/resources` as needed. +- [ ] **Surefire:** With `testFailureIgnore`, verify **`target/surefire-reports/`**, not only exit code. + +### Severity (optional) + +| Level | Examples | +|-------|----------| +| **Blocker** | Unapproved breaking public API; critical CVE; no tests for new behavior. | +| **Major** | Undocumented HTML/JSON behavior change; missing Javadoc on new public API; risky dependency bump. | +| **Minor** | Style, typos, internal refactor with equivalent coverage. | + +## References + +- **`skills/testing/SKILL.md`** — test conventions and Surefire. +- **`skills/contentstack-utils-java/SKILL.md`** — API boundaries. +- **`AGENTS.md`** — stack and commands. diff --git a/skills/contentstack-utils-java/SKILL.md b/skills/contentstack-utils-java/SKILL.md new file mode 100644 index 0000000..d5973b2 --- /dev/null +++ b/skills/contentstack-utils-java/SKILL.md @@ -0,0 +1,44 @@ +--- +name: contentstack-utils-java +description: Public API — Utils, GQL, DefaultOption, RTE/embedded JSON; use when changing SDK behavior or contracts +--- + +# Contentstack Utils API – Contentstack Utils (Java) + +## When to use + +- Changing **`Utils`**, **`GQL`**, **`DefaultOption`**, **`Option`**, callbacks, or embedded/RTE logic. +- Reviewing JSON shape assumptions for CDA or GraphQL responses. + +## Instructions + +### Scope + +- Artifact **`com.contentstack.sdk:utils`** only **transforms** JSON that apps or the [Java Delivery SDK](https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/java) already fetched. +- **Authentication, API keys, delivery tokens, and `includeEmbeddedItems()`** are out of scope here — handled by the SDK or app code. + +### Entry points + +- **`com.contentstack.utils.Utils`** — `render`, `renderContent`, `renderContents`, `jsonToHTML` for REST/CDA-style JSON with `_embedded_items`; dot-paths into entries (e.g. `group.field`). Variant-related helpers as documented in `Utils`. +- **`com.contentstack.utils.GQL`** — `jsonToHTML` for GraphQL-shaped entries (`embedded_itemsConnection`, `edges`, `node`, JSON RTE under `json`). Do not instantiate `GQL` (private constructor). + +### Rendering and options + +- Implement **`com.contentstack.utils.interfaces.Option`** or extend **`com.contentstack.utils.render.DefaultOption`** for custom embedded HTML, marks, and nodes. +- Use **`com.contentstack.utils.interfaces.NodeCallback`** and **`com.contentstack.utils.helper.Metadata`** with **`embedded.StyleType`** / **`embedded.ItemType`** as in existing code. + +### Data flow and compatibility + +- Shared traversal: **`AutomateCommon`**; JSON RTE trees: **`NodeToHTML`**. +- Preserve keys and HTML class names (`_embedded_items`, `embedded-entry`, etc.) unless shipping a **breaking** version with changelog. +- Prefer null-safe **`opt*`** / **`has`** on `JSONObject` / `JSONArray`. + +### Alignment with Contentstack + +- Entry JSON shapes align with the [Content Delivery API](https://www.contentstack.com/docs/apis/content-delivery-api/) as consumed by the Java SDK; root **`README.md`** shows `Contentstack.stack`, `Entry`, `Query` usage **outside** this JAR. + +## References + +- **`skills/java/SKILL.md`** — language and package conventions. +- **`skills/testing/SKILL.md`** — tests for API changes. +- Root **`README.md`** — Maven coordinates and embedded-items examples. diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md new file mode 100644 index 0000000..3a988df --- /dev/null +++ b/skills/dev-workflow/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dev-workflow +description: Branches, CI, build and test commands, PR expectations, optional TDD — use when onboarding or before a PR +--- + +# Development workflow – Contentstack Utils (Java) + +## When to use + +- Starting work in this repository or onboarding. +- Before opening or updating a pull request. +- You need the canonical commands for build, test, sample, and CI pointers. + +## Instructions + +### Branches + +- Default integration for PRs is often **`staging`**; merging into **`master`** may be restricted (see `.github/workflows/check-branch.yml`). +- Feature/fix branches often use ticket-style names (e.g. `fix/DX-5734`). + +### Running tests and builds + +- **All tests:** `mvn test` +- **Compile only:** `mvn clean compile` +- **Full check before PR:** `mvn clean test` (and `mvn -f sample/pom.xml compile` if you changed `sample/` — install parent first; see `skills/framework/SKILL.md`). +- Review **`target/surefire-reports/`** when debugging: **`testFailureIgnore`** is `true` in `pom.xml` (see `skills/testing/SKILL.md`). + +### Pull requests + +- Describe the change; link issues/tickets when applicable. +- Keep public API backward-compatible unless releasing a breaking version; update **`Changelog.md`** for user-visible behavior. +- Use **`skills/code-review/SKILL.md`** as the review checklist. + +### Optional: TDD + +- If the team uses TDD: RED → GREEN → REFACTOR. Structure and Surefire behavior are in **`skills/testing/SKILL.md`**. + +### CI and security + +- **Java 17** in `.github/workflows/maven-publish.yml`. +- **Snyk** Maven scan on PRs: `.github/workflows/sca-scan.yml`. +- **Javadoc:** optional locally `mvn javadoc:javadoc`; attach phase uses `-Xdoclint:none` per `pom.xml`. + +### Lint / format + +- No repo-wide Checkstyle/Spotless — match existing style (`skills/java/SKILL.md`). + +## References + +- **`skills/testing/SKILL.md`** — Surefire, JaCoCo, fixtures. +- **`skills/framework/SKILL.md`** — Maven install skips, publishing, `sample/pom.xml`. +- **`AGENTS.md`** — commands quick reference. diff --git a/skills/framework/SKILL.md b/skills/framework/SKILL.md new file mode 100644 index 0000000..43d679f --- /dev/null +++ b/skills/framework/SKILL.md @@ -0,0 +1,64 @@ +--- +name: framework +description: Maven, plugins, Java 17, publishing, sample module deps — use when editing pom.xml or release tooling +--- + +# Framework (build & tooling) – Contentstack Utils (Java) + +## When to use + +- Editing root **`pom.xml`** or **`sample/pom.xml`**. +- Changing release/publish flow, signing, or CI-related Maven settings. +- Aligning **`sample/`** `contentstack.utils.version` with a new library release. + +## Instructions + +### Build commands + +```bash +mvn clean compile +mvn test +mvn package +``` + +Local install without GPG/Javadoc (common for dev before `sample/`): + +```bash +mvn install -DskipTests -Dmaven.javadoc.skip=true -Dgpg.skip=true +``` + +### Java version + +- **`maven-compiler-plugin`** — **`17`**. Match **Java 17** locally and in `.github/workflows/maven-publish.yml`. + +### Key plugins (root `pom.xml`) + +| Plugin | Role | +|--------|------| +| `maven-surefire-plugin` | Runs JUnit; note `testFailureIgnore` | +| `jacoco-maven-plugin` | Coverage → `target/site/jacoco/` | +| `maven-javadoc-plugin` | Javadoc JAR; `-Xdoclint:none` in execution | +| `central-publishing-maven-plugin` | Maven Central publishing | +| `maven-gpg-plugin` | Signs artifacts on release | + +### Coordinates + +- **groupId:** `com.contentstack.sdk` +- **artifactId:** `utils` +- **version:** root `pom.xml` `` + +### Dependency hygiene + +- Keep the dependency set small; this JAR is pulled in by the Contentstack Java SDK. +- **Snyk** on PRs: `.github/workflows/sca-scan.yml`. +- **`sample/pom.xml`:** Keep **`contentstack.utils.version`** in sync when bumping the root version; use **`dependencyManagement`** for transitive CVE overrides when needed. + +### Formatting + +- No Checkstyle/Spotless — match surrounding code (`skills/java/SKILL.md`). + +## References + +- **`skills/dev-workflow/SKILL.md`** — when to run full build/test. +- **`skills/testing/SKILL.md`** — Surefire/JaCoCo behavior. +- **`AGENTS.md`** — CI file pointers. diff --git a/skills/java/SKILL.md b/skills/java/SKILL.md new file mode 100644 index 0000000..494e745 --- /dev/null +++ b/skills/java/SKILL.md @@ -0,0 +1,54 @@ +--- +name: java +description: Java 17, package layout, naming, JSON/Jsoup, Javadoc — use when editing any production or test Java in this repo +--- + +# Java – Contentstack Utils (Java) + +## When to use + +- Editing any `.java` file under this repository. +- Adding dependencies or debating style vs other Contentstack SDKs. + +## Instructions + +### Language and runtime + +- **Java 17** via `maven-compiler-plugin` `17` in `pom.xml`. Ignore legacy `maven.compiler.source/target` `1.8` — the compiler plugin wins. +- Avoid raw types; use generics where applicable. + +### Package and layout + +- Production code lives under **`com.contentstack.utils`** and subpackages: `render`, `node`, `embedded`, `helper`, `interfaces`. +- Do not add new top-level packages without team alignment. + +### Naming + +- **Classes:** PascalCase (`Utils`, `DefaultOption`, `AutomateCommon`). +- **Methods / variables:** camelCase. +- **Tests:** Mixed conventions already in use — see **`skills/testing/SKILL.md`**. + +### JSON and HTML + +- Prefer **`org.json`** (`JSONObject`, `JSONArray`) for APIs and internals used by `Utils` and `GQL`. +- Use **Jsoup** for RTE HTML; follow patterns in `AutomateCommon` and `Utils`. + +### Validation and utility types + +- `javax.validation.constraints` (e.g. `@NotNull`) on some public methods — keep Javadoc aligned with null behavior. +- Private constructors for non-instantiable types where the codebase already does (`GQL`, `AutomateCommon`). + +### Dependencies + +- Small JAR consumed by the Contentstack Java SDK — prefer minimal dependencies; justify additions in `pom.xml`. +- **No Lombok** in this repo unless explicitly adopted by the team. + +### Documentation + +- Javadoc on public API; examples must match real entry points (`Utils.render`, `GQL.jsonToHTML`, `DefaultOption`). + +## References + +- **`skills/contentstack-utils-java/SKILL.md`** — RTE, embedded, GraphQL shapes. +- **`skills/testing/SKILL.md`** — test naming and layout. +- **`AGENTS.md`** — tech stack summary. diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 0000000..d2dc2d7 --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,47 @@ +--- +name: testing +description: JUnit 4, Surefire, JaCoCo, fixtures, offline policy — use when adding or changing tests +--- + +# Testing – Contentstack Utils (Java) + +## When to use + +- Creating or editing tests under `src/test/java/com/contentstack/utils/`. +- Adding JSON under `src/test/resources/`. +- Investigating CI failures or coverage gaps. + +## Instructions + +### Framework + +- **JUnit 4** (`junit:junit`, test scope in `pom.xml`). +- **Maven Surefire** runs classes from `src/test/java`. + +### Surefire caveat + +- **`testFailureIgnore`** is **`true`** in `pom.xml`. Always inspect **`target/surefire-reports/`** — a successful Maven exit code does not guarantee all tests passed. + +### Naming and layout + +- Mirror package **`com.contentstack.utils`**. +- Existing patterns: `UtilTests`, `DefaultOptionTests`, `AssetLinkTest`, `TestRte`, `TestMetadata`, `Test*`, `*Test`, `*Tests`. +- No separate `*IT` Maven profile in this repo; optional **`sample/`** uses the Delivery SDK with env vars (see `sample/README.md`). + +### Fixtures + +- JSON and assets under **`src/test/resources/`** (e.g. `multiple_rich_text_content.json`, `reports/`). Loading patterns: `ReadResource`, `UtilTests` `@BeforeClass`. + +### Helpers + +- `ReadResource`, `DefaultOptionClass`, and similar helpers — keep tests **deterministic** and **offline** (no live API for default unit tests). + +### Coverage + +- **JaCoCo** on `mvn test`; HTML report **`target/site/jacoco/index.html`**. + +## References + +- **`skills/dev-workflow/SKILL.md`** — when to run tests before PRs. +- **`skills/framework/SKILL.md`** — Maven/Surefire configuration. +- **`skills/code-review/SKILL.md`** — test expectations for reviews. diff --git a/src/main/java/com/contentstack/utils/EditableTags.java b/src/main/java/com/contentstack/utils/EditableTags.java new file mode 100644 index 0000000..cc38868 --- /dev/null +++ b/src/main/java/com/contentstack/utils/EditableTags.java @@ -0,0 +1,351 @@ +package com.contentstack.utils; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +/** + * Live Preview editable tags (CSLP) — parity with contentstack-utils-javascript + * {@code entry-editable.ts}. + */ +public final class EditableTags { + + /** + * Variant / meta-key state threaded through {@link #getTag(Object, String, boolean, String, AppliedVariantsState)}. + */ + public static final class AppliedVariantsState { + private final JSONObject appliedVariants; + private final boolean shouldApplyVariant; + private final String metaKey; + + public AppliedVariantsState(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) { + this.appliedVariants = appliedVariants; + this.shouldApplyVariant = shouldApplyVariant; + this.metaKey = metaKey != null ? metaKey : ""; + } + + public JSONObject getAppliedVariants() { + return appliedVariants; + } + + public boolean isShouldApplyVariant() { + return shouldApplyVariant; + } + + public String getMetaKey() { + return metaKey; + } + } + + private EditableTags() { + } + + /** + * Adds Contentstack Live Preview (CSLP) data tags to an entry for editable UIs. + * Mutates the entry by attaching a {@code $} property with tag strings or objects + * ({@code data-cslp} / {@code data-cslp-parent-field}) for each field. + * + * @param entry CDA-style entry JSON (must not be {@code null}); must contain {@code uid} + * @param contentTypeUid content type UID (e.g. {@code blog_post}) + * @param tagsAsObject if {@code true}, tags are JSON objects; if {@code false}, {@code data-cslp=...} strings + * @param locale locale code (default in overloads: {@code en-us}) + * @param options optional; controls locale casing (default lowercases locale) + */ + public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale, + EditableTagsOptions options) { + if (entry == null) { + return; + } + boolean useLowerCaseLocale = true; + if (options != null) { + useLowerCaseLocale = options.isUseLowerCaseLocale(); + } + String ct = contentTypeUid == null ? "" : contentTypeUid.toLowerCase(); + String loc = locale == null ? "en-us" : locale; + if (useLowerCaseLocale) { + loc = loc.toLowerCase(); + } + JSONObject applied = entry.optJSONObject("_applied_variants"); + if (applied == null) { + JSONObject system = entry.optJSONObject("system"); + if (system != null) { + applied = system.optJSONObject("applied_variants"); + } + } + boolean shouldApply = applied != null; + String uid = entry.optString("uid", ""); + String prefix = ct + "." + uid + "." + loc; + AppliedVariantsState state = new AppliedVariantsState(applied, shouldApply, ""); + entry.put("$", getTag(entry, prefix, tagsAsObject, loc, state)); + } + + /** + * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions) + */ + public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject) { + addEditableTags(entry, contentTypeUid, tagsAsObject, "en-us", null); + } + + /** + * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions) + */ + public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale) { + addEditableTags(entry, contentTypeUid, tagsAsObject, locale, null); + } + + /** + * Alias for {@link #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)} — matches JS + * {@code addTags}. + */ + public static void addTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale, + EditableTagsOptions options) { + addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options); + } + + /** + * Recursive tag map for the given content (entry object or array). Exposed for parity with JS tests. + * + * @param content {@link JSONObject}, {@link JSONArray}, or null + * @param prefix path prefix ({@code contentTypeUid.entryUid.locale...}) + * @param tagsAsObject string vs object tag form + * @param locale locale for reference entries + * @param appliedVariants variant state + * @return map of field keys to tag string or tag object + */ + public static JSONObject getTag(Object content, String prefix, boolean tagsAsObject, String locale, + AppliedVariantsState appliedVariants) { + if (content == null || JSONObject.NULL.equals(content)) { + return new JSONObject(); + } + if (content instanceof JSONArray) { + return getTagForArray((JSONArray) content, prefix, tagsAsObject, locale, appliedVariants); + } + if (content instanceof JSONObject) { + return getTagForJSONObject((JSONObject) content, prefix, tagsAsObject, locale, appliedVariants); + } + return new JSONObject(); + } + + private static JSONObject getTagForJSONObject(JSONObject content, String prefix, boolean tagsAsObject, + String locale, AppliedVariantsState appliedVariants) { + JSONObject tags = new JSONObject(); + Iterator keys = content.keys(); + while (keys.hasNext()) { + String key = keys.next(); + handleKey(tags, key, content.opt(key), prefix, tagsAsObject, locale, appliedVariants); + } + return tags; + } + + private static JSONObject getTagForArray(JSONArray content, String prefix, boolean tagsAsObject, String locale, + AppliedVariantsState appliedVariants) { + JSONObject tags = new JSONObject(); + for (int i = 0; i < content.length(); i++) { + String key = Integer.toString(i); + handleKey(tags, key, content.opt(i), prefix, tagsAsObject, locale, appliedVariants); + } + return tags; + } + + /** One entry from {@code Object.entries} — same structure for {@link JSONObject} and {@link JSONArray}. */ + private static void handleKey(JSONObject tags, String key, Object value, String prefix, boolean tagsAsObject, + String locale, AppliedVariantsState appliedVariants) { + if ("$".equals(key)) { + return; + } + boolean shouldApplyVariant = appliedVariants.isShouldApplyVariant(); + JSONObject applied = appliedVariants.getAppliedVariants(); + + String metaUid = metaUidFromValue(value); + String metaKeyPrefix = appliedVariants.getMetaKey().isEmpty() ? "" : appliedVariants.getMetaKey() + "."; + String updatedMetakey = shouldApplyVariant ? metaKeyPrefix + key : ""; + if (!metaUid.isEmpty() && !updatedMetakey.isEmpty()) { + updatedMetakey = updatedMetakey + "." + metaUid; + } + // For array fields, per-element processing below must not overwrite this — line 220's field tag uses it. + String fieldMetakey = updatedMetakey; + + if (value instanceof JSONArray) { + JSONArray arr = (JSONArray) value; + for (int index = 0; index < arr.length(); index++) { + Object obj = arr.opt(index); + if (obj == null || JSONObject.NULL.equals(obj)) { + continue; + } + String childKey = key + "__" + index; + String parentKey = key + "__parent"; + metaUid = metaUidFromValue(obj); + String elementMetakey = shouldApplyVariant ? metaKeyPrefix + key : ""; + if (!metaUid.isEmpty() && !elementMetakey.isEmpty()) { + elementMetakey = elementMetakey + "." + metaUid; + } + String indexPath = prefix + "." + key + "." + index; + String fieldPath = prefix + "." + key; + putTag(tags, childKey, indexPath, tagsAsObject, applied, shouldApplyVariant, elementMetakey); + putParentTag(tags, parentKey, fieldPath, tagsAsObject); + if (obj instanceof JSONObject) { + JSONObject jobj = (JSONObject) obj; + if (jobj.has("_content_type_uid") && jobj.has("uid")) { + JSONObject newApplied = jobj.optJSONObject("_applied_variants"); + if (newApplied == null) { + JSONObject sys = jobj.optJSONObject("system"); + if (sys != null) { + newApplied = sys.optJSONObject("applied_variants"); + } + } + boolean newShould = newApplied != null; + String refLocale = jobj.has("locale") && !jobj.isNull("locale") + ? jobj.optString("locale", locale) + : locale; + String refPrefix = jobj.optString("_content_type_uid") + "." + jobj.optString("uid") + "." + + refLocale; + jobj.put("$", getTag(jobj, refPrefix, tagsAsObject, refLocale, + new AppliedVariantsState(newApplied, newShould, ""))); + } else { + jobj.put("$", getTag(jobj, indexPath, tagsAsObject, locale, + new AppliedVariantsState(applied, shouldApplyVariant, elementMetakey))); + } + } + } + } else if (value instanceof JSONObject) { + JSONObject valueObj = (JSONObject) value; + valueObj.put("$", getTag(valueObj, prefix + "." + key, tagsAsObject, locale, + new AppliedVariantsState(applied, shouldApplyVariant, updatedMetakey))); + } + + String fieldTagPath = prefix + "." + key; + putTag(tags, key, fieldTagPath, tagsAsObject, applied, shouldApplyVariant, fieldMetakey); + } + + private static String metaUidFromValue(Object value) { + if (!(value instanceof JSONObject)) { + return ""; + } + JSONObject jo = (JSONObject) value; + JSONObject meta = jo.optJSONObject("_metadata"); + if (meta == null) { + return ""; + } + return meta.optString("uid", ""); + } + + private static void putTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject, + JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) { + TagsPayload payload = new TagsPayload(appliedVariants, shouldApplyVariant, metaKey); + if (tagsAsObject) { + tags.put(key, getTagsValueAsObject(dataValue, payload)); + } else { + tags.put(key, getTagsValueAsString(dataValue, payload)); + } + } + + private static void putParentTag(JSONObject tags, String key, String dataValue, boolean tagsAsObject) { + if (tagsAsObject) { + tags.put(key, getParentTagsValueAsObject(dataValue)); + } else { + tags.put(key, getParentTagsValueAsString(dataValue)); + } + } + + private static final class TagsPayload { + private final JSONObject appliedVariants; + private final boolean shouldApplyVariant; + private final String metaKey; + + private TagsPayload(JSONObject appliedVariants, boolean shouldApplyVariant, String metaKey) { + this.appliedVariants = appliedVariants; + this.shouldApplyVariant = shouldApplyVariant; + this.metaKey = metaKey != null ? metaKey : ""; + } + } + + static String applyVariantToDataValue(String dataValue, JSONObject appliedVariants, boolean shouldApplyVariant, + String metaKey) { + if (shouldApplyVariant && appliedVariants != null) { + Object direct = appliedVariants.opt(metaKey); + if (direct != null && !JSONObject.NULL.equals(direct)) { + String variant = String.valueOf(direct); + String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1); + if (newDataValueArray.length > 1) { + newDataValueArray[1] = newDataValueArray[1] + "_" + variant; + return String.join(".", newDataValueArray); + } + } + String parentVariantisedPath = getParentVariantisedPath(appliedVariants, metaKey); + if (parentVariantisedPath != null && !parentVariantisedPath.isEmpty()) { + Object v = appliedVariants.opt(parentVariantisedPath); + if (v != null && !JSONObject.NULL.equals(v)) { + String variant = String.valueOf(v); + String[] newDataValueArray = ("v2:" + dataValue).split("\\.", -1); + if (newDataValueArray.length > 1) { + newDataValueArray[1] = newDataValueArray[1] + "_" + variant; + return String.join(".", newDataValueArray); + } + } + } + } + return dataValue; + } + + static String getParentVariantisedPath(JSONObject appliedVariants, String metaKey) { + try { + if (appliedVariants == null) { + return ""; + } + List variantisedFieldPaths = new ArrayList<>(appliedVariants.keySet()); + variantisedFieldPaths.sort(Comparator.comparingInt(String::length).reversed()); + String[] childPathFragments = metaKey.split("\\.", -1); + if (childPathFragments.length == 0 || variantisedFieldPaths.isEmpty()) { + return ""; + } + for (String path : variantisedFieldPaths) { + String[] parentFragments = path.split("\\.", -1); + if (parentFragments.length > childPathFragments.length) { + continue; + } + boolean all = true; + for (int i = 0; i < parentFragments.length; i++) { + if (!Objects.equals(parentFragments[i], childPathFragments[i])) { + all = false; + break; + } + } + if (all) { + return path; + } + } + return ""; + } catch (RuntimeException e) { + return ""; + } + } + + private static JSONObject getTagsValueAsObject(String dataValue, TagsPayload payload) { + String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant, + payload.metaKey); + JSONObject o = new JSONObject(); + o.put("data-cslp", resolved); + return o; + } + + private static String getTagsValueAsString(String dataValue, TagsPayload payload) { + String resolved = applyVariantToDataValue(dataValue, payload.appliedVariants, payload.shouldApplyVariant, + payload.metaKey); + return "data-cslp=" + resolved; + } + + private static JSONObject getParentTagsValueAsObject(String dataValue) { + JSONObject o = new JSONObject(); + o.put("data-cslp-parent-field", dataValue); + return o; + } + + private static String getParentTagsValueAsString(String dataValue) { + return "data-cslp-parent-field=" + dataValue; + } +} diff --git a/src/main/java/com/contentstack/utils/EditableTagsOptions.java b/src/main/java/com/contentstack/utils/EditableTagsOptions.java new file mode 100644 index 0000000..3055b39 --- /dev/null +++ b/src/main/java/com/contentstack/utils/EditableTagsOptions.java @@ -0,0 +1,30 @@ +package com.contentstack.utils; + +/** + * Options for {@link Utils#addEditableTags(org.json.JSONObject, String, boolean, String, EditableTagsOptions)}. + */ +public final class EditableTagsOptions { + + private boolean useLowerCaseLocale = true; + + public EditableTagsOptions() { + } + + /** + * When {@code true} (default), the locale string is lowercased to match the JavaScript Utils default. + * + * @return whether locale is normalized to lowercase + */ + public boolean isUseLowerCaseLocale() { + return useLowerCaseLocale; + } + + /** + * @param useLowerCaseLocale if {@code true}, locale is lowercased; if {@code false}, locale is left as-is + * @return this instance for chaining + */ + public EditableTagsOptions setUseLowerCaseLocale(boolean useLowerCaseLocale) { + this.useLowerCaseLocale = useLowerCaseLocale; + return this; + } +} diff --git a/src/main/java/com/contentstack/utils/Utils.java b/src/main/java/com/contentstack/utils/Utils.java index 86ea789..6f4f810 100644 --- a/src/main/java/com/contentstack/utils/Utils.java +++ b/src/main/java/com/contentstack/utils/Utils.java @@ -399,6 +399,53 @@ public static JSONObject getDataCsvariantsAttribute(JSONArray entries, String co return getVariantMetadataTags(entries, contentTypeUid); } + /** + * Adds Contentstack Live Preview (CSLP) editable tags to an entry. Mutates {@code entry} by attaching a + * {@code $} object with {@code data-cslp} / {@code data-cslp-parent-field} values. Behavior matches + * contentstack-utils-javascript {@code addTags} / {@code entry-editable.ts}. + * + * @param entry CDA-style entry JSON; if {@code null}, the method returns without changes + * @param contentTypeUid content type UID (lower-cased internally) + * @param tagsAsObject if {@code true}, tag values are JSON objects; if {@code false}, {@code data-cslp=...} strings + * @param locale locale segment for paths (default in overloads: {@code en-us}) + * @param options optional; default lowercases locale unless disabled + * @see EditableTags#addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions) + */ + public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale, + EditableTagsOptions options) { + EditableTags.addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options); + } + + /** + * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions) + */ + public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject) { + EditableTags.addEditableTags(entry, contentTypeUid, tagsAsObject); + } + + /** + * @see #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions) + */ + public static void addEditableTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale) { + EditableTags.addEditableTags(entry, contentTypeUid, tagsAsObject, locale); + } + + /** + * Alias for {@link #addEditableTags(JSONObject, String, boolean, String, EditableTagsOptions)} (JS {@code addTags}). + */ + public static void addTags(JSONObject entry, String contentTypeUid, boolean tagsAsObject, String locale, + EditableTagsOptions options) { + EditableTags.addTags(entry, contentTypeUid, tagsAsObject, locale, options); + } + + /** + * Recursive CSLP tag map for tests and advanced use — see {@link EditableTags#getTag(Object, String, boolean, String, EditableTags.AppliedVariantsState)}. + */ + public static JSONObject getTag(Object content, String prefix, boolean tagsAsObject, String locale, + EditableTags.AppliedVariantsState appliedVariants) { + return EditableTags.getTag(content, prefix, tagsAsObject, locale, appliedVariants); + } + private static JSONArray extractVariantAliasesFromEntry(JSONObject entry) { JSONArray variantArray = new JSONArray(); JSONObject publishDetails = entry.optJSONObject("publish_details"); diff --git a/src/test/java/com/contentstack/utils/EditableTagsTest.java b/src/test/java/com/contentstack/utils/EditableTagsTest.java new file mode 100644 index 0000000..30a11a1 --- /dev/null +++ b/src/test/java/com/contentstack/utils/EditableTagsTest.java @@ -0,0 +1,219 @@ +package com.contentstack.utils; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; + +/** + * Live Preview editable tags — parity with contentstack-utils-javascript {@code entry-editable.ts}. + */ +public class EditableTagsTest { + + @Test + public void getTagReturnsEmptyForNullContent() { + JSONObject tags = Utils.getTag(null, "a.b.c", false, "en-us", + new EditableTags.AppliedVariantsState(null, false, "")); + Assert.assertNotNull(tags); + Assert.assertEquals(0, tags.length()); + } + + @Test + public void addEditableTagsPrimitivesAsStrings() { + JSONObject entry = new JSONObject(); + entry.put("uid", "entry1"); + entry.put("title", "Hello"); + entry.put("count", 42); + Utils.addEditableTags(entry, "Blog", false, "en-us", null); + JSONObject dollar = entry.getJSONObject("$"); + Assert.assertEquals("data-cslp=blog.entry1.en-us.title", dollar.getString("title")); + Assert.assertEquals("data-cslp=blog.entry1.en-us.count", dollar.getString("count")); + } + + @Test + public void addEditableTagsPrimitivesAsObjects() { + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("title", "Hi"); + Utils.addEditableTags(entry, "Post", true, "en-us", null); + JSONObject cslp = entry.getJSONObject("$").getJSONObject("title"); + Assert.assertEquals("post.e1.en-us.title", cslp.getString("data-cslp")); + } + + @Test + public void contentTypeUidLowercasedAndLocaleLowercasedByDefault() { + JSONObject entry = new JSONObject(); + entry.put("uid", "u1"); + entry.put("title", "x"); + Utils.addEditableTags(entry, "LANDING", false, "EN-US", null); + Assert.assertTrue(entry.getJSONObject("$").getString("title").contains(".en-us.")); + } + + @Test + public void useLowerCaseLocaleFalsePreservesLocaleCasing() { + JSONObject entry = new JSONObject(); + entry.put("uid", "u1"); + entry.put("title", "x"); + EditableTagsOptions opt = new EditableTagsOptions().setUseLowerCaseLocale(false); + Utils.addEditableTags(entry, "ct", false, "EN-US", opt); + Assert.assertTrue(entry.getJSONObject("$").getString("title").contains(".EN-US.")); + } + + @Test + public void nestedObjectGetsChildDollarMap() { + JSONObject inner = new JSONObject(); + inner.put("name", "inner"); + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("group", inner); + Utils.addEditableTags(entry, "ct", false, "en-us", null); + JSONObject groupDollar = inner.getJSONObject("$"); + Assert.assertEquals("data-cslp=ct.e1.en-us.group.name", groupDollar.getString("name")); + Assert.assertEquals("data-cslp=ct.e1.en-us.group", entry.getJSONObject("$").getString("group")); + } + + @Test + public void arrayFieldIndexAndParentTags() { + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("items", new JSONArray().put("a").put("b")); + Utils.addEditableTags(entry, "ct", false, "en-us", null); + JSONObject dollar = entry.getJSONObject("$"); + Assert.assertEquals("data-cslp=ct.e1.en-us.items.0", dollar.getString("items__0")); + Assert.assertEquals("data-cslp=ct.e1.en-us.items.1", dollar.getString("items__1")); + Assert.assertEquals("data-cslp-parent-field=ct.e1.en-us.items", dollar.getString("items__parent")); + Assert.assertEquals("data-cslp=ct.e1.en-us.items", dollar.getString("items")); + } + + /** + * Parent field tag for an array must use the field-level metakey (for variant resolution), not the last + * element's {@code _metadata.uid} suffix — otherwise a per-element variant key (e.g. {@code items.uidB}) + * would incorrectly win for the parent {@code items} tag. + */ + @Test + public void arrayFieldParentTagUsesFieldMetakeyNotLastElementMetadata() { + JSONObject applied = new JSONObject(); + applied.put("items", "fieldVar"); + applied.put("items.uidB", "wrongVar"); + JSONObject metaA = new JSONObject(); + metaA.put("uid", "uidA"); + JSONObject metaB = new JSONObject(); + metaB.put("uid", "uidB"); + JSONObject el0 = new JSONObject(); + el0.put("_metadata", metaA); + el0.put("x", "a"); + JSONObject el1 = new JSONObject(); + el1.put("_metadata", metaB); + el1.put("x", "b"); + JSONArray arr = new JSONArray().put(el0).put(el1); + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("_applied_variants", applied); + entry.put("items", arr); + Utils.addEditableTags(entry, "ct", false, "en-us", null); + String parentItemsTag = entry.getJSONObject("$").getString("items"); + Assert.assertTrue("parent field should resolve variant via key \"items\"", + parentItemsTag.contains("ct.e1_fieldVar.en-us.items")); + Assert.assertFalse("parent field must not apply last element's variant (items.uidB -> wrongVar)", + parentItemsTag.contains("e1_wrongVar")); + } + + @Test + public void referenceInArrayUsesRefPrefix() { + JSONObject ref = new JSONObject(); + ref.put("_content_type_uid", "author_ct"); + ref.put("uid", "refuid"); + ref.put("title", "Author"); + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("authors", new JSONArray().put(ref)); + Utils.addEditableTags(entry, "post", false, "en-us", null); + JSONObject refDollar = ref.getJSONObject("$"); + Assert.assertEquals("data-cslp=author_ct.refuid.en-us.title", refDollar.getString("title")); + } + + /** + * Referenced entry may declare its own {@code locale}; recursive {@code getTag} must receive {@code refLocale} + * (not the parent entry locale) so nested plain objects use the correct path segment, and nested refs in arrays + * without {@code locale} fall back to that reference locale (not the top-level entry locale). + */ + @Test + public void referenceInArrayPassesRefLocaleToNestedGetTag() { + JSONObject nested = new JSONObject(); + nested.put("name", "Nested"); + JSONObject subRef = new JSONObject(); + subRef.put("_content_type_uid", "child_ct"); + subRef.put("uid", "c1"); + subRef.put("x", "v"); + JSONObject ref = new JSONObject(); + ref.put("_content_type_uid", "author_ct"); + ref.put("uid", "refuid"); + ref.put("locale", "fr-fr"); + ref.put("profile", nested); + ref.put("nested_refs", new JSONArray().put(subRef)); + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("authors", new JSONArray().put(ref)); + Utils.addEditableTags(entry, "post", false, "en-us", null); + Assert.assertEquals("data-cslp=author_ct.refuid.fr-fr.profile.name", nested.getJSONObject("$").getString("name")); + Assert.assertEquals("data-cslp=child_ct.c1.fr-fr.x", subRef.getJSONObject("$").getString("x")); + } + + @Test + public void variantDirectFieldAppendsVariantToUidSegment() { + JSONObject applied = new JSONObject(); + applied.put("title", "varA"); + JSONObject entry = new JSONObject(); + entry.put("uid", "eu1"); + entry.put("_applied_variants", applied); + entry.put("title", "T"); + Utils.addEditableTags(entry, "blog", false, "en-us", null); + String tag = entry.getJSONObject("$").getString("title"); + Assert.assertTrue(tag.startsWith("data-cslp=v2:")); + Assert.assertTrue(tag.contains("blog.eu1_varA.en-us.title")); + } + + @Test + public void appliedVariantsFromSystem() { + JSONObject applied = new JSONObject(); + applied.put("field1", "v1"); + JSONObject system = new JSONObject(); + system.put("applied_variants", applied); + JSONObject entry = new JSONObject(); + entry.put("uid", "u1"); + entry.put("system", system); + entry.put("field1", "x"); + Utils.addEditableTags(entry, "ct", false, "en-us", null); + String tag = entry.getJSONObject("$").getString("field1"); + Assert.assertTrue(tag.contains("v2:")); + Assert.assertTrue(tag.contains("u1_v1")); + } + + @Test + public void parentVariantisedPathInheritance() { + JSONObject applied = new JSONObject(); + applied.put("parent", "pv"); + JSONObject entry = new JSONObject(); + entry.put("uid", "e1"); + entry.put("_applied_variants", applied); + entry.put("parent", new JSONObject().put("child", "val")); + Utils.addEditableTags(entry, "ct", false, "en-us", null); + JSONObject parentObj = entry.getJSONObject("parent"); + String childTag = parentObj.getJSONObject("$").getString("child"); + Assert.assertTrue(childTag.startsWith("data-cslp=v2:")); + Assert.assertTrue(childTag.contains("e1_pv")); + } + + @Test + public void addTagsAliasMatchesAddEditableTags() { + JSONObject a = new JSONObject(); + a.put("uid", "1"); + a.put("t", "x"); + JSONObject b = new JSONObject(); + b.put("uid", "1"); + b.put("t", "x"); + Utils.addEditableTags(a, "c", false, "en-us", null); + Utils.addTags(b, "c", false, "en-us", null); + Assert.assertEquals(a.getJSONObject("$").toString(), b.getJSONObject("$").toString()); + } +}