001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.freemarker;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.util.*;
023
024import freemarker.core.*;
025import freemarker.template.*;
026
027/**
028 * The recursive {@code <@node>} console FreeMarker directive: one entry in a {@code <@navigation>} tree, of any
029 * depth, or (with {@code under=}) a page-scoped addition appended under an existing chrome nav node.
030 *
031 * <p>
032 * Attributes: {@code id} (required, checked against the id grammar &mdash; E-4), {@code label} (required),
033 * {@code href} (required on a leaf &mdash; E-2), {@code visible} (a strict boolean, omit defaults to {@code true}
034 * &mdash; E-5), {@code selected} (a strict boolean, omit defaults to {@code false} &mdash; E-5), and {@code under}
035 * (a chrome nav id path; see below). {@code format=} and any unknown attribute are rejected. A duplicate sibling id is rejected (E-3). At most one node tree-wide may set
036 * {@code selected=true} (E-6).
037 *
038 * <p>
039 * {@code visible=false} omits the node and its whole subtree &mdash; the body is not even rendered. A node not
040 * nested inside {@code <@navigation>} and without {@code under=} is rejected fail-closed.
041 *
042 * <h5 class='section'>Example:</h5>
043 * <p class='bcode'>
044 *    &lt;@navigation&gt;
045 *      &lt;@node id="work" label="Work" href="/work"&gt;
046 *        &lt;@node id="work-queue" label="Queue" href="/work/queue"/&gt;
047 *      &lt;/@node&gt;
048 *      &lt;@node id="setup" label="Setup" href="/setup" visible=isAdmin/&gt;
049 *    &lt;/@navigation&gt;
050 * </p>
051 *
052 * <h5 class='section'>{@code under=}: page-scoped nav additions:</h5>
053 * <p>
054 * {@code under=} is allowed only on a {@code <@node>} that is a direct child of {@code <@page>}, or of
055 * {@code <@console>} outside {@code <@navigation>}; anywhere else it is E-B14.  The node (and any ordinary nested
056 * {@code <@node>} children, built exactly as inside {@code <@navigation>}) is recorded as a pending addition and
057 * applied at {@code </@console>}, before {@code activeNav} resolves.  An unknown {@code under=} path is E-B8; a
058 * duplicate sibling under the resolved parent is E-3.  {@link PageSpec#navUnder(String, PageCapture.NavAdder)} is
059 * the Java twin; both funnel into the same resolution pass.  Pending additions resolve in order &mdash; Java
060 * {@code navUnder} calls first, then {@code <@node under>} in document order &mdash; so an addition can target only
061 * a node created by an earlier one; a forward reference is E-B8.
062 * <p class='bcode'>
063 *    &lt;@page tab="fleet/instances/${instance.id}/logs"&gt;
064 *      &lt;@node under="fleet/instances" id=instance.id label=instance.name href="/fleet/instances/${instance.id}"&gt;
065 *        &lt;@node id="logs" label="Logs" href="/fleet/instances/${instance.id}/logs" selected=true/&gt;
066 *      &lt;/@node&gt;
067 *    &lt;/@page&gt;
068 * </p>
069 *
070 * @since 10.0.0
071 */
072@SuppressWarnings({
073   "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape.
074   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
075})
076public final class NodeDirectiveModel implements TemplateDirectiveModel {
077
078   /** The shared-variable name this directive registers under. */
079   public static final String NAME = "node";
080
081   static final Set<String> ATTRS = Set.of("id", "label", "href", "visible", "selected", "under");
082
083   NodeDirectiveModel() {}
084
085   @Override
086   @SuppressWarnings({
087      "unchecked" // FreeMarker's raw params Map is String-keyed by contract.
088   })
089   public void execute(Environment env, @SuppressWarnings("rawtypes") Map params, TemplateModel[] loopVars,
090         TemplateDirectiveBody body) throws TemplateException, IOException {
091      Map<String, TemplateModel> p = params;
092      if (p.containsKey("format"))
093         throw FtlAttrLists.reject("<@node> has no format= attribute.");
094      FtlAttrLists.rejectUnknown(p, NAME, ATTRS);
095
096      var id = FtlAttrLists.scalar(p, "id");
097      if (id.isEmpty())
098         throw FtlAttrLists.reject("<@node> requires id=.");
099      FtlAttrLists.checkId(NAME, id);
100      var label = FtlAttrLists.scalar(p, "label");
101      if (label.isEmpty())
102         throw FtlAttrLists.reject("<@node> requires label=.");
103      var href = FtlAttrLists.scalar(p, "href");
104      var visible = FtlAttrLists.strictBoolean(p, NAME, "visible", true);
105      var selected = FtlAttrLists.strictBoolean(p, NAME, "selected", false);
106      var under = FtlAttrLists.scalar(p, "under");
107
108      if (p.containsKey("under"))
109         for (var segment : under.split("/", -1))
110            if (segment.isBlank())
111               throw FtlAttrLists.reject(f(
112                  "<@node id='%s'> under must name a '/'-separated nav id path; got '%s'.", id, under));
113
114      var cap = PageCapture.get(env);
115      if (! under.isEmpty()) {
116         if (n(cap) || cap.navOpen || cap.cardOpen || cap.slotOpen || ! (cap.pageOpen || cap.consoleOpen))
117            throw FtlAttrLists.reject(f(
118               "<@node id='%s' under='%s'> must be a direct child of <@page> or <@console>, outside <@navigation>.", id, under));
119      } else if (n(cap) || ! cap.navOpen) {
120         throw FtlAttrLists.reject("<@node> must be nested inside <@navigation>.");
121      }
122
123      // visible=false omits the node and its whole subtree; the body is not rendered.
124      if (! visible)
125         return;
126
127      if (! under.isEmpty()) {
128         buildUnder(cap, under, id, label, href, selected, body);
129         return;
130      }
131
132      var node = cap.navCursor.peek().add(id, label, href.isEmpty() ? null : href);
133      cap.navCursor.push(node);
134      try (var sink = new StringWriter()) {
135         if (nn(body))
136            body.render(sink);
137      } finally {
138         cap.navCursor.pop();
139      }
140
141      if (node.children().isEmpty() && n(node.href()))
142         throw FtlAttrLists.reject(f("<@node id='%s'> leaf requires href=.", id));
143      if (selected) {
144         if (cap.underRootOpen) {
145            if (nn(cap.underRootSelected))
146               throw FtlAttrLists.reject(f("<@node id='%s'> selected=true but '%s' is already selected.",
147                  node.id(), underFullPath(cap, cap.underRootSelected)));
148            cap.underRootSelected = node;
149         } else {
150            cap.select(node.idPath());
151         }
152      }
153   }
154
155   // under=: builds the node (and its ordinary nested children) against a throwaway detached root, then queues
156   // a NavAdder that copies the finished subtree onto the real, resolved ancestor at </@console>.
157   private static void buildUnder(PageCapture cap, String under, String id, String label, String href,
158         boolean selected, TemplateDirectiveBody body) throws TemplateException, IOException {
159      var top = NavNode.root().add(id, label, href.isEmpty() ? null : href);
160      cap.navCursor.push(top);
161      cap.navOpen = true;
162      cap.underRootOpen = true;
163      cap.underRootParent = under;
164      try (var sink = new StringWriter()) {
165         if (nn(body))
166            body.render(sink);
167      } finally {
168         cap.navCursor.pop();
169         cap.navOpen = false;
170         cap.underRootOpen = false;
171      }
172      if (top.children().isEmpty() && n(top.href()))
173         throw FtlAttrLists.reject(f("<@node id='%s'> leaf requires href=.", id));
174
175      var selectedMarker = cap.underRootSelected;
176      cap.underRootSelected = null;
177      if (selected) {
178         if (nn(selectedMarker))
179            throw FtlAttrLists.reject(f("<@node id='%s'> selected=true but '%s' is already selected.",
180               id, underFullPath(cap, selectedMarker)));
181         selectedMarker = top;
182      }
183
184      var marker = selectedMarker;
185      cap.addPendingNavUnder(under, parent -> {
186         var found = new NavNode[1];
187         copySubtree(top, parent, marker, found);
188         if (nn(found[0]))
189            cap.select(found[0].idPath());
190      });
191   }
192
193   // The selected node's full path, as it will be once attached: the under= path followed by its detached path.
194   private static String underFullPath(PageCapture cap, NavNode detached) {
195      return cap.underRootParent + "/" + String.join("/", detached.idPath());
196   }
197
198   // Recursively copies a detached subtree onto a real, resolved ancestor; NavNode has no re-parenting operation,
199   // so every node is rebuilt via its public getters.
200   private static NavNode copySubtree(NavNode src, NavNode dstParent, NavNode selectedMarker, NavNode[] found)
201         throws TemplateModelException {
202      var copy = dstParent.add(src.id(), src.label(), src.href());
203      if (src == selectedMarker)
204         found[0] = copy;
205      for (var child : src.children())
206         copySubtree(child, copy, selectedMarker, found);
207      return copy;
208   }
209}