AuthorTodayExtractor

The script adds a button to the site for downloading books to an FB2 file

Verze ze dne 16. 09. 2023. Zobrazit nejnovější verzi.

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name           AuthorTodayExtractor
// @name:ru        AuthorTodayExtractor
// @namespace      90h.yy.zz
// @version        1.0.2
// @author         Ox90
// @match          https://author.today/*
// @description    The script adds a button to the site for downloading books to an FB2 file
// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
// @require        https://greatest.deepsurf.us/scripts/468831-html2fb2lib/code/HTML2FB2Lib.js?version=1249508
// @grant          GM.xmlHttpRequest
// @grant          unsafeWindow
// @connect        *
// @run-at         document-start
// @license        MIT
// ==/UserScript==

/**
 * Разрешение `@connect *` Необходимо для пользователей tampermonkey, чтобы получить возможность загружать картинки
 * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
 * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
 * "Always allow all domains" при подтверждении запроса. Детали:
 * https://www.tampermonkey.net/documentation.php#_connect
 */

(function start() {
  "use strict";

const PROGRAM_NAME = "ATExtractor";

let app = null;
let stage = 0;
let mobile = false;
let mainBtn = null;

/**
 * Начальный запуск скрипта сразу после загрузки страницы сайта
 *
 * @return void
 */
function init() {
  pageHandler();
  // Следить за ajax контейнером
  const ajax_el = document.getElementById("pjax-container");
  if (ajax_el) (new MutationObserver(() => pageHandler())).observe(ajax_el, { childList: true });
}

/**
 * Начальная идентификация страницы и запуск необходимых функций
 *
 * @return void
 */
function pageHandler() {
  const path = document.location.pathname;
  if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) {
    // Это страница настроек (личный кабинет пользователя)
    ensureSettingsMenuItems();
    if (path === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") {
      // Это страница настроек скрипта
      handleSettingsPage();
    }
    return;
  }
  if (/work\/\d+$/.test(path)) {
    // Страница книги
    handleWorkPage();
    return;
  }
}

/**
 * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры
 *
 * @return void
 */
function handleWorkPage() {
  // Найти и сохранить объект App.
  // App нужен для получения userId, который используется как часть ключа при расшифровке.
  app = window.app || (unsafeWindow && unsafeWindow.app) || {};
  // Добавить кнопку на панель
  setMainButton();
}

/**
 * Находит панель и добавляет туда кнопку, если она отсутствует.
 * Вызывается не только при инициализации скрипта но и при изменениях ajax контейнере сайта
 *
 * @return void
 */
function setMainButton() {
  // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки
  let a_panel = null;
  if (document.querySelector("div.book-action-panel a[href^='/reader/']")) {
    a_panel = document.querySelector("div.book-panel div.book-action-panel");
    mobile = false;
  } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) {
    a_panel = document.querySelector("div.work-details div.row div.btn-library-work");
    a_panel = a_panel && a_panel.parentElement;
    mobile = true;
  }
  if (!a_panel) return;

  if (!mainBtn) {
    // Похоже кнопки нет. Создать кнопку и привязать действие.
    mainBtn = createButton(mobile);
    const ael = mobile && mainBtn || mainBtn.children[0];
    ael.addEventListener("click", event => {
      event.preventDefault();
      displayDownloadDialog();
    });
  }

  if (!a_panel.contains(mainBtn)) {
    // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
    // Если не удалось найти нужную позицию, тогда добавить кнопку как последнюю в панели.
    let sbl = null;
    if (!mobile) {
      sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
      sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling);
    } else {
      sbl = a_panel.querySelector("#btn-download");
      if (sbl) sbl = sbl.nextElementSibling;
    }
    if (!sbl) {
      if (!mobile) {
        sbl = document.querySelector("div.mt-lg.text-center");
      } else {
        sbl = a_panel.querySelector("a.btn-work-more");
      }
    }
    // Добавить кнопку на страницу книги
    if (sbl) {
      a_panel.insertBefore(mainBtn, sbl);
    } else {
      a_panel.appendChild(mainBtn);
    }
  }
}

/**
 * Создает и возвращает элемент кнопки, которая размещается на странице книги
 *
 * @return Element HTML-элемент кнопки для добавления на страницу
 */
function createButton() {
  const ae = document.createElement("a");
  ae.classList.add("btn", "btn-default", mobile && "btn-download-work" || "btn-block");
  ae.style.borderColor = "green";
  ae.innerHTML = "<i class=\"icon-download\"></i>";
  ae.appendChild(document.createTextNode(""));
  let btn = ae;
  if (!mobile) {
    btn = document.createElement("div");
    btn.classList.add("mt-lg");
    btn.appendChild(ae);
  }
  btn.setText = function(text) {
    let el = this.nodeName === "A" ? this : this.querySelector("a");
    el.childNodes[1].textContent = " " + (text || "Скачать FB2");
  };
  btn.setText();
  return btn;
}

/**
 * Обработчик нажатия кнопки "Скачать FB2" на странице книги
 *
 * @return void
 */
async function displayDownloadDialog() {
  if (mainBtn.disabled) return;
  try {
    mainBtn.disabled = true;
    mainBtn.setText("Анализ...");
    const params = getBookOverview();
    let log = null;
    let doc = new FB2DocumentEx();
    doc.bookTitle = params.title;
    doc.id = params.workId;
    doc.idPrefix = "atextr_";
    doc.status = params.status;
    doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
    const chapters = await getChaptersList(params);
    doc.totalChapters = chapters.length;
    const dlg = new DownloadDialog({
      title: "Формирование файла FB2",
      mobile: mobile,
      annotation: !!params.authorNotes,
      images: true,
      materials: !!params.materials,
      chapters: chapters,
      onclose: () => {
        Loader.abortAll();
        log = null;
        doc = null;
        if (dlg.link) {
          URL.revokeObjectURL(dlg.link.href);
          dlg.link = null;
        }
      },
      onsubmit: result => {
        result.bookPanel = params.bookPanel;
        result.annotation = params.annotation;
        if (result.authorNotes) result.authorNotes = params.authorNotes;
        if (result.materials) result.materials = params.materials;
        dlg.result = result;
        makeAction(doc, dlg, log);
      }
    });
    dlg.show();
    log = new LogElement(dlg.log);
    if (chapters.length) {
      setStage(0);
    } else {
      dlg.button.textContent = setStage(3);
      dlg.nextPage();
      log.warning("Нет доступных глав для выгрузки!");
    }
  } catch (err) {
    console.error(err);
    Notification.display(err.message, "error");
  } finally {
    mainBtn.disabled = false;
    mainBtn.setText();
  }
}

