// ==UserScript==
// @name MB: Bulk copy-paste work codes
// @version 2023.12.07
// @description Copy work identifiers from various online repertoires and paste them into MB works with ease.
// @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_bulk_copy_work_codes.user.js
// @updateURL https://raw.github.com/ROpdebee/mb-userscripts/main/mb_bulk_copy_work_codes.user.js
// @match https://iswcnet.cisac.org/*
// @match https://online.gema.de/werke/search.faces*
// @match *://musicbrainz.org/*/edit
// @match *://*.musicbrainz.org/*/edit
// @match *://musicbrainz.org/release/*/edit-relationships
// @match *://*.musicbrainz.org/release/*/edit-relationships
// @match *://musicbrainz.org/*/create
// @match *://*.musicbrainz.org/*/create
// @require https://raw.github.com/ROpdebee/mb-userscripts/main/lib/work_identifiers.js?v=2023.12.07
// @run-at document-end
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_info
// ==/UserScript==
//////////////
// Utils
//////////////
// Taken from https://stackoverflow.com/a/44622467
class DefaultDict {
constructor(defaultInit) {
return new Proxy({}, {
get: (target, name) => name in target ?
target[name] :
(target[name] = typeof defaultInit === 'function' ?
new defaultInit().valueOf() :
defaultInit)
});
}
};
Array.prototype.groupBy = function(keyFn, valTransform) {
return Object.assign({}, this.reduce(
(acc, el) => {
acc[keyFn(el)].push((valTransform || (e => e))(el));
return acc;
},
new DefaultDict(Array)));
};
Array.prototype.intersect = function(other) {
return this.filter(el => other.includes(el));
};
Array.prototype.difference = function(other) {
return this.filter(el => !other.includes(el));
};
function findDivByText(parent, text) {
let divs = [...parent.querySelectorAll("div")];
return divs.filter(n => n.innerText === text);
}
//////////////
// MB
//////////////
const LOG_STYLES = {
'error': 'background-color: FireBrick; color: white; font-weight: bold;',
'warning': 'background-color: Gold;',
'info': 'background-color: GainsBoro;',
'success': 'background-color: LightGreen;',
};
function normaliseID(id, agencyKey) {
let formatResult = MBWorkIdentifiers.validateCode(id, agencyKey)
if (!formatResult.isValid) {
return id.replace(/(?:^0+|[\.\s-])/g, '');
}
return formatResult.formattedCode;
}
/**
* Convert translated agency IDs into English variant.
* TODO: There needs to be a better way to do this without hardcoding...
*/
function normaliseAgencyId(agencyId) {
return agencyId
.replace(/-ID$/, ' ID') // German and Dutch use e.g. 'ASCAP-ID'.
.replace(/^ID (.+)/, '$1 ID') // French and Italian use 'ID ASCAP'
.replace(/-tunniste$/, ' ID'); // Finnish
// TODO: "PRS tune code" is heavily translated in French etc. We need a more
// robust way of converting those.
}
function getSelectedID(select) {
return normaliseAgencyId(select.options[select.selectedIndex].text.trim());
}
function setRowKey(select, agencyKey) {
let idx = [...select.options].findIndex(opt => normaliseAgencyId(opt.text.trim()) === agencyKey);
if (idx < 0) {
throw new Error('Unknown agency key');
}
select.selectedIndex = idx;
}
function computeAgencyConflicts(mbCodes, extCodes) {
// Conflicting IDs when MB already has IDs for this key and the external codes
// don't match the IDs that MB already has
let commonKeys = Object.keys(mbCodes).intersect(Object.keys(extCodes));
return commonKeys
.filter(k => mbCodes[k].length) // No MB codes => no conflicts
.filter(k => extCodes[k].map(c => normaliseID(c, k)).difference(mbCodes[k].map(c => normaliseID(c, k))).length)
.map(k => [k, mbCodes[k], extCodes[k]]);
}
function extractCodes(data) {
let agencyCodes = data['agencyCodes'];
return Object.entries(agencyCodes).reduce(
(acc, [key, codes]) => {
acc[MBWorkIdentifiers.agencyNameToID(key)] = codes;
return acc;
}, {});
}
function deduplicateCodes(codes, key) {
const seen = new Set();
// We can't just map with normaliseID and convert to Set and back to array,
// since we must retain the original code value before cleanup, as the user
// might opt not to auto-format the codes.
const results = [];
for (const code of codes) {
if (seen.has(normaliseID(code, key))) continue;
seen.add(normaliseID(code, key));
results.push(code);
}
return results;
}
function fillInput(inp, val) {
inp.value = val;
inp.style.backgroundColor = 'yellow';
}
// Style and concept by loujine
// https://github.com/loujine/musicbrainz-scripts/blob/master/mbz-loujine-common.js (MIT license).
const mainUIHTML = `
ROpdebee's work code tools
Log
Validation errors
`
class BaseWorkForm {
constructor(theForm) {
this.form = theForm;
this.form.ROpdebee_Work_Codes_Found = true; // Prevent processing it again
this.addToolsUI();
this.activateButtons();
this.checkExistingCodes();
}
activateButtons() {
// The button to paste work codes
this.form.querySelector('button#ROpdebee_MB_Paste_Work')
.addEventListener('click', (evt) => {
evt.preventDefault();
// Since we use an arrow function, current `this` is the instance itself.
// We need to bind it properly to give a method reference though.
this.resetLog();
this.readData(this.checkAndFill.bind(this));
});
this.form.querySelector('button#ROpdebee_MB_Format_Codes')
.addEventListener('click', (evt) => {
evt.preventDefault();
this.resetLog();
let formattedAny = this.formatExistingCodes();
if (formattedAny) {
this.fillEditNote([], 'existing', true);
}
});
let autoFormatCheckbox = this.form.querySelector('input#ROpdebee_MB_Autoformat_Codes');
autoFormatCheckbox
.addEventListener('change', (evt) => {
evt.preventDefault();
if (evt.currentTarget.checked) {
localStorage.setItem(evt.currentTarget.id, 'delete me to disable');
} else {
localStorage.removeItem(evt.currentTarget.id);
}
});
autoFormatCheckbox.checked = !!localStorage.getItem('ROpdebee_MB_Autoformat_Codes');
}
checkExistingCodes() {
this.resetValidationLog();
this.existingCodeInputs.forEach(({ select, input }) => {
let agencyKey = getSelectedID(select);
let agencyCode = input.value;
let checkResult = MBWorkIdentifiers.validateCode(agencyCode, agencyKey);
if (!checkResult.isValid) {
input.style.backgroundColor = 'red';
this.addValidationError(agencyKey, agencyCode, checkResult.message);
} else if (checkResult.wasChanged) {
input.style.backgroundColor = 'orange';
this.addFormatWarning(agencyKey, agencyCode);
}
})
}
formatExistingCodes() {
let formattedAny = false;
this.existingCodeInputs.forEach(({ select, input }) => {
let agencyKey = getSelectedID(select);
let agencyCode = input.value;
let checkResult = MBWorkIdentifiers.validateCode(agencyCode, agencyKey);
if (checkResult.isValid && checkResult.wasChanged) {
fillInput(input, checkResult.formattedCode);
this.log('info', `Changed ${agencyKey} ${agencyCode} to ${checkResult.formattedCode}`);
formattedAny = true;
}
});
return formattedAny;
}
resetLog() {
let logDiv = this.form.querySelector('div#ROpdebee_MB_Paste_Work_Log');
logDiv.style.display = 'none';
[...logDiv.children]
.slice(1) // Skip the heading
.forEach(el => el.remove());
}
resetValidationLog() {
let logDiv = this.form.querySelector('div#ROpdebee_MB_Code_Validation_Errors');
logDiv.style.display = 'none';
[...logDiv.children]
.slice(1) // Skip the heading
.forEach(el => el.remove());
}
get autoformatCodes() {
return this.form.querySelector('input#ROpdebee_MB_Autoformat_Codes').checked;
}
get existingCodeInputs() {
return [...this.form
.querySelectorAll('table#work-attributes tr')]
.map(row => ({
'select': row.querySelector('td > select'),
'input': row.querySelector('td > input'),
}))
.filter(({ select, input }) => select !== null && select.selectedIndex !== 0 && input !== null && input.value);
}
get existingCodes() {
return this.existingCodeInputs
.groupBy(
({ select }) => getSelectedID(select),
({ input: { value }}) => value);
}
get existingISWCs() {
return [...this.form
.querySelectorAll('input[name^="edit-work.iswcs."]')]
.map(({ value }) => value)
.filter(({ length }) => length);
}
findEmptyRow(parentSelector, inputName) {
let parent = this.form.querySelector(parentSelector);
let rows = [...parent.querySelectorAll('input[name*="' + inputName + '"]')];
let emptyRows = rows.filter(({value}) => !value.length);
if (emptyRows.length) {
return emptyRows[0];
}
// Need to add a new row
let newRowBtn = parent.querySelector('button.add-item');
newRowBtn.click();
return this.findEmptyRow(parentSelector, inputName);
}
checkAndFill(rawData) {
let data = this.parseData(rawData);
console.log(data);
let externalCodes = extractCodes(data);
let externalISWCs = data['iswcs'];
let mbCodes = this.existingCodes;
let mbISWCs = this.existingISWCs;
// Sanity check
let dupeAgencies = Object.entries(externalCodes)
.filter(([key, codes]) => codes.length > 1)
.map(([key, codes]) => key);
if (dupeAgencies.length) {
const lis = dupeAgencies.reduce((acc, agency) => {
return acc + `
${agency}: ${externalCodes[agency].join(', ')}
`
}, '');
this.log('warning', `
Found duplicate work codes in input.
Please double-check whether all of these codes belong to this work.
`
}, '');
this.log('warning', `
Encountered unsupported agencies.
If you encounter these a lot, please consider filing a ticket.
${lis}
`);
}
if (this.autoformatCodes) {
this.formatExistingCodes();
}
this.checkExistingCodes();
this.maybeFillTitle(title);
this.fillEditNote(unknownAgencyCodes, source, this.autoformatCodes);
}
maybeFillTitle(title) {
let titleInp = this.form.querySelector('input[name="edit-work.name"]');
if (titleInp.value) {
// Not filling if already filled.
return;
}
// Completely lowercase the title before adding it. ISWCNet has completely
// uppercased titles. Depending on the guesscase_keepuppercase cookie,
// guess case might not properly transform it.
fillInput(titleInp, title.toLowerCase());
titleInp.closest('div.row')
.querySelector('button.guesscase-title')
.click();
}
fillISWC(iswc) {
let row = this.findEmptyRow('div.form-row-text-list', 'edit-work.iswcs.');
fillInput(row, iswc);
}
fillAgencyCodes(agencyKey, agencyCodes) {
agencyCodes.forEach(code => {
let input = this.findEmptyRow('table#work-attributes', 'edit-work.attributes.');
// Will throw when the agency isn't know the MB, handled by caller.
setRowKey(input.closest('tr').querySelector('td > select'), agencyKey);
fillInput(input, code);
});
}
fillEditNote(unknownAgencies, source, wasFormatted) {
let noteContent = unknownAgencies.reduce((acc, [agencyKey, agencyCodes]) => {
return acc + agencyKey + ': ' + agencyCodes.join(', ') + '\n';
}, unknownAgencies.length ? 'Unsupported agencies:\n' : '');
if (noteContent) {
this.fillEditNoteTop(noteContent);
}
let fmtAppliedStr = wasFormatted ? MBWorkIdentifiers.VERSION : 'not applied';
let editNoteBottom = `${GM_info.script.name} v${GM_info.script.version} (source: ${source}, formatting: ${fmtAppliedStr})`;
this.fillEditNoteBottom(editNoteBottom);
}
fillEditNoteTop(content) {
let note = this.form.querySelector('textarea[name="edit-work.edit_note"]');
let noteParts = note.value.split('–\n');
let top = noteParts[0];
if (!top) {
top = content + '\n';
} else {
top += content;
}
noteParts[0] = top;
note.value = noteParts.join('–\n');
}
fillEditNoteBottom(content) {
let note = this.form.querySelector('textarea[name="edit-work.edit_note"]');
let noteParts = note.value.split('–\n');
let bottom = noteParts[1];
if (!bottom) {
bottom = content;
} else {
bottom += '\n' + content;
}
noteParts[0] = noteParts[0] ? noteParts[0] : '\n';
noteParts[1] = bottom;
note.value = noteParts.join('–\n');
}
readData(cb) {
let data = GM_getValue('workCodeData');
if (!data) {
this.log('error', 'No data found. Did you copy anything?');
return;
}
cb(data);
// Reset again to prevent filling the same data on another edit page.
GM_deleteValue('workCodeData');
}
parseData(raw) {
try {
return JSON.parse(raw);
} catch(e) {
this.log('error', 'Invalid data');
console.log(raw);
console.log(e);
return {};
}
}
promptForConfirmation(conflicts) {
const lis = conflicts.reduce((acc, [agency, mbCodes, extCodes]) => {
return acc + `
${agency}: [${mbCodes.join(', ')}] vs [${extCodes.join(', ')}]
`
}, '');
let msg = `Uh-oh. MB already has the following codes with conflicting data:
Are you sure you want to fill these?
Note: New codes will be added and will not replace the existing ones.