Импорт JSON с проверкой и сохранением прежних записей
Экспорт переносит собственные заметки в документ версии 1. Обратная операция сложнее: JSON может иметь неправильный формат, слишком большой размер или неожиданные поля. В этой главе добавим импорт на options. Результат — выбранный файл полностью проверяется до записи, существующие UUID не перезаписываются, а пользователь видит количество добавленных и пропущенных заметок. Это учебное слияние отсутствующих записей, не универсальное восстановление всего профиля.
Импорт является явным действием человека. Страница содержит input type="file" и кнопку добавления. Мы не читаем произвольный путь компьютера и не ищем файлы в папках без выбора пользователя. Атрибут accept помогает выбрать JSON, но не доказывает правильность содержимого. Даже файл с расширением .json может содержать другой документ. Поэтому проверка начинается с размера и JSON.parse, а окончательный договор подтверждает worker.
Прочитать ещё не значит принять
options.js требует выбранный файл и ограничивает его пятьюстами двенадцатью КиБ. Эта учебная граница сдерживает одну загрузку в интерфейс. Затем file.text читает содержание, JSON.parse строит значение. Корректный синтаксис JSON означает лишь возможность получить объект: в нём ещё может не быть notes или нужной версии. Мы показываем число записей в простой предварительной строке и спрашиваем подтверждение добавления, но worker всё равно должен проверить документ самостоятельно.
Подтверждение формулирует конкретное действие: добавить отсутствующие заметки и сохранить существующие. Оно не обещает заменить всё хранилище. Пользовательский выбор в браузерном confirm относится именно к этому импорту, а не к установке расширения или чужому аккаунту. В учебном пакете автор confirm не нажимал и файлы пользователя не выбирал. Мы показываем готовый механизм для самостоятельного действия читателя на собственных данных.
Worker требует format="reader-notes", version=1, массив notes и не более двухсот пятидесяти элементов. Это отдельное ограничение операции, не квота всей local-коллекции. Старые записи плюс новые могут превышать количество одного импорта, пока браузер позволяет их хранить. Каждый элемент проходит validateStored. Если любой объект неверен, map прерывается до storage.set, поэтому известная ошибка формы не оставляет уже добавленную первую половину файла.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/browser-extension-lab/lesson-09/.
data.js
export function validateNote(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Ожидался объект заметки");
}
for (const [key, max] of [["title", 200], ["quote", 2000], ["note", 2000]]) {
if (typeof value[key] !== "string" || value[key].length > max) {
throw new Error(`Некорректное поле ${key}`);
}
}
if (typeof value.url !== "string" || value.url.length > 4096) {
throw new Error("Некорректный адрес");
}
const url = new URL(value.url);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw new Error("Поддерживаются обычные веб-страницы без данных входа");
}
url.hash = "";
return { title: value.title, url: url.href, quote: value.quote, note: value.note };
}
export function validateStored(value) {
const note = validateNote(value);
if (typeof value.id !== "string" ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
.test(value.id)) {
throw new Error("Некорректный идентификатор");
}
if (typeof value.createdAt !== "string" ||
!Number.isFinite(Date.parse(value.createdAt))) {
throw new Error("Некорректная дата");
}
return { ...note, id: value.id, createdAt: new Date(value.createdAt).toISOString() };
}
worker.js
import { validateNote, validateStored } from "./data.js";
const trustedPages = ["popup.html", "options.html"].map(
(path) => chrome.runtime.getURL(path)
);
const ready = chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });
let queue = Promise.resolve();
async function listNotes() {
const values = await chrome.storage.local.get(null);
return Object.entries(values)
.filter(([key]) => key.startsWith("note:"))
.map(([, value]) => validateStored(value))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.id.localeCompare(b.id));
}
async function handle(message) {
await ready;
if (message.type === "LIST") return listNotes();
if (message.type === "SAVE") {
const value = validateNote(message.payload);
const note = { ...value, id: crypto.randomUUID(), createdAt: new Date().toISOString() };
await chrome.storage.local.set({ [`note:${note.id}`]: note });
return note;
}
if (message.type === "GET_SETTINGS") {
const { settings = { defaultNote: "" } } = await chrome.storage.local.get("settings");
return { defaultNote: typeof settings.defaultNote === "string"
? settings.defaultNote.slice(0, 2000) : "", storeFullUrl: settings.storeFullUrl === true };
}
if (message.type === "SET_SETTINGS") {
const value = message.payload;
if (!value || typeof value.defaultNote !== "string" || value.defaultNote.length > 2000) {
throw new Error("Начальный текст слишком длинный или имеет неверный тип");
}
const settings = { defaultNote: value.defaultNote, storeFullUrl: value.storeFullUrl === true };
await chrome.storage.local.set({ settings });
return settings;
}
if (message.type === "IMPORT") {
const bundle = message.payload;
if (!bundle || bundle.format !== "reader-notes" || bundle.version !== 1 ||
!Array.isArray(bundle.notes) || bundle.notes.length > 250) {
throw new Error("Неподдерживаемый формат или слишком много заметок");
}
const notes = bundle.notes.map(validateStored);
if (new Set(notes.map((note) => note.id)).size !== notes.length) {
throw new Error("В файле повторяется идентификатор");
}
const existing = await chrome.storage.local.get(notes.map((note) => `note:${note.id}`));
const incoming = Object.fromEntries(notes
.filter((note) => !Object.hasOwn(existing, `note:${note.id}`))
.map((note) => [`note:${note.id}`, note]));
await chrome.storage.local.set(incoming);
return { added: Object.keys(incoming).length, skipped: notes.length - Object.keys(incoming).length };
}
throw new Error("Неизвестная команда");
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id || !trustedPages.includes(sender.url) ||
!message || typeof message !== "object" || message.version !== 1 ||
typeof message.type !== "string") {
sendResponse({ ok: false, error: "Команда отклонена" });
return false;
}
queue = queue.then(() => handle(message)).then(
(data) => sendResponse({ ok: true, data }),
(error) => sendResponse({ ok: false, error: error.message })
);
// This literal true keeps sendResponse available for the asynchronous operation.
return true;
});
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; расширение не устанавливалось и не запускалось.
Импорт 2 новых UUID → added=2, skipped=0
Повтор того же файла → added=0, skipped=2
Неверная версия/повтор UUID/недопустимый URL → отказ до записи.
Идентичность и дубли
UUID сохраняется из экспорта. Внутри одного файла повтор UUID запрещён, потому что два объекта с одинаковой идентичностью имеют неоднозначный смысл. Не выбираем молча последнюю строку и не позволяем порядку массива определять замену. Пользователь должен исправить такой документ или экспортировать согласованный набор заново. Это особенно важно при ручной редактуре JSON: видимые одинаковые заголовки могут быть разными заметками, а одинаковый UUID всегда означает один объект по нашему договору.
После проверки worker читает ключи incoming id из local. Элементы, уже существующие по note:
Object.fromEntries собирает только безопасные ключи note:<проверенный UUID> и нормализованные объекты. Мы не используем неизвестные имена свойств из файла как произвольные ключи local. Настройки и другие области storage не входят в эту запись. Поэтому документ не может назначить себе полномочие поменять settings только из-за наличия дополнительного свойства. Проверка формы и проекция известных полей удерживают импорт внутри предметной области заметок.
Один writer и предел storage
Команда IMPORT проходит через ту же очередь worker, что SAVE и настройки. В пределах текущей жизни обработчика это не даёт другой нашей команде вклиниться между проверкой существующих ключей и записью. Но очередь остаётся оперативной, а storage.local не объявляется PostgreSQL-транзакцией. Мы не обещаем всем возможным ручным изменениям профиля ту же согласованность. Если появится второй writer, его потребуется включить в договор или поменять способ хранения.
Набор incoming передаётся одним set после проверки всех записей. При отказе Promise интерфейс не показывает успех. При неопределённом результате закрытия контекста пользователь может повторить импорт: уже существующие UUID должны быть пропущены. Такая повторяемость полезна, но не означает, что операция умеет откатывать любые внешние изменения. Она просто не создаёт новые идентичности для объектов из того же экспорта и не перезаписывает найденные записи.
Квота storage всё ещё может отказать, даже если файл прошёл ограничения размера и числа объектов. Размер файла и допустимая общая коллекция — разные условия. Мы не добавляем unlimitedStorage ради примера и не удаляем прежние заметки автоматически, чтобы освободить место. Данные пользователя важнее красивой надписи успеха. Разбор конкретной коллекции и выбор удаления были бы самостоятельной операцией с отдельным явным результатом.
Ожидаемое слияние
Положительный сценарий: экспорт двух заметок импортируется в пустую учебную коллекцию и даёт added=2, skipped=0. Повтор того же файла даёт added=0, skipped=2. Документ с неизвестной версией, повтором UUID или javascript: в url должен отказать до записи известных неверных данных. Текст с символами HTML остаётся текстом и позднее выводится через textContent. Импорт не исполняет код, не открывает все ссылки и не отправляет данные наружу.
Полные data.js и worker.js на этой странице содержат проверки; options.js с выбором файла находится в lesson-09. Все исходники совпадают с архивом, действия автора не выполнялись. Для самостоятельного разбора измените в вымышленном документе только одно свойство и определите этап отказа: синтаксис, оболочка, UUID, дата или URL. Так обратная операция становится понятной цепочкой проверок, а не кнопкой «загрузить всё». Следующий урок научит искать в полученной коллекции без изменения storage и без регулярного выражения из пользовательского ввода.
Подсчёт skipped не означает, что версии одинакового UUID совпали побайтно. Он означает лишь, что этот идентификатор уже есть в local и политика слияния сохраняет прежний объект. Если пользователь вручную изменил note в экспорте, повтор не применит такую правку к существующей записи. Это следует показать словами заранее. Иначе сообщение о пропущенной строке выглядело бы неожиданной потерей редактирования. Для изменения заметок нужен отдельный договор обновления и разрешения конфликтов, которого операция добавления отсутствующих записей намеренно не вводит.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Заметок читателя» содержит все двенадцать состояний. Установка, запуск и публикация не выполнялись.