AuthorTodayExtractor

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

بۇ قوليازمىنى قاچىلاش؟
ئاپتورنىڭ تەۋسىيەلىگەن قوليازمىسى

سىز بەلكىم FicbookExtractor نى ياقتۇرۇشىڭىز مۇمكىن.

بۇ قوليازمىنى قاچىلاش
  1. // ==UserScript==
  2. // @name AuthorTodayExtractor
  3. // @name:ru AuthorTodayExtractor
  4. // @namespace 90h.yy.zz
  5. // @version 1.6.2
  6. // @author Ox90
  7. // @match https://author.today/*
  8. // @description The script adds a button to the site for downloading books to an FB2 file
  9. // @description:ru Скрипт добавляет кнопку для скачивания книги в формате FB2
  10. // @require https://update.greatest.deepsurf.us/scripts/468831/1478439/HTML2FB2Lib.js
  11. // @grant GM.xmlHttpRequest
  12. // @grant unsafeWindow
  13. // @connect author.today
  14. // @connect cm.author.today
  15. // @connect *
  16. // @run-at document-start
  17. // @license MIT
  18. // ==/UserScript==
  19.  
  20. /**
  21. * Записи вида `@connect` необходимы пользователям tampermonkey для загрузки обложек и изображений внутри глав.
  22. * Разрешение `@connect cm.author.today` - для загрузки обложек и дополнительных материалов.
  23. * Разрешение `@connect author.today` - для загрузки обложек у старых книг.
  24. * Разрешение `@connect *` необходимо для того, чтобы получить возможность загружать картинки
  25. * внутри глав со сторонних ресурсов, когда авторы ссылаются в своих главах на сторонние хостинги картинок.
  26. * Такое хоть и редко, но встречается. Это разрешение прописано, чтобы пользователю отображалась кнопка
  27. * "Always allow all domains" при подтверждении запроса.
  28. * Детали: https://www.tampermonkey.net/documentation.php#_connect
  29. */
  30.  
  31. (function start() {
  32. "use strict";
  33.  
  34. const PROGRAM_NAME = "ATExtractor";
  35.  
  36. let app = null;
  37. let stage = 0;
  38. let mobile = false;
  39. let mainBtn = null;
  40.  
  41. /**
  42. * Начальный запуск скрипта сразу после загрузки страницы сайта
  43. *
  44. * @return void
  45. */
  46. function init() {
  47. addStyles();
  48. pageHandler();
  49. // Следить за ajax контейнером
  50. const ajax_el = document.getElementById("pjax-container");
  51. if (ajax_el) (new MutationObserver(() => pageHandler())).observe(ajax_el, { childList: true });
  52. }
  53.  
  54. /**
  55. * Начальная идентификация страницы и запуск необходимых функций
  56. *
  57. * @return void
  58. */
  59. function pageHandler() {
  60. const path = document.location.pathname;
  61. if (path.startsWith("/account/") || (path.startsWith("/u/") && path.endsWith("/edit"))) {
  62. // Это страница настроек (личный кабинет пользователя)
  63. ensureSettingsMenuItems();
  64. if (path === "/account/settings" && (new URL(document.location)).searchParams.get("script") === "atex") {
  65. // Это страница настроек скрипта
  66. handleSettingsPage();
  67. }
  68. return;
  69. }
  70. if (/work\/\d+$/.test(path)) {
  71. // Страница книги
  72. handleWorkPage();
  73. return;
  74. }
  75. }
  76.  
  77. /**
  78. * Обработчик страницы с книгой. Добавляет кнопку и инициирует необходимые структуры
  79. *
  80. * @return void
  81. */
  82. function handleWorkPage() {
  83. // Найти и сохранить объект App.
  84. // App нужен для получения userId, который используется как часть ключа при расшифровке.
  85. app = window.app || (unsafeWindow && unsafeWindow.app) || {};
  86. // Добавить кнопку на панель
  87. setMainButton();
  88. }
  89.  
  90. /**
  91. * Находит панель и добавляет туда кнопку, если она отсутствует.
  92. * Вызывается не только при инициализации скрипта но и при изменениях ajax контейнере сайта
  93. *
  94. * @return void
  95. */
  96. function setMainButton() {
  97. // Проверить, что это текст, а не, например, аудиокнига, и найти панель для вставки кнопки
  98. let a_panel = null;
  99. if (document.querySelector("div.book-action-panel a[href^='/reader/']")) {
  100. a_panel = document.querySelector("div.book-panel div.book-action-panel");
  101. mobile = false;
  102. } else if (document.querySelector("div.work-details div.row a[href^='/reader/']")) {
  103. a_panel = document.querySelector("div.work-details div.row div.btn-library-work");
  104. a_panel = a_panel && a_panel.parentElement;
  105. mobile = true;
  106. }
  107. if (!a_panel) return;
  108.  
  109. if (!mainBtn) {
  110. // Похоже кнопки нет. Создать кнопку и привязать действие.
  111. mainBtn = createButton(mobile);
  112. const ael = mobile && mainBtn || mainBtn.children[0];
  113. ael.addEventListener("click", event => {
  114. event.preventDefault();
  115. displayDownloadDialog();
  116. });
  117. }
  118.  
  119. if (!a_panel.contains(mainBtn)) {
  120. // Выбрать позицию для кнопки: или после оригинальной, или перед группой кнопок внизу.
  121. // Если не удалось найти нужную позицию, тогда добавить кнопку как последнюю в панели.
  122. let sbl = null;
  123. if (!mobile) {
  124. sbl = a_panel.querySelector("div.mt-lg>a.btn>i.icon-download");
  125. sbl && (sbl = sbl.parentElement.parentElement.nextElementSibling);
  126. } else {
  127. sbl = a_panel.querySelector("#btn-download");
  128. if (sbl) sbl = sbl.nextElementSibling;
  129. }
  130. if (!sbl) {
  131. if (!mobile) {
  132. sbl = document.querySelector("div.mt-lg.text-center");
  133. } else {
  134. sbl = a_panel.querySelector("a.btn-work-more");
  135. }
  136. }
  137. // Добавить кнопку на страницу книги
  138. if (sbl) {
  139. a_panel.insertBefore(mainBtn, sbl);
  140. } else {
  141. a_panel.appendChild(mainBtn);
  142. }
  143. }
  144. }
  145.  
  146. /**
  147. * Создает и возвращает элемент кнопки, которая размещается на странице книги
  148. *
  149. * @return Element HTML-элемент кнопки для добавления на страницу
  150. */
  151. function createButton() {
  152. const ae = document.createElement("a");
  153. ae.classList.add("btn", "btn-default", mobile && "btn-download-work" || "btn-block");
  154. ae.style.borderColor = "green";
  155. ae.innerHTML = "<i class=\"icon-download\"></i>";
  156. ae.appendChild(document.createTextNode(""));
  157. let btn = ae;
  158. if (!mobile) {
  159. btn = document.createElement("div");
  160. btn.classList.add("mt-lg");
  161. btn.appendChild(ae);
  162. }
  163. btn.setText = function(text) {
  164. let el = this.nodeName === "A" ? this : this.querySelector("a");
  165. el.childNodes[1].textContent = " " + (text || "Скачать FB2");
  166. };
  167. btn.setText();
  168. return btn;
  169. }
  170.  
  171. /**
  172. * Обработчик нажатия кнопки "Скачать FB2" на странице книги
  173. *
  174. * @return void
  175. */
  176. async function displayDownloadDialog() {
  177. if (mainBtn.disabled) return;
  178. try {
  179. mainBtn.disabled = true;
  180. mainBtn.setText("Анализ...");
  181. const params = getBookOverview();
  182. let log = null;
  183. let doc = new FB2DocumentEx();
  184. doc.bookTitle = params.title;
  185. doc.id = params.workId;
  186. doc.idPrefix = "atextr_";
  187. doc.status = params.status;
  188. doc.programName = PROGRAM_NAME + " v" + GM_info.script.version;
  189. const chapters = await getChaptersList(params);
  190. doc.totalChapters = chapters.length;
  191. const dlg = new DownloadDialog({
  192. title: "Формирование файла FB2",
  193. annotation: !!params.authorNotes,
  194. cover: !!params.cover,
  195. materials: !!params.materials,
  196. settings: {
  197. addnotes: Settings.get("addnotes"),
  198. addcover: Settings.get("addcover"),
  199. addimages: Settings.get("addimages"),
  200. materials: Settings.get("materials")
  201. },
  202. chapters: chapters,
  203. onclose: () => {
  204. Loader.abortAll();
  205. log = null;
  206. doc = null;
  207. if (dlg.link) {
  208. URL.revokeObjectURL(dlg.link.href);
  209. dlg.link = null;
  210. }
  211. },
  212. onsubmit: result => {
  213. result.cover = params.cover;
  214. result.bookPanel = params.bookPanel;
  215. result.annotation = params.annotation;
  216. if (result.authorNotes) result.authorNotes = params.authorNotes;
  217. if (result.materials) result.materials = params.materials;
  218. dlg.result = result;
  219. makeAction(doc, dlg, log);
  220. }
  221. });
  222. dlg.show();
  223. log = new LogElement(dlg.log);
  224. if (chapters.length) {
  225. setStage(0);
  226. } else {
  227. dlg.button.textContent = setStage(3);
  228. dlg.nextPage();
  229. log.warning("Нет доступных глав для выгрузки!");
  230. }
  231. } catch (err) {
  232. console.error(err);
  233. Notification.display(err.message, "error");
  234. } finally {
  235. mainBtn.disabled = false;
  236. mainBtn.setText();
  237. }
  238. }
  239.  
  240. /**
  241. * Фактический обработчик нажатий на кнопку формы выгрузки
  242. *
  243. * @param FB2Document doc Формируемый документ
  244. * @param DownloadDialog dlg Экземпляр формы выгрузки
  245. * @param LogElement log Лог для фиксации прогресса
  246. *
  247. * @return void
  248. */
  249. async function makeAction(doc, dlg, log) {
  250. try {
  251. switch (stage) {
  252. case 0:
  253. dlg.button.textContent = setStage(1);
  254. dlg.nextPage();
  255. await getBookContent(doc, dlg.result, log);
  256. if (stage == 1) dlg.button.textContent = setStage(2);
  257. break;
  258. case 1:
  259. Loader.abortAll();
  260. dlg.button.textContent = setStage(3);
  261. log.warning("Операция прервана");
  262. Notification.display("Операция прервана", "warning");
  263. break;
  264. case 2:
  265. if (!dlg.link) {
  266. dlg.link = document.createElement("a");
  267. dlg.link.setAttribute("download", genBookFileName(doc, { chaptersRange: dlg.result.chaptersRange }));
  268. // Должно быть text/plain, но в этом случае мобильный Firefox к имени файла добавляет .txt
  269. dlg.link.href = URL.createObjectURL(new Blob([ doc ], { type: "application/octet-stream" }));
  270. }
  271. dlg.link.click();
  272. break;
  273. case 3:
  274. dlg.hide();
  275. break;
  276. }
  277. } catch (err) {
  278. if (err.name !== "AbortError") {
  279. console.error(err);
  280. log.message(err.message, "red");
  281. Notification.display(err.message, "error");
  282. }
  283. dlg.button.textContent = setStage(3);
  284. }
  285. }
  286.  
  287. /**
  288. * Выбор стадии работы скрипта
  289. *
  290. * @param int new_stage Числовое значение новой стадии
  291. *
  292. * @return string Текст для кнопки диалога
  293. */
  294. function setStage(new_stage) {
  295. stage = new_stage;
  296. return [ "Продолжить", "Прервать", "Сохранить в файл", "Закрыть" ][new_stage] || "Error";
  297. }
  298.  
  299. /**
  300. * Возвращает объект с предварительными результатами анализа книги
  301. *
  302. * @return Object
  303. */
  304. function getBookOverview() {
  305. const res = {};
  306.  
  307. res.bookPanel = document.querySelector("div.book-panel div.book-meta-panel") ||
  308. document.querySelector("div.work-details div.work-header-content");
  309.  
  310. res.title = res.bookPanel && (res.bookPanel.querySelector(".book-title") || res.bookPanel.querySelector(".card-title"));
  311. res.title = res.title ? res.title.textContent.trim() : null;
  312.  
  313. const wid = /^\/work\/(\d+)$/.exec(document.location.pathname);
  314. res.workId = wid && wid[1] || null;
  315.  
  316. const status_el = res.bookPanel && res.bookPanel.querySelector(".book-status-icon");
  317. if (status_el) {
  318. if (status_el.classList.contains("icon-check")) {
  319. res.status = "finished";
  320. } else if (status_el.classList.contains("icon-pencil")) {
  321. res.status = "in-progress";
  322. }
  323. } else {
  324. res.status = "fragment";
  325. }
  326.  
  327. const empty = el => {
  328. if (!el) return false;
  329. // Считается что аннотация есть только в том случае,
  330. // если имеются непустые текстовые ноды непосредственно в блоке аннотации
  331. return !Array.from(el.childNodes).some(node => {
  332. return node.nodeName === "#text" && node.textContent.trim() !== "";
  333. });
  334. };
  335.  
  336. let annotation = mobile ?
  337. document.querySelector("div.card-content-inner>div.card-description") :
  338. (res.bookPanel && res.bookPanel.querySelector("#tab-annotation>div.annotation"));
  339. if (annotation.children.length > 0) {
  340. const notes = annotation.querySelector(":scope>div.rich-content>p.text-primary.mb0");
  341. if (notes && !empty(notes.parentElement)) res.authorNotes = notes.parentElement;
  342. annotation = annotation.querySelector(":scope>div.rich-content");
  343. if (!empty(annotation) && annotation !== notes) res.annotation = annotation;
  344. }
  345.  
  346. const cover = mobile ?
  347. document.querySelector("div.work-cover>.work-cover-content>img.cover-image") :
  348. document.querySelector("div.book-cover>.book-cover-content>img.cover-image");
  349. if (cover) {
  350. res.cover = cover;
  351. }
  352.  
  353. const materials = mobile ?
  354. document.querySelector("#accordion-item-materials>div.accordion-item-content div.picture") :
  355. res.bookPanel && res.bookPanel.querySelector("div.book-materials div.picture");
  356. if (materials) {
  357. res.materials = materials;
  358. }
  359.  
  360. return res;
  361. }
  362.  
  363. /**
  364. * Возвращает список глав из DOM-дерева сайта в формате
  365. * { title: string, locked: bool, workId: string, chapterId: string }.
  366. *
  367. * @return array Массив объектов с данными о главах
  368. */
  369. async function getChaptersList(params) {
  370. const el_list = document.querySelectorAll(
  371. mobile &&
  372. "div.work-table-of-content>ul.list-unstyled>li" ||
  373. "div.book-tab-content>div#tab-chapters>ul.table-of-content>li"
  374. );
  375.  
  376. if (!el_list.length) {
  377. // Не найдено ни одной главы, возможно это рассказ
  378. // Запрашивает первую главу чтобы получить объект в исходном HTML коде ответа сервера
  379. let chapters = null;
  380. try {
  381. const r = await Loader.addJob(new URL(`/reader/${params.workId}`, document.location), {
  382. method: "GET",
  383. responseType: "text"
  384. });
  385. const meta = /app\.init\("readerIndex",\s*(\{[\s\S]+?\})\s*\)/.exec(r.response); // Ищет строку инициализации с данными главы
  386. if (!meta) throw new Error("Не найдены метаданные книги в ответе сервера");
  387. let w_id = /\bworkId\s*:\s*(\d+)/.exec(r.response);
  388. w_id = w_id && w_id[1] || params.workId;
  389. let c_ls = /\bchapters\s*:\s*(\[.+\])\s*,?[\n\r]+/.exec(r.response);
  390. c_ls = c_ls && c_ls[1] || "[]";
  391. chapters = (JSON.parse(c_ls) || []).map(ch => {
  392. return { title: ch.title, workId: w_id, chapterId: "" + ch.id };
  393. });
  394. const w_fm = /\bworkForm\s*:\s*"(.+)"/.exec(r.response);
  395. if (w_fm && w_fm[1].toLowerCase() === "story" && chapters.length === 1) chapters[0].title = "";
  396. chapters[0].locked = false;
  397. } catch (err) {
  398. console.error(err);
  399. throw new Error("Ошибка загрузки метаданных главы");
  400. }
  401. return chapters;
  402. }
  403. // Анализирует найденные HTML элементы с главами
  404. const res = [];
  405. for (let i = 0; i < el_list.length; ++i) {
  406. const el = el_list[i].children[0];
  407. if (el) {
  408. let ids = null;
  409. const title = el.textContent;
  410. let locked = false;
  411. if (el.tagName === "A" && el.hasAttribute("href")) {
  412. ids = /^\/reader\/(\d+)\/(\d+)$/.exec((new URL(el.href)).pathname);
  413. } else if (el.tagName === "SPAN") {
  414. if (el.parentElement.querySelector("i.icon-lock")) locked = true;
  415. }
  416. if (title && (ids || locked)) {
  417. const ch = { title: title, locked: locked };
  418. if (ids) {
  419. ch.workId = ids[1];
  420. ch.chapterId = ids[2];
  421. }
  422. res.push(ch);
  423. }
  424. }
  425. }
  426. return res;
  427. }
  428.  
  429. /**
  430. * Производит формирование описания книги, загрузку и анализ глав и доп.материалов
  431. *
  432. * @param FB2DocumentEx doc Формируемый документ
  433. * @param Object bdata Объект с предварительными данными
  434. * @param LogElement log Лог для фиксации процесса формирования книги
  435. *
  436. * @return void
  437. */
  438. async function getBookContent(doc, bdata, log) {
  439. await extractDescriptionData(doc, bdata, log);
  440. if (stage !== 1) return;
  441.  
  442. log.message("---");
  443. await extractChapters(doc, bdata.chapters, { noImages: !bdata.addimages }, log);
  444. if (stage !== 1) return;
  445.  
  446. if (bdata.materials) {
  447. log.message("---");
  448. log.message("Дополнительные материалы:");
  449. await extractMaterials(doc, bdata.materials, log);
  450. doc.hasMaterials = true;
  451. if (stage !== 1) return;
  452. }
  453. if (bdata.addimages) {
  454. const icnt = doc.binaries.reduce((cnt, img) => {
  455. if (!img.value) ++cnt;
  456. return cnt;
  457. }, 0);
  458. if (icnt) {
  459. log.message("---");
  460. log.warning(`Проблемы с загрузкой изображений: ${icnt}`);
  461. await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога
  462. if (confirm("Имеются незагруженные изображения. Использовать заглушку?")) {
  463. const li = log.message("Применение заглушки...");
  464. try {
  465. const img = getDummyImage();
  466. replaceBadImages(doc, img);
  467. doc.binaries.push(img);
  468. li.ok();
  469. } catch (err) {
  470. li.fail();
  471. throw err;
  472. }
  473. } else {
  474. log.message("Проблемные изображения заменены на текст");
  475. }
  476. }
  477. }
  478. let webpList = [];
  479. const imgTypes = doc.binaries.reduce((map, bin) => {
  480. if (bin instanceof FB2Image && bin.value) {
  481. const type = bin.type;
  482. map.set(type, (map.get(type) || 0) + 1);
  483. if (type === "image/webp") webpList.push(bin);
  484. }
  485. return map;
  486. }, new Map());
  487. if (imgTypes.size) {
  488. log.message("---");
  489. log.message("Изображения:");
  490. imgTypes.forEach((cnt, type) => log.message(`- ${type}: ${cnt}`));
  491. if (webpList.length) {
  492. log.warning("Найдены изображения формата WebP. Могут быть проблемы с отображением на старых читалках.");
  493. await new Promise(resolve => setTimeout(resolve, 100)); // Для обновления лога
  494. if (confirm("Выполнить конвертацию WebP --> JPEG?")) {
  495. const li = log.message("Конвертация изображений...");
  496. let ecnt = 0;
  497. for (const img of webpList) {
  498. try {
  499. await img.convert("image/jpeg");
  500. } catch(err) {
  501. console.log(`Ошибка конвертации изображения: id=${img.id}; type=${img.type};`);
  502. ++ecnt;
  503. }
  504. }
  505. if (!ecnt) {
  506. li.ok();
  507. } else {
  508. li.fail();
  509. log.warning("Часть изображений не удалось сконвертировать!");
  510. }
  511. }
  512. }
  513. }
  514. if (doc.unknowns) {
  515. log.message("---");
  516. log.warning(`Найдены неизвестные элементы: ${doc.unknowns}`);
  517. log.message("Преобразованы в текст без форматирования");
  518. }
  519. doc.history.push("v1.0 - создание fb2 - (Ox90)");
  520. log.message("---");
  521. log.message("Готово!");
  522. if (Settings.get("sethint", true)) {
  523. log.message("---");
  524. const hint = document.createElement("span");
  525. hint.innerHTML =
  526. "<i>Для формирования имени файла будет использован следующий шаблон: <b>" + Settings.get("filename") +
  527. "</b>. Вы можете настроить скрипт и отключить это сообщение в " +
  528. " <a href=\"/account/settings?script=atex\" target=\"_blank\">в личном кабинете</a>.</i>";
  529. log.message(hint);
  530. }
  531. }
  532.  
  533. /**
  534. * Извлекает доступные данные описания книги из DOM элементов сайта
  535. *
  536. * @param FB2DocumentEx doc Формируемый документ
  537. * @param Object bdata Объект с предварительными данными
  538. * @param LogElement log Лог для фиксации процесса формирования книги
  539. *
  540. * @return void
  541. */
  542. async function extractDescriptionData(doc, bdata, log) {
  543. if (!bdata.bookPanel) throw new Error("Не найдена панель с информацией о книге!");
  544. if (!doc.bookTitle) throw new Error("Не найден заголовок книги");
  545. const book_panel = bdata.bookPanel;
  546.  
  547. log.message("Заголовок:").text(doc.bookTitle);
  548. // Авторы
  549. const authors = mobile ?
  550. book_panel.querySelectorAll("div.card-author>a") :
  551. book_panel.querySelectorAll("div.book-authors>span[itemprop=author]>a");
  552. doc.bookAuthors = Array.from(authors).reduce((list, el) => {
  553. const au = el.textContent.trim();
  554. if (au) {
  555. const a = new FB2Author(au);
  556. const hp = /^\/u\/([^\/]+)\/works(?:\?|$)/.exec((new URL(el.href)).pathname);
  557. if (hp) a.homePage = (new URL(`/u/${hp[1]}`, document.location)).toString();
  558. list.push(a);
  559. }
  560. return list;
  561. }, []);
  562. if (!doc.bookAuthors.length) throw new Error("Не найдена информация об авторах");
  563. log.message("Авторы:").text(doc.bookAuthors.length);
  564. // Жанры
  565. let genres = mobile ?
  566. book_panel.querySelectorAll("div.work-stats a[href^=\"/work/genre/\"]") :
  567. book_panel.querySelectorAll("div.book-genres>a[href^=\"/work/genre/\"]");
  568. genres = Array.from(genres).reduce((list, el) => {
  569. const s = el.textContent.trim();
  570. if (s) list.push(s);
  571. return list;
  572. }, []);
  573. doc.genres = new FB2GenreList(genres);
  574. if (doc.genres.length) {
  575. console.info("Жанры: " + doc.genres.map(g => g.value).join(", "));
  576. } else {
  577. console.warn("Не идентифицирован ни один жанр!");
  578. }
  579. log.message("Жанры:").text(doc.genres.length);
  580. // Ключевые слова
  581. const tags = mobile ?
  582. document.querySelectorAll("div.work-details ul.work-tags a[href^=\"/work/tag/\"]") :
  583. book_panel.querySelectorAll("span.tags a[href^=\"/work/tag/\"]");
  584. doc.keywords = Array.from(tags).reduce((list, el) => {
  585. const tag = el.textContent.trim();
  586. if (tag) list.push(tag);
  587. return list;
  588. }, []);
  589. log.message("Ключевые слова:").text(doc.keywords.length || "нет");
  590. // Серия
  591. let seq_el = Array.from(book_panel.querySelectorAll("div>a")).find(el => {
  592. return el.href && /^\/work\/series\/\d+$/.test((new URL(el.href)).pathname);
  593. });
  594. if (seq_el) {
  595. const name = seq_el.textContent.trim();
  596. if (name) {
  597. const seq = { name: name };
  598. seq_el = seq_el.nextElementSibling;
  599. if (seq_el && seq_el.tagName === "SPAN") {
  600. const num = /^#(\d+)$/.exec(seq_el.textContent.trim());
  601. if (num) seq.number = num[1];
  602. }
  603. doc.sequence = seq;
  604. log.message("Серия:").text(name);
  605. if (seq.number) log.message("Номер в серии:").text(seq.number);
  606. }
  607. }
  608. // Дата публикации книги (последнее обновление)
  609. const dt = book_panel.querySelector("span[data-format=calendar-short][data-time]");
  610. if (dt) {
  611. const d = new Date(dt.getAttribute("data-time"));
  612. if (!isNaN(d.valueOf())) doc.bookDate = d;
  613. }
  614. log.message("Дата публикации:").text(doc.bookDate ? FB2Utils.dateToAtom(doc.bookDate) : "n/a");
  615. // Ссылка на источник
  616. doc.sourceURL = document.location.origin + document.location.pathname;
  617. log.message("Источкик:").text(doc.sourceURL);
  618. // Обложка книги
  619. if (bdata.cover) {
  620. const src = bdata.cover.src;
  621. if (src) {
  622. const li = log.message("Загрузка обложки...");
  623. if (!bdata.skipCover) {
  624. const img = new FB2Image(src);
  625. try {
  626. await img.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"));
  627. img.id = "cover" + img.suffix();
  628. doc.coverpage = img;
  629. doc.binaries.push(img);
  630. li.ok();
  631. log.message("Размер обложки:").text(img.size + " байт");
  632. log.message("Тип обложки:").text(img.type);
  633. } catch (err) {
  634. li.fail();
  635. throw err;
  636. }
  637. } else {
  638. li.skipped();
  639. }
  640. }
  641. }
  642. if (!bdata.cover || (!doc.coverpage && !bdata.skipCover)) log.warning("Обложка книги не найдена!");
  643. // Аннотация
  644. if (bdata.annotation || bdata.authorNotes) {
  645. const li = log.message("Анализ аннотации...");
  646. try {
  647. doc.bindParser("a", new AnnotationParser());
  648. if (bdata.annotation) {
  649. await doc.parse("a", log, {}, bdata.annotation);
  650. }
  651. if (bdata.authorNotes) {
  652. if (doc.annotation && doc.annotation.children.length) {
  653. // Пустая строка между аннотацией и примечаниями автора
  654. doc.annotation.children.push(new FB2EmptyLine());
  655. }
  656. await doc.parse("a", log, {}, bdata.authorNotes);
  657. }
  658. li.ok();
  659. } catch (err) {
  660. li.fail();
  661. throw err;
  662. } finally {
  663. doc.bindParser();
  664. }
  665. } else {
  666. log.warning("Нет аннотации!");
  667. }
  668. }
  669.  
  670. /**
  671. * Запрашивает выбранные ранее части книги с сервера по переданному в аргументе списку.
  672. * Главы запрашиваются последовательно, чтобы не удивлять сервер запросами всех глав одновременно.
  673. *
  674. * @param FB2DocumentEx doc Формируемый документ
  675. * @param Array desired Массив с описанием глав для выгрузки (id и название)
  676. * @param object params Параметры формирования глав
  677. * @param LogElement log Лог для фиксации процесса формирования книги
  678. *
  679. * @return void
  680. */
  681. async function extractChapters(doc, desired, params, log) {
  682. let li = null;
  683. try {
  684. const total = desired.length;
  685. let position = 0;
  686. doc.bindParser("c", new ChapterParser());
  687. for (const ch of desired) {
  688. if (stage !== 1) break;
  689. li = log.message(`Получение главы ${++position}/${total}...`);
  690. const html = await getChapterContent(ch.workId, ch.chapterId);
  691. await doc.parse("c", log, params, html.body, ch.title);
  692. li.ok();
  693. }
  694. } catch (err) {
  695. if (li) li.fail();
  696. throw err;
  697. } finally {
  698. doc.bindParser();
  699. }
  700. }
  701.  
  702. /**
  703. * Запрашивает содержимое указанной главы с сервера
  704. *
  705. * @param string workId Id книги
  706. * @param string chapterId Id главы
  707. *
  708. * @return HTMLDocument главы книги
  709. */
  710. async function getChapterContent(workId, chapterId) {
  711. // workId числовой, отфильтрован регуляркой, кодировать для запроса не нужно
  712. const url = new URL(`/reader/${workId}/chapter`, document.location);
  713. url.searchParams.set("id", chapterId);
  714. url.searchParams.set("_", Date.now());
  715. const result = await Loader.addJob(url, {
  716. method: "GET",
  717. headers: { "Accept": "application/json, text/javascript, */*; q=0.01" },
  718. responseType: "text"
  719. });
  720. let response = null;
  721. try {
  722. response = JSON.parse(result.response);
  723. } catch (err) {
  724. console.error(err);
  725. throw new Error("Неожиданный ответ сервера");
  726. }
  727. if (!response.isSuccessful) {
  728. if (Array.isArray(response.messages) && response.messages.length) {
  729. if (response.messages[0].toLowerCase() === "unadulted") {
  730. throw new Error("Контент для взрослых. Зайдите в любую главу книги, подтвердите свой возраст и попробуйте снова");
  731. }
  732. }
  733. throw new Error("Сервер ответил: Unsuccessful");
  734. }
  735. const readerSecret = result.headers.get("reader-secret");
  736. if (!readerSecret) throw new Error("Не найден ключ для расшифровки текста");
  737. // Декодировать ответ от сервера
  738. const chapterString = decryptText(response, readerSecret);
  739. // Преобразовать в HTML элемент.
  740. // Присваивание innerHTML не ипользуется по причине его небезопасности.
  741. // Лучше перестраховаться на случай возможного внедрения скриптов в тело книги.
  742. return new DOMParser().parseFromString(chapterString, "text/html");
  743. }
  744.  
  745. /**
  746. * Расшифровывает полученную от сервера строку с текстом
  747. *
  748. * @param chapter string Зашифованная глава книги, полученная от сервера
  749. * @param secret string Часть ключа для расшифровки
  750. *
  751. * @return string Расшифрованный текст
  752. */
  753. function decryptText(chapter, secret) {
  754. let ss = secret.split("").reverse().join("") + "@_@" + (app.userId || "");
  755. let slen = ss.length;
  756. let clen = chapter.data.text.length;
  757. let result = [];
  758. for (let pos = 0; pos < clen; ++pos) {
  759. result.push(String.fromCharCode(chapter.data.text.charCodeAt(pos) ^ ss.charCodeAt(Math.floor(pos % slen))));
  760. }
  761. return result.join("");
  762. }
  763.  
  764. /**
  765. * Просматривает элементы с картинками в дополнительных материалах,
  766. * затем загружает их по ссылкам и сохраняет в виде массива с описанием, если оно есть.
  767. *
  768. * @param FB2DocumentEx doc Формируемый документ
  769. * @param Element materials HTML-элемент с дополнительными материалами
  770. * @param LogElement log Лог для фиксации процесса формирования книги
  771. *
  772. * @return void
  773. */
  774. async function extractMaterials(doc, materials, log) {
  775. const list = Array.from(materials.querySelectorAll("figure")).reduce((res, el) => {
  776. const link = el.querySelector("a");
  777. if (link && link.href) {
  778. const ch = new FB2Chapter();
  779. const cp = el.querySelector("figcaption");
  780. const ds = (cp && cp.textContent.trim() !== "") ? cp.textContent.trim() : "Без описания";
  781. const im = new FB2Image(link.href);
  782. ch.children.push(new FB2Paragraph(ds));
  783. ch.children.push(im);
  784. res.push(ch);
  785. doc.binaries.push(im);
  786. }
  787. return res;
  788. }, []);
  789.  
  790. let cnt = list.length;
  791. if (cnt) {
  792. let pos = 0;
  793. while (true) {
  794. const l = [];
  795. // Грузить не более 5 картинок за раз
  796. while (pos < cnt && l.length < 5) {
  797. const li = log.message("Загрузка изображения...");
  798. l.push(list[pos++].children[1].load((loaded, total) => li.text(`${Math.round(loaded / total * 100)}%`))
  799. .then(() => li.ok())
  800. .catch(err => {
  801. li.fail();
  802. if (err.name === "AbortError") throw err;
  803. })
  804. );
  805. }
  806. if (!l.length || stage !== 1) break;
  807. await Promise.all(l);
  808. }
  809. const ch = new FB2Chapter("Дополнительные материалы");
  810. ch.children = list;
  811. doc.chapters.push(ch);
  812. } else {
  813. log.warning("Изображения не найдены");
  814. }
  815. }
  816.  
  817. /**
  818. * Создает картинку-заглушку в фомате png
  819. *
  820. * @return FB2Image
  821. */
  822. function getDummyImage() {
  823. const WIDTH = 300;
  824. const HEIGHT = 150;
  825. let canvas = document.createElement("canvas");
  826. canvas.setAttribute("width", WIDTH);
  827. canvas.setAttribute("height", HEIGHT);
  828. if (!canvas.getContext) throw new Error("Ошибка работы с элементом canvas");
  829. let ctx = canvas.getContext("2d");
  830. // Фон
  831. ctx.fillStyle = "White";
  832. ctx.fillRect(0, 0, WIDTH, HEIGHT);
  833. // Обводка
  834. ctx.lineWidth = 4;
  835. ctx.strokeStyle = "Gray";
  836. ctx.strokeRect(0, 0, WIDTH, HEIGHT);
  837. // Тень
  838. ctx.shadowOffsetX = 2;
  839. ctx.shadowOffsetY = 2;
  840. ctx.shadowBlur = 2;
  841. ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
  842. // Крест
  843. let margin = 25;
  844. let size = 40;
  845. ctx.lineWidth = 10;
  846. ctx.strokeStyle = "Red";
  847. ctx.moveTo(WIDTH / 2 - size / 2, margin);
  848. ctx.lineTo(WIDTH / 2 + size / 2, margin + size);
  849. ctx.stroke();
  850. ctx.moveTo(WIDTH / 2 + size / 2, margin);
  851. ctx.lineTo(WIDTH / 2 - size / 2, margin + size);
  852. ctx.stroke();
  853. // Текст
  854. ctx.font = "42px Times New Roman";
  855. ctx.fillStyle = "Black";
  856. ctx.textAlign = "center";
  857. ctx.fillText("No image", WIDTH / 2, HEIGHT - 30, WIDTH);
  858. // Формирование итогового FB2 элемента
  859. const img = new FB2Image();
  860. img.id = "dummy.png";
  861. img.type = "image/png";
  862. let data_str = canvas.toDataURL(img.type);
  863. img.value = data_str.substr(data_str.indexOf(",") + 1);
  864. return img;
  865. }
  866.  
  867. /**
  868. * Замена всех незагруженных изображений другим изображением
  869. *
  870. * @param FB2DocumentEx doc Формируемый документ
  871. * @param FB2Image img Изображение для замены
  872. *
  873. * @return void
  874. */
  875. function replaceBadImages(doc, img) {
  876. const replaceChildren = function(fr, img) {
  877. for (let i = 0; i < fr.children.length; ++i) {
  878. const ch = fr.children[i];
  879. if (ch instanceof FB2Image) {
  880. if (!ch.value) fr.children[i] = img;
  881. } else {
  882. replaceChildren(ch, img);
  883. }
  884. }
  885. };
  886. if (doc.annotation) replaceChildren(doc.annotation, img);
  887. doc.chapters.forEach(ch => replaceChildren(ch, img));
  888. if (doc.materials) replaceChildren(doc.materials, img);
  889. }
  890.  
  891. /**
  892. * Формирует имя файла для книги
  893. *
  894. * @param FB2DocumentEx doc FB2 документ
  895. * @param Object extra Дополнительные данные
  896. *
  897. * @return string Имя файла с расширением
  898. */
  899. function genBookFileName(doc, extra) {
  900. function xtrim(s) {
  901. const r = /^[\s=\-_.,;!]*(.+?)[\s=\-_.,;!]*$/.exec(s);
  902. return r && r[1] || s;
  903. }
  904.  
  905. const fn_template = Settings.get("filename", true).trim();
  906. const ndata = new Map();
  907. // Автор [\a]
  908. const author = doc.bookAuthors[0];
  909. if (author) {
  910. const author_names = [ author.firstName, author.middleName, author.lastName ].reduce(function(res, nm) {
  911. if (nm) res.push(nm);
  912. return res;
  913. }, []);
  914. if (author_names.length) {
  915. ndata.set("a", author_names.join(" "));
  916. } else if (author.nickName) {
  917. ndata.set("a", author.nickName);
  918. }
  919. }
  920. // Серия [\s, \n, \N]
  921. const seq_names = [];
  922. if (doc.sequence && doc.sequence.name) {
  923. const seq_name = xtrim(doc.sequence.name);
  924. if (seq_name) {
  925. const seq_num = doc.sequence.number;
  926. if (seq_num) {
  927. ndata.set("n", seq_num);
  928. ndata.set("N", (seq_num.length < 2 ? "0" : "") + seq_num);
  929. seq_names.push(seq_name + " " + seq_num);
  930. }
  931. ndata.set("s", seq_name);
  932. seq_names.push(seq_name);
  933. }
  934. }
  935. // Название книги. Делается попытка вырезать название серии из названия книги [\t]
  936. // Название серии будет удалено из названия книги лишь в том случае, если оно присутвует в шаблоне.
  937. let book_name = xtrim(doc.bookTitle);
  938. if (ndata.has("s") && fn_template.includes("\\s")) {
  939. const book_lname = book_name.toLowerCase();
  940. const book_len = book_lname.length;
  941. for (let i = 0; i < seq_names.length; ++i) {
  942. const seq_lname = seq_names[i].toLowerCase();
  943. const seq_len = seq_lname.length;
  944. if (book_len - seq_len >= 5) {
  945. let str = null;
  946. if (book_lname.startsWith(seq_lname)) str = xtrim(book_name.substr(seq_len));
  947. else if (book_lname.endsWith(seq_lname)) str = xtrim(book_name.substr(-seq_len));
  948. if (str) {
  949. if (str.length >= 5) book_name = str;
  950. break;
  951. }
  952. }
  953. }
  954. }
  955. ndata.set("t", book_name);
  956. // Статус скачиваемой книжки [\b]
  957. let status = "";
  958. if (doc.totalChapters === doc.chapters.length - (doc.hasMaterials ? 1 : 0)) {
  959. switch (doc.status) {
  960. case "finished":
  961. status = "F";
  962. break;
  963. case "in-progress":
  964. status = "U";
  965. break;
  966. case "fragment":
  967. status = "P";
  968. break;
  969. }
  970. } else {
  971. status = "P";
  972. }
  973. ndata.set("b", status);
  974. // Выбранные главы [\c]
  975. // Если цикл завершен и выбраны все главы (статус "F"), то возвращается пустое значение.
  976. if (status != "F") {
  977. const cr = extra.chaptersRange;
  978. ndata.set("c", cr[0] === cr[1] ? `${cr[0]}` : `${cr[0]}-${cr[1]}`);
  979. }
  980. // Id книги [\i]
  981. ndata.set("i", doc.id);
  982. // Окончательное формирование имени файла плюс дополнительные чистки и проверки.
  983. function replacer(str) {
  984. let cnt = 0;
  985. const new_str = str.replace(/\\([asnNtbci])/g, (match, ti) => {
  986. const res = ndata.get(ti);
  987. if (res === undefined) return "";
  988. ++cnt;
  989. return res;
  990. });
  991. return { str: new_str, count: cnt };
  992. }
  993. function processParts(str, depth) {
  994. const parts = [];
  995. const pos = str.indexOf('<');
  996. if (pos !== 0) {
  997. parts.push(replacer(pos == -1 ? str : str.slice(0, pos)));
  998. }
  999. if (pos != -1) {
  1000. let i = pos + 1;
  1001. let n = 1;
  1002. for ( ; i < str.length; ++i) {
  1003. const c = str[i];
  1004. if (c == '<') {
  1005. ++n;
  1006. } else if (c == '>') {
  1007. --n;
  1008. if (!n) {
  1009. parts.push(processParts(str.slice(pos + 1, i), depth + 1));
  1010. break;
  1011. }
  1012. }
  1013. }
  1014. if (++i < str.length) parts.push(processParts(str.slice(i), depth));
  1015. }
  1016. const sa = [];
  1017. let cnt = 0
  1018. for (const it of parts) {
  1019. sa.push(it.str);
  1020. cnt += it.count;
  1021. }
  1022. return {
  1023. str: (!depth || cnt) ? sa.join("") : "",
  1024. count: cnt
  1025. };
  1026. }
  1027. const fname = processParts(fn_template, 0).str.replace(/[\0\/\\\"\*\?\<\>\|:]+/g, "");
  1028. return `${fname.substr(0, 250)}.fb2`;
  1029. }
  1030.  
  1031. /**
  1032. * Создает пункт меню настроек скрипта если не существует
  1033. *
  1034. * @return void
  1035. */
  1036. function ensureSettingsMenuItems() {
  1037. const menu = document.querySelector("aside nav ul.nav");
  1038. if (!menu || menu.querySelector("li.atex-settings")) return;
  1039. let item = document.createElement("li");
  1040. if (!menu.querySelector("li.Ox90-settings-menu")) {
  1041. item.classList.add("nav-heading", "Ox90-settings-menu");
  1042. menu.appendChild(item);
  1043. item.innerHTML = '<span><i class="icon-cogs icon-fw"></i> Внешние скрипты</span>';
  1044. item = document.createElement("li");
  1045. }
  1046. item.classList.add("atex-settings");
  1047. menu.appendChild(item);
  1048. item.innerHTML = '<a class="nav-link" href="/account/settings?script=atex">AutorTodayExtractor</a>';
  1049. }
  1050.  
  1051. /**
  1052. * Генерирует страницу настроек скрипта
  1053. *
  1054. * @return void
  1055. */
  1056. function handleSettingsPage() {
  1057. // Изменить активный пункт меню
  1058. const menu = document.querySelector("aside nav ul.nav");
  1059. if (menu) {
  1060. const active = menu.querySelector("li.active");
  1061. active && active.classList.remove("active");
  1062. menu.querySelector("li.atex-settings").classList.add("active");
  1063. }
  1064. // Найти секцию с контентом
  1065. const section = document.querySelector("#pjax-container section.content");
  1066. if (!section) return;
  1067. // Очистить секцию
  1068. while (section.firstChild) section.lastChild.remove();
  1069. // Создать свою панель и добавить в секцию
  1070. const panel = document.createElement("div");
  1071. panel.classList.add("panel", "panel-default");
  1072. section.appendChild(panel);
  1073. panel.innerHTML = '<div class="panel-heading">Параметры скрипта AuthorTodayExtractor</div>';
  1074. const body = document.createElement("div");
  1075. body.classList.add("panel-body");
  1076. panel.appendChild(body);
  1077. const form = document.createElement("form");
  1078. form.method = "post";
  1079. form.style.display = "flex";
  1080. form.style.rowGap = "1em";
  1081. form.style.flexDirection = "column";
  1082. body.appendChild(form);
  1083. let fndiv = document.createElement("div");
  1084. fndiv.innerHTML = '<label>Шаблон имени файла (без расширения)</label>';
  1085. form.appendChild(fndiv);
  1086. const filename = document.createElement("input");
  1087. filename.type = "text";
  1088. filename.style.maxWidth = "25em";
  1089. filename.classList.add("form-control");
  1090. filename.value = Settings.get("filename");
  1091. fndiv.appendChild(filename);
  1092. const descr = document.createElement("ul");
  1093. descr.style.color = "gray";
  1094. descr.style.fontSize = "90%";
  1095. descr.style.margin = "0";
  1096. descr.style.paddingLeft = "2em";
  1097. descr.innerHTML =
  1098. "<li>\\a - Автор книги;</li>" +
  1099. "<li>\\s - Серия книги;</li>" +
  1100. "<li>\\n - Порядковый номер в серии;</li>" +
  1101. "<li>\\N - Порядковый номер в серии с ведущим нулем;</li>" +
  1102. "<li>\\t - Название книги;</li>" +
  1103. "<li>\\i - Идентификатор книги (workId на сайте);</li>" +
  1104. "<li>\\b - Статус книги (F - завершена, U - не завершена, P - выгружена частично);</li>" +
  1105. "<li>\\c - Диапазон глав в случае, если книга не завершена или выбраны не все главы;</li>" +
  1106. "<li>&lt;&hellip;&gt; - Если внутри такого блока будут отсутвовать данные для шаблона, то весь блок будет удален;</li>";
  1107. fndiv.appendChild(descr);
  1108. let addnotes = HTML.createCheckbox("Добавить примечания автора в аннотацию", Settings.get("addnotes"));
  1109. let addcover = HTML.createCheckbox("Грузить обложку книги", Settings.get("addcover"));
  1110. let addimages = HTML.createCheckbox("Грузить картинки внутри глав", Settings.get("addimages"));
  1111. let materials = HTML.createCheckbox("Грузить дополнительные материалы", Settings.get("materials"));
  1112. let sethint = HTML.createCheckbox("Отображать подсказку о настройках в логе выгрузки", Settings.get("sethint"));
  1113. form.append(addnotes, addcover, addimages, materials, sethint);
  1114. addnotes = addnotes.querySelector("input");
  1115. addcover = addcover.querySelector("input");
  1116. addimages = addimages.querySelector("input");
  1117. materials = materials.querySelector("input");
  1118. sethint = sethint.querySelector("input");
  1119.  
  1120. const buttons = document.createElement("div");
  1121. buttons.innerHTML = '<button type="submit" class="btn btn-primary">Сохранить</button>';
  1122. form.appendChild(buttons);
  1123.  
  1124. form.addEventListener("submit", event => {
  1125. event.preventDefault();
  1126. try {
  1127. Settings.set("filename", filename.value);
  1128. Settings.set("addnotes", addnotes.checked);
  1129. Settings.set("addcover", addcover.checked);
  1130. Settings.set("addimages", addimages.checked);
  1131. Settings.set("materials", materials.checked);
  1132. Settings.set("sethint", sethint.checked);
  1133. Settings.save();
  1134. Notification.display("Настройки сохранены", "success");
  1135. } catch (err) {
  1136. console.error(err);
  1137. Notification.display("Ошибка сохранения настроек");
  1138. }
  1139. });
  1140. }
  1141.  
  1142. //---------- Классы ----------
  1143.  
  1144. /**
  1145. * Расширение класса библиотеки в целях обеспечения загрузки изображений,
  1146. * информирования о наличии неизвестных HTML элементов и отображения прогресса в логе.
  1147. */
  1148. class FB2DocumentEx extends FB2Document {
  1149. constructor() {
  1150. super();
  1151. this.unknowns = 0;
  1152. }
  1153.  
  1154. parse(parser_id, log, params, ...args) {
  1155. const bin_start = this.binaries.length;
  1156. super.parse(parser_id, ...args).forEach(el => {
  1157. log.warning(`Найден неизвестный элемент: ${el.nodeName}`);
  1158. ++this.unknowns;
  1159. });
  1160. const u_bin = this.binaries.slice(bin_start);
  1161. return (async () => {
  1162. const it = u_bin[Symbol.iterator]();
  1163. const get_list = function() {
  1164. const list = [];
  1165. for (let i = 0; i < 5; ++i) {
  1166. const r = it.next();
  1167. if (r.done) break;
  1168. list.push(r.value);
  1169. }
  1170. return list;
  1171. };
  1172. while (true) {
  1173. const list = get_list();
  1174. if (!list.length || stage !== 1) break;
  1175. await Promise.all(list.map(bin => {
  1176. const li = log.message("Загрузка изображения...");
  1177. if (params.noImages) return Promise.resolve().then(() => li.skipped());
  1178. return bin.load((loaded, total) => li.text("" + Math.round(loaded / total * 100) + "%"))
  1179. .then(() => li.ok())
  1180. .catch((err) => {
  1181. li.fail();
  1182. if (err.name === "AbortError") throw err;
  1183. });
  1184. }));
  1185. }
  1186. })();
  1187. }
  1188. }
  1189.  
  1190. /**
  1191. * Расширение класса библиотеки в целях передачи элементов с изображениями
  1192. * и неизвестных элементов в документ, а также для возможности раздельной
  1193. * обработки аннотации и примечаний автора.
  1194. */
  1195. class AnnotationParser extends FB2AnnotationParser {
  1196. run(fb2doc, element) {
  1197. this._binaries = [];
  1198. this._unknown_nodes = [];
  1199. this.parse(element);
  1200. if (this._annotation && this._annotation.children.length) {
  1201. this._annotation.normalize();
  1202. if (!fb2doc.annotation) {
  1203. fb2doc.annotation = this._annotation;
  1204. } else {
  1205. this._annotation.children.forEach(ch => fb2doc.annotation.children.push(ch));
  1206. }
  1207. this._binaries.forEach(bin => fb2doc.binaries.push(bin));
  1208. }
  1209. const un = this._unknown_nodes;
  1210. this._binaries = null;
  1211. this._annotation = null;
  1212. this._unknown_nodes = null;
  1213. return un;
  1214. }
  1215.  
  1216. processElement(fb2el, depth) {
  1217. if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);
  1218. return super.processElement(fb2el, depth);
  1219. }
  1220. }
  1221.  
  1222. /**
  1223. * Расширение класса библиотеки в целях передачи списка неизвестных элементов в документ
  1224. */
  1225. class ChapterParser extends FB2ChapterParser {
  1226. run(fb2doc, element, title) {
  1227. this._unknown_nodes = [];
  1228. super.run(fb2doc, element, title);
  1229. const un = this._unknown_nodes;
  1230. this._unknown_nodes = null;
  1231. return un;
  1232. }
  1233.  
  1234. startNode(node, depth) {
  1235. if (node.nodeName === "DIV") {
  1236. const nnode = document.createElement("p");
  1237. node.childNodes.forEach(ch => nnode.appendChild(ch.cloneNode(true)));
  1238. node = nnode;
  1239. }
  1240. return super.startNode(node, depth);
  1241. }
  1242.  
  1243. processElement(fb2el, depth) {
  1244. if (fb2el instanceof FB2UnknownNode) this._unknown_nodes.push(fb2el.value);
  1245. return super.processElement(fb2el, depth);
  1246. }
  1247. }
  1248.  
  1249. /**
  1250. * Класс управления модальным диалоговым окном
  1251. */
  1252. class ModalDialog {
  1253. constructor(params) {
  1254. this._modal = null;
  1255. this._overlay = null;
  1256. this._title = params.title || "";
  1257. this._onclose = params.onclose;
  1258. }
  1259.  
  1260. show() {
  1261. this._ensureForm();
  1262. this._ensureContent();
  1263. document.body.appendChild(this._overlay);
  1264. document.body.classList.add("modal-open");
  1265. this._modal.focus();
  1266. }
  1267.  
  1268. hide() {
  1269. this._overlay && this._overlay.remove();
  1270. this._overlay = null;
  1271. this._modal = null;
  1272. document.body.classList.remove("modal-open");
  1273. if (this._onclose) {
  1274. this._onclose();
  1275. this._onclose = null;
  1276. }
  1277. }
  1278.  
  1279. _ensureForm() {
  1280. if (!this._overlay) {
  1281. this._overlay = document.createElement("div");
  1282. this._overlay.classList.add("ate-dlg-overlay");
  1283. this._modal = this._overlay.appendChild(document.createElement("div"));
  1284. this._modal.classList.add("ate-dialog");
  1285. this._modal.tabIndex = -1;
  1286. this._modal.setAttribute("role", "dialog");
  1287. const header = this._modal.appendChild(document.createElement("div"));
  1288. header.classList.add("ate-title");
  1289. header.appendChild(document.createElement("div")).textContent = this._title;
  1290. const cb = header.appendChild(document.createElement("button"));
  1291. cb.type = "button";
  1292. cb.classList.add("ate-close-btn");
  1293. cb.textContent = "×";
  1294. this._modal.appendChild(document.createElement("form"));
  1295.  
  1296. this._overlay.addEventListener("click", event => {
  1297. if (event.target === this._overlay || event.target.closest(".ate-close-btn")) this.hide();
  1298. });
  1299. this._overlay.addEventListener("keydown", event => {
  1300. if (event.code == "Escape" && !event.shiftKey && !event.ctrlKey && !event.altKey) {
  1301. event.preventDefault();
  1302. this.hide();
  1303. }
  1304. });
  1305. }
  1306. }
  1307.  
  1308. _ensureContent() {
  1309. }
  1310. }
  1311.  
  1312. class DownloadDialog extends ModalDialog {
  1313. constructor(params) {
  1314. super(params);
  1315. this.log = null;
  1316. this.button = null;
  1317. this._ann = params.annotation;
  1318. this._cvr = params.cover;
  1319. this._mat = params.materials;
  1320. this._set = params.settings;
  1321. this._chs = params.chapters;
  1322. this._sub = params.onsubmit;
  1323. this._pg1 = null;
  1324. this._pg2 = null;
  1325. }
  1326.  
  1327. hide() {
  1328. super.hide();
  1329. this.log = null;
  1330. this.button = null;
  1331. }
  1332.  
  1333. nextPage() {
  1334. this._pg1.style.display = "none";
  1335. this._pg2.style.display = "";
  1336. }
  1337.  
  1338. _ensureContent() {
  1339. const form = this._modal.querySelector("form");
  1340. form.replaceChildren();
  1341. this._pg1 = form.appendChild(document.createElement("div"));
  1342. this._pg2 = form.appendChild(document.createElement("div"));
  1343. this._pg1.classList.add("ate-page");
  1344. this._pg2.classList.add("ate-page");
  1345. this._pg2.style.display = "none";
  1346.  
  1347. const fst = this._pg1.appendChild(document.createElement("fieldset"));
  1348. const leg = fst.appendChild(document.createElement("legend"));
  1349. leg.textContent = "Главы для выгрузки";
  1350.  
  1351. const chs = fst.appendChild(document.createElement("div"));
  1352. chs.classList.add("ate-chapter-list");
  1353.  
  1354. const ntp = chs.appendChild(document.createElement("div"));
  1355. ntp.classList.add("ate-note");
  1356. ntp.textContent = "Выберите главы для выгрузки. Обратите внимание: выгружены могут быть только доступные вам главы.";
  1357.  
  1358. const tbd = fst.appendChild(document.createElement("div"));
  1359. tbd.classList.add("ate-toolbar");
  1360.  
  1361. const its = tbd.appendChild(document.createElement("span"));
  1362. const selected = document.createElement("strong");
  1363. selected.textContent = 0;
  1364. const total = document.createElement("strong");
  1365. its.append("Выбрано глав: ", selected, " из ", total);
  1366.  
  1367. const tb1 = tbd.appendChild(document.createElement("button"));
  1368. tb1.type = "button";
  1369. tb1.title = "Выделить все/ничего";
  1370. tb1.classList.add("ate-group-select");
  1371. const tb1i = document.createElement("i");
  1372. tb1i.classList.add("icon-check");
  1373. tb1.append(tb1i, " ?");
  1374.  
  1375. const nte = HTML.createCheckbox("Добавить примечания автора в аннотацию", this._ann && this._set.addnotes);
  1376. if (!this._ann) nte.querySelector("input").disabled = true;
  1377. this._pg1.appendChild(nte);
  1378.  
  1379. const cve = HTML.createCheckbox("Грузить обложку книги", this._cvr && this._set.addcover);
  1380. if (!this._cvr) cve.querySelector("input").disabled = true;
  1381. this._pg1.appendChild(cve);
  1382.  
  1383. const img = HTML.createCheckbox("Грузить картинки внутри глав", this._set.addimages);
  1384. this._pg1.appendChild(img);
  1385.  
  1386. const nmt = HTML.createCheckbox("Грузить дополнительные материалы", this._mat && this._set.materials);
  1387. if (!this._mat) nmt.querySelector("input").disabled = true;
  1388. this._pg1.appendChild(nmt);
  1389.  
  1390. const log = this._pg2.appendChild(document.createElement("div"));
  1391.  
  1392. const sbd = form.appendChild(document.createElement("div"));
  1393. sbd.classList.add("ate-buttons");
  1394. const sbt = sbd.appendChild(document.createElement("button"));
  1395. sbt.type = "submit";
  1396. sbt.classList.add("button", "btn", "btn-success");
  1397. sbt.textContent = "Продолжить";
  1398. const cbt = sbd.appendChild(document.createElement("button"));
  1399. cbt.type = "button";
  1400. cbt.classList.add("button", "btn", "btn-default");
  1401. cbt.textContent = "Закрыть";
  1402.  
  1403. let ch_cnt = 0;
  1404. this._chs.forEach(ch => {
  1405. const el = HTML.createChapterCheckbox(ch);
  1406. ch.element = el.querySelector("input");
  1407. chs.append(el);
  1408. ++ch_cnt;
  1409. });
  1410. total.textContent = ch_cnt;
  1411.  
  1412. chs.addEventListener("change", event => {
  1413. const cnt = this._chs.reduce((cnt, ch) => {
  1414. if (!ch.locked && ch.element.checked) ++cnt;
  1415. return cnt;
  1416. }, 0);
  1417. selected.textContent = cnt;
  1418. sbt.disabled = !cnt;
  1419. });
  1420.  
  1421. tb1.addEventListener("click", event => {
  1422. const chf = this._chs.some(ch => !ch.locked && !ch.element.checked);
  1423. this._chs.forEach(ch => {
  1424. ch.element.checked = (chf && !ch.locked);
  1425. });
  1426. chs.dispatchEvent(new Event("change"));
  1427. });
  1428.  
  1429. cbt.addEventListener("click", event => this.hide());
  1430.  
  1431. form.addEventListener("submit", event => {
  1432. event.preventDefault();
  1433. if (this._sub) {
  1434. const res = {};
  1435. res.authorNotes = nte.querySelector("input").checked;
  1436. res.skipCover = !cve.querySelector("input").checked;
  1437. res.addimages = img.querySelector("input").checked;
  1438. res.materials = nmt.querySelector("input").checked;
  1439. let ch_min = 0;
  1440. let ch_max = 0;
  1441. res.chapters = this._chs.reduce((res, ch, idx) => {
  1442. if (!ch.locked && ch.element.checked) {
  1443. res.push({ title: ch.title, workId: ch.workId, chapterId: ch.chapterId });
  1444. ch_max = idx + 1;
  1445. if (!ch_min) ch_min = ch_max;
  1446. }
  1447. return res;
  1448. }, []);
  1449. res.chaptersRange = [ ch_min, ch_max ];
  1450. this._sub(res);
  1451. }
  1452. });
  1453.  
  1454. chs.dispatchEvent(new Event("change"));
  1455. this.log = log;
  1456. this.button = sbt;
  1457. }
  1458. }
  1459.  
  1460. /**
  1461. * Класс общего назначения для создания однотипных HTML элементов
  1462. */
  1463. class HTML {
  1464.  
  1465. /**
  1466. * Создает единичный элемент типа checkbox в стиле сайта
  1467. *
  1468. * @param title string Подпись для checkbox
  1469. * @param checked bool Начальное состояние checkbox
  1470. *
  1471. * @return Element HTML-элемент для последующего добавления на форму
  1472. */
  1473. static createCheckbox(title, checked) {
  1474. const root = document.createElement("div");
  1475. root.classList.add("ate-checkbox");
  1476. const label = root.appendChild(document.createElement("label"));
  1477. const input = document.createElement("input");
  1478. input.type = "checkbox";
  1479. input.checked = checked;
  1480. const span = document.createElement("span");
  1481. span.classList.add("icon-check-bold");
  1482. label.append(input, span, title);
  1483. return root;
  1484. }
  1485.  
  1486. /**
  1487. * Создает checkbox для диалога выбора глав
  1488. *
  1489. * @param chapter object Данные главы
  1490. *
  1491. * @return Element HTML-элемент для последующего добавления на форму
  1492. */
  1493. static createChapterCheckbox(chapter) {
  1494. const root = this.createCheckbox(chapter.title || "Без названия", !chapter.locked);
  1495. if (chapter.locked) {
  1496. root.querySelector("input").disabled = true;
  1497. const lock = document.createElement("i");
  1498. lock.classList.add("icon-lock", "text-muted", "ml-sm");
  1499. root.children[0].appendChild(lock);
  1500. }
  1501. if (!chapter.title) root.style.fontStyle = "italic";
  1502. return root;
  1503. }
  1504. }
  1505.  
  1506. /**
  1507. * Класс для отображения сообщений в виде лога
  1508. */
  1509. class LogElement {
  1510.  
  1511. /**
  1512. * Конструктор
  1513. *
  1514. * @param Element element HTML-элемент, в который будут добавляться записи
  1515. */
  1516. constructor(element) {
  1517. element.classList.add("ate-log");
  1518. this._element = element;
  1519. }
  1520.  
  1521. /**
  1522. * Добавляет сообщение с указанным текстом и цветом
  1523. *
  1524. * @param mixed msg Сообщение для отображения. Может быть HTML-элементом
  1525. * @param string color Цвет в формате CSS (не обязательный параметр)
  1526. *
  1527. * @return LogItemElement Элемент лога, в котором может быть отображен результат или другой текст
  1528. */
  1529. message(msg, color) {
  1530. const item = document.createElement("div");
  1531. if (msg instanceof HTMLElement) {
  1532. item.appendChild(msg);
  1533. } else {
  1534. item.textContent = msg;
  1535. }
  1536. if (color) item.style.color = color;
  1537. this._element.appendChild(item);
  1538. this._element.scrollTop = this._element.scrollHeight;
  1539. return new LogItemElement(item);
  1540. }
  1541.  
  1542. /**
  1543. * Сообщение с темно-красным цветом
  1544. *
  1545. * @param mixed msg См. метод message
  1546. *
  1547. * @return LogItemElement См. метод message
  1548. */
  1549. warning(msg) {
  1550. this.message(msg, "#a00");
  1551. }
  1552. }
  1553.  
  1554. /**
  1555. * Класс реализации элемента записи в логе,
  1556. * используется классом LogElement.
  1557. */
  1558. class LogItemElement {
  1559. constructor(element) {
  1560. this._element = element;
  1561. this._span = null;
  1562. }
  1563.  
  1564. /**
  1565. * Отображает сообщение "ok" в конце записи лога зеленым цветом
  1566. *
  1567. * @return void
  1568. */
  1569. ok() {
  1570. this._setSpan("ok", "green");
  1571. }
  1572.  
  1573. /**
  1574. * Аналогичен методу ok
  1575. */
  1576. fail() {
  1577. this._setSpan("ошибка!", "red");
  1578. }
  1579.  
  1580. /**
  1581. * Аналогичен методу ok
  1582. */
  1583. skipped() {
  1584. this._setSpan("пропущено", "blue");
  1585. }
  1586.  
  1587. /**
  1588. * Отображает указанный текстстандартным цветом сайта
  1589. *
  1590. * @param string s Текст для отображения
  1591. *
  1592. */
  1593. text(s) {
  1594. this._setSpan(s, "");
  1595. }
  1596.  
  1597. _setSpan(text, color) {
  1598. if (!this._span) {
  1599. this._span = document.createElement("span");
  1600. this._element.appendChild(this._span);
  1601. }
  1602. this._span.style.color = color;
  1603. this._span.textContent = " " + text;
  1604. }
  1605. }
  1606.  
  1607.  
  1608. /**
  1609. * Класс реализует доступ к хранилищу с настройками скрипта
  1610. * Здесь используется localStorage
  1611. */
  1612. class Settings {
  1613.  
  1614. /**
  1615. * Возвращает значение опции по ее имени
  1616. *
  1617. * @param name string Имя опции
  1618. * @param reset bool Сбрасывает кэш перед получением опции
  1619. *
  1620. * @return mixed
  1621. */
  1622. static get(name, reset) {
  1623. if (reset) Settings._values = null;
  1624. this._ensureValues();
  1625. let val = Settings._values[name];
  1626. switch (name) {
  1627. case "filename":
  1628. if (typeof(val) !== "string" || val.trim() === "") val = "\\a.< \\s \\N.> \\t [AT-\\i-\\b]";
  1629. break;
  1630. case "sethint":
  1631. case "addcover":
  1632. case "addnotes":
  1633. case "addimages":
  1634. case "materials":
  1635. if (typeof(val) !== "boolean") val = true;
  1636. break;
  1637. }
  1638. return val;
  1639. }
  1640.  
  1641. /**
  1642. * Обновляет значение опции
  1643. *
  1644. * @param name string Имя опции
  1645. * @param value mixed Значение опции
  1646. *
  1647. * @return void
  1648. */
  1649. static set(name, value) {
  1650. this._ensureValues();
  1651. this._values[name] = value;
  1652. }
  1653.  
  1654. /**
  1655. * Сохраняет (перезаписывает) настройки скрипта в хранилище
  1656. *
  1657. * @return void
  1658. */
  1659. static save() {
  1660. localStorage.setItem("atex.settings", JSON.stringify(this._values || {}));
  1661. }
  1662.  
  1663. /**
  1664. * Читает настройки из локального хранилища, если они не были считаны ранее
  1665. */
  1666. static _ensureValues() {
  1667. if (this._values) return;
  1668. try {
  1669. this._values = JSON.parse(localStorage.getItem("atex.settings"));
  1670. } catch (err) {
  1671. this._values = null;
  1672. }
  1673. if (!this._values || typeof(this._values) !== "object") Settings._values = {};
  1674. }
  1675. }
  1676.  
  1677. /**
  1678. * Класс для работы с всплывающими уведомлениями. Для аутентичности используются стили сайта.
  1679. */
  1680. class Notification {
  1681.  
  1682. /**
  1683. * Конструктор. Вызвается из static метода display
  1684. *
  1685. * @param data Object Объект с полями text (string) и type (string)
  1686. *
  1687. * @return void
  1688. */
  1689. constructor(data) {
  1690. this._data = data;
  1691. this._element = null;
  1692. }
  1693.  
  1694. /**
  1695. * Возвращает HTML-элемент блока с текстом уведомления
  1696. *
  1697. * @return Element HTML-элемент для добавление в контейнер уведомлений
  1698. */
  1699. element() {
  1700. if (!this._element) {
  1701. this._element = document.createElement("div");
  1702. this._element.classList.add("toast", "toast-" + (this._data.type || "success"));
  1703. const msg = document.createElement("div");
  1704. msg.classList.add("toast-message");
  1705. msg.textContent = "ATEX: " + this._data.text;
  1706. this._element.appendChild(msg);
  1707. this._element.addEventListener("click", () => this._element.remove());
  1708. setTimeout(() => {
  1709. this._element.style.transition = "opacity 2s ease-in-out";
  1710. this._element.style.opacity = "0";
  1711. setTimeout(() => {
  1712. const ctn = this._element.parentElement;
  1713. this._element.remove();
  1714. if (!ctn.childElementCount) ctn.remove();
  1715. }, 2000); // Продолжительность плавного растворения уведомления - 2 секунды
  1716. }, 10000); // Длительность отображения уведомления - 10 секунд
  1717. }
  1718. return this._element;
  1719. }
  1720.  
  1721. /**
  1722. * Метод для отображения уведомлений на сайте. К тексту сообщения будет автоматически добавлена метка скрипта
  1723. *
  1724. * @param text string Текст уведомления
  1725. * @param type string Тип уведомления. Допустимые типы: `success`, `warning`, `error`
  1726. *
  1727. * @return void
  1728. */
  1729. static display(text, type) {
  1730. let ctn = document.getElementById("toast-container");
  1731. if (!ctn) {
  1732. ctn = document.createElement("div");
  1733. ctn.id = "toast-container";
  1734. ctn.classList.add("toast-top-right");
  1735. ctn.setAttribute("role", "alert");
  1736. ctn.setAttribute("aria-live", "polite");
  1737. document.body.appendChild(ctn);
  1738. }
  1739. ctn.appendChild((new Notification({ text: text, type: type })).element());
  1740. }
  1741. }
  1742.  
  1743. /**
  1744. * Класс загрузчика данных с сайта.
  1745. * Реализован через GM.xmlHttpRequest чтобы обойти ограничения CORS
  1746. * Если протокол, домен и порт совпадают, то используется стандартная загрузка.
  1747. */
  1748. class Loader extends FB2Loader {
  1749.  
  1750. /**
  1751. * Старт загрузки ресурса с указанного URL
  1752. *
  1753. * @param url Object Экземпляр класса URL (обязательный)
  1754. * @param params Object Объект с параметрами запроса (необязательный)
  1755. *
  1756. * @return mixed
  1757. */
  1758. static async addJob(url, params) {
  1759. params ||= {};
  1760. if (url.origin === document.location.origin) {
  1761. params.extended = true;
  1762. return super.addJob(url, params);
  1763. }
  1764.  
  1765. params.url = url;
  1766. params.method ||= "GET";
  1767. params.responseType = params.responseType === "binary" ? "blob" : "text";
  1768. if (!this.ctl_list) this.ctl_list = new Set();
  1769.  
  1770. return new Promise((resolve, reject) => {
  1771. let req = null;
  1772. params.onload = r => {
  1773. if (r.status === 200) {
  1774. const headers = new Headers();
  1775. r.responseHeaders.split("\n").forEach(hs => {
  1776. const h = /^([A-Za-z][A-Za-z0-9-]*):\s*(.+)$/.exec(hs);
  1777. if (h) headers.append(h[1], h[2].trim());
  1778. });
  1779. resolve({ headers: headers, response: r.response });
  1780. } else {
  1781. reject(new Error(`Сервер вернул ошибку (${r.status})`));
  1782. }
  1783. };
  1784. params.onerror = err => reject(err);
  1785. params.ontimeout = err => reject(err);
  1786. params.onloadend = () => {
  1787. if (req) this.ctl_list.delete(req);
  1788. };
  1789. if (params.onprogress) {
  1790. const progress = params.onprogress;
  1791. params.onprogress = pe => {
  1792. if (pe.lengthComputable) {
  1793. progress(pe.loaded, pe.total);
  1794. }
  1795. };
  1796. }
  1797. try {
  1798. req = GM.xmlHttpRequest(params);
  1799. if (req) this.ctl_list.add(req);
  1800. } catch (err) {
  1801. reject(err);
  1802. }
  1803. });
  1804. }
  1805.  
  1806. static abortAll() {
  1807. super.abortAll();
  1808. if (this.ctl_list) {
  1809. this.ctl_list.forEach(ctl => ctl.abort());
  1810. this.ctl_list.clear();
  1811. }
  1812. }
  1813. }
  1814.  
  1815. /**
  1816. * Переопределение загрузчика для возможности использования своего лоадера
  1817. * а также для того, чтобы избегать загрузки картинок в формате webp.
  1818. */
  1819. FB2Image.prototype._load = async function(url, params) {
  1820. // Попытка избавиться от webp через подмену параметров запроса
  1821. const u = new URL(url);
  1822. if (u.pathname.endsWith(".webp")) {
  1823. // Изначально была загружена картинка webp. Попытаться принудить сайт отдать картинку другого формата.
  1824. u.searchParams.set("format", "jpeg");
  1825. } else if (u.searchParams.get("format") === "webp") {
  1826. // Изначально картинка не webp, но параметр присутсвует. Вырезать.
  1827. // Возможно позже придется указывать его явно, когда сайт сделает webp форматом по умолчанию.
  1828. u.searchParams.delete("format");
  1829. }
  1830. // Еще одна попытка избавиться от webp через подмену заголовков
  1831. params ||= {};
  1832. params.headers ||= {};
  1833. if (!params.headers.Accept) params.headers.Accept = "image/jpeg,image/png,*/*;q=0.8";
  1834. // Использовать свой лоадер
  1835. return (await Loader.addJob(u, params)).response;
  1836. };
  1837.  
  1838. //-------------------------
  1839.  
  1840. function addStyle(css) {
  1841. const style = document.getElementById("ate_styles") || (function() {
  1842. const style = document.createElement('style');
  1843. style.type = 'text/css';
  1844. style.id = "ate_styles";
  1845. document.head.appendChild(style);
  1846. return style;
  1847. })();
  1848. const sheet = style.sheet;
  1849. sheet.insertRule(css, (sheet.rules || sheet.cssRules || []).length);
  1850. }
  1851.  
  1852. function addStyles() {
  1853. [
  1854. ".ate-dlg-overlay, .ate-title { display:flex; align-items:center; justify-content:center; }",
  1855. ".ate-dialog, .ate-dialog form, .ate-page, .ate-dialog fieldset, .ate-chapter-list { display:flex; flex-direction:column; }",
  1856. ".ate-page, .ate-dialog form, .ate-dialog fieldset { flex:1; overflow:hidden; }",
  1857. ".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; }",
  1858. ".ate-dialog { display:flex; flex-direction:column; position:fixed; top:0; left:0; bottom:0; right:0; background-color:#fff; overflow-y:auto; }",
  1859. ".ate-title { flex:0 0 auto; padding:10px; color:#66757f; background-color:#edf1f2; border-bottom:1px solid #e5e5e5; }",
  1860. ".ate-title>div:first-child { margin:auto; }",
  1861. ".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; }",
  1862. ".ate-close-btn:hover { opacity:.9 }",
  1863. ".ate-dialog form { padding:10px 15px 15px; white-space:normal; gap:10px; min-height:30em; }",
  1864. ".ate-page { gap:10px; }",
  1865. ".ate-dialog fieldset { border:1px solid #bbb; border-radius:6px; padding:10px; margin:0; gap:10px; }",
  1866. ".ate-dialog legend { display:inline; width:unset; font-size:100%; margin:0; padding:0 5px; border:none; }",
  1867. ".ate-chapter-list { flex:1; gap:10px; overflow-y:auto; }",
  1868. ".ate-toolbar { display:flex; align-items:center; padding-top:10px; border-top:1px solid #bbb; }",
  1869. ".ate-group-select { margin-left:auto; }",
  1870. ".ate-log { flex:1; padding:6px; border:1px solid #bbb; border-radius:6px; overflow:auto; }",
  1871. ".ate-buttons { display:flex; flex-direction:column; gap:10px; }",
  1872. ".ate-buttons button { min-width:8em; }",
  1873. ".ate-checkbox label { cursor:pointer; margin:0; }",
  1874. ".ate-checkbox input { position:static; visibility:hidden; width:0; float:right; }", // position:absolute провоцирует прокрутку overlay-я в мобильной версии сайта
  1875. ".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; }",
  1876. ".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; }",
  1877. ".ate-checkbox:hover span { border-color:#5d9ced; }",
  1878. ".ate-checkbox input:checked + span { border-color:#5d9cec; background-color:#5d9ced; }",
  1879. ".ate-checkbox input:disabled + span { border-color:#ddd; background-color:#ddd; }",
  1880. ".ate-checkbox input:checked + span:before { color:#fff; opacity:1; transition:color .3s ease-out; }",
  1881. //".ate-chapter-list .ate-note { margin-bottom: 5px; }",
  1882. //".ate-chapter-list .ate-checkbox label { padding:5px; width:99%; }",
  1883. //".ate-chapter-list .ate-checkbox label:hover { color:#34749e; background-color:#f5f7fa; }",
  1884. "@media (min-width:520px) and (min-height:600px) {" +
  1885. ".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); }" +
  1886. ".ate-title { border-top-left-radius:6px; border-top-right-radius:6px; }" +
  1887. ".ate-buttons { flex-flow:row wrap; justify-content:center; }" +
  1888. ".ate-buttons .btn-default { display:none; }" +
  1889. "}"
  1890. ].forEach(s => addStyle(s));
  1891. }
  1892.  
  1893. // Запускает скрипт после загрузки страницы сайта
  1894. if (document.readyState === "loading") window.addEventListener("DOMContentLoaded", init);
  1895. else init();
  1896.  
  1897. })();