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/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/content/ContentParser.java b/cms-api/src/main/java/com/condation/cms/api/content/ContentParser.java index b642f3aa5..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 @@ -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.4.0", forRemoval = false) Content parse(final ReadOnlyFile contentFile) throws IOException; + /** + * @deprecated use {@link #parseMeta(ContentResource)} instead + */ + @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 467a25a82..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,27 +33,67 @@ * @author thmar */ public interface Content { + /** + * @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 use {@link com.condation.cms.api.repository.ContentRepository#isVisible(ContentNode)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) boolean isVisible (ContentNode node); - + + /** + * @deprecated use {@link com.condation.cms.api.repository.ContentRepository#sections(ContentNode)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) List listSectionEntries(final ReadOnlyFile contentFile); - + + /** + * @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 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 use {@link com.condation.cms.api.repository.ContentRepository#get(String)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) Optional byPath (final String path); + /** + * @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 use {@link com.condation.cms.api.repository.ContentRepository#query(BiFunction)} + */ + @Deprecated(since = "8.4.0", forRemoval = false) public ContentQuery query(final BiFunction nodeMapper); + /** + * @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 570625773..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 @@ -126,10 +126,22 @@ public boolean isSectionEntry() { return SectionUtil.isSectionEntry(name); } + /** + * @deprecated use + * {@code ContentRepository.variantContext(node).isVariant()}; + * this method depends on the filesystem path convention + */ + @Deprecated(since = "8.4.0", forRemoval = false) public boolean isVariant() { return variantPathSegment() >= 0; } + /** + * @deprecated use + * {@code ContentRepository.variantContext(node).activeVariantId()}; + * this method depends on the filesystem path convention + */ + @Deprecated(since = "8.4.0", forRemoval = false) public Optional variantId() { var pathParts = normalizedPathParts(); var variantSegment = variantPathSegment(pathParts); @@ -142,7 +154,12 @@ public Optional variantId() { /** * Returns the indexed path of the canonical page represented by this * variant. + * + * @deprecated use + * {@code ContentRepository.variantContext(node).canonical().uri()}; + * this method depends on the filesystem path convention */ + @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/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/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/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/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/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/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/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/db/collection/Collections.java b/cms-api/src/main/java/com/condation/cms/api/repository/CollectionAccess.java similarity index 59% rename from cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java rename to cms-api/src/main/java/com/condation/cms/api/repository/CollectionAccess.java index ccfa0b753..da7a789fd 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/repository/CollectionAccess.java @@ -1,4 +1,4 @@ -package com.condation.cms.api.db.collection; +package com.condation.cms.api.repository; /*- * #%L @@ -21,22 +21,8 @@ * #L% */ -import java.util.Set; - -/** - * Site-scoped access to collections. - */ -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); +/** 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/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..939f0b68d --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/repository/ContentRepository.java @@ -0,0 +1,77 @@ +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); + + default Optional variantSelectorId(ContentNode node) { + return Optional.empty(); + } + + 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/db/collection/CollectionCursorSupport.java b/cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java similarity index 55% rename from cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java rename to cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java index 454add884..52215059f 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java +++ b/cms-api/src/main/java/com/condation/cms/api/repository/MutableCollectionRepository.java @@ -1,4 +1,4 @@ -package com.condation.cms.api.db.collection; +package com.condation.cms.api.repository; /*- * #%L @@ -21,20 +21,18 @@ * #L% */ -import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.CursorPage; -import java.util.function.Consumer; +import com.condation.cms.api.db.collection.CollectionItem; +import java.io.IOException; +import java.util.Map; -/** - * 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 { +/** 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; - CursorPage metadataCursorPage( - String collection, - String cursor, - long size, - Consumer> queryConfigurer); + void delete(String collection, String id) throws IOException; } 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/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/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-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..50f9db02e --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/variants/VariantContext.java @@ -0,0 +1,39 @@ +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) { + + public boolean isVariant() { + return activeVariantId.isPresent(); + } +} 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..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(); @@ -80,13 +78,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 +86,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/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/ContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java index ccfe0e37d..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,17 +24,16 @@ 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; -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,23 +41,26 @@ */ 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, final ContentNode collectionNode, final CollectionItem item, 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..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 @@ -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,46 @@ 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())); + } + + /** + * @deprecated use {@link #parse(ContentResource)} instead + */ + @Override + @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()); - } - - 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(); @@ -89,15 +114,22 @@ private Map _parseMeta(ContentRecord content) { } } + /** + * @deprecated use {@link #parseMeta(ContentResource)} instead + */ @Override + @Deprecated(since = "8.4.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..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 @@ -23,12 +23,10 @@ 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; 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; @@ -44,9 +42,12 @@ 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.CollectionRepository; +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,36 @@ 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; + private final CollectionRepository collectionRepository; + + public DefaultContentRenderer( + Supplier templates, + DB db, + SiteProperties siteProperties, + ModuleManager moduleManager, + 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( String uri, @@ -99,45 +114,111 @@ 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 { + return renderResolved( + document.node(), + 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 sectionNode = contentRepository.get(section.id()); + var renderedContent = renderResolved( + sectionNode.orElse(null), + 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 rawContent = document.map(ContentDocument::content).orElse(""); + var node = document.map(ContentDocument::node); + return renderResolved( + node.orElse(null), + 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 { + return renderResolved( + document.node(), + 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) { @@ -148,7 +229,6 @@ private String renderContent(final String rawContent, final RequestContext conte @Override public String renderCollection( - ReadOnlyFile collectionFile, ContentNode collectionNode, CollectionItem item, String template, @@ -156,7 +236,7 @@ public String renderCollection( var meta = new HashMap<>(item.meta()); meta.put("template", template); return renderResolved( - collectionFile, + collectionNode, context, new ResolvedRenderInput( collectionNode.url(), @@ -166,32 +246,12 @@ 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())); }); } - @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, + ContentNode currentNode, RequestContext context, ResolvedRenderInput input, Consumer modelExtending) throws IOException { @@ -201,8 +261,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); @@ -217,13 +276,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()); @@ -282,7 +341,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<>(); @@ -292,19 +351,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( + contentRepository, currentNode, context, customOperators); queryFn.setContentType(siteProperties.defaultContentType()); return queryFn; } - protected NodeListFunctionBuilder createNodeListFunction(final ReadOnlyFile contentFile, final RequestContext context) { - var nlFn = new NodeListFunctionBuilder(db, 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, contentFile, context); + protected NavigationFunction createNavigationFunction(final ContentNode currentNode, final RequestContext context) { + var navFn = new NavigationFunction(contentRepository, currentNode, context); navFn.contentType(siteProperties.defaultContentType()); return navFn; } @@ -335,37 +395,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..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,77 +21,39 @@ * #L% */ -import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.DB; -import com.condation.cms.core.content.io.YamlHeaderUpdater; +import com.condation.cms.api.repository.ContentRepository; +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.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.yaml.snakeyaml.LoaderOptions; -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Loads and stores the selector configured for a canonical page. */ @Slf4j -@RequiredArgsConstructor public class VariantSelectorConfigurationRepository { public static final String DEFAULT_SELECTOR_ID = "date-range"; public static final String CONFIG_FILE_NAME = "variants.yaml"; private static final String SELECTOR_PROPERTY = "selector"; - private final DB db; - private final VariantResolver variantResolver; + private final ContentRepository contentRepository; + private final MutableContentRepository mutableContentRepository; - public String getSelectorId(ContentNode node) { - var configurationFile = configurationFile(node); - if (!Files.exists(configurationFile)) { - return DEFAULT_SELECTOR_ID; - } + public VariantSelectorConfigurationRepository(ContentRepository contentRepository, + MutableContentRepository mutableContentRepository) { + this.contentRepository = contentRepository; + this.mutableContentRepository = mutableContentRepository; + } - try { - var yaml = new Yaml(new SafeConstructor(new LoaderOptions())); - var data = yaml.load(Files.readString(configurationFile)); - if (data instanceof Map map - && map.get(SELECTOR_PROPERTY) instanceof String selector - && !selector.isBlank()) { - return selector.trim(); - } - } catch (Exception exception) { - log.error("Could not read variant selector configuration '{}'", configurationFile, exception); - } - return DEFAULT_SELECTOR_ID; + public String getSelectorId(ContentNode node) { + 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 = variantResolver.resolveContext(node).canonical(); - var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); - var canonicalFile = contentBase.resolve(canonical.path()); - var fileName = canonicalFile.getFileName().toString(); - var pageName = fileName.endsWith(".md") - ? fileName.substring(0, fileName.length() - 3) - : fileName; - return canonicalFile.getParent() - .resolve(".variants") - .resolve(pageName) - .resolve(CONFIG_FILE_NAME); + 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 056a36b05..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,28 +23,32 @@ 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.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, + ContentRepository contentRepository) { + this.contentRenderer = contentRenderer; + this.contentRepository = contentRepository; + } public Optional getViewContent(final RequestContext context) { return getViewContent(context, true); @@ -52,37 +56,34 @@ 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 page = view.getNodes( - db, - contentFile, - context.get(ContentParserFeature.class).contentParser(), - context.get(RenderContext.class).markdownRenderer(), + contentRepository, + contentNode, 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..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,32 +20,22 @@ * 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.db.ContentNode; 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 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 ContentNodeMapper contentNodeMapper; + protected final ContentNode currentNode; + protected final ContentRepository contentRepository; protected final RequestContext context; protected boolean isPreview() { @@ -65,18 +55,44 @@ 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); + protected String repositoryPath(String basePath, String path) { + basePath = normalize(basePath); + var childPath = path.replace('\\', '/'); + while (childPath.startsWith("./")) { + childPath = childPath.substring(2); + } + while (childPath.startsWith("/")) { + childPath = childPath.substring(1); + } + if (".".equals(childPath)) { + childPath = ""; + } + if (childPath.isEmpty()) { + return basePath; + } + return basePath.isEmpty() ? childPath : basePath + "/" + childPath; + } - return Optional.of(md); - } catch (IOException ex) { - log.error(null, ex); + 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 Optional.empty(); + 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 9b9cfc25c..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,14 +23,11 @@ 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.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 +55,17 @@ class NodeListFunction extends AbstractCurrentNodeFunction { return true; }; - public NodeListFunction(DB db, ReadOnlyFile currentNode, RequestContext context) { + public NodeListFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { super( - db, currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), + contentRepository, context); } - public NodeListFunction(DB db, ReadOnlyFile currentNode, RequestContext context, boolean excludeIndexMd) { - this(db, 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); } @@ -116,13 +112,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.path()); allContentNodes.addAll(children); }); @@ -141,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); @@ -152,23 +148,24 @@ 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); 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) -> { - 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); @@ -177,15 +174,14 @@ 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<>(); - 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; @@ -194,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 0e4ee5f7d..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,14 +22,11 @@ */ 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.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 +69,15 @@ public class NodeListFunctionBuilder extends AbstractCurrentNodeFunction { return filename1.compareTo(filename2); }; - public NodeListFunctionBuilder(DB db, ReadOnlyFile currentNode, RequestContext context) { + public NodeListFunctionBuilder(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { super( - db, currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), + contentRepository, context); - this.nodeListFunction = new NodeListFunction(db, currentNode, context); - this.nodeListFunctionNoIndex = new NodeListFunction(db, currentNode, context, true); + this.nodeListFunction = new NodeListFunction(contentRepository, currentNode, context); + this.nodeListFunctionNoIndex = new NodeListFunction( + 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..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 @@ -22,18 +22,14 @@ */ 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.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; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import java.util.ArrayList; @@ -50,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; @@ -57,13 +55,11 @@ public class NavigationFunction extends AbstractCurrentNodeFunction { private final HookSystem hookSystem; - public NavigationFunction(DB db, ReadOnlyFile currentNode, RequestContext context) { + public NavigationFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { super( - db, currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), + contentRepository, context); hookSystem = context.get(HookSystemFeature.class).hookSystem(); } @@ -80,26 +76,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 = db.getContent().byPath(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(); @@ -137,19 +122,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) { @@ -158,19 +135,19 @@ 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(); } try { final List navNodes = new ArrayList( - db.getContent().listContent(base, start) + contentRepository.children(repositoryPath(base, start)) .stream() .filter(NodeUtil::getMenuVisibility) .filter(NodeUtil.contentTypeFiler(contentType)) @@ -194,16 +171,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); }); @@ -214,13 +189,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(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 node.equals(currentNode) || currentNode.equals(nodeIndex); + var parentDirectory = directory; + return contentRepository.get(parentDirectory + SLASH_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 b1dceab5f..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,14 +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.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.filesystem.metadata.query.ExtendableQuery; +import com.condation.cms.api.repository.ContentRepository; import com.condation.cms.content.template.functions.AbstractCurrentNodeFunction; import com.google.common.base.Strings; import java.util.HashMap; @@ -50,20 +46,20 @@ public class QueryFunction extends AbstractCurrentNodeFunction { @Setter private String contentType; - public QueryFunction(DB db, ReadOnlyFile currentNode, RequestContext context) { + public QueryFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context) { this( - db, + contentRepository, currentNode, context, Map.of()); } - public QueryFunction(DB db, ReadOnlyFile currentNode, RequestContext context, final Map> queryOperations) { + public QueryFunction(ContentRepository contentRepository, + ContentNode currentNode, RequestContext context, + final Map> queryOperations) { super( - db, - currentNode, - context.get(ContentParserFeature.class).contentParser(), - context.get(MarkdownRendererFeature.class).markdownRenderer(), - context.get(ContentNodeMapperFeature.class).contentNodeMapper(), + currentNode, + contentRepository, context); this.extendedQueryOperations = queryOperations; } @@ -80,8 +76,8 @@ private BiFunction nodeMapper() { public ContentQuery create() { - var query = db.getContent().query(nodeMapper()); - ((ExtendableQuery)query).addAllCustomOperators(extendedQueryOperations); + var query = contentRepository.query(nodeMapper()); + query.customOperators(extendedQueryOperations); if (!Strings.isNullOrEmpty(contentType)) { query.contentType(contentType); @@ -90,9 +86,10 @@ public ContentQuery create() { } public ContentQuery create(final String startUri) { - var query = db.getContent().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 d4177e23a..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 @@ -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.ContentNode; 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; @@ -38,7 +37,6 @@ import lombok.RequiredArgsConstructor; import org.graalvm.polyglot.Context; import org.yaml.snakeyaml.Yaml; -import com.condation.cms.api.db.cms.ReadOnlyFile; /** * @@ -46,16 +44,14 @@ */ @RequiredArgsConstructor public class NodeResolver { - final DB db; - final ReadOnlyFile currentNode; - final ContentParser contentParser; - final MarkdownRenderer markdownRenderer; + final ContentRepository contentRepository; + 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 +71,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( + 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..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,19 +21,16 @@ * #L% */ -import com.condation.cms.api.content.ContentParser; -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.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; import org.graalvm.polyglot.Context; -import com.condation.cms.api.db.cms.ReadOnlyFile; /** * @@ -47,14 +44,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 ContentRepository contentRepository, + final ContentNode 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(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(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..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,10 +34,7 @@ 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.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 +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.core.serivce.ServiceRegistry; -import com.condation.cms.core.serivce.impl.SiteDBService; +import com.condation.cms.api.repository.CollectionRepository; import java.util.List; import java.util.Map; import java.util.Optional; @@ -61,13 +57,8 @@ 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 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; @@ -82,30 +73,19 @@ 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(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}")); 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 +102,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"), @@ -140,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/")); @@ -157,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(); @@ -183,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(); @@ -191,7 +170,7 @@ void resolvesAComplexRouteAgainstIndexedMetadata() { } @Test - void readsTheItemFileFromTheConfiguredSourceSite() throws Exception { + void rendersAnItemFromTheConfiguredSourceSiteWithoutExposingItsFile() throws Exception { define( new CollectionDefinition( "blog", @@ -200,31 +179,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) + var response = new CollectionResolver(renderer, collections, 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/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..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,40 +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 java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; +import com.condation.cms.api.repository.ContentRepository; +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 resolver = mock(VariantResolver.class); - when(db.getFileSystem()).thenReturn(fileSystem); - when(fileSystem.resolve(Constants.Folders.CONTENT)).thenReturn(contentBase); - when(resolver.resolveContext(canonical)).thenReturn( - new VariantResolver.VariantContext(canonical, Optional.empty(), List.of()) - ); - repository = new VariantSelectorConfigurationRepository(db, resolver); + contentRepository = mock(ContentRepository.class); + mutableContentRepository = mock(MutableContentRepository.class); + repository = new VariantSelectorConfigurationRepository( + contentRepository, mutableContentRepository); } @Test @@ -69,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 14b74144f..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 @@ -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 @@ -106,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())); @@ -121,8 +128,9 @@ public static void shutdown() throws Exception { @Test void test_root() { ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext).run(() -> { - var currentNode = db.getFileSystem().contentBase(); - NavigationFunction fn = new NavigationFunction(db, currentNode, requestContext); + var currentNode = contentRepository.get("index.md").orElseThrow(); + NavigationFunction fn = new NavigationFunction( + 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..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 @@ -24,16 +24,20 @@ 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; +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; @@ -41,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; @@ -51,6 +56,8 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.Mockito.mock; + /** * * @author t.marx @@ -73,6 +80,8 @@ public class NavigationFunctionTest { MarkdownRenderer markdownRenderer; @Mock ContentNodeMapper contentNodeMapper; + @Mock + ContentRepository contentRepository; NavigationFunction sut; @@ -95,10 +104,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 = mock(SiteProperties.class); + Mockito.lenient().when(siteProperties.contextPath()).thenReturn("/"); + requestContext.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); - sut = new NavigationFunction(db, - 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 @@ -118,7 +130,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/SiteCollectionRepositoryService.java b/cms-core/src/main/java/com/condation/cms/core/serivce/impl/SiteCollectionRepositoryService.java new file mode 100644 index 000000000..1717c95f5 --- /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 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 new file mode 100644 index 000000000..0d743c49a --- /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 exclusively 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/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/FileCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java similarity index 69% rename from cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java rename to cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java index 7b7405f50..2f3fd5930 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemCollectionRepository.java @@ -26,16 +26,19 @@ 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.Collection; 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; @@ -44,7 +47,6 @@ 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; @@ -53,11 +55,10 @@ import java.util.regex.Pattern; import lombok.extern.slf4j.Slf4j; -/** - * Site-scoped, file-backed collections implementation. - */ +/** Site-scoped collection repository backed by Markdown files and a persistent index. */ @Slf4j -public class FileCollections implements Collections, CollectionCursorSupport, AutoCloseable { +public final class FileSystemCollectionRepository + implements MutableCollectionRepository, 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); @@ -72,7 +73,7 @@ public class FileCollections implements Collections, CollectionCursorSupport, Au private MultiRootRecursiveWatcher watcher; private ContentChangeCoordinator changeCoordinator; - public FileCollections( + public FileSystemCollectionRepository( String siteId, Path hostBase, Function> metaParser) { @@ -86,39 +87,38 @@ public void init() throws IOException { Files.createDirectories(collectionsBase); metaData = new CollectionMetaData(hostBase); metaData.open(); - changeCoordinator = new ContentChangeCoordinator( - CHANGE_QUIET_PERIOD, - this::processChanges); + changeCoordinator = new ContentChangeCoordinator(CHANGE_QUIET_PERIOD, this::processChanges); rebuild(false); watcher = new MultiRootRecursiveWatcher(siteId, List.of(collectionsBase)); - var publisher = Objects.requireNonNull( + 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) { - if (item.type() == FileEvent.Type.OVERFLOW) { - changeCoordinator.requestFullResync(); - } else { - changeCoordinator.submit(item.file().toPath()); - } - this.subscription.request(1); - } - }); + publisher.subscribe(new MultiRootRecursiveWatcher.AbstractFileEventSubscriber() { + @Override + public void onNext(FileEvent item) { + handleEvent(item); + this.subscription.request(1); + } + }); watcher.start(); } @Override - public com.condation.cms.api.db.collection.Collection collection(String name) { - validateCollectionName(name); - return new FileCollection(name); + public Set names() { + return Set.copyOf(collectionNames); } @Override - public Set names() { - return Set.copyOf(collectionNames); + public CollectionAccess access(String collection) { + validateExistingCollection(collection); + return CollectionAccess.READ_WRITE; + } + + @Override + public Collection collection(String name) { + validateCollectionName(name); + return new FileCollection(name); } @Override @@ -128,16 +128,65 @@ public CursorPage metadataCursorPage( long size, Consumer> queryConfigurer) { validateCollectionName(collection); - return metaData.cursorPage( - collection, - this::mapMetadata, - cursor, - size, - queryConfigurer); + return metaData.cursorPage(collection, this::mapMetadata, 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, itemPath(collection, id), 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 refresh(String collection, String id) { + 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); + 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); + refresh(collection, id); + } + + private Path writableItem(String collection, String id) { + validateExistingCollection(collection); + CollectionItemId.requireValid(id); + 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"); + } + return target; + } + + void refresh(String collection, String id) { validateCollectionName(collection); CollectionItemId.requireValid(id); var file = collectionsBase.resolve(collection).resolve(id + ".md"); @@ -145,7 +194,7 @@ public void refresh(String collection, String id) { if (Files.isRegularFile(file)) { index(file); } else { - metaData.removeFile(collection + "/" + id + ".md"); + metaData.removeFile(itemPath(collection, id)); } } catch (IOException ex) { throw new IllegalStateException("could not refresh collection item", ex); @@ -198,10 +247,7 @@ private void processPath(Path path) throws IOException { } return; } - if (parts.length != 2 || !isValidItemFile(path)) { - return; - } - if (!isValidCollectionName(parts[0])) { + if (parts.length != 2 || !isValidItemFile(path) || !isValidCollectionName(parts[0])) { return; } if (Files.isRegularFile(path)) { @@ -242,7 +288,7 @@ private void scanCollection(Path collection) throws IOException { var stalePaths = metaData.paths(name); try (var files = Files.list(collection)) { for (var iterator = files.filter(Files::isRegularFile) - .filter(FileCollections::isValidItemFile).iterator(); iterator.hasNext();) { + .filter(FileSystemCollectionRepository::isValidItemFile).iterator(); iterator.hasNext();) { var file = iterator.next(); var path = PathUtil.toRelativeEntry(file, collectionsBase); stalePaths.remove(path); @@ -265,8 +311,7 @@ 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()); + attributes.lastModifiedTime().toInstant(), ZoneId.systemDefault()); metaData.addFile(path, metaParser.apply(file), modified, stamp); } @@ -282,11 +327,7 @@ private CollectionItem map(ContentNode node, int ignoredExcerptLength) { 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()); + id, collection, path, readMarkdownBody(collectionsBase.resolve(path)), node.data()); } private CollectionItemMetadata mapMetadata(ContentNode node, int ignoredExcerptLength) { @@ -306,16 +347,14 @@ private static String readMarkdownBody(Path file) { } } - 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)); + 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) { @@ -328,6 +367,13 @@ private static void validateCollectionName(String 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) { @@ -341,7 +387,7 @@ public void close() throws IOException { } } - private class FileCollection implements com.condation.cms.api.db.collection.Collection { + private class FileCollection implements Collection { private final String name; @@ -357,19 +403,19 @@ public String name() { @Override public Optional item(String id) { CollectionItemId.requireValid(id); - return metaData.byPath(name + "/" + id + ".md") + return metaData.byPath(itemPath(name, id)) .filter(NodeVisibility::isVisible) - .map(node -> FileCollections.this.map(node, 0)); + .map(node -> FileSystemCollectionRepository.this.map(node, 0)); } @Override public ContentQuery query() { - return metaData.query(name, FileCollections.this::map); + return metaData.query(name, FileSystemCollectionRepository.this::map); } @Override public ContentQuery metadataQuery() { - return metaData.query(name, FileCollections.this::mapMetadata); + return metaData.query(name, FileSystemCollectionRepository.this::mapMetadata); } } } 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..5b59c20b8 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystemContentRepository.java @@ -0,0 +1,388 @@ +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.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; + +/** + * {@link ContentRepository} adapter backed by the existing filesystem index. + * + * @author thmar + */ +@Slf4j +public final class FileSystemContentRepository implements MutableContentRepository { + + 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 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 _) { + 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); + } + + @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)); + } + + @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())) + .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(Constants.PATH_SEPARATOR, java.util.Arrays.copyOf(pathParts, index)) + + Constants.PATH_SEPARATOR + 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/main/java/com/condation/cms/filesystem/ReferencedCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollectionRepository.java similarity index 52% 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 fc9aed81d..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,38 +25,41 @@ 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.SiteDBService; +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; } @Override public Collection collection(String name) { - return targetCollections(name).collection(name); + var sourceSite = sourceSite(name); + return sourceSite == null + ? localRepository.collection(name) + : sourceRepository(sourceSite, name).collection(name); } @Override @@ -65,16 +68,16 @@ public CursorPage metadataCursorPage( String cursor, long size, Consumer> queryConfigurer) { - var target = targetCollections(collection); - if (!(target instanceof CollectionCursorSupport cursorSupport)) { - throw new UnsupportedOperationException("collection storage does not support cursor paging"); - } - return cursorSupport.metadataCursorPage(collection, cursor, size, queryConfigurer); + var sourceSite = sourceSite(collection); + 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)) @@ -85,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 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 refresh(String collection, String id) { - if (!isLocal(collection)) { + 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) { @@ -105,16 +141,12 @@ 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/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/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 new file mode 100644 index 000000000..547d22585 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemCollectionRepositoryTest.java @@ -0,0 +1,93 @@ +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.repository.CollectionAccess; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +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; + +class FileSystemCollectionRepositoryTest { + + @TempDir + Path tempDirectory; + + private FileSystemCollectionRepository repository; + + @BeforeEach + 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 + 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"); + 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(); + Assertions.assertThat(repository.get("blog", "entry")).isEmpty(); + } + + @Test + void rejectsOverwritesTraversalAndUnknownCollections() 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("missing", "entry", Map.of(), "other")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not found"); + Assertions.assertThat(repository.access("blog")).isEqualTo(CollectionAccess.READ_WRITE); + } +} 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..e21959e95 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemContentRepositoryTest.java @@ -0,0 +1,227 @@ +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.never; +import static org.mockito.Mockito.times; +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.nio.file.Files; +import java.nio.file.Path; +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; +import org.junit.jupiter.api.io.TempDir; + +class FileSystemContentRepositoryTest { + + @TempDir + Path contentBase; + + 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); + when(fileSystem.resolve(com.condation.cms.api.Constants.Folders.CONTENT)) + .thenReturn(contentBase); + 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, 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.isVariant()).isTrue(); + 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()); + } + + @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, 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()); + } + + 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..b4961eedc --- /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; + +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-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionRepositoryTest.java similarity index 69% 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 c317023db..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 @@ -27,18 +27,19 @@ 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.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() { @@ -47,43 +48,42 @@ 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(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( + 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); @@ -91,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/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 f585515c6..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 @@ -52,6 +52,11 @@ 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.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; import com.condation.cms.api.template.TemplateEngine; import com.condation.cms.api.theme.Theme; @@ -67,7 +72,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 +91,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; @@ -160,8 +166,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 @@ -304,7 +310,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 +324,43 @@ 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 MutableContentRepository mutableContentRepository( + DB db, + ContentStore contentStore, + ContentParser contentParser) { + return new FileSystemContentRepository( + db.getContent(), + db.getFileSystem(), + contentStore, + contentParser); + } + + @Provides + @Singleton + public ContentRepository contentRepository(MutableContentRepository repository) { + return repository; + } + + @Provides + @Singleton + public MutableCollectionRepository mutableCollectionRepository(DB db) { + return db.getCollectionRepository(); + } + + @Provides + @Singleton + public CollectionRepository collectionRepository(MutableCollectionRepository repository) { + return repository; + } + @Provides @Singleton public ExtensionManager extensionManager(DB db, Configuration configuration, Engine engine) throws IOException { @@ -360,45 +403,42 @@ 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, + CollectionRepository collectionRepository) { return new DefaultContentRenderer( - contentParser, () -> injector.getInstance(TemplateEngine.class), db, siteProperties, - moduleManager); + moduleManager, + contentRepository, + collectionRepository); } @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 @Singleton public CollectionResolver collectionResolver( ContentRenderer contentRenderer, - FileDB db, + CollectionRepository collectionRepository, Configuration configuration) { - return new CollectionResolver(contentRenderer, db, configuration); - } - - @Provides - @Singleton - public VariantResolver variantResolver(FileDB db) { - return new VariantResolver(db); + return new CollectionResolver(contentRenderer, collectionRepository, configuration); } @Provides @Singleton public VariantSelectorConfigurationRepository variantSelectorConfigurationRepository( - FileDB db, - VariantResolver variantResolver + ContentRepository contentRepository, + MutableContentRepository mutableContentRepository ) { - return new VariantSelectorConfigurationRepository(db, variantResolver); + return new VariantSelectorConfigurationRepository(contentRepository, mutableContentRepository); } @Provides @@ -423,8 +463,8 @@ public VariantSelector variantSelector(ConfigurableVariantSelector selector) { @Provides @Singleton public ViewResolver viewResolver(ContentRenderer contentRenderer, - FileDB db) { - return new ViewResolver(contentRenderer, db); + 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..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,10 +23,15 @@ 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; +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; import lombok.RequiredArgsConstructor; @@ -45,12 +50,21 @@ 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)); 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..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,16 +23,11 @@ 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.InjectorFeature; +import com.condation.cms.api.feature.features.RepositoryFeature; 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.templates.functions.TemplateFunction; import java.util.HashMap; import java.util.Map; -import java.util.Optional; import lombok.RequiredArgsConstructor; /** @@ -43,7 +38,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 +51,25 @@ 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(RepositoryFeature.class).contentRepository(); + 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 8a0e779bd..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 @@ -22,14 +22,11 @@ */ 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.feature.features.RepositoryFeature; import com.condation.cms.api.request.RequestContext; 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; @@ -47,13 +44,12 @@ 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(RepositoryFeature.class).contentRepository(); var contentRenderer = requestContext.get(InjectorFeature.class).injector().getInstance(ContentRenderer.class); - List sectionEntryList = db.getContent().listSectionEntries(contentFile); - - Map> sectionEntries = contentRenderer.renderSectionEntries(sectionEntryList, requestContext); + 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..c8bac92c5 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,26 @@ 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, + db.getCollectionRepository() ); } @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/collections/CollectionFunctionsIntegrationTest.java b/integration-tests/src/test/java/com/condation/cms/collections/CollectionFunctionsIntegrationTest.java index 818269ee8..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 @@ -38,6 +38,8 @@ 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; @@ -84,10 +86,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, 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 5771804c9..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 @@ -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,16 @@ 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, + db.getCollectionRepository()); } @AfterAll public static void shutdown () throws Exception { @@ -113,8 +121,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 +140,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..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 @@ -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,18 @@ 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, + db.getCollectionRepository()); + 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..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,13 +30,17 @@ 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; 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,13 +65,14 @@ public class ViewParserTest { static FileDB db; + static ContentRepository contentRepository; static ContentParser parser = new DefaultContentParser(); static MarkdownRenderer markdownRenderer = TestHelper.getRenderer(); static RequestContext requestContext; @BeforeEach void setup_test () throws IOException { - requestContext = TestHelper.requestContext("", parser, markdownRenderer, new ContentNodeMapper(db, parser)); + requestContext = TestHelper.requestContext("", parser, markdownRenderer, new ContentNodeMapper(contentRepository)); } @BeforeAll @@ -92,6 +97,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 +109,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 +125,10 @@ 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( + 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))); @@ -133,7 +144,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 +157,10 @@ 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( + contentRepository, + new ContentNode("view/view.yaml", "/view/view", "view.yaml", Map.of()), + context, queryParams, requestContext); Assertions.assertThat(page) .isNotNull() @@ -161,7 +175,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..1b3b9d745 --- /dev/null +++ b/integration-tests/src/test/java/com/condation/cms/repository/FileSystemContentRepositoryIntegrationTest.java @@ -0,0 +1,146 @@ +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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +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 = 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 -> { + 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..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 @@ -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,10 +87,13 @@ static void setup () throws IOException { } }, config); db.init(); + contentRepository = new FileSystemContentRepository( + db.getContent(), db.getFileSystem(), + new FileSystemContentStore(db.getFileSystem()), parser); - nodeList = new NodeListFunctionBuilder(db, - 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 { @@ -173,10 +180,10 @@ void list_asterix () { @Test void test_from_subfolder () throws IOException { - var nodeList = new NodeListFunctionBuilder(db, - 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(); + var builder = new NodeListFunctionBuilder(contentRepository, + contentRepository.get("nodelist2/index.md").orElseThrow(), + TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); + 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( @@ -189,14 +196,14 @@ void test_from_subfolder () throws IOException { @Test void test_json () throws IOException { - var nodeList = new NodeListFunctionBuilder(db, - 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(); + var builder = new NodeListFunctionBuilder(contentRepository, + contentRepository.get("index.md").orElseThrow(), + TestHelper.requestContext("/", parser, markdownRenderer, new ContentNodeMapper(contentRepository))); + 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 0024b5efe..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 @@ -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,10 +84,13 @@ 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, - 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; @@ -128,10 +135,9 @@ public void test_visibility() { @Test public void test_path() throws Exception { - var sut = new NavigationFunction(db, - 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(); @@ -143,15 +149,15 @@ public void test_path() throws Exception { @Test public void test_json () throws IOException { - var navigationFunction = new NavigationFunction(db, - db.getReadOnlyFileSystem().contentBase().resolve("nav/index.md"), - TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(db, defaultContentParser))); - - List list = navigationFunction.json().list("/json"); + var navFunction = new NavigationFunction(contentRepository, + contentRepository.get("nav/index.md").orElseThrow(), + TestHelper.requestContext("/", defaultContentParser, markdownRenderer, new ContentNodeMapper(contentRepository))); + + 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/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..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 @@ -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,10 +83,13 @@ 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, - 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..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,7 +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.DBFeature; +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; @@ -50,23 +50,24 @@ 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(RepositoryFeature.class).contentRepository(); 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 6a081f994..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,10 @@ */ 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.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; @@ -46,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 @@ -67,15 +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; } - NavigationFunction navFN = new NavigationFunction(db, startNode, requestContext); + NavigationFunction navFN = new NavigationFunction( + 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..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 @@ -21,15 +21,13 @@ * #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; 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.*; @@ -38,10 +36,7 @@ public class QueryParserTest { @Mock - private DB db; - - @Mock - private Content content; + private ContentRepository contentRepository; @Mock private ContentQuery contentQuery; @@ -54,14 +49,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 +63,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 +72,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 +80,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 +88,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/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 ffa68194b..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 @@ -29,8 +29,16 @@ 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.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.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; @@ -44,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)) { @@ -65,6 +73,48 @@ protected DB getDB (Map parameters) { return getContext().get(DBFeature.class).db(); } } + + protected ContentRepository getContentRepository(Map parameters) { + if (parameters.containsKey(SITE_ID)) { + return siteContentRepositoryService(parameters).repository(); + } + return getContext().get(RepositoryFeature.class).contentRepository(); + } + + protected MutableContentRepository getMutableContentRepository(Map parameters) { + if (parameters.containsKey(SITE_ID)) { + 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(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))); + } public static ReadOnlyFile getBase(DBFileSystem fileSystem, String type) { 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 aab7eac39..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 @@ -22,22 +22,22 @@ */ 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.cms.ReadOnlyFile; +import com.condation.cms.api.db.ContentNode; 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; +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.core.content.io.ContentFileParser; +import com.condation.cms.api.utils.MapUtil; +import com.condation.cms.api.repository.ContentRepository; +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; @@ -52,7 +52,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; @@ -61,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; /** * @@ -69,51 +67,42 @@ */ @Slf4j @Extension(UIRemoteMethodExtensionPoint.class) -public class RemoteContentEndpointsExtension extends AbstractExtensionPoint implements UIRemoteMethodExtensionPoint { +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 { - 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()); - 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; } @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()); - 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, document.metadata(), updatedContent); + log.debug(LOG_PATTERN, target.uri()); + } catch (IOException ex) { + log.error("", ex); + throw new RPCException(0, ex.getMessage()); } return result; @@ -122,8 +111,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)); @@ -137,21 +125,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); @@ -164,27 +148,21 @@ 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()); - 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, 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; @@ -194,8 +172,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"); @@ -210,19 +187,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)); @@ -241,21 +214,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); @@ -268,8 +234,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"); @@ -282,12 +247,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, @@ -295,18 +257,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; @@ -314,8 +271,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"); @@ -331,38 +287,16 @@ 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); + var collectionRoute = new CollectionRouteResolver( + getCollectionRepository(parameters), collectionConfiguration).resolve(path); if (collectionRoute.isEmpty()) { return result; } @@ -389,14 +323,13 @@ 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 = 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); @@ -404,15 +337,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); @@ -434,49 +369,75 @@ 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()); } var uri = contentUri(parameters); return new EditableTarget( uri, - db.getFileSystem().contentBase().resolve(uri), null, null); } - private void refresh(EditableTarget target, DB db) { + private EditableDocument loadTarget(EditableTarget target, Map parameters) + throws IOException, RPCException { if (target.collectionName() != null) { - db.getCollections().refresh(target.collectionName(), target.itemId()); + 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()) + .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, + Map metadata, String content) throws IOException { + if (target.collectionName() != null) { + getMutableCollectionRepository(parameters).save( + target.collectionName(), target.itemId(), metadata, content); + getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); } else { - getContext().get(EventBusFeature.class).eventBus() - .publish(new ReIndexContentMetaDataEvent(target.uri())); - db.getFileSystem().flushContentChanges(); + 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(); } - getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); + } + + private static String fileName(String path) { + var normalized = path.replace('\\', '/'); + var separator = normalized.lastIndexOf('/'); + return separator < 0 ? normalized : normalized.substring(separator + 1); } 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/RemoteFileEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpoints.java index 6da94a311..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,10 +53,10 @@ @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 { - final DB db = getDB(parameters); - var uri = (String) parameters.getOrDefault("uri", ""); if (uri == null) { uri = ""; @@ -65,6 +65,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 +86,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 +110,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 +139,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 +150,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 +161,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 @@ -189,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; @@ -202,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) { @@ -215,14 +238,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 +272,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 +316,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 +361,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..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 @@ -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; @@ -63,15 +61,15 @@ 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) { 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 +92,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 +138,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 +149,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 + 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(); + 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 +184,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 +196,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 +213,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..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; @@ -59,15 +57,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 +92,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 +142,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 b05d95bae..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,17 +24,16 @@ import com.condation.cms.api.auth.Permissions; import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.DB; import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.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; @@ -69,8 +68,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 +96,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 +118,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 = getVariantResolver(db).resolveContext(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 +131,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 +175,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 +211,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 = getVariantResolver(db).resolveContext(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 +245,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 = getVariantResolver(db).resolveContext(contentNode); + var contentNode = findContentNode(parameters, uri); + var variantContext = getContentRepository(parameters).variantContext(contentNode); var variants = variantContext.variants() .stream() .sorted(Comparator.comparing(Variant::id)) @@ -306,20 +273,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 VariantResolver getVariantResolver(DB db) { - return getContext().get(InjectorFeature.class).injector().getInstance(VariantResolver.class); - } - protected ConfigurableVariantSelector getConfigurableVariantSelector() { return getContext().get(InjectorFeature.class) .injector().getInstance(ConfigurableVariantSelector.class); @@ -347,6 +310,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..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 @@ -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; /** * @@ -74,44 +67,24 @@ 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(), 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( @@ -121,19 +94,15 @@ && 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())); } - return getContentTarget(contentUri(parameters)); + return getContentTarget(parameters, contentUri(parameters)); } private record WorkflowTarget( ContentNode node, - ReadOnlyFile file, - Path writableFile, boolean collection, String collectionName, String itemId) { @@ -179,11 +148,11 @@ 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 repository = getContentRepository(parameters); + var query = repository.query() .variants(VariantSearchMode.ORIGINAL); Page page; @@ -193,13 +162,13 @@ 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); } - return mapPage(db, page); + return mapPage(page); } @RemoteMethod(name = "workflow.transit", permissions = {Permissions.WORKFLOW_EXECUTE}) @@ -208,8 +177,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"); @@ -225,15 +192,19 @@ 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()) { - 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 { - 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 +241,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..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,6 +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.RepositoryFeature; 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 +105,12 @@ private void setCurrentContentNode(Request request) { if (uri == null || uri.isBlank()) { return; } - if (!moduleContext.has(DBFeature.class)) { + if (!moduleContext.has(RepositoryFeature.class)) { return; } - var content = moduleContext.get(DBFeature.class).db().getContent(); - content.byUri(uri.trim()) - .or(() -> content.byPath(uri.trim())) + var content = moduleContext.get(RepositoryFeature.class).contentRepository(); + content.findByUrl(uri.trim()) + .or(() -> content.get(uri.trim())) .ifPresent(node -> requestContext.add( CurrentNodeFeature.class, new CurrentNodeFeature(node) @@ -125,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/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()); + } + } +} 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..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 @@ -27,18 +27,12 @@ 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; 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; @@ -48,28 +42,25 @@ 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; 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 { @@ -80,24 +71,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; @@ -108,28 +81,35 @@ class RemoteCollectionEndpointsTest { private WFStatusProvider statusProvider; 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")); - endpoints = new RemoteCollectionEndpoints(); + fileRepository = new FileSystemCollectionRepository( + "test-site", tempDirectory, RemoteCollectionEndpointsTest::parseMeta); + fileRepository.init(); + repository = fileRepository; + 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); - lenient().when(collection.query()).thenReturn(collectionQuery); - lenient().when(collection.metadataQuery()).thenReturn(metadataQuery); - lenient().when(collectionQuery.get()).thenReturn(List.of()); - 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); + @AfterEach + void tearDown() throws Exception { + fileRepository.close(); } @Test @@ -168,31 +148,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")); - 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))); + 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", @@ -205,7 +168,7 @@ void rejectsDuplicateSlugsAfterNormalizationWhenSaving() throws Exception { @Test void rejectsDuplicateAndInvalidCollectionItemIds() throws Exception { - Files.writeString(collectionsDirectory.resolve("blog/existing.md"), "existing"); + repository.create("blog", "existing", Map.of("status", "published"), "existing"); assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "existing"))) .isInstanceOfSatisfying( @@ -233,17 +196,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"); + 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( @@ -266,4 +231,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); + } + } } 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..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; @@ -45,6 +39,11 @@ 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.repository.MutableCollectionRepository; +import com.condation.cms.api.repository.MutableContentRepository; import java.io.IOException; import java.util.List; import java.util.Map; @@ -70,41 +69,45 @@ class RemoteContentEndpointsExtensionTest { private SiteModuleContext moduleContext; @Mock - private DB db; + private MutableContentRepository contentRepository; @Mock - private Content content; - - @Mock - private DBFileSystem fileSystem; - - @Mock - private ReadOnlyFile contentBase; - - @Mock - private ReadOnlyFile contentFile; - - @Mock - private Collections collections; + private MutableCollectionRepository collectionRepository; 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; + } + + @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 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 +118,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 +137,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, @@ -148,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, @@ -170,10 +173,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,12 +195,11 @@ 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 void getContentNodeKeepsPublicCollectionRouteAndDisablesVariants() throws Exception { - var nonExistingPath = mock(ReadOnlyFile.class); var authorCollection = mock(Collection.class); @SuppressWarnings("unchecked") var query = (ContentQuery) mock(ContentQuery.class); @@ -208,11 +208,8 @@ 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(collections.collection("authors")).thenReturn(authorCollection); + when(contentRepository.findByUrl("/people/jane-doe")).thenReturn(Optional.empty()); + 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/RemoteFileEnpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteFileEnpointsTest.java index fe26a6b70..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,18 +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.core.content.io.ContentFileParser; -import java.nio.file.Files; +import com.condation.cms.api.repository.ContentDocument; +import com.condation.cms.api.repository.MutableContentRepository; import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -41,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 @@ -65,9 +63,9 @@ public class RemoteFileEnpointsTest { @Mock private DBFileSystem dbFileSystem; - @TempDir - private Path tempDir; - + @Mock + private MutableContentRepository contentRepository; + public RemoteFileEnpointsTest() { } @@ -85,14 +83,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(); + 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 +99,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(); + when(contentRepository.children("")).thenReturn(List.of(node)); + + var endpoints = endpoints(); endpoints.setContext(moduleContext); @SuppressWarnings("unchecked") @@ -145,21 +127,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")); + 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); endpoints.renameFile(Map.of( "type", "content", @@ -168,14 +141,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(); + 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..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 @@ -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); @@ -165,10 +169,8 @@ public void testFilterPages_WithPagination() throws RPCException { } @Test - public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { + public void testSearchPages_returnsRewrittenUriAndTitle() { // 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 { + public void testSearchPages_missingTitle_fallsBackToEmptyString() { // 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 a7dd319dd..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,30 +21,24 @@ * #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; +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; import com.condation.cms.content.ConfigurableVariantSelector; -import com.condation.cms.content.VariantResolver; import com.condation.cms.content.VariantSelectorConfigurationRepository; import com.condation.cms.modules.ui.extensionpoints.remotemethods.dto.VariantDto; import java.util.List; import java.util.Map; import java.util.Optional; -import java.nio.file.Files; -import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -60,20 +54,11 @@ class RemoteVariantEndpointTest { @Mock private SiteModuleContext moduleContext; - @Mock - private DB db; - - @Mock - private Content content; - - @Mock - private DBFileSystem fileSystem; - @Mock private EventBus eventBus; @Mock - private VariantResolver variantResolver; + private MutableContentRepository contentRepository; @Mock private ConfigurableVariantSelector configurableVariantSelector; @@ -83,15 +68,17 @@ class RemoteVariantEndpointTest { private RemoteVariantEndpoint endpoint; - @TempDir - private Path tempDir; - @BeforeEach void setUp() { endpoint = new RemoteVariantEndpoint() { @Override - protected VariantResolver getVariantResolver(DB db) { - return variantResolver; + protected ContentRepository getContentRepository(Map parameters) { + return contentRepository; + } + + @Override + protected MutableContentRepository getMutableContentRepository(Map parameters) { + return contentRepository; } @Override @@ -105,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 @@ -128,13 +111,13 @@ 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) ); - 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") @@ -164,9 +147,9 @@ 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(variantResolver.resolveContext(summer)).thenReturn( - new VariantResolver.VariantContext(canonical, Optional.of("summer"), variants) + when(contentRepository.get(summer.path())).thenReturn(Optional.of(summer)); + when(contentRepository.variantContext(summer)).thenReturn( + new VariantContext(canonical, Optional.of("summer"), variants) ); @SuppressWarnings("unchecked") @@ -182,9 +165,9 @@ 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(variantResolver.resolveContext(node)).thenReturn( - new VariantResolver.VariantContext(node, Optional.empty(), List.of()) + when(contentRepository.get("about.md")).thenReturn(Optional.of(node)); + when(contentRepository.variantContext(node)).thenReturn( + new VariantContext(node, Optional.empty(), List.of()) ); @SuppressWarnings("unchecked") @@ -204,8 +187,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) @@ -217,7 +200,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", @@ -238,7 +221,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")); @@ -255,20 +238,15 @@ 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(variantResolver.resolveContext(canonical)).thenReturn( - new VariantResolver.VariantContext( + when(contentRepository.get(canonical.path())).thenReturn(Optional.of(canonical)); + when(contentRepository.variantContext(canonical)).thenReturn( + new VariantContext( canonical, Optional.empty(), List.of(new Variant("summer", summer)) ) ); + when(contentRepository.resourceExists(".variants/about/summer")).thenReturn(true); @SuppressWarnings("unchecked") var result = (Map) endpoint.delete(Map.of( @@ -279,17 +257,15 @@ 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(variantResolver.resolveContext(canonical)).thenReturn( - new VariantResolver.VariantContext(canonical, Optional.empty(), List.of()) + when(contentRepository.get(canonical.path())).thenReturn(Optional.of(canonical)); + when(contentRepository.variantContext(canonical)).thenReturn( + new VariantContext(canonical, Optional.empty(), List.of()) ); assertThatThrownBy(() -> endpoint.delete(Map.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..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,30 +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; @@ -67,49 +62,28 @@ class RemoteWorkflowEndpointsExtensionTest { private SiteModuleContext moduleContext; @Mock - private DB db; + private ContentRepository contentRepository; @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 CollectionRepository collectionRepository; private RemoteWorkflowEndpointsExtension endpoints; @BeforeEach void setUp() { - endpoints = new RemoteWorkflowEndpointsExtension(); + endpoints = new RemoteWorkflowEndpointsExtension() { + @Override + protected ContentRepository getContentRepository(Map parameters) { + return contentRepository; + } + + @Override + protected CollectionRepository getCollectionRepository(Map parameters) { + return collectionRepository; + } + }; 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.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 @@ -173,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, @@ -190,15 +164,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..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,11 +27,13 @@ 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.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; @@ -94,8 +96,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 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( @@ -104,13 +106,13 @@ 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(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()); - 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 +125,17 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { @Test void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { var context = new RequestContext(); - var db = mock(DB.class); - var content = mock(Content.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(DBFeature.class)).thenReturn(true); - when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getContent()).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"); - 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); @@ -147,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( @@ -158,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!