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 $this->headingEnterCall->setAttribute("id", $this->getHeadingId()); 67 } 68 } else { 69 $this->startFileIndex = 0; 70 } 71 $this->lineNumber = 1; // the heading 72 73 } 74 75 76 public static function createOutlineRoot(Outline $outlineContext): OutlineSection 77 { 78 return new OutlineSection($outlineContext,null); 79 } 80 81 82 /** 83 * Return a text to an HTML Id 84 * @param string $fragment 85 * @return string 86 */ 87 public static function textToHtmlSectionId(string $fragment): string 88 { 89 $check = false; 90 // for empty string, the below function returns `section` 91 return sectionID($fragment, $check); 92 } 93 94 public static function createFromEnterHeadingCall(Outline $outline,Call $enterHeadingCall): OutlineSection 95 { 96 return new OutlineSection($outline, $enterHeadingCall); 97 } 98 99 public function getFirstChild(): OutlineSection 100 { 101 102 /** @noinspection PhpIncompatibleReturnTypeInspection */ 103 return parent::getFirstChild(); 104 105 } 106 107 108 public function addContentCall(Call $actualCall): OutlineSection 109 { 110 111 $this->contentCalls[] = $actualCall; 112 return $this; 113 114 115 } 116 117 public function addHeaderCall(Call $actualCall): OutlineSection 118 { 119 120 $this->headingCalls[] = $actualCall; 121 return $this; 122 } 123 124 public function getLabel(): string 125 { 126 $label = ""; 127 foreach ($this->headingCalls as $call) { 128 if ($call->getTagName() === Outline::DOKUWIKI_HEADING_CALL_NAME) { 129 $label = $call->getInstructionCall()[1][0]; 130 // no more label call 131 break; 132 } 133 if ($call->isTextCall()) { 134 // Building the text for the toc 135 // only cdata for now 136 // no image, ... 137 if ($label != "") { 138 $label .= " "; 139 } 140 $label .= trim($call->getCapturedContent()); 141 } 142 } 143 return trim($label); 144 } 145 146 public function setStartPosition(int $startPosition): OutlineSection 147 { 148 $this->startFileIndex = $startPosition; 149 return $this; 150 } 151 152 public function setEndPosition(int $endFileIndex): OutlineSection 153 { 154 $this->endFileIndex = $endFileIndex; 155 return $this; 156 } 157 158 /** 159 * @return Call[] 160 */ 161 public function getHeadingCalls(): array 162 { 163 if ( 164 $this->headingEnterCall !== null && 165 $this->headingEnterCall->isPluginCall() && 166 !$this->headingEnterCall->hasAttribute("id") 167 ) { 168 $this->headingEnterCall->addAttribute("id", $this->getHeadingId()); 169 } 170 return $this->headingCalls; 171 } 172 173 174 public 175 function getEnterHeadingCall(): ?Call 176 { 177 return $this->headingEnterCall; 178 } 179 180 181 public 182 function getCalls(): array 183 { 184 return array_merge($this->headingCalls, $this->contentCalls); 185 } 186 187 public 188 function getContentCalls(): array 189 { 190 return $this->contentCalls; 191 } 192 193 /** 194 * @return int 195 */ 196 public 197 function getLevel(): int 198 { 199 if ($this->headingEnterCall === null) { 200 return 0; 201 } 202 switch ($this->headingEnterCall->getTagName()) { 203 case Outline::DOKUWIKI_HEADING_CALL_NAME: 204 $level = $this->headingEnterCall->getInstructionCall()[1][1]; 205 break; 206 default: 207 $level = $this->headingEnterCall->getAttribute(HeadingTag::LEVEL); 208 break; 209 } 210 211 try { 212 return DataType::toInteger($level); 213 } catch (ExceptionBadArgument $e) { 214 // should not happen 215 LogUtility::internalError("The level ($level) could not be cast to an integer", self::CANONICAL); 216 return 0; 217 } 218 } 219 220 public 221 function getStartPosition(): int 222 { 223 return $this->startFileIndex; 224 } 225 226 public 227 function getEndPosition(): ?int 228 { 229 return $this->endFileIndex; 230 } 231 232 public 233 function hasContentCall(): bool 234 { 235 return sizeof($this->contentCalls) > 0; 236 } 237 238 /** 239 */ 240 public 241 function getHeadingId() 242 { 243 244 if (!isset($this->headingId)) { 245 $id = $this->headingEnterCall->getAttribute("id"); 246 if ($id !== null) { 247 return $id; 248 } 249 250 $label = $this->getLabel(); 251 252 /** 253 * For Level 1 (ie Heading 1), we use the path as id and not the label 254 * Why? because when we bundle all pages in a single page 255 * (With {@link FetcherPageBundler} 256 * we can transform a wiki link to an internal link 257 */ 258 $level = $this->getLevel(); 259 if ($level === 1) { 260 // id is the path id 261 $markupPath = $this->getRoot()->outlineContext->getMarkupPath(); 262 if ($markupPath !== null) { 263 $label = $markupPath->toAbsoluteId(); 264 } 265 } 266 267 $this->headingId = sectionID($label, $this->tocUniqueId); 268 } 269 return $this->headingId; 270 271 } 272 273 /** 274 * A HTML section should have a heading 275 * but in a markup document, we may have data before the first 276 * heading making a section without heading 277 * @return bool 278 */ 279 public 280 function hasHeading(): bool 281 { 282 return $this->headingEnterCall !== null; 283 } 284 285 /** 286 * @return OutlineSection[] 287 */ 288 public 289 function getChildren(): array 290 { 291 return parent::getChildren(); 292 } 293 294 public function setLevel(int $level): OutlineSection 295 { 296 297 switch ($this->headingEnterCall->getTagName()) { 298 case Outline::DOKUWIKI_HEADING_CALL_NAME: 299 $this->headingEnterCall->getInstructionCall()[1][1] = $level; 300 break; 301 default: 302 $this->headingEnterCall->setAttribute(HeadingTag::LEVEL, $level); 303 $headingExitCall = $this->headingCalls[count($this->headingCalls) - 1]; 304 $headingExitCall->setAttribute(HeadingTag::LEVEL, $level); 305 break; 306 } 307 308 /** 309 * Update the descendants sections 310 * @param OutlineSection $parentSection 311 * @return void 312 */ 313 $updateLevel = function (OutlineSection $parentSection) { 314 foreach ($parentSection->getChildren() as $child) { 315 $child->setLevel($parentSection->getLevel() + 1); 316 } 317 }; 318 TreeVisit::visit($this, $updateLevel); 319 320 return $this; 321 } 322 323 324 public function deleteContentCalls(): OutlineSection 325 { 326 $this->contentCalls = []; 327 return $this; 328 } 329 330 public function incrementLineNumber(): OutlineSection 331 { 332 $this->lineNumber++; 333 return $this; 334 } 335 336 public function getLineCount(): int 337 { 338 return $this->lineNumber; 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