Greasy Fork is available in English.

TGchan ID Tracker

Provides info about each poster and allows navigating poster's posts.

Från och med 2018-06-17. Se den senaste versionen.

// ==UserScript==
// @name        TGchan ID Tracker
// @namespace   TGchanIDTracker
// @include     *//tgchan.org/kusaba/quest/res/*
// @include     *//tgchan.org/kusaba/questarch/res/*
// @include     *//tgchan.org/kusaba/questdis/res/*
// @include     *//tgchan.org/kusaba/graveyard/res/*
// @description Provides info about each poster and allows navigating poster's posts.
// @version     5
// @grant       none
// @icon        data:image/vnd.microsoft.icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsSAAALEgHS3X78AAAANklEQVQokWNgoBOI2mJKpEomMvQgNAxRPUy4JGjjJJqoZoSrZmBgWOZzGlk/mlKILBMafxAAAE1pG/UEXzMMAAAAAElFTkSuQmCC
// ==/UserScript==
"use strict";
var timeStart = Date.now();
if (document.body.querySelector("#navUp")) { //sanity check; don't run the script if it already ran
  return;
}
var threadID = document.querySelector("[id$=-y]").id;
var boardName = threadID.split("-")[1];
var posts = getPosts(); //extract and cache whatever stuff we need from DOM;
var userNodes = {}; //dictionary => posterId / user node ; we use these nodes to build a graph, connecting one user to the other
doPartialUnionFind(); //do a partial union-find run to determine if this is an image quest
var author = find(makeSet(posts[0].userID)); //author is the user that made the first post
var imageQuest = boardName != "questdis" && (author.fileCount / author.postCount) >= 0.5; //Image quests are when the author posts images more than 50% of the time.
if (!imageQuest) { //do normal union-find again from the start
  userNodes = {};
  doNormalUnionFind();
  author = find(userNodes[posts[0].userID]);
}
else { //continue from the previous partial union-find, but adhere to certain union rules
  doConditionalUnionFind(); //specifically, even if author's ID has a link to some other user's ID, we will not merge the two unless that other user posted files
  var lastNumberInStringRegex = new RegExp("([0-9]+)([^0-9]*)$"); //regular expressions must be precompiled since we'll be using each thousands of times
  doUserAnalytics(); //do some advanced analytics to determine which users could also be the author and consequently merge them with the author
}
setPostUsers(); //do final user unification
setPostCountElements(); //set correct post count to our cached elements based on which user they belong to
var hrefString = "/kusaba/" + boardName + "/res/" + threadID.split("-")[2] + ".html#";
setPostLinkElements(); //set correct targets to our up/down link elements based on which user they belong to
setPostUpdates(); //set a flag to posts that are updates
if (boardName != "questdis") {
  setPostMetadata(); //add additional data next to our elements, such as when a poster made multiple posts within an update
}
//finally insert stuff into DOM
insertButtonIcons(); //add icons for our elements; f*ck firefox for using shitty emoji characters
insertCSS(); //add CSS for our elements
insertPostInfo(); //add poster info next to each post's ID
//console.log(getUsers()); //for debugging purposes; outputs a list of users and the IDs that each user uses
console.log(`TGchan ID Tracker run time = ${Date.now() - timeStart}ms`);
//THE END

