Текстовый файл из учебного комплекта, не свидетельство установленного runtime. Вернуться к учебнику · Исходный файл
1 /* Общий локальный поиск. Этот же модуль проверяется Node.js без браузера. 2 * Данные уже встроены в страницу; чтения диска или обращений к сервисам нет. */ 3 (function (root, factory) { 4 'use strict'; 5 const api = factory(); 6 if (typeof module === 'object' && module.exports) module.exports = api; 7 else { 8 root.VoiceTextbookSearch = api; 9 api.mount(root.document, root); 10 } 11 })(typeof globalThis === 'object' ? globalThis : this, function () { 12 'use strict'; 13 // Регистр и ё/е не мешают искать русские термины. Символы кода не отбрасываем. 14 function normalize(value) { return String(value).toLowerCase().replace(/ё/g, 'е'); } 15 function terms(query) { 16 const found = []; 17 for (const match of String(query).trim().matchAll(/"([^"]+)"|(\S+)/gu)) { 18 const term = normalize((match[1] || match[2]).trim()); 19 if (term && !found.includes(term)) found.push(term); 20 } 21 return found; 22 } 23 24 function ranges(text, words) { 25 // Не выполняем регулярное выражение пользователя и не создаём HTML из запроса. 26 const value = normalize(text), found = []; 27 for (const word of words) { 28 let offset = 0, start; 29 while ((start = value.indexOf(word, offset)) !== -1) { 30 found.push([start, start + word.length]); 31 offset = start + word.length; 32 } 33 } 34 found.sort((a, b) => a[0] - b[0] || b[1] - a[1]); 35 const merged = []; 36 for (const range of found) { 37 const last = merged[merged.length - 1]; 38 if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]); 39 else merged.push(range.slice()); 40 } 41 return merged; 42 } 43 44 function excerpt(text, words) { 45 const value = normalize(text); 46 // Выбираем окно с наибольшим числом разных слов. Номер строки относится 47 // к самому совпадению, а не к началу окружающего поясняющего фрагмента. 48 const positions = words.map(word => value.indexOf(word)).filter(n => n >= 0); 49 let best = positions[0] || 0, score = -1; 50 for (const at of positions) { 51 const window = value.slice(Math.max(0, at - 70), at + 190); 52 const matched = words.filter(word => window.includes(word)).length; 53 if (matched > score) { best = at; score = matched; } 54 } 55 const start = Math.max(0, best - 70), end = Math.min(text.length, best + 210); 56 return {text: (start ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''), 57 line: text.slice(0, best).split('\n').length}; 58 } 59 60 function create(index) { 61 // Нормализация больших исходников выполняется один раз, а не на каждую клавишу. 62 const entries = index.entries.map(entry => ({...entry, 63 key: normalize(entry.title + '\n' + (entry.chapter || '') + '\n' + entry.path + '\n' + entry.text), 64 titleKey: normalize(entry.title), pathKey: normalize(entry.path)})); 65 return {index, search(query) { 66 const words = terms(query); 67 if (!words.length) return []; 68 return entries.filter(entry => words.every(word => entry.key.includes(word))).map(entry => { 69 const snippet = excerpt(entry.text, words); 70 const score = (entry.kind === 'chapter' ? 8 : 0) + 71 words.filter(word => entry.titleKey.includes(word)).length * 15 + 72 words.filter(word => entry.pathKey.includes(word)).length * 6; 73 return {...entry, snippet: snippet.text, line: entry.kind === 'file' ? snippet.line : null, 74 href: entry.href + (entry.kind === 'file' ? '#L' + snippet.line : ''), score}; 75 }).sort((a, b) => b.score - a.score || a.path.localeCompare(b.path, 'ru') || a.title.localeCompare(b.title, 'ru')); 76 }}; 77 } 78 79 function mount(document, host) { 80 if (!document) return null; 81 const input = document.getElementById('book-search'); 82 if (!input) return null; 83 const panel = document.getElementById('book-search-results'); 84 const list = document.getElementById('book-search-list'); 85 const status = document.getElementById('book-search-status'); 86 const help = document.getElementById('book-search-help'); 87 const more = document.getElementById('book-search-more'); 88 let engine; 89 try { engine = create(JSON.parse(document.getElementById('voice-search-index').textContent)); } 90 catch (error) { 91 input.disabled = true; help.textContent = 'Индекс поиска повреждён. Проверьте комплект командой python3 tooling/verify.py.'; 92 return null; 93 } 94 const batch = 30; 95 let results = [], shown = 0, timer, lastQuery = ''; 96 help.textContent = `${engine.index.chapters} глава · ${engine.index.files} текстовых файлов. Все слова; фраза в "кавычках". Ctrl+K — поиск.`; 97 input.disabled = false; 98 99 function highlight(node, text, words) { 100 let offset = 0; 101 for (const [start, end] of ranges(text, words)) { 102 node.appendChild(document.createTextNode(text.slice(offset, start))); 103 const mark = document.createElement('mark'); mark.textContent = text.slice(start, end); 104 node.appendChild(mark); offset = end; 105 } 106 node.appendChild(document.createTextNode(text.slice(offset))); 107 } 108 function close() { host.clearTimeout(timer); panel.hidden = true; input.setAttribute('aria-expanded', 'false'); } 109 function addBatch() { 110 const words = terms(lastQuery); 111 for (const result of results.slice(shown, shown + batch)) { 112 const item = document.createElement('li'); 113 const link = document.createElement('a'); link.setAttribute('href', result.href); 114 const title = document.createElement('strong'); highlight(title, result.title, words); 115 const context = document.createElement('span'); context.className = 'search-context'; 116 context.textContent = result.kind === 'chapter' ? 'Глава · ' + result.chapter : result.path + ' · строка ' + result.line; 117 const snippet = document.createElement('span'); snippet.className = 'search-snippet'; 118 highlight(snippet, result.snippet, words); 119 link.appendChild(title); link.appendChild(context); link.appendChild(snippet); 120 link.addEventListener('click', () => { 121 close(); 122 // Повторный выбор текущего hash не вызывает hashchange. В этом случае 123 // сами раскрываем подробности и возвращаем фокус в найденный раздел. 124 if (result.href.startsWith('#') && host.location.hash === result.href) { 125 const target = document.getElementById(decodeURIComponent(result.href.slice(1))); 126 if (target) { 127 let ancestor = target.parentElement; 128 while (ancestor) { if (ancestor.tagName === 'DETAILS') ancestor.open = true; ancestor = ancestor.parentElement; } 129 target.setAttribute('tabindex', '-1'); target.focus({preventScroll: true}); target.scrollIntoView({block: 'start'}); 130 } 131 } 132 }); 133 item.appendChild(link); list.appendChild(item); 134 } 135 shown = Math.min(shown + batch, results.length); 136 more.hidden = shown >= results.length; 137 more.textContent = `Показать ещё · осталось ${results.length - shown}`; 138 status.textContent = results.length ? `Найдено: ${results.length}. Показано: ${shown}.` : 'Совпадений нет. Попробуйте другое слово или меньше слов.'; 139 } 140 function run() { 141 host.clearTimeout(timer); lastQuery = input.value.trim(); 142 list.replaceChildren(); shown = 0; results = engine.search(lastQuery); 143 if (!terms(lastQuery).length) { close(); status.textContent = ''; more.hidden = true; return; } 144 panel.hidden = false; input.setAttribute('aria-expanded', 'true'); addBatch(); 145 } 146 function first() { const link = list.querySelector('a'); if (link) link.focus(); } 147 input.addEventListener('input', () => { host.clearTimeout(timer); timer = host.setTimeout(run, 120); }); 148 input.addEventListener('focus', () => { if (input.value.trim()) run(); }); 149 input.addEventListener('keydown', event => { 150 if (event.key === 'ArrowDown') { event.preventDefault(); run(); first(); } 151 if (event.key === 'Escape') { event.preventDefault(); close(); } 152 }); 153 document.getElementById('book-search-form').addEventListener('submit', event => { event.preventDefault(); run(); first(); }); 154 document.getElementById('book-search-close').addEventListener('click', () => { input.focus(); close(); }); 155 more.addEventListener('click', addBatch); 156 panel.addEventListener('keydown', event => { 157 if (event.key === 'Escape') { event.preventDefault(); input.focus(); close(); return; } 158 if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return; 159 const links = Array.from(list.querySelectorAll('a')); 160 const at = links.indexOf(document.activeElement); 161 if (at < 0) return; 162 event.preventDefault(); 163 const next = at + (event.key === 'ArrowDown' ? 1 : -1); 164 if (next < 0) input.focus(); 165 else if (next < links.length) links[next].focus(); 166 else if (!more.hidden) more.focus(); 167 }); 168 document.addEventListener('keydown', event => { 169 if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { 170 event.preventDefault(); input.focus(); input.select(); 171 } 172 }); 173 document.addEventListener('click', event => { 174 if (!document.getElementById('book-search-shell').contains(event.target)) close(); 175 }); 176 return {engine, run, close, addBatch}; 177 } 178 return {normalize, terms, ranges, excerpt, create, mount}; 179 });