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.widgets;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.util.*;
022import java.util.regex.*;
023
024import org.apache.juneau.commons.http.*;
025
026/**
027 * A reusable month-calendar definition: month/year navigation, event categories, and a display-only legend
028 * (parent concept #10).
029 *
030 * <p>
031 * This is the {@link Widget} marker type &mdash; the {@code juneau-console-widgets}
032 * {@code org.apache.juneau.console.widgets.Widget}, <b>not</b> {@code org.apache.juneau.marshall.html.HtmlWidget}
033 * (the unrelated {@code $W{...}} HTML header-widget SPI).  It is <b>bean-only</b>: the emitter
034 * ({@code CalendarTable}) and the {@code juneau-calendar.js}/{@code .css} runtime live in
035 * {@code juneau-console-views}, which composes this bean.  Widgets keeps no dependency on views.
036 *
037 * <p>
038 * The server renders the initial month grid + legend and paints real seed chips into the day cells (progressive
039 * enhancement); the runtime then computes each month client-side and hydrates events from a same-origin, data-only
040 * per-month GET whose envelope is {@code {contractVersion, year, month, events}}.  When {@link #endpoint} is
041 * <jk>null</jk> the calendar is seed-only and month navigation is disabled.
042 *
043 * @since 10.0.0
044 */
045@SuppressWarnings({
046   "java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
047})
048public class CalendarDef implements Widget {
049
050   /**
051    * The frozen contract version for the per-month event GET envelope and the stamped
052    * {@code data-juneau-calendar-contract} attribute.  Serialized as the JSON <b>string</b> {@code "2"} (a numeric
053    * {@code 2} would fail the client's strict {@code ===} handshake).
054    *
055    * <p>
056    * Bumped from {@code "1"} to {@code "2"} for the {@code end}-becomes-layout-significant change even though the
057    * envelope gained <b>no new required field</b> ({@code end} was already carried for forward-compat).  The bump
058    * is justified <i>because this same constant is the stamped {@code data-juneau-calendar-contract} attribute</i>:
059    * a v1 runtime paired with a v2 server would silently ignore {@code end} and paint a multi-day event as a single
060    * chip, so making the handshake fail loud is the point.  Do <b>not</b> copy this as a general pattern &mdash; a
061    * wire contract that is not also a runtime-handshake stamp should not bump for a no-new-field change.
062    */
063   public static final String CONTRACT_VERSION = "2";
064
065   /** The default chip cap per day cell before collapsing into "+N more". */
066   public static final int DEFAULT_MAX_PER_DAY = 3;
067
068   /**
069    * The hard internal ceiling on spanning-bar lanes in one week row.
070    *
071    * <p>
072    * The lane budget is <b>derived</b> from {@link #effectiveMaxPerDay()} and clamped here; it is deliberately not
073    * author-controllable (there is no {@code maxLanesPerWeek} bean field).  Bars that would need a lane beyond the
074    * budget are collapsed into the day cells' "+N more" affordance instead of stacking a week row arbitrarily tall.
075    */
076   public static final int MAX_LANES_PER_WEEK = 8;
077
078   /** The charset every id (calendar / category) must match. */
079   private static final Pattern ID_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9_-]*");
080
081   /** Required, non-blank instance id; charset {@code [A-Za-z][A-Za-z0-9_-]*}.  Emitted as {@code data-juneau-calendar}. */
082   public String id;
083
084   /**
085    * Same-origin path template for the per-month event GET; must contain {@code {year}} and {@code {month}}.
086    * May be <jk>null</jk>: seed-only, with month navigation disabled.
087    */
088   public String endpoint;
089
090   /** Declared event categories, in legend display order.  A referenced {@code categoryId} must be declared here. */
091   public List<EventCategory> categories;
092
093   /** Optional initial-month seed events, painted into the initial month server-side and embedded as the sidecar. */
094   public List<CalendarEvent> events;
095
096   /** Which view to render; v1 supports {@link CalendarView#MONTH} only.  Defaults to {@code MONTH}. */
097   public CalendarView view;
098
099   /** First column of the week grid; defaults to {@link WeekStart#SUNDAY}. */
100   public WeekStart weekStart;
101
102   /** Optional 1-based initial month ({@code 1..12}); paired with {@link #initialYear}. */
103   public Integer initialMonth;
104
105   /** Optional initial year; paired with {@link #initialMonth}. */
106   public Integer initialYear;
107
108   /** Optional max chips per day before "+N more"; defaults to {@link #DEFAULT_MAX_PER_DAY}. */
109   public Integer maxPerDay;
110
111   /**
112    * Creates an empty calendar definition.
113    *
114    * @return A new {@link CalendarDef}.
115    */
116   public static CalendarDef create() {
117      return new CalendarDef();
118   }
119
120   /**
121    * Sets the instance id.
122    *
123    * @param value The id.  Must match {@code [A-Za-z][A-Za-z0-9_-]*}.
124    * @return This object.
125    */
126   public CalendarDef id(String value) {
127      id = value;
128      return this;
129   }
130
131   /**
132    * Sets the per-month event GET template.
133    *
134    * @param value A same-origin path template containing {@code {year}} and {@code {month}}, or <jk>null</jk> for
135    *    seed-only.
136    * @return This object.
137    */
138   public CalendarDef endpoint(String value) {
139      endpoint = value;
140      return this;
141   }
142
143   /**
144    * Sets the declared categories, in legend order.
145    *
146    * @param value The categories.
147    * @return This object.
148    */
149   public CalendarDef categories(EventCategory...value) {
150      categories = l(value);
151      return this;
152   }
153
154   /**
155    * Sets the initial-month seed events.
156    *
157    * @param value The seed events.
158    * @return This object.
159    */
160   public CalendarDef events(CalendarEvent...value) {
161      events = l(value);
162      return this;
163   }
164
165   /**
166    * Sets the view.
167    *
168    * @param value The view.
169    * @return This object.
170    */
171   public CalendarDef view(CalendarView value) {
172      view = value;
173      return this;
174   }
175
176   /**
177    * Sets the week start column.
178    *
179    * @param value The week start.
180    * @return This object.
181    */
182   public CalendarDef weekStart(WeekStart value) {
183      weekStart = value;
184      return this;
185   }
186
187   /**
188    * Sets the initial year and 1-based month together.
189    *
190    * @param year The initial year.
191    * @param month The 1-based initial month ({@code 1..12}).
192    * @return This object.
193    */
194   public CalendarDef initial(int year, int month) {
195      initialYear = year;
196      initialMonth = month;
197      return this;
198   }
199
200   /**
201    * Sets the max chips per day before "+N more".
202    *
203    * @param value The cap; must be {@code >= 1}.
204    * @return This object.
205    */
206   public CalendarDef maxPerDay(int value) {
207      maxPerDay = value;
208      return this;
209   }
210
211   /**
212    * The effective view &mdash; the declared {@link #view} or {@link CalendarView#MONTH} when unset.
213    *
214    * @return The effective view.
215    */
216   public CalendarView effectiveView() {
217      return view == null ? CalendarView.MONTH : view;
218   }
219
220   /**
221    * The effective week start &mdash; the declared {@link #weekStart} or {@link WeekStart#SUNDAY} when unset.
222    *
223    * @return The effective week start.
224    */
225   public WeekStart effectiveWeekStart() {
226      return weekStart == null ? WeekStart.SUNDAY : weekStart;
227   }
228
229   /**
230    * The effective chip cap &mdash; the declared {@link #maxPerDay} or {@link #DEFAULT_MAX_PER_DAY} when unset.
231    *
232    * @return The effective chip cap.
233    */
234   public int effectiveMaxPerDay() {
235      return maxPerDay == null ? DEFAULT_MAX_PER_DAY : maxPerDay;
236   }
237
238   /**
239    * The per-week-row spanning-bar lane budget &mdash; {@link #effectiveMaxPerDay()} clamped to
240    * {@link #MAX_LANES_PER_WEEK}.
241    *
242    * <p>
243    * Spanning bars occupy <b>lanes</b> counted against this budget; {@link #maxPerDay} itself continues to cap the
244    * <b>non-spanning</b> chips (single-day all-day plus timed) inside a day cell.  The two caps are separate on
245    * purpose: a bar crossing a cell does not consume that cell's chip budget, and a hidden chip does not consume a
246    * lane.
247    *
248    * @return The derived lane budget, at least 1 and at most {@link #MAX_LANES_PER_WEEK}.
249    */
250   public int effectiveLaneBudget() {
251      return Math.min(effectiveMaxPerDay(), MAX_LANES_PER_WEEK);
252   }
253
254   /**
255    * The seed events that are well-formed enough to paint &mdash; the malformed ones are dropped, exactly as a
256    * per-month GET handler drops them via {@link CalendarEvent#retainWellFormed(Collection)}.
257    *
258    * @return A new mutable list of paintable seed events, in declaration order.  Never <jk>null</jk>.
259    */
260   public List<CalendarEvent> wellFormedEvents() {
261      return CalendarEvent.retainWellFormed(events);
262   }
263
264   /** The declared category ids. */
265   private Set<String> categoryIds() {
266      var ids = new HashSet<String>();
267      if (categories != null)
268         for (var c : categories)
269            ids.add(c.id);   // validateCategories() has already rejected null entries and null ids
270      return ids;
271   }
272
273   @Override /* Widget */
274   public void validate() {
275      validate(initialYear, initialMonth);
276   }
277
278   /**
279    * Fail-closed bean validation against a resolved (year, month) window.
280    *
281    * <p>
282    * When {@link #initialYear}/{@link #initialMonth} are unset, the emitter resolves the effective window from the
283    * injected clock and passes it here so seed events are still checked against the month actually rendered.
284    *
285    * @param effectiveYear The year the initial month is rendered for, or <jk>null</jk> to skip the off-month check.
286    * @param effectiveMonth The 1-based month rendered, or <jk>null</jk> to skip the off-month check.
287    * @throws IllegalArgumentException If this definition is not well-formed.
288    */
289   public void validate(Integer effectiveYear, Integer effectiveMonth) {
290      if (ib(id))
291         throw iaex("CalendarDef id must not be null or blank.");
292      if (!ID_PATTERN.matcher(id).matches())
293         throw iaex("CalendarDef id '%s' must match [A-Za-z][A-Za-z0-9_-]*.", id);
294
295      validateEndpoint();
296
297      if (maxPerDay != null && maxPerDay < 1)
298         throw iaex("CalendarDef maxPerDay must be >= 1.");
299
300      validateInitialMonthAndYear();
301      validateCategories();
302      validateEvents(effectiveYear, effectiveMonth);
303   }
304
305   /** Validates the optional per-month event GET template (see {@link #endpoint}). */
306   private void validateEndpoint() {
307      if (endpoint == null)
308         return;
309      if (endpoint.isBlank())
310         throw iaex("CalendarDef endpoint must not be blank (use null for a seed-only calendar).");
311      if (!SafePathTemplate.isSafeTemplate(endpoint, "{year}", "{month}"))
312         throw iaex("CalendarDef endpoint must be a same-origin path template containing {year} and {month}: %s",
313            endpoint);
314   }
315
316   /** Validates that {@link #initialMonth}/{@link #initialYear} are paired and in range. */
317   private void validateInitialMonthAndYear() {
318      if ((initialMonth == null) != (initialYear == null))
319         throw iaex("CalendarDef initialMonth and initialYear must be set together or neither.");
320      if (initialMonth != null && (initialMonth < 1 || initialMonth > 12))
321         throw iaex("CalendarDef initialMonth must be in 1..12.");
322   }
323
324   /** Validates the declared event categories, in legend order. */
325   private void validateCategories() {
326      if (categories == null)
327         return;
328      var seenCat = new HashSet<String>();
329      for (var c : categories) {
330         if (c == null)
331            throw iaex("CalendarDef category must not be null.");
332         if (c.id == null || c.id.isBlank())
333            throw iaex("EventCategory id must not be null or blank.");
334         if (!ID_PATTERN.matcher(c.id).matches())
335            throw iaex("EventCategory id '%s' must match [A-Za-z][A-Za-z0-9_-]*.", c.id);
336         if (!seenCat.add(c.id))
337            throw iaex("CalendarDef duplicate category id '%s'.", c.id);
338         if (c.label == null || c.label.isBlank())
339            throw iaex("EventCategory '%s' label must not be null or blank.", c.id);
340      }
341   }
342
343   /** Validates the initial-month seed events against the resolved (year, month) window. */
344   private void validateEvents(Integer effectiveYear, Integer effectiveMonth) {
345      if (events == null)
346         return;
347      var known = categoryIds();
348      var seenEvt = new HashSet<String>();
349      for (var e : events) {
350         // A malformed event is DROPPED, not fatal: one bad event must never cost the whole calendar.  The
351         // per-month GET path drops the same set via CalendarEvent.retainWellFormed.
352         if (e == null || e.malformedReason() != null)
353            continue;
354         if (!seenEvt.add(e.id))
355            throw iaex("CalendarDef duplicate event id '%s'.", e.id);
356         e.validate(known, effectiveYear, effectiveMonth);
357      }
358   }
359
360   /** The view families; v1 ships {@code MONTH} only so an author cannot set a value that only fails at {@code validate()}. */
361   public enum CalendarView {
362
363      /** The month grid view. */
364      MONTH
365   }
366
367   /** The first column of the week grid. */
368   public enum WeekStart {
369
370      /** Weeks start on Sunday (the default). */
371      SUNDAY,
372
373      /** Weeks start on Monday. */
374      MONDAY;
375
376      /** The lowercased wire token ({@code sunday}/{@code monday}). */
377      public String token() {
378         return name().toLowerCase(java.util.Locale.ROOT);
379      }
380   }
381}