//functions ; things which should be simple but are not
function getPosts() {
  var defaultName = boardName == "questdis" ? "Anonymous" : "Suggestion";
  var defaultFragment = createDefaultFragment(); //optimization => create a generic document fragment with post count and up/down link, which we'll be cloning
  var postsIndex = {}; //dictionary => postID / post object
  var postsArray = []; //array => post object
  document.querySelectorAll(".postwidth").forEach(function(postHeaderElement) { //querySelectorAll is FASTER than getElementsByClassName when DOM is large
	var postID = postHeaderElement.querySelector("span[id^=dnb]").id.split("-")[2];
	if (!postsIndex[postID]) { //checking this may seem unnecessary, but it's required for compatibility with some imageboard extensions
	  var uidElement = postHeaderElement.querySelector(".uid");
	  var uid, name, trip;
	  uid = uidElement.textContent.substring(4);
	  trip = postHeaderElement.querySelector(".postertrip");
	  if (trip) { //use tripcode instead of name if it exists
		name = trip.textContent;
	  }
	  else {
		name = postHeaderElement.querySelector(".postername").textContent.trim();
		if (name && name == defaultName) {
		  name = "";
		}
	  }
	  postsIndex[postID] = {
		postID: postID,
		userID: uid,
		name: name,
		subject: postHeaderElement.querySelector(".filetitle"),
		fileElement: postHeaderElement.querySelector(".filesize"),
		activeContent: postHeaderElement.nextElementSibling.querySelector("img, iframe"), //if the blockquote contains icons (or iframes heh)
		insertElement: uidElement, //element next to which we're gonna be inserting our stuff.
		fragment: defaultFragment.cloneNode(true) //Optimization: Use a temporary documentFragment for each reply to minimize DOM insertions; all of the insertions are done at the end
	  };
	}
  });
  //convert to an array. This may seem unnecessary, but it's done in a milisecond so... *shrug*; allows me to use forEach()
  for (var postID in postsIndex) {
	postsArray.push(postsIndex[postID]);
  }
  return postsArray;
}
function createDefaultFragment() {
  var fragment = document.createDocumentFragment();
  fragment.appendChild(document.createRange().createContextualFragment(
`<span class="postCount inherit"></span>
<a class="disabled"><svg class="navIcon"><use xlink:href="#navUp"/></svg></a>
<a class="disabled"><svg class="navIcon"><use xlink:href="#navDown"/></svg></a>`));
  return fragment;
}
function doNormalUnionFind() {
  posts.forEach(function(post) {
	var user = !post.name ? find(makeSet(post.userID)) : union(makeSet(post.userID), makeSet(post.name));
	user.postCount++;
	if (post.fileElement || post.activeContent) {
	  user.fileCount++;
	}
  });
}
function doPartialUnionFind() {
  posts.forEach(function(post) {
	if (post.fileElement || post.activeContent) {
	  var user = !post.name ? find(makeSet(post.userID)) : union(makeSet(post.userID), makeSet(post.name));
	  user.postCount++;
	  user.fileCount++;
	}
  });
  posts.forEach(function(post) {
	if (!post.fileElement && !post.activeContent) {
	  var userFromID = userNodes[post.userID];
	  var userFromName = userNodes[post.name];
	  if (userFromID && userFromName) {
		union(userFromID, userFromName).postCount++;
	  }
	  else if (userFromID) {
		find(userFromID).postCount++;
	  }
	  else if (userFromName) {
		find(userFromName).postCount++;
	  }
	}
  });
}
function doConditionalUnionFind() {
  var alreadyCounted = {};
  for (var userNode in userNodes) {
	alreadyCounted[userNode] = true;
  }
  posts.forEach(function(post) {
	if (!post.fileElement && !post.activeContent) {
	  var userFromID = find(makeSet(post.userID));
	  if (post.name) {
		var userFromName = find(makeSet(post.name));
		if (userFromID != author && userFromName != author) {
		  if (!alreadyCounted[userFromID.id] && !alreadyCounted[userFromName.id]) {
			userFromID.postCount++;
		  }
		  union(userFromID, userFromName);
		}
	  }
	  else {
		if (!alreadyCounted[userFromID.id]) {
		  userFromID.postCount++;
		}
	  }
	}
  });
  posts.forEach(function(post) {
	if (!post.fileElement && !post.activeContent) {
	  var userFromID = find(makeSet(post.userID));
	  if (post.name) {
		var userFromName = find(makeSet(post.name));
		if ((userFromID == author && userFromName != author && userFromName.fileCount > 0) || (userFromName == author && userFromID != author && userFromID.fileCount > 0)) {
		  console.log(`Merged ${userFromID == author ? userFromID : userFromName} with author (post ${post.postID}) (indirect name/ID link)`);
		  author = union(userFromID, userFromName);
		}
	  }
	}
  });
}
function doUserAnalytics() {
  //consider posters, who follow the file naming scheme, as being the quest author
  //consider posters, who create posts that contain both icons and files, as the quest author
  //consider posters, who post images at least 75% of the time and who posted 5+ images, as being the quest author. Actually, nevermind. This doesn't seem to work well.
  var predictedFileName;
  var lastFileNames = [];
  var lastUsers = [];
  lastUsers.push(author);
  posts.forEach(function(post) {
	if (post.fileElement && post.fileElement.firstChild.textContent.trim() == "File") {
	  var user = find(makeSet(post.userID));
	  var fileInfo = post.fileElement.lastChild.textContent.split(", "); //flash files have no original name
	  var fileName = fileInfo[2] ? fileInfo[2].split("\n")[0] : "";
	  if (post.postID == "1047") {
		var asd = "asd";
	  }
	  if (user == author) {
		var backwardsPredictedName = getPredictedFileName(fileName, -1);
		for (let i = 0; i < lastUsers.length; i++) {
		  if (lastUsers[i] != author && lastFileNames[i] == backwardsPredictedName) { //also predict backwards :)
			author = union(lastUsers[i], author);
			console.log(`Merged ${lastUsers[i].id} with author (post ${post.postID}) for file naming scheme (backwards)`);
		  }
		}
		predictedFileName = getPredictedFileName(fileName, 1);
		lastFileNames = [];
		lastUsers = [];
	  }
	  else {
		if (fileName == predictedFileName) {
		  author = union(user, author);
		  console.log(`Merged ${user.id} with author (post ${post.postID}) for file naming scheme`);
		  predictedFileName = getPredictedFileName(fileName, 1);
		  user = author;
		}
		else if (post.activeContent) {
		  author = union(user, author);
		  console.log(`Merged ${user.id} with author (post ${post.postID}) for making a post with file and icons`);
		  predictedFileName = getPredictedFileName(fileName, 1);
		  user = author;
		}
		/*else if (user.fileCount >= 5 && (user.fileCount / user.postCount) >= 0.75) {
		  author = union(user, author);
		  console.log(`Merged ${user.id} with author (post ${post.postID}) for being a common image poster`);
		  predictedFileName = getPredictedFileName(fileName, 1);
		  user = author;
		}*/
		lastFileNames.push(fileName);
		lastUsers.push(user);
	  }
	}
  });
}
function setPostUsers() {
  posts.forEach(function(post) {
	post.user = find(makeSet(post.userID));
	if (post.name) {
	  var userFromName = find(makeSet(post.name));
	  if (post.user != userFromName && userFromName == author) {
		post.user = userFromName; //in case a post belongs to two unconnected users, heh, which can only happen if one of them is the author, set its owner to the one that is the author
	  }
	}
  });
}
function setPostCountElements() {
  posts.forEach(function(post) {
	post.fragment.firstChild.textContent = "(" + post.user.postCount + ")";
  });
}
function setPostLinkElements() {
  var lastPosts = {}; //dictionary => userID / post object; remember last post for each poster so that we can create links between them
  posts.forEach(function(post) {
	var lastPost = lastPosts[post.user.id];
	if (lastPost) {
	  setLinkTarget(post.fragment.children[1], lastPost.postID);
	  setLinkTarget(lastPost.fragment.children[2], post.postID);
	}
	lastPosts[post.user.id] = post;
  });
}
function setLinkTarget(link, targetPostID) {
  link.className = "inherit";
  link.href = hrefString + targetPostID;
  link.onclick = function() { return highlight(targetPostID, true); };
  //unfortunately, previews break links which navigate down to a later post. TGchan <:)
  //link.className = "ref|"+boardName+"|"+threadID+"|" + targetPostID;
  //addmouseoverevents(link, targetPostID);
}
/*function addmouseoverevents(link, targetPostID) {
  if (link.addEventListener) {
    link.addEventListener("mouseover", addreflinkpreview, false);
    link.addEventListener("mouseout", delreflinkpreview, false);
  }
  else if (link.attachEvent) {
    link.attachEvent("onmouseover", addreflinkpreview);
    link.attachEvent("onmouseout", delreflinkpreview);
  }
}*/
function setPostUpdates() {
  // Updates are posts made by the author and, in case of image quests, author post that contain files or icons
  var partialUpdateRegex = new RegExp("[0-9][ ]*/[ ]*[0-9]");
  posts.forEach(function(post) {
	if (post.user == author && (!imageQuest || (post.fileElement || post.activeContent || isPartialUpdateSubject(post.subject, partialUpdateRegex)))) {
	  post.isUpdate = true;
	}
  });
}
function isPartialUpdateSubject(subject, partialUpdateRegex) {
  if (subject) {
	return subject.textContent.trim().match(partialUpdateRegex);
  }
  return false;
}
function setPostMetadata() {
  var lastPost = posts[(posts.length - 1)];
  var suggestionsGroup = {}; //dictionary => userID / array[postID] ; a group of posts made in-between two updates, grouped by user ID
  posts.forEach(function(post) {
	if (!post.isUpdate) {
	  if (!suggestionsGroup[post.user.id]) {
		suggestionsGroup[post.user.id] = [];
	  }
	  suggestionsGroup[post.user.id].push(post);
	}
	if (post.isUpdate || post == lastPost) { //process the group of suggestions
	  detectMultiposts(suggestionsGroup);
	  suggestionsGroup = {};
	}
	if (!post.isUpdate && post.fileElement) {
	  post.fragment.appendChild(getNewCrimsonSpan(" (non-update)"));
	}
	if (!post.isUpdate && post.user == author) {
	  post.fragment.appendChild(getNewCrimsonSpan(" (clarification)"));
	}
  });
}
function detectMultiposts(suggestionsGroup) {
  for (var userID in suggestionsGroup) {
	var user = suggestionsGroup[userID][0].user;
	var suggestions = suggestionsGroup[userID];
	var count = suggestions.length
	if (count == user.postCount) {
	  for (let i = 0; i < count; i++) { //mark post count for (new) posters who only made posts in this update
		suggestions[i].fragment.firstChild.className = "postCount crimson";
	  }
	}
	if (count > 1 && user != author) {
	  for (let i = 0; i < count; i++) { //mark posts of the users that made multiple posts inside an update
		suggestions[i].fragment.appendChild(getNewCrimsonSpan(" post " + (i+1) + "/" + count));
	  }
	}
  }
}
function getNewCrimsonSpan(text) {
  var newSpan = document.createElement("span");
  newSpan.textContent = text;
  newSpan.className = "crimson";
  return newSpan;
}

