LZT Stats

Detailed statistics of your activity

  1. // ==UserScript==
  2. // @name LZT Stats
  3. // @namespace lzt-stats
  4. // @version 1.4.1
  5. // @description Detailed statistics of your activity
  6. // @author Toil
  7. // @license MIT
  8. // @match https://zelenka.guru/*
  9. // @match https://lolz.live/*
  10. // @match https://lolz.guru/*
  11. // @match https://lzt.market/*
  12. // @match https://lolz.market/*
  13. // @match https://zelenka.market/*
  14. // @icon https://www.google.com/s2/favicons?sz=64&domain=zelenka.guru
  15. // @require https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.0/chart.umd.min.js
  16. // @supportURL https://zelenka.guru/toil/
  17. // @grant GM_setValue
  18. // @grant GM_getValue
  19. // @grant GM_deleteValue
  20. // @grant GM_listValues
  21. // @grant GM_addStyle
  22. // ==/UserScript==
  23.  
  24. (function () {
  25. "use strict";
  26.  
  27. // * STYLES
  28. GM_addStyle(`
  29. .LZTStatsTabs {
  30. width: 100%;
  31. box-sizing: border-box;
  32. padding: 0 10px;
  33. border: none !important;
  34. margin: 15px auto !important;
  35. display: flex;
  36. align-items: center;
  37. justify-content: center;
  38. }
  39.  
  40. @media screen and (max-width: 699px) {
  41. .LZTStatsTabs {
  42. flex-direction: column;
  43. }
  44. }
  45.  
  46. .LZTStatsTab {
  47. position: relative;
  48. padding: 10px;
  49. margin: 0 4px;
  50. float: left;
  51. font-weight: 600;
  52. list-style: none !important;
  53. font-size: 14px;
  54. }
  55.  
  56. .LZTStatsTab:hover {
  57. cursor: pointer;
  58. }
  59.  
  60. .LZTStatsTab.active {
  61. box-shadow: inset 0px -2px 0px 0px #0daf77;
  62. transform: translateY(-1px);
  63. transition: .2s;
  64. }
  65.  
  66. .LZTStatsTab:not(.active):hover {
  67. box-shadow: inset 0px -2px 0px 0px rgb(54, 54, 54);
  68. }
  69.  
  70. .LZTStatsInfo {
  71. display: flex;
  72. flex-wrap: wrap;
  73. gap: 12px;
  74. }
  75.  
  76. @media screen and (max-width: 699px) {
  77. .LZTStatsInfo {
  78. flex-direction: column;
  79. align-content: center;
  80. }
  81. }
  82.  
  83. .LZTStatsInfo .LZTStatsItem {
  84. height: 63px;
  85. background: #2D2D2D;
  86. border-radius: 8px;
  87. padding: 12px;
  88. box-sizing: border-box;
  89. display: flex;
  90. align-items: center;
  91. white-space: normal;
  92. }
  93.  
  94. .LZTStatsIcon {
  95. margin-right: 6px;
  96. }
  97.  
  98. @media screen and (min-width: 700px) {
  99. .LZTStatsInfo .LZTStatsItem {
  100. flex: 1 1 calc((100% / 3) - 24px);
  101. max-width: 195px;
  102. }
  103. }
  104.  
  105. @media screen and (max-width: 699px) {
  106. .LZTStatsInfo .LZTStatsItem {
  107. width: 90%;
  108. }
  109. }
  110.  
  111. .LZTStatsInfo .LZTStatsItem i {
  112. width: 24px;
  113. height: 24px;
  114. font-size: 24px;
  115. }
  116.  
  117. .LZTStatsInfo .LZTStatsItem p {
  118. font-weight: 400;
  119. font-size: 13px;
  120. line-height: 16px;
  121. color: rgba(214, 214, 214, 0.80);
  122. margin-bottom: 6px;
  123. max-width: 95px;
  124. }
  125.  
  126. .LZTStatsInfo .LZTStatsItem .LZTStatsChangeInfo {
  127. height: 16px;
  128. margin-left: auto;
  129. display: flex;
  130. }
  131.  
  132. .LZTStatsInfo .LZTStatsItem .LZTStatsChangeInfo i {
  133. width: 16px;
  134. height: 16px;
  135. font-size: 16px;
  136. margin-left: 2px;
  137. }
  138.  
  139. #LZTStatsModalTitle {
  140. text-align: center;
  141. padding: 16px;
  142. font-size: 20px;
  143. font-weight: bold;
  144. }
  145.  
  146. .LZTStatsChatComment {
  147. background: rgb(54, 54, 54);
  148. margin: 5px 15px;
  149. padding: 10px 15px;
  150. border-radius: 10px;
  151. }
  152.  
  153. .LZTStatsSectionItem {
  154. max-width: 580px;
  155. flex-basis: 50%;
  156. flex-grow: 1;
  157. height: 64px;
  158. display: flex;
  159. align-items: center;
  160.  
  161. transition: all 0.5s ease;
  162. }
  163.  
  164. .LZTStatsSectionItem:hover {
  165. background: rgba(54, 54, 54, 0.75);
  166. border-radius: 8px;
  167. cursor: pointer;
  168. }
  169.  
  170. .LZTUpSectionTextContainer {
  171. display: flex;
  172. flex-direction: column;
  173. justify-content: center;
  174. flex: 1 1 auto;
  175. max-width: 100%;
  176. }
  177.  
  178. .LZTStatsSectionItem i {
  179. width: 28px;
  180. height: 28px;
  181. margin: 20px;
  182. font-size: 28px;
  183. color: #0daf77;
  184. }
  185.  
  186. .LZTStatsSectionTitle {
  187. display: block;
  188. margin-right: 20px;
  189. font-size: 15px;
  190. font-weight: bold;
  191. text-overflow: ellipsis;
  192. white-space: nowrap;
  193. overflow: hidden;
  194. }
  195.  
  196. #LZTStatsLoadChatHistory, .LZTStatsTotalMessages {
  197. margin: 15px;
  198. }
  199.  
  200. .LZTStatsCheckContainer {
  201. margin: 15px 20px;
  202. max-width: 95%;
  203. display: flex;
  204. align-items: center;
  205. }
  206.  
  207. .LZTStatsCheckContainer label {
  208. white-space: nowrap;
  209. }
  210.  
  211. #LZTStatsPreloader {
  212. width: 100%;
  213. height: 42px;
  214. font-size: 36px;
  215. text-align: center;
  216. animation: rotate 3s linear infinite;
  217. }
  218.  
  219. @keyframes rotate {
  220. from {
  221. transform: rotate(0deg);
  222. }
  223. to {
  224. transform: rotate(360deg);
  225. }
  226. }
  227. `);
  228.  
  229. // * CONSTANTS
  230. const HOUR_IN_SECS = 3600;
  231. const DAY_IN_SECS = HOUR_IN_SECS * 24;
  232. const MONTH_IN_SECS = DAY_IN_SECS * 30;
  233. const XF_LANG = XenForo?.visitor?.language_id === 1 ? "en" : "ru";
  234. const AVAILABLED_DATA_KEYS = [
  235. "stats-messages",
  236. "stats-messages-edited",
  237. "stats-messages-deleted",
  238. "stats-comments",
  239. "stats-comments-edited",
  240. "stats-comments-deleted",
  241. "stats-chat",
  242. "stats-chat-edited",
  243. "stats-chat-deleted",
  244. "stats-threads-created",
  245. "stats-threads-edited",
  246. "stats-threads-deleted",
  247. "stats-sympathies-gotten",
  248. "stats-sympathies-new",
  249. "stats-likes-gotten",
  250. "stats-likes",
  251. "stats-participates",
  252. "stats-reports",
  253. "stats-polls",
  254. "stats-conversation",
  255. "stats-conversation-edited",
  256. ];
  257.  
  258. // * I18N
  259. const i18n = {
  260. ru: {
  261. messages: "Сообщений",
  262. "messages-edited": "Изменено сообщений",
  263. "messages-deleted": "Удалено сообщений",
  264. comments: "Комментариев",
  265. "comments-edited": "Изменено комментариев",
  266. "comments-deleted": "Удалено комментариев",
  267. chat: "Сообщений в чате",
  268. "chat-edited": "Изменено в чате",
  269. "chat-deleted": "Удалено из чата",
  270. "threads-created": "Создано тем",
  271. "threads-edited": "Изменено тем",
  272. "threads-deleted": "Удалено тем",
  273. "sympathies-gotten": "Получено симпатий",
  274. "sympathies-new": "Поставлено симпатий",
  275. "likes-gotten": "Лайки за сообщения",
  276. likes: "Поставлено лайков",
  277. participates: "Участий в розыгрышах",
  278. reports: "Репортов",
  279. polls: "Пройдено опросов",
  280. conversation: "Сообщений в ЛС",
  281. "conversation-edited": "Изменено в ЛС",
  282. warnings: "Получено баллов",
  283. "load-chat-history": "Загрузить историю чата",
  284. loaded: "Загружено",
  285. "total-chat-messages": "Всего сообщений",
  286. "text-not-found": "текст не найден",
  287. "download-collected-data": "Скачать собранные данные",
  288. "upload-data-from-file": "Загрузить данные из файла",
  289. "upload-data-warning":
  290. "Вы уверены, что хотите загрузить данные из файла?\n\nПосле загрузки текущие данные будут перезаписаны!",
  291. "upload-data-cancelled":
  292. "Отмена. Вы отказались от загрузки данных из файла.",
  293. "tab-period-year": "За год",
  294. "tab-period-month": "За месяц",
  295. "tab-period-week": "За неделю",
  296. "tab-period-day": "За день",
  297. "tab-chat": "Чат",
  298. "tab-settings": "Настройки",
  299. "upload-file-error": "Произошла ошибка при загрузке данных из файла",
  300. "upload-data-convert-error": "Произошла ошибка при преобразование данных",
  301. "upload-data-success":
  302. "Данные успешно загружены. Перезагружаем меню для обновления...",
  303. "time-format": "Альтернативный формат подсчета времени",
  304. "time-format-desc":
  305. "Если включено, то подсчет времени в статистике идет с 00:00, а не за последние 24 часа",
  306. "time-format-alert":
  307. "Формат отображения статистики был изменен. Перезагружаем меню для обновления...",
  308. },
  309. en: {
  310. messages: "Messages",
  311. "messages-edited": "Changed messages",
  312. "messages-deleted": "Deleted messages",
  313. comments: "Comments",
  314. "comments-edited": "Changed comments",
  315. "comments-deleted": "Deleted comments",
  316. chat: "Chat messages",
  317. "chat-edited": "Changed in chat",
  318. "chat-deleted": "Deleted from chat",
  319. "threads-created": "Created threads",
  320. "threads-edited": "Changed threads",
  321. "threads-deleted": "Deleted threads",
  322. "sympathies-gotten": "Received sympathies",
  323. "sympathies-new": "Put sympathies",
  324. "likes-gotten": "Received likes for messages",
  325. likes: "Put likes",
  326. participates: "Participations in contests",
  327. reports: "Reports",
  328. polls: "Completed surveys",
  329. conversation: "Messages in PM",
  330. "conversation-edited": "Changed in PM",
  331. warnings: "Points received",
  332. "load-chat-history": "Load Chat History",
  333. loaded: "Loaded",
  334. "total-chat-messages": "Total messages",
  335. "text-not-found": "text not found",
  336. "download-collected-data": "Download the collected data",
  337. "upload-data-from-file": "Upload data from a file",
  338. "upload-data-warning":
  339. "Are you sure you want to upload data from a file?\n\n After loading, the current data will be overwritten!",
  340. "upload-data-cancelled":
  341. "Cancel. You refused to upload data from the file.",
  342. "tab-period-year": "Per year",
  343. "tab-period-month": "Per month",
  344. "tab-period-week": "Per week",
  345. "tab-period-day": "Per day",
  346. "tab-chat": "Chat",
  347. "tab-settings": "Settings",
  348. "upload-file-error": "An error occurred while loading data from a file",
  349. "upload-data-convert-error": "An error occurred while converting data",
  350. "upload-data-success":
  351. "The data has been uploaded successfully. Reloading the menu to update...",
  352. "time-format": "Alternative time counting format",
  353. "time-format-desc":
  354. "If enabled, the time is counted in the statistics from 00:00, and not for the last 24 hours",
  355. "time-format-alert":
  356. "The format for displaying statistics has been changed. Reloading the menu to update...",
  357. },
  358. get(phrase) {
  359. return this[XF_LANG]?.[phrase] ?? phrase;
  360. },
  361. };
  362.  
  363. // * REGEXES
  364. const SEND_IN_THREAD = /^threads\/([^d]+)\/add-reply$/;
  365. const COMMENT_IN_THREAD = /^posts\/([^d]+)\/comment$/;
  366.  
  367. const DELETE_MESSAGE_IN_THREAD = /^posts\/([^d]+)\/delete$/;
  368. const DELETE_COMMENT_IN_THREAD = /^posts\/comments\/([^d]+)\/delete$/;
  369.  
  370. const EDITED_MESSAGE_IN_THREAD = /^posts\/([^d]+)\/save-inline$/;
  371. const EDITED_COMMENT_IN_THREAD = /^posts\/comments\/([^d]+)\/save-inline$/;
  372.  
  373. const PARTICIPATE_IN_CONTEST = /^threads\/([^d]+)\/participate/; // ! DON'T ADD ANYTHING TO THE END!! THERE'S A CAPTCHA GOING ON
  374. const SYMPATHY_OR_LIKE_IN_THREAD = /\/posts\/([^d]+)\/like$/;
  375. const REPORT_IN_THREAD = /^posts\/([^d]+)\/report$/;
  376.  
  377. const LIKE_IN_PROFILE = /\/profile-posts\/([^d]+)\/like$/;
  378.  
  379. const SEND_IN_PROFILE_USERID = /^members\/([^d]+)\/post$/;
  380. const SEND_IN_PROFILE_TAG = /^([-\w]+)\/post$/;
  381. const COMMENT_IN_PROFILE = /^profile-posts\/([^d]+)\/comment$/;
  382.  
  383. const POLL_IN_THREAD = /^threads\/([^d]+)\/poll\/vote/;
  384. const POLL_IN_PROFILE = /^profile-posts\/([^d]+)\/poll\/vote/;
  385.  
  386. const SEND_IN_CHAT = /^\/chatbox\/post-message$/;
  387. const EDIT_IN_CHAT = /^\/chatbox\/([^d]+)\/edit$/;
  388. const DELETE_IN_CHAT = /^\/chatbox\/([^d]+)\/delete$/;
  389.  
  390. const CREATE_NEW_THREAD = /^forums\/([-\w]+)\/add-thread$/;
  391. const EDIT_THREAD = /^threads\/([^d]+)\/save$/;
  392. const DELETE_THREAD = /^threads\/([^d]+)\/delete$/;
  393.  
  394. const SEND_IN_CONVERSATION = /^conversations\/([^d]+)\/insert-reply$/;
  395. const EDIT_IN_CONVERSATION = /^conversations\/([^d]+)\/save-message/; // ! DON'T ADD ANYTHING TO THE END!! THERE GOES THE MESSAGE NUMBER
  396.  
  397. const FIND_THREAD_ID = /^threads\/([^d]+)\//;
  398. const FIND_POST_ID = /^posts\/([^d]+)\//;
  399. const FIND_PROFILE_POST_ID = /^profile-posts\/([^d]+)\//;
  400. const FIND_USER_POST = /^([-\w]+)\/post$/;
  401. const FIND_USER_ID = /^members\/([^d]+)\//;
  402. const FIND_COMMENT_POST_ID = /^posts\/comments\/([^d]+)\//;
  403. const FIND_FORUM_ID = /^forums\/([-\w]+)\//;
  404. const FIND_POST_ID_IN_FULL_URL = /\/posts\/([^d]+)\//;
  405. const FIND_CHAT_ID = /^\/chatbox\/([^d]+)\//;
  406. const FIND_CONVERSATION_ID = /^conversations\/([^d]+)\//;
  407.  
  408. // * HOOKS
  409. function initHooks() {
  410. // reference: https://github.com/LOLZHelper/LOLZHelperReborn/blob/main/src/common/hooks.js
  411. const XF_AJAX = XenForo.ajax;
  412.  
  413. XenForo.ajax = function () {
  414. console.debug("[LZTStats.initHooks] [XenForo.ajax]", arguments);
  415. const url = arguments[0];
  416. const data = arguments[1];
  417. const success = arguments[2];
  418. const options = arguments[3];
  419.  
  420. const timestamp = getTimestamp();
  421. switch (true) {
  422. case SEND_IN_THREAD.test(url): {
  423. const threadId = getThreadIdByURL(url);
  424. saveStats(
  425. {
  426. id: threadId,
  427. timestamp,
  428. },
  429. "stats-messages"
  430. );
  431. break;
  432. }
  433. case COMMENT_IN_THREAD.test(url): {
  434. const postId = getPostIdByURL(url);
  435. saveStats(
  436. {
  437. id: postId,
  438. timestamp,
  439. },
  440. "stats-comments"
  441. );
  442. break;
  443. }
  444. case PARTICIPATE_IN_CONTEST.test(url): {
  445. const threadId = getThreadIdByURL(url);
  446. saveStats(
  447. {
  448. id: threadId,
  449. timestamp,
  450. },
  451. "stats-participates"
  452. );
  453. break;
  454. }
  455. case REPORT_IN_THREAD.test(url): {
  456. const postId = getPostIdByURL(url);
  457. saveStats(
  458. {
  459. id: postId,
  460. // message: data?.[1] || '', // weighs a lot
  461. timestamp,
  462. },
  463. "stats-reports"
  464. );
  465. break;
  466. }
  467. case CREATE_NEW_THREAD.test(url): {
  468. const forumId = getForumIdByURL(url);
  469. saveStats(
  470. {
  471. id: forumId,
  472. timestamp,
  473. },
  474. "stats-threads-created"
  475. );
  476. break;
  477. }
  478. case EDIT_THREAD.test(url): {
  479. const threadId = getThreadIdByURL(url);
  480. saveStats(
  481. {
  482. id: threadId,
  483. timestamp,
  484. },
  485. "stats-threads-edited"
  486. );
  487. break;
  488. }
  489. case DELETE_THREAD.test(url): {
  490. const threadId = getThreadIdByURL(url);
  491. saveStats(
  492. {
  493. id: threadId,
  494. timestamp,
  495. },
  496. "stats-threads-deleted"
  497. );
  498. break;
  499. }
  500. case DELETE_COMMENT_IN_THREAD.test(url) && data?.length > 0: {
  501. const postId = getCommentPostIdByURL(url);
  502. saveStats(
  503. {
  504. id: postId,
  505. timestamp,
  506. },
  507. "stats-comments-deleted"
  508. );
  509. break;
  510. }
  511. case DELETE_MESSAGE_IN_THREAD.test(url) && data?.length > 0: {
  512. // ! Do not switch places with comments or you will have to fix the regex
  513. const postId = getPostIdByURL(url);
  514. saveStats(
  515. {
  516. id: postId,
  517. timestamp,
  518. },
  519. "stats-messages-deleted"
  520. );
  521. break;
  522. }
  523. case EDITED_COMMENT_IN_THREAD.test(url): {
  524. const postId = getCommentPostIdByURL(url);
  525. saveStats(
  526. {
  527. id: postId,
  528. timestamp,
  529. },
  530. "stats-comments-edited"
  531. );
  532. break;
  533. }
  534. case EDITED_MESSAGE_IN_THREAD.test(url): {
  535. // ! Do not switch places with comments or you will have to fix the regex
  536. const postId = getPostIdByURL(url);
  537. saveStats(
  538. {
  539. id: postId,
  540. timestamp,
  541. },
  542. "stats-messages-edited"
  543. );
  544. break;
  545. }
  546. case (POLL_IN_THREAD.test(url) || POLL_IN_PROFILE.test(url)) &&
  547. data?.length > 0: {
  548. const id = POLL_IN_THREAD.test(url)
  549. ? getThreadIdByURL(url)
  550. : getProfilePostIdByURL(url);
  551. saveStats(
  552. {
  553. id: id,
  554. timestamp,
  555. },
  556. "stats-polls"
  557. );
  558. break;
  559. }
  560. case SEND_IN_PROFILE_USERID.test(url) ||
  561. (SEND_IN_PROFILE_TAG.test(url) &&
  562. window.location.pathname.replace("/", "") + "/post" === url): {
  563. // Check by ID, if the user has a tag, then check so that the path matches the name in the link
  564. const userId = getUserIdByURL(url); // it can also return the tag
  565. saveStats(
  566. {
  567. id: userId,
  568. timestamp,
  569. },
  570. "stats-messages"
  571. );
  572. break;
  573. }
  574. case COMMENT_IN_PROFILE.test(url): {
  575. const postId = getCommentPostIdByURL(url);
  576. saveStats(
  577. {
  578. id: postId,
  579. timestamp,
  580. },
  581. "stats-comments"
  582. );
  583. break;
  584. }
  585. case SEND_IN_CHAT.test(url): {
  586. saveStats(
  587. {
  588. id: 0,
  589. message: data?.message,
  590. timestamp,
  591. },
  592. "stats-chat"
  593. );
  594. break;
  595. }
  596. case EDIT_IN_CHAT.test(url): {
  597. const chatId = getChatIdByURL(url);
  598. saveStats(
  599. {
  600. id: chatId,
  601. timestamp,
  602. },
  603. "stats-chat-edited"
  604. );
  605. break;
  606. }
  607. case DELETE_IN_CHAT.test(url): {
  608. const chatId = getChatIdByURL(url);
  609. saveStats(
  610. {
  611. id: chatId,
  612. timestamp,
  613. },
  614. "stats-chat-deleted"
  615. );
  616. break;
  617. }
  618. case SEND_IN_CONVERSATION.test(url): {
  619. const conversationId = getConversationIdByURL(url);
  620. saveStats(
  621. {
  622. id: conversationId,
  623. timestamp,
  624. },
  625. "stats-conversation"
  626. );
  627. break;
  628. }
  629. case EDIT_IN_CONVERSATION.test(url): {
  630. const conversationId = getConversationIdByURL(url);
  631. saveStats(
  632. {
  633. id: conversationId,
  634. timestamp,
  635. },
  636. "stats-conversation-edited"
  637. );
  638. break;
  639. }
  640. default:
  641. break;
  642. }
  643.  
  644. const ajaxRes = XF_AJAX.apply(this, arguments);
  645. ajaxRes.then((res) => {
  646. // it seems to work
  647. switch (true) {
  648. case SYMPATHY_OR_LIKE_IN_THREAD.test(url): {
  649. const postId = getPostIdByFullURL(url);
  650. const parsedHTML = getHTMLFromString(res.templateHtml);
  651. const likeCountLeftEl = parsedHTML.querySelector(".likeCountLeft"); // if not null is sympathies else likes
  652. const likeTextEl = parsedHTML.querySelector(".likeText");
  653. if (likeTextEl && findYouInEl(likeTextEl)) {
  654. saveStats(
  655. {
  656. id: postId,
  657. timestamp,
  658. },
  659. likeCountLeftEl ? "stats-sympathies-new" : "stats-likes"
  660. );
  661. }
  662.  
  663. break;
  664. }
  665. case LIKE_IN_PROFILE.test(url): {
  666. const postId = getPostIdByFullURL(url);
  667. const parsedHTML = getHTMLFromString(res.templateHtml);
  668. const likeTextEl = parsedHTML.querySelector(".likeText");
  669. if (likeTextEl && findYouInEl(likeTextEl)) {
  670. saveStats(
  671. {
  672. id: postId,
  673. timestamp,
  674. },
  675. "stats-likes"
  676. );
  677. }
  678.  
  679. break;
  680. }
  681. default:
  682. break;
  683. }
  684. });
  685.  
  686. return ajaxRes;
  687. };
  688. }
  689.  
  690. // * REGISTERS
  691. function regMenuBtn(name) {
  692. const menuBtn = document.createElement("li");
  693.  
  694. const link = document.createElement("a");
  695. link.classList.add("bold");
  696. link.style.color = "#0daf77";
  697. link.innerText = name;
  698.  
  699. const separator = document.createElement("div");
  700. separator.classList.add("account-menu-sep");
  701.  
  702. menuBtn.appendChild(link);
  703.  
  704. const latestMenuItem = document.querySelector(
  705. "#AccountMenu > .blockLinksList > li:last-child"
  706. );
  707. latestMenuItem.insertAdjacentElement("beforebegin", menuBtn);
  708. latestMenuItem.insertAdjacentElement("beforebegin", separator);
  709.  
  710. return menuBtn;
  711. }
  712.  
  713. function regModal(name, mainEl = "") {
  714. return XenForo.alert(mainEl, name, null, () => {
  715. document.querySelector("div.modal.fade")?.remove();
  716. });
  717. }
  718.  
  719. function regAlert(text, time) {
  720. // text can be html
  721. return XenForo.alert(text, false, time);
  722. }
  723.  
  724. function setMenuTitle(modal, title) {
  725. const modalOverlay = modal?.[0];
  726. const modalTitle = modalOverlay?.querySelector("h2.heading");
  727. modalTitle.id = "LZTStatsModalTitle";
  728. modalTitle.innerText = title;
  729. }
  730.  
  731. // * CLASSES
  732. class Tab {
  733. /**
  734. *
  735. * @constructor
  736. * @param {string} name - name of the tab
  737. * @param {string} tabId - id of the tab
  738. * @param {string} sectionId - id of the section
  739. * @param {boolean} active - status of tab
  740. */
  741.  
  742. constructor(name, tabId, sectionId, active) {
  743. this.name = name;
  744. this.tabId = tabId;
  745. this.sectionId = sectionId;
  746. this.active = active;
  747. }
  748.  
  749. createElement() {
  750. const tab = document.createElement("li");
  751. tab.classList.add("LZTStatsTab");
  752. tab.id = this.tabId;
  753.  
  754. const span = document.createElement("span");
  755. span.innerText = this.name;
  756.  
  757. tab.appendChild(span);
  758. tab.addEventListener("click", () => this.setActive());
  759. return tab;
  760. }
  761.  
  762. setActive() {
  763. if (!document.getElementById(this.tabId)) {
  764. // ignore errors if rerendering menu
  765. return;
  766. }
  767.  
  768. document
  769. .querySelectorAll(".LZTStatsTab")
  770. .forEach((tab) => tab.classList.remove("active"));
  771.  
  772. document.getElementById(this.tabId).classList.add("active");
  773.  
  774. document
  775. .querySelectorAll(".LZTStatsModalContent > .LZTStatsSection")
  776. .forEach((section) => (section.style.display = "none"));
  777.  
  778. document.getElementById(this.sectionId).style.display = "";
  779. }
  780. }
  781.  
  782. // * HELPERS
  783. function initMenuBtn() {
  784. const menuBtn = regMenuBtn("LZT Stats");
  785. menuBtn.addEventListener("click", renderMenu);
  786. }
  787.  
  788. async function renderMenu() {
  789. const modal = regModal(
  790. "LZT Stats",
  791. '<div class="LZTStatsModalContent"><i id="LZTStatsPreloader" class="fas fa-spinner-third"></i></div>'
  792. );
  793. setMenuTitle(modal, "LZT Stats");
  794.  
  795. const modalContent = document.querySelector(".LZTStatsModalContent");
  796. console.debug("[LZTStats.renderMenu.menuBtn.onclick]", modalContent, modal);
  797. if (modal?.[0]?.parentElement) {
  798. // add id for customize modal
  799. modal[0].parentElement.id = "LZTStatsOverlay";
  800. }
  801.  
  802. const tabsContainer = document.createElement("ul");
  803. tabsContainer.classList.add("LZTStatsTabs");
  804.  
  805. /**
  806. * items - list of objects (usually: id, timestamp)
  807. * label - title of the item
  808. * data - converted items to work with graph
  809. * icon - icon of the item
  810. * changeValue - Value compared to last time
  811. * hidden - default visibility in graph
  812. */
  813. const statsData = [
  814. {
  815. items: await GM_getValue("stats-messages", []),
  816. label: "messages",
  817. icon: "far fa-comment-alt",
  818. data: [],
  819. changeValue: null,
  820. hidden: false,
  821. },
  822. {
  823. items: await GM_getValue("stats-messages-edited", []),
  824. label: "messages-edited",
  825. icon: "far fa-comment-alt-edit",
  826. data: [],
  827. changeValue: null,
  828. hidden: true,
  829. },
  830. {
  831. items: await GM_getValue("stats-messages-deleted", []),
  832. label: "messages-deleted",
  833. icon: "far fa-comment-alt-times",
  834. data: [],
  835. changeValue: null,
  836. hidden: true,
  837. },
  838. {
  839. items: await GM_getValue("stats-comments", []),
  840. label: "comments",
  841. icon: "far fa-comment-alt-dots",
  842. data: [],
  843. changeValue: null,
  844. hidden: false,
  845. },
  846. {
  847. items: await GM_getValue("stats-comments-edited", []),
  848. label: "comments-edited",
  849. icon: "far fa-comment-alt-edit",
  850. data: [],
  851. changeValue: null,
  852. hidden: true,
  853. },
  854. {
  855. items: await GM_getValue("stats-comments-deleted", []),
  856. label: "comments-deleted",
  857. icon: "far fa-comment-alt-times",
  858. data: [],
  859. changeValue: null,
  860. hidden: true,
  861. },
  862. {
  863. items: await GM_getValue("stats-chat", []),
  864. label: "chat",
  865. icon: "far fa-comments",
  866. data: [],
  867. changeValue: null,
  868. hidden: false,
  869. },
  870. {
  871. items: await GM_getValue("stats-chat-edited", []),
  872. label: "chat-edited",
  873. icon: "far fa-comment-edit",
  874. data: [],
  875. changeValue: null,
  876. hidden: true,
  877. },
  878. {
  879. items: await GM_getValue("stats-chat-deleted", []),
  880. label: "chat-deleted",
  881. icon: "far fa-comment-times",
  882. data: [],
  883. changeValue: null,
  884. hidden: true,
  885. },
  886. {
  887. items: await GM_getValue("stats-threads-created", []),
  888. label: "threads-created",
  889. icon: "far fa-file-alt",
  890. data: [],
  891. changeValue: null,
  892. hidden: false,
  893. },
  894. {
  895. items: await GM_getValue("stats-threads-edited", []),
  896. label: "threads-edited",
  897. icon: "far fa-file-edit",
  898. data: [],
  899. changeValue: null,
  900. hidden: true,
  901. },
  902. {
  903. items: await GM_getValue("stats-threads-deleted", []),
  904. label: "threads-deleted",
  905. icon: "far fa-file-times",
  906. data: [],
  907. changeValue: null,
  908. hidden: true,
  909. },
  910. {
  911. items: await getSympathiesStats(),
  912. label: "sympathies-gotten",
  913. icon: "far fa-heart",
  914. data: [],
  915. changeValue: null,
  916. hidden: true, // the graph does not correspond to reality
  917. },
  918. {
  919. items: await GM_getValue("stats-sympathies-new", []),
  920. label: "sympathies-new",
  921. icon: "far fa-heart",
  922. data: [],
  923. changeValue: null,
  924. hidden: true,
  925. },
  926. {
  927. items: await getLikesStats(),
  928. label: "likes-gotten",
  929. icon: "far fa-thumbs-up",
  930. data: [],
  931. changeValue: null,
  932. hidden: true,
  933. },
  934. {
  935. items: await GM_getValue("stats-likes", []),
  936. label: "likes",
  937. icon: "far fa-thumbs-up",
  938. data: [],
  939. changeValue: null,
  940. hidden: true,
  941. },
  942. {
  943. items: await GM_getValue("stats-participates", []),
  944. label: "participates",
  945. icon: "far fa-gift",
  946. data: [],
  947. changeValue: null,
  948. hidden: false,
  949. },
  950. {
  951. items: await GM_getValue("stats-reports", []),
  952. label: "reports",
  953. icon: "far fa-bullhorn",
  954. data: [],
  955. changeValue: null,
  956. hidden: false,
  957. },
  958. {
  959. items: await GM_getValue("stats-polls", []),
  960. label: "polls",
  961. icon: "far fa-poll",
  962. data: [],
  963. changeValue: null,
  964. hidden: true,
  965. },
  966. {
  967. items: await GM_getValue("stats-conversation", []),
  968. label: "conversation",
  969. icon: "far fa-comments-alt",
  970. data: [],
  971. changeValue: null,
  972. hidden: false,
  973. },
  974. {
  975. items: await GM_getValue("stats-conversation-edited", []),
  976. label: "conversation-edited",
  977. icon: "far fa-comment-alt-edit",
  978. data: [],
  979. changeValue: null,
  980. hidden: true,
  981. },
  982. {
  983. items: await getWarningsStats(),
  984. label: "warnings",
  985. icon: "far fa-exclamation-triangle",
  986. data: [],
  987. changeValue: null,
  988. hidden: true,
  989. },
  990. ];
  991.  
  992. // ** STATS BY ALL TIME SECTION
  993. const allTimeSection = document.createElement("div");
  994. allTimeSection.classList.add("LZTStatsSection");
  995. allTimeSection.id = "LZTStatsAllSection";
  996. await createTimeSection(allTimeSection, statsData, 12, MONTH_IN_SECS);
  997.  
  998. // ** STATS BY ALL MONTH SECTION
  999. const monthTimeSection = document.createElement("div");
  1000. monthTimeSection.classList.add("LZTStatsSection");
  1001. monthTimeSection.id = "LZTStatsMonthSection";
  1002. await createTimeSection(monthTimeSection, statsData, 30, DAY_IN_SECS);
  1003.  
  1004. // ** STATS BY ALL WEEK SECTION
  1005. const weekTimeSection = document.createElement("div");
  1006. weekTimeSection.classList.add("LZTStatsSection");
  1007. weekTimeSection.id = "LZTStatsWeekSection";
  1008. await createTimeSection(weekTimeSection, statsData, 7, DAY_IN_SECS);
  1009.  
  1010. // ** STATS BY ALL DAY SECTION
  1011. const dayTimeSection = document.createElement("div");
  1012. dayTimeSection.classList.add("LZTStatsSection");
  1013. dayTimeSection.id = "LZTStatsDaySection";
  1014. await createTimeSection(dayTimeSection, statsData, 24, HOUR_IN_SECS);
  1015.  
  1016. // ** CHAT SECTION
  1017. const chatSection = document.createElement("div");
  1018. chatSection.classList.add("LZTStatsSection");
  1019. chatSection.id = "LZTStatsChatSection";
  1020.  
  1021. const loadChatHistory = document.createElement("button");
  1022. loadChatHistory.classList.add("button", "primary", "fit");
  1023. loadChatHistory.id = "LZTStatsLoadChatHistory";
  1024. loadChatHistory.innerText = i18n.get("load-chat-history");
  1025. loadChatHistory.onclick = async () => {
  1026. loadChatHistory.disabled = true;
  1027. loadChatHistory.classList.add("disabled");
  1028. loadChatHistory.innerText = i18n.get("loaded");
  1029.  
  1030. const chatMessages = (await GM_getValue("stats-chat")) || [];
  1031. const messagesEl = [];
  1032. const messagesTotalEl = document.createElement("div");
  1033. messagesTotalEl.classList.add("LZTStatsTotalMessages");
  1034. messagesTotalEl.innerText = `${i18n.get("total-chat-messages")}: ${
  1035. chatMessages.length
  1036. }`;
  1037.  
  1038. for (const msg of chatMessages.reverse()) {
  1039. const chatEl = document.createElement("p");
  1040. const chatTimeEl = document.createElement("p");
  1041. chatEl.classList.add("LZTStatsChatComment");
  1042. chatEl.innerText = msg?.message || i18n.get("text-not-found");
  1043. const chatDate = new Date(msg.timestamp * 1000);
  1044. chatTimeEl.innerText = `\n${formatDate(
  1045. chatDate.getHours()
  1046. )}:${formatDate(chatDate.getMinutes())} ${formatDate(
  1047. chatDate.getDate()
  1048. )}.${formatDate(chatDate.getMonth() + 1)}.${formatDate(
  1049. chatDate.getFullYear()
  1050. )}`;
  1051. chatEl.append(chatTimeEl);
  1052. messagesEl.push(chatEl);
  1053. }
  1054.  
  1055. chatSection.append(messagesTotalEl, ...messagesEl);
  1056. };
  1057. chatSection.append(loadChatHistory);
  1058.  
  1059. // ** SETTINGS SECTION
  1060. const settingsSection = document.createElement("div");
  1061. settingsSection.classList.add("LZTStatsSection");
  1062. settingsSection.id = "LZTStatsSettingsSection";
  1063.  
  1064. // *** DOWNLOAD DATA
  1065. const settingsDownloadEl = createSectionItem(
  1066. i18n.get("download-collected-data"),
  1067. "fa-file-download"
  1068. );
  1069. settingsSection.append(settingsDownloadEl);
  1070.  
  1071. settingsDownloadEl.onclick = () => {
  1072. const data = statsData.map((s) => {
  1073. const temp = {};
  1074. const key = "stats-" + s.label; // label is raw i18n phrase. Adding "stats-" so that the output array names matches the data names in GM Storage
  1075. const val = s.items;
  1076. temp[key] = val;
  1077. return temp;
  1078. });
  1079.  
  1080. // we combine all the data into a single object, so that there is a normal structure of the json file
  1081. downloadJSONFile(JSON.stringify(Object.assign(...data)), "LZTStats");
  1082. };
  1083.  
  1084. // *** UPLOAD DATA
  1085. // !!! OVERWRITES DATA IN GM STORAGE
  1086. const settingsUploadEl = createSectionItem(
  1087. i18n.get("upload-data-from-file"),
  1088. "fa-file-upload"
  1089. );
  1090. settingsSection.append(settingsUploadEl);
  1091.  
  1092. settingsUploadEl.onclick = async () => {
  1093. const approve = confirm(i18n.get("upload-data-warning"));
  1094. if (!approve) {
  1095. return regAlert(
  1096. `<span style="color: #f13838">${i18n.get(
  1097. "upload-data-cancelled"
  1098. )}</span>`,
  1099. 5000
  1100. );
  1101. }
  1102.  
  1103. const data = await uploadJSONFile();
  1104. try {
  1105. const parsedData = JSON.parse(data);
  1106.  
  1107. for (const key in parsedData) {
  1108. if (AVAILABLED_DATA_KEYS.includes(key)) {
  1109. GM_setValue(key, parsedData[key]);
  1110. }
  1111. }
  1112.  
  1113. regAlert(i18n.get("upload-data-success"), 5000);
  1114. await reRenderModal(modal);
  1115. } catch (e) {
  1116. console.error(i18n.get("upload-data-convert-error"), e);
  1117. regAlert(
  1118. `<span style="color: #f13838">${i18n.get(
  1119. "upload-data-convert-error"
  1120. )}</span>`,
  1121. 5000
  1122. );
  1123. }
  1124. };
  1125.  
  1126. // *** STATS TIME FORMAT
  1127. const checkboxContainer = document.createElement("div");
  1128. const checkbox = document.createElement("input");
  1129. const checkboxLabel = document.createElement("label");
  1130. const checkboxLabelSpan = document.createElement("span");
  1131. settingsSection.append(checkboxContainer);
  1132.  
  1133. checkbox.type = "checkbox";
  1134. checkbox.id = "LZTStatsTimeFormat";
  1135. checkbox.checked = await GM_getValue("stats-time-format", false);
  1136.  
  1137. checkboxLabelSpan.classList.add("fa", "fa-question", "Tooltip");
  1138. checkboxLabelSpan.setAttribute("title", i18n.get("time-format-desc"));
  1139. checkboxLabel.htmlFor = "LZTStatsTimeFormat";
  1140. checkboxLabel.append(i18n.get("time-format"), checkboxLabelSpan);
  1141.  
  1142. checkboxContainer.classList.add("LZTStatsCheckContainer");
  1143. checkboxContainer.append(checkbox, checkboxLabel);
  1144.  
  1145. checkboxContainer.onclick = async (event) => {
  1146. await GM_setValue("stats-time-format", event.target.checked);
  1147. regAlert(i18n.get("time-format-alert"), 5000);
  1148. await reRenderModal(modal);
  1149. };
  1150.  
  1151. async function reRenderModal(modal) {
  1152. $(modal?.[0]?.parentElement?.parentElement).trigger("hidden"); // soft modal remove
  1153. document.querySelector(".modal-backdrop")?.remove();
  1154. await renderMenu();
  1155. }
  1156.  
  1157. // ** MODAL EXECUTORS
  1158. modalContent.querySelector("#LZTStatsPreloader")?.remove();
  1159. modalContent.append(
  1160. tabsContainer,
  1161. allTimeSection,
  1162. monthTimeSection,
  1163. weekTimeSection,
  1164. dayTimeSection,
  1165. chatSection,
  1166. settingsSection
  1167. );
  1168.  
  1169. const tabs = [
  1170. new Tab(
  1171. i18n.get("tab-period-year"),
  1172. "LZTStatsAllTab",
  1173. "LZTStatsAllSection",
  1174. true
  1175. ),
  1176. new Tab(
  1177. i18n.get("tab-period-month"),
  1178. "LZTStatsMonthTab",
  1179. "LZTStatsMonthSection",
  1180. false
  1181. ),
  1182. new Tab(
  1183. i18n.get("tab-period-week"),
  1184. "LZTStatsWeekTab",
  1185. "LZTStatsWeekSection",
  1186. false
  1187. ),
  1188. new Tab(
  1189. i18n.get("tab-period-day"),
  1190. "LZTStatsDayTab",
  1191. "LZTStatsDaySection",
  1192. false
  1193. ),
  1194. new Tab(
  1195. i18n.get("tab-chat"),
  1196. "LZTStatsChatTab",
  1197. "LZTStatsChatSection",
  1198. false
  1199. ),
  1200. new Tab(
  1201. i18n.get("tab-settings"),
  1202. "LZTStatsSettingsTab",
  1203. "LZTStatsSettingsSection",
  1204. false
  1205. ),
  1206. ];
  1207.  
  1208. for (const tab of tabs) {
  1209. tabsContainer.appendChild(tab.createElement());
  1210. tab.active ? tab.setActive() : null;
  1211. }
  1212.  
  1213. modalContent
  1214. .querySelectorAll(".Tooltip")
  1215. ?.forEach((el) => XenForo.Tooltip($(el))); // Registering tooltips
  1216. }
  1217.  
  1218. async function createTimeSection(
  1219. containerEl,
  1220. statsData,
  1221. GRAPH_LENGTH,
  1222. GRAPH_TIME_FORMAT
  1223. ) {
  1224. statsData.forEach(
  1225. (s) =>
  1226. (s.data = getSumValuesByTime(
  1227. calcSumByTime(s.items, GRAPH_TIME_FORMAT, GRAPH_LENGTH)
  1228. ))
  1229. );
  1230. const currentTimestamp = getFormattedTimestamp();
  1231.  
  1232. const calculatedGraphTime = GRAPH_TIME_FORMAT * GRAPH_LENGTH;
  1233. const lastPageDate = currentTimestamp - calculatedGraphTime * 2;
  1234. const thisPageDate = currentTimestamp - calculatedGraphTime;
  1235.  
  1236. if (GRAPH_TIME_FORMAT !== MONTH_IN_SECS) {
  1237. // skip year format info
  1238. statsData.forEach(
  1239. (s) =>
  1240. (s.changeValue =
  1241. s.items.filter((i) => i.timestamp > thisPageDate).length -
  1242. s.items.filter(
  1243. (i) => i.timestamp > lastPageDate && i.timestamp < thisPageDate
  1244. ).length)
  1245. );
  1246. }
  1247.  
  1248. const timeArray = getTimeArray(GRAPH_TIME_FORMAT, GRAPH_LENGTH);
  1249.  
  1250. const statsContainer = document.createElement("div");
  1251. statsContainer.classList.add("LZTStatsInfo");
  1252.  
  1253. const statsItems = statsData.map((s) =>
  1254. createStatsItem(
  1255. i18n.get(s.label),
  1256. s.items.filter((i) => i.timestamp > thisPageDate).length,
  1257. s.icon,
  1258. s.changeValue
  1259. )
  1260. );
  1261. statsContainer.append(...statsItems);
  1262.  
  1263. const graph = document.createElement("canvas");
  1264. if (document.querySelector(".xenOverlay.slim")) {
  1265. graph.width = 300;
  1266. graph.height = 600;
  1267. } else {
  1268. graph.width = 600;
  1269. graph.height = 450;
  1270. }
  1271. graph.id = `LZTStatsGraph-${GRAPH_LENGTH}-${GRAPH_TIME_FORMAT}`;
  1272.  
  1273. containerEl.append(statsContainer, graph);
  1274.  
  1275. new Chart(graph, {
  1276. type: "line",
  1277. data: {
  1278. labels: timeArray.reverse(),
  1279. datasets: statsData.map((s) => ({
  1280. label: i18n.get(s.label),
  1281. data: s.data,
  1282. borderWidth: 1,
  1283. hidden: s.hidden,
  1284. tension: 0.2,
  1285. })),
  1286. },
  1287. options: {
  1288. scales: {
  1289. y: {
  1290. beginAtZero: true,
  1291. },
  1292. },
  1293. },
  1294. });
  1295. }
  1296.  
  1297. function createStatsItem(title, value, iconClasses = "", changeValue = null) {
  1298. const item = document.createElement("div");
  1299. item.classList.add("LZTStatsItem");
  1300.  
  1301. const iconContainer = document.createElement("div");
  1302. iconContainer.classList.add("LZTStatsIcon");
  1303. const icon = document.createElement("i");
  1304. icon.classList.add(...iconClasses.split(" ")); // convert to the view that FontAwesome works with
  1305. iconContainer.appendChild(icon);
  1306.  
  1307. const textContainer = document.createElement("div");
  1308. textContainer.innerText = value;
  1309. const textTitle = document.createElement("p");
  1310. textTitle.innerText = title;
  1311. textContainer.insertAdjacentElement("afterbegin", textTitle);
  1312.  
  1313. item.append(iconContainer, textContainer);
  1314.  
  1315. if (typeof changeValue === "number") {
  1316. const changeContainer = document.createElement("div");
  1317. changeContainer.classList.add("LZTStatsChangeInfo");
  1318. changeContainer.innerText = changeValue;
  1319. const changeIcon = document.createElement("i");
  1320.  
  1321. // Set icon and style by number that has changed
  1322. // default: Just like last time
  1323. let changeIconClasses = "far fa-arrows-alt-v";
  1324. let changeIconStyle = "#D6D6D6";
  1325. if (changeValue < 0) {
  1326. // Less than last time
  1327. changeIconClasses = "fas fa-caret-down";
  1328. changeIconStyle = "#ea4c4c";
  1329. } else if (changeValue > 0) {
  1330. // More than last time
  1331. changeIconClasses = "fas fa-caret-up";
  1332. changeIconStyle = "#0daf77";
  1333. }
  1334.  
  1335. changeIcon.classList.add(...changeIconClasses.split(" ")); // convert to the view that FontAwesome works with
  1336. changeIcon.style = `color: ${changeIconStyle}`;
  1337. changeContainer.appendChild(changeIcon);
  1338. item.append(changeContainer);
  1339. }
  1340.  
  1341. return item;
  1342. }
  1343.  
  1344. function createSectionItem(text, icon = "fa-vial") {
  1345. const settingsEl = document.createElement("div");
  1346. settingsEl.classList.add("LZTStatsSectionItem");
  1347.  
  1348. const itemIcon = document.createElement("i");
  1349. itemIcon.classList.add("far", icon);
  1350.  
  1351. const textContainer = document.createElement("div");
  1352. textContainer.classList.add("LZTStatsSectionTextContainer");
  1353.  
  1354. const textEl = document.createElement("span");
  1355. textEl.classList.add("LZTStatsSectionTitle");
  1356. textEl.innerText = text;
  1357.  
  1358. textContainer.append(textEl);
  1359. settingsEl.append(itemIcon, textContainer);
  1360. return settingsEl;
  1361. }
  1362.  
  1363. // * REQUESTS
  1364.  
  1365. async function getSympathiesStats() {
  1366. const savedSympathies =
  1367. (await GM_getValue("stats-sympathies-gotten")) || [];
  1368. let currentTimestamp = getTimestamp();
  1369. const lastSave = currentTimestamp - DAY_IN_SECS * 30;
  1370.  
  1371. // sympathies, except sympathies for the last 30 days
  1372. let sympathies = savedSympathies.filter((s) => s.timestamp < lastSave);
  1373.  
  1374. const userId = getUserId();
  1375.  
  1376. try {
  1377. // post_comment for reducing the weight of the answer
  1378. // the value of sympathies for 7 days and for 30 days in "post" and "post_comment" doesn't change
  1379.  
  1380. // we use fetch instead of ajax because ajax doesn't return the necessary data
  1381. const res = await fetch(
  1382. `/members/${userId}/likes?type=gotten&content_type=post_comment`,
  1383. {
  1384. method: "GET",
  1385. credentials: "include",
  1386. }
  1387. );
  1388.  
  1389. const resHTML = await res.text();
  1390.  
  1391. const parsedHTML = getHTMLFromString(resHTML);
  1392. const pageDescription = parsedHTML.querySelector("#pageDescription");
  1393.  
  1394. if (pageDescription) {
  1395. const likesText = pageDescription.innerText.split(" - ");
  1396. const likesWeek = likesText[1]?.match(/(\d+)/)?.[0];
  1397. const likesMonth = likesText[2]?.match(/(\d+)/)?.[0];
  1398. let stepWeek =
  1399. likesWeek > 0
  1400. ? Math.floor((DAY_IN_SECS * 7) / likesWeek)
  1401. : DAY_IN_SECS * 7; // for predict division to zero
  1402. let stepMonth =
  1403. likesMonth > 0
  1404. ? Math.floor((DAY_IN_SECS * 30) / likesMonth)
  1405. : DAY_IN_SECS * 30; // +for predict division to zero
  1406. let timestamp = getTimestamp();
  1407.  
  1408. for (let i = 0; i < likesMonth; i++) {
  1409. const step = i > likesWeek ? stepMonth : stepWeek;
  1410. timestamp = timestamp - step;
  1411.  
  1412. sympathies.push({
  1413. id: 0,
  1414. timestamp,
  1415. });
  1416. }
  1417.  
  1418. if (
  1419. !savedSympathies.length ||
  1420. !savedSympathies?.filter((s) => s.timestamp > lastSave)?.length
  1421. ) {
  1422. // update saved value if there are no recently saved values
  1423. await GM_setValue("stats-sympathies-gotten", sympathies);
  1424. }
  1425. }
  1426. } catch (err) {
  1427. console.error("[LZTStats.getSympathiesStats]", err);
  1428. }
  1429.  
  1430. return sympathies;
  1431. }
  1432.  
  1433. async function getLikesStats() {
  1434. // c+p getSympathiesStats (wait 2.0)
  1435. const savedLikes = (await GM_getValue("stats-likes-gotten")) || [];
  1436. let currentTimestamp = getTimestamp();
  1437. const lastSave = currentTimestamp - DAY_IN_SECS * 30;
  1438.  
  1439. // likes, except likes for the last 30 days
  1440. let likes = savedLikes.filter((s) => s.timestamp < lastSave);
  1441.  
  1442. const userId = getUserId();
  1443.  
  1444. try {
  1445. // post_comment for reducing the weight of the answer
  1446. // the value of sympathies for 7 days and for 30 days in "post" and "post_comment" doesn't change
  1447.  
  1448. // we use fetch instead of ajax because ajax doesn't return the necessary data
  1449. const res = await fetch(
  1450. `/members/${userId}/likes2?type=gotten&content_type=post_comment`,
  1451. {
  1452. method: "GET",
  1453. credentials: "include",
  1454. }
  1455. );
  1456.  
  1457. const resHTML = await res.text();
  1458.  
  1459. const parsedHTML = getHTMLFromString(resHTML);
  1460. const pageDescription = parsedHTML.querySelector("#pageDescription");
  1461.  
  1462. if (pageDescription) {
  1463. const likesText = pageDescription.innerText.split(" - ");
  1464. const likesWeek = likesText[1]?.match(/(\d+)/)?.[0];
  1465. const likesMonth = likesText[2]?.match(/(\d+)/)?.[0];
  1466. console.log(likesWeek, likesMonth, likesWeek > 0, likesMonth > 0);
  1467. let stepWeek =
  1468. likesWeek > 0
  1469. ? Math.floor((DAY_IN_SECS * 6.99) / likesWeek)
  1470. : DAY_IN_SECS * 7; // for predict division to zero
  1471. let stepMonth =
  1472. likesMonth > 0
  1473. ? Math.floor((DAY_IN_SECS * 29.99) / likesMonth)
  1474. : DAY_IN_SECS * 30; // +for predict division to zero
  1475. let timestamp = getTimestamp();
  1476.  
  1477. for (let i = 0; i < likesMonth; i++) {
  1478. const step = i >= likesWeek ? stepMonth : stepWeek;
  1479. timestamp =
  1480. timestamp - step >
  1481. currentTimestamp - DAY_IN_SECS * (i >= likesWeek ? 30 : 7)
  1482. ? timestamp - step
  1483. : timestamp;
  1484.  
  1485. likes.push({
  1486. id: 0,
  1487. timestamp,
  1488. });
  1489. }
  1490.  
  1491. if (
  1492. !savedLikes.length ||
  1493. !savedLikes?.filter((s) => s.timestamp > lastSave)?.length
  1494. ) {
  1495. // update saved value if there are no recently saved values
  1496. await GM_setValue("stats-likes-gotten", likes);
  1497. }
  1498. }
  1499. } catch (err) {
  1500. console.error("[LZTStats.getLikesStats]", err);
  1501. }
  1502.  
  1503. return likes;
  1504. }
  1505.  
  1506. async function getWarningsStats() {
  1507. // user warning score
  1508. const warnings = [];
  1509. const userId = getUserId();
  1510. try {
  1511. const res = await XenForo.ajax(`/members/${userId}/warnings`);
  1512. const resHTML = res.templateHtml;
  1513.  
  1514. const parsedHTML = getHTMLFromString(resHTML);
  1515. const dataRows = parsedHTML.querySelectorAll(".dataRow");
  1516. // .dataRow.muted is old warnings + has another structure
  1517.  
  1518. for (const dataRow of dataRows) {
  1519. let gottenDate;
  1520. const warningDate = dataRow.querySelector("td.warningDate");
  1521. const warningDateChild = warningDate?.firstChild;
  1522. if (!warningDateChild) {
  1523. continue;
  1524. }
  1525.  
  1526. if (warningDateChild.dataset.time) {
  1527. gottenDate = warningDateChild.dataset.time;
  1528. } else if (
  1529. (warningDateChild.dataset.datestring &&
  1530. warningDateChild.dataset.timestring) ||
  1531. warningDateChild.title
  1532. ) {
  1533. let tempDate =
  1534. warningDateChild.dataset.datestring &&
  1535. warningDateChild.dataset.timestring
  1536. ? `${warningDateChild.dataset.datestring} ${warningDateChild.dataset.timestring}`
  1537. : warningDateChild.title;
  1538. tempDate = fixDateStringChars(tempDate);
  1539.  
  1540. gottenDate = Math.floor(Date.parse(tempDate) / 1000);
  1541. }
  1542.  
  1543. if (!gottenDate) {
  1544. continue;
  1545. }
  1546.  
  1547. let gottenPoints =
  1548. Number(dataRow.querySelector("td.warningPoints")?.innerText) || 0;
  1549.  
  1550. for (let i = 0; i < gottenPoints; i++) {
  1551. // if have points add to warnings (0 - skip)
  1552. warnings.push({
  1553. id: 0,
  1554. timestamp: gottenDate,
  1555. });
  1556. }
  1557. }
  1558. } catch (err) {
  1559. console.error("[LZTStats.getWarningsStats]", err);
  1560. }
  1561.  
  1562. return warnings;
  1563. }
  1564.  
  1565. // * UTILS
  1566. function findYouInEl(likeTextEl) {
  1567. // find "You" in el for sympathies and likes
  1568. return (
  1569. likeTextEl.innerText.includes("Это нравится Вам") ||
  1570. likeTextEl.innerText.includes("Вам нравится это") ||
  1571. likeTextEl.innerText.includes("You like this") ||
  1572. likeTextEl.innerText.includes("You and") ||
  1573. likeTextEl.innerText.includes("You,")
  1574. );
  1575. }
  1576.  
  1577. function fixDateStringChars(datestring) {
  1578. // replace russian shortcut to eng shortcut
  1579. // and replace preposition in text to void
  1580. return datestring
  1581. .toLowerCase()
  1582. .replace("янв", "jan")
  1583. .replace("фев", "feb")
  1584. .replace("мар", "mar")
  1585. .replace("апр", "apr")
  1586. .replace("май", "may")
  1587. .replace("июн", "jun")
  1588. .replace("июл", "jul")
  1589. .replace("авг", "aug")
  1590. .replace("сен", "sep")
  1591. .replace("окт", "oct")
  1592. .replace("ноя", "nov")
  1593. .replace("дек", "dec")
  1594. .replace("в ", "")
  1595. .replace("at ", "");
  1596. }
  1597.  
  1598. function getUserId() {
  1599. return XenForo.visitor?.user_id;
  1600. }
  1601.  
  1602. function getTimestamp() {
  1603. return Math.floor(Date.now() / 1000);
  1604. }
  1605.  
  1606. function getFormattedTimestamp() {
  1607. // It's used so that the chart starts from the beginning of the current day, and does not go for the past 24 hours
  1608. const useFormatted = GM_getValue("stats-time-format", false); // false - for the last 24 hours, true - since the beginning of the day (from 00:00 to 23:59)
  1609. let timestamp = getTimestamp();
  1610. if (!useFormatted) {
  1611. return timestamp;
  1612. }
  1613.  
  1614. const currentDate = new Date(timestamp * 1000); // multiply 1000 to bring it back in MS
  1615. const timeFromZero = currentDate.setHours(23, 59, 59, 59);
  1616. timestamp = timeFromZero / 1000;
  1617.  
  1618. return timestamp;
  1619. }
  1620.  
  1621. function getHTMLFromString(HTMLString) {
  1622. const parser = new DOMParser();
  1623. return parser.parseFromString(HTMLString, "text/html");
  1624. }
  1625.  
  1626. function roundMinutes(date) {
  1627. // https://stackoverflow.com/questions/7293306/how-to-round-to-nearest-hour-using-javascript-date-object
  1628. date.setHours(date.getHours() + Math.round(date.getMinutes() / 60));
  1629. date.setMinutes(0, 0, 0); // Resets also seconds and milliseconds
  1630.  
  1631. return date;
  1632. }
  1633.  
  1634. function formatDate(dateString) {
  1635. // format X hours, minutes and etc to 0X. Ex. 1 -> 01, 12 -> 12
  1636. return ("0" + dateString).slice(-2);
  1637. }
  1638.  
  1639. function getThreadIdByURL(threadURL) {
  1640. const threadId = threadURL.match(FIND_THREAD_ID)?.[1]?.split("/")?.[0];
  1641. return Number(threadId) || 0;
  1642. }
  1643.  
  1644. function getPostIdByURL(postURL) {
  1645. const postId = postURL.match(FIND_POST_ID)?.[1]?.split("/")?.[0];
  1646. return Number(postId) || 0;
  1647. }
  1648.  
  1649. function getProfilePostIdByURL(postURL) {
  1650. const postId = postURL.match(FIND_PROFILE_POST_ID)?.[1]?.split("/")?.[0];
  1651. return Number(postId) || 0;
  1652. }
  1653.  
  1654. function getUserIdByURL(userURL) {
  1655. const matched =
  1656. userURL.match(FIND_USER_POST) || userURL.match(FIND_USER_ID);
  1657. const userId = matched?.[1]?.split("/")?.[0];
  1658. return String(userId) || 0;
  1659. }
  1660.  
  1661. function getCommentPostIdByURL(postURL) {
  1662. const postId = postURL.match(FIND_COMMENT_POST_ID)?.[1]?.split("/")?.[0];
  1663. return Number(postId) || 0;
  1664. }
  1665.  
  1666. function getForumIdByURL(forumURL) {
  1667. const forumId = forumURL.match(FIND_FORUM_ID)?.[1]?.split("/")?.[0];
  1668. return String(forumId) || 0;
  1669. }
  1670.  
  1671. function getPostIdByFullURL(postURL) {
  1672. const postId = postURL
  1673. .match(FIND_POST_ID_IN_FULL_URL)?.[1]
  1674. ?.split("/")?.[0];
  1675. return Number(postId) || 0;
  1676. }
  1677.  
  1678. function getChatIdByURL(chatURL) {
  1679. const chatId = chatURL.match(FIND_CHAT_ID)?.[1]?.split("/")?.[0];
  1680. return Number(chatId) || 0;
  1681. }
  1682.  
  1683. function getConversationIdByURL(conversationURL) {
  1684. const conversationId = conversationURL
  1685. .match(FIND_CONVERSATION_ID)?.[1]
  1686. ?.split("/")?.[0];
  1687. return Number(conversationId) || 0;
  1688. }
  1689.  
  1690. function calcSumByTime(data, timeFormat = DAY_IN_SECS, maxLength = 7) {
  1691. let sepatedData = {};
  1692. let timestamp = getFormattedTimestamp();
  1693.  
  1694. while (Object.keys(sepatedData).length < maxLength) {
  1695. const temp = data.filter(
  1696. (m) => m.timestamp > timestamp - timeFormat && m.timestamp < timestamp
  1697. );
  1698. const date = roundMinutes(new Date((timestamp - timeFormat) * 1000));
  1699. let dateString = `${formatDate(date.getDate())}.${formatDate(
  1700. date.getMonth() + 1
  1701. )}`;
  1702. if (timeFormat === HOUR_IN_SECS) {
  1703. dateString = `${formatDate(date.getHours())}:${formatDate(
  1704. date.getMinutes()
  1705. )}`;
  1706. }
  1707.  
  1708. sepatedData[dateString] = temp;
  1709. timestamp -= timeFormat;
  1710. }
  1711.  
  1712. return sepatedData;
  1713. }
  1714.  
  1715. function getSumValuesByTime(sumData) {
  1716. return Object.values(sumData)
  1717. .map((m) => m.length)
  1718. .reverse();
  1719. }
  1720.  
  1721. function getTimeArray(timeFormat = DAY_IN_SECS, maxLength = 7) {
  1722. let timeArray = [];
  1723. let timestamp = getFormattedTimestamp();
  1724. const firstDateFormat = timeFormat === MONTH_IN_SECS ? 0 : timeFormat;
  1725.  
  1726. for (let i = 0; i < maxLength; i++) {
  1727. const date = roundMinutes(new Date((timestamp - firstDateFormat) * 1000));
  1728. if (timeFormat === HOUR_IN_SECS) {
  1729. timeArray.push(
  1730. `${formatDate(date.getHours())}:${formatDate(date.getMinutes())}`
  1731. );
  1732. } else {
  1733. timeArray.push(
  1734. `${formatDate(date.getDate())}.${formatDate(date.getMonth() + 1)}`
  1735. ); // month start with 0
  1736. }
  1737.  
  1738. timestamp -= timeFormat;
  1739. }
  1740.  
  1741. return timeArray;
  1742. }
  1743.  
  1744. /**
  1745. * Save stats in GM Storage
  1746. *
  1747. * @param {object} stats - object of stats (ex.: id, message, timestamp)
  1748. * @param {string} valueName - name of storage
  1749. */
  1750. function saveStats(stats, valueName) {
  1751. console.debug("[LZTStats.saveStats]", stats);
  1752. let oldData = GM_getValue(valueName);
  1753. if (oldData === undefined) {
  1754. oldData = [];
  1755. }
  1756.  
  1757. oldData.push(stats);
  1758.  
  1759. GM_setValue(valueName, oldData);
  1760. }
  1761.  
  1762. const sleep = (m) => new Promise((r) => setTimeout(r, m));
  1763.  
  1764. // * FILES
  1765. function downloadJSONFile(data, name) {
  1766. const blob = new Blob([data], {
  1767. type: "application/json",
  1768. });
  1769. const link = document.createElement("a");
  1770. link.href = window.URL.createObjectURL(blob);
  1771. link.download = `${name}.json`;
  1772. link.click();
  1773. return link;
  1774. }
  1775.  
  1776. async function uploadJSONFile() {
  1777. const input = document.createElement("input");
  1778. input.type = "file";
  1779. input.accept = "application/json";
  1780. input.click();
  1781.  
  1782. const file = await new Promise((resolve) => {
  1783. input.onchange = () => {
  1784. resolve(input.files[0]);
  1785. };
  1786. });
  1787.  
  1788. const reader = new FileReader();
  1789. reader.readAsText(file);
  1790.  
  1791. return await new Promise((resolve) => {
  1792. reader.onload = () => {
  1793. resolve(reader.result);
  1794. };
  1795. reader.onerror = (e) => {
  1796. console.error(i18n.get("upload-file-error"), e);
  1797. resolve(false);
  1798. };
  1799. });
  1800. }
  1801.  
  1802. // * MAIN
  1803. function init() {
  1804. initHooks();
  1805. initMenuBtn();
  1806. }
  1807.  
  1808. init();
  1809. })();