/**
 * Фактический обработчик нажатий на кнопку формы выгрузки
 *
 * @param FB2Document    doc Формируемый документ
 * @param DownloadDialog dlg Экземпляр формы выгрузки
 * @param LogElement     log Лог для фиксации прогресса
 *
 * @return void
 */
async function makeAction(doc, dlg, log) {
  try {
    switch (stage) {
      case 0:
        dlg.button.textContent = setStage(1);
        dlg.nextPage();
        await getBookContent(doc, dlg.result, log);
        if (stage == 1) dlg.button.textContent = setStage(2);
        break;
      case 1:
        Loader.abortAll();
        dlg.button.textContent = setStage(3);
        log.warning("Операция прервана");
        Notification.display("Операция прервана", "warning");
        break;
      case 2:
        if (!dlg.link) {
          dlg.link = document.createElement("a");
          dlg.link.download = genBookFileName(doc);
          // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt
          dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
        }
        dlg.link.click();
        break;
      case 3:
        dlg.hide();
        break;
    }
  } catch (err) {
    if (err.name !== "AbortError") {
      console.error(err);
      log.message(err.message, "red");
      Notification.display(err.message, "error");
    }
    dlg.button.textContent = setStage(3);
  }
}

/**
 * Выбор стадии работы скрипта
 *
 * @param int new_stage Числовое значение новой стадии
 *
 * @return string Текст для кнопки диалога
 */
function setStage(new_stage) {
  stage = new_stage;
  return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
}

/**
 * Возвращает объект с предварительными результатами анализа книги
 *
 * @return Object
 */
function getBookOverview() {
  const res = {};

  res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") ||
    document.querySelector("div.work-details div.work-header-content");

  res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title"));
  res.title = res.title ? res.title.textContent.trim() : null;

  const wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
  res.workId = wid && wid[1] || null;

  const status_el = res.bookPanel && res.bookPanel.querySelector(".book-status-icon");
  if (status_el.classList.contains("icon-check")) {
    res.status = "finished";
  } else if (status_el.classList.contains("icon-pencil")) {
    res.status = "in-progress";
  }

  const empty = el => {
    if (!el) return false;
    // Считается что аннотация есть только в том случае,
    // если имеются непустые текстовые ноды непосредственно в блоке аннотации
    return !Array.from(el.childNodes).some(node => {
      return node.nodeName === "#text" && node.textContent.trim() !== "";
    });
  };

  let annotation = mobile ?
    document.querySelector("div.card-content-inner>div.card-description") :
    (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation"));
  if (annotation.children.length > 0) {
    const notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0");
    if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement;
    annotation = annotation.querySelector(":scope>div.rich-content");
    if (!empty(annotation) && annotation !== notes) res.annotation = annotation;
  }

  const materials = mobile ?
    document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") :
    res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture");
  if (materials) {
    res.materials = materials;
  }

  return res;
}

/**
 * Возвращает список глав из DOM-дерева сайта в формате
 * { title: string, locked: bool, workId: string, chapterId: string }.
 *
 * @return array Массив объектов с данными о главах
 */
async function getChaptersList(params) {
  const el_list = document.querySelectorAll(
    mobile &&
    "div.work-table-of-content>ul.list-unstyled>li" ||
    "div.book-tab-content>div#tab-chapters>ul.table-of-content>li"
  );

  if (!el_list.length) {
    // Не найдено ни одной главы, возможно это рассказ
    // Запрашивает первую главу чтобы получить объект в исходном HTML коде ответа сервера
    let chapters = null;
    try {
      const r = await Loader.addJob(`/reader/${params.workId}`, {
        method: "GET",
        responseType: "text"
      });
      const meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы
      if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера");
      let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response);
      w_id = w_id && w_id[1] || params.workId;
      let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response);
      c_ls = c_ls && c_ls[1] || "[]";
      chapters = (JSON.parse(c_ls) || []).map(ch => {
        return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
      });
      const w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response);
      if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) chapters[0].title = "";
      chapters[0].locked = false;
    } catch (err) {
      console.error(err);
      throw new Error("Ошибка загрузки метаданных главы");
    }
    return chapters;
  }
  // Анализирует найденные HTML элементы с главами
  const res = [];
  for (let i = 0; i < el_list.length; ++i) {
    const el = el_list[i].children[0];
    if (el) {
      let ids = null;
      const title = el.textContent;
      let locked = false;
      if (el.tagName === "A" && el.hasAttribute("href")) {
        ids = /^\/reader\/(\d+)\/(\d+)$/.exec(el.getAttribute("href"));
      } else if (el.tagName === "SPAN") {
        if (el.parentElement.querySelector("i.icon-lock")) locked = true;
      }
      if (title && (ids || locked)) {
        const ch = { title: title, locked: locked };
        if (ids) {
          ch.workId = ids[1];
          ch.chapterId = ids[2];
        }
        res.push(ch);
      }
    }
  }
  return res;
}

