Skip to content

Package: DefaultBlogViewController$VirtualSiteNodeFinder

DefaultBlogViewController$VirtualSiteNodeFinder

nameinstructionbranchcomplexitylinemethod
DefaultBlogViewController.VirtualSiteNodeFinder(DefaultBlogViewController)
M: 0 C: 10
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
DefaultBlogViewController.VirtualSiteNodeFinder(DefaultBlogViewController.VirtualSiteNodeFinder, Object)
M: 15 C: 0
0%
M: 0 C: 0
100%
M: 1 C: 0
0%
M: 4 C: 0
0%
M: 1 C: 0
0%
computeResults()
M: 0 C: 16
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 5
100%
M: 0 C: 1
100%
createVirtualNode(Content)
M: 0 C: 11
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 2
100%
M: 0 C: 1
100%
lambda$computeResults$0(Content)
M: 0 C: 6
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
lambda$computeResults$1(Content)
M: 0 C: 5
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
M: 0 C: 1
100%
lambda$createVirtualNode$2(SiteNode, Content, ResourcePath)
M: 0 C: 11
100%
M: 0 C: 0
100%
M: 0 C: 1
100%
M: 0 C: 3
100%
M: 0 C: 1
100%

Coverage

1: /*
2: * #%L
3: * *********************************************************************************************************************
4: *
5: * NorthernWind - lightweight CMS
6: * http://northernwind.tidalwave.it - git clone https://bitbucket.org/tidalwave/northernwind-src.git
7: * %%
8: * Copyright (C) 2011 - 2023 Tidalwave s.a.s. (http://tidalwave.it)
9: * %%
10: * *********************************************************************************************************************
11: *
12: * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
13: * the License. You may obtain a copy of the License at
14: *
15: * http://www.apache.org/licenses/LICENSE-2.0
16: *
17: * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
18: * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
19: * specific language governing permissions and limitations under the License.
20: *
21: * *********************************************************************************************************************
22: *
23: *
24: * *********************************************************************************************************************
25: * #L%
26: */
27: package it.tidalwave.northernwind.frontend.ui.component.blog;
28:
29: import javax.annotation.Nonnull;
30: import java.time.Instant;
31: import java.time.ZoneId;
32: import java.time.ZonedDateTime;
33: import java.time.format.DateTimeFormatter;
34: import java.time.format.FormatStyle;
35: import java.util.ArrayList;
36: import java.util.Collection;
37: import java.util.Comparator;
38: import java.util.HashMap;
39: import java.util.List;
40: import java.util.Locale;
41: import java.util.Map;
42: import java.util.Optional;
43: import java.util.function.Function;
44: import it.tidalwave.util.Finder;
45: import it.tidalwave.util.Key;
46: import it.tidalwave.util.spi.HierarchicFinderSupport;
47: import it.tidalwave.northernwind.core.model.Content;
48: import it.tidalwave.northernwind.core.model.HttpStatusException;
49: import it.tidalwave.northernwind.core.model.RequestLocaleManager;
50: import it.tidalwave.northernwind.core.model.ResourcePath;
51: import it.tidalwave.northernwind.core.model.ResourceProperties;
52: import it.tidalwave.northernwind.core.model.SiteNode;
53: import it.tidalwave.northernwind.frontend.ui.RenderContext;
54: import it.tidalwave.northernwind.frontend.ui.spi.VirtualSiteNode;
55: import lombok.AllArgsConstructor;
56: import lombok.EqualsAndHashCode;
57: import lombok.Getter;
58: import lombok.RequiredArgsConstructor;
59: import lombok.With;
60: import lombok.extern.slf4j.Slf4j;
61: import static java.util.Collections.reverseOrder;
62: import static java.util.Collections.*;
63: import static java.util.Comparator.*;
64: import static java.util.stream.Collectors.*;
65: import static javax.servlet.http.HttpServletResponse.*;
66: import static it.tidalwave.util.CollectionUtils.split;
67: import static it.tidalwave.util.LocalizedDateTimeFormatters.getDateTimeFormatterFor;
68: import static it.tidalwave.northernwind.core.model.Content.*;
69: import static it.tidalwave.northernwind.frontend.ui.component.Properties.*;
70: import static it.tidalwave.northernwind.frontend.ui.component.nodecontainer.NodeContainerViewController.*;
71: import static it.tidalwave.northernwind.util.UrlEncoding.encodedUtf8;
72: import static lombok.AccessLevel.PUBLIC;
73:
74: /***********************************************************************************************************************
75: *
76: * <p>A default implementation of the {@link BlogViewController} that is independent of the presentation technology.
77: * This class is capable to render:</p>
78: *
79: * <ul>
80: * <li>blog posts (in various ways)</li>
81: * <li>an index of the blog</li>
82: * <li>a tag cloud</li>
83: * </ul>
84: *
85: * <p>It accepts path parameters as follows:</p>
86: *
87: * <ul>
88: * <li>{@code <uri>}: selects a single post with the given uri;</li>
89: * <li>{@code <category>}: selects posts with the given category;</li>
90: * <li>{@code tags/<tag>}: selects posts with the given tag;</li>
91: * <li>{@code index}: renders a post index, with links to single posts;</li>
92: * <li>{@code index/<category>}: renders an index of posts with the given category;</li>
93: * <li>{@code index/tag/<tag>}: renders an index of posts with the given tag.</li>
94: * </ul>
95: *
96: * <p>Supported properties of the {@link SiteNode}:</p>
97: *
98: * <ul>
99: * <li>{@code P_CONTENT_PATHS}: one or more {@code Content} that contains the posts to render; they are folders and can have
100: * sub-folders, which will be searched for in a recursive fashion;</li>
101: * <li>{@code P_MAX_FULL_ITEMS}: the max. number of posts to be rendered in full;</li>
102: * <li>{@code P_MAX_LEADIN_ITEMS}: the max. number of posts to be rendered with lead-in text;</li>
103: * <li>{@code P_MAX_ITEMS}: the max. number of posts to be rendered as links;</li>
104: * <li>{@code P_DATE_FORMAT}: the pattern for formatting date and times;</li>
105: * <li>{@code P_TIME_ZONE}: the time zone for rendering dates (defaults to CET);</li>
106: * <li>{@code P_INDEX}: if {@code true}, forces an index rendering (useful e.g. when used in sidebars);</li>
107: * <li>{@code P_TAG_CLOUD}: if {@code true}, forces a tag cloud rendering (useful e.g. when used in sidebars).</li>
108: * </ul>
109: *
110: * <p>The {@code P_DATE_FORMAT} property accepts any valid pattern in Java 8, plus the values {@code S-}, {@code M-},
111: * {@code L-}, {@code F-}, which stand for small/medium/large and full patterns for a given locale.</p>
112: *
113: * <p>Supported properties of the {@link Content}:</p>
114: *
115: * <ul>
116: * <li>{@code P_TITLE}: the title;</li>
117: * <li>{@code P_FULL_TEXT}: the full text;</li>
118: * <li>{@code P_LEADIN_TEXT}: the lead-in text;</li>
119: * <li>{@code P_ID}: the unique id;</li>
120: * <li>{@code P_IMAGE_ID}: the id of an image representative of the post;</li>
121: * <li>{@code P_PUBLISHING_DATE}: the publishing date;</li>
122: * <li>{@code P_CREATION_DATE}: the creation date;</li>
123: * <li>{@code P_TAGS}: the tags;</li>
124: * <li>{@code P_CATEGORY}: the category.</li>
125: * </ul>
126: *
127: * <p>When preparing for rendering, the following dynamic properties will be set, only if a single post is rendered:</p>
128: *
129: * <ul>
130: * <li>{@code PD_URL}: the canonical URL of the post;</li>
131: * <li>{@code PD_ID}: the unique id of the post;</li>
132: * <li>{@code PD_IMAGE_ID}: the id of the representative image.</li>
133: * </ul>
134: *
135: * <p>Concrete implementations must provide two methods for rendering the blog posts and the tag cloud:</p>
136: *
137: * <ul>
138: * <li>{@link #renderPosts(java.util.List, java.util.List, java.util.List) }</li>
139: * <li>{@link #renderTagCloud(java.util.Collection) }</li>
140: * </ul>
141: *
142: * @author Fabrizio Giudici
143: *
144: **********************************************************************************************************************/
145: @RequiredArgsConstructor @Slf4j
146: public abstract class DefaultBlogViewController implements BlogViewController
147: {
148: /*******************************************************************************************************************
149: *
150: *
151: ******************************************************************************************************************/
152: @AllArgsConstructor(access = PUBLIC) @Getter @EqualsAndHashCode
153: public static class TagAndCount
154: {
155: public final String tag;
156: public final int count;
157:
158: @With
159: public final String rank;
160:
161: public TagAndCount (@Nonnull final String tag)
162: {
163: this(tag, 1, "");
164: }
165:
166: @Nonnull
167: public TagAndCount reduced (@Nonnull final TagAndCount other)
168: {
169: if (!this.tag.equals(other.tag))
170: {
171: throw new IllegalArgumentException("Mismatching " + this + " vs " + other);
172: }
173:
174: return new TagAndCount(tag, this.count + other.count, "");
175: }
176:
177: @Override @Nonnull
178: public String toString()
179: {
180: return String.format("TagAndCount(%s, %d, %s)", tag, count, rank);
181: }
182: }
183:
184: /*******************************************************************************************************************
185: *
186: * A {@link Finder} which returns virtual {@link SiteNode}s representing the multiple contents served by the
187: * {@link SiteNode} associated to this controller. This is typically used to create site maps.
188: *
189: ******************************************************************************************************************/
190: // TODO: add eventual localized versions
191: @RequiredArgsConstructor
192: private static class VirtualSiteNodeFinder extends HierarchicFinderSupport<SiteNode, VirtualSiteNodeFinder>
193: {
194: private static final long serialVersionUID = 1L;
195:
196: @Nonnull
197: private final transient DefaultBlogViewController controller;
198:
199: public VirtualSiteNodeFinder (@Nonnull final VirtualSiteNodeFinder other, @Nonnull final Object override)
200: {
201: super(other, override);
202: final var source = getSource(VirtualSiteNodeFinder.class, other, override);
203: this.controller = source.controller;
204: }
205:
206: @Override @Nonnull
207: protected List<SiteNode> computeResults()
208: {
209: return controller.findAllPosts(controller.getViewProperties())
210: .stream()
211: .peek(p -> log.trace(">>>> virtual node for: {}", p.getExposedUri()))
212: .flatMap(post -> createVirtualNode(post).stream())
213: .collect(toList());
214: }
215:
216: @Nonnull
217: private Optional<VirtualSiteNode> createVirtualNode (@Nonnull final Content post)
218: {
219: final var siteNode = controller.siteNode;
220: return post.getExposedUri().map(uri -> new VirtualSiteNode(siteNode,
221: siteNode.getRelativeUri().appendedWith(uri),
222: post.getProperties()));
223: }
224: }
225:
226: private static final Map<String, Function<Locale, DateTimeFormatter>> DATETIME_FORMATTER_MAP_BY_STYLE = new HashMap<>();
227:
228: static
229: {
230: DATETIME_FORMATTER_MAP_BY_STYLE.put("S-", locale -> getDateTimeFormatterFor(FormatStyle.SHORT, locale));
231: DATETIME_FORMATTER_MAP_BY_STYLE.put("M-", locale -> getDateTimeFormatterFor(FormatStyle.MEDIUM, locale));
232: DATETIME_FORMATTER_MAP_BY_STYLE.put("L-", locale -> getDateTimeFormatterFor(FormatStyle.LONG, locale));
233: DATETIME_FORMATTER_MAP_BY_STYLE.put("F-", locale -> getDateTimeFormatterFor(FormatStyle.FULL, locale));
234: }
235:
236: protected static final List<Key<ZonedDateTime>> DATE_KEYS = List.of(P_PUBLISHING_DATE, P_CREATION_DATE);
237:
238: public static final ZonedDateTime TIME0 = Instant.ofEpochMilli(0).atZone(ZoneId.of("GMT"));
239:
240: public static final String DEFAULT_TIMEZONE = "CET";
241:
242: private static final int NO_LIMIT = 9999;
243:
244: private static final String INDEX_PREFIX = "index";
245:
246: private static final String TAG_PREFIX = "tag";
247:
248: private static final ResourcePath TAG_CLOUD = ResourcePath.of("tags");
249:
250: private static final Comparator<Content> REVERSE_DATE_COMPARATOR = (p1, p2) ->
251: p2.getProperty(DATE_KEYS).orElse(TIME0).compareTo(p1.getProperty(DATE_KEYS).orElse(TIME0));
252:
253: @Nonnull
254: private final SiteNode siteNode;
255:
256: @Nonnull
257: private final BlogView view;
258:
259: @Nonnull
260: private final RequestLocaleManager requestLocaleManager;
261:
262: private Optional<String> tag = Optional.empty();
263:
264: private Optional<String> uriOrCategory = Optional.empty();
265:
266: private boolean indexMode;
267:
268: private boolean tagCloudMode;
269:
270: protected Optional<String> title = Optional.empty();
271:
272: /* VisibleForTesting */ final List<Content> fullPosts = new ArrayList<>();
273:
274: /* VisibleForTesting */ final List<Content> leadInPosts = new ArrayList<>();
275:
276: /* VisibleForTesting */ final List<Content> linkedPosts = new ArrayList<>();
277:
278: /*******************************************************************************************************************
279: *
280: * {@inheritDoc}
281: *
282: ******************************************************************************************************************/
283: @Override
284: public void prepareRendering (@Nonnull final RenderContext context)
285: throws HttpStatusException
286: {
287: log.info("prepareRendering(RenderContext) for {}", siteNode);
288:
289: final var viewProperties = getViewProperties();
290: indexMode = viewProperties.getProperty(P_INDEX).orElse(false);
291: var pathParams = context.getPathParams(siteNode);
292: tagCloudMode = viewProperties.getProperty(P_TAG_CLOUD).orElse(false);
293:
294: if (pathParams.equals(TAG_CLOUD))
295: {
296: tagCloudMode = true;
297: }
298: else if (pathParams.startsWith(INDEX_PREFIX))
299: {
300: indexMode = true;
301: pathParams = pathParams.withoutLeading();
302: }
303:
304: if (pathParams.startsWith(TAG_PREFIX) && (pathParams.getSegmentCount() == 2)) // matches(TAG_PREFIX, ".*")
305: {
306: tag = Optional.of(pathParams.getTrailing());
307: }
308: else if (pathParams.getSegmentCount() == 1)
309: {
310: uriOrCategory = Optional.of(pathParams.getLeading());
311: }
312: else if (!pathParams.isEmpty())
313: {
314: throw new HttpStatusException(SC_BAD_REQUEST);
315: }
316:
317: if (tagCloudMode)
318: {
319: setTitle(context);
320: }
321: else
322: {
323: prepareBlogPosts(context, viewProperties);
324:
325: if ((fullPosts.size() == 1) && leadInPosts.isEmpty() && linkedPosts.isEmpty())
326: {
327: setDynamicProperties(context, fullPosts.get(0));
328: }
329: else
330: {
331: setTitle(context);
332: }
333: }
334: }
335:
336: /*******************************************************************************************************************
337: *
338: * {@inheritDoc}
339: *
340: ******************************************************************************************************************/
341: @Override
342: public void renderView (@Nonnull final RenderContext context)
343: throws Exception
344: {
345: log.info("renderView() for {}", siteNode);
346:
347: if (tagCloudMode)
348: {
349: renderTagCloud();
350: }
351: else
352: {
353: renderPosts(fullPosts, leadInPosts, linkedPosts);
354: }
355: }
356:
357: /*******************************************************************************************************************
358: *
359: * {@inheritDoc}
360: *
361: ******************************************************************************************************************/
362: @Override @Nonnull
363: public Finder<SiteNode> findVirtualSiteNodes()
364: {
365: return new VirtualSiteNodeFinder(this);
366: }
367:
368: /*******************************************************************************************************************
369: *
370: * Renders the blog posts. Must be implemented by concrete subclasses.
371: *
372: * @param fullPosts the posts to be rendered in full
373: * @param leadinPosts the posts to be rendered with lead in text
374: * @param linkedPosts the posts to be rendered as references
375: * @throws Exception if something fails
376: *
377: ******************************************************************************************************************/
378: @SuppressWarnings("squid:S00112")
379: protected abstract void renderPosts (@Nonnull List<? extends Content> fullPosts,
380: @Nonnull List<? extends Content> leadinPosts,
381: @Nonnull List<? extends Content> linkedPosts)
382: throws Exception;
383:
384: /*******************************************************************************************************************
385: *
386: * Renders the tag cloud. Must be implemented by concrete subclasses.
387: *
388: * @param tagsAndCount the tags
389: *
390: ******************************************************************************************************************/
391: @SuppressWarnings("squid:S00112")
392: protected abstract void renderTagCloud (@Nonnull Collection<? extends TagAndCount> tagsAndCount);
393:
394: /*******************************************************************************************************************
395: *
396: * Creates a link for a {@link ResourcePath}.
397: *
398: * @param path the path
399: * @return the link
400: *
401: ******************************************************************************************************************/
402: @Nonnull
403: protected final String createLink (@Nonnull final ResourcePath path)
404: {
405: return siteNode.getSite().createLink(siteNode.getRelativeUri().appendedWith(path));
406: }
407:
408: /*******************************************************************************************************************
409: *
410: * Creates a link for a tag.
411: *
412: * @param tag the tag
413: * @return the link
414: *
415: ******************************************************************************************************************/
416: @Nonnull
417: protected final String createTagLink (final String tag)
418: {
419: // TODO: shouldn't ResourcePath always encode incoming strings?
420: var link = siteNode.getSite().createLink(siteNode.getRelativeUri().appendedWith(TAG_PREFIX)
421: .appendedWith(encodedUtf8(tag)));
422:
423: // TODO: Workaround because createLink() doesn't append trailing / if the link contains a dot.
424: // Refactor by passing a parameter to createLink that overrides the default behaviour.
425: if (!link.endsWith("/") && !link.contains("?"))
426: {
427: link += "/";
428: }
429:
430: return link;
431: }
432:
433: /*******************************************************************************************************************
434: *
435: *
436: ******************************************************************************************************************/
437: @Nonnull
438: protected final ResourceProperties getViewProperties()
439: {
440: return siteNode.getPropertyGroup(view.getId());
441: }
442:
443: /*******************************************************************************************************************
444: *
445: * Formats a date with the settings taken from the configuration and the request settings.
446: *
447: * @param dateTime the date to render
448: * @return the formatted date
449: *
450: ******************************************************************************************************************/
451: @Nonnull
452: protected final String formatDateTime (@Nonnull final ZonedDateTime dateTime)
453: {
454: return dateTime.format(findDateTimeFormatter());
455: }
456:
457: /*******************************************************************************************************************
458: *
459: * Prepares the blog posts.
460: *
461: * @param context the rendering context
462: * @param properties the view properties
463: * @throws HttpStatusException status 404 if no post found
464: *
465: ******************************************************************************************************************/
466: protected final void prepareBlogPosts (@Nonnull final RenderContext context, @Nonnull final ResourceProperties properties)
467: throws HttpStatusException
468: {
469: final var maxFullItems = indexMode ? 0 : properties.getProperty(P_MAX_FULL_ITEMS).orElse(NO_LIMIT);
470: final var maxLeadinItems = indexMode ? 0 : properties.getProperty(P_MAX_LEADIN_ITEMS).orElse(NO_LIMIT);
471: final var maxItems = indexMode ? NO_LIMIT : properties.getProperty(P_MAX_ITEMS).orElse(NO_LIMIT);
472:
473: log.debug(">>>> preparing blog posts for {}: maxFullItems: {}, maxLeadinItems: {}, maxItems: {} (index: {}, tag: {}, uri: {})",
474: view.getId(), maxFullItems, maxLeadinItems, maxItems, indexMode, tag.orElse(""), uriOrCategory.orElse(""));
475:
476: final var posts = findPosts(context, properties)
477: .stream()
478: .filter(post -> post.getProperty(P_TITLE).isPresent())
479: .sorted(REVERSE_DATE_COMPARATOR)
480: .collect(toList());
481:
482: if (posts.isEmpty())
483: {
484: throw new HttpStatusException(SC_NOT_FOUND);
485: }
486:
487: final var split = split(posts, 0, maxFullItems, maxFullItems + maxLeadinItems, maxItems);
488: fullPosts.addAll(split.get(0));
489: leadInPosts.addAll(split.get(1));
490: linkedPosts.addAll(split.get(2));
491: }
492:
493: /*******************************************************************************************************************
494: *
495: * Renders the tag cloud.
496: *
497: ******************************************************************************************************************/
498: private void renderTagCloud()
499: {
500: final var tagsAndCount = findAllPosts(getViewProperties())
501: .stream()
502: .flatMap(post -> post.getProperty(P_TAGS).stream().flatMap(Collection::stream))
503: .collect(toMap(t -> t, TagAndCount::new, TagAndCount::reduced))
504: .values()
505: .stream()
506: .sorted(comparing(TagAndCount::getTag))
507: .collect(toList());
508: renderTagCloud(withRanks(tagsAndCount));
509: }
510:
511: /*******************************************************************************************************************
512: *
513: * Finds all the relevant posts, applying filtering as needed.
514: *
515: ******************************************************************************************************************/
516: // TODO: use some short circuit to prevent from loading unnecessary data
517: @Nonnull
518: private List<Content> findPosts (@Nonnull final RenderContext context, @Nonnull final ResourceProperties properties)
519: {
520: final var pathParams = context.getPathParams(siteNode);
521: final var filtering = tag.isPresent() || uriOrCategory.isPresent();
522: final var allPosts = findAllPosts(properties);
523: final var posts = new ArrayList<Content>();
524: //
525: // The thing works differently in function of pathParams:
526: // when no pathParams, return all the posts;
527: // when it matches a category, return all the posts in that category;
528: // when it matches an exposed URI of a single specific post:
529: // if not in 'index' mode, return only that post;
530: // if in 'index' mode, returns all the posts.
531: //
532: if (indexMode && !filtering)
533: {
534: posts.addAll(allPosts);
535: }
536: else
537: {
538: if (tag.isPresent())
539: {
540: posts.addAll(filteredByTag(allPosts, tag.get()));
541: }
542: else
543: {
544: posts.addAll(filteredByExposedUri(allPosts, pathParams)
545: // pathParams matches an exposedUri; thus it's not a category, so an index wants all
546: .map(singlePost -> indexMode ? allPosts : singletonList(singlePost))
547: // pathParams didn't match an exposedUri, so it's interpreted as a category to filter posts
548: .orElseGet(() -> filteredByCategory(allPosts, uriOrCategory)));
549: }
550: }
551:
552: log.debug(">>>> found {} items", posts.size());
553:
554: return posts;
555: }
556:
557: /*******************************************************************************************************************
558: *
559: * Finds all the posts.
560: *
561: ******************************************************************************************************************/
562: @Nonnull
563: private List<Content> findAllPosts (@Nonnull final ResourceProperties properties)
564: {
565: return properties.getProperty(P_CONTENT_PATHS).orElse(emptyList()).stream()
566: .flatMap(path -> siteNode.getSite().find(_Content_).withRelativePath(path).stream()
567: .flatMap(folder -> folder.findChildren().stream()))
568: .collect(toList());
569: }
570:
571: /*******************************************************************************************************************
572: *
573: * Returns the proper {@link DateTimeFormatter}. It is built from an explicit pattern, if defined in the current
574: * {@link SiteNode}; otherwise the one provided by the {@link RequestLocaleManager} is used. The formatter is
575: * configured with the time zone defined in the {@code SiteNode}, or a default is used.
576: *
577: * @return the {@code DateTimeFormatter}
578: *
579: ******************************************************************************************************************/
580: @Nonnull
581: private DateTimeFormatter findDateTimeFormatter()
582: {
583: final var locale = requestLocaleManager.getLocales().get(0);
584: final var viewProperties = getViewProperties();
585: final var dtf = viewProperties.getProperty(P_DATE_FORMAT)
586: .map(s -> s.replaceAll("EEEEE+", "EEEE"))
587: .map(s -> s.replaceAll("MMMMM+", "MMMM"))
588: .map(p -> (((p.length() == 2) ? DATETIME_FORMATTER_MAP_BY_STYLE.get(p).apply(locale)
589: : DateTimeFormatter.ofPattern(p)).withLocale(locale)))
590: .orElse(requestLocaleManager.getDateTimeFormatter());
591:
592: final var zoneId = viewProperties.getProperty(P_TIME_ZONE).orElse(DEFAULT_TIMEZONE);
593: return dtf.withZone(ZoneId.of(zoneId));
594: }
595:
596: /*******************************************************************************************************************
597: *
598: *
599: *
600: ******************************************************************************************************************/
601: private void setDynamicProperties (@Nonnull final RenderContext context, @Nonnull final Content post)
602: {
603: context.setDynamicNodeProperty(PD_TITLE, computeTitle(post));
604: post.getExposedUri().map(this::createLink).ifPresent(l -> context.setDynamicNodeProperty(PD_URL, l));
605: post.getProperty(P_ID).ifPresent(id -> context.setDynamicNodeProperty(PD_ID, id));
606: post.getProperty(P_IMAGE_ID).ifPresent(id -> context.setDynamicNodeProperty(PD_IMAGE_ID, id));
607: }
608:
609: /*******************************************************************************************************************
610: *
611: *
612: ******************************************************************************************************************/
613: private void setTitle (@Nonnull final RenderContext context)
614: {
615: if (tagCloudMode)
616: {
617: title = Optional.of("Tags");
618: }
619: else if (indexMode)
620: {
621: title = Optional.of("Post index");
622:
623: if (tag.isPresent())
624: {
625: title = Optional.of(String.format("Posts tagged as '%s'", tag.get()));
626: }
627: else uriOrCategory.ifPresent(s -> title = Optional.of(String.format("Posts in category '%s'", s)));
628: }
629: else
630: {
631: title = getViewProperties().getProperty(P_TITLE).map(String::trim).flatMap(DefaultBlogViewController::filterEmptyString);
632: }
633:
634: title.ifPresent(view::setTitle);
635: title.ifPresent(s -> context.setDynamicNodeProperty(PD_TITLE, s));
636: }
637:
638: /*******************************************************************************************************************
639: *
640: *
641: ******************************************************************************************************************/
642: @Nonnull
643: private String computeTitle (@Nonnull final Content post)
644: {
645: final var prefix = siteNode.getProperty(P_TITLE).orElse("");
646: final var title = post.getProperty(P_TITLE).orElse("");
647: final var separator = "".equals(prefix) || "".equals(title) ? "" : " - ";
648:
649: return prefix + separator + title;
650: }
651:
652: /*******************************************************************************************************************
653: *
654: *
655: ******************************************************************************************************************/
656: @Nonnull
657: private static List<TagAndCount> withRanks (@Nonnull final Collection<? extends TagAndCount> tagsAndCount)
658: {
659: final var counts = tagsAndCount.stream()
660: .map(TagAndCount::getCount)
661: .distinct()
662: .sorted(reverseOrder())
663: .collect(toList());
664: return tagsAndCount.stream().map(tac -> tac.withRank(rankOf(tac.count, counts))).collect(toList());
665: }
666:
667: /*******************************************************************************************************************
668: *
669: * Filters the given posts that match the selected category; returns all the posts if the category is empty.
670: *
671: * @param posts the source posts
672: * @param category the category
673: * @return the filtered posts
674: *
675: ******************************************************************************************************************/
676: @Nonnull
677: private static List<Content> filteredByCategory (@Nonnull final List<? extends Content> posts,
678: @Nonnull final Optional<String> category)
679: {
680: return posts.stream().filter(post -> hasCategory(post, category)).collect(toList());
681: }
682:
683: /*******************************************************************************************************************
684: *
685: * Filters the {@code sourcePosts} that matches the selected{@code tag}; returns all
686: * posts if the category is empty.
687: *
688: * @param posts the source posts
689: * @param tag the tag
690: * @return the filtered posts
691: *
692: ******************************************************************************************************************/
693: @Nonnull
694: private static List<Content> filteredByTag (@Nonnull final List<? extends Content> posts, @Nonnull final String tag)
695: {
696: return posts.stream().filter(post -> hasTag(post, tag)).collect(toList());
697: }
698:
699: /*******************************************************************************************************************
700: *
701: *
702: ******************************************************************************************************************/
703: @Nonnull
704: private static Optional<Content> filteredByExposedUri (@Nonnull final List<Content> posts,
705: @Nonnull final ResourcePath exposedUri)
706: {
707: return posts.stream().filter(post -> post.getExposedUri().map(exposedUri::equals).orElse(false)).findFirst();
708: }
709:
710: /*******************************************************************************************************************
711: *
712: *
713: ******************************************************************************************************************/
714: @Nonnull
715: private static String rankOf (final int count, final List<Integer> counts)
716: {
717: assert counts.contains(count);
718: final var rank = counts.indexOf(count) + 1;
719: return (rank <= 10) ? Integer.toString(rank) : "Others";
720: }
721:
722: /*******************************************************************************************************************
723: *
724: *
725: ******************************************************************************************************************/
726: private static boolean hasCategory (@Nonnull final Content post, @Nonnull final Optional<String> category)
727: {
728: return category.isEmpty() || post.getProperty(P_CATEGORY).equals(category);
729: }
730:
731: /*******************************************************************************************************************
732: *
733: *
734: ******************************************************************************************************************/
735: private static boolean hasTag (@Nonnull final Content post, @Nonnull final String tag)
736: {
737: return post.getProperty(P_TAGS).orElse(emptyList()).contains(tag);
738: }
739:
740: /*******************************************************************************************************************
741: *
742: *
743: *
744: ******************************************************************************************************************/
745: @Nonnull
746: private static Optional<String> filterEmptyString (@Nonnull final String s)
747: {
748: return "".equals(s) ? Optional.empty() : Optional.of(s);
749: }
750: }