diff --git a/cms-api/src/main/java/com/condation/cms/api/db/Content.java b/cms-api/src/main/java/com/condation/cms/api/db/Content.java index 168038765..467a25a82 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/Content.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/Content.java @@ -56,5 +56,12 @@ public interface Content { public ContentQuery query(final String startURI, final BiFunction nodeMapper); - public List searchByTitle (@NonNull String input); + default List searchByTitle (@NonNull String input) { + return searchByTitle(input, VariantSearchMode.ALL); + } + + List searchByTitle( + @NonNull String input, + @NonNull VariantSearchMode variantSearchMode + ); } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java index a1209d64e..570625773 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java @@ -26,9 +26,8 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.utils.MapUtil; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.api.utils.SectionUtil; -import com.google.common.math.DoubleMath; +import com.condation.cms.api.workflow.WFStatusProvider; import java.io.Serializable; import java.time.LocalDate; import java.util.HashMap; @@ -127,6 +126,55 @@ public boolean isSectionEntry() { return SectionUtil.isSectionEntry(name); } + public boolean isVariant() { + return variantPathSegment() >= 0; + } + + public Optional variantId() { + var pathParts = normalizedPathParts(); + var variantSegment = variantPathSegment(pathParts); + if (variantSegment < 0 || variantSegment + 2 >= pathParts.length) { + return Optional.empty(); + } + return Optional.of(pathParts[variantSegment + 2]); + } + + /** + * Returns the indexed path of the canonical page represented by this + * variant. + */ + public Optional originalUri() { + var pathParts = normalizedPathParts(); + var variantSegment = variantPathSegment(pathParts); + if (variantSegment < 0 || variantSegment + 1 >= pathParts.length) { + return Optional.empty(); + } + + var originalName = pathParts[variantSegment + 1] + ".md"; + if (variantSegment == 0) { + return Optional.of(originalName); + } + return Optional.of(String.join("/", java.util.Arrays.copyOf(pathParts, variantSegment)) + + "/" + originalName); + } + + private int variantPathSegment() { + return variantPathSegment(normalizedPathParts()); + } + + private int variantPathSegment(String[] pathParts) { + for (int index = 0; index < pathParts.length; index++) { + if (".variants".equals(pathParts[index])) { + return index; + } + } + return -1; + } + + private String[] normalizedPathParts() { + return uri.replace('\\', '/').split("/"); + } + public boolean isRedirect() { return MapUtil.getValue(data, Constants.MetaFields.REDIRECT_LOCATION) != null; } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java b/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java index a007e2002..ee970c893 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java @@ -48,4 +48,10 @@ public interface DBFileSystem { ReadOnlyFile contentBase(); ReadOnlyFile assetBase(); + + /** + * Processes all content changes that have already been queued for indexing + * and waits until the index has been updated. + */ + void flushContentChanges(); } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java b/cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java new file mode 100644 index 000000000..ca97d1aab --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/VariantSearchMode.java @@ -0,0 +1,32 @@ +package com.condation.cms.api.db; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +/** + * Controls whether a content search includes canonical pages, variants, or + * both. + */ +public enum VariantSearchMode { + ORIGINAL, + VARIANT, + ALL +} diff --git a/cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java b/cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java new file mode 100644 index 000000000..26913a2a7 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/extensions/VariantSelectorExtensionPoint.java @@ -0,0 +1,40 @@ +package com.condation.cms.api.extensions; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.variants.VariantSelector; + +/** + * Extension point for automatic variant selection strategies. + * + *

Manager and explicit preview selection are handled by the CMS before an + * extension is invoked. Implementations therefore only select a variant for a + * normal public request.

+ */ +public abstract class VariantSelectorExtensionPoint + extends AbstractExtensionPoint + implements VariantSelector { + + public abstract String id(); + + public abstract String label(); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/variants/Variant.java b/cms-api/src/main/java/com/condation/cms/api/variants/Variant.java new file mode 100644 index 000000000..5241a3dea --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/variants/Variant.java @@ -0,0 +1,30 @@ +package com.condation.cms.api.variants; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; + +/** + * A named variant of a canonical content node. + */ +public record Variant(String id, ContentNode node) { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelection.java b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelection.java new file mode 100644 index 000000000..3fcc3a653 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelection.java @@ -0,0 +1,48 @@ +package com.condation.cms.api.variants; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Optional; + +/** + * Describes the result and origin of a variant selection. + */ +public record VariantSelection(Optional variant, Source source) { + + public static VariantSelection canonical() { + return new VariantSelection(Optional.empty(), Source.CANONICAL); + } + + public static VariantSelection preview(Variant variant) { + return new VariantSelection(Optional.of(variant), Source.PREVIEW); + } + + public static VariantSelection automatic(Variant variant) { + return new VariantSelection(Optional.of(variant), Source.AUTOMATIC); + } + + public enum Source { + CANONICAL, + PREVIEW, + AUTOMATIC + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelector.java b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelector.java new file mode 100644 index 000000000..585d97c71 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantSelector.java @@ -0,0 +1,38 @@ +package com.condation.cms.api.variants; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.request.RequestContext; +import java.util.List; + +/** + * Selects the content variant to render for a request. + */ +public interface VariantSelector { + + VariantSelection select( + ContentNode canonicalNode, + List variants, + RequestContext context + ); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java b/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java index 809faf34a..3e3f40ee9 100644 --- a/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java +++ b/cms-api/src/main/java/com/condation/cms/api/workflow/DefaultWFStatusProvider.java @@ -24,7 +24,6 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.utils.DateRange; -import java.time.Instant; import java.util.Date; /** @@ -44,7 +43,7 @@ public boolean isPublished(ContentNode node) { @Override public Status status(ContentNode node) { var published = isPublished(node); - var publish_date = (Date) node.data().getOrDefault(Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now())); + var publish_date = (Date) node.data().getOrDefault(Constants.MetaFields.PUBLISH_DATE, null); var unpublish_date = (Date) node.data().getOrDefault(Constants.MetaFields.UNPUBLISH_DATE, null); return new Status(published, publish_date, unpublish_date, DateRange.isNowWithin(publish_date, unpublish_date), statusValue(node)); diff --git a/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java b/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java index 7c4c21042..fd74b87dd 100644 --- a/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java +++ b/cms-api/src/main/java/com/condation/cms/api/workflow/WFStatusProvider.java @@ -2,10 +2,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.utils.DateRange; -import java.time.Instant; -import java.time.LocalDate; import java.util.Date; -import org.jspecify.annotations.NonNull; /*- * #%L @@ -40,5 +37,5 @@ public interface WFStatusProvider { String newNodeStatus (); - public static record Status (boolean published, @NonNull Date publish_date, Date unpublish_date, boolean withinSchedule, String currentStage){}; + public static record Status (boolean published, Date publish_date, Date unpublish_date, boolean withinSchedule, String currentStage){}; } diff --git a/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java b/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java index 36da7211e..e5325d857 100644 --- a/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java @@ -39,4 +39,32 @@ public void test_publish() { Assertions.assertThat(NodeVisibility.isVisible(contentNode)).isFalse(); Assertions.assertThat(contentNode.isVisible()).isFalse(); } + + @Test + void detectsVariantAndExposesVariantMetadata() { + var contentNode = new ContentNode( + "products/.variants/about/summer/about.md", + "/products/.variants/about/summer/about", + "about.md", + Map.of() + ); + + Assertions.assertThat(contentNode.isVariant()).isTrue(); + Assertions.assertThat(contentNode.variantId()).contains("summer"); + Assertions.assertThat(contentNode.originalUri()).contains("products/about.md"); + } + + @Test + void doesNotTreatSimilarFolderNameAsVariant() { + var contentNode = new ContentNode( + "products/my.variants/about.md", + "/products/my.variants/about", + "about.md", + Map.of() + ); + + Assertions.assertThat(contentNode.isVariant()).isFalse(); + Assertions.assertThat(contentNode.variantId()).isEmpty(); + Assertions.assertThat(contentNode.originalUri()).isEmpty(); + } } diff --git a/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java b/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java index c5c474005..df53f2023 100644 --- a/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java @@ -37,6 +37,18 @@ * @author thorstenmarx */ public class DefaultWFStatusProviderTest { + + @Test + public void missingPublishDateRemainsUnsetAndHasNoStartLimit() { + var contentNode = new ContentNode("", "", "", Map.of( + Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED + )); + + var status = new DefaultWFStatusProvider().status(contentNode); + + Assertions.assertThat(status.publish_date()).isNull(); + Assertions.assertThat(status.withinSchedule()).isTrue(); + } @Test public void test_publish_date_1_11_2023() { diff --git a/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java new file mode 100644 index 000000000..b22d97f17 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/ConfigurableVariantSelector.java @@ -0,0 +1,172 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.extensions.VariantSelectorExtensionPoint; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import com.condation.cms.api.variants.VariantSelector; +import com.condation.modules.api.ModuleManager; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Applies CMS-owned manager and preview rules before delegating public + * requests to the selector configured for the canonical page. + */ +@Slf4j +public class ConfigurableVariantSelector implements VariantSelector { + + public static final String DATE_RANGE_LABEL = "Date range"; + public static final String VARIANT_QUERY_PARAMETER = "variant"; + public static final String CANONICAL_VARIANT_ID = "default"; + + private final DefaultVariantSelector dateRangeSelector; + private final VariantSelectorConfigurationRepository configurationRepository; + private final ModuleManager moduleManager; + + public ConfigurableVariantSelector( + DefaultVariantSelector dateRangeSelector, + VariantSelectorConfigurationRepository configurationRepository, + ModuleManager moduleManager + ) { + this.dateRangeSelector = dateRangeSelector; + this.configurationRepository = configurationRepository; + this.moduleManager = moduleManager; + } + + @Override + public VariantSelection select( + ContentNode canonicalNode, + List variants, + RequestContext context + ) { + if (hasExplicitPreviewSelection(context)) { + return selectPreviewVariant(canonicalNode, variants, context.get(RequestFeature.class)); + } + if (isManager(context)) { + return VariantSelection.canonical(); + } + + var selectorId = configurationRepository.getSelectorId(canonicalNode); + var selector = selectors().get(selectorId); + if (selector == null) { + log.warn( + "Configured variant selector '{}' is unavailable for '{}'; using '{}'", + selectorId, + canonicalNode.path(), + VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID + ); + selector = dateRangeSelector; + } + + try { + return selector.select(canonicalNode, variants, context); + } catch (RuntimeException exception) { + log.error( + "Variant selector '{}' failed for '{}'; using date-range fallback", + selectorId, + canonicalNode.path(), + exception + ); + return dateRangeSelector.select(canonicalNode, variants, context); + } + } + + public Map availableSelectors() { + Map descriptors = new LinkedHashMap<>(); + descriptors.put( + VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, + new SelectorDescriptor( + VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, + DATE_RANGE_LABEL + ) + ); + moduleManager.extensions(VariantSelectorExtensionPoint.class).forEach(extension -> + descriptors.putIfAbsent( + extension.id(), + new SelectorDescriptor(extension.id(), extension.label()) + ) + ); + return Map.copyOf(descriptors); + } + + public boolean hasSelector(String selectorId) { + return availableSelectors().containsKey(selectorId); + } + + private Map selectors() { + Map selectors = new LinkedHashMap<>(); + selectors.put(VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, dateRangeSelector); + moduleManager.extensions(VariantSelectorExtensionPoint.class).forEach(extension -> + selectors.putIfAbsent(extension.id(), extension) + ); + return selectors; + } + + private boolean isManager(RequestContext context) { + return context.has(IsPreviewFeature.class) + && IsPreviewFeature.Mode.MANAGER.equals(context.get(IsPreviewFeature.class).mode()); + } + + private boolean hasExplicitPreviewSelection(RequestContext context) { + return context.has(IsPreviewFeature.class) + && (IsPreviewFeature.Mode.PREVIEW.equals(context.get(IsPreviewFeature.class).mode()) + || IsPreviewFeature.Mode.MANAGER.equals(context.get(IsPreviewFeature.class).mode())) + && context.has(RequestFeature.class) + && context.get(RequestFeature.class) + .hasQueryParameter(VARIANT_QUERY_PARAMETER); + } + + private VariantSelection selectPreviewVariant( + ContentNode canonicalNode, + List variants, + RequestFeature request + ) { + var variantId = request.getQueryParameter(VARIANT_QUERY_PARAMETER, "").trim(); + if (variantId.isBlank() || CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { + return VariantSelection.canonical(); + } + + return variants.stream() + .filter(variant -> variant.id().equals(variantId)) + .findFirst() + .map(VariantSelection::preview) + .orElseGet(() -> { + log.warn( + "Requested preview variant '{}' does not exist for '{}'", + variantId, + canonicalNode.path() + ); + return VariantSelection.canonical(); + }); + } + + public record SelectorDescriptor(String id, String label) { + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java index 850317009..19a6e8da3 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java @@ -27,29 +27,51 @@ import com.condation.cms.api.content.RedirectContentResponse; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.NodeVisibility; import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.IsPreviewFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelector; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.core.content.ContentResolvingStrategy; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Optional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * * @author t.marx */ -@RequiredArgsConstructor @Slf4j public class ContentResolver { private final ContentRenderer contentRenderer; private final DB db; + + private final VariantResolver variantResolver; + + private final VariantSelector variantSelector; + + public ContentResolver(ContentRenderer contentRenderer, DB db) { + this(contentRenderer, db, new VariantResolver(db), new DefaultVariantSelector()); + } + + public ContentResolver( + ContentRenderer contentRenderer, + DB db, + VariantResolver variantResolver, + VariantSelector variantSelector + ) { + this.contentRenderer = contentRenderer; + this.db = db; + this.variantResolver = variantResolver; + this.variantSelector = variantSelector; + } public Optional getContent (final RequestContext context) { return getContent(context, true); @@ -82,8 +104,6 @@ private Optional getContent(final RequestContext context, boole if (contentNode == null) { return Optional.empty(); } - - var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); if (checkVisibility && !db.getContent().isVisible(contentNode)) { return Optional.empty(); @@ -101,7 +121,22 @@ private Optional getContent(final RequestContext context, boole } else if (!Constants.NodeType.PAGE.equals(contentNode.nodeType())) { return Optional.empty(); } - context.add(CurrentNodeFeature.class, new CurrentNodeFeature(contentNode)); + + var selection = variantSelector.select( + contentNode, + variantResolver.getVariants(contentNode), + context + ); + var selectedNode = selection.variant() + .map(Variant::node) + .filter(node -> !checkVisibility + || context.has(IsPreviewFeature.class) + || NodeVisibility.isVisible(node)) + .orElse(contentNode); + + var contentFile = db.getFileSystem().contentBase().resolve(selectedNode.path()); + + context.add(CurrentNodeFeature.class, new CurrentNodeFeature(selectedNode)); try { @@ -111,9 +146,9 @@ private Optional getContent(final RequestContext context, boole var content = contentRenderer.render(contentFile, context, renderedSectionEntries); - var contentType = contentNode.contentType(); + var contentType = selectedNode.contentType(); - return Optional.of(new DefaultContentResponse(content, contentType, contentNode)); + return Optional.of(new DefaultContentResponse(content, contentType, selectedNode)); } catch (IOException ex) { log.error(null, ex); return Optional.empty(); diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java index 35bc3b4af..c82b346f4 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java @@ -54,7 +54,6 @@ import com.condation.cms.hooksystem.extensions.TemplateHooks; import com.condation.cms.content.template.functions.LinkFunction; import com.condation.cms.content.template.functions.MarkdownFunction; -import com.condation.cms.content.template.functions.hooks.HooksTemlateFunction; import com.condation.cms.content.template.functions.list.NodeListFunctionBuilder; import com.condation.cms.content.template.functions.navigation.NavigationFunction; import com.condation.cms.content.template.functions.query.QueryFunction; diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java new file mode 100644 index 000000000..a9cfef20a --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultVariantSelector.java @@ -0,0 +1,80 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import com.condation.cms.api.variants.VariantSelector; +import com.condation.cms.api.workflow.DefaultWFStatusProvider; +import com.condation.cms.api.workflow.WFStatusProvider; +import java.util.Comparator; +import java.util.List; + +/** + * Selects the newest published variant within its configured date range. + * + * @author thorstenmarx + */ +public class DefaultVariantSelector implements VariantSelector { + + @Override + public VariantSelection select( + ContentNode canonicalNode, + List variants, + RequestContext context + ) { + var statusProvider = getStatusProvider(context); + + return variants.stream() + .map(variant -> new ScheduledVariant( + variant, + statusProvider.status(variant.node()) + )) + .filter(variant -> variant.status().published()) + .filter(variant -> variant.status().withinSchedule()) + .max(Comparator.comparing( + variant -> variant.status().publish_date(), + Comparator.nullsFirst(Comparator.naturalOrder()) + ).thenComparing(variant -> variant.variant().id())) + .map(ScheduledVariant::variant) + .map(VariantSelection::automatic) + .orElseGet(VariantSelection::canonical); + } + + private WFStatusProvider getStatusProvider(RequestContext context) { + if (context.has(WorkflowFeature.class)) { + return context.get(WorkflowFeature.class) + .workflow() + .getStatusProvider(); + } + return new DefaultWFStatusProvider(); + } + + private record ScheduledVariant( + Variant variant, + WFStatusProvider.Status status + ) { + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java new file mode 100644 index 000000000..64b9fb94a --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java @@ -0,0 +1,141 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.api.variants.Variant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.nio.file.Path; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * + * @author thorstenmarx + */ +@Slf4j +@RequiredArgsConstructor +public class VariantResolver { + + private final DB db; + + public Optional loadVariant (ContentNode node, String variantId) { + return getVariants(resolveContext(node).canonical()).stream() + .filter(variant -> variant.id().equals(variantId)) + .findFirst(); + } + + /** + * Resolves the canonical page, active variant and sibling variants for + * either a canonical page or one of its variants. + */ + public VariantContext resolveContext(ContentNode node) { + var location = variantLocation(node); + var canonical = location + .flatMap(value -> db.getContent().byPath(value.canonicalPath())) + .orElse(node); + var activeVariantId = location + .filter(value -> canonical != node) + .map(VariantLocation::variantId); + + return new VariantContext(canonical, activeVariantId, getVariants(canonical)); + } + + public List getVariants (ContentNode node) { + + var folder = db.getFileSystem().contentBase().resolve(node.path()).getParent(); + var variantsFolder = folder.resolve(".variants/" + removeMarkdown(node.name())); + + if (variantsFolder.exists()) { + try { + List variants = new ArrayList<>(); + variantsFolder.children().stream().filter(ReadOnlyFile::isDirectory).forEach(variant -> { + var id = variant.getFileName(); + var contentFile = variant.resolve(node.name()); + var url = PathUtil.toURL(contentFile, db.getFileSystem().contentBase()); + var variantNode = db.getContent().byUrl(url); + if (variantNode.isPresent()) { + variants.add(new Variant(id, variantNode.get())); + } + }); + + return variants; + } catch (IOException ex) { + log.error("error loading variants", ex); + } + } + + return Collections.emptyList(); + } + + private String removeMarkdown (String filename) { + if (filename.endsWith(".md")) { + return filename.substring(0, filename.lastIndexOf(".md")); + } + return filename; + } + + private Optional variantLocation(ContentNode node) { + var path = Path.of(node.path()); + for (int index = 0; index < path.getNameCount(); index++) { + if (!".variants".equals(path.getName(index).toString())) { + continue; + } + if (index + 3 != path.getNameCount() - 1) { + return Optional.empty(); + } + + var pageFolder = path.getName(index + 1).toString(); + var variantId = path.getName(index + 2).toString(); + var fileName = path.getName(index + 3).toString(); + if (!pageFolder.equals(removeMarkdown(fileName))) { + return Optional.empty(); + } + + Path canonicalPath = index == 0 + ? Path.of(fileName) + : path.subpath(0, index).resolve(fileName); + return Optional.of(new VariantLocation( + canonicalPath.toString().replace('\\', '/'), + variantId + )); + } + return Optional.empty(); + } + + public record VariantContext( + ContentNode canonical, + Optional activeVariantId, + List variants + ) { + } + + private record VariantLocation(String canonicalPath, String variantId) { + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java new file mode 100644 index 000000000..24747366b --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java @@ -0,0 +1,97 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.core.content.io.YamlHeaderUpdater; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Loads and stores the selector configured for a canonical page. + */ +@Slf4j +@RequiredArgsConstructor +public class VariantSelectorConfigurationRepository { + + public static final String DEFAULT_SELECTOR_ID = "date-range"; + public static final String CONFIG_FILE_NAME = "variants.yaml"; + private static final String SELECTOR_PROPERTY = "selector"; + + private final DB db; + private final VariantResolver variantResolver; + + public String getSelectorId(ContentNode node) { + var configurationFile = configurationFile(node); + if (!Files.exists(configurationFile)) { + return DEFAULT_SELECTOR_ID; + } + + try { + var yaml = new Yaml(new SafeConstructor(new LoaderOptions())); + var data = yaml.load(Files.readString(configurationFile)); + if (data instanceof Map map + && map.get(SELECTOR_PROPERTY) instanceof String selector + && !selector.isBlank()) { + return selector.trim(); + } + } catch (Exception exception) { + log.error("Could not read variant selector configuration '{}'", configurationFile, exception); + } + return DEFAULT_SELECTOR_ID; + } + + public void setSelectorId(ContentNode node, String selectorId) throws IOException { + if (selectorId == null || selectorId.isBlank()) { + throw new IllegalArgumentException("selectorId must not be blank"); + } + var configurationFile = configurationFile(node); + Files.createDirectories(configurationFile.getParent()); + YamlHeaderUpdater.saveMetaData( + configurationFile, + Map.of(SELECTOR_PROPERTY, selectorId.trim()) + ); + } + + public Path configurationFile(ContentNode node) { + var canonical = variantResolver.resolveContext(node).canonical(); + var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var canonicalFile = contentBase.resolve(canonical.path()); + var fileName = canonicalFile.getFileName().toString(); + var pageName = fileName.endsWith(".md") + ? fileName.substring(0, fileName.length() - 3) + : fileName; + return canonicalFile.getParent() + .resolve(".variants") + .resolve(pageName) + .resolve(CONFIG_FILE_NAME); + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java new file mode 100644 index 000000000..53b6ff5ad --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/ConfigurableVariantSelectorTest.java @@ -0,0 +1,144 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.extensions.VariantSelectorExtensionPoint; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import com.condation.modules.api.ModuleManager; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ConfigurableVariantSelectorTest { + + private final ContentNode canonical = new ContentNode("about.md", "/about", "about.md", Map.of()); + private final Variant summer = new Variant( + "summer", + new ContentNode(".variants/about/summer/about.md", "/summer", "about.md", Map.of()) + ); + private final List variants = List.of(summer); + private DefaultVariantSelector dateRange; + private VariantSelectorConfigurationRepository configuration; + private VariantSelectorExtensionPoint extension; + private ConfigurableVariantSelector selector; + + @BeforeEach + void setUp() { + dateRange = mock(DefaultVariantSelector.class); + configuration = mock(VariantSelectorConfigurationRepository.class); + extension = mock(VariantSelectorExtensionPoint.class); + var moduleManager = mock(ModuleManager.class); + when(extension.id()).thenReturn("audience"); + when(extension.label()).thenReturn("Audience"); + when(moduleManager.extensions(VariantSelectorExtensionPoint.class)).thenReturn(List.of(extension)); + selector = new ConfigurableVariantSelector(dateRange, configuration, moduleManager); + } + + @Test + void publicRequestsUseConfiguredModuleSelector() { + var context = mock(RequestContext.class); + var expected = VariantSelection.canonical(); + when(configuration.getSelectorId(canonical)).thenReturn("audience"); + when(extension.select(canonical, variants, context)).thenReturn(expected); + + assertThat(selector.select(canonical, variants, context)).isSameAs(expected); + verify(extension).select(canonical, variants, context); + } + + @Test + void managerRequestsAlwaysUseCentralSelectionLogic() { + var context = context(IsPreviewFeature.Mode.MANAGER, null); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.canonical()); + verify(configuration, never()).getSelectorId(canonical); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void explicitPreviewSelectsVariantWithoutInvokingASelector() { + var context = context(IsPreviewFeature.Mode.PREVIEW, "summer"); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.preview(summer)); + verify(configuration, never()).getSelectorId(canonical); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void explicitManagerPreviewSelectsVariantWithoutInvokingASelector() { + var context = context(IsPreviewFeature.Mode.MANAGER, "summer"); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.preview(summer)); + verify(configuration, never()).getSelectorId(canonical); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void explicitPreviewCanForceCanonicalContent() { + var context = context( + IsPreviewFeature.Mode.PREVIEW, + ConfigurableVariantSelector.CANONICAL_VARIANT_ID + ); + + assertThat(selector.select(canonical, variants, context)) + .isEqualTo(VariantSelection.canonical()); + verify(dateRange, never()).select(canonical, variants, context); + verify(extension, never()).select(canonical, variants, context); + } + + @Test + void exposesBuiltInAndModuleSelectors() { + assertThat(selector.availableSelectors()) + .containsKeys(VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID, "audience"); + } + + private RequestContext context(IsPreviewFeature.Mode mode, String variantId) { + var context = mock(RequestContext.class); + var request = mock(RequestFeature.class); + when(context.has(IsPreviewFeature.class)).thenReturn(true); + when(context.get(IsPreviewFeature.class)).thenReturn(new IsPreviewFeature(mode)); + when(context.has(RequestFeature.class)).thenReturn(true); + when(context.get(RequestFeature.class)).thenReturn(request); + when(request.hasQueryParameter(ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER)) + .thenReturn(variantId != null); + when(request.getQueryParameter(ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, "")) + .thenReturn(variantId == null ? "" : variantId); + return context; + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java new file mode 100644 index 000000000..530170f92 --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/DefaultVariantSelectorTest.java @@ -0,0 +1,187 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantSelection; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DefaultVariantSelectorTest { + + private final DefaultVariantSelector selector = new DefaultVariantSelector(); + private final ContentNode canonicalNode = node("about.md"); + @Test + public void automaticSelectionUsesPublishedVariantWithinSchedule() { + var active = scheduledVariant( + "active", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + + var selection = selector.select( + canonicalNode, + List.of(active), + context() + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.AUTOMATIC); + Assertions.assertThat(selection.variant()).contains(active); + } + + @Test + public void automaticSelectionSupportsPublishedVariantWithoutSchedule() { + var unscheduled = new Variant( + "unscheduled", + new ContentNode( + ".variants/about/unscheduled/about.md", + "/.variants/about/unscheduled/about", + "about.md", + Map.of(Constants.MetaFields.STATUS, "published") + ) + ); + + var selection = selector.select( + canonicalNode, + List.of(unscheduled), + context() + ); + + Assertions.assertThat(selection.variant()).contains(unscheduled); + } + + @Test + public void automaticSelectionIgnoresDraftAndVariantsOutsideSchedule() { + var draft = scheduledVariant( + "draft", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "draft" + ); + var future = scheduledVariant( + "future", + Instant.now().plus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(2)), + "published" + ); + var expired = scheduledVariant( + "expired", + Instant.now().minus(Duration.ofDays(2)), + Instant.now().minus(Duration.ofDays(1)), + "published" + ); + + var selection = selector.select( + canonicalNode, + List.of(draft, future, expired), + context() + ); + + Assertions.assertThat(selection.source()).isEqualTo(VariantSelection.Source.CANONICAL); + Assertions.assertThat(selection.variant()).isEmpty(); + } + + @Test + public void newestActiveVariantWins() { + var older = scheduledVariant( + "older", + Instant.now().minus(Duration.ofDays(2)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + var newer = scheduledVariant( + "newer", + Instant.now().minus(Duration.ofDays(1)), + Instant.now().plus(Duration.ofDays(1)), + "published" + ); + + var selection = selector.select( + canonicalNode, + List.of(newer, older), + context() + ); + + Assertions.assertThat(selection.variant()).contains(newer); + } + + @Test + public void variantsWithSameScheduleUseIdAsStableTieBreaker() { + var publishDate = Instant.now().minus(Duration.ofDays(1)); + var unpublishDate = Instant.now().plus(Duration.ofDays(1)); + var alpha = scheduledVariant("alpha", publishDate, unpublishDate, "published"); + var beta = scheduledVariant("beta", publishDate, unpublishDate, "published"); + + var firstOrder = selector.select( + canonicalNode, + List.of(beta, alpha), + context() + ); + var secondOrder = selector.select( + canonicalNode, + List.of(alpha, beta), + context() + ); + + Assertions.assertThat(firstOrder.variant()).contains(beta); + Assertions.assertThat(secondOrder.variant()).contains(beta); + } + + private RequestContext context() { + return new RequestContext(); + } + + private ContentNode node(String path) { + return new ContentNode(path, "/" + path, path, Map.of()); + } + + private Variant scheduledVariant( + String id, + Instant publishDate, + Instant unpublishDate, + String status + ) { + Map data = new HashMap<>(); + data.put(Constants.MetaFields.STATUS, status); + data.put(Constants.MetaFields.PUBLISH_DATE, Date.from(publishDate)); + data.put(Constants.MetaFields.UNPUBLISH_DATE, Date.from(unpublishDate)); + return new Variant( + id, + new ContentNode( + ".variants/about/" + id + "/about.md", + "/.variants/about/" + id + "/about", + "about.md", + data + ) + ); + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java b/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java new file mode 100644 index 000000000..d4be8206b --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java @@ -0,0 +1,74 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VariantResolverTest { + + @Test + void resolvesCanonicalPageAndActiveVariantFromVariantPath() { + var db = mock(DB.class); + var content = mock(Content.class); + var fileSystem = mock(DBFileSystem.class); + var contentBase = mock(ReadOnlyFile.class); + var canonicalFile = mock(ReadOnlyFile.class); + var canonicalFolder = mock(ReadOnlyFile.class); + var variantsFolder = mock(ReadOnlyFile.class); + var canonical = node("news/about.md", "/news/about", "about.md"); + var variant = node( + "news/.variants/about/summer/about.md", + "/news/.variants/about/summer/about", + "about.md" + ); + + when(db.getContent()).thenReturn(content); + when(db.getFileSystem()).thenReturn(fileSystem); + when(fileSystem.contentBase()).thenReturn(contentBase); + when(content.byPath("news/about.md")).thenReturn(Optional.of(canonical)); + when(contentBase.resolve("news/about.md")).thenReturn(canonicalFile); + when(canonicalFile.getParent()).thenReturn(canonicalFolder); + when(canonicalFolder.resolve(".variants/about")).thenReturn(variantsFolder); + when(variantsFolder.exists()).thenReturn(false); + + var context = new VariantResolver(db).resolveContext(variant); + + assertThat(context.canonical()).isEqualTo(canonical); + assertThat(context.activeVariantId()).contains("summer"); + assertThat(context.variants()).isEmpty(); + } + + private ContentNode node(String path, String url, String name) { + return new ContentNode(path, url, name, Map.of("title", name)); + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java b/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java new file mode 100644 index 000000000..7efabd686 --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java @@ -0,0 +1,79 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VariantSelectorConfigurationRepositoryTest { + + @TempDir + Path contentBase; + + private final ContentNode canonical = new ContentNode( + "news/about.md", "/news/about", "about.md", Map.of() + ); + private VariantSelectorConfigurationRepository repository; + + @BeforeEach + void setUp() { + var db = mock(DB.class); + var fileSystem = mock(DBFileSystem.class); + var resolver = mock(VariantResolver.class); + when(db.getFileSystem()).thenReturn(fileSystem); + when(fileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(contentBase); + when(resolver.resolveContext(canonical)).thenReturn( + new VariantResolver.VariantContext(canonical, Optional.empty(), List.of()) + ); + repository = new VariantSelectorConfigurationRepository(db, resolver); + } + + @Test + void missingConfigurationUsesDateRangeDefault() { + assertThat(repository.getSelectorId(canonical)) + .isEqualTo(VariantSelectorConfigurationRepository.DEFAULT_SELECTOR_ID); + } + + @Test + void storesOneConfigurationAtCanonicalVariantFolder() throws Exception { + repository.setSelectorId(canonical, "audience"); + + var file = contentBase.resolve("news/.variants/about/variants.yaml"); + assertThat(repository.configurationFile(canonical)).isEqualTo(file); + assertThat(Files.readString(file)).contains("selector: audience"); + assertThat(repository.getSelectorId(canonical)).isEqualTo("audience"); + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java index b3206aece..2d81e0134 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java @@ -24,6 +24,7 @@ import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.utils.PathUtil; import java.util.List; @@ -102,10 +103,10 @@ public Optional> getMeta(String uri) { } @Override - public List searchByTitle (String input) { - var titleQuery = fileSystem.getMetaData().searchByTitle(input); + public List searchByTitle (String input, VariantSearchMode variantSearchMode) { + var titleQuery = fileSystem.getMetaData().searchByTitle(input); - return titleQuery.list(); + return titleQuery.list(variantSearchMode); } } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java index 7173b102b..f381df82e 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java @@ -312,7 +312,8 @@ void handleContentEvent(FileEvent event) { } } - void flushContentChanges() { + @Override + public void flushContentChanges() { contentChangeCoordinator.flushNow(); } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java index 905106f5d..91df27df1 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java @@ -24,6 +24,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.filesystem.metadata.persistent.TitleQuery; import java.io.IOException; import java.time.LocalDate; @@ -64,7 +65,7 @@ public interface MetaData { List listSectionEntries(String pagePath); - TitleQuery searchByTitle(String path); + TitleQuery searchByTitle(String input); void clear (); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java index 2d337c071..0bb96beef 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java @@ -158,7 +158,7 @@ private List queryContentNodes() { var contentNodes = result.stream() .map(document -> document.get("_uri")) - .map(metaData::byUri) + .map(metaData::byPath) .filter(Optional::isPresent) .map(Optional::get) .filter(node -> !node.isDirectory()) diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java index 54c8f5b30..140780a1a 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java @@ -25,6 +25,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.NodeVisibility; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.filesystem.metadata.AbstractMetaData; import com.condation.cms.filesystem.metadata.query.ExcerptMapperFunction; @@ -153,6 +154,11 @@ public synchronized void addFile(String uri, Map data, LocalDate Document document = new Document(); document.add(new StringField("_uri", uri, Field.Store.YES)); document.add(new StringField("_url", node.url(), Field.Store.YES)); + document.add(new StringField("_variant", Boolean.toString(node.isVariant()), Field.Store.YES)); + node.variantId().ifPresent(variantId -> + document.add(new StringField("_variant_id", variantId, Field.Store.YES))); + node.originalUri().ifPresent(originalUri -> + document.add(new StringField("_variant_original", originalUri, Field.Store.YES))); //document.add(new StringField("_source", GSON.toJson(node), Field.Store.NO)); DocumentHelper.addData(document, data); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java index 3b0cffc06..ebfd15724 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java @@ -23,6 +23,7 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.filesystem.MetaData; import com.condation.cms.filesystem.metadata.PageMetaData; import java.io.IOException; @@ -36,7 +37,6 @@ import org.apache.lucene.queryparser.flexible.core.QueryNodeException; import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; -import org.apache.lucene.search.Query; import org.apache.lucene.search.TermQuery; /** @@ -54,21 +54,34 @@ public class TitleQuery { private String contentType = Constants.DEFAULT_CONTENT_TYPE; + public List list(VariantSearchMode variantSearchMode) { + return queryContentNodes(variantSearchMode); + } + public List list() { - return queryContentNodes(); + return queryContentNodes(VariantSearchMode.ALL); } - private List queryContentNodes() { + private List queryContentNodes(VariantSearchMode variantSearchMode) { try { BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); queryBuilder.add(new TermQuery(new Term("content.type", contentType)), BooleanClause.Occur.MUST); queryBuilder.add(titleQueryFactory.createQuery(input), BooleanClause.Occur.MUST); + if (variantSearchMode != VariantSearchMode.ALL) { + queryBuilder.add( + new TermQuery(new Term( + "_variant", + Boolean.toString(variantSearchMode == VariantSearchMode.VARIANT) + )), + BooleanClause.Occur.MUST + ); + } List result = index.query(queryBuilder.build()); var contentNodes = result.stream() .map(document -> document.get("_uri")) - .map(metaData::byUri) + .map(metaData::byPath) .filter(Optional::isPresent) .map(Optional::get) .filter(node -> !node.isDirectory()) diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java index df6cd77e1..521cc90b2 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java @@ -23,14 +23,21 @@ import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.utils.FileUtils; import com.condation.cms.filesystem.metadata.query.ExtendableQuery; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.function.Supplier; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterAll; @@ -62,6 +69,15 @@ static void setup() throws IOException { } }); fileSystem.init(); + fileSystem.getMetaData().addFile( + "test/.variants/test1/summer/test1.md", + Map.of( + "name", "test1-summer", + "title", "Superman Summer", + "status", "published" + ), + LocalDate.now() + ); content = new FileContent(fileSystem); } @@ -283,6 +299,52 @@ public void test_searchByTitle_blankInputMatchesAllPages() { Assertions.assertThat(nodes).hasSize(3); } + @Test + void test_searchByTitle_canSearchOnlyOriginals() { + List nodes = content.searchByTitle("Superman", VariantSearchMode.ORIGINAL); + + Assertions.assertThat(nodes) + .singleElement() + .satisfies(node -> { + Assertions.assertThat(node.isVariant()).isFalse(); + Assertions.assertThat(node.data().get("name")).isEqualTo("test1"); + }); + } + + @Test + void test_searchByTitle_canSearchOnlyVariants() { + List nodes = inManagerContext(() -> + content.searchByTitle("Superman", VariantSearchMode.VARIANT)); + + Assertions.assertThat(nodes) + .singleElement() + .satisfies(node -> { + Assertions.assertThat(node.isVariant()).isTrue(); + Assertions.assertThat(node.variantId()).contains("summer"); + Assertions.assertThat(node.originalUri()).contains("test/test1.md"); + }); + } + + @Test + void test_searchByTitle_canSearchOriginalsAndVariants() { + List nodes = inManagerContext(() -> + content.searchByTitle("Superman", VariantSearchMode.ALL)); + + Assertions.assertThat(nodes).hasSize(2); + } + + private List inManagerContext( + Supplier> search + ) { + var requestContext = new RequestContext(); + requestContext.add( + IsPreviewFeature.class, + new IsPreviewFeature(IsPreviewFeature.Mode.MANAGER) + ); + return ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext) + .call(search::get); + } + @Test public void test_byUrl() throws IOException { var node = content.byUrl("/"); diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java index f43d3752e..6bea7212a 100644 --- a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java +++ b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java @@ -61,7 +61,12 @@ import com.condation.cms.content.ContentResolver; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.DefaultContentRenderer; +import com.condation.cms.content.DefaultVariantSelector; +import com.condation.cms.content.ConfigurableVariantSelector; import com.condation.cms.content.TaxonomyResolver; +import com.condation.cms.content.VariantResolver; +import com.condation.cms.api.variants.VariantSelector; +import com.condation.cms.content.VariantSelectorConfigurationRepository; import com.condation.cms.content.ViewResolver; import com.condation.cms.content.shortcodes.ShortCodeParser; import com.condation.cms.content.template.functions.taxonomy.TaxonomyFunction; @@ -350,8 +355,42 @@ public ContentRenderer contentRenderer(ContentParser contentParser, Injector inj @Provides @Singleton public ContentResolver contentResolver(ContentRenderer contentRenderer, - FileDB db) { - return new ContentResolver(contentRenderer, db); + FileDB db, VariantResolver variantResolver, VariantSelector variantSelector) { + return new ContentResolver(contentRenderer, db, variantResolver, variantSelector); + } + + @Provides + @Singleton + public VariantResolver variantResolver(FileDB db) { + return new VariantResolver(db); + } + + @Provides + @Singleton + public VariantSelectorConfigurationRepository variantSelectorConfigurationRepository( + FileDB db, + VariantResolver variantResolver + ) { + return new VariantSelectorConfigurationRepository(db, variantResolver); + } + + @Provides + @Singleton + public ConfigurableVariantSelector configurableVariantSelector( + VariantSelectorConfigurationRepository configurationRepository, + ModuleManager moduleManager + ) { + return new ConfigurableVariantSelector( + new DefaultVariantSelector(), + configurationRepository, + moduleManager + ); + } + + @Provides + @Singleton + public VariantSelector variantSelector(ConfigurableVariantSelector selector) { + return selector; } @Provides diff --git a/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java b/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java index c87021cf1..9c931df07 100644 --- a/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java +++ b/cms-server/src/main/java/com/condation/cms/server/filter/PooledRequestContextFilter.java @@ -23,6 +23,7 @@ import com.condation.cms.api.PerformanceProperties; import com.condation.cms.api.annotations.Experimental; import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; @@ -92,6 +93,7 @@ public boolean handle(Request httpRequest, Response rspns, Callback clbck) throw } finally { requestContext.features.remove(RequestFeature.class); requestContext.features.remove(IsPreviewFeature.class); + requestContext.features.remove(CurrentNodeFeature.class); requestContextPoolable.release(); } } diff --git a/integration-tests/hosts/test/content/.variants/test/summer/test.md b/integration-tests/hosts/test/content/.variants/test/summer/test.md new file mode 100644 index 000000000..3b7382231 --- /dev/null +++ b/integration-tests/hosts/test/content/.variants/test/summer/test.md @@ -0,0 +1,7 @@ +--- +title: Summer variant +template: test.ftl +status: published +--- + +Summer variant content diff --git a/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java b/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java index 6cc399a20..611c7414f 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/ContentResolverTest.java @@ -32,6 +32,8 @@ import com.condation.cms.api.content.DefaultContentResponse; import com.condation.cms.api.content.RedirectContentResponse; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.template.TemplateEngine; import static com.condation.cms.content.ContentRendererNGTest.contentRenderer; @@ -147,4 +149,23 @@ public void test_not_published() throws IOException { Assertions.assertThat(optional).isEmpty(); } + @Test + public void previewVariantOverride() throws IOException { + var context = TestHelper.requestContext("test"); + context.add( + RequestFeature.class, + new RequestFeature("test", Map.of("variant", java.util.List.of("summer"))) + ); + context.add(IsPreviewFeature.class, new IsPreviewFeature()); + + var optional = contentResolver.getContent(context); + + Assertions.assertThat(optional).isPresent(); + Assertions.assertThat(optional.get()).isInstanceOf(DefaultContentResponse.class); + var response = (DefaultContentResponse) optional.get(); + Assertions.assertThat(response.content()).contains("Summer variant content"); + Assertions.assertThat(response.node().path()) + .isEqualTo(".variants/test/summer/test.md"); + } + } diff --git a/integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java b/integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java new file mode 100644 index 000000000..0ab6062ea --- /dev/null +++ b/integration-tests/src/test/java/com/condation/cms/e2e/VariantTest.java @@ -0,0 +1,57 @@ +package com.condation.cms.e2e; + +/*- + * #%L + * integration-tests + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import com.condation.cms.test.e2e.CMSServerExtension; +import com.condation.cms.cli.tools.CLIServerUtils; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.junit.UsePlaywright; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * + * @author thmar + */ +@UsePlaywright +public class VariantTest { + + @RegisterExtension + static CMSServerExtension serverExtensions = new CMSServerExtension(); + + @Test + void server_is_started() throws Exception { + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isPresent(); + } + + @Test + void variant_is_selectable_in_preview(Page page) { + page.navigate("http://localhost:2020?preview=preview&variant=summer"); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("Summer time"); + } + + @Test + void variant_is_not_selectable_without_preview(Page page) { + page.navigate("http://localhost:2020?variant=summer"); + Assertions.assertThat(page.locator("title").innerText()).isEqualTo("Startpage"); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java index 66d476913..ea668206c 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuHookExtension.java @@ -22,7 +22,6 @@ */ import com.condation.cms.api.annotations.Action; import com.condation.cms.api.annotations.Filter; -import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.extensions.HookSystemRegisterExtensionPoint; import com.condation.cms.api.ui.action.UIHookAction; import com.condation.cms.api.ui.action.UIScriptAction; diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java index f3b94e3bf..2f3b59062 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java @@ -116,6 +116,36 @@ public void manage_media() { scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/translations") ) public void manage_translations() {} + + @ShortCut( + id = "page-variants", + title = "Page variants", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-6", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variants") + ) + public void page_variants() {} + + @ShortCut( + id = "page-variant-create", + title = "Create page variant", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-7", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-variant") + ) + public void create_page_variant() {} + + @ShortCut( + id = "page-variant-selector", + title = "Configure variant selection", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-8", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variant-selector") + ) + public void configure_variant_selector() {} @Override @@ -126,6 +156,9 @@ public Map> getLocalizations() { "page-create", "Neue Seite erstellen", "page-edit-content", "Inhalt bearbeiten", "page-edit-meta", "Metadaten bearbeiten", + "page-variants", "Seitenvarianten", + "page-variant-create", "Seitenvariante erstellen", + "page-variant-selector", "Variantenauswahl konfigurieren", "language.de", "Deutsch", "language.en", "Englisch" ), @@ -134,6 +167,9 @@ public Map> getLocalizations() { "page-create", "Create new page", "page-edit-content", "Edit content", "page-edit-meta", "Edit metadata", + "page-variants", "Page variants", + "page-variant-create", "Create page variant", + "page-variant-selector", "Configure variant selection", "language.de", "German", "language.en", "English" ) diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java index a2a5143fc..4b53a4e7d 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtension.java @@ -32,7 +32,6 @@ import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.SiteDBService; -import com.condation.cms.filesystem.FileSystem; import com.condation.cms.modules.ui.utils.UIHooks; import java.nio.file.Path; import java.util.Map; diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java index a38f3c08d..d17e85947 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java @@ -28,6 +28,7 @@ import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.extensions.AbstractExtensionPoint; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; @@ -47,6 +48,8 @@ import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.utils.SectionUtil; import com.condation.cms.content.SectionEntry; +import com.condation.cms.content.ConfigurableVariantSelector; +import com.condation.cms.content.VariantResolver; import com.condation.cms.modules.ui.utils.FormHelper; import com.condation.cms.modules.ui.utils.MarkdownHelper; import com.condation.cms.modules.ui.utils.MetaConverter; @@ -69,7 +72,7 @@ public Object getContent(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().contentBase(); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var contentFile = contentBase.resolve(uri); @@ -95,7 +98,7 @@ public Object setContent(Map parameters) throws RPCException { var contentBase = db.getFileSystem().contentBase(); var updatedContent = FormHelper.getContent(parameters.get("content")); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var contentFile = contentBase.resolve(uri); @@ -128,7 +131,7 @@ public Object replaceContent(Map parameters) throws RPCException var replacement = (String)parameters.get("content"); int start = NumberUtils.toInt(parameters.getOrDefault("start", -1l)); int end = NumberUtils.toInt(parameters.getOrDefault("end", -1l)); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); Map result = new HashMap<>(); result.put("uri", uri); @@ -169,7 +172,7 @@ public Object setMeta(Map parameters) throws RPCException { var updateParam = (Map>) parameters.get("meta"); var update = MetaConverter.convertMeta(updateParam); - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var contentFile = contentBase.resolve(uri); @@ -279,7 +282,7 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); var content = (String) parameters.getOrDefault("content", ""); - var parentUri = (String) parameters.get("parentUri"); + var parentUri = contentUri(parameters, "parentUri"); var section = (String) parameters.get("section"); var sectionEntryName = (String) parameters.get("sectionEntryName"); var template = (String) parameters.get("template"); @@ -355,7 +358,31 @@ public Object getContentNode (Map parameters) { Map result = new HashMap<>(); result.put("url", url); if (contentFile != null) { - result.put("uri", PathUtil.toRelativeFile(contentFile, contentBase)); + var canonicalUri = PathUtil.toRelativeFile(contentFile, contentBase); + var selectedNode = db.getContent().byUri(canonicalUri).orElse(null); + var query = com.condation.cms.api.utils.HTTPUtil.queryParameters(URI.create(url).getQuery()); + var variantId = query.getOrDefault( + ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER, + List.of() + ).stream().findFirst().orElse("").trim(); + String activeVariantId = null; + if (selectedNode != null + && !variantId.isBlank() + && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { + var selectedVariant = new VariantResolver(db).loadVariant(selectedNode, variantId); + if (selectedVariant.isPresent()) { + selectedNode = selectedVariant.get().node(); + activeVariantId = selectedVariant.get().id(); + } + } + if (selectedNode != null) { + contentFile = contentBase.resolve(selectedNode.uri()); + result.put("uri", selectedNode.uri()); + } else { + result.put("uri", canonicalUri); + } + result.put("canonicalUri", canonicalUri); + result.put("variantId", activeVariantId); var sectionEntries = db.getContent().listSectionEntries(contentFile); Map> sectionMap = new HashMap<>(); @@ -372,4 +399,19 @@ public Object getContentNode (Map parameters) { return result; } + + private String contentUri(Map parameters) throws RPCException { + return contentUri(parameters, "uri"); + } + + private String contentUri(Map parameters, String parameterName) throws RPCException { + var value = parameters.get(parameterName); + if (value instanceof String uri && !uri.isBlank()) { + return uri; + } + if (getRequestContext().has(CurrentNodeFeature.class)) { + return getRequestContext().get(CurrentNodeFeature.class).node().uri(); + } + throw new RPCException(400, parameterName + " must not be blank"); + } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java index 4d2bea285..d033e0aa6 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java @@ -21,8 +21,11 @@ * #L% */ import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.Constants; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.FileUtils; import com.condation.cms.api.utils.SectionUtil; @@ -38,6 +41,8 @@ import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.modules.ui.utils.UIPathUtil; +import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.core.content.io.YamlHeaderUpdater; import java.nio.file.Path; /** @@ -76,7 +81,8 @@ public Object list(Map parameters) throws RPCException { } contentFile.children().stream() .filter(child -> !SectionUtil.isSectionEntry(child.getFileName())) - .map(this::map) + .filter(child -> !".variants".equals(child.getFileName())) + .map(child -> map(db, type, child)) .forEach(files::add); } catch (IOException ex) { log.error("", ex); @@ -89,7 +95,7 @@ public Object list(Map parameters) throws RPCException { } else if (!f1.directory() && f2.directory()) { return 1; } else { - return f1.name().compareToIgnoreCase(f2.name()); + return f1.displayName().compareToIgnoreCase(f2.displayName()); } }); @@ -165,31 +171,37 @@ public Object renameFile(Map parameters) throws RPCException { var sourcePath = writableBase.resolve(uri).resolve(name); var targetPath = writableBase.resolve(uri).resolve(newName); - log.debug("renaming from {} to {}", sourcePath, targetPath); - if (!Files.exists(sourcePath)) { throw new RPCException("Source file not found: " + sourcePath); } + if (!"assets".equals(type) && name.endsWith(".md") && !Files.isDirectory(sourcePath)) { + var parser = new ContentFileParser(sourcePath.toString()); + var metadata = parser.getHeader() == null + ? new HashMap() + : new HashMap<>(parser.getHeader()); + metadata.put(Constants.MetaFields.TITLE, newName.trim()); + YamlHeaderUpdater.saveMarkdownFileWithHeader( + sourcePath, + metadata, + parser.getContent() + ); + getContext().get(EventBusFeature.class).eventBus().syncPublish( + new ReIndexContentMetaDataEvent(PathUtil.toRelativeFile(sourcePath, writableBase)) + ); + db.getFileSystem().flushContentChanges(); + result.put("success", true); + result.put("newName", name); + result.put("title", newName.trim()); + return result; + } + + log.debug("renaming from {} to {}", sourcePath, targetPath); if (Files.exists(targetPath)) { throw new RPCException("Target file already exists: " + targetPath); } Files.move(sourcePath, targetPath); - if (!"assets".equals(type) && !Files.isDirectory(targetPath)) { - var contentFile = contentBase.resolve(uri).resolve(name); - var sections = db.getContent().listSectionEntries(contentFile); - - for (var node : sections) { - var sourceSectionPath = writableBase.resolve(node.uri()); - var targetSectionPath = writableBase.resolve(node.uri().replace(name, newName)); - if (Files.exists(sourceSectionPath)) { - log.debug("renaming section {} to {}", sourceSectionPath, targetSectionPath); - Files.move(sourceSectionPath, targetSectionPath); - } - } - } - result.put("success", true); result.put("newName", newName); @@ -269,12 +281,13 @@ private boolean isMedia (String filename) { return name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".webp") + || name.endsWith(".avif") || name.endsWith(".png") || name.endsWith(".svg") || name.endsWith(".gif"); } - private File map (ReadOnlyFile readOnlyFile) { + private File map (DB db, String type, ReadOnlyFile readOnlyFile) { if (readOnlyFile.isDirectory()) { return new Directory( readOnlyFile.getFileName(), @@ -286,14 +299,27 @@ private File map (ReadOnlyFile readOnlyFile) { readOnlyFile.uri() ); } else { + var title = "assets".equals(type) + ? readOnlyFile.getFileName() + : db.getContent().byPath(readOnlyFile.relativePath()) + .map(node -> node.data().get(Constants.MetaFields.TITLE)) + .filter(String.class::isInstance) + .map(String.class::cast) + .filter(value -> !value.isBlank()) + .orElse(readOnlyFile.getFileName()); return new Content( readOnlyFile.getFileName(), - readOnlyFile.uri() + readOnlyFile.uri(), + title ); } } - public record Content(String name, String uri) implements File { + public record Content(String name, String uri, String title) implements File { + @Override + public String displayName() { + return title; + } } public record Media(String name, String uri) implements File { @@ -313,6 +339,9 @@ public boolean directory() { public static interface File { String name (); String uri (); + default String displayName () { + return name(); + } default boolean directory () { return false; } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java index 3aa35b73b..31efe9c08 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpoints.java @@ -24,6 +24,7 @@ import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.EventBusFeature; @@ -72,7 +73,7 @@ public Object searchPages (Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().contentBase(); - var hits = db.getContent().searchByTitle(query).stream() + var hits = db.getContent().searchByTitle(query, VariantSearchMode.ORIGINAL).stream() .map(node -> { var contentFile = contentBase.resolve(node.uri()); var url = PathUtil.toURL(contentFile, contentBase); diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java new file mode 100644 index 000000000..b05d95bae --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpoint.java @@ -0,0 +1,352 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.feature.features.EventBusFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.ui.annotations.RemoteMethod; +import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.content.VariantResolver; +import com.condation.cms.content.ConfigurableVariantSelector; +import com.condation.cms.content.VariantSelectorConfigurationRepository; +import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.core.content.io.YamlHeaderUpdater; +import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; +import com.condation.cms.modules.ui.utils.UIPathUtil; +import com.condation.modules.api.annotation.Extension; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Remote methods for loading the variants of a content node. + * + * @author thorstenmarx + */ +@Extension(UIRemoteMethodExtensionPoint.class) +@Slf4j +public class RemoteVariantEndpoint extends AbstractRemoteMethodeExtension { + + @RemoteMethod(name = "variants.selectors.get", permissions = {Permissions.CONTENT_EDIT}) + public Object getSelectors(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + if (uri.isBlank()) { + throw new RPCException(400, "uri must not be blank"); + } + + var db = getDB(parameters); + var node = findContentNode(db, uri); + var configurableSelector = getConfigurableVariantSelector(); + var repository = getVariantSelectorConfigurationRepository(); + + return Map.of( + "selector", repository.getSelectorId(node), + "selectors", configurableSelector.availableSelectors().values() + .stream() + .sorted(Comparator.comparing( + ConfigurableVariantSelector.SelectorDescriptor::label + )) + .toList() + ); + } + + @RemoteMethod(name = "variants.selector.set", permissions = {Permissions.CONTENT_EDIT}) + public Object setSelector(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + var selectorId = stringParameter(parameters, "selector"); + if (uri.isBlank() || selectorId.isBlank()) { + throw new RPCException(400, "uri and selector must not be blank"); + } + + var configurableSelector = getConfigurableVariantSelector(); + if (!configurableSelector.hasSelector(selectorId)) { + throw new RPCException(400, "unknown variant selector"); + } + + var db = getDB(parameters); + var node = findContentNode(db, uri); + try { + getVariantSelectorConfigurationRepository().setSelectorId(node, selectorId); + return Map.of("selector", selectorId); + } catch (Exception exception) { + log.error("Could not save variant selector for '{}'", node.path(), exception); + throw new RPCException(500, exception.getMessage()); + } + } + + @RemoteMethod(name = "variants.create", permissions = {Permissions.CONTENT_EDIT}) + public Object create(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + var id = stringParameter(parameters, "id"); + var title = stringParameter(parameters, "title"); + var template = stringParameter(parameters, "template"); + var copyContent = Boolean.TRUE.equals(parameters.get("copyContent")); + + if (uri.isBlank() || id.isBlank() || title.isBlank()) { + throw new RPCException(400, "uri, id and title must not be blank"); + } + + var db = getDB(parameters); + var requestedNode = findContentNode(db, uri); + var canonicalNode = getVariantResolver(db).resolveContext(requestedNode).canonical(); + var selectedTemplate = copyContent + ? canonicalNode.getMetaValue(Constants.MetaFields.TEMPLATE, "") + : template; + if (selectedTemplate.isBlank()) { + throw new RPCException(400, "template must not be blank"); + } + if (uiHooks().contentTypes().getPageTemplates().stream() + .noneMatch(pageTemplate -> pageTemplate.template().equals(selectedTemplate))) { + throw new RPCException(400, "unknown page template"); + } + var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var canonicalFile = contentBase.resolve(canonicalNode.path()); + var variantId = UIPathUtil.toValidFilename(id); + if (variantId.isBlank() || ".".equals(variantId) || "..".equals(variantId)) { + throw new RPCException(400, "invalid variant id"); + } + + var fileName = canonicalFile.getFileName().toString(); + var pageName = fileName.endsWith(".md") + ? fileName.substring(0, fileName.length() - 3) + : fileName; + var variantFile = canonicalFile.getParent() + .resolve(".variants") + .resolve(pageName) + .resolve(variantId) + .resolve(fileName); + + try { + if (!UIPathUtil.isChild(contentBase, variantFile)) { + throw new RPCException(400, "invalid variant path"); + } + if (Files.exists(variantFile)) { + throw new RPCException(409, "variant already exists"); + } + + var body = copyContent ? new ContentFileParser(canonicalFile.toString()).getContent() : ""; + var sectionCopies = copyContent + ? db.getContent().listSectionEntries(db.getFileSystem().contentBase().resolve(canonicalNode.path())) + .stream() + .map(section -> new SectionCopy( + contentBase.resolve(section.path()), + variantFile.getParent().resolve(section.name()) + )) + .toList() + : java.util.List.of(); + if (sectionCopies.stream().anyMatch(copy -> Files.exists(copy.target()))) { + throw new RPCException(409, "variant section already exists"); + } + + Map meta = new HashMap<>(); + meta.put(Constants.MetaFields.TITLE, title); + meta.put(Constants.MetaFields.TEMPLATE, selectedTemplate); + meta.put(Constants.MetaFields.STATUS, getContext().get(WorkflowFeature.class) + .workflow().getStatusProvider().newNodeStatus()); + meta.put("createdAt", Date.from(Instant.now())); + meta.put("createdBy", getUserName()); + + Files.createDirectories(variantFile.getParent()); + var createdFiles = new ArrayList(); + try { + YamlHeaderUpdater.saveMarkdownFileWithHeader(variantFile, meta, body); + createdFiles.add(variantFile); + for (var sectionCopy : sectionCopies) { + Files.copy( + sectionCopy.source(), + sectionCopy.target(), + StandardCopyOption.COPY_ATTRIBUTES + ); + createdFiles.add(sectionCopy.target()); + } + } catch (Exception exception) { + for (var createdFile : createdFiles.reversed()) { + Files.deleteIfExists(createdFile); + } + throw exception; + } + + var eventBus = getContext().get(EventBusFeature.class).eventBus(); + for (var createdFile : createdFiles) { + eventBus.syncPublish(new ReIndexContentMetaDataEvent( + PathUtil.toRelativeFile(createdFile, contentBase) + )); + } + db.getFileSystem().flushContentChanges(); + var newUri = PathUtil.toRelativeFile(variantFile, contentBase); + + return Map.of( + "id", variantId, + "uri", newUri, + "url", managerVariantPreviewUrl(canonicalNode.url(), variantId) + ); + } catch (RPCException exception) { + throw exception; + } catch (Exception exception) { + log.error("Could not create variant '{}' for '{}'", variantId, canonicalNode.path(), exception); + throw new RPCException(500, exception.getMessage()); + } + } + + @RemoteMethod(name = "variants.delete", permissions = {Permissions.CONTENT_EDIT}) + public Object delete(Map parameters) throws RPCException { + var uri = stringParameter(parameters, "uri"); + var variantId = stringParameter(parameters, "id"); + if (uri.isBlank() || variantId.isBlank()) { + throw new RPCException(400, "uri and id must not be blank"); + } + + var db = getDB(parameters); + var requestedNode = findContentNode(db, uri); + var variantContext = getVariantResolver(db).resolveContext(requestedNode); + var variant = variantContext.variants().stream() + .filter(candidate -> candidate.id().equals(variantId)) + .findFirst() + .orElseThrow(() -> new RPCException(404, "variant not found")); + var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var variantFolder = contentBase.resolve(variant.node().path()).getParent(); + + try { + if (!UIPathUtil.isChild(contentBase, variantFolder) + || !Files.exists(variantFolder) + || !Files.isDirectory(variantFolder)) { + throw new RPCException(404, "variant folder not found"); + } + var folderUri = PathUtil.toRelativeFile(variantFolder, contentBase); + try (var paths = Files.walk(variantFolder)) { + for (var path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + getContext().get(EventBusFeature.class).eventBus().syncPublish( + new ReIndexContentMetaDataEvent(folderUri) + ); + db.getFileSystem().flushContentChanges(); + + return Map.of( + "id", variantId, + "url", managerPreviewUrl(variantContext.canonical().url()) + ); + } catch (RPCException exception) { + throw exception; + } catch (Exception exception) { + log.error("Could not delete variant '{}' for '{}'", variantId, variantContext.canonical().path(), exception); + throw new RPCException(500, exception.getMessage()); + } + } + + @RemoteMethod(name = "variants.get", permissions = {Permissions.CONTENT_EDIT}) + public Object get(Map parameters) throws RPCException { + var uri = (String) parameters.getOrDefault("uri", ""); + if (uri.isBlank()) { + throw new RPCException(400, "uri must not be blank"); + } + + var db = getDB(parameters); + var contentNode = findContentNode(db, uri); + var variantContext = getVariantResolver(db).resolveContext(contentNode); + var variants = variantContext.variants() + .stream() + .sorted(Comparator.comparing(Variant::id)) + .map(variant -> new VariantDto( + variant.id(), + variant.node().uri(), + managerVariantPreviewUrl(variantContext.canonical().url(), variant.id()), + variant.node().data() + )) + .toList(); + + Map result = new LinkedHashMap<>(); + result.put("uri", variantContext.canonical().uri()); + result.put("canonical", Map.of( + "uri", variantContext.canonical().uri(), + "url", managerPreviewUrl(variantContext.canonical().url()), + "title", variantContext.canonical() + .getMetaValue(Constants.MetaFields.TITLE, variantContext.canonical().name()), + "template", variantContext.canonical() + .getMetaValue(Constants.MetaFields.TEMPLATE, "") + )); + result.put("activeVariantId", variantContext.activeVariantId().orElse(null)); + result.put("variants", variants); + return result; + } + + private ContentNode findContentNode(DB db, String uri) throws RPCException { + return db.getContent() + .byPath(uri) + .or(() -> db.getContent().byUrl(uri)) + .orElseThrow(() -> new RPCException( + 404, + "content node for uri %s not found".formatted(uri) + )); + } + + protected VariantResolver getVariantResolver(DB db) { + return getContext().get(InjectorFeature.class).injector().getInstance(VariantResolver.class); + } + + protected ConfigurableVariantSelector getConfigurableVariantSelector() { + return getContext().get(InjectorFeature.class) + .injector().getInstance(ConfigurableVariantSelector.class); + } + + protected VariantSelectorConfigurationRepository getVariantSelectorConfigurationRepository() { + return getContext().get(InjectorFeature.class) + .injector().getInstance(VariantSelectorConfigurationRepository.class); + } + + private String stringParameter(Map parameters, String name) { + var value = parameters.get(name); + return value instanceof String stringValue ? stringValue.trim() : ""; + } + + private String managerPreviewUrl(String url) { + return url + (url.contains("?") ? "&" : "?") + "preview=manager"; + } + + private String managerVariantPreviewUrl(String canonicalUrl, String variantId) { + return managerPreviewUrl(canonicalUrl) + + "&" + + ConfigurableVariantSelector.VARIANT_QUERY_PARAMETER + + "=" + + java.net.URLEncoder.encode(variantId, java.nio.charset.StandardCharsets.UTF_8); + } + + private record SectionCopy(java.nio.file.Path source, java.nio.file.Path target) { + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java index 6a282202a..506bd86f0 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtension.java @@ -27,6 +27,7 @@ import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.extensions.AbstractExtensionPoint; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.ui.rpc.RPCException; @@ -62,7 +63,7 @@ private Optional getContentNode(String uri) { var node_uri = PathUtil.toRelativeFile(contentFile, contentBase); - var node = db.getContent().byUri(node_uri); + var node = db.getContent().byPath(node_uri); if (node.isEmpty()) { return Optional.empty(); } @@ -88,7 +89,7 @@ private ReadOnlyFile getContentFile(String uri) { @RemoteMethod(name = "workflow.manager.node.status", permissions = {Permissions.CONTENT_EDIT}) public Object nodeStatus(Map parameters) throws RPCException { - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); Map result = new HashMap<>(); var contentNodeOpt = getContentNode(uri); @@ -111,7 +112,7 @@ public Object nodeStatus(Map parameters) throws RPCException { @RemoteMethod(name = "workflow.transitions.get", permissions = {Permissions.CONTENT_EDIT}) public Object getTransitions(Map parameters) throws RPCException { - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); Map result = new HashMap<>(); @@ -138,7 +139,7 @@ public Object getTransitions(Map parameters) throws RPCException public Object transit(Map parameters) throws RPCException { var result = new HashMap(); try { - var uri = (String) parameters.get("uri"); + var uri = contentUri(parameters); var transition = (String) parameters.get("transitionId"); final DB db = getContext().get(DBFeature.class).db(); @@ -167,4 +168,15 @@ public Object transit(Map parameters) throws RPCException { return result; } + private String contentUri(Map parameters) throws RPCException { + var value = parameters.get("uri"); + if (value instanceof String uri && !uri.isBlank()) { + return uri; + } + if (getRequestContext().has(CurrentNodeFeature.class)) { + return getRequestContext().get(CurrentNodeFeature.class).node().uri(); + } + throw new RPCException(400, "uri must not be blank"); + } + } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java new file mode 100644 index 000000000..e082c37e5 --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/dto/VariantDto.java @@ -0,0 +1,37 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods.dto; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Map; + +/** + * Variant data exposed to the manager UI. + * + * @author thorstenmarx + */ +public record VariantDto( + String id, + String uri, + String url, + Map meta +) { +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java index 1d1d6748d..c10b212ee 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java @@ -21,6 +21,8 @@ * #L% */ import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; @@ -47,6 +49,8 @@ @Slf4j public class RemoteCallHandler extends JettyHandler { + public static final String CONTENT_URI_HEADER = "X-CMS-Content-Uri"; + private final RemoteMethodService remoteCallService; private final SiteModuleContext moduleContext; private final RequestContext requestContext; @@ -68,6 +72,7 @@ public boolean handle(Request request, Response response, Callback callback) thr if (userOpt.isEmpty()) { rpcResult = new RPCResult(new RPCError("no user present")); } else { + setCurrentContentNode(request); Optional result = remoteCallService.execute(remoteCall.method(), remoteCall.parameters(), userOpt.get()); if (result.isPresent()) { rpcResult = new RPCResult(result.get()); @@ -87,6 +92,23 @@ public boolean handle(Request request, Response response, Callback callback) thr return true; } + private void setCurrentContentNode(Request request) { + var uri = request.getHeaders().get(CONTENT_URI_HEADER); + if (uri == null || uri.isBlank()) { + return; + } + if (!moduleContext.has(DBFeature.class)) { + return; + } + var content = moduleContext.get(DBFeature.class).db().getContent(); + content.byUri(uri.trim()) + .or(() -> content.byPath(uri.trim())) + .ifPresent(node -> requestContext.add( + CurrentNodeFeature.class, + new CurrentNodeFeature(node) + )); + } + private RPCResult buildErrorResult(Exception e, String method) { log.error("error executing endpoint {}", method, e); if (e instanceof RPCException rpcException) { diff --git a/modules/ui-module/src/main/resources/manager/actions/page/add-section.js b/modules/ui-module/src/main/resources/manager/actions/page/add-section.js index 83e799565..86bd5cb59 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/add-section.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/add-section.js @@ -20,15 +20,12 @@ */ import { openModal } from '@cms/modules/modal.js'; import { showToast } from '@cms/modules/toast.js'; -import { addSection, getContentNode } from '@cms/modules/rpc/rpc-content.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { addSection } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import Handlebars from 'https://cdn.jsdelivr.net/npm/handlebars@4.7.8/+esm'; import { i18n } from '@cms/modules/localization.js'; import { getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager.js'; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); var template = Handlebars.compile(`
@@ -51,9 +48,9 @@ export async function runAction(params) { }), fullscreen: false, onCancel: (event) => { }, - validate: () => validate(contentNode, params.section), + validate: () => validate(params.section), onOk: async (event) => { - var result = await createSection(contentNode.result.uri, params.section); + var result = await createSection(params.section); if (result) { showToast({ title: i18n.t("manager.actions.addsection.titles.alert", "Create Entry"), @@ -78,7 +75,7 @@ export async function runAction(params) { const getSectionEntryName = () => { return document.getElementById("cms-section-name").value; }; -const validate = (contentNode, targetSection) => { +const validate = (targetSection) => { const template = document.getElementById("cms-section-template-selection").value; if (template === "000") { showToast({ @@ -114,14 +111,13 @@ function isUriInSection(data, sectionEntryKey, targetUri) { } return sectionArray.some(item => item.uri === targetUri); } -const createSection = async (parentUri, parentSectionName) => { +const createSection = async (parentSectionName) => { const template = document.getElementById("cms-section-template-selection").value; if (template === "000") { return false; } try { await addSection({ - parentUri: parentUri, sectionEntryName: getSectionEntryName(), section: parentSectionName, template: template diff --git a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts new file mode 100644 index 000000000..a83d0b4a2 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: () => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js new file mode 100644 index 000000000..f1c0ece63 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js @@ -0,0 +1,144 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; +import { createVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js'; +import { showToast } from '@cms/modules/toast.js'; +const value = (id) => document.getElementById(id)?.value.trim() ?? ''; +const validate = () => { + const copyContent = document.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; + const missing = [ + ['cms-variant-id', 'Variant ID'], + ['cms-variant-title', 'Title'], + ...(copyContent ? [] : [['cms-variant-template', 'Template']]) + ].find(([id]) => !value(id) || (id === 'cms-variant-template' && value(id) === '__none__')); + if (!missing) { + return true; + } + showToast({ + title: i18n.t('manager.actions.page.variant.create.validation.title', 'Create variant'), + message: i18n.t(`manager.actions.page.variant.create.validation.${missing[0]}`, `${missing[1]} is required.`), + type: 'error', + timeout: 3000 + }); + return false; +}; +export const runAction = async () => { + try { + const [activeContentNode, templatesResponse] = await Promise.all([ + getContentNode({ url: getPreviewUrl() }), + getPageTemplates({}) + ]); + const variantContext = await getVariants({ uri: activeContentNode.result.uri }); + const templates = Array.from(templatesResponse.result ?? []) + .sort((left, right) => String(left.name).localeCompare(String(right.name))); + const currentTemplate = variantContext.canonical.template; + const options = templates.map((template) => { + const selected = template.template === currentTemplate ? ' selected' : ''; + return ``; + }).join(''); + openModal({ + title: i18n.t('manager.actions.page.variant.create.title', 'Create page variant'), + body: ` +
+ + +
+
+ + +
+
+ + +
+
+ Initial content +
+ + +
+
+ + +
+
`, + fullscreen: false, + validate, + onCancel: () => { }, + onShow: (modalElement) => { + const templateSelect = modalElement.querySelector('#cms-variant-template'); + const contentOptions = modalElement.querySelectorAll('input[name="cms-variant-content"]'); + const syncTemplateState = () => { + const copyContent = modalElement.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; + if (copyContent) { + templateSelect.value = currentTemplate; + } + templateSelect.disabled = copyContent; + }; + contentOptions.forEach(option => option.addEventListener('change', syncTemplateState)); + syncTemplateState(); + }, + onOk: async () => { + try { + const copyContent = document.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; + const result = await createVariant({ + uri: variantContext.canonical.uri, + id: value('cms-variant-id'), + title: value('cms-variant-title'), + template: copyContent ? currentTemplate : value('cms-variant-template'), + copyContent + }); + showToast({ + title: i18n.t('manager.actions.page.variant.create.success.title', 'Variant created'), + message: i18n.t('manager.actions.page.variant.create.success.message', 'The page variant was created.'), + type: 'success', + timeout: 3000 + }); + loadPreview(result.url); + } + catch (error) { + showError(error); + } + } + }); + } + catch (error) { + showError(error); + } +}; +const escapeHtml = (input) => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; +const showError = (error) => showToast({ + title: i18n.t('manager.actions.page.variant.create.error.title', 'Could not create variant'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js index b14f04e7b..d06dbe8f7 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-content.js @@ -20,26 +20,15 @@ */ import { openModal } from '@cms/modules/modal.js'; import { createForm } from '@cms/modules/form/forms.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; -import { getContentNode, getContent, setContent } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; +import { getContent, setContent } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { showToast } from '@cms/modules/toast.js'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const nodeContent = await getContent({ - uri: uri - }); + const nodeContent = await getContent(uri ? { uri } : {}); const form = createForm({ fields: [ { @@ -63,7 +52,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setContent({ - uri: uri, + ...(uri && { uri }), content: updateData.content }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js index 5d2aae81e..476a9f22b 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-form.js @@ -20,28 +20,17 @@ */ import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { buildValuesFromFields, getValueByPath } from '@cms/modules/node.js'; -import { getContentNode, getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; +import { getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { openSidebar } from '@cms/modules/sidebar.js'; import { getPageTemplates, getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const getContentResponse = await getContent({ - uri: uri - }); + const getContentResponse = await getContent(uri ? { uri } : {}); var templates = null; if (params.type === "sectionEntry") { templates = (await getSectionEntryTemplates()).result; @@ -75,7 +64,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js index d7285ba9d..a69dc11da 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute-list.js @@ -20,27 +20,16 @@ */ import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { getValueByPath } from '@cms/modules/node.js'; -import { getContentNode, getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; +import { getContent, setMeta } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { openSidebar } from '@cms/modules/sidebar.js'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const getContentResponse = await getContent({ - uri: uri - }); + const getContentResponse = await getContent(uri ? { uri } : {}); let formDefinition = { fields: [], values: {} @@ -65,7 +54,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js index ceb2f2d4b..7f29e2419 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-metaattribute.js @@ -20,27 +20,16 @@ */ import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { getValueByPath } from '@cms/modules/node.js'; -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; import { i18n } from '@cms/modules/localization.js'; import { openSidebar } from '@cms/modules/sidebar.js'; // hook.js export async function runAction(params) { - var uri = null; - if (params.uri) { - uri = params.uri; - } - else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); - uri = contentNode.result.uri; - } + const uri = params.uri || null; try { - const getContentResponse = await getContent({ - uri: uri - }); + const getContentResponse = await getContent(uri ? { uri } : {}); let formDefinition = { fields: [ { @@ -64,7 +53,7 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js index f2a9171fd..1c16a0582 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-page-settings.js @@ -21,8 +21,8 @@ import { openSidebar } from '@cms/modules/sidebar.js'; import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { i18n } from '@cms/modules/localization.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; import { buildValuesFromFields } from '@cms/modules/node.js'; @@ -36,13 +36,8 @@ const DEFAULT_FIELDS = [ } ]; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }); + const getContentResponse = await getContent({}); var pageTemplates = (await getPageTemplates()).result; var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template); var pageSettingsForm = []; @@ -75,7 +70,6 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js b/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js index a8b6e2a0c..3de471ef1 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/edit-schedule.js @@ -21,8 +21,8 @@ import { openModal } from '@cms/modules/modal.js'; import { createForm } from '@cms/modules/form/forms.js'; import { showToast } from '@cms/modules/toast.js'; -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js'; +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; import { i18n } from '@cms/modules/localization.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; import { buildValuesFromFields } from '@cms/modules/node.js'; @@ -39,13 +39,8 @@ const DEFAULT_FIELDS = [ } ]; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }); try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }); + const getContentResponse = await getContent({}); //const previewMetaForm = getMetaForm() const fields = [ ...DEFAULT_FIELDS, @@ -68,7 +63,6 @@ export async function runAction(params) { var updateData = form.getData(); try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }); showToast({ diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts new file mode 100644 index 000000000..a83d0b4a2 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: () => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js new file mode 100644 index 000000000..8205c730d --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js @@ -0,0 +1,80 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { getPreviewUrl } from '@cms/modules/preview.utils.js'; +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { getVariantSelectors, setVariantSelector } from '@cms/modules/rpc/rpc-variant.js'; +import { showToast } from '@cms/modules/toast.js'; +const SELECT_ID = 'cms-variant-selector'; +const escapeHtml = (input) => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; +const option = (selector, selected) => ``; +const showError = (error) => showToast({ + title: i18n.t('manager.actions.page.variant-selector.error.title', 'Could not configure variant selection'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); +export const runAction = async () => { + try { + const contentNode = await getContentNode({ url: getPreviewUrl() }); + const result = await getVariantSelectors(contentNode.result.uri); + openModal({ + title: i18n.t('manager.actions.page.variant-selector.title', 'Configure variant selection'), + body: ` +
+ + +
+ The strategy is applied to public requests. Manager and explicit preview selection remain unchanged. +
+
`, + fullscreen: false, + onCancel: () => { }, + validate: () => Boolean(document.getElementById(SELECT_ID)?.value), + onOk: async () => { + try { + const selector = document.getElementById(SELECT_ID).value; + await setVariantSelector(contentNode.result.uri, selector); + showToast({ + title: i18n.t('manager.actions.page.variant-selector.success.title', 'Variant selection configured'), + message: i18n.t('manager.actions.page.variant-selector.success.message', 'The selection strategy was saved.'), + type: 'success', + timeout: 3000 + }); + } + catch (error) { + showError(error); + } + } + }); + } + catch (error) { + showError(error); + } +}; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts b/modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts new file mode 100644 index 000000000..a83d0b4a2 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: () => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js new file mode 100644 index 000000000..e67bc84f6 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -0,0 +1,165 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { alertConfirm } from '@cms/modules/alerts.js'; +import { openModal } from '@cms/modules/modal.js'; +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; +import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; +import { deleteVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js'; +import { showToast } from '@cms/modules/toast.js'; +const VARIANT_LIST_ID = 'cms-page-variants'; +const variantTitle = (variant) => { + const title = variant.meta?.title; + return typeof title === 'string' && title.trim() ? title : variant.id; +}; +const createVariantLink = (titleText, idText, uriText, url, variantId, active, modal) => { + const link = document.createElement('a'); + link.href = url; + link.className = `list-group-item list-group-item-action${active ? ' active' : ''}`; + link.setAttribute('aria-current', active ? 'true' : 'false'); + const heading = document.createElement('div'); + heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; + const title = document.createElement('strong'); + title.textContent = titleText; + heading.appendChild(title); + const id = document.createElement('span'); + id.className = active ? 'badge text-bg-light' : 'badge text-bg-secondary'; + id.textContent = idText; + heading.appendChild(id); + link.appendChild(heading); + const uri = document.createElement('small'); + uri.className = active ? '' : 'text-body-secondary'; + uri.textContent = uriText; + link.appendChild(uri); + link.addEventListener('click', (event) => { + event.preventDefault(); + modal.hide(); + loadPreview(url, variantId ? { variant: variantId } : {}); + }); + return link; +}; +const createVariantItem = (variant, result, modal, container) => { + const item = document.createElement('div'); + const active = result.activeVariantId === variant.id; + item.className = `list-group-item d-flex align-items-center gap-2 p-0${active ? ' active' : ''}`; + const link = createVariantLink(variantTitle(variant), variant.id, variant.uri, result.canonical.url, variant.id, active, modal); + link.classList.remove('list-group-item', 'active'); + link.classList.add('flex-grow-1', 'border-0', 'rounded-0', 'px-3', 'py-2'); + if (active) { + link.classList.add('text-reset'); + } + item.appendChild(link); + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger flex-shrink-0 me-2'; + deleteButton.title = i18n.t('manager.actions.page.variants.delete', 'Delete variant'); + deleteButton.setAttribute('aria-label', `${deleteButton.title}: ${variant.id}`); + deleteButton.innerHTML = ''; + deleteButton.addEventListener('click', async () => { + const confirmed = await alertConfirm({ + title: i18n.t('manager.actions.page.variants.delete.confirm.title', 'Delete page variant?'), + message: i18n.t('manager.actions.page.variants.delete.confirm.message', `The variant “${escapeHtml(variantTitle(variant))}” and all its sections will be permanently deleted.`) + }); + if (!confirmed) { + return; + } + try { + const wasActive = result.activeVariantId === variant.id; + const deleted = await deleteVariant(result.canonical.uri, variant.id); + showToast({ + title: i18n.t('manager.actions.page.variants.delete.success.title', 'Variant deleted'), + message: i18n.t('manager.actions.page.variants.delete.success.message', `The variant “${variant.id}” was deleted.`), + type: 'success', + timeout: 3000 + }); + if (wasActive) { + modal.hide(); + loadPreview(deleted.url); + return; + } + result.variants = result.variants.filter(candidate => candidate.id !== variant.id); + container.replaceChildren(); + renderVariants(container, result, modal); + } + catch (error) { + showToast({ + title: i18n.t('manager.actions.page.variants.delete.error.title', 'Could not delete variant'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } + }); + item.appendChild(deleteButton); + return item; +}; +const renderVariants = (container, result, modal) => { + if (result.variants.length === 0) { + const emptyMessage = document.createElement('p'); + emptyMessage.className = 'text-body-secondary mb-0'; + emptyMessage.textContent = i18n.t('manager.actions.page.variants.empty', 'This page has no variants.'); + container.appendChild(emptyMessage); + return; + } + const list = document.createElement('div'); + list.className = 'list-group'; + list.appendChild(createVariantLink(result.canonical.title, i18n.t('manager.actions.page.variants.canonical', 'Original'), result.canonical.uri, result.canonical.url, null, !result.activeVariantId, modal)); + result.variants.forEach((variant) => { + list.appendChild(createVariantItem(variant, result, modal, container)); + }); + container.appendChild(list); +}; +const escapeHtml = (input) => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; +export const runAction = async () => { + try { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }); + const result = await getVariants({ + uri: contentNode.result.uri + }); + let modal; + modal = openModal({ + title: i18n.t('manager.actions.page.variants.title', 'Page variants'), + body: `
`, + fullscreen: false, + size: 'lg', + onCancel: () => { }, + onOk: () => { }, + onShow: (modalElement) => { + const container = modalElement.querySelector(`#${VARIANT_LIST_ID}`); + renderVariants(container, result, modal); + } + }); + } + catch (error) { + showToast({ + title: i18n.t('manager.actions.page.variants.error.title', 'Could not load page variants'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } +}; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js b/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js index 5add8ba6d..3dae34895 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/wf-run-transition.js @@ -24,7 +24,6 @@ import { wfTransit } from "@cms/modules/rpc/rpc-workflow"; import { showToast } from "@cms/modules/toast"; export async function runAction(params) { var request = { - uri: params.uri, transitionId: params.transitionId }; try { diff --git a/modules/ui-module/src/main/resources/manager/css/manager.css b/modules/ui-module/src/main/resources/manager/css/manager.css index 50a10d23a..ec8f23374 100644 --- a/modules/ui-module/src/main/resources/manager/css/manager.css +++ b/modules/ui-module/src/main/resources/manager/css/manager.css @@ -96,6 +96,7 @@ i[data-cms-section-handle] { .cms-current-page-container { display: flex; align-items: center; + gap: 0.5rem; min-width: 0; margin-top: 0.5rem; } @@ -105,12 +106,35 @@ i[data-cms-section-handle] { } .cms-current-page__path { + flex: 1 1 auto; + min-width: 0; overflow: hidden; color: rgba(255, 255, 255, 0.9); font-family: var(--bs-font-monospace); text-overflow: ellipsis; } +.cms-current-variant { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0.3rem; + max-width: min(12rem, 35vw); + border: 0; + font-size: 0.75rem; + line-height: 1.25; + white-space: nowrap; +} + +.cms-current-variant:disabled { + opacity: 0.65; +} + +.cms-current-variant__label { + overflow: hidden; + text-overflow: ellipsis; +} + @media (min-width: 992px) { .cms-current-page-container { margin-top: 0; diff --git a/modules/ui-module/src/main/resources/manager/index.html b/modules/ui-module/src/main/resources/manager/index.html index 031dd4d53..40c367f85 100644 --- a/modules/ui-module/src/main/resources/manager/index.html +++ b/modules/ui-module/src/main/resources/manager/index.html @@ -180,6 +180,14 @@ Page /
+
diff --git a/modules/ui-module/src/main/resources/manager/js/manager-inject.js b/modules/ui-module/src/main/resources/manager/js/manager-inject.js index 1edf13ab0..987c7b634 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/manager.js b/modules/ui-module/src/main/resources/manager/js/manager.js index 440c1d11e..bb2280f5e 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager.js +++ b/modules/ui-module/src/main/resources/manager/js/manager.js @@ -105,6 +105,7 @@ const updateCurrentPage = (url) => { displayUrl.searchParams.delete("preview"); displayUrl.searchParams.delete("preview-token"); displayUrl.searchParams.delete("nocache"); + displayUrl.searchParams.delete("variant"); const displayPath = `${displayUrl.pathname}${displayUrl.search}${displayUrl.hash}`; pagePath.textContent = displayPath; pagePath.closest(".cms-current-page")?.setAttribute("title", displayUrl.href); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts index f8d509a6b..e1c7c16d4 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.d.ts @@ -18,10 +18,12 @@ * along with this program. If not, see . * #L% */ -export function renameFileAction({ state, getTargetFolder, filename }: { +export function renameFileAction({ state, getTargetFolder, filename, title, content }: { state: any; getTargetFolder: any; filename: any; + title: any; + content: any; }): Promise; export function deleteElementAction({ elementName, state, deleteFN, getTargetFolder }: { elementName: any; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js index e40f04529..f5bdd7cee 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.actions.js @@ -24,11 +24,15 @@ import { i18n } from '@cms/modules/localization.js'; import { alertSelect, alertConfirm, alertPrompt } from '@cms/modules/alerts.js'; import { showToast } from '@cms/modules/toast.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; -export async function renameFileAction({ state, getTargetFolder, filename }) { +export async function renameFileAction({ state, getTargetFolder, filename, title, content }) { const newName = await alertPrompt({ - title: i18n.t("filebrowser.rename.title", "Rename file"), - label: i18n.t("filebrowser.rename.label", "New name"), - placeholder: filename + title: content + ? i18n.t("filebrowser.renameContent.title", "Edit title") + : i18n.t("filebrowser.rename.title", "Rename"), + label: content + ? i18n.t("filebrowser.renameContent.label", "Title") + : i18n.t("filebrowser.rename.label", "New name"), + placeholder: content ? title : filename }); var extraOptions = {}; if (state.options.siteId) { @@ -44,8 +48,12 @@ export async function renameFileAction({ state, getTargetFolder, filename }) { ...extraOptions }); showToast({ - title: i18n.t("filebrowser.rename.success.title", 'File renamed'), - message: i18n.t("filebrowser.rename.success.message", "File renamed successfully"), + title: content + ? i18n.t("filebrowser.renameContent.success.title", 'Title updated') + : i18n.t("filebrowser.rename.success.title", 'Renamed'), + message: content + ? i18n.t("filebrowser.renameContent.success.message", "Title updated successfully") + : i18n.t("filebrowser.rename.success.message", "Renamed successfully"), type: 'info', timeout: 3000 }); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts index 0869c773e..0c529bf7f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.d.ts @@ -20,6 +20,6 @@ */ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js index 95188230f..cfa6c377e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.js @@ -32,7 +32,7 @@ const defaultOptions = { uri: "", onSelect: null, fullscreen: true, - title: i18n.t("filebrowser.title", "Filesystem"), + title: i18n.t("filebrowser.title", "Content"), filter: (file) => { return true; // Default filter allows all files } @@ -81,7 +81,7 @@ const initFileBrowser = async (uri) => { if (fileBrowserElement) { fileBrowserElement.innerHTML = filebrowserTemplate({ files: files, - filenameHeader: i18n.t("filebrowser.filename", "Filename"), + filenameHeader: i18n.t("filebrowser.filename", "Name"), actionHeader: i18n.t("filebrowser.action", "Action"), actions: getActions(), asset: state.options.type === "assets", @@ -204,10 +204,13 @@ const fileActions = () => { }); } else if (action === "renameFile") { + const row = element.closest("[data-cms-file-name]"); renameFileAction({ state: state, getTargetFolder: getTargetFolder, - filename: filename + filename: filename, + title: row.querySelector('td')?.textContent?.trim() || filename, + content: row.hasAttribute("data-cms-file-content") }).then(async () => { await initFileBrowser(state.currentFolder); }); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js index f95f025c4..80ac1e52f 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser/filebrowser.template.js @@ -71,6 +71,7 @@ const template = Handlebars.compile(` {{#if directory}} @@ -79,7 +80,7 @@ const template = Handlebars.compile(` {{/if}} - {{name}} + {{#if title}}{{title}}{{else}}{{name}}{{/if}} {{#if directory}} {{#ifNotEquals name ".."}} @@ -120,7 +121,7 @@ const template = Handlebars.compile(` {{#ifNotEquals name ".."}} diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts index 23eeb2258..7d3d89dcb 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts @@ -20,7 +20,7 @@ */ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts index e91051759..b1e393873 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts @@ -21,7 +21,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: null; + let _cache: any; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js index 5b99de065..4d5a0a9da 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js @@ -19,28 +19,47 @@ * #L% */ import { getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContent, getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getWfManagerStatus } from './rpc/rpc-workflow'; import { executeScriptAction } from '../manager-globals'; -export function updateStateButton() { - var previewUrl = getPreviewUrl(); - ; - if (!previewUrl) { - document.querySelector('#cms-btn-status').classList.add('disabled'); - document.querySelector('#cms-btn-status').setAttribute('title', 'No preview URL available'); +import { getActivePreviewContent } from './preview-context.js'; +const updateVariantBadge = (content) => { + const badge = document.querySelector('#cms-current-variant'); + const label = document.querySelector('#cms-current-variant-label'); + if (!badge || !label) { return; } - var previewUrl = getPreviewUrl(); - getContentNode({ - url: previewUrl - }).then((contentNode) => { - getWfManagerStatus({ - uri: contentNode.result.uri - }).then((getStatusResponse) => { - updateNodeStatus(getStatusResponse, contentNode.result.uri); - }).catch(() => { - hideStatusButton(); + const variantId = content?.variantId; + label.textContent = content ? (variantId || 'Original') : 'Loading…'; + badge.disabled = !content?.uri; + badge.classList.toggle('text-bg-warning', Boolean(variantId)); + badge.classList.toggle('text-bg-secondary', !variantId); + badge.setAttribute('title', variantId ? `Current variant: ${variantId}` : (content ? 'Original page' : 'Loading preview content')); +}; +window.addEventListener('cms:preview-context-changed', (event) => { + updateVariantBadge(event.detail); +}); +document.addEventListener('DOMContentLoaded', () => { + const badge = document.querySelector('#cms-current-variant'); + badge?.addEventListener('click', () => { + executeScriptAction({ + module: window.manager.baseUrl + '/actions/page/variants', + function: 'runAction', + parameters: {} }); + }); + updateVariantBadge(getActivePreviewContent()); +}); +export function updateStateButton() { + const previewUrl = getPreviewUrl(); + const activePreviewContent = getActivePreviewContent(previewUrl); + if (!previewUrl || !activePreviewContent?.uri) { + const statusButton = document.querySelector('#cms-btn-status'); + statusButton?.classList.add('disabled'); + statusButton?.setAttribute('title', 'No preview content available'); + return; + } + getWfManagerStatus({}).then((getStatusResponse) => { + updateNodeStatus(getStatusResponse); }).catch(() => { hideStatusButton(); }); @@ -51,7 +70,7 @@ function hideStatusButton() { statusBtn.classList.add('disabled'); } } -function updateNodeStatus(statusResponse, uri) { +function updateNodeStatus(statusResponse) { const statusBtn = document.querySelector('#cms-btn-status'); if (!statusBtn) return; @@ -97,9 +116,9 @@ function updateNodeStatus(statusResponse, uri) { statusBtn.classList.add(statusClass); iconEl.classList.add(statusIcon); statusBtn.querySelector('#cms-btn-status-text').textContent = statusText; - updateWorkflowStatus(statusResponse, uri); + updateWorkflowStatus(statusResponse); } -const updateWorkflowStatus = (statusResponse, uri) => { +const updateWorkflowStatus = (statusResponse) => { let visibilityStatus = document.querySelector('#cms-workflow-visibility'); Array.from(visibilityStatus.classList).forEach(className => { if (className.startsWith('bi-')) { @@ -146,16 +165,15 @@ const updateWorkflowStatus = (statusResponse, uri) => { wfTransitionsContainer.querySelectorAll('.workflow-transition').forEach((btn, index) => { btn.addEventListener('click', () => { const transition = transitions[index]; - executeTransition(uri, transition.id); + executeTransition(transition.id); }); }); }; -const executeTransition = async (uri, transitionId) => { +const executeTransition = async (transitionId) => { var cmd = { "module": window.manager.baseUrl + "/actions/page/wf-run-transition", "function": "runAction", "parameters": { - "uri": uri, "transitionId": transitionId } }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js index 55c9eef94..3894184d8 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js @@ -22,6 +22,8 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; +import { setActivePreviewContent } from '@cms/modules/preview-context.js'; +import { updateStateButton } from '@cms/modules/manager-ui.js'; const executeImageForm = (payload) => { const cmd = { "module": window.manager.baseUrl + "/actions/media/edit-media-form", @@ -181,6 +183,8 @@ const initMessageHandlers = () => { const contentNode = await getContentNode({ url: getPreviewUrl() }); + setActivePreviewContent(contentNode.result); + updateStateButton(); var message = { "type": "getContentNodeResponse", "payload": { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts index 125ee5274..d35ac75a0 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/media/mediabrowser.d.ts @@ -20,8 +20,8 @@ */ export function openMediaBrowser(optionsParam: any): Promise; export namespace state { - let options: null; + let options: any; let currentFolder: string; - let metadataForm: null; - let metadataImage: null; + let metadataForm: any; + let metadataImage: any; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts new file mode 100644 index 000000000..c69c1c5c0 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts @@ -0,0 +1,29 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export interface ActivePreviewContent { + uri: string; + url?: string; + canonicalUri?: string; + variantId?: string | null; +} +declare const setActivePreviewContent: (content: ActivePreviewContent | null) => void; +declare const getActivePreviewContent: (currentPreviewUrl?: string) => ActivePreviewContent | null; +export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js new file mode 100644 index 000000000..487fc1d71 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.js @@ -0,0 +1,49 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +let activeContent = null; +const setActivePreviewContent = (content) => { + activeContent = content?.uri ? { ...content } : null; + window.dispatchEvent(new CustomEvent("cms:preview-context-changed", { detail: activeContent })); +}; +const comparableUrl = (url) => { + try { + const parsed = new URL(url, window.location.origin); + parsed.hash = ''; + return `${parsed.pathname}${parsed.search}`; + } + catch { + return null; + } +}; +const getActivePreviewContent = (currentPreviewUrl) => { + if (!activeContent) { + return null; + } + if (!currentPreviewUrl || !activeContent.url) { + return activeContent; + } + const activeUrl = comparableUrl(activeContent.url); + const currentUrl = comparableUrl(currentPreviewUrl); + return activeUrl !== null && activeUrl === currentUrl + ? activeContent + : null; +}; +export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts index 6cfeb16ad..520ce2957 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts @@ -22,6 +22,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: null): void; +declare function init(defaultUrl?: any): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts index 20c850ee6..b529867d3 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts @@ -22,5 +22,5 @@ export function activatePreviewOverlay(): void; export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; -export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement | null; +export function loadPreview(url: any, options?: {}): void; +export function getPreviewFrame(): HTMLElement; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js index efc5be27a..b889392fd 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.js @@ -19,6 +19,7 @@ * #L% */ import { EventBus } from "@cms/modules/event-bus.js"; +import { setActivePreviewContent } from "@cms/modules/preview-context.js"; //PreviewHistory.init(); // close overlay on preview loaded EventBus.on("preview:loaded", (data) => { @@ -52,8 +53,9 @@ const reloadPreview = () => { activatePreviewOverlay(); getPreviewFrame().contentDocument.location.reload(true); }; -const loadPreview = (url) => { +const loadPreview = (url, options = {}) => { activatePreviewOverlay(); + setActivePreviewContent(null); try { // Fallback-Host für relative URLs, damit URL-Parsing funktioniert const dummyBase = window.location.origin; @@ -62,6 +64,9 @@ const loadPreview = (url) => { if (!parsedUrl.searchParams.has("preview")) { parsedUrl.searchParams.append("preview", "manager"); } + if (options.variant) { + parsedUrl.searchParams.set("variant", options.variant); + } parsedUrl.searchParams.delete("preview-token"); //parsedUrl.searchParams.append("preview-token", window.manager.previewToken); parsedUrl.searchParams.delete("nocache"); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts new file mode 100644 index 000000000..6fde1add6 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.d.ts @@ -0,0 +1,73 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export interface VariantDto { + id: string; + uri: string; + url: string; + meta: Record; +} +export interface GetVariantsOptions { + uri: string; + siteId?: string; +} +export interface GetVariantsResult { + uri: string; + canonical: { + uri: string; + url: string; + title: string; + template: string; + }; + activeVariantId?: string | null; + variants: VariantDto[]; +} +export interface CreateVariantOptions { + uri: string; + id: string; + title: string; + template: string; + copyContent: boolean; +} +export interface CreateVariantResult { + id: string; + uri: string; + url: string; +} +export interface DeleteVariantResult { + id: string; + url: string; +} +export interface VariantSelectorDto { + id: string; + label: string; +} +export interface GetVariantSelectorsResult { + selector: string; + selectors: VariantSelectorDto[]; +} +declare const getVariants: (options: GetVariantsOptions) => Promise; +declare const createVariant: (options: CreateVariantOptions) => Promise; +declare const deleteVariant: (uri: string, id: string) => Promise; +declare const getVariantSelectors: (uri: string) => Promise; +declare const setVariantSelector: (uri: string, selector: string) => Promise<{ + selector: string; +}>; +export { createVariant, deleteVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js new file mode 100644 index 000000000..b8a486124 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-variant.js @@ -0,0 +1,57 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { executeRemoteCall } from '@cms/modules/rpc/rpc.js'; +const getVariants = async (options) => { + const data = { + method: 'variants.get', + parameters: options + }; + return (await executeRemoteCall(data)).result; +}; +const createVariant = async (options) => { + const data = { + method: 'variants.create', + parameters: options + }; + return (await executeRemoteCall(data)).result; +}; +const deleteVariant = async (uri, id) => { + const data = { + method: 'variants.delete', + parameters: { uri, id } + }; + return (await executeRemoteCall(data)).result; +}; +const getVariantSelectors = async (uri) => { + const data = { + method: 'variants.selectors.get', + parameters: { uri } + }; + return (await executeRemoteCall(data)).result; +}; +const setVariantSelector = async (uri, selector) => { + const data = { + method: 'variants.selector.set', + parameters: { uri, selector } + }; + return (await executeRemoteCall(data)).result; +}; +export { createVariant, deleteVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts index b11822c10..569b3c2e6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-workflow.d.ts @@ -19,7 +19,7 @@ * #L% */ export interface GetTransitionsRequest { - uri: string; + uri?: string; } export interface GetTransitionsDto { id: string; @@ -29,7 +29,7 @@ declare const getWfTransitions: (options: GetTransitionsRequest) => Promise<{ transitions: GetTransitionsDto[]; }>; export interface GetWFManagerRequest { - uri: string; + uri?: string; } export interface GetWFManagerStatusDto { published: boolean; @@ -49,7 +49,7 @@ export interface getWFManagerDto { } declare const getWfManagerStatus: (options: GetWFManagerRequest) => Promise; export interface WfTransitRequest { - uri: string; + uri?: string; transitionId: string; } export interface WFTransitDto { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js index 985f77d3d..5724b2258 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js @@ -20,6 +20,7 @@ */ import { i18n } from "@cms/modules/localization.js"; import { getCSRFToken } from "../utils"; +import { getActivePreviewContent } from "../preview-context.js"; export class RPCClientError extends Error { constructor(code, message) { super(message); @@ -36,11 +37,23 @@ const executeRemoteMethodCall = async (method, parameters) => { parameters: parameters }; const csrfToken = getCSRFToken(); + const previewFrame = document.getElementById("contentPreview"); + let currentPreviewUrl = ""; + try { + currentPreviewUrl = previewFrame?.contentWindow?.location.href ?? ""; + } + catch { + // A context header is optional when the preview URL cannot be read. + } + const activePreviewContent = getActivePreviewContent(currentPreviewUrl); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - ...(csrfToken && { 'X-CSRF-Token': csrfToken }) + ...(csrfToken && { 'X-CSRF-Token': csrfToken }), + ...(activePreviewContent?.uri && { + 'X-CMS-Content-Uri': activePreviewContent.uri + }) }, body: JSON.stringify(data) }); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/state.js b/modules/ui-module/src/main/resources/manager/js/modules/state.js index a0988236f..7274c0b66 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/state.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/state.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts index 30e36cd4d..65018962d 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts @@ -20,11 +20,11 @@ */ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: null): any; + function getTabState(key: any, defaultValue?: any): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string | null; + function getAuthToken(): string; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/resources/manager/public/manager-login.js b/modules/ui-module/src/main/resources/manager/public/manager-login.js index 24aa077ae..6f21eee99 100644 --- a/modules/ui-module/src/main/resources/manager/public/manager-login.js +++ b/modules/ui-module/src/main/resources/manager/public/manager-login.js @@ -1,4 +1,3 @@ -"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/src/actions/page/add-section.js b/modules/ui-module/src/main/ts/src/actions/page/add-section.js index c84936380..c2bf1d210 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/add-section.js +++ b/modules/ui-module/src/main/ts/src/actions/page/add-section.js @@ -20,18 +20,14 @@ */ import { openModal } from '@cms/modules/modal.js' import { showToast } from '@cms/modules/toast.js' -import { addSection, getContentNode } from '@cms/modules/rpc/rpc-content.js' -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js' +import { addSection } from '@cms/modules/rpc/rpc-content.js' +import { reloadPreview } from '@cms/modules/preview.utils.js' import Handlebars from 'https://cdn.jsdelivr.net/npm/handlebars@4.7.8/+esm' import { i18n } from '@cms/modules/localization.js' import { getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager.js'; export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - var template = Handlebars.compile(`
@@ -57,9 +53,9 @@ export async function runAction(params) { }), fullscreen: false, onCancel: (event) => {}, - validate: () => validate(contentNode, params.section), + validate: () => validate(params.section), onOk: async (event) => { - var result = await createSection(contentNode.result.uri, params.section); + var result = await createSection(params.section); if (result) { showToast({ title: i18n.t("manager.actions.addsection.titles.alert", "Create Entry"), @@ -86,7 +82,7 @@ const getSectionEntryName = () => { return document.getElementById("cms-section-name").value } -const validate = (contentNode, targetSection) => { +const validate = (targetSection) => { const template = document.getElementById("cms-section-template-selection").value if (template === "000") { showToast({ @@ -132,7 +128,7 @@ function isUriInSection(data, sectionEntryKey, targetUri) { return sectionArray.some(item => item.uri === targetUri); } -const createSection = async (parentUri, parentSectionName) => { +const createSection = async (parentSectionName) => { const template = document.getElementById("cms-section-template-selection").value if (template === "000") { @@ -140,7 +136,6 @@ const createSection = async (parentUri, parentSectionName) => { } try { await addSection({ - parentUri: parentUri, sectionEntryName: getSectionEntryName(), section: parentSectionName, template: template diff --git a/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts new file mode 100644 index 000000000..eb26bb54e --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts @@ -0,0 +1,168 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js' +import { openModal } from '@cms/modules/modal.js' +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' +import { getContentNode } from '@cms/modules/rpc/rpc-content.js' +import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' +import { createVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js' +import { showToast } from '@cms/modules/toast.js' + +const value = (id: string): string => + (document.getElementById(id) as HTMLInputElement | HTMLSelectElement)?.value.trim() ?? ''; + +const validate = (): boolean => { + const copyContent = (document.querySelector( + 'input[name="cms-variant-content"]:checked' + ) as HTMLInputElement)?.value === 'copy'; + const missing = [ + ['cms-variant-id', 'Variant ID'], + ['cms-variant-title', 'Title'], + ...(copyContent ? [] : [['cms-variant-template', 'Template']]) + ].find(([id]) => !value(id) || (id === 'cms-variant-template' && value(id) === '__none__')); + + if (!missing) { + return true; + } + + showToast({ + title: i18n.t('manager.actions.page.variant.create.validation.title', 'Create variant'), + message: i18n.t( + `manager.actions.page.variant.create.validation.${missing[0]}`, + `${missing[1]} is required.` + ), + type: 'error', + timeout: 3000 + }); + return false; +}; + +export const runAction = async () => { + try { + const [activeContentNode, templatesResponse] = await Promise.all([ + getContentNode({ url: getPreviewUrl() }), + getPageTemplates({}) + ]); + const variantContext = await getVariants({ uri: activeContentNode.result.uri }); + const templates = Array.from(templatesResponse.result ?? []) + .sort((left: any, right: any) => String(left.name).localeCompare(String(right.name))); + const currentTemplate = variantContext.canonical.template; + + const options = templates.map((template: any) => { + const selected = template.template === currentTemplate ? ' selected' : ''; + return ``; + }).join(''); + + openModal({ + title: i18n.t('manager.actions.page.variant.create.title', 'Create page variant'), + body: ` +
+ + +
+
+ + +
+
+ + +
+
+ Initial content +
+ + +
+
+ + +
+
`, + fullscreen: false, + validate, + onCancel: () => {}, + onShow: (modalElement: HTMLElement) => { + const templateSelect = modalElement.querySelector( + '#cms-variant-template' + ) as HTMLSelectElement; + const contentOptions = modalElement.querySelectorAll( + 'input[name="cms-variant-content"]' + ); + const syncTemplateState = () => { + const copyContent = (modalElement.querySelector( + 'input[name="cms-variant-content"]:checked' + ) as HTMLInputElement)?.value === 'copy'; + if (copyContent) { + templateSelect.value = currentTemplate; + } + templateSelect.disabled = copyContent; + }; + + contentOptions.forEach(option => + option.addEventListener('change', syncTemplateState)); + syncTemplateState(); + }, + onOk: async () => { + try { + const copyContent = (document.querySelector( + 'input[name="cms-variant-content"]:checked' + ) as HTMLInputElement)?.value === 'copy'; + const result = await createVariant({ + uri: variantContext.canonical.uri, + id: value('cms-variant-id'), + title: value('cms-variant-title'), + template: copyContent ? currentTemplate : value('cms-variant-template'), + copyContent + }); + showToast({ + title: i18n.t('manager.actions.page.variant.create.success.title', 'Variant created'), + message: i18n.t('manager.actions.page.variant.create.success.message', 'The page variant was created.'), + type: 'success', + timeout: 3000 + }); + loadPreview(result.url); + } catch (error) { + showError(error); + } + } + }); + } catch (error) { + showError(error); + } +}; + +const escapeHtml = (input: unknown): string => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; + +const showError = (error: unknown) => showToast({ + title: i18n.t('manager.actions.page.variant.create.error.title', 'Could not create variant'), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-content.js b/modules/ui-module/src/main/ts/src/actions/page/edit-content.js index f76833f0d..939f13071 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-content.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-content.js @@ -20,27 +20,17 @@ */ import {openModal} from '@cms/modules/modal.js' import {createForm} from '@cms/modules/form/forms.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' -import {getContentNode, getContent, setContent} from '@cms/modules/rpc/rpc-content.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' +import {getContent, setContent} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { showToast } from '@cms/modules/toast.js' // hook.js export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const nodeContent = await getContent({ - uri: uri - }) + const nodeContent = await getContent(uri ? { uri } : {}) const form = createForm({ fields: [ @@ -66,7 +56,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setContent({ - uri: uri, + ...(uri && { uri }), content: updateData.content }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js index fdca73529..4cfb00d6f 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-form.js @@ -20,9 +20,9 @@ */ import {createForm} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' import { buildValuesFromFields, getValueByPath } from '@cms/modules/node.js' -import {getContentNode, getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' +import {getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { openSidebar } from '@cms/modules/sidebar.js' import { getPageTemplates, getSectionEntryTemplates } from '@cms/modules/rpc/rpc-manager' @@ -30,20 +30,10 @@ import { getPageTemplates, getSectionEntryTemplates } from '@cms/modules/rpc/rpc export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const getContentResponse = await getContent({ - uri: uri - }) + const getContentResponse = await getContent(uri ? { uri } : {}) var templates = null @@ -86,7 +76,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js index 1c46a48e2..0f35c17c8 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute-list.js @@ -20,29 +20,19 @@ */ import {createForm} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' import { getValueByPath } from '@cms/modules/node.js' -import {getContentNode, getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' +import {getContent, setMeta} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { openSidebar } from '@cms/modules/sidebar.js' // hook.js export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const getContentResponse = await getContent({ - uri: uri - }) + const getContentResponse = await getContent(uri ? { uri } : {}) let formDefinition = { fields: [], @@ -74,7 +64,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js index 533a7eaf2..503b71666 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-metaattribute.js @@ -20,29 +20,19 @@ */ import {createForm} from '@cms/modules/form/forms.js' import {showToast} from '@cms/modules/toast.js' -import {getPreviewUrl, reloadPreview} from '@cms/modules/preview.utils.js' +import {reloadPreview} from '@cms/modules/preview.utils.js' import { getValueByPath } from '@cms/modules/node.js' -import { getContentNode, setMeta, getContent} from '@cms/modules/rpc/rpc-content.js' +import { setMeta, getContent} from '@cms/modules/rpc/rpc-content.js' import { i18n } from '@cms/modules/localization.js' import { openSidebar } from '@cms/modules/sidebar.js' // hook.js export async function runAction(params) { - var uri = null - if (params.uri) { - uri = params.uri - } else { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - uri = contentNode.result.uri - } + const uri = params.uri || null try { - const getContentResponse = await getContent({ - uri: uri - }) + const getContentResponse = await getContent(uri ? { uri } : {}) let formDefinition = { fields: [ @@ -69,7 +59,7 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: uri, + ...(uri && { uri }), meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js b/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js index cebbddc40..d8da138ea 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-page-settings.js @@ -21,8 +21,8 @@ import { openSidebar } from '@cms/modules/sidebar.js' import { createForm } from '@cms/modules/form/forms.js' import { showToast } from '@cms/modules/toast.js' -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js' +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' +import { reloadPreview } from '@cms/modules/preview.utils.js' import { i18n } from '@cms/modules/localization.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' import { buildValuesFromFields } from '@cms/modules/node.js' @@ -39,14 +39,8 @@ const DEFAULT_FIELDS = [ export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }) + const getContentResponse = await getContent({}) var pageTemplates = (await getPageTemplates()).result @@ -87,7 +81,6 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js b/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js index 9cc9895de..584e92405 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js +++ b/modules/ui-module/src/main/ts/src/actions/page/edit-schedule.js @@ -21,8 +21,8 @@ import { openModal } from '@cms/modules/modal.js' import { createForm } from '@cms/modules/form/forms.js' import { showToast } from '@cms/modules/toast.js' -import { getContentNode, setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' -import { getPreviewUrl, reloadPreview } from '@cms/modules/preview.utils.js' +import { setMeta, getContent } from '@cms/modules/rpc/rpc-content.js' +import { reloadPreview } from '@cms/modules/preview.utils.js' import { i18n } from '@cms/modules/localization.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' import { buildValuesFromFields } from '@cms/modules/node.js' @@ -42,14 +42,8 @@ const DEFAULT_FIELDS = [ export async function runAction(params) { - const contentNode = await getContentNode({ - url: getPreviewUrl() - }) - try { - const getContentResponse = await getContent({ - uri: contentNode.result.uri - }) + const getContentResponse = await getContent({}) //const previewMetaForm = getMetaForm() const fields = [ @@ -77,7 +71,6 @@ export async function runAction(params) { var updateData = form.getData() try { await setMeta({ - uri: contentNode.result.uri, meta: updateData }) showToast({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts b/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts new file mode 100644 index 000000000..b51570359 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts @@ -0,0 +1,102 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js' +import { openModal } from '@cms/modules/modal.js' +import { getPreviewUrl } from '@cms/modules/preview.utils.js' +import { getContentNode } from '@cms/modules/rpc/rpc-content.js' +import { + getVariantSelectors, + setVariantSelector, + VariantSelectorDto +} from '@cms/modules/rpc/rpc-variant.js' +import { showToast } from '@cms/modules/toast.js' + +const SELECT_ID = 'cms-variant-selector'; + +const escapeHtml = (input: unknown): string => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; + +const option = (selector: VariantSelectorDto, selected: string): string => + ``; + +const showError = (error: unknown) => showToast({ + title: i18n.t( + 'manager.actions.page.variant-selector.error.title', + 'Could not configure variant selection' + ), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 +}); + +export const runAction = async () => { + try { + const contentNode = await getContentNode({ url: getPreviewUrl() }); + const result = await getVariantSelectors(contentNode.result.uri); + + openModal({ + title: i18n.t( + 'manager.actions.page.variant-selector.title', + 'Configure variant selection' + ), + body: ` +
+ + +
+ The strategy is applied to public requests. Manager and explicit preview selection remain unchanged. +
+
`, + fullscreen: false, + onCancel: () => {}, + validate: () => Boolean((document.getElementById(SELECT_ID) as HTMLSelectElement)?.value), + onOk: async () => { + try { + const selector = (document.getElementById(SELECT_ID) as HTMLSelectElement).value; + await setVariantSelector(contentNode.result.uri, selector); + showToast({ + title: i18n.t( + 'manager.actions.page.variant-selector.success.title', + 'Variant selection configured' + ), + message: i18n.t( + 'manager.actions.page.variant-selector.success.message', + 'The selection strategy was saved.' + ), + type: 'success', + timeout: 3000 + }); + } catch (error) { + showError(error); + } + } + }); + } catch (error) { + showError(error); + } +}; diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts new file mode 100644 index 000000000..f9b2ad2d7 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -0,0 +1,232 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js' +import { alertConfirm } from '@cms/modules/alerts.js' +import { openModal } from '@cms/modules/modal.js' +import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js' +import { getContentNode } from '@cms/modules/rpc/rpc-content.js' +import { + deleteVariant, + getVariants, + GetVariantsResult, + VariantDto +} from '@cms/modules/rpc/rpc-variant.js' +import { showToast } from '@cms/modules/toast.js' + +const VARIANT_LIST_ID = 'cms-page-variants'; + +const variantTitle = (variant: VariantDto): string => { + const title = variant.meta?.title; + return typeof title === 'string' && title.trim() ? title : variant.id; +}; + +const createVariantLink = ( + titleText: string, + idText: string, + uriText: string, + url: string, + variantId: string | null, + active: boolean, + modal: any +): HTMLAnchorElement => { + const link = document.createElement('a'); + link.href = url; + link.className = `list-group-item list-group-item-action${active ? ' active' : ''}`; + link.setAttribute('aria-current', active ? 'true' : 'false'); + + const heading = document.createElement('div'); + heading.className = 'd-flex w-100 justify-content-between align-items-center gap-3'; + + const title = document.createElement('strong'); + title.textContent = titleText; + heading.appendChild(title); + + const id = document.createElement('span'); + id.className = active ? 'badge text-bg-light' : 'badge text-bg-secondary'; + id.textContent = idText; + heading.appendChild(id); + link.appendChild(heading); + + const uri = document.createElement('small'); + uri.className = active ? '' : 'text-body-secondary'; + uri.textContent = uriText; + link.appendChild(uri); + + link.addEventListener('click', (event) => { + event.preventDefault(); + modal.hide(); + loadPreview(url, variantId ? { variant: variantId } : {}); + }); + return link; +}; + +const createVariantItem = ( + variant: VariantDto, + result: GetVariantsResult, + modal: any, + container: HTMLElement +): HTMLElement => { + const item = document.createElement('div'); + const active = result.activeVariantId === variant.id; + item.className = `list-group-item d-flex align-items-center gap-2 p-0${active ? ' active' : ''}`; + + const link = createVariantLink( + variantTitle(variant), + variant.id, + variant.uri, + result.canonical.url, + variant.id, + active, + modal + ); + link.classList.remove('list-group-item', 'active'); + link.classList.add('flex-grow-1', 'border-0', 'rounded-0', 'px-3', 'py-2'); + if (active) { + link.classList.add('text-reset'); + } + item.appendChild(link); + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger flex-shrink-0 me-2'; + deleteButton.title = i18n.t('manager.actions.page.variants.delete', 'Delete variant'); + deleteButton.setAttribute('aria-label', `${deleteButton.title}: ${variant.id}`); + deleteButton.innerHTML = ''; + deleteButton.addEventListener('click', async () => { + const confirmed = await alertConfirm({ + title: i18n.t('manager.actions.page.variants.delete.confirm.title', 'Delete page variant?'), + message: i18n.t( + 'manager.actions.page.variants.delete.confirm.message', + `The variant “${escapeHtml(variantTitle(variant))}” and all its sections will be permanently deleted.` + ) + }); + if (!confirmed) { + return; + } + + try { + const wasActive = result.activeVariantId === variant.id; + const deleted = await deleteVariant(result.canonical.uri, variant.id); + showToast({ + title: i18n.t('manager.actions.page.variants.delete.success.title', 'Variant deleted'), + message: i18n.t( + 'manager.actions.page.variants.delete.success.message', + `The variant “${variant.id}” was deleted.` + ), + type: 'success', + timeout: 3000 + }); + if (wasActive) { + modal.hide(); + loadPreview(deleted.url); + return; + } + result.variants = result.variants.filter(candidate => candidate.id !== variant.id); + container.replaceChildren(); + renderVariants(container, result, modal); + } catch (error) { + showToast({ + title: i18n.t( + 'manager.actions.page.variants.delete.error.title', + 'Could not delete variant' + ), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } + }); + item.appendChild(deleteButton); + return item; +}; + +const renderVariants = (container: HTMLElement, result: GetVariantsResult, modal: any) => { + if (result.variants.length === 0) { + const emptyMessage = document.createElement('p'); + emptyMessage.className = 'text-body-secondary mb-0'; + emptyMessage.textContent = i18n.t( + 'manager.actions.page.variants.empty', + 'This page has no variants.' + ); + container.appendChild(emptyMessage); + return; + } + + const list = document.createElement('div'); + list.className = 'list-group'; + list.appendChild(createVariantLink( + result.canonical.title, + i18n.t('manager.actions.page.variants.canonical', 'Original'), + result.canonical.uri, + result.canonical.url, + null, + !result.activeVariantId, + modal + )); + + result.variants.forEach((variant) => { + list.appendChild(createVariantItem(variant, result, modal, container)); + }); + + container.appendChild(list); +}; + +const escapeHtml = (input: unknown): string => { + const element = document.createElement('div'); + element.textContent = String(input ?? ''); + return element.innerHTML; +}; + +export const runAction = async () => { + try { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }); + const result = await getVariants({ + uri: contentNode.result.uri + }); + + let modal: any; + modal = openModal({ + title: i18n.t('manager.actions.page.variants.title', 'Page variants'), + body: `
`, + fullscreen: false, + size: 'lg', + onCancel: () => { }, + onOk: () => { }, + onShow: (modalElement: HTMLElement) => { + const container = modalElement.querySelector(`#${VARIANT_LIST_ID}`) as HTMLElement; + renderVariants(container, result, modal); + } + }); + } catch (error) { + showToast({ + title: i18n.t( + 'manager.actions.page.variants.error.title', + 'Could not load page variants' + ), + message: error instanceof Error ? error.message : String(error), + type: 'error', + timeout: 3000 + }); + } +}; diff --git a/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts b/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts index e80b3edc5..05bbb231b 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/wf-run-transition.ts @@ -27,7 +27,6 @@ import { showToast } from "@cms/modules/toast"; export async function runAction(params : any) { var request = { - uri : params.uri, transitionId: params.transitionId }; diff --git a/modules/ui-module/src/main/ts/src/js/manager.js b/modules/ui-module/src/main/ts/src/js/manager.js index 35edcd4bc..0bcfa3450 100644 --- a/modules/ui-module/src/main/ts/src/js/manager.js +++ b/modules/ui-module/src/main/ts/src/js/manager.js @@ -122,8 +122,9 @@ const updateCurrentPage = (url) => { const displayUrl = new URL(url); displayUrl.searchParams.delete("preview"); - displayUrl.searchParams.delete("preview-token"); - displayUrl.searchParams.delete("nocache"); + displayUrl.searchParams.delete("preview-token"); + displayUrl.searchParams.delete("nocache"); + displayUrl.searchParams.delete("variant"); const displayPath = `${displayUrl.pathname}${displayUrl.search}${displayUrl.hash}`; pagePath.textContent = displayPath; diff --git a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js index faae97637..c307df671 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js +++ b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.actions.js @@ -27,11 +27,15 @@ import { alertSelect, alertConfirm, alertPrompt } from '@cms/modules/alerts.js' import { showToast } from '@cms/modules/toast.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; -export async function renameFileAction({ state, getTargetFolder, filename }) { +export async function renameFileAction({ state, getTargetFolder, filename, title, content }) { const newName = await alertPrompt({ - title: i18n.t("filebrowser.rename.title", "Rename file"), - label: i18n.t("filebrowser.rename.label", "New name"), - placeholder: filename + title: content + ? i18n.t("filebrowser.renameContent.title", "Edit title") + : i18n.t("filebrowser.rename.title", "Rename"), + label: content + ? i18n.t("filebrowser.renameContent.label", "Title") + : i18n.t("filebrowser.rename.label", "New name"), + placeholder: content ? title : filename }); var extraOptions = {} if (state.options.siteId) { @@ -47,8 +51,12 @@ export async function renameFileAction({ state, getTargetFolder, filename }) { ...extraOptions }); showToast({ - title: i18n.t("filebrowser.rename.success.title", 'File renamed'), - message: i18n.t("filebrowser.rename.success.message", "File renamed successfully"), + title: content + ? i18n.t("filebrowser.renameContent.success.title", 'Title updated') + : i18n.t("filebrowser.rename.success.title", 'Renamed'), + message: content + ? i18n.t("filebrowser.renameContent.success.message", "Title updated successfully") + : i18n.t("filebrowser.rename.success.message", "Renamed successfully"), type: 'info', timeout: 3000 }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js index 85ba465c3..add6003ab 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js +++ b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.js @@ -35,7 +35,7 @@ const defaultOptions = { uri: "", onSelect: null, fullscreen: true, - title: i18n.t("filebrowser.title", "Filesystem"), + title: i18n.t("filebrowser.title", "Content"), filter : (file) => { return true; // Default filter allows all files } @@ -92,7 +92,7 @@ const initFileBrowser = async (uri) => { if (fileBrowserElement) { fileBrowserElement.innerHTML = filebrowserTemplate({ files: files, - filenameHeader: i18n.t("filebrowser.filename", "Filename"), + filenameHeader: i18n.t("filebrowser.filename", "Name"), actionHeader: i18n.t("filebrowser.action", "Action"), actions: getActions(), asset: state.options.type === "assets", @@ -223,10 +223,13 @@ const fileActions = () => { await initFileBrowser(state.currentFolder); }); } else if (action === "renameFile") { + const row = element.closest("[data-cms-file-name]"); renameFileAction({ state: state, getTargetFolder: getTargetFolder, - filename: filename + filename: filename, + title: row.querySelector('td')?.textContent?.trim() || filename, + content: row.hasAttribute("data-cms-file-content") }).then(async () => { await initFileBrowser(state.currentFolder); }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js index ea3be4afe..a58986d0c 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js +++ b/modules/ui-module/src/main/ts/src/js/modules/filebrowser/filebrowser.template.js @@ -75,6 +75,7 @@ const template = Handlebars.compile(` {{#if directory}} @@ -83,7 +84,7 @@ const template = Handlebars.compile(` {{/if}} - {{name}} + {{#if title}}{{title}}{{else}}{{name}}{{/if}} {{#if directory}} {{#ifNotEquals name ".."}} @@ -124,7 +125,7 @@ const template = Handlebars.compile(` {{#ifNotEquals name ".."}} diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js index 5cc029d34..ca40f3df9 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js @@ -20,31 +20,56 @@ */ import { getPreviewUrl } from '@cms/modules/preview.utils.js'; -import { getContent, getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getWfManagerStatus } from './rpc/rpc-workflow'; import { executeScriptAction } from '../manager-globals'; +import { getActivePreviewContent } from './preview-context.js'; -export function updateStateButton() { - var previewUrl = getPreviewUrl();; - if (!previewUrl) { - document.querySelector('#cms-btn-status').classList.add('disabled'); - document.querySelector('#cms-btn-status').setAttribute('title', 'No preview URL available'); +const updateVariantBadge = (content) => { + const badge = document.querySelector('#cms-current-variant'); + const label = document.querySelector('#cms-current-variant-label'); + if (!badge || !label) { return; - } - var previewUrl = getPreviewUrl(); - - getContentNode({ - url: previewUrl - }).then((contentNode) => { - - getWfManagerStatus({ - uri: contentNode.result.uri - }).then((getStatusResponse) => { - updateNodeStatus(getStatusResponse, contentNode.result.uri); - }).catch(() => { - hideStatusButton(); + + const variantId = content?.variantId; + label.textContent = content ? (variantId || 'Original') : 'Loading…'; + badge.disabled = !content?.uri; + badge.classList.toggle('text-bg-warning', Boolean(variantId)); + badge.classList.toggle('text-bg-secondary', !variantId); + badge.setAttribute( + 'title', + variantId ? `Current variant: ${variantId}` : (content ? 'Original page' : 'Loading preview content') + ); +}; + +window.addEventListener('cms:preview-context-changed', (event) => { + updateVariantBadge(event.detail); +}); + +document.addEventListener('DOMContentLoaded', () => { + const badge = document.querySelector('#cms-current-variant'); + badge?.addEventListener('click', () => { + executeScriptAction({ + module: window.manager.baseUrl + '/actions/page/variants', + function: 'runAction', + parameters: {} }); + }); + updateVariantBadge(getActivePreviewContent()); +}); + +export function updateStateButton() { + const previewUrl = getPreviewUrl(); + const activePreviewContent = getActivePreviewContent(previewUrl); + if (!previewUrl || !activePreviewContent?.uri) { + const statusButton = document.querySelector('#cms-btn-status'); + statusButton?.classList.add('disabled'); + statusButton?.setAttribute('title', 'No preview content available'); + return; + } + + getWfManagerStatus({}).then((getStatusResponse) => { + updateNodeStatus(getStatusResponse); }).catch(() => { hideStatusButton(); }); @@ -57,7 +82,7 @@ function hideStatusButton() { } } -function updateNodeStatus(statusResponse, uri) { +function updateNodeStatus(statusResponse) { const statusBtn = document.querySelector('#cms-btn-status'); if (!statusBtn) return; const iconEl = statusBtn.querySelector('#cms-btn-status-icon'); @@ -106,10 +131,10 @@ function updateNodeStatus(statusResponse, uri) { iconEl.classList.add(statusIcon); statusBtn.querySelector('#cms-btn-status-text').textContent = statusText; - updateWorkflowStatus(statusResponse, uri); + updateWorkflowStatus(statusResponse); } -const updateWorkflowStatus = (statusResponse, uri) => { +const updateWorkflowStatus = (statusResponse) => { let visibilityStatus = document.querySelector('#cms-workflow-visibility'); Array.from(visibilityStatus.classList).forEach(className => { @@ -158,17 +183,16 @@ const updateWorkflowStatus = (statusResponse, uri) => { wfTransitionsContainer.querySelectorAll('.workflow-transition').forEach((btn, index) => { btn.addEventListener('click', () => { const transition = transitions[index]; - executeTransition(uri, transition.id); + executeTransition(transition.id); }); }); } -const executeTransition = async (uri, transitionId) => { +const executeTransition = async (transitionId) => { var cmd = { "module": window.manager.baseUrl + "/actions/page/wf-run-transition", "function": "runAction", "parameters": { - "uri": uri, "transitionId": transitionId } } diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts index 40dfd791e..86bbd17d5 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts @@ -22,6 +22,8 @@ import { executeScriptAction } from '@cms/js/manager-globals.js'; import frameMessenger from '@cms/modules/frameMessenger.js'; import { getPreviewFrame, getPreviewUrl } from '@cms/modules/preview.utils.js'; import { getContentNode, setMetaBatch } from '@cms/modules/rpc/rpc-content.js'; +import { setActivePreviewContent } from '@cms/modules/preview-context.js'; +import { updateStateButton } from '@cms/modules/manager-ui.js'; const executeImageForm = (payload: any) => { const cmd: any = { @@ -185,6 +187,8 @@ const initMessageHandlers = () => { const contentNode = await getContentNode({ url: getPreviewUrl() }) + setActivePreviewContent(contentNode.result); + updateStateButton(); var message : any = { "type": "getContentNodeResponse", "payload": { diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts new file mode 100644 index 000000000..c7bc68758 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts @@ -0,0 +1,65 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +export interface ActivePreviewContent { + uri: string; + url?: string; + canonicalUri?: string; + variantId?: string | null; +} + +let activeContent: ActivePreviewContent | null = null; + +const setActivePreviewContent = (content: ActivePreviewContent | null) => { + activeContent = content?.uri ? { ...content } : null; + window.dispatchEvent(new CustomEvent( + "cms:preview-context-changed", + { detail: activeContent } + )); +}; + +const comparableUrl = (url: string): string | null => { + try { + const parsed = new URL(url, window.location.origin); + parsed.hash = ''; + return `${parsed.pathname}${parsed.search}`; + } catch { + return null; + } +}; + +const getActivePreviewContent = ( + currentPreviewUrl?: string +): ActivePreviewContent | null => { + if (!activeContent) { + return null; + } + if (!currentPreviewUrl || !activeContent.url) { + return activeContent; + } + const activeUrl = comparableUrl(activeContent.url); + const currentUrl = comparableUrl(currentPreviewUrl); + return activeUrl !== null && activeUrl === currentUrl + ? activeContent + : null; +}; + +export { getActivePreviewContent, setActivePreviewContent }; diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js b/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js index 6877cb1d5..425eac3ba 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js +++ b/modules/ui-module/src/main/ts/src/js/modules/preview.utils.js @@ -20,6 +20,7 @@ */ import { EventBus } from "@cms/modules/event-bus.js"; +import { setActivePreviewContent } from "@cms/modules/preview-context.js"; //PreviewHistory.init(); // close overlay on preview loaded @@ -58,8 +59,9 @@ const reloadPreview = () => { getPreviewFrame().contentDocument.location.reload(true); } -const loadPreview = (url) => { +const loadPreview = (url, options = {}) => { activatePreviewOverlay(); + setActivePreviewContent(null); try { // Fallback-Host für relative URLs, damit URL-Parsing funktioniert @@ -70,6 +72,9 @@ const loadPreview = (url) => { if (!parsedUrl.searchParams.has("preview")) { parsedUrl.searchParams.append("preview", "manager"); } + if (options.variant) { + parsedUrl.searchParams.set("variant", options.variant); + } parsedUrl.searchParams.delete("preview-token") //parsedUrl.searchParams.append("preview-token", window.manager.previewToken); parsedUrl.searchParams.delete("nocache"); diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts new file mode 100644 index 000000000..ba6a8322f --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-variant.ts @@ -0,0 +1,117 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { executeRemoteCall } from '@cms/modules/rpc/rpc.js' + +export interface VariantDto { + id: string; + uri: string; + url: string; + meta: Record; +} + +export interface GetVariantsOptions { + uri: string; + siteId?: string; +} + +export interface GetVariantsResult { + uri: string; + canonical: { + uri: string; + url: string; + title: string; + template: string; + }; + activeVariantId?: string | null; + variants: VariantDto[]; +} + +export interface CreateVariantOptions { + uri: string; + id: string; + title: string; + template: string; + copyContent: boolean; +} + +export interface CreateVariantResult { + id: string; + uri: string; + url: string; +} + +export interface DeleteVariantResult { + id: string; + url: string; +} + +export interface VariantSelectorDto { + id: string; + label: string; +} + +export interface GetVariantSelectorsResult { + selector: string; + selectors: VariantSelectorDto[]; +} + +const getVariants = async (options: GetVariantsOptions): Promise => { + const data = { + method: 'variants.get', + parameters: options + }; + return (await executeRemoteCall(data)).result as GetVariantsResult; +}; + +const createVariant = async (options: CreateVariantOptions): Promise => { + const data = { + method: 'variants.create', + parameters: options + }; + return (await executeRemoteCall(data)).result as CreateVariantResult; +}; + +const deleteVariant = async (uri: string, id: string): Promise => { + const data = { + method: 'variants.delete', + parameters: { uri, id } + }; + return (await executeRemoteCall(data)).result as DeleteVariantResult; +}; + +const getVariantSelectors = async (uri: string): Promise => { + const data = { + method: 'variants.selectors.get', + parameters: { uri } + }; + return (await executeRemoteCall(data)).result as GetVariantSelectorsResult; +}; + +const setVariantSelector = async (uri: string, selector: string): Promise<{ selector: string }> => { + const data = { + method: 'variants.selector.set', + parameters: { uri, selector } + }; + return (await executeRemoteCall(data)).result as { selector: string }; +}; + +export { createVariant, deleteVariant, getVariants, getVariantSelectors, setVariantSelector }; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts index 41f487253..a8d00c1b0 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-workflow.ts @@ -22,7 +22,7 @@ import { executeRemoteCall } from '@cms/modules/rpc/rpc.js' export interface GetTransitionsRequest { - uri: string; // The URI of the folder where the page should be created + uri?: string; } export interface GetTransitionsDto { id: string, @@ -37,7 +37,7 @@ const getWfTransitions = async (options: GetTransitionsRequest) => { }; export interface GetWFManagerRequest { - uri: string; // The URI of the folder where the page should be created + uri?: string; } export interface GetWFManagerStatusDto { published: boolean, @@ -64,7 +64,7 @@ const getWfManagerStatus = async (options: GetWFManagerRequest) => { }; export interface WfTransitRequest { - uri: string; // The URI of the folder where the page should be created + uri?: string; transitionId: string; // The URI of the folder where the page should be created } export interface WFTransitDto { diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts index 1656f6c8a..5ce381f25 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts @@ -21,6 +21,7 @@ import { i18n } from "@cms/modules/localization.js"; import { getCSRFToken } from "../utils"; +import { getActivePreviewContent } from "../preview-context.js"; interface Options { method: string; @@ -50,11 +51,22 @@ const executeRemoteMethodCall = async (method : string, parameters : any) => { parameters: parameters } const csrfToken = getCSRFToken(); + const previewFrame = document.getElementById("contentPreview") as HTMLIFrameElement | null; + let currentPreviewUrl = ""; + try { + currentPreviewUrl = previewFrame?.contentWindow?.location.href ?? ""; + } catch { + // A context header is optional when the preview URL cannot be read. + } + const activePreviewContent = getActivePreviewContent(currentPreviewUrl); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - ...(csrfToken && { 'X-CSRF-Token': csrfToken }) + ...(csrfToken && { 'X-CSRF-Token': csrfToken }), + ...(activePreviewContent?.uri && { + 'X-CMS-Content-Uri': activePreviewContent.uri + }) }, body: JSON.stringify(data) }) diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java index 35544bb8b..91deaef11 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java @@ -25,7 +25,11 @@ import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; import java.io.IOException; import java.util.Map; @@ -90,4 +94,23 @@ void setContent_throwsRPCException_whenParsingFails() throws IOException { .isInstanceOf(RPCException.class) .hasMessage("disk error"); } + + @Test + void getContentUsesCurrentNodeWhenUriIsOmitted() throws Exception { + var uri = ".variants/about/summer/about.md"; + var requestContext = new RequestContext(); + requestContext.add( + CurrentNodeFeature.class, + new CurrentNodeFeature(new ContentNode(uri, "/about", "about.md", Map.of())) + ); + when(contentBase.resolve(uri)).thenReturn(contentFile); + when(contentFile.getContent()).thenThrow(new IOException("variant selected")); + + assertThatThrownBy(() -> ScopedValue.where( + RequestContextScope.REQUEST_CONTEXT, + requestContext + ).call(() -> endpoints.getContent(Map.of()))) + .isInstanceOf(RPCException.class) + .hasMessage("variant selected"); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java index fdfc372d8..099b54316 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java @@ -22,16 +22,26 @@ */ import com.condation.cms.api.Constants; +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.core.content.io.ContentFileParser; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Map; +import java.util.Optional; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @@ -54,6 +64,9 @@ public class RemoteFileEnpointsTest { @Mock private DBFileSystem dbFileSystem; + + @TempDir + private Path tempDir; public RemoteFileEnpointsTest() { } @@ -75,5 +88,80 @@ public void create_folder_with_absolut_path_throws_error() throws RPCException { ))).isInstanceOf(RPCException.class);//.hasMessage("invalid path"); } + + @Test + void listUsesContentTitleButKeepsTechnicalName() throws Exception { + var content = Mockito.mock(Content.class); + var contentBase = Mockito.mock(ReadOnlyFile.class); + var page = Mockito.mock(ReadOnlyFile.class); + var node = new ContentNode( + "about.md", + "/about", + "about.md", + Map.of(Constants.MetaFields.TITLE, "About us") + ); + Mockito.when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + Mockito.when(db.getFileSystem()).thenReturn(dbFileSystem); + Mockito.when(db.getContent()).thenReturn(content); + Mockito.when(dbFileSystem.contentBase()).thenReturn(contentBase); + Mockito.when(contentBase.resolve("")).thenReturn(contentBase); + Mockito.when(contentBase.isDirectory()).thenReturn(true); + Mockito.when(contentBase.children()).thenReturn(List.of(page)); + Mockito.when(page.getFileName()).thenReturn("about.md"); + Mockito.when(page.uri()).thenReturn("/about"); + Mockito.when(page.relativePath()).thenReturn("about.md"); + Mockito.when(content.byPath("about.md")).thenReturn(Optional.of(node)); + + var endpoints = new RemoteFileEnpoints(); + endpoints.setContext(moduleContext); + + @SuppressWarnings("unchecked") + var result = (Map) endpoints.list(Map.of( + "type", "content", + "uri", "" + )); + var files = (List) result.get("files"); + + Assertions.assertThat(files).singleElement().satisfies(file -> { + Assertions.assertThat(file.name()).isEqualTo("about.md"); + Assertions.assertThat(file.displayName()).isEqualTo("About us"); + Assertions.assertThat(((RemoteFileEnpoints.Content) file).title()).isEqualTo("About us"); + }); + } + + @Test + void renameMarkdownContentUpdatesTitleWithoutChangingFileName() throws Exception { + var contentBase = Mockito.mock(ReadOnlyFile.class); + var directory = Mockito.mock(ReadOnlyFile.class); + var contentFile = Mockito.mock(ReadOnlyFile.class); + var eventBus = Mockito.mock(EventBus.class); + var page = tempDir.resolve("about.md"); + Files.writeString(page, "---\ntitle: Old title\ntemplate: page\n---\n\nBody"); + Mockito.when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + Mockito.when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); + Mockito.when(db.getFileSystem()).thenReturn(dbFileSystem); + Mockito.when(dbFileSystem.contentBase()).thenReturn(contentBase); + Mockito.when(dbFileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(tempDir); + Mockito.when(contentBase.resolve("")).thenReturn(directory); + Mockito.when(directory.resolve("about.md")).thenReturn(contentFile); + + var endpoints = new RemoteFileEnpoints(); + endpoints.setContext(moduleContext); + endpoints.renameFile(Map.of( + "type", "content", + "uri", "", + "name", "about.md", + "newName", "New title" + )); + + Assertions.assertThat(page).exists(); + Assertions.assertThat(tempDir.resolve("New title")).doesNotExist(); + var parser = new ContentFileParser(page.toString()); + Assertions.assertThat(parser.getHeader()) + .containsEntry(Constants.MetaFields.TITLE, "New title") + .containsEntry(Constants.MetaFields.TEMPLATE, "page"); + Assertions.assertThat(parser.getContent()).isEqualTo("Body"); + Mockito.verify(dbFileSystem).flushContentChanges(); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java index 2404185c6..0f34afcf9 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemotePageEnpointsTest.java @@ -28,6 +28,7 @@ import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.module.SiteModuleContext; @@ -174,7 +175,7 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { meta.put(Constants.MetaFields.TITLE, "Superman Returns"); ContentNode node = new ContentNode("test/test1.md", "/test/test1", "test1.md", meta); - when(content.searchByTitle("superman")).thenReturn(List.of(node)); + when(content.searchByTitle("superman", VariantSearchMode.ORIGINAL)).thenReturn(List.of(node)); when(contentBase.resolve("test/test1.md")).thenReturn(contentFile); when(contentBase.relativize(contentFile)).thenReturn(contentFile); when(contentFile.toString()).thenReturn("test/test1.md"); @@ -195,6 +196,7 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { assertThat(hits).hasSize(1); assertThat(hits.get(0).title()).isEqualTo("Superman Returns"); assertThat(hits.get(0).uri()).isNotNull(); + verify(content).searchByTitle("superman", VariantSearchMode.ORIGINAL); } @Test @@ -206,7 +208,7 @@ public void testSearchPages_missingTitle_fallsBackToEmptyString() throws RPCExce ContentNode node = new ContentNode("test/test2.md", "/test/test2", "test2.md", new HashMap<>()); - when(content.searchByTitle("")).thenReturn(List.of(node)); + when(content.searchByTitle("", VariantSearchMode.ORIGINAL)).thenReturn(List.of(node)); when(contentBase.resolve("test/test2.md")).thenReturn(contentFile); when(contentBase.relativize(contentFile)).thenReturn(contentFile); when(contentFile.toString()).thenReturn("test/test2.md"); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java new file mode 100644 index 000000000..a7dd319dd --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteVariantEndpointTest.java @@ -0,0 +1,308 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.EventBusFeature; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.content.ConfigurableVariantSelector; +import com.condation.cms.content.VariantResolver; +import com.condation.cms.content.VariantSelectorConfigurationRepository; +import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RemoteVariantEndpointTest { + + @Mock + private SiteModuleContext moduleContext; + + @Mock + private DB db; + + @Mock + private Content content; + + @Mock + private DBFileSystem fileSystem; + + @Mock + private EventBus eventBus; + + @Mock + private VariantResolver variantResolver; + + @Mock + private ConfigurableVariantSelector configurableVariantSelector; + + @Mock + private VariantSelectorConfigurationRepository selectorConfigurationRepository; + + private RemoteVariantEndpoint endpoint; + + @TempDir + private Path tempDir; + + @BeforeEach + void setUp() { + endpoint = new RemoteVariantEndpoint() { + @Override + protected VariantResolver getVariantResolver(DB db) { + return variantResolver; + } + + @Override + protected ConfigurableVariantSelector getConfigurableVariantSelector() { + return configurableVariantSelector; + } + + @Override + protected VariantSelectorConfigurationRepository getVariantSelectorConfigurationRepository() { + return selectorConfigurationRepository; + } + }; + endpoint.setContext(moduleContext); + lenient().when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + lenient().when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); + lenient().when(db.getContent()).thenReturn(content); + lenient().when(db.getFileSystem()).thenReturn(fileSystem); + lenient().when(fileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(tempDir); + } + + @Test + void getReturnsVariantsSortedById() throws RPCException { + var node = node("about.md", "/about", Map.of( + "title", "About", + "template", "page.html" + )); + var summerNode = node( + ".variants/about/summer/about.md", + "/.variants/about/summer/about", + Map.of("title", "Summer") + ); + var campaignNode = node( + ".variants/about/campaign/about.md", + "/.variants/about/campaign/about", + Map.of("title", "Campaign") + ); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + var variants = List.of( + new Variant("summer", summerNode), + new Variant("campaign", campaignNode) + ); + when(variantResolver.resolveContext(node)).thenReturn( + new VariantResolver.VariantContext(node, Optional.empty(), variants) + ); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.get(Map.of("uri", "about.md")); + + assertThat(result).containsEntry("uri", "about.md"); + assertThat((List) result.get("variants")) + .extracting(VariantDto::id) + .containsExactly("campaign", "summer"); + assertThat((List) result.get("variants")) + .extracting(VariantDto::url) + .containsExactly( + "/about?preview=manager&variant=campaign", + "/about?preview=manager&variant=summer" + ); + assertThat(result).containsEntry("activeVariantId", null); + assertThat((Map) result.get("canonical")) + .containsEntry("template", "page.html"); + } + + @Test + void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { + var canonical = node("about.md", "/about", Map.of("title", "About")); + var summer = node( + ".variants/about/summer/about.md", + "/.variants/about/summer/about", + Map.of("title", "Summer") + ); + var variants = List.of(new Variant("summer", summer)); + when(content.byPath(summer.path())).thenReturn(Optional.of(summer)); + when(variantResolver.resolveContext(summer)).thenReturn( + new VariantResolver.VariantContext(canonical, Optional.of("summer"), variants) + ); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.get(Map.of("uri", summer.path())); + + assertThat(result) + .containsEntry("uri", "about.md") + .containsEntry("activeVariantId", "summer"); + assertThat((Map) result.get("canonical")) + .containsEntry("url", "/about?preview=manager"); + } + + @Test + void getReturnsEmptyListWhenNodeHasNoVariants() throws RPCException { + var node = node("about.md", "/about", Map.of()); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(variantResolver.resolveContext(node)).thenReturn( + new VariantResolver.VariantContext(node, Optional.empty(), List.of()) + ); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.get(Map.of("uri", "about.md")); + + assertThat(result).containsEntry("variants", List.of()); + } + + @Test + void getRejectsBlankUri() { + assertThatThrownBy(() -> endpoint.get(Map.of())) + .isInstanceOf(RPCException.class) + .satisfies(exception -> + assertThat(((RPCException) exception).getCode()).isEqualTo(400) + ); + } + + @Test + void getReturnsNotFoundForUnknownNode() { + when(content.byPath("missing.md")).thenReturn(Optional.empty()); + when(content.byUrl("missing.md")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> endpoint.get(Map.of("uri", "missing.md"))) + .isInstanceOf(RPCException.class) + .satisfies(exception -> + assertThat(((RPCException) exception).getCode()).isEqualTo(404) + ); + } + + @Test + void getSelectorsReturnsConfigurationAndAvailableStrategies() throws RPCException { + var node = node("about.md", "/about", Map.of()); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(selectorConfigurationRepository.getSelectorId(node)).thenReturn("audience"); + when(configurableVariantSelector.availableSelectors()).thenReturn(Map.of( + "date-range", + new ConfigurableVariantSelector.SelectorDescriptor("date-range", "Date range"), + "audience", + new ConfigurableVariantSelector.SelectorDescriptor("audience", "Audience") + )); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.getSelectors(Map.of("uri", "about.md")); + + assertThat(result).containsEntry("selector", "audience"); + assertThat((List) result.get("selectors")) + .extracting(ConfigurableVariantSelector.SelectorDescriptor::id) + .containsExactly("audience", "date-range"); + } + + @Test + void setSelectorPersistsConfigurationForPage() throws Exception { + var node = node("about.md", "/about", Map.of()); + when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(configurableVariantSelector.hasSelector("audience")).thenReturn(true); + + var result = endpoint.setSelector(Map.of("uri", "about.md", "selector", "audience")); + + assertThat(result).isEqualTo(Map.of("selector", "audience")); + verify(selectorConfigurationRepository).setSelectorId(node, "audience"); + } + + @Test + void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception { + var canonical = node("about.md", "/about", Map.of("title", "About")); + var summer = node( + ".variants/about/summer/about.md", + "/.variants/about/summer/about", + Map.of("title", "Summer") + ); + var variantFolder = tempDir.resolve(".variants/about/summer"); + Files.createDirectories(variantFolder); + Files.writeString(variantFolder.resolve("about.md"), "variant"); + Files.writeString(variantFolder.resolve("about.main.hero.md"), "section"); + var configuration = tempDir.resolve(".variants/about/variants.yaml"); + Files.writeString(configuration, "selector: date-range"); + when(content.byPath(canonical.path())).thenReturn(Optional.of(canonical)); + when(variantResolver.resolveContext(canonical)).thenReturn( + new VariantResolver.VariantContext( + canonical, + Optional.empty(), + List.of(new Variant("summer", summer)) + ) + ); + + @SuppressWarnings("unchecked") + var result = (Map) endpoint.delete(Map.of( + "uri", canonical.path(), + "id", "summer" + )); + + assertThat(result) + .containsEntry("id", "summer") + .containsEntry("url", "/about?preview=manager"); + assertThat(variantFolder).doesNotExist(); + assertThat(configuration).exists(); + verify(fileSystem).flushContentChanges(); + } + + @Test + void deleteRejectsUnknownVariant() { + var canonical = node("about.md", "/about", Map.of()); + when(content.byPath(canonical.path())).thenReturn(Optional.of(canonical)); + when(variantResolver.resolveContext(canonical)).thenReturn( + new VariantResolver.VariantContext(canonical, Optional.empty(), List.of()) + ); + + assertThatThrownBy(() -> endpoint.delete(Map.of( + "uri", canonical.path(), + "id", "missing" + ))) + .isInstanceOf(RPCException.class) + .satisfies(exception -> + assertThat(((RPCException) exception).getCode()).isEqualTo(404) + ); + } + + private ContentNode node(String uri, String url, Map data) { + return new ContentNode(uri, url, "about.md", data); + } +} diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java index cc4f8fc31..8d5455083 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java @@ -22,6 +22,11 @@ */ import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.db.Content; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; @@ -33,10 +38,13 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.server.Request; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) class RemoteCallHandlerTest { @@ -78,4 +86,55 @@ void buildErrorResult_fallsBackToCodeMinusOne_forGenericException() throws Excep assertThat(result.error()).isEqualTo(new RPCError("boom")); assertThat(result.error().code()).isEqualTo(-1); } + + @Test + void contentUriHeaderAddsCurrentNodeFeature() throws Exception { + var context = new RequestContext(); + var db = mock(DB.class); + var content = mock(Content.class); + var request = mock(Request.class); + var headers = mock(HttpFields.class); + var node = new ContentNode( + ".variants/about/summer/about.md", + "/about", + "about.md", + Map.of() + ); + when(moduleContext.has(DBFeature.class)).thenReturn(true); + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getContent()).thenReturn(content); + when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn(node.uri()); + when(content.byUri(node.uri())).thenReturn(Optional.of(node)); + + var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); + var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); + method.setAccessible(true); + method.invoke(handler, request); + + assertThat(context.get(CurrentNodeFeature.class).node()).isEqualTo(node); + } + + @Test + void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { + var context = new RequestContext(); + var db = mock(DB.class); + var content = mock(Content.class); + var request = mock(Request.class); + var headers = mock(HttpFields.class); + when(moduleContext.has(DBFeature.class)).thenReturn(true); + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getContent()).thenReturn(content); + when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn("unknown.md"); + when(content.byUri("unknown.md")).thenReturn(Optional.empty()); + when(content.byPath("unknown.md")).thenReturn(Optional.empty()); + + var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); + var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); + method.setAccessible(true); + method.invoke(handler, request); + + assertThat(context.has(CurrentNodeFeature.class)).isFalse(); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java index eb6f2d562..bf58e173d 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/json/UIGsonProviderTest.java @@ -38,12 +38,17 @@ public UIGsonProviderTest() { @Test void testContentSerializationIncludesInterfaceProperties() { - var content = new RemoteFileEnpoints.Content("readme.md", "/docs/readme.md"); + var content = new RemoteFileEnpoints.Content( + "readme.md", + "/docs/readme.md", + "Read me" + ); String json = UIGsonProvider.INSTANCE.toJson(content); JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); Assertions.assertThat(obj.get("name").getAsString()).isEqualTo("readme.md"); Assertions.assertThat(obj.get("uri").getAsString()).isEqualTo("/docs/readme.md"); + Assertions.assertThat(obj.get("title").getAsString()).isEqualTo("Read me"); Assertions.assertThat(obj.get("directory").getAsBoolean()).isFalse(); Assertions.assertThat(obj.get("media").getAsBoolean()).isFalse(); Assertions.assertThat(obj.get("content").getAsBoolean()).isTrue(); diff --git a/test-server/hosts/demo/content/.variants/about/summer/about.md b/test-server/hosts/demo/content/.variants/about/summer/about.md new file mode 100644 index 000000000..6ac2c3baf --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/summer/about.md @@ -0,0 +1,21 @@ +--- +title: About summer campaign +template: default.html +parent: + text: testsdf +count: 46 +description: desc +features: [ + ] +background_color: '#000000' +range_test: 0 +unpublish_date: null +status: published +publish_date: null +translations: + de: /ueber +--- + +# About summer campaign + +back to [index](/) diff --git a/test-server/hosts/demo/content/.variants/about/variants.yaml b/test-server/hosts/demo/content/.variants/about/variants.yaml new file mode 100644 index 000000000..f2dd60758 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/variants.yaml @@ -0,0 +1 @@ +selector: date-range diff --git a/test-server/hosts/demo/content/.variants/about/winter-campain/about.md b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md new file mode 100644 index 000000000..3c3061c16 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/about/winter-campain/about.md @@ -0,0 +1,11 @@ +--- +template: default.html +createdAt: 2026-07-29T11:53:35.309Z +createdBy: test +title: Winter Campaign +status: draft +--- + +# About winter + +back to [index](/) diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md new file mode 100644 index 000000000..076781d86 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.bla.md @@ -0,0 +1,31 @@ +--- +title: Startseite +template: section.html +search: + index: false +description: my new descritpion for that awesome section-lol +parent: + text: text for a section +layout: + order: 0 +media_url: compass-7592444_1920.jpg +status: published +object: + values: + - title: section 1-1 + description: in section 1 + features: search + - title: New Item + description: asdads + features: export +about: its all about content +unpublish_date: null +publish_date: null +about1: more content more better +--- + +# This is a section: summer + +And no i can edit the content + +blabladdddsd asd diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md new file mode 100644 index 000000000..c8f4ef63f --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.other.md @@ -0,0 +1,20 @@ +--- +template: section.html +layout: + order: 3 +parent: + text: another parent text +description: another description +status: draft +about: "thats the about text \nand we also support **multilines**\n\nhere" +unpublish_date: null +publish_date: null +object: + values: + - title: platz 1 + description: tolles ding + features: search +title: '' +--- + +another section diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md new file mode 100644 index 000000000..022486910 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test.md @@ -0,0 +1,11 @@ +--- +template: section.html +description: sec descriptione +layout: + order: 1 +parent: + text: sec parent text +status: draft +--- + +test diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md new file mode 100644 index 000000000..b8749083c --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.asection.test1.md @@ -0,0 +1,19 @@ +--- +template: section.html +description: test23 +layout: + order: 2 +status: draft +parent: + text: '' +object: + values: [ + ] +about: about normal +unpublish_date: null +about1: about 1111 +title: '' +publish_date: null +--- + +das ist der inhalt2 diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.md b/test-server/hosts/demo/content/.variants/index/summer/index.md new file mode 100644 index 000000000..b5cf3699f --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.md @@ -0,0 +1,57 @@ +--- +template: start.html +createdAt: 2026-07-29T12:06:01.530Z +createdBy: test +title: Summer time +status: draft +features: [ + ] +object: + values: [ + ] +taxonomy: + tags: [ + ] +background_color: '#000000' +range_test: 0 +seo: + description: '' +linked_page: '' +media_url: '' +--- + +# Demo Project (summer) + +![TestBild!](/media/images/test.jpg?format=small) + +That's a demo page with some extra features to show the manager application! + +Hello world! + +Here some content! + +Hello: [[cms:username]][[/cms:username]] +Theme: [[ext:theme_name]][[/ext:theme_name]] + +[about](/about) + +[this is a new page](/this-is-a-new-page) + + +```java +// its a comment +System.out.println("Hello world!"); +``` + +### say hello +[[ext:say_hello name="CondationCMS" /]] + + +### test ShortCode with content +--- +[[ext:bold_content]]This content will be bold[[/ext:bold_content]] +--- + + +### example from module +[[ext:example /]] diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md new file mode 100644 index 000000000..142b9cee3 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.bla.md @@ -0,0 +1,30 @@ +--- +title: Startseite +template: section_v.html +search: + index: false +description: my new descritpion for that awesome section-lol +parent: + text: text for a section +layout: + order: 0 +status: published +object: + values: + - title: section 1-1 + description: in section 1 + features: search + - title: New Item + description: asdads + features: export +about: its all about content +unpublish_date: null +publish_date: null +about1: more content more better +--- + +# This is a section: bla + +And no i can edit the content + +blabladdddsd asd diff --git a/test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md new file mode 100644 index 000000000..12d03f08b --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/summer/index.vsection.other.md @@ -0,0 +1,20 @@ +--- +template: section_v.html +layout: + order: 1 +parent: + text: another parent text +description: another description +status: draft +about: "thats the about text \nand we also support **multilines**\n\nhere" +unpublish_date: null +publish_date: null +object: + values: + - title: platz 1 + description: tolles ding + features: search +title: '' +--- + +another section diff --git a/test-server/hosts/demo/content/about.md b/test-server/hosts/demo/content/about.md index 524fed68b..33f29b01d 100644 --- a/test-server/hosts/demo/content/about.md +++ b/test-server/hosts/demo/content/about.md @@ -1,19 +1,19 @@ --- -title: 'New of: start.html' template: default.html parent: text: testsdf -count: 46 -description: desc features: [ ] background_color: '#000000' range_test: 0 -unpublish_date: null -status: published -publish_date: null translations: de: /ueber +count: 46 +description: desc +unpublish_date: null +title: About +publish_date: null +status: published --- # About diff --git a/test-server/hosts/demo/content/index.asection.bla.md b/test-server/hosts/demo/content/index.asection.bla.md index 2a3f8cdc9..326cafdfd 100644 --- a/test-server/hosts/demo/content/index.asection.bla.md +++ b/test-server/hosts/demo/content/index.asection.bla.md @@ -24,7 +24,7 @@ publish_date: null about1: more content more better --- -# This is a section: bla +# This is a section: bla bla And no i can edit the content