Tumblr Dashboard - clickable links to images and display time-stamps

All Tumblr images receive direct link to their high-res variant. A colored box around each image indicates the vertical resolution of the high-res image.

  1. // ==UserScript==
  2. // @name Tumblr Dashboard - clickable links to images and display time-stamps
  3. // @namespace tumblr_dashboard_linkify
  4. // @version 4.1.1
  5. // @license GNU AGPLv3
  6. // @description All Tumblr images receive direct link to their high-res variant. A colored box around each image indicates the vertical resolution of the high-res image.
  7. // @author marp
  8. // @homepageURL https://greasyfork.org/en/users/204542-marp
  9. // @match https://www.tumblr.com/
  10. // @match https://www.tumblr.com/*
  11. // @match https://*.media.tumblr.com/*
  12. // @grant GM_xmlhttpRequest
  13. // @connect tumblr.com
  14. // @run-at document-end
  15. // ==/UserScript==
  16.  
  17. // jshint esversion:8
  18.  
  19.  
  20. /* function nsResolver(prefix) {
  21. if (prefix === 'svg') {
  22. return 'http://www.w3.org/2000/svg';
  23. } else {
  24. return null;
  25. }
  26. } */
  27.  
  28. function doNothing_tumblr_dashboard_linkify(event) {
  29. event.preventDefault();
  30. return false;
  31. }
  32.  
  33. function insertOrChangeLinkElement(myDoc, wrapElement, linkTarget) {
  34. var parentnode;
  35. parentnode = wrapElement.parentNode;
  36. if (parentnode.nodeName.toLowerCase() == "a") {
  37. parentnode.setAttribute("href", linkTarget);
  38. parentnode.setAttribute("target", "_blank");
  39. parentnode.addEventListener("click", doNothing_tumblr_dashboard_linkify, true);
  40. } else {
  41. var newnode;
  42. newnode = myDoc.createElement("a");
  43. newnode.setAttribute("href", linkTarget);
  44. newnode.setAttribute("target", "_blank");
  45. newnode.addEventListener("click", doNothing_tumblr_dashboard_linkify, true);
  46. parentnode.replaceChild(newnode, wrapElement);
  47. newnode.appendChild(wrapElement);
  48. }
  49. }
  50.  
  51. function getHighResImageURL(imageElement) {
  52. var srcarray;
  53. var tmpstr;
  54. srcarray = imageElement.getAttribute("srcset").split(",");
  55. // QUICK AND DIRTY - assume largest image is the last in array... seems to be true for Tumblr... but might change...
  56. tmpstr = srcarray[srcarray.length-1].trim();
  57. return tmpstr.substring(0, tmpstr.indexOf(" "));
  58. }
  59.  
  60.  
  61.  
  62. function createImageLinks(myDoc, myContext) {
  63.  
  64. if (myDoc===null) myDoc= myContext;
  65. if (myDoc===null) return;
  66. if (myContext===null) myContext= myDoc;
  67.  
  68. var matches;
  69. var imageurl;
  70.  
  71. // the img might be added as part of a whole post (first expr) - or just the img or the div/img, in which case we need to check if the image is part of the correct hierarchy (second expr)
  72. matches=myDoc.evaluate(".//article//button[@aria-label]//figure//div/img[@srcset and @sizes]"
  73. + " | " +
  74. "self::img[@srcset and @sizes and parent::div/ancestor::figure/ancestor::button[@aria-label]/ancestor::article]"
  75. + " | " +
  76. "self::div/img[@srcset and @sizes and parent::div/ancestor::figure/ancestor::button[@aria-label]/ancestor::article]"
  77. + " | " +
  78. "./ancestor-or-self::article/descendant::button[@aria-label]//figure//div/img[@srcset and @sizes]",
  79. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  80. for(var i=0, el; (i<matches.snapshotLength); i++) {
  81. el=matches.snapshotItem(i);
  82. if (el && el.previousSibling === null) {
  83. try {
  84. imageurl=getHighResImageURL(el);
  85. if (imageurl && imageurl.length > 5) {
  86. checkUrlHeaderOnlyPromise(imageurl, el).then( (result) => {
  87. if ( (result !== null) && (result.size !== null) && (result.url !== null) && (result.element !== null)) {
  88. insertOrChangeLinkElement(result.element.ownerDocument, result.element.parentNode, result.url);
  89. result.element.style = "box-sizing: border-box; border: 5px solid Grey;";
  90. result.element.setAttribute("title", getSizeText(result.size));
  91. getImageDimensionsPromise(result.url, result.element, result.size).then( (result2) => {
  92. result2.element.style = "box-sizing: border-box; border: 5px solid " + result2.color + ";";
  93. result2.element.setAttribute("title", getSizeText(result2.size) + " - " + result2.width + " x " + result2.height);
  94. });
  95. }
  96. });
  97. }
  98. } catch (e) { console.warn("error: ", e); }
  99. }
  100. }
  101. }
  102.  
  103.  
  104. var fixedHeightStyle = null;
  105. function processFixedHeightNonsense(myDoc, myContext) {
  106.  
  107. if (myContext.nodeType === Node.ELEMENT_NODE) {
  108. var matches, i, el;
  109. if (fixedHeightStyle === null) {
  110. matches = myDoc.evaluate("./descendant-or-self::div[ @class and parent::div/parent::article and descendant::button[@aria-label]//figure//img[@srcset and @sizes] ]",
  111. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  112. var compstyles;
  113. for(i=0; (i<matches.snapshotLength); i++) {
  114. el=matches.snapshotItem(i);
  115. compstyles = myDoc.defaultView.getComputedStyle(el);
  116. if (compstyles.getPropertyValue("height") == "300px" && compstyles.getPropertyValue("overflow-y") == "hidden") {
  117. fixedHeightStyle = el.className;
  118. break;
  119. }
  120. }
  121. }
  122.  
  123. if (fixedHeightStyle !== null) {
  124. matches = myDoc.evaluate("./descendant-or-self::div[ @class='" + fixedHeightStyle + "' and parent::div/parent::article and descendant::button[@aria-label]//figure//img[@srcset and @sizes] ]",
  125. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  126. for(i=0; (i<matches.snapshotLength); i++) {
  127. el=matches.snapshotItem(i);
  128. el.className = "";
  129. }
  130. }
  131. }
  132. }
  133.  
  134.  
  135. function getSizeText(sizeInBytes) {
  136. if (sizeInBytes === null) {
  137. return "";
  138. }
  139. if (sizeInBytes >= 1048576) {
  140. return (sizeInBytes / 1048576).toFixed(1) + " MB";
  141. }
  142. else if (sizeInBytes >= 1024) {
  143. return (sizeInBytes / 1024).toFixed(0) + " KB";
  144. }
  145. else {
  146. return sizeInBytes.toFixed(0) + " B";
  147. }
  148. }
  149.  
  150. // This ASYNC method returns a promise to retrieve the HTTP response header data for the supplied URL.
  151. // It uses an "HTTP HEAD" request which does NOT download the response payload (to minimize network traffic)
  152. async function checkUrlHeaderOnlyPromise(url, element) {
  153. return new Promise((resolve, reject) => {
  154. GM_xmlhttpRequest({
  155. method: 'HEAD',
  156. url: url,
  157. onload: function(response) {
  158. if (response.readyState >= 2) {
  159. var contentLength = -1;
  160. var conlenstr = response.responseHeaders.split("\r\n").find(str => str.toLowerCase().startsWith("content-length: "));
  161. if (conlenstr !== undefined) {
  162. contentLength = parseInt(conlenstr.slice(16), 10);
  163. if (isNaN(contentLength)) {
  164. contentLength = -1;
  165. }
  166. }
  167. resolve( { url: response.finalUrl, size: contentLength, origurl: url, element: element} );
  168. } else {
  169. reject( { url: url, size: -1, origurl: url, element: element } );
  170. }
  171. },
  172. ontimeout: function(response) {
  173. reject( { url: url, size: -1, origurl: url, element: element } );
  174. },
  175. onerror: function(response) {
  176. reject( { url: url, size: -1, origurl: url, element: element } );
  177. }
  178. });
  179. });
  180. }
  181.  
  182. // This ASYNC method gets the natural dimensions of the supplied image
  183. // This means the image needs to be downloaded fully, unfortunately!
  184. // Thus, a delay is to be expected, except if the image is already cached
  185. // Depending on the image height, the method suggests a "markup color" and then discards the downloaded image again (but does not invalidate cache).
  186. // "divelement" is only passed-through - it is a helper to supply the DOM context to the surrounding asynchronous promise then function of the caller
  187. async function getImageDimensionsPromise(imageurl, element, imagesize) {
  188. var image;
  189. var imageH;
  190. var imageW;
  191. var color;
  192.  
  193. // sanity check - skip full download of image if it is larger than 20MB
  194. if ( (imagesize !== null) && (imagesize > 20971520) ) {
  195. return {url: imageurl, element: element, width: "unknown", height: "unknown", color: "Grey", size:imagesize};
  196. }
  197.  
  198. image = new Image();
  199. image.src = imageurl;
  200.  
  201. await image.decode().then(function() {
  202. imageH = image.naturalHeight;
  203. imageW = image.naturalWidth;
  204. });
  205.  
  206. image.src = "data:,"; // clear the image, now that we no longer need it
  207.  
  208. if (imageH >= 2160) {
  209. color = "hsl(160, 100%, 70%)";
  210. } else if (imageH >= 1080) {
  211. color = "hsl(" + (120.0 + 40.0 * ((imageH - 1080.0) / 1080.0)) + ", 100%, " + (50.0 + 20.0 * ((imageH - 1080.0) / 1080.0)) + "%)";
  212. // color = "Lime"; // #00FF00, HSL(120°, 100%, 50%)
  213. } else if (imageH >=270 ) {
  214. color = "hsl(" + (120.0 * ((imageH - 270.0) / 810.0)) + ", 100%, 50%)";
  215. // color = "Red"; // #FF0000, HSL(0°, 100%, 50%)
  216. } else if (imageH < 270 && imageH > 0 ) {
  217. color = "hsl(0, 100%, " + (50.0 * (imageH / 270.0)) + "%)";
  218. // color = "Red"; // #FF0000, HSL(0°, 100%, 50%)
  219. } else {
  220. color = "Grey";
  221. }
  222.  
  223. return {url: imageurl, element: element, width: imageW, height: imageH, color: color, size:imagesize};
  224. }
  225.  
  226.  
  227.  
  228.  
  229.  
  230.  
  231.  
  232. var myDateTimeFormat = Intl.DateTimeFormat(undefined, {weekday: "short", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric" /*, timeZoneName: "shortOffset" */ });
  233.  
  234. function displayDateTime(myDoc, myContext) {
  235. if (myDoc===null) myDoc= myContext;
  236. if (myDoc===null) return;
  237. if (myContext===null) myContext= myDoc;
  238.  
  239. var matches;
  240. var datetime;
  241.  
  242. matches=myDoc.evaluate(".//article//header//time[@datetime and not(@displaytimestampscript='1')]"
  243. + " | " +
  244. "./ancestor-or-self::article/descendant::header//time[@datetime and not(@displaytimestampscript='1')]",
  245. myContext, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
  246. for(var i=0, el; (i<matches.snapshotLength); i++) {
  247. el=matches.snapshotItem(i);
  248. if (el) {
  249. try {
  250. datetime = el.getAttribute("datetime");
  251. el.setAttribute("displaytimestampscript", "1"); // flag that this node was added or edited by this script
  252. el.textContent = myDateTimeFormat.format(Date.parse(datetime));
  253. } catch (e) { console.warn("error: ", e); }
  254. }
  255. }
  256. }
  257.  
  258.  
  259.  
  260. function removeImageHtmlCrap(myDoc, myContext) {
  261. if (myDoc===null) myDoc= myContext;
  262. if (myDoc===null) return;
  263. if (myContext===null) myContext= myDoc;
  264.  
  265. var matches;
  266. var imgurl_full;
  267. var imgurl_match;
  268. var partialurl;
  269. var singlematch;
  270. var singlenode;
  271. var sib;
  272. var vsize;
  273.  
  274. imgurl_full = window.location.href;
  275. // this part of the URL isd the same for all available sizes of the image
  276. partialurl = imgurl_full.match(/https?:\/\/[^/]+\.tumblr\.com\/[^/]+\//i);
  277. if (partialurl) {
  278. imgurl_match = partialurl[0];
  279.  
  280. singlematch = myDoc.evaluate("./descendant-or-self::img[contains(@srcset,'" + imgurl_match + "') or contains(@src,'" + imgurl_match + "')]",
  281. myContext, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  282. singlenode = singlematch.singleNodeValue;
  283. if (singlenode) {
  284. // modify the image to use the largest available size varient (which is equal to the page URL!)
  285. // change several styles so that the image fits into the available viewport space
  286. singlenode.parentNode.setAttribute("style", "padding: 0px;");
  287. sib = singlenode.previousElementSibling; //this is the blog title (if available)
  288. if (sib===null) {
  289. singlenode.parentNode.parentNode.setAttribute("style", "padding: 0px;");
  290. sib = singlenode.parentNode.previousElementSibling; //this is the blog title (if available)
  291. }
  292. if (sib) {
  293. vsize = sib.clientHeight;
  294. } else {
  295. vsize = 0;
  296. }
  297. if (singlenode.hasAttribute("srcset")) {
  298. singlenode.removeAttribute("srcset");
  299. singlenode.removeAttribute("sizes");
  300. }
  301. singlenode.setAttribute("src", imgurl_full);
  302. singlenode.setAttribute("style", "max-width: 99vw; max-height: calc(99vh - " + vsize +"px); object-fit: contain;");
  303. singlenode.removeAttribute("class");
  304. }
  305.  
  306. //remove all DIVs that are unrelated to the image as well as to the blog title (which appears right above the image)
  307. matches = myDoc.evaluate("./descendant-or-self::div[not( ./descendant-or-self::img["+
  308. "(contains(@srcset,'" + imgurl_match + "') or contains(@src,'" + imgurl_match + "'))] )"
  309. + "and " +
  310. "not( ./descendant-or-self::img[@role='img'] ) ]",
  311. myContext, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
  312. for(var i=0, el; (i<matches.snapshotLength); i++) {
  313. el=matches.snapshotItem(i);
  314. if (el) {
  315. try {
  316. el.remove();
  317. } catch (e) { console.warn("error: ", e); }
  318. }
  319. }
  320.  
  321. }
  322. }
  323.  
  324.  
  325. var observer;
  326. var config;
  327. var singlematch;
  328. var rootnode;
  329.  
  330. if ( window.location.href.includes('.media.tumblr.com/') ) {
  331. // special part of script - acting only on direct image URLs to remove the HTML-crap injected by Tumblr
  332.  
  333. // create an observer instance and iterate through each individual new node
  334. observer = new MutationObserver(function(mutations) {
  335. mutations.forEach(function(mutation) {
  336. mutation.addedNodes.forEach(function(addedNode) {
  337. removeImageHtmlCrap(mutation.target.ownerDocument, addedNode.parentNode);
  338. });
  339. });
  340. });
  341. // configuration of the observer
  342. config = { attributes: false, childList: true, characterData: false, subtree: true };
  343. // new twitter UI has few stable IDs - need to start very high with "root" node
  344. singlematch = document.evaluate("//body[@id='tumblr']/div[@id='root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  345. //console.info("singlematch: ", singlematch);
  346. rootnode = singlematch.singleNodeValue;
  347. if (rootnode) {
  348. //start the observer for new nodes
  349. observer.observe(rootnode, config);
  350. //process already loaded nodes (the initial posts before scrolling down for the first time)
  351. removeImageHtmlCrap(document, rootnode);
  352. }
  353.  
  354.  
  355. } else { // this is "normal" part of script - acting on anything except direct image URLs
  356.  
  357.  
  358. // create an observer instance and iterate through each individual new node
  359. observer = new MutationObserver(function(mutations) {
  360. mutations.forEach(function(mutation) {
  361. mutation.addedNodes.forEach(function(addedNode) {
  362. createImageLinks(mutation.target.ownerDocument, addedNode);
  363. displayDateTime(mutation.target.ownerDocument, addedNode);
  364. processFixedHeightNonsense(mutation.target.ownerDocument, addedNode);
  365. });
  366. });
  367. });
  368. // configuration of the observer
  369. config = { attributes: false, childList: true, characterData: false, subtree: true };
  370. // Tumblr UI has few stable IDs - need to start very high with "root" node
  371. singlematch = document.evaluate("//body[@id='tumblr']/div[@id='root']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  372. //console.info("singlematch: ", singlematch);
  373. rootnode = singlematch.singleNodeValue;
  374. //start the observer for new nodes
  375. observer.observe(rootnode, config);
  376. //process already loaded nodes (the initial posts before scrolling down for the first time)
  377. createImageLinks(document, rootnode);
  378. displayDateTime(document, rootnode);
  379. processFixedHeightNonsense(document, rootnode);
  380. }