NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Mangadex Autocomplete // @description Autocompletes @mentions and :titles. Maintains a small history of user posts and manga you recently viewed and searches that for matches. Example image shown in additional info // @namespace https://github.com/Brandon-Beck // @author Brandon Beck // @license MIT // @icon https://mangadex.org/favicon-96x96.png // @version 0.0.13 // @grant unsafeWindow // @grant GM.getValue // @grant GM.setValue // @grant GM_getValue // @grant GM_setValue // @require https://gitcdn.xyz/repo/ichord/Caret.js/341fb20b6126220192b2cd226836cd5d614b3e09/dist/jquery.caret.js // @require https://gitcdn.xyz/repo/ichord/At.js/1b7a52011ec2571f73385d0c0d81a61003142050/dist/js/jquery.atwho.js // @require https://gitcdn.xyz/repo/Brandon-Beck/Mangadex-Userscripts/a480c30b64fba63fad4e161cdae01e093bce1e4c/common.js // @require https://gitcdn.xyz/repo/Brandon-Beck/Mangadex-Userscripts/21ec54406809722c425c39a0f5b6aad59fb3d88d/uncommon.js // @require https://gitcdn.xyz/repo/Brandon-Beck/Mangadex-Userscripts/0d46bb0b3fa43f11ea904945e7baef7c6e2a6a5b/settings-ui.js // @match https://mangadex.org/* // ==/UserScript== // FIXME: Move away from dead atwho. Considing https://github.com/zurb/tribute /* global XPath ,XPath2 ,getUserValues ,setUserValue ,dbg ,$ */ /* global SettingsUI ,SettingsUIValidationError */ 'use strict' const MANGADEX_BASE_URI = 'https://mangadex.org' const xp = new XPath() /* ************************************* * Functions That ought to go in a library */ function insertStylesheet(cssText) { // const cssId = `css_${cssText.toString().replace(/\W/g ,'_')}` const style = document.createElement('style') style.type = 'text/css' if (style.styleSheet) { // This is required for IE8 and below. style.styleSheet.cssText = cssText } else { style.appendChild(document.createTextNode(cssText)) } document.head.appendChild(style) return style.sheet } // For using AtWho's CSS. Disabled since it is difficault to make it use mangadex's active theme function addCssLink(css_url) { const cssId = `css_${css_url.toString().replace(/\W/g ,'_')}` if (!document.getElementById(cssId)) { const link = document.createElement('link') link.id = cssId link.rel = 'stylesheet' link.type = 'text/css' link.href = css_url // link.media = 'all'; document.head.appendChild(link) return link.sheet } } function insertIntoStylesheet({ stylesheet ,selector ,css_text }) { if (stylesheet.insertRule) { css_text = `${selector} {${css_text}}` stylesheet.insertRule(css_text ,stylesheet.cssRules.length) } else if (stylesheet.addRule) { stylesheet.addRule(selector ,css_text) } } function findCSS_Rules({ classID ,exactProperties = [] ,matchProperties = [] ,ignoredStylesheets = [] }) { let resultRule = {} let resultCssText = '' let exactCssText = '' let matchCssText = '' let result_stylesheet for (let i = 0; i < document.styleSheets.length; i++) { // Object.keys(document.styleSheets).forEach((i) => { try { const stylesheet = document.styleSheets[i] if (ignoredStylesheets.indexOf(stylesheet) >= 0) continue const style_rules = stylesheet.cssRules ? stylesheet.cssRules : stylesheet.rules if (style_rules) { result_stylesheet = stylesheet // for (let r = 0; r < style_rules.length; r++) { Object.keys(style_rules).forEach((r) => { if (style_rules[r].selectorText && style_rules[r].selectorText === classID) { resultRule = style_rules[r] Object.values(style_rules[r].style).forEach((key) => { const v = style_rules[r].style[key] resultCssText += `${key}: ${v}; ` if (exactProperties.indexOf(key) >= 0) { exactCssText += `${key}: ${v}; ` } matchProperties.forEach((reg) => { if (key.startsWith(reg)) matchCssText += `${key}: ${v}; ` }) }) // return { stylesheet, rule: style_rules[r] } } }) } } catch (e) { // Rethrow exception if it's not a SecurityError. Note that SecurityError // exception is specific to Firefox. if (e.name !== 'SecurityError') throw e // continue // on as normal } // }) } // let css_text = Object.enresultRule.reduce( (accum='',[k,v]) => { accum+=v } ) return { stylesheet: result_stylesheet ,rule: resultRule ,matchCssText ,exactCssText ,resultCssText } } function duplicate_cssRule({ origSelector ,newSelector ,exactProperties ,matchProperties ,ignoredStylesheets ,targetStylesheet: insertInto }) { // if(findCSS_Rule(new_selector)) return true; // Must have already done this one const { stylesheet: origStylesheet ,rule ,matchCssText ,exactCssText ,resultCssText } = findCSS_Rules({ classID: origSelector ,exactProperties ,matchProperties ,ignoredStylesheets }) let cssText = matchProperties ? matchCssText : resultCssText if (!cssText) return false let targetStylesheet = insertInto if (targetStylesheet == null) targetStylesheet = origStylesheet if (targetStylesheet.insertRule) { cssText = `${newSelector} {${cssText}}` targetStylesheet.insertRule(cssText ,targetStylesheet.cssRules.length) } else if (targetStylesheet.addRule) { targetStylesheet.addRule(newSelector ,cssText) } return true } function stableSort(arr ,cmp = (a ,b) => { if (a < b) return -1 if (a > b) return 1 return 0 }) { const stabilizedThis = arr.map((el ,index) => [el ,index]) const stableCmp = (a ,b) => { const order = cmp(a[0] ,b[0]) if (order !== 0) return order return a[1] - b[1] } stabilizedThis.sort(stableCmp) for (let i = 0; i < arr.length; i++) { arr[i] = stabilizedThis[i][0] } return arr } /* ************************************* * Our crap */ function mangadexStyleURIComponent(str) { // replace all non-alpha-numeric characters with dashing dashes return str.replace(/[^a-zA-Z0-9]/g ,'-') } function clipText(text ,max_length) { return (text.length > max_length) ? `${text.substr(0 ,max_length - 1)}…` : text } function getVisibleText(node) { if (node.nodeType === Node.TEXT_NODE) return node.textContent const style = getComputedStyle(node) if (style && style.display === 'none') return '' let text = '' for (let i = 0; i < node.childNodes.length; i++) text += getVisibleText(node.childNodes[i]) return text } // iill use classes once we get private variables function Manga({ id ,title ,description ,image ,isFollowing ,lastViewedDate }) { const manga = this if (!(manga instanceof Manga)) { return new Manga() } const privateObject = { id ,title: title ? title.trim() : undefined ,description: description ? description.trim() : undefined ,image ,isFollowing ,lastViewedDate: lastViewedDate || 0 } Object.defineProperties(this ,{ id: { get() { return privateObject.id } ,enumerable: true } ,title: { get() { return privateObject.title } ,set(val) { return privateObject.title = val.trim() } ,enumerable: true } ,description: { get() { return privateObject.description } ,set(val) { return privateObject.description = val.trim() } ,enumerable: true } ,excerpt: { get() { return clipText(privateObject.description ,100) } ,enumerable: true } ,thumbnail: { get() { return `${MANGADEX_BASE_URI}/images/manga/${privateObject.id}.thumb.jpg` } ,enumerable: true } ,image: { get() { return privateObject.image || this.thumbnail } ,set(val) { return privateObject.image = val } ,enumerable: true } ,isFollowing: { get() { return privateObject.isFollowing === true } ,set(val) { return privateObject.isFollowing = val } ,enumerable: true } ,url: { get() { // TODO make this gnerate a nicer link. nothing after the id really maters, but its nice to have a readable link return `${MANGADEX_BASE_URI}/title/${privateObject.id}/${mangadexStyleURIComponent(privateObject.title)}` } ,enumerable: true } ,lastViewedDate: { get() { return privateObject.lastViewedDate } } }) this.updateViewedTime = () => { privateObject.lastViewedDate = Date.now() } this.savable = () => privateObject return this } function AttemptParseMangaTitlePage(mangaList) { let id try { [,id] = window.location.href.match(/^https:\/\/mangadex\.org\/title\/(\d+)/) } catch (e) { return undefined } // return if this is not a tile page if (id == null) return undefined // Bulild manga entry const titleElm = xp.new(`//*[${XPath2.containsClass('card-header')} and ./span[${XPath2.containsClass('fa-book')}] ]`).getElement() const title = titleElm.textContent // At the least, we need to know the extention const imgElm = xp.new(`//*[${XPath2.containsClass('card-body')}]//img[starts-with(@src,'/images/manga/${id}')]`).getElement() const image = imgElm.src const descriptionElm = xp.new(`//*[${XPath2.containsClass('card-body')}]//div[./div[1][text() = 'Description:']]/div[2]`).getElement() const description = descriptionElm.textContent const followingElm = xp.new(`//*[${XPath2.containsClass('card-body')}]//div[./div[1][text() = 'Actions:']]/div[2]/div[${XPath2.containsClass('btn-group')}]/div[${XPath2.containsClass('dropdown-menu')} and ./a/span[@title='Follow'] ]/a[${XPath2.containsClass('disabled')}]`).getElement() let isFollowing = false if (followingElm) { isFollowing = true } mangaList.push({ title ,id ,description ,image ,isFollowing ,updateViewedTime: true }) } function AttemptParseMangaFollowsPage(mangaList) { // NOTE id was probably suppose to be titles. Expect it to change const entryXpath = `./div[${XPath2.containsClass('row')}]/div[${XPath2.attrHasValueStartingWith('@class' ,'col')}]` const titleXpath = `.//a[${XPath2.containsClass('manga_title')} and starts-with(@href,'/title/')]` const descXpath = `./div[preceding-sibling::ul[${XPath2.containsClass('list-inline')}] and @style]` // const chaptersElm = xp.new("//div[@id='chapters']").getElement() // return if this is not a tile page // if (chaptersElm == null) return undefined // xp.new(`./div[${XPath2.containsClass('row')}]/div[${XPath2.attrHasValueStartingWith('@class' ,'col')}]`) // .forEachElement((chapterElm) => { // generic over follows, search, titles, featured pages. xp.new(`//div/${entryXpath}[${titleXpath} and ${descXpath}]`) .forEachElement((chapterElm) => { // Bulild manga entry try { const titleElm = xp.new(`.//a[${XPath2.containsClass('manga_title')} and starts-with(@href,'/title/')]`).getElement(chapterElm) const title = titleElm.textContent const match = titleElm.getAttribute('href').match(/\/title\/(\d+)\//) if (match == null) return undefined const [,id] = match if (id == null) return undefined const descriptionElm = xp.new(`./div[preceding-sibling::ul[${XPath2.containsClass('list-inline')}] and @style]`).getElement(chapterElm) const description = descriptionElm.textContent let isFollowing if (window.location.href.match(/^https:\/\/mangadex\.org\/follows\//)) { isFollowing = true } mangaList.push({ title ,id ,description ,isFollowing ,updateViewedTime: false }) } catch (e) { dbg(e) throw e } return undefined }) } function AttemptParseMangaFollowUpdates(mangaList) { const isHome = window.location.href.match(/^https:\/\/mangadex\.org(\/[^/]*)?$/) != null // return if this is not on home page if (!isHome) return undefined const entries = xp.new(`//div[@id='follows_update']/div[${XPath2.containsClass('row')}]/div`) entries.forEachElement((entry) => { const titleElm = xp.new(`.//a[${XPath2.containsClass('manga_title')} and starts-with(@href,'/title/')]`).getElement(entry) const [,id] = titleElm.getAttribute('href').match(/\/title\/(\d+)\//) if (id == null) return undefined const title = titleElm.textContent // NOTE No point getting images for thumbnails ATM. we can generate those links const isFollowing = true mangaList.push({ title ,id ,isFollowing }) }) // Bulild manga entry } function MangaList({ list: loadableList = { followed: {} ,unfollowed: {} } ,titleHistLimit = 200 }) { const mangaList = this if (!(mangaList instanceof MangaList)) { return new MangaList() } this.maxSize = titleHistLimit mangaList.list = { followed: {} ,unfollowed: {} } const cleanupHistory = () => { if (Object.keys(this.list.unfollowed).length <= this.maxSize) return false let cnt = 0 this.list.unfollowed = Object.entries(this.list.unfollowed).sort(([,a] ,[,b]) => a.lastViewedDate > b.lastViewedDate).filter(() => { if (this.maxSize > cnt++) return true return false }).reduce((accum ,[k ,o]) => { accum[k] = o return accum } ,{}) return true } this.load = (val) => { Object.entries(val.followed).forEach(([k ,v]) => { this.list.followed[k] = new Manga(v) }) Object.entries(val.unfollowed).forEach(([k ,v]) => { this.list.unfollowed[k] = new Manga(v) }) } this.savable = () => { cleanupHistory() const obj = { followed: {} ,unfollowed: {} } Object.entries(mangaList.list.followed).forEach(([k ,v]) => { obj.followed[k] = v.savable() }) Object.entries(mangaList.list.unfollowed).forEach(([k ,v]) => { obj.unfollowed[k] = v.savable() }) return obj } this.push = ({ id ,description ,image ,title ,isFollowing ,updateViewedTime = false ,...mangaArgs }) => { const manga = this.list.followed[id] || this.list.unfollowed[id] || new Manga({ id ,...mangaArgs }) if (description) manga.description = description if (image) manga.image = image if (title) manga.title = title if (isFollowing != null) manga.isFollowing = isFollowing if (updateViewedTime) manga.updateViewedTime() if (manga.isFollowing) { this.list.followed[manga.id] = manga delete (this.list.unfollowed[manga.id]) } else { this.list.unfollowed[manga.id] = manga delete (this.list.followed[manga.id]) } } this.autoComplete = (partial_name ,{ case_sensitive = false ,fuzzy = true ,showUnfollowed = 0 } = {}) => { let matches = Object.values(this.list.followed).concat(showUnfollowed === 0 ? Object.values(this.list.unfollowed) : []).filter((e) => { // If this user is already marked as the highest priority match, dont process them anymore. const regex_partial_name = new RegExp(`${fuzzy ? '' : '^'}${partial_name}` ,`${case_sensitive ? '' : 'i'}`) if (e.title.match(regex_partial_name)) { return true } return false }) matches = stableSort(matches ,(a ,b) => { // List people whos names start with partial before those with partial anywhere in name if (fuzzy) { const regex_partial_name = new RegExp(`^${partial_name}` ,`${case_sensitive ? '' : 'i'}`) const am = a.title.match(regex_partial_name) != null const bm = b.title.match(regex_partial_name) != null if (am !== bm) { return bm } } // List those we are following before those we are not { const am = a.isFollowing const bm = b.isFollowing if (am !== bm) { return bm } } { const am = a.lastViewedDate const bm = b.lastViewedDate if (am > bm) return -1 if (am < bm) return 1 } return 0 }) return matches } this.load(loadableList) return this } function History({ history: loadedHistory = [] ,historySize = 200 } = {}) { const uhist = this if (!(uhist instanceof History)) { return new History() } function clipText(text ,max_length) { return (text.length > max_length) ? `${text.substr(0 ,max_length - 1)}…` : text } function getVisibleText(node) { if (node.nodeType === Node.TEXT_NODE) return node.textContent const style = getComputedStyle(node) if (style && style.display === 'none') return '' let text = '' for (let i = 0; i < node.childNodes.length; i++) text += getVisibleText(node.childNodes[i]) return text } const cleanupHistory = () => { if (this.history.size > this.max_size) { // delete(this.history.entries().next().value[0]); this.history.shift() } } this.max_size = historySize this.history = loadedHistory this.push = (item) => { function array_move(arr ,old_index ,new_index) { arr.splice(new_index ,0 ,arr.splice(old_index ,1)[0]) return arr } let exists = false this.history.some((e ,k) => { if (e.id === item.id) { exists = true array_move(this.history ,k ,0) return true } return false }) if (exists) { return false } this.history.unshift(item) cleanupHistory() } this.autoComplete = (partial_name ,{ case_sensitive = false ,fuzzy = true } = {}) => { let matches = this.history.filter((e) => { // If this user is already marked as the highest priority match, dont process them anymore. const regex_partial_name = new RegExp(`${fuzzy ? '' : '^'}${partial_name}` ,`${case_sensitive ? '' : 'i'}`) if (e.user_name.match(regex_partial_name)) { return true } return false }) matches = stableSort(matches ,(a ,b) => { // List those from this thread before other threads { const am = a.thread_id === thread_id const bm = b.thread_id === thread_id if (am !== bm) { return bm } } // List people whos names start with partial before those with partial anywhere in name if (fuzzy) { const regex_partial_name = new RegExp(`^${partial_name}` ,`${case_sensitive ? '' : 'i'}`) const am = a.user_name.match(regex_partial_name) != null const bm = b.user_name.match(regex_partial_name) != null if (am !== bm) { return bm } } // List those who mentioned us before those who did not. if (a.did_mention !== b.did_mention) { return b.did_mention } }) const seen = {} matches = matches.filter((e) => { if (seen[e.user_id]) { return false } seen[e.user_id] = true return true }) return matches } return this } const posts = xp.new('//tr').with(xp.new().contains('@class' ,'post')) // Because Javascript's does not require .sort to be Stable. // Currently Chrome alone uses Unstable sort. They are now moving to Stable. // This returns the same results for all browsers, // userid = Your user ID function User({ name ,id ,img }) { const user = this if (!(user instanceof User)) { return new User() } user.name = name user.id = id user.img = img return user } function UserList({ list = {} }) { const userList = this if (!(userList instanceof UserList)) { return new UserList() } userList.list = list userList.push = (user) => { userList.list[user.id] = user } return userList } function Post({ post_id ,time ,user_id ,thread_id }) { const post = this if (!(post instanceof Post)) { return new Post() } post.user_id = user_id post.thread_id = thread_id post.id = post_id post.time = time return post } function Thread({ id ,title ,manga_id }) { const thread = this if (!(thread instanceof Thread)) { return new Thread() } thread.id = id thread.title = title thread.manga_id = manga_id return thread } function UserHistory({ read_posts_history = [] ,user_id ,username ,historySize = 200 } = {}) { const uhist = this if (!(uhist instanceof UserHistory)) { return new UserHistory() } const cleanupHistory = () => { while (this.history.length > this.max_size) { // delete(this.history.entries().next().value[0]); this.history.pop() } } this.user_id = user_id this.username = '' // get from userid this.max_size = parseInt(historySize) this.history = read_posts_history this.push = (post) => { const post_id = parseInt(post.id.replace(/^post_/ ,'')) // this.history.delete(post_id); // this.history.set(post_id,{user_id:user_id,user_img:user_img,excerpt:excerpt}); // this.history.filter((e)=> { e.thread_id === thread_id } ); function array_move(arr ,old_index ,new_index) { arr.splice(new_index ,0 ,arr.splice(old_index ,1)[0]) return arr } let exists = false this.history.some((e ,k) => { if (e.post_id === post_id) { exists = true array_move(this.history ,k ,0) return true } return false }) if (exists) { return false } let time let thread let thread_id let user let user_name let user_level let user_color let user_img let postContents let did_mention try { time = xp.new('.//span').with(xp.new('./span').with(xp.new().contains('@class' ,'fa-clock'))).getElement(post).title thread = xp.new('./td/span/a').with(xp.new('preceding-sibling::span').with(xp.new().contains('@class' ,'fa-clock'))).getElement(post).href thread_id = parseInt(thread.match(/\/thread\/(\d+)\//)[1]) user = xp.new('.//a[contains(@class,"user_level") and starts-with(@href,"/user/")]').getElement(post) user_name = user.textContent // TODO: actualy store user level user_level = user.className user_color = user.style.color user_id = parseInt(user.href.match(/\/user\/(\d+)\//)[1]) user_img = xp.new(`.//img[${XPath2.containsClass('avatar')}]`).getElement(post).src postContents = xp.new('.//div').with(xp.new().contains('@class' ,'postbody')).getElement(post) did_mention = Boolean(xp.new(`.//a[@href="https://mangadex.org/user/${uhist.user_id}"]`).getElement(postContents)) } catch (e) { dbg('Error occured while trying to parse post.') dbg(post) dbg(e) // an error occured return undefined } // cleanText. Hide spoilers and other invisible crap const cleanText = getVisibleText(postContents) const excerpt = clipText(cleanText ,100) this.history.unshift({ thread_id ,user_name ,user_level ,user_color ,user_id ,user_img ,did_mention ,post_id ,excerpt ,time }) cleanupHistory() } this.autoComplete = (partial_name ,{ thread_id = 0 ,case_sensitive = false ,fuzzy = true ,showUsersWho = 3 } = {}) => { let matches = this.history.filter((e) => { // If this user is already marked as the highest priority match, dont process them anymore. const regex_partial_name = new RegExp(`${fuzzy ? '' : '^'}${partial_name}` ,`${case_sensitive ? '' : 'i'}`) if (e.user_name.match(regex_partial_name)) { if (showUsersWho === 2) return true if (showUsersWho === 1 && e.did_mention) return true if (showUsersWho <= 1 && e.thread_id === thread_id) return true return false } return false }) matches = stableSort(matches ,(a ,b) => { // List those from this thread before other threads { const am = a.thread_id === thread_id const bm = b.thread_id === thread_id if (am !== bm) { return bm } } // List people whos names start with partial before those with partial anywhere in name if (fuzzy) { const regex_partial_name = new RegExp(`^${partial_name}` ,`${case_sensitive ? '' : 'i'}`) const am = a.user_name.match(regex_partial_name) != null const bm = b.user_name.match(regex_partial_name) != null if (am !== bm) { return bm } } // List those who mentioned us before those who did not. if (a.did_mention !== b.did_mention) { return b.did_mention } }) const seen = {} matches = matches.filter((e) => { if (seen[e.user_id]) { return false } seen[e.user_id] = true return true }) return matches } return this } function getCurrentUserID() { xp.new('id("navbarSupportedContent")').with(xp.new().contains('@class' ,'navbarSupportedContent')) const current_user_id = xp.new('id("navbarSupportedContent")//a[contains(@href,"/user/")]').getElement().href.match(/\/user\/(\d+)\//)[1] return parseInt(current_user_id) } function initSettingsDialog({ loaded_settings ,atWhoMethods }) { const settingsUi = new SettingsUI({ groupName: 'Auto-Complete' ,settingsTreeConfig: { saveLocation: 'settings' ,autosave: true } }) const autocompleteTypes = settingsUi.addMultiselect({ title: 'Autocompletes' ,key: 'autocompleteTypes' ,titleText: 'What all should we will autocomplete?' ,placeholder: 'Autocompletion disabled! Click here to re-enable...' }) autocompleteTypes.addOption({ key: 'usernames' ,title: '@Mentions' ,settingsTreeConfig: { defaultValue: true ,onchange: () => { atWhoMethods.rebuild() } } }) autocompleteTypes.addOption({ key: 'titles' ,title: ':Title' ,settingsTreeConfig: { defaultValue: true ,onchange: () => { atWhoMethods.rebuild() } } }) /* const userCompletionCharCount = settingsUi.addTextbox({ key: 'userCompletionCharCount' ,title: 'Minimum username length' ,settingsTreeConfig: { defaultValue: 0 ,corrector: newNumberCorrector(0 ,10) } ,min: 0 ,max: 10 ,titleText: 'Mnimum number of characters in the Username you must type before autocompletion starts. Default: 0' ,type: 'number' }) */ const showUsersWho = settingsUi.addSelect({ title: 'Show users who' ,key: 'showUsersWho' // ,placeholder: 'Are in this thread' // ,branchingSingleselect: true ,settingsTreeConfig: { defaultValue: 0 } }) showUsersWho.addOption({ // key: 'areInThread' // key: 0 title: 'Are in this thread' // ,settingsTreeConfig: { defaultValue: true } }) showUsersWho.addOption({ // key: 'haveMentionedYou' title: 'Have @mentioned you' // ,settingsTreeConfig: { defaultValue: true } }) showUsersWho.addOption({ // key: 'everyone' title: 'We know exist' // ,settingsTreeConfig: { defaultValue: true } }) function newNumberValidator(min ,max) { return (textVal) => { const val = parseInt(textVal) if (textVal.match(/[^0-9]/) || typeof val !== 'number' || val < min || val > max) { throw new SettingsUIValidationError({ feedback: `Must be a number between ${min} and ${max}` }) } return true } } function newNumberCorrector(min ,max) { return (textVal) => { const val = parseInt(textVal) if (textVal == null || (typeof textVal === 'string' && (textVal.length === 0 || textVal.match(/[^0-9]/))) || typeof val !== 'number') { return undefined } if (val < min) return min if (val > max) return max // dont triger callback via type change. return textVal } } const userHistLimit = settingsUi.addTextbox({ key: 'max_post_history' ,title: 'User History Size' ,settingsTreeConfig: { defaultValue: 200 ,corrector: newNumberCorrector(20 ,2000) } ,min: 20 ,max: 2000 ,titleText: 'Maximum number of user posts we should remember. Used for @mention autocompletion' ,type: 'number' }) const titleCompletionChar = settingsUi.addTextbox({ key: 'titleCompletionChar' ,title: 'Title Completion Trigger' ,settingsTreeConfig: { defaultValue: ':' ,onchange: () => { atWhoMethods.rebuild() } // ,corrector: newNumberCorrector(0 ,2000) } ,titleText: 'Character(s) you must type in order to trigger Manga Title auto completion. default: colon character <:>' }) /* const titleCompletionCharCount = settingsUi.addTextbox({ key: 'titleCompletionCharCount' ,title: 'Minimum title length' ,settingsTreeConfig: { defaultValue: 200 ,corrector: newNumberCorrector(0 ,10) } ,min: 0 ,max: 10 ,titleText: 'Mnimum number of characters in the Title you must type before autocompletion starts. Default: 0' ,type: 'number' }) */ const showUnfollowed = settingsUi.addSelect({ title: 'Unfollowed manga is' ,key: 'showUnfollowed' // ,placeholder: 'Are in this thread' // ,branchingSingleselect: true ,settingsTreeConfig: { defaultValue: 0 } }) showUnfollowed.addOption({ title: 'Shown' // ,value: true }) showUnfollowed.addOption({ title: 'Hiden' // ,value: false }) const autocompleteTitleInto = settingsUi.addMultiselect({ title: 'Title to bbcode' ,key: 'autocompleteTitleInto' ,titleText: 'Autocompleted Manga titles can be transformed into bbcode!' ,placeholder: 'No Magic! ...Just a title please' // ,branchingSingleselect: true // ,settingsTreeConfig: { defaultValue: 0 } }) autocompleteTitleInto.addOption({ key: 'thumbnail' ,title: 'Thumbnail' ,settingsTreeConfig: { defaultValue: false } }) autocompleteTitleInto.addOption({ key: 'link' ,title: 'Link' ,settingsTreeConfig: { defaultValue: true } }) autocompleteTitleInto.addOption({ key: 'description' ,title: 'Description Spoiler' // potentialy way to long to put outside a spoiler ,settingsTreeConfig: { defaultValue: false } }) const autocompleteTitleStyle = settingsUi.addMultiselect({ title: 'Title style' ,key: 'autocompleteTitleStyle' ,titleText: 'BBCode formating style' ,placeholder: 'Let me format it myself!' // ,branchingSingleselect: true // ,settingsTreeConfig: { defaultValue: 0 } }) autocompleteTitleStyle.addOption({ key: 'center' ,title: 'Center' ,settingsTreeConfig: { defaultValue: false } }) const titleHistLimit = settingsUi.addTextbox({ key: 'max_title_history' ,title: 'Unfollowed memory size' ,settingsTreeConfig: { defaultValue: 10 ,corrector: newNumberCorrector(0 ,2000) } ,min: 0 ,max: 2000 ,titleText: 'Maximum number of Non-Followed titles we should remember. Used for :Title autocompletion. Added to history when you visit the title page.' ,type: 'number' }) // Load our saved settings object into the ui // settingsUi.settingsTree.load_all() settingsUi.settingsTree.value = loaded_settings // return new settings object which is bound to the UI. const settings = settingsUi.settingsTree.value return settings } function formatMangaItem(item) { // When getters/setters are present, the object is assigned to name. For some reason const obj = item.name // TODO text carosel for long names return `<li class="dropdown-item px-0 " style=""> <div class="d-flex justify-content-between align-items-center px-2" style="height:50px; max-width: 400px;" title="${obj.description != null ? clipText(obj.description ,1000) : 'Description unavailible until you visit the title page'}"> <div class="h-100"> <span class="${obj.isFollowing ? 'far fa-bookmark' : ''}"></span> <img src="${obj.image}" class="mh-100 rounded avatar"/> </div> <span class="manga_title d-inline-block text-truncate" style="">${obj.title}</span> </div></li>` } function main({ read_posts_history ,mangaTitleHistory ,settings: loaded_settings }) { const atWhoMethods = { rebuild: () => undefined } const settings = initSettingsDialog({ loaded_settings ,atWhoMethods }) // Manga History const mangaList = new MangaList({ list: mangaTitleHistory ,titleHistLimit: settings.max_title_history }) AttemptParseMangaTitlePage(mangaList) AttemptParseMangaFollowUpdates(mangaList) AttemptParseMangaFollowsPage(mangaList) setUserValue('mangaTitleHistory' ,mangaList.savable()) unsafeWindow.mangaList = mangaList // User History const user_id = getCurrentUserID() const uhist = new UserHistory({ read_posts_history ,user_id // NOTE History size changes will only take effect once a new post is seen. ,historySize: settings.max_post_history }) unsafeWindow.uhist = uhist unsafeWindow.settings = settings // Add current page's posts to history. let thread = xp.new(posts).append('//td/span/a').with(xp.new('preceding-sibling::span').with(xp.new().contains('@class' ,'fa-clock'))).getElement() if (thread) { thread = thread.href const thread_id = parseInt(thread.match(/\/thread\/(\d+)\//)[1]) if (window.location.pathname.startsWith('/thread/')) { posts.forEachOrderedElement((post) => { uhist.push(post) }) } else { // Consider more efficient approch const snap = posts.getOrderedSnapshot() for (let i = snap.snapshotLength - 1; i >= 0; i--) { uhist.push(snap.snapshotItem(i)) } } setUserValues({ read_posts_history: uhist.history }) // NOTE there can be more than one textarea. but they all use the same id :O function autoComplete(partial_name ,render_view) { const r = uhist.autoComplete(partial_name ,{ thread_id ,case_sensitive: false ,fuzzy: true ,showUsersWho: settings.showUsersWho }) render_view(r) } function autoCompleteManga(partial_name ,render_view) { const r = mangaList.autoComplete(partial_name ,{ case_sensitive: false ,fuzzy: true ,showUnfollowed: settings.showUnfollowed }) render_view(r) } function formatDisplayItem(item) { return `<li class="dropdown-item px-0 " style=""><div class="d-flex justify-content-between align-items-center px-2" style="height:50px;" title="${item.excerpt}"> <div class="h-100"> <span class="">${item.did_mention ? '@' : ''}</span> <span class="${item.thread_id === thread_id ? 'far fa-comments' : ''}"></span> <img src="${item.user_img}" class="mh-100 rounded avatar"/> </div> <span class="${item.user_level}" style="color:${item.user_color};">${item.user_name}</span> </div></li>` } atWhoMethods.rebuild = () => { $('textarea[id="text"]').atwho('destroy') if (settings.autocompleteTypes.usernames) { $('textarea[id="text"]').atwho({ at: '@' ,displayTpl: formatDisplayItem ,insertTpl: '${atwho-at}${user_name}' ,searchKey: 'user_name' // We don't want to use your filter or sorter. remoteFilter is a better fit for us. ,data: [] // data: uhist.history, ,limit: 200 ,callbacks: { remoteFilter: autoComplete // NoOp ,sorter: (_ ,i) => i } }) } if (settings.autocompleteTypes.titles) { $('textarea[id="text"]').atwho({ at: settings.titleCompletionChar ,displayTpl: formatMangaItem ,insertTpl: ({ 'atwho-at': atwhoat ,name: item }) => { const link = settings.autocompleteTitleInto.link ? { open: `[url=${item.url}]` ,close: '[/url]' } : { open: '' ,close: '' } const alignment = settings.autocompleteTitleStyle.center ? { open: '[center]' ,close: '[/center]' } : { open: '' ,close: '' } const img = settings.autocompleteTitleInto.thumbnail ? `\n${alignment.open}${link.open}[img]${item.thumbnail}[/img]${link.close}${alignment.close}` : '' const desc = settings.autocompleteTitleInto.description && item.description != null ? ` | Description: [spoiler]${item.description}[/spoiler]` : '' return `${alignment.open}${link.open}${item.title}${link.close}${alignment.close}${desc}${img}` } ,searchKey: 'title' ,data: [] // ,data: [...Object.values(mangaList.list.followed) ,...Object.values(mangaList.list.unfollowed)] ,limit: 200 ,callbacks: { remoteFilter: autoCompleteManga // NoOp ,sorter: (_ ,i) => i } }) } $('.atwho-container').addClass('container ') $('.atwho-view').css({ display: 'none' }) $('.atwho-view-ul').addClass('pre-scrollable d-inline-flex flex-column') } atWhoMethods.rebuild() // HACK make atwho use flex instead of block // This also works instead of inline flex on ul, but it makes the container visible until atwho is invoked // Hide now. atwho changes display between none and whatever it is already set to automaticly // $('.atwho-view').css({display:'flex'}) // $('.atwho-view-ul').addClass('pre-scrollable ') // These make atwho dropdown menu use the same color theme as the site // const atwhoStylesheet = addCssLink('https://gitcdn.xyz/repo/ichord/At.js/1b7a52011ec2571f73385d0c0d81a61003142050/dist/css/jquery.atwho.css') const customStylesheet = insertStylesheet('') // HACK dropdown-menu breaks atwho autoscroll somehow. lets just copy parts of the theme duplicate_cssRule({ origSelector: '.dropdown-menu' ,newSelector: '.atwho-view-ul' ,matchProperties: ['background' ,'color' ,'border' ,'margin' ,'padding'] // ,ignoredStylesheets: [atwhoStylesheet] ,targetStylesheet: customStylesheet }) duplicate_cssRule({ origSelector: '.dropdown-menu.show' ,newSelector: '.atwho-view-ul' ,matchProperties: ['background' ,'color' ,'border' ,'margin' ,'padding'] // ,ignoredStylesheets: [atwhoStylesheet] ,targetStylesheet: customStylesheet }) // make the selected atwho user be highlighted using the same color theme as the site. duplicate_cssRule({ origSelector: '.dropdown-item:hover, .dropdown-item:focus' ,newSelector: '.atwho-view .cur' ,matchProperties: ['background' ,'color'] // ,ignoredStylesheets: [atwhoStylesheet] ,targetStylesheet: customStylesheet }) // For debugging // unsafeWindow.uhist = uhist } } // setTimeout( () => { getUserValues({ read_posts_history: [] ,mangaTitleHistory: { followed: {} ,unfollowed: {} } ,settings: {} }).then(main)