/**
 * Производит формирование описания книги, загрузку и анализ глав и доп.материалов
 *
 * @param FB2DocumentEx doc   Формируемый документ
 * @param Object        bdata Объект с предварительными данными
 * @param LogElement    log   Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function getBookContent(doc, bdata, log) {
  await extractDescriptionData(doc, bdata, log);
  if (stage !== 1) return;

  log.message("---");
  await extractChapters(doc, bdata.chapters, { noImages: bdata.noImages }, log);
  if (stage !== 1) return;

  if (bdata.materials) {
    log.message("---");
    log.message("Дополнительные материалы:");
    await extractMaterials(doc, bdata.materials, log);
    doc.hasMaterials = true;
    if (stage !== 1) return;
  }
  if (!bdata.noImages) {
    const icnt = doc.binaries.reduce((cnt, img) => {
      if (!img.value) ++cnt;
      return cnt;
    }, 0);
    if (icnt) {
      log.message("---");
      log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
      if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) {
        const li = log.message("Применение заглушки...");
        try {
          const img = getDummyImage();
          replaceBadImages(doc, img);
          doc.binaries.push(img);
          li.ok();
        } catch (err) {
          li.fail();
          throw err;
        }
      } else {
        log.message("Проблемные изображения заменены на текст");
      }
    }
  }
  if (doc.unknowns) {
    log.message("---");
    log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
    log.message("Преобразованы в текст без форматирования");
  }
  doc.history.push("v1.0 - создание fb2 - (Ox90)");
  log.message("---");
  log.message("Готово!");
  if (Settings.get("fnhint", true)) {
    log.message("---");
    const hint = document.createElement("span");
    hint.innerHTML =
      "<i>Для формирования имени файла будет использован следующий шаблон: <b>" + Settings.get("filename") +
      "</b>. Вы можете изменить шаблон и скрыть это сообщение в " +
      " <a href=\"/account/settings?script=atex\" target=\"_blank\">настройках скрипта</a> в личном кабинете.</i>";
    log.message(hint);
  }
}

/**
 * Извлекает доступные данные описания книги из DOM элементов сайта
 *
 * @param FB2DocumentEx doc   Формируемый документ
 * @param Object        bdata Объект с предварительными данными
 * @param LogElement    log   Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function extractDescriptionData(doc, bdata, log) {
  if (!bdata.bookPanel) throw new Error("Не найдена панель с информацией о книге!");
  if (!doc.bookTitle) throw new Error("Не найден заголовок книги");
  const book_panel = bdata.bookPanel;

  log.message("Заголовок:").text(doc.bookTitle);
  // Авторы
  const authors = mobile ?
    book_panel.querySelectorAll("div.card-author>a") :
    book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
  doc.bookAuthors = Array.from(authors).reduce((list, el) => {
    const au = el.textContent.trim();
    if (au) {
      const a = new FB2Author(au);
      const hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec((new URL(el.href)).pathname);
      if (hp) a.homePage = (new URL(`/u/${hp[1]}`, document.location)).toString();
      list.push(a);
    }
    return list;
  }, []);
  if (!doc.bookAuthors.length) throw new Error("Не найдена информация об авторах");
  log.message("Авторы:").text(doc.bookAuthors.length);
  // Жанры
  let genres = mobile ?
    book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") :
    book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]");
  genres = Array.from(genres).reduce((list, el) => {
    const s = el.textContent.trim();
    if (s) list.push(s);
    return list;
  }, []);
  doc.genres = new FB2GenreList(genres);
  if (doc.genres.length) {
    console.info("Жанры: " + doc.genres.map(g => g.value).join(", "));
  } else {
    console.warn("Не идентифицирован ни один жанр!");
  }
  log.message("Жанры:").text(doc.genres.length);
  // Ключевые слова
  const tags = mobile ?
    document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
    book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
  doc.keywords = Array.from(tags).reduce((list, el) => {
    const tag = el.textContent.trim();
    if (tag) list.push(tag);
    return list;
  }, []);
  log.message("Ключевые слова:").text(doc.keywords.length || "нет");
  // Серия
  let seq_el = Array.from(book_panel.querySelectorAll("div>a")).find(el => {
    return el.href && /^\/work\/series\/\d+$/.test((new URL(el.href)).pathname);
  });
  if (seq_el) {
    const name = seq_el.textContent.trim();
    if (name) {
      const seq = { name: name };
      seq_el = seq_el.nextElementSibling;
      if (seq_el && seq_el.tagName === "SPAN") {
        const num = /^#(\d+)$/.exec(seq_el.textContent.trim());
        if (num) seq.number = num[1];
      }
      doc.sequence = seq;
      log.message("Серия:").text(name);
      if (seq.number) log.message("Номер в серии:").text(seq.number);
    }
  }
  // Дата книги (последнее обновление)
  const dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
  if (dt) {
    const d = new Date(dt.getAttribute("data-time"));
    if (!isNaN(d.valueOf())) doc.bookDate = d;
  }
  log.message("Дата книги:").text(doc.bookDate ? FB2Utils.dateToAtom(doc.bookDate) : "n/a");
  // Ссылка на источник
  doc.sourceURL = document.location.origin + document.location.pathname;
  log.message("Источкик:").text(doc.sourceURL);
  // Обложка книги
  const cp_el = mobile ?
    document.querySelector("div.work-cover>a.work-cover-content>img.cover-image") :
    document.querySelector("div.book-cover>a.book-cover-content>img.cover-image");
  if (cp_el) {
    const src = cp_el.src;
    if (src) {
      const img = new FB2Image(src);
      const li = log.message("Загрузка обложки...");
      try {
        await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
        img.id = "cover" + img.suffix();
        doc.coverpage = img;
        doc.binaries.push(img);
        li.ok();
        log.message("Размер обложки:").text(img.size + " байт");
        log.message("Тип обложки:").text(img.type);
      } catch (err) {
        li.fail();
        throw err;
      }
    }
  }
  if (!doc.coverpage) log.warning("Обложка книги не найдена!");
  // Аннотация
  if (bdata.annotation || bdata.authorNotes) {
    const li = log.message("Анализ аннотации...");
    try {
      doc.bindParser(new AnnotationParser(), "a");
      if (bdata.annotation) {
        await doc.parse("a", log, {}, bdata.annotation);
      }
      if (bdata.authorNotes) {
        if (doc.annotation && doc.annotation.children.length) {
          // Пустая строка между аннотацией и примечаниями автора
          doc.annotation.children.push(new FB2EmptyLine());
        }
        await doc.parse("a", log, {}, bdata.authorNotes);
      }
      li.ok();
    } catch (err) {
      li.fail();
      throw err;
    } finally {
      doc.bindParser();
    }
  } else {
    log.warning("Нет аннотации!");
  }
}

/**
 * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
 * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
 *
 * @param FB2DocumentEx doc     Формируемый документ
 * @param Array         desired Массив с описанием глав для выгрузки (id и название)
 * @param object        params  Параметры формирования глав
 * @param LogElement    log     Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function extractChapters(doc, desired, params, log) {
  let li = null;
  try {
    const total = desired.length;
    let position = 0;
    doc.bindParser(new ChapterParser(), "c");
    for (const ch of desired) {
      if (stage !== 1) break;
      li = log.message(`Получение главы ${++position}/${total}...`);
      const html = await getChapterContent(ch.workId, ch.chapterId);
      await doc.parse("c", log, params, html.body, ch.title);
      li.ok();
    }
  } catch (err) {
    if (li) li.fail();
    throw err;
  } finally {
    doc.bindParser();
  }
}

/**
 * Запрашивает содержимое указанной главы с сервера
 *
 * @param string workId    Id книги
 * @param string chapterId Id главы
 *
 * @return HTMLDocument главы книги
 */
async function getChapterContent(workId, chapterId) {
  // Id-ы числовые, отфильтрованы регуляркой, кодировать для запроса не нужно
  const result = await Loader.addJob(new URL(`/reader/${workId}/chapter?id=${chapterId}`, document.location), {
    method: "GET",
    headers: { "Accept": "application/json, text/javascript, */*; q=0.01" },
    responseType: "text",
  });
  const readerSecret = result.headers["reader-secret"];
  if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста");
  let response = null;
  try {
    response = JSON.parse(result.response);
  } catch (err) {
    console.error(err);
    throw new Error("Неожиданный ответ сервера");
  }
  if (!response.isSuccessful) throw new Error("Сервер ответил: Unsuccessful");
  // Декодировать ответ от сервера
  const chapterString = decryptText(response, readerSecret);
  // Преобразовать в HTML элемент.
  // Присваивание innerHTML не ипользуется по причине его небезопасности.
  // Лучше перестраховаться на случай возможного внедрения скриптов в тело книги.
  return new DOMParser().parseFromString(chapterString, "text/html");
}