function getPredictedFileName(fileName, inc) {
  if (!fileName) {
	return;
  }
  var matches = fileName.match(lastNumberInStringRegex);
  if (!matches) {
	return "";
  }
  //e.g. quest50.png => quest51.png
  return fileName.replace(lastNumberInStringRegex, (+matches[1]+inc+"").padStart(matches[1].length,0)+"$2");
}

function insertCSS() {
  var newStyle = document.createElement("style");
  newStyle.innerText = " .inherit { color: inherit; } .crimson { color: crimson; } .disabled { color: grey; } .disabled:hover { color: grey; } .postCount { margin-left: 3px; } .navIcon { width: 12px; height: 18px; vertical-align: text-top; }";
  document.body.appendChild(newStyle);
}
function insertButtonIcons() {
  document.body.insertAdjacentHTML("beforeend",`
<div style="height: 0; width: 0; position: fixed;">
 <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <symbol viewBox="0 0 24 24" id="navUp"><path stroke="currentColor" fill="none" stroke-width="3" stroke-miterlimit="10" d="M3 22.5l9-9 9 9M3 13.5l9-9 9 9"/></symbol>
  <symbol viewBox="0 0 24 24" id="navDown"><path stroke="currentColor" fill="none" stroke-width="3" stroke-miterlimit="10" d="M3 11.5l9 9 9-9M3 2.5l9 9 9-9"/></symbol>
 </svg>
</div>`);
}
function insertPostInfo() {
  posts.forEach(function(post) {
	post.insertElement.parentNode.insertBefore(post.fragment, post.insertElement.nextSibling);
  });
}
function getUsers() {
  var isIDRegEx = new RegExp("^[0-9a-f]{6}$");
  var users = Object.values(userNodes).filter(u => u.parent == u).map(u => Object.keys(userNodes).filter(k => find(userNodes[k]) == u));
  var sortedUsers = users.filter(u => u.length > 1 && find(userNodes[u[0]]) != author);
  var anons = users.filter(u => u.length == 1 && u[0] != author.id);
  sortedUsers.sort((a, b) => { return find(userNodes[b[0]]).postCount - find(userNodes[a[0]]).postCount; });
  sortedUsers.splice(0, 0, users.filter(u => find(userNodes[u[0]]) == author)[0]);
  sortedUsers.forEach(function(arr) {
	arr.sort((a,b) => { var am = isIDRegEx.test(a); var bm = isIDRegEx.test(b); if (am && bm || !am && !bm) return a.localeCompare(b); else return am ? 1 : -1; });
	arr.splice(0, 0, find(userNodes[arr[0]]).postCount);
  });
  anons.sort((a,b) => { return userNodes[b].postCount - userNodes[a].postCount; });
  anons = anons.map( anonID => `${anonID} (${userNodes[anonID].postCount})` );
  anons.splice(0, 0, "Anons:");
  sortedUsers.push(anons);
  return sortedUsers;
}

//Welcome to UNION-FIND. I'm using this algorithm to group id's and names
function makeSet(id) {
  var userNode = userNodes[id];
  if (!userNode) {
	var newUser = { id: id, postCount: 0, fileCount: 0 }; //I'm a special snowflake, so I'm gonna be using postCount instead of rank/size to determine how to merge sets
	newUser.parent = newUser;
    userNodes[id] = newUser;
	return newUser;
  }
  return userNode;
}
function find(node) { //find with included path compression
  while (node.parent != node) {
    var curr = node;
    node = node.parent;
    curr.parent = node.parent;
  }
  return node;
}
function union(node1, node2) {
  var node1root = find(node1);
  var node2root = find(node2);
  if (node1root == node2root) {
    return node1root;
  }
  if (node1root.postCount < node2root.postCount) {
    var temp = node1root;
    node1root = node2root;
	node2root = temp;
  }
  node2root.parent = node1root;
  node1root.postCount += node2root.postCount;
  node1root.fileCount += node2root.fileCount;
  return node1root;
}