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.views;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022
023import org.apache.juneau.marshall.collections.*;
024
025/**
026 * One ribbon (toolbar) entry above a table.
027 *
028 * <p>
029 * The ribbon runtime ({@code juneau-ribbon.js}) implements every behavior; this type only names it and carries its
030 * parameters.  {@link #toMap()} produces the same {@code {type, ...}} entry the runtime reads from {@code viewDef.ribbon}.
031 *
032 * <p>
033 * Setters are not gated per item type, but {@link #toMap()} fails loudly rather than emit a key the runtime ignores
034 * for that type (for example {@code form} on a {@code refresh}, or {@code symbol} on an {@code optionGroup}), and
035 * checks the invariants the runtime cannot: a top-level {@code option} needs {@code column} or {@code param} (exactly
036 * one) plus a {@code value}, and a group's {@code default} must name a member.  Group members take only
037 * {@code id}, {@code title}, {@code symbol}, {@code column}, {@code param} and {@code value}.
038 *
039 * <h5 class='section'>Example:</h5>
040 * <p class='bjava'>
041 *    <jv>table</jv>.ribbon(
042 *       RibbonItem.<jsm>collapseAll</jsm>(),
043 *       RibbonItem.<jsm>export</jsm>(<js>"copy"</js>, <js>"csv"</js>).optional(<js>"excel"</js>, <js>"pdf"</js>),
044 *       RibbonItem.<jsm>option</jsm>(<js>"dropped-only"</js>).title(<js>"Dropped only"</js>).column(<js>"status"</js>).value(<js>"$eq(DROPPED)"</js>).persist(<jk>true</jk>),
045 *       RibbonItem.<jsm>optionGroup</jsm>(<js>"phase"</js>,
046 *          RibbonItem.<jsm>option</jsm>(<js>"pending"</js>).column(<js>"phase"</js>).value(<js>"$in(Waiting)"</js>),
047 *          RibbonItem.<jsm>option</jsm>(<js>"done"</js>).column(<js>"phase"</js>).value(<js>"$in(Completed)"</js>)
048 *       ).defaultOption(<js>"pending"</js>),
049 *       RibbonItem.<jsm>dialog</jsm>(<js>"add"</js>).title(<js>"Add repo"</js>).form(<js>"/rest/skill-repos/add-modal"</js>)
050 *          .endpoint(<js>"/rest/skill-repos/new"</js>).method(RowAction.Method.<jsf>POST</jsf>)
051 *          .onSuccess(RowAction.OnSuccess.<jsf>REDRAW</jsf>),
052 *       RibbonItem.<jsm>refresh</jsm>());
053 *
054 *    RibbonItem.<jsm>refresh</jsm>().title(<js>"Refresh tasks"</js>).target(<js>"tasks"</js>);
055 *    RibbonItem.<jsm>publish</jsm>(<js>"Focus east"</js>, <js>"app.region-picked"</js>, Map.<jsm>of</jsm>(<js>"region"</js>, <js>"east"</js>));
056 * </p>
057 *
058 * @since 10.0.0
059 */
060@SuppressWarnings({
061   "java:S1192", // Repeated literals are contract wire keys; named constants would obscure the contract shape.
062   "java:S3776" // Validation and rendering chains many independent guards; splitting them adds no clarity.
063})
064public final class RibbonItem {
065
066   private static final List<String> EXPORT_BUTTONS = List.of("copy", "csv", "print", "excel", "pdf");
067   private static final List<String> EXPORT_OPTIONAL = List.of("excel", "pdf");
068   private static final List<String> APPEARANCES = List.of("icon");
069
070   // The keys juneau-ribbon.js reads for each item type (everything else is ignored there, so toMap() rejects it).
071   private static final Set<String> COMMON = Set.of("type", "id", "title", "group", "appearance", "symbol", "visibleWhen");
072   private static final Map<String,Set<String>> ALLOWED = Map.of(
073      "refresh", union(COMMON, "target"),
074      "collapseAll", union(COMMON, "target"),
075      "pausePolling", union(COMMON, "target"),
076      "dialog", union(COMMON, "form", "endpoint", "method", "onSuccess"),
077      "export", Set.of("type", "group", "appearance", "buttons", "optional", "visibleWhen"),
078      "option", union(COMMON, "column", "param", "value", "persist", "default", "target"),
079      "optionGroup", Set.of("type", "id", "title", "appearance", "persist", "default", "deselectable", "options", "target", "visibleWhen"),
080      "publish", union(COMMON, "topic", "payload"),
081      "divider", Set.of("type", "visibleWhen")
082   );
083   private static final java.util.regex.Pattern CARD_ID = java.util.regex.Pattern.compile("^[A-Za-z][A-Za-z0-9_-]{0,63}$");
084   private static final Set<String> MEMBER_ALLOWED = Set.of("id", "title", "symbol", "column", "param", "value");
085
086   private final String type;
087   private String id;
088   private String title;
089   private String group;
090   private String appearance;
091   private String symbol;
092   private String form;
093   private String endpoint;
094   private String method;
095   private String onSuccess;
096   private List<String> buttons;
097   private List<String> optional;
098   private String column;
099   private String param;
100   private String value;
101   private Boolean persist;
102   private Object dflt;  // Boolean for an option, String (member id) for an optionGroup.
103   private Boolean deselectable;
104   private List<RibbonItem> options;
105   private String target;
106   private List<VisibilityRule> visibleWhen;
107   private String topic;
108   private Map<String,Object> payload;
109
110   private RibbonItem(String type) {
111      this.type = type;
112   }
113
114   /**
115    * A visual separator between ribbon items; it also closes any open item cluster.
116    *
117    * @return A new {@link RibbonItem}.
118    */
119   public static RibbonItem divider() {
120      return new RibbonItem("divider");
121   }
122
123   /**
124    * The ribbon's reload-the-table button.
125    *
126    * @return A new {@link RibbonItem}.
127    */
128   public static RibbonItem refresh() {
129      return new RibbonItem("refresh");
130   }
131
132   /**
133    * The ribbon's collapse-all-detail-rows button.
134    *
135    * @return A new {@link RibbonItem}.
136    */
137   public static RibbonItem collapseAll() {
138      return new RibbonItem("collapseAll");
139   }
140
141   /**
142    * The ribbon's pause/resume auto-refresh toggle.
143    *
144    * @return A new {@link RibbonItem}.
145    */
146   public static RibbonItem pausePolling() {
147      return new RibbonItem("pausePolling");
148   }
149
150   /**
151    * The ribbon's export menu, offering the named buttons.
152    *
153    * <p>
154    * A button here is required: if its library is missing at runtime (JSZip for {@code excel}, pdfMake for
155    * {@code pdf}) it renders disabled and logs an error.  Use {@link #optional(String...)} for buttons that should
156    * simply be omitted when their library is absent.  The menu may consist of optional buttons alone, but
157    * {@link #toMap()} rejects one with no buttons at all.
158    *
159    * @param buttons The required buttons, each one of {@code copy}, {@code csv}, {@code print}, {@code excel} or
160    *    {@code pdf}.
161    * @return A new {@link RibbonItem}.
162    * @throws IllegalArgumentException If {@code buttons} names a button not in that set.
163    */
164   public static RibbonItem export(String...buttons) {
165      for (var b : buttons)
166         checkOneOf(b, EXPORT_BUTTONS, "export button");
167      var r = new RibbonItem("export");
168      r.buttons = buttons.length == 0 ? null : List.of(buttons);
169      return r;
170   }
171
172   /**
173    * A ribbon button that opens a modal dialog.
174    *
175    * @param actionId This item's stable id (also the dialog's logical name).  Must not be <jk>null</jk> or blank.
176    * @return A new {@link RibbonItem}.
177    * @throws IllegalArgumentException If {@code actionId} is <jk>null</jk> or blank.
178    */
179   public static RibbonItem dialog(String actionId) {
180      if (ib(actionId))
181         throw iaex("RibbonItem dialog action id must not be null or blank.");
182      var r = new RibbonItem("dialog");
183      r.id = actionId;
184      return r;
185   }
186
187   /**
188    * A server-query toggle button.
189    *
190    * <p>
191    * Set exactly one of {@link #column(String)} (a BeanQuery {@code $}-expression {@link #value(String) value}
192    * against that column) or {@link #param(String)} (a URL query parameter).  Checked by {@link #toMap()}.
193    *
194    * @param id The toggle's stable id.  Must not be <jk>null</jk> or blank.
195    * @return A new {@link RibbonItem}.
196    * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank.
197    */
198   public static RibbonItem option(String id) {
199      if (ib(id))
200         throw iaex("RibbonItem option id must not be null or blank.");
201      var r = new RibbonItem("option");
202      r.id = id;
203      return r;
204   }
205
206   /**
207    * A group of mutually exclusive {@link #option(String) option} toggles.
208    *
209    * <p>
210    * A member may omit {@code column}/{@code param}/{@code value}; selecting it then applies no filter (an "All"
211    * member).
212    *
213    * @param id The group's stable id.  Must not be <jk>null</jk> or blank.
214    * @param members The members, each created by {@link #option(String)}.  Must not be empty.
215    * @return A new {@link RibbonItem}.
216    * @throws IllegalArgumentException If {@code id} is <jk>null</jk> or blank, or {@code members} is empty or
217    *    holds anything other than an {@code option}.
218    */
219   public static RibbonItem optionGroup(String id, RibbonItem...members) {
220      if (ib(id))
221         throw iaex("RibbonItem optionGroup id must not be null or blank.");
222      if (members.length == 0)
223         throw iaex("RibbonItem optionGroup '%s' requires at least one option.", id);
224      for (var m : members)
225         if (! "option".equals(m.type))
226            throw iaex("RibbonItem optionGroup '%s' member must be an option, not '%s'.", id, m.type);
227      var r = new RibbonItem("optionGroup");
228      r.id = id;
229      r.options = List.of(members);
230      return r;
231   }
232
233   /**
234    * A ribbon button that publishes a message when clicked.  The topic must be a custom topic declared in the
235    * page's {@code topics} or a card's {@code publishes}, or {@code cmd:<cardId>}; the console's wiring check
236    * enforces that.  Rendered only on a console page (no bus, no button).
237    *
238    * @param title The button label.
239    * @param topic The topic to publish.
240    * @param payload A JSON-serializable payload, or <jk>null</jk> for <code>{}</code>.
241    * @return A new {@link RibbonItem}.
242    * @throws IllegalArgumentException If {@code title} or {@code topic} is <jk>null</jk> or blank.
243    */
244   public static RibbonItem publish(String title, String topic, Map<String,Object> payload) {
245      if (ib(title))
246         throw iaex("RibbonItem publish requires a title.");
247      if (ib(topic))
248         throw iaex("RibbonItem publish '%s' requires a topic.", title);
249      var i = new RibbonItem("publish");
250      i.title = title;
251      i.topic = topic;
252      i.payload = payload == null ? null : new LinkedHashMap<>(payload);
253      return i;
254   }
255
256   /**
257    * Sends this item's command to another card instead of its own table.  Accepted on {@code refresh},
258    * {@code collapseAll}, {@code pausePolling}, {@code option} and {@code optionGroup}; {@link #toMap()} rejects it
259    * elsewhere.
260    *
261    * @param cardId The target card id; the console's wiring check verifies it names a card that handles the op.
262    * @return This object.
263    * @throws IllegalArgumentException If {@code cardId} is not a valid card id.
264    */
265   public RibbonItem target(String cardId) {
266      if (cardId == null || ! CARD_ID.matcher(cardId).matches())
267         throw iaex("RibbonItem target '%s' must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.", cardId);
268      target = cardId;
269      return this;
270   }
271
272   /**
273    * Hides this item unless every rule matches the console's facts.  Evaluated by the client; this only serializes the
274    * rules.  Accepted on every item type; not on an {@code optionGroup} member.
275    *
276    * @param rules The rules, ANDed together.  None clears them.
277    * @return This object.
278    */
279   public RibbonItem visibleWhen(VisibilityRule...rules) {
280      if (rules == null || Arrays.stream(rules).anyMatch(Objects::isNull))
281         throw iaex("RibbonItem visibleWhen rules must not be null.");
282      visibleWhen = rules.length == 0 ? null : List.of(rules);
283      return this;
284   }
285
286   /**
287    * Sets this item's stable id.
288    *
289    * <p>
290    * The id is emitted on the item's ribbon entry, where it is scoped to its card (it is not stamped as a DOM id).
291    * {@link #toMap()} rejects an id on the types the runtime ignores it for
292    * ({@code divider} and {@code export}).
293    *
294    * @param v The id.  Must not be <jk>null</jk> or blank.
295    * @return This object.
296    * @throws IllegalArgumentException If {@code v} is <jk>null</jk> or blank.
297    */
298   public RibbonItem id(String v) {
299      if (ib(v))
300         throw iaex("RibbonItem %s id must not be null or blank.", type);
301      id = v;
302      return this;
303   }
304
305   /**
306    * Sets this item's title/label text.
307    *
308    * <p>
309    * On an {@code optionGroup} the title is not drawn; it becomes the group's accessible name
310    * ({@code role="group"} with {@code aria-label}).
311    *
312    * @param v The title.  Can be <jk>null</jk> to unset.
313    * @return This object.
314    */
315   public RibbonItem title(String v) {
316      title = v;
317      return this;
318   }
319
320   /**
321    * Clusters this item with adjacent items sharing the same group name.
322    *
323    * @param v The group name.  Can be <jk>null</jk> to unset.
324    * @return This object.
325    */
326   public RibbonItem group(String v) {
327      group = v;
328      return this;
329   }
330
331   /**
332    * Sets how this item's button is drawn.
333    *
334    * @param v The appearance; currently only {@code icon}.  Can be <jk>null</jk> to unset.
335    * @return This object.
336    * @throws IllegalArgumentException If {@code v} is not a known appearance.
337    */
338   public RibbonItem appearance(String v) {
339      if (v != null)
340         checkOneOf(v, APPEARANCES, "appearance");
341      appearance = v;
342      return this;
343   }
344
345   /**
346    * Sets the icon name overriding this item's default icon.
347    *
348    * @param v The icon name.  Can be <jk>null</jk> to unset.
349    * @return This object.
350    */
351   public RibbonItem symbol(String v) {
352      symbol = v;
353      return this;
354   }
355
356   /**
357    * Sets the form-source URL supplying a dialog item's input fields.
358    *
359    * @param url The form URL.  Can be <jk>null</jk> to unset.
360    * @return This object.
361    */
362   public RibbonItem form(String url) {
363      form = url;
364      return this;
365   }
366
367   /**
368    * Sets the URL a dialog item's form submits to.
369    *
370    * @param url The endpoint URL.  Can be <jk>null</jk> to unset.
371    * @return This object.
372    */
373   public RibbonItem endpoint(String url) {
374      endpoint = url;
375      return this;
376   }
377
378   /**
379    * Sets the non-safe HTTP method a dialog item's form submits with.
380    *
381    * @param m The method.  Must not be <jk>null</jk>.
382    * @return This object.
383    */
384   public RibbonItem method(RowAction.Method m) {
385      if (m == null)
386         throw iaex("RibbonItem method must not be null.");
387      method = m.wire();
388      return this;
389   }
390
391   /**
392    * Sets what the runtime does with a successful dialog submission.
393    *
394    * @param s The success behavior.  Must not be <jk>null</jk>.
395    * @return This object.
396    */
397   public RibbonItem onSuccess(RowAction.OnSuccess s) {
398      if (s == null)
399         throw iaex("RibbonItem onSuccess must not be null.");
400      onSuccess = s.wire();
401      return this;
402   }
403
404   /**
405    * Adds export buttons that are omitted, rather than rendered disabled, when their library is absent.
406    *
407    * @param names The buttons, each {@code excel} or {@code pdf}.
408    * @return This object.
409    * @throws IllegalArgumentException If a name is not one of those, or is already a required button.
410    */
411   public RibbonItem optional(String...names) {
412      for (var n : names) {
413         checkOneOf(n, EXPORT_OPTIONAL, "optional export button");
414         if (buttons != null && buttons.contains(n))
415            throw iaex("RibbonItem export button '%s' is both required and optional.", n);
416      }
417      optional = names.length == 0 ? null : List.of(names);
418      return this;
419   }
420
421   /**
422    * Scopes an {@code option} to a table column.
423    *
424    * @param name The row-data column name.  Can be <jk>null</jk> to unset.
425    * @return This object.
426    */
427   public RibbonItem column(String name) {
428      column = name;
429      return this;
430   }
431
432   /**
433    * Scopes an {@code option} to a URL query parameter.
434    *
435    * @param name The parameter name.  Can be <jk>null</jk> to unset.
436    * @return This object.
437    */
438   public RibbonItem param(String name) {
439      param = name;
440      return this;
441   }
442
443   /**
444    * Sets an {@code option}'s value: a BeanQuery {@code $}-expression for a {@link #column(String) column}-scoped
445    * option, or the raw parameter value for a {@link #param(String) param}-scoped one.
446    *
447    * @param v The value.  Can be <jk>null</jk> to unset.
448    * @return This object.
449    */
450   public RibbonItem value(String v) {
451      value = v;
452      return this;
453   }
454
455   /**
456    * Sets whether this option's (or group's) state is remembered across page loads.
457    *
458    * @param v Whether to persist.
459    * @return This object.
460    */
461   public RibbonItem persist(boolean v) {
462      persist = v;
463      return this;
464   }
465
466   /**
467    * Marks an {@code option} as on until the user (or persisted state) says otherwise.
468    *
469    * @return This object.
470    */
471   public RibbonItem defaultOn() {
472      dflt = Boolean.TRUE;
473      return this;
474   }
475
476   /**
477    * Selects an {@code optionGroup} member until the user (or persisted state) says otherwise.
478    *
479    * @param memberId The member's id; checked against the members by {@link #toMap()}.
480    * @return This object.
481    * @throws IllegalArgumentException If this item is a plain {@code option} (use {@link #defaultOn()}).
482    */
483   public RibbonItem defaultOption(String memberId) {
484      if ("option".equals(type))
485         throw iaex("RibbonItem option '%s' cannot take defaultOption(); use defaultOn().", id);
486      dflt = memberId;
487      return this;
488   }
489
490   /**
491    * Lets an {@code optionGroup}'s selected member be clicked again to deselect it.
492    *
493    * @param v Whether the group is deselectable.
494    * @return This object.
495    */
496   public RibbonItem deselectable(boolean v) {
497      deselectable = v;
498      return this;
499   }
500
501   /**
502    * Builds this item's ribbon entry.
503    *
504    * @return The entry, {@code type} first, then only the keys that were set.
505    * @throws IllegalArgumentException If the item sets a key the runtime ignores for its type, a top-level
506    *    {@code option} sets neither or both of {@code column} and {@code param} or no {@code value}, an
507    *    {@code export} has no buttons, or a {@code default} does not suit the item (an {@code option} takes
508    *    {@link #defaultOn()}; an {@code optionGroup} takes {@link #defaultOption(String)} naming a member), or a
509    *    {@code target} is set on a type the runtime does not route.
510    */
511   public JsonMap toMap() {
512      var m = toMap(true);
513      check(m, null);
514      return m;
515   }
516
517   private JsonMap toMap(boolean withType) {
518      var m = new JsonMap();
519      if (withType)
520         m.put("type", type);
521      if (id != null)
522         m.put("id", id);
523      if (title != null)
524         m.put("title", title);
525      if (group != null)
526         m.put("group", group);
527      if (appearance != null)
528         m.put("appearance", appearance);
529      if (symbol != null)
530         m.put("symbol", symbol);
531      if (topic != null)
532         m.put("topic", topic);
533      if (payload != null)
534         m.put("payload", payload);
535      if (target != null)
536         m.put("target", target);
537      if (form != null)
538         m.put("form", form);
539      if (endpoint != null)
540         m.put("endpoint", endpoint);
541      if (method != null)
542         m.put("method", method);
543      if (onSuccess != null)
544         m.put("onSuccess", onSuccess);
545      if (buttons != null)
546         m.put("buttons", buttons);
547      if (optional != null)
548         m.put("optional", optional);
549      if (column != null)
550         m.put("column", column);
551      if (param != null)
552         m.put("param", param);
553      if (value != null)
554         m.put("value", value);
555      if (persist != null)
556         m.put("persist", persist);
557      if (dflt != null)
558         m.put("default", dflt);
559      if (deselectable != null)
560         m.put("deselectable", deselectable);
561      if (visibleWhen != null)
562         m.put("visibleWhen", VisibilityRule.toMaps(visibleWhen));
563      if (options != null) {
564         m.put("options", new JsonList(options.stream().map(o -> o.toMap(false)).toList()));
565      }
566      return m;
567   }
568
569   /**
570    * Validates an authored ribbon item map (for example from a JSON5 card body) with exactly the rules
571    * {@link #toMap()} applies to a built item: a known {@code type}, only keys the runtime reads, an
572    * {@code export} with at least one valid button, an {@code option} with exactly one of {@code column} or
573    * {@code param} plus a {@code value}, a {@code default} that suits the item, a valid {@code appearance} and
574    * {@code target}, a well-formed {@code visibleWhen} (a list of {@code field}/{@code op} rules with a known op), and the same rules for each {@code optionGroup} member.
575    *
576    * @param item The authored item.  Must not be <jk>null</jk>.
577    * @throws IllegalArgumentException If the item is not valid.
578    */
579   public static void validate(Map<?,?> item) {
580      check(item, null);
581   }
582
583   // The one rule set behind toMap() and validate().  parentId is non-null only for an optionGroup member (a map with no 'type').
584   private static void check(Map<?,?> m, String parentId) {
585      var isMember = parentId != null;
586      var t = m.get("type");
587      var declaredType = t instanceof String ts ? ts : null;
588      var type = isMember ? "option" : declaredType;
589      if (! isMember && (type == null || ! ALLOWED.containsKey(type))) {
590         if (t == null)
591            throw iaex("RibbonItem requires a 'type'; one of '%s'.", String.join(", ", new TreeSet<>(ALLOWED.keySet())));
592         if (! (t instanceof String))
593            throw iaex("RibbonItem 'type' must be a string, not '%s'; one of '%s'.", t, String.join(", ", new TreeSet<>(ALLOWED.keySet())));
594         throw iaex("RibbonItem type '%s' is not one of '%s'.", t, String.join(", ", new TreeSet<>(ALLOWED.keySet())));
595      }
596      var id = m.get("id");
597      if (! isMember) {
598         if ("export".equals(type)) {
599            if (m.get("buttons") == null && m.get("optional") == null)
600               throw iaex("RibbonItem export requires at least one button.");
601            checkAll(m.get("buttons"), EXPORT_BUTTONS, "export button");
602            checkAll(m.get("optional"), EXPORT_OPTIONAL, "optional export button");
603         }
604         if (m.get("appearance") != null)
605            checkOneOf(String.valueOf(m.get("appearance")), APPEARANCES, "appearance");
606         if (m.get("target") != null && ! (m.get("target") instanceof String ts && CARD_ID.matcher(ts).matches()))
607            throw iaex("RibbonItem target '%s' must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$.", m.get("target"));
608      }
609      if (isMember || "option".equals(type))
610         checkScope(m, id, ! isMember);
611      if (! isMember && m.get("default") != null)
612         checkDefault(m, type, id);
613      if (! isMember && m.get("visibleWhen") != null)
614         checkVisibleWhen(m.get("visibleWhen"), type, id);
615      if ("optionGroup".equals(type) && m.get("options") != null) {
616         if (! (m.get("options") instanceof List<?> members))
617            throw iaex("RibbonItem optionGroup '%s' options must be a list.", id);
618         for (var o : members) {
619            if (! (o instanceof Map<?,?> mm))
620               throw iaex("RibbonItem optionGroup '%s' each option must be an object.", id);
621            check(mm, String.valueOf(id));
622         }
623      }
624      var allowed = isMember ? MEMBER_ALLOWED : ALLOWED.get(type);
625      for (var k : m.keySet())
626         if (! allowed.contains(String.valueOf(k))) {
627            if (isMember)
628               throw iaex("RibbonItem optionGroup '%s' member '%s' does not accept '%s'.", parentId, id, k);
629            throw iaex("RibbonItem %s%s does not accept '%s'.", type, id == null ? "" : " '" + id + "'", k);
630         }
631   }
632
633   private static void checkVisibleWhen(Object v, String type, Object id) {
634      var ok = v instanceof List<?> l && l.stream().allMatch(r -> r instanceof Map<?,?> rm
635         && rm.get("field") instanceof String f && ! f.isBlank()
636         && rm.get("op") instanceof String op && VisibilityRule.KNOWN_OPS.contains(op));
637      if (! ok)
638         throw iaex("RibbonItem %s%s visibleWhen must be a list of {field, op[, value]} rules with a known op.", type, id == null ? "" : " '" + id + "'");
639   }
640
641   private static void checkAll(Object v, List<String> allowed, String what) {
642      if (v == null)
643         return;
644      if (! (v instanceof List<?> l))
645         throw iaex("RibbonItem %s list must be a list.", what);
646      for (var b : l)
647         checkOneOf(String.valueOf(b), allowed, what);
648   }
649
650   private static void checkScope(Map<?,?> m, Object id, boolean topLevel) {
651      var column = m.get("column");
652      var param = m.get("param");
653      if (column != null && param != null)
654         throw iaex("RibbonItem option '%s' sets both column '%s' and param '%s'.", id, column, param);
655      if (topLevel && column == null && param == null)
656         throw iaex("RibbonItem option '%s' sets neither column nor param.", id);
657      if (topLevel && m.get("value") == null)
658         throw iaex("RibbonItem option '%s' requires a value.", id);
659   }
660
661   private static void checkDefault(Map<?,?> m, String type, Object id) {
662      var d = m.get("default");
663      if ("optionGroup".equals(type)) {
664         var ok = d instanceof String && m.get("options") instanceof List<?> l
665            && l.stream().anyMatch(o -> o instanceof Map<?,?> mm && d.equals(mm.get("id")));
666         if (! ok)
667            throw iaex("RibbonItem optionGroup '%s' default '%s' is not one of its options.", id, d);
668      } else if ("option".equals(type) && ! Boolean.TRUE.equals(d)) {
669         throw iaex("RibbonItem option '%s' default '%s' must be on; use defaultOn().", id, d);
670      }
671   }
672
673   private static Set<String> union(Set<String> base, String...more) {
674      var s = new LinkedHashSet<>(base);
675      s.addAll(Arrays.asList(more));
676      return Set.copyOf(s);
677   }
678
679   private static void checkOneOf(String v, List<String> allowed, String what) {
680      if (! allowed.contains(v))
681         throw iaex("RibbonItem %s '%s' is not one of '%s'.", what, v, String.join(", ", allowed));
682   }
683}