Экспорт и импорт заметок
Оглавление серии «Документы в вебе» · Урок 8 из 10.
Заметки сохраняются локально и восстанавливаются для тех же байтов документа. Но локальное хранилище браузера не является переносимым архивом. Добавим явный экспорт и импорт JSON. Результат урока — файл с версией схемы, идентификатором документа и заметками; импорт либо целиком заменяет набор после проверки, либо оставляет прежние записи на месте.
Мы не будем автоматически объединять два архива по текстам заметок. Такой договор требует правил разрешения конфликтов, удаления и повторного импорта. Для первого варианта выберем полную замену. Название кнопки и сообщение после действия должны объяснять это поведение, чтобы читатель не ожидал незаметного добавления записей к текущему набору.
Архив описывает свою схему
JSON-файл содержит schema, documentId и notes. Значение schema: 1 относится к нашему формату заметок. Оно не обозначает версию PDF, браузера или библиотеки. Если позже появятся прямоугольные области, цвета или ответы на комментарии, изменение структуры потребует миграции либо нового номера схемы. Нельзя молча трактовать разные формы данных как одинаковые только потому, что обе являются JSON.
documentId сохраняет SHA-256 точных байтов PDF. В предыдущем уроке этот идентификатор участвовал в ключе локального хранилища; теперь он сопровождает заметки за пределами браузера. Даже если человек переименует JSON, приложение сможет сравнить его внутреннюю связь с открытым документом. Имя файла остаётся удобством, а не главным доказательством соответствия версии.
Ниже полный модуль exchange.js, добавляемый на восьмом шаге. Он использует существующую проверку заметок из notes.js:
import { validateNotes } from './notes.js';
export function packNotes(documentId, notes) {
return { schema: 1, documentId, notes };
}
export async function readNotes(file, documentId, pageCount) {
if (!file || file.size > 4 * 1024 * 1024) throw new Error('Архив заметок больше 4 MiB');
const value = JSON.parse(await file.text());
if (!value || value.schema !== 1 || value.documentId !== documentId)
throw new Error('Архив относится к другой версии документа');
return validateNotes(value.notes, pageCount);
}
export function downloadJSON(value) {
const blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'list-notes.json';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Функция упаковки получает уже действительные записи рабочего набора. Она не записывает исходный PDF внутрь JSON. Поэтому перенос архива заметок на другой компьютер не доставляет сам документ. Там нужно открыть PDF с теми же байтами и импортировать JSON. Это разделение полезно, когда документ распространяется отдельно, но требует ясного пояснения в интерфейсе и инструкции курса.
Размер файла и размер текста
Перед чтением ограничиваем импорт четырьмя MiB. Затем после разбора JSON снова проверяем количество записей, длину текста, номера страниц и идентификаторы. Это разные пределы. Ограничение размера файла защищает от слишком большого входа ещё до создания объектов JavaScript. Ограничение структуры не позволяет небольшому JSON содержать недопустимые страницы или чрезмерное число коротких записей.
Предел импорта должен позволять вернуть собственный экспорт. В рабочем наборе допускаются двести заметок по две тысячи символов. JSON может дополнительно экранировать некоторые символы и содержать отступы. Поэтому нельзя выбрать очень маленький лимит файла только на основании того, что обычная заметка выглядит короткой. Если приложение разрешает сохранить набор, но затем запрещает импортировать его штатный экспорт, архив перестаёт выполнять обещанную задачу переноса.
file.text() читает содержимое файла как текст. Фильтр accept в HTML помогает выбирать JSON, но не доказывает, что выбранный файл соответствует нашей схеме. Расширение можно изменить, а содержимое может быть повреждено. Поэтому JSON.parse, проверка схемы и повторная проверка записей остаются обязательными. Строка, похожая на JavaScript, не выполняется: никакого eval или загрузки модулей из архива здесь нет.
Лишние поля отдельных записей не переходят в рабочие объекты, поскольку validateNotes создаёт разрешённую форму заново. Проверка не является общей системой доверия к любому JSON. Она реализует узкий договор нашего приложения: идентификатор, существующий номер страницы и обычный текст. Ссылки, команды, CSS и HTML не являются частью этого договора.
Замена происходит после полной проверки
Обработчик импорта сначала запоминает действующий объект заметок, документ и номер операции. Затем читает файл. Только после завершения чтения и проверки получает новый массив. До этого момента старые записи остаются на месте. Если одна запись из ста ошибочна, частично импортировать первые девяносто девять не будем: весь набор отклоняется с объяснением.
После ожидания проверяем, что документ и хранилище прежние, а операция последняя. Во время чтения человек может выбрать другой архив или, в последнем уроке, другой PDF. Медленный результат старого события не должен перезаписать более поздний выбор. Счётчик importEpoch управляет именно импортом; счётчики рендера и поиска не обязаны иметь с ним одинаковые значения.
const currentNotes = notes;
const pdf = viewer.pdf;
const epoch = ++importEpoch;
const next = await readNotes(event.target.files[0], viewer.documentId, pdf.numPages);
if (notes !== currentNotes || viewer.pdf !== pdf || epoch !== importEpoch) return;
currentNotes.replace(next);
showNotes();
status.textContent = 'Архив заменил локальные заметки';
Это основной участок обработчика из полного main.js. Его блок catch показывает ошибку только для действующей операции. Запись в localStorage остаётся внутри replace и предшествует замене состояния памяти. Если сохранение не удалось, успешного сообщения быть не должно, даже когда импортный JSON полностью корректен.
Скачивание использует временный адрес
При экспорте создаём Blob с типом application/json и получаем временный объектный URL. Он указывает на данные внутри браузера, а не на новый серверный файл. Ссылка с атрибутом download предлагает браузеру сохранить содержимое под понятным именем. Конкретное поведение загрузки зависит от браузера и его настроек; код не получает права незаметно выбрать произвольную папку на диске.
После инициирования скачивания удаляем временную ссылку из DOM и освобождаем URL через URL.revokeObjectURL. Освобождение отложено на небольшой интервал, чтобы не уничтожать адрес прямо в момент передачи действия браузеру. Это практический механизм управления ресурсом, а не гарантия завершения скачивания ровно через секунду. Документация объектных URL описывает освобождение связи, а не подтверждение сохранённого пользовательского файла.
JSON заметок содержит собственный текст читателя. Экспорт не превращает его автоматически в публичную публикацию. Если позже появится серверное хранение или отправка архива другому человеку, нужно спроектировать отдельное действие пользователя и права доступа. Сейчас наш код лишь предлагает локальное скачивание и принимает явно выбранный файл.
После самостоятельной работы ожидается такой цикл: добавить заметку, экспортировать набор, удалить локальную запись, затем импортировать архив для того же PDF и увидеть восстановленный текст. Архив с другим documentId, повторяющимися идентификаторами или номером несуществующей страницы должен оставить текущий набор прежним. Пустой корректный массив, напротив, является допустимой полной заменой и удаляет рабочие заметки. В следующем уроке используем эти данные для отдельного печатного представления.
Полные файлы этого шага находятся в папке lesson-08 архива «Лист». Учебный просмотрщик, библиотека и генератор PDF при подготовке не запускались; результаты выше ожидаются из разбора кода.