Поиск в локальных заметках
Пользователь может сохранить и импортировать много заметок. Теперь добавим поиск по названию, собственному тексту, цитате и адресу. Результат главы — поле поиска меняет только видимую коллекцию, а сохранённые данные остаются прежними. Дополнительно разберём асинхронность: ответ предыдущего запроса не должен подменять результат более нового ввода, когда пользователь быстро меняет строку.
Поиск выполняется локально в popup после LIST. Сеть и отдельный поисковый сервер не нужны для учебного объёма данных. Строка запроса является текстом, а не регулярным выражением и не SQL. Мы выбираем includes для буквального поиска подстроки. Поэтому символы вроде квадратных скобок имеют обычный смысл и не превращаются в инструкции поиска. Более сложный язык запросов потребовал бы отдельной грамматики и объяснения, которого в текущем интерфейсе нет.
Нормализация запроса
Поле type="search" получает доступное имя. При чтении значения применяется trim и toLocaleLowerCase("ru"). Trim убирает внешние пробелы, а преобразование регистра делает простое сопоставление менее чувствительным к заглавным буквам. Это не полнотекстовый морфологический поиск: слово «заметка» не обязано находить «заметки», если буквальная последовательность не совпадает. Такое ограничение стоит назвать заранее, чтобы человек понимал результат без обещания интеллектуального анализа текста.
Для каждой записи собираем четыре известных строковых поля. Их типы уже подтвердил validateStored, поэтому обработчик может применить lower-case без угадывания. some возвращает true, если хотя бы одно поле содержит запрос. Мы не объединяем сырые объекты через JSON.stringify ради случайного поиска по служебным ключам. Пользователь ищет содержание и адрес, а UUID или название свойства не входят в заявленный поисковый смысл.
Пустая строка проходит includes для любой строки, поэтому после очистки поля снова видна вся коллекция. Это удобный явный договор, не особый статус ошибки. Если ни одна запись не подходит, список становится пустым, но storage не очищается. Такой момент особенно важен для пользователя: исчезновение элементов на экране означает фильтр, а не удаление. В дальнейшем полезный интерфейс может показывать число найденных записей, однако сам механизм уже определён и не зависит от такой подписи.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/browser-extension-lab/lesson-10/.
popup.js
const status = document.querySelector("#status");
const list = document.querySelector("#notes");
const noteInput = document.querySelector("#note");
async function request(type, payload) {
const reply = await chrome.runtime.sendMessage({ version: 1, type, payload });
if (!reply?.ok) throw new Error(reply?.error ?? "Нет ответа расширения");
return reply.data;
}
function render(notes) {
list.replaceChildren();
for (const note of notes) {
const item = document.createElement("li");
const title = document.createElement("strong");
title.textContent = note.title;
const text = document.createElement("p");
text.textContent = note.note || note.quote;
item.append(title, text);
list.append(item);
}
}
async function refresh() {
const notes = await request("LIST");
const query = document.querySelector("#search").value.trim().toLocaleLowerCase("ru");
const selected = notes.filter((note) => [note.title, note.note, note.quote, note.url]
.some((value) => value.toLocaleLowerCase("ru").includes(query)));
render(selected);
}
document.querySelector("#save").addEventListener("click", async () => {
const button = document.querySelector("#save");
button.disabled = true;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!Number.isInteger(tab?.id)) throw new Error("Нет активной вкладки");
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id }, files: ["content.js"]
});
const page = results.find((item) => item.frameId === 0)?.result;
if (!page) throw new Error("Не удалось прочитать страницу");
await request("SAVE", { ...page, note: noteInput.value });
noteInput.value = "";
status.textContent = "Заметка сохранена";
try { await refresh(); }
catch (error) { status.textContent = `Заметка сохранена, список не обновлён: ${error.message}`; }
} catch (error) {
status.textContent = `Сохранение не завершено: ${error.message}`;
} finally {
button.disabled = false;
}
});
document.querySelector("#settings").addEventListener("click", () => {
chrome.runtime.openOptionsPage();
});
refresh().catch((error) => { status.textContent = error.message; });
request("GET_SETTINGS").then((settings) => {
if (noteInput.value === "") noteInput.value = settings.defaultNote;
}).catch((error) => { status.textContent = error.message; });
let revision = 0;
document.querySelector("#search").addEventListener("input", async () => {
const current = ++revision;
try {
const notes = await request("LIST");
if (current !== revision) return;
const query = document.querySelector("#search").value.trim().toLocaleLowerCase("ru");
render(notes.filter((note) => [note.title, note.note, note.quote, note.url]
.some((value) => value.toLocaleLowerCase("ru").includes(query))));
} catch (error) { status.textContent = error.message; }
});
popup.html
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Заметки читателя</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Заметки читателя</h1>
<p>Сохраняйте собственную заметку о выбранной странице.</p>
<textarea id="note" maxlength="2000" aria-label="Ваша заметка"></textarea>
<button id="save" type="button">Сохранить страницу</button>
<button id="settings" type="button">Настройки</button>
<input id="search" type="search" aria-label="Поиск по заметкам">
<p id="status" role="status" aria-live="polite"></p>
<ul id="notes"></ul>
<script type="module" src="popup.js"></script>
</body>
</html>
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; расширение не устанавливалось и не запускалось.
Поиск utc → буквальное совпадение без учёта регистра.
Нет совпадения → пустой видимый список, local не меняется.
Очистка поля → все записи.
Устаревший revision не обновляет DOM.
Отбор и безопасный вывод
render сначала вызывает replaceChildren, затем создаёт элементы результата. Предыдущие строки исчезают из DOM, новые строятся из отфильтрованного массива. Title и note/quote по-прежнему выводятся через textContent. Мы не добавляем выделение совпадения через необработанный HTML. Даже если поисковая строка похожа на тег, она служит текстовому сопоставлению. Добавление подсветки позже должно сохранить эту границу, например создавая отдельные текстовые узлы и элементы, а не подставляя пользовательский запрос в innerHTML.
Сортировка коллекции остаётся определённой LIST: сначала дата, затем UUID при равенстве. Фильтрация сохраняет порядок оставшихся элементов. Не сортируем результат заново по длине совпадения без объяснения, поскольку пользователь уже привык видеть новые заметки первыми. Для иного ранжирования потребовалось бы назвать критерий и показать связанный пример. В этом уроке один независимый результат — выбрать подходящие строки, не меняя их идентичность и данные.
После SAVE refresh снова читает коллекцию и применяет текущий запрос поля. Это означает, что новая запись может не появиться в видимом списке, если не подходит фильтру. Такой исход не должен выглядеть неуспехом сохранения. Факт SAVE подтверждается отдельно, а фильтр отвечает за отображение. Поэтому сообщение состояния сохраняет различие: запись может быть сохранена, даже если сейчас не видна. Пользователь может очистить поиск и увидеть полную коллекцию.
Как устаревший ответ проигрывает новому
Input-событие увеличивает revision и запоминает текущее число. Затем оно ждёт LIST. После ответа сравнивает свой номер с актуальным revision. Если пользователь уже ввёл другую строку, старый обработчик не обновляет DOM. Это простой способ отменить эффект устаревшей асинхронной операции без отмены самой команды worker. Число существует только в текущем popup и не является постоянной версией данных storage.
Представьте два ввода: сначала «диапазон», затем «UTC». Первый LIST задержался, второй завершился быстрее. Без проверки первый обработчик мог бы последним показать неактуальную выборку. С revision он замечает, что его исходный номер больше не текущий, и прекращает вывод. Важно сравнивать после await, потому что именно во время ожидания появляется возможность нового события. Проверка только перед отправкой не защищала бы поздний результат.
Текущая реализация вызывает LIST на ввод, поэтому для очень большой коллекции понадобится другая оценка. Можно кэшировать полученные записи в popup и обновлять их по изменениям, либо добавить debounce. Но тогда надо определить, когда кэш становится устаревшим и как согласуется сохранение. Мы не вводим оптимизацию, не объясняя её договор. Для учебного набора важно сначала получить правильную актуальность результата и сохранить простой источник данных, а измерения скорости здесь не выполнялись.
Наблюдаемый смысл фильтра
Ожидаемый сценарий использует три вымышленные заметки: о диапазонах, о UTC и о manifest. Запрос «utc» оставляет только подходящую запись независимо от регистра исходного текста. Запрос с несуществующей подстрокой даёт пустой экранный список. Очистка поля возвращает все записи, подтверждая отсутствие удаления. Быстрый новый ввод должен побеждать старый ответ. Автор эти события не выполнял и не заявляет измеренную задержку; результат описывает механизм исходников.
Полный popup.js на этой странице совпадает с lesson-10; HTML также содержит поле search. Для самостоятельного разбора определите, какие значения являются постоянными, а какие относятся к одному поиску: заметки лежат в local, query и revision — в текущем интерфейсе. Завершение popup удалит фильтр, но не записи. Следующая глава пересмотрит адрес страницы с точки зрения минимального доступа и приватности, сохранив именно пользовательское действие как основание чтения.
Поисковая строка не сохраняется в local. При новом открытии popup пользователь видит полный список и начинает новый фильтр. Это подходит короткому инструменту чтения и объясняет, почему экспорт не зависит от текущей видимости результата. LIST для экспорта возвращает все собственные заметки, а не только подходящие последнему запросу DOM. Если понадобился бы экспорт выбранного поднабора, это стоило бы назвать отдельной операцией и показать её критерий, чтобы человек не получил неожиданно неполную резервную копию своей коллекции.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Заметок читателя» содержит все двенадцать состояний. Установка, запуск и публикация не выполнялись.