[Userscript] Forum: IME2Furigana

Oh no :sweat_smile:

1 Like

Looks like this:

:joy:

How does it look like during editing? Does the furigana show up above the kanji in the preview?

1 Like

No, but we have an actual error message this time. Maybe it makes sense to you (because it doesn’t to me :joy: )

1 Like

If you open the console, type in require("pretty-text/engines/discourse-markdown-it") and hit enter, what is the result?

1 Like

This line showed up:

So I expanded all the main tags (aka those that show up when all are closed) once because I wasn’t sure if you needed something in them.

Can you try out this version of IME2Furigana?

Script
// ==UserScript==
// @name         IME2Furigana
// @namespace    ime2furigana
// @version      1.7
// @description  Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author       Sinyaven
// @license      MIT-0
// @match        https://community.wanikani.com/*
// @grant        none
// ==/UserScript==

(async function() {
    "use strict";

	/* global require, exportFunction */
    /* eslint no-multi-spaces: "off" */

	//////////////
	// settings //
	//////////////

	const ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP = true;

	//////////////

	const DISCOURSE_REPLY_BOX_ID = "reply-control";
	const DISCOURSE_REPLY_AREA_CLASS = "reply-area";
	const DISCOURSE_BUTTON_BAR_CLASS = "d-editor-button-bar";
	const NO_BACK_CONVERSION_CLASS_FLAG = "ruby-to-furigana-markup-disabled";
	const RUBY_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>";
	const RUBY_SPOILER_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>[spoiler]$2[/spoiler]</rt><rp>)</rp></ruby>";
	const FURIGANA_REGEX = /^[\u3041-\u3096\u3000-\u303f\uff01-\uff5e¥]+$/;
	const KANJI_REGEX = /([\uff66-\uff9d\u4e00-\u9faf\u3400-\u4dbf]+)/;
	const RUBY_REGEX = /<ruby\b[^>]*>((?:(?!<\/?ruby\b)[^])+)<\/ruby>/; // using [^] as a complete wildcard (as opposed to . which does not match newlines without the dotAll flag)
	const SPOILER_REGEX = /^\[spoiler\]([^]*)\[\/spoiler\]$/;
	const COOK_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!\[)|[^<>])*)>\[(?!spoiler\s*\])([^\]]*)\]/g;
	const COOK_SPOILER_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!{)|[^<>])*)>{([^}]*)}/g;

	// negative lookbehind might not be supported (e.g. Waterfox) - in that case use an insufficient regex and hope for the best
	let greaterThan_regex = null;
	try { greaterThan_regex = new RegExp("(?<!<\\/?\\b[^<>]*)>", "g"); } catch (e) { greaterThan_regex = /^>/g; }

	let mode = 1;
	let furigana = "";
	let bMode = null;
	let tText = null;
	let dBanner = null;
	let alreadyInjected = false;

	// ---STORAGE--- //

	mode = parseInt(localStorage.getItem("furiganaMode") || mode);
	addEventListener("storage", e => e.key === "furiganaMode" ? modeValueChangeHandler(parseInt(e.newValue)) : undefined);

	function modeValueChangeHandler(newValue) {
		mode = newValue;
		if (!bMode) return;

		updateButton();
		// trigger _updatePreview() by appending a space, dispatching a change event, and then removing the space
		let textValue = tText.value;
		let selectionStart = tText.selectionStart;
		let selectionEnd = tText.selectionEnd;
		let selectionDirection = tText.selectionDirection;
		tText.value += " ";
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
		tText.value = textValue;
		tText.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function setModeValue(newValue) {
		modeValueChangeHandler(newValue);
		localStorage.setItem("furiganaMode", mode);
	}

	// ---REPLY BOX AND TEXT AREA DETECTION--- //

	let dObserverTarget = await waitFor(DISCOURSE_REPLY_BOX_ID, 1000, 30); // Greasemonkey seems to inject script before reply box is available, so we might have to wait
	let observer = new MutationObserver(m => m.forEach(handleMutation));
	observer.observe(dObserverTarget, {childList: true, subtree: true});

	addCss();

	// text area might already be open
	setupForTextArea(document.querySelector("textarea.d-editor-input"));
	addButton(document.getElementsByClassName(DISCOURSE_BUTTON_BAR_CLASS)[0]);

	function handleMutation(mutation) {
		let addedNodes = Array.from(mutation.addedNodes);
		let removedNodes = Array.from(mutation.removedNodes);
		// those forEach() are executed at most once
		addedNodes.filter(n => n.tagName === "TEXTAREA").forEach(setupForTextArea);
		addedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_BUTTON_BAR_CLASS)).forEach(addButton);
		removedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_REPLY_AREA_CLASS)).forEach(cleanup);
	}

	function setupForTextArea(textArea) {
		if (!textArea) return;
		tText = textArea;
		textArea.addEventListener("compositionupdate", update);
		textArea.addEventListener("compositionend", addFurigana);
		injectIntoDiscourse();
	}

	async function waitFor(elementId, checkInterval = 1000, waitCutoff = Infinity) {
		let result = null;
		while (--waitCutoff > 0 && !(result = document.getElementById(elementId))) await sleep(checkInterval);
		return result;
	}

	function sleep(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}

	// ---MAIN LOGIC--- //

	function addButton(div) {
		if (!div || (bMode && bMode.parentElement === div)) return;
		bMode = document.createElement("button");
		bMode.id = "ime2furigana-button";
		bMode.className = "btn no-text btn-icon ember-view";
		bMode.textContent = "F";
		updateButton();
		bMode.addEventListener("click", cycleMode);
		div.appendChild(bMode);
	}

	function cycleMode() {
		setModeValue(mode > 1 ? 0 : mode + 1);
		if (tText) tText.focus();
	}

	function updateButton() {
		bMode.classList.toggle("active", mode);
		bMode.classList.toggle("blur", mode === 2);
		bMode.title = "IME2Furigana - " + (mode ? (mode === 1 ? "on" : "blur") : "off");
	}

	function update(event) {
		if (FURIGANA_REGEX.test(event.data)) {
			furigana = event.data;
		}
	}

	function addFurigana(event) {
		if (!mode || event.data.length === 0) return;
		furigana = furigana.replace(/n/g, "ん");
		let parts = event.data.split(KANJI_REGEX);
		if (parts.length === 1) return;
		let hiraganaParts = parts.map(p => Array.from(p).map(c => katakanaToHiragana(c)).join(""));
		let regex = new RegExp("^" + hiraganaParts.map((p, idx) => "(" + (idx & 1 ? ".+" : p) + ")").join("") + "$");
		let rt = furigana.match(regex);
		if (!rt) {
			parts = [event.data];
			rt = [null, furigana];
		}
		rt.shift();
		let rtStart = mode === 2 ? "{" : "[";
		let rtEnd   = mode === 2 ? "}" : "]";
		let markup  = parts.map((p, idx) => idx & 1 ? "<" + p + ">" + rtStart + rt[idx] + rtEnd : p).join("");
		event.target.setRangeText(markup, event.target.selectionStart - event.data.length, event.target.selectionStart, "end");
	}

	function katakanaToHiragana(k) {
		let c = k.charCodeAt(0);
		return c >= 12449 && c <= 12531 ? String.fromCharCode(k.charCodeAt(0) - 96) : k;
	}

	function cleanup() {
		furigana = "";
		bMode = null;
		tText = null;
		dBanner = null;
	}

	// ---CONVERTING BACK TO FURIGANA MARKUP--- //

	function removeBanner() {
		if (dBanner) dBanner.parentElement.removeChild(dBanner);
		dBanner = null;
	}

	function checkForRubyTags() {
		if (tText.parentElement.parentElement.classList.contains(NO_BACK_CONVERSION_CLASS_FLAG)) return;
		if (!RUBY_REGEX.test(tText.value)) return removeBanner();
		if (dBanner) return;
		dBanner = document.createElement("div");
		let bConvert = document.createElement("button");
		let bCancel = document.createElement("button");
		dBanner.id = "ime2furigana-conversion-banner";
		dBanner.textContent = "Convert <ruby> to furigana markup?";
		bConvert.textContent = "\u2714";
		bCancel.textContent = "\u274C";
		dBanner.appendChild(bConvert);
		dBanner.appendChild(bCancel);
		bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
		bCancel.addEventListener("click", () => { tText.parentElement.parentElement.classList.add(NO_BACK_CONVERSION_CLASS_FLAG); removeBanner(); });
		tText.insertAdjacentElement("beforebegin", dBanner);
	}

	function rubyToFuriganaMarkup() {
		let parts = tText.value.split(RUBY_REGEX);
		if (parts.length === 1) return;
		tText.value = parts.map((p, idx) => idx & 1 ? rubyContentToFuriganaMarkup(p) : p).join("");
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function rubyContentToFuriganaMarkup(ruby) {
		// should be able to handle both interleaved and tabular markup
		// remove <rp>...</rp> or <rp>...<rt>
		ruby = ruby.split(/<rp\s*>/).map((part, idx) => idx === 0 ? part : part.substr(part.search(/<\/rp\s*>|<rt\s*>/))).join("").replace(/<\/rp\s*>/g, "");
		// get rt content
		let rt = ruby.split(/<rt\s*>/).map(part => part.substr(0, part.concat("<rb>").search(/<rb\s*>|<\/rt\s*>/)));
		rt.shift();
		// get rb content
		let rb = ruby.split(/(?:<\/rt\s*>\s*)?<rb\s*>|<\/rt\s*>/).map(part => part.substr(0, part.concat("<rt>").search(/(?:<\/rb\s*>\s*)?<rt\s*>/))).filter(part => !/^\s*$/.test(part));
		// add furigana markup brackets to rt
		rt = rt.map(v => SPOILER_REGEX.test(v) ? ("{" + SPOILER_REGEX.exec(v)[1] + "}") : ("[" + v + "]"));
		// sanitize rb ("<" not allowed except for tags)
		rb = rb.map(v => v.replace(/<(?!\/?\b[^<>]*>)/g, "&lt;"));
		// sanitize rb (">" not allowed except for tags)
		rb = rb.map(v => v.replace(greaterThan_regex, "&gt;"));
		// sanitize rt ("]" or "}" not allowed)
		rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "&rsqb;") : v.replace(/}(?!$)/, "&rcub;"));
		// pad rt/rb to be the same length
		let result = rb.reduce((total, v, idx) => total + "<" + v + ">" + (rt[idx] || "[]"), "");
		result += rt.slice(rb.length).reduce((total, v) => total + "<>" + v, "");
		return result;
	}

	// ---COOKING RULE INJECTION--- //

	function injectIntoDiscourse() {
		if (alreadyInjected) return;
		alreadyInjected = true;
		// greasemonkey workaround: unsafeWindow + exportFunction
		let w = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
		let e = typeof exportFunction === "undefined" ? o => o : exportFunction;
		injectCustomCook(w, e);
		injectCustomSave(w, e);
	}

	function injectCustomCook(w, e) {
		let oldCook = require("pretty-text/engines/discourse-markdown-it").cook;
		require("pretty-text/engines/discourse-markdown-it").cook = e((raw, opts) => oldCook(customCook(raw), opts), w);
	}

	function injectCustomSave(w, e) {
		let oldSave = require("discourse/controllers/composer").default.prototype.save;
		require("discourse/controllers/composer").default.prototype.save = e(function(t) { tText.value = customCook(tText.value); tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true})); oldSave.call(this, t); }, w);
	}

	function customCook(raw) {
		if (!mode) {
			removeBanner();
			return raw;
		}
		ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP ? checkForRubyTags() : rubyToFuriganaMarkup();
		raw = raw.replace(COOK_SEARCH_REGEX, RUBY_TEMPLATE);
		return raw.replace(COOK_SPOILER_SEARCH_REGEX, RUBY_SPOILER_TEMPLATE);
	}

	// ---ADD CSS--- //

	function addCss() {
		let style = document.createElement("style");
		style.textContent = "#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: var(--tertiary-low, rgba(163, 225, 255, 0.5)); }" +
			"#ime2furigana-conversion-banner > button { background-color: transparent; border: none; }" +
			"#ime2furigana-button.active { background-color: #00000042; }" +
			"#ime2furigana-button.blur { filter: blur(2px); }";
		document.head.appendChild(style);
	}
})();

