// ==UserScript==
// @name AuthorTodayExtractor
// @name:ru AuthorTodayExtractor
// @namespace 90h.yy.zz
// @version 1.5.1
// @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://update.greasyfork.org/scripts/468831/1478439/HTML2FB2Lib.js
// @grant GM.xmlHttpRequest
// @grant unsafeWindow
// @connect author.today
// @connect cm.author.today
// @connect *
// @run-at document-start
// @license MIT
// ==/UserScript==
/**
* Записи вида `@connect` необходимы пользователям tampermonkey для загрузки обложек и изображений внутри глав.
* Разрешение `@connect cm.author.today` - для загрузки обложек и дополнительных материалов.
* Разрешение `@connect author.today` - для загрузки обложек у старых книг.
* Разрешение `@connect *` необходимо для того, чтобы получить возможность загружать картинки
* внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
* Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
* "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() {
addStyles();
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",
annotation: !!params.authorNotes,
cover: !!params.cover,
materials: !!params.materials,
settings: {
addnotes: Settings.get("addnotes"),
addcover: Settings.get("addcover"),
addimages: Settings.get("addimages"),
materials: Settings.get("materials")
},
chapters: chapters,
onclose: () => {
Loader.abortAll();
log = null;
doc = null;
if (dlg.link) {
URL.revokeObjectURL(dlg.link.href);
dlg.link = null;
}
},
onsubmit: result => {
result.cover = params.cover;
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.setAttribute("download", genBookFileName(doc, { chaptersRange: dlg.result.chaptersRange }));
// Должно быть 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) {
if (status_el.classList.contains("icon-check")) {
res.status = "finished";
} else if (status_el.classList.contains("icon-pencil")) {
res.status = "in-progress";
}
} else {
res.status = "fragment";
}
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 cover = mobile ?
document.querySelector("div.work-cover>.work-cover-content>img.cover-image") :
document.querySelector("div.book-cover>.book-cover-content>img.cover-image");
if (cover) {
res.cover = cover;
}
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(new URL(`/reader/${params.workId}`, document.location), {
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.addimages }, 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.addimages) {
const icnt = doc.binaries.reduce((cnt, img) => {
if (!img.value) ++cnt;
return cnt;
}, 0);
if (icnt) {
log.message("---");
log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога
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("Проблемные изображения заменены на текст");
}
}
}
let webpList = [];
const imgTypes = doc.binaries.reduce((map, bin) => {
if (bin instanceof FB2Image && bin.value) {
const type = bin.type;
map.set(type, (map.get(type) || 0) + 1);
if (type === "image/webp") webpList.push(bin);
}
return map;
}, new Map());
if (imgTypes.size) {
log.message("---");
log.message("Изображения:");
imgTypes.forEach((cnt, type) => log.message(`- ${type}: ${cnt}`));
if (webpList.length) {
log.warning("Найдены изображения формата WebP. Могут быть проблемы с отображением на старых читалках.");
await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога
if (confirm("Выполнить конвертацию WebP --> JPEG?")) {
const li = log.message("Конвертация изображений...");
let ecnt = 0;
for (const img of webpList) {
try {
await img.convert("image/jpeg");
} catch(err) {
console.log(`Ошибка конвертации изображения: id=${img.id}; type=${img.type};`);
++ecnt;
}
}
if (!ecnt) {
li.ok();
} else {
li.fail();
log.warning("Часть изображений не удалось сконвертировать!");
}
}
}
}
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("sethint", 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);
// Обложка книги
if (bdata.cover) {
const src = bdata.cover.src;
if (src) {
const li = log.message("Загрузка обложки...");
if (!bdata.skipCover) {
const img = new FB2Image(src);
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;
}
} else {
li.skipped();
}
}
}
if (!bdata.cover || (!doc.coverpage && !bdata.skipCover)) log.warning("Обложка книги не найдена!");
// Аннотация
if (bdata.annotation || bdata.authorNotes) {
const li = log.message("Анализ аннотации...");
try {
doc.bindParser("a", new AnnotationParser());
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("c", new ChapterParser());
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) {
// workId числовой, отфильтрован регуляркой, кодировать для запроса не нужно
const url = new URL(`/reader/${workId}/chapter`, document.location);
url.searchParams.set("id", chapterId);
url.searchParams.set("_", Date.now());
const result = await Loader.addJob(url, {
method: "GET",
headers: { "Accept": "application/json, text/javascript, */*; q=0.01" },
responseType: "text"
});
const readerSecret = result.headers.get("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 документ
* @param Object extra Дополнительные данные
*
* @return string Имя файла с расширением
*/
function genBookFileName(doc, extra) {
function xtrim(s) {
const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
return r && r[1] || s;
}
const fn_template = Settings.get("filename", true).trim();
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);
if (ndata.has("s") && fn_template.includes("\\s")) {
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;
case "fragment":
status = "P";
break;
}
} else {
status = "P";
}
ndata.set("b", status);
// Выбранные главы [\c]
// Если цикл завершен и выбраны все главы (статус "F"), то возвращается пустое значение.
if (status != "F") {
const cr = extra.chaptersRange;
ndata.set("c", cr[0] === cr[1] ? `${cr[0]}` : `${cr[0]}-${cr[1]}`);
}
// Id книги [\i]
ndata.set("i", doc.id);
// Окончательное формирование имени файла плюс дополнительные чистки и проверки.
function replacer(str) {
let cnt = 0;
const new_str = str.replace(/\\([asnNtbci])/g, (match, ti) => {
const res = ndata.get(ti);
if (res === undefined) return "";
++cnt;
return res;
});
return { str: new_str, count: cnt };
}
function processParts(str, depth) {
const parts = [];
const pos = str.indexOf('<');
if (pos !== 0) {
parts.push(replacer(pos == -1 ? str : str.slice(0, pos)));
}
if (pos != -1) {
let i = pos + 1;
let n = 1;
for ( ; i < str.length; ++i) {
const c = str[i];
if (c == '<') {
++n;
} else if (c == '>') {
--n;
if (!n) {
parts.push(processParts(str.slice(pos + 1, i), depth + 1));
break;
}
}
}
if (++i < str.length) parts.push(processParts(str.slice(i), depth));
}
const sa = [];
let cnt = 0
for (const it of parts) {
sa.push(it.str);
cnt += it.count;
}
return {
str: (!depth || cnt) ? sa.join("") : "",
count: cnt
};
}
const fname = processParts(fn_template, 0).str.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>\\c - Диапазон глав в случае, если книга не завершена или выбраны не все главы;</li>" +
"<li><…> - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>";
fndiv.appendChild(descr);
let addnotes = HTML.createCheckbox("Добавить примечания автора в аннотацию", Settings.get("addnotes"));
let addcover = HTML.createCheckbox("Грузить обложку книги", Settings.get("addcover"));
let addimages = HTML.createCheckbox("Грузить картинки внутри глав", Settings.get("addimages"));
let materials = HTML.createCheckbox("Грузить дополнительные материалы", Settings.get("materials"));
let sethint = HTML.createCheckbox("Отображать подсказку о настройках в логе выгрузки", Settings.get("sethint"));
form.append(addnotes, addcover, addimages, materials, sethint);
addnotes = addnotes.querySelector("input");
addcover = addcover.querySelector("input");
addimages = addimages.querySelector("input");
materials = materials.querySelector("input");
sethint = sethint.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("addnotes", addnotes.checked);
Settings.set("addcover", addcover.checked);
Settings.set("addimages", addimages.checked);
Settings.set("materials", materials.checked);
Settings.set("sethint", sethint.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._overlay = null;
this._title = params.title || "";
this._onclose = params.onclose;
}
show() {
this._ensureForm();
this._ensureContent();
document.body.appendChild(this._overlay);
document.body.classList.add("modal-open");
this._modal.focus();
}
hide() {
this._overlay && this._overlay.remove();
this._overlay = null;
this._modal = null;
document.body.classList.remove("modal-open");
if (this._onclose) {
this._onclose();
this._onclose = null;
}
}
_ensureForm() {
if (!this._overlay) {
this._overlay = document.createElement("div");
this._overlay.classList.add("ate-dlg-overlay");
this._modal = this._overlay.appendChild(document.createElement("div"));
this._modal.classList.add("ate-dialog");
this._modal.tabIndex = -1;
this._modal.setAttribute("role", "dialog");
const header = this._modal.appendChild(document.createElement("div"));
header.classList.add("ate-title");
header.appendChild(document.createElement("div")).textContent = this._title;
const cb = header.appendChild(document.createElement("button"));
cb.type = "button";
cb.classList.add("ate-close-btn");
cb.textContent = "×";
this._modal.appendChild(document.createElement("form"));
this._overlay.addEventListener("click", event => {
if (event.target === this._overlay || event.target.closest(".ate-close-btn")) this.hide();
});
this._overlay.addEventListener("keydown", event => {
if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
this.hide();
}
});
}
}
_ensureContent() {
}
}
class DownloadDialog extends ModalDialog {
constructor(params) {
super(params);
this.log = null;
this.button = null;
this._ann = params.annotation;
this._cvr = params.cover;
this._mat = params.materials;
this._set = params.settings;
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 = "";
}
_ensureContent() {
const form = this._modal.querySelector("form");
form.replaceChildren();
this._pg1 = form.appendChild(document.createElement("div"));
this._pg2 = form.appendChild(document.createElement("div"));
this._pg1.classList.add("ate-page");
this._pg2.classList.add("ate-page");
this._pg2.style.display = "none";
const fst = this._pg1.appendChild(document.createElement("fieldset"));
const leg = fst.appendChild(document.createElement("legend"));
leg.textContent = "Главы для выгрузки";
const chs = fst.appendChild(document.createElement("div"));
chs.classList.add("ate-chapter-list");
const ntp = chs.appendChild(document.createElement("div"));
ntp.classList.add("ate-note");
ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
const tbd = fst.appendChild(document.createElement("div"));
tbd.classList.add("ate-toolbar");
const its = tbd.appendChild(document.createElement("span"));
const selected = document.createElement("strong");
selected.textContent = 0;
const total = document.createElement("strong");
its.append("Выбрано глав: ", selected, " из ", total);
const tb1 = tbd.appendChild(document.createElement("button"));
tb1.type = "button";
tb1.title = "Выделить все/ничего";
tb1.classList.add("ate-group-select");
const tb1i = document.createElement("i");
tb1i.classList.add("icon-check");
tb1.append(tb1i, " ?");
const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann && this._set.addnotes);
if (!this._ann) nte.querySelector("input").disabled = true;
this._pg1.appendChild(nte);
const cve = HTML.createCheckbox("Грузить обложку книги", this._cvr && this._set.addcover);
if (!this._cvr) cve.querySelector("input").disabled = true;
this._pg1.appendChild(cve);
const img = HTML.createCheckbox("Грузить картинки внутри глав", this._set.addimages);
this._pg1.appendChild(img);
const nmt = HTML.createCheckbox("Грузить дополнительные материалы", this._mat && this._set.materials);
if (!this._mat) nmt.querySelector("input").disabled = true;
this._pg1.appendChild(nmt);
const log = this._pg2.appendChild(document.createElement("div"));
const sbd = form.appendChild(document.createElement("div"));
sbd.classList.add("ate-buttons");
const sbt = sbd.appendChild(document.createElement("button"));
sbt.type = "submit";
sbt.classList.add("button", "btn", "btn-success");
sbt.textContent = "Продолжить";
const cbt = sbd.appendChild(document.createElement("button"));
cbt.type = "button";
cbt.classList.add("button", "btn", "btn-default");
cbt.textContent = "Закрыть";
let ch_cnt = 0;
this._chs.forEach(ch => {
const el = HTML.createChapterCheckbox(ch);
ch.element = el.querySelector("input");
chs.append(el);
++ch_cnt;
});
total.textContent = ch_cnt;
chs.addEventListener("change", event => {
const cnt = this._chs.reduce((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"));
});
cbt.addEventListener("click", event => this.hide());
form.addEventListener("submit", event => {
event.preventDefault();
if (this._sub) {
const res = {};
res.authorNotes = nte.querySelector("input").checked;
res.skipCover = !cve.querySelector("input").checked;
res.addimages = img.querySelector("input").checked;
res.materials = nmt.querySelector("input").checked;
let ch_min = 0;
let ch_max = 0;
res.chapters = this._chs.reduce((res, ch, idx) => {
if (!ch.locked && ch.element.checked) {
res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId });
ch_max = idx + 1;
if (!ch_min) ch_min = ch_max;
}
return res;
}, []);
res.chaptersRange = [ ch_min, ch_max ];
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("ate-checkbox");
const label = root.appendChild(document.createElement("label"));
const input = document.createElement("input");
input.type = "checkbox";
input.checked = checked;
const span = document.createElement("span");
span.classList.add("icon-check-bold");
label.append(input, span, 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.classList.add("ate-log");
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 "sethint":
case "addcover":
case "addnotes":
case "addimages":
case "materials":
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 extends FB2Loader {
/**
* Старт загрузки ресурса с указанного URL
*
* @param url Object Экземпляр класса URL (обязательный)
* @param params Object Объект с параметрами запроса (необязательный)
*
* @return mixed
*/
static async addJob(url, params) {
params ||= {};
if (url.origin === document.location.origin) {
params.extended = true;
return super.addJob(url, params);
}
params.url = url;
params.method ||= "GET";
params.responseType = params.responseType === "binary" ? "blob" : "text";
if (!this.ctl_list) this.ctl_list = new Set();
return new Promise((resolve, reject) => {
let req = null;
params.onload = r => {
if (r.status === 200) {
const headers = new Headers();
r.responseHeaders.split("\n").forEach(hs => {
const h = hs.split(":");
if (h[1]) headers.append(h[0], 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() {
super.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;
};
//-------------------------
function addStyle(css) {
const style = document.getElementById("ate_styles") || (function() {
const style = document.createElement('style');
style.type = 'text/css';
style.id = "ate_styles";
document.head.appendChild(style);
return style;
})();
const sheet = style.sheet;
sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
}
function addStyles() {
[
".ate-dlg-overlay, .ate-title { display:flex; align-items:center; justify-content:center; }",
".ate-dialog, .ate-dialog form, .ate-page, .ate-dialog fieldset, .ate-chapter-list { display:flex; flex-direction:column; }",
".ate-page, .ate-dialog form, .ate-dialog fieldset { flex:1; overflow:hidden; }",
".ate-dlg-overlay { display:flex; position:fixed; top:0; left:0; bottom:0; right:0; overflow:auto; background-color:rgba(0,0,0,.3); white-space:nowrap; z-index:10000; }",
".ate-dialog { display:flex; flex-direction:column; position:fixed; top:0; left:0; bottom:0; right:0; background-color:#fff; overflow-y:auto; }",
".ate-title { flex:0 0 auto; padding:10px; color:#66757f; background-color:#edf1f2; border-bottom:1px solid #e5e5e5; }",
".ate-title>div:first-child { margin:auto; }",
".ate-close-btn { cursor:pointer; border:0; background-color:transparent; font-size:21px; font-weight:bold; line-height:1; text-shadow:0 1px 0 #fff; opacity:.4; }",
".ate-close-btn:hover { opacity:.9 }",
".ate-dialog form { padding:10px 15px 15px; white-space:normal; gap:10px; min-height:30em; }",
".ate-page { gap:10px; }",
".ate-dialog fieldset { border:1px solid #bbb; border-radius:6px; padding:10px; margin:0; gap:10px; }",
".ate-dialog legend { display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none; }",
".ate-chapter-list { flex:1; gap:10px; overflow-y:auto; }",
".ate-toolbar { display:flex; align-items:center; padding-top:10px; border-top:1px solid #bbb; }",
".ate-group-select { margin-left:auto; }",
".ate-log { flex:1; padding:6px; border:1px solid #bbb; border-radius:6px; overflow:auto; }",
".ate-buttons { display:flex; flex-direction:column; gap:10px; }",
".ate-buttons button { min-width:8em; }",
".ate-checkbox label { cursor:pointer; margin:0; }",
".ate-checkbox input { position:static; visibility:hidden; width:0; float:right; }", // position:absolute провоцирует прокрутку overlay-я в мобильной версии сайта
".ate-checkbox span { position:relative; display:inline-block; width:17px; height:17px; margin-top:2px; margin-right:10px; text-align:center; vertical-align:top; border-radius:2px; border:1px solid #ccc; }",
".ate-checkbox span:before { position:absolute; top:0; left:-1px; right:0; bottom:0; margin-left:1px; opacity:0; text-align:center; font-size:10px; line-height:16px; vertical-align:middle; }",
".ate-checkbox:hover span { border-color:#5d9ced; }",
".ate-checkbox input:checked + span { border-color:#5d9cec; background-color:#5d9ced; }",
".ate-checkbox input:disabled + span { border-color:#ddd; background-color:#ddd; }",
".ate-checkbox input:checked + span:before { color:#fff; opacity:1; transition:color .3s ease-out; }",
//".ate-chapter-list .ate-note { margin-bottom: 5px; }",
//".ate-chapter-list .ate-checkbox label { padding:5px; width:99%; }",
//".ate-chapter-list .ate-checkbox label:hover { color:#34749e; background-color:#f5f7fa; }",
"@media (min-width:520px) and (min-height:600px) {" +
".ate-dialog { position:static; max-width:35em; min-width:30em; height:80vh; border-radius:6px; border:1px solid rgba(0,0,0,.2); box-shadow:0 3px 9px rgba(0,0,0,.5); }" +
".ate-title { border-top-left-radius:6px; border-top-right-radius:6px; }" +
".ate-buttons { flex-flow:row wrap; justify-content:center; }" +
".ate-buttons .btn-default { display:none; }" +
"}"
].forEach(s => addStyle(s));
}
// Запускает скрипт после загрузки страницы сайта
if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
else init();
})();