/**
 * Расшифровывает полученную от сервера строку с текстом
 *
 * @param chapter string Зашифованная глава книги, полученная от сервера
 * @param secret  string Часть ключа для расшифровки
 *
 * @return string Расшифрованный текст
 */
function decryptText(chapter, secret) {
  let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
  let slen = ss.length;
  let clen = chapter.data.text.length;
  let result = [];
  for (let pos = 0; pos < clen; ++pos) {
    result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
  }
  return result.join("");
}

/**
 * Просматривает элементы с картинками в дополнительных материалах,
 * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
 *
 * @param FB2DocumentEx doc       Формируемый документ
 * @param Element       materials HTML-элемент с дополнительными материалами
 * @param LogElement    log       Лог для фиксации процесса формирования книги
 *
 * @return void
 */
async function extractMaterials(doc, materials, log) {
  const list = Array.from(materials.querySelectorAll("figure")).reduce((res, el) => {
    const link = el.querySelector("a");
    if (link && link.href) {
      const ch = new FB2Chapter();
      const cp = el.querySelector("figcaption");
      const ds = (cp && cp.textContent.trim() !== "") ? cp.textContent.trim() : "Без описания";
      const im = new FB2Image(link.href);
      ch.children.push(new FB2Paragraph(ds));
      ch.children.push(im);
      res.push(ch);
      doc.binaries.push(im);
    }
    return res;
  }, []);

  let cnt = list.length;
  if (cnt) {
    let pos = 0;
    while (true) {
      const l = [];
      // Грузить не более 5 картинок за раз
       while (pos < cnt && l.length < 5) {
        const li = log.message("Загрузка изображения...");
        l.push(list[pos++].children[1].load((loaded, total) => li.text(`${Math.round(loaded / total * 100)}%`))
            .then(() => li.ok())
            .catch(err => {
              li.fail();
              if (err.name === "AbortError") throw err;
            })
        );
      }
      if (!l.length || stage !== 1) break;
      await Promise.all(l);
    }
    const ch = new FB2Chapter("Дополнительные материалы");
    ch.children = list;
    doc.chapters.push(ch);
  } else {
    log.warning("Изображения не найдены");
  }
}

/**
 * Создает картинку-заглушку в фомате png
 *
 * @return FB2Image
 */
function getDummyImage() {
  const WIDTH = 300;
  const HEIGHT = 150;
  let canvas = document.createElement("canvas");
  canvas.setAttribute("width", WIDTH);
  canvas.setAttribute("height", HEIGHT);
  if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
  let ctx = canvas.getContext("2d");
  // Фон
  ctx.fillStyle = "White";
  ctx.fillRect(0, 0, WIDTH, HEIGHT);
  // Обводка
  ctx.lineWidth = 4;
  ctx.strokeStyle = "Gray";
  ctx.strokeRect(0, 0, WIDTH, HEIGHT);
  // Тень
  ctx.shadowOffsetX = 2;
  ctx.shadowOffsetY = 2;
  ctx.shadowBlur = 2;
  ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
  // Крест
  let margin = 25;
  let size = 40;
  ctx.lineWidth = 10;
  ctx.strokeStyle = "Red";
  ctx.moveTo(WIDTH / 2 - size / 2, margin);
  ctx.lineTo(WIDTH / 2 + size / 2, margin + size);
  ctx.stroke();
  ctx.moveTo(WIDTH / 2 + size / 2, margin);
  ctx.lineTo(WIDTH / 2 - size / 2, margin + size);
  ctx.stroke();
  // Текст
  ctx.font = "42px Times New Roman";
  ctx.fillStyle = "Black";
  ctx.textAlign = "center";
  ctx.fillText("No image", WIDTH / 2, HEIGHT - 30, WIDTH);
  // Формирование итогового FB2 элемента
  const img = new FB2Image();
  img.id = "dummy.png";
  img.type = "image/png";
  let data_str = canvas.toDataURL(img.type);
  img.value = data_str.substr(data_str.indexOf(",") + 1);
  return img;
}

/**
 * Замена всех незагруженных изображений другим изображением
 *
 * @param FB2DocumentEx doc Формируемый документ
 * @param FB2Image      img Изображение для замены
 *
 * @return void
 */
function replaceBadImages(doc, img) {
  const replaceChildren = function(fr, img) {
    for (let i = 0; i < fr.children.length; ++i) {
      const ch = fr.children[i];
      if (ch instanceof FB2Image) {
        if (!ch.value) fr.children[i] = img;
      } else {
        replaceChildren(ch, img);
      }
    }
  };
  if (doc.annotation) replaceChildren(doc.annotation, img);
  doc.chapters.forEach(ch => replaceChildren(ch, img));
  if (doc.materials) replaceChildren(doc.materials, img);
}

/**
 * Формирует имя файла для книги
 *
 * @param FB2DocumentEx doc FB2 документ
 *
 * @return string Имя файла с расширением
 */