<振>[ふ]り<仮名>[がな]

Preview still just look like the code it inserted…

Edit: yep still code. Will now check console…

2nd Edit: And here is the new error:

You can also try MeddleMonkey to see if it works.

I am looking at Safari-related TamperMonkey issues, here.

1 Like

Maybe it works with this version:

Script
// ==UserScript==
// @name         IME2Furigana
// @namespace    ime2furigana
// @version      1.7
// @description  Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author       Sinyaven
// @license      MIT-0
// @match        https://community.wanikani.com/*
// @inject-into  page
// @grant        none
// ==/UserScript==

(async function() {
    "use strict";

	/* global require, exportFunction */
    /* eslint no-multi-spaces: "off" */

	//////////////
	// settings //
	//////////////

	const ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP = true;

	//////////////

	const DISCOURSE_REPLY_BOX_ID = "reply-control";
	const DISCOURSE_REPLY_AREA_CLASS = "reply-area";
	const DISCOURSE_BUTTON_BAR_CLASS = "d-editor-button-bar";
	const NO_BACK_CONVERSION_CLASS_FLAG = "ruby-to-furigana-markup-disabled";
	const RUBY_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>";
	const RUBY_SPOILER_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>[spoiler]$2[/spoiler]</rt><rp>)</rp></ruby>";
	const FURIGANA_REGEX = /^[\u3041-\u3096\u3000-\u303f\uff01-\uff5e¥]+$/;
	const KANJI_REGEX = /([\uff66-\uff9d\u4e00-\u9faf\u3400-\u4dbf]+)/;
	const RUBY_REGEX = /<ruby\b[^>]*>((?:(?!<\/?ruby\b)[^])+)<\/ruby>/; // using [^] as a complete wildcard (as opposed to . which does not match newlines without the dotAll flag)
	const SPOILER_REGEX = /^\[spoiler\]([^]*)\[\/spoiler\]$/;
	const COOK_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!\[)|[^<>])*)>\[(?!spoiler\s*\])([^\]]*)\]/g;
	const COOK_SPOILER_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!{)|[^<>])*)>{([^}]*)}/g;

	// negative lookbehind might not be supported (e.g. Waterfox) - in that case use an insufficient regex and hope for the best
	let greaterThan_regex = null;
	try { greaterThan_regex = new RegExp("(?<!<\\/?\\b[^<>]*)>", "g"); } catch (e) { greaterThan_regex = /^>/g; }

	let mode = 1;
	let furigana = "";
	let bMode = null;
	let tText = null;
	let dBanner = null;
	let alreadyInjected = false;

	// ---STORAGE--- //

	mode = parseInt(localStorage.getItem("furiganaMode") || mode);
	addEventListener("storage", e => e.key === "furiganaMode" ? modeValueChangeHandler(parseInt(e.newValue)) : undefined);

	function modeValueChangeHandler(newValue) {
		mode = newValue;
		if (!bMode) return;

		updateButton();
		// trigger _updatePreview() by appending a space, dispatching a change event, and then removing the space
		let textValue = tText.value;
		let selectionStart = tText.selectionStart;
		let selectionEnd = tText.selectionEnd;
		let selectionDirection = tText.selectionDirection;
		tText.value += " ";
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
		tText.value = textValue;
		tText.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function setModeValue(newValue) {
		modeValueChangeHandler(newValue);
		localStorage.setItem("furiganaMode", mode);
	}

	// ---REPLY BOX AND TEXT AREA DETECTION--- //

	let dObserverTarget = await waitFor(DISCOURSE_REPLY_BOX_ID, 1000, 30); // Greasemonkey seems to inject script before reply box is available, so we might have to wait
	let observer = new MutationObserver(m => m.forEach(handleMutation));
	observer.observe(dObserverTarget, {childList: true, subtree: true});

	addCss();

	// text area might already be open
	setupForTextArea(document.querySelector("textarea.d-editor-input"));
	addButton(document.getElementsByClassName(DISCOURSE_BUTTON_BAR_CLASS)[0]);

	function handleMutation(mutation) {
		let addedNodes = Array.from(mutation.addedNodes);
		let removedNodes = Array.from(mutation.removedNodes);
		// those forEach() are executed at most once
		addedNodes.filter(n => n.tagName === "TEXTAREA").forEach(setupForTextArea);
		addedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_BUTTON_BAR_CLASS)).forEach(addButton);
		removedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_REPLY_AREA_CLASS)).forEach(cleanup);
	}

	function setupForTextArea(textArea) {
		if (!textArea) return;
		tText = textArea;
		textArea.addEventListener("compositionupdate", update);
		textArea.addEventListener("compositionend", addFurigana);
		injectIntoDiscourse();
	}

	async function waitFor(elementId, checkInterval = 1000, waitCutoff = Infinity) {
		let result = null;
		while (--waitCutoff > 0 && !(result = document.getElementById(elementId))) await sleep(checkInterval);
		return result;
	}

	function sleep(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}

	// ---MAIN LOGIC--- //

	function addButton(div) {
		if (!div || (bMode && bMode.parentElement === div)) return;
		bMode = document.createElement("button");
		bMode.id = "ime2furigana-button";
		bMode.className = "btn no-text btn-icon ember-view";
		bMode.textContent = "F";
		updateButton();
		bMode.addEventListener("click", cycleMode);
		div.appendChild(bMode);
	}

	function cycleMode() {
		setModeValue(mode > 1 ? 0 : mode + 1);
		if (tText) tText.focus();
	}

	function updateButton() {
		bMode.classList.toggle("active", mode);
		bMode.classList.toggle("blur", mode === 2);
		bMode.title = "IME2Furigana - " + (mode ? (mode === 1 ? "on" : "blur") : "off");
	}

	function update(event) {
		if (FURIGANA_REGEX.test(event.data)) {
			furigana = event.data;
		}
	}

	function addFurigana(event) {
		if (!mode || event.data.length === 0) return;
		furigana = furigana.replace(/n/g, "ん");
		let parts = event.data.split(KANJI_REGEX);
		if (parts.length === 1) return;
		let hiraganaParts = parts.map(p => Array.from(p).map(c => katakanaToHiragana(c)).join(""));
		let regex = new RegExp("^" + hiraganaParts.map((p, idx) => "(" + (idx & 1 ? ".+" : p) + ")").join("") + "$");
		let rt = furigana.match(regex);
		if (!rt) {
			parts = [event.data];
			rt = [null, furigana];
		}
		rt.shift();
		let rtStart = mode === 2 ? "{" : "[";
		let rtEnd   = mode === 2 ? "}" : "]";
		let markup  = parts.map((p, idx) => idx & 1 ? "<" + p + ">" + rtStart + rt[idx] + rtEnd : p).join("");
		event.target.setRangeText(markup, event.target.selectionStart - event.data.length, event.target.selectionStart, "end");
	}

	function katakanaToHiragana(k) {
		let c = k.charCodeAt(0);
		return c >= 12449 && c <= 12531 ? String.fromCharCode(k.charCodeAt(0) - 96) : k;
	}

	function cleanup() {
		furigana = "";
		bMode = null;
		tText = null;
		dBanner = null;
	}

	// ---CONVERTING BACK TO FURIGANA MARKUP--- //

	function removeBanner() {
		if (dBanner) dBanner.parentElement.removeChild(dBanner);
		dBanner = null;
	}

	function checkForRubyTags() {
		if (tText.parentElement.parentElement.classList.contains(NO_BACK_CONVERSION_CLASS_FLAG)) return;
		if (!RUBY_REGEX.test(tText.value)) return removeBanner();
		if (dBanner) return;
		dBanner = document.createElement("div");
		let bConvert = document.createElement("button");
		let bCancel = document.createElement("button");
		dBanner.id = "ime2furigana-conversion-banner";
		dBanner.textContent = "Convert <ruby> to furigana markup?";
		bConvert.textContent = "\u2714";
		bCancel.textContent = "\u274C";
		dBanner.appendChild(bConvert);
		dBanner.appendChild(bCancel);
		bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
		bCancel.addEventListener("click", () => { tText.parentElement.parentElement.classList.add(NO_BACK_CONVERSION_CLASS_FLAG); removeBanner(); });
		tText.insertAdjacentElement("beforebegin", dBanner);
	}

	function rubyToFuriganaMarkup() {
		let parts = tText.value.split(RUBY_REGEX);
		if (parts.length === 1) return;
		tText.value = parts.map((p, idx) => idx & 1 ? rubyContentToFuriganaMarkup(p) : p).join("");
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function rubyContentToFuriganaMarkup(ruby) {
		// should be able to handle both interleaved and tabular markup
		// remove <rp>...</rp> or <rp>...<rt>
		ruby = ruby.split(/<rp\s*>/).map((part, idx) => idx === 0 ? part : part.substr(part.search(/<\/rp\s*>|<rt\s*>/))).join("").replace(/<\/rp\s*>/g, "");
		// get rt content
		let rt = ruby.split(/<rt\s*>/).map(part => part.substr(0, part.concat("<rb>").search(/<rb\s*>|<\/rt\s*>/)));
		rt.shift();
		// get rb content
		let rb = ruby.split(/(?:<\/rt\s*>\s*)?<rb\s*>|<\/rt\s*>/).map(part => part.substr(0, part.concat("<rt>").search(/(?:<\/rb\s*>\s*)?<rt\s*>/))).filter(part => !/^\s*$/.test(part));
		// add furigana markup brackets to rt
		rt = rt.map(v => SPOILER_REGEX.test(v) ? ("{" + SPOILER_REGEX.exec(v)[1] + "}") : ("[" + v + "]"));
		// sanitize rb ("<" not allowed except for tags)
		rb = rb.map(v => v.replace(/<(?!\/?\b[^<>]*>)/g, "&lt;"));
		// sanitize rb (">" not allowed except for tags)
		rb = rb.map(v => v.replace(greaterThan_regex, "&gt;"));
		// sanitize rt ("]" or "}" not allowed)
		rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "&rsqb;") : v.replace(/}(?!$)/, "&rcub;"));
		// pad rt/rb to be the same length
		let result = rb.reduce((total, v, idx) => total + "<" + v + ">" + (rt[idx] || "[]"), "");
		result += rt.slice(rb.length).reduce((total, v) => total + "<>" + v, "");
		return result;
	}

	// ---COOKING RULE INJECTION--- //

	function injectIntoDiscourse() {
		if (alreadyInjected) return;
		alreadyInjected = true;
		// greasemonkey workaround: unsafeWindow + exportFunction
		let w = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
		let e = typeof exportFunction === "undefined" ? o => o : exportFunction;
		injectCustomCook(w, e);
		injectCustomSave(w, e);
	}

	function injectCustomCook(w, e) {
		let oldCook = require("pretty-text/engines/discourse-markdown-it").cook;
		require("pretty-text/engines/discourse-markdown-it").cook = e((raw, opts) => oldCook(customCook(raw), opts), w);
	}

	function injectCustomSave(w, e) {
		let oldSave = require("discourse/controllers/composer").default.prototype.save;
		require("discourse/controllers/composer").default.prototype.save = e(function(t) { tText.value = customCook(tText.value); tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true})); oldSave.call(this, t); }, w);
	}

	function customCook(raw) {
		if (!mode) {
			removeBanner();
			return raw;
		}
		ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP ? checkForRubyTags() : rubyToFuriganaMarkup();
		raw = raw.replace(COOK_SEARCH_REGEX, RUBY_TEMPLATE);
		return raw.replace(COOK_SPOILER_SEARCH_REGEX, RUBY_SPOILER_TEMPLATE);
	}

	// ---ADD CSS--- //

	function addCss() {
		let style = document.createElement("style");
		style.textContent = "#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: var(--tertiary-low, rgba(163, 225, 255, 0.5)); }" +
			"#ime2furigana-conversion-banner > button { background-color: transparent; border: none; }" +
			"#ime2furigana-button.active { background-color: #00000042; }" +
			"#ime2furigana-button.blur { filter: blur(2px); }";
		document.head.appendChild(style);
	}
})();
1 Like

