diff --git a/changelog.rst b/changelog.rst index 401c3b5..146577a 100644 --- a/changelog.rst +++ b/changelog.rst @@ -2,6 +2,25 @@ **CHANGELOG** ================ +*v1.5.0* +============ + +NEW FEATURE: Variants utility (CDA entry variant aliases). + +- Added ``Utils.get_variant_aliases`` to read variant alias strings from ``publish_details.variants`` on a CDA entry (single dict or list of entries). Supports optional ``content_type_uid`` when ``_content_type_uid`` is absent on the entry. +- Added ``Utils.get_variant_metadata_tags`` to build a ``data-csvariants`` HTML data-attribute value (JSON string of the multi-entry alias results). + +NEW FEATURE: Live Preview editable tags (CSLP). + +- Added JS-parity editable tagging helpers in ``contentstack_utils/entry_editable.py``. +- Added ``addEditableTags`` / ``addTags`` to mutate an entry with a ``$`` map of CSLP tags (supports nested objects, arrays, references, and applied variants; normalizes case for ``contentTypeUid`` and locale similar to JS). +- Added ``getTag`` helper for building tag maps recursively. +- Exported ``addEditableTags``, ``addTags``, and ``getTag`` at package level, and delegated via ``Utils`` for backward compatibility. + +BUG FIX: Test compatibility. + +- Fixed deprecated unittest assertion usage in ``tests/convert_style.py`` for newer Python versions. + *v1.4.0* ============ diff --git a/contentstack_utils/__init__.py b/contentstack_utils/__init__.py index c404814..880edd6 100644 --- a/contentstack_utils/__init__.py +++ b/contentstack_utils/__init__.py @@ -16,6 +16,7 @@ from contentstack_utils.utils import Utils from contentstack_utils.gql import GQL from contentstack_utils.automate import Automate +from contentstack_utils.entry_editable import addEditableTags, addTags, getTag __all__ = ( "Utils", @@ -25,7 +26,10 @@ "Automate", "StyleType", "ItemType", -"NodeToHtml" +"NodeToHtml", +"addEditableTags", +"addTags", +"getTag", ) __title__ = 'contentstack_utils' diff --git a/contentstack_utils/entry_editable.py b/contentstack_utils/entry_editable.py new file mode 100644 index 0000000..716fb45 --- /dev/null +++ b/contentstack_utils/entry_editable.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional, Union, cast + + +AppliedVariants = Optional[Dict[str, Any]] +TagValue = Union[str, Dict[str, str]] + + +def _get_parent_variantised_path(applied_variants: Dict[str, Any], meta_key: str) -> str: + """ + Port of JS getParentVariantisedPath(). + Finds the longest variantised field path that is a prefix of meta_key. + """ + try: + if not meta_key: + return "" + variantised_field_paths = sorted(applied_variants.keys(), key=len, reverse=True) + child_fragments = meta_key.split(".") + if not child_fragments or not variantised_field_paths: + return "" + for path in variantised_field_paths: + parent_fragments = str(path).split(".") + if len(parent_fragments) > len(child_fragments): + continue + if all(child_fragments[i] == parent_fragments[i] for i in range(len(parent_fragments))): + return str(path) + return "" + except Exception: + return "" + + +def _apply_variant_to_data_value(data_value: str, applied_variants: AppliedVariants, meta_key: str, should_apply_variant: bool) -> str: + """ + Port of JS applyVariantToDataValue(). + + If the current field (or its parent field path) is variantised, prefixes with + 'v2:' and appends `_{variant}` to the entry uid segment of the dot-path. + """ + if not should_apply_variant or not applied_variants or not meta_key or not isinstance(applied_variants, dict): + return data_value + + variant: Optional[str] = None + if meta_key in applied_variants: + variant = str(applied_variants[meta_key]) + else: + parent_path = _get_parent_variantised_path(applied_variants, meta_key) + if parent_path: + variant = str(applied_variants.get(parent_path)) + + if not variant: + return data_value + + parts = ("v2:" + data_value).split(".") + if len(parts) >= 2: + parts[1] = parts[1] + "_" + variant + return ".".join(parts) + + +def _tags_value(data_value: str, tags_as_object: bool, applied_variants: AppliedVariants, meta_key: str, should_apply_variant: bool) -> TagValue: + resolved = _apply_variant_to_data_value(data_value, applied_variants, meta_key, should_apply_variant) + if tags_as_object: + return {"data-cslp": resolved} + return f"data-cslp={resolved}" + + +def _parent_tags_value(data_value: str, tags_as_object: bool) -> TagValue: + if tags_as_object: + return {"data-cslp-parent-field": data_value} + return f"data-cslp-parent-field={data_value}" + + +def getTag( # pylint: disable=invalid-name + content: Any, + prefix: str, + tags_as_object: bool, + locale: str, + applied_variants: AppliedVariants, + should_apply_variant: bool, + meta_key: str = "", +) -> Dict[str, Any]: + """ + Port of JS getTag() from `src/entry-editable.ts`. + + Returns a dict mapping field keys to CSLP tag values, and mutates nested objects/refs + by attaching their own `$` tag maps. + """ + if content is None or not isinstance(content, dict): + return {} + + tags: Dict[str, Any] = {} + for key, value in content.items(): + if key == "$": + continue + + meta_uid = "" + if isinstance(value, dict): + meta = value.get("_metadata") + if isinstance(meta, dict) and meta.get("uid"): + meta_uid = str(meta.get("uid")) + + meta_key_prefix = (meta_key + ".") if meta_key else "" + updated_meta_key = f"{meta_key_prefix}{key}" if should_apply_variant else "" + if meta_uid and updated_meta_key: + updated_meta_key = updated_meta_key + "." + meta_uid + + if isinstance(value, list): + for index, obj in enumerate(value): + if obj is None: + continue + + child_key = f"{key}__{index}" + parent_key = f"{key}__parent" + + obj_meta_uid = "" + if isinstance(obj, dict): + meta = obj.get("_metadata") + if isinstance(meta, dict) and meta.get("uid"): + obj_meta_uid = str(meta.get("uid")) + + array_meta_key = f"{meta_key_prefix}{key}" if should_apply_variant else "" + if obj_meta_uid and array_meta_key: + array_meta_key = array_meta_key + "." + obj_meta_uid + + tags[child_key] = _tags_value( + f"{prefix}.{key}.{index}", + tags_as_object, + applied_variants, + array_meta_key, + should_apply_variant, + ) + tags[parent_key] = _parent_tags_value(f"{prefix}.{key}", tags_as_object) + + # Reference entries in array + if isinstance(obj, dict) and obj.get("_content_type_uid") is not None and obj.get("uid") is not None: + new_applied_variants = obj.get("_applied_variants") + if new_applied_variants is None and isinstance(obj.get("system"), dict): + new_applied_variants = cast(dict, obj["system"]).get("applied_variants") + new_should_apply_variant = bool(new_applied_variants) + + obj_locale = obj.get("locale") or locale + obj["$"] = getTag( + obj, + f"{obj.get('_content_type_uid')}.{obj.get('uid')}.{obj_locale}", + tags_as_object, + locale, + cast(AppliedVariants, new_applied_variants), + new_should_apply_variant, + meta_key="", + ) + continue + + if isinstance(obj, dict): + obj["$"] = getTag( + obj, + f"{prefix}.{key}.{index}", + tags_as_object, + locale, + applied_variants, + should_apply_variant, + meta_key=array_meta_key, + ) + + tags[key] = _tags_value( + f"{prefix}.{key}", + tags_as_object, + applied_variants, + updated_meta_key, + should_apply_variant, + ) + continue + + if isinstance(value, dict): + value["$"] = getTag( + value, + f"{prefix}.{key}", + tags_as_object, + locale, + applied_variants, + should_apply_variant, + meta_key=updated_meta_key, + ) + tags[key] = _tags_value( + f"{prefix}.{key}", + tags_as_object, + applied_variants, + updated_meta_key, + should_apply_variant, + ) + continue + + tags[key] = _tags_value( + f"{prefix}.{key}", + tags_as_object, + applied_variants, + updated_meta_key, + should_apply_variant, + ) + + return tags + + +def addTags( # pylint: disable=invalid-name + entry: Optional[dict], + contentTypeUid: str, + tagsAsObject: bool, + locale: str = "en-us", + options: Optional[dict] = None, +) -> None: + """ + Port of JS addTags() from `src/entry-editable.ts`. + Mutates `entry` by attaching a `$` dict of CSLP tags. + """ + if not entry: + return + + use_lower_case_locale = True + if isinstance(options, dict) and "useLowerCaseLocale" in options: + use_lower_case_locale = bool(options.get("useLowerCaseLocale")) + + content_type_uid = (contentTypeUid or "").lower() + resolved_locale = (locale or "en-us") + if use_lower_case_locale: + resolved_locale = resolved_locale.lower() + + applied_variants = entry.get("_applied_variants") + if applied_variants is None and isinstance(entry.get("system"), dict): + applied_variants = cast(dict, entry["system"]).get("applied_variants") + should_apply_variant = bool(applied_variants) + + entry["$"] = getTag( + entry, + f"{content_type_uid}.{entry.get('uid')}.{resolved_locale}", + tagsAsObject, + resolved_locale, + cast(AppliedVariants, applied_variants), + should_apply_variant, + meta_key="", + ) + + +# JS parity export name +addEditableTags = addTags # pylint: disable=invalid-name + +# Pythonic aliases +add_tags = addTags +get_tags = getTag + diff --git a/contentstack_utils/utils.py b/contentstack_utils/utils.py index 134c9a4..4519d2d 100644 --- a/contentstack_utils/utils.py +++ b/contentstack_utils/utils.py @@ -1,17 +1,69 @@ # pylint: disable=missing-function-docstring import json -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Optional, Union from lxml import etree from contentstack_utils.automate import Automate +from contentstack_utils.entry_editable import addEditableTags as _addEditableTags +from contentstack_utils.entry_editable import addTags as _addTags +from contentstack_utils.entry_editable import getTag as _getTag from contentstack_utils.helper.converter import convert_style from contentstack_utils.helper.metadata import Metadata from contentstack_utils.render.options import Options class Utils(Automate): + # JS parity helpers (moved to `contentstack_utils/entry_editable.py`) + @staticmethod + def addTags( # pylint: disable=invalid-name + entry: dict, + contentTypeUid: str, + tagsAsObject: Optional[bool] = None, + locale: str = "en-us", + options: Optional[dict] = None, + **kwargs, + ) -> None: + # Support pythonic kwarg name too (backward compatibility with earlier port). + if tagsAsObject is None and "tags_as_object" in kwargs: + tagsAsObject = bool(kwargs["tags_as_object"]) + if tagsAsObject is None: + tagsAsObject = False + return _addTags(entry, contentTypeUid, tagsAsObject, locale, options) + + @staticmethod + def addEditableTags( # pylint: disable=invalid-name + entry: dict, + contentTypeUid: str, + tagsAsObject: Optional[bool] = None, + locale: str = "en-us", + options: Optional[dict] = None, + **kwargs, + ) -> None: + if tagsAsObject is None and "tags_as_object" in kwargs: + tagsAsObject = bool(kwargs["tags_as_object"]) + if tagsAsObject is None: + tagsAsObject = False + return _addEditableTags(entry, contentTypeUid, tagsAsObject, locale, options) + + @staticmethod + def getTag( # pylint: disable=invalid-name + content: Any, + prefix: str, + tagsAsObject: bool, + locale: str, + appliedVariants: Optional[dict], + shouldApplyVariant: bool, + metaKey: str = "", + ) -> Dict[str, Any]: + # Keep JS argument names for parity. + return _getTag(content, prefix, tagsAsObject, locale, appliedVariants, shouldApplyVariant, metaKey) + + # Pythonic aliases + add_tags = addTags + get_tags = getTag + get_tag = getTag @staticmethod def _variants_map_from_entry(entry: dict) -> dict: diff --git a/setup.py b/setup.py index e229819..80f3be5 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ long_description_content_type="text/markdown", url="https://github.com/contentstack/contentstack-utils-python", license='MIT', - version='1.4.0', + version='1.5.0', install_requires=[ ], diff --git a/tests/convert_style.py b/tests/convert_style.py index 52eb132..982f154 100644 --- a/tests/convert_style.py +++ b/tests/convert_style.py @@ -10,7 +10,7 @@ def setUp(self): def test_converter_style_block(self): _returns = converter.convert_style('block') - self.assertEquals(StyleType.BLOCK, _returns) + self.assertEqual(StyleType.BLOCK, _returns) def test_converter_style_inline(self): _returns = converter.convert_style('inline') diff --git a/tests/test_editable_tags.py b/tests/test_editable_tags.py new file mode 100644 index 0000000..4f2c67b --- /dev/null +++ b/tests/test_editable_tags.py @@ -0,0 +1,56 @@ +import unittest + +from contentstack_utils.utils import Utils + + +class TestEditableTags(unittest.TestCase): + def test_add_tags_mutates_entry_with_dollar_map(self): + entry = {"uid": "e1", "title": "Hello", "count": 1} + Utils.addTags(entry, "Blog_Post", tags_as_object=True, locale="EN-us") + self.assertIn("$", entry) + self.assertEqual(entry["$"]["title"], {"data-cslp": "blog_post.e1.en-us.title"}) + self.assertEqual(entry["$"]["count"], {"data-cslp": "blog_post.e1.en-us.count"}) + + def test_add_tags_string_mode(self): + entry = {"uid": "e1", "title": "Hello"} + Utils.addTags(entry, "blog_post", tags_as_object=False, locale="en-us") + self.assertEqual(entry["$"]["title"], "data-cslp=blog_post.e1.en-us.title") + + def test_array_tags_add_index_and_parent_keys(self): + entry = {"uid": "e1", "array": ["hello", "world"]} + Utils.addTags(entry, "blog", tags_as_object=True, locale="en-us") + self.assertEqual(entry["$"]["array"], {"data-cslp": "blog.e1.en-us.array"}) + self.assertEqual(entry["$"]["array__0"], {"data-cslp": "blog.e1.en-us.array.0"}) + self.assertEqual(entry["$"]["array__1"], {"data-cslp": "blog.e1.en-us.array.1"}) + self.assertEqual(entry["$"]["array__parent"], {"data-cslp-parent-field": "blog.e1.en-us.array"}) + + def test_reference_entry_inside_array_gets_own_dollar(self): + entry = { + "uid": "e1", + "refs": [ + { + "uid": "r1", + "_content_type_uid": "ref_ct", + "title": "Ref Title", + } + ], + } + Utils.addTags(entry, "blog", tags_as_object=True, locale="en-us") + ref = entry["refs"][0] + self.assertIn("$", ref) + self.assertEqual(ref["$"]["title"], {"data-cslp": "ref_ct.r1.en-us.title"}) + + def test_variantised_field_applies_v2_prefix_and_uid_suffix(self): + entry = { + "uid": "e1", + "_applied_variants": {"title": "v123"}, + "title": {"value": "Hello"}, + } + Utils.addTags(entry, "blog", tags_as_object=True, locale="en-us") + # title is an object; ensure we tag the object itself (like JS) and apply variant. + self.assertEqual(entry["$"]["title"], {"data-cslp": "v2:blog.e1_v123.en-us.title"}) + + +if __name__ == "__main__": + unittest.main() +