// ==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/Christopher-McGinnis
// @author Christopher McGinnis
// @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/Christopher-McGinnis/Mangadex-Userscripts/a480c30b64fba63fad4e161cdae01e093bce1e4c/common.js
// @require https://gitcdn.xyz/repo/Christopher-McGinnis/Mangadex-Userscripts/21ec54406809722c425c39a0f5b6aad59fb3d88d/uncommon.js
// @require https://gitcdn.xyz/repo/Christopher-McGinnis/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)