The F stopped showing up again.

The only error is this again “Refused to execute a script because its hash, its nonce, or ‘unsafe-inline’ does not appear in the script-src directive of the Content Security Policy.”

I don’t know enough about bugs or coding to have any idea if that is the bug.

I would prefer to not try every script manager in the apple store. :joy: And is it that likely that the safari issues are handled well in other ones? Safari is so often a low priority.

Too bad. It seems that this error shows up when the script manager attempts to inject the script into page and then falls back to content. However, when the script is injected into content, it cannot access the window object which is needed to add most of the IME2Furigana functionality.

Maybe I can put the code into a separate file and insert a <script> element referencing it. I’m not sure if I would then again run into CSP issues.

1 Like

Thank you for all the time you are spending on this (@polv too). Safari *sigh*, amiright? :sweat_smile:

2 Likes

It’s mostly about Apple company’s stance, AFAIK; like you have to develop in Xcode, and gotta pay yearly fee, even if your app is free; and most importantly, you need a Mac to develop at all.

There are other things like Safari’s JavaScript engine is different from Chrome family, and Firefox family. Microsoft’s Edge eventually decided to use Chrome’s engine. – Well, it is totally better than the time of Netscape Navigator - most websites work fine, though extensions are another issue.


Though, part of the issue today, is Safari trying to be more secure, therefore enforces more Content-Security-Policy (CSP).

2 Likes

Thank you for the explanation.

I will admit that I like that Safari/Apple is trying to be more secure and privacy oriented. Part of me haven’t quite accepted that it also means I can’t have some of the nice things (as easily anyway).


@Sinyaven and @polv Since I’m possibly the only safari user who wants these scripts, don’t worry about it. I will try any new versions you throw at me, but it is fine if this is it. :blush:

I’ve lived without these scripts so far, and I can keep doing that. ^^

2 Likes

If you still have the “Userscripts” extension installed and are up for more testing, here is a version which tries to fall back to a different method if the original one fails:

Script
// ==UserScript==
// @name         IME2Furigana
// @namespace    ime2furigana
// @version      1.7
// @description  Adds furigana markup functionality to Discourse. When inputting kanji with an IME, furigana markup is automatically added.
// @author       Sinyaven
// @license      MIT-0
// @match        https://community.wanikani.com/*
// @homepageURL  https://community.wanikani.com/t/39109
// @grant        none
// ==/UserScript==

(async function() {
	"use strict";

	/* global require, exportFunction */
	/* eslint no-multi-spaces: "off" */

	//////////////
	// settings //
	//////////////

	const ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP = true;

	//////////////

	const DISCOURSE_REPLY_BOX_ID = "reply-control";
	const DISCOURSE_REPLY_AREA_CLASS = "reply-area";
	const DISCOURSE_BUTTON_BAR_CLASS = "d-editor-button-bar";
	const NO_BACK_CONVERSION_CLASS_FLAG = "ruby-to-furigana-markup-disabled";
	const RUBY_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt>$2</rt><rp>)</rp></ruby>";
	const RUBY_SPOILER_TEMPLATE = "<ruby lang = 'ja-JP'>$1<rp>(</rp><rt><span class='spoiler'>$2</span></rt><rp>)</rp></ruby>";
	const FURIGANA_REGEX = /^[\u3041-\u3096\u3000-\u303f\uff01-\uff5e¥]+$/;
	const KANJI_REGEX = /([\uff66-\uff9d\u4e00-\u9faf\u3400-\u4dbf]+)/;
	const RUBY_REGEX = /<ruby\b[^>]*>((?:(?!<\/?ruby\b)[^])+)<\/ruby>/; // using [^] as a complete wildcard (as opposed to . which does not match newlines without the dotAll flag)
	const SPOILER_BBCODE_REGEX = /^\[spoiler\]([^]*)\[\/spoiler\]$/;
	const SPOILER_HTML_REGEX = /^<span\b[^>]*\bclass\s*=\s*["'][^"']*\bspoiler\b[^"']*["'][^>]*>([^]*)<\/span>$/;
	const COOK_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!\[)|[^<>])*)>\[(?!spoiler\s*\])([^\]]*)\]/g;
	const COOK_SPOILER_SEARCH_REGEX = /<(?!\s)((?:<\/?\b[^<>]*>(?!{)|[^<>])*)>{([^}]*)}/g;

	// negative lookbehind might not be supported (e.g. Waterfox) - in that case use an insufficient regex and hope for the best
	let greaterThan_regex = null;
	try { greaterThan_regex = new RegExp("(?<!<\\/?\\b[^<>]*)>", "g"); } catch (e) { greaterThan_regex = /^>/g; }

	let mode = 1;
	let furigana = "";
	let bMode = null;
	let tText = null;
	let dBanner = null;
	let alreadyInjected = false;

	// ---STORAGE--- //

	mode = parseInt(localStorage.getItem("furiganaMode") || mode);
	addEventListener("storage", e => e.key === "furiganaMode" ? modeValueChangeHandler(parseInt(e.newValue)) : undefined);

	function modeValueChangeHandler(newValue) {
		mode = newValue;
		if (!bMode) return;

		updateButton();
		// trigger _updatePreview() by appending a space, dispatching a change event, and then removing the space
		let textValue = tText.value;
		let selectionStart = tText.selectionStart;
		let selectionEnd = tText.selectionEnd;
		let selectionDirection = tText.selectionDirection;
		tText.value += " ";
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
		tText.value = textValue;
		tText.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function setModeValue(newValue) {
		modeValueChangeHandler(newValue);
		localStorage.setItem("furiganaMode", mode);
	}

	// ---REPLY BOX AND TEXT AREA DETECTION--- //

	let dObserverTarget = await waitFor(DISCOURSE_REPLY_BOX_ID, 1000, 30); // Greasemonkey seems to inject script before reply box is available, so we might have to wait
	let observer = new MutationObserver(m => m.forEach(handleMutation));
	observer.observe(dObserverTarget, {childList: true, subtree: true});

	addCss();

	// text area might already be open
	setupForTextArea(document.querySelector("textarea.d-editor-input"));
	addButton(document.getElementsByClassName(DISCOURSE_BUTTON_BAR_CLASS)[0]);

	function handleMutation(mutation) {
		let addedNodes = Array.from(mutation.addedNodes);
		let removedNodes = Array.from(mutation.removedNodes);
		// those forEach() are executed at most once
		addedNodes.filter(n => n.tagName === "TEXTAREA").forEach(setupForTextArea);
		addedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_BUTTON_BAR_CLASS)).forEach(addButton);
		removedNodes.filter(n => n.classList && n.classList.contains(DISCOURSE_REPLY_AREA_CLASS)).forEach(cleanup);
	}

	function setupForTextArea(textArea) {
		if (!textArea) return;
		tText = textArea;
		textArea.addEventListener("compositionupdate", update);
		textArea.addEventListener("compositionend", addFurigana);
		textArea.addEventListener("keydown", e => e.ctrlKey && e.shiftKey && e.key.toUpperCase() === "F" ? cycleMode() : undefined);
		injectIntoDiscourse();
	}

	async function waitFor(elementId, checkInterval = 1000, waitCutoff = Infinity) {
		let result = null;
		while (--waitCutoff > 0 && !(result = document.getElementById(elementId))) await sleep(checkInterval);
		return result;
	}

	function sleep(ms) {
		return new Promise(resolve => setTimeout(resolve, ms));
	}

	// ---MAIN LOGIC--- //

	function addButton(div) {
		if (!div || (bMode && bMode.parentElement === div)) return;
		bMode = document.createElement("button");
		bMode.id = "ime2furigana-button";
		bMode.className = "btn no-text btn-icon ember-view";
		bMode.textContent = "F";
		updateButton();
		bMode.addEventListener("click", cycleMode);
		div.appendChild(bMode);
	}

	function cycleMode() {
		setModeValue(mode > 1 ? 0 : mode + 1);
		if (tText) tText.focus();
	}

	function updateButton() {
		bMode.classList.toggle("active", mode);
		bMode.classList.toggle("blur", mode === 2);
		bMode.title = "IME2Furigana - " + (mode ? (mode === 1 ? "on" : "blur") : "off");
	}

	function update(event) {
		if (FURIGANA_REGEX.test(event.data)) {
			furigana = event.data;
		}
	}

	function addFurigana(event) {
		if (!mode || event.data.length === 0) return;
		furigana = furigana.replace(/n/g, "ん");
		let parts = event.data.split(KANJI_REGEX);
		if (parts.length === 1) return;
		let hiraganaParts = parts.map(p => Array.from(p).map(c => katakanaToHiragana(c)).join(""));
		let regex = new RegExp("^" + hiraganaParts.map((p, idx) => "(" + (idx & 1 ? ".+" : p) + ")").join("") + "$");
		let rt = furigana.match(regex);
		if (!rt) {
			parts = [event.data];
			rt = [null, furigana];
		}
		rt.shift();
		let rtStart = mode === 2 ? "{" : "[";
		let rtEnd   = mode === 2 ? "}" : "]";
		let markup  = parts.map((p, idx) => idx & 1 ? "<" + p + ">" + rtStart + rt[idx] + rtEnd : p).join("");
		event.target.setRangeText(markup, event.target.selectionStart - event.data.length, event.target.selectionStart, "end");
	}

	function katakanaToHiragana(k) {
		let c = k.charCodeAt(0);
		return c >= 12449 && c <= 12531 ? String.fromCharCode(k.charCodeAt(0) - 96) : k;
	}

	function cleanup() {
		furigana = "";
		bMode = null;
		tText = null;
		dBanner = null;
	}

	// ---CONVERTING BACK TO FURIGANA MARKUP--- //

	function removeBanner() {
		if (dBanner) dBanner.parentElement.removeChild(dBanner);
		dBanner = null;
	}

	function checkForRubyTags() {
		if (tText.parentElement.parentElement.classList.contains(NO_BACK_CONVERSION_CLASS_FLAG)) return;
		if (!RUBY_REGEX.test(tText.value)) return removeBanner();
		if (dBanner) return;
		dBanner = document.createElement("div");
		let bConvert = document.createElement("button");
		let bCancel = document.createElement("button");
		dBanner.id = "ime2furigana-conversion-banner";
		dBanner.textContent = "Convert <ruby> to furigana markup?";
		bConvert.textContent = "\u2714";
		bCancel.textContent = "\u274C";
		dBanner.appendChild(bConvert);
		dBanner.appendChild(bCancel);
		bConvert.addEventListener("click", () => { rubyToFuriganaMarkup(); removeBanner(); });
		bCancel.addEventListener("click", () => { tText.parentElement.parentElement.classList.add(NO_BACK_CONVERSION_CLASS_FLAG); removeBanner(); });
		tText.insertAdjacentElement("beforebegin", dBanner);
	}

	function rubyToFuriganaMarkup() {
		let parts = tText.value.split(RUBY_REGEX);
		if (parts.length === 1) return;
		tText.value = parts.map((p, idx) => idx & 1 ? rubyContentToFuriganaMarkup(p) : p).join("");
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
	}

	function rubyContentToFuriganaMarkup(ruby) {
		// should be able to handle both interleaved and tabular markup
		// remove <rp>...</rp> or <rp>...<rt>
		ruby = ruby.split(/<rp\s*>/).map((part, idx) => idx === 0 ? part : part.substr(part.search(/<\/rp\s*>|<rt\s*>/))).join("").replace(/<\/rp\s*>/g, "");
		// get rt content
		let rt = ruby.split(/<rt\s*>/).map(part => part.substr(0, part.concat("<rb>").search(/<rb\s*>|<\/rt\s*>/)));
		rt.shift();
		// get rb content
		let rb = ruby.split(/(?:<\/rt\s*>\s*)?<rb\s*>|<\/rt\s*>/).map(part => part.substr(0, part.concat("<rt>").search(/(?:<\/rb\s*>\s*)?<rt\s*>/))).filter(part => !/^\s*$/.test(part));
		// add furigana markup brackets to rt
		rt = rt.map(v => SPOILER_BBCODE_REGEX.test(v) ? ("{" + SPOILER_BBCODE_REGEX.exec(v)[1] + "}") : SPOILER_HTML_REGEX.test(v) ? ("{" + SPOILER_HTML_REGEX.exec(v)[1] + "}") : ("[" + v + "]"));
		// sanitize rb ("<" not allowed except for tags)
		rb = rb.map(v => v.replace(/<(?!\/?\b[^<>]*>)/g, "&lt;"));
		// sanitize rb (">" not allowed except for tags)
		rb = rb.map(v => v.replace(greaterThan_regex, "&gt;"));
		// sanitize rt ("]" or "}" not allowed)
		rt = rt.map(v => v[0] === "[" ? v.replace(/\](?!$)/, "&rsqb;") : v.replace(/}(?!$)/, "&rcub;"));
		// pad rt/rb to be the same length
		let result = rb.reduce((total, v, idx) => total + "<" + v + ">" + (rt[idx] || "[]"), "");
		result += rt.slice(rb.length).reduce((total, v) => total + "<>" + v, "");
		return result;
	}

	// ---COOKING RULE INJECTION--- //

	function injectIntoDiscourse() {
		// greasemonkey workaround: unsafeWindow + exportFunction
		let w = typeof unsafeWindow === "undefined" ? window : unsafeWindow;
		let e = typeof exportFunction === "undefined" ? o => o : exportFunction;
		if (!w.require) { contentContextFallback(); return; }
		if (alreadyInjected) return;
		alreadyInjected = true;
		injectCustomCook(w, e);
		injectCustomSave(w, e);
	}

	function injectCustomCook(w, e) {
		let oldCook = w.require("pretty-text/engines/discourse-markdown-it").cook;
		w.require("pretty-text/engines/discourse-markdown-it").cook = e((raw, opts) => oldCook(customCook(raw), opts), w);
	}

	function injectCustomSave(w, e) {
		let oldSave = w.require("discourse/controllers/composer").default.prototype.save;
		w.require("discourse/controllers/composer").default.prototype.save = e(function(t) { applyCustomCookToInput(); oldSave.call(this, t); }, w);
	}

	function customCook(raw) {
		if (!mode) {
			removeBanner();
			return raw;
		}
		ASK_BEFORE_CONVERTING_RUBY_TO_FURIGANA_MARKUP ? checkForRubyTags() : rubyToFuriganaMarkup();
		let halfCooked = raw.replace(COOK_SEARCH_REGEX, RUBY_TEMPLATE);
		halfCooked = halfCooked.replace(COOK_SPOILER_SEARCH_REGEX, RUBY_SPOILER_TEMPLATE);
		bMode.classList.toggle("markup-found", halfCooked !== raw);
		return halfCooked;
	}

	function applyCustomCookToInput() {
		tText.value = customCook(tText.value);
		tText.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
		bMode.classList.remove("markup-found");
	}

	// ---FALLBACK IF WE CANNOT ACCESS JAVASCRIPT OBJECTS FROM PAGE CONTEXT--- //

	function contentContextFallback() {
		console.warn("IME2Furigana: No access to objects in page context. Using fallback method which might be a bit slower, can cause text flickering, and pauses the instant preview during IME input.");
		const bSave = document.querySelector(".save-or-cancel button");
		let fallbackIgnoreChange = false;
		async function callback() {
			if (fallbackIgnoreChange || !mode) return;
			fallbackIgnoreChange = true;
			const original           = tText.value;
			const selectionStart     = tText.selectionStart;
			const selectionEnd       = tText.selectionEnd;
			const selectionDirection = tText.selectionDirection;
			applyCustomCookToInput();
			await true;
			tText.value = original;
			tText.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
			fallbackIgnoreChange = false;
		}
		function delayedCallback() {
			if (fallbackIgnoreChange || !mode) return;
			setTimeout(callback);
		}
		tText.addEventListener("compositionstart", () => { fallbackIgnoreChange = true; });
		tText.addEventListener("compositionend"  , () => { fallbackIgnoreChange = false; });
		tText.addEventListener("input"  , callback);
		tText.addEventListener("change" , delayedCallback);
		tText.addEventListener("keyup"  , delayedCallback);
		bSave.addEventListener("click"  , applyCustomCookToInput);
	}

	// ---ADD CSS--- //

	function addCss() {
		let style = document.createElement("style");
		style.textContent = `
			#ime2furigana-conversion-banner { transform: translateY(-0.25em); padding: 0.2em 0.6em; border-bottom: 1px solid gray; background-color: var(--tertiary-low, rgba(163, 225, 255, 0.5)); }
			#ime2furigana-conversion-banner > button { background-color: transparent; border: none; }
			#ime2furigana-button.active.markup-found { border-bottom: 4px solid var(--tertiary, blue); padding-bottom: calc(0.5em - 3px); }
			#ime2furigana-button.active { background-color: #00000042; }
			#ime2furigana-button.blur { filter: blur(2px); }`;
		document.head.appendChild(style);
	}
})();
1 Like

()仮名(がな) 出来(でき)る?そうだね。()かった!
Well, it seems to work with userscripts now. :smiley:

Even if the extension itself was being annoying to get to work again; something about the old permission to work on community.wanikani messing with allowing it this time, until I turned off and turned on the permission. :roll_eyes:

Any chance you can get this to work with Tampermonkey? Actually, I’m gonna go see if it works with tampermonkey… And it turns out as kinda expected (since you weren’t trying to fix it for tampermonkey) that the F doesn’t show up.

3 Likes

Sorry, but it seems that the Tampermonkey version for Safari does not manage to inject scripts on the forums at all, so there’s nothing I can do.

1 Like

Ah, well, doh. :sweat_smile:

Well, that version does work with Userscripts. When editing a message I had to add/delete a letter for the banner about turning it back into the easy format to show up, but maybe that is true for other versions too. I thought I should mention it though.

1 Like