Greasy Fork

SB/SV/QQ: Threadmark Word Counter

Display the word count of threadmarked forum posts in the header area of the post

目前为 2024-06-13 提交的版本。查看 最新版本

// ==UserScript==
// @name           SB/SV/QQ: Threadmark Word Counter
// @namespace      https://github.com/w4tchdoge
// @version        1.1.0-20240614_034608
// @description    Display the word count of threadmarked forum posts in the header area of the post
// @author         w4tchdoge
// @homepage       https://github.com/w4tchdoge/MISC-UserScripts
// @match          *://forums.spacebattles.com/threads/*
// @match          *://forums.sufficientvelocity.com/threads/*
// @match          *://forum.questionablequesting.com/threads/*
// @run-at         document-idle
// @license        AGPL-3.0-or-later
// @history        1.1.0 — Reworked `CSSRGBintoComponents()` to better handle cases where the color is in the format `color(srgb ...)`. Changed how lightness_increase works so that it's a negative value when `color-scheme` is `light` and zero when `color-scheme` is neither `light` nor `dark`. Reworked `phb_color` so that the string it outputs uses the newer space separated `rgb()` parameters (https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb#syntax)
// @history        1.0.0 — Initial commit
// ==/UserScript==

(function () {
	`use strict`;

	const start_time = performance.now();
	console.log(`
Initializing Threadmark Word Counter UserScript
———————————————–————————————————
Time since Start: ${performance.now() - start_time}ms`
	);

	// Conversion of the word counting Regular Expression used by AO3, with added support for most Unicode scripts supported in Regular Expressions
	// Vanilla AO3 compliant script_list:
	// const script_list = [`Han`, `Hiragana`, `Katakana`, `Thai`];
	// Full script_list:
	const script_list = [`Arabic`, `Armenian`, `Balinese`, `Bengali`, `Bopomofo`, `Braille`, `Buginese`, `Buhid`, `Canadian_Aboriginal`, `Carian`, `Cham`, `Cherokee`, `Common`, `Coptic`, `Cuneiform`, `Cypriot`, `Cyrillic`, `Deseret`, `Devanagari`, `Ethiopic`, `Georgian`, `Glagolitic`, `Gothic`, `Greek`, `Gujarati`, `Gurmukhi`, `Han`, `Hangul`, `Hanunoo`, `Hebrew`, `Hiragana`, `Inherited`, `Kannada`, `Katakana`, `Kayah_Li`, `Kharoshthi`, `Khmer`, `Lao`, `Latin`, `Lepcha`, `Limbu`, `Linear_B`, `Lycian`, `Lydian`, `Malayalam`, `Mongolian`, `Myanmar`, `New_Tai_Lue`, `Nko`, `Ogham`, `Ol_Chiki`, `Old_Italic`, `Old_Persian`, `Oriya`, `Osmanya`, `Phags_Pa`, `Phoenician`, `Rejang`, `Runic`, `Saurashtra`, `Shavian`, `Sinhala`, `Sundanese`, `Syloti_Nagri`, `Syriac`, `Tagalog`, `Tagbanwa`, `Tai_Le`, `Tamil`, `Telugu`, `Thaana`, `Thai`, `Tibetan`, `Tifinagh`, `Ugaritic`, `Vai`, `Yi`];
	// Excludes the Unicode scripts "Common" and "Latin" because that messes with the counting somehow
	// Exclude "Inherited" just to be safe
	const script_exclude_list = [`Common`, `Latin`, `Inherited`];
	const word_count_regex = new RegExp((function () {
		// Switch from using alternations in a group (e.g. (a|b|c)) to a character class (e.g. [abc]) for performance reasons (https://stackoverflow.com/a/27791811/11750206)
		const regex_scripts = script_list.filter((elm) => !script_exclude_list.includes(elm)).map((elm) => `\\p{Script=${elm}}`).join(``);
		const full_regex_str = `[${regex_scripts}]|((?![${regex_scripts}])[\\p{Letter}\\p{Mark}\\p{Number}\\p{Connector_Punctuation}])+`;
		return full_regex_str;
	})(), `gv`);

	function WordCounter(string, word_count_regex_expr = word_count_regex) {
		// Count the number of words
		// Counting method from: https://stackoverflow.com/a/76673564/11750206, https://stackoverflow.com/a/69486719/11750206, and https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/matchAll
		// Regex substitutions from: https://github.com/otwcode/otwarchive/blob/943f585818005be8df269d84ca454af478150e75/lib/word_counter.rb#L30C33-L30C68
		const word_count_arr = Array.from(string.replaceAll(/--/g, `—`).replaceAll(/['’‘-]/g, ``).matchAll(word_count_regex_expr), (m) => m[0]);
		const word_count_int = word_count_arr.length;

		return word_count_int;
	}

	// Colour Functions for manipulating wc_borderColor

	// Split the initial CSS colour string into components
	function CSSRGBintoComponents(rgb_str) {
		const match_regex = /^(rgb|color\(srgb(?=\s))/i;
		const split_regex = /[^\d\.%]/i;

		const regex_test = match_regex.test(rgb_str);

		if (regex_test == false) {
			throw new Error(`The parameter passed to CSSRGBintoComponents() is not an rgb()/rgba()/color(srgb ...) CSS colour string.`);
		}

		function CommonFilter(input_str, splt_rgx = split_regex) {
			const [red, green, blue, alpha] = input_str.split(splt_rgx)
				.filter(elm => {
					try {
						return ((!isNaN(parseFloat(elm)) && isFinite(elm)) || elm.includes(`%`));
					} catch (error) {
						return false;
					}
				});

			return [red, green, blue, alpha];
		}

		const color_test = rgb_str.split(/[\s\(]/i).at(0).toLowerCase().includes(`color`);

		if (color_test) {

			const [comp_red, comp_green, comp_blue, comp_alpha] = CommonFilter(rgb_str)
				.map((element, index) => {
					if (Boolean(element) && index < 3) {
						const output_str = parseInt(parseFloat(element) * 255).toString();
						return output_str;
					} else { return element.toString(); }
				});

			return [comp_red, comp_green, comp_blue, comp_alpha];

		} else {

			const [comp_red, comp_green, comp_blue, comp_alpha] = CommonFilter(rgb_str);

			return [comp_red, comp_green, comp_blue, comp_alpha];

		}
	}

	// Reference XYZ values ; Observer: 2° ; Illuminant: D65
	const [ref_X, ref_Y, ref_Z] = [95.047, 100.000, 108.883];

	// Converts RGB components into CIELAB
	// Input is an array of RGB components in that order
	// Outputs an array of CIE Lab components in that order
	// from https://stackoverflow.com/a/73998199/11750206
	function RGBtoLAB([red, green, blue]) {
		const [var_R, var_G, var_B] =
			[red, green, blue]
				.map(n => n / 255)
				.map(n =>
					n > 0.0405
						? ((n + 0.055) / 1.055) ** 2.4
						: n / 12.92
				)
				.map(n => n * 100);

		const [X, Y, Z] = [
			var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805,
			var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722,
			var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505
		];

		const [var_X, var_Y, var_Z] =
			[X / ref_X, Y / ref_Y, Z / ref_Z]
				.map(n =>
					n > 0.008856
						? n ** (1 / 3)
						: (7.787 * n) + (16 / 116)
				);

		const [CIE_L, CIE_a, CIE_b] =
			[
				(116 * var_Y) - 16,
				500 * (var_X - var_Y),
				200 * (var_Y - var_Z)
			];

		return [CIE_L, CIE_a, CIE_b];
	}

	// Converts CIELAB components into RGB
	// Input is an array of CIE Lab components in that order
	// Outputs an array of RGB components in that order
	// from https://stackoverflow.com/a/73998199/11750206
	function LABtoRGB([CIE_L, CIE_a, CIE_b]) {
		const
			var_Y = (CIE_L + 16) / 116,
			var_X = (CIE_a / 500) + var_Y,
			var_Z = var_Y - (CIE_b / 200);

		const [X, Y, Z] = (() => {
			const [mid_X, mid_Y, mid_Z] = [var_X, var_Y, var_Z]
				.map(n =>
					n ** 3 > 0.008856
						? n ** 3
						: (n - 16 / 116) / 7.787
				);

			return [mid_X * ref_X, mid_Y * ref_Y, mid_Z * ref_Z];
		})();

		const [var_R, var_G, var_B] = [
			X * 3.2406 + Y * -1.5372 + Z * -0.4986,
			X * -0.9689 + Y * 1.8758 + Z * 0.0415,
			X * 0.0557 + Y * -0.2040 + Z * 1.0570
		];

		const [R, G, B] = [var_R, var_G, var_B]
			.map(n => n / 100)
			.map(n =>
				n > 0.0031308
					? (1.055 * (n ** (1 / 2.4))) - 0.055
					: 12.92 * n
			)
			.map(n => parseInt(n * 255));

		return [R, G, B];
	}


	// Get array of threadmarked posts
	const threadmarked_posts = Array.from(document.querySelectorAll(`article.hasThreadmark:has(.message-inner .message-cell--main)`));

	// Time logging because why not
	console.log(`
Starting Word Counting of Threadmarks
———————————————–————————————————
Time since Start: ${performance.now() - start_time}ms`
	);

	// Iterate on the array of threadmarked posts
	threadmarked_posts.forEach(function (element, index, array) {
		// Get the timestamp element which the Word count element will be put after
		const tmrkd_post_timestamp = element.querySelector(`.message-cell--main .message-attribution-main`);

		// Get the computed styles of the threadmark header element (where the threadmark name,category, and nav buttons are)
		// This is to mimick the border style when making the word count element
		// Also getting the threadmark title and category because it's right there why not
		const [threadmark_title, threadmark_category, ph_styles] = (function () {
			const tmrk_header = element.querySelector(`div.message-cell--threadmark-header`);
			const
				tmrk_title = tmrk_header.querySelector(`span.primary > span[id^="threadmark"]`).cloneNode(true).textContent.trim(),
				tmrk_category = tmrk_header.querySelector(`span.primary > label[for^="threadmark"]`).cloneNode(true).textContent.trim();

			const tmrk_header_styles = getComputedStyle(tmrk_header);
			return [tmrk_title, tmrk_category, tmrk_header_styles];
		})();

		// Change how much higher the perceptual lightness of the word count border colour is
		// const lightness_increase = 10;
		// const lightness_increase = 6.75;
		const lightness_increase = (() => {
			const up_value = 6.75;
			const site_color_scheme = getComputedStyle(document.querySelector(`body`)).getPropertyValue(`color-scheme`);

			// if (site_color_scheme == `light`) {
			// 	const output_num = -up_value;
			// 	return output_num;
			// } else {
			// 	const output_num = up_value;
			// 	return output_num;
			// }

			switch (site_color_scheme) {
				case `light`:
					return -up_value;

				case `dark`:
					return up_value;

				default:
					return 0;
			}
		})();

		// Assign/Get/Calculate the border properties for the word count element border
		const [wc_borderWidth, wc_borderStyle, wc_borderColor] = (function () {
			const
				// Manually set to 1px because that's what it's set to in the Threadmark header
				// But computed style returns 0.740 recurring
				phb_width = `1px`,

				// Get the border style
				phb_style = ph_styles.getPropertyValue(`border-bottom-style`),

				// Calculated the border colour based on retrieved border colour and lightness_increase
				phb_color = (() => {
					const [initial_red, initial_green, initial_blue, initial_alpha] = CSSRGBintoComponents(ph_styles.getPropertyValue(`border-bottom-color`));

					const [i_CIE_L, i_CIE_a, i_CIE_b] = RGBtoLAB([initial_red, initial_green, initial_blue]);
					const [final_CIE_L, final_CIE_a, final_CIE_b] = [i_CIE_L + lightness_increase, i_CIE_a, i_CIE_b];
					const [final_red, final_green, final_blue] = LABtoRGB([final_CIE_L, final_CIE_a, final_CIE_b]);

					if (Boolean(initial_alpha)) {
						const out_phb_color = `rgb(${final_red} ${final_green} ${final_blue} / ${initial_alpha})`;
						return out_phb_color;
					} else {
						const out_phb_color = `rgb(${final_red} ${final_green} ${final_blue})`;
						return out_phb_color;
					}
				})();

			return [phb_width, phb_style, phb_color];
		})();

		// Get the actual text of the threadmark to be word counted
		const threadmark_text = element.querySelector(`.message-inner .message-cell--main .message-content article.message-body .bbWrapper`).cloneNode(true).textContent;

		// Calculate word count using WordCounter() and format it to a thousand-separated string
		const word_count = new Intl.NumberFormat({ style: `decimal` }).format(WordCounter(threadmark_text));

		// Threadmark info logging + Time logging
		console.log(
			`
Threadmark ${index + 1}
Threadmark Category: ${threadmark_category}
Threadmark Title: ${threadmark_title}
Word Count: ${word_count} words
———————————————–————————————————
Time since Start: ${performance.now() - start_time}ms`
		);

		// Create the word count element
		const word_count_element = Object.assign(document.createElement(`ul`), {
			id: `threadmark-word-count`,
			className: `listInline`,
			// style: `padding-left: 0.5em; margin-left: 0.5em; border-left: ${wc_borderWidth} ${wc_borderStyle} ${wc_borderColor};`,
			style: `padding-left: 7.5px; margin-left: 7.5px; border-left: ${wc_borderWidth} ${wc_borderStyle} ${wc_borderColor};`,
			innerHTML: (function () {
				const element = Object.assign(document.createElement(`li`), {
					className: `u-concealed`,
					innerHTML: `Word Count: ${word_count} words`
				});
				return element.outerHTML;
			})()
		});

		// Check if the word count element already exists
		const wc_elem_check = element.querySelector(`#threadmark-word-count`);
		if (Boolean(wc_elem_check)) {
			// If yes, replace existing one with new one
			wc_elem_check.replaceWith(word_count_element);
		} else {
			// If no, add a word count element
			tmrkd_post_timestamp.after(word_count_element);
		}
	});

	// More time logging wheeeeeeeeeeeeeeeeeeee
	console.log(`
Completed Word Counting of Threadmarks
UserScript has completed running
———————————————–————————————————
Time since Start: ${performance.now() - start_time}ms`
	);

})();