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 1 commit
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
34 changes: 34 additions & 0 deletions src/lib/MB/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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 {
const MB: {
releaseEditor?: ReleaseEditor;
sourceExternalLinksEditor?: {
current: ExternalLinks;
};
};
}
19 changes: 18 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 @@ -61,3 +61,20 @@ export function parseDOM(html: string, baseUrl: string): Document {

return doc;
}

// 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);
}
}
221 changes: 140 additions & 81 deletions src/mb_multi_external_links/index.ts
Original file line number Diff line number Diff line change
@@ -1,91 +1,150 @@
import { assertNonNull } from '@lib/util/assert';

const noSplitCboxHtml = `
<div id="ROpdebee_no_split_cont">
<input type="checkbox" id="ROpdebee_no_split">
<label for="ROpdebee_no_split">Don't split links</label>
</div>`;

function addInputHandler(): void {
const inp = document.querySelector('#external-links-editor > tbody > tr.external-link-item:last-child input');
// React might still be initializing, retry soon
if (!inp) {
return void setTimeout(addInputHandler, 100);
}

const existingCboxCont = document.querySelector('#ROpdebee_no_split_cont');
if (existingCboxCont) {
inp.insertAdjacentElement('afterend', existingCboxCont);
} else {
inp.insertAdjacentHTML('afterend', noSplitCboxHtml);
}
inp.addEventListener('input', handleLinkInput);
}
// 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 { qsa, qsMaybe, setInputValue } from '@lib/util/dom';

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

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

// Other edit pages, confirmed to exist on artists and labels, probably
// others as well
return MB.sourceExternalLinksEditor;
function getLastInput(editor: ExternalLinks): HTMLInputElement {
const linkInputs = qsa<HTMLInputElement>('input.value', editor.tableRef.current);
return linkInputs[linkInputs.length - 1];
}

function handleLinkInput(evt: Event): void {
const target = evt.currentTarget;
assertNonNull(target);
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);

// Queued for execution after we've split the links
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(() => {
// Remove ourselves from this element and add ourselves to the new empty
// input, in case new links will be pasted. The current event target
// doesn't exist anymore/was repurposed to store a pasted link.
target.removeEventListener('input', handleLinkInput);
addInputHandler();
}, 100);

if (document.querySelector('#ROpdebee_no_split').checked) return;

const links = (target as HTMLInputElement).value.trim().split(/\s+/);

// No need to split the input if there's only one link.
if (links.length <= 1) return;

// Don't let the link editor handle multi-link inputs.
evt.stopPropagation();

// We'll retrieve the React component to feed it the links directly, rather
// than changing the input's value and dispatching events. Reason being
// that dispatching events doesn't seem to work. Not sure why, probably due
// to React.
const tbody = getExtLinksEditor()._reactInternals.child.child;

links.forEach((link) => {
// Get the ExternalLink React component. Need to do this again for each
// link since it changes whenever we add a new one.
const extLink = tbody.child;
// Need to get the *last* ExternalLink, this is where we're inputting.
while (extLink.sibling) {
extLink = extLink.sibling;
}

// Normally called on change events, stores the link in some internal
// state.
extLink.memoizedProps.handleUrlChange(link);
// Normally called on blur events, to finalise the link.
// Fake event, just needs those props.
extLink.memoizedProps.handleUrlBlur({ currentTarget: { value: link } });
lastInput.dispatchEvent(new Event('focusout', { bubbles: true }));
submitUrls(editor, urls.slice(1));
});
}

// 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 cont = document.querySelector('#external-links-editor-container');
if (cont) {
addInputHandler();
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 {
const lastInput = getLastInput(editor);
lastInput.after(checkboxElmt, labelElmt);
}

async function run(): 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');
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, 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);
}

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

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