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