tooling/search.js

Текстовый файл из учебного комплекта, не свидетельство установленного 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 });