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