xref: /plugin/combo/ComboStrap/OutlineSection.php (revision dff3a8c8e9d5229502eb360aee88a665b7d1c2dc)
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