1/*  DokuWiki MoaiEditor Match_headers.js file
2    Version : 0.5 (May 5, 2026)
3    Author  : MoaiTools <info@moaitools.org>
4    License : GPL 3 (http://www.gnu.org/licenses/gpl.html) */
5
6MoaiEditor.MatchHeaders = class {
7
8    constructor(outer) {
9
10        // Arguments
11        this.outer = outer;
12
13        // Variables
14        this.nodes = [];            // Array of header nodes in the html      {i, text, type, handle, matchline}
15        this.candidates = [];       // Array of candidate lines               {i, text, type, linenum}
16    }
17    // ┌───────────────────────────────────┐
18    // │ Public                            │
19    // └───────────────────────────────────┘
20
21    findAll () {
22
23
24        // Find all header matches
25        for (let node of this.nodes) {
26            // Find the line
27            const line = this.findMatch(node);
28            // Update the header array (for header clicks and table of contents)
29            node.matchline = line;
30            // Append to the matches array (for synchonized scrolling)
31            if (line !== null)
32                this.outer.matches.add (node.type, node.handle, line, line, 'header');
33        }
34    }
35    // ────────────────────────────────────
36    findMatch (node) {
37
38        // Check for similar headers
39        var matches = [];
40        for (let candidate of this.candidates)
41            if (this.same(node, candidate))
42                matches.push(candidate);
43        // If there are no matches return null
44        if (matches.length == 0)
45            return null;
46        // If there is just one match, return the line number
47        if (matches.length == 1)
48            return matches[0].linenum;
49        // If there is more than one match, check if one of them has the same neighbors
50        var maxScore = -1;
51        var bestMatch = null;
52        for (let candidate of matches) {
53            const score = this.neighborsScore(node, candidate);
54            if (score > maxScore) {
55                maxScore = score;
56                bestMatch = candidate;
57            }
58        }
59        return bestMatch.linenum;
60    }
61    // ────────────────────────────────────
62    parseText (lines) {
63        this.candidates = [];
64        var i = 0;
65        var l = -1;
66        // Iterarate every line of text
67        for (let line of lines) {
68            l += 1;
69            // Continue if it's not a header line
70            const header = this.isHeader(line);
71            if (!header)
72                continue;
73            // Otherwise store the header
74            this.candidates.push({
75                i       : i,
76                type    : header.type,
77                text    : header.text,
78                linenum : l,
79            });
80            // Increment index
81            i += 1;
82        }
83    }
84    // ┌───────────────────────────────────┐
85    // │ Private                           │
86    // └───────────────────────────────────┘
87
88    neighborsScore (node, candidate) {
89
90        var d = 1;
91        var score = 0;
92        var idx_node = node.i;
93        var idx_text = candidate.i;
94        while (true) {
95            // Left neighbors
96            var leftNodeNeighbor = this.getNeighbor(this.nodes, idx_node-d);
97            var leftTextNeighbor = this.getNeighbor(this.candidates, idx_text-d);
98            if (this.same(leftNodeNeighbor, leftTextNeighbor))
99                score += 1;
100            // Right neighbors
101            var rightNodeNeighbor = this.getNeighbor(this.nodes, idx_node+d);
102            var rightTextNeighbor = this.getNeighbor(this.candidates, idx_text+d);
103            if (this.same(rightNodeNeighbor, rightTextNeighbor))
104                score += 1;
105            // Exit condition
106            if (score % 2 != 0  ||  leftNodeNeighbor === null   ||  rightNodeNeighbor === null)
107                break;
108            // Increment distance
109            d += 1;
110        }
111        return score/(2*d);
112    }
113    // ────────────────────────────────────
114    same (node, candidate) {
115        if (node === null  &&  candidate === null)
116            return true;
117        if (node === null  ||  candidate === null)
118            return false;
119        if (node.type != candidate.type)
120            return false;
121        if (node.text != candidate.text)
122            return false;
123        return true;
124    }
125    // ────────────────────────────────────
126    getNeighbor (array, idx) {
127        if (idx < 0  ||  idx > array.length-1)
128            return null;
129        return array[idx];
130    }
131    // ────────────────────────────────────
132    isHeader (line) {
133
134        // Fail if the line does not include '=='
135        if (line.search("==") == -1)
136            return null;
137        // Fail if the line starts with two or more spaces
138        if (line.substr(0,2) == '  ')
139            return null;
140        // Fail if the trimmed line does not end in '=='
141        if (line.trim().slice(-2) != '==')
142            return null;
143        // Search all sequences of two or more '='
144        let regex = /(={2,})/gd;
145        let matches = [...line.matchAll(regex)];
146        // Fail if we have less than two sequences
147        if (matches.length < 2)
148            return null;
149        // Extract the content of the header (inner part)
150        var first = matches[0];
151        var last  = matches.at(-1);
152        var pos_start = first.indices[0][1];
153        var pos_end   = last.index;
154        var content = line.substring(pos_start, pos_end);
155        var text = content.trim();
156        // Fail if the content is empty (or whitespace)
157        if (text.length == 0)
158            return null;
159        // Determine the type of header (h1, h2, etc)
160        var len = first.indices[0][1] - first.indices[0][0];
161        var type;
162        if (len < 6)
163            type = ['H5','H4','H3','H2'][len-2];
164        else
165            type = 'H1';
166        // Return the data
167        return {
168            type         : type,
169            text         : text,
170            content      : content,
171            contentStart : pos_start,
172            contentEnd   : pos_end
173        };
174    }
175}; // End Class
176