From 27c9c3777d90087324fb346601e65aa3b6bcb8f6 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Sun, 13 Sep 2026 19:34:04 +0200 Subject: [PATCH 1/8] repository abstraction --- .../cms/api/content/ContentParser.java | 13 + .../com/condation/cms/api/db/Content.java | 9 + .../com/condation/cms/api/db/ContentNode.java | 11 + .../cms/api/repository/ContentDocument.java | 32 ++ .../cms/api/repository/ContentRepository.java | 73 +++++ .../cms/api/repository/ContentResource.java | 47 +++ .../cms/api/repository/ContentStore.java | 45 +++ .../condation/cms/api/repository/Section.java | 40 +++ .../cms/api/variants/VariantContext.java | 35 +++ .../cms/content/ContentRenderer.java | 24 +- .../cms/content/ContentResolver.java | 43 ++- .../cms/content/DefaultContentParser.java | 56 +++- .../cms/content/DefaultContentRenderer.java | 214 ++++++++------ .../cms/content/TaxonomyResolver.java | 47 +-- .../cms/content/VariantResolver.java | 141 --------- ...ariantSelectorConfigurationRepository.java | 12 +- .../condation/cms/content/ViewResolver.java | 38 ++- .../AbstractCurrentNodeFunction.java | 36 +-- .../functions/list/NodeListFunction.java | 26 +- .../list/NodeListFunctionBuilder.java | 14 +- .../navigation/NavigationFunction.java | 13 +- .../functions/query/QueryFunction.java | 24 +- .../cms/content/views/NodeResolver.java | 12 +- .../cms/content/views/ViewParser.java | 6 +- .../cms/content/views/model/View.java | 14 +- .../cms/content/ContentResolverTest.java | 84 ++++++ .../cms/content/VariantResolverTest.java | 74 ----- ...ntSelectorConfigurationRepositoryTest.java | 10 +- .../navigation/NavigationFunctionITest.java | 10 +- .../navigation/NavigationFunctionTest.java | 5 +- .../FileSystemContentRepository.java | 275 ++++++++++++++++++ .../filesystem/FileSystemContentStore.java | 110 +++++++ .../FileSystemContentRepositoryTest.java | 193 ++++++++++++ .../FileSystemContentStoreTest.java | 82 ++++++ .../cms/server/configs/SiteModule.java | 53 ++-- .../functions/impl/NodeFunction.java | 13 +- distribution/scripts/update-cms.sh | 241 +++++++++++++++ .../com/condation/cms/SectionEntriesTest.java | 20 +- .../cms/content/ContentRendererNGTest.java | 23 +- .../cms/content/ContentResolverTest.java | 16 +- .../cms/content/TaxonomyResolverTest.java | 7 +- .../cms/content/views/ViewParserTest.java | 19 +- ...ystemContentRepositoryIntegrationTest.java | 144 +++++++++ .../list/NodeListFunctionBuilderNGTest.java | 13 +- .../navigation/NavigationFunctionNGTest.java | 13 +- .../functions/query/QueryFunctionTest.java | 9 +- .../api/handlers/v1/NavigationHandler.java | 7 +- .../RemoteContentEndpointsExtension.java | 7 +- .../remotemethods/RemoteVariantEndpoint.java | 12 +- .../RemoteVariantEndpointTest.java | 29 +- 50 files changed, 1939 insertions(+), 555 deletions(-) create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/ContentDocument.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/ContentResource.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/ContentStore.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/Section.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java delete mode 100644 cms-content/src/main/java/com/condation/cms/content/VariantResolver.java create mode 100644 cms-content/src/test/java/com/condation/cms/content/ContentResolverTest.java delete mode 100644 cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java create mode 100644 cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java create mode 100644 cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentStore.java create mode 100644 cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java create mode 100644 cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java create mode 100644 distribution/scripts/update-cms.sh create mode 100644 integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java diff --git a/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java b/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java index b642f3aa5..75e7a7605 100644 --- a/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java +++ b/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java @@ -22,6 +22,7 @@ */ import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.repository.ContentResource; import java.io.IOException; import java.io.Serializable; import java.util.Map; @@ -36,8 +37,20 @@ public interface ContentParser { void clearCache(String uri); + Content parse(final ContentResource contentResource) throws IOException; + + Map parseMeta(final ContentResource contentResource) throws IOException; + + /** + * @deprecated use {@link #parse(ContentResource)} instead + */ + @Deprecated(since = "8.3.0", forRemoval = false) Content parse(final ReadOnlyFile contentFile) throws IOException; + /** + * @deprecated use {@link #parseMeta(ContentResource)} instead + */ + @Deprecated(since = "8.3.0", forRemoval = false) Map parseMeta(final ReadOnlyFile contentFile) throws IOException; record ContentRecord(String content, String meta) implements Serializable {} 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 467a25a82..a9c59aca4 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 @@ -33,27 +33,36 @@ * @author thmar */ public interface Content { + @Deprecated(since = "8.3.0", forRemoval = false) boolean isVisible (String uri); + @Deprecated(since = "8.3.0", forRemoval = false) boolean isVisible (ContentNode node); + @Deprecated(since = "8.3.0", forRemoval = false) List listSectionEntries(final ReadOnlyFile contentFile); + @Deprecated(since = "8.3.0", forRemoval = false) List listContent(final ReadOnlyFile base, final String start); + @Deprecated(since = "8.3.0", forRemoval = false) List listDirectories(final ReadOnlyFile base, final String start); @Deprecated(since = "8.3.0") Optional byUri (final String uri); + @Deprecated(since = "8.3.0", forRemoval = false) Optional byPath (final String path); + @Deprecated(since = "8.3.0", forRemoval = false) Optional byUrl (final String url); Optional> getMeta(final String path); + @Deprecated(since = "8.3.0", forRemoval = false) public ContentQuery query(final BiFunction nodeMapper); + @Deprecated(since = "8.3.0", forRemoval = false) public ContentQuery query(final String startURI, final BiFunction nodeMapper); default List searchByTitle (@NonNull String input) { 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 570625773..42d08e713 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 @@ -126,10 +126,20 @@ public boolean isSectionEntry() { return SectionUtil.isSectionEntry(name); } + /** + * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; + * this method depends on the filesystem path convention + */ + @Deprecated(since = "8.3.0", forRemoval = false) public boolean isVariant() { return variantPathSegment() >= 0; } + /** + * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; + * this method depends on the filesystem path convention + */ + @Deprecated(since = "8.3.0", forRemoval = false) public Optional variantId() { var pathParts = normalizedPathParts(); var variantSegment = variantPathSegment(pathParts); @@ -143,6 +153,7 @@ public Optional variantId() { * Returns the indexed path of the canonical page represented by this * variant. */ + @Deprecated(since = "8.3.0", forRemoval = false) public Optional originalUri() { var pathParts = normalizedPathParts(); var variantSegment = variantPathSegment(pathParts); diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/ContentDocument.java b/cms-api/src/main/java/com/condation/cms/api/repository/ContentDocument.java new file mode 100644 index 000000000..1255b0f37 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/ContentDocument.java @@ -0,0 +1,32 @@ +package com.condation.cms.api.repository; + +/*- + * #%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 content node together with its parsed body. + * + * @author thmar + */ +public record ContentDocument(ContentNode node, String content) { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java b/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java new file mode 100644 index 000000000..11c7fc213 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java @@ -0,0 +1,73 @@ +package com.condation.cms.api.repository; + +/*- + * #%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.db.ContentQuery; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantContext; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; + +/** + * Backend-neutral access to indexed CMS content. + * + * A repository exposes content semantics such as indexed queries and the + * relation between a page and its sections. It deliberately does not expose + * how those entries are physically stored. + * + * @author thmar + */ +public interface ContentRepository { + + Optional get(String path); + + Optional findByUrl(String url); + + Optional load(ContentNode node) throws IOException; + + List children(String path); + + List directories(String path); + + boolean isVisible(ContentNode node); + + List
sections(ContentNode owner) throws IOException; + + List
sections(ContentNode owner, String sectionName) throws IOException; + + List variants(ContentNode node); + + Optional variant(ContentNode node, String variantId); + + VariantContext variantContext(ContentNode node); + + ContentQuery query(); + + ContentQuery query(String startPath); + + ContentQuery query(BiFunction nodeMapper); + + ContentQuery query(String startPath, BiFunction nodeMapper); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/ContentResource.java b/cms-api/src/main/java/com/condation/cms/api/repository/ContentResource.java new file mode 100644 index 000000000..324099c3a --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/ContentResource.java @@ -0,0 +1,47 @@ +package com.condation.cms.api.repository; + +/*- + * #%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.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +/** + * A raw resource stored below a {@link ContentStore}'s content root. + * + * @author thmar + */ +public interface ContentResource { + + String path(); + + boolean directory(); + + Instant lastModified() throws IOException; + + default String content() throws IOException { + return content(StandardCharsets.UTF_8); + } + + String content(Charset charset) throws IOException; +} diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/ContentStore.java b/cms-api/src/main/java/com/condation/cms/api/repository/ContentStore.java new file mode 100644 index 000000000..324ced84d --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/ContentStore.java @@ -0,0 +1,45 @@ +package com.condation.cms.api.repository; + +/*- + * #%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.io.IOException; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Backend-neutral, read-only access to raw content resources. + * + * Paths are relative to the content root and use {@code /} as separator. + * Domain-level lookup and querying belongs to {@link ContentRepository}. + * + * @author thmar + */ +public interface ContentStore { + + Optional get(String path); + + Stream list(String path) throws IOException; + + default boolean exists(String path) { + return get(path).isPresent(); + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/Section.java b/cms-api/src/main/java/com/condation/cms/api/repository/Section.java new file mode 100644 index 000000000..9ac8a8137 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/Section.java @@ -0,0 +1,40 @@ +package com.condation.cms.api.repository; + +/*- + * #%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.Map; + +/** + * A section belonging to a content node. + * + * The identifiers are backend-neutral strings. A filesystem repository may + * use relative paths while a database repository may use database ids. + * + * @author thmar + */ +public record Section( + String id, + String owner, + String name, + Map data, + String content) { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java b/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java new file mode 100644 index 000000000..26e647cac --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java @@ -0,0 +1,35 @@ +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 java.util.List; +import java.util.Optional; + +/** + * Canonical node, active variant and all sibling variants for a content node. + */ +public record VariantContext( + ContentNode canonical, + Optional activeVariantId, + List variants) { +} diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java index ccfe0e37d..b26f4f552 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java @@ -28,13 +28,13 @@ import com.condation.cms.api.db.taxonomy.Taxonomy; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; -import com.condation.cms.api.template.TemplateEngine; +import com.condation.cms.api.repository.ContentDocument; +import com.condation.cms.api.repository.Section; import com.condation.cms.content.views.model.View; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.Consumer; /** * @@ -42,11 +42,12 @@ */ public interface ContentRenderer { - String render(final ReadOnlyFile contentFile, final RequestContext context) throws IOException; + String render(final ContentDocument document, final RequestContext context, + final Map> sectionEntries) throws IOException; - String render(final ReadOnlyFile contentFile, final RequestContext context, final Map> sectionEntries) throws IOException; - - String render(final ReadOnlyFile contentFile, final RequestContext context, final Map> sectionEntries, final Map meta, final String markdownContent, final Consumer modelExtending) throws IOException; + Map> renderSections( + final List
sections, + final RequestContext context) throws IOException; String renderCollection( final ReadOnlyFile collectionFile, @@ -55,10 +56,13 @@ String renderCollection( final String template, final RequestContext context) throws IOException; - Map> renderSectionEntries(final List sectionEntryNodes, final RequestContext context) throws IOException; + String renderTaxonomyContent(final Optional document, + final Taxonomy taxonomy, Optional taxonomyValue, + final RequestContext context, final Map meta, + final Page page, + Map> sectionEntries) throws IOException; - String renderTaxonomy(final Optional contentFileOpt, final Taxonomy taxonomy, Optional taxonomyValue, final RequestContext context, final Map meta, final Page page, Map> sectionEntries) throws IOException; + String renderView(final ContentDocument document, final View view, + final RequestContext requestContext, final Page page) throws IOException; - String renderView(final ReadOnlyFile viewFile, final View view, final ContentNode contentNode, final RequestContext requestContext, final Page page) throws IOException; - } 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 19a6e8da3..f5ee1a077 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 @@ -26,12 +26,11 @@ import com.condation.cms.api.content.DefaultContentResponse; 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.repository.ContentRepository; import com.condation.cms.api.variants.Variant; import com.condation.cms.api.variants.VariantSelector; import com.condation.cms.api.utils.HTTPUtil; @@ -51,25 +50,17 @@ public class ContentResolver { private final ContentRenderer contentRenderer; - private final DB db; - - private final VariantResolver variantResolver; + private final ContentRepository contentRepository; 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, + ContentRepository contentRepository, VariantSelector variantSelector ) { this.contentRenderer = contentRenderer; - this.db = db; - this.variantResolver = variantResolver; + this.contentRepository = contentRepository; this.variantSelector = variantSelector; } @@ -85,13 +76,13 @@ private Optional getContent(final RequestContext context, boole final String uri = context.get(RequestFeature.class).uri(); var path = ContentResolvingStrategy.uriToPath(uri); - Optional contentNodeOpt = db.getContent().byUrl(uri); + Optional contentNodeOpt = contentRepository.findByUrl(uri); // handle alias ContentNode contentNode = null; Optional aliasRedirectUrl = Optional.empty(); if (contentNodeOpt.isEmpty()) { - var query = db.getContent().query((node, count) -> node); + var query = contentRepository.query(); var result = query.whereContains(Constants.MetaFields.ALIASES, "/" + path).get(); if (!result.isEmpty()) { contentNode = result.getFirst(); @@ -105,7 +96,7 @@ private Optional getContent(final RequestContext context, boole return Optional.empty(); } - if (checkVisibility && !db.getContent().isVisible(contentNode)) { + if (checkVisibility && !contentRepository.isVisible(contentNode)) { return Optional.empty(); } @@ -124,27 +115,27 @@ private Optional getContent(final RequestContext context, boole var selection = variantSelector.select( contentNode, - variantResolver.getVariants(contentNode), + contentRepository.variants(contentNode), context ); var selectedNode = selection.variant() .map(Variant::node) .filter(node -> !checkVisibility || context.has(IsPreviewFeature.class) - || NodeVisibility.isVisible(node)) + || contentRepository.isVisible(node)) .orElse(contentNode); - var contentFile = db.getFileSystem().contentBase().resolve(selectedNode.path()); - context.add(CurrentNodeFeature.class, new CurrentNodeFeature(selectedNode)); try { - - List sectionEntries = db.getContent().listSectionEntries(contentFile); - - Map> renderedSectionEntries = contentRenderer.renderSectionEntries(sectionEntries, context); - - var content = contentRenderer.render(contentFile, context, renderedSectionEntries); + var document = contentRepository.load(selectedNode); + if (document.isEmpty()) { + return Optional.empty(); + } + var sections = contentRepository.sections(selectedNode); + Map> renderedSectionEntries = + contentRenderer.renderSections(sections, context); + var content = contentRenderer.render(document.get(), context, renderedSectionEntries); var contentType = selectedNode.contentType(); diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java b/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java index 01b0ead4c..1e7600924 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java @@ -23,9 +23,11 @@ import com.condation.cms.api.cache.ICache; import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.repository.ContentResource; import com.google.common.base.Strings; import java.io.IOException; import java.util.Collections; +import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.yaml.snakeyaml.Yaml; @@ -60,23 +62,43 @@ public void clearCache(String uri) { } @Override + public Content parse(final ContentResource contentResource) throws IOException { + return parse(contentResource.path(), contentResource.content()); + } + + @Override + public Map parseMeta(final ContentResource contentResource) throws IOException { + return _parseMeta(readContent(contentResource.content())); + } + + @Override + @Deprecated(since = "8.3.0", forRemoval = false) public Content parse(final ReadOnlyFile contentFile) throws IOException { - - if (contentCache != null && contentCache.contains(contentFile.relativePath())) { - return contentCache.get(contentFile.relativePath()); - } - - ContentRecord readContent = readContent(contentFile); + if (contentCache != null && contentCache.contains(contentFile.relativePath())) { + return contentCache.get(contentFile.relativePath()); + } + var readContent = readContent(contentFile.getAllLines()); var content = new Content(readContent.content(), _parseMeta(readContent)); - - if (contentCache != null) { - contentCache.put(contentFile.uri(), content); - } - - return content; + if (contentCache != null) { + contentCache.put(contentFile.uri(), content); + } + return content; } + private Content parse(String cacheKey, String rawContent) { + if (contentCache != null && contentCache.contains(cacheKey)) { + return contentCache.get(cacheKey); + } + + ContentRecord readContent = readContent(rawContent); + var content = new Content(readContent.content(), _parseMeta(readContent)); + if (contentCache != null) { + contentCache.put(cacheKey, content); + } + return content; + } + private Map _parseMeta(ContentRecord content) { if (Strings.isNullOrEmpty(content.meta().trim())) { return Collections.emptyMap(); @@ -90,14 +112,18 @@ private Map _parseMeta(ContentRecord content) { } @Override + @Deprecated(since = "8.3.0", forRemoval = false) public Map parseMeta(final ReadOnlyFile contentFile) throws IOException { - ContentRecord readContent = readContent(contentFile); + ContentRecord readContent = readContent(contentFile.getAllLines()); return _parseMeta(readContent); } - private ContentRecord readContent(final ReadOnlyFile contentFile) throws IOException { - var fileContent = contentFile.getAllLines(); + private ContentRecord readContent(final String rawContent) { + return readContent(rawContent.lines().toList()); + } + + private ContentRecord readContent(final List fileContent) { StringBuilder contentBuilder = new StringBuilder(); StringBuilder metaBuilder = new StringBuilder(); 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 c0d62d11d..5dc54af6a 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 @@ -23,7 +23,6 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.SiteProperties; -import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; @@ -44,9 +43,11 @@ import com.condation.cms.api.messages.MessageSource; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentDocument; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.Section; import com.condation.cms.api.template.TemplateEngine; -import com.condation.cms.api.utils.PathUtil; -import com.condation.cms.api.utils.SectionUtil; +import com.condation.cms.api.utils.MapUtil; import com.condation.cms.content.pipeline.ContentPipelineFactory; import com.condation.cms.content.views.model.View; import com.condation.cms.api.content.MapAccess; @@ -73,22 +74,33 @@ import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Supplier; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * * @author t.marx */ -@RequiredArgsConstructor @Slf4j public class DefaultContentRenderer implements ContentRenderer { - private final ContentParser contentParser; private final Supplier templates; private final DB db; private final SiteProperties siteProperties; private final ModuleManager moduleManager; + private final ContentRepository contentRepository; + + public DefaultContentRenderer( + Supplier templates, + DB db, + SiteProperties siteProperties, + ModuleManager moduleManager, + ContentRepository contentRepository) { + this.templates = templates; + this.db = db; + this.siteProperties = siteProperties; + this.moduleManager = moduleManager; + this.contentRepository = contentRepository; + } private record ResolvedRenderInput( String uri, @@ -99,45 +111,119 @@ private record ResolvedRenderInput( } @Override - public String render(final ReadOnlyFile contentFile, final RequestContext context) throws IOException { - return render(contentFile, context, Collections.emptyMap()); + public String render( + ContentDocument document, + RequestContext context, + Map> sectionEntries) throws IOException { + var contentFile = db.getFileSystem().contentBase().resolve(document.node().path()); + return renderResolved( + contentFile, + context, + new ResolvedRenderInput( + document.node().path(), + sectionEntries, + document.node().data(), + document.content(), + Optional.of(document.node())), + model -> { + }); } @Override - public String render(final ReadOnlyFile contentFile, final RequestContext context, final Map> sectionEntries) throws IOException { - var content = contentParser.parse(contentFile); + public Map> renderSections( + List
sections, + RequestContext context) throws IOException { + if (sections.isEmpty()) { + return Collections.emptyMap(); + } - return render(contentFile, context, sectionEntries, content.meta(), content.content(), (model) -> { - }); + Map> renderedSections = new HashMap<>(); + for (var section : sections) { + try { + var sectionFile = db.getFileSystem().contentBase().resolve(section.id()); + var sectionNode = contentRepository.get(section.id()); + var renderedContent = renderResolved( + sectionFile, + context, + new ResolvedRenderInput( + section.id(), + Collections.emptyMap(), + section.data(), + section.content(), + sectionNode), + model -> { + }); + var index = MapUtil.getValue( + section.data(), + Constants.MetaFields.LAYOUT_ORDER, + Constants.DEFAULT_SECTION_ENTRY_LAYOUT_ORDER); + renderedSections.computeIfAbsent(section.name(), ignored -> new ArrayList<>()) + .add(new SectionEntry(section.name(), index, renderedContent, section.data())); + } catch (Exception ex) { + log.error("error rendering section {}", section.id(), ex); + } + } + renderedSections.values().forEach(entries -> + entries.sort((first, second) -> Integer.compare(first.index(), second.index()))); + return renderedSections; } @Override - public String renderTaxonomy( - final Optional contentFileOpt, - final Taxonomy taxonomy, Optional taxonomyValue, final RequestContext context, - final Map meta, final Page page, final Map> sectionEntries) throws IOException { - var contentFile = contentFileOpt.orElseGet(() -> db.getFileSystem().contentBase().resolve("index.md")); - var content = contentFileOpt.isPresent() ? - contentParser.parse(contentFileOpt.get()).content() - : ""; - - return render(contentFile, context, sectionEntries, meta, content, (model) -> { - model.values.put("taxonomy", taxonomy); - model.values.put("taxonomy_values", db.getTaxonomies().values(taxonomy)); - if (taxonomyValue.isPresent()) { - model.values.put("taxonomy_value", taxonomyValue.get()); - } - model.values.put("page", page); + public String renderTaxonomyContent( + Optional document, + Taxonomy taxonomy, + Optional taxonomyValue, + RequestContext context, + Map meta, + Page page, + Map> sectionEntries) throws IOException { + var contentFile = document + .map(ContentDocument::node) + .map(ContentNode::path) + .map(path -> db.getFileSystem().contentBase().resolve(path)) + .orElseGet(() -> db.getFileSystem().contentBase().resolve("index.md")); + var rawContent = document.map(ContentDocument::content).orElse(""); + var node = document.map(ContentDocument::node); + return renderResolved( + contentFile, + context, + new ResolvedRenderInput( + node.map(ContentNode::path).orElse("index.md"), + sectionEntries, + meta, + rawContent, + node), + model -> extendTaxonomyModel(model, taxonomy, taxonomyValue, page)); + } - }); + private void extendTaxonomyModel( + TemplateEngine.Model model, + Taxonomy taxonomy, + Optional taxonomyValue, + Page page) { + model.values.put("taxonomy", taxonomy); + model.values.put("taxonomy_values", db.getTaxonomies().values(taxonomy)); + taxonomyValue.ifPresent(value -> model.values.put("taxonomy_value", value)); + model.values.put("page", page); } @Override - public String renderView(final ReadOnlyFile viewFile, final View view, final ContentNode contentNode, final RequestContext requestContext, final Page page) throws IOException { - return render(viewFile, requestContext, Collections.emptyMap(), - contentNode.data(), "", (model) -> { - model.values.put("page", page); - }); + public String renderView( + ContentDocument document, + View view, + RequestContext requestContext, + Page page) throws IOException { + var viewFile = db.getFileSystem().contentBase().resolve(document.node().path()); + return renderResolved( + viewFile, + requestContext, + new ResolvedRenderInput( + document.node().path(), + Collections.emptyMap(), + document.node().data(), + "", + Optional.of(document.node())), + model -> model.values.put("page", page)); } private String renderContent(final String rawContent, final RequestContext context, final TemplateEngine.Model model) { @@ -170,26 +256,6 @@ public String renderCollection( }); } - @Override - public String render(final ReadOnlyFile contentFile, final RequestContext context, - final Map> sectionEntries, - final Map meta, final String rawContent, final Consumer modelExtending - ) throws IOException { - var uri = PathUtil.toRelativeFile(contentFile, db.getFileSystem().contentBase()); - - Optional contentNode = db.getContent().byUri(uri); - return renderResolved( - contentFile, - context, - new ResolvedRenderInput( - uri, - sectionEntries, - meta, - rawContent, - contentNode), - modelExtending); - } - private String renderResolved( ReadOnlyFile contentFile, RequestContext context, @@ -292,19 +358,20 @@ protected QueryFunction createQueryFunction(final ReadOnlyFile contentFile, fina .extensions(ContentQueryOperatorExtensionPoint.class) .forEach(extension -> customOperators.put(extension.getOperator(), extension.getPredicate())); - var queryFn = new QueryFunction(db, contentFile, context, customOperators); + var queryFn = new QueryFunction( + db, contentRepository, contentFile, context, customOperators); queryFn.setContentType(siteProperties.defaultContentType()); return queryFn; } protected NodeListFunctionBuilder createNodeListFunction(final ReadOnlyFile contentFile, final RequestContext context) { - var nlFn = new NodeListFunctionBuilder(db, contentFile, context); + var nlFn = new NodeListFunctionBuilder(db, contentRepository, contentFile, context); nlFn.contentType(siteProperties.defaultContentType()); return nlFn; } protected NavigationFunction createNavigationFunction(final ReadOnlyFile contentFile, final RequestContext context) { - var navFn = new NavigationFunction(db, contentFile, context); + var navFn = new NavigationFunction(db, contentRepository, contentFile, context); navFn.contentType(siteProperties.defaultContentType()); return navFn; } @@ -335,37 +402,4 @@ private void extendModel(final TemplateEngine.Model model, Namespace namespace) }); } - @Override - public Map> renderSectionEntries(final List sectionEntryNodes, final RequestContext context) throws IOException { - - if (sectionEntryNodes.isEmpty()) { - return Collections.emptyMap(); - } - - Map> sectionEntries = new HashMap<>(); - - final ReadOnlyFile contentBase = db.getFileSystem().contentBase(); - sectionEntryNodes.forEach(node -> { - try { - var sectionEntryPath = contentBase.resolve(node.uri()); - var content = render(sectionEntryPath, context); - var name = SectionUtil.getSectionName(node.name()); - var index = node.getMetaValue(Constants.MetaFields.LAYOUT_ORDER, Constants.DEFAULT_SECTION_ENTRY_LAYOUT_ORDER); - - if (!sectionEntries.containsKey(name)) { - sectionEntries.put(name, new ArrayList<>()); - } - - sectionEntries.get(name).add(new SectionEntry(name, index, content, node.data())); - } catch (Exception ex) { - log.error("error render sectionEntries", ex); - } - - }); - - sectionEntries.values().forEach(list -> list.sort((s1, s2) -> Integer.compare(s1.index(), s2.index()))); - - return sectionEntries; - } - } diff --git a/cms-content/src/main/java/com/condation/cms/content/TaxonomyResolver.java b/cms-content/src/main/java/com/condation/cms/content/TaxonomyResolver.java index 2765e5eb1..8146374c6 100644 --- a/cms-content/src/main/java/com/condation/cms/content/TaxonomyResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/TaxonomyResolver.java @@ -22,41 +22,49 @@ */ import com.condation.cms.api.Constants; -import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.content.TaxonomyResponse; -import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.taxonomy.Taxonomy; -import com.condation.cms.api.feature.features.ContentParserFeature; import com.condation.cms.api.feature.features.CurrentTaxonomyFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; -import com.condation.cms.core.content.ContentResolvingStrategy; +import com.condation.cms.api.repository.ContentDocument; +import com.condation.cms.api.repository.ContentRepository; import com.google.inject.Inject; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * * @author t.marx */ -@RequiredArgsConstructor(onConstructor = @__({@Inject})) @Slf4j public class TaxonomyResolver { private final ContentRenderer contentRenderer; private final DB db; private final ContentNodeMapper contentNodeMapper; - + private final ContentRepository contentRepository; + + @Inject + public TaxonomyResolver( + ContentRenderer contentRenderer, + DB db, + ContentNodeMapper contentNodeMapper, + ContentRepository contentRepository) { + this.contentRenderer = contentRenderer; + this.db = db; + this.contentNodeMapper = contentNodeMapper; + this.contentRepository = contentRepository; + } + private Optional getTaxonomy(final RequestContext context) { var uri = context.get(RequestFeature.class).uri(); if ("/".equals(uri)) { @@ -117,23 +125,18 @@ public Optional getTaxonomyResponse(final RequestContext conte context.add(CurrentTaxonomyFeature.class, new CurrentTaxonomyFeature(taxonomy, value, meta, resultPage)); - Optional contentFileOpt = ContentResolvingStrategy.resolve(context.get(RequestFeature.class).uri(), db); - + Optional document = Optional.empty(); Map> sectionEntries = Collections.emptyMap(); - if (contentFileOpt.isPresent()) { - var contentFile = contentFileOpt.get(); - - var contentParser = context.get(ContentParserFeature.class).contentParser(); - var content = contentParser.parse(contentFile); - - meta.putAll(content.meta()); - - List sectionEntryList = db.getContent().listSectionEntries(contentFile); - - sectionEntries = contentRenderer.renderSectionEntries(sectionEntryList, context); + var contentNode = contentRepository.findByUrl(context.get(RequestFeature.class).uri()); + if (contentNode.isPresent()) { + document = contentRepository.load(contentNode.get()); + meta.putAll(contentNode.get().data()); + var sections = contentRepository.sections(contentNode.get()); + sectionEntries = contentRenderer.renderSections(sections, context); } - String content = contentRenderer.renderTaxonomy(contentFileOpt, taxonomy, value, context, meta, resultPage, sectionEntries); + String content = contentRenderer.renderTaxonomyContent( + document, taxonomy, value, context, meta, resultPage, sectionEntries); return Optional.of(new TaxonomyResponse(content, taxonomy)); } catch (Exception ex) { 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 deleted file mode 100644 index 64b9fb94a..000000000 --- a/cms-content/src/main/java/com/condation/cms/content/VariantResolver.java +++ /dev/null @@ -1,141 +0,0 @@ -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 index 24747366b..967d66ec5 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java @@ -24,12 +24,12 @@ 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.repository.ContentRepository; 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; @@ -39,7 +39,6 @@ * Loads and stores the selector configured for a canonical page. */ @Slf4j -@RequiredArgsConstructor public class VariantSelectorConfigurationRepository { public static final String DEFAULT_SELECTOR_ID = "date-range"; @@ -47,7 +46,12 @@ public class VariantSelectorConfigurationRepository { private static final String SELECTOR_PROPERTY = "selector"; private final DB db; - private final VariantResolver variantResolver; + private final ContentRepository contentRepository; + + public VariantSelectorConfigurationRepository(DB db, ContentRepository contentRepository) { + this.db = db; + this.contentRepository = contentRepository; + } public String getSelectorId(ContentNode node) { var configurationFile = configurationFile(node); @@ -82,7 +86,7 @@ public void setSelectorId(ContentNode node, String selectorId) throws IOExceptio } public Path configurationFile(ContentNode node) { - var canonical = variantResolver.resolveContext(node).canonical(); + var canonical = contentRepository.variantContext(node).canonical(); var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); var canonicalFile = contentBase.resolve(canonical.path()); var fileName = canonicalFile.getFileName().toString(); diff --git a/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java b/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java index 056a36b05..9225c4ac6 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java @@ -24,27 +24,35 @@ import com.condation.cms.api.content.DefaultContentResponse; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; -import com.condation.cms.api.feature.features.ContentParserFeature; 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.repository.ContentRepository; import com.condation.cms.content.views.ViewParser; import com.condation.cms.extensions.request.RequestExtensions; import java.util.Optional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /** * * @author t.marx */ -@RequiredArgsConstructor @Slf4j public class ViewResolver { private final ContentRenderer contentRenderer; private final DB db; + private final ContentRepository contentRepository; + + public ViewResolver( + ContentRenderer contentRenderer, + DB db, + ContentRepository contentRepository) { + this.contentRenderer = contentRenderer; + this.db = db; + this.contentRepository = contentRepository; + } public Optional getViewContent(final RequestContext context) { return getViewContent(context, true); @@ -52,37 +60,37 @@ public Optional getViewContent(final RequestContext context) { public Optional getViewContent(final RequestContext context, final boolean checkVisibility) { var requestUrl = context.get(RequestFeature.class).uri(); - var contentNodeOpt = db.getContent().byUrl(requestUrl); + var contentNodeOpt = contentRepository.findByUrl(requestUrl); if (contentNodeOpt.isEmpty()) { return Optional.empty(); } final ContentNode contentNode = contentNodeOpt.get(); - if (checkVisibility && !db.getContent().isVisible(contentNode)) { + if (checkVisibility && !contentRepository.isVisible(contentNode)) { return Optional.empty(); } if (!contentNode.isView()) { return Optional.empty(); } - var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); - if (!contentFile.exists()) { - return Optional.empty(); - } context.add(CurrentNodeFeature.class, new CurrentNodeFeature(contentNode)); try { - var view = ViewParser.parse(contentFile); + var document = contentRepository.load(contentNode); + if (document.isEmpty()) { + return Optional.empty(); + } + var view = ViewParser.parse(document.get().content()); + var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); var page = view.getNodes( - db, - contentFile, - context.get(ContentParserFeature.class).contentParser(), - context.get(RenderContext.class).markdownRenderer(), + db, + contentRepository, + contentFile, context.get(RequestExtensions.class).getContext(), context.get(RequestFeature.class).queryParameters(), context); - var content = contentRenderer.renderView(contentFile, view, contentNode, context, page); + var content = contentRenderer.renderView(document.get(), view, context, page); return Optional.of(new DefaultContentResponse(content, contentNode)); } catch (Exception ex) { log.error(null, ex); diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java index ec69e98e3..df6af628e 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java @@ -20,31 +20,26 @@ * along with this program. If not, see . * #L% */ -import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.IsPreviewFeature; import com.condation.cms.api.mapper.ContentNodeMapper; -import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; -import java.io.IOException; -import java.util.Optional; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.utils.PathUtil; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; /** * * @author t.marx */ @RequiredArgsConstructor -@Slf4j public abstract class AbstractCurrentNodeFunction { protected final DB db; protected final ReadOnlyFile currentNode; - protected final ContentParser contentParser; - protected final MarkdownRenderer markdownRenderer; + protected final ContentRepository contentRepository; protected final ContentNodeMapper contentNodeMapper; protected final RequestContext context; @@ -65,18 +60,19 @@ protected String getPreviewMode() { return IsPreviewFeature.Mode.PREVIEW.getValue(); } - protected Optional parse(ReadOnlyFile node) { - try { - //Path rel = contentBase.relativize(node); - if (node.isDirectory()) { - node = node.resolve("index.md"); - } - var md = contentParser.parse(node); - - return Optional.of(md); - } catch (IOException ex) { - log.error(null, ex); + protected String repositoryPath(ReadOnlyFile base, String path) { + var basePath = PathUtil.toRelativePath(base, db.getFileSystem().contentBase()) + .replace('\\', '/'); + var childPath = path.replace('\\', '/'); + while (childPath.startsWith("./")) { + childPath = childPath.substring(2); + } + while (childPath.startsWith("/")) { + childPath = childPath.substring(1); + } + if (childPath.isEmpty()) { + return basePath; } - return Optional.empty(); + return basePath.isEmpty() ? childPath : basePath + "/" + childPath; } } diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java index 9b9cfc25c..67ffae404 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java @@ -27,10 +27,9 @@ import com.condation.cms.api.db.Page; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; -import com.condation.cms.api.feature.features.ContentParserFeature; -import com.condation.cms.api.feature.features.MarkdownRendererFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import java.util.ArrayList; import java.util.Arrays; @@ -58,18 +57,19 @@ class NodeListFunction extends AbstractCurrentNodeFunction { return true; }; - public NodeListFunction(DB db, ReadOnlyFile currentNode, RequestContext context) { + public NodeListFunction(DB db, ContentRepository contentRepository, + ReadOnlyFile currentNode, RequestContext context) { super( db, currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), + contentRepository, context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); } - public NodeListFunction(DB db, ReadOnlyFile currentNode, RequestContext context, boolean excludeIndexMd) { - this(db, currentNode, context); + public NodeListFunction(DB db, ContentRepository contentRepository, + ReadOnlyFile currentNode, RequestContext context, boolean excludeIndexMd) { + this(db, contentRepository, currentNode, context); this.excludeIndexMd = excludeIndexMd; } @@ -116,13 +116,12 @@ Page getNodes(final String start, int page, int size, int excerptLengt blog\/*\/* */ if (path.contains("*")) { - final ReadOnlyFile contentBase = db.getFileSystem().contentBase(); List relevantPaths = getPaths(baseNode, path); List allContentNodes = new ArrayList<>(); relevantPaths.forEach((metaNode) -> { - List children = db.getContent().listContent(contentBase, metaNode.uri()); + List children = contentRepository.children(metaNode.uri()); allContentNodes.addAll(children); }); @@ -159,9 +158,9 @@ private List getPaths(final ReadOnlyFile base, final String path) { var part = parts[0]; List nodes; if ("*".equals(part)) { - nodes = db.getContent().listDirectories(base, ""); + nodes = contentRepository.directories(repositoryPath(base, "")); } else { - nodes = db.getContent().listDirectories(base, part); + nodes = contentRepository.directories(repositoryPath(base, part)); } if (parts.length > 1) { nodes.forEach((node) -> { @@ -181,11 +180,10 @@ public Page getNodesFromBase(final ReadOnlyFile base, final String sta final Comparator comparator, final Predicate nodeFilter) { try { List nodes = new ArrayList<>(); - final List navNodes = db.getContent() - .listContent(base, start) + final List navNodes = contentRepository + .children(repositoryPath(base, start)) .stream().filter(nodeFilter) .toList(); - final ReadOnlyFile contentBase = db.getFileSystem().contentBase(); long total = navNodes.stream().filter(nodeNameFilter).count(); int skipCount = (page - 1) * pageSize; diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java index 0e4ee5f7d..cbcc425db 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java @@ -26,10 +26,9 @@ import com.condation.cms.api.db.Page; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; -import com.condation.cms.api.feature.features.ContentParserFeature; -import com.condation.cms.api.feature.features.MarkdownRendererFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.utils.NodeUtil; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import java.util.Comparator; @@ -72,16 +71,17 @@ public class NodeListFunctionBuilder extends AbstractCurrentNodeFunction { return filename1.compareTo(filename2); }; - public NodeListFunctionBuilder(DB db, ReadOnlyFile currentNode, RequestContext context) { + public NodeListFunctionBuilder(DB db, ContentRepository contentRepository, + ReadOnlyFile currentNode, RequestContext context) { super( db, currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), + contentRepository, context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); - this.nodeListFunction = new NodeListFunction(db, currentNode, context); - this.nodeListFunctionNoIndex = new NodeListFunction(db, currentNode, context, true); + this.nodeListFunction = new NodeListFunction(db, contentRepository, currentNode, context); + this.nodeListFunctionNoIndex = new NodeListFunction( + db, contentRepository, currentNode, context, true); } public NodeListFunctionBuilder from(String from) { diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java index f931cbc63..1d841cb78 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java @@ -25,13 +25,12 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; -import com.condation.cms.api.feature.features.ContentParserFeature; import com.condation.cms.api.feature.features.HookSystemFeature; -import com.condation.cms.api.feature.features.MarkdownRendererFeature; import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.hooks.Hooks; import com.condation.cms.api.model.NavNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.utils.NodeUtil; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.api.utils.HTTPUtil; @@ -57,12 +56,12 @@ public class NavigationFunction extends AbstractCurrentNodeFunction { private final HookSystem hookSystem; - public NavigationFunction(DB db, ReadOnlyFile currentNode, RequestContext context) { + public NavigationFunction(DB db, ContentRepository contentRepository, + ReadOnlyFile currentNode, RequestContext context) { super( db, currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), + contentRepository, context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); hookSystem = context.get(HookSystemFeature.class).hookSystem(); @@ -84,7 +83,7 @@ public List path() { var node = currentNode; while (node != null) { var uri = PathUtil.toRelativeFile(node, contentBase); - final Optional contentNode = db.getContent().byPath(uri); + final Optional contentNode = contentRepository.get(uri); if (contentNode.isPresent()) { var metaNode = contentNode.get(); var nodeName = NodeUtil.getName(metaNode); @@ -170,7 +169,7 @@ private List getNodesFromBase(final ReadOnlyFile base, final String sta } try { final List navNodes = new ArrayList( - db.getContent().listContent(base, start) + contentRepository.children(repositoryPath(base, start)) .stream() .filter(NodeUtil::getMenuVisibility) .filter(NodeUtil.contentTypeFiler(contentType)) diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java index b1dceab5f..a84215eb1 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java @@ -25,10 +25,9 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; -import com.condation.cms.api.feature.features.ContentParserFeature; -import com.condation.cms.api.feature.features.MarkdownRendererFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.filesystem.metadata.query.ExtendableQuery; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import com.google.common.base.Strings; @@ -50,19 +49,22 @@ public class QueryFunction extends AbstractCurrentNodeFunction { @Setter private String contentType; - public QueryFunction(DB db, ReadOnlyFile currentNode, RequestContext context) { + public QueryFunction(DB db, ContentRepository contentRepository, + ReadOnlyFile currentNode, RequestContext context) { this( - db, + db, + contentRepository, currentNode, context, Map.of()); } - public QueryFunction(DB db, ReadOnlyFile currentNode, RequestContext context, final Map> queryOperations) { + public QueryFunction(DB db, ContentRepository contentRepository, + ReadOnlyFile currentNode, RequestContext context, + final Map> queryOperations) { super( - db, - currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), + db, + currentNode, + contentRepository, context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); this.extendedQueryOperations = queryOperations; @@ -80,7 +82,7 @@ private BiFunction nodeMapper() { public ContentQuery create() { - var query = db.getContent().query(nodeMapper()); + var query = contentRepository.query(nodeMapper()); ((ExtendableQuery)query).addAllCustomOperators(extendedQueryOperations); if (!Strings.isNullOrEmpty(contentType)) { @@ -90,7 +92,7 @@ public ContentQuery create() { } public ContentQuery create(final String startUri) { - var query = db.getContent().query(startUri, nodeMapper()); + var query = contentRepository.query(startUri, nodeMapper()); ((ExtendableQuery)query).addAllCustomOperators(extendedQueryOperations); diff --git a/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java b/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java index d4177e23a..369e5dec8 100644 --- a/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java @@ -22,13 +22,12 @@ */ import com.condation.cms.api.Constants; -import com.condation.cms.api.content.ContentParser; 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.markdown.MarkdownRenderer; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.views.model.View; import com.condation.cms.content.template.functions.list.NodeListFunctionBuilder; import com.condation.cms.content.template.functions.query.QueryFunction; @@ -47,15 +46,15 @@ @RequiredArgsConstructor public class NodeResolver { final DB db; + final ContentRepository contentRepository; final ReadOnlyFile currentNode; - final ContentParser contentParser; - final MarkdownRenderer markdownRenderer; final Context context; final Map> queryParams; public Page nodelist (View view, RequestContext requestContext) { NodeListFunctionBuilder nodelistBuilder = new NodeListFunctionBuilder( - db, + db, + contentRepository, currentNode, requestContext ); @@ -75,7 +74,8 @@ public Page nodelist (View view, RequestContext requestContext) { } public Page query (View view, RequestContext requestContext) { - QueryFunction queryFunction = new QueryFunction(db, currentNode, requestContext); + QueryFunction queryFunction = new QueryFunction( + db, contentRepository, currentNode, requestContext); context.getBindings("js").putMember("queryParams", queryParams); diff --git a/cms-content/src/main/java/com/condation/cms/content/views/ViewParser.java b/cms-content/src/main/java/com/condation/cms/content/views/ViewParser.java index 6a73ed8b9..4a3b30896 100644 --- a/cms-content/src/main/java/com/condation/cms/content/views/ViewParser.java +++ b/cms-content/src/main/java/com/condation/cms/content/views/ViewParser.java @@ -22,26 +22,24 @@ */ import com.condation.cms.content.views.model.View; -import java.io.IOException; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.Constructor; import org.yaml.snakeyaml.representer.Representer; -import com.condation.cms.api.db.cms.ReadOnlyFile; /** * * @author t.marx */ public class ViewParser { - public static View parse(final ReadOnlyFile viewFile) throws IOException { + public static View parse(final String content) { Representer representer = new Representer(new DumperOptions()); representer.getPropertyUtils().setSkipMissingProperties(true); LoaderOptions loaderOptions = new LoaderOptions(); Constructor constructor = new Constructor(View.class, loaderOptions); Yaml yaml = new Yaml(constructor, representer); - return yaml.loadAs(viewFile.getContent(), View.class); + return yaml.loadAs(content, View.class); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/views/model/View.java b/cms-content/src/main/java/com/condation/cms/content/views/model/View.java index a7424c74d..4500f820a 100644 --- a/cms-content/src/main/java/com/condation/cms/content/views/model/View.java +++ b/cms-content/src/main/java/com/condation/cms/content/views/model/View.java @@ -21,14 +21,12 @@ * #L% */ -import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; -import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.views.NodeResolver; -import java.nio.file.Path; import java.util.List; import java.util.Map; import lombok.Data; @@ -47,14 +45,16 @@ public class View { private String template; private Content content; - public Page getNodes (final DB db, final ReadOnlyFile currentNode, final ContentParser contentParser, - final MarkdownRenderer markdownRenderer, final Context context, final Map> queryParams, + public Page getNodes (final DB db, final ContentRepository contentRepository, + final ReadOnlyFile currentNode, final Context context, final Map> queryParams, final RequestContext requestContext) { if (content.getNodelist() != null) { - return new NodeResolver(db, currentNode, contentParser, markdownRenderer, context, queryParams).nodelist(this, requestContext); + return new NodeResolver(db, contentRepository, currentNode, context, queryParams) + .nodelist(this, requestContext); } else if (content.getQuery() != null) { - return new NodeResolver(db, currentNode, contentParser, markdownRenderer, context, queryParams).query(this, requestContext); + return new NodeResolver(db, contentRepository, currentNode, context, queryParams) + .query(this, requestContext); } return Page.EMPTY; } diff --git a/cms-content/src/test/java/com/condation/cms/content/ContentResolverTest.java b/cms-content/src/test/java/com/condation/cms/content/ContentResolverTest.java new file mode 100644 index 000000000..c7d3110b6 --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/ContentResolverTest.java @@ -0,0 +1,84 @@ +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.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.content.DefaultContentResponse; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentDocument; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.Section; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ContentResolverTest { + + @Test + void loadsAndRendersPageAndSectionsOnlyThroughRepository() throws Exception { + var repository = mock(ContentRepository.class); + var renderer = mock(ContentRenderer.class); + var node = new ContentNode("index.md", "/", "index.md", Map.of("template", "page.html")); + var document = new ContentDocument(node, "page body"); + var section = new Section( + "index.hero.md", + "index.md", + "hero", + Map.of("template", "section.html"), + "hero body"); + Map> renderedSections = Map.of( + "hero", + List.of(new SectionEntry("hero", "rendered hero", section.data()))); + + when(repository.findByUrl("/")).thenReturn(Optional.of(node)); + when(repository.isVisible(node)).thenReturn(true); + when(repository.variants(node)).thenReturn(List.of()); + when(repository.load(node)).thenReturn(Optional.of(document)); + when(repository.sections(node)).thenReturn(List.of(section)); + when(renderer.renderSections( + org.mockito.ArgumentMatchers.eq(List.of(section)), + org.mockito.ArgumentMatchers.any())) + .thenReturn(renderedSections); + when(renderer.render(org.mockito.ArgumentMatchers.eq(document), org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.eq(renderedSections))).thenReturn("rendered page"); + + var context = new RequestContext(); + context.add(RequestFeature.class, new RequestFeature("/", Map.of())); + var response = new ContentResolver(renderer, repository, new DefaultVariantSelector()) + .getContent(context); + + assertThat(response).containsInstanceOf(DefaultContentResponse.class); + var rendered = (DefaultContentResponse) response.orElseThrow(); + assertThat(rendered.content()).isEqualTo("rendered page"); + assertThat(rendered.node()).isEqualTo(node); + verify(repository).load(node); + verify(repository).sections(node); + verify(renderer).render(document, context, renderedSections); + } +} 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 deleted file mode 100644 index d4be8206b..000000000 --- a/cms-content/src/test/java/com/condation/cms/content/VariantResolverTest.java +++ /dev/null @@ -1,74 +0,0 @@ -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 index 7efabd686..739cbf734 100644 --- a/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java @@ -29,6 +29,8 @@ 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.repository.ContentRepository; +import com.condation.cms.api.variants.VariantContext; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -52,13 +54,13 @@ class VariantSelectorConfigurationRepositoryTest { void setUp() { var db = mock(DB.class); var fileSystem = mock(DBFileSystem.class); - var resolver = mock(VariantResolver.class); + var contentRepository = mock(ContentRepository.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()) + when(contentRepository.variantContext(canonical)).thenReturn( + new VariantContext(canonical, Optional.empty(), List.of()) ); - repository = new VariantSelectorConfigurationRepository(db, resolver); + repository = new VariantSelectorConfigurationRepository(db, contentRepository); } @Test diff --git a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java index 14b74144f..d632dc38c 100644 --- a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java +++ b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java @@ -35,11 +35,14 @@ import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.markdown.module.CMSMarkdownRenderer; import com.condation.cms.content.template.functions.navigation.NavigationFunction; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.hooksystem.CMSHookSystem; import java.nio.file.Path; @@ -62,6 +65,7 @@ public class NavigationFunctionITest { private static FileDB db; + private static ContentRepository contentRepository; @Mock private Request request; @@ -89,6 +93,9 @@ public static void setup() throws Exception { } }, config); db.init(); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); } @BeforeEach @@ -122,7 +129,8 @@ public static void shutdown() throws Exception { void test_root() { ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext).run(() -> { var currentNode = db.getFileSystem().contentBase(); - NavigationFunction fn = new NavigationFunction(db, currentNode, requestContext); + NavigationFunction fn = new NavigationFunction( + db, contentRepository, currentNode, requestContext); var nodes = fn.json().list("."); diff --git a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java index a62f8cc46..37f624490 100644 --- a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java @@ -34,6 +34,7 @@ import com.condation.cms.api.hooks.FilterContext; import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.mapper.ContentNodeMapper; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.model.NavNode; import com.condation.cms.api.request.RequestContext; @@ -73,6 +74,8 @@ public class NavigationFunctionTest { MarkdownRenderer markdownRenderer; @Mock ContentNodeMapper contentNodeMapper; + @Mock + ContentRepository contentRepository; NavigationFunction sut; @@ -96,7 +99,7 @@ public void setup () { requestContext.add(MarkdownRendererFeature.class, new MarkdownRendererFeature(markdownRenderer)); requestContext.add(ContentNodeMapperFeature.class, new ContentNodeMapperFeature(contentNodeMapper)); - sut = new NavigationFunction(db, + sut = new NavigationFunction(db, contentRepository, new NIOReadOnlyFile(Path.of("content/current/"), Path.of("content/")) , requestContext); } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java new file mode 100644 index 000000000..785f45644 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java @@ -0,0 +1,275 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * 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.ContentQuery; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.content.ContentParser; +import com.condation.cms.api.repository.ContentDocument; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.ContentStore; +import com.condation.cms.api.repository.Section; +import com.condation.cms.api.utils.SectionUtil; +import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantContext; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.BiFunction; +import lombok.extern.slf4j.Slf4j; + +/** + * {@link ContentRepository} adapter backed by the existing filesystem index. + * + * @author thmar + */ +@Slf4j +public final class FileSystemContentRepository implements ContentRepository { + + private final Content content; + private final DBFileSystem fileSystem; + private final ContentStore contentStore; + private final ContentParser contentParser; + + public FileSystemContentRepository( + Content content, + DBFileSystem fileSystem, + ContentStore contentStore, + ContentParser contentParser) { + this.content = Objects.requireNonNull(content); + this.fileSystem = Objects.requireNonNull(fileSystem); + this.contentStore = Objects.requireNonNull(contentStore); + this.contentParser = Objects.requireNonNull(contentParser); + } + + @Override + public Optional get(String path) { + return content.byPath(path); + } + + @Override + public Optional findByUrl(String url) { + return content.byUrl(url); + } + + @Override + public Optional load(ContentNode node) throws IOException { + Objects.requireNonNull(node, "node"); + var resource = contentStore.get(node.path()); + if (resource.isEmpty() || resource.get().directory()) { + return Optional.empty(); + } + var parsedContent = contentParser.parse(resource.get()); + return Optional.of(new ContentDocument(node, parsedContent.content())); + } + + @Override + public List children(String path) { + return content.listContent(fileSystem.contentBase(), path); + } + + @Override + public List directories(String path) { + return content.listDirectories(fileSystem.contentBase(), path); + } + + @Override + public boolean isVisible(ContentNode node) { + return content.isVisible(node); + } + + @Override + public List
sections(ContentNode owner) throws IOException { + Objects.requireNonNull(owner, "owner"); + return toSections(owner, sectionNodes(owner)); + } + + @Override + public List
sections(ContentNode owner, String sectionName) throws IOException { + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(sectionName, "sectionName"); + var matchingNodes = sectionNodes(owner).stream() + .filter(section -> sectionName.equals(SectionUtil.getSectionName(section.name()))) + .toList(); + return toSections(owner, matchingNodes); + } + + private List sectionNodes(ContentNode owner) { + return content.listSectionEntries(fileSystem.contentBase().resolve(owner.path())); + } + + private List
toSections(ContentNode owner, List nodes) throws IOException { + var sections = new ArrayList
(nodes.size()); + for (var node : nodes) { + var sectionResource = contentStore.get(node.path()); + if (sectionResource.isEmpty() || sectionResource.get().directory()) { + continue; + } + var parsedContent = contentParser.parse(sectionResource.get()); + sections.add(new Section( + node.path(), + owner.path(), + SectionUtil.getSectionName(node.name()), + node.data(), + parsedContent.content())); + } + return List.copyOf(sections); + } + + @Override + public List variants(ContentNode node) { + Objects.requireNonNull(node, "node"); + var canonical = canonicalNode(node); + var parent = parentPath(canonical.path()); + var variantsPath = join(parent, ".variants/" + removeMarkdown(canonical.name())); + + try (var resources = contentStore.list(variantsPath)) { + return resources + .filter(resource -> resource.directory()) + .map(resource -> new VariantCandidate( + fileName(resource.path()), + join(resource.path(), canonical.name()))) + .map(candidate -> get(candidate.path()) + .map(variantNode -> new Variant(candidate.id(), variantNode))) + .flatMap(Optional::stream) + .toList(); + } catch (IOException ex) { + log.error("error loading variants for {}", canonical.path(), ex); + return Collections.emptyList(); + } + } + + @Override + public Optional variant(ContentNode node, String variantId) { + Objects.requireNonNull(variantId, "variantId"); + return variants(node).stream() + .filter(variant -> variant.id().equals(variantId)) + .findFirst(); + } + + @Override + public VariantContext variantContext(ContentNode node) { + Objects.requireNonNull(node, "node"); + var location = variantLocation(node); + var canonical = location + .flatMap(value -> get(value.canonicalPath())) + .orElse(node); + var activeVariantId = location + .filter(value -> !canonical.equals(node)) + .map(VariantLocation::variantId); + return new VariantContext(canonical, activeVariantId, variants(canonical)); + } + + private ContentNode canonicalNode(ContentNode node) { + return variantLocation(node) + .flatMap(location -> get(location.canonicalPath())) + .orElse(node); + } + + private Optional variantLocation(ContentNode node) { + var pathParts = normalize(node.path()).split("/"); + for (int index = 0; index < pathParts.length; index++) { + if (!".variants".equals(pathParts[index])) { + continue; + } + if (index + 3 != pathParts.length - 1) { + return Optional.empty(); + } + + var pageFolder = pathParts[index + 1]; + var variantId = pathParts[index + 2]; + var fileName = pathParts[index + 3]; + if (!pageFolder.equals(removeMarkdown(fileName))) { + return Optional.empty(); + } + + var canonicalPath = index == 0 + ? fileName + : String.join("/", java.util.Arrays.copyOf(pathParts, index)) + "/" + fileName; + return Optional.of(new VariantLocation(canonicalPath, variantId)); + } + return Optional.empty(); + } + + @Override + public ContentQuery query() { + return content.query((node, excerptLength) -> node); + } + + @Override + public ContentQuery query(String startPath) { + return content.query(startPath, (node, excerptLength) -> node); + } + + @Override + public ContentQuery query(BiFunction nodeMapper) { + return content.query(nodeMapper); + } + + @Override + public ContentQuery query( + String startPath, + BiFunction nodeMapper) { + return content.query(startPath, nodeMapper); + } + + private static String normalize(String path) { + var normalized = path.replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized; + } + + private static String parentPath(String path) { + var normalized = normalize(path); + var separator = normalized.lastIndexOf('/'); + return separator < 0 ? "" : normalized.substring(0, separator); + } + + private static String join(String parent, String child) { + return parent.isEmpty() ? child : parent + "/" + child; + } + + private static String fileName(String path) { + var normalized = normalize(path); + var separator = normalized.lastIndexOf('/'); + return separator < 0 ? normalized : normalized.substring(separator + 1); + } + + private static String removeMarkdown(String filename) { + return filename.endsWith(".md") + ? filename.substring(0, filename.length() - 3) + : filename; + } + + private record VariantCandidate(String id, String path) { + } + + private record VariantLocation(String canonicalPath, String variantId) { + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentStore.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentStore.java new file mode 100644 index 000000000..5801d56c4 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentStore.java @@ -0,0 +1,110 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * 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.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.repository.ContentResource; +import com.condation.cms.api.repository.ContentStore; +import java.io.IOException; +import java.nio.charset.Charset; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * {@link ContentStore} adapter for the existing CMS file system. + * + * @author thmar + */ +public final class FileSystemContentStore implements ContentStore { + + private final ReadOnlyFile contentRoot; + + public FileSystemContentStore(DBFileSystem fileSystem) { + this(fileSystem.contentBase()); + } + + public FileSystemContentStore(ReadOnlyFile contentRoot) { + this.contentRoot = Objects.requireNonNull(contentRoot); + } + + @Override + public Optional get(String path) { + var normalizedPath = normalize(path); + var file = contentRoot.resolve(normalizedPath); + if (!file.exists()) { + return Optional.empty(); + } + return Optional.of(new FileSystemContentResource(normalizedPath, file)); + } + + @Override + public Stream list(String path) throws IOException { + var normalizedPath = normalize(path); + var directory = contentRoot.resolve(normalizedPath); + if (!directory.exists() || !directory.isDirectory()) { + return Stream.empty(); + } + + return directory.children().stream() + .map(child -> new FileSystemContentResource(childPath(normalizedPath, child), child)) + .map(ContentResource.class::cast); + } + + private static String childPath(String parentPath, ReadOnlyFile child) { + return parentPath.isEmpty() + ? child.getFileName() + : parentPath + "/" + child.getFileName(); + } + + private static String normalize(String path) { + Objects.requireNonNull(path, "path"); + var normalized = path.trim().replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/") && !normalized.isEmpty()) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return ".".equals(normalized) ? "" : normalized; + } + + private record FileSystemContentResource(String path, ReadOnlyFile file) implements ContentResource { + + @Override + public boolean directory() { + return file.isDirectory(); + } + + @Override + public Instant lastModified() throws IOException { + return Instant.ofEpochMilli(file.getLastModifiedTime()); + } + + @Override + public String content(Charset charset) throws IOException { + return file.getContent(charset); + } + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java new file mode 100644 index 000000000..c6203a643 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java @@ -0,0 +1,193 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * 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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.DBFileSystem; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.content.ContentParser; +import com.condation.cms.api.repository.ContentResource; +import com.condation.cms.api.repository.ContentStore; +import java.io.IOException; +import java.nio.charset.Charset; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class FileSystemContentRepositoryTest { + + private Content content; + private DBFileSystem fileSystem; + private ContentParser contentParser; + private ContentStore contentStore; + private ReadOnlyFile contentRoot; + private FileSystemContentRepository repository; + + @BeforeEach + void setUp() { + content = mock(Content.class); + fileSystem = mock(DBFileSystem.class); + contentRoot = mock(ReadOnlyFile.class); + contentParser = mock(ContentParser.class); + contentStore = mock(ContentStore.class); + when(fileSystem.contentBase()).thenReturn(contentRoot); + repository = new FileSystemContentRepository(content, fileSystem, contentStore, contentParser); + } + + @Test + void delegatesNodeLookupToExistingContentImplementation() { + var page = node("articles/page.md"); + when(content.byPath(page.path())).thenReturn(java.util.Optional.of(page)); + + Assertions.assertThat(repository.get(page.path())).contains(page); + } + + @Test + void loadsParsedContentThroughTheContentStore() throws Exception { + var page = node("articles/page.md"); + var resource = resource(page.path(), false, "---\ntitle: Page\n---\nbody"); + when(contentStore.get(page.path())).thenReturn(Optional.of(resource)); + when(contentParser.parse(resource)).thenReturn(new ContentParser.Content("body", page.data())); + + Assertions.assertThat(repository.load(page)) + .hasValueSatisfying(document -> { + Assertions.assertThat(document.node()).isEqualTo(page); + Assertions.assertThat(document.content()).isEqualTo("body"); + }); + verify(contentParser).parse(resource); + } + + @Test + void exposesSectionsAsDomainObjectsWithoutLeakingFilenameMatchingToCallers() throws Exception { + var owner = node("articles/page.md"); + var ownerFile = mock(ReadOnlyFile.class); + var hero = node("articles/page.hero.md"); + var secondHero = node("articles/page.hero.secondary.md"); + var sidebar = node("articles/page.sidebar.md"); + var heroResource = resource(hero.path(), false, "hero"); + var secondHeroResource = resource(secondHero.path(), false, "secondary"); + var sidebarResource = resource(sidebar.path(), false, "sidebar"); + when(contentRoot.resolve(owner.path())).thenReturn(ownerFile); + when(content.listSectionEntries(ownerFile)).thenReturn(List.of(hero, secondHero, sidebar)); + when(contentStore.get(hero.path())).thenReturn(Optional.of(heroResource)); + when(contentStore.get(secondHero.path())).thenReturn(Optional.of(secondHeroResource)); + when(contentStore.get(sidebar.path())).thenReturn(Optional.of(sidebarResource)); + when(contentParser.parse(heroResource)).thenReturn(new ContentParser.Content("hero", hero.data())); + when(contentParser.parse(secondHeroResource)).thenReturn(new ContentParser.Content("secondary", secondHero.data())); + when(contentParser.parse(sidebarResource)).thenReturn(new ContentParser.Content("sidebar", sidebar.data())); + + Assertions.assertThat(repository.sections(owner)) + .extracting(section -> section.id(), section -> section.owner(), + section -> section.name(), section -> section.content()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(hero.path(), owner.path(), "hero", "hero"), + org.assertj.core.groups.Tuple.tuple(secondHero.path(), owner.path(), "hero", "secondary"), + org.assertj.core.groups.Tuple.tuple(sidebar.path(), owner.path(), "sidebar", "sidebar")); + org.mockito.Mockito.clearInvocations(contentParser); + Assertions.assertThat(repository.sections(owner, "hero")) + .extracting(section -> section.id()) + .containsExactly(hero.path(), secondHero.path()); + verify(contentParser, org.mockito.Mockito.never()).parse(sidebarResource); + } + + @Test + void resolvesVariantsAndCanonicalContextInsideTheRepository() throws Exception { + var canonical = node("articles/page.md"); + var blue = node("articles/.variants/page/blue/page.md"); + var red = node("articles/.variants/page/red/page.md"); + when(contentStore.list("articles/.variants/page")).thenReturn(Stream.of( + resource("articles/.variants/page/blue", true, ""), + resource("articles/.variants/page/red", true, ""))); + when(content.byPath(blue.path())).thenReturn(Optional.of(blue)); + when(content.byPath(red.path())).thenReturn(Optional.of(red)); + when(content.byPath(canonical.path())).thenReturn(Optional.of(canonical)); + + Assertions.assertThat(repository.variants(canonical)) + .extracting(variant -> variant.id(), variant -> variant.node()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("blue", blue), + org.assertj.core.groups.Tuple.tuple("red", red)); + + when(contentStore.list("articles/.variants/page")).thenReturn(Stream.of( + resource("articles/.variants/page/blue", true, ""), + resource("articles/.variants/page/red", true, ""))); + var context = repository.variantContext(blue); + Assertions.assertThat(context.canonical()).isEqualTo(canonical); + Assertions.assertThat(context.activeVariantId()).contains("blue"); + Assertions.assertThat(context.variants()).hasSize(2); + } + + @Test + @SuppressWarnings("unchecked") + void reusesTheExistingQueryApi() { + ContentQuery allContentQuery = mock(ContentQuery.class); + ContentQuery scopedQuery = mock(ContentQuery.class); + when(content.query(any())).thenReturn(allContentQuery); + when(content.query(org.mockito.ArgumentMatchers.eq("articles"), any())) + .thenReturn(scopedQuery); + + Assertions.assertThat(repository.query()).isSameAs(allContentQuery); + Assertions.assertThat(repository.query("articles")).isSameAs(scopedQuery); + verify(content).query(org.mockito.ArgumentMatchers.eq("articles"), any()); + } + + private static ContentNode node(String path) { + var name = path.substring(path.lastIndexOf('/') + 1); + return new ContentNode(path, "/" + path.replace(".md", ""), name, Map.of()); + } + + private static ContentResource resource(String path, boolean directory, String content) { + return new ContentResource() { + @Override + public String path() { + return path; + } + + @Override + public boolean directory() { + return directory; + } + + @Override + public Instant lastModified() { + return Instant.EPOCH; + } + + @Override + public String content(Charset charset) throws IOException { + return content; + } + }; + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java new file mode 100644 index 000000000..aeec1193c --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java @@ -0,0 +1,82 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * 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.exceptions.AccessNotAllowedException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class FileSystemContentStoreTest { + + @TempDir + Path tempDirectory; + + private Path contentRoot; + private FileSystemContentStore store; + + @BeforeEach + void setUp() throws Exception { + contentRoot = Files.createDirectories(tempDirectory.resolve("content")); + store = new FileSystemContentStore(new NIOReadOnlyFile(contentRoot, contentRoot)); + } + + @Test + void getsResourcesFromTheExistingReadOnlyFileSystem() throws Exception { + var page = contentRoot.resolve("articles/hello.md"); + Files.createDirectories(page.getParent()); + Files.writeString(page, "Grüße aus dem Store", StandardCharsets.UTF_8); + + var resource = store.get("/articles\\hello.md"); + + Assertions.assertThat(resource).isPresent(); + Assertions.assertThat(resource.get().path()).isEqualTo("articles/hello.md"); + Assertions.assertThat(resource.get().directory()).isFalse(); + Assertions.assertThat(resource.get().content()).isEqualTo("Grüße aus dem Store"); + Assertions.assertThat(resource.get().lastModified()).isNotNull(); + Assertions.assertThat(store.exists("articles/hello.md")).isTrue(); + Assertions.assertThat(store.get("missing.md")).isEmpty(); + } + + @Test + void listsOnlyDirectChildrenWithStoreRelativePaths() throws Exception { + var articles = Files.createDirectories(contentRoot.resolve("articles/nested")); + Files.writeString(contentRoot.resolve("articles/first.md"), "first"); + Files.writeString(articles.resolve("second.md"), "second"); + + Assertions.assertThat(store.list("articles")) + .extracting(resource -> resource.path()) + .containsExactlyInAnyOrder("articles/first.md", "articles/nested"); + Assertions.assertThat(store.list("articles/first.md")).isEmpty(); + Assertions.assertThat(store.list("missing")).isEmpty(); + } + + @Test + void keepsPathTraversalProtectionOfTheExistingFileSystem() { + Assertions.assertThatThrownBy(() -> store.get("../outside.md")) + .isInstanceOf(AccessNotAllowedException.class); + } +} 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 f585515c6..4e2c15cf1 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 @@ -52,6 +52,8 @@ import com.condation.cms.api.media.MediaService; import com.condation.cms.api.messages.MessageSource; import com.condation.cms.api.messaging.Messaging; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.ContentStore; import com.condation.cms.api.scheduler.CronJobContext; import com.condation.cms.api.template.TemplateEngine; import com.condation.cms.api.theme.Theme; @@ -67,7 +69,6 @@ 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; @@ -87,6 +88,8 @@ import com.condation.cms.core.theme.DefaultTheme; import com.condation.cms.extensions.ExtensionManager; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.MetaData; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.media.FileMediaService; @@ -304,7 +307,7 @@ public MessageSource messages(SiteProperties site, DB db, CacheManager cacheMana @Provides @Singleton @Eager - public DB fileDb(SiteProperties site, DefaultContentParser contentParser, Configuration configuration, EventBus eventBus) throws IOException { + public DB fileDb(SiteProperties site, DefaultContentParser contentParser, Configuration configuration, EventBus eventBus) throws IOException { var db = new FileDB(hostBase, eventBus, (file) -> { try { ReadOnlyFile cmsFile = new NIOReadOnlyFile(file, hostBase.resolve(Constants.Folders.CONTENT)); @@ -318,6 +321,25 @@ public DB fileDb(SiteProperties site, DefaultContentParser contentParser, Config return db; } + @Provides + @Singleton + public ContentStore contentStore(DB db) { + return new FileSystemContentStore(db.getFileSystem()); + } + + @Provides + @Singleton + public ContentRepository contentRepository( + DB db, + ContentStore contentStore, + ContentParser contentParser) { + return new FileSystemContentRepository( + db.getContent(), + db.getFileSystem(), + contentStore, + contentParser); + } + @Provides @Singleton public ExtensionManager extensionManager(DB db, Configuration configuration, Engine engine) throws IOException { @@ -360,21 +382,22 @@ public RenderContentFunction renderContentFunction (ContentResolver contentResol @Provides @Singleton - public ContentRenderer contentRenderer(ContentParser contentParser, Injector injector, FileDB db, - SiteProperties siteProperties, ModuleManager moduleManager) { + public ContentRenderer contentRenderer(Injector injector, FileDB db, + SiteProperties siteProperties, ModuleManager moduleManager, + ContentRepository contentRepository) { return new DefaultContentRenderer( - contentParser, () -> injector.getInstance(TemplateEngine.class), db, siteProperties, - moduleManager); + moduleManager, + contentRepository); } @Provides @Singleton public ContentResolver contentResolver(ContentRenderer contentRenderer, - FileDB db, VariantResolver variantResolver, VariantSelector variantSelector) { - return new ContentResolver(contentRenderer, db, variantResolver, variantSelector); + ContentRepository contentRepository, VariantSelector variantSelector) { + return new ContentResolver(contentRenderer, contentRepository, variantSelector); } @Provides @@ -386,19 +409,13 @@ public CollectionResolver collectionResolver( return new CollectionResolver(contentRenderer, db, configuration); } - @Provides - @Singleton - public VariantResolver variantResolver(FileDB db) { - return new VariantResolver(db); - } - @Provides @Singleton public VariantSelectorConfigurationRepository variantSelectorConfigurationRepository( FileDB db, - VariantResolver variantResolver + ContentRepository contentRepository ) { - return new VariantSelectorConfigurationRepository(db, variantResolver); + return new VariantSelectorConfigurationRepository(db, contentRepository); } @Provides @@ -423,8 +440,8 @@ public VariantSelector variantSelector(ConfigurableVariantSelector selector) { @Provides @Singleton public ViewResolver viewResolver(ContentRenderer contentRenderer, - FileDB db) { - return new ViewResolver(contentRenderer, db); + FileDB db, ContentRepository contentRepository) { + return new ViewResolver(contentRenderer, db, contentRepository); } @Provides diff --git a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java index 8a0e779bd..a89847ed1 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java @@ -21,15 +21,15 @@ * #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.feature.features.InjectorFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.utils.PathUtil; import com.condation.cms.content.ContentRenderer; import com.condation.cms.content.SectionEntry; import java.io.IOException; -import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -50,10 +50,13 @@ public NodeFunction(RequestContext requestContext) { protected void extendMap(Map node, ReadOnlyFile contentFile) { try { var db = requestContext.get(InjectorFeature.class).injector().getInstance(DB.class); + var contentRepository = requestContext.get(InjectorFeature.class) + .injector().getInstance(ContentRepository.class); var contentRenderer = requestContext.get(InjectorFeature.class).injector().getInstance(ContentRenderer.class); - List sectionEntryList = db.getContent().listSectionEntries(contentFile); - - Map> sectionEntries = contentRenderer.renderSectionEntries(sectionEntryList, requestContext); + var path = PathUtil.toRelativeFile(contentFile, db.getFileSystem().contentBase()); + var contentNode = contentRepository.get(path).orElseThrow(); + var sectionEntries = contentRenderer.renderSections( + contentRepository.sections(contentNode), requestContext); node.put("sections", sectionEntries); } catch (IOException iOException) { log.error("error loading sections", iOException); diff --git a/distribution/scripts/update-cms.sh b/distribution/scripts/update-cms.sh new file mode 100644 index 000000000..fa157efa8 --- /dev/null +++ b/distribution/scripts/update-cms.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +usage() { + printf '%s\n' \ + 'Verwendung:' \ + ' update-cms.sh --check [SERVER] [SSH-BENUTZER] [DOWNLOAD-URL]' \ + ' update-cms.sh --install [SERVER] [SSH-BENUTZER]' \ + '' \ + '--check Neue Version herunterladen, pruefen und vollstaendig vorbereiten.' \ + '--install Vorbereitete Version aktivieren und den Dienst anschliessend neu starten.' +} + +prompt_if_empty() { + local variable_name=$1 + local prompt=$2 + local value=${!variable_name:-} + + if [[ -z "$value" ]]; then + read -r -p "$prompt" value + printf -v "$variable_name" '%s' "$value" + fi +} + +mode= +case ${1:-} in + --check) + mode=check + shift + ;; + --install) + shift + ;; + --help|-h) + usage + exit 0 + ;; + '') + printf 'Bitte --check oder --install angeben.\n\n' >&2 + usage >&2 + exit 2 + ;; + --*) + printf 'Unbekannte Option: %s\n\n' "$1" >&2 + usage >&2 + exit 2 + ;; + *) + printf 'Bitte zuerst --check oder --install angeben.\n\n' >&2 + usage >&2 + exit 2 + ;; +esac + +server=${1:-} +ssh_user=${2:-} +download_url=${3:-} + +prompt_if_empty server "Server (Hostname oder IP): " +prompt_if_empty ssh_user "SSH-Benutzer: " +if [[ "$mode" == check ]]; then + prompt_if_empty download_url "Download-URL der tar.gz-Datei: " +fi + +if [[ ! "$server" =~ ^[a-zA-Z0-9._:-]+$ ]]; then + printf 'Ungueltiger Servername: %s\n' "$server" >&2 + exit 2 +fi + +if [[ ! "$ssh_user" =~ ^[a-zA-Z_][a-zA-Z0-9_.-]*$ ]]; then + printf 'Ungueltiger SSH-Benutzer: %s\n' "$ssh_user" >&2 + exit 2 +fi + +if [[ "$mode" == check && ! "$download_url" =~ ^https?://[^[:space:]]+$ ]]; then + printf 'Die Download-URL muss mit http:// oder https:// beginnen.\n' >&2 + exit 2 +fi + +for command_name in ssh base64 tr cat; do + command -v "$command_name" >/dev/null 2>&1 || { + printf 'Auf diesem Rechner fehlt das benoetigte Programm: %s\n' "$command_name" >&2 + exit 1 + } +done + +printf '\nVerbindung zu %s@%s wird hergestellt.\n' "$ssh_user" "$server" +printf 'SSH und sudo fragen ihre Passwoerter bei Bedarf selbst ab.\n\n' + +# %q verhindert, dass Sonderzeichen in den Argumenten als Shell-Code interpretiert werden. +printf -v quoted_mode '%q' "$mode" +printf -v quoted_url '%q' "$download_url" + +remote_script=$(cat <<'REMOTE_SCRIPT' +set -Eeuo pipefail + +MODE=$1 +DOWNLOAD_URL=${2:-} +SERVICE=cms +INSTALL_DIR=/opt/condation-server +UPDATE_DIR=/opt/condation-server_update +ARCHIVE="$UPDATE_DIR/update.tar.gz" +NEW_DIR="$UPDATE_DIR/condation-server" +READY_FILE="$NEW_DIR/.condation-update-ready" +STAMP=$(date +%Y%m%d-%H%M%S) +BACKUP_DIR="/opt/condation-cms-$STAMP" +FAILED_DIR="/opt/condation-server-failed-$STAMP" + +old_moved=0 +new_activated=0 + +rollback() { + local exit_code=${1:-$?} + trap - ERR INT TERM + set +e + + printf '\nUpdate fehlgeschlagen (Exit-Code %s). Rollback wird versucht.\n' "$exit_code" >&2 + + if (( new_activated == 1 )); then + sudo systemctl stop "$SERVICE" + sudo mv -- "$INSTALL_DIR" "$FAILED_DIR" + sudo mv -- "$BACKUP_DIR" "$INSTALL_DIR" + sudo systemctl start "$SERVICE" + printf 'Die fehlgeschlagene neue Version liegt unter %s.\n' "$FAILED_DIR" >&2 + elif (( old_moved == 1 )); then + sudo mv -- "$BACKUP_DIR" "$INSTALL_DIR" + fi + + exit "$exit_code" +} + +validate_existing_installation() { + sudo test -d "$INSTALL_DIR" || { + printf 'Die bestehende Installation %s wurde nicht gefunden.\n' "$INSTALL_DIR" >&2 + return 1 + } + sudo test -d "$INSTALL_DIR/config" || { + printf 'Erforderlicher Ordner fehlt: %s/config\n' "$INSTALL_DIR" >&2 + return 1 + } + sudo test -f "$INSTALL_DIR/server.toml" || { + printf 'Erforderliche Datei fehlt: %s/server.toml\n' "$INSTALL_DIR" >&2 + return 1 + } + sudo test -d "$INSTALL_DIR/hosts" || { + printf 'Erforderlicher Ordner fehlt: %s/hosts\n' "$INSTALL_DIR" >&2 + return 1 + } +} + +copy_runtime_data() { + printf 'Uebernehme aktuelle Konfiguration und Hosts ...\n' + sudo rm -rf -- "$NEW_DIR/theme" "$NEW_DIR/themes" "$NEW_DIR/hosts" "$NEW_DIR/config" + sudo rm -f -- "$NEW_DIR/server.toml" + sudo cp -a -- "$INSTALL_DIR/config" "$NEW_DIR/config" + sudo cp -a -- "$INSTALL_DIR/server.toml" "$NEW_DIR/server.toml" + sudo cp -a -- "$INSTALL_DIR/hosts" "$NEW_DIR/hosts" +} + +command -v systemctl >/dev/null 2>&1 || { + printf 'Auf dem Server fehlt das benoetigte Programm: systemctl\n' >&2 + exit 1 +} + +sudo -v +validate_existing_installation +sudo systemctl cat "$SERVICE" >/dev/null + +if [[ "$MODE" == check ]]; then + for command_name in wget tar; do + command -v "$command_name" >/dev/null 2>&1 || { + printf 'Auf dem Server fehlt das benoetigte Programm: %s\n' "$command_name" >&2 + exit 1 + } + done + + printf 'Lade neue Version herunter ...\n' + sudo rm -rf -- "$UPDATE_DIR" + sudo mkdir -- "$UPDATE_DIR" + sudo wget --output-document="$ARCHIVE" -- "$DOWNLOAD_URL" + + printf 'Pruefe und entpacke Archiv ...\n' + sudo tar -tzf "$ARCHIVE" >/dev/null + sudo tar -xzf "$ARCHIVE" -C "$UPDATE_DIR" + sudo test -d "$NEW_DIR" || { + printf 'Das Archiv enthaelt nicht den erwarteten Ordner condation-server.\n' >&2 + exit 1 + } + + copy_runtime_data + sudo touch -- "$READY_FILE" + + printf '\nPruefung erfolgreich. Der laufende Dienst wurde nicht veraendert.\n' + printf 'Die vorbereitete Version liegt unter %s.\n' "$NEW_DIR" + printf 'Zum Aktivieren das Skript anschliessend mit --install ausfuehren.\n' + exit 0 +fi + +sudo test -d "$NEW_DIR" && sudo test -f "$READY_FILE" || { + printf 'Keine vollstaendig vorbereitete Version unter %s gefunden.\n' "$NEW_DIR" >&2 + printf 'Bitte zuerst das Skript mit --check ausfuehren.\n' >&2 + exit 1 +} + +sudo test ! -e "$BACKUP_DIR" || { + printf 'Das Backup-Ziel existiert bereits: %s\n' "$BACKUP_DIR" >&2 + exit 1 +} + +# Zwischen Check und Installation koennen sich diese Daten geaendert haben. +copy_runtime_data + +trap 'rollback $?' ERR +trap 'rollback 130' INT +trap 'rollback 143' TERM +printf 'Aktiviere vorbereitete Version ...\n' +sudo mv -- "$INSTALL_DIR" "$BACKUP_DIR" +old_moved=1 +sudo mv -- "$NEW_DIR" "$INSTALL_DIR" +new_activated=1 +sudo rm -f -- "$INSTALL_DIR/.condation-update-ready" + +printf 'Starte Dienst %s mit der neuen Version neu ...\n' "$SERVICE" +sudo systemctl restart "$SERVICE" +sudo systemctl is-active --quiet "$SERVICE" +trap - ERR INT TERM + +sudo rm -rf -- "$UPDATE_DIR" || printf 'Warnung: %s konnte nicht entfernt werden.\n' "$UPDATE_DIR" >&2 + +printf '\nUpdate erfolgreich.\n' +printf 'Backup der alten Version: %s\n' "$BACKUP_DIR" +sudo systemctl --no-pager --full status "$SERVICE" || true +REMOTE_SCRIPT +) + +# Das Skript wird als Kommando und nicht ueber stdin uebertragen. So bleibt das +# Terminal fuer die interaktiven SSH- und sudo-Passwortabfragen verfuegbar. +remote_payload=$(printf '%s' "$remote_script" | base64 | tr -d '\r\n') +ssh -tt -- "${ssh_user}@${server}" \ + "printf '%s' '${remote_payload}' | base64 --decode | bash -s -- ${quoted_mode} ${quoted_url}" diff --git a/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java b/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java index f457b9786..9240aa867 100644 --- a/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java +++ b/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java @@ -30,11 +30,14 @@ import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.template.TemplateEngine; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.DefaultContentRenderer; import com.condation.cms.content.SectionEntry; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.template.TemplateEngineTest; import com.condation.cms.test.TestSiteProperties; @@ -57,6 +60,7 @@ public class SectionEntriesTest extends TemplateEngineTest { static DefaultContentRenderer contentRenderer; static MarkdownRenderer markdownRenderer; static FileDB db; + static ContentRepository contentRepository; @BeforeAll public static void beforeClass() throws IOException { @@ -83,21 +87,25 @@ public static void beforeClass() throws IOException { db.init(); markdownRenderer = TestHelper.getRenderer(); TemplateEngine templates = new TestTemplateEngine(db); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); - contentRenderer = new DefaultContentRenderer(contentParser, - () -> templates, + contentRenderer = new DefaultContentRenderer(() -> templates, db, new TestSiteProperties(Map.of()), - new MockModuleManager() + new MockModuleManager(), + contentRepository ); } @Test public void test_section_entries() throws IOException { - List listSectionEntries = db.getContent().listSectionEntries(db.getReadOnlyFileSystem().contentBase().resolve("page.md")); - Assertions.assertThat(listSectionEntries).hasSize(4); + var page = contentRepository.get("page.md").orElseThrow(); + var sections = contentRepository.sections(page); + Assertions.assertThat(sections).hasSize(4); - Map> renderedSectionEntries = contentRenderer.renderSectionEntries(listSectionEntries, TestHelper.requestContext()); + Map> renderedSectionEntries = contentRenderer.renderSections(sections, TestHelper.requestContext()); Assertions.assertThat(renderedSectionEntries) .hasSize(1) diff --git a/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java b/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java index 5771804c9..b812ee064 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java @@ -34,8 +34,11 @@ import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.template.TemplateEngine; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.template.TemplateEngineTest; import com.condation.cms.test.TestSiteProperties; @@ -63,6 +66,7 @@ public class ContentRendererNGTest extends TemplateEngineTest { static ModuleManager moduleManager = new MockModuleManager(); static FileDB db; + static ContentRepository contentRepository; static Path hostBase; @BeforeAll @@ -89,12 +93,15 @@ public static void beforeClass () throws IOException { db.init(); markdownRenderer = TestHelper.getRenderer(); TemplateEngine templates = new TestTemplateEngine(db); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); - contentRenderer = new DefaultContentRenderer(contentParser, - () -> templates, + contentRenderer = new DefaultContentRenderer(() -> templates, db, new TestSiteProperties(Map.of()), - moduleManager); + moduleManager, + contentRepository); } @AfterAll public static void shutdown () throws Exception { @@ -113,8 +120,9 @@ public void testSomeMethod() throws IOException { """; - var content = contentRenderer.render(new NIOReadOnlyFile(Path.of("hosts/test/content/test.md"), Path.of("hosts/test/")) - , TestHelper.requestContext()); + var content = contentRenderer.render( + contentRepository.load(contentRepository.get("test.md").orElseThrow()).orElseThrow(), + TestHelper.requestContext(), Map.of()); Assertions.assertThat(content).isEqualToIgnoringWhitespace(expectedHTML); } @@ -131,8 +139,9 @@ public void test_subfolder() throws IOException { """; - var content = contentRenderer.render(new NIOReadOnlyFile(Path.of("hosts/test/content/products/test.md"), Path.of("hosts/test/")) - , TestHelper.requestContext()); + var content = contentRenderer.render( + contentRepository.load(contentRepository.get("products/test.md").orElseThrow()).orElseThrow(), + TestHelper.requestContext(), Map.of()); Assertions.assertThat(content).isEqualToIgnoringWhitespace(expectedHTML); } 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 611c7414f..ed4b73447 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 @@ -36,10 +36,13 @@ import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.template.TemplateEngine; +import com.condation.cms.api.repository.ContentRepository; import static com.condation.cms.content.ContentRendererNGTest.contentRenderer; import static com.condation.cms.content.ContentRendererNGTest.moduleManager; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.test.TestSiteProperties; import java.io.IOException; @@ -60,6 +63,7 @@ public class ContentResolverTest { static MarkdownRenderer markdownRenderer; static ContentResolver contentResolver; static FileDB db; + static ContentRepository contentRepository; @BeforeAll public static void setup() throws IOException { @@ -83,13 +87,17 @@ public static void setup() throws IOException { db.init(); markdownRenderer = TestHelper.getRenderer(); TemplateEngine templates = new TestTemplateEngine(db); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); - contentRenderer = new DefaultContentRenderer(contentParser, - () -> templates, + contentRenderer = new DefaultContentRenderer(() -> templates, db, new TestSiteProperties(Map.of()), - moduleManager); - contentResolver = new ContentResolver(contentRenderer, db); + moduleManager, + contentRepository); + contentResolver = new ContentResolver( + contentRenderer, contentRepository, new DefaultVariantSelector()); } @AfterAll diff --git a/integration-tests/src/test/java/com/condation/cms/content/TaxonomyResolverTest.java b/integration-tests/src/test/java/com/condation/cms/content/TaxonomyResolverTest.java index 5da25a354..2d38e6bf3 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/TaxonomyResolverTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/TaxonomyResolverTest.java @@ -31,6 +31,7 @@ import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import java.util.Map; import java.util.Optional; import org.assertj.core.api.Assertions; @@ -56,6 +57,9 @@ public class TaxonomyResolverTest { @Mock ContentNodeMapper contentNodeMapper; + + @Mock + ContentRepository contentRepository; @Mock Taxonomies taxonomies; @@ -67,7 +71,8 @@ public void setup () { Mockito.lenient().when(db.getTaxonomies()).thenReturn(taxonomies); Mockito.lenient().when(taxonomies.forSlug("tags")).thenReturn(Optional.of(new Taxonomy())); - taxonomyResolver = new TaxonomyResolver(contentRenderer, db, contentNodeMapper); + taxonomyResolver = new TaxonomyResolver( + contentRenderer, db, contentNodeMapper, contentRepository); } @Test diff --git a/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java b/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java index 1e0f20708..c34e729ab 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java @@ -34,9 +34,12 @@ import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import java.io.IOException; import java.net.URISyntaxException; @@ -61,6 +64,7 @@ public class ViewParserTest { static FileDB db; + static ContentRepository contentRepository; static ContentParser parser = new DefaultContentParser(); static MarkdownRenderer markdownRenderer = TestHelper.getRenderer(); static RequestContext requestContext; @@ -92,6 +96,9 @@ static void setup () throws IOException { } }, config); db.init(); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), parser); } @AfterAll static void close () throws Exception { @@ -101,7 +108,7 @@ static void close () throws Exception { @Test public void test_query () throws Exception { final ReadOnlyFile currentNode = db.getReadOnlyFileSystem().resolve("content/query/view.yaml"); - var view = ViewParser.parse(currentNode); + var view = ViewParser.parse(currentNode.getContent()); Assertions.assertThat(view).isNotNull(); @@ -117,7 +124,8 @@ public void test_query () throws Exception { long before = System.currentTimeMillis(); - var page = view.getNodes(db, currentNode, parser, markdownRenderer, context, queryParams, requestContext); + var page = view.getNodes( + db, contentRepository, currentNode, context, queryParams, requestContext); System.out.println("took %d ms".formatted((System.currentTimeMillis() - before))); @@ -133,7 +141,7 @@ public void test_query () throws Exception { @Test public void test_nodelist () throws Exception { final ReadOnlyFile currentNode = db.getReadOnlyFileSystem().resolve("content/view/view.yaml"); - var view = ViewParser.parse(currentNode); + var view = ViewParser.parse(currentNode.getContent()); Assertions.assertThat(view).isNotNull(); @@ -146,7 +154,8 @@ public void test_nodelist () throws Exception { .allowHostAccess(HostAccess.ALL) .engine(engine).build()) { Map> queryParams = new HashMap<>(Map.of("page", List.of("1"))); - var page = view.getNodes(db, currentNode, parser, markdownRenderer, context, queryParams, requestContext); + var page = view.getNodes( + db, contentRepository, currentNode, context, queryParams, requestContext); Assertions.assertThat(page) .isNotNull() @@ -161,7 +170,7 @@ public void test_nodelist () throws Exception { public void test() throws IOException, URISyntaxException { var view = ViewParser.parse(new NIOReadOnlyFile( Path.of(ViewParser.class.getResource("view-nodelist.yaml").toURI()), - Path.of("./"))); + Path.of("./")).getContent()); Assertions.assertThat(view.getTemplate()).isEqualTo("views/test.html"); Assertions.assertThat(view.getContent().getNodelist().getFrom()).isEqualTo("./"); diff --git a/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java b/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java new file mode 100644 index 000000000..7bd66da4a --- /dev/null +++ b/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java @@ -0,0 +1,144 @@ +package com.condation.cms.repository; + +/*- + * #%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.TestDirectoryUtils; +import com.condation.cms.api.Constants; +import com.condation.cms.api.SiteProperties; +import com.condation.cms.api.configuration.Configuration; +import com.condation.cms.api.configuration.configs.SiteConfiguration; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.content.DefaultContentParser; +import com.condation.cms.core.eventbus.DefaultEventBus; +import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; +import com.condation.cms.filesystem.NIOReadOnlyFile; +import java.io.IOException; +import java.nio.file.Path; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class FileSystemContentRepositoryIntegrationTest { + + private static FileDB db; + private static ContentRepository repository; + + @BeforeAll + static void setUp() throws IOException { + var hostBase = Path.of("target/test-repository-" + System.nanoTime()); + TestDirectoryUtils.copyDirectory(Path.of("hosts/test"), hostBase); + + var contentParser = new DefaultContentParser(); + var configuration = new Configuration(); + var siteConfiguration = Mockito.mock(SiteConfiguration.class); + var siteProperties = Mockito.mock(SiteProperties.class); + Mockito.when(siteProperties.id()).thenReturn("repository-test-site"); + Mockito.when(siteConfiguration.siteProperties()).thenReturn(siteProperties); + configuration.add(SiteConfiguration.class, siteConfiguration); + + db = new FileDB(hostBase, new DefaultEventBus(), file -> { + try { + ReadOnlyFile cmsFile = new NIOReadOnlyFile( + file, hostBase.resolve(Constants.Folders.CONTENT)); + return contentParser.parseMeta(cmsFile); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }, configuration); + db.init(); + + repository = new FileSystemContentRepository( + db.getContent(), + db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), + contentParser); + } + + @AfterAll + static void tearDown() throws Exception { + db.close(); + } + + @Test + void loadsIndexedContentAndDelegatesQueries() throws Exception { + var node = repository.get("products/test.md").orElseThrow(); + + Assertions.assertThat(repository.findByUrl("/products/test")) + .contains(node); + Assertions.assertThat(repository.load(node)) + .hasValueSatisfying(document -> + Assertions.assertThat(document.content()) + .contains("Das ist ein Produkt!")); + Assertions.assertThat(repository.isVisible(node)).isTrue(); + Assertions.assertThat(repository.isVisible( + repository.get("alias-hidden.md").orElseThrow())).isFalse(); + Assertions.assertThat(repository.children("products")) + .extracting(contentNode -> contentNode.path()) + .containsExactlyInAnyOrder("products/index.md", "products/test.md"); + Assertions.assertThat(repository.directories("nodelist")) + .extracting(contentNode -> contentNode.path()) + .containsExactlyInAnyOrder("nodelist/folder1", "nodelist/folder2"); + Assertions.assertThat(repository.query() + .where(Constants.MetaFields.TITLE, "ProduktSeite") + .get()) + .extracting(contentNode -> contentNode.path()) + .contains("products/test.md"); + } + + @Test + void loadsSectionsWithTheirParsedContent() throws Exception { + var page = repository.get("page.md").orElseThrow(); + + Assertions.assertThat(repository.sections(page, "left")) + .hasSize(4) + .allSatisfy(section -> { + Assertions.assertThat(section.owner()).isEqualTo(page.path()); + Assertions.assertThat(section.name()).isEqualTo("left"); + Assertions.assertThat(section.content()).contains("Und hier der Inhalt"); + }); + } + + @Test + void resolvesVariantsAndTheirCanonicalContext() throws Exception { + var canonical = repository.get("test.md").orElseThrow(); + var summer = repository.variant(canonical, "summer").orElseThrow(); + + Assertions.assertThat(summer.node().path()) + .isEqualTo(".variants/test/summer/test.md"); + Assertions.assertThat(repository.load(summer.node())) + .hasValueSatisfying(document -> + Assertions.assertThat(document.content()) + .contains("Summer variant content")); + + var context = repository.variantContext(summer.node()); + Assertions.assertThat(context.canonical()).isEqualTo(canonical); + Assertions.assertThat(context.activeVariantId()).contains("summer"); + Assertions.assertThat(context.variants()) + .extracting(variant -> variant.id()) + .containsExactly("summer"); + } +} diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java index 1b5f8c103..333c0d9da 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java @@ -34,10 +34,13 @@ import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.model.ListNode; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.template.functions.list.NodeListFunctionBuilder; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import java.io.IOException; import java.nio.file.Path; @@ -56,6 +59,7 @@ public class NodeListFunctionBuilderNGTest { static NodeListFunctionBuilder nodeList; static FileDB db; + static ContentRepository contentRepository; static DefaultContentParser parser = new DefaultContentParser(); static MarkdownRenderer markdownRenderer = TestHelper.getRenderer(); @@ -83,8 +87,11 @@ static void setup () throws IOException { } }, config); db.init(); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), parser); - nodeList = new NodeListFunctionBuilder(db, + nodeList = new NodeListFunctionBuilder(db, contentRepository, new NIOReadOnlyFile(db.getFileSystem().resolve("content/").resolve("index.md"), hostBase), TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(db, parser))); } @@ -173,7 +180,7 @@ void list_asterix () { @Test void test_from_subfolder () throws IOException { - var nodeList = new NodeListFunctionBuilder(db, + var nodeList = new NodeListFunctionBuilder(db, contentRepository, new NIOReadOnlyFile(db.getFileSystem().resolve("content/nodelist2/index.md"), hostBase), TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(db, parser))); Page page = nodeList.from("./sub_folder/*").page(1).size(10).list(); @@ -189,7 +196,7 @@ void test_from_subfolder () throws IOException { @Test void test_json () throws IOException { - var nodeList = new NodeListFunctionBuilder(db, + var nodeList = new NodeListFunctionBuilder(db, contentRepository, new NIOReadOnlyFile(db.getFileSystem().resolve("content/index.md"), hostBase), TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(db, parser))); Page page = nodeList.from("./json").page(1).size(10).list(); diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java index 0024b5efe..e4b4dee49 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java @@ -32,10 +32,13 @@ import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.model.NavNode; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.template.functions.navigation.NavigationFunction; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import java.io.IOException; import java.nio.file.Path; @@ -55,6 +58,7 @@ public class NavigationFunctionNGTest { static NavigationFunction navigationFunction; private static FileDB db; + private static ContentRepository contentRepository; static MarkdownRenderer markdownRenderer = TestHelper.getRenderer(); static Path hostBase = Path.of("hosts/test/"); @@ -80,8 +84,11 @@ static void init() throws IOException { } }, config); db.init(); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); defaultContentParser = new DefaultContentParser(); - navigationFunction = new NavigationFunction(db, + navigationFunction = new NavigationFunction(db, contentRepository, db.getReadOnlyFileSystem().contentBase().resolve("nav/index.md"), TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); } @@ -128,7 +135,7 @@ public void test_visibility() { @Test public void test_path() throws Exception { - var sut = new NavigationFunction(db, + var sut = new NavigationFunction(db, contentRepository, db.getReadOnlyFileSystem().contentBase().resolve("nav3/folder1/index.md") , TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); @@ -143,7 +150,7 @@ public void test_path() throws Exception { @Test public void test_json () throws IOException { - var navigationFunction = new NavigationFunction(db, + var navigationFunction = new NavigationFunction(db, contentRepository, db.getReadOnlyFileSystem().contentBase().resolve("nav/index.md"), TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java index 5baba428c..267a2856f 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java @@ -31,10 +31,13 @@ import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.markdown.MarkdownRenderer; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.DefaultContentParser; import com.condation.cms.content.template.functions.query.QueryFunction; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import java.io.IOException; import java.nio.file.Path; @@ -52,6 +55,7 @@ public class QueryFunctionTest { static QueryFunction query; private static FileDB db; + private static ContentRepository contentRepository; static MarkdownRenderer markdownRenderer = TestHelper.getRenderer(); @AfterAll @@ -79,8 +83,11 @@ static void init() throws IOException { } }, config); db.init(); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); defaultContentParser = new DefaultContentParser(); - query = new QueryFunction(db, + query = new QueryFunction(db, contentRepository, new NIOReadOnlyFile(Path.of("hosts/test/content/nav/index.md"), hostBase), TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); } diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java index 6a081f994..24ed64525 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java @@ -25,7 +25,9 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.extensions.http.HttpHandler; import com.condation.cms.api.model.NavNode; +import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.api.utils.RequestUtil; import com.condation.cms.content.template.functions.navigation.NavigationFunction; @@ -75,7 +77,10 @@ public boolean handle(Request request, Response response, Callback callback) thr return true; } - NavigationFunction navFN = new NavigationFunction(db, startNode, requestContext); + var contentRepository = requestContext.get(InjectorFeature.class) + .injector().getInstance(ContentRepository.class); + NavigationFunction navFN = new NavigationFunction( + db, contentRepository, startNode, requestContext); var navNodes = navFN.contentType(contentType).list(start, depth); 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 aab7eac39..e86b00253 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 @@ -31,12 +31,14 @@ import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.EventBusFeature; +import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; import com.condation.cms.api.feature.features.ConfigurationFeature; import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.modules.api.annotation.Extension; @@ -52,7 +54,6 @@ 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.content.CollectionRouteResolver; import com.condation.cms.modules.ui.utils.FormHelper; import com.condation.cms.modules.ui.utils.MarkdownHelper; @@ -389,7 +390,9 @@ public Object getContentNode (Map parameters) { if (selectedNode != null && !variantId.isBlank() && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { - var selectedVariant = new VariantResolver(db).loadVariant(selectedNode, variantId); + var selectedVariant = getContext().get(InjectorFeature.class) + .injector().getInstance(ContentRepository.class) + .variant(selectedNode, variantId); if (selectedVariant.isPresent()) { selectedNode = selectedVariant.get().node(); activeVariantId = selectedVariant.get().id(); 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 index b05d95bae..0043ab4db 100644 --- 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 @@ -29,12 +29,12 @@ 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.repository.ContentRepository; 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; @@ -123,7 +123,7 @@ public Object create(Map parameters) throws RPCException { var db = getDB(parameters); var requestedNode = findContentNode(db, uri); - var canonicalNode = getVariantResolver(db).resolveContext(requestedNode).canonical(); + var canonicalNode = getContentRepository().variantContext(requestedNode).canonical(); var selectedTemplate = copyContent ? canonicalNode.getMetaValue(Constants.MetaFields.TEMPLATE, "") : template; @@ -233,7 +233,7 @@ public Object delete(Map parameters) throws RPCException { var db = getDB(parameters); var requestedNode = findContentNode(db, uri); - var variantContext = getVariantResolver(db).resolveContext(requestedNode); + var variantContext = getContentRepository().variantContext(requestedNode); var variant = variantContext.variants().stream() .filter(candidate -> candidate.id().equals(variantId)) .findFirst() @@ -279,7 +279,7 @@ public Object get(Map parameters) throws RPCException { var db = getDB(parameters); var contentNode = findContentNode(db, uri); - var variantContext = getVariantResolver(db).resolveContext(contentNode); + var variantContext = getContentRepository().variantContext(contentNode); var variants = variantContext.variants() .stream() .sorted(Comparator.comparing(Variant::id)) @@ -316,8 +316,8 @@ private ContentNode findContentNode(DB db, String uri) throws RPCException { )); } - protected VariantResolver getVariantResolver(DB db) { - return getContext().get(InjectorFeature.class).injector().getInstance(VariantResolver.class); + protected ContentRepository getContentRepository() { + return getContext().get(InjectorFeature.class).injector().getInstance(ContentRepository.class); } protected ConfigurableVariantSelector getConfigurableVariantSelector() { 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 index a7dd319dd..0e0e6c683 100644 --- 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 @@ -30,10 +30,11 @@ 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.repository.ContentRepository; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.variants.Variant; +import com.condation.cms.api.variants.VariantContext; 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; @@ -73,7 +74,7 @@ class RemoteVariantEndpointTest { private EventBus eventBus; @Mock - private VariantResolver variantResolver; + private ContentRepository contentRepository; @Mock private ConfigurableVariantSelector configurableVariantSelector; @@ -90,8 +91,8 @@ class RemoteVariantEndpointTest { void setUp() { endpoint = new RemoteVariantEndpoint() { @Override - protected VariantResolver getVariantResolver(DB db) { - return variantResolver; + protected ContentRepository getContentRepository() { + return contentRepository; } @Override @@ -133,8 +134,8 @@ void getReturnsVariantsSortedById() throws RPCException { new Variant("summer", summerNode), new Variant("campaign", campaignNode) ); - when(variantResolver.resolveContext(node)).thenReturn( - new VariantResolver.VariantContext(node, Optional.empty(), variants) + when(contentRepository.variantContext(node)).thenReturn( + new VariantContext(node, Optional.empty(), variants) ); @SuppressWarnings("unchecked") @@ -165,8 +166,8 @@ void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { ); 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) + when(contentRepository.variantContext(summer)).thenReturn( + new VariantContext(canonical, Optional.of("summer"), variants) ); @SuppressWarnings("unchecked") @@ -183,8 +184,8 @@ void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { 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()) + when(contentRepository.variantContext(node)).thenReturn( + new VariantContext(node, Optional.empty(), List.of()) ); @SuppressWarnings("unchecked") @@ -262,8 +263,8 @@ void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception 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( + when(contentRepository.variantContext(canonical)).thenReturn( + new VariantContext( canonical, Optional.empty(), List.of(new Variant("summer", summer)) @@ -288,8 +289,8 @@ void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception 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()) + when(contentRepository.variantContext(canonical)).thenReturn( + new VariantContext(canonical, Optional.empty(), List.of()) ); assertThatThrownBy(() -> endpoint.delete(Map.of( From fd9d7591238a1ae446ab674efb3ec050c1fc5760 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Mon, 14 Sep 2026 15:49:45 +0200 Subject: [PATCH 2/8] refactoring and usage of new repository --- .../condation/cms/api/db/ContentQuery.java | 13 ++ .../features/TemplateEngineFeature.java | 2 +- .../cms/api/mapper/ContentNodeMapper.java | 40 +--- .../cms/api/repository/ContentRepository.java | 4 + .../repository/MutableContentRepository.java | 44 ++++ .../cms/api/template/TemplateEngine.java | 4 +- .../cms/content/CollectionResolver.java | 21 -- .../cms/content/ContentRenderer.java | 2 - .../cms/content/DefaultContentRenderer.java | 43 ++-- ...ariantSelectorConfigurationRepository.java | 51 +--- .../condation/cms/content/ViewResolver.java | 9 +- .../AbstractCurrentNodeFunction.java | 40 +++- .../functions/list/NodeListFunction.java | 45 ++-- .../list/NodeListFunctionBuilder.java | 12 +- .../navigation/NavigationFunction.java | 94 ++++---- .../functions/query/QueryFunction.java | 21 +- .../functions/taxonomy/TaxonomyFunction.java | 18 +- .../cms/content/views/NodeResolver.java | 9 +- .../cms/content/views/model/View.java | 11 +- .../cms/content/CollectionResolverTest.java | 35 +-- ...ntSelectorConfigurationRepositoryTest.java | 33 +-- .../navigation/NavigationFunctionITest.java | 6 +- .../navigation/NavigationFunctionTest.java | 16 +- .../serivce/impl/NodeTranslationService.java | 52 ++--- .../impl/SiteContentRepositoryService.java | 32 +++ .../FileSystemContentRepository.java | 114 ++++++++- .../metadata/query/ExtendableQuery.java | 5 + .../FileSystemContentRepositoryTest.java | 31 +++ .../cms/server/configs/SiteModule.java | 23 +- .../cms/server/host/Initializer.java | 10 +- .../functions/impl/AbstractNodeFunction.java | 46 ++-- .../functions/impl/NodeFunction.java | 10 +- .../cms/content/views/ViewParserTest.java | 11 +- .../list/NodeListFunctionBuilderNGTest.java | 18 +- .../navigation/NavigationFunctionNGTest.java | 19 +- .../functions/query/QueryFunctionTest.java | 6 +- .../system/api/handlers/ApiEndpoints.java | 11 +- .../api/handlers/v1/NavigationHandler.java | 19 +- .../system/api/handlers/v1/QueryHandler.java | 10 +- .../system/api/helpers/NodeHelper.java | 5 +- .../system/api/helpers/QueryParser.java | 6 +- .../system/api/services/ContentService.java | 37 +-- .../system/api/helpers/QueryParserTest.java | 21 +- .../api/services/ContentServiceTest.java | 7 +- .../AbstractRemoteMethodeExtension.java | 22 ++ .../RemoteContentEndpointsExtension.java | 219 ++++++++---------- .../remotemethods/RemoteFileEnpoints.java | 114 ++++++--- .../remotemethods/RemotePageEnpoints.java | 77 +++--- .../RemoteTranslationEnpoints.java | 38 ++- .../remotemethods/RemoteVariantEndpoint.java | 126 +++++----- .../RemoteWorkflowEndpointsExtension.java | 52 ++--- .../modules/ui/http/RemoteCallHandler.java | 11 +- .../RemoteContentEndpointsExtensionTest.java | 48 ++-- .../remotemethods/RemoteFileEnpointsTest.java | 86 ++++--- .../remotemethods/RemotePageEnpointsTest.java | 40 ++-- .../RemoteVariantEndpointTest.java | 39 ++-- .../RemoteWorkflowEndpointsExtensionTest.java | 17 +- .../ui/http/RemoteCallHandlerTest.java | 30 +-- 58 files changed, 1036 insertions(+), 949 deletions(-) create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/MutableContentRepository.java create mode 100644 cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java diff --git a/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java b/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java index a654998f9..69dba2380 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java @@ -25,6 +25,7 @@ import com.condation.cms.api.Constants; import java.util.List; import java.util.Map; +import java.util.function.BiPredicate; /** * @@ -91,6 +92,18 @@ ContentQuery within( ContentQuery expression(final String expressions); + /** + * Adds backend-specific comparison operators without exposing the concrete + * query implementation to callers. + */ + default ContentQuery customOperators( + Map> operators) { + if (!operators.isEmpty()) { + throw new UnsupportedOperationException("custom query operators are not supported"); + } + return this; + } + /** * Restricts the query to items whose title matches the input. * diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/TemplateEngineFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/TemplateEngineFeature.java index a31b5a2e4..880bd2b82 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/TemplateEngineFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/TemplateEngineFeature.java @@ -38,7 +38,7 @@ public record TemplateEngineFeature(TemplateEngine templateEngine) implements Fe public String render(String template, Map model, RequestContext requestContext) { try { - var templateModel = new TemplateEngine.Model(null, null, requestContext); + var templateModel = new TemplateEngine.Model(null, requestContext); templateModel.values.putAll(model); return templateEngine.render(template, templateModel); } catch (IOException ioe) { diff --git a/cms-api/src/main/java/com/condation/cms/api/mapper/ContentNodeMapper.java b/cms-api/src/main/java/com/condation/cms/api/mapper/ContentNodeMapper.java index 8c48fcfc5..d1d600ed7 100644 --- a/cms-api/src/main/java/com/condation/cms/api/mapper/ContentNodeMapper.java +++ b/cms-api/src/main/java/com/condation/cms/api/mapper/ContentNodeMapper.java @@ -21,18 +21,14 @@ * #L% */ import com.condation.cms.api.Constants; -import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.content.MapAccess; 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.feature.features.MarkdownRendererFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.api.utils.NodeUtil; -import java.io.IOException; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,34 +40,22 @@ @Slf4j public class ContentNodeMapper { - private final DB db; - private final ContentParser contentParser; - - protected Optional parse(ReadOnlyFile node) { - try { - //Path rel = contentBase.relativize(node); - if (node.isDirectory()) { - node = node.resolve("index.md"); - } - var md = contentParser.parse(node); - - return Optional.of(md); - } catch (IOException ex) { - log.error(null, ex); - } - return Optional.empty(); - } + private final ContentRepository contentRepository; public ListNode toListNode(final ContentNode node, final RequestContext context, final int excerptLength) { var name = NodeUtil.getName(node); - final ReadOnlyFile contentBase = db.getFileSystem().contentBase(); - var temp_path = contentBase.resolve(node.path()); - var url = HTTPUtil.modifyUrl(node.url(), context); - - var md = parse(temp_path); - var excerpt = NodeUtil.excerpt(node, md.get().content(), excerptLength, context.get(MarkdownRendererFeature.class).markdownRenderer()); + var rawContent = ""; + try { + rawContent = contentRepository.load(node) + .map(document -> document.content()) + .orElse(""); + } catch (Exception ex) { + log.error("could not load content for {}", node.path(), ex); + } + var excerpt = NodeUtil.excerpt(node, rawContent, excerptLength, + context.get(MarkdownRendererFeature.class).markdownRenderer()); return new ListNode(name, url, excerpt, MapAccess.of(node.data())); } diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java b/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java index 11c7fc213..939f0b68d 100644 --- a/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java +++ b/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java @@ -63,6 +63,10 @@ public interface ContentRepository { VariantContext variantContext(ContentNode node); + default Optional variantSelectorId(ContentNode node) { + return Optional.empty(); + } + ContentQuery query(); ContentQuery query(String startPath); diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/MutableContentRepository.java b/cms-api/src/main/java/com/condation/cms/api/repository/MutableContentRepository.java new file mode 100644 index 000000000..59cba0e4c --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/MutableContentRepository.java @@ -0,0 +1,44 @@ +package com.condation.cms.api.repository; + +/*- + * #%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 java.io.IOException; +import java.util.Map; + +/** Backend-neutral mutations for managed content. */ +public interface MutableContentRepository extends ContentRepository { + + void save(String path, Map metadata, String content) throws IOException; + + void createDirectory(String path) throws IOException; + + void delete(String path) throws IOException; + + void deleteRecursively(String path) throws IOException; + + void move(String sourcePath, String targetPath) throws IOException; + + boolean resourceExists(String path); + + void setVariantSelectorId(ContentNode node, String selectorId) throws IOException; +} diff --git a/cms-api/src/main/java/com/condation/cms/api/template/TemplateEngine.java b/cms-api/src/main/java/com/condation/cms/api/template/TemplateEngine.java index bb06a014c..46050df67 100644 --- a/cms-api/src/main/java/com/condation/cms/api/template/TemplateEngine.java +++ b/cms-api/src/main/java/com/condation/cms/api/template/TemplateEngine.java @@ -21,7 +21,6 @@ * #L% */ import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.theme.Theme; import java.io.IOException; @@ -49,12 +48,11 @@ default String renderFromString(final String templateString, final TemplateEngin public static class Model { public final Map values = new HashMap<>(); - public final ReadOnlyFile contentFile; public final ContentNode contentNode; public final RequestContext requestContext; public Model copy() { - var copy = new Model(this.contentFile, this.contentNode, this.requestContext); + var copy = new Model(this.contentNode, this.requestContext); copy.values.putAll(this.values); diff --git a/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java b/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java index 1cb0e3a61..0874d56d7 100644 --- a/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java @@ -80,13 +80,7 @@ private Optional render( CurrentCollectionItemFeature.class, new CurrentCollectionItemFeature(collectionItem)); - var sourceDB = sourceDB(route.definition()); - var collectionFile = sourceDB.getFileSystem().collectionsBase().resolve(collectionItem.path()); - if (!collectionFile.exists()) { - return Optional.empty(); - } var content = contentRenderer.renderCollection( - collectionFile, node, collectionItem, route.detail().template(), @@ -94,19 +88,4 @@ private Optional render( return Optional.of(new DefaultContentResponse(content, Constants.DEFAULT_CONTENT_TYPE, node)); } - private DB sourceDB(CollectionDefinition definition) { - var sourceSite = definition.sourceSite(); - if (sourceSite.isEmpty()) { - return db; - } - var siteConfiguration = configuration.get(SiteConfiguration.class); - if (siteConfiguration != null - && siteConfiguration.siteProperties().id().equals(sourceSite.get())) { - return db; - } - return ServiceRegistry.getInstance().get(sourceSite.get(), SiteDBService.class) - .orElseThrow(() -> new IllegalStateException( - "collection source site is not available: " + sourceSite.get())) - .db(); - } } diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java index b26f4f552..406ce5550 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java @@ -24,7 +24,6 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.Page; import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.taxonomy.Taxonomy; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; @@ -50,7 +49,6 @@ Map> renderSections( final RequestContext context) throws IOException; String renderCollection( - final ReadOnlyFile collectionFile, final ContentNode collectionNode, final CollectionItem item, final String template, 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 5dc54af6a..60ce4b23b 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 @@ -27,7 +27,6 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.taxonomy.Taxonomy; import com.condation.cms.api.extensions.ContentQueryOperatorExtensionPoint; import com.condation.cms.api.extensions.TemplateModelExtendingExtensionPoint; @@ -115,9 +114,8 @@ public String render( ContentDocument document, RequestContext context, Map> sectionEntries) throws IOException { - var contentFile = db.getFileSystem().contentBase().resolve(document.node().path()); return renderResolved( - contentFile, + document.node(), context, new ResolvedRenderInput( document.node().path(), @@ -140,10 +138,9 @@ public Map> renderSections( Map> renderedSections = new HashMap<>(); for (var section : sections) { try { - var sectionFile = db.getFileSystem().contentBase().resolve(section.id()); var sectionNode = contentRepository.get(section.id()); var renderedContent = renderResolved( - sectionFile, + sectionNode.orElse(null), context, new ResolvedRenderInput( section.id(), @@ -177,15 +174,10 @@ public String renderTaxonomyContent( Map meta, Page page, Map> sectionEntries) throws IOException { - var contentFile = document - .map(ContentDocument::node) - .map(ContentNode::path) - .map(path -> db.getFileSystem().contentBase().resolve(path)) - .orElseGet(() -> db.getFileSystem().contentBase().resolve("index.md")); var rawContent = document.map(ContentDocument::content).orElse(""); var node = document.map(ContentDocument::node); return renderResolved( - contentFile, + node.orElse(null), context, new ResolvedRenderInput( node.map(ContentNode::path).orElse("index.md"), @@ -213,9 +205,8 @@ public String renderView( View view, RequestContext requestContext, Page page) throws IOException { - var viewFile = db.getFileSystem().contentBase().resolve(document.node().path()); return renderResolved( - viewFile, + document.node(), requestContext, new ResolvedRenderInput( document.node().path(), @@ -234,7 +225,6 @@ private String renderContent(final String rawContent, final RequestContext conte @Override public String renderCollection( - ReadOnlyFile collectionFile, ContentNode collectionNode, CollectionItem item, String template, @@ -242,7 +232,7 @@ public String renderCollection( var meta = new HashMap<>(item.meta()); meta.put("template", template); return renderResolved( - collectionFile, + collectionNode, context, new ResolvedRenderInput( collectionNode.url(), @@ -257,7 +247,7 @@ public String renderCollection( } private String renderResolved( - ReadOnlyFile contentFile, + ContentNode currentNode, RequestContext context, ResolvedRenderInput input, Consumer modelExtending) throws IOException { @@ -267,8 +257,7 @@ private String renderResolved( var rawContent = input.rawContent(); var contentNode = input.contentNode(); TemplateEngine.Model model = new TemplateEngine.Model( - contentFile, - contentNode.orElse(null), + contentNode.orElse(currentNode), context); modelExtending.accept(model); @@ -283,13 +272,13 @@ private String renderResolved( namespace.add(Constants.TemplateNamespaces.NODE, "properties", new MapAccess((NodeProperties.createNodeProperties(contentNode.orElse(null), siteProperties)))); - NavigationFunction navigationFunction = createNavigationFunction(contentFile, context); + NavigationFunction navigationFunction = createNavigationFunction(currentNode, context); namespace.add(Constants.TemplateNamespaces.CMS, "navigation", navigationFunction); - NodeListFunctionBuilder nodeListFunction = createNodeListFunction(contentFile, context); + NodeListFunctionBuilder nodeListFunction = createNodeListFunction(currentNode, context); namespace.add(Constants.TemplateNamespaces.CMS, "nodeList", nodeListFunction); - QueryFunction queryFunction = createQueryFunction(contentFile, context); + QueryFunction queryFunction = createQueryFunction(currentNode, context); namespace.add(Constants.TemplateNamespaces.CMS, "query", queryFunction); namespace.add(Constants.TemplateNamespaces.CMS, "geo", new GeoFunction()); @@ -348,7 +337,7 @@ protected MarkdownFunction createMarkdownFunction(final RequestContext context) return new MarkdownFunction(context.get(MarkdownRendererFeature.class).markdownRenderer()); } - protected QueryFunction createQueryFunction(final ReadOnlyFile contentFile, final RequestContext context) { + protected QueryFunction createQueryFunction(final ContentNode currentNode, final RequestContext context) { Map> customOperators = new HashMap<>(); @@ -359,19 +348,19 @@ protected QueryFunction createQueryFunction(final ReadOnlyFile contentFile, fina .forEach(extension -> customOperators.put(extension.getOperator(), extension.getPredicate())); var queryFn = new QueryFunction( - db, contentRepository, contentFile, context, customOperators); + contentRepository, currentNode, context, customOperators); queryFn.setContentType(siteProperties.defaultContentType()); return queryFn; } - protected NodeListFunctionBuilder createNodeListFunction(final ReadOnlyFile contentFile, final RequestContext context) { - var nlFn = new NodeListFunctionBuilder(db, contentRepository, contentFile, context); + protected NodeListFunctionBuilder createNodeListFunction(final ContentNode currentNode, final RequestContext context) { + var nlFn = new NodeListFunctionBuilder(contentRepository, currentNode, context); nlFn.contentType(siteProperties.defaultContentType()); return nlFn; } - protected NavigationFunction createNavigationFunction(final ReadOnlyFile contentFile, final RequestContext context) { - var navFn = new NavigationFunction(db, contentRepository, contentFile, context); + protected NavigationFunction createNavigationFunction(final ContentNode currentNode, final RequestContext context) { + var navFn = new NavigationFunction(contentRepository, currentNode, context); navFn.contentType(siteProperties.defaultContentType()); return navFn; } 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 index 967d66ec5..c13b7f5a1 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java @@ -23,12 +23,9 @@ 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.repository.ContentRepository; -import com.condation.cms.core.content.io.YamlHeaderUpdater; +import com.condation.cms.api.repository.MutableContentRepository; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.yaml.snakeyaml.LoaderOptions; @@ -45,57 +42,23 @@ public class VariantSelectorConfigurationRepository { public static final String CONFIG_FILE_NAME = "variants.yaml"; private static final String SELECTOR_PROPERTY = "selector"; - private final DB db; private final ContentRepository contentRepository; + private final MutableContentRepository mutableContentRepository; - public VariantSelectorConfigurationRepository(DB db, ContentRepository contentRepository) { - this.db = db; + public VariantSelectorConfigurationRepository(ContentRepository contentRepository, + MutableContentRepository mutableContentRepository) { this.contentRepository = contentRepository; + this.mutableContentRepository = mutableContentRepository; } 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; + return contentRepository.variantSelectorId(node).orElse(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 = contentRepository.variantContext(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); + mutableContentRepository.setVariantSelectorId(node, selectorId); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java b/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java index 9225c4ac6..796173b2c 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java @@ -23,7 +23,6 @@ import com.condation.cms.api.content.ContentResponse; import com.condation.cms.api.content.DefaultContentResponse; 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.RequestFeature; import com.condation.cms.api.request.RequestContext; @@ -42,15 +41,12 @@ public class ViewResolver { private final ContentRenderer contentRenderer; - private final DB db; private final ContentRepository contentRepository; public ViewResolver( ContentRenderer contentRenderer, - DB db, ContentRepository contentRepository) { this.contentRenderer = contentRenderer; - this.db = db; this.contentRepository = contentRepository; } @@ -81,12 +77,9 @@ public Optional getViewContent(final RequestContext context, fi return Optional.empty(); } var view = ViewParser.parse(document.get().content()); - var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); - var page = view.getNodes( - db, contentRepository, - contentFile, + contentNode, context.get(RequestExtensions.class).getContext(), context.get(RequestFeature.class).queryParameters(), context); diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java index df6af628e..17b32078b 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/AbstractCurrentNodeFunction.java @@ -20,14 +20,11 @@ * along with this program. If not, see . * #L% */ -import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.feature.features.IsPreviewFeature; -import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.repository.ContentRepository; -import com.condation.cms.api.utils.PathUtil; import lombok.RequiredArgsConstructor; /** @@ -37,10 +34,8 @@ @RequiredArgsConstructor public abstract class AbstractCurrentNodeFunction { - protected final DB db; - protected final ReadOnlyFile currentNode; + protected final ContentNode currentNode; protected final ContentRepository contentRepository; - protected final ContentNodeMapper contentNodeMapper; protected final RequestContext context; protected boolean isPreview() { @@ -60,9 +55,8 @@ protected String getPreviewMode() { return IsPreviewFeature.Mode.PREVIEW.getValue(); } - protected String repositoryPath(ReadOnlyFile base, String path) { - var basePath = PathUtil.toRelativePath(base, db.getFileSystem().contentBase()) - .replace('\\', '/'); + protected String repositoryPath(String basePath, String path) { + basePath = normalize(basePath); var childPath = path.replace('\\', '/'); while (childPath.startsWith("./")) { childPath = childPath.substring(2); @@ -70,9 +64,35 @@ protected String repositoryPath(ReadOnlyFile base, String path) { while (childPath.startsWith("/")) { childPath = childPath.substring(1); } + if (".".equals(childPath)) { + childPath = ""; + } if (childPath.isEmpty()) { return basePath; } return basePath.isEmpty() ? childPath : basePath + "/" + childPath; } + + protected String currentDirectory() { + if (currentNode == null) { + return ""; + } + var path = normalize(currentNode.path()); + if (currentNode.isDirectory()) { + return path; + } + var separator = path.lastIndexOf('/'); + return separator < 0 ? "" : path.substring(0, separator); + } + + protected static String normalize(String path) { + var normalized = path == null ? "" : path.replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/") && !normalized.isEmpty()) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } } diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java index 67ffae404..1e8c5f236 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunction.java @@ -23,9 +23,7 @@ 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.Page; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; @@ -57,19 +55,17 @@ class NodeListFunction extends AbstractCurrentNodeFunction { return true; }; - public NodeListFunction(DB db, ContentRepository contentRepository, - ReadOnlyFile currentNode, RequestContext context) { + public NodeListFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { super( - db, currentNode, contentRepository, - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); } - public NodeListFunction(DB db, ContentRepository contentRepository, - ReadOnlyFile currentNode, RequestContext context, boolean excludeIndexMd) { - this(db, contentRepository, currentNode, context); + public NodeListFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context, boolean excludeIndexMd) { + this(contentRepository, currentNode, context); this.excludeIndexMd = excludeIndexMd; } @@ -92,17 +88,17 @@ public Page list(String start, int page, int size, int excerptLength, Page getNodes(final String start, int page, int size, int excerptLength, final Comparator comparator, final Predicate nodeFilter) { - ReadOnlyFile baseNode = null; + String baseNode = null; String path = start; // first select base node if (start.startsWith("/")) { - baseNode = db.getFileSystem().contentBase(); + baseNode = ""; path = start.substring(1); } else if (start.equals(".")) { - baseNode = currentNode.getParent(); + baseNode = currentDirectory(); path = ""; } else if (start.startsWith("./")) { - baseNode = currentNode.getParent(); + baseNode = currentDirectory(); path = start.substring(2); } @@ -121,7 +117,7 @@ Page getNodes(final String start, int page, int size, int excerptLengt List allContentNodes = new ArrayList<>(); relevantPaths.forEach((metaNode) -> { - List children = contentRepository.children(metaNode.uri()); + List children = contentRepository.children(metaNode.path()); allContentNodes.addAll(children); }); @@ -140,7 +136,8 @@ Page getNodes(final String start, int page, int size, int excerptLengt .skip(skipCount) .limit(size) .forEach(node -> { - navNodes.add(contentNodeMapper.toListNode(node, context, excerptLength)); + navNodes.add(context.get(ContentNodeMapperFeature.class).contentNodeMapper() + .toListNode(node, context, excerptLength)); }); int totalPages = (int) Math.ceil((float) total / size); @@ -151,7 +148,7 @@ Page getNodes(final String start, int page, int size, int excerptLengt } } - private List getPaths(final ReadOnlyFile base, final String path) { + private List getPaths(final String base, final String path) { Set relevantPaths = new HashSet<>(); var parts = path.split(Constants.SPLIT_PATH_PATTERN); @@ -163,11 +160,12 @@ private List getPaths(final ReadOnlyFile base, final String path) { nodes = contentRepository.directories(repositoryPath(base, part)); } if (parts.length > 1) { - nodes.forEach((node) -> { - var newPath = Arrays.copyOfRange(parts, 1, parts.length); - var subnodes = getPaths(base.resolve(part), String.join("/", newPath)); - relevantPaths.addAll(subnodes); - }); + var remainingPath = String.join("/", Arrays.copyOfRange(parts, 1, parts.length)); + if ("*".equals(part)) { + nodes.forEach(node -> relevantPaths.addAll(getPaths(node.path(), remainingPath))); + } else if (!nodes.isEmpty()) { + relevantPaths.addAll(getPaths(repositoryPath(base, part), remainingPath)); + } } if (parts.length == 1) { relevantPaths.addAll(nodes); @@ -176,7 +174,7 @@ private List getPaths(final ReadOnlyFile base, final String path) { return new ArrayList<>(relevantPaths); } - public Page getNodesFromBase(final ReadOnlyFile base, final String start, final int page, final int pageSize, + public Page getNodesFromBase(final String base, final String start, final int page, final int pageSize, final Comparator comparator, final Predicate nodeFilter) { try { List nodes = new ArrayList<>(); @@ -192,7 +190,8 @@ public Page getNodesFromBase(final ReadOnlyFile base, final String sta .skip(skipCount) .limit(pageSize) .forEach(node -> { - nodes.add(contentNodeMapper.toListNode(node, context)); + nodes.add(context.get(ContentNodeMapperFeature.class).contentNodeMapper() + .toListNode(node, context)); }); int totalPages = (int) Math.ceil((float) total / pageSize); diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java index cbcc425db..f1544baf9 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/list/NodeListFunctionBuilder.java @@ -22,9 +22,7 @@ */ 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.Page; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; @@ -71,17 +69,15 @@ public class NodeListFunctionBuilder extends AbstractCurrentNodeFunction { return filename1.compareTo(filename2); }; - public NodeListFunctionBuilder(DB db, ContentRepository contentRepository, - ReadOnlyFile currentNode, RequestContext context) { + public NodeListFunctionBuilder(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { super( - db, currentNode, contentRepository, - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); - this.nodeListFunction = new NodeListFunction(db, contentRepository, currentNode, context); + this.nodeListFunction = new NodeListFunction(contentRepository, currentNode, context); this.nodeListFunctionNoIndex = new NodeListFunction( - db, contentRepository, currentNode, context, true); + contentRepository, currentNode, context, true); } public NodeListFunctionBuilder from(String from) { diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java index 1d841cb78..90c8375e1 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java @@ -22,8 +22,6 @@ */ 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.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; import com.condation.cms.api.feature.features.HookSystemFeature; import com.condation.cms.api.hooks.HookSystem; @@ -32,7 +30,6 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.utils.NodeUtil; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import java.util.ArrayList; @@ -56,13 +53,11 @@ public class NavigationFunction extends AbstractCurrentNodeFunction { private final HookSystem hookSystem; - public NavigationFunction(DB db, ContentRepository contentRepository, - ReadOnlyFile currentNode, RequestContext context) { + public NavigationFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { super( - db, currentNode, contentRepository, - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); hookSystem = context.get(HookSystemFeature.class).hookSystem(); } @@ -79,26 +74,15 @@ public NavigationFunction named(String name) { */ public List path() { List navNodes = new ArrayList<>(); - var contentBase = db.getFileSystem().contentBase(); var node = currentNode; while (node != null) { - var uri = PathUtil.toRelativeFile(node, contentBase); - final Optional contentNode = contentRepository.get(uri); - if (contentNode.isPresent()) { - var metaNode = contentNode.get(); - var nodeName = NodeUtil.getName(metaNode); - - var path = contentBase.resolve(metaNode.uri()); - final NavNode navNode = new NavNode(nodeName, HTTPUtil.modifyUrl(metaNode.url(), context), isCurrentNode(path)); - if (!navNodes.contains(navNode)) { - navNodes.add(navNode); - } - } - if (node.hasParent()) { - node = node.getParent(); - } else { - node = null; + var nodeName = NodeUtil.getName(node); + final NavNode navNode = new NavNode(nodeName, + HTTPUtil.modifyUrl(node.url(), context), isCurrentNode(node)); + if (!navNodes.contains(navNode)) { + navNodes.add(navNode); } + node = parentNode(node).orElse(null); } navNodes = navNodes.reversed(); @@ -136,19 +120,11 @@ public List list(final String start, final long depth) { private List getNodes(final String start, final int depth) { List navNodes = Collections.emptyList(); if (start.startsWith("/")) { // root - navNodes = getNodesFromBase(db.getFileSystem().contentBase(), start.substring(1), depth); + navNodes = getNodesFromBase("", start.substring(1), depth); } else if (start.equals(".")) { // current - if (currentNode.equals(db.getFileSystem().contentBase())) { - navNodes = getNodesFromBase(currentNode, "", depth); - } else { - navNodes = getNodesFromBase(currentNode.getParent(), "", depth); - } + navNodes = getNodesFromBase(currentDirectory(), "", depth); } else if (start.startsWith("./")) { // subfolder of current - if (currentNode.equals(db.getFileSystem().contentBase())) { - navNodes = getNodesFromBase(currentNode, start.substring(2), depth); - } else { - navNodes = getNodesFromBase(currentNode.getParent(), start.substring(2), depth); - } + navNodes = getNodesFromBase(currentDirectory(), start.substring(2), depth); } if (name != null) { @@ -157,13 +133,13 @@ private List getNodes(final String start, final int depth) { return navNodes; } - private List getSubNodesFromBaseRemoveCurrent(final ReadOnlyFile base, final String start, final int depth, final String toRemovePath) { + private List getSubNodesFromBaseRemoveCurrent(final String base, final String start, final int depth, final String toRemovePath) { List nodes = getNodesFromBase(base, start, depth); return nodes.stream().filter(node -> !node.path().equals(toRemovePath)).toList(); } - private List getNodesFromBase(final ReadOnlyFile base, final String start, final int depth) { + private List getNodesFromBase(final String base, final String start, final int depth) { if (depth == 0) { return Collections.emptyList(); } @@ -193,16 +169,14 @@ private List getNodesFromBase(final ReadOnlyFile base, final String sta }); final List nodes = new ArrayList<>(); - final ReadOnlyFile contentBase = db.getFileSystem().contentBase(); navNodes.forEach((node) -> { var name = NodeUtil.getName(node); - var path = contentBase.resolve(node.uri()); var node_url = HTTPUtil.modifyUrl(node.url(), context); final NavNode navNode = new NavNode( name, node_url, - isCurrentNode(path), - getSubNodesFromBaseRemoveCurrent(path.getParent(), "./", (depth - 1), node_url) + isCurrentNode(node), + getSubNodesFromBaseRemoveCurrent(childBase(node), "./", (depth - 1), node_url) ); nodes.add(navNode); }); @@ -213,13 +187,37 @@ private List getNodesFromBase(final ReadOnlyFile base, final String sta return Collections.emptyList(); } - private boolean isCurrentNode(final ReadOnlyFile node) { - ReadOnlyFile nodeIndex; - if ("index.md".equals(node.getFileName())) { - nodeIndex = node; - } else { - nodeIndex = node.resolve("index.md"); + private boolean isCurrentNode(final ContentNode node) { + return currentNode != null && (node.equals(currentNode) + || normalize(node.url()).equals(normalize(currentNode.url()))); + } + + private String childBase(ContentNode node) { + var path = normalize(node.path()); + return path.endsWith("index.md") + ? path.substring(0, path.length() - "index.md".length()) + : path; + } + + private Optional parentNode(ContentNode node) { + var path = normalize(node.path()); + var directory = node.isDirectory() ? path : parentPath(path); + if (path.endsWith("/index.md")) { + directory = parentPath(path.substring(0, path.length() - "/index.md".length())); + } else if ("index.md".equals(path)) { + return Optional.empty(); + } + if (directory.isEmpty()) { + return contentRepository.get("index.md").filter(parent -> !parent.equals(node)); } - return node.equals(currentNode) || currentNode.equals(nodeIndex); + var parentDirectory = directory; + return contentRepository.get(parentDirectory + "/index.md") + .or(() -> contentRepository.get(parentDirectory)) + .filter(parent -> !parent.equals(node)); + } + + private static String parentPath(String path) { + var separator = path.lastIndexOf('/'); + return separator < 0 ? "" : path.substring(0, separator); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java index a84215eb1..5dd827999 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/query/QueryFunction.java @@ -22,13 +22,10 @@ */ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.feature.features.ContentNodeMapperFeature; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.repository.ContentRepository; -import com.condation.cms.filesystem.metadata.query.ExtendableQuery; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import com.google.common.base.Strings; import java.util.HashMap; @@ -49,23 +46,20 @@ public class QueryFunction extends AbstractCurrentNodeFunction { @Setter private String contentType; - public QueryFunction(DB db, ContentRepository contentRepository, - ReadOnlyFile currentNode, RequestContext context) { + public QueryFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { this( - db, contentRepository, currentNode, context, Map.of()); } - public QueryFunction(DB db, ContentRepository contentRepository, - ReadOnlyFile currentNode, RequestContext context, + public QueryFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context, final Map> queryOperations) { super( - db, currentNode, contentRepository, - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), context); this.extendedQueryOperations = queryOperations; } @@ -83,7 +77,7 @@ private BiFunction nodeMapper() { public ContentQuery create() { var query = contentRepository.query(nodeMapper()); - ((ExtendableQuery)query).addAllCustomOperators(extendedQueryOperations); + query.customOperators(extendedQueryOperations); if (!Strings.isNullOrEmpty(contentType)) { query.contentType(contentType); @@ -92,9 +86,10 @@ public ContentQuery create() { } public ContentQuery create(final String startUri) { - var query = contentRepository.query(startUri, nodeMapper()); + var base = startUri.startsWith("/") ? "" : currentDirectory(); + var query = contentRepository.query(repositoryPath(base, startUri), nodeMapper()); - ((ExtendableQuery)query).addAllCustomOperators(extendedQueryOperations); + query.customOperators(extendedQueryOperations); if (!Strings.isNullOrEmpty(contentType)) { query.contentType(contentType); diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/taxonomy/TaxonomyFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/taxonomy/TaxonomyFunction.java index 59eb2ea37..2b2817b69 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/taxonomy/TaxonomyFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/taxonomy/TaxonomyFunction.java @@ -23,7 +23,7 @@ import com.condation.cms.api.db.taxonomy.Taxonomy; -import com.condation.cms.filesystem.FileDB; +import com.condation.cms.api.db.DB; import com.google.inject.Inject; import java.util.Collections; import java.util.List; @@ -37,26 +37,26 @@ @RequiredArgsConstructor(onConstructor = @__({@Inject})) public class TaxonomyFunction { - private final FileDB fileDB; + private final DB db; public List all () { - return fileDB.getTaxonomies().all(); + return db.getTaxonomies().all(); } public Taxonomy get (String slug) { - return fileDB.getTaxonomies().forSlug(slug).orElse(null); + return db.getTaxonomies().forSlug(slug).orElse(null); } public Set values (String slug) { - var taxonomy = fileDB.getTaxonomies().forSlug(slug); + var taxonomy = db.getTaxonomies().forSlug(slug); if (taxonomy.isEmpty()) { return Collections.emptySet(); } - return fileDB.getTaxonomies().values(taxonomy.get()); + return db.getTaxonomies().values(taxonomy.get()); } public Set values (Taxonomy taxonomy) { - return fileDB.getTaxonomies().values(taxonomy); + return db.getTaxonomies().values(taxonomy); } public String url (final String taxonomy, final String value) { @@ -65,7 +65,7 @@ public String url (final String taxonomy, final String value) { public String getTitle (final String taxonomy) { - var taxo = fileDB.getTaxonomies().forSlug(taxonomy); + var taxo = db.getTaxonomies().forSlug(taxonomy); if (taxo.isPresent()) { return taxo.get().getTitle(); } @@ -75,7 +75,7 @@ public String getTitle (final String taxonomy) { public String getTitle (final String taxonomy, String value) { - var taxo = fileDB.getTaxonomies().forSlug(taxonomy); + var taxo = db.getTaxonomies().forSlug(taxonomy); if (taxo.isPresent()) { return taxo.get().getValueTitle(value); diff --git a/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java b/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java index 369e5dec8..224e95f31 100644 --- a/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/views/NodeResolver.java @@ -23,7 +23,7 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.Page; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; @@ -37,7 +37,6 @@ import lombok.RequiredArgsConstructor; import org.graalvm.polyglot.Context; import org.yaml.snakeyaml.Yaml; -import com.condation.cms.api.db.cms.ReadOnlyFile; /** * @@ -45,15 +44,13 @@ */ @RequiredArgsConstructor public class NodeResolver { - final DB db; final ContentRepository contentRepository; - final ReadOnlyFile currentNode; + final ContentNode currentNode; final Context context; final Map> queryParams; public Page nodelist (View view, RequestContext requestContext) { NodeListFunctionBuilder nodelistBuilder = new NodeListFunctionBuilder( - db, contentRepository, currentNode, requestContext @@ -75,7 +72,7 @@ public Page nodelist (View view, RequestContext requestContext) { public Page query (View view, RequestContext requestContext) { QueryFunction queryFunction = new QueryFunction( - db, contentRepository, currentNode, requestContext); + contentRepository, currentNode, requestContext); context.getBindings("js").putMember("queryParams", queryParams); diff --git a/cms-content/src/main/java/com/condation/cms/content/views/model/View.java b/cms-content/src/main/java/com/condation/cms/content/views/model/View.java index 4500f820a..57ff50104 100644 --- a/cms-content/src/main/java/com/condation/cms/content/views/model/View.java +++ b/cms-content/src/main/java/com/condation/cms/content/views/model/View.java @@ -21,7 +21,7 @@ * #L% */ -import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.Page; import com.condation.cms.api.model.ListNode; import com.condation.cms.api.request.RequestContext; @@ -31,7 +31,6 @@ import java.util.Map; import lombok.Data; import org.graalvm.polyglot.Context; -import com.condation.cms.api.db.cms.ReadOnlyFile; /** * @@ -45,15 +44,15 @@ public class View { private String template; private Content content; - public Page getNodes (final DB db, final ContentRepository contentRepository, - final ReadOnlyFile currentNode, final Context context, final Map> queryParams, + public Page getNodes (final ContentRepository contentRepository, + final ContentNode currentNode, final Context context, final Map> queryParams, final RequestContext requestContext) { if (content.getNodelist() != null) { - return new NodeResolver(db, contentRepository, currentNode, context, queryParams) + return new NodeResolver(contentRepository, currentNode, context, queryParams) .nodelist(this, requestContext); } else if (content.getQuery() != null) { - return new NodeResolver(db, contentRepository, currentNode, context, queryParams) + return new NodeResolver(contentRepository, currentNode, context, queryParams) .query(this, requestContext); } return Page.EMPTY; diff --git a/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java b/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java index b13a17b0e..e15b127ba 100644 --- a/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java @@ -35,9 +35,7 @@ import com.condation.cms.api.content.DefaultContentResponse; import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.Page; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.collection.Collection; import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.collection.CollectionItemMetadata; @@ -45,8 +43,6 @@ 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.core.serivce.ServiceRegistry; -import com.condation.cms.core.serivce.impl.SiteDBService; import java.util.List; import java.util.Map; import java.util.Optional; @@ -65,9 +61,6 @@ class CollectionResolverTest { private final com.condation.cms.api.db.collection.Collections collections = mock(com.condation.cms.api.db.collection.Collections.class); private final Collection collection = mock(Collection.class); - private final DBFileSystem fileSystem = mock(DBFileSystem.class); - private final ReadOnlyFile collectionsBase = mock(ReadOnlyFile.class); - private final ReadOnlyFile itemFile = mock(ReadOnlyFile.class); private final ConcurrentHashMap definitions = new ConcurrentHashMap<>(); private final Configuration configuration = new Configuration(); private CollectionConfiguration collectionConfiguration; @@ -84,23 +77,13 @@ void setUp() throws Exception { configuration.add(CollectionConfiguration.class, collectionConfiguration); when(db.getCollections()).thenReturn(collections); when(collections.collection("blog")).thenReturn(collection); - when(db.getFileSystem()).thenReturn(fileSystem); - when(fileSystem.collectionsBase()).thenReturn(collectionsBase); - when(collectionsBase.resolve("blog/first.md")).thenReturn(itemFile); - when(itemFile.exists()).thenReturn(true); when(renderer.renderCollection( - eq(itemFile), any(), eq(item), anyString(), any())).thenReturn("

First

"); } - @AfterEach - void clearServices() { - ServiceRegistry.getInstance().clear(); - } - @Test void resolvesAnIdRouteAndUsesReloadedDefinitions() throws Exception { define(definition("/old/{id}")); @@ -122,7 +105,6 @@ void resolvesAnIdRouteAndUsesReloadedDefinitions() throws Exception { Assertions.assertThat(context.get(CurrentCollectionItemFeature.class).item()).isEqualTo(item); var node = ArgumentCaptor.forClass(com.condation.cms.api.db.ContentNode.class); verify(renderer).renderCollection( - eq(itemFile), node.capture(), eq(item), eq("collections/detail.html"), @@ -191,7 +173,7 @@ void resolvesAComplexRouteAgainstIndexedMetadata() { } @Test - void readsTheItemFileFromTheConfiguredSourceSite() throws Exception { + void rendersAnItemFromTheConfiguredSourceSiteWithoutExposingItsFile() throws Exception { define( new CollectionDefinition( "blog", @@ -200,31 +182,16 @@ void readsTheItemFileFromTheConfiguredSourceSite() throws Exception { "/shared/{id}", "collections/detail.html"))); when(collection.item("first")).thenReturn(Optional.of(item)); - var sourceDB = mock(DB.class); - var sourceFileSystem = mock(DBFileSystem.class); - var sourceCollectionsBase = mock(ReadOnlyFile.class); - var sourceItemFile = mock(ReadOnlyFile.class); - when(sourceDB.getFileSystem()).thenReturn(sourceFileSystem); - when(sourceFileSystem.collectionsBase()).thenReturn(sourceCollectionsBase); - when(sourceCollectionsBase.resolve("blog/first.md")).thenReturn(sourceItemFile); - when(sourceItemFile.exists()).thenReturn(true); when(renderer.renderCollection( - eq(sourceItemFile), any(), eq(item), anyString(), any())).thenReturn("

Shared

"); - ServiceRegistry.getInstance().register( - "content-site", - SiteDBService.class, - new SiteDBService(sourceDB)); - var response = new CollectionResolver(renderer, db, configuration) .getContent(context("/shared/first")); Assertions.assertThat(response).isPresent(); verify(renderer).renderCollection( - eq(sourceItemFile), any(), eq(item), eq("collections/detail.html"), 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 index 739cbf734..4b9d989f1 100644 --- a/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/VariantSelectorConfigurationRepositoryTest.java @@ -25,42 +25,30 @@ 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 com.condation.cms.api.repository.ContentRepository; -import com.condation.cms.api.variants.VariantContext; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; +import com.condation.cms.api.repository.MutableContentRepository; 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; +import static org.mockito.Mockito.verify; class VariantSelectorConfigurationRepositoryTest { - @TempDir - Path contentBase; - private final ContentNode canonical = new ContentNode( "news/about.md", "/news/about", "about.md", Map.of() ); private VariantSelectorConfigurationRepository repository; + private ContentRepository contentRepository; + private MutableContentRepository mutableContentRepository; @BeforeEach void setUp() { - var db = mock(DB.class); - var fileSystem = mock(DBFileSystem.class); - var contentRepository = mock(ContentRepository.class); - when(db.getFileSystem()).thenReturn(fileSystem); - when(fileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(contentBase); - when(contentRepository.variantContext(canonical)).thenReturn( - new VariantContext(canonical, Optional.empty(), List.of()) - ); - repository = new VariantSelectorConfigurationRepository(db, contentRepository); + contentRepository = mock(ContentRepository.class); + mutableContentRepository = mock(MutableContentRepository.class); + repository = new VariantSelectorConfigurationRepository( + contentRepository, mutableContentRepository); } @Test @@ -71,11 +59,10 @@ void missingConfigurationUsesDateRangeDefault() { @Test void storesOneConfigurationAtCanonicalVariantFolder() throws Exception { + when(contentRepository.variantSelectorId(canonical)).thenReturn(Optional.of("audience")); 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"); + verify(mutableContentRepository).setVariantSelectorId(canonical, "audience"); assertThat(repository.getSelectorId(canonical)).isEqualTo("audience"); } } diff --git a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java index d632dc38c..a008f3b2d 100644 --- a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java +++ b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionITest.java @@ -113,7 +113,7 @@ public void setupRequestContext() { requestContext.add(ConfigurationFeature.class, configFeature); requestContext.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); requestContext.add(ContentParserFeature.class, new ContentParserFeature(contentParser)); - requestContext.add(ContentNodeMapperFeature.class, new ContentNodeMapperFeature(new ContentNodeMapper(db, contentParser))); + requestContext.add(ContentNodeMapperFeature.class, new ContentNodeMapperFeature(new ContentNodeMapper(contentRepository))); requestContext.add(MarkdownRendererFeature.class, new MarkdownRendererFeature(new CMSMarkdownRenderer())); requestContext.add(HookSystemFeature.class, new HookSystemFeature(new CMSHookSystem())); @@ -128,9 +128,9 @@ public static void shutdown() throws Exception { @Test void test_root() { ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext).run(() -> { - var currentNode = db.getFileSystem().contentBase(); + var currentNode = contentRepository.get("index.md").orElseThrow(); NavigationFunction fn = new NavigationFunction( - db, contentRepository, currentNode, requestContext); + contentRepository, currentNode, requestContext); var nodes = fn.json().list("."); diff --git a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java index 37f624490..451ea92ae 100644 --- a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java @@ -24,13 +24,16 @@ import com.condation.cms.content.template.functions.navigation.NavigationFunction; import com.condation.cms.api.content.ContentParser; +import com.condation.cms.api.SiteProperties; 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.feature.features.ContentNodeMapperFeature; import com.condation.cms.api.feature.features.ContentParserFeature; import com.condation.cms.api.feature.features.HookSystemFeature; import com.condation.cms.api.feature.features.MarkdownRendererFeature; +import com.condation.cms.api.feature.features.SitePropertiesFeature; import com.condation.cms.api.hooks.FilterContext; import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.mapper.ContentNodeMapper; @@ -42,6 +45,7 @@ import com.condation.cms.hooksystem.CMSHookSystem; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import org.assertj.core.api.Assertions; @@ -98,10 +102,13 @@ public void setup () { requestContext.add(ContentParserFeature.class, new ContentParserFeature(contentParser)); requestContext.add(MarkdownRendererFeature.class, new MarkdownRendererFeature(markdownRenderer)); requestContext.add(ContentNodeMapperFeature.class, new ContentNodeMapperFeature(contentNodeMapper)); + var siteProperties = Mockito.mock(SiteProperties.class); + Mockito.lenient().when(siteProperties.contextPath()).thenReturn("/"); + requestContext.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); - sut = new NavigationFunction(db, contentRepository, - new NIOReadOnlyFile(Path.of("content/current/"), Path.of("content/")) - , requestContext); + sut = new NavigationFunction(contentRepository, + new ContentNode("current/index.md", "/current", "index.md", Map.of()), + requestContext); } @Test @@ -121,7 +128,8 @@ public void test_hook_path() { Assertions.assertThat(hookCalled).isTrue(); Assertions.assertThat(nodes).containsExactly( - new NavNode("test", "test", false) + new NavNode("test", "test", false), + new NavNode("index.md", "/current", true) ); } diff --git a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/NodeTranslationService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/NodeTranslationService.java index 1e28b7fd4..eb7a74866 100644 --- a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/NodeTranslationService.java +++ b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/NodeTranslationService.java @@ -22,13 +22,8 @@ */ import com.condation.cms.api.Constants; -import com.condation.cms.api.db.DB; -import com.condation.cms.api.eventbus.EventBus; -import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.api.utils.PathUtil; -import com.condation.cms.core.content.ContentResolvingStrategy; -import com.condation.cms.core.content.io.ContentFileParser; -import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.cms.core.serivce.Service; import java.io.IOException; import java.util.HashMap; @@ -42,34 +37,26 @@ @Slf4j public class NodeTranslationService implements Service { - private final DB db; - private final EventBus eventBus; + private final MutableContentRepository contentRepository; - public NodeTranslationService (final DB db, final EventBus eventBus) { - this.db = db; - this.eventBus = eventBus; + public NodeTranslationService (final MutableContentRepository contentRepository) { + this.contentRepository = contentRepository; } public boolean removeTranslation (String uri, String language) { - var contentFile = ContentResolvingStrategy.resolve(uri, db).orElse(null); - - if (contentFile != null) { + var node = contentRepository.findByUrl(uri).or(() -> contentRepository.get(uri)); + if (node.isPresent()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - - Map meta = parser.getHeader(); + var document = contentRepository.load(node.get()).orElseThrow(); + Map meta = new HashMap<>(node.get().data()); var translations = (Map)meta.getOrDefault("translations", new HashMap<>()); translations.remove(language); meta.put("translations", translations); - var path = contentFile.relativePath(); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(path); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, parser.getContent()); + var path = node.get().path(); + contentRepository.save(path, meta, document.content()); log.debug("file {} saved", path); - eventBus.publish(new ReIndexContentMetaDataEvent(path)); - return true; } catch (IOException ex) { log.error("", ex); @@ -82,26 +69,19 @@ public boolean removeTranslation (String uri, String language) { public boolean addTranslation (String uri, String site, String translationUri, String language) { - var contentFile = ContentResolvingStrategy.resolve(uri, db).orElse(null); - - if (contentFile != null) { + var node = contentRepository.findByUrl(uri).or(() -> contentRepository.get(uri)); + if (node.isPresent()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - - Map meta = parser.getHeader(); + var document = contentRepository.load(node.get()).orElseThrow(); + Map meta = new HashMap<>(node.get().data()); var translations = (Map)meta.getOrDefault("translations", new HashMap<>()); translations.put(language, PathUtil.toURL(translationUri)); meta.put("translations", translations); - var path = contentFile.relativePath(); - - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(path); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, parser.getContent()); + var path = node.get().path(); + contentRepository.save(path, meta, document.content()); log.debug("file {} saved", path); - eventBus.publish(new ReIndexContentMetaDataEvent(path)); - return true; } catch (IOException ex) { log.error("", ex); diff --git a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java new file mode 100644 index 000000000..3444d5a57 --- /dev/null +++ b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java @@ -0,0 +1,32 @@ +package com.condation.cms.core.serivce.impl; + +/*- + * #%L + * CMS Core + * %% + * 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.repository.ContentRepository; +import com.condation.cms.api.repository.MutableContentRepository; +import com.condation.cms.core.serivce.Service; + +/** Exposes a site's content boundary to cross-site callers. */ +public record SiteContentRepositoryService( + ContentRepository repository, + MutableContentRepository mutableRepository) implements Service { +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java index 785f45644..b4fbfd5bc 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java @@ -29,17 +29,29 @@ import com.condation.cms.api.repository.ContentDocument; import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.repository.ContentStore; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.api.repository.Section; import com.condation.cms.api.utils.SectionUtil; import com.condation.cms.api.variants.Variant; import com.condation.cms.api.variants.VariantContext; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.BiFunction; +import com.condation.cms.api.Constants; +import com.condation.cms.api.utils.FileUtils; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.core.content.io.YamlHeaderUpdater; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; import lombok.extern.slf4j.Slf4j; /** @@ -48,7 +60,7 @@ * @author thmar */ @Slf4j -public final class FileSystemContentRepository implements ContentRepository { +public final class FileSystemContentRepository implements MutableContentRepository { private final Content content; private final DBFileSystem fileSystem; @@ -87,6 +99,68 @@ public Optional load(ContentNode node) throws IOException { return Optional.of(new ContentDocument(node, parsedContent.content())); } + @Override + public void save(String path, java.util.Map metadata, String rawContent) + throws IOException { + var target = writablePath(path); + Files.createDirectories(target.getParent()); + YamlHeaderUpdater.saveMarkdownFileWithHeader(target, metadata, rawContent); + fileSystem.flushContentChanges(); + } + + @Override + public void createDirectory(String path) throws IOException { + Files.createDirectories(writablePath(path)); + fileSystem.flushContentChanges(); + } + + @Override + public void delete(String path) throws IOException { + Files.deleteIfExists(writablePath(path)); + fileSystem.flushContentChanges(); + } + + @Override + public void deleteRecursively(String path) throws IOException { + var target = writablePath(path); + if (Files.isDirectory(target)) { + FileUtils.deleteFolder(target); + } else { + Files.deleteIfExists(target); + } + fileSystem.flushContentChanges(); + } + + @Override + public void move(String sourcePath, String targetPath) throws IOException { + var source = writablePath(sourcePath); + var target = writablePath(targetPath); + if (Files.exists(target)) { + throw new java.nio.file.FileAlreadyExistsException(targetPath); + } + Files.createDirectories(target.getParent()); + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target); + } + fileSystem.flushContentChanges(); + } + + @Override + public boolean resourceExists(String path) { + return contentStore.exists(normalize(path)); + } + + private Path writablePath(String path) { + var root = fileSystem.resolve(Constants.Folders.CONTENT).toAbsolutePath().normalize(); + var target = root.resolve(normalize(path)).normalize(); + if (!target.startsWith(root) || target.equals(root)) { + throw new IllegalArgumentException("invalid content path: " + path); + } + return target; + } + @Override public List children(String path) { return content.listContent(fileSystem.contentBase(), path); @@ -184,6 +258,44 @@ public VariantContext variantContext(ContentNode node) { return new VariantContext(canonical, activeVariantId, variants(canonical)); } + @Override + public Optional variantSelectorId(ContentNode node) { + var path = variantConfigurationPath(node); + try { + var resource = contentStore.get(path); + if (resource.isEmpty()) { + return Optional.empty(); + } + var data = new Yaml(new SafeConstructor(new LoaderOptions())) + .load(resource.get().content()); + if (data instanceof java.util.Map map + && map.get("selector") instanceof String selector + && !selector.isBlank()) { + return Optional.of(selector.trim()); + } + } catch (Exception ex) { + log.error("could not load variant selector for {}", node.path(), ex); + } + return Optional.empty(); + } + + @Override + public void setVariantSelectorId(ContentNode node, String selectorId) throws IOException { + if (selectorId == null || selectorId.isBlank()) { + throw new IllegalArgumentException("selectorId must not be blank"); + } + var target = writablePath(variantConfigurationPath(node)); + Files.createDirectories(target.getParent()); + YamlHeaderUpdater.saveMetaData(target, java.util.Map.of("selector", selectorId.trim())); + fileSystem.flushContentChanges(); + } + + private String variantConfigurationPath(ContentNode node) { + var canonical = canonicalNode(node); + var parent = parentPath(canonical.path()); + return join(parent, ".variants/" + removeMarkdown(canonical.name()) + "/variants.yaml"); + } + private ContentNode canonicalNode(ContentNode node) { return variantLocation(node) .flatMap(location -> get(location.canonicalPath())) diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/query/ExtendableQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/query/ExtendableQuery.java index aa6bd5392..81c61a101 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/query/ExtendableQuery.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/query/ExtendableQuery.java @@ -48,6 +48,11 @@ public ContentQuery addAllCustomOperators (Map customOperators(Map> operators) { + return addAllCustomOperators(operators); + } @Data public static class Context { diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java index c6203a643..87ca8eb06 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java @@ -36,6 +36,8 @@ import com.condation.cms.api.repository.ContentStore; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Map; @@ -44,9 +46,13 @@ import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; public class FileSystemContentRepositoryTest { + @TempDir + Path contentBase; + private Content content; private DBFileSystem fileSystem; private ContentParser contentParser; @@ -62,6 +68,8 @@ void setUp() { contentParser = mock(ContentParser.class); contentStore = mock(ContentStore.class); when(fileSystem.contentBase()).thenReturn(contentRoot); + when(fileSystem.resolve(com.condation.cms.api.Constants.Folders.CONTENT)) + .thenReturn(contentBase); repository = new FileSystemContentRepository(content, fileSystem, contentStore, contentParser); } @@ -162,6 +170,29 @@ void reusesTheExistingQueryApi() { verify(content).query(org.mockito.ArgumentMatchers.eq("articles"), any()); } + @Test + void writesMovesAndDeletesContentBehindTheMutableRepositoryBoundary() throws Exception { + repository.save("drafts/page.md", Map.of("title", "Draft"), "body"); + var source = contentBase.resolve("drafts/page.md"); + Assertions.assertThat(Files.readString(source)) + .contains("title: Draft", "body"); + + repository.move("drafts/page.md", "published/page.md"); + var target = contentBase.resolve("published/page.md"); + Assertions.assertThat(source).doesNotExist(); + Assertions.assertThat(target).exists(); + + repository.deleteRecursively("published"); + Assertions.assertThat(contentBase.resolve("published")).doesNotExist(); + verify(fileSystem, org.mockito.Mockito.times(3)).flushContentChanges(); + } + + @Test + void rejectsWritesOutsideTheContentRoot() { + Assertions.assertThatThrownBy(() -> repository.save("../outside.md", Map.of(), "")) + .isInstanceOf(IllegalArgumentException.class); + } + private static ContentNode node(String path) { var name = path.substring(path.lastIndexOf('/') + 1); return new ContentNode(path, "/" + path.replace(".md", ""), name, Map.of()); 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 4e2c15cf1..63c65a6bc 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 @@ -53,6 +53,7 @@ import com.condation.cms.api.messages.MessageSource; import com.condation.cms.api.messaging.Messaging; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.api.repository.ContentStore; import com.condation.cms.api.scheduler.CronJobContext; import com.condation.cms.api.template.TemplateEngine; @@ -163,8 +164,8 @@ public Messaging messaging () { @Provides @Singleton - public ContentNodeMapper contentNodeMapper (DB db, ContentParser contentParser) { - return new ContentNodeMapper(db, contentParser); + public ContentNodeMapper contentNodeMapper (ContentRepository contentRepository) { + return new ContentNodeMapper(contentRepository); } @Provides @@ -329,7 +330,7 @@ public ContentStore contentStore(DB db) { @Provides @Singleton - public ContentRepository contentRepository( + public MutableContentRepository mutableContentRepository( DB db, ContentStore contentStore, ContentParser contentParser) { @@ -340,6 +341,12 @@ public ContentRepository contentRepository( contentParser); } + @Provides + @Singleton + public ContentRepository contentRepository(MutableContentRepository repository) { + return repository; + } + @Provides @Singleton public ExtensionManager extensionManager(DB db, Configuration configuration, Engine engine) throws IOException { @@ -412,10 +419,10 @@ public CollectionResolver collectionResolver( @Provides @Singleton public VariantSelectorConfigurationRepository variantSelectorConfigurationRepository( - FileDB db, - ContentRepository contentRepository + ContentRepository contentRepository, + MutableContentRepository mutableContentRepository ) { - return new VariantSelectorConfigurationRepository(db, contentRepository); + return new VariantSelectorConfigurationRepository(contentRepository, mutableContentRepository); } @Provides @@ -440,8 +447,8 @@ public VariantSelector variantSelector(ConfigurableVariantSelector selector) { @Provides @Singleton public ViewResolver viewResolver(ContentRenderer contentRenderer, - FileDB db, ContentRepository contentRepository) { - return new ViewResolver(contentRenderer, db, contentRepository); + ContentRepository contentRepository) { + return new ViewResolver(contentRenderer, contentRepository); } @Provides diff --git a/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java b/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java index ba9337b99..2ddc857a5 100644 --- a/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java +++ b/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java @@ -27,6 +27,9 @@ import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.NodeTranslationService; import com.condation.cms.core.serivce.impl.SiteDBService; +import com.condation.cms.core.serivce.impl.SiteContentRepositoryService; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.core.serivce.impl.SiteLinkService; import com.condation.cms.core.serivce.impl.SitePropertiesService; import lombok.RequiredArgsConstructor; @@ -45,12 +48,17 @@ public class Initializer { void initServices () { var db = host.injector.getInstance(DB.class); ServiceRegistry.getInstance().register(host.id(), SiteDBService.class, new SiteDBService(db)); + ServiceRegistry.getInstance().register(host.id(), SiteContentRepositoryService.class, + new SiteContentRepositoryService( + host.injector.getInstance(ContentRepository.class), + host.injector.getInstance(MutableContentRepository.class))); var config = host.injector.getInstance(Configuration.class); ServiceRegistry.getInstance().register(host.id(), SiteLinkService.class, new SiteLinkService(config)); ServiceRegistry.getInstance().register(host.id(), SitePropertiesService.class, new SitePropertiesService(config)); - ServiceRegistry.getInstance().register(host.id(), NodeTranslationService.class, new NodeTranslationService(db, host.injector.getInstance(EventBus.class))); + ServiceRegistry.getInstance().register(host.id(), NodeTranslationService.class, + new NodeTranslationService(host.injector.getInstance(MutableContentRepository.class))); } } diff --git a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java index 545ae71aa..e781f8c4d 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java @@ -23,16 +23,13 @@ import com.condation.cms.api.content.MapAccess; 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.db.ContentNode; import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.request.RequestContext; -import com.condation.cms.api.utils.PathUtil; -import com.condation.cms.core.content.ContentResolvingStrategy; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.templates.functions.TemplateFunction; import java.util.HashMap; import java.util.Map; -import java.util.Optional; import lombok.RequiredArgsConstructor; /** @@ -43,7 +40,7 @@ public abstract class AbstractNodeFunction implements TemplateFunction { protected final RequestContext requestContext; - protected void extendMap (Map node, ReadOnlyFile contentFile) { + protected void extendMap (Map node, ContentNode contentNode) { } @@ -56,27 +53,26 @@ public Object invoke(Object... params) { if (!(params[0] instanceof String)) { return null; } - String uri = ContentResolvingStrategy.uriToPath((String)params[0]); - - var db = requestContext.get(InjectorFeature.class).injector().getInstance(DB.class); - var contentBase = db.getFileSystem().contentBase(); - - Optional contentFileOpt = ContentResolvingStrategy.resolve(uri, db); - - if (contentFileOpt.isPresent()) { - var node_uri = PathUtil.toRelativeFile(contentFileOpt.get(), contentBase); - final Optional nodeByUri = db.getContent().byUri(node_uri); - if (nodeByUri.isPresent()) { - var node = new HashMap(); - node.put("meta", new MapAccess(nodeByUri.get().data())); - node.put("uri", PathUtil.toURL(contentFileOpt.get(), contentBase)); - - extendMap(node, contentFileOpt.get()); - - return node; - } + var repository = requestContext.get(InjectorFeature.class).injector() + .getInstance(ContentRepository.class); + var contentNode = repository.findByUrl((String) params[0]) + .or(() -> repository.get(normalize((String) params[0]))); + if (contentNode.isPresent()) { + var node = new HashMap(); + node.put("meta", new MapAccess(contentNode.get().data())); + node.put("uri", contentNode.get().url()); + extendMap(node, contentNode.get()); + return node; } return null; } + + private static String normalize(String value) { + var result = value.replace('\\', '/'); + while (result.startsWith("/")) { + result = result.substring(1); + } + return result; + } } diff --git a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java index a89847ed1..4b043cc7c 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java @@ -21,14 +21,11 @@ * #L% */ -import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.repository.ContentRepository; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.content.ContentRenderer; -import com.condation.cms.content.SectionEntry; import java.io.IOException; import java.util.Map; import lombok.extern.slf4j.Slf4j; @@ -47,14 +44,11 @@ public NodeFunction(RequestContext requestContext) { } @Override - protected void extendMap(Map node, ReadOnlyFile contentFile) { + protected void extendMap(Map node, ContentNode contentNode) { try { - var db = requestContext.get(InjectorFeature.class).injector().getInstance(DB.class); var contentRepository = requestContext.get(InjectorFeature.class) .injector().getInstance(ContentRepository.class); var contentRenderer = requestContext.get(InjectorFeature.class).injector().getInstance(ContentRenderer.class); - var path = PathUtil.toRelativeFile(contentFile, db.getFileSystem().contentBase()); - var contentNode = contentRepository.get(path).orElseThrow(); var sectionEntries = contentRenderer.renderSections( contentRepository.sections(contentNode), requestContext); node.put("sections", sectionEntries); diff --git a/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java b/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java index c34e729ab..bdc7ae6e1 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/views/ViewParserTest.java @@ -30,6 +30,7 @@ import com.condation.cms.api.configuration.configs.SiteConfiguration; import com.condation.cms.api.content.ContentParser; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.mapper.ContentNodeMapper; import com.condation.cms.api.markdown.MarkdownRenderer; @@ -71,7 +72,7 @@ public class ViewParserTest { @BeforeEach void setup_test () throws IOException { - requestContext = TestHelper.requestContext("", parser, markdownRenderer, new ContentNodeMapper(db, parser)); + requestContext = TestHelper.requestContext("", parser, markdownRenderer, new ContentNodeMapper(contentRepository)); } @BeforeAll @@ -125,7 +126,9 @@ public void test_query () throws Exception { long before = System.currentTimeMillis(); var page = view.getNodes( - db, contentRepository, currentNode, context, queryParams, requestContext); + contentRepository, + new ContentNode("query/view.yaml", "/query/view", "view.yaml", Map.of()), + context, queryParams, requestContext); System.out.println("took %d ms".formatted((System.currentTimeMillis() - before))); @@ -155,7 +158,9 @@ public void test_nodelist () throws Exception { .engine(engine).build()) { Map> queryParams = new HashMap<>(Map.of("page", List.of("1"))); var page = view.getNodes( - db, contentRepository, currentNode, context, queryParams, requestContext); + contentRepository, + new ContentNode("view/view.yaml", "/view/view", "view.yaml", Map.of()), + context, queryParams, requestContext); Assertions.assertThat(page) .isNotNull() diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java index 333c0d9da..9d091e11f 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java @@ -91,9 +91,9 @@ static void setup () throws IOException { db.getContent(), db.getFileSystem(), new FileSystemContentStore(db.getFileSystem()), parser); - nodeList = new NodeListFunctionBuilder(db, contentRepository, - new NIOReadOnlyFile(db.getFileSystem().resolve("content/").resolve("index.md"), hostBase), - TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(db, parser))); + nodeList = new NodeListFunctionBuilder(contentRepository, + contentRepository.get("index.md").orElseThrow(), + TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); } @AfterAll static void close () throws Exception { @@ -180,9 +180,9 @@ void list_asterix () { @Test void test_from_subfolder () throws IOException { - var nodeList = new NodeListFunctionBuilder(db, contentRepository, - new NIOReadOnlyFile(db.getFileSystem().resolve("content/nodelist2/index.md"), hostBase), - TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(db, parser))); + var nodeList = new NodeListFunctionBuilder(contentRepository, + contentRepository.get("nodelist2/index.md").orElseThrow(), + TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); Page page = nodeList.from("./sub_folder/*").page(1).size(10).list(); var nodeUris = page.getItems().stream().map(ListNode::path).collect(Collectors.toList()); Assertions.assertThat(nodeUris) @@ -196,9 +196,9 @@ void test_from_subfolder () throws IOException { @Test void test_json () throws IOException { - var nodeList = new NodeListFunctionBuilder(db, contentRepository, - new NIOReadOnlyFile(db.getFileSystem().resolve("content/index.md"), hostBase), - TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(db, parser))); + var nodeList = new NodeListFunctionBuilder(contentRepository, + contentRepository.get("index.md").orElseThrow(), + TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); Page page = nodeList.from("./json").page(1).size(10).list(); Assertions.assertThat(page.getItems()).hasSize(1); Assertions.assertThat(page.getItems().getFirst().name()).isEqualTo("HTML"); diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java index e4b4dee49..a74f558fa 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java @@ -88,9 +88,9 @@ static void init() throws IOException { db.getContent(), db.getFileSystem(), new FileSystemContentStore(db.getFileSystem()), contentParser); defaultContentParser = new DefaultContentParser(); - navigationFunction = new NavigationFunction(db, contentRepository, - db.getReadOnlyFileSystem().contentBase().resolve("nav/index.md"), - TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); + navigationFunction = new NavigationFunction(contentRepository, + contentRepository.get("nav/index.md").orElseThrow(), + TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(contentRepository))); } protected static DefaultContentParser defaultContentParser; @@ -135,10 +135,9 @@ public void test_visibility() { @Test public void test_path() throws Exception { - var sut = new NavigationFunction(db, contentRepository, - db.getReadOnlyFileSystem().contentBase().resolve("nav3/folder1/index.md") - , - TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); + var sut = new NavigationFunction(contentRepository, + contentRepository.get("nav3/folder1/index.md").orElseThrow(), + TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(contentRepository))); List path = sut.path(); @@ -150,9 +149,9 @@ public void test_path() throws Exception { @Test public void test_json () throws IOException { - var navigationFunction = new NavigationFunction(db, contentRepository, - db.getReadOnlyFileSystem().contentBase().resolve("nav/index.md"), - TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); + var navigationFunction = new NavigationFunction(contentRepository, + contentRepository.get("nav/index.md").orElseThrow(), + TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(contentRepository))); List list = navigationFunction.json().list("/json"); Assertions.assertThat(list).hasSize(1); diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java index 267a2856f..9b42c24c7 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/query/QueryFunctionTest.java @@ -87,9 +87,9 @@ static void init() throws IOException { db.getContent(), db.getFileSystem(), new FileSystemContentStore(db.getFileSystem()), contentParser); defaultContentParser = new DefaultContentParser(); - query = new QueryFunction(db, contentRepository, - new NIOReadOnlyFile(Path.of("hosts/test/content/nav/index.md"), hostBase), - TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); + query = new QueryFunction(contentRepository, + contentRepository.get("nav/index.md").orElseThrow(), + TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(contentRepository))); } protected static DefaultContentParser defaultContentParser; diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java index 0eb3a9ff2..51ff2ea2a 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java @@ -27,7 +27,8 @@ import com.condation.cms.api.extensions.http.APIHandlerExtensionPoint; import com.condation.cms.api.extensions.http.PathMapping; import com.condation.cms.api.feature.features.ConfigurationFeature; -import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.modules.system.api.services.ContentService; import com.condation.cms.modules.system.api.handlers.v1.NavigationHandler; import com.condation.cms.modules.system.api.handlers.v1.QueryHandler; @@ -50,23 +51,25 @@ public PathMapping getMapping() { var siteProperties = getContext().get(ConfigurationFeature.class).configuration().get(SiteConfiguration.class).siteProperties(); var whitelist = siteProperties.getOrDefault("api.whitelist", List.of("")); + var contentRepository = getRequestContext().get(InjectorFeature.class) + .injector().getInstance(ContentRepository.class); mapping.add(PathSpec.from("/v1/content/*"), "GET", new ContentHandler(new ContentService( - getContext().get(DBFeature.class).db(), + contentRepository, new HashSet<>(whitelist) )) ); mapping.add(PathSpec.from("/v1/navigation/*"), "GET", - new NavigationHandler(getContext().get(DBFeature.class).db(), getRequestContext()) + new NavigationHandler(contentRepository, getRequestContext()) ); mapping.add(PathSpec.from("/v1/query"), "POST", - new QueryHandler(getContext().get(DBFeature.class).db()) + new QueryHandler(contentRepository) ); return mapping; diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java index 24ed64525..ff17c8eb0 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/NavigationHandler.java @@ -22,10 +22,8 @@ */ import com.condation.cms.api.Constants; -import com.condation.cms.api.db.DB; import com.condation.cms.api.extensions.http.HttpHandler; import com.condation.cms.api.model.NavNode; -import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.utils.HTTPUtil; @@ -48,12 +46,12 @@ */ public class NavigationHandler implements HttpHandler { - private final DB db; + private final ContentRepository contentRepository; private final RequestContext requestContext; - public NavigationHandler(final DB db, final RequestContext requestContext) { + public NavigationHandler(final ContentRepository contentRepository, final RequestContext requestContext) { this.requestContext = requestContext; - this.db = db; + this.contentRepository = contentRepository; } @Override @@ -69,18 +67,19 @@ public boolean handle(Request request, Response response, Callback callback) thr var depth = Integer.valueOf(queryParameters.getOrDefault("depth", List.of("1")).getFirst()); var contentType = queryParameters.getOrDefault("contentType", List.of(Constants.ContentTypes.HTML)).getFirst(); - var startNode = db.getFileSystem().contentBase().resolve(uri); + var requestUrl = uri.isEmpty() ? "/" : "/" + uri; + var repositoryPath = uri; + var startNode = contentRepository.findByUrl(requestUrl) + .or(() -> contentRepository.get(repositoryPath)); - if (startNode == null) { + if (startNode.isEmpty()) { response.setStatus(404); callback.succeeded(); return true; } - var contentRepository = requestContext.get(InjectorFeature.class) - .injector().getInstance(ContentRepository.class); NavigationFunction navFN = new NavigationFunction( - db, contentRepository, startNode, requestContext); + contentRepository, startNode.get(), requestContext); var navNodes = navFN.contentType(contentType).list(start, depth); diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/QueryHandler.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/QueryHandler.java index bd2e9f7df..8b8836b41 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/QueryHandler.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/v1/QueryHandler.java @@ -21,7 +21,7 @@ * #L% */ -import com.condation.cms.api.db.DB; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.extensions.http.HttpHandler; import static com.condation.cms.core.configuration.GSONProvider.GSON; import com.condation.cms.modules.system.api.helpers.QueryParser; @@ -36,11 +36,11 @@ public class QueryHandler implements HttpHandler { - private final DB db; + private final ContentRepository contentRepository; private final QueryParser queryParser; - public QueryHandler(final DB db) { - this.db = db; + public QueryHandler(final ContentRepository contentRepository) { + this.contentRepository = contentRepository; this.queryParser = new QueryParser(); } @@ -48,7 +48,7 @@ public QueryHandler(final DB db) { public boolean handle(Request request, Response response, Callback callback) throws Exception { try { String body = Content.Source.asString(request); - Object result = queryParser.parse(db, body); + Object result = queryParser.parse(contentRepository, body); response.getHeaders().add(HttpHeader.CONTENT_TYPE, "application/json; charset=utf-8"); Content.Sink.write(response, true, GSON.toJson(result), callback); diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/NodeHelper.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/NodeHelper.java index 2eb4f414b..77848b746 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/NodeHelper.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/NodeHelper.java @@ -25,7 +25,8 @@ import com.condation.cms.api.feature.features.ConfigurationFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.HTTPUtil; -import com.condation.cms.filesystem.metadata.PageMetaData; +import com.condation.cms.api.db.NodeVisibility; +import com.condation.cms.api.Constants; import java.util.Collections; import java.util.Map; import org.eclipse.jetty.server.Request; @@ -40,7 +41,7 @@ private NodeHelper() { } public static Map getLinks(ContentNode node, Request request) { - if (!PageMetaData.isVisible(node) || !PageMetaData.isPage(node)) { + if (!NodeVisibility.isVisible(node) || !Constants.NodeType.PAGE.equals(node.nodeType())) { return Collections.emptyMap(); } return getLinks(node.uri(), request); diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/QueryParser.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/QueryParser.java index 0ee3cb513..debbda6c3 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/QueryParser.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/helpers/QueryParser.java @@ -23,16 +23,16 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.DB; +import com.condation.cms.api.repository.ContentRepository; import static com.condation.cms.core.configuration.GSONProvider.GSON; import java.util.List; import java.util.Map; public class QueryParser { - public Object parse(DB db, String jsonBody) { + public Object parse(ContentRepository contentRepository, String jsonBody) { Map queryMap = GSON.fromJson(jsonBody, Map.class); - ContentQuery query = db.getContent().query((node, a) -> node); + ContentQuery query = contentRepository.query(); if (queryMap.containsKey("contentType")) { query.contentType((String) queryMap.get("contentType")); diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/services/ContentService.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/services/ContentService.java index 1536ed84c..f5c63f937 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/services/ContentService.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/services/ContentService.java @@ -22,9 +22,7 @@ */ 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.repository.ContentRepository; import com.condation.cms.modules.system.api.helpers.NodeHelper; import com.condation.cms.modules.system.api.helpers.WhitelistFilter; import java.util.HashMap; @@ -41,7 +39,7 @@ @RequiredArgsConstructor public class ContentService { - private final DB db; + private final ContentRepository contentRepository; private final Set metaWhiteList; public Optional resolve (String uri, Request request) { @@ -60,33 +58,12 @@ public Optional resolve (String uri, Request request) { } private Optional resolveContentNode(String uri) { - var contentBase = db.getFileSystem().contentBase(); - var contentPath = contentBase.resolve(uri); - ReadOnlyFile contentFile = null; - if (contentPath.exists() && contentPath.isDirectory()) { - // use index.md - var tempFile = contentPath.resolve("index.md"); - if (tempFile.exists()) { - contentFile = tempFile; - } else { - return Optional.empty(); - } - } else { - var temp = contentBase.resolve(uri + ".md"); - if (temp.exists()) { - contentFile = temp; - } else { - return Optional.empty(); - } - } - - var filePath = PathUtil.toRelativeFile(contentFile, contentBase); - if (!db.getContent().isVisible(filePath)) { + var requestUrl = uri.startsWith("/") ? uri : "/" + uri; + var contentNode = contentRepository.findByUrl(requestUrl) + .or(() -> contentRepository.get(uri)); + if (contentNode.isEmpty() || !contentRepository.isVisible(contentNode.get())) { return Optional.empty(); } - - final ContentNode contentNode = db.getContent().byUri(filePath).get(); - - return Optional.ofNullable(contentNode); + return contentNode; } } diff --git a/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java b/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java index 94c81c6d3..04750260c 100644 --- a/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java +++ b/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java @@ -21,9 +21,8 @@ * #L% */ -import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.DB; +import com.condation.cms.api.repository.ContentRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,10 +37,7 @@ public class QueryParserTest { @Mock - private DB db; - - @Mock - private Content content; + private ContentRepository contentRepository; @Mock private ContentQuery contentQuery; @@ -54,14 +50,13 @@ public class QueryParserTest { @BeforeEach void setUp() { queryParser = new QueryParser(); - when(db.getContent()).thenReturn(content); - when(content.query(any())).thenReturn(contentQuery); + when(contentRepository.query()).thenReturn(contentQuery); } @Test void testSimpleQuery() { String json = "{\"contentType\": \"post\"}"; - queryParser.parse(db, json); + queryParser.parse(contentRepository, json); verify(contentQuery).contentType("post"); verify(contentQuery).get(); } @@ -69,7 +64,7 @@ void testSimpleQuery() { @Test void testWhereQuery() { String json = "{\"where\": [{\"field\": \"author\", \"value\": \"John Doe\"}]}"; - queryParser.parse(db, json); + queryParser.parse(contentRepository, json); verify(contentQuery).where("author", "=", "John Doe"); verify(contentQuery).get(); } @@ -78,7 +73,7 @@ void testWhereQuery() { void testOrderByQuery() { String json = "{\"orderby\": {\"field\": \"date\", \"direction\": \"desc\"}}"; when(contentQuery.orderby("date")).thenReturn(sort); - queryParser.parse(db, json); + queryParser.parse(contentRepository, json); verify(sort).desc(); verify(contentQuery).get(); } @@ -86,7 +81,7 @@ void testOrderByQuery() { @Test void testPageQuery() { String json = "{\"page\": {\"number\": 2, \"size\": 10}}"; - queryParser.parse(db, json); + queryParser.parse(contentRepository, json); verify(contentQuery).page(2, 10); } @@ -94,7 +89,7 @@ void testPageQuery() { void testComplexQuery() { String json = "{\"contentType\": \"article\", \"where\": [{\"field\": \"category\", \"value\": \"tech\"}], \"orderby\": {\"field\": \"views\", \"direction\": \"desc\"}}"; when(contentQuery.orderby("views")).thenReturn(sort); - queryParser.parse(db, json); + queryParser.parse(contentRepository, json); verify(contentQuery).contentType("article"); verify(contentQuery).where("category", "=", "tech"); verify(sort).desc(); diff --git a/modules/api-module/src/test/java/com/condation/cms/modules/system/api/services/ContentServiceTest.java b/modules/api-module/src/test/java/com/condation/cms/modules/system/api/services/ContentServiceTest.java index cdf5bbde2..fa38f70b1 100644 --- a/modules/api-module/src/test/java/com/condation/cms/modules/system/api/services/ContentServiceTest.java +++ b/modules/api-module/src/test/java/com/condation/cms/modules/system/api/services/ContentServiceTest.java @@ -31,6 +31,8 @@ import com.condation.cms.content.DefaultContentParser; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import java.nio.file.Path; import java.util.Set; @@ -79,7 +81,10 @@ public static void setup() throws Exception { }, config); db.init(); - contentService = new ContentService(db, Set.of("title")); + var repository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), contentParser); + contentService = new ContentService(repository, Set.of("title")); } @BeforeEach 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 ffa68194b..2e4fa909a 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 @@ -29,9 +29,13 @@ import com.condation.cms.api.feature.features.AuthFeature; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.HookSystemFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableContentRepository; 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.core.serivce.impl.SiteContentRepositoryService; import com.condation.cms.modules.ui.utils.UIHooks; import java.nio.file.Path; import java.util.Map; @@ -65,6 +69,24 @@ protected DB getDB (Map parameters) { return getContext().get(DBFeature.class).db(); } } + + protected ContentRepository getContentRepository(Map parameters) { + if (parameters.containsKey(SITE_ID)) { + return ServiceRegistry.getInstance().get((String) parameters.get(SITE_ID), + SiteContentRepositoryService.class).orElseThrow().repository(); + } + return getContext().get(InjectorFeature.class).injector() + .getInstance(ContentRepository.class); + } + + protected MutableContentRepository getMutableContentRepository(Map parameters) { + if (parameters.containsKey(SITE_ID)) { + return ServiceRegistry.getInstance().get((String) parameters.get(SITE_ID), + SiteContentRepositoryService.class).orElseThrow().mutableRepository(); + } + return getContext().get(InjectorFeature.class).injector() + .getInstance(MutableContentRepository.class); + } public static ReadOnlyFile getBase(DBFileSystem fileSystem, String type) { 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 e86b00253..cfb0428f5 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 @@ -23,6 +23,7 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; @@ -38,6 +39,7 @@ import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.api.utils.MapUtil; import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.content.io.YamlHeaderUpdater; @@ -70,7 +72,7 @@ */ @Slf4j @Extension(UIRemoteMethodExtensionPoint.class) -public class RemoteContentEndpointsExtension extends AbstractExtensionPoint implements UIRemoteMethodExtensionPoint { +public class RemoteContentEndpointsExtension extends AbstractRemoteMethodeExtension { @RemoteMethod(name = "content.get", permissions = {Permissions.CONTENT_EDIT}) public Object getContent(Map parameters) throws RPCException { @@ -79,18 +81,14 @@ public Object getContent(Map parameters) throws RPCException { Map result = new HashMap<>(); result.put("uri", target.uri()); - if (target.file().exists()) { - try { - ContentFileParser parser = new ContentFileParser(target.file()); - result.put(Parameters.CONTENT, parser.getContent()); - result.put("meta", parser.getHeader()); - } catch (IOException ex) { - log.error("", ex); - throw new RPCException(0, ex.getMessage()); - } - } else { - throw new RPCException(404, "content not found"); - } + try { + var document = loadTarget(target, parameters); + result.put(Parameters.CONTENT, document.content()); + result.put("meta", document.metadata()); + } catch (IOException ex) { + log.error("", ex); + throw new RPCException(0, ex.getMessage()); + } return result; } @@ -103,18 +101,13 @@ public Object setContent(Map parameters) throws RPCException { Map result = new HashMap<>(); result.put("uri", target.uri()); - if (target.file().exists()) { - try { - ContentFileParser parser = new ContentFileParser(target.file()); - - Map meta = parser.getHeader(); - YamlHeaderUpdater.saveMarkdownFileWithHeader(target.writableFile(db), meta, updatedContent); - refresh(target, db); - log.debug(LOG_PATTERN, target.uri()); - } catch (IOException ex) { - log.error("", ex); - throw new RPCException(0, ex.getMessage()); - } + try { + var document = loadTarget(target, parameters); + saveTarget(target, parameters, db, document.metadata(), updatedContent); + log.debug(LOG_PATTERN, target.uri()); + } catch (IOException ex) { + log.error("", ex); + throw new RPCException(0, ex.getMessage()); } return result; @@ -123,8 +116,7 @@ public Object setContent(Map parameters) throws RPCException { @RemoteMethod(name = "content.replace", permissions = {Permissions.CONTENT_EDIT}) public Object replaceContent(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); + var repository = getMutableContentRepository(parameters); var replacement = (String)parameters.get(Parameters.CONTENT); int start = NumberUtils.toInt(parameters.getOrDefault("start", -1l)); @@ -138,21 +130,17 @@ public Object replaceContent(Map parameters) throws RPCException throw new RPCException("replacement must not be null"); } - var contentFile = contentBase.resolve(uri); - - if (contentFile != null) { + var node = repository.get(uri); + if (node.isPresent()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - - var content = parser.getContent(); + var document = repository.load(node.get()).orElseThrow(); + var content = document.content(); var contextPath = getContext().get(SitePropertiesFeature.class).siteProperties().contextPath(); var updatedContent = MarkdownHelper.replaceImage(contextPath, content, start, end, replacement); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, parser.getHeader(), updatedContent); + repository.save(uri, node.get().data(), updatedContent); log.debug(LOG_PATTERN, uri); } catch (IOException ex) { log.error("", ex); @@ -172,20 +160,15 @@ public Object setMeta(Map parameters) throws RPCException { Map result = new HashMap<>(); result.put("uri", target.uri()); - if (target.file().exists()) { - try { - ContentFileParser parser = new ContentFileParser(target.file()); - - Map meta = parser.getHeader(); + try { + var document = loadTarget(target, parameters); + Map meta = new HashMap<>(document.metadata()); YamlHeaderUpdater.mergeFlatMapIntoNestedMap(meta, update); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(target.writableFile(db), meta, parser.getContent()); - refresh(target, db); + saveTarget(target, parameters, db, meta, document.content()); log.debug(LOG_PATTERN, target.uri()); - } catch (IOException ex) { + } catch (IOException ex) { log.error("", ex); throw new RPCException(0, ex.getMessage()); - } } return result; @@ -195,8 +178,7 @@ private record Update (String uri, Map> meta) {} @RemoteMethod(name = "meta.set.batch", permissions = {Permissions.CONTENT_EDIT}) public Object setMetaBatch(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); + var repository = getMutableContentRepository(parameters); Map result = new HashMap<>(); result.put("endpoint", "meta.set.batch"); @@ -211,19 +193,15 @@ public Object setMetaBatch(Map parameters) throws RPCException { try { updates.forEach(update -> { - var contentFile = contentBase.resolve(update.uri); - - if (contentFile != null) { + var node = repository.get(update.uri); + if (node.isPresent()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - - Map fileMeta = parser.getHeader(); + var document = repository.load(node.get()).orElseThrow(); + Map fileMeta = new HashMap<>(node.get().data()); var metaUpdated = MetaConverter.convertMeta(update.meta); YamlHeaderUpdater.mergeFlatMapIntoNestedMap(fileMeta, metaUpdated); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(update.uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, fileMeta, parser.getContent()); + repository.save(update.uri, fileMeta, document.content()); log.debug(LOG_PATTERN, update.uri); getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(update.uri)); @@ -242,21 +220,14 @@ public Object setMetaBatch(Map parameters) throws RPCException { @RemoteMethod(name = "content.sectionEntry.delete", permissions = {Permissions.CONTENT_EDIT}) public Object deleteSectionEntry(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); + var repository = getMutableContentRepository(parameters); var uri = (String) parameters.get("uri"); - final Path contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); - - var contentFile = contentBase.resolve(uri); Map result = new HashMap<>(); result.put("uri", uri); - if (contentFile != null - && PathUtil.isChild(contentBase, contentFile) - && Files.exists(contentFile) - && !Files.isDirectory(contentFile) - ) { + if (repository.resourceExists(uri)) { try { - Files.delete(contentFile); + repository.delete(uri); getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); } catch (Exception ex) { log.error("", ex); @@ -269,8 +240,7 @@ public Object deleteSectionEntry(Map parameters) throws RPCExcep @RemoteMethod(name = "content.sectionEntry.add", permissions = {Permissions.CONTENT_EDIT}) public Object addSectionEntry(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); + var repository = getMutableContentRepository(parameters); var content = (String) parameters.getOrDefault(Parameters.CONTENT, ""); var parentUri = contentUri(parameters, "parentUri"); @@ -283,12 +253,9 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio var uri = UIFileNameUtil.createSectionEntryFileName(parentUri, section, sectionEntryName); - var contentFile = contentBase.resolve(uri); - Map result = new HashMap<>(); result.put("uri", uri); - if (contentFile != null) { - try { + try { Map meta = Map.of( "template", template, "title", title, @@ -296,18 +263,13 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio "order", 1000) ); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, content); + repository.save(uri, meta, content); log.debug(LOG_PATTERN, uri); getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(uri)); - } catch (IOException ex) { + } catch (IOException ex) { log.error("", ex); throw new RPCException(0, ex.getMessage()); - } - } else { - throw new RPCException(0, "invalid uri"); } return result; @@ -316,7 +278,7 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio @RemoteMethod(name = "content.node", permissions = {Permissions.CONTENT_EDIT}) public Object getContentNode (Map parameters) { final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); + var repository = getContentRepository(parameters); var url = (String) parameters.get("url"); @@ -332,35 +294,12 @@ public Object getContentNode (Map parameters) { path = "/" + path; } - var selectedNode = db.getContent().byUrl(path).orElse(null); - ReadOnlyFile contentFile = null; - String canonicalUri = null; - if (selectedNode != null) { - canonicalUri = selectedNode.path(); - contentFile = contentBase.resolve(canonicalUri); - } else { - var filePath = path.substring(1); - var contentPath = contentBase.resolve(filePath); - if (contentPath.exists() && contentPath.isDirectory()) { - var indexFile = contentPath.resolve("index.md"); - if (indexFile.exists()) { - contentFile = indexFile; - } - } else { - var markdownFile = contentBase.resolve(filePath + ".md"); - if (markdownFile.exists()) { - contentFile = markdownFile; - } - } - if (contentFile != null) { - canonicalUri = PathUtil.toRelativeFile(contentFile, contentBase); - selectedNode = db.getContent().byPath(canonicalUri).orElse(null); - } - } + var selectedNode = repository.findByUrl(path).orElse(null); + String canonicalUri = selectedNode == null ? null : selectedNode.path(); Map result = new HashMap<>(); result.put("url", url); - if (contentFile == null || canonicalUri == null) { + if (selectedNode == null || canonicalUri == null) { var collectionConfiguration = getContext().get(ConfigurationFeature.class) .configuration().get(CollectionConfiguration.class); var collectionRoute = new CollectionRouteResolver(db, collectionConfiguration).resolve(path); @@ -390,16 +329,13 @@ public Object getContentNode (Map parameters) { if (selectedNode != null && !variantId.isBlank() && !ConfigurableVariantSelector.CANONICAL_VARIANT_ID.equalsIgnoreCase(variantId)) { - var selectedVariant = getContext().get(InjectorFeature.class) - .injector().getInstance(ContentRepository.class) - .variant(selectedNode, variantId); + var selectedVariant = repository.variant(selectedNode, variantId); if (selectedVariant.isPresent()) { selectedNode = selectedVariant.get().node(); activeVariantId = selectedVariant.get().id(); } } if (selectedNode != null) { - contentFile = contentBase.resolve(selectedNode.path()); result.put("uri", selectedNode.path()); } else { result.put("uri", canonicalUri); @@ -407,15 +343,17 @@ public Object getContentNode (Map parameters) { result.put("canonicalUri", canonicalUri); result.put("variantId", activeVariantId); - var sectionEntries = db.getContent().listSectionEntries(contentFile); + var sectionEntries = selectedNode == null + ? java.util.List.of() + : loadSections(repository, selectedNode); Map> sectionMap = new HashMap<>(); sectionEntries.forEach(sectionEntry -> { - String uri = sectionEntry.uri(); - String name = SectionUtil.getSectionName(sectionEntry.name()); - var index = sectionEntry.getMetaValue(Constants.MetaFields.LAYOUT_ORDER, Constants.DEFAULT_SECTION_ENTRY_LAYOUT_ORDER); + String uri = sectionEntry.id(); + String name = sectionEntry.name(); + var index = MapUtil.getValue(sectionEntry.data(), Constants.MetaFields.LAYOUT_ORDER, Constants.DEFAULT_SECTION_ENTRY_LAYOUT_ORDER); sectionMap.computeIfAbsent(name, k -> new ArrayList<>()) - .add(new SectionEntry(sectionEntry.name(), index, "", sectionEntry.data(), uri)); + .add(new SectionEntry(fileName(uri), index, "", sectionEntry.data(), uri)); }); result.put("sections", sectionMap); @@ -453,11 +391,55 @@ && getRequestContext().has(CurrentCollectionItemFeature.class)) { var uri = contentUri(parameters); return new EditableTarget( uri, - db.getFileSystem().contentBase().resolve(uri), + null, null, null); } + private EditableDocument loadTarget(EditableTarget target, Map parameters) + throws IOException, RPCException { + if (target.collectionName() != null) { + if (!target.file().exists()) { + throw new RPCException(404, "content not found"); + } + var parser = new ContentFileParser(target.file()); + return new EditableDocument(parser.getHeader(), parser.getContent()); + } + var repository = getContentRepository(parameters); + var node = repository.get(target.uri()) + .orElseThrow(() -> new RPCException(404, "content not found")); + var document = repository.load(node) + .orElseThrow(() -> new RPCException(404, "content not found")); + return new EditableDocument(new HashMap<>(node.data()), document.content()); + } + + private void saveTarget(EditableTarget target, Map parameters, DB db, + Map metadata, String content) throws IOException { + if (target.collectionName() != null) { + YamlHeaderUpdater.saveMarkdownFileWithHeader(target.writableFile(db), metadata, content); + refresh(target, db); + } else { + getMutableContentRepository(parameters).save(target.uri(), metadata, content); + getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); + } + } + + private List loadSections( + ContentRepository repository, ContentNode node) { + try { + return repository.sections(node); + } catch (IOException ex) { + log.error("could not load sections for {}", node.path(), ex); + return List.of(); + } + } + + private static String fileName(String path) { + var normalized = path.replace('\\', '/'); + var separator = normalized.lastIndexOf('/'); + return separator < 0 ? normalized : normalized.substring(separator + 1); + } + private void refresh(EditableTarget target, DB db) { if (target.collectionName() != null) { db.getCollections().refresh(target.collectionName(), target.itemId()); @@ -482,4 +464,7 @@ private Path writableFile(DB db) { return db.getFileSystem().resolve(folder).resolve(uri); } } + + private record EditableDocument(Map metadata, String content) { + } } 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 6da94a311..222f1d7af 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 @@ -55,8 +55,6 @@ public class RemoteFileEnpoints extends AbstractRemoteMethodeExtension { @RemoteMethod(name = "files.list", permissions = {Permissions.CONTENT_EDIT}) public Object list(Map parameters) throws RPCException { - final DB db = getDB(parameters); - var uri = (String) parameters.getOrDefault("uri", ""); if (uri == null) { uri = ""; @@ -65,6 +63,10 @@ public Object list(Map parameters) throws RPCException { uri = uri.substring(1); } var type = (String) parameters.get("type"); + if ("content".equals(type)) { + return listContent(parameters, uri); + } + final DB db = getDB(parameters); var contentBase = getBase(db.getFileSystem(), type); var contentFile = contentBase.resolve(uri); @@ -82,7 +84,7 @@ public Object list(Map parameters) throws RPCException { contentFile.children().stream() .filter(child -> !SectionUtil.isSectionEntry(child.getFileName())) .filter(child -> !".variants".equals(child.getFileName())) - .map(child -> map(db, type, child)) + .map(this::map) .forEach(files::add); } catch (IOException ex) { log.error("", ex); @@ -106,14 +108,24 @@ public Object list(Map parameters) throws RPCException { @RemoteMethod(name = "files.delete", permissions = {Permissions.CONTENT_EDIT}) public Object delete(Map parameters) throws RPCException { - final DB db = getDB(parameters); - Map result = new HashMap<>(); try { var uri = (String) parameters.getOrDefault("uri", ""); var name = (String) parameters.getOrDefault("name", ""); var type = (String) parameters.get("type"); + if ("content".equals(type)) { + var repository = getMutableContentRepository(parameters); + var path = join(uri, name); + var node = repository.get(path); + var sections = node.isPresent() ? repository.sections(node.get()) : List.of(); + repository.deleteRecursively(path); + for (var section : sections) { + repository.deleteRecursively(section.id()); + } + return result; + } + final DB db = getDB(parameters); var contentBase = getBase(db.getFileSystem(), type); var contentFile = contentBase.resolve(uri).resolve(name); @@ -125,17 +137,6 @@ public Object delete(Map parameters) throws RPCException { FileUtils.deleteFolder(writableBase.resolve(uri).resolve(name)); } else if ("assets".equals(type)) { Files.deleteIfExists(writableBase.resolve(uri).resolve(name)); - } else { - var sections = db.getContent().listSectionEntries(contentFile); - Files.deleteIfExists(writableBase.resolve(uri).resolve(name)); - sections.forEach(node -> { - try { - log.debug("deleting section {}", node.uri()); - FileUtils.deleteFolder(writableBase.resolve(node.uri())); - } catch (IOException ioe) { - log.error("error deleting file {}", node.uri(), ioe); - } - }); } } catch (Exception e) { log.error("", e); @@ -147,7 +148,6 @@ public Object delete(Map parameters) throws RPCException { @RemoteMethod(name = "files.rename", permissions = {Permissions.CONTENT_EDIT}) public Object renameFile(Map parameters) throws RPCException { - final DB db = getDB(parameters); Map result = new HashMap<>(); try { @@ -159,7 +159,28 @@ public Object renameFile(Map parameters) throws RPCException { if (newName == null || newName.isBlank()) { throw new IllegalArgumentException("newName must not be null or blank"); } + if ("content".equals(type)) { + var repository = getMutableContentRepository(parameters); + var source = join(uri, name); + if (name.endsWith(".md")) { + var node = repository.get(source).orElseThrow( + () -> new RPCException("Source content not found: " + source)); + var document = repository.load(node).orElseThrow(); + var metadata = new HashMap<>(node.data()); + metadata.put(Constants.MetaFields.TITLE, newName.trim()); + repository.save(source, metadata, document.content()); + result.put("success", true); + result.put("newName", name); + result.put("title", newName.trim()); + } else { + repository.move(source, join(uri, newName)); + result.put("success", true); + result.put("newName", newName); + } + return result; + } + final DB db = getDB(parameters); var contentBase = getBase(db.getFileSystem(), type); // check if both paths are in host directory @@ -215,14 +236,17 @@ public Object renameFile(Map parameters) throws RPCException { @RemoteMethod(name = "folders.create", permissions = {Permissions.CONTENT_EDIT}) public Object createFolder(Map parameters) throws RPCException { - final DB db = getDB(parameters); - Map result = new HashMap<>(); try { var name = (String) parameters.getOrDefault("name", ""); var uri = (String) parameters.getOrDefault("uri", ""); var type = (String) parameters.get("type"); + if ("content".equals(type)) { + getMutableContentRepository(parameters).createDirectory(join(uri, UIPathUtil.slugify(name))); + return result; + } + final DB db = getDB(parameters); var contentBase = getWritableBase(db.getFileSystem(), type); name = UIPathUtil.slugify(name); @@ -246,14 +270,18 @@ public Object createFolder(Map parameters) throws RPCException { @RemoteMethod(name = "files.create", permissions = {Permissions.CONTENT_EDIT}) public Object createFile(Map parameters) throws RPCException { - final DB db = getDB(parameters); - Map result = new HashMap<>(); try { var uri = (String) parameters.getOrDefault("uri", ""); var name = (String) parameters.getOrDefault("name", ""); var type = (String) parameters.get("type"); + if ("content".equals(type)) { + var path = join(uri, UIPathUtil.slugify(name)); + getMutableContentRepository(parameters).save(path, Map.of(), ""); + return result; + } + final DB db = getDB(parameters); var contentBase = getWritableBase(db.getFileSystem(), type); name = UIPathUtil.slugify(name); @@ -286,8 +314,40 @@ private boolean isMedia (String filename) { || name.endsWith(".svg") || name.endsWith(".gif"); } + + private Object listContent(Map parameters, String uri) { + var repository = getContentRepository(parameters); + List files = repository.children(uri).stream() + .filter(node -> !node.isSectionEntry()) + .filter(node -> !".variants".equals(node.name())) + .map(node -> (File) (node.isDirectory() + ? new Directory(node.name(), node.path()) + : new Content(node.name(), node.path(), node.url(), + node.getMetaValue(Constants.MetaFields.TITLE, node.name())))) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + var parent = parentPath(uri); + if (!uri.isBlank()) { + files.add(new Directory("..", parent)); + } + files.sort((first, second) -> { + if (first.directory() != second.directory()) { + return first.directory() ? -1 : 1; + } + return first.displayName().compareToIgnoreCase(second.displayName()); + }); + return Map.of("uri", uri, "files", files); + } + + private static String join(String parent, String child) { + return parent == null || parent.isBlank() ? child : parent + "/" + child; + } + + private static String parentPath(String path) { + var separator = path.lastIndexOf('/'); + return separator < 0 ? "" : path.substring(0, separator); + } - private File map (DB db, String type, ReadOnlyFile readOnlyFile) { + private File map(ReadOnlyFile readOnlyFile) { if (readOnlyFile.isDirectory()) { return new Directory( readOnlyFile.getFileName(), @@ -299,19 +359,11 @@ private File map (DB db, String type, 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 + readOnlyFile.getFileName() ); } } 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 a245d1e7a..23f0eda2a 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 @@ -70,8 +70,8 @@ public Object searchPages (Map parameters) throws RPCException { query = stringValue; } - final DB db = getContext().get(DBFeature.class).db(); - var hits = db.getContent().searchByTitle(query, VariantSearchMode.ORIGINAL).stream() + var hits = getContentRepository(parameters).query() + .searchByTitle(query).variants(VariantSearchMode.ORIGINAL).get().stream() .map(node -> { String url = node.url(); String previewUrl = HTTPUtil.modifyUrl(url, context); @@ -94,9 +94,7 @@ public Object searchPages (Map parameters) throws RPCException { @RemoteMethod(name = "pages.filter", permissions = {Permissions.CONTENT_EDIT}) public Object filterPages (Map parameters) throws RPCException { - final DB db = getDB(parameters); - - var query = db.getContent().query((node, length) -> node); + var query = getContentRepository(parameters).query(); if (parameters.containsKey("contentType") && parameters.get("contentType") != null) { query.contentType(parameters.get("contentType").toString()); @@ -142,13 +140,9 @@ public Object filterPages (Map parameters) throws RPCException { var pageList = query.page(page, size); - var contentBase = db.getFileSystem().contentBase(); return new Page<>(pageList.getTotalItems(), pageList.getPageSize(), pageList.getTotalPages(), pageList.getPage(), pageList.getItems().stream().map(node -> { - var temp_path = contentBase.resolve(node.uri()); - var url = PathUtil.toURL(temp_path, contentBase); - - url = HTTPUtil.modifyUrl(url, context); + var url = HTTPUtil.modifyUrl(node.url(), context); return new NodeDTO(url, node.data()); }).toList() @@ -157,30 +151,29 @@ public Object filterPages (Map parameters) throws RPCException { @RemoteMethod(name = "page.delete", permissions = {Permissions.CONTENT_EDIT}) public Object deletePage(Map parameters) throws RPCException { - final DB db = getDB(parameters); + var repository = getMutableContentRepository(parameters); Map result = new HashMap<>(); try { var uri = (String) parameters.getOrDefault("uri", ""); var name = (String) parameters.getOrDefault("name", ""); - var contentBase = db.getFileSystem().contentBase(); if (Strings.isNullOrEmpty(name)) { throw new RPCException(0, "filename can not be null"); } - var contentFile = contentBase.resolve(uri).resolve(name); - - log.debug("deleting file {}", contentFile.uri()); - var sections = db.getContent().listSectionEntries(contentFile); - Files.deleteIfExists(db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri).resolve(name)); - sections.forEach(node -> { - try { - log.debug("deleting section {}", node.uri()); - FileUtils.deleteFolder(db.getFileSystem().resolve(node.uri())); + var path = uri.isBlank() ? name : uri + "/" + name; + log.debug("deleting content {}", path); + var node = repository.get(path); + var sections = node.isPresent() ? repository.sections(node.get()) : java.util.List.of(); + repository.delete(path); + sections.forEach(section -> { + try { + log.debug("deleting section {}", section.id()); + repository.deleteRecursively(section.id()); } catch (IOException ioe) { - log.error("error deleting file {}", node.uri(), ioe); + log.error("error deleting section {}", section.id(), ioe); } }); } catch (Exception e) { @@ -193,7 +186,7 @@ public Object deletePage(Map parameters) throws RPCException { @RemoteMethod(name = "page.create", permissions = {Permissions.CONTENT_EDIT}) public Object createPage(Map parameters) throws RPCException { - final DB db = getDB(parameters); + var repository = getMutableContentRepository(parameters); Map result = new HashMap<>(); @@ -205,8 +198,6 @@ public Object createPage(Map parameters) throws RPCException { throw new RPCException(1, "name must not be empty"); } - var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); - var contentType = (String) parameters.getOrDefault("contentType", ""); var pageTemplate = uiHooks().contentTypes().getPageTemplate(contentType); @@ -224,31 +215,17 @@ public Object createPage(Map parameters) throws RPCException { name = UIPathUtil.toValidFilename(name); - Path newFile = null; - if (name.endsWith(".md")) { - newFile = contentBase.resolve(uri).resolve(name); - } else { - newFile = contentBase.resolve(uri).resolve(name).resolve("index.md"); - } - - - if (newFile.isAbsolute()) { - throw new RPCException(1, "absolut path is not supported"); - } else if (Files.exists(newFile)) { - throw new RPCException(1, "directory already exists"); - } else if (!UIPathUtil.isChild(contentBase, newFile)) { - throw new RPCException(1, "invalid path"); - } - Files.createDirectories(newFile.getParent()); - Files.createFile(newFile); - var newURI = PathUtil.toRelativeFile(newFile, contentBase); - getContext().get(EventBusFeature.class).eventBus() - .syncPublish(new ReIndexContentMetaDataEvent(newURI)); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(newFile, meta, ""); - - String url = PathUtil.toURL(newFile, contentBase); - result.put("uri", url); + String newFile; + if (name.endsWith(".md")) { + newFile = uri.isBlank() ? name : uri + "/" + name; + } else { + newFile = (uri.isBlank() ? name : uri + "/" + name) + "/index.md"; + } + if (repository.resourceExists(newFile)) { + throw new RPCException(1, "directory already exists"); + } + repository.save(newFile, meta, ""); + result.put("uri", repository.get(newFile).map(node -> node.url()).orElse(newFile)); } catch (Exception e) { log.error("", e); throw new RPCException(0, e.getMessage()); diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java index e2fb79ab7..7ebe5d91b 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java @@ -59,15 +59,13 @@ public class RemoteTranslationEnpoints extends AbstractRemoteMethodeExtension { @RemoteMethod(name = "translations.get", permissions = {Permissions.CONTENT_EDIT}) public Object get(Map parameters) throws RPCException { - final DB db = getDB(parameters); - Map result = new HashMap<>(); List translations = new ArrayList<>(); result.put("translations", translations); var uri = (String) parameters.getOrDefault("uri", ""); - var contentNodeOpt = db.getContent().byUri(uri); + var contentNodeOpt = getContentRepository(parameters).get(uri); var contentNode = contentNodeOpt.orElseThrow(() -> new RPCException("content node for uri %s not found".formatted(uri))); var siteProperties = getContext().get(ConfigurationFeature.class).configuration().get(SiteConfiguration.class).siteProperties(); @@ -96,30 +94,25 @@ public Object get(Map parameters) throws RPCException { @RemoteMethod(name = "translations.remove", permissions = {Permissions.CONTENT_EDIT}) public Object remove(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); + var repository = getMutableContentRepository(parameters); var uri = (String) parameters.get("uri"); var language = (String) parameters.get("language"); - var contentFile = contentBase.resolve(uri); - Map result = new HashMap<>(); result.put("uri", uri); - if (contentFile != null) { + var node = repository.get(uri); + if (node.isPresent()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - - Map meta = parser.getHeader(); + var document = repository.load(node.get()).orElseThrow(); + Map meta = new HashMap<>(node.get().data()); if (meta.containsKey("translations")) { var translations = (Map) meta.get("translations"); if (!translations.containsKey(language)) { return result; } var oldTranslationUri = (String)translations.remove(language); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, parser.getContent()); + repository.save(uri, meta, document.content()); log.debug("file {} saved", uri); getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(uri)); @@ -151,29 +144,24 @@ public Object remove(Map parameters) throws RPCException { @RemoteMethod(name = "translations.add", permissions = {Permissions.CONTENT_EDIT}) public Object add(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); + var repository = getMutableContentRepository(parameters); var uri = (String) parameters.get("uri"); var language = (String) parameters.get("language"); var translation_url = (String) parameters.get("translationUri"); - var contentFile = contentBase.resolve(uri); - Map result = new HashMap<>(); result.put("uri", uri); - if (contentFile != null) { + var node = repository.get(uri); + if (node.isPresent()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - - Map meta = parser.getHeader(); + var document = repository.load(node.get()).orElseThrow(); + Map meta = new HashMap<>(node.get().data()); var translations = (Map) meta.getOrDefault("translations", new HashMap<>()); translations.put(language, translation_url); meta.put("translations", translations); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, parser.getContent()); + repository.save(uri, meta, document.content()); log.debug("file {} saved", uri); getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(uri)); 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 index 0043ab4db..a3c5afc71 100644 --- 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 @@ -69,8 +69,7 @@ public Object getSelectors(Map parameters) throws RPCException { throw new RPCException(400, "uri must not be blank"); } - var db = getDB(parameters); - var node = findContentNode(db, uri); + var node = findContentNode(parameters, uri); var configurableSelector = getConfigurableVariantSelector(); var repository = getVariantSelectorConfigurationRepository(); @@ -98,8 +97,7 @@ public Object setSelector(Map parameters) throws RPCException { throw new RPCException(400, "unknown variant selector"); } - var db = getDB(parameters); - var node = findContentNode(db, uri); + var node = findContentNode(parameters, uri); try { getVariantSelectorConfigurationRepository().setSelectorId(node, selectorId); return Map.of("selector", selectorId); @@ -121,9 +119,9 @@ public Object create(Map parameters) throws RPCException { throw new RPCException(400, "uri, id and title must not be blank"); } - var db = getDB(parameters); - var requestedNode = findContentNode(db, uri); - var canonicalNode = getContentRepository().variantContext(requestedNode).canonical(); + var repository = getMutableContentRepository(parameters); + var requestedNode = findContentNode(parameters, uri); + var canonicalNode = repository.variantContext(requestedNode).canonical(); var selectedTemplate = copyContent ? canonicalNode.getMetaValue(Constants.MetaFields.TEMPLATE, "") : template; @@ -134,42 +132,39 @@ public Object create(Map parameters) throws RPCException { .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 canonicalPath = canonicalNode.path().replace('\\', '/'); + var separator = canonicalPath.lastIndexOf('/'); + var parent = separator < 0 ? "" : canonicalPath.substring(0, separator); + var fileName = separator < 0 ? canonicalPath : canonicalPath.substring(separator + 1); var pageName = fileName.endsWith(".md") ? fileName.substring(0, fileName.length() - 3) : fileName; - var variantFile = canonicalFile.getParent() - .resolve(".variants") - .resolve(pageName) - .resolve(variantId) - .resolve(fileName); + var variantFolder = join(parent, ".variants/" + pageName + "/" + variantId); + var variantFile = join(variantFolder, fileName); try { - if (!UIPathUtil.isChild(contentBase, variantFile)) { - throw new RPCException(400, "invalid variant path"); - } - if (Files.exists(variantFile)) { + if (repository.resourceExists(variantFile)) { throw new RPCException(409, "variant already exists"); } - var body = copyContent ? new ContentFileParser(canonicalFile.toString()).getContent() : ""; + var body = copyContent + ? repository.load(canonicalNode).orElseThrow().content() + : ""; var sectionCopies = copyContent - ? db.getContent().listSectionEntries(db.getFileSystem().contentBase().resolve(canonicalNode.path())) + ? repository.sections(canonicalNode) .stream() .map(section -> new SectionCopy( - contentBase.resolve(section.path()), - variantFile.getParent().resolve(section.name()) + section.data(), section.content(), + join(variantFolder, fileName(section.id())) )) .toList() : java.util.List.of(); - if (sectionCopies.stream().anyMatch(copy -> Files.exists(copy.target()))) { + if (sectionCopies.stream().anyMatch(copy -> repository.resourceExists(copy.target()))) { throw new RPCException(409, "variant section already exists"); } @@ -181,38 +176,24 @@ public Object create(Map parameters) throws RPCException { meta.put("createdAt", Date.from(Instant.now())); meta.put("createdBy", getUserName()); - Files.createDirectories(variantFile.getParent()); - var createdFiles = new ArrayList(); + var createdFiles = new ArrayList(); try { - YamlHeaderUpdater.saveMarkdownFileWithHeader(variantFile, meta, body); + repository.save(variantFile, meta, body); createdFiles.add(variantFile); for (var sectionCopy : sectionCopies) { - Files.copy( - sectionCopy.source(), - sectionCopy.target(), - StandardCopyOption.COPY_ATTRIBUTES - ); + repository.save(sectionCopy.target(), sectionCopy.metadata(), sectionCopy.content()); createdFiles.add(sectionCopy.target()); } } catch (Exception exception) { for (var createdFile : createdFiles.reversed()) { - Files.deleteIfExists(createdFile); + repository.delete(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, + "uri", variantFile, "url", managerVariantPreviewUrl(canonicalNode.url(), variantId) ); } catch (RPCException exception) { @@ -231,32 +212,20 @@ public Object delete(Map parameters) throws RPCException { throw new RPCException(400, "uri and id must not be blank"); } - var db = getDB(parameters); - var requestedNode = findContentNode(db, uri); - var variantContext = getContentRepository().variantContext(requestedNode); + var repository = getMutableContentRepository(parameters); + var requestedNode = findContentNode(parameters, uri); + var variantContext = repository.variantContext(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(); + var variantFolder = parentPath(variant.node().path()); try { - if (!UIPathUtil.isChild(contentBase, variantFolder) - || !Files.exists(variantFolder) - || !Files.isDirectory(variantFolder)) { + if (!repository.resourceExists(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(); + repository.deleteRecursively(variantFolder); return Map.of( "id", variantId, @@ -277,9 +246,8 @@ public Object get(Map parameters) throws RPCException { throw new RPCException(400, "uri must not be blank"); } - var db = getDB(parameters); - var contentNode = findContentNode(db, uri); - var variantContext = getContentRepository().variantContext(contentNode); + var contentNode = findContentNode(parameters, uri); + var variantContext = getContentRepository(parameters).variantContext(contentNode); var variants = variantContext.variants() .stream() .sorted(Comparator.comparing(Variant::id)) @@ -306,20 +274,16 @@ public Object get(Map parameters) throws RPCException { return result; } - private ContentNode findContentNode(DB db, String uri) throws RPCException { - return db.getContent() - .byPath(uri) - .or(() -> db.getContent().byUrl(uri)) + private ContentNode findContentNode(Map parameters, String uri) throws RPCException { + var repository = getContentRepository(parameters); + return repository.get(uri) + .or(() -> repository.findByUrl(uri)) .orElseThrow(() -> new RPCException( 404, "content node for uri %s not found".formatted(uri) )); } - protected ContentRepository getContentRepository() { - return getContext().get(InjectorFeature.class).injector().getInstance(ContentRepository.class); - } - protected ConfigurableVariantSelector getConfigurableVariantSelector() { return getContext().get(InjectorFeature.class) .injector().getInstance(ConfigurableVariantSelector.class); @@ -347,6 +311,22 @@ private String managerVariantPreviewUrl(String canonicalUrl, String variantId) { + java.net.URLEncoder.encode(variantId, java.nio.charset.StandardCharsets.UTF_8); } - private record SectionCopy(java.nio.file.Path source, java.nio.file.Path target) { + private static String join(String parent, String child) { + return parent.isEmpty() ? child : parent + "/" + child; + } + + private static String parentPath(String path) { + var normalized = path.replace('\\', '/'); + var separator = normalized.lastIndexOf('/'); + return separator < 0 ? "" : normalized.substring(0, separator); + } + + private static String fileName(String path) { + var normalized = path.replace('\\', '/'); + var separator = normalized.lastIndexOf('/'); + return separator < 0 ? normalized : normalized.substring(separator + 1); + } + + private record SectionCopy(Map metadata, String content, String 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 71155b161..f27af8342 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 @@ -74,33 +74,16 @@ public class RemoteWorkflowEndpointsExtension extends AbstractRemoteMethodeExten private static final String TRANSITIONS = "transitions"; private static final String STATUS = "status"; - private Optional getContentTarget(String uri) { - final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); - var contentFile = contentBase.resolve(uri); - - if (!contentFile.exists()) { - return Optional.empty(); - } - - var node_uri = PathUtil.toRelativeFile(contentFile, contentBase); - - var node = db.getContent().byPath(node_uri); + private Optional getContentTarget(Map parameters, String uri) { + var node = getContentRepository(parameters).get(uri); if (node.isEmpty()) { return Optional.empty(); } return Optional.of(new WorkflowTarget( - new ContentNode( - node.get().uri(), - node.get().url(), - node.get().name(), - node.get().data(), - node.get().directory(), - node.get().children(), - node.get().lastmodified()), - contentFile, - db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri), + node.get(), + null, + null, false, null, null)); @@ -127,7 +110,7 @@ && getRequestContext().has(CurrentCollectionItemFeature.class)) { item.collection(), item.id())); } - return getContentTarget(contentUri(parameters)); + return getContentTarget(parameters, contentUri(parameters)); } private record WorkflowTarget( @@ -179,11 +162,10 @@ public Object getTransitions(Map parameters) throws RPCException @RemoteMethod(name = "workflow.pages.unpublished", permissions = {Permissions.CONTENT_EDIT}) public Object unpublishedPages(Map parameters) throws RPCException { - DB db = getDB(parameters); long requestedPage = Math.max(1, NumberUtils.toLong(parameters.getOrDefault("page", 1L))); long requestedSize = Math.clamp(NumberUtils.toLong(parameters.getOrDefault("size", 10L)), 1, 100); Workflow workflow = getContext().get(WorkflowFeature.class).workflow(); - var query = db.getContent().query((node, length) -> node) + var query = getContentRepository(parameters).query() .variants(VariantSearchMode.ORIGINAL); Page page; @@ -199,7 +181,7 @@ public Object unpublishedPages(Map parameters) throws RPCExcepti page = inMemoryPage(unpublished, requestedPage, requestedSize); } - return mapPage(db, page); + return mapPage(page); } @RemoteMethod(name = "workflow.transit", permissions = {Permissions.WORKFLOW_EXECUTE}) @@ -225,15 +207,15 @@ public Object transit(Map parameters) throws RPCException { ensureAllowed(transition); workflow.transit(transitionId, contentNode); - ContentFileParser parser = new ContentFileParser(target.get().file()); - YamlHeaderUpdater.saveMarkdownFileWithHeader( - target.get().writableFile(), contentNode.data(), parser.getContent()); if (target.get().collection()) { + ContentFileParser parser = new ContentFileParser(target.get().file()); + YamlHeaderUpdater.saveMarkdownFileWithHeader( + target.get().writableFile(), contentNode.data(), parser.getContent()); db.getCollections().refresh(target.get().collectionName(), target.get().itemId()); } else { - getContext().get(EventBusFeature.class).eventBus() - .publish(new ReIndexContentMetaDataEvent(contentNode.uri())); - db.getFileSystem().flushContentChanges(); + var repository = getMutableContentRepository(parameters); + var document = repository.load(contentNode).orElseThrow(); + repository.save(contentNode.path(), contentNode.data(), document.content()); } getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); @@ -270,11 +252,9 @@ private Page inMemoryPage(List nodes, long requestedPa return new Page<>(totalItems, requestedSize, totalPages, pageNumber, nodes.subList(from, to)); } - private Page mapPage(DB db, Page page) { - var contentBase = db.getFileSystem().contentBase(); + private Page mapPage(Page page) { List items = page.getItems().stream().map(node -> { - String url = PathUtil.toURL(contentBase.resolve(node.uri()), contentBase); - return new NodeDTO(HTTPUtil.modifyUrl(url, getContext()), node.data()); + return new NodeDTO(HTTPUtil.modifyUrl(node.url(), getContext()), node.data()); }).toList(); return new Page<>(page.getTotalItems(), page.getPageSize(), page.getTotalPages(), page.getPage(), items); } 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 d1ac8e0ea..42ba7a32f 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 @@ -25,6 +25,8 @@ import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; @@ -104,12 +106,13 @@ private void setCurrentContentNode(Request request) { if (uri == null || uri.isBlank()) { return; } - if (!moduleContext.has(DBFeature.class)) { + if (!moduleContext.has(InjectorFeature.class)) { return; } - var content = moduleContext.get(DBFeature.class).db().getContent(); - content.byUri(uri.trim()) - .or(() -> content.byPath(uri.trim())) + var content = moduleContext.get(InjectorFeature.class).injector() + .getInstance(ContentRepository.class); + content.findByUrl(uri.trim()) + .or(() -> content.get(uri.trim())) .ifPresent(node -> requestContext.add( CurrentNodeFeature.class, new CurrentNodeFeature(node) 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 fab6a3c6d..f8916ad3f 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 @@ -45,6 +45,8 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableContentRepository; import java.io.IOException; import java.util.List; import java.util.Map; @@ -87,11 +89,24 @@ class RemoteContentEndpointsExtensionTest { @Mock private Collections collections; + @Mock + private MutableContentRepository contentRepository; + private RemoteContentEndpointsExtension endpoints; @BeforeEach void setUp() { - endpoints = new RemoteContentEndpointsExtension(); + endpoints = new RemoteContentEndpointsExtension() { + @Override + protected ContentRepository getContentRepository(Map parameters) { + return contentRepository; + } + + @Override + protected MutableContentRepository getMutableContentRepository(Map parameters) { + return contentRepository; + } + }; endpoints.setContext(moduleContext); when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); lenient().when(db.getFileSystem()).thenReturn(fileSystem); @@ -102,9 +117,9 @@ void setUp() { @Test void getContent_throwsRPCException_whenParsingFails() throws IOException { - when(contentBase.resolve("broken.md")).thenReturn(contentFile); - when(contentFile.exists()).thenReturn(true); - when(contentFile.getContent()).thenThrow(new IOException("disk error")); + var node = new ContentNode("broken.md", "/broken", "broken.md", Map.of()); + when(contentRepository.get("broken.md")).thenReturn(Optional.of(node)); + when(contentRepository.load(node)).thenThrow(new IOException("disk error")); Map params = Map.of("uri", "broken.md"); @@ -115,9 +130,9 @@ void getContent_throwsRPCException_whenParsingFails() throws IOException { @Test void setContent_throwsRPCException_whenParsingFails() throws IOException { - when(contentBase.resolve("broken.md")).thenReturn(contentFile); - when(contentFile.exists()).thenReturn(true); - when(contentFile.getContent()).thenThrow(new IOException("disk error")); + var node = new ContentNode("broken.md", "/broken", "broken.md", Map.of()); + when(contentRepository.get("broken.md")).thenReturn(Optional.of(node)); + when(contentRepository.load(node)).thenThrow(new IOException("disk error")); Map params = Map.of("uri", "broken.md", "content", "hello"); @@ -134,9 +149,9 @@ void getContentUsesCurrentNodeWhenUriIsOmitted() throws Exception { CurrentNodeFeature.class, new CurrentNodeFeature(new ContentNode(uri, "/about", "about.md", Map.of())) ); - when(contentBase.resolve(uri)).thenReturn(contentFile); - when(contentFile.exists()).thenReturn(true); - when(contentFile.getContent()).thenThrow(new IOException("variant selected")); + var node = new ContentNode(uri, "/about", "about.md", Map.of()); + when(contentRepository.get(uri)).thenReturn(Optional.of(node)); + when(contentRepository.load(node)).thenThrow(new IOException("variant selected")); assertThatThrownBy(() -> ScopedValue.where( RequestContextScope.REQUEST_CONTEXT, @@ -170,10 +185,8 @@ void getContentNodeResolvesUriFromCustomPageUrl() throws Exception { "other.md", Map.of() ); - when(db.getContent()).thenReturn(content); - when(content.byUrl("/total-other-page")).thenReturn(Optional.of(node)); - when(contentBase.resolve("pages/other.md")).thenReturn(contentFile); - when(content.listSectionEntries(contentFile)).thenReturn(List.of()); + when(contentRepository.findByUrl("/total-other-page")).thenReturn(Optional.of(node)); + when(contentRepository.sections(node)).thenReturn(List.of()); var requestContext = new RequestContext(); requestContext.add( @@ -194,7 +207,7 @@ void getContentNodeResolvesUriFromCustomPageUrl() throws Exception { .containsEntry("canonicalUri", "pages/other.md") .containsEntry("contentKind", "content") .containsEntry("supportsVariants", true); - verify(content).byUrl("/total-other-page"); + verify(contentRepository).findByUrl("/total-other-page"); } @Test @@ -208,10 +221,7 @@ void getContentNodeKeepsPublicCollectionRouteAndDisablesVariants() throws Except var metadata = new CollectionItemMetadata( item.id(), item.collection(), item.path(), item.meta()); - when(db.getContent()).thenReturn(content); - when(content.byUrl("/people/jane-doe")).thenReturn(Optional.empty()); - when(contentBase.resolve("people/jane-doe")).thenReturn(nonExistingPath); - when(contentBase.resolve("people/jane-doe.md")).thenReturn(nonExistingPath); + when(contentRepository.findByUrl("/people/jane-doe")).thenReturn(Optional.empty()); when(collections.collection("authors")).thenReturn(authorCollection); when(authorCollection.metadataQuery()).thenReturn(query); when(query.where("slug", "jane-doe")).thenReturn(query); 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 fe26a6b70..f1cd99832 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 @@ -32,6 +32,8 @@ 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.repository.ContentDocument; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.core.content.io.ContentFileParser; import java.nio.file.Files; import java.nio.file.Path; @@ -65,6 +67,9 @@ public class RemoteFileEnpointsTest { @Mock private DBFileSystem dbFileSystem; + @Mock + private MutableContentRepository contentRepository; + @TempDir private Path tempDir; @@ -85,14 +90,11 @@ void rejectsUnsupportedFileTypesInsteadOfReturningNullBases() { } @Test - public void create_folder_with_absolut_path_throws_error() throws RPCException { - - Mockito.when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - Mockito.when(db.getFileSystem()).thenReturn(dbFileSystem); - Mockito.when(dbFileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(Path.of(".")); + public void create_folder_with_absolut_path_throws_error() throws Exception { - - RemoteFileEnpoints fileEndpoints = new RemoteFileEnpoints(); + Mockito.doThrow(new IllegalArgumentException("invalid content path")) + .when(contentRepository).createDirectory(Mockito.anyString()); + RemoteFileEnpoints fileEndpoints = endpoints(); fileEndpoints.setContext(moduleContext); Assertions.assertThatThrownBy(() -> fileEndpoints.createFolder(Map.of( @@ -104,28 +106,15 @@ public void create_folder_with_absolut_path_throws_error() throws RPCException { @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(); + Mockito.when(contentRepository.children("")).thenReturn(List.of(node)); + + var endpoints = endpoints(); endpoints.setContext(moduleContext); @SuppressWarnings("unchecked") @@ -145,21 +134,12 @@ void listUsesContentTitleButKeepsTechnicalName() throws Exception { @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(); + var node = new ContentNode("about.md", "/about", "about.md", + Map.of(Constants.MetaFields.TITLE, "Old title", Constants.MetaFields.TEMPLATE, "page")); + Mockito.when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); + Mockito.when(contentRepository.load(node)).thenReturn(Optional.of(new ContentDocument(node, "Body"))); + + var endpoints = endpoints(); endpoints.setContext(moduleContext); endpoints.renameFile(Map.of( "type", "content", @@ -168,14 +148,28 @@ void renameMarkdownContentUpdatesTitleWithoutChangingFileName() throws Exception "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(); + Mockito.verify(contentRepository).save( + Mockito.eq("about.md"), + Mockito.argThat(meta -> "New title".equals(meta.get(Constants.MetaFields.TITLE)) + && "page".equals(meta.get(Constants.MetaFields.TEMPLATE))), + Mockito.eq("Body")); + } + + private RemoteFileEnpoints endpoints() { + var endpoints = new RemoteFileEnpoints() { + @Override + protected MutableContentRepository getMutableContentRepository(Map parameters) { + return contentRepository; + } + + @Override + protected com.condation.cms.api.repository.ContentRepository getContentRepository( + Map parameters) { + return contentRepository; + } + }; + endpoints.setContext(moduleContext); + return endpoints; } } 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 add4c58b1..eaf0d73ca 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 @@ -33,6 +33,7 @@ import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.repository.ContentRepository; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -67,6 +68,9 @@ public class RemotePageEnpointsTest { @Mock private ContentQuery query; + + @Mock + private ContentRepository contentRepository; @Mock private ContentQuery.Sort sort; @@ -87,7 +91,12 @@ public class RemotePageEnpointsTest { @BeforeEach public void setUp() { - pageEndpoints = new RemotePageEnpoints(); + pageEndpoints = new RemotePageEnpoints() { + @Override + protected ContentRepository getContentRepository(Map parameters) { + return contentRepository; + } + }; pageEndpoints.setContext(moduleContext); Mockito.lenient().when(db.getFileSystem()).thenReturn(fileSystem); @@ -97,17 +106,14 @@ public void setUp() { @Test public void testFilterPages_Success() throws RPCException { // Arrange - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getContent()).thenReturn(content); - when(content.query(any())).thenReturn(query); + when(contentRepository.query()).thenReturn(query); - List expectedNodes = List.of(mock(ContentNode.class)); + List expectedNodes = List.of(new ContentNode( + "test.md", "/test", "test.md", Map.of(Constants.MetaFields.TITLE, "Test"))); when(query.page(Mockito.anyLong(), Mockito.anyLong())).thenReturn(new Page(0, 0, 0, 0, expectedNodes)); when(query.orderby(anyString())).thenReturn(sort); when(sort.desc()).thenReturn(query); - when(contentBase.resolve(Mockito.any())).thenReturn(contentFile); - when(contentBase.relativize(contentFile)).thenReturn(contentFile); when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); Map parameters = new HashMap<>(); @@ -144,9 +150,7 @@ public void testFilterPages_Success() throws RPCException { @Test public void testFilterPages_WithPagination() throws RPCException { // Arrange - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getContent()).thenReturn(content); - when(content.query(any())).thenReturn(query); + when(contentRepository.query()).thenReturn(query); Page expectedPage = new Page<>(100, 10, 10, 1, new ArrayList<>()); when(query.page(1L, 10L)).thenReturn(expectedPage); @@ -167,8 +171,6 @@ public void testFilterPages_WithPagination() throws RPCException { @Test public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { // Arrange - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getContent()).thenReturn(content); when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); when(siteProperties.contextPath()).thenReturn("/cms"); @@ -176,7 +178,10 @@ 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", VariantSearchMode.ORIGINAL)).thenReturn(List.of(node)); + when(contentRepository.query()).thenReturn(query); + when(query.searchByTitle("superman")).thenReturn(query); + when(query.variants(VariantSearchMode.ORIGINAL)).thenReturn(query); + when(query.get()).thenReturn(List.of(node)); Map parameters = new HashMap<>(); parameters.put("query", "superman"); @@ -194,20 +199,21 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { assertThat(hits.get(0).title()).isEqualTo("Superman Returns"); assertThat(hits.get(0).uri()).isEqualTo("/cms/test/test1"); assertThat(hits.get(0).url()).isEqualTo("/test/test1"); - verify(content).searchByTitle("superman", VariantSearchMode.ORIGINAL); + verify(query).searchByTitle("superman"); } @Test public void testSearchPages_missingTitle_fallsBackToEmptyString() throws RPCException { // Arrange - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getContent()).thenReturn(content); when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); when(siteProperties.contextPath()).thenReturn("/"); ContentNode node = new ContentNode("test/test2.md", "/test/test2", "test2.md", new HashMap<>()); - when(content.searchByTitle("", VariantSearchMode.ORIGINAL)).thenReturn(List.of(node)); + when(contentRepository.query()).thenReturn(query); + when(query.searchByTitle("")).thenReturn(query); + when(query.variants(VariantSearchMode.ORIGINAL)).thenReturn(query); + when(query.get()).thenReturn(List.of(node)); Map parameters = new HashMap<>(); parameters.put("query", ""); 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 index 0e0e6c683..61b00c8a4 100644 --- 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 @@ -31,6 +31,7 @@ import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.variants.Variant; import com.condation.cms.api.variants.VariantContext; @@ -74,7 +75,7 @@ class RemoteVariantEndpointTest { private EventBus eventBus; @Mock - private ContentRepository contentRepository; + private MutableContentRepository contentRepository; @Mock private ConfigurableVariantSelector configurableVariantSelector; @@ -91,7 +92,12 @@ class RemoteVariantEndpointTest { void setUp() { endpoint = new RemoteVariantEndpoint() { @Override - protected ContentRepository getContentRepository() { + protected ContentRepository getContentRepository(Map parameters) { + return contentRepository; + } + + @Override + protected MutableContentRepository getMutableContentRepository(Map parameters) { return contentRepository; } @@ -129,7 +135,7 @@ void getReturnsVariantsSortedById() throws RPCException { "/.variants/about/campaign/about", Map.of("title", "Campaign") ); - when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); var variants = List.of( new Variant("summer", summerNode), new Variant("campaign", campaignNode) @@ -165,7 +171,7 @@ void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { Map.of("title", "Summer") ); var variants = List.of(new Variant("summer", summer)); - when(content.byPath(summer.path())).thenReturn(Optional.of(summer)); + when(contentRepository.get(summer.path())).thenReturn(Optional.of(summer)); when(contentRepository.variantContext(summer)).thenReturn( new VariantContext(canonical, Optional.of("summer"), variants) ); @@ -183,7 +189,7 @@ void getFromVariantReturnsCanonicalAndActiveVariant() throws RPCException { @Test void getReturnsEmptyListWhenNodeHasNoVariants() throws RPCException { var node = node("about.md", "/about", Map.of()); - when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); when(contentRepository.variantContext(node)).thenReturn( new VariantContext(node, Optional.empty(), List.of()) ); @@ -205,8 +211,8 @@ void getRejectsBlankUri() { @Test void getReturnsNotFoundForUnknownNode() { - when(content.byPath("missing.md")).thenReturn(Optional.empty()); - when(content.byUrl("missing.md")).thenReturn(Optional.empty()); + when(contentRepository.get("missing.md")).thenReturn(Optional.empty()); + when(contentRepository.findByUrl("missing.md")).thenReturn(Optional.empty()); assertThatThrownBy(() -> endpoint.get(Map.of("uri", "missing.md"))) .isInstanceOf(RPCException.class) @@ -218,7 +224,7 @@ void getReturnsNotFoundForUnknownNode() { @Test void getSelectorsReturnsConfigurationAndAvailableStrategies() throws RPCException { var node = node("about.md", "/about", Map.of()); - when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); when(selectorConfigurationRepository.getSelectorId(node)).thenReturn("audience"); when(configurableVariantSelector.availableSelectors()).thenReturn(Map.of( "date-range", @@ -239,7 +245,7 @@ void getSelectorsReturnsConfigurationAndAvailableStrategies() throws RPCExceptio @Test void setSelectorPersistsConfigurationForPage() throws Exception { var node = node("about.md", "/about", Map.of()); - when(content.byPath("about.md")).thenReturn(Optional.of(node)); + when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); when(configurableVariantSelector.hasSelector("audience")).thenReturn(true); var result = endpoint.setSelector(Map.of("uri", "about.md", "selector", "audience")); @@ -256,13 +262,7 @@ void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception "/.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(contentRepository.get(canonical.path())).thenReturn(Optional.of(canonical)); when(contentRepository.variantContext(canonical)).thenReturn( new VariantContext( canonical, @@ -270,6 +270,7 @@ void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception List.of(new Variant("summer", summer)) ) ); + when(contentRepository.resourceExists(".variants/about/summer")).thenReturn(true); @SuppressWarnings("unchecked") var result = (Map) endpoint.delete(Map.of( @@ -280,15 +281,13 @@ void deleteRemovesVariantAndSectionsButKeepsPageConfiguration() throws Exception assertThat(result) .containsEntry("id", "summer") .containsEntry("url", "/about?preview=manager"); - assertThat(variantFolder).doesNotExist(); - assertThat(configuration).exists(); - verify(fileSystem).flushContentChanges(); + verify(contentRepository).deleteRecursively(".variants/about/summer"); } @Test void deleteRejectsUnknownVariant() { var canonical = node("about.md", "/about", Map.of()); - when(content.byPath(canonical.path())).thenReturn(Optional.of(canonical)); + when(contentRepository.get(canonical.path())).thenReturn(Optional.of(canonical)); when(contentRepository.variantContext(canonical)).thenReturn( new VariantContext(canonical, Optional.empty(), List.of()) ); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java index fc8c370e1..ebf902a07 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java @@ -39,6 +39,7 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.workflow.WFStatusProvider; import com.condation.cms.api.workflow.WFStatusQueryProvider; import com.condation.cms.api.workflow.Workflow; @@ -93,13 +94,21 @@ class RemoteWorkflowEndpointsExtensionTest { @Mock private Collections collections; + @Mock + private ContentRepository contentRepository; + private RemoteWorkflowEndpointsExtension endpoints; @BeforeEach void setUp() { - endpoints = new RemoteWorkflowEndpointsExtension(); + endpoints = new RemoteWorkflowEndpointsExtension() { + @Override + protected ContentRepository getContentRepository(Map parameters) { + return contentRepository; + } + }; endpoints.setContext(moduleContext); - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + lenient().when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); lenient().when(db.getFileSystem()).thenReturn(fileSystem); lenient().when(db.getCollections()).thenReturn(collections); lenient().when(collections.isLocal("blog")).thenReturn(true); @@ -190,15 +199,13 @@ void nodeStatus_ignoresReferencedCollectionItems() throws Exception { @Test void unpublishedPages_delegatesFilteringAndPaginationToWorkflowProvider() throws RPCException { - Content content = mock(Content.class); @SuppressWarnings("unchecked") ContentQuery query = mock(ContentQuery.class); WFStatusQueryProvider statusProvider = mock(WFStatusQueryProvider.class); Workflow workflow = mock(Workflow.class); Page emptyPage = new Page<>(0, 5, 0, 1, List.of()); - when(db.getContent()).thenReturn(content); - doReturn(query).when(content).query(any()); + doReturn(query).when(contentRepository).query(); when(query.variants(VariantSearchMode.ORIGINAL)).thenReturn(query); when(statusProvider.unpublished(query)).thenReturn(query); when(query.page(1, 5)).thenReturn(emptyPage); 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 d048ef67c..43b45477a 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 @@ -31,11 +31,14 @@ import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.ui.rpc.RPCResult; import com.condation.cms.modules.ui.services.RemoteMethodService; +import com.google.inject.Injector; import java.lang.reflect.Method; import java.util.Map; import java.util.Optional; @@ -94,8 +97,8 @@ void buildErrorResult_fallsBackToCodeMinusOne_forGenericException() throws Excep @Test void contentUriHeaderAddsCurrentNodeFeature() throws Exception { var context = new RequestContext(); - var db = mock(DB.class); - var content = mock(Content.class); + var injector = mock(Injector.class); + var content = mock(ContentRepository.class); var request = mock(Request.class); var headers = mock(HttpFields.class); var node = new ContentNode( @@ -104,13 +107,14 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { "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(moduleContext.has(InjectorFeature.class)).thenReturn(true); + when(moduleContext.get(InjectorFeature.class)).thenReturn(new InjectorFeature(injector)); + when(injector.getInstance(ContentRepository.class)).thenReturn(content); when(request.getHeaders()).thenReturn(headers); when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn(null); when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn(node.uri()); - when(content.byUri(node.uri())).thenReturn(Optional.of(node)); + when(content.findByUrl(node.uri())).thenReturn(Optional.empty()); + when(content.get(node.uri())).thenReturn(Optional.of(node)); var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); @@ -123,18 +127,18 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { @Test void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { var context = new RequestContext(); - var db = mock(DB.class); - var content = mock(Content.class); + var injector = mock(Injector.class); + var content = mock(ContentRepository.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(moduleContext.has(InjectorFeature.class)).thenReturn(true); + when(moduleContext.get(InjectorFeature.class)).thenReturn(new InjectorFeature(injector)); + when(injector.getInstance(ContentRepository.class)).thenReturn(content); when(request.getHeaders()).thenReturn(headers); when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn(null); 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()); + when(content.findByUrl("unknown.md")).thenReturn(Optional.empty()); + when(content.get("unknown.md")).thenReturn(Optional.empty()); var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); From dc5d50e4ce6dfd0970c3352cb83c87c0e7bb6827 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 15 Sep 2026 13:12:19 +0200 Subject: [PATCH 3/8] refactorings --- .../cms/api/annotations/FeatureScope.java | 2 +- .../cms/api/db/collection/Collection.java | 4 +- .../cms/api/db/collection/Collections.java | 7 +- .../features/ConfigurationFeature.java | 2 +- .../cms/api/feature/features/DBFeature.java | 2 +- .../api/feature/features/EventBusFeature.java | 2 +- .../feature/features/MessagingFeature.java | 2 +- .../features/MutableRepositoryFeature.java | 34 ++++ .../feature/features/RepositoryFeature.java | 34 ++++ .../features/ServerPropertiesFeature.java | 2 +- .../features/SitePropertiesFeature.java | 2 +- .../api/feature/features/ThemeFeature.java | 2 +- .../api/feature/features/WorkflowFeature.java | 2 +- .../cms/api/repository/CollectionAccess.java | 28 ++++ .../api/repository/CollectionRepository.java | 63 +++++++ .../MutableCollectionRepository.java | 38 +++++ .../cms/content/CollectionResolver.java | 8 +- .../cms/content/CollectionRouteResolver.java | 6 +- .../cms/content/DefaultContentRenderer.java | 8 +- .../cms/content/CollectionResolverTest.java | 17 +- .../impl/SiteCollectionRepositoryService.java | 32 ++++ .../impl/SiteContentRepositoryService.java | 6 +- .../FileSystemCollectionRepository.java | 155 ++++++++++++++++++ .../cms/filesystem/ReferencedCollections.java | 30 ++-- .../FileSystemCollectionRepositoryTest.java | 97 +++++++++++ .../filesystem/ReferencedCollectionsTest.java | 15 +- .../cms/request/RequestContextFactory.java | 11 ++ .../server/configs/SiteConfigInitializer.java | 18 ++ .../cms/server/configs/SiteModule.java | 25 ++- .../cms/server/host/Initializer.java | 9 +- .../functions/impl/AbstractNodeFunction.java | 7 +- .../functions/impl/NodeFunction.java | 5 +- .../com/condation/cms/SectionEntriesTest.java | 4 +- .../CollectionFunctionsIntegrationTest.java | 6 +- .../cms/content/ContentRendererNGTest.java | 4 +- .../cms/content/ContentResolverTest.java | 4 +- .../system/api/handlers/ApiEndpoints.java | 6 +- .../CollectionTemplateFunctionExtensions.java | 6 +- .../CollectionMenuExtension.java | 11 +- .../UiTemplateModelExtension.java | 6 +- .../AbstractRemoteMethodeExtension.java | 48 ++++-- .../RemoteCollectionEndpoints.java | 115 ++++++------- .../RemoteContentEndpointsExtension.java | 65 ++------ .../remotemethods/RemotePageEnpoints.java | 2 - .../RemoteTranslationEnpoints.java | 2 - .../remotemethods/RemoteVariantEndpoint.java | 1 - .../RemoteWorkflowEndpointsExtension.java | 32 ++-- .../modules/ui/http/RemoteCallHandler.java | 15 +- .../cms/modules/ui/utils/ActionFactory.java | 8 +- .../UiTemplateModelExtensionTest.java | 13 +- .../RemoteCollectionEndpointsTest.java | 31 ++-- .../RemoteContentEndpointsExtensionTest.java | 49 ++---- .../RemoteVariantEndpointTest.java | 24 --- .../RemoteWorkflowEndpointsExtensionTest.java | 57 ++----- .../ui/http/RemoteCallHandlerTest.java | 32 ++-- .../ui/utils/ActionFactoryAppsTest.java | 15 +- .../demo/collections/authors/thorsten.md | 8 +- .../content/.variants/index/variants.yaml | 1 + test-server/hosts/demo/content/index.md | 52 +++--- 59 files changed, 872 insertions(+), 420 deletions(-) create mode 100644 cms-api/src/main/java/com/condation/cms/api/feature/features/MutableRepositoryFeature.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/feature/features/RepositoryFeature.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/CollectionAccess.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/CollectionRepository.java create mode 100644 cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java create mode 100644 cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java create mode 100644 cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java create mode 100644 cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java create mode 100644 test-server/hosts/demo/content/.variants/index/variants.yaml diff --git a/cms-api/src/main/java/com/condation/cms/api/annotations/FeatureScope.java b/cms-api/src/main/java/com/condation/cms/api/annotations/FeatureScope.java index 8b5286982..509f2238f 100644 --- a/cms-api/src/main/java/com/condation/cms/api/annotations/FeatureScope.java +++ b/cms-api/src/main/java/com/condation/cms/api/annotations/FeatureScope.java @@ -41,7 +41,7 @@ public enum Scope { REQUEST, - GLOBAL, + SITE, MODULE, SERVER } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java index 2f3337290..647ab0ee1 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java @@ -24,9 +24,7 @@ import com.condation.cms.api.db.ContentQuery; import java.util.Optional; -/** - * A named, file-backed collection. - */ +/** A named collection, independent of its persistence backend. */ public interface Collection { String name(); diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java index ccfa0b753..ded22558c 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java @@ -24,8 +24,13 @@ import java.util.Set; /** - * Site-scoped access to collections. + * Low-level collection index used by storage adapters. + * + * @deprecated Application code should depend on + * {@link com.condation.cms.api.repository.CollectionRepository}. This type + * remains as the compatibility boundary for the filesystem implementation. */ +@Deprecated(forRemoval = false) public interface Collections { Collection collection(String name); diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/ConfigurationFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/ConfigurationFeature.java index 2fd6b176a..48af3a0fd 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/ConfigurationFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/ConfigurationFeature.java @@ -30,7 +30,7 @@ * * @author t.marx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) public record ConfigurationFeature(Configuration configuration) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/DBFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/DBFeature.java index 705282b64..c8cf773c3 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/DBFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/DBFeature.java @@ -30,7 +30,7 @@ * * @author t.marx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE}) public record DBFeature(DB db) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/EventBusFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/EventBusFeature.java index d237b4455..b26e4f8da 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/EventBusFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/EventBusFeature.java @@ -29,6 +29,6 @@ * * @author t.marx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE}) public record EventBusFeature(EventBus eventBus) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/MessagingFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/MessagingFeature.java index 39dc5d9aa..c03326fe5 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/MessagingFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/MessagingFeature.java @@ -29,6 +29,6 @@ * * @author t.marx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE}) public record MessagingFeature(Messaging messaging) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/MutableRepositoryFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/MutableRepositoryFeature.java new file mode 100644 index 000000000..c33e4cb79 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/MutableRepositoryFeature.java @@ -0,0 +1,34 @@ +package com.condation.cms.api.feature.features; + +/*- + * #%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.annotations.FeatureScope; +import com.condation.cms.api.feature.Feature; +import com.condation.cms.api.repository.MutableCollectionRepository; +import com.condation.cms.api.repository.MutableContentRepository; + +/** Mutable repositories available only to site-level and module code. */ +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE}) +public record MutableRepositoryFeature( + MutableContentRepository contentRepository, + MutableCollectionRepository collectionRepository) implements Feature { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/RepositoryFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/RepositoryFeature.java new file mode 100644 index 000000000..8fcdd22b0 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/RepositoryFeature.java @@ -0,0 +1,34 @@ +package com.condation.cms.api.feature.features; + +/*- + * #%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.annotations.FeatureScope; +import com.condation.cms.api.feature.Feature; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.ContentRepository; + +/** Read-only repositories available in site, module and request contexts. */ +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) +public record RepositoryFeature( + ContentRepository contentRepository, + CollectionRepository collectionRepository) implements Feature { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/ServerPropertiesFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/ServerPropertiesFeature.java index 3eda8a6b5..e29a6a2d7 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/ServerPropertiesFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/ServerPropertiesFeature.java @@ -32,7 +32,7 @@ * * @author t.marx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) public record ServerPropertiesFeature(ServerProperties serverProperties) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/SitePropertiesFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/SitePropertiesFeature.java index 068bcfac1..d8ad42d17 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/SitePropertiesFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/SitePropertiesFeature.java @@ -32,7 +32,7 @@ * * @author t.marx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) public record SitePropertiesFeature(SiteProperties siteProperties) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/ThemeFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/ThemeFeature.java index 7fb786e25..1af3d5f10 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/ThemeFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/ThemeFeature.java @@ -32,7 +32,7 @@ * @author t.marx */ @AllArgsConstructor -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) public class ThemeFeature implements Feature { private Theme theme; diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/WorkflowFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/WorkflowFeature.java index 9dd7c0243..36858a0a0 100644 --- a/cms-api/src/main/java/com/condation/cms/api/feature/features/WorkflowFeature.java +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/WorkflowFeature.java @@ -29,7 +29,7 @@ * * @author thorstenmarx */ -@FeatureScope({FeatureScope.Scope.GLOBAL, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) +@FeatureScope({FeatureScope.Scope.SITE, FeatureScope.Scope.MODULE, FeatureScope.Scope.REQUEST}) public record WorkflowFeature(Workflow workflow) implements Feature { } diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/CollectionAccess.java b/cms-api/src/main/java/com/condation/cms/api/repository/CollectionAccess.java new file mode 100644 index 000000000..da7a789fd --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/CollectionAccess.java @@ -0,0 +1,28 @@ +package com.condation.cms.api.repository; + +/*- + * #%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% + */ + +/** Describes whether a collection can be changed through this site. */ +public enum CollectionAccess { + READ_ONLY, + READ_WRITE +} diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/CollectionRepository.java b/cms-api/src/main/java/com/condation/cms/api/repository/CollectionRepository.java new file mode 100644 index 000000000..795118945 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/CollectionRepository.java @@ -0,0 +1,63 @@ +package com.condation.cms.api.repository; + +/*- + * #%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.ContentQuery; +import com.condation.cms.api.db.CursorPage; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +/** Backend-neutral access to named collection data. */ +public interface CollectionRepository { + + Set names(); + + default boolean exists(String collection) { + return names().contains(collection); + } + + CollectionAccess access(String collection); + + Collection collection(String name); + + default Optional get(String collection, String id) { + return collection(collection).item(id); + } + + default ContentQuery query(String collection) { + return collection(collection).query(); + } + + default ContentQuery metadataQuery(String collection) { + return collection(collection).metadataQuery(); + } + + CursorPage metadataCursorPage( + String collection, + String cursor, + long size, + Consumer> queryConfigurer); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java b/cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java new file mode 100644 index 000000000..52215059f --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java @@ -0,0 +1,38 @@ +package com.condation.cms.api.repository; + +/*- + * #%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.collection.CollectionItem; +import java.io.IOException; +import java.util.Map; + +/** Backend-neutral mutations for collections owned by a site. */ +public interface MutableCollectionRepository extends CollectionRepository { + + CollectionItem create(String collection, String id, Map metadata, String content) + throws IOException; + + void save(String collection, String id, Map metadata, String content) + throws IOException; + + void delete(String collection, String id) throws IOException; +} diff --git a/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java b/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java index 0874d56d7..19a03a582 100644 --- a/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java @@ -29,13 +29,11 @@ import com.condation.cms.api.content.ContentResponse; import com.condation.cms.api.content.DefaultContentResponse; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.DB; import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; 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.core.serivce.ServiceRegistry; -import com.condation.cms.core.serivce.impl.SiteDBService; +import com.condation.cms.api.repository.CollectionRepository; import java.io.IOException; import java.util.HashMap; import java.util.Optional; @@ -48,7 +46,7 @@ public class CollectionResolver { private final ContentRenderer contentRenderer; - private final DB db; + private final CollectionRepository collectionRepository; private final Configuration configuration; public Optional getContent(RequestContext context) throws IOException { @@ -56,7 +54,7 @@ public Optional getContent(RequestContext context) throws IOExc if (collectionConfiguration == null) { return Optional.empty(); } - var route = new CollectionRouteResolver(db, collectionConfiguration) + var route = new CollectionRouteResolver(collectionRepository, collectionConfiguration) .resolve(context.get(RequestFeature.class).uri()); if (route.isEmpty()) { return Optional.empty(); diff --git a/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java b/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java index 310a9c917..6b63e84c1 100644 --- a/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java @@ -24,8 +24,8 @@ import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.configuration.configs.CollectionDefinition; import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; -import com.condation.cms.api.db.DB; import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.content.utils.SlugUtil; import java.util.Comparator; import java.util.Optional; @@ -35,7 +35,7 @@ @RequiredArgsConstructor public class CollectionRouteResolver { - private final DB db; + private final CollectionRepository collectionRepository; private final CollectionConfiguration configuration; public Optional resolve(String requestUri) { @@ -64,7 +64,7 @@ private Optional resolve(CollectionDefinition definition, String return Optional.empty(); } - var collection = db.getCollections().collection(definition.name()); + var collection = collectionRepository.collection(definition.name()); Optional item; if (isSimpleParameter(detail.get())) { var routeValue = simpleRouteValue(detail.get(), uri); 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 60ce4b23b..708dc9f98 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 @@ -44,6 +44,7 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.repository.ContentDocument; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.repository.Section; import com.condation.cms.api.template.TemplateEngine; import com.condation.cms.api.utils.MapUtil; @@ -87,18 +88,21 @@ public class DefaultContentRenderer implements ContentRenderer { private final SiteProperties siteProperties; private final ModuleManager moduleManager; private final ContentRepository contentRepository; + private final CollectionRepository collectionRepository; public DefaultContentRenderer( Supplier templates, DB db, SiteProperties siteProperties, ModuleManager moduleManager, - ContentRepository contentRepository) { + ContentRepository contentRepository, + CollectionRepository collectionRepository) { this.templates = templates; this.db = db; this.siteProperties = siteProperties; this.moduleManager = moduleManager; this.contentRepository = contentRepository; + this.collectionRepository = collectionRepository; } private record ResolvedRenderInput( @@ -242,7 +246,7 @@ public String renderCollection( Optional.of(collectionNode)), model -> { model.values.put("collection_item", item); - model.values.put("collection", db.getCollections().collection(item.collection())); + model.values.put("collection", collectionRepository.collection(item.collection())); }); } diff --git a/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java b/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java index e15b127ba..69a91639e 100644 --- a/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java @@ -34,7 +34,6 @@ import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; import com.condation.cms.api.content.DefaultContentResponse; 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.collection.Collection; import com.condation.cms.api.db.collection.CollectionItem; @@ -43,6 +42,7 @@ 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.repository.CollectionRepository; import java.util.List; import java.util.Map; import java.util.Optional; @@ -57,9 +57,7 @@ class CollectionResolverTest { private final ContentRenderer renderer = mock(ContentRenderer.class); - private final DB db = mock(DB.class); - private final com.condation.cms.api.db.collection.Collections collections = - mock(com.condation.cms.api.db.collection.Collections.class); + private final CollectionRepository collections = mock(CollectionRepository.class); private final Collection collection = mock(Collection.class); private final ConcurrentHashMap definitions = new ConcurrentHashMap<>(); private final Configuration configuration = new Configuration(); @@ -75,7 +73,6 @@ class CollectionResolverTest { void setUp() throws Exception { collectionConfiguration = new CollectionConfiguration(definitions); configuration.add(CollectionConfiguration.class, collectionConfiguration); - when(db.getCollections()).thenReturn(collections); when(collections.collection("blog")).thenReturn(collection); when(renderer.renderCollection( any(), @@ -88,7 +85,7 @@ void setUp() throws Exception { void resolvesAnIdRouteAndUsesReloadedDefinitions() throws Exception { define(definition("/old/{id}")); when(collection.item("first")).thenReturn(Optional.of(item)); - var resolver = new CollectionResolver(renderer, db, configuration); + var resolver = new CollectionResolver(renderer, collections, configuration); var context = context("/blog/first"); Assertions.assertThat(resolver.getContent(context)).isEmpty(); @@ -122,7 +119,7 @@ void resolvesAConfiguredFrontMatterField() throws Exception { when(query.page(1, 2)).thenReturn(new Page<>(1, 2, 1, 1, List.of( new CollectionItemMetadata("first", "blog", "blog/first.md", item.meta())))); when(collection.item("first")).thenReturn(Optional.of(item)); - var resolver = new CollectionResolver(renderer, db, configuration); + var resolver = new CollectionResolver(renderer, collections, configuration); var response = resolver.getContent(context("/blog/first-post/")); @@ -139,7 +136,7 @@ void doesNotScanTheCollectionForLegacyUnnormalizedRouteValues() throws Exception when(exactQuery.where("slug", "ueber-uns")).thenReturn(exactQuery); when(exactQuery.page(1, 2)).thenReturn(new Page<>(0, 2, 0, 1, List.of())); - var response = new CollectionResolver(renderer, db, configuration) + var response = new CollectionResolver(renderer, collections, configuration) .getContent(context("/blog/ueber-uns")); Assertions.assertThat(response).isEmpty(); @@ -165,7 +162,7 @@ void resolvesAComplexRouteAgainstIndexedMetadata() { new CollectionItemMetadata("first", "blog", "blog/first.md", metadata))); when(collection.item("first")).thenReturn(Optional.of(event)); - var route = new CollectionRouteResolver(db, collectionConfiguration) + var route = new CollectionRouteResolver(collections, collectionConfiguration) .resolve("/events/2026/09/03/germany/muenchen/"); Assertions.assertThat(route).isPresent(); @@ -187,7 +184,7 @@ void rendersAnItemFromTheConfiguredSourceSiteWithoutExposingItsFile() throws Exc eq(item), anyString(), any())).thenReturn("

Shared

"); - var response = new CollectionResolver(renderer, db, configuration) + var response = new CollectionResolver(renderer, collections, configuration) .getContent(context("/shared/first")); Assertions.assertThat(response).isPresent(); diff --git a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java new file mode 100644 index 000000000..269744e92 --- /dev/null +++ b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java @@ -0,0 +1,32 @@ +package com.condation.cms.core.serivce.impl; + +/*- + * #%L + * CMS Core + * %% + * 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.repository.CollectionRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; +import com.condation.cms.core.serivce.Service; + +/** Exposes a site's collection boundary to cross-site callers (e.g. remote UI calls made with a siteId). */ +public record SiteCollectionRepositoryService( + CollectionRepository repository, + MutableCollectionRepository mutableRepository) implements Service { +} diff --git a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java index 3444d5a57..bc109cb5c 100644 --- a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java +++ b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java @@ -10,12 +10,12 @@ * 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% @@ -25,7 +25,7 @@ import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.core.serivce.Service; -/** Exposes a site's content boundary to cross-site callers. */ +/** Exposes a site's content boundary to cross-site callers (e.g. remote UI calls made with a siteId). */ public record SiteContentRepositoryService( ContentRepository repository, MutableContentRepository mutableRepository) implements Service { diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java new file mode 100644 index 000000000..4ad241e94 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java @@ -0,0 +1,155 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * 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.ContentQuery; +import com.condation.cms.api.db.CursorPage; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.CollectionCursorSupport; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemId; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.MutableCollectionRepository; +import com.condation.cms.core.content.io.YamlHeaderUpdater; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +/** Collection repository backed by the existing collection filesystem index. */ +public final class FileSystemCollectionRepository implements MutableCollectionRepository { + + private final Collections collections; + private final DBFileSystem fileSystem; + + public FileSystemCollectionRepository(Collections collections, DBFileSystem fileSystem) { + this.collections = Objects.requireNonNull(collections); + this.fileSystem = Objects.requireNonNull(fileSystem); + } + + @Override + public Set names() { + return collections.names(); + } + + @Override + public CollectionAccess access(String collection) { + validateCollection(collection); + return collections.isLocal(collection) + ? CollectionAccess.READ_WRITE + : CollectionAccess.READ_ONLY; + } + + @Override + public Collection collection(String name) { + return collections.collection(name); + } + + @Override + public CursorPage metadataCursorPage( + String collection, + String cursor, + long size, + Consumer> queryConfigurer) { + if (!(collections instanceof CollectionCursorSupport cursorSupport)) { + throw new UnsupportedOperationException("collection storage does not support cursor paging"); + } + return cursorSupport.metadataCursorPage(collection, cursor, size, queryConfigurer); + } + + @Override + public CollectionItem create( + String collection, + String id, + Map metadata, + String content) throws IOException { + var target = writableItem(collection, id); + if (Files.exists(target)) { + throw new FileAlreadyExistsException(collection + "/" + id); + } + write(collection, id, target, metadata, content); + return new CollectionItem( + id, + collection, + collection + "/" + id + ".md", + content, + metadata); + } + + @Override + public void save( + String collection, + String id, + Map metadata, + String content) throws IOException { + write(collection, id, writableItem(collection, id), metadata, content); + } + + @Override + public void delete(String collection, String id) throws IOException { + var target = writableItem(collection, id); + if (!Files.isRegularFile(target)) { + throw new java.nio.file.NoSuchFileException(collection + "/" + id); + } + Files.delete(target); + collections.refresh(collection, id); + } + + private void write( + String collection, + String id, + Path target, + Map metadata, + String content) throws IOException { + Files.createDirectories(target.getParent()); + YamlHeaderUpdater.saveMarkdownFileWithHeader(target, metadata, content); + collections.refresh(collection, id); + } + + private Path writableItem(String collection, String id) { + validateCollection(collection); + CollectionItemId.requireValid(id); + if (access(collection) != CollectionAccess.READ_WRITE) { + throw new UnsupportedOperationException("referenced collection is read-only: " + collection); + } + var root = fileSystem.resolve(Constants.Folders.COLLECTIONS).toAbsolutePath().normalize(); + var target = root.resolve(collection).resolve(id + ".md").normalize(); + if (!target.startsWith(root) || target.equals(root)) { + throw new IllegalArgumentException("invalid collection item path"); + } + return target; + } + + private void validateCollection(String collection) { + if (!collections.names().contains(collection)) { + throw new IllegalArgumentException("collection not found: " + collection); + } + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java index fc9aed81d..b9a72b49c 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java @@ -28,8 +28,10 @@ import com.condation.cms.api.db.collection.CollectionCursorSupport; import com.condation.cms.api.db.collection.CollectionItemMetadata; import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.core.serivce.ServiceRegistry; -import com.condation.cms.core.serivce.impl.SiteDBService; +import com.condation.cms.core.serivce.impl.SiteCollectionRepositoryService; import java.util.HashSet; import java.util.Set; import java.util.function.Consumer; @@ -56,7 +58,10 @@ final class ReferencedCollections implements Collections, CollectionCursorSuppor @Override public Collection collection(String name) { - return targetCollections(name).collection(name); + var sourceSite = sourceSite(name); + return sourceSite == null + ? localCollections.collection(name) + : sourceRepository(sourceSite, name).collection(name); } @Override @@ -65,8 +70,12 @@ public CursorPage metadataCursorPage( String cursor, long size, Consumer> queryConfigurer) { - var target = targetCollections(collection); - if (!(target instanceof CollectionCursorSupport cursorSupport)) { + var sourceSite = sourceSite(collection); + if (sourceSite != null) { + return sourceRepository(sourceSite, collection).metadataCursorPage( + collection, cursor, size, queryConfigurer); + } + if (!(localCollections instanceof CollectionCursorSupport cursorSupport)) { throw new UnsupportedOperationException("collection storage does not support cursor paging"); } return cursorSupport.metadataCursorPage(collection, cursor, size, queryConfigurer); @@ -105,16 +114,13 @@ private String sourceSite(String collection) { .orElse(null); } - private Collections targetCollections(String collection) { - var sourceSite = sourceSite(collection); - if (sourceSite == null) { - return localCollections; - } - var source = ServiceRegistry.getInstance().get(sourceSite, SiteDBService.class) + private CollectionRepository sourceRepository( + String sourceSite, String collection) { + var source = ServiceRegistry.getInstance().get(sourceSite, SiteCollectionRepositoryService.class) .orElseThrow(() -> new IllegalStateException( "collection source site is not available: " + sourceSite)); - var sourceCollections = source.db().getCollections(); - if (!sourceCollections.isLocal(collection)) { + var sourceCollections = source.repository(); + if (sourceCollections.access(collection) != CollectionAccess.READ_WRITE) { throw new IllegalStateException( "referenced collections must point to a local collection: " + sourceSite + "/" + collection); diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java new file mode 100644 index 000000000..58d8d177f --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java @@ -0,0 +1,97 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * 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.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.repository.CollectionAccess; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class FileSystemCollectionRepositoryTest { + + @TempDir + Path tempDirectory; + + private Collections collections; + private FileSystemCollectionRepository repository; + + @BeforeEach + void setUp() { + collections = mock(Collections.class); + var fileSystem = mock(DBFileSystem.class); + when(collections.names()).thenReturn(Set.of("blog", "shared")); + when(collections.isLocal("blog")).thenReturn(true); + when(collections.isLocal("shared")).thenReturn(false); + when(fileSystem.resolve(Constants.Folders.COLLECTIONS)) + .thenReturn(tempDirectory.resolve("collections")); + repository = new FileSystemCollectionRepository(collections, fileSystem); + } + + @Test + void createsSavesAndDeletesItemsBehindTheRepositoryBoundary() throws Exception { + repository.create("blog", "entry", Map.of("title", "First"), "First body"); + var item = tempDirectory.resolve("collections/blog/entry.md"); + Assertions.assertThat(item) + .exists() + .content() + .contains("title: First", "First body"); + verify(collections).refresh("blog", "entry"); + + repository.save("blog", "entry", Map.of("title", "Changed"), "Changed body"); + Assertions.assertThat(item).content().contains("title: Changed", "Changed body"); + + repository.delete("blog", "entry"); + Assertions.assertThat(item).doesNotExist(); + verify(collections, org.mockito.Mockito.times(3)).refresh("blog", "entry"); + } + + @Test + void rejectsOverwritesTraversalAndWritesToReferencedCollections() throws Exception { + repository.create("blog", "entry", Map.of(), "body"); + + Assertions.assertThatThrownBy( + () -> repository.create("blog", "entry", Map.of(), "other")) + .isInstanceOf(FileAlreadyExistsException.class); + Assertions.assertThatThrownBy( + () -> repository.save("blog", "../entry", Map.of(), "other")) + .isInstanceOf(IllegalArgumentException.class); + Assertions.assertThatThrownBy( + () -> repository.save("shared", "entry", Map.of(), "other")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("read-only"); + Assertions.assertThat(repository.access("blog")).isEqualTo(CollectionAccess.READ_WRITE); + Assertions.assertThat(repository.access("shared")).isEqualTo(CollectionAccess.READ_ONLY); + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java index c317023db..c3b7b81bd 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java @@ -27,11 +27,12 @@ import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.configuration.configs.CollectionDefinition; -import com.condation.cms.api.db.DB; import com.condation.cms.api.db.collection.Collection; import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.core.serivce.ServiceRegistry; -import com.condation.cms.core.serivce.impl.SiteDBService; +import com.condation.cms.core.serivce.impl.SiteCollectionRepositoryService; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.assertj.core.api.Assertions; @@ -49,16 +50,14 @@ void clearServices() { void resolvesReferencedCollectionsThroughTheSourceSite() { var local = mock(Collections.class); when(local.names()).thenReturn(Set.of("local")); - var sourceCollections = mock(Collections.class); + var sourceCollections = mock(MutableCollectionRepository.class); var sourceCollection = mock(Collection.class); - when(sourceCollections.isLocal("shared")).thenReturn(true); + when(sourceCollections.access("shared")).thenReturn(CollectionAccess.READ_WRITE); when(sourceCollections.collection("shared")).thenReturn(sourceCollection); - var sourceDB = mock(DB.class); - when(sourceDB.getCollections()).thenReturn(sourceCollections); ServiceRegistry.getInstance().register( "content-site", - SiteDBService.class, - new SiteDBService(sourceDB)); + SiteCollectionRepositoryService.class, + new SiteCollectionRepositoryService(sourceCollections, sourceCollections)); var definitions = new ConcurrentHashMap(); definitions.put("shared", new CollectionDefinition("shared", "content-site", null)); var collections = new ReferencedCollections( diff --git a/cms-server/src/main/java/com/condation/cms/request/RequestContextFactory.java b/cms-server/src/main/java/com/condation/cms/request/RequestContextFactory.java index 889c84162..508dfce05 100644 --- a/cms-server/src/main/java/com/condation/cms/request/RequestContextFactory.java +++ b/cms-server/src/main/java/com/condation/cms/request/RequestContextFactory.java @@ -37,6 +37,7 @@ import com.condation.cms.api.feature.features.IsDevModeFeature; import com.condation.cms.api.feature.features.MarkdownRendererFeature; import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.feature.features.ServerPropertiesFeature; import com.condation.cms.api.feature.features.SiteMediaServiceFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; @@ -51,6 +52,8 @@ import com.condation.cms.api.model.Parameter; import com.condation.cms.api.workflow.WFStatusProvider; import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.template.TemplateEngine; import com.condation.cms.api.theme.Theme; import com.condation.cms.api.utils.HTTPUtil; @@ -95,6 +98,7 @@ public RequestContext createContext () { var siteMediaService = injector.getInstance(MediaService.class); requestContext.add(InjectorFeature.class, new InjectorFeature(injector)); + addRepositories(requestContext); requestContext.add(ThemeFeature.class, new ThemeFeature(theme)); requestContext.add(ContentParserFeature.class, new ContentParserFeature(injector.getInstance(ContentParser.class))); @@ -187,6 +191,7 @@ private RequestContext create() throws IOException { var requestContext = new RequestContext(); requestContext.add(InjectorFeature.class, new InjectorFeature(injector)); + addRepositories(requestContext); requestContext.add(ThemeFeature.class, new ThemeFeature(theme)); requestContext.add(ContentParserFeature.class, new ContentParserFeature(injector.getInstance(ContentParser.class))); @@ -225,6 +230,12 @@ private RequestContext create() throws IOException { return requestContext; } + private void addRepositories(RequestContext requestContext) { + requestContext.add(RepositoryFeature.class, new RepositoryFeature( + injector.getInstance(ContentRepository.class), + injector.getInstance(CollectionRepository.class))); + } + // used public RequestContext create( String contextPath, diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteConfigInitializer.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteConfigInitializer.java index d2de9e851..c13203651 100644 --- a/cms-server/src/main/java/com/condation/cms/server/configs/SiteConfigInitializer.java +++ b/cms-server/src/main/java/com/condation/cms/server/configs/SiteConfigInitializer.java @@ -34,6 +34,8 @@ import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.feature.features.MessagingFeature; +import com.condation.cms.api.feature.features.MutableRepositoryFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.feature.features.ServerPropertiesFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; import com.condation.cms.api.feature.features.ThemeFeature; @@ -41,6 +43,10 @@ import com.condation.cms.api.workflow.WFStatusProvider; import com.condation.cms.api.messaging.Messaging; import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; +import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.api.scheduler.CronJobContext; import com.condation.cms.api.theme.Theme; import com.condation.cms.api.workflow.Workflow; @@ -78,6 +84,12 @@ private void initCronJobContext() { context.add(ThemeFeature.class, new ThemeFeature(injector.getInstance(Theme.class))); context.add(ConfigurationFeature.class, new ConfigurationFeature(injector.getInstance(Configuration.class))); context.add(WorkflowFeature.class, new WorkflowFeature(injector.getInstance(Workflow.class))); + context.add(RepositoryFeature.class, new RepositoryFeature( + injector.getInstance(ContentRepository.class), + injector.getInstance(CollectionRepository.class))); + context.add(MutableRepositoryFeature.class, new MutableRepositoryFeature( + injector.getInstance(MutableContentRepository.class), + injector.getInstance(MutableCollectionRepository.class))); } private void initModuleContext () { @@ -91,6 +103,12 @@ private void initModuleContext () { cmsModuleContext.add(ThemeFeature.class, new ThemeFeature(injector.getInstance(Theme.class))); cmsModuleContext.add(ConfigurationFeature.class, new ConfigurationFeature(injector.getInstance(Configuration.class))); cmsModuleContext.add(WorkflowFeature.class, new WorkflowFeature(injector.getInstance(Workflow.class))); + cmsModuleContext.add(RepositoryFeature.class, new RepositoryFeature( + injector.getInstance(ContentRepository.class), + injector.getInstance(CollectionRepository.class))); + cmsModuleContext.add(MutableRepositoryFeature.class, new MutableRepositoryFeature( + injector.getInstance(MutableContentRepository.class), + injector.getInstance(MutableCollectionRepository.class))); cmsModuleContext.add(CronJobSchedulerFeature.class, new CronJobSchedulerFeature(injector.getInstance(SiteCronJobScheduler.class))); cmsModuleContext.add(CacheManagerFeature.class, new CacheManagerFeature(injector.getInstance(CacheManager.class))); cmsModuleContext.add(InjectorFeature.class, new InjectorFeature(injector)); 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 63c65a6bc..2cc00fe11 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 @@ -53,6 +53,8 @@ import com.condation.cms.api.messages.MessageSource; import com.condation.cms.api.messaging.Messaging; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.api.repository.ContentStore; import com.condation.cms.api.scheduler.CronJobContext; @@ -90,6 +92,7 @@ import com.condation.cms.extensions.ExtensionManager; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.MetaData; import com.condation.cms.filesystem.NIOReadOnlyFile; @@ -347,6 +350,18 @@ public ContentRepository contentRepository(MutableContentRepository repository) return repository; } + @Provides + @Singleton + public MutableCollectionRepository mutableCollectionRepository(DB db) { + return new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem()); + } + + @Provides + @Singleton + public CollectionRepository collectionRepository(MutableCollectionRepository repository) { + return repository; + } + @Provides @Singleton public ExtensionManager extensionManager(DB db, Configuration configuration, Engine engine) throws IOException { @@ -391,13 +406,15 @@ public RenderContentFunction renderContentFunction (ContentResolver contentResol @Singleton public ContentRenderer contentRenderer(Injector injector, FileDB db, SiteProperties siteProperties, ModuleManager moduleManager, - ContentRepository contentRepository) { + ContentRepository contentRepository, + CollectionRepository collectionRepository) { return new DefaultContentRenderer( () -> injector.getInstance(TemplateEngine.class), db, siteProperties, moduleManager, - contentRepository); + contentRepository, + collectionRepository); } @Provides @@ -411,9 +428,9 @@ public ContentResolver contentResolver(ContentRenderer contentRenderer, @Singleton public CollectionResolver collectionResolver( ContentRenderer contentRenderer, - FileDB db, + CollectionRepository collectionRepository, Configuration configuration) { - return new CollectionResolver(contentRenderer, db, configuration); + return new CollectionResolver(contentRenderer, collectionRepository, configuration); } @Provides diff --git a/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java b/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java index 2ddc857a5..8a594c240 100644 --- a/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java +++ b/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java @@ -27,8 +27,11 @@ import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.NodeTranslationService; import com.condation.cms.core.serivce.impl.SiteDBService; +import com.condation.cms.core.serivce.impl.SiteCollectionRepositoryService; import com.condation.cms.core.serivce.impl.SiteContentRepositoryService; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.core.serivce.impl.SiteLinkService; import com.condation.cms.core.serivce.impl.SitePropertiesService; @@ -48,11 +51,15 @@ public class Initializer { void initServices () { var db = host.injector.getInstance(DB.class); ServiceRegistry.getInstance().register(host.id(), SiteDBService.class, new SiteDBService(db)); + ServiceRegistry.getInstance().register(host.id(), SiteCollectionRepositoryService.class, + new SiteCollectionRepositoryService( + host.injector.getInstance(CollectionRepository.class), + host.injector.getInstance(MutableCollectionRepository.class))); ServiceRegistry.getInstance().register(host.id(), SiteContentRepositoryService.class, new SiteContentRepositoryService( host.injector.getInstance(ContentRepository.class), host.injector.getInstance(MutableContentRepository.class))); - + var config = host.injector.getInstance(Configuration.class); ServiceRegistry.getInstance().register(host.id(), SiteLinkService.class, new SiteLinkService(config)); diff --git a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java index e781f8c4d..f5d5d7f0b 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/AbstractNodeFunction.java @@ -23,10 +23,8 @@ import com.condation.cms.api.content.MapAccess; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.request.RequestContext; -import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.templates.functions.TemplateFunction; import java.util.HashMap; import java.util.Map; @@ -53,8 +51,7 @@ public Object invoke(Object... params) { if (!(params[0] instanceof String)) { return null; } - var repository = requestContext.get(InjectorFeature.class).injector() - .getInstance(ContentRepository.class); + var repository = requestContext.get(RepositoryFeature.class).contentRepository(); var contentNode = repository.findByUrl((String) params[0]) .or(() -> repository.get(normalize((String) params[0]))); if (contentNode.isPresent()) { diff --git a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java index 4b043cc7c..702ca721e 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/functions/impl/NodeFunction.java @@ -23,8 +23,8 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.request.RequestContext; -import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.ContentRenderer; import java.io.IOException; import java.util.Map; @@ -46,8 +46,7 @@ public NodeFunction(RequestContext requestContext) { @Override protected void extendMap(Map node, ContentNode contentNode) { try { - var contentRepository = requestContext.get(InjectorFeature.class) - .injector().getInstance(ContentRepository.class); + var contentRepository = requestContext.get(RepositoryFeature.class).contentRepository(); var contentRenderer = requestContext.get(InjectorFeature.class).injector().getInstance(ContentRenderer.class); var sectionEntries = contentRenderer.renderSections( contentRepository.sections(contentNode), requestContext); diff --git a/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java b/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java index 9240aa867..2275fc5b5 100644 --- a/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java +++ b/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java @@ -37,6 +37,7 @@ import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.template.TemplateEngineTest; @@ -95,7 +96,8 @@ public static void beforeClass() throws IOException { db, new TestSiteProperties(Map.of()), new MockModuleManager(), - contentRepository + contentRepository, + new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem()) ); } diff --git a/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java b/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java index 818269ee8..c7c522692 100644 --- a/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java +++ b/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java @@ -38,9 +38,12 @@ import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; +import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.hooksystem.CMSHookSystem; import com.condation.cms.hooksystem.extensions.TemplateHooks; import com.condation.cms.modules.system.templates.CollectionTemplateFunctionExtensions; @@ -84,10 +87,11 @@ void setUp() throws Exception { createCollectionItems(); db = createDatabase(); db.init(); - + var extension = new CollectionTemplateFunctionExtensions(); var moduleContext = new SiteModuleContext(); moduleContext.add(DBFeature.class, new DBFeature(db)); + moduleContext.add(RepositoryFeature.class, new RepositoryFeature(null, new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem()))); extension.setContext(moduleContext); collection = (Collection) extension.collection(new Parameter(Map.of("value", COLLECTION_NAME))); diff --git a/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java b/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java index b812ee064..7fc1c81e7 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java @@ -38,6 +38,7 @@ import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.template.TemplateEngineTest; @@ -101,7 +102,8 @@ public static void beforeClass () throws IOException { db, new TestSiteProperties(Map.of()), moduleManager, - contentRepository); + contentRepository, + new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem())); } @AfterAll public static void shutdown () throws Exception { 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 ed4b73447..1f867a813 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 @@ -42,6 +42,7 @@ import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; +import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.test.TestSiteProperties; @@ -95,7 +96,8 @@ public static void setup() throws IOException { db, new TestSiteProperties(Map.of()), moduleManager, - contentRepository); + contentRepository, + new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem())); contentResolver = new ContentResolver( contentRenderer, contentRepository, new DefaultVariantSelector()); } diff --git a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java index 51ff2ea2a..1c2f7c83e 100644 --- a/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java +++ b/modules/api-module/src/main/java/com/condation/cms/modules/system/api/handlers/ApiEndpoints.java @@ -27,8 +27,7 @@ import com.condation.cms.api.extensions.http.APIHandlerExtensionPoint; import com.condation.cms.api.extensions.http.PathMapping; import com.condation.cms.api.feature.features.ConfigurationFeature; -import com.condation.cms.api.feature.features.InjectorFeature; -import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.modules.system.api.services.ContentService; import com.condation.cms.modules.system.api.handlers.v1.NavigationHandler; import com.condation.cms.modules.system.api.handlers.v1.QueryHandler; @@ -51,8 +50,7 @@ public PathMapping getMapping() { var siteProperties = getContext().get(ConfigurationFeature.class).configuration().get(SiteConfiguration.class).siteProperties(); var whitelist = siteProperties.getOrDefault("api.whitelist", List.of("")); - var contentRepository = getRequestContext().get(InjectorFeature.class) - .injector().getInstance(ContentRepository.class); + var contentRepository = getRequestContext().get(RepositoryFeature.class).contentRepository(); mapping.add(PathSpec.from("/v1/content/*"), "GET", diff --git a/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java b/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java index 138081b73..f91cc0503 100644 --- a/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java +++ b/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java @@ -23,7 +23,7 @@ import com.condation.cms.api.annotations.TemplateFunction; import com.condation.cms.api.extensions.RegisterTemplateFunctionExtensionPoint; -import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.model.Parameter; import com.condation.modules.api.annotation.Extension; @@ -39,6 +39,8 @@ public Object collection(Parameter params) { if (value == null) { throw new IllegalArgumentException("collection name must not be null"); } - return context.get(DBFeature.class).db().getCollections().collection(value.toString()); + return context.get(RepositoryFeature.class) + .collectionRepository() + .collection(value.toString()); } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java index 47cce840f..c093bf925 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java @@ -23,7 +23,8 @@ import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.extensions.AbstractExtensionPoint; -import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.feature.features.HookSystemFeature; import com.condation.cms.api.ui.action.UIScriptAction; import com.condation.cms.api.ui.elements.Menu; @@ -37,15 +38,15 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; -/** Adds one manager menu entry for every collection discovered on disk. */ +/** Adds one manager menu entry for every writable collection of the site. */ @Extension(UIActionsExtensionPoint.class) public class CollectionMenuExtension extends AbstractExtensionPoint implements UIActionsExtensionPoint { @Override public void addMenuItems(Menu menu) { - var db = getContext().get(DBFeature.class).db(); - var names = db.getCollections().names().stream() - .filter(db.getCollections()::isLocal) + var repository = getContext().get(RepositoryFeature.class).collectionRepository(); + var names = repository.names().stream() + .filter(name -> repository.access(name) == CollectionAccess.READ_WRITE) .sorted() .toList(); if (names.isEmpty()) { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java index b2efcb10a..8da3ac405 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java @@ -22,7 +22,8 @@ */ import com.condation.cms.api.extensions.TemplateModelExtendingExtensionPoint; -import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.feature.features.IsPreviewFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.JSONUtil; @@ -119,7 +120,8 @@ public String collectionToolbar( if (item == null) { return ""; } - if (!siteContext.get(DBFeature.class).db().getCollections().isLocal(item.collection())) { + var collections = siteContext.get(RepositoryFeature.class).collectionRepository(); + if (collections.access(item.collection()) != CollectionAccess.READ_WRITE) { return ""; } var options = new HashMap<>(additional); 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 2e4fa909a..5a6371797 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 @@ -29,13 +29,17 @@ import com.condation.cms.api.feature.features.AuthFeature; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.HookSystemFeature; -import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.MutableRepositoryFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.api.repository.MutableContentRepository; 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.core.serivce.impl.SiteCollectionRepositoryService; import com.condation.cms.core.serivce.impl.SiteContentRepositoryService; +import com.condation.cms.core.serivce.impl.SiteDBService; import com.condation.cms.modules.ui.utils.UIHooks; import java.nio.file.Path; import java.util.Map; @@ -72,20 +76,44 @@ protected DB getDB (Map parameters) { protected ContentRepository getContentRepository(Map parameters) { if (parameters.containsKey(SITE_ID)) { - return ServiceRegistry.getInstance().get((String) parameters.get(SITE_ID), - SiteContentRepositoryService.class).orElseThrow().repository(); + return siteContentRepositoryService(parameters).repository(); } - return getContext().get(InjectorFeature.class).injector() - .getInstance(ContentRepository.class); + return getContext().get(RepositoryFeature.class).contentRepository(); } protected MutableContentRepository getMutableContentRepository(Map parameters) { if (parameters.containsKey(SITE_ID)) { - return ServiceRegistry.getInstance().get((String) parameters.get(SITE_ID), - SiteContentRepositoryService.class).orElseThrow().mutableRepository(); + return siteContentRepositoryService(parameters).mutableRepository(); + } + return getContext().get(MutableRepositoryFeature.class).contentRepository(); + } + + protected CollectionRepository getCollectionRepository(Map parameters) { + if (parameters.containsKey(SITE_ID)) { + return siteCollectionRepositoryService(parameters).repository(); + } + return getContext().get(RepositoryFeature.class).collectionRepository(); + } + + protected MutableCollectionRepository getMutableCollectionRepository(Map parameters) { + if (parameters.containsKey(SITE_ID)) { + return siteCollectionRepositoryService(parameters).mutableRepository(); } - return getContext().get(InjectorFeature.class).injector() - .getInstance(MutableContentRepository.class); + return getContext().get(MutableRepositoryFeature.class).collectionRepository(); + } + + private SiteContentRepositoryService siteContentRepositoryService(Map parameters) { + return ServiceRegistry.getInstance() + .get((String) parameters.get(SITE_ID), SiteContentRepositoryService.class) + .orElseThrow(() -> new IllegalArgumentException( + "unknown site: " + parameters.get(SITE_ID))); + } + + private SiteCollectionRepositoryService siteCollectionRepositoryService(Map parameters) { + return ServiceRegistry.getInstance() + .get((String) parameters.get(SITE_ID), SiteCollectionRepositoryService.class) + .orElseThrow(() -> new IllegalArgumentException( + "unknown site: " + parameters.get(SITE_ID))); } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java index 31458ff0b..20d130a3f 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java @@ -23,31 +23,28 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.auth.Permissions; -import com.condation.cms.api.db.DB; import com.condation.cms.api.db.CursorPage; import com.condation.cms.api.db.Page; -import com.condation.cms.api.db.collection.CollectionCursorSupport; import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.collection.CollectionItemId; import com.condation.cms.api.db.collection.CollectionItemMetadata; import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.CollectionRepository; 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.utils.MapUtil; import com.condation.cms.content.utils.SlugUtil; import com.condation.cms.content.template.functions.LinkFunction; -import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.cms.modules.ui.utils.FormHelper; import com.condation.cms.modules.ui.utils.MetaConverter; import com.condation.cms.modules.ui.utils.NumberUtils; import com.condation.modules.api.annotation.Extension; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Instant; import java.util.Date; import java.util.HashMap; @@ -85,9 +82,9 @@ public record CursorItemsDto(List items, String nextCursor) { @RemoteMethod(name = "collections.items", permissions = {Permissions.CONTENT_EDIT}) public Object items(Map parameters) throws RPCException { - var db = getDB(parameters); + var repository = getCollectionRepository(parameters); var collectionName = requiredString(parameters, Parameters.COLLECTION); - ensureCollectionExists(db.getCollections().names(), collectionName); + ensureCollectionExists(repository.names(), collectionName); long page = Math.max(1, NumberUtils.toLong(parameters.getOrDefault("page", 1L))); long size = Math.clamp( @@ -96,7 +93,7 @@ public Object items(Map parameters) throws RPCException { MAX_PAGE_SIZE); var title = optionalString(parameters, "query"); - var query = db.getCollections().collection(collectionName).metadataQuery(); + var query = repository.metadataQuery(collectionName); if (!title.isBlank()) { query.searchByTitle(title); } @@ -112,27 +109,29 @@ public Object items(Map parameters) throws RPCException { @RemoteMethod(name = "collections.items.cursor", permissions = {Permissions.CONTENT_EDIT}) public Object cursorItems(Map parameters) throws RPCException { - var db = getDB(parameters); + var repository = getCollectionRepository(parameters); var collectionName = requiredString(parameters, Parameters.COLLECTION); - ensureCollectionExists(db.getCollections().names(), collectionName); + ensureCollectionExists(repository.names(), collectionName); long size = Math.clamp( NumberUtils.toLong(parameters.getOrDefault("size", DEFAULT_PAGE_SIZE)), 1, MAX_PAGE_SIZE); - if (!(db.getCollections() instanceof CollectionCursorSupport cursorSupport)) { - throw new RPCException(501, "collection storage does not support cursor paging"); - } var title = optionalString(parameters, "query"); - CursorPage result = cursorSupport.metadataCursorPage( - collectionName, - optionalString(parameters, "cursor"), - size, - query -> { - if (!title.isBlank()) { - query.searchByTitle(title); - } - query.orderby(Constants.MetaFields.TITLE).asc(); - }); + CursorPage result; + try { + result = repository.metadataCursorPage( + collectionName, + optionalString(parameters, "cursor"), + size, + query -> { + if (!title.isBlank()) { + query.searchByTitle(title); + } + query.orderby(Constants.MetaFields.TITLE).asc(); + }); + } catch (UnsupportedOperationException exception) { + throw new RPCException(501, exception.getMessage()); + } return new CursorItemsDto( result.items().stream().map(this::itemDto).toList(), result.nextCursor()); @@ -152,22 +151,18 @@ public Object get(Map parameters) throws RPCException { @RemoteMethod(name = "collections.item.save", permissions = {Permissions.CONTENT_EDIT}) public synchronized Object save(Map parameters) throws RPCException { - var db = getDB(parameters); + var repository = getMutableCollectionRepository(parameters); var item = item(parameters); - ensureLocalCollection(db, item.collection()); - var sourceFile = db.getFileSystem().collectionsBase().resolve(item.path()); - var writableFile = db.getFileSystem().resolve(Constants.Folders.COLLECTIONS).resolve(item.path()); + ensureWritable(repository, item.collection()); try { - var parser = new ContentFileParser(sourceFile); - var meta = new HashMap<>(parser.getHeader()); + var meta = new HashMap<>(item.meta()); var rawMeta = typedMeta(parameters.get("meta")); YamlHeaderUpdater.mergeFlatMapIntoNestedMap(meta, MetaConverter.convertMeta(rawMeta)); - normalizeAndValidateSlug(db, item.collection(), item.id(), meta); + normalizeAndValidateSlug(repository, item.collection(), item.id(), meta); var content = parameters.containsKey(Parameters.CONTENT) ? FormHelper.getContent(parameters.get(Parameters.CONTENT)) - : parser.getContent(); - YamlHeaderUpdater.saveMarkdownFileWithHeader(writableFile, meta, content); - db.getCollections().refresh(item.collection(), item.id()); + : item.content(); + repository.save(item.collection(), item.id(), meta, content); invalidateContentCache(); return Map.of("saved", true); } catch (IOException | RuntimeException ex) { @@ -178,13 +173,12 @@ public synchronized Object save(Map parameters) throws RPCExcept @RemoteMethod(name = "collections.item.create", permissions = {Permissions.CONTENT_EDIT}) public synchronized Object create(Map parameters) throws RPCException { - var db = getDB(parameters); + var repository = getMutableCollectionRepository(parameters); var collectionName = requiredString(parameters, Parameters.COLLECTION); var id = requiredItemId(parameters); - ensureCollectionExists(db.getCollections().names(), collectionName); - ensureLocalCollection(db, collectionName); - var writableFile = writableFile(db, collectionName, id); - if (Files.exists(writableFile)) { + ensureCollectionExists(repository.names(), collectionName); + ensureWritable(repository, collectionName); + if (repository.get(collectionName, id).isPresent()) { throw new RPCException(409, "collection item already exists"); } @@ -192,7 +186,7 @@ public synchronized Object create(Map parameters) throws RPCExce YamlHeaderUpdater.mergeFlatMapIntoNestedMap( meta, MetaConverter.convertMeta(typedMeta(parameters.get("meta")))); - normalizeAndValidateSlug(db, collectionName, id, meta); + normalizeAndValidateSlug(repository, collectionName, id, meta); meta.putIfAbsent(Constants.MetaFields.TITLE, id); meta.put("createdAt", Date.from(Instant.now())); meta.put("createdBy", getUserName()); @@ -202,16 +196,9 @@ public synchronized Object create(Map parameters) throws RPCExce var content = FormHelper.getContent(parameters.get(Parameters.CONTENT)); try { - Files.createDirectories(writableFile.getParent()); - YamlHeaderUpdater.saveMarkdownFileWithHeader(writableFile, meta, content); - db.getCollections().refresh(collectionName, id); + var createdItem = repository.create(collectionName, id, meta, content); invalidateContentCache(); - return itemDto(new CollectionItem( - id, - collectionName, - collectionName + "/" + id + ".md", - content, - meta)); + return itemDto(createdItem); } catch (IOException | RuntimeException exception) { log.error("could not create collection item {}/{}", collectionName, id, exception); throw new RPCException(0, exception.getMessage()); @@ -220,19 +207,17 @@ public synchronized Object create(Map parameters) throws RPCExce @RemoteMethod(name = "collections.item.delete", permissions = {Permissions.CONTENT_EDIT}) public Object delete(Map parameters) throws RPCException { - var db = getDB(parameters); + var repository = getMutableCollectionRepository(parameters); var collectionName = requiredString(parameters, Parameters.COLLECTION); var id = requiredItemId(parameters); - ensureCollectionExists(db.getCollections().names(), collectionName); - ensureLocalCollection(db, collectionName); - var writableFile = writableFile(db, collectionName, id); - if (!Files.isRegularFile(writableFile)) { + ensureCollectionExists(repository.names(), collectionName); + ensureWritable(repository, collectionName); + if (repository.get(collectionName, id).isEmpty()) { throw new RPCException(404, "collection item not found"); } try { - Files.delete(writableFile); - db.getCollections().refresh(collectionName, id); + repository.delete(collectionName, id); invalidateContentCache(); return Map.of("deleted", true); } catch (IOException | RuntimeException exception) { @@ -242,12 +227,12 @@ public Object delete(Map parameters) throws RPCException { } private CollectionItem item(Map parameters) throws RPCException { - var db = getDB(parameters); + var repository = getCollectionRepository(parameters); var collectionName = requiredString(parameters, Parameters.COLLECTION); var id = requiredItemId(parameters); - ensureCollectionExists(db.getCollections().names(), collectionName); + ensureCollectionExists(repository.names(), collectionName); try { - return db.getCollections().collection(collectionName).item(id) + return repository.get(collectionName, id) .orElseThrow(() -> new RPCException(404, "collection item not found")); } catch (IllegalArgumentException ex) { throw new RPCException(400, ex.getMessage()); @@ -289,26 +274,20 @@ private void invalidateContentCache() { getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); } - private static Path writableFile(DB db, String collectionName, String id) { - return db.getFileSystem().resolve(Constants.Folders.COLLECTIONS) - .resolve(collectionName) - .resolve(id + ".md"); - } - private static void ensureCollectionExists(java.util.Set names, String name) throws RPCException { if (!names.contains(name)) { throw new RPCException(404, "collection not found: " + name); } } - private static void ensureLocalCollection(DB db, String name) throws RPCException { - if (!db.getCollections().isLocal(name)) { + private static void ensureWritable(CollectionRepository repository, String name) throws RPCException { + if (repository.access(name) != CollectionAccess.READ_WRITE) { throw new RPCException(403, "referenced collection is read-only: " + name); } } private static void normalizeAndValidateSlug( - DB db, + CollectionRepository repository, String collectionName, String itemId, Map meta) throws RPCException { @@ -325,7 +304,7 @@ private static void normalizeAndValidateSlug( throw new RPCException(400, "collection item slug must not be blank"); } - var duplicate = db.getCollections().collection(collectionName).metadataQuery() + var duplicate = repository.metadataQuery(collectionName) .where("slug", slug) .page(1, 2) .getItems().stream() 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 cfb0428f5..5f19b39b9 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 @@ -22,13 +22,10 @@ */ import com.condation.cms.api.Constants; import com.condation.cms.api.auth.Permissions; -import com.condation.cms.api.db.DB; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; 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.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.EventBusFeature; @@ -38,10 +35,9 @@ import com.condation.cms.api.feature.features.ConfigurationFeature; import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.api.utils.MapUtil; import com.condation.cms.api.repository.ContentRepository; -import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.modules.api.annotation.Extension; import java.io.IOException; @@ -64,7 +60,6 @@ import com.condation.cms.modules.ui.utils.UIFileNameUtil; import com.condation.cms.modules.ui.utils.UIPathUtil; import java.nio.file.Files; -import java.nio.file.Path; /** * @@ -76,8 +71,7 @@ public class RemoteContentEndpointsExtension extends AbstractRemoteMethodeExtens @RemoteMethod(name = "content.get", permissions = {Permissions.CONTENT_EDIT}) public Object getContent(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); - var target = editableTarget(parameters, db); + var target = editableTarget(parameters); Map result = new HashMap<>(); result.put("uri", target.uri()); @@ -95,15 +89,14 @@ public Object getContent(Map parameters) throws RPCException { @RemoteMethod(name = "content.set", permissions = {Permissions.CONTENT_EDIT}) public Object setContent(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); var updatedContent = FormHelper.getContent(parameters.get(Parameters.CONTENT)); - var target = editableTarget(parameters, db); + var target = editableTarget(parameters); Map result = new HashMap<>(); result.put("uri", target.uri()); try { var document = loadTarget(target, parameters); - saveTarget(target, parameters, db, document.metadata(), updatedContent); + saveTarget(target, parameters, document.metadata(), updatedContent); log.debug(LOG_PATTERN, target.uri()); } catch (IOException ex) { log.error("", ex); @@ -153,10 +146,9 @@ public Object replaceContent(Map parameters) throws RPCException @RemoteMethod(name = "meta.set", permissions = {Permissions.CONTENT_EDIT}) public Object setMeta(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); var updateParam = (Map>) parameters.get("meta"); var update = MetaConverter.convertMeta(updateParam); - var target = editableTarget(parameters, db); + var target = editableTarget(parameters); Map result = new HashMap<>(); result.put("uri", target.uri()); @@ -164,7 +156,7 @@ public Object setMeta(Map parameters) throws RPCException { var document = loadTarget(target, parameters); Map meta = new HashMap<>(document.metadata()); YamlHeaderUpdater.mergeFlatMapIntoNestedMap(meta, update); - saveTarget(target, parameters, db, meta, document.content()); + saveTarget(target, parameters, meta, document.content()); log.debug(LOG_PATTERN, target.uri()); } catch (IOException ex) { log.error("", ex); @@ -277,7 +269,6 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio @RemoteMethod(name = "content.node", permissions = {Permissions.CONTENT_EDIT}) public Object getContentNode (Map parameters) { - final DB db = getContext().get(DBFeature.class).db(); var repository = getContentRepository(parameters); var url = (String) parameters.get("url"); @@ -302,7 +293,8 @@ public Object getContentNode (Map parameters) { if (selectedNode == null || canonicalUri == null) { var collectionConfiguration = getContext().get(ConfigurationFeature.class) .configuration().get(CollectionConfiguration.class); - var collectionRoute = new CollectionRouteResolver(db, collectionConfiguration).resolve(path); + var collectionRoute = new CollectionRouteResolver( + getCollectionRepository(parameters), collectionConfiguration).resolve(path); if (collectionRoute.isEmpty()) { return result; } @@ -375,16 +367,15 @@ private String contentUri(Map parameters, String parameterName) throw new RPCException(400, parameterName + " must not be blank"); } - private EditableTarget editableTarget(Map parameters, DB db) throws RPCException { + private EditableTarget editableTarget(Map parameters) throws RPCException { if (!parameters.containsKey("uri") && getRequestContext().has(CurrentCollectionItemFeature.class)) { var item = getRequestContext().get(CurrentCollectionItemFeature.class).item(); - if (!db.getCollections().isLocal(item.collection())) { + if (getCollectionRepository(parameters).access(item.collection()) != CollectionAccess.READ_WRITE) { throw new RPCException(403, "referenced collection is read-only: " + item.collection()); } return new EditableTarget( item.path(), - db.getFileSystem().collectionsBase().resolve(item.path()), item.collection(), item.id()); } @@ -392,18 +383,16 @@ && getRequestContext().has(CurrentCollectionItemFeature.class)) { return new EditableTarget( uri, null, - null, null); } private EditableDocument loadTarget(EditableTarget target, Map parameters) throws IOException, RPCException { if (target.collectionName() != null) { - if (!target.file().exists()) { - throw new RPCException(404, "content not found"); - } - var parser = new ContentFileParser(target.file()); - return new EditableDocument(parser.getHeader(), parser.getContent()); + var item = getCollectionRepository(parameters) + .get(target.collectionName(), target.itemId()) + .orElseThrow(() -> new RPCException(404, "content not found")); + return new EditableDocument(new HashMap<>(item.meta()), item.content()); } var repository = getContentRepository(parameters); var node = repository.get(target.uri()) @@ -413,11 +402,12 @@ private EditableDocument loadTarget(EditableTarget target, Map p return new EditableDocument(new HashMap<>(node.data()), document.content()); } - private void saveTarget(EditableTarget target, Map parameters, DB db, + private void saveTarget(EditableTarget target, Map parameters, Map metadata, String content) throws IOException { if (target.collectionName() != null) { - YamlHeaderUpdater.saveMarkdownFileWithHeader(target.writableFile(db), metadata, content); - refresh(target, db); + getMutableCollectionRepository(parameters).save( + target.collectionName(), target.itemId(), metadata, content); + getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); } else { getMutableContentRepository(parameters).save(target.uri(), metadata, content); getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); @@ -440,29 +430,10 @@ private static String fileName(String path) { return separator < 0 ? normalized : normalized.substring(separator + 1); } - private void refresh(EditableTarget target, DB db) { - if (target.collectionName() != null) { - db.getCollections().refresh(target.collectionName(), target.itemId()); - } else { - getContext().get(EventBusFeature.class).eventBus() - .publish(new ReIndexContentMetaDataEvent(target.uri())); - db.getFileSystem().flushContentChanges(); - } - getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); - } - private record EditableTarget( String uri, - ReadOnlyFile file, String collectionName, String itemId) { - - private Path writableFile(DB db) { - var folder = collectionName == null - ? Constants.Folders.CONTENT - : Constants.Folders.COLLECTIONS; - return db.getFileSystem().resolve(folder).resolve(uri); - } } private record EditableDocument(Map metadata, String content) { 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 23f0eda2a..19188cbed 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 @@ -22,11 +22,9 @@ */ import com.condation.cms.api.Constants; 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; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java index 7ebe5d91b..532d86530 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteTranslationEnpoints.java @@ -24,10 +24,8 @@ import com.condation.cms.api.SiteProperties; import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.configuration.configs.SiteConfiguration; -import com.condation.cms.api.db.DB; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.feature.features.ConfigurationFeature; -import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.modules.api.annotation.Extension; 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 index a3c5afc71..e48669dd5 100644 --- 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 @@ -24,7 +24,6 @@ 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; 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 f27af8342..cb76afc59 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 @@ -20,26 +20,21 @@ * along with this program. If not, see . * #L% */ -import com.condation.cms.api.Constants; import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.db.ContentNode; -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.InjectorFeature; -import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.HTTPUtil; -import com.condation.cms.api.utils.PathUtil; -import com.condation.cms.core.content.io.YamlHeaderUpdater; import com.condation.modules.api.annotation.Extension; import java.util.HashMap; import java.util.Map; @@ -55,13 +50,11 @@ import com.condation.cms.auth.services.User; import com.condation.cms.auth.services.UserService; import com.condation.cms.auth.services.WorkflowAuthorizationService; -import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.modules.ui.model.NodeDTO; import com.condation.cms.modules.ui.utils.NumberUtils; import java.io.IOException; import java.util.Optional; import java.util.List; -import java.nio.file.Path; /** * @@ -82,19 +75,16 @@ private Optional getContentTarget(Map parameters return Optional.of(new WorkflowTarget( node.get(), - null, - null, false, null, null)); } private Optional getWorkflowTarget(Map parameters) throws RPCException { - final DB db = getContext().get(DBFeature.class).db(); if (!parameters.containsKey("uri") && getRequestContext().has(CurrentCollectionItemFeature.class)) { var item = getRequestContext().get(CurrentCollectionItemFeature.class).item(); - if (!db.getCollections().isLocal(item.collection())) { + if (getCollectionRepository(parameters).access(item.collection()) != CollectionAccess.READ_WRITE) { return Optional.empty(); } var node = new ContentNode( @@ -104,8 +94,6 @@ && getRequestContext().has(CurrentCollectionItemFeature.class)) { new HashMap<>(item.meta())); return Optional.of(new WorkflowTarget( node, - db.getFileSystem().collectionsBase().resolve(item.path()), - db.getFileSystem().resolve(Constants.Folders.COLLECTIONS).resolve(item.path()), true, item.collection(), item.id())); @@ -115,8 +103,6 @@ && getRequestContext().has(CurrentCollectionItemFeature.class)) { private record WorkflowTarget( ContentNode node, - ReadOnlyFile file, - Path writableFile, boolean collection, String collectionName, String itemId) { @@ -190,8 +176,6 @@ public Object transit(Map parameters) throws RPCException { try { var transitionId = requiredTransitionId(parameters); - final DB db = getContext().get(DBFeature.class).db(); - var target = getWorkflowTarget(parameters); if (target.isEmpty()) { throw new RPCException(404, "content node not found"); @@ -208,10 +192,14 @@ public Object transit(Map parameters) throws RPCException { workflow.transit(transitionId, contentNode); if (target.get().collection()) { - ContentFileParser parser = new ContentFileParser(target.get().file()); - YamlHeaderUpdater.saveMarkdownFileWithHeader( - target.get().writableFile(), contentNode.data(), parser.getContent()); - db.getCollections().refresh(target.get().collectionName(), target.get().itemId()); + var repository = getMutableCollectionRepository(parameters); + var item = repository.get(target.get().collectionName(), target.get().itemId()) + .orElseThrow(() -> new RPCException(404, "collection item not found")); + repository.save( + target.get().collectionName(), + target.get().itemId(), + contentNode.data(), + item.content()); } else { var repository = getMutableContentRepository(parameters); var document = repository.load(contentNode).orElseThrow(); 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 42ba7a32f..cb75b9f22 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 @@ -25,8 +25,7 @@ import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.feature.features.DBFeature; -import com.condation.cms.api.feature.features.InjectorFeature; -import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; @@ -106,11 +105,10 @@ private void setCurrentContentNode(Request request) { if (uri == null || uri.isBlank()) { return; } - if (!moduleContext.has(InjectorFeature.class)) { + if (!moduleContext.has(RepositoryFeature.class)) { return; } - var content = moduleContext.get(InjectorFeature.class).injector() - .getInstance(ContentRepository.class); + var content = moduleContext.get(RepositoryFeature.class).contentRepository(); content.findByUrl(uri.trim()) .or(() -> content.get(uri.trim())) .ifPresent(node -> requestContext.add( @@ -128,12 +126,13 @@ private boolean setCurrentCollectionItem(Request request) { if (itemId == null || itemId.isBlank()) { return false; } - if (!moduleContext.has(DBFeature.class)) { + if (!moduleContext.has(RepositoryFeature.class)) { return false; } try { - var item = moduleContext.get(DBFeature.class).db() - .getCollections().collection(collectionName.trim()).item(itemId.trim()); + var item = moduleContext.get(RepositoryFeature.class) + .collectionRepository() + .get(collectionName.trim(), itemId.trim()); item.ifPresent(value -> { requestContext.add(CurrentCollectionItemFeature.class, new CurrentCollectionItemFeature(value)); requestContext.add(CurrentNodeFeature.class, new CurrentNodeFeature(new ContentNode( diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java index 875256991..40b17cd52 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java @@ -46,7 +46,8 @@ import com.condation.cms.auth.services.User; import com.condation.cms.auth.services.RoleService; import com.condation.cms.api.feature.features.InjectorFeature; -import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.repository.CollectionAccess; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; @@ -188,8 +189,9 @@ public Menu createContentTypeMenu() { .build(); }).forEach(menu::addMenuEntry); - context.get(DBFeature.class).db().getCollections().names().stream() - .filter(context.get(DBFeature.class).db().getCollections()::isLocal) + var collections = context.get(RepositoryFeature.class).collectionRepository(); + collections.names().stream() + .filter(name -> collections.access(name) == CollectionAccess.READ_WRITE) .sorted() .map(name -> MenuEntry.builder() .id("collection-" + name) diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java index e96dd5727..e79778e73 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java @@ -26,9 +26,11 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.request.RequestContext; import java.util.Map; @@ -39,12 +41,11 @@ class UiTemplateModelExtensionTest { @Test void doesNotRenderCollectionToolbarForReferencedCollection() { - var db = mock(DB.class); - var collections = mock(Collections.class); - when(db.getCollections()).thenReturn(collections); - when(collections.isLocal("authors")).thenReturn(false); + var collections = mock(CollectionRepository.class); + when(collections.access("authors")).thenReturn(CollectionAccess.READ_ONLY); var siteContext = mock(SiteModuleContext.class); - when(siteContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(siteContext.get(RepositoryFeature.class)).thenReturn(new RepositoryFeature( + mock(com.condation.cms.api.repository.ContentRepository.class), collections)); var requestContext = new RequestContext(); requestContext.add(IsPreviewFeature.class, new IsPreviewFeature()); var helper = new UiTemplateModelExtension.UIHelper(requestContext, siteContext); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java index 828b1d4c6..b643c058f 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java @@ -35,6 +35,8 @@ import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.collection.CollectionItemMetadata; import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.feature.features.AuthFeature; import com.condation.cms.api.feature.features.ConfigurationFeature; @@ -48,6 +50,7 @@ import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.workflow.WFStatusProvider; import com.condation.cms.api.workflow.Workflow; +import com.condation.cms.filesystem.FileSystemCollectionRepository; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -108,18 +111,28 @@ class RemoteCollectionEndpointsTest { private WFStatusProvider statusProvider; private RemoteCollectionEndpoints endpoints; + private MutableCollectionRepository repository; private Path collectionsDirectory; @BeforeEach void setUp() throws Exception { collectionsDirectory = tempDirectory.resolve(Constants.Folders.COLLECTIONS); Files.createDirectories(collectionsDirectory.resolve("blog")); - endpoints = new RemoteCollectionEndpoints(); + lenient().when(fileSystem.resolve(Constants.Folders.COLLECTIONS)).thenReturn(collectionsDirectory); + repository = new FileSystemCollectionRepository(collections, fileSystem); + endpoints = new RemoteCollectionEndpoints() { + @Override + protected CollectionRepository getCollectionRepository(Map parameters) { + return repository; + } + + @Override + protected MutableCollectionRepository getMutableCollectionRepository(Map parameters) { + return repository; + } + }; endpoints.setContext(moduleContext); - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - lenient().when(db.getFileSystem()).thenReturn(fileSystem); - lenient().when(db.getCollections()).thenReturn(collections); lenient().when(collections.names()).thenReturn(Set.of("blog")); lenient().when(collections.isLocal("blog")).thenReturn(true); lenient().when(collections.collection("blog")).thenReturn(collection); @@ -129,7 +142,6 @@ void setUp() throws Exception { lenient().when(metadataQuery.where(anyString(), any())).thenReturn(metadataQuery); lenient().when(metadataQuery.page(1, 2)).thenReturn(new com.condation.cms.api.db.Page<>( 0, 2, 0, 1, List.of())); - lenient().when(fileSystem.resolve(Constants.Folders.COLLECTIONS)).thenReturn(collectionsDirectory); } @Test @@ -184,12 +196,7 @@ void rejectsDuplicateSlugsAfterNormalizationWhenSaving() throws Exception { "blog", "blog/first.md", Map.of("slug", "ueber-uns")); - var collectionsBase = mock(ReadOnlyFile.class); - var sourceFile = mock(ReadOnlyFile.class); when(collection.item("second")).thenReturn(Optional.of(editedItem)); - when(fileSystem.collectionsBase()).thenReturn(collectionsBase); - when(collectionsBase.resolve("blog/second.md")).thenReturn(sourceFile); - when(sourceFile.getContent()).thenReturn("---\nslug: second\n---\n\nBody\n"); when(metadataQuery.where("slug", "ueber-uns")).thenReturn(metadataQuery); when(metadataQuery.page(1, 2)).thenReturn(new com.condation.cms.api.db.Page<>( 1, 2, 1, 1, List.of(existingItem))); @@ -206,6 +213,8 @@ void rejectsDuplicateSlugsAfterNormalizationWhenSaving() throws Exception { @Test void rejectsDuplicateAndInvalidCollectionItemIds() throws Exception { Files.writeString(collectionsDirectory.resolve("blog/existing.md"), "existing"); + when(collection.item("existing")).thenReturn(Optional.of(new CollectionItem( + "existing", "blog", "blog/existing.md", "existing", Map.of()))); assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "existing"))) .isInstanceOfSatisfying( @@ -234,6 +243,8 @@ void deletesCollectionItemAndRefreshesIndex() throws Exception { when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); var item = collectionsDirectory.resolve("blog/obsolete.md"); Files.writeString(item, "obsolete"); + when(collection.item("obsolete")).thenReturn(Optional.of(new CollectionItem( + "obsolete", "blog", "blog/obsolete.md", "obsolete", Map.of()))); endpoints.delete(Map.of("collection", "blog", "id", "obsolete")); 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 f8916ad3f..6b59e568f 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 @@ -21,13 +21,8 @@ * #L% */ -import com.condation.cms.api.db.Content; -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.db.collection.CollectionItem; import com.condation.cms.api.db.collection.CollectionItemMetadata; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.configuration.Configuration; import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.configuration.configs.CollectionDefinition; @@ -36,7 +31,6 @@ import com.condation.cms.api.db.Page; import com.condation.cms.api.db.collection.Collection; import com.condation.cms.api.feature.features.ConfigurationFeature; -import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.RequestFeature; @@ -46,6 +40,9 @@ import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.api.repository.MutableContentRepository; import java.io.IOException; import java.util.List; @@ -72,25 +69,10 @@ class RemoteContentEndpointsExtensionTest { private SiteModuleContext moduleContext; @Mock - private DB db; - - @Mock - private Content content; - - @Mock - private DBFileSystem fileSystem; - - @Mock - private ReadOnlyFile contentBase; - - @Mock - private ReadOnlyFile contentFile; - - @Mock - private Collections collections; + private MutableContentRepository contentRepository; @Mock - private MutableContentRepository contentRepository; + private MutableCollectionRepository collectionRepository; private RemoteContentEndpointsExtension endpoints; @@ -106,13 +88,19 @@ protected ContentRepository getContentRepository(Map parameters) protected MutableContentRepository getMutableContentRepository(Map parameters) { return contentRepository; } + + @Override + protected CollectionRepository getCollectionRepository(Map parameters) { + return collectionRepository; + } + + @Override + protected MutableCollectionRepository getMutableCollectionRepository(Map parameters) { + return collectionRepository; + } }; endpoints.setContext(moduleContext); - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - lenient().when(db.getFileSystem()).thenReturn(fileSystem); - lenient().when(fileSystem.contentBase()).thenReturn(contentBase); - lenient().when(db.getCollections()).thenReturn(collections); - lenient().when(collections.isLocal("blog")).thenReturn(true); + lenient().when(collectionRepository.access("blog")).thenReturn(CollectionAccess.READ_WRITE); } @Test @@ -163,7 +151,7 @@ void getContentUsesCurrentNodeWhenUriIsOmitted() throws Exception { @Test void rejectsEditingAReferencedCollectionItem() { - when(collections.isLocal("blog")).thenReturn(false); + when(collectionRepository.access("blog")).thenReturn(CollectionAccess.READ_ONLY); var requestContext = new RequestContext(); requestContext.add( CurrentCollectionItemFeature.class, @@ -212,7 +200,6 @@ void getContentNodeResolvesUriFromCustomPageUrl() throws Exception { @Test void getContentNodeKeepsPublicCollectionRouteAndDisablesVariants() throws Exception { - var nonExistingPath = mock(ReadOnlyFile.class); var authorCollection = mock(Collection.class); @SuppressWarnings("unchecked") var query = (ContentQuery) mock(ContentQuery.class); @@ -222,7 +209,7 @@ void getContentNodeKeepsPublicCollectionRouteAndDisablesVariants() throws Except item.id(), item.collection(), item.path(), item.meta()); when(contentRepository.findByUrl("/people/jane-doe")).thenReturn(Optional.empty()); - when(collections.collection("authors")).thenReturn(authorCollection); + when(collectionRepository.collection("authors")).thenReturn(authorCollection); when(authorCollection.metadataQuery()).thenReturn(query); when(query.where("slug", "jane-doe")).thenReturn(query); when(query.page(1, 2)).thenReturn(new Page<>(1, 2, 1, 1, List.of(metadata))); 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 index 61b00c8a4..d2b2b1e14 100644 --- 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 @@ -21,13 +21,8 @@ * #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.repository.ContentRepository; @@ -41,12 +36,9 @@ 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; @@ -62,15 +54,6 @@ class RemoteVariantEndpointTest { @Mock private SiteModuleContext moduleContext; - @Mock - private DB db; - - @Mock - private Content content; - - @Mock - private DBFileSystem fileSystem; - @Mock private EventBus eventBus; @@ -85,9 +68,6 @@ class RemoteVariantEndpointTest { private RemoteVariantEndpoint endpoint; - @TempDir - private Path tempDir; - @BeforeEach void setUp() { endpoint = new RemoteVariantEndpoint() { @@ -112,11 +92,7 @@ protected VariantSelectorConfigurationRepository getVariantSelectorConfiguration } }; 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 diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java index ebf902a07..b6312e155 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java @@ -21,31 +21,25 @@ * #L% */ -import com.condation.cms.api.Constants; -import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.DBFileSystem; -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.Page; import com.condation.cms.api.db.VariantSearchMode; -import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; -import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.workflow.WFStatusProvider; import com.condation.cms.api.workflow.WFStatusQueryProvider; import com.condation.cms.api.workflow.Workflow; import java.util.List; import java.util.Map; -import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -68,34 +62,10 @@ class RemoteWorkflowEndpointsExtensionTest { private SiteModuleContext moduleContext; @Mock - private DB db; - - @Mock - private DBFileSystem fileSystem; - - @Mock - private ReadOnlyFile contentBase; - - @Mock - private ReadOnlyFile contentFile; - - @Mock - private ReadOnlyFile collectionsBase; - - @Mock - private ReadOnlyFile collectionFile; - - @Mock - private Path collectionsWritableBase; - - @Mock - private Path collectionWritableFile; - - @Mock - private Collections collections; + private ContentRepository contentRepository; @Mock - private ContentRepository contentRepository; + private CollectionRepository collectionRepository; private RemoteWorkflowEndpointsExtension endpoints; @@ -106,19 +76,14 @@ void setUp() { protected ContentRepository getContentRepository(Map parameters) { return contentRepository; } + + @Override + protected CollectionRepository getCollectionRepository(Map parameters) { + return collectionRepository; + } }; endpoints.setContext(moduleContext); - lenient().when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - lenient().when(db.getFileSystem()).thenReturn(fileSystem); - lenient().when(db.getCollections()).thenReturn(collections); - lenient().when(collections.isLocal("blog")).thenReturn(true); - lenient().when(fileSystem.contentBase()).thenReturn(contentBase); - lenient().when(fileSystem.collectionsBase()).thenReturn(collectionsBase); - lenient().when(collectionsBase.resolve("blog/first.md")).thenReturn(collectionFile); - lenient().when(fileSystem.resolve(Constants.Folders.COLLECTIONS)).thenReturn(collectionsWritableBase); - lenient().when(collectionsWritableBase.resolve("blog/first.md")).thenReturn(collectionWritableFile); - lenient().when(contentBase.resolve("missing.md")).thenReturn(contentFile); - lenient().when(contentFile.exists()).thenReturn(false); + lenient().when(collectionRepository.access("blog")).thenReturn(CollectionAccess.READ_WRITE); } @Test @@ -182,7 +147,7 @@ void nodeStatus_usesCurrentCollectionItemOnDetailPage() throws Exception { @Test void nodeStatus_ignoresReferencedCollectionItems() throws Exception { - when(collections.isLocal("blog")).thenReturn(false); + when(collectionRepository.access("blog")).thenReturn(CollectionAccess.READ_ONLY); var requestContext = new RequestContext(); requestContext.add( CurrentCollectionItemFeature.class, 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 43b45477a..5bc53760e 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 @@ -27,18 +27,17 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.collection.Collection; import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.DBFeature; -import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.ui.rpc.RPCError; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.ui.rpc.RPCResult; import com.condation.cms.modules.ui.services.RemoteMethodService; -import com.google.inject.Injector; import java.lang.reflect.Method; import java.util.Map; import java.util.Optional; @@ -97,8 +96,8 @@ void buildErrorResult_fallsBackToCodeMinusOne_forGenericException() throws Excep @Test void contentUriHeaderAddsCurrentNodeFeature() throws Exception { var context = new RequestContext(); - var injector = mock(Injector.class); var content = mock(ContentRepository.class); + var repositories = new RepositoryFeature(content, mock(CollectionRepository.class)); var request = mock(Request.class); var headers = mock(HttpFields.class); var node = new ContentNode( @@ -107,9 +106,8 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { "about.md", Map.of() ); - when(moduleContext.has(InjectorFeature.class)).thenReturn(true); - when(moduleContext.get(InjectorFeature.class)).thenReturn(new InjectorFeature(injector)); - when(injector.getInstance(ContentRepository.class)).thenReturn(content); + when(moduleContext.has(RepositoryFeature.class)).thenReturn(true); + when(moduleContext.get(RepositoryFeature.class)).thenReturn(repositories); when(request.getHeaders()).thenReturn(headers); when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn(null); when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn(node.uri()); @@ -127,13 +125,12 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { @Test void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { var context = new RequestContext(); - var injector = mock(Injector.class); var content = mock(ContentRepository.class); + var repositories = new RepositoryFeature(content, mock(CollectionRepository.class)); var request = mock(Request.class); var headers = mock(HttpFields.class); - when(moduleContext.has(InjectorFeature.class)).thenReturn(true); - when(moduleContext.get(InjectorFeature.class)).thenReturn(new InjectorFeature(injector)); - when(injector.getInstance(ContentRepository.class)).thenReturn(content); + when(moduleContext.has(RepositoryFeature.class)).thenReturn(true); + when(moduleContext.get(RepositoryFeature.class)).thenReturn(repositories); when(request.getHeaders()).thenReturn(headers); when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn(null); when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn("unknown.md"); @@ -151,9 +148,8 @@ void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { @Test void collectionHeadersAddCurrentCollectionItemAndNodeFeatures() throws Exception { var context = new RequestContext(); - var db = mock(DB.class); - var collections = mock(Collections.class); - var collection = mock(Collection.class); + var collections = mock(CollectionRepository.class); + var repositories = new RepositoryFeature(mock(ContentRepository.class), collections); var request = mock(Request.class); var headers = mock(HttpFields.class); var item = new CollectionItem( @@ -162,11 +158,9 @@ void collectionHeadersAddCurrentCollectionItemAndNodeFeatures() throws Exception "blog/first.md", "Body", Map.of("title", "First")); - when(moduleContext.has(DBFeature.class)).thenReturn(true); - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getCollections()).thenReturn(collections); - when(collections.collection("blog")).thenReturn(collection); - when(collection.item("first")).thenReturn(Optional.of(item)); + when(moduleContext.has(RepositoryFeature.class)).thenReturn(true); + when(moduleContext.get(RepositoryFeature.class)).thenReturn(repositories); + when(collections.get("blog", "first")).thenReturn(Optional.of(item)); when(request.getHeaders()).thenReturn(headers); when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn("blog"); when(headers.get(RemoteCallHandler.COLLECTION_ITEM_HEADER)).thenReturn("first"); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java index 5470f14cb..05c08ede6 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java @@ -24,8 +24,11 @@ import com.condation.cms.api.SiteProperties; import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.repository.CollectionAccess; +import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.feature.features.SitePropertiesFeature; import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.module.SiteModuleContext; @@ -37,6 +40,7 @@ import com.condation.cms.api.ui.extensions.UIActionsExtensionPoint; import com.condation.cms.auth.services.User; import com.condation.modules.api.ModuleManager; +import com.google.inject.Injector; import java.util.List; import java.util.Map; import java.util.Set; @@ -164,12 +168,11 @@ void addsCollectionsToCreateContentMenu() { SiteModuleContext context = mock(SiteModuleContext.class); when(context.get(SitePropertiesFeature.class)) .thenReturn(new SitePropertiesFeature(siteProperties)); - DB db = mock(DB.class); - Collections collections = mock(Collections.class); - when(context.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getCollections()).thenReturn(collections); + CollectionRepository collections = mock(CollectionRepository.class); + when(context.get(RepositoryFeature.class)).thenReturn(new RepositoryFeature( + mock(com.condation.cms.api.repository.ContentRepository.class), collections)); when(collections.names()).thenReturn(Set.of("blog", "shared")); - when(collections.isLocal("blog")).thenReturn(true); + when(collections.access("blog")).thenReturn(CollectionAccess.READ_WRITE); HookSystem hookSystem = mock(HookSystem.class); when(hookSystem.doFilter(eq(UIHooks.HOOK_REGISTER_CONTENT_TYPES), any(ContentTypes.class))) diff --git a/test-server/hosts/demo/collections/authors/thorsten.md b/test-server/hosts/demo/collections/authors/thorsten.md index e24de93a7..f2450ef80 100644 --- a/test-server/hosts/demo/collections/authors/thorsten.md +++ b/test-server/hosts/demo/collections/authors/thorsten.md @@ -1,7 +1,9 @@ --- -title: Thorsten -status: published description: CondationCMS main author +title: Thorsten publish_date: 2026-04-07T00:00:00Z slug: thorsten ---- \ No newline at end of file +status: published +--- + +that my author diff --git a/test-server/hosts/demo/content/.variants/index/variants.yaml b/test-server/hosts/demo/content/.variants/index/variants.yaml new file mode 100644 index 000000000..f2dd60758 --- /dev/null +++ b/test-server/hosts/demo/content/.variants/index/variants.yaml @@ -0,0 +1 @@ +selector: date-range diff --git a/test-server/hosts/demo/content/index.md b/test-server/hosts/demo/content/index.md index e36bb7312..7edd0b090 100644 --- a/test-server/hosts/demo/content/index.md +++ b/test-server/hosts/demo/content/index.md @@ -1,25 +1,39 @@ --- -title: Startpage template: start.html -search: - index: false -status: published -description: Thats awesome 1234 parent: text: another text for the meta attribute , seems to work -count: 20 -background_color: '#c2e0c6' range_test: 42 choose_color: green -unpublish_date: null -publish_date: null +radioed: form +author: thorsten +count: 20 +description: Thats awesome +taxonomy: + tags: + - kleidung + - hoodies_sweatshirts + - schuhe + - kinderkleidung + - Small Test + - New tag +title: Startpage +media_url: images/fff.png features: - export +search: + index: false +background_color: '#c2e0c6' +translations: + de: / checked: - farbe +unpublish_date: null +seo: + description: hier kommt die beschreibung +linked_page: /about +publish_date: null selected: form -radioed: form -media_url: images/fff.png +status: published object: values: - title: Test 1 and more @@ -28,20 +42,6 @@ object: - title: test description: blub features: search -seo: - description: hier kommt die beschreibung -linked_page: /about -translations: - de: / -taxonomy: - tags: - - kleidung - - hoodies_sweatshirts - - schuhe - - kinderkleidung - - Small Test - - New tag -author: thorsten --- # Demo Project @@ -50,7 +50,7 @@ author: thorsten That's a demo page with some extra features to show the manager application! -Hello world! +Hello world 2! Here some content! From 6e8bd7f153452f55ba77b07af534c4e11f8f5a6d Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 15 Sep 2026 14:52:04 +0200 Subject: [PATCH 4/8] fix sonar issues --- .../java/com/condation/cms/api/Constants.java | 2 + .../cms/api/content/ContentParser.java | 4 +- .../com/condation/cms/api/db/Content.java | 63 ++++++++++++++----- .../com/condation/cms/api/db/ContentNode.java | 9 ++- .../cms/api/db/collection/Collections.java | 2 +- .../cms/content/DefaultContentParser.java | 10 ++- ...ariantSelectorConfigurationRepository.java | 5 -- .../navigation/NavigationFunction.java | 16 ++--- .../navigation/NavigationFunctionTest.java | 4 +- .../FileSystemContentRepository.java | 5 +- .../FileSystemCollectionRepositoryTest.java | 4 +- .../FileSystemContentRepositoryTest.java | 8 ++- .../FileSystemContentStoreTest.java | 2 +- .../cms/server/host/Initializer.java | 1 - ...ystemContentRepositoryIntegrationTest.java | 12 ++-- .../list/NodeListFunctionBuilderNGTest.java | 12 ++-- .../navigation/NavigationFunctionNGTest.java | 10 +-- .../system/api/helpers/QueryParserTest.java | 1 - .../AbstractRemoteMethodeExtension.java | 2 +- .../RemoteContentEndpointsExtension.java | 8 ++- .../remotemethods/RemoteFileEnpoints.java | 20 +++--- .../remotemethods/RemotePageEnpoints.java | 4 +- .../remotemethods/RemoteFileEnpointsTest.java | 25 +++----- 23 files changed, 135 insertions(+), 94 deletions(-) diff --git a/cms-api/src/main/java/com/condation/cms/api/Constants.java b/cms-api/src/main/java/com/condation/cms/api/Constants.java index 20faf8780..7c031ea26 100644 --- a/cms-api/src/main/java/com/condation/cms/api/Constants.java +++ b/cms-api/src/main/java/com/condation/cms/api/Constants.java @@ -95,6 +95,8 @@ public static class ContentTypes { public static final String JSON = "application/json"; } + public static final String PATH_SEPARATOR = "/"; + public static final String SPLIT_PATH_PATTERN = Pattern.quote("/"); public static final Pattern TAXONOMY_VALUE = Pattern.compile("taxonomy\\.([a-zA-Z0-9-]+)\\.yaml"); diff --git a/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java b/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java index 75e7a7605..79c848935 100644 --- a/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java +++ b/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java @@ -44,13 +44,13 @@ public interface ContentParser { /** * @deprecated use {@link #parse(ContentResource)} instead */ - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) Content parse(final ReadOnlyFile contentFile) throws IOException; /** * @deprecated use {@link #parseMeta(ContentResource)} instead */ - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) Map parseMeta(final ReadOnlyFile contentFile) throws IOException; record ContentRecord(String content, String meta) implements Serializable {} 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 a9c59aca4..51cc3cca7 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 @@ -33,36 +33,67 @@ * @author thmar */ public interface Content { - @Deprecated(since = "8.3.0", forRemoval = false) + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#isVisible(ContentNode)} + * after resolving the node via {@link com.condation.cms.api.repository.ContentRepository#get(String)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) boolean isVisible (String uri); - - @Deprecated(since = "8.3.0", forRemoval = false) + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#isVisible(ContentNode)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) boolean isVisible (ContentNode node); - - @Deprecated(since = "8.3.0", forRemoval = false) + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#sections(ContentNode)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) List listSectionEntries(final ReadOnlyFile contentFile); - - @Deprecated(since = "8.3.0", forRemoval = false) + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#children(String)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) List listContent(final ReadOnlyFile base, final String start); - - @Deprecated(since = "8.3.0", forRemoval = false) + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#directories(String)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) List listDirectories(final ReadOnlyFile base, final String start); - - @Deprecated(since = "8.3.0") + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#get(String)} + */ + @Deprecated(since = "8.4.0") Optional byUri (final String uri); - @Deprecated(since = "8.3.0", forRemoval = false) + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#get(String)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) Optional byPath (final String path); - @Deprecated(since = "8.3.0", forRemoval = false) + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#findByUrl(String)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) Optional byUrl (final String url); Optional> getMeta(final String path); - - @Deprecated(since = "8.3.0", forRemoval = false) + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#query(BiFunction)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) public ContentQuery query(final BiFunction nodeMapper); - @Deprecated(since = "8.3.0", forRemoval = false) + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#query(String, BiFunction)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) public ContentQuery query(final String startURI, final BiFunction nodeMapper); default List searchByTitle (@NonNull String input) { 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 42d08e713..7a104e75f 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 @@ -130,7 +130,7 @@ public boolean isSectionEntry() { * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; * this method depends on the filesystem path convention */ - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) public boolean isVariant() { return variantPathSegment() >= 0; } @@ -139,7 +139,7 @@ public boolean isVariant() { * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; * this method depends on the filesystem path convention */ - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) public Optional variantId() { var pathParts = normalizedPathParts(); var variantSegment = variantPathSegment(pathParts); @@ -152,8 +152,11 @@ public Optional variantId() { /** * Returns the indexed path of the canonical page represented by this * variant. + * + * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; + * this method depends on the filesystem path convention */ - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) public Optional originalUri() { var pathParts = normalizedPathParts(); var variantSegment = variantPathSegment(pathParts); diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java index ded22558c..914dfb178 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java @@ -30,7 +30,7 @@ * {@link com.condation.cms.api.repository.CollectionRepository}. This type * remains as the compatibility boundary for the filesystem implementation. */ -@Deprecated(forRemoval = false) +@Deprecated(since = "8.4.0", forRemoval = false) public interface Collections { Collection collection(String name); diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java b/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java index 1e7600924..40e10d762 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultContentParser.java @@ -71,8 +71,11 @@ public Map parseMeta(final ContentResource contentResource) thro return _parseMeta(readContent(contentResource.content())); } + /** + * @deprecated use {@link #parse(ContentResource)} instead + */ @Override - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) public Content parse(final ReadOnlyFile contentFile) throws IOException { if (contentCache != null && contentCache.contains(contentFile.relativePath())) { return contentCache.get(contentFile.relativePath()); @@ -111,8 +114,11 @@ private Map _parseMeta(ContentRecord content) { } } + /** + * @deprecated use {@link #parseMeta(ContentResource)} instead + */ @Override - @Deprecated(since = "8.3.0", forRemoval = false) + @Deprecated(since = "8.4.0", forRemoval = false) public Map parseMeta(final ReadOnlyFile contentFile) throws IOException { ContentRecord readContent = readContent(contentFile.getAllLines()); 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 index c13b7f5a1..d8191e331 100644 --- a/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java +++ b/cms-content/src/main/java/com/condation/cms/content/VariantSelectorConfigurationRepository.java @@ -21,16 +21,11 @@ * #L% */ -import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.api.repository.MutableContentRepository; import java.io.IOException; -import java.util.Map; 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. diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java index 90c8375e1..9ed7d7947 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/navigation/NavigationFunction.java @@ -46,6 +46,8 @@ public class NavigationFunction extends AbstractCurrentNodeFunction { private static final int DEFAULT_DEPTH = 1; + private static final String INDEX_MD = "index.md"; + private static final String SLASH_INDEX_MD = "/" + INDEX_MD; private String contentType = Constants.DEFAULT_CONTENT_TYPE; @@ -194,24 +196,24 @@ private boolean isCurrentNode(final ContentNode node) { private String childBase(ContentNode node) { var path = normalize(node.path()); - return path.endsWith("index.md") - ? path.substring(0, path.length() - "index.md".length()) + return path.endsWith(INDEX_MD) + ? path.substring(0, path.length() - INDEX_MD.length()) : path; } private Optional parentNode(ContentNode node) { var path = normalize(node.path()); var directory = node.isDirectory() ? path : parentPath(path); - if (path.endsWith("/index.md")) { - directory = parentPath(path.substring(0, path.length() - "/index.md".length())); - } else if ("index.md".equals(path)) { + if (path.endsWith(SLASH_INDEX_MD)) { + directory = parentPath(path.substring(0, path.length() - SLASH_INDEX_MD.length())); + } else if (INDEX_MD.equals(path)) { return Optional.empty(); } if (directory.isEmpty()) { - return contentRepository.get("index.md").filter(parent -> !parent.equals(node)); + return contentRepository.get(INDEX_MD).filter(parent -> !parent.equals(node)); } var parentDirectory = directory; - return contentRepository.get(parentDirectory + "/index.md") + return contentRepository.get(parentDirectory + SLASH_INDEX_MD) .or(() -> contentRepository.get(parentDirectory)) .filter(parent -> !parent.equals(node)); } diff --git a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java index 451ea92ae..731c0fdfd 100644 --- a/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/template/navigation/NavigationFunctionTest.java @@ -56,6 +56,8 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.Mockito.mock; + /** * * @author t.marx @@ -102,7 +104,7 @@ public void setup () { requestContext.add(ContentParserFeature.class, new ContentParserFeature(contentParser)); requestContext.add(MarkdownRendererFeature.class, new MarkdownRendererFeature(markdownRenderer)); requestContext.add(ContentNodeMapperFeature.class, new ContentNodeMapperFeature(contentNodeMapper)); - var siteProperties = Mockito.mock(SiteProperties.class); + var siteProperties = mock(SiteProperties.class); Mockito.lenient().when(siteProperties.contextPath()).thenReturn("/"); requestContext.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java index b4fbfd5bc..5b59c20b8 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java @@ -141,7 +141,7 @@ public void move(String sourcePath, String targetPath) throws IOException { Files.createDirectories(target.getParent()); try { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException exception) { + } catch (AtomicMoveNotSupportedException _) { Files.move(source, target); } fileSystem.flushContentChanges(); @@ -321,7 +321,8 @@ private Optional variantLocation(ContentNode node) { var canonicalPath = index == 0 ? fileName - : String.join("/", java.util.Arrays.copyOf(pathParts, index)) + "/" + fileName; + : String.join(Constants.PATH_SEPARATOR, java.util.Arrays.copyOf(pathParts, index)) + + Constants.PATH_SEPARATOR + fileName; return Optional.of(new VariantLocation(canonicalPath, variantId)); } return Optional.empty(); diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java index 58d8d177f..9deb01b7d 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java @@ -22,6 +22,7 @@ */ import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,7 +31,6 @@ import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.repository.CollectionAccess; import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; import java.util.Set; @@ -74,7 +74,7 @@ void createsSavesAndDeletesItemsBehindTheRepositoryBoundary() throws Exception { repository.delete("blog", "entry"); Assertions.assertThat(item).doesNotExist(); - verify(collections, org.mockito.Mockito.times(3)).refresh("blog", "entry"); + verify(collections, times(3)).refresh("blog", "entry"); } @Test diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java index 87ca8eb06..31be00e24 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java @@ -23,6 +23,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -48,7 +50,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -public class FileSystemContentRepositoryTest { +class FileSystemContentRepositoryTest { @TempDir Path contentBase; @@ -126,7 +128,7 @@ void exposesSectionsAsDomainObjectsWithoutLeakingFilenameMatchingToCallers() thr Assertions.assertThat(repository.sections(owner, "hero")) .extracting(section -> section.id()) .containsExactly(hero.path(), secondHero.path()); - verify(contentParser, org.mockito.Mockito.never()).parse(sidebarResource); + verify(contentParser, never()).parse(sidebarResource); } @Test @@ -184,7 +186,7 @@ void writesMovesAndDeletesContentBehindTheMutableRepositoryBoundary() throws Exc repository.deleteRecursively("published"); Assertions.assertThat(contentBase.resolve("published")).doesNotExist(); - verify(fileSystem, org.mockito.Mockito.times(3)).flushContentChanges(); + verify(fileSystem, times(3)).flushContentChanges(); } @Test diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java index aeec1193c..b4961eedc 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentStoreTest.java @@ -30,7 +30,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -public class FileSystemContentStoreTest { +class FileSystemContentStoreTest { @TempDir Path tempDirectory; diff --git a/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java b/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java index 8a594c240..5f88264c4 100644 --- a/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java +++ b/cms-server/src/main/java/com/condation/cms/server/host/Initializer.java @@ -23,7 +23,6 @@ import com.condation.cms.api.configuration.Configuration; import com.condation.cms.api.db.DB; -import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.NodeTranslationService; import com.condation.cms.core.serivce.impl.SiteDBService; diff --git a/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java b/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java index 7bd66da4a..1b3b9d745 100644 --- a/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java +++ b/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java @@ -40,7 +40,9 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class FileSystemContentRepositoryIntegrationTest { @@ -54,10 +56,10 @@ static void setUp() throws IOException { var contentParser = new DefaultContentParser(); var configuration = new Configuration(); - var siteConfiguration = Mockito.mock(SiteConfiguration.class); - var siteProperties = Mockito.mock(SiteProperties.class); - Mockito.when(siteProperties.id()).thenReturn("repository-test-site"); - Mockito.when(siteConfiguration.siteProperties()).thenReturn(siteProperties); + var siteConfiguration = mock(SiteConfiguration.class); + var siteProperties = mock(SiteProperties.class); + when(siteProperties.id()).thenReturn("repository-test-site"); + when(siteConfiguration.siteProperties()).thenReturn(siteProperties); configuration.add(SiteConfiguration.class, siteConfiguration); db = new FileDB(hostBase, new DefaultEventBus(), file -> { diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java index 9d091e11f..394d32222 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/list/NodeListFunctionBuilderNGTest.java @@ -180,10 +180,10 @@ void list_asterix () { @Test void test_from_subfolder () throws IOException { - var nodeList = new NodeListFunctionBuilder(contentRepository, + var builder = new NodeListFunctionBuilder(contentRepository, contentRepository.get("nodelist2/index.md").orElseThrow(), TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); - Page page = nodeList.from("./sub_folder/*").page(1).size(10).list(); + Page page = builder.from("./sub_folder/*").page(1).size(10).list(); var nodeUris = page.getItems().stream().map(ListNode::path).collect(Collectors.toList()); Assertions.assertThat(nodeUris) .containsExactlyInAnyOrder( @@ -196,14 +196,14 @@ void test_from_subfolder () throws IOException { @Test void test_json () throws IOException { - var nodeList = new NodeListFunctionBuilder(contentRepository, + var builder = new NodeListFunctionBuilder(contentRepository, contentRepository.get("index.md").orElseThrow(), TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); - Page page = nodeList.from("./json").page(1).size(10).list(); + Page page = builder.from("./json").page(1).size(10).list(); Assertions.assertThat(page.getItems()).hasSize(1); Assertions.assertThat(page.getItems().getFirst().name()).isEqualTo("HTML"); - - page = nodeList.from("./json").page(1).size(10).json().list(); + + page = builder.from("./json").page(1).size(10).json().list(); Assertions.assertThat(page.getItems()).hasSize(1); Assertions.assertThat(page.getItems().getFirst().name()).isEqualTo("JSON"); diff --git a/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java b/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java index a74f558fa..4862dfe1d 100644 --- a/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/template/functions/navigation/NavigationFunctionNGTest.java @@ -149,15 +149,15 @@ public void test_path() throws Exception { @Test public void test_json () throws IOException { - var navigationFunction = new NavigationFunction(contentRepository, + var navFunction = new NavigationFunction(contentRepository, contentRepository.get("nav/index.md").orElseThrow(), TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(contentRepository))); - - List list = navigationFunction.json().list("/json"); + + List list = navFunction.json().list("/json"); Assertions.assertThat(list).hasSize(1); Assertions.assertThat(list.get(0).name()).isEqualTo("JSON"); - - list = navigationFunction.html().list("/json"); + + list = navFunction.html().list("/json"); Assertions.assertThat(list).hasSize(1); Assertions.assertThat(list.get(0).name()).isEqualTo("HTML"); } diff --git a/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java b/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java index 04750260c..ced5a0b66 100644 --- a/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java +++ b/modules/api-module/src/test/java/com/condation/cms/modules/system/api/helpers/QueryParserTest.java @@ -28,7 +28,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; 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 5a6371797..f6631aeb8 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 @@ -52,7 +52,7 @@ public abstract class AbstractRemoteMethodeExtension extends AbstractExtensionPo private static final String SITE_ID = "siteId"; private static final String ASSETS = "assets"; - private static final String CONTENT = "content"; + protected static final String CONTENT = "content"; protected String getUserName() { if (getRequestContext().has(AuthFeature.class)) { 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 5f19b39b9..055611329 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 @@ -69,6 +69,8 @@ @Extension(UIRemoteMethodExtensionPoint.class) public class RemoteContentEndpointsExtension extends AbstractRemoteMethodeExtension { + private static final String CONTENT_NOT_FOUND = "content not found"; + @RemoteMethod(name = "content.get", permissions = {Permissions.CONTENT_EDIT}) public Object getContent(Map parameters) throws RPCException { var target = editableTarget(parameters); @@ -391,14 +393,14 @@ private EditableDocument loadTarget(EditableTarget target, Map p if (target.collectionName() != null) { var item = getCollectionRepository(parameters) .get(target.collectionName(), target.itemId()) - .orElseThrow(() -> new RPCException(404, "content not found")); + .orElseThrow(() -> new RPCException(404, CONTENT_NOT_FOUND)); return new EditableDocument(new HashMap<>(item.meta()), item.content()); } var repository = getContentRepository(parameters); var node = repository.get(target.uri()) - .orElseThrow(() -> new RPCException(404, "content not found")); + .orElseThrow(() -> new RPCException(404, CONTENT_NOT_FOUND)); var document = repository.load(node) - .orElseThrow(() -> new RPCException(404, "content not found")); + .orElseThrow(() -> new RPCException(404, CONTENT_NOT_FOUND)); return new EditableDocument(new HashMap<>(node.data()), document.content()); } 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 222f1d7af..5a97daffb 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 @@ -53,6 +53,8 @@ @Extension(UIRemoteMethodExtensionPoint.class) public class RemoteFileEnpoints extends AbstractRemoteMethodeExtension { + private static final String SUCCESS = "success"; + @RemoteMethod(name = "files.list", permissions = {Permissions.CONTENT_EDIT}) public Object list(Map parameters) throws RPCException { var uri = (String) parameters.getOrDefault("uri", ""); @@ -63,7 +65,7 @@ public Object list(Map parameters) throws RPCException { uri = uri.substring(1); } var type = (String) parameters.get("type"); - if ("content".equals(type)) { + if (CONTENT.equals(type)) { return listContent(parameters, uri); } final DB db = getDB(parameters); @@ -114,7 +116,7 @@ public Object delete(Map parameters) throws RPCException { var uri = (String) parameters.getOrDefault("uri", ""); var name = (String) parameters.getOrDefault("name", ""); var type = (String) parameters.get("type"); - if ("content".equals(type)) { + if (CONTENT.equals(type)) { var repository = getMutableContentRepository(parameters); var path = join(uri, name); var node = repository.get(path); @@ -159,7 +161,7 @@ public Object renameFile(Map parameters) throws RPCException { if (newName == null || newName.isBlank()) { throw new IllegalArgumentException("newName must not be null or blank"); } - if ("content".equals(type)) { + if (CONTENT.equals(type)) { var repository = getMutableContentRepository(parameters); var source = join(uri, name); if (name.endsWith(".md")) { @@ -169,12 +171,12 @@ public Object renameFile(Map parameters) throws RPCException { var metadata = new HashMap<>(node.data()); metadata.put(Constants.MetaFields.TITLE, newName.trim()); repository.save(source, metadata, document.content()); - result.put("success", true); + result.put(SUCCESS, true); result.put("newName", name); result.put("title", newName.trim()); } else { repository.move(source, join(uri, newName)); - result.put("success", true); + result.put(SUCCESS, true); result.put("newName", newName); } return result; @@ -210,7 +212,7 @@ public Object renameFile(Map parameters) throws RPCException { new ReIndexContentMetaDataEvent(PathUtil.toRelativeFile(sourcePath, writableBase)) ); db.getFileSystem().flushContentChanges(); - result.put("success", true); + result.put(SUCCESS, true); result.put("newName", name); result.put("title", newName.trim()); return result; @@ -223,7 +225,7 @@ public Object renameFile(Map parameters) throws RPCException { Files.move(sourcePath, targetPath); - result.put("success", true); + result.put(SUCCESS, true); result.put("newName", newName); } catch (Exception e) { @@ -242,7 +244,7 @@ public Object createFolder(Map parameters) throws RPCException { var name = (String) parameters.getOrDefault("name", ""); var uri = (String) parameters.getOrDefault("uri", ""); var type = (String) parameters.get("type"); - if ("content".equals(type)) { + if (CONTENT.equals(type)) { getMutableContentRepository(parameters).createDirectory(join(uri, UIPathUtil.slugify(name))); return result; } @@ -276,7 +278,7 @@ public Object createFile(Map parameters) throws RPCException { var uri = (String) parameters.getOrDefault("uri", ""); var name = (String) parameters.getOrDefault("name", ""); var type = (String) parameters.get("type"); - if ("content".equals(type)) { + if (CONTENT.equals(type)) { var path = join(uri, UIPathUtil.slugify(name)); getMutableContentRepository(parameters).save(path, Map.of(), ""); return result; 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 19188cbed..bdae42ba9 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 @@ -61,7 +61,7 @@ public record SearchResultDto(String uri, String url, String title) { } @RemoteMethod(name = "pages.search", permissions = {Permissions.CONTENT_EDIT}) - public Object searchPages (Map parameters) throws RPCException { + public Object searchPages (Map parameters) { String query = ""; if (parameters.get("query") instanceof String stringValue) { @@ -161,7 +161,7 @@ public Object deletePage(Map parameters) throws RPCException { throw new RPCException(0, "filename can not be null"); } - var path = uri.isBlank() ? name : uri + "/" + name; + var path = uri.isBlank() ? name : uri + Constants.PATH_SEPARATOR + name; log.debug("deleting content {}", path); var node = repository.get(path); var sections = node.isPresent() ? repository.sections(node.get()) : java.util.List.of(); 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 f1cd99832..f1bb9affc 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,20 +22,13 @@ */ 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.api.repository.ContentDocument; import com.condation.cms.api.repository.MutableContentRepository; -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; @@ -43,11 +36,14 @@ 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; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + /** * * @author thmar @@ -70,9 +66,6 @@ public class RemoteFileEnpointsTest { @Mock private MutableContentRepository contentRepository; - @TempDir - private Path tempDir; - public RemoteFileEnpointsTest() { } @@ -92,7 +85,7 @@ void rejectsUnsupportedFileTypesInsteadOfReturningNullBases() { @Test public void create_folder_with_absolut_path_throws_error() throws Exception { - Mockito.doThrow(new IllegalArgumentException("invalid content path")) + doThrow(new IllegalArgumentException("invalid content path")) .when(contentRepository).createDirectory(Mockito.anyString()); RemoteFileEnpoints fileEndpoints = endpoints(); fileEndpoints.setContext(moduleContext); @@ -112,7 +105,7 @@ void listUsesContentTitleButKeepsTechnicalName() throws Exception { "about.md", Map.of(Constants.MetaFields.TITLE, "About us") ); - Mockito.when(contentRepository.children("")).thenReturn(List.of(node)); + when(contentRepository.children("")).thenReturn(List.of(node)); var endpoints = endpoints(); endpoints.setContext(moduleContext); @@ -136,8 +129,8 @@ void listUsesContentTitleButKeepsTechnicalName() throws Exception { void renameMarkdownContentUpdatesTitleWithoutChangingFileName() throws Exception { var node = new ContentNode("about.md", "/about", "about.md", Map.of(Constants.MetaFields.TITLE, "Old title", Constants.MetaFields.TEMPLATE, "page")); - Mockito.when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); - Mockito.when(contentRepository.load(node)).thenReturn(Optional.of(new ContentDocument(node, "Body"))); + when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); + when(contentRepository.load(node)).thenReturn(Optional.of(new ContentDocument(node, "Body"))); var endpoints = endpoints(); endpoints.setContext(moduleContext); @@ -148,7 +141,7 @@ void renameMarkdownContentUpdatesTitleWithoutChangingFileName() throws Exception "newName", "New title" )); - Mockito.verify(contentRepository).save( + verify(contentRepository).save( Mockito.eq("about.md"), Mockito.argThat(meta -> "New title".equals(meta.get(Constants.MetaFields.TITLE)) && "page".equals(meta.get(Constants.MetaFields.TEMPLATE))), From 8ea49e157c374dc41845dcc601118eb71100804d Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 15 Sep 2026 15:01:10 +0200 Subject: [PATCH 5/8] fix sonar issues --- .../extensionpoints/remotemethods/RemotePageEnpointsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 eaf0d73ca..372eb726b 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 @@ -169,7 +169,7 @@ public void testFilterPages_WithPagination() throws RPCException { } @Test - public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { + public void testSearchPages_returnsRewrittenUriAndTitle() { // Arrange when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); when(siteProperties.contextPath()).thenReturn("/cms"); @@ -203,7 +203,7 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { } @Test - public void testSearchPages_missingTitle_fallsBackToEmptyString() throws RPCException { + public void testSearchPages_missingTitle_fallsBackToEmptyString() { // Arrange when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); when(siteProperties.contextPath()).thenReturn("/"); From ebc520b2d935a8428728ff02605b818e62d2442a Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 15 Sep 2026 15:37:25 +0200 Subject: [PATCH 6/8] minor refactoring --- .../com/condation/cms/api/db/ContentNode.java | 9 ++- .../cms/api/variants/VariantContext.java | 4 + .../impl/SiteCollectionRepositoryService.java | 2 +- .../impl/SiteContentRepositoryService.java | 6 +- .../FileSystemContentRepositoryTest.java | 1 + .../RemoteWorkflowEndpointsExtension.java | 5 +- .../AbstractRemoteMethodeExtensionTest.java | 78 +++++++++++++++++++ 7 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtensionTest.java 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 7a104e75f..60d75b719 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 @@ -127,7 +127,8 @@ public boolean isSectionEntry() { } /** - * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; + * @deprecated use + * {@code ContentRepository.variantContext(node).isVariant()}; * this method depends on the filesystem path convention */ @Deprecated(since = "8.4.0", forRemoval = false) @@ -136,7 +137,8 @@ public boolean isVariant() { } /** - * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; + * @deprecated use + * {@code ContentRepository.variantContext(node).activeVariantId()}; * this method depends on the filesystem path convention */ @Deprecated(since = "8.4.0", forRemoval = false) @@ -153,7 +155,8 @@ public Optional variantId() { * Returns the indexed path of the canonical page represented by this * variant. * - * @deprecated use {@code ContentRepository.variantContext(ContentNode)}; + * @deprecated use + * {@code ContentRepository.variantContext(node).canonical().uri()}; * this method depends on the filesystem path convention */ @Deprecated(since = "8.4.0", forRemoval = false) diff --git a/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java b/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java index 26e647cac..50f9db02e 100644 --- a/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java @@ -32,4 +32,8 @@ public record VariantContext( ContentNode canonical, Optional activeVariantId, List variants) { + + public boolean isVariant() { + return activeVariantId.isPresent(); + } } diff --git a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java index 269744e92..1717c95f5 100644 --- a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java +++ b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java @@ -25,7 +25,7 @@ import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.core.serivce.Service; -/** Exposes a site's collection boundary to cross-site callers (e.g. remote UI calls made with a siteId). */ +/** Exposes a site's collection boundary exclusively to cross-site callers. */ public record SiteCollectionRepositoryService( CollectionRepository repository, MutableCollectionRepository mutableRepository) implements Service { diff --git a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java index bc109cb5c..0d743c49a 100644 --- a/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java +++ b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteContentRepositoryService.java @@ -13,11 +13,11 @@ * * 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 + * 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 . + * along with this program. If not, see . * #L% */ @@ -25,7 +25,7 @@ import com.condation.cms.api.repository.MutableContentRepository; import com.condation.cms.core.serivce.Service; -/** Exposes a site's content boundary to cross-site callers (e.g. remote UI calls made with a siteId). */ +/** Exposes a site's content boundary exclusively to cross-site callers. */ public record SiteContentRepositoryService( ContentRepository repository, MutableContentRepository mutableRepository) implements Service { diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java index 31be00e24..e21959e95 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java @@ -154,6 +154,7 @@ void resolvesVariantsAndCanonicalContextInsideTheRepository() throws Exception { resource("articles/.variants/page/red", true, ""))); var context = repository.variantContext(blue); Assertions.assertThat(context.canonical()).isEqualTo(canonical); + Assertions.assertThat(context.isVariant()).isTrue(); Assertions.assertThat(context.activeVariantId()).contains("blue"); Assertions.assertThat(context.variants()).hasSize(2); } 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 cb76afc59..f3df14ad8 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 @@ -151,7 +151,8 @@ public Object unpublishedPages(Map parameters) throws RPCExcepti long requestedPage = Math.max(1, NumberUtils.toLong(parameters.getOrDefault("page", 1L))); long requestedSize = Math.clamp(NumberUtils.toLong(parameters.getOrDefault("size", 10L)), 1, 100); Workflow workflow = getContext().get(WorkflowFeature.class).workflow(); - var query = getContentRepository(parameters).query() + var repository = getContentRepository(parameters); + var query = repository.query() .variants(VariantSearchMode.ORIGINAL); Page page; @@ -161,7 +162,7 @@ public Object unpublishedPages(Map parameters) throws RPCExcepti log.warn("Workflow '{}' status provider does not implement WFStatusQueryProvider; " + "falling back to in-memory unpublished-page filtering", workflow.getId()); List unpublished = query.get().stream() - .filter(node -> !node.isVariant()) + .filter(node -> !repository.variantContext(node).isVariant()) .filter(node -> !workflow.getStatusProvider().isPublished(node)) .toList(); page = inMemoryPage(unpublished, requestedPage, requestedSize); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtensionTest.java new file mode 100644 index 000000000..113ac3008 --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/AbstractRemoteMethodeExtensionTest.java @@ -0,0 +1,78 @@ +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.feature.features.MutableRepositoryFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.ContentRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; +import com.condation.cms.api.repository.MutableContentRepository; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class AbstractRemoteMethodeExtensionTest { + + @Test + void resolvesLocalRepositoriesFromFeatures() { + var content = mock(ContentRepository.class); + var collections = mock(CollectionRepository.class); + var mutableContent = mock(MutableContentRepository.class); + var mutableCollections = mock(MutableCollectionRepository.class); + var context = mock(SiteModuleContext.class); + when(context.get(RepositoryFeature.class)) + .thenReturn(new RepositoryFeature(content, collections)); + when(context.get(MutableRepositoryFeature.class)) + .thenReturn(new MutableRepositoryFeature(mutableContent, mutableCollections)); + var extension = new TestExtension(); + extension.setContext(context); + + Assertions.assertThat(extension.content()).isSameAs(content); + Assertions.assertThat(extension.collections()).isSameAs(collections); + Assertions.assertThat(extension.mutableContent()).isSameAs(mutableContent); + Assertions.assertThat(extension.mutableCollections()).isSameAs(mutableCollections); + } + + private static final class TestExtension extends AbstractRemoteMethodeExtension { + + ContentRepository content() { + return getContentRepository(Map.of()); + } + + CollectionRepository collections() { + return getCollectionRepository(Map.of()); + } + + MutableContentRepository mutableContent() { + return getMutableContentRepository(Map.of()); + } + + MutableCollectionRepository mutableCollections() { + return getMutableCollectionRepository(Map.of()); + } + } +} From fd105e3b728dbf0a5af593dd13193d16fb4810d8 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 15 Sep 2026 20:40:49 +0200 Subject: [PATCH 7/8] merge old collections impl with collections repository --- .../java/com/condation/cms/api/db/DB.java | 4 +- .../collection/CollectionCursorSupport.java | 40 -- .../cms/api/db/collection/Collections.java | 47 --- .../cms/filesystem/FileCollections.java | 375 ------------------ .../com/condation/cms/filesystem/FileDB.java | 28 +- .../FileSystemCollectionRepository.java | 338 ++++++++++++++-- ...va => ReferencedCollectionRepository.java} | 84 ++-- ...ystemCollectionRepositoryStorageTest.java} | 21 +- .../FileSystemCollectionRepositoryTest.java | 48 +-- ...> ReferencedCollectionRepositoryTest.java} | 25 +- .../cms/server/configs/SiteModule.java | 3 +- .../com/condation/cms/SectionEntriesTest.java | 3 +- .../CollectionFunctionsIntegrationTest.java | 3 +- .../cms/content/ContentRendererNGTest.java | 3 +- .../cms/content/ContentResolverTest.java | 3 +- .../RemoteCollectionEndpointsTest.java | 99 ++--- 16 files changed, 456 insertions(+), 668 deletions(-) delete mode 100644 cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java delete mode 100644 cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java delete mode 100644 cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java rename cms-filesystem/src/main/java/com/condation/cms/filesystem/{ReferencedCollections.java => ReferencedCollectionRepository.java} (61%) rename cms-filesystem/src/test/java/com/condation/cms/filesystem/{FileCollectionsTest.java => FileSystemCollectionRepositoryStorageTest.java} (96%) rename cms-filesystem/src/test/java/com/condation/cms/filesystem/{ReferencedCollectionsTest.java => ReferencedCollectionRepositoryTest.java} (80%) diff --git a/cms-api/src/main/java/com/condation/cms/api/db/DB.java b/cms-api/src/main/java/com/condation/cms/api/db/DB.java index 6956bfe84..536e666a2 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/DB.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/DB.java @@ -22,8 +22,8 @@ */ import com.condation.cms.api.db.taxonomy.Taxonomies; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.db.cms.ReadOnlyFileSystem; +import com.condation.cms.api.repository.MutableCollectionRepository; /** @@ -42,7 +42,7 @@ public interface DB extends AutoCloseable{ public Content getContent(); - public Collections getCollections(); + public MutableCollectionRepository getCollectionRepository(); public Taxonomies getTaxonomies(); } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java deleted file mode 100644 index 454add884..000000000 --- a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.condation.cms.api.db.collection; - -/*- - * #%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.ContentQuery; -import com.condation.cms.api.db.CursorPage; -import java.util.function.Consumer; - -/** - * Internal capability for infrastructure that needs cursor-based collection - * traversal. It is deliberately separate from {@link Collection} and - * {@link ContentQuery}, which are exposed to templates. - */ -public interface CollectionCursorSupport { - - CursorPage metadataCursorPage( - String collection, - String cursor, - long size, - Consumer> queryConfigurer); -} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java deleted file mode 100644 index 914dfb178..000000000 --- a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.condation.cms.api.db.collection; - -/*- - * #%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.Set; - -/** - * Low-level collection index used by storage adapters. - * - * @deprecated Application code should depend on - * {@link com.condation.cms.api.repository.CollectionRepository}. This type - * remains as the compatibility boundary for the filesystem implementation. - */ -@Deprecated(since = "8.4.0", forRemoval = false) -public interface Collections { - - Collection collection(String name); - - Set names(); - - /** Returns whether a collection is stored by this site and may be modified here. */ - default boolean isLocal(String collection) { - return true; - } - - /** Re-indexes one item after a synchronous manager write, or removes a deleted item from the index. */ - void refresh(String collection, String id); -} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java deleted file mode 100644 index 7b7405f50..000000000 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java +++ /dev/null @@ -1,375 +0,0 @@ -package com.condation.cms.filesystem; - -/*- - * #%L - * CMS FileSystem - * %% - * 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.ContentQuery; -import com.condation.cms.api.db.CursorPage; -import com.condation.cms.api.db.NodeVisibility; -import com.condation.cms.api.db.collection.CollectionCursorSupport; -import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.collection.CollectionItemId; -import com.condation.cms.api.db.collection.CollectionItemMetadata; -import com.condation.cms.api.db.collection.Collections; -import com.condation.cms.api.utils.PathUtil; -import com.condation.cms.core.content.io.ContentFileParser; -import com.condation.cms.filesystem.metadata.persistent.CollectionMetaData; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.BasicFileAttributes; -import java.time.Duration; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.regex.Pattern; -import lombok.extern.slf4j.Slf4j; - -/** - * Site-scoped, file-backed collections implementation. - */ -@Slf4j -public class FileCollections implements Collections, CollectionCursorSupport, AutoCloseable { - - private static final Pattern COLLECTION_NAME = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9_-]*"); - private static final Duration CHANGE_QUIET_PERIOD = Duration.ofMillis(200); - - private final String siteId; - private final Path hostBase; - private final Path collectionsBase; - private final Function> metaParser; - private final Set collectionNames = ConcurrentHashMap.newKeySet(); - - private CollectionMetaData metaData; - private MultiRootRecursiveWatcher watcher; - private ContentChangeCoordinator changeCoordinator; - - public FileCollections( - String siteId, - Path hostBase, - Function> metaParser) { - this.siteId = siteId; - this.hostBase = hostBase; - this.collectionsBase = hostBase.resolve(Constants.Folders.COLLECTIONS); - this.metaParser = metaParser; - } - - public void init() throws IOException { - Files.createDirectories(collectionsBase); - metaData = new CollectionMetaData(hostBase); - metaData.open(); - changeCoordinator = new ContentChangeCoordinator( - CHANGE_QUIET_PERIOD, - this::processChanges); - rebuild(false); - - watcher = new MultiRootRecursiveWatcher(siteId, List.of(collectionsBase)); - var publisher = Objects.requireNonNull( - watcher.getPublisher(collectionsBase), - "collections publisher must be available"); - publisher.subscribe( - new MultiRootRecursiveWatcher.AbstractFileEventSubscriber() { - @Override - public void onNext(FileEvent item) { - if (item.type() == FileEvent.Type.OVERFLOW) { - changeCoordinator.requestFullResync(); - } else { - changeCoordinator.submit(item.file().toPath()); - } - this.subscription.request(1); - } - }); - watcher.start(); - } - - @Override - public com.condation.cms.api.db.collection.Collection collection(String name) { - validateCollectionName(name); - return new FileCollection(name); - } - - @Override - public Set names() { - return Set.copyOf(collectionNames); - } - - @Override - public CursorPage metadataCursorPage( - String collection, - String cursor, - long size, - Consumer> queryConfigurer) { - validateCollectionName(collection); - return metaData.cursorPage( - collection, - this::mapMetadata, - cursor, - size, - queryConfigurer); - } - - @Override - public void refresh(String collection, String id) { - validateCollectionName(collection); - CollectionItemId.requireValid(id); - var file = collectionsBase.resolve(collection).resolve(id + ".md"); - try { - if (Files.isRegularFile(file)) { - index(file); - } else { - metaData.removeFile(collection + "/" + id + ".md"); - } - } catch (IOException ex) { - throw new IllegalStateException("could not refresh collection item", ex); - } - } - - void handleEvent(FileEvent event) { - if (event.type() == FileEvent.Type.OVERFLOW) { - changeCoordinator.requestFullResync(); - } else { - changeCoordinator.submit(event.file().toPath()); - } - } - - void flushChanges() { - changeCoordinator.flushNow(); - } - - public void reindex() { - changeCoordinator.requestFullResync(); - changeCoordinator.flushNow(); - } - - private void processChanges(boolean fullResync, Set paths) { - try { - if (fullResync) { - rebuild(true); - return; - } - for (var path : paths) { - processPath(path); - } - } catch (IOException ex) { - log.error("error processing collection changes", ex); - } - } - - private void processPath(Path path) throws IOException { - var relative = PathUtil.toRelativeEntry(path, collectionsBase); - if (relative.isBlank() || relative.startsWith("../")) { - return; - } - var parts = relative.split("/"); - if (parts.length == 1) { - if (Files.isDirectory(path)) { - scanCollection(path); - } else if (!Files.exists(path)) { - metaData.removeDirectory(parts[0]); - collectionNames.remove(parts[0]); - } - return; - } - if (parts.length != 2 || !isValidItemFile(path)) { - return; - } - if (!isValidCollectionName(parts[0])) { - return; - } - if (Files.isRegularFile(path)) { - index(path); - } else if (!Files.exists(path)) { - metaData.removeFile(relative); - } - } - - private void rebuild(boolean force) throws IOException { - metaData.startBatch(); - try { - if (force) { - metaData.clear(); - } - var staleCollections = metaData.collectionNames(); - collectionNames.clear(); - try (var collections = Files.list(collectionsBase)) { - for (var iterator = collections.filter(Files::isDirectory).iterator(); iterator.hasNext();) { - var collection = iterator.next(); - staleCollections.remove(collection.getFileName().toString()); - scanCollection(collection); - } - } - staleCollections.forEach(metaData::removeDirectory); - } finally { - metaData.stopBatch(); - } - } - - private void scanCollection(Path collection) throws IOException { - var name = collection.getFileName().toString(); - if (!isValidCollectionName(name)) { - log.warn("ignoring invalid collection name {}", name); - return; - } - collectionNames.add(name); - var stalePaths = metaData.paths(name); - try (var files = Files.list(collection)) { - for (var iterator = files.filter(Files::isRegularFile) - .filter(FileCollections::isValidItemFile).iterator(); iterator.hasNext();) { - var file = iterator.next(); - var path = PathUtil.toRelativeEntry(file, collectionsBase); - stalePaths.remove(path); - var stamp = fileStamp(file); - if (metaData.byPath(path).isEmpty() - || !metaData.fileStamp(path).filter(stamp::equals).isPresent()) { - index(file, path, stamp); - } - } - } - stalePaths.forEach(metaData::removeFile); - } - - private void index(Path file) throws IOException { - var path = PathUtil.toRelativeEntry(file, collectionsBase); - index(file, path, fileStamp(file)); - } - - private void index(Path file, String path, String stamp) throws IOException { - collectionNames.add(path.substring(0, path.indexOf('/'))); - var attributes = Files.readAttributes(file, BasicFileAttributes.class); - var modified = LocalDate.ofInstant( - attributes.lastModifiedTime().toInstant(), - ZoneId.systemDefault()); - metaData.addFile(path, metaParser.apply(file), modified, stamp); - } - - private static String fileStamp(Path file) throws IOException { - var attributes = Files.readAttributes(file, BasicFileAttributes.class); - return attributes.lastModifiedTime().toMillis() + ":" + attributes.size(); - } - - private CollectionItem map(ContentNode node, int ignoredExcerptLength) { - var path = node.path(); - var separator = path.indexOf('/'); - var collection = path.substring(0, separator); - var filename = path.substring(separator + 1); - var id = filename.substring(0, filename.length() - 3); - return new CollectionItem( - id, - collection, - path, - readMarkdownBody(collectionsBase.resolve(path)), - node.data()); - } - - private CollectionItemMetadata mapMetadata(ContentNode node, int ignoredExcerptLength) { - var path = node.path(); - var separator = path.indexOf('/'); - var collection = path.substring(0, separator); - var filename = path.substring(separator + 1); - var id = filename.substring(0, filename.length() - 3); - return new CollectionItemMetadata(id, collection, path, node.data()); - } - - private static String readMarkdownBody(Path file) { - try { - return new ContentFileParser(file.toString()).getContent(); - } catch (IOException ex) { - throw new UncheckedIOException("could not read collection item " + file, ex); - } - } - - private static boolean isMarkdown(Path file) { - return file.getFileName().toString().endsWith(".md"); - } - - private static boolean isValidItemFile(Path file) { - if (!isMarkdown(file)) { - return false; - } - var filename = file.getFileName().toString(); - return CollectionItemId.isValid(filename.substring(0, filename.length() - 3)); - } - - private static boolean isValidCollectionName(String name) { - return name != null && COLLECTION_NAME.matcher(name).matches(); - } - - private static void validateCollectionName(String name) { - if (!isValidCollectionName(name)) { - throw new IllegalArgumentException("invalid collection name: " + name); - } - } - - @Override - public void close() throws IOException { - if (watcher != null) { - watcher.stop(); - } - if (changeCoordinator != null) { - changeCoordinator.close(); - } - if (metaData != null) { - metaData.close(); - } - } - - private class FileCollection implements com.condation.cms.api.db.collection.Collection { - - private final String name; - - private FileCollection(String name) { - this.name = name; - } - - @Override - public String name() { - return name; - } - - @Override - public Optional item(String id) { - CollectionItemId.requireValid(id); - return metaData.byPath(name + "/" + id + ".md") - .filter(NodeVisibility::isVisible) - .map(node -> FileCollections.this.map(node, 0)); - } - - @Override - public ContentQuery query() { - return metaData.query(name, FileCollections.this::map); - } - - @Override - public ContentQuery metadataQuery() { - return metaData.query(name, FileCollections.this::mapMetadata); - } - } -} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileDB.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileDB.java index 9c1bb0bd3..9e20daba3 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileDB.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileDB.java @@ -28,8 +28,8 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.taxonomy.Taxonomies; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.filesystem.taxonomy.FileTaxonomies; import java.io.IOException; import java.nio.file.Path; @@ -52,8 +52,8 @@ public class FileDB implements DB { private FileSystem fileSystem; private FileContent content; - private FileCollections localCollections; - private Collections collections; + private FileSystemCollectionRepository localCollectionRepository; + private MutableCollectionRepository collectionRepository; private ReadOnlyFileSystem readOnlyFileSystem; private FileTaxonomies taxonomies; @@ -71,13 +71,15 @@ public void init () throws IOException { readOnlyFileSystem = new WrappedReadOnlyFileSystem(fileSystem); content = new FileContent(fileSystem); - localCollections = new FileCollections(siteProperties.id(), hostBaseDirectory, contentParser); - localCollections.init(); + localCollectionRepository = new FileSystemCollectionRepository( + siteProperties.id(), hostBaseDirectory, contentParser); + localCollectionRepository.init(); var collectionConfiguration = configuration.get( com.condation.cms.api.configuration.configs.CollectionConfiguration.class); - collections = collectionConfiguration == null - ? localCollections - : new ReferencedCollections(siteProperties.id(), localCollections, collectionConfiguration); + collectionRepository = collectionConfiguration == null + ? localCollectionRepository + : new ReferencedCollectionRepository( + siteProperties.id(), localCollectionRepository, collectionConfiguration); taxonomies = new FileTaxonomies(configuration, content); } @@ -97,7 +99,7 @@ public void init () throws IOException { public void reindex() { var siteProperties = configuration.get(SiteConfiguration.class).siteProperties(); fileSystem.reindex(indexFields(siteProperties.get("index.fields"))); - localCollections.reindex(); + localCollectionRepository.reindex(); } @Deprecated @@ -114,8 +116,8 @@ public DBFileSystem getFileSystem() { @Override public void close() throws Exception { try { - if (localCollections != null) { - localCollections.close(); + if (localCollectionRepository != null) { + localCollectionRepository.close(); } } finally { fileSystem.shutdown(); @@ -128,8 +130,8 @@ public Content getContent() { } @Override - public Collections getCollections() { - return collections; + public MutableCollectionRepository getCollectionRepository() { + return collectionRepository; } @Override diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java index 4ad241e94..2f3fd5930 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java @@ -22,54 +22,103 @@ */ import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.CursorPage; -import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.NodeVisibility; import com.condation.cms.api.db.collection.Collection; -import com.condation.cms.api.db.collection.CollectionCursorSupport; import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.collection.CollectionItemId; import com.condation.cms.api.db.collection.CollectionItemMetadata; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.repository.MutableCollectionRepository; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.content.io.YamlHeaderUpdater; +import com.condation.cms.filesystem.metadata.persistent.CollectionMetaData; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; import java.util.Map; -import java.util.Objects; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; -/** Collection repository backed by the existing collection filesystem index. */ -public final class FileSystemCollectionRepository implements MutableCollectionRepository { +/** Site-scoped collection repository backed by Markdown files and a persistent index. */ +@Slf4j +public final class FileSystemCollectionRepository + implements MutableCollectionRepository, AutoCloseable { - private final Collections collections; - private final DBFileSystem fileSystem; + private static final Pattern COLLECTION_NAME = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9_-]*"); + private static final Duration CHANGE_QUIET_PERIOD = Duration.ofMillis(200); - public FileSystemCollectionRepository(Collections collections, DBFileSystem fileSystem) { - this.collections = Objects.requireNonNull(collections); - this.fileSystem = Objects.requireNonNull(fileSystem); + private final String siteId; + private final Path hostBase; + private final Path collectionsBase; + private final Function> metaParser; + private final Set collectionNames = ConcurrentHashMap.newKeySet(); + + private CollectionMetaData metaData; + private MultiRootRecursiveWatcher watcher; + private ContentChangeCoordinator changeCoordinator; + + public FileSystemCollectionRepository( + String siteId, + Path hostBase, + Function> metaParser) { + this.siteId = siteId; + this.hostBase = hostBase; + this.collectionsBase = hostBase.resolve(Constants.Folders.COLLECTIONS); + this.metaParser = metaParser; + } + + public void init() throws IOException { + Files.createDirectories(collectionsBase); + metaData = new CollectionMetaData(hostBase); + metaData.open(); + changeCoordinator = new ContentChangeCoordinator(CHANGE_QUIET_PERIOD, this::processChanges); + rebuild(false); + + watcher = new MultiRootRecursiveWatcher(siteId, List.of(collectionsBase)); + var publisher = java.util.Objects.requireNonNull( + watcher.getPublisher(collectionsBase), + "collections publisher must be available"); + publisher.subscribe(new MultiRootRecursiveWatcher.AbstractFileEventSubscriber() { + @Override + public void onNext(FileEvent item) { + handleEvent(item); + this.subscription.request(1); + } + }); + watcher.start(); } @Override public Set names() { - return collections.names(); + return Set.copyOf(collectionNames); } @Override public CollectionAccess access(String collection) { - validateCollection(collection); - return collections.isLocal(collection) - ? CollectionAccess.READ_WRITE - : CollectionAccess.READ_ONLY; + validateExistingCollection(collection); + return CollectionAccess.READ_WRITE; } @Override public Collection collection(String name) { - return collections.collection(name); + validateCollectionName(name); + return new FileCollection(name); } @Override @@ -78,10 +127,8 @@ public CursorPage metadataCursorPage( String cursor, long size, Consumer> queryConfigurer) { - if (!(collections instanceof CollectionCursorSupport cursorSupport)) { - throw new UnsupportedOperationException("collection storage does not support cursor paging"); - } - return cursorSupport.metadataCursorPage(collection, cursor, size, queryConfigurer); + validateCollectionName(collection); + return metaData.cursorPage(collection, this::mapMetadata, cursor, size, queryConfigurer); } @Override @@ -95,12 +142,7 @@ public CollectionItem create( throw new FileAlreadyExistsException(collection + "/" + id); } write(collection, id, target, metadata, content); - return new CollectionItem( - id, - collection, - collection + "/" + id + ".md", - content, - metadata); + return new CollectionItem(id, collection, itemPath(collection, id), content, metadata); } @Override @@ -119,7 +161,7 @@ public void delete(String collection, String id) throws IOException { throw new java.nio.file.NoSuchFileException(collection + "/" + id); } Files.delete(target); - collections.refresh(collection, id); + refresh(collection, id); } private void write( @@ -130,16 +172,13 @@ private void write( String content) throws IOException { Files.createDirectories(target.getParent()); YamlHeaderUpdater.saveMarkdownFileWithHeader(target, metadata, content); - collections.refresh(collection, id); + refresh(collection, id); } private Path writableItem(String collection, String id) { - validateCollection(collection); + validateExistingCollection(collection); CollectionItemId.requireValid(id); - if (access(collection) != CollectionAccess.READ_WRITE) { - throw new UnsupportedOperationException("referenced collection is read-only: " + collection); - } - var root = fileSystem.resolve(Constants.Folders.COLLECTIONS).toAbsolutePath().normalize(); + var root = collectionsBase.toAbsolutePath().normalize(); var target = root.resolve(collection).resolve(id + ".md").normalize(); if (!target.startsWith(root) || target.equals(root)) { throw new IllegalArgumentException("invalid collection item path"); @@ -147,9 +186,236 @@ private Path writableItem(String collection, String id) { return target; } - private void validateCollection(String collection) { - if (!collections.names().contains(collection)) { + void refresh(String collection, String id) { + validateCollectionName(collection); + CollectionItemId.requireValid(id); + var file = collectionsBase.resolve(collection).resolve(id + ".md"); + try { + if (Files.isRegularFile(file)) { + index(file); + } else { + metaData.removeFile(itemPath(collection, id)); + } + } catch (IOException ex) { + throw new IllegalStateException("could not refresh collection item", ex); + } + } + + void handleEvent(FileEvent event) { + if (event.type() == FileEvent.Type.OVERFLOW) { + changeCoordinator.requestFullResync(); + } else { + changeCoordinator.submit(event.file().toPath()); + } + } + + void flushChanges() { + changeCoordinator.flushNow(); + } + + public void reindex() { + changeCoordinator.requestFullResync(); + changeCoordinator.flushNow(); + } + + private void processChanges(boolean fullResync, Set paths) { + try { + if (fullResync) { + rebuild(true); + return; + } + for (var path : paths) { + processPath(path); + } + } catch (IOException ex) { + log.error("error processing collection changes", ex); + } + } + + private void processPath(Path path) throws IOException { + var relative = PathUtil.toRelativeEntry(path, collectionsBase); + if (relative.isBlank() || relative.startsWith("../")) { + return; + } + var parts = relative.split("/"); + if (parts.length == 1) { + if (Files.isDirectory(path)) { + scanCollection(path); + } else if (!Files.exists(path)) { + metaData.removeDirectory(parts[0]); + collectionNames.remove(parts[0]); + } + return; + } + if (parts.length != 2 || !isValidItemFile(path) || !isValidCollectionName(parts[0])) { + return; + } + if (Files.isRegularFile(path)) { + index(path); + } else if (!Files.exists(path)) { + metaData.removeFile(relative); + } + } + + private void rebuild(boolean force) throws IOException { + metaData.startBatch(); + try { + if (force) { + metaData.clear(); + } + var staleCollections = metaData.collectionNames(); + collectionNames.clear(); + try (var collections = Files.list(collectionsBase)) { + for (var iterator = collections.filter(Files::isDirectory).iterator(); iterator.hasNext();) { + var collection = iterator.next(); + staleCollections.remove(collection.getFileName().toString()); + scanCollection(collection); + } + } + staleCollections.forEach(metaData::removeDirectory); + } finally { + metaData.stopBatch(); + } + } + + private void scanCollection(Path collection) throws IOException { + var name = collection.getFileName().toString(); + if (!isValidCollectionName(name)) { + log.warn("ignoring invalid collection name {}", name); + return; + } + collectionNames.add(name); + var stalePaths = metaData.paths(name); + try (var files = Files.list(collection)) { + for (var iterator = files.filter(Files::isRegularFile) + .filter(FileSystemCollectionRepository::isValidItemFile).iterator(); iterator.hasNext();) { + var file = iterator.next(); + var path = PathUtil.toRelativeEntry(file, collectionsBase); + stalePaths.remove(path); + var stamp = fileStamp(file); + if (metaData.byPath(path).isEmpty() + || !metaData.fileStamp(path).filter(stamp::equals).isPresent()) { + index(file, path, stamp); + } + } + } + stalePaths.forEach(metaData::removeFile); + } + + private void index(Path file) throws IOException { + var path = PathUtil.toRelativeEntry(file, collectionsBase); + index(file, path, fileStamp(file)); + } + + private void index(Path file, String path, String stamp) throws IOException { + collectionNames.add(path.substring(0, path.indexOf('/'))); + var attributes = Files.readAttributes(file, BasicFileAttributes.class); + var modified = LocalDate.ofInstant( + attributes.lastModifiedTime().toInstant(), ZoneId.systemDefault()); + metaData.addFile(path, metaParser.apply(file), modified, stamp); + } + + private static String fileStamp(Path file) throws IOException { + var attributes = Files.readAttributes(file, BasicFileAttributes.class); + return attributes.lastModifiedTime().toMillis() + ":" + attributes.size(); + } + + private CollectionItem map(ContentNode node, int ignoredExcerptLength) { + var path = node.path(); + var separator = path.indexOf('/'); + var collection = path.substring(0, separator); + var filename = path.substring(separator + 1); + var id = filename.substring(0, filename.length() - 3); + return new CollectionItem( + id, collection, path, readMarkdownBody(collectionsBase.resolve(path)), node.data()); + } + + private CollectionItemMetadata mapMetadata(ContentNode node, int ignoredExcerptLength) { + var path = node.path(); + var separator = path.indexOf('/'); + var collection = path.substring(0, separator); + var filename = path.substring(separator + 1); + var id = filename.substring(0, filename.length() - 3); + return new CollectionItemMetadata(id, collection, path, node.data()); + } + + private static String readMarkdownBody(Path file) { + try { + return new ContentFileParser(file.toString()).getContent(); + } catch (IOException ex) { + throw new UncheckedIOException("could not read collection item " + file, ex); + } + } + + private static boolean isValidItemFile(Path file) { + var filename = file.getFileName().toString(); + return filename.endsWith(".md") + && CollectionItemId.isValid(filename.substring(0, filename.length() - 3)); + } + + private static String itemPath(String collection, String id) { + return collection + "/" + id + ".md"; + } + + private static boolean isValidCollectionName(String name) { + return name != null && COLLECTION_NAME.matcher(name).matches(); + } + + private static void validateCollectionName(String name) { + if (!isValidCollectionName(name)) { + throw new IllegalArgumentException("invalid collection name: " + name); + } + } + + private void validateExistingCollection(String collection) { + validateCollectionName(collection); + if (!collectionNames.contains(collection)) { throw new IllegalArgumentException("collection not found: " + collection); } } + + @Override + public void close() throws IOException { + if (watcher != null) { + watcher.stop(); + } + if (changeCoordinator != null) { + changeCoordinator.close(); + } + if (metaData != null) { + metaData.close(); + } + } + + private class FileCollection implements Collection { + + private final String name; + + private FileCollection(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public Optional item(String id) { + CollectionItemId.requireValid(id); + return metaData.byPath(itemPath(name, id)) + .filter(NodeVisibility::isVisible) + .map(node -> FileSystemCollectionRepository.this.map(node, 0)); + } + + @Override + public ContentQuery query() { + return metaData.query(name, FileSystemCollectionRepository.this::map); + } + + @Override + public ContentQuery metadataQuery() { + return metaData.query(name, FileSystemCollectionRepository.this::mapMetadata); + } + } } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollectionRepository.java similarity index 61% rename from cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java rename to cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollectionRepository.java index b9a72b49c..57b1f1208 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollectionRepository.java @@ -25,34 +25,32 @@ import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.CursorPage; import com.condation.cms.api.db.collection.Collection; -import com.condation.cms.api.db.collection.CollectionCursorSupport; +import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.collection.CollectionItemMetadata; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.repository.CollectionRepository; +import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.SiteCollectionRepositoryService; +import java.io.IOException; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; -/** - * Adds lazy, read-only collection references to the collections stored by one - * site. Source sites are looked up for every access so configuration and site - * reloads do not leave cached cross-site references behind. - */ -final class ReferencedCollections implements Collections, CollectionCursorSupport { +/** Adds lazy, read-only cross-site references to a site's local collection repository. */ +final class ReferencedCollectionRepository implements MutableCollectionRepository { private final String siteId; - private final Collections localCollections; + private final MutableCollectionRepository localRepository; private final CollectionConfiguration configuration; - ReferencedCollections( + ReferencedCollectionRepository( String siteId, - Collections localCollections, + MutableCollectionRepository localRepository, CollectionConfiguration configuration) { this.siteId = siteId; - this.localCollections = localCollections; + this.localRepository = localRepository; this.configuration = configuration; } @@ -60,7 +58,7 @@ final class ReferencedCollections implements Collections, CollectionCursorSuppor public Collection collection(String name) { var sourceSite = sourceSite(name); return sourceSite == null - ? localCollections.collection(name) + ? localRepository.collection(name) : sourceRepository(sourceSite, name).collection(name); } @@ -71,19 +69,15 @@ public CursorPage metadataCursorPage( long size, Consumer> queryConfigurer) { var sourceSite = sourceSite(collection); - if (sourceSite != null) { - return sourceRepository(sourceSite, collection).metadataCursorPage( - collection, cursor, size, queryConfigurer); - } - if (!(localCollections instanceof CollectionCursorSupport cursorSupport)) { - throw new UnsupportedOperationException("collection storage does not support cursor paging"); - } - return cursorSupport.metadataCursorPage(collection, cursor, size, queryConfigurer); + return sourceSite == null + ? localRepository.metadataCursorPage(collection, cursor, size, queryConfigurer) + : sourceRepository(sourceSite, collection).metadataCursorPage( + collection, cursor, size, queryConfigurer); } @Override public Set names() { - var names = new HashSet<>(localCollections.names()); + var names = new HashSet<>(localRepository.names()); configuration.collections().values().stream() .filter(definition -> definition.sourceSite() .filter(sourceSite -> !siteId.equals(sourceSite)) @@ -94,17 +88,50 @@ public Set names() { } @Override - public boolean isLocal(String collection) { - return sourceSite(collection) == null; + public CollectionAccess access(String collection) { + validateCollection(collection); + return sourceSite(collection) == null + ? localRepository.access(collection) + : CollectionAccess.READ_ONLY; } @Override - public void refresh(String collection, String id) { - if (!isLocal(collection)) { + public CollectionItem create( + String collection, + String id, + Map metadata, + String content) throws IOException { + ensureWritable(collection); + return localRepository.create(collection, id, metadata, content); + } + + @Override + public void save( + String collection, + String id, + Map metadata, + String content) throws IOException { + ensureWritable(collection); + localRepository.save(collection, id, metadata, content); + } + + @Override + public void delete(String collection, String id) throws IOException { + ensureWritable(collection); + localRepository.delete(collection, id); + } + + private void ensureWritable(String collection) { + if (access(collection) != CollectionAccess.READ_WRITE) { throw new UnsupportedOperationException( "referenced collection is read-only: " + collection); } - localCollections.refresh(collection, id); + } + + private void validateCollection(String collection) { + if (!names().contains(collection)) { + throw new IllegalArgumentException("collection not found: " + collection); + } } private String sourceSite(String collection) { @@ -114,8 +141,7 @@ private String sourceSite(String collection) { .orElse(null); } - private CollectionRepository sourceRepository( - String sourceSite, String collection) { + private CollectionRepository sourceRepository(String sourceSite, String collection) { var source = ServiceRegistry.getInstance().get(sourceSite, SiteCollectionRepositoryService.class) .orElseThrow(() -> new IllegalStateException( "collection source site is not available: " + sourceSite)); diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileCollectionsTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryStorageTest.java similarity index 96% rename from cms-filesystem/src/test/java/com/condation/cms/filesystem/FileCollectionsTest.java rename to cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryStorageTest.java index a29ab8dd5..fb1949fbd 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileCollectionsTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryStorageTest.java @@ -23,7 +23,6 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.collection.CollectionCursorSupport; import com.condation.cms.api.feature.features.IsPreviewFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.request.RequestContext; @@ -41,7 +40,7 @@ import org.junit.jupiter.api.io.TempDir; import org.yaml.snakeyaml.Yaml; -class FileCollectionsTest { +class FileSystemCollectionRepositoryStorageTest { @TempDir Path tempDirectory; @@ -163,12 +162,12 @@ void reusesPersistedCollectionIndexWhenFilesAreUnchanged() throws Exception { return parseMeta(path); }; - var first = new FileCollections("test-site", tempDirectory, parser); + var first = new FileSystemCollectionRepository("test-site", tempDirectory, parser); first.init(); first.close(); Assertions.assertThat(parseCount).hasValue(2); - var second = new FileCollections("test-site", tempDirectory, parser); + var second = new FileSystemCollectionRepository("test-site", tempDirectory, parser); try { second.init(); Assertions.assertThat(parseCount).hasValue(2); @@ -184,7 +183,7 @@ void explicitReindexReparsesUnchangedCollectionItems() throws Exception { write("blog/first.md", "title: First", "First"); write("blog/second.md", "title: Second", "Second"); var parseCount = new AtomicInteger(); - var collections = new FileCollections("test-site", tempDirectory, path -> { + var collections = new FileSystemCollectionRepository("test-site", tempDirectory, path -> { parseCount.incrementAndGet(); return parseMeta(path); }); @@ -209,10 +208,9 @@ void pagesCollectionsWithAnOpaqueCursorAndExpiresItAfterIndexChanges() throws Ex write("blog/third.md", "title: C", "Third"); var collections = createCollections(); try { - var cursorSupport = (CollectionCursorSupport) collections; - var firstPage = cursorSupport.metadataCursorPage( + var firstPage = collections.metadataCursorPage( "blog", null, 2, query -> query.orderby("title").asc()); - var secondPage = cursorSupport.metadataCursorPage( + var secondPage = collections.metadataCursorPage( "blog", firstPage.nextCursor(), 2, query -> query.orderby("title").asc()); Assertions.assertThat(firstPage.items()).extracting(item -> item.id()) @@ -225,7 +223,7 @@ void pagesCollectionsWithAnOpaqueCursorAndExpiresItAfterIndexChanges() throws Ex write("blog/third.md", "title: D", "Changed"); collections.refresh("blog", "third"); Assertions.assertThatIllegalArgumentException().isThrownBy(() -> - cursorSupport.metadataCursorPage( + collections.metadataCursorPage( "blog", firstPage.nextCursor(), 2, @@ -433,8 +431,9 @@ void appliesConfiguredContentWorkflow() throws Exception { } } - private FileCollections createCollections() throws Exception { - var collections = new FileCollections("test-site", tempDirectory, FileCollectionsTest::parseMeta); + private FileSystemCollectionRepository createCollections() throws Exception { + var collections = new FileSystemCollectionRepository( + "test-site", tempDirectory, FileSystemCollectionRepositoryStorageTest::parseMeta); collections.init(); return collections; } diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java index 9deb01b7d..547d22585 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java @@ -21,20 +21,13 @@ * #L% */ -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.condation.cms.api.Constants; -import com.condation.cms.api.db.DBFileSystem; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.repository.CollectionAccess; import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; -import java.util.Set; import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -44,19 +37,19 @@ class FileSystemCollectionRepositoryTest { @TempDir Path tempDirectory; - private Collections collections; private FileSystemCollectionRepository repository; @BeforeEach - void setUp() { - collections = mock(Collections.class); - var fileSystem = mock(DBFileSystem.class); - when(collections.names()).thenReturn(Set.of("blog", "shared")); - when(collections.isLocal("blog")).thenReturn(true); - when(collections.isLocal("shared")).thenReturn(false); - when(fileSystem.resolve(Constants.Folders.COLLECTIONS)) - .thenReturn(tempDirectory.resolve("collections")); - repository = new FileSystemCollectionRepository(collections, fileSystem); + void setUp() throws Exception { + Files.createDirectories(tempDirectory.resolve("collections/blog")); + repository = new FileSystemCollectionRepository( + "test-site", tempDirectory, _ -> Map.of("status", "published")); + repository.init(); + } + + @AfterEach + void tearDown() throws Exception { + repository.close(); } @Test @@ -67,18 +60,22 @@ void createsSavesAndDeletesItemsBehindTheRepositoryBoundary() throws Exception { .exists() .content() .contains("title: First", "First body"); - verify(collections).refresh("blog", "entry"); + Assertions.assertThat(repository.get("blog", "entry")) + .isPresent() + .get() + .extracting(result -> result.content()) + .isEqualTo("First body"); repository.save("blog", "entry", Map.of("title", "Changed"), "Changed body"); Assertions.assertThat(item).content().contains("title: Changed", "Changed body"); repository.delete("blog", "entry"); Assertions.assertThat(item).doesNotExist(); - verify(collections, times(3)).refresh("blog", "entry"); + Assertions.assertThat(repository.get("blog", "entry")).isEmpty(); } @Test - void rejectsOverwritesTraversalAndWritesToReferencedCollections() throws Exception { + void rejectsOverwritesTraversalAndUnknownCollections() throws Exception { repository.create("blog", "entry", Map.of(), "body"); Assertions.assertThatThrownBy( @@ -88,10 +85,9 @@ void rejectsOverwritesTraversalAndWritesToReferencedCollections() throws Excepti () -> repository.save("blog", "../entry", Map.of(), "other")) .isInstanceOf(IllegalArgumentException.class); Assertions.assertThatThrownBy( - () -> repository.save("shared", "entry", Map.of(), "other")) - .isInstanceOf(UnsupportedOperationException.class) - .hasMessageContaining("read-only"); + () -> repository.save("missing", "entry", Map.of(), "other")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not found"); Assertions.assertThat(repository.access("blog")).isEqualTo(CollectionAccess.READ_WRITE); - Assertions.assertThat(repository.access("shared")).isEqualTo(CollectionAccess.READ_ONLY); } } diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionRepositoryTest.java similarity index 80% rename from cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java rename to cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionRepositoryTest.java index c3b7b81bd..f64e61d7c 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionRepositoryTest.java @@ -28,18 +28,18 @@ import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.configuration.configs.CollectionDefinition; import com.condation.cms.api.db.collection.Collection; -import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.SiteCollectionRepositoryService; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -class ReferencedCollectionsTest { +class ReferencedCollectionRepositoryTest { @AfterEach void clearServices() { @@ -48,7 +48,7 @@ void clearServices() { @Test void resolvesReferencedCollectionsThroughTheSourceSite() { - var local = mock(Collections.class); + var local = mock(MutableCollectionRepository.class); when(local.names()).thenReturn(Set.of("local")); var sourceCollections = mock(MutableCollectionRepository.class); var sourceCollection = mock(Collection.class); @@ -60,29 +60,30 @@ void resolvesReferencedCollectionsThroughTheSourceSite() { new SiteCollectionRepositoryService(sourceCollections, sourceCollections)); var definitions = new ConcurrentHashMap(); definitions.put("shared", new CollectionDefinition("shared", "content-site", null)); - var collections = new ReferencedCollections( + var collections = new ReferencedCollectionRepository( "consumer-site", local, new CollectionConfiguration(definitions)); Assertions.assertThat(collections.names()).containsExactlyInAnyOrder("local", "shared"); - Assertions.assertThat(collections.isLocal("shared")).isFalse(); + Assertions.assertThat(collections.access("shared")).isEqualTo(CollectionAccess.READ_ONLY); Assertions.assertThat(collections.collection("shared")).isSameAs(sourceCollection); - Assertions.assertThatThrownBy(() -> collections.refresh("shared", "item")) + Assertions.assertThatThrownBy(() -> collections.save("shared", "item", Map.of(), "body")) .isInstanceOf(UnsupportedOperationException.class) .hasMessageContaining("read-only"); } @Test - void usesReloadedConfigurationWithoutRecreatingTheCollectionsFacade() { - var local = mock(Collections.class); + void usesReloadedConfigurationWithoutRecreatingTheRepository() throws Exception { + var local = mock(MutableCollectionRepository.class); var localCollection = mock(Collection.class); when(local.names()).thenReturn(Set.of("shared")); + when(local.access("shared")).thenReturn(CollectionAccess.READ_WRITE); when(local.collection("shared")).thenReturn(localCollection); var definitions = new ConcurrentHashMap(); definitions.put("shared", new CollectionDefinition("shared", "content-site", null)); var configuration = new CollectionConfiguration(definitions); - var collections = new ReferencedCollections( + var collections = new ReferencedCollectionRepository( "consumer-site", local, configuration); @@ -90,9 +91,9 @@ void usesReloadedConfigurationWithoutRecreatingTheCollectionsFacade() { definitions.put("shared", new CollectionDefinition("shared", null)); configuration.replaceCollections(definitions); - Assertions.assertThat(collections.isLocal("shared")).isTrue(); + Assertions.assertThat(collections.access("shared")).isEqualTo(CollectionAccess.READ_WRITE); Assertions.assertThat(collections.collection("shared")).isSameAs(localCollection); - collections.refresh("shared", "item"); - verify(local).refresh("shared", "item"); + collections.save("shared", "item", Map.of(), "body"); + verify(local).save("shared", "item", Map.of(), "body"); } } 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 2cc00fe11..e246605d0 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 @@ -92,7 +92,6 @@ import com.condation.cms.extensions.ExtensionManager; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; -import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.MetaData; import com.condation.cms.filesystem.NIOReadOnlyFile; @@ -353,7 +352,7 @@ public ContentRepository contentRepository(MutableContentRepository repository) @Provides @Singleton public MutableCollectionRepository mutableCollectionRepository(DB db) { - return new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem()); + return db.getCollectionRepository(); } @Provides diff --git a/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java b/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java index 2275fc5b5..c8bac92c5 100644 --- a/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java +++ b/integration-tests/src/test/java/com/condation/cms/SectionEntriesTest.java @@ -37,7 +37,6 @@ import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; -import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.template.TemplateEngineTest; @@ -97,7 +96,7 @@ public static void beforeClass() throws IOException { new TestSiteProperties(Map.of()), new MockModuleManager(), contentRepository, - new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem()) + db.getCollectionRepository() ); } diff --git a/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java b/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java index c7c522692..87e78e79d 100644 --- a/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java +++ b/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java @@ -43,7 +43,6 @@ import com.condation.cms.core.content.io.ContentFileParser; import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; -import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.hooksystem.CMSHookSystem; import com.condation.cms.hooksystem.extensions.TemplateHooks; import com.condation.cms.modules.system.templates.CollectionTemplateFunctionExtensions; @@ -91,7 +90,7 @@ void setUp() throws Exception { var extension = new CollectionTemplateFunctionExtensions(); var moduleContext = new SiteModuleContext(); moduleContext.add(DBFeature.class, new DBFeature(db)); - moduleContext.add(RepositoryFeature.class, new RepositoryFeature(null, new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem()))); + moduleContext.add(RepositoryFeature.class, new RepositoryFeature(null, db.getCollectionRepository())); extension.setContext(moduleContext); collection = (Collection) extension.collection(new Parameter(Map.of("value", COLLECTION_NAME))); diff --git a/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java b/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java index 7fc1c81e7..3d2335745 100644 --- a/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/content/ContentRendererNGTest.java @@ -38,7 +38,6 @@ import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; -import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.template.TemplateEngineTest; @@ -103,7 +102,7 @@ public static void beforeClass () throws IOException { new TestSiteProperties(Map.of()), moduleManager, contentRepository, - new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem())); + db.getCollectionRepository()); } @AfterAll public static void shutdown () throws Exception { 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 1f867a813..fa21ab7e2 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 @@ -42,7 +42,6 @@ import com.condation.cms.core.eventbus.DefaultEventBus; import com.condation.cms.filesystem.FileDB; import com.condation.cms.filesystem.FileSystemContentRepository; -import com.condation.cms.filesystem.FileSystemCollectionRepository; import com.condation.cms.filesystem.FileSystemContentStore; import com.condation.cms.filesystem.NIOReadOnlyFile; import com.condation.cms.test.TestSiteProperties; @@ -97,7 +96,7 @@ public static void setup() throws IOException { new TestSiteProperties(Map.of()), moduleManager, contentRepository, - new FileSystemCollectionRepository(db.getCollections(), db.getFileSystem())); + db.getCollectionRepository()); contentResolver = new ContentResolver( contentRenderer, contentRepository, new DefaultVariantSelector()); } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java index b643c058f..5d7a060d6 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java @@ -27,14 +27,7 @@ import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.configuration.configs.CollectionDefinition; import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; -import com.condation.cms.api.db.DB; -import com.condation.cms.api.db.DBFileSystem; -import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.cms.ReadOnlyFile; -import com.condation.cms.api.db.collection.Collection; -import com.condation.cms.api.db.collection.CollectionItem; -import com.condation.cms.api.db.collection.CollectionItemMetadata; -import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.repository.CollectionAccess; import com.condation.cms.api.repository.CollectionRepository; import com.condation.cms.api.repository.MutableCollectionRepository; import com.condation.cms.api.eventbus.EventBus; @@ -53,26 +46,22 @@ import com.condation.cms.filesystem.FileSystemCollectionRepository; import java.nio.file.Files; import java.nio.file.Path; -import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.yaml.snakeyaml.Yaml; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; @ExtendWith(MockitoExtension.class) class RemoteCollectionEndpointsTest { @@ -83,24 +72,6 @@ class RemoteCollectionEndpointsTest { @Mock private SiteModuleContext moduleContext; - @Mock - private DB db; - - @Mock - private DBFileSystem fileSystem; - - @Mock - private Collections collections; - - @Mock - private Collection collection; - - @Mock - private ContentQuery collectionQuery; - - @Mock - private ContentQuery metadataQuery; - @Mock private EventBus eventBus; @@ -112,14 +83,17 @@ class RemoteCollectionEndpointsTest { private RemoteCollectionEndpoints endpoints; private MutableCollectionRepository repository; + private FileSystemCollectionRepository fileRepository; private Path collectionsDirectory; @BeforeEach void setUp() throws Exception { collectionsDirectory = tempDirectory.resolve(Constants.Folders.COLLECTIONS); Files.createDirectories(collectionsDirectory.resolve("blog")); - lenient().when(fileSystem.resolve(Constants.Folders.COLLECTIONS)).thenReturn(collectionsDirectory); - repository = new FileSystemCollectionRepository(collections, fileSystem); + fileRepository = new FileSystemCollectionRepository( + "test-site", tempDirectory, RemoteCollectionEndpointsTest::parseMeta); + fileRepository.init(); + repository = fileRepository; endpoints = new RemoteCollectionEndpoints() { @Override protected CollectionRepository getCollectionRepository(Map parameters) { @@ -132,16 +106,11 @@ protected MutableCollectionRepository getMutableCollectionRepository(Map( - 0, 2, 0, 1, List.of())); + @AfterEach + void tearDown() throws Exception { + fileRepository.close(); } @Test @@ -180,26 +149,14 @@ void createsCollectionItemWithWorkflowMetadataAndRefreshesIndex() throws Excepti "status: draft", "createdBy: editor", "# Body"); - verify(collections).refresh("blog", "first-item"); } @Test void rejectsDuplicateSlugsAfterNormalizationWhenSaving() throws Exception { - var editedItem = new CollectionItem( - "second", - "blog", - "blog/second.md", - "", - Map.of("slug", "second")); - var existingItem = new CollectionItemMetadata( - "first", - "blog", - "blog/first.md", - Map.of("slug", "ueber-uns")); - when(collection.item("second")).thenReturn(Optional.of(editedItem)); - when(metadataQuery.where("slug", "ueber-uns")).thenReturn(metadataQuery); - when(metadataQuery.page(1, 2)).thenReturn(new com.condation.cms.api.db.Page<>( - 1, 2, 1, 1, List.of(existingItem))); + repository.create( + "blog", "first", Map.of("slug", "ueber-uns", "status", "published"), ""); + repository.create( + "blog", "second", Map.of("slug", "second", "status", "published"), ""); assertThatThrownBy(() -> endpoints.save(Map.of( "collection", "blog", @@ -212,9 +169,7 @@ void rejectsDuplicateSlugsAfterNormalizationWhenSaving() throws Exception { @Test void rejectsDuplicateAndInvalidCollectionItemIds() throws Exception { - Files.writeString(collectionsDirectory.resolve("blog/existing.md"), "existing"); - when(collection.item("existing")).thenReturn(Optional.of(new CollectionItem( - "existing", "blog", "blog/existing.md", "existing", Map.of()))); + repository.create("blog", "existing", Map.of("status", "published"), "existing"); assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "existing"))) .isInstanceOfSatisfying( @@ -242,19 +197,19 @@ void rejectsInvalidItemIdsConsistentlyWhenLoadingItems() { void deletesCollectionItemAndRefreshesIndex() throws Exception { when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); var item = collectionsDirectory.resolve("blog/obsolete.md"); - Files.writeString(item, "obsolete"); - when(collection.item("obsolete")).thenReturn(Optional.of(new CollectionItem( - "obsolete", "blog", "blog/obsolete.md", "obsolete", Map.of()))); + repository.create("blog", "obsolete", Map.of("status", "published"), "obsolete"); endpoints.delete(Map.of("collection", "blog", "id", "obsolete")); assertThat(item).doesNotExist(); - verify(collections).refresh("blog", "obsolete"); + assertThat(repository.get("blog", "obsolete")).isEmpty(); } @Test void rejectsWritesToReferencedCollections() { - when(collections.isLocal("blog")).thenReturn(false); + repository = mock(MutableCollectionRepository.class); + when(repository.names()).thenReturn(Set.of("blog")); + when(repository.access("blog")).thenReturn(CollectionAccess.READ_ONLY); assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "new-item"))) .isInstanceOfSatisfying( @@ -277,4 +232,14 @@ private RequestContext requestContext() { requestContext.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); return requestContext; } + + @SuppressWarnings("unchecked") + private static Map parseMeta(Path file) { + try { + var parts = Files.readString(file).split("---", 3); + return parts.length == 3 ? new Yaml().load(parts[1]) : Map.of(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } } From 2b37035ac521aebcf730c8ba783f3c5f422287f6 Mon Sep 17 00:00:00 2001 From: Thorsten Marx Date: Tue, 15 Sep 2026 20:48:47 +0200 Subject: [PATCH 8/8] remove unused import --- .../remotemethods/RemoteCollectionEndpointsTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java index 5d7a060d6..ed49f5db0 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java @@ -33,7 +33,6 @@ import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.feature.features.AuthFeature; import com.condation.cms.api.feature.features.ConfigurationFeature; -import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; import com.condation.cms.api.feature.features.WorkflowFeature;