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