by",
// so they rarely collide — but when they do, the same tier rule decides.
let forced = null;
const tiers = [['artist', a], ['label', l], ['place', pl]].filter(([, x]) => x);
if (instrumentMatch) forced = 'artist';
else if (tiers.length === 1) forced = tiers[0][0];
else if (tiers.length > 1) {
tiers.sort((x, y) => x[1].tier - y[1].tier);
forced = tiers[0][1].tier < tiers[1][1].tier ? tiers[0][0] : null; // a clear winner, or genuinely ambiguous
}
return { artistMatch, labelMatch, placeMatch, instrumentMatch, forced };
}
// the entity type for an ORDINARY credit row: forced by its role's own
// classification when unambiguous, otherwise the same release-artist-
// credit auto-detect crKind rows already use.
function rowEntityType(row) {
const rt = (row.role || '').toLowerCase().trim();
const cls = roleCache.get(rt);
return (cls && cls.forced) || txpAutoEntityType(row.entity, 'artist');
}
function attachResolution(row) {
row.key = row.li + ':' + row.pi + ':' + row.si;
if (!row.matched) { row.roleMatch = null; row.entityMatch = null; row.entityType = null; row.entityForced = null; row.creating = null; return row; }
row.entityMatch = entityOverride.has(row.key) ? entityOverride.get(row.key) : (entityCache.get((row.entity || '').toLowerCase().trim()) || null);
row.creating = pendingCreates.get((row.entity || '').toLowerCase().trim()) || null; // #544 background create in flight
// #525 follow-up (majkinetor): "Can we just replace role with the
// other one once the entity is selected? That way it should never
// happen." A pre-resolution guess (forced-or-auto-detected) can only
// ever be a best effort for a genuinely ambiguous role — once an
// entity is ACTUALLY resolved (auto or manually, including via the
// Artist/Label toggle), its own real entityType is ground truth and
// decides which relationship type applies, no more guessing. A
// FORCED type (role only exists on one side) still wins outright —
// there's no valid alternate relationship type to fall back to even
// if a pasted MBID happened to be the other kind.
if (row.crKind) {
row.entityForced = TXP_CR_LABEL_ONLY.has(row.crKind) ? 'label' : null;
row.entityType = row.entityForced || (row.entityMatch ? row.entityMatch.entityType : txpCrEntityType(row.crKind, row.entity));
row.roleMatch = row.entityMatch ? txpCopyrightLinkType(row.crKind, row.entityType) : null;
return row;
}
const rt = (row.role || '').toLowerCase().trim();
const cls = roleCache.get(rt) || null;
row.roleClass = cls;
row.entityForced = (cls && cls.forced) || null;
row.entityType = row.entityForced || (row.entityMatch ? row.entityMatch.entityType : rowEntityType(row));
row.roleMatch = cls ? (row.entityType === 'label' ? cls.labelMatch
: row.entityType === 'place' ? cls.placeMatch
: (cls.instrumentMatch || cls.artistMatch)) : null;
return row;
}
function txpCopyrightLinkType(kind, entityType) {
const roles = linkTypesForPair(entityType, 'release');
return roles.find(r => r.name.toLowerCase() === TXP_CR_TYPE_NAME[kind]) || null;
}
const dotClass = r => !r.matched ? 'gt-tp-dot-red' : (r.roleMatch && r.entityMatch) ? 'gt-tp-dot-green' : 'gt-tp-dot-amber';
const statusText = r => {
if (!r.matched) return 'no match';
if (appliedKeys.has(r.key)) return '✓ applied';
if (r.roleMatch && r.entityMatch) return 'ready';
return [!r.roleMatch && 'role?', !r.entityMatch && 'entity?'].filter(Boolean).join(' ');
};
txpEl = el('div', 'gt-cons-ov');
const panel = el('div', 'gt-cons gt-tp'), hdr = el('div', 'gt-cons-hdr');
hdr.appendChild(el('span', 'gt-cons-title', 'Text parser'));
const annoBtn = el('button', 'gt-tp-anno', 'Load annotation'); annoBtn.type = 'button';
hdr.appendChild(annoBtn);
// #522 follow-up (majkinetor): "Add resizable columns and maximize
// button" — same maximize/restore toggle Match Works already has.
const maxBtn = el('button', 'gt-cons-x', '⛶'); maxBtn.type = 'button'; maxBtn.title = 'Maximize / restore';
maxBtn.onclick = () => {
if (panel.classList.toggle('gt-tp-max')) { panel._savedW = panel.style.width; panel._savedH = panel.style.height; panel.style.width = ''; panel.style.height = ''; maxBtn.textContent = '❐'; maxBtn.title = 'Restore'; }
else { panel.style.width = panel._savedW || ''; panel.style.height = panel._savedH || ''; maxBtn.textContent = '⛶'; maxBtn.title = 'Maximize'; }
saveState(); // #544 follow-up: maximizing changed the window and saved nothing
};
hdr.appendChild(maxBtn);
const xb = el('button', 'gt-cons-x', '✕'); xb.type = 'button'; xb.onclick = closeTextParser; hdr.appendChild(xb);
const ctrl = el('div', 'gt-tp-ctrl');
const patIn = el('input', 'gt-tp-pat'); patIn.type = 'text'; patIn.value = pattern;
const patClr = el('button', 'gt-tp-clr', '✕'); patClr.type = 'button'; patClr.title = 'Clear pattern';
const presets = el('span', 'gt-tp-presets');
TXP_PRESETS.forEach(p => { const c = el('button', 'gt-tp-chip', p); c.type = 'button'; c.onclick = () => { patIn.value = p; onPatternChange(); }; presets.appendChild(c); });
// #525 (majkinetor): "lets move the Resolve all to the top, to match the
// standard, here is the example in GT's work matcher" then "Name it also
// Match with the icon" — mirrors Match Works' own toolbar exactly: its
// "⚡ Match" button lives at the right of the TOP bar, not the footer
// (renderWorkMatch's `tb`), styled the same bold-purple "primary" way.
const resolveBtn = el('button', 'gt-tp-resolve', '⚡ Match'); resolveBtn.type = 'button';
resolveBtn.title = 'Resolve every role and entity that can auto-resolve';
// #539: scope picker. Release is still the default — the recording scope is
// opt-in and inert until a track selector is typed.
const scopeWrap = el('span', 'gt-tp-scope');
const scopeSel = el('select', 'gt-tp-scope-sel');
[['release', 'Release'], ['recording', 'Recordings']].forEach(([v, t]) => { const o = el('option', '', t); o.value = v; scopeSel.appendChild(o); });
scopeSel.value = scope.kind;
const tracksIn = el('input', 'gt-tp-tracks'); tracksIn.type = 'text'; tracksIn.value = scope.spec;
tracksIn.placeholder = 'tracks: 1,3,5-7 · 2:4 · all';
tracksIn.title = 'Which recordings the credits go on: a track number as shown (3, A1), a range (5-7), '
+ 'a medium-qualified number or range (2:4, 2:4-6), a whole medium (2:*), or all. '
+ 'Leave empty to use the tracks ticked in the editor.';
const tracksInfo = el('span', 'gt-tp-tracks-info', '');
// #597: per-row track detection. Separate from Scope on purpose — Scope
// says where rows that name NO tracks go, this says whether a row may name
// its own at all, and the two are useful in every combination.
// ⚠ its own class, NOT a second .gt-tp-scope-sel — Playwright's page.* helpers
// are strict, so a duplicate class makes verify-539's selectOption throw.
const detSel = el('select', 'gt-tp-det-sel');
[['auto', 'Tracks: auto'], ['off', 'Tracks: off']].forEach(([v, t]) => { const o = el('option', '', t); o.value = v; detSel.appendChild(o); });
detSel.value = scope.tracks;
detSel.title = 'auto: a credit that names its own tracks — "Lead vocals (tracks 2,6,9)", "Djembe (track 4)" — goes on those recordings, '
+ 'whatever Scope says; everything else follows Scope. A clause only counts when every number in it is a real track on this release, '
+ 'so "(2003)" is left alone.\noff: the pre-#597 behaviour — Scope decides for every row.';
scopeWrap.append(el('span', 'gt-tp-scope-lbl', 'Scope'), scopeSel, tracksIn, tracksInfo, detSel);
// What did that selector actually match? Shown before anything is applied,
// because "1-3" meaning something other than you thought is a silent way to
// credit the wrong recordings.
const refreshScopeUi = () => {
const rec = scope.kind === 'recording';
tracksIn.style.display = rec ? '' : 'none';
tracksInfo.style.display = rec ? '' : 'none';
if (!rec) return;
const targets = txpScopeTargets();
const ticked = !scope.spec.trim();
// #539 follow-up: listing all 23 numbers made the toolbar wide enough to
// push ⚡ Match onto a second row. Show a handful, keep the rest in the
// tooltip — the count is the part that matters at a glance.
const nums = targets.map(t => t.num);
const shown = nums.length > 6 ? `${nums.slice(0, 6).join(', ')} +${nums.length - 6}` : nums.join(', ');
tracksInfo.textContent = targets.length
? `→ ${targets.length} track${targets.length > 1 ? 's' : ''}${ticked ? ' (ticked)' : ''}: ${shown}`
: (ticked ? '→ no tracks ticked' : '→ nothing matched');
tracksInfo.style.color = targets.length ? 'var(--mbu-ok)' : 'var(--mbu-warn)';
tracksInfo.title = targets.map(t => `${t.num} ${t.title}`).join('\n');
};
// The recordings this run will credit: an explicit selector wins, otherwise
// the editor's own ticked tracks (the same idiom as GT's other tools).
function txpScopeTargets() {
const rows = txpTrackRows();
if (scope.spec.trim()) return txpMatchTracks(scope.spec, rows);
return rows.filter(r => { const cb = r.tr.querySelector('input.recording'); return cb && cb.checked; });
}
scopeSel.onchange = () => { scope.kind = scopeSel.value; roleCache.clear(); refreshScopeUi(); render(); saveState(); };
tracksIn.oninput = () => { scope.spec = tracksIn.value; refreshScopeUi(); saveState(); };
detSel.onchange = () => { scope.tracks = detSel.value; render(); saveState(); }; // #597
// #539 follow-up (majkinetor): "We again have new row, this time for out of
// the box patterns … Move scope to the bottom, opposite of Apply." So the
// top bar is back to what it was — pattern · presets · ⚡ Match — and the
// scope control lives in the footer instead, at the opposite end from
// Apply. It belongs there anyway: it is part of deciding what Apply does,
// not part of writing the pattern.
// #544 (majkinetor): "Freeze matched should be next to the pattern input in
// the header (like Apollo)". Icon-only, per the standing toolbar rule — the
// top bar has twice been one control too wide (#539), and the tooltip
// carries the meaning.
const freezeBtn = el('button', 'gt-tp-freeze', '🔒'); freezeBtn.type = 'button';
freezeBtn.title = 'Freeze matched — lock this pattern onto every line that still uses the default pattern and already matches it, then try another pattern on what is left';
ctrl.append(patIn, patClr, freezeBtn, presets, resolveBtn);
const src = el('div', 'gt-tp-src');
const srcTgl = el('button', 'gt-tp-srctgl', '▾ Paste credit text'); srcTgl.type = 'button';
const ta = el('textarea', 'gt-tp-ta');
// #522 follow-up (majkinetor, live): "move copyright help text into the
// textbox hint when its empty" — folded into the placeholder instead of
// a permanent line taking up space even once you're pasting real text.
ta.placeholder = 'Paste credit text here, one credit per line…\n\nA ©/(C)/copyright or ℗/(P)/phonographic copyright line ("© 2020 Some Label", "℗ & © 2020 Some Label") is recognized automatically alongside your pattern. Each entity resolves against artists or labels depending on its role — a label-only role like "distributed by" is searched as a label; an ambiguous one auto-detects against this release\'s own credited artists, with a toggle to override.';
src.append(srcTgl, ta);
const body = el('div', 'gt-cons-body gt-tp-body');
const tbl = el('table', 'gt-cons-tbl gt-tp-tbl');
// #522 follow-up: "Add resizable columns" — table-layout:fixed + a
// so each column's width is just one 's inline style,
// adjustable by dragging a handle on its header without touching every
// cell in the column.
// #522 follow-up (majkinetor, live): "memorize as you do it constantly"
// — resized column widths are remembered (a global GM value, not a
// per-release one — this is a layout preference, not release data).
const TXP_COLS_KEY = 'gt-tp-colwidths';
// #597 added the "tracks" column. A saved 8-entry array no longer matches
// the 9 cols, so the length check below falls back to these defaults —
// widths reset once on upgrade, which is the right way for that to go.
const defaultColWidths = ['24px', '120px', '230px', '110px', '170px', '110px', '170px', '90px', '80px'];
let savedColWidths = null;
try { savedColWidths = JSON.parse(GM_getValue(TXP_COLS_KEY, '')) || null; } catch (e) {}
const colgroup = el('colgroup');
(savedColWidths && savedColWidths.length === defaultColWidths.length ? savedColWidths : defaultColWidths).forEach(w => {
const c = document.createElement('col'); c.style.width = w; colgroup.appendChild(c);
});
const saveColWidths = () => { try { GM_setValue(TXP_COLS_KEY, JSON.stringify([...colgroup.children].map(c => c.style.width))); } catch (e) {} };
const thead = el('thead');
thead.innerHTML = ' | pattern | raw line | role | entity | → role | → entity | tracks | |
';
[...thead.querySelectorAll('th')].forEach((th, i) => {
if (i === 0) return; // the status-dot column stays fixed
const handle = el('span', 'gt-tp-colresize');
th.appendChild(handle);
let startX = 0, startW = 0;
const onMove = e => { colgroup.children[i].style.width = Math.max(40, startW + (e.clientX - startX)) + 'px'; };
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveColWidths(); };
handle.addEventListener('mousedown', e => {
e.preventDefault(); startX = e.clientX; startW = colgroup.children[i].getBoundingClientRect().width;
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
const tbody = el('tbody');
tbl.append(colgroup, thead, tbody);
body.appendChild(tbl);
const foot = el('div', 'gt-cons-foot');
const cnt = el('span', 'gt-tp-cnt');
// #550: no ↗ any more — nothing opens, the annotation edit is submitted here.
const applyClearBtn = el('button', 'gt-cons-btn', 'Apply & clear annotation'); applyClearBtn.type = 'button';
applyClearBtn.title = 'Apply the resolved rows, then clear this release\'s annotation — submitted for you, with its own edit note';
applyClearBtn.style.display = 'none';
const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button';
// #544 (majkinetor): "Add [freeze pattern] option for matching rows the same
// as Apollo" — Apollo's tc-tpp-freeze. Stamps the current pattern into every
// still-default line that matches it right now, so trying a different
// pattern afterwards leaves those alone and only re-parses the rest.
// It lives in the footer rather than the top bar because that bar has twice
// been one control too wide (#539).
foot.append(scopeWrap, cnt, applyClearBtn, applyBtn); // #539: scope at the far left, Apply at the far right
const syncTextareaFromLines = () => { ta.value = lines.map(l => l.raw).join('\n'); };
// #522 follow-up (majkinetor, live): "Lets have an option to remove a
// row. It is also removed from the input text." Removing a LINE shifts
// every later line's index down by one — appliedKeys AND entityOverride
// are keyed by position (li:pi:si), so both are renumbered here rather
// than left stale (an appliedKeys entry could mislabel an unrelated row
// as already-applied; a leftover override could silently reassign a
// pick that was meant for a different, now-shifted row).
const renumberKey = (k, delLi) => { const [li, pi, si] = k.split(':').map(Number); return (li > delLi ? li - 1 : li) + ':' + pi + ':' + si; };
function deleteLine(delLi) {
lines.splice(delLi, 1);
const renumbered = new Set();
appliedKeys.forEach(k => { const li = +k.split(':')[0]; if (li !== delLi) renumbered.add(renumberKey(k, delLi)); });
appliedKeys.clear(); renumbered.forEach(k => appliedKeys.add(k));
const ovRenumbered = new Map();
entityOverride.forEach((v, k) => { const li = +k.split(':')[0]; if (li !== delLi) ovRenumbered.set(renumberKey(k, delLi), v); });
entityOverride.clear(); ovRenumbered.forEach((v, k) => entityOverride.set(k, v));
syncTextareaFromLines(); render(); saveState();
}
function render() {
// #522 follow-up (majkinetor, live): "after editing pattern in a row,
// focus is lost immediately" — render() rebuilds the whole tbody, which
// destroys and recreates whichever input was focused on every
// keystroke. Both the pattern-override AND the (new) inline raw-text
// edit inputs carry a shared data-fkey; remember it (+ caret position)
// and restore focus to whichever element gets the same fkey again
// after rebuilding.
const active = document.activeElement;
const activeFkey = (active && active.dataset && active.dataset.fkey) || null;
const activeSel = activeFkey && typeof active.selectionStart === 'number' ? [active.selectionStart, active.selectionEnd] : null;
const rows = parsedRows().map(attachResolution);
const spanByLine = new Map();
rows.forEach(r => spanByLine.set(r.li, (spanByLine.get(r.li) || 0) + 1));
tbody.textContent = '';
rows.forEach(r => {
const stCls = dotClass(r).replace('gt-tp-dot-', 'gt-tp-st-'); // amber/red row tint, green stays plain
const tr = el('tr', 'gt-tp-row' + (r.matched ? '' : ' gt-tp-nomatch') + (stCls === 'gt-tp-st-green' ? '' : ' ' + stCls));
const dotTd = el('td'); dotTd.appendChild(el('span', 'gt-tp-dot ' + dotClass(r)));
tr.appendChild(dotTd);
if (r.pi === 0 && r.si === 0) {
const span = spanByLine.get(r.li);
const ovTd = el('td'); if (span > 1) ovTd.rowSpan = span;
const ov = el('input', 'gt-tp-ov'); ov.type = 'text'; ov.placeholder = pattern; ov.value = lines[r.li].override || '';
ov.title = 'Override the pattern for just this line'; ov.dataset.fkey = 'ov:' + r.li;
ov.addEventListener('input', () => { lines[r.li].override = ov.value; render(); saveState(); });
ovTd.appendChild(ov); tr.appendChild(ovTd);
// #522 follow-up: "add option to edit raw column in the table, and
// it should also change original row in the text" — an editable
// input synced back into the textarea, plus a remove button.
const rawTd = el('td'); if (span > 1) rawTd.rowSpan = span;
const rawWrap = el('span', 'gt-tp-rawwrap');
const rawIn = el('input', 'gt-tp-raw'); rawIn.type = 'text'; rawIn.value = r.raw; rawIn.title = r.raw;
rawIn.dataset.fkey = 'raw:' + r.li;
rawIn.addEventListener('input', () => {
lines[r.li].raw = rawIn.value;
[...entityOverride.keys()].filter(k => +k.split(':')[0] === r.li).forEach(k => entityOverride.delete(k));
syncTextareaFromLines(); render(); saveState();
});
const delBtn = el('button', 'gt-tp-rowdel', '✕'); delBtn.type = 'button'; delBtn.title = 'Remove this line';
delBtn.onclick = () => deleteLine(r.li);
rawWrap.append(rawIn, delBtn);
rawTd.appendChild(rawWrap);
tr.appendChild(rawTd);
}
tr.appendChild(el('td', 'gt-tp-c', r.role || ''));
// #525 (majkinetor): "since column Entity is used as 'credited as',
// lets add right click to it, which will set it to choosen entity
// (if there is one)... used to fast clear any suffixes that came
// from raw text" — a raw credit like "Felix Vincent*" (Discogs-style
// footnote marker) resolves fine to the real artist, but the raw
// text is still what gets sent as the credited-as override on Apply
// (applyResolvedRows: r.entity !== r.entityMatch.name → credit).
// Right-click swaps the raw entity substring for the resolved
// entity's own canonical name, clearing that stray override.
const entRawTd = el('td', 'gt-tp-c', r.entity || '');
if (r.matched && r.entity) {
entRawTd.title = r.entityMatch
? `Right-click: replace with the resolved name "${r.entityMatch.name}" (clears suffixes/typos in the raw text)`
: 'Resolve the entity first, then right-click here to clean up the raw text';
entRawTd.addEventListener('contextmenu', e => {
e.preventDefault();
if (!r.entityMatch) { toast('Resolve this row’s entity first'); return; }
const canonical = r.entityMatch.name || '';
if (!canonical || canonical === r.entity) { toast('Already matches the resolved name'); return; }
const rawBefore = lines[r.li].raw;
if (!rawBefore.includes(r.entity)) { toast('Could not find that exact text in the raw line'); return; }
lines[r.li].raw = rawBefore.replace(r.entity, canonical);
// seed both caches with the cleaned-up text so the row (and any
// other row sharing it) reads as already-resolved on the very
// next render, instead of regressing to "search" just because
// the raw text — and therefore the parsed entity text the
// caches are keyed by — changed underneath it.
entityOverride.set(r.key, r.entityMatch);
entityCache.set(canonical.toLowerCase().trim(), r.entityMatch);
syncTextareaFromLines(); render(); saveState();
toast(`Set to “${canonical}”`);
});
}
tr.appendChild(entRawTd);
// #522 follow-up (majkinetor, live): "Tidy up artist / role column —
// remove circles, short helper text..." then, after trying a plain
// non-clickable resolved cell: "After a role is selected, I am not
// able to change it as its not a link any more... Clicking an
// element should always bring back search and for artist right
// click should open it." Unresolved still shows the plain word
// "search"; resolved is clickable again to reopen the picker — for
// the entity, a real so right-click still offers "open in new
// tab" natively too, but a plain left-click is intercepted to reopen
// the picker instead of navigating, and OUR contextmenu handler
// opens it directly (skipping the browser's own context menu).
const roleTd = el('td', 'gt-tp-c');
if (r.matched && !r.crKind) {
if (r.roleMatch) { const rs = el('button', 'gt-tp-search gt-tp-resolved', r.roleMatch.name); rs.type = 'button'; rs.title = 'Click to change'; rs.onclick = () => txpPickRole(r); roleTd.appendChild(rs); }
else { const rb = el('button', 'gt-tp-search', 'search'); rb.type = 'button'; rb.onclick = () => txpPickRole(r); roleTd.appendChild(rb); }
} else if (r.matched) {
roleTd.appendChild(el('span', r.roleMatch ? 'gt-tp-resolved' : 'gt-tp-status', r.roleMatch ? r.roleMatch.name : '(resolves once holder is picked)'));
}
tr.appendChild(roleTd);
const entTd = el('td', 'gt-tp-c');
if (r.matched) {
if (r.entityMatch) {
const a = el('a', 'gt-tp-resolved', r.entityMatch.name || '');
// #544: the disambiguation is set apart from the name here too, so
// the resolved cell and the search results read the same way.
if (r.entityMatch.disambiguation) a.appendChild(el('span', 'gt-tp-disamb', ` (${r.entityMatch.disambiguation})`));
a.href = '/' + r.entityMatch.entityType + '/' + r.entityMatch.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Click to change · right-click to open';
a.addEventListener('click', e => { e.preventDefault(); txpPickEntity(r, a); });
a.addEventListener('contextmenu', e => { e.preventDefault(); window.open(a.href, '_blank', 'noopener'); });
entTd.appendChild(a);
} else if (r.creating) {
// #544: a background create is in flight for this text — say so
// instead of offering "search", and let the row resolve itself when
// the create tab commits.
// the kind lives in the tooltip, not the label: this column is narrow
// and "creating artist “…”…" wrapped to two lines in every row.
// #544 follow-up: it is a BUTTON — clicking cancels, so a create that
// failed in a way nothing here can see never strands the row.
const c = el('button', 'gt-tp-creating', `creating “${trunc(r.creating.name, 16)}”… ✕`);
c.type = 'button';
c.title = `A background tab is creating this ${r.creating.kind}; the row fills in on its own once it commits.\nClick to stop waiting and get the search back.`;
c.onclick = () => { const p = r.creating; if (p && p.cancel) p.cancel('cancelled'); else { pendingCreates.delete((r.entity || '').toLowerCase().trim()); render(); } };
entTd.appendChild(c);
} else { const ab = el('button', 'gt-tp-search', 'search'); ab.type = 'button'; ab.onclick = () => txpPickEntity(r, ab); entTd.appendChild(ab); }
}
tr.appendChild(entTd);
/* #597: where this credit is actually going. Not decoration — the role
matcher tolerates a "(tracks 2,6,9)" suffix and resolves it exactly
as the clean text would, so before this column a run that ignored the
clause and credited the whole release looked completely normal. */
const trkTd = el('td', 'gt-tp-trk');
if (r.trackNote) {
// the line carried more than one track list — see txpDetectTracks
const w = el('span', 'gt-tp-trk-warn', '⚠ split line');
w.title = `${r.trackNote}\n\nOne line can only carry one track list, so this credit is going wherever Scope points instead. Split it into one role per line.`;
trkTd.appendChild(w);
} else if (r.trackTargets && r.trackTargets.length) {
const nums = r.trackTargets.map(t => t.num);
const s = el('span', 'gt-tp-trk-on', nums.length > 4 ? `${nums.slice(0, 4).join(', ')} +${nums.length - 4}` : nums.join(', '));
s.title = `From “${r.trackSpec}” in the credit text:\n` + r.trackTargets.map(t => `${t.num} ${t.title}`).join('\n');
trkTd.appendChild(s);
} else if (scope.kind === 'recording') {
trkTd.appendChild(el('span', 'gt-tp-trk-dim', 'scope'));
}
tr.appendChild(trkTd);
const stTd = el('td', 'gt-tp-status' + (appliedKeys.has(r.key) ? ' gt-tp-applied' : ''), statusText(r));
tr.appendChild(stTd);
tbody.appendChild(tr);
});
// #522 follow-up (majkinetor, live): "There is a tight column on the
// left with text when there is no text input" — the empty-state
// had no colspan, so table-layout:fixed confined it to the FIRST
// (narrowest) column, wrapping the message into a tall sliver.
if (!rows.length) { const td = el('td', 'gt-pop-note', 'Paste credit text above, or load the annotation.'); td.colSpan = 9; tbody.appendChild(el('tr')).appendChild(td); }
const matched = rows.filter(r => r.matched).length;
const ready = rows.filter(r => r.matched && r.roleMatch && r.entityMatch && !appliedKeys.has(r.key)).length;
const applied = rows.filter(r => appliedKeys.has(r.key)).length;
cnt.textContent = rows.length ? `${matched}/${rows.length} matched` + (ready ? ` · ${ready} ready` : '') + (applied ? ` · ${applied} applied` : '') : '';
applyBtn.disabled = !ready;
applyClearBtn.disabled = !ready;
applyClearBtn.style.display = loadedFromAnnotation ? '' : 'none';
if (activeFkey) {
const restored = tbody.querySelector(`[data-fkey="${activeFkey}"]`);
if (restored) { restored.focus(); if (activeSel) { try { restored.setSelectionRange(activeSel[0], activeSel[1]); } catch (e) {} } }
}
}
async function resolveAll() {
resolveBtn.disabled = true; resolveBtn.textContent = 'Resolving…';
try {
const rows = parsedRows().filter(r => r.matched);
const crRows = rows.filter(r => r.crKind);
const creditRows = rows.filter(r => !r.crKind);
// #525 (majkinetor): "Published by is also label... map roles to
// entities" — role classification is entirely synchronous (no
// network) and must happen BEFORE the unified entity-resolution
// list below, since an ordinary row's entity type (rowEntityType)
// depends on its role's classification.
// #539: the role vocabulary is per entity PAIR — "recorded at" exists
// for both, but plenty of release roles have no recording equivalent
// and vice versa. Follow the chosen scope, or a recording run would be
// matched against release-only link types.
const scopeTarget = scope.kind === 'recording' ? 'recording' : 'release';
const artistCands = linkTypesForPair('artist', scopeTarget);
const labelCands = linkTypesForPair('label', scopeTarget);
const placeCands = linkTypesForPair('place', scopeTarget); // #525: "recorded at", "mixed at", "mastered at", …
const instrumentLt = artistCands.find(c => c.name === 'instrument');
const instrumentCands = instrumentLt ? txpInstrumentCandidates() : [];
const roleTexts = [...new Set(creditRows.map(r => (r.role || '').toLowerCase().trim()).filter(Boolean))];
roleTexts.forEach(rt => { if (!roleCache.has(rt)) roleCache.set(rt, classifyRoleText(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands)); });
// role matches are synchronous — show them immediately, before the
// network-bound entity loop below even starts.
render(); saveState();
// #524/#525: ONE unified (text, entityType) resolution list —
// copyright/legal-notice holders and ordinary credit entities share
// the exact same artist-or-label lookup, entityType decided per
// text (forced where MB only allows one, otherwise auto-detected
// against the release's own credited artists). Dedup by entity text
// (the common case — the same text rarely needs both an artist AND
// a label resolution in the same block; see txpCrEntityType/
// rowEntityType's own docs for that edge).
const entityTexts = new Map(); // text -> entityType ('artist'|'label')
crRows.forEach(r => { if (r.entity) entityTexts.set(r.entity, txpCrEntityType(r.crKind, r.entity)); });
creditRows.forEach(r => { if (r.entity) entityTexts.set(r.entity, rowEntityType(r)); });
const entities = [...entityTexts.entries()];
// #522 follow-up (majkinetor): "while it is resolving, lets show a
// message ... resolving 4/N" then "make it show in the button
// itself" — total counts only the names that actually need a real
// lookup (already-cached ones are instant skips, not "work" the
// button should promise is coming).
resolveTotal = entities.filter(([t]) => !entityCache.has(t.toLowerCase().trim())).length;
resolveDone = 0;
const bumpProgress = () => { resolveDone++; resolveBtn.textContent = `Resolving ${resolveDone}/${resolveTotal}`; };
resolveBtn.textContent = `Resolving ${resolveDone}/${resolveTotal}`;
for (const [text, entityType] of entities) {
const key = text.toLowerCase().trim();
if (entityCache.has(key)) continue;
try {
let hit = entityType === 'artist' ? await txpResolveByExactAlias(text)
: entityType === 'place' ? null // no alias resolver for places; exact-name search below covers it
: await txpResolveLabelByExactAlias(text);
if (!hit) {
const cands = entityType === 'artist' ? await txpSearchArtist(text, 5)
: entityType === 'place' ? await txpSearchPlace(text, 5)
: await txpSearchLabel(text, 5);
const exact = cands.filter(c => (c.name || '').toLowerCase().trim() === key);
if (exact.length === 1) { const full = await txpFetchEntity(exact[0].gid || exact[0].id, entityType); if (full) hit = { entity: full }; }
}
entityCache.set(key, hit ? hit.entity : null);
} catch (e) { entityCache.set(key, null); }
// #524 follow-up (majkinetor): "'Resolve all' should update the
// table as it goes... to avoid the looks of 'nothing happens'" —
// each entity is a real network round-trip, so re-render after
// every one instead of batching the whole thing behind one
// render() at the very end.
bumpProgress(); render(); saveState();
}
} finally {
resolveBtn.disabled = false; resolveBtn.textContent = '⚡ Match'; render(); saveState();
}
}
function txpPickRole(r) {
const scopeTarget = scope.kind === 'recording' ? 'recording' : 'release'; // #539
const artistCands = linkTypesForPair('artist', scopeTarget);
const labelCands = linkTypesForPair('label', scopeTarget);
const instrumentLt = artistCands.find(c => c.name === 'instrument');
// offer specific instruments alongside the direct link types (tagged
// so onPick can tell them apart and build the right {id, attributeId}
// shape — see classifyRoleText's own auto-resolution for the same
// split). #525: label-pair roles are offered too (tagged "(label)"
// in their description) — a manual pick from either list is the
// ultimate override for a role classifyRoleText couldn't force.
const instrumentOpts = instrumentLt ? txpInstrumentCandidates().map(c => ({ id: c.id, name: c.name, desc: 'instrument', _instrument: true, _ltId: instrumentLt.id, _entityType: 'artist' })) : [];
// #666: vocal types the same way — the "vocal" link type plus that attribute
const vocalLt = artistCands.find(c => c.name === 'vocal');
const vocalOpts = vocalLt ? txpVocalCandidates().map(c => ({ id: c.id, name: c.name, desc: 'vocal', _instrument: true, _ltId: vocalLt.id, _entityType: 'artist' })) : [];
const roles = [
...artistCands.map(c => ({ ...c, _entityType: 'artist' })),
...labelCands.map(c => ({ ...c, _entityType: 'label', desc: (c.desc ? c.desc + ' ' : '') + '(label)' })),
// #525: place roles too — "recorded at", "mixed at", "mastered at" …
...linkTypesForPair('place', scopeTarget).map(c => ({ ...c, _entityType: 'place', desc: (c.desc ? c.desc + ' ' : '') + '(place)' })),
...vocalOpts,
...instrumentOpts,
];
// #522 follow-up (majkinetor, live): "when clicking search on a role,
// it is not already filled in in search box in popup like Artist" —
// pre-fill/pre-filter with the parsed role text, same as the entity
// popover already does with r.entity.
openRolePicker(roles, `Pick a role for “${trunc(r.entity || r.raw, 40)}”`, picked => {
const resolved = picked._instrument ? { id: picked._ltId, name: picked.name, attributeId: picked.id } : { id: picked.id, name: picked.name };
roleCache.set((r.role || '').toLowerCase().trim(), {
artistMatch: picked._entityType === 'artist' && !picked._instrument ? resolved : null,
labelMatch: picked._entityType === 'label' ? resolved : null,
placeMatch: picked._entityType === 'place' ? resolved : null,
instrumentMatch: picked._instrument ? resolved : null,
forced: picked._entityType,
});
render(); saveState();
}, r.role || '');
}
function txpPickEntity(r, anchor) {
closePopover();
// #524/#525 follow-up (majkinetor): "regarding artist vs label, maybe
// we can have 2 tabs in search" — auto-detect (r.entityType, already
// computed by attachResolution for BOTH copyright holders and
// ordinary credit rows) picks the starting tab; a row whose entity
// type is FORCED (r.entityForced — e.g. crKind distributed/marketed/
// licensee, or an ordinary role that only exists on one side like
// "published") can't toggle at all, since the other search would just
// produce an unresolvable pick.
const canToggle = !r.entityForced;
let searchKind = r.entityType || 'artist';
popEl = el('div', 'gt-pop gt-tp-apop');
const hdr = el('div', 'gt-pop-hdr'); popEl.appendChild(hdr);
if (canToggle) {
const tabs = el('div', 'gt-tp-tabs');
const TAB_LABEL = { artist: 'Artist', label: 'Label', place: 'Place' }; // #525: Place added
const mkTab = kind => { const b = el('button', 'gt-tp-tab', TAB_LABEL[kind]); b.type = 'button'; b.dataset.kind = kind; b.onclick = () => { searchKind = kind; renderChrome(); runSearch(); }; return b; };
tabs.append(mkTab('label'), mkTab('artist'), mkTab('place'));
popEl.appendChild(tabs);
}
// #522 follow-up (majkinetor, live): "Remove 'Create artist' from the
// bottom of artist search, add it as a + in the search, as we do in
// all other scripts" (mock: a "[+]" button inside the search box
// itself) — replaces the separate link row below the results.
const qWrap = el('div', 'gt-tp-qwrap');
const q = el('input', 'gt-tp-q'); q.type = 'text'; q.value = r.entity || '';
const createBtn = el('button', 'gt-tp-plus', '+'); createBtn.type = 'button';
// #525 (majkinetor): "We need to wait tab close after creating an artist
// to pick up the mbid (as in CH). Also, as in CH, preset type to person
// and set default sort (if artist is created, not if label is created)."
//
// Credit Hoarder gets the MBID back over a BroadcastChannel, but it also
// RUNS on /artist/create and the created entity page. Group Therapy only
// matches the relationship editor, so there is nobody on the other side to
// post back. Keeping the window handle achieves the same thing: the create
// tab is same-origin, so we can watch it land on // and adopt
// that entity — and fall back to a name search if the tab is just closed.
// #544 (majkinetor): "Implement right click on (+) to create entity in the
// background" — same creation, without the tab stealing focus, so a run of
// several unresolved names can be fired off and picked up as they land.
const startCreate = (background) => {
// #544: "it should not ignore the search string used but seed that one"
// — if the search box was edited to drop a "(Greenprint)" suffix, THAT
// is the name to create, not the raw parsed text.
const name = (q.value || '').trim() || r.entity || '';
const kind = searchKind;
const params = new URLSearchParams();
params.set(`edit-${kind}.name`, name);
if (kind === 'artist') {
params.set('edit-artist.sort_name', txpGuessSortName(name));
params.set('edit-artist.type_id', '1'); // Person — sort name above assumes it
}
// #544: "Adding entity doesn't have any edit notes. It should do the
// same as CH/Apollo" — the created entity carried no attribution at
// all. Same shape as Apollo's entityActionNote: signature, then what
// was being done and where.
params.set(`edit-${kind}.edit_note`, txpCreateNote(kind));
// ── background: GM_openInTab, and the created page posts the MBID back
if (background && typeof GM_openInTab === 'function') {
const token = Math.random().toString(36).slice(2);
// #598 (majkinetor): "When 2 background tabs are open creating artist,
// one of them doesn't close and consequently return MBID to Text
// Pattern." This used to store THE pending create — a single slot — so
// starting a second one overwrote the first. The first tab's /create
// page then read back a token that was not its own, decided the form
// in front of it belonged to somebody else, and stood down without
// ever pressing Enter: a tab left sitting on a filled-in form, and a
// row spinning on "creating…" until the ten-minute timeout.
// The store is a LIST now, so concurrent creates cannot evict one
// another; see gtPendingAdd and the two handlers at the bottom.
gtPendingAdd({ kind, token, ts: Date.now() });
// #544 follow-up (majkinetor): "When creating in the background, it
// doesn't click Enter in 2nd tab." The seeded /create page is only a
// filled-in FORM — in the foreground you see it and press Enter, but a
// background tab just sits there unsubmitted, so nothing is ever
// created and the parser waits for a post-back that cannot come.
// autoSubmitSeededCreate (below) presses it. This param is how that
// handler knows the form in front of it is the one THIS create opened
// — matching only on "a pending create of this kind exists" would also
// fire on a /artist/create the user opened by hand minutes later.
// MusicBrainz ignores parameters it does not know.
//
// ⚠ NOT named `gt_token`. `>` is a LEGACY HTML entity that decodes
// without its semicolon, so `>_token=…` came back out of any HTML
// attribute as `>_token=…` — measured: the submitted form's action
// carried `%3E_token`, and the handler's own check would then never
// match. Any name not starting with a legacy entity is fine.
params.set('x_gtcreate', token);
let ch = null, timeout = null, tab = null;
// #544: the row, not the popover, now carries the "waiting" state — so
// the popover closes at once and the next name can be started while this
// one is still in its tab. Keyed by the ROW's text (what entityCache is
// keyed by), not the possibly-edited search box, so the rows showing that
// text are the ones that light up and the ones that later resolve.
const pendKey = (r.entity || '').toLowerCase().trim();
// Nothing below may touch the popover: it is gone, and `popEl` may by now
// belong to a DIFFERENT picker the user opened in the meantime.
let watch = null;
const stop = () => { try { ch && ch.close(); } catch (e) {} clearTimeout(timeout); clearInterval(watch); pendingCreates.delete(pendKey); };
// #544 follow-up (majkinetor): "The new `creating ...` placeholder stays
// forever if there is error/tab closed. I should be able to cancel it
// like in CH." Two ways out, because neither alone is enough:
// • the row's placeholder is clickable — the reliable one, since a
// create can fail in ways nothing here can observe (a duplicate-check
// page, a validation error, the tab left open and forgotten);
// • the tab handle is watched, so simply CLOSING the tab clears it.
// GM_openInTab's handle is not guaranteed to report `closed` in every
// manager, which is exactly why it is the backstop and not the fix.
const cancel = (why) => {
if (!pendingCreates.has(pendKey)) return;
stop();
gtPendingDrop(token); // #598: drop only THIS create — another may still be in flight
render(); saveState();
toast(`Stopped waiting for “${trunc(name, 30)}” — ${why}`);
};
pendingCreates.set(pendKey, { kind, name, cancel });
closePopover(); render();
try {
ch = new BroadcastChannel(GT_CREATE_CH);
ch.onmessage = async (ev) => {
const d = ev && ev.data;
if (!d || d.token !== token || d.kind !== kind || !d.gid) return;
stop();
// #544 follow-up (majkinetor): "It doesn't close the tab after
// commit, like CH/Apollo." The created entity's page does call
// window.close() on itself, but that is a no-op for a tab opened
// by GM_openInTab unless the script grants window.close — so the
// OPENER closes it, using the handle GM_openInTab hands back.
// Exactly what Credit Hoarder does and for the same reason (#273:
// "a GM-opened tab can't always self-close via window.close()").
// Closed here, before the entity fetch: the entity exists either
// way, so a slow or failed lookup must not leave the tab open.
try { if (tab && typeof tab.close === 'function') tab.close(); } catch (e) {}
const ent = await txpFetchEntity(d.gid, kind);
// #544: resolve in BULK, exactly as a left click in the picker does —
// every row with this text flips from "creating…" to the new entity.
if (ent) { entityCache.set(pendKey, ent); render(); saveState(); toast(`Created ${kind} “${ent.name || name}”`); }
else { render(); toast(`Created the ${kind}, but could not read it back — click the row to search for it`); }
};
} catch (e) { /* no BroadcastChannel — falls through to the timeout */ }
// Give up quietly rather than spinning forever if the tab is closed
// without saving; the row falls back to "search". #544
timeout = setTimeout(() => cancel('timed out after 10 minutes'), 10 * 60 * 1000);
tab = GM_openInTab(`/${kind}/create?` + params.toString(), { active: false, insert: true, setParent: true });
// the tab closing without a post-back means the create did not happen —
// clear the placeholder instead of leaving it spinning. A short grace
// period first, because the SUCCESS path closes the tab too (from the
// message handler) and must not be reported as an abandoned create.
try { if (tab && 'onclose' in tab) tab.onclose = () => setTimeout(() => cancel('the create tab was closed'), 1500); } catch (e) {}
watch = setInterval(() => {
let closed = false;
try { closed = !!(tab && tab.closed); } catch (e) { closed = false; }
if (closed) { clearInterval(watch); watch = null; setTimeout(() => cancel('the create tab was closed'), 1500); }
}, 1500);
return;
}
const url = `/${kind}/create?` + params.toString();
// ── foreground: keep the window handle and watch where it lands.
// Here the popover STAYS open (you are looking at the tab you just
// opened), so the spinner belongs on the + button. The background path
// above closed the popover and signals on the row instead. #544
createBtn.classList.add('gt-tp-plus-wait');
createBtn.title = `Waiting for the new ${kind}…`;
const win = window.open(url, '_blank');
let done = false;
const finish = async (gid) => {
if (done) return; done = true;
clearInterval(timer);
createBtn.classList.remove('gt-tp-plus-wait');
renderChrome();
if (gid) {
const ent = await txpFetchEntity(gid, kind);
if (ent) { try { win.close(); } catch (e) {} pick(ent, false); return; }
}
q.value = name; runSearch(); // fell through — let them pick it
};
const timer = setInterval(() => {
let path = null;
try { path = win.closed ? null : win.location.pathname; } catch (e) { path = null; } // mid-navigation
if (path) {
const m = path.match(new RegExp('^/' + kind + '/(' + GID_RE.source + ')$'));
if (m) return finish(m[1]);
return;
}
if (win.closed) finish(null);
}, 600);
};
createBtn.onclick = () => startCreate(false);
createBtn.addEventListener('contextmenu', e => { e.preventDefault(); startCreate(true); }); // #544
qWrap.append(q, createBtn);
popEl.appendChild(qWrap);
const list = el('div', 'gt-tp-results'); popEl.appendChild(list);
// #522 follow-up (majkinetor, live): "if one artist has multiple
// instruments, selecting one selects all... right clicking a choice
// in search sets all, and normal clicking only that 1." A left click
// resolves ONLY this row (entityOverride, position-keyed); a right
// click resolves every row sharing this exact entity text (the
// shared entityCache, same as auto-resolve uses) — same propagation
// as before, now opt-in instead of automatic.
// #544 (majkinetor): "Let left click change all entities upon selection
// (currently it is on right click)." Swapped: the common case — one
// person credited on several lines — is now the plain click, and the
// narrow "just this row" case moved to right-click.
popEl.appendChild(el('div', 'gt-tp-hint', 'Click: every row with this same text · Right-click: this row only'));
// header/placeholder/create-title/tab-highlight all depend on
// searchKind, which the tabs above can change after the popover is
// already open — kept in one place so toggling stays in sync.
const renderChrome = () => {
const ARTICLE = { artist: 'an artist', label: 'a label', place: 'a place' };
const PLURAL = { artist: 'artists', label: 'labels', place: 'places' };
hdr.textContent = `Pick ${ARTICLE[searchKind]} for “${trunc(r.role || r.raw, 40)}”`;
q.placeholder = `search ${PLURAL[searchKind]}, or paste an MBID / URL…`;
createBtn.title = `Create ${searchKind} “${trunc(r.entity || '', 40)}” ↗ · right-click to create in the background`;
if (canToggle) [...popEl.querySelectorAll('.gt-tp-tab')].forEach(b => b.classList.toggle('gt-tp-tab-on', b.dataset.kind === searchKind));
};
renderChrome();
const pick = (entity, bulk) => {
if (bulk) entityCache.set((r.entity || '').toLowerCase().trim(), entity);
else entityOverride.set(r.key, entity);
closePopover(); render(); saveState();
};
// #544 (majkinetor): "Disambiguation in search results should have
// different font style than entity name so it isn't confused with the
// name" — "Fiona (reggae artist)" read as one name. The comment is its
// own span now: smaller, grey, italic.
const resRow = (name, disamb, typ) => {
const row = el('div', 'gt-tp-res');
row.appendChild(el('span', 'gt-tp-restype', typ));
row.appendChild(el('span', 'gt-tp-resname', name));
if (disamb) row.appendChild(el('span', 'gt-tp-disamb', ` (${disamb})`));
return row;
};
const runSearch = async () => {
const term = (q.value || '').trim(); list.textContent = ''; if (!term) return;
const gid = (term.match(GID_RE) || [])[0];
if (gid) {
// #544 (majkinetor): "pasting MBID should work imediately rather than
// showing search result". An MBID is already an exact answer — there
// is nothing to choose between — so resolve and take it. Applies to
// every row with this text, matching what a left click now does.
const ent = await txpFetchEntity(gid, searchKind);
if (ent) { pick(ent, true); return; }
list.appendChild(el('div', 'gt-pop-note', 'Nothing found with that MBID.'));
return;
}
const searchFor = { artist: txpSearchArtist, label: txpSearchLabel, place: txpSearchPlace }[searchKind] || txpSearchArtist;
const searches = [searchFor(term, 8).then(l => l.map(c => ({ ...c, _kind: searchKind })))];
const cands = (await Promise.all(searches)).flat();
if (!cands.length) { list.appendChild(el('div', 'gt-pop-note', 'No matches.')); return; }
cands.forEach(c => {
const row = resRow(c.name || '', c.comment || '', c._kind);
row.addEventListener('click', async () => { const full = await txpFetchEntity(c.gid || c.id, c._kind); if (full) pick(full, true); }); // #544: all rows
row.addEventListener('contextmenu', async e => { e.preventDefault(); const full = await txpFetchEntity(c.gid || c.id, c._kind); if (full) pick(full, false); }); // #544: this row only
list.appendChild(row);
});
};
document.body.appendChild(popEl);
// #522 sixth round (majkinetor, live, screenshot): "Artist popup is
// displaced" — it always anchored to the TABLE's own top-left corner
// plus a fixed 40px offset, regardless of which row/column was
// actually clicked. Anchor to the clicked element itself instead,
// same convention every other popover in this file already uses.
const anchorEl = anchor || txpEl.querySelector('.gt-tp-tbl');
// #522 follow-up (majkinetor, live, screenshot): "search popup can be
// offscreen" — the FIRST clamp ran before any results existed, so it
// sized against an almost-empty popover; once results/notes filled
// it back in the popover grew well past that clamp. Re-run it after
// every search (initial load AND subsequent typing), against the
// popover's actual current size.
//
// #544 (majkinetor): "Entity search popup could be position better. It
// bothers me it is always on the edge and touches scrollbar." Three
// reasons it ended up jammed against the scrollbar, all fixed here:
// • the clamp used window.innerWidth, which INCLUDES the vertical
// scrollbar — so "inside the window" still meant underneath it, and
// the right-hand tab and + button were clipped. clientWidth is the
// scrollbar-free box.
// • the 8px margin left it visually glued to the edge even when it fit.
// • when the anchor sits far right (maximized parser), aligning the
// popover's LEFT edge to it always overflows; align its RIGHT edge to
// the anchor instead, which keeps it next to what was clicked.
// Vertically it now flips ABOVE the anchor when there isn't room below,
// rather than sliding up to cover the row that opened it.
const MARGIN = 16;
const reposition = () => {
// a debounced search can still resolve after the popover itself was
// already closed (closePopover nulls the shared popEl) — nothing to
// reposition at that point.
if (!popEl) return;
const rr = popEl.getBoundingClientRect();
// re-measured every time: the anchor moves when the parser window is
// maximized or scrolled while the popover is open
const ar = anchorEl.getBoundingClientRect();
const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
const maxLeft = vw - rr.width - MARGIN;
let left = ar.left;
if (left > maxLeft) left = Math.min(maxLeft, ar.right - rr.width); // right-align to the anchor
popEl.style.left = Math.max(MARGIN, Math.min(left, maxLeft)) + 'px';
const below = ar.bottom + 4, above = ar.top - rr.height - 4;
const top = (below + rr.height + MARGIN <= vh || above < MARGIN) ? below : above;
popEl.style.top = Math.max(MARGIN, Math.min(top, vh - rr.height - MARGIN)) + 'px';
};
const run = async () => { try { await runSearch(); } finally { reposition(); } };
reposition();
let t = null;
q.addEventListener('input', () => { clearTimeout(t); t = setTimeout(run, 300); });
q.addEventListener('paste', () => setTimeout(run, 0));
setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); q.focus(); run(); }, 0);
}
// core of Apply, factored out so "Apply & clear annotation" can run the
// exact same dispatch logic before opening the annotation editor.
async function applyResolvedRows() {
const re = RE(); if (!re) { toast('Editor not ready'); return null; }
// #539: one dispatch per (credit × target). appliedKeys is keyed per
// target too, so the same parsed text can be applied to track 3 now and
// track 7 later without the second run thinking it already ran.
const fallback = scope.kind === 'recording' ? txpScopeTargets().map(t => t.rec) : [release];
// #597: a row that named its own tracks goes THERE; the Scope control is
// only the default for rows that named none. That is what lets one run
// handle a liner-note block where most credits are release-wide and a few
// are "(track 4)" — no mode switching between them.
const targetsFor = r => (r.trackTargets && r.trackTargets.length) ? r.trackTargets.map(t => t.rec) : fallback;
const allRows = parsedRows().map(attachResolution);
if (!fallback.length && !allRows.some(r => r.trackTargets && r.trackTargets.length)) {
toast(scope.spec.trim() ? 'No track matched that selection' : 'No tracks ticked — type track numbers or tick some');
return null;
}
const keyFor = (r, target) => {
const t = targetsFor(r);
return (t.length === 1 && t === fallback && scope.kind !== 'recording') ? r.key : `${r.key}|${target.gid || target.id}`;
};
const rows = allRows.filter(r => r.matched && r.roleMatch && r.entityMatch
&& targetsFor(r).length && targetsFor(r).some(t => !appliedKeys.has(keyFor(r, t))));
if (!rows.length) { toast('Nothing resolved to apply'); return null; }
let ok = 0, fail = 0, dated = 0;
let okOwn = 0; // #597: credits that went to tracks the ROW named
const ownNums = new Set();
for (const r of rows) {
const own = !!(r.trackTargets && r.trackTargets.length);
if (own) r.trackTargets.forEach(t => ownNums.add(t.num));
const credit = r.entity && r.entity !== (r.entityMatch.name || '') ? r.entity : '';
const dates = txpNoticeDatePeriod(r.year); // #574: "in 2021", not "from 2021 to present"
if (dates) dated++;
// an instrument-role match carries an attributeId (the "instrument"
// link type doesn't say WHICH instrument on its own). #666: vocal-type
// and qualifier matches carry attributeIds (one or more) instead.
const attrIds = r.roleMatch.attributeIds || (r.roleMatch.attributeId ? [r.roleMatch.attributeId] : []);
const attrs = attrIds.length ? buildAttrTree(attrIds.map(id => ({ typeID: id, text_value: '', credited_as: '' }))) : null;
for (const target of targetsFor(r)) {
const k = keyFor(r, target);
if (appliedKeys.has(k)) continue;
try {
dispatchRelationship(re, target, r.entityMatch, r.roleMatch.id, credit, attrs, dates);
appliedKeys.add(k); ok++; if (own) okOwn++;
} catch (e) { fail++; try { console.warn('[Group Therapy] text-parser apply failed:', e); } catch (_) {} }
}
}
// #574: which rows carried a notice year is the one thing that is invisible
// in the parser's own preview (it shows role/holder, never the date), so it
// goes in the log — otherwise a wrong year is only findable in MB's diff.
try {
const yrs = rows.filter(r => r.year).map(r => `${r.crKind || 'credit'} ${r.entity || ''} = in ${r.year}`);
console.debug(`[Group Therapy] Text parser: applied ${ok} rel(s), ${fail} failed; ${dated} carried a notice year (begin = end, ended)`
+ (yrs.length ? ` — ${yrs.join('; ')}` : ''));
} catch (_) {}
// #539 follow-up (majkinetor): "Make sure scope info is added to the edit
// note." Which tracks were credited is the part a reviewer cannot infer
// from the diff alone when several runs are batched into one edit, so the
// note names them (capped — a 40-track selection would bury the note).
// #597: one run can now do both — some credits to tracks they named
// themselves, the rest wherever Scope points — so the note has to say so
// rather than describing only the Scope half and quietly misreporting
// where the other credits went.
const cap = ns => (ns.length > 8 ? `${ns.slice(0, 8).join(', ')} … (+${ns.length - 8})` : ns.join(', '));
const nums = scope.kind === 'recording' ? txpScopeTargets().map(t => t.num) : [];
const okFallback = ok - okOwn;
const on = [...ownNums];
// `where` is a phrase that has to read correctly in two sentences (this
// note and txpClearAnnotationNote's), so the two single-destination cases
// keep their original wording exactly and only a genuinely MIXED run gets
// the longer "n … and n …" form.
const fallbackWhere = scope.kind === 'recording'
? ` to ${fallback.length} recording${fallback.length > 1 ? 's' : ''} (track${nums.length > 1 ? 's' : ''} ${cap(nums)})`
: ' on the release';
const ownWhere = ` to track${on.length > 1 ? 's' : ''} ${cap(on)} named in the credit text`;
const where = (okOwn && okFallback) ? ` — ${okFallback}${fallbackWhere} and ${okOwn}${ownWhere}`
: okOwn ? ownWhere
: okFallback ? fallbackWhere : '';
if (ok) markUsed(`Parsed ${ok} credit${ok > 1 ? 's' : ''} from text${where}`);
const shortWhere = okOwn && !okFallback ? ` to ${ownNums.size} track${ownNums.size > 1 ? 's' : ''}`
: scope.kind === 'recording' ? ` to ${fallback.length} recording${fallback.length > 1 ? 's' : ''}` : '';
toast(fail ? `Applied ${ok}, ${fail} failed — see console` : `✓ Applied ${ok} credit${ok > 1 ? 's' : ''}${shortWhere} — review & save`);
saveState();
// #550: `where` is handed back so the annotation edit's note can name the
// same scope as the relationship edit's, rather than recomputing it.
return { ok, fail, where };
}
// #522 follow-up (majkinetor, live): "Apply should close the window."
async function txpApply() {
const res = await applyResolvedRows();
if (res) closeTextParser();
}
// #522 follow-up (majkinetor): "after loading annotation, add another
// button - Apply and remove annotation" — applies exactly like Apply,
// then clears the release's annotation.
//
// #550 (majkinetor): "Apply and clear annotation should do clearing in the
// background. It is weird to double check empty field." It used to open the
// annotation editor in a new tab with the text pre-emptied, which left you
// reviewing a page whose only content was a field this tool had just
// blanked — a confirmation step with nothing to confirm. It is submitted
// here instead, with its own edit note.
//
// Only offered once text has actually come FROM the annotation this
// session — clearing an unrelated annotation because you happened to
// paste your own credit text would be a real, silent mistake.
async function txpApplyAndClearAnnotation() {
const res = await applyResolvedRows();
if (!res) return;
// Nothing actually dispatched — clearing the annotation now would delete
// the text with nothing to show for it. applyResolvedRows has already said
// what went wrong.
if (!res.ok) return;
const label = applyClearBtn.textContent;
applyClearBtn.disabled = true; applyBtn.disabled = true;
applyClearBtn.textContent = 'Clearing annotation…';
try {
const out = await txpClearAnnotation(release.gid,
txpClearAnnotationNote(res.ok, res.where),
'Credits moved to relationships');
closeTextParser();
toast(out.skipped ? 'Applied — the annotation was already empty' : `Applied, and the annotation was cleared (${out.was} characters)`);
} catch (e) {
// the relationships ARE applied at this point; only the clear failed.
// Leave the window open so it is obvious which half needs attention.
applyClearBtn.textContent = label;
applyClearBtn.disabled = false; applyBtn.disabled = false;
toast(`Applied, but the annotation was not cleared: ${(e && e.message) || e}`);
}
}
// #522 follow-up (majkinetor, live): "When you exit and return to text
// parser window, if you resolved anything its gone. Keep the complete
// state." — role/artist resolutions and which rows were already applied
// are now persisted too, not just the pasted text and pattern. Maps/Sets
// aren't JSON-native, so they're flattened to plain arrays here and
// rebuilt on load (see the `saved` restore below).
const saveState = () => txpSaveState(release.gid, {
// #544 follow-up (majkinetor): "When exiting and returning Text parser,
// any freezed patterns are gone. State should be kept completely." The
// per-line pattern override IS the freeze — freezeBtn stamps `pattern`
// into lines[i].override — and it was the one piece of row state the save
// never carried, so reopening rebuilt every line with override:''.
// Positional is correct here and nowhere else: `text` is saved in the very
// same call, so index i means the same line on the way back in. (Compare
// onTextChange, which must DROP position-keyed state, precisely because
// there the text has changed underneath it.)
overrides: lines.map(l => (l && l.override) || ''),
// "State should be kept completely" — the window's own two states reset
// on every reopen as well: whether it was maximized, and whether the paste
// box was rolled up. Both are one flag each and both are visible.
maximized: panel.classList.contains('gt-tp-max'),
srcOpen: ta.style.display !== 'none',
// #544 follow-up (majkinetor): 'Button "load and remove annotation" is lost
// when exiting / returning.' "Apply & clear annotation" only shows once the
// text CAME FROM the annotation, and that flag was a plain local — so
// reopening always hid the button even though the annotation text it acts on
// was restored right beside it.
loadedFromAnnotation,
text: ta.value, pattern,
scopeKind: scope.kind, scopeSpec: scope.spec, scopeTracks: scope.tracks,
roleCache: [...roleCache.entries()],
entityCache: [...entityCache.entries()],
entityOverride: [...entityOverride.entries()],
appliedKeys: [...appliedKeys],
});
const onTextChange = () => {
const newLines = ta.value.split('\n').map((raw, i) => ({ raw, override: (lines[i] && lines[i].override) || '' }));
// ANY line whose raw text actually changed — not just a line being
// added/removed — can point its li:pi:si position at completely
// different content (caught live: replacing a whole single-line
// paste with a DIFFERENT single-line paste keeps the same li:0:0
// position). Two position-keyed things go stale this way: the
// per-row entity override (a wrong pick could silently reattach to
// unrelated new text) AND appliedKeys (a brand-new, never-applied
// line could show "✓ applied" and get silently skipped by Apply,
// caught live: a fresh paste inherited "applied" from an EARLIER,
// unrelated line that happened to land at the same li:0:0). Drop
// both for every touched line; untouched lines elsewhere keep theirs.
const max = Math.max(newLines.length, lines.length);
for (let li = 0; li < max; li++) {
if (!lines[li] || !newLines[li] || lines[li].raw !== newLines[li].raw) {
[...entityOverride.keys()].filter(k => +k.split(':')[0] === li).forEach(k => entityOverride.delete(k));
[...appliedKeys].filter(k => +k.split(':')[0] === li).forEach(k => appliedKeys.delete(k));
}
}
lines = newLines;
render(); saveState();
};
const onPatternChange = () => { pattern = patIn.value; _txpPattern = pattern; render(); saveState(); };
patIn.addEventListener('input', onPatternChange);
patClr.onclick = () => { patIn.value = ''; onPatternChange(); };
ta.addEventListener('input', onTextChange);
ta.addEventListener('input', () => { loadedFromAnnotation = false; });
// #528 (majkinetor): "Once on load" — the compound-copyright-line split
// runs once right after a paste lands (not on every keystroke, so it
// doesn't fight a mid-edit user), same setTimeout(0)-after-paste idiom
// already used elsewhere in this file.
ta.addEventListener('paste', () => setTimeout(() => {
const split = txpSplitCompoundCopyrightLines(ta.value);
if (split !== ta.value) { ta.value = split; onTextChange(); }
}, 0));
const setSrcOpen = (open) => { ta.style.display = open ? '' : 'none'; srcTgl.textContent = (open ? '▾' : '▸') + ' Paste credit text'; };
srcTgl.onclick = () => { setSrcOpen(ta.style.display === 'none'); saveState(); };
resolveBtn.onclick = resolveAll;
// #544 freeze: per LINE, since the override is a line's property — a line
// that expanded into several rows is frozen once.
freezeBtn.onclick = () => {
const seen = new Set();
let n = 0;
parsedRows().forEach(r => {
if (seen.has(r.li)) return;
const ln = lines[r.li];
if (!ln || (ln.override || '').trim()) return; // already pinned to something
if (!r.matched) return; // nothing matched, nothing to freeze
seen.add(r.li);
ln.override = pattern;
n++;
});
render(); saveState();
toast(n ? `Froze ${n} matched line${n !== 1 ? 's' : ''} to “${pattern}”` : 'No still-default lines match this pattern');
};
applyBtn.onclick = txpApply;
applyClearBtn.onclick = txpApplyAndClearAnnotation;
// #522 follow-up (majkinetor, live): "Load annotation should load it into
// text field without any confirmation, there is no need for additional
// interface" — straight into the paste box, no preview/confirm step.
annoBtn.onclick = async () => {
annoBtn.disabled = true; annoBtn.textContent = 'Loading…';
try {
const text = await txpFetchAnnotation(release.gid);
if (!text) toast('No annotation found for this release');
// onTextChange() saves, but it also CLEARS loadedFromAnnotation via the
// textarea's own input handler — so the flag is set and saved after it. #544
else { ta.value = txpSplitCompoundCopyrightLines(text); onTextChange(); loadedFromAnnotation = true; render(); saveState(); }
} catch (e) { toast('Failed to load annotation: ' + (e && e.message || e)); }
annoBtn.disabled = false; annoBtn.textContent = 'Load annotation';
};
panel.append(hdr, ctrl, src, body, foot);
txpEl.appendChild(panel); document.body.appendChild(txpEl);
txpEl.addEventListener('mousedown', e => { if (e.target === txpEl) closeTextParser(); });
document.addEventListener('keydown', onTxpKey, true);
if (saved) {
if (saved.text) {
ta.value = saved.text;
const ov = saved.overrides || []; // #544 follow-up: frozen patterns
lines = ta.value.split('\n').map((raw, i) => ({ raw, override: ov[i] || '' }));
}
(saved.roleCache || []).forEach(([k, v]) => roleCache.set(k, v));
(saved.entityCache || []).forEach(([k, v]) => entityCache.set(k, v));
(saved.entityOverride || []).forEach(([k, v]) => entityOverride.set(k, v));
(saved.appliedKeys || []).forEach(k => appliedKeys.add(k));
loadedFromAnnotation = !!saved.loadedFromAnnotation; // #544: brings "Apply & clear annotation" back with its text
if (saved.maximized) maxBtn.onclick(); // same path the button takes, so the saved size handling matches
if (saved.srcOpen === false) setSrcOpen(false);
}
// #544 follow-up: whatever was last on screen is what comes back, whichever
// control changed it — see closeTextParser.
_txpSaveOnClose = saveState;
render();
refreshScopeUi(); // #539: show what the saved scope selects, before anything is touched
setTimeout(() => { try { patIn.focus(); patIn.select(); } catch (e) {} }, 30);
}
// ── work credits: right-click a work's checkbox → copy its writer/composer credits to ticked works ─
// (Per maintainer: we don't copy the work itself, we add the source work's own relationships to the
// selected works.) Read the work's artist rels (writer/composer/lyricist/…) via fiber, dedup, dispatch.
function workCreditRels(work) {
const out = [], seen = new Set();
document.querySelectorAll('.relationship-item').forEach(item => {
const rel = relFromNode(item); if (!rel || !looksRel(rel)) return;
const w0 = sameEntity(rel.entity0, work), w1 = sameEntity(rel.entity1, work);
if (!w0 && !w1) return;
const other = w0 ? rel.entity1 : rel.entity0;
if (!other || ['recording', 'url', 'work'].includes(other.entityType)) return; // work credits (writer/composer/lyricist/publisher/…) — skip performance (recording), based-on (work), url
const k = rel.linkTypeID + '|' + other.gid; if (seen.has(k)) return; seen.add(k);
const credit = w0 ? rel.entity1_credit : rel.entity0_credit;
out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, removed: rel._status === 3 });
});
return out;
}
async function removeWorkRels(work, srcRels) {
const want = new Set(srcRels.map(s => s.linkTypeID + '|' + (s.other && s.other.gid)));
for (let guard = 0; guard < 300; guard++) {
let btn = null;
for (const item of document.querySelectorAll('.relationship-item')) {
const rel = relFromNode(item); if (!rel || !looksRel(rel) || rel._status === 3) continue;
const w0 = sameEntity(rel.entity0, work), w1 = sameEntity(rel.entity1, work);
if (!w0 && !w1) continue;
const other = w0 ? rel.entity1 : rel.entity0;
if (other && !['recording', 'url', 'work'].includes(other.entityType) && want.has(rel.linkTypeID + '|' + other.gid)) { btn = item.querySelector(REMOVE_SEL); break; }
}
if (!btn) break;
try { btn.click(); } catch (e) {}
await new Promise(r => setTimeout(r, 70));
}
}
/* ── #470 replace a role ───────────────────────────────────────────────────
"I'm editing a jazz release that is 100% instrumental. All the works have
'writer' instead of 'composer'. It'd be awesome if I could just mark all
writers on highlighted works and change them from 'writer' to 'composer'."
MB has no "change the type of this relationship" bulk action, so this is a
remove + re-add of the same pair under a different link type, driven
through the same editor dispatch everything else here uses. Scope mirrors
the ×-menu's: this ROLE everywhere, or this ROLE for this one target —
narrowed to the ticked recordings/works when there is a selection.
Attributes are deliberately NOT carried over: they belong to a specific
link type (a "drums (drum set)" attribute is meaningless on "composer"),
and MB would reject or silently drop them. Rels that HAVE attributes are
therefore flagged in the confirm step rather than quietly mangled — which
is the "it complicates on roles that have attributes" majkinetor called
out, handled by being explicit instead of clever. */
// the entity ends of a rel, as {src, other, credit}: `src` is the work or
// recording the credit hangs off, `other` the artist/label/… on the far end.
// credit always belongs to the far end, so it's read from whichever side that is.
function relEnds(rel) {
if (!rel) return null;
const isContainer = e => e && (e.entityType === 'work' || e.entityType === 'recording');
const a = rel.entity0, b = rel.entity1;
if (isContainer(a) && !isContainer(b)) return { src: a, other: b, credit: rel.entity1_credit || '' };
if (isContainer(b) && !isContainer(a)) return { src: b, other: a, credit: rel.entity0_credit || '' };
return null;
}
const relHasAttributes = rel => { try { return !!rel.attributes && [...W.MB.tree.iterate(rel.attributes)].length > 0; } catch (e) { return false; } };
// every link type valid for the SAME entity pair as `rel` — that's what MB
// will accept as a replacement. Deprecated types and the current type are
// dropped; the rest are sorted by name so the picker reads alphabetically.
function replacementRoles(rel) {
const lts = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {};
const cur = lts[rel.linkTypeID];
if (!cur) return [];
// MB keys link_type by BOTH numeric id and gid, so Object.values() yields
// every type twice — dedupe by id or the picker lists everything double.
const seen = new Set();
return Object.values(lts)
.filter(t => t && !t.deprecated && t.id !== cur.id && t.type0 === cur.type0 && t.type1 === cur.type1)
.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; })
.map(t => ({ id: t.id, name: t.name, desc: (t.description || '').replace(/<[^>]*>/g, '').trim() }))
.sort((p, q) => p.name.localeCompare(q.name));
}
// #522: the same filtering as replacementRoles(), but for a pair that has NO
// existing relationship yet (the text parser is CREATING new credits, not
// replacing one) — driven from two raw entityType strings instead. MB's
// link_type table keys type0/type1 in a canonical (sorted) order.
// #544: attribution for an entity created from the parser. Mirrors Apollo's
// entityActionNote — the script signature, then what was being done and on
// which release — so the new artist/label is not an unexplained edit.
// Module scope on purpose: the __groupTherapy export at the bottom
// references it, and that export is wrapped in try/catch — defined inside
// openTextParser it threw ReferenceError there and silently took the WHOLE
// test hook down with it.
/* #550 (majkinetor): "Apply and clear annotation should do clearing in the
background. It is weird to double check empty field. … It should also add
appropriate edit note."
MusicBrainz's annotation editor is a plain server-rendered form with no CSRF
token — same shape as /add-alias, which Falcon already posts to directly —
so it can be fetched, emptied and submitted without ever showing the user a
page whose only content is a field we just blanked. An annotation edit is an
auto-edit, so it applies immediately.
Two things this deliberately does NOT do:
• it never submits when the annotation is already empty (there is no edit
to make, and MB would reject it anyway) — that case is reported, not faked;
• it re-reads the annotation afterwards and only reports success if it is
actually gone. A 200 from a form POST proves the request left the browser
and nothing more. */
const TXP_ANNO_URL = gid => `/release/${gid}/edit_annotation`;
function txpClearAnnotationNote(applied, scopeLabel) {
return `${editNoteSig()}
Cleared the annotation: its credits were entered as ${applied} relationship${applied === 1 ? '' : 's'}${scopeLabel || ''} instead, so the text was redundant.`;
}
async function txpFetchAnnotationForm(gid) {
const r = await fetch(TXP_ANNO_URL(gid), { credentials: 'same-origin' });
if (!r.ok) throw new Error(`could not open the annotation editor (HTTP ${r.status})`);
const html = await r.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const ta = doc.querySelector('textarea[name="edit-annotation.text"]');
// A logged-out session is redirected to the login page, which is a perfectly
// valid 200 with no annotation form on it — say which of the two happened.
if (!ta) throw new Error(/\/login/.test(r.url) ? 'you are not logged in to MusicBrainz' : 'MusicBrainz did not return the annotation form');
return { doc, ta, text: ta.value || '' };
}
async function txpClearAnnotation(gid, note, changelog) {
const { doc, text } = await txpFetchAnnotationForm(gid);
if (!text.trim()) return { skipped: true };
const body = new URLSearchParams();
// carry every hidden field the form declares rather than naming the ones we
// know about — if MusicBrainz adds one, dropping it silently would be a
// rejected or malformed edit with no clue why.
doc.querySelectorAll('form input[type="hidden"][name]').forEach(i => body.append(i.name, i.value));
body.set('edit-annotation.text', '');
body.set('edit-annotation.changelog', changelog || '');
body.set('edit-annotation.edit_note', note || '');
const r = await fetch(TXP_ANNO_URL(gid), {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
});
if (!r.ok) throw new Error(`MusicBrainz rejected the annotation edit (HTTP ${r.status})`);
// read it back — the POST returning 200 is not evidence the annotation is gone
const after = await txpFetchAnnotationForm(gid);
if (after.text.trim()) throw new Error('MusicBrainz accepted the request but the annotation is still there');
return { cleared: true, was: text.trim().length };
}
function txpCreateNote(kind) {
const relUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, '');
return `${editNoteSig()}
Created this ${kind} while adding credits parsed from text to ${relUrl}`;
}
function linkTypesForPair(typeA, typeB) {
const lts = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {};
const [t0, t1] = [typeA, typeB].sort();
const seen = new Set();
return Object.values(lts)
.filter(t => t && !t.deprecated && t.type0 === t0 && t.type1 === t1)
.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; })
.map(t => ({ id: t.id, name: t.name, desc: (t.description || '').replace(/<[^>]*>/g, '').trim() }))
.sort((p, q) => p.name.localeCompare(q.name));
}
// #522 follow-up (majkinetor, live): "Instrument roles (flute, saxophone
// etc.) are not resolved automatically" — MB has no standalone "Guitar" /
// "Flute" link type; a specific instrument is an ATTRIBUTE (id 14's own
// subtree, root_id === 14, live-verified) on the generic "instrument"
// performer relationship. Role text that doesn't match any direct link
// type is also tried against this vocabulary — a hit becomes the
// "instrument" link type PLUS that attribute, not a link type on its own.
const INSTRUMENT_ATTR_ROOT = 14;
// #666: vocal types ("lead vocals", "background vocals", "soprano vocals" …)
// are the same shape — the "vocal" link type plus an attribute from root 3
// (live-verified on the sandbox: 4 lead, 12 background, 13 choir, 461 other …).
const VOCAL_ATTR_ROOT = 3;
function txpAttrCandidates(root) {
const lat = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type) || {};
const seen = new Set();
return Object.values(lat)
.filter(a => a && a.root_id === root && a.id !== root)
.filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; })
.map(a => ({ id: a.id, name: a.name }))
.sort((p, q) => p.name.localeCompare(q.name));
}
const txpInstrumentCandidates = () => txpAttrCandidates(INSTRUMENT_ATTR_ROOT);
const txpVocalCandidates = () => txpAttrCandidates(VOCAL_ATTR_ROOT);
// #666: liner-note wordings MB's vocal vocabulary doesn't use. Applied to the
// whole (lower-cased, flag-stripped) role text before matching.
const TXP_VOCAL_SYNONYMS = {
'backing vocals': 'background vocals', 'backing vocal': 'background vocals', 'backing vocalist': 'background vocals',
'backup vocals': 'background vocals', 'backup vocal': 'background vocals', 'background vocal': 'background vocals',
'background vocalist': 'background vocals', 'backing': 'background vocals',
'lead vocal': 'lead vocals', 'lead vocalist': 'lead vocals', 'lead singer': 'lead vocals',
'choir': 'choir vocals', 'spoken word': 'spoken vocals',
};
// #666: qualifier words that are ATTRIBUTES on many link types ("Guest
// Vocals", "Co-Producer", "Executive Producer"). Names, not ids, are the key —
// the id is looked up from MB's own table at runtime.
const TXP_FLAG_RE = /^(guest|additional|solo|co|executive|assistant|associate)(?:\s*-\s*|\s+)(.+)$/;
function txpFlagAttrId(name) {
const lat = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type) || {};
const hit = Object.values(lat).find(a => a && a.id === a.root_id && a.name === name);
return hit ? hit.id : null;
}
// does link type `ltId` accept attribute `attrId`? (its `attributes` map is keyed by attribute id)
function txpLtAllowsAttr(ltId, attrId) {
const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type && W.MB.linkedEntities.link_type[ltId];
return !!(lt && lt.attributes && (lt.attributes[attrId] || lt.attributes[String(attrId)]));
}
const ROLE_HIST_KEY = 'gt-role-history';
const roleHistory = () => { try { return JSON.parse(GM_getValue(ROLE_HIST_KEY, '[]')) || []; } catch (e) { return []; } };
function rememberRole(id, name) {
try {
const h = roleHistory().filter(x => x.id !== id);
h.unshift({ id, name });
GM_setValue(ROLE_HIST_KEY, JSON.stringify(h.slice(0, 8)));
} catch (e) {}
}
// searchable role picker. Recently-used roles float to the top (majkinetor:
// "keep last ones in combo history") since a cleanup pass usually applies the
// same replacement over and over. Takes the role LIST directly (not a `rel`)
// so #522's text parser can feed it `linkTypesForPair(...)` roles for a pair
// that has no existing relationship yet.
function openRolePicker(roles, title, onPick, initialQuery) {
if (!roles.length) { toast('No relationship types are valid for this pair'); return; }
const ov = el('div', 'gt-cons-ov'), panel = el('div', 'gt-cons gt-role-pick');
const hdr = el('div', 'gt-cons-hdr');
// #544 (majkinetor): "Role close button should be on standard position."
// The title span carried no class, so it missed the `flex:1` that pushes
// the ✕ to the right in every other dialog here — the button sat glued to
// the end of the title text. Same classes as the rest now.
hdr.appendChild(el('span', 'gt-cons-title', title));
const close = el('button', 'gt-cons-x', '✕'); close.type = 'button'; close.title = 'Cancel'; hdr.appendChild(close);
panel.appendChild(hdr);
const search = el('input', 'gt-role-search');
search.type = 'text'; search.placeholder = 'Type to filter roles…';
if (initialQuery) search.value = initialQuery;
panel.appendChild(search);
const list = el('div', 'gt-role-list');
panel.appendChild(list);
const done = () => { try { ov.remove(); } catch (e) {} };
close.addEventListener('click', done);
ov.addEventListener('mousedown', e => { if (e.target === ov) done(); });
let rows = [];
// #544 (majkinetor): "Keyboard up/down cant be used when selecting role."
// Enter used to fire rows[0] blindly, so the only reachable choice was
// whatever sorted first. An explicit cursor makes the list navigable and
// makes Enter mean "the one I can see highlighted".
let active = 0;
const paintActive = () => {
rows.forEach((row, i) => row.classList.toggle('gt-role-active', i === active));
const row = rows[active];
if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' });
};
const moveActive = delta => {
if (!rows.length) return;
active = (active + delta + rows.length) % rows.length; // wraps, so Up from the top lands on the last
paintActive();
};
function render() {
const q = search.value.trim().toLowerCase();
const hist = roleHistory();
const rank = r => { const i = hist.findIndex(h => h.id === r.id); return i < 0 ? 999 : i; };
const shown = roles.filter(r => !q || r.name.toLowerCase().includes(q))
.sort((p, o) => (rank(p) - rank(o)) || p.name.localeCompare(o.name));
list.textContent = '';
rows = shown.slice(0, 200).map(r => {
const row = el('div', 'gt-role-row');
const nm = el('span', 'gt-role-name', r.name);
if (rank(r) < 999) nm.appendChild(el('span', 'gt-role-recent', ' recent'));
row.appendChild(nm);
if (r.desc) row.appendChild(el('span', 'gt-role-desc', r.desc)); // #544: no 110-char truncation — the CSS clamps to two lines
row.addEventListener('click', () => { rememberRole(r.id, r.name); done(); onPick(r); });
row.addEventListener('mousemove', () => { const i = rows.indexOf(row); if (i >= 0 && i !== active) { active = i; paintActive(); } }); // #544: keep mouse and keyboard on the same row
list.appendChild(row);
return row;
});
if (!shown.length) list.appendChild(el('div', 'gt-note', 'No role matches that filter'));
if (active >= rows.length) active = 0; // #544: the filter just changed the list under us
paintActive();
}
search.addEventListener('input', render);
search.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.preventDefault(); done(); return; }
// #544: Up/Down move the cursor; PageUp/PageDown and Home/End jump, since
// the list runs to 200 roles and paging through it one at a time is not
// navigation. Enter takes whatever is highlighted.
if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); return; }
if (e.key === 'PageDown') { e.preventDefault(); moveActive(10); return; }
if (e.key === 'PageUp') { e.preventDefault(); moveActive(-10); return; }
if (e.key === 'Home') { e.preventDefault(); active = 0; paintActive(); return; }
if (e.key === 'End') { e.preventDefault(); active = Math.max(0, rows.length - 1); paintActive(); return; }
if (e.key === 'Enter' && rows.length) { e.preventDefault(); (rows[active] || rows[0]).click(); }
});
render();
ov.appendChild(panel);
document.body.appendChild(ov);
// #522 sixth round (majkinetor, live): "Make role text in the search
// box selected" — pre-filled text is selected so typing immediately
// overwrites it instead of appending.
setTimeout(() => { try { search.focus(); search.select(); } catch (e) {} }, 30);
}
// remove + re-add each matching rel under `newLt`. Re-add FIRST, then remove:
// if a dispatch throws we bail with the original still intact rather than
// having deleted a credit we then failed to recreate.
function replaceRole(items, newLt, describe) {
const re = RE(); if (!re) { toast('MusicBrainz relationship editor not ready'); return 0; }
let n = 0, skipped = 0, lostAttrs = 0;
for (const it of items) {
const rel = relFromNode(it);
const ends = relEnds(rel);
if (!rel || !ends || !ends.src || ends.src.id == null) { skipped++; continue; }
if (relHasAttributes(rel)) lostAttrs++;
try {
dispatchRelationship(re, ends.src, ends.other, newLt.id, ends.credit, null, rel);
} catch (e) { skipped++; try { console.warn('[Group Therapy] replace-role dispatch failed:', e); } catch (_) {} continue; }
const rm = it.querySelector(REMOVE_SEL);
if (rm) { try { rm.click(); } catch (e) {} }
n++;
}
if (n) markUsed(describe(n));
const bits = [`Replaced ${n} credit${n > 1 ? 's' : ''} with “${newLt.name}”`];
if (lostAttrs) bits.push(`${lostAttrs} had attributes that could not carry over`);
if (skipped) bits.push(`${skipped} skipped (see console)`);
toast(bits.join(' — ') + ' — review & save');
return n;
}
// the two menu entries offered on a rel's pencil: this role everywhere, and
// this role only where the far end is this specific entity.
function replaceRoleMenuItems(item) {
const rel = relFromNode(item);
if (!rel || !relEnds(rel)) return [];
const btn = item.querySelector(REMOVE_SEL);
if (!btn) return [];
const roleLabel = pickRoleLabel(item.closest('tr')) || ltName(rel.linkTypeID);
const tgt = targetLabel(item);
const sel = selectionKeys();
const scoped = list => (sel.recs.size || sel.works.size) ? list.filter(i => itemInSelection(i, sel)) : list;
const byRole = scoped(collect(btn, 'role'));
const byRoleTgt = scoped(collect(btn, 'role-and-target'));
const scopeNote = (sel.recs.size || sel.works.size)
? `scoped to ${[sel.recs.size && `${sel.recs.size} recording${sel.recs.size > 1 ? 's' : ''}`, sel.works.size && `${sel.works.size} work${sel.works.size > 1 ? 's' : ''}`].filter(Boolean).join(' + ')} selected`
: null;
const t = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; };
const mk = (label, list, what) => ({
label, sub: String(list.length),
run: () => {
if (!list.length) { toast('Nothing matches that scope'); return; }
openRolePicker(replacementRoles(rel), `Replace with which role? (${list.length} credit${list.length > 1 ? 's' : ''})`, newLt =>
replaceRole(list, newLt, n => `Replaced ${what} with “${newLt.name}” on ${n} credit${n > 1 ? 's' : ''}` + (scopeNote ? ` (${scopeNote})` : '')));
},
});
const out = ['sep'];
if (scopeNote) out.push({ note: scopeNote });
out.push(
mk(`Replace role ${t(roleLabel, 34)}…`, byRole, `“${roleLabel}”`),
mk(`Replace ${t(roleLabel, 20)} for “${t(tgt, 20)}”…`, byRoleTgt, `“${roleLabel}” for “${tgt}”`),
);
return out;
}
function openWorkMenu(workRef, x, y, preselect, extraItems) {
const srcWork = (workRef && workRef.entityType === 'work') ? workRef : workEntity(workRef); // #373 accept a work entity (from a rel) or a checkbox
if (!srcWork) { openMenu(x, y, [{ header: 'Could not read this work' }]); return; }
const srcRels = workCreditRels(srcWork).filter(r => !r.removed);
const entries = srcRels.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: preselect ? !!preselect(s) : true })); // #373 pencil/+ pre-tick a subset
const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel);
// Destinations: ticked works come from MB's own selection state, not the DOM — a newly-created
// work's checkbox has no readable React entity (its fiber differs), so a DOM scan misses it, but
// selectedWorks holds every ticked work. New works carry a NEGATIVE id and may have no gid yet, so
// identify by gid-or-id (not gid alone, which would drop them). If NOTHING is ticked, fall back to
// every OTHER work on the page — mirrors openCopyMenu's recording behavior (#522 follow-up: work
// credit copy required a pre-tick while recording credit copy already copied-to-all by default).
const idOf = w => w.gid || ('#' + w.id);
const tickedWorks = () => {
const out = [], seen = new Set();
try {
for (const e of W.MB.tree.iterate(RE().state.selectedWorks)) {
const w = Array.isArray(e) ? e[1] : e; if (!w) continue;
if (srcWork.gid && w.gid === srcWork.gid) continue; // skip the source work
if (srcWork.id != null && w.id === srcWork.id) continue;
const k = idOf(w); if (seen.has(k)) continue; seen.add(k);
out.push(w);
}
} catch (e) {}
return out;
};
const allOtherWorks = () => {
const out = [], seen = new Set();
document.querySelectorAll('input.work').forEach(cb => {
const w = workEntity(cb); if (!w) return;
if (srcWork.gid && w.gid === srcWork.gid) return;
if (srcWork.id != null && w.id === srcWork.id) return;
const k = idOf(w); if (seen.has(k)) return; seen.add(k);
out.push(w);
});
return out;
};
const ticked0 = tickedWorks();
const destWorks = ticked0.length ? ticked0 : allOtherWorks();
const nR = srcRels.length, nD = destWorks.length;
const destNames = destWorks.map(w => val(w.name));
const items = [];
if (!nR) items.push({ header: `“${trunc(val(srcWork.name), 34)}” has no credits` });
else if (!nD) items.push({ header: 'No other works to copy to' });
else {
const copyItem = { label: 'Copy', sub: String(nR), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, destWorks)) markUsed(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} from work “${val(srcWork.name)}” to ${nD} work${nD > 1 ? 's' : ''}`); toast(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} to ${nD} work${nD > 1 ? 's' : ''} — review & save`); } };
const moveItem = { label: 'Move (remove here)', danger: true, run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, destWorks)) markUsed(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} from work “${val(srcWork.name)}” to ${nD} work${nD > 1 ? 's' : ''}`); removeWorkRels(srcWork, c); toast(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} to ${nD} work${nD > 1 ? 's' : ''} — review & save`); } };
items.push({ header: `Copy to ${ticked0.length ? '' : 'all '}${nD} work${nD > 1 ? 's' : ''}` },
{ note: destNames.slice(0, 6).join(' · ') + (destNames.length > 6 ? ` +${destNames.length - 6} more` : '') },
{ checklist: entries, onToggle: () => { const n = chosen().length; copyItem._setSub && copyItem._setSub(String(n)); } },
copyItem, moveItem);
}
if (extraItems && extraItems.length) items.push(...extraItems); // #470 "Replace role…" from the pencil
openMenu(x, y, items);
}
// discoverability tooltips: label the controls GT hooks (set lazily on first hover; React may wipe
// them on re-render, so we re-set via the data flag). Kept out of `title` when MB already set one.
function hintControls(ev) {
const t = ev.target; if (!t || !t.closest) return;
const cb = t.closest && t.closest('tr.track input.recording, tr.track input.work');
if (cb && !cb.dataset.gtHint) { cb.dataset.gtHint = '1'; if (!cb.title) cb.title = cb.matches('input.work')
? 'Group Therapy: right-click to copy/move this work’s relationships to the ticked works'
: 'Group Therapy: right-click to copy/move this recording’s credits to the ticked recordings'; return; }
const x = t.closest('button.icon.remove-item');
if (x && !x.dataset.gtHint) { x.dataset.gtHint = '1'; if (!x.title) x.title = 'Group Therapy: right-click to remove a whole group (this role / this target / both)'; }
}
// ── boot ────────────────────────────────────────────────────────────────
function boot() {
injectStyle();
document.body.addEventListener('contextmenu', onContextMenu, true);
document.body.addEventListener('mouseover', onOver);
document.body.addEventListener('mousemove', onMove);
document.body.addEventListener('mouseout', onOut);
document.body.addEventListener('mouseover', hintControls, true);
let tries = 0; (function tryInject() { if (injectCloneButton() || tries++ > 40) return; setTimeout(tryInject, 500); })();
try { W.__groupTherapy = { VERSION, fmtBadges, badgeInk, FMT_FAMILY, collect, removeButtons, highlightPage, recordingRels, recordingEntity, copyCredits, checkedDestinations, openCopyMenu, removeSourceRels, rowForRecording, fetchReleaseRels, injectCloneButton, openCopyFromPopover, workEntity, workCreditRels, openWorkMenu, mediumFormatOf, formatExcludeRolesFor, RE, replacementRoles, replaceRole, replaceRoleMenuItems, relEnds, openRolePicker,
// #522 text parser
txpTokenize, txpCompile, txpExpand, linkTypesForPair, openTextParser, closeTextParser,
txpTrackRows, txpMatchTracks, // #539 recording scope
txpMatchTrackToken, txpDetectTracks, // #597 per-row tracks
txpCreateNote, // #544
txpClearAnnotation, txpFetchAnnotationForm, txpClearAnnotationNote, // #550
txpSearchArtist, txpResolveByExactAlias, txpFetchEntity, txpFetchAnnotation, txpAnnoHtmlToText,
txpSearchLabel, txpResolveLabelByExactAlias, txpParseCopyrightLine, txpNoticeDatePeriod, txpNarrowByScore, txpInstrumentCandidates, txpVocalCandidates,
txpSplitCompoundCopyrightLines,
}; } catch (e) {}
console.log('[Group Therapy] ' + mbuStartupInfo('Group Therapy'));
console.log(`[Group Therapy] v${VERSION} ready — right-click a relationship's × for group delete; hover a name/role to highlight.`);
}
/* #544 (majkinetor): "Right click on + doesn't create in the background" —
window.open + win.blur()/window.focus() does not put a tab in the
background in Chrome, so it behaved exactly like a left click.
GM_openInTab({active:false}) does, but it hands back no window handle, so
the created MBID cannot be read by polling the tab's URL the way the
foreground path does.
Credit Hoarder solved this by running on the created entity's own page and
posting the MBID back (#273); the same shape here. That is what the new
/artist/*, /label/*, /place/* matches are for — this handler and nothing
else. Everything below still boots only on the relationship editor. */
const GT_CREATE_CH = 'gt-entity-created';
const GT_PENDING_KEY = 'gt:pendingCreate';
const GT_PENDING_TTL = 10 * 60 * 1000;
/* #598: which create a given tab is running. sessionStorage, deliberately —
it is per-TAB and survives navigation within that tab, which is exactly the
relation we need: the //create page and the // page it
submits itself into are the same tab, and no amount of shared GM state can
tell those two pages apart once several creates are in flight at once. The
create page stamps its token here before pressing Enter; the entity page
reads it and knows, with no guessing, which row is waiting for it. */
const GT_TAB_KEY = 'gt:createTab';
/* #598: the pending-create store. One slot became a list, because two
concurrent background creates evicted each other — see the comment at the
GM_openInTab call. Every read prunes: a create nobody ever completed must
not keep matching forever, and the TTL is the same ten minutes the opener
waits before it gives up on its own. */
function gtPending() {
let v = null;
try { v = JSON.parse(GM_getValue(GT_PENDING_KEY, '') || 'null'); } catch (e) {}
// An install upgrading mid-create still has the old single-object shape
// under this key; adopt it rather than throwing that create away.
if (v && !Array.isArray(v)) v = [v];
if (!Array.isArray(v)) return [];
const now = Date.now();
return v.filter(p => p && p.token && p.kind && p.ts && now - p.ts < GT_PENDING_TTL);
}
function gtPendingWrite(list) { try { GM_setValue(GT_PENDING_KEY, JSON.stringify(list)); } catch (e) {} }
function gtPendingAdd(rec) { gtPendingWrite(gtPending().concat([rec])); }
function gtPendingDrop(token) { gtPendingWrite(gtPending().filter(p => p.token !== token)); }
function gtTabMark(rec) { try { sessionStorage.setItem(GT_TAB_KEY, JSON.stringify(rec)); } catch (e) {} }
function gtTabRead() { try { return JSON.parse(sessionStorage.getItem(GT_TAB_KEY) || 'null'); } catch (e) { return null; } }
function gtTabClear() { try { sessionStorage.removeItem(GT_TAB_KEY); } catch (e) {} }
const GT_ENTITY_PATH = /^\/(artist|label|place)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i;
(function announceCreatedEntity() {
const m = location.pathname.match(GT_ENTITY_PATH);
if (!m) return; // not an entity page
const kind = m[1].toLowerCase(), gid = m[2].toLowerCase();
const list = gtPending();
let token = null;
// #598: the tab's own stamp is the authority. It was written by this script
// in this tab moments ago, immediately before pressing "Enter edit", so it
// identifies the create exactly even with several tabs mid-flight.
const mark = gtTabRead();
if (mark && mark.kind === kind && mark.token && mark.ts && Date.now() - mark.ts < GT_PENDING_TTL) {
token = mark.token;
} else {
// No stamp: an older create page, or sessionStorage unavailable. Fall
// back to the pre-#598 behaviour, but ONLY where there is nothing to
// guess between — handing the wrong MBID to the wrong row is a worse
// outcome than leaving a tab open for the user to deal with.
const same = list.filter(p => p.kind === kind);
if (same.length !== 1) return;
token = same[0].token;
}
// Only answer a create WE started — otherwise merely browsing an artist
// page would close the tab.
if (!token) return;
gtTabClear();
gtPendingDrop(token);
try {
const ch = new BroadcastChannel(GT_CREATE_CH);
ch.postMessage({ kind, gid, token });
setTimeout(() => { try { ch.close(); } catch (e) {} try { window.close(); } catch (e) {} }, 300);
} catch (e) {}
})();
/* #544 follow-up (majkinetor): "When creating in the background, it doesn't
click Enter in 2nd tab."
A seeded //create URL only PRE-FILLS the form; MusicBrainz still waits
for "Enter edit". In the foreground that is the user's own click. In a
background tab nobody ever clicks it, so the entity is never created and the
text parser waits out its full ten minutes for a post-back that cannot come.
Three things keep this from ever submitting a form it should not:
• the x_gtcreate token in the URL must name a create that is actually
pending, so this can only ever fire on a page one background create
opened — not on a /artist/create the user opened themselves while a
create was pending;
• the name field must actually be filled, i.e. the seed really landed;
• it stamps the tab and marks the record `submitted` BEFORE clicking, so
it presses Enter exactly once. If MusicBrainz answers with a
duplicate-check or validation page, this stands down and the tab is left
for the user — which is the right outcome for anything needing a human
decision. (The stamp outlives that hand-off, so a create the user
finishes themselves still reports its MBID back to the row. #598) */
const GT_CREATE_PATH = /^\/(artist|label|place)\/create\/?$/i;
(function autoSubmitSeededCreate() {
const m = location.pathname.match(GT_CREATE_PATH);
if (!m) return;
const kind = m[1].toLowerCase();
// This runs unattended in a tab nobody is looking at, so it says what it
// decided — without a line here, "it didn't create anything" is
// indistinguishable from "it never ran".
const say = (msg) => { try { console.log('[Group Therapy] background create: ' + msg); } catch (e) {} };
// #598: the URL's token is the question, and the pending LIST is looked up
// by it. The old order asked "what is the one pending create?" and then
// required this page to be it — so the moment a second create was started,
// the first tab's page failed that test and never pressed Enter. Which
// create this page belongs to is written right there in its own URL; there
// was never a reason to ask a shared slot instead.
let token = null; try { token = new URLSearchParams(location.search).get('x_gtcreate'); } catch (e) {}
if (!token) { say('no create token in this URL — we did not open this form, leaving it alone'); return; }
const pending = gtPending().find(p => p.token === token && p.kind === kind);
if (!pending) { say(`no pending ${kind} create matches this page's token — leaving this form alone`); return; }
if (pending.submitted || gtTabRead()) { say('already pressed Enter once for this create — standing down (MusicBrainz may be asking something)'); return; }
const go = () => {
// MusicBrainz's edit form needs its own JavaScript before "Enter edit"
// does anything — measured: clicking at document-start left the tab
// sitting on a filled-in /create page with nothing submitted, while the
// same click after load went straight through to the new artist. A
// userscript manager runs this at document-end, but that is not late
// enough to rely on, so wait for the document to actually be done.
if (document.readyState !== 'complete') return false;
const form = document.querySelector(`form.edit-${kind}`);
const name = form && form.querySelector(`[name="edit-${kind}.name"]`);
const submit = form && form.querySelector('button[type="submit"], input[type="submit"]');
if (!form || !name || !name.value.trim() || !submit) return false;
// Re-read the record HERE, not just at start-up: `pending` was captured
// before the wait, so two instances of this script on one document (or a
// second create started meanwhile) could both pass the start-up guard and
// both click — two artists from one press. Observed with a doubled
// injection in testing; cheap to make impossible for a create action.
//
// #598: the tab stamp is what makes this exact rather than approximate.
// It is per-tab, so a create running in ANOTHER tab can neither set nor
// clear it, while a second instance in THIS document sees it at once.
// Writing it is also what lets the created entity's page — the same tab,
// one navigation later — know which of several creates it is.
if (gtTabRead()) { say('already pressed Enter once in this tab — standing down'); return true; }
const now = gtPending().find(p => p.token === token);
if (!now || now.submitted) { say('this create is no longer pending (cancelled, or already submitted) — standing down'); return true; }
gtTabMark({ kind, token, ts: Date.now() });
gtPendingWrite(gtPending().map(p => (p.token === token ? Object.assign({}, p, { submitted: true }) : p)));
say(`pressing "Enter edit" for “${name.value.trim()}”`);
try { submit.click(); } catch (e) { say('the click threw: ' + ((e && e.message) || e)); }
return true;
};
// MusicBrainz's edit forms bind late (the same reason Falcon waits for its
// seeded rows to settle), so clicking the instant the DOM exists can land
// before the seed is bound. Retry briefly rather than guessing one delay.
say('waiting for the seeded form to be ready');
let tries = 0;
const iv = setInterval(() => {
if (go()) { clearInterval(iv); return; }
if (++tries > 120) { clearInterval(iv); say('gave up waiting for the form — the tab is left for you to submit by hand'); }
else if (tries % 20 === 0) say(`still waiting (readyState=${document.readyState}, form=${!!document.querySelector('form.edit-' + kind)})`);
}, 250); // ~30s
})();
// Self-guard the page: in the String Theory bundle this script runs on EVERY union-matched URL
// (Apollo's /release/*/edit, /artist/*, …), so its hover-highlight etc. would bleed onto other pages.
// Standalone the @match restricts it; the bundle doesn't — so only boot on the relationship editor.
if (/\/release\/[^/]+\/edit-relationships\/?$/i.test(location.pathname)) {
if (document.body) boot(); else document.addEventListener('DOMContentLoaded', boot, { once: true });
}
})();
|