OILS / web / search.js View on Github | oils.pub

146 lines, 120 significant
1const res = await fetch(`${window.basePath}/index.json`);
2const index = await res.json();
3
4/**
5 * True Damerau–Levenshtein distance (allows multiple transpositions).
6 * Adapted from [0] with help from ChatGPT 5.2.
7 *
8 * Computes the DL distance between textA and textB. This is the number of edits between A and B. The possible edits are:
9 * - Insertion (adding a single char)
10 * - Deletion (removing a single char)
11 * - Substitution (changing a single char)
12 * - Transposition (swapping the locations of 2 chars)
13 *
14 * [0] https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance#Distance_with_adjacent_transpositions
15 */
16function damerauLevenshteinDistance(textA, textB) {
17 const lenA = textA.length;
18 const lenB = textB.length;
19
20 const INF = lenA + lenB;
21
22 // lastRowByChar == da in the pseudocode:
23 // maps a character -> last row index (1..lenA) where it appeared in textA
24 const lastRowByChar = new Map();
25
26 // dist is the shifted version of d. Size: (lenA+2) x (lenB+2)
27 const dist = Array.from({ length: lenA + 2 }, () => new Array(lenB + 2).fill(0));
28
29 // Initialize sentinel borders
30 dist[0][0] = INF;
31 for (let i = 0; i <= lenA; i++) {
32 dist[i + 1][0] = INF; // d[i, -1]
33 dist[i + 1][1] = i; // d[i, 0]
34 }
35 for (let j = 0; j <= lenB; j++) {
36 dist[0][j + 1] = INF; // d[-1, j]
37 dist[1][j + 1] = j; // d[ 0, j]
38 }
39
40 // Main dynamic programming algorithm
41 for (let i = 1; i <= lenA; i++) {
42 let lastMatchingColInB = 0; // db in the pseudocode (last column j where a[i] matched)
43 const charA = textA[i - 1];
44
45 for (let j = 1; j <= lenB; j++) {
46 const charB = textB[j - 1];
47
48 const lastRowWithCharBInA = lastRowByChar.get(charB) ?? 0; // k := da[b[j]]
49 const lastMatchingColForThisRow = lastMatchingColInB; // ℓ := db
50
51 const cost = (charA === charB) ? 0 : 1;
52 if (cost === 0) lastMatchingColInB = j;
53
54 // Shifted accesses:
55 // d[i-1, j-1] -> dist[i][j]
56 // d[i, j-1] -> dist[i+1][j]
57 // d[i-1, j ] -> dist[i][j+1]
58 const substitution = dist[i][j] + cost;
59 const insertion = dist[i + 1][j] + 1;
60 const deletion = dist[i][j + 1] + 1;
61
62 // Transposition term (shifted):
63 // d[k-1, ℓ-1] in pseudocode -> dist[k][ℓ]
64 const transposition =
65 dist[lastRowWithCharBInA][lastMatchingColForThisRow] +
66 (i - lastRowWithCharBInA - 1) +
67 cost +
68 (j - lastMatchingColForThisRow - 1);
69
70 dist[i + 1][j + 1] = Math.min(substitution, insertion, deletion, transposition);
71 }
72
73 // da[a[i]] := i
74 lastRowByChar.set(charA, i);
75 }
76
77 // return d[lenA, lenB] -> dist[lenA+1][lenB+1]
78 return dist[lenA + 1][lenB + 1];
79}
80
81/**
82 * Search the global `index` against `query`.
83 *
84 * The matching algorithm is as follows, include all items with:
85 * - An exact match
86 * - Damerau-Levenshtein distance <= 5
87 *
88 * Note that all matching is case-insensitive.
89 *
90 * Only the top 25 results are given. We first show the exact matches, and then
91 * the inexact matches ranked by decreasing DL distance.
92 */
93function search(query) {
94 if (!query) {
95 return [];
96 }
97
98 // We do case insensitive matching.
99 query = query.toLowerCase();
100
101 // Results is a list of pairs [entry, rank] where a lower rank is better.
102 const results = [];
103 for (const entry of index) {
104 const symbol = entry.symbol.toLowerCase();
105 if (symbol.includes(query)) {
106 results.push([entry, 0]);
107 continue;
108 }
109
110 const distance = damerauLevenshteinDistance(query, entry.symbol);
111 if (distance > 5) {
112 continue;
113 }
114
115 results.push([entry, distance]);
116 }
117
118 results.sort(([_a, aRank], [_b, bRank]) => aRank - bRank);
119 return results.map(([entry, _rank]) => entry).slice(0, 25);
120}
121
122
123const searchDiv = document.getElementById("search");
124const searchbar = document.getElementById("searchbar");
125
126let resultsList = null;
127searchbar.addEventListener('input', async (event) => {
128 const query = event.target.value;
129 const results = search(query);
130
131 if (resultsList) {
132 resultsList.remove();
133 }
134
135 resultsList = document.createElement('ul');
136 searchDiv.appendChild(resultsList);
137
138 for (const result of results) {
139 const item = document.createElement('li');
140 const link = document.createElement('a');
141 link.innerHTML = result.symbol;
142 link.href = window.basePath + '/' + result.anchor;
143 item.appendChild(link);
144 resultsList.appendChild(item);
145 }
146});