function genBookFileName(doc) {
  function xtrim(s) {
    const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
    return r && r[1] || s;
  }

  const ndata = new Map();
  // Автор [\a]
  const author = doc.bookAuthors[0];
  if (author) {
    const author_names = [ author.firstName, author.middleName, author.lastName ].reduce(function(res, nm) {
      if (nm) res.push(nm);
      return res;
    }, []);
    if (author_names.length) {
      ndata.set("a", author_names.join(" "));
    } else if (author.nickname) {
      ndata.set("a", author.nickname);
    }
  }
  // Серия [\s, \n, \N]
  const seq_names = [];
  if (doc.sequence && doc.sequence.name) {
    const seq_name = xtrim(doc.sequence.name);
    if (seq_name) {
      const seq_num = doc.sequence.number;
      if (seq_num) {
        ndata.set("n", seq_num);
        ndata.set("N", (seq_num.length < 2 ? "0" : "") + seq_num);
        seq_names.push(seq_name + " " + seq_num);
      }
      ndata.set("s", seq_name);
      seq_names.push(seq_name);
    }
  }
  // Название книги. Делается попытка вырезать название серии из названия книги [\t]
  let book_name = xtrim(doc.bookTitle);
  const book_lname = book_name.toLowerCase();
  const book_len = book_lname.length;
  for (let i = 0; i < seq_names.length; ++i) {
    const seq_lname = seq_names[i].toLowerCase();
    const seq_len = seq_lname.length;
    if (book_len - seq_len >= 5) {
      let str = null;
      if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len));
        else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len));
      if (str) {
        if (str.length >= 5) book_name = str;
        break;
      }
    }
  }
  ndata.set("t", book_name);
  // Статус скачиваемой книжки [\b]
  let status = "";
  if (doc.totalChapters === doc.chapters.length - (doc.hasMaterials ? 1 : 0)) {
    switch (doc.status) {
      case "finished":
        status = "F";
        break;
      case "in-progress":
        status = "U";
        break;
    }
  } else {
    status = "P";
  }
  ndata.set("b", status);
  // Id книги [\i]
  ndata.set("i", doc.id);
  // Окончательное формирование имени файла плюс дополнительные чистки и проверки.
  function replacer(str, collapse) {
    let total = 0;
    let absent = 0;
    const new_str = str.replace(/\\([asnNtbi])/g, function(match, ti) {
      ++total;
      const res = ndata.get(ti);
      if (res === undefined) ++absent;
      return res || "";
    });
    return (!collapse || !total || absent < total) ? new_str : "";
  }
  function processParts(str, depth) {
    const res = /^(.*?)<(.*)>(.*?)$/.exec(str);
    if (!res) return replacer(str, depth);
    return replacer(res[1], depth) + processParts(res[2], depth + 1) + replacer(res[3], depth);
  }
  const fname = processParts(Settings.get("filename", true).trim(), 0).replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  return `${fname.substr(0, 250)}.fb2`;
}

/**
 * Создает пункт меню настроек скрипта если не существует
 *
 * @return void
 */
function ensureSettingsMenuItems() {
  const menu = document.querySelector("aside nav ul.nav");
  if (!menu || menu.querySelector("li.atex-settings")) return;
  let item = document.createElement("li");
  if (!menu.querySelector("li.Ox90-settings-menu")) {
    item.classList.add("nav-heading", "Ox90-settings-menu");
    menu.appendChild(item);
    item.innerHTML = '<span><i class="icon-cogs icon-fw"></i> Внешние скрипты</span>';
    item = document.createElement("li");
  }
  item.classList.add("atex-settings");
  menu.appendChild(item);
  item.innerHTML = '<a class="nav-link" href="/account/settings?script=atex">AutorTodayExtractor</a>';
}

/**
 * Генерирует страницу настроек скрипта
 *
 * @return void
 */
function handleSettingsPage() {
  // Изменить активный пункт меню
  const menu = document.querySelector("aside nav ul.nav");
  if (menu) {
    const active = menu.querySelector("li.active");
    active && active.classList.remove("active");
    menu.querySelector("li.atex-settings").classList.add("active");
  }
  // Найти секцию с контентом
  const section = document.querySelector("#pjax-container section.content");
  if (!section) return;
  // Очистить секцию
  while (section.firstChild) section.lastChild.remove();
  // Создать свою панель и добавить в секцию
  const panel = document.createElement("div");
  panel.classList.add("panel", "panel-default");
  section.appendChild(panel);
  panel.innerHTML = '<div class="panel-heading">Параметры скрипта AuthorTodayExtractor</div>';
  const body = document.createElement("div");
  body.classList.add("panel-body");
  panel.appendChild(body);
  const form = document.createElement("form");
  form.method = "post";
  form.style.display = "flex";
  form.style.rowGap = "1em";
  form.style.flexDirection = "column";
  body.appendChild(form);
  let fndiv = document.createElement("div");
  fndiv.innerHTML = '<label>Шаблон имени файла (без расширения)</label>';
  form.appendChild(fndiv);
  const filename = document.createElement("input");
  filename.type = "text";
  filename.style.maxWidth = "25em";
  filename.classList.add("form-control");
  filename.value = Settings.get("filename");
  fndiv.appendChild(filename);
  const descr = document.createElement("ul");
  descr.style.color = "gray";
  descr.style.fontSize = "90%";
  descr.style.margin = "0";
  descr.style.paddingLeft = "2em";
  descr.innerHTML =
    "<li>\\a - Автор книги;</li>" +
    "<li>\\s - Серия книги;</li>" +
    "<li>\\n - Порядковый номер в серии;</li>" +
    "<li>\\N - Порядковый номер в серии с ведущим нулем;</li>" +
    "<li>\\t - Название книги;</li>" +
    "<li>\\i - Идентификатор книги (workId на сайте);</li>" +
    "<li>\\b - Статус книги (F - завершена, U - не завершена, P - выгружена частично);</li>" +
    "<li>&lt;&hellip;&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>";
  fndiv.appendChild(descr);
  let fnhint = HTML.createCheckbox("Отображать подсказку о настройке шаблона в логе выгрузки", Settings.get("fnhint"));
  fnhint.classList.remove("mb");
  form.appendChild(fnhint);
  fnhint = fnhint.querySelector("input");

  const buttons = document.createElement("div");
  buttons.innerHTML = '<button type="submit" class="btn btn-primary">Сохранить</button>';
  form.appendChild(buttons);

  form.addEventListener("submit", event => {
    event.preventDefault();
    try {
      Settings.set("filename", filename.value);
      Settings.set("fnhint", fnhint.checked);
      Settings.save();
      Notification.display("Настройки сохранены", "success");
    } catch (err) {
      console.error(err);
      Notification.display("Ошибка сохранения настроек");
    }
  });
}

//---------- Классы ----------

/**
 * Расширение класса библиотеки в целях обеспечения загрузки изображений,
 * информирования о наличии неизвестных HTML элементов и отображения прогресса в логе.
 */
class FB2DocumentEx extends FB2Document {
  constructor() {
    super();
    this.unknowns = 0;
  }

  parse(parser_id, log, params, ...args) {
    const bin_start = this.binaries.length;
    super.parse(parser_id, ...args).forEach(el => {
      log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
      ++this.unknowns;
    });
    const u_bin = this.binaries.slice(bin_start);
    return (async () => {
      const it = u_bin[Symbol.iterator]();
      const get_list = function() {
        const list = [];
        for (let i = 0; i < 5; ++i) {
          const r = it.next();
          if (r.done) break;
          list.push(r.value);
        }
        return list;
      };
      while (true) {
        const list = get_list();
        if (!list.length || stage !== 1) break;
        await Promise.all(list.map(bin => {
          const li = log.message("Загрузка изображения...");
          if (params.noImages) return Promise.resolve().then(() => li.skipped());
          return bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
            .then(() => li.ok())
            .catch((err) => {
              li.fail();
              if (err.name === "AbortError") throw err;
            });
        }));
      }
    })();
  }
}

