NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name Fimfiction Comment Bookmarks
// @description Comment bookmarking on Fimfiction.net
// @version 1.0.8
// @author Marker
// @license MIT
// @namespace https://github.com/marktaiwan/
// @homepageURL https://github.com/marktaiwan/Fimfiction-Comment-Bookmarks
// @supportURL https://github.com/marktaiwan/Fimfiction-Comment-Bookmarks/issues
// @include https://www.fimfiction.net/*
// @grant none
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_LABEL = 'markers_comment_bookmarks';
const CATEGORY_LIST = ['story', 'blog', 'user', 'group', 'group_forum'];
const DB_VERSION = 1;
const BC_SUPPORT = ('BroadcastChannel' in window);
/** Modified from https://gist.github.com/MoOx/8614711
*/
function composeElement(obj) {
/** https://gist.github.com/youssman/745578062609e8acac9f
* camelToDash('userId') => "user-id"
*/
function camelToDash(str) {
return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase();
}
let ele;
if (obj.tag !== undefined) {
ele = document.createElement(obj.tag);
if (obj.attributes !== undefined) {
for (const attr in obj.attributes) {
if (obj.attributes.hasOwnProperty(attr)) {
ele.setAttribute(camelToDash(attr), obj.attributes[attr]);
}
}
}
} else {
ele = document.createDocumentFragment();
}
if (obj.html !== undefined) ele.innerHTML = obj.html;
if (obj.text) ele.appendChild(document.createTextNode(obj.text));
if (Array.isArray(obj.children)) {
for (const child of obj.children) {
ele.appendChild((child instanceof window.HTMLElement) ? child : composeElement(child));
}
}
return ele;
}
const onReady = (() => {
const callbacks = [];
document.addEventListener('DOMContentLoaded', () => callbacks.forEach(fn => fn()), {once: true});
return (fn) => {
if (document.readyState == 'loading') {
callbacks.push(fn);
} else {
fn();
}
};
})();
function initCSS() {
const styleElement = document.createElement('style');
styleElement.id = `${SCRIPT_LABEL}-css`;
styleElement.type = 'text/css';
styleElement.innerHTML = ` /* Generated by userscript "FiM Comment Bookmark" */
/* colors */
#${SCRIPT_LABEL}--pop-up-wrapper {
--window-background: #f7f5f2;
--comment-body-color: #555;
--comment-body-background-color: hsla(0, 0%, 50%, 0.01);
--overlay-gradient: linear-gradient(
to bottom,
var(--comment-body-color) 70%,
var(--comment-body-background-color) 100%
);
--subject-font-color: #3073b9;
--border-color: #e6e6e6;
--pop-up-background: #fff;
--title-row-bg: #eee;
--title-row-bg-hover: #fafafa;
--button-generic-background: #258bd4;
--button-generic-background-hightlight: #409cdd;
--button-generic-color: #fff;
--category-story: #4caf50;
--category-blog: #9c27b0;
--category-forum: #f44336;
--category-profile: #ff9800;
--category-story-highlight: #5cb75f;
--category-blog-highlight: #ae2bc5;
--category-forum-highlight: #f6574c;
--category-profile-highlight: #ffa31a;
}
#${SCRIPT_LABEL}--pop-up-wrapper.nightmode {
--window-background: #222935;
--comment-body-color: #a3abc3;
--comment-body-background-color: rgba(255, 255, 255, 0.04);
--subject-font-color: #52b7ff;
--border-color: #313b4c;
--pop-up-background: #29313f;
--title-row-bg: #2f3848;
--title-row-bg-hover: #394257;
--button-generic-background: #5088b5;
--button-generic-background-hightlight: #457aa5;
--category-story: #449c47;
--category-blog: #89229b;
--category-forum: #f32a1b;
--category-profile: #e68a00;
--category-story-highlight: #3d8a3f;
--category-blog-highlight: #761d86;
--category-forum-highlight: #e91b0c;
--category-profile-highlight: #cc7a00;
}
/* /colors */
.${SCRIPT_LABEL}-options-button.active[data-filter-type="story"] {
background-color: var(--category-story);
}
.${SCRIPT_LABEL}-options-button.active[data-filter-type="blog"] {
background-color: var(--category-blog);
}
.${SCRIPT_LABEL}-options-button.active[data-filter-type="forum"] {
background-color: var(--category-forum);
}
.${SCRIPT_LABEL}-options-button.active[data-filter-type="profile"] {
background-color: var(--category-profile);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="story"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="story"] {
background-color: var(--category-story-highlight);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="blog"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="blog"] {
background-color: var(--category-blog-highlight);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="forum"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="forum"] {
background-color: var(--category-forum-highlight);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="profile"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="profile"] {
background-color: var(--category-profile-highlight);
}
/* scroller */
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar {
width: 6px;
height: 6px;
background-color: transparent;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb {
background-color: #888;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-track {
background-color: #ddd;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb:hover {
background-color: #a3a3a3;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb:active {
background-color: #6d6d6d;
}
.${SCRIPT_LABEL}-flex-grow {
flex-grow: 1;
}
.${SCRIPT_LABEL}--window-content select, .${SCRIPT_LABEL}--window-content input[type="text"] {
background-color: var(--pop-up-background);
color: inherit;
font-size: inherit;
border: 1px groove;
padding: 0px 4px;
}
.${SCRIPT_LABEL}--window-content input[type="text"] {
padding: 2px 4px;
}
#${SCRIPT_LABEL}--pop-up-wrapper {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
display: flex;
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
background-color: rgba(0,0,0,0.5);
}
#${SCRIPT_LABEL}--pop-up {
max-width: 1200px;
width: 90vw;
}
#${SCRIPT_LABEL}--pop-up > div {
width: 100%;
}
.${SCRIPT_LABEL}--window-content {
display: flex;
flex-direction: column;
overflow: auto;
padding: 10px 18px;
height: calc(100vh - 120px);
background-color: var(--window-background);
}
.${SCRIPT_LABEL}-list {
display: flex;
flex-direction: column;
min-height: 400px;
border: 1px solid rgba(0,0,0,0.2);
background-color: var(--pop-up-background);
}
.${SCRIPT_LABEL}-list-header {
border-bottom: 1px solid var(--border-color);
padding: 3px 8px;
line-height: 2;
font-size: 16px;
display: flex;
justify-content: flex-start;
align-items: baseline;
flex-wrap: wrap;
}
.${SCRIPT_LABEL}-list-header > * {
margin: 0px 2px;
}
#${SCRIPT_LABEL}-list-container {
overflow: auto;
overflow-anchor: none;
}
#${SCRIPT_LABEL}-list-container > :first-child {
border-top-style: none;
}
#${SCRIPT_LABEL}-list-container > * {
border-top: 1px solid var(--border-color);
}
#${SCRIPT_LABEL}-list-container li.comment {
display: block;
padding: 8px;
line-height: 1.5em;
overflow: hidden;
position: relative;
padding-right: 100px;
}
#${SCRIPT_LABEL}-list-container .date>span {
font-size: 0.9em;
}
#${SCRIPT_LABEL}-list-container .date {
position: absolute;
top: 50%;
right: 10px;
margin-top: -7px;
font-size: 0.9em;
}
#${SCRIPT_LABEL}-list-container .comment_information {
display: inherit;
padding-right: 50px;
min-height: 32px;
}
#${SCRIPT_LABEL}-list-container .comment_information > a.subject {
font-size: 1.2em;
color: var(--subject-font-color);
}
#${SCRIPT_LABEL}-list-container .comment .comment_information .buttons {
position: absolute;
right: 0px;
user-select: none;
-moz-user-select: none;
}
#${SCRIPT_LABEL}-list-container .comment:hover .comment_information .buttons {
opacity: 1;
}
#${SCRIPT_LABEL}-list-container .comment .comment_information .buttons a.disabled {
opacity: 0.2;
cursor: default;
}
#${SCRIPT_LABEL}-pagination {
border-bottom: 1px solid var(--border-color);
padding: 3px 8px;
user-select: none;
-moz-user-select: none;
}
.${SCRIPT_LABEL}-pagination-link {
margin: 0px 2px;
}
.${SCRIPT_LABEL}-pagination-link.disabled {
color: unset;
cursor: unset;
}
.${SCRIPT_LABEL}-pagination-link.disabled:hover {
text-decoration: none;
}
.${SCRIPT_LABEL}-comment-author-span {
font-size: 1em;
}
.${SCRIPT_LABEL}-comment-author {
color: unset;
white-space: nowrap;
}
.${SCRIPT_LABEL}-divider {
margin: 0px 4px;
}
.${SCRIPT_LABEL}-snippet-wrapper {
position: relative;
background-color: var(--comment-body-background-color);
box-shadow: inset 1px 1px 5px 0px rgba(0, 0, 0, 0.05);
}
.${SCRIPT_LABEL}-snippet {
overflow: hidden;
max-height: 80px;
white-space: pre-line;
padding: 0.4em;
color: var(--comment-body-color);
}
.${SCRIPT_LABEL}-snippet.expanded {
max-height: unset;
}
.${SCRIPT_LABEL}-snippet.overlay {
transition: background-color 0.5s cubic-bezier(0.5, 0, 0, 1);
-webkit-background-clip: text;
background-clip: text;
background-image: var(--overlay-gradient);
background-color: transparent;
color: transparent;
cursor: pointer;
user-select: none;
-moz-user-select: none;
}
.${SCRIPT_LABEL}-snippet.overlay:hover {
background-color: var(--comment-body-color);
}
.${SCRIPT_LABEL}-options-bar {
display: flex;
flex-wrap: wrap;
margin-bottom: 4px;
}
.${SCRIPT_LABEL}-options-button {
color: var(--comment-body-color);
background-color: var(--pop-up-background);
cursor: pointer;
user-select: none;
-moz-user-select: none;
font-size: .8125rem;
font-weight: normal;
margin: 2px 0px;
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
display: inline-block;
text-align: center;
line-height: 16px;
outline: none;
}
.${SCRIPT_LABEL}-options-button.active {
color: var(--button-generic-color);
background-color: var(--button-generic-background);
}
.${SCRIPT_LABEL}-options-button.clickable:hover, .${SCRIPT_LABEL}-options-button.active:hover, .${SCRIPT_LABEL}-options-button:focus {
background-color: var(--button-generic-background-hightlight);
color: var(--button-generic-color);
}
.${SCRIPT_LABEL}-article-container {
display: block;
}
.${SCRIPT_LABEL}-article-container.expanded .${SCRIPT_LABEL}-comment-container {
display: initial;
}
#${SCRIPT_LABEL}-list-container .${SCRIPT_LABEL}-comment-container li.comment{
padding-left: 24px;
}
.${SCRIPT_LABEL}-title-row {
padding: 2px 0px;
font-size: 18px;
border-left-style: solid;
border-left-width: 4px;
background-color: var(--title-row-bg);
cursor: pointer;
user-select: none;
-moz-user-select: none;
}
.${SCRIPT_LABEL}-title-row:hover {
background-color: var(--title-row-bg-hover);
}
.${SCRIPT_LABEL}-article-container .expand-indicator:before {
content: '\\f0da';
}
.${SCRIPT_LABEL}-article-container.expanded .expand-indicator:before {
content: '\\f0d7';
}
.${SCRIPT_LABEL}-preview.quote_container {
position: initial;
z-index: initial;
margin-top: 8px;
}
.preview_active .${SCRIPT_LABEL}-preview.quote_container {
display: block;
}
#${SCRIPT_LABEL}--pop-up-wrapper.hidden,
.${SCRIPT_LABEL}-article-container .${SCRIPT_LABEL}-comment-container,
.${SCRIPT_LABEL}-comment-container .hidden-when-nested,
.preview_active .${SCRIPT_LABEL}-snippet-wrapper,
.preview_active .${SCRIPT_LABEL}-comment-author-span,
.${SCRIPT_LABEL}-preview.quote_container,
.${SCRIPT_LABEL}-preview.quote_container .buttons {
display: none;
}
div.corner-stubb {
border-width: 5px;
border-style: solid;
border-right-color: transparent;
border-bottom-color: transparent;
top: 0px;
left: 0px;
width: 0px;
height: 0px;
position: absolute;
}
.${SCRIPT_LABEL}-comment-container div.corner-stubb {
border-style: none;
}
[data-category-indicator="story"] {
border-color: var(--category-story);
}
[data-category-indicator="blog"] {
border-color: var(--category-blog);
}
[data-category-indicator="group_forum"] {
border-color: var(--category-forum);
}
[data-category-indicator="user"], [data-category-indicator="group"] {
border-color: var(--category-profile);
}
/* Layout fixes for small screens/mobile view */
@media (max-width: 700px) {
.${SCRIPT_LABEL}-preview.quote_container .comment_information {
display: flex;
padding-right: initial;
}
.${SCRIPT_LABEL}-preview.quote_container .comment {
background-color: unset;
}
.quote_container .data {
margin: 0px;
}
#${SCRIPT_LABEL}-list-container .${SCRIPT_LABEL}-comment-container li.comment, #${SCRIPT_LABEL}-list-container li.comment {
padding: 4px;
}
#${SCRIPT_LABEL}-list-container .date {
display: none;
}
}`;
document.head.appendChild(styleElement);
}
function initLocalStorage() {
if (!localStorage[SCRIPT_LABEL]) localStorage[SCRIPT_LABEL] = '{}';
// default settings
const store = JSON.parse(localStorage[SCRIPT_LABEL]);
const defaults = {
'comment_collapse': true,
'comment_pagination': true,
'sort-method': 'post',
'sort-direction': 'asc',
'category_filter': {story: true, blog: true, forum: true, profile: true}
};
for (const prop in defaults) {
if (!store.hasOwnProperty(prop)) setLocalStorage(prop, defaults[prop]);
}
}
function getLocalStorage(prop) {
const store = JSON.parse(localStorage[SCRIPT_LABEL]);
return store[prop];
}
function setLocalStorage(prop, val) {
const store = JSON.parse(localStorage[SCRIPT_LABEL]);
store[prop] = val;
localStorage[SCRIPT_LABEL] = JSON.stringify(store);
return val;
}
const getDB = (() => {
let dbPromise = null;
return () => {
return (dbPromise) ? dbPromise : dbPromise = new Promise((resolve, reject) => {
const dbRequest = window.indexedDB.open(SCRIPT_LABEL, DB_VERSION);
dbRequest.onsuccess = (e) => {
const dbConnection = e.target.result;
dbConnection.onclose = () => dbPromise = null;
dbConnection.onversionchange = () => dbConnection.close();
resolve(dbConnection);
};
dbRequest.onerror = (e) => reject(e.target.error);
dbRequest.onupgradeneeded = (e) => {
const db = e.target.result;
if (e.oldVersion < 1) {
/* --- Initialize database --- */
// Record includes: {category, commentId, commentListId, commentTimestamp, bookmarkTimestamp, commentAuthor, commentAuthorId, commentSnippet, chapterName}
// commentId from different categories do not share the same counter,
// so we cannot use them to sort chronologically and must use the timestamps
const bookmarkStore = db.createObjectStore('bookmarkStore', {keyPath: ['commentId', 'category']});
bookmarkStore.createIndex('commentTimestamp', 'commentTimestamp');
bookmarkStore.createIndex('bookmarkTimestamp', 'bookmarkTimestamp');
// Record includes: {commentListId, category, title, author, comments[]}
// 'title' would be the title of the story/blog post/forum thread.
// 'author' corresponds to the user that created the story/blog post/profile, or corresponds to the group in the case of forum posts.
const articleStore = db.createObjectStore('articleStore', {keyPath: ['commentListId', 'category']});
articleStore.createIndex('articleTitle', 'title');
articleStore.createIndex('articleAuthor', 'author');
}
};
});
};
})();
async function clearDB() {
const db = await getDB();
const tx = db.transaction(db.objectStoreNames, 'readwrite');
for (const storeName of tx.objectStoreNames) {
const store = tx.objectStore(storeName);
const request = store.clear();
request.onerror = (e) => console.error(e.target.error);
}
tx.oncomplete = () => {
broadcastBookmarkChangeEvent({type: 'dbClear'});
window.StyledAlert('All bookmarks deleted.');
};
}
async function exportDB() {
const db = await getDB();
const tx = db.transaction(db.objectStoreNames, 'readonly');
const storeArray = [];
for (const storeName of tx.objectStoreNames) {
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onsuccess = (e) => storeArray.push({name: storeName, records: e.target.result});
request.onerror = (e) => console.error(e.target.error);
}
tx.oncomplete = () => {
const blob = new Blob([JSON.stringify({version: db.version, stores: storeArray})], {type: 'application/json'});
const anchor = document.createElement('a');
anchor.setAttribute('href', URL.createObjectURL(blob));
anchor.setAttribute('download', 'FiM Comment Bookmarks.json');
document.body.appendChild(anchor); // Won't work in Firefox otherwise
anchor.click();
anchor.remove();
};
tx.onerror = (e) => console.error(e.target.error);
}
function importDB() {
const filePicker = composeElement({tag: 'input', attributes: {type: 'file'}});
filePicker.addEventListener('change', () => {
const reader = new FileReader();
reader.readAsText(filePicker.files[0]);
reader.onload = async (e) => {
const parsedDB = JSON.parse(e.target.result);
if (parsedDB.version !== DB_VERSION) {
throw new Error(`Database version mismatch:
The script only support imports with the same database version.
Current version: ${DB_VERSION}
Imported version: ${parsedDB.version}`);
}
const bookmarkStore = parsedDB.stores.find(store => store.name == 'bookmarkStore').records;
const articleStore = parsedDB.stores.find(store => store.name == 'articleStore').records;
for (const entry of bookmarkStore) {
const {commentId, category, commentListId} = entry;
if (await checkBookmark(commentId, category)) continue;
const article = articleStore.find(article => (article.commentListId == commentListId && article.category == category));
await setBookmark(entry, article);
}
broadcastBookmarkChangeEvent({type: 'dbImport'});
window.StyledAlert('Comment bookmarks successfully imported.');
};
}, {once: true});
filePicker.click();
}
function setBookmark(bookmarkEntry, articleRecord) {
return new Promise(async (resolve, reject) => {
const {commentId, commentListId, category} = bookmarkEntry;
articleRecord = {...articleRecord, commentListId, category};
const oldArticle = await getArticleRecord(commentListId, category);
articleRecord.comments = (oldArticle) ? oldArticle.comments : [];
// insert into sorted list
let index;
for (index = 0; index < articleRecord.comments.length; ++index) {
if (commentId < articleRecord.comments[index]) break;
}
articleRecord.comments.splice(index, 0, commentId);
const db = await getDB();
const tx = db.transaction(['bookmarkStore', 'articleStore'], 'readwrite');
const bookmarkRequest = tx.objectStore('bookmarkStore').add(bookmarkEntry);
const articleRequest = tx.objectStore('articleStore').put(articleRecord);
tx.oncomplete = () => {
resolve();
};
bookmarkRequest.onerror = (e) => {
console.warn('Bookmark entry already exist in database', bookmarkEntry);
reject(e.target.error);
};
articleRequest.onerror = (e) => reject(e.target.error);
});
}
async function updateBookmarkSnippet(commentId, category, newComment) {
const bookmarkEntry = await getBookmark(commentId, category);
const newSnippet = plaintext(newComment);
if (bookmarkEntry.commentSnippet == newSnippet) return;
bookmarkEntry.commentSnippet = newSnippet;
const db = await getDB();
const tx = db.transaction('bookmarkStore', 'readwrite');
tx.objectStore('bookmarkStore')
.put(bookmarkEntry)
.onerror = (e) => e.target.error;
tx.oncomplete = () => broadcastBookmarkChangeEvent({type: 'change', record: {commentId, category}});
}
function deleteBookmark(commentId, category) {
return new Promise(async (resolve, reject) => {
const bookmark = await getBookmark(commentId, category);
if (!bookmark) throw {name: 'InvalidStateError'};
const article = await getArticleRecord(bookmark.commentListId, category);
if (!article) throw {name: 'InvalidStateError'};
// remove commentId from article's comments array
const indexPosition = article.comments.findIndex(ele => ele == commentId);
if (indexPosition < 0) {
console.warn('commentId not found in articleRecord. Attempt to delete bookmark anyway.');
} else {
article.comments.splice(indexPosition, 1);
}
const db = await getDB();
const tx = db.transaction(['bookmarkStore', 'articleStore'], 'readwrite');
const bookmarkRequest = tx.objectStore('bookmarkStore').delete([commentId, category]);
const articleRequest = (article.comments.length > 0)
? tx.objectStore('articleStore').put(article)
: tx.objectStore('articleStore').delete([article.commentListId, article.category]);
tx.oncomplete = () => {
broadcastBookmarkChangeEvent({type: 'remove', record: {commentId, category}});
resolve();
};
bookmarkRequest.onerror = (e) => reject(e.target.error);
articleRequest.onerror = (e) => reject(e.target.error);
});
}
function checkBookmark(commentId, category) {
return new Promise(async (resolve) => {
const db = await getDB();
const tx = db.transaction('bookmarkStore', 'readwrite');
tx.objectStore('bookmarkStore')
.count(IDBKeyRange.only([commentId, category]))
.onsuccess = (e) => resolve(e.target.result !== 0);
});
}
function getBookmark(commentId, category) {
return new Promise(async (resolve) => {
const db = await getDB();
const tx = db.transaction('bookmarkStore', 'readonly');
tx.objectStore('bookmarkStore')
.get([commentId, category])
.onsuccess = (e) => resolve(e.target.result); // result is 'undefined' if no matching record
});
}
function getArticleRecord(commentListId, category) {
return new Promise(async (resolve) => {
const db = await getDB();
const tx = db.transaction('articleStore', 'readonly');
tx.objectStore('articleStore')
.get([commentListId, category])
.onsuccess = (e) => resolve(e.target.result);
});
}
function getAllRecords(store, index) {
return new Promise(async (resolve) => {
const db = await getDB();
const tx = db.transaction(store, 'readonly');
if (index) {
tx.objectStore(store)
.index(index).getAll()
.onsuccess = (e) => resolve(e.target.result);
} else {
tx.objectStore(store)
.getAll()
.onsuccess = (e) => resolve(e.target.result);
}
});
}
function broadcastBookmarkChangeEvent(data = {}) {
// notify other open tabs of database change
if (BC_SUPPORT) {
const bc = new BroadcastChannel(SCRIPT_LABEL);
bc.postMessage({lastModified: data});
bc.close();
} else {
// localStorage fallback, dispatch event on current document
setLocalStorage('db_last_modified', {timestamp: Date.now(), data: data});
const e = new StorageEvent('storage');
e.initStorageEvent('storage', false, false, SCRIPT_LABEL);
window.dispatchEvent(e);
}
}
const getPageCategory = (() => {
let category;
const fn = () => {
// identiy whether the current page is a forum thread, blog post, or story page by regexing the URL
const re = new RegExp('^https://www.fimfiction.net/(.+?)/');
const result = re.exec(document.location.href);
if (result === null || !CATEGORY_LIST.includes(result[1])) {
throw new Error('Unexpected category for URL:' + document.location.href);
}
if (result[1] == 'group' && document.querySelector('.comment_list[data-type="comments_group_thread"]')) {
category = 'group_forum';
} else {
category = result[1];
}
return category;
};
return () => category || fn();
})();
function toggleOn(commentId) {
document.querySelectorAll(`.${SCRIPT_LABEL}-bookmark-button[data-comment-id="${commentId}"]`).forEach(button => {
button.dataset.bookmarked = '1';
button.querySelector('i').classList.add('fa-bookmark');
button.querySelector('i').classList.remove('fa-bookmark-o');
});
}
function toggleOff(commentId) {
document.querySelectorAll(`.${SCRIPT_LABEL}-bookmark-button[data-comment-id="${commentId}"]`).forEach(button => {
button.dataset.bookmarked = '0';
button.querySelector('i').classList.add('fa-bookmark-o');
button.querySelector('i').classList.remove('fa-bookmark');
});
}
const addButton = (() => {
const callbackId = new WeakMap();
const io = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
const comment = entry.target;
if (entry.isIntersecting) {
const id = window.requestAnimationFrame(() => {
callbackId.delete(comment);
observer.unobserve(comment);
comment.removeAttribute(`${SCRIPT_LABEL}-observer-pending`);
execAddButton(comment);
});
callbackId.set(comment, id);
} else {
const id = callbackId.get(comment);
window.cancelAnimationFrame(id);
callbackId.delete(comment);
}
});
}, {rootMargin: '100px'});
function execAddButton(comment) {
const commentId = Number.parseInt(comment.dataset.comment_id);
const category = getPageCategory();
checkBookmark(commentId, category).then(isBookmarked => {
if (isBookmarked) {
toggleOn(commentId);
updateBookmarkSnippet(commentId, category, comment);
} else {
toggleOff(commentId);
}
});
}
return (comment) => {
const buttons = comment.querySelector('.comment_information .buttons');
if (buttons === null || comment.closest(`#${SCRIPT_LABEL}-list-container`)) return; // deleted messages or is bookmark preview
const commentId = Number.parseInt(comment.dataset.comment_id);
const category = getPageCategory();
if (!buttons.querySelector(`.${SCRIPT_LABEL}-bookmark-button`)) {
const anchor = composeElement({
tag: 'a',
attributes: {title: 'Bookmark this comment', class: `${SCRIPT_LABEL}-bookmark-button`, dataCommentId: commentId, dataCommentCategory: category},
children: [{
tag: 'i',
attributes: {class: 'fa fa-fw'}
},{
tag: 'span',
text: 'Bookmark'
}]
});
buttons.insertBefore(anchor, buttons.firstChild);
}
if (!comment.hasAttribute(`${SCRIPT_LABEL}-observer-pending`)) {
comment.setAttribute(`${SCRIPT_LABEL}-observer-pending`, '');
io.observe(comment);
}
};
})();
function commentButtonHandler(event) {
const button = event.target.closest(`.${SCRIPT_LABEL}-bookmark-button`);
if (!button || !button.hasAttribute('data-bookmarked')) return;
const comment = button.closest('.comment');
const commentList = comment.closest('.comment_list');
const bookmarkEntry = {};
const articleRecord = {};
bookmarkEntry.commentId = Number.parseInt(comment.dataset.comment_id);
bookmarkEntry.commentListId = Number.parseInt(commentList.dataset.item);
bookmarkEntry.category = button.dataset.commentCategory;
if (!CATEGORY_LIST.includes(bookmarkEntry.category) || window.isNaN(bookmarkEntry.commentId)) {
throw new Error('Unexpected value in ', bookmarkEntry);
}
checkBookmark(bookmarkEntry.commentId, bookmarkEntry.category).then(isBookmarked => {
if (!isBookmarked) {
try {
bookmarkEntry.commentTimestamp = Number.parseInt(comment.querySelector('[data-time]').dataset.time) * 1000;
} catch (exception) {
if (!(exception instanceof TypeError)) throw exception;
/* fallback for when recently made comments lacks the 'data-time' attribute in their timestamp. Oh joy...
* e.g. <span title="Tuesday 1st of January 2019 @4:20am">Tuesday</span>
*/
const monthLookup = {
January: '01',
February: '02',
March: '03',
April: '04',
May: '05',
June: '06',
July: '07',
August: '08',
September: '09',
October: '10',
November: '11',
December: '12'};
const span = comment.querySelector('.meta a>[title]'); // most liable to break
const rx = new RegExp('^\\w+ (\\d+)(?:st|nd|rd|th) of (\\w+) (\\d+) @(\\d+):(\\d+)(am|pm)$');
const results = rx.exec(span.getAttribute('title'));
const day = results[1].padStart(2, '0');
const month = monthLookup[results[2]];
const year = results[3];
let hour = (Number.parseInt(results[4]) + (results[6] == 'pm' ? 12 : 0)).toString().padStart(2, '0');
const minute = results[5];
if (hour == '24') {
// TIL there is no such thing as 2019-01-15T24:58,
// change that to 2019-01-16T00:58 instead.
hour = '00';
const date = new Date(`${year}-${month}-${day}T${hour}:${minute}`);
date.setDate(date.getDate() + 1); // no need to worry about end-of-month, setDate takes that into account
bookmarkEntry.commentTimestamp = date.getTime();
} else {
bookmarkEntry.commentTimestamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}`);
}
}
bookmarkEntry.bookmarkTimestamp = Date.now();
bookmarkEntry.commentAuthor = comment.querySelector('.author .name').textContent;
bookmarkEntry.commentAuthorId = Number.parseInt(new RegExp('^/user/([0-9]+)/[^/]*$').exec(comment.querySelector('.author .name').getAttribute('href'))[1]);
bookmarkEntry.commentSnippet = plaintext(comment);
switch (bookmarkEntry.category) {
/* For each category, commentListId corresponds to:
* story -> story id / chapter id
* group -> group id
* group_forum -> forum thread
* blog -> blog post
* user -> user id
*/
case 'story': {
const storyName = document.querySelector('meta[property="og:title"]').content;
let chapterName, storyAuthor;
if (document.querySelector('article.story_container') !== null) {
// title page
const anchor = comment.querySelector('.desktop a');
if (anchor) {
chapterName = anchor.textContent;
} else {
chapterName = null;
}
storyAuthor = document.querySelector('.user-page-header h1 a[href^="/user/"]').textContent;
} else {
// chapter page
bookmarkEntry.commentListId = Number.parseInt(document.querySelector('[data-story-id]').dataset.storyId);
chapterName = document.querySelector('#chapter_title').textContent;
storyAuthor = document.querySelector('.story-page-header .author a').textContent;
}
bookmarkEntry.chapterName = chapterName;
articleRecord.title = storyName;
articleRecord.author = storyAuthor;
break;
}
case 'group': {
const groupName = document.querySelector('.group_name').textContent.trim();
articleRecord.title = groupName;
articleRecord.author = groupName;
break;
}
case 'group_forum': {
const groupName = document.querySelector('.group_name').textContent.trim();
const subject = document.querySelectorAll('.group .breadcrumbs a')[1].textContent.trim();
bookmarkEntry.groupId = Number.parseInt(document.querySelector('.group-page').dataset.groupId);
articleRecord.title = subject;
articleRecord.author = groupName;
break;
}
case 'blog': {
const postTitle = document.querySelector('.blog-post-content-box h1 a[href^="/blog/"]').textContent;
const blogAuthor = document.querySelector('.blog-page .information_box a[href^="/user/"]').textContent;
articleRecord.title = postTitle;
articleRecord.author = blogAuthor;
break;
}
case 'user': {
const userName = document.querySelector('.user-page-header h1 a[href^="/user/"]').textContent;
articleRecord.title = userName;
articleRecord.author = userName;
break;
}
default: {
throw Error('Unexpected data-comment-category value: ' + bookmarkEntry.category);
}
}
// rudimentary error checking
for (const prop in bookmarkEntry) {
const val = bookmarkEntry[prop];
if (val === undefined || (val === null && prop !== 'chapterName') || (typeof val == 'number' && window.isNaN(val))) {
console.log(bookmarkEntry);
throw TypeError('bookmarkEntry contains undefined values');
}
}
for (const prop in articleRecord) {
const val = articleRecord[prop];
if (val === undefined || val === null || (typeof val == 'number' && window.isNaN(val))) {
console.log(articleRecord);
throw TypeError('articleRecord contains undefined values');
}
}
// everything checks out, update the button
setBookmark(bookmarkEntry, articleRecord).then(() => {
const {commentId, category} = bookmarkEntry;
toggleOn(commentId);
broadcastBookmarkChangeEvent({type: 'add', record: {commentId, category}});
});
} else {
deleteBookmark(bookmarkEntry.commentId, bookmarkEntry.category)
.then(() => toggleOff(bookmarkEntry.commentId))
.catch(e => {
if (e.name == 'InvalidStateError') {
console.log(e);
toggleOff(bookmarkEntry.commentId);
} else {
throw e;
}
});
}
});
}
function plaintext(comment) {
const body = comment.querySelector('.comment_data').cloneNode(true);
// remove inline comment previews
body.querySelectorAll('.comment.inline-quote').forEach(inlineComment => inlineComment.remove());
// quote links
for (const quote of body.querySelectorAll('.comment_quote_link')) {
quote.insertAdjacentText('afterbegin', '@');
quote.normalize();
}
// horizontal rule
for (const ele of body.querySelectorAll('hr')) {
ele.parentElement.replaceChild(document.createTextNode('\n------\n'), ele);
}
// process emotes, replace all <img> with its 'alt' attribute (e.g. :rainbowhuh:)
for (const emote of body.querySelectorAll('img.emoticon')) {
emote.parentElement.replaceChild(document.createTextNode(emote.alt), emote);
}
// process image embeds
for (const ele of body.querySelectorAll('.collapsed-image-container, .user_image')) {
ele.parentElement.replaceChild(document.createTextNode('[image embed]'), ele);
}
// process other embeds
for (const embed of body.querySelectorAll('.bbcode-embed, .oembed')) {
let prefix = '';
let suffix = '';
let ele;
if (embed.dataset.origin !== undefined) {
/* This should apply to:
* YouTube
* Gfycat
* Streamable
* Twitch
*/
prefix = embed.dataset.origin;
} else if (embed.dataset.provider == 'soundcloud') {
prefix = 'SoundCloud';
} else if ((ele = body.querySelector('.story-card .story_link'))) {
prefix = 'Fimfiction';
suffix = ': ' + ele.title;
}
if (prefix !== '') prefix += ' ';
ele = document.createElement('p');
ele.innerText = `[${prefix}embed${suffix}]`;
embed.parentElement.replaceChild(ele, embed);
}
// headings
for (const heading of body.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
/* eslint-disable no-fallthrough */
/* Yes. They are all meant to fallthrough */
let prefix = '';
switch (heading.tagName) {
case 'H6': prefix += '#';
case 'H5': prefix += '#';
case 'H4': prefix += '#';
case 'H3': prefix += '#';
case 'H2': prefix += '#';
case 'H1': prefix += '#';
}
/* eslint-enable no-fallthrough */
heading.insertAdjacentText('afterbegin', prefix + ' ');
heading.insertAdjacentText('beforeend', '\n');
heading.normalize();
}
// bullet list
for (const listItem of body.querySelectorAll('ul>li')) {
listItem.insertAdjacentText('beforebegin', ' - ');
listItem.insertAdjacentText('beforeend', '\n');
listItem.normalize();
}
// numbered list
for (const list of body.querySelectorAll('ol')) {
let i = 1;
for (const listItem of list.children) {
listItem.insertAdjacentText('beforebegin', ` ${i++}. `);
listItem.insertAdjacentText('beforeend', '\n');
listItem.normalize();
}
}
// linebreak for some elements
for (const ele of body.querySelectorAll('blockquote, ul, ol, code')) {
ele.insertAdjacentText('beforebegin', '\n');
ele.insertAdjacentText('afterend', '\n');
}
// process blockquotes, create a treewalker to iterate over all the text nodes in the comment,
// count the number of <blockquote> until comment root, and prepend '>' accordingly
let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const currentNode = walker.currentNode;
let node, firstTextNodeInLine;
if (!currentNode.parentElement.matches('blockquote *')) continue;
node = currentNode;
firstTextNodeInLine = true;
while (firstTextNodeInLine && node.parentElement.nodeName != 'BLOCKQUOTE') {
firstTextNodeInLine = (node == node.parentElement.firstChild || node.previousSibling.nodeName == 'BR');
node = node.parentElement;
}
if (currentNode.nodeValue == ''
|| (!currentNode.parentElement.matches('blockquote > *') && !firstTextNodeInLine)
|| (currentNode != currentNode.parentElement.firstChild
&& currentNode.previousSibling.nodeName != 'BR'
&& !currentNode.parentElement.matches('blockquote > ol, blockquote > ul'))
) continue; // Look on my works, and despair.
node = currentNode.parentElement;
while (node !== body) {
if (node.tagName == 'LI') break;
if (node.tagName == 'BLOCKQUOTE') {
currentNode.nodeValue = '>' + currentNode.nodeValue;
}
node = node.parentElement;
}
}
// double linkbreak after paragraph
for (const paragraph of body.querySelectorAll('p')) {
paragraph.insertAdjacentText('afterend', '\n\n');
}
// <br>
for (const br of body.querySelectorAll('br')) {
br.parentElement.replaceChild(document.createTextNode('\n'), br);
}
const textSegments = [];
walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
textSegments.push(walker.currentNode.nodeValue);
}
// remove leading and trailing white spaces, and remove excessive repeating linebreaks
return textSegments.join('').trim()
.replace(/(\n{3,})/g, '\n\n')
.replace(/[‘’]/g, '\'').replace(/[“”]/g, '"'); // because FUCK smart quotes
}
function checkNightmode() {
return (document.getElementById('stylesheetMain').href.indexOf('light') == -1);
}
function overlayClickHandler(e) {
e.target.classList.add('expanded');
e.target.classList.remove('overlay');
}
function applySnippetOverlay(ele) {
if (!ele
|| ele.closest('.comment').classList.contains('preview_active')
|| ele.classList.contains('overlay')
|| ele.classList.contains('expanded')) {
return;
}
const overflow = (ele.scrollHeight > ele.clientHeight || ele.scrollWidth > ele.clientWidth);
ele.classList.toggle('overlay', overflow);
if (overflow) {
ele.addEventListener('click', overlayClickHandler, {once: true});
} else {
ele.removeEventListener('click', overlayClickHandler);
}
}
const composeComment = (() => {
// This function could potentially be very time intensive.
// We can save some time by caching the response.
const cache = new Map();
document.addEventListener('bookmarkchange', (e) => {
const eventType = e.detail.type;
if (eventType == 'remove' || eventType == 'change') {
const {commentId, category} = e.detail.record;
const key = `${commentId}${category}`;
cache.delete(key);
} else if (eventType == 'dbClear') {
cache.clear();
}
});
return (entry) => {
const {category, commentId, commentListId, commentAuthor, commentAuthorId,
commentSnippet: commentBody, commentTimestamp: unixTimestamp} = entry;
const key = `${commentId}${category}`;
if (cache.has(key)) {
const response = cache.get(key);
// resetting stylings
response.querySelector(`.${SCRIPT_LABEL}-snippet`).classList.remove('expanded');
response.classList.remove('preview_active');
const quoteContainer = response.querySelector('.quote_container');
if (quoteContainer.childElementCount) quoteContainer.firstElementChild.remove();
return response;
}
const fullTimestamp = new Date(unixTimestamp).toString();
const displayDate = new Intl.DateTimeFormat('en', {month: 'short', day: 'numeric', year: 'numeric'}).format(unixTimestamp);
let commentTitle = ' ';
let chapterComponent = '';
switch (category) {
case 'story':
commentTitle += `${(entry.chapterName) ? '- ' : ''}${entry.title} - ${entry.postAuthor}`;
chapterComponent = (entry.chapterName) ? ' ' + entry.chapterName : '';
break;
case 'group':
commentTitle += `${entry.title}`;
break;
case 'group_forum':
commentTitle += `${entry.title} - ${entry.postAuthor}`;
break;
case 'blog':
commentTitle += `${entry.title} - ${entry.postAuthor}`;
break;
case 'user':
commentTitle += `${entry.title}`;
break;
}
let permalink;
switch (category) {
case 'story': case 'blog': case 'user': case 'group':
permalink = `/${category}/${commentListId}/#comment/${commentId}`;
break;
case 'group_forum':
permalink = `/group/${entry.groupId}/_/thread/${commentListId}/#comment/${commentId}`;
break;
}
const ele = composeElement({
tag: 'li', attributes: {class: 'comment', dataCommentId: commentId, dataCommentCategory: category},
children: [{
tag: 'div', attributes: {class: 'corner-stubb', dataCategoryIndicator: category}
},{
tag: 'span', attributes: {class: 'date'},
children: [{
tag: 'span',
attributes: {title: fullTimestamp},
text: displayDate
}]
},{
tag: 'div', attributes: {class: 'comment_information'},
children: [{
tag: 'a', attributes: {href: permalink, class: 'subject'},
children: [{
tag: 'span', text: '#'
},{
tag: 'span', attributes: {class: 'chapter-component'}, text: chapterComponent
},{
tag: 'span', attributes: {class: 'hidden-when-nested'}, text: commentTitle
}]
},{
tag: 'wbr'
},{
tag: 'span', attributes: {class: `${SCRIPT_LABEL}-comment-author-span`},
children: [{
tag: 'span', attributes: {class: `${SCRIPT_LABEL}-divider`}, text: '·'
},{
tag: 'a',
attributes: {href: `/user/${commentAuthorId}/`, class: `${SCRIPT_LABEL}-comment-author`},
text: commentAuthor
}]
},{
tag: 'div', attributes: {class: 'buttons'},
children: [{
tag: 'a', attributes: {title: 'Toggle live preview'},
children: [{tag: 'i', attributes: {class: 'fa fa-fw fa-desktop'}}]
},{
tag: 'a', attributes: {title: 'Delete bookmark'},
children: [{tag: 'i', attributes: {class: 'fa fa-fw fa-trash-o'}}]
}]
}]
},{
tag: 'div', attributes: {class: `${SCRIPT_LABEL}-snippet-wrapper`},
children: [{tag: 'div', attributes: {class: `${SCRIPT_LABEL}-snippet`}, text: commentBody}]
},{
tag: 'div', attributes: {class: `${SCRIPT_LABEL}-preview quote_container`}
}]
});
cache.set(key, ele);
return ele;
};
})();
const toggleLivePreview = (() => {
const responseCache = new Map();
const makeURL = (commentId, category) => {
const ajaxCategory = {
story: 'story_comments',
group: 'comments_group',
group_forum: 'comments_group_thread',
blog: 'blog_posts_comments',
user: 'comments_user_page'
};
return `/ajax/comments/${ajaxCategory[category]}/${commentId}`;
};
// remove edited comments from cache
document.addEventListener('bookmarkchange', (e) => {
const eventType = e.detail.type;
if (eventType == 'change' || eventType == 'remove') {
const {commentId, category} = event.detail.record;
responseCache.delete(makeURL(commentId, category));
} else if (eventType == 'dbClear') {
responseCache.clear();
}
});
const fetchComment = (commentId, category) => {
return new Promise((resolve, reject) => {
const cacheMaxAge = 3600; // 3600 seconds == 1 hour
const fetchURL = makeURL(commentId, category);
const response = responseCache.get(fetchURL);
if (response && (Math.floor((Date.now() - response.timestamp) / 1e3) < cacheMaxAge)) {
resolve(response.body);
} else {
window.fetch(fetchURL, {credentials: 'same-origin'})
.then(response => response.json())
.then(obj => {
if (obj.hasOwnProperty('error')) {
responseCache.delete(fetchURL);
reject(obj.error);
} else {
responseCache.set(fetchURL, {timestamp: Date.now(), body: obj.content});
resolve(obj.content);
}
});
}
});
};
return (commentBody) => {
const commentId = Number.parseInt(commentBody.dataset.commentId);
const category = commentBody.dataset.commentCategory;
const quoteContainer = commentBody.querySelector('.quote_container');
const snippet = commentBody.querySelector(`.${SCRIPT_LABEL}-snippet`);
if (quoteContainer.hasAttribute('fetching')) return;
if (commentBody.classList.contains('preview_active')) {
quoteContainer.firstElementChild.remove();
commentBody.classList.remove('preview_active');
applySnippetOverlay(snippet);
} else {
quoteContainer.setAttribute('fetching', '');
fetchComment(commentId, category)
.then(responseHTML => {
quoteContainer.innerHTML = responseHTML;
commentBody.classList.add('preview_active');
quoteContainer.removeAttribute('fetching');
if (quoteContainer.querySelector('.comment_data')) {
updateBookmarkSnippet(commentId, category, quoteContainer.firstElementChild);
}
window.App.BindAll(quoteContainer);
window.App.DispatchEvent(quoteContainer, 'loadVisibleImages');
}).catch(e => {
console.error(e);
const btn = commentBody.querySelector('[title="Toggle live preview]"');
btn.classList.add('disabled');
btn.title = 'Could not fetch this comment';
quoteContainer.removeAttribute('fetching');
});
}
};
})();
function updateList(pageNumber) {
const container = document.getElementById(`${SCRIPT_LABEL}-list-container`);
if (!container) return;
const sortMethod = container.dataset.sortMethod;
const sortDirection = container.dataset.sortDirection;
const ITEMS_PER_PAGE = 50;
let bookmarkIndexName;
switch (sortMethod) {
case 'post':
bookmarkIndexName = 'commentTimestamp';
break;
case 'bookmark-age':
bookmarkIndexName = 'bookmarkTimestamp';
break;
case 'comment-age':
bookmarkIndexName = 'commentTimestamp';
break;
}
Promise.all([getAllRecords('bookmarkStore', bookmarkIndexName), getAllRecords('articleStore', 'articleTitle')]).then(records => {
const [commentRecords, articleRecords] = records;
const matchRecord = (record, property, query) => (record[property] && record[property].toLowerCase().indexOf(query.toLowerCase()) != -1);
const temp = {};
for (const button of document.getElementById(`${SCRIPT_LABEL}-filter`).querySelectorAll('[data-filter-type]')) {
const type = button.dataset.filterType;
temp[type] = button.classList.contains('active');
}
const categoryFilter = {
story: temp['story'],
blog: temp['blog'],
group_forum: temp['forum'],
user: temp['profile'],
group: temp['profile'],
};
const textField = document.getElementById(`${SCRIPT_LABEL}-search-input`);
const searchType = {title: undefined, comment: undefined, author: undefined};
document.querySelectorAll(`#${SCRIPT_LABEL}-search [data-search-type]`).forEach(button => {
const fieldName = button.dataset.searchType;
searchType[fieldName] = button.classList.contains('active');
});
const queryString = textField.value;
const doTextSearch = (new RegExp('\\S').test(queryString) && !Object.values(searchType).every(val => !val));
const keepArticle = [];
const matchedComments = commentRecords.map(comment => {
// denormalize the title for easier filtering in the next step
const article = articleRecords.find(record => (record.commentListId == comment.commentListId && record.category == comment.category));
comment.title = article.title;
comment.postAuthor = article.author;
return comment;
})
.filter(comment => {
// find the comments we want to display
if (!categoryFilter[comment.category]) return false;
return ((!doTextSearch)
|| (searchType.author && matchRecord(comment, 'commentAuthor', queryString))
|| (searchType.comment && matchRecord(comment, 'commentSnippet', queryString))
|| (searchType.title && matchRecord(comment, 'chapterName', queryString))
|| (searchType.title && matchRecord(comment, 'title', queryString))
|| (searchType.title && matchRecord(comment, 'postAuthor', queryString)));
});
matchedComments.forEach(({commentListId, category}) => keepArticle.push({commentListId, category}));
// keep a record of article entries for displaying in 'post' view
const matchedArticles = (!doTextSearch && Object.values(categoryFilter).every(val => val))
? articleRecords
: articleRecords.filter(article => {
return (categoryFilter[article.category]
&& keepArticle.find(({commentListId, category}) => (article.commentListId == commentListId && article.category == category)));
});
// pagination
const paginationBar = document.getElementById(`${SCRIPT_LABEL}-pagination`);
const pageNumberContainer = document.getElementById(`${SCRIPT_LABEL}-numbered-pages`);
const totalPageCount = (sortMethod == 'post')
? Math.ceil((matchedArticles.length / ITEMS_PER_PAGE) || 1)
: Math.ceil((matchedComments.length / ITEMS_PER_PAGE) || 1);
const currentPageNumber = Math.min(totalPageCount, (pageNumber || paginationBar.dataset.pageNumber));
const doPagination = (totalPageCount != 1) && getLocalStorage('comment_pagination');
const startIndex = (doPagination)
? (currentPageNumber - 1) * ITEMS_PER_PAGE
: 0;
const endIndex = (doPagination)
? currentPageNumber * ITEMS_PER_PAGE
: totalPageCount * ITEMS_PER_PAGE;
const prevLink = document.getElementById(`${SCRIPT_LABEL}-prev-page`);
const nextLink = document.getElementById(`${SCRIPT_LABEL}-next-page`);
prevLink.classList.toggle('disabled', currentPageNumber == 1);
nextLink.classList.toggle('disabled', currentPageNumber == totalPageCount);
prevLink.dataset.goToPage = currentPageNumber - 1;
nextLink.dataset.goToPage = currentPageNumber + 1;
while (pageNumberContainer.firstChild) pageNumberContainer.firstChild.remove();
paginationBar.classList.toggle('hidden', !doPagination);
if (doPagination) {
for (let i = 1, dividerL = false, dividerR = false; i <= totalPageCount; ++i) {
if (i > 2 && i < currentPageNumber - 3) {
if (!dividerL) {
pageNumberContainer.appendChild(composeElement({tag: 'span', text: '...'}));
dividerL = true;
}
continue;
}
if (i < totalPageCount - 1 && i > currentPageNumber + 3) {
if (!dividerR) {
pageNumberContainer.appendChild(composeElement({tag: 'span', text: '...'}));
dividerR = true;
}
continue;
}
pageNumberContainer.appendChild(composeElement({
tag: 'a',
attributes: {
class: `${SCRIPT_LABEL}-pagination-link ${(i == currentPageNumber) ? 'disabled' : ''}`,
dataGoToPage: i
},
text: i
}));
}
}
const frag = document.createDocumentFragment();
if (sortMethod == 'post') {
for (let i = startIndex, len = matchedArticles.length; i < endIndex && i < len; ++i) {
const article = matchedArticles[i];
const category = article.category;
const comments = article.comments;
let displayTitle;
let displayAuthor;
switch (category) {
case 'story': case 'blog': case 'group_forum':
displayTitle = `${article.title}${(article.author) ? ' - ' : ''}`;
displayAuthor = article.author;
break;
case 'user': case 'group':
displayTitle = `${article.title}`;
displayAuthor = '';
break;
}
const articleElement = composeElement({
tag: 'li', attributes: {class: `${SCRIPT_LABEL}-article-container`},
children: [{
tag: 'div', attributes: {class: `${SCRIPT_LABEL}-title-row`, dataCategoryIndicator: category},
children: [{
tag: 'span',
children: [{tag: 'i', attributes: {class: 'fa fa-fw expand-indicator'}}]
},{
tag: 'span', text: displayTitle
},{
tag: 'span',
children: [{tag: 'i', text: displayAuthor}]
}]
},{
tag: 'ul', attributes: {class: `${SCRIPT_LABEL}-comment-container`}
}]
});
const commentContainer = articleElement.querySelector(`.${SCRIPT_LABEL}-comment-container`);
if (sortDirection == 'asc') comments.reverse();
for (let i = 0, len = comments.length; i < len; ++i) {
const commentId = comments[i];
const comment = matchedComments.find(record => (record.commentId == commentId && record.category == category));
if (comment) commentContainer.appendChild(composeComment(comment));
}
frag.appendChild(articleElement);
}
} else {
if (sortDirection == 'asc') matchedComments.reverse();
for (let i = startIndex, len = matchedComments.length; i < endIndex && i < len; ++i) {
frag.appendChild(composeComment(matchedComments[i]));
}
}
// don't collapse comments
if (!getLocalStorage('comment_collapse')) {
frag.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(ele => {
ele.classList.add('expanded');
ele.classList.remove('overlay');
ele.removeEventListener('click', overlayClickHandler);
});
}
/* finished iterating through all the records, insert elements into document */
// clear the container first
while (container.firstChild) container.firstChild.remove();
container.appendChild(frag);
container.scrollTop = 0;
// attach listeners for snippet expanders, need to be live on the document to calculate if content is overflowing
if (!document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden') && getLocalStorage('comment_collapse')) {
container.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
}
window.App.binders.forEach(fn => fn(container)); // User card
});
}
function displayBookmarkPanel() {
const menu = document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`);
if (!menu) return;
menu.classList.toggle('nightmode', checkNightmode()); // apply the nightmode styling
menu.classList.remove('hidden');
menu.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
}
function initBookmarkPanel() {
const title = 'Comment Bookmarks';
const menu = composeElement({tag: 'div', attributes: {id: `${SCRIPT_LABEL}--pop-up-wrapper`, class: 'hidden'}});
menu.innerHTML = `
<div id="${SCRIPT_LABEL}--pop-up" class="drop-down-pop-up-container">
<div class="drop-down-pop-up">
<h1>
<span><i class="fa fa-folder-open"></i> ${title}</span>
<a class="close_button"></a>
</h1>
<div class="drop-down-pop-up-content">
<div class="${SCRIPT_LABEL}--window-content">
<div class="${SCRIPT_LABEL}-flex-wrapper">
<div class="${SCRIPT_LABEL}-options-bar">
<div class="${SCRIPT_LABEL}-options-group ${SCRIPT_LABEL}-flex-grow">
Display setting:
<button class="${SCRIPT_LABEL}-options-button" id="${SCRIPT_LABEL}-comment-collapse" type="button" title="Collapse all comment snippets by default">Compact view</button>
<button class="${SCRIPT_LABEL}-options-button" id="${SCRIPT_LABEL}-comment-pagination" type="button" title="Display 50 items per page">Pagination</button>
</div>
<div class="${SCRIPT_LABEL}-options-group">
<button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-stats" type="button" title="View statistics">Stats</button>
<button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-export-button" type="button" title="Backup bookmarks to file">Export</button>
<button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-import-button" type="button" title="Import and merge bookmarks from backup">Import</button>
<button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-clear-button" type="button" title="Delete all stored bookmarks">Clear</button>
</div>
</div>
</div>
<div class="${SCRIPT_LABEL}-list">
<div class="${SCRIPT_LABEL}-flex-wrapper">
<div class="${SCRIPT_LABEL}-list-header">
<span>Sort by: </span>
<select id="${SCRIPT_LABEL}-sorting">
<option value="post">parent post</option>
<option value="comment-age">comment date</option>
<option value="bookmark-age">added date</option>
</select>
<select id="${SCRIPT_LABEL}-sorting-direction">
<option value="asc">newest first</option>
<option value="desc">oldest first</option>
</select>
<span id="${SCRIPT_LABEL}-filter">
<span>Filter by:</span>
<button class="${SCRIPT_LABEL}-options-button" title="Story comments" data-filter-type="story">Story</button>
<button class="${SCRIPT_LABEL}-options-button" title="Blog comments" data-filter-type="blog">Blog</button>
<button class="${SCRIPT_LABEL}-options-button" title="Forum posts" data-filter-type="forum">Forum</button>
<button class="${SCRIPT_LABEL}-options-button" title="User and group profile comments" data-filter-type="profile">Profile</button>
</span>
<span id="${SCRIPT_LABEL}-search">
<span>Search:</span>
<input id="${SCRIPT_LABEL}-search-input" type="text" size="15">
<button class="${SCRIPT_LABEL}-options-button active" title="Title, chapter name, or the author of the parent post" data-search-type="title">Title</button>
<button class="${SCRIPT_LABEL}-options-button active" title="Comment body" data-search-type="comment">Comment</button>
<button class="${SCRIPT_LABEL}-options-button active" title="Author of the comment" data-search-type="author">Commenter</button>
</span>
</div>
</div>
<div id="${SCRIPT_LABEL}-pagination" data-page-number="1">
<a id="${SCRIPT_LABEL}-prev-page" class="${SCRIPT_LABEL}-pagination-link">Prev</a>
<span id="${SCRIPT_LABEL}-numbered-pages"></span>
<a id="${SCRIPT_LABEL}-next-page" class="${SCRIPT_LABEL}-pagination-link">Next</a>
</div>
<ul id="${SCRIPT_LABEL}-list-container">
</ul>
</div>
</div>
</div>
<div class="drop-down-pop-up-footer">
<center>
<button class="styled_button" style="padding: 5px 40px;">Close</button>
</center>
</div>
</div>
</div>`;
// Overlays may need to be added or removed due to change to viewport
const resizeHandler = (() => {
const interval = 500;
let lastCall = 0;
return function () {
const now = Date.now();
if (now - lastCall < interval) return;
menu.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
lastCall = now;
};
})();
const bindClickHandlers = (() => {
const callbacks = [];
menu.addEventListener('click', e => {
for (const callback of callbacks) {
const {selector, fn, bubble} = callback;
const ele = e.target.closest(selector);
if ((bubble && ele) || e.target.matches(selector)) {
if (ele.tagName == 'BUTTON') ele.blur();
fn(ele, e);
}
}
});
return (selector, fn, bubble = true) => {
callbacks.push({selector, fn, bubble});
};
})();
/* --- attaching event listeners --- */
window.addEventListener('resize', resizeHandler);
// pagination links
bindClickHandlers(`.${SCRIPT_LABEL}-pagination-link`, link => {
if (link.classList.contains('disabled')) return;
const pageNumber = Number.parseInt(link.dataset.goToPage);
document.getElementById(`${SCRIPT_LABEL}-pagination`).dataset.pageNumber = pageNumber;
updateList(pageNumber);
});
// article container expand/collapse
bindClickHandlers(`.${SCRIPT_LABEL}-title-row`, target => {
const container = target.closest(`.${SCRIPT_LABEL}-article-container`);
container.classList.toggle('expanded');
container.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
});
bindClickHandlers('[title="Toggle live preview"]', btn => {
const comment = btn.closest('.comment');
toggleLivePreview(comment);
});
// comment delete button
bindClickHandlers('[title="Delete bookmark"]', btn => {
const comment = btn.closest('.comment');
const id = Number.parseInt(comment.dataset.commentId);
const cat = comment.dataset.commentCategory;
window.ConfirmPrompt('Are you sure you want to delete this bookmark?', () => {
deleteBookmark(id, cat)
.then(() => {
comment.remove();
const ele = document.getElementById('comment_' + id);
if (ele && cat == getPageCategory()) toggleOff(id);
})
.catch(exception => {
if (exception.name == 'InvalidStateError') {
console.warn(exception);
comment.remove();
const ele = document.getElementById('comment_' + id);
if (ele && cat == getPageCategory()) toggleOff(id);
} else {
throw exception;
}
});
});
});
// close bookmark panel
menu.dataset.pendingListUpdate = '0';
bindClickHandlers(`.close_button, .drop-down-pop-up-footer button, #${SCRIPT_LABEL}--pop-up-wrapper, .subject`, () => {
window.removeEventListener('resize', resizeHandler);
document.getElementById(`${SCRIPT_LABEL}-list-container`).scrollTop = 0;
menu.classList.add('hidden');
if (menu.dataset.pendingListUpdate == '1') {
menu.dataset.pendingListUpdate = '0';
updateList();
} else {
menu.querySelectorAll('.expanded').forEach(ele => ele.classList.remove('expanded'));
menu.querySelectorAll('.preview_active').forEach(ele => ele.classList.remove('preview_active'));
menu.querySelectorAll('.quote_container > *').forEach(ele => ele.remove());
}
}, false);
// display stats
bindClickHandlers(`#${SCRIPT_LABEL}-stats`, () => {
Promise.all([getAllRecords('bookmarkStore'), getAllRecords('articleStore')]).then(records => {
const [bookmarkStore, articleStore] = records;
const totalBookmarks = bookmarkStore.length;
const totalArticles = articleStore.length;
let storyCount = 0;
let blogCount = 0;
let forumCount = 0;
let profileCount = 0;
bookmarkStore.forEach(entry => {
switch (entry.category) {
case ('story'): ++storyCount;
break;
case ('blog'): ++blogCount;
break;
case ('group_forum'): ++forumCount;
break;
case ('user'): case ('group'): ++profileCount;
break;
}
});
const title = 'Bookmark Statistics';
const innerHTML = `
You have bookmarked a total of <b>${totalBookmarks}</b> ${totalBookmarks == 1 ? 'comment' : 'comments'} from <b>${totalArticles}</b> ${totalArticles == 1 ? 'post' : 'posts'}.
<br>
<br>
<ul style="list-style-type: initial; margin-left: 20px;">
<li><b>${storyCount}</b> from stories</li>
<li><b>${blogCount}</b> from blog posts</li>
<li><b>${forumCount}</b> from forum threads</li>
<li><b>${profileCount}</b> from user and group profiles</li>
</ul>
`;
window.StyledAlert(innerHTML, title, {align: 'left'});
});
});
// comment collapse
menu.querySelector(`#${SCRIPT_LABEL}-comment-collapse`).classList.toggle('active', getLocalStorage('comment_collapse'));
bindClickHandlers(`#${SCRIPT_LABEL}-comment-collapse`, button => {
button.classList.toggle('active');
setLocalStorage('comment_collapse', button.classList.contains('active'));
updateList(1);
});
// pagination toggle
menu.querySelector(`#${SCRIPT_LABEL}-comment-pagination`).classList.toggle('active', getLocalStorage('comment_pagination'));
bindClickHandlers(`#${SCRIPT_LABEL}-comment-pagination`, button => {
button.classList.toggle('active');
setLocalStorage('comment_pagination', button.classList.contains('active'));
updateList(1);
});
// import/export
bindClickHandlers(`#${SCRIPT_LABEL}-export-button`, () => {
window.ConfirmPrompt('Export your bookmarks to file?', exportDB);
});
bindClickHandlers(`#${SCRIPT_LABEL}-import-button`, () => {
window.ConfirmPrompt('This will add all bookmarks from the selected backup to your present collection, proceed?', importDB);
});
bindClickHandlers(`#${SCRIPT_LABEL}-clear-button`, () => {
window.ConfirmPrompt('This will delete all currently stored bookmarks, proceed?', () => {
window.ConfirmPrompt('Are you REALLY sure?', clearDB);
});
});
// don't navigate on relative comment links inside live preview
bindClickHandlers('.quote_container a[href^="#comment/"]', (target, event) => {
event.preventDefault();
event.stopPropagation();
}, false);
// comment category filter
const filter = getLocalStorage('category_filter');
for (const button of menu.querySelectorAll(`#${SCRIPT_LABEL}-filter [data-filter-type]`)) {
const type = button.dataset.filterType;
button.classList.toggle('active', filter[type]);
}
bindClickHandlers(`#${SCRIPT_LABEL}-filter [data-filter-type]`, target => {
const filter = getLocalStorage('category_filter');
target.classList.toggle('active');
filter[target.dataset.filterType] = target.classList.contains('active');
setLocalStorage('category_filter', filter);
updateList(1);
});
// search options
bindClickHandlers(`#${SCRIPT_LABEL}-search [data-search-type]`, target => {
const textField = document.getElementById(`${SCRIPT_LABEL}-search-input`);
target.classList.toggle('active');
if (new RegExp('\\S').test(textField.value)) updateList(1); // only update list if textfield's not empty
});
const textField = menu.querySelector(`#${SCRIPT_LABEL}-search-input`);
textField.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
const value = e.target.value;
e.stopPropagation();
e.preventDefault();
if (value !== '') {
e.target.value = '';
updateList(1);
} else {
e.target.blur();
}
});
textField.addEventListener('focus', e => {
const textField = e.target;
textField.setSelectionRange(0, textField.value.length);
e.stopPropagation();
e.preventDefault();
});
textField.addEventListener('input', (() => {
const delay = 100; // ms
let timeoutId = null;
return () => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
updateList(1);
}, delay);
};
})());
// dropdown menus
const sortMethod = getLocalStorage('sort-method');
const sortDirection = getLocalStorage('sort-direction');
menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortMethod = sortMethod;
menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortDirection = sortDirection;
let select;
select = menu.querySelector(`#${SCRIPT_LABEL}-sorting`);
select.value = sortMethod;
select.addEventListener('change', e => {
const sortMethod = e.target.value;
setLocalStorage('sort-method', sortMethod);
menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortMethod = sortMethod;
updateList(1);
});
select = menu.querySelector(`#${SCRIPT_LABEL}-sorting-direction`);
select.value = sortDirection;
select.addEventListener('change', e => {
const sortDirection = e.target.value;
setLocalStorage('sort-direction', sortDirection);
menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortDirection = sortDirection;
updateList(1);
});
// UI update in response to database change
document.addEventListener('bookmarkchange', (e) => {
const eventType = e.detail.type;
// update the toggle status of any bookmark button on the page,
// skip on eventType 'change' because it wouldn't affect button toggle state
if (eventType != 'change') {
document.querySelectorAll('div.comment').forEach(addButton);
}
// don't update if the menu is open
// reset to display page one on database clear or import
if (eventType == 'dbClear' || eventType == 'dbImport') {
updateList(1);
} else if (menu.classList.contains('hidden')) {
updateList(); // keep the page number
} else {
menu.dataset.pendingListUpdate = '1';
}
});
const dispatchBookmarkChangeEvent = (data) => {
// only dispatch even when page is visible
if (document.hidden) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState == 'visible') document.dispatchEvent(new CustomEvent('bookmarkchange', {detail: data}));
}, {once: true});
} else {
document.dispatchEvent(new CustomEvent('bookmarkchange', {detail: data}));
}
};
// updateList across othere instances
if (BC_SUPPORT) {
const bc = new BroadcastChannel(SCRIPT_LABEL);
bc.onmessage = (e) => {
if (e.data.hasOwnProperty('lastModified')) {
const data = e.data.lastModified;
dispatchBookmarkChangeEvent(data);
}
};
} else {
// fallback by listening to localStorage changes
window.addEventListener('storage', (() => {
let store = getLocalStorage('db_last_modified') || setLocalStorage('db_last_modified', {timestamp: 0, data: {}});
let lastModified = store.timestamp;
return (e) => {
if (e.key !== SCRIPT_LABEL) return;
store = getLocalStorage('db_last_modified');
if (lastModified < store.timestamp) {
lastModified = store.timestamp;
dispatchBookmarkChangeEvent(store.data);
}
};
})());
}
const applyScrolltrap = (() => {
const selectors = [];
menu.addEventListener('wheel', (e) => {
// let ele be the closest ancestor that matches one of the selectors
let ele = null;
let temp = e.target;
do {
ele = (selectors.some(selector => temp.matches(selector))) ? temp : null;
temp = temp.parentElement;
} while (temp !== null && temp !== menu && ele === null);
if (!ele) {
e.preventDefault();
e.stopPropagation();
return;
}
const position = ele.scrollTop;
const scrollBottomPosition = ele.scrollHeight - ele.clientHeight;
const scrollDirection = e.deltaY;
if (scrollDirection > 0 && position >= scrollBottomPosition
|| scrollDirection < 0 && position <= 0) {
e.preventDefault();
e.stopPropagation();
}
});
return (selector) => {
selectors.push(selector);
};
})();
// prevent scrolling of background
applyScrolltrap(`#${SCRIPT_LABEL}-list-container`);
applyScrolltrap(`.${SCRIPT_LABEL}--window-content`);
document.querySelector('.content').appendChild(menu);
updateList();
}
function initUI() {
const creationObserver = (function () {
const observedElements = [];
const callbacks = [];
const executeCallback = (fn, node) => {
if (observedElements.some(observedNode => observedNode == node)) return;
observedElements.push(node);
fn(node);
};
const obs = new MutationObserver(mutationRecords => {
mutationRecords.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
callbacks.forEach(({selector, fn}) => {
if (node.matches(selector)) executeCallback(fn, node);
node.querySelectorAll(selector).forEach(childNode => executeCallback(fn, childNode));
});
});
});
});
obs.observe(document.body, {childList: true, subtree: true});
return function (selector, fn) {
document.querySelectorAll(selector).forEach(node => executeCallback(fn, node));
callbacks.push({selector, fn});
};
})();
// bookmark panel button
const menuButton = composeElement({
tag: 'li',
children: [{
tag: 'a',
attributes: {class: `${SCRIPT_LABEL}-menu-button`},
children: [{
tag: 'i',
attributes: {class: 'fa fa-bookmark'}
},{
tag: 'span',
text: 'Bookmarks'
}]
}]
});
creationObserver('nav.user_toolbar>ul', function (toolbar) {
// desktop view
const button = menuButton.cloneNode(true);
button.addEventListener('click', displayBookmarkPanel);
toolbar.insertBefore(button, toolbar.children[toolbar.children.length - 2]);
});
creationObserver('.navigation-drawer-list a[href="/search/blog-posts"]', target => {
// mobile view
const drawer = target.closest('.navigation-drawer-list');
const button = menuButton.cloneNode(true);
button.addEventListener('click', displayBookmarkPanel);
drawer.insertBefore(button, drawer.firstElementChild);
});
creationObserver('.comment_list', list => {
list.addEventListener('click', commentButtonHandler);
});
creationObserver('div.comment', comment => {
comment.removeAttribute(`${SCRIPT_LABEL}-observer-pending`);
addButton(comment);
});
// override the z-index on the author popup
// 10000 because that's the z-index of the top navigation bars when it's in fixed mode
creationObserver('.info-card-container', target => {
function toggle(card) {
if (document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden')) {
card.style.zIndex = '';
} else {
card.style.zIndex = '10000';
}
}
new MutationObserver(mutationRecords => {
mutationRecords.forEach(mutation => {
toggle(mutation.target);
});
}).observe(target, {attributeFilter: ['class']});
toggle(target);
});
// override the z-index on the stats window
creationObserver('#dimmers .dimmer, body>.drop-down-pop-up-container', target => {
if (document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden')) return;
target.style.zIndex = '10000';
});
// detect comment edit and apply updateBookmarkSnippet
creationObserver('.comment_data', target => {
const processMutationEvent = (mutation) => {
const comment = mutation.target.closest('.comment');
// check for edit button and if element is not hidden
if (comment.querySelector('[data-click="toggleEditComment"]') && !mutation.target.classList.contains('hidden')) {
const button = comment.querySelector(`.${SCRIPT_LABEL}-bookmark-button`);
const commentId = Number.parseInt(button.dataset.commentId);
const category = button.dataset.commentCategory;
if (button.dataset.bookmarked == '1') {
updateBookmarkSnippet(commentId, category, comment);
}
}
};
new MutationObserver(mutationRecords => {
mutationRecords.forEach(processMutationEvent);
}).observe(target, {attributeFilter: ['class']});
});
initBookmarkPanel();
}
initLocalStorage();
onReady(initCSS);
onReady(initUI);
})();