1<?php 2 3namespace ComboStrap; 4 5 6class OutlineSection extends TreeNode 7{ 8 const CANONICAL = "outline"; 9 10 11 /** 12 * Not to confound with header calls that are {@link OutlineSection::getContentCalls()} 13 * of a section that has children 14 * 15 * @var Call[] $headingCalls 16 */ 17 private array $headingCalls = []; 18 /** 19 * 20 * @var Call[] $contentCalls 21 */ 22 private array $contentCalls = []; 23 24 25 private string $headingId; 26 27 private int $startFileIndex; 28 private ?int $endFileIndex = null; 29 30 /** 31 * @var Call|null - the first heading call for the section 32 */ 33 private ?Call $headingEnterCall; 34 /** 35 * @var array an array to make sure that the id are unique 36 */ 37 private array $tocUniqueId = []; 38 39 /** 40 * @var int - a best guess on the number of 41 */ 42 private int $lineNumber; 43 /** 44 * @var Outline - the outline that created this section (only on root, this is to get the path for the heading) 45 */ 46 private Outline $outlineContext; 47 48 49 /** 50 * @param Call|null $headingEnterCall - null if the section is the root 51 */ 52 private function __construct(Call $headingEnterCall = null) 53 { 54 $this->headingEnterCall = $headingEnterCall; 55 if ($headingEnterCall !== null) { 56 $position = $headingEnterCall->getFirstMatchedCharacterPosition(); 57 if ($position === null) { 58 $this->startFileIndex = 0; 59 } else { 60 $this->startFileIndex = $position; 61 } 62 $this->addHeaderCall($headingEnterCall); 63 } else { 64 $this->startFileIndex = 0; 65 } 66 $this->lineNumber = 1; // the heading 67 68 } 69 70 71 public static function createOutlineRoot(): OutlineSection 72 { 73 return new OutlineSection(null); 74 } 75 76 77 /** 78 * Return a text to an HTML Id 79 * @param string $fragment 80 * @return string 81 */ 82 public static function textToHtmlSectionId(string $fragment): string 83 { 84 $check = false; 85 // for empty string, the below function returns `section` 86 return sectionID($fragment, $check); 87 } 88 89 public static function createFromEnterHeadingCall(Call $enterHeadingCall): OutlineSection 90 { 91 return new OutlineSection($enterHeadingCall); 92 } 93 94 public function getFirstChild(): OutlineSection 95 { 96 97 /** @noinspection PhpIncompatibleReturnTypeInspection */ 98 return parent::getFirstChild(); 99 100 } 101 102 103 public function addContentCall(Call $actualCall): OutlineSection 104 { 105 106 $this->contentCalls[] = $actualCall; 107 return $this; 108 109 110 } 111 112 public function addHeaderCall(Call $actualCall): OutlineSection 113 { 114 115 $this->headingCalls[] = $actualCall; 116 return $this; 117 } 118 119 public function getLabel(): string 120 { 121 $label = ""; 122 foreach ($this->headingCalls as $call) { 123 if ($call->getTagName() === Outline::DOKUWIKI_HEADING_CALL_NAME) { 124 $label = $call->getInstructionCall()[1][0]; 125 // no more label call 126 break; 127 } 128 if ($call->isTextCall()) { 129 // Building the text for the toc 130 // only cdata for now 131 // no image, ... 132 if ($label != "") { 133 $label .= " "; 134 } 135 $label .= trim($call->getCapturedContent()); 136 } 137 } 138 return trim($label); 139 } 140 141 public function setStartPosition(int $startPosition): OutlineSection 142 { 143 $this->startFileIndex = $startPosition; 144 return $this; 145 } 146 147 public function setEndPosition(int $endFileIndex): OutlineSection 148 { 149 $this->endFileIndex = $endFileIndex; 150 return $this; 151 } 152 153 /** 154 * @return Call[] 155 */ 156 public function getHeadingCalls(): array 157 { 158 if ( 159 $this->headingEnterCall !== null && 160 $this->headingEnterCall->isPluginCall() && 161 !$this->headingEnterCall->hasAttribute("id") 162 ) { 163 $this->headingEnterCall->addAttribute("id", $this->getHeadingId()); 164 } 165 return $this->headingCalls; 166 } 167 168 169 public 170 function getEnterHeadingCall(): ?Call 171 { 172 return $this->headingEnterCall; 173 } 174 175 176 public 177 function getCalls(): array 178 { 179 return array_merge($this->headingCalls, $this->contentCalls); 180 } 181 182 public 183 function getContentCalls(): array 184 { 185 return $this->contentCalls; 186 } 187 188 /** 189 * @return int 190 */ 191 public 192 function getLevel(): int 193 { 194 if ($this->headingEnterCall === null) { 195 return 0; 196 } 197 switch ($this->headingEnterCall->getTagName()) { 198 case Outline::DOKUWIKI_HEADING_CALL_NAME: 199 $level = $this->headingEnterCall->getInstructionCall()[1][1]; 200 break; 201 default: 202 $level = $this->headingEnterCall->getAttribute(HeadingTag::LEVEL); 203 break; 204 } 205 206 try { 207 return DataType::toInteger($level); 208 } catch (ExceptionBadArgument $e) { 209 // should not happen 210 LogUtility::internalError("The level ($level) could not be cast to an integer", self::CANONICAL); 211 return 0; 212 } 213 } 214 215 public 216 function getStartPosition(): int 217 { 218 return $this->startFileIndex; 219 } 220 221 public 222 function getEndPosition(): ?int 223 { 224 return $this->endFileIndex; 225 } 226 227 public 228 function hasContentCall(): bool 229 { 230 return sizeof($this->contentCalls) > 0; 231 } 232 233 /** 234 */ 235 public 236 function getHeadingId() 237 { 238 239 if (!isset($this->headingId)) { 240 $id = $this->headingEnterCall->getAttribute("id"); 241 if ($id !== null) { 242 return $id; 243 } 244 245 $label = $this->getLabel(); 246 247 /** 248 * For Level 1 (ie Heading 1), we use the path as id and not the label 249 * Why? because when we bundle all pages in a single page 250 * (With {@link FetcherPageBundler} 251 * we can transform a wiki link to an internal link 252 */ 253 $level = $this->getLevel(); 254 if ($level === 1) { 255 // id is the path id 256 $markupPath = $this->getRoot()->outlineContext->getMarkupPath(); 257 if ($markupPath !== null) { 258 $label = $markupPath->toAbsoluteId(); 259 } 260 } 261 262 $this->headingId = sectionID($label, $this->tocUniqueId); 263 } 264 return $this->headingId; 265 266 } 267 268 /** 269 * A HTML section should have a heading 270 * but in a markup document, we may have data before the first 271 * heading making a section without heading 272 * @return bool 273 */ 274 public 275 function hasHeading(): bool 276 { 277 return $this->headingEnterCall !== null; 278 } 279 280 /** 281 * @return OutlineSection[] 282 */ 283 public 284 function getChildren(): array 285 { 286 return parent::getChildren(); 287 } 288 289 public function setLevel(int $level): OutlineSection 290 { 291 switch ($this->headingEnterCall->getTagName()) { 292 case Outline::DOKUWIKI_HEADING_CALL_NAME: 293 $this->headingEnterCall->getInstructionCall()[1][1] = $level; 294 break; 295 default: 296 $this->headingEnterCall->setAttribute(HeadingTag::LEVEL, $level); 297 $headingExitCall = $this->headingCalls[count($this->headingCalls) - 1]; 298 $headingExitCall->setAttribute(HeadingTag::LEVEL, $level); 299 break; 300 } 301 302 /** 303 * Update the descendants sections 304 * @param OutlineSection $parentSection 305 * @return void 306 */ 307 $updateLevel = function (OutlineSection $parentSection) { 308 foreach ($parentSection->getChildren() as $child) { 309 $child->setLevel($parentSection->getLevel() + 1); 310 } 311 }; 312 TreeVisit::visit($this, $updateLevel); 313 314 return $this; 315 } 316 317 318 public function deleteContentCalls(): OutlineSection 319 { 320 $this->contentCalls = []; 321 return $this; 322 } 323 324 public function incrementLineNumber(): OutlineSection 325 { 326 $this->lineNumber++; 327 return $this; 328 } 329 330 public function getLineCount(): int 331 { 332 return $this->lineNumber; 333 } 334 335 public function setOutlineContext(Outline $outline): OutlineSection 336 { 337 $this->outlineContext = $outline; 338 return $this; 339 } 340 341 private function getRoot() 342 { 343 $actual = $this; 344 while ($actual->hasParent()) { 345 try { 346 $actual = $actual->getParent(); 347 } catch (ExceptionNotFound $e) { 348 // should not as we check before 349 } 350 } 351 return $actual; 352 } 353 354 /** 355 * @param MarkupPath|null $startPath - the path from where the page bundle is started to see if the link is of a page that was bundled 356 * @return $this - when merging 2 page, we need to make sure that the link becomes internal 357 * if the page was bundled 358 * (ie a link to :page:yolo become #pageyolo) 359 */ 360 public function updatePageLinkToInternal(?MarkupPath $startPath): OutlineSection 361 { 362 foreach ($this->contentCalls as $contentCall) { 363 364 if (!$contentCall->isPluginCall()) { 365 continue; 366 } 367 $componentName = $contentCall->getComponentName(); 368 if ($componentName === "combo_link" && $contentCall->getState() === DOKU_LEXER_ENTER) { 369 $refString = $contentCall->getAttribute("ref"); 370 if ($refString === null) { 371 continue; 372 } 373 try { 374 $markupRef = MarkupRef::createLinkFromRef($refString); 375 } catch (ExceptionBadArgument|ExceptionBadSyntax|ExceptionNotFound $e) { 376 // pffff 377 continue; 378 } 379 if ($markupRef->getSchemeType() !== MarkupRef::WIKI_URI) { 380 continue; 381 } 382 try { 383 $parentPath = $startPath->toWikiPath()->getParent()->toAbsoluteId(); 384 } catch (ExceptionNotFound $e) { 385 // root then 386 $parentPath = ":"; 387 } 388 if (!StringUtility::startWiths($refString, $parentPath)) { 389 continue; 390 } 391 $noCheck = false; 392 $expectedH1ID = sectionID($refString, $noCheck); 393 $contentCall->setAttribute("ref", "#" . $expectedH1ID); 394 395 } 396 } 397 398 /** 399 * Update the links to internal 400 */ 401 $updateLink = function (OutlineSection $parentSection) use ($startPath) { 402 foreach ($parentSection->getChildren() as $child) { 403 $child->updatePageLinkToInternal($startPath); 404 } 405 }; 406 TreeVisit::visit($this, $updateLink); 407 return $this; 408 } 409 410 411} 412