001/* 002 * Copyright 2025 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.odf.content; 017 018import java.io.IOException; 019import java.util.ArrayList; 020import java.util.Arrays; 021import java.util.HashSet; 022import java.util.List; 023import java.util.Map; 024import java.util.Objects; 025import java.util.Set; 026import java.util.regex.Pattern; 027import java.util.stream.Collectors; 028 029import org.apache.avalon.framework.service.ServiceException; 030import org.apache.avalon.framework.service.ServiceManager; 031import org.apache.cocoon.ProcessingException; 032import org.apache.cocoon.environment.ObjectModelHelper; 033import org.apache.cocoon.environment.Request; 034import org.apache.cocoon.generation.ServiceableGenerator; 035import org.apache.cocoon.xml.AttributesImpl; 036import org.apache.cocoon.xml.XMLUtils; 037import org.apache.commons.lang3.StringUtils; 038import org.xml.sax.SAXException; 039 040import org.ametys.cms.contenttype.ContentTypesHelper; 041import org.ametys.cms.data.ContentValue; 042import org.ametys.cms.data.RichText; 043import org.ametys.cms.data.RichTextHelper; 044import org.ametys.cms.repository.Content; 045import org.ametys.odf.ODFHelper; 046import org.ametys.odf.program.AbstractProgram; 047import org.ametys.odf.program.Container; 048import org.ametys.odf.program.Program; 049import org.ametys.odf.program.ProgramPart; 050import org.ametys.odf.program.SubProgram; 051import org.ametys.odf.program.TraversableProgramPart; 052import org.ametys.plugins.repository.AmetysObjectResolver; 053import org.ametys.runtime.model.View; 054 055/** 056 * Generator for orientation paths in program. 057 */ 058public class ProgramOrientationPathsGenerator extends ServiceableGenerator 059{ 060 // FIXME Accept all view names starting with "main" to be able to make the ODF and ODF orientation skins coexist (whereas they use different main views) 061 private static final Pattern __ALLOWED_VIEW_NAMES = Pattern.compile("main[a-z\\-]*"); 062 // Max number of characters for description per year of duration 063 private static int _MAX_DESC_LENGTH_PER_YEAR = 160; 064 065 private AmetysObjectResolver _resolver; 066 private ContentTypesHelper _cTypesHelper; 067 private ODFHelper _odfHelper; 068 private RichTextHelper _richTextHelper; 069 070 071 @Override 072 public void service(ServiceManager smanager) throws ServiceException 073 { 074 _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE); 075 _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 076 _odfHelper = (ODFHelper) smanager.lookup(ODFHelper.ROLE); 077 _richTextHelper = (RichTextHelper) smanager.lookup(RichTextHelper.ROLE); 078 } 079 080 @Override 081 public void generate() throws IOException, SAXException, ProcessingException 082 { 083 Request request = ObjectModelHelper.getRequest(objectModel); 084 Content content = (Content) request.getAttribute(Content.class.getName()); 085 086 if (content == null) 087 { 088 String contentId = request.getParameter("contentId"); 089 if (StringUtils.isBlank(contentId)) 090 { 091 throw new IllegalArgumentException("Content is missing in request attribute or parameters"); 092 } 093 094 content = _resolver.resolveById(contentId); 095 } 096 097 if (!(content instanceof AbstractProgram abstractProgram)) 098 { 099 throw new IllegalArgumentException("Cannot get orientation paths of a non abstract program '" + content.getId() + "'"); 100 } 101 102 String viewName = parameters.getParameter("viewName", StringUtils.EMPTY); 103 String fallbackViewName = parameters.getParameter("fallbackViewName", StringUtils.EMPTY); 104 105 View view = _cTypesHelper.getViewWithFallback(viewName, fallbackViewName, content); 106 107 // only allow view starting with "main" to optimize performances when structure is not needed 108 if (view == null || !__ALLOWED_VIEW_NAMES.matcher(view.getName()).matches()) 109 { 110 contentHandler.startDocument(); 111 XMLUtils.createElement(contentHandler, "orientation-paths"); 112 contentHandler.endDocument(); 113 return; 114 } 115 116 // Extract all years first to determine min and max levels 117 List<YearContainer> allYears = _extractAllYears(abstractProgram); 118 119 // Analyze the program structure to extract orientation paths 120 List<OrientationPath> orientationPaths = _computePaths(allYears); 121 122 contentHandler.startDocument(); 123 124 int minYear = orientationPaths.stream() 125 .mapToInt(OrientationPath::minYear) 126 .min() 127 .orElse(-1); 128 129 int maxYear = orientationPaths.stream() 130 .mapToInt(OrientationPath::maxYear) 131 .max() 132 .orElse(-1); 133 134 AttributesImpl attr = new AttributesImpl(); 135 attr.addCDATAAttribute("minYear", String.valueOf(minYear)); 136 attr.addCDATAAttribute("maxYear", String.valueOf(maxYear)); 137 138 XMLUtils.startElement(contentHandler, "orientation-paths", attr); 139 140 // Generate SAX events for each orientation path 141 for (OrientationPath path : orientationPaths) 142 { 143 _saxOrientationPath(path); 144 } 145 146 XMLUtils.endElement(contentHandler, "orientation-paths"); 147 148 contentHandler.endDocument(); 149 } 150 151 private List<YearContainer> _extractAllYears(AbstractProgram abstractProgram) 152 { 153 List<YearContainer> result = new ArrayList<>(); 154 155 Set<Container> years = _odfHelper.getYears(abstractProgram); 156 Set<String> yearIds = years.stream().map(Container::getId).collect(Collectors.toSet()); 157 158 for (Container year : years) 159 { 160 // Extract year level from period (annee1 -> 1, annee2 -> 2, etc.) 161 int yearLevel = -1; 162 163 try 164 { 165 String period = year.getValue("period/code"); 166 if (StringUtils.isNotBlank(period) && period.startsWith("annee") && period.length() > 5) 167 { 168 // Extract number from "anneeX" pattern 169 String numberStr = period.substring(5); // Skip "annee" 170 yearLevel = Integer.parseInt(numberStr); 171 } 172 } 173 catch (Exception e) 174 { 175 getLogger().warn("Failed to extract year level from container " + year.getId(), e); 176 } 177 178 if (yearLevel > 0) 179 { 180 // Filter previousYears and nextYears to only include those present in the current program part 181 182 ContentValue[] previousYears = year.getValue("previousYears"); 183 List<ContentValue> currentPreviousYears = previousYears != null ? Arrays.stream(previousYears).filter(y -> yearIds.contains(y.getContentId())).toList() : List.of(); 184 185 ContentValue[] nextYears = year.getValue("nextYears"); 186 List<ContentValue> currentNextYears = nextYears != null ? Arrays.stream(nextYears).filter(y -> yearIds.contains(y.getContentId())).toList() : List.of(); 187 188 result.add(new YearContainer(year, yearLevel, (TraversableProgramPart) _odfHelper.getParentProgramItem(year, abstractProgram), currentPreviousYears, currentNextYears)); 189 } 190 } 191 192 return result; 193 } 194 195 private List<OrientationPath> _computePaths(List<YearContainer> allYears) 196 { 197 // Build a map for quick lookup by container ID 198 Map<String, YearContainer> yearsMap = allYears.stream() 199 .collect(Collectors.toMap(y -> y.container().getId(), y -> y)); 200 201 // Find starting years: those without previousYears and with a valid period 202 List<YearContainer> startingYears = allYears.stream() 203 .filter(y -> y.previousYears().isEmpty() && y.yearLevel() > 0) 204 .toList(); 205 206 // Build paths starting from each starting year 207 List<OrientationPath> paths = new ArrayList<>(); 208 for (YearContainer startYear : startingYears) 209 { 210 OrientationPath path = _buildPath(startYear, yearsMap); 211 212 if (path != null) 213 { 214 paths.add(path); 215 } 216 } 217 218 return paths; 219 } 220 221 private OrientationPath _buildPath(YearContainer fromYear, Map<String, YearContainer> yearsMap) 222 { 223 List<PathPart> parts = _getParts(fromYear, yearsMap, new HashSet<>()); 224 225 if (parts == null || parts.isEmpty()) 226 { 227 return null; 228 } 229 230 int minYear = parts.stream().mapToInt(PathPart::minYear).min().orElse(-1); 231 int maxYear = parts.stream().mapToInt(PathPart::maxYear).max().orElse(-1); 232 233 return new OrientationPath(minYear, maxYear, parts); 234 } 235 236 private List<PathPart> _getParts(YearContainer fromYear, Map<String, YearContainer> yearsMap, Set<String> visitedYears) 237 { 238 String currentYearId = fromYear.container().getId(); 239 240 // Check for cycles 241 if (visitedYears.contains(currentYearId)) 242 { 243 getLogger().warn("Cycle detected at year " + currentYearId + ". Stopping path construction."); 244 return null; 245 } 246 247 visitedYears.add(currentYearId); 248 249 // Group consecutive years under the same parent using nextYears 250 List<YearContainer> groupedYears = new ArrayList<>(); 251 groupedYears.add(fromYear); 252 253 YearContainer lastYear = fromYear; 254 255 // Follow nextYears chain as long as they stay in the same parent 256 boolean keepGrouping = true; 257 while (keepGrouping) 258 { 259 YearContainer currentLastYear = lastYear; 260 261 // Check if there's exactly one next year and in the same parent 262 List<ContentValue> nextYears = currentLastYear.nextYears(); 263 if (nextYears.size() == 1) 264 { 265 YearContainer nextYear = yearsMap.get(nextYears.get(0).getContentId()); 266 if (nextYear != null && nextYear.parent().getId().equals(currentLastYear.parent().getId())) 267 { 268 groupedYears.add(nextYear); 269 visitedYears.add(nextYear.container().getId()); 270 lastYear = nextYear; 271 continue; 272 } 273 } 274 275 // No single next year in same parent - stop grouping 276 keepGrouping = false; 277 } 278 279 // Check if the grouped years represent ALL years in the parent 280 TraversableProgramPart parent = fromYear.parent(); 281 boolean groupContainsAllYearsInParent = groupedYears.size() == parent.getProgramPartChildren().size(); 282 283 List<PathPart> parts = new ArrayList<>(); 284 285 if (groupContainsAllYearsInParent) 286 { 287 // Create a PathPart for the grouped years 288 parts.add(_createGroupedPathPart(groupedYears, parent)); 289 } 290 else 291 { 292 for (YearContainer year : groupedYears) 293 { 294 // Create a PathPart for each year individually 295 parts.add(_createSingleYearPathPart(year)); 296 } 297 } 298 299 // Get all next years from the last year in the group 300 List<String> allNextYearIds = lastYear.nextYears().stream() 301 .map(cv -> cv.getContentId()) 302 .toList(); 303 304 // Continue building path for each next year (handles bifurcations) 305 for (String nextYearId : allNextYearIds) 306 { 307 YearContainer nextYear = yearsMap.get(nextYearId); 308 if (nextYear != null && !visitedYears.contains(nextYearId)) 309 { 310 parts.addAll(_getParts(nextYear, yearsMap, visitedYears)); 311 } 312 } 313 314 return parts; 315 } 316 317 private PathPart _createSingleYearPathPart(YearContainer year) 318 { 319 String id = "part-" + year.container().getId(); 320 int yearLevel = year.yearLevel(); 321 RichText desc = year.container().getDescription(); 322 String description = desc != null ? _richTextHelper.richTextToString(desc, _MAX_DESC_LENGTH_PER_YEAR) : ""; 323 324 List<String> nextParts = year.nextYears().stream().map(y -> "part-" + y.getContentId()).toList(); 325 326 return new PathPart(id, year.parent().getId(), _getParentType(year.parent()), year.container().getTitle(), description, yearLevel, yearLevel, List.of(year.container()), nextParts, false); 327 } 328 329 private PathPart _createGroupedPathPart(List<YearContainer> years, ProgramPart parent) 330 { 331 YearContainer firstYear = years.get(0); 332 YearContainer lastYear = years.get(years.size() - 1); 333 String id = "part-" + firstYear.container().getId(); 334 int minYear = firstYear.yearLevel(); 335 int maxYear = lastYear.yearLevel(); 336 337 RichText desc; 338 if (parent instanceof AbstractProgram abstractProgram) 339 { 340 desc = abstractProgram.getPresentation(); 341 } 342 else 343 { 344 desc = ((Container) parent).getDescription(); 345 } 346 347 // Adapt description length based on duration 348 String description = desc != null ? _richTextHelper.richTextToString(desc, (maxYear - minYear + 1) * _MAX_DESC_LENGTH_PER_YEAR) : ""; 349 350 List<Container> containers = years.stream().map(y -> y.container()).toList(); 351 List<String> nextParts = lastYear.nextYears().stream().map(y -> "part-" + y.getContentId()).toList(); 352 353 return new PathPart(id, parent.getId(), _getParentType(parent), ((Content) parent).getTitle(), description, minYear, maxYear, containers, nextParts, true); 354 } 355 356 private String _getParentType(ProgramPart parent) 357 { 358 if (parent instanceof SubProgram) 359 { 360 return "subProgram"; 361 } 362 else if (parent instanceof Program) 363 { 364 return "program"; 365 } 366 else 367 { 368 return "container"; 369 } 370 } 371 372 private void _saxOrientationPath(OrientationPath path) throws SAXException 373 { 374 AttributesImpl pathAttrs = new AttributesImpl(); 375 pathAttrs.addCDATAAttribute("minYear", String.valueOf(path.minYear())); 376 pathAttrs.addCDATAAttribute("maxYear", String.valueOf(path.maxYear())); 377 pathAttrs.addCDATAAttribute("duration", String.valueOf(path.maxYear() - path.minYear() + 1)); 378 XMLUtils.startElement(contentHandler, "path", pathAttrs); 379 380 for (PathPart part : path.parts()) 381 { 382 _saxPathPart(part); 383 } 384 385 XMLUtils.endElement(contentHandler, "path"); 386 } 387 388 private void _saxPathPart(PathPart part) throws SAXException 389 { 390 AttributesImpl partAttrs = new AttributesImpl(); 391 392 partAttrs.addCDATAAttribute("id", part.id()); 393 partAttrs.addCDATAAttribute("parentId", part.parentId()); 394 partAttrs.addCDATAAttribute("parentType", part.parentType()); 395 partAttrs.addCDATAAttribute("minYear", String.valueOf(part.minYear())); 396 partAttrs.addCDATAAttribute("maxYear", String.valueOf(part.maxYear())); 397 partAttrs.addCDATAAttribute("duration", String.valueOf(part.maxYear() - part.minYear() + 1)); 398 partAttrs.addCDATAAttribute("isGrouped", String.valueOf(part.isGrouped())); 399 400 XMLUtils.startElement(contentHandler, "part", partAttrs); 401 402 XMLUtils.createElement(contentHandler, "title", Objects.toString(part.title(), "")); 403 XMLUtils.createElement(contentHandler, "description", Objects.toString(part.description(), "")); 404 405 // Generate container references 406 XMLUtils.startElement(contentHandler, "containers"); 407 408 for (Container container : part.containers()) 409 { 410 AttributesImpl attr = new AttributesImpl(); 411 attr.addCDATAAttribute("id", container.getId()); 412 XMLUtils.createElement(contentHandler, "container", attr); 413 } 414 415 XMLUtils.endElement(contentHandler, "containers"); 416 417 // Generate next parts references 418 XMLUtils.startElement(contentHandler, "next-parts"); 419 420 for (String nextId : part.nextPartIds()) 421 { 422 AttributesImpl attr = new AttributesImpl(); 423 attr.addCDATAAttribute("ref", nextId); 424 XMLUtils.createElement(contentHandler, "part", attr); 425 } 426 427 XMLUtils.endElement(contentHandler, "next-parts"); 428 429 XMLUtils.endElement(contentHandler, "part"); 430 } 431 432 private record YearContainer(Container container, int yearLevel, TraversableProgramPart parent, List<ContentValue> previousYears, List<ContentValue> nextYears) { /* empty */ } 433 434 /** 435 * Represents a part of a path, which can span multiple consecutive years under the same parent. 436 * Years are grouped together if they share the same parent. 437 * @param id unique identifier for this path part 438 * @param parentId ID of the parent (Program or SubProgram) containing these years 439 * @param parentType the type of the parent 440 * @param title title describing this part of the path 441 * @param description description of this part 442 * @param minYear minimum year level in this path 443 * @param maxYear maximum year level in this path 444 * @param containers list of containers that make up this part 445 * @param nextPartIds list of possible next path part IDs (for bifurcations) 446 * @param isGrouped indicates if this part represents grouped years 447 */ 448 public record PathPart(String id, String parentId, String parentType, String title, String description, int minYear, int maxYear, List<Container> containers, List<String> nextPartIds, boolean isGrouped) { /* empty */ } 449 450 /** 451 * Represents a complete orientation path in the program. 452 * @param minYear minimum year level in this path 453 * @param maxYear maximum year level in this path 454 * @param parts ordered list of path parts that make up this path 455 */ 456 public record OrientationPath(int minYear, int maxYear, List<PathPart> parts) { /* empty */ } 457}