/**
 * Расширение класса библиотеки в целях передачи элементов с изображениями
 * и неизвестных элементов в документ, а также для возможности раздельной
 * обработки аннотации и примечаний автора.
 */
class AnnotationParser extends FB2AnnotationParser {
  run(fb2doc, element) {
    this._binaries = [];
    this._unknown_nodes = [];
    this.parse(element);
    if (this._annotation && this._annotation.children.length) {
      this._annotation.normalize();
      if (!fb2doc.annotation) {
        fb2doc.annotation = this._annotation;
      } else {
        this._annotation.children.forEach(ch => fb2doc.annotation.children.push(ch));
      }
      this._binaries.forEach(bin => fb2doc.binaries.push(bin));
    }
    const un = this._unknown_nodes;
    this._binaries = null;
    this._annotation = null;
    this._unknown_nodes = null;
    return un;
  }

  processElement(fb2el, depth) {
    if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);
    return super.processElement(fb2el, depth);
  }
}

/**
 * Расширение класса библиотеки в целях передачи списка неизвестных элементов в документ
 */
class ChapterParser extends FB2ChapterParser {
  run(fb2doc, element, title) {
    this._unknown_nodes = [];
    super.run(fb2doc, element, title);
    const un = this._unknown_nodes;
    this._unknown_nodes = null;
    return un;
  }

  startNode(node, depth) {
    if (node.nodeName === "DIV") {
      const nnode = document.createElement("p");
      node.childNodes.forEach(ch => nnode.appendChild(ch.cloneNode(true)));
      node = nnode;
    }
    return super.startNode(node, depth);
  }

  processElement(fb2el, depth) {
    if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);
    return super.processElement(fb2el, depth);
  }
}

/**
 * Класс управления модальным диалоговым окном
 */
class ModalDialog {
  constructor(params) {
    this._modal = null;
    this._content = null;
    this._backdrop = null;
    this._title = params.title || "";
    this._mobile = !!params.mobile;
    this._onclose = params.onclose;
  }

  show() {
    if (!this._mobile) {
      this._ensureForm();
      this._ensureContent();
      document.body.appendChild(this._modal)
      document.body.appendChild(this.backdrop);
      document.body.classList.add("modal-open");
    } else {
      this._ensureFormMobile();
      this._ensureContent();
      document.body.appendChild(this._modal)
    }
    this._modal.focus();
  }

  hide() {
    if (this._mobile) {
      this._hideMobile();
      return;
    }
    if (this._modal && this.backdrop) {
      this.backdrop.remove();
      this.backdrop = null;
      this._modal.remove();
      this._modal = null;
      document.body.classList.remove("modal-open");
      if (this._onclose) {
        this._onclose();
        this._onclose = null;
      }
    }
    this._content = null;
  }

  _ensureForm() {
    if (!this._modal) {
      this._modal = document.createElement("div");
      this._modal.classList.add("modal", "fade", "in");
      this._modal.setAttribute("tabindex", "-1");
      this._modal.setAttribute("role", "dialog");
      this._modal.setAttribute("style", "display:block; padding-right:12px;");
      this._modal.innerHTML =
        "<div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\">" +
        "<div class=\"modal-header\">" +
        "<button type=\"button\" class=\"close\"><span>×</span></button><h4 class=\"modal-title\"></h4></div>" +
        "<div class=\"modal-body\" style=\"color:#656565; min-width:250px; max-width:max(500px,35vw);\"></div>" +
        "</div></div>";
      this._modal.querySelector(".modal-title").textContent = this._title;
      this._content = this._modal.querySelector(".modal-body");
      this._modal.addEventListener("click", event => {
        if (event.target === this._modal || event.target.closest("button.close")) this.hide();
      });
      this._modal.addEventListener("keydown", event => {
        if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
          event.preventDefault();
          this.hide();
        }
      });
    }
    if (!this._backdrop) {
      this.backdrop = document.createElement("div");
      this.backdrop.classList.add("modal-backdrop", "fade", "in");
    }
  }

  _ensureFormMobile() {
    if (!this._modal) {
      this._modal = document.createElement("div");
      this._modal.classList.add("popup", "popup-screen-content", "modal-in");
      this._modal.setAttribute("style", "display:block; overflow:hidden;");
      this._modal.innerHTML =
        "<div class=\"content-block\">" +
        "<h2 class=\"text-center\"></h2>" +
        "<div class=\"modal-body\" style=\"color:#656565;\"></div>" +
        "<button class=\"mt button btn btn-default\">Закрыть</button>" +
        "</div>";
      this._modal.querySelector("h2").textContent = this._title;
      this._content = this._modal.querySelector(".modal-body");
      this._modal.querySelector("button.btn-default").addEventListener("click", event => this.hide());
    }
    this._turnOverlayMobile(true);
  }

  _ensureContent() {
  }

  _hideMobile() {
    if (this._modal) {
      this._modal.remove();
      this._modal = null;
    }
    if (this._onclose) {
      this._onclose();
      this._onclose = null;
    }
    this._turnOverlayMobile(false);
  }

  _turnOverlayMobile(on) {
    let overlay = document.querySelector("div.popup-overlay");
    if (!overlay && on) {
      overlay = document.createElement("div");
      overlay.classList.add("popup-overlay");
      document.body.appendChild(overlay);
    }
    if (on) {
      overlay.classList.add("modal-overlay-visible");
    } else if (overlay) {
      overlay.classList.remove("modal-overlay-visible");
    }
  }
}

class DownloadDialog extends ModalDialog {
  constructor(params) {
    super(params);
    this._ann = params.annotation;
    this._img = params.images;
    this._mat = params.materials;
    this._chs = params.chapters;
    this._sub = params.onsubmit;
    this._pg1 = null;
    this._pg2 = null;
  }

  hide() {
    super.hide();
    this.log = null;
    this.button = null;
  }

  nextPage() {
    this._pg1.style.display = "none";
    this._pg2.style.display = "block";
  }

