ReadliBookExtractor

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

当前为 2023-06-10 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           ReadliBookExtractor
// @namespace      90h.yy.zz
// @version        0.1.2
// @author         Ox90
// @match          https://readli.net/*
// @description    The script adds a button to the site for downloading books to an FB2 file
// @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
// @grant          unsafeWindow
// @run-at         document-start
// @license        MIT
// ==/UserScript==

(function start() {

const PROGRAM_NAME = "RLBookExtractor";

let env = {};
let stage = 0;

Date.prototype.toAtomDate = function() {
  let m = this.getMonth() + 1;
  let d = this.getDate();
  return "" + this.getFullYear() + '-' + (m < 10 ? "0" : "") + m + "-" + (d < 10 ? "0" : "") + d;
};

function init() {
  env.popupShow = window.popupShow || (unsafeWindow && unsafeWindow.popupShow);
  pageHandler();
}

function pageHandler() {
  if (!document.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]")) return;
  const book_page = document.querySelector("main.main>section.wrapper.page");
  if (book_page) {
    const dlg_data = makeDownloadDialog();
    insertDownloadButton(book_page, dlg_data);
  }
}

function insertDownloadButton(book_page, dlg_data) {
  const btn_list = book_page.querySelector("section.download>ul.download__list");
  if (btn_list) {
    // Создать кнопку
    const btn = document.createElement("li");
    btn.classList.add("download__item");
    const link = document.createElement("a");
    link.classList.add("download__link");
    link.href = "#";
    link.textContent = "fb2-ex";
    btn.appendChild(link);
    // Попытаться вставить новую кнопку сразу после оригинальной fb2
    let item = btn_list.firstElementChild;
    while (item) {
      if (item.textContent === "fb2") break;
      item = item.nextElementSibling;
    }
    if (item) {
      item.after(btn);
    } else {
      btn_list.appendChild(btn);
    }
    // Ссылка на данные книги
    let book_data = null;
    // Установить обработчик для новой кнопки
    btn.addEventListener("click", event => {
      event.preventDefault();
      try {
        dlg_data.log.clean();
        dlg_data.sbm.textContent = setStage(0);
        env.popupShow("#rbe-download-dlg");
        book_data = getBookInfo(book_page, dlg_data.log);
        dlg_data.sbm.disabled = false;
      } catch (e) {
        dlg_data.log.message(e.message, "red");
      }
    });
    // Установить обработчик для основной кнопки диалога
    dlg_data.sbm.addEventListener("click", () => makeAction(book_data, dlg_data));
    // Установить обработчик для скрытия диалога
    dlg_data.dlg.addEventListener("dlg-hide", () => {
      if (dlg_data.link) {
        URL.revokeObjectURL(dlg_data.link.href);
        dlg_data.link = null;
      }
      book_data = null;
    });
  }
}

function makeDownloadDialog() {
  const popups = document.querySelector("div.popups");
  if (!popups) throw new Error("Не найден блок popups");
  const dlg_c = document.createElement("div");
  dlg_c.id = "rbe-download-dlg";
  popups.appendChild(dlg_c);
  dlg_c.innerHTML =
    '<div class="popup" data-src="#rbe-download-dlg">' +
    '<button class="popup__close button-close-2"></button>' +
    '<div class="popup-form">' +
    '<h2>Скачать книгу</h2>' +
    '<div class="rbe-log"></div>' +
    '<button class="button rbe-submit" disabled="true">Продолжить</button>' +
    '</div>' +
    '</div>';
  const dlg = dlg_c.querySelector("div.popup-form");
  const dlg_data = {
    dlg: dlg,
    log: new LogElement(dlg.querySelector(".rbe-log")),
    sbm: dlg.querySelector("button.rbe-submit")
  };
  (new MutationObserver(() => {
    if (dlg_c.children.length) {
      dlg.dispatchEvent(new CustomEvent("dlg-hide"));
    }
  })).observe(dlg_c, { childList: true });
  return dlg_data;
}

async function makeAction(book_data, dlg_data) {
  try {
    switch (stage) {
      case 0:
        dlg_data.sbm.textContent = setStage(1);
        await getBookContent(book_data, dlg_data.log);
        dlg_data.sbm.textContent = setStage(2);
        break;
      case 1:
        Fetcher.abortAll();
        dlg_data.sbm.textContent = setStage(3);
        break;
      case 2:
        if (!dlg_data.link) {
          dlg_data.link = document.createElement("a");
          dlg_data.link.download = genBookFileName(book_data.fb2doc);
          dlg_data.link.href = URL.createObjectURL(new Blob([ book_data.fb2doc ], { type: "application/octet-stream" }));
          dlg_data.fb2doc = null;
        }
        dlg_data.link.click();
        break;
      case 3:
        dlg_data.dlg.closest("div.popup[data-src=\"#rbe-download-dlg\"]").querySelector("button.popup__close").click();
        break;
    }
  } catch (err) {
    dlg_data.log.message(err.message, "red");
    dlg_data.sbm.textContent = setStage(3);
  }
}

function setStage(new_stage) {
  stage = new_stage;
  return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
}

function getBookInfo(book_page, log) {
  const data = {};
  // Id книги
  const id = (() => {
    const el = book_page.querySelector("a.book-actions__button[href^=\"/chitat-online/\"]");
    if (el) {
      const id = (new URL(el)).searchParams.get("b");
      if (id) return id;
    }
    throw new Error("Не найден Id книги!");
  })();
  data.id = id;
  // Название книги
  const title = (() => {
    const el = book_page.querySelector("div.main-info>h1.main-info__title");
    return el && el.textContent.trim() || "";
  })();
  if (!title) throw new Error("Не найдено название книги");
  let li = log.message("Название:").text(title);
  data.bookTitle = title;
  // Авторы
  const authors = Array.from(book_page.querySelectorAll("div.main-info>a[href^=\"/avtor/\"]")).reduce((list, el) => {
    const content = el.textContent.trim();
    if (content) {
      const author = new FB2Author(content);
      author.homePage = el.href;
      list.push(author);
    }
    return list;
  }, []);
  log.message("Авторы:").text(authors.length || "нет");
  if (!authors.length) log.warning("Не найдена информация об авторах");
  data.authors = authors;
  // Жанры
  //
  log.message("Жанры:").text("не реализовано");
  // Ключевые слова
  const tags = Array.from(book_page.querySelectorAll("div.tags>ul.tags__list>li.tags__item")).reduce((list, el) => {
    const content = el.textContent.trim();
    const r = /^#(.+)$/.exec(content);
    if (r) list.push(r[1]);
    return list;
  }, []);
  log.message("Теги:").text(tags && tags.length || "нет");
  data.keywords = tags;
  // Серия
  const sequence = (() => {
    let el = book_page.querySelector("div.book-info a[href^=\"/serie/\"]");
    if (el) {
      let r = /^(.+?)(?:\s+#(\d+))?$/.exec(el.textContent.trim());
      if (r && r[1]) {
        const res = { name: r[1]};
        log.message("Серия:").text(r[1]);
        if (r[2]) {
          res.number = r[2];
          log.message("Номер в серии:").text(r[2]);
        }
        return res;
      }
    }
  })();
  if (sequence) data.sequence = sequence;
  // Дата
  const bookDate = (() => {
    const el = book_page.querySelector("ul.book-chars>li.book-chars__item");
    if (el) {
      const r = /^Размещено.+(\d{2})\.(\d{2})\.(\d{4})$/.exec(el.textContent.trim());
      if (r) {
        log.message("Последнее обновление:").text(`${r[1]}.${r[2]}.${r[3]}`);
        return new Date(`${r[3]}-${r[2]}-${r[1]}`);
      }
    }
  })();
  if (bookDate) data.bookDate = bookDate;
  // Ссылка на источник
  data.sourceURL = document.location.origin + document.location.pathname;
  // Аннотация
  const annotation = (() => {
    const el = book_page.querySelector("article.seo__content");
    if (el && el.firstElementChild && el.firstElementChild.tagName === "H2" && el.firstElementChild.textContent === "Аннотация") {
      const c_el = el.cloneNode(true);
      c_el.firstElementChild.remove();
      return c_el;
    }
  })();
  if (annotation) {
    data.annotation = annotation;
  } else {
    log.warning("Аннотация не найдена!");
  }
  // Количество страниц
  const pages = (() => {
    const li = log.message("Количество страниц:");
    const el = book_page.querySelector(".book-about__pages .button-pages__right");
    if (el) {
      const pages_str = el.textContent;
      let r = /^(\d+)/.exec(pages_str);
      if (r) {
        li.text(r[1]);
        return parseInt(r[1]);
      }
    }
    li.fail();
    return 0;
  })();
  if (pages) data.pageCount = pages;
  // Обложка книги
  const cover_url = (() => {
    const el = book_page.querySelector("div.book-image img");
    if (el) return el.src;
    return null;
  })();
  if (cover_url) data.coverpageURL = cover_url;
  //--
  return data;
}

async function getBookContent(book_data, log) {
  let li = null;
  try {
    const fb2doc = new FB2Document();
    fb2doc.id = book_data.id;
    fb2doc.bookTitle = book_data.bookTitle;
    fb2doc.bookAuthors = book_data.authors;
    if (book_data.genres) {
      fb2doc.genres = book_data.genres;
    } else {
      fb2doc.genres = [ new FB2Element("genre", "network_literature") ];
    }
    if (book_data.sequence) fb2doc.sequence = book_data.sequence;
    fb2doc.lang = "ru";
    // Обложка книги
    if (book_data.coverpageURL) {
      li = log.message("Загрузка обложки...");
      try {
        fb2doc.coverpage = new FB2Image(book_data.coverpageURL);
        await fb2doc.coverpage.load();
        fb2doc.coverpage.id = "cover" + fb2doc.coverpage.suffix();
        fb2doc.binaries.push(fb2doc.coverpage);
        li.ok();
        log.message("Размер обложки:").text(fb2doc.coverpage.size + " байт");
        log.message("Тип файла обложки:").text(fb2doc.coverpage.type);
      } catch (err) {
        li.fail();
        throw err;
      }
    } else {
      log.warning("Обложка книги не найдена!");
    }
    // Анализ аннотации
    if (book_data.annotation) {
      const li = log.message("Анализ аннотации...");
      try {
        const annotation = new FB2Annotation();
        await annotation.setContentFromHTML(book_data.annotation);
        annotation.normalize();
        li.ok();
        if (annotation.children.length) {
          fb2doc.annotation = annotation;
        } else {
          log.warning("Не найдено содержимое аннотации!");
        }
      } catch (err) {
        li.fail();
        throw err;
      }
    }
    //--
    li = null;
    if (book_data.keywords.length) fb2doc.keywords = new FB2Element("keywords", book_data.keywords.join(", "));
    if (book_data.bookDate) fb2doc.bookDate = book_data.bookDate;
    fb2doc.sourceURL = book_data.sourceURL;
    //--
    log.message("---");
    // Страницы
    const page_url = new URL("/chitat-online/", document.location);
    page_url.searchParams.set("b", book_data.id);
    for (let pn = 1; pn <= book_data.pageCount; ++pn) {
      li = log.message(`Получение страницы ${pn}/${book_data.pageCount}...`);
      page_url.searchParams.set("pg", pn);
      const page = getPageElement(await Fetcher.addJob(page_url));
      if (pn !== 1 || ! await getAuthorNotes(fb2doc, page, log)) {
        await updateChapters(fb2doc, page, log);
      }
      li.ok();
    }
    log.message("---");
    log.message("Всего глав:").text(fb2doc.chapters.length);
    li = log.message("Анализ содержимого глав...");
    fb2doc.chapters.forEach(ch => ch.normalize());
    li.ok();
    log.message("---");
    log.message("Готово!");
    //--
    book_data.fb2doc = fb2doc;
  } catch (err) {
    li && li.fail();
    throw err;
  }
}

async function getAuthorNotes(fb2doc, page, log) {
  const hdr = page.querySelector("section>subtitle");
  if (!hdr || hdr.textContent !== "Примечания автора:") return false;
  let notes = null;
  let annot = new FB2Annotation();
  annot.children.push(new FB2Paragraph("Примечания автора:"));
  for (let notes = hdr.nextElementSibling; notes; notes = notes.nextElementSibling) {
    const tname = notes.tagName;
    if (tname === "SUBTITLE") break;
    if (tname === "P") {
      if (annot.children.length) annot.children.push(new FB2EmptyLine());
      await annot.appendContentFromHTML(notes, fb2doc, log);
    }
  }
  if (!annot.children.length) return false;
  log.message("Найдены примечания автора");
  annot.normalize();
  if (fb2doc.annotation) {
    fb2doc.annotation.children.push(new FB2EmptyLine());
    annot.children.forEach(el => fb2doc.annotation.children.push(el));
  } else {
    fb2doc.annotation = annot;
  }
  return true;
}

function getPageElement(html) {
  const doc = (new DOMParser()).parseFromString(html, "text/html");
  const page_el = doc.querySelector("article.reading__content>div.reading__text");
  if (!page_el) throw new Error("Ошибка анализа HTML страницы");
  return page_el;
}

async function updateChapters(fb2doc, page, log) {
  // Вырезать скрипты и рекламные блоки
  Array.from(page.children).forEach(el => {
    const tn = el.tagName;
    if ((tn === "DIV" && el.textContent.trim() === "") || tn === "SCRIPT" || tn === "INS") el.remove();
  });
  // Вырезать пустые блоки в начале страницы
  while (page.firstChild && !page.firstChild.textContent.trim()) {
    page.firstChild.remove();
  }
  if (!page.childNodes.length) return;
  //--
  if (page.firstChild.nodeName === "H3") {
    // Найдено название главы
    const title = page.firstChild.textContent.trim();
    page.firstChild.remove();
    const chapter = new FB2Chapter(title);
    await chapter.setContentFromHTML(page, fb2doc, log);
    fb2doc.chapters.push(chapter);
  } else {
    if (!fb2doc.chapters.length) fb2doc.chapters.push(new FB2Chapter());
    await fb2doc.chapters[fb2doc.chapters.length - 1].appendContentFromHTML(page, fb2doc, log);
  }
    
}

function genBookFileName(fb2doc) {
  function xtrim(s) {
    const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
    return r && r[1] || s;
  }

  const parts = [];
  if (fb2doc.bookAuthors.length) parts.push(fb2doc.bookAuthors[0]);
  if (fb2doc.sequence) {
    let name = xtrim(fb2doc.sequence.name);
    if (fb2doc.sequence.number) {
      const num = fb2doc.sequence.number;
      name += (num.length < 2 ? "0" : "") + num;
    }
    parts.push(name);
  }
  parts.push(xtrim(fb2doc.bookTitle));
  let fname = (parts.join(". ") + " [RL-" + fb2doc.id + "]").replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  if (fname.length > 250) fname = fname.substr(0, 250);
  return fname + ".fb2";
}

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

class FB2Document {
  constructor() {
    this.binaries = [];
    this.bookAuthors = [];
    this.genres = [];
    this.chapters = [];
    this.xmldoc = null;
  }

  toString() {
    this._ensureXMLDocument();
    const root = this.xmldoc.documentElement;
    this.markBinaries();
    root.appendChild(this._makeDescriptionElement());
    root.appendChild(this._makeBodyElement());
    this._makeBinaryElements().forEach(el => root.appendChild(el));
    const res = (new XMLSerializer()).serializeToString(this.xmldoc);
    this.xmldoc = null;
    return res;
  }

  markBinaries() {
    let idx = 0;
    this.binaries.forEach(img => {
      if (!img.id) img.id = "image" + (++idx) + img.suffix();
    });
  }

  createElement(name) {
    this._ensureXMLDocument();
    return this.xmldoc.createElementNS(this.xmldoc.documentElement.namespaceURI, name);
  }

  createTextNode(value) {
    this._ensureXMLDocument();
    return this.xmldoc.createTextNode(value);
  }

  _ensureXMLDocument() {
    if (!this.xmldoc) {
      this.xmldoc = new DOMParser().parseFromString(
        '<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"/>',
        "application/xml"
      );
      this.xmldoc.documentElement.setAttribute("xmlns:l", "http://www.w3.org/1999/xlink");
    }
  }

  _makeDescriptionElement() {
    const desc = this.createElement("description");
    // title-info
    const t_info = this.createElement("title-info");
    desc.appendChild(t_info);
    this.genres.forEach(g => t_info.appendChild(g.xml(this)));
    (this.bookAuthors.length ? this.bookAuthors : [ new FB2Author("Неизвестный автор") ]).forEach(a => {
      t_info.appendChild(a.xml(this));
    });
    t_info.appendChild((new FB2Element("book-title", this.bookTitle)).xml(this));
    t_info.appendChild(this.annotation.xml(this));
    if (this.keywords) t_info.appendChild(this.keywords.xml(this));
    if (this.bookDate) {
      const el = this.createElement("date");
      el.setAttribute("value", this.bookDate.toAtomDate());
      el.textContent = this.bookDate.getFullYear();
      t_info.appendChild(el);
    }
    if (this.coverpage) {
      const el = this.createElement("coverpage");
      el.appendChild(this.coverpage.xml(this));
      t_info.appendChild(el);
    }
    const lang = this.createElement("lang");
    lang.textContent = "ru";
    t_info.appendChild(lang);
    if (this.sequence) {
      const el = this.createElement("sequence");
      el.setAttribute("name", this.sequence.name);
      if (this.sequence.number) el.setAttribute("number", this.sequence.number);
      t_info.appendChild(el);
    }
    // document-info
    const d_info = this.createElement("document-info");
    desc.appendChild(d_info);
    d_info.appendChild((new FB2Author("Ox90")).xml(this));
    d_info.appendChild((new FB2Element("program-used", PROGRAM_NAME + " v" + GM_info.script.version)).xml(this));
    d_info.appendChild((() => {
      const f_time = new Date();
      const el = this.createElement("date");
      el.setAttribute("value", f_time.toAtomDate());
      el.textContent = f_time.toUTCString();
      return el;
    })());
    if (this.sourceURL) {
      d_info.appendChild((new FB2Element("src-url", this.sourceURL)).xml(this));
    }
    d_info.appendChild((new FB2Element("id", this._genBookId())).xml(this));
    d_info.appendChild((new FB2Element("version", "1.0")).xml(this));
    return desc;
  }

  _makeBodyElement() {
    const body = this.createElement("body");
    const title = this.createElement("title");
    body.appendChild(title);
    if (this.bookAuthors.length) title.appendChild((new FB2Paragraph(this.bookAuthors.join(", "))).xml(this));
    title.appendChild((new FB2Paragraph(this.bookTitle)).xml(this));
    this.chapters.forEach(ch => body.appendChild(ch.xml(this)));
    return body;
  }

  _makeBinaryElements() {
    const res = this.binaries.reduce((list, img) => {
      if (img.value) {
        const el = this.createElement("binary");
        el.setAttribute("id", img.id);
        el.setAttribute("content-type", img.type);
        el.textContent = img.value;
        list.push(el);
      }
      return list;
    }, []);
    return res;
  }

  _genBookId() {
    let str = this.sourceURL;
    let hash = 0;
    const slen = str.length;
    for (let i = 0; i < slen; ++i) {
      const ch = str.charCodeAt(i);
      hash = ((hash << 5) - hash) + ch;
      hash = hash & hash; // Convert to 32bit integer
    }
    return "rbe_" + Math.abs(hash).toString() + (hash > 0 ? "1" : "");
  }
}

class FB2Element {
  constructor(name, value) {
    this.name = name;
    this.value = value !== undefined ? value : null;
    this.children = [];
  }

  static async fromHTML(node, fb2doc, log) {
    let fb2el = null;
    const names = new Map([
      [ "U", "emphasis" ], [ "EM", "emphasis" ], [ "EMPHASIS", "emphasis" ], [ "I", "emphasis" ],
      [ "S", "strike" ], [ "DEL", "strike" ], [ "STRIKE", "strike" ],
      [ "STRONG", "strong" ], [ "BLOCKQUOTE", "cite" ],
      [ "#comment", null ]
    ]);
    const node_name = node.nodeName;
    if (names.has(node_name)) {
      const name = names.get(node_name);
      if (!name) return;
      fb2el = new FB2Element(names.get(node_name));
    } else {
      switch (node_name) {
        case "#text":
          return new FB2Text(node.textContent);
        case "P":
          fb2el = new FB2Paragraph();
          break;
        case "SUBTITLE":
          fb2el = new FB2Subtitle();
          break;
        case "A":
          fb2el = new FB2Link(node.href || node.getAttribute("l:href"));
          break;
        case "BR":
          return new FB2EmptyLine();
        case "HR":
          return new FB2Paragraph("---");
        case "IMG":
          {
            const img = new FB2Image(node.src);
            if (fb2doc) fb2doc.binaries.push(img);
            let li = log.message("Загрузка изображения...");
            try {
              await img.load();
              li.ok();
            } catch (err) {
              li.fail();
              throw err;
            }
            return img;
          }
        default:
          throw new Error("Неизвестный HTML блок: " + node.nodeName);
      }
    }
    await fb2el.appendContentFromHTML(node, fb2doc, log);
    return fb2el;
  }

  hasValue() {
    return ((this.value !== undefined && this.value !== null) || !!this.children.length);
  }

  async setContentFromHTML(data, fb2doc, log) {
    this.children = [];
    await this.appendContentFromHTML(data, fb2doc, log);
  }

  async appendContentFromHTML(data, fb2doc, log) {
    for (const node of data.childNodes) {
      let fe = await FB2Element.fromHTML(node, fb2doc, log);
      if (fe) this.children.push(fe);
    }
  }

  normalize() {
    const res_list = [ this ];
    let cur_el = this;
    const children = this.children;
    this.children = [];
    children.forEach(el => {
      if (el instanceof FB2EmptyLine || el instanceof FB2Subtitle) {
        res_list.push(el);
        cur_el = new this.constructor();
        res_list.push(cur_el);
      } else {
        el.normalize().forEach(el => {
          cur_el.children.push(el);
        });
      }
    });
    return res_list;
  }

  xml(doc) {
    const el = doc.createElement(this.name);
    if (this.value !== null) el.textContent = this.value;
    this.children.forEach(ch => el.appendChild(ch.xml(doc)));
    return el;
  }
}

class FB2BlockElement extends FB2Element {
  normalize() {
    // Удалить пробельные символы в конце блока
    while (this.children.length) {
      const el = this.children[this.children.length - 1];
      if (el.name === "text" && typeof(el.value) === "string") {
        el.value = el.value.trimEnd();
        if (!el.value) {
          this.children.pop();
          continue;
        }
      }
      break;
    }
    // Удалить пробельные символы в начале блока
    while (this.children.length) {
      const el = this.children[0];
      if (el.name === "text" && typeof(el.value) === "string") {
        el.value = el.value.trimStart();
        if (!el.value) {
          this.children.shift();
          continue;
        }
      }
      break;
    }
    //--
    return super.normalize();
  }
}

/**
 * FB2 элемент верхнего уровня section
 */
class FB2Chapter extends FB2Element {
  constructor(title) {
    super("section");
    this.title = title;
  }

  normalize() {
    // Обернуть текстовые ноды в параграфы и удалить пустые элементы
    this.children = this.children.reduce((list, el) => {
      if (el instanceof FB2Text) {
        const pe = new FB2Paragraph();
        pe.children.push(el);
        el = pe;
      }
      el.normalize().forEach(el => {
        if (el.hasValue()) list.push(el);
      });
      return list;
    }, []);
    return [ this ];
  }

  xml(doc) {
    const el = super.xml(doc);
    if (this.title) {
      const t_el = doc.createElement("title");
      const p_el = doc.createElement("p");
      p_el.textContent = this.title;
      t_el.appendChild(p_el);
      el.prepend(t_el);
    }
    return el;
  }
}

/**
 * FB2 элемент верхнего уровня annotation
 */
class FB2Annotation extends FB2Element {
  constructor() {
    super("annotation");
  }

  normalize() {
    // Обернуть неформатированный текст, разделенный <br> в параграфы
    let lp = null;
    const newParagraph = list => {
      lp = new FB2Paragraph();
      list.push(lp);
    };
    this.children = this.children.reduce((list, el) => {
      if (el.name === "empty-line") {
        newParagraph(list);
      } else if (el instanceof FB2BlockElement) {
        list.push(el);
        lp = null;
      } else {
        if (!lp) newParagraph(list);
        lp.children.push(el);
      }
      return list;
    }, []);
    // Запустить собственную нормализацию дочерних элементов
    // чтобы исключить дальнейшее всплытие элементов
    this.children = this.children.reduce((list, el) => {
      el.normalize().forEach(el => {
        if (el.hasValue()) list.push(el);
      });
      return list;
    }, []);
  }
}

class FB2Subtitle extends FB2BlockElement {
  constructor(value) {
    super("subtitle", value);
  }
}

class FB2Paragraph extends FB2BlockElement {
  constructor(value) {
    super("p", value);
  }
}

class FB2EmptyLine extends FB2Element {
  constructor() {
    super("empty-line");
  }

  hasValue() {
    return true;
  }
}

class FB2Text extends FB2Element {
  constructor(value) {
    super("text", value);
  }

  xml(doc) {
    return doc.createTextNode(this.value);
  }
}

class FB2Link extends FB2Element {
  constructor(href) {
    super("a");
    this.href = href;
  }

  xml(doc) {
    const el = super.xml(doc);
    el.setAttribute("l:href", this.href);
    return el;
  }
}

class FB2Author extends FB2Element {
  constructor(s) {
    super("author");
    const a = s.split(" ");
    switch (a.length) {
      case 1:
        this.nickName = s;
        break;
      case 2:
        this.firstName = a[0];
        this.lastName = a[1];
        break;
      default:
        this.firstName = a[0];
        this.middleName = a.slice(1, -1).join(" ");
        this.lastName = a[a.length - 1];
        break;
    }
    this.homePage = null;
  }

  hasValue() {
    return (!!this.firstName || !!this.lastName || !!this.middleName);
  }

  toString() {
    if (!this.firstName) return this.nickName;
    return [ this.firstName, this.middleName, this.lastName ].reduce((list, name) => {
      if (name) list.push(name);
      return list;
    }, []).join(" ");
  }

  xml(doc) {
    let a_el = super.xml(doc);
    [
      [ "first-name", this.firstName ], [ "middle-name", this.middleName ],
      [ "last-name", this.lastName ], [ "home-page", this.homePage ],
      [ "nickname", this.nickName ]
    ].forEach(it => {
      if (it[1]) {
        const e = doc.createElement(it[0]);
        e.textContent = it[1];
        a_el.appendChild(e);
      }
    });
    return a_el;
  }
}

class FB2Image extends FB2Element {
  constructor(value) {
    super("image");
    if (typeof(value) === "string") {
      this.url = value;
    } else {
      this.value = value;
    }
  }

  async load() {
    if (this.url) {
      const bin = await Fetcher.addJob(this.url, { responseType: "binary" });
      this.type = bin.type;
      this.size = bin.size;
      return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.addEventListener("loadend", (event) => resolve(event.target.result));
        reader.readAsDataURL(bin);
      }).then(base64str => {
        this.value = base64str.substr(base64str.indexOf(",") + 1);
      }).catch(err => {
        throw new Error("Ошибка загрузки изображения");
      });
    }
  }

  xml(doc) {
    if (this.value) {
      const el = doc.createElement(this.name);
      el.setAttribute("l:href", "#" + this.id);
      return el
    }
    const el = doc.createElement("p");
    const id = this.id || "изображение";
    el.textContent = `[ ${id} ]`;
    return el;
  }

  suffix() {
    switch (this.type) {
      case "image/png":
        return ".png";
      case "image/jpeg":
        return ".jpg";
      case "image/webp":
        return ".webp";
    }
    return "";
  }
}

//---

class LogElement {
  constructor(element) {
    element.style.padding = ".5em";
    element.style.fontSize = "90%";
    element.style.border = "1px solid lightgray";
    element.style.marginBottom = "1em";
    element.style.borderRadius = "6px";
    element.style.textAlign = "left";
    element.style.overflowY = "auto";
    element.style.maxHeight = "50vh";
    this._element = element;
  }

  clean() {
    while (this._element.firstChild) this._element.lastChild.remove();
  }

  message(message, color) {
    const item = document.createElement("div");
    if (message instanceof HTMLElement) {
      item.appendChild(message);
    } else {
      item.textContent = message;
    }
    if (color) item.style.color = color;
    this._element.appendChild(item);
    this._element.scrollTop = this._element.scrollHeight;
    return new LogItemElement(item);
  }

  warning(s) {
    this.message(s, "#a00");
  }
}

class LogItemElement {
  constructor(element) {
    this._element = element;
    this._span = null;
  }

  ok() {
    this._setSpan("ok", "green");
  }

  fail() {
    this._setSpan("ошибка!", "red");
  }

  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;
  }
}

class Fetcher {
  static async addJob(url, params) {
    params ||= {};
    const fp = {};
    fp.method = params.method || "GET";
    fp.credentials = "same-origin";
    fp.signal = Fetcher._getSignal();
    const resp = await fetch(url, fp);
    if (!resp.ok) throw new Error(`Сервер вернул ошибку (${resp.status})`);
    switch (params.responseType) {
      case "binary":
        return await resp.blob();
      default:
        return await resp.text();
    }
  }

  static abortAll() {
    if (Fetcher._controller) {
      Fetcher._controller.abort();
      Fetcher._controller = null;
    }
  }

  static _getSignal() {
    let controller = Fetcher._controller;
    if (!controller) Fetcher._controller = controller = new AbortController();
    return controller.signal;
  }
}

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

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

}());