diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/Block.java b/cms-content/src/main/java/com/condation/cms/content/markdown/Block.java index c961f289..436b6cfc 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/Block.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/Block.java @@ -10,25 +10,32 @@ * 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 t.marx */ public interface Block { int start(); int end(); - - String render (InlineRenderer inlineRenderer); + + /** + * Renders this block. {@code documentOffset} is the absolute position of + * this block's start in the original document — passed through to the + * {@link InlineRenderer} so inline elements can compute absolute positions. + */ + String render(InlineRenderer inlineRenderer, int documentOffset); + + default String render(InlineRenderer inlineRenderer) { + return render(inlineRenderer, 0); + } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/BlockTokenizer.java b/cms-content/src/main/java/com/condation/cms/content/markdown/BlockTokenizer.java index 798c9081..d8e25f46 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/BlockTokenizer.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/BlockTokenizer.java @@ -10,12 +10,12 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * #L% @@ -28,7 +28,8 @@ /** * Block-level markdown tokenizer with recursion depth limit. - * Optimized to prevent stack overflow on pathological inputs. + * Returns {@link LocatedBlock} instances whose {@code absoluteStart}/ + * {@code absoluteEnd} are correct offsets into the original document string. * * @author t.marx */ @@ -38,23 +39,25 @@ public class BlockTokenizer { private final Options options; private static final int MAX_RECURSION_DEPTH = 100; - protected List tokenize(final String original_md) throws IOException { - return tokenizeWithDepth(original_md, 0); + protected List tokenize(final String original_md) throws IOException { + return tokenizeWithDepth(original_md, 0, 0); } /** - * Tokenizes markdown with recursion depth tracking. - * Throws exception if depth exceeds limit to prevent stack overflow. + * @param original_md the markdown substring to tokenize + * @param documentOffset cumulative character offset of this substring in the full document + * @param depth current recursion depth */ - private List tokenizeWithDepth(final String original_md, int depth) throws IOException { + private List tokenizeWithDepth(final String original_md, int documentOffset, int depth) throws IOException { if (depth > MAX_RECURSION_DEPTH) { throw new IOException("Maximum recursion depth exceeded in markdown parsing"); } var md = original_md.replaceAll("\r\n", "\n"); StringBuilder mdBuilder = new StringBuilder(md); + int offset = documentOffset; - final List blocks = new ArrayList<>(); + final List blocks = new ArrayList<>(); for (var blockRule : options.blockElementRules) { Block block = null; @@ -62,10 +65,12 @@ private List tokenizeWithDepth(final String original_md, int depth) throw if (block.start() != 0) { var before = mdBuilder.substring(0, block.start()); - blocks.addAll(tokenizeWithDepth(before, depth + 1)); + blocks.addAll(tokenizeWithDepth(before, offset, depth + 1)); + offset += block.start(); } - blocks.add(block); + blocks.add(new LocatedBlock(block, offset, offset + (block.end() - block.start()))); + offset += block.end() - block.start(); mdBuilder.delete(0, block.end()); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/CMSMarkdown.java b/cms-content/src/main/java/com/condation/cms/content/markdown/CMSMarkdown.java index 3ce15c62..6e94734d 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/CMSMarkdown.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/CMSMarkdown.java @@ -10,12 +10,12 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * #L% @@ -23,10 +23,8 @@ import com.condation.cms.api.request.RequestContextScope; import com.condation.cms.content.markdown.rules.block.ParagraphBlockRule; import com.condation.cms.content.markdown.rules.inline.TextInlineRule; -import com.condation.cms.content.markdown.utils.StringUtils; import java.io.IOException; import java.util.List; -import java.util.function.Function; import java.util.function.Supplier; /** @@ -38,7 +36,6 @@ public class CMSMarkdown { private final BlockTokenizer blockTokenizer; - private final InlineElementTokenizer inlineTokenizer; private final List blockRules; @@ -47,22 +44,10 @@ public class CMSMarkdown { private final boolean parallelRendering; private final int parallelThreshold; - /** - * Creates a markdown renderer with default settings (parallel rendering - * enabled for 10+ blocks). - */ public CMSMarkdown(Options options) { this(options, true, 10); } - /** - * Creates a markdown renderer with custom parallel rendering configuration. - * - * @param options markdown rendering options - * @param parallelRendering enable parallel block rendering - * @param parallelThreshold minimum number of blocks to trigger parallel - * rendering - */ public CMSMarkdown(Options options, boolean parallelRendering, int parallelThreshold) { this.blockTokenizer = new BlockTokenizer(options); this.inlineTokenizer = new InlineElementTokenizer(options); @@ -74,34 +59,33 @@ public CMSMarkdown(Options options, boolean parallelRendering, int parallelThres inlineRules.addLast(new TextInlineRule()); } - private String renderInlineElements(final String inline_md) throws IOException { - List blocks = inlineTokenizer.tokenize(inline_md); + private String renderInlineElements(final String inline_md, int documentOffset) throws IOException { + List blocks = inlineTokenizer.tokenize(inline_md, documentOffset); - // Pre-size StringBuilder based on input length to reduce allocations final StringBuilder htmlBuilder = new StringBuilder(inline_md.length() + 128); - - // Use simple loop instead of streams for better performance - for (InlineBlock block : blocks) { - htmlBuilder.append(block.render()); + for (LocatedInlineBlock located : blocks) { + htmlBuilder.append(located.block().render(located.absoluteStart(), located.absoluteEnd())); } - return htmlBuilder.toString(); } + private String renderBlock(LocatedBlock located, InlineRenderer inlineRenderer, BlockRenderer blockRenderer) { + Block block = located.block(); + if (block instanceof BlockContainer blockContainer) { + return blockContainer.render(blockRenderer); + } + return block.render(inlineRenderer, located.absoluteStart()); + } + public String render(final String md) throws IOException { - // Escape input markdown - String escapedMd = StringUtils.escape(md); - List blocks = blockTokenizer.tokenize(escapedMd); + List blocks = blockTokenizer.tokenize(md); - // Pre-size StringBuilder based on input to reduce allocations final StringBuilder htmlBuilder = new StringBuilder(md.length() + 256); - // Create renderers once instead of as lambdas - InlineRenderer inlineRenderer = (content) -> { + InlineRenderer inlineRenderer = (content, documentOffset) -> { try { - return renderInlineElements(content); + return renderInlineElements(content, documentOffset); } catch (IOException ioe) { - // Log error but don't break rendering return ""; } }; @@ -109,31 +93,19 @@ public String render(final String md) throws IOException { try { return this.render(content); } catch (IOException e) { - // Log error but don't break rendering return ""; } }; - // Use parallel rendering for large documents (10+ blocks) - // For small documents, sequential is faster due to parallel overhead if (parallelRendering && blocks.size() >= parallelThreshold) { - // Capture ScopedValue on the calling thread BEFORE entering the parallel stream. - // ForkJoinPool worker threads do not inherit ScopedValue bindings, so we must - // capture the context here and explicitly re-bind it inside each worker lambda. final var capturedContext = RequestContextScope.REQUEST_CONTEXT.isBound() ? RequestContextScope.REQUEST_CONTEXT.get() : null; - // Parallel rendering: 2-4x faster on multi-core CPUs List renderedBlocks = blocks.parallelStream() - .map(block -> { - final Supplier renderBlockSupplier = () -> { - if (block instanceof BlockContainer blockContainer) { - return blockContainer.render(blockRenderer); - } else { - return block.render(inlineRenderer); - } - }; + .map(located -> { + final Supplier renderBlockSupplier = () -> + renderBlock(located, inlineRenderer, blockRenderer); try { if (capturedContext != null) { return ScopedValue.where(RequestContextScope.REQUEST_CONTEXT, capturedContext) @@ -147,23 +119,15 @@ public String render(final String md) throws IOException { }) .toList(); - // Append in order (toList() preserves order) for (String rendered : renderedBlocks) { htmlBuilder.append(rendered); } } else { - // Sequential rendering for small documents - for (Block block : blocks) { - String rendered; - if (block instanceof BlockContainer blockContainer) { - rendered = blockContainer.render(blockRenderer); - } else { - rendered = block.render(inlineRenderer); - } - htmlBuilder.append(rendered); + for (LocatedBlock located : blocks) { + htmlBuilder.append(renderBlock(located, inlineRenderer, blockRenderer)); } } - return StringUtils.unescape(htmlBuilder.toString()); + return htmlBuilder.toString(); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/InlineBlock.java b/cms-content/src/main/java/com/condation/cms/content/markdown/InlineBlock.java index 2468e7b6..b93b9a47 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/InlineBlock.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/InlineBlock.java @@ -37,6 +37,14 @@ public interface InlineBlock { String render(); + /** + * Renders with absolute document positions. Override for elements that need + * to embed position metadata (e.g. images). Defaults to {@link #render()}. + */ + default String render(int absoluteStart, int absoluteEnd) { + return render(); + } + default boolean isPreview() { if (!RequestContextScope.REQUEST_CONTEXT.isBound()) { return false; @@ -45,6 +53,17 @@ default boolean isPreview() { return requestContext != null && requestContext.has(IsPreviewFeature.class); } + default boolean isManagerPreview() { + if (!RequestContextScope.REQUEST_CONTEXT.isBound()) { + return false; + } + var requestContext = RequestContextScope.REQUEST_CONTEXT.get(); + if (requestContext == null || !requestContext.has(IsPreviewFeature.class)) { + return false; + } + return IsPreviewFeature.Mode.MANAGER.equals(requestContext.get(IsPreviewFeature.class).mode()); + } + default Optional getRequestContext () { if (!RequestContextScope.REQUEST_CONTEXT.isBound()) { return Optional.empty(); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/InlineElementTokenizer.java b/cms-content/src/main/java/com/condation/cms/content/markdown/InlineElementTokenizer.java index bfe40b1f..1c1fa5ca 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/InlineElementTokenizer.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/InlineElementTokenizer.java @@ -10,12 +10,12 @@ * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * #L% @@ -29,7 +29,8 @@ /** * Inline-level markdown tokenizer with recursion depth limit. - * Optimized to prevent stack overflow on pathological inputs. + * Returns {@link LocatedInlineBlock} instances whose absolute positions are + * correct offsets into the original document string. * * @author t.marx */ @@ -39,23 +40,39 @@ public class InlineElementTokenizer { private final Options options; private static final int MAX_RECURSION_DEPTH = 100; - public List tokenize(final String original_md) throws IOException { - return doTokenize(this, original_md, 0); + /** + * Tokenizes inline markdown without document-offset tracking (legacy entry point). + * Absolute positions will equal relative positions (documentOffset = 0). + */ + public List tokenize(final String original_md) throws IOException { + return tokenize(original_md, 0); } /** - * Tokenizes inline elements with recursion depth tracking. - * Throws exception if depth exceeds limit to prevent stack overflow. + * Tokenizes inline markdown with a known document offset. + * + * @param original_md inline markdown content (block body) + * @param documentOffset absolute start of this content in the full document */ - protected List doTokenize(final InlineElementTokenizer tokenizer, final String original_md, int depth) throws IOException { + public List tokenize(final String original_md, int documentOffset) throws IOException { + return doTokenize(this, original_md, documentOffset, 0); + } + + protected List doTokenize( + final InlineElementTokenizer tokenizer, + final String original_md, + int documentOffset, + int depth) throws IOException { + if (depth > MAX_RECURSION_DEPTH) { throw new IOException("Maximum recursion depth exceeded in inline parsing"); } var md = original_md.replaceAll("\r\n", "\n"); StringBuilder mdBuilder = new StringBuilder(md); + int offset = documentOffset; - final List blocks = new ArrayList<>(); + final List blocks = new ArrayList<>(); for (var blockRule : options.inlineElementRules) { InlineBlock block = null; @@ -63,16 +80,21 @@ protected List doTokenize(final InlineElementTokenizer tokenizer, f if (block.start() != 0) { var before = mdBuilder.substring(0, block.start()); - blocks.addAll(doTokenize(tokenizer, before, depth + 1)); + blocks.addAll(doTokenize(tokenizer, before, offset, depth + 1)); + offset += block.start(); } - blocks.add(block); + blocks.add(new LocatedInlineBlock(block, offset, offset + (block.end() - block.start()))); + offset += block.end() - block.start(); mdBuilder.delete(0, block.end()); } } if (mdBuilder.length() > 0) { - blocks.add(new TextInlineRule.TextBlock(0, mdBuilder.length(), mdBuilder.toString())); + blocks.add(new LocatedInlineBlock( + new TextInlineRule.TextBlock(0, mdBuilder.length(), mdBuilder.toString()), + offset, + offset + mdBuilder.length())); } return blocks; diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/InlineRenderer.java b/cms-content/src/main/java/com/condation/cms/content/markdown/InlineRenderer.java index 7ea39ef9..fc8b0cd7 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/InlineRenderer.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/InlineRenderer.java @@ -10,24 +10,29 @@ * 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% */ - /** + * Renders inline markdown content. The {@code documentOffset} is the absolute + * character position of {@code inline_md} in the full document, used to + * compute correct absolute positions for inline elements like images. * * @author t.marx */ public interface InlineRenderer { - - String render (String inline_md); - + + String render(String inline_md, int documentOffset); + + default String render(String inline_md) { + return render(inline_md, 0); + } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/LocatedBlock.java b/cms-content/src/main/java/com/condation/cms/content/markdown/LocatedBlock.java new file mode 100644 index 00000000..22485c9b --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/LocatedBlock.java @@ -0,0 +1,32 @@ +package com.condation.cms.content.markdown; + +/*- + * #%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% + */ + +/** + * Wraps a {@link Block} with its absolute start/end positions in the original + * markdown document. The block's own {@code start()}/{@code end()} are relative + * to the substring the rule received; {@code absoluteStart}/{@code absoluteEnd} + * are correct offsets into the full document string. + * + * @author t.marx + */ +public record LocatedBlock(Block block, int absoluteStart, int absoluteEnd) {} diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/LocatedInlineBlock.java b/cms-content/src/main/java/com/condation/cms/content/markdown/LocatedInlineBlock.java new file mode 100644 index 00000000..0c88061d --- /dev/null +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/LocatedInlineBlock.java @@ -0,0 +1,31 @@ +package com.condation.cms.content.markdown; + +/*- + * #%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% + */ + +/** + * Wraps an {@link InlineBlock} with its absolute start/end positions in the + * original markdown document, combining the enclosing block's document offset + * with the inline element's position within the block content. + * + * @author t.marx + */ +public record LocatedInlineBlock(InlineBlock block, int absoluteStart, int absoluteEnd) {} diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/BlockquoteBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/BlockquoteBlockRule.java index 9bc0f623..9ec52f09 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/BlockquoteBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/BlockquoteBlockRule.java @@ -56,8 +56,8 @@ public static record BlockquoteBlock(int start, int end, String content) impleme @Override - public String render(InlineRenderer inlineRenderer) { - return "
%s
".formatted(inlineRenderer.render(content)); + public String render(InlineRenderer inlineRenderer, int documentOffset) { + return "
%s
".formatted(inlineRenderer.render(content, documentOffset)); } @Override diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/CodeBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/CodeBlockRule.java index 3725dbe9..d09cf24a 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/CodeBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/CodeBlockRule.java @@ -25,6 +25,7 @@ import com.condation.cms.content.markdown.Block; import com.condation.cms.content.markdown.BlockElementRule; import com.condation.cms.content.markdown.InlineRenderer; +import com.condation.cms.content.markdown.utils.StringUtils; import com.google.common.html.HtmlEscapers; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -52,15 +53,15 @@ public Block next(final String md) { public static record CodeBlock (int start, int end, String content, String language) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { if (language == null || "".equals(language)) { return "
%s
".formatted(escape(content)); } return "
%s
".formatted(language, escape(content)); } - private String escape (String html) { - return HtmlEscapers.htmlEscaper().escape(html); + private String escape(String html) { + return StringUtils.escapeToEntities(HtmlEscapers.htmlEscaper().escape(html)); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRule.java index 7f5c414f..3872e847 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRule.java @@ -92,15 +92,15 @@ static record DefinitionList(String title, List values) { public static record DefinitionListBlock(int start, int end, DefinitionListContainer listContainer) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { StringBuilder sb = new StringBuilder(); sb.append("
"); listContainer.lists().forEach(list -> { - sb.append("
").append(inlineRenderer.render(list.title())).append("
"); - + sb.append("
").append(inlineRenderer.render(list.title(), documentOffset)).append("
"); + list.values.forEach(item -> { - sb.append("
").append(inlineRenderer.render(item)).append("
"); + sb.append("
").append(inlineRenderer.render(item, documentOffset)).append("
"); }); }); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HeadingBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HeadingBlockRule.java index 05e24186..efaf7912 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HeadingBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HeadingBlockRule.java @@ -54,11 +54,11 @@ public Block next(String md) { public static record HeadingBlock(int start, int end, String heading, int level, String id) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { return "%s".formatted( level, id, - inlineRenderer.render(heading), + inlineRenderer.render(heading, documentOffset), level ); } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRule.java index 867f81b0..e47a901b 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRule.java @@ -49,7 +49,7 @@ public Block next(String md) { public static record HRBlock(int start, int end) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { return "
"; } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ListBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ListBlockRule.java index ee691306..ee042e6e 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ListBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ListBlockRule.java @@ -74,13 +74,13 @@ public Block next(String md) { public static record ListBlock(int start, int end, List items, boolean ordered) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { if (ordered) { return "
    %s
".formatted( - items.stream().map(item -> "
  • " + inlineRenderer.render(item) + "
  • ").collect(Collectors.joining())); + items.stream().map(item -> "
  • " + inlineRenderer.render(item, documentOffset) + "
  • ").collect(Collectors.joining())); } else { return "
      %s
    ".formatted( - items.stream().map(item -> "
  • " + inlineRenderer.render(item) + "
  • ").collect(Collectors.joining())); + items.stream().map(item -> "
  • " + inlineRenderer.render(item, documentOffset) + "
  • ").collect(Collectors.joining())); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ParagraphBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ParagraphBlockRule.java index 5b09e4d1..28ea2af9 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ParagraphBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/ParagraphBlockRule.java @@ -51,8 +51,8 @@ public Block next(String md) { public static record ParagraphBlock(int start, int end, String content) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { - return "

    %s

    ".formatted(inlineRenderer.render(content)); + public String render(InlineRenderer inlineRenderer, int documentOffset) { + return "

    %s

    ".formatted(inlineRenderer.render(content, documentOffset)); } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TableBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TableBlockRule.java index eada9201..142036ea 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TableBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TableBlockRule.java @@ -182,7 +182,7 @@ private String renderStyle (int index) { } @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { StringBuilder sb = new StringBuilder(); sb.append(""); @@ -192,7 +192,7 @@ public String render(InlineRenderer inlineRenderer) { AtomicInteger index = new AtomicInteger(0); table.header.values.forEach((header) -> { sb.append(""); }); @@ -206,7 +206,7 @@ public String render(InlineRenderer inlineRenderer) { sb.append(""); row.values.forEach(items -> { sb.append(""); }); sb.append(""); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TagBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TagBlockRule.java index e2a9805c..1822efce 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TagBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TagBlockRule.java @@ -60,7 +60,7 @@ public boolean has(String codeName) { public static record TagBlock(int start, int end, TagParser.TagInfo tagInfo) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { List params = tagInfo.rawAttributes() .entrySet().stream() .filter(entry -> !entry.getKey().equals("_content")) @@ -72,7 +72,7 @@ public String render(InlineRenderer inlineRenderer) { .formatted( tagInfo.name(), String.join(" ", params), - inlineRenderer.render((String)tagInfo.rawAttributes().getOrDefault("_content", "")), + inlineRenderer.render((String)tagInfo.rawAttributes().getOrDefault("_content", ""), documentOffset), tagInfo.name() ); } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRule.java index 59e685f8..cafa2873 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRule.java @@ -88,7 +88,7 @@ static record Item(String title, boolean checked) { public static record TaskListBlock(int start, int end, TaskList taskList) implements Block { @Override - public String render(InlineRenderer inlineRenderer) { + public String render(InlineRenderer inlineRenderer, int documentOffset) { StringBuilder sb = new StringBuilder(); sb.append("
      "); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageInlineRule.java index 9bbf2f4d..4a690aa7 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageInlineRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ImageInlineRule.java @@ -53,26 +53,35 @@ public static record ImageInlineBlock(int start, int end, String src, String alt @Override public String render() { + return render(start, end); + } + + @Override + public String render(int absoluteStart, int absoluteEnd) { var altText = alt; var requestContext = getRequestContext(); if (Strings.isNullOrEmpty(altText) && requestContext.isPresent()) { var imageUrl = ImageUtil.getRawPath(src, requestContext.get()); var media = requestContext.get().get(SiteMediaServiceFeature.class).mediaService().get(imageUrl); - + if (media != null && media.meta().containsKey("alt")) { altText = (String) media.meta().get("alt"); } } - + var uiSelector = ""; - if (isPreview()) { - uiSelector = " data-cms-ui-selector=\"content-image\" "; + if (isManagerPreview()) { + uiSelector = new StringBuilder() + .append(" data-cms-ui-selector=\"content-image\" ") + .append(" data-cms-md-start=\"").append(absoluteStart).append("\" ") + .append(" data-cms-md-end=\"").append(absoluteEnd).append("\" ") + .toString(); } - + if (title != null && !"".equals(title.trim())) { return "\"%s\"".formatted(src, altText, title, uiSelector); } - return "\"%s\"".formatted(src, altText, uiSelector); + return "\"%s\"".formatted(src, altText, uiSelector); } } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ItalicInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ItalicInlineRule.java index 50aa9f8f..e366f495 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ItalicInlineRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/ItalicInlineRule.java @@ -51,7 +51,7 @@ public static record ItalicInlineBlock(InlineElementTokenizer tokenizer, int sta @Override public String render() { try { - var renderedContent = tokenizer.tokenize(content).stream().map(b -> b.render()).collect(Collectors.joining()); + var renderedContent = tokenizer.tokenize(content).stream().map(b -> b.block().render()).collect(Collectors.joining()); return "%s".formatted(renderedContent); } catch (IOException ex) { return "%s".formatted(content); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrikethroughInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrikethroughInlineRule.java index fef9a892..d1d20b66 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrikethroughInlineRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrikethroughInlineRule.java @@ -50,7 +50,7 @@ public static record StrikethroughInlineBlock(InlineElementTokenizer tokenizer, @Override public String render() { try { - var renderedContent = tokenizer.tokenize(content).stream().map(b -> b.render()).collect(Collectors.joining()); + var renderedContent = tokenizer.tokenize(content).stream().map(b -> b.block().render()).collect(Collectors.joining()); return "%s".formatted(renderedContent); } catch (IOException ex) { return "%s".formatted(content); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrongInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrongInlineRule.java index c1407a00..1fc63083 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrongInlineRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/StrongInlineRule.java @@ -51,7 +51,7 @@ public static record StrongInlineBlock(InlineElementTokenizer tokenizer, int sta @Override public String render() { try { - var renderedContent = tokenizer.tokenize(content).stream().map(b -> b.render()).collect(Collectors.joining()); + var renderedContent = tokenizer.tokenize(content).stream().map(b -> b.block().render()).collect(Collectors.joining()); return "%s".formatted(renderedContent); } catch (IOException ex) { return "%s".formatted(content); diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/TextInlineRule.java b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/TextInlineRule.java index 23cc7e26..16b4e84a 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/TextInlineRule.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/rules/inline/TextInlineRule.java @@ -25,6 +25,7 @@ import com.condation.cms.content.markdown.InlineBlock; import com.condation.cms.content.markdown.InlineElementRule; import com.condation.cms.content.markdown.InlineElementTokenizer; +import com.condation.cms.content.markdown.utils.StringUtils; import com.google.common.base.Strings; /** @@ -44,7 +45,7 @@ public InlineBlock next(InlineElementTokenizer tokenizer, String md) { public static record TextBlock(int start, int end, String content) implements InlineBlock { @Override public String render() { - return content; + return StringUtils.escapeToEntities(content); } } } diff --git a/cms-content/src/main/java/com/condation/cms/content/markdown/utils/StringUtils.java b/cms-content/src/main/java/com/condation/cms/content/markdown/utils/StringUtils.java index 9569b72e..73d62d8c 100644 --- a/cms-content/src/main/java/com/condation/cms/content/markdown/utils/StringUtils.java +++ b/cms-content/src/main/java/com/condation/cms/content/markdown/utils/StringUtils.java @@ -36,9 +36,13 @@ public class StringUtils { private static final Map ESCAPE = new HashMap<>(); + // Direct escape-sequence → HTML entity mapping (no placeholder needed) + private static final Map ESCAPE_TO_ENTITY = new HashMap<>(); + private static final String AMP_PLACEHOLDER = "AMP#PLACE#HOLDER"; private static final Pattern ESCAPE_PATTERN; + private static final Pattern ESCAPE_TO_ENTITY_PATTERN; private static final Pattern UNESCAPE_PATTERN; static { @@ -60,12 +64,31 @@ public class StringUtils { ESCAPE.put("\\!", AMP_PLACEHOLDER + "#33;"); ESCAPE.put("\\|", AMP_PLACEHOLDER + "#124;"); + ESCAPE_TO_ENTITY.put("\\#", "#"); + ESCAPE_TO_ENTITY.put("\\*", "*"); + ESCAPE_TO_ENTITY.put("\\`", "`"); + ESCAPE_TO_ENTITY.put("\\_", "_"); + ESCAPE_TO_ENTITY.put("\\{", "{"); + ESCAPE_TO_ENTITY.put("\\}", "}"); + ESCAPE_TO_ENTITY.put("\\[", "["); + ESCAPE_TO_ENTITY.put("\\]", "]"); + ESCAPE_TO_ENTITY.put("\\<", "<"); + ESCAPE_TO_ENTITY.put("\\>", ">"); + ESCAPE_TO_ENTITY.put("\\(", "("); + ESCAPE_TO_ENTITY.put("\\)", ")"); + ESCAPE_TO_ENTITY.put("\\+", "+"); + ESCAPE_TO_ENTITY.put("\\-", "-"); + ESCAPE_TO_ENTITY.put("\\.", "."); + ESCAPE_TO_ENTITY.put("\\!", "!"); + ESCAPE_TO_ENTITY.put("\\|", "|"); + // Build regex pattern: (\#|\*|\`|\_|...) - captures all escape sequences String regexPattern = ESCAPE.keySet().stream() .map(Pattern::quote) .reduce((a, b) -> a + "|" + b) .orElse(""); ESCAPE_PATTERN = Pattern.compile(regexPattern); + ESCAPE_TO_ENTITY_PATTERN = ESCAPE_PATTERN; // same pattern, different replacement map // Pattern for unescaping UNESCAPE_PATTERN = Pattern.compile(Pattern.quote(AMP_PLACEHOLDER)); @@ -106,6 +129,27 @@ public static String escape(String md) { return result.toString(); } + /** + * Converts markdown escape sequences (e.g. {@code \*}) directly to HTML entities + * (e.g. {@code *}). Used by TextBlock at render time so that positions in the + * original markdown string are not shifted by pre-processing. + */ + public static String escapeToEntities(String text) { + if (Strings.isNullOrEmpty(text)) { + return text; + } + Matcher matcher = ESCAPE_TO_ENTITY_PATTERN.matcher(text); + StringBuffer result = new StringBuffer(text.length() + 32); + while (matcher.find()) { + String replacement = ESCAPE_TO_ENTITY.get(matcher.group()); + if (replacement != null) { + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + } + matcher.appendTail(result); + return result.toString(); + } + public static String removeLeadingPipe(String s) { return s.replaceAll("^\\|+", ""); } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/BlockTokenizerTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/BlockTokenizerTest.java index a97434ef..228d4309 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/BlockTokenizerTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/BlockTokenizerTest.java @@ -10,30 +10,26 @@ * 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.content.markdown.Options; -import com.condation.cms.content.markdown.BlockTokenizer; -import com.condation.cms.content.markdown.Block; import com.condation.cms.content.markdown.rules.block.CodeBlockRule; import com.condation.cms.content.markdown.rules.block.ParagraphBlockRule; import java.io.IOException; import java.util.List; import static org.assertj.core.api.Assertions.*; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; /** - * * @author t.marx */ public class BlockTokenizerTest extends MarkdownTest { @@ -51,53 +47,53 @@ public static void setup() { @Test void test_single_line() throws IOException { String content = load("block_single_line.md"); - List blocks = sut.tokenize(content); + List blocks = sut.tokenize(content); assertThat(blocks).hasSize(1); - assertThat(blocks.get(0)).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); - var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(0); + assertThat(blocks.get(0).block()).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); + var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(0).block(); assertThat(pb.content()).isEqualToIgnoringNewLines("Hallo"); } @Test void test_two_lines() throws IOException { String content = load("block_two_lines.md"); - List blocks = sut.tokenize(content); + List blocks = sut.tokenize(content); assertThat(blocks).hasSize(1); - assertThat(blocks.get(0)).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); - var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(0); + assertThat(blocks.get(0).block()).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); + var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(0).block(); assertThat(pb.content()).isEqualToIgnoringNewLines("Hallo\nLeute"); } @Test void test_two_blocks() throws IOException { String content = load("block_two_blocks.md"); - List blocks = sut.tokenize(content); + List blocks = sut.tokenize(content); assertThat(blocks).hasSize(2); - assertThat(blocks.get(0)).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); - assertThat(blocks.get(1)).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); - var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(0); + assertThat(blocks.get(0).block()).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); + assertThat(blocks.get(1).block()).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); + var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(0).block(); assertThat(pb.content()).isEqualToIgnoringNewLines("Hallo"); - pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(1); + pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(1).block(); assertThat(pb.content()).isEqualToIgnoringNewLines("Leute"); } - + @Test void test_code_paragraph() throws IOException { String content = load("block_code_paragraph.md"); - List blocks = sut.tokenize(content); + List blocks = sut.tokenize(content); assertThat(blocks).hasSize(4); - assertThat(blocks.get(0)).isInstanceOf(CodeBlockRule.CodeBlock.class); - assertThat(blocks.get(1)).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); - assertThat(blocks.get(2)).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); - assertThat(blocks.get(3)).isInstanceOf(CodeBlockRule.CodeBlock.class); - var cb = (CodeBlockRule.CodeBlock) blocks.get(0); + assertThat(blocks.get(0).block()).isInstanceOf(CodeBlockRule.CodeBlock.class); + assertThat(blocks.get(1).block()).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); + assertThat(blocks.get(2).block()).isInstanceOf(ParagraphBlockRule.ParagraphBlock.class); + assertThat(blocks.get(3).block()).isInstanceOf(CodeBlockRule.CodeBlock.class); + var cb = (CodeBlockRule.CodeBlock) blocks.get(0).block(); assertThat(cb.content()).isEqualToIgnoringNewLines("java.lang.System.out.println(\"Hello world!\");"); assertThat(cb.language()).isEqualToIgnoringNewLines("java"); - var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(2); + var pb = (ParagraphBlockRule.ParagraphBlock) blocks.get(2).block(); assertThat(pb.content()).isEqualToIgnoringNewLines("Hallo"); } } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/LargeBlockTokenizerTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/LargeBlockTokenizerTest.java index c4148761..5d7053f7 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/LargeBlockTokenizerTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/LargeBlockTokenizerTest.java @@ -47,7 +47,7 @@ public static void setup() { @Test void test_large_file() throws IOException { String content = load("large_block.md"); - List blocks = sut.tokenize(content); + List blocks = sut.tokenize(content); assertThat(blocks).isNotEmpty(); } } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRuleTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRuleTest.java index 92f924c4..58f7119c 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRuleTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/DefinitionListBlockRuleTest.java @@ -65,7 +65,7 @@ public void basic_test() { )) )); - var rendered = next.render((md) -> md); + var rendered = next.render((md, offset) -> md); Assertions.assertThat(rendered).isEqualToIgnoringWhitespace(expected); } @@ -109,7 +109,7 @@ public void mulitple_test() { )) )); - var rendered = next.render((md) -> md); + var rendered = next.render((md, offset) -> md); Assertions.assertThat(rendered).isEqualToIgnoringWhitespace(expected); } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRuleTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRuleTest.java index 41ed0a74..c2781c1e 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRuleTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/HorizontalRuleBlockRuleTest.java @@ -50,7 +50,7 @@ void test_horizontal_rule(String input) { .isNotNull() .isInstanceOf(HorizontalRuleBlockRule.HRBlock.class); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      "); } @Test @@ -69,7 +69,7 @@ void test_horizontal_rule_with_before() { .isNotNull() .isInstanceOf(HorizontalRuleBlockRule.HRBlock.class); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      "); } @Test @@ -91,9 +91,9 @@ void test_horizontal_rule() { .isNotNull() .isInstanceOf(HorizontalRuleBlockRule.HRBlock.class); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      "); - Assertions.assertThat(next.render(value -> value)).isEqualToIgnoringWhitespace(expected); + Assertions.assertThat(next.render((value, offset) -> value)).isEqualToIgnoringWhitespace(expected); } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/ListBlockRuleTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/ListBlockRuleTest.java index d9627388..0f19954f 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/ListBlockRuleTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/ListBlockRuleTest.java @@ -49,7 +49,7 @@ void test_ordered_list() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("Hallo", "Leute")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      1. Hallo
      2. Leute
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      1. Hallo
      2. Leute
      "); } @Test @@ -65,7 +65,7 @@ void test_unordered_list_star() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("Hallo", "Leute")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      • Hallo
      • Leute
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      • Hallo
      • Leute
      "); } @Test @@ -81,7 +81,7 @@ void test_unordered_list_minus() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("Hallo", "Leute")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      • Hallo
      • Leute
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      • Hallo
      • Leute
      "); } @Test @@ -97,7 +97,7 @@ void test_unordered_list_plus() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("Hallo", "Leute")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      • Hallo
      • Leute
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      • Hallo
      • Leute
      "); } @Test @@ -113,7 +113,7 @@ void test_unordered_list_issue() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("ul item 1", "ul item 2")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      • ul item 1
      • ul item 2
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      • ul item 1
      • ul item 2
      "); } @Test @@ -129,7 +129,7 @@ void test_dot_issue_183() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("first sentence. second sentence.", "item 2")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      1. first sentence. second sentence.
      2. item 2
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      1. first sentence. second sentence.
      2. item 2
      "); } @Test @@ -145,7 +145,7 @@ void ordered_list_multiline_items() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("first sentence.\nsecond sentence.", "item 2")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      1. first sentence.\nsecond sentence.
      2. item 2
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      1. first sentence.\nsecond sentence.
      2. item 2
      "); } @Test @@ -161,6 +161,6 @@ void unordered_list_multiline_items() { .asInstanceOf(InstanceOfAssertFactories.type(ListBlockRule.ListBlock.class)) .hasFieldOrPropertyWithValue("items", List.of("first sentence.\nsecond sentence.", "item 2")); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("
      • first sentence.\nsecond sentence.
      • item 2
      "); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("
      • first sentence.\nsecond sentence.
      • item 2
      "); } } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TableBlockRuleTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TableBlockRuleTest.java index 9b927502..1e369e50 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TableBlockRuleTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TableBlockRuleTest.java @@ -85,7 +85,7 @@ public void basic_test() { new TableBlockRule.Row(List.of("r2 / c1", "r2 / c2")) )); - var rendered = next.render((md) -> md); + var rendered = next.render((md, offset) -> md); Assertions.assertThat(rendered).isEqualToIgnoringWhitespace(expected); } @@ -144,7 +144,7 @@ public void align_test() { new TableBlockRule.Row(List.of("r2 / c1", "r2 / c2", "r2 / c3")) )); - var rendered = next.render((md) -> md); + var rendered = next.render((md, offset) -> md); Assertions.assertThat(rendered).isEqualToIgnoringWhitespace(expected); } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TagBlockRuleTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TagBlockRuleTest.java index 198d6406..a4e17c44 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TagBlockRuleTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TagBlockRuleTest.java @@ -54,7 +54,7 @@ void long_form() { "_content", "Google" )); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("[[link url=\"https://google.de/\"]]Google[[/link]]"); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("[[link url=\"https://google.de/\"]]Google[[/link]]"); } @Test @@ -75,7 +75,7 @@ void short_form() { "url", "https://google.de/" )); - Assertions.assertThat(next.render((content) -> content)).isEqualTo("[[link url=\"https://google.de/\"]][[/link]]"); + Assertions.assertThat(next.render((content, offset) -> content)).isEqualTo("[[link url=\"https://google.de/\"]][[/link]]"); } @Test @@ -98,7 +98,7 @@ void test_issue () { "title", "Everybody loves little cats" )); - Assertions.assertThat(next.render((content) -> content)) + Assertions.assertThat(next.render((content, offset) -> content)) .isEqualTo("[[video id=\"y0sF5xhGreA\" title=\"Everybody loves little cats\" type=\"youtube\"]][[/video]]"); } diff --git a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRuleTest.java b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRuleTest.java index 504319dc..4a2d02bf 100644 --- a/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRuleTest.java +++ b/cms-content/src/test/java/com/condation/cms/content/markdown/rules/block/TaskListBlockRuleTest.java @@ -64,7 +64,7 @@ public void basic_test() { )) ); - var rendered = next.render((md) -> md); + var rendered = next.render((md, offset) -> md); Assertions.assertThat(rendered).isEqualToIgnoringWhitespace(expected); } @@ -102,7 +102,7 @@ public void mulitple_test() { )) ); - var rendered = next.render((md) -> md); + var rendered = next.render((md, offset) -> md); Assertions.assertThat(rendered).isEqualToIgnoringWhitespace(expected); } diff --git a/modules/ui-module/pom.xml b/modules/ui-module/pom.xml index 409e93b0..e44c016e 100644 --- a/modules/ui-module/pom.xml +++ b/modules/ui-module/pom.xml @@ -28,6 +28,14 @@ cms-hooksystem + com.condation.cms + cms-media + + + com.condation.cms + cms-content + + io.jsonwebtoken jjwt-api 0.13.0 diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UIJettyHttpHandlerExtension.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UIJettyHttpHandlerExtension.java index 820b1574..18c1114f 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UIJettyHttpHandlerExtension.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/UIJettyHttpHandlerExtension.java @@ -43,6 +43,7 @@ import com.condation.cms.modules.ui.http.auth.CSRFHandler; import com.condation.cms.modules.ui.http.auth.LoginResourceHandler; import com.condation.cms.modules.ui.http.auth.LogoutHandler; +import com.condation.cms.modules.ui.http.auth.RefreshTokenHandler; import com.condation.cms.modules.ui.http.auth.UIAuthHandler; import com.condation.cms.modules.ui.http.auth.UIAuthRedirectHandler; import com.condation.cms.modules.ui.services.RemoteMethodService; @@ -135,6 +136,7 @@ public Mapping getMapping() { try { + mapping.add(PathSpec.from("/manager/refresh"), new RefreshTokenHandler(getContext(), getRequestContext())); mapping.add(PathSpec.from("/manager/login"), new LoginResourceHandler(getContext(), getRequestContext())); //mapping.add(PathSpec.from("/manager/login.action"), new LoginHandler(getContext(), getRequestContext(), failedLoginsCounter)); mapping.add(PathSpec.from("/manager/login.action"), new AjaxLoginHandler(getContext(), getRequestContext(), failedLoginsCounter, logins)); 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 2074105b..2b25dfd6 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 @@ -21,6 +21,7 @@ * #L% */ import com.condation.cms.api.Constants; +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.NodeStatus; @@ -31,6 +32,7 @@ import com.condation.cms.api.feature.features.DBFeature; 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.ui.extensions.UIRemoteMethodExtensionPoint; import com.condation.cms.api.utils.PathUtil; import com.condation.cms.core.content.io.ContentFileParser; @@ -44,10 +46,13 @@ import java.util.Map; import lombok.extern.slf4j.Slf4j; import com.condation.cms.api.ui.annotations.RemoteMethod; +import com.condation.cms.api.ui.rpc.RPCException; import com.condation.cms.api.utils.SectionUtil; import com.condation.cms.content.SectionEntry; import com.condation.cms.modules.ui.utils.FormHelper; +import com.condation.cms.modules.ui.utils.MarkdownHelper; import com.condation.cms.modules.ui.utils.MetaConverter; +import com.condation.cms.modules.ui.utils.NumberUtils; import com.condation.cms.modules.ui.utils.UIFileNameUtil; import com.condation.cms.modules.ui.utils.UIPathUtil; import java.nio.file.Files; @@ -115,6 +120,47 @@ public Object setContent(Map parameters) { return result; } + + @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.getReadOnlyFileSystem().resolve(Constants.Folders.CONTENT); + + var replacement = (String)parameters.get("content"); + int start = NumberUtils.toInt(parameters.getOrDefault("start", -1l)); + int end = NumberUtils.toInt(parameters.getOrDefault("end", -1l)); + var uri = (String) parameters.get("uri"); + + Map result = new HashMap<>(); + result.put("uri", uri); + + if (replacement == null) { + throw new RPCException("replacement must not be null"); + } + + var contentFile = contentBase.resolve(uri); + + if (contentFile != null) { + try { + ContentFileParser parser = new ContentFileParser(contentFile); + + var content = parser.getContent(); + + var contextPath = getContext().get(SitePropertiesFeature.class).siteProperties().contextPath(); + + var updatedContent = MarkdownHelper.replaceImage(contextPath, content, start, end, replacement); + + var filePath = db.getFileSystem().resolve(Constants.Folders.CONTENT).resolve(uri); + + YamlHeaderUpdater.saveMarkdownFileWithHeader(filePath, parser.getHeader(), updatedContent); + log.debug("file {} saved", uri); + } catch (IOException ex) { + log.error("", ex); + } + } + + return result; + } @RemoteMethod(name = "meta.set", permissions = {Permissions.CONTENT_EDIT}) public Object setMeta(Map parameters) { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMediaEnpoints.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMediaEnpoints.java index fa4b5f5f..be30af28 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMediaEnpoints.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/extensionpoints/remotemethods/RemoteMediaEnpoints.java @@ -22,10 +22,13 @@ */ import com.condation.cms.api.Constants; import com.condation.cms.api.auth.Permissions; +import com.condation.cms.api.configuration.configs.MediaConfiguration; import com.condation.cms.api.eventbus.events.InvalidateMediaCache; import com.condation.cms.api.extensions.AbstractExtensionPoint; +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.InjectorFeature; import com.condation.cms.api.feature.features.SiteMediaServiceFeature; import com.condation.cms.api.feature.features.SitePropertiesFeature; import com.condation.cms.api.ui.extensions.UIRemoteMethodExtensionPoint; @@ -37,6 +40,7 @@ import com.condation.cms.api.utils.ImageUtil; import com.condation.cms.modules.ui.utils.MetaConverter; import com.condation.cms.core.content.io.YamlHeaderUpdater; +import com.condation.cms.media.SiteMediaManager; import java.net.URI; import java.util.HashMap; @@ -48,6 +52,22 @@ @Extension(UIRemoteMethodExtensionPoint.class) public class RemoteMediaEnpoints extends AbstractExtensionPoint implements UIRemoteMethodExtensionPoint { + @RemoteMethod(name = "media.formats.get", permissions = {Permissions.CONTENT_EDIT}) + public Object getResolutions(Map parameters) throws RPCException { + try { + var image = (String) parameters.getOrDefault("image", ""); + + var imagePath = getMediaPath(image); + + var mediaFormats = getRequestContext().get(ConfigurationFeature.class).configuration().get(MediaConfiguration.class).getFormats(); + + return Map.of("formats", mediaFormats.stream().map(format -> format.name()).toList()); + } catch (Exception e) { + log.error("", e); + throw new RPCException(0, e.getMessage()); + } + } + @RemoteMethod(name = "media.meta.get", permissions = {Permissions.CONTENT_EDIT}) public Object getMediaMeta(Map parameters) throws RPCException { try { diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/RefreshTokenHandler.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/RefreshTokenHandler.java new file mode 100644 index 00000000..7c10b4fc --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/http/auth/RefreshTokenHandler.java @@ -0,0 +1,83 @@ +package com.condation.cms.modules.ui.http.auth; + +/*- + * #%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.configuration.configs.ServerConfiguration; +import com.condation.cms.api.feature.features.ConfigurationFeature; +import com.condation.cms.api.module.SiteModuleContext; +import com.condation.cms.api.request.RequestContext; +import com.condation.cms.modules.ui.http.JettyHandler; +import com.condation.cms.modules.ui.utils.AuthUtil; +import com.condation.cms.modules.ui.utils.TokenUtils; +import com.condation.cms.modules.ui.utils.json.UIGsonProvider; +import java.time.Duration; +import java.util.Map; +import org.eclipse.jetty.io.Content; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.util.Callback; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Handler für den expliziten Refresh-Endpunkt. + * + * Die Route /manager/refresh verwendet hier das Refresh-Cookie, um bei Bedarf + * neue Auth- und Refresh-Tokens zu setzen. + */ +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenHandler extends JettyHandler { + + private final SiteModuleContext moduleContext; + private final RequestContext requestContext; + + @Override + public boolean handle(Request request, Response response, Callback callback) throws Exception { + if (!request.getMethod().equalsIgnoreCase("POST")) { + return false; + } + + var newAuthToken = AuthUtil.refreshTokens(request, response, moduleContext, requestContext); + if (newAuthToken.isPresent()) { + var secret = moduleContext.get(ConfigurationFeature.class).configuration().get(ServerConfiguration.class).serverProperties().secret(); + + var payload = TokenUtils.getPayload(newAuthToken.get(), secret); + if (payload.isEmpty()) { + log.warn("Refresh succeeded but token payload could not be parsed"); + response.setStatus(401); + Content.Sink.write(response, true, UIGsonProvider.INSTANCE.toJson(Map.of("status", "error", "reason", "unauthorized")), callback); + return true; + } + + response.setStatus(200); + Content.Sink.write(response, true, UIGsonProvider.INSTANCE.toJson(Map.of( + "status", "ok", + "previewToken", TokenUtils.createToken(payload.get().username(), secret, Duration.ofHours(1), Duration.ofDays(7)) + )), callback); + } else { + response.setStatus(401); + Content.Sink.write(response, true, UIGsonProvider.INSTANCE.toJson(Map.of("status", "error", "reason", "unauthorized")), callback); + } + + return true; + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/AuthUtil.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/AuthUtil.java index e71a60a2..0607d198 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/AuthUtil.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/AuthUtil.java @@ -48,7 +48,7 @@ public final class AuthUtil { private AuthUtil() { } - private static boolean tryRefresh(Request request, Response response, SiteModuleContext moduleContext, RequestContext requestContext) { + private static Optional tryRefresh(Request request, Response response, SiteModuleContext moduleContext, RequestContext requestContext) { var secret = moduleContext.get(ConfigurationFeature.class).configuration().get(ServerConfiguration.class).serverProperties().secret(); var refreshTokenCache = moduleContext.get(CacheManagerFeature.class).cacheManager().get( @@ -58,24 +58,28 @@ private static boolean tryRefresh(Request request, Response response, SiteModule var refreshCookie = CookieUtil.getCookie(request, UIConstants.COOKIE_CMS_REFRESH_TOKEN); if (refreshCookie.isEmpty()) { - return false; + return Optional.empty(); } var token = refreshCookie.get().getValue(); var payload = TokenUtils.getPayload(token, secret); if (payload.isPresent()) { - if (refreshTokenCache.contains(token)) { - refreshTokenCache.invalidate(token); + refreshTokenCache.invalidate(token); - Optional userOpt = moduleContext.get(InjectorFeature.class).injector().getInstance(UserService.class).byUsername(Realm.of("manager-users"), payload.get().username()); - if (userOpt.isPresent()) { - updateCookies(userOpt.get(), response, requestContext, moduleContext); - return true; - } + Optional userOpt = moduleContext.get(InjectorFeature.class).injector().getInstance(UserService.class).byUsername(Realm.of("manager-users"), payload.get().username()); + if (userOpt.isPresent()) { + return Optional.of(updateCookies(userOpt.get(), response, requestContext, moduleContext)); } } - return false; + return Optional.empty(); + } + + /** + * Returns the new auth token string if refresh succeeded, empty otherwise. + */ + public static Optional refreshTokens(Request request, Response response, SiteModuleContext moduleContext, RequestContext requestContext) { + return tryRefresh(request, response, moduleContext, requestContext); } public static boolean checkAuthTokens(Request request, Response response, SiteModuleContext moduleContext, RequestContext requestContext) { @@ -84,8 +88,7 @@ public static boolean checkAuthTokens(Request request, Response response, SiteMo var secret = moduleContext.get(ConfigurationFeature.class).configuration().get(ServerConfiguration.class).serverProperties().secret(); if (authCookie.isEmpty()) { - // try refresh - if (tryRefresh(request, response, moduleContext, requestContext)) { + if (tryRefresh(request, response, moduleContext, requestContext).isPresent()) { return true; } } else { @@ -94,8 +97,7 @@ public static boolean checkAuthTokens(Request request, Response response, SiteMo var payload = TokenUtils.getPayload(token, secret); if (payload.isEmpty()) { - // try refresh - if (tryRefresh(request, response, moduleContext, requestContext)) { + if (tryRefresh(request, response, moduleContext, requestContext).isPresent()) { return true; } } else { @@ -106,7 +108,10 @@ public static boolean checkAuthTokens(Request request, Response response, SiteMo return false; } - public static void updateCookies(User user, Response response, RequestContext requestContext, SiteModuleContext moduleContext) { + /** + * Creates and sets new auth/refresh/preview cookies. Returns the new auth token. + */ + public static String updateCookies(User user, Response response, RequestContext requestContext, SiteModuleContext moduleContext) { try { var secret = moduleContext.get(ConfigurationFeature.class).configuration().get(ServerConfiguration.class).serverProperties().secret(); @@ -140,6 +145,8 @@ public static void updateCookies(User user, Response response, RequestContext re new CacheManager.CacheConfig(1000l, Duration.ofDays(7)) ); refreshTokenCache.put(refreshToken, true); + + return authToken; } catch (Exception ex) { log.error("", ex); throw new RuntimeException(ex); diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/MarkdownHelper.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/MarkdownHelper.java new file mode 100644 index 00000000..9c533755 --- /dev/null +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/MarkdownHelper.java @@ -0,0 +1,108 @@ +package com.condation.cms.modules.ui.utils; + +/*- + * #%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.content.markdown.rules.inline.ImageInlineRule; +import java.util.Objects; + +/** + * + * @author thorstenmarx + */ +public class MarkdownHelper { + + public static String replaceImage(String contextPath, String markdown, + int start, + int end, + String replacement) { + Objects.requireNonNull(markdown); + Objects.requireNonNull(replacement); + + // step is necessary because it is also in markdown renderer + markdown = markdown.replace("\r\n", "\n"); + + if (start < 0 || end < start || end > markdown.length()) { + throw new IllegalArgumentException( + "Invalid range: start=" + start + ", end=" + end); + } + + String segment = markdown.substring(start, end); + + ImageInlineRule rule = new ImageInlineRule(); + ImageInlineRule.ImageInlineBlock block = (ImageInlineRule.ImageInlineBlock) rule.next(null, segment); + + if (block == null) { + return markdown; + } + + int qIndex = block.src().indexOf("?"); + var query = ""; + if (qIndex != -1) { + query = block.src().substring(qIndex); + } + + if (contextPath.equals("/")) { + contextPath = ""; + } + var imageurl = contextPath + "/media/" + replacement; + if (block.src().startsWith(contextPath + "/assets")) { + imageurl = contextPath + "/assets/" + replacement; + } + + StringBuilder image = new StringBuilder() + .append("![").append(block.alt()).append("]") + .append("(").append(imageurl).append(query).append(")"); + + StringBuilder sb = new StringBuilder( + markdown.length() - (end - start) + replacement.length()); + sb.append(markdown, 0, start); + sb.append(image); + sb.append(markdown, end, markdown.length()); + + return sb.toString(); + } + + public static String replaceRange(String markdown, + int start, + int end, + String replacement) { + + Objects.requireNonNull(markdown); + Objects.requireNonNull(replacement); + + // step is necessary because it is also in markdown renderer + markdown = markdown.replace("\r\n", "\n"); + + if (start < 0 || end < start || end > markdown.length()) { + throw new IllegalArgumentException( + "Invalid range: start=" + start + ", end=" + end); + } + + StringBuilder sb = new StringBuilder( + markdown.length() - (end - start) + replacement.length()); + + sb.append(markdown, 0, start); + sb.append(replacement); + sb.append(markdown, end, markdown.length()); + + return sb.toString(); + } +} diff --git a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/NumberUtils.java b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/NumberUtils.java index b5e063ec..cb693a3a 100644 --- a/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/NumberUtils.java +++ b/modules/ui-module/src/main/java/com/condation/cms/modules/ui/utils/NumberUtils.java @@ -39,4 +39,17 @@ public static long toLong(Object value) { throw new IllegalArgumentException("Invalid page value: " + value); }; } + + public static int toInt(Object value) { + return switch (value) { + case null -> + 1; + case Number n -> + n.intValue(); + case String s -> + Integer.parseInt(s); + default -> + throw new IllegalArgumentException("Invalid page value: " + value); + }; + } } diff --git a/modules/ui-module/src/main/resources/manager/actions/media/edit-focal-point.js b/modules/ui-module/src/main/resources/manager/actions/media/edit-focal-point.js index 6c4a6392..a917e7b5 100644 --- a/modules/ui-module/src/main/resources/manager/actions/media/edit-focal-point.js +++ b/modules/ui-module/src/main/resources/manager/actions/media/edit-focal-point.js @@ -66,6 +66,10 @@ export async function runAction(params) { const wrapper = document.getElementById("cmsFocalWrapper"); const image = document.getElementById("cms-image"); const point = document.getElementById("cmsFocalPoint"); + if (wrapper === null || image === null || point === null) { + console.error("One or more required elements not found"); + return; + } if (image.complete) { setFocalPoint(image, point, focalX, focalY); } diff --git a/modules/ui-module/src/main/resources/manager/actions/media/select-content-media.d.ts b/modules/ui-module/src/main/resources/manager/actions/media/select-content-media.d.ts new file mode 100644 index 00000000..6dc2ed24 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/media/select-content-media.d.ts @@ -0,0 +1,21 @@ +/*- + * #%L + * UI Module + * %% + * Copyright (C) 2023 - 2026 CondationCMS + * %% + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * #L% + */ +export declare function runAction(params: any): Promise; diff --git a/modules/ui-module/src/main/resources/manager/actions/media/select-content-media.js b/modules/ui-module/src/main/resources/manager/actions/media/select-content-media.js new file mode 100644 index 00000000..02e50d2f --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/actions/media/select-content-media.js @@ -0,0 +1,81 @@ +/*- + * #%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 { openFileBrowser } from "@cms/modules/filebrowser.js"; +import { i18n } from "@cms/modules/localization.js"; +import { getPreviewUrl, reloadPreview } from "@cms/modules/preview.utils.js"; +import { getContentNode, replaceContent } from "@cms/modules/rpc/rpc-content.js"; +import { showToast } from "@cms/modules/toast.js"; +export async function runAction(params) { + var uri = null; + if (params.options.uri) { + uri = params.options.uri; + } + else { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }); + uri = contentNode.result.uri; + } + openFileBrowser({ + type: "assets", + filter: (file) => { + return file.media || file.directory; + }, + onSelect: async (file) => { + if (file && file.uri) { + var selectedFile = file.uri; // Use the file's URI + if (file.uri.startsWith("/")) { + selectedFile = file.uri.substring(1); // Remove leading slash if present + } + var updateData = {}; + updateData[params.options.metaElement] = { + type: 'media', + value: selectedFile + }; + var options = { + uri: uri, + content: selectedFile, + start: params.options.start, + end: params.options.end + }; + var replaceMedia = await replaceContent(options); + if (replaceMedia.result.error != null && replaceMedia.result.error === true) { + showToast({ + title: i18n.t('manager.actions.media.select-content-media.toast.title-error', "Media not updated"), + message: i18n.t('manager.actions.media.select-content-media.toast.message-error', "New media has not been updated successfully."), + type: 'error', // optional: info | success | warning | error + timeout: 3000 + }); + reloadPreview(); + } + else { + showToast({ + title: i18n.t('manager.actions.media.select-media.toast.title', "Media updated"), + message: i18n.t('manager.actions.media.select-media.toast.message', "New media has been updated successfully."), + type: 'success', // optional: info | success | warning | error + timeout: 3000 + }); + reloadPreview(); + } + } + } + }); +} diff --git a/modules/ui-module/src/main/resources/manager/actions/page/translations.js b/modules/ui-module/src/main/resources/manager/actions/page/translations.js index 7745258f..f026bca1 100644 --- a/modules/ui-module/src/main/resources/manager/actions/page/translations.js +++ b/modules/ui-module/src/main/resources/manager/actions/page/translations.js @@ -40,7 +40,7 @@ export async function runAction(params) { onOk: async (event) => { }, onShow: async (modalElement) => { - modalElement.querySelectorAll('button[data-action]').forEach(button => { + modalElement.querySelectorAll('button[data-action]').forEach((button) => { button.addEventListener('click', async (e) => { const action = e.currentTarget.getAttribute('data-action'); const siteId = e.currentTarget.getAttribute('data-id'); diff --git a/modules/ui-module/src/main/resources/manager/index.html b/modules/ui-module/src/main/resources/manager/index.html index 878d7ac6..76e51220 100644 --- a/modules/ui-module/src/main/resources/manager/index.html +++ b/modules/ui-module/src/main/resources/manager/index.html @@ -94,7 +94,8 @@ baseUrl: '{{ managerBaseURL }}', contextPath: '{{ contextPath }}', siteId: '{{ siteId }}', - previewUrl: "{{ links.createUrl('/?preview=manager') | raw }}" + previewUrl: "{{ links.createUrl('/?preview=manager') | raw }}", + refreshUrl: "{{ links.createUrl('/manager/refresh') | raw }}" } diff --git a/modules/ui-module/src/main/resources/manager/js/manager-inject.js b/modules/ui-module/src/main/resources/manager/js/manager-inject.js index 75083adb..2b11d2b1 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager-inject.js +++ b/modules/ui-module/src/main/resources/manager/js/manager-inject.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/manager.js b/modules/ui-module/src/main/resources/manager/js/manager.js index 9987b664..506b4e96 100644 --- a/modules/ui-module/src/main/resources/manager/js/manager.js +++ b/modules/ui-module/src/main/resources/manager/js/manager.js @@ -29,7 +29,20 @@ import { setCSRFToken } from '@cms/modules/utils.js'; frameMessenger.on('load', (payload) => { EventBus.emit("preview:loaded", {}); }); +function heartbeat() { + fetch(window.manager.refreshUrl, { + method: "POST", + credentials: "include" + }) + .then(res => res.json()) + .then(data => { + window.manager.previewToken = data.previewToken; + }); +} document.addEventListener("DOMContentLoaded", function () { + setInterval(() => { + heartbeat(); + }, 10 * 60 * 1000); //PreviewHistory.init("/"); //updateStateButton(); activatePreviewOverlay(); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts index 0c529bf7..0869c773 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/filebrowser.d.ts @@ -20,6 +20,6 @@ */ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: any; + let options: null; let currentFolder: string; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.checkbox.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.checkbox.js index 16f4e92f..6bbe7a57 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.checkbox.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.checkbox.js @@ -24,7 +24,7 @@ const createCheckboxField = (options, value = []) => { const key = options.key || ""; const name = options.name || id; const title = options.title || ""; - const choices = options.options.choices || []; + const choices = options.options?.choices || []; const selectedValues = new Set(value); const checkboxes = choices.map((choice, idx) => { const inputId = `${id}-${idx}`; @@ -46,7 +46,10 @@ const createCheckboxField = (options, value = []) => { `; }; const getData = (context) => { - const data = {}; + var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='checkbox']").forEach(container => { const name = container.querySelector("input[type='checkbox']").name; const checkedBoxes = container.querySelectorAll("input[type='checkbox']:checked"); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.color.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.color.js index 23416358..6cfba7fd 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.color.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.color.js @@ -33,6 +33,9 @@ const createColorField = (options, value = '#000000') => { }; const getColorData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='color'] input").forEach((el) => { data[el.name] = { type: 'color', diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.date.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.date.js index 31a16ded..4a090c7e 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.date.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.date.js @@ -41,6 +41,9 @@ const createDateField = (options, value = '') => { }; const getDateData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='date'] input").forEach((el) => { const value = getUTCDateFromInput(el); // "2025-05-31" data[el.name] = { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.datetime.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.datetime.js index bff91c58..8df816d5 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.datetime.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.datetime.js @@ -41,6 +41,9 @@ const createDateTimeField = (options, value = '') => { }; const getDateTimeData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='datetime'] input").forEach((el) => { const value = getUTCDateTimeFromInput(el); // "2025-05-31T15:30" data[el.name] = { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.easymde.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.easymde.js index 7b547663..12e86fe6 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.easymde.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.easymde.js @@ -34,6 +34,9 @@ const createMarkdownField = (options, value = '') => { }; const getData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } markdownEditors.forEach(({ input, editor }) => { data[input.name] = { type: "easymde", diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js index b520c971..b7b60256 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.list.js @@ -80,7 +80,7 @@ const handleAddItem = (e, container, context) => { `; listGroup.insertAdjacentHTML("beforeend", itemMarkup); - var itemElement = listGroup.querySelector(`[data-cms-form-field-item="${itemId}"]`); + const itemElement = listGroup.querySelector(`[data-cms-form-field-item="${itemId}"]`); if (itemElement) { itemElement.addEventListener('dblclick', (e) => handleDoubleClick(e, context)); const removeBtn = itemElement.querySelector('.remove-btn'); @@ -99,7 +99,7 @@ const getItemForm = async (el) => { const getContentResponse = await getContent({ uri: contentNode.result.uri }); - var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template); + var selected = pageTemplates.filter((pageTemplate) => pageTemplate.template === getContentResponse?.result?.meta?.template); const listContainer = el.closest("[data-cms-form-field-type='list']"); const fieldName = listContainer?.getAttribute('name'); var itemForm = []; @@ -108,7 +108,7 @@ const getItemForm = async (el) => { } if (!itemForm || itemForm.length === 0) { let itemTypes = (await getListItemTypes({})).result; - var selectedItemType = itemTypes.filter(itemType => itemType.name === fieldName); + var selectedItemType = itemTypes.filter((itemType) => itemType.name === fieldName); itemForm = (selectedItemType.length === 1) ? selectedItemType[0].data?.form.fields : []; } return itemForm; @@ -136,16 +136,22 @@ const handleDoubleClick = async (event, context) => { el.setAttribute('data-cms-form-field-item-data', JSON.stringify(updateData)); const listContainer = el.closest("[data-cms-form-field-type='list']"); const nameField = listContainer?.getAttribute('data-name-field') || 'name'; - el.querySelector('.object-name').textContent = updateData[nameField]; + const objectNameEl = el.querySelector('.object-name'); + if (!objectNameEl) + return; + objectNameEl.textContent = updateData[nameField] || ""; } }); } }; const getData = (context) => { var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach((el) => { let value = []; - el.querySelectorAll("[data-cms-form-field-item]").forEach(itemEl => { + el.querySelectorAll("[data-cms-form-field-item]").forEach((itemEl) => { const itemData = itemEl.getAttribute('data-cms-form-field-item-data'); if (itemData) { value.push(JSON.parse(itemData)); @@ -162,7 +168,7 @@ const getData = (context) => { return data; }; const init = (context) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach(listContainer => { + context.formElement?.querySelectorAll("[data-cms-form-field-type='list']").forEach(listContainer => { listContainer.querySelectorAll("[data-cms-form-field-item]").forEach(field => { field.addEventListener('dblclick', (e) => handleDoubleClick(e, context)); // Remove-Button-Listener setzen diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.mail.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.mail.js index 1ca07c93..cb4651b9 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.mail.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.mail.js @@ -34,6 +34,9 @@ const createEmailField = (options, value = '') => { }; const getData = (context) => { var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='mail'] input").forEach((el) => { let value = el.value; data[el.name] = { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.markdown.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.markdown.js index 0d134d31..8475a02c 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.markdown.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.markdown.js @@ -38,7 +38,11 @@ const createMarkdownField = (options, value = '') => { }; const getData = (context) => { const data = {}; - const editorInputs = context.formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); + const formElement = context.formElement; + if (!formElement) { + return data; + } + const editorInputs = formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); editorInputs.forEach((input) => { const editor = input.cherryEditor; if (editor && editor.getMarkdown) { @@ -58,8 +62,12 @@ const getData = (context) => { return data; }; const init = async (context) => { + const formElement = context.formElement; + if (!formElement) { + return; + } const cmsTagsMenu = await buildCmsTagsMenu(); - const editorInputs = context.formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); + const editorInputs = formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); editorInputs.forEach((input) => { const containerId = input.dataset.cherryId; const initialValue = decodeURIComponent(input.dataset.initialValue || ""); @@ -113,7 +121,7 @@ const getEditorFromEvent = (event) => { const buildCmsTagsMenu = async () => { const response = await getTagNames({}); const tagNames = response.result || []; - const submenuConfig = tagNames.map(tag => ({ + const submenuConfig = tagNames.map((tag) => ({ name: tag.charAt(0).toUpperCase() + tag.slice(1), value: tag, noIcon: true, diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.media.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.media.js index be29625a..653d4ab9 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.media.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.media.js @@ -56,6 +56,9 @@ const createMediaField = (options, value = '') => { }; const getData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { const input = wrapper.querySelector(".cms-media-input-value"); if (input) { @@ -68,6 +71,9 @@ const getData = (context) => { return data; }; const init = (context) => { + if (!context.formElement) { + return; + } context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { const dropZone = wrapper.querySelector(".cms-drop-zone"); const input = wrapper.querySelector(".cms-media-input"); @@ -101,7 +107,14 @@ const init = (context) => { //dropZone.addEventListener("click", () => input.click()); // Handle file selection input.addEventListener("change", (e) => { - const file = e.target.files[0]; + if (e.target === null) { + return; + } + var inputElement = e.target; + if (inputElement.files == null) { + return; + } + const file = inputElement.files[0]; if (file) { preview.src = URL.createObjectURL(file); handleUpload(wrapper, file); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.number.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.number.js index 7111e6fe..10f48128 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.number.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.number.js @@ -37,7 +37,11 @@ const createNumberField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='number'] input").forEach((el) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='number'] input").forEach((el) => { const value = el.value; data[el.name] = { type: 'number', diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.radio.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.radio.js index a3efe892..9412f5aa 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.radio.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.radio.js @@ -47,7 +47,11 @@ const createRadioField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='radio']").forEach(container => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='radio']").forEach(container => { const name = container.querySelector("input[type='radio']").name; const checked = container.querySelector("input[type='radio']:checked"); if (checked) { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.range.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.range.js index 152ed99e..19485a95 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.range.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.range.js @@ -38,7 +38,11 @@ const createRangeField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='range'] input").forEach((el) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='range'] input").forEach((el) => { data[el.name] = { type: 'range', value: parseFloat(el.value) diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.reference.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.reference.js index 8282b938..958b6288 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.reference.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.reference.js @@ -41,7 +41,11 @@ const createReferenceField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='reference'] input").forEach((el) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='reference'] input").forEach((el) => { let value = el.value; data[el.name] = { type: 'reference', @@ -51,7 +55,11 @@ const getData = (context) => { return data; }; const init = (context) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='reference']").forEach(wrapper => { + const formElement = context.formElement; + if (!formElement) { + return; + } + formElement.querySelectorAll("[data-cms-form-field-type='reference']").forEach(wrapper => { const fileManager = wrapper.querySelector(".cms-reference-button"); if (!fileManager) return; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.select.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.select.js index 7ed94d20..9da65446 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.select.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.select.js @@ -41,8 +41,8 @@ const createSelectField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement - .querySelectorAll("[data-cms-form-field-type='select'] select") + context.formElement?. + querySelectorAll("[data-cms-form-field-type='select'] select") .forEach((el) => { let value = el.value; // optional: type-konvertierung, aber fallback ist immer der echte Wert diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.text.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.text.js index 63b8c357..0c01a797 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.text.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.text.js @@ -34,6 +34,9 @@ const createTextField = (options, value = '') => { }; const getData = (context) => { var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='text'] input").forEach((el) => { let value = el.value; data[el.name] = { diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/field.textarea.js b/modules/ui-module/src/main/resources/manager/js/modules/form/field.textarea.js index 34581dc5..a26c4bb3 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/field.textarea.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/field.textarea.js @@ -34,6 +34,10 @@ const createTextAreaField = (options, value = '') => { }; const getData = (context) => { var data = {}; + if (context.formElement === null) { + console.error('Form element not found.'); + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='text'] textarea").forEach((el) => { let value = el.value; data[el.name] = { 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 eee7c6d5..ea091c80 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 @@ -45,7 +45,7 @@ const createForm = (options) => { formElement: null, fields: fields }; - const fieldHtml = fields.map(field => { + const fieldHtml = fields.map((field) => { const val = values[field.name] || ''; switch (field.type) { case 'email': @@ -103,6 +103,10 @@ const createForm = (options) => { } container.innerHTML = html; context.formElement = container.querySelector('form'); + if (!context.formElement) { + console.error('Form element not found.'); + return; + } context.formElement.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.tagName.toLowerCase() !== 'textarea') { e.preventDefault(); @@ -111,7 +115,7 @@ const createForm = (options) => { context.formElement.addEventListener('submit', (e) => { e.preventDefault(); e.stopPropagation(); - context.formElement.classList.add('was-validated'); + context.formElement?.classList.add('was-validated'); }); CodeField.init(context); MarkdownField.init(context); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts index 7d3d89dc..23eeb225 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/form/utils.d.ts @@ -20,7 +20,7 @@ */ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts index b1e39387..e9105175 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/localization.d.ts @@ -21,7 +21,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: any; + let _cache: null; /** * Loads and merges remote localizations with defaults. */ 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 083ac47a..cef4be80 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 @@ -47,6 +47,16 @@ const executeImageSelect = (payload) => { }; executeScriptAction(cmd); }; +const executeContentImageReplace = (payload) => { + const cmd = { + "module": window.manager.baseUrl + "/actions/media/select-content-media", + "function": "runAction", + "parameters": { + "options": payload.options ? payload.options : {} + } + }; + executeScriptAction(cmd); +}; const initMessageHandlers = () => { frameMessenger.on('preview:reload', (payload) => { }); @@ -88,6 +98,9 @@ const initMessageHandlers = () => { else if (payload.element === "image" && payload.editor === "select") { executeImageSelect(payload); } + else if (payload.element === "image" && payload.editor === "replace") { + executeContentImageReplace(payload); + } else if (payload.element === "image" && payload.editor === "focal-point") { var cmd = { "module": window.manager.baseUrl + "/actions/media/edit-focal-point", diff --git a/modules/ui-module/src/main/resources/manager/js/modules/manager/media.inject.js b/modules/ui-module/src/main/resources/manager/js/modules/manager/media.inject.js index 193502b6..c89fd259 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/manager/media.inject.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/manager/media.inject.js @@ -84,15 +84,18 @@ export const initContentMediaToolbar = (img) => { return; } var toolbar = img.closest('[data-cms-toolbar]'); - var parentToolbarDef = JSON.parse(toolbar.dataset.cmsToolbar); + var parentToolbarDef = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); if (!parentToolbarDef) { return; } var toolbarDefinition = { "options": { - "uri": parentToolbarDef.uri + "uri": parentToolbarDef.uri, + "start": img.dataset.cmsMdStart || null, + "end": img.dataset.cmsMdEnd || null }, "actions": [ + "replace", "meta", "focalPoint" ] @@ -103,7 +106,7 @@ export const initMediaToolbar = (img) => { if (!isSameDomainImage(img)) { return; } - var toolbarDefinition = JSON.parse(img.dataset.cmsMediaToolbar); + var toolbarDefinition = JSON.parse(img.dataset.cmsMediaToolbar || '{}'); initToolbar(img, toolbarDefinition); }; export const initToolbar = (img, toolbarDefinition) => { @@ -119,6 +122,15 @@ export const initToolbar = (img, toolbarDefinition) => { }); toolbar.appendChild(selectButton); } + if (toolbarDefinition.actions.includes('replace')) { + const replaceButton = document.createElement('button'); + replaceButton.innerHTML = IMAGE_ICON; + replaceButton.setAttribute("title", "Replace media"); + replaceButton.addEventListener('click', (event) => { + replaceMedia(toolbarDefinition.options.start, toolbarDefinition.options.end, toolbarDefinition.options.element, toolbarDefinition.options.uri); + }); + toolbar.appendChild(replaceButton); + } if (toolbarDefinition.actions.includes('meta')) { const metaButton = document.createElement('button'); metaButton.setAttribute('data-cms-action', 'editMediaForm'); @@ -174,6 +186,22 @@ export const initToolbar = (img, toolbarDefinition) => { positionToolbar(); }); }; +const replaceMedia = (start, end, metaElement, uri) => { + var command = { + type: 'edit', + payload: { + editor: "replace", + element: "image", + options: { + metaElement: metaElement, + uri: uri, + start: start, + end: end + } + } + }; + frameMessenger.send(window.parent, command); +}; const selectMedia = (metaElement, uri) => { var command = { type: 'edit', 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 580cdf53..392198a9 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 @@ -22,7 +22,7 @@ import frameMessenger from "@cms/modules/frameMessenger.js"; import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; const addSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'add-sectionEntry', payload: { @@ -33,7 +33,7 @@ const addSection = (event) => { }; const deleteSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'delete-sectionEntry', payload: { @@ -44,7 +44,7 @@ const deleteSection = (event) => { }; const setPublishForSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var action = event.currentTarget.getAttribute('data-cms-action'); var command = { type: 'section-set-published', @@ -57,7 +57,7 @@ const setPublishForSection = (event) => { }; const orderSections = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'edit-sections', payload: { @@ -68,7 +68,7 @@ const orderSections = (event) => { }; const editContent = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'edit', payload: { @@ -83,7 +83,7 @@ const editContent = (event) => { }; const editAttributes = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'edit', payload: { @@ -116,7 +116,7 @@ const editAttributes = (event) => { frameMessenger.send(window.parent, command); }; export const initToolbar = (container) => { - var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); if (!toolbarDefinition.actions) { return; } @@ -143,7 +143,7 @@ export const initToolbar = (container) => { toolbar.classList.remove('visible'); } }); - toolbarDefinition.actions.forEach(action => { + toolbarDefinition.actions.forEach((action) => { if (action === "editContent") { const button = document.createElement('button'); button.setAttribute('data-cms-action', 'edit'); diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts index 520ce295..6cfeb16a 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.history.d.ts @@ -22,6 +22,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: any): void; +declare function init(defaultUrl?: null): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts index e7c2a28b..20c850ee 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/preview.utils.d.ts @@ -23,4 +23,4 @@ export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement; +export function getPreviewFrame(): HTMLElement | null; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.d.ts index 26338623..0df37da3 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.d.ts @@ -18,11 +18,23 @@ * along with this program. If not, see . * #L% */ +import { RPCResponse } from '@cms/modules/rpc/rpc.js'; declare const getContentNode: (options: any) => Promise; declare const getContent: (options: any) => Promise; declare const setContent: (options: any) => Promise; +export interface ReplaceContent { + error: boolean | null; + uri: string; +} +export interface ReplaceContentOptions { + uri: string; + content: string; + start: number; + end: number; +} +declare const replaceContent: (options: ReplaceContentOptions) => Promise>; declare const setMeta: (options: any) => Promise; declare const setMetaBatch: (options: any) => Promise; declare const addSection: (options: any) => Promise; declare const deleteSection: (options: any) => Promise; -export { getContentNode, getContent, setContent, setMeta, setMetaBatch, addSection, deleteSection }; +export { getContentNode, getContent, setContent, replaceContent, setMeta, setMetaBatch, addSection, deleteSection }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.js b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.js index 285c9e39..e0a97a88 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc-content.js @@ -40,6 +40,13 @@ const setContent = async (options) => { }; return await executeRemoteCall(data); }; +const replaceContent = async (options) => { + var data = { + method: "content.replace", + parameters: options + }; + return await executeRemoteCall(data); +}; const setMeta = async (options) => { var data = { method: "meta.set", @@ -68,4 +75,4 @@ const deleteSection = async (options) => { }; return await executeRemoteCall(data); }; -export { getContentNode, getContent, setContent, setMeta, setMetaBatch, addSection, deleteSection }; +export { getContentNode, getContent, setContent, replaceContent, setMeta, setMetaBatch, addSection, deleteSection }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.d.ts index 71e4a384..753fc6d8 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/rpc/rpc.d.ts @@ -22,6 +22,9 @@ interface Options { method: string; parameters?: any; } +export interface RPCResponse { + result: T; +} declare const executeRemoteCall: (options: Options) => Promise; declare const executeRemoteMethodCall: (method: string, parameters: any) => Promise; export { executeRemoteCall, executeRemoteMethodCall }; 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 d6c1aa58..d1598ff2 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 @@ -28,18 +28,19 @@ const executeRemoteMethodCall = async (method, parameters) => { method: method, parameters: parameters }; + const csrfToken = getCSRFToken(); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - 'X-CSRF-Token': getCSRFToken() + ...(csrfToken && { 'X-CSRF-Token': csrfToken }) }, body: JSON.stringify(data) }); if (response.status === 403) { alert(i18n.t("ui.redirect.login", "You where logged out due to inactivity. Please log in again.")); window.location.href = window.manager.baseUrl + "/login"; - return; + throw new Error("Unauthorized"); } return await response.json(); }; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/state.js b/modules/ui-module/src/main/resources/manager/js/modules/state.js index 7274c0b6..a0988236 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/state.js +++ b/modules/ui-module/src/main/resources/manager/js/modules/state.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts index 65018962..30e36cd4 100644 --- a/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/resources/manager/js/modules/ui-state.d.ts @@ -20,11 +20,11 @@ */ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: any): any; + function getTabState(key: any, defaultValue?: null): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string; + function getAuthToken(): string | null; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/resources/manager/js/modules/wizard.d.ts b/modules/ui-module/src/main/resources/manager/js/modules/wizard.d.ts new file mode 100644 index 00000000..bb3a3b4b --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/wizard.d.ts @@ -0,0 +1,26 @@ +/*- + * #%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 function openWizard(optionsParam: any): { + wizardId: string; + modalInstance: any; + goToStep: (index: any) => void; + getCurrentStep: () => number; +}; diff --git a/modules/ui-module/src/main/resources/manager/js/modules/wizard.js b/modules/ui-module/src/main/resources/manager/js/modules/wizard.js new file mode 100644 index 00000000..688b9529 --- /dev/null +++ b/modules/ui-module/src/main/resources/manager/js/modules/wizard.js @@ -0,0 +1,207 @@ +/*- + * #%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"; +const defaultOptions = { + title: 'Wizard', + fullscreen: false, + size: null, + showStepIndicator: true, + nextLabel: () => i18n.t('wizard.buttons.next', 'Next'), + prevLabel: () => i18n.t('wizard.buttons.previous', 'Previous'), + finishLabel: () => i18n.t('wizard.buttons.finish', 'Finish'), + cancelLabel: () => i18n.t('wizard.buttons.cancel', 'Cancel'), + validateStep: () => true, +}; +const renderStepBody = (step, containerId) => { + const container = document.getElementById(containerId); + if (!container) + return; + container.innerHTML = ''; + if (typeof step.body === 'function') { + const bodyResult = step.body(); + if (typeof bodyResult === 'string') { + container.innerHTML = bodyResult; + } + else if (bodyResult instanceof HTMLElement) { + container.appendChild(bodyResult); + } + else if (bodyResult && typeof bodyResult.then === 'function') { + bodyResult.then((result) => { + container.innerHTML = typeof result === 'string' ? result : ''; + if (result instanceof HTMLElement) { + container.appendChild(result); + } + }); + } + } + else if (step.body instanceof HTMLElement) { + container.appendChild(step.body); + } + else { + container.innerHTML = step.body || ''; + } +}; +const renderStepIndicator = (steps, currentStep, indicatorContainer) => { + if (!indicatorContainer) + return; + indicatorContainer.innerHTML = ''; + steps.forEach((step, index) => { + const stepNode = document.createElement('div'); + stepNode.className = `wizard-step-item ${index === currentStep ? 'active' : index < currentStep ? 'completed' : ''}`; + stepNode.innerHTML = ` +
      ${index + 1}
      +
      ${step.title || i18n.t('wizard.step', 'Step')} ${index + 1}
      + `; + indicatorContainer.appendChild(stepNode); + }); +}; +const openWizard = (optionsParam) => { + const wizardId = 'wizard_' + Date.now(); + const options = { + ...defaultOptions, + ...optionsParam, + }; + const steps = Array.isArray(options.steps) ? options.steps : []; + let currentStep = 0; + let fullscreen = ''; + if (options.fullscreen) { + fullscreen = 'modal-fullscreen'; + } + let size = ''; + if (options.size) { + size = 'modal-' + options.size; + } + const modalHtml = ` + `; + const container = document.getElementById('modalContainer'); + const modalDiv = document.createElement('div'); + modalDiv.innerHTML = modalHtml.trim(); + const modalNode = modalDiv.firstChild; + container.appendChild(modalNode); + const modalElement = document.getElementById(wizardId); + const modalInstance = new bootstrap.Modal(modalElement, { + backdrop: 'static', + keyboard: true, + focus: true, + }); + const prevBtn = document.getElementById(`${wizardId}_prevBtn`); + const nextBtn = document.getElementById(`${wizardId}_nextBtn`); + const cancelBtn = document.getElementById(`${wizardId}_cancelBtn`); + const stepTitle = document.getElementById(`${wizardId}_stepTitle`); + const stepIndicator = options.showStepIndicator ? document.getElementById(`${wizardId}_stepIndicator`) : null; + const bodyContainerId = `${wizardId}_bodyContainer`; + const updateButtons = () => { + prevBtn.style.display = currentStep === 0 ? 'none' : ''; + nextBtn.textContent = currentStep === steps.length - 1 ? options.finishLabel() : options.nextLabel(); + }; + const renderStep = () => { + const step = steps[currentStep] || {}; + const titleText = step.title || `${i18n.t('wizard.step', 'Step')} ${currentStep + 1}`; + if (stepTitle) { + stepTitle.textContent = titleText; + } + renderStepBody(step, bodyContainerId); + if (options.showStepIndicator && stepIndicator) { + renderStepIndicator(steps, currentStep, stepIndicator); + } + updateButtons(); + if (typeof step.onShow === 'function') { + step.onShow(modalElement); + } + }; + const goToStep = (index) => { + const step = steps[currentStep] || {}; + if (typeof step.validate === 'function' && !step.validate()) { + return; + } + if (typeof options.validateStep === 'function' && !options.validateStep(currentStep)) { + return; + } + if (currentStep !== index && typeof step.onHide === 'function') { + step.onHide(modalElement); + } + currentStep = Math.max(0, Math.min(index, steps.length - 1)); + renderStep(); + if (typeof options.onStepChange === 'function') { + options.onStepChange(currentStep); + } + }; + prevBtn.addEventListener('click', () => goToStep(currentStep - 1)); + cancelBtn.addEventListener('click', () => { + modalInstance.hide(); + if (typeof options.onCancel === 'function') { + options.onCancel(); + } + }); + nextBtn.addEventListener('click', () => { + const step = steps[currentStep] || {}; + const valid = typeof step.validate === 'function' ? step.validate() : true; + if (!valid) { + return; + } + if (currentStep === steps.length - 1) { + modalInstance.hide(); + if (typeof options.onFinish === 'function') { + options.onFinish(); + } + return; + } + goToStep(currentStep + 1); + }); + modalElement.addEventListener('shown.bs.modal', () => { + renderStep(); + if (typeof options.onShow === 'function') { + options.onShow(modalElement); + } + }); + modalElement.addEventListener('hidden.bs.modal', () => { + modalNode.remove(); + if (typeof options.onClose === 'function') { + options.onClose(); + } + }); + modalInstance.show(); + return { + wizardId, + modalInstance, + goToStep, + getCurrentStep: () => currentStep, + }; +}; +export { openWizard }; diff --git a/modules/ui-module/src/main/resources/manager/public/manager-login.js b/modules/ui-module/src/main/resources/manager/public/manager-login.js index 6f21eee9..24aa077a 100644 --- a/modules/ui-module/src/main/resources/manager/public/manager-login.js +++ b/modules/ui-module/src/main/resources/manager/public/manager-login.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/actions/media/edit-focal-point.js b/modules/ui-module/src/main/ts/dist/actions/media/edit-focal-point.js index 6c4a6392..a917e7b5 100644 --- a/modules/ui-module/src/main/ts/dist/actions/media/edit-focal-point.js +++ b/modules/ui-module/src/main/ts/dist/actions/media/edit-focal-point.js @@ -66,6 +66,10 @@ export async function runAction(params) { const wrapper = document.getElementById("cmsFocalWrapper"); const image = document.getElementById("cms-image"); const point = document.getElementById("cmsFocalPoint"); + if (wrapper === null || image === null || point === null) { + console.error("One or more required elements not found"); + return; + } if (image.complete) { setFocalPoint(image, point, focalX, focalY); } diff --git a/modules/ui-module/src/main/ts/dist/actions/media/select-content-media.d.ts b/modules/ui-module/src/main/ts/dist/actions/media/select-content-media.d.ts new file mode 100644 index 00000000..85620d3a --- /dev/null +++ b/modules/ui-module/src/main/ts/dist/actions/media/select-content-media.d.ts @@ -0,0 +1 @@ +export declare function runAction(params: any): Promise; diff --git a/modules/ui-module/src/main/ts/dist/actions/media/select-content-media.js b/modules/ui-module/src/main/ts/dist/actions/media/select-content-media.js new file mode 100644 index 00000000..02e50d2f --- /dev/null +++ b/modules/ui-module/src/main/ts/dist/actions/media/select-content-media.js @@ -0,0 +1,81 @@ +/*- + * #%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 { openFileBrowser } from "@cms/modules/filebrowser.js"; +import { i18n } from "@cms/modules/localization.js"; +import { getPreviewUrl, reloadPreview } from "@cms/modules/preview.utils.js"; +import { getContentNode, replaceContent } from "@cms/modules/rpc/rpc-content.js"; +import { showToast } from "@cms/modules/toast.js"; +export async function runAction(params) { + var uri = null; + if (params.options.uri) { + uri = params.options.uri; + } + else { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }); + uri = contentNode.result.uri; + } + openFileBrowser({ + type: "assets", + filter: (file) => { + return file.media || file.directory; + }, + onSelect: async (file) => { + if (file && file.uri) { + var selectedFile = file.uri; // Use the file's URI + if (file.uri.startsWith("/")) { + selectedFile = file.uri.substring(1); // Remove leading slash if present + } + var updateData = {}; + updateData[params.options.metaElement] = { + type: 'media', + value: selectedFile + }; + var options = { + uri: uri, + content: selectedFile, + start: params.options.start, + end: params.options.end + }; + var replaceMedia = await replaceContent(options); + if (replaceMedia.result.error != null && replaceMedia.result.error === true) { + showToast({ + title: i18n.t('manager.actions.media.select-content-media.toast.title-error', "Media not updated"), + message: i18n.t('manager.actions.media.select-content-media.toast.message-error', "New media has not been updated successfully."), + type: 'error', // optional: info | success | warning | error + timeout: 3000 + }); + reloadPreview(); + } + else { + showToast({ + title: i18n.t('manager.actions.media.select-media.toast.title', "Media updated"), + message: i18n.t('manager.actions.media.select-media.toast.message', "New media has been updated successfully."), + type: 'success', // optional: info | success | warning | error + timeout: 3000 + }); + reloadPreview(); + } + } + } + }); +} diff --git a/modules/ui-module/src/main/ts/dist/actions/page/translations.js b/modules/ui-module/src/main/ts/dist/actions/page/translations.js index 7745258f..f026bca1 100644 --- a/modules/ui-module/src/main/ts/dist/actions/page/translations.js +++ b/modules/ui-module/src/main/ts/dist/actions/page/translations.js @@ -40,7 +40,7 @@ export async function runAction(params) { onOk: async (event) => { }, onShow: async (modalElement) => { - modalElement.querySelectorAll('button[data-action]').forEach(button => { + modalElement.querySelectorAll('button[data-action]').forEach((button) => { button.addEventListener('click', async (e) => { const action = e.currentTarget.getAttribute('data-action'); const siteId = e.currentTarget.getAttribute('data-id'); diff --git a/modules/ui-module/src/main/ts/dist/js/manager-inject.js b/modules/ui-module/src/main/ts/dist/js/manager-inject.js index 75083adb..2b11d2b1 100644 --- a/modules/ui-module/src/main/ts/dist/js/manager-inject.js +++ b/modules/ui-module/src/main/ts/dist/js/manager-inject.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/manager.js b/modules/ui-module/src/main/ts/dist/js/manager.js index 9987b664..506b4e96 100644 --- a/modules/ui-module/src/main/ts/dist/js/manager.js +++ b/modules/ui-module/src/main/ts/dist/js/manager.js @@ -29,7 +29,20 @@ import { setCSRFToken } from '@cms/modules/utils.js'; frameMessenger.on('load', (payload) => { EventBus.emit("preview:loaded", {}); }); +function heartbeat() { + fetch(window.manager.refreshUrl, { + method: "POST", + credentials: "include" + }) + .then(res => res.json()) + .then(data => { + window.manager.previewToken = data.previewToken; + }); +} document.addEventListener("DOMContentLoaded", function () { + setInterval(() => { + heartbeat(); + }, 10 * 60 * 1000); //PreviewHistory.init("/"); //updateStateButton(); activatePreviewOverlay(); diff --git a/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts index 34d9f9cb..e10842a8 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/filebrowser.d.ts @@ -1,5 +1,5 @@ export function openFileBrowser(optionsParam: any): Promise; export namespace state { - let options: any; + let options: null; let currentFolder: string; } diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.checkbox.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.checkbox.js index e6997083..3efa4721 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.checkbox.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.checkbox.js @@ -4,7 +4,7 @@ const createCheckboxField = (options, value = []) => { const key = options.key || ""; const name = options.name || id; const title = options.title || ""; - const choices = options.options.choices || []; + const choices = options.options?.choices || []; const selectedValues = new Set(value); const checkboxes = choices.map((choice, idx) => { const inputId = `${id}-${idx}`; @@ -26,7 +26,10 @@ const createCheckboxField = (options, value = []) => { `; }; const getData = (context) => { - const data = {}; + var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='checkbox']").forEach(container => { const name = container.querySelector("input[type='checkbox']").name; const checkedBoxes = container.querySelectorAll("input[type='checkbox']:checked"); diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.color.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.color.js index 23416358..6cfba7fd 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.color.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.color.js @@ -33,6 +33,9 @@ const createColorField = (options, value = '#000000') => { }; const getColorData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='color'] input").forEach((el) => { data[el.name] = { type: 'color', diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.date.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.date.js index 31a16ded..4a090c7e 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.date.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.date.js @@ -41,6 +41,9 @@ const createDateField = (options, value = '') => { }; const getDateData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='date'] input").forEach((el) => { const value = getUTCDateFromInput(el); // "2025-05-31" data[el.name] = { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.datetime.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.datetime.js index bff91c58..8df816d5 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.datetime.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.datetime.js @@ -41,6 +41,9 @@ const createDateTimeField = (options, value = '') => { }; const getDateTimeData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='datetime'] input").forEach((el) => { const value = getUTCDateTimeFromInput(el); // "2025-05-31T15:30" data[el.name] = { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.easymde.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.easymde.js index 7b547663..12e86fe6 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.easymde.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.easymde.js @@ -34,6 +34,9 @@ const createMarkdownField = (options, value = '') => { }; const getData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } markdownEditors.forEach(({ input, editor }) => { data[input.name] = { type: "easymde", diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.list.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.list.js index b520c971..b7b60256 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.list.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.list.js @@ -80,7 +80,7 @@ const handleAddItem = (e, container, context) => { `; listGroup.insertAdjacentHTML("beforeend", itemMarkup); - var itemElement = listGroup.querySelector(`[data-cms-form-field-item="${itemId}"]`); + const itemElement = listGroup.querySelector(`[data-cms-form-field-item="${itemId}"]`); if (itemElement) { itemElement.addEventListener('dblclick', (e) => handleDoubleClick(e, context)); const removeBtn = itemElement.querySelector('.remove-btn'); @@ -99,7 +99,7 @@ const getItemForm = async (el) => { const getContentResponse = await getContent({ uri: contentNode.result.uri }); - var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template); + var selected = pageTemplates.filter((pageTemplate) => pageTemplate.template === getContentResponse?.result?.meta?.template); const listContainer = el.closest("[data-cms-form-field-type='list']"); const fieldName = listContainer?.getAttribute('name'); var itemForm = []; @@ -108,7 +108,7 @@ const getItemForm = async (el) => { } if (!itemForm || itemForm.length === 0) { let itemTypes = (await getListItemTypes({})).result; - var selectedItemType = itemTypes.filter(itemType => itemType.name === fieldName); + var selectedItemType = itemTypes.filter((itemType) => itemType.name === fieldName); itemForm = (selectedItemType.length === 1) ? selectedItemType[0].data?.form.fields : []; } return itemForm; @@ -136,16 +136,22 @@ const handleDoubleClick = async (event, context) => { el.setAttribute('data-cms-form-field-item-data', JSON.stringify(updateData)); const listContainer = el.closest("[data-cms-form-field-type='list']"); const nameField = listContainer?.getAttribute('data-name-field') || 'name'; - el.querySelector('.object-name').textContent = updateData[nameField]; + const objectNameEl = el.querySelector('.object-name'); + if (!objectNameEl) + return; + objectNameEl.textContent = updateData[nameField] || ""; } }); } }; const getData = (context) => { var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach((el) => { let value = []; - el.querySelectorAll("[data-cms-form-field-item]").forEach(itemEl => { + el.querySelectorAll("[data-cms-form-field-item]").forEach((itemEl) => { const itemData = itemEl.getAttribute('data-cms-form-field-item-data'); if (itemData) { value.push(JSON.parse(itemData)); @@ -162,7 +168,7 @@ const getData = (context) => { return data; }; const init = (context) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach(listContainer => { + context.formElement?.querySelectorAll("[data-cms-form-field-type='list']").forEach(listContainer => { listContainer.querySelectorAll("[data-cms-form-field-item]").forEach(field => { field.addEventListener('dblclick', (e) => handleDoubleClick(e, context)); // Remove-Button-Listener setzen diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.mail.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.mail.js index 1ca07c93..cb4651b9 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.mail.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.mail.js @@ -34,6 +34,9 @@ const createEmailField = (options, value = '') => { }; const getData = (context) => { var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='mail'] input").forEach((el) => { let value = el.value; data[el.name] = { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.markdown.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.markdown.js index 0d134d31..8475a02c 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.markdown.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.markdown.js @@ -38,7 +38,11 @@ const createMarkdownField = (options, value = '') => { }; const getData = (context) => { const data = {}; - const editorInputs = context.formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); + const formElement = context.formElement; + if (!formElement) { + return data; + } + const editorInputs = formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); editorInputs.forEach((input) => { const editor = input.cherryEditor; if (editor && editor.getMarkdown) { @@ -58,8 +62,12 @@ const getData = (context) => { return data; }; const init = async (context) => { + const formElement = context.formElement; + if (!formElement) { + return; + } const cmsTagsMenu = await buildCmsTagsMenu(); - const editorInputs = context.formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); + const editorInputs = formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); editorInputs.forEach((input) => { const containerId = input.dataset.cherryId; const initialValue = decodeURIComponent(input.dataset.initialValue || ""); @@ -113,7 +121,7 @@ const getEditorFromEvent = (event) => { const buildCmsTagsMenu = async () => { const response = await getTagNames({}); const tagNames = response.result || []; - const submenuConfig = tagNames.map(tag => ({ + const submenuConfig = tagNames.map((tag) => ({ name: tag.charAt(0).toUpperCase() + tag.slice(1), value: tag, noIcon: true, diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.media.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.media.js index be29625a..653d4ab9 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.media.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.media.js @@ -56,6 +56,9 @@ const createMediaField = (options, value = '') => { }; const getData = (context) => { const data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { const input = wrapper.querySelector(".cms-media-input-value"); if (input) { @@ -68,6 +71,9 @@ const getData = (context) => { return data; }; const init = (context) => { + if (!context.formElement) { + return; + } context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { const dropZone = wrapper.querySelector(".cms-drop-zone"); const input = wrapper.querySelector(".cms-media-input"); @@ -101,7 +107,14 @@ const init = (context) => { //dropZone.addEventListener("click", () => input.click()); // Handle file selection input.addEventListener("change", (e) => { - const file = e.target.files[0]; + if (e.target === null) { + return; + } + var inputElement = e.target; + if (inputElement.files == null) { + return; + } + const file = inputElement.files[0]; if (file) { preview.src = URL.createObjectURL(file); handleUpload(wrapper, file); diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.number.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.number.js index 7111e6fe..10f48128 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.number.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.number.js @@ -37,7 +37,11 @@ const createNumberField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='number'] input").forEach((el) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='number'] input").forEach((el) => { const value = el.value; data[el.name] = { type: 'number', diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.radio.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.radio.js index a3efe892..9412f5aa 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.radio.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.radio.js @@ -47,7 +47,11 @@ const createRadioField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='radio']").forEach(container => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='radio']").forEach(container => { const name = container.querySelector("input[type='radio']").name; const checked = container.querySelector("input[type='radio']:checked"); if (checked) { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.range.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.range.js index 152ed99e..19485a95 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.range.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.range.js @@ -38,7 +38,11 @@ const createRangeField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='range'] input").forEach((el) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='range'] input").forEach((el) => { data[el.name] = { type: 'range', value: parseFloat(el.value) diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.reference.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.reference.js index 8282b938..958b6288 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.reference.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.reference.js @@ -41,7 +41,11 @@ const createReferenceField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='reference'] input").forEach((el) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='reference'] input").forEach((el) => { let value = el.value; data[el.name] = { type: 'reference', @@ -51,7 +55,11 @@ const getData = (context) => { return data; }; const init = (context) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='reference']").forEach(wrapper => { + const formElement = context.formElement; + if (!formElement) { + return; + } + formElement.querySelectorAll("[data-cms-form-field-type='reference']").forEach(wrapper => { const fileManager = wrapper.querySelector(".cms-reference-button"); if (!fileManager) return; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.select.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.select.js index 7ed94d20..9da65446 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.select.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.select.js @@ -41,8 +41,8 @@ const createSelectField = (options, value = '') => { }; const getData = (context) => { const data = {}; - context.formElement - .querySelectorAll("[data-cms-form-field-type='select'] select") + context.formElement?. + querySelectorAll("[data-cms-form-field-type='select'] select") .forEach((el) => { let value = el.value; // optional: type-konvertierung, aber fallback ist immer der echte Wert diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.text.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.text.js index 63b8c357..0c01a797 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.text.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.text.js @@ -34,6 +34,9 @@ const createTextField = (options, value = '') => { }; const getData = (context) => { var data = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='text'] input").forEach((el) => { let value = el.value; data[el.name] = { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/field.textarea.js b/modules/ui-module/src/main/ts/dist/js/modules/form/field.textarea.js index 34581dc5..a26c4bb3 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/field.textarea.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/field.textarea.js @@ -34,6 +34,10 @@ const createTextAreaField = (options, value = '') => { }; const getData = (context) => { var data = {}; + if (context.formElement === null) { + console.error('Form element not found.'); + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='text'] textarea").forEach((el) => { let value = el.value; data[el.name] = { diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/forms.js b/modules/ui-module/src/main/ts/dist/js/modules/form/forms.js index eee7c6d5..ea091c80 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/forms.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/forms.js @@ -45,7 +45,7 @@ const createForm = (options) => { formElement: null, fields: fields }; - const fieldHtml = fields.map(field => { + const fieldHtml = fields.map((field) => { const val = values[field.name] || ''; switch (field.type) { case 'email': @@ -103,6 +103,10 @@ const createForm = (options) => { } container.innerHTML = html; context.formElement = container.querySelector('form'); + if (!context.formElement) { + console.error('Form element not found.'); + return; + } context.formElement.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.tagName.toLowerCase() !== 'textarea') { e.preventDefault(); @@ -111,7 +115,7 @@ const createForm = (options) => { context.formElement.addEventListener('submit', (e) => { e.preventDefault(); e.stopPropagation(); - context.formElement.classList.add('was-validated'); + context.formElement?.classList.add('was-validated'); }); CodeField.init(context); MarkdownField.init(context); diff --git a/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts index 79c944de..525b147b 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/form/utils.d.ts @@ -1,6 +1,6 @@ declare const createID: () => string; declare const utcToLocalDateTimeInputValue: (utcString: string) => string; -declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateTimeFromInput(inputElement: HTMLInputElement): string | null; declare function utcToLocalDateInputValue(utcString: string): string; -declare function getUTCDateFromInput(inputElement: HTMLInputElement): string; +declare function getUTCDateFromInput(inputElement: HTMLInputElement): string | null; export { createID, utcToLocalDateTimeInputValue, getUTCDateTimeFromInput, utcToLocalDateInputValue, getUTCDateFromInput }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts index 0b22efab..9eeed5a1 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/localization.d.ts @@ -1,7 +1,7 @@ export function localizeUi(): Promise; export namespace i18n { let _locale: any; - let _cache: any; + let _cache: null; /** * Loads and merges remote localizations with defaults. */ diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js index 083ac47a..cef4be80 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/manager.message.handlers.js @@ -47,6 +47,16 @@ const executeImageSelect = (payload) => { }; executeScriptAction(cmd); }; +const executeContentImageReplace = (payload) => { + const cmd = { + "module": window.manager.baseUrl + "/actions/media/select-content-media", + "function": "runAction", + "parameters": { + "options": payload.options ? payload.options : {} + } + }; + executeScriptAction(cmd); +}; const initMessageHandlers = () => { frameMessenger.on('preview:reload', (payload) => { }); @@ -88,6 +98,9 @@ const initMessageHandlers = () => { else if (payload.element === "image" && payload.editor === "select") { executeImageSelect(payload); } + else if (payload.element === "image" && payload.editor === "replace") { + executeContentImageReplace(payload); + } else if (payload.element === "image" && payload.editor === "focal-point") { var cmd = { "module": window.manager.baseUrl + "/actions/media/edit-focal-point", diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/media.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/media.inject.js index 193502b6..c89fd259 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/media.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/media.inject.js @@ -84,15 +84,18 @@ export const initContentMediaToolbar = (img) => { return; } var toolbar = img.closest('[data-cms-toolbar]'); - var parentToolbarDef = JSON.parse(toolbar.dataset.cmsToolbar); + var parentToolbarDef = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); if (!parentToolbarDef) { return; } var toolbarDefinition = { "options": { - "uri": parentToolbarDef.uri + "uri": parentToolbarDef.uri, + "start": img.dataset.cmsMdStart || null, + "end": img.dataset.cmsMdEnd || null }, "actions": [ + "replace", "meta", "focalPoint" ] @@ -103,7 +106,7 @@ export const initMediaToolbar = (img) => { if (!isSameDomainImage(img)) { return; } - var toolbarDefinition = JSON.parse(img.dataset.cmsMediaToolbar); + var toolbarDefinition = JSON.parse(img.dataset.cmsMediaToolbar || '{}'); initToolbar(img, toolbarDefinition); }; export const initToolbar = (img, toolbarDefinition) => { @@ -119,6 +122,15 @@ export const initToolbar = (img, toolbarDefinition) => { }); toolbar.appendChild(selectButton); } + if (toolbarDefinition.actions.includes('replace')) { + const replaceButton = document.createElement('button'); + replaceButton.innerHTML = IMAGE_ICON; + replaceButton.setAttribute("title", "Replace media"); + replaceButton.addEventListener('click', (event) => { + replaceMedia(toolbarDefinition.options.start, toolbarDefinition.options.end, toolbarDefinition.options.element, toolbarDefinition.options.uri); + }); + toolbar.appendChild(replaceButton); + } if (toolbarDefinition.actions.includes('meta')) { const metaButton = document.createElement('button'); metaButton.setAttribute('data-cms-action', 'editMediaForm'); @@ -174,6 +186,22 @@ export const initToolbar = (img, toolbarDefinition) => { positionToolbar(); }); }; +const replaceMedia = (start, end, metaElement, uri) => { + var command = { + type: 'edit', + payload: { + editor: "replace", + element: "image", + options: { + metaElement: metaElement, + uri: uri, + start: start, + end: end + } + } + }; + frameMessenger.send(window.parent, command); +}; const selectMedia = (metaElement, uri) => { var command = { type: 'edit', diff --git a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js index 580cdf53..392198a9 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/manager/toolbar.inject.js @@ -22,7 +22,7 @@ import frameMessenger from "@cms/modules/frameMessenger.js"; import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ICON, SECTION_SORT_ICON, SECTION_UNPUBLISHED_ICON } from "@cms/modules/manager/toolbar-icons"; const addSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'add-sectionEntry', payload: { @@ -33,7 +33,7 @@ const addSection = (event) => { }; const deleteSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'delete-sectionEntry', payload: { @@ -44,7 +44,7 @@ const deleteSection = (event) => { }; const setPublishForSection = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var action = event.currentTarget.getAttribute('data-cms-action'); var command = { type: 'section-set-published', @@ -57,7 +57,7 @@ const setPublishForSection = (event) => { }; const orderSections = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'edit-sections', payload: { @@ -68,7 +68,7 @@ const orderSections = (event) => { }; const editContent = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'edit', payload: { @@ -83,7 +83,7 @@ const editContent = (event) => { }; const editAttributes = (event) => { var toolbar = event.target.closest('[data-cms-toolbar]'); - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command = { type: 'edit', payload: { @@ -116,7 +116,7 @@ const editAttributes = (event) => { frameMessenger.send(window.parent, command); }; export const initToolbar = (container) => { - var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar); + var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); if (!toolbarDefinition.actions) { return; } @@ -143,7 +143,7 @@ export const initToolbar = (container) => { toolbar.classList.remove('visible'); } }); - toolbarDefinition.actions.forEach(action => { + toolbarDefinition.actions.forEach((action) => { if (action === "editContent") { const button = document.createElement('button'); button.setAttribute('data-cms-action', 'edit'); diff --git a/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts index 02a1aa10..19f95341 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/preview.history.d.ts @@ -2,6 +2,6 @@ export namespace PreviewHistory { export { init }; export { navigatePreview }; } -declare function init(defaultUrl?: any): void; +declare function init(defaultUrl?: null): void; declare function navigatePreview(url: any, usePush?: boolean): void; export {}; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts index f2f167f3..24beb8ec 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/preview.utils.d.ts @@ -3,4 +3,4 @@ export function deActivatePreviewOverlay(): void; export function getPreviewUrl(): any; export function reloadPreview(): void; export function loadPreview(url: any): void; -export function getPreviewFrame(): HTMLElement; +export function getPreviewFrame(): HTMLElement | null; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.d.ts index f33f4ad9..500f28b2 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.d.ts @@ -1,8 +1,20 @@ +import { RPCResponse } from '@cms/modules/rpc/rpc.js'; declare const getContentNode: (options: any) => Promise; declare const getContent: (options: any) => Promise; declare const setContent: (options: any) => Promise; +export interface ReplaceContent { + error: boolean | null; + uri: string; +} +export interface ReplaceContentOptions { + uri: string; + content: string; + start: number; + end: number; +} +declare const replaceContent: (options: ReplaceContentOptions) => Promise>; declare const setMeta: (options: any) => Promise; declare const setMetaBatch: (options: any) => Promise; declare const addSection: (options: any) => Promise; declare const deleteSection: (options: any) => Promise; -export { getContentNode, getContent, setContent, setMeta, setMetaBatch, addSection, deleteSection }; +export { getContentNode, getContent, setContent, replaceContent, setMeta, setMetaBatch, addSection, deleteSection }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.js b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.js index 285c9e39..e0a97a88 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc-content.js @@ -40,6 +40,13 @@ const setContent = async (options) => { }; return await executeRemoteCall(data); }; +const replaceContent = async (options) => { + var data = { + method: "content.replace", + parameters: options + }; + return await executeRemoteCall(data); +}; const setMeta = async (options) => { var data = { method: "meta.set", @@ -68,4 +75,4 @@ const deleteSection = async (options) => { }; return await executeRemoteCall(data); }; -export { getContentNode, getContent, setContent, setMeta, setMetaBatch, addSection, deleteSection }; +export { getContentNode, getContent, setContent, replaceContent, setMeta, setMetaBatch, addSection, deleteSection }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.d.ts index 285cce37..121bc30e 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.d.ts @@ -2,6 +2,9 @@ interface Options { method: string; parameters?: any; } +export interface RPCResponse { + result: T; +} declare const executeRemoteCall: (options: Options) => Promise; declare const executeRemoteMethodCall: (method: string, parameters: any) => Promise; export { executeRemoteCall, executeRemoteMethodCall }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.js b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.js index d6c1aa58..d1598ff2 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/rpc/rpc.js @@ -28,18 +28,19 @@ const executeRemoteMethodCall = async (method, parameters) => { method: method, parameters: parameters }; + const csrfToken = getCSRFToken(); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - 'X-CSRF-Token': getCSRFToken() + ...(csrfToken && { 'X-CSRF-Token': csrfToken }) }, body: JSON.stringify(data) }); if (response.status === 403) { alert(i18n.t("ui.redirect.login", "You where logged out due to inactivity. Please log in again.")); window.location.href = window.manager.baseUrl + "/login"; - return; + throw new Error("Unauthorized"); } return await response.json(); }; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/state.js b/modules/ui-module/src/main/ts/dist/js/modules/state.js index 7274c0b6..a0988236 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/state.js +++ b/modules/ui-module/src/main/ts/dist/js/modules/state.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts index cf3faa9a..d064b5d4 100644 --- a/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts +++ b/modules/ui-module/src/main/ts/dist/js/modules/ui-state.d.ts @@ -1,10 +1,10 @@ export namespace UIStateManager { function setTabState(key: any, value: any): void; - function getTabState(key: any, defaultValue?: any): any; + function getTabState(key: any, defaultValue?: null): any; function setLocale(locale: any): void; function getLocale(): any; function removeTabState(key: any): void; function setAuthToken(token: any): void; - function getAuthToken(): string; + function getAuthToken(): string | null; function clearAuthToken(): void; } diff --git a/modules/ui-module/src/main/ts/dist/js/modules/wizard.d.ts b/modules/ui-module/src/main/ts/dist/js/modules/wizard.d.ts new file mode 100644 index 00000000..13b63e73 --- /dev/null +++ b/modules/ui-module/src/main/ts/dist/js/modules/wizard.d.ts @@ -0,0 +1,6 @@ +export function openWizard(optionsParam: any): { + wizardId: string; + modalInstance: any; + goToStep: (index: any) => void; + getCurrentStep: () => number; +}; diff --git a/modules/ui-module/src/main/ts/dist/js/modules/wizard.js b/modules/ui-module/src/main/ts/dist/js/modules/wizard.js new file mode 100644 index 00000000..688b9529 --- /dev/null +++ b/modules/ui-module/src/main/ts/dist/js/modules/wizard.js @@ -0,0 +1,207 @@ +/*- + * #%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"; +const defaultOptions = { + title: 'Wizard', + fullscreen: false, + size: null, + showStepIndicator: true, + nextLabel: () => i18n.t('wizard.buttons.next', 'Next'), + prevLabel: () => i18n.t('wizard.buttons.previous', 'Previous'), + finishLabel: () => i18n.t('wizard.buttons.finish', 'Finish'), + cancelLabel: () => i18n.t('wizard.buttons.cancel', 'Cancel'), + validateStep: () => true, +}; +const renderStepBody = (step, containerId) => { + const container = document.getElementById(containerId); + if (!container) + return; + container.innerHTML = ''; + if (typeof step.body === 'function') { + const bodyResult = step.body(); + if (typeof bodyResult === 'string') { + container.innerHTML = bodyResult; + } + else if (bodyResult instanceof HTMLElement) { + container.appendChild(bodyResult); + } + else if (bodyResult && typeof bodyResult.then === 'function') { + bodyResult.then((result) => { + container.innerHTML = typeof result === 'string' ? result : ''; + if (result instanceof HTMLElement) { + container.appendChild(result); + } + }); + } + } + else if (step.body instanceof HTMLElement) { + container.appendChild(step.body); + } + else { + container.innerHTML = step.body || ''; + } +}; +const renderStepIndicator = (steps, currentStep, indicatorContainer) => { + if (!indicatorContainer) + return; + indicatorContainer.innerHTML = ''; + steps.forEach((step, index) => { + const stepNode = document.createElement('div'); + stepNode.className = `wizard-step-item ${index === currentStep ? 'active' : index < currentStep ? 'completed' : ''}`; + stepNode.innerHTML = ` +
      ${index + 1}
      +
      ${step.title || i18n.t('wizard.step', 'Step')} ${index + 1}
      + `; + indicatorContainer.appendChild(stepNode); + }); +}; +const openWizard = (optionsParam) => { + const wizardId = 'wizard_' + Date.now(); + const options = { + ...defaultOptions, + ...optionsParam, + }; + const steps = Array.isArray(options.steps) ? options.steps : []; + let currentStep = 0; + let fullscreen = ''; + if (options.fullscreen) { + fullscreen = 'modal-fullscreen'; + } + let size = ''; + if (options.size) { + size = 'modal-' + options.size; + } + const modalHtml = ` + `; + const container = document.getElementById('modalContainer'); + const modalDiv = document.createElement('div'); + modalDiv.innerHTML = modalHtml.trim(); + const modalNode = modalDiv.firstChild; + container.appendChild(modalNode); + const modalElement = document.getElementById(wizardId); + const modalInstance = new bootstrap.Modal(modalElement, { + backdrop: 'static', + keyboard: true, + focus: true, + }); + const prevBtn = document.getElementById(`${wizardId}_prevBtn`); + const nextBtn = document.getElementById(`${wizardId}_nextBtn`); + const cancelBtn = document.getElementById(`${wizardId}_cancelBtn`); + const stepTitle = document.getElementById(`${wizardId}_stepTitle`); + const stepIndicator = options.showStepIndicator ? document.getElementById(`${wizardId}_stepIndicator`) : null; + const bodyContainerId = `${wizardId}_bodyContainer`; + const updateButtons = () => { + prevBtn.style.display = currentStep === 0 ? 'none' : ''; + nextBtn.textContent = currentStep === steps.length - 1 ? options.finishLabel() : options.nextLabel(); + }; + const renderStep = () => { + const step = steps[currentStep] || {}; + const titleText = step.title || `${i18n.t('wizard.step', 'Step')} ${currentStep + 1}`; + if (stepTitle) { + stepTitle.textContent = titleText; + } + renderStepBody(step, bodyContainerId); + if (options.showStepIndicator && stepIndicator) { + renderStepIndicator(steps, currentStep, stepIndicator); + } + updateButtons(); + if (typeof step.onShow === 'function') { + step.onShow(modalElement); + } + }; + const goToStep = (index) => { + const step = steps[currentStep] || {}; + if (typeof step.validate === 'function' && !step.validate()) { + return; + } + if (typeof options.validateStep === 'function' && !options.validateStep(currentStep)) { + return; + } + if (currentStep !== index && typeof step.onHide === 'function') { + step.onHide(modalElement); + } + currentStep = Math.max(0, Math.min(index, steps.length - 1)); + renderStep(); + if (typeof options.onStepChange === 'function') { + options.onStepChange(currentStep); + } + }; + prevBtn.addEventListener('click', () => goToStep(currentStep - 1)); + cancelBtn.addEventListener('click', () => { + modalInstance.hide(); + if (typeof options.onCancel === 'function') { + options.onCancel(); + } + }); + nextBtn.addEventListener('click', () => { + const step = steps[currentStep] || {}; + const valid = typeof step.validate === 'function' ? step.validate() : true; + if (!valid) { + return; + } + if (currentStep === steps.length - 1) { + modalInstance.hide(); + if (typeof options.onFinish === 'function') { + options.onFinish(); + } + return; + } + goToStep(currentStep + 1); + }); + modalElement.addEventListener('shown.bs.modal', () => { + renderStep(); + if (typeof options.onShow === 'function') { + options.onShow(modalElement); + } + }); + modalElement.addEventListener('hidden.bs.modal', () => { + modalNode.remove(); + if (typeof options.onClose === 'function') { + options.onClose(); + } + }); + modalInstance.show(); + return { + wizardId, + modalInstance, + goToStep, + getCurrentStep: () => currentStep, + }; +}; +export { openWizard }; diff --git a/modules/ui-module/src/main/ts/dist/public/manager-login.js b/modules/ui-module/src/main/ts/dist/public/manager-login.js index 6f21eee9..24aa077a 100644 --- a/modules/ui-module/src/main/ts/dist/public/manager-login.js +++ b/modules/ui-module/src/main/ts/dist/public/manager-login.js @@ -1,3 +1,4 @@ +"use strict"; /*- * #%L * UI Module diff --git a/modules/ui-module/src/main/ts/globals.d.ts b/modules/ui-module/src/main/ts/globals.d.ts index 714f0a99..e74d4360 100644 --- a/modules/ui-module/src/main/ts/globals.d.ts +++ b/modules/ui-module/src/main/ts/globals.d.ts @@ -12,6 +12,7 @@ declare global { contextPath: string, siteId: string, previewUrl: string, + refreshUrl: string, }, EasyMDE : any, Cherry: any diff --git a/modules/ui-module/src/main/ts/src/actions/media/edit-focal-point.ts b/modules/ui-module/src/main/ts/src/actions/media/edit-focal-point.ts index 7c8b57cc..4bbdef76 100644 --- a/modules/ui-module/src/main/ts/src/actions/media/edit-focal-point.ts +++ b/modules/ui-module/src/main/ts/src/actions/media/edit-focal-point.ts @@ -25,7 +25,7 @@ import { reloadPreview } from "@cms/modules/preview.utils.js"; import { getMediaMetaData, setMediaMetaData } from "@cms/modules/rpc/rpc-media.js"; import { showToast } from "@cms/modules/toast.js"; -export async function runAction(params) { +export async function runAction(params : any) { var uri = params.options.uri || null; var mediaUrl = removeFormatParamFromUrl(uri); @@ -46,8 +46,8 @@ export async function runAction(params) { openModal({ title: i18n.t("media.focal.title", "Edit focal point"), body: template, - onCancel: (event) => { }, - onOk: async (event) => { + onCancel: (event : any) => { }, + onOk: async (event : any) => { var setMetaResponse = await setMediaMetaData({ image: mediaUrl, meta: { @@ -70,10 +70,15 @@ export async function runAction(params) { reloadPreview(); }, onShow: () => { - const wrapper: HTMLElement = document.getElementById("cmsFocalWrapper"); - const image: HTMLImageElement = document.getElementById("cms-image") as HTMLImageElement; - const point: HTMLElement = document.getElementById("cmsFocalPoint"); + const wrapper: HTMLElement | null = document.getElementById("cmsFocalWrapper"); + const image: HTMLImageElement | null = document.getElementById("cms-image") as HTMLImageElement; + const point: HTMLElement | null = document.getElementById("cmsFocalPoint"); + if (wrapper === null || image === null || point === null) { + console.error("One or more required elements not found"); + return; + } + if (image.complete) { setFocalPoint(image, point, focalX, focalY); } else { diff --git a/modules/ui-module/src/main/ts/src/actions/media/select-content-media.ts b/modules/ui-module/src/main/ts/src/actions/media/select-content-media.ts new file mode 100644 index 00000000..0914c8f1 --- /dev/null +++ b/modules/ui-module/src/main/ts/src/actions/media/select-content-media.ts @@ -0,0 +1,89 @@ +/*- + * #%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 { openFileBrowser } from "@cms/modules/filebrowser.js"; +import { i18n } from "@cms/modules/localization.js"; +import { getPreviewUrl, reloadPreview } from "@cms/modules/preview.utils.js"; +import { getContentNode, replaceContent, ReplaceContentOptions } from "@cms/modules/rpc/rpc-content.js"; +import { showToast } from "@cms/modules/toast.js"; +import { openWizard } from "@cms/modules/wizard.js"; + +export async function runAction(params : any) { + + + var uri : any = null + if (params.options.uri) { + uri = params.options.uri + } else { + const contentNode = await getContentNode({ + url: getPreviewUrl() + }) + uri = contentNode.result.uri + } + + openFileBrowser({ + type: "assets", + filter : (file: any) => { + return file.media || file.directory; + }, + onSelect: async (file: any) => { + + if (file && file.uri) { + + var selectedFile = file.uri; // Use the file's URI + if (file.uri.startsWith("/")) { + selectedFile = file.uri.substring(1); // Remove leading slash if present + } + + var updateData : any = {} + updateData[params.options.metaElement] = { + type: 'media', + value: selectedFile + } + var options: ReplaceContentOptions = { + uri : uri, + content: selectedFile, + start: params.options.start, + end: params.options.end + } + + var replaceMedia = await replaceContent(options) + if (replaceMedia.result.error != null && replaceMedia.result.error === true) { + showToast({ + title: i18n.t('manager.actions.media.select-content-media.toast.title-error', "Media not updated"), + message: i18n.t('manager.actions.media.select-content-media.toast.message-error', "New media has not been updated successfully."), + type: 'error', // optional: info | success | warning | error + timeout: 3000 + }); + reloadPreview() + } else { + showToast({ + title: i18n.t('manager.actions.media.select-media.toast.title', "Media updated"), + message: i18n.t('manager.actions.media.select-media.toast.message', "New media has been updated successfully."), + type: 'success', // optional: info | success | warning | error + timeout: 3000 + }); + reloadPreview() + } + } + } + }) +} diff --git a/modules/ui-module/src/main/ts/src/actions/media/select-media.ts b/modules/ui-module/src/main/ts/src/actions/media/select-media.ts index 2bb2bb4d..08cbb96b 100644 --- a/modules/ui-module/src/main/ts/src/actions/media/select-media.ts +++ b/modules/ui-module/src/main/ts/src/actions/media/select-media.ts @@ -25,10 +25,10 @@ import { getPreviewUrl, reloadPreview } from "@cms/modules/preview.utils.js"; import { getContentNode, setMeta } from "@cms/modules/rpc/rpc-content.js"; import { showToast } from "@cms/modules/toast.js"; -export async function runAction(params) { +export async function runAction(params : any) { - var uri = null + var uri : any = null if (params.options.uri) { uri = params.options.uri } else { @@ -40,7 +40,7 @@ export async function runAction(params) { openFileBrowser({ type: "assets", - filter : (file) => { + filter : (file: any) => { return file.media || file.directory; }, onSelect: async (file: any) => { @@ -52,7 +52,7 @@ export async function runAction(params) { selectedFile = file.uri.substring(1); // Remove leading slash if present } - var updateData = {} + var updateData : any = {} updateData[params.options.metaElement] = { type: 'media', value: selectedFile diff --git a/modules/ui-module/src/main/ts/src/actions/page/section-set-published.ts b/modules/ui-module/src/main/ts/src/actions/page/section-set-published.ts index 62ef9c5e..570856ff 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/section-set-published.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/section-set-published.ts @@ -22,7 +22,7 @@ import { reloadPreview } from "@cms/modules/preview.utils"; import { setMeta } from "@cms/modules/rpc/rpc-content"; -export async function runAction(params) { +export async function runAction(params : any) { var request = { uri : params.sectionUri, diff --git a/modules/ui-module/src/main/ts/src/actions/page/translations.ts b/modules/ui-module/src/main/ts/src/actions/page/translations.ts index f86790dd..5a374dd2 100644 --- a/modules/ui-module/src/main/ts/src/actions/page/translations.ts +++ b/modules/ui-module/src/main/ts/src/actions/page/translations.ts @@ -40,13 +40,13 @@ export async function runAction(params: any) { openModal({ title: 'Manage Translations', body: modelContent, - onCancel: (event) => { }, - onOk: async (event) => { + onCancel: (event : any) => { }, + onOk: async (event : any) => { }, - onShow: async (modalElement) => { + onShow: async (modalElement : any) => { - modalElement.querySelectorAll('button[data-action]').forEach(button => { - button.addEventListener('click', async (e) => { + modalElement.querySelectorAll('button[data-action]').forEach((button : HTMLElement) => { + button.addEventListener('click', async (e : any) => { const action = (e.currentTarget as HTMLElement).getAttribute('data-action'); const siteId = (e.currentTarget as HTMLElement).getAttribute('data-id'); const lang = (e.currentTarget as HTMLElement).getAttribute('data-lang'); @@ -55,7 +55,7 @@ export async function runAction(params: any) { openFileBrowser({ siteId: siteId || '', type: 'content', - onSelect: async (file) => { + onSelect: async (file : any) => { console.log('Selected translation file:', file); if (file && file.uri) { var selectedFile = file.uri; // Use the file's URI diff --git a/modules/ui-module/src/main/ts/src/actions/reload-preview.ts b/modules/ui-module/src/main/ts/src/actions/reload-preview.ts index f87a6c05..cda1908c 100644 --- a/modules/ui-module/src/main/ts/src/actions/reload-preview.ts +++ b/modules/ui-module/src/main/ts/src/actions/reload-preview.ts @@ -21,6 +21,6 @@ import { reloadPreview } from "@cms/modules/preview.utils.js"; -export async function runAction(params) { +export async function runAction(params : any) { reloadPreview(); } diff --git a/modules/ui-module/src/main/ts/src/js/manager.js b/modules/ui-module/src/main/ts/src/js/manager.js index 00ae3176..f0a3c73c 100644 --- a/modules/ui-module/src/main/ts/src/js/manager.js +++ b/modules/ui-module/src/main/ts/src/js/manager.js @@ -34,9 +34,23 @@ frameMessenger.on('load', (payload) => { EventBus.emit("preview:loaded", {}); }); +function heartbeat() { + fetch(window.manager.refreshUrl, { + method: "POST", + credentials: "include" + }) + .then(res => res.json()) + .then(data => { + window.manager.previewToken = data.previewToken; + }); +} document.addEventListener("DOMContentLoaded", function () { + setInterval(() => { + heartbeat(); + }, 10 * 60 * 1000); + //PreviewHistory.init("/"); //updateStateButton(); diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.checkbox.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.checkbox.ts index 580b8df7..40924e31 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.checkbox.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.checkbox.ts @@ -31,13 +31,13 @@ export interface CheckboxOptions extends FieldOptions{ }; } -const createCheckboxField = (options : CheckboxOptions, value = []) => { +const createCheckboxField = (options : CheckboxOptions, value: string[] = []) => { const id = createID(); const key = options.key || ""; const name = options.name || id; const title = options.title || ""; - const choices = options.options.choices || []; - const selectedValues = new Set(value); + const choices = options.options?.choices || []; + const selectedValues = new Set(value); const checkboxes = choices.map((choice, idx) => { const inputId = `${id}-${idx}`; @@ -61,11 +61,14 @@ const createCheckboxField = (options : CheckboxOptions, value = []) => { }; const getData = (context : FormContext) => { - const data = {}; + var data : any = {}; + if (!context.formElement) { + return data; + } context.formElement.querySelectorAll("[data-cms-form-field-type='checkbox']").forEach(container => { const name = (container.querySelector("input[type='checkbox']") as HTMLInputElement).name; const checkedBoxes = container.querySelectorAll("input[type='checkbox']:checked"); - const values = Array.from(checkedBoxes).map((el : HTMLInputElement) => el.value); + const values = Array.from(checkedBoxes).map((el : any) => el.value); data[name] = { type: 'checkbox', value: values diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.color.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.color.ts index 42c4b673..fc25659a 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.color.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.color.ts @@ -39,8 +39,11 @@ const createColorField = (options: ColorFieldOptions, value = '#000000') => { }; const getColorData = (context : FormContext) => { - const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='color'] input").forEach((el: HTMLInputElement ) => { + const data : any = {}; + if (!context.formElement) { + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='color'] input").forEach((el: any ) => { data[el.name] = { type: 'color', value: el.value diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.date.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.date.ts index edd4e717..9991dd43 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.date.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.date.ts @@ -51,9 +51,11 @@ const createDateField = (options: DateFieldOptions, value : any = '') => { const getDateData = (context : FormContext) => { - const data = {}; - - context.formElement.querySelectorAll("[data-cms-form-field-type='date'] input").forEach((el: HTMLInputElement) => { + const data : any = {}; + if (!context.formElement) { + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='date'] input").forEach((el: any) => { const value = getUTCDateFromInput(el); // "2025-05-31" data[el.name] = { type: "date", diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.datetime.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.datetime.ts index fdea9b84..7fad1308 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.datetime.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.datetime.ts @@ -51,9 +51,11 @@ const createDateTimeField = (options: DateTimeFieldOptions, value : any = '') => const getDateTimeData = (context : FormContext) => { - const data = {}; - - context.formElement.querySelectorAll("[data-cms-form-field-type='datetime'] input").forEach((el: HTMLInputElement) => { + const data : any = {}; + if (!context.formElement) { + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='datetime'] input").forEach((el: any) => { const value = getUTCDateTimeFromInput(el); // "2025-05-31T15:30" data[el.name] = { type: 'datetime', diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.easymde.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.easymde.ts index 2a08cf5a..9c803e47 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.easymde.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.easymde.ts @@ -22,7 +22,12 @@ import { createID } from "@cms/modules/form/utils.js"; import { i18n } from "@cms/modules/localization.js" import { FieldOptions, FormContext, FormField } from "@cms/modules/form/forms.js"; -let markdownEditors = []; +interface MarkdownEditorEntry { + input: HTMLTextAreaElement; + editor: any; +} + +let markdownEditors: MarkdownEditorEntry[] = []; export interface EasyMDEFieldOptions extends FieldOptions { } @@ -40,8 +45,11 @@ const createMarkdownField = (options : EasyMDEFieldOptions, value : string = '') }; const getData = (context : FormContext) => { - const data = {}; - markdownEditors.forEach(({ input, editor }) => { + const data : any = {}; + if (!context.formElement) { + return data; + } + markdownEditors.forEach(({ input , editor }) => { data[input.name] = { type: "easymde", value: editor.value() @@ -54,7 +62,7 @@ const init = (context : FormContext) => { markdownEditors = []; const editorInputs = document.querySelectorAll('[data-cms-form-field-type="easymde"] textarea'); - editorInputs.forEach((input: HTMLTextAreaElement) => { + editorInputs.forEach((input: any) => { const initialValue = decodeURIComponent(input.dataset.initialValue || ""); input.value = initialValue; // Set initial value for EasyMDE diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts index c4730849..5d9675bd 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.list.ts @@ -96,7 +96,7 @@ const handleAddItem = (e: Event, container: HTMLElement, context: FormContext) = listGroup.insertAdjacentHTML("beforeend", itemMarkup); - var itemElement: HTMLElement = listGroup.querySelector(`[data-cms-form-field-item="${itemId}"]`) + const itemElement: HTMLElement | null = listGroup.querySelector(`[data-cms-form-field-item="${itemId}"]`) if (itemElement) { itemElement.addEventListener('dblclick', (e) => handleDoubleClick(e, context)); @@ -122,7 +122,7 @@ const getItemForm = async (el: HTMLElement) => { uri: contentNode.result.uri }) - var selected = pageTemplates.filter(pageTemplate => pageTemplate.template === getContentResponse?.result?.meta?.template) + var selected = pageTemplates.filter((pageTemplate : any) => pageTemplate.template === getContentResponse?.result?.meta?.template) const listContainer = el.closest("[data-cms-form-field-type='list']"); const fieldName = listContainer?.getAttribute('name'); @@ -134,7 +134,7 @@ const getItemForm = async (el: HTMLElement) => { if (!itemForm || itemForm.length === 0) { let itemTypes = (await getListItemTypes({})).result - var selectedItemType = itemTypes.filter(itemType => itemType.name === fieldName) + var selectedItemType = itemTypes.filter((itemType : any) => itemType.name === fieldName) itemForm = (selectedItemType.length === 1) ? selectedItemType[0].data?.form.fields : [] } @@ -162,25 +162,29 @@ const handleDoubleClick = async (event: Event, context: FormContext) => { title: 'Edit Item', fullscreen: true, form: form, - onCancel: (event) => { }, - onOk: async (event) => { + onCancel: (event: Event) => { }, + onOk: async (event: Event) => { var updateData = form.getRawData() el.setAttribute('data-cms-form-field-item-data', JSON.stringify(updateData)); const listContainer = el.closest("[data-cms-form-field-type='list']"); const nameField = listContainer?.getAttribute('data-name-field') || 'name'; - - el.querySelector('.object-name').textContent = updateData[nameField]; + const objectNameEl = el.querySelector('.object-name'); + if (!objectNameEl) return; + objectNameEl.textContent = updateData[nameField] || ""; } }); } } const getData = (context: FormContext) => { - var data = {} - context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach((el: HTMLInputElement) => { - let value = [] - el.querySelectorAll("[data-cms-form-field-item]").forEach(itemEl => { + var data : any = {}; + if (!context.formElement) { + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach((el: any) => { + let value : any = [] + el.querySelectorAll("[data-cms-form-field-item]").forEach((itemEl: any) => { const itemData = itemEl.getAttribute('data-cms-form-field-item-data'); if (itemData) { value.push(JSON.parse(itemData)); @@ -198,7 +202,7 @@ const getData = (context: FormContext) => { } const init = (context: FormContext) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='list']").forEach(listContainer => { + context.formElement?.querySelectorAll("[data-cms-form-field-type='list']").forEach(listContainer => { listContainer.querySelectorAll("[data-cms-form-field-item]").forEach(field => { field.addEventListener('dblclick', (e) => handleDoubleClick(e, context)); // Remove-Button-Listener setzen diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.mail.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.mail.ts index 66bf161f..ca44fcad 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.mail.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.mail.ts @@ -41,8 +41,11 @@ const createEmailField = (options: MailFieldOptions, value : string = '') => { }; const getData = (context : FormContext) => { - var data = {} - context.formElement.querySelectorAll("[data-cms-form-field-type='mail'] input").forEach((el : HTMLInputElement) => { + var data : any = {}; + if (!context.formElement) { + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='mail'] input").forEach((el : any) => { let value = el.value data[el.name] = { type: 'mail', diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.markdown.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.markdown.ts index fa5bc752..85e728dc 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.markdown.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.markdown.ts @@ -52,11 +52,15 @@ const createMarkdownField = (options: MarkdownFieldOptions, value: string = '') }; const getData = (context : FormContext) => { - const data = {}; + const data : any = {}; + const formElement = context.formElement; + if (!formElement) { + return data; + } - const editorInputs = context.formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); + const editorInputs = formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); - editorInputs.forEach((input: HTMLInputElement) => { + editorInputs.forEach((input: any) => { const editor = (input as any).cherryEditor; if (editor && editor.getMarkdown) { @@ -78,11 +82,15 @@ const getData = (context : FormContext) => { const init = async (context : FormContext) => { + const formElement = context.formElement; + if (!formElement) { + return; + } const cmsTagsMenu = await buildCmsTagsMenu(); - const editorInputs = context.formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); - editorInputs.forEach((input: HTMLInputElement) => { + const editorInputs = formElement.querySelectorAll('[data-cms-form-field-type="markdown"] input'); + editorInputs.forEach((input: any) => { const containerId = input.dataset.cherryId; const initialValue = decodeURIComponent(input.dataset.initialValue || ""); @@ -144,7 +152,7 @@ const buildCmsTagsMenu = async () => { const response = await getTagNames({}); const tagNames = response.result || []; - const submenuConfig = tagNames.map(tag => ({ + const submenuConfig = tagNames.map((tag: string) => ({ name: tag.charAt(0).toUpperCase() + tag.slice(1), value: tag, noIcon: true, @@ -158,7 +166,7 @@ const buildCmsTagsMenu = async () => { return window.Cherry.createMenuHook("CMS-Tags", { title: "CMS Tags", - onClick: (selection, tag) => { + onClick: (selection: string, tag : string) => { return `[[${tag}]]${selection || ""}[[/${tag}]]`; }, subMenuConfig: submenuConfig @@ -176,7 +184,7 @@ const cmsImageSelection = window.Cherry.createMenuHook("Image", { openFileBrowser({ type: "assets", fullscreen: false, - filter: (file) => { + filter: (file: any) => { return file.media || file.directory; }, onSelect: async (file: any) => { @@ -194,7 +202,7 @@ const cmsImageSelection = window.Cherry.createMenuHook("Image", { // select media format var mediaFormats = (await getMediaFormats({})).result || []; - var formatOptions = {}; + var formatOptions : any = {}; formatOptions["original"] = "Original"; mediaFormats.forEach((format : any) => { formatOptions[format.name] = format.name; diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.media.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.media.ts index ee475949..76a0a242 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.media.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.media.ts @@ -61,8 +61,12 @@ const createMediaField = (options: MediaFieldOptions, value : string = '') => { }; const getData = (context : FormContext) => { - const data = {}; + const data : any= {}; + if (!context.formElement) { + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { const input = wrapper.querySelector(".cms-media-input-value") as HTMLInputElement; if (input) { @@ -76,7 +80,11 @@ const getData = (context : FormContext) => { }; const init = (context : FormContext) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { + if (!context.formElement) { + return; + } + + context.formElement.querySelectorAll("[data-cms-form-field-type='media']").forEach(wrapper => { const dropZone = wrapper.querySelector(".cms-drop-zone"); const input = wrapper.querySelector(".cms-media-input") as HTMLInputElement; const preview = wrapper.querySelector(".cms-media-image") as HTMLImageElement; @@ -85,18 +93,18 @@ const init = (context : FormContext) => { if (!input || !dropZone || !preview || !openMediaManager) return; // Handle file drop - dropZone.addEventListener("dragover", (e) => { + dropZone.addEventListener("dragover", (e : any) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.add("drag-over"); }); - dropZone.addEventListener("dragleave", (e) => { + dropZone.addEventListener("dragleave", (e : any) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); }); - dropZone.addEventListener("drop", (e : DragEvent) => { + dropZone.addEventListener("drop", (e : any) => { e.preventDefault(); e.stopPropagation(); dropZone.classList.remove("drag-over"); @@ -113,7 +121,14 @@ const init = (context : FormContext) => { // Handle file selection input.addEventListener("change", (e: Event) => { - const file = (e.target as HTMLInputElement).files[0]; + if (e.target === null) { + return; + } + var inputElement = e.target as HTMLInputElement; + if (inputElement.files == null) { + return; + } + const file = inputElement.files[0]; if (file) { preview.src = URL.createObjectURL(file); handleUpload(wrapper, file); @@ -125,7 +140,7 @@ const init = (context : FormContext) => { openMediaManager.onclick = () => { openFileBrowser({ type: "assets", - filter : (file) => { + filter : (file : any) => { return file.media || file.directory; }, onSelect: (file : any) => { @@ -152,21 +167,21 @@ const init = (context : FormContext) => { }); }; -const handleUpload = (wrapper, file) => { +const handleUpload = (wrapper : any, file : any) => { const inputValue = wrapper.querySelector(".cms-media-input-value"); uploadFileWithProgress({ uploadEndpoint: "/manager/upload2", file: file, uri: "not relevant for media fields", - onProgress: (percent) => { + onProgress: (percent: number) => { console.log(`Upload progress: ${percent}%`); }, - onSuccess: (data) => { + onSuccess: (data: any) => { if (data.filename) { inputValue.value = data.filename; // Set the input value to the uploaded file's name } }, - onError: (error) => { + onError: (error: any) => { console.error("Upload failed:", error); } }); diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.number.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.number.ts index d9bd294a..2560d80a 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.number.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.number.ts @@ -50,8 +50,12 @@ const createNumberField = (options: NumberFieldOptions, value: string = '') => { }; const getData = (context : FormContext) => { - const data = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='number'] input").forEach((el : HTMLInputElement) => { + const data : any = {}; + const formElement = context.formElement; + if (!formElement) { + return data; + } + formElement.querySelectorAll("[data-cms-form-field-type='number'] input").forEach((el : any) => { const value = el.value; data[el.name] = { type: 'number', diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.radio.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.radio.ts index ae620530..d4bfdaf9 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.radio.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.radio.ts @@ -60,9 +60,13 @@ const createRadioField = (options: RadioFieldOptions, value: string = '') => { }; const getData = (context : FormContext) => { - const data = {}; - - context.formElement.querySelectorAll("[data-cms-form-field-type='radio']").forEach(container => { + const data : any = {}; + const formElement = context.formElement; + if (!formElement) { + return data; + } + + formElement.querySelectorAll("[data-cms-form-field-type='radio']").forEach(container => { const name = (container.querySelector("input[type='radio']") as HTMLInputElement).name; const checked = container.querySelector("input[type='radio']:checked") as HTMLInputElement; if (checked) { diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.range.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.range.ts index 4723f815..d538058b 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.range.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.range.ts @@ -49,9 +49,13 @@ const createRangeField = (options: RangeFieldOptions, value : string = '') => { }; const getData = (context : FormContext) => { - const data = {}; - - context.formElement.querySelectorAll("[data-cms-form-field-type='range'] input").forEach((el : HTMLInputElement) => { + const data : any = {}; + const formElement = context.formElement; + if (!formElement) { + return data; + } + + formElement.querySelectorAll("[data-cms-form-field-type='range'] input").forEach((el : any) => { data[el.name] = { type: 'range', value: parseFloat(el.value) diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.reference.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.reference.ts index bfbafd1b..05dd25d0 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.reference.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.reference.ts @@ -50,9 +50,14 @@ const createReferenceField = (options: ReferenceFieldOptions, value: string = '' }; const getData = (context: FormContext) => { - const data = {}; + const data : any = {}; - context.formElement.querySelectorAll("[data-cms-form-field-type='reference'] input").forEach((el: HTMLInputElement) => { + const formElement = context.formElement; + if (!formElement) { + return data; + } + + formElement.querySelectorAll("[data-cms-form-field-type='reference'] input").forEach((el: any) => { let value = el.value data[el.name] = { type: 'reference', @@ -63,7 +68,12 @@ const getData = (context: FormContext) => { }; const init = (context: FormContext) => { - context.formElement.querySelectorAll("[data-cms-form-field-type='reference']").forEach(wrapper => { + const formElement = context.formElement; + if (!formElement) { + return; + } + + formElement.querySelectorAll("[data-cms-form-field-type='reference']").forEach(wrapper => { const fileManager = wrapper.querySelector(".cms-reference-button") as HTMLButtonElement; if (!fileManager) return; @@ -81,7 +91,7 @@ const init = (context: FormContext) => { openFileBrowser({ type: "content", siteid: siteid, - filter: (file) => { + filter: (file : any) => { return file.content || file.directory; }, onSelect: (file: any) => { diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.select.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.select.ts index 8c180a9e..e77f2d2f 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.select.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.select.ts @@ -52,9 +52,9 @@ const createSelectField = (options: SelectFieldOptions, value: string = '') => { const getData = (context : FormContext) => { const data: Record = {}; - context.formElement - .querySelectorAll("[data-cms-form-field-type='select'] select") - .forEach((el: HTMLSelectElement) => { + context.formElement?. + querySelectorAll("[data-cms-form-field-type='select'] select") + .forEach((el: any) => { let value: any = el.value; // optional: type-konvertierung, aber fallback ist immer der echte Wert diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.text.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.text.ts index 6b9a8f57..37a8fc83 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.text.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.text.ts @@ -40,8 +40,11 @@ const createTextField = (options: TextFieldOptions, value : string = '') => { }; const getData = (context : FormContext) => { - var data = {} - context.formElement.querySelectorAll("[data-cms-form-field-type='text'] input").forEach((el : HTMLInputElement) => { + var data : any = {} + if (!context.formElement) { + return data + } + context.formElement.querySelectorAll("[data-cms-form-field-type='text'] input").forEach((el : any) => { let value = el.value data[el.name] = { type: 'text', diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/field.textarea.ts b/modules/ui-module/src/main/ts/src/js/modules/form/field.textarea.ts index d8ae48f7..7af74fd8 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/field.textarea.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/field.textarea.ts @@ -40,8 +40,12 @@ const createTextAreaField = (options: TextAreaFieldOptions, value : string = '') }; const getData = (context : FormContext) => { - var data = {} - context.formElement.querySelectorAll("[data-cms-form-field-type='text'] textarea").forEach((el : HTMLInputElement) => { + var data : any = {} + if (context.formElement === null) { + console.error('Form element not found.'); + return data; + } + context.formElement.querySelectorAll("[data-cms-form-field-type='text'] textarea").forEach((el : any) => { let value = el.value data[el.name] = { type: 'textarea', 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 3f60415b..7f1bbe53 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 @@ -39,7 +39,7 @@ import { TextAreaField } from "@cms/modules/form/field.textarea.js"; import { ReferenceField } from "@cms/modules/form/field.reference.js"; -const createForm = (options) : Form => { +const createForm = (options : any) : Form => { const fields = options.fields || []; const values = options.values || {}; const formId = createID(); @@ -49,7 +49,7 @@ const createForm = (options) : Form => { fields: fields } - const fieldHtml = fields.map(field => { + const fieldHtml = fields.map((field : any) => { const val = values[field.name] || ''; switch (field.type) { case 'email': @@ -99,7 +99,7 @@ const createForm = (options) : Form => { `; - const init = (container) => { + const init = (container : any) => { if (typeof container === 'string') { container = document.querySelector(container); } @@ -110,6 +110,11 @@ const createForm = (options) : Form => { container.innerHTML = html; context.formElement = container.querySelector('form'); + if (!context.formElement) { + console.error('Form element not found.'); + return; + } + context.formElement.addEventListener('keydown', (e : KeyboardEvent) => { if (e.key === 'Enter' && (e.target as HTMLElement).tagName.toLowerCase() !== 'textarea') { e.preventDefault(); @@ -119,7 +124,7 @@ const createForm = (options) : Form => { context.formElement.addEventListener('submit', (e) => { e.preventDefault(); e.stopPropagation(); - context.formElement.classList.add('was-validated'); + context.formElement?.classList.add('was-validated'); }); CodeField.init(context) MarkdownField.init(context) @@ -166,12 +171,12 @@ const createForm = (options) : Form => { }; }; -const flattenFormData = (input) => { +const flattenFormData = (input : any) => { const result = {}; for (const key in input) { const value = input[key].value; const parts = key.split("."); - let current = result; + let current : any = result; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === parts.length - 1) { diff --git a/modules/ui-module/src/main/ts/src/js/modules/form/utils.ts b/modules/ui-module/src/main/ts/src/js/modules/form/utils.ts index f4b333f4..4ad694bb 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/form/utils.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/form/utils.ts @@ -24,7 +24,7 @@ const utcToLocalDateTimeInputValue = (utcString : string) => { const date = new Date(utcString); if (isNaN(date.getTime())) return ""; - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n : any) => String(n).padStart(2, '0'); const yyyy = date.getFullYear(); const MM = pad(date.getMonth() + 1); 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 9744751e..712af5f3 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 @@ -50,6 +50,17 @@ const executeImageSelect = (payload: any) => { executeScriptAction(cmd); } +const executeContentImageReplace = (payload: any) => { + const cmd: any = { + "module": window.manager.baseUrl + "/actions/media/select-content-media", + "function": "runAction", + "parameters": { + "options": payload.options ? payload.options : {} + } + } + executeScriptAction(cmd); +} + const initMessageHandlers = () => { frameMessenger.on('preview:reload', (payload: any) => { @@ -89,6 +100,8 @@ const initMessageHandlers = () => { executeImageForm(payload); } else if (payload.element === "image" && payload.editor === "select") { executeImageSelect(payload); + } else if (payload.element === "image" && payload.editor === "replace") { + executeContentImageReplace(payload); } else if (payload.element === "image" && payload.editor === "focal-point") { var cmd: any = { "module": window.manager.baseUrl + "/actions/media/edit-focal-point", @@ -117,7 +130,7 @@ const initMessageHandlers = () => { executeScriptAction(cmd) } }); - frameMessenger.on('edit-sections', (payload) => { + frameMessenger.on('edit-sections', (payload : any) => { var cmd : any= { "module": window.manager.baseUrl + "/actions/page/edit-sections", "function": "runAction", diff --git a/modules/ui-module/src/main/ts/src/js/modules/manager/media.inject.ts b/modules/ui-module/src/main/ts/src/js/modules/manager/media.inject.ts index 4e8f2848..9887d106 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/manager/media.inject.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/manager/media.inject.ts @@ -21,7 +21,7 @@ import { EDIT_ATTRIBUTES_ICON, IMAGE_ICON, MEDIA_CROP_ICON } from "@cms/modules/manager/toolbar-icons"; import frameMessenger from '@cms/modules/frameMessenger.js'; -const isSameDomainImage = (imgElement) => { +const isSameDomainImage = (imgElement : HTMLImageElement) => { if (!(imgElement instanceof HTMLImageElement)) { return false; // ist kein } @@ -78,7 +78,7 @@ export const initMediaUploadOverlay = (img: HTMLImageElement) => { } }); - overlay.addEventListener('click', (e) => { + overlay.addEventListener('click', (e: any) => { selectMedia(img.dataset.cmsMetaElement, img.dataset.cmsNodeUri); }); @@ -98,7 +98,7 @@ export const initContentMediaToolbar = (img: HTMLImageElement) => { } var toolbar = img.closest('[data-cms-toolbar]') as HTMLElement; - var parentToolbarDef = JSON.parse(toolbar.dataset.cmsToolbar); + var parentToolbarDef = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); if (!parentToolbarDef) { return; @@ -107,9 +107,12 @@ export const initContentMediaToolbar = (img: HTMLImageElement) => { var toolbarDefinition = { "options": { - "uri": parentToolbarDef.uri + "uri": parentToolbarDef.uri, + "start": img.dataset.cmsMdStart || null, + "end": img.dataset.cmsMdEnd || null }, "actions": [ + "replace", "meta", "focalPoint" ] @@ -123,7 +126,7 @@ export const initMediaToolbar = (img: HTMLImageElement) => { return; } - var toolbarDefinition = JSON.parse(img.dataset.cmsMediaToolbar); + var toolbarDefinition = JSON.parse(img.dataset.cmsMediaToolbar || '{}'); initToolbar(img, toolbarDefinition); }; @@ -142,6 +145,15 @@ export const initToolbar = (img: HTMLImageElement, toolbarDefinition: any) => { }); toolbar.appendChild(selectButton); } + if (toolbarDefinition.actions.includes('replace')) { + const replaceButton = document.createElement('button'); + replaceButton.innerHTML = IMAGE_ICON; + replaceButton.setAttribute("title", "Replace media"); + replaceButton.addEventListener('click', (event) => { + replaceMedia(toolbarDefinition.options.start, toolbarDefinition.options.end, toolbarDefinition.options.element, toolbarDefinition.options.uri); + }); + toolbar.appendChild(replaceButton); + } if (toolbarDefinition.actions.includes('meta')) { const metaButton = document.createElement('button'); metaButton.setAttribute('data-cms-action', 'editMediaForm'); @@ -203,7 +215,24 @@ export const initToolbar = (img: HTMLImageElement, toolbarDefinition: any) => { }); }; -const selectMedia = (metaElement: string, uri?: string) => { +const replaceMedia = (start : number, end : number, metaElement?: string, uri?: string) => { + var command = { + type: 'edit', + payload: { + editor: "replace", + element: "image", + options: { + metaElement: metaElement, + uri: uri, + start: start, + end: end + } + } + } + frameMessenger.send(window.parent, command); +} + +const selectMedia = (metaElement?: string, uri?: string) => { var command = { type: 'edit', payload: { 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 12cfbf9b..27385e33 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 @@ -23,7 +23,7 @@ import { EDIT_ATTRIBUTES_ICON, EDIT_PAGE_ICON, SECTION_ADD_ICON, SECTION_DELETE_ const addSection = (event : Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar ||'{}') var command : any = { type: 'add-sectionEntry', @@ -36,7 +36,7 @@ const addSection = (event : Event) => { const deleteSection = (event: Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar ||'{}') var command = { type: 'delete-sectionEntry', @@ -49,7 +49,7 @@ const deleteSection = (event: Event) => { const setPublishForSection = (event: Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}') var action = (event.currentTarget as HTMLElement).getAttribute('data-cms-action'); @@ -65,7 +65,7 @@ const setPublishForSection = (event: Event) => { const orderSections = (event : Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}') var command = { type: 'edit-sections', @@ -79,7 +79,7 @@ const orderSections = (event : Event) => { const editContent = (event: Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}') var command : any = { type: 'edit', @@ -97,7 +97,7 @@ const editContent = (event: Event) => { const editAttributes = (event: Event) => { var toolbar = (event.target as HTMLElement).closest('[data-cms-toolbar]') as HTMLElement; - var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(toolbar.dataset.cmsToolbar || '{}'); var command : any = { type: 'edit', @@ -135,7 +135,7 @@ const editAttributes = (event: Event) => { export const initToolbar = (container: HTMLElement) => { - var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar) + var toolbarDefinition = JSON.parse(container.dataset.cmsToolbar || '{}'); if (!toolbarDefinition.actions) { return } @@ -164,7 +164,7 @@ export const initToolbar = (container: HTMLElement) => { } }); - toolbarDefinition.actions.forEach(action => { + toolbarDefinition.actions.forEach((action : any) => { if (action === "editContent") { const button = document.createElement('button'); button.setAttribute('data-cms-action', 'edit'); diff --git a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-content.ts b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-content.ts index 73891e88..e1535ca1 100644 --- a/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-content.ts +++ b/modules/ui-module/src/main/ts/src/js/modules/rpc/rpc-content.ts @@ -19,7 +19,7 @@ * #L% */ -import { executeRemoteCall } from '@cms/modules/rpc/rpc.js' +import { executeRemoteCall, RPCResponse } from '@cms/modules/rpc/rpc.js' const getContentNode = async (options : any) => { var data = { @@ -45,6 +45,26 @@ const setContent = async (options : any) => { return await executeRemoteCall(data); }; +export interface ReplaceContent { + error: boolean | null; + uri: string; +} + +export interface ReplaceContentOptions { + uri: string; + content: string; + start: number; + end: number; +} + +const replaceContent = async (options : ReplaceContentOptions): Promise> => { + var data = { + method: "content.replace", + parameters: options + } + return await executeRemoteCall(data); +}; + const setMeta = async (options : any) => { var data = { method: "meta.set", @@ -77,4 +97,4 @@ const deleteSection = async (options : any) => { return await executeRemoteCall(data); }; -export { getContentNode, getContent, setContent, setMeta, setMetaBatch, addSection, deleteSection }; +export { getContentNode, getContent, setContent, replaceContent, setMeta, setMetaBatch, addSection, deleteSection }; 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 514fde6b..5b7eee77 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 @@ -27,7 +27,11 @@ interface Options { parameters?: any; } -const executeRemoteCall = async (options: Options) => { +export interface RPCResponse { + result: T; +} + +const executeRemoteCall = async (options: Options) => { return executeRemoteMethodCall(options.method, options.parameters); }; @@ -36,11 +40,12 @@ const executeRemoteMethodCall = async (method : string, parameters : any) => { method: method, parameters: parameters } + const csrfToken = getCSRFToken(); var response = await fetch(window.manager.baseUrl + "/rpc", { method: "POST", headers: { 'Content-Type': 'application/json', - 'X-CSRF-Token': getCSRFToken() + ...(csrfToken && { 'X-CSRF-Token': csrfToken }) }, body: JSON.stringify(data) }) @@ -48,7 +53,7 @@ const executeRemoteMethodCall = async (method : string, parameters : any) => { if (response.status === 403) { alert(i18n.t("ui.redirect.login", "You where logged out due to inactivity. Please log in again.")); window.location.href = window.manager.baseUrl + "/login"; - return; + throw new Error("Unauthorized"); } return await response.json(); diff --git a/modules/ui-module/src/main/ts/src/js/modules/wizard.js b/modules/ui-module/src/main/ts/src/js/modules/wizard.js new file mode 100644 index 00000000..c761dd5f --- /dev/null +++ b/modules/ui-module/src/main/ts/src/js/modules/wizard.js @@ -0,0 +1,224 @@ +/*- + * #%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"; + +const defaultOptions = { + title: 'Wizard', + fullscreen: false, + size: null, + showStepIndicator: true, + nextLabel: () => i18n.t('wizard.buttons.next', 'Next'), + prevLabel: () => i18n.t('wizard.buttons.previous', 'Previous'), + finishLabel: () => i18n.t('wizard.buttons.finish', 'Finish'), + cancelLabel: () => i18n.t('wizard.buttons.cancel', 'Cancel'), + validateStep: () => true, +}; + +const renderStepBody = (step, containerId) => { + const container = document.getElementById(containerId); + if (!container) return; + container.innerHTML = ''; + + if (typeof step.body === 'function') { + const bodyResult = step.body(); + if (typeof bodyResult === 'string') { + container.innerHTML = bodyResult; + } else if (bodyResult instanceof HTMLElement) { + container.appendChild(bodyResult); + } else if (bodyResult && typeof bodyResult.then === 'function') { + bodyResult.then((result) => { + container.innerHTML = typeof result === 'string' ? result : ''; + if (result instanceof HTMLElement) { + container.appendChild(result); + } + }); + } + } else if (step.body instanceof HTMLElement) { + container.appendChild(step.body); + } else { + container.innerHTML = step.body || ''; + } +}; + +const renderStepIndicator = (steps, currentStep, indicatorContainer) => { + if (!indicatorContainer) return; + indicatorContainer.innerHTML = ''; + steps.forEach((step, index) => { + const stepNode = document.createElement('div'); + stepNode.className = `wizard-step-item ${index === currentStep ? 'active' : index < currentStep ? 'completed' : ''}`; + stepNode.innerHTML = ` +
      ${index + 1}
      +
      ${step.title || i18n.t('wizard.step', 'Step')} ${index + 1}
      + `; + indicatorContainer.appendChild(stepNode); + }); +}; + +const openWizard = (optionsParam) => { + const wizardId = 'wizard_' + Date.now(); + + const options = { + ...defaultOptions, + ...optionsParam, + }; + + const steps = Array.isArray(options.steps) ? options.steps : []; + let currentStep = 0; + + let fullscreen = ''; + if (options.fullscreen) { + fullscreen = 'modal-fullscreen'; + } + + let size = ''; + if (options.size) { + size = 'modal-' + options.size; + } + + const modalHtml = ` + `; + + const container = document.getElementById('modalContainer'); + const modalDiv = document.createElement('div'); + modalDiv.innerHTML = modalHtml.trim(); + const modalNode = modalDiv.firstChild; + container.appendChild(modalNode); + + const modalElement = document.getElementById(wizardId); + const modalInstance = new bootstrap.Modal(modalElement, { + backdrop: 'static', + keyboard: true, + focus: true, + }); + + const prevBtn = document.getElementById(`${wizardId}_prevBtn`); + const nextBtn = document.getElementById(`${wizardId}_nextBtn`); + const cancelBtn = document.getElementById(`${wizardId}_cancelBtn`); + const stepTitle = document.getElementById(`${wizardId}_stepTitle`); + const stepIndicator = options.showStepIndicator ? document.getElementById(`${wizardId}_stepIndicator`) : null; + const bodyContainerId = `${wizardId}_bodyContainer`; + + const updateButtons = () => { + prevBtn.style.display = currentStep === 0 ? 'none' : ''; + nextBtn.textContent = currentStep === steps.length - 1 ? options.finishLabel() : options.nextLabel(); + }; + + const renderStep = () => { + const step = steps[currentStep] || {}; + const titleText = step.title || `${i18n.t('wizard.step', 'Step')} ${currentStep + 1}`; + if (stepTitle) { + stepTitle.textContent = titleText; + } + renderStepBody(step, bodyContainerId); + if (options.showStepIndicator && stepIndicator) { + renderStepIndicator(steps, currentStep, stepIndicator); + } + updateButtons(); + if (typeof step.onShow === 'function') { + step.onShow(modalElement); + } + }; + + const goToStep = (index) => { + const step = steps[currentStep] || {}; + if (typeof step.validate === 'function' && !step.validate()) { + return; + } + if (typeof options.validateStep === 'function' && !options.validateStep(currentStep)) { + return; + } + if (currentStep !== index && typeof step.onHide === 'function') { + step.onHide(modalElement); + } + currentStep = Math.max(0, Math.min(index, steps.length - 1)); + renderStep(); + if (typeof options.onStepChange === 'function') { + options.onStepChange(currentStep); + } + }; + + prevBtn.addEventListener('click', () => goToStep(currentStep - 1)); + cancelBtn.addEventListener('click', () => { + modalInstance.hide(); + if (typeof options.onCancel === 'function') { + options.onCancel(); + } + }); + nextBtn.addEventListener('click', () => { + const step = steps[currentStep] || {}; + const valid = typeof step.validate === 'function' ? step.validate() : true; + if (!valid) { + return; + } + if (currentStep === steps.length - 1) { + modalInstance.hide(); + if (typeof options.onFinish === 'function') { + options.onFinish(); + } + return; + } + goToStep(currentStep + 1); + }); + + modalElement.addEventListener('shown.bs.modal', () => { + renderStep(); + if (typeof options.onShow === 'function') { + options.onShow(modalElement); + } + }); + + modalElement.addEventListener('hidden.bs.modal', () => { + modalNode.remove(); + if (typeof options.onClose === 'function') { + options.onClose(); + } + }); + + modalInstance.show(); + + return { + wizardId, + modalInstance, + goToStep, + getCurrentStep: () => currentStep, + }; +}; + +export { openWizard }; diff --git a/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/MarkdownHelperTest.java b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/MarkdownHelperTest.java new file mode 100644 index 00000000..7d568519 --- /dev/null +++ b/modules/ui-module/src/test/java/com/condation/cms/modules/ui/utils/MarkdownHelperTest.java @@ -0,0 +1,258 @@ +package com.condation.cms.modules.ui.utils; + +/*- + * #%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 org.assertj.core.api.Assertions; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class MarkdownHelperTest { + + @Test + void shouldReplaceMiddleSection() { + String markdown = "Hello World"; + + String result = MarkdownHelper.replaceRange( + markdown, + 6, + 11, + "CMS"); + + assertEquals("Hello CMS", result); + } + + @Test + void shouldReplaceAtBeginning() { + String markdown = "Hello World"; + + String result = MarkdownHelper.replaceRange( + markdown, + 0, + 5, + "Hi"); + + assertEquals("Hi World", result); + } + + @Test + void shouldReplaceAtEnd() { + String markdown = "Hello World"; + + String result = MarkdownHelper.replaceRange( + markdown, + 6, + 11, + "Universe"); + + assertEquals("Hello Universe", result); + } + + @Test + void shouldReplaceWholeString() { + String markdown = "Hello World"; + + String result = MarkdownHelper.replaceRange( + markdown, + 0, + markdown.length(), + "New Content"); + + assertEquals("New Content", result); + } + + @Test + void shouldInsertAtBeginning() { + String markdown = "World"; + + String result = MarkdownHelper.replaceRange( + markdown, + 0, + 0, + "Hello "); + + assertEquals("Hello World", result); + } + + @Test + void shouldInsertAtEnd() { + String markdown = "Hello"; + + String result = MarkdownHelper.replaceRange( + markdown, + markdown.length(), + markdown.length(), + " World"); + + assertEquals("Hello World", result); + } + + @Test + void shouldInsertInMiddle() { + String markdown = "HelloWorld"; + + String result = MarkdownHelper.replaceRange( + markdown, + 5, + 5, + " "); + + assertEquals("Hello World", result); + } + + @Test + void shouldRemoveSection() { + String markdown = "Hello World"; + + String result = MarkdownHelper.replaceRange( + markdown, + 5, + 11, + ""); + + assertEquals("Hello", result); + } + + @Test + void shouldReplaceMarkdownImage() { + String markdown = """ + # Article + + ![Team](/images/team.jpg) + + Some text. + """; + + String oldImage = "![Team](/images/team.jpg)"; + int start = markdown.indexOf(oldImage); + int end = start + oldImage.length(); + + String result = MarkdownHelper.replaceRange( + markdown, + start, + end, + "![Team](/images/new-team.jpg)"); + + assertTrue(result.contains("![Team](/images/new-team.jpg)")); + assertFalse(result.contains("![Team](/images/team.jpg)")); + } + + @Test + void shouldThrowForNegativeStart() { + assertThrows( + IllegalArgumentException.class, + () -> MarkdownHelper.replaceRange( + "test", + -1, + 2, + "x")); + } + + @Test + void shouldThrowWhenEndBeforeStart() { + assertThrows( + IllegalArgumentException.class, + () -> MarkdownHelper.replaceRange( + "test", + 3, + 2, + "x")); + } + + @Test + void shouldThrowWhenEndExceedsLength() { + assertThrows( + IllegalArgumentException.class, + () -> MarkdownHelper.replaceRange( + "test", + 0, + 10, + "x")); + } + + @Test + void shouldThrowForNullMarkdown() { + assertThrows( + NullPointerException.class, + () -> MarkdownHelper.replaceRange( + null, + 0, + 0, + "x")); + } + + @Test + void shouldThrowForNullReplacement() { + assertThrows( + NullPointerException.class, + () -> MarkdownHelper.replaceRange( + "test", + 0, + 0, + null)); + } + + @Test + void shouldReplaceSimpleMediaImage() { + String md = "![img](/media/images/test.jpg)"; + + String result = MarkdownHelper.replaceImage( + "/", + md, + 0, + md.length(), + "testimg.jpg" + ); + + Assertions.assertThat(result).isEqualTo("![img](/media/testimg.jpg)"); + } + + @Test + void shouldReplaceSimpleMediaImageWithFormat() { + String md = "![img](/media/images/test.jpg?format=small)"; + + String result = MarkdownHelper.replaceImage( + "/", + md, + 0, + md.length(), + "testimg.jpg" + ); + + Assertions.assertThat(result).isEqualTo("![img](/media/testimg.jpg?format=small)"); + } + + @Test + void shouldReplaceSimpleMediaImageWithContextPath() { + String md = "![img](/de/media/images/test.jpg)"; + + String result = MarkdownHelper.replaceImage( + "/de", + md, + 0, + md.length(), + "testimg.jpg" + ); + + Assertions.assertThat(result).isEqualTo("![img](/de/media/testimg.jpg)"); + } + +} diff --git a/test-server/hosts/demo/content/index.md b/test-server/hosts/demo/content/index.md index de12f0cc..15ab3b26 100644 --- a/test-server/hosts/demo/content/index.md +++ b/test-server/hosts/demo/content/index.md @@ -42,7 +42,7 @@ Hello world! Here some content! -Hello: [[cms:username]][[/cms:username]] +Hello: [[cms:username]][[/cms:username]] Theme: [[ext:theme_name]][[/ext:theme_name]] [about](/about) @@ -51,8 +51,9 @@ Theme: [[ext:theme_name]][[/ext:theme_name]] ```java +// its a comment System.out.println("Hello world!"); ``` ### say hello -[[ext:say_hello name="CondationCMS" /]] \ No newline at end of file +[[ext:say_hello name="CondationCMS" /]]
    "); - sb.append(inlineRenderer.render(header)); + sb.append(inlineRenderer.render(header, documentOffset)); sb.append("
    "); - sb.append(inlineRenderer.render(items)); + sb.append(inlineRenderer.render(items, documentOffset)); sb.append("