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 — 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 — 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 — 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 — 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 — 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 — {@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 — 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}