diff --git a/README.md b/README.md index 1385a0028..2dfebf9be 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ +[![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=CondationCMS_cms-server&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=CondationCMS_cms-server) + +![Maven Central Version](https://img.shields.io/maven-central/v/com.condation.cms/cms-api) + +![NPM Version](https://img.shields.io/npm/v/condation-cms-ui) + + # CondationCMS + CondationCMS is a fast, flexible, and developer-friendly content management system built with Java. Content is stored in Markdown files with YAML front matter instead of a database. The file and directory structure remains transparent, easy to version with Git, and directly represents the structure of the website. @@ -89,6 +97,15 @@ http://localhost:2020/manager/login Sign in with the Manager user created in the previous step. +### Rebuild a site's metadata indexes + +With the server running, rebuild the content and collection metadata indexes +for a site after changing its index configuration: + +```bash +./server.sh host reindex +``` + ## Documentation Detailed information about installing, configuring, using, and extending CondationCMS is available in the official [CondationCMS documentation](https://condation.com/documentation). @@ -99,4 +116,4 @@ CondationCMS is licensed under the [GNU Affero General Public License v3.0](./LI Modules, plugins, themes, and extensions that use only the public and documented extension APIs may be distributed under a different license, including proprietary or commercial licenses. -See [LICENSE-EXCEPTION.md](./LICENSE-EXCEPTION.md) for details. \ No newline at end of file +See [LICENSE-EXCEPTION.md](./LICENSE-EXCEPTION.md) for details. 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 1efead7b5..20faf8780 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 @@ -75,6 +75,7 @@ public static class MetaFields { public static class Folders { public static final String CONTENT = "content/"; + public static final String COLLECTIONS = "collections/"; public static final String TEMPLATES = "templates/"; public static final String ASSETS = "assets/"; public static final String EXTENSIONS = "extensions/"; diff --git a/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionConfiguration.java b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionConfiguration.java new file mode 100644 index 000000000..a0a841dd1 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionConfiguration.java @@ -0,0 +1,52 @@ +package com.condation.cms.api.configuration.configs; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.configuration.Config; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Reloadable, site-scoped collection definitions. + */ +public class CollectionConfiguration implements Config { + + private volatile Map collections; + + public CollectionConfiguration(Map collections) { + replaceCollections(collections); + } + + /** Atomically replaces the complete set of collection definitions. */ + public void replaceCollections(Map collections) { + this.collections = Map.copyOf(Objects.requireNonNull(collections)); + } + + public Optional collection(String name) { + return Optional.ofNullable(collections.get(name)); + } + + public Map collections() { + return collections; + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionDefinition.java b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionDefinition.java new file mode 100644 index 000000000..75494ac39 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionDefinition.java @@ -0,0 +1,60 @@ +package com.condation.cms.api.configuration.configs; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Configuration of one named collection. + */ +public record CollectionDefinition(String name, String site, CollectionDetailConfiguration detail) { + + private static final Pattern VALID_NAME_PATTERN = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9_-]*"); + + public CollectionDefinition { + Objects.requireNonNull(name, "collection name must not be null"); + if (!VALID_NAME_PATTERN.matcher(name).matches()) { + throw new IllegalArgumentException("invalid collection name: " + name); + } + if (site != null) { + site = site.trim(); + if (site.isEmpty()) { + throw new IllegalArgumentException("collection site must not be blank"); + } + } + } + + public CollectionDefinition(String name, CollectionDetailConfiguration detail) { + this(name, null, detail); + } + + public Optional detailPage() { + return Optional.ofNullable(detail); + } + + /** Site whose collection data should be used, or empty for a local collection. */ + public Optional sourceSite() { + return Optional.ofNullable(site); + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionDetailConfiguration.java b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionDetailConfiguration.java new file mode 100644 index 000000000..69e5e2012 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/configuration/configs/CollectionDetailConfiguration.java @@ -0,0 +1,125 @@ +package com.condation.cms.api.configuration.configs; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Route and template used for collection detail pages. + */ +public record CollectionDetailConfiguration( + String route, + String template, + Map> mappings) { + + private static final Pattern PARAMETER = Pattern.compile( + "\\{([a-zA-Z][a-zA-Z0-9_.-]*)(?::([^{}]*))?}"); + + public CollectionDetailConfiguration { + Objects.requireNonNull(route, "collection detail route must not be null"); + Objects.requireNonNull(template, "collection detail template must not be null"); + + route = normalizeRoute(route); + template = template.trim(); + if (template.isEmpty()) { + throw new IllegalArgumentException("collection detail template must not be blank"); + } + + var copiedMappings = new LinkedHashMap>(); + Objects.requireNonNull(mappings, "collection detail mappings must not be null") + .forEach((field, values) -> copiedMappings.put(field, Map.copyOf(values))); + mappings = Map.copyOf(copiedMappings); + + var parameters = parameters(route); + if (parameters.isEmpty()) { + throw new IllegalArgumentException("collection detail route must contain at least one parameter"); + } + var matcher = PARAMETER.matcher(route); + var formattedFields = new HashSet(); + while (matcher.find()) { + var format = matcher.group(2); + if (format == null) { + continue; + } + if (format.isBlank()) { + throw new IllegalArgumentException("collection detail date format must not be blank"); + } + if (format.contains("/")) { + throw new IllegalArgumentException("collection detail date format must not contain '/': " + format); + } + DateTimeFormatter.ofPattern(format); + formattedFields.add(matcher.group(1)); + } + for (var field : mappings.keySet()) { + if (!parameters.contains(field)) { + throw new IllegalArgumentException("mapping references no route parameter: " + field); + } + if (formattedFields.contains(field)) { + throw new IllegalArgumentException("route parameter cannot have both a format and a mapping: " + field); + } + } + } + + public CollectionDetailConfiguration(String route, String template) { + this(route, template, Map.of()); + } + + public String parameter() { + return parameters().getFirst(); + } + + public List parameters() { + return parameters(route); + } + + public int parameterOccurrences() { + return (int) PARAMETER.matcher(route).results().count(); + } + + public boolean hasFormats() { + return PARAMETER.matcher(route).results().anyMatch(match -> match.group(2) != null); + } + + private static List parameters(String route) { + return PARAMETER.matcher(route).results() + .map(match -> match.group(1)) + .distinct() + .toList(); + } + + private static String normalizeRoute(String value) { + var normalized = value.trim(); + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java b/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java index e2d700d6f..a654998f9 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/ContentQuery.java @@ -22,6 +22,7 @@ */ +import com.condation.cms.api.Constants; import java.util.List; import java.util.Map; @@ -90,6 +91,17 @@ ContentQuery within( ContentQuery expression(final String expressions); + /** + * Restricts the query to items whose title matches the input. + * + * Implementations with a full-text title index should override this method. + * The default keeps existing query implementations source compatible and uses + * their regular title-field matching. + */ + default ContentQuery searchByTitle(final String input) { + return where(Constants.MetaFields.TITLE, input); + } + public static interface Sort { public ContentQuery asc(); diff --git a/cms-api/src/main/java/com/condation/cms/api/db/CursorPage.java b/cms-api/src/main/java/com/condation/cms/api/db/CursorPage.java new file mode 100644 index 000000000..a11fd0acb --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/CursorPage.java @@ -0,0 +1,39 @@ +package com.condation.cms.api.db; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.List; + +/** + * One forward-only cursor page. The cursor is opaque and expires when the + * underlying index changes. + */ +public record CursorPage(List items, String nextCursor) { + + public CursorPage { + items = List.copyOf(items); + } + + public boolean hasNext() { + return nextCursor != null && !nextCursor.isBlank(); + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/DB.java b/cms-api/src/main/java/com/condation/cms/api/db/DB.java index 9e2d212b5..6956bfe84 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/DB.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/DB.java @@ -22,6 +22,7 @@ */ import com.condation.cms.api.db.taxonomy.Taxonomies; +import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.db.cms.ReadOnlyFileSystem; @@ -40,6 +41,8 @@ public interface DB extends AutoCloseable{ public ReadOnlyFileSystem getReadOnlyFileSystem(); public Content getContent(); + + public Collections getCollections(); public Taxonomies getTaxonomies(); } diff --git a/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java b/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java index ee970c893..26e215216 100644 --- a/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java +++ b/cms-api/src/main/java/com/condation/cms/api/db/DBFileSystem.java @@ -47,6 +47,8 @@ public interface DBFileSystem { ReadOnlyFile contentBase(); + ReadOnlyFile collectionsBase(); + ReadOnlyFile assetBase(); /** diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java new file mode 100644 index 000000000..2f3337290 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collection.java @@ -0,0 +1,40 @@ +package com.condation.cms.api.db.collection; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentQuery; +import java.util.Optional; + +/** + * A named, file-backed collection. + */ +public interface Collection { + + String name(); + + Optional item(String id); + + ContentQuery query(); + + /** Queries collection metadata without opening the Markdown body files. */ + ContentQuery metadataQuery(); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java new file mode 100644 index 000000000..454add884 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionCursorSupport.java @@ -0,0 +1,40 @@ +package com.condation.cms.api.db.collection; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.CursorPage; +import java.util.function.Consumer; + +/** + * Internal capability for infrastructure that needs cursor-based collection + * traversal. It is deliberately separate from {@link Collection} and + * {@link ContentQuery}, which are exposed to templates. + */ +public interface CollectionCursorSupport { + + CursorPage metadataCursorPage( + String collection, + String cursor, + long size, + Consumer> queryConfigurer); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItem.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItem.java new file mode 100644 index 000000000..45f84b9c8 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItem.java @@ -0,0 +1,36 @@ +package com.condation.cms.api.db.collection; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Map; + +/** + * A collection entry. The content is raw Markdown and is never rendered by + * the collection layer. + */ +public record CollectionItem( + String id, + String collection, + String path, + String content, + Map meta) { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItemId.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItemId.java new file mode 100644 index 000000000..42d55d14f --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItemId.java @@ -0,0 +1,44 @@ +package com.condation.cms.api.db.collection; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.regex.Pattern; + +/** Canonical validation rules for collection item identifiers. */ +public final class CollectionItemId { + + private static final Pattern VALID_ID = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9_.-]*"); + + private CollectionItemId() { + } + + public static boolean isValid(String id) { + return id != null && VALID_ID.matcher(id).matches(); + } + + public static String requireValid(String id) { + if (!isValid(id)) { + throw new IllegalArgumentException("invalid collection item id: " + id); + } + return id; + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItemMetadata.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItemMetadata.java new file mode 100644 index 000000000..9ee625926 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/CollectionItemMetadata.java @@ -0,0 +1,32 @@ +package com.condation.cms.api.db.collection; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Map; + +/** A collection entry without loading its Markdown body. */ +public record CollectionItemMetadata( + String id, + String collection, + String path, + Map meta) { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java new file mode 100644 index 000000000..ccfa0b753 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/db/collection/Collections.java @@ -0,0 +1,42 @@ +package com.condation.cms.api.db.collection; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Set; + +/** + * Site-scoped access to collections. + */ +public interface Collections { + + Collection collection(String name); + + Set names(); + + /** Returns whether a collection is stored by this site and may be modified here. */ + default boolean isLocal(String collection) { + return true; + } + + /** Re-indexes one item after a synchronous manager write, or removes a deleted item from the index. */ + void refresh(String collection, String id); +} diff --git a/cms-api/src/main/java/com/condation/cms/api/eventbus/events/ReloadCollectionsConfig.java b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/ReloadCollectionsConfig.java new file mode 100644 index 000000000..e249e6202 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/ReloadCollectionsConfig.java @@ -0,0 +1,30 @@ +package com.condation.cms.api.eventbus.events; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.eventbus.Event; + +/** + * Triggers reloading the site collection configuration. + */ +public record ReloadCollectionsConfig() implements Event { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/eventbus/events/lifecycle/ReIndexHostEvent.java b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/lifecycle/ReIndexHostEvent.java new file mode 100644 index 000000000..ef3d7197e --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/eventbus/events/lifecycle/ReIndexHostEvent.java @@ -0,0 +1,27 @@ +package com.condation.cms.api.eventbus.events.lifecycle; + +/*- + * #%L + * CMS API + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.eventbus.Event; + +public record ReIndexHostEvent(String host) implements Event { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/feature/features/CurrentCollectionItemFeature.java b/cms-api/src/main/java/com/condation/cms/api/feature/features/CurrentCollectionItemFeature.java new file mode 100644 index 000000000..f485eeff1 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/feature/features/CurrentCollectionItemFeature.java @@ -0,0 +1,31 @@ +package com.condation.cms.api.feature.features; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.annotations.FeatureScope; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.feature.Feature; + +/** Identifies the collection item represented by the current request. */ +@FeatureScope({FeatureScope.Scope.REQUEST}) +public record CurrentCollectionItemFeature(CollectionItem item) implements Feature { +} diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/CollectionType.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/CollectionType.java new file mode 100644 index 000000000..d275134f5 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/CollectionType.java @@ -0,0 +1,53 @@ +package com.condation.cms.api.ui.elements; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.util.Map; + +/** A collection editor type registered with the manager UI. */ +public record CollectionType( + String name, + String label, + Map forms) { + + public CollectionType { + name = ContentTypeDefinitionMapper.string(name, ""); + label = ContentTypeDefinitionMapper.string(label, name); + forms = ContentTypeDefinitionMapper.copyForms(forms); + } + + public CollectionType(String name, Map forms) { + this(name, name, forms); + } + + static CollectionType fromMap(Map collectionType) { + var name = ContentTypeDefinitionMapper.string(collectionType.get("name"), ""); + return new CollectionType( + name, + ContentTypeDefinitionMapper.string(collectionType.get("label"), name), + ContentTypeDefinitionMapper.forms(collectionType.get("forms"))); + } + + public FormDefinition getForm(String name) { + return forms.getOrDefault(name, FormDefinition.empty()); + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java index 0391d4188..31e909492 100644 --- a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypeDefinitionMapper.java @@ -23,6 +23,7 @@ import com.condation.cms.api.ui.elements.fields.CheckboxField; import com.condation.cms.api.ui.elements.fields.CodeField; +import com.condation.cms.api.ui.elements.fields.CollectionField; import com.condation.cms.api.ui.elements.fields.ColorField; import com.condation.cms.api.ui.elements.fields.DateField; import com.condation.cms.api.ui.elements.fields.DateTimeField; @@ -196,6 +197,9 @@ private static FormField field(Map definition) { case "reference" -> ReferenceField.builder() .name(name).title(title).required(required).requiredMessage(requiredMessage) .siteId(string(options.get("siteid"), null)).build(); + case "collection" -> CollectionField.builder() + .name(name).title(title).required(required).requiredMessage(requiredMessage) + .collection(string(options.get("collection"), null)).build(); case "tags" -> TagsField.builder() .name(name).title(title).required(required).requiredMessage(requiredMessage) .taxonomy(string(options.get("taxonomy"), null)).build(); diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypes.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypes.java index 17a0e54cd..f2dfdc825 100644 --- a/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypes.java +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/ContentTypes.java @@ -43,6 +43,26 @@ public class ContentTypes { private final Set pageTemplates = new LinkedHashSet<>(); private final Set sectionEntryTemplates = new LinkedHashSet<>(); private final Set listItemTypes = new LinkedHashSet<>(); + private final Set collectionTypes = new LinkedHashSet<>(); + + public void registerCollection(CollectionType collectionType) { + var registeredType = Objects.requireNonNull(collectionType, "collectionType"); + collectionTypes.removeIf(existing -> existing.name().equals(registeredType.name())); + collectionTypes.add(registeredType); + } + + /** JavaScript interop overload. */ + public void registerCollection(Map collectionType) { + registerCollection(CollectionType.fromMap(collectionType)); + } + + public Optional getCollection(String name) { + return collectionTypes.stream().filter(type -> type.name().equals(name)).findFirst(); + } + + public Set getCollections() { + return Collections.unmodifiableSet(new LinkedHashSet<>(collectionTypes)); + } public void registerListItemType(ListItemType listItemType) { listItemTypes.add(Objects.requireNonNull(listItemType, "listItemType")); diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/CollectionField.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/CollectionField.java new file mode 100644 index 000000000..205c0e127 --- /dev/null +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/CollectionField.java @@ -0,0 +1,50 @@ +package com.condation.cms.api.ui.elements.fields; + +/*- + * #%L + * CMS Api + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import lombok.Builder; +import lombok.Getter; + +/** A manager form field for selecting an item from a configured collection. */ +@Getter +public final class CollectionField extends FormField { + + private final Options options; + + @Builder + public CollectionField( + String name, + String title, + boolean required, + String requiredMessage, + String collection) { + super("collection", name, title, required, requiredMessage); + this.options = new Options(collection); + } + + public CollectionField(String name, String title, String collection) { + this(name, title, false, null, collection); + } + + public record Options(String collection) { + } +} diff --git a/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/FormField.java b/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/FormField.java index 84340a257..de9a097be 100644 --- a/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/FormField.java +++ b/cms-api/src/main/java/com/condation/cms/api/ui/elements/fields/FormField.java @@ -28,6 +28,7 @@ public abstract sealed class FormField permits CheckboxField, CodeField, + CollectionField, ColorField, DateField, DateTimeField, diff --git a/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java b/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java index ec63fb477..048c73f1b 100644 --- a/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java +++ b/cms-api/src/test/java/com/condation/cms/api/ui/elements/ContentTypesTest.java @@ -21,6 +21,7 @@ * #L% */ +import com.condation.cms.api.ui.elements.fields.CollectionField; import com.condation.cms.api.ui.elements.fields.FormField; import com.condation.cms.api.ui.elements.fields.MarkdownField; import com.condation.cms.api.ui.elements.fields.NumberField; @@ -59,9 +60,14 @@ void convertsDynamicRegistrationIntoDetachedTypedDefinition() { "contentFolder", "content", "createButton", false, "forms", Map.of("settings", settings))); + Map collectionInput = new HashMap<>(Map.of( + "name", "blog", + "label", "Blog posts", + "forms", Map.of("edit", settings))); ContentTypes contentTypes = new ContentTypes(); contentTypes.registerPageTemplate(input); + contentTypes.registerCollection(collectionInput); PageTemplate pageTemplate = contentTypes.getPageTemplate("StartPage").orElseThrow(); assertThat(pageTemplate).isInstanceOf(PageTemplate.class); @@ -96,6 +102,10 @@ void convertsDynamicRegistrationIntoDetachedTypedDefinition() { assertThat(pageTemplate.getForm("settings").tabs().getFirst().title()).isEqualTo("Details"); assertThat(pageTemplate.getForm("settings").tabs().getFirst().fields().getFirst().getName()) .isEqualTo("description"); + assertThat(contentTypes.getCollection("blog")).hasValueSatisfying(collection -> { + assertThat(collection.label()).isEqualTo("Blog posts"); + assertThat(collection.getForm("edit").fields()).hasSize(1); + }); } @Test @@ -118,18 +128,46 @@ void supportsTypedJavaRegistration() { .forms(Map.of("attributes", form)) .build(); ListItemType listItemType = new ListItemType("features", form); + CollectionType collectionType = new CollectionType("blog", "Blog posts", Map.of("edit", form)); ContentTypes contentTypes = new ContentTypes(); contentTypes.registerPageTemplate(pageTemplate); contentTypes.registerSectionEntryTemplate(sectionEntryTemplate); contentTypes.registerListItemType(listItemType); + contentTypes.registerCollection(collectionType); assertThat(contentTypes.getPageTemplates()).containsExactly(pageTemplate); assertThat(contentTypes.getSectionEntryTemplates("main")).containsExactly(sectionEntryTemplate); assertThat(contentTypes.getListItemTypes()).containsExactly(listItemType); + assertThat(contentTypes.getCollections()).containsExactly(collectionType); assertThat(pageTemplate.createButton()).isTrue(); assertThat(form.fields()).allMatch(FormField.class::isInstance); assertThat(form.tabs()).singleElement() .satisfies(tab -> assertThat(tab.fields()).allMatch(FormField.class::isInstance)); } + + @Test + void mapsCollectionFieldAndItsConfiguredCollection() { + Map collectionField = Map.of( + "type", "collection", + "name", "author", + "title", "Author", + "required", true, + "options", Map.of("collection", "authors")); + Map input = Map.of( + "name", "Article", + "template", "article.html", + "forms", Map.of("settings", Map.of("fields", List.of(collectionField)))); + + ContentTypes contentTypes = new ContentTypes(); + contentTypes.registerPageTemplate(input); + + assertThat(contentTypes.getPageTemplate("Article").orElseThrow().getForm("settings").fields()) + .singleElement() + .isInstanceOfSatisfying(CollectionField.class, field -> { + assertThat(field.getName()).isEqualTo("author"); + assertThat(field.isRequired()).isTrue(); + assertThat(field.getOptions().collection()).isEqualTo("authors"); + }); + } } diff --git a/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java b/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java new file mode 100644 index 000000000..1cb0e3a61 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/CollectionResolver.java @@ -0,0 +1,112 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.configuration.Configuration; +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.SiteConfiguration; +import com.condation.cms.api.content.ContentResponse; +import com.condation.cms.api.content.DefaultContentResponse; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.core.serivce.ServiceRegistry; +import com.condation.cms.core.serivce.impl.SiteDBService; +import java.io.IOException; +import java.util.HashMap; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +/** + * Resolves configured collection detail routes and renders their items. + */ +@RequiredArgsConstructor +public class CollectionResolver { + + private final ContentRenderer contentRenderer; + private final DB db; + private final Configuration configuration; + + public Optional getContent(RequestContext context) throws IOException { + var collectionConfiguration = configuration.get(CollectionConfiguration.class); + if (collectionConfiguration == null) { + return Optional.empty(); + } + var route = new CollectionRouteResolver(db, collectionConfiguration) + .resolve(context.get(RequestFeature.class).uri()); + if (route.isEmpty()) { + return Optional.empty(); + } + return render(route.get(), context); + } + + private Optional render( + CollectionRouteResolver.ResolvedRoute route, + RequestContext context) throws IOException { + var collectionItem = route.item(); + var nodeData = new HashMap<>(collectionItem.meta()); + nodeData.put("template", route.detail().template()); + var node = new ContentNode( + collectionItem.path(), + route.uri(), + collectionItem.id() + ".md", + nodeData); + context.add(CurrentNodeFeature.class, new CurrentNodeFeature(node)); + context.add( + CurrentCollectionItemFeature.class, + new CurrentCollectionItemFeature(collectionItem)); + + var sourceDB = sourceDB(route.definition()); + var collectionFile = sourceDB.getFileSystem().collectionsBase().resolve(collectionItem.path()); + if (!collectionFile.exists()) { + return Optional.empty(); + } + var content = contentRenderer.renderCollection( + collectionFile, + node, + collectionItem, + route.detail().template(), + context); + return Optional.of(new DefaultContentResponse(content, Constants.DEFAULT_CONTENT_TYPE, node)); + } + + private DB sourceDB(CollectionDefinition definition) { + var sourceSite = definition.sourceSite(); + if (sourceSite.isEmpty()) { + return db; + } + var siteConfiguration = configuration.get(SiteConfiguration.class); + if (siteConfiguration != null + && siteConfiguration.siteProperties().id().equals(sourceSite.get())) { + return db; + } + return ServiceRegistry.getInstance().get(sourceSite.get(), SiteDBService.class) + .orElseThrow(() -> new IllegalStateException( + "collection source site is not available: " + sourceSite.get())) + .db(); + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java b/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java new file mode 100644 index 000000000..310a9c917 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/CollectionRouteResolver.java @@ -0,0 +1,141 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.content.utils.SlugUtil; +import java.util.Comparator; +import java.util.Optional; +import lombok.RequiredArgsConstructor; + +/** Resolves configured collection detail routes without rendering them. */ +@RequiredArgsConstructor +public class CollectionRouteResolver { + + private final DB db; + private final CollectionConfiguration configuration; + + public Optional resolve(String requestUri) { + if (configuration == null) { + return Optional.empty(); + } + var uri = normalizeUri(requestUri); + for (var definition : configuration.collections().values().stream() + .sorted(Comparator.comparing(CollectionDefinition::name)) + .toList()) { + var resolved = resolve(definition, uri); + if (resolved.isPresent()) { + return resolved; + } + } + return Optional.empty(); + } + + private Optional resolve(CollectionDefinition definition, String uri) { + var detail = definition.detailPage(); + if (detail.isEmpty()) { + return Optional.empty(); + } + var template = new CollectionRouteTemplate(detail.get()); + if (!template.matchesShape(uri)) { + return Optional.empty(); + } + + var collection = db.getCollections().collection(definition.name()); + Optional item; + if (isSimpleParameter(detail.get())) { + var routeValue = simpleRouteValue(detail.get(), uri); + if ("id".equals(detail.get().parameter())) { + item = findById(collection, routeValue); + } else { + item = findByRouteValue(collection, detail.get().parameter(), routeValue); + } + } else { + var matches = collection.metadataQuery().get().stream() + .filter(candidate -> template.matches(uri, candidate.id(), candidate.meta())) + .limit(2) + .toList(); + item = matches.size() == 1 ? collection.item(matches.getFirst().id()) : Optional.empty(); + } + return item.map(value -> new ResolvedRoute(definition, detail.get(), value, uri)); + } + + private static boolean isSimpleParameter(CollectionDetailConfiguration detail) { + return detail.parameters().size() == 1 + && detail.parameterOccurrences() == 1 + && !detail.hasFormats() + && detail.mappings().isEmpty(); + } + + private static String simpleRouteValue(CollectionDetailConfiguration detail, String uri) { + var token = "{" + detail.parameter() + "}"; + var tokenStart = detail.route().indexOf(token); + var prefix = detail.route().substring(0, tokenStart); + var suffix = detail.route().substring(tokenStart + token.length()); + var end = uri.length() - suffix.length(); + return uri.substring(prefix.length(), end); + } + + private static Optional findByRouteValue( + com.condation.cms.api.db.collection.Collection collection, + String parameter, + String routeValue) { + var slug = SlugUtil.slugify(routeValue); + if (slug.isBlank()) { + return Optional.empty(); + } + + var exactMatches = collection.metadataQuery() + .where(parameter, slug) + .page(1, 2) + .getItems(); + if (exactMatches.size() == 1) { + return collection.item(exactMatches.getFirst().id()); + } + return Optional.empty(); + } + + private static Optional findById( + com.condation.cms.api.db.collection.Collection collection, + String id) { + try { + return collection.item(id); + } catch (IllegalArgumentException _) { + return Optional.empty(); + } + } + + private static String normalizeUri(String uri) { + return CollectionRouteTemplate.normalizeUri(uri); + } + + public record ResolvedRoute( + CollectionDefinition definition, + CollectionDetailConfiguration detail, + CollectionItem item, + String uri) { + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/CollectionRouteTemplate.java b/cms-content/src/main/java/com/condation/cms/content/CollectionRouteTemplate.java new file mode 100644 index 000000000..107e5c9c2 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/CollectionRouteTemplate.java @@ -0,0 +1,178 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.utils.MapUtil; +import com.condation.cms.content.utils.SlugUtil; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.util.Date; +import java.util.Map; +import java.util.regex.Pattern; + +/** Renders and matches the small template language used by collection routes. */ +public final class CollectionRouteTemplate { + + private static final Pattern TOKEN = Pattern.compile( + "\\{([a-zA-Z][a-zA-Z0-9_.-]*)(?::([^{}]*))?}"); + + private final CollectionDetailConfiguration configuration; + private final Pattern shape; + + public CollectionRouteTemplate(CollectionDetailConfiguration configuration) { + this.configuration = configuration; + this.shape = compileShape(configuration.route()); + } + + public String render(String id, Map metadata) { + var route = new StringBuilder(); + var matcher = TOKEN.matcher(configuration.route()); + var end = 0; + while (matcher.find()) { + route.append(configuration.route(), end, matcher.start()); + var field = matcher.group(1); + var value = "id".equals(field) ? id : MapUtil.getValue(metadata, field); + if (value == null || value.toString().isBlank()) { + throw new IllegalArgumentException("collection item has no route value for: " + field); + } + route.append(toRouteValue(field, matcher.group(2), value)); + end = matcher.end(); + } + route.append(configuration.route(), end, configuration.route().length()); + return route.toString(); + } + + public boolean matchesShape(String uri) { + return shape.matcher(normalizeUri(uri)).matches(); + } + + public boolean matches(String uri, String id, Map metadata) { + try { + return normalizeUri(uri).equals(normalizeUri(render(id, metadata))); + } catch (IllegalArgumentException _) { + return false; + } + } + + private String toRouteValue(String field, String format, Object rawValue) { + if ("id".equals(field) + && !configuration.mappings().containsKey(field) + && format == null) { + return rawValue.toString(); + } + + var mapping = configuration.mappings().get(field); + if (mapping != null) { + var mapped = mapping.get(rawValue.toString()); + if (mapped == null) { + throw new IllegalArgumentException( + "collection item route value has no mapping for: " + field + "=" + rawValue); + } + return slugifyPath(mapped, field); + } + + if (format != null) { + return slugifyPath(formatDate(rawValue, format, field), field); + } + return slugifyPath(rawValue.toString(), field); + } + + private static String formatDate(Object value, String pattern, String field) { + var formatter = DateTimeFormatter.ofPattern(pattern); + TemporalAccessor temporal = switch (value) { + case Date date -> date.toInstant().atZone(ZoneId.systemDefault()); + case TemporalAccessor accessor -> accessor; + case CharSequence text -> parseIsoDate(text.toString(), field); + default -> throw new IllegalArgumentException( + "collection item route value is not a date: " + field); + }; + try { + return formatter.format(temporal); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("collection item route date cannot be formatted: " + field, ex); + } + } + + private static TemporalAccessor parseIsoDate(String value, String field) { + for (var parser : java.util.List.>of( + ZonedDateTime::parse, + OffsetDateTime::parse, + LocalDateTime::parse, + LocalDate::parse, + text -> Instant.parse(text).atZone(ZoneId.systemDefault()))) { + try { + return parser.apply(value); + } catch (DateTimeParseException _) { + // Try the next ISO-8601 representation. + } + } + throw new IllegalArgumentException("collection item route value is not an ISO date: " + field); + } + + private static String slugifyPath(String value, String field) { + var segments = value.split("/", -1); + var result = new StringBuilder(); + for (var index = 0; index < segments.length; index++) { + var segment = SlugUtil.slugify(segments[index]); + if (segment.isBlank()) { + throw new IllegalArgumentException( + "collection item route value cannot be converted to a slug: " + field); + } + if (index > 0) { + result.append('/'); + } + result.append(segment); + } + return result.toString(); + } + + private static Pattern compileShape(String route) { + var regex = new StringBuilder("^"); + var matcher = TOKEN.matcher(route); + var end = 0; + while (matcher.find()) { + regex.append(Pattern.quote(route.substring(end, matcher.start()))).append(".+?"); + end = matcher.end(); + } + regex.append(Pattern.quote(route.substring(end))).append("/?$"); + return Pattern.compile(regex.toString()); + } + + static String normalizeUri(String uri) { + var normalized = uri == null ? "" : uri.trim(); + if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java index f4b7d5782..ccfe0e37d 100644 --- a/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/ContentRenderer.java @@ -23,6 +23,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.taxonomy.Taxonomy; import com.condation.cms.api.model.ListNode; @@ -47,6 +48,13 @@ public interface ContentRenderer { String render(final ReadOnlyFile contentFile, final RequestContext context, final Map> sectionEntries, final Map meta, final String markdownContent, final Consumer modelExtending) throws IOException; + String renderCollection( + final ReadOnlyFile collectionFile, + final ContentNode collectionNode, + final CollectionItem item, + final String template, + final RequestContext context) throws IOException; + Map> renderSectionEntries(final List sectionEntryNodes, final RequestContext context) throws IOException; String renderTaxonomy(final Optional contentFileOpt, final Taxonomy taxonomy, Optional taxonomyValue, final RequestContext context, final Map meta, final Page page, Map> sectionEntries) throws IOException; diff --git a/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java b/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java index d14c280c5..c0d62d11d 100644 --- a/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/DefaultContentRenderer.java @@ -27,6 +27,7 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.collection.CollectionItem; import com.condation.cms.api.db.cms.ReadOnlyFile; import com.condation.cms.api.db.taxonomy.Taxonomy; import com.condation.cms.api.extensions.ContentQueryOperatorExtensionPoint; @@ -89,6 +90,14 @@ public class DefaultContentRenderer implements ContentRenderer { private final SiteProperties siteProperties; private final ModuleManager moduleManager; + private record ResolvedRenderInput( + String uri, + Map> sectionEntries, + Map meta, + String rawContent, + Optional contentNode) { + } + @Override public String render(final ReadOnlyFile contentFile, final RequestContext context) throws IOException { return render(contentFile, context, Collections.emptyMap()); @@ -137,6 +146,30 @@ private String renderContent(final String rawContent, final RequestContext conte return pipeline.process(rawContent); } + @Override + public String renderCollection( + ReadOnlyFile collectionFile, + ContentNode collectionNode, + CollectionItem item, + String template, + RequestContext context) throws IOException { + var meta = new HashMap<>(item.meta()); + meta.put("template", template); + return renderResolved( + collectionFile, + context, + new ResolvedRenderInput( + collectionNode.url(), + Collections.emptyMap(), + meta, + item.content(), + Optional.of(collectionNode)), + model -> { + model.values.put("collection_item", item); + model.values.put("collection", db.getCollections().collection(item.collection())); + }); + } + @Override public String render(final ReadOnlyFile contentFile, final RequestContext context, final Map> sectionEntries, @@ -145,7 +178,28 @@ public String render(final ReadOnlyFile contentFile, final RequestContext contex var uri = PathUtil.toRelativeFile(contentFile, db.getFileSystem().contentBase()); Optional contentNode = db.getContent().byUri(uri); + return renderResolved( + contentFile, + context, + new ResolvedRenderInput( + uri, + sectionEntries, + meta, + rawContent, + contentNode), + modelExtending); + } + private String renderResolved( + ReadOnlyFile contentFile, + RequestContext context, + ResolvedRenderInput input, + Consumer modelExtending) throws IOException { + var uri = input.uri(); + var sectionEntries = input.sectionEntries(); + var meta = input.meta(); + var rawContent = input.rawContent(); + var contentNode = input.contentNode(); TemplateEngine.Model model = new TemplateEngine.Model( contentFile, contentNode.orElse(null), diff --git a/cms-content/src/main/java/com/condation/cms/content/template/functions/LinkFunction.java b/cms-content/src/main/java/com/condation/cms/content/template/functions/LinkFunction.java index 97f693ffb..b12f379ee 100644 --- a/cms-content/src/main/java/com/condation/cms/content/template/functions/LinkFunction.java +++ b/cms-content/src/main/java/com/condation/cms/content/template/functions/LinkFunction.java @@ -22,8 +22,13 @@ */ +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.feature.features.ConfigurationFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.HTTPUtil; +import com.condation.cms.content.CollectionRouteTemplate; +import java.util.Objects; import lombok.RequiredArgsConstructor; /** @@ -38,4 +43,29 @@ public class LinkFunction { public String createUrl (String url) { return HTTPUtil.modifyUrl(url, requestContext); } + + /** + * Creates the configured detail URL for a collection item. + * + * @param item collection item to link to + * @return context-aware detail URL + */ + public String collectionUrl(CollectionItem item) { + Objects.requireNonNull(item, "collection item must not be null"); + var configuration = requestContext.get(ConfigurationFeature.class) + .configuration() + .get(CollectionConfiguration.class); + if (configuration == null) { + throw new IllegalStateException("collection configuration is not available"); + } + + var definition = configuration.collection(item.collection()) + .orElseThrow(() -> new IllegalArgumentException( + "collection is not configured: " + item.collection())); + var detail = definition.detailPage() + .orElseThrow(() -> new IllegalArgumentException( + "collection has no detail route: " + item.collection())); + var route = new CollectionRouteTemplate(detail).render(item.id(), item.meta()); + return createUrl(route); + } } diff --git a/cms-content/src/main/java/com/condation/cms/content/utils/SlugUtil.java b/cms-content/src/main/java/com/condation/cms/content/utils/SlugUtil.java new file mode 100644 index 000000000..3d27264a2 --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/utils/SlugUtil.java @@ -0,0 +1,46 @@ +package com.condation.cms.content.utils; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.github.slugify.Slugify; + +/** Shared slug rules for generated paths and collection routes. */ +public final class SlugUtil { + + private static final Slugify SLUGIFIER = Slugify.builder() + .customReplacement("ä", "ae") + .customReplacement("Ä", "ae") + .customReplacement("ü", "ue") + .customReplacement("Ü", "ue") + .customReplacement("ö", "oe") + .customReplacement("Ö", "oe") + .customReplacement("ß", "ss") + .lowerCase(true) + .build(); + + private SlugUtil() { + } + + public static String slugify(String input) { + return SLUGIFIER.slugify(input); + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java b/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java new file mode 100644 index 000000000..b13a17b0e --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/CollectionResolverTest.java @@ -0,0 +1,250 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.configuration.Configuration; +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.content.DefaultContentResponse; +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; +import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.RequestFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.core.serivce.ServiceRegistry; +import com.condation.cms.core.serivce.impl.SiteDBService; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.time.LocalDate; +import java.util.concurrent.ConcurrentHashMap; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class CollectionResolverTest { + + private final ContentRenderer renderer = mock(ContentRenderer.class); + private final DB db = mock(DB.class); + private final com.condation.cms.api.db.collection.Collections collections = + mock(com.condation.cms.api.db.collection.Collections.class); + private final Collection collection = mock(Collection.class); + private final DBFileSystem fileSystem = mock(DBFileSystem.class); + private final ReadOnlyFile collectionsBase = mock(ReadOnlyFile.class); + private final ReadOnlyFile itemFile = mock(ReadOnlyFile.class); + private final ConcurrentHashMap definitions = new ConcurrentHashMap<>(); + private final Configuration configuration = new Configuration(); + private CollectionConfiguration collectionConfiguration; + private final CollectionItem item = new CollectionItem( + "first", + "blog", + "blog/first.md", + "# First", + Map.of("title", "First", "slug", "first-post")); + + @BeforeEach + void setUp() throws Exception { + collectionConfiguration = new CollectionConfiguration(definitions); + configuration.add(CollectionConfiguration.class, collectionConfiguration); + when(db.getCollections()).thenReturn(collections); + when(collections.collection("blog")).thenReturn(collection); + when(db.getFileSystem()).thenReturn(fileSystem); + when(fileSystem.collectionsBase()).thenReturn(collectionsBase); + when(collectionsBase.resolve("blog/first.md")).thenReturn(itemFile); + when(itemFile.exists()).thenReturn(true); + when(renderer.renderCollection( + eq(itemFile), + any(), + eq(item), + anyString(), + any())).thenReturn("

First

"); + } + + @AfterEach + void clearServices() { + ServiceRegistry.getInstance().clear(); + } + + @Test + void resolvesAnIdRouteAndUsesReloadedDefinitions() throws Exception { + define(definition("/old/{id}")); + when(collection.item("first")).thenReturn(Optional.of(item)); + var resolver = new CollectionResolver(renderer, db, configuration); + var context = context("/blog/first"); + + Assertions.assertThat(resolver.getContent(context)).isEmpty(); + + define(definition("/blog/{id}")); + var response = resolver.getContent(context); + + Assertions.assertThat(response) + .isPresent() + .get() + .isInstanceOfSatisfying(DefaultContentResponse.class, content -> + Assertions.assertThat(content.content()).isEqualTo("

First

")); + Assertions.assertThat(context.get(CurrentNodeFeature.class).node().url()).isEqualTo("/blog/first"); + Assertions.assertThat(context.get(CurrentCollectionItemFeature.class).item()).isEqualTo(item); + var node = ArgumentCaptor.forClass(com.condation.cms.api.db.ContentNode.class); + verify(renderer).renderCollection( + eq(itemFile), + node.capture(), + eq(item), + eq("collections/detail.html"), + eq(context)); + Assertions.assertThat(node.getValue().data()).containsEntry("template", "collections/detail.html"); + } + + @Test + void resolvesAConfiguredFrontMatterField() throws Exception { + define(definition("/blog/{slug}")); + @SuppressWarnings("unchecked") + var query = (ContentQuery) mock(ContentQuery.class); + when(collection.metadataQuery()).thenReturn(query); + when(query.where("slug", "first-post")).thenReturn(query); + when(query.page(1, 2)).thenReturn(new Page<>(1, 2, 1, 1, List.of( + new CollectionItemMetadata("first", "blog", "blog/first.md", item.meta())))); + when(collection.item("first")).thenReturn(Optional.of(item)); + var resolver = new CollectionResolver(renderer, db, configuration); + + var response = resolver.getContent(context("/blog/first-post/")); + + Assertions.assertThat(response).isPresent(); + verify(query).where("slug", "first-post"); + } + + @Test + void doesNotScanTheCollectionForLegacyUnnormalizedRouteValues() throws Exception { + define(definition("/blog/{slug}")); + @SuppressWarnings("unchecked") + var exactQuery = (ContentQuery) mock(ContentQuery.class); + when(collection.metadataQuery()).thenReturn(exactQuery); + when(exactQuery.where("slug", "ueber-uns")).thenReturn(exactQuery); + when(exactQuery.page(1, 2)).thenReturn(new Page<>(0, 2, 0, 1, List.of())); + + var response = new CollectionResolver(renderer, db, configuration) + .getContent(context("/blog/ueber-uns")); + + Assertions.assertThat(response).isEmpty(); + verify(collection).metadataQuery(); + } + + @Test + void resolvesAComplexRouteAgainstIndexedMetadata() { + var detail = new CollectionDetailConfiguration( + "/events/{date:yyyy}/{date:MM}/{date:dd}/{location.country}/{location.city}", + "collections/detail.html", + Map.of("location.country", Map.of("de", "germany"))); + define(new CollectionDefinition("blog", detail)); + var metadata = Map.of( + "date", LocalDate.of(2026, 9, 3), + "location", Map.of("country", "de", "city", "München")); + var event = new CollectionItem( + "first", "blog", "blog/first.md", "# Event", metadata); + @SuppressWarnings("unchecked") + var query = (ContentQuery) mock(ContentQuery.class); + when(collection.metadataQuery()).thenReturn(query); + when(query.get()).thenReturn(List.of( + new CollectionItemMetadata("first", "blog", "blog/first.md", metadata))); + when(collection.item("first")).thenReturn(Optional.of(event)); + + var route = new CollectionRouteResolver(db, collectionConfiguration) + .resolve("/events/2026/09/03/germany/muenchen/"); + + Assertions.assertThat(route).isPresent(); + Assertions.assertThat(route.orElseThrow().item()).isEqualTo(event); + } + + @Test + void readsTheItemFileFromTheConfiguredSourceSite() throws Exception { + define( + new CollectionDefinition( + "blog", + "content-site", + new CollectionDetailConfiguration( + "/shared/{id}", + "collections/detail.html"))); + when(collection.item("first")).thenReturn(Optional.of(item)); + var sourceDB = mock(DB.class); + var sourceFileSystem = mock(DBFileSystem.class); + var sourceCollectionsBase = mock(ReadOnlyFile.class); + var sourceItemFile = mock(ReadOnlyFile.class); + when(sourceDB.getFileSystem()).thenReturn(sourceFileSystem); + when(sourceFileSystem.collectionsBase()).thenReturn(sourceCollectionsBase); + when(sourceCollectionsBase.resolve("blog/first.md")).thenReturn(sourceItemFile); + when(sourceItemFile.exists()).thenReturn(true); + when(renderer.renderCollection( + eq(sourceItemFile), + any(), + eq(item), + anyString(), + any())).thenReturn("

Shared

"); + ServiceRegistry.getInstance().register( + "content-site", + SiteDBService.class, + new SiteDBService(sourceDB)); + + var response = new CollectionResolver(renderer, db, configuration) + .getContent(context("/shared/first")); + + Assertions.assertThat(response).isPresent(); + verify(renderer).renderCollection( + eq(sourceItemFile), + any(), + eq(item), + eq("collections/detail.html"), + any()); + } + + private static CollectionDefinition definition(String route) { + return new CollectionDefinition( + "blog", + new CollectionDetailConfiguration(route, "collections/detail.html")); + } + + private void define(CollectionDefinition definition) { + definitions.put("blog", definition); + collectionConfiguration.replaceCollections(definitions); + } + + private static RequestContext context(String uri) { + var context = new RequestContext(); + context.add(RequestFeature.class, new RequestFeature(uri, Map.of())); + return context; + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/CollectionRouteTemplateTest.java b/cms-content/src/test/java/com/condation/cms/content/CollectionRouteTemplateTest.java new file mode 100644 index 000000000..0b262215c --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/CollectionRouteTemplateTest.java @@ -0,0 +1,91 @@ +package com.condation.cms.content; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import java.time.LocalDate; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class CollectionRouteTemplateTest { + + @Test + void rendersMultipleNestedMetadataValues() { + var template = new CollectionRouteTemplate(detail( + "/collection/{date}|{location.city}", Map.of())); + + var route = template.render("event-1", Map.of( + "date", "2026-09-03", + "location", Map.of("city", "München"))); + + Assertions.assertThat(route).isEqualTo("/collection/2026-09-03|muenchen"); + } + + @Test + void formatsDatesWithJavaDateTimeFormatterNotation() { + var template = new CollectionRouteTemplate(detail( + "/events/{date:yyyy}/{date:MM}/{date:dd}", Map.of())); + + Assertions.assertThat(template.render("event-1", Map.of("date", LocalDate.of(2026, 9, 3)))) + .isEqualTo("/events/2026/09/03"); + Assertions.assertThat(template.matches( + "/events/2026/09/03/", "event-1", Map.of("date", "2026-09-03"))) + .isTrue(); + } + + @Test + void mapsMetadataValuesBeforeCreatingTheSlug() { + var template = new CollectionRouteTemplate(detail( + "/{location.country}/{location.city}", + Map.of("location.country", Map.of("de", "Germany")))); + + Assertions.assertThat(template.render("event-1", Map.of( + "location", Map.of("country", "de", "city", "Berlin")))) + .isEqualTo("/germany/berlin"); + } + + @Test + void rejectsMissingMappingValues() { + var template = new CollectionRouteTemplate(detail( + "/{location.country}", + Map.of("location.country", Map.of("de", "germany")))); + + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> template.render( + "event-1", Map.of("location", Map.of("country", "fr")))) + .withMessageContaining("location.country=fr"); + } + + @Test + void rejectsSlashesInsideDateFormats() { + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> detail("/events/{date:yyyy/MM}", Map.of())) + .withMessageContaining("must not contain '/'"); + } + + private static CollectionDetailConfiguration detail( + String route, + Map> mappings) { + return new CollectionDetailConfiguration(route, "collections/event.html", mappings); + } +} diff --git a/cms-content/src/test/java/com/condation/cms/content/template/functions/LinkFunctionTest.java b/cms-content/src/test/java/com/condation/cms/content/template/functions/LinkFunctionTest.java new file mode 100644 index 000000000..3981d1959 --- /dev/null +++ b/cms-content/src/test/java/com/condation/cms/content/template/functions/LinkFunctionTest.java @@ -0,0 +1,158 @@ +package com.condation.cms.content.template.functions; + +/*- + * #%L + * CMS Content + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.SiteProperties; +import com.condation.cms.api.configuration.Configuration; +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.feature.features.ConfigurationFeature; +import com.condation.cms.api.feature.features.SitePropertiesFeature; +import com.condation.cms.api.request.RequestContext; +import java.util.Map; +import java.time.LocalDate; +import java.util.concurrent.ConcurrentHashMap; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LinkFunctionTest { + + private final ConcurrentHashMap definitions = new ConcurrentHashMap<>(); + private final RequestContext context = new RequestContext(); + private CollectionConfiguration collectionConfiguration; + private final CollectionItem item = new CollectionItem( + "item_1", + "blog", + "blog/item_1.md", + "", + Map.of("slug", "first-post")); + + @BeforeEach + void setUp() { + var configuration = new Configuration(); + collectionConfiguration = new CollectionConfiguration(definitions); + configuration.add(CollectionConfiguration.class, collectionConfiguration); + context.add(ConfigurationFeature.class, new ConfigurationFeature(configuration)); + + var siteProperties = mock(SiteProperties.class); + when(siteProperties.contextPath()).thenReturn("/docs"); + context.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); + } + + @Test + void createsContextAwareUrlUsingTheItemId() { + define("/articles/{id}"); + + var url = new LinkFunction(context).collectionUrl(item); + + Assertions.assertThat(url).isEqualTo("/docs/articles/item_1"); + } + + @Test + void createsContextAwareUrlUsingConfiguredFrontMatter() { + define("/articles/{slug}"); + + var url = new LinkFunction(context).collectionUrl(item); + + Assertions.assertThat(url).isEqualTo("/docs/articles/first-post"); + } + + @Test + void slugifiesConfiguredFrontMatterForTheUrl() { + define("/articles/{slug}"); + var itemWithUnnormalizedSlug = new CollectionItem( + "item_2", + "blog", + "blog/item_2.md", + "", + Map.of("slug", "Über uns & das CMS")); + + var url = new LinkFunction(context).collectionUrl(itemWithUnnormalizedSlug); + + Assertions.assertThat(url).isEqualTo("/docs/articles/ueber-uns-das-cms"); + } + + @Test + void usesReloadedCollectionRoute() { + define("/old/{id}"); + var links = new LinkFunction(context); + Assertions.assertThat(links.collectionUrl(item)).isEqualTo("/docs/old/item_1"); + + define("/new/{slug}"); + + Assertions.assertThat(links.collectionUrl(item)).isEqualTo("/docs/new/first-post"); + } + + @Test + void rejectsItemsWithoutTheConfiguredRouteValue() { + define("/articles/{slug}"); + var itemWithoutSlug = new CollectionItem( + "item_2", + "blog", + "blog/item_2.md", + "", + Map.of()); + + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> new LinkFunction(context).collectionUrl(itemWithoutSlug)) + .withMessageContaining("slug"); + } + + @Test + void createsUrlFromMultipleFormattedAndMappedMetadataValues() { + definitions.put("blog", new CollectionDefinition( + "blog", + new CollectionDetailConfiguration( + "/events/{date:yyyy}-{date:MM}-{date:dd}/{location.country}/{location.city}", + "collections/detail.html", + Map.of("location.country", Map.of("de", "germany"))))); + collectionConfiguration.replaceCollections(definitions); + var event = new CollectionItem( + "event_1", + "blog", + "blog/event_1.md", + "", + Map.of( + "date", LocalDate.of(2026, 9, 3), + "location", Map.of("country", "de", "city", "München"))); + + Assertions.assertThat(new LinkFunction(context).collectionUrl(event)) + .isEqualTo("/docs/events/2026-09-03/germany/muenchen"); + } + + private static CollectionDefinition definition(String route) { + return new CollectionDefinition( + "blog", + new CollectionDetailConfiguration(route, "collections/detail.html")); + } + + private void define(String route) { + definitions.put("blog", definition(route)); + collectionConfiguration.replaceCollections(definitions); + } +} diff --git a/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java b/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java index cc527f981..0ee715a10 100644 --- a/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java +++ b/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigManagement.java @@ -56,34 +56,55 @@ public void reload () { } public void initConfiguration (Configuration configuration) { + var serverConfiguration = require("server", SimpleConfiguration.class); + var siteConfiguration = require("site", SimpleConfiguration.class); + var taxonomyConfiguration = require( + "taxonomy", com.condation.cms.core.configuration.configs.TaxonomyConfiguration.class); + var collectionConfiguration = require( + "collections", com.condation.cms.core.configuration.configs.CollectionConfiguration.class); + var mediaConfiguration = require( + "media", com.condation.cms.core.configuration.configs.MediaConfiguration.class); + configuration.add( ServerConfiguration.class, - new ServerConfiguration(new ExtendedServerProperties((SimpleConfiguration) get("server").get())) + new ServerConfiguration(new ExtendedServerProperties(serverConfiguration)) ); configuration.add( SiteConfiguration.class, - new SiteConfiguration(new ExtendedSiteProperties((SimpleConfiguration) get("site").get())) + new SiteConfiguration(new ExtendedSiteProperties(siteConfiguration)) ); configuration.add( com.condation.cms.api.configuration.configs.TaxonomyConfiguration.class, new com.condation.cms.api.configuration.configs.TaxonomyConfiguration( - ((com.condation.cms.core.configuration.configs.TaxonomyConfiguration) get("taxonomy") - .get()).getTaxonomies() + taxonomyConfiguration.getTaxonomies() ) ); + configuration.add( + com.condation.cms.api.configuration.configs.CollectionConfiguration.class, + collectionConfiguration.apiConfiguration() + ); var mediaConfig = new com.condation.cms.api.configuration.configs.MediaConfiguration( - ((com.condation.cms.core.configuration.configs.MediaConfiguration) get("media") - .get()).getMediaFormats() + mediaConfiguration.getMediaFormats() ); - mediaConfig.setProcessor(((com.condation.cms.core.configuration.configs.MediaConfiguration) get("media") - .get()).getProcessor()); - mediaConfig.setBinPath(((com.condation.cms.core.configuration.configs.MediaConfiguration) get("media") - .get()).getValueOrDefault("bin_path", "")); + mediaConfig.setProcessor(mediaConfiguration.getProcessor()); + mediaConfig.setBinPath(mediaConfiguration.getValueOrDefault("bin_path", "")); configuration.add( com.condation.cms.api.configuration.configs.MediaConfiguration.class, mediaConfig ); } + + private T require(String key, Class type) { + var configuration = configurations.get(key); + if (configuration == null) { + throw new IllegalStateException("Missing configuration: " + key); + } + if (!type.isInstance(configuration)) { + throw new IllegalStateException("Configuration '%s' is not of type %s" + .formatted(key, type.getSimpleName())); + } + return type.cast(configuration); + } } diff --git a/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigurationFactory.java b/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigurationFactory.java index b58afe18e..8b7f9e840 100644 --- a/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigurationFactory.java +++ b/cms-core/src/main/java/com/condation/cms/core/configuration/ConfigurationFactory.java @@ -25,6 +25,7 @@ import com.condation.cms.core.configuration.configs.SimpleConfiguration; import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.eventbus.events.ReloadMediaConfig; +import com.condation.cms.api.eventbus.events.ReloadCollectionsConfig; import com.condation.cms.api.eventbus.events.ReloadParentThemeConfig; import com.condation.cms.api.eventbus.events.ReloadServerConfig; import com.condation.cms.api.eventbus.events.ReloadSiteConfig; @@ -51,6 +52,7 @@ * @author t.marx */ public class ConfigurationFactory { + private static final String CONFIG_RELOAD_CRON = "0/10 * * * * ?"; public static ConfigManagement create(Path hostBase, EventBus eventBus, CronJobScheduler cronScheduler) throws IOException { ConfigManagement management = new ConfigManagement(); @@ -61,7 +63,7 @@ public static ConfigManagement create(Path hostBase, EventBus eventBus, CronJobS serverConfiguration.getString("env", "dev"), hostBase, new CompositeReload( - new CronReload("0/10 * * * * ?", cronScheduler), + new CronReload(CONFIG_RELOAD_CRON, cronScheduler), new EventReload<>(eventBus, ReloadSiteConfig.class) ) ); @@ -69,10 +71,18 @@ public static ConfigManagement create(Path hostBase, EventBus eventBus, CronJobS eventBus, hostBase, new CompositeReload( - new CronReload("0/10 * * * * ?", cronScheduler), + new CronReload(CONFIG_RELOAD_CRON, cronScheduler), new EventReload<>(eventBus, ReloadTaxonomyConfig.class) ) ); + final com.condation.cms.core.configuration.configs.CollectionConfiguration collectionConfiguration = collectionConfiguration( + eventBus, + hostBase, + new CompositeReload( + new CronReload(CONFIG_RELOAD_CRON, cronScheduler), + new EventReload<>(eventBus, ReloadCollectionsConfig.class) + ) + ); final SimpleConfiguration themeConfiguration = themeConfiguration( "theme", @@ -97,6 +107,7 @@ public static ConfigManagement create(Path hostBase, EventBus eventBus, CronJobS management.add(serverConfiguration.id(), serverConfiguration); management.add(siteConfiguration.id(), siteConfiguration); management.add(taxonomyConfiguration.id(), taxonomyConfiguration); + management.add(collectionConfiguration.id(), collectionConfiguration); management.add(mediaConfiguration.id(), mediaConfiguration); management.add(themeConfiguration.id(), themeConfiguration); management.add(themeConfiguration.id(), parentThemeConfiguration); @@ -109,9 +120,12 @@ public static SimpleConfiguration themeConfiguration(String id, String themePath } private static SimpleConfiguration themeConfiguration(String id, EventBus eventBus, String theme) throws IOException { var themeBase = ServerUtil.getPath(Constants.Folders.THEMES); - ReloadStrategy reloadStrategy = "parent-theme".equals(id) - ? new EventReload<>(eventBus, ReloadParentThemeConfig.class) - : new EventReload<>(eventBus, ReloadThemeConfig.class); + ReloadStrategy reloadStrategy; + if ("parent-theme".equals(id)) { + reloadStrategy = new EventReload<>(eventBus, ReloadParentThemeConfig.class); + } else { + reloadStrategy = new EventReload<>(eventBus, ReloadThemeConfig.class); + } return SimpleConfiguration.builder(eventBus) .id(id) .reloadStrategy(reloadStrategy) @@ -190,4 +204,16 @@ private static TaxonomyConfiguration taxonomyConfiguration(EventBus eventBus, Pa .addSource(TomlConfigSource.build(hostBase.resolve("config/taxonomy.toml"))) .build(); } + + private static com.condation.cms.core.configuration.configs.CollectionConfiguration collectionConfiguration( + EventBus eventBus, + Path hostBase, + ReloadStrategy reloadStrategy) throws IOException { + return com.condation.cms.core.configuration.configs.CollectionConfiguration.builder(eventBus) + .id("collections") + .reloadStrategy(reloadStrategy) + .addSource(YamlConfigSource.build(hostBase.resolve("config/collections.yaml"))) + .addSource(TomlConfigSource.build(hostBase.resolve("config/collections.toml"))) + .build(); + } } diff --git a/cms-core/src/main/java/com/condation/cms/core/configuration/configs/CollectionConfiguration.java b/cms-core/src/main/java/com/condation/cms/core/configuration/configs/CollectionConfiguration.java new file mode 100644 index 000000000..962adffc7 --- /dev/null +++ b/cms-core/src/main/java/com/condation/cms/core/configuration/configs/CollectionConfiguration.java @@ -0,0 +1,214 @@ +package com.condation.cms.core.configuration.configs; + +/*- + * #%L + * CMS Core + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.eventbus.events.ConfigurationReloadEvent; +import com.condation.cms.core.configuration.ConfigSource; +import com.condation.cms.core.configuration.IConfiguration; +import com.condation.cms.core.configuration.ReloadStrategy; +import com.condation.cms.core.configuration.reload.NoReload; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; + +/** + * Loads collection definitions from site configuration sources. + */ +@Slf4j +public class CollectionConfiguration extends AbstractConfiguration implements IConfiguration { + + private final List sources; + private final ReloadStrategy reloadStrategy; + private final EventBus eventBus; + private final String id; + private final com.condation.cms.api.configuration.configs.CollectionConfiguration apiConfiguration = + new com.condation.cms.api.configuration.configs.CollectionConfiguration(Map.of()); + + private CollectionConfiguration(Builder builder) { + this.sources = builder.sources; + this.reloadStrategy = builder.reloadStrategy; + this.eventBus = builder.eventBus; + this.id = builder.id; + reloadStrategy.register(this); + reload(); + } + + public static Builder builder(EventBus eventBus) { + return new Builder(eventBus); + } + + @Override + protected List getSources() { + return sources; + } + + @Override + public String id() { + return id; + } + + public Map getCollections() { + return apiConfiguration.collections(); + } + + public com.condation.cms.api.configuration.configs.CollectionConfiguration apiConfiguration() { + return apiConfiguration; + } + + @Override + public void reload() { + var reloaded = false; + var updatedCollections = new HashMap(); + try { + for (var source : sources) { + reloaded |= source.reload(); + if (!source.exists()) { + continue; + } + for (var entry : source.getMap("collections").entrySet()) { + var definition = parse(entry.getKey(), entry.getValue()); + updatedCollections.put(definition.name(), definition); + } + } + } catch (RuntimeException ex) { + log.error("could not reload collection configuration; keeping previous configuration", ex); + return; + } + + apiConfiguration.replaceCollections(updatedCollections); + if (reloaded && eventBus != null) { + eventBus.publish(new ConfigurationReloadEvent(id)); + } + } + + private CollectionDefinition parse(String name, Object value) { + try { + if (!(value instanceof Map collection)) { + throw new IllegalArgumentException("collection definition must be a map"); + } + + var site = optionalStringValue(collection.get("site"), "site"); + var detailValue = collection.get("detail"); + if (detailValue == null) { + return new CollectionDefinition(name, site, null); + } + if (!(detailValue instanceof Map detail)) { + throw new IllegalArgumentException("collection detail definition must be a map"); + } + + var route = stringValue(detail.get("route"), "collection detail route"); + var template = stringValue(detail.get("template"), "collection detail template"); + if (detail.containsKey("formats")) { + throw new IllegalArgumentException( + "collection detail formats must be declared in the route, for example {date:yyyy}"); + } + var mappings = nestedStringMap(detail.get("mappings"), "collection detail mappings"); + return new CollectionDefinition( + name, + site, + new CollectionDetailConfiguration(route, template, mappings)); + } catch (RuntimeException ex) { + throw new IllegalArgumentException("invalid configuration for collection " + name, ex); + } + } + + private static Map stringMap(Object value, String field) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map source)) { + throw new IllegalArgumentException(field + " must be a map"); + } + var result = new HashMap(); + for (var entry : source.entrySet()) { + result.put(stringValue(entry.getKey(), field + " key"), + stringValue(entry.getValue(), field + " value")); + } + return result; + } + + private static Map> nestedStringMap(Object value, String field) { + if (value == null) { + return Map.of(); + } + if (!(value instanceof Map source)) { + throw new IllegalArgumentException(field + " must be a map"); + } + var result = new HashMap>(); + for (var entry : source.entrySet()) { + result.put(stringValue(entry.getKey(), field + " key"), + stringMap(entry.getValue(), field + " values")); + } + return result; + } + + private static String stringValue(Object value, String field) { + if (!(value instanceof String string) || string.isBlank()) { + throw new IllegalArgumentException(field + " must be a non-empty string"); + } + return string; + } + + private static String optionalStringValue(Object value, String field) { + if (value == null) { + return null; + } + return stringValue(value, "collection " + field); + } + + public static class Builder { + + private final List sources = new ArrayList<>(); + private ReloadStrategy reloadStrategy = new NoReload(); + private String id = UUID.randomUUID().toString(); + private final EventBus eventBus; + + private Builder(EventBus eventBus) { + this.eventBus = eventBus; + } + + public Builder id(String uniqueId) { + this.id = uniqueId; + return this; + } + + public Builder addSource(ConfigSource source) { + sources.add(source); + return this; + } + + public Builder reloadStrategy(ReloadStrategy reload) { + this.reloadStrategy = reload; + return this; + } + + public CollectionConfiguration build() { + return new CollectionConfiguration(this); + } + } +} diff --git a/cms-core/src/test/java/com/condation/cms/core/configuration/CollectionConfigurationTest.java b/cms-core/src/test/java/com/condation/cms/core/configuration/CollectionConfigurationTest.java new file mode 100644 index 000000000..9620bdcc3 --- /dev/null +++ b/cms-core/src/test/java/com/condation/cms/core/configuration/CollectionConfigurationTest.java @@ -0,0 +1,140 @@ +package com.condation.cms.core.configuration; + +/*- + * #%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 static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.eventbus.events.ConfigurationReloadEvent; +import com.condation.cms.core.configuration.configs.CollectionConfiguration; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class CollectionConfigurationTest { + + @Test + void updatesTheSharedConfigurationOnReload() { + var eventBus = mock(EventBus.class); + var source = mock(ConfigSource.class); + var initial = Map.of( + "blog", + Map.of("site", "content-site", "detail", Map.of( + "route", "/blog/{slug}", + "template", "collections/blog.html")), + "listing-only", + Map.of()); + var updated = Map.of( + "products", + Map.of("detail", Map.of( + "route", "/products/{id}", + "template", "collections/product.html"))); + + when(source.exists()).thenReturn(true); + when(source.reload()).thenReturn(false, true); + when(source.getMap("collections")).thenReturn(initial, updated); + + var configuration = CollectionConfiguration.builder(eventBus) + .id("collections") + .addSource(source) + .build(); + var apiConfiguration = configuration.apiConfiguration(); + var initialSnapshot = configuration.getCollections(); + + Assertions.assertThat(initialSnapshot).containsOnlyKeys("blog", "listing-only"); + Assertions.assertThat(initialSnapshot.get("blog").detailPage().orElseThrow().parameter()) + .isEqualTo("slug"); + Assertions.assertThat(initialSnapshot.get("blog").sourceSite()).contains("content-site"); + Assertions.assertThat(initialSnapshot.get("listing-only").sourceSite()).isEmpty(); + + configuration.reload(); + + var updatedSnapshot = configuration.getCollections(); + Assertions.assertThat(configuration.apiConfiguration()).isSameAs(apiConfiguration); + Assertions.assertThat(apiConfiguration.collections()).isSameAs(updatedSnapshot); + Assertions.assertThat(updatedSnapshot).isNotSameAs(initialSnapshot); + Assertions.assertThat(initialSnapshot).containsOnlyKeys("blog", "listing-only"); + Assertions.assertThat(updatedSnapshot).containsOnlyKeys("products"); + Assertions.assertThat(updatedSnapshot.get("products").detailPage().orElseThrow().route()) + .isEqualTo("/products/{id}"); + verify(eventBus).publish(new ConfigurationReloadEvent("collections")); + } + + @Test + void keepsTheCompletePreviousSnapshotWhenOneDefinitionIsInvalid() { + var eventBus = mock(EventBus.class); + var source = mock(ConfigSource.class); + var initial = Map.of( + "blog", Map.of("detail", Map.of( + "route", "/blog/{id}", + "template", "collections/blog.html"))); + var invalid = Map.of( + "products", Map.of("detail", Map.of( + "route", "/products/{id}", + "template", "collections/product.html")), + "broken", Map.of("detail", Map.of("route", "/broken/{id}"))); + when(source.exists()).thenReturn(true); + when(source.reload()).thenReturn(false, true); + when(source.getMap("collections")).thenReturn(initial, invalid); + + var configuration = CollectionConfiguration.builder(eventBus) + .id("collections") + .addSource(source) + .build(); + var initialSnapshot = configuration.getCollections(); + + configuration.reload(); + + Assertions.assertThat(configuration.getCollections()).isSameAs(initialSnapshot); + Assertions.assertThat(configuration.getCollections()).containsOnlyKeys("blog"); + verifyNoInteractions(eventBus); + } + + @Test + void readsRouteFormatsAndMappings() { + var source = mock(ConfigSource.class); + when(source.exists()).thenReturn(true); + when(source.getMap("collections")).thenReturn(Map.of( + "events", Map.of("detail", Map.of( + "route", "/events/{date:yyyy}/{date:MM}/{date:dd}/{location.country}/{location.city}", + "template", "collections/event.html", + "mappings", Map.of("location.country", Map.of("de", "germany")))))); + + var detail = CollectionConfiguration.builder(null) + .addSource(source) + .build() + .getCollections() + .get("events") + .detailPage() + .orElseThrow(); + + Assertions.assertThat(detail.parameters()) + .containsExactly("date", "location.country", "location.city"); + Assertions.assertThat(detail.parameterOccurrences()).isEqualTo(5); + Assertions.assertThat(detail.hasFormats()).isTrue(); + Assertions.assertThat(detail.mappings().get("location.country")) + .containsEntry("de", "germany"); + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java new file mode 100644 index 000000000..7b7405f50 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileCollections.java @@ -0,0 +1,375 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.CursorPage; +import com.condation.cms.api.db.NodeVisibility; +import com.condation.cms.api.db.collection.CollectionCursorSupport; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemId; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.utils.PathUtil; +import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.filesystem.metadata.persistent.CollectionMetaData; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; + +/** + * Site-scoped, file-backed collections implementation. + */ +@Slf4j +public class FileCollections implements Collections, CollectionCursorSupport, AutoCloseable { + + private static final Pattern COLLECTION_NAME = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9_-]*"); + private static final Duration CHANGE_QUIET_PERIOD = Duration.ofMillis(200); + + private final String siteId; + private final Path hostBase; + private final Path collectionsBase; + private final Function> metaParser; + private final Set collectionNames = ConcurrentHashMap.newKeySet(); + + private CollectionMetaData metaData; + private MultiRootRecursiveWatcher watcher; + private ContentChangeCoordinator changeCoordinator; + + public FileCollections( + String siteId, + Path hostBase, + Function> metaParser) { + this.siteId = siteId; + this.hostBase = hostBase; + this.collectionsBase = hostBase.resolve(Constants.Folders.COLLECTIONS); + this.metaParser = metaParser; + } + + public void init() throws IOException { + Files.createDirectories(collectionsBase); + metaData = new CollectionMetaData(hostBase); + metaData.open(); + changeCoordinator = new ContentChangeCoordinator( + CHANGE_QUIET_PERIOD, + this::processChanges); + rebuild(false); + + watcher = new MultiRootRecursiveWatcher(siteId, List.of(collectionsBase)); + var publisher = Objects.requireNonNull( + watcher.getPublisher(collectionsBase), + "collections publisher must be available"); + publisher.subscribe( + new MultiRootRecursiveWatcher.AbstractFileEventSubscriber() { + @Override + public void onNext(FileEvent item) { + if (item.type() == FileEvent.Type.OVERFLOW) { + changeCoordinator.requestFullResync(); + } else { + changeCoordinator.submit(item.file().toPath()); + } + this.subscription.request(1); + } + }); + watcher.start(); + } + + @Override + public com.condation.cms.api.db.collection.Collection collection(String name) { + validateCollectionName(name); + return new FileCollection(name); + } + + @Override + public Set names() { + return Set.copyOf(collectionNames); + } + + @Override + public CursorPage metadataCursorPage( + String collection, + String cursor, + long size, + Consumer> queryConfigurer) { + validateCollectionName(collection); + return metaData.cursorPage( + collection, + this::mapMetadata, + cursor, + size, + queryConfigurer); + } + + @Override + public void refresh(String collection, String id) { + validateCollectionName(collection); + CollectionItemId.requireValid(id); + var file = collectionsBase.resolve(collection).resolve(id + ".md"); + try { + if (Files.isRegularFile(file)) { + index(file); + } else { + metaData.removeFile(collection + "/" + id + ".md"); + } + } catch (IOException ex) { + throw new IllegalStateException("could not refresh collection item", ex); + } + } + + void handleEvent(FileEvent event) { + if (event.type() == FileEvent.Type.OVERFLOW) { + changeCoordinator.requestFullResync(); + } else { + changeCoordinator.submit(event.file().toPath()); + } + } + + void flushChanges() { + changeCoordinator.flushNow(); + } + + public void reindex() { + changeCoordinator.requestFullResync(); + changeCoordinator.flushNow(); + } + + private void processChanges(boolean fullResync, Set paths) { + try { + if (fullResync) { + rebuild(true); + return; + } + for (var path : paths) { + processPath(path); + } + } catch (IOException ex) { + log.error("error processing collection changes", ex); + } + } + + private void processPath(Path path) throws IOException { + var relative = PathUtil.toRelativeEntry(path, collectionsBase); + if (relative.isBlank() || relative.startsWith("../")) { + return; + } + var parts = relative.split("/"); + if (parts.length == 1) { + if (Files.isDirectory(path)) { + scanCollection(path); + } else if (!Files.exists(path)) { + metaData.removeDirectory(parts[0]); + collectionNames.remove(parts[0]); + } + return; + } + if (parts.length != 2 || !isValidItemFile(path)) { + return; + } + if (!isValidCollectionName(parts[0])) { + return; + } + if (Files.isRegularFile(path)) { + index(path); + } else if (!Files.exists(path)) { + metaData.removeFile(relative); + } + } + + private void rebuild(boolean force) throws IOException { + metaData.startBatch(); + try { + if (force) { + metaData.clear(); + } + var staleCollections = metaData.collectionNames(); + collectionNames.clear(); + try (var collections = Files.list(collectionsBase)) { + for (var iterator = collections.filter(Files::isDirectory).iterator(); iterator.hasNext();) { + var collection = iterator.next(); + staleCollections.remove(collection.getFileName().toString()); + scanCollection(collection); + } + } + staleCollections.forEach(metaData::removeDirectory); + } finally { + metaData.stopBatch(); + } + } + + private void scanCollection(Path collection) throws IOException { + var name = collection.getFileName().toString(); + if (!isValidCollectionName(name)) { + log.warn("ignoring invalid collection name {}", name); + return; + } + collectionNames.add(name); + var stalePaths = metaData.paths(name); + try (var files = Files.list(collection)) { + for (var iterator = files.filter(Files::isRegularFile) + .filter(FileCollections::isValidItemFile).iterator(); iterator.hasNext();) { + var file = iterator.next(); + var path = PathUtil.toRelativeEntry(file, collectionsBase); + stalePaths.remove(path); + var stamp = fileStamp(file); + if (metaData.byPath(path).isEmpty() + || !metaData.fileStamp(path).filter(stamp::equals).isPresent()) { + index(file, path, stamp); + } + } + } + stalePaths.forEach(metaData::removeFile); + } + + private void index(Path file) throws IOException { + var path = PathUtil.toRelativeEntry(file, collectionsBase); + index(file, path, fileStamp(file)); + } + + private void index(Path file, String path, String stamp) throws IOException { + collectionNames.add(path.substring(0, path.indexOf('/'))); + var attributes = Files.readAttributes(file, BasicFileAttributes.class); + var modified = LocalDate.ofInstant( + attributes.lastModifiedTime().toInstant(), + ZoneId.systemDefault()); + metaData.addFile(path, metaParser.apply(file), modified, stamp); + } + + private static String fileStamp(Path file) throws IOException { + var attributes = Files.readAttributes(file, BasicFileAttributes.class); + return attributes.lastModifiedTime().toMillis() + ":" + attributes.size(); + } + + private CollectionItem map(ContentNode node, int ignoredExcerptLength) { + var path = node.path(); + var separator = path.indexOf('/'); + var collection = path.substring(0, separator); + var filename = path.substring(separator + 1); + var id = filename.substring(0, filename.length() - 3); + return new CollectionItem( + id, + collection, + path, + readMarkdownBody(collectionsBase.resolve(path)), + node.data()); + } + + private CollectionItemMetadata mapMetadata(ContentNode node, int ignoredExcerptLength) { + var path = node.path(); + var separator = path.indexOf('/'); + var collection = path.substring(0, separator); + var filename = path.substring(separator + 1); + var id = filename.substring(0, filename.length() - 3); + return new CollectionItemMetadata(id, collection, path, node.data()); + } + + private static String readMarkdownBody(Path file) { + try { + return new ContentFileParser(file.toString()).getContent(); + } catch (IOException ex) { + throw new UncheckedIOException("could not read collection item " + file, ex); + } + } + + private static boolean isMarkdown(Path file) { + return file.getFileName().toString().endsWith(".md"); + } + + private static boolean isValidItemFile(Path file) { + if (!isMarkdown(file)) { + return false; + } + var filename = file.getFileName().toString(); + return CollectionItemId.isValid(filename.substring(0, filename.length() - 3)); + } + + private static boolean isValidCollectionName(String name) { + return name != null && COLLECTION_NAME.matcher(name).matches(); + } + + private static void validateCollectionName(String name) { + if (!isValidCollectionName(name)) { + throw new IllegalArgumentException("invalid collection name: " + name); + } + } + + @Override + public void close() throws IOException { + if (watcher != null) { + watcher.stop(); + } + if (changeCoordinator != null) { + changeCoordinator.close(); + } + if (metaData != null) { + metaData.close(); + } + } + + private class FileCollection implements com.condation.cms.api.db.collection.Collection { + + private final String name; + + private FileCollection(String name) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + @Override + public Optional item(String id) { + CollectionItemId.requireValid(id); + return metaData.byPath(name + "/" + id + ".md") + .filter(NodeVisibility::isVisible) + .map(node -> FileCollections.this.map(node, 0)); + } + + @Override + public ContentQuery query() { + return metaData.query(name, FileCollections.this::map); + } + + @Override + public ContentQuery metadataQuery() { + return metaData.query(name, FileCollections.this::mapMetadata); + } + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileContent.java index 2d81e0134..fd7761860 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 @@ -104,9 +104,10 @@ public Optional> getMeta(String uri) { @Override public List searchByTitle (String input, VariantSearchMode variantSearchMode) { - var titleQuery = fileSystem.getMetaData().searchByTitle(input); - - return titleQuery.list(variantSearchMode); + return query((node, excerptLength) -> node) + .searchByTitle(input) + .variants(variantSearchMode) + .get(); } } 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 f451a83e8..9c1bb0bd3 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileDB.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/FileDB.java @@ -28,6 +28,7 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.taxonomy.Taxonomies; +import com.condation.cms.api.db.collection.Collections; import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.filesystem.taxonomy.FileTaxonomies; import java.io.IOException; @@ -51,6 +52,8 @@ public class FileDB implements DB { private FileSystem fileSystem; private FileContent content; + private FileCollections localCollections; + private Collections collections; private ReadOnlyFileSystem readOnlyFileSystem; private FileTaxonomies taxonomies; @@ -68,6 +71,13 @@ public void init () throws IOException { readOnlyFileSystem = new WrappedReadOnlyFileSystem(fileSystem); content = new FileContent(fileSystem); + localCollections = new FileCollections(siteProperties.id(), hostBaseDirectory, contentParser); + localCollections.init(); + var collectionConfiguration = configuration.get( + com.condation.cms.api.configuration.configs.CollectionConfiguration.class); + collections = collectionConfiguration == null + ? localCollections + : new ReferencedCollections(siteProperties.id(), localCollections, collectionConfiguration); taxonomies = new FileTaxonomies(configuration, content); } @@ -84,6 +94,12 @@ public void init () throws IOException { Map.Entry::getValue)); } + public void reindex() { + var siteProperties = configuration.get(SiteConfiguration.class).siteProperties(); + fileSystem.reindex(indexFields(siteProperties.get("index.fields"))); + localCollections.reindex(); + } + @Deprecated @Override public ReadOnlyFileSystem getReadOnlyFileSystem() { @@ -97,7 +113,13 @@ public DBFileSystem getFileSystem() { @Override public void close() throws Exception { - fileSystem.shutdown(); + try { + if (localCollections != null) { + localCollections.close(); + } + } finally { + fileSystem.shutdown(); + } } @Override @@ -105,6 +127,11 @@ public Content getContent() { return content; } + @Override + public Collections getCollections() { + return collections; + } + @Override public Taxonomies getTaxonomies() { return taxonomies; 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 f1e48d57a..a28e6061c 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 @@ -73,7 +73,7 @@ public class FileSystem implements ModuleFileSystem, DBFileSystem { private final Path hostBaseDirectory; private final EventBus eventBus; final Function> contentParser; - private final Map indexFields; + private Map indexFields; private MultiRootRecursiveWatcher fileWatcher; private ContentChangeCoordinator contentChangeCoordinator; @@ -337,6 +337,16 @@ public void flushContentChanges() { contentChangeCoordinator.flushNow(); } + public void reindex(Map indexFields) { + var fields = indexFields == null ? Map.of() : Map.copyOf(indexFields); + if (metaData instanceof PersistentMetaData persistentMetaData) { + persistentMetaData.configureIndexFields(fields); + } + this.indexFields = fields; + contentChangeCoordinator.requestFullResync(); + contentChangeCoordinator.flushNow(); + } + private void processContentChanges(boolean fullResync, Set paths) { MdcScope.forSite(siteId).run(() -> { try { @@ -401,6 +411,12 @@ public ReadOnlyFile contentBase() { return new NIOReadOnlyFile(path, path); } + @Override + public ReadOnlyFile collectionsBase() { + var path = resolve(Constants.Folders.COLLECTIONS); + return new NIOReadOnlyFile(path, path); + } + @Override public ReadOnlyFile assetBase() { var path = resolve(Constants.Folders.ASSETS); 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 91df27df1..a0d386d56 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/MetaData.java @@ -24,8 +24,6 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; -import com.condation.cms.api.db.VariantSearchMode; -import com.condation.cms.filesystem.metadata.persistent.TitleQuery; import java.io.IOException; import java.time.LocalDate; import java.util.List; @@ -65,8 +63,6 @@ public interface MetaData { List listSectionEntries(String pagePath); - TitleQuery searchByTitle(String input); - void clear (); Map getNodes(); diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java new file mode 100644 index 000000000..fc9aed81d --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/ReferencedCollections.java @@ -0,0 +1,124 @@ +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.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.CursorPage; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.CollectionCursorSupport; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.core.serivce.ServiceRegistry; +import com.condation.cms.core.serivce.impl.SiteDBService; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Adds lazy, read-only collection references to the collections stored by one + * site. Source sites are looked up for every access so configuration and site + * reloads do not leave cached cross-site references behind. + */ +final class ReferencedCollections implements Collections, CollectionCursorSupport { + + private final String siteId; + private final Collections localCollections; + private final CollectionConfiguration configuration; + + ReferencedCollections( + String siteId, + Collections localCollections, + CollectionConfiguration configuration) { + this.siteId = siteId; + this.localCollections = localCollections; + this.configuration = configuration; + } + + @Override + public Collection collection(String name) { + return targetCollections(name).collection(name); + } + + @Override + public CursorPage metadataCursorPage( + String collection, + String cursor, + long size, + Consumer> queryConfigurer) { + var target = targetCollections(collection); + if (!(target instanceof CollectionCursorSupport cursorSupport)) { + throw new UnsupportedOperationException("collection storage does not support cursor paging"); + } + return cursorSupport.metadataCursorPage(collection, cursor, size, queryConfigurer); + } + + @Override + public Set names() { + var names = new HashSet<>(localCollections.names()); + configuration.collections().values().stream() + .filter(definition -> definition.sourceSite() + .filter(sourceSite -> !siteId.equals(sourceSite)) + .isPresent()) + .map(definition -> definition.name()) + .forEach(names::add); + return Set.copyOf(names); + } + + @Override + public boolean isLocal(String collection) { + return sourceSite(collection) == null; + } + + @Override + public void refresh(String collection, String id) { + if (!isLocal(collection)) { + throw new UnsupportedOperationException( + "referenced collection is read-only: " + collection); + } + localCollections.refresh(collection, id); + } + + private String sourceSite(String collection) { + return configuration.collection(collection) + .flatMap(definition -> definition.sourceSite()) + .filter(source -> !siteId.equals(source)) + .orElse(null); + } + + private Collections targetCollections(String collection) { + var sourceSite = sourceSite(collection); + if (sourceSite == null) { + return localCollections; + } + var source = ServiceRegistry.getInstance().get(sourceSite, SiteDBService.class) + .orElseThrow(() -> new IllegalStateException( + "collection source site is not available: " + sourceSite)); + var sourceCollections = source.db().getCollections(); + if (!sourceCollections.isLocal(collection)) { + throw new IllegalStateException( + "referenced collections must point to a local collection: " + + sourceSite + "/" + collection); + } + return sourceCollections; + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/CollectionMetaData.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/CollectionMetaData.java new file mode 100644 index 000000000..8fd3b882f --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/CollectionMetaData.java @@ -0,0 +1,360 @@ +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 com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.CursorPage; +import com.condation.cms.filesystem.MetaData; +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.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.StringField; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.TermQuery; +import org.h2.mvstore.MVMap; +import org.h2.mvstore.MVStore; + +/** + * Shared metadata and Lucene index for every collection of one site. + */ +@Slf4j +public class CollectionMetaData implements MetaData { + + private static final String INDEX_SCHEMA_VERSION = "1"; + public static final String FIELD_COLLECTION = "_collection"; + public static final String FIELD_ID = "_id"; + + private final Path hostPath; + private LuceneIndex index; + private MVStore store; + private MVMap nodes; + private MVMap fileStamps; + private MVMap settings; + private boolean batchMode; + + public CollectionMetaData(Path hostPath) { + this.hostPath = hostPath; + } + + @Override + public void open() throws IOException { + var dataPath = hostPath.resolve("data/collections"); + Files.createDirectories(dataPath.resolve("store")); + Files.createDirectories(dataPath.resolve("index")); + + store = MVStore.open(dataPath.resolve("store/data.db").toString()); + nodes = store.openMap("nodes"); + fileStamps = store.openMap("file-stamps"); + settings = store.openMap("settings"); + var indexPath = dataPath.resolve("index"); + var recreate = !INDEX_SCHEMA_VERSION.equals(settings.get("index-schema")) + || !LuceneIndex.exists(indexPath); + index = new LuceneIndex(); + index.open(indexPath, recreate); + if (recreate) { + nodes.clear(); + fileStamps.clear(); + settings.put("index-schema", INDEX_SCHEMA_VERSION); + store.commit(); + } + } + + @Override + public void close() throws IOException { + try { + if (index != null) { + index.close(); + } + if (store != null) { + store.close(); + } + } catch (Exception ex) { + throw new IOException(ex); + } + } + + public void startBatch() { + batchMode = true; + index.setBatchMode(true); + } + + public void stopBatch() { + try { + index.setBatchMode(false); + index.commit(); + store.commit(); + } catch (IOException ex) { + log.error("error committing collection index", ex); + } finally { + batchMode = false; + } + } + + @Override + public synchronized void addFile(String path, Map data, LocalDate lastModified) { + addFile(path, data, lastModified, null); + } + + public synchronized void addFile( + String path, + Map data, + LocalDate lastModified, + String fileStamp) { + var normalizedPath = normalize(path); + var separator = normalizedPath.indexOf('/'); + if (separator <= 0 || separator == normalizedPath.length() - 1) { + throw new IllegalArgumentException("collection item path must contain collection and filename"); + } + + var collection = normalizedPath.substring(0, separator); + var filename = normalizedPath.substring(separator + 1); + var id = filename.endsWith(".md") + ? filename.substring(0, filename.length() - 3) + : filename; + var node = new ContentNode( + normalizedPath, + normalizedPath, + filename, + data, + lastModified); + nodes.put(normalizedPath, node); + + var document = new Document(); + document.add(new StringField("_uri", normalizedPath, Field.Store.YES)); + document.add(new StringField(FIELD_COLLECTION, collection, Field.Store.YES)); + document.add(new StringField(FIELD_ID, id, Field.Store.YES)); + DocumentHelper.addData(document, data); + DocumentHelper.addSearchFields(document, data); + DocumentHelper.addAvailableFields(document); + try { + index.update(new Term("_uri", normalizedPath), document); + if (fileStamp != null) { + fileStamps.put(normalizedPath, fileStamp); + } + commitStoreIfNecessary(); + } catch (IOException ex) { + log.error("error indexing collection item {}", normalizedPath, ex); + } + } + + @Override + public synchronized void removeFile(String path) { + var normalizedPath = normalize(path); + try { + index.delete(new TermQuery(new Term("_uri", normalizedPath))); + nodes.remove(normalizedPath); + fileStamps.remove(normalizedPath); + commitStoreIfNecessary(); + } catch (IOException ex) { + log.error("error deleting collection item {}", normalizedPath, ex); + } + } + + @Override + public synchronized void removeDirectory(String path) { + var collection = normalize(path); + var prefix = collection + "/"; + var affectedPaths = new ArrayList(); + var cursor = nodes.cursor(prefix); + while (cursor.hasNext()) { + var itemPath = cursor.next(); + if (!itemPath.startsWith(prefix)) { + break; + } + affectedPaths.add(itemPath); + } + affectedPaths.forEach(nodes::remove); + try { + index.delete(new TermQuery(new Term(FIELD_COLLECTION, collection))); + affectedPaths.forEach(fileStamps::remove); + commitStoreIfNecessary(); + } catch (IOException ex) { + log.error("error deleting collection {}", collection, ex); + } + } + + @Override + public synchronized void removePath(String path) { + var normalizedPath = normalize(path); + if (normalizedPath.contains("/")) { + removeFile(normalizedPath); + } else { + removeDirectory(normalizedPath); + } + } + + @Override + public Optional byUri(String uri) { + return byPath(uri); + } + + @Override + public Optional byPath(String path) { + return Optional.ofNullable(nodes.get(normalize(path))); + } + + @Override + public Optional byUrl(String url) { + return Optional.empty(); + } + + @Override + public void createDirectory(String path) { + // Collections are represented by the _collection field, not directory nodes. + } + + @Override + public Optional findFolder(String path) { + return Optional.empty(); + } + + @Override + public List listChildren(String path) { + var prefix = normalize(path) + "/"; + return nodes.values().stream() + .filter(node -> node.path().startsWith(prefix)) + .toList(); + } + + @Override + public List listSectionEntries(String pagePath) { + return List.of(); + } + + @Override + public synchronized void clear() { + nodes.clear(); + fileStamps.clear(); + try { + index.delete(MatchAllDocsQuery.INSTANCE); + } catch (IOException ex) { + log.error("error clearing collection index", ex); + } + } + + public Optional fileStamp(String path) { + return Optional.ofNullable(fileStamps.get(normalize(path))); + } + + public Set paths(String collection) { + var result = new HashSet(); + var prefix = normalize(collection) + "/"; + var cursor = nodes.cursor(prefix); + while (cursor.hasNext()) { + var path = cursor.next(); + if (!path.startsWith(prefix)) { + break; + } + result.add(path); + } + return result; + } + + public Set collectionNames() { + var result = new HashSet(); + for (var path : nodes.keySet()) { + var separator = path.indexOf('/'); + if (separator > 0) { + result.add(path.substring(0, separator)); + } + } + return result; + } + + private void commitStoreIfNecessary() { + if (!batchMode) { + store.commit(); + } + } + + @Override + public Map getNodes() { + return new ConcurrentHashMap<>(nodes); + } + + @Override + public Map getTree() { + return Map.of(); + } + + @Override + public ContentQuery query(BiFunction nodeMapper) { + return collectionQuery(null, nodeMapper); + } + + @Override + public ContentQuery query(String collection, BiFunction nodeMapper) { + return collectionQuery(collection, nodeMapper); + } + + public CursorPage cursorPage( + String collection, + BiFunction nodeMapper, + String cursor, + long size, + Consumer> queryConfigurer) { + var query = collectionQuery(collection, nodeMapper); + queryConfigurer.accept(query); + return query.cursorPage(cursor, size); + } + + private LuceneQuery collectionQuery( + String collection, + BiFunction nodeMapper) { + var scope = collection == null + ? null + : new TermQuery(new Term(FIELD_COLLECTION, collection)); + return new LuceneQuery<>( + index, + this, + new ExcerptMapperFunction<>(nodeMapper), + LuceneQueryPolicy.COLLECTION, + scope); + } + + private static String normalize(String path) { + var normalized = path == null ? "" : path.replace('\\', '/'); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } +} diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneIndex.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneIndex.java index dcc90f744..8f93a31c4 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneIndex.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneIndex.java @@ -24,11 +24,15 @@ import com.condation.cms.api.utils.FileUtils; import com.condation.cms.filesystem.metadata.persistent.lucene.TitleAnalyzer; import com.condation.cms.filesystem.metadata.persistent.lucene.TitlePrefixAnalyzer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Collections; +import java.util.Base64; import java.util.EnumSet; import java.util.List; import java.util.Map; @@ -40,12 +44,15 @@ import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper; import org.apache.lucene.document.Document; import org.apache.lucene.index.DocValuesType; +import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.FieldInfos; import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriterConfig; import org.apache.lucene.index.Term; import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.FieldDoc; import org.apache.lucene.search.Query; +import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.SearcherFactory; import org.apache.lucene.search.SearcherManager; import org.apache.lucene.search.Sort; @@ -57,6 +64,7 @@ import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import org.apache.lucene.store.NRTCachingDirectory; +import org.apache.lucene.util.BytesRef; /** * @@ -65,11 +73,19 @@ @Slf4j public class LuceneIndex implements AutoCloseable { + private static final int CURSOR_VERSION = 1; + @FunctionalInterface interface UriVisitor { boolean visit(String uri) throws IOException; } + record CursorResult(List uris, String nextCursor) { + } + + record SeekPageResult(long totalItems, List uris) { + } + public static final Analyzer INDEX_ANALYZER = new TitlePrefixAnalyzer(); public static final Analyzer SEARCH_ANALYZER = new TitleAnalyzer(); @@ -124,84 +140,246 @@ void delete(Query query) throws IOException { } } - List query(Query query, Sort sort) throws IOException { + void scanUris(Query query, Sort sort, int batchSize, UriVisitor visitor) throws IOException { + if (batchSize < 1) { + throw new IllegalArgumentException("batchSize must be greater than zero"); + } + IndexSearcher searcher = nrt_manager.acquire(); try { - var topDocs = searcher.search(query, Integer.MAX_VALUE, sort); + var storedFields = searcher.storedFields(); + org.apache.lucene.search.ScoreDoc after = null; - List result = new ArrayList<>(); - for (var scoreDoc : topDocs.scoreDocs) { - result.add(searcher.storedFields().document(scoreDoc.doc)); - } + while (true) { + var hits = sort == null + ? searcher.searchAfter(after, query, batchSize) + : searcher.searchAfter(after, query, batchSize, sort); + if (hits.scoreDocs.length == 0) { + return; + } - return result; - } catch (IOException e) { - log.error("", e); + for (var hit : hits.scoreDocs) { + var document = storedFields.document(hit.doc, Set.of("_uri")); + if (!visitor.visit(document.get("_uri"))) { + return; + } + } + after = hits.scoreDocs[hits.scoreDocs.length - 1]; + } } finally { nrt_manager.release(searcher); } - return Collections.emptyList(); } - List query(Query query) throws IOException { + CursorResult cursorPage( + Query query, + Sort sort, + int pageSize, + String cursor, + String cursorKey) throws IOException { + if (pageSize < 1 || pageSize > 10_000) { + throw new IllegalArgumentException("cursor page size must be between 1 and 10000"); + } + IndexSearcher searcher = nrt_manager.acquire(); try { - var topDocs = searcher.search(query, Integer.MAX_VALUE); - - List result = new ArrayList<>(); - for (var scoreDoc : topDocs.scoreDocs) { - result.add(searcher.storedFields().document(scoreDoc.doc)); + long generation = ((DirectoryReader) searcher.getIndexReader()).getVersion(); + var queryKey = cursorKey; + var sortKey = sort == null ? "" : sort.toString(); + ScoreDoc after = decodeCursor(cursor, generation, queryKey, sortKey, sort); + var hits = sort == null + ? searcher.searchAfter(after, query, pageSize + 1) + : searcher.searchAfter(after, query, pageSize + 1, sort); + var storedFields = searcher.storedFields(); + var uris = new ArrayList(Math.min(pageSize, hits.scoreDocs.length)); + int returned = Math.min(pageSize, hits.scoreDocs.length); + for (int index = 0; index < returned; index++) { + uris.add(storedFields.document(hits.scoreDocs[index].doc, Set.of("_uri")).get("_uri")); } - - return result; - } catch (IOException e) { - log.error("", e); + var nextCursor = hits.scoreDocs.length > pageSize + ? encodeCursor(hits.scoreDocs[returned - 1], generation, queryKey, sortKey) + : null; + return new CursorResult(List.copyOf(uris), nextCursor); } finally { nrt_manager.release(searcher); } - return Collections.emptyList(); } - int count(Query query) throws IOException { + SeekPageResult seekPage( + Query query, + Sort sort, + long offset, + int pageSize) throws IOException { + if (offset < 0) { + throw new IllegalArgumentException("offset must not be negative"); + } + if (pageSize < 1) { + throw new IllegalArgumentException("pageSize must be greater than zero"); + } + IndexSearcher searcher = nrt_manager.acquire(); try { - return searcher.count(query); + long totalItems = searcher.count(query); + if (offset >= totalItems) { + return new SeekPageResult(totalItems, List.of()); + } + + ScoreDoc after = seekToOffset(searcher, query, sort, offset); + var hits = sort == null + ? searcher.searchAfter(after, query, pageSize) + : searcher.searchAfter(after, query, pageSize, sort); + var storedFields = searcher.storedFields(); + var uris = new ArrayList(hits.scoreDocs.length); + for (var hit : hits.scoreDocs) { + uris.add(storedFields.document(hit.doc, Set.of("_uri")).get("_uri")); + } + return new SeekPageResult(totalItems, List.copyOf(uris)); } finally { nrt_manager.release(searcher); } } - void scanUris(Query query, Sort sort, int batchSize, UriVisitor visitor) throws IOException { - if (batchSize < 1) { - throw new IllegalArgumentException("batchSize must be greater than zero"); + private ScoreDoc seekToOffset( + IndexSearcher searcher, + Query query, + Sort sort, + long offset) throws IOException { + ScoreDoc after = null; + long remaining = offset; + while (remaining > 0) { + int stepSize = (int) Math.min(remaining, 1024); + var hits = sort == null + ? searcher.searchAfter(after, query, stepSize) + : searcher.searchAfter(after, query, stepSize, sort); + if (hits.scoreDocs.length == 0) { + return after; + } + after = hits.scoreDocs[hits.scoreDocs.length - 1]; + remaining -= hits.scoreDocs.length; } + return after; + } - IndexSearcher searcher = nrt_manager.acquire(); + private String encodeCursor(ScoreDoc scoreDoc, long generation, String queryKey, String sortKey) { try { - var storedFields = searcher.storedFields(); - org.apache.lucene.search.ScoreDoc after = null; - - while (true) { - var hits = sort == null - ? searcher.searchAfter(after, query, batchSize) - : searcher.searchAfter(after, query, batchSize, sort); - if (hits.scoreDocs.length == 0) { - return; + var bytes = new ByteArrayOutputStream(); + try (var output = new DataOutputStream(bytes)) { + output.writeInt(CURSOR_VERSION); + output.writeLong(generation); + output.writeUTF(queryKey); + output.writeUTF(sortKey); + output.writeInt(scoreDoc.doc); + output.writeFloat(scoreDoc.score); + if (scoreDoc instanceof FieldDoc fieldDoc) { + output.writeInt(fieldDoc.fields.length); + for (var field : fieldDoc.fields) { + writeCursorValue(output, field); + } + } else { + output.writeInt(-1); } + } + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes.toByteArray()); + } catch (IOException ex) { + throw new IllegalStateException("could not encode cursor", ex); + } + } - for (var hit : hits.scoreDocs) { - var document = storedFields.document(hit.doc, Set.of("_uri")); - if (!visitor.visit(document.get("_uri"))) { - return; + private ScoreDoc decodeCursor( + String cursor, + long generation, + String queryKey, + String sortKey, + Sort sort) { + if (cursor == null || cursor.isBlank()) { + return null; + } + try { + var bytes = Base64.getUrlDecoder().decode(cursor); + try (var input = new DataInputStream(new ByteArrayInputStream(bytes))) { + if (input.readInt() != CURSOR_VERSION + || input.readLong() != generation + || !input.readUTF().equals(queryKey) + || !input.readUTF().equals(sortKey)) { + throw new IllegalArgumentException("cursor has expired or belongs to another query"); + } + int doc = input.readInt(); + float score = input.readFloat(); + int fieldCount = input.readInt(); + if (fieldCount < 0) { + if (sort != null) { + throw new IllegalArgumentException("cursor sort does not match query sort"); } + return new ScoreDoc(doc, score); } - after = hits.scoreDocs[hits.scoreDocs.length - 1]; + if (sort == null || fieldCount != sort.getSort().length) { + throw new IllegalArgumentException("cursor sort does not match query sort"); + } + var fields = new Object[fieldCount]; + for (int index = 0; index < fieldCount; index++) { + fields[index] = readCursorValue(input); + } + return new FieldDoc(doc, score, fields); } - } finally { - nrt_manager.release(searcher); + } catch (IllegalArgumentException ex) { + throw ex; + } catch (IOException ex) { + throw new IllegalArgumentException("invalid cursor", ex); + } + } + + private void writeCursorValue(DataOutputStream output, Object value) throws IOException { + switch (value) { + case null -> output.writeByte(0); + case BytesRef bytes -> { + output.writeByte(1); + output.writeInt(bytes.length); + output.write(bytes.bytes, bytes.offset, bytes.length); + } + case Long longValue -> { + output.writeByte(2); + output.writeLong(longValue); + } + case Integer intValue -> { + output.writeByte(3); + output.writeInt(intValue); + } + case Double doubleValue -> { + output.writeByte(4); + output.writeDouble(doubleValue); + } + case Float floatValue -> { + output.writeByte(5); + output.writeFloat(floatValue); + } + case String stringValue -> { + output.writeByte(6); + output.writeUTF(stringValue); + } + default -> throw new IllegalArgumentException( + "unsupported cursor sort value: " + value.getClass().getName()); } } + private Object readCursorValue(DataInputStream input) throws IOException { + return switch (input.readByte()) { + case 0 -> null; + case 1 -> { + int length = input.readInt(); + if (length < 0 || length > 1_000_000) { + throw new IllegalArgumentException("invalid cursor value length"); + } + yield new BytesRef(input.readNBytes(length)); + } + case 2 -> input.readLong(); + case 3 -> input.readInt(); + case 4 -> input.readDouble(); + case 5 -> input.readFloat(); + case 6 -> input.readUTF(); + default -> throw new IllegalArgumentException("invalid cursor value type"); + }; + } + Optional resolveSort(String field, boolean reverse) throws IOException { IndexSearcher searcher = nrt_manager.acquire(); try { @@ -272,7 +450,20 @@ private SortField numericSortField( } public void open(Path path) throws IOException { - if (Files.exists(path)) { + open(path, true); + } + + public static boolean exists(Path path) throws IOException { + if (!Files.isDirectory(path)) { + return false; + } + try (var directory = FSDirectory.open(path)) { + return DirectoryReader.indexExists(directory); + } + } + + public void open(Path path, boolean recreate) throws IOException { + if (recreate && Files.exists(path)) { FileUtils.deleteFolder(path); } Files.createDirectories(path); @@ -288,7 +479,9 @@ public void open(Path path) throws IOException { ); IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer); - indexWriterConfig.setOpenMode(IndexWriterConfig.OpenMode.CREATE); + indexWriterConfig.setOpenMode(recreate + ? IndexWriterConfig.OpenMode.CREATE + : IndexWriterConfig.OpenMode.CREATE_OR_APPEND); indexWriterConfig.setCommitOnClose(true); nrt_index = new NRTCachingDirectory(directory, 5.0, 60.0); writer = new IndexWriter(nrt_index, indexWriterConfig); 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 3aeef04eb..467c79bb6 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 @@ -23,7 +23,9 @@ import com.condation.cms.api.Constants; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.CursorPage; import com.condation.cms.api.db.DistanceUnit; +import com.condation.cms.api.db.NodeVisibility; import com.condation.cms.api.db.Page; import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.feature.features.IsPreviewFeature; @@ -47,7 +49,6 @@ import java.util.Objects; import java.util.Optional; import java.util.function.Predicate; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.lucene.document.LongField; import org.apache.lucene.document.LatLonPoint; @@ -58,6 +59,7 @@ import org.apache.lucene.search.PrefixQuery; import org.apache.lucene.search.Query; import org.apache.lucene.search.TermQuery; +import org.apache.lucene.queryparser.flexible.core.QueryNodeException; /** * @@ -65,7 +67,6 @@ * @param */ @Slf4j -@RequiredArgsConstructor public class LuceneQuery extends ExtendableQuery implements ContentQuery.Sort { private static final int SCAN_BATCH_SIZE = 128; @@ -74,6 +75,8 @@ public class LuceneQuery extends ExtendableQuery implements ContentQuery.S private final LuceneIndex index; private final MetaData metaData; private final ExcerptMapperFunction nodeMapper; + private final LuceneQueryPolicy policy; + private final Optional scopeQuery; private String contentType = Constants.DEFAULT_CONTENT_TYPE; @@ -90,17 +93,38 @@ enum Order { private List> extensionOperations = new ArrayList<>(); - private final Parser expressionsParser = new Parser(); + private final Parser expressionsParser = new Parser(); + private final TitleQueryFactory titleQueryFactory = new TitleQueryFactory(LuceneIndex.SEARCH_ANALYZER); public LuceneQuery( final String startUri, final LuceneIndex index, final MetaData metaData, final ExcerptMapperFunction nodeMapper) { - this(index, metaData, nodeMapper); + this(index, metaData, nodeMapper, LuceneQueryPolicy.CONTENT, null); this.startUri = Optional.ofNullable(startUri); } + public LuceneQuery( + final LuceneIndex index, + final MetaData metaData, + final ExcerptMapperFunction nodeMapper) { + this(index, metaData, nodeMapper, LuceneQueryPolicy.CONTENT, null); + } + + public LuceneQuery( + final LuceneIndex index, + final MetaData metaData, + final ExcerptMapperFunction nodeMapper, + final LuceneQueryPolicy policy, + final Query scopeQuery) { + this.index = Objects.requireNonNull(index, "index must not be null"); + this.metaData = Objects.requireNonNull(metaData, "metaData must not be null"); + this.nodeMapper = Objects.requireNonNull(nodeMapper, "nodeMapper must not be null"); + this.policy = Objects.requireNonNull(policy, "policy must not be null"); + this.scopeQuery = Optional.ofNullable(scopeQuery); + } + @Override public ContentQuery excerpt(long excerptLength) { nodeMapper.setExcerpt((int) excerptLength); @@ -171,6 +195,43 @@ public List get() { return result.nodes; } + CursorPage cursorPage(String cursor, long size) { + if (size < 1 || size > 10_000) { + throw new IllegalArgumentException("cursor page size must be between 1 and 10000"); + } + if (!extensionOperations.isEmpty()) { + throw new UnsupportedOperationException( + "cursor paging is not available with in-memory query extensions"); + } + try { + var structuralQuery = structuralVisibilityQuery(buildBaseQuery()); + var completeQuery = completeVisibilityQuery(structuralQuery) + .orElseThrow(() -> new UnsupportedOperationException( + "cursor paging requires an indexable workflow visibility filter")); + org.apache.lucene.search.Sort sort = null; + if (orderByField.isPresent()) { + sort = index.resolveSort(orderByField.get(), Order.DESC.equals(sortOrder)) + .orElseThrow(() -> new UnsupportedOperationException( + "cursor paging requires an indexed sort field: " + orderByField.get())); + } + // Visibility schedule bounds contain the current millisecond and therefore + // are intentionally excluded from the stable cursor identity. + var cursorKey = structuralQuery.toString() + + "|preview=" + hasPreview() + + "|workflow=" + statusProvider().getClass().getName(); + var result = index.cursorPage( + completeQuery, sort, Math.toIntExact(size), cursor, cursorKey); + var nodes = result.uris().stream() + .map(metaData::byPath) + .flatMap(Optional::stream) + .toList(); + return new CursorPage<>(mapContentNodes(nodes).nodes, result.nextCursor()); + } catch (IOException ex) { + log.error("error cursor-paging lucene query", ex); + return new CursorPage<>(List.of(), null); + } + } + private List queryContentNodes() { try { var contentNodes = new ArrayList(); @@ -194,26 +255,16 @@ private Page fastPage( long page, long size, long offset) throws IOException { - long totalItems = index.count(query); - if (offset >= totalItems) { - return new Page<>(totalItems, size, totalPages(totalItems, size), (int) page, List.of()); - } - - var nodes = new ArrayList((int) Math.min(size, Integer.MAX_VALUE)); - var hitIndex = new long[]{0}; - index.scanUris(query, sort, batchSize(size), uri -> { - long currentIndex = hitIndex[0]++; - if (currentIndex < offset) { - return true; - } - metaData.byPath(uri).ifPresent(nodes::add); - return nodes.size() < size; - }); + var result = index.seekPage(query, sort, offset, Math.toIntExact(size)); + var nodes = result.uris().stream() + .map(metaData::byPath) + .flatMap(Optional::stream) + .toList(); return new Page<>( - totalItems, + result.totalItems(), size, - totalPages(totalItems, size), + totalPages(result.totalItems(), size), (int) page, mapContentNodes(nodes).nodes); } @@ -268,16 +319,35 @@ private Page inMemorySortedPage(long page, long size, long offset) { private Query buildBaseQuery() { var baseQuery = new BooleanQuery.Builder(); queryBuilder.build().clauses().forEach(baseQuery::add); - baseQuery.add( - new TermQuery(new Term("content.type", contentType)), - BooleanClause.Occur.MUST); - startUri.ifPresent(uri -> baseQuery.add( - new PrefixQuery(new Term("_uri", uri)), - BooleanClause.Occur.FILTER)); + scopeQuery.ifPresent(query -> baseQuery.add(query, BooleanClause.Occur.FILTER)); + if (policy == LuceneQueryPolicy.CONTENT) { + baseQuery.add( + new TermQuery(new Term("content.type", contentType)), + BooleanClause.Occur.MUST); + startUri.ifPresent(uri -> baseQuery.add( + new PrefixQuery(new Term("_uri", uri)), + BooleanClause.Occur.FILTER)); + } return baseQuery.build(); } + @Override + public ContentQuery searchByTitle(String input) { + if (input == null || input.isBlank()) { + return this; + } + try { + queryBuilder.add(titleQueryFactory.createQuery(input), BooleanClause.Occur.MUST); + return this; + } catch (QueryNodeException ex) { + throw new IllegalArgumentException("invalid title search", ex); + } + } + private Query structuralVisibilityQuery(Query query) { + if (policy == LuceneQueryPolicy.COLLECTION) { + return query; + } var visiblePages = new BooleanQuery.Builder(); visiblePages.add(query, BooleanClause.Occur.MUST); visiblePages.add( @@ -378,10 +448,17 @@ private boolean isManagerPreview() { } private boolean isAcceptedNode(ContentNode node) { - return !node.isDirectory() - && PageMetaData.isPage(node) - && PageMetaData.isVisible(node) - && extensionOperations.stream().allMatch(predicate -> predicate.test(node)); + if (node.isDirectory()) { + return false; + } + if (policy == LuceneQueryPolicy.COLLECTION && !NodeVisibility.isVisible(node)) { + return false; + } + if (policy == LuceneQueryPolicy.CONTENT + && (!PageMetaData.isPage(node) || !PageMetaData.isVisible(node))) { + return false; + } + return extensionOperations.stream().allMatch(predicate -> predicate.test(node)); } private void validatePage(long page, long size) { @@ -425,18 +502,27 @@ public Sort orderby(String field) { @Override public ContentQuery json() { + if (policy == LuceneQueryPolicy.COLLECTION) { + return this; + } this.contentType = Constants.ContentTypes.JSON; return this; } @Override public ContentQuery html() { + if (policy == LuceneQueryPolicy.COLLECTION) { + return this; + } this.contentType = Constants.ContentTypes.HTML; return this; } @Override public ContentQuery contentType(String contentType) { + if (policy == LuceneQueryPolicy.COLLECTION) { + return this; + } this.contentType = contentType; return this; } @@ -444,6 +530,9 @@ public ContentQuery contentType(String contentType) { @Override public ContentQuery variants(VariantSearchMode mode) { Objects.requireNonNull(mode, "mode must not be null"); + if (policy == LuceneQueryPolicy.COLLECTION) { + return this; + } if (mode != VariantSearchMode.ALL) { queryBuilder.add( new TermQuery(new Term("_variant", Boolean.toString(mode == VariantSearchMode.VARIANT))), diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQueryPolicy.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQueryPolicy.java new file mode 100644 index 000000000..02ca2d6a5 --- /dev/null +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/LuceneQueryPolicy.java @@ -0,0 +1,31 @@ +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% + */ + +/** + * Selects the domain rules applied on top of the shared Lucene query + * implementation. + */ +public enum LuceneQueryPolicy { + CONTENT, + COLLECTION +} 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 0b79827c7..b70789098 100644 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java +++ b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/PersistentMetaData.java @@ -25,7 +25,6 @@ import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.ContentQuery; import com.condation.cms.api.db.NodeVisibility; -import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.filesystem.metadata.AbstractMetaData; import com.condation.cms.filesystem.metadata.persistent.field.IndexFieldConfiguration; @@ -58,15 +57,13 @@ public class PersistentMetaData extends AbstractMetaData implements AutoCloseable { private final Path hostPath; - private final Map indexFieldDefinitions; + private volatile Map indexFieldDefinitions; private LuceneIndex index; private MVStore store; private SectionIndex sectionIndex; private UrlIndex urlIndex; private MVMap nodesByPath; - - private TitleQueryFactory titleQueryFactory; public PersistentMetaData(Path hostPath) { this(hostPath, Map.of()); @@ -77,6 +74,10 @@ public PersistentMetaData(Path hostPath, Map indexFields) { this.indexFieldDefinitions = IndexFieldConfiguration.parse(indexFields); } + public synchronized void configureIndexFields(Map indexFields) { + this.indexFieldDefinitions = IndexFieldConfiguration.parse(indexFields); + } + @Override public void open() throws IOException { @@ -102,7 +103,6 @@ public void open() throws IOException { sectionIndex.clear(); urlIndex.clear(); - titleQueryFactory = new TitleQueryFactory(LuceneIndex.SEARCH_ANALYZER); } @Override @@ -308,9 +308,4 @@ public ContentQuery query(final String startURI, final BiFunction(uri, this.index, this, new ExcerptMapperFunction<>(nodeMapper)); } - @Override - public TitleQuery searchByTitle (String input) { - return new TitleQuery(titleQueryFactory, input, index, this); - - } } diff --git a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java b/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java deleted file mode 100644 index ebfd15724..000000000 --- a/cms-filesystem/src/main/java/com/condation/cms/filesystem/metadata/persistent/TitleQuery.java +++ /dev/null @@ -1,101 +0,0 @@ -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 com.condation.cms.api.db.ContentNode; -import com.condation.cms.api.db.VariantSearchMode; -import com.condation.cms.filesystem.MetaData; -import com.condation.cms.filesystem.metadata.PageMetaData; -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.lucene.document.Document; -import org.apache.lucene.index.Term; -import org.apache.lucene.queryparser.flexible.core.QueryNodeException; -import org.apache.lucene.search.BooleanClause; -import org.apache.lucene.search.BooleanQuery; -import org.apache.lucene.search.TermQuery; - -/** - * - * @author thorstenmarx - */ -@Slf4j -@RequiredArgsConstructor -public class TitleQuery { - - private final TitleQueryFactory titleQueryFactory; - private final String input; - private final LuceneIndex index; - private final MetaData metaData; - - private String contentType = Constants.DEFAULT_CONTENT_TYPE; - - public List list(VariantSearchMode variantSearchMode) { - return queryContentNodes(variantSearchMode); - } - - public List list() { - return queryContentNodes(VariantSearchMode.ALL); - } - - private List queryContentNodes(VariantSearchMode variantSearchMode) { - - try { - BooleanQuery.Builder queryBuilder = new BooleanQuery.Builder(); - queryBuilder.add(new TermQuery(new Term("content.type", contentType)), BooleanClause.Occur.MUST); - queryBuilder.add(titleQueryFactory.createQuery(input), BooleanClause.Occur.MUST); - if (variantSearchMode != VariantSearchMode.ALL) { - queryBuilder.add( - new TermQuery(new Term( - "_variant", - Boolean.toString(variantSearchMode == VariantSearchMode.VARIANT) - )), - BooleanClause.Occur.MUST - ); - } - List result = index.query(queryBuilder.build()); - - var contentNodes = result.stream() - .map(document -> document.get("_uri")) - .map(metaData::byPath) - .filter(Optional::isPresent) - .map(Optional::get) - .filter(node -> !node.isDirectory()) - .filter(PageMetaData::isPage) - .filter(PageMetaData::isVisible) - .toList(); - - return contentNodes; - } catch (IOException ex) { - log.error("", ex); - } catch (QueryNodeException ex) { - log.error("", ex); - } - return Collections.emptyList(); - - } -} diff --git a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileCollectionsTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileCollectionsTest.java new file mode 100644 index 000000000..a29ab8dd5 --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileCollectionsTest.java @@ -0,0 +1,497 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.db.ContentNode; +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.collection.CollectionCursorSupport; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; +import com.condation.cms.api.workflow.WFStatusProvider; +import com.condation.cms.api.workflow.WFStatusQueryProvider; +import com.condation.cms.api.workflow.WorkflowInstance; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +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.yaml.snakeyaml.Yaml; + +class FileCollectionsTest { + + @TempDir + Path tempDirectory; + + @Test + void queriesCollectionsWithFilteringSortingPagingAndRawMarkdown() throws Exception { + write("blog/first.md", "title: First\nfeatured: true\nrank: 2", "# First"); + write("blog/second.md", "title: Second\nfeatured: false\nrank: 3", "**Second**"); + write("blog/third.md", "title: Third\nfeatured: true\nrank: 1", "_Third_"); + write("authors/first.md", "title: Author\nfeatured: true\nrank: 0", "Author body"); + write("blog/nested/ignored.md", "title: Ignored", "Ignored body"); + + var collections = createCollections(); + try { + Assertions.assertThat(collections.names()).containsExactlyInAnyOrder("blog", "authors"); + + var page = collections.collection("blog") + .query() + .where("featured", true) + .orderby("rank") + .asc() + .page(1, 1); + + Assertions.assertThat(page.getTotalItems()).isEqualTo(2); + Assertions.assertThat(page.getTotalPages()).isEqualTo(2); + Assertions.assertThat(page.getItems()) + .singleElement() + .satisfies(item -> { + Assertions.assertThat(item.id()).isEqualTo("third"); + Assertions.assertThat(item.collection()).isEqualTo("blog"); + Assertions.assertThat(item.path()).isEqualTo("blog/third.md"); + Assertions.assertThat(item.content()).isEqualTo("_Third_"); + Assertions.assertThat(item.meta()).containsEntry("title", "Third"); + }); + + Assertions.assertThat(collections.collection("authors").query().get()) + .extracting(item -> item.id()) + .containsExactly("first"); + } finally { + collections.close(); + } + } + + @Test + void appliesFlatFileChangesIncrementally() throws Exception { + var item = write("blog/item.md", "title: Before\nfeatured: false", "Before"); + var collections = createCollections(); + try { + write("blog/item.md", "title: After\nfeatured: true", "After"); + collections.handleEvent(new FileEvent(item.toFile(), FileEvent.Type.MODIFIED)); + collections.flushChanges(); + + Assertions.assertThat(collections.collection("blog").query().where("featured", true).get()) + .singleElement() + .satisfies(result -> { + Assertions.assertThat(result.meta()).containsEntry("title", "After"); + Assertions.assertThat(result.content()).isEqualTo("After"); + }); + + Files.delete(item); + collections.handleEvent(new FileEvent(item.toFile(), FileEvent.Type.DELETED)); + collections.flushChanges(); + Assertions.assertThat(collections.collection("blog").query().get()).isEmpty(); + } finally { + collections.close(); + } + } + + @Test + void searchesCollectionTitlesWithPaging() throws Exception { + write("blog/first.md", "title: Collection Article One", "First"); + write("blog/second.md", "title: Collection Article Two", "Second"); + write("blog/other.md", "title: Unrelated Entry", "Other"); + + var collections = createCollections(); + try { + var page = collections.collection("blog") + .query() + .searchByTitle("Collection Article") + .orderby("title") + .asc() + .page(2, 1); + + Assertions.assertThat(page.getTotalItems()).isEqualTo(2); + Assertions.assertThat(page.getTotalPages()).isEqualTo(2); + Assertions.assertThat(page.getItems()) + .extracting(item -> item.id()) + .containsExactly("second"); + } finally { + collections.close(); + } + } + + @Test + void queriesMetadataWithoutReturningMarkdownBodies() throws Exception { + write("blog/first.md", "title: First", "Large body"); + var collections = createCollections(); + try { + var result = collections.collection("blog").metadataQuery().page(1, 10); + + Assertions.assertThat(result.getItems()) + .singleElement() + .satisfies(item -> { + Assertions.assertThat(item.id()).isEqualTo("first"); + Assertions.assertThat(item.meta()).containsEntry("title", "First"); + }); + } finally { + collections.close(); + } + } + + @Test + void reusesPersistedCollectionIndexWhenFilesAreUnchanged() throws Exception { + write("blog/first.md", "title: First", "First"); + write("blog/second.md", "title: Second", "Second"); + var parseCount = new AtomicInteger(); + var parser = (java.util.function.Function>) path -> { + parseCount.incrementAndGet(); + return parseMeta(path); + }; + + var first = new FileCollections("test-site", tempDirectory, parser); + first.init(); + first.close(); + Assertions.assertThat(parseCount).hasValue(2); + + var second = new FileCollections("test-site", tempDirectory, parser); + try { + second.init(); + Assertions.assertThat(parseCount).hasValue(2); + Assertions.assertThat(second.collection("blog").metadataQuery().page(1, 10).getTotalItems()) + .isEqualTo(2); + } finally { + second.close(); + } + } + + @Test + void explicitReindexReparsesUnchangedCollectionItems() throws Exception { + write("blog/first.md", "title: First", "First"); + write("blog/second.md", "title: Second", "Second"); + var parseCount = new AtomicInteger(); + var collections = new FileCollections("test-site", tempDirectory, path -> { + parseCount.incrementAndGet(); + return parseMeta(path); + }); + + try { + collections.init(); + collections.reindex(); + + Assertions.assertThat(parseCount).hasValue(4); + Assertions.assertThat(collections.collection("blog").metadataQuery().get()) + .extracting(item -> item.id()) + .containsExactlyInAnyOrder("first", "second"); + } finally { + collections.close(); + } + } + + @Test + void pagesCollectionsWithAnOpaqueCursorAndExpiresItAfterIndexChanges() throws Exception { + write("blog/first.md", "title: A", "First"); + write("blog/second.md", "title: B", "Second"); + write("blog/third.md", "title: C", "Third"); + var collections = createCollections(); + try { + var cursorSupport = (CollectionCursorSupport) collections; + var firstPage = cursorSupport.metadataCursorPage( + "blog", null, 2, query -> query.orderby("title").asc()); + var secondPage = cursorSupport.metadataCursorPage( + "blog", firstPage.nextCursor(), 2, query -> query.orderby("title").asc()); + + Assertions.assertThat(firstPage.items()).extracting(item -> item.id()) + .containsExactly("first", "second"); + Assertions.assertThat(firstPage.hasNext()).isTrue(); + Assertions.assertThat(secondPage.items()).extracting(item -> item.id()) + .containsExactly("third"); + Assertions.assertThat(secondPage.hasNext()).isFalse(); + + write("blog/third.md", "title: D", "Changed"); + collections.refresh("blog", "third"); + Assertions.assertThatIllegalArgumentException().isThrownBy(() -> + cursorSupport.metadataCursorPage( + "blog", + firstPage.nextCursor(), + 2, + query -> query.orderby("title").asc())) + .withMessageContaining("cursor has expired"); + } finally { + collections.close(); + } + } + + @Test + void regularPageUsesServerSideSeekingWithoutAnExternalCursor() throws Exception { + write("blog/first.md", "title: A", "First"); + write("blog/second.md", "title: B", "Second"); + write("blog/third.md", "title: C", "Third"); + write("blog/fourth.md", "title: D", "Fourth"); + write("blog/fifth.md", "title: E", "Fifth"); + var collections = createCollections(); + try { + var query = collections.collection("blog").metadataQuery(); + var page = query + .orderby("title").asc() + .page(2, 2); + + Assertions.assertThat(page.getTotalItems()).isEqualTo(5); + Assertions.assertThat(page.getTotalPages()).isEqualTo(3); + Assertions.assertThat(page.getPage()).isEqualTo(2); + Assertions.assertThat(page.getItems()).extracting(item -> item.id()) + .containsExactly("third", "fourth"); + Assertions.assertThat(query.getClass().getMethods()) + .noneMatch(method -> method.getName().equals("cursorPage") + || method.getName().equals("seekPage")); + } finally { + collections.close(); + } + } + + @Test + void refreshesOneCollectionItemImmediately() throws Exception { + write("blog/item.md", "title: Before", "Before"); + var collections = createCollections(); + try { + write("blog/item.md", "title: After", "After"); + collections.refresh("blog", "item"); + + Assertions.assertThat(collections.collection("blog").item("item")) + .isPresent() + .get() + .satisfies(item -> { + Assertions.assertThat(item.meta()).containsEntry("title", "After"); + Assertions.assertThat(item.content()).isEqualTo("After"); + }); + } finally { + collections.close(); + } + } + + @Test + void preservesMarkdownHorizontalRulesOutsideFrontMatter() throws Exception { + write( + "blog/item.md", + "title: Horizontal rules", + "Introduction\n\n---\n\nMiddle section\n\n---\n\nConclusion"); + var collections = createCollections(); + try { + Assertions.assertThat(collections.collection("blog").item("item")) + .isPresent() + .get() + .extracting(item -> item.content()) + .isEqualTo("Introduction\n\n---\n\nMiddle section\n\n---\n\nConclusion"); + } finally { + collections.close(); + } + } + + @Test + void preservesHorizontalRulesInMarkdownWithoutFrontMatter() throws Exception { + var file = tempDirectory.resolve("collections/blog/item.md"); + Files.createDirectories(file.getParent()); + Files.writeString(file, "Introduction\n\n---\n\nConclusion\n"); + var collections = createCollections(); + try { + var previewContext = new RequestContext(); + previewContext.add(IsPreviewFeature.class, new IsPreviewFeature(IsPreviewFeature.Mode.PREVIEW)); + var optionalItem = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, previewContext) + .call(() -> collections.collection("blog").item("item")); + Assertions.assertThat(optionalItem) + .isPresent() + .get() + .extracting(item -> item.content()) + .isEqualTo("Introduction\n\n---\n\nConclusion\n"); + } finally { + collections.close(); + } + } + + @Test + void removesDeletedCollectionItemImmediatelyOnRefresh() throws Exception { + var item = write("blog/item.md", "title: Before", "Before"); + var collections = createCollections(); + try { + Files.delete(item); + collections.refresh("blog", "item"); + + Assertions.assertThat(collections.collection("blog").query().get()).isEmpty(); + } finally { + collections.close(); + } + } + + @Test + void rejectsUnsafeCollectionNames() throws Exception { + var collections = createCollections(); + try { + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> collections.collection("../content")); + } finally { + collections.close(); + } + } + + @Test + void consistentlyRejectsInvalidItemIdsFromFilesAndDirectLookups() throws Exception { + write("blog/valid-item.md", "title: Valid", "Valid"); + write("blog/invalid item.md", "title: Invalid", "Invalid"); + var collections = createCollections(); + try { + Assertions.assertThat(collections.collection("blog").query().get()) + .extracting(item -> item.id()) + .containsExactly("valid-item"); + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> collections.collection("blog").item("invalid item")) + .withMessage("invalid collection item id: invalid item"); + } finally { + collections.close(); + } + } + + @Test + void appliesDefaultWorkflowSchedulingAndPreview() throws Exception { + writeMeta("blog/published.md", "status: published", "Published"); + writeMeta("blog/draft.md", "status: draft", "Draft"); + writeMeta( + "blog/future.md", + "status: published\npublish_date: 2099-01-01T00:00:00Z", + "Future"); + writeMeta( + "blog/expired.md", + "status: published\nunpublish_date: 2000-01-01T00:00:00Z", + "Expired"); + + var collections = createCollections(); + try { + var publicPage = collections.collection("blog").query().page(1, 10); + Assertions.assertThat(publicPage.getTotalItems()).isEqualTo(1); + Assertions.assertThat(publicPage.getItems()) + .extracting(item -> item.id()) + .containsExactly("published"); + Assertions.assertThat(collections.collection("blog").item("published")).isPresent(); + Assertions.assertThat(collections.collection("blog").item("draft")).isEmpty(); + + var previewContext = new RequestContext(); + previewContext.add(IsPreviewFeature.class, new IsPreviewFeature(IsPreviewFeature.Mode.PREVIEW)); + var previewItems = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, previewContext) + .call(() -> collections.collection("blog").query().get()); + var previewDraft = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, previewContext) + .call(() -> collections.collection("blog").item("draft")); + + Assertions.assertThat(previewItems) + .extracting(item -> item.id()) + .containsExactlyInAnyOrder("published", "draft", "future", "expired"); + Assertions.assertThat(previewDraft).isPresent(); + } finally { + collections.close(); + } + } + + @Test + void appliesConfiguredContentWorkflow() throws Exception { + writeMeta("blog/approved.md", "status: draft\napproval: approved", "Approved"); + writeMeta("blog/blocked.md", "status: published\napproval: blocked", "Blocked"); + + var collections = createCollections(); + try { + var requestContext = new RequestContext(); + requestContext.add( + WorkflowFeature.class, + new WorkflowFeature(new WorkflowInstance("approval", "Approval", approvalStatusProvider()))); + + var result = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext) + .call(() -> collections.collection("blog").query().page(1, 10)); + var approvedItem = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext) + .call(() -> collections.collection("blog").item("approved")); + var blockedItem = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext) + .call(() -> collections.collection("blog").item("blocked")); + + Assertions.assertThat(result.getTotalItems()).isEqualTo(1); + Assertions.assertThat(result.getItems()) + .singleElement() + .satisfies(item -> Assertions.assertThat(item.id()).isEqualTo("approved")); + Assertions.assertThat(approvedItem).isPresent(); + Assertions.assertThat(blockedItem).isEmpty(); + } finally { + collections.close(); + } + } + + private FileCollections createCollections() throws Exception { + var collections = new FileCollections("test-site", tempDirectory, FileCollectionsTest::parseMeta); + collections.init(); + return collections; + } + + private Path write(String relativePath, String meta, String body) throws Exception { + return writeMeta(relativePath, "status: published\n" + meta, body); + } + + private Path writeMeta(String relativePath, String meta, String body) throws Exception { + var file = tempDirectory.resolve("collections").resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, "---%n%s%n---%n%s%n".formatted(meta, body)); + return file; + } + + private static WFStatusProvider approvalStatusProvider() { + return new WFStatusQueryProvider() { + @Override + public boolean isPublished(ContentNode node) { + return "approved".equals(node.data().get("approval")); + } + + @Override + public Status status(ContentNode node) { + return new Status( + isPublished(node), + null, + null, + true, + (String) node.data().get("approval")); + } + + @Override + public String newNodeStatus() { + return "blocked"; + } + + @Override + public Optional> published(ContentQuery query) { + return Optional.of(query.where("approval", "approved")); + } + + @Override + public ContentQuery unpublished(ContentQuery query) { + return query.where("approval", "!=", "approved"); + } + }; + } + + @SuppressWarnings("unchecked") + private static Map parseMeta(Path file) { + try { + var content = Files.readString(file); + var parts = content.split("---", 3); + return parts.length == 3 ? new Yaml().load(parts[1]) : Map.of(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } +} 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 index 22252f496..9382646af 100644 --- a/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemIncrementalEventTest.java +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/FileSystemIncrementalEventTest.java @@ -21,7 +21,10 @@ * #L% */ +import static org.mockito.Mockito.mock; + import com.condation.cms.api.Constants; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.eventbus.EventListener; import com.condation.cms.api.eventbus.events.ContentChangedEvent; @@ -29,6 +32,7 @@ import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; @@ -51,7 +55,7 @@ public void directoryRenameReindexesOnlyTheMovedSubtree() throws Exception { 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 -> { + var fileSystem = new FileSystem("test-site", tempDirectory, mock(EventBus.class), file -> { try { return new Yaml().load(Files.readString(file)); } catch (Exception ex) { @@ -138,6 +142,41 @@ public void duplicateWatcherAndExplicitEventsAreProcessedAsOneBatch() throws Exc } } + @Test + public void explicitReindexReparsesContentWithUpdatedIndexFields() throws Exception { + var content = tempDirectory.resolve("content"); + Files.createDirectories(content); + Files.writeString(content.resolve("location.md"), """ + status: published + location: + latitude: 51.4818 + longitude: 7.2162 + """); + var parseCount = new AtomicInteger(); + var fileSystem = new FileSystem("test-site", tempDirectory, mock(EventBus.class), file -> { + parseCount.incrementAndGet(); + try { + return new Yaml().load(Files.readString(file)); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }); + + try { + fileSystem.init(); + fileSystem.reindex(Map.of("location", "geo")); + + Assertions.assertThat(parseCount).hasValue(2); + Assertions.assertThat(fileSystem.query((node, excerptLength) -> node) + .within("location", 51.4818, 7.2162, 1, "km") + .get()) + .extracting(ContentNode::uri) + .containsExactly("location.md"); + } 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/ReferencedCollectionsTest.java b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java new file mode 100644 index 000000000..c317023db --- /dev/null +++ b/cms-filesystem/src/test/java/com/condation/cms/filesystem/ReferencedCollectionsTest.java @@ -0,0 +1,99 @@ +package com.condation.cms.filesystem; + +/*- + * #%L + * CMS FileSystem + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.core.serivce.ServiceRegistry; +import com.condation.cms.core.serivce.impl.SiteDBService; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ReferencedCollectionsTest { + + @AfterEach + void clearServices() { + ServiceRegistry.getInstance().clear(); + } + + @Test + void resolvesReferencedCollectionsThroughTheSourceSite() { + var local = mock(Collections.class); + when(local.names()).thenReturn(Set.of("local")); + var sourceCollections = mock(Collections.class); + var sourceCollection = mock(Collection.class); + when(sourceCollections.isLocal("shared")).thenReturn(true); + when(sourceCollections.collection("shared")).thenReturn(sourceCollection); + var sourceDB = mock(DB.class); + when(sourceDB.getCollections()).thenReturn(sourceCollections); + ServiceRegistry.getInstance().register( + "content-site", + SiteDBService.class, + new SiteDBService(sourceDB)); + var definitions = new ConcurrentHashMap(); + definitions.put("shared", new CollectionDefinition("shared", "content-site", null)); + var collections = new ReferencedCollections( + "consumer-site", + local, + new CollectionConfiguration(definitions)); + + Assertions.assertThat(collections.names()).containsExactlyInAnyOrder("local", "shared"); + Assertions.assertThat(collections.isLocal("shared")).isFalse(); + Assertions.assertThat(collections.collection("shared")).isSameAs(sourceCollection); + Assertions.assertThatThrownBy(() -> collections.refresh("shared", "item")) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("read-only"); + } + + @Test + void usesReloadedConfigurationWithoutRecreatingTheCollectionsFacade() { + var local = mock(Collections.class); + var localCollection = mock(Collection.class); + when(local.names()).thenReturn(Set.of("shared")); + when(local.collection("shared")).thenReturn(localCollection); + var definitions = new ConcurrentHashMap(); + definitions.put("shared", new CollectionDefinition("shared", "content-site", null)); + var configuration = new CollectionConfiguration(definitions); + var collections = new ReferencedCollections( + "consumer-site", + local, + configuration); + + definitions.put("shared", new CollectionDefinition("shared", null)); + configuration.replaceCollections(definitions); + + Assertions.assertThat(collections.isLocal("shared")).isTrue(); + Assertions.assertThat(collections.collection("shared")).isSameAs(localCollection); + collections.refresh("shared", "item"); + verify(local).refresh("shared", "item"); + } +} diff --git a/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/executor/ActionExecutor.java b/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/executor/ActionExecutor.java index 98f165d3b..3fb96e7ec 100644 --- a/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/executor/ActionExecutor.java +++ b/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/executor/ActionExecutor.java @@ -22,6 +22,7 @@ */ import com.condation.cms.api.hooks.ActionContext; +import com.condation.cms.hooksystem.ActionHook; import com.condation.cms.hooksystem.registry.ActionRegistry; import java.util.ArrayList; import java.util.HashMap; @@ -47,7 +48,8 @@ public List execute(String name, Map arguments) { registry.get(name).forEach(hook -> { try { - T result = (T) hook.function().apply(context); + var typedHook = (ActionHook) hook; + T result = (T) typedHook.function().apply(context); if (result != null) { context.results().add(result); } diff --git a/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/registry/ActionRegistry.java b/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/registry/ActionRegistry.java index 5ad3fcad8..074dde7d3 100644 --- a/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/registry/ActionRegistry.java +++ b/cms-hooksystem/src/main/java/com/condation/cms/hooksystem/registry/ActionRegistry.java @@ -35,13 +35,13 @@ */ public class ActionRegistry { - private final Multimap hooks = ArrayListMultimap.create(); + private final Multimap> hooks = ArrayListMultimap.create(); public void register(String name, ActionFunction function, int priority) { hooks.put(name, new ActionHook<>(name, priority, function)); } - public List get(String name) { + public List> get(String name) { return hooks.get(name).stream() .sorted(Comparator.comparingInt(ActionHook::priority)) .toList(); diff --git a/cms-server/src/main/java/com/condation/cms/cli/commands/HostCommands.java b/cms-server/src/main/java/com/condation/cms/cli/commands/HostCommands.java index f09866695..b1bf35371 100644 --- a/cms-server/src/main/java/com/condation/cms/cli/commands/HostCommands.java +++ b/cms-server/src/main/java/com/condation/cms/cli/commands/HostCommands.java @@ -23,7 +23,7 @@ import com.condation.cms.cli.commands.host.ReloadHost; -import lombok.extern.slf4j.Slf4j; +import com.condation.cms.cli.commands.host.ReIndexHost; import picocli.CommandLine; /** @@ -37,13 +37,16 @@ }, mixinStandardHelpOptions = true, subcommands = { - ReloadHost.class + ReloadHost.class, + ReIndexHost.class }) -@Slf4j public class HostCommands implements Runnable { + @CommandLine.Spec + private CommandLine.Model.CommandSpec commandSpec; + @Override public void run() { - System.out.println("Subcommand needed: 'reload'"); + commandSpec.commandLine().getOut().println("Subcommand needed: 'reload' or 'reindex'"); } } diff --git a/cms-server/src/main/java/com/condation/cms/cli/commands/host/ReIndexHost.java b/cms-server/src/main/java/com/condation/cms/cli/commands/host/ReIndexHost.java new file mode 100644 index 000000000..03c24551f --- /dev/null +++ b/cms-server/src/main/java/com/condation/cms/cli/commands/host/ReIndexHost.java @@ -0,0 +1,63 @@ +package com.condation.cms.cli.commands.host; + +/*- + * #%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.ServerProperties; +import com.condation.cms.cli.tools.CLIServerUtils; +import com.condation.cms.core.configuration.ConfigurationFactory; +import com.condation.cms.core.configuration.properties.ExtendedServerProperties; +import com.condation.cms.ipc.Command; +import com.condation.cms.ipc.IPCClient; +import java.util.concurrent.Callable; +import picocli.CommandLine; + +@CommandLine.Command( + name = "reindex", + description = "Rebuilds all metadata indexes of a site. The server must be running." +) +public class ReIndexHost implements Callable { + + @CommandLine.Spec + private CommandLine.Model.CommandSpec commandSpec; + + @CommandLine.Parameters( + paramLabel = "", + index = "0", + description = "The site to reindex." + ) + private String site; + + @Override + public Integer call() throws Exception { + if (CLIServerUtils.getCMSProcess().isEmpty()) { + commandSpec.commandLine().getErr().println("server not running"); + return 1; + } + + ServerProperties properties = new ExtendedServerProperties( + ConfigurationFactory.serverConfiguration()); + var ipcClient = new IPCClient(properties.ipc()); + ipcClient.send(new Command("reindex_host").setHeader("host", site)); + commandSpec.commandLine().getOut().printf("reindex of site '%s' triggered%n", site); + return 0; + } +} diff --git a/cms-server/src/main/java/com/condation/cms/ipc/IPCProtocol.java b/cms-server/src/main/java/com/condation/cms/ipc/IPCProtocol.java index caca3b4ce..b400b70b2 100644 --- a/cms-server/src/main/java/com/condation/cms/ipc/IPCProtocol.java +++ b/cms-server/src/main/java/com/condation/cms/ipc/IPCProtocol.java @@ -26,6 +26,7 @@ import com.condation.cms.api.IPCProperties; import com.condation.cms.api.eventbus.Event; import com.condation.cms.api.eventbus.events.RepoCheckoutEvent; +import com.condation.cms.api.eventbus.events.lifecycle.ReIndexHostEvent; import com.condation.cms.api.eventbus.events.lifecycle.ReloadHostEvent; import com.condation.cms.api.eventbus.events.lifecycle.ServerShutdownInitiated; import java.util.function.Consumer; @@ -71,6 +72,14 @@ public void processInput(final String theInput) { } log.debug("trigger reload host event"); eventConsumer.accept(new ReloadHostEvent((String)hostHeader.get())); + } else if ("reindex_host".equals(command.getCommand())) { + var hostHeader = command.getHeader("host"); + if (hostHeader.isEmpty()) { + log.warn("host header not set"); + return; + } + log.debug("trigger reindex host event"); + eventConsumer.accept(new ReIndexHostEvent((String) hostHeader.get())); } else if ("repo_checkout".equals(command.getCommand())) { var repoHeader = command.getHeader("repo"); if (!repoHeader.isPresent()) { diff --git a/cms-server/src/main/java/com/condation/cms/server/JettyServer.java b/cms-server/src/main/java/com/condation/cms/server/JettyServer.java index f256c1629..e4c9f8f68 100644 --- a/cms-server/src/main/java/com/condation/cms/server/JettyServer.java +++ b/cms-server/src/main/java/com/condation/cms/server/JettyServer.java @@ -26,6 +26,7 @@ import com.condation.cms.api.eventbus.Event; import com.condation.cms.api.eventbus.EventBus; import com.condation.cms.api.eventbus.events.lifecycle.HostReadyEvent; +import com.condation.cms.api.eventbus.events.lifecycle.ReIndexHostEvent; import com.condation.cms.api.eventbus.events.lifecycle.ReloadHostEvent; import com.condation.cms.api.eventbus.events.lifecycle.ServerReadyEvent; import com.condation.cms.api.eventbus.events.lifecycle.ServerShutdownInitiated; @@ -103,6 +104,13 @@ public void reloadVHost(String vhost) { }); } + public void reindexVHost(String vhost) { + log.info("reindexing host {}", vhost); + vhosts.stream() + .filter(host -> host.id().equals(vhost)) + .forEach(host -> MdcScope.forSite(host.id()).run(host::reindex)); + } + public void startup() throws IOException { // init metrics @@ -148,6 +156,10 @@ public void startup() throws IOException { reloadVHost(event.host()); }); + serverEventBus.register(ReIndexHostEvent.class, (event) -> { + reindexVHost(event.host()); + }); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { log.debug("shutting down"); diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteHandlerModule.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteHandlerModule.java index c9e92f0fe..ee6feb868 100644 --- a/cms-server/src/main/java/com/condation/cms/server/configs/SiteHandlerModule.java +++ b/cms-server/src/main/java/com/condation/cms/server/configs/SiteHandlerModule.java @@ -44,6 +44,7 @@ import com.condation.cms.server.handler.StaticFileHandler; import com.condation.cms.server.handler.WellKnownHandler; import com.condation.cms.server.handler.auth.JettyAuthenticationHandler; +import com.condation.cms.server.handler.content.JettyCollectionHandler; import com.condation.cms.server.handler.content.JettyContentHandler; import com.condation.cms.server.handler.content.JettyTaxonomyHandler; import com.condation.cms.server.handler.content.JettyViewHandler; @@ -73,6 +74,7 @@ public class SiteHandlerModule extends AbstractModule { protected void configure() { bind(JettyViewHandler.class).in(Singleton.class); + bind(JettyCollectionHandler.class).in(Singleton.class); bind(JettyContentHandler.class).in(Singleton.class); bind(JettyTaxonomyHandler.class).in(Singleton.class); bind(RoutesHandler.class).in(Singleton.class); diff --git a/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java b/cms-server/src/main/java/com/condation/cms/server/configs/SiteModule.java index b35470036..f585515c6 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 @@ -59,6 +59,7 @@ import com.condation.cms.api.workflow.Workflow; import com.condation.cms.api.workflow.WorkflowInstance; import com.condation.cms.auth.services.AuthService; +import com.condation.cms.content.CollectionResolver; import com.condation.cms.content.ContentRenderer; import com.condation.cms.content.ContentResolver; import com.condation.cms.content.DefaultContentParser; @@ -376,6 +377,15 @@ public ContentResolver contentResolver(ContentRenderer contentRenderer, return new ContentResolver(contentRenderer, db, variantResolver, variantSelector); } + @Provides + @Singleton + public CollectionResolver collectionResolver( + ContentRenderer contentRenderer, + FileDB db, + Configuration configuration) { + return new CollectionResolver(contentRenderer, db, configuration); + } + @Provides @Singleton public VariantResolver variantResolver(FileDB db) { diff --git a/cms-server/src/main/java/com/condation/cms/server/handler/content/JettyCollectionHandler.java b/cms-server/src/main/java/com/condation/cms/server/handler/content/JettyCollectionHandler.java new file mode 100644 index 000000000..d880fd4a2 --- /dev/null +++ b/cms-server/src/main/java/com/condation/cms/server/handler/content/JettyCollectionHandler.java @@ -0,0 +1,61 @@ +package com.condation.cms.server.handler.content; + +/*- + * #%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 com.condation.cms.api.content.DefaultContentResponse; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.content.CollectionResolver; +import com.google.inject.Inject; +import lombok.RequiredArgsConstructor; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; + +/** + * Serves collection items whose detail routes are configured for the site. + */ +@RequiredArgsConstructor(onConstructor = @__({@Inject})) +public class JettyCollectionHandler extends Handler.Abstract { + + private final CollectionResolver collectionResolver; + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + var requestContext = (RequestContext) request.getAttribute(Constants.REQUEST_CONTEXT_ATTRIBUTE_NAME); + var resolvedContent = collectionResolver.getContent(requestContext); + if (resolvedContent.isEmpty()) { + return false; + } + + var content = (DefaultContentResponse) resolvedContent.get(); + response.setStatus(200); + response.getHeaders().add( + HttpHeader.CONTENT_TYPE, + "%s; charset=utf-8".formatted(content.contentType())); + Content.Sink.write(response, true, content.content(), callback); + return true; + } +} diff --git a/cms-server/src/main/java/com/condation/cms/server/host/VHost.java b/cms-server/src/main/java/com/condation/cms/server/host/VHost.java index 9fa6a7824..ebef54628 100644 --- a/cms-server/src/main/java/com/condation/cms/server/host/VHost.java +++ b/cms-server/src/main/java/com/condation/cms/server/host/VHost.java @@ -70,6 +70,7 @@ import com.condation.cms.server.filter.PreviewFilter; import com.condation.cms.server.handler.StaticFileHandler; import com.condation.cms.server.handler.auth.JettyAuthenticationHandler; +import com.condation.cms.server.handler.content.JettyCollectionHandler; import com.condation.cms.server.handler.content.JettyContentHandler; import com.condation.cms.server.handler.content.JettyTaxonomyHandler; import com.condation.cms.server.handler.content.JettyViewHandler; @@ -168,6 +169,16 @@ public void reload() { } } + public void reindex() { + try { + injector.getInstance(ConfigManagement.class).reload(); + injector.getInstance(FileDB.class).reindex(); + log.info("reindex of host {} completed", id()); + } catch (Exception e) { + log.error("reindex of host {} failed", id(), e); + } + } + public List hostnames() { return injector.getInstance(SiteProperties.class).hostnames(); } @@ -402,6 +413,7 @@ private Handler createRootSequence(PreviewFilter uiPreviewFilter) { contentHandler = injector.getInstance(JettyContentHandler.class); var taxonomyHandler = injector.getInstance(JettyTaxonomyHandler.class); + var collectionHandler = injector.getInstance(JettyCollectionHandler.class); var viewHandler = injector.getInstance(JettyViewHandler.class); var routesHandler = injector.getInstance(RoutesHandler.class); var authHandler = injector.getInstance(JettyAuthenticationHandler.class); @@ -422,6 +434,7 @@ private Handler createRootSequence(PreviewFilter uiPreviewFilter) { uiPreviewFilter, viewHandler, taxonomyHandler, + collectionHandler, contentHandler ); diff --git a/cms-server/src/test/java/com/condation/cms/cli/commands/HostCommandsTest.java b/cms-server/src/test/java/com/condation/cms/cli/commands/HostCommandsTest.java new file mode 100644 index 000000000..d85fb4db4 --- /dev/null +++ b/cms-server/src/test/java/com/condation/cms/cli/commands/HostCommandsTest.java @@ -0,0 +1,38 @@ +package com.condation.cms.cli.commands; + +/*- + * #%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 org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +class HostCommandsTest { + + @Test + void exposesReindexSubcommand() { + var commandLine = new CommandLine(new HostCommands()); + var parsed = commandLine.parseArgs("reindex", "demo"); + + Assertions.assertThat(commandLine.getSubcommands()).containsKey("reindex"); + Assertions.assertThat(parsed.subcommand().commandSpec().name()).isEqualTo("reindex"); + } +} diff --git a/cms-server/src/test/java/com/condation/cms/ipc/IPCProtocolTest.java b/cms-server/src/test/java/com/condation/cms/ipc/IPCProtocolTest.java new file mode 100644 index 000000000..02e7ef844 --- /dev/null +++ b/cms-server/src/test/java/com/condation/cms/ipc/IPCProtocolTest.java @@ -0,0 +1,59 @@ +package com.condation.cms.ipc; + +/*- + * #%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.IPCProperties; +import com.condation.cms.api.eventbus.Event; +import com.condation.cms.api.eventbus.events.lifecycle.ReIndexHostEvent; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class IPCProtocolTest { + + @Test + void translatesReindexCommandIntoHostEvent() { + var event = new AtomicReference(); + var protocol = new IPCProtocol(event::set, new TestIPCProperties()); + var input = new IPCCommands().toJsonString( + new Command("reindex_host").setHeader("host", "demo")); + + protocol.processInput(input); + + Assertions.assertThat(event.get()) + .isEqualTo(new ReIndexHostEvent("demo")); + } + + private record TestIPCProperties() implements IPCProperties { + + @Override + public int port() { + return 0; + } + + @Override + public Optional password() { + return Optional.empty(); + } + } +} diff --git a/cms-templates/src/main/java/com/condation/cms/templates/DynamicConfiguration.java b/cms-templates/src/main/java/com/condation/cms/templates/DynamicConfiguration.java index 311bb1172..901ed9d77 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/DynamicConfiguration.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/DynamicConfiguration.java @@ -119,7 +119,13 @@ public Object invoke(Object... params) { } else if (params.length == 1 && params[0] instanceof Map) { parameter = new Parameter((Map)params[0], requestContext); } else { - parameter = new Parameter(); + parameter = new Parameter(requestContext); + for (int index = 0; index < params.length; index++) { + parameter.put(Integer.toString(index), params[index]); + } + if (params.length == 1) { + parameter.put("value", params[0]); + } } return function.apply(parameter); } diff --git a/cms-templates/src/main/java/com/condation/cms/templates/utils/MacroUtils.java b/cms-templates/src/main/java/com/condation/cms/templates/utils/MacroUtils.java index 2415d8b2d..f6ec1662d 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/utils/MacroUtils.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/utils/MacroUtils.java @@ -50,7 +50,7 @@ public static Optional parseMacro(String expression) { List paramList = new ArrayList<>(); if (!params.isEmpty()) { - paramList = Arrays.asList(params.split("\\s*,\\s*")); + paramList = Arrays.asList(params.split("\\s*+,\\s*+")); } return Optional.of(new MacroTag.Macro(methodName, paramList)); diff --git a/cms-templates/src/main/java/com/condation/cms/templates/utils/TemplateUtils.java b/cms-templates/src/main/java/com/condation/cms/templates/utils/TemplateUtils.java index ba682f272..0558881cb 100644 --- a/cms-templates/src/main/java/com/condation/cms/templates/utils/TemplateUtils.java +++ b/cms-templates/src/main/java/com/condation/cms/templates/utils/TemplateUtils.java @@ -44,7 +44,7 @@ public static boolean hasFilters(String expression) { return false; } - String[] parts = expression.split("\\s+\\|\\s+"); // Nur " | " als Trenner verwenden + String[] parts = expression.split("\\s++\\|\\s++"); // Nur " | " als Trenner verwenden return parts.length > 1; } @@ -54,7 +54,7 @@ public static List extractFilters(String expression) { return filters; } - String[] parts = expression.split("\\s+\\|\\s+"); + String[] parts = expression.split("\\s++\\|\\s++"); if (parts.length < 2) { return filters; } diff --git a/cms-templates/src/test/java/com/condation/cms/templates/DynamicConfigurationFunctionWrapperTest.java b/cms-templates/src/test/java/com/condation/cms/templates/DynamicConfigurationFunctionWrapperTest.java new file mode 100644 index 000000000..6a751bacd --- /dev/null +++ b/cms-templates/src/test/java/com/condation/cms/templates/DynamicConfigurationFunctionWrapperTest.java @@ -0,0 +1,40 @@ +package com.condation.cms.templates; + +/*- + * #%L + * CMS Templates + * %% + * 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.request.RequestContext; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class DynamicConfigurationFunctionWrapperTest { + + @Test + void exposesSinglePositionalArgumentToContextStyleFunctions() { + var wrapper = new DynamicConfiguration.FunctionWrapper( + "cms", + "collection", + params -> params.get("value"), + new RequestContext()); + + Assertions.assertThat(wrapper.invoke("blog")).isEqualTo("blog"); + } +} diff --git a/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java b/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java index e247fc616..64e083b5f 100644 --- a/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java +++ b/cms-templates/src/test/java/com/condation/cms/templates/TemplateEngineFunctionsTest.java @@ -46,7 +46,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.Function; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -100,6 +99,7 @@ public TemplateLoader getLoader() { .add("fn_param", "{{ ext.testfn4({'name': 'World'}) }}") // explicit namespace .add("fn_namespace", "{{ ns1.shout({'text': 'hello'}) }}") + .add("fn_collection", "{{ cms.collection('blog').query() }}") // chained with filter .add("fn_chained", "{{ ext.testfn3() | upper }}"); } @@ -157,6 +157,13 @@ void test_explicit_namespace_function_renders() throws IOException { .isEqualToIgnoringWhitespace("HELLO!"); } + @Test + void test_collection_function_accepts_scalar_name_and_can_be_chained() throws IOException { + Template template = SUT.getTemplate("fn_collection"); + Assertions.assertThat(template.evaluate(Map.of(), dynamicConfiguration)) + .isEqualToIgnoringWhitespace("blog"); + } + // --- function result can be chained with filter --- @Test @@ -171,8 +178,9 @@ void test_function_result_chained_with_filter() throws IOException { @Test void all_functions_are_registered() { var tfs = dynamicConfiguration.templateFunctions(); - // testfn1 (map), testfn2 (Parameter), testfn3 (no-arg), testfn4 (@Param), shout (@Param, ns1) - Assertions.assertThat(tfs).isNotNull().hasSize(5); + // testfn1 (map), testfn2 (Parameter), testfn3 (no-arg), testfn4 (@Param), + // shout (@Param, ns1), collection (Parameter, cms) + Assertions.assertThat(tfs).isNotNull().hasSize(6); } @Test @@ -216,5 +224,17 @@ public Object testfn4(@Param("name") String name) { public Object shout(@Param("text") String text) { return text.toUpperCase() + "!"; } + + @TemplateFunction(value = "collection", namespace = "cms") + public Object collection(Parameter params) { + return new TestCollection(params.get("value").toString()); + } + } + + public record TestCollection(String name) { + + public String query() { + return name; + } } } diff --git a/documentation/collections.md b/documentation/collections.md new file mode 100644 index 000000000..508c44084 --- /dev/null +++ b/documentation/collections.md @@ -0,0 +1,32 @@ +# Collections + +Collection detail pages are configured in `config/collections.yaml`. A route can use the item ID +and any number of metadata fields. Nested metadata is addressed with dot notation. + +```yaml +collections: + events: + detail: + route: /events/{date:yyyy}/{date:MM}/{date:dd}/{location.country}/{location.city} + template: collections/event-detail.html + mappings: + location.country: + de: germany + fr: france +``` + +A format is appended to its field with `:`, for example `{date:yyyy}`. It uses Java +`DateTimeFormatter` patterns. Date values may be `java.util.Date`, Java time values such as +`LocalDate`, or ISO-8601 strings. A format must not contain `/`. Each desired URL segment therefore +needs its own placeholder; the example produces `/events/2026/09/03/germany/berlin`. + +`mappings` translates the exact metadata value before it is converted to a URL slug. Every value +used by an item must have a configured mapping; a missing mapping makes URL generation fail and the +item cannot match an incoming detail route. Routes must resolve to exactly one collection item. + +Existing single-field routes remain valid: + +```yaml +route: /articles/{id} +route: /articles/{slug} +``` diff --git a/documentation/index.md b/documentation/index.md index 7b46e9819..2503455aa 100644 --- a/documentation/index.md +++ b/documentation/index.md @@ -2,4 +2,6 @@ ## Basics [Running](running.md) -[Routing](routing.md) \ No newline at end of file +[Routing](routing.md) + +[Collections](collections.md) diff --git a/modules/example-module/src/main/resources/com/condation/cms/modules/example/ui/example-action.js b/modules/example-module/src/main/resources/com/condation/cms/modules/example/ui/example-action.js index 41a9cbe38..b9a278f56 100644 --- a/modules/example-module/src/main/resources/com/condation/cms/modules/example/ui/example-action.js +++ b/modules/example-module/src/main/resources/com/condation/cms/modules/example/ui/example-action.js @@ -18,7 +18,7 @@ * along with this program. If not, see . * #L% */ -import { showToast } from 'condation-cms-ui/dist/js/modules/toast.js'; +import { showToast } from '@cms/modules/toast.js'; export async function runAction(parameters) { console.log("This is an example action"); showToast({ diff --git a/modules/example-module/src/main/ts/dist/example-action.js b/modules/example-module/src/main/ts/dist/example-action.js index 7c04ab322..950877bb7 100644 --- a/modules/example-module/src/main/ts/dist/example-action.js +++ b/modules/example-module/src/main/ts/dist/example-action.js @@ -1,4 +1,24 @@ -import { showToast } from 'condation-cms-ui/dist/js/modules/toast.js'; +/*- + * #%L + * CMS Example Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { showToast } from '@cms/modules/toast.js'; export async function runAction(parameters) { console.log("This is an example action"); showToast({ diff --git a/modules/example-module/src/main/ts/package-lock.json b/modules/example-module/src/main/ts/package-lock.json new file mode 100644 index 000000000..cefbd4c77 --- /dev/null +++ b/modules/example-module/src/main/ts/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "ts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ts", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "condation-cms-ui": "^0.2.0", + "typescript": "^5.9.3" + } + }, + "node_modules/condation-cms-ui": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/condation-cms-ui/-/condation-cms-ui-0.2.0.tgz", + "integrity": "sha512-IY5byzVFjN92oM4MCk6ZM64Lwu1Ql34iA02R3cAOiTEcJG5/jRuTySBsVGhvS6rEeIjfO9WrKV0SmRO0Qc7l5A==", + "dependencies": { + "typescript": "next" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/modules/example-module/src/main/ts/package.json b/modules/example-module/src/main/ts/package.json index 20a5aeeba..2917e607d 100644 --- a/modules/example-module/src/main/ts/package.json +++ b/modules/example-module/src/main/ts/package.json @@ -4,7 +4,7 @@ "description": "", "main": "index.js", "scripts": { - "compile": "tsc" + "build": "tsc" }, "keywords": [], "author": "", diff --git a/modules/example-module/src/main/ts/src/example-action.ts b/modules/example-module/src/main/ts/src/example-action.ts index 1d44ae9c7..486364b60 100644 --- a/modules/example-module/src/main/ts/src/example-action.ts +++ b/modules/example-module/src/main/ts/src/example-action.ts @@ -19,7 +19,7 @@ * #L% */ -import { showToast } from 'condation-cms-ui/dist/js/modules/toast.js'; +import { showToast } from '@cms/modules/toast.js'; export async function runAction(parameters : any) : Promise { console.log("This is an example action"); diff --git a/modules/example-module/src/main/ts/tsconfig.json b/modules/example-module/src/main/ts/tsconfig.json index 757f2b4e9..4a0c3b762 100644 --- a/modules/example-module/src/main/ts/tsconfig.json +++ b/modules/example-module/src/main/ts/tsconfig.json @@ -9,7 +9,10 @@ "allowJs": true, "checkJs": false, "lib": ["dom", "es2020"], - "sourceMap": false + "sourceMap": false, + "paths": { + "@cms/modules/*": ["./node_modules/condation-cms-ui/dist/js/modules/*"] + } }, "include": [ "src/**/*", diff --git a/modules/system-modules/src/main/java/com/condation/cms/modules/system/tags/ImageTags.java b/modules/system-modules/src/main/java/com/condation/cms/modules/system/tags/ImageTags.java index 9fabd8293..0d8491407 100644 --- a/modules/system-modules/src/main/java/com/condation/cms/modules/system/tags/ImageTags.java +++ b/modules/system-modules/src/main/java/com/condation/cms/modules/system/tags/ImageTags.java @@ -60,8 +60,8 @@ public String getImage (Parameter param) { .formatted( mediaUrl, StringEscapeUtils.ESCAPE_HTML4.translate(altText), - media.meta().getOrDefault("width", -1), - media.meta().getOrDefault("height", -1) + (int) media.meta().getOrDefault("width", -1), + (int) media.meta().getOrDefault("height", -1) ); } diff --git a/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java b/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java new file mode 100644 index 000000000..138081b73 --- /dev/null +++ b/modules/system-modules/src/main/java/com/condation/cms/modules/system/templates/CollectionTemplateFunctionExtensions.java @@ -0,0 +1,44 @@ +package com.condation.cms.modules.system.templates; + +/*- + * #%L + * CMS System Modules + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.annotations.TemplateFunction; +import com.condation.cms.api.extensions.RegisterTemplateFunctionExtensionPoint; +import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.model.Parameter; +import com.condation.modules.api.annotation.Extension; + +/** + * Makes site collections available as {@code cms.collection('name')}. + */ +@Extension(value = RegisterTemplateFunctionExtensionPoint.class, cached = Extension.Caching.TRUE) +public class CollectionTemplateFunctionExtensions extends RegisterTemplateFunctionExtensionPoint { + + @TemplateFunction(value = "collection", namespace = "cms") + public Object collection(Parameter params) { + var value = params.get(params.containsKey("name") ? "name" : "value"); + if (value == null) { + throw new IllegalArgumentException("collection name must not be null"); + } + return context.get(DBFeature.class).db().getCollections().collection(value.toString()); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java new file mode 100644 index 000000000..47cce840f --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/CollectionMenuExtension.java @@ -0,0 +1,76 @@ +package com.condation.cms.modules.ui.extensionpoints; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.extensions.AbstractExtensionPoint; +import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.HookSystemFeature; +import com.condation.cms.api.ui.action.UIScriptAction; +import com.condation.cms.api.ui.elements.Menu; +import com.condation.cms.api.ui.elements.MenuEntry; +import com.condation.cms.api.ui.extensions.UIActionsExtensionPoint; +import com.condation.cms.api.utils.HTTPUtil; +import com.condation.cms.modules.ui.utils.UIHooks; +import com.condation.modules.api.annotation.Extension; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** Adds one manager menu entry for every collection discovered on disk. */ +@Extension(UIActionsExtensionPoint.class) +public class CollectionMenuExtension extends AbstractExtensionPoint implements UIActionsExtensionPoint { + + @Override + public void addMenuItems(Menu menu) { + var db = getContext().get(DBFeature.class).db(); + var names = db.getCollections().names().stream() + .filter(db.getCollections()::isLocal) + .sorted() + .toList(); + if (names.isEmpty()) { + return; + } + + var contentTypes = new UIHooks(getRequestContext().get(HookSystemFeature.class).hookSystem()).contentTypes(); + var position = new AtomicInteger(1); + var children = names.stream().map(name -> MenuEntry.builder() + .id("collection-" + name) + .name(contentTypes.getCollection(name).map(type -> type.label()).orElse(name)) + .position(position.getAndIncrement()) + .permissions(List.of(Permissions.CONTENT_EDIT)) + .action(new UIScriptAction( + HTTPUtil.modifyUrl("/manager/actions/collection/manage-collection", getContext()), + Map.of("collection", name))) + .children(new ArrayList<>()) + .build()).toList(); + + menu.addMenuEntry(MenuEntry.builder() + .id("collections-menu") + .name("Collections") + .position(5) + .permissions(List.of(Permissions.CONTENT_EDIT)) + .children(new ArrayList<>(children)) + .build()); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuManagerUiActionExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuManagerUiActionExtension.java index bfd14c91e..fb934b72b 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuManagerUiActionExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/MenuManagerUiActionExtension.java @@ -48,5 +48,6 @@ public class MenuManagerUiActionExtension extends AbstractExtensionPoint impleme scriptAction = @ScriptAction(module = "/manager/actions/menu/manage-menus") ) public void manage_menus() { + // can be empty } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java index 2f3b59062..712b93b3f 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/PageMenuExtension.java @@ -33,146 +33,124 @@ * * @author t.marx */ - @Extensions({ - @Extension(UIActionsExtensionPoint.class), - @Extension(HookSystemRegisterExtensionPoint.class), - @Extension(UILocalizationExtensionPoint.class) + @Extension(UIActionsExtensionPoint.class), + @Extension(HookSystemRegisterExtensionPoint.class), + @Extension(UILocalizationExtensionPoint.class) }) public class PageMenuExtension extends HookSystemRegisterExtensionPoint implements UIActionsExtensionPoint, UILocalizationExtensionPoint { -// @com.condation.cms.api.ui.annotations.MenuEntry( -// id = "pageMenu", -// name = "Page", -// position = 10 -// ) -// public void parentDefinition() { -// -// } + @ShortCut( + id = "page-create", + title = "Create new page", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-3", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-page") + ) + public void createPage() { + // can be empty + + } + + @ShortCut( + id = "page-edit-meta", + title = "Edit page settings", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-2", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/edit-page-settings") + ) + public void pageSettings() { + // can be empty + } - /* - @com.condation.cms.api.ui.annotations.MenuEntry( - parent = "pageMenu", - id = "page-create", - name = "Create new page", - position = 1, - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-page") - )*/ - @ShortCut( - id = "page-create", - title = "Create new page", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-3", - section = "Page", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-page") - ) - public void create_page() { - } - /* - @com.condation.cms.api.ui.annotations.MenuEntry( - parent = "pageMenu", - id = "page-edit-meta", - name = "Edit MetaData", - position = 3, - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/edit-page-settings") - )*/ - @ShortCut( - id = "page-edit-meta", - title = "Edit page settings", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-2", - section = "Page", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/edit-page-settings") - ) - public void page_settings() {} - /* - @com.condation.cms.api.ui.annotations.MenuEntry( - parent = "pageMenu", - id = "manage-assets", - name = "Manage assets", - position = 10, - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/manage-assets") - ) - */ - @ShortCut( - id = "manager-assets", - title = "Manage assets", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-4", - section = "Assets", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/manage-assets") - ) - public void manage_media() { + @ShortCut( + id = "manager-assets", + title = "Manage assets", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-4", + section = "Assets", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/manage-assets") + ) + public void managMedia() { + // can be empty + } - } - - @ShortCut( - id = "page-edit-translations", - title = "Edit page translations", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-5", - section = "Page", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/translations") - ) - public void manage_translations() {} + @ShortCut( + id = "page-edit-translations", + title = "Edit page translations", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-5", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/translations") + ) + public void manageTranslations() { + // can be empty + } - @ShortCut( - id = "page-variants", - title = "Page variants", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-6", - section = "Page", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variants") - ) - public void page_variants() {} + @ShortCut( + id = "page-variants", + title = "Page variants", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-6", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variants") + ) + public void pageVariants() { + // can be empty + } - @ShortCut( - id = "page-variant-create", - title = "Create page variant", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-7", - section = "Page", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-variant") - ) - public void create_page_variant() {} + @ShortCut( + id = "page-variant-create", + title = "Create page variant", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-7", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/create-variant") + ) + public void createPageVariant() { + // can be empty + } - @ShortCut( - id = "page-variant-selector", - title = "Configure variant selection", - permissions = {Permissions.CONTENT_EDIT}, - hotkey = "ctrl-8", - section = "Page", - scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variant-selector") - ) - public void configure_variant_selector() {} - + @ShortCut( + id = "page-variant-selector", + title = "Configure variant selection", + permissions = {Permissions.CONTENT_EDIT}, + hotkey = "ctrl-8", + section = "Page", + scriptAction = @com.condation.cms.api.ui.annotations.ScriptAction(module = "/manager/actions/page/variant-selector") + ) + public void configureVariantSelector() { + // can be empty + } - @Override - public Map> getLocalizations() { - return Map.of( - "de", Map.of( - "pageMenu", "Seite", - "page-create", "Neue Seite erstellen", - "page-edit-content", "Inhalt bearbeiten", - "page-edit-meta", "Metadaten bearbeiten", - "page-variants", "Seitenvarianten", - "page-variant-create", "Seitenvariante erstellen", - "page-variant-selector", "Variantenauswahl konfigurieren", - "language.de", "Deutsch", - "language.en", "Englisch" - ), - "en", Map.of( - "pageMenu", "Page", - "page-create", "Create new page", - "page-edit-content", "Edit content", - "page-edit-meta", "Edit metadata", - "page-variants", "Page variants", - "page-variant-create", "Create page variant", - "page-variant-selector", "Configure variant selection", - "language.de", "German", - "language.en", "English" - ) - ); - } + @Override + public Map> getLocalizations() { + return Map.of( + "de", Map.of( + "pageMenu", "Seite", + "page-create", "Neue Seite erstellen", + "page-edit-content", "Inhalt bearbeiten", + "page-edit-meta", "Metadaten bearbeiten", + "page-variants", "Seitenvarianten", + "page-variant-create", "Seitenvariante erstellen", + "page-variant-selector", "Variantenauswahl konfigurieren", + "language.de", "Deutsch", + "language.en", "Englisch" + ), + "en", Map.of( + "pageMenu", "Page", + "page-create", "Create new page", + "page-edit-content", "Edit content", + "page-edit-meta", "Edit metadata", + "page-variants", "Page variants", + "page-variant-create", "Create page variant", + "page-variant-selector", "Configure variant selection", + "language.de", "German", + "language.en", "English" + ) + ); + } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java index ae7812697..b2efcb10a 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtension.java @@ -22,9 +22,12 @@ */ import com.condation.cms.api.extensions.TemplateModelExtendingExtensionPoint; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.IsPreviewFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.utils.JSONUtil; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.module.SiteModuleContext; import com.condation.modules.api.annotation.Extension; import java.util.Collections; import java.util.HashMap; @@ -40,13 +43,14 @@ public class UiTemplateModelExtension extends TemplateModelExtendingExtensionPoi @Override public Map getModel() { - return Map.of("ui", new UIHelper(getRequestContext())); + return Map.of("ui", new UIHelper(getRequestContext(), getContext())); } @RequiredArgsConstructor public static class UIHelper { private final RequestContext requestContext; + private final SiteModuleContext siteContext; public String editMeta (String editor, String element) { return editMeta(editor, element, Collections.emptyMap()); @@ -100,6 +104,29 @@ public String toolbar (String id, String type, String[] actions, Map additional) { + if (!requestContext.has(IsPreviewFeature.class)) { + return ""; + } + if (item == null) { + return ""; + } + if (!siteContext.get(DBFeature.class).db().getCollections().isLocal(item.collection())) { + return ""; + } + var options = new HashMap<>(additional); + options.put("collection", item.collection()); + options.put("itemId", item.id()); + return toolbar(item.collection() + "-" + item.id(), "collectionItem", actions, options); + } public String mediaToolbar (String [] actions, Map options) { if (!requestContext.has(IsPreviewFeature.class)) { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/Parameters.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/Parameters.java new file mode 100644 index 000000000..c2d6af3d0 --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/Parameters.java @@ -0,0 +1,36 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +/** + * + * @author thmar + */ +public class Parameters { + + private Parameters() { + } + + + public static final String COLLECTION = "collection"; + public static final String CONTENT = "content"; +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java new file mode 100644 index 000000000..31458ff0b --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpoints.java @@ -0,0 +1,368 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.CursorPage; +import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.collection.CollectionCursorSupport; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemId; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; +import com.condation.cms.api.feature.features.EventBusFeature; +import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.ui.annotations.RemoteMethod; +import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.utils.MapUtil; +import com.condation.cms.content.utils.SlugUtil; +import com.condation.cms.content.template.functions.LinkFunction; +import com.condation.cms.core.content.io.ContentFileParser; +import com.condation.cms.core.content.io.YamlHeaderUpdater; +import com.condation.cms.modules.ui.utils.FormHelper; +import com.condation.cms.modules.ui.utils.MetaConverter; +import com.condation.cms.modules.ui.utils.NumberUtils; +import com.condation.modules.api.annotation.Extension; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** Manager endpoints for listing and editing collection items. */ +@Slf4j +@Extension(UIRemoteMethodExtensionPoint.class) +public class RemoteCollectionEndpoints extends AbstractRemoteMethodeExtension { + + private static final long DEFAULT_PAGE_SIZE = 10; + private static final long MAX_PAGE_SIZE = 100; + + public record ItemDto( + String id, + String collection, + String path, + String title, + String detailUrl, + Map meta) { + } + + public record EditableItemDto( + String id, + String collection, + String path, + String content, + Map meta) { + } + + public record CursorItemsDto(List items, String nextCursor) { + } + + @RemoteMethod(name = "collections.items", permissions = {Permissions.CONTENT_EDIT}) + public Object items(Map parameters) throws RPCException { + var db = getDB(parameters); + var collectionName = requiredString(parameters, Parameters.COLLECTION); + ensureCollectionExists(db.getCollections().names(), collectionName); + + long page = Math.max(1, NumberUtils.toLong(parameters.getOrDefault("page", 1L))); + long size = Math.clamp( + NumberUtils.toLong(parameters.getOrDefault("size", DEFAULT_PAGE_SIZE)), + 1, + MAX_PAGE_SIZE); + var title = optionalString(parameters, "query"); + + var query = db.getCollections().collection(collectionName).metadataQuery(); + if (!title.isBlank()) { + query.searchByTitle(title); + } + query.orderby(Constants.MetaFields.TITLE).asc(); + Page result = query.page(page, size); + return new Page<>( + result.getTotalItems(), + result.getPageSize(), + result.getTotalPages(), + result.getPage(), + result.getItems().stream().map(this::itemDto).toList()); + } + + @RemoteMethod(name = "collections.items.cursor", permissions = {Permissions.CONTENT_EDIT}) + public Object cursorItems(Map parameters) throws RPCException { + var db = getDB(parameters); + var collectionName = requiredString(parameters, Parameters.COLLECTION); + ensureCollectionExists(db.getCollections().names(), collectionName); + long size = Math.clamp( + NumberUtils.toLong(parameters.getOrDefault("size", DEFAULT_PAGE_SIZE)), + 1, + MAX_PAGE_SIZE); + if (!(db.getCollections() instanceof CollectionCursorSupport cursorSupport)) { + throw new RPCException(501, "collection storage does not support cursor paging"); + } + var title = optionalString(parameters, "query"); + CursorPage result = cursorSupport.metadataCursorPage( + collectionName, + optionalString(parameters, "cursor"), + size, + query -> { + if (!title.isBlank()) { + query.searchByTitle(title); + } + query.orderby(Constants.MetaFields.TITLE).asc(); + }); + return new CursorItemsDto( + result.items().stream().map(this::itemDto).toList(), + result.nextCursor()); + } + + + @RemoteMethod(name = "collections.item.get", permissions = {Permissions.CONTENT_EDIT}) + public Object get(Map parameters) throws RPCException { + var item = item(parameters); + return new EditableItemDto( + item.id(), + item.collection(), + item.path(), + item.content(), + item.meta()); + } + + @RemoteMethod(name = "collections.item.save", permissions = {Permissions.CONTENT_EDIT}) + public synchronized Object save(Map parameters) throws RPCException { + var db = getDB(parameters); + var item = item(parameters); + ensureLocalCollection(db, item.collection()); + var sourceFile = db.getFileSystem().collectionsBase().resolve(item.path()); + var writableFile = db.getFileSystem().resolve(Constants.Folders.COLLECTIONS).resolve(item.path()); + try { + var parser = new ContentFileParser(sourceFile); + var meta = new HashMap<>(parser.getHeader()); + var rawMeta = typedMeta(parameters.get("meta")); + YamlHeaderUpdater.mergeFlatMapIntoNestedMap(meta, MetaConverter.convertMeta(rawMeta)); + normalizeAndValidateSlug(db, item.collection(), item.id(), meta); + var content = parameters.containsKey(Parameters.CONTENT) + ? FormHelper.getContent(parameters.get(Parameters.CONTENT)) + : parser.getContent(); + YamlHeaderUpdater.saveMarkdownFileWithHeader(writableFile, meta, content); + db.getCollections().refresh(item.collection(), item.id()); + invalidateContentCache(); + return Map.of("saved", true); + } catch (IOException | RuntimeException ex) { + log.error("could not save collection item {}/{}", item.collection(), item.id(), ex); + throw new RPCException(0, ex.getMessage()); + } + } + + @RemoteMethod(name = "collections.item.create", permissions = {Permissions.CONTENT_EDIT}) + public synchronized Object create(Map parameters) throws RPCException { + var db = getDB(parameters); + var collectionName = requiredString(parameters, Parameters.COLLECTION); + var id = requiredItemId(parameters); + ensureCollectionExists(db.getCollections().names(), collectionName); + ensureLocalCollection(db, collectionName); + var writableFile = writableFile(db, collectionName, id); + if (Files.exists(writableFile)) { + throw new RPCException(409, "collection item already exists"); + } + + var meta = new HashMap(); + YamlHeaderUpdater.mergeFlatMapIntoNestedMap( + meta, + MetaConverter.convertMeta(typedMeta(parameters.get("meta")))); + normalizeAndValidateSlug(db, collectionName, id, meta); + meta.putIfAbsent(Constants.MetaFields.TITLE, id); + meta.put("createdAt", Date.from(Instant.now())); + meta.put("createdBy", getUserName()); + meta.put( + Constants.MetaFields.STATUS, + getContext().get(WorkflowFeature.class).workflow().getStatusProvider().newNodeStatus()); + var content = FormHelper.getContent(parameters.get(Parameters.CONTENT)); + + try { + Files.createDirectories(writableFile.getParent()); + YamlHeaderUpdater.saveMarkdownFileWithHeader(writableFile, meta, content); + db.getCollections().refresh(collectionName, id); + invalidateContentCache(); + return itemDto(new CollectionItem( + id, + collectionName, + collectionName + "/" + id + ".md", + content, + meta)); + } catch (IOException | RuntimeException exception) { + log.error("could not create collection item {}/{}", collectionName, id, exception); + throw new RPCException(0, exception.getMessage()); + } + } + + @RemoteMethod(name = "collections.item.delete", permissions = {Permissions.CONTENT_EDIT}) + public Object delete(Map parameters) throws RPCException { + var db = getDB(parameters); + var collectionName = requiredString(parameters, Parameters.COLLECTION); + var id = requiredItemId(parameters); + ensureCollectionExists(db.getCollections().names(), collectionName); + ensureLocalCollection(db, collectionName); + var writableFile = writableFile(db, collectionName, id); + if (!Files.isRegularFile(writableFile)) { + throw new RPCException(404, "collection item not found"); + } + + try { + Files.delete(writableFile); + db.getCollections().refresh(collectionName, id); + invalidateContentCache(); + return Map.of("deleted", true); + } catch (IOException | RuntimeException exception) { + log.error("could not delete collection item {}/{}", collectionName, id, exception); + throw new RPCException(0, exception.getMessage()); + } + } + + private CollectionItem item(Map parameters) throws RPCException { + var db = getDB(parameters); + var collectionName = requiredString(parameters, Parameters.COLLECTION); + var id = requiredItemId(parameters); + ensureCollectionExists(db.getCollections().names(), collectionName); + try { + return db.getCollections().collection(collectionName).item(id) + .orElseThrow(() -> new RPCException(404, "collection item not found")); + } catch (IllegalArgumentException ex) { + throw new RPCException(400, ex.getMessage()); + } + } + + private ItemDto itemDto(CollectionItem item) { + return itemDto(item.id(), item.collection(), item.path(), item.meta()); + } + + private ItemDto itemDto(CollectionItemMetadata item) { + return itemDto(item.id(), item.collection(), item.path(), item.meta()); + } + + private ItemDto itemDto( + String id, + String collection, + String path, + Map meta) { + var title = meta.get(Constants.MetaFields.TITLE); + return new ItemDto( + id, + collection, + path, + title == null || title.toString().isBlank() ? id : title.toString(), + detailUrl(new CollectionItem(id, collection, path, "", meta)), + meta); + } + + private String detailUrl(CollectionItem item) { + try { + return new LinkFunction(getRequestContext()).collectionUrl(item); + } catch (IllegalArgumentException | IllegalStateException _) { + return null; + } + } + + private void invalidateContentCache() { + getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); + } + + private static Path writableFile(DB db, String collectionName, String id) { + return db.getFileSystem().resolve(Constants.Folders.COLLECTIONS) + .resolve(collectionName) + .resolve(id + ".md"); + } + + private static void ensureCollectionExists(java.util.Set names, String name) throws RPCException { + if (!names.contains(name)) { + throw new RPCException(404, "collection not found: " + name); + } + } + + private static void ensureLocalCollection(DB db, String name) throws RPCException { + if (!db.getCollections().isLocal(name)) { + throw new RPCException(403, "referenced collection is read-only: " + name); + } + } + + private static void normalizeAndValidateSlug( + DB db, + String collectionName, + String itemId, + Map meta) throws RPCException { + var value = MapUtil.getValue(meta, "slug"); + if (value == null) { + return; + } + if (!(value instanceof CharSequence)) { + throw new RPCException(400, "collection item slug must be text"); + } + + var slug = SlugUtil.slugify(value.toString()); + if (slug.isBlank()) { + throw new RPCException(400, "collection item slug must not be blank"); + } + + var duplicate = db.getCollections().collection(collectionName).metadataQuery() + .where("slug", slug) + .page(1, 2) + .getItems().stream() + .filter(item -> !item.id().equals(itemId)) + .findAny() + .isPresent(); + if (duplicate) { + throw new RPCException(409, "collection item slug already exists: " + slug); + } + meta.put("slug", slug); + } + + private static String requiredString(Map parameters, String name) throws RPCException { + var value = optionalString(parameters, name); + if (value.isBlank()) { + throw new RPCException(400, name + " must not be blank"); + } + return value; + } + + private static String requiredItemId(Map parameters) throws RPCException { + var id = requiredString(parameters, "id"); + try { + return CollectionItemId.requireValid(id); + } catch (IllegalArgumentException ex) { + throw new RPCException(400, ex.getMessage()); + } + } + + private static String optionalString(Map parameters, String name) { + return parameters.get(name) instanceof String value ? value.trim() : ""; + } + + @SuppressWarnings("unchecked") + private static Map> typedMeta(Object value) { + return value instanceof Map map + ? (Map>) map + : Map.of(); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java index 74dda2aae..aab7eac39 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtension.java @@ -29,9 +29,12 @@ import com.condation.cms.api.extensions.AbstractExtensionPoint; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.EventBusFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; +import com.condation.cms.api.feature.features.ConfigurationFeature; +import com.condation.cms.api.configuration.configs.CollectionConfiguration; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.core.content.io.ContentFileParser; @@ -50,6 +53,7 @@ import com.condation.cms.content.SectionEntry; import com.condation.cms.content.ConfigurableVariantSelector; import com.condation.cms.content.VariantResolver; +import com.condation.cms.content.CollectionRouteResolver; import com.condation.cms.modules.ui.utils.FormHelper; import com.condation.cms.modules.ui.utils.MarkdownHelper; import com.condation.cms.modules.ui.utils.MetaConverter; @@ -70,24 +74,22 @@ public class RemoteContentEndpointsExtension extends AbstractExtensionPoint impl @RemoteMethod(name = "content.get", permissions = {Permissions.CONTENT_EDIT}) public Object getContent(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); - - var uri = contentUri(parameters); - - var contentFile = contentBase.resolve(uri); + var target = editableTarget(parameters, db); Map result = new HashMap<>(); - result.put("uri", uri); - if (contentFile != null) { + result.put("uri", target.uri()); + if (target.file().exists()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); - result.put("content", parser.getContent()); + ContentFileParser parser = new ContentFileParser(target.file()); + result.put(Parameters.CONTENT, parser.getContent()); result.put("meta", parser.getHeader()); } catch (IOException ex) { log.error("", ex); throw new RPCException(0, ex.getMessage()); } - } + } else { + throw new RPCException(404, "content not found"); + } return result; } @@ -95,25 +97,19 @@ public Object getContent(Map parameters) throws RPCException { @RemoteMethod(name = "content.set", permissions = {Permissions.CONTENT_EDIT}) public Object setContent(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); - - var updatedContent = FormHelper.getContent(parameters.get("content")); - var uri = contentUri(parameters); - - var contentFile = contentBase.resolve(uri); + var updatedContent = FormHelper.getContent(parameters.get(Parameters.CONTENT)); + var target = editableTarget(parameters, db); Map result = new HashMap<>(); - result.put("uri", uri); - if (contentFile != null) { + result.put("uri", target.uri()); + if (target.file().exists()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); + ContentFileParser parser = new ContentFileParser(target.file()); Map meta = parser.getHeader(); - - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, updatedContent); - log.debug("file {} saved", uri); + YamlHeaderUpdater.saveMarkdownFileWithHeader(target.writableFile(db), meta, updatedContent); + refresh(target, db); + log.debug(LOG_PATTERN, target.uri()); } catch (IOException ex) { log.error("", ex); throw new RPCException(0, ex.getMessage()); @@ -122,13 +118,14 @@ public Object setContent(Map parameters) throws RPCException { return result; } + private static final String LOG_PATTERN = "file {} saved"; @RemoteMethod(name = "content.replace", permissions = {Permissions.CONTENT_EDIT}) public Object replaceContent(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().contentBase(); - var replacement = (String)parameters.get("content"); + var replacement = (String)parameters.get(Parameters.CONTENT); int start = NumberUtils.toInt(parameters.getOrDefault("start", -1l)); int end = NumberUtils.toInt(parameters.getOrDefault("end", -1l)); var uri = contentUri(parameters); @@ -155,7 +152,7 @@ public Object replaceContent(Map parameters) throws RPCException var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, parser.getHeader(), updatedContent); - log.debug("file {} saved", uri); + log.debug(LOG_PATTERN, uri); } catch (IOException ex) { log.error("", ex); throw new RPCException(0, ex.getMessage()); @@ -168,29 +165,22 @@ public Object replaceContent(Map parameters) throws RPCException @RemoteMethod(name = "meta.set", permissions = {Permissions.CONTENT_EDIT}) public Object setMeta(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); - var updateParam = (Map>) parameters.get("meta"); var update = MetaConverter.convertMeta(updateParam); - var uri = contentUri(parameters); - - var contentFile = contentBase.resolve(uri); + var target = editableTarget(parameters, db); Map result = new HashMap<>(); - result.put("uri", uri); - if (contentFile != null) { + result.put("uri", target.uri()); + if (target.file().exists()) { try { - ContentFileParser parser = new ContentFileParser(contentFile); + ContentFileParser parser = new ContentFileParser(target.file()); Map meta = parser.getHeader(); YamlHeaderUpdater.mergeFlatMapIntoNestedMap(meta, update); - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, parser.getContent()); - log.debug("file {} saved", uri); - - getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(uri)); + YamlHeaderUpdater.saveMarkdownFileWithHeader(target.writableFile(db), meta, parser.getContent()); + refresh(target, db); + log.debug(LOG_PATTERN, target.uri()); } catch (IOException ex) { log.error("", ex); throw new RPCException(0, ex.getMessage()); @@ -233,7 +223,7 @@ public Object setMetaBatch(Map parameters) throws RPCException { var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(update.uri); YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, fileMeta, parser.getContent()); - log.debug("file {} saved", update.uri); + log.debug(LOG_PATTERN, update.uri); getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(update.uri)); } catch (IOException ex) { @@ -281,7 +271,7 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().resolve(Constants.Folders.CONTENT); - var content = (String) parameters.getOrDefault("content", ""); + var content = (String) parameters.getOrDefault(Parameters.CONTENT, ""); var parentUri = contentUri(parameters, "parentUri"); var section = (String) parameters.get("section"); var sectionEntryName = (String) parameters.get("sectionEntryName"); @@ -308,7 +298,7 @@ public Object addSectionEntry(Map parameters) throws RPCExceptio var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, meta, content); - log.debug("file {} saved", uri); + log.debug(LOG_PATTERN, uri); getContext().get(EventBusFeature.class).eventBus().publish(new ReIndexContentMetaDataEvent(uri)); } catch (IOException ex) { @@ -370,8 +360,25 @@ public Object getContentNode (Map parameters) { Map result = new HashMap<>(); result.put("url", url); if (contentFile == null || canonicalUri == null) { + var collectionConfiguration = getContext().get(ConfigurationFeature.class) + .configuration().get(CollectionConfiguration.class); + var collectionRoute = new CollectionRouteResolver(db, collectionConfiguration).resolve(path); + if (collectionRoute.isEmpty()) { + return result; + } + var item = collectionRoute.get().item(); + result.put("uri", item.path()); + result.put("canonicalUri", item.path()); + result.put("variantId", null); + result.put("contentKind", "collection"); + result.put("supportsVariants", false); + result.put("collection", item.collection()); + result.put("collectionItemId", item.id()); + result.put("sections", Map.of()); return result; } + result.put("contentKind", "content"); + result.put("supportsVariants", true); var query = com.condation.cms.api.utils.HTTPUtil.queryParameters(requestUri.getQuery()); var variantId = query.getOrDefault( @@ -426,4 +433,50 @@ private String contentUri(Map parameters, String parameterName) } throw new RPCException(400, parameterName + " must not be blank"); } + + private EditableTarget editableTarget(Map parameters, DB db) throws RPCException { + if (!parameters.containsKey("uri") + && getRequestContext().has(CurrentCollectionItemFeature.class)) { + var item = getRequestContext().get(CurrentCollectionItemFeature.class).item(); + if (!db.getCollections().isLocal(item.collection())) { + throw new RPCException(403, "referenced collection is read-only: " + item.collection()); + } + return new EditableTarget( + item.path(), + db.getFileSystem().collectionsBase().resolve(item.path()), + item.collection(), + item.id()); + } + var uri = contentUri(parameters); + return new EditableTarget( + uri, + db.getFileSystem().contentBase().resolve(uri), + null, + null); + } + + private void refresh(EditableTarget target, DB db) { + if (target.collectionName() != null) { + db.getCollections().refresh(target.collectionName(), target.itemId()); + } else { + getContext().get(EventBusFeature.class).eventBus() + .publish(new ReIndexContentMetaDataEvent(target.uri())); + db.getFileSystem().flushContentChanges(); + } + getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); + } + + private record EditableTarget( + String uri, + ReadOnlyFile file, + String collectionName, + String itemId) { + + private Path writableFile(DB db) { + var folder = collectionName == null + ? Constants.Folders.CONTENT + : Constants.Folders.COLLECTIONS; + return db.getFileSystem().resolve(folder).resolve(uri); + } + } } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteManagerEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteManagerEnpoints.java index 7a0ba7d8a..70d91ec05 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteManagerEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteManagerEnpoints.java @@ -99,6 +99,16 @@ public Object getListItemTypes(Map parameters) throws RPCExcepti throw new RPCException(0, e.getMessage()); } } + + @RemoteMethod(name = "manager.contentTypes.collections", permissions = {Permissions.CONTENT_EDIT}) + public Object getCollectionTypes(Map parameters) throws RPCException { + try { + return uiHooks().contentTypes().getCollections(); + } catch (Exception ex) { + log.error("could not load collection editor definitions", ex); + throw new RPCException(0, ex.getMessage()); + } + } @RemoteMethod(name = "manager.token.createCSRF", permissions = {Permissions.CONTENT_EDIT}) public Object createCSRFToken(Map parameters) throws RPCException { 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 63fd41f70..71155b161 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 @@ -30,7 +30,11 @@ import com.condation.cms.api.feature.features.InjectorFeature; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.feature.features.EventBusFeature; +import com.condation.cms.api.eventbus.events.InvalidateContentCacheEvent; +import com.condation.cms.api.eventbus.events.ReIndexContentMetaDataEvent; import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.HTTPUtil; @@ -57,6 +61,7 @@ import java.io.IOException; import java.util.Optional; import java.util.List; +import java.nio.file.Path; /** * @@ -69,10 +74,10 @@ public class RemoteWorkflowEndpointsExtension extends AbstractRemoteMethodeExten private static final String TRANSITIONS = "transitions"; private static final String STATUS = "status"; - private Optional getContentNode(String uri) { + private Optional getContentTarget(String uri) { final DB db = getContext().get(DBFeature.class).db(); var contentBase = db.getFileSystem().contentBase(); - var contentFile = getContentFile(uri); + var contentFile = contentBase.resolve(uri); if (!contentFile.exists()) { return Optional.empty(); @@ -85,7 +90,7 @@ private Optional getContentNode(String uri) { return Optional.empty(); } - return Optional.of( + return Optional.of(new WorkflowTarget( new ContentNode( node.get().uri(), node.get().url(), @@ -93,29 +98,58 @@ private Optional getContentNode(String uri) { node.get().data(), node.get().directory(), node.get().children(), - node.get().lastmodified() - )); + node.get().lastmodified()), + contentFile, + db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri), + false, + null, + null)); } - private ReadOnlyFile getContentFile(String uri) { + private Optional getWorkflowTarget(Map parameters) throws RPCException { final DB db = getContext().get(DBFeature.class).db(); - var contentBase = db.getFileSystem().contentBase(); - return contentBase.resolve(uri); + if (!parameters.containsKey("uri") + && getRequestContext().has(CurrentCollectionItemFeature.class)) { + var item = getRequestContext().get(CurrentCollectionItemFeature.class).item(); + if (!db.getCollections().isLocal(item.collection())) { + return Optional.empty(); + } + var node = new ContentNode( + item.path(), + item.path(), + item.id() + ".md", + new HashMap<>(item.meta())); + return Optional.of(new WorkflowTarget( + node, + db.getFileSystem().collectionsBase().resolve(item.path()), + db.getFileSystem().resolve(Constants.Folders.COLLECTIONS).resolve(item.path()), + true, + item.collection(), + item.id())); + } + return getContentTarget(contentUri(parameters)); + } + + private record WorkflowTarget( + ContentNode node, + ReadOnlyFile file, + Path writableFile, + boolean collection, + String collectionName, + String itemId) { } @RemoteMethod(name = "workflow.manager.node.status", permissions = {Permissions.CONTENT_EDIT}) public Object nodeStatus(Map parameters) throws RPCException { - var uri = contentUri(parameters); Map result = new HashMap<>(); - var contentNodeOpt = getContentNode(uri); - - if (contentNodeOpt.isEmpty()) { + var target = getWorkflowTarget(parameters); + if (target.isEmpty()) { return result; } - var node = contentNodeOpt.get(); + var node = target.get().node(); final Workflow workflow = getContext().get(WorkflowFeature.class).workflow(); var status = workflow.getStatusProvider().status(node); @@ -129,18 +163,16 @@ public Object nodeStatus(Map parameters) throws RPCException { @RemoteMethod(name = "workflow.transitions.get", permissions = {Permissions.CONTENT_EDIT}) public Object getTransitions(Map parameters) throws RPCException { - var uri = contentUri(parameters); - Map result = new HashMap<>(); - var contentNodeOpt = getContentNode(uri); - if (contentNodeOpt.isEmpty()) { + var target = getWorkflowTarget(parameters); + if (target.isEmpty()) { result.put(TRANSITIONS, java.util.List.of()); return result; } Workflow workflow = getContext().get(WorkflowFeature.class).workflow(); - result.put(TRANSITIONS, transitionDtos(allowedTransitions(workflow, contentNodeOpt.get()))); + result.put(TRANSITIONS, transitionDtos(allowedTransitions(workflow, target.get().node()))); return result; } @@ -174,17 +206,16 @@ public Object unpublishedPages(Map parameters) throws RPCExcepti public Object transit(Map parameters) throws RPCException { var result = new HashMap(); try { - var uri = contentUri(parameters); var transitionId = requiredTransitionId(parameters); final DB db = getContext().get(DBFeature.class).db(); - var contentNodeOpt = getContentNode(uri); - if (contentNodeOpt.isEmpty()) { + var target = getWorkflowTarget(parameters); + if (target.isEmpty()) { throw new RPCException(404, "content node not found"); } - var contentNode = contentNodeOpt.get(); + var contentNode = target.get().node(); Workflow workflow = getContext().get(WorkflowFeature.class).workflow(); WFTransition transition = workflow.getNextTransitions(contentNode).stream() @@ -194,12 +225,17 @@ public Object transit(Map parameters) throws RPCException { ensureAllowed(transition); workflow.transit(transitionId, contentNode); - var contentFile = getContentFile(uri); - - ContentFileParser parser = new ContentFileParser(contentFile); - - var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); - YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, contentNode.data(), parser.getContent()); + ContentFileParser parser = new ContentFileParser(target.get().file()); + YamlHeaderUpdater.saveMarkdownFileWithHeader( + target.get().writableFile(), contentNode.data(), parser.getContent()); + if (target.get().collection()) { + db.getCollections().refresh(target.get().collectionName(), target.get().itemId()); + } else { + getContext().get(EventBusFeature.class).eventBus() + .publish(new ReIndexContentMetaDataEvent(contentNode.uri())); + db.getFileSystem().flushContentChanges(); + } + getContext().get(EventBusFeature.class).eventBus().publish(new InvalidateContentCacheEvent()); result.put("success", true); } catch (RPCException ex) { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/JSActionHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/JSActionHandler.java index 766e15934..7163a00a4 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/JSActionHandler.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/JSActionHandler.java @@ -24,12 +24,15 @@ import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.extensions.UIScriptActionSourceExtension; import com.google.common.base.Strings; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.io.Content; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Response; @@ -42,6 +45,7 @@ @Slf4j @RequiredArgsConstructor public class JSActionHandler extends JettyHandler { + private static final String JAVASCRIPT_EXTENSION = ".js"; private final FileSystem fileSystem; private final String base; @@ -62,12 +66,10 @@ public boolean handle(Request request, Response response, Callback callback) thr if (moduleContent.isPresent()) { scriptContent = moduleContent.get(); } else { - var resourceFile = resourceName + ".js"; - var files = fileSystem.getPath(base); - var path = files.resolve(resourceFile); - if (Files.exists(path)) { - scriptContent = Files.readString(path); - } + var bundledScript = getBundledScript(resourceName); + scriptContent = bundledScript.isPresent() + ? bundledScript.get() + : getScriptFromFileSystem(resourceName).orElse(""); } @@ -75,11 +77,46 @@ public boolean handle(Request request, Response response, Callback callback) thr response.getHeaders().put(HttpHeader.CONTENT_TYPE, "application/javascript; charset=UTF-8"); Content.Sink.write(response, true, scriptContent, callback); } else { + response.setStatus(HttpStatus.NOT_FOUND_404); callback.succeeded(); } return true; } + + Optional getBundledScript(String resourceName) { + var resourcePath = "%s/%s".formatted(base, scriptResourceName(resourceName)); + try (var stream = JSActionHandler.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + return Optional.empty(); + } + return Optional.of(new String(stream.readAllBytes(), StandardCharsets.UTF_8)); + } catch (IOException exception) { + log.error("Could not load manager action {}", resourcePath, exception); + return Optional.empty(); + } + } + + private Optional getScriptFromFileSystem(String resourceName) { + var resourceFile = scriptResourceName(resourceName); + var files = fileSystem.getPath(base); + var path = files.resolve(resourceFile); + if (!Files.exists(path)) { + return Optional.empty(); + } + try { + return Optional.of(Files.readString(path)); + } catch (IOException exception) { + log.error("Could not load manager action {}", path, exception); + return Optional.empty(); + } + } + + private String scriptResourceName(String resourceName) { + return resourceName.endsWith(JAVASCRIPT_EXTENSION) + ? resourceName + : resourceName + JAVASCRIPT_EXTENSION; + } private Optional getScriptContentFromModules (String filename) { return context.get(ModuleManagerFeature.class).moduleManager().extensions(UIScriptActionSourceExtension.class) diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java index c10b212ee..d1ac8e0ea 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/RemoteCallHandler.java @@ -22,6 +22,8 @@ */ import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.feature.features.CurrentNodeFeature; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; +import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; import com.condation.cms.api.ui.rpc.RPCError; @@ -50,6 +52,8 @@ public class RemoteCallHandler extends JettyHandler { public static final String CONTENT_URI_HEADER = "X-CMS-Content-Uri"; + public static final String COLLECTION_HEADER = "X-CMS-Collection"; + public static final String COLLECTION_ITEM_HEADER = "X-CMS-Collection-Item"; private final RemoteMethodService remoteCallService; private final SiteModuleContext moduleContext; @@ -93,6 +97,9 @@ public boolean handle(Request request, Response response, Callback callback) thr } private void setCurrentContentNode(Request request) { + if (setCurrentCollectionItem(request)) { + return; + } var uri = request.getHeaders().get(CONTENT_URI_HEADER); if (uri == null || uri.isBlank()) { return; @@ -109,6 +116,33 @@ private void setCurrentContentNode(Request request) { )); } + private boolean setCurrentCollectionItem(Request request) { + var collectionName = request.getHeaders().get(COLLECTION_HEADER); + if (collectionName == null || collectionName.isBlank()) { + return false; + } + var itemId = request.getHeaders().get(COLLECTION_ITEM_HEADER); + if (itemId == null || itemId.isBlank()) { + return false; + } + if (!moduleContext.has(DBFeature.class)) { + return false; + } + try { + var item = moduleContext.get(DBFeature.class).db() + .getCollections().collection(collectionName.trim()).item(itemId.trim()); + item.ifPresent(value -> { + requestContext.add(CurrentCollectionItemFeature.class, new CurrentCollectionItemFeature(value)); + requestContext.add(CurrentNodeFeature.class, new CurrentNodeFeature(new ContentNode( + value.path(), value.path(), value.id() + ".md", value.meta()))); + }); + return item.isPresent(); + } catch (IllegalArgumentException ex) { + log.warn("invalid collection preview context", ex); + return false; + } + } + private RPCResult buildErrorResult(Exception e, String method) { log.error("error executing endpoint {}", method, e); if (e instanceof RPCException rpcException) { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/AjaxLoginHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/AjaxLoginHandler.java index a078cfffa..ff7c940fb 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/AjaxLoginHandler.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/AjaxLoginHandler.java @@ -63,6 +63,8 @@ public class AjaxLoginHandler extends JettyHandler { private static final int ATTEMPTS_TO_BLOCK = 3; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + public static record Login(User user, String token) { } @@ -210,7 +212,7 @@ private void validate(Request request, Response response, Callback callback, Com } private String generateCode() { - int code = new SecureRandom().nextInt(1_000_000); + int code = SECURE_RANDOM.nextInt(1_000_000); return String.format("%06d", code); } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java index 4f31eb458..04ac1373f 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/ActionFactory.java @@ -46,6 +46,7 @@ import com.condation.cms.auth.services.User; import com.condation.cms.auth.services.RoleService; import com.condation.cms.api.feature.features.InjectorFeature; +import com.condation.cms.api.feature.features.DBFeature; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; @@ -137,6 +138,23 @@ public Menu createContentTypeMenu() { .build(); }).forEach(menu::addMenuEntry); + context.get(DBFeature.class).db().getCollections().names().stream() + .filter(context.get(DBFeature.class).db().getCollections()::isLocal) + .sorted() + .map(name -> MenuEntry.builder() + .id("collection-" + name) + .name(contentTypes.getCollection(name) + .map(collection -> collection.label()) + .orElse(name)) + .action(new UIScriptAction( + HTTPUtil.prependContext( + "/manager/actions/collection/create-collection-item", + siteProperties), + Map.of("collection", name))) + .children(new ArrayList<>()) + .build()) + .forEach(menu::addMenuEntry); + return menu; } diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/UIPathUtil.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/UIPathUtil.java index 4d10588da..70095e971 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/UIPathUtil.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/UIPathUtil.java @@ -21,7 +21,7 @@ * #L% */ import com.condation.cms.api.utils.FileUtils; -import com.github.slugify.Slugify; +import com.condation.cms.content.utils.SlugUtil; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -34,16 +34,6 @@ @Slf4j public class UIPathUtil { - private static final Slugify SLUGIFIER = Slugify.builder() - .customReplacement("ä", "ae") - .customReplacement("Ä", "ae") - .customReplacement("ü", "ue") - .customReplacement("Ü", "ue") - .customReplacement("ö", "oe") - .customReplacement("Ö", "oe") - .customReplacement("ß", "ss") - .lowerCase(true).build(); - public static String toUri(final Path contentFile, final Path contentBase) { Path relativize = contentBase.relativize(contentFile); // if (Files.isDirectory(contentFile)) { @@ -79,7 +69,7 @@ public static String getType(Path path) { } public static String slugify (String input) { - return SLUGIFIER.slugify(input); + return SlugUtil.slugify(input); } public static String toValidFilename(String input) { diff --git a/modules/ui-module/src/main/resources/manager/actions/collection/create-collection-item.d.ts b/modules/ui-module/src/main/resources/manager/actions/collection/create-collection-item.d.ts new file mode 100644 index 000000000..9214742d6 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/collection/create-collection-item.d.ts @@ -0,0 +1,27 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { CollectionItemSummary } from '@cms/modules/rpc/rpc-collection.js'; +export interface CreateCollectionItemOptions { + collection: string; + onCreated?: (item: CollectionItemSummary) => void | Promise; +} +export declare const openCollectionItemCreator: (options: CreateCollectionItemOptions) => Promise; +export declare const runAction: (options: CreateCollectionItemOptions) => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/collection/create-collection-item.js b/modules/ui-module/src/main/resources/manager/actions/collection/create-collection-item.js new file mode 100644 index 000000000..5ad4c2c02 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/collection/create-collection-item.js @@ -0,0 +1,108 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { collectionForm } from './edit-collection-item.js'; +import { createForm, getFormFields } from '@cms/modules/form/forms.js'; +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { loadPreview } from '@cms/modules/preview.utils.js'; +import { createCollectionItem } from '@cms/modules/rpc/rpc-collection.js'; +import { getCollectionTypes } from '@cms/modules/rpc/rpc-manager.js'; +import { showToast } from '@cms/modules/toast.js'; +const ID_FIELD = 'id'; +const CONTENT_FIELD = 'content'; +const fieldValue = (field) => String(field?.value ?? field ?? '').trim(); +export const openCollectionItemCreator = async (options) => { + try { + const typeResponse = await getCollectionTypes(); + const definition = collectionForm(typeResponse.result, options.collection, 'create'); + const hasIdField = getFormFields(definition).some(field => field.name === ID_FIELD); + const form = createForm({ + fields: [ + ...(hasIdField ? [] : [{ + type: 'text', + name: ID_FIELD, + title: i18n.t('collection.item.create.id', 'Item ID'), + required: true + }]), + ...(definition.fields ?? []) + ], + tabs: definition.tabs ?? [], + values: {} + }); + openModal({ + title: i18n.t('collection.item.create.title', 'Create collection item'), + body: '', + form, + fullscreen: true, + onCancel: () => { }, + onOk: async () => { + const data = form.getData(); + const id = fieldValue(data[ID_FIELD]); + const content = data[CONTENT_FIELD]; + delete data[ID_FIELD]; + delete data[CONTENT_FIELD]; + try { + const item = await createCollectionItem({ + collection: options.collection, + id, + content, + meta: data + }); + showToast({ + title: i18n.t('collection.item.create.success.title', 'Collection item created'), + message: i18n.t('collection.item.create.success.message', 'The collection item was created successfully.'), + type: 'success', + timeout: 3000 + }); + await options.onCreated?.(item); + return true; + } + catch (error) { + showToast({ + title: i18n.t('collection.item.create.error.title', 'Collection item not created'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + return false; + } + } + }); + } + catch (error) { + showToast({ + title: i18n.t('collection.item.create.loadError.title', 'Collection form could not be loaded'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + } +}; +export const runAction = async (options) => { + await openCollectionItemCreator({ + ...options, + onCreated: item => { + if (item.detailUrl) { + loadPreview(item.detailUrl); + } + } + }); +}; diff --git a/modules/ui-module/src/main/resources/manager/actions/collection/edit-collection-item.d.ts b/modules/ui-module/src/main/resources/manager/actions/collection/edit-collection-item.d.ts new file mode 100644 index 000000000..57358ee23 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/collection/edit-collection-item.d.ts @@ -0,0 +1,36 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { Form } from '@cms/modules/form/forms.js'; +import { CollectionType } from '@cms/modules/rpc/rpc-manager.js'; +export declare const collectionForm: (types: CollectionType[], collection: string, mode?: "create" | "edit") => any; +export interface EditCollectionItemOptions { + collection: string; + id: string; + reloadAfterSave?: boolean; + onSaved?: () => void | Promise; +} +export interface CollectionItemEditor { + form: Form; + save: () => Promise; +} +export declare const createCollectionItemEditor: (options: EditCollectionItemOptions) => Promise; +export declare const openCollectionItemEditor: (options: EditCollectionItemOptions) => Promise; +export declare const runAction: (options: EditCollectionItemOptions) => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/collection/edit-collection-item.js b/modules/ui-module/src/main/resources/manager/actions/collection/edit-collection-item.js new file mode 100644 index 000000000..da2a8d466 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/collection/edit-collection-item.js @@ -0,0 +1,116 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { createForm, getFormFields } from '@cms/modules/form/forms.js'; +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { buildValuesFromFields } from '@cms/modules/node.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; +import { getCollectionItem, saveCollectionItem } from '@cms/modules/rpc/rpc-collection.js'; +import { getCollectionTypes } from '@cms/modules/rpc/rpc-manager.js'; +import { showToast } from '@cms/modules/toast.js'; +const CONTENT_FIELD = 'content'; +const defaultForm = { + fields: [ + { type: 'text', name: 'title', title: 'Title', required: true }, + { type: 'markdown', name: CONTENT_FIELD, title: 'Content', height: '60vh' } + ], + tabs: [] +}; +export const collectionForm = (types, collection, mode = 'edit') => { + const forms = types.find(type => type.name === collection)?.forms; + return forms?.[mode] ?? forms?.edit ?? defaultForm; +}; +export const createCollectionItemEditor = async (options) => { + const [item, typeResponse] = await Promise.all([ + getCollectionItem(options.collection, options.id), + getCollectionTypes() + ]); + const definition = collectionForm(typeResponse.result, options.collection); + const fields = getFormFields(definition); + const form = createForm({ + fields: definition.fields ?? [], + tabs: definition.tabs ?? [], + values: { + ...buildValuesFromFields(fields, item.meta), + [CONTENT_FIELD]: item.content + } + }); + return { + form, + save: async () => { + const data = form.getData(); + const content = data[CONTENT_FIELD]; + delete data[CONTENT_FIELD]; + try { + await saveCollectionItem({ + collection: options.collection, + id: options.id, + content, + meta: data + }); + showToast({ + title: i18n.t('collection.item.edit.success.title', 'Collection item updated'), + message: i18n.t('collection.item.edit.success.message', 'The collection item was updated successfully.'), + type: 'success', + timeout: 3000 + }); + await options.onSaved?.(); + if (options.reloadAfterSave) { + reloadPreview(); + } + return true; + } + catch (error) { + showToast({ + title: i18n.t('collection.item.edit.error.title', 'Collection item not updated'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + return false; + } + } + }; +}; +export const openCollectionItemEditor = async (options) => { + try { + const editor = await createCollectionItemEditor(options); + openModal({ + title: i18n.t('collection.item.edit.title', 'Edit collection item'), + body: '', + form: editor.form, + fullscreen: true, + onCancel: () => { }, + onOk: editor.save + }); + } + catch (error) { + showToast({ + title: i18n.t('collection.item.edit.loadError.title', 'Collection item could not be loaded'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + } +}; +export const runAction = async (options) => { + await openCollectionItemEditor({ ...options, reloadAfterSave: true }); +}; diff --git a/modules/ui-module/src/main/resources/manager/actions/collection/manage-collection.d.ts b/modules/ui-module/src/main/resources/manager/actions/collection/manage-collection.d.ts new file mode 100644 index 000000000..4a16720aa --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/collection/manage-collection.d.ts @@ -0,0 +1,23 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare const runAction: (options: { + collection: string; +}) => Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/collection/manage-collection.js b/modules/ui-module/src/main/resources/manager/actions/collection/manage-collection.js new file mode 100644 index 000000000..4788a72c3 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/collection/manage-collection.js @@ -0,0 +1,321 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { openCollectionItemCreator } from './create-collection-item.js'; +import { createCollectionItemEditor } from './edit-collection-item.js'; +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { loadPreview } from '@cms/modules/preview.utils.js'; +import { deleteCollectionItem, listCollectionItemsCursor } from '@cms/modules/rpc/rpc-collection.js'; +import { showToast } from '@cms/modules/toast.js'; +const PAGE_SIZE = 10; +const MIN_SEARCH_LENGTH = 3; +const escapeHtml = (value) => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +const renderItems = (items) => { + if (items.length === 0) { + return `

${i18n.t('collection.items.empty', 'No collection items found.')}

`; + } + return `
${items.map(item => ` +
+
+ ${escapeHtml(item.title)}
+ ${escapeHtml(item.id)} +
+
+ + ${item.detailUrl ? `` : ''} + +
+
`).join('')}
`; +}; +export const runAction = async (options) => { + let currentPage = 1; + let cursorHistory = ['']; + let currentQuery = ''; + let requestVersion = 0; + let editorRequestVersion = 0; + let modal; + let modalElement = null; + let collectionSlider = null; + let browsePanel = null; + let editorPanel = null; + let editorContent = null; + let editorSaveButton = null; + let currentEditor = null; + let isSaving = false; + const body = ` +
+
+
+
+ + +
+ +
+
+
+
+ +
`; + const closeEditor = () => { + editorRequestVersion++; + currentEditor = null; + isSaving = false; + if (editorSaveButton) + editorSaveButton.disabled = true; + collectionSlider?.classList.remove('is-editing-item'); + browsePanel?.setAttribute('aria-hidden', 'false'); + editorPanel?.setAttribute('aria-hidden', 'true'); + if (browsePanel) + browsePanel.inert = false; + if (editorPanel) + editorPanel.inert = true; + }; + const openEditor = async (id) => { + if (!collectionSlider || !editorContent || !editorSaveButton) + return; + const version = ++editorRequestVersion; + currentEditor = null; + editorSaveButton.disabled = true; + editorContent.innerHTML = `
${i18n.t('collection.item.edit.loading', 'Loading collection item...')}
`; + collectionSlider.classList.add('is-editing-item'); + browsePanel?.setAttribute('aria-hidden', 'true'); + editorPanel?.setAttribute('aria-hidden', 'false'); + if (browsePanel) + browsePanel.inert = true; + if (editorPanel) + editorPanel.inert = false; + try { + const editor = await createCollectionItemEditor({ + collection: options.collection, + id, + onSaved: async () => { + currentPage = 1; + cursorHistory = ['']; + await update(); + closeEditor(); + } + }); + if (version !== editorRequestVersion) + return; + currentEditor = editor; + editorContent.innerHTML = ''; + editor.form.init(editorContent); + editorSaveButton.disabled = false; + } + catch (error) { + if (version !== editorRequestVersion) + return; + showToast({ + title: i18n.t('collection.item.edit.loadError.title', 'Collection item could not be loaded'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + closeEditor(); + } + }; + const saveEditor = async () => { + if (!currentEditor || !editorSaveButton || isSaving || !currentEditor.form.validate()) + return; + isSaving = true; + editorSaveButton.disabled = true; + const saved = await currentEditor.save(); + isSaving = false; + if (!saved && currentEditor) + editorSaveButton.disabled = false; + }; + const update = async () => { + const version = ++requestVersion; + const root = modalElement?.querySelector('[data-collection-results]'); + const pagination = modalElement?.querySelector('[data-collection-pagination]'); + if (!root || !pagination) + return; + root.innerHTML = `
${i18n.t('collection.items.loading', 'Loading collection items...')}
`; + try { + const page = await listCollectionItemsCursor({ + collection: options.collection, + query: currentQuery, + cursor: cursorHistory[currentPage - 1], + size: PAGE_SIZE + }); + if (version !== requestVersion) + return; + if (page.items.length === 0 && currentPage > 1) { + currentPage--; + await update(); + return; + } + root.innerHTML = renderItems(page.items); + pagination.innerHTML = currentPage > 1 || page.nextCursor ? ` + ` : ''; + root.querySelectorAll('[data-collection-edit]').forEach(button => { + button.addEventListener('click', () => openEditor(button.dataset.collectionEdit ?? '')); + }); + root.querySelectorAll('[data-collection-open]').forEach(button => { + button.addEventListener('click', () => { + modal.hide(); + loadPreview(button.dataset.collectionOpen ?? ''); + }); + }); + root.querySelectorAll('[data-collection-delete]').forEach(button => { + button.addEventListener('click', async () => { + const id = button.dataset.collectionDelete ?? ''; + const prompt = `${i18n.t('collection.items.deleteConfirm', 'Delete collection item')} “${id}”?`; + if (!window.confirm(prompt)) + return; + try { + await deleteCollectionItem(options.collection, id); + currentPage = 1; + cursorHistory = ['']; + showToast({ + title: i18n.t('collection.items.deleteSuccess.title', 'Collection item deleted'), + message: i18n.t('collection.items.deleteSuccess.message', 'The collection item was deleted successfully.'), + type: 'success', + timeout: 3000 + }); + await update(); + } + catch (error) { + showToast({ + title: i18n.t('collection.items.deleteError.title', 'Collection item not deleted'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + } + }); + }); + pagination.querySelectorAll('[data-collection-direction]').forEach(button => { + button.addEventListener('click', () => { + if (button.dataset.collectionDirection === 'next' && page.nextCursor) { + cursorHistory[currentPage] = page.nextCursor; + currentPage++; + } + else if (button.dataset.collectionDirection === 'previous' && currentPage > 1) { + currentPage--; + } + update(); + }); + }); + } + catch (error) { + if (version !== requestVersion) + return; + root.innerHTML = `
${escapeHtml(error?.message ?? error)}
`; + pagination.innerHTML = ''; + } + }; + modal = openModal({ + title: options.collection, + body, + size: 'xl', + showFooter: false, + onShow: (element) => { + modalElement = element; + collectionSlider = element.querySelector('[data-collection-slider]'); + browsePanel = element.querySelector('[data-collection-browse-panel]'); + editorPanel = element.querySelector('[data-collection-editor-panel]'); + editorContent = element.querySelector('[data-collection-editor-content]'); + editorSaveButton = element.querySelector('[data-collection-editor-save]'); + const input = element.querySelector('#cms-collection-search'); + element.querySelectorAll('[data-collection-editor-back], [data-collection-editor-cancel]') + .forEach(button => button.addEventListener('click', closeEditor)); + editorSaveButton?.addEventListener('click', saveEditor); + element.querySelector('[data-collection-create]')?.addEventListener('click', () => { + openCollectionItemCreator({ + collection: options.collection, + onCreated: async () => { + currentQuery = ''; + currentPage = 1; + cursorHistory = ['']; + if (input) + input.value = ''; + await update(); + } + }); + }); + let debounce; + input?.addEventListener('input', () => { + window.clearTimeout(debounce); + debounce = window.setTimeout(() => { + const value = input.value.trim(); + if (value.length > 0 && value.length < MIN_SEARCH_LENGTH) + return; + currentQuery = value; + currentPage = 1; + cursorHistory = ['']; + update(); + }, 300); + }); + update(); + } + }); +}; diff --git a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js index f1c0ece63..6f5c98595 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/create-variant.js @@ -25,6 +25,7 @@ import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js'; import { createVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js'; import { showToast } from '@cms/modules/toast.js'; +import { ensureVariantsSupported } from '@cms/modules/variant-support.js'; const value = (id) => document.getElementById(id)?.value.trim() ?? ''; const validate = () => { const copyContent = document.querySelector('input[name="cms-variant-content"]:checked')?.value === 'copy'; @@ -46,10 +47,11 @@ const validate = () => { }; export const runAction = async () => { try { - const [activeContentNode, templatesResponse] = await Promise.all([ - getContentNode({ url: getPreviewUrl() }), - getPageTemplates({}) - ]); + const activeContentNode = await getContentNode({ url: getPreviewUrl() }); + if (!ensureVariantsSupported(activeContentNode.result)) { + return; + } + const templatesResponse = await getPageTemplates({}); const variantContext = await getVariants({ uri: activeContentNode.result.uri }); const templates = Array.from(templatesResponse.result ?? []) .sort((left, right) => String(left.name).localeCompare(String(right.name))); diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js index 8205c730d..63eed7ee0 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/variant-selector.js @@ -24,6 +24,7 @@ import { getPreviewUrl } from '@cms/modules/preview.utils.js'; import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { getVariantSelectors, setVariantSelector } from '@cms/modules/rpc/rpc-variant.js'; import { showToast } from '@cms/modules/toast.js'; +import { ensureVariantsSupported } from '@cms/modules/variant-support.js'; const SELECT_ID = 'cms-variant-selector'; const escapeHtml = (input) => { const element = document.createElement('div'); @@ -41,6 +42,9 @@ const showError = (error) => showToast({ export const runAction = async () => { try { const contentNode = await getContentNode({ url: getPreviewUrl() }); + if (!ensureVariantsSupported(contentNode.result)) { + return; + } const result = await getVariantSelectors(contentNode.result.uri); openModal({ title: i18n.t('manager.actions.page.variant-selector.title', 'Configure variant selection'), diff --git a/modules/ui-module/src/main/resources/manager/actions/page/variants.js b/modules/ui-module/src/main/resources/manager/actions/page/variants.js index e67bc84f6..c95ca7235 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/variants.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/variants.js @@ -25,6 +25,7 @@ import { getPreviewUrl, loadPreview } from '@cms/modules/preview.utils.js'; import { getContentNode } from '@cms/modules/rpc/rpc-content.js'; import { deleteVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js'; import { showToast } from '@cms/modules/toast.js'; +import { ensureVariantsSupported } from '@cms/modules/variant-support.js'; const VARIANT_LIST_ID = 'cms-page-variants'; const variantTitle = (variant) => { const title = variant.meta?.title; @@ -137,6 +138,9 @@ export const runAction = async () => { const contentNode = await getContentNode({ url: getPreviewUrl() }); + if (!ensureVariantsSupported(contentNode.result)) { + return; + } const result = await getVariants({ uri: contentNode.result.uri }); diff --git a/modules/ui-module/src/main/resources/manager/css/manager.css b/modules/ui-module/src/main/resources/manager/css/manager.css index 7247aa40f..32928ce66 100644 --- a/modules/ui-module/src/main/resources/manager/css/manager.css +++ b/modules/ui-module/src/main/resources/manager/css/manager.css @@ -126,6 +126,10 @@ i[data-cms-section-handle] { white-space: nowrap; } +.cms-current-variant[hidden] { + display: none; +} + .cms-current-variant:disabled { opacity: 0.65; } @@ -760,6 +764,33 @@ i[data-cms-section-handle] { transform: translateX(-100%); } +.cms-collection-manager-slider { + display: grid; + grid-template-columns: 100% 100%; + overflow: hidden; +} + +.cms-collection-manager-panel { + grid-row: 1; + min-width: 0; + transition: transform 0.25s ease; +} + +.cms-collection-manager-panel--browse { + grid-column: 1; +} + +.cms-collection-manager-panel--editor { + grid-column: 2; + max-height: 70vh; + overflow-y: auto; + padding-left: 1rem; +} + +.cms-collection-manager-slider.is-editing-item .cms-collection-manager-panel { + transform: translateX(-100%); +} + .cms-media-grid { --cms-media-grid-gap: 12px; --cms-media-card-preview-height: 140px; diff --git a/modules/ui-module/src/main/resources/manager/index.html b/modules/ui-module/src/main/resources/manager/index.html index 76419af7e..7ed77cbf1 100644 --- a/modules/ui-module/src/main/resources/manager/index.html +++ b/modules/ui-module/src/main/resources/manager/index.html @@ -60,8 +60,7 @@ "@cms/js/": "{{ managerBaseURL }}/js/", "@cms/libs/": "{{ managerBaseURL }}/js/libs/", "@cms/manager/": "{{ managerBaseURL }}/js/manager/", - "@cms/modules/": "{{ managerBaseURL }}/js/modules/", - "condation-cms-ui/dist/js/modules/" : "{{ managerBaseURL }}/js/modules/" + "@cms/modules/": "{{ managerBaseURL }}/js/modules/" } } @@ -215,6 +214,7 @@ class="badge rounded-pill text-bg-secondary cms-current-variant" type="button" title="Current page variant" + hidden disabled> Loading… diff --git a/modules/ui-module/src/main/resources/manager/js/manager-inject-init.js b/modules/ui-module/src/main/resources/manager/js/manager-inject-init.js index 37b64ba09..ba32ddd8a 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject-init.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject-init.js @@ -58,7 +58,7 @@ export function initIframe() { continue; } if (item.data.status === 'published') { - sectionContainer.setAttribute('data-cms-action', 'unpublish'); + sectionContainer.dataset.cmsAction = 'unpublish'; sectionContainer.setAttribute("title", "Unpublish"); if (isSectionPublishedExpired(item)) { sectionContainer.innerHTML = SECTION_UNPUBLISHED_ICON; @@ -75,7 +75,7 @@ export function initIframe() { } else { sectionContainer.innerHTML = SECTION_UNPUBLISHED_ICON; - sectionContainer.setAttribute('data-cms-action', 'publish'); + sectionContainer.dataset.cmsAction = 'publish'; sectionContainer.setAttribute("title", "Publish"); sectionContainer.classList.remove('cms-published'); sectionContainer.classList.remove('cms-published-expired'); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/collection-picker.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/collection-picker.d.ts new file mode 100644 index 000000000..c0eba907b --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/collection-picker.d.ts @@ -0,0 +1,28 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { CollectionItemSummary } from '@cms/modules/rpc/rpc-collection.js'; +export interface CollectionItemPickerOptions { + collection: string; + title?: string; + selectText?: string; + onSelect: (item: CollectionItemSummary) => void | Promise; +} +export declare const openCollectionItemPicker: (options: CollectionItemPickerOptions) => void; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/collection-picker.js b/modules/ui-module/src/main/resources/manager/js/modules/collection-picker.js new file mode 100644 index 000000000..4924b7031 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/collection-picker.js @@ -0,0 +1,156 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { listCollectionItems } from '@cms/modules/rpc/rpc-collection.js'; +const PAGE_SIZE = 10; +const escapeHtml = (value) => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +const renderMessage = (message) => `

${escapeHtml(message)}

`; +const renderResults = (response, selectText) => { + if (response.items.length === 0) { + return renderMessage(i18n.t('collection.picker.noResults', 'No collection items found.')); + } + const page = Math.max(1, response.page); + const totalPages = Math.max(1, response.totalPages); + return ` +
+ ${response.items.map((item, index) => ` + `).join('')} +
+
+ + + ${escapeHtml(i18n.t('collection.picker.page', 'Page'))} ${page} / ${totalPages} + + +
`; +}; +export const openCollectionItemPicker = (options) => { + let modal; + let requestVersion = 0; + let debounceTimer; + let currentPage = 1; + const inputId = `cms-collection-picker-${Date.now()}`; + const selectText = options.selectText || i18n.t('collection.picker.select', 'Select'); + modal = openModal({ + title: options.title || i18n.t('collection.picker.title', 'Select collection item'), + body: ` + + +
`, + fullscreen: false, + size: 'lg', + showFooter: false, + onShow: (modalElement) => { + const input = modalElement.querySelector(`#${inputId}`); + const resultsElement = modalElement.querySelector('[data-collection-picker-results]'); + const loadPage = async () => { + const version = ++requestVersion; + resultsElement.innerHTML = ` +
+ + ${escapeHtml(i18n.t('collection.picker.loading', 'Loading collection items...'))} +
`; + try { + const response = await listCollectionItems({ + collection: options.collection, + query: input.value.trim() || undefined, + page: currentPage, + size: PAGE_SIZE + }); + if (version !== requestVersion) + return; + currentPage = response.page; + resultsElement.innerHTML = renderResults(response, selectText); + resultsElement.querySelectorAll('[data-collection-picker-index]').forEach(button => { + button.addEventListener('click', async () => { + const item = response.items[Number(button.dataset.collectionPickerIndex)]; + if (!item) + return; + await options.onSelect(item); + modal.hide(); + }); + }); + resultsElement.querySelectorAll('[data-collection-picker-page]') + .forEach(button => button.addEventListener('click', () => { + const targetPage = Number(button.dataset.collectionPickerPage); + if (button.disabled || !Number.isInteger(targetPage) || targetPage < 1 + || targetPage > response.totalPages || targetPage === currentPage) { + return; + } + currentPage = targetPage; + loadPage(); + })); + } + catch (error) { + // exception is ignored, message to user is displayed in the modal + if (version !== requestVersion) + return; + resultsElement.innerHTML = ` +
+ ${escapeHtml(i18n.t('collection.picker.loadError', 'Could not load collection items.'))} +
`; + } + }; + input.addEventListener('input', () => { + window.clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(() => { + currentPage = 1; + loadPage(); + }, 300); + }); + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + window.clearTimeout(debounceTimer); + currentPage = 1; + loadPage(); + } + }); + loadPage(); + input.focus(); + } + }); +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/event-bus.js b/modules/ui-module/src/main/resources/manager/js/modules/event-bus.js index 16ee9dff6..55a6111a2 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/event-bus.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/event-bus.js @@ -21,7 +21,7 @@ const listeners = {}; export const EventBus = { on(event, handler) { - (listeners[event] || (listeners[event] = [])).push(handler); + (listeners[event] ||= []).push(handler); }, emit(event, payload) { (listeners[event] || []).forEach(fn => fn(payload)); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.collection.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/field.collection.d.ts new file mode 100644 index 000000000..bd0aabe1c --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.collection.d.ts @@ -0,0 +1,27 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { FieldOptions, FormField } from '@cms/modules/form/forms.js'; +export interface CollectionFieldOptions extends FieldOptions { + options?: { + collection?: string; + }; +} +export declare const CollectionField: FormField; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.collection.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.collection.js new file mode 100644 index 000000000..d51c8f955 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.collection.js @@ -0,0 +1,95 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { openCollectionItemPicker } from '@cms/modules/collection-picker.js'; +import { createID } from '@cms/modules/form/utils.js'; +import { i18n } from '@cms/modules/localization.js'; +const escapeHtml = (value) => String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +const createCollectionField = (options, value = '') => { + const id = createID(); + const key = `field.${options.name}`; + const title = i18n.t(key, options.title); + const collection = options.options?.collection || ''; + const disabled = collection ? '' : ' disabled'; + return ` +
+ +
+ + + +
+ ${collection ? '' : `
${escapeHtml(i18n.t('collection.field.missingCollection', 'No collection is configured for this field.'))}
`} +
`; +}; +const init = (context) => { + const formElement = context.formElement; + if (!formElement) + return; + formElement.querySelectorAll("[data-cms-form-field-type='collection']").forEach(wrapper => { + const input = wrapper.querySelector('.cms-collection-input-value'); + const selectButton = wrapper.querySelector('.cms-collection-select'); + const clearButton = wrapper.querySelector('.cms-collection-clear'); + const collection = wrapper.dataset.cmsCollection; + if (!input || !selectButton || !clearButton || !collection) + return; + selectButton.addEventListener('click', () => { + openCollectionItemPicker({ + collection, + title: i18n.t('collection.field.dialogTitle', `Select item from ${collection}`), + onSelect: item => { + input.value = item.id; + input.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + }); + clearButton.addEventListener('click', () => { + input.value = ''; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + }); +}; +const getData = (context) => { + const data = {}; + context.formElement?.querySelectorAll("[data-cms-form-field-type='collection'] input[name]").forEach(input => { + data[input.name] = { + type: 'collection', + value: input.value + }; + }); + return data; +}; +export const CollectionField = { + markup: createCollectionField, + init, + data: getData +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js b/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js index a65313914..3f8408baf 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/forms.js @@ -37,6 +37,7 @@ import { MediaField } from "@cms/modules/form/field.media.js"; import { ListField } from "@cms/modules/form/field.list.js"; import { TextAreaField } from "@cms/modules/form/field.textarea.js"; import { ReferenceField } from "@cms/modules/form/field.reference.js"; +import { CollectionField } from "@cms/modules/form/field.collection.js"; import { TagsField } from "@cms/modules/form/field.tags.js"; import { i18n } from "@cms/modules/localization.js"; const getFormFields = (definition) => { @@ -47,11 +48,11 @@ const getFormFields = (definition) => { return [...fields, ...tabFields]; }; const escapeHtml = (value) => String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); const createForm = (options) => { const standaloneFields = Array.isArray(options.fields) ? options.fields : []; const tabs = Array.isArray(options.tabs) ? options.tabs : []; @@ -101,6 +102,8 @@ const createForm = (options) => { return TextAreaField.markup(field, val); case 'reference': return ReferenceField.markup(field, val); + case 'collection': + return CollectionField.markup(field, val); case 'tags': return TagsField.markup(field, val); default: @@ -227,6 +230,7 @@ const createForm = (options) => { MediaField.init(context); ListField.init(context); ReferenceField.init(context); + CollectionField.init(context); TagsField.init(context); markRequiredFields(); }; @@ -253,6 +257,7 @@ const createForm = (options) => { ...ListField.data(context), ...TextAreaField.data(context), ...ReferenceField.data(context), + ...CollectionField.data(context), ...TagsField.data(context) }; return data; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js index 66066392b..c17852563 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager-ui.js @@ -28,6 +28,23 @@ const updateVariantBadge = (content) => { if (!badge || !label) { return; } + const hasContent = Boolean(content?.uri); + const supportsVariants = hasContent + && content?.supportsVariants !== false + && content?.contentKind !== 'collection'; + badge.hidden = !hasContent; + if (!hasContent) { + badge.disabled = true; + return; + } + if (!supportsVariants) { + label.textContent = 'Unsupported'; + badge.disabled = false; + badge.classList.remove('text-bg-warning'); + badge.classList.add('text-bg-secondary'); + badge.setAttribute('title', 'Collections do not support variants'); + return; + } const variantId = content?.variantId; label.textContent = content ? (variantId || 'Original') : 'Loading…'; badge.disabled = !content?.uri; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js index 3894184d8..d0e854a3e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/manager.message.handlers.js @@ -145,6 +145,16 @@ const initMessageHandlers = () => { } executeScriptAction(cmd); }); + frameMessenger.on('edit-collection-item', (payload) => { + executeScriptAction({ + module: window.manager.baseUrl + '/actions/collection/edit-collection-item', + function: 'runAction', + parameters: { + collection: payload.collection, + id: payload.id + } + }); + }); frameMessenger.on('add-sectionEntry', (payload) => { var cmd = { "module": window.manager.baseUrl + "/actions/page/add-section", diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js index b137a68f6..9ae67f499 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/toolbar.inject.js @@ -115,6 +115,17 @@ const editAttributes = (event) => { */ frameMessenger.send(window.parent, command); }; +const editCollectionItem = (event) => { + const toolbar = event.target.closest('[data-cms-toolbar]'); + const definition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); + frameMessenger.send(window.parent, { + type: 'edit-collection-item', + payload: { + collection: definition.collection, + id: definition.itemId + } + }); +}; const initDragDrop = (container) => { if (container.dataset.cmsDragDropInitialized === 'true') { return; @@ -132,7 +143,7 @@ const initDragDrop = (container) => { const keepPlaceholderPosition = Symbol('keepPlaceholderPosition'); const createPlaceholder = (item) => { const nextPlaceholder = document.createElement('div'); - nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); + nextPlaceholder.dataset.cmsDragPlaceholder = 'true'; const cs = getComputedStyle(item); nextPlaceholder.style.width = item.offsetWidth + 'px'; nextPlaceholder.style.height = item.offsetHeight + 'px'; @@ -213,7 +224,7 @@ const initDragDrop = (container) => { if (itemToolbar && !itemToolbar.querySelector('[data-cms-drag-handle]')) { const handle = document.createElement('button'); handle.setAttribute('type', 'button'); - handle.setAttribute('data-cms-drag-handle', ''); + handle.dataset.cmsDragHandle = ''; handle.setAttribute('title', 'Drag to reorder'); handle.setAttribute('aria-label', 'Drag to reorder'); handle.innerHTML = MOVE_ICON; @@ -334,7 +345,7 @@ export const initToolbar = (container) => { toolbarDefinition.actions.forEach((action) => { if (action === "editContent") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'edit'); + button.dataset.cmsAction = 'edit'; button.innerHTML = EDIT_PAGE_ICON; button.setAttribute("title", "Edit content"); button.addEventListener('click', editContent); @@ -342,15 +353,23 @@ export const initToolbar = (container) => { } else if (action === "editAttributes") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'editAttributes'); + button.dataset.cmsAction = 'editAttributes'; button.innerHTML = EDIT_ATTRIBUTES_ICON; button.setAttribute("title", "Edit attributes"); button.addEventListener('click', editAttributes); toolbar.appendChild(button); } + else if (action === "editCollectionItem") { + const button = document.createElement('button'); + button.dataset.cmsAction = 'editCollectionItem'; + button.innerHTML = EDIT_ATTRIBUTES_ICON; + button.setAttribute('title', 'Edit collection item'); + button.addEventListener('click', editCollectionItem); + toolbar.appendChild(button); + } else if (action === "orderSectionEntries") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'editSections'); + button.dataset.cmsAction = 'editSections'; button.innerHTML = SECTION_SORT_ICON; button.setAttribute("title", "Order"); button.addEventListener('click', orderSections); @@ -358,7 +377,7 @@ export const initToolbar = (container) => { } else if (action === "addSectionEntry") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'addSection'); + button.dataset.cmsAction = 'addSection'; button.innerHTML = SECTION_ADD_ICON; button.setAttribute("title", "Add"); button.addEventListener('click', addSection); @@ -366,7 +385,7 @@ export const initToolbar = (container) => { } else if (action === "deleteSectionEntry") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'deleteSection'); + button.dataset.cmsAction = 'deleteSection'; button.innerHTML = SECTION_DELETE_ICON; button.setAttribute("title", "Delete"); button.addEventListener('click', deleteSection); @@ -382,8 +401,8 @@ export const initToolbar = (container) => { }); if (toolbarDefinition.type === "sectionEntry") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'publish'); - button.setAttribute('data-cms-section-uri', toolbarDefinition.uri); + button.dataset.cmsAction = 'publish'; + button.dataset.cmsSectionUri = toolbarDefinition.uri; button.classList.add('cms-unpublished'); button.innerHTML = SECTION_UNPUBLISHED_ICON; button.setAttribute("title", "Publish"); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts index c69c1c5c0..e9d912a35 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview-context.d.ts @@ -23,6 +23,10 @@ export interface ActivePreviewContent { url?: string; canonicalUri?: string; variantId?: string | null; + contentKind?: 'content' | 'collection'; + supportsVariants?: boolean; + collection?: string; + collectionItemId?: string; } declare const setActivePreviewContent: (content: ActivePreviewContent | null) => void; declare const getActivePreviewContent: (currentPreviewUrl?: string) => ActivePreviewContent | null; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-collection.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-collection.d.ts new file mode 100644 index 000000000..eb17ed4ee --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-collection.d.ts @@ -0,0 +1,74 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export interface CollectionItemSummary { + id: string; + collection: string; + path: string; + title: string; + detailUrl?: string | null; + meta: Record; +} +export interface EditableCollectionItem { + id: string; + collection: string; + path: string; + content: string; + meta: Record; +} +export interface CollectionItemsPage { + totalItems: number; + pageSize: number; + totalPages: number; + page: number; + items: CollectionItemSummary[]; +} +export interface CollectionItemsCursorPage { + items: CollectionItemSummary[]; + nextCursor?: string | null; +} +export interface ListCollectionItemsOptions { + collection: string; + query?: string; + page?: number; + size?: number; +} +export interface ListCollectionItemsCursorOptions { + collection: string; + query?: string; + cursor?: string; + size?: number; +} +export declare const listCollectionItems: (options: ListCollectionItemsOptions) => Promise; +export declare const listCollectionItemsCursor: (options: ListCollectionItemsCursorOptions) => Promise; +export declare const getCollectionItem: (collection: string, id: string) => Promise; +export declare const saveCollectionItem: (options: { + collection: string; + id: string; + content: any; + meta: Record; +}) => Promise; +export declare const createCollectionItem: (options: { + collection: string; + id: string; + content: any; + meta: Record; +}) => Promise; +export declare const deleteCollectionItem: (collection: string, id: string) => Promise; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-collection.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-collection.js new file mode 100644 index 000000000..dbe4fad60 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-collection.js @@ -0,0 +1,57 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { executeRemoteCall } from '@cms/modules/rpc/rpc.js'; +export const listCollectionItems = async (options) => { + return (await executeRemoteCall({ + method: 'collections.items', + parameters: options + })).result; +}; +export const listCollectionItemsCursor = async (options) => { + return (await executeRemoteCall({ + method: 'collections.items.cursor', + parameters: options + })).result; +}; +export const getCollectionItem = async (collection, id) => { + return (await executeRemoteCall({ + method: 'collections.item.get', + parameters: { collection, id } + })).result; +}; +export const saveCollectionItem = async (options) => { + await executeRemoteCall({ + method: 'collections.item.save', + parameters: options + }); +}; +export const createCollectionItem = async (options) => { + return (await executeRemoteCall({ + method: 'collections.item.create', + parameters: options + })).result; +}; +export const deleteCollectionItem = async (collection, id) => { + await executeRemoteCall({ + method: 'collections.item.delete', + parameters: { collection, id } + }); +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.d.ts index 9385b0d14..a5cde78ef 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.d.ts @@ -45,12 +45,18 @@ export interface ListItemType { name: string; form: FormDefinition; } +export interface CollectionType { + name: string; + label: string; + forms: Record; +} interface ContentTypeResponse { result: T[]; } declare const getSectionEntryTemplates: (options: any) => Promise>; declare const getPageTemplates: (options: any) => Promise>; declare const getListItemTypes: (options: any) => Promise>; +declare const getCollectionTypes: () => Promise>; declare const getMediaForm: (options: any) => Promise; declare const createCSRFToken: (options: any) => Promise; export declare enum Format { @@ -71,4 +77,4 @@ export interface MediaFormatsResponse { } declare const getMediaFormats: (options: any) => Promise; declare const getShortCodeNames: (options: any) => Promise; -export { getSectionEntryTemplates, getPageTemplates, getMediaForm, getShortCodeNames, getMediaFormats, getListItemTypes, createCSRFToken }; +export { getSectionEntryTemplates, getPageTemplates, getMediaForm, getShortCodeNames, getMediaFormats, getListItemTypes, getCollectionTypes, createCSRFToken }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.js index d9c29286f..a1c6b7b6b 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-manager.js @@ -40,6 +40,12 @@ const getListItemTypes = async (options) => { }; return await executeRemoteCall(data); }; +const getCollectionTypes = async () => { + return await executeRemoteCall({ + method: "manager.contentTypes.collections", + parameters: {} + }); +}; const getMediaForm = async (options) => { var data = { method: "manager.media.form", @@ -74,4 +80,4 @@ const getShortCodeNames = async (options) => { }; return await executeRemoteCall(data); }; -export { getSectionEntryTemplates, getPageTemplates, getMediaForm, getShortCodeNames, getMediaFormats, getListItemTypes, createCSRFToken }; +export { getSectionEntryTemplates, getPageTemplates, getMediaForm, getShortCodeNames, getMediaFormats, getListItemTypes, getCollectionTypes, createCSRFToken }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js index 5724b2258..6ebcd61f3 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.js @@ -53,6 +53,12 @@ const executeRemoteMethodCall = async (method, parameters) => { ...(csrfToken && { 'X-CSRF-Token': csrfToken }), ...(activePreviewContent?.uri && { 'X-CMS-Content-Uri': activePreviewContent.uri + }), + ...(activePreviewContent?.contentKind === 'collection' && activePreviewContent.collection && { + 'X-CMS-Collection': activePreviewContent.collection + }), + ...(activePreviewContent?.contentKind === 'collection' && activePreviewContent.collectionItemId && { + 'X-CMS-Collection-Item': activePreviewContent.collectionItemId }) }, body: JSON.stringify(data) diff --git a/modules/ui-module/src/main/resources/manager/js/modules/variant-support.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/variant-support.d.ts new file mode 100644 index 000000000..4837ac7da --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/variant-support.d.ts @@ -0,0 +1,23 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { ActivePreviewContent } from '@cms/modules/preview-context.js'; +export declare const variantsSupported: (content?: ActivePreviewContent | null) => boolean; +export declare const ensureVariantsSupported: (content?: ActivePreviewContent | null) => boolean; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/variant-support.js b/modules/ui-module/src/main/resources/manager/js/modules/variant-support.js new file mode 100644 index 000000000..4db0ce03b --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/variant-support.js @@ -0,0 +1,37 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +import { i18n } from '@cms/modules/localization.js'; +import { showToast } from '@cms/modules/toast.js'; +export const variantsSupported = (content) => Boolean(content?.uri) + && content?.supportsVariants !== false + && content?.contentKind !== 'collection'; +export const ensureVariantsSupported = (content) => { + if (variantsSupported(content)) { + return true; + } + showToast({ + title: i18n.t('manager.actions.page.variants.unsupported.title', 'Variants not supported'), + message: i18n.t('manager.actions.page.variants.unsupported.message', 'Collections do not support variants.'), + type: 'info', + timeout: 3000 + }); + return false; +}; diff --git a/modules/ui-module/src/main/ts/package.json b/modules/ui-module/src/main/ts/package.json index 9f8a94e7b..e060cbe03 100644 --- a/modules/ui-module/src/main/ts/package.json +++ b/modules/ui-module/src/main/ts/package.json @@ -3,6 +3,7 @@ "author": "CondationCMS", "version": "0.3.0", "scripts": { + "dist": "tsc", "build": "tsc && node scripts/copy-dist.mjs", "test": "tsc --noEmit && node --test test/*.test.mjs" }, diff --git a/modules/ui-module/src/main/ts/src/actions/collection/create-collection-item.ts b/modules/ui-module/src/main/ts/src/actions/collection/create-collection-item.ts new file mode 100644 index 000000000..7c2302bdd --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/collection/create-collection-item.ts @@ -0,0 +1,117 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { collectionForm } from './edit-collection-item.js'; +import { createForm, getFormFields } from '@cms/modules/form/forms.js'; +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { loadPreview } from '@cms/modules/preview.utils.js'; +import { CollectionItemSummary, createCollectionItem } from '@cms/modules/rpc/rpc-collection.js'; +import { getCollectionTypes } from '@cms/modules/rpc/rpc-manager.js'; +import { showToast } from '@cms/modules/toast.js'; + +const ID_FIELD = 'id'; +const CONTENT_FIELD = 'content'; + +export interface CreateCollectionItemOptions { + collection: string; + onCreated?: (item: CollectionItemSummary) => void | Promise; +} + +const fieldValue = (field: any): string => String(field?.value ?? field ?? '').trim(); + +export const openCollectionItemCreator = async (options: CreateCollectionItemOptions) => { + try { + const typeResponse = await getCollectionTypes(); + const definition = collectionForm(typeResponse.result, options.collection, 'create'); + const hasIdField = getFormFields(definition).some(field => field.name === ID_FIELD); + const form = createForm({ + fields: [ + ...(hasIdField ? [] : [{ + type: 'text', + name: ID_FIELD, + title: i18n.t('collection.item.create.id', 'Item ID'), + required: true + }]), + ...(definition.fields ?? []) + ], + tabs: definition.tabs ?? [], + values: {} + }); + + openModal({ + title: i18n.t('collection.item.create.title', 'Create collection item'), + body: '', + form, + fullscreen: true, + onCancel: () => {}, + onOk: async () => { + const data = form.getData(); + const id = fieldValue(data[ID_FIELD]); + const content = data[CONTENT_FIELD]; + delete data[ID_FIELD]; + delete data[CONTENT_FIELD]; + try { + const item = await createCollectionItem({ + collection: options.collection, + id, + content, + meta: data + }); + showToast({ + title: i18n.t('collection.item.create.success.title', 'Collection item created'), + message: i18n.t('collection.item.create.success.message', 'The collection item was created successfully.'), + type: 'success', + timeout: 3000 + }); + await options.onCreated?.(item); + return true; + } catch (error: any) { + showToast({ + title: i18n.t('collection.item.create.error.title', 'Collection item not created'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + return false; + } + } + }); + } catch (error: any) { + showToast({ + title: i18n.t('collection.item.create.loadError.title', 'Collection form could not be loaded'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + } +}; + +export const runAction = async (options: CreateCollectionItemOptions) => { + await openCollectionItemCreator({ + ...options, + onCreated: item => { + if (item.detailUrl) { + loadPreview(item.detailUrl); + } + } + }); +}; diff --git a/modules/ui-module/src/main/ts/src/actions/collection/edit-collection-item.ts b/modules/ui-module/src/main/ts/src/actions/collection/edit-collection-item.ts new file mode 100644 index 000000000..a899a06aa --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/collection/edit-collection-item.ts @@ -0,0 +1,141 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { createForm, Form, getFormFields } from '@cms/modules/form/forms.js'; +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { buildValuesFromFields } from '@cms/modules/node.js'; +import { reloadPreview } from '@cms/modules/preview.utils.js'; +import { getCollectionItem, saveCollectionItem } from '@cms/modules/rpc/rpc-collection.js'; +import { CollectionType, getCollectionTypes } from '@cms/modules/rpc/rpc-manager.js'; +import { showToast } from '@cms/modules/toast.js'; + +const CONTENT_FIELD = 'content'; + +const defaultForm = { + fields: [ + { type: 'text', name: 'title', title: 'Title', required: true }, + { type: 'markdown', name: CONTENT_FIELD, title: 'Content', height: '60vh' } + ], + tabs: [] +}; + +export const collectionForm = ( + types: CollectionType[], + collection: string, + mode: 'create' | 'edit' = 'edit' +): any => { + const forms = types.find(type => type.name === collection)?.forms; + return forms?.[mode] ?? forms?.edit ?? defaultForm; +}; + +export interface EditCollectionItemOptions { + collection: string; + id: string; + reloadAfterSave?: boolean; + onSaved?: () => void | Promise; +} + +export interface CollectionItemEditor { + form: Form; + save: () => Promise; +} + +export const createCollectionItemEditor = async ( + options: EditCollectionItemOptions +): Promise => { + const [item, typeResponse] = await Promise.all([ + getCollectionItem(options.collection, options.id), + getCollectionTypes() + ]); + const definition = collectionForm(typeResponse.result, options.collection); + const fields = getFormFields(definition); + const form = createForm({ + fields: definition.fields ?? [], + tabs: definition.tabs ?? [], + values: { + ...buildValuesFromFields(fields, item.meta), + [CONTENT_FIELD]: item.content + } + }); + + return { + form, + save: async () => { + const data = form.getData(); + const content = data[CONTENT_FIELD]; + delete data[CONTENT_FIELD]; + try { + await saveCollectionItem({ + collection: options.collection, + id: options.id, + content, + meta: data + }); + showToast({ + title: i18n.t('collection.item.edit.success.title', 'Collection item updated'), + message: i18n.t('collection.item.edit.success.message', 'The collection item was updated successfully.'), + type: 'success', + timeout: 3000 + }); + await options.onSaved?.(); + if (options.reloadAfterSave) { + reloadPreview(); + } + return true; + } catch (error: any) { + showToast({ + title: i18n.t('collection.item.edit.error.title', 'Collection item not updated'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + return false; + } + } + }; +}; + +export const openCollectionItemEditor = async (options: EditCollectionItemOptions) => { + try { + const editor = await createCollectionItemEditor(options); + + openModal({ + title: i18n.t('collection.item.edit.title', 'Edit collection item'), + body: '', + form: editor.form, + fullscreen: true, + onCancel: () => {}, + onOk: editor.save + }); + } catch (error: any) { + showToast({ + title: i18n.t('collection.item.edit.loadError.title', 'Collection item could not be loaded'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + } +}; + +export const runAction = async (options: EditCollectionItemOptions) => { + await openCollectionItemEditor({ ...options, reloadAfterSave: true }); +}; diff --git a/modules/ui-module/src/main/ts/src/actions/collection/manage-collection.ts b/modules/ui-module/src/main/ts/src/actions/collection/manage-collection.ts new file mode 100644 index 000000000..a7765d182 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/collection/manage-collection.ts @@ -0,0 +1,315 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { openCollectionItemCreator } from './create-collection-item.js'; +import { CollectionItemEditor, createCollectionItemEditor } from './edit-collection-item.js'; +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { loadPreview } from '@cms/modules/preview.utils.js'; +import { CollectionItemSummary, deleteCollectionItem, listCollectionItemsCursor } from '@cms/modules/rpc/rpc-collection.js'; +import { showToast } from '@cms/modules/toast.js'; + +const PAGE_SIZE = 10; +const MIN_SEARCH_LENGTH = 3; + +const escapeHtml = (value: string | number | boolean | null | undefined): string => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const renderItems = (items: CollectionItemSummary[]): string => { + if (items.length === 0) { + return `

${i18n.t('collection.items.empty', 'No collection items found.')}

`; + } + return `
${items.map(item => ` +
+
+ ${escapeHtml(item.title)}
+ ${escapeHtml(item.id)} +
+
+ + ${item.detailUrl ? `` : ''} + +
+
`).join('')}
`; +}; + +export const runAction = async (options: { collection: string }) => { + let currentPage = 1; + let cursorHistory = ['']; + let currentQuery = ''; + let requestVersion = 0; + let editorRequestVersion = 0; + let modal: any; + let modalElement: HTMLElement | null = null; + let collectionSlider: HTMLElement | null = null; + let browsePanel: HTMLElement | null = null; + let editorPanel: HTMLElement | null = null; + let editorContent: HTMLElement | null = null; + let editorSaveButton: HTMLButtonElement | null = null; + let currentEditor: CollectionItemEditor | null = null; + let isSaving = false; + + const body = ` +
+
+
+
+ + +
+ +
+
+
+
+ +
`; + + const closeEditor = () => { + editorRequestVersion++; + currentEditor = null; + isSaving = false; + if (editorSaveButton) editorSaveButton.disabled = true; + collectionSlider?.classList.remove('is-editing-item'); + browsePanel?.setAttribute('aria-hidden', 'false'); + editorPanel?.setAttribute('aria-hidden', 'true'); + if (browsePanel) browsePanel.inert = false; + if (editorPanel) editorPanel.inert = true; + }; + + const openEditor = async (id: string) => { + if (!collectionSlider || !editorContent || !editorSaveButton) return; + const version = ++editorRequestVersion; + currentEditor = null; + editorSaveButton.disabled = true; + editorContent.innerHTML = `
${i18n.t('collection.item.edit.loading', 'Loading collection item...')}
`; + collectionSlider.classList.add('is-editing-item'); + browsePanel?.setAttribute('aria-hidden', 'true'); + editorPanel?.setAttribute('aria-hidden', 'false'); + if (browsePanel) browsePanel.inert = true; + if (editorPanel) editorPanel.inert = false; + + try { + const editor = await createCollectionItemEditor({ + collection: options.collection, + id, + onSaved: async () => { + currentPage = 1; + cursorHistory = ['']; + await update(); + closeEditor(); + } + }); + if (version !== editorRequestVersion) return; + currentEditor = editor; + editorContent.innerHTML = ''; + editor.form.init(editorContent); + editorSaveButton.disabled = false; + } catch (error: any) { + if (version !== editorRequestVersion) return; + showToast({ + title: i18n.t('collection.item.edit.loadError.title', 'Collection item could not be loaded'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + closeEditor(); + } + }; + + const saveEditor = async () => { + if (!currentEditor || !editorSaveButton || isSaving || !currentEditor.form.validate()) return; + isSaving = true; + editorSaveButton.disabled = true; + const saved = await currentEditor.save(); + isSaving = false; + if (!saved && currentEditor) editorSaveButton.disabled = false; + }; + + const update = async () => { + const version = ++requestVersion; + const root = modalElement?.querySelector('[data-collection-results]') as HTMLElement | null; + const pagination = modalElement?.querySelector('[data-collection-pagination]') as HTMLElement | null; + if (!root || !pagination) return; + root.innerHTML = `
${i18n.t('collection.items.loading', 'Loading collection items...')}
`; + try { + const page = await listCollectionItemsCursor({ + collection: options.collection, + query: currentQuery, + cursor: cursorHistory[currentPage - 1], + size: PAGE_SIZE + }); + if (version !== requestVersion) return; + if (page.items.length === 0 && currentPage > 1) { + currentPage--; + await update(); + return; + } + root.innerHTML = renderItems(page.items); + pagination.innerHTML = currentPage > 1 || page.nextCursor ? ` + ` : ''; + + root.querySelectorAll('[data-collection-edit]').forEach(button => { + button.addEventListener('click', () => openEditor(button.dataset.collectionEdit ?? '')); + }); + root.querySelectorAll('[data-collection-open]').forEach(button => { + button.addEventListener('click', () => { + modal.hide(); + loadPreview(button.dataset.collectionOpen ?? ''); + }); + }); + root.querySelectorAll('[data-collection-delete]').forEach(button => { + button.addEventListener('click', async () => { + const id = button.dataset.collectionDelete ?? ''; + const prompt = `${i18n.t('collection.items.deleteConfirm', 'Delete collection item')} “${id}”?`; + if (!window.confirm(prompt)) return; + try { + await deleteCollectionItem(options.collection, id); + currentPage = 1; + cursorHistory = ['']; + showToast({ + title: i18n.t('collection.items.deleteSuccess.title', 'Collection item deleted'), + message: i18n.t('collection.items.deleteSuccess.message', 'The collection item was deleted successfully.'), + type: 'success', + timeout: 3000 + }); + await update(); + } catch (error: any) { + showToast({ + title: i18n.t('collection.items.deleteError.title', 'Collection item not deleted'), + message: error?.message ?? String(error), + type: 'error', + timeout: 3000 + }); + } + }); + }); + pagination.querySelectorAll('[data-collection-direction]').forEach(button => { + button.addEventListener('click', () => { + if (button.dataset.collectionDirection === 'next' && page.nextCursor) { + cursorHistory[currentPage] = page.nextCursor; + currentPage++; + } else if (button.dataset.collectionDirection === 'previous' && currentPage > 1) { + currentPage--; + } + update(); + }); + }); + } catch (error: any) { + if (version !== requestVersion) return; + root.innerHTML = `
${escapeHtml(error?.message ?? error)}
`; + pagination.innerHTML = ''; + } + }; + + modal = openModal({ + title: options.collection, + body, + size: 'xl', + showFooter: false, + onShow: (element: HTMLElement) => { + modalElement = element; + collectionSlider = element.querySelector('[data-collection-slider]'); + browsePanel = element.querySelector('[data-collection-browse-panel]'); + editorPanel = element.querySelector('[data-collection-editor-panel]'); + editorContent = element.querySelector('[data-collection-editor-content]'); + editorSaveButton = element.querySelector('[data-collection-editor-save]'); + const input = element.querySelector('#cms-collection-search'); + element.querySelectorAll('[data-collection-editor-back], [data-collection-editor-cancel]') + .forEach(button => button.addEventListener('click', closeEditor)); + editorSaveButton?.addEventListener('click', saveEditor); + element.querySelector('[data-collection-create]')?.addEventListener('click', () => { + openCollectionItemCreator({ + collection: options.collection, + onCreated: async () => { + currentQuery = ''; + currentPage = 1; + cursorHistory = ['']; + if (input) input.value = ''; + await update(); + } + }); + }); + let debounce: number | undefined; + input?.addEventListener('input', () => { + window.clearTimeout(debounce); + debounce = window.setTimeout(() => { + const value = input.value.trim(); + if (value.length > 0 && value.length < MIN_SEARCH_LENGTH) return; + currentQuery = value; + currentPage = 1; + cursorHistory = ['']; + update(); + }, 300); + }); + update(); + } + }); +}; diff --git a/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts index eb26bb54e..0b4f2a95c 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/create-variant.ts @@ -26,6 +26,7 @@ import { getContentNode } from '@cms/modules/rpc/rpc-content.js' import { getPageTemplates } from '@cms/modules/rpc/rpc-manager.js' import { createVariant, getVariants } from '@cms/modules/rpc/rpc-variant.js' import { showToast } from '@cms/modules/toast.js' +import { ensureVariantsSupported } from '@cms/modules/variant-support.js' const value = (id: string): string => (document.getElementById(id) as HTMLInputElement | HTMLSelectElement)?.value.trim() ?? ''; @@ -58,10 +59,11 @@ const validate = (): boolean => { export const runAction = async () => { try { - const [activeContentNode, templatesResponse] = await Promise.all([ - getContentNode({ url: getPreviewUrl() }), - getPageTemplates({}) - ]); + const activeContentNode = await getContentNode({ url: getPreviewUrl() }); + if (!ensureVariantsSupported(activeContentNode.result)) { + return; + } + const templatesResponse = await getPageTemplates({}); const variantContext = await getVariants({ uri: activeContentNode.result.uri }); const templates = Array.from(templatesResponse.result ?? []) .sort((left: any, right: any) => String(left.name).localeCompare(String(right.name))); diff --git a/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts b/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts index b51570359..e0cc02e4f 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/variant-selector.ts @@ -29,6 +29,7 @@ import { VariantSelectorDto } from '@cms/modules/rpc/rpc-variant.js' import { showToast } from '@cms/modules/toast.js' +import { ensureVariantsSupported } from '@cms/modules/variant-support.js' const SELECT_ID = 'cms-variant-selector'; @@ -55,6 +56,9 @@ const showError = (error: unknown) => showToast({ export const runAction = async () => { try { const contentNode = await getContentNode({ url: getPreviewUrl() }); + if (!ensureVariantsSupported(contentNode.result)) { + return; + } const result = await getVariantSelectors(contentNode.result.uri); openModal({ diff --git a/modules/ui-module/src/main/ts/src/actions/page/variants.ts b/modules/ui-module/src/main/ts/src/actions/page/variants.ts index f9b2ad2d7..e1370c9a8 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/variants.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/variants.ts @@ -31,6 +31,7 @@ import { VariantDto } from '@cms/modules/rpc/rpc-variant.js' import { showToast } from '@cms/modules/toast.js' +import { ensureVariantsSupported } from '@cms/modules/variant-support.js' const VARIANT_LIST_ID = 'cms-page-variants'; @@ -201,6 +202,9 @@ export const runAction = async () => { const contentNode = await getContentNode({ url: getPreviewUrl() }); + if (!ensureVariantsSupported(contentNode.result)) { + return; + } const result = await getVariants({ uri: contentNode.result.uri }); diff --git a/modules/ui-module/src/main/ts/src/js/manager-inject-init.js b/modules/ui-module/src/main/ts/src/js/manager-inject-init.js index 4594a141a..447d7c1b6 100644 --- a/modules/ui-module/src/main/ts/src/js/manager-inject-init.js +++ b/modules/ui-module/src/main/ts/src/js/manager-inject-init.js @@ -72,7 +72,7 @@ export function initIframe() { continue; } if (item.data.status === 'published') { - sectionContainer.setAttribute('data-cms-action', 'unpublish'); + sectionContainer.dataset.cmsAction = 'unpublish'; sectionContainer.setAttribute("title", "Unpublish"); if (isSectionPublishedExpired(item)) { @@ -91,7 +91,7 @@ export function initIframe() { } } else { sectionContainer.innerHTML = SECTION_UNPUBLISHED_ICON; - sectionContainer.setAttribute('data-cms-action', 'publish'); + sectionContainer.dataset.cmsAction = 'publish'; sectionContainer.setAttribute("title", "Publish"); sectionContainer.classList.remove('cms-published'); sectionContainer.classList.remove('cms-published-expired'); diff --git a/modules/ui-module/src/main/ts/src/js/modules/collection-picker.ts b/modules/ui-module/src/main/ts/src/js/modules/collection-picker.ts new file mode 100644 index 000000000..81e40bf9b --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/collection-picker.ts @@ -0,0 +1,177 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js'; +import { openModal } from '@cms/modules/modal.js'; +import { + CollectionItemSummary, + CollectionItemsPage, + listCollectionItems +} from '@cms/modules/rpc/rpc-collection.js'; + +const PAGE_SIZE = 10; + +export interface CollectionItemPickerOptions { + collection: string; + title?: string; + selectText?: string; + onSelect: (item: CollectionItemSummary) => void | Promise; +} + +const escapeHtml = (value: string | number | boolean | null | undefined): string => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const renderMessage = (message: string): string => + `

${escapeHtml(message)}

`; + +const renderResults = (response: CollectionItemsPage, selectText: string): string => { + if (response.items.length === 0) { + return renderMessage(i18n.t('collection.picker.noResults', 'No collection items found.')); + } + + const page = Math.max(1, response.page); + const totalPages = Math.max(1, response.totalPages); + return ` +
+ ${response.items.map((item, index) => ` + `).join('')} +
+
+ + + ${escapeHtml(i18n.t('collection.picker.page', 'Page'))} ${page} / ${totalPages} + + +
`; +}; + +export const openCollectionItemPicker = (options: CollectionItemPickerOptions): void => { + let modal: any; + let requestVersion = 0; + let debounceTimer: number | undefined; + let currentPage = 1; + const inputId = `cms-collection-picker-${Date.now()}`; + const selectText = options.selectText || i18n.t('collection.picker.select', 'Select'); + + modal = openModal({ + title: options.title || i18n.t('collection.picker.title', 'Select collection item'), + body: ` + + +
`, + fullscreen: false, + size: 'lg', + showFooter: false, + onShow: (modalElement: HTMLElement) => { + const input = modalElement.querySelector(`#${inputId}`) as HTMLInputElement; + const resultsElement = modalElement.querySelector('[data-collection-picker-results]') as HTMLElement; + + const loadPage = async () => { + const version = ++requestVersion; + resultsElement.innerHTML = ` +
+ + ${escapeHtml(i18n.t('collection.picker.loading', 'Loading collection items...'))} +
`; + try { + const response = await listCollectionItems({ + collection: options.collection, + query: input.value.trim() || undefined, + page: currentPage, + size: PAGE_SIZE + }); + if (version !== requestVersion) return; + + currentPage = response.page; + resultsElement.innerHTML = renderResults(response, selectText); + resultsElement.querySelectorAll('[data-collection-picker-index]').forEach(button => { + button.addEventListener('click', async () => { + const item = response.items[Number(button.dataset.collectionPickerIndex)]; + if (!item) return; + await options.onSelect(item); + modal.hide(); + }); + }); + resultsElement.querySelectorAll('[data-collection-picker-page]') + .forEach(button => button.addEventListener('click', () => { + const targetPage = Number(button.dataset.collectionPickerPage); + if (button.disabled || !Number.isInteger(targetPage) || targetPage < 1 + || targetPage > response.totalPages || targetPage === currentPage) { + return; + } + currentPage = targetPage; + loadPage(); + })); + } catch (error) { + // exception is ignored, message to user is displayed in the modal + if (version !== requestVersion) return; + resultsElement.innerHTML = ` +
+ ${escapeHtml(i18n.t('collection.picker.loadError', 'Could not load collection items.'))} +
`; + } + }; + + input.addEventListener('input', () => { + window.clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(() => { + currentPage = 1; + loadPage(); + }, 300); + }); + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + window.clearTimeout(debounceTimer); + currentPage = 1; + loadPage(); + } + }); + + loadPage(); + input.focus(); + } + }); +}; diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.collection.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.collection.ts new file mode 100644 index 000000000..00b634e78 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.collection.ts @@ -0,0 +1,115 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { openCollectionItemPicker } from '@cms/modules/collection-picker.js'; +import { FieldOptions, FormContext, FormField } from '@cms/modules/form/forms.js'; +import { createID } from '@cms/modules/form/utils.js'; +import { i18n } from '@cms/modules/localization.js'; + +export interface CollectionFieldOptions extends FieldOptions { + options?: { + collection?: string; + }; +} + +const escapeHtml = (value: string | number | boolean | null | undefined): string => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +const createCollectionField = (options: CollectionFieldOptions, value: string = ''): string => { + const id = createID(); + const key = `field.${options.name}`; + const title = i18n.t(key, options.title); + const collection = options.options?.collection || ''; + const disabled = collection ? '' : ' disabled'; + + return ` +
+ +
+ + + +
+ ${collection ? '' : `
${escapeHtml(i18n.t( + 'collection.field.missingCollection', + 'No collection is configured for this field.' + ))}
`} +
`; +}; + +const init = (context: FormContext): void => { + const formElement = context.formElement; + if (!formElement) return; + + formElement.querySelectorAll("[data-cms-form-field-type='collection']").forEach(wrapper => { + const input = wrapper.querySelector('.cms-collection-input-value') as HTMLInputElement | null; + const selectButton = wrapper.querySelector('.cms-collection-select') as HTMLButtonElement | null; + const clearButton = wrapper.querySelector('.cms-collection-clear') as HTMLButtonElement | null; + const collection = wrapper.dataset.cmsCollection; + if (!input || !selectButton || !clearButton || !collection) return; + + selectButton.addEventListener('click', () => { + openCollectionItemPicker({ + collection, + title: i18n.t('collection.field.dialogTitle', `Select item from ${collection}`), + onSelect: item => { + input.value = item.id; + input.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + }); + clearButton.addEventListener('click', () => { + input.value = ''; + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + }); +}; + +const getData = (context: FormContext): Record => { + const data: Record = {}; + context.formElement?.querySelectorAll( + "[data-cms-form-field-type='collection'] input[name]" + ).forEach(input => { + data[input.name] = { + type: 'collection', + value: input.value + }; + }); + return data; +}; + +export const CollectionField = { + markup: createCollectionField, + init, + data: getData +} as FormField; diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts b/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts index 914d9fe5f..0def25aef 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/forms.ts @@ -37,6 +37,7 @@ import { MediaField } from "@cms/modules/form/field.media.js"; import { ListField } from "@cms/modules/form/field.list.js"; import { TextAreaField } from "@cms/modules/form/field.textarea.js"; import { ReferenceField } from "@cms/modules/form/field.reference.js"; +import { CollectionField } from "@cms/modules/form/field.collection.js"; import { TagsField } from "@cms/modules/form/field.tags.js"; import { i18n } from "@cms/modules/localization.js"; @@ -48,13 +49,13 @@ const getFormFields = (definition: any): any[] => { return [...fields, ...tabFields]; }; -const escapeHtml = (value: any): string => String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - +const escapeHtml = (value: string | number | boolean | null | undefined): string => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); const createForm = (options : any) : Form => { const standaloneFields = Array.isArray(options.fields) ? options.fields : []; @@ -107,6 +108,8 @@ const createForm = (options : any) : Form => { return TextAreaField.markup(field, val) case 'reference': return ReferenceField.markup(field, val) + case 'collection': + return CollectionField.markup(field, val) case 'tags': return TagsField.markup(field, val) default: @@ -239,6 +242,7 @@ const createForm = (options : any) : Form => { MediaField.init(context) ListField.init(context) ReferenceField.init(context) + CollectionField.init(context) TagsField.init(context) markRequiredFields(); }; @@ -266,6 +270,7 @@ const createForm = (options : any) : Form => { ...ListField.data(context), ...TextAreaField.data(context), ...ReferenceField.data(context), + ...CollectionField.data(context), ...TagsField.data(context) }; return data diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js index 92cb6103e..8b96c4482 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js +++ b/modules/ui-module/src/main/ts/src/js/modules/manager-ui.js @@ -31,6 +31,25 @@ const updateVariantBadge = (content) => { return; } + const hasContent = Boolean(content?.uri); + const supportsVariants = hasContent + && content?.supportsVariants !== false + && content?.contentKind !== 'collection'; + badge.hidden = !hasContent; + if (!hasContent) { + badge.disabled = true; + return; + } + + if (!supportsVariants) { + label.textContent = 'Unsupported'; + badge.disabled = false; + badge.classList.remove('text-bg-warning'); + badge.classList.add('text-bg-secondary'); + badge.setAttribute('title', 'Collections do not support variants'); + return; + } + const variantId = content?.variantId; label.textContent = content ? (variantId || 'Original') : 'Loading…'; badge.disabled = !content?.uri; diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts index 86bbd17d5..381b586b8 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/manager.message.handlers.ts @@ -146,6 +146,17 @@ const initMessageHandlers = () => { executeScriptAction(cmd) }); + frameMessenger.on('edit-collection-item', (payload: any) => { + executeScriptAction({ + module: window.manager.baseUrl + '/actions/collection/edit-collection-item', + function: 'runAction', + parameters: { + collection: payload.collection, + id: payload.id + } + }); + }); + frameMessenger.on('add-sectionEntry', (payload: any) => { var cmd : any = { "module": window.manager.baseUrl + "/actions/page/add-section", diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts index a015b698c..1ce8e30b3 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/toolbar.inject.ts @@ -132,6 +132,18 @@ const editAttributes = (event: Event) => { frameMessenger.send(window.parent, command); } +const editCollectionItem = (event: Event) => { + const toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; + const definition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); + frameMessenger.send(window.parent, { + type: 'edit-collection-item', + payload: { + collection: definition.collection, + id: definition.itemId + } + }); +}; + const initDragDrop = (container: HTMLElement) => { if (container.dataset.cmsDragDropInitialized === 'true') { @@ -156,7 +168,7 @@ const initDragDrop = (container: HTMLElement) => { const createPlaceholder = (item: HTMLElement) => { const nextPlaceholder = document.createElement('div'); - nextPlaceholder.setAttribute('data-cms-drag-placeholder', ''); + nextPlaceholder.dataset.cmsDragPlaceholder = 'true'; const cs = getComputedStyle(item); nextPlaceholder.style.width = item.offsetWidth + 'px'; nextPlaceholder.style.height = item.offsetHeight + 'px'; @@ -250,7 +262,7 @@ const initDragDrop = (container: HTMLElement) => { if (itemToolbar && !itemToolbar.querySelector('[data-cms-drag-handle]')) { const handle = document.createElement('button'); handle.setAttribute('type', 'button'); - handle.setAttribute('data-cms-drag-handle', ''); + handle.dataset.cmsDragHandle = ''; handle.setAttribute('title', 'Drag to reorder'); handle.setAttribute('aria-label', 'Drag to reorder'); handle.innerHTML = MOVE_ICON; @@ -391,7 +403,7 @@ export const initToolbar = (container: HTMLElement) => { toolbarDefinition.actions.forEach((action : any) => { if (action === "editContent") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'edit'); + button.dataset.cmsAction = 'edit'; button.innerHTML = EDIT_PAGE_ICON; button.setAttribute("title", "Edit content"); button.addEventListener('click', editContent); @@ -399,15 +411,23 @@ export const initToolbar = (container: HTMLElement) => { toolbar.appendChild(button); } else if (action === "editAttributes") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'editAttributes'); + button.dataset.cmsAction = 'editAttributes'; button.innerHTML = EDIT_ATTRIBUTES_ICON; button.setAttribute("title", "Edit attributes"); button.addEventListener('click', editAttributes); + toolbar.appendChild(button); + } else if (action === "editCollectionItem") { + const button = document.createElement('button'); + button.dataset.cmsAction = 'editCollectionItem'; + button.innerHTML = EDIT_ATTRIBUTES_ICON; + button.setAttribute('title', 'Edit collection item'); + button.addEventListener('click', editCollectionItem); + toolbar.appendChild(button); } else if (action === "orderSectionEntries") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'editSections'); + button.dataset.cmsAction = 'editSections'; button.innerHTML = SECTION_SORT_ICON; button.setAttribute("title", "Order"); button.addEventListener('click', orderSections); @@ -415,7 +435,7 @@ export const initToolbar = (container: HTMLElement) => { toolbar.appendChild(button); } else if (action === "addSectionEntry") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'addSection'); + button.dataset.cmsAction = 'addSection'; button.innerHTML = SECTION_ADD_ICON; button.setAttribute("title", "Add"); button.addEventListener('click', addSection); @@ -423,7 +443,7 @@ export const initToolbar = (container: HTMLElement) => { toolbar.appendChild(button); } else if (action === "deleteSectionEntry") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'deleteSection'); + button.dataset.cmsAction = 'deleteSection'; button.innerHTML = SECTION_DELETE_ICON; button.setAttribute("title", "Delete"); button.addEventListener('click', deleteSection); @@ -440,8 +460,8 @@ export const initToolbar = (container: HTMLElement) => { if (toolbarDefinition.type === "sectionEntry") { const button = document.createElement('button'); - button.setAttribute('data-cms-action', 'publish'); - button.setAttribute('data-cms-section-uri', toolbarDefinition.uri); + button.dataset.cmsAction = 'publish'; + button.dataset.cmsSectionUri = toolbarDefinition.uri; button.classList.add('cms-unpublished'); button.innerHTML = SECTION_UNPUBLISHED_ICON; button.setAttribute("title", "Publish"); diff --git a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts index c7bc68758..59160adda 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/preview-context.ts @@ -24,6 +24,10 @@ export interface ActivePreviewContent { url?: string; canonicalUri?: string; variantId?: string | null; + contentKind?: 'content' | 'collection'; + supportsVariants?: boolean; + collection?: string; + collectionItemId?: string; } let activeContent: ActivePreviewContent | null = null; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-collection.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-collection.ts new file mode 100644 index 000000000..8fbb1bddd --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-collection.ts @@ -0,0 +1,126 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { executeRemoteCall } from '@cms/modules/rpc/rpc.js'; + +export interface CollectionItemSummary { + id: string; + collection: string; + path: string; + title: string; + detailUrl?: string | null; + meta: Record; +} + + +export interface EditableCollectionItem { + id: string; + collection: string; + path: string; + content: string; + meta: Record; +} + +export interface CollectionItemsPage { + totalItems: number; + pageSize: number; + totalPages: number; + page: number; + items: CollectionItemSummary[]; +} + +export interface CollectionItemsCursorPage { + items: CollectionItemSummary[]; + nextCursor?: string | null; +} + +export interface ListCollectionItemsOptions { + collection: string; + query?: string; + page?: number; + size?: number; +} + +export interface ListCollectionItemsCursorOptions { + collection: string; + query?: string; + cursor?: string; + size?: number; +} + +export const listCollectionItems = async ( + options: ListCollectionItemsOptions +): Promise => { + return (await executeRemoteCall({ + method: 'collections.items', + parameters: options + })).result as CollectionItemsPage; +}; + +export const listCollectionItemsCursor = async ( + options: ListCollectionItemsCursorOptions +): Promise => { + return (await executeRemoteCall({ + method: 'collections.items.cursor', + parameters: options + })).result as CollectionItemsCursorPage; +}; + +export const getCollectionItem = async ( + collection: string, + id: string +): Promise => { + return (await executeRemoteCall({ + method: 'collections.item.get', + parameters: { collection, id } + })).result as EditableCollectionItem; +}; + +export const saveCollectionItem = async (options: { + collection: string; + id: string; + content: any; + meta: Record; +}): Promise => { + await executeRemoteCall({ + method: 'collections.item.save', + parameters: options + }); +}; + +export const createCollectionItem = async (options: { + collection: string; + id: string; + content: any; + meta: Record; +}): Promise => { + return (await executeRemoteCall({ + method: 'collections.item.create', + parameters: options + })).result as CollectionItemSummary; +}; + +export const deleteCollectionItem = async (collection: string, id: string): Promise => { + await executeRemoteCall({ + method: 'collections.item.delete', + parameters: { collection, id } + }); +}; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-manager.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-manager.ts index 96d53fe7f..ae8c1ee3b 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-manager.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-manager.ts @@ -53,6 +53,12 @@ export interface ListItemType { form: FormDefinition; } +export interface CollectionType { + name: string; + label: string; + forms: Record; +} + interface ContentTypeResponse { result: T[]; } @@ -81,6 +87,13 @@ const getListItemTypes = async (options : any): Promise> => { + return await executeRemoteCall({ + method: "manager.contentTypes.collections", + parameters: {} + }); +}; + const getMediaForm = async (options : any) => { var data = { method: "manager.media.form", @@ -138,5 +151,6 @@ export { getShortCodeNames, getMediaFormats, getListItemTypes, + getCollectionTypes, createCSRFToken }; diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts index 5ce381f25..65faed8e6 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc.ts @@ -66,6 +66,12 @@ const executeRemoteMethodCall = async (method : string, parameters : any) => { ...(csrfToken && { 'X-CSRF-Token': csrfToken }), ...(activePreviewContent?.uri && { 'X-CMS-Content-Uri': activePreviewContent.uri + }), + ...(activePreviewContent?.contentKind === 'collection' && activePreviewContent.collection && { + 'X-CMS-Collection': activePreviewContent.collection + }), + ...(activePreviewContent?.contentKind === 'collection' && activePreviewContent.collectionItemId && { + 'X-CMS-Collection-Item': activePreviewContent.collectionItemId }) }, body: JSON.stringify(data) diff --git a/modules/ui-module/src/main/ts/src/js/modules/variant-support.ts b/modules/ui-module/src/main/ts/src/js/modules/variant-support.ts new file mode 100644 index 000000000..3c70961bb --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/variant-support.ts @@ -0,0 +1,45 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import { i18n } from '@cms/modules/localization.js'; +import { ActivePreviewContent } from '@cms/modules/preview-context.js'; +import { showToast } from '@cms/modules/toast.js'; + +export const variantsSupported = (content?: ActivePreviewContent | null): boolean => + Boolean(content?.uri) + && content?.supportsVariants !== false + && content?.contentKind !== 'collection'; + +export const ensureVariantsSupported = (content?: ActivePreviewContent | null): boolean => { + if (variantsSupported(content)) { + return true; + } + showToast({ + title: i18n.t('manager.actions.page.variants.unsupported.title', 'Variants not supported'), + message: i18n.t( + 'manager.actions.page.variants.unsupported.message', + 'Collections do not support variants.' + ), + type: 'info', + timeout: 3000 + }); + return false; +}; diff --git a/modules/ui-module/src/main/ts/tsconfig.json b/modules/ui-module/src/main/ts/tsconfig.json index 84a30ee75..b7910b1ea 100644 --- a/modules/ui-module/src/main/ts/tsconfig.json +++ b/modules/ui-module/src/main/ts/tsconfig.json @@ -3,10 +3,10 @@ "outDir": "dist", "rootDir": "src", "module": "esnext", - "target": "es2020", + "target": "es2021", "allowJs": true, "checkJs": false, - "lib": ["dom", "es2020"], + "lib": ["dom", "es2021"], "sourceMap": false, "declaration": true, "paths": { diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java new file mode 100644 index 000000000..e96dd5727 --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/UiTemplateModelExtensionTest.java @@ -0,0 +1,62 @@ +package com.condation.cms.modules.ui.extensionpoints; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.IsPreviewFeature; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.request.RequestContext; +import java.util.Map; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class UiTemplateModelExtensionTest { + + @Test + void doesNotRenderCollectionToolbarForReferencedCollection() { + var db = mock(DB.class); + var collections = mock(Collections.class); + when(db.getCollections()).thenReturn(collections); + when(collections.isLocal("authors")).thenReturn(false); + var siteContext = mock(SiteModuleContext.class); + when(siteContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + var requestContext = new RequestContext(); + requestContext.add(IsPreviewFeature.class, new IsPreviewFeature()); + var helper = new UiTemplateModelExtension.UIHelper(requestContext, siteContext); + var item = new CollectionItem( + "first", + "authors", + "authors/first.md", + "content", + Map.of()); + + var toolbar = helper.collectionToolbar(item, new String[]{"edit"}); + + Assertions.assertThat(toolbar).isEmpty(); + } +} diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java new file mode 100644 index 000000000..828b1d4c6 --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteCollectionEndpointsTest.java @@ -0,0 +1,269 @@ +package com.condation.cms.modules.ui.extensionpoints.remotemethods; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import com.condation.cms.api.Constants; +import com.condation.cms.api.SiteProperties; +import com.condation.cms.api.configuration.Configuration; +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.DBFileSystem; +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.eventbus.EventBus; +import com.condation.cms.api.feature.features.AuthFeature; +import com.condation.cms.api.feature.features.ConfigurationFeature; +import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.EventBusFeature; +import com.condation.cms.api.feature.features.SitePropertiesFeature; +import com.condation.cms.api.feature.features.WorkflowFeature; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; +import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.workflow.WFStatusProvider; +import com.condation.cms.api.workflow.Workflow; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; + +@ExtendWith(MockitoExtension.class) +class RemoteCollectionEndpointsTest { + + @TempDir + Path tempDirectory; + + @Mock + private SiteModuleContext moduleContext; + + @Mock + private DB db; + + @Mock + private DBFileSystem fileSystem; + + @Mock + private Collections collections; + + @Mock + private Collection collection; + + @Mock + private ContentQuery collectionQuery; + + @Mock + private ContentQuery metadataQuery; + + @Mock + private EventBus eventBus; + + @Mock + private Workflow workflow; + + @Mock + private WFStatusProvider statusProvider; + + private RemoteCollectionEndpoints endpoints; + private Path collectionsDirectory; + + @BeforeEach + void setUp() throws Exception { + collectionsDirectory = tempDirectory.resolve(Constants.Folders.COLLECTIONS); + Files.createDirectories(collectionsDirectory.resolve("blog")); + endpoints = new RemoteCollectionEndpoints(); + endpoints.setContext(moduleContext); + + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + lenient().when(db.getFileSystem()).thenReturn(fileSystem); + lenient().when(db.getCollections()).thenReturn(collections); + lenient().when(collections.names()).thenReturn(Set.of("blog")); + lenient().when(collections.isLocal("blog")).thenReturn(true); + lenient().when(collections.collection("blog")).thenReturn(collection); + lenient().when(collection.query()).thenReturn(collectionQuery); + lenient().when(collection.metadataQuery()).thenReturn(metadataQuery); + lenient().when(collectionQuery.get()).thenReturn(List.of()); + lenient().when(metadataQuery.where(anyString(), any())).thenReturn(metadataQuery); + lenient().when(metadataQuery.page(1, 2)).thenReturn(new com.condation.cms.api.db.Page<>( + 0, 2, 0, 1, List.of())); + lenient().when(fileSystem.resolve(Constants.Folders.COLLECTIONS)).thenReturn(collectionsDirectory); + } + + @Test + void createsCollectionItemWithWorkflowMetadataAndRefreshesIndex() throws Exception { + when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); + when(moduleContext.get(WorkflowFeature.class)).thenReturn(new WorkflowFeature(workflow)); + when(workflow.getStatusProvider()).thenReturn(statusProvider); + when(statusProvider.newNodeStatus()).thenReturn("draft"); + var requestContext = requestContext(); + var parameters = Map.of( + "collection", "blog", + "id", "first-item", + "content", Map.of("type", "markdown", "value", "# Body"), + "meta", Map.of( + "title", Map.of("type", "text", "value", "First item"), + "slug", Map.of("type", "text", "value", "Über das CMS"))); + + var result = ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, requestContext) + .call(() -> endpoints.create(parameters)); + + assertThat(result).isInstanceOfSatisfying( + RemoteCollectionEndpoints.ItemDto.class, + item -> { + assertThat(item) + .extracting( + RemoteCollectionEndpoints.ItemDto::id, + RemoteCollectionEndpoints.ItemDto::title) + .containsExactly("first-item", "First item"); + assertThat(item.detailUrl()).isEqualTo("/articles/ueber-das-cms"); + }); + assertThat(collectionsDirectory.resolve("blog/first-item.md")) + .content() + .contains( + "title: First item", + "slug: ueber-das-cms", + "status: draft", + "createdBy: editor", + "# Body"); + verify(collections).refresh("blog", "first-item"); + } + + @Test + void rejectsDuplicateSlugsAfterNormalizationWhenSaving() throws Exception { + var editedItem = new CollectionItem( + "second", + "blog", + "blog/second.md", + "", + Map.of("slug", "second")); + var existingItem = new CollectionItemMetadata( + "first", + "blog", + "blog/first.md", + Map.of("slug", "ueber-uns")); + var collectionsBase = mock(ReadOnlyFile.class); + var sourceFile = mock(ReadOnlyFile.class); + when(collection.item("second")).thenReturn(Optional.of(editedItem)); + when(fileSystem.collectionsBase()).thenReturn(collectionsBase); + when(collectionsBase.resolve("blog/second.md")).thenReturn(sourceFile); + when(sourceFile.getContent()).thenReturn("---\nslug: second\n---\n\nBody\n"); + when(metadataQuery.where("slug", "ueber-uns")).thenReturn(metadataQuery); + when(metadataQuery.page(1, 2)).thenReturn(new com.condation.cms.api.db.Page<>( + 1, 2, 1, 1, List.of(existingItem))); + + assertThatThrownBy(() -> endpoints.save(Map.of( + "collection", "blog", + "id", "second", + "meta", Map.of("slug", Map.of("type", "text", "value", "Ueber uns"))))) + .isInstanceOfSatisfying( + RPCException.class, + exception -> assertThat(exception.getCode()).isEqualTo(409)); + } + + @Test + void rejectsDuplicateAndInvalidCollectionItemIds() throws Exception { + Files.writeString(collectionsDirectory.resolve("blog/existing.md"), "existing"); + + assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "existing"))) + .isInstanceOfSatisfying( + RPCException.class, + exception -> assertThat(exception.getCode()).isEqualTo(409)); + assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "../unsafe"))) + .isInstanceOfSatisfying( + RPCException.class, + exception -> assertThat(exception.getCode()).isEqualTo(400)); + } + + @Test + void rejectsInvalidItemIdsConsistentlyWhenLoadingItems() { + assertThatThrownBy(() -> endpoints.get(Map.of("collection", "blog", "id", "invalid item"))) + .isInstanceOfSatisfying( + RPCException.class, + exception -> { + assertThat(exception.getCode()).isEqualTo(400); + assertThat(exception.getMessage()) + .isEqualTo("invalid collection item id: invalid item"); + }); + } + + @Test + void deletesCollectionItemAndRefreshesIndex() throws Exception { + when(moduleContext.get(EventBusFeature.class)).thenReturn(new EventBusFeature(eventBus)); + var item = collectionsDirectory.resolve("blog/obsolete.md"); + Files.writeString(item, "obsolete"); + + endpoints.delete(Map.of("collection", "blog", "id", "obsolete")); + + assertThat(item).doesNotExist(); + verify(collections).refresh("blog", "obsolete"); + } + + @Test + void rejectsWritesToReferencedCollections() { + when(collections.isLocal("blog")).thenReturn(false); + + assertThatThrownBy(() -> endpoints.create(Map.of("collection", "blog", "id", "new-item"))) + .isInstanceOfSatisfying( + RPCException.class, + exception -> assertThat(exception.getCode()).isEqualTo(403)); + } + + private RequestContext requestContext() { + var configuration = new Configuration(); + var definitions = new ConcurrentHashMap(); + definitions.put("blog", new CollectionDefinition( + "blog", + new CollectionDetailConfiguration("/articles/{slug}", "article.html"))); + configuration.add(CollectionConfiguration.class, new CollectionConfiguration(definitions)); + var siteProperties = mock(SiteProperties.class); + when(siteProperties.contextPath()).thenReturn("/"); + var requestContext = new RequestContext(); + requestContext.add(AuthFeature.class, new AuthFeature("editor")); + requestContext.add(ConfigurationFeature.class, new ConfigurationFeature(configuration)); + requestContext.add(SitePropertiesFeature.class, new SitePropertiesFeature(siteProperties)); + return requestContext; + } +} diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java index 6c187be76..fab6a3c6d 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteContentEndpointsExtensionTest.java @@ -25,7 +25,19 @@ import com.condation.cms.api.db.DB; import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.CollectionItemMetadata; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.configuration.Configuration; +import com.condation.cms.api.configuration.configs.CollectionConfiguration; +import com.condation.cms.api.configuration.configs.CollectionDefinition; +import com.condation.cms.api.configuration.configs.CollectionDetailConfiguration; +import com.condation.cms.api.db.ContentQuery; +import com.condation.cms.api.db.Page; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.feature.features.ConfigurationFeature; import com.condation.cms.api.feature.features.DBFeature; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.RequestFeature; import com.condation.cms.api.module.SiteModuleContext; @@ -37,6 +49,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -47,6 +60,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; @ExtendWith(MockitoExtension.class) class RemoteContentEndpointsExtensionTest { @@ -69,6 +84,9 @@ class RemoteContentEndpointsExtensionTest { @Mock private ReadOnlyFile contentFile; + @Mock + private Collections collections; + private RemoteContentEndpointsExtension endpoints; @BeforeEach @@ -76,13 +94,16 @@ void setUp() { endpoints = new RemoteContentEndpointsExtension(); endpoints.setContext(moduleContext); when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getFileSystem()).thenReturn(fileSystem); - when(fileSystem.contentBase()).thenReturn(contentBase); + lenient().when(db.getFileSystem()).thenReturn(fileSystem); + lenient().when(fileSystem.contentBase()).thenReturn(contentBase); + lenient().when(db.getCollections()).thenReturn(collections); + lenient().when(collections.isLocal("blog")).thenReturn(true); } @Test void getContent_throwsRPCException_whenParsingFails() throws IOException { when(contentBase.resolve("broken.md")).thenReturn(contentFile); + when(contentFile.exists()).thenReturn(true); when(contentFile.getContent()).thenThrow(new IOException("disk error")); Map params = Map.of("uri", "broken.md"); @@ -95,6 +116,7 @@ void getContent_throwsRPCException_whenParsingFails() throws IOException { @Test void setContent_throwsRPCException_whenParsingFails() throws IOException { when(contentBase.resolve("broken.md")).thenReturn(contentFile); + when(contentFile.exists()).thenReturn(true); when(contentFile.getContent()).thenThrow(new IOException("disk error")); Map params = Map.of("uri", "broken.md", "content", "hello"); @@ -113,6 +135,7 @@ void getContentUsesCurrentNodeWhenUriIsOmitted() throws Exception { new CurrentNodeFeature(new ContentNode(uri, "/about", "about.md", Map.of())) ); when(contentBase.resolve(uri)).thenReturn(contentFile); + when(contentFile.exists()).thenReturn(true); when(contentFile.getContent()).thenThrow(new IOException("variant selected")); assertThatThrownBy(() -> ScopedValue.where( @@ -123,6 +146,22 @@ void getContentUsesCurrentNodeWhenUriIsOmitted() throws Exception { .hasMessage("variant selected"); } + @Test + void rejectsEditingAReferencedCollectionItem() { + when(collections.isLocal("blog")).thenReturn(false); + var requestContext = new RequestContext(); + requestContext.add( + CurrentCollectionItemFeature.class, + new CurrentCollectionItemFeature(new CollectionItem( + "first", "blog", "blog/first.md", "Body", Map.of()))); + + assertThatThrownBy(() -> ScopedValue.where( + RequestContextScope.REQUEST_CONTEXT, + requestContext).call(() -> endpoints.getContent(Map.of()))) + .isInstanceOfSatisfying(RPCException.class, exception -> + assertThat(exception.getCode()).isEqualTo(403)); + } + @Test void getContentNodeResolvesUriFromCustomPageUrl() throws Exception { var node = new ContentNode( @@ -152,7 +191,57 @@ void getContentNodeResolvesUriFromCustomPageUrl() throws Exception { assertThat(result) .containsEntry("uri", "pages/other.md") - .containsEntry("canonicalUri", "pages/other.md"); + .containsEntry("canonicalUri", "pages/other.md") + .containsEntry("contentKind", "content") + .containsEntry("supportsVariants", true); verify(content).byUrl("/total-other-page"); } + + @Test + void getContentNodeKeepsPublicCollectionRouteAndDisablesVariants() throws Exception { + var nonExistingPath = mock(ReadOnlyFile.class); + var authorCollection = mock(Collection.class); + @SuppressWarnings("unchecked") + var query = (ContentQuery) mock(ContentQuery.class); + var item = new CollectionItem( + "author-1", "authors", "authors/author-1.md", "", Map.of("slug", "jane-doe")); + var metadata = new CollectionItemMetadata( + item.id(), item.collection(), item.path(), item.meta()); + + when(db.getContent()).thenReturn(content); + when(content.byUrl("/people/jane-doe")).thenReturn(Optional.empty()); + when(contentBase.resolve("people/jane-doe")).thenReturn(nonExistingPath); + when(contentBase.resolve("people/jane-doe.md")).thenReturn(nonExistingPath); + when(collections.collection("authors")).thenReturn(authorCollection); + when(authorCollection.metadataQuery()).thenReturn(query); + when(query.where("slug", "jane-doe")).thenReturn(query); + when(query.page(1, 2)).thenReturn(new Page<>(1, 2, 1, 1, List.of(metadata))); + when(authorCollection.item("author-1")).thenReturn(Optional.of(item)); + + var configuration = new Configuration(); + configuration.add(CollectionConfiguration.class, new CollectionConfiguration( + new ConcurrentHashMap<>(Map.of( + "authors", + new CollectionDefinition( + "authors", + new CollectionDetailConfiguration("/people/{slug}", "author.html")))))); + when(moduleContext.get(ConfigurationFeature.class)) + .thenReturn(new ConfigurationFeature(configuration)); + + var requestContext = new RequestContext(); + requestContext.add(RequestFeature.class, new RequestFeature("/", "/people/jane-doe", Map.of(), null)); + + @SuppressWarnings("unchecked") + var result = (Map) ScopedValue.where( + RequestContextScope.REQUEST_CONTEXT, + requestContext + ).call(() -> endpoints.getContentNode(Map.of( + "url", "https://example.test/people/jane-doe?preview=manager"))); + + assertThat(result) + .containsEntry("url", "https://example.test/people/jane-doe?preview=manager") + .containsEntry("uri", "authors/author-1.md") + .containsEntry("contentKind", "collection") + .containsEntry("supportsVariants", false); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java index cf9d16f4a..fc8c370e1 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteWorkflowEndpointsExtensionTest.java @@ -21,6 +21,7 @@ * #L% */ +import com.condation.cms.api.Constants; import com.condation.cms.api.db.DB; import com.condation.cms.api.db.DBFileSystem; import com.condation.cms.api.db.Content; @@ -29,14 +30,21 @@ import com.condation.cms.api.db.Page; import com.condation.cms.api.db.VariantSearchMode; import com.condation.cms.api.db.cms.ReadOnlyFile; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.WorkflowFeature; import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.api.ui.rpc.RPCException; +import com.condation.cms.api.workflow.WFStatusProvider; import com.condation.cms.api.workflow.WFStatusQueryProvider; import com.condation.cms.api.workflow.Workflow; import java.util.List; import java.util.Map; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -70,6 +78,21 @@ class RemoteWorkflowEndpointsExtensionTest { @Mock private ReadOnlyFile contentFile; + @Mock + private ReadOnlyFile collectionsBase; + + @Mock + private ReadOnlyFile collectionFile; + + @Mock + private Path collectionsWritableBase; + + @Mock + private Path collectionWritableFile; + + @Mock + private Collections collections; + private RemoteWorkflowEndpointsExtension endpoints; @BeforeEach @@ -77,8 +100,14 @@ void setUp() { endpoints = new RemoteWorkflowEndpointsExtension(); endpoints.setContext(moduleContext); when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); - when(db.getFileSystem()).thenReturn(fileSystem); - when(fileSystem.contentBase()).thenReturn(contentBase); + lenient().when(db.getFileSystem()).thenReturn(fileSystem); + lenient().when(db.getCollections()).thenReturn(collections); + lenient().when(collections.isLocal("blog")).thenReturn(true); + lenient().when(fileSystem.contentBase()).thenReturn(contentBase); + lenient().when(fileSystem.collectionsBase()).thenReturn(collectionsBase); + lenient().when(collectionsBase.resolve("blog/first.md")).thenReturn(collectionFile); + lenient().when(fileSystem.resolve(Constants.Folders.COLLECTIONS)).thenReturn(collectionsWritableBase); + lenient().when(collectionsWritableBase.resolve("blog/first.md")).thenReturn(collectionWritableFile); lenient().when(contentBase.resolve("missing.md")).thenReturn(contentFile); lenient().when(contentFile.exists()).thenReturn(false); } @@ -90,8 +119,8 @@ void nodeStatus_returnsResultWithoutStatus_whenContentNodeNotFound() throws RPCE @SuppressWarnings("unchecked") Map result = (Map) endpoints.nodeStatus(params); - assertThat(result).doesNotContainKey("status"); - assertThat(result).doesNotContainKey("error"); + assertThat(result).doesNotContainKey("status") + .doesNotContainKey("error"); } @Test @@ -101,8 +130,8 @@ void getTransitions_returnsEmptyTransitions_whenContentNodeNotFound() throws RPC @SuppressWarnings("unchecked") Map result = (Map) endpoints.getTransitions(params); - assertThat(result).containsEntry("transitions", java.util.List.of()); - assertThat(result).doesNotContainKey("error"); + assertThat(result).containsEntry("transitions", java.util.List.of()) + .doesNotContainKey("error"); } @Test @@ -114,6 +143,51 @@ void transit_throwsRPCException_whenContentNodeNotFound() { .satisfies(ex -> assertThat(((RPCException) ex).getCode()).isEqualTo(404)); } + @Test + void nodeStatus_usesCurrentCollectionItemOnDetailPage() throws Exception { + var item = new CollectionItem( + "first", + "blog", + "blog/first.md", + "Body", + Map.of("title", "First", "status", "draft")); + var requestContext = new RequestContext(); + requestContext.add( + CurrentCollectionItemFeature.class, + new CurrentCollectionItemFeature(item)); + var workflow = mock(Workflow.class); + var statusProvider = mock(WFStatusProvider.class); + var status = new WFStatusProvider.Status(false, null, null, true, "draft"); + when(workflow.getStatusProvider()).thenReturn(statusProvider); + when(statusProvider.status(any(ContentNode.class))).thenReturn(status); + when(moduleContext.get(WorkflowFeature.class)).thenReturn(new WorkflowFeature(workflow)); + + @SuppressWarnings("unchecked") + var result = (Map) ScopedValue.where( + RequestContextScope.REQUEST_CONTEXT, + requestContext).call(() -> endpoints.nodeStatus(Map.of())); + + assertThat(result).containsEntry("status", status) + .containsEntry("transitions", List.of()); + } + + @Test + void nodeStatus_ignoresReferencedCollectionItems() throws Exception { + when(collections.isLocal("blog")).thenReturn(false); + var requestContext = new RequestContext(); + requestContext.add( + CurrentCollectionItemFeature.class, + new CurrentCollectionItemFeature(new CollectionItem( + "first", "blog", "blog/first.md", "Body", Map.of()))); + + @SuppressWarnings("unchecked") + var result = (Map) ScopedValue.where( + RequestContextScope.REQUEST_CONTEXT, + requestContext).call(() -> endpoints.nodeStatus(Map.of())); + + assertThat(result).isEmpty(); + } + @Test void unpublishedPages_delegatesFilteringAndPaginationToWorkflowProvider() throws RPCException { Content content = mock(Content.class); diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/JSActionHandlerTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/JSActionHandlerTest.java new file mode 100644 index 000000000..b19083e95 --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/JSActionHandlerTest.java @@ -0,0 +1,44 @@ +package com.condation.cms.modules.ui.http; + +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ + +import java.nio.file.FileSystems; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class JSActionHandlerTest { + + @Test + void loadsNestedCollectionActionFromBundledResources() { + var handler = new JSActionHandler( + FileSystems.getDefault(), + "/manager/actions", + mock(com.condation.cms.api.module.SiteModuleContext.class)); + + assertThat(handler.getBundledScript("collection/manage-collection")) + .hasValueSatisfying(script -> assertThat(script).contains("export const runAction")); + assertThat(handler.getBundledScript("collection/edit-collection-item.js")) + .hasValueSatisfying(script -> assertThat(script).contains("openCollectionItemEditor")); + } +} diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java index 8d5455083..d048ef67c 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/http/RemoteCallHandlerTest.java @@ -25,6 +25,10 @@ import com.condation.cms.api.db.Content; import com.condation.cms.api.db.ContentNode; import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.collection.Collection; +import com.condation.cms.api.db.collection.CollectionItem; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.feature.features.CurrentCollectionItemFeature; import com.condation.cms.api.feature.features.CurrentNodeFeature; import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.request.RequestContext; @@ -104,6 +108,7 @@ void contentUriHeaderAddsCurrentNodeFeature() throws Exception { when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); when(db.getContent()).thenReturn(content); when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn(null); when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn(node.uri()); when(content.byUri(node.uri())).thenReturn(Optional.of(node)); @@ -126,6 +131,7 @@ void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); when(db.getContent()).thenReturn(content); when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn(null); when(headers.get(RemoteCallHandler.CONTENT_URI_HEADER)).thenReturn("unknown.md"); when(content.byUri("unknown.md")).thenReturn(Optional.empty()); when(content.byPath("unknown.md")).thenReturn(Optional.empty()); @@ -137,4 +143,36 @@ void unknownContentUriHeaderDoesNotAddCurrentNodeFeature() throws Exception { assertThat(context.has(CurrentNodeFeature.class)).isFalse(); } + + @Test + void collectionHeadersAddCurrentCollectionItemAndNodeFeatures() throws Exception { + var context = new RequestContext(); + var db = mock(DB.class); + var collections = mock(Collections.class); + var collection = mock(Collection.class); + var request = mock(Request.class); + var headers = mock(HttpFields.class); + var item = new CollectionItem( + "first", + "blog", + "blog/first.md", + "Body", + Map.of("title", "First")); + when(moduleContext.has(DBFeature.class)).thenReturn(true); + when(moduleContext.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getCollections()).thenReturn(collections); + when(collections.collection("blog")).thenReturn(collection); + when(collection.item("first")).thenReturn(Optional.of(item)); + when(request.getHeaders()).thenReturn(headers); + when(headers.get(RemoteCallHandler.COLLECTION_HEADER)).thenReturn("blog"); + when(headers.get(RemoteCallHandler.COLLECTION_ITEM_HEADER)).thenReturn("first"); + + var handler = new RemoteCallHandler(remoteMethodService, moduleContext, context); + var method = RemoteCallHandler.class.getDeclaredMethod("setCurrentContentNode", Request.class); + method.setAccessible(true); + method.invoke(handler, request); + + assertThat(context.get(CurrentCollectionItemFeature.class).item()).isEqualTo(item); + assertThat(context.get(CurrentNodeFeature.class).node().uri()).isEqualTo(item.path()); + } } diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java index b8c0dab36..9ac0b01d4 100644 --- a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/ActionFactoryAppsTest.java @@ -23,31 +23,41 @@ import com.condation.cms.api.SiteProperties; import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.db.DB; +import com.condation.cms.api.db.collection.Collections; +import com.condation.cms.api.feature.features.DBFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; +import com.condation.cms.api.hooks.HookSystem; import com.condation.cms.api.module.SiteModuleContext; import com.condation.cms.api.ui.action.UIScriptAction; import com.condation.cms.api.ui.apps.App; import com.condation.cms.api.ui.apps.AppExtensionPoint; +import com.condation.cms.api.ui.elements.CollectionType; +import com.condation.cms.api.ui.elements.ContentTypes; import com.condation.cms.auth.services.User; import com.condation.modules.api.ModuleManager; import java.util.List; import java.util.Map; +import java.util.Set; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.any; class ActionFactoryAppsTest { @Test void createsAuthorizedAppsWithContextAwareIconAndScriptAction() { - SiteProperties siteProperties = Mockito.mock(SiteProperties.class); - Mockito.when(siteProperties.contextPath()).thenReturn("/de"); - SiteModuleContext context = Mockito.mock(SiteModuleContext.class); - Mockito.when(context.get(SitePropertiesFeature.class)) + SiteProperties siteProperties = mock(SiteProperties.class); + when(siteProperties.contextPath()).thenReturn("/de"); + SiteModuleContext context = mock(SiteModuleContext.class); + when(context.get(SitePropertiesFeature.class)) .thenReturn(new SitePropertiesFeature(siteProperties)); - AppExtensionPoint extension = Mockito.mock(AppExtensionPoint.class); - Mockito.when(extension.getApps()).thenReturn(List.of( + AppExtensionPoint extension = mock(AppExtensionPoint.class); + when(extension.getApps()).thenReturn(List.of( new App( "menu-manager", "Menu Manager", @@ -60,8 +70,8 @@ void createsAuthorizedAppsWithContextAwareIconAndScriptAction() { "/manager/public/apps/admin.svg", new UIScriptAction("/manager/actions/admin", Map.of()), List.of(Permissions.CACHE_INVALIDATE)))); - ModuleManager moduleManager = Mockito.mock(ModuleManager.class); - Mockito.when(moduleManager.extensions(AppExtensionPoint.class)) + ModuleManager moduleManager = mock(ModuleManager.class); + when(moduleManager.extensions(AppExtensionPoint.class)) .thenReturn(List.of(extension)); ActionFactory factory = new ActionFactory( @@ -80,4 +90,48 @@ void createsAuthorizedAppsWithContextAwareIconAndScriptAction() { .isEqualTo("/de/manager/actions/menu/manage-menus")); }); } + + @Test + void addsCollectionsToCreateContentMenu() { + SiteProperties siteProperties = mock(SiteProperties.class); + when(siteProperties.contextPath()).thenReturn("/de"); + SiteModuleContext context = mock(SiteModuleContext.class); + when(context.get(SitePropertiesFeature.class)) + .thenReturn(new SitePropertiesFeature(siteProperties)); + DB db = mock(DB.class); + Collections collections = mock(Collections.class); + when(context.get(DBFeature.class)).thenReturn(new DBFeature(db)); + when(db.getCollections()).thenReturn(collections); + when(collections.names()).thenReturn(Set.of("blog", "shared")); + when(collections.isLocal("blog")).thenReturn(true); + + HookSystem hookSystem = mock(HookSystem.class); + when(hookSystem.doFilter(eq(UIHooks.HOOK_REGISTER_CONTENT_TYPES), any(ContentTypes.class))) + .thenAnswer(invocation -> { + ContentTypes contentTypes = invocation.getArgument(1); + contentTypes.registerCollection(new CollectionType("blog", "Blog posts", Map.of())); + return contentTypes; + }); + ActionFactory factory = new ActionFactory( + context, + siteProperties, + hookSystem, + mock(ModuleManager.class), + new User("editor", "hash", new String[]{"editor"})); + + Assertions.assertThat(factory.createContentTypeMenu().getMenuEntry("collection-blog")) + .isPresent() + .get() + .satisfies(entry -> { + Assertions.assertThat(entry.getName()).isEqualTo("Blog posts"); + Assertions.assertThat(entry.getAction()).isInstanceOfSatisfying( + UIScriptAction.class, + action -> { + Assertions.assertThat(action.getModule()) + .isEqualTo("/de/manager/actions/collection/create-collection-item"); + Assertions.assertThat(action.getParameters()).containsEntry("collection", "blog"); + }); + }); + Assertions.assertThat(factory.createContentTypeMenu().getMenuEntry("collection-shared")).isEmpty(); + } } diff --git a/test-server/hosts/demo/collections/authors/thorsten.md b/test-server/hosts/demo/collections/authors/thorsten.md new file mode 100644 index 000000000..e24de93a7 --- /dev/null +++ b/test-server/hosts/demo/collections/authors/thorsten.md @@ -0,0 +1,7 @@ +--- +title: Thorsten +status: published +description: CondationCMS main author +publish_date: 2026-04-07T00:00:00Z +slug: thorsten +--- \ No newline at end of file diff --git a/test-server/hosts/demo/collections/blog/item_1.md b/test-server/hosts/demo/collections/blog/item_1.md new file mode 100644 index 000000000..b54538bdd --- /dev/null +++ b/test-server/hosts/demo/collections/blog/item_1.md @@ -0,0 +1,8 @@ +--- +description: This is the first item +title: Blog item 1 +publish_date: 2026-04-07T00:00:00Z +status: published +--- + + diff --git a/test-server/hosts/demo/collections/blog/item_2.md b/test-server/hosts/demo/collections/blog/item_2.md new file mode 100644 index 000000000..82ebb2baf --- /dev/null +++ b/test-server/hosts/demo/collections/blog/item_2.md @@ -0,0 +1,6 @@ +--- +title: Blog item 2 +status: published +description: This is the second item +publish_date: 2026-04-08T00:00:00Z +--- \ No newline at end of file diff --git a/test-server/hosts/demo/collections/blog/item_3.md b/test-server/hosts/demo/collections/blog/item_3.md new file mode 100644 index 000000000..5b028790e --- /dev/null +++ b/test-server/hosts/demo/collections/blog/item_3.md @@ -0,0 +1,8 @@ +--- +description: This is the third item of 3 +title: Blog item 3 +publish_date: 2026-04-09T00:00:00Z +status: draft +--- + + diff --git a/test-server/hosts/demo/config/collections.yaml b/test-server/hosts/demo/config/collections.yaml new file mode 100644 index 000000000..cba1c8e8b --- /dev/null +++ b/test-server/hosts/demo/config/collections.yaml @@ -0,0 +1,9 @@ +collections: + blog: + detail: + route: /blog/{publish_date:yyyy}/{publish_date:MM}/{id} + template: collections/blog-detail.html + authors: + detail: + route: /collections/authors/{slug} + template: collections/author-detail.html diff --git a/test-server/hosts/demo/content/collections/index.md b/test-server/hosts/demo/content/collections/index.md new file mode 100644 index 000000000..391189bc0 --- /dev/null +++ b/test-server/hosts/demo/content/collections/index.md @@ -0,0 +1,6 @@ +--- +title: Collections Test +status: published +template: collections.html +--- + diff --git a/test-server/hosts/demo/content/index.md b/test-server/hosts/demo/content/index.md index 7c70d3360..e36bb7312 100644 --- a/test-server/hosts/demo/content/index.md +++ b/test-server/hosts/demo/content/index.md @@ -41,6 +41,7 @@ taxonomy: - kinderkleidung - Small Test - New tag +author: thorsten --- # Demo Project diff --git a/test-server/hosts/demo_de/config/collections.yaml b/test-server/hosts/demo_de/config/collections.yaml new file mode 100644 index 000000000..4340718b6 --- /dev/null +++ b/test-server/hosts/demo_de/config/collections.yaml @@ -0,0 +1,6 @@ +collections: + authors: + site: demo-site + detail: + route: /collections/authors/{slug} + template: collections/author-detail.html diff --git a/test-server/hosts/demo_de/config/menus/main.yaml b/test-server/hosts/demo_de/config/menus/main.yaml new file mode 100644 index 000000000..1543632f1 --- /dev/null +++ b/test-server/hosts/demo_de/config/menus/main.yaml @@ -0,0 +1,35 @@ +id: main +name: Mainmenu +items: +- id: a772de6e-66f1-4b59-9345-82f44d4f36d2 + type: link + label: Startseite + url: / + target: _self + enabled: true + children: [ + ] +- id: 07bc9ae6-d520-442e-93cb-9c53d12af13a + type: link + label: das ist eine neue seite + url: /total-other-page + target: _self + enabled: true + children: [ + ] +- id: fa5c603d-e470-434c-86b7-6f387821b9c7 + type: link + label: Content example + url: /content + target: _self + enabled: true + children: [ + ] +- id: af033b5a-33b5-4345-bc81-df78732cb7ce + type: link + label: Locations + url: /locations + target: _self + enabled: true + children: [ + ] diff --git a/test-server/hosts/demo_de/content/collections/index.md b/test-server/hosts/demo_de/content/collections/index.md new file mode 100644 index 000000000..9bf568545 --- /dev/null +++ b/test-server/hosts/demo_de/content/collections/index.md @@ -0,0 +1,6 @@ +--- +title: Collections remote Test +status: published +template: collections-authors.html +--- + diff --git a/test-server/modules/example-module/libs/example-module-8.2.0.jar b/test-server/modules/example-module/libs/example-module-8.2.0.jar deleted file mode 100644 index a66b86c22..000000000 Binary files a/test-server/modules/example-module/libs/example-module-8.2.0.jar and /dev/null differ diff --git a/test-server/modules/example-module/libs/example-module-8.3.0.jar b/test-server/modules/example-module/libs/example-module-8.3.0.jar new file mode 100644 index 000000000..496b908d6 Binary files /dev/null and b/test-server/modules/example-module/libs/example-module-8.3.0.jar differ diff --git a/test-server/modules/example-module/module.properties b/test-server/modules/example-module/module.properties index 32924449b..b989ecf46 100644 --- a/test-server/modules/example-module/module.properties +++ b/test-server/modules/example-module/module.properties @@ -1,4 +1,4 @@ id=example-module name=example module -version=8.1.0 +version=8.3.0 priority=HIGH \ No newline at end of file diff --git a/test-server/themes/demo/extensions/theme.manager.js b/test-server/themes/demo/extensions/theme.manager.js index a1661d47f..e923102d9 100644 --- a/test-server/themes/demo/extensions/theme.manager.js +++ b/test-server/themes/demo/extensions/theme.manager.js @@ -40,6 +40,50 @@ const UnPublishDateField = { $hooks.registerFilter("manager/contentTypes/register", (contentTypes) => { + contentTypes.registerCollection({ + name: "blog", + label: "Blog", + forms: { + edit: { + fields: [ + TitleField, + DescriptionField, + { + type: "markdown", + name: "content", + title: "Content", + height: "60vh" + } + ] + } + } + }); + + contentTypes.registerCollection({ + name: "authors", + label: "Authors", + forms: { + edit: { + fields: [ + TitleField, + DescriptionField, + { + type: "text", + name: "slug", + title: "Slug", + required: true + }, + { + type: "markdown", + name: "content", + title: "Content", + height: "60vh" + } + ] + } + } + }); + contentTypes.registerPageTemplate({ name: "StartPage", template: "start.html", @@ -54,6 +98,14 @@ $hooks.registerFilter("manager/contentTypes/register", (contentTypes) => { name: "linked_page", title: "Verlinkte Seite" }, + { + type: "collection", + name: "author", + title: "Autor", + options: { + collection: "authors" + } + }, { type: "media", name: "media_url", diff --git a/test-server/themes/demo/templates/collections-authors.html b/test-server/themes/demo/templates/collections-authors.html new file mode 100644 index 000000000..5aa3d5414 --- /dev/null +++ b/test-server/themes/demo/templates/collections-authors.html @@ -0,0 +1,34 @@ + + + + + + + + + {% include "libs/fragments.html" %} + + + + + + +
+
+ {{ node.content | raw }} +
+ +

Remote author collection

+ {% assign items = cms.collection("authors").query().get() %} + + {% for item in items %} + + {% endfor %} +
+ + + + + diff --git a/test-server/themes/demo/templates/collections.html b/test-server/themes/demo/templates/collections.html new file mode 100644 index 000000000..5b8ac667e --- /dev/null +++ b/test-server/themes/demo/templates/collections.html @@ -0,0 +1,43 @@ + + + + + + + + + {% include "libs/fragments.html" %} + + + + + + +
+
+ {{ node.content | raw }} +
+ +

Blog collection

+ {% assign items = cms.collection("blog").query().get() %} + + {% for item in items %} + + {% endfor %} + +

Author collection

+ {% assign items = cms.collection("authors").query().get() %} + + {% for item in items %} + + {% endfor %} +
+ + + + + diff --git a/test-server/themes/demo/templates/collections/author-detail.html b/test-server/themes/demo/templates/collections/author-detail.html new file mode 100644 index 000000000..3a02af2c2 --- /dev/null +++ b/test-server/themes/demo/templates/collections/author-detail.html @@ -0,0 +1,26 @@ + + + + + + + + + {% include "libs/fragments.html" %} + + + + + + +
+

{{ node.meta.title }}

+
+ {{ node.content | raw }} +
+
+ + + + + diff --git a/test-server/themes/demo/templates/collections/blog-detail.html b/test-server/themes/demo/templates/collections/blog-detail.html new file mode 100644 index 000000000..4f373b502 --- /dev/null +++ b/test-server/themes/demo/templates/collections/blog-detail.html @@ -0,0 +1,20 @@ + + + + + + + {% include "libs/fragments.html" %} + + + +
+

{{ node.meta.title }}

+

{{ node.meta.description }}

+
+ {{ node.content | raw }} +
+
+ + +