diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 5b4fc926d..c7c120314 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -16,7 +16,7 @@ jobs: - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '25-ea' + java-version: '25' distribution: 'temurin' cache: maven 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 f790d1878..2dbad6191 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 @@ -70,6 +70,7 @@ public static class MetaFields { public static final String ALIASES_REDIRECT = "aliases_redirect"; public static final String TRANSLATIONS = "translations"; + public static final String URL = "url"; } public static class Folders { diff --git a/cms-api/src/main/java/com/condation/cms/api/SiteProperties.java b/cms-api/src/main/java/com/condation/cms/api/SiteProperties.java index 8f23e44a0..72a45e7b0 100644 --- a/cms-api/src/main/java/com/condation/cms/api/SiteProperties.java +++ b/cms-api/src/main/java/com/condation/cms/api/SiteProperties.java @@ -47,6 +47,7 @@ public interface SiteProperties { public String theme (); + @Deprecated(since = "8.3.0") public String queryIndexMode (); public Locale locale (); 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 7a452c3e1..168038765 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 @@ -43,9 +43,14 @@ public interface Content { List listDirectories(final ReadOnlyFile base, final String start); + @Deprecated(since = "8.3.0") Optional byUri (final String uri); - - Optional> getMeta(final String uri); + + Optional byPath (final String path); + + Optional byUrl (final String url); + + Optional> getMeta(final String path); public ContentQuery query(final BiFunction nodeMapper); 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 f89d827fc..a1209d64e 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/ContentNode.java @@ -26,6 +26,7 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.utils.MapUtil; +import com.condation.cms.api.utils.PathUtil; import com.condation.cms.api.utils.SectionUtil; import com.google.common.math.DoubleMath; import java.io.Serializable; @@ -38,23 +39,27 @@ * * @author t.marx */ -public record ContentNode(String uri, String name, Map data, +public record ContentNode(String uri, String url, String name, Map data, boolean directory, Map children, LocalDate lastmodified) implements Serializable { - public ContentNode(String uri, String name, Map data, boolean directory, Map children) { - this(uri, name, data, directory, children, LocalDate.now()); + public ContentNode(String uri, String url, String name, Map data, boolean directory, Map children) { + this(uri, url, name, data, directory, children, LocalDate.now()); } - public ContentNode(String uri, String name, Map data, boolean directory) { - this(uri, name, data, directory, new HashMap<>(), LocalDate.now()); + public ContentNode(String uri, String url, String name, Map data, boolean directory) { + this(uri, url, name, data, directory, new HashMap<>(), LocalDate.now()); } - public ContentNode(String uri, String name, Map data) { - this(uri, name, data, false, new HashMap<>(), LocalDate.now()); + public ContentNode(String uri, String url, String name, Map data) { + this(uri, url, name, data, false, new HashMap<>(), LocalDate.now()); } - public ContentNode(String uri, String name, Map data, LocalDate lastmodified) { - this(uri, name, data, false, new HashMap<>(), lastmodified); + public ContentNode(String uri, String url, String name, Map data, LocalDate lastmodified) { + this(uri, url, name, data, false, new HashMap<>(), lastmodified); + } + + public String path () { + return uri; } public String nodeType() { 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 f93daf227..8c48fcfc5 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 @@ -31,7 +31,6 @@ import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.api.utils.NodeUtil; -import com.condation.cms.api.utils.PathUtil; import java.io.IOException; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -67,10 +66,9 @@ public ListNode toListNode(final ContentNode node, final RequestContext context, var name = NodeUtil.getName(node); final ReadOnlyFile contentBase = db.getFileSystem().contentBase(); - var temp_path = contentBase.resolve(node.uri()); - var url = PathUtil.toURL(temp_path, contentBase); + var temp_path = contentBase.resolve(node.path()); - url = HTTPUtil.modifyUrl(url, context); + 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()); diff --git a/cms-api/src/main/java/com/condation/cms/api/utils/PathUtil.java b/cms-api/src/main/java/com/condation/cms/api/utils/PathUtil.java index 2836fe622..75f4858fc 100644 --- a/cms-api/src/main/java/com/condation/cms/api/utils/PathUtil.java +++ b/cms-api/src/main/java/com/condation/cms/api/utils/PathUtil.java @@ -52,7 +52,7 @@ public static String toRelativePath(final Path contentPath, final Path contentBa if (!Files.isDirectory(contentPath)) { tempPath = contentPath.getParent(); } - Path relativize = contentBase.relativize(tempPath); + Path relativize = normalizedAbsolute(contentBase).relativize(normalizedAbsolute(tempPath)); var uri = relativize.toString(); uri = uri.replaceAll("\\\\", "/"); return uri; @@ -70,7 +70,7 @@ public static String toRelativePath(final ReadOnlyFile contentPath, final ReadOn } public static String toRelativeFile(final Path contentFile, final Path contentBase) { - Path relativize = contentBase.relativize(contentFile); + Path relativize = normalizedAbsolute(contentBase).relativize(normalizedAbsolute(contentFile)); if (Files.isDirectory(contentFile)) { relativize = relativize.resolve("index.md"); } @@ -79,6 +79,21 @@ public static String toRelativeFile(final Path contentFile, final Path contentBa return uri; } + /** + * Relativizes a file system entry without accessing the entry itself. This + * is required for delete events, where the path no longer exists. + */ + public static String toRelativeEntry(final Path entry, final Path contentBase) { + return normalizedAbsolute(contentBase) + .relativize(normalizedAbsolute(entry)) + .toString() + .replace('\\', '/'); + } + + private static Path normalizedAbsolute(Path path) { + return path.toAbsolutePath().normalize(); + } + public static String toRelativeFile(ReadOnlyFile contentFile, final ReadOnlyFile contentBase) { if (contentFile.isDirectory()) { contentFile = contentFile.resolve("index.md"); @@ -119,4 +134,22 @@ public static String toURL (String relFile) { return relFile; } + + public static String normalizeURL(final String url) { + if (url == null || url.isBlank()) { + return "/"; + } + + var normalized = url.trim().replace('\\', '/'); + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.contains("//")) { + normalized = normalized.replace("//", "/"); + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } } diff --git a/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java b/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java index 497da397a..36da7211e 100644 --- a/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/db/ContentNodeTest.java @@ -35,7 +35,7 @@ public class ContentNodeTest { @Test public void test_publish() { - var contentNode = new ContentNode("", "", Map.of()); + var contentNode = new ContentNode("", "", "", Map.of()); Assertions.assertThat(NodeVisibility.isVisible(contentNode)).isFalse(); Assertions.assertThat(contentNode.isVisible()).isFalse(); } diff --git a/cms-api/src/test/java/com/condation/cms/api/utils/PathUtilTest.java b/cms-api/src/test/java/com/condation/cms/api/utils/PathUtilTest.java index d73c25db5..1e84df2d2 100644 --- a/cms-api/src/test/java/com/condation/cms/api/utils/PathUtilTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/utils/PathUtilTest.java @@ -26,6 +26,7 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** * @@ -61,5 +62,31 @@ public void test_to_url() { toURI = PathUtil.toURL(contentBase.resolve(""), contentBase); assertThat(toURI).isEqualTo("/"); } + + @Test + public void test_normalize_url() { + assertThat(PathUtil.normalizeURL(null)).isEqualTo("/"); + assertThat(PathUtil.normalizeURL("shop//item/")).isEqualTo("/shop/item"); + assertThat(PathUtil.normalizeURL("/shop/item")).isEqualTo("/shop/item"); + } + + @Test + public void relativeEntryDoesNotNeedToExist(@TempDir Path tempDirectory) { + var deletedEntry = tempDirectory.resolve("old/sub/page.md"); + + assertThat(PathUtil.toRelativeEntry(deletedEntry, tempDirectory)) + .isEqualTo("old/sub/page.md"); + } + + @Test + public void relativePathsAcceptAbsoluteEntryAndRelativeBase() { + var relativeBase = Path.of("target", "mixed-paths"); + var absoluteFile = relativeBase.resolve("sections/page.md").toAbsolutePath().normalize(); + + assertThat(PathUtil.toRelativeFile(absoluteFile, relativeBase)) + .isEqualTo("sections/page.md"); + assertThat(PathUtil.toRelativePath(absoluteFile, relativeBase)) + .isEqualTo("sections"); + } } diff --git a/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java b/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java index 72369798a..c5c474005 100644 --- a/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/workflow/DefaultWFStatusProviderTest.java @@ -42,7 +42,7 @@ public class DefaultWFStatusProviderTest { public void test_publish_date_1_11_2023() { var cal = Calendar.getInstance(); cal.set(2023, 11, 1); - var contentNode = new ContentNode("", "", Map.of( + var contentNode = new ContentNode("", "", "", Map.of( Constants.MetaFields.PUBLISH_DATE, cal.getTime(), Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED )); @@ -53,7 +53,7 @@ public void test_publish_date_1_11_2023() { public void test_publish_date_1_11_2123() { var cal = Calendar.getInstance(); cal.set(2123, 11, 1); - var contentNode = new ContentNode("", "", Map.of( + var contentNode = new ContentNode("", "", "", Map.of( Constants.MetaFields.PUBLISH_DATE, cal.getTime(), Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED )); @@ -64,7 +64,7 @@ public void test_publish_date_1_11_2123() { public void test_unpublish_date_1_11_2023() { var cal = Calendar.getInstance(); cal.set(2023, 11, 1); - var contentNode = new ContentNode("", "", Map.of( + var contentNode = new ContentNode("", "", "", Map.of( Constants.MetaFields.UNPUBLISH_DATE, cal.getTime(), Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED )); @@ -75,7 +75,7 @@ public void test_unpublish_date_1_11_2023() { public void test_unpublish_date_1_11_2123() { var cal = Calendar.getInstance(); cal.set(2123, 11, 1); - var contentNode = new ContentNode("", "", Map.of( + var contentNode = new ContentNode("", "", "", Map.of( Constants.MetaFields.UNPUBLISH_DATE, cal.getTime(), Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED )); diff --git a/cms-api/src/test/java/com/condation/cms/api/workflow/WorkflowInstanceTest.java b/cms-api/src/test/java/com/condation/cms/api/workflow/WorkflowInstanceTest.java index 081c21755..f8cf92027 100644 --- a/cms-api/src/test/java/com/condation/cms/api/workflow/WorkflowInstanceTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/workflow/WorkflowInstanceTest.java @@ -61,7 +61,7 @@ void setup() { @Test void simple_wf_test () { - ContentNode node = new ContentNode("/", "Node", new HashMap<>()); + ContentNode node = new ContentNode("/", "/", "Node", new HashMap<>()); var transitions = wf.getNextTransitions(node); @@ -84,7 +84,7 @@ void simple_wf_test () { @Test void transit_with_unknown_id_throws() { - ContentNode node = new ContentNode("/", "Node", new HashMap<>()); + ContentNode node = new ContentNode("/", "/", "Node", new HashMap<>()); Assertions.assertThatThrownBy(() -> wf.transit("nonexistent", node)) .isInstanceOf(WFTransitionException.class) @@ -94,7 +94,7 @@ void transit_with_unknown_id_throws() { @Test void transit_blocked_by_guard_throws() { // node is in draft — "unpublish" guard requires published status - ContentNode node = new ContentNode("/", "Node", new HashMap<>()); + ContentNode node = new ContentNode("/", "/", "Node", new HashMap<>()); Assertions.assertThatThrownBy(() -> wf.transit("unpublish", node)) .isInstanceOf(WFTransitionException.class) 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 59d6f9ba9..850317009 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentResolver.java @@ -27,14 +27,11 @@ 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.cms.ReadOnlyFile; 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.utils.HTTPUtil; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.core.content.ContentResolvingStrategy; -import com.google.common.base.Strings; import java.io.IOException; import java.util.List; import java.util.Map; @@ -63,55 +60,48 @@ public Optional getErrorContent (final RequestContext context) } private Optional getContent(final RequestContext context, boolean checkVisibility) { - var contentBase = db.getFileSystem().contentBase(); - var path = ContentResolvingStrategy.uriToPath(context.get(RequestFeature.class).uri()); - Optional contentFileOpt = ContentResolvingStrategy.resolve(context.get(RequestFeature.class).uri(), db); - ReadOnlyFile contentFile = contentFileOpt.orElse(null); + final String uri = context.get(RequestFeature.class).uri(); + var path = ContentResolvingStrategy.uriToPath(uri); + + Optional contentNodeOpt = db.getContent().byUrl(uri); + // handle alias ContentNode contentNode = null; - boolean aliasRedirect = false; - if (contentFile == null || !contentFile.exists()) { + Optional aliasRedirectUrl = Optional.empty(); + if (contentNodeOpt.isEmpty()) { var query = db.getContent().query((node, count) -> node); var result = query.whereContains(Constants.MetaFields.ALIASES, "/" + path).get(); if (!result.isEmpty()) { contentNode = result.getFirst(); - contentFile = contentBase.resolve(contentNode.uri()); - aliasRedirect = true; + aliasRedirectUrl = Optional.of(contentNode.url()); } } else { - var uri = PathUtil.toRelativeFile(contentFile, contentBase); - final Optional nodeByUri = db.getContent().byUri(uri); - if (nodeByUri.isPresent()) { - contentNode = nodeByUri.get(); - } + contentNode = contentNodeOpt.get(); } if (contentNode == null) { return Optional.empty(); } + + var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); if (checkVisibility && !db.getContent().isVisible(contentNode)) { return Optional.empty(); } - if (contentNode.isRedirect()) { - return Optional.of(new DefaultContentResponse(contentNode)); - } else if (!Constants.NodeType.PAGE.equals(contentNode.nodeType())) { - return Optional.empty(); - } - context.add(CurrentNodeFeature.class, new CurrentNodeFeature(contentNode)); - if (contentNode.isRedirect()) { return Optional.of(new RedirectContentResponse(contentNode.getRedirectLocation(), contentNode.getRedirectStatus())); - } else if (aliasRedirect) { + } else if (aliasRedirectUrl.isPresent()) { var doRedirect = contentNode.getMetaValue(Constants.MetaFields.ALIASES_REDIRECT, true); if (doRedirect) { - var url = PathUtil.toURL(contentFile, contentBase); - url = HTTPUtil.modifyUrl(url, context); + var url = HTTPUtil.modifyUrl(aliasRedirectUrl.get(), context); return Optional.of(new RedirectContentResponse(url, 301)); } + } else if (!Constants.NodeType.PAGE.equals(contentNode.nodeType())) { + return Optional.empty(); } + context.add(CurrentNodeFeature.class, new CurrentNodeFeature(contentNode)); try { diff --git a/cms-content/src/main/java/com/condation/cms/content/NodeProperties.java b/cms-content/src/main/java/com/condation/cms/content/NodeProperties.java index 0f4726ae9..23a8c81a5 100644 --- a/cms-content/src/main/java/com/condation/cms/content/NodeProperties.java +++ b/cms-content/src/main/java/com/condation/cms/content/NodeProperties.java @@ -24,7 +24,6 @@ import com.condation.cms.api.SiteProperties; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.utils.HTTPUtil; -import com.condation.cms.api.utils.PathUtil; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -43,7 +42,7 @@ public static Map createNodeProperties (ContentNode node, SitePr Map properties = new HashMap<>(); - var canonicalUrl = PathUtil.toURL(node .uri()); + var canonicalUrl = node.url(); canonicalUrl = HTTPUtil.prependContext(canonicalUrl, siteProperties); properties.put("url", canonicalUrl); 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 4cac89237..056a36b05 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java +++ b/cms-content/src/main/java/com/condation/cms/content/ViewResolver.java @@ -24,15 +24,12 @@ 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.db.cms.ReadOnlyFile; 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.utils.PathUtil; import com.condation.cms.content.views.ViewParser; import com.condation.cms.extensions.request.RequestExtensions; -import com.google.common.base.Strings; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -54,43 +51,22 @@ public Optional getViewContent(final RequestContext context) { } public Optional getViewContent(final RequestContext context, final boolean checkVisibility) { - String path; - if (Strings.isNullOrEmpty(context.get(RequestFeature.class).uri())) { - path = ""; - } else if (context.get(RequestFeature.class).uri().startsWith("/")) { - // remove leading slash - path = context.get(RequestFeature.class).uri().substring(1); - } else { - path = context.get(RequestFeature.class).uri(); + var requestUrl = context.get(RequestFeature.class).uri(); + var contentNodeOpt = db.getContent().byUrl(requestUrl); + if (contentNodeOpt.isEmpty()) { + return Optional.empty(); } - var contentBase = db.getFileSystem().contentBase(); - var contentPath = contentBase.resolve(path); - 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(path + ".md"); - if (temp.exists()) { - contentFile = temp; - } else { - return Optional.empty(); - } + final ContentNode contentNode = contentNodeOpt.get(); + if (checkVisibility && !db.getContent().isVisible(contentNode)) { + return Optional.empty(); } - - var uri = PathUtil.toRelativeFile(contentFile, contentBase); - if (checkVisibility && !db.getContent().isVisible(uri)) { + if (!contentNode.isView()) { return Optional.empty(); } - final ContentNode contentNode = db.getContent().byUri(uri).get(); - if (!contentNode.isView()) { + var contentFile = db.getFileSystem().contentBase().resolve(contentNode.path()); + if (!contentFile.exists()) { return Optional.empty(); } context.add(CurrentNodeFeature.class, new CurrentNodeFeature(contentNode)); 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 ae26af800..ec69e98e3 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 @@ -28,7 +28,6 @@ import com.condation.cms.api.markdown.MarkdownRenderer; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.request.RequestContextScope; -import com.condation.cms.api.utils.HTTPUtil; import java.io.IOException; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -49,33 +48,6 @@ public abstract class AbstractCurrentNodeFunction { protected final ContentNodeMapper contentNodeMapper; protected final RequestContext context; - protected String getUrl(ReadOnlyFile node) { - StringBuilder sb = new StringBuilder(); - - while (node != null && !node.equals(db.getFileSystem().contentBase())) { - - var filename = node.getFileName(); - if (!filename.equals("index.md")) { - if (filename.endsWith(".md")) { - filename = filename.substring(0, filename.length() - 3); - } - sb.insert(0, filename); - sb.insert(0, "/"); - } - if (node.hasParent()) { - node = node.getParent(); - } else { - node = null; - } - } - - var url = sb.toString(); - - url = "".equals(url) ? "/" : url; - - return HTTPUtil.modifyUrl(url, context); - } - protected boolean isPreview() { if (RequestContextScope.REQUEST_CONTEXT.isBound() && RequestContextScope.REQUEST_CONTEXT.get().has(IsPreviewFeature.class)) { 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 9b7238a55..f931cbc63 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 @@ -34,6 +34,7 @@ import com.condation.cms.api.request.RequestContext; 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; import java.util.Collections; @@ -83,13 +84,13 @@ public List path() { var node = currentNode; while (node != null) { var uri = PathUtil.toRelativeFile(node, contentBase); - final Optional contentNode = db.getContent().byUri(uri); + 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, getUrl(path), isCurrentNode(path)); + final NavNode navNode = new NavNode(nodeName, HTTPUtil.modifyUrl(metaNode.url(), context), isCurrentNode(path)); if (!navNodes.contains(navNode)) { navNodes.add(navNode); } @@ -197,7 +198,7 @@ private List getNodesFromBase(final ReadOnlyFile base, final String sta navNodes.forEach((node) -> { var name = NodeUtil.getName(node); var path = contentBase.resolve(node.uri()); - var node_url = getUrl(path); + var node_url = HTTPUtil.modifyUrl(node.url(), context); final NavNode navNode = new NavNode( name, node_url, diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/translation/NodeTranslations.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/translation/NodeTranslations.java index b59c32b9d..4293d1e7f 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/translation/NodeTranslations.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/translation/NodeTranslations.java @@ -25,7 +25,6 @@ import com.condation.cms.api.SiteProperties; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.utils.HTTPUtil; -import com.condation.cms.api.utils.PathUtil; import com.condation.cms.core.serivce.ServiceRegistry; import com.condation.cms.core.serivce.impl.SiteLinkService; import com.condation.cms.core.serivce.impl.SitePropertiesService; @@ -61,7 +60,7 @@ public List translations () { locale = ServiceRegistry.getInstance().get(mapping.site(), SitePropertiesService.class).get().siteProperties().locale().getCountry().toLowerCase(); } else if (mapping.language().equals(siteProperties.language())) { url = HTTPUtil.prependContext( - PathUtil.toURL(node.uri()), + node.url(), siteProperties); locale = siteProperties.locale().getCountry().toLowerCase(); } diff --git a/cms-core/src/main/java/com/condation/cms/core/content/io/YamlHeaderUpdater.java b/cms-core/src/main/java/com/condation/cms/core/content/io/YamlHeaderUpdater.java index 5fe7b249a..4963fb734 100644 --- a/cms-core/src/main/java/com/condation/cms/core/content/io/YamlHeaderUpdater.java +++ b/cms-core/src/main/java/com/condation/cms/core/content/io/YamlHeaderUpdater.java @@ -28,8 +28,10 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Map; public class YamlHeaderUpdater { @@ -94,8 +96,7 @@ public static void saveMarkdownFileWithHeader(Path filePath, Map builder.append("---\n\n"); builder.append(content.trim()).append("\n"); - // Write to file - Files.write(filePath, builder.toString().getBytes(StandardCharsets.UTF_8)); + writeAtomically(filePath, builder.toString()); } public static void saveMetaData(Path filePath, Map metadata) throws IOException { @@ -113,6 +114,24 @@ public static void saveMetaData(Path filePath, Map metadata) thr builder.append(yamlContent); // Write to file - Files.write(filePath, builder.toString().getBytes(StandardCharsets.UTF_8)); + writeAtomically(filePath, builder.toString()); } + + private static void writeAtomically(Path filePath, String content) throws IOException { + var absolutePath = filePath.toAbsolutePath(); + var parent = absolutePath.getParent(); + var temporaryFile = Files.createTempFile( + parent, "." + absolutePath.getFileName() + ".cms-write-", ".tmp"); + try { + Files.writeString(temporaryFile, content, StandardCharsets.UTF_8); + try { + Files.move(temporaryFile, absolutePath, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporaryFile, absolutePath, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporaryFile); + } + } } 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 ef56acb86..1e28b7fd4 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,9 @@ */ import com.condation.cms.api.Constants; -import com.condation.cms.api.configuration.Configuration; -import com.condation.cms.api.configuration.configs.SiteConfiguration; 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.feature.features.EventBusFeature; -import com.condation.cms.api.utils.HTTPUtil; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.core.content.ContentResolvingStrategy; import com.condation.cms.core.content.io.ContentFileParser; diff --git a/cms-core/src/test/java/com/condation/cms/core/content/io/YamlHeaderUpdaterTest.java b/cms-core/src/test/java/com/condation/cms/core/content/io/YamlHeaderUpdaterTest.java new file mode 100644 index 000000000..e08face49 --- /dev/null +++ b/cms-core/src/test/java/com/condation/cms/core/content/io/YamlHeaderUpdaterTest.java @@ -0,0 +1,50 @@ +package com.condation.cms.core.content.io; + +/*- + * #%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 java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class YamlHeaderUpdaterTest { + + @TempDir + Path temporaryDirectory; + + @Test + void markdownFileIsReplacedWithoutLeavingTemporaryFiles() throws Exception { + var markdownFile = temporaryDirectory.resolve("page.md"); + Files.writeString(markdownFile, "old content"); + + YamlHeaderUpdater.saveMarkdownFileWithHeader( + markdownFile, Map.of("title", "New title"), "New content"); + + Assertions.assertThat(Files.readString(markdownFile)) + .contains("title: New title") + .endsWith("New content\n"); + try (var files = Files.list(temporaryDirectory)) { + Assertions.assertThat(files).containsExactly(markdownFile); + } + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/ContentChangeCoordinator.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ContentChangeCoordinator.java new file mode 100644 index 000000000..169a3f93c --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ContentChangeCoordinator.java @@ -0,0 +1,164 @@ +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 java.nio.file.Path; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +final class ContentChangeCoordinator implements AutoCloseable { + + private final Object lock = new Object(); + private final Duration quietPeriod; + private final BiConsumer> batchProcessor; + private final ScheduledExecutorService executor; + private final Set pendingPaths = new LinkedHashSet<>(); + + private ScheduledFuture scheduledFlush; + private long generation; + private boolean fullResync; + private boolean closed; + + ContentChangeCoordinator(Duration quietPeriod, BiConsumer> batchProcessor) { + this.quietPeriod = quietPeriod; + this.batchProcessor = batchProcessor; + this.executor = Executors.newSingleThreadScheduledExecutor( + Thread.ofVirtual().name("content-change-coordinator", 0).factory()); + } + + void submit(Path path) { + synchronized (lock) { + if (closed || fullResync) { + return; + } + pendingPaths.add(path.toAbsolutePath().normalize()); + scheduleFlush(quietPeriod.toMillis(), ++generation); + } + } + + void requestFullResync() { + synchronized (lock) { + if (closed) { + return; + } + fullResync = true; + pendingPaths.clear(); + scheduleFlush(0, ++generation); + } + } + + void flushNow() { + try { + var future = executor.submit(this::forceFlush); + future.get(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } catch (Exception ex) { + log.error("error while flushing content changes", ex); + } + } + + private void scheduleFlush(long delayMillis, long scheduledGeneration) { + if (scheduledFlush != null) { + scheduledFlush.cancel(false); + } + scheduledFlush = executor.schedule( + () -> flush(scheduledGeneration), delayMillis, TimeUnit.MILLISECONDS); + } + + private void flush(long scheduledGeneration) { + final boolean resync; + final Set paths; + synchronized (lock) { + if (scheduledGeneration != generation) { + return; + } + resync = fullResync; + paths = Collections.unmodifiableSet(new LinkedHashSet<>(pendingPaths)); + fullResync = false; + pendingPaths.clear(); + scheduledFlush = null; + } + + process(resync, paths); + } + + private void forceFlush() { + final boolean resync; + final Set paths; + synchronized (lock) { + generation++; + if (scheduledFlush != null) { + scheduledFlush.cancel(false); + } + resync = fullResync; + paths = Collections.unmodifiableSet(new LinkedHashSet<>(pendingPaths)); + fullResync = false; + pendingPaths.clear(); + scheduledFlush = null; + } + process(resync, paths); + } + + private void process(boolean resync, Set paths) { + if (!resync && paths.isEmpty()) { + return; + } + + try { + batchProcessor.accept(resync, paths); + } catch (Exception ex) { + log.error("error while processing content changes", ex); + } + } + + @Override + public void close() { + synchronized (lock) { + if (closed) { + return; + } + closed = true; + if (scheduledFlush != null) { + scheduledFlush.cancel(false); + } + } + flushNow(); + executor.shutdown(); + try { + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + executor.shutdownNow(); + } + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java index 0418afce3..b3206aece 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java @@ -53,11 +53,8 @@ public boolean isVisible(ContentNode node) { @Override public List listSectionEntries(ReadOnlyFile contentFile) { - String folder = PathUtil.toRelativePath(contentFile, fileSystem.contentBase()); - String filename = contentFile.getFileName(); - filename = filename.substring(0, filename.length() - 3); - - return fileSystem.listSectionEntries(filename, folder); + var pagePath = PathUtil.toRelativeFile(contentFile, fileSystem.contentBase()); + return fileSystem.listSectionEntries(pagePath); } @Override @@ -79,6 +76,16 @@ public Optional byUri(String uri) { return fileSystem.getMetaData().byUri(uri); } + @Override + public Optional byPath(String path) { + return fileSystem.getMetaData().byPath(path); + } + + @Override + public Optional byUrl(String url) { + return fileSystem.getMetaData().byUrl(url); + } + @Override public ContentQuery query(BiFunction nodeMapper) { return fileSystem.query(nodeMapper); 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 a85ffb268..6a14b8236 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 @@ -55,15 +55,12 @@ public class FileDB implements DB { private FileTaxonomies taxonomies; - public void init () throws IOException { - init(MetaData.Type.PERSISTENT); - } - public void init (MetaData.Type metaDataType) throws IOException { + public void init () throws IOException { fileSystem = new FileSystem( configuration.get(SiteConfiguration.class).siteProperties().id(), hostBaseDirectory, eventBus, contentParser); - fileSystem.init(metaDataType); + fileSystem.init(); readOnlyFileSystem = new WrappedReadOnlyFileSystem(fileSystem); content = new FileContent(fileSystem); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileEvent.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileEvent.java index 771af3162..d8832d481 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileEvent.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileEvent.java @@ -33,6 +33,7 @@ public record FileEvent(File file, Type type) { public enum Type { CREATED, MODIFIED, - DELETED + DELETED, + OVERFLOW } } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java index 25f837a09..7173b102b 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileSystem.java @@ -25,7 +25,6 @@ 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.NodeVisibility; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.eventbus.events.ContentChangedEvent; @@ -37,7 +36,6 @@ import com.condation.cms.api.utils.PathUtil; import com.condation.cms.core.utils.MdcScope; import com.condation.cms.filesystem.metadata.PageMetaData; -import com.condation.cms.filesystem.metadata.memory.MemoryMetaData; import com.condation.cms.filesystem.metadata.persistent.PersistentMetaData; import java.io.IOException; import java.nio.charset.Charset; @@ -48,14 +46,17 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.time.LocalDate; +import java.time.Duration; import java.time.ZoneId; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; -import java.util.regex.Pattern; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -68,12 +69,15 @@ @Slf4j public class FileSystem implements ModuleFileSystem, DBFileSystem { + private static final Duration CONTENT_CHANGE_QUIET_PERIOD = Duration.ofMillis(200); + private final String siteId; private final Path hostBaseDirectory; private final EventBus eventBus; final Function> contentParser; private MultiRootRecursiveWatcher fileWatcher; + private ContentChangeCoordinator contentChangeCoordinator; private Path contentBase; @Getter @@ -93,7 +97,7 @@ protected ContentQuery query(final String startURI, final BiFunction> getMeta(final String uri) { - var node = metaData.byUri(uri); + var node = metaData.byPath(uri); if (node.isEmpty()) { return Optional.empty(); } @@ -115,6 +119,9 @@ public void shutdown() { if (fileWatcher != null) { fileWatcher.stop(); } + if (contentChangeCoordinator != null) { + contentChangeCoordinator.close(); + } if (metaData != null) { try { metaData.close(); @@ -218,56 +225,14 @@ public List listContent(final String folder) { } public List listSectionEntries(final Path contentFile) { - String folder = PathUtil.toRelativePath(contentFile, contentBase); - String filename = contentFile.getFileName().toString(); - filename = filename.substring(0, filename.length() - 3); - - return FileSystem.this.listSectionEntries(filename, folder); + return listSectionEntries(PathUtil.toRelativeFile(contentFile, contentBase)); } - public List listSectionEntries(final String filename, String folder) { - List nodes = new ArrayList<>(); - - final Pattern isSectionEntryOf = Constants.SECTION_ENTRY_OF_PATTERN.apply(filename); - final Pattern isNamedSectionEntryOf = Constants.SECTION_ENTRY_NAMED_OF_PATTERN.apply(filename); - - if ("".equals(folder)) { - metaData.getTree().values() - .stream() - .filter(node -> !node.isHidden()) - .filter(NodeVisibility::isVisible) - .filter(node -> node.isSectionEntry()) - .filter(node -> { - return isSectionEntryOf.matcher(node.name()).matches() || isNamedSectionEntryOf.matcher(node.name()).matches(); - }) - .forEach((node) -> { - nodes.add(node); - }); - } else { - Optional findFolder = metaData.findFolder(folder); - if (findFolder.isPresent()) { - findFolder.get().children().values() - .stream() - .filter(node -> !node.isHidden()) - .filter(NodeVisibility::isVisible) - .filter(node -> node.isSectionEntry()) - .filter(node - -> isSectionEntryOf.matcher(node.name()).matches() || isNamedSectionEntryOf.matcher(node.name()).matches() - ) - .forEach((node) -> { - nodes.add(node); - }); - } - } - - return nodes; + public List listSectionEntries(final String pagePath) { + return metaData.listSectionEntries(pagePath); } private void addOrUpdateMetaData(Path file) { - addOrUpdateMetaData(file, false); - } - - private void addOrUpdateMetaData(Path file, boolean batch) { try { if (!Files.exists(file)) { return; @@ -279,8 +244,6 @@ private void addOrUpdateMetaData(Path file, boolean batch) { Map fileMeta = contentParser.apply(file); var uri = PathUtil.toRelativeFile(file, contentBase); - - eventBus.publish(new InvalidateContentCacheEvent()); var lastModified = LocalDate.ofInstant(Files.getLastModifiedTime(file).toInstant(), ZoneId.systemDefault()); @@ -290,38 +253,24 @@ private void addOrUpdateMetaData(Path file, boolean batch) { } } - public void init() throws IOException { - init(MetaData.Type.PERSISTENT); - } - public void init(MetaData.Type metaDataType) throws IOException { + public void init() throws IOException { log.debug("init filesystem"); - if (MetaData.Type.MEMORY.equals(metaDataType)) { - this.metaData = new MemoryMetaData(); - } else { - this.metaData = new PersistentMetaData(this.hostBaseDirectory); - } + this.metaData = new PersistentMetaData(this.hostBaseDirectory); + this.metaData.open(); this.contentBase = resolve("content/"); + this.contentChangeCoordinator = new ContentChangeCoordinator( + CONTENT_CHANGE_QUIET_PERIOD, this::processContentChanges); var templateBase = resolve("templates/"); log.debug("init filewatcher"); this.fileWatcher = new MultiRootRecursiveWatcher(siteId, List.of(contentBase, templateBase)); fileWatcher.getPublisher(contentBase).subscribe(new MultiRootRecursiveWatcher.AbstractFileEventSubscriber() { @Override public void onNext(FileEvent item) { - MdcScope.forSite(siteId).run(() -> { - try { - if (item.file().isDirectory() || FileEvent.Type.DELETED.equals(item.type())) { - swapMetaData(); - } else { - addOrUpdateMetaData(item.file().toPath()); - } - } catch (IOException ex) { - log.error("", ex); - } - }); + handleContentEvent(item); this.subscription.request(1); } @@ -347,19 +296,76 @@ public void onNext(FileEvent item) { fileWatcher.start(); eventBus.register(ReIndexContentMetaDataEvent.class, (event) -> { + if (event.uri() == null) { + contentChangeCoordinator.requestFullResync(); + } else { + contentChangeCoordinator.submit(contentBase.resolve(event.uri())); + } + }); + } + + void handleContentEvent(FileEvent event) { + if (event.type() == FileEvent.Type.OVERFLOW) { + contentChangeCoordinator.requestFullResync(); + } else { + contentChangeCoordinator.submit(event.file().toPath()); + } + } + + void flushContentChanges() { + contentChangeCoordinator.flushNow(); + } + + private void processContentChanges(boolean fullResync, Set paths) { + MdcScope.forSite(siteId).run(() -> { try { - if (event.uri() == null) { + if (fullResync) { swapMetaData(); - } else { - var contentFile = contentBase.resolve(event.uri()); - addOrUpdateMetaData(contentFile); + return; } + + var changedPaths = new LinkedHashSet(); + for (var path : paths) { + if (processContentPath(path)) { + changedPaths.add(path); + } + } + publishContentChanges(changedPaths); } catch (IOException ex) { - log.error("error while reindex meta data", ex); + log.error("error while processing content changes", ex); } }); } + private boolean processContentPath(Path path) throws IOException { + if (Files.exists(path)) { + if (Files.isDirectory(path)) { + reInitFolder(path); + return true; + } + if (PathUtil.isContentFile(path)) { + addOrUpdateMetaData(path); + return true; + } + return false; + } + + var relativePath = PathUtil.toRelativeEntry(path, contentBase); + if (metaData.byPath(relativePath).isEmpty() && metaData.findFolder(relativePath).isEmpty()) { + return false; + } + metaData.removePath(relativePath); + return true; + } + + private void publishContentChanges(Collection paths) { + if (paths.isEmpty()) { + return; + } + paths.forEach(path -> eventBus.publish(new ContentChangedEvent(path))); + eventBus.publish(new InvalidateContentCacheEvent()); + } + private void swapMetaData() throws IOException { log.debug("rebuild metadata"); metaData.clear(); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java index c745e5da0..905106f5d 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java @@ -38,24 +38,33 @@ */ public interface MetaData { - public enum Type { - MEMORY, PERSISTENT - } - void open () throws IOException; void close () throws IOException; - void addFile(final String uri, final Map data, final LocalDate lastModified); + void addFile(final String path, final Map data, final LocalDate lastModified); + + void removeFile(final String path); + + void removeDirectory(final String path); + + void removePath(final String path); + @Deprecated(since = "8.3.0") Optional byUri(final String uri); - void createDirectory(final String uri); + Optional byPath(final String path); + + Optional byUrl (final String url); + + void createDirectory(final String path); + + Optional findFolder(String path); - Optional findFolder(String uri); + List listChildren(String path); - List listChildren(String uri); + List listSectionEntries(String pagePath); - TitleQuery searchByTitle(String uri); + TitleQuery searchByTitle(String path); void clear (); @@ -64,5 +73,5 @@ public enum Type { Map getTree(); ContentQuery query(final BiFunction nodeMapper); - ContentQuery query(final String startURI, final BiFunction nodeMapper); + ContentQuery query(final String startPath, final BiFunction nodeMapper); } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MultiRootRecursiveWatcher.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MultiRootRecursiveWatcher.java index 5b235fed4..0b40be5ed 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MultiRootRecursiveWatcher.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MultiRootRecursiveWatcher.java @@ -127,19 +127,25 @@ public void start() throws IOException { events.forEach((event) -> { Path path = (Path) watchKey.watchable(); + if (event.kind() == OVERFLOW) { + log.warn("Overflow occurred, requesting metadata resync"); + roots.values().stream() + .filter(root -> PathUtil.isChild(root.path, path)) + .forEach(root -> root.publisher.submit(new FileEvent(path.toFile(), FileEvent.Type.OVERFLOW))); + return; + } File file = path.resolve((Path) event.context()).toFile(); final FileEvent fileEvent; if (event.kind().equals(ENTRY_CREATE)) { fileEvent = new FileEvent(file, FileEvent.Type.CREATED); + if (file.isDirectory()) { + walkTreeAndSetWatches(file.toPath()); + } } else if (event.kind().equals(ENTRY_DELETE)) { fileEvent = new FileEvent(file, FileEvent.Type.DELETED); } else if (event.kind().equals(ENTRY_MODIFY)) { fileEvent = new FileEvent(file, FileEvent.Type.MODIFIED); - } else if (event.kind() == OVERFLOW) { - log.warn("Overflow occurred, resyncing watches"); - walkTreeAndSetWatches(); - fileEvent = null; } else { fileEvent = null; } @@ -199,35 +205,40 @@ private void updateWatches() { private synchronized void walkTreeAndSetWatches() { log.debug("Registering new folders at watch service ..."); - roots.values().forEach(root -> { - try { - Files.walkFileTree(root.path, new FileVisitor() { - @Override - public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { - registerWatch(dir); - return FileVisitResult.CONTINUE; - } + roots.values().forEach(root -> walkTreeAndSetWatches(root.path)); - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - return FileVisitResult.CONTINUE; - } + } - @Override - public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { - return FileVisitResult.CONTINUE; - } + private synchronized void walkTreeAndSetWatches(Path root) { + if (!Files.isDirectory(root)) { + return; + } + try { + Files.walkFileTree(root, new FileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + registerWatch(dir); + return FileVisitResult.CONTINUE; + } - @Override - public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { - return FileVisitResult.CONTINUE; - } - }); - } catch (IOException e) { - log.warn("Failed to walkTreeAndSetWatches {}", root, e); - } - }); + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + return FileVisitResult.CONTINUE; + } + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + log.warn("Failed to register folder tree {}", root, e); + } } private synchronized void unregisterStaleWatches() { diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/AbstractMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/AbstractMetaData.java index 1454b5cac..fcea7bc99 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/AbstractMetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/AbstractMetaData.java @@ -24,9 +24,8 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.feature.features.IsPreviewFeature; -import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.filesystem.MetaData; +import com.condation.cms.api.utils.PathUtil; import com.google.common.base.Strings; import java.util.Arrays; import java.util.Collections; @@ -46,6 +45,8 @@ public abstract class AbstractMetaData implements MetaData { protected ConcurrentMap nodes; + protected ConcurrentMap urlToUri; + protected ConcurrentMap tree; @Override @@ -53,6 +54,7 @@ public void clear() { if (nodes != null) { nodes.clear(); tree.clear(); + urlToUri.clear(); } } @@ -74,11 +76,24 @@ public ConcurrentMap getTree() { @Override public Optional byUri(String uri) { - if (!nodes.containsKey(uri)) { + return Optional.ofNullable(nodes.get(uri)); + } + + @Override + public Optional byPath(String path) { + return Optional.ofNullable(nodes.get(path)); + } + + @Override + public Optional byUrl(String url) { + + if (url == null) { return Optional.empty(); } - return Optional.of(nodes.get(uri)); - } + var uri = urlToUri.get(PathUtil.normalizeURL(url)); + return Optional.ofNullable(uri).flatMap(this::byPath); + + } @Override public Optional findFolder(String uri) { @@ -107,8 +122,11 @@ public void createDirectory(String uri) { if (Strings.isNullOrEmpty(uri)) { return; } + if (getFolder(uri).isPresent()) { + return; + } var parts = uri.split(Constants.SPLIT_PATH_PATTERN); - ContentNode n = new ContentNode(uri, parts[parts.length - 1], Map.of(), true); + ContentNode n = new ContentNode(uri, uri, parts[parts.length - 1], Map.of(), true); Optional parentFolder; if (parts.length == 1) { @@ -127,6 +145,18 @@ public void createDirectory(String uri) { } } + protected void removeFromTree(String path) { + var separatorIndex = path.lastIndexOf('/'); + var name = path.substring(separatorIndex + 1); + if (separatorIndex < 0) { + tree.remove(name); + return; + } + + var parentPath = path.substring(0, separatorIndex); + getFolder(parentPath).ifPresent(parent -> parent.children().remove(name)); + } + @Override public List listChildren(String uri) { if ("".equals(uri)) { diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/MemoryMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/MemoryMetaData.java deleted file mode 100644 index e9719d423..000000000 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/MemoryMetaData.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.condation.cms.filesystem.metadata.memory; - -/*- - * #%L - * CMS FileSystem - * %% - * Copyright (C) 2023 - 2026 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * #L% - */ - - -import com.condation.cms.api.Constants; -import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.filesystem.metadata.AbstractMetaData; -import com.condation.cms.filesystem.metadata.persistent.TitleQuery; -import java.io.IOException; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BiFunction; - -/** - * - * @author t.marx - */ -public class MemoryMetaData extends AbstractMetaData { - - @Override - public void addFile(final String uri, final Map data, final LocalDate lastModified) { - - var parts = uri.split(Constants.SPLIT_PATH_PATTERN); - final ContentNode node = new ContentNode(uri, parts[parts.length - 1], data, lastModified); - - nodes.put(uri, node); - - var folder = getFolder(uri); - if (folder.isPresent()) { - folder.get().children().put(node.name(), node); - } else { - tree.put(node.name(), node); - } - } - - void remove(String uri) { - nodes.remove(uri); - - var folder = getFolder(uri); - var parts = uri.split(Constants.SPLIT_PATH_PATTERN); - var name = parts[parts.length - 1]; - if (folder.isPresent()) { - folder.get().children().remove(name); - } else { - tree.remove(name); - } - } - - @Override - public void open() throws IOException { - if (nodes == null) { - nodes = new ConcurrentHashMap<>(); - tree = new ConcurrentHashMap<>(); - } - } - - @Override - public void close() throws IOException { - nodes = null; - tree = null; - } - - @Override - public ContentQuery query(final BiFunction nodeMapper) { - return new MemoryQuery(new ArrayList<>(nodes.values()), nodeMapper); - } - - @Override - public ContentQuery query(final String startURI, final BiFunction nodeMapper) { - - final String uri; - if (startURI.startsWith("/")) { - uri = startURI.substring(1); - } else { - uri = startURI; - } - - var filtered = getNodes().values().stream().filter(node -> node.uri().startsWith(uri)).toList(); - - return new MemoryQuery(filtered, nodeMapper); - } - - @Override - public TitleQuery searchByTitle(String uri) { - throw new UnsupportedOperationException("Not supported yet."); // Generated from nbfs://nbhost/SystemFileSystem/Templates/Classes/Code/GeneratedMethodBody - } - - -} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/MemoryQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/MemoryQuery.java deleted file mode 100644 index e57c38835..000000000 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/MemoryQuery.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.condation.cms.filesystem.metadata.memory; - -/*- - * #%L - * CMS FileSystem - * %% - * Copyright (C) 2023 - 2026 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * #L% - */ - -import com.condation.cms.api.Constants; -import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.Page; -import com.condation.cms.api.utils.NodeUtil; -import com.condation.cms.filesystem.metadata.AbstractMetaData; -import com.condation.cms.filesystem.metadata.PageMetaData; -import static com.condation.cms.filesystem.metadata.memory.QueryUtil.filtered; -import static com.condation.cms.filesystem.metadata.memory.QueryUtil.sorted; -import com.condation.cms.filesystem.metadata.query.ExcerptMapperFunction; -import com.condation.cms.filesystem.metadata.query.ExtendableQuery; -import com.condation.cms.filesystem.metadata.query.Queries; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.function.BiFunction; -import java.util.stream.Stream; - -/** - * - * @author t.marx - * @param - */ -@Deprecated(since = "2025.10") -public class MemoryQuery extends ExtendableQuery { - - private QueryContext context; - - public MemoryQuery(Collection nodes, BiFunction nodeMapper) { - this(nodes.stream(), new ExcerptMapperFunction<>(nodeMapper)); - } - - public MemoryQuery(Stream nodes, ExcerptMapperFunction nodeMapper) { - this(new QueryContext<>(nodes, nodeMapper, Constants.DEFAULT_CONTENT_TYPE)); - } - - public MemoryQuery(QueryContext context) { - this.context = context; - } - - @Override - public MemoryQuery excerpt(final long excerptLength) { - context.getNodeMapper().setExcerpt((int)excerptLength); - return this; - } - - @Override - public MemoryQuery where(final String field, final Object value) { - return where(field, Queries.Operator.EQ, value); - } - - @Override - public MemoryQuery where(final String field, final String operator, final Object value) { - if (Queries.isDefaultOperation(operator)) { - return where(field, Queries.operator4String(operator), value); - } else if (getContext().getQueryOperations().containsKey(operator)) { - return new MemoryQuery<>(QueryUtil.filter_extension(context, field, value, getContext().getQueryOperations().get(operator))); - } - throw new IllegalArgumentException("unknown operator " + operator); - } - - @Override - public MemoryQuery whereContains(final String field, final Object value) { - return where(field, Queries.Operator.CONTAINS, value); - } - - @Override - public MemoryQuery whereNotContains(final String field, final Object value) { - return where(field, Queries.Operator.CONTAINS_NOT, value); - } - - @Override - public MemoryQuery whereIn(final String field, final Object... value) { - return where(field, Queries.Operator.IN, value); - } - - @Override - public MemoryQuery whereNotIn(final String field, final Object... value) { - return where(field, Queries.Operator.NOT_IN, value); - } - - @Override - public MemoryQuery whereIn(final String field, final List value) { - return where(field, Queries.Operator.IN, value); - } - - @Override - public MemoryQuery whereNotIn(final String field, final List value) { - return where(field, Queries.Operator.NOT_IN, value); - } - - @Override - public MemoryQuery whereExists(final String field) { - return new MemoryQuery<>(QueryUtil.exists(context, field)); - } - - private MemoryQuery where(final String field, final Queries.Operator operator, final Object value) { - return new MemoryQuery<>(filtered(context, field, value, operator)); - } - - - @Override - public MemoryQuery html() { - context.setContentType(Constants.ContentTypes.HTML); - return new MemoryQuery<>(context); - } - - @Override - public MemoryQuery json() { - context.setContentType(Constants.ContentTypes.JSON); - return new MemoryQuery<>(context); - } - - @Override - public MemoryQuery contentType(String contentType) { - context.setContentType(contentType); - return new MemoryQuery<>(context); - } - - @Override - public List get() { - return context.getNodes() - .filter(NodeUtil.contentTypeFiler(context.getContentType())) - .filter(node -> !node.isDirectory()) - .filter(PageMetaData::isPage) - .filter(PageMetaData::isVisible) - .map(context.getNodeMapper()) - .toList(); - } - - public Page page(final Object page, final Object size) { - int i_page = Constants.DEFAULT_PAGE; - int i_size = Constants.DEFAULT_PAGE_SIZE; - if (page instanceof Integer || page instanceof Long) { - i_page = ((Number) page).intValue(); - } else if (page instanceof String string) { - i_page = Integer.parseInt(string); - } - if (size instanceof Integer || size instanceof Long) { - i_size = ((Number) size).intValue(); - } else if (size instanceof String string) { - i_size = Integer.parseInt(string); - } - return page((int) i_page, (int) i_size); - } - - @Override - public Page page(final long page, final long pageSize) { - long offset = (page - 1) * pageSize; - - var filteredNodes = context.getNodes() - .filter(NodeUtil.contentTypeFiler(context.getContentType())) - .filter(node -> !node.isDirectory()) - .filter(PageMetaData::isPage) - .filter(PageMetaData::isVisible) - .toList(); - - var totalItems = filteredNodes.size(); - - var filteredTargetNodes = filteredNodes.stream() - .skip(offset) - .limit(pageSize) - .map(context.getNodeMapper()) - .toList(); - - int totalPages = (int) Math.ceil((float) totalItems / pageSize); - return new Page<>(totalItems, pageSize, totalPages, (int)page, filteredTargetNodes); - } - - @Override - public Sort orderby(final String field) { - return new Sort<>(field, context); - } - - @Override - public Map> groupby(final String field) { - return QueryUtil.groupby(context.getNodes(), field); - } - - @Override - public ContentQuery expression(String expressions) { - throw new UnsupportedOperationException("Not supported yet."); // Generated from nbfs://nbhost/SystemFileSystem/Templates/Classes/Code/GeneratedMethodBody - } - - public static record Sort(String field, QueryContext context) implements ContentQuery.Sort { - - public MemoryQuery asc() { - return new MemoryQuery(sorted(context, field, true)); - } - - public MemoryQuery desc() { - return new MemoryQuery(sorted(context, field, false)); - } - } -} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java index 457cc680d..2d337c071 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQuery.java @@ -26,7 +26,6 @@ import com.condation.cms.api.db.Page; import com.condation.cms.filesystem.MetaData; import com.condation.cms.filesystem.metadata.PageMetaData; -import com.condation.cms.filesystem.metadata.memory.QueryUtil; import com.condation.cms.filesystem.metadata.query.ExcerptMapperFunction; import com.condation.cms.filesystem.metadata.query.ExtendableQuery; import com.condation.cms.filesystem.metadata.query.Queries; diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java index 8d3fdf094..54c8f5b30 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java @@ -24,13 +24,15 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.NodeVisibility; +import com.condation.cms.api.utils.PathUtil; import com.condation.cms.filesystem.metadata.AbstractMetaData; import com.condation.cms.filesystem.metadata.query.ExcerptMapperFunction; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; -import java.util.Collections; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.function.BiFunction; @@ -40,9 +42,10 @@ import org.apache.lucene.document.Field; import org.apache.lucene.document.StringField; import org.apache.lucene.index.Term; -import org.apache.lucene.queryparser.flexible.core.QueryNodeException; -import org.apache.lucene.queryparser.flexible.standard.StandardQueryParser; import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.PrefixQuery; +import org.apache.lucene.search.TermQuery; +import org.h2.mvstore.MVMap; import org.h2.mvstore.MVStore; /** @@ -57,6 +60,9 @@ public class PersistentMetaData extends AbstractMetaData implements AutoCloseabl private LuceneIndex index; private MVStore store; + private SectionIndex sectionIndex; + private UrlIndex urlIndex; + private MVMap nodesByPath; private TitleQueryFactory titleQueryFactory; @@ -69,15 +75,21 @@ public void open() throws IOException { index = new LuceneIndex(); index.open(hostPath.resolve("data/metadata/index")); - - + + store = MVStore.open(hostPath.resolve("data/metadata/store/data.db").toString()); - nodes = store.openMap("nodes"); + nodesByPath = store.openMap("nodes"); + nodes = nodesByPath; tree = store.openMap("tree"); + urlToUri = store.openMap("urlToUri"); + sectionIndex = new SectionIndex(store); + urlIndex = new UrlIndex(store, urlToUri); - nodes.clear(); + nodesByPath.clear(); tree.clear(); + sectionIndex.clear(); + urlIndex.clear(); titleQueryFactory = new TitleQueryFactory(LuceneIndex.SEARCH_ANALYZER); } @@ -108,14 +120,28 @@ public void stopBatch () { log.error("error commiting index", ex); } } + + @Override + public synchronized void createDirectory(String path) { + super.createDirectory(path); + } @Override - public void addFile(String uri, Map data, LocalDate lastModified) { + public synchronized void addFile(String uri, Map data, LocalDate lastModified) { + + var url = PathUtil.toURL(uri); + + if (data.get(Constants.MetaFields.URL) instanceof String configuredUrl && !configuredUrl.isBlank()) { + url = configuredUrl; + } + url = PathUtil.normalizeURL(url); var parts = uri.split(Constants.SPLIT_PATH_PATTERN); - final ContentNode node = new ContentNode(uri, parts[parts.length - 1], data, lastModified); + final ContentNode node = new ContentNode(uri, url, parts[parts.length - 1], data, lastModified); nodes.put(uri, node); + urlIndex.put(node); + sectionIndex.add(uri); var folder = getFolder(uri); if (folder.isPresent()) { @@ -126,6 +152,7 @@ public void addFile(String uri, Map data, LocalDate lastModified Document document = new Document(); document.add(new StringField("_uri", uri, Field.Store.YES)); + document.add(new StringField("_url", node.url(), Field.Store.YES)); //document.add(new StringField("_source", GSON.toJson(node), Field.Store.NO)); DocumentHelper.addData(document, data); @@ -143,10 +170,98 @@ public void addFile(String uri, Map data, LocalDate lastModified } @Override - public void clear() { + public synchronized void removeFile(String path) { + var node = nodesByPath.remove(path); + if (node == null) { + return; + } + + removeNodeFromSecondaryIndexes(node); + removeFromTree(path); + try { + index.delete(new TermQuery(new Term("_uri", path))); + } catch (IOException ex) { + log.error("error deleting metadata for {}", path, ex); + } + } + + @Override + public synchronized void removeDirectory(String path) { + var normalizedPath = stripTrailingSlash(path); + if (normalizedPath.isEmpty() || getFolder(normalizedPath).isEmpty()) { + return; + } + + var prefix = normalizedPath + "/"; + var affectedPaths = pathsWithPrefix(prefix); + removeFromTree(normalizedPath); + affectedPaths.forEach(affectedPath -> { + var node = nodesByPath.remove(affectedPath); + if (node != null) { + removeNodeFromSecondaryIndexes(node); + } + }); + + try { + index.delete(new PrefixQuery(new Term("_uri", prefix))); + } catch (IOException ex) { + log.error("error deleting metadata below {}", normalizedPath, ex); + } + } + + @Override + public synchronized void removePath(String path) { + var normalizedPath = stripTrailingSlash(path); + if (nodesByPath.containsKey(normalizedPath)) { + removeFile(normalizedPath); + } else { + removeDirectory(normalizedPath); + } + } + + private List pathsWithPrefix(String prefix) { + var paths = new ArrayList(); + var cursor = nodesByPath.cursor(prefix); + while (cursor.hasNext()) { + var path = cursor.next(); + if (!path.startsWith(prefix)) { + break; + } + paths.add(path); + } + return paths; + } + + private void removeNodeFromSecondaryIndexes(ContentNode node) { + urlIndex.remove(node.path()); + sectionIndex.remove(node.path()); + } + + private static String stripTrailingSlash(String path) { + var normalized = path.replace('\\', '/'); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + @Override + public List listSectionEntries(String pagePath) { + return sectionIndex.findByPagePath(pagePath).stream() + .map(nodes::get) + .filter(node -> node != null) + .filter(node -> !node.isHidden()) + .filter(NodeVisibility::isVisible) + .toList(); + } + + @Override + public synchronized void clear() { super.clear(); + sectionIndex.clear(); + urlIndex.clear(); try { - index.delete(new MatchAllDocsQuery()); + index.delete(MatchAllDocsQuery.INSTANCE); } catch (IOException ex) { log.error("", ex); } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/QueryContext.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/QueryContext.java similarity index 95% rename from cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/QueryContext.java rename to cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/QueryContext.java index 44bf172c1..39b2b1430 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/QueryContext.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/QueryContext.java @@ -1,4 +1,4 @@ -package com.condation.cms.filesystem.metadata.memory; +package com.condation.cms.filesystem.metadata.persistent; /*- * #%L diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/QueryUtil.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/QueryUtil.java similarity index 98% rename from cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/QueryUtil.java rename to cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/QueryUtil.java index bb50baabc..7a466a940 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/memory/QueryUtil.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/QueryUtil.java @@ -1,4 +1,4 @@ -package com.condation.cms.filesystem.metadata.memory; +package com.condation.cms.filesystem.metadata.persistent; /*- * #%L diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/SectionIndex.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/SectionIndex.java new file mode 100644 index 000000000..4f9144179 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/SectionIndex.java @@ -0,0 +1,86 @@ +package com.condation.cms.filesystem.metadata.persistent; + +/*- + * #%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.utils.SectionUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.h2.mvstore.MVMap; +import org.h2.mvstore.MVStore; + +final class SectionIndex { + + private static final String MAP_NAME = "sectionsByOwnerPath"; + private static final String KEY_SEPARATOR = "\u0000"; + + private final MVMap entries; + + SectionIndex(MVStore store) { + entries = store.openMap(MAP_NAME); + } + + void add(String sectionPath) { + ownerPath(sectionPath).ifPresent(pagePath -> + entries.put(keyPrefix(pagePath) + sectionPath, sectionPath)); + } + + void remove(String sectionPath) { + ownerPath(sectionPath).ifPresent(pagePath -> + entries.remove(keyPrefix(pagePath) + sectionPath)); + } + + List findByPagePath(String pagePath) { + var prefix = keyPrefix(pagePath); + var cursor = entries.cursor(prefix); + var sectionPaths = new ArrayList(); + + while (cursor.hasNext()) { + var key = cursor.next(); + if (!key.startsWith(prefix)) { + break; + } + sectionPaths.add(cursor.getValue()); + } + return List.copyOf(sectionPaths); + } + + void clear() { + entries.clear(); + } + + private static Optional ownerPath(String sectionPath) { + var separatorIndex = sectionPath.lastIndexOf('/'); + var fileName = sectionPath.substring(separatorIndex + 1); + if (!SectionUtil.isSectionEntry(fileName)) { + return Optional.empty(); + } + + var ownerFileName = fileName.substring(0, fileName.indexOf('.')) + ".md"; + var folder = separatorIndex < 0 ? "" : sectionPath.substring(0, separatorIndex + 1); + return Optional.of(folder + ownerFileName); + } + + private static String keyPrefix(String pagePath) { + return pagePath + KEY_SEPARATOR; + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/UrlIndex.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/UrlIndex.java new file mode 100644 index 000000000..206d369e8 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/UrlIndex.java @@ -0,0 +1,102 @@ +package com.condation.cms.filesystem.metadata.persistent; + +/*- + * #%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.ContentNode; +import java.util.Optional; +import java.util.concurrent.ConcurrentMap; +import org.h2.mvstore.MVMap; +import org.h2.mvstore.MVStore; + +final class UrlIndex { + + private static final String KEY_SEPARATOR = "\u0000"; + private static final String SEQUENCE_KEY = "sequence"; + + private final ConcurrentMap winners; + private final MVMap candidates; + private final MVMap candidateByPath; + private final MVMap state; + + UrlIndex(MVStore store, ConcurrentMap winners) { + this.winners = winners; + candidates = store.openMap("urlCandidates"); + candidateByPath = store.openMap("urlCandidateByPath"); + state = store.openMap("urlIndexState"); + } + + void put(ContentNode node) { + remove(node.path()); + + var sequence = state.getOrDefault(SEQUENCE_KEY, 0L) + 1; + state.put(SEQUENCE_KEY, sequence); + + var candidateKey = candidateKey(node.url(), sequence, node.path()); + candidates.put(candidateKey, node.path()); + candidateByPath.put(node.path(), candidateKey); + winners.put(node.url(), node.path()); + } + + void remove(String path) { + var candidateKey = candidateByPath.remove(path); + if (candidateKey == null) { + return; + } + + var url = urlFromCandidateKey(candidateKey); + candidates.remove(candidateKey); + if (winners.remove(url, path)) { + findLatestCandidate(url).ifPresentOrElse( + winner -> winners.put(url, winner), + () -> winners.remove(url) + ); + } + } + + void clear() { + winners.clear(); + candidates.clear(); + candidateByPath.clear(); + state.clear(); + } + + private Optional findLatestCandidate(String url) { + var prefix = url + KEY_SEPARATOR; + var cursor = candidates.cursor(prefix + Character.MAX_VALUE, null, true); + while (cursor.hasNext()) { + var key = cursor.next(); + if (!key.startsWith(prefix)) { + break; + } + return Optional.ofNullable(cursor.getValue()); + } + return Optional.empty(); + } + + private static String candidateKey(String url, long sequence, String path) { + return url + KEY_SEPARATOR + "%016x".formatted(sequence) + KEY_SEPARATOR + path; + } + + private static String urlFromCandidateKey(String candidateKey) { + return candidateKey.substring(0, candidateKey.indexOf(KEY_SEPARATOR)); + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemIncrementalEventTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemIncrementalEventTest.java new file mode 100644 index 000000000..22252f496 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemIncrementalEventTest.java @@ -0,0 +1,144 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.eventbus.EventListener; +import com.condation.cms.api.eventbus.events.ContentChangedEvent; +import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; +import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; +import org.yaml.snakeyaml.Yaml; + +public class FileSystemIncrementalEventTest { + + @TempDir + Path tempDirectory; + + @Test + public void directoryRenameReindexesOnlyTheMovedSubtree() throws Exception { + var content = tempDirectory.resolve("content"); + var oldDirectory = content.resolve("old"); + Files.createDirectories(oldDirectory.resolve("nested")); + Files.createDirectories(content.resolve("unaffected")); + writePage(oldDirectory.resolve("page.md"), "/stable-page"); + writePage(oldDirectory.resolve("nested/child.md"), "/stable-child"); + writePage(content.resolve("unaffected/page.md"), "/unaffected"); + + var fileSystem = new FileSystem("test-site", tempDirectory, Mockito.mock(EventBus.class), file -> { + try { + return new Yaml().load(Files.readString(file)); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + try { + fileSystem.init(); + var newDirectory = content.resolve("new"); + Files.createDirectories(newDirectory.resolve("nested")); + writePage(newDirectory.resolve("page.md"), "/stable-page"); + writePage(newDirectory.resolve("nested/child.md"), "/stable-child"); + Files.delete(oldDirectory.resolve("nested/child.md")); + Files.delete(oldDirectory.resolve("nested")); + Files.delete(oldDirectory.resolve("page.md")); + Files.delete(oldDirectory); + + fileSystem.handleContentEvent(new FileEvent(oldDirectory.toFile(), FileEvent.Type.DELETED)); + fileSystem.handleContentEvent(new FileEvent(newDirectory.toFile(), FileEvent.Type.CREATED)); + fileSystem.flushContentChanges(); + + Assertions.assertThat(fileSystem.getMetaData().byPath("old/page.md")).isEmpty(); + Assertions.assertThat(fileSystem.getMetaData().byPath("old/nested/child.md")).isEmpty(); + Assertions.assertThat(fileSystem.getMetaData().byPath("new/page.md")).isPresent(); + Assertions.assertThat(fileSystem.getMetaData().byPath("new/nested/child.md")).isPresent(); + Assertions.assertThat(fileSystem.getMetaData().byPath("unaffected/page.md")).isPresent(); + Assertions.assertThat(fileSystem.getMetaData().byUrl("/stable-page")) + .get() + .extracting(node -> node.path()) + .isEqualTo("new/page.md"); + } finally { + fileSystem.shutdown(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void duplicateWatcherAndExplicitEventsAreProcessedAsOneBatch() throws Exception { + var content = tempDirectory.resolve("content"); + Files.createDirectories(content); + var files = new Path[] { + content.resolve("index.asection.bla.md"), + content.resolve("index.asection.test.md"), + content.resolve("index.asection.test1.md"), + content.resolve("index.asection.other.md") + }; + for (var file : files) { + writePage(file, "/page"); + } + + var parseCount = new AtomicInteger(); + var eventBus = Mockito.mock(EventBus.class); + var fileSystem = new FileSystem("test-site", tempDirectory, eventBus, file -> { + parseCount.incrementAndGet(); + try { + return new Yaml().load(Files.readString(file)); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + + try { + fileSystem.init(); + var listenerCaptor = org.mockito.ArgumentCaptor.forClass(EventListener.class); + Mockito.verify(eventBus).register( + Mockito.eq(ReIndexContentMetaDataEvent.class), listenerCaptor.capture()); + var reindexListener = (EventListener) listenerCaptor.getValue(); + parseCount.set(0); + Mockito.clearInvocations(eventBus); + + for (var file : files) { + var uri = content.relativize(file).toString().replace('\\', '/'); + reindexListener.consum(new ReIndexContentMetaDataEvent(uri)); + fileSystem.handleContentEvent(new FileEvent(file.toFile(), FileEvent.Type.MODIFIED)); + fileSystem.handleContentEvent(new FileEvent(file.toFile(), FileEvent.Type.MODIFIED)); + } + fileSystem.flushContentChanges(); + + Assertions.assertThat(parseCount).hasValue(4); + Mockito.verify(eventBus, Mockito.times(4)).publish(Mockito.any(ContentChangedEvent.class)); + Mockito.verify(eventBus, Mockito.times(1)).publish(Mockito.any(InvalidateContentCacheEvent.class)); + } finally { + fileSystem.shutdown(); + } + } + + private static void writePage(Path path, String url) throws Exception { + Files.writeString(path, "status: published%n%s: %s%n".formatted(Constants.MetaFields.URL, url)); + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java index 226f261cd..df6cd77e1 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/PresistentFileSystemTest.java @@ -61,7 +61,7 @@ static void setup() throws IOException { throw new RuntimeException(e); } }); - fileSystem.init(MetaData.Type.PERSISTENT); + fileSystem.init(); content = new FileContent(fileSystem); } @@ -282,4 +282,27 @@ public void test_searchByTitle_blankInputMatchesAllPages() { Assertions.assertThat(nodes).hasSize(3); } + + @Test + public void test_byUrl() throws IOException { + var node = content.byUrl("/"); + + Assertions.assertThat(node).isPresent(); + Assertions.assertThat(node.get().uri()).isEqualTo("index.md"); + + node = content.byUrl("/test/test1"); + + Assertions.assertThat(node).isPresent(); + Assertions.assertThat(node.get().uri()).isEqualTo("test/test1.md"); + } + + @Test + public void test_listSectionEntriesUsesPagePath() { + var page = fileSystem.contentBase().resolve("index.md"); + + Assertions.assertThat(content.listSectionEntries(page)) + .singleElement() + .extracting(node -> node.path()) + .isEqualTo("index.slot.item.md"); + } } diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataIncrementalDeleteTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataIncrementalDeleteTest.java new file mode 100644 index 000000000..35f8cebf0 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataIncrementalDeleteTest.java @@ -0,0 +1,97 @@ +package com.condation.cms.filesystem.metadata.persistent; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class PersistentMetaDataIncrementalDeleteTest { + + private static final Map PUBLISHED = Map.of(Constants.MetaFields.STATUS, "published"); + + @TempDir + Path tempDirectory; + + @Test + public void removeDirectoryOnlyRemovesTheSelectedSubtree() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.createDirectory("archive"); + metadata.createDirectory("archive/nested"); + metadata.createDirectory("archive-other"); + metadata.addFile("archive/page.md", page("/old-page"), LocalDate.now()); + metadata.addFile("archive/page.hero.md", PUBLISHED, LocalDate.now()); + metadata.addFile("archive/nested/child.md", page("/old-child"), LocalDate.now()); + metadata.addFile("archive-other/page.md", page("/still-there"), LocalDate.now()); + + metadata.removePath("archive"); + + Assertions.assertThat(metadata.byPath("archive/page.md")).isEmpty(); + Assertions.assertThat(metadata.byPath("archive/nested/child.md")).isEmpty(); + Assertions.assertThat(metadata.byUrl("/old-page")).isEmpty(); + Assertions.assertThat(metadata.byUrl("/old-child")).isEmpty(); + Assertions.assertThat(metadata.listSectionEntries("archive/page.md")).isEmpty(); + Assertions.assertThat(metadata.findFolder("archive")).isEmpty(); + + Assertions.assertThat(metadata.byPath("archive-other/page.md")).isPresent(); + Assertions.assertThat(metadata.byUrl("/still-there")).isPresent(); + Assertions.assertThat(metadata.findFolder("archive-other")).isPresent(); + Assertions.assertThat(metadata.query((node, count) -> node.path()).get()) + .containsExactly("archive-other/page.md"); + } + } + + @Test + public void directoryRenameCanBeProcessedAsDeleteAndCreateOfSubtree() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.createDirectory("old"); + metadata.createDirectory("old/nested"); + metadata.addFile("old/page.md", PUBLISHED, LocalDate.now()); + metadata.addFile("old/nested/child.md", PUBLISHED, LocalDate.now()); + + metadata.removePath("old"); + metadata.createDirectory("new"); + metadata.createDirectory("new/nested"); + metadata.addFile("new/page.md", PUBLISHED, LocalDate.now()); + metadata.addFile("new/nested/child.md", PUBLISHED, LocalDate.now()); + + Assertions.assertThat(metadata.getNodes()).containsOnlyKeys("new/page.md", "new/nested/child.md"); + Assertions.assertThat(metadata.byUrl("/old/page")).isEmpty(); + Assertions.assertThat(metadata.byUrl("/new/page")).isPresent(); + Assertions.assertThat(metadata.findFolder("old")).isEmpty(); + Assertions.assertThat(metadata.findFolder("new/nested")).isPresent(); + } + } + + private static Map page(String url) { + return Map.of( + Constants.MetaFields.STATUS, "published", + Constants.MetaFields.URL, url + ); + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataSectionIndexTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataSectionIndexTest.java new file mode 100644 index 000000000..8778ba607 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataSectionIndexTest.java @@ -0,0 +1,82 @@ +package com.condation.cms.filesystem.metadata.persistent; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class PersistentMetaDataSectionIndexTest { + + private static final Map PUBLISHED = Map.of(Constants.MetaFields.STATUS, "published"); + + @TempDir + Path tempDirectory; + + @Test + public void sectionsAreFoundByPagePathWhenPageHasCustomUrl() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.addFile("products/article.md", Map.of(Constants.MetaFields.URL, "/shop/summer"), LocalDate.now()); + metadata.addFile("products/article.hero.md", PUBLISHED, LocalDate.now()); + metadata.addFile("products/article.features.first.md", PUBLISHED, LocalDate.now()); + + Assertions.assertThat(metadata.listSectionEntries("products/article.md")) + .extracting(node -> node.path()) + .containsExactly( + "products/article.features.first.md", + "products/article.hero.md" + ); + } + } + + @Test + public void sectionsOfOtherPagesAndFoldersAreNotReturned() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.addFile("products/article.hero.md", PUBLISHED, LocalDate.now()); + metadata.addFile("products/other.hero.md", PUBLISHED, LocalDate.now()); + metadata.addFile("archive/article.hero.md", PUBLISHED, LocalDate.now()); + + Assertions.assertThat(metadata.listSectionEntries("products/article.md")) + .singleElement() + .extracting(node -> node.path()) + .isEqualTo("products/article.hero.md"); + } + } + + @Test + public void deletedSectionIsRemovedFromIndex() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.addFile("products/article.hero.md", PUBLISHED, LocalDate.now()); + + metadata.removeFile("products/article.hero.md"); + + Assertions.assertThat(metadata.listSectionEntries("products/article.md")).isEmpty(); + } + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataUrlTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataUrlTest.java new file mode 100644 index 000000000..0f064ffa0 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaDataUrlTest.java @@ -0,0 +1,85 @@ +package com.condation.cms.filesystem.metadata.persistent; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class PersistentMetaDataUrlTest { + + @TempDir + Path tempDirectory; + + @Test + public void customUrlIsNormalizedAndOldMappingIsRemoved() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.addFile("content/page.md", Map.of(Constants.MetaFields.URL, "shop//item/"), LocalDate.now()); + + var node = metadata.byUrl("/shop/item/"); + Assertions.assertThat(node).isPresent(); + Assertions.assertThat(node.get().path()).isEqualTo("content/page.md"); + Assertions.assertThat(node.get().url()).isEqualTo("/shop/item"); + + metadata.addFile("content/page.md", Map.of(Constants.MetaFields.URL, "/new-item"), LocalDate.now()); + + Assertions.assertThat(metadata.byUrl("/shop/item")).isEmpty(); + Assertions.assertThat(metadata.byUrl("/new-item")).isPresent(); + } + } + + @Test + public void lastDuplicateUrlWinsAndRemainsWhenLoserChangesUrl() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.addFile("first.md", Map.of(Constants.MetaFields.URL, "/duplicate"), LocalDate.now()); + metadata.addFile("second.md", Map.of(Constants.MetaFields.URL, "/duplicate"), LocalDate.now()); + + Assertions.assertThat(metadata.byUrl("/duplicate")).get().extracting(node -> node.path()).isEqualTo("second.md"); + + metadata.addFile("first.md", Map.of(Constants.MetaFields.URL, "/other"), LocalDate.now()); + + Assertions.assertThat(metadata.byUrl("/duplicate")).get().extracting(node -> node.path()).isEqualTo("second.md"); + } + } + + @Test + public void previousDuplicateWinsWhenCurrentWinnerIsDeleted() throws Exception { + try (var metadata = new PersistentMetaData(tempDirectory)) { + metadata.open(); + metadata.addFile("first.md", Map.of(Constants.MetaFields.URL, "/duplicate"), LocalDate.now()); + metadata.addFile("second.md", Map.of(Constants.MetaFields.URL, "/duplicate"), LocalDate.now()); + + metadata.removeFile("second.md"); + + Assertions.assertThat(metadata.byUrl("/duplicate")) + .get() + .extracting(node -> node.path()) + .isEqualTo("first.md"); + } + } +} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/query/QueryPerfTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/query/QueryPerfTest.java deleted file mode 100644 index e3e274d7a..000000000 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/query/QueryPerfTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.condation.cms.filesystem.query; - -/*- - * #%L - * CMS FileSystem - * %% - * Copyright (C) 2023 - 2026 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * #L% - */ - -import com.condation.cms.api.Constants; -import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.workflow.DefaultWFStatusProvider; -import com.condation.cms.filesystem.metadata.memory.MemoryQuery; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.RepeatedTest; - -/** - * - * @author t.marx - */ -public class QueryPerfTest { - - private static Collection nodes; - - private static int COUNT = 1000; - - @BeforeAll - public static void setup() { - System.out.println("build elements"); - nodes = new ArrayList<>(); - for (int i = 0; i < COUNT; i++) { - var node = new ContentNode("/test" + i, "test2.md", Map.of( - "article", Map.of("featured", (i % 2 == 0 ? true : false)), - "index", i, - Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED, - Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now().minus(1, ChronoUnit.DAYS)), - "tags", List.of("one", "two")) - ); - nodes.add(node); - } - } - - protected MemoryQuery createQuery() { - var query = new MemoryQuery<>(nodes, (node, i) -> node); - - return query; - } - - @Nested - @DisplayName("testing without secondary index") - class NoIndex { - - @RepeatedTest(10) - public void test_no_index() { - System.out.println("run tests without index"); - - MemoryQuery query = createQuery(); - var nodes = query.where("article.featured", true).get(); - Assertions.assertThat(nodes).hasSize(COUNT / 2); - -// query = createQuery(false); -// nodes = query.where("article.featured", "=", true).get(); -// Assertions.assertThat(nodes).hasSize(COUNT / 2); - -// query = createQuery(false); -// nodes = query.where("article.featured", false).get(); -// Assertions.assertThat(nodes).hasSize(COUNT / 2); - -// query = createQuery(false); -// nodes = query.where("article.featured", "=", false).get(); -// Assertions.assertThat(nodes).hasSize(COUNT / 2); - } - } - - @Nested - @DisplayName("testing with secondary index") - class WithIndex { - - @RepeatedTest(1) - public void test_use_index() { - System.out.println("run tests with index"); - - MemoryQuery query = createQuery(); - var nodes = query.where("article.featured", true).get(); - Assertions.assertThat(nodes).hasSize(COUNT / 2); - -// query = createQuery(true); -// nodes = query.where("article.featured", "=", true).get(); -// Assertions.assertThat(nodes).hasSize(COUNT / 2); - -// query = createQuery(true); -// nodes = query.where("article.featured", false).get(); -// Assertions.assertThat(nodes).hasSize(COUNT / 2); - -// query = createQuery(true); -// nodes = query.where("article.featured", "=", false).get(); -// Assertions.assertThat(nodes).hasSize(COUNT / 2); - } - } - -} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/query/QueryTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/query/QueryTest.java deleted file mode 100644 index 2a549f335..000000000 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/query/QueryTest.java +++ /dev/null @@ -1,305 +0,0 @@ -package com.condation.cms.filesystem.query; - -/*- - * #%L - * CMS FileSystem - * %% - * Copyright (C) 2023 - 2026 CondationCMS - * %% - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * #L% - */ - -import com.condation.cms.api.Constants; -import com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.workflow.DefaultWFStatusProvider; -import com.condation.cms.filesystem.metadata.memory.MemoryQuery; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -/** - * - * @author t.marx - */ -public class QueryTest { - - private static Collection nodes; - - @BeforeAll - public static void setup() { - nodes = new ArrayList<>(); - ContentNode node = new ContentNode("/", "index.md", Map.of( - "featured", true, - Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED, - Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now().plus(1, ChronoUnit.DAYS)))); - nodes.add(node); - node = new ContentNode("/2", "index2.md", Map.of( - "featured", true, - Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED, - Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now().minus(1, ChronoUnit.DAYS)))); - nodes.add(node); - node = new ContentNode("/test1", "test1.md", Map.of( - "featured", false, - "index", 1, - Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED, - Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now().minus(1, ChronoUnit.DAYS)), - "tags", List.of("three", "four") - )); - nodes.add(node); - node = new ContentNode("/test2", "test2.md", Map.of( - "featured", false, - "index", 2, - Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED, - Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now().minus(1, ChronoUnit.DAYS)), - "tags", List.of("one", "two")) - ); - nodes.add(node); - - node = new ContentNode("/json", "test-json.md", Map.of( - "featured", false, - "index", 2, - Constants.MetaFields.STATUS, DefaultWFStatusProvider.STATUS_PUBLISHED, - Constants.MetaFields.PUBLISH_DATE, Date.from(Instant.now().minus(1, ChronoUnit.DAYS)), - "tags", List.of("one", "two"), - "content", Map.of("type", Constants.ContentTypes.JSON)) - ); - nodes.add(node); - } - - protected MemoryQuery createQuery() { - var query = new MemoryQuery<>(nodes, (node, i) -> node); - query.addCustomOperators("none", (node_value, value) -> false); - - return query; - } - - @Test - public void test_custom_operator() { - MemoryQuery query = createQuery(); - var nodes = query.where("featured", "none", true).get(); - Assertions.assertThat(nodes).hasSize(0); - } - - @Test - public void test_eq() { - MemoryQuery query = createQuery(); - var nodes = query.where("featured", true).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.getFirst().uri()).isEqualTo("/2"); - - query = createQuery(); - nodes = query.where("featured", "=", true).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.getFirst().uri()).isEqualTo("/2"); - } - - @Test - public void test_not() { - MemoryQuery query = createQuery(); - var nodes = query.where("featured", "!=", true).get(); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test1", "/test2"); - } - - @Test - public void test_data() { - MemoryQuery query = createQuery(); - var nodes = query.get(); - Assertions.assertThat(nodes).hasSize(3); - } - - @Test - public void test_sort_asc() { - MemoryQuery query = createQuery(); - var nodes = query.where("featured", false).orderby("index").asc().get(); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test1"); - Assertions.assertThat(nodes.get(1).uri()).isEqualTo("/test2"); - } - - @Test - public void test_sort_desc() { - MemoryQuery query = createQuery(); - var nodes = query.where("featured", false).orderby("index").desc().get(); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test2"); - Assertions.assertThat(nodes.get(1).uri()).isEqualTo("/test1"); - } - - @Test - public void test_offset_0() { - MemoryQuery query = createQuery(); - var page = query.where("featured", false).orderby("index").desc().page(1, 1); - Assertions.assertThat(page.getItems()).hasSize(1); - Assertions.assertThat(page.getItems().get(0).uri()).isEqualTo("/test2"); - } - - @Test - public void test_offset_1() { - MemoryQuery query = createQuery(); - var page = query.where("featured", false).orderby("index").desc().page(2, 1); - Assertions.assertThat(page.getItems()).hasSize(1); - Assertions.assertThat(page.getItems().getFirst().uri()).isEqualTo("/test1"); - } - - @Test - public void test_contains() { - MemoryQuery query = createQuery(); - var nodes = query.whereContains("tags", "one").get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test2"); - } - - @Test - public void test_contains_operator() { - MemoryQuery query = createQuery(); - var nodes = query.where("tags", "contains", "one").get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test2"); - } - - @Test - public void test_not_contains() { - MemoryQuery query = createQuery(); - var nodes = query.whereNotContains("tags", "one").get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test1"); - } - - @Test - public void test_not_contains_operator() { - MemoryQuery query = createQuery(); - var nodes = query.where("tags", "not contains", "one").get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test1"); - } - - @Test - public void test_gt() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", ">", 1).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test2"); - } - - @Test - public void test_gte() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", ">=", 2).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test2"); - } - - @Test - public void test_lt() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", "<", 2).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test1"); - } - - @Test - public void test_lte() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", "<=", 1).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/test1"); - } - - @Test - public void test_group_by() { - MemoryQuery query = createQuery(); - var nodes = query.groupby("featured"); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes).containsKeys(true, false); - Assertions.assertThat(nodes.get(true).stream().map(ContentNode::uri).toList()).contains("/", "/2"); - Assertions.assertThat(nodes.get(false).stream().map(ContentNode::uri).toList()).contains("/test1", "/test2"); - } - - @Test - public void test_in() { - MemoryQuery query = createQuery(); - var nodes = query.whereIn("index", 1, 2).get(); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test1", "/test2"); - } - - @Test - public void test_in_operator() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", "in", List.of(1, 2)).get(); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test1", "/test2"); - - query = createQuery(); - nodes = query.where("index", "in", List.of(1, 2).toArray()).get(); - Assertions.assertThat(nodes).hasSize(2); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test1", "/test2"); - } - - @Test - public void test_not_in() { - MemoryQuery query = createQuery(); - var nodes = query.whereNotIn("index", 1, 3, 4).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test2"); - } - - @Test - public void test_not_in_operator() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", "not in", List.of(1, 3, 4)).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test2"); - - query = createQuery(); - nodes = query.where("index", "not in", List.of(1, 3, 4).toArray()).get(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.stream().map(ContentNode::uri).toList()).contains("/test2"); - } - - @Test - public void test_json() { - MemoryQuery query = createQuery(); - var nodes = query.json().page(1, 1).getItems(); - Assertions.assertThat(nodes).hasSize(1); - Assertions.assertThat(nodes.get(0).uri()).isEqualTo("/json"); - } - - @Test - public void test_where_not_null() { - MemoryQuery query = createQuery(); - var nodes = query.where("index", "!=", null).get(); - Assertions.assertThat(nodes).hasSize(2); - - query = createQuery(); - query.where("index", "=", null).get(); - Assertions.assertThat(nodes).hasSize(2); - } - - @Test - public void test_where_exists() { - MemoryQuery query = createQuery(); - var nodes = query.whereExists("index").get(); - Assertions.assertThat(nodes).hasSize(2); - } -} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/taxonomy/PersistentFileTaxonomiesTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/taxonomy/PersistentFileTaxonomiesTest.java index c7489183c..75800f553 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/taxonomy/PersistentFileTaxonomiesTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/taxonomy/PersistentFileTaxonomiesTest.java @@ -72,7 +72,7 @@ public static void setup() throws IOException { throw new RuntimeException(e); } }); - fileSystem.init(MetaData.Type.PERSISTENT); + fileSystem.init(); taxonomies = new FileTaxonomies(config, new FileContent(fileSystem)); } diff --git a/cms-server/src/main/java/com/condation/cms/cli/tools/CLIServerUtils.java b/cms-server/src/main/java/com/condation/cms/cli/tools/CLIServerUtils.java index 4eb4d8105..334700ebf 100644 --- a/cms-server/src/main/java/com/condation/cms/cli/tools/CLIServerUtils.java +++ b/cms-server/src/main/java/com/condation/cms/cli/tools/CLIServerUtils.java @@ -24,7 +24,11 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.utils.ServerUtil; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; @@ -39,15 +43,41 @@ */ public class CLIServerUtils { + private static final String PID_PROPERTY = "pid"; + private static final String STARTED_AT_PROPERTY = "startedAt"; private static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneOffset.UTC); - public static Optional getCMSProcess() throws Exception { + public static Optional getCMSProcess() throws IOException { var pidFile = ServerUtil.getPath(Constants.PID_FILE); if (!Files.exists(pidFile)) { return Optional.empty(); } - var pid = Files.readString(pidFile); - return ProcessHandle.of(Long.parseLong(pid.trim())); + + var properties = new Properties(); + try (var reader = Files.newBufferedReader(pidFile, StandardCharsets.UTF_8)) { + properties.load(reader); + } + + final long pid; + final long expectedStart; + try { + pid = Long.parseLong(properties.getProperty(PID_PROPERTY)); + expectedStart = Long.parseLong(properties.getProperty(STARTED_AT_PROPERTY)); + } catch (NumberFormatException | NullPointerException ex) { + return removeStalePidFile(pidFile); + } + + var process = ProcessHandle.of(pid); + if (process.isEmpty() || !process.get().isAlive()) { + return removeStalePidFile(pidFile); + } + + var actualStart = process.get().info().startInstant().map(Instant::toEpochMilli); + if (actualStart.isEmpty() || actualStart.get() != expectedStart) { + return removeStalePidFile(pidFile); + } + + return process; } private static Optional getStartTime() { @@ -87,8 +117,27 @@ public static String getUptime() { } public static void writePidFile() throws IOException { - Files.deleteIfExists(ServerUtil.getPath(Constants.PID_FILE)); - Files.writeString(ServerUtil.getPath(Constants.PID_FILE), String.valueOf(ProcessHandle.current().pid())); + var process = ProcessHandle.current(); + var startedAt = process.info().startInstant() + .orElseThrow(() -> new IOException("process start time is not available")); + + var content = PID_PROPERTY + "=" + process.pid() + System.lineSeparator() + + STARTED_AT_PROPERTY + "=" + startedAt.toEpochMilli() + System.lineSeparator(); + var pidFile = ServerUtil.getPath(Constants.PID_FILE); + var temporaryPidFile = pidFile.resolveSibling(pidFile.getFileName() + ".tmp"); + + Files.writeString(temporaryPidFile, content, StandardCharsets.UTF_8); + try { + Files.move(temporaryPidFile, pidFile, + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporaryPidFile, pidFile, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static Optional removeStalePidFile(Path pidFile) throws IOException { + Files.deleteIfExists(pidFile); + return Optional.empty(); } public static Semver getVersion() { 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 9c500142b..4c13ad801 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 @@ -290,11 +290,7 @@ public DB fileDb(SiteProperties site, DefaultContentParser contentParser, Config throw new RuntimeException(ioe); } }, configuration); - if ("MEMORY".equals(site.queryIndexMode())) { - db.init(MetaData.Type.MEMORY); - } else { - db.init(MetaData.Type.PERSISTENT); - } + db.init(); return db; } diff --git a/cms-server/src/test/java/com/condation/cms/cli/tools/CLIServerUtilsTest.java b/cms-server/src/test/java/com/condation/cms/cli/tools/CLIServerUtilsTest.java new file mode 100644 index 000000000..b39b14d24 --- /dev/null +++ b/cms-server/src/test/java/com/condation/cms/cli/tools/CLIServerUtilsTest.java @@ -0,0 +1,108 @@ +package com.condation.cms.cli.tools; + +/*- + * #%L + * CMS Server + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import com.condation.cms.api.Constants; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Properties; +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 CLIServerUtilsTest { + + @TempDir + Path serverHome; + + private String previousServerHome; + + @BeforeEach + void configureServerHome() { + previousServerHome = System.getProperty("cms.home"); + System.setProperty("cms.home", serverHome.toString()); + } + + @AfterEach + void restoreServerHome() { + if (previousServerHome == null) { + System.clearProperty("cms.home"); + } else { + System.setProperty("cms.home", previousServerHome); + } + } + + @Test + void writtenPidFileIdentifiesCurrentProcess() throws Exception { + CLIServerUtils.writePidFile(); + + Assertions.assertThat(CLIServerUtils.getCMSProcess()) + .contains(ProcessHandle.current()); + + var properties = readPidFile(); + Assertions.assertThat(properties.getProperty("pid")) + .isEqualTo(Long.toString(ProcessHandle.current().pid())); + Assertions.assertThat(properties.getProperty("startedAt")) + .isEqualTo(Long.toString(currentProcessStart())); + } + + @Test + void reusedPidIsNotRecognizedAsCmsProcess() throws Exception { + writePidFile(ProcessHandle.current().pid(), currentProcessStart() - 1); + + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isEmpty(); + Assertions.assertThat(pidFile()).doesNotExist(); + } + + @Test + void legacyPidFileIsRemovedBecauseItCannotBeVerified() throws Exception { + Files.writeString(pidFile(), Long.toString(ProcessHandle.current().pid())); + + Assertions.assertThat(CLIServerUtils.getCMSProcess()).isEmpty(); + Assertions.assertThat(pidFile()).doesNotExist(); + } + + private long currentProcessStart() { + return ProcessHandle.current().info().startInstant() + .map(Instant::toEpochMilli) + .orElseThrow(); + } + + private void writePidFile(long pid, long startedAt) throws Exception { + Files.writeString(pidFile(), "pid=" + pid + System.lineSeparator() + + "startedAt=" + startedAt + System.lineSeparator()); + } + + private Properties readPidFile() throws Exception { + var properties = new Properties(); + try (var reader = Files.newBufferedReader(pidFile())) { + properties.load(reader); + } + return properties; + } + + private Path pidFile() { + return serverHome.resolve(Constants.PID_FILE); + } +} diff --git a/integration-tests/src/test/java/com/condation/cms/utils/NodeUtilNGTest.java b/integration-tests/src/test/java/com/condation/cms/utils/NodeUtilNGTest.java index bbfe021a9..3861ff0e6 100644 --- a/integration-tests/src/test/java/com/condation/cms/utils/NodeUtilNGTest.java +++ b/integration-tests/src/test/java/com/condation/cms/utils/NodeUtilNGTest.java @@ -41,7 +41,7 @@ public NodeUtilNGTest() { @Test public void getName_returns_default_name() { - ContentNode node = new ContentNode("/", "index", Map.of()); + ContentNode node = new ContentNode("/", "/", "index", Map.of()); var name = NodeUtil.getName(node); @@ -50,7 +50,7 @@ public void getName_returns_default_name() { @Test public void getName_returns_title() { - ContentNode node = new ContentNode("/", "index", Map.of( + ContentNode node = new ContentNode("/", "/", "index", Map.of( "title", "The Title" )); @@ -61,7 +61,7 @@ public void getName_returns_title() { @Test public void getName_returns_title_if_emtpy_menu() { - ContentNode node = new ContentNode("/", "index", Map.of( + ContentNode node = new ContentNode("/", "/", "index", Map.of( "title", "The Title", "menu", Map.of( ) @@ -74,7 +74,7 @@ public void getName_returns_title_if_emtpy_menu() { @Test public void getName_returns_menu_title() { - ContentNode node = new ContentNode("/", "index", Map.of( + ContentNode node = new ContentNode("/", "/", "index", Map.of( "title", "The Title", "menu", Map.of( "title", "Menu title" @@ -88,7 +88,7 @@ public void getName_returns_menu_title() { @Test public void getMenuPosition() { - ContentNode node = new ContentNode("/", "index", Map.of( + ContentNode node = new ContentNode("/", "/", "index", Map.of( "menu", Map.of( "position", 1.5 ) @@ -99,7 +99,7 @@ public void getMenuPosition() { @Test public void getDefaultMenuPosition() { - ContentNode node = new ContentNode("/", "index", Map.of( + ContentNode node = new ContentNode("/", "/", "index", Map.of( "menu", Map.of() )); var position = NodeUtil.getMenuPosition(node); @@ -108,7 +108,7 @@ public void getDefaultMenuPosition() { @Test public void getDefaultMenuPositionNoMenuMap() { - ContentNode node = new ContentNode("/", "index", Map.of()); + ContentNode node = new ContentNode("/", "/", "index", Map.of()); var position = NodeUtil.getMenuPosition(node); Assertions.assertThat(position).isEqualTo(Constants.DEFAULT_MENU_POSITION); } 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 748e9dc1a..6a282202a 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 @@ -70,6 +70,7 @@ private Optional getContentNode(String uri) { return Optional.of( new ContentNode( node.get().uri(), + node.get().url(), node.get().name(), node.get().data(), node.get().directory(), 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 776aced8a..2404185c6 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 @@ -172,7 +172,7 @@ public void testSearchPages_returnsRewrittenUriAndTitle() throws RPCException { Map meta = new HashMap<>(); meta.put(Constants.MetaFields.TITLE, "Superman Returns"); - ContentNode node = new ContentNode("test/test1.md", "test1.md", meta); + ContentNode node = new ContentNode("test/test1.md", "/test/test1", "test1.md", meta); when(content.searchByTitle("superman")).thenReturn(List.of(node)); when(contentBase.resolve("test/test1.md")).thenReturn(contentFile); @@ -204,7 +204,7 @@ public void testSearchPages_missingTitle_fallsBackToEmptyString() throws RPCExce when(db.getContent()).thenReturn(content); when(moduleContext.get(SitePropertiesFeature.class)).thenReturn(new SitePropertiesFeature(siteProperties)); - ContentNode node = new ContentNode("test/test2.md", "test2.md", new HashMap<>()); + ContentNode node = new ContentNode("test/test2.md", "/test/test2", "test2.md", new HashMap<>()); when(content.searchByTitle("")).thenReturn(List.of(node)); when(contentBase.resolve("test/test2.md")).thenReturn(contentFile); diff --git a/test-server/hosts/demo/content/this-is-a-new-page/index.md b/test-server/hosts/demo/content/this-is-a-new-page/index.md index cb16cbd12..513324611 100644 --- a/test-server/hosts/demo/content/this-is-a-new-page/index.md +++ b/test-server/hosts/demo/content/this-is-a-new-page/index.md @@ -1,4 +1,5 @@ --- +url: /total-other-page template: start.html createdAt: 2025-07-24T08:37:49.711Z createdBy: ''