  _ensureContent() {
    while (this._content.firsChild) this._content.lastChild.remove();
    const form = document.createElement("form");
    this._content.appendChild(form);

    this._pg1 = document.createElement("div");
    this._pg2 = document.createElement("div");
    this._pg2.style.display = "none";
    form.appendChild(this._pg1);
    form.appendChild(this._pg2);

    const fst = document.createElement("fieldset");
    fst.setAttribute("style", "border:1px solid #bbb; border-radius:6px; padding:5px 12px 0 12px;");
    this._pg1.appendChild(fst);
    const leg = document.createElement("legend");
    leg.setAttribute("style", "display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none;");
    leg.textContent = "Главы для выгрузки";
    fst.appendChild(leg);

    const chs = document.createElement("div");
    chs.setAttribute("style", "overflow:auto; max-height:50vh;");
    fst.appendChild(chs);

    const ntp = document.createElement("p");
    ntp.classList.add("mb");
    ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
    chs.appendChild(ntp);

    const tbd = document.createElement("div");
    tbd.classList.add("mt", "mb");
    tbd.setAttribute("style", "display:flex; padding-top:10px; border-top:1px solid #bbb;");
    fst.appendChild(tbd);

    const its = document.createElement("span");
    its.setAttribute("style", "margin:auto 5px auto 0");
    its.appendChild(document.createTextNode("Выбрано глав: "));
    tbd.appendChild(its);
    const selected = document.createElement("strong");
    selected.appendChild(document.createTextNode("0"));
    its.appendChild(selected);
    its.appendChild(document.createTextNode(" из "));
    const total = document.createElement("strong");
    its.appendChild(total);

    const tb1 = document.createElement("button");
    tb1.type = "button";
    tb1.setAttribute("title", "Выделить все/ничего");
    tb1.setAttribute("style", "margin-left:auto;");
    tbd.appendChild(tb1);
    const tb1i = document.createElement("i");
    tb1i.classList.add("icon-check");
    tb1.appendChild(tb1i);
    tb1.appendChild(document.createTextNode(" ?"));

    const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann);
    if (!this._ann) nte.querySelector("input").disabled = true;
    nte.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
    this._pg1.appendChild(nte);

    const nie = HTML.createCheckbox("Не грузить картинки внутри глав", !this._img);
    nie.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
    this._pg1.appendChild(nie);

    const nmt = HTML.createCheckbox("Не грузить дополнительные материалы", false);
    if (!this._mat) nmt.querySelector("input").disabled = true;
    nmt.setAttribute("style", "margin-top:" + (this._mobile && "10px" || "-10px"));
    this._pg1.appendChild(nmt);

    const log = document.createElement("div");
    log.classList.add("mb");
    this._pg2.appendChild(log);

    const sbd = document.createElement("div");
    sbd.classList.add("mt", "text-center");
    form.appendChild(sbd);
    const sbt = document.createElement("button");
    sbt.type = "submit";
    sbt.classList.add("button", "btn", "btn-success");
    sbt.textContent = "Продолжить";
    sbd.appendChild(sbt);

    let ch_cnt = 0;
    this._chs.forEach(ch => {
      const el = HTML.createChapterCheckbox(ch);
      ch.element = el.querySelector("input");
      chs.appendChild(el);
      ++ch_cnt;
    });
    total.textContent = ch_cnt;

    chs.addEventListener("change", event => {
      const cnt = this._chs.reduce(function(cnt, ch) {
        if (!ch.locked && ch.element.checked) ++cnt;
        return cnt;
      }, 0);
      selected.textContent = cnt;
      sbt.disabled = !cnt;
    });

    tb1.addEventListener("click", event => {
      const chf = this._chs.some(ch => !ch.locked && !ch.element.checked);
      this._chs.forEach(ch => {
        ch.element.checked = (chf && !ch.locked);
      });
      chs.dispatchEvent(new Event("change"));
    });

    form.addEventListener("submit", event => {
      event.preventDefault();
      const res = {};
      res.authorNotes = nte.querySelector("input").checked;
      res.noImages = nie.querySelector("input").checked;
      res.materials = !nmt.querySelector("input").checked;
      res.chapters = this._chs.reduce((res, ch) => {
        if (!ch.locked && ch.element.checked) {
          res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId });
        }
        return res;
      }, []);
      if (this._sub) this._sub(res);
    });

    chs.dispatchEvent(new Event("change"));
    this.log = log;
    this.button = sbt;
  }
}

/**
 * Класс общего назначения для создания однотипных HTML элементов
 */
class HTML {

  /**
   * Создает единичный элемент типа checkbox в стиле сайта
   *
   * @param title   string Подпись для checkbox
   * @param checked bool   Начальное состояние checkbox
   *
   * @return Element HTML-элемент для последующего добавления на форму
   */
  static createCheckbox(title, checked) {
    const root = document.createElement("div");
    root.classList.add("checkbox", "c-checkbox", "no-fastclick", "mb");
    const label = document.createElement("label");
    root.appendChild(label);
    const input = document.createElement("input");
    input.type = "checkbox";
    input.checked = checked;
    label.appendChild(input);
    const span = document.createElement("span");
    span.classList.add("icon-check-bold");
    label.appendChild(span);
    label.append(title);
    return root;
  }

  /**
   * Создает checkbox для диалога выбора глав
   *
   * @param chapter object Данные главы
   *
   * @return Element HTML-элемент для последующего добавления на форму
   */
  static createChapterCheckbox(chapter) {
    const root = this.createCheckbox(chapter.title || "Без названия", !chapter.locked);
    if (chapter.locked) {
      root.querySelector("input").disabled = true;
      const lock = document.createElement("i");
      lock.classList.add("icon-lock", "text-muted", "ml-sm");
      root.children[0].appendChild(lock);
    }
    if (!chapter.title) root.style.fontStyle = "italic";
    return root;
  }
}

/**
 * Класс для отображения сообщений в виде лога
 */
class LogElement {

  /**
   * Конструктор
   *
   * @param Element element HTML-элемент, в который будут добавляться записи
   */
  constructor(element) {
    element.style.overflow = "auto";
    element.style.height = "50vh";
    element.style.minWidth = "30vw";
    element.style.padding = "6px";
    element.style.border = "1px solid #bbb";
    element.style.borderRadius = "6px";
    this._element = element;
  }

  /**
   * Добавляет сообщение с указанным текстом и цветом
   *
   * @param mixed  msg   Сообщение для отображения. Может быть HTML-элементом
   * @param string color Цвет в формате CSS (не обязательный параметр)
   *
   * @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст
   */
  message(msg, color) {
    const item = document.createElement("div");
    if (msg instanceof HTMLElement) {
      item.appendChild(msg);
    } else {
      item.textContent = msg;
    }
    if (color) item.style.color = color;
    this._element.appendChild(item);
    this._element.scrollTop = this._element.scrollHeight;
    return new LogItemElement(item);
  }

  /**
   * Сообщение с темно-красным цветом
   *
   * @param mixed msg См. метод message
   *
   * @return LogItemElement См. метод message
   */
  warning(msg) {
    this.message(msg, "#a00");
  }
}

/**
 * Класс реализации элемента записи в логе,
 * используется классом LogElement.
 */
class LogItemElement {
  constructor(element) {
    this._element = element;
    this._span = null;
  }

