Greasy Fork

Greasy Fork is available in English.

SB/SV/QQ: Threadmark Word Counter

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name           SB/SV/QQ: Threadmark Word Counter
// @namespace      https://github.com/w4tchdoge
// @version        1.1.1-20240618_161840
// @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.1 — Log the sum of the word count of all threadmarks currently being displayed to the browser console
// @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`
	);

	let word_count_sum = 0;

	// 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_int = parseInt(WordCounter(threadmark_text));
		const word_count_str = new Intl.NumberFormat({ style: `decimal` }).format(word_count_int);

		// Add to sum
		word_count_sum += word_count_int;

		// Threadmark info logging + Time logging
		console.log(
			`
Threadmark ${index + 1}
Threadmark Category: ${threadmark_category}
Threadmark Title: ${threadmark_title}
Word Count: ${word_count_str} 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_str} 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);
		}
	});

	const word_count_sum_elm = (() => {
		const out_elm = Object.assign(document.createElement(`div`), {
			id: `CrW_Thrdmrk_Wrd_Cnt_Sum_hidden`,
		});
		out_elm.setAttribute(`data_word_count_sum`, word_count_sum);

		return out_elm;
	})();
	document.querySelector(`body`).appendChild(word_count_sum_elm);

	console.log(`
Sum of the word counts of the threadmarks on the current page:`, word_count_sum);

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

})();