-
Notifications
You must be signed in to change notification settings - Fork 2
/
creditParserUI.js
300 lines (260 loc) · 10.4 KB
/
creditParserUI.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
import { addMessageToEditNote } from './editNote.js';
import { parserDefaults } from './parseCopyrightNotice.js';
import { readyRelationshipEditor } from './reactHydration.js';
import { automaticHeight, automaticWidth } from '@kellnerd/es-utils/dom/autoResize.js';
import { createElement, injectStylesheet } from '@kellnerd/es-utils/dom/create.js';
import { dom, qs, qsa } from '@kellnerd/es-utils/dom/select.js';
import { getPattern, getPatternAsRegExp } from '@kellnerd/es-utils/regex/parse.js';
import { slugify } from '@kellnerd/es-utils/string/casingStyle.js';
import {
persistCheckbox,
persistDetails,
persistInput,
} from '@kellnerd/es-utils/userscript/persistElement.js';
const creditParserUI = `
<details id="credit-parser">
<summary>
<h2>Credit Parser</h2>
</summary>
<form>
<details id="credit-parser-config">
<summary><h3>Advanced configuration</h3></summary>
<ul id="credit-patterns"></ul>
</details>
<div class="row">
<textarea name="credit-input" id="credit-input" cols="120" rows="1" placeholder="Paste credits here…"></textarea>
</div>
<div class="row">
Identified relationships will be added to the release and/or the matching recordings and works (only if these are selected).
</div>
<div class="row">
<input type="checkbox" name="remove-parsed-lines" id="remove-parsed-lines" />
<label class="inline" for="remove-parsed-lines">Remove parsed lines</label>
<input type="checkbox" name="parser-autofocus" id="parser-autofocus" />
<label class="inline" for="parser-autofocus">Autofocus the parser on page load</label>
</div>
<div class="row buttons">
</div>
</form>
</details>`;
const css = `
details#credit-parser summary {
cursor: pointer;
display: block;
}
details#credit-parser summary > h2, details#credit-parser summary > h3 {
display: list-item;
}
textarea#credit-input {
overflow-y: hidden;
}
#credit-parser label[title] {
border-bottom: 1px dotted;
cursor: help;
}`;
const uiReadyEventType = 'credit-parser-ui-ready';
/**
* Injects the basic UI of the credit parser and waits until the UI has been expanded before it continues with the build tasks.
* @param {...(() => void)} buildTasks Handlers which can be registered for additional UI build tasks.
*/
export async function buildCreditParserUI(...buildTasks) {
await readyRelationshipEditor();
/** @type {HTMLDetailsElement} */
const existingUI = dom('credit-parser');
// possibly called by multiple userscripts, do not inject the UI again
if (!existingUI) {
// inject credit parser between the sections for track and release relationships,
// use the "Release Relationships" heading as orientation since #tracklist is missing for releases without mediums
qs('.release-relationship-editor > h2:nth-of-type(2)').insertAdjacentHTML('beforebegin', creditParserUI);
injectStylesheet(css, 'credit-parser');
}
// execute all additional build tasks once the UI is open and ready
if (existingUI && existingUI.open) {
// our custom event already happened because the UI builder code is synchronous
buildTasks.forEach((task) => task());
} else {
// wait for our custom event if the UI is not (fully) initialized or is collapsed
buildTasks.forEach((task) => document.addEventListener(uiReadyEventType, () => task(), { once: true }));
}
if (existingUI) return;
// continue initialization of the UI once it has been opened
persistDetails('credit-parser', true).then((UI) => {
if (UI.open) {
initializeUI();
} else {
UI.addEventListener('toggle', initializeUI, { once: true });
}
});
}
async function initializeUI() {
const creditInput = dom('credit-input');
// persist the state of the UI
persistCheckbox('remove-parsed-lines');
await persistCheckbox('parser-autofocus');
persistDetails('credit-parser-config').then((config) => {
// hidden pattern inputs have a zero width, so they have to be resized if the config has not been open initially
if (!config.open) {
config.addEventListener('toggle', () => {
qsa('input.pattern', config).forEach((input) => automaticWidth.call(input));
}, { once: true });
}
});
// auto-resize the credit textarea on input
creditInput.addEventListener('input', automaticHeight);
// load seeded data from hash
const seededData = new URLSearchParams(window.location.hash.slice(1));
const seededCredits = seededData.get('credits');
if (seededCredits) {
setTextarea(creditInput, seededCredits);
const seededEditNote = seededData.get('edit-note');
if (seededEditNote) {
addMessageToEditNote(seededEditNote);
}
}
addButton('Load annotation', (creditInput) => {
/** @type {ReleaseT} */
const release = MB.getSourceEntityInstance();
const annotation = release.latest_annotation;
if (annotation) {
setTextarea(creditInput, annotation.text);
}
});
addPatternInput({
label: 'Credit terminator',
description: 'Matches the end of a credit (default when empty: end of line)',
defaultValue: parserDefaults.terminatorRE,
});
addPatternInput({
label: 'Credit separator',
description: 'Splits a credit into role and artist (disabled when empty)',
defaultValue: /\s[–-]\s|:\s|\t+/,
});
addPatternInput({
label: 'Name separator',
description: 'Splits the extracted name into multiple names (disabled when empty)',
defaultValue: parserDefaults.nameSeparatorRE,
});
// trigger all additional UI build tasks
document.dispatchEvent(new CustomEvent(uiReadyEventType));
// focus the credit parser input (if this setting is enabled)
if (dom('parser-autofocus').checked) {
creditInput.scrollIntoView();
creditInput.focus();
}
}
/**
* Adds a new button with the given label and click handler to the credit parser UI.
* @param {string} label
* @param {(creditInput: HTMLTextAreaElement, event: MouseEvent) => any} clickHandler
* @param {string} [description] Description of the button, shown as tooltip.
*/
export function addButton(label, clickHandler, description) {
/** @type {HTMLTextAreaElement} */
const creditInput = dom('credit-input');
/** @type {HTMLButtonElement} */
const button = createElement(`<button type="button">${label}</button>`);
if (description) {
button.title = description;
}
button.addEventListener('click', (event) => clickHandler(creditInput, event));
return qs('#credit-parser .buttons').appendChild(button);
}
/**
* Adds a new parser button with the given label and handler to the credit parser UI.
* @param {string} label
* @param {(creditLine: string, event: MouseEvent) => import('@kellnerd/es-utils').MaybePromise<CreditParserLineStatus>} parser
* Handler which parses the given credit line and returns whether it was successful.
* @param {string} [description] Description of the button, shown as tooltip.
*/
export function addParserButton(label, parser, description) {
/** @type {HTMLInputElement} */
const removeParsedLines = dom('remove-parsed-lines');
return addButton(label, async (creditInput, event) => {
const credits = creditInput.value.split('\n').map((line) => line.trim());
const parsedLines = [], skippedLines = [];
for (const line of credits) {
// skip empty lines, but keep them for display of skipped lines
if (!line) {
skippedLines.push(line);
continue;
}
// treat partially parsed lines as both skipped and parsed
const parserStatus = await parser(line, event);
if (parserStatus !== 'skipped') {
parsedLines.push(line);
}
if (parserStatus !== 'done') {
skippedLines.push(line);
}
}
if (parsedLines.length) {
addMessageToEditNote(parsedLines.join('\n'));
}
if (removeParsedLines.checked) {
setTextarea(creditInput, skippedLines.join('\n'));
}
}, description);
}
/**
* Adds a persisted input field for regular expressions with a validation handler to the credit parser UI.
* @param {object} config
* @param {string} [config.id] ID and name of the input element (derived from `label` if missing).
* @param {string} config.label Content of the label (without punctuation).
* @param {string} config.description Description which should be used as tooltip.
* @param {string} config.defaultValue Default value of the input.
*/
function addPatternInput(config) {
const id = config.id || slugify(config.label);
/** @type {HTMLInputElement} */
const patternInput = createElement(`<input type="text" class="pattern" name="${id}" id="${id}" placeholder="String or /RegExp/" />`);
const explanationLink = document.createElement('a');
explanationLink.innerText = 'help';
explanationLink.target = '_blank';
explanationLink.title = 'Displays a diagram representation of this RegExp';
const resetButton = createElement(`<button type="button" title="Reset the input to its default value">Reset</button>`);
resetButton.addEventListener('click', () => setInput(patternInput, config.defaultValue));
// auto-resize the pattern input on input
patternInput.addEventListener('input', automaticWidth);
// validate pattern and update explanation link on change
patternInput.addEventListener('change', function () {
explanationLink.href = 'https://kellnerd.github.io/regexper/#' + encodeURIComponent(getPatternAsRegExp(this.value) ?? this.value);
this.classList.remove('error', 'success');
this.title = '';
try {
if (getPattern(this.value) instanceof RegExp) {
this.classList.add('success');
this.title = 'Valid regular expression';
}
} catch (error) {
this.classList.add('error');
this.title = `Invalid regular expression: ${error.message}\nThe default value will be used.`;
}
});
// inject label, input, reset button and explanation link
const container = document.createElement('li');
container.insertAdjacentHTML('beforeend', `<label for="${id}" title="${config.description}">${config.label}:</label>`);
container.append(' ', patternInput, ' ', resetButton, ' ', explanationLink);
dom('credit-patterns').appendChild(container);
// persist the input and calls the setter for the initial value (persisted value or the default)
persistInput(patternInput, config.defaultValue).then(setInput);
return patternInput;
}
/**
* Sets the input to the given value (optional), resizes it and triggers persister and validation.
* @param {HTMLInputElement} input
* @param {string} [value]
*/
function setInput(input, value) {
if (value) input.value = value;
automaticWidth.call(input);
input.dispatchEvent(new Event('change'));
}
/**
* Sets the textarea to the given value and adjusts the height.
* @param {HTMLTextAreaElement} textarea
* @param {string} value
*/
function setTextarea(textarea, value) {
textarea.value = value;
automaticHeight.call(textarea);
}