  /**
   * Отображает сообщение "ok" в конце записи лога зеленым цветом
   *
   * @return void
   */
  ok() {
    this._setSpan("ok", "green");
  }

  /**
   * Аналогичен методу ok
   */
  fail() {
    this._setSpan("ошибка!", "red");
  }

  /**
   * Аналогичен методу ok
   */
  skipped() {
    this._setSpan("пропущено", "blue");
  }

  /**
   * Отображает указанный текстстандартным цветом сайта
   *
   * @param string s Текст для отображения
   *
   */
  text(s) {
    this._setSpan(s, "");
  }

  _setSpan(text, color) {
    if (!this._span) {
      this._span = document.createElement("span");
      this._element.appendChild(this._span);
    }
    this._span.style.color = color;
    this._span.textContent = " " + text;
  }
}


/**
 * Класс реализует доступ к хранилищу с настройками скрипта
 * Здесь используется localStorage
 */
class Settings {

  /**
   * Возващает значение опции по ее имени
   *
   * @param name  string Имя опции
   * @param reset bool   Сбрасывает кэш перед получением опции
   *
   * @return mixed
   */
  static get(name, reset) {
    if (reset) Settings._values = null;
    this._ensureValues();
    let val = Settings._values[name];
    switch (name) {
      case "filename":
        if (typeof(val) !== "string" || val.trim() === "") val = "\\a.< \\s \\N.> \\t [AT-\\i-\\b]";
        break;
      case "fnhint":
        if (typeof(val) !== "boolean") val = true;
        break;
    }
    return val;
  }

  /**
   * Обновляет значение опции
   *
   * @param name  string Имя опции
   * @param value mixed  Значение опции
   *
   * @return void
   */
  static set(name, value) {
    this._ensureValues();
    this._values[name] = value;
  }

  /**
   * Сохраняет (перезаписывает) настройки скрипта в хранилище
   *
   * @return void
   */
  static save() {
    localStorage.setItem("atex.settings", JSON.stringify(this._values || {}));
  }

  /**
   * Читает настройки из локального хранилища, если они не были считаны ранее
   */
  static _ensureValues() {
    if (this._values) return;
    try {
      this._values = JSON.parse(localStorage.getItem("atex.settings"));
    } catch (err) {
      this._values = null;
    }
    if (!this._values || typeof(this._values) !== "object") Settings._values = {};
  }
}

/**
 * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
 */
class Notification {

  /**
   * Конструктор. Вызвается из static метода display
   *
   * @param data Object Объект с полями text (string) и type (string)
   *
   * @return void
   */
  constructor(data) {
    this._data = data;
    this._element = null;
  }

  /**
   * Возвращает HTML-элемент блока с текстом уведомления
   *
   * @return Element HTML-элемент для добавление в контейнер уведомлений
   */
  element() {
    if (!this._element) {
      this._element = document.createElement("div");
      this._element.classList.add("toast", "toast-" + (this._data.type || "success"));
      const msg = document.createElement("div");
      msg.classList.add("toast-message");
      msg.textContent = "ATEX: " + this._data.text;
      this._element.appendChild(msg);
      this._element.addEventListener("click", () => this._element.remove());
      setTimeout(() => {
        this._element.style.transition = "opacity 2s ease-in-out";
        this._element.style.opacity = "0";
        setTimeout(() => {
          const ctn = this._element.parentElement;
          this._element.remove();
          if (!ctn.childElementCount) ctn.remove();
        }, 2000); // Продолжительность плавного растворения уведомления - 2 секунды
      }, 10000); // Длительность отображения уведомления - 10 секунд
    }
    return this._element;
  }

  /**
   * Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта
   *
   * @param text string Текст уведомления
   * @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error`
   *
   * @return void
   */
  static display(text, type) {
    let ctn = document.getElementById("toast-container");
    if (!ctn) {
      ctn = document.createElement("div");
      ctn.id = "toast-container";
      ctn.classList.add("toast-top-right");
      ctn.setAttribute("role", "alert");
      ctn.setAttribute("aria-live", "polite");
      document.body.appendChild(ctn);
    }
    ctn.appendChild((new Notification({ text: text, type: type })).element());
  }
}

/**
 * Класс загрузчика данных с сайта.
 * Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS
 */
class Loader {
  static async addJob(url, params) {
    if (!this.ctl_list) this.ctl_list = new Set();
    params ||= {};
    params.url = url;
    params.method ||= "GET";
    params.responseType = params.responseType === "binary" ? "blob" : "text";
    return new Promise((resolve, reject) => {
      let req = null;
      params.onload = r => {
        if (r.status === 200) {
          const headers = {};
          r.responseHeaders.split("\n").forEach(hs => {
            const h = hs.split(":");
            if (h[1]) headers[h[0].trim().toLowerCase()] = h[1].trim();
          });
          resolve({ headers: headers, response: r.response });
        } else {
          reject(new Error(`Сервер вернул ошибку (${r.status})`));
        }
      };
      params.onerror = err => reject(err);
      params.ontimeout = err => reject(err);
      params.onloadend = () => {
        if (req) this.ctl_list.delete(req);
      };
      if (params.onprogress) {
        const progress = params.onprogress;
        params.onprogress = pe => {
          if (pe.lengthComputable) {
            progress(pe.loaded, pe.total);
          }
        };
      }
      try {
        req = GM.xmlHttpRequest(params);
        if (req) this.ctl_list.add(req);
      } catch (err) {
        reject(err);
      }
    });
  }

  static abortAll() {
    if (this.ctl_list) {
      this.ctl_list.forEach(ctl => ctl.abort());
      this.ctl_list.clear();
    }
  }
}

/**
 * Переопределение загрузчика для возможности использования своего лоадера
 * а также для того, чтобы избегать загрузки картинок в формате webp.
 */
FB2Image.prototype._load = async function(url, params) {
  // Попытка избавиться от webp через подмену параметров запроса
  const u = new URL(url);
  if (u.pathname.endsWith(".webp")) {
    // Изначально была загружена картинка webp. Попытаться принудить сайт отдать картинку другого формата.
    u.searchParams.set("format", "jpeg");
  } else if (u.searchParams.get("format") === "webp") {
    // Изначально картинка не webp, но параметр присутсвует. Вырезать.
    // Возможно позже придется указывать его явно, когда сайт сделает webp форматом по умолчанию.
    u.searchParams.delete("format");
  }
  // Еще одна попытка избавиться от webp через подмену заголовков
  params ||= {};
  params.headers ||= {};
  if (!params.headers.Accept) params.headers.Accept = "image/jpeg,image/png,*/*;q=0.8";
  // Использовать свой лоадер
  return (await Loader.addJob(u, params)).response;
};

//-------------------------

// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  else init();

})();