Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(split links): rewrite "MB: Paste multiple external links" to TypeScript, fix shortcomings #473

Merged
merged 13 commits into from
Jun 12, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@ Seed the recording comments for the batch recording comments userscripts with li
### MB: QoL: Paste multiple external links at once
Paste multiple external links at once into the external link editor. Input is split on whitespace (newlines, tabs, spaces, etc.) and fed into the link editor separately.

[![Install](https://img.shields.io/badge/install-latest-informational?style=for-the-badge&logo=tampermonkey)](mb_multi_external_links.user.js?raw=1)
[![Source](https://img.shields.io/badge/source-grey?style=for-the-badge&logo=github)](mb_multi_external_links.user.js)
[![Install](https://img.shields.io/badge/dynamic/json?label=install&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2FROpdebee%2Fmb-userscripts%2Fdist%2Fmb_multi_external_links.metadata.json&logo=tampermonkey&style=for-the-badge&color=informational)](https://raw.github.com/ROpdebee/mb-userscripts/dist/mb_multi_external_links.user.js)
[![Source](https://img.shields.io/badge/source-grey?style=for-the-badge&logo=github)](src/mb_multi_external_links)
[![Changelog](https://img.shields.io/badge/changelog-grey?style=for-the-badge)](https://github.com/ROpdebee/mb-userscripts/blob/dist/mb_multi_external_links.changelog.md)
6 changes: 3 additions & 3 deletions mb_multi_external_links.user.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// ==UserScript==
// @name MB: QoL: Paste multiple external links at once
// @version 2021.9.19
// @version 2022.6.10
// @description Enables pasting multiple links, separated by whitespace, into the external link editor.
// @author ROpdebee
// @license MIT; https://opensource.org/licenses/MIT
// @namespace https://github.com/ROpdebee/mb-userscripts
// @downloadURL https://raw.github.com/ROpdebee/mb-userscripts/main/mb_multi_external_links.user.js
// @updateURL https://raw.github.com/ROpdebee/mb-userscripts/main/mb_multi_external_links.user.js
// @downloadURL https://raw.github.com/ROpdebee/mb-userscripts/dist/mb_multi_external_links.user.js
// @updateURL https://raw.github.com/ROpdebee/mb-userscripts/dist/mb_multi_external_links.meta.js
// @match *://*.musicbrainz.org/*/edit
// @match *://musicbrainz.org/*/edit
// @run-at document-end
Expand Down
36 changes: 36 additions & 0 deletions src/lib/MB/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Incomplete

export interface ExternalLinksLink {
rawUrl: string;
url: string;
submitted: boolean;
}

export interface ExternalLinks {
handleUrlChange(linkIndexes: readonly number[], urlIndex: number, rawUrl: string): void;
handleUrlBlur(index: number, isDuplicate: boolean, event: FocusEvent, urlIndex: number, canMerge: boolean): void;
tableRef: {
current: HTMLTableElement;
};

state: {
links: ExternalLinksLink[];
};
}

export interface ReleaseEditor {
externalLinks: {
current: ExternalLinks;
};
}

declare global {
interface Window {
MB: {
releaseEditor?: ReleaseEditor;
sourceExternalLinksEditor?: {
current: ExternalLinks;
};
};
}
}
29 changes: 28 additions & 1 deletion src/lib/util/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* DOM utilities.
*/

import { assertNonNull } from './assert';
import { assertDefined, assertNonNull } from './assert';

/**
* Element.querySelector shorthand, query result required to exist.
Expand Down Expand Up @@ -47,6 +47,15 @@ export function onDocumentLoaded(listener: () => void): void {
}
}

// istanbul ignore next: TODO: Test
export function onWindowLoaded(listener: () => void, windowInstance: Window = window): void {
if (windowInstance.document.readyState === 'complete') {
listener();
} else {
windowInstance.addEventListener('load', listener);
}
}

export function parseDOM(html: string, baseUrl: string): Document {
const doc = new DOMParser().parseFromString(html, 'text/html');

Expand All @@ -61,3 +70,21 @@ export function parseDOM(html: string, baseUrl: string): Document {

return doc;
}

// istanbul ignore next: TODO: Test
// https://github.com/facebook/react/issues/10135#issuecomment-401496776
// Via loujine's wikidata script.
export function setInputValue(input: HTMLInputElement, value: string): void {
ROpdebee marked this conversation as resolved.
Show resolved Hide resolved
/* eslint-disable @typescript-eslint/unbound-method -- Will bind later. */
const valueSetter = Object.getOwnPropertyDescriptor(input, 'value')?.set;
const prototype = Object.getPrototypeOf(input) as typeof HTMLInputElement.prototype;
const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
/* eslint-enable @typescript-eslint/unbound-method */

if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(input, value);
} else {
assertDefined(valueSetter, 'Element has no value setter');
valueSetter.call(input, value);
}
}
197 changes: 197 additions & 0 deletions src/mb_multi_external_links/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// istanbul ignore file: Better suited for E2E test.

import type { ExternalLinks } from '@lib/MB/types';
import { ConsoleSink } from '@lib/logging/consoleSink';
import { LogLevel } from '@lib/logging/levels';
import { LOGGER } from '@lib/logging/logger';
import { assertDefined } from '@lib/util/assert';
import { retryTimes } from '@lib/util/async';
import { createPersistentCheckbox } from '@lib/util/checkboxes';
import { onWindowLoaded, qsa, qsMaybe, setInputValue } from '@lib/util/dom';

import DEBUG_MODE from 'consts:debug-mode';
import USERSCRIPT_ID from 'consts:userscript-id';

function getExternalLinksEditor(mbInstance: typeof window.MB): ExternalLinks {
// Can be found in the MB object, but exact property depends on actual page.
const editor = (mbInstance.releaseEditor?.externalLinks ?? mbInstance.sourceExternalLinksEditor)?.current;
assertDefined(editor, 'Cannot find external links editor object');
return editor;
}

function getLastInput(editor: ExternalLinks): HTMLInputElement {
const linkInputs = qsa<HTMLInputElement>('input.value', editor.tableRef.current);
return linkInputs[linkInputs.length - 1];
}

function submitUrls(editor: ExternalLinks, urls: string[]): void {
// Technically we're recursively calling the patched methods, but the
// patched methods just pass through to the originals when there's only
// one link.
if (urls.length === 0) return;

const lastInput = getLastInput(editor);

LOGGER.debug(`Submitting URL ${urls[0]}`);
setInputValue(lastInput, urls[0]);
lastInput.dispatchEvent(new InputEvent('input', { bubbles: true }));
// Need to wait a while before the input event is processed before we can
// fire the blur event, otherwise things get messy.
setTimeout(() => {
lastInput.dispatchEvent(new Event('focusout', { bubbles: true }));
submitUrls(editor, urls.slice(1));
});
}

const Patcher = {
urlQueue: [] as string[],

patchOnBlur(editor: ExternalLinks, originalOnBlur: ExternalLinks['handleUrlBlur']): ExternalLinks['handleUrlBlur'] {
return (index, isDupe, event, urlIndex, canMerge) => {
// onchange should have removed the other URLs and queued them in the urlQueue.
originalOnBlur(index, isDupe, event, urlIndex, canMerge);

// Past each link in the URL queue one-by-one.
ROpdebee marked this conversation as resolved.
Show resolved Hide resolved
submitUrls(editor, this.urlQueue);
this.urlQueue = [];
};
},

patchOnChange(originalOnBlur: ExternalLinks['handleUrlChange']): ExternalLinks['handleUrlChange'] {
return (linkIndexes, urlIndex, rawUrl) => {
// Split the URLs and only feed the first URL into actual handler. This
// is to prevent it from performing any cleanup or relationship type
// inference that doesn't make sense.
// We'll feed the other URLs one-by-one separately later on the blur event.
// However, we need to "remember" those URLs, as the original handler
// seems to assign the input value and we'll lose the rest of the URLs.
LOGGER.debug(`onchange received URLs ${rawUrl}`);
const splitUrls = rawUrl.trim().split(/\s+/);
this.urlQueue = splitUrls.slice(1);
originalOnBlur(linkIndexes, urlIndex, splitUrls.length > 0 ? splitUrls[0] : rawUrl);
};
},
};

interface LinkSplitter {
enable(): void;
disable(): void;
toggle(): void;
setEnabled(enabled: boolean): void;
}

function createLinkSplitter(editor: ExternalLinks): LinkSplitter {
const originalOnBlur = editor.handleUrlBlur.bind(editor);
const originalOnChange = editor.handleUrlChange.bind(editor);

const patchedOnBlur = Patcher.patchOnBlur(editor, originalOnBlur);
const patchedOnChange = Patcher.patchOnChange(originalOnChange);

return {
enable(): void {
LOGGER.debug('Enabling link splitter');
editor.handleUrlBlur = patchedOnBlur;
editor.handleUrlChange = patchedOnChange;
},
disable(): void {
LOGGER.debug('Disabling link splitter');
editor.handleUrlBlur = originalOnBlur;
editor.handleUrlChange = originalOnChange;
},
setEnabled(enabled: boolean): void {
if (enabled) {
this.enable();
} else {
this.disable();
}
},
toggle(): void {
this.setEnabled(editor.handleUrlBlur === originalOnBlur);
},
};
}

function insertCheckboxElements(editor: ExternalLinks, checkboxElmt: HTMLInputElement, labelElmt: HTMLLabelElement): void {
// Adding the checkbox beneath the last input element would require constantly
// removing and reinserting while react re-renders the link editor. Instead,
// let's just add it outside of the table and align it with JS.
editor.tableRef.current.after(checkboxElmt, labelElmt);
const lastInput = getLastInput(editor);
const marginLeft = lastInput.offsetLeft + (lastInput.parentElement?.offsetLeft ?? 0);
checkboxElmt.style.marginLeft = `${marginLeft}px`;
}

async function run(windowInstance: Window): Promise<void> {
// This element should be present even before React is initialized. Checking
// for its existence enables us to skip attempting to find the link input on
// edit pages that don't have external links, without having to exclude
// specific pages.
const editorContainer = qsMaybe<HTMLElement>('#external-links-editor-container', windowInstance.document);
if (!editorContainer) return;

// We might be running before the release editor is loaded, so retry a couple of times
// until it is. We could also just have a different @run-at or listen for the window
// load event, but if the page takes an extraordinary amount to load e.g. an image,
// the external links editor may be ready long before we run. We want to add our
// functionality as soon as possible without waiting for the whole page to load.
const editor = await retryTimes(() => getExternalLinksEditor(windowInstance.MB), 100, 50);
const splitter = createLinkSplitter(editor);
const [checkboxElmt, labelElmt] = createPersistentCheckbox('ROpdebee_multi_links_no_split', "Don't split links", () => {
splitter.toggle();
});
splitter.setEnabled(!checkboxElmt.checked);
insertCheckboxElements(editor, checkboxElmt, labelElmt);
}

function onIframeAdded(iframe: HTMLIFrameElement): void {
LOGGER.debug(`Initialising on iframe ${iframe.src}`);
const iframeWindow = iframe.contentWindow;
if (!iframeWindow) return;

function runInIframe(): void {
run(iframeWindow!)
.catch((err) => {
LOGGER.error('Something went wrong', err);
});
ROpdebee marked this conversation as resolved.
Show resolved Hide resolved
}

// Cannot use onDocumentLoaded even if we make it accept a custom document
// since iframe contentDocument doesn't fire the DOMContentLoaded event in
// Firefox.
onWindowLoaded(runInIframe, iframeWindow);
}

// Observe for additions of embedded entity creation dialogs and run the link
// splitter on those as well.
function listenForIframes(): void {
const iframeObserver = new MutationObserver((mutations) => {
for (const addedNode of mutations.flatMap((mut) => [...mut.addedNodes])) {
if (addedNode instanceof HTMLElement && addedNode.classList.contains('iframe-dialog')) {
// Addition of a dialog: Get the iframe and run the splitter
const iframe = qsMaybe<HTMLIFrameElement>('iframe', addedNode);

if (iframe) {
onIframeAdded(iframe);
}
}
}
});

iframeObserver.observe(document, {
subtree: true,
childList: true,
});
}

LOGGER.configure({
logLevel: DEBUG_MODE ? LogLevel.DEBUG : LogLevel.INFO,
});
LOGGER.addSink(new ConsoleSink(USERSCRIPT_ID));

run(window)
// TODO: Replace this by `logFailure`
.catch((err) => {
LOGGER.error('Something went wrong', err);
});

listenForIframes();
19 changes: 19 additions & 0 deletions src/mb_multi_external_links/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { UserscriptMetadata } from '@lib/util/metadata';
import { transformMBMatchURL } from '@lib/util/metadata';

const metadata: UserscriptMetadata = {
name: 'MB: QoL: Paste multiple external links at once',
description: 'Enables pasting multiple links, separated by whitespace, into the external link editor.',
'run-at': 'document-end',
match: [
'*/edit',
'*/edit?*', // Not entirely sure whether these links can ever exist, but if they do, we should match them.
ROpdebee marked this conversation as resolved.
Show resolved Hide resolved
'release/*/edit-relationships*',
'*/add',
'*/add?*',
'*/create',
'*/create?*',
].map((path) => transformMBMatchURL(path)),
};

export default metadata;
10 changes: 10 additions & 0 deletions src/mb_multi_external_links/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../configs/tsconfig.base-web.json",
"include": ["**/*"],
"references": [
{ "path": "../lib/" }
],
"compilerOptions": {
"types": ["nativejsx/types/jsx"]
}
}
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
{ "path": "./build" },
{ "path": "./src/lib" },
{ "path": "./src/mb_enhanced_cover_art_uploads" },
{ "path": "./src/mb_multi_external_links" },
{ "path": "./tests/unit/build" },
{ "path": "./tests/unit/lib" },
{ "path": "./tests/unit/mb_enhanced_cover_art_uploads" },
Expand Down