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