// ==UserScript== // @name String Theory // @namespace https://github.com/majkinetor/musicbrainz-userscripts // @version 2026.7.9.195543 // @description Unified bundle of 7 MusicBrainz userscripts (apollo_editor, art_station, credit_hoarder, group_therapy, isrc_scout, mammoth, platform_check). Built by userscripts/string_theory/build.mjs — do not hand-edit. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij4NCiAgPCEtLSBodWItYW5kLXNwb2tlICJuZXR3b3JrIiBnbHlwaCwgc2luZ2xlIHZpdmlkIHZpb2xldCBvbiB0cmFuc3BhcmVudCBzbyBpdCByZWFkcyBvbiBib3RoIGRhcmsgYW5kIGxpZ2h0IHBhZ2VzIC0tPg0KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiM3YzVjZmYiIHN0cm9rZS13aWR0aD0iNC42IiBzdHJva2UtbGluZWNhcD0icm91bmQiPg0KICAgIDxwYXRoIGQ9Ik0zMiAzMiBMMzIgMTUiLz4NCiAgICA8cGF0aCBkPSJNMzIgMzIgTDQ2LjUgMjMuNSIvPg0KICAgIDxwYXRoIGQ9Ik0zMiAzMiBMNDYuNSA0MC41Ii8+DQogICAgPHBhdGggZD0iTTMyIDMyIEwzMiA0OSIvPg0KICAgIDxwYXRoIGQ9Ik0zMiAzMiBMMTcuNSA0MC41Ii8+DQogICAgPHBhdGggZD0iTTMyIDMyIEwxNy41IDIzLjUiLz4NCiAgPC9nPg0KICA8ZyBmaWxsPSIjN2M1Y2ZmIj4NCiAgICA8Y2lyY2xlIGN4PSIzMiIgY3k9IjMyIiByPSI4LjYiLz4NCiAgICA8Y2lyY2xlIGN4PSIxNSIgY3k9IjE5LjUiIHI9IjYuNCIvPg0KICAgIDxjaXJjbGUgY3g9IjQ5IiBjeT0iMTkuNSIgcj0iNi40Ii8+DQogICAgPGNpcmNsZSBjeD0iMzIiIGN5PSI1NyIgcj0iNi40Ii8+DQogIDwvZz4NCiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjN2M1Y2ZmIiBzdHJva2Utd2lkdGg9IjMuOCI+DQogICAgPGNpcmNsZSBjeD0iMzIiIGN5PSI3IiByPSI0LjkiLz4NCiAgICA8Y2lyY2xlIGN4PSIxNSIgY3k9IjQ0LjUiIHI9IjQuOSIvPg0KICAgIDxjaXJjbGUgY3g9IjQ5IiBjeT0iNDQuNSIgcj0iNC45Ii8+DQogIDwvZz4NCjwvc3ZnPg0K // @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/string_theory // @run-at document-start // @match https://*.musicbrainz.org/release/add* // @match https://*.musicbrainz.org/release/*/edit // @match https://*.musicbrainz.org/release/*/edit_annotation // @match https://*.musicbrainz.org/artist/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @connect * // @match *://*.musicbrainz.org/release/*/cover-art // @match *://*.musicbrainz.org/release/*/add-cover-art // @match *://*.musicbrainz.org/event/*/event-art // @match *://*.musicbrainz.org/event/*/add-event-art // @grant GM.xmlHttpRequest // @match https://*.musicbrainz.org/release/*/edit-relationships // @match https://*.musicbrainz.org/label/* // @match https://*.musicbrainz.org/place/* // @match https://tidal.com/album/* // @match https://listen.tidal.com/album/* // @grant unsafeWindow // @connect qobuz.com // @connect www.qobuz.com // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @match *://*.musicbrainz.org/release/*/edit-relationships // @match https://*.musicbrainz.org/release/* // @match https://*.musicbrainz.org/oauth2/oob* // @match https://www.beatport.com/release/* // @connect musicbrainz.org // @connect beta.musicbrainz.org // @connect isrc-api.soundexchange.com // @connect isrc.soundexchange.com // @connect api.deezer.com // @connect isrchunt.com // @connect openapi.tidal.com // @connect auth.tidal.com // @connect volumo.com // @connect hdtracks.azurewebsites.net // @connect api.beatport.com // @connect bandcamp.com // @connect music.apple.com // @match https://*.musicbrainz.org/* // @match https://*.musicbrainz.org/release-group/*/edit // @match https://*.musicbrainz.org/release-group/*/edit-relationships // @connect query.wikidata.org // @connect search.brave.com // @connect html.duckduckgo.com // @connect duckduckgo.com // @connect api.discogs.com // @connect www.discogs.com // @connect open.spotify.com // @connect itunes.apple.com // @connect sambl.lioncat6.com // ==/UserScript== // Auto-generated by userscripts/string_theory/build.mjs — do NOT edit by hand. // Bundles (verbatim, each wrapped in a run-at gate): apollo_editor, art_station, credit_hoarder, group_therapy, isrc_scout, mammoth, platform_check. try { console.log('%c String Theory %c v2026.7.9.195543 ', 'background:#7c5cff;color:#fff;font-weight:bold;border-radius:3px;padding:2px 6px', 'color:#7c5cff;font-weight:bold'); console.log("String Theory bundles:\n · Apollo Editor v2026.7.9\n · Art Station v2026.7.9\n · Credit Hoarder v2026.7.2\n · Group Therapy v2026.7.9\n · ISRC Scout v2026.7.9\n · Mammoth v2026.7.3\n · Platform Check v2026.7.9"); } catch (e) {} // ===== apollo_editor (@run-at document-start) ===================================== (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Apollo Editor*","namespace":"https://musicbrainz.org/","version":"2026.7.9","description":"Speed up per-track artist-credit resolution in the MusicBrainz release editor — bulk-match each track's artist text to an MB artist (sibling releases in the release group first, then search), one-click apply, multi-artist aware, create-on-the-fly. Same table whether floating or replacing the integrated tracklist.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md","supportURL":null,"icon":"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M13 22 L19 22 L16 30 Z' fill='%23ff8c3b'/%3E%3Cpath d='M14.4 22 L17.6 22 L16 27 Z' fill='%23ffd24a'/%3E%3Cpath d='M12 18 L8 23.5 L12 22 Z' fill='%233d2470'/%3E%3Cpath d='M20 18 L24 23.5 L20 22 Z' fill='%233d2470'/%3E%3Cpath d='M16 2.5 C19 7 20 12 20 16 L20 22 L12 22 L12 16 C12 12 13 7 16 2.5 Z' fill='%235f3ec0'/%3E%3Ccircle cx='16' cy='12.5' r='3' fill='%23cfe8ff' stroke='%232a1a52' stroke-width='1'/%3E%3C/svg%3E"}) }) : { script: {"name":"Apollo Editor*","namespace":"https://musicbrainz.org/","version":"2026.7.9","description":"Speed up per-track artist-credit resolution in the MusicBrainz release editor — bulk-match each track's artist text to an MB artist (sibling releases in the release group first, then search), one-click apply, multi-artist aware, create-on-the-fly. Same table whether floating or replacing the integrated tracklist.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md","supportURL":null,"icon":"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M13 22 L19 22 L16 30 Z' fill='%23ff8c3b'/%3E%3Cpath d='M14.4 22 L17.6 22 L16 27 Z' fill='%23ffd24a'/%3E%3Cpath d='M12 18 L8 23.5 L12 22 Z' fill='%233d2470'/%3E%3Cpath d='M20 18 L24 23.5 L20 22 Z' fill='%233d2470'/%3E%3Cpath d='M16 2.5 C19 7 20 12 20 16 L20 22 L12 22 L12 16 C12 12 13 7 16 2.5 Z' fill='%235f3ec0'/%3E%3Ccircle cx='16' cy='12.5' r='3' fill='%23cfe8ff' stroke='%232a1a52' stroke-width='1'/%3E%3C/svg%3E"} }; (f=>f())(function(){ /* * Editor model (discovered via test/ spikes): * read MB.releaseEditor.rootField.release().mediums()[m].tracks()[t] * .artistCredit() → { names:[{ artist:{name,gid,id}, name(creditedAs), joinPhrase }] } * search GET /ws/js/artist?q=&direct=false → full entities (incl. numeric id). * sibling GET /ws/2/release?release-group=&inc=recordings+artist-credits → other versions' * per-track credits with gids; disambiguates search hits by title. * write track.artistCredit({ names:[{ artist: fullEntity, name: creditedAs, joinPhrase }] }) * ops ed.removeTrack(t) · ed.moveTrackUp(t)/moveTrackDown(t) · track.name(s) · * track.length(ms) · track.formattedLength() · ed.utils.unformatTrackLength('3:53') */ (function () { 'use strict'; // #283 every Log.* call is captured into an in-page buffer and surfaced in a // dedicated log viewer — opened from a Log button next to "? Help" — // copy/pastable as a Markdown
block, like the other scripts. (The // console output was dropped: it just duplicated this buffer.) const LOG = []; const _logListeners = new Set(); const _lpad = (n, w = 2) => String(n).padStart(w, '0'); const _logTs = d => `${_lpad(d.getHours())}:${_lpad(d.getMinutes())}:${_lpad(d.getSeconds())}.${_lpad(d.getMilliseconds(), 3)}`; const _logStr = v => { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; function _logRecord(sev, args) { const msg = args.map(_logStr).join(' ').replace(/\s+/g, ' ').trim(); if (!msg) return; LOG.push({ t: new Date(), sev, msg }); _logListeners.forEach(f => { try { f(); } catch (e) {} }); } const Log = { info: (...a) => _logRecord('info', a), warn: (...a) => _logRecord('warn', a), err: (...a) => _logRecord('error', a), ok: (...a) => _logRecord('ok', a), debug: (...a) => _logRecord('debug', a), }; const _logCounts = () => LOG.reduce((acc, e) => { if (e.sev === 'warn') acc.warn++; else if (e.sev === 'error') acc.error++; return acc; }, { warn: 0, error: 0 }); // escape, then turn http(s) URLs into clickable links for the log viewer const _logLinkify = s => esc(s).replace(/(https?:\/\/[^\s<]+)/g, (m) => { const t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; // keep trailing punctuation out of the URL const url = m.slice(0, m.length - t.length); return `${url}${t}`; }); // copy/pastable Markdown — collapsed
wrapping a fenced log block. function logMarkdown() { const PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; const body = LOG.length ? LOG.map(e => `${_logTs(e.t)} ${PRE[e.sev] || ''}${e.msg}`).join('\n') : '(no activity logged)'; const c = _logCounts(); let title = 'Apollo Editor'; try { title += ' v' + scriptVersion(); } catch (e) {} const tally = (c.warn || c.error) ? ` (${c.warn} warning${c.warn === 1 ? '' : 's'}, ${c.error} error${c.error === 1 ? '' : 's'})` : ''; return `
${title} — session log${tally}\n\n` + '```log\n' + body + '\n```' + `\n\n
`; } async function copyLog(btn) { const md = logMarkdown(); let ok = false; try { await navigator.clipboard.writeText(md); ok = true; } catch (e) { try { const ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) {} } if (btn) { const o = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = o; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(() => { btn.textContent = o; }, 1500); } } // #283 remember the log window across sessions: open?/minimized?/position const LOGWIN_KEY = 'apolloEditor.logwin'; const loadLogWin = () => { try { return JSON.parse(localStorage.getItem(LOGWIN_KEY) || '{}'); } catch (e) { return {}; } }; const saveLogWin = (patch) => { try { localStorage.setItem(LOGWIN_KEY, JSON.stringify(Object.assign(loadLogWin(), patch))); } catch (e) {} }; // the Log button opens this popup: the full session log + a Copy control. function openLog() { document.getElementById('tc-logpop')?.remove(); style(); // ensure the popup CSS is injected (e.g. on auto-open before settings is opened) saveLogWin({ open: true }); const st = loadLogWin(); const pop = document.createElement('div'); pop.id = 'tc-logpop'; pop.innerHTML = `
Activity log ` + `` + `` + `
` + `
`; document.body.appendChild(pop); // restore saved open position (used when not minimized, and remembered for restore) if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } pop._restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; const renderList = () => { const list = pop.querySelector('.tc-log-list'); list.innerHTML = LOG.length ? LOG.map(e => `
${_logTs(e.t)}${_logLinkify(e.msg)}
`).join('') : '
No activity yet.
'; const c = _logCounts(); pop.querySelector('.tc-log-badge').textContent = `(${LOG.length})` + (c.warn || c.error ? ` · ${c.warn}⚠ ${c.error}✖` : ''); list.scrollTop = list.scrollHeight; }; renderList(); _logListeners.add(renderList); const onKey = e => { if (e.key === 'Escape') close(); }; const close = () => { saveLogWin({ open: false }); _logListeners.delete(renderList); pop.remove(); document.removeEventListener('keydown', onKey); }; pop.querySelector('.tc-logpop-copy').onclick = () => copyLog(pop.querySelector('.tc-logpop-copy')); const minBtn = pop.querySelector('.tc-logpop-min'); const setMin = (m) => { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to bottom else if (pop._restore) { Object.assign(pop.style, pop._restore); } }; minBtn.onclick = () => { const m = pop.classList.toggle('min'); setMin(m); saveLogWin({ min: m }); }; if (st.min) { pop.classList.add('min'); setMin(true); } // restore minimized state pop.querySelector('.tc-logpop-x').onclick = close; // floating, non-modal window — draggable by its header pop.querySelector('.tc-logpop-h').addEventListener('mousedown', (e) => { if (e.target.closest('button')) return; e.preventDefault(); const r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; const ox = e.clientX - r.left, oy = e.clientY - r.top; const mv = ev => { pop.style.left = Math.max(0, Math.min(innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(innerHeight - 36, ev.clientY - oy)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { pop._restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; saveLogWin({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); document.addEventListener('keydown', onKey); } // first log line: the script + version. The MB release line is logged once the // editor is ready (so it carries the real title) — see init(). Log.info('Apollo Editor' + (() => { try { return ' v' + GM_info.script.version; } catch (e) { return ''; } })()); const W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const ORIGIN = location.origin; const u = v => { try { return typeof v === 'function' ? v() : v; } catch (e) { return undefined; } }; const getEditor = () => { try { return W.MB && W.MB.releaseEditor; } catch (e) { return null; } }; // normalize hyphen/dash look-alikes (MB uses ‐ U+2010, others use - U+002D, en/em // dashes, minus…) to a plain '-' so e.g. "Gol‐e Yakh" folds the same as "Gol-e Yakh" const fold = s => (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); const sameName = (a, b) => fold(a) === fold(b); // gid → artist disambiguation, harvested from every WS2/js artist-credit the // script fetches (search results, recording lookups). The MB page (KO) model // doesn't carry disambiguations for freshly-picked entities, so the recordings // table falls back to this cache to show them after a pick. #195 const _disamb = new Map(); const noteDisamb = (gid, c) => { if (gid && c) _disamb.set(gid, c); }; const getDisamb = gid => (gid && _disamb.get(gid)) || ''; const MBID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; // a MusicBrainz /artist/ URL, a bare MBID, or an MBID pasted anywhere in the text → the gid function mbidFrom(v) { v = (v || '').trim(); const url = v.match(new RegExp('musicbrainz\\.org/artist/(' + MBID_RE.source + ')', 'i')); if (url) return url[1].toLowerCase(); const m = v.match(new RegExp('(?:^|[\\s/])(' + MBID_RE.source + ')(?:[\\s/?#]|$)', 'i')); return m ? m[1].toLowerCase() : null; } // an ISRC (CC-XXX-YY-NNNNN), with or without separators, found as a standalone // token → the normalised 12-char uppercase form. Word-boundaried so it won't // fire mid-string; the structure (2 alpha · 3 alphanum · 7 digit) is specific // enough not to collide with a bare MBID. #196 const ISRC_TOKEN = /\b([A-Za-z]{2})-?([A-Za-z0-9]{3})-?([0-9]{2})-?([0-9]{5})\b/; function isrcFrom(v) { const m = String(v || '').match(ISRC_TOKEN); return m ? (m[1] + m[2] + m[3] + m[4]).toUpperCase() : null; } /* ── create-artist-in-a-tab → auto-insert (BroadcastChannel handshake, like the Discogs importer) ── */ const ART_CHANNEL = ('BroadcastChannel' in W) ? new W.BroadcastChannel('apollo-editor-artist') : null; const PENDING_KEY = 'apolloEditor.pendingArtist'; const CLOSE_KEY = 'apolloEditor.closeAfterEdit'; // tab opened just to add a Discogs link → self-close after submit const _pendingCreates = new Map(); let _createSeq = 0; if (ART_CHANNEL) ART_CHANNEL.addEventListener('message', e => { const d = e.data; if (!d || d.type !== 'tc-artist-created') return; const pend = _pendingCreates.get(d.token); if (!pend) return; _pendingCreates.delete(d.token); if (!d.gid) { Log.warn('artist created but no gid came back'); return; } // The postMessage can only carry a plain {gid,name,id}, but commitTrack writes // `artist: ` into the NATIVE artist credit — and MB only treats it as a // real linked artist when it's the WHOLE entity (the same shape fetchEntity / the // paste path produce). A plain object renders in the Apollo table but is dropped // from the native model on submit. So re-fetch the full entity in THIS tab (the // artist is already indexed — the create tab fetched it before posting back). #191 fetchEntity(d.gid).then(ent => { pickArtist(pend.slot, ent || { gid: d.gid, name: d.name, id: d.id }); // #273: close the background create tab via its handle (a GM-opened tab can't always self-close). try { if (pend.bgTab && typeof pend.bgTab.close === 'function') pend.bgTab.close(); } catch (x) {} Log.info('inserted newly-created artist', JSON.stringify(d.name), 'into the table' + (ent ? '' : ' (plain fallback — native link may be incomplete)')); }); }); /* ── settings ── */ const SKEY = 'apolloEditor.settings.v1'; function loadSettings() { const d = { apolloEnabled: true, colWidths: {}, applyMode: 'all', altRows: false, gridCols: false, gridRows: true, replaceReleaseInfo: true, replaceTracklist: true, replaceRecordings: true, modifyAnnotation: true, modifyDuplicates: true, autoMatch: false, autoMatchRec: false, discogsUrlMatch: true, recLenTol: 5, recIgnoreCase: true, recIgnorePunct: true, recTitleTol: 1, recCutoff: 'near', recDetailedHl: false, recPunctSize: 3, recHlColor: '#e53935', lastTool: '', layout: 'normal', lastView: 'apollo', zenMode: true, autoConfirmSeed: true, keepCaretColumn: true, hoverHighlight: false, srRegex: false, srTemplates: [], srSeedV: 0, srHistory: [] }; try { const stored = JSON.parse(localStorage.getItem(SKEY) || '{}'); const s = Object.assign(d, stored); if (stored.gridCols === undefined && stored.grid !== undefined) s.gridCols = stored.grid; return s; } catch (e) { return d; } } function saveSettings() { try { localStorage.setItem(SKEY, JSON.stringify(SETTINGS)); } catch (e) {} } let SETTINGS = loadSettings(); try { srSeedTemplates(); } catch (e) {} // #375 seed the default S&R templates once let _cfgTab = 'general'; // remembered settings tab (#294), per session // measure a name's pixel width so an artist input can shrink-wrap to its text (so the // clear × hugs the name instead of sitting at the box's far right). #284 follow-up. const _nmMeasCtx = (() => { try { return document.createElement('canvas').getContext('2d'); } catch (e) { return null; } })(); const fitNmWidth = inp => { if (!_nmMeasCtx) return; const v = inp.value; if (!v) { inp.style.width = ''; return; } // empty → let flex give it the full width _nmMeasCtx.font = '13px Arial'; // matches .tc-search .nm inp.style.width = (Math.ceil(_nmMeasCtx.measureText(v).width) + 5) + 'px'; }; // FOUC guard (we run at document-start): on the standalone Edit annotation page, hide the native form until our // editor mounts (and removes #tc-anno-fouc), so the original interface never flashes. Skipped when off. if (/\/release\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/edit_annotation/.test(location.pathname) && SETTINGS.apolloEnabled !== false && SETTINGS.modifyAnnotation !== false) { const s = document.createElement('style'); s.id = 'tc-anno-fouc'; s.textContent = '#content > form{visibility:hidden}'; (document.head || document.documentElement).appendChild(s); setTimeout(() => document.getElementById('tc-anno-fouc')?.remove(), 6000); // safety: never hide forever if the editor fails to mount } function waitFor(check, { tries = 120, every = 500 } = {}) { return new Promise(res => { let n = 0; const t = () => { let v; try { v = check(); } catch (e) {} if (v) return res(v); if (++n >= tries) return res(null); setTimeout(t, every); }; t(); }); } /* ── model access ── */ function release() { return u(getEditor().rootField.release); } function mediums() { return u(release().mediums) || []; } function koTrack(mi, ti) { return u(mediums()[mi].tracks)[ti]; } // #376 pending-edit state for a track's recording: {rec} if the recording itself has open edits, // {art} if any artist in its credit does — MB marks each with span.mp (gold). function recPendingState(mi, ti) { try { const ko = koTrack(mi, ti), rec = u(ko.recording); // #376 check the CURRENTLY-shown artist credit (the track's), not the recording's — so picking a // different (non-pending) artist clears the highlight. Title pending stays recording-level. // Only a SELECTED artist (has a gid) counts — a free-text credit with no linked entity gets no // highlight (majkinetor: "remove highlight when no artist is selected — it's for a previously // selected artist"). artGids drives the per-SLOT gold so a free-text slot beside a pending one stays clean. const artGids = new Set(); const ac = u(ko.artistCredit), names = ac && u(ac.names); if (names) for (const n of names) { const a = u(n.artist); if (a && u(a.editsPending)) { const g = u(a.gid); if (g) artGids.add(g); } } return { rec: rec ? !!u(rec.editsPending) : false, art: artGids.size > 0, artGids }; } catch (e) { return null; } } // #376 a slot golds only when it's a SELECTED artist (committed, with a gid) whose entity is pending — // so editing a field to free text (which unselects the mbid) drops the gold on the next re-adorn. const slotIsPending = (artGids, slot) => !!(slot && slot.committed && slot.gid && artGids && artGids.has(slot.gid)); function liveNames(track) { const ac = u(track.artistCredit) || {}; return u(ac.names) || []; } const ORIGINALS = new Map(); const snapTrack = t => ({ title: u(t.name) || '', number: u(t.number), length: u(t.formattedLength) || '', names: liveNames(t).map(n => ({ artist: u(n.artist) || { name: u(n.name) || '' }, creditedAs: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' })), }); function snapshotOriginals() { ORIGINALS.clear(); mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => ORIGINALS.set(mi + ':' + ti, snapTrack(t)))); Log.info('snapshot of', ORIGINALS.size, 'original tracks'); } // MB lazy-loads each medium's tracks asynchronously, so the startup snapshot misses mediums that // hadn't loaded yet. Capture the page-load state of any track that appears later — before matching // writes to it — so change-tracking (the ↺ button + the changed-row border) works on every medium. function snapshotMissing() { let added = 0; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const k = mi + ':' + ti; if (!ORIGINALS.has(k)) { ORIGINALS.set(k, snapTrack(t)); added++; } })); if (added) Log.info('snapshot +', added, 'newly loaded original track(s) →', ORIGINALS.size, 'total'); } function readTracklist() { const out = []; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const names = liveNames(t).map(n => { const a = u(n.artist) || null; return { creditedAs: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '', artistGid: a ? u(a.gid) : null, artistName: a ? u(a.name) : '' }; }); out.push({ mi, ti, number: u(t.number), title: u(t.name) || '', length: u(t.formattedLength) || '', names, resolved: names.length > 0 && names.every(n => n.artistGid) }); })); return out; } /* ── search + siblings ── */ const _cache = new Map(); // resolve an MBID to a full entity (incl. the numeric id needed for the credit write-back) async function fetchEntity(gid) { try { const j = await fetch(`${ORIGIN}/ws/js/entity/${gid}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); // return the WHOLE entity (like a search hit) so the credit write-back has every field it needs if (j && j.gid) { if (!j.entityType) j.entityType = 'artist'; return j; } } catch (e) { Log.warn('fetch entity failed', gid, e.message); } return null; } async function searchArtist(name, limit) { limit = limit || 8; const k = fold(name) + '|' + limit; if (!fold(name)) return []; if (_cache.has(k)) return _cache.get(k); let list = []; try { const j = await fetch(`${ORIGIN}/ws/js/artist?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) { Log.warn('search failed:', name, e.message); } list = list.filter(c => c && (c.name || '').trim()); // drop the trailing empty placeholder entry list.forEach(c => noteDisamb(c.gid || c.id, c.comment)); // cache disambiguations for the table after a pick (#195) _cache.set(k, list); return list; } // full alias arrays for display (the js search only carries primaryAlias, often empty). One WS2 // search per query returns every result's aliases with locale — no per-artist fetch. Cached. const _aliasCache = new Map(); // query → { gid: aliases } const _gidAliases = new Map(); // gid → aliases — survives table rebuilds (so the bar keeps its alias) const cacheAliases = (gid, aks) => { if (gid && aks) _gidAliases.set(gid, aks); }; async function fetchAliases(name) { const k = fold(name); if (!k) return {}; if (_aliasCache.has(k)) return _aliasCache.get(k); const map = {}; try { const w = await fetch(`${ORIGIN}/ws/2/artist?query=${encodeURIComponent(name)}&limit=12&fmt=json`, { headers: { Accept: 'application/json' } }).then(r => r.json()); (w.artists || []).forEach(a => { map[a.id] = a.aliases || []; cacheAliases(a.id, a.aliases || []); }); } catch (e) { Log.warn('alias fetch failed', name, e.message); } _aliasCache.set(k, map); return map; } // aliases for already-resolved artists (existing releases / auto-matched) WITHOUT a fetch each — // one batched WS2 query per ~90 gids (arid:g1 OR arid:g2 …), cached by gid async function fetchAliasesByGids(gids) { const uniq = [...new Set((gids || []).filter(g => g && !_gidAliases.has(g)))]; for (let i = 0; i < uniq.length; i += 90) { const q = uniq.slice(i, i + 90).map(g => 'arid:' + g).join(' OR '); try { const w = await fetch(`${ORIGIN}/ws/2/artist?query=${encodeURIComponent(q)}&limit=100&fmt=json`, { headers: { Accept: 'application/json' } }).then(r => r.json()); (w.artists || []).forEach(a => { cacheAliases(a.id, a.aliases || []); noteDisamb(a.id, a.disambiguation); }); } catch (e) { Log.warn('batch alias fetch failed', e.message); } } } const isEditingNow = () => { const a = document.activeElement; return a && /^(INPUT|SELECT)$/.test(a.tagName) && (a.closest('.tc-medsec') || a.closest('#tc-panel')); }; // re-run adorn for every rendered slot (adds/updates the alias span) WITHOUT rebuilding rows — so it // can't steal focus or detach the slot an in-flight edit is using function refreshAdorns() { if (!MODEL) return; MODEL.tracks.forEach(t => { const row = rowEl(t.mi, t.ti); if (!row) return; const ps = recPendingState(t.mi, t.ti); const pg = ps && ps.artGids; const searches = row.querySelectorAll('.tc-search'); t.slots.forEach((s, i) => { const search = searches[i]; if (search) { adorn(search, s, search.querySelector('.nm')); search.classList.toggle('tc-slot-pending', slotIsPending(pg, s)); } }); }); } // batch-fetch aliases for every committed artist we don't have yet, then refresh the bars in place async function enrichResolvedAliases() { if (!MODEL) return; const need = []; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.committed && s.gid && !_gidAliases.has(s.gid)) need.push(s.gid); })); if (!need.length) return; await fetchAliasesByGids(need); refreshAdorns(); } // the alias(es) to show next to a result: the English-locale one(s) if present, otherwise the first // alias — joined with ", " and capped so it never gets too long // MusicBrainz special-purpose artists carry hundreds of junk/locale aliases // (e.g. [unknown] → '"Gold Diggers of 1937" Chorus'); never surface one as an AKA. // Keyed by MBID, not a name pattern — not all are bracketed (Various Artists) and // plenty of real artists DO use brackets. (#171, per @chaban-mb) const SPECIAL_PURPOSE_ARTISTS = new Set([ '125ec42a-7229-4250-afc5-e057484327fe', // [unknown] 'f731ccc4-e22a-43af-a747-64213329e088', // [anonymous] '33cf029c-63b0-41a0-9855-be2a3665fb3b', // [data] '314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc', // [dialogue] 'eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61', // [no artist] '9be7f096-97ec-4615-8957-8d40b5dcbc41', // [traditional] '89ad4ac3-39f7-470e-963a-56509c546377', // Various Artists '7e84f845-ac16-41fe-9ff8-df12eb32af55', // MusicBrainz Test Artist '66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49', // [Disney] 'a0ef7e1d-44ff-4039-9435-7d5fefdeecc9', // [theatre] '90068d37-bae7-4292-be4a-704c145bd616', // [church chimes] '80a8851f-444c-4539-892b-ad2a49292aa9', // [language instruction] ]); function aliasStr(c) { if (c.gid && SPECIAL_PURPOSE_ARTISTS.has(c.gid)) return null; // #171 — no AKA for special-purpose artists const name = c.name || '', aks = c.aliases || [], diff = s => s && fold(s) !== fold(name); const en = aks.filter(a => /^en/i.test(a.locale || '') && diff(a.name)).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)).map(a => a.name); let out = en; if (!out.length) { const first = aks.find(a => diff(a.name)); out = first ? [first.name] : (diff(c.primaryAlias) ? [c.primaryAlias] : []); } const seen = new Set(); out = out.filter(s => { const f = fold(s); if (!f || seen.has(f)) return false; seen.add(f); return true; }); if (!out.length) return null; let s = out.join(', '); const MAX = 48; if (s.length > MAX) { const cut = s.lastIndexOf(', ', MAX); s = (cut > 12 ? s.slice(0, cut) : s.slice(0, MAX - 1)) + '…'; } return s; } async function fetchSiblings(rgGid) { const map = new Map(); try { const r = await fetch(`${ORIGIN}/ws/2/release?release-group=${rgGid}&inc=recordings+artist-credits&fmt=json&limit=100`, { headers: { Accept: 'application/json' } }); if (!r.ok) { Log.warn('WS2 sibling fetch', r.status); return map; } const j = await r.json(); (j.releases || []).forEach(rel => (rel.media || []).forEach(med => (med.tracks || []).forEach(t => { const title = fold(t.title || (t.recording && t.recording.title)); const ac = (t['artist-credit'] && t['artist-credit'].length) ? t['artist-credit'] : ((t.recording && t.recording['artist-credit']) || []); if (!title || map.has(title) || !ac.length || !ac.every(x => x.artist && x.artist.id)) return; map.set(title, ac.map(x => ({ gid: x.artist.id, name: x.artist.name, creditedAs: x.name || x.artist.name, joinPhrase: x.joinphrase || '' }))); }))); } catch (e) { Log.warn('sibling load failed:', e.message); } return map; } let _sibCache = { gid: undefined, map: null }; async function loadSiblingMap(force) { const rg = u(release().releaseGroup); const rgGid = rg ? u(rg.gid) : null; if (!rgGid) { Log.info('no release group linked → search-only'); return new Map(); } if (!force && _sibCache.gid === rgGid && _sibCache.map && _sibCache.map.size) return _sibCache.map; let map = new Map(); for (let i = 0; i < 3 && !map.size; i++) { if (i) await new Promise(r => setTimeout(r, 1100)); map = await fetchSiblings(rgGid); } _sibCache = { gid: rgGid, map }; if (map.size) Log.info('sibling map:', map.size, 'titles from RG', rgGid); else Log.warn('sibling map empty (RG', rgGid + ') — search only; retries on rebuild'); return map; } /* ── Discogs artist-link matching (#224) ────────────────────────────────────── * When the release carries a Discogs link, match each track artist by its * Discogs URL — a strong, human-verified signal — before the name search. * Self-contained: Apollo stays fully independent of Credit Hoarder. Gated by * the "Discogs artist link matching" option. Resolves one slot at a time * (matchModel is already sequential), waiting + retrying on rate limits. */ const DISCOGS_TOKEN = 'gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ'; // Scrape the release's Discogs link from the page — edit-relationships anchors // or the release editor's external-link inputs. First match wins (#224). let _discogsUrlLogged = false; let _discVerifyUrl = null, _lastDiscCheck = null; // dedupe the per-run "verifying…"/outcome log lines function discogsReleaseUrlFromPage() { const re = /discogs\.com\/(?:[a-z]{2}\/)?(?:[^/\s"']*\/)?release\/(\d+)/i; let url = null; for (const a of document.querySelectorAll('a[href*="discogs.com/release/"]')) { const m = re.exec(a.getAttribute('href') || ''); if (m) { url = `https://www.discogs.com/release/${m[1]}`; break; } } if (!url) for (const inp of document.querySelectorAll('input')) { const m = re.exec(inp.value || ''); if (m) { url = `https://www.discogs.com/release/${m[1]}`; break; } } if (url && !_discogsUrlLogged) { _discogsUrlLogged = true; Log.info('Discogs: release link found —', url); } return url; } // Map folded track title → array of Discogs artist www-URLs (one per credited // track artist, in order). Keyed by title like the sibling map, so it's robust // to medium/ordering differences. Cached per release link. let _discogsMap = { url: undefined, map: null }; async function loadDiscogsMap(force) { if (SETTINGS.discogsUrlMatch === false) return null; const url = discogsReleaseUrlFromPage(); if (!url) { _discogsMap = { url: null, map: null }; return null; } if (!force && _discogsMap.url === url && _discogsMap.map) return _discogsMap.map; const api = `${url.replace('https://www.discogs.com/release/', 'https://api.discogs.com/releases/')}?token=${DISCOGS_TOKEN}`; // #281: the Discogs API (one shared token) is rate-limited — a 429 used to make // the fetch return null, which was then CACHED as an empty map, so the whole // session showed no links until a full page reload. Retry with backoff, and // crucially never cache a FAILED fetch (return null → caller retries / shows a // retry affordance). Only a real JSON response is cached (success or genuine // empty), keyed by URL. for (let attempt = 0; attempt < 4; attempt++) { let r; try { r = await fetch(api); } catch (e) { Log.warn('Discogs match: fetch failed —', e.message); await _sleep(800 * (attempt + 1)); continue; } // network → retry, don't cache if (r.status === 429 || r.status === 503) { // rate limited → back off, don't cache const ra = parseInt(r.headers.get('retry-after') || '', 10); Log.warn('Discogs match: rate limited (HTTP', r.status + ') — backing off'); await _sleep(Math.max(1000, (ra > 0 ? ra : 2) * 1000)); continue; } if (!r.ok) { Log.warn('Discogs match: HTTP', r.status); await _sleep(800 * (attempt + 1)); continue; } // transient → retry, don't cache let json = null; try { json = await r.json(); } catch (e) { await _sleep(800); continue; } const map = new Map(); // #283: the release-level artist(s) (194 = Discogs "Various", skip) — used as a // fallback for tracks that credit no per-track artist (single-artist releases). map.releaseArtists = (json && Array.isArray(json.artists)) ? json.artists.filter(a => a && a.id && a.id !== 194).map(a => `https://www.discogs.com/artist/${a.id}`) : []; // #283: also keep the per-track artist URLs in Discogs order, so a track whose // TITLE doesn't match (transliteration / punctuation the fold can't catch) can // still be matched BY POSITION when the two tracklists are the same length. map.byPos = []; if (json && Array.isArray(json.tracklist)) { json.tracklist.filter(t => (t.type_ || 'track') === 'track').forEach(t => { const urls = (t.artists || []).map(a => (a && a.id) ? `https://www.discogs.com/artist/${a.id}` : null); map.byPos.push(urls); const key = fold(t.title || ''); if (!key || map.has(key)) return; map.set(key, urls); }); // this loads the Discogs release for the link CHECK (and for URL-matching unset // artists) — not re-matching already-set ones, hence "loaded" not "matched". Log.info('Discogs: loaded release —', map.size, 'track title(s)' + (map.releaseArtists.length ? ` + ${map.releaseArtists.length} release artist(s)` : ''), 'from', url); } else { Log.warn('Discogs: release JSON had no tracklist'); } _discogsMap = { url, map }; // cache ONLY a real response (success or genuine empty) return map; } Log.warn('Discogs match: could not load release JSON after retries (rate-limited?) — leaving uncached'); return null; // all retries exhausted → unknown, NOT cached, so the next call retries } // #283: a track's per-slot Discogs artist URLs — by folded title, falling back to // POSITION when the title doesn't match and the two tracklists are the same length // (so subtle title differences don't drop the track). { urls, byPos }. function discogsUrlsForTrack(dmap, title, index, total) { if (!dmap) return { urls: null, byPos: false }; const byTitle = dmap.get(fold(title)); if (byTitle) return { urls: byTitle, byPos: false }; if (dmap.byPos && dmap.byPos.length === total && total > 0) return { urls: dmap.byPos[index] || null, byPos: true }; return { urls: null, byPos: false }; } // Resolve a Discogs artist URL to MB artist(s) via its URL relationship. // Returns [{ gid, name }] (0 / 1 / many) on success, or `null` when the lookup // could not complete (rate-limited / network) — callers must treat null as // "unknown" and NOT cache it, so a transient 503 never becomes a false // negative (#227). Only successful responses are cached. const _discogsResolveCache = new Map(); const _sleep = ms => new Promise(z => setTimeout(z, ms)); // The public /ws/2 endpoint is rate-limited (~1 req/s). Serialize every call // through a gate with a minimum gap so a model full of artists doesn't trip // the limiter and get a wall of 503s (#227). let _wsGate = Promise.resolve(); let _wsLast = 0; const WS_MIN_GAP = 700; function wsGet(url) { const run = async () => { const gap = WS_MIN_GAP - (Date.now() - _wsLast); if (gap > 0) await _sleep(gap); try { return await fetch(url, { headers: { Accept: 'application/json' } }); } finally { _wsLast = Date.now(); } }; const p = _wsGate.then(run, run); _wsGate = p.then(() => {}, () => {}); // keep the chain alive regardless of outcome return p; } async function resolveByDiscogsUrl(discogsUrl, force) { if (!discogsUrl) return []; if (!force && _discogsResolveCache.has(discogsUrl)) return _discogsResolveCache.get(discogsUrl); for (let attempt = 0; attempt < 5; attempt++) { let r; try { r = await wsGet(`${ORIGIN}/ws/2/url?resource=${encodeURIComponent(discogsUrl)}&inc=artist-rels&fmt=json`); } catch (e) { await _sleep(1200); continue; } // network error → retry, don't cache if (r.status === 404) { _discogsResolveCache.set(discogsUrl, []); _dput('resolve', discogsUrl, []); return []; } // URL not in MB → 0 owners (cacheable) if (r.status === 429 || r.status === 503) { // rate limited → back off, don't cache const ra = parseInt(r.headers.get('retry-after') || '', 10); await _sleep(Math.max(1200, (ra > 0 ? ra : 1) * 1000)); continue; } if (!r.ok) { await _sleep(1200); continue; } // other transient error → retry, don't cache let j; try { j = await r.json(); } catch (e) { await _sleep(1200); continue; } const seen = new Set(); const out = (j.relations || []).filter(rel => rel.artist && rel.artist.id).map(rel => ({ gid: rel.artist.id, name: rel.artist.name })).filter(e => !seen.has(e.gid) && seen.add(e.gid)); _discogsResolveCache.set(discogsUrl, out); _dput('resolve', discogsUrl, out); // cache ONLY a successful response return out; } return null; // all retries exhausted — unknown, leave uncached so it's retried later } // slot status from a match result — 'disc' for a confident Discogs-URL match, // 'rg' for a release-group sibling, else the name-search confidence. const slotStatusOf = m => !m.entity ? 'none' : (m.source === 'rg' ? 'rg' : (m.source === 'discogs' && m.confidence === 'high') ? 'disc' : m.confidence); /* ── add / create the Discogs link for a slot (#227) ────────────────────────── * Stash the slot's Discogs artist URL and decide whether a link can be added: * - unresolved slot + known URL → create the artist seeded with the link * - matched slot + URL that has NO MB owner yet → add it to that artist * - URL already owned (by this or another artist) → nothing to add * Reuses the cached `resolveByDiscogsUrl` (no extra request during matching). */ const DISCOGS_ARTIST_LINK_TYPE = '180'; // MB Discogs artist-URL relationship (string, for seeding) const DISCOGS_LINK_TYPE_ID = 180; // numeric, for matching the js entity rels const discogsIdOf = u => (String(u).match(/\/artist\/(\d+)/) || [])[1] || null; // The Discogs URLs a MB artist links, via the INTERNAL /ws/js entity endpoint // (not rate-limited). Returns an array (possibly empty) on success, or null on // failure. Cached per gid (#227 speed: linked artists cost no /ws/2 call). const _artistRelsCache = new Map(); // #231: persist the Discogs link caches across reloads/releases so the // rate-limited /ws/2/url lookups (and the internal rels reads) aren't repeated // on F5 or when revisiting a release. 1-day TTL (links change rarely; a fresh // edit still drops the entry immediately via the #227 invalidation). Invisible // (no UI), apollo-only. Stored as { resolve: {url:{v,t}}, rels: {gid:{v,t}} }. const DCACHE_KEY = 'apollo:discogs-link-cache-v1'; const DCACHE_TTL = 24 * 60 * 60 * 1000; let _dpersist = { resolve: {}, rels: {} }; let _dsaveTimer = null; function _dsave() { if (_dsaveTimer) return; _dsaveTimer = setTimeout(() => { _dsaveTimer = null; try { localStorage.setItem(DCACHE_KEY, JSON.stringify(_dpersist)); } catch (e) {} }, 700); } function _dput(kind, key, val) { _dpersist[kind][key] = { v: val, t: Date.now() }; _dsave(); } function _ddrop(kind, key) { if (_dpersist[kind] && _dpersist[kind][key]) { delete _dpersist[kind][key]; _dsave(); } } (function _dloadPersist() { try { const o = JSON.parse(localStorage.getItem(DCACHE_KEY) || 'null'); if (!o || typeof o !== 'object') return; const now = Date.now(); let pruned = false; for (const [k, e] of Object.entries(o.resolve || {})) { if (e && now - e.t < DCACHE_TTL) { _dpersist.resolve[k] = e; _discogsResolveCache.set(k, e.v); } else pruned = true; } for (const [k, e] of Object.entries(o.rels || {})) { if (e && now - e.t < DCACHE_TTL) { _dpersist.rels[k] = e; _artistRelsCache.set(k, e.v); } else pruned = true; } if (pruned) _dsave(); // write back without the expired entries } catch (e) {} })(); async function artistDiscogsUrls(gid) { if (!gid) return []; if (_artistRelsCache.has(gid)) return _artistRelsCache.get(gid); let out = null; for (let attempt = 0; attempt < 4; attempt++) { try { const r = await fetch(`${ORIGIN}/ws/js/entity/${gid}?inc=rels`, { headers: { Accept: 'application/json' } }); if (r.status === 429 || r.status === 503) { await _sleep(800); continue; } // rate limited → back off, don't cache a miss if (!r.ok) { await _sleep(500); continue; } const j = await r.json(); out = (j.relationships || j.relations || []) .filter(x => x.linkTypeID === DISCOGS_LINK_TYPE_ID && (x.target_type === 'url' || (x.target && x.target.entityType === 'url'))) .map(x => (x.target && (x.target.name || x.target.decoded || x.target.pretty_name)) || ''); break; } catch (e) { await _sleep(500); } } if (out !== null) { _artistRelsCache.set(gid, out); _dput('rels', gid, out); } // cache only a real response (a rate-limited miss stays uncached → retried) return out; } async function tagDiscogsAddable(slot, durl) { slot._discogsUrl = durl || null; slot._discogsConflict = null; slot._discogsMismatch = null; slot._discogsPending = false; slot._discogsChecked = false; if (!durl || SETTINGS.discogsUrlMatch === false) { slot._discogsAddable = false; return; } // #281: only a RESOLVED (committed) artist can meaningfully have/lack a Discogs // link — an unresolved slot has no MB artist to link, and on add-release that // littered every row with chains/warnings. Skip those (the + create button still // seeds slot._discogsUrl, so create-with-link is unaffected). Keep _discogsUrl set. if (!slot.committed || !slot.gid) { slot._discogsAddable = false; return; } // #306 — special-purpose artists ([no artist], Various Artists, [Disney], …) must // never carry a Discogs link (some can't even be edited), so never offer to add one. if (SPECIAL_PURPOSE_ARTISTS.has(slot.gid)) { slot._discogsAddable = false; return; } slot._discogsChecked = true; if (slot.gid) { // FREE check first: this artist's own Discogs links (internal endpoint) const own = await artistDiscogsUrls(slot.gid); if (own === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } // unknown const want = discogsIdOf(durl); if (own.some(u => discogsIdOf(u) === want)) { slot._discogsAddable = false; return; } // already linked to THIS Discogs page → legit, no badge if (own.length) { // #227: artist links a DIFFERENT Discogs page than the release credits → mismatch. slot._discogsAddable = true; // still offer to add the release's link (majkinetor) — flagged with ⚠ slot._discogsMismatch = own.find(u => discogsIdOf(u)) || own[0]; return; } // /ws/js found no Discogs link — confirm with the AUTHORITATIVE URL→artist // lookup before declaring it missing. This also catches a false-empty // /ws/js response (the artist actually owns the URL) so we never offer to // add a link it already has (#227). const hits = await resolveByDiscogsUrl(durl); if (hits === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } if (hits.some(h => h.gid === slot.gid)) { slot._discogsAddable = false; return; } // this artist already owns the URL → legit slot._discogsAddable = true; if (hits.length) slot._discogsConflict = hits[0]; // URL owned by a different artist → add anyway, but warn return; } // unresolved slot — need the URL→artist lookup to decide create vs. pick const hits = await resolveByDiscogsUrl(durl); if (hits === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } slot._discogsAddable = hits.length === 0; if (hits.length) slot._discogsConflict = hits[0]; } // Tag every slot in the model — covers page-load 'set' artists, which skip the // match pass (#227). Updates each row as it resolves (real-time) and shows // progress in the always-visible toolbar status. Cached resolves are instant. // The release's Discogs link can land in the DOM a beat after we're invoked — // it lives on the Release-Information tab and switching to Tracklist first // races its render. Poll briefly (silently — blank badge, not "checking…") so // the check still runs on the FIRST visit instead of bailing to nothing. A // release that genuinely has no Discogs link just polls out and stays blank. async function discogsReleaseUrlSoon(maxMs = 8000) { const t0 = Date.now(); for (;;) { const u = discogsReleaseUrlFromPage(); if (u || Date.now() - t0 >= maxMs) return u || null; await _sleep(400); } } let _tagDiscogsRunning = false, _tagDiscogsQueued = false; let _discMapFailed = false, _discMapRetried = false; // #281: Discogs API unreachable → retry affordance async function tagDiscogsForAll() { if (!MODEL || SETTINGS.discogsUrlMatch === false) return; // #281: MB lazy-loads the rest of the tracks a beat after we start, which rebuilds // the model (fresh slots) and calls us again mid-run. The old guard DROPPED that // call, so the rebuilt model was never checked and the badge went blank until a // reload. Queue the call and re-run against the latest model when this run ends. if (_tagDiscogsRunning) { _tagDiscogsQueued = true; return; } _tagDiscogsRunning = true; try { // wait for the release Discogs link to exist before deciding there's nothing // to do — this is the #1 reason a first visit showed a blank badge. const relUrl = await discogsReleaseUrlSoon(); if (!relUrl) { _discMapFailed = false; setDiscProgress(''); return; } // genuinely no Discogs link on this release setDiscProgress('checking Discogs links…'); // now that we know there's work, show it const dmap = await loadDiscogsMap(); if (dmap === null) { // #281: couldn't reach the Discogs API (rate-limited / network) after retries. // DON'T show a misleading blank (which looked like "no links, all good") — flag // it so the badge offers a one-click retry, and auto-retry once shortly so it // usually self-heals without the user having to reload the page. _discMapFailed = true; if (!_discMapRetried) { _discMapRetried = true; setTimeout(() => { if (_discMapFailed) tagDiscogsForAll(); }, 6000); } return; } _discMapFailed = false; _discMapRetried = false; const jobs = []; const relArtists = (dmap && dmap.releaseArtists) || []; let posUsed = 0; const total = MODEL.tracks.length; if (dmap) MODEL.tracks.forEach((t, ti) => { const { urls, byPos } = discogsUrlsForTrack(dmap, t.title, ti, total); const durls = urls || []; const hasTrackArtists = durls.some(Boolean); if (byPos && hasTrackArtists) posUsed++; t.slots.forEach((s, i) => { // per-track Discogs artist (by title, else by position), else inherit the // release-level artist POSITIONALLY (#283/#287). Strictly relArtists[i]: a // single release artist only fills slot 0, NOT a track's feat. guests — // assigning the release artist to a guest slot caused false mismatches (#287). const durl = durls[i] || (!hasTrackArtists ? (relArtists[i] || null) : null); if (durl) { s._discByPos = !!(durls[i] && byPos); jobs.push([s, durl]); } }); }); if (!jobs.length) { setDiscProgress(''); // nothing to check (no per-track and no release-level artist links). Empty // model = still loading → stay silent; dedupe so re-runs don't repeat it. if (MODEL.tracks && MODEL.tracks.length && _lastDiscCheck !== 'none') { _lastDiscCheck = 'none'; Log.info('Discogs check: no artist links to verify'); } return; } const firstCheck = _discVerifyUrl !== relUrl; if (firstCheck) { _discVerifyUrl = relUrl; Log.info('Discogs check: verifying', jobs.length, 'artist link(s) across', total, total === 1 ? 'track' : 'tracks', (posUsed ? `(${posUsed} matched by position)` : '') + '…'); } let done = 0, lastRender = 0; for (const [s, durl] of jobs) { await tagDiscogsAddable(s, durl); if (firstCheck) Log.debug('Discogs:', (s.name || s.gid || 'slot'), '—', s._discogsPending ? 'pending (will re-check)' : !s._discogsAddable ? 'already linked' : discAddTooltip(s), s._discByPos ? '(matched by position)' : ''); done++; // update rows + the progress text together, throttled — set the text AFTER // rerender so refreshStatus can't blank it const now = Date.now(); if (now - lastRender > 300) { if (!isEditingNow()) rerender(); setDiscProgress(`checking Discogs links ${done}/${jobs.length}…`); lastRender = now; } } // A slot whose lookup returned null (cold cache / a transient rate-limit) is // left "pending" and never shows in the badge — that's why the FIRST visit // could read "0 links" while the SECOND (warm persistent cache) suddenly // revealed them. Retry the pending slots once now that the per-request gate // has paced out and the caches are partly warm, so the count settles on the // first visit. Anything still pending is surfaced by the badge (not hidden). const pend = jobs.filter(([s]) => s._discogsPending); if (pend.length) { let r = 0; for (const [s, durl] of pend) { setDiscProgress(`re-checking Discogs links ${++r}/${pend.length}…`); await tagDiscogsAddable(s, durl); } } const addable = jobs.filter(([s]) => s._discogsAddable).length; const pendLeft = jobs.filter(([s]) => s._discogsPending).length; const outcome = (addable === 0 ? `all ${jobs.length} artist link(s) already in MusicBrainz ✓` : `${addable} of ${jobs.length} link(s) can be added to MusicBrainz`) + (pendLeft ? ` (${pendLeft} pending)` : ''); if (outcome !== _lastDiscCheck) { _lastDiscCheck = outcome; Log.info('Discogs check:', outcome); } // dedupe identical re-run results if (!isEditingNow()) rerender(); } finally { _tagDiscogsRunning = false; // #281: if a rebuild raced us, re-run for the latest model (it repaints the // badge); otherwise settle the persistent badge now. (#227) if (_tagDiscogsQueued) { _tagDiscogsQueued = false; tagDiscogsForAll(); } else setDiscStat(); } } // chain glyph shown in place of the artist-type icon when a Discogs link can be added const DISCOGS_LINK_SVG = ''; // warning triangle shown when the Discogs URL links a DIFFERENT MB artist (#227) const DISCOGS_WARN_SVG = ''; // #—: a distinct glyph for a Discogs "mismatch" (artist links a different page) — a circle-! so it // reads apart from the "conflict" triangle at a glance (and not by colour alone). const DISCOGS_MISMATCH_SVG = ''; // after the artist's Discogs link changes (foreground return or background // postback): drop the stale caches and re-tag every slot crediting that artist. async function reTagAfterDiscogsLink(gid, url, name) { _artistRelsCache.delete(gid); _ddrop('rels', gid); _discogsResolveCache.delete(url); _ddrop('resolve', url); const own = await artistDiscogsUrls(gid); if (own && own.some(u => discogsIdOf(u) === discogsIdOf(url))) { MODEL && MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.gid === gid) s._flash = true; })); discMsg(`added Discogs link to ${name}`); } await tagDiscogsForAll(); } function addOrCreateDiscogsLink(slot, background) { const url = slot._discogsUrl; if (!url) return; if (slot.gid) { const gid = slot.gid; const p = new URLSearchParams({ 'edit-artist.url.0.text': url, 'edit-artist.url.0.link_type_id': DISCOGS_ARTIST_LINK_TYPE, 'edit-artist.edit_note': entityActionNote('Added Discogs link') }); const editUrl = `${ORIGIN}/artist/${gid}/edit?${p}`; // #273: right-click → add the link SILENTLY in a background tab + auto-submit. // The edit-page bootstrap clicks "Enter edit"; the saved entity page posts // `tc-edit-committed` back, and we re-tag + close the GM tab here (no focus // return happens for a background tab). if (background && typeof GM_openInTab === 'function' && ART_CHANNEL) { const bgTab = GM_openInTab(`${editUrl}#tc-autocommit`, { active: false, insert: true }); const onCommitted = (e) => { if (!e.data || e.data.type !== 'tc-edit-committed' || e.data.gid !== gid) return; ART_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} reTagAfterDiscogsLink(gid, url, slot.name); }; ART_CHANNEL.addEventListener('message', onCommitted); Log.info('Discogs link (background) for', slot.name || gid, '→', url); return; } // foreground: open the edit form, flag it to auto-close after submit, verify on return const tab = W.open(editUrl, '_blank'); if (tab) { const trySet = () => { try { tab.sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) { setTimeout(trySet, 50); } }; trySet(); } Log.info('Discogs link: opening edit for', slot.name || gid, '→', url); const onReturn = async () => { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onReturn); await reTagAfterDiscogsLink(gid, url, slot.name); }; document.addEventListener('visibilitychange', onReturn); } else { // no MB artist yet — create one seeded with the link (same as +) createArtist((slot.creditedAs || '').trim() || slot.name || '', slot, url, background); } } async function matchSlot(creditedAs, sib, discogsUrl) { const who = creditedAs || '(track artist)'; // #224: a Discogs artist-link match outranks the name search. if (SETTINGS.discogsUrlMatch !== false && discogsUrl) { const hits = await resolveByDiscogsUrl(discogsUrl); if (hits && hits.length === 1) { const e = await fetchEntity(hits[0].gid); if (e && e.gid) { Log.info('Match:', who, '→', e.name, '— via Discogs URL'); return { entity: e, source: 'discogs', confidence: 'high', candidates: [e] }; } } else if (hits && hits.length > 1) { // ambiguous — surface every linked artist plus the name-search hits and let the user pick const named = await searchArtist(creditedAs); const ents = []; for (const h of hits) { const e = await fetchEntity(h.gid); if (e && e.gid) ents.push(e); } const merged = [...ents, ...named.filter(c => !ents.some(e => e.gid === (c.gid || c.id)))]; if (ents.length) { Log.info('Match:', who, '—', ents.length, 'MB artists link that Discogs URL; pick one'); return { entity: ents[0], source: 'discogs', confidence: 'low', candidates: merged }; } } // couldn't resolve via the URL → fall back to name search. 0 hits = the // Discogs URL the release credits isn't linked to any MB artist (e.g. the // mismatch case); null = the lookup was rate-limited. if (hits && hits.length === 0) Log.debug('Match:', who, '— Discogs URL not linked to any MB artist → name search'); else if (hits == null) Log.debug('Match:', who, '— Discogs URL lookup unavailable (rate-limited) → name search'); } let candidates = await searchArtist(creditedAs); let entity = null, source = 'search', confidence = 'low'; if (sib && sib.gid) { // the sibling release names the EXACT artist (gid) — use it. Prefer a search hit (richer data), but // if the gid isn't in the results (ambiguous/duplicate name like "Eva", or a case-only difference // vs the recording artist), resolve the gid directly so the RG match never gets lost. let hit = candidates.find(c => c.gid === sib.gid) || (await fetchEntity(sib.gid)); if (hit && hit.gid) { entity = hit; source = 'rg'; confidence = 'high'; } } if (!entity) { const top = candidates[0] || null; if (!top) return { entity: null, source: 'none', confidence: 'none', candidates: [] }; entity = top; // an exact name match is only high-confidence (and auto-committed) when it's UNAMBIGUOUS — when // several artists share that exact name (e.g. three "Dansu"), there's no way to know which is // right, so leave it 'low' for the user to pick rather than confidently linking the first. const exact = candidates.filter(c => sameName(c.name, creditedAs)); confidence = (sameName(top.name, creditedAs) && exact.length === 1) ? 'high' : 'low'; } return { entity, source, confidence, candidates: [entity, ...candidates.filter(c => c.gid !== entity.gid)] }; } async function buildModel(onProgress) { const tl = readTracklist(); const siblings = await loadSiblingMap(); const dmap = await loadDiscogsMap(); const tracks = []; const todo = tl.filter(t => t.names.some(n => !n.artistGid)); let done = 0; for (let ti = 0; ti < tl.length; ti++) { const t = tl[ti]; const sib = siblings.get(fold(t.title)) || null; const durls = discogsUrlsForTrack(dmap, t.title, ti, tl.length).urls; // title, else by position (#283) const slots = []; for (let i = 0; i < t.names.length; i++) { const n = t.names[i]; if (n.artistGid) { slots.push({ creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: 'set', entity: null, gid: n.artistGid, name: n.artistName, candidates: [], committed: true }); } else { const m = await matchSlot(n.creditedAs, sib && sib[i], durls && durls[i]); const status = slotStatusOf(m); const slot = { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status, entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, committed: false }; await tagDiscogsAddable(slot, durls && durls[i]); // #227 slots.push(slot); } } const te = { mi: t.mi, ti: t.ti, number: t.number, title: t.title, length: t.length, slots }; te.slots.forEach(s => { s._entry = te; }); te.guessTitle = guessTitleStr(te); tracks.push(te); if (t.names.some(n => !n.artistGid)) { done++; if (onProgress) onProgress(done, todo.length); } } return { tracks }; } /* ── live commit / reset / structural ops (no apply phase — every change writes through) ── */ // write a whole track's artist credit from its slots: committed slots use the picked entity, // uncommitted ones stay as unresolved credited text. function commitTrack(entry) { const track = koTrack(entry.mi, entry.ti), live = liveNames(track); // #366 map each already-set slot to the live full entity by GID, NOT by position. Positional lookup // (live[i]) broke after a slot was removed/reordered: every later slot picked up the previous artist's // entity — resurrecting a just-removed artist under the next slot's credited-as and dropping the last. const liveByGid = new Map(); live.forEach(n => { const a = u(n.artist); const g = a && u(a.gid); if (g && !liveByGid.has(g)) liveByGid.set(g, a); }); track.artistCredit({ names: entry.slots.map(s => { if (s.status === 'set') { const a = (s.gid && liveByGid.get(s.gid)) || s.entity || { name: s.name || s.creditedAs }; return { artist: a, name: s.creditedAs, joinPhrase: s.joinPhrase }; } if (s.committed && s.entity) return { artist: s.entity, name: s.creditedAs, joinPhrase: s.joinPhrase }; return { artist: { name: s.creditedAs }, name: s.creditedAs, joinPhrase: s.joinPhrase }; }) }); } // on load, immediately write the confident matches (RG/HIGH) — that's the "no apply phase" behaviour function autoCommit() { MODEL.tracks.forEach(t => { let any = false; t.slots.forEach(s => { if (s.status === 'rg' || s.status === 'high' || s.status === 'disc') { s.committed = true; any = true; } }); if (any || t.slots.some(s => s.status === 'set')) commitTrack(t); }); } function autoCommitTrack(t) { let any = false; t.slots.forEach(s => { if (s.status === 'rg' || s.status === 'high' || s.status === 'disc') { s.committed = true; any = true; } }); if (any) commitTrack(t); } // build the table model WITHOUT matching (instant) — unresolved slots are flagged _pending function buildShell() { snapshotMissing(); // capture page-load state for any lazily-loaded medium before matching touches it // a rebuild re-reads the live model, where every linked artist looks identical — so without this we'd // collapse all match badges (rg / name / user) back to "set". Carry the match source forward by gid. const prevStatus = new Map(); if (MODEL && MODEL.tracks) MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.gid && s.committed && s.status && s.status !== 'set') prevStatus.set(s.gid, { status: s.status, entity: s.entity, candidates: s.candidates }); })); const tracks = readTracklist().map(t => { const slots = t.names.map(n => { if (!n.artistGid) return { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false, _pending: true }; const carry = prevStatus.get(n.artistGid); // preserve rg / name / user across the rebuild; genuine page-load links stay "set" return { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: carry ? carry.status : 'set', entity: carry ? carry.entity : null, gid: n.artistGid, name: n.artistName, candidates: carry ? (carry.candidates || []) : [], committed: true }; }); const te = { mi: t.mi, ti: t.ti, number: t.number, title: t.title, length: t.length, slots }; te.slots.forEach(s => { s._entry = te; }); te.guessTitle = guessTitleStr(te); return te; }); return { tracks }; } // match the _pending slots, updating the table row-by-row as results come in async function matchModel(onProgress) { const isEditing = isEditingNow; // don't rebuild rows (and orphan the search popup) while the user is in a field setMatching(true); try { const siblings = await loadSiblingMap(); const dmap = await loadDiscogsMap(); const todo = MODEL.tracks.filter(t => t.slots.some(s => s._pending)); let done = 0; const total = MODEL.tracks.length; for (let ti = 0; ti < MODEL.tracks.length; ti++) { const t = MODEL.tracks[ti]; if (!t.slots.some(s => s._pending)) continue; const sib = siblings.get(fold(t.title)) || null; const durls = discogsUrlsForTrack(dmap, t.title, ti, total).urls; // title, else by position (#283) for (let i = 0; i < t.slots.length; i++) { const s = t.slots[i]; if (!s._pending) continue; const m = await matchSlot(s.creditedAs, sib && sib[i], durls && durls[i]); Object.assign(s, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates }); delete s._pending; await tagDiscogsAddable(s, durls && durls[i]); // #227 } autoCommitTrack(t); if (!isEditing()) rerender(); done++; if (onProgress) onProgress(done, todo.length); } if (!isEditing()) rerender(); } finally { setMatching(false); refreshStatus(); } // set the final per-medium badges once the pass is done // #227: tag/resolve Discogs links AFTER the match finally (so its summary // message isn't overwritten by refreshStatus) — covers 'set' artists too. // Not awaited: tagDiscogsForAll may now poll a few seconds for the release // Discogs link to load, and we don't want that holding up the caller's // alias-enrichment. It owns the badge and updates it when it settles. tagDiscogsForAll(); } // (re-)match every still-unmatched slot — the "Match" button / used when auto-match is off async function matchAll() { if (!MODEL) return; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.status !== 'set' && !s.committed) s._pending = true; })); await matchModel((d, n) => updateStatus(`matching ${d}/${n}…`)); } // has this track changed from its page-load state (title/#/length or any artist credit)? function trackChanged(entry) { const orig = ORIGINALS.get(entry.mi + ':' + entry.ti); if (!orig) return false; const t = koTrack(entry.mi, entry.ti); if ((u(t.name) || '') !== orig.title || String(u(t.number)) !== String(orig.number) || (u(t.formattedLength) || '') !== orig.length) return true; if (entry.slots.length !== orig.names.length) return true; for (let i = 0; i < entry.slots.length; i++) { const s = entry.slots[i], o = orig.names[i]; const curGid = (s.committed && s.gid) ? s.gid : ''; const origGid = (o.artist && u(o.artist.gid)) || ''; if (curGid !== origGid || (s.creditedAs || '') !== (o.creditedAs || '') || (s.joinPhrase || '') !== (o.joinPhrase || '')) return true; } return false; } function resetTrack(entry) { const orig = ORIGINALS.get(entry.mi + ':' + entry.ti); if (!orig) return; const t = koTrack(entry.mi, entry.ti); t.artistCredit({ names: orig.names.map(o => ({ artist: o.artist, name: o.creditedAs, joinPhrase: o.joinPhrase })) }); try { t.name(orig.title); } catch (e) {} try { t.number(orig.number); } catch (e) {} try { if (typeof t.formattedLength === 'function') t.formattedLength(orig.length); } catch (e) {} Log.info('reset track', entry.number, 'to original (all cells)'); } let _selfEdit = false; // true while WE mutate the tracklist, so the change-watcher ignores it // a medium with a CD disc ID (TOC) has a fixed track count — native MB locks adding/removing/ // reordering its tracks. Mirror that so Apollo never silently corrupts the disc-ID association. #125 function mediumLocked(mi) { try { const m = mediums()[mi]; return !!(m && typeof m.hasToc === 'function' && m.hasToc()); } catch (e) { return false; } } // #329/#330: classify a track. Pregap = position 0; data track = isDataTrack(); else audio. function trackKind(entry) { try { const ko = koTrack(entry.mi, entry.ti); if (typeof ko.position === 'function' && ko.position() === 0) return 'pregap'; if (typeof ko.isDataTrack === 'function' && !!ko.isDataTrack()) return 'data'; } catch (e) {} return 'audio'; } // #329: on a disc-ID (TOC) medium the audio tracks' lengths are fixed by the TOC, so // native MB makes them read-only. Pregap + data tracks aren't covered by the audio TOC // and stay editable — mirror that for Apollo's length cells. function trackLenLocked(entry) { return mediumLocked(entry.mi) && trackKind(entry) === 'audio'; } function removeTrack(entry) { if (mediumLocked(entry.mi)) { Log.info('medium', entry.mi + 1, 'disc-ID locked — remove blocked'); return; } _selfEdit = true; try { getEditor().removeTrack(koTrack(entry.mi, entry.ti)); } finally { _selfEdit = false; } Log.info('removed track', entry.number); } function moveTrack(entry, dir) { if (mediumLocked(entry.mi)) { Log.info('medium', entry.mi + 1, 'disc-ID locked — move blocked'); return; } const ed = getEditor(); const t = koTrack(entry.mi, entry.ti); _selfEdit = true; try { (dir < 0 ? ed.moveTrackUp : ed.moveTrackDown).call(ed, t); } finally { _selfEdit = false; } } // move a track to a target index WITHIN its medium by stepping MB's own up/down ops — never touches the // model array directly, so the editor can't diverge (drag-to-reorder rides on this) function moveTrackToIndex(entry, destTi) { if (mediumLocked(entry.mi)) { Log.info('medium', entry.mi + 1, 'disc-ID locked — reorder blocked'); return false; } const ed = getEditor(), t = koTrack(entry.mi, entry.ti); const n = (u(mediums()[entry.mi].tracks) || []).length; destTi = Math.max(0, Math.min(n - 1, destTi)); let cur = entry.ti; if (cur === destTi) return false; _selfEdit = true; try { while (cur > destTi) { ed.moveTrackUp.call(ed, t); cur--; } while (cur < destTi) { ed.moveTrackDown.call(ed, t); cur++; } } catch (e) { Log.warn('move-to-index failed', e.message); } finally { _selfEdit = false; } Log.info('moved track', entry.number, 'from', entry.ti, '→', destTi, 'in medium', entry.mi + 1); return true; } // add N blank tracks to a medium by driving MB's own "Add tracks" control (the green +) function addTracks(mi, n) { if (mediumLocked(mi)) { Log.info('medium', mi + 1, 'disc-ID locked — add blocked'); return; } const btns = [...document.querySelectorAll('button[data-click="addNewTracks"]')]; const inputs = [...document.querySelectorAll('input[data-bind*="addTrackCount"]')]; const btn = btns[mi] || btns[btns.length - 1]; const inp = inputs[mi] || inputs[inputs.length - 1]; if (!btn) { Log.warn('no native add-tracks button found'); return; } const med = mediums()[mi]; const before = med ? (u(med.tracks) || []).length : 0; _selfEdit = true; try { if (inp) { inp.value = String(n); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } btn.click(); } finally { _selfEdit = false; } // MB seeds each new track with the *previous* track's artist credit — clear it so new tracks are blank if (med) { const tks = u(med.tracks) || []; for (let i = before; i < tks.length; i++) try { tks[i].artistCredit({ names: [{ artist: null, name: '', joinPhrase: '' }] }); } catch (e) {} } Log.info('added', n, 'track(s) to medium', mi + 1); // refresh immediately (blank tracks need no matching) instead of the 400ms watcher + match pass MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); } function setTitle(entry, v) { koTrack(entry.mi, entry.ti).name(v); } function setNumber(entry, v) { try { koTrack(entry.mi, entry.ti).number(v); } catch (e) { Log.warn('set number failed', v, e.message); } } function setLength(entry, v) { const t = koTrack(entry.mi, entry.ti); try { if (typeof t.formattedLength === 'function') t.formattedLength(v); else { const ed = getEditor(); const ms = ed.utils && ed.utils.unformatTrackLength ? ed.utils.unformatTrackLength(v) : null; if (ms != null && !isNaN(ms)) t.length(ms); } } catch (e) { Log.warn('set length failed', v, e.message); } } // MB guess case: preview into track.previewName (no mutation) to detect the diff; click-type to apply function guessTitleStr(entry) { const ed = getEditor(), t = koTrack(entry.mi, entry.ti); try { ed.guessCaseTrackName(t, { type: 'mouseenter', buttons: 0 }); const g = u(t.previewName); ed.guessCaseTrackName(t, { type: 'mouseleave' }); return (g == null) ? u(t.name) : g; } catch (e) { return u(t.name); } } function applyGuessTitle(entry) { try { getEditor().guessCaseTrackName(koTrack(entry.mi, entry.ti), { type: 'click' }); } catch (e) { Log.warn('guess case failed', e.message); } } // Lazily get the absolute overlay that hosts a title cell's action buttons // (Aa / ⋔), so they don't reserve flex width and shrink the input. #153 function tActions(wrap) { let a = wrap.querySelector('.t-actions'); if (!a) { a = document.createElement('span'); a.className = 't-actions'; wrap.appendChild(a); } return a; } /* ── create artist ── */ function guessSortName(name) { const n = (name || '').trim(); if (!/^[\x00-\x7F]+$/.test(n)) return n; const p = n.split(/\s+/); if (p.length < 2) return n; const last = p.pop(); return last + ', ' + p.join(' '); } // open MB's create-artist form; when it's saved, the new artist page posts the MBID back over the // channel (handshake via sessionStorage token) and closes itself, and we drop it into the slot. function createArtist(name, slot, discogsUrl, background) { let url = `${ORIGIN}/artist/create?edit-artist.name=${encodeURIComponent(name)}&edit-artist.sort_name=${encodeURIComponent(guessSortName(name))}`; // #227: seed the Discogs link so the new artist is born already linked if (discogsUrl) { url += `&edit-artist.url.0.text=${encodeURIComponent(discogsUrl)}&edit-artist.url.0.link_type_id=${DISCOGS_ARTIST_LINK_TYPE}`; _discogsResolveCache.delete(discogsUrl); } url += `&edit-artist.edit_note=${encodeURIComponent(entityActionNote('Created this artist'))}`; // proper attribution on the created artist const token = (slot && ART_CHANNEL) ? ('tc-' + Date.now() + '-' + (++_createSeq)) : null; // #273: right-click → create SILENTLY in a background tab and auto-submit. // GM_openInTab gives no tab handle to write sessionStorage on, so we carry the // postback token in the URL hash; the create-page bootstrap stores it as the // pending marker, waits for the seeded form to render, and clicks "Enter edit". // The saved /artist/ page posts the MBID back (existing handler) and we // close the GM tab here on that postback. if (background && token && typeof GM_openInTab === 'function') { const bgTab = GM_openInTab(`${url}#tc-autocommit=${encodeURIComponent(token)}`, { active: false, insert: true }); _pendingCreates.set(token, { slot, bgTab }); Log.info('create-artist (background) for', JSON.stringify(name), '— will auto-insert on save'); return; } const tab = W.open(url, '_blank'); // NOT noopener — we set a token on the new tab's sessionStorage if (tab && token) { _pendingCreates.set(token, { slot }); const trySet = () => { try { tab.sessionStorage.setItem(PENDING_KEY, token); } catch (e) { setTimeout(trySet, 50); } }; trySet(); Log.info('create-artist for', JSON.stringify(name), '— will auto-insert on save'); } else { Log.info('open MB create-artist for', JSON.stringify(name)); } } // runs on a freshly-saved /artist/ page opened by createArtist: post the MBID back, then close function handleArtistPageCallback() { const m = location.pathname.match(new RegExp('^/artist/(' + MBID_RE.source + ')', 'i')); if (!m) return false; let token = null; try { token = sessionStorage.getItem(PENDING_KEY); } catch (e) {} if (!token) return false; try { sessionStorage.removeItem(PENDING_KEY); } catch (e) {} const gid = m[1].toLowerCase(); fetchEntity(gid).then(ent => { if (ART_CHANNEL) ART_CHANNEL.postMessage({ type: 'tc-artist-created', token, gid, id: ent ? ent.id : null, name: ent ? ent.name : '' }); setTimeout(() => { try { W.close(); } catch (e) {} }, 80); }); return true; } // a tab opened just to ADD a Discogs link to an existing artist closes itself // once the edit submits and redirects to the clean /artist/ page. Guarded // to the clean entity page ($-anchored, so NOT /artist//edit) — it never // closes before the user submits. The opener re-verifies on focus return. function handleEditLinkClose() { const m = location.pathname.match(new RegExp('^/artist/(' + MBID_RE.source + ')$', 'i')); if (!m) return false; let mark = null; try { mark = sessionStorage.getItem(CLOSE_KEY); } catch (e) {} if (!mark) return false; try { sessionStorage.removeItem(CLOSE_KEY); } catch (e) {} // #273: tell the opener the link edit committed so a BACKGROUND add-link (which // never regains focus) can re-tag the slots + close this GM tab. A foreground // add-link has no listener for it — harmless there (it uses focus-return). if (ART_CHANNEL) { try { ART_CHANNEL.postMessage({ type: 'tc-edit-committed', gid: m[1].toLowerCase() }); } catch (e) {} } setTimeout(() => { try { W.close(); } catch (e) {} }, 80); return true; } // #273: on the MB create/edit form opened in a BACKGROUND tab (hash flag), store // the right marker (so the saved entity page posts back / closes), wait for the // seeded change to RENDER — the "Enter edit" button shows before MB's React // external-links editor applies the seeded URL, so an early click would submit // an empty edit — then click it. function handleAutoCommit() { const onCreate = /\/(artist|label|place)\/create\b/i.test(location.pathname); const onEdit = new RegExp('/(artist|label|place)/' + MBID_RE.source + '/edit\\b', 'i').test(location.pathname); if (!onCreate && !onEdit) return false; const hm = location.hash.match(/tc-autocommit(?:=([^&]+))?/); if (!hm) return false; if (onCreate) { let tok = ''; try { tok = decodeURIComponent(hm[1] || ''); } catch (e) { tok = hm[1] || ''; } try { sessionStorage.setItem(PENDING_KEY, tok); } catch (e) {} } else { try { sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) {} } const et = (location.pathname.match(/\/(artist|label|place)\//) || [])[1] || 'artist'; const seedUrl = new URLSearchParams(location.search).get(`edit-${et}.url.0.text`) || ''; const seedKey = seedUrl ? seedUrl.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/+$/, '').toLowerCase() : ''; let tries = 0; const submit = () => { const seedReady = !seedKey || document.body.innerHTML.toLowerCase().includes(seedKey); const btn = document.querySelector('button.submit.positive') || [...document.querySelectorAll('button[type="submit"]')].find(b => /enter edit/i.test(b.textContent || '')); if (seedReady && btn && !btn.disabled) { btn.click(); return; } if (tries++ < 100) setTimeout(submit, 200); // ~20s grace (background tabs are slower) }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', submit); else submit(); return true; } /* ════════════════════════ UI ════════════════════════ */ const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md'; const VERSION = '2026.7.9.161114'; // keep in sync with @version (fallback when GM_info is unavailable under @grant none) const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; // shared attribution header (same shape as the other scripts' edit notes) const apolloAttribution = () => { const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; return (s.name || 'Apollo Editor') + ' v' + scriptVersion() + ' by ' + (s.author || 'majkinetor') + ' - ' + (s.homepageURL || s.homepage || HELP_URL); }; // edit note for an artist Apollo creates or links from a credit slot — // attribution + what was done + which release was being edited function entityActionNote(action) { const releaseUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ''); return apolloAttribution() + '\n\n' + action + ' while editing ' + releaseUrl; } // Apollo Editor — a launching rocket in the theme purple (recreated from the requested clipart) const ICON = ''; // window // outline person / group type icons (use currentColor so they take the link colour) const PERSON_SVG = ''; const GROUP_SVG = ''; const typeSvg = c => { const t = ((c && c.typeName) || '').toLowerCase(); return (t === 'group' || t === 'orchestra' || t === 'choir') ? GROUP_SVG : PERSON_SVG; }; const JOIN_OPTIONS = [ { label: '&', value: ' & ' }, { label: ',', value: ', ' }, { label: 'feat.', value: ' feat. ' }, { label: 'ft.', value: ' ft. ' }, { label: 'featuring', value: ' featuring ' }, { label: 'and', value: ' and ' }, { label: 'vs.', value: ' vs. ' }, { label: 'x', value: ' x ' }, { label: 'with', value: ' with ' }, { label: '/', value: ' / ' }, { label: '·', value: ' · ' }, { label: 'presents', value: ' presents ' }, ]; const COLORS = { set: '#d6f0d8', rg: '#d6f0d8', high: '#d8e6ff', low: '#fdf3d0', user: '#e9dcfb', none: '#fbdcdf' }; const COLS = [{ k: 'mv', w: 32, label: '' }, { k: 'num', w: 38, label: '#' }, { k: 'title', w: 360, label: 'Title' }, { k: 'art', w: 380, label: 'Artist' }, { k: 'len', w: 52, label: 'Length' }, { k: 'badge', w: 56, label: 'Match' }]; const badgeText = s => ({ rg: 'rg', disc: 'disc', high: 'name', user: 'user', set: 'set', low: 'low' })[s.status] || ''; const colW = (k, d) => (SETTINGS.colWidths && SETTINGS.colWidths[k]) || d; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // Enter in our inputs must not bubble to MB's form (it switches tabs); commit by blurring instead const enterBlurs = el => el.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); el.blur(); } }); function rowConfidence(t) { const live = t.slots.filter(s => s.status !== 'set'); if (!live.length) return 'set'; const order = ['none', 'low', 'user', 'high', 'disc', 'rg']; return live.map(s => s.status).sort((a, b) => order.indexOf(a) - order.indexOf(b))[0]; } const badge = s => `${s === 'rg' ? 'RG' : s === 'disc' ? 'DISC' : s.toUpperCase()}`; const css = ` .tc-badge{font-size:10px;font-weight:bold;border-radius:9px;padding:1px 7px;color:#fff;white-space:nowrap} .tc-badge.rg{background:#1f8a4c}.tc-badge.set{background:#6c757d}.tc-badge.high{background:#2f6fd6}.tc-badge.disc{background:#0a7a8c} .tc-badge.low{background:#e0a800}.tc-badge.user{background:#6f42c1}.tc-badge.none{background:#c0392b} .tc-btn{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:#444} .tc-btn:hover{background:linear-gradient(#fff,#eee);border-color:#bbb} .tc-btn.primary{color:#5f3ec0;font-weight:bold}.tc-btn.primary:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:#4f33a3} .tc-tbsep{width:1px;height:18px;background:#ddd;flex:none;margin:0 2px} /* vertical divider before the Match cluster, shared by both toolbars */ .tc-btn:disabled,.tc-btn:disabled:hover{color:#aaa;background:transparent;border-color:transparent;cursor:default;font-weight:normal} .tc-btn.mini{padding:1px 6px;font-size:11px} .tc-icon{cursor:pointer;border:none;background:none;font-size:13px;padding:0 2px;color:#666} #tc-panel a,#tc-mirror-wrap a{color:#4800a0;text-decoration:none}#tc-panel a:hover,#tc-mirror-wrap a:hover{text-decoration:underline} .tc-mirror{table-layout:fixed;width:100%;border-collapse:collapse;font:13px Arial,Helvetica,sans-serif;background:#fff} /* clean "normal" look, shared with the Recordings table: light header, no column fill/borders, soft row rule */ .tc-mirror th{position:relative;background:transparent;border-bottom:1px solid #ccc;text-align:left;padding:4px 7px;font-size:11px;font-weight:bold;color:#777;overflow:hidden} .tc-mirror th:last-child{border-right:none} .tc-mirror td{padding:4px 7px;vertical-align:middle;overflow:hidden;background:#fff} .tc-mirror.gridrows td{border-bottom:1px solid #e0e0e0} /* row line BETWEEN tracks (a track is one row, never between its artists) */ .tc-mirror td.c-art{vertical-align:top;padding-top:0;padding-bottom:0} /* green matched boxes touch row-to-row (no white gap) */ .tc-mirror td.c-badge{vertical-align:top} .tc-mirror td.c-badge{position:relative;padding:0;text-align:center} .tc-mirror .tc-resizer{position:absolute;right:-1px;top:0;height:100%;width:9px;cursor:col-resize;border-right:2px solid transparent} .tc-mirror th:hover .tc-resizer,.tc-mirror .tc-resizer:hover{border-right-color:#5f3ec0} .tc-mirror .c-num{color:#888;font-variant-numeric:tabular-nums;text-align:center} .tc-mirror th.c-len{text-align:right} .tc-mirror .c-mv{white-space:nowrap;text-align:center} .tc-mirror input.t-title,.tc-mirror input.t-len,.tc-mirror input.t-num{width:100%;box-sizing:border-box;border:1px solid transparent;background:transparent;font:13px Arial;padding:3px 2px} .tc-mirror input.t-len,.tc-mirror input.t-num{text-align:right;color:#666} .tc-mirror input.t-num{text-align:center} .tc-mirror input.t-title:hover,.tc-mirror input.t-title:focus,.tc-mirror input.t-len:hover,.tc-mirror input.t-len:focus,.tc-mirror input.t-num:hover,.tc-mirror input.t-num:focus{border-color:#bbb;background:#fff} .tc-mirror input.t-len[readonly]{color:#b3b3b3;cursor:default} /* #329: TOC-fixed length — not editable */ .tc-mirror input.t-len[readonly]:hover,.tc-mirror input.t-len[readonly]:focus{border-color:transparent;background:transparent} .tc-mirror .t-wrap{display:flex;align-items:center;gap:3px;position:relative}.tc-mirror .t-wrap input.t-title{flex:1;min-width:0;width:auto} /* In-cell action buttons (Aa / ⋔) overlay the input's right edge instead of sitting in the flex flow, so they don't reserve width and shrink the input — otherwise "Fit" sizes the column to the text but the reserved button space clips long titles. #153 */ .tc-mirror .t-actions{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:3px;pointer-events:none} .tc-mirror .t-actions>*{pointer-events:auto} .tc-mirror input.t-title.diff{background:#fff6da;border-color:#e7ce8a;border-radius:3px} .tc-mirror input.t-title.gcpreview{background:#e3f6e3;border-color:#86c686;border-radius:3px} /* #203: rich title display — read-only styled text (confusable chars enlarged) shown when the title isn't being edited; clicking/tabbing into it shows the native input. Mirrors the input's diff/gcpreview/hasfeat backgrounds so the cell looks unchanged. */ .tc-mirror .t-title-disp{flex:1;min-width:0;min-height:1.55em;box-sizing:border-box;border:1px solid transparent;border-radius:3px;background:transparent;font:13px Arial;padding:3px 2px;white-space:pre;overflow:hidden;cursor:text;display:flex;align-items:center} .tc-mirror .t-title-ph{color:#b7b7b7;font-style:italic} /* #357: empty-title placeholder in the rich display */ .tc-mirror .t-title-disp:hover{border-color:#bbb;background:#fff} .tc-mirror .t-title-disp.diff{background:#fff6da;border-color:#e7ce8a} .tc-mirror .t-title-disp.gcpreview{background:#e3f6e3;border-color:#86c686} .tc-mirror .t-title-disp.hasfeat{background:#eaf1fb;border-color:#9bbbe0} .tc-mirror .t-title-disp.tc-hidden{display:none} /* #376 mirror MB's native gold "modification pending" mark — color ONLY the pending field (the title for a recording edit, the artist for an artist edit), not the whole row */ .tc-mirror tr.tc-rec-pending td.c-title .t-title-disp,.tc-mirror tr.tc-rec-pending td.c-title input.t-title{background:#ffdd99;border-color:#e5b544} .tc-mirror td.c-art .tc-search.tc-slot-pending{background:#ffdd99!important;border-color:#e5b544} /* #376 per-slot: only a selected artist with pending edits golds, never a free-text slot */ .tc-mirror.compact .t-title-disp{padding:0 2px;font-size:12px} .tc-mirror input.t-title.tc-eml:not(.tc-editing){position:absolute;width:1px;height:1px;min-width:0;padding:0;margin:0;border:0;opacity:0;pointer-events:none} /* MB medium-format select made to read as plain text — click still opens the native dropdown */ select.tc-fmt-flat{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:1px solid transparent;background:transparent;font:bold 15px Arial;color:#222;padding:2px 5px;cursor:pointer} select.tc-fmt-flat:hover{background:#efeaf9;border-color:#d7ccef;border-radius:3px} /* #154: theme the native medium header (legend · collapse · format · "Medium title" · move/remove) to match Apollo while the tracklist takeover is on. Scoped to body.tc-tl-on so the original look returns the instant you switch back to the native editor — AND every button rule is further scoped to fieldset.advanced-medium, because tc-tl-on lives on (stays on while you visit other tabs) and remove-item / guesscase-title are generic classes that also exist on the Release-information tab (external-link ✕, title Aa). Without the medium scope they leaked their glyphs onto that tab (#160). */ body.tc-tl-on fieldset.advanced-medium{border:1px solid #e7e0f5;border-radius:8px;background:#fbfaff;margin:0 0 12px;padding:3px 12px 6px} body.tc-tl-on fieldset.advanced-medium > legend{font:700 12px Arial;letter-spacing:.05em;text-transform:uppercase;color:#5f3ec0!important;padding:0 6px;margin-left:2px} body.tc-tl-on table.advanced-format{width:100%;border-collapse:collapse;margin:0} body.tc-tl-on table.advanced-format > tbody > tr > td{padding:3px 5px;vertical-align:middle;border:none} body.tc-tl-on table.advanced-format td.format{display:flex;align-items:center;gap:8px;flex-wrap:wrap} /* the flat ▸ / ▾ icon buttons (collapse, move, remove) — drop MB's sprite, draw a themed glyph */ body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down, body.tc-tl-on fieldset.advanced-medium button.icon.remove-item{background:none!important;border:none;width:30px;height:28px;padding:0;margin:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;border-radius:5px;color:#7d6bc0;font-size:18px;line-height:1} /* the triangle glyphs render small for their font-size — pump the arrows up so they read clearly (#154) */ body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium::before,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium::before, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up::before,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down::before{font-size:21px} body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium:hover,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium:hover, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up:hover,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down:hover{background:#efeaf9;color:#5f3ec0} body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium::before{content:'▸'} body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium::before{content:'▾'} body.tc-tl-on fieldset.advanced-medium button.icon.medium-up::before{content:'▴'} body.tc-tl-on fieldset.advanced-medium button.icon.medium-down::before{content:'▾'} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item{color:#cc6699;margin-left:14px!important} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item::before{content:'✕';font-weight:bold} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item:hover{background:#fbe9f1;color:#c0392b} /* "Medium title:" label + input + the Aa guess-case button */ body.tc-tl-on table.advanced-format td.format > label[for^="medium-title"]{font:12px Arial;color:#8a7bb8;margin-left:6px} body.tc-tl-on input[id^="medium-title-"]{flex:1 1 220px;min-width:200px;border:1px solid #d6cdec;border-radius:4px;padding:3px 7px;font:13px Arial;background:#fff;box-shadow:none} body.tc-tl-on input[id^="medium-title-"]:focus{border-color:#8a72c8;outline:none} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title{background:none!important;border:1px solid #d6cdec;border-radius:4px;width:auto;height:auto;min-width:0;padding:2px 7px;margin:0;cursor:pointer;color:#6f42c1;font:bold 11px Arial;line-height:1.4} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title::before{content:'Aa'} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title:hover{background:#efeaf9;border-color:#bcaae6;color:#5f3ec0} .tc-mirror .t-gc{flex:none;cursor:pointer;border:1px solid #e7ce8a;background:#fff6da;color:#8a6d00;font:bold 10px Arial;border-radius:3px;padding:1px 4px;visibility:hidden}.tc-mirror .t-gc:hover{background:#ffefb8} .tc-mirror tr:hover .t-gc{visibility:visible} .tc-mirror input.t-title.hasfeat{background:#eaf1fb;border-color:#9bbbe0;border-radius:3px} .tc-mirror .t-feat{flex:none;cursor:pointer;border:1px solid #9bbbe0;background:#eaf1fb;color:#2c5d9b;font:bold 12px Arial;border-radius:3px;padding:0 4px;line-height:16px;visibility:hidden}.tc-mirror .t-feat:hover{background:#d6e4f7} .tc-mirror tr:hover .t-feat{visibility:visible} .tc-mirror .mv{cursor:pointer;color:#6f54c0;font-size:12px;padding:0 1px} /* drag-to-reorder: ⠿ handle + drop indicators (a purple line at the row edge you'll drop against) */ .tc-mirror .tc-drag{cursor:grab;color:#b3a3dd;font-size:15px;line-height:1;padding:0 3px;user-select:none} .tc-mirror .tc-drag:hover{color:#5f3ec0}.tc-mirror .tc-drag:active{cursor:grabbing} .tc-mirror tr.tc-dragging td{opacity:.45} .tc-mirror tr.tc-drop-before td{box-shadow:inset 0 2px 0 #5f3ec0} .tc-mirror tr.tc-drop-after td{box-shadow:inset 0 -2px 0 #5f3ec0} /* alternate row colors / grid (toggled in ⚙) */ .tc-mirror.alt tbody tr:nth-child(even) td{background:#f6f4fb} .tc-mirror.gridcols td{border-right:1px solid #ededed}.tc-mirror.gridcols td:last-child{border-right:none} /* density layouts: compact (tight) · normal (default, shared with Recordings) · cozy (airy) */ .tc-mirror.cozy th{padding:7px 7px}.tc-mirror.cozy td{padding:8px 7px} .tc-mirror.compact th{padding:2px 6px} .tc-mirror.compact td{padding:0 6px} .tc-mirror.compact .tc-aslot,.tc-mirror.compact .tc-bl{height:21px} .tc-mirror.compact input.t-title,.tc-mirror.compact input.t-len,.tc-mirror.compact input.t-num{padding:0 2px;font-size:12px} .tc-mirror.compact .tc-search{padding:0 5px}.tc-mirror.compact .tc-search .nm{padding:1px 0;font-size:12px} .tc-mirror.compact .tc-cred{padding:0 4px 0 15px} /* badge column: pills per artist line; on row hover the track ↺/✕ overlay it */ .tc-bl{height:28px;box-sizing:border-box;display:flex;align-items:center;justify-content:center} .tc-trackacts{position:absolute;inset:0;display:none;align-items:center;justify-content:center;gap:10px;background:rgba(255,255,255,.93)} .tc-mirror tr:hover .tc-trackacts{display:flex} .tc-trackacts button{cursor:pointer;border:none;background:none;font-size:16px;line-height:1} .tc-trackacts .trev{color:#9a8fc0}.tc-trackacts .trev:hover{color:#5f3ec0} .tc-trackacts .rm{color:#c0392b;font-weight:bold}.tc-trackacts .rm:hover{color:#a02519} .tc-mirror tr.tc-changed td:first-child{box-shadow:inset 3px 0 0 #5f3ec0} /* a track that differs from its page-load state */ /* one artist = one aligned fixed-height line: credited-as · icon · search box · acts (no line between artists) */ .tc-aslot{display:flex;align-items:center;gap:5px;height:28px;box-sizing:border-box} .tc-cred{flex:none;width:130px;text-align:right;box-sizing:border-box;font:11px Arial;color:#1c1c1c;border:1px solid transparent;background:transparent;padding:1px 4px 1px 15px;transition:color .12s} .tc-cred::placeholder{color:#cfcfcf} .tc-credwrap{position:relative;flex:none;display:inline-flex;align-items:center} .tc-cred-clr{position:absolute;left:2px;top:50%;transform:translateY(-50%);z-index:2;display:none;border:none;background:none;color:#bbb;cursor:pointer;font-size:12px;line-height:1;padding:0} .tc-aslot.tc-has-cred:hover .tc-cred-clr{display:block} .tc-cred-clr:hover{color:#c0392b} .tc-cred:hover,.tc-cred:focus{border-color:#cdbff0;background:#fff;color:#333} .tc-aslot.tc-can-split .tc-cred{background:#fff3cf;border-color:#e7ce8a;border-radius:3px;color:#8a6d00} .tc-aslot.tc-can-split .tc-cred::placeholder{color:#caa64e} .tc-tic{flex:none;width:18px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:#6f54c0;text-decoration:none} .tc-tic.link{cursor:pointer}.tc-tic.link:hover{color:#4f2bab}.tc-tic.dim{color:#c6bbe6} .tc-tic.discogs-add{color:#0a7a8c;cursor:pointer;background:#d6eff3;border-radius:4px}.tc-tic.discogs-add:hover{color:#075e6b;background:#bfe6ed} .tc-tic.discogs-conflict{color:#c0392b;cursor:pointer;background:#fbe3e0;border-radius:4px}.tc-tic.discogs-conflict:hover{color:#96271c;background:#f6cfc9} .tc-tic.discogs-mismatch{color:#b26a00;cursor:pointer;background:#fdecc8;border-radius:4px}.tc-tic.discogs-mismatch:hover{color:#915700;background:#fbe0a8} /* one fixed-width search box per artist (so all lines align); name fills it, + + join sit at the right */ .tc-search{flex:1 1 0;min-width:0;align-self:stretch;display:flex;align-items:center;gap:4px;border:none;border-radius:4px;background:#fff;padding:0 6px;overflow:hidden;transition:box-shadow .12s} /* unmatched = plain white; the green fill marks a match. transition: fade the #284 hover-highlight ring in/out */ .tc-nm-clr{flex:none;display:none;align-items:center;border:none;background:none;color:#bbb;cursor:pointer;font-size:16px;line-height:1;padding:0 2px;visibility:hidden} /* a little bigger */ .tc-search.tc-has-nm .tc-nm-clr{display:inline-flex} /* a filled slot always reserves the × slot (invisible) → hover doesn't shift the text */ .tc-search.tc-has-nm:hover .tc-nm-clr{visibility:visible} .tc-search.tc-has-nm .nm:not(:focus){flex:0 1 auto} /* when not editing, size the input to the name (its size attr) so the × sits right after it. Focused stays full-width for comfortable typing */ .tc-nm-clr:hover{color:#c0392b} .tc-search:focus-within{box-shadow:inset 0 0 0 1px #b9a4e0} .tc-search.matched{background:#e3f4e7} /* #284: hover-highlight every instance of the same artist (ring keeps the underlying matched/unmatched colour) */ /* #284 hover-highlight: a soft 1px ring + faint inset wash (box-shadow, so it doesn't disturb the matched-green BACKGROUND), and no bold (which reflowed → the "jump"). The base .tc-search/.tc-cred carry a short transition so it fades in AND out. */ .tc-aslot.tc-arthl .tc-search{box-shadow:inset 0 0 0 1px #b79ee8, inset 0 0 0 100px rgba(123,79,208,.06)} .tc-aslot.tc-arthl .tc-cred{color:#6a4fb0} /* "Alternate row colors": tint the matched box a touch deeper on every other track (per row, so a multi-artist group stays one shade) — the only way the banding shows through the green fill */ .tc-mirror.alt tbody tr:nth-child(even) .tc-search.matched{background:#d6ecdd} /* group ALL of a track's artist boxes under ONE border: collapse adjacent boxes, round only the outer corners; the inner borders become subtle dividers between the individual artists. #119 */ .tc-mirror td.c-art .tc-aslot .tc-search{border-radius:0} .tc-mirror td.c-art .tc-aslot:first-child .tc-search{border-top-left-radius:4px;border-top-right-radius:4px} .tc-mirror td.c-art .tc-aslot:last-child .tc-search{border-bottom-left-radius:4px;border-bottom-right-radius:4px} .tc-mirror td.c-art .tc-aslot:not(:first-child) .tc-search{border-top:none} .tc-mirror td.c-art .tc-aslot:not(:last-child) .tc-search{border-bottom:none} /* no horizontal line between a track's artists — one seamless box */ /* split/multi-artist cue: a faint purple left stripe on tracks that have 2+ artists */ .tc-mirror td.c-art:has(.tc-aslot ~ .tc-aslot){box-shadow:inset 2px 0 0 #d8cbf0} @keyframes tcflash{0%{box-shadow:0 0 0 3px #e0a800}70%{box-shadow:0 0 0 3px #e0a800}100%{box-shadow:0 0 0 0 rgba(224,168,0,0)}} .tc-search.tc-flash{animation:tcflash 1.5s ease-out} .tc-search.tc-marked{border:2px solid #e0a800} /* persists when a pick changed several tracks */ .tc-search .nm{flex:1 1 0;min-width:0;border:none;background:transparent;font:13px Arial;padding:3px 0;outline:none} .tc-search .tc-bar-aka{flex:0 1 auto;min-width:0;max-width:55%;margin-left:2px;color:#9bb8a8;font-size:11px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} .tc-search .tc-bar-disamb{flex:0 1 auto;min-width:0;max-width:55%;margin-left:4px;color:#999;font-size:11px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} /* artist disambiguation, grey like native #195 */ .tc-search .mk{flex:none;order:9;cursor:pointer;border:none;background:none;color:#1f8a4c;font-weight:bold;font-size:15px;line-height:1;padding:0 2px}.tc-search .mk:hover{color:#136b39} /* order:9 keeps + pinned at the far right, past the join-phrase */ /* push + to the right edge only when there's NO join-phrase; with a join, .tc-joinwrap's margin-left:auto already pushes the right-group and + (order:9) follows it (two auto-margins would split the gap). #298 follow-up */ .tc-search:not(:has(.tc-joinwrap)) .mk{margin-left:auto} .tc-joinwrap{flex:none;margin-left:auto;display:flex;align-items:center;gap:0} .tc-join{width:auto;text-align:right;border:1px solid transparent;background:transparent;color:#777;font:italic 900 12px Arial;padding:1px 2px;border-radius:3px} .tc-join:hover,.tc-join:focus{border-color:#bcdcc6;background:#fff;color:#444} .tc-joinarrow{cursor:pointer;border:none;background:none;color:#9a8fc0;font-size:10px;padding:0 1px;line-height:1}.tc-joinarrow:hover{color:#5f3ec0} /* #208 join-phrase spacing flags: ␣ where a space is missing, ␣?␣ when the phrase is missing entirely */ .tc-joinwrap.tc-jp-bad .tc-join{border-color:var(--tc-hl,#e53935);background:#fff0f0;color:#b00} .tc-jp-nolead::before,.tc-jp-notrail::after,.tc-jp-nophrase::before{background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px;font:700 11px Arial;line-height:1} .tc-jp-nolead::before{content:'␣'} .tc-jp-notrail::after{content:'␣'} .tc-jp-nophrase::before{content:'␣?␣'} .tc-joinpop .tc-acrow{justify-content:space-between;gap:14px}.tc-joinpop .cmt{color:#999} .tc-acts{flex:none;width:76px;display:flex;align-items:center;justify-content:flex-start;gap:4px;padding-left:4px} .tc-enter,.tc-slotx,.tc-splitb,.tc-slotgrab{cursor:pointer;border:none;background:none;padding:0 1px;visibility:hidden;line-height:1} .tc-enter{color:#7d6bc0;font-size:19px}.tc-enter:hover{color:#5f3ec0} .tc-splitb{color:#7d6bc0;font-size:16px;font-weight:bold}.tc-splitb:hover{color:#5f3ec0} .tc-aslot:not(.tc-can-split) .tc-splitb{display:none} .tc-slotgrab{cursor:grab;color:#9a8fb5;font-size:13px;user-select:none}.tc-slotgrab:hover{color:#5f3ec0}.tc-slotgrab:active{cursor:grabbing} /* #150: drag to reorder this artist within the credit */ .tc-aslot.tc-slotdragging{opacity:.45} .tc-aslot.tc-slotdrop-before{box-shadow:inset 0 2px 0 #5f3ec0}.tc-aslot.tc-slotdrop-after{box-shadow:inset 0 -2px 0 #5f3ec0} .tc-slotx{color:#cc6699;font-size:13px}.tc-slotx:hover{color:#c0392b} .tc-mirror tr:hover .tc-enter,.tc-mirror tr:hover .tc-slotx,.tc-mirror tr:hover .tc-splitb,.tc-mirror tr:hover .tc-slotgrab{visibility:visible} .tc-acpop{position:fixed;z-index:100002;background:#fff;border:1px solid #b9a4e0;border-radius:4px;box-shadow:0 6px 22px rgba(40,20,80,.3);max-height:300px;overflow:auto;font:12px Arial;min-width:210px} .tc-acrow{display:flex;align-items:center;gap:7px;padding:4px 9px;cursor:pointer} .tc-acrow:hover,.tc-acrow.hi{background:#ede9f6} .tc-acrow .tic{flex:none;width:17px;display:inline-flex;align-items:center;justify-content:center;color:#6f54c0} .tc-acrow .nm{font-weight:600;color:#222}.tc-acrow .cmt{color:#888;font-size:11px} .tc-acrow .tc-aka{color:#9bb8a8;font-size:11px;font-style:italic} .tc-acrow.none{color:#888;font-style:italic;cursor:default} .tc-acmore{justify-content:center;font-style:italic;color:#6f54c0;border-top:1px solid #e3dcf2;position:sticky;bottom:0;background:#faf8ff} .tc-acrow.exact{background:#dff3e5}.tc-acrow.exact .nm{color:#136b39}.tc-acrow.exact:hover,.tc-acrow.exact.hi{background:#cfeed9} .tc-toolbar{padding:5px 4px;font-size:12px;color:#555;display:flex;align-items:center;gap:6px} .tc-toolbar select{font:12px Arial;padding:1px} .tc-medhdr{background:#dfd7f0;font-weight:bold;color:#4b3a82;padding:4px 8px} #tc-panel{position:fixed;top:90px;right:18px;width:720px;max-width:96vw;max-height:84vh;background:#fff; border:1px solid #b9a4e0;border-radius:6px;box-shadow:0 8px 34px rgba(40,20,80,.32);z-index:99999; display:flex;flex-direction:column;font:13px/1.4 Arial,Helvetica,sans-serif;color:#1c1c1c} #tc-hdr{display:flex;align-items:center;gap:8px;padding:8px 11px;background:#ede9f6;border-bottom:1px solid #d7ccef;border-radius:6px 6px 0 0;cursor:move;user-select:none} #tc-hdr b{flex:1;color:#563b8f;font-size:14px}#tc-hdr .meta{font-size:12px;color:#6b6b6b} #tc-body{flex:1;overflow:auto} #tc-foot{display:flex;align-items:center;gap:8px;padding:8px 11px;border-top:1px solid #d7ccef;background:#f6f4fb;border-radius:0 0 6px 6px} #tc-foot .sp{flex:1} /* the global toolbar stays pinned at the top while scrolling the tracklist */ #tc-mirror-wrap{margin:4px 0 6px;position:sticky;top:0;z-index:50;background:#fff;border-bottom:1px solid #e3dcf2;box-shadow:0 3px 8px rgba(40,20,80,.07)} .tc-medsec{margin:2px 0 14px} #tc-bar{display:flex;align-items:center;gap:8px;padding:6px 4px;flex-wrap:nowrap;min-width:0} /* #280: never wrap → toast / "added link" text can't push the toolbar taller */ #tc-bar b{color:#563b8f}#tc-bar .sp{flex:1 1 0;min-width:0} #tc-bar > .tc-btn{flex:none;white-space:nowrap} /* #280: Match / ▾ never shrink → never wrap to 2 lines when toast text appears */ .tc-toast{flex:0 1 auto;min-width:0;color:#5f3ec0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:center} /* #280: shrink (ellipsis) instead of forcing a wrap */ .tc-globalstat{flex:none;font-size:12px;color:#999;font-style:italic;white-space:nowrap} .tc-am-lbl{flex:none;display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#555;white-space:nowrap}.tc-am-lbl select{font:12px Arial;padding:1px 3px} .tc-globalstat.tc-unres{font-style:normal;font-weight:bold;color:#fff;background:#d6342c;padding:1px 8px;border-radius:9px} /* #227: persistent "N missing Discogs links" badge (teal, like the DISC match badge) */ .tc-disc-msg{flex:0 1 auto;min-width:0;font-size:12px;font-weight:600;color:#5f3ec0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:8px} .tc-discstat{flex:none;font-size:12px;color:#999;font-style:italic;white-space:nowrap} .tc-discstat.tc-disc-badge{font-style:normal;font-weight:bold;color:#fff;background:#0a7a8c;padding:1px 8px;border-radius:9px} .tc-discstat.tc-disc-ok{font-style:normal;font-weight:bold;color:#1a7a3c} .tc-discstat.tc-disc-pend{font-style:normal;font-weight:bold;color:#fff;background:#c98a00;padding:1px 8px;border-radius:9px} .tc-tablewrap{overflow-x:auto} .tc-addrow{padding:8px 4px;font-size:13px;color:#555;display:flex;align-items:center;gap:6px} .tc-addrow input.tc-addn{width:54px;font:13px Arial;padding:2px 4px;border:1px solid #bbb;border-radius:3px} .tc-addbtn{width:22px;height:22px;border-radius:50%;border:1px solid #d6cdec;background:transparent;color:#9a8fc0;font:bold 15px/1 Arial;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} .tc-addbtn:hover{background:#f0ecfa;color:#6f42c1;border-color:#b9a4e0} /* #330: pregap/data-track toggles + row markers */ .tc-medopts{display:inline-flex;align-items:center;gap:12px;margin-left:14px} .tc-medopt{display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#666;cursor:pointer;white-space:nowrap} .tc-medopt:has(input:disabled){color:#b0b0b0;cursor:default} /* #330: disc-ID medium — toggle shown but locked */ .tc-trkkind{flex:none;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#5a7a45;background:#eef6e8;border:1px solid #cfe3bf;border-radius:8px;padding:0 5px;margin-right:5px} .tc-mirror tr.tc-row-pregap td,.tc-mirror tr.tc-row-data td{background:#f7f5fb} .tc-mirror tr.tc-datadiv td{background:#efeaf9;color:#5b4a86;font-size:11px;font-weight:600;letter-spacing:.04em;padding:3px 8px} .tc-mirror th .tc-hstatus{font-weight:normal;font-style:italic;color:#999;margin-left:12px;font-size:11px} .tc-mirror th .tc-hstatus.tc-unres{font-style:normal;font-weight:bold;color:#fff;background:#d6342c;padding:1px 7px;border-radius:9px;font-size:11px} .tc-mirror th .tc-hdr-am{float:right;font-weight:normal;font-style:normal;font-size:11px;color:#444;margin-right:14px;max-width:140px} .tc-tools{display:flex;align-items:center;gap:6px 8px;flex:1 1 auto;min-width:0;flex-wrap:wrap} /* #280: label + tools wrap together; wrapped rows start at the left (under the label) */ .tc-toolslabel{flex:none;font:800 11px Arial;letter-spacing:.03em;text-transform:uppercase;color:#6f42c1;cursor:pointer;white-space:nowrap;border-bottom:1px dotted #b9a4e0;line-height:1.5} .tc-toolslabel:hover{color:#4b2e83} .tc-toolbtns{display:contents} /* #280: its buttons/groups are direct flex items of .tc-tools so they wrap to the left edge */ .tc-toolbtn{display:inline-flex;align-items:center;gap:5px;border:1px solid #d8d0ec;background:linear-gradient(#fff,#f4f1fb);border-radius:5px;padding:4px 9px;font:13px Arial;color:#4a4a4a;cursor:pointer;white-space:nowrap;flex:none} .tc-toolbtn:hover{border-color:#bcaae6;background:#f3eefe} .tc-toolbtn.active{background:#5f3ec0;border-color:#4f33a3;color:#fff;font-weight:600} .tc-toolbtn .tc-tbic{font-size:13px;line-height:1} .tc-icimg{height:14px;width:auto;vertical-align:-2px;display:inline-block} /* image-based tool icon (bridged tools can ship their own) */ .tc-mi-ic .tc-icimg{height:15px} .tc-tc-ic .tc-icimg{height:16px} .tc-tc-seg.cb-icon .tc-icimg{height:13px} .tc-toolbtn.instant .tc-tbic{color:#2e9b57} .tc-toolbtn.active .tc-tbic{color:#fff} .tc-toolbtn.tc-more{font-weight:bold;color:#7a68b8;border-style:dashed} .tc-toolopts{display:flex;align-items:center;gap:6px;min-width:0} .tc-gco,.tc-sro,.tc-colso,.tc-medo{display:flex;align-items:center;gap:8px} .tc-colso{gap:4px} .tc-colbtn{font:12px Arial;padding:2px 9px;border:1px solid #bbb;border-radius:4px;background:#fff;cursor:pointer;color:#333} .tc-colbtn:hover{background:#f0ecfa;border-color:#a98fe0} /* #152/#375: Search & Replace — RE toggle, search caret, invalid-regex flag, Saved & History popup */ .tc-srbtn{cursor:pointer;border:1px solid #d6cdec;background:#fff;color:#6f42c1;font:bold 11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap} .tc-srbtn:hover{background:#efeaf9;border-color:#bcaae6} .tc-srbtn.on{background:#6f42c1;color:#fff;border-color:#5f3ec0} .tc-sr-find.tc-sr-bad{border-color:#d6342c!important;background:#fff1f0} .tc-sr-star{color:#e0a800;font-size:13px;line-height:1;padding:3px 8px} /* #375 fav-star button opens Saved & History */ .tc-sr-star:hover{color:#c69500;background:#fff8e6;border-color:#e6cf8a} .tc-srtpl{position:fixed;z-index:100003;background:#fff;border:1px solid #b9a4e0;border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:#1c1c1c;min-width:460px;max-width:680px;max-height:70vh;overflow:auto} .tc-srtpl-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid #ece7f6;position:sticky;top:0;background:#fff;z-index:1} .tc-srtpl-hdt{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:#5f3ec0} .tc-srtpl-savebtn{margin-left:auto;cursor:pointer;border:1px solid #cdeccd;background:#f2fbf2;color:#2e7d32;font:bold 11px Arial;border-radius:4px;padding:3px 9px;white-space:nowrap} .tc-srtpl-savebtn:hover{background:#e6f6e6;border-color:#a9dca9} .tc-srtpl-savewrap{display:flex;align-items:center;gap:6px;margin-left:auto;flex:1} .tc-srtpl-saveok{cursor:pointer;border:1px solid #cdeccd;background:#f2fbf2;color:#2e7d32;font:bold 12px Arial;border-radius:4px;padding:2px 8px} .tc-srtpl-saveok:hover{background:#e6f6e6} .tc-srtpl-empty{padding:12px;color:#999;font-style:italic} .tc-srtpl-sec{font:700 10px Arial;letter-spacing:.05em;text-transform:uppercase;color:#9a8fb5;background:#faf8ff;padding:5px 12px;border-top:1px solid #ece7f6;border-bottom:1px solid #f0ebfa} .tc-srtpl-row{display:grid;grid-template-columns:1.1fr 1.5fr 1.5fr 26px 18px;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;border-bottom:1px solid #f4f0fc} .tc-srtpl-row:hover,.tc-srtpl-row.tc-srtpl-sel{background:#f0ebfb} .tc-srtpl-nm{font-weight:600;color:#4b3a82;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-srtpl-f,.tc-srtpl-r{font-family:ui-monospace,Consolas,'Liberation Mono',Menlo,monospace;color:#555;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* #375: curly quotes render cleanly (Courier New mangled them) */ .tc-srtpl-re{font:bold 10px Arial;color:#6f42c1;text-align:center} .tc-srtpl-tail{text-align:center} .tc-srtpl-x{visibility:hidden;border:none;background:none;color:#cc6699;cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-x,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-x{visibility:visible}.tc-srtpl-x:hover{color:#c0392b} .tc-srtpl-name{flex:1;min-width:120px;box-sizing:border-box;border:1px solid #d6cdec;border-radius:4px;padding:3px 7px;font:13px Arial} .tc-srtpl-name:focus{border-color:#8a72c8;outline:none} .tc-toolopts label,.tc-opt label{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:#555} .tc-toolopts input[type=text],.tc-opt input[type=text]{font:12px Arial;padding:2px 5px;border:1px solid #bbb;border-radius:3px;width:120px} .tc-toolopts input[type=text]::placeholder,.tc-opt input[type=text]::placeholder{color:#c2c2c2} .tc-toolopts select,.tc-opt select{font:12px Arial;padding:1px} /* #280 — pinned tools' params on the 2nd toolbar row (scrolls if they overflow) */ .tc-bar2{display:flex;align-items:center;gap:10px;padding:2px 4px 7px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:thin} .tc-opt{display:inline-flex;align-items:center;gap:7px;background:#faf8fe;border:1px solid #ece5f8;border-radius:7px;padding:3px 9px;flex:none} .tc-optname{font:700 12px Arial;color:#5f3ec0;display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .tc-opttrig{cursor:pointer;border-radius:5px;padding:2px 5px;border:1px solid transparent} .tc-opttrig:hover{background:#efe7fb;border-color:#dccff5} .tc-opt .tc-tbic{font-size:13px} /* #280 — right-click a tool name to collapse its params; they flyout on hover/focus */ .tc-opt.tc-collapsed{position:relative} .tc-opt.tc-collapsed > .tc-optname{border-bottom:1px dotted #b6a3e6} /* flyout TOUCHES the name (top:100% over the group's padding-bottom) so the hover area is contiguous — no dead gap that would drop :hover before you reach it */ .tc-opt.tc-collapsed > :not(.tc-optname){display:none;position:absolute;top:100%;left:-1px;z-index:100000;background:#fff;border:1px solid #b9a4e0;border-radius:0 7px 7px 7px;box-shadow:0 6px 22px rgba(40,20,80,.3);padding:7px 10px} .tc-opt.tc-collapsed:hover > :not(.tc-optname),.tc-opt.tc-collapsed:focus-within > :not(.tc-optname){display:flex} /* #280 — Customize tools popover */ .tc-toolcfg{position:fixed;z-index:100002;background:#fff;border:1px solid #b9a4e0;border-radius:9px;box-shadow:0 10px 30px rgba(40,20,80,.32);padding:8px 0 4px;font:13px Arial;width:340px} .tc-tc-h{font-weight:800;color:#4b2e83;padding:3px 14px 8px} .tc-tc-list{max-height:62vh;overflow:auto} .tc-tc-row{display:flex;align-items:center;gap:8px;padding:5px 12px} .tc-tc-row:hover{background:#f6f2fd} .tc-tc-row.off{opacity:.6} .tc-tc-row.over-bottom{box-shadow:inset 0 -2px 0 #8a72c8} .tc-tc-row.over-top{box-shadow:inset 0 2px 0 #8a72c8} .tc-tc-row.drag{opacity:.4} .tc-tc-grab{color:#b3a3dd;cursor:grab;font-size:15px;line-height:1;flex:none;user-select:none} /* same ⠿ grip as the tracklist drag handle (.tc-drag) */ .tc-tc-grab:hover{color:#5f3ec0}.tc-tc-grab:active{cursor:grabbing} .tc-tc-ic{width:26px;text-align:center;color:#7a68b8;flex:none} .tc-tc-lab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-tc-onbar{display:inline-flex;flex:none}.tc-tc-onbar input{accent-color:#6f42c1} .tc-tc-dens{display:inline-flex;flex:none;border:1px solid #d6cdec;border-radius:5px;overflow:hidden} .tc-tc-seg{flex:none;width:30px;box-sizing:border-box;border:none;border-left:1px solid #e6ddf3;background:#fff;color:#a99fc4;font:bold 12px Arial;height:22px;padding:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} /* #280: fixed width → the [icon|T] groups line up across rows */ .tc-tc-seg:first-child{border-left:none} .tc-tc-seg:hover{background:#f3eefe} .tc-tc-seg.on{background:#6f42c1;color:#fff} .tc-tc-seg.on:hover{background:#5f3ec0} .tc-tc-pin{flex:none;box-sizing:border-box;width:22px;text-align:center;border:none;background:none;cursor:pointer;font-size:13px;line-height:1;filter:grayscale(1);opacity:.35;padding:1px 0;display:inline-flex;align-items:center;justify-content:center} /* fixed width so the column aligns even when a tool has no pin */ .tc-tc-pin.on{filter:none;opacity:1} .tc-tc-pin.none{visibility:hidden} .tc-tc-hint{font-size:11px;color:#999;padding:8px 14px 2px;border-top:1px solid #f0ebfa;margin-top:5px} .tc-mi-ic{flex:0 0 auto;display:inline-flex;justify-content:center;min-width:18px;text-align:center;color:#7a68b8;margin-right:8px;white-space:nowrap} .tc-menu{position:fixed;z-index:100001;background:#fff;border:1px solid #b9a4e0;border-radius:6px;box-shadow:0 6px 22px rgba(40,20,80,.3);padding:4px 0;font:13px Arial;min-width:170px} .tc-menu.tc-mini{width:max-content;max-width:260px}.tc-menu.tc-mini .tc-mi{white-space:nowrap} .tc-menu .tc-mi{display:flex;align-items:center;padding:6px 15px;cursor:pointer;color:#333;font-weight:bold}.tc-menu .tc-mi:hover{background:#ede9f6;color:#4b2e83} .tc-menu .tc-sep{border-top:1px solid #e6e0f2;margin:4px 0} .tc-settings label{display:flex;gap:8px;align-items:center;margin:7px 0;color:#333}.tc-settings label.opt{font-size:12px} .tc-settings input[type=text],.tc-settings #tc-sr-find,.tc-settings #tc-sr-rep{font:13px Arial;padding:3px 5px;border:1px solid #bbb;border-radius:3px} .tc-settings .srrow{display:flex;align-items:center;gap:8px;margin-top:8px}.tc-settings .srrow span{flex:1;color:#777;font-size:12px} @keyframes tctitleflash{0%{background:#fff3b0}100%{background:transparent}} .tc-mirror input.t-title.srflash{animation:tctitleflash 1.8s ease-out} #tc-settings{position:fixed;z-index:100001;background:#fff;border:1px solid #b9a4e0;border-radius:6px;box-shadow:0 6px 24px rgba(40,20,80,.3);padding:11px 13px;font:13px Arial;color:#222;width:340px} #tc-settings h4{display:flex;align-items:center;gap:6px;margin:0 0 9px;padding-bottom:8px;border-bottom:1px solid #e3dcf2;color:#563b8f;font-size:13px} #tc-settings h4 .tc-ver{font-size:11px;font-weight:normal;color:#999} #tc-settings h4{flex-wrap:wrap} #tc-settings h4 .tc-help{margin-left:8px;flex:none;white-space:nowrap;font-size:12px;font-weight:normal;text-decoration:none;color:#5f3ec0;border:1px solid #c9b8ee;border-radius:4px;padding:1px 8px} #tc-settings h4 .tc-help:hover{background:#f0ecfa} #tc-settings h4 .tc-logbtn{margin-left:auto;flex:none;font:normal 12px Arial;color:#5f3ec0;background:none;border:1px solid transparent;border-radius:4px;padding:1px 8px;cursor:pointer} #tc-settings h4 .tc-logbtn:hover{background:#f3eefb;border-color:#c9b8ee} /* #283 activity-log popup */ /* floating, movable, NON-modal window (no backdrop) */ #tc-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:100002;display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:#fff;border:1px solid #b9a4e0;border-radius:11px;box-shadow:0 12px 40px rgba(40,20,80,.4);font:13px Arial;color:#222;overflow:hidden} #tc-logpop .tc-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid #e3dcf2;color:#563b8f;cursor:move;user-select:none} #tc-logpop .tc-log-m a{color:#5f3ec0} #tc-logpop .tc-logpop-sp{margin-left:auto} #tc-logpop .tc-logpop-copy,#tc-logpop .tc-logpop-x,#tc-logpop .tc-logpop-min{font:12px Arial;color:#5f3ec0;background:#f3eefb;border:1px solid #c9b8ee;border-radius:5px;padding:2px 9px;cursor:pointer} #tc-logpop .tc-logpop-copy:hover,#tc-logpop .tc-logpop-x:hover,#tc-logpop .tc-logpop-min:hover{background:#e9e0f8} #tc-logpop.min .tc-log-list,#tc-logpop.min .tc-logpop-copy,#tc-logpop.min .tc-logpop-x{display:none} #tc-logpop.min{max-height:none;width:auto} #tc-logpop.min .tc-logpop-sp{display:none} #tc-logpop .tc-log-badge{color:#9a8cba;font-size:11px} #tc-logpop .tc-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;background:#faf8fe;padding:8px 11px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.55} #tc-logpop .tc-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word} #tc-logpop .tc-log-t{color:#a99fc2;flex:0 0 auto} #tc-logpop .tc-log-m{flex:1 1 auto;color:#444} #tc-logpop .tc-log-ok .tc-log-m{color:#2e7d4f} #tc-logpop .tc-log-warn .tc-log-m{color:#b06a00} #tc-logpop .tc-log-error .tc-log-m{color:#c0344d} #tc-logpop .tc-log-debug{opacity:.72} #tc-logpop .tc-log-debug .tc-log-m{color:#7a7a7a} #tc-logpop .tc-log-empty{color:#9a8cba} #tc-settings label{display:flex;gap:8px;align-items:center;margin:7px 0;color:#333} #tc-settings label input[type=checkbox]{margin:0;flex:none} #tc-settings .hint{color:#777;font-size:11px;margin:0 0 4px 24px} #tc-settings .tc-s-sec{font-weight:bold;color:#333;margin:12px 0 5px} #tc-settings .tc-s-group{padding-left:8px} #tc-settings .tc-s-top{padding-left:0;margin-top:2px} #tc-settings .tc-s-sub{font-weight:bold;color:#444;margin:0} #tc-settings div.tc-s-sub{margin:8px 0 3px} #tc-settings .tc-s-row{display:flex;align-items:center;gap:12px;margin:7px 0;color:#333} #tc-settings .tc-s-rad{display:inline-flex;align-items:center;gap:4px;margin:0;font-weight:normal;cursor:pointer} #tc-settings .tc-s-row input[type=radio]{margin:0} #tc-settings #tc-s-lentol,#tc-settings #tc-s-titletol,#tc-settings #tc-s-punctsize{width:48px;font:13px Arial;padding:2px 5px;border:1px solid #bbb;border-radius:3px} #tc-settings #tc-s-hlcolor{width:34px;height:22px;padding:0;border:1px solid #bbb;border-radius:3px;cursor:pointer;background:none} /* #294 tabbed config: one short pane at a time instead of one tall column */ #tc-settings .tc-tabs{display:flex;gap:2px;margin:0 0 8px;border-bottom:1px solid #e3dcf2} #tc-settings .tc-tab-btn{flex:1;font:600 12px Arial;color:#888;background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-1px;padding:5px 4px 7px;cursor:pointer} #tc-settings .tc-tab-btn:hover{color:#5f3ec0} #tc-settings .tc-tab-btn.active{color:#563b8f;border-bottom-color:#7d4fd0} #tc-settings .tc-tab-pane[hidden]{display:none} #tc-settings .tc-tab-pane .tc-s-top{margin-top:0} #tc-settings .tc-s-row.lentol{gap:7px} #tc-launch{position:fixed;bottom:14px;right:14px;z-index:99998;display:inline-flex;align-items:stretch;background:#5f3ec0;color:#fff;border-radius:20px;font:bold 13px Arial;box-shadow:0 3px 12px rgba(40,20,80,.3);overflow:hidden} #tc-launch .tc-launch-lbl{padding:8px 13px;cursor:pointer} #tc-launch .tc-launch-lbl:hover{background:rgba(255,255,255,.13)} #tc-launch .tc-launch-gear{padding:8px 11px;cursor:pointer;font-size:14px;display:flex;align-items:center;border-left:1px solid rgba(255,255,255,.28)} #tc-launch .tc-launch-gear:hover{background:rgba(255,255,255,.13)} #tc-btn,#tc-gear-btn{vertical-align:middle} /* ────────────────────────────────────────────────────────────────── MOBILE / NARROW VIEWPORTS — Tracklist MusicBrainz serves width=device-width, so the fixed-layout mirror (Title 360px + #/len/badge ≈ 540px of fixed columns) overflows the viewport and collapses the flexible Artist column to a few pixels — the artist search box becomes unusable. Below ~700px each track becomes a full-width card: #/title/length/match on the top line and the artist credit(s) spanning the FULL width beneath. Hover-only controls are revealed (touch screens have no hover). 820px covers phones and small tablets (the artist column only gets comfortable above ~1000px desktop width). ────────────────────────────────────────────────────────────────── */ @media (max-width: 820px) { /* tools bar: wrap so "⚡ Match" never gets pushed off the edge */ #tc-bar{flex-wrap:wrap;gap:6px 8px} #tc-bar > .sp{flex-basis:100%;height:0} /* the spacer forces the Match cluster onto its own line */ .tc-am-lbl{white-space:normal} .tc-tablewrap{overflow-x:visible} .tc-mirror{display:block} .tc-mirror > colgroup,.tc-mirror > thead{display:none} .tc-mirror > tbody{display:block} .tc-mirror > tbody > tr{display:grid;grid-template-columns:20px 34px 1fr 46px 30px; align-items:center;column-gap:6px;row-gap:3px;padding:7px 8px;background:transparent} .tc-mirror.gridrows > tbody > tr{border-bottom:1px solid #e0e0e0} .tc-mirror > tbody > tr > td{display:block;padding:0;border:none!important;background:transparent;overflow:visible} .tc-mirror td.c-mv{grid-column:1;grid-row:1} .tc-mirror td.c-num{grid-column:2;grid-row:1} .tc-mirror td.c-title{grid-column:3;grid-row:1} .tc-mirror td.c-len{grid-column:4;grid-row:1} .tc-mirror td.c-badge{grid-column:5;grid-row:1} .tc-mirror td.c-art{grid-column:1 / -1;grid-row:2;width:auto} .tc-mirror input.t-len{width:100%;text-align:right} /* the medium header keeps its own full-width line */ .tc-mirror > tbody > tr:has(.tc-medhdr){display:block;padding:0} .tc-mirror td.tc-medhdr{display:block} /* zebra + changed-marker move to the row (cells are transparent now) */ .tc-mirror.alt > tbody > tr:nth-child(even){background:#f6f4fb} .tc-mirror > tbody > tr.tc-changed{box-shadow:inset 3px 0 0 #5f3ec0} .tc-mirror > tbody > tr.tc-changed td:first-child{box-shadow:none} /* trim the fixed credited-as + actions so the search box gets the width */ .tc-cred{width:84px} .tc-acts{width:auto;gap:3px;padding-left:3px} /* touch = no hover: reveal the per-row / per-artist controls permanently */ .tc-mirror tr .t-gc,.tc-mirror tr .t-feat, .tc-enter,.tc-splitb,.tc-slotgrab,.tc-slotx{visibility:visible} .tc-aslot:not(.tc-can-split) .tc-splitb{display:none} } `; function style() { if (document.getElementById('tc-css')) return; const s = document.createElement('style'); s.id = 'tc-css'; s.textContent = css; document.head.appendChild(s); } /* ── settings popover (view options) ── */ function applyViewClasses() { const layout = SETTINGS.layout || 'normal'; document.querySelectorAll('.tc-mirror, .tc-rectbl').forEach(t => { // both tables share the layout/alt/grid options t.classList.toggle('alt', !!SETTINGS.altRows); t.classList.toggle('gridcols', !!SETTINGS.gridCols); t.classList.toggle('gridrows', SETTINGS.gridRows !== false); t.classList.remove('compact', 'cozy', 'normal'); t.classList.add(layout); }); } function openSettings(anchor) { style(); let s = document.getElementById('tc-settings'); if (s) { s.remove(); return; } s = document.createElement('div'); s.id = 'tc-settings'; s.innerHTML = `

${ICON} Apollo Editor v${scriptVersion()}? Help

`; document.body.appendChild(s); const r = anchor ? anchor.getBoundingClientRect() : { left: 60, right: 60, bottom: 80 }; // keep it fully on-screen — right-align to the gear if it would overflow (uses the real width), and // clamp/scroll vertically so a tall dialog never runs off the bottom (#119). Re-run on each tab // switch since panes differ in height. const maxH = window.innerHeight - 16; s.style.maxHeight = maxH + 'px'; s.style.overflowY = 'auto'; const place = () => { s.style.left = Math.max(8, Math.min(r.right - s.offsetWidth, window.innerWidth - s.offsetWidth - 10)) + 'px'; const h = Math.min(s.offsetHeight, maxH); let top = r.bottom + 6; if (top + h > window.innerHeight - 8) top = Math.max(8, window.innerHeight - h - 8); s.style.top = top + 'px'; }; // tabs: show one pane at a time so the panel stays short (#294) const tabBtns = s.querySelectorAll('.tc-tab-btn'), panes = s.querySelectorAll('.tc-tab-pane'); const showTab = name => { _cfgTab = name; tabBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === name)); panes.forEach(p => { p.hidden = p.dataset.pane !== name; }); place(); }; tabBtns.forEach(b => b.onclick = () => showTab(b.dataset.tab)); showTab(_cfgTab); const am = s.querySelector('#tc-s-automatch'), amRec = s.querySelector('#tc-s-automatchrec'), alt = s.querySelector('#tc-s-alt'), gridcols = s.querySelector('#tc-s-gridcols'), gridrows = s.querySelector('#tc-s-gridrows'); am.checked = SETTINGS.autoMatch !== false; amRec.checked = !!SETTINGS.autoMatchRec; alt.checked = !!SETTINGS.altRows; gridcols.checked = !!SETTINGS.gridCols; gridrows.checked = SETTINGS.gridRows !== false; const curLayout = SETTINGS.layout || 'normal'; s.querySelectorAll('input[name="tc-s-layout"]').forEach(rb => { rb.checked = rb.value === curLayout; rb.onchange = () => { if (rb.checked) { SETTINGS.layout = rb.value; saveSettings(); applyViewClasses(); } }; }); am.onchange = () => { SETTINGS.autoMatch = am.checked; saveSettings(); }; amRec.onchange = () => { SETTINGS.autoMatchRec = amRec.checked; saveSettings(); }; const dmatch = s.querySelector('#tc-s-discogsmatch'); if (dmatch) { dmatch.checked = SETTINGS.discogsUrlMatch !== false; dmatch.onchange = () => { SETTINGS.discogsUrlMatch = dmatch.checked; saveSettings(); }; } const lentol = s.querySelector('#tc-s-lentol'), titletol = s.querySelector('#tc-s-titletol'), igc = s.querySelector('#tc-s-ignorecase'), igp = s.querySelector('#tc-s-ignorepunct'); lentol.value = SETTINGS.recLenTol != null ? SETTINGS.recLenTol : 5; titletol.value = SETTINGS.recTitleTol || 0; igc.checked = SETTINGS.recIgnoreCase !== false; igp.checked = !!SETTINGS.recIgnorePunct; const refreshRec = () => { try { if (document.getElementById('tc-recwrap')) rerenderRec(); } catch (e) {} }; // live-update the table lentol.onchange = () => { const v = Math.max(0, Math.min(60, parseInt(lentol.value, 10) || 0)); SETTINGS.recLenTol = v; lentol.value = v; saveSettings(); refreshRec(); }; titletol.onchange = () => { const v = Math.max(0, Math.min(20, parseInt(titletol.value, 10) || 0)); SETTINGS.recTitleTol = v; titletol.value = v; saveSettings(); refreshRec(); }; igc.onchange = () => { SETTINGS.recIgnoreCase = igc.checked; saveSettings(); refreshRec(); }; igp.onchange = () => { SETTINGS.recIgnorePunct = igp.checked; saveSettings(); refreshRec(); }; const detailhl = s.querySelector('#tc-s-detailhl'); if (detailhl) { detailhl.checked = !!SETTINGS.recDetailedHl; detailhl.onchange = () => { SETTINGS.recDetailedHl = detailhl.checked; saveSettings(); refreshRec(); }; } const hlcol = s.querySelector('#tc-s-hlcolor'); if (hlcol) { hlcol.value = SETTINGS.recHlColor || '#e53935'; hlcol.oninput = () => { SETTINGS.recHlColor = hlcol.value; applyHlColor(); }; hlcol.onchange = () => { SETTINGS.recHlColor = hlcol.value; applyHlColor(); saveSettings(); refreshRec(); }; } const punctsz = s.querySelector('#tc-s-punctsize'); if (punctsz) { punctsz.value = SETTINGS.recPunctSize != null ? SETTINGS.recPunctSize : 3; punctsz.onchange = () => { const v = Math.max(0, Math.min(12, parseInt(punctsz.value, 10) || 0)); SETTINGS.recPunctSize = v; punctsz.value = v; saveSettings(); refreshRec(); }; } alt.onchange = () => { SETTINGS.altRows = alt.checked; saveSettings(); applyViewClasses(); }; const hoverhl = s.querySelector('#tc-s-hoverhl'); if (hoverhl) { hoverhl.checked = !!SETTINGS.hoverHighlight; hoverhl.onchange = () => { SETTINGS.hoverHighlight = hoverhl.checked; saveSettings(); if (!hoverhl.checked) { _hlCur = null; document.querySelectorAll('.tc-aslot.tc-arthl').forEach(e => e.classList.remove('tc-arthl')); } }; } gridcols.onchange = () => { SETTINGS.gridCols = gridcols.checked; saveSettings(); applyViewClasses(); }; gridrows.onchange = () => { SETTINGS.gridRows = gridrows.checked; saveSettings(); applyViewClasses(); }; const replri = s.querySelector('#tc-s-replri'), repltl = s.querySelector('#tc-s-repltl'), replrec = s.querySelector('#tc-s-replrec'), modanno = s.querySelector('#tc-s-modanno'), cnav = s.querySelector('#tc-s-compactnav'), zen = s.querySelector('#tc-s-zen'), moddupes = s.querySelector('#tc-s-moddupes'); replri.checked = SETTINGS.replaceReleaseInfo !== false; repltl.checked = SETTINGS.replaceTracklist !== false; replrec.checked = SETTINGS.replaceRecordings !== false; modanno.checked = SETTINGS.modifyAnnotation !== false; cnav.checked = SETTINGS.compactNav !== false; zen.checked = !!SETTINGS.zenMode; if (moddupes) { moddupes.checked = !!SETTINGS.modifyDuplicates; moddupes.onchange = () => { SETTINGS.modifyDuplicates = moddupes.checked; saveSettings(); applyDuplicates(); }; } replri.onchange = () => { SETTINGS.replaceReleaseInfo = replri.checked; saveSettings(); applyView(); }; repltl.onchange = () => { SETTINGS.replaceTracklist = repltl.checked; saveSettings(); applyView(); }; replrec.onchange = () => { SETTINGS.replaceRecordings = replrec.checked; saveSettings(); applyView(); }; modanno.onchange = () => { SETTINGS.modifyAnnotation = modanno.checked; saveSettings(); applyView(); applyAnnotationPage(); }; cnav.onchange = () => { SETTINGS.compactNav = cnav.checked; saveSettings(); applyNav(); applyZen(); }; zen.onchange = () => { SETTINGS.zenMode = zen.checked; saveSettings(); applyZen(); }; const aconf = s.querySelector('#tc-s-autoconfirm'); if (aconf) { aconf.checked = SETTINGS.autoConfirmSeed !== false; aconf.onchange = () => { SETTINGS.autoConfirmSeed = aconf.checked; saveSettings(); }; } const kcaret = s.querySelector('#tc-s-keepcaret'); if (kcaret) { kcaret.checked = SETTINGS.keepCaretColumn !== false; kcaret.onchange = () => { SETTINGS.keepCaretColumn = kcaret.checked; saveSettings(); }; } // #279 const off = e => { if (!s.contains(e.target) && e.target !== anchor) { s.remove(); document.removeEventListener('mousedown', off); } }; const lbtn = s.querySelector('.tc-logbtn'); if (lbtn) lbtn.onclick = () => { s.remove(); document.removeEventListener('mousedown', off); openLog(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); } /* ── the one shared table ── */ let MODEL = null; let ACTIVE = {}; // { mode, tbody, statusEl } // transient message (e.g. "matching d/n") shown in every table's Artist header const updateStatus = t => { document.querySelectorAll('.tc-medsec .tc-hstatus, #tc-panel .tc-hstatus, .tc-globalstat').forEach(e => { e.textContent = t; e.classList.remove('tc-unres'); }); }; // scroll to + focus the first unresolved artist search box (the white, non-matched one) function focusFirstUnresolved() { const box = document.querySelector('.tc-mirror .tc-search:not(.matched)'); if (!box) return; box.scrollIntoView({ block: 'center', behavior: 'smooth' }); (box.querySelector('input.nm') || box).focus(); } // the always-visible total in the toolbar (left of Match) — shows the release-wide unresolved count / progress; // when there are unresolved artists the badge is clickable and jumps to the first one const setGlobalStat = n => { document.querySelectorAll('.tc-globalstat').forEach(e => { e.textContent = n ? statusText(n) : ''; e.classList.toggle('tc-unres', n > 0); e.onclick = n > 0 ? focusFirstUnresolved : null; e.style.cursor = n > 0 ? 'pointer' : ''; e.title = n > 0 ? 'jump to the first unresolved artist' : ''; }); }; // #227: persistent missing-Discogs-links badge. Stays until every link is // added; each click steps to the NEXT track missing one and focuses its credit. let _discNavIdx = -1; // an artist whose Discogs link needs attention: missing (addable) OR mismatched // (links a different Discogs page than the release credits). #227 const discNeedsAttention = s => !!s._discogsAddable; // mismatch is now addable too (offer the release's link, flagged ⚠) // The tooltip text for an addable slot's link/⚠ icon — shared so the log shows // the exact same message (a mismatch/conflict reads differently from a plain add). function discAddTooltip(s) { const mism = s._discogsMismatch, conf = s._discogsConflict; return mism ? `Discogs mismatch: this artist links ${mism}, the release credits ${s._discogsUrl} — click to add the release's link anyway` : conf ? `Discogs links a different MB artist: ${conf.name} — click to add it to ${s.name || 'this artist'} anyway` : (s.gid ? 'Add the Discogs link to this artist' : 'Create this artist with its Discogs link'); } function focusNextMissingDiscogs() { const list = []; MODEL.tracks.forEach(t => t.slots.forEach((s, i) => { if (discNeedsAttention(s)) list.push([t, i]); })); if (!list.length) { _discNavIdx = -1; return; } _discNavIdx = (_discNavIdx + 1) % list.length; const [t, i] = list[_discNavIdx]; const row = rowEl(t.mi, t.ti); if (!row) return; row.scrollIntoView({ block: 'center', behavior: 'smooth' }); const creds = row.querySelectorAll('.tc-cred'); (creds[i] || row).focus(); } const missingDiscogsCount = () => { let n = 0; if (MODEL) MODEL.tracks.forEach(t => t.slots.forEach(s => { if (discNeedsAttention(s)) n++; })); return n; }; const setDiscStat = () => { // #281: Discogs API unreachable → an amber, clickable "retry" badge instead of a // blank that reads as "nothing to do". (It also auto-retries once on its own.) if (_discMapFailed) { document.querySelectorAll('.tc-discstat').forEach(e => { e.classList.remove('tc-disc-badge', 'tc-disc-ok'); e.classList.add('tc-disc-pend'); e.textContent = '🔗 Discogs ⟳'; e.style.cursor = 'pointer'; e.title = 'Couldn’t reach Discogs (rate-limited) — click to retry'; e.onclick = () => tagDiscogsForAll(); }); return; } let miss = 0, mism = 0, pend = 0, checked = false; if (MODEL) MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s._discogsChecked) checked = true; // #281: a RESOLVED slot was actually checked (unresolved slots don't count) if (s._discogsMismatch) mism++; else if (s._discogsAddable) miss++; else if (s._discogsPending) pend++; })); const n = miss + mism; document.querySelectorAll('.tc-discstat').forEach(e => { e.classList.remove('tc-disc-badge', 'tc-disc-ok', 'tc-disc-pend'); e.onclick = null; e.style.cursor = ''; e.title = ''; if (n) { // links to add / mismatches → teal action badge, click steps to the next e.textContent = `🔗 ${n} link${n === 1 ? '' : 's'}` + (pend ? ` · ${pend}?` : ''); e.classList.add('tc-disc-badge'); e.onclick = focusNextMissingDiscogs; e.style.cursor = 'pointer'; e.title = `${miss} missing, ${mism} mismatched Discogs link${n === 1 ? '' : 's'}` + (pend ? `, ${pend} unchecked` : '') + ' — click to step to the next'; } else if (pend) { // every job ran but some lookups couldn't complete (rate-limited / cold) → // say so honestly and let a click retry, rather than reading as "all OK" e.textContent = `🔗 ${pend} unchecked`; e.classList.add('tc-disc-pend'); e.onclick = () => tagDiscogsForAll(); e.style.cursor = 'pointer'; e.title = `${pend} Discogs link${pend === 1 ? '' : 's'} couldn't be checked (rate-limited) — click to retry`; } else if (checked) { // the check ran and every Discogs artist credit is already linked in MB — // a positive confirmation so a blank badge no longer means "did it run?" e.textContent = '✓ Discogs links OK'; e.classList.add('tc-disc-ok'); e.title = 'every Discogs artist credit on this release is already linked in MusicBrainz'; } else { e.textContent = ''; // no Discogs link on the release / not checked yet } }); }; // transient progress text in the same slot, while the check runs (not a pill) const setDiscProgress = (t) => document.querySelectorAll('.tc-discstat').forEach(e => { e.textContent = t || ''; e.classList.remove('tc-disc-badge', 'tc-disc-ok', 'tc-disc-pend'); e.onclick = null; e.style.cursor = ''; e.title = ''; }); // transient action feedback (a pick propagated, S&R count, …) — lives in the toolbar so it never // overwrites a medium's unresolved badge; auto-clears let _toastTimer = null; const toast = msg => { document.querySelectorAll('.tc-toast').forEach(e => { e.textContent = msg || ''; }); clearTimeout(_toastTimer); if (msg) _toastTimer = setTimeout(() => toast(''), 5000); }; // #281: "added Discogs link to X" feedback sits right next to the "N links" badge // (not in the centre toast), so it reads together with the count it just changed. let _discMsgTimer = null; const discMsg = msg => { document.querySelectorAll('.tc-disc-msg').forEach(e => { e.textContent = msg || ''; }); clearTimeout(_discMsgTimer); if (msg) _discMsgTimer = setTimeout(() => discMsg(''), 5000); }; const unresolvedIn = mi => { let n = 0; MODEL.tracks.forEach(t => { if (mi != null && t.mi !== mi) return; t.slots.forEach(s => { if (!(s.status === 'set' || s.committed)) n++; }); }); return n; }; const statusText = n => (n ? `⚠ ${n} unresolved!` : 'all matched'); const setStatusSpan = (span, n) => { if (!span) return; span.textContent = statusText(n); span.classList.toggle('tc-unres', n > 0); }; // disable the Match button while a match pass is running let _matching = false; function setMatching(on) { _matching = on; const b = document.querySelector('#tc-bar [data-act="match"], #tc-hdr [data-act="match"]'); if (b) b.disabled = on; } // re-fill every active tbody (per-medium sections in mirror mode, or the single panel table) const rerender = () => { _hlCur = null; if (ACTIVE.sections) ACTIVE.sections.forEach(s => fillRows(s.tbody, s.mi)); else if (ACTIVE.tbody) fillRows(ACTIVE.tbody); refreshStatus(); }; // our rendered row for a track, wherever it lives (a per-medium section or the floating panel) const rowEl = (mi, ti) => document.querySelector(`.tc-medsec tr[data-tk="${mi}:${ti}"], #tc-panel tr[data-tk="${mi}:${ti}"]`); // ↑/↓ : move to the same field in the prev/next ROW — but for the per-artist fields (search box, // credited-as) walk EVERY line in document order, so multi-artist tracks and media boundaries are // all included. Returns true if it moved. function focusSameField(inp, dir) { const sel = inp.classList.contains('t-num') ? '.t-num' : inp.classList.contains('t-title') ? '.t-title' : inp.classList.contains('t-len') ? '.t-len' : inp.classList.contains('tc-cred') ? '.tc-cred' : inp.classList.contains('nm') ? '.tc-search input.nm' : null; if (!sel) return false; const scope = inp.closest('#tc-panel') ? '#tc-panel' : '.tc-medsec'; const all = [...document.querySelectorAll(`${scope} ${sel}`)]; // flat list across all rows/sections/artist lines const cur = all.indexOf(inp); if (cur < 0) return false; const dest = all[cur + dir]; if (!dest) return false; // remember the destination by row + its slot index within that row (survives a commit-rebuild) const destRow = dest.closest('tr[data-tk]'); const destTk = destRow ? destRow.dataset.tk : null; const destIdx = destRow ? [...destRow.querySelectorAll(sel)].indexOf(dest) : 0; const destPos = cur + dir; // #279: carry the caret COLUMN across the move instead of selecting the whole // field (jesus2099's keyboard-select behaviour) — so you keep typing / fix // casing at the same spot rather than overwriting. Clamped to the destination's // length, so a shorter field just drops the caret at its end. const caret = (inp.selectionStart != null) ? inp.selectionStart : (inp.value || '').length; inp.blur(); // committing the current field on blur can rebuild the rows — focus AFTER, from the fresh DOM const go = () => { let t = null; if (destTk) { const d = document.querySelector(`${scope} tr[data-tk="${destTk}"]`); if (d) { const xs = [...d.querySelectorAll(sel)]; t = xs[Math.min(destIdx, xs.length - 1)]; } } if (!t) t = [...document.querySelectorAll(`${scope} ${sel}`)][destPos]; if (t && document.activeElement !== t) { t.focus(); // #279: keep the caret column by default; the toggle restores select-all-on-arrive. if (SETTINGS.keepCaretColumn !== false) { if (typeof t.setSelectionRange === 'function') { const p = Math.min(caret, (t.value || '').length); try { t.setSelectionRange(p, p); } catch (e) {} } } else if (t.select && !t.classList.contains('nm')) { t.select(); } } }; go(); setTimeout(go, 0); return true; } // ↓/Enter → next field, ↑/Shift+Enter → prev. NOT wired on the artist search box (Enter picks there). function wireRowNav(inp) { inp.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { if (focusSameField(inp, 1)) e.preventDefault(); } else if (e.key === 'ArrowUp') { if (focusSameField(inp, -1)) e.preventDefault(); } else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (!focusSameField(inp, e.shiftKey ? -1 : 1)) inp.blur(); } // move on; blur (commit) if at the edge }); } // show each medium's OWN unresolved count in its header (or the global count for the floating panel) function refreshStatus() { if (!MODEL || _matching) return; // while a pass runs the headers show "matching d/n" — don't flicker the badge if (ACTIVE.sections) ACTIVE.sections.forEach(s => setStatusSpan(s.sec.querySelector('.tc-hstatus'), unresolvedIn(s.mi))); else document.querySelectorAll('#tc-panel .tc-hstatus').forEach(span => setStatusSpan(span, unresolvedIn(null))); setGlobalStat(unresolvedIn(null)); // release-wide total in the toolbar if (!_tagDiscogsRunning) setDiscStat(); // #227: keep the missing-links badge in sync (the check owns it while running) } function buildTable() { const t = document.createElement('table'); t.className = 'tc-mirror' + (SETTINGS.altRows ? ' alt' : '') + (SETTINGS.gridCols ? ' gridcols' : '') + (SETTINGS.gridRows !== false ? ' gridrows' : '') + ' ' + (SETTINGS.layout || 'normal'); // the Artist column is the flexible filler (no fixed width) — it absorbs the slack so every OTHER // column keeps its EXACT width (table-layout:fixed) and resizes 1:1 with the mouse (no jump) t.innerHTML = `${COLS.map(c => c.k === 'art' ? '' : ``).join('')}` + `${COLS.map(c => `${c.label}${c.k === 'art' ? '' : ''}`).join('')}`; return t; } // the artist-selection-mode dropdown now lives in the Artist column header (right-aligned) const AM_SELECT = ``; // wire the apply-mode combo (now lives in the toolbar, was in the Artist header) function wireApplyMode(root) { const am = (root || document).querySelector('.tc-applymode'); if (!am) return; am.value = SETTINGS.applyMode || 'all'; am.onchange = () => { SETTINGS.applyMode = am.value; saveSettings(); document.querySelectorAll('.tc-applymode').forEach(s => { s.value = am.value; }); Log.info('applyMode =', am.value); }; } // #330: per-medium "Pregap" / "Data track" checkboxes that drive MB's writable medium // observables hasPregap()/hasDataTracks() (the same thing native MB's buttons do), then // rebuild so the new/removed track shows. Returns a or null (locked / no medium). // Deep data-section editing (its own Add-track, bounded reorder) intentionally stays native. function mediumOpts(target) { if (target == null) return null; const med = mediums()[target]; if (!med) return null; // #330: show the toggles on EVERY medium and keep them ENABLED even on a disc-ID medium. // Pregap/data tracks live outside the audio TOC (the same reason their lengths stay editable, // #329), and native MB leaves these controls enabled there too. const opts = document.createElement('span'); opts.className = 'tc-medopts'; const mkOpt = (label, prop, help) => { const lbl = document.createElement('label'); lbl.className = 'tc-medopt'; lbl.title = help; const cb = document.createElement('input'); cb.type = 'checkbox'; try { cb.checked = typeof med[prop] === 'function' && !!med[prop](); } catch (e) {} cb.onchange = () => { _selfEdit = true; try { med[prop](cb.checked); } catch (e) { Log.warn(`${label} toggle failed`, e.message); } finally { _selfEdit = false; } Log.info(`medium ${target + 1}: ${label} ${cb.checked ? 'added' : 'removed'}`); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); }; lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + label)); return lbl; }; opts.appendChild(mkOpt('Pregap', 'hasPregap', 'Add / remove a hidden pregap track (position 0)')); opts.appendChild(mkOpt('Data track', 'hasDataTracks', 'Add / remove a trailing data track (for several, use the native editor)')); return opts; } // one Apollo table for a single medium (its own header row + Add footer); returns the tbody. // mi == null renders the whole release into one table (the floating panel). function mountTable(container, mi) { container.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'tc-tablewrap'; container.appendChild(wrap); const table = buildTable(); wrap.appendChild(table); wireResizers(table); const am = table.querySelector('.tc-applymode'); if (am) { am.value = SETTINGS.applyMode || 'all'; am.onchange = () => { SETTINGS.applyMode = am.value; saveSettings(); document.querySelectorAll('.tc-applymode').forEach(s => { s.value = am.value; }); Log.info('applyMode =', am.value); }; ['mousedown', 'mousemove', 'click'].forEach(ev => am.addEventListener(ev, e => e.stopPropagation())); // don't let the column resizer hijack it } // Footer per medium: "Add N track(s)" (only when the medium can take new tracks — // a disc-ID medium has a fixed track count, #125) PLUS the pregap/data toggles. The // toggles render even on a locked medium (disabled, #330), so every medium shows them. // In the combined multi-medium view the toggles live on each medium header instead // (see fillRows), so only attach them to this footer for a per-medium section or a // single-medium release. const target = (mi == null) ? Math.max(0, mediums().length - 1) : mi; const canAdd = !mediumLocked(target); const opts = (mi != null || mediums().length <= 1) ? mediumOpts(target) : null; if (canAdd || opts) { const addrow = document.createElement('div'); addrow.className = 'tc-addrow'; if (canAdd) { addrow.innerHTML = `Add track(s) `; const addn = addrow.querySelector('.tc-addn'), addbtn = addrow.querySelector('.tc-addbtn'); addbtn.onclick = () => addTracks(target, Math.max(1, parseInt(addn.value, 10) || 1)); } if (opts) addrow.appendChild(opts); container.appendChild(addrow); } return table.querySelector('tbody'); } // resize a column by dragging near its right border from ANY row (or the header) function wireResizers(table) { const cols = [...table.querySelectorAll('col')]; const TOL = 5; const artIdx = COLS.findIndex(c => c.k === 'art'); // the flexible filler column // detect a column boundary near the cursor (each th's right edge), including Artist's right edge. #119 const borderIdx = clientX => { const ths = table.querySelectorAll('thead th'); for (let i = 0; i < ths.length - 1; i++) { if (Math.abs(ths[i].getBoundingClientRect().right - clientX) <= TOL) return i; } return -1; }; let dragging = false; table.addEventListener('mousemove', e => { if (!dragging) table.style.cursor = borderIdx(e.clientX) >= 0 ? 'col-resize' : ''; }); table.addEventListener('mousedown', e => { const i = borderIdx(e.clientX); if (i < 0) return; e.preventDefault(); dragging = true; // data columns have exact fixed widths (the spacer column absorbs slack), so the style width IS the // rendered width — resize is 1:1, no jump. Columns LEFT of the Artist filler resize from their own // right edge; columns AT/AFTER it (Length, badge) have no room to their right, so each boundary // resizes the column to its RIGHT, inversely (drag right = that column shrinks, Artist absorbs). #119 const ths = [...table.querySelectorAll('thead th')]; const inverse = i >= artIdx; const ci = inverse ? i + 1 : i; const col = cols[ci], startX = e.clientX, startW = parseInt(col.style.width) || (ths[ci] && ths[ci].offsetWidth) || 100; const mm = ev => { col.style.width = Math.max(36, startW + (ev.clientX - startX) * (inverse ? -1 : 1)) + 'px'; }; const mu = () => { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); dragging = false; SETTINGS.colWidths = SETTINGS.colWidths || {}; SETTINGS.colWidths[COLS[ci].k] = parseInt(col.style.width); saveSettings(); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); } // ── column sizing (the "Resize columns" tool) ── Artist stays the flexible filler; the rest get // explicit widths in SETTINGS.colWidths and are pushed to every live table in place (no rebuild). function applyColWidths() { SETTINGS.colWidths = SETTINGS.colWidths || {}; document.querySelectorAll('.tc-mirror').forEach(table => { const cols = [...table.querySelectorAll('colgroup col')]; COLS.forEach((c, i) => { if (!cols[i]) return; cols[i].style.width = c.k === 'art' ? '' : colW(c.k, c.w) + 'px'; }); }); } function colsDefault() { SETTINGS.colWidths = {}; saveSettings(); applyColWidths(); Log.info('columns → default widths'); } // fit each text column (#, Title, Length) to its widest content; Artist absorbs the slack function colsFit() { const tables = [...document.querySelectorAll('.tc-mirror')]; if (!tables.length) return; SETTINGS.colWidths = SETTINGS.colWidths || {}; const probe = tables[0].querySelector('tbody input') || tables[0]; const cx = (colsFit._cv || (colsFit._cv = document.createElement('canvas'))).getContext('2d'); cx.font = getComputedStyle(probe).font || '13px sans-serif'; const PAD = { num: 22, title: 32, len: 22 }, CAP = { num: 90, title: 720, len: 90 }; ['num', 'title', 'len'].forEach(k => { const def = COLS.find(c => c.k === k); let max = cx.measureText(def.label || '').width; tables.forEach(t => t.querySelectorAll(`tbody td.c-${k} input`).forEach(inp => { max = Math.max(max, cx.measureText(inp.value || '').width); })); SETTINGS.colWidths[k] = Math.min(CAP[k], Math.max(36, Math.round(max) + PAD[k])); }); saveSettings(); applyColWidths(); Log.info('columns → fit content', JSON.stringify(SETTINGS.colWidths)); } // "centered" / balanced: give Title and Artist an equal share of the row (Artist flexes to the other half) function colsBalanced() { const table = document.querySelector('.tc-mirror'); if (!table) return; SETTINGS.colWidths = SETTINGS.colWidths || {}; const total = table.clientWidth || table.offsetWidth || 900; const fixed = colW('mv', 32) + colW('num', 38) + colW('len', 52) + colW('badge', 56); SETTINGS.colWidths.title = Math.max(160, Math.round((total - fixed) / 2)); saveSettings(); applyColWidths(); Log.info('columns → balanced (Title = Artist)', SETTINGS.colWidths.title); } // picking an artist writes through immediately; in "all" mode it also copies to every other // track credited to the same text, committing each. function pickArtist(slot, c) { if (!c || !c.gid) return; if (c.aliases) cacheAliases(c.gid, c.aliases); // keep the chosen artist's aliases for the bar else if (!_gidAliases.has(c.gid)) fetchAliasesByGids([c.gid]).then(() => refreshAdorns()); // alias not loaded yet (fast pick / "Show more" result) — fetch + show it without re-searching #128 MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); // clear the previous selection's outlines const entry = slot._entry, beforeKey = creditKey(entry); // whole-credit snapshot BEFORE the pick (and credited-as auto-fill) slot.entity = c; slot.gid = c.gid; slot.name = c.name; slot.status = 'user'; slot.committed = true; slot.query = null; slot._flash = true; if (!(slot.creditedAs || '').trim()) slot.creditedAs = c.name; // auto-fill the credited-as when the user hasn't set one commitTrack(entry); // whole-credit match, like MB's native "all matching tracks": copy this track's resulting // credit (the picked artist included) to every other track that shared its credit string. const copies = propagateCredit(entry, beforeKey); if (copies) { slot._marked = true; Log.info('propagated', c.name, '→', copies, 'matching track(s)'); } rerender(); if (copies) toast(`linked “${c.name}” — also on ${copies} matching track${copies > 1 ? 's' : ''}`); // #227: the new artist may lack the slot's Discogs link — recompute the add affordance if (slot._discogsUrl) tagDiscogsAddable(slot, slot._discogsUrl).then(() => rerender()); } // Ctrl/Cmd-click a search result → set that artist on EVERY still-unresolved slot (bulk-fill, e.g. a // various-artists comp that's actually one artist). Resolved (green) slots are left untouched. function pickArtistAllUnresolved(c) { if (!c || !c.gid || !MODEL) return; if (c.aliases) cacheAliases(c.gid, c.aliases); else if (!_gidAliases.has(c.gid)) fetchAliasesByGids([c.gid]).then(() => refreshAdorns()); MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); const touched = new Set(); let n = 0; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.committed) return; // skip already-resolved slots Object.assign(s, { entity: c, gid: c.gid, name: c.name, status: 'user', committed: true, query: null, _flash: true, _marked: true }); if (!(s.creditedAs || '').trim()) s.creditedAs = c.name; touched.add(s._entry); n++; })); touched.forEach(commitTrack); rerender(); toast(n ? `linked “${c.name}” on ${n} unresolved track${n > 1 ? 's' : ''}` : 'no unresolved tracks'); } async function revertSlot(entry, i) { const orig = ORIGINALS.get(entry.mi + ':' + entry.ti); if (!orig || !orig.names[i]) return; const on = orig.names[i], slot = entry.slots[i]; slot.creditedAs = on.creditedAs; slot.joinPhrase = on.joinPhrase; slot.query = null; const a = u(on.artist) || {}, gid = u(a.gid); if (gid) Object.assign(slot, { status: 'set', gid, name: u(a.name), entity: { gid, name: u(a.name), id: u(a.id) }, candidates: [], committed: true }); else { const sib = (await loadSiblingMap()).get(fold(entry.title)); const durls = (await loadDiscogsMap())?.get(fold(entry.title)); const m = await matchSlot(on.creditedAs, sib && sib[i], durls && durls[i]); Object.assign(slot, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, committed: false }); await tagDiscogsAddable(slot, durls && durls[i]); } commitTrack(entry); Log.info('reverted slot', i, 'of track', entry.number); rerender(); } const blankSlot = entry => ({ creditedAs: '', joinPhrase: '', status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false, _entry: entry }); function focusSlotInput(entry, idx) { const row = rowEl(entry.mi, entry.ti); if (row) { const ins = row.querySelectorAll('.tc-search input.nm'); if (ins[idx]) ins[idx].focus(); } } /* ── "all matching tracks" propagation (mirrors MB's native changeMatchingTrackArtists) ── The match key is the WHOLE artist-credit string — each slot's as-credited text (its credited-as, or the artist name when there's no override) + its join phrase, in order. Linked-artist identity is ignored, exactly like MB's reduceArtistCredit. An empty credit never propagates. Peers are re-derived per action; since every artist action propagates, a matched group stays in lockstep through a multi-step edit (add slot → type → pick). */ const creditKey = entry => entry.slots.map(s => (s.creditedAs || s.name || '') + (s.joinPhrase || '')).join(''); function cloneSlots(src, destEntry) { return src.slots.map(s => ({ creditedAs: s.creditedAs, joinPhrase: s.joinPhrase, // committed+linked slots become 'user' so commitTrack writes our entity (not the peer's stale live one) status: (s.committed && s.entity && s.status === 'set') ? 'user' : s.status, entity: s.entity, gid: s.gid, name: s.name, candidates: (s.candidates || []).slice(), committed: s.committed, query: s.query || null, _entry: destEntry, _flash: true, _marked: true, })); } // Apply `entry`'s resulting credit to every OTHER track whose credit string still equals // `beforeKey` (its string before this edit). Returns how many peer tracks were changed. function propagateCredit(entry, beforeKey) { if ((SETTINGS.applyMode || 'all') !== 'all' || !beforeKey || !beforeKey.trim()) return 0; MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); // clear the previous action's outline let n = 0; MODEL.tracks.forEach(t => { if (t === entry || creditKey(t) !== beforeKey) return; t.slots = cloneSlots(entry, t); commitTrack(t); n++; }); if (n) entry.slots.forEach(s => { s._marked = true; }); return n; } // Run a credit mutation on `entry`, commit it, then propagate to matching tracks. `liveRerender` // false (text edits) skips the table rebuild when nothing propagated, so the field keeps focus. function editCredit(entry, mutate, verb, liveRerender = true) { const beforeKey = creditKey(entry); mutate(); commitTrack(entry); const n = propagateCredit(entry, beforeKey); if (liveRerender || n) rerender(); Log.info(verb, 'on track', entry.number, n ? ('· +' + n + ' matching') : ''); if (n) toast(`${verb} — also on ${n} matching track${n > 1 ? 's' : ''}`); return n; } // split a credit: append an artist slot (the + create-row / API uses this) function addSlot(entry) { editCredit(entry, () => { const last = entry.slots[entry.slots.length - 1]; if (last && !(last.joinPhrase || '').trim()) last.joinPhrase = ' & '; entry.slots.push(blankSlot(entry)); }, 'added artist slot'); focusSlotInput(entry, entry.slots.length - 1); } // ↵ : insert an artist slot right after this one function addSlotAfter(entry, idx) { editCredit(entry, () => { if (!(entry.slots[idx].joinPhrase || '').trim()) entry.slots[idx].joinPhrase = ' & '; const s = blankSlot(entry); s.joinPhrase = idx + 1 < entry.slots.length ? ' & ' : ''; entry.slots.splice(idx + 1, 0, s); }, 'inserted artist slot'); focusSlotInput(entry, idx + 1); } // merge: remove an artist slot (clearing the trailing join on the new last slot) function removeSlot(entry, idx) { if (entry.slots.length <= 1) return; editCredit(entry, () => { entry.slots.splice(idx, 1); const last = entry.slots[entry.slots.length - 1]; if (last) last.joinPhrase = ''; }, 'removed artist slot'); } function revertTrack(entry) { resetTrack(entry); rebuild(true); } // parse a combined credit ("A feat. B & C") into [{name, sep}] — sep is the separator AFTER each name const SEP_RE = /\s*(\bfeat\.?|\bft\.?|\bfeaturing|&|\band\b|\bvs\.?|\bwith\b|×|・|,|;)\s*/gi; function splitArtistText(text) { const parts = (text || '').split(SEP_RE); const out = []; for (let i = 0; i < parts.length; i += 2) { const name = (parts[i] || '').trim(); if (name) out.push({ name, sep: parts[i + 1] || '' }); } return out; } function normJoin(sep) { const s = (sep || '').trim().toLowerCase(); if (!s) return ' & '; if (s === '&') return ' & '; if (/^feat|^ft/.test(s)) return ' feat. '; if (s === 'and') return ' and '; if (s === ',') return ', '; if (s === ';') return '; '; if (/^vs/.test(s)) return ' vs. '; if (s === 'with') return ' with '; if (s === '×' || s === 'x') return ' × '; return ' ' + sep.trim() + ' '; } // ⋔ : split this slot's combined credit into one slot per artist, auto-match (if on), drop the credited-as override async function splitSlot(entry, idx) { const slot = entry.slots[idx]; const parts = splitArtistText(slot.creditedAs || slot.name || slot.query || ''); if (parts.length < 2) return; const beforeKey = creditKey(entry); // snapshot before the split, for "all matching tracks" const fresh = parts.map((p, i) => { const s = blankSlot(entry); s.creditedAs = p.name; s.joinPhrase = i < parts.length - 1 ? normJoin(p.sep) : ''; s._pending = true; return s; }); entry.slots.splice(idx, 1, ...fresh); entry.slots.forEach(s => { s._entry = entry; }); commitTrack(entry); rerender(); Log.info('split', JSON.stringify(slot.creditedAs || slot.name), '→', parts.map(p => p.name).join(' · ')); if (SETTINGS.autoMatch !== false) await matchModel(); else fresh.forEach(s => { delete s._pending; }); // remove the credited-as override on the matched parts (the artist name is the credit) entry.slots.forEach(s => { if (s.committed && s.gid) s.creditedAs = ''; }); commitTrack(entry); const n = propagateCredit(entry, beforeKey); // apply the finished split to every track that shared the old credit rerender(); if (n) { Log.info('split — also on', n, 'matching track(s)'); toast(`split — also on ${n} matching track${n > 1 ? 's' : ''}`); } } // + create-button at the right end of the box (before the join), only when the slot is unmatched; // and the alias on the resolved bar — only while the slot stays committed (gone the moment you edit) function adorn(search, slot, inp) { [...search.querySelectorAll('.mk, .tc-bar-aka, .tc-bar-disamb')].forEach(e => e.remove()); search.classList.toggle('matched', !!slot.committed); const ref = search.querySelector('.tc-joinwrap'); const aks = _gidAliases.get(slot.gid); const aka = slot.committed ? aliasStr({ gid: slot.gid, name: slot.name, aliases: aks || (slot.entity && slot.entity.aliases) || [], primaryAlias: slot.entity && slot.entity.primaryAlias }) : null; // when matched, size the name field to its content so the alias sits right after it on the LEFT // (instead of being pushed to the far right by a full-width field); reset when unmatched. #128 if (slot.committed) { inp.style.flex = '0 1 auto'; inp.size = Math.max(3, String(slot.name || inp.value || '').length + 1); } else { inp.style.flex = ''; inp.removeAttribute('size'); } // content-sizing the matched field leaves a short name with almost no click target, so clicking // anywhere in the rest of the bar (the empty space / alias) focuses it to open the search. #128 search.onmousedown = e => { if (e.target === inp || e.target.closest('.tc-joinwrap, .mk')) return; e.preventDefault(); inp.focus(); }; if (aka) { const al = document.createElement('span'); al.className = 'tc-bar-aka'; al.textContent = aka; al.title = aka; search.insertBefore(al, ref); } // artist disambiguation, like native MB's credit editor — grey, after the alias. // Sourced from the cache (filled by the same gid alias fetch); shows even for // special-purpose artists like [unknown], whose alias is suppressed. #195 const dis = slot.committed ? getDisamb(slot.gid) : ''; if (dis) { const ds = document.createElement('span'); ds.className = 'tc-bar-disamb'; ds.textContent = '(' + dis + ')'; ds.title = dis; search.insertBefore(ds, ref); } if (!slot.committed) { const mk = document.createElement('button'); mk.className = 'mk'; mk.textContent = '+'; mk.title = 'create this artist on MusicBrainz · right-click: create silently in a background tab'; mk.onmousedown = e => { if (e.button === 2) return; e.preventDefault(); createArtist(inp.value.trim() || slot.creditedAs, slot, slot._discogsUrl || null); }; mk.oncontextmenu = e => { e.preventDefault(); createArtist(inp.value.trim() || slot.creditedAs, slot, slot._discogsUrl || null, true); }; search.insertBefore(mk, ref); } } // the badge column: a pill per artist line, plus a hover overlay with the track ↺/✕ actions function renderBadgeCell(cell, track) { const changed = trackChanged(track); // ↺ only makes sense (and only shows) when there's something to revert const locked = mediumLocked(track.mi); // disc-ID medium: no remove button (#125) cell.innerHTML = track.slots.map(s => `
${s.committed ? `${badgeText(s)}` : ''}
`).join('') + `
${changed ? '' : ''}${locked ? '' : ''}
`; const trev = cell.querySelector('.trev'); if (trev) trev.onclick = () => revertTrack(track); const rm = cell.querySelector('.rm'); if (rm) rm.onclick = () => { removeTrack(track); rebuild(); }; const row = cell.closest('tr'); if (row) row.classList.toggle('tc-changed', changed); // mark the row (left border) } // join phrase: editable text that grows right-to-left, plus a ▾ that opens the presets list function joinControl(entry, slot, refreshBadges) { const wrap = document.createElement('span'); wrap.className = 'tc-joinwrap'; const inp = document.createElement('input'); inp.className = 'tc-join'; inp.value = slot.joinPhrase || ''; inp.title = 'join phrase to the next artist (editable; ▾ for presets)'; const fit = () => { inp.size = Math.max(2, inp.value.length || 2); }; fit(); // #208: flag a join phrase missing a space on either side (␣) or missing // entirely (␣?␣). Every rendered join control sits BETWEEN two artists // (the last slot has none), so an empty value here is a genuine gap. // Same px>0 master switch as the #203 marking. const markJoin = () => { wrap.classList.remove('tc-jp-nolead', 'tc-jp-notrail', 'tc-jp-nophrase', 'tc-jp-bad'); if ((SETTINGS.recPunctSize | 0) <= 0) return; const v = inp.value; if (v === '') { wrap.classList.add('tc-jp-nophrase', 'tc-jp-bad'); return; } // CJK scripts (Japanese と / 、, Chinese, Korean) don't space their joins — // skip the space flags when the join or an adjacent name is CJK (#208, chaban) const idx = entry.slots.indexOf(slot); const nxt = entry.slots[idx + 1]; const cjk = isCjk(v) || isCjk(slot.creditedAs || slot.name) || (nxt && isCjk(nxt.creditedAs || nxt.name)); let bad = false; // leading space wanted unless the join is a tight ","/";" separator (#208, chaban) if (!cjk && !/^\s/.test(v) && !/^\s*[,;]/.test(v)) { wrap.classList.add('tc-jp-nolead'); bad = true; } if (!cjk && !/\s$/.test(v)) { wrap.classList.add('tc-jp-notrail'); bad = true; } if (bad) wrap.classList.add('tc-jp-bad'); }; inp.oninput = () => { fit(); markJoin(); }; inp.onchange = () => { editCredit(entry, () => { slot.joinPhrase = inp.value; }, 'join phrase', false); markJoin(); if (refreshBadges) refreshBadges(); }; enterBlurs(inp); const arrow = document.createElement('button'); arrow.className = 'tc-joinarrow'; arrow.textContent = '▾'; arrow.title = 'common join phrases'; let pop = null; const close = () => { if (pop) { pop.remove(); pop = null; } }; arrow.onclick = () => { if (pop) { close(); return; } pop = document.createElement('div'); pop.className = 'tc-acpop tc-joinpop'; pop.innerHTML = JOIN_OPTIONS.map(o => `
${esc(o.label)}"${esc(o.value)}"
`).join(''); document.body.appendChild(pop); const r = inp.getBoundingClientRect(); pop.style.left = Math.max(4, r.right - 150) + 'px'; pop.style.top = (r.bottom + 4) + 'px'; pop.style.minWidth = '150px'; [...pop.querySelectorAll('[data-v]')].forEach(row => { row.onmousedown = e => { e.preventDefault(); inp.value = row.dataset.v; fit(); markJoin(); editCredit(entry, () => { slot.joinPhrase = inp.value; }, 'join phrase', false); if (refreshBadges) refreshBadges(); close(); }; }); const off = e => { if (pop && !pop.contains(e.target) && e.target !== arrow) { close(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); }; wrap.appendChild(inp); wrap.appendChild(arrow); markJoin(); return wrap; } // attach the type-to-search autocomplete to an existing function wireAutocomplete(inp, slot, refresh) { let pop = null, list = [], hi = -1, seq = 0, onScroll = null; let curQuery = '', curLimit = 8; // "Show more…" pagination: bump the limit and re-search const position = () => { if (!pop) return; const r = inp.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = (r.bottom + 2) + 'px'; pop.style.minWidth = Math.max(210, r.width) + 'px'; }; const ensure = () => { if (pop) return; pop = document.createElement('div'); pop.className = 'tc-acpop'; document.body.appendChild(pop); onScroll = () => { if (!pop || !pop.isConnected) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); pop = null; } else position(); }; window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onScroll); position(); }; const close = () => { if (pop) pop.remove(); pop = null; hi = -1; if (onScroll) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); onScroll = null; } }; const choose = c => { close(); pickArtist(slot, c); }; const searching = () => { ensure(); list = []; pop.innerHTML = `
Searching…
`; position(); }; const akaHtml = c => { const a = aliasStr(c); return a ? `${esc(a)}` : ''; }; // a full page of results probably means there are more — offer "Show more…" (like MB's native popup) const loadMore = () => { curLimit = curLimit >= 50 ? 100 : curLimit >= 25 ? 50 : 25; const my = ++seq; const more = pop && pop.querySelector('.tc-acmore'); if (more) more.textContent = 'Loading…'; searchArtist(curQuery, curLimit).then(res => { if (my === seq && document.activeElement === inp) showResults(res, curQuery); }); }; const draw = arr => { ensure(); list = arr; const q = inp.value.trim() || slot.creditedAs; pop.innerHTML = arr.length ? arr.map((c, i) => `
${typeSvg(c)}${esc(c.name)}${akaHtml(c)}${c.comment ? `${esc(c.comment)}` : ''}
`).join('') : `
no matches — use + to create
`; [...pop.querySelectorAll('.tc-acrow[data-i]')].forEach(row => { row.title = 'click to set · Ctrl-click to set on all unresolved tracks'; row.onmousedown = e => { e.preventDefault(); const c = arr[+row.dataset.i]; if (e.ctrlKey || e.metaKey) { close(); pickArtistAllUnresolved(c); } else choose(c); }; }); if (curQuery && arr.length >= curLimit && curLimit < 100) { // likely more available → a clickable "Show more…" footer const more = document.createElement('div'); more.className = 'tc-acrow tc-acmore'; more.textContent = 'Show more…'; more.onmousedown = e => { e.preventDefault(); loadMore(); }; pop.appendChild(more); } position(); }; // patch in the full aliases (one WS2 search) without a full redraw, so it doesn't reset the keyboard highlight const patchAliases = arr => { if (!pop) return; arr.forEach((c, i) => { const a = aliasStr(c); if (!a) return; const row = pop.querySelector(`.tc-acrow[data-i="${i}"]`); if (!row) return; let sp = row.querySelector('.tc-aka'); if (!sp) { sp = document.createElement('span'); sp.className = 'tc-aka'; const nm = row.querySelector('.nm'); nm.parentNode.insertBefore(sp, nm.nextSibling); } sp.textContent = a; }); }; const showResults = (arr, q) => { draw(arr); // Fetch aliases for the EXACT result gids (batched, limit 100) — not the old by-name query // capped at 12, which left every "Show more" result past the 12th with no alias. Caches into // _gidAliases so a pick (and the resolved bar) get the alias too. #128 fetchAliasesByGids(arr.map(c => c.gid)).then(() => { if (document.activeElement !== inp || !pop) return; arr.forEach(c => { const a = _gidAliases.get(c.gid); if (a && a.length) c.aliases = a; }); patchAliases(arr); }); }; const runSearch = q => { curQuery = q; curLimit = 8; const my = ++seq; searching(); searchArtist(q).then(res => { if (my === seq && document.activeElement === inp) showResults(res, q); }); }; // paste an MBID or a MusicBrainz /artist/ URL → resolve it straight to that artist. Gate on the // field value (not focus): a commit-rerender can steal focus before the fetch returns. const resolveByGid = async gid => { ensure(); list = []; pop.innerHTML = `
Resolving…
`; position(); const ent = await fetchEntity(gid); if (mbidFrom(inp.value) !== gid) return; if (ent && ent.id) { const entry = slot._entry, i = entry.slots.indexOf(slot); close(); pickArtist(slot, ent); focusSlotInput(entry, i); } else { pop.innerHTML = `
MBID not found
`; } }; // refocus the (re-rendered) field so keyboard nav keeps working after a paste inp.onfocus = () => { if (slot.committed && slot.candidates && slot.candidates.length) { curQuery = inp.value.trim() || slot.creditedAs || slot.name; curLimit = 8; showResults(slot.candidates, curQuery); return; } const q = inp.value.trim() || (slot.creditedAs || '').trim(); if (q) runSearch(q); else close(); // empty → no dropdown }; let tmr; inp.oninput = () => { slot.query = inp.value; clearTimeout(tmr); const gid = mbidFrom(inp.value); if (gid) { resolveByGid(gid); return; } // pasted an MBID / artist URL → resolve directly (pickArtist replaces whatever was there; no un-link needed) // editing away from the matched artist un-links it: bar goes white, + creates the typed name if (slot.committed && !sameName(inp.value, slot.name)) { slot.committed = false; slot.status = 'none'; slot.entity = null; slot.gid = null; commitTrack(slot._entry); if (refresh) refresh(); } if (!inp.value.trim()) { close(); return; } // nothing typed → don't search searching(); const my = ++seq; tmr = setTimeout(async () => { curQuery = inp.value; curLimit = 8; const res = await searchArtist(inp.value); if (my === seq && document.activeElement === inp) showResults(res, inp.value); }, 250); }; // arrows browse the results popup WHILE searching; once the slot is resolved they move row-to-row instead const browsing = () => pop && !slot.committed && list.length; // highlight the row at index `hi` and keep it on screen (the popup scrolls, so a selection past the // last visible row was going off-screen) — #128-adjacent nav fix const hiliteRow = () => { const rows = [...pop.querySelectorAll('[data-i]')]; rows.forEach((r, i) => r.classList.toggle('hi', i === hi)); const cur = rows[hi]; if (cur) cur.scrollIntoView({ block: 'nearest' }); }; inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); inp.focus(); } // close the popup but keep the field focused, so the next ↓ navigates rows else if (e.key === 'ArrowDown') { if (browsing()) { hi = Math.min(list.length - 1, hi + 1); hiliteRow(); e.preventDefault(); } else { close(); if (focusSameField(inp, 1)) e.preventDefault(); } } else if (e.key === 'ArrowUp') { if (browsing()) { hi = Math.max(0, hi - 1); hiliteRow(); e.preventDefault(); } else { close(); if (focusSameField(inp, -1)) e.preventDefault(); } } else if (e.key === 'Enter') { e.preventDefault(); const c = list[hi >= 0 ? hi : 0]; if (c) { const entry = slot._entry, i = entry.slots.indexOf(slot); choose(c); focusSlotInput(entry, i); } } // keep focus on the field after picking (so ↓ moves on) }; inp.onblur = () => setTimeout(close, 160); // keep whatever the user typed (no reset) } // #284: hovering an artist highlights every OTHER instance of that same artist in // the tracklist (matched by gid when resolved, else by the typed/credited name) — // the same idea as the green "matched" bars, but live on hover. STICKY: the last // highlighted artist stays lit even when the mouse isn't over it; moving onto a // DIFFERENT real artist clears the previous highlight (and lights the new one up if // it appears more than once). Only empty slots / gaps keep the previous highlight — // so moving the mouse across rows/gaps never flickers, but landing on another artist // (even a one-off) drops the stale highlight. function hlArtist(id) { if (!SETTINGS.hoverHighlight) return; // #284: opt-in (Appearance setting, off by default) if (!id || id === 'n:' || id === 'g:' || id === _hlCur) return; // empty slot or already current → keep current _hlCur = id; document.querySelectorAll('.tc-aslot.tc-arthl').forEach(e => e.classList.remove('tc-arthl')); const matches = [...document.querySelectorAll('.tc-aslot')].filter(e => e.dataset.art === id); if (matches.length > 1) matches.forEach(e => e.classList.add('tc-arthl')); // single instance → nothing to show } let _hlCur = null; const slotArtId = s => (s.committed && s.gid) ? 'g:' + s.gid : 'n:' + fold(s.creditedAs || s.name || s.query || ''); // one artist = one aligned line: [credited-as][icon][green/white search bar][join][↵ hover][✕ hover] function slotEl(entry, s, idx, refreshBadges) { const line = document.createElement('div'); line.className = 'tc-aslot'; line.dataset.art = slotArtId(s); line.addEventListener('mouseenter', () => hlArtist(line.dataset.art)); // "splittable" (several artists) drives both the credited-as highlight and the ⋔ button, via a line // class that updates live as you edit if (splitArtistText(s.creditedAs || s.name || s.query || '').length > 1) line.classList.add('tc-can-split'); // credited-as: shown empty when it's exactly the artist name (the name is the placeholder); only a real override shows const same = s.name && s.creditedAs === s.name; const cred = document.createElement('input'); cred.className = 'tc-cred'; cred.value = (s.creditedAs && !same) ? s.creditedAs : ''; cred.placeholder = s.name || 'credit…'; cred.title = 'credited-as override (blank = same as the artist name)'; // #228: a hover × on the left of the field clears the credited-as override const credClr = document.createElement('button'); credClr.type = 'button'; credClr.className = 'tc-cred-clr'; credClr.textContent = '×'; credClr.title = 'clear the credited-as override'; const updCredClr = () => line.classList.toggle('tc-has-cred', !!cred.value.trim()); credClr.onmousedown = e => e.preventDefault(); credClr.onclick = () => { if (!cred.value) return; cred.value = ''; cred.onchange(); updCredClr(); }; cred.oninput = () => { line.classList.toggle('tc-can-split', splitArtistText(cred.value || s.name || '').length > 1); updCredClr(); }; // re-evaluate the highlight / ⋔ + clear button as you type cred.onchange = () => { const v = cred.value.trim(); const newCred = v || (s.name || ''); // whole-credit "all matching tracks" propagation (liveRerender=false → keep focus when nothing propagates) editCredit(entry, () => { s.creditedAs = newCred; if (s.creditedAs === s.name) cred.value = ''; }, 'credited-as', false); refreshBadges(); // a credited-as edit changes the track → update the ↺ button + changed-row border now }; wireRowNav(cred); // wrap so the × can be absolutely positioned over the field's (empty, right- // aligned) left edge — it must not take flow space or it shifts the row (#228) const credWrap = document.createElement('span'); credWrap.className = 'tc-credwrap'; credWrap.appendChild(cred); credWrap.appendChild(credClr); line.appendChild(credWrap); updCredClr(); let ic; if (s._discogsAddable) { // #227: a Discogs link can be added — swap the artist-type icon for a link icon. // Amber warning glyph (still clickable to add) when the URL belongs to a // different MB artist (conflict) or the artist already links a different // Discogs page (mismatch); plain teal link icon otherwise. const conf = s._discogsConflict, mism = s._discogsMismatch; // conflict (URL belongs to a different MB artist) → red triangle; mismatch (artist links a // different Discogs page) → amber circle-! ; conflict wins the icon when both are flagged. const warnCls = conf ? 'discogs-conflict' : 'discogs-mismatch', warnSvg = conf ? DISCOGS_WARN_SVG : DISCOGS_MISMATCH_SVG; ic = document.createElement('a'); ic.className = 'tc-tic ' + ((conf || mism) ? warnCls : 'discogs-add'); ic.href = '#'; ic.innerHTML = (conf || mism) ? warnSvg : DISCOGS_LINK_SVG; ic.title = discAddTooltip(s); ic.title += ' · right-click: do it silently in a background tab'; ic.onmousedown = e => e.preventDefault(); ic.onclick = e => { e.preventDefault(); addOrCreateDiscogsLink(s); }; ic.oncontextmenu = e => { e.preventDefault(); addOrCreateDiscogsLink(s, true); }; // #273 background } else if (s._discogsConflict) { // unresolved slot whose Discogs URL already belongs to an MB artist — info only (pick that artist) ic = document.createElement('a'); ic.className = 'tc-tic discogs-conflict'; ic.innerHTML = DISCOGS_WARN_SVG; ic.href = `${ORIGIN}/artist/${s._discogsConflict.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = `Discogs links this URL to ${s._discogsConflict.name} — pick that artist`; } else { ic = document.createElement(s.gid ? 'a' : 'span'); ic.className = 'tc-tic ' + (s.gid ? 'link' : 'dim'); ic.innerHTML = typeSvg(s.entity); if (s.gid) { ic.href = `${ORIGIN}/artist/${s.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = 'open artist page'; } else ic.title = 'no artist linked yet'; } line.appendChild(ic); const search = document.createElement('span'); search.className = 'tc-search'; const inp = document.createElement('input'); inp.className = 'nm'; inp.value = s.committed ? (s.name || s.creditedAs) : (s.query || s.creditedAs || ''); inp.placeholder = 'search artist…'; inp.title = inp.value; search.appendChild(inp); // #228: hover × right after the name clears the field (and unsets a matched // artist — the input handler un-links on an empty value) const nmClr = document.createElement('button'); nmClr.type = 'button'; nmClr.className = 'tc-nm-clr'; nmClr.textContent = '×'; nmClr.title = 'clear / unset the artist'; // size the input to its text (so the clear × hugs the name when not editing — #284 follow-up); // `size` is in characters, close enough. Min 2 keeps a clicked-but-empty field usable. const updNmClr = () => { const v = inp.value.trim(); search.classList.toggle('tc-has-nm', !!v); fitNmWidth(inp); }; nmClr.onmousedown = e => e.preventDefault(); nmClr.onclick = () => { inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.focus(); updNmClr(); }; inp.addEventListener('input', updNmClr); search.appendChild(nmClr); updNmClr(); if (idx < entry.slots.length - 1) search.appendChild(joinControl(entry, s, refreshBadges)); // join lives inside the box, right side // #376 keep the pending gold in sync with the slot's selected/committed state on every re-adorn — // editing the field to free text (unselecting the mbid) must drop the gold, not just the green. const applyPend = () => { const ps = recPendingState(entry.mi, entry.ti); search.classList.toggle('tc-slot-pending', slotIsPending(ps && ps.artGids, s)); }; adorn(search, s, inp); applyPend(); if (s._marked) search.classList.add('tc-marked'); if (s._flash) { search.classList.add('tc-flash'); delete s._flash; } line.appendChild(search); wireAutocomplete(inp, s, () => { adorn(search, s, inp); applyPend(); refreshBadges(); refreshStatus(); }); // fixed-width actions area (keeps all search boxes the same width); both reveal on row hover const acts = document.createElement('span'); acts.className = 'tc-acts'; const add = document.createElement('button'); add.className = 'tc-enter'; add.textContent = '↵'; add.title = 'add another artist to this credit'; add.onclick = () => addSlotAfter(entry, idx); acts.appendChild(add); // ⋔ split: only when this credit looks like several artists (& / feat. / , …) { const sp = document.createElement('button'); sp.className = 'tc-splitb'; sp.textContent = '⋔'; sp.title = 'split into separate artists (& / feat. …) and match'; sp.onclick = () => splitSlot(entry, idx); acts.appendChild(sp); } if (entry.slots.length > 1) { const g = document.createElement('span'); g.className = 'tc-slotgrab'; g.textContent = '⠿'; g.draggable = true; g.title = 'drag to reorder this artist within the credit'; acts.appendChild(g); // #150 const x = document.createElement('button'); x.className = 'tc-slotx'; x.textContent = '✕'; x.title = 'remove this artist'; x.onclick = () => removeSlot(entry, idx); acts.appendChild(x); } line.appendChild(acts); if (entry.slots.length > 1) wireSlotDrag(line, entry, idx); // #150 return line; } // #150: reorder an artist within a track's credit by dragging its ⠿ handle onto another slot. // Join phrases ("feat." / "&" / …) are positional separators that stay put as artists move through // them — so "A feat. B" reordered reads "B feat. A", and the final position is always join-less. function moveSlot(entry, from, to) { const n0 = entry.slots.length; if (from === to || from < 0 || to < 0 || from >= n0 || to >= n0) return; editCredit(entry, () => { const joins = entry.slots.map(s => s.joinPhrase); const [s] = entry.slots.splice(from, 1); entry.slots.splice(to, 0, s); entry.slots.forEach((x, i) => { x.joinPhrase = i < n0 - 1 ? (joins[i] || ' & ') : ''; x._entry = entry; }); }, 'reordered artist'); } let _slotDrag = null; // { entry, from } of the artist slot being dragged function wireSlotDrag(line, entry, idx) { const handle = line.querySelector('.tc-slotgrab'); if (handle) { handle.addEventListener('dragstart', e => { _slotDrag = { entry, from: idx }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'slot'); } catch (x) {} try { e.dataTransfer.setDragImage(line, 18, 12); } catch (x) {} line.classList.add('tc-slotdragging'); }); handle.addEventListener('dragend', () => { line.classList.remove('tc-slotdragging'); clearSlotDropMarks(line.parentElement); _slotDrag = null; }); } const after = e => (e.clientY - line.getBoundingClientRect().top) > line.getBoundingClientRect().height / 2; line.addEventListener('dragover', e => { if (!_slotDrag || _slotDrag.entry !== entry) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearSlotDropMarks(line.parentElement); line.classList.add(after(e) ? 'tc-slotdrop-after' : 'tc-slotdrop-before'); }); line.addEventListener('dragleave', () => line.classList.remove('tc-slotdrop-before', 'tc-slotdrop-after')); line.addEventListener('drop', e => { if (!_slotDrag || _slotDrag.entry !== entry) return; e.preventDefault(); const from = _slotDrag.from, gap = idx + (after(e) ? 1 : 0), dest = gap > from ? gap - 1 : gap; clearSlotDropMarks(line.parentElement); _slotDrag = null; moveSlot(entry, from, dest); }); } const clearSlotDropMarks = host => host && host.querySelectorAll('.tc-slotdrop-before,.tc-slotdrop-after').forEach(l => l.classList.remove('tc-slotdrop-before', 'tc-slotdrop-after')); // drag-to-reorder WITHIN a medium: grab the ⠿ handle and drop a track anywhere in its medium. The actual // move rides on MB's own up/down ops (moveTrackToIndex), so the editor never diverges. Cross-medium drops // are ignored (same-medium only). Replaces the old ▲▼ buttons. let _drag = null; // { mi, ti } of the row being dragged const clearDropMarks = tb => tb && tb.querySelectorAll('.tc-drop-before,.tc-drop-after').forEach(r => r.classList.remove('tc-drop-before', 'tc-drop-after')); const dropAfter = (tr, clientY) => { const r = tr.getBoundingClientRect(); return (clientY - r.top) > r.height / 2; }; function wireDragReorder(tr, t) { const handle = tr.querySelector('.tc-drag'); if (handle) { handle.addEventListener('dragstart', e => { _drag = { mi: t.mi, ti: t.ti }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', t.mi + ':' + t.ti); } catch (x) {} try { e.dataTransfer.setDragImage(tr, 18, 12); } catch (x) {} tr.classList.add('tc-dragging'); }); handle.addEventListener('dragend', () => { tr.classList.remove('tc-dragging'); clearDropMarks(tr.parentElement); _drag = null; }); } tr.addEventListener('dragover', e => { if (!_drag || _drag.mi !== t.mi) return; // same medium only e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearDropMarks(tr.parentElement); tr.classList.add(dropAfter(tr, e.clientY) ? 'tc-drop-after' : 'tc-drop-before'); }); tr.addEventListener('dragleave', () => tr.classList.remove('tc-drop-before', 'tc-drop-after')); tr.addEventListener('drop', e => { if (!_drag || _drag.mi !== t.mi) return; e.preventDefault(); const fromTi = _drag.ti, gap = t.ti + (dropAfter(tr, e.clientY) ? 1 : 0), dest = gap > fromTi ? gap - 1 : gap; clearDropMarks(tr.parentElement); _drag = null; if (moveTrackToIndex({ mi: t.mi, ti: fromTi, number: fromTi + 1 }, dest)) rebuild(); }); } function fillRows(tbody, mi) { document.querySelectorAll('.tc-acpop').forEach(p => p.remove()); // rebuilding rows detaches inputs — drop any open search/join popups so they can't orphan tbody.innerHTML = ''; let lastMi = -1, lastDataMi = -1; const multi = mediums().length > 1 && mi == null; const tracks = (mi == null) ? MODEL.tracks : MODEL.tracks.filter(t => t.mi === mi); tracks.forEach(t => { if (multi && t.mi !== lastMi) { const r = document.createElement('tr'); const td = document.createElement('td'); td.className = 'tc-medhdr'; td.colSpan = COLS.length; td.textContent = `Medium ${t.mi + 1}`; const o = mediumOpts(t.mi); if (o) td.appendChild(o); r.appendChild(td); tbody.appendChild(r); lastMi = t.mi; } // #330: per-medium pregap/data toggles in the combined view const kind = trackKind(t); // #330: pregap / data / audio if (kind === 'data' && t.mi !== lastDataMi) { const dr = document.createElement('tr'); dr.className = 'tc-datadiv'; dr.innerHTML = `⤓ Data tracks`; tbody.appendChild(dr); lastDataMi = t.mi; } const tr = document.createElement('tr'); tr.dataset.tk = t.mi + ':' + t.ti; tr.dataset.mi = t.mi; tr.dataset.ti = t.ti; if (kind !== 'audio') tr.classList.add(kind === 'pregap' ? 'tc-row-pregap' : 'tc-row-data'); // #376 mirror MB's native "modification pending" mark (span.mp): flag recordings that have open edits const ps = recPendingState(t.mi, t.ti); if (ps && (ps.rec || ps.art)) { if (ps.rec) tr.classList.add('tc-rec-pending'); if (ps.art) tr.classList.add('tc-art-pending'); tr.title = ps.rec && ps.art ? 'Recording and artist have pending edits' : ps.rec ? 'This recording has pending edits' : 'This recording’s artist has pending edits'; } const locked = mediumLocked(t.mi); // disc-ID medium: no reorder handle (#125) const lenLocked = trackLenLocked(t); // disc-ID medium: audio-track length fixed by the TOC (#329) const canDrag = !locked && kind === 'audio'; // #330: pregap is pinned at 0, data tracks aren't reordered here tr.innerHTML = `${canDrag ? '' : ''}
${kind === 'pregap' ? 'pregap' : ''}
`; const badgeCell = tr.querySelector('.c-badge'); const refreshBadges = () => renderBadgeCell(badgeCell, t); const art = tr.querySelector('.c-art'); const pgids = ps && ps.artGids; // #376 gold ONLY the slot(s) whose SELECTED artist has pending edits — not free-text neighbours, and not a field edited to free text t.slots.forEach((s, si) => { const se = slotEl(t, s, si, refreshBadges); const sr = se.querySelector('.tc-search'); if (sr) sr.classList.toggle('tc-slot-pending', slotIsPending(pgids, s)); art.appendChild(se); }); refreshBadges(); // guess-case: highlight when the title differs from its guessed form; a per-title button applies it const tin = tr.querySelector('.t-title'); const diff = t.guessTitle && t.guessTitle !== t.title; if (t._srFlash) { tin.classList.add('srflash'); delete t._srFlash; } // flash titles changed by search & replace // #203: rich title display — show the title as styled read-only text (confusable / // invisible chars enlarged + named on hover) when not editing, and drop into the // native input on click/tab. Only when enlargement is on (recPunctSize > 0). let disp = null, paintDisp = null; if ((SETTINGS.recPunctSize | 0) > 0) { disp = document.createElement('span'); disp.className = 't-title-disp'; paintDisp = (val) => { const v = val != null ? val : tin.value; // empty title → a faint "title…" placeholder so there's a visible, full-height // click target (an empty disp collapses to a hard-to-hit thin line). #357 disp.innerHTML = v ? dhRun(v) : 'title…'; ['diff', 'gcpreview', 'hasfeat', 'srflash'].forEach(c => disp.classList.toggle(c, tin.classList.contains(c))); }; tin.classList.add('tc-eml'); tin.parentElement.insertBefore(disp, tin); // sits in the input's flex slot while resting disp.addEventListener('mousedown', e => { e.preventDefault(); tin.focus(); }); tin.addEventListener('focus', () => { tin.classList.add('tc-editing'); disp.classList.add('tc-hidden'); }); tin.addEventListener('blur', () => { tin.classList.remove('tc-editing'); paintDisp(tin.value); disp.classList.remove('tc-hidden'); }); paintDisp(t.title); } if (diff) { tin.classList.add('diff'); tin.title = 'Guess case → ' + t.guessTitle; const gb = document.createElement('button'); gb.className = 't-gc'; gb.textContent = 'Aa'; gb.title = 'Guess case → ' + t.guessTitle + '\n(right-click: guess case all tracks)'; const wrap = tr.querySelector('.t-wrap'); // like MB's integrated guess case: hovering the title cell previews the guessed name // (highlighted), leaving restores it, clicking Aa applies it. Never preview while editing. const preview = () => { if (document.activeElement !== tin) { tin.value = t.guessTitle; tin.classList.add('gcpreview'); if (paintDisp) paintDisp(t.guessTitle); } }; const restore = () => { tin.value = t.title; tin.classList.remove('gcpreview'); if (paintDisp) paintDisp(t.title); }; wrap.onmouseenter = preview; wrap.onmouseleave = () => { if (document.activeElement !== tin) restore(); }; tin.addEventListener('focus', restore); // clicking in to edit shows the real title, not the preview gb.onclick = () => { restore(); applyGuessTitle(t); t.title = u(koTrack(t.mi, t.ti).name); t.guessTitle = guessTitleStr(t); rerender(); }; // right-click the [Aa] runs guess case on every track (same as the Tools-menu action) — #123 gb.oncontextmenu = e => { e.preventDefault(); restore(); guessCaseAll(); }; tActions(wrap).appendChild(gb); } // featured-artist split: flag titles carrying "feat./ft./featuring" and offer the split inline, // mirroring [Aa] — click ⋔ splits this track, right-click splits all (#124) if (FEAT_RE.test(t.title)) { tin.classList.add('hasfeat'); if (!tin.title) tin.title = 'Title has a featured artist'; const fb = document.createElement('button'); fb.className = 't-feat'; fb.textContent = '⋔'; fb.title = 'Split featured artist out of the title into the artist credit\n(right-click: split all tracks)'; fb.onclick = () => guessFeatTrack(t); fb.oncontextmenu = e => { e.preventDefault(); guessFeatAll(); }; tActions(tr.querySelector('.t-wrap')).appendChild(fb); } tin.onchange = e => { setTitle(t, e.target.value); t.title = e.target.value; t.guessTitle = guessTitleStr(t); rerender(); }; wireRowNav(tin); if (paintDisp) paintDisp(t.title); // re-sync after the diff / feat blocks set their state classes const numIn = tr.querySelector('.t-num'), lenIn = tr.querySelector('.t-len'); numIn.onchange = e => { setNumber(t, e.target.value); refreshBadges(); }; wireRowNav(numIn); if (!lenLocked) lenIn.onchange = e => { let v = e.target.value.trim(); if (v) { const ed = getEditor(); const ms = ed && ed.utils && ed.utils.unformatTrackLength ? ed.utils.unformatTrackLength(v) : NaN; if (ms == null || isNaN(ms)) v = ''; } // invalid (letters/garbage) → delete; valid shorthand like "111" is kept (MB normalizes it to 1:11) setLength(t, v); try { const ko = koTrack(t.mi, t.ti); const norm = typeof ko.formattedLength === 'function' ? (u(ko.formattedLength()) || '') : v; e.target.value = norm; t.length = norm; } catch (err) { e.target.value = v; t.length = v; } // reflect MB's normalized value back into the cell immediately refreshBadges(); }; wireRowNav(lenIn); if (canDrag) wireDragReorder(tr, t); // #330: don't make pregap/data rows drag sources or drop targets tbody.appendChild(tr); }); } async function loadAndRender(onProgress) { MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } // (re)build per-medium tables + hide/tidy native rerender(); // show the tables instantly if (SETTINGS.autoMatch !== false) await matchModel(onProgress); else { updateStatus('auto-match off — click Match'); tagDiscogsForAll(); } // #227: tag 'set' artists even when not matching enrichResolvedAliases(); // batch-fetch aliases for resolved artists (existing releases too) } async function rebuild(noMatch) { MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); if (!noMatch && SETTINGS.autoMatch !== false) await matchModel(); else if (!noMatch) tagDiscogsForAll(); // #227: tag 'set' artists when auto-match is off — but NOT on a clear/revert (it would check zero matched artists); refreshStatus clears the badge instead enrichResolvedAliases(); } // revert to the page-load state, but DON'T auto-match (that only runs on startup) — Match is manual here function revertAll() { if (!MODEL) return; if (!W.confirm("Revert every track to what it was when the page loaded?")) return; MODEL.tracks.forEach(resetTrack); rebuild(true); } function guessCaseAll() { if (!MODEL) return; MODEL.tracks.forEach(t => { applyGuessTitle(t); t.title = u(koTrack(t.mi, t.ti).name); t.guessTitle = guessTitleStr(t); }); rerender(); Log.info('guess case → all titles'); } // titles carrying a featured-artist credit ("Foo feat. X", "ft.", "featuring") — detect so the // row can flag them and offer the split inline (#124). Needs a space/bracket/start before the // marker and whitespace/bracket/end after, so words like "soft"/"feats"/"drift" don't trip it. const FEAT_RE = /(?:^|[\s([])(?:feat|ft|featuring)\.?(?=[\s)\]]|$)/i; // integrated MB feature: pull "feat. X" out of titles into artist credits, then re-read + re-match async function guessFeatAll() { const ed = getEditor(); mediums().forEach(med => (u(med.tracks) || []).forEach(t => { try { ed.guessTrackFeatArtists(t); } catch (e) { try { ed.guessTrackFeatArtists(t, { type: 'click' }); } catch (e2) { Log.warn('guess feat failed', e2.message); } } })); await loadAndRender(); Log.info('guessed feat artists from titles'); } // single-track variant — fired by the per-track ⋔ split button (#124) async function guessFeatTrack(entry) { const ed = getEditor(), t = koTrack(entry.mi, entry.ti); try { ed.guessTrackFeatArtists(t); } catch (e) { try { ed.guessTrackFeatArtists(t, { type: 'click' }); } catch (e2) { Log.warn('guess feat failed', e2.message); } } await loadAndRender(); Log.info('guessed feat artists for track', entry.number); } // medium-scoped tools — each acts on one medium (chosen via the inline medium combo) async function swapMedium(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; _selfEdit = true; try { ed.swapTitlesWithArtists(m); } catch (e) { Log.warn('swap failed', e.message); } finally { _selfEdit = false; } await loadAndRender(); Log.info('swapped titles ↔ artists on medium', mi + 1); } function resetNumbers(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; _selfEdit = true; try { ed.resetTrackNumbers(m); } catch (e) { Log.warn('reset numbers failed', e.message); } finally { _selfEdit = false; } rebuild(); } function openParser(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; try { ed.openTrackParser(m); } catch (e) { Log.warn('open parser failed', e.message); } } function runMediumTool(act, mi) { if (act === 'parser') openParser(mi); else if (act === 'resetnum') resetNumbers(mi); else if (act === 'swap') swapMedium(mi); } function runAction(a) { // #378 a bridged external tool (x:…). The PINNED toolbar button reaches us via bindActions (which // rebinds every [data-act] to runAction, overriding pickTool), so re-sync the bridge map HERE too — // otherwise a stale map (a prior sync while the external button was hidden) means nothing fires, while // the Tools menu (which goes through pickTool/getToolCfg) still works. if (String(a).startsWith('x:')) { syncBridges(); const b = _bridgeMap[a]; if (b) fireBridge(b); else Log.warn('Apollo bridge not present:', a); return; } if (a === 'match') matchAll(); else if (a === 'revert') revertAll(); else if (a === 'guesscase') guessCaseAll(); else if (a === 'guessfeat') guessFeatAll(); else if (a === 'cols') colsFit(); // the Columns button's default action is Fit else if (MEDIUM_TOOLS.has(a)) runMediumTool(a, 0); else if (_bridgeMap[a]) fireBridge(_bridgeMap[a]); } function bindActions(host) { host.querySelectorAll('[data-act]').forEach(b => { const a = b.dataset.act; b.onclick = () => { if (a === 'menu') openToolsMenu(b); else if (a === 'tool') runActiveTool(); else if (a === 'toolsmenu') openToolsMenu(b); else if (a === 'toolscfg') openToolsConfig(b); else if (a === 'gear') openSettings(b); else if (a === 'revertmenu') openMiniMenu(b, [{ label: '↺ Revert all', title: 'revert every track to its page-load state', onClick: revertAll }, { label: '✕ Clear all', title: 'unselect every track artist, keeping the credited-as text (titles and lengths kept)', onClick: clearAllTracks }]); else if (a === 'close') { host.remove(); ACTIVE = {}; } else runAction(a); }; }); } // a small one-off dropdown (e.g. the ▾ next to "Revert all"); items: {label, title?, onClick} function openMiniMenu(anchor, items) { document.querySelectorAll('.tc-menu.tc-mini').forEach(m => m.remove()); const m = document.createElement('div'); m.className = 'tc-menu tc-mini'; m.innerHTML = items.map((it, i) => `
${esc(it.label)}
`).join(''); document.body.appendChild(m); const r = anchor.getBoundingClientRect(); const w = m.offsetWidth, h = m.offsetHeight; // right-align the menu to the anchor (opens leftward) and clamp on-screen, so it never runs off the edge m.style.left = Math.round(Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))) + 'px'; m.style.top = Math.round(Math.max(8, Math.min(r.bottom + 4, window.innerHeight - h - 8))) + 'px'; m.querySelectorAll('.tc-mi').forEach(el => el.onclick = () => { const it = items[+el.dataset.i]; m.remove(); it.onClick(); }); const off = e => { if (!m.contains(e.target) && e.target !== anchor) { m.remove(); document.removeEventListener('mousedown', off, true); } }; setTimeout(() => document.addEventListener('mousedown', off, true), 0); } // Tracklist "Clear all": empty the title, artist credit and length of every track (with confirm) function clearAllTracks() { if (!MODEL) return; if (!W.confirm('Unselect the artist of EVERY track? (the credited-as text, titles and lengths are kept; does not submit)')) return; MODEL.tracks.forEach(t => { try { const ko = koTrack(t.mi, t.ti); const names = u(u(ko.artistCredit).names) || []; // keep each credit's display text (the credited-as name, or the artist's name if none) but drop the // selected entity — turning a matched artist back into unmatched text (artist: bare {name}), ready // to re-match. Mirrors how commitTrack writes an unresolved slot. ko.artistCredit({ names: names.map(n => { const text = u(n.name) || (n.artist && u(u(n.artist).name)) || ''; return { artist: { name: text }, name: text, joinPhrase: u(n.joinPhrase) || '' }; }) }); } catch (e) {} }); Log.info('cleared all track artist selections (kept credited-as text)'); rebuild(true); // no re-match, or it would instantly re-link the kept text } /* ── Configurable Tools bar (#280) ─────────────────────────────────────────── A row of tool buttons you choose (the rest live behind ⋯). The ACTIVE tool's params render inline, right after the buttons; tools you "pin" keep their params on a 2nd row. Click the "Tools" label to customise (visibility, order, per-tool icon/text, pinning). ── */ const MENU = [ { act: 'parser', label: 'Track parser', icon: '☰' }, // ☰ { act: 'swap', label: 'Swap', icon: '⇅' }, // ⇅ { act: 'resetnum', label: 'Reset #', icon: '#' }, { act: 'guessfeat', label: 'Guess feat.', icon: 'ft', instant: true }, { act: 'guesscase', label: 'Guess case', icon: 'Aa', params: true }, { act: 'sr', label: 'Search and Replace', icon: 'S&R', params: true }, { act: 'cols', label: 'Resize columns', icon: '↔', params: true }, // ↔ ]; const TOOL = Object.fromEntries(MENU.map(m => [m.act, m])); const LABELS = Object.fromEntries(MENU.map(m => [m.act, m.label])); const MEDIUM_TOOLS = new Set(['parser', 'resetnum', 'swap']); // act on ONE medium (inline medium combo when >1) const OPTLESS = new Set(['guessfeat']); // global, no options — fires on pick (non-sticky) const hasParams = act => !!(TOOL[act] && TOOL[act].params); /* ── Tier-0 tool bridges (#322) ─────────────────────────────────────────── Surface a button that some OTHER (user)script drops on the page as a fire-and-forget Apollo Tool, but ONLY when it's actually present. Two ways: (a) Built-in registry — for known buttons we want to adopt UNMODIFIED (e.g. kellnerd's "Guess punctuation"), matched by visible text. Add another by appending one { label, icon, find } entry; `find()` returns the live element or null. (b) Convention — any element tagged `class="apollo-tool"` (optional `data-apollo-label` / `data-apollo-icon`) is surfaced too, so a cooperating script self-registers with NO Apollo change. Either kind is param-less: activating clicks the element, then Apollo re-reads (the external code writes the native fields / KO model, which Apollo doesn't watch per-value), so its grid reflects the change. ── */ const bridgeBtn = re => [...document.querySelectorAll('button, input[type="button"], input[type="submit"]')] // #378 must NOT match Apollo's OWN surfaced copy of the tool (the pinned bar button / Tools-menu item // carry the same label) — otherwise the pinned button fires itself and nothing happens. Skip our UI. .find(b => b.offsetParent !== null && !b.closest('.tc-tools, .tc-toolbtns, .tc-menu, #tc-panel, .tc-mirror') && re.test((b.textContent || b.value || '').trim())) || null; // built-in registry: known external buttons, each with a STABLE `act` so its // bar/menu placement persists. Add one entry { act, label, icon, find } to bridge another. const BRIDGE = [ { act: 'x:gpunct', label: 'Guess punctuation', icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAHYgAAB2IBOHqZ2wAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAFpSURBVDiNpZOxjwFBGMV/e5FspZeoFETlL9Bug0RDL5FolVpRUqxCr1iNUelUEhmFZqlEVAolFRuxsswVl9uzWVfceeX73nvzfTPzaUIIxRuIAJTL5X+ZR6MRH++cDrwOOBwOdLtdbrdbqDafzxmPx78H2LZNtVplt9txPp993vM8TNOk1WoFeIQQ6htSSmUYhur3++rxePi853mq0WioUqmkttutzwshVOS57U6nQy6Xo1KpBLoaDAYsl0t6vR6pVOr1HViWheM4IfPlcmE4HJLNZkPmQMBqtSIajbJYLFiv175gs9lwvV653+/MZjOOx2MgwB/BdV1OpxPtdhuAYrFIvV73X0JKiZQSXdcxTZN0Oh3sIBaLBZInkwlKqRDvui7T6TQ8gmEYAWE8HkfTNBKJBMlkMlQLjVAoFHAcB9u20XWdWq3mi5rNJpZlsd/vyWQy5PP5n7Tnf/BXCCHU27sQga+t+i8+AYUS9lO02Bg3AAAAAElFTkSuQmCC', find: () => bridgeBtn(/^guess\s+punctuation$/i) }, ]; let _bridges = [], _bridgeMap = {}; // refresh the tools available right now: built-in matches that resolve + elements // tagged class="apollo-tool" (self-registering). Each carries a stable act so the // normal tool-config (bar / menu / Customize, persisted) treats it like a native tool. function syncBridges() { const out = []; for (const b of BRIDGE) { let el = null; try { el = b.find(); } catch (e) {} if (el) out.push({ act: b.act, label: b.label, icon: b.icon, el }); } document.querySelectorAll('.apollo-tool').forEach(el => { const label = el.dataset.apolloLabel || (el.textContent || '').trim() || 'Tool'; const act = 'x:' + (el.dataset.apolloId || el.id || label.toLowerCase().replace(/[^a-z0-9]+/g, '-')); if (!out.some(o => o.act === act)) out.push({ act, label, icon: el.dataset.apolloIcon || '🔧', el }); }); _bridges = out; _bridgeMap = Object.fromEntries(out.map(b => [b.act, b])); Log.debug('Apollo bridges synced:', out.length ? out.map(b => b.act + (b.el ? '' : '(no-el)')).join(', ') : '(none)'); // #378 diagnostics return out; } // tool-def accessor: native tools + present bridges, so render/customize treat them alike const TD = act => TOOL[act] || _bridgeMap[act] || { act, icon: '?', label: act }; // a tool icon is normally a short text glyph (Aa, S&R, ↔); a data:/http(s) URL // renders as a small instead, so a bridged tool can ship its own icon. const isImgIcon = v => typeof v === 'string' && /^(?:data:|https?:\/\/)/.test(v); const iconHtml = v => isImgIcon(v) ? `` : esc(v == null ? '' : v); async function fireBridge(it) { if (!it) { Log.warn('Apollo bridge: nothing to fire (no tool)'); return; } // #378 re-resolve the LIVE element at fire time: the cached one can be stale, and a fresh find() also // avoids firing Apollo's own surfaced copy of the tool. Fall back to the cached element. let el = it.el; const reg = BRIDGE.find(b => b.act === it.act); try { if (reg && reg.find) { const fresh = reg.find(); if (fresh) el = fresh; } } catch (e) { Log.warn('Apollo bridge: find() threw', e.message); } Log.info('Apollo bridge:', it.label, '· cached el', it.el ? 'yes' : 'null', '· resolved', !el ? 'null' : (el === it.el ? 'cached' : 'fresh'), '· ourUI', !!(el && el.closest && el.closest('.tc-tools,.tc-toolbtns,.tc-menu,#tc-panel'))); if (!el) { Log.warn('Apollo bridge: element gone —', it.label); return; } Log.info('Apollo bridge: clicking', it.label, '→', el.tagName + '.' + ((el.className || '').toString().split(' ')[0] || '')); el.click(); try { await rebuild(true); } catch (e) { Log.warn('Apollo bridge: re-render failed', e.message); } // re-read (no re-match) } // default Tools bar (in display order) for a fresh install — every other tool starts // in the Tools ▾ menu. Each entry sets the tool's icon/text and whether its params // start collapsed (right-click a name toggles that later). const TOOL_DEFAULTS = [ { act: 'guesscase', icon: true, text: false, hideParams: true }, // Aa, collapsed { act: 'cols', icon: false, text: true, hideParams: true }, // Resize columns, collapsed { act: 'sr', icon: true, text: false, hideParams: false }, // S&R, params shown ]; const TOOL_DEF = Object.fromEntries(TOOL_DEFAULTS.map(d => [d.act, d])); let _toolMedium = 0; // the medium chosen in the inline combo — shared across all medium-scoped tools const toolMedium = () => Math.min(Math.max(0, _toolMedium), mediums().length - 1); // per-tool config: { act, onBar, pinned, icon, text } in display order. Saved // tools merge over defaults; any tool not yet saved (a future one) is appended // and defaults to the ⋯ menu. function getToolCfg() { syncBridges(); // #322: present bridges become first-class tools (cfg/bar/menu/customize) const known = act => !!(TOOL[act] || _bridgeMap[act]); // drop saved entries for tools/bridges no longer present const saved = Array.isArray(SETTINGS.toolCfg) ? SETTINGS.toolCfg.filter(t => t && known(t.act)) : []; const order = saved.map(t => t.act); // fresh install: the default on-bar tools lead (in their order), then the rest TOOL_DEFAULTS.forEach(d => { if (!order.includes(d.act)) order.push(d.act); }); MENU.forEach(m => { if (!order.includes(m.act)) order.push(m.act); }); _bridges.forEach(b => { if (!order.includes(b.act)) order.push(b.act); }); // present bridges default to the Tools menu const byAct = Object.fromEntries(saved.map(t => [t.act, t])); return order.map(act => { const s = byAct[act] || {}, d = TOOL_DEF[act]; let icon = s.icon != null ? !!s.icon : (d ? d.icon : true); let text = s.text != null ? !!s.text : (d ? d.text : true); if (!icon && !text) text = true; // at least one of icon/text const onBar = s.onBar != null ? !!s.onBar : !!d; const hideParams = s.hideParams != null ? !!s.hideParams : (d ? !!d.hideParams : false); return { act, onBar, icon, text, hideParams }; }); } function saveToolCfg(cfg) { SETTINGS.toolCfg = cfg.map(t => ({ act: t.act, onBar: !!t.onBar, icon: t.icon !== false, text: t.text !== false, hideParams: !!t.hideParams })); saveSettings(); } const cfgOf = act => getToolCfg().find(t => t.act === act) || { act, onBar: false, icon: true, text: true }; // tools surfaced on the bar for THIS session only (picked from the Tools menu) — // never persisted; they fall back to the menu next session (#280). const TEMP_BAR = new Set(); // hovering the "Guess case" tool button previews the guessed form on every differing title function previewAllGuess(on) { if (!MODEL) return; MODEL.tracks.forEach(t => { if (!(t.guessTitle && t.guessTitle !== t.title)) return; const row = rowEl(t.mi, t.ti); if (!row) return; const tin = row.querySelector('.t-title'); if (!tin || document.activeElement === tin) return; const val = on ? t.guessTitle : t.title; tin.value = val; tin.classList.toggle('gcpreview', on); // #203: when the rich title display is on, the visible text is a .t-title-disp // span over the (hidden) input — repaint it too, or the preview is invisible const disp = row.querySelector('.t-title-disp'); if (disp) { disp.innerHTML = dhRun(val); disp.classList.toggle('gcpreview', on); } }); } function wireToolHover() { document.querySelectorAll('.tc-toolbtn[data-act="guesscase"]').forEach(b => { b.onmouseenter = () => previewAllGuess(true); b.onmouseleave = () => previewAllGuess(false); }); } // #280: render every on-bar tool inline at its position — a plain button when it // has no params, or a group (icon/name trigger + its params) when it does. The // toolbar grows and WRAPS (flex) when it runs out of room (CSS), never shoving Match. function renderToolbar() { const host = document.querySelector('.tc-toolbtns'); if (!host) return; host.innerHTML = ''; getToolCfg().filter(t => t.onBar || TEMP_BAR.has(t.act)).forEach(t => host.appendChild(hasInlineParams(t.act) ? makeToolGroup(t) : makeToolButton(t))); } function makeToolButton(t) { const m = TD(t.act); const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-toolbtn' + (m.instant ? ' instant' : ''); b.dataset.act = t.act; b.innerHTML = (t.icon ? `${iconHtml(m.icon)}` : '') + (t.text ? `${esc(m.label)}` : ''); if (!t.text) b.title = m.label; b.onclick = () => pickTool(t.act); return b; } function pickTool(act) { if (String(act).startsWith('x:')) Log.info('Apollo: external tool activated —', act, '· in map:', !!_bridgeMap[act]); // #378 diagnostics (bar button or Tools-menu item) if (_bridgeMap[act]) return runAction(act); // bridged external tool — fire-and-forget if (OPTLESS.has(act)) return runAction(act); // instant (Guess feat.) // #280: a param tool picked from the Tools menu has nowhere to show its controls // unless it's on the bar — so surface it there for THIS session (not persisted; // it returns to the menu next session). Customize is where you make it permanent. if (hasInlineParams(act)) { if (!cfgOf(act).onBar) { TEMP_BAR.add(act); renderToolbar(); } return triggerTool(act); } if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); } function runActiveTool() { const act = SETTINGS.lastTool; if (!act) return; if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); } // #280: a pinned tool's panel icon runs its primary action (what the row-1 button did) function triggerTool(act) { if (act === 'sr') { const f = document.querySelector('.tc-sr-find'), r = document.querySelector('.tc-sr-rep'); if (f) f.value = ''; if (r) r.value = ''; srActivate(); MODEL && MODEL.tracks.forEach(t => { delete t._srFlash; }); rerender(); if (f) f.focus(); return; } if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); // guesscase → guess-case all · cols → Fit } // the "Tools" label menu: the tools NOT on the bar, then Customize… function openToolsMenu(anchor) { let m = document.getElementById('tc-menu'); if (m) { m.remove(); return; } const off = getToolCfg().filter(t => !t.onBar && !TEMP_BAR.has(t.act)); // includes off-bar bridges (#322) m = document.createElement('div'); m.id = 'tc-menu'; m.className = 'tc-menu'; m.innerHTML = off.map(t => `
${iconHtml(TD(t.act).icon)}${esc(TD(t.act).label)}
`).join('') + (off.length ? '
' : '') + '
⚙︎Customize…
'; document.body.appendChild(m); const r = anchor.getBoundingClientRect(), mw = m.offsetWidth, mh = m.offsetHeight; const below = r.bottom + 4, above = r.top - 4 - mh; let top = (below + mh > window.innerHeight - 8 && above >= 8) ? above : below; top = Math.max(8, Math.min(top, window.innerHeight - mh - 8)); m.style.left = Math.max(8, Math.min(r.left, window.innerWidth - mw - 8)) + 'px'; m.style.top = top + 'px'; m.querySelectorAll('.tc-mi').forEach(el => el.onclick = () => { m.remove(); if (el.dataset.act === '__cfg') openToolsConfig(anchor); else pickTool(el.dataset.act); }); const off2 = e => { if (!m.contains(e.target) && e.target !== anchor) { m.remove(); document.removeEventListener('mousedown', off2); } }; setTimeout(() => document.addEventListener('mousedown', off2), 0); } // build a tool's parameter controls into `host` (used both inline on row 1 and on row 2) function buildToolParams(act, host) { if (act === 'guesscase') { const g = gcNative(); const box = document.createElement('span'); box.className = 'tc-gco'; if (g && g.lang) { const sel = g.lang.cloneNode(true); sel.className = 'tc-gc-lang'; sel.value = g.lang.value; sel.title = 'Guess Case language'; sel.onchange = () => { setNative(g.lang, sel.value); recomputeGuesses(); }; box.appendChild(sel); } const mkChk = (text, el) => { const l = document.createElement('label'); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = el ? el.checked : false; c.disabled = !el; c.onchange = () => { setNative(el, c.checked); recomputeGuesses(); }; l.append(c, document.createTextNode(' ' + text)); return l; }; if (g) { box.appendChild(mkChk('Keep uppercased', g.keepUC)); box.appendChild(mkChk('Keep Roman', g.roman)); } host.appendChild(box); // #280: no Apply button — clicking the "Aa Guess case" icon/name applies it } else if (act === 'sr') { srActivate(); const box = document.createElement('span'); box.className = 'tc-sro'; // #375 open Saved & History via ↓ in either field, or a ★ button after RE (the in-field caret was hard to hit) const find = document.createElement('input'); find.type = 'text'; find.className = 'tc-sr-find'; find.placeholder = srRegexOn() ? 'search (regex)' : 'search'; const rep = document.createElement('input'); rep.type = 'text'; rep.className = 'tc-sr-rep'; rep.placeholder = 'replace'; const run = () => srLive(find.value, rep.value, true); find.oninput = rep.oninput = run; const openOnDown = e => { if (e.key === 'ArrowDown' && !_srPop) { e.preventDefault(); openSrTemplates(find, find, rep, re); } }; // popup anchors to the search field (left-aligned) find.addEventListener('keydown', openOnDown); rep.addEventListener('keydown', openOnDown); const re = document.createElement('button'); re.type = 'button'; re.className = 'tc-srbtn tc-sr-re' + (srRegexOn() ? ' on' : ''); re.textContent = 'RE'; re.title = 'Use regular expressions (search is a regex; $1, $ work in replace)'; re.onclick = () => { SETTINGS.srRegex = !srRegexOn(); saveSettings(); re.classList.toggle('on', srRegexOn()); find.placeholder = srRegexOn() ? 'search (regex)' : 'search'; run(); }; const star = document.createElement('button'); star.type = 'button'; star.className = 'tc-srbtn tc-sr-star'; star.textContent = '★'; star.title = 'Saved & recent patterns (or press ↓ in the search field)'; star.onclick = () => openSrTemplates(find, find, rep, re); box.append(find, rep, re, star); host.appendChild(box); } else if (act === 'cols') { const box = document.createElement('span'); box.className = 'tc-colso'; const mk = (label, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-colbtn'; b.textContent = label; b.title = title; b.onclick = fn; return b; }; box.append( mk('Fit', 'size #, Title and Length to their content (Artist absorbs the slack)', colsFit), mk('Centered', 'balance Title and Artist to equal width', colsBalanced), mk('Default', 'reset every column to its default width', colsDefault), ); host.appendChild(box); } else if (MEDIUM_TOOLS.has(act) && mediums().length > 1) { const box = document.createElement('span'); box.className = 'tc-medo'; const sel = document.createElement('select'); sel.className = 'tc-medsel'; sel.title = 'which medium'; mediums().forEach((m, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = 'Medium ' + (i + 1); sel.appendChild(o); }); sel.value = String(toolMedium()); sel.onchange = () => { _toolMedium = parseInt(sel.value, 10) || 0; }; // #280: no Run button — pick the medium here, then click the tool's icon/name to run it // (matches Guess case / S&R / Resize, which all apply on the icon click). box.append(sel); host.appendChild(box); } } const hasInlineParams = act => hasParams(act) || (MEDIUM_TOOLS.has(act) && mediums().length > 1); // an inline param tool: a clickable icon/name trigger (obeys icon/text cfg) + its params function makeToolGroup(t) { const act = t.act; const grp = document.createElement('span'); grp.className = 'tc-opt'; grp.dataset.tool = act; const name = document.createElement('span'); name.className = 'tc-optname tc-opttrig'; name.innerHTML = (t.icon ? `${iconHtml(TD(act).icon)}` : '') + (t.text ? `${esc(TD(act).label)}` : ''); name.onclick = () => triggerTool(act); // collapsed: drop the title so the native tooltip can't pop up over the flyout const setTitle = () => { name.title = grp.classList.contains('tc-collapsed') ? '' : TOOL[act].label + ' — click to run, right-click to collapse parameters'; }; // #280: right-click the name collapses/expands its params (state remembered); // collapsed params fly out on hover/focus. Toggle in place so typed S&R text survives. name.oncontextmenu = (e) => { e.preventDefault(); const now = !grp.classList.contains('tc-collapsed'); grp.classList.toggle('tc-collapsed', now); setTitle(); const cfg = getToolCfg(); const c = cfg.find(x => x.act === act); if (c) { c.hideParams = now; saveToolCfg(cfg); } }; // preview on the whole group (not just the name) so it persists while the cursor // moves onto the params/flyout — mouseenter/leave ignore parent↔descendant moves if (act === 'guesscase') { grp.onmouseenter = () => previewAllGuess(true); grp.onmouseleave = () => previewAllGuess(false); } if (t.hideParams) grp.classList.add('tc-collapsed'); setTitle(); grp.appendChild(name); buildToolParams(act, grp); return grp; } let _bridgeKey = '', _bridgeT = 0, _bridgeObs = null; const bridgeKeyNow = () => _bridges.map(b => b.act).sort().join('|'); function refreshBridgesIfChanged() { if (!document.querySelector('.tc-toolbtns')) return; // toolbar not mounted syncBridges(); const key = bridgeKeyNow(); if (key !== _bridgeKey) { _bridgeKey = key; renderToolbar(); } } // #322: an external tool button (e.g. Guess punctuation) may be injected AFTER // Apollo first builds its toolbar, so a pinned bridge wouldn't show until a // re-render. Watch for the present-bridge set changing and re-render the bar. function watchBridges() { if (_bridgeObs) return; _bridgeObs = new MutationObserver(() => { clearTimeout(_bridgeT); _bridgeT = setTimeout(refreshBridgesIfChanged, 300); }); _bridgeObs.observe(document.body, { childList: true, subtree: true }); } function initTools() { renderToolbar(); _bridgeKey = bridgeKeyNow(); watchBridges(); } // ── "Customize tools" popover (opens from the Tools label) ── function openToolsConfig(anchor) { let p = document.getElementById('tc-toolcfg'); if (p) { p.remove(); return; } p = document.createElement('div'); p.id = 'tc-toolcfg'; p.className = 'tc-toolcfg'; let dragAct = null; const apply = () => { renderToolbar(); }; const update = (act, patch) => { const cfg = getToolCfg(); const t = cfg.find(x => x.act === act); if (!t) return; Object.assign(t, patch); if (!t.icon && !t.text) { if ('icon' in patch) t.text = true; else t.icon = true; } // keep ≥1 saveToolCfg(cfg); render(); apply(); }; const reorder = (fromAct, toAct, after) => { const cfg = getToolCfg(); const fi = cfg.findIndex(t => t.act === fromAct); if (fi < 0) return; const item = cfg.splice(fi, 1)[0]; let ti = cfg.findIndex(t => t.act === toAct); if (ti < 0) ti = cfg.length - 1; if (after) ti += 1; cfg.splice(ti, 0, item); saveToolCfg(cfg); render(); apply(); }; const below = (e, row) => { const rc = row.getBoundingClientRect(); return e.clientY > rc.top + rc.height / 2; }; // drop after/before per cursor (#280) function render() { p.innerHTML = '
Customize tools
' + '
Drag ⠿ to reorder · ☑ shows it on the bar (else under the Tools menu) · tools with settings show them inline
'; const list = p.querySelector('.tc-tc-list'); getToolCfg().forEach(t => { const m = TD(t.act); const row = document.createElement('div'); row.className = 'tc-tc-row' + (t.onBar ? '' : ' off'); row.dataset.act = t.act; row.draggable = true; row.innerHTML = '' + `` + `${iconHtml(m.icon)}${esc(m.label)}` + ``; list.appendChild(row); }); list.querySelectorAll('.tc-tc-row').forEach(row => { const act = row.dataset.act; row.querySelector('.cb-onbar').onchange = e => update(act, { onBar: e.target.checked }); row.querySelector('.cb-icon').onclick = () => update(act, { icon: !cfgOf(act).icon }); row.querySelector('.cb-text').onclick = () => update(act, { text: !cfgOf(act).text }); row.ondragstart = e => { dragAct = act; row.classList.add('drag'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', act); } catch (x) {} }; row.ondragend = () => { dragAct = null; p.querySelectorAll('.tc-tc-row').forEach(r => r.classList.remove('drag', 'over-top', 'over-bottom')); }; row.ondragover = e => { e.preventDefault(); row.classList.toggle('over-bottom', below(e, row)); row.classList.toggle('over-top', !below(e, row)); }; // #280: indicate drop direction row.ondragleave = () => row.classList.remove('over-top', 'over-bottom'); row.ondrop = e => { e.preventDefault(); const dir = below(e, row); row.classList.remove('over-top', 'over-bottom'); const from = dragAct || (e.dataTransfer && e.dataTransfer.getData('text/plain')); if (from && from !== act) reorder(from, act, dir); }; }); } render(); document.body.appendChild(p); const r = anchor.getBoundingClientRect(); // open BELOW the whole toolbar (it may have wrapped to several rows), not just // below the Tools label — so the popover never covers the 2nd row (#280) const bar = document.getElementById('tc-bar'); const barBottom = bar ? bar.getBoundingClientRect().bottom : r.bottom; p.style.left = Math.max(8, Math.min(r.left, window.innerWidth - p.offsetWidth - 8)) + 'px'; p.style.top = Math.max(8, Math.min(barBottom + 6, window.innerHeight - p.offsetHeight - 8)) + 'px'; const off = e => { if (!p.contains(e.target) && e.target !== anchor) { p.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // proxy MusicBrainz's own (hidden) Guess-case options so they actually affect its guessing function gcNative() { const fs = document.querySelector('fieldset.guesscase, .guesscase'); if (!fs) return null; const lang = fs.querySelector('select'); const checks = [...fs.querySelectorAll('input[type=checkbox]')]; const txt = c => ((c.closest('label') || {}).textContent || '').toLowerCase(); const keepUC = checks.find(c => txt(c).includes('keep') && txt(c).includes('uppercas')) || checks.find(c => txt(c).includes('keep')) || checks[0] || null; const roman = checks.find(c => txt(c).includes('roman')) || checks[1] || null; return { lang, keepUC, roman }; } // MB's guess-case options are React-controlled: a synthetic `change` is ignored, so setting .checked // never writes the option/cookie. A real .click() fires MB's own handler (the mode can't show the exact colors, only emoji approximations) function wireCutoff(wrap) { const host = wrap.querySelector('.tc-cutoff'); if (!host) return; const dotEl = host.querySelector('.tc-cutoff-dot'), lblEl = host.querySelector('.tc-cutoff-lbl'); const OPTS = [['exact', 'exact'], ['tolerance', 'tolerance'], ['near', 'near'], ['low', 'low'], ['vlow', 'very low']]; const paint = () => { const v = SETTINGS.recCutoff || 'near'; const o = OPTS.find(x => x[0] === v) || OPTS[2]; dotEl.style.background = CONF_COLOR[v]; lblEl.textContent = o[1]; }; paint(); host.onclick = () => { document.querySelectorAll('.tc-cutoff-menu').forEach(m => m.remove()); const cur = SETTINGS.recCutoff || 'near'; const m = document.createElement('div'); m.className = 'tc-cutoff-menu'; m.innerHTML = OPTS.map(([v, l]) => `
${l}
`).join(''); document.body.appendChild(m); const r = host.getBoundingClientRect(); m.style.left = r.left + 'px'; m.style.top = (r.bottom + 3) + 'px'; m.querySelectorAll('.mi').forEach(it => it.onmousedown = e => { e.preventDefault(); SETTINGS.recCutoff = it.dataset.v; saveSettings(); paint(); m.remove(); }); const off = e => { if (!m.contains(e.target) && !host.contains(e.target)) { m.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); }; } // lightweight fingerprint of the live recording state. The tab watcher compares // it each tick so the mirror re-renders when MB changes a recording externally — // e.g. it clears a track's recording after the track title is edited — instead of // only updating on our own picker actions (stale-row bug). let _lastRecSig = ''; function recSig() { let s = ''; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const rec = u(t.recording); const gid = rec ? (u(rec.gid) || '') : ''; const isNew = (typeof t.hasNewRecording === 'function' && u(t.hasNewRecording)) ? 'N' : ''; s += mi + ':' + ti + ':' + gid + ':' + isNew + ':' + (u(t.name) || '') + '|'; })); return s; } // jump from the toolbar "N without a recording" to the first such track's picker function openFirstUnsetPicker(row) { const wrap = document.getElementById('tc-recwrap'); if (!wrap || !row) return; const tr = wrap.querySelector('tbody tr.tc-recrow[data-mi="' + row.mi + '"][data-ti="' + row.ti + '"]'); if (tr) tr.scrollIntoView({ behavior: 'smooth', block: 'center' }); openRecPicker(row, (tr && tr.querySelector('.tc-recname')) || wrap.querySelector('.tc-recwarn')); } // #303 — recording video marker (mirrors the native recordings table's ) const VIDEO_MARK = ''; // (re)render just the rows + the unset-count — leaves the toolbar (status/inputs) untouched function renderRecBody(wrap) { wrap = wrap || document.getElementById('tc-recwrap'); if (!wrap) return; const tb = wrap.querySelector('tbody'); if (!tb) return; const rows = readRecordings(); const multi = mediums().length > 1; const unset = rows.filter(r => !r.recGid && !r.isNew).length; const firstUnset = rows.find(r => !r.recGid && !r.isNew); const warn = wrap.querySelector('.tc-recwarn'); if (warn) { warn.textContent = unset ? '⚠ ' + unset + ' without a recording' : ''; warn.onclick = (unset && firstUnset) ? () => openFirstUnsetPicker(firstUnset) : null; // #139 follow-up: jump to the first unset track's picker warn.title = unset ? 'jump to the first track without a recording' : ''; } tb.innerHTML = ''; // #149: iterate ALL mediums so collapsed ones still appear (with an expand // control) instead of being silently dropped — previously only the medium MB // had loaded showed up. mediums().forEach((med, mi) => { const collapsed = !mediumLoadedRec(med); if (multi || collapsed) { const mr = document.createElement('tr'); mr.className = 'tc-recmed' + (collapsed ? ' tc-recmed-coll' : ''); mr.dataset.mi = String(mi); if (collapsed) { const td = document.createElement('td'); td.colSpan = 8; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tc-recmed-exp'; btn.textContent = '▸ Medium ' + (mi + 1) + ' — left click to expand, right click to expand all'; btn.title = 'Left click: load this medium · Right click: load all collapsed media'; btn.onclick = () => expandRecMedium(mi, btn); btn.oncontextmenu = (e) => { e.preventDefault(); expandAllRecMediums(btn); }; td.appendChild(btn); mr.appendChild(td); } else { mr.innerHTML = 'Medium ' + (mi + 1) + ''; } tb.appendChild(mr); } if (collapsed) return; // no track rows until the user expands it rows.forEach(r => { if (r.mi === mi) tb.appendChild(renderRecRow(r)); }); }); _lastRecSig = recSig(); // mark the state this render reflects, so the tick only re-renders on real changes } function mediumLoadedRec(med) { try { return typeof med.loaded === 'function' ? !!u(med.loaded) : true; } catch (e) { return true; } } // #149: load a collapsed medium's recordings on demand — the native per-medium // "Edit" button's loadTracks action (those buttons are hidden). loadTracks fetches // async, so poll the model until the medium reports loaded, then re-render. function expandRecMedium(mi, btn) { const med = mediums()[mi]; if (!med) return; if (btn) { btn.classList.add('loading'); btn.textContent = '⏳ Medium ' + (mi + 1) + ' — loading…'; } try { if (typeof med.loadTracks === 'function') { Log.info('loading recordings for medium ' + (mi + 1)); med.loadTracks(); } } catch (e) { Log.warn('loadTracks failed', e.message); } let n = 0; // loadTracks makes MB re-render the recordings panel, which can wipe our // mounted table — so re-mount via showRecMirror (re-anchors to the now-loaded // medium's table + re-renders) once the medium reports loaded. #149 const poll = () => { n++; if (mediumLoadedRec(med) || n > 75) { snapshotRecOriginals(); showRecMirror(); } else setTimeout(poll, 200); }; setTimeout(poll, 200); } // #185: expand EVERY still-collapsed medium at once (right-click an expand arrow). // Fires loadTracks on all collapsed media, then re-renders once they've all loaded // (one poll for the whole set, vs one per medium). function expandAllRecMediums(btn) { const pending = mediums().filter(med => !mediumLoadedRec(med)); if (!pending.length) return; Log.info('expand all media (recordings):', pending.length, 'collapsed'); if (btn) { btn.classList.add('loading'); btn.textContent = '⏳ expanding all media…'; } pending.forEach(med => { try { if (typeof med.loadTracks === 'function') med.loadTracks(); } catch (e) { Log.warn('loadTracks failed', e.message); } }); let n = 0; const poll = () => { n++; if (pending.every(mediumLoadedRec) || n > 100) { snapshotRecOriginals(); showRecMirror(); } else setTimeout(poll, 200); }; setTimeout(poll, 200); } function renderRecRow(r) { const d = r.diffs || {}; // recording name: click to open the picker. Artists are links. When a copy-to-match flag is set // (from the picker), the cell previews the track value the recording will become (green). #119 // when a copy flag is on, preview the track value AND keep the recording's // original alongside it, struck through: "→ New (O̶r̶i̶g̶i̶n̶a̶l̶)". #146 // recording disambiguation shown in grey after the name, like the native UI. #144 const disamb = r.recComment ? ' (' + esc(r.recComment) + ')' : ''; const titleCell = r.copyTitle ? '→ ' + dhRun(r.title || '') + (r.recName ? ' ' + esc(r.recName) + disamb + '' : '') : r.isNew ? '+ new recording' : r.recName ? (dhRun(r.recName) + disamb) : '— none —'; const artistCell = r.copyArtist ? '→ ' + dhRun(r.trackArtist || '') + (r.recArtist ? ' ' + esc(r.recArtist) + '' : '') : (r.recArtistHtml || ''); const tolHas = f => (r.tolDiffs || []).some(x => x === f || x.startsWith(f)); // within-tolerance diffs highlight the cells too // No more tc-updavail blue underline (#197): a native-offers-copy row is now // always tolerance (never exact), so it's already coloured by its tolerance // state — the extra blue line was redundant. Right-click copy still works // (driven by tElig below), it just isn't drawn as a separate cue. const tCls = r.copyTitle ? 'tc-copy' : (d.title || tolHas('title') ? 'tc-diff' : ''); const aCls = r.copyArtist ? 'tc-copy' : (d.artist || tolHas('artist') ? 'tc-diff' : ''); const tElig = r.rawTitleDiff || r.copyTitle, aElig = r.rawArtistDiff || r.copyArtist; const changed = recChangedFromOrig(r.mi, r.ti); // differs from the page-load recording // #186 detailed highlighting (opt-in): per-character title + artist diff + graded length shade. // Falls back to the flat tc-diff highlight when off, in copy-preview, or for long titles. // Diffs on the LITERAL strings, so a casing/punctuation-only difference still shows the exact // changed characters even though the tolerance/ignore-casing settings treat the row as a match. // The cell keeps its base background (the match/mismatch colour, like the length cells); // the per-character highlight is drawn on TOP — so the diff classes are left unchanged. #186 const dh = SETTINGS.recDetailedHl && r.recGid && !r.isNew; let trackTitleHtml = dhRun(r.title || ''), recTitleHtml = titleCell; if (dh && !r.copyTitle && r.recName != null && (r.title || '') !== (r.recName || '')) { const segs = charDiff(r.title || '', r.recName || ''); if (segs) { trackTitleHtml = diffSide(segs, -1); recTitleHtml = diffSide(segs, 1) + disamb; } } // artists: ENTITY-level diff that KEEPS the per-artist links + disambiguations // (#186: links used to drop here because we char-diffed plain text; #195: // disambiguations now show). The artist whose entity isn't on the other side // is boxed; a credited-as (same entity) isn't. Trigger on an ENTITY difference, // not just text — a track artist swapped to a SAME-NAME different artist reads // identically but must still be boxed so it's not missed (#186, chaban). const acKeys = ac => (ac || []).map(a => a.gid || ('name:' + (a.name || '').toLowerCase().trim())).join(''); const artistEntitiesDiffer = acKeys(r.trackAc) !== acKeys(r.recAc); let trackArtistHtml2 = r.trackArtistHtml || '', recArtistCell = artistCell; if (dh && !r.copyArtist && r.recArtist != null && ((r.trackArtist || '') !== (r.recArtist || '') || artistEntitiesDiffer)) { trackArtistHtml2 = acLinksDiff(r.trackAc, r.recAc, true); recArtistCell = acLinksDiff(r.recAc, r.trackAc, true); } let recLenCls = (d.len || tolHas('length')) ? 'tc-diff' : '', recLenStyle = ''; if (dh && (d.len || tolHas('length'))) { const sh = lenShade((r.recLen || 0) - (r.trackLen || 0)); if (sh) { recLenCls = 'tc-dh-len'; recLenStyle = ' style="background:' + sh.bg + ';color:' + sh.fg + '"'; } } const tr = document.createElement('tr'); tr.className = 'tc-recrow' + (changed ? ' tc-recchanged' : ''); tr.dataset.mi = r.mi; tr.dataset.ti = r.ti; { const ps = recPendingState(r.mi, r.ti); if (ps && (ps.rec || ps.art)) { if (ps.rec) tr.classList.add('tc-rec-pending'); if (ps.art) tr.classList.add('tc-art-pending'); tr.title = ps.rec && ps.art ? 'Recording and artist have pending edits' : ps.rec ? 'This recording has pending edits' : 'This recording’s artist has pending edits'; } } // #376 tr.innerHTML = '' + esc(String(r.number == null ? '' : r.number)) + '' + '' + trackTitleHtml + '' + '' + trackArtistHtml2 + '' + '' + fmtMs(r.trackLen) + '' + '' + '' + recTitleHtml + (r.recVideo ? ' ' + VIDEO_MARK : '') + '' + '' + recArtistCell + '' + '' + fmtMs(r.recLen) + ''; const dot = tr.querySelector('.tc-dot'); if (r.conf) { dot.style.background = r.conf.color; dot.title = r.conf.label + ' — differs: ' + r.conf.diffs.join(', '); } else if (r.recGid) { dot.style.background = r.exact ? CONF_COLOR.exact : CONF_COLOR.tolerance; dot.title = r.exact ? 'Exact match' : 'Tolerance match' + (r.tolDiffs && r.tolDiffs.length ? ' (' + r.tolDiffs.join(', ') + ')' : ''); } else dot.style.visibility = 'hidden'; const nameCell = tr.querySelector('.tc-recname'); nameCell.classList.add('tc-clickable'); nameCell.title = 'change recording — suggestions / search' + (tElig ? ' · right-click: copy track title to recording (Ctrl: row · Alt: column)' : ''); nameCell.onclick = () => openRecPicker(r, nameCell); if (aElig) { const artCell = tr.querySelector('.tc-recartist'); if (artCell) artCell.title = 'right-click: copy track artist to recording (Ctrl: row · Alt: column)'; } if (changed) { // per-row revert ↺ (single), shown on hover when changed const rev = document.createElement('button'); rev.className = 'tc-rec-rev'; rev.textContent = '↺'; rev.title = 'revert to the original recording'; rev.onclick = e => { e.stopPropagation(); revertRecording(r); }; nameCell.appendChild(rev); } // #348: hint the track-title / track-artist cells' right-click when the recording's value differs const tkt = tr.querySelector('.tc-tkt'); if (tkt && !r.isNew && r.recName != null && r.recName !== '' && (r.title || '') !== r.recName) { tkt.title = 'right-click: set track title to the recording title “' + r.recName + '” (Alt: whole column)'; } const tka = tr.querySelector('.tc-tka'); if (tka && !r.isNew && r.recArtist != null && r.recArtist !== '' && (r.trackArtist || '') !== r.recArtist) { tka.title = 'right-click: set track artist to the recording artist “' + r.recArtist + '” (Alt: whole column)'; } return tr; } // confidence level of a candidate vs the track: 0 tolerance(green) · 1 near · 2 low · 3 very low // "Cutoff" = the LOWEST confidence still auto-linked; anything below it is left unmatched. Levels run // best→worst: exact(blue) 0 · tolerance(green) 1 · near 2 · low 3 · very low 4. A candidate links // when its combined level ≤ the chosen cutoff. #119 const CUTOFF = { exact: 0, tolerance: 1, near: 2, low: 3, vlow: 4 }; function recComboLevel(d, ctx) { return recExactMatch(d, ctx) ? 0 : recConfLevel(d, ctx) + 1; } // fold exact/green into one ladder with the lower bands function recConfLevel(data, ctx) { if (!ctx) return 0; let n = 0; const lenDiff = recLenGap(data.length, ctx.length); if (data.name && ctx.title && !recTitleEq(data.name, ctx.title)) n++; if (data.artist && ctx.artist && !sameAcEntities(data, ctx) && !recNameEq(data.artist, ctx.artist)) n++; // #190 same entity = credited-as only, not a diff if (lenDiff > 0) n++; if (n >= 3 && lenDiff > 10000) return 3; if (lenDiff > 15000) return 2; if (n >= 2 && lenDiff <= 15000) return 2; if (n === 1 || lenDiff > 3000) return 1; return 0; } // Auto-match: for each UNSET track, load MB's suggestions and link the BEST-confidence one (not just // MB's first) when it clears the "ignore below" threshold. Already-linked tracks are left untouched. #119 let _autoMatching = false; async function autoMatchRecordings() { if (_autoMatching) return; _autoMatching = true; const wrap = document.getElementById('tc-recwrap'); const setStatus = t => { const e = wrap && wrap.querySelector('.tc-rec-amstatus'); if (e) e.textContent = t; }; const maxLevel = CUTOFF[SETTINGS.recCutoff || 'near']; let linked = 0, considered = 0; try { // ONE request: pull the whole release group's recordings, index by normalised title, match locally let byTitle = new Map(), pool = []; try { const rel = release(); const rg = rel && u(rel.releaseGroup); const rgGid = rg && u(rg.gid); if (rgGid) { setStatus('loading release-group recordings…'); pool = await fetchRgRecordings(rgGid); pool.forEach(p => { const k = recFold(p.name); if (!byTitle.has(k)) byTitle.set(k, []); byTitle.get(k).push(p); }); } } catch (e) { Log.warn('RG pool load failed', e.message); } const todo = readRecordings().filter(r => !r.recGid); for (let i = 0; i < todo.length; i++) { const r = todo[i]; considered++; setStatus('auto-matching ' + (i + 1) + '/' + todo.length + '…'); const ko = koTrack(r.mi, r.ti); const ctx = { title: r.title, artist: r.trackArtist, length: r.trackLen, artistGids: acArtistGids(u(ko.artistCredit)) }; // best candidate from the local RG pool (normalised-title match; fuzzy scan if a Title tolerance is set). // lower confidence level wins; within the same level, an EXACT (no-tolerance) match is preferred. #119 let best = null, bestLevel = Infinity; const consider = d => { const lvl = recComboLevel(d, ctx); if (lvl < bestLevel) { bestLevel = lvl; best = d; } }; // lower combined level (exact < tolerance < near < …) wins let cands = byTitle.get(recFold(r.title)) || []; if (!cands.length && (SETTINGS.recTitleTol || 0) > 0 && pool.length) cands = pool.filter(p => recTitleEq(p.name, r.title)); cands.forEach(consider); // only fall back to MB's per-track lookup (network) when the pool had nothing good enough if (!best || bestLevel > maxLevel) { let sugg = (typeof ko.suggestedRecordings === 'function' ? (u(ko.suggestedRecordings) || []) : []); if (!sugg.length) { try { getEditor().recordingAssociation.findRecordingSuggestions(ko); } catch (e) {} for (let t = 0; t < 28; t++) { await new Promise(z => setTimeout(z, 250)); const loading = typeof ko.loadingSuggestedRecordings === 'function' ? u(ko.loadingSuggestedRecordings) : false; sugg = u(ko.suggestedRecordings) || []; if (!loading && sugg.length) break; if (!loading && t >= 3) break; } } for (let s = 0; s < sugg.length; s++) { consider(suggData(sugg[s])); if (bestLevel === 0) break; } // 0 = exact, can't do better } if (best && bestLevel <= maxLevel) { try { ko.setRecordingValue(recEntityFrom(best)); linked++; renderRecBody(); } catch (e) { Log.warn('auto-match set failed', e.message); } } } } finally { _autoMatching = false; rerenderRec(); const w = document.getElementById('tc-recwrap'); const e = w && w.querySelector('.tc-rec-amstatus'); if (e) e.textContent = 'linked ' + linked + ' of ' + considered + ' unset track' + (considered === 1 ? '' : 's'); Log.info('auto-match: linked', linked, 'of', considered, 'unset tracks'); } } // submit-flag setters (per track / all tracks) + a light re-render of the recordings table function setCopy(field, entry, on) { try { const t = koTrack(entry.mi, entry.ti); if (field === 'title') t.updateRecordingTitle(on); else t.updateRecordingArtist(on); } catch (e) { Log.warn('set copy ' + field + ' failed', e.message); } } function setCopyAll(field) { const flag = field === 'title' ? 'copyTitle' : 'copyArtist'; // only the rows where this field actually differs (or is already flagged) — copying a matching value is a no-op const rows = readRecordings().filter(r => r.recGid && ((r.diffs && r.diffs[field]) || r[flag])); const allOn = rows.length && rows.every(r => r[flag]); // toggle: if every eligible row is on, turn all off rows.forEach(r => setCopy(field, r, !allOn)); Log.info((allOn ? 'cleared' : 'set') + ' copy-' + field + ' on all ' + rows.length + ' recording(s)'); } function rerenderRec() { renderRecBody(); } // body only — keeps the toolbar (status / inputs) intact /* ── original-recording snapshot for revert + clear-all (#119) ── */ const _recOrig = new Map(); // Additive: snapshots tracks not yet captured, keeping the earliest (page-load) // baseline. Re-run safely after a collapsed medium is expanded (#149) so its // newly-loaded tracks also get a revert baseline. function snapshotRecOriginals() { mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const key = mi + ':' + ti; if (_recOrig.has(key)) return; const r = u(t.recording); _recOrig.set(key, { entity: r, gid: r ? u(r.gid) : null, isNew: typeof t.hasNewRecording === 'function' ? !!u(t.hasNewRecording) : false }); })); } function _restoreRec(entry, o) { const ko = koTrack(entry.mi, entry.ti); try { if (o.isNew) ko.hasNewRecording(true); else if (o.entity) ko.setRecordingValue(o.entity); } catch (e) { Log.warn('revert recording failed', e.message); } } function recChangedFromOrig(mi, ti) { const o = _recOrig.get(mi + ':' + ti); if (!o) return false; const ko = koTrack(mi, ti), r = u(ko.recording); const curGid = r ? u(r.gid) : null, curNew = typeof ko.hasNewRecording === 'function' ? !!u(ko.hasNewRecording) : false; return curGid !== o.gid || curNew !== o.isNew; } function revertRecording(entry) { const o = _recOrig.get(entry.mi + ':' + entry.ti); if (o) { _restoreRec(entry, o); rerenderRec(); Log.info('reverted recording for track', entry.ti + 1); } } function revertAllRecordings() { _recOrig.forEach((o, key) => { const p = key.split(':'); _restoreRec({ mi: +p[0], ti: +p[1] }, o); }); rerenderRec(); Log.info('reverted all recordings to the page-load state'); } function clearAllRecordings() { if (!W.confirm('Set every track to a NEW recording (clear all existing recording links)?')) return; mediums().forEach(med => (u(med.tracks) || []).forEach(t => { try { t.hasNewRecording(true); } catch (e) {} })); rerenderRec(); Log.info('cleared all recording links → new recordings'); } /* ── recording picker (#119 P2.2): suggestions + search-by-name → setRecordingValue ── */ let _recPop = null, _recPopAnchor = null, _recPopPos = null; // _recPopPos persists a dragged location across reopens function closeRecPop() { if (!_recPop) return; _recPop.remove(); _recPop = null; _recPopAnchor = null; // keep _recPopPos — a moved panel stays put on the next row click document.removeEventListener('mousedown', _recPopOutside, true); document.removeEventListener('keydown', _recPopKey, true); window.removeEventListener('scroll', _recPopReposition, true); window.removeEventListener('resize', _recPopReposition); } function _recPopOutside(e) { if (_recPop && !_recPop.contains(e.target)) closeRecPop(); } function _recPopKey(e) { if (e.key === 'Escape') closeRecPop(); } // dock the picker as a tall panel whose RIGHT edge aligns with the status-circle column (so it sits // over the Track half, not far off to the side), using the full viewport height. // once the user drags it (header), leave its position alone. #119 function _recPopReposition() { if (!_recPop) return; const M = 10, W = _recPop.offsetWidth || 410; if (_recPopPos) { // user dragged it once — keep that spot (clamped into view), only refresh the height const left = Math.round(Math.max(M, Math.min(_recPopPos.left, window.innerWidth - W - M))); const top = Math.round(Math.max(M, Math.min(_recPopPos.top, window.innerHeight - 60))); _recPop.style.left = left + 'px'; _recPop.style.top = top + 'px'; _recPop.style.maxHeight = (window.innerHeight - top - M) + 'px'; return; } const wrap = document.getElementById('tc-recwrap'); const wr = wrap ? wrap.getBoundingClientRect() : null; const sep = wrap && wrap.querySelector('td.c-sep, th.c-sep'); // the status-circle column // align the popup's right edge with the status circles; fall back to the wrap's left edge let left = sep ? sep.getBoundingClientRect().left - W : (wr ? wr.left : M); _recPop.style.left = Math.round(Math.max(M, Math.min(left, window.innerWidth - W - M))) + 'px'; const top = Math.round(Math.max(M, Math.min(wr ? wr.top : 60, window.innerHeight - 240))); _recPop.style.top = top + 'px'; _recPop.style.maxHeight = (window.innerHeight - top - M) + 'px'; } // drag the picker by its header function _recPopDrag(hd) { hd.style.cursor = 'move'; hd.addEventListener('mousedown', e => { if (e.target.closest('button, a, input')) return; e.preventDefault(); const r = _recPop.getBoundingClientRect(), ox = e.clientX - r.left, oy = e.clientY - r.top; const mm = ev => { const left = Math.max(0, Math.min(ev.clientX - ox, window.innerWidth - 40)), top = Math.max(0, Math.min(ev.clientY - oy, window.innerHeight - 40)); _recPopPos = { left, top }; _recPop.style.left = left + 'px'; _recPop.style.top = top + 'px'; }; const mu = () => { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); } // a WS2 recording → the flat shape used by the picker / matcher (gid, name, length, artist text + raw ac, …) function mapWsRec(r) { (r['artist-credit'] || []).forEach(a => a.artist && noteDisamb(a.artist.id, a.artist.disambiguation)); // cache disambiguations (#195) return { gid: r.id, name: r.title, length: r.length || null, artist: (r['artist-credit'] || []).map(a => (a.name || (a.artist && a.artist.name) || '') + (a.joinphrase || '')).join(''), artistGids: (r['artist-credit'] || []).map(a => a.artist && a.artist.id).filter(Boolean), // #190 entity-aware artist match ac: r['artist-credit'] || [], // raw credit, so the linked recording keeps its artist on screen releases: (() => { const seen = new Set(), out = []; (r.releases || []).forEach(rl => { const k = rl.id || rl.title; if (rl.title && !seen.has(k)) { seen.add(k); const rg = rl['release-group']; out.push({ name: rl.title, gid: rl.id, rgGid: rg ? rg.id : null, rgName: rg ? rg.title : null }); } }); return out; })(), isrcs: r.isrcs || [], comment: r.disambiguation || '', video: !!r.video, // #303 — surface the video flag in the picker results }; } // every recording in a release group, fetched ONCE (paginated) — the pool auto-match matches against // locally, so a full release matches with ~one request instead of a per-track lookup each. #119 async function fetchRgRecordings(rgGid) { const out = []; let offset = 0; for (let page = 0; page < 12; page++) { let j; try { j = await fetch(`${ORIGIN}/ws/2/recording?query=rgid:${encodeURIComponent(rgGid)}&fmt=json&limit=100&offset=${offset}&inc=artist-credits+releases+release-groups+isrcs`, { headers: { Accept: 'application/json' } }).then(r => r.json()); } catch (e) { Log.warn('RG recordings fetch failed', e.message); break; } (j.recordings || []).forEach(r => out.push(mapWsRec(r))); offset += 100; if (!(j.recordings || []).length || offset >= (j.count || 0)) break; } return out; } async function searchRecordings(q) { q = (q || '').trim(); if (!q) return []; try { const j = await fetch(`${ORIGIN}/ws/2/recording?query=${encodeURIComponent(q)}&fmt=json&limit=15&inc=artist-credits+releases+release-groups+isrcs`, { headers: { Accept: 'application/json' } }).then(r => r.json()); return (j.recordings || []).map(mapWsRec); } catch (e) { Log.warn('recording search failed', e.message); return []; } } // direct lookup of one recording by MBID — backs pasting a recording MBID / URL // into the picker's search field (mirrors the artist picker). #189 async function fetchRecordingById(gid) { try { const j = await fetch(`${ORIGIN}/ws/2/recording/${gid}?fmt=json&inc=artist-credits+releases+release-groups+isrcs`, { headers: { Accept: 'application/json' } }).then(r => r.json()); return j && j.id ? mapWsRec(j) : null; } catch (e) { Log.warn('recording lookup failed', e.message); return null; } } // recordings sharing an ISRC — backs pasting an ISRC into the picker's search // field (one ISRC can map to several recordings). #196 async function fetchRecordingsByIsrc(isrc) { try { // NB: the `isrc` resource rejects `release-groups` as an inc param (unlike // the `recording` resource) — including it returns an error object with no // recordings, which is why an existing ISRC came back "not found". #196 const j = await fetch(`${ORIGIN}/ws/2/isrc/${encodeURIComponent(isrc)}?fmt=json&inc=artist-credits+releases+isrcs`, { headers: { Accept: 'application/json' } }).then(r => r.json()); return ((j && j.recordings) || []).map(mapWsRec); } catch (e) { Log.warn('ISRC lookup failed', e.message); return []; } } function recEntityFrom(data) { if (data.entity) return data.entity; // suggestions are already full MB entities try { const spec = { entityType: 'recording', gid: data.gid, name: data.name, length: data.length || null }; // build the artist credit from the WS2 result so the recording shows its artist (not blank). #119 if (data.ac && data.ac.length) { spec.artistCredit = { names: data.ac.map(a => ({ name: a.name || (a.artist && a.artist.name) || '', joinPhrase: a.joinphrase || '', artist: W.MB.entity({ entityType: 'artist', gid: a.artist && a.artist.id, name: a.artist && a.artist.name }, 'artist'), })) }; } return W.MB.entity(spec, 'recording'); } catch (e) { Log.warn('build recording entity failed', e.message); return null; } } function pickRecording(entry, data) { if (!data) return; const ent = recEntityFrom(data); if (!ent) return; try { koTrack(entry.mi, entry.ti).setRecordingValue(ent); Log.info('linked recording', JSON.stringify(data.name), '→ track', entry.number); } catch (e) { Log.warn('setRecordingValue failed', e.message); } closeRecPop(); rerenderRec(); } // "Add a new recording" — native binds this to the per-track hasNewRecording observable (#119) function pickNewRecording(entry) { try { koTrack(entry.mi, entry.ti).hasNewRecording(true); Log.info('new recording for track', entry.number); } catch (e) { Log.warn('hasNewRecording failed', e.message); } closeRecPop(); rerenderRec(); } // pull display data off a suggestion entity (releases live in appearsOn.results; isrcs may be objects) function suggData(s) { const e = u(s); const ap = u(e.appearsOn); const rels = []; const seen = new Set(); if (ap && ap.results) ap.results.forEach(r => { const rr = u(r); const name = u(rr.name), gid = u(rr.gid); const rg = u(rr.releaseGroup); const k = gid || name; if (name && !seen.has(k)) { seen.add(k); rels.push({ name, gid, rgGid: rg ? u(rg.gid) : null, rgName: rg ? u(rg.name) : null }); } }); const isrcs = (u(e.isrcs) || []).map(x => typeof x === 'string' ? x : (x && (x.isrc || u(x.isrc)))).filter(Boolean); return { entity: e, gid: u(e.gid), name: u(e.name), length: u(e.length), artist: acText(u(e.artistCredit)), artistGids: acArtistGids(u(e.artistCredit)), releases: rels, isrcs, video: !!u(e.video) }; // #303 video flag } // confidence of a picker result vs the track that opened the picker (same scheme as the table dot) function resultConfClass(data, ctx) { if (!ctx) return ''; const lvl = recConfLevel(data, ctx); if (lvl === 0 && recExactMatch(data, ctx)) return ' tc-conf-exact'; // ideal: blue left border return ' tc-conf-' + ['tolerance', 'near', 'low', 'vlow'][lvl]; } // render "appears on" releases as links (each {name,gid,rgGid?,rgName?}); plain strings tolerated for safety. // Releases of the SAME release group collapse to a single "RG ×N" link, like MB's own suggestions (#136). // cap = max entries shown before a "+N more" tail (0 = show all). #119 function relLinksHtml(relsArr, cap) { const arr = relsArr || []; // group by release group; releases with no RG info each stay their own entry (graceful fallback) const groups = [], byRg = new Map(); arr.forEach(rl => { const o = rl && typeof rl === 'object' ? rl : { name: rl }; const rgKey = o.rgGid || (o.rgName ? 'n:' + o.rgName : null); if (rgKey && byRg.has(rgKey)) { byRg.get(rgKey).count++; return; } const g = { rgGid: o.rgGid, rgName: o.rgName, name: o.name, gid: o.gid, count: 1 }; if (rgKey) byRg.set(rgKey, g); groups.push(g); }); const shown = cap ? groups.slice(0, cap) : groups; const html = shown.map(g => { if (g.count > 1) { // multiple releases in this RG → link the RG title with a count const label = esc(g.rgName || g.name); const a = g.rgGid ? '' + label + '' : label; return a + ' ×' + g.count + ''; } return g.gid ? '' + esc(g.name) + '' : esc(g.name); }).join(', '); const extra = groups.length - shown.length; return html + (extra > 0 ? ' +' + extra + ' more' : ''); } // render a WS2 artist-credit (the raw `artist-credit` array kept on mapped // recordings) as links + optional disambiguations. Network-free: the gids and // disambiguations come from the search response itself. #199 #195 function acLinksWs(ac, withDisamb) { const names = ac || []; if (!names.length) return ''; return names.map(n => { const a = n.artist || {}; noteDisamb(a.id, a.disambiguation); // feed the cache so the table shows it after a pick (#195) const nm = n.name || a.name || ''; const dis = withDisamb && a.disambiguation ? ' (' + esc(a.disambiguation) + ')' : ''; const link = a.id ? '' + esc(nm) + '' : esc(nm); return link + dis + esc(n.joinphrase || ''); }).join(''); } // a picker result row — mirrors the native list: title + length, by artist, appears on, ISRCs; // left-border colour = confidence vs the track. Title links to the recording and the // artist credit links to each artist (with disambiguations), so an odd-looking result // can be inspected without first selecting it (#199 #195). Clicks on these links are // ignored by the row's pick handler (it bails on `closest('a')`). function recRowHtml(data, ctx) { const rels = relLinksHtml(data.releases, 6); const isrcs = (data.isrcs || []).slice(0, 4).join(', '); // highlight the fields that differ from the track, like the table does const dT = ctx && data.name && ctx.title && !recTitleEq(data.name, ctx.title); const dA = ctx && data.artist && ctx.artist && !recNameEq(data.artist, ctx.artist); const dL = !!(ctx && recLenGap(data.length, ctx.length) > 0); const titleHtml = data.gid ? '' + esc(data.name || '') + '' : esc(data.name || ''); const artistHtml = (data.ac && data.ac.length) ? acLinksWs(data.ac, true) : esc(data.artist || ''); return '
' + '
' + titleHtml + '' + (data.video ? ' ' + VIDEO_MARK : '') + (data.comment ? ' (' + esc(data.comment) + ')' : '') + '' + (data.length ? fmtMs(data.length) : '') + '
' + (artistHtml ? '
by ' + artistHtml + '
' : '') + (rels ? '
appears on: ' + rels + '
' : '') + (isrcs ? '
ISRCs: ' + esc(isrcs) + '
' : '') + '
'; } function openRecPicker(entry, anchor) { recStyle(); closeRecPop(); const pop = document.createElement('div'); pop.className = 'tc-recpop'; _recPop = pop; _recPopAnchor = anchor; document.body.appendChild(pop); const ko = koTrack(entry.mi, entry.ti); const data = {}; const ctx = { title: u(ko.name), artist: acText(u(ko.artistCredit)), length: u(ko.length), artistGids: acArtistGids(u(ko.artistCredit)) }; // for result confidence colouring // the currently-linked recording (or "new recording" if that's flagged) const curRec = u(ko.recording); const isNew = typeof ko.hasNewRecording === 'function' && !!u(ko.hasNewRecording); const curGid = !isNew && curRec ? u(curRec.gid) : null; const curArtist = curRec ? acText(u(curRec.artistCredit)) : ''; // Title - Artist … Len, same shape as the header (length pushed to the right // edge); the artist links to the RECORDING's artist (acLinks), not plain text. #189 const curHtml = isNew ? '+ new recording (created on submit)' : curGid ? '' + esc(u(curRec.name) || '') + '' + (u(curRec.video) ? ' ' + VIDEO_MARK : '') // #303 video flag on the linked recording + (u(curRec.comment) ? ' (' + esc(u(curRec.comment)) + ')' : '') // disambiguation on selection #144 + (curArtist ? ' - ' + acLinks(u(curRec.artistCredit), true) + '' : '') + '' + (u(curRec.length) ? '' + fmtMs(u(curRec.length)) + '' : '') : '— none —'; const trackArtist = ctx.artist, trackLen = u(ko.length); // Show the copy checkboxes whenever NATIVE MB offers them (rawTitleDiff / // rawArtistDiff proxy the native update checkboxes), not just when Apollo's // tolerance/casing-filtered diff fires — so casing-only diffs still offer it. #146 const showCopyT = !isNew && (entry.rawTitleDiff || entry.copyTitle), showCopyA = !isNew && (entry.rawArtistDiff || entry.copyArtist); pop.innerHTML = '
' + '' + esc(u(ko.name) || '') + '' + (trackArtist ? ' - ' + acLinks(u(ko.artistCredit)) + '' : '') + '' + (trackLen ? '' + fmtMs(trackLen) + '' : '') + '
' + '
' + '
' + curHtml + '
' + // ISRC of the linked recording — makes it obvious when an ISRC drove the // selection (#196). Hidden until the async fill finds at least one. (curGid ? '' : '') + (curGid ? '
appears on:
' : '') + '
' + (showCopyT || showCopyA ? '
' + (showCopyT ? '' : '') + (showCopyA ? '' : '') + '
' : '') + '
' + '
suggestions
finding suggestions…
' + '
search results
type to search…
'; const newBtn = pop.querySelector('.tc-rpk-qnew'); if (newBtn) newBtn.onclick = () => pickNewRecording(entry); const ctEl = pop.querySelector('.tc-rpk-ct'); if (ctEl) ctEl.onchange = () => { setCopy('title', entry, ctEl.checked); rerenderRec(); }; const caEl = pop.querySelector('.tc-rpk-ca'); if (caEl) caEl.onchange = () => { setCopy('artist', entry, caEl.checked); rerenderRec(); }; // fill the current recording's full "appears on" (all releases, linkable) — not in the page model, so fetch it if (curGid) { fetch(ORIGIN + '/ws/2/recording/' + curGid + '?fmt=json&inc=artist-credits+releases+release-groups+isrcs', { headers: { Accept: 'application/json' } }) .then(r => r.json()).then(j => { if (!_recPop) return; const el = pop.querySelector('.tc-rpk-curon-list'); if (el) { const seen = new Set(), rels = []; (j.releases || []).forEach(rl => { const k = rl.id || rl.title; if (rl.title && !seen.has(k)) { seen.add(k); const rg = rl['release-group']; rels.push({ name: rl.title, gid: rl.id, rgGid: rg ? rg.id : null, rgName: rg ? rg.title : null }); } }); el.innerHTML = rels.length ? relLinksHtml(rels, 0) : '—'; } // Artist disambiguations in the header: the page (KO) model doesn't carry // them, so backfill from the WS2 artist-credit once it arrives (#195). const acEl = pop.querySelector('.tc-rpk-curby-ac'); if (acEl && j['artist-credit']) { const h = acLinksWs(j['artist-credit'], true); if (h) acEl.innerHTML = h; } // ISRC line — reveal only when the recording actually has ISRC(s). #196 const isrcWrap = pop.querySelector('.tc-rpk-curisrc'), isrcEl = pop.querySelector('.tc-rpk-curisrc-list'); const isrcs = (j.isrcs || []).filter(Boolean); if (isrcWrap && isrcEl && isrcs.length) { isrcEl.textContent = isrcs.join(', '); isrcWrap.style.display = ''; } }).catch(() => {}); } _recPopDrag(pop.querySelector('.tc-rpk-hd')); // header is the drag handle _recPopReposition(); // dock it right + tall now the content (and height) exist const q = pop.querySelector('.tc-rpk-q'), suggBox = pop.querySelector('.tc-rpk-sugg'), resBox = pop.querySelector('.tc-rpk-res'); // collapsible suggestions — click the header to fold it away and see search results immediately (remembered) const suggSec = pop.querySelector('.tc-rpk-suggsec'); const applySuggCollapse = () => pop.classList.toggle('tc-sugg-collapsed', !!SETTINGS.recSuggCollapsed); if (suggSec) { applySuggCollapse(); suggSec.onclick = () => { SETTINGS.recSuggCollapsed = !SETTINGS.recSuggCollapsed; saveSettings(); applySuggCollapse(); _recPopReposition(); }; } // click a row to pick it — but a click on a link (release / artist) inside the row just follows the // link (new tab) and leaves the picker open; it must NOT pick + close the window. #119 const wire = box => box.querySelectorAll('.tc-rpk-row').forEach(row => { row.onclick = e => { if (e.target.closest('a')) return; pickRecording(entry, data[row.dataset.gid]); }; }); // de-dupe: a recording already shown under SUGGESTIONS is not repeated in SEARCH RESULTS below (#119) const suggGids = new Set(); let lastResults = []; const paintResults = () => { const filtered = lastResults.filter(r => !suggGids.has(r.gid)); resBox.innerHTML = filtered.length ? filtered.map(d => recRowHtml(d, ctx)).join('') : '
' + (lastResults.length ? 'all matches are shown in suggestions' : 'no matches') + '
'; wire(resBox); }; // suggestions are lazy in MB — render what's there, else trigger findRecordingSuggestions and poll const renderSugg = () => { const list = (typeof ko.suggestedRecordings === 'function' ? (u(ko.suggestedRecordings) || []) : []).map(suggData); list.forEach(s => { data[s.gid] = s; }); if (!list.length) return false; suggGids.clear(); list.forEach(s => suggGids.add(s.gid)); suggBox.innerHTML = list.map(d => recRowHtml(d, ctx)).join(''); wire(suggBox); if (lastResults.length) paintResults(); // suggestions arrived after a search → drop any now-duplicate rows return true; }; if (!renderSugg()) { try { getEditor().recordingAssociation.findRecordingSuggestions(ko); } catch (e) { Log.warn('findRecordingSuggestions failed', e.message); } let tries = 0; const poll = () => { if (!_recPop) return; const loading = typeof ko.loadingSuggestedRecordings === 'function' ? u(ko.loadingSuggestedRecordings) : false; if (!loading && renderSugg()) { rerenderRec(); return; } // also refresh the ⊕ count on the row if (!loading && tries > 3) { suggBox.innerHTML = '
no suggestions
'; return; } if (++tries < 40) setTimeout(poll, 250); }; setTimeout(poll, 250); } q.value = u(ko.name) || ''; q.title = 'free-form — raw MB query, e.g. isrc:USXXX… or artist:"…"'; // auto-query for THIS track. Narrow = title + artist + a ±10s length window (precise). Relaxed // ("show all") = title only, ignoring artist & length — for classical, covers, re-recordings. #119 // the relaxed/narrow choice is remembered (SETTINGS) so it carries across picker opens and reloads. let relax = !!SETTINGS.recRelax; const esq = s => String(s || '').replace(/(["\\])/g, '\\$1'); const autoQuery = () => { const title = u(ko.name), artist = acText(u(ko.artistCredit)), len = u(ko.length); if (relax) return title; // broad title search (covers / loose matches), identical to typing the title — NOT an exact phrase let qy = 'recording:"' + esq(title) + '"'; if (artist) qy += ' AND artist:"' + esq(artist) + '"'; if (len) qy += ' AND dur:[' + Math.max(0, len - 10000) + ' TO ' + (len + 10000) + ']'; return qy; }; let seq = 0, tmr = null; const runSearch = async (query, fallbackTitle) => { const my = ++seq; // paste a recording MBID or a /recording/ URL → resolve and LINK it // immediately (same as pasting an MBID in the artist picker). #189 const gid = mbidFrom(query); if (gid) { resBox.innerHTML = '
resolving recording…
'; const rec = await fetchRecordingById(gid); if (my !== seq || !_recPop) return; if (rec) pickRecording(entry, rec); // links the recording + closes the picker else resBox.innerHTML = '
recording MBID not found
'; return; } // paste an ISRC → resolve to its recording(s). Exactly one → link // immediately (same as an MBID); several → list them to choose from. #196 const isrc = isrcFrom(query); if (isrc) { resBox.innerHTML = '
resolving ISRC ' + esc(isrc) + '…
'; const recs = await fetchRecordingsByIsrc(isrc); if (my !== seq || !_recPop) return; if (recs.length === 1) { pickRecording(entry, recs[0]); return; } // one hit → link + close if (recs.length > 1) { recs.forEach(rr => { data[rr.gid] = rr; }); lastResults = recs; paintResults(); return; } resBox.innerHTML = '
no recording with ISRC ' + esc(isrc) + '
'; return; } resBox.innerHTML = '
searching…
'; let results = await searchRecordings(query); if (fallbackTitle && !results.length) results = await searchRecordings(u(ko.name) || ''); // smart query too tight → broaden if (my !== seq || !_recPop) return; results.forEach(rr => { data[rr.gid] = rr; }); lastResults = results; paintResults(); // paintResults hides any recording already listed under suggestions }; // "show all" toggles relaxed mode and re-runs the track-derived search (independent of any manual edit). // the button is painted from the remembered state on open, and the toggle persists it. #119 const relaxBtn = pop.querySelector('.tc-rpk-relax'); const paintRelax = () => { if (!relaxBtn) return; relaxBtn.classList.toggle('on', relax); relaxBtn.textContent = relax ? 'narrow' : 'show all'; relaxBtn.title = relax ? 'back to a precise search (title + artist + ±10s length)' : 'relaxed search — show all recordings with this title, ignoring artist & length'; }; paintRelax(); if (relaxBtn) relaxBtn.onclick = () => { relax = !relax; SETTINGS.recRelax = relax; saveSettings(); paintRelax(); runSearch(autoQuery(), !relax); }; // once the user edits the box, search their raw text (free Lucene); the initial run is the auto query q.oninput = () => { clearTimeout(tmr); tmr = setTimeout(() => runSearch(q.value, false), 300); }; q.focus(); q.select(); runSearch(autoQuery(), !relax); _recPopReposition(); setTimeout(() => { document.addEventListener('mousedown', _recPopOutside, true); document.addEventListener('keydown', _recPopKey, true); window.addEventListener('scroll', _recPopReposition, true); window.addEventListener('resize', _recPopReposition); }, 0); } // the Recordings tab panel (#recordings) — check the PANEL not the inner table (we hide the table) function recordingsVisible() { const p = document.getElementById('recordings'); return !!(p && p.offsetParent !== null); } // hide the native recording-assignment table and render the Apollo comparison table in its place. // Both read/write the same MB model, so toggling Original/Apollo lets you work in either view (#119). function showRecMirror() { _apolloUsed = true; recStyle(); applyHlColor(); snapshotRecOriginals(); // capture the page-load recording associations, for revert // Anchor on a loaded medium's assignation table when present; otherwise (every // medium collapsed, #149) mount into the recordings panel itself so we still // render — each collapsed medium then shows an expand control. const tbl = document.getElementById('track-recording-assignation'); const host = tbl ? tbl.parentElement : document.getElementById('recordings'); if (!host) return; let wrap = document.getElementById('tc-recwrap'); if (!wrap) { wrap = document.createElement('div'); wrap.id = 'tc-recwrap'; host.insertBefore(wrap, tbl || host.firstChild); } document.body.classList.add('tc-rec-on'); // CSS hides the native table + widens the column (no flash) renderRecMirror(wrap); // optional: auto-match the Recordings tab on load (settings), once per page session if (SETTINGS.autoMatchRec && !_recAutoMatchedOnce) { _recAutoMatchedOnce = true; setTimeout(() => autoMatchRecordings(), 0); } } let _recAutoMatchedOnce = false; function hideRecMirror() { document.body.classList.remove('tc-rec-on'); closeRecPop(); _recPopPos = null; // drop any dragged location so the next visit docks fresh const w = document.getElementById('tc-recwrap'); if (w) w.remove(); } /* ═══════════════════════════════════════════════════════════════════════ COMPACT NAVIGATION — hide the native editor's step-tab row + footer and relocate them compactly: a segmented step switcher (Release | Tracklist | Recordings | ⊟ Edit note) on the right of the entity-tab row, the wizard buttons (Cancel / Prev / Next / Finish) top-right by the title, and "Add medium" at the right of the tracklist table. Everything is a proxy to the still-present (visually-hidden) native control, so MB's wizard is untouched and the feature reverts cleanly when toggled off. ═══════════════════════════════════════════════════════════════════════ */ function navOn() { return SETTINGS.apolloEnabled !== false && SETTINGS.compactNav !== false; } // compact nav is part of Apollo — off with the global switch const DIFF_ICON = ''; const STEP_DEFS = [ { key: 'information', label: 'Release', title: 'Release information' }, { key: 'duplicates-tab', label: 'Duplicates', title: 'Release duplicates' }, // Add-release only; hidden when its native tab is absent { key: 'tracklist', label: 'Tracklist', title: 'Tracklist' }, { key: 'recordings', label: 'Recordings', title: 'Recordings' }, { key: 'edit-note', diff: true, title: 'Edit note — review changes & add an edit note' } ]; // only the submit button is kept (Cancel/Prev/Next are reachable via the entity tabs / step switcher); // it shows ONLY when there are pending changes. Both forms are mirrored — whichever MB renders. const WIZ_DEFS = [ { id: 'enter', label: '✓ Enter edit', cls: 'tc-wiz-enter', find: f => f.querySelector('#enter-edit') || [...f.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent)) }, { id: 'finish', label: '✓ Finish', cls: 'tc-wiz-finish', find: f => [...f.querySelectorAll('button')].find(b => /^\s*finish\s*$/i.test(b.textContent)) } ]; // Right-side paginators (#140) — proxy MB's native footer Cancel / Previous / Next. // MB data-binds their visibility (Prev hidden on the first step, Next on the last), // mirrored in syncNav via `vis(nat)`. Cancel carries the native `.negative` class. const PAGE_DEFS = [ { id: 'cancel', label: '✕ Cancel', cls: 'tc-wiz-cancel', find: f => f.querySelector('button.negative') || [...f.querySelectorAll('button')].find(b => /^\s*cancel\s*$/i.test(b.textContent)) }, { id: 'prev', label: '‹ Prev', cls: 'tc-wiz-prev', find: f => [...f.querySelectorAll('button')].find(b => /previous/i.test(b.textContent)) }, { id: 'next', label: 'Next ›', cls: 'tc-wiz-next', find: f => [...f.querySelectorAll('button')].find(b => /^\s*next\s*»?\s*$/i.test(b.textContent)) } ]; function hasChanges() { try { const re = W.MB && W.MB.releaseEditor; return !!(re && typeof re.allowsSubmission === 'function' && re.allowsSubmission()); } catch (e) { return false; } } function editorEl() { return document.getElementById('release-editor'); } function stepNavEl() { const e = editorEl(); return e && e.querySelector(':scope > ul.ui-tabs-nav'); } function navFooterEl() { const e = editorEl(); return e && e.querySelector(':scope > div.buttons'); } function stepLink(key) { const n = stepNavEl(); return n && n.querySelector('a[href="#' + key + '"]'); } function activeStepKey() { const n = stepNavEl(); const a = n && n.querySelector('li.ui-tabs-active a'); return a ? (a.getAttribute('href') || '').slice(1) : ''; } function vis(el) { return !!(el && el.style.display !== 'none' && !el.disabled); } // native inline display reflects MB's per-step show/hide let _navStyled = false; function navStyle() { if (_navStyled) return; _navStyled = true; const css = ` .tc-nav-vh{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0!important} .tc-nav-steps{display:inline-flex;align-items:stretch;border:1px solid #c9bce0;border-radius:7px;overflow:hidden;background:#fff;font-family:Arial,Helvetica,sans-serif} .tc-nav-step{font:600 13px Arial;padding:5px 14px;border:none;border-right:1px solid #e6def5;background:#fff;color:#5a3e94;cursor:pointer;line-height:1.5;white-space:nowrap} .tc-nav-step:last-child{border-right:none} .tc-nav-step:hover{background:#f3f0fb} .tc-nav-step.active{background:#6a4d9a;color:#fff} .tc-nav-step.tc-nav-diff{display:flex;align-items:center;padding:5px 10px} .tc-nav-step.tc-nav-diff svg{width:15px;height:15px;display:block} /* mirror MB's native tab states: disabled (e.g. Recordings until the tracklist is complete) + error-tab (validation warnings) */ .tc-nav-step:disabled,.tc-nav-step.tc-nav-disabled{opacity:.45;cursor:not-allowed;color:#9a8fb5;background:#fff} .tc-nav-step.tc-nav-warn{background:#fde3e3;color:#c0392b} .tc-nav-step.tc-nav-warn.active{background:#f3c4c4;color:#992318} .tc-nav-step.tc-nav-warn::before{content:"⚠";margin-right:5px} .tabs.tc-nav-sticky{position:sticky;top:0;background:#fff;z-index:20} /* freeze the nav row when scrolling */ /* MB's green TAGGER badge (Picard) sits at the header's top-right and collides with the frozen nav — lift it clear above the nav row, and keep the sticky nav above it so it's never covered */ body:has(.tabs.tc-nav-sticky) .releaseheader span.tagger-icon{position:relative;z-index:1;transform:translateY(-16px)} /* with the step-tab row hidden, strip the editor's jQuery-UI frame + the panel's top padding so no empty box is left */ #release-editor.tc-nav-on{margin-top:0;padding:0;border:none;background:none;box-shadow:none} #release-editor.tc-nav-on > .ui-tabs-panel{padding-top:0;border:none} #tc-nav-steps-wrap{position:absolute;right:0;bottom:6px;z-index:5} #tc-nav-right{display:flex;align-items:center;gap:10px} #tc-nav-wiz{display:inline-flex;align-items:center;gap:2px} .tc-nav-wbtn{font:13px Arial;padding:3px 9px;border:1px solid transparent;background:none;border-radius:5px;cursor:pointer;color:#555;display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .tc-nav-wbtn:not(:disabled):hover{background:#f4f4f4;border-color:#d2d2d2} .tc-nav-wbtn:disabled{opacity:.4;cursor:default;font-weight:normal;color:#999;background:none;border-color:transparent} .tc-nav-wbtn.tc-wiz-finish,.tc-nav-wbtn.tc-wiz-enter{color:#2f7a45;font-weight:600} .tc-nav-wbtn.tc-wiz-finish:hover,.tc-nav-wbtn.tc-wiz-enter:hover{background:#e6f3ea;border-color:#a9d2b6} .tc-addmed{font:13px Arial;padding:4px 12px;border:1px solid #d6cdec;background:#fff;color:#6a4d9a;border-radius:5px;cursor:pointer;margin-left:auto} .tc-addmed:hover{background:#f3f0fb} /* #140 — full-width nav bar: step switcher + Finish on the left, Cancel/Prev/Next paginators on the right */ #tc-nav-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;top:0;z-index:20;background:#fff;padding:5px 2px 6px;border-bottom:1px solid #e6def5;margin-bottom:6px} #tc-nav-left{display:flex;align-items:center;gap:10px;min-width:0;flex-wrap:wrap} #tc-nav-pager{display:inline-flex;align-items:center;gap:4px;flex-shrink:0} .tc-nav-wbtn.tc-wiz-cancel{color:#c0392b} .tc-nav-wbtn.tc-wiz-cancel:not(:disabled):hover{background:#fdecec;border-color:#e6b3b3} .tc-nav-wbtn.tc-wiz-prev,.tc-nav-wbtn.tc-wiz-next{color:#5a3e94;font-weight:600;border-color:#d6cdec;background:#f6f3fc} .tc-nav-wbtn.tc-wiz-prev:not(:disabled):hover,.tc-nav-wbtn.tc-wiz-next:not(:disabled):hover{background:#ece5f8;border-color:#b9a4e0} /* #141 Zen editing — the nav-bar release title (shown only in zen) + hiding the page chrome */ #tc-nav-title{display:none} /* two lines (album / artist + versions), centred, tight so they fit the nav button height (#141) */ body.tc-zen-on #tc-nav-title{display:flex;flex-direction:column;justify-content:center;flex:1 1 0;min-width:0;text-align:center;line-height:1.15;padding:0 14px} #tc-nav-title a{color:inherit;text-decoration:none}#tc-nav-title a:hover{text-decoration:underline} #tc-nav-title .tc-nav-title-album{font:600 14px Arial;color:#5a3e94;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* #290: mirror MB's native "modification pending" mark (span.mp, #ffdd99) on the release name — the native header that carries it is hidden by zen. Inline span so it hugs the text; same gold as native. */ #tc-nav-title .tc-nav-title-name{border-radius:3px;padding:0 1px} #tc-nav-title.tc-nav-title-pending .tc-nav-title-name{background:#ffdd99;padding:0 5px} #tc-nav-title.tc-nav-title-pending .tc-nav-title-name a{color:#33291a} #tc-nav-title .tc-nav-title-artist{font:12px Arial;color:#777;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #tc-nav-title .tc-nav-title-ver{color:#999} body.tc-zen-on .header,body.tc-zen-on .releaseheader,body.tc-zen-on #page > .tabs,body.tc-zen-on #footer{display:none!important} /* zen: drop the page's top spacing so the sticky nav bar pins flush to the top and doesn't drift on scroll (#141) */ body.tc-zen-on #page{padding-top:0!important;margin-top:0!important} body.tc-zen-on #page.fullwidth{padding-top:0!important;margin-top:0!important} /* MOBILE: the top nav bar wraps — step switcher + Finish on line 1, the Cancel/Prev/Next paginators stay grouped, and (in zen) the release title drops to its own full-width line so nothing gets squeezed or clipped. */ @media (max-width: 820px){ #tc-nav-bar{flex-wrap:wrap;gap:5px 8px;padding:5px 2px} #tc-nav-left{flex:1 1 auto} #tc-nav-pager{order:2} .tc-nav-step,.tc-nav-wbtn{padding:3px 8px;font-size:12px} body.tc-zen-on #tc-nav-title{order:3;flex:1 1 100%;text-align:left;padding:1px 2px 0} body.tc-zen-on #tc-nav-title .tc-nav-title-album, body.tc-zen-on #tc-nav-title .tc-nav-title-artist{white-space:normal} }`; const s = document.createElement('style'); s.id = 'tc-nav-style'; s.textContent = css; document.head.appendChild(s); } // build (once) the full-width nav bar at the top of the editor (#140): // left → step switcher (Release | Tracklist | Recordings | ⊟) + Finish/Enter // right → Cancel / Prev / Next paginators // Everything proxies the still-present (visually-hidden) native control. function buildNav() { navStyle(); if (document.getElementById('tc-nav-bar')) return; // already built const ed = editorEl(); if (!ed) return; // step switcher const steps = document.createElement('div'); steps.className = 'tc-nav-steps'; steps.id = 'tc-nav-steps'; STEP_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-step' + (d.diff ? ' tc-nav-diff' : ''); b.dataset.step = d.key; b.dataset.baseTitle = d.title; b.innerHTML = d.diff ? DIFF_ICON : d.label; b.title = d.title; b.onclick = () => { const l = stepLink(d.key); if (l) l.click(); setTimeout(syncNav, 40); }; steps.appendChild(b); }); // submit button (Finish / Enter edit) — sits to the right of the switcher const wiz = document.createElement('div'); wiz.id = 'tc-nav-wiz'; WIZ_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-wbtn ' + d.cls; b.dataset.wiz = d.id; b.textContent = d.label; b.onclick = () => { ensureApolloEditNote(); const f = navFooterEl(); const nat = f && d.find(f); if (nat) nat.click(); setTimeout(syncNav, 40); }; // #130: set the edit note on the compact-nav submit path too wiz.appendChild(b); }); // right-side paginators (Cancel / Prev / Next) const pager = document.createElement('div'); pager.id = 'tc-nav-pager'; PAGE_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-wbtn ' + d.cls; b.dataset.page = d.id; b.textContent = d.label; b.onclick = () => { const f = navFooterEl(); const nat = f && d.find(f); if (nat) nat.click(); setTimeout(syncNav, 40); }; pager.appendChild(b); }); const left = document.createElement('div'); left.id = 'tc-nav-left'; left.append(steps, wiz); const title = document.createElement('div'); title.id = 'tc-nav-title'; // #141 Zen: release title · artist · versions (shown only in zen) const bar = document.createElement('div'); bar.id = 'tc-nav-bar'; bar.append(left, title, pager); ed.insertBefore(bar, ed.firstChild); // a dedicated full-width row at the top of the editor (frozen on scroll) fillNavTitle(); } // Zen editing (#141): hide the site header, release header, entity tabs + footer, // leaving just the Apollo nav bar (which gains the release title) and the editor. function applyZen() { const on = !!(SETTINGS.zenMode && navOn() && editorEl()); document.body.classList.toggle('tc-zen-on', on); if (on) fillNavTitle(); } // populate the nav-bar title: " by (N versions)", all links — // mirrors the native release header that zen hides. Cheap; only fills once. function fillNavTitle() { const el = document.getElementById('tc-nav-title'); if (!el) return; let rel; try { rel = release(); } catch (e) { return; } if (!rel) return; const gid = u(rel.gid), name = u(rel.name) || ''; if (!name) return; const artist = acLinks(u(rel.artistCredit)) || ''; // live-update: rebuild only when the title or artist actually changed (not every tick) (#141) const sig = name + '\x01' + artist; if (el.dataset.sig === sig) return; el.dataset.sig = sig; const album = gid ? '' + esc(name) + '' : esc(name); // version count — reuse the native release header's "see all versions" link let ver = ''; const rh = document.querySelector('.releaseheader'); const va = rh && [...rh.querySelectorAll('a')].find(a => /version/i.test(a.textContent || '')); if (va) { const m = (va.textContent.match(/(\d+)/) || [])[1]; ver = ' (' + (m ? m + ' versions' : 'all versions') + ')'; } el.innerHTML = '
' + album + '
' + '
' + artist + ver + '
'; } // keep the proxies in sync with the native state each tick function syncNav() { if (!navOn()) return; const active = activeStepKey(); document.querySelectorAll('#tc-nav-steps .tc-nav-step').forEach(b => { b.classList.toggle('active', b.dataset.step === active); const link = stepLink(b.dataset.step), li = link && link.closest('li'); // mirror MB's native tab state if (!link) { b.style.display = 'none'; return; } // step's native tab not present on this page (e.g. Duplicates exists only on Add) b.style.display = ''; const dis = !!(li && li.classList.contains('ui-state-disabled')); const panel = document.getElementById(b.dataset.step); // MB sets error-tab for some errors but not link errors — also scan the panel for a visible field-error const err = !!((li && li.classList.contains('error-tab')) || (panel && panel.querySelector('.field-error[data-visible="1"]'))); b.disabled = dis; b.classList.toggle('tc-nav-disabled', dis); b.classList.toggle('tc-nav-warn', err); b.title = (dis && link && link.title) ? link.title : (b.dataset.baseTitle || b.title); // disabled → MB's "enter all track info…" hint }); const f = navFooterEl(); const changed = hasChanges(); // the submit button appears only when there are pending changes // #290: MIRROR MB's native "modification pending" mark. MB wraps the release // title in (gold #ffdd99) when the entity has open edits — // it's there on entrance. Zen hides the native header, so reflect that exact // state onto the release name in our toolbar — read straight from the DOM, no // logic of our own. The .mp node stays in the DOM even while the header is hidden. const navTitle = document.getElementById('tc-nav-title'); if (navTitle) navTitle.classList.toggle('tc-nav-title-pending', !!document.querySelector('.releaseheader h1 .mp')); WIZ_DEFS.forEach(d => { const proxy = document.querySelector('#tc-nav-wiz [data-wiz="' + d.id + '"]'); if (!proxy) return; const nat = f && d.find(f); proxy.style.display = (vis(nat) && changed) ? '' : 'none'; }); // paginators stay in a fixed position — Prev/Next are never hidden, just disabled // when MB's native button isn't applicable (Prev on the first step, Next on the last) (#140) PAGE_DEFS.forEach(d => { const proxy = document.querySelector('#tc-nav-pager [data-page="' + d.id + '"]'); if (!proxy) return; const nat = f && d.find(f); proxy.disabled = !vis(nat); }); updateStickyOffsets(); } // stack the sticky Apollo toolbars BELOW the frozen entity-tab row (both default to top:0 and would // otherwise overlap, hiding the pinned tab row). The row height is dynamic, so measure it each sync. function updateStickyOffsets() { const tabs = document.getElementById('tc-nav-bar'); // #140: the full-width nav bar is the frozen header now const h = (navOn() && tabs) ? tabs.offsetHeight : 0; const w = document.getElementById('tc-mirror-wrap'); if (w) w.style.top = h ? h + 'px' : ''; const r = document.querySelector('#tc-recwrap .tc-rec-tb'); if (r) r.style.top = h ? h + 'px' : ''; } // the native "Add medium" OPENER — it lives in the editor footer (present on the Tracklist step) and // opens MB's add-medium parser dialog. (NB: the button inside #add-medium-dialog is the dialog's commit // button, disabled until tracks are parsed — proxying that one does nothing.) function nativeAddMediumBtn() { const f = navFooterEl(); let b = f && [...f.querySelectorAll('button')].find(x => /add medium/i.test(x.textContent)); if (b) return b; const tl = document.getElementById('tracklist'); return tl ? [...tl.querySelectorAll('button')].find(x => /add medium/i.test(x.textContent) && !x.closest('#add-medium-dialog')) : null; } // move "Add medium" to the right end of the Apollo tracklist (opposite "Add tracks"), proxying the native opener function relocateAddMedium() { const nat = nativeAddMediumBtn(); const want = navOn() && document.getElementById('tc-mirror-wrap') && nat; if (!want) { const p = document.getElementById('tc-addmed'); if (p) p.remove(); return; } if (document.getElementById('tc-addmed')) return; // proxy already placed (the add-row may rebuild → re-add then) const btn = document.createElement('button'); btn.id = 'tc-addmed'; btn.className = 'tc-addmed'; btn.title = 'add a new medium'; btn.textContent = '+ Add medium'; btn.onclick = () => { const n = nativeAddMediumBtn(); if (n) n.click(); }; const rows = document.querySelectorAll('.tc-medsec .tc-addrow'); const host = rows[rows.length - 1]; if (host) host.appendChild(btn); // far right of the last medium's add-tracks row else { const secs = document.querySelectorAll('.tc-medsec'); const last = secs[secs.length - 1]; if (last) { const row = document.createElement('div'); row.className = 'tc-addrow'; row.appendChild(btn); last.appendChild(row); } } // locked last medium → own right-aligned row } function applyNav() { if (!editorEl()) return; if (navOn()) { buildNav(); editorEl().classList.add('tc-nav-on'); const sn = stepNavEl(); if (sn) sn.classList.add('tc-nav-vh'); const f = navFooterEl(); if (f) f.classList.add('tc-nav-vh'); syncNav(); } else { editorEl().classList.remove('tc-nav-on'); const sn = stepNavEl(); if (sn) sn.classList.remove('tc-nav-vh'); const f = navFooterEl(); if (f) f.classList.remove('tc-nav-vh'); const tabs = document.querySelector('#page > .tabs, .tabs'); if (tabs) tabs.classList.remove('tc-nav-sticky'); ['tc-nav-bar', 'tc-nav-steps-wrap', 'tc-nav-addbar'].forEach(id => { const e = document.getElementById(id); if (e) e.remove(); }); } relocateAddMedium(); updateStickyOffsets(); } /* ── edit note: credit Apollo when it's used, appended to the bottom (keep import-script notes) ── */ let _apolloUsed = false; // set true once a tracklist/recordings mirror is shown — i.e. Apollo is in use function ensureApolloEditNote() { if (!_apolloUsed) return; const ta = document.getElementById('edit-note-text'); if (!ta) return; // MB's plain edit-note textarea (name=edit_note) const cur = ta.value || ''; if (/Apollo Editor/i.test(cur)) return; // already credited — don't duplicate const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; // same shape as the other scripts' edit notes const note = (s.name || 'Apollo Editor') + ' v' + scriptVersion() + ' by ' + (s.author || 'majkinetor') + ' - ' + (s.homepageURL || s.homepage || HELP_URL); const kept = cur.replace(/\s+$/, ''); // keep any existing note (import scripts etc.), append below const val = kept ? kept + '\n\n' + note : note; // use the native setter so a React-controlled textarea actually picks up the change (plain .value can be ignored — #130) try { const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, val); } catch (e) { ta.value = val; } ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } // append the note the moment the user submits — by then any import-script note is already present function watchSubmit() { document.addEventListener('click', e => { const b = e.target && e.target.closest && e.target.closest('button'); if (!b) return; const f = navFooterEl(); if (f && f.contains(b) && /^\s*(finish|enter edit)\s*$/i.test(b.textContent || '')) ensureApolloEditNote(); }, true); // capture phase: runs before MB reads the textarea for submission } /* ── Release information takeover (#129): tidy the first tab — hide the help bubble, clean the external links, and add an Apollo gear. Toggled by the shared Original/Apollo button. ── */ let _riStyled = false; let _riExtFs = null; // the External-links
, remembered so we can move it back function riStyle() { if (_riStyled) return; _riStyled = true; const css = ` body.tc-ri-on .tc-ri-helphidden{display:none!important} /* hide the inline "?" help / info icons + guidance across the whole Release-information panel */ body.tc-ri-on #information .tooltip-wrapper, body.tc-ri-on #information .icon.help, body.tc-ri-on #information span.img.help, body.tc-ri-on #information .inline-help, body.tc-ri-on #information a.help, body.tc-ri-on #information .guidance, body.tc-ri-on #information .guidance-popover, /* hide help bubbles only — never a functional editor bubble (URL cleanup, add/edit link) */ body.tc-ri-on #information .bubble:not(:has(input,button,select,textarea)){display:none!important} /* MB's link-editor popups must clear our sticky compact-nav (z-index 20), else the first link's popup opens under the nav bar and looks cut off */ body.tc-ri-on .dialog.popover, body.tc-ri-on .bubble:has(input,button,select,textarea){z-index:50} /* ---- two-column layout: form on the left, external links lifted into the (now-used) right column ---- */ body.tc-ri-on #information{display:flex;flex-wrap:wrap;gap:14px 30px;align-items:flex-start;max-width:100%;box-sizing:border-box} /* wrap → links stack below the form on a narrow window instead of overlapping it */ body.tc-ri-on #information > div.half-width{flex:0 1 620px;min-width:0;width:auto} /* form keeps its natural width */ /* ---- #143: compact, low-noise Release-info form, matching Apollo's purple ---- */ body.tc-ri-on #information > div.half-width fieldset{border:none;margin:0 0 12px;padding:0} /* drop the boxy fieldset frames */ body.tc-ri-on #information > div.half-width legend{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:#8a7bb8;padding:0 0 5px;margin:0 0 4px;border-bottom:1px solid #ece7f6;width:100%;box-sizing:border-box} body.tc-ri-on #information table.row-form{border-collapse:collapse;width:100%} body.tc-ri-on #information table.row-form > tbody > tr > td{padding:2px 6px;vertical-align:middle} /* tighter rows */ body.tc-ri-on #information table.row-form label{font-size:12px;color:#6a6a6a;font-weight:normal} /* the original bolds only the Title + Artist labels among all the field captions — match that, keeping the rest (and the checkbox labels) light so they aren't intrusive (#143) */ body.tc-ri-on #information table.row-form label[for="title"], body.tc-ri-on #information table.row-form label[for="release-artist"]{font-weight:600;color:#4a4a4a} body.tc-ri-on #information input[type=text], body.tc-ri-on #information select, body.tc-ri-on #information textarea{font-size:12px;padding:2px 6px;border:1px solid #d6cdec;border-radius:4px;box-shadow:none} /* no background → MB's green auto-fill tint survives */ body.tc-ri-on #information input[type=text]:focus, body.tc-ri-on #information select:focus, body.tc-ri-on #information textarea:focus{border-color:#8a72c8;outline:none} /* "Additional information": the native annotation textarea renders narrower than the disambiguation input, and this fieldset's wider "Disambiguation:" caption pushed both fields ~23px right of the fields above. Pin its label column to the same 150px the main form uses so the fields left-align with Title/Barcode, and make both fields fill the column so they're the same width and right-align with the rest. */ /* :not(#external-links-editor) — MB's external-links editor is ALSO a table.row-form inside the information fieldset; without the exclusion this 150px label-column width hit its favicon cell (normally 30px) and shoved every link URL ~150px to the right, misaligning the icons. */ body.tc-ri-on #information fieldset.information table.row-form:not(#external-links-editor) > tbody > tr > td:first-child{width:150px} body.tc-ri-on #information fieldset.information textarea#annotation, body.tc-ri-on #information fieldset.information input#comment{width:100%!important;max-width:none!important;box-sizing:border-box} /* MB pins the annotation textarea to 354px via an !important rule in its (cross-origin) stylesheet — override it so the field fills the column like the input */ body.tc-ri-on #information .buttons button,body.tc-ri-on #information button.styled-button{font-size:12px} body.tc-ri-on #information .lookup-performed{background-color:#eef8ec!important} /* soften MB's bright auto-fill green to a pale tint (#143) */ body.tc-ri-on #information > div.documentation{display:none} /* the contextual help text — replaced by the links column */ /* #143: on-demand help popover. The native field bubbles still carry the clickable link to the *selected* entity ("You selected "); we surface that next to the focused field instead of the removed help column — without bringing back the verbose style-guide noise. (unscoped: the element lives on ; visibility is gated by the .on class, only added while Apollo is on.) */ #tc-ri-help{position:fixed;z-index:9999;display:none;max-width:360px;width:max-content;background:#fff;border:1px solid #d6cdec;border-radius:7px;box-shadow:0 6px 22px rgba(60,40,110,.20);padding:9px 12px;font-size:12px;line-height:1.45;color:#444} #tc-ri-help.on{display:block} #tc-ri-help p{margin:0 0 5px} #tc-ri-help p:last-child{margin-bottom:0} #tc-ri-help a{color:#5f3ec0;text-decoration:none} #tc-ri-help a:hover{text-decoration:underline} #tc-ri-help .comment,#tc-ri-help .name-variation a[title]{color:#8a8a8a} body.tc-ri-on #tc-ri-rightcol{flex:1 1 340px;min-width:300px;max-width:100%;box-sizing:border-box} /* links take the remaining width, but wrap below the form when there isn't room for both; never wider than the row */ /* External links matches the form sections: no boxy border, same compact purple header (#143) */ body.tc-ri-on #tc-ri-rightcol > fieldset{margin-top:0;max-width:100%;min-width:0;box-sizing:border-box;border:none;padding:0} body.tc-ri-on #tc-ri-rightcol > fieldset > legend{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:#8a7bb8;padding:0 0 5px;margin:0 0 4px;border-bottom:1px solid #ece7f6;width:100%;box-sizing:border-box} body.tc-ri-on #tc-ri-rightcol #external-links-editor{max-width:100%;box-sizing:border-box} /* #297: front cover thumbnail under the external-links section (display only) */ #tc-ri-cover{margin:14px 0 0} #tc-ri-cover .tc-ri-cover-h{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:#8a7bb8;padding:0 0 5px;margin:0 0 6px;border-bottom:1px solid #ece7f6} #tc-ri-cover a{display:inline-block;line-height:0} /* shrink-wrap the image so only the cover itself is the link, not the empty space beside it */ #tc-ri-cover img{display:block;width:180px;max-width:100%;height:auto;border:1px solid #e0d9f0;border-radius:6px;box-shadow:0 1px 5px rgba(40,20,80,.14)} /* ---- external links as a grid: the URL row spans every column, the link's type combos flow into aligned columns beneath it, and "Add another relationship" (the [+]) lands in the last cell. ---- */ body.tc-ri-on #external-links-editor{display:block} body.tc-ri-on #external-links-editor > tbody{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(185px,100%),1fr));column-gap:14px;row-gap:2px;padding-left:45px;align-items:center;box-sizing:border-box} /* URL line spans all columns; pulled back so the favicon sits at the column's left edge */ body.tc-ri-on #external-links-editor tr.external-link-item{grid-column:1 / -1;display:flex;align-items:center;gap:9px;padding:7px 6px 1px;margin-left:-45px;border-radius:6px;position:relative} body.tc-ri-on #external-links-editor tr.external-link-item:hover{background:#f6f4fb} body.tc-ri-on #external-links-editor tr.external-link-item > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.external-link-item > td:first-child{flex:none;width:30px;height:30px;display:flex;align-items:center;justify-content:center;cursor:context-menu;position:relative;top:4px} /* larger favicon → right-click to edit URL */ body.tc-ri-on #external-links-editor .favicon{transform:scale(1.45);transform-origin:center;margin-right:0} /* master's size; drop MB's 4px margin-right that pushed the scaled icon off-centre and clipped it (#143) */ body.tc-ri-on #external-links-editor tr.external-link-item > td:last-child{flex:1;min-width:0} body.tc-ri-on #external-links-editor a.url{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px} /* hover edit/remove icons removed. link-actions -> display:contents; the edit pencils are kept in the DOM (positioned, invisible) so our JS proxy clicks still anchor MB's editor popup near the link instead of 0,0 */ body.tc-ri-on #external-links-editor td.link-actions{display:contents} body.tc-ri-on #external-links-editor td.link-actions > button.edit-item{position:absolute;left:6px;top:8px;width:18px;height:18px;opacity:0;pointer-events:none;margin:0;padding:0} /* whole-link remove ("Remove link") — revealed on URL-row hover at the right end (no layout shift) */ body.tc-ri-on #external-links-editor tr.external-link-item td.link-actions > button.remove-item{display:inline-flex;align-items:center;order:9;margin:0 2px 0 8px;transform:scale(.85);opacity:0;transition:opacity .12s} body.tc-ri-on #external-links-editor tr.external-link-item:hover td.link-actions > button.remove-item{opacity:.5} body.tc-ri-on #external-links-editor tr.external-link-item td.link-actions > button.remove-item:hover{opacity:1} /* each type combo is one grid cell: [x] [type fills the cell] [video] [!]. A fixed left slot is reserved for the per-type [x] so the type text lines up whether or not a remove button is present (single vs multi type) */ body.tc-ri-on #external-links-editor tr.relationship-item{display:flex;align-items:center;gap:5px;min-width:0;padding:0 0 1px 22px;position:relative} body.tc-ri-on #external-links-editor tr.relationship-item > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.relationship-item > td:first-child{display:none} /* the cell content is a 3-track grid [ type (1fr) | video | ! ] so the select always ends at the same x and the carets line up across rows, whether or not a video checkbox / error badge is present */ body.tc-ri-on #external-links-editor tr.relationship-item > td:last-child{display:grid;grid-template-columns:minmax(0,1fr) 15px 16px;align-items:center;column-gap:2px;flex:1;min-width:0} body.tc-ri-on #external-links-editor tr.relationship-item .relationship-content{grid-column:1;display:flex;align-items:center;min-width:0} body.tc-ri-on #external-links-editor tr.relationship-item .relationship-content > label:first-child{display:none} /* the "Type:" caption */ /* a relationship with a date period ("stream for free (1111-11-11 – 1112-11-11)") needs more room than one 185px type cell, but not the whole row — span two cells so several dated types still share a wide row. The type stays on one line; only a very long date wraps to a second line *inside* the cell, so it never overflows into the neighbouring type (overlapping its remove ✗) (#143). */ body.tc-ri-on #external-links-editor tr.relationship-item:has(.date-period){grid-column:span 2} body.tc-ri-on #external-links-editor tr.relationship-item:has(.date-period) .relationship-name{flex-wrap:wrap;white-space:nowrap;overflow:visible} /* let a very long date wrap to a 2nd line inside the cell (not clipped) */ /* per-type remove [x] sits in the reserved left slot (absolute), so the type text starts at the same x with or without it. left:6px centres MB's native ✗ sprite (≈13.6px) on the same x as the "add another relationship" [+] that wraps directly below it when the types stack — at left:0 the native sprite landed ~6px too far left of the [+] (the old #154 flat glyph was wider, so it happened to line up). (#160) */ body.tc-ri-on #external-links-editor tr.relationship-item td.link-actions > button[class*="remove"]{display:inline-flex;align-items:center;position:absolute;left:6px;top:50%;transform:translateY(-50%) scale(.85);margin:0;opacity:.6;transition:opacity .12s} body.tc-ri-on #external-links-editor tr.relationship-item td.link-actions > button[class*="remove"]:hover{opacity:1} /* type text (committed) / select (editable) fills the cell */ body.tc-ri-on #external-links-editor tr.relationship-item .relationship-name{display:flex;align-items:center;flex:1;min-width:0;font-size:12px;color:#5a3e94;background:transparent;border:none;border-radius:0;padding:0;font-weight:normal;cursor:context-menu;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* keep the type on one line — never wrap it one-word-per-line as the column resizes (#143) */ body.tc-ri-on #external-links-editor tr.relationship-item .relationship-name:hover{color:#3a2d5c} /* editable type dropdowns — appearance:none so the text starts flush at the cell edge; a custom caret keeps the affordance. padding-right reserves room for the 10px caret with a small gap; keep it tight (not 15px) so the type label has a few px of slack and never clips its last glyph under sub-pixel rounding at 100% browser zoom on a scaled display (#143). */ body.tc-ri-on #external-links-editor select{-webkit-appearance:none;-moz-appearance:none;appearance:none;font-size:12px;color:#5a3e94;background-color:transparent;background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='10'%20height='7'%20viewBox='0%200%2010%207'%3E%3Cpath%20d='M1%201l4%204%204-4'%20fill='none'%20stroke='%235a3e94'%20stroke-width='1.5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right center;border:none;border-radius:0;box-shadow:none;padding:0 12px 0 0;height:auto;margin:0;flex:1;min-width:0;width:100%;cursor:pointer} body.tc-ri-on #external-links-editor select:hover{color:#3a2d5c} /* video attribute → a compact checkbox (no "video" caption) */ body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container{grid-column:2;justify-self:start;display:inline-flex;align-items:center;margin:0} body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container label{font-size:0;display:inline-flex;align-items:center;cursor:pointer} body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input{margin:0} /* error → a compact "!" badge (full text on hover via title) so it doesn't reflow the combos when it appears */ body.tc-ri-on #external-links-editor tr.relationship-item .error.field-error{grid-column:3;justify-self:start;font-size:0;display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;background:#fdecec;border:1px solid #f0c4c4;margin:0;cursor:help} body.tc-ri-on #external-links-editor tr.relationship-item .error.field-error::before{content:"!";font:bold 11px/1 Arial;color:#d33} /* #169: MB flags a relationship/URL with pending edits via a small This relationship has open edits. that the compact type cell clips away. Rather than re-fit the icon (alignment is fragile here), surface it with COLOUR — an amber type label + a left accent bar on the row. Pure :has(), no layout shift (inset box-shadow). */ body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]){box-shadow:inset 2px 0 0 0 #e8920c} body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) .relationship-name, body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) select{color:#b26a00} body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) .relationship-name:hover, body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) select:hover{color:#8a5200} /* same cue when the open edit is on the URL itself (pending add/remove of the link) */ body.tc-ri-on #external-links-editor tr.external-link-item:has(img.info[alt*="open edits" i]){box-shadow:inset 2px 0 0 0 #e8920c} body.tc-ri-on #external-links-editor tr.external-link-item:has(img.info[alt*="open edits" i]) a.url{color:#b26a00} /* "Add another relationship" (the [+]) — flows into the last grid cell; padding-left matches the per-type [x] inset so they line up */ body.tc-ri-on #external-links-editor tr.add-relationship{display:flex;align-items:center;margin:0;padding:0 0 0 6px} body.tc-ri-on #external-links-editor tr.add-relationship > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.add-relationship > td:empty{display:none} body.tc-ri-on #external-links-editor tr.add-relationship td.add-item{display:inline-grid} /* size the cell to the [+] button */ /* the [+] is a touch smaller than the per-type [x] remove */ body.tc-ri-on #external-links-editor tr.add-relationship button.add-item{font-size:0;width:13px;height:13px;border-radius:50%;border:1px solid #d6cdec;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;margin:0;line-height:1} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item::before{content:"+";font:bold 9px/1 Arial;color:#9a8fc0} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item:hover{background:#f0ecfa;border-color:#b9a4e0} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item:hover::before{color:#6f42c1} /* the "add another link" input row */ body.tc-ri-on #external-links-editor tr.external-link-item .value.with-button input{width:100%} /* collapse the "Add another link" field into a [+] button that expands to a full input on click/focus */ body.tc-ri-on #external-links-editor input[placeholder^="Add"]{box-sizing:border-box;width:22px;min-width:0;height:22px;padding:0;margin:2px 0;border:1px solid #d6cdec;border-radius:50%;background-color:transparent;color:transparent;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='11'%20height='11'%20viewBox='0%200%2011%2011'%3E%3Cpath%20d='M5.5%201v9M1%205.5h9'%20stroke='%239a8fc0'%20stroke-width='1.6'%20stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center;transition:width .12s ease} body.tc-ri-on #external-links-editor input[placeholder^="Add"]:hover{background-color:#f0ecfa;border-color:#b9a4e0} body.tc-ri-on #external-links-editor input[placeholder^="Add"]::placeholder{color:transparent} /* hide "Add another link" text inside the collapsed [+] */ body.tc-ri-on #external-links-editor input[placeholder^="Add"]:focus{width:100%;height:auto;padding:4px 7px;border:1px solid #999;border-radius:4px;background-color:#fff;background-image:none;color:#333;cursor:text} body.tc-ri-on #external-links-editor input[placeholder^="Add"]:focus::placeholder{color:#999} /* ---- dead-link checker ---- */ #tc-ri-toolbar{position:absolute;right:10px;bottom:8px;display:flex;align-items:center;gap:8px;z-index:3} /* the add-link field expands to full width on focus and would sit under the Check-links button — hide it while editing */ body.tc-ri-on #tc-ri-rightcol > fieldset:has(#external-links-editor input[placeholder^="Add"]:focus) > #tc-ri-toolbar{display:none} #tc-ri-check{font:12px Arial;display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid #d6cdec;border-radius:6px;background:#f6f3fc;color:#5a3e94;cursor:pointer} #tc-ri-check:hover{background:#ece5f8;border-color:#b9a4e0} #tc-ri-check:disabled{opacity:.6;cursor:default} #tc-ri-check .tc-spin{width:12px;height:12px;border:2px solid #cdb8ec;border-top-color:#6f42c1;border-radius:50%;animation:tc-spin .7s linear infinite;display:none} #tc-ri-check.busy .tc-spin{display:inline-block} @keyframes tc-spin{to{transform:rotate(360deg)}} #tc-ri-check-status{font:12px Arial;color:#777} /* a dead link (4xx/5xx/unreachable): faded favicon + struck URL, like Platform Check's not-found state */ body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead .favicon{filter:grayscale(1);opacity:.45} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead a.url{text-decoration:line-through;opacity:.55} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead a.url::after{content:" ✖ " attr(data-tc-deadcode);color:#c0392b;font-size:11px;text-decoration:none;opacity:.9} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-ok a.url::after{content:" ✓";color:#2c7a45;font-size:11px;opacity:.7} /* annotation editor: a bordered box wrapping the toolbar + (bigger) textarea + in-place preview */ body.tc-ri-on #tc-anno-wrap{border:1px solid #d6cdec;border-radius:7px;background:#fff;overflow:hidden;box-sizing:border-box} #tc-anno-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;background:#f6f3fc;border-bottom:1px solid #e7defa} #tc-anno-bar button{font:12px Arial;display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:1px solid #d6cdec;border-radius:6px;background:#fff;color:#5a3e94;cursor:pointer} #tc-anno-bar button:hover{background:#ece5f8;border-color:#b9a4e0} #tc-anno-bar button:disabled{opacity:.6;cursor:default} #tc-anno-bar button.tc-anno-icon{padding:4px 8px;font-size:13px;line-height:1;font-weight:700} #tc-anno-bar .tc-mk-ico{width:22px;height:14px;display:block} #tc-anno-bar .tc-mk-mb{width:17px;height:17px;border-radius:3px} #tc-anno-bar .tc-mk-sq{width:15px;height:15px} /* maximize: the editor fills the viewport over a dimmed backdrop (Esc or the button restores it) */ body.tc-anno-max-open{overflow:hidden} body.tc-anno-max-open::before{content:'';position:fixed;inset:0;background:rgba(30,20,55,.42);z-index:10000} body.tc-ri-on #tc-anno-wrap.tc-anno-max{position:fixed;inset:14px;z-index:10001;display:flex;flex-direction:column;max-width:none;box-shadow:0 14px 50px rgba(35,20,70,.45)} #tc-anno-wrap.tc-anno-max #tc-anno-body{flex:1 1 auto;min-height:0} body.tc-ri-on #tc-anno-wrap.tc-anno-max textarea,#tc-anno-wrap.tc-anno-max #tc-anno-preview{min-height:0;height:auto} #tc-anno-wrap.tc-anno-max #tc-anno-history{flex:1 1 auto;min-height:0;max-height:none} #tc-anno-bar #tc-anno-help{width:25px;justify-content:center;color:#7a5fc0} #tc-anno-bar.tc-anno-prev-on #tc-anno-preview-btn{background:#5f3ec0;color:#fff;border-color:#5f3ec0} #tc-anno-bar.tc-anno-hist-on #tc-anno-history-btn{background:#5f3ec0;color:#fff;border-color:#5f3ec0} #tc-anno-bar.tc-anno-hist-on button:not(#tc-anno-history-btn):not(#tc-anno-max){opacity:.4;pointer-events:none} /* History active → only History + maximize stay usable */ #tc-anno-status{font:italic 11px Arial;font-weight:normal;color:#8a7bb8;letter-spacing:0;text-transform:none} /* shown next to the Annotation: label */ /* three toolbar groups: [Preview Clear] [markup ?] [History] (the 1:3 spacers place markup/? left-of-centre) */ #tc-anno-bar .tc-anno-sp1{flex:1 1 0;min-width:14px} #tc-anno-bar .tc-anno-sp2{flex:3 1 0;min-width:14px} /* editor body: the active textarea on the left; the live preview splits in on the right when toggled */ #tc-anno-body{display:flex;align-items:stretch;min-height:240px} #tc-anno-edit{flex:1 1 0;min-width:0;display:flex;flex-direction:column} body.tc-ri-on #tc-anno-wrap textarea{display:block;width:100%!important;max-width:none!important;min-height:240px;flex:1 1 auto;border:none!important;border-radius:0;padding:9px 11px;resize:vertical;box-shadow:none;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;line-height:1.55;tab-size:4} /* standalone Edit annotation page: Changelog + Annotation rows stacked full-width with the label above */ body.tc-anno-page #content > form > .row:not(.no-label){display:block;margin:0 0 16px;max-width:1100px} body.tc-anno-page #content > form > .row:not(.no-label) > label{display:block;width:auto;text-align:left;float:none;font:600 12px Arial;letter-spacing:.02em;color:#6a6a6a;margin:0 0 5px} body.tc-anno-page #content > form > .row > input[type=text]{width:100%;max-width:680px;box-sizing:border-box;padding:5px 8px;border:1px solid #d6cdec;border-radius:5px} body.tc-anno-page #content > form > fieldset.editnote{max-width:1100px} /* fill the remaining viewport height (height is set per-resize in JS) */ body.tc-anno-page #tc-anno-wrap{display:flex;flex-direction:column} body.tc-anno-page #tc-anno-wrap > #tc-anno-body,body.tc-anno-page #tc-anno-wrap > #tc-anno-history{flex:1 1 auto;min-height:0;max-height:none} body.tc-anno-page #tc-anno-wrap textarea,body.tc-anno-page #tc-anno-wrap #tc-anno-preview{min-height:0} /* Changelog row becomes [label above] then [input + Enter edit] side by side */ body.tc-anno-page #content > form > .row.tc-cl-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px} body.tc-anno-page #content > form > .row.tc-cl-row > label{flex:1 1 100%;margin-bottom:4px} body.tc-anno-page #content > form > .row.tc-cl-row > input[type=text]{flex:1 1 auto;width:auto;max-width:600px} body.tc-anno-page #tc-anno-submit{flex:0 0 auto;font:600 13px Arial;padding:7px 18px;border:1px solid #2c7a45;border-radius:6px;background:#3aa55f;color:#fff;cursor:pointer} body.tc-anno-page #tc-anno-submit:hover{background:#2c8a4d} #tc-anno-preview{flex:1 1 0;min-width:0;min-height:240px;padding:10px 13px;background:#fff;border-left:1px solid #e7defa;font-size:13px;line-height:1.5;color:#333;overflow:auto;word-break:break-word;box-sizing:border-box} #tc-anno-preview .tc-anno-empty{color:#999;font-style:italic} #tc-anno-preview p{margin:0 0 8px} #tc-anno-preview .tc-anno-h{margin:10px 0 6px;color:#3d2470;font-weight:700;line-height:1.25} #tc-anno-preview h1.tc-anno-h{font-size:18px} #tc-anno-preview h2.tc-anno-h{font-size:16px} #tc-anno-preview h3.tc-anno-h,#tc-anno-preview h4.tc-anno-h,#tc-anno-preview h5.tc-anno-h,#tc-anno-preview h6.tc-anno-h{font-size:14px} #tc-anno-preview .tc-anno-ul{margin:0 0 8px;padding-left:22px} #tc-anno-preview .tc-anno-pre{margin:0 0 8px;padding:8px 10px;background:#f0ecf8;border-radius:4px;font-family:Consolas,monospace;font-size:12px;white-space:pre-wrap} #tc-anno-preview hr{border:none;border-top:1px solid #cdbce8;margin:10px 0} #tc-anno-preview a{color:#5f3ec0;text-decoration:none} #tc-anno-preview a:hover{text-decoration:underline} /* syntax help popover (hover the ? button) */ #tc-anno-help-pop{position:fixed;z-index:10000;display:none;width:370px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow-y:auto;background:#fff;border:1px solid #d6cdec;border-radius:8px;box-shadow:0 8px 26px rgba(60,40,110,.22);padding:10px 13px;font:12px Arial;color:#444;line-height:1.5} #tc-anno-help-pop.on{display:block} #tc-anno-help-pop table{border-collapse:collapse;margin:6px 0;width:100%} #tc-anno-help-pop td{padding:2px 7px 2px 0;vertical-align:top} #tc-anno-help-pop td:last-child{color:#777} #tc-anno-help-pop code{background:#f0ecf8;border-radius:3px;padding:0 4px;font-family:Consolas,monospace;color:#5a3e94} #tc-anno-help-pop .tc-help-dim{color:#999} /* Disambiguation + Annotation span the full column with their label stacked ABOVE (not the 150px label column). :has targets exactly those two rows, so the relocated External-links table is untouched. */ body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment), body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation){display:block;width:100%;margin:0 0 14px} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td{display:block;width:100%!important;padding:0} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td:first-child, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td:first-child{text-align:left!important;padding:0 0 4px} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td:first-child label, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td:first-child label{display:block;width:auto;float:none;text-align:left!important;font:600 12px Arial;letter-spacing:.02em;color:#6a6a6a} body.tc-ri-on #information fieldset.information input#comment{width:100%!important;max-width:none!important;box-sizing:border-box} /* annotation History: the selected version rendered on the LEFT, user cards on the RIGHT */ #tc-anno-history{display:flex;min-height:240px;max-height:520px;overflow:hidden;box-sizing:border-box} #tc-anno-history .tc-hist-view{flex:1 1 auto;order:1;overflow:auto;padding:11px 14px;font-size:13px;line-height:1.5;color:#333;word-break:break-word} #tc-anno-history .tc-hist-list{flex:0 0 250px;order:2;overflow-y:auto;overflow-x:hidden;background:#faf8ff;border-left:1px solid #e7defa} #tc-anno-history .tc-hist-card{display:flex;gap:9px;align-items:flex-start;width:100%;box-sizing:border-box;text-align:left;border:none;border-bottom:1px solid #efeafb;background:none;cursor:pointer;padding:9px 11px} #tc-anno-history .tc-hist-card:hover{background:#f0ebfb} #tc-anno-history .tc-hist-card.on{background:#ece5f8;box-shadow:inset -3px 0 0 #5f3ec0} #tc-anno-history .tc-hist-av{width:30px;height:30px;border-radius:50%;flex:0 0 auto;object-fit:cover;background:#e7defa;border:1px solid #ddd} #tc-anno-history .tc-hist-meta{display:flex;flex-direction:column;min-width:0;flex:1 1 auto;font:12px Arial} #tc-anno-history .tc-hist-editor{font-weight:600;color:#3d2470} #tc-anno-history .tc-hist-date{color:#777;font-size:11px} #tc-anno-history .tc-hist-cl{color:#8a8a8a;font-style:italic;font-size:11px;margin-top:2px} #tc-anno-history .tc-hist-cur{color:#2c7a45;font-style:normal} #tc-anno-history .tc-hist-clmsg{color:#5a4a78;font-style:italic;font-size:11px;margin-top:2px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #tc-anno-history .tc-hist-revert{align-self:center;flex:0 0 auto;width:26px;height:26px;border:1px solid #d6cdec;border-radius:5px;background:#fff;color:#5a3e94;font-size:15px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s} #tc-anno-history .tc-hist-card.on .tc-hist-revert,#tc-anno-history .tc-hist-card:hover .tc-hist-revert{opacity:1} /* visible on the selected card, or any card on hover */ #tc-anno-history .tc-hist-revert:hover{background:#ece5f8;border-color:#b9a4e0} #tc-anno-history .tc-hist-revert:disabled{opacity:.5;cursor:default} #tc-anno-history .tc-hist-msg{color:#999;font-style:italic;font-size:12px;padding:6px 2px} #tc-anno-history .tc-hist-bar{display:flex;align-items:center;gap:10px;margin:0 0 10px;flex-wrap:wrap} #tc-anno-history .tc-hist-use{font:12px Arial;padding:4px 11px;border:1px solid #b9a4e0;border-radius:6px;background:#f6f3fc;color:#5a3e94;cursor:pointer} #tc-anno-history .tc-hist-use:hover{background:#ece5f8} #tc-anno-history .tc-hist-vmeta{font:11px Arial;color:#888} #tc-anno-history .tc-anno-rendered h1{font-size:18px;margin:8px 0 6px;color:#3d2470} #tc-anno-history .tc-anno-rendered h2{font-size:16px;margin:8px 0 6px;color:#3d2470} #tc-anno-history .tc-anno-rendered h3{font-size:14px;margin:8px 0 6px;color:#3d2470} #tc-anno-history .tc-anno-rendered p{margin:0 0 8px} #tc-anno-history .tc-anno-rendered ul{margin:0 0 8px;padding-left:22px} #tc-anno-history .tc-anno-rendered a{color:#5f3ec0;text-decoration:none} #tc-anno-history .tc-anno-rendered a:hover{text-decoration:underline} #tc-anno-history .tc-anno-rendered .annotation-details,#tc-anno-history .tc-anno-rendered h2.annotation{display:none}`; const s = document.createElement('style'); s.id = 'tc-ri-style'; s.textContent = css; document.head.appendChild(s); } // move the External-links fieldset into a dedicated right column (or back home when Apollo is off). // Only the server-rendered
wrapper is moved — React's editor root inside it is untouched. function relocateLinks(on) { const panel = document.getElementById('information'); if (!panel) return; const half = panel.querySelector(':scope > div.half-width'); if (!half) return; if (!_riExtFs || !_riExtFs.isConnected) { // find the External-links fieldset by its editor table, or — while that's still loading — by its legend, // so the section moves to the right column immediately instead of showing "Loading…" at the bottom const ext = document.getElementById('external-links-editor'); _riExtFs = ext ? ext.closest('fieldset') : [...half.querySelectorAll('fieldset')].find(f => /external links/i.test(f.querySelector('legend')?.textContent || '')); } const fs = _riExtFs; if (!fs) return; if (on) { let col = panel.querySelector(':scope > #tc-ri-rightcol'); if (!col) { col = document.createElement('div'); col.id = 'tc-ri-rightcol'; panel.appendChild(col); } if (fs.parentElement !== col) { if (!fs._tcHome) fs._tcHome = { parent: fs.parentElement, next: fs.nextElementSibling }; col.appendChild(fs); } ensureCheckToolbar(col); riCover(col); // #297: front cover thumbnail below the external-links section } else if (fs._tcHome && fs.parentElement !== fs._tcHome.parent) { // the Check-links toolbar is appended *inside* the fieldset, so it would travel home with it and orphan // onto the native Release-information tab (#160) — drop it before moving the fieldset back fs.querySelector(':scope > #tc-ri-toolbar')?.remove(); fs._tcHome.parent.insertBefore(fs, fs._tcHome.next && fs._tcHome.next.isConnected ? fs._tcHome.next : null); const col = panel.querySelector(':scope > #tc-ri-rightcol'); col?.querySelector(':scope > #tc-ri-cover')?.remove(); if (col && !col.children.length) col.remove(); } } // #297: the current release MBID (live editor gid, else from the URL); '' on /release/add function currentMbid() { try { const r = u(getEditor().rootField.release); const g = r && u(r.gid); if (g) return g; } catch (e) {} return (location.pathname.match(MBID_RE) || [''])[0]; } // Append/refresh a front-cover thumbnail (Cover Art Archive) at the bottom of the // right column, below external links. Display only; hides itself when there's no art. function riCover(col) { const mbid = currentMbid(); let box = col.querySelector(':scope > #tc-ri-cover'); if (!mbid) { box?.remove(); return; } if (box && box.dataset.mbid === mbid) { col.appendChild(box); return; } // keep, just ensure it's last box?.remove(); box = document.createElement('div'); box.id = 'tc-ri-cover'; box.dataset.mbid = mbid; box.style.display = 'none'; // hidden until the cover actually loads — no "FRONT COVER" flash on coverless releases box.innerHTML = '
Front cover
'; const a = document.createElement('a'); a.href = ORIGIN + '/release/' + mbid + '/cover-art'; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Cover art (Cover Art Archive)'; const img = document.createElement('img'); img.alt = 'Front cover'; img.referrerPolicy = 'no-referrer'; // not lazy: a hidden (display:none) box would never load a lazy image // reveal only once the image loads. On error the box stays hidden — but is KEPT // (marked for this mbid) so riCover doesn't rebuild + re-request it every relayout // tick (#297: front-250 request loop / OpaqueResponseBlocking on coverless releases). img.onload = () => { box.style.display = ''; }; img.src = 'https://coverartarchive.org/release/' + mbid + '/front-250'; a.appendChild(img); box.appendChild(a); col.appendChild(box); } // ---- dead-link checker (#138): check each external link's HTTP status, fade the dead ones, and turn on // "This relationship has ended" for each of a dead link's relationship types ---- const _deadLinks = new Map(); // url -> { dead, code } — kept so marks survive React re-renders of the editor const GMX = (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest) || null; // HEAD (then GET on 405/403/0) → { status, dead }. dead = 4xx/5xx or unreachable. function checkUrl(url) { return new Promise(resolve => { if (!GMX) { resolve({ status: null, dead: null }); return; } // no GM (e.g. page-context) → unknown let done = false; const fin = r => { if (!done) { done = true; resolve(r); } }; const req = method => { try { GMX({ method, url, timeout: 15000, onload: r => { const s = r.status; if (method === 'HEAD' && (s === 405 || s === 501 || s === 403 || s === 0)) return req('GET'); fin({ status: s, dead: !s || s >= 400 }); }, onerror: () => method === 'HEAD' ? req('GET') : fin({ status: 0, dead: true }), ontimeout: () => fin({ status: 0, dead: true }) }); } catch (e) { fin({ status: -1, dead: true }); } }; req('HEAD'); }); } // each external-link row with a real URL, paired with its and the relationship-item rows beneath it function linkRows() { const ext = document.getElementById('external-links-editor'); if (!ext) return []; const rows = [...ext.querySelectorAll('tr')]; const out = []; rows.forEach((r, i) => { if (!r.classList.contains('external-link-item')) return; const a = r.querySelector('a.url'); if (!a) return; // skip the "add another link" input row const rels = []; for (let k = i + 1; k < rows.length; k++) { const n = rows[k]; if (n.classList.contains('external-link-item')) break; if (n.classList.contains('relationship-item')) rels.push(n); } out.push({ row: r, url: a.href, rels }); }); return out; } function markLinkRow(row, dead, code) { row.classList.toggle('tc-link-dead', dead === true); row.classList.toggle('tc-link-ok', dead === false); const a = row.querySelector('a.url'); if (a) { if (dead && code) a.setAttribute('data-tc-deadcode', code); else a.removeAttribute('data-tc-deadcode'); } } function remarkDeadLinks() { // re-apply marks after the React editor re-renders (called from the observer) if (!_deadLinks.size) return; linkRows().forEach(({ row, url }) => { const v = _deadLinks.get(url); if (v) markLinkRow(row, v.dead, v.code); }); } // open a relationship's edit dialog, tick "This relationship has ended", click Done function setRelEnded(relRow) { return new Promise(resolve => { const edit = relRow.querySelector('button.edit-item'); if (!edit) { resolve(false); return; } edit.click(); setTimeout(() => { const dlg = [...document.querySelectorAll('.dialog.popover,[role="dialog"],.bubble')].find(d => d.offsetParent !== null && /relationship has ended|has ended/i.test(d.textContent)); if (!dlg) { resolve(false); return; } const cb = [...dlg.querySelectorAll('input[type=checkbox]')].find(c => /ended/i.test((c.closest('label') || c.parentElement || {}).textContent || '')); if (cb && !cb.checked) cb.click(); const done = [...dlg.querySelectorAll('button')].find(b => /^\s*done\s*$/i.test(b.textContent)); setTimeout(() => { if (done) done.click(); resolve(!!cb); }, 70); }, 240); }); } let _checking = false; async function checkAllLinks(setEnded) { if (_checking) return; _checking = true; const btn = document.getElementById('tc-ri-check'), stat = document.getElementById('tc-ri-check-status'); const links = linkRows(); if (btn) { btn.classList.add('busy'); btn.disabled = true; } if (stat) stat.textContent = 'checking ' + links.length + ' link(s)…'; let dead = 0, done = 0; const queue = links.slice(); const worker = async () => { while (queue.length) { const L = queue.shift(); const r = await checkUrl(L.url); _deadLinks.set(L.url, { dead: r.dead, code: r.status }); markLinkRow(L.row, r.dead, r.status); if (r.dead && setEnded) { for (const rel of L.rels) await setRelEnded(rel); } if (r.dead) dead++; done++; if (stat) stat.textContent = 'checked ' + done + '/' + links.length + (dead ? ' · ' + dead + ' dead' : ''); } }; await Promise.all([worker(), worker(), worker()]); if (btn) { btn.classList.remove('busy'); btn.disabled = false; } if (stat) stat.textContent = links.length ? (dead ? dead + ' dead link(s)' + (setEnded ? ' — marked “ended”' : '') : 'all ' + links.length + ' OK') : 'no links'; _checking = false; } // the "Check links" button + status, pinned at the bottom-right of the External-links box (across from the // add-link [+]). Hidden when the release has no links yet. Lives in the fieldset wrapper, not the React editor. function ensureCheckToolbar(col) { const fs = _riExtFs && _riExtFs.isConnected ? _riExtFs : (col && col.querySelector('fieldset')); if (!fs) return; let bar = fs.querySelector(':scope > #tc-ri-toolbar'); if (!bar) { fs.style.position = fs.style.position || 'relative'; bar = document.createElement('div'); bar.id = 'tc-ri-toolbar'; bar.innerHTML = ''; bar.querySelector('#tc-ri-check').onclick = () => checkAllLinks(true); fs.appendChild(bar); } bar.style.display = linkRows().length ? '' : 'none'; // no links → no button } // ── Annotation editor: a small toolbar above the release annotation textarea, with a live // Preview (MB markup → HTML), Clear, and — inspired by kellnerd's annotationConverter — a // Markdown→MB converter and a WS2 "resolve names" action that labels bare MB entity URLs. ── const ANNO_NAME_FIELD = { artist:'name', label:'name', area:'name', place:'name', instrument:'name', series:'name', event:'name', genre:'name', 'release-group':'title', release:'title', recording:'title', work:'title' }; // release-group MUST precede release in the alternation (else "release" matches the prefix of "release-group/…") const ANNO_ENTITY_RE = /https?:\/\/(?:beta\.)?musicbrainz\.org\/(artist|label|area|place|instrument|series|event|genre|release-group|release|recording|work)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; const _annoName = new Map(); // entity url → resolved display name (shared by Resolve-names + Preview) const _annoEsc = s => s.replace(/&/g, '&').replace(//g, '>'); // build nested
    /
      from a flat list of {level>=1, html, ordered} (MB nests by 4-space indentation; // "a." marks an auto-numbered list, "*" a bullet list) function bulletsToHtml(items) { let out = '', depth = 0, openLi = false; const stack = []; const open = ord => { const t = ord ? 'ol' : 'ul', c = ord ? 'tc-anno-ol' : 'tc-anno-ul'; out += `<${t} class="${c}">`; stack.push(t); }; const close = () => { out += ``; }; for (const it of items) { const lvl = Math.max(1, it.level); if (lvl > depth) { while (depth < lvl) { open(it.ordered); depth++; openLi = false; } } else { if (openLi) { out += ''; openLi = false; } while (depth > lvl) { close(); depth--; if (depth > 0) out += ''; } } out += '
    1. ' + it.html; openLi = true; } if (openLi) out += '
    2. '; while (depth > 0) { close(); depth--; if (depth > 0) out += ''; } return out; } // MB annotation markup → HTML, replicating the documented subset (musicbrainz.org/doc/Annotation): // ''italic'' '''bold''', = h1 = .. === h3 ===, [url] / [url|text] / bare-url links, ---- rule, // (4n)-space "*" nested bullets, 8-space code, [/] literal brackets. Pure + sync (no network). function annoToHtml(src) { if (!src || !src.trim()) return '(nothing to preview)'; src = String(src).replace(/[/g, '\x01').replace(/]/g, '\x02'); // protect literal brackets const inline = txt => { let s = _annoEsc(txt); const links = []; // pull links out first so '' / ''' never touch a URL const stash = html => '\x03' + (links.push(html) - 1) + '\x04'; // only http(s)/ftp are linkified (as MB does) — anything else (e.g. javascript:) renders as plain text const anchor = (url, label) => { url = url.trim(); if (!/^(?:https?|ftp):\/\//i.test(url)) return null; const name = _annoName.get(url); return stash(`
      ${label != null ? label : (name ? _annoEsc(name) : _annoEsc(url))}`); }; s = s.replace(/\[([^\]|]+)\|([^\]]*)\]/g, (_m, url, text) => anchor(url, text ? _annoEsc(text) : null) ?? _annoEsc(_m)); s = s.replace(/\[([^\]|]+)\]/g, (_m, url) => anchor(url, null) ?? _annoEsc(_m)); s = s.replace(/(^|[\s(])((?:https?|ftp):\/\/[^\s<>]+[^\s<>.,;:!?)])/g, (_m, pre, url) => pre + (anchor(url, null) ?? _annoEsc(url))); s = s.replace(/'''''(.+?)'''''/g, '$1').replace(/'''(.+?)'''/g, '$1').replace(/''(.+?)''/g, '$1'); s = s.replace(/\x03(\d+)\x04/g, (_m, i) => links[+i]); // restore links return s; }; const lines = src.split(/\r?\n/), out = []; let i = 0; while (i < lines.length) { const ln = lines[i]; let m; if (/^\s*$/.test(ln)) { i++; continue; } if (/^-{4,}\s*$/.test(ln)) { out.push('
      '); i++; continue; } if ((m = ln.match(/^(={1,6})\s*(.*?)\s*=*\s*$/)) && m[2]) { const n = Math.min(m[1].length, 6); out.push(`${inline(m[2])}`); i++; continue; } // lists BEFORE code: a "(4n)-space * " line is a level-n bullet, "(4n)-space a. " an auto-numbered item // (MB nests by indentation); only 8-space lines that are NOT a list item are code. if (/^ {4,}(?:\*|[a-z]\.)[ \t]+/i.test(ln)) { const items = []; let bm; while (i < lines.length && (bm = lines[i].match(/^( +)(\*|[a-z]\.)[ \t]+(.*)$/i))) { items.push({ level: Math.max(1, Math.floor(bm[1].length / 4)), ordered: bm[2] !== '*', html: inline(bm[3]) }); i++; } out.push(bulletsToHtml(items)); continue; } if (/^ {8}/.test(ln)) { const buf = []; while (i < lines.length && /^ {8}/.test(lines[i]) && !/^ {4,}(?:\*|[a-z]\.)[ \t]/i.test(lines[i])) { buf.push(_annoEsc(lines[i].slice(8))); i++; } out.push('
      ' + buf.join('\n') + '
      '); continue; } const buf = []; // a paragraph: consume the CURRENT line first (do-while → i ALWAYS advances, so an do { buf.push(inline(lines[i])); i++; } // empty-title "= =" heading can't spin forever), while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^-{4,}\s*$/.test(lines[i]) && !/^={1,6}\s/.test(lines[i]) && !/^ {4,}(?:\*|[a-z]\.)[ \t]+/i.test(lines[i]) && !/^ {8}/.test(lines[i])); // then following non-blank, non-block lines out.push('

      ' + buf.join('
      ') + '

      '); } return out.join('').replace(/\x01/g, '[').replace(/\x02/g, ']'); } // Markdown → MB annotation markup (kellnerd-inspired). Pure + sync. function mdToAnno(src) { if (!src) return src; const urls = [], blocks = []; // protect URLs (* / _) and code blocks from the inline passes const stashU = u => '\x05' + (urls.push(u) - 1) + '\x06'; const stashB = b => '\x07' + (blocks.push(b) - 1) + '\x08'; src = src.replace(/^```[^\n]*\n([\s\S]*?)\n```[ \t]*$/gm, (_m, code) => stashB(code.split('\n').map(l => ' ' + l).join('\n'))); // ```fenced``` → MB 8-space block src = src.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => `[${stashU(url)}|${text}]`); // [text](url) → [url|text] src = src.replace(/(^|[\s(])((?:https?|ftp):\/\/[^\s<>]+)/g, (_m, pre, url) => pre + stashU(url)); // bare urls src = src.replace(/\[([^\[\]]*)\]/g, (m, inner) => inner.includes('\x05') ? m : '[' + inner + ']'); // a non-link [x] → encoded brackets, so MB doesn't read it as a (broken) link src = src.replace(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm, (_m, h, t) => { const n = Math.min(h.length, 3); const e = '='.repeat(n); return `${e} ${t} ${e}`; }); src = src.replace(/(\*\*|__)(.+?)\1/g, "'''$2'''"); // **bold** / __bold__ src = src.replace(/(? { const sp = ind.replace(/\t/g, ' ').length; return ' '.repeat((Math.floor(sp / 2) + 1) * 4) + 'a. '; }); // markdown numbered list → MB "a." (MB auto-numbers a., not 1.) src = src.replace(/^([ \t]*)[-*+][ \t]+/gm, (_m, ind) => { const sp = ind.replace(/\t/g, ' ').length; return ' '.repeat((Math.floor(sp / 2) + 1) * 4) + '* '; }); // markdown bullet (2-space-per-level indent) → MB (4n)-space bullet src = src.replace(/\x07(\d+)\x08/g, (_m, i) => blocks[+i]); return src.replace(/\x05(\d+)\x06/g, (_m, i) => urls[+i]); } // MB annotation markup → Markdown (reverse of mdToAnno; powers the Markdown toggle's "back" direction) function annoToMd(src) { if (!src) return src; const blocks = []; // pull MB code blocks out first so '''/'' inside them aren't touched const stashB = b => '\x07' + (blocks.push(b) - 1) + '\x08'; src = src.replace(/(?:^ {8}(?! *(?:\*|[a-z]\.)[ \t]).*(?:\n|$))+/gim, m => { const trail = m.endsWith('\n') ? '\n' : ''; const code = m.replace(/\n$/, '').split('\n').map(l => l.slice(8)).join('\n'); return stashB('```\n' + code + '\n```') + trail; }); // MB 8-space block (not a nested list item) → ```fenced``` src = src.replace(/\[([^\]|]+)\|([^\]]*)\]/g, (_m, url, text) => text ? `[${text}](${url})` : url); // [url|text] → [text](url); [url|] (empty label) → bare url src = src.replace(/\[((?:https?|ftp):\/\/[^\]|]+)\]/g, (_m, url) => url); // [url] → bare url src = src.replace(/'''''(.+?)'''''/g, '***$1***').replace(/'''(.+?)'''/g, '**$1**').replace(/''(.+?)''/g, '*$1*'); src = src.replace(/^(={1,6})[ \t]*(.*?)[ \t]*=*[ \t]*$/gm, (_m, e, t) => '#'.repeat(e.length) + ' ' + t); // = H = → # H src = src.replace(/^( {4,})[a-z]\.[ \t]+/gim, (_m, ind) => ' '.repeat(Math.max(0, Math.floor(ind.length / 4) - 1)) + '1. '); // MB "a." auto-numbered → markdown "1." numbered list src = src.replace(/^( {4,})\*[ \t]+/gm, (_m, ind) => ' '.repeat(Math.max(0, Math.floor(ind.length / 4) - 1)) + '- '); // MB (4n)-space bullet → markdown (2-space-per-level) bullet src = src.replace(/^-{4,}[ \t]*$/gm, '---'); // ---- → --- src = src.replace(/[/g, '[').replace(/]/g, ']'); // decode literal brackets back to plain [ ] (literal in Markdown) return src.replace(/\x07(\d+)\x08/g, (_m, i) => blocks[+i]); } function annoReplaceAsync(str, re, fn) { // async String.replace (kellnerd's replaceAsync) const parts = []; let last = 0, m; re.lastIndex = 0; while ((m = re.exec(str)) !== null) { parts.push(str.slice(last, m.index), fn(...m, m.index, str)); last = m.index + m[0].length; if (!re.global) break; } parts.push(str.slice(last)); return Promise.all(parts).then(p => p.join('')); } async function annoLookupName(type, mbid, full) { // WS2 entity name, cached type = type.toLowerCase(); if (_annoName.has(full)) return _annoName.get(full); try { const r = await fetch(`${location.origin}/ws/2/${type}/${mbid}?fmt=json`, { headers: { Accept: 'application/json' } }); if (!r.ok) return null; const j = await r.json(); const name = j[ANNO_NAME_FIELD[type] || 'name']; if (name) { _annoName.set(full, name); return name; } return null; } catch { return null; } } // is this URL an MB entity URL? (tolerates a trailing path like /release//annotations) → {type,mbid} function annoEntity(url) { const m = ANNO_ENTITY_RE.exec(url); ANNO_ENTITY_RE.lastIndex = 0; return m ? { type: m[1].toLowerCase(), mbid: m[2] } : null; } // add the entity name to every MB entity link that doesn't already have one — handles MB [url] / [url|] // and Markdown []()/bare URLs, in either editing mode. Links that already carry a label are left alone. // Captures the URL first, THEN tests it for an entity, so trailing path segments don't break the match. async function annoResolveNames(src, md) { // md=true → emit Markdown links [Name](url); else MB links [url|Name] const lbl = async (url) => { const e = annoEntity(url); return e ? await annoLookupName(e.type, e.mbid, url) : null; }; if (!md) src = await annoReplaceAsync(src, /\[([^\]|]+)\|?\]/g, async (m, url) => { url = url.trim(); const n = await lbl(url); return n ? `[${url}|${n}]` : m; }); // MB [url] / [url|] src = await annoReplaceAsync(src, /\[\]\(([^)\s]+)\)/g, async (m, url) => { const n = await lbl(url); return n ? `[${n}](${url})` : m; }); // Markdown [](url) // a bare URL (not already inside a [..] or (..) link) → named, in the active markup src = await annoReplaceAsync(src, /(?\]]+)/g, async (m, url) => { const n = await lbl(url); return n ? (md ? `[${n}](${url})` : `[${url}|${n}]`) : m; }); return src; } // annotation History: parse the /annotations page into a version list, and pull a single version's // rendered annotation HTML from its "View this version" page (musicbrainz.org, same-origin fetch). async function annoFetchHistory(mbid) { const r = await fetch(`${location.origin}/release/${mbid}/annotations`, { credentials: 'same-origin' }); if (!r.ok) throw new Error('history ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); const out = []; doc.querySelectorAll('table tr').forEach(tr => { const view = [...tr.querySelectorAll('a')].find(a => /this version/i.test(a.textContent || '')); if (!view) return; const ua = tr.querySelector('a[href^="/user/"]'); const editor = ua?.textContent.trim() || ''; const avatar = ua?.querySelector('img')?.getAttribute('src') || ''; const date = [...tr.querySelectorAll('td')].map(c => c.textContent.trim()).find(t => /\d{4}-\d{2}-\d{2}/.test(t)) || ''; const cl = (view.parentElement.textContent.match(/\(([^)]*)\)/) || [, ''])[1]; out.push({ editor, avatar, date, changelog: /no changelog/i.test(cl) ? '' : cl, url: view.getAttribute('href') }); }); return out; } async function annoFetchVersion(url) { const r = await fetch(new URL(url, location.origin).href, { credentials: 'same-origin' }); if (!r.ok) throw new Error('version ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); const body = doc.querySelector('.annotation-body'); return body ? body.innerHTML : '(this version is empty)'; } // reconstruct MB markup from a rendered annotation's HTML (MB exposes no raw text per revision) — used to // load a past version back into the editor. Lossy on exotic markup, faithful for the common elements. function annoHtmlToMb(html) { if (/this annotation is empty/i.test(html)) return ''; const root = new DOMParser().parseFromString('
      ' + html + '
      ', 'text/html').body.firstChild; const inline = node => { let s = ''; node.childNodes.forEach(n => { if (n.nodeType === 3) s += n.textContent; else if (n.nodeType === 1) { const t = n.tagName.toLowerCase(), inner = inline(n); if (t === 'strong' || t === 'b') s += "'''" + inner + "'''"; else if (t === 'em' || t === 'i') s += "''" + inner + "''"; else if (t === 'a') { const href = n.getAttribute('href') || ''; s += href ? `[${/^https?:\/\//i.test(href) ? href : location.origin + href}|${inner}]` : inner; } else if (t === 'br') s += '\n'; else s += inner; } }); return s; }; const lines = []; const listWalk = (listNode, ordered, level) => { [...listNode.children].forEach(li => { if (li.tagName.toLowerCase() !== 'li') return; const clone = li.cloneNode(true); clone.querySelectorAll(':scope > ul, :scope > ol').forEach(s => s.remove()); lines.push(' '.repeat(level * 4) + (ordered ? 'a. ' : '* ') + inline(clone).trim()); [...li.children].forEach(c => { const ct = c.tagName.toLowerCase(); if (ct === 'ul' || ct === 'ol') listWalk(c, ct === 'ol', level + 1); }); }); }; const walk = node => node.childNodes.forEach(n => { if (n.nodeType === 3) { if (n.textContent.trim()) lines.push(n.textContent.trim(), ''); return; } if (n.nodeType !== 1) return; const t = n.tagName.toLowerCase(); if (/^h[1-6]$/.test(t)) { const e = '='.repeat(Math.min(+t[1], 3)); lines.push(`${e} ${inline(n).trim()} ${e}`, ''); } else if (t === 'p') lines.push(inline(n).replace(/\n+$/, '').trim(), ''); else if (t === 'ul' || t === 'ol') { listWalk(n, t === 'ol', 1); lines.push(''); } else if (t === 'pre') { inline(n).replace(/\n$/, '').split('\n').forEach(l => lines.push(' ' + l)); lines.push(''); } else if (t === 'hr') lines.push('----', ''); else if (t === 'div' || t === 'blockquote') walk(n); else { const x = inline(n).trim(); if (x) lines.push(x, ''); } }); walk(root); return lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, ''); } // write into the annotation textarea so MB's model (knockout 'change' / React 'input') picks it up + dirties function annoSet(ta, value) { const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(ta, value); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } // Enter on a list line continues it (same indent+marker — bullet "*"/"-" or numbered "a."/"1."); an empty // item ends the list. Pure → testable. function annoContinueBullet(value, pos) { const lineStart = value.lastIndexOf('\n', pos - 1) + 1; let lineEnd = value.indexOf('\n', pos); if (lineEnd < 0) lineEnd = value.length; const m = value.slice(lineStart, lineEnd).match(/^([ \t]*)([*+-]|\d+\.|[a-z]\.)([ \t]+)(.*)$/i); if (!m) return null; if (m[4].trim() === '') return { value: value.slice(0, lineStart) + value.slice(lineEnd), caret: lineStart }; // empty item → end list const prefix = m[1] + m[2] + m[3]; return { value: value.slice(0, pos) + '\n' + prefix + value.slice(pos), caret: pos + 1 + prefix.length }; } // Ctrl/Cmd+B/I: wrap the selection with `marker`, or — with no selection — surround the word under the cursor. Pure. function annoWrap(value, selStart, selEnd, marker) { let a = selStart, b = selEnd; if (a === b) { while (a > 0 && /\w/.test(value[a - 1])) a--; while (b < value.length && /\w/.test(value[b])) b++; } return { value: value.slice(0, a) + marker + value.slice(a, b) + marker + value.slice(b), selStart: a + marker.length, selEnd: b + marker.length }; } // Tab on a selection cycles the lines through plain → bullet → numbered → bullet…; Shift+Tab (strip=true) // removes any list marker. Pure → testable. function annoListSelection(value, selStart, selEnd, raw, strip) { let s = value.lastIndexOf('\n', selStart - 1) + 1; let e = selEnd; if (e > s && value[e - 1] === '\n') e--; let lineEnd = value.indexOf('\n', e); if (lineEnd < 0) lineEnd = value.length; const block = value.slice(s, lineEnd); const first = (block.split('\n').find(l => l.trim() !== '') || '').match(/^[ \t]*([-*+]|\d+\.|[a-z]\.)/i); const ordered = !!(first && /[-*+]/.test(first[1])); // currently bullets → switch to numbered; else → bullets const repl = block.split('\n').map(ln => { if (ln.trim() === '') return ln; if (strip) return ln.replace(/^[ \t]*(?:[-*+]|\d+\.|[a-z]\.)[ \t]+/i, ''); // remove the list marker, keep the text const txt = ln.replace(/^[ \t]*(?:[-*+]|\d+\.|[a-z]\.)?[ \t]*/i, ''); // drop leading ws + any existing marker return raw ? (ordered ? ' a. ' : ' * ') + txt : (ordered ? '1. ' : '- ') + txt; }).join('\n'); return { value: value.slice(0, s) + repl + value.slice(lineEnd), selStart: s, selEnd: s + repl.length }; } // "Join lines": collapse the selected lines — or, with no selection, the paragraph at the caret — into a // single line, turning every interior newline into one space so hard-wrapped text (e.g. Bandcamp credits) // reflows. Leaves blank-line paragraph boundaries as the natural edge of an empty selection. Pure → testable. function annoJoinBlock(value, selStart, selEnd) { let a, b; if (selStart === selEnd) { // no selection → expand to the run of non-blank lines around the caret a = value.lastIndexOf('\n', selStart - 1) + 1; while (a > 0) { const ps = value.lastIndexOf('\n', a - 2) + 1; if (!value.slice(ps, a - 1).trim()) break; a = ps; } b = value.indexOf('\n', selEnd); if (b < 0) b = value.length; while (b < value.length) { let ne = value.indexOf('\n', b + 1); if (ne < 0) ne = value.length; if (!value.slice(b + 1, ne).trim()) break; b = ne; } } else { // selection → expand to whole lines a = value.lastIndexOf('\n', selStart - 1) + 1; let e = selEnd; if (e > a && value[e - 1] === '\n') e--; b = value.indexOf('\n', e); if (b < 0) b = value.length; } const joined = value.slice(a, b).replace(/[ \t]*\r?\n[ \t]*/g, ' ').replace(/[ \t]{2,}/g, ' ').replace(/^[ \t]+|[ \t]+$/g, ''); return { value: value.slice(0, a) + joined + value.slice(b), selStart: a, selEnd: a + joined.length }; } // Wrap #annotation in a bordered editor box (toolbar + a Markdown editing surface + the raw MB field + an // in-place preview). Markdown is the DEFAULT surface; the real #annotation field always holds MB markup (so // saving is always correct) — Markdown edits are converted into it live. Mounted ONCE per #annotation node // (the 500ms applyReleaseInfo poll must not rebuild it — that was the flicker). const ANNO_MD_LOGO = ''; const ANNO_MAX_ICON = ''; // Join lines: two lines pulled together toward one (a downward merge between a top and bottom rule). const ANNO_JOIN_ICON = ''; const ANNO_MIN_ICON = ''; const ANNO_MB_LOGO = 'MB'; const ANNO_HELP_HTML = 'Markdown (converted to MusicBrainz markup on save)' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
      **bold** *italic*bold / italic
      # H1 ## H2 ### H3headings
      - itembullet list (indent 2 spaces to nest)
      1. itemnumbered list (MB auto-numbers)
      [text](url) · bare URLlink
      ```code```code block
      ---horizontal rule
      [x]shown literally (auto-encoded)
      ' + 'Shortcuts' + '' + '' + '' + '' + '' + '
      Ctrl/Cmd+B / +Ibold / italic (selection or word)
      Entercontinue the current list
      Tabindent · on a selection → bullet list (Tab again → numbered, again → bullet…)
      Shift+Tabon a selection → remove the list marker
      ' + '
      A MusicBrainz entity URL (bare or []()) gets its name added automatically.
      '; function ensureAnnotationToolbar(taArg) { const ta = taArg || document.getElementById('annotation'); if (!ta) return; // the MB annotation field — always holds MB markup if (ta._tcAnnoMounted && ta._tcAnnoMounted.isConnected) return; const mbid = (location.pathname.match(/\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/) || [])[1]; // present on /release//edit, absent on /release/add const wrap = document.createElement('div'); wrap.id = 'tc-anno-wrap'; const bar = document.createElement('div'); bar.id = 'tc-anno-bar'; bar.innerHTML = '' + '' + '' + '' + '' + '' + '' + '' + (mbid ? '' : ''); const body = document.createElement('div'); body.id = 'tc-anno-body'; const editPane = document.createElement('div'); editPane.id = 'tc-anno-edit'; const md = document.createElement('textarea'); md.id = 'tc-anno-mdinput'; md.spellcheck = false; // the Markdown editing surface const prev = document.createElement('div'); prev.id = 'tc-anno-preview'; prev.style.display = 'none'; const hist = document.createElement('div'); hist.id = 'tc-anno-history'; hist.style.display = 'none'; const helpPop = document.createElement('div'); helpPop.id = 'tc-anno-help-pop'; helpPop.innerHTML = ANNO_HELP_HTML; ta.parentNode.insertBefore(wrap, ta); editPane.append(md, ta); body.append(editPane, prev); wrap.append(bar, body, hist, helpPop); ta._tcAnnoMounted = wrap; // the field's row — a in the release editor, a div.row on the standalone Edit annotation page const annoRow = wrap.closest('tr, .row'); // release editor: put Disambiguation above Annotation (both already span the full column via CSS) const commRow = document.getElementById('comment')?.closest('tr'); if (annoRow && commRow && commRow !== annoRow && annoRow.previousElementSibling !== commRow) annoRow.parentNode.insertBefore(commRow, annoRow); // status messages appear next to the "Annotation:" label (not in the toolbar) const statusEl = document.createElement('span'); statusEl.id = 'tc-anno-status'; const labelCell = annoRow?.querySelector('td:first-child label') || annoRow?.querySelector('td:first-child') || annoRow?.querySelector('label'); if (labelCell) labelCell.appendChild(statusEl); const $ = id => bar.querySelector('#' + id); const status = (msg, ms) => { statusEl.textContent = msg ? ' — ' + msg : ''; if (ms) setTimeout(() => { if (statusEl.textContent === ' — ' + msg) statusEl.textContent = ''; }, ms); }; // apply a new value as a minimal range edit via execCommand, so it joins the textarea's NATIVE undo stack // (Ctrl+Z undoes Ctrl+B/I, Tab lists, Enter continuation, …). Falls back to the native setter if unsupported. const editTa = (el, val, s, e2) => { const old = el.value; if (old !== val) { let p = 0; const lim = Math.min(old.length, val.length); while (p < lim && old[p] === val[p]) p++; let so = old.length, sn = val.length; while (so > p && sn > p && old[so - 1] === val[sn - 1]) { so--; sn--; } const ins = val.slice(p, sn); el.focus(); el.setSelectionRange(p, so); let ok = false; try { ok = ins ? document.execCommand('insertText', false, ins) : (so > p ? document.execCommand('delete') : true); } catch { ok = false; } if (!ok || el.value !== val) { const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(el, val); el.dispatchEvent(new Event('input', { bubbles: true })); } // fallback: no native undo, but correct } el.setSelectionRange(s, e2 == null ? s : e2); el.dispatchEvent(new Event('change', { bubbles: true })); }; const syncMdToField = () => annoSet(ta, mdToAnno(md.value)); // Markdown surface → MB field (keeps the model correct) const activeEl = () => surface === 'raw' ? ta : md; let surface = 'md', previewing = false, view = 'edit'; // surface: md|raw · previewing: split preview · view: edit|history const renderPreview = () => { if (previewing && view === 'edit') prev.innerHTML = annoToHtml(ta.value); }; const apply = () => { body.style.display = view === 'edit' ? 'flex' : 'none'; hist.style.display = view === 'history' ? '' : 'none'; md.style.display = surface === 'md' ? '' : 'none'; ta.style.display = surface === 'raw' ? '' : 'none'; prev.style.display = previewing && view === 'edit' ? '' : 'none'; bar.classList.toggle('tc-anno-prev-on', previewing); bar.classList.toggle('tc-anno-hist-on', view === 'history'); const mdBtn = $('tc-anno-md'); mdBtn.innerHTML = surface === 'md' ? ANNO_MD_LOGO : ANNO_MB_LOGO; mdBtn.title = surface === 'md' ? 'Editing as Markdown — click to edit the raw MusicBrainz markup' : 'Editing raw MusicBrainz markup — click to edit as Markdown'; renderPreview(); }; let previewT; md.addEventListener('input', () => { if (surface === 'md') syncMdToField(); clearTimeout(previewT); previewT = setTimeout(renderPreview, 120); }); ta.addEventListener('input', () => { if (surface === 'raw') { clearTimeout(previewT); previewT = setTimeout(renderPreview, 120); } }); const wireKeys = el => el.addEventListener('keydown', e => { const raw = el === ta; if (e.key === 'Enter' && !e.shiftKey && el.selectionStart === el.selectionEnd) { const r = annoContinueBullet(el.value, el.selectionStart); if (r) { e.preventDefault(); editTa(el, r.value, r.caret); } } else if (e.key === 'Tab') { e.preventDefault(); if (el.selectionStart !== el.selectionEnd) { // Tab → bullet → numbered → bullet…; Shift+Tab → remove the list marker const r = annoListSelection(el.value, el.selectionStart, el.selectionEnd, raw, e.shiftKey); editTa(el, r.value, r.selStart, r.selEnd); } else if (!e.shiftKey) { const p = el.selectionStart, v = el.value; editTa(el, v.slice(0, p) + '\t' + v.slice(p), p + 1); } // plain Tab → insert a tab } else if ((e.ctrlKey || e.metaKey) && !e.altKey && /^[biBI]$/.test(e.key)) { e.preventDefault(); const bold = e.key.toLowerCase() === 'b'; const marker = raw ? (bold ? "'''" : "''") : (bold ? '**' : '*'); const r = annoWrap(el.value, el.selectionStart, el.selectionEnd, marker); editTa(el, r.value, r.selStart, r.selEnd); } }); wireKeys(md); wireKeys(ta); // auto-resolve unnamed MB entity links (no button) — on blur of the editing surface, and once on mount let resolving = false; const autoResolve = async (el) => { if (resolving || !ANNO_ENTITY_RE.test(el.value)) return; resolving = true; const before = el.value; try { const after = await annoResolveNames(before, el === md); if (after !== before && el.value === before) { editTa(el, after, Math.min(el.selectionStart, after.length)); status('named entity links', 2000); } } finally { resolving = false; } }; md.addEventListener('blur', () => autoResolve(md)); ta.addEventListener('blur', () => autoResolve(ta)); $('tc-anno-preview-btn').onclick = () => { previewing = !previewing; apply(); }; $('tc-anno-md').onclick = () => { if (surface === 'md') syncMdToField(); surface = surface === 'md' ? 'raw' : 'md'; if (surface === 'md') md.value = annoToMd(ta.value); apply(); activeEl().focus(); }; $('tc-anno-clear').onclick = () => { md.value = ''; annoSet(ta, ''); renderPreview(); }; // Join lines: reflow the selected lines (or the caret's paragraph) into one — works on whichever surface is active $('tc-anno-join').onclick = () => { const el = activeEl(); const r = annoJoinBlock(el.value, el.selectionStart, el.selectionEnd); editTa(el, r.value, r.selStart, r.selEnd); el.focus(); }; // maximize / restore the editor (fills the viewport) const setMax = on => { wrap.classList.toggle('tc-anno-max', on); document.body.classList.toggle('tc-anno-max-open', on); if (on) wrap.style.height = ''; else if (wrap._tcFill) wrap._tcFill(); const b = $('tc-anno-max'); b.innerHTML = on ? ANNO_MIN_ICON : ANNO_MAX_ICON; b.title = on ? 'Restore the editor (Esc)' : 'Maximize the editor (Esc to restore)'; renderPreview(); }; $('tc-anno-max').onclick = () => setMax(!wrap.classList.contains('tc-anno-max')); wrap.addEventListener('keydown', e => { if (e.key === 'Escape' && wrap.classList.contains('tc-anno-max')) { setMax(false); activeEl().focus(); } }); // hover-help popover const help = $('tc-anno-help'); let helpHideT; const showHelp = () => { clearTimeout(helpHideT); helpPop.classList.add('on'); const r = help.getBoundingClientRect(), ph = helpPop.offsetHeight, pw = helpPop.offsetWidth; const left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8)); let top = r.bottom + 6; if (top + ph > window.innerHeight - 8) top = Math.max(8, r.top - ph - 6); // flip above the button if it would overflow the bottom if (top < 8) top = 8; helpPop.style.left = Math.round(left) + 'px'; helpPop.style.top = Math.round(top) + 'px'; }; const hideHelp = () => { helpHideT = setTimeout(() => helpPop.classList.remove('on'), 180); }; help.addEventListener('mouseenter', showHelp); help.addEventListener('focus', showHelp); help.addEventListener('mouseleave', hideHelp); help.addEventListener('blur', hideHelp); helpPop.addEventListener('mouseenter', () => clearTimeout(helpHideT)); helpPop.addEventListener('mouseleave', hideHelp); // History — version list as user cards on the RIGHT, the selected version rendered on the LEFT const renderHistory = async () => { hist.innerHTML = '
      Loading history…
      '; let versions; try { versions = await annoFetchHistory(mbid); } catch { hist.innerHTML = '
      Failed to load history.
      '; return; } if (!versions.length) { hist.innerHTML = '
      No annotation history yet.
      '; return; } hist.innerHTML = '
      Select a version to display it.
      '; const list = hist.querySelector('.tc-hist-list'), vw = hist.querySelector('.tc-hist-view'); versions.forEach((v, idx) => { const card = document.createElement('div'); card.className = 'tc-hist-card'; card.tabIndex = 0; card.innerHTML = (v.avatar ? `` : '') + `${_annoEsc(v.editor)}${_annoEsc(v.date)}` + (idx === 0 ? 'current' : '') + (v.changelog ? `“${_annoEsc(v.changelog)}”` : '') + '' + ``; card.onclick = async (e) => { if (e.target.closest('.tc-hist-revert')) return; list.querySelectorAll('.tc-hist-card').forEach(c => c.classList.remove('on')); card.classList.add('on'); vw.innerHTML = '
      Loading…
      '; try { vw.innerHTML = '
      ' + await annoFetchVersion(v.url) + '
      '; } catch { vw.innerHTML = '
      Failed to load this version.
      '; } }; const revert = card.querySelector('.tc-hist-revert'); if (revert) revert.onclick = async (e) => { e.stopPropagation(); revert.disabled = true; try { const mb = annoHtmlToMb(await annoFetchVersion(v.url)); annoSet(ta, mb); md.value = annoToMd(ta.value); view = 'edit'; apply(); activeEl().focus(); status('reverted to ' + v.date + ' — review before submitting', 4000); } catch { status('failed to load that version', 3000); } finally { revert.disabled = false; } }; list.appendChild(card); }); }; if ($('tc-anno-history-btn')) $('tc-anno-history-btn').onclick = () => { view = view === 'history' ? 'edit' : 'history'; apply(); if (view === 'history') renderHistory(); }; md.value = annoToMd(ta.value); // seed the Markdown surface from existing MB markup (no annoSet → no spurious dirty) apply(); // reflect EXTERNAL changes to the annotation (Scribe, scripts, revert) in the Markdown surface. // ko's `value` binding sets the textarea WITHOUT firing input, so a DOM listener can't see it — // subscribe to the observable. Our own md→field writes round-trip equal, so they're ignored (no loop). try { const annoObs = getEditor().rootField.release().annotation; if (annoObs && typeof annoObs.subscribe === 'function' && !ta._tcAnnoSub) { ta._tcAnnoSub = annoObs.subscribe(v => { const nv = v || ''; if (mdToAnno(md.value) === nv) return; // our own write / already in sync md.value = annoToMd(nv); // render the preview from nv directly — ta.value may not be updated yet (ko subscriber // order isn't guaranteed), which would leave the split preview showing the OLD annotation. if (previewing && view === 'edit') prev.innerHTML = annoToHtml(nv); }); } } catch (e) {} setTimeout(() => autoResolve(activeEl()), 400); // name any unnamed links already in the annotation } function annoWant() { return apolloEnabled() && SETTINGS.modifyAnnotation !== false; } // global Apollo toggle + the "Modify annotations" setting // tear the editor down and put the native textarea back (when the setting is turned off) function unmountAnnotation(taArg) { const ta = taArg || document.getElementById('annotation'); const wrap = ta && ta._tcAnnoMounted; if (!wrap || !wrap.isConnected) { if (ta) ta._tcAnnoMounted = null; return; } ta.style.display = ''; wrap.parentNode.insertBefore(ta, wrap); wrap.remove(); ta._tcAnnoMounted = null; document.getElementById('tc-anno-status')?.remove(); } // standalone /release//edit_annotation page: mount our editor on the annotation field, move the // Changelog above it (like Disambiguation in /edit). Gated by the same "Modify annotations" setting. function applyAnnotationPage() { if (!/\/release\/[0-9a-f-]{36}\/edit_annotation/.test(location.pathname)) return; const ta = document.querySelector('textarea[name="edit-annotation.text"]'); if (!ta) return; ensureLauncher(); // the floating Original / Apollo switcher + ⚙ settings, same as the release editor const form = ta.closest('form'), hide = annoWant(); if (hide) { document.body.classList.add('tc-ri-on', 'tc-anno-page'); if (!document.getElementById('tc-ri-style')) riStyle(); ensureAnnotationToolbar(ta); const annoRow = (ta._tcAnnoMounted || ta).closest('.row'), clRow = document.querySelector('input[name="edit-annotation.changelog"]')?.closest('.row'); if (annoRow && clRow && clRow !== annoRow && annoRow.previousElementSibling !== clRow) annoRow.parentNode.insertBefore(clRow, annoRow); } else { unmountAnnotation(ta); document.body.classList.remove('tc-anno-page'); } // hide everything below the editor — the Edit note (the Change note already serves that role), the "Make all // edits votable" row, the native Preview button, and MB's formatting guide — keeping only "Enter edit". const annoRowEl = (ta._tcAnnoMounted || ta).closest('.row'), els = new Set(); const fh = [...document.querySelectorAll('#content h3')].find(h => /annotation formatting/i.test(h.textContent || '')); if (fh) { els.add(fh); for (let n = fh.nextElementSibling; n; n = n.nextElementSibling) els.add(n); } // the guide is the h3 + everything after it (NOT its parent, which is #content!) [...document.querySelectorAll('#content h2')].forEach(h => { if (/^\s*edit note\s*$/i.test((h.textContent || '').trim())) { els.add(h); let n = h.nextElementSibling; while (n && n.tagName === 'P') { els.add(n); n = n.nextElementSibling; } } }); if (form && annoRowEl) { const kids = [...form.children], ai = kids.indexOf(annoRowEl); kids.forEach((ch, i) => { if (i > ai) els.add(ch); }); } // everything below the editor, incl. the native buttons els.forEach(el => { el.style.display = hide ? 'none' : ''; }); // our own "Enter edit" (the native one is hidden) — placed to the right of the Changelog input; just clicks // the native submit so MB's flow runs const clInput = document.querySelector('input[name="edit-annotation.changelog"]'), clRow = clInput?.closest('.row'); let sub = document.getElementById('tc-anno-submit'); if (hide && !sub && clInput) { sub = document.createElement('button'); sub.id = 'tc-anno-submit'; sub.type = 'button'; sub.textContent = '✓ Enter edit'; sub.onclick = () => { const b = form && [...form.querySelectorAll('button, input[type=submit]')].find(x => x.id !== 'tc-anno-submit' && /enter edit/i.test((x.textContent || x.value || '').trim())); if (b) b.click(); }; // the NATIVE submit (not ourselves) clInput.after(sub); } if (clRow) clRow.classList.toggle('tc-cl-row', hide); if (sub) sub.style.display = hide ? '' : 'none'; document.getElementById('tc-anno-fouc')?.remove(); // reveal the (now transformed) form — no native flash // make the editor fill the remaining viewport height (down to just above the footer) const w = ta._tcAnnoMounted; if (hide && w) { if (!w._tcFill) { w._tcFill = () => { if (!w.isConnected || w.classList.contains('tc-anno-max')) { w.style.height = ''; return; } w.style.height = Math.max(300, window.innerHeight - w.getBoundingClientRect().top - 18) + 'px'; }; window.addEventListener('resize', w._tcFill); } requestAnimationFrame(w._tcFill); } else if (w && w._tcFill) w.style.height = ''; } // MB's contextual guidance box(es) — anything outside #information that's just the style-guidelines help // (the in-panel ones are hidden by CSS via #information .bubble/.guidance) function nativeHelpBubbles() { const out = new Set(); const isHelp = e => !e.querySelector('input,button,select,textarea'); // a functional editor bubble (URL cleanup, add/edit link) has controls — never hide it document.querySelectorAll('#release-editor .bubble, #release-editor .guidance, #release-editor .guidance-popover, #page .bubble').forEach(e => { if (isHelp(e)) out.add(e); }); [...document.querySelectorAll('#page div')].forEach(e => { if (e.offsetParent === null || document.getElementById('information')?.contains(e)) return; if (e.querySelector('a[href*="style"]') && (e.textContent || '').length < 400 && !e.querySelector('input,button,select,textarea,table,fieldset,h2')) out.add(e); }); return [...out]; } // #143: the help column is hidden, but MB keeps each field's native bubble populated — for the // entity fields (release group / label / artist) that bubble holds "You selected ", the // clickable link to the chosen entity. MB sets the focused field's bubble to inline display:block // even while the column is hidden, so on focus we clone that selection message into a compact, // on-theme popover beside the field. Generic style-guide bubbles (no entity link) stay hidden. let _riHelpWired = false; function wireHelpPopover() { if (_riHelpWired) return; _riHelpWired = true; let pop = null, hideT = null; const ensurePop = () => { if (pop && pop.isConnected) return pop; pop = document.createElement('div'); pop.id = 'tc-ri-help'; pop.addEventListener('mouseenter', () => clearTimeout(hideT)); // keep open so the link is clickable pop.addEventListener('mouseleave', hide); document.body.appendChild(pop); return pop; }; function hide() { clearTimeout(hideT); hideT = setTimeout(() => { if (pop) pop.classList.remove('on'); }, 160); } const showFor = (field) => { const doc = document.querySelector('#information > div.documentation'); if (!doc) { hide(); return; } // the focused field's bubble (MB flags it display:block) — only if it carries a selection link const bub = [...doc.querySelectorAll('.bubble')].find(b => /display:\s*block/.test(b.getAttribute('style') || '') && b.querySelector('a[href^="/release-group/"],a[href^="/label/"],a[href^="/artist/"]')); if (!bub) { hide(); return; } const p = ensurePop(); p.innerHTML = bub.innerHTML; // the rendered "You selected …" message (knockout comment nodes render as nothing) p.querySelectorAll('a').forEach(a => { a.target = '_blank'; a.rel = 'noopener'; }); const r = field.getBoundingClientRect(); const w = Math.min(360, window.innerWidth - 16); // prefer to the right of the field (like MB's native bubble) so it never covers the field's own // autocomplete dropdown; drop below, left-aligned, when there isn't room on the right let left = r.right + 12, top = r.top; if (left + w > window.innerWidth - 8) { left = Math.min(r.left, window.innerWidth - w - 8); top = r.bottom + 6; } p.style.left = Math.round(Math.max(8, left)) + 'px'; p.style.top = Math.round(top) + 'px'; clearTimeout(hideT); p.classList.add('on'); }; document.addEventListener('focusin', e => { if (!document.body.classList.contains('tc-ri-on')) return; const info = document.getElementById('information'); if (!info || !info.contains(e.target)) return; const field = e.target.closest('input,select,textarea'); if (!field) return; if (field.closest('#tc-anno-wrap')) { hide(); return; } // the annotation editor isn't an entity field — no "You selected …" bubble setTimeout(() => { if (document.activeElement === field) showFor(field); }, 30); // let MB pick the bubble first }); document.addEventListener('focusout', e => { const info = document.getElementById('information'); if (info && info.contains(e.target)) hide(); }); } // clicking the favicon edits the URL (edit1); clicking the type chip edits the relationship type (edit2). // Both proxy to MB's own (hover-hidden) pencil buttons so the native editor bubble does the actual work. let _riClicksWired = false; function wireLinkClicks() { if (_riClicksWired) return; _riClicksWired = true; // right-click the favicon → edit URL; right-click a type → edit type. Both proxy to MB's own pencil button. document.addEventListener('contextmenu', e => { if (!document.body.classList.contains('tc-ri-on')) return; const ext = document.getElementById('external-links-editor'); if (!ext || !ext.contains(e.target)) return; const type = e.target.closest('.relationship-name, .relationship-content, select.link-type'); if (type) { const btn = type.closest('tr.relationship-item')?.querySelector('button.edit-item'); if (btn) { e.preventDefault(); btn.click(); } return; } const linkRow = e.target.closest('tr.external-link-item'); if (linkRow && e.target.closest('td:first-child')) { // the favicon cell const btn = linkRow.querySelector('button.edit-item'); if (btn) { e.preventDefault(); btn.click(); } } }); } // Tell the user the favicon + type are right-click-editable (the affordance isn't obvious). // Re-applied each tick via applyReleaseInfo, so React re-renders that drop the title get it back. function annotateLinkEditHints() { const ext = document.getElementById('external-links-editor'); if (!ext) return; const URL_HINT = 'Right-click to edit the URL', TYPE_HINT = 'Right-click to edit the relationship type'; ext.querySelectorAll('tr.external-link-item > td:first-child').forEach(td => { if (td.title !== URL_HINT) td.title = URL_HINT; }); ext.querySelectorAll('tr.relationship-item .relationship-name, tr.relationship-item select.link-type').forEach(el => { if (el.title !== TYPE_HINT) el.title = TYPE_HINT; }); } // MB indents hierarchical link-type options with leading spaces (" purchase for download"); the Hide MB native buttons (Add / Reorder / Import…)` + `` + `` + `
      ` + `
      ` + `` + `` + ``; document.body.appendChild(panel); panel.querySelector('.as-setup-hidefoot').onchange = e => { SETTINGS.hideMbFooter = e.target.checked; save(); applyHideFooter(); }; panel.querySelector('.as-setup-autotype').onchange = e => { SETTINGS.autoType = e.target.checked; save(); }; panel.querySelector('.as-setup-autocomment').onchange = e => { SETTINGS.autoComment = e.target.checked; save(); }; panel.querySelector('.as-setup-autofront').onchange = e => { SETTINGS.autoFront = e.target.checked; save(); }; panel.querySelector('.as-setup-autofront-mode').onchange = e => { SETTINGS.autoFrontMode = e.target.value; save(); }; panel.querySelector('.as-setup-clearsel').onchange = e => { SETTINGS.clearSelAfterOp = e.target.checked; save(); }; panel.querySelector('.as-setup-followpan').onchange = e => { SETTINGS.followPan = e.target.checked; save(); const img = document.querySelector('.as-lb-img'); if (img) applyZoom(img); }; const off = e => { if (!panel.contains(e.target) && e.target.id !== 'as-setup-btn') { panel.remove(); document.removeEventListener('mousedown', off); } }; panel.querySelector('.as-setup-logbtn').onclick = () => { panel.remove(); document.removeEventListener('mousedown', off); openLog(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // #283 remember the log window across sessions: open?/minimized?/position const LOGWIN_KEY = 'artstation:logwin'; const loadLogWin = () => { try { return JSON.parse(localStorage.getItem(LOGWIN_KEY) || '{}'); } catch (e) { return {}; } }; const saveLogWin = (patch) => { try { localStorage.setItem(LOGWIN_KEY, JSON.stringify(Object.assign(loadLogWin(), patch))); } catch (e) {} }; // #283 the Log button opens this popup: the full session log + a Copy control. function openLog() { document.getElementById('as-logpop')?.remove(); saveLogWin({ open: true }); const st = loadLogWin(); const pop = document.createElement('div'); pop.id = 'as-logpop'; pop.innerHTML = `
      Activity log ` + `` + `` + `
      ` + `
      `; document.body.appendChild(pop); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } pop._restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; const renderList = () => { const list = pop.querySelector('.as-log-list'); list.innerHTML = LOG.length ? LOG.map(e => `
      ${_ts(e.t)}${_logLinkify(e.msg)}
      `).join('') : '
      No activity yet.
      '; const c = logCounts(); pop.querySelector('.as-log-badge').textContent = `(${LOG.length})` + (c.warn || c.error ? ` · ${c.warn}⚠ ${c.error}✖` : ''); list.scrollTop = list.scrollHeight; }; renderList(); _logListeners.add(renderList); const onKey = e => { if (e.key === 'Escape') close(); }; const close = () => { saveLogWin({ open: false }); _logListeners.delete(renderList); pop.remove(); document.removeEventListener('keydown', onKey); }; pop.querySelector('.as-logpop-copy').onclick = () => copyLog(pop.querySelector('.as-logpop-copy')); const minBtn = pop.querySelector('.as-logpop-min'); const setMin = (m) => { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to bottom else if (pop._restore) { Object.assign(pop.style, pop._restore); } }; minBtn.onclick = () => { const m = pop.classList.toggle('min'); setMin(m); saveLogWin({ min: m }); }; if (st.min) { pop.classList.add('min'); setMin(true); } // restore minimized state pop.querySelector('.as-logpop-x').onclick = close; // floating, non-modal window — draggable by its header pop.querySelector('.as-logpop-h').addEventListener('mousedown', (e) => { if (e.target.closest('button')) return; e.preventDefault(); const r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; const ox = e.clientX - r.left, oy = e.clientY - r.top; const mv = ev => { pop.style.left = Math.max(0, Math.min(innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(innerHeight - 36, ev.clientY - oy)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { pop._restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; saveLogWin({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); document.addEventListener('keydown', onKey); } // at big tile sizes the selection outline alone is plenty obvious, so drop the // per-card ✓ badge — keeps large artwork uncluttered. #234 function applyZoomClass() { root.classList.toggle('as-zoomed', SETTINGS.tile >= 280); } function render() { mount(); const y = window.scrollY; // keep the viewport put — rebuilding innerHTML must not jump the page const n = opsCount(); const groups = grouped(); // #238 Detailed view: a flat list, image left + all type checkboxes & the full // comment beside it (read long comments / see every type without the popover). const body = SETTINGS.detailed ? `
      ${MODEL.filter(it => !it._del).slice().sort(sortFn).map(detailRow).join('')}
      ` : SETTINGS.group ? groups.map(g => groupRow(g.type, g.items)).join('') // compact: label column + cards beside it : groups.map(g => section(g.type, g.items)).join(''); root.innerHTML = iaNoticeHtml() + bar(n) + commentPresets() + dropZone() + newSection() + body + deletedSection(); root.classList.toggle('as-darkened', !!_iaDark); // #368 disable editing while a darkened notice is up wire(); hydrateImgs(); // re-attach cached for new/pending covers so they don't reload applyOriginal(); // keep the native/script view state across re-renders applyZoomClass(); fitTypePills(); // show as many types as the pill width allows fitFooters(); // hide a comment that can't fit even a few chars (no ugly sliver) fitToolbar(); // icon-only buttons if the toolbar would otherwise wrap if (window.scrollY !== y) window.scrollTo(0, y); } // #234: a grid card's type pill shows as many of its types as fit on one line; // a trailing "+" appears only when some types are hidden for lack of space. function fitTypePills() { root.querySelectorAll('.as-foot-type .as-type').forEach(pill => { if (pill.classList.contains('as-type-add')) return; // untyped placeholder const it = byId(cardId(pill)); if (!it || !it.types.length) return; const types = it.types; pill.textContent = types.join(', '); let n = types.length; while (n > 1 && pill.scrollWidth > pill.clientWidth) { n--; pill.textContent = types.slice(0, n).join(', ') + ' +'; } }); } // the comment shares the footer row with the dimensions; on a narrow card the // dimensions win and the comment can be squeezed to a 1-2px sliver of a glyph. // Hide it entirely below a readable width rather than show that sliver. function fitFooters() { root.querySelectorAll('.as-card .as-foot-cmt').forEach(cmt => { cmt.classList.remove('as-cmt-collapsed'); const txt = cmt.querySelector('.as-cmt-text'); if (!txt) return; // empty comment (hover pencil) // hide ONLY a comment clipped to an unreadable sliver — a SHORT comment that fully // fits (e.g. "A") keeps a narrow column but must still show (and stay clickable). if (txt.scrollWidth > txt.clientWidth + 1 && cmt.clientWidth < 24) cmt.classList.add('as-cmt-collapsed'); }); } // shared autocomplete of the comments already used on this release (#238 presets) function commentPresets() { const seen = [...new Set(MODEL.map(it => (it.comment || '').trim()).filter(Boolean))]; return `${seen.map(c => ``).join('')}`; } function grouped() { if (!SETTINGS.group) { // Position view (committed order): new uploads sit INLINE, positioned among covers const items = MODEL.filter(it => !it._del).slice().sort(sortFn); return [{ type: null, items }]; } // group mode is view-only; new uploads get their own section on top (see newSection) let items = MODEL.filter(it => !it._del && !it._new); // group by primary type; untyped → NO_TYPE; order groups by TYPE_ORDER then alpha const map = new Map(); for (const it of items) { const t = (it.types[0] || NO_TYPE); if (!map.has(t)) map.set(t, []); map.get(t).push(it); } const keys = [...map.keys()].sort((a, b) => { const ia = TYPE_ORDER.indexOf(a), ib = TYPE_ORDER.indexOf(b); if (a === NO_TYPE) return 1; if (b === NO_TYPE) return -1; return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b); }); for (const k of keys) map.get(k).sort(sortFn); return keys.map(k => ({ type: k, items: map.get(k) })); } const typeRank = t => { const i = TYPE_ORDER.indexOf(t); return i < 0 ? 99 : i; }; function sortFn(a, b) { // the still-sourcing placeholder (no image/dims yet) always leads so its progress // shows; everything else — INCLUDING real new covers — takes part in the chosen sort. if (!!a._sourcing !== !!b._sourcing) return a._sourcing ? -1 : 1; if (SETTINGS.sort === 'newest') { // staged new covers ARE the newest → they lead (insertion order); existing by CAA id desc if (!!a._new !== !!b._new) return a._new ? -1 : 1; if (a._new) return a.order - b.order; return b.id - a.id; } if (SETTINGS.sort === 'bytype') return typeRank(a.types[0] || '') - typeRank(b.types[0] || '') || a.order - b.order; if (SETTINGS.sort === 'dim') return (b.w * b.h) - (a.w * a.h) || a.order - b.order; return a.order - b.order; // position (committed order) — new covers have low order, so still lead } const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function bar(n) { return `
      ${IS_EVENT ? '' : ``} Size ${!canReorder() ? '' : ''} ${selBox()}
      `; } const commitInner = n => `Enter edit${n ? ` (${n})` : ''}`; // #234: the selection cluster lives in the center of the main toolbar (the old // bottom bulk bar is gone). syncSel() rebuilds just this span in place so // right-click paint-select never reflows the grid. function selBox() { const sel = MODEL.filter(it => it._sel && !it._del); return `${sel.length ? `${sel.length} selected` : 'none selected'} ${sel.length ? ` ` : ''}`; } function section(type, items) { const label = type === null ? ('All ' + ITEMS) : type; return `

      ${esc(label)}

      ${items.length}
      ${items.map(card).join('')}
      `; } // compact group: type label in a left column, cards flow beside it (no full-width waste) function groupRow(type, items) { const label = type === NO_TYPE ? 'No type' : type; return `
      ${esc(label)}${items.length}
      ${items.map(card).join('')}
      `; } function dropZone() { if (!_dropZone) return ''; return `
      ⬇ Drop ${ENT.noun} files or a folder hereor click to browse · Shift-click for a folder · new ${ITEMS} go first
      `; } function newSection() { if (!SETTINGS.group) return ''; // Position view shows new uploads inline, positioned among covers // group view excludes _new covers from the type groups, so the New uploads section // is the ONLY place they show — include in-progress sourcing placeholders too, or // a URL/ECAU/MH import would add nothing visible while it works. const news = MODEL.filter(it => it._new && !it._del).sort((a, b) => a.order - b.order); if (!news.length) return ''; return `

      New uploads

      ${news.length}
      ${news.map(card).join('')}
      `; } function deletedSection() { const dels = MODEL.filter(it => it._del); if (!dels.length) return ''; const body = SETTINGS.detailed // match the current view — detail rows, not grid cards ? `
      ${dels.map(detailRow).join('')}
      ` : `
      ${dels.map(card).join('')}
      `; return `

      Marked for removal

      ${dels.length}
      ${body}`; } // Stable CAA thumbnails are HTTP-cached, so recreating their on each // render is cheap and flicker-free. NEW (blob) and PENDING (no CAA thumb yet → // the thumb 404s and we fall back to the full original) covers, though, visibly // RELOAD on every render — type change, resize, anything. So we keep ONE live // per such cover in _imgCache and re-attach the already-decoded node into // a host slot after each render instead of building a fresh one. (covers "reload // in place" — #235) const _imgCache = new Map(); function thumbImg(it, size) { if (it._new || it._pending) return ``; return ``; } function hydrateImgs() { root.querySelectorAll('.as-imghost[data-host]').forEach(host => { const id = host.dataset.host, size = +host.dataset.size, it = byId(id); if (!it) return; let img = _imgCache.get(String(id)); if (!img) { // first sighting — build + load it once img = new Image(); img.loading = 'lazy'; img.draggable = false; img.alt = ''; img.onerror = () => { // pending thumb not ready → show the original if (it._new) { img.closest('.as-thumb, .as-dthumb')?.classList.add('na', 'as-na-new'); return; } // #250 a staged blob that won't decode — no CAA fallback exists const orig = !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else img.closest('.as-thumb, .as-dthumb')?.classList.add('na'); }; img.src = it._new ? it._file : thumb(it.id, size); _imgCache.set(String(id), img); } host.replaceWith(img); // re-attach the cached (decoded) node — no reload }); } // #249 a small favicon chip in the cover's bottom-left corner naming where a // newly-sourced image came from (ECAU provider / MH Covers), shown until commit. function provBadge(it) { if (!(it._new && it._provIcon)) return ''; const tip = `Sourced from ${it._provider || 'provider'}` + (it._provUrl ? `\n${it._provUrl}` : ''); // #249 URL on a second line return ``; } // #248 (vzell) tooltip for a locally-uploaded cover — its original file name. const uploadTip = it => (it._new && it._uploadName) ? ` title="${esc(it._uploadName)}"` : ''; function card(it) { if (it._sourcing) return `
      ` + `
      ${esc(it._srcLabel || 'Sourcing…')}
      `; return `
      ${thumbImg(it, SETTINGS.tile > 260 ? 500 : 250)} ${it._new ? 'NEW' : ''} ${it._pdf ? 'PDF' : ''} ${provBadge(it)} ${it._del ? '' : ''}
      ${foot(it)}
      `; } // #234: footer below the image (mockup-driven). Row 1 = the comment on the // left + "dimensions · size" on the right — sharing one row means an empty // comment costs no extra height. Row 2 = the type as a centered pill on a // divider line at the card's bottom (first type only, "+" when there are // more). Empty comment → a hover-only ✎; untyped → a faint + pill. function foot(it) { const firstType = it.types[0] || ''; // seed with the full list; fitTypePills() trims to what fits and adds "+" if needed const typePill = firstType ? `${esc(it.types.join(', '))}` : `+ type`; const typeRow = `
      ${typePill}
      `; const dim = `${dimHtml(it)}`; if (it._del) return `
      ${dim}
      ${typeRow}
      `; const cmt = it._editcmt ? `` : (it.comment ? `${esc(it.comment)}` : ``); // reverse-image search this cover — flat magnifier, hover-revealed, in the comment // row. Shown for any cover with a public URL: a published cover, or a new cover // sourced from a URL (a purely local dropped file has no URL to search by). const search = searchUrlFor(it) ? `` : ''; return `
      ${cmt}${search}${dim}
      ${typeRow}
      `; } // #238 Detailed view row: image + id on the left, all type checkboxes and the // full comment field beside it. No per-row toolbar actions (selection / delete // live on the main toolbar). function detailRow(it) { if (it._sourcing) return `
      ${esc(it._srcLabel || 'Sourcing…')}
      `; if (it._del) return `
      ${it._new ? 'NEW' : ''}${thumbImg(it, 250)}${it._pdf ? 'PDF' : ''}
      ${esc(dimText(it))}
      ${it._new ? '' : `
      #${esc(it.id)}
      `}
      ${esc(it.types.join(', ') || 'no type')}${it.comment ? ` — “${esc(it.comment)}”` : ''}
      `; const types = ALL_TYPES.map(t => ``).join(''); return `
      ${it._new ? 'NEW' : ''}${thumbImg(it, 250)}${provBadge(it)}${it._pdf ? 'PDF' : ''}
      ${esc(dimText(it))}
      ${it._new ? '' : `
      #${esc(it.id)}
      `}
      Types
      ${types}
      `; } // ── interaction ─────────────────────────────────────────────────────────────── function byId(id) { return MODEL.find(it => String(it.id) === String(id)); } function cardId(el) { const c = el.closest('.as-card, .as-drow'); return c ? c.dataset.id : null; } // Wire the comment controls. #234 split the footer (pencil on row 1, comment // on row 2 which only exists when there's a comment), so entering/leaving edit // re-renders — render() keeps the viewport put, so the page still doesn't jump. // focus a comment input with the caret at the END — re-rendering then plain .focus() // drops the cursor at position 0, which is jarring when editing an existing comment. const focusCmtEnd = inp => { if (!inp) return; inp.focus(); const n = inp.value.length; try { inp.setSelectionRange(n, n); } catch (e) {} }; function wireComments() { root.querySelectorAll('.as-fsearch').forEach(b => b.onclick = e => { e.stopPropagation(); openImageSearchPop(b, byId(cardId(b))); }); root.querySelectorAll('.as-pencil, .as-cmt-text').forEach(el => el.onclick = e => { e.stopPropagation(); const it = byId(cardId(el)); if (!it) return; it._editcmt = true; render(); focusCmtEnd(root.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"] .as-cmt`)); }); root.querySelectorAll('.as-cmt').forEach(inp => { inp.oninput = () => { const it = byId(cardId(inp)); if (it) { it.comment = inp.value; refreshStaged(); } }; inp.onblur = () => { const it = byId(cardId(inp)); if (it) { it._editcmt = false; render(); } }; // Enter saves and jumps to the NEXT card's comment (Escape just bails out). inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); inp.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const it = byId(cardId(inp)); if (!it) return; it.comment = inp.value; it._editcmt = false; inp.onblur = null; // we drive the transition — don't let the stale blur double-render const cards = [...root.querySelectorAll('.as-card:not(.del)')]; const idx = cards.findIndex(c => c.dataset.id === String(it.id)); const nextIt = (idx >= 0 && cards[idx + 1]) ? byId(cards[idx + 1].dataset.id) : null; if (nextIt) nextIt._editcmt = true; refreshStaged(); render(); if (nextIt) focusCmtEnd(root.querySelector(`.as-card[data-id="${CSS.escape(String(nextIt.id))}"] .as-cmt`)); }; }); } // #238 wire the detailed-view rows: thumbnail → lightbox, inline type checkboxes, // and the comment field — all editing the model in place (no re-render, no jump). function wireDetail() { root.querySelectorAll('.as-drow').forEach(row => { const it = byId(row.dataset.id); if (!it) return; const th = row.querySelector('.as-dthumb'); if (th) { th.onclick = e => { if (e.target.closest('button')) return; if (it._pdf) window.open(it._img, '_blank', 'noopener'); else openLightbox(it.id); }; const img = th.querySelector(':scope > img'); // #250 gallery img only — never the .as-prov favicon (see wire()) if (img) { img.onerror = () => { const orig = !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else th.classList.add('na'); }; if (img.complete && !img.naturalWidth && img.getAttribute('src')) img.onerror(); } } row.querySelectorAll('.as-dtypes input').forEach(cb => cb.onchange = () => { it.types = ALL_TYPES.filter(t => row.querySelector(`.as-dtypes input[value="${CSS.escape(t)}"]`).checked); refreshStaged(); }); const cmt = row.querySelector('.as-dcmt'); if (cmt) { cmt.oninput = () => { it.comment = cmt.value; refreshStaged(); }; // Enter jumps to the NEXT row's comment (Escape bails) — matches the grid. // Detailed view edits in place, so just move focus (no re-render). cmt.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); cmt.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const rows = [...root.querySelectorAll('.as-drow:not(.del)')]; const idx = rows.findIndex(r => r.dataset.id === String(it.id)); const nc = (idx >= 0 && rows[idx + 1]) ? rows[idx + 1].querySelector('.as-dcmt') : null; if (nc) { nc.focus(); nc.select(); } else cmt.blur(); }; } // selection: the checkbox is the certain indicator; right-click also paints const sel = row.querySelector('.as-dsel'); if (sel) sel.onchange = () => { it._sel = sel.checked; row.classList.toggle('sel', it._sel); syncSel(); }; row.onmousedown = e => { if (e.button !== 2) return; e.preventDefault(); _paint = { value: !it._sel, cards: [] }; paintCard(row); }; }); } // bump the thumbnail size by one notch (dir +1 bigger / -1 smaller), update the live // CSS var + slider, and persist/re-fit once changes settle. Shared by the size slider's // wheel and the right-click+wheel gallery shortcut (#259). let _szT = null; function resizeTile(dir) { SETTINGS.tile = Math.max(120, Math.min(340, SETTINGS.tile + (dir > 0 ? 25 : -25))); // #259 bigger step → less scrolling const sizeEl = root.querySelector('.as-size'); if (sizeEl) sizeEl.value = SETTINGS.tile; document.documentElement.style.setProperty('--as-tile', SETTINGS.tile + 'px'); applyZoomClass(); fitTypePills(); fitFooters(); clearTimeout(_szT); _szT = setTimeout(() => { save(); render(); }, 250); // persist + re-fit once scrolling settles } function wire() { const sizeEl = root.querySelector('.as-size'); sizeEl.oninput = e => { SETTINGS.tile = +e.target.value; document.documentElement.style.setProperty('--as-tile', SETTINGS.tile + 'px'); applyZoomClass(); fitTypePills(); }; sizeEl.onchange = () => { save(); render(); }; // scroll the wheel over the slider to resize (no need to drag it) sizeEl.onwheel = e => { e.preventDefault(); e.stopPropagation(); resizeTile(e.deltaY < 0 ? 1 : -1); }; // stopProp: don't also trigger the RMB+wheel root handler (#259) const view = root.querySelector('.as-view'); if (view) view.onclick = e => { e.stopPropagation(); openViewPop(view); }; const dw = root.querySelector('.as-dragwarn'); if (dw) dw.onclick = () => { SETTINGS.detailed = false; SETTINGS.group = false; SETTINGS.sort = 'type'; save(); render(); }; root.querySelector('.as-add').onclick = toggleDropZone; const mh = root.querySelector('.as-mh'); if (mh) mh.onclick = openMHCovers; const src = root.querySelector('.as-src'); if (src) { src.onclick = e => { e.stopPropagation(); openSourcePop(src); }; refreshSrcCount(); // show how many import sources are available on the button: "URL (3)" } const mhIc = root.querySelector('.as-mh-ic'); if (mhIc) mhIc.onerror = () => mhIc.replaceWith(document.createTextNode('🔍')); root.querySelectorAll('.as-prov img').forEach(img => img.onerror = () => { const s = img.closest('.as-prov'); if (s) s.style.display = 'none'; }); // #249 hide a missing provider favicon const commit = root.querySelector('.as-commit'); if (commit && !commit.disabled) commit.onclick = enterEdit; root.querySelectorAll('.as-undo').forEach(b => b.onclick = e => { e.stopPropagation(); const it = byId(cardId(e.target)); if (it) { it._del = false; render(); } }); wireComments(); wireDetail(); const dz = root.querySelector('.as-dropzone'); if (dz) { dz.onclick = e => (e.shiftKey ? pickFolder : pickFiles)(); // #359: Shift-click browses a folder dz.ondragover = e => { e.preventDefault(); dz.classList.add('over'); }; dz.ondragleave = () => dz.classList.remove('over'); dz.ondrop = async e => { e.preventDefault(); dz.classList.remove('over'); await addFromDrop(e.dataTransfer); }; } // type pill → popover root.querySelectorAll('.as-type').forEach(ch => ch.onclick = e => { e.stopPropagation(); openTypePop(ch); }); // click the THUMB (not just the , which is display:none on a not-yet-propagated // cover) → lightbox; PDFs open in a new tab. right-click card → toggle selection root.querySelectorAll('.as-thumb').forEach(th => { th.onclick = e => { if (e.target.closest('button') || _lpSwallow) return; const it = byId(cardId(e.target)); if (!it) return; if (it._pdf) window.open(it._img, '_blank', 'noopener'); else openLightbox(it.id); }; // #250 (vzell) only wire the GALLERY image, which is a direct child of the thumb. // A new cover's gallery isn't here yet (it's an .as-imghost placeholder until // hydrateImgs runs), and the only present is the provider-badge favicon nested // in .as-prov — a plain querySelector('img') grabbed THAT, so a 404 favicon fired // this onerror and added .na, whose CSS hid the real JPEG. `:scope > img` never // matches the badge; new covers are left to hydrateImgs, which owns their onerror. const img = th.querySelector(':scope > img'); if (!img) return; // A freshly-added cover has its original uploaded but the CAA thumbnails // (250/500) aren't generated yet — so the thumb URL 404s and native MB // shows a placeholder. We can do better: fall back to the full original // (the same URL the lightbox uses), so the image shows in the gallery. // Only show the "not on CAA" placeholder if the original 404s too. PDFs // can't render as , so they keep the placeholder. img.onerror = () => { const it = byId(cardId(img)); const orig = it && !it._pdf ? (it._img || imgUrl(it.id)) : null; if (orig && img.getAttribute('src') !== orig) img.src = orig; else th.classList.add('na'); }; if (img.complete && !img.naturalWidth && img.getAttribute('src')) img.onerror(); }); // right-button paint-select IN PLACE — no render(), so the page never jumps. // down toggles the start card; holding right + moving paints the same state on hovered cards. root.querySelectorAll('.as-card').forEach(c => { c.onmousedown = e => { if (e.button !== 2 || c.classList.contains('del')) return; e.preventDefault(); const it = byId(c.dataset.id); if (!it) return; _paint = { value: !it._sel, cards: [] }; paintCard(c); }; wireCardTouch(c); // #251 long-press to select, drag-handle to reorder (mobile) }); wireSel(); wireDrag(); markCursor(); } function wireSel() { const q = s => root.querySelector(s); q('.as-selall') && (q('.as-selall').onclick = () => { selectable().forEach(it => it._sel = true); root.querySelectorAll('.as-card:not(.del), .as-drow').forEach(c => c.classList.add('sel')); root.querySelectorAll('.as-dsel').forEach(cb => cb.checked = true); syncSel(); }); q('.as-selclr') && (q('.as-selclr').onclick = () => clearSel()); q('.as-bk-rm') && (q('.as-bk-rm').onclick = () => { let n = 0; MODEL.forEach(it => { if (it._sel) { it._del = true; it._sel = false; n++; } }); asLog.info(`Batch: marked ${n} ${n === 1 ? ITEM : ITEMS} for removal`); render(); }); q('.as-bk-dl') && (q('.as-bk-dl').onclick = async e => { const sel = MODEL.filter(it => it._sel && !it._del && !it._sourcing); if (!sel.length) return; // include NEW covers (download their local blob) if (sel.length === 1) { dlOne(sel[0]); maybeClearSel(); return; } // single → save the image directly const b = e.currentTarget, lbl = b.querySelector('.as-bt'), old = lbl ? lbl.textContent : ''; b.disabled = true; if (lbl) lbl.style.display = 'inline'; // show progress even in compact mode const prog = (d, t) => { if (lbl) lbl.textContent = `Zipping ${d}/${t}…`; }; prog(0, sel.length); try { await dlZip(sel, prog); } finally { b.disabled = false; if (lbl) { lbl.textContent = old; lbl.style.display = ''; } } // multiple → one .zip (#240) maybeClearSel(); // #277 }); q('.as-bk-type') && (q('.as-bk-type').onclick = e => { e.stopPropagation(); openBulkTypePop(q('.as-bk-type')); }); q('.as-bk-cmt') && (q('.as-bk-cmt').onclick = e => { e.stopPropagation(); openBulkCommentPop(q('.as-bk-cmt')); }); q('.as-bk-report') && (q('.as-bk-report').onclick = e => { e.stopPropagation(); openReport(); }); } // right-button paint selection (held + move) let _paint = null; function paintCard(c) { if (!c || !_paint || c.classList.contains('del')) return; const it = byId(c.dataset.id); if (!it || it._sel === _paint.value) return; it._sel = _paint.value; c.classList.toggle('sel', it._sel); const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; // keep the row checkbox in sync _paint.cards.push(c); syncSel(); } // #259 the right-click+wheel resize shares the RMB-down with paint-select; if the user // wheels, the gesture was a resize, so undo any cards toggled on the way in. function cancelPaint() { if (!_paint) return; const prev = !_paint.value; for (const c of _paint.cards) { const it = byId(c.dataset.id); if (!it) continue; it._sel = prev; c.classList.toggle('sel', it._sel); const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; } _paint = null; syncSel(); } document.addEventListener('mousemove', e => { if (!_paint || !e.buttons) return; // e.buttons falls to 0 if the button was released off-window const c = e.target.closest && e.target.closest('.as-card, .as-drow'); if (c && root.contains(c)) paintCard(c); }); document.addEventListener('mouseup', e => { _paint = null; if (e.button === 2) _rmb = false; }); // #259 hold the right mouse button and scroll the wheel anywhere in the gallery to set // thumbnail size. RMB is also paint-select, so a wheel cancels the in-flight select. let _rmb = false; root.addEventListener('mousedown', e => { if (e.button === 2) _rmb = true; }); window.addEventListener('blur', () => { _rmb = false; }); root.addEventListener('wheel', e => { if (!_rmb) return; e.preventDefault(); // don't scroll the page while resizing cancelPaint(); // the RMB-down was the start of a resize, not a select resizeTile(e.deltaY < 0 ? 1 : -1); }, { passive: false }); // ── #251 touch support: long-press to select, long-press-then-drag to reorder ─── // A tap opens the viewer; _lpSwallow eats the click synthesised after a long-press // so it doesn't ALSO open the viewer. let _lpSwallow = false; const swallowTap = () => { _lpSwallow = true; setTimeout(() => { _lpSwallow = false; }, 450); }; function toggleSel(c) { const it = byId(c.dataset.id); if (!it || it._del) return; it._sel = !it._sel; c.classList.toggle('sel', it._sel); const cb = c.querySelector('.as-dsel'); if (cb) cb.checked = it._sel; syncSel(); } let _tdrag = null; // active touch reorder: { block, ghost, tgt } function wireCardTouch(c) { if (c.classList.contains('del')) return; let timer = null, start = null, engaged = false, moved = false; c.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; const t = e.touches[0]; start = { x: t.clientX, y: t.clientY }; engaged = false; moved = false; timer = setTimeout(() => { engaged = true; try { navigator.vibrate && navigator.vibrate(15); } catch (x) {} const it = byId(c.dataset.id); if (canReorder() && it && !it._del) startTouchDrag(c, it, start); // pick up to reorder else toggleSel(c); // otherwise select }, 420); }, { passive: true }); c.addEventListener('touchmove', e => { if (!start) return; const t = e.touches[0], far = Math.hypot(t.clientX - start.x, t.clientY - start.y) > 12; if (!engaged) { if (far) { clearTimeout(timer); timer = null; start = null; } return; } // pre-engage move = page scroll e.preventDefault(); moved = true; if (_tdrag) moveTouchDrag(t); }, { passive: false }); c.addEventListener('touchend', e => { clearTimeout(timer); timer = null; if (_tdrag) { e.preventDefault(); const dropped = endTouchDrag(); if (!dropped && !moved) toggleSel(c); swallowTap(); start = null; return; } if (engaged) { e.preventDefault(); swallowTap(); } // long-press select already done start = null; }, { passive: false }); c.addEventListener('touchcancel', () => { clearTimeout(timer); timer = null; if (_tdrag) endTouchDrag(); start = null; }); } function startTouchDrag(c, it, start) { _drag = it; const block = dragBlock(); block.forEach(g => cardEl(g)?.classList.add('as-dragging')); const r = c.getBoundingClientRect(); const ghost = c.cloneNode(true); ghost.className = 'as-card as-ghost'; ghost.style.cssText = `position:fixed;left:0;top:0;width:${r.width}px;z-index:100050;pointer-events:none;opacity:.92;transform:translate(${r.left}px,${r.top}px) scale(1.04);box-shadow:0 10px 30px rgba(0,0,0,.4)`; document.body.appendChild(ghost); _tdrag = { block, ghost, off: { x: start.x - r.left, y: start.y - r.top }, tgt: null }; } function moveTouchDrag(t) { const g = _tdrag.ghost; g.style.transform = `translate(${t.clientX - _tdrag.off.x}px,${t.clientY - _tdrag.off.y}px) scale(1.04)`; g.style.visibility = 'hidden'; const under = document.elementFromPoint(t.clientX, t.clientY); g.style.visibility = ''; const card = under && under.closest && under.closest('.as-card[draggable="true"]'); root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); const tgt = card && byId(card.dataset.id); _tdrag.tgt = (tgt && !_tdrag.block.includes(tgt)) ? tgt : null; if (_tdrag.tgt) card.classList.add('as-drop'); } function endTouchDrag() { const { block, ghost, tgt } = _tdrag; _tdrag = null; ghost.remove(); root.querySelectorAll('.as-dragging').forEach(c => c.classList.remove('as-dragging')); root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); _drag = null; if (tgt) { reorder(block, tgt); render(); return true; } return false; } window.addEventListener('resize', () => { if (root.isConnected) fitToolbar(); }); // right-click is our selection gesture across the gallery — suppress the native menu there document.addEventListener('contextmenu', e => { if (root.contains(e.target)) e.preventDefault(); }); // refresh just the toolbar's selection cluster in place — no grid reflow, so // right-click paint-select never makes the page jump. function syncSel() { const box = root.querySelector('.as-selbox'); if (box) { box.innerHTML = selBox(); wireSel(); fitToolbar(); } } // drop the whole selection (model flags + on-screen cards/checkboxes + toolbar). function clearSel() { MODEL.forEach(it => it._sel = false); root.querySelectorAll('.as-card.sel, .as-drow.sel').forEach(c => c.classList.remove('sel')); root.querySelectorAll('.as-dsel').forEach(cb => cb.checked = false); syncSel(); } // #277: after a batch op (type / comment / download / report), drop the // selection — on by default, opt-out in the gear panel. const maybeClearSel = () => { if (SETTINGS.clearSelAfterOp) clearSel(); }; function refreshStaged() { const n = opsCount(); const c = root.querySelector('.as-commit'); if (c) { c.innerHTML = commitInner(n); c.disabled = !n; if (!c.disabled) c.onclick = enterEdit; fitToolbar(); } } // #234: when the toolbar's real items + gaps can't fit one row (the flex // spacers would have to collapse and it'd wrap), collapse the labelled buttons // to icon-only — the icons + tooltips carry the meaning. Measured by summing // widths (the flex:1 spacers defeat scrollWidth/offsetTop-based detection). function fitToolbar() { const bar = root.querySelector('.as-bar'); if (!bar) return; bar.classList.remove('as-compact'); // measure at full labels const kids = [...bar.children]; let need = 11 * Math.max(0, kids.length - 1); // inter-item gaps kids.forEach(el => { if (!el.classList.contains('as-sp')) need += el.offsetWidth; }); bar.classList.toggle('as-compact', need > bar.clientWidth - 24); // minus h-padding } // the list of pending MB operations behind "N staged changes" function pendingOps() { const label = it => it.types[0] || (it._new ? 'new image' : ITEM); const ops = []; MODEL.filter(it => it._new && !it._del && !it._sourcing).forEach(it => ops.push(`➕ Add ${label(it)}${it.types.length ? ` — ${it.types.join(', ')}` : ''}${it.comment ? ` “${it.comment}”` : ''}`)); MODEL.filter(it => it._del && !it._new).forEach(it => ops.push(`🗑 Remove ${label(it)}`)); MODEL.filter(it => !it._del && !it._new).forEach(it => { if (it.types.join('|') !== it._origTypes.join('|')) ops.push(`🏷 Set type on ${it._origTypes[0] || ITEM} → ${it.types.join(', ') || '(none)'}`); if (it.comment !== it._origComment) ops.push(`✎ Comment on ${label(it)} → ${it.comment ? `“${it.comment}”` : '(cleared)'}`); }); // reorder = the EXISTING covers' relative order changed. Inserting new covers // shifts indices but is positioned by the add op itself (not a separate reorder). const ex = MODEL.filter(it => !it._del && !it._new); const now = ex.slice().sort((a, b) => a.order - b.order).map(it => it.id).join(','); const orig = ex.slice().sort((a, b) => a._origOrder - b._origOrder).map(it => it.id).join(','); if (now !== orig) ops.push('↕ Reorder ' + ITEMS); return ops; } // the count shown on "Enter edit (N)" = the number of real MB edits we'll submit // (buildPlan merges a cover's type+comment change into one edit), so it matches // the panel's operation list exactly. #234 const opsCount = () => buildPlan().length; // #234: the "View ▾" dropdown — Sort options + Group toggle, moved off the // main toolbar to free its center for the selection controls. function openViewPop(btn) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const pop = document.createElement('div'); pop.className = 'as-pop as-view-pop'; const sorts = [['type', 'Position'], ['bytype', 'Type'], ['dim', 'Dimensions'], ['newest', 'Newest']]; const vmode = SETTINGS.detailed ? 'detailed' : SETTINGS.group ? 'group' : 'grid'; pop.innerHTML = `
      Sort
      ` + sorts.map(([v, l]) => ``).join('') + `
      View
      ` + [['grid', 'Grid', ''], ['detailed', 'Detailed view', '(list + all types & comment)'], ['group', 'Group by type', '(view-only)']] .map(([v, l, note]) => ``).join(''); document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); pop.querySelectorAll('input[name="as-vsort"]').forEach(r => r.onchange = () => { SETTINGS.sort = r.value; save(); render(); }); // #234: the view modes are mutually exclusive — Grid / Detailed / Group. pop.querySelectorAll('input[name="as-vmode"]').forEach(r => r.onchange = () => { SETTINGS.detailed = r.value === 'detailed'; SETTINGS.group = r.value === 'group'; save(); render(); }); const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // position a popover next to an anchor, flipping up / clamping so it stays on-screen function placePop(pop, r) { const ph = pop.offsetHeight, pw = pop.offsetWidth, vh = innerHeight, vw = innerWidth, M = 8; let top = r.bottom + 3; if (top + ph > vh - M && r.top - ph - 3 >= M) top = r.top - ph - 3; // flip above the anchor top = Math.max(M, Math.min(top, vh - ph - M)); let left = Math.max(M, Math.min(r.left, vw - pw - M)); pop.style.top = (top + scrollY) + 'px'; pop.style.left = (left + scrollX) + 'px'; } function openTypePop(chip) { const it = byId(cardId(chip)); if (!it) return; openTypePopFor(it, chip, () => render()); } // shared single-cover type picker — anchored to `anchor`, mutating `it.types`, // calling `onChange` after each toggle. Used by the grid pills AND the lightbox. function openTypePopFor(it, anchor, onChange) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const pop = document.createElement('div'); pop.className = 'as-pop'; pop.innerHTML = `
      ${ALL_TYPES.map(t => ``).join('')}
      `; document.body.appendChild(pop); placePop(pop, anchor.getBoundingClientRect()); const close = () => { pop.remove(); document.removeEventListener('mousedown', off); _popJustClosed = true; setTimeout(() => { _popJustClosed = false; }, 0); }; pop.querySelectorAll('input').forEach(cb => cb.onchange = () => { it.types = ALL_TYPES.filter(t => pop.querySelector(`input[value="${CSS.escape(t)}"]`).checked); onChange && onChange(); }); // right-click a type → set ONLY that type and close (quick single-type set) pop.querySelectorAll('.as-type-grid label').forEach(lab => lab.oncontextmenu = e => { e.preventDefault(); e.stopPropagation(); it.types = [lab.querySelector('input').value]; onChange && onChange(); close(); }); const off = e => { if (!pop.contains(e.target)) close(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); return pop; } let _popJustClosed = false; // bridges the mousedown-dismiss → click gap so a pop dismissal doesn't also close the lightbox let _drag = null; // the block being dragged: the whole selection if the grabbed card is selected, else just it const dragBlock = () => (_drag && _drag._sel) ? MODEL.filter(it => it._sel && !it._del).sort((a, b) => a.order - b.order) : (_drag ? [_drag] : []); function wireDrag() { root.querySelectorAll('.as-card[draggable="true"]').forEach(card => { card.ondragstart = e => { _drag = byId(card.dataset.id); dragBlock().forEach(g => cardEl(g)?.classList.add('as-dragging')); e.dataTransfer.effectAllowed = 'move'; }; card.ondragend = () => { root.querySelectorAll('.as-dragging').forEach(c => c.classList.remove('as-dragging')); _drag = null; root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); }; card.ondragover = e => { const tgt = byId(card.dataset.id); if (!_drag || dragBlock().includes(tgt)) return; // not onto a member of the moving block e.preventDefault(); root.querySelectorAll('.as-drop').forEach(c => c.classList.remove('as-drop')); card.classList.add('as-drop'); }; card.ondrop = e => { e.preventDefault(); const tgt = byId(card.dataset.id); const block = dragBlock(); if (!_drag || !tgt || block.includes(tgt)) return; reorder(block, tgt); render(); }; }); } const cardEl = it => root.querySelector(`.as-card[data-id="${CSS.escape(String(it.id))}"]`); function reorder(block, tgt) { // move the block (one card, or the whole selection) next to tgt, preserving the // block's relative order. Drop on the side you came from: forward → after tgt. const seq = MODEL.filter(it => !it._del).slice().sort((a, b) => a.order - b.order); const set = new Set(block); const fromFirst = Math.min(...block.map(b => seq.indexOf(b))); const forward = fromFirst < seq.indexOf(tgt); const rest = seq.filter(it => !set.has(it)); const to = rest.indexOf(tgt) + (forward ? 1 : 0); rest.splice(to, 0, ...block); rest.forEach((it, i) => it.order = i); } // ── actions ─────────────────────────────────────────────────────────────────── // #244 a download name that round-trips the type back via #243: // " , ." — types lowercased, "/" → "_", // no type → "none". e.g. "09 front,sticker Front cover with the sticker.jpg" function downloadName(it, pos, ext, pad = 2) { const nn = String(pos).padStart(pad, '0'); const types = (it.types && it.types.length) ? it.types.map(t => t.toLowerCase().replace(/[\\/]/g, '_')).join(',') : 'none'; const comment = (it.comment || '').trim().slice(0, 100); const base = (nn + ' ' + types + (comment ? ' ' + comment : '')).replace(/[<>:"|?*]/g, '_').replace(/[\\/]/g, '_').replace(/\s+/g, ' ').trim(); return `${base}.${ext}`; } // download URL + extension for a cover — NEW covers use their local blob, not a CAA URL const dlUrl = it => it._new ? it._file : (it._img || imgUrl(it.id)); function dlExt(it) { if (it._new) { const n = (it._fileObj && it._fileObj.name) || ''; const m = n.match(/\.([a-z0-9]+)$/i); return (m ? m[1] : ((it._fileObj && it._fileObj.type || '').split('/')[1] || 'jpg')).toLowerCase().replace('jpeg', 'jpg'); } return ((it._img || imgUrl(it.id)).match(/\.(jpg|jpeg|png|gif|pdf|webp)(?:$|\?)/i) || [, 'jpg'])[1].toLowerCase(); } async function dlOne(it, size) { const orig = !size || size === 'original' || it._new; // new covers only have their local blob const url = orig ? dlUrl(it) : thumb(it.id, size), ext = dlExt(it); let name = downloadName(it, it.order + 1, ext); if (!orig) name = name.replace(/\.(\w+)$/, ` ${size}.$1`); // note the thumbnail size try { // cross-origin is ignored by browsers — fetch the blob (CAA // sends CORS) and download via a same-origin object URL so it actually saves const blob = await fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }); const obj = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = obj; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(obj), 8000); asLog.ok(`Download: saved ${name}`); } catch (e) { logErr(`Download: ${name} (opened in a tab instead)`, e); window.open(url, '_blank'); } // fallback: just open it } // #240: multiple selected covers → one ZIP. Triggering N separate downloads // from timeouts trips the browser's "downloading multiple files" block (only the // first saves); a single zip download sidesteps it entirely. function crc32(bytes) { let t = crc32._t; if (!t) { t = crc32._t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } } let crc = 0xFFFFFFFF; for (let i = 0; i < bytes.length; i++) crc = (crc >>> 8) ^ t[(crc ^ bytes[i]) & 0xFF]; return (crc ^ 0xFFFFFFFF) >>> 0; } // store-only ZIP record builders (no compression — covers are already JPEG/PNG) const _zdv = (len, fill) => { const a = new Uint8Array(len); fill(new DataView(a.buffer)); return a; }; const zipLocal = (crc, size, nameLen) => _zdv(30, v => { v.setUint32(0, 0x04034b50, true); v.setUint16(4, 20, true); v.setUint32(14, crc, true); v.setUint32(18, size, true); v.setUint32(22, size, true); v.setUint16(26, nameLen, true); }); const zipCentral = (crc, size, nameLen, off) => _zdv(46, v => { v.setUint32(0, 0x02014b50, true); v.setUint16(4, 20, true); v.setUint16(6, 20, true); v.setUint32(16, crc, true); v.setUint32(20, size, true); v.setUint32(24, size, true); v.setUint16(28, nameLen, true); v.setUint32(42, off, true); }); const zipEOCD = (count, cdSize, cdOff) => _zdv(22, v => { v.setUint32(0, 0x06054b50, true); v.setUint16(8, count, true); v.setUint16(10, count, true); v.setUint32(12, cdSize, true); v.setUint32(16, cdOff, true); }); function makeZip(files) { const enc = new TextEncoder(); const parts = [], central = []; let offset = 0; for (const f of files) { const name = enc.encode(f.name), crc = crc32(f.data), size = f.data.length; parts.push(zipLocal(crc, size, name.length), name, f.data); central.push(zipCentral(crc, size, name.length, offset), name); offset += 30 + name.length + size; } const cdSize = central.reduce((s, c) => s + c.length, 0); return new Blob([...parts, ...central, zipEOCD(files.length, cdSize, offset)], { type: 'application/zip' }); } // member names, disambiguating same-type covers (front.jpg, booklet.jpg, booklet-2.jpg …) function zipNames(sel) { const used = new Set(); const pad = Math.max(2, String(Math.max(0, ...sel.map(it => it.order + 1))).length); return sel.map(it => { const url = dlUrl(it), ext = dlExt(it); let name = downloadName(it, it.order + 1, ext, pad); // #244 "NN types comment.ext" const b = name.replace(/\.[^.]+$/, ''); let n = 2; while (used.has(name.toLowerCase())) name = `${b} (${n++}).${ext}`; used.add(name.toLowerCase()); return { url, name, it }; }); } // #274: covers (esp. big ones from coverartarchive.org → archive.org) fail // intermittently — a transient 5xx / network hiccup / slow large transfer. // Without a retry those covers were silently dropped from the archive. Retry // with backoff, and abort a stalled attempt so it retries instead of hanging. async function fetchBytes(url, attempts = 4) { let lastErr; for (let i = 0; i < attempts; i++) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 120000); // 2 min per attempt try { const r = await fetch(url, { cache: 'no-store', signal: ctrl.signal }); clearTimeout(timer); if (!r.ok) throw new Error('HTTP ' + r.status); return new Uint8Array(await r.arrayBuffer()); } catch (e) { clearTimeout(timer); lastErr = e; } // #274: plain fetch() is CORS-bound — a PDF booklet on archive.org carries // no Access-Control-Allow-Origin, so fetch() ALWAYS fails it even though the // browser views it fine. GM_xmlhttpRequest is not CORS-bound; use it as the // fallback (it also recovers transient image failures). try { const blob = await gmFetch(url); if (blob) return new Uint8Array(await blob.arrayBuffer()); } catch (e) { lastErr = e; } if (i < attempts - 1) await new Promise(res => setTimeout(res, 800 * (i + 1))); } throw lastErr; } // #274: warn the user (clearly, and in the archive's own README) when covers // couldn't be downloaded, instead of silently shipping an incomplete zip. function warnDropped(failed, total) { const n = failed.length; const list = failed.slice(0, 4).join(', ') + (n > 4 ? `, +${n - 4} more` : ''); toast(`⚠ ${n}/${total} file${n === 1 ? '' : 's'} failed to download — missing from the archive: ${list}. See README.md inside.`, 14000); } // capture the original's resolution from its bytes (no extra request) so the manifest table has it async function measureBytes(it, data) { if (it.w || it._pdf || !data) return; try { const bmp = await createImageBitmap(new Blob([data])); it.w = bmp.width; it.h = bmp.height; bmp.close && bmp.close(); } catch (e) {} } async function dlZip(sel, onProgress) { const items = zipNames(sel), enc = new TextEncoder(); // unique, sortable archive name: " .zip" — the // export timestamp + file count make every download distinct (no overwrite / same-name reuse). const _p = n => String(n).padStart(2, '0'), _d = new Date(); const zipName = `${MBID} ${_d.getFullYear()}-${_p(_d.getMonth() + 1)}-${_p(_d.getDate())}T${_p(_d.getHours())}-${_p(_d.getMinutes())}-${_p(_d.getSeconds())} ${items.length} ${ITEMS}.zip`; asLog.info(`Download: zipping ${items.length} ${items.length === 1 ? ITEM : ITEMS}`); // #240: stream the zip straight to disk when the browser supports it — the // download starts immediately (first cover written as soon as it arrives) and // the whole archive is never buffered in memory. // Grab the save handle FIRST, while we still hold the user gesture. // showSaveFilePicker needs transient user activation; awaiting loadSizes (an // archive.org fetch) before it lets that activation expire, so the picker threw // SecurityError. We also used to swallow EVERY failure as "cancelled" → the zip // download silently did nothing. Now: pick the file first, and on any non-cancel // failure (gesture lost, blocked by the userscript manager, API unavailable) log // the reason and fall through to the blob download so the zip still saves. let handle = null; // Call showSaveFilePicker on the REAL window (unsafeWindow): on the userscript-sandboxed // `window` proxy some managers (e.g. Tampermonkey) throw TypeError "illegal invocation", // which forced the blob fallback — and Chromium then silently blocks the 2nd+ of those // repeat downloads. The native Save dialog isn't subject to that gate. #282 const _win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; if (_win.showSaveFilePicker) { try { handle = await _win.showSaveFilePicker({ suggestedName: zipName, types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }] }); } catch (e) { if (e && e.name === 'AbortError') return; // genuine user cancel — respect it asLog.warn(`Save dialog unavailable (${(e && e.name) || e}) — saving via direct download instead`); handle = null; // fall through to the blob fallback below } } await loadSizes(); // byte sizes for the manifest; resolutions are captured during the fetch below if (handle) { const w = await handle.createWritable(); const central = []; let offset = 0, done = 0; const failed = []; const writeEntry = async (nameStr, data) => { const name = enc.encode(nameStr), crc = crc32(data); await w.write(zipLocal(crc, data.length, name.length)); await w.write(name); await w.write(data); central.push({ crc, size: data.length, name, offset }); offset += 30 + name.length + data.length; }; for (const o of items) { asLog.debug(`Zip: fetching ${o.name} ← ${o.url}`); let data; try { data = await fetchBytes(o.url); } catch (e) { failed.push(o.name); logErr(`Zip: ${o.name}`, e); onProgress && onProgress(++done, items.length); continue; } // #274: record, don't silently drop await measureBytes(o.it, data); await writeEntry(o.name, data); asLog.info(`Zip + ${o.name} — ${fmtBytes(data.length)}${o.it && o.it.w ? `, ${o.it.w}×${o.it.h}` : ''}${o.it && o.it._pdf ? ', PDF' : ''}`); onProgress && onProgress(++done, items.length); } await writeEntry('README.md', enc.encode(manifestMd(sel, failed))); // manifest last — now has resolutions + any drops asLog.debug('Zip + README.md (manifest)'); let cdSize = 0; for (const c of central) { await w.write(zipCentral(c.crc, c.size, c.name.length, c.offset)); await w.write(c.name); cdSize += 46 + c.name.length; } await w.write(zipEOCD(central.length, cdSize, offset)); await w.close(); asLog.ok(`Download: saved ${zipName} — ${central.length} file${central.length === 1 ? '' : 's'} · ${fmtBytes(offset)} (streamed)${failed.length ? `, ${failed.length} dropped` : ''}`); if (failed.length) warnDropped(failed, items.length); return; } // fallback: fetch with a small concurrency pool (NOT all at once — flooding // the network with many big covers is what made them fail/drop, #274), keep // original order, then one blob download. let done = 0; const out = new Array(items.length).fill(null); const failed = []; let idx = 0; const worker = async () => { while (true) { const i = idx++; if (i >= items.length) break; const o = items[i]; asLog.debug(`Zip: fetching ${o.name} ← ${o.url}`); try { const data = await fetchBytes(o.url); await measureBytes(o.it, data); out[i] = { name: o.name, data }; asLog.info(`Zip + ${o.name} — ${fmtBytes(data.length)}${o.it && o.it.w ? `, ${o.it.w}×${o.it.h}` : ''}${o.it && o.it._pdf ? ', PDF' : ''}`); } catch (e) { failed.push(o.name); logErr(`Zip: ${o.name}`, e); } onProgress && onProgress(++done, items.length); } }; await Promise.all(Array.from({ length: Math.min(4, items.length) }, worker)); const covers = out.filter(Boolean); if (!covers.length) { toast('⚠ Download failed — could not fetch any cover. Try again.', 10000); return; } const entries = [...covers, { name: 'README.md', data: enc.encode(manifestMd(sel, failed)) }]; // manifest last — now has resolutions const total = entries.reduce((s, e) => s + e.data.length, 0); const obj = URL.createObjectURL(makeZip(entries)); const a = document.createElement('a'); a.href = obj; a.download = zipName; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(obj), 8000); asLog.ok(`Download: saved ${zipName} — ${covers.length} file${covers.length === 1 ? '' : 's'} · ${fmtBytes(total)}${failed.length ? `, ${failed.length} dropped` : ''}`); if (failed.length) warnDropped(failed, items.length); } // ── #235 source covers from covers.musichoarders.xyz (the sanctioned MH Covers // integration — same window.open + postMessage protocol the "Ame" script uses; // no internal MH API). A chosen cover is fetched and dropped into the gallery // as a staged NEW cover, so it rides the normal Enter-edit upload flow. ───── const MH_ORIGIN = 'https://covers.musichoarders.xyz'; // cross-origin GET → Blob (covers can be on any provider host → needs GM xhr) function gmFetch(url, onProgress) { return new Promise((resolve, reject) => { const gx = (typeof GM !== 'undefined' && GM.xmlHttpRequest && GM.xmlHttpRequest.bind(GM)) || (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || null; if (!gx) { fetch(url).then(r => r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status))).then(resolve, reject); return; } gx({ method: 'GET', url, responseType: 'blob', timeout: 180000, onprogress: e => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }, onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error('HTTP ' + r.status)), onerror: () => reject(new Error('network error')), ontimeout: () => reject(new Error('timed out')) }); }); } // ── session log (#283) ──────────────────────────────────────────────────── // A troubleshooting log of everything the script does this session — downloads, // zipping, provider/MH-Covers integrations, archive.org sign/upload, MB edits, // reorders, and every warning/error (incl. the ones that used to die silently // in a catch). Reviewable + copy/pastable as a Markdown
      block for a // GitHub issue, mirroring Credit Hoarder. `toast()` feeds it; operations log // explicitly, and verbose diagnostics go in at `debug`. const LOG = []; const _logListeners = new Set(); function asLog(sev, msg) { const text = String(msg == null ? '' : msg).replace(/\s+/g, ' ').trim(); if (!text) return; LOG.push({ t: new Date(), sev, msg: text }); _logListeners.forEach(f => { try { f(); } catch (e) {} }); } asLog.info = m => asLog('info', m); asLog.ok = m => asLog('ok', m); asLog.warn = m => asLog('warn', m); asLog.error = m => asLog('error', m); asLog.debug = m => asLog('debug', m); // standard shape for a caught error: "" const logErr = (ctx, e) => asLog('error', ctx + ' — ' + ((e && e.message) || e || 'unknown error')); const logCounts = () => LOG.reduce((a, e) => { if (e.sev === 'warn') a.warn++; else if (e.sev === 'error') a.error++; return a; }, { warn: 0, error: 0 }); // escape, then turn http(s) URLs into clickable links for the log viewer const _logLinkify = s => esc(s).replace(/(https?:\/\/[^\s<]+)/g, (m) => { const t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; // keep trailing punctuation out of the URL const url = m.slice(0, m.length - t.length); return `${url}${t}`; }); const _ts = d => { const p = (n, w = 2) => String(n).padStart(w, '0'); return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`; }; const fmtBytes = n => (n == null) ? '?' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(2) + ' MB'; // Build the copy/pastable Markdown: a collapsed
      wrapping a fenced log // block (same shape as the other scripts — paste straight into a GitHub issue). function logMarkdown() { const PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; const body = LOG.length ? LOG.map(e => `${_ts(e.t)} ${PRE[e.sev] || ''}${e.msg}`).join('\n') : '(no activity logged)'; const c = logCounts(); let title = 'Art Station' + ((_gm && _gm.version) ? ' v' + _gm.version : ''); // version next to the script name try { const t = (releaseInfo().title || '').trim(); if (t) title += ' — ' + t; } catch (e) {} const tally = (c.warn || c.error) ? ` (${c.warn} warning${c.warn === 1 ? '' : 's'}, ${c.error} error${c.error === 1 ? '' : 's'})` : ''; return `
      ${title} — session log${tally}\n\n` + '```log\n' + body + '\n```' + `\n\n
      `; } async function copyLog(btn) { const md = logMarkdown(); let okCopy = false; try { await navigator.clipboard.writeText(md); okCopy = true; } catch (e) { try { const ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); okCopy = document.execCommand('copy'); ta.remove(); } catch (x) {} } if (btn) { const o = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = o; btn.textContent = okCopy ? 'Copied ✓' : 'Copy failed'; setTimeout(() => { btn.textContent = o; }, 1500); } } let _toastT; function toast(msg, ms = 2800) { const s = String(msg); // mirror every toast into the log (warn if it leads with ⚠, ok if it has ✓/✅) asLog(/^\s*⚠/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info', s.replace(/^\s*[⚠✓✅]\s*/, '')); let el = document.getElementById('as-toast'); if (!el) { el = document.createElement('div'); el.id = 'as-toast'; document.body.appendChild(el); } el.textContent = msg; el.style.opacity = '1'; clearTimeout(_toastT); _toastT = setTimeout(() => { el.style.opacity = '0'; }, ms); } function openMHCovers() { const info = releaseInfo(); const artist = info.artists.map(a => a.name).join(' ').trim(); const album = (info.title || '').trim(); if (!album) { toast('Could not read the release title'); return; } const p = new URLSearchParams(); // remote.* puts MH Covers into integration ("pick") mode — picking a cover posts // it back over the browser channel instead of just opening the image. #235 p.set('remote.port', 'browser'); p.set('remote.agent', 'Art Station - MusicBrainz'); p.set('remote.text', 'Pick a cover for this MusicBrainz release.'); if (artist) p.set('artist', artist); p.set('album', album); const win = window.open(`${MH_ORIGIN}?${p}`, '_blank'); if (!win) { toast('Pop-up blocked — allow pop-ups for MH Covers'); return; } toast('Pick a cover in the MH Covers tab…', 6000); const onMsg = async e => { if (e.source !== win) return; let host = ''; try { host = new URL(e.origin).hostname; } catch (err) {} if (!/(^|\.)musichoarders\.xyz$/.test(host)) return; let o; try { o = JSON.parse(e.data); } catch (err) { return; } if (o.action !== 'primary' && o.action !== 'secondary') return; cleanup(); try { win.close(); } catch (err) {} await addCoverFromMH(o); }; const onUnload = () => { try { win.close(); } catch (err) {} }; const cleanup = () => { window.removeEventListener('message', onMsg); window.removeEventListener('beforeunload', onUnload); }; window.addEventListener('message', onMsg); window.addEventListener('beforeunload', onUnload); } // MH reports the cover's underlying source (e.g. "itunes", "bandcamp") — map the // known ones to a recognisable name + favicon so the staged cover shows where it // came from, the same as ECAU sources do (#249). Unknown → labelled as MH Covers. const MH_SOURCE = { itunes: ['Apple Music', 'music.apple.com'], applemusic: ['Apple Music', 'music.apple.com'], deezer: ['Deezer', 'deezer.com'], spotify: ['Spotify', 'spotify.com'], tidal: ['Tidal', 'tidal.com'], qobuz: ['Qobuz', 'qobuz.com'], bandcamp: ['Bandcamp', 'bandcamp.com'], discogs: ['Discogs', 'discogs.com'], amazonmusic: ['Amazon', 'amazon.com'], amazon: ['Amazon', 'amazon.com'], vgmdb: ['VGMdb', 'vgmdb.net'], junodownload: ['Juno', 'junodownload.com'], beatport: ['Beatport', 'beatport.com'], sevendigital: ['7digital', '7digital.com'], }; function mhProvider(o) { const key = String(o.source || o.sourceName || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const m = MH_SOURCE[key]; if (m) return { name: `${m[0]} (via MH)`, icon: provIconUrl(m[1]) }; return { name: 'MH Covers', icon: `${MH_ORIGIN}/favicon.svg` }; } async function addCoverFromMH(o) { const url = o.bigCoverUrl || o.smallCoverUrl; if (!url) return; const prov = mhProvider(o); const slot = addSourcingSlot(`Sourcing ${prov.name}…`); // show the in-grid spinner placeholder, same as URL/provider sourcing try { const blob = await gmFetch(url, (l, t) => setSourcingLabel(slot, `Fetching ${prov.name}… ${Math.round(l / t * 100)}%`)); const ext = (String(url).match(/\.(jpe?g|png|gif|webp)(?:$|\?)/i) || [, 'jpg'])[1].toLowerCase().replace('jpeg', 'jpg'); const type = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const file = new File([blob], `mh-${Date.now()}.${ext}`, { type }); dropSourcingSlot(slot); const added = await addFilesDeduped([file], [{ provider: prov.name, provIcon: prov.icon, provUrl: url }]); // #253 if (added) toast('Added cover from MH Covers ✓'); else { render(); toast('That cover is already added'); } } catch (e) { dropSourcingSlot(slot); render(); toast('Could not fetch the cover — ' + e.message, 5000); } } // ── #242 Source from any provider via ECAU (ROpdebee's Enhanced Cover Art Uploads). ── // We don't reimplement providers — that scraping/maximization is the high-churn part // ECAU owns. Instead we seed ECAU's public x_seed interface in a HIDDEN add-cover-art // iframe (so no native/ECAU UI is ever shown), let it fetch + maximize, then harvest // the File(s) from MB's native uploader preview rows (the blob: previews + each // image's checked type checkboxes / comment) and stage them as NEW covers — riding the // normal "you get what you see" Enter-edit flow. Requires ECAU installed (it's what the // manager injects into the iframe). #242 const ECAU_TIMEOUT = 45000; // Some providers (notably Amazon) hand ECAU their site logo / "smile" favicon // alongside the real covers. Those are tiny; real cover art is never this small. // Drop anything whose longest side is under this so the brand glyph isn't staged. #242 const MIN_ART_PX = 200; // ECAU writes progress/errors into #ROpdebee_log_container (an ID, not a class — // the old `.`-selector matched nothing, so failures spun to the timeout). Each // message is a .msg. span; a real failure is .msg.error / .msg.warning. // Return that exact text so we can show ECAU's own message (#286). #242 function ecauError(doc) { const cont = doc.querySelector('#ROpdebee_log_container'); if (!cont) return null; const msgs = [...cont.querySelectorAll('.msg.error, .msg.warning')]; if (msgs.length) return (msgs[msgs.length - 1].textContent || '').replace(/\s+/g, ' ').trim() || null; const txt = (cont.textContent || '').replace(/\s+/g, ' ').trim(); if (txt && /failed to (fetch|enqueue|load)|invalid url|could ?n.?t|no (valid )?image|not a? ?support|unable to|refusing to/i.test(txt)) return txt.slice(-220); return null; } // ECAU injects its own UI into the add page (the paste-URL box, the "Import from …" // buttons, the supported-providers link). Its presence is how we tell the manager // actually loaded it — used to warn in the source popover and to fail a sourcing // attempt fast (instead of spinning to ECAU_TIMEOUT) when it isn't there. #242 const ecauUI = doc => !!(doc && doc.querySelector('#ROpdebee_paste_url, .ROpdebee_import_url_buttons, #ROpdebee_ecau_providers_link')); const NO_ECAU = 'Enhanced Cover Art Uploads isn’t installed or is disabled — it powers provider / URL sourcing.'; let _ecauProbe = null; // cached: load the add page once in a hidden frame and see if ECAU injects its UI function ecauInstalled() { return _ecauProbe || (_ecauProbe = new Promise(resolve => { const ifr = document.createElement('iframe'); ifr.style.cssText = 'position:fixed;left:-10000px;top:0;width:900px;height:700px;border:0;opacity:0;pointer-events:none'; let done = false; const finish = v => { if (done) return; done = true; clearInterval(poll); clearTimeout(killer); try { ifr.remove(); } catch (e) {} resolve(v); }; const poll = setInterval(() => { let d; try { d = ifr.contentDocument; } catch (e) { return; } if (ecauUI(d)) finish(true); }, 300); const killer = setTimeout(() => finish(false), 9000); ifr.src = `${R}/add-${ART}`; document.body.appendChild(ifr); })); } // a placeholder card (spinner + label) shown at the front of the gallery while ECAU // works, so the (sometimes slow) provider fetch has visible in-grid progress. It's // replaced by the real cover on success, removed on failure. function addSourcingSlot(label) { const ph = { id: 'srcing-' + Math.random().toString(36).slice(2, 7), types: [], comment: '', order: -1, w: 0, h: 0, _new: true, _sourcing: true, _srcLabel: label, _origTypes: [], _origComment: '', _origOrder: -1 }; const rest = MODEL.slice().sort((a, b) => a.order - b.order); MODEL = [ph, ...rest]; MODEL.forEach((it, i) => it.order = i); render(); return ph.id; } function setSourcingLabel(id, text) { const it = MODEL.find(x => x.id === id); if (it) it._srcLabel = text; const el = document.querySelector(`.as-card[data-id="${CSS.escape(id)}"] .as-srcing-lbl`); if (el) el.textContent = text; } function dropSourcingSlot(id) { MODEL = MODEL.filter(it => it.id !== id); MODEL.forEach((it, i) => it.order = i); } // ── #250 Plugin API ───────────────────────────────────────────────────────── // Third-party userscripts register a custom cover provider — it shows as its own // "Import from " button in the Source popover. The provider's run(ctx) // returns the images it fetched ITSELF (a Blob — so the provider's own // authenticated, e.g. CloudFlare-cleared, session does the fetch) OR plain URLs // (Art Station fetches those via GM xhr). Each cover keeps the provider's badge. const _customProviders = []; let _srcBtn = null; // the button that opened the Source popover (to re-open on late registration) const hostIcon = u => { try { return provIconUrl(new URL(u).hostname.replace(/^www\./, '')); } catch (e) { return ''; } }; // #250 (vzell) a provider may declare `match` — host string / array / RegExp / predicate — // so its "Import from …" button only appears when the release actually links that site, // and the matched external link(s) are handed to run() (ctx.link / ctx.links). Normalise // any of those forms to a (url)=>bool. No match → button always shows (legacy). function normMatch(m) { if (!m) return null; if (typeof m === 'function') return m; if (m instanceof RegExp) return u => { try { return m.test(u); } catch (e) { return false; } }; const needles = (Array.isArray(m) ? m : [m]).map(s => String(s).toLowerCase()).filter(Boolean); return u => { const lu = String(u || '').toLowerCase(); let h = ''; try { h = new URL(u).hostname.toLowerCase(); } catch (e) {} return needles.some(s => h === s || h.endsWith('.' + s) || lu.includes(s)); }; } function registerProvider(p) { if (!p || typeof p.run !== 'function' || !p.name) return false; const id = p.id || p.name; if (_customProviders.some(x => x.id === id)) return false; // de-dupe _customProviders.push({ id, name: String(p.name), icon: p.icon || '', run: p.run, match: normMatch(p.match) }); refreshSrcCount(); // #270 keep the button count right when a provider registers after the toolbar built if (document.querySelector('.as-src-pop') && _srcBtn) openSourcePop(_srcBtn); // reflect in an open popover return true; } // does a blob decode as a real image in OUR realm? (the true test of a usable cover) async function decodesImg(blob) { try { const bmp = await createImageBitmap(blob); bmp.close && bmp.close(); return true; } catch (e) { return false; } } async function providerBlob(it) { // one provider result → a Blob in OUR realm if (it == null) return null; if (it.dataUrl) { try { return await fetch(it.dataUrl).then(r => r.blob()); } catch (e) {} } // A provider fetches images in ITS OWN userscript sandbox, so the Blob/File it hands back // belongs to a different realm. On some managers (notably Firefox's Xray wrappers) those // bytes don't survive the boundary — the blob reports the right size but never decodes or // renders (#250, vzell's Jungleland). So whenever the provider also gave us a URL, we fetch // the image OURSELVES, in our own realm, and never touch the foreign object at all. This is // the robust default; the raw Blob is only used when no URL is available (e.g. a session- // locked image the provider could fetch but we can't). const directUrl = it.url || it.source || ''; if (directUrl) { try { const b = await gmFetch(directUrl); if (b && (b.type === 'application/pdf' || await decodesImg(b))) return b; } catch (e) {} } // No usable URL — launder the provider's own bytes into a fresh same-realm Blob, and verify // it actually decodes (a cross-realm copy can have the right length but unreadable content). const raw = it.blob || it.file; if (raw && typeof raw.arrayBuffer === 'function') { try { const buf = await raw.arrayBuffer(); const u8 = new Uint8Array(buf), copy = new Uint8Array(u8.length); copy.set(u8); // explicit same-realm byte copy const b = new Blob([copy], { type: raw.type || 'image/jpeg' }); if ((raw.type === 'application/pdf') || await decodesImg(b)) return b; // PDFs can't be decode-checked; trust them } catch (e) {} } return null; } function sourceFromProvider(prov, links) { const slot = addSourcingSlot(`Sourcing ${prov.name}…`); asLog.info(`Integration: sourcing covers from ${prov.name}`); const info = releaseInfo(); // #250 (vzell) ctx.link/links = the release's external link(s) this provider matched, // so run() can key off them instead of guessing the source page. ctx.url stays the MB page. const ctx = { mbid: MBID, entity: ENT.kind, artist: info.artists.map(a => a.name).join(', '), title: info.title, url: info.url, link: (links && links[0]) || '', links: links || [] }; let done = false; const finish = () => { done = true; dropSourcingSlot(slot); }; const killer = setTimeout(() => { if (done) return; finish(); render(); asLog.warn(`Integration: ${prov.name} timed out`); toast(`${prov.name} timed out`, 6000); }, 90000); Promise.resolve().then(() => prov.run(ctx)).then(async list => { if (done) return; clearTimeout(killer); const items = Array.isArray(list) ? list : (list ? [list] : []); const files = [], metas = []; for (const it of items) { let blob; try { blob = await providerBlob(it); } catch (e) { blob = null; asLog.debug(`Integration: ${prov.name} image fetch failed — ${(e && e.message) || e}`); } if (!blob) continue; const mime = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const ext = (mime.split('/')[1] || 'jpg').replace('jpeg', 'jpg'); const types = Array.isArray(it.types) ? it.types.filter(t => ALL_TYPES.includes(t)) : []; const srcUrl = it.source || it.url || ''; files.push(new File([blob], `prov-${Date.now()}-${files.length}.${ext}`, { type: mime })); metas.push({ types, comment: it.comment || '', provider: prov.name, provIcon: prov.icon || hostIcon(srcUrl), provUrl: srcUrl }); } finish(); if (files.length) { addFiles(files, metas); toast(`Added ${files.length} image${files.length > 1 ? 's' : ''} from ${prov.name} ✓`); } else { render(); toast(`${prov.name} returned no image`, 5000); } }).catch(e => { if (done) return; clearTimeout(killer); finish(); render(); toast(`${prov.name} failed — ${(e && e.message) || e}`, 8000); }); } // expose the registry on the page (and a CustomEvent fallback for managers that // isolate `window` from other userscripts). Either way is fine to call repeatedly. (function exposeApi() { // addImageUrl: bring an image in by URL — the bridge the reverse-image-search // picker companion (as_picker.user.js) calls when it lands on the MB page with // images the user picked elsewhere. Returns true so the companion knows AS is here. const api = { apiVersion: 1, registerProvider, addImageUrl: (url, prov) => { sourceFromUrl(url, prov); return true; } }; // no prov → sourceFromUrl derives name+favicon from the host try { (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).ArtStation = api; } catch (e) { try { window.ArtStation = api; } catch (x) {} } try { document.addEventListener('artstation:register-provider', e => { try { registerProvider(e.detail); } catch (x) {} }); } catch (e) {} // also accept picks via a DOM event (the companion may prefer this over the API object) try { document.addEventListener('artstation:add-image', e => { try { if (e.detail && e.detail.url) api.addImageUrl(e.detail.url, e.detail.prov); } catch (x) {} }); } catch (e) {} })(); // prov (optional) = { name, icon } the cover is being sourced from — passed by the // "Import from " buttons, else derived from the URL. Stamped on each new // cover so the gallery shows where it came from until commit (#249). function sourceFromUrl(rawUrl, prov) { const url = (rawUrl || '').trim(); if (!/^https?:\/\//i.test(url)) { toast('Enter a provider or image URL (https://…)', 4000); return; } // known provider → its name+icon; otherwise fall back to the URL's host so a // pasted link from anywhere (e.g. nugs.net) still gets a favicon badge. #249 if (!prov) { const pf = providerOf(url); if (pf) prov = { name: pf.name, icon: provIconUrl(pf.domain) }; else { try { const h = new URL(url).hostname.replace(/^www\./, ''); if (h) prov = { name: h, icon: provIconUrl(h) }; } catch (e) {} } } const p = new URLSearchParams(); p.set('x_seed.origin', releaseInfo().url); p.set('x_seed.image.0.url', url); const ifr = document.createElement('iframe'); ifr.style.cssText = 'position:fixed;left:-10000px;top:0;width:1100px;height:900px;border:0;opacity:0;pointer-events:none'; document.body.appendChild(ifr); ifr.src = `${R}/add-${ART}?${p}`; asLog.info(`Source: ${(prov && prov.name) || 'URL'} — ${url}`); // name the source attempted (mirrors tooltip) const slot = addSourcingSlot(prov ? `Sourcing ${prov.name}…` : 'Sourcing…'); let done = false, lastN = 0, settleAt = 0, noUiSince = 0; const stop = () => { clearInterval(poll); clearTimeout(killer); try { ifr.remove(); } catch (e) {} }; // a preview is the uploader's rendered image — usually a blob:, but in some browsers // ECAU leaves it as the remote provider URL (e.g. i.discogs.com). Fetch blobs in-frame; // fetch remote URLs via GM xhr so the page CSP can't block them (the FF-vs-Chromium // difference vzell hit). #242 const previewSel = '.uploader-preview-image, img[src^="blob:"]'; async function harvest(doc, win) { const files = [], metas = []; const seen = new Set(); for (const img of [...doc.querySelectorAll(previewSel)]) { const src = img.src || img.getAttribute('src'); if (!src || seen.has(src)) continue; seen.add(src); let blob; try { blob = /^blob:/i.test(src) ? await win.fetch(src).then(r => r.blob()) : await gmFetch(src); } catch (e) { continue; } // skip a provider's logo/favicon (e.g. Amazon's smile) — decode the actual blob, // not the preview (MB may downscale that), and drop sub-cover-sized art. #242 try { const bmp = await (win.createImageBitmap || createImageBitmap)(blob); const big = Math.max(bmp.width, bmp.height); bmp.close && bmp.close(); if (big && big < MIN_ART_PX) continue; } catch (e) {} const { types, comment } = readArtMeta(img); // #253 THIS image's own type/comment block (never doc-wide) const mime = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const ext = (mime.split('/')[1] || 'jpg').replace('jpeg', 'jpg'); files.push(new File([blob], `ecau-${Date.now()}-${files.length}.${ext}`, { type: mime })); // #260 if ECAU left the preview as a remote image URL (it does for some providers, // e.g. Discogs → i.discogs.com), keep that DIRECT image URL alongside the page URL. const directUrl = /^https?:/i.test(src) ? src : ''; metas.push({ types, comment, provider: prov && prov.name, provIcon: prov && prov.icon, provUrl: url, provImageUrl: directUrl }); } // #364 ECAU pre-fills the add-page edit note with its source attribution. On the native add page we // harvest that (harvestSeeds → _seedNote); do the same from this hidden sourcing frame so the commit // dialog is pre-filled with the same note on the release page too. Mark these covers so their own // source line isn't stacked on top of it. if (files.length) { try { const en = doc.querySelector('textarea.edit-note, textarea[name*="edit_note"]'); const note = en && (en.value || '').trim(); if (note) { _seedNote = _seedNote && _seedNote.includes(note) ? _seedNote : (_seedNote ? _seedNote + '\n\n' + note : note); metas.forEach(m => { m.ecauNote = true; }); } } catch (e) {} } return { files, metas }; } const poll = setInterval(async () => { if (done) return; let doc, win; try { doc = ifr.contentDocument; win = ifr.contentWindow; } catch (e) { return; } if (!doc || !win) return; const n = doc.querySelectorAll(previewSel).length; if (!n) { // nothing yet — but if ECAU has reported a failure, stop now (don't spin) const err = ecauError(doc); if (err) { done = true; stop(); dropSourcingSlot(slot); render(); toast('⚠ ' + (prov && prov.name ? prov.name + ': ' : '') + err, 13000); return; } // #286 surface ECAU's own message (mirrors to the log) // ECAU absent: the add page has fully loaded but ECAU never injected its UI → // fail fast (~6s) with a clear message instead of spinning to the 45s timeout. if (doc.readyState === 'complete' && !ecauUI(doc)) { if (!noUiSince) noUiSince = performance.now(); else if (performance.now() - noUiSince > 6000) { done = true; stop(); dropSourcingSlot(slot); render(); toast(NO_ECAU, 9000); } } else noUiSince = 0; return; } if (n !== lastN) { lastN = n; settleAt = performance.now() + 1500; setSourcingLabel(slot, 'Adding…'); return; } // still arriving → wait if (performance.now() < settleAt) return; done = true; const { files, metas } = await harvest(doc, win); stop(); dropSourcingSlot(slot); const added = files.length ? await addFilesDeduped(files, metas) : 0; // #253 skip an image already staged if (added) toast(`Added ${added} image${added > 1 ? 's' : ''} from ${(prov && prov.name) || 'provider'} ✓`); // name the source in the log/toast else { render(); toast(files.length ? 'That image is already added' : `${(prov && prov.name) || 'Provider'} returned no image`, 5000); } }, 400); const killer = setTimeout(() => { if (done) return; done = true; stop(); let edoc; try { edoc = ifr.contentDocument; } catch (e) {} const err = edoc && ecauError(edoc); // #286 even at timeout, prefer ECAU's own failure message dropSourcingSlot(slot); render(); toast(err ? ('⚠ ' + (prov && prov.name ? prov.name + ': ' : '') + err) : 'No image returned — is “Enhanced Cover Art Uploads” installed? It powers provider sourcing.', err ? 13000 : 9000); }, ECAU_TIMEOUT); } // ECAU-supported art providers we recognise on a release's external links, so the // popover can offer "Import from " the way the native add page does. // domain = the provider's CANONICAL site (not the linked subdomain, e.g. // analogafrica.bandcamp.com → bandcamp.com) — favicons come from there. const ART_PROVIDERS = [ { re: /(^|\.)discogs\.com$/i, name: 'Discogs', domain: 'discogs.com' }, { re: /(^|\.)bandcamp\.com$/i, name: 'Bandcamp', domain: 'bandcamp.com' }, { re: /(^|\.)music\.apple\.com$|(^|\.)itunes\.apple\.com$/i, name: 'Apple Music', domain: 'music.apple.com' }, { re: /(^|\.)open\.spotify\.com$|(^|\.)spotify\.com$/i, name: 'Spotify', domain: 'spotify.com' }, { re: /(^|\.)amazon\./i, name: 'Amazon', domain: 'amazon.com' }, { re: /(^|\.)deezer\.com$/i, name: 'Deezer', domain: 'deezer.com' }, { re: /(^|\.)tidal\.com$/i, name: 'Tidal', domain: 'tidal.com' }, { re: /(^|\.)qobuz\.com$/i, name: 'Qobuz', domain: 'qobuz.com' }, { re: /(^|\.)vgmdb\.net$/i, name: 'VGMdb', domain: 'vgmdb.net' }, { re: /7digital\./i, name: '7digital', domain: '7digital.com' }, { re: /(^|\.)beatport\.com$/i, name: 'Beatport', domain: 'beatport.com' }, { re: /(^|\.)junodownload\.com$|(^|\.)juno\.co\.uk$/i, name: 'Juno', domain: 'junodownload.com' }, ]; // Google's favicon service returns a real icon for every provider domain — more // reliable than guessing /favicon.ico (artist subdomains 404) or DuckDuckGo (which // serves a blank placeholder for some, e.g. Spotify). const provIconUrl = d => `https://www.google.com/s2/favicons?sz=64&domain=${d}`; function providerOf(url) { let h = ''; try { h = new URL(url).hostname; } catch (e) { return null; } return ART_PROVIDERS.find(x => x.re.test(h)) || null; } // ALL of the release/event's external link URLs (one WS2 fetch, cached) — used both by // the recognised-provider list and by #250 custom-provider link matching. let _urlRels = null; async function releaseUrls() { if (_urlRels) return _urlRels; try { const j = await fetch(`https://musicbrainz.org/ws/2/${ENT.kind}/${MBID}?inc=url-rels&fmt=json`, { headers: { Accept: 'application/json' } }).then(r => r.ok ? r.json() : null); _urlRels = [...new Set(((j && j.relations) || []).map(rel => rel.url && rel.url.resource).filter(Boolean))]; } catch (e) { _urlRels = []; } return _urlRels; } // the release/event's external links → the recognised art providers, deduped async function artProviderLinks() { const seen = new Set(), out = []; for (const u of await releaseUrls()) { const prov = providerOf(u); if (!prov) continue; const key = prov.name + '|' + u; if (seen.has(key)) continue; seen.add(key); out.push({ name: prov.name, url: u, icon: provIconUrl(prov.domain) }); } return out; } // #250 (vzell) custom providers whose declared `match` hits a link on THIS release, // each with the matched URL(s). A provider that declared no match is always offered. async function matchedCustomProviders() { const links = await releaseUrls(); return _customProviders .map(p => ({ p, urls: p.match ? links.filter(u => { try { return p.match(u); } catch (e) { return false; } }) : [] })) .filter(x => !x.p.match || x.urls.length); } let _provLinks = null; // fetched once per page; reused by the button count + the popover function getProvLinks() { return _provLinks ? Promise.resolve(_provLinks) : artProviderLinks().then(l => (_provLinks = l)); } // the count on the Source button = built-in linked platforms + matched custom providers // (#270 — it was only counting the platforms, so "URL (1)" showed beside a 3-button popover). // Re-run on render and whenever a provider registers (which may happen after the toolbar built). function refreshSrcCount() { const src = document.querySelector('.as-src'); const n = src && src.querySelector('.as-src-n'); if (!n) return; Promise.all([getProvLinks(), matchedCustomProviders()]).then(([l, m]) => { const total = l.length + m.length; n.textContent = total ? ` (${total})` : ''; src.title = total ? `Source ${ENT.noun} — ${total} source${total > 1 ? 's' : ''} (linked platform${l.length === 1 && !m.length ? '' : 's'}, registered providers, or any URL)` : `Source ${ENT.noun} from a linked platform, a registered provider, or any URL`; }).catch(() => {}); } function openSourcePop(btn) { _srcBtn = btn; // #250 remembered so a late provider registration can re-open this popover document.querySelectorAll('.as-pop').forEach(p => p.remove()); const pop = document.createElement('div'); pop.className = 'as-pop as-src-pop'; pop.innerHTML = `
      Source ${ENT.noun}
      ` + `
      Looking for linked platforms…
      ` + `
      ` + `
      or paste any URL
      ` + `` + `
      ` + `
      Powered by ROpdebee's Enhanced Cover Art Uploads (must be installed).
      `; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); // #250 custom providers registered by other userscripts — one stacked "Import from …" // button each, but only for providers whose declared `match` hits a link on this release. const cbox = pop.querySelector('.as-src-custom'); if (cbox && _customProviders.length) { matchedCustomProviders().then(matched => { if (!cbox.isConnected || !matched.length) return; cbox.innerHTML = matched.map((x, i) => ``).join(''); cbox.querySelectorAll('.as-src-prov-b').forEach(b => b.onclick = () => { const x = matched[+b.dataset.ci]; pop.remove(); sourceFromProvider(x.p, x.urls); }); cbox.querySelectorAll('.as-src-ic').forEach(img => img.onerror = () => { img.style.visibility = 'hidden'; }); placePop(pop, btn.getBoundingClientRect()); }); } const inp = pop.querySelector('.as-src-inp'); inp.focus(); const go = () => { const v = inp.value; pop.remove(); sourceFromUrl(v); }; pop.querySelector('.as-src-go').onclick = go; inp.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); go(); } else if (e.key === 'Escape') { e.preventDefault(); pop.remove(); } }; // paste a URL → fetch immediately (no need to click Fetch). Read after the paste // lands; only auto-go when the whole field is a URL (typing-then-pasting won't fire). inp.onpaste = () => setTimeout(() => { if (/^https?:\/\//i.test(inp.value.trim())) go(); }, 0); // populate "Import from " buttons from the release's linked platforms getProvLinks().then(provs => { const box = pop.querySelector('.as-src-prov'); if (!box) return; if (!provs.length) { box.textContent = `No supported platforms linked on this ${ENT.kind}.`; placePop(pop, btn.getBoundingClientRect()); return; } box.classList.remove('as-pop-note'); box.innerHTML = provs.map((p, i) => ``).join('') + (provs.length > 1 ? `` : ''); box.querySelectorAll('.as-src-prov-b').forEach(b => b.onclick = () => { const p = provs[+b.dataset.i]; pop.remove(); sourceFromUrl(p.url, { name: p.name, icon: p.icon }); }); const allBtn = box.querySelector('.as-src-all'); if (allBtn) allBtn.onclick = () => { pop.remove(); provs.forEach(p => sourceFromUrl(p.url, { name: p.name, icon: p.icon })); }; // one sourcing slot per provider box.querySelectorAll('.as-src-ic').forEach(img => img.onerror = () => { img.style.visibility = 'hidden'; }); // hide a missing favicon (no inline handler — CSP) placePop(pop, btn.getBoundingClientRect()); }); // detect a missing/disabled ECAU and turn the footer note into a clear warning, // so the user knows BEFORE fetching (sourcing also fails fast if they try anyway). ecauInstalled().then(ok => { if (ok || !pop.isConnected) return; const note = pop.querySelector('.as-pop-note:last-child'); if (!note) return; note.classList.add('as-src-warn'); note.innerHTML = `⚠ ${esc(NO_ECAU)} Install / enable →`; placePop(pop, btn.getBoundingClientRect()); }); const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } let _dropZone = IS_ADD; // #361: on the direct /add-(cover|event)-art page, open the drop zone straight away function toggleDropZone() { _dropZone = !_dropZone; render(); if (_dropZone) root.querySelector('.as-dropzone')?.scrollIntoView({ block: 'nearest' }); } // Reveal the drop zone automatically when files are dragged onto the page, so you don't // have to click "Add image" first; the whole gallery then accepts the drop (the browser // won't navigate to the file). Internal reorder drags carry no Files, so they're ignored; // the zone auto-hides after a drop or when the drag leaves the window. let _autoDz = false; // a drag we accept: local files, OR an image dragged from another tab/page (a URL — #331) const isFileDrag = e => { try { const t = [...((e.dataTransfer && e.dataTransfer.types) || [])]; return t.includes('Files') || t.includes('text/uri-list'); } catch (x) { return false; } }; window.addEventListener('dragover', e => { if (!isFileDrag(e)) return; e.preventDefault(); if (!_dropZone) { _dropZone = true; _autoDz = true; render(); root.querySelector('.as-dropzone')?.scrollIntoView({ block: 'nearest' }); } }); window.addEventListener('drop', async e => { if (!isFileDrag(e)) return; e.preventDefault(); e.stopPropagation(); // stage every file drop here (a near-miss outside the zone still works); also stops the zone's own ondrop double-adding _autoDz = false; root.querySelector('.as-dropzone')?.classList.remove('over'); const added = await addFromDrop(e.dataTransfer); if (!added) { _dropZone = false; render(); } }, true); window.addEventListener('dragleave', e => { if (_autoDz && !e.relatedTarget) { _dropZone = false; _autoDz = false; render(); } }); function newItem(f, meta) { let types = (meta && meta.types && meta.types.length) ? meta.types.slice() : []; let comment = (meta && meta.comment) || ''; if (!types.length && (SETTINGS.autoType || SETTINGS.autoComment)) { // #243/#244 guess type + comment from the file name const p = parseName(f.name); if (SETTINGS.autoType) types = p.types; if (SETTINGS.autoComment && !comment && p.comment) comment = p.comment; } return { id: 'new-' + Math.random().toString(36).slice(2, 8), types, comment, order: 0, w: 0, h: 0, bytes: f.size, _del: false, _new: true, _pdf: f.type === 'application/pdf', _file: URL.createObjectURL(f), _fileObj: f, _provider: (meta && meta.provider) || '', _provIcon: (meta && meta.provIcon) || '', _provUrl: (meta && meta.provUrl) || '', // #249 where this image was sourced (shown until committed) _provImageUrl: (meta && meta.provImageUrl) || '', // #260 direct image URL when the provider exposes one (e.g. Discogs) _ecauNote: !!(meta && meta.ecauNote), // #364 its source is already in the seeded commit note → don't add a per-cover source line too _seedSrc: (meta && meta.seedSrc) || '', _seedTypes: (meta && meta.seedTypes) ? meta.seedTypes.slice() : null, // #248 native-uploader row + last types synced from it _seedBlobSrc: (meta && meta.seedBlobSrc) || '', // #253 the row's current blob URL (changes when ECAU maximises) _contentKey: (meta && meta.contentKey) || '', // #253 image-content fingerprint, to drop duplicate sourced/seeded covers // #248 (vzell) original file name for a locally-picked/dropped upload — shown in the // thumb tooltip. Sourced/seeded covers carry a synthetic File name, so skip those // (they show their provider/source via provBadge instead). Disk path isn't recoverable. _uploadName: (meta && (meta.provider || meta.seedSrc)) ? '' : ((f && f.name) || ''), _origTypes: [], _origComment: '', _origOrder: -1 }; } // #262 AS doesn't import a source's per-cover types (dropped as unreliable, #253), so // imports arrive untyped. Front is by far the most common, so optionally type the FIRST // imported cover Front. Only touches a cover with no type yet (a file-name type wins), and // in "when none exists" mode only when no Front is already present (existing or staged) — // which is the safe default: it can't create a duplicate Front. "always" can (see #262). function maybeAutoFront(news) { if (!SETTINGS.autoFront || !news.length) return; const first = news.find(it => !it.types.length); // first untyped cover of this import if (!first) return; if (SETTINGS.autoFrontMode !== 'always' && MODEL.some(it => !it._del && it.types.includes('Front'))) return; // a Front already exists first.types = ['Front']; } // metas (optional) carries per-file { types, comment } — used when sourcing covers // that already know their type/comment (e.g. ECAU provider import, #242) function addFiles(files, metas) { const news = [...files].map((f, i) => ({ f, meta: metas && metas[i] })) .filter(x => x.f.type.startsWith('image/') || x.f.type === 'application/pdf').map(x => newItem(x.f, x.meta)); if (!news.length) return; maybeAutoFront(news); // #262 type the first untyped imported cover Front (per setting), before they're inserted // New covers go before the existing/published ones (majkinetor: they were landing last). WHERE among // the staged-new they land depends on the source (#370): // • a batch harvested from the native add rows (ECAU/Discogs) can arrive across several passes (an // image not decodable yet is deferred to a later pass), so it APPENDS after covers already staged — // keeping the import's source order instead of reversing. // • a manually-added cover (drop, URL, MH / other provider) is the NEWEST deliberate add, so it LEADS. const rest = MODEL.slice().sort((a, b) => a.order - b.order); const stagedNew = rest.filter(m => m._new), published = rest.filter(m => !m._new); const isSeedBatch = news.some(n => n._seedSrc); MODEL = isSeedBatch ? [...stagedNew, ...news, ...published] : [...news, ...stagedNew, ...published]; MODEL.forEach((it, i) => it.order = i); news.forEach(measure); // fill in each new cover's resolution from its local file _dropZone = false; render(); } // #253 a content fingerprint so the same image isn't staged twice. Sourcing // (ECAU/MH/providers) and the add-page harvest can surface the SAME cover more // than once — ECAU re-fires as it maximises, a provider may return dups, etc. async function fileKey(file) { try { const buf = await file.arrayBuffer(); const h = await crypto.subtle.digest('SHA-1', buf); return file.size + ':' + [...new Uint8Array(h)].map(b => b.toString(16).padStart(2, '0')).join(''); } catch (e) { return 'sz' + file.size; } // fallback: byte length only } // stage files, skipping any whose content already exists as a staged NEW cover // (or repeats within this batch). Returns how many were actually added. async function addFilesDeduped(files, metas) { const have = new Set(MODEL.filter(m => m._new && !m._del && m._contentKey).map(m => m._contentKey)); const outF = [], outM = []; for (let i = 0; i < files.length; i++) { const f = files[i]; if (!(f.type.startsWith('image/') || f.type === 'application/pdf')) continue; const k = await fileKey(f); if (have.has(k)) continue; have.add(k); outF.push(f); outM.push(Object.assign({}, metas && metas[i], { contentKey: k })); } if (outF.length) addFiles(outF, outM); return outF.length; } // #243 a drop can include whole FOLDERS — recurse the directory entries to collect every // file. webkitGetAsEntry() must be read synchronously while the drop event is live. // #359: dropped/browsed folders upload recursively, but BOUNDED — one level of // subdirectories deep (the given dir + its immediate subfolders) and at most // DIR_MAX_FILES image/PDF files, so a stray huge tree can't stage thousands. Loose // files dropped directly are kept as-is (type-filtered downstream by addFiles). const DIR_MAX_DEPTH = 1, DIR_MAX_FILES = 100; const DIR_ACCEPT_RE = /\.(jpe?g|png|gif|pdf)$/i; let _dropTruncated = false; function filesFromDrop(dt) { const entries = [...(dt.items || [])].map(i => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean); if (!entries.some(e => e.isDirectory)) return Promise.resolve([...(dt.files || [])]); const out = []; _dropTruncated = false; // walk a directory: collect its image/PDF files, descend into subdirs only while // within DIR_MAX_DEPTH, and stop once DIR_MAX_FILES is reached. const walk = (entry, depth) => new Promise(res => { if (out.length >= DIR_MAX_FILES) { _dropTruncated = true; return res(); } if (entry.isFile) { if (!DIR_ACCEPT_RE.test(entry.name)) return res(); // inside a folder → only CAA image/PDF types entry.file(f => { if (out.length < DIR_MAX_FILES) out.push(f); else _dropTruncated = true; res(); }, () => res()); return; } if (!entry.isDirectory) return res(); const rd = entry.createReader(); const readBatch = () => rd.readEntries(async ents => { if (!ents.length || out.length >= DIR_MAX_FILES) return res(); if (depth >= DIR_MAX_DEPTH && ents.some(e => e.isDirectory)) _dropTruncated = true; // deeper levels skipped const next = ents.filter(e => e.isFile || depth < DIR_MAX_DEPTH); await Promise.all(next.map(e => walk(e, e.isDirectory ? depth + 1 : depth))); readBatch(); }, () => res()); readBatch(); }); // top-level dropped FILES kept as-is; dropped DIRECTORIES walked with the bounds above. return Promise.all(entries.map(e => e.isFile ? new Promise(r => e.file(f => { out.push(f); r(); }, () => r())) : walk(e, 0) )).then(() => out); } // #331: an image dragged from another tab/page arrives as a URL, not a File. Pull it out // of the drop (uri-list → in the HTML → a plain-text URL). function urlFromDrop(dt) { try { const uri = (dt.getData('text/uri-list') || '').split(/\r?\n/).map(s => s.trim()).find(s => s && !s.startsWith('#')); if (uri) return uri; const m = (dt.getData('text/html') || '').match(/]+src\s*=\s*["']([^"']+)["']/i); if (m) return m[1]; const txt = (dt.getData('text/plain') || '').trim(); if (/^https?:\/\//i.test(txt)) return txt; } catch (e) {} return null; } // Fetch a dropped image URL (CORS-free via fetchBytes/GM) and wrap it as a File. Sniffs the // bytes for a CAA-accepted type (JPEG/PNG/GIF/PDF); warns + skips anything else (e.g. webp). async function fileFromUrl(url) { asLog.info('Fetching dropped image…'); const bytes = await fetchBytes(url); const hex = [...bytes.slice(0, 4)].map(b => b.toString(16).padStart(2, '0')).join(''); let type, ext; if (hex.startsWith('ffd8ff')) { type = 'image/jpeg'; ext = 'jpg'; } else if (hex.startsWith('89504e47')) { type = 'image/png'; ext = 'png'; } else if (hex.startsWith('47494638')) { type = 'image/gif'; ext = 'gif'; } else if (hex.startsWith('25504446')) { type = 'application/pdf'; ext = 'pdf'; } else { asLog.warn(`Dropped image isn't a Cover Art Archive type (JPEG/PNG/GIF/PDF) — skipped: ${url.slice(0, 90)}`); return null; } let name = ''; try { name = decodeURIComponent(new URL(url).pathname.split('/').pop() || ''); } catch (e) {} if (!/\.(jpe?g|png|gif|pdf)$/i.test(name)) name = (name.replace(/\.[^./]*$/, '') || 'dropped-image') + '.' + ext; return new File([bytes], name, { type }); } // Unified drop ingest: local files if present, else fetch a dropped image URL. Returns true // when something was staged. async function addFromDrop(dt) { const files = await filesFromDrop(dt); if (files && files.length) { addFiles(files); if (_dropTruncated) toast(`Folder upload capped: first ${DIR_MAX_FILES} images, one level of subfolders deep`, 6000); return true; } const url = urlFromDrop(dt); if (!url) return false; try { const f = await fileFromUrl(url); if (f) { addFiles([f], [{ provImageUrl: url, provUrl: url }]); return true; } } catch (e) { asLog.err('Drop fetch failed: ' + (e.message || e)); } return false; } function pickFiles() { // Only the types MusicBrainz / the Cover Art Archive accept — `image/*` was too // broad (it offered webp, bmp, … which CAA rejects). JPEG · PNG · GIF · PDF. const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/jpeg,image/png,image/gif,application/pdf'; inp.multiple = true; inp.onchange = () => addFiles(inp.files); inp.click(); } // #359: pick a FOLDER → upload its image/PDF files one level of subfolders deep, capped // at DIR_MAX_FILES (webkitRelativePath is "folder/[sub/]file", so depth = segments − 2). function pickFolder() { const inp = document.createElement('input'); inp.type = 'file'; inp.webkitdirectory = true; inp.multiple = true; inp.onchange = () => { const imgs = [...inp.files].filter(f => DIR_ACCEPT_RE.test(f.name)); const inDepth = imgs.filter(f => (f.webkitRelativePath.split('/').length - 2) <= DIR_MAX_DEPTH); let files = inDepth, truncated = imgs.length > inDepth.length; // deeper files skipped if (inDepth.length > DIR_MAX_FILES) { files = inDepth.slice(0, DIR_MAX_FILES); truncated = true; } if (files.length) addFiles(files); if (truncated) toast(`Folder upload capped: first ${DIR_MAX_FILES} images, one level of subfolders deep`, 6000); else if (!files.length) toast('No JPEG / PNG / GIF / PDF images in that folder (first level)', 5000); }; inp.click(); } // ── Phase 2a: apply staged changes as real MB edits (form-replay) ───────────── const R = ENT.base; // /release/ | /event/ const ART = ENT.art; // cover-art | event-art — the MB endpoint + form-field suffix // credit the tool in every edit note (user's note first, then the attribution) const editNote = m => [m.note && m.note.trim(), ATTRIBUTION].filter(Boolean).join('\n\n'); // #260 a sourced cover records where it came from in ITS OWN add edit note. The commit // note is shared across all ops, so this per-cover provenance is appended only to that // upload (sourced covers carry _provider/_provUrl; local uploads have neither → nothing added). const sourceLine = it => { if (!it || it._ecauNote) return ''; // #364 attribution already carried in the seeded commit note const who = (it._provider && String(it._provider).trim()) || ''; const page = (it._provUrl && String(it._provUrl).trim()) || ''; const img = (it._provImageUrl && String(it._provImageUrl).trim()) || ''; const main = page || img; if (!who && !main) return ''; let s = `Cover art sourced from ${who || 'an external provider'}`; if (main) s += ` — ${main}`; if (img && img !== main) s += `\nImage: ${img}`; // #260 the direct image URL, when distinct from the page return s; }; const editNoteFor = (m, it) => [m.note && m.note.trim(), sourceLine(it), ATTRIBUTION].filter(Boolean).join('\n\n'); async function getPostForm(url) { const html = await fetch(url, { credentials: 'same-origin' }).then(r => { if (!r.ok) throw new Error('GET ' + r.status); return r.text(); }); const doc = new DOMParser().parseFromString(html, 'text/html'); const form = [...doc.querySelectorAll('form')].find(f => (f.getAttribute('method') || '').toUpperCase() === 'POST'); if (!form) throw new Error('no POST form at ' + url); form._action = new URL(form.getAttribute('action') || url, location.origin + url).href; return form; } // carry every hidden field (csrf/nonce/etc.) verbatim — that's the point of form-replay function copyHidden(form, params, skip) { form.querySelectorAll('input[type=hidden]').forEach(h => { if (h.name && !(skip && skip.test(h.name))) params.append(h.name, h.value); }); } function typeMapOf(form, prefix) { const m = {}; form.querySelectorAll(`input[name="${prefix}.type_id"]`).forEach(cb => { const l = cb.closest('label'); const n = (l ? l.textContent : '').trim(); if (n) m[n] = cb.value; }); return m; } async function buildEdit(it, meta) { // retype / comment on an existing cover const form = await getPostForm(`${R}/edit-${ART}/${it.id}`); const p = new URLSearchParams(); copyHidden(form, p); const tm = typeMapOf(form, `edit-${ART}`); it.types.forEach(t => { if (tm[t]) p.append(`edit-${ART}.type_id`, tm[t]); }); p.append(`edit-${ART}.comment`, it.comment); p.append(`edit-${ART}.edit_note`, editNote(meta)); if (meta.votable) p.append(`edit-${ART}.make_votable`, '1'); return { method: 'POST', url: form._action, body: p }; } async function buildRemove(it, meta) { // #264 if the cover is already gone (e.g. removed in a prior edit but lingering in a // stale CAA listing), the remove form 404s — treat that as "already removed", not an error. let form; try { form = await getPostForm(`${R}/remove-${ART}/${it.id}`); } catch (e) { if (/\b404\b/.test(String((e && e.message) || e))) return { noop: true, note: 'already removed (not on the release)' }; throw e; } const p = new URLSearchParams(); copyHidden(form, p); p.append('confirm.edit_note', editNote(meta)); if (meta.votable) p.append('confirm.make_votable', '1'); return { method: 'POST', url: form._action, body: p }; } // Phase 2b: upload a new image. (1) sign via MB, (2) POST file to archive.org, (3) register. // The sign endpoint reserves an image_id/nonce per call and fetches an S3 policy from the // Internet Archive, so concurrent calls for the same release RACE and 500 — committing 6 // covers, only the 1st succeeded and the rest failed "sign 500". So serialise signing // through a gate (the slow S3 PUT still overlaps) and retry transient 5xx/429 (IA flakes). let _signGate = Promise.resolve(); async function signUploadRaw(mime, ctl) { for (let attempt = 1; ; attempt++) { if (ctl && ctl.aborted) throw new Error('cancelled'); const r = await fetch(`/ws/js/${ART}-upload/${MBID}?mime_type=${encodeURIComponent(mime || 'image/jpeg')}`, { credentials: 'same-origin', signal: ctl && ctl.ac.signal }); if (r.ok) return r.json(); // { action, image_id, formdata, nonce } if (attempt >= 4 || ![429, 500, 502, 503, 504].includes(r.status)) throw new Error('sign ' + r.status); asLog.debug(`Upload: sign got ${r.status}, retrying (${attempt}/3)`); await new Promise(res => setTimeout(res, 500 * attempt + Math.floor(Math.random() * 400))); // backoff + jitter } } function signUpload(mime, ctl) { const run = () => signUploadRaw(mime, ctl); const p = _signGate.then(run, run); // one sign at a time, regardless of prior failures _signGate = p.catch(() => {}); return p; } let _addForm = null; const addForm = () => (_addForm = _addForm || getPostForm(`${R}/add-${ART}`)); // step 1: sign (serialised by the gate above) then PUT the file to archive.org. Stores // the signed upload on the item; the slow PUT is the part that overlaps across items. async function uploadStep(it, onProgress, ctl) { if (ctl && ctl.aborted) throw new Error('cancelled'); const mime = (it._fileObj && it._fileObj.type) || 'image/jpeg'; const signed = await signUpload(mime, ctl); if (ctl && ctl.aborted) throw new Error('cancelled'); const fd = new FormData(); Object.entries(signed.formdata).forEach(([k, v]) => fd.append(k, v)); fd.append('file', it._fileObj, (it._fileObj && it._fileObj.name) || String(signed.image_id)); // XHR (not fetch) so we get upload progress + a timeout — a big cover used to // sit silently with no feedback, and a stalled POST would hang forever. #240/#235 // The live xhr is registered on ctl so Cancel can abort an in-flight upload. await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', signed.action); xhr.timeout = 300000; // 5 min ceiling for large covers if (ctl) ctl.xhrs.add(xhr); const done = () => { if (ctl) ctl.xhrs.delete(xhr); }; xhr.upload.onprogress = e => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); }; xhr.onload = () => { done(); (xhr.status >= 200 && xhr.status < 300) ? resolve() : reject(new Error('IA upload ' + xhr.status)); }; xhr.onerror = () => { done(); reject(new Error('IA upload network error')); }; xhr.ontimeout = () => { done(); reject(new Error('IA upload timed out')); }; xhr.onabort = () => { done(); reject(new Error('cancelled')); }; xhr.send(fd); }); it._signed = signed; } // step 2: register on MB. Runs in PARALLEL (#362) — the per-upload `position` only groups the batch, so // the trailing reorder edit is what sets the final order (see buildReorder / runAdds). async function registerStep(it, meta, ctl) { const form = await addForm(); const tm = typeMapOf(form, `add-${ART}`); const typeIds = it.types.map(t => tm[t]).filter(Boolean); const mime = (it._fileObj && it._fileObj.type) || 'image/jpeg'; const p = new URLSearchParams(); copyHidden(form, p, /\.(nonce|position|id|type_id|comment|mime_type)$/); p.append(`add-${ART}.id`, it._signed.image_id); p.append(`add-${ART}.position`, String(it.order + 1)); p.append(`add-${ART}.nonce`, it._signed.nonce); p.append(`add-${ART}.mime_type`, mime); // required Select (MB Form::Role::AddArt) typeIds.forEach(id => p.append(`add-${ART}.type_id`, id)); p.append(`add-${ART}.comment`, it.comment); p.append(`add-${ART}.edit_note`, editNoteFor(meta, it)); // #260 include this cover's source, if any if (meta.votable) p.append(`add-${ART}.make_votable`, '1'); const add = await fetch(`${R}/add-${ART}`, { method: 'POST', body: p, credentials: 'same-origin', signal: ctl && ctl.ac.signal }); if (!add.ok) throw new Error('add ' + add.status); } async function runAdd(it, meta, dry, report) { // dry-run summary only (live uses runAdds) const mime = (it._fileObj && it._fileObj.type) || 'image/jpeg'; const form = await addForm(); const typeIds = it.types.map(t => typeMapOf(form, `add-${ART}`)[t]).filter(Boolean); report(`1. GET /ws/js/${ART}-upload/${MBID}?mime_type=${mime} → {action,image_id,formdata,nonce}\n` + `2. POST ‹signed archive.org action› multipart: ‹policy,signature,key,AWSAccessKeyId…› + file (${(it._fileObj && it._fileObj.name) || 'file'}, ${(it._fileObj && it._fileObj.size) || '?'}b)\n` + `3. POST ${R}/add-${ART}\n add-${ART}.id=‹image_id›\n add-${ART}.position=${it.order + 1}\n add-${ART}.nonce=‹nonce›\n add-${ART}.mime_type=${mime}\n` + ` add-${ART}.type_id=${typeIds.join(',') || '(none)'}\n add-${ART}.comment=${it.comment}\n add-${ART}.edit_note=${editNoteFor(meta, it).replace(/\n+/g, ' / ')}` + (meta.votable ? `\n add-${ART}.make_votable=1` : '')); } async function buildReorder(meta) { // single edit: full ordered artwork list const form = await getPostForm(`${R}/reorder-${ART}`); const p = new URLSearchParams(); copyHidden(form, p, /\.artwork\./); // #261 the full final order — NEW covers included by their post-upload image_id (runs // after the uploads register). MB's add `position` only places the whole upload as a // group, so without this the relative order of multiple new covers (or a new cover // slotted among existing ones) isn't preserved. const seq = MODEL.filter(it => !it._del && !it._sourcing).sort((a, b) => a.order - b.order); let n = 0; for (const it of seq) { const id = it._new ? (it._signed && it._signed.image_id) : it.id; if (!id) continue; // a new cover whose upload failed — leave it out of the ordering p.append(`reorder-${ART}.artwork.${n}.id`, id); p.append(`reorder-${ART}.artwork.${n}.position`, String(n + 1)); n++; } p.append(`reorder-${ART}.edit_note`, editNote(meta)); if (meta.votable) p.append(`reorder-${ART}.make_votable`, '1'); return { method: 'POST', url: form._action, body: p }; } // ordered work list (uploads are Phase 2b): remove → retype/comment → reorder function buildPlan() { const plan = []; MODEL.filter(it => it._new && !it._del && !it._sourcing).forEach(it => plan.push({ label: `Add ${it.types[0] || 'new image'}${it.comment ? ` “${it.comment}”` : ''} (upload)`, kind: 'add', it, run: (m, dry, report) => runAdd(it, m, dry, report) })); MODEL.filter(it => it._del && !it._new).forEach(it => plan.push({ label: `Remove ${it.types[0] || ITEM}`, id: it.id, kind: 'remove', build: m => buildRemove(it, m) })); MODEL.filter(it => !it._del && !it._new && (it.comment !== it._origComment || it.types.join('|') !== it._origTypes.join('|'))) .forEach(it => { // readable description of what changed on this cover (the panel list now // doubles as the "pending operations" view — #234) const ch = []; if (it.types.join('|') !== it._origTypes.join('|')) ch.push(`type → ${it.types.join(', ') || '(none)'}`); if (it.comment !== it._origComment) ch.push(`comment → ${it.comment ? `“${it.comment}”` : '(cleared)'}`); plan.push({ label: `Edit ${it._origTypes[0] || ITEM}: ${ch.join(', ')}`, id: it.id, kind: 'edit', build: m => buildEdit(it, m) }); }); const ex = MODEL.filter(it => !it._del && !it._new); const now = ex.slice().sort((a, b) => a.order - b.order).map(it => it.id).join(','); const orig = ex.slice().sort((a, b) => a._origOrder - b._origOrder).map(it => it.id).join(','); // #261 reorder when the existing order changed OR new covers were uploaded among // others (their order isn't honoured by the per-upload position alone). Needs ≥2 covers. const all = MODEL.filter(it => !it._del && !it._sourcing); const reorderNeeded = all.length >= 2 && (now !== orig || all.some(it => it._new)); if (reorderNeeded) plan.push({ label: 'Reorder ' + ITEMS, kind: 'reorder', build: m => buildReorder(m) }); return plan; } function enterEdit() { document.getElementById('as-commit')?.remove(); const plan = buildPlan(); const ov = document.createElement('div'); ov.id = 'as-commit'; ov.innerHTML = `
      Apply ${plan.length} change${plan.length===1?'':'s'} as MusicBrainz edits🕓 My ${ENT.Noun} edits
      ${plan.map((o, i) => `
      ${esc(o.label)}${o.id ? ` #${esc(o.id)}` : ''}${o.skip ? `${esc(o.skip)}` : ''}
      `).join('')}
      `; document.body.appendChild(ov); if (_seedNote) ov.querySelector('.as-cm-note').value = _seedNote; // #248/#364 carry over a seeded edit note (native add page, or captured from a hidden ECAU sourcing frame) // backdrop click closes — but NOT while a live run is in flight (#269): that // path bypassed the abort, orphaning the in-flight edits. During a run the only // exits are Cancel (aborts) or Close (after it finishes). ov.onclick = e => { if (e.target === ov && !ov._running) ov.remove(); }; ov.querySelector('.as-cm-cancel').onclick = () => ov.remove(); const dryEl = ov.querySelector('.as-cm-dryrun'); const goBtn = ov.querySelector('.as-cm-go'); const setGoLabel = () => goBtn.textContent = dryEl.checked ? 'Dry run' : 'Submit edits'; dryEl.onchange = setGoLabel; setGoLabel(); goBtn.onclick = () => runPlan(ov, plan, { note: ov.querySelector('.as-cm-note').value, votable: ov.querySelector('.as-cm-vote').checked, dry: dryEl.checked }); } // #278: per-row progress bar pinned on the right of each op row. `pct` null → // leave the width; `state` colours it (busy=indeterminate sweep, ''=in-progress // accent, done=green, dry=muted, err=red, cancel=grey). function setRowBar(row, pct, state) { if (!row) return; const bar = row.querySelector('.as-cm-bar'); if (!bar) return; bar.classList.add('on'); bar.classList.remove('busy', 'done', 'dry', 'err', 'cancel'); if (state) bar.classList.add(state); if (pct != null) { const f = bar.querySelector('.as-cm-bfill'); if (f) f.style.width = Math.max(0, Math.min(100, pct)) + '%'; } } async function runOp(ov, op, meta, ctl) { const row = ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); const st = row.querySelector('.as-cm-st'), pay = row.querySelector('.as-cm-payload'); if (op.skip) { st.textContent = '⏭'; return; } if (ctl && ctl.aborted) { st.textContent = '⛔'; setRowBar(row, 100, 'cancel'); return; } st.textContent = '⏳'; setRowBar(row, null, 'busy'); if (!meta.dry && !op.run) asLog.info(`${op.label} — applying…`); // make each commit operation visible as it runs try { if (op.run) { // multi-step op (uploads) reports its own payload await op.run(meta, meta.dry, txt => { pay.textContent = txt; }); st.textContent = meta.dry ? '👁' : '✅'; if (meta.dry) row.classList.add('dry'); setRowBar(row, 100, meta.dry ? 'dry' : 'done'); } else { const req = await op.build(meta); if (req && req.noop) { st.textContent = '✅'; pay.textContent = req.note || 'nothing to do'; setRowBar(row, 100, 'done'); return; } // #264 already-done op (e.g. removing a cover that's already gone) if (meta.dry) { st.textContent = '👁'; row.classList.add('dry'); pay.textContent = `${req.method} ${req.url}\n${decodeURIComponent(req.body.toString()).replace(/\+/g, ' ').replace(/&/g, '\n ')}`; setRowBar(row, 100, 'dry'); } else { const r = await fetch(req.url, { method: 'POST', body: req.body, credentials: 'same-origin', signal: ctl && ctl.ac.signal }); if (!r.ok) throw new Error('HTTP ' + r.status); st.textContent = '✅'; setRowBar(row, 100, 'done'); asLog.ok(`Edit: ${op.label}`); } } } catch (e) { const cancelled = ctl && ctl.aborted; st.textContent = cancelled ? '⛔' : '❌'; pay.textContent = String(e && e.message || e); setRowBar(row, 100, cancelled ? 'cancel' : 'err'); if (!cancelled) { row.classList.add('err'); op._err = true; logErr(`Edit: ${op.label}`, e); } // #275: flag for the Repeat retry } } // bounded-concurrency map async function pool(items, conc, fn) { let i = 0; const worker = async () => { while (i < items.length) { const k = i++; await fn(items[k]); } }; await Promise.all(Array.from({ length: Math.min(conc, items.length || 1) }, worker)); } const runPool = (ops, conc, ov, meta, ctl) => pool(ops, conc, op => runOp(ov, op, meta, ctl)); // adds: upload to archive.org AND register on MB in PARALLEL, per image. Position is not relied on here // (MB's add `position` only places the whole upload as one group) — the trailing reorder edit is what // establishes the final order, so register order no longer matters. (#362) async function runAdds(ov, addOps, meta, ctl) { if (meta.dry || !addOps.length) return runPool(addOps, meta.dry ? 8 : 1, ov, meta, ctl); const rowOf = op => ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); const setSt = (op, s) => { rowOf(op).querySelector('.as-cm-st').textContent = s; }; const fail = (op, e) => { const row = rowOf(op); row.querySelector('.as-cm-st').textContent = '❌'; row.querySelector('.as-cm-payload').textContent = String(e && e.message || e); row.classList.add('err'); op._err = true; setRowBar(row, 100, 'err'); logErr(`Upload: ${op.label}`, e); }; const stop = (op) => { setSt(op, '⛔'); op._err = true; setRowBar(rowOf(op), 100, 'cancel'); }; addOps.forEach(op => { setSt(op, '⏳'); setRowBar(rowOf(op), null, 'busy'); }); await pool(addOps, 4, async op => { if (ctl && ctl.aborted) return stop(op); // #278: the live upload % drives the per-row bar (was a cramped inline "⏫94%") const sz = (op.it && op.it._fileObj && op.it._fileObj.size) ? ` (${fmtBytes(op.it._fileObj.size)})` : ''; asLog.info(`Upload: ${op.label}${sz} — uploading to archive.org…`); try { await uploadStep(op.it, (l, t) => { setSt(op, '⏫'); setRowBar(rowOf(op), l / t * 100, ''); }, ctl); setSt(op, '⏫'); setRowBar(rowOf(op), 100, ''); asLog.debug(`Upload: ${op.label} — uploaded, registering`); if (ctl && ctl.aborted) return stop(op); await registerStep(op.it, meta, ctl); setSt(op, '✅'); setRowBar(rowOf(op), 100, 'done'); asLog.ok(`Upload: ${op.label} — registered on MusicBrainz ✓`); } catch (e) { (ctl && ctl.aborted) ? stop(op) : fail(op, e); } }); // parallel upload+register w/ progress (abortable via ctl); order fixed by the reorder edit below } async function runPlan(ov, plan, meta, opsToRun) { const goBtn = ov.querySelector('.as-cm-go'), cancelBtn = ov.querySelector('.as-cm-cancel'); goBtn.disabled = true; // #275: `opsToRun` set → Repeat run (just the failed ops). Keep the original // `_i` row mapping; reset each retried row's ❌/error back to pending first. const isRepeat = !!opsToRun; let ops = opsToRun || plan; // #362 register runs in parallel, so ORDER is set by the trailing reorder edit — a repeat that retries // any upload must re-run the reorder too (it re-reads the now-succeeded image ids). Pull the plan's // reorder op into this run if a retried add isn't already accompanied by it. if (isRepeat && ops.some(o => o.kind === 'add')) { const ro = plan.find(o => o.kind === 'reorder'); if (ro && !ops.includes(ro)) ops = ops.concat(ro); } if (!isRepeat) plan.forEach((op, i) => { op._i = i; }); else ops.forEach(op => { op._err = false; const row = ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); if (row) { row.classList.remove('err'); const st = row.querySelector('.as-cm-st'); if (st) st.textContent = '○'; const pl = row.querySelector('.as-cm-payload'); if (pl) pl.textContent = ''; } }); // ctl carries the abort flag + the live xhrs/fetch-signal so Cancel works mid-run const ctl = { aborted: false, xhrs: new Set(), ac: new AbortController() }; // #269 while a live run is in flight: block accidental backdrop dismissal (see // enterEdit) AND warn before the page is unloaded, so edits can't be silently // orphaned by clicking out or navigating away. Both are cleared the moment the // run finishes — the unload guard BEFORE the clean-run auto-reload below, or it // would block its own reload. let unloadGuard = null; if (!meta.dry) { ov._running = true; unloadGuard = e => { e.preventDefault(); e.returnValue = ''; return ''; }; window.addEventListener('beforeunload', unloadGuard); } const endRun = () => { ov._running = false; if (unloadGuard) { window.removeEventListener('beforeunload', unloadGuard); unloadGuard = null; } }; if (meta.dry) { cancelBtn.disabled = true; } else { cancelBtn.disabled = false; cancelBtn.textContent = 'Cancel'; cancelBtn.onclick = () => { // abort in-flight uploads + skip the rest if (ctl.aborted) { ov.remove(); return; } ctl.aborted = true; try { ctl.ac.abort(); } catch (e) {} ctl.xhrs.forEach(x => { try { x.abort(); } catch (e) {} }); cancelBtn.textContent = 'Cancelling…'; cancelBtn.disabled = true; }; } const CONC = meta.dry ? 8 : 4; // modest concurrency live to stay friendly to MB // #278: overall progress — a big batch buries "how much is left" in a long scroll, // so show a header bar + "done / total" that ticks as each op reaches a terminal // state. Polled (not threaded through every status-set site) so the count is robust. const prog = ov.querySelector('.as-cm-prog'); const progFill = prog.querySelector('.as-cm-prog-fill'), progTxt = prog.querySelector('.as-cm-prog-txt'); const tickOverall = () => { let done = 0; for (const op of ops) { const r = ov.querySelector(`.as-cm-op[data-i="${op._i}"]`); const s = r ? r.querySelector('.as-cm-st').textContent : ''; if ('✅👁❌⛔⏭'.includes(s)) done++; } const total = ops.length, pct = total ? Math.round(done / total * 100) : 100; progFill.style.width = pct + '%'; progTxt.textContent = `${done} / ${total} · ${pct}%`; }; prog.hidden = false; tickOverall(); const progTimer = setInterval(tickOverall, 150); if (!meta.dry) asLog.info(`Commit: ${isRepeat ? 'retrying' : 'applying'} ${ops.length} edit${ops.length === 1 ? '' : 's'}${meta.votable ? ' (votable)' : ''}`); // uploads + register run in parallel; edits/removes parallel; the reorder edit runs LAST and sets order. try { await runAdds(ov, ops.filter(o => o.kind === 'add'), meta, ctl); if (!ctl.aborted) await runPool(ops.filter(o => o.kind === 'edit' || o.kind === 'remove'), CONC, ov, meta, ctl); if (!ctl.aborted) await runPool(ops.filter(o => o.kind === 'reorder'), 1, ov, meta, ctl); } finally { clearInterval(progTimer); tickOverall(); // settle the overall bar on the final tally endRun(); // run finished/failed — re-allow backdrop close and drop the unload guard (before any auto-reload) } cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; cancelBtn.onclick = () => ov.remove(); if (ctl.aborted) { // mark any not-yet-started ops as cancelled, leave the modal up ov.querySelectorAll('.as-cm-op').forEach(r => { const s = r.querySelector('.as-cm-st'); if (s.textContent === '○' || s.textContent === '⏳') { s.textContent = '⛔'; setRowBar(r, 100, 'cancel'); } }); tickOverall(); asLog.warn('Commit: cancelled by user'); goBtn.textContent = 'Cancelled'; goBtn.disabled = true; return; } if (!meta.dry) { const b = ov.querySelector('.as-cm-go'); const errs = ov.querySelectorAll('.as-cm-op.err').length; asLog[errs ? 'warn' : 'ok'](`Commit: finished — ${errs ? `${errs} failed of ${ops.length}` : `all ${ops.length} succeeded`}`); if (!errs) { // #234: clean run → reload automatically so the gallery shows the new // state (brief pause so the ✅s are visible first). // #248 on the add page there's nothing to reload INTO — land on the cover-art // tab so the freshly-uploaded covers show in the normal gallery. b.textContent = IS_ADD ? 'Done — opening cover art…' : 'Done — reloading…'; b.disabled = true; b.classList.remove('as-cm-repeat'); // clear the error styling if a repeat just went clean setTimeout(() => { if (IS_ADD) location.href = `${ENT.base}/${ENT.art}`; else location.reload(); }, 900); } else { // #275: something failed — offer to RE-RUN just the failed ops in place // (uploads / comment / type changes are expensive to redo by hand, so // don't force a full reload that throws the staged work away). Reddish to // signal the error state. b.textContent = `Repeat (${errs} failed)`; b.disabled = false; b.classList.add('as-cm-repeat'); b.onclick = () => runPlan(ov, plan, meta, plan.filter(o => o._err)); } } else ov.querySelector('.as-cm-go').disabled = false; } // ── lightbox (#230: click image → popup, ←→↑↓ navigate) ─────────────────────── let _lb = null; // current lightbox image id let _z = { s: 1, x: 0, y: 0 }; // wheel-zoom state (scale + translate) let _pinch = null, _pan = null; // #251 active touch pinch-zoom / one-finger pan function applyZoom(img) { img.style.transform = `translate(${_z.x}px,${_z.y}px) scale(${_z.s})`; img.style.cursor = _z.s > 1 ? (SETTINGS.followPan ? 'crosshair' : 'grab') : ''; } function resetZoom() { _z = { s: 1, x: 0, y: 0 }; const img = document.querySelector('.as-lb-img'); if (img) applyZoom(img); } // keyboard zoom (↑/↓ in the lightbox) — anchored on the image centre, same step as the wheel function zoomKey(dir) { const img = document.querySelector('.as-lb-img'); if (!img) return; const ns = Math.min(8, Math.max(1, _z.s * (dir > 0 ? 1.2 : 1 / 1.2))); if (ns === _z.s) return; const r = ns / _z.s; _z.x *= r; _z.y *= r; _z.s = ns; if (ns === 1) { _z.x = 0; _z.y = 0; } applyZoom(img); } const visible = () => grouped().flatMap(g => g.items); // flat, in displayed order function openLightbox(id) { const it0 = byId(id); if (it0 && it0._pdf) { window.open(it0._img, '_blank', 'noopener'); return; } // PDFs render in a new tab _lb = id; _cursorId = id; _lbEditCmt = false; let ov = document.getElementById('as-lb'); if (!ov) { ov = document.createElement('div'); ov.id = 'as-lb'; ov.innerHTML = `
      `; document.body.appendChild(ov); ov.querySelector('.as-lb-x').onclick = closeLightbox; ov.querySelector('.as-lb-del').onclick = e => { e.stopPropagation(); deleteLbCover(); }; const dlMenu = ov.querySelector('.as-lb-dlmenu'); let _dlJustClosed = false; ov.querySelector('.as-lb-dl').onclick = e => { e.stopPropagation(); dlMenu.classList.remove('open'); const it = byId(_lb); if (it) dlOne(it); }; ov.querySelector('.as-lb-dlcaret').onclick = e => { e.stopPropagation(); dlMenu.classList.toggle('open'); }; dlMenu.querySelectorAll('button').forEach(b => b.onclick = e => { e.stopPropagation(); dlMenu.classList.remove('open'); const it = byId(_lb); if (it) dlOne(it, b.dataset.sz); }); // click anywhere outside the Download control closes its size menu (capture so it // fires regardless of stopPropagation). _dlJustClosed bridges the mousedown→click // gap so a backdrop click that dismisses the menu doesn't also close the viewer. document.addEventListener('mousedown', e => { if (dlMenu.classList.contains('open') && !(e.target.closest && e.target.closest('.as-lb-dlwrap'))) { dlMenu.classList.remove('open'); _dlJustClosed = true; setTimeout(() => { _dlJustClosed = false; }, 0); } }, true); ov.querySelector('.as-lb-play').onclick = e => { e.stopPropagation(); togglePlay(); }; ov.querySelector('.as-lb-prev').onclick = e => { e.stopPropagation(); lbNav(-1); }; ov.querySelector('.as-lb-next').onclick = e => { e.stopPropagation(); lbNav(1); }; // a backdrop click while a type popover is open OR the comment is focused // should dismiss THAT (handled by their own outside-click/blur), not close // the whole viewer — _popJustClosed / _lbJustBlurred bridge the mousedown→click gap ov.onclick = e => { if (e.target === ov && !_popJustClosed && !_lbJustBlurred && !_dlJustClosed && !document.querySelector('.as-pop')) closeLightbox(); }; // wheel zooms the image toward the cursor (instead of scrolling the page behind) ov.addEventListener('wheel', e => { e.preventDefault(); const img = ov.querySelector('.as-lb-img'); const r = img.getBoundingClientRect(); const cx = r.left + r.width / 2 - _z.x, cy = r.top + r.height / 2 - _z.y; // untransformed centre const relx = e.clientX - cx, rely = e.clientY - cy; const ns = Math.min(8, Math.max(1, _z.s * (e.deltaY < 0 ? 1.2 : 1 / 1.2))); if (ns === _z.s) return; _z.x = relx - ns * (relx - _z.x) / _z.s; _z.y = rely - ns * (rely - _z.y) / _z.s; _z.s = ns; if (ns === 1) { _z.x = 0; _z.y = 0; } applyZoom(img); }, { passive: false }); // drag to pan when zoomed (skipped when follow-pan is on — mousemove handles it) ov.querySelector('.as-lb-img').addEventListener('mousedown', e => { if (SETTINGS.followPan || _z.s <= 1) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY, ox = _z.x, oy = _z.y, img = e.currentTarget; const mv = ev => { _z.x = ox + (ev.clientX - sx); _z.y = oy + (ev.clientY - sy); applyZoom(img); }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); // #323 follow-pan: when zoomed, moving the mouse over the image pans the view // (cursor position maps across the image's extent). Default on; only over the // image itself, so the bars/buttons stay usable. ov.addEventListener('mousemove', e => { if (!SETTINGS.followPan || _z.s <= 1) return; const img = ov.querySelector('.as-lb-img'); if (!img || e.target !== img) return; const r = img.getBoundingClientRect(); const w0 = r.width / _z.s, h0 = r.height / _z.s; if (!w0 || !h0) return; const cx = r.left + r.width / 2 - _z.x, cy = r.top + r.height / 2 - _z.y; // pan-invariant centre const clamp = v => v < -1 ? -1 : v > 1 ? 1 : v; const fx = clamp((e.clientX - cx) / (w0 / 2)), fy = clamp((e.clientY - cy) / (h0 / 2)); _z.x = -fx * (_z.s - 1) * (w0 / 2); _z.y = -fy * (_z.s - 1) * (h0 / 2); applyZoom(img); }); // #251 mobile: just the full image — swipe left/right to navigate, swipe down // to close, tap toggles the controls; pinch to zoom, one finger to pan, tap to // reset. (hidden chrome by default on a touch screen.) ov.classList.toggle('as-lb-touch', matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window); let tsx = 0, tsy = 0, tmoved = false, tmulti = false; ov.addEventListener('touchstart', e => { tmulti = e.touches.length > 1; if (tmulti) return; tsx = e.touches[0].clientX; tsy = e.touches[0].clientY; tmoved = false; }, { passive: true }); ov.addEventListener('touchmove', e => { if (tmulti || e.touches.length > 1) { tmulti = true; return; } if (Math.hypot(e.touches[0].clientX - tsx, e.touches[0].clientY - tsy) > 8) tmoved = true; }, { passive: true }); ov.addEventListener('touchend', e => { if (tmulti) return; // a pinch/2-finger gesture, not a swipe if (_z.s > 1) { if (!tmoved && !_pinch) resetZoom(); return; } // zoomed: tap → fit, else pan handled it const t = e.changedTouches[0], dx = t.clientX - tsx, dy = t.clientY - tsy; if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy) * 1.3) { lbNav(dx < 0 ? 1 : -1); return; } if (dy > 80 && dy > Math.abs(dx) * 1.3) { closeLightbox(); return; } if (!tmoved) ov.classList.toggle('as-lb-chrome'); // tap toggles the controls }, { passive: true }); // pinch-zoom toward the pinch midpoint (mirrors the wheel zoom), one-finger pan when zoomed const limg = ov.querySelector('.as-lb-img'); const tdist = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); limg.addEventListener('touchstart', e => { if (e.touches.length === 2) { e.preventDefault(); const m = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; _pinch = { d0: tdist(e.touches[0], e.touches[1]), s0: _z.s, x0: _z.x, y0: _z.y, m }; _pan = null; } else if (e.touches.length === 1 && _z.s > 1) { _pan = { x: e.touches[0].clientX, y: e.touches[0].clientY, x0: _z.x, y0: _z.y }; } }, { passive: false }); limg.addEventListener('touchmove', e => { if (_pinch && e.touches.length === 2) { e.preventDefault(); const ns = Math.min(8, Math.max(1, _pinch.s0 * tdist(e.touches[0], e.touches[1]) / _pinch.d0)); const r = limg.getBoundingClientRect(), cx = r.left + r.width / 2 - _z.x, cy = r.top + r.height / 2 - _z.y; const relx = _pinch.m.x - cx, rely = _pinch.m.y - cy; _z.x = relx - ns * (relx - _pinch.x0) / _pinch.s0; _z.y = rely - ns * (rely - _pinch.y0) / _pinch.s0; _z.s = ns; if (ns === 1) { _z.x = 0; _z.y = 0; } applyZoom(limg); } else if (_pan && e.touches.length === 1 && _z.s > 1) { e.preventDefault(); _z.x = _pan.x0 + (e.touches[0].clientX - _pan.x); _z.y = _pan.y0 + (e.touches[0].clientY - _pan.y); applyZoom(limg); } }, { passive: false }); limg.addEventListener('touchend', e => { if (e.touches.length < 2) _pinch = null; if (e.touches.length === 0) _pan = null; }, { passive: false }); } resetZoom(); // a fresh open starts un-zoomed; ←/→ navigation keeps the zoom ov.classList.remove('as-lb-chrome'); // #251 touch: start as just-the-image, tap to reveal controls paintLightbox(); preloadNeighbors(); ov.style.display = 'flex'; } // prefetch the adjacent covers' 1200px so arrow-nav is instant const _preloaded = new Set(); function preloadNeighbors() { const seq = visible().filter(it => !it._pdf); const i = seq.findIndex(it => String(it.id) === String(_lb)); if (i < 0 || !seq.length) return; [1, 2, -1, -2].forEach(d => { // prefetch 2 covers each way so nav stays instant const it = seq[(i + d + seq.length) % seq.length]; if (it && !it._new && !_preloaded.has(it.id)) { _preloaded.add(it.id); const im = new Image(); im.src = thumb(it.id, 1200); } }); } function paintLightbox() { const ov = document.getElementById('as-lb'); if (!ov) return; const it = byId(_lb); if (!it) return; const img = ov.querySelector('.as-lb-img'); const src = it._new ? it._file : thumb(it.id, 1200); // NOTE: no resetZoom here — the zoom level is kept while navigating ←/→ (#234); // it's reset only on a fresh open (openLightbox) and on close. ov.classList.remove('na'); // hide until the NEW src has decoded — otherwise the previous image lingers // visibly while the 1200px loads ("original shows shortly") img.classList.add('loading'); img.onload = () => { img.classList.remove('loading'); ov.classList.remove('na'); }; img.onerror = () => { // thumbnails aren't generated yet (pending / just uploaded) — show the original, // which MB serves before the thumbs exist (its "original" link). #230 if (!it._new && it._img && img.src !== it._img) { img.src = it._img; return; } img.classList.remove('loading'); ov.classList.add('na'); }; img.src = src; if (img.complete && img.naturalWidth) img.classList.remove('loading'); const dims = [it.bytes ? fmtSize(it.bytes) : '', it.w && it.h ? `${it.w} × ${it.h}` : ''].filter(Boolean).join(' · '); // #380-followup show file size in full screen too, like the card const cap = ov.querySelector('.as-lb-cap'); // type is a clickable chip (same picker as the grid pills) so it can be set full-screen cap.innerHTML = `${dims ? `${esc(dims)}` : ''}`; cap.querySelector('.as-lb-type').onclick = e => { e.stopPropagation(); openTypePopFor(byId(_lb), e.currentTarget, () => { _lbDirty = true; paintLightbox(); }); }; paintCmtArea(ov, it); } let _lbEditCmt = false; let _lbJustBlurred = false; // bridges the mousedown-blur → click gap so defocusing the comment doesn't also close the viewer function paintCmtArea(ov, it) { const area = ov.querySelector('.as-lb-cmtarea'); if (!area) return; if (_lbEditCmt) { area.innerHTML = ``; const inp = area.querySelector('.as-lb-cmt'); inp.value = it.comment || ''; inp.oninput = () => { const cur = byId(_lb); if (cur) { cur.comment = inp.value; _lbDirty = true; } }; inp.onblur = () => { _lbEditCmt = false; _lbJustBlurred = true; setTimeout(() => { _lbJustBlurred = false; }, 0); paintCmtArea(ov, byId(_lb)); }; // Enter saves and advances to the next image, keeping its comment open for editing. inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); inp.blur(); return; } if (e.key !== 'Enter') return; e.preventDefault(); const cur = byId(_lb); if (cur) { cur.comment = inp.value; _lbDirty = true; } inp.onblur = null; // we drive the transition — don't let the stale blur cancel edit mode lbNav(1, true); }; inp.focus(); } else if (it.comment) { // not editing: show the comment as plain centered text (no input box), like the gallery area.innerHTML = `
      ${esc(it.comment)}
      `; area.querySelector('.as-lb-cmt-text').onclick = () => { _lbEditCmt = true; paintCmtArea(ov, byId(_lb)); }; } else { area.innerHTML = ``; area.querySelector('.as-lb-cmtadd').onclick = () => { _lbEditCmt = true; paintCmtArea(ov, byId(_lb)); }; } } let _lbDirty = false; function closeLightbox() { stopPlay(); resetZoom(); _lb = null; const ov = document.getElementById('as-lb'); if (ov) ov.style.display = 'none'; const dm = ov && ov.querySelector('.as-lb-dlmenu'); if (dm) dm.classList.remove('open'); // don't reopen with the menu still showing if (_lbDirty) { _lbDirty = false; render(); } // reflect comment edits in the grid } let _play = null; function updatePlayBtn() { const b = document.querySelector('.as-lb-play'); if (b) b.textContent = _play ? '⏸ Pause' : '▶ Play'; } function stopPlay() { if (_play) { clearInterval(_play); _play = null; updatePlayBtn(); } } function togglePlay() { if (_play) stopPlay(); else { _play = setInterval(() => lbNav(1), 3000); updatePlayBtn(); } } function lbNav(d, keepEdit) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); // a dangling type pop must not survive a cover change (incl. slideshow) const seq = visible().filter(it => !it._pdf); // PDFs open in a tab, not the lightbox if (!seq.length) return; let i = seq.findIndex(it => String(it.id) === String(_lb)); if (i < 0) i = 0; i = (i + d + seq.length) % seq.length; // keepEdit (Enter in the comment field) carries edit-mode to the next image _lb = seq[i].id; _cursorId = _lb; _lbEditCmt = !!keepEdit; paintLightbox(); markCursor(true); preloadNeighbors(); } // Del in full-screen view → mark the current cover for removal (same as the grid's // delete: _del moves it to "Marked for removal", undoable there), then advance to // the next cover — or close the lightbox if that was the last one. function deleteLbCover() { const it = byId(_lb); if (!it) return; const seq = visible().filter(x => !x._pdf); const i = seq.findIndex(x => String(x.id) === String(_lb)); it._del = true; it._sel = false; _lbDirty = true; toast(`“${(it.types && it.types[0]) || ITEM}” marked for removal — undo in the grid`); const rest = visible().filter(x => !x._pdf); // recomputed without the just-deleted cover if (!rest.length) { closeLightbox(); return; } const nx = rest[Math.min(i, rest.length - 1)]; resetZoom(); _lb = nx.id; _cursorId = nx.id; _lbEditCmt = false; paintLightbox(); markCursor(true); preloadNeighbors(); } // ── keyboard cursor (arrows select / move; Enter opens lightbox) ────────────── let _cursorId = null; function markCursor(scroll) { root.querySelectorAll('.as-card.as-cursor').forEach(c => c.classList.remove('as-cursor')); if (!_cursorId) return; const c = root.querySelector(`.as-card[data-id="${CSS.escape(String(_cursorId))}"]`); if (c) { c.classList.add('as-cursor'); if (scroll) c.scrollIntoView({ block: 'nearest' }); } } function moveCursor(dx, dy) { const cards = [...root.querySelectorAll('.as-card:not(.del)')]; if (!cards.length) return; let cur = cards.find(c => c.dataset.id === String(_cursorId)) || cards[0]; if (!_cursorId) { _cursorId = cur.dataset.id; markCursor(true); return; } const r0 = cur.getBoundingClientRect(); let best = null, bestD = Infinity; for (const c of cards) { if (c === cur) continue; const r = c.getBoundingClientRect(); const ddx = (r.left + r.width / 2) - (r0.left + r0.width / 2); const ddy = (r.top + r.height / 2) - (r0.top + r0.height / 2); if (dx > 0 && ddx <= 4) continue; if (dx < 0 && ddx >= -4) continue; if (dy > 0 && ddy <= 4) continue; if (dy < 0 && ddy >= -4) continue; // penalise off-axis drift so motion stays mostly in the requested direction const d = (dx ? Math.abs(ddx) + Math.abs(ddy) * 3 : Math.abs(ddy) + Math.abs(ddx) * 3); if (d < bestD) { bestD = d; best = c; } } if (best) { _cursorId = best.dataset.id; markCursor(true); } } document.addEventListener('keydown', e => { const t = e.target; // a popover (type picker / bulk pop) is open → Escape dismisses IT first // (wherever focus is), and other keys are swallowed so navigation/zoom/delete // don't act behind it. Mirrors the backdrop-click behaviour. if (document.querySelector('.as-pop')) { if (e.key === 'Escape') { e.preventDefault(); document.querySelectorAll('.as-pop').forEach(p => p.remove()); } else if (!(t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) e.preventDefault(); return; } if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return; if (_lb) { if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); lbNav(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); lbNav(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); zoomKey(1); } // ↑ zoom in else if (e.key === 'ArrowDown') { e.preventDefault(); zoomKey(-1); } // ↓ zoom out else if (e.key === 'p' || e.key === 'P') { e.preventDefault(); togglePlay(); } else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); deleteLbCover(); } else if (e.key === 'Enter') { e.preventDefault(); _lbEditCmt = true; paintCmtArea(document.getElementById('as-lb'), byId(_lb)); } // start editing the comment else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); const it = byId(_lb); if (it) dlOne(it); } // download original return; } if (!root.isConnected || !root.querySelector('.as-card')) return; const map = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; if (map[e.key]) { e.preventDefault(); moveCursor(map[e.key][0], map[e.key][1]); } else if (e.key === 'Enter' && _cursorId) { e.preventDefault(); openLightbox(_cursorId); } else if (e.key === ' ' && _cursorId) { e.preventDefault(); const it = byId(_cursorId); if (it && !it._del) { it._sel = !it._sel; render(); } } else if ((e.key === 'Delete' || e.key === 'Backspace') && _cursorId) { // mark the focused cover for removal (mirrors the viewer's Del) e.preventDefault(); const it = byId(_cursorId); if (!it || it._del) return; const cards = [...root.querySelectorAll('.as-card:not(.del)')]; const i = cards.findIndex(c => c.dataset.id === String(_cursorId)); it._del = true; it._sel = false; // advance the cursor to the next remaining cover so Del can be pressed repeatedly const rest = cards.filter(c => c.dataset.id !== String(_cursorId)); _cursorId = rest.length ? rest[Math.min(i, rest.length - 1)].dataset.id : null; render(); } }); function openBulkTypePop(btn) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const sel = MODEL.filter(it => it._sel && !it._del); if (!sel.length) return; const pop = document.createElement('div'); pop.className = 'as-pop'; pop.innerHTML = `
      Set type on ${sel.length} ${ITEM}${sel.length===1?'':'s'}
      ` + `
      ${ALL_TYPES.map(t => ``).join('')}
      ` + `
      `; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); const picked = () => ALL_TYPES.filter(t => pop.querySelector(`input[value="${CSS.escape(t)}"]`).checked); const lbl = n => `${n} ${n === 1 ? ITEM : ITEMS}`; const replace = ts => { sel.forEach(it => it.types = ts.slice()); asLog.info(`Batch: set type [${ts.join(', ') || 'none'}] on ${lbl(sel.length)}`); if (SETTINGS.clearSelAfterOp) sel.forEach(it => it._sel = false); pop.remove(); render(); }; // #277 pop.querySelector('.as-pop-apply').onclick = () => replace(picked()); pop.querySelector('.as-pop-add').onclick = () => { const ts = picked(); sel.forEach(it => it.types = [...new Set([...it.types, ...ts])]); asLog.info(`Batch: added type [${ts.join(', ') || 'none'}] to ${lbl(sel.length)}`); if (SETTINGS.clearSelAfterOp) sel.forEach(it => it._sel = false); pop.remove(); render(); }; // #277 // right-click a type → replace all selected with ONLY that type and close pop.querySelectorAll('.as-type-grid label').forEach(lab => lab.oncontextmenu = e => { e.preventDefault(); e.stopPropagation(); document.removeEventListener('mousedown', off); replace([lab.querySelector('input').value]); }); const off = e => { if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // #236: set one comment on every selected cover at once. Pre-fills the shared // comment if they already agree; Apply writes it, Clear blanks them all. // Reverse-image search one cover for a higher-res copy. Per-card (the 🔍 on each // tile) so it's unambiguously one image; a still-local NEW file has no public URL // to search by. Each engine opens pre-loaded with that URL — no download + drop. function openImageSearchPop(btn, it) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const url = it && searchUrlFor(it); // CAA original, or a sourced new cover's provider URL if (!url) { toast(it && it._new ? 'Reverse search needs a public image URL — this one is a local file (source it from a URL instead)' : 'Reverse search needs a published image'); return; } const pop = document.createElement('div'); pop.className = 'as-pop as-search-pop'; // latest (main-branch) install link for the companion — never a feature branch const COMPANION_URL = 'https://raw.githubusercontent.com/majkinetor/musicbrainz-userscripts/main/userscripts/art_station/as_picker/as_picker.user.js'; pop.innerHTML = `
      Search for a higher-res copy
      ` + IMG_SEARCH_ENGINES.map((e, i) => ``).join('') + `
      Requires the Art Station Picker script for click-capture.
      `; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); // mb_as_pick= activates the picker companion (as_picker.user.js, if installed): it // lets you click the higher-res image anywhere and sends it back here. The value is THIS // release's MBID so the companion tags each pick with it — a pick stays destined for this // release even if this tab is closed, so it can't leak onto some other release's cover-art // page. Harmless if the companion isn't installed (engines ignore the unknown query param). // NB: a *query* param, not a #hash — Yandex's SPA blanks on an unexpected hash, not a param. const openEng = eng => { asLog.info(`Search: ${eng.name} ← ${it.types && it.types.length ? it.types.join('/') : ITEM} ${it.id}`); window.open(eng.u(url) + '&mb_as_pick=' + MBID, '_blank', 'noopener,noreferrer'); }; pop.querySelectorAll('.as-search-eng').forEach(b => b.onclick = () => { openEng(IMG_SEARCH_ENGINES[+b.dataset.i]); pop.remove(); }); const allBtn = pop.querySelector('.as-search-all'); if (allBtn) allBtn.onclick = () => { IMG_SEARCH_ENGINES.forEach(openEng); pop.remove(); }; // each in its own tab (the browser may ask to allow multiple popups the first time) const off = e => { if (!pop.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } function openBulkCommentPop(btn) { document.querySelectorAll('.as-pop').forEach(p => p.remove()); const sel = MODEL.filter(it => it._sel && !it._del); if (!sel.length) return; const common = sel.every(it => it.comment === sel[0].comment) ? sel[0].comment : ''; const pop = document.createElement('div'); pop.className = 'as-pop as-cmt-pop'; pop.innerHTML = `
      Comment on ${sel.length} ${ITEM}${sel.length===1?'':'s'}
      ` + `` + `
      `; document.body.appendChild(pop); placePop(pop, btn.getBoundingClientRect()); const inp = pop.querySelector('.as-bulk-cmt'); inp.focus(); inp.select(); const apply = v => { sel.forEach(it => it.comment = v); asLog.info(`Batch: ${v ? `set comment “${v}”` : 'cleared comment'} on ${sel.length} ${sel.length === 1 ? ITEM : ITEMS}`); if (SETTINGS.clearSelAfterOp) sel.forEach(it => it._sel = false); pop.remove(); render(); }; // #277 pop.querySelector('.as-pop-apply').onclick = () => apply(inp.value); pop.querySelector('.as-bulk-cmt-clr').onclick = () => apply(''); inp.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); apply(inp.value); } else if (e.key === 'Escape') { e.preventDefault(); pop.remove(); } }; const off = e => { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // ── #239 postable report (Markdown / HTML) of the selected covers ───────────── // release artist(s) + title, linked, parsed from the page header function releaseInfo() { const title = (document.querySelector('h1 bdi') || document.querySelector('h1'))?.textContent?.trim() || 'release'; const sub = document.querySelector('p.subheader') || document.querySelector('.subheader'); const artists = sub ? [...sub.querySelectorAll('a[href*="/artist/"]')] .filter(a => !/\/create(\?|$)/.test(a.getAttribute('href'))) .map(a => ({ name: a.textContent.trim(), url: 'https://musicbrainz.org' + a.getAttribute('href').split(/[?#]/)[0] })) : []; return { title, url: `https://musicbrainz.org${ENT.base}`, artists }; } const pad2 = n => String(n).padStart(2, '0'); // the logged-in MB user, for the export manifest ("Exported by"). Read the name from // the /user/ href — the link text may be a label like "Profile". function mbUser() { for (const a of document.querySelectorAll('a[href^="/user/"]')) { const m = (a.getAttribute('href') || '').match(/^\/user\/([^/?#]+)\/?$/); if (m) return decodeURIComponent(m[1]); } return ''; } // #244 a README.md / manifest for the download archive (and a Report type): release // header, export metadata, and the artwork list linking each type-named file to its original. // shared rows for the "Detailed table" layout / archive manifest function manifestRows(sel) { const ord = sel.slice().sort((a, b) => a.order - b.order); const pad = Math.max(2, String(Math.max(0, ...ord.map(it => it.order + 1))).length); return ord.map(it => ({ pos: pad2(it.order + 1), name: downloadName(it, it.order + 1, dlExt(it), pad).replace(/^\d+\s+/, ''), // drop the position — the Position column already has it orig: dlUrl(it), res: (it.w && it.h) ? `${it.w} × ${it.h}` : '', size: it.bytes ? fmtSize(it.bytes) : '', })); } function manifestHead() { const info = releaseInfo(); const d = new Date(), date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; return { info, date, by: mbUser() }; } // #244 markdown manifest (README.md in the archive + the Markdown "Detailed table" report) function manifestMd(sel, failed) { const { info, date, by } = manifestHead(); const artists = info.artists.length ? info.artists.map(a => `[${a.name}](${a.url})`).join(', ') : 'Unknown artist'; const out = [`# ${artists} - [${info.title}](${info.url})`, '', `- **Export date:** ${date}`]; if (by) out.push(`- **Exported by:** ${by}`); // #274: if any cover couldn't be downloaded, flag it loudly at the top of the // manifest so an incomplete archive is never mistaken for a complete one. if (failed && failed.length) { out.push('', `> ⚠ **${failed.length} file${failed.length === 1 ? '' : 's'} could not be downloaded** and ${failed.length === 1 ? 'is' : 'are'} **missing** from this archive — re-download to get ${failed.length === 1 ? 'it' : 'them'}:`, '', ...failed.map(f => `> - ${f}`)); } out.push('', '## Artwork', '', '| Position | Cover | Resolution | Size |', '| --- | --- | --- | --- |'); manifestRows(sel).forEach(r => out.push(`| ${r.pos} | [${r.name}](${r.orig}) | ${r.res} | ${r.size} |`)); out.push('', `*Report created with [Art Station](${SCRIPT_URL})${_gm ? ' v' + _gm.version : ''}*`); return out.join('\n') + '\n'; } function manifestHtml(sel) { const { info, date, by } = manifestHead(); const artists = info.artists.length ? info.artists.map(a => `${esc(a.name)}`).join(', ') : 'Unknown artist'; const out = [`

      ${artists} - ${esc(info.title)}

      `, `

      Export date: ${date}${by ? `
      Exported by: ${esc(by)}` : ''}

      `, '']; manifestRows(sel).forEach(r => out.push(``)); out.push('
      PositionCoverResolutionSize
      ${r.pos}${esc(r.name)}${esc(r.res)}${esc(r.size)}
      ', `

      Report created with Art Station${_gm ? ' v' + esc(_gm.version) : ''}

      `); return out.join('\n'); } // ensure resolution (loads originals) + byte sizes are known before a manifest is built async function ensureMeasured(sel) { await loadSizes(); await pool(sel.filter(it => !it.w && !it._pdf), 4, it => new Promise(res => { const src = it._new ? it._file : imgUrl(it.id); if (!src) return res(); const img = new Image(); img.onload = () => { it.w = img.naturalWidth; it.h = img.naturalHeight; res(); }; img.onerror = () => res(); img.src = src; })); } function buildReport(opts) { const info = releaseInfo(); const sel = MODEL.filter(it => it._sel && !it._del && !it._new).slice().sort(sortFn); if (opts.layout === 'detailed') return opts.format === 'html' ? manifestHtml(sel) : manifestMd(sel); // #244 table w/ position, type-name, resolution, size const sz = opts.size, W = sz === 'original' ? null : sz; const url = it => `${CAA}/${it.id}${sz === 'original' ? '' : '-' + sz}.jpg`; const alt = it => (it.types[0] || (IS_EVENT ? 'event art' : ITEM)).toLowerCase(); const cap = it => [it.types.join(', ') || 'no type', it.comment].filter(Boolean).join(' — '); const out = []; if (opts.format === 'html') { const artists = info.artists.length ? info.artists.map(a => `${esc(a.name)}`).join(', ') : 'Unknown artist'; out.push(`

      ${artists} - ${esc(info.title)}

      `, ''); if (opts.layout === 'captioned') sel.forEach(it => out.push(`

      ${esc(alt(it))}
      ${esc(cap(it))}

      `)); else out.push(sel.map(it => `${esc(alt(it))}`).join(' ')); } else { const artists = info.artists.length ? info.artists.map(a => `[${a.name}](${a.url})`).join(', ') : 'Unknown artist'; out.push(`### ${artists} - [${info.title}](${info.url})`, ''); if (opts.layout === 'captioned') sel.forEach(it => out.push(`**${cap(it)}** `, `![${alt(it)}](${url(it)})`, '')); else out.push(sel.map(it => `![${alt(it)}](${url(it)})`).join(' ')); } return out.join('\n'); } function openReport() { const sel = MODEL.filter(it => it._sel && !it._del && !it._new); const omitted = MODEL.filter(it => it._sel && it._new && !it._del && !it._sourcing).length; document.getElementById('as-report')?.remove(); const ov = document.createElement('div'); ov.id = 'as-report'; ov.innerHTML = `
      Report — ${sel.length} ${ITEM}${sel.length===1?'':'s'}
      ${omitted ? `${omitted} unsaved upload${omitted===1?'':'s'} omitted (no CAA URL yet)` : ''}
      `; document.body.appendChild(ov); const close = () => { ov.remove(); maybeClearSel(); }; // #277: report counts as "using" the selection → clear on close ov.onclick = e => { if (e.target === ov) close(); }; ov.querySelector('.as-cm-cancel').onclick = close; const ta = ov.querySelector('.as-rp-out'); const regen = async () => { const opts = { format: ov.querySelector('.as-rp-fmt').value, size: ov.querySelector('.as-rp-size').value, layout: ov.querySelector('.as-rp-layout').value }; if (opts.layout === 'detailed') { ta.value = 'Measuring resolutions…'; await ensureMeasured(sel); } // load dimensions for the table ta.value = buildReport(opts); }; ov.querySelectorAll('select').forEach(s => s.onchange = regen); ov.querySelector('.as-rp-copy').onclick = async () => { ta.select(); try { await navigator.clipboard.writeText(ta.value); } catch (e) { document.execCommand('copy'); } asLog.info(`Batch: copied ${ov.querySelector('.as-rp-fmt').value} report — ${sel.length} ${sel.length === 1 ? ITEM : ITEMS} (${ov.querySelector('.as-rp-layout').value})`); const b = ov.querySelector('.as-rp-copy'); b.textContent = '✓ Copied'; setTimeout(() => { b.textContent = '📋 Copy'; }, 1200); }; regen(); } // ── styles ─────────────────────────────────────────────────────────────────── const css = ` :root{ --as-tile:${SETTINGS.tile}px; --as-acc:#5f3ec0; --as-warn:#c0392b; } /* the sticky toolbar grows a line when a selection adds the bulk-action buttons; scroll-anchoring would then nudge the page on every right-click select. Disable anchoring so the scrollbar stays put (Art Station owns this page's scroll). */ html{overflow-anchor:none} #as-root{font:14px/1.4 -apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#222;margin:0 0 18px} .as-ia{margin:0 0 8px;padding:9px 13px;border-radius:8px;font-size:13px;line-height:1.45;border:1px solid} .as-ia-warn{background:#fff7e6;border-color:#f0c877;color:#7a5200} .as-ia-dark{background:#fdecec;border-color:#e2a1a1;color:#8a1f1f;font-weight:600} .as-darkened .as-add,.as-darkened .as-mh,.as-darkened .as-src,.as-darkened .as-commit,.as-darkened .as-tbtn,.as-darkened .as-bk-rm{opacity:.4;pointer-events:none} .as-bar{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:8px 11px;padding:8px 12px;background:#fff;border:1px solid #e2dcef;border-radius:9px;box-shadow:0 1px 5px rgba(60,40,110,.07);flex-wrap:wrap;margin-bottom:6px} .as-setup-ic{width:30px;height:30px;object-fit:contain;flex:0 0 auto} .as-bar>*{flex:0 0 auto} /* "Original" (Apollo-style switch): hide the whole Art Station UI, MB's native page shows through */ #as-root.as-orig{display:none} /* one unified pill like Apollo's launcher: label segment + a divider + the gear */ #as-switch-wrap{position:fixed;bottom:14px;right:14px;z-index:99998;display:inline-flex;align-items:stretch;background:var(--as-acc);color:#fff;border-radius:20px;font:bold 13px Arial;box-shadow:0 3px 12px rgba(40,20,80,.3);overflow:hidden} #as-switch{padding:8px 14px;cursor:pointer;background:none;border:none;color:inherit;font:inherit} #as-switch:hover{background:rgba(255,255,255,.13)} #as-setup-btn{padding:8px 12px;cursor:pointer;font-size:14px;display:flex;align-items:center;background:none;border:none;border-left:1px solid rgba(255,255,255,.28);color:inherit} #as-setup-btn:hover{background:rgba(255,255,255,.13)} #as-setup{position:fixed;bottom:58px;right:14px;z-index:99999;width:max-content;min-width:320px;max-width:92vw;background:#fff;border:1px solid #cbbdf0;border-radius:10px;box-shadow:0 8px 28px rgba(40,20,80,.32);font:13px Arial;color:#222} .as-setup-h{display:flex;align-items:center;gap:7px;padding:10px 12px;border-bottom:1px solid #ece6f8;color:#563b8f} .as-setup-ver{font-size:11px;font-weight:normal;color:#999} .as-setup-help{font-size:12px;text-decoration:none;color:#5f3ec0;border:1px solid #c9b8ee;border-radius:4px;padding:1px 8px} .as-setup-help:hover{background:#f0ecfa} .as-setup-logbtn{margin-left:auto;font-size:12px;color:#5f3ec0;background:none;border:1px solid transparent;border-radius:4px;padding:1px 8px;cursor:pointer;font-family:inherit} .as-setup-logbtn:hover{background:#f3eefb;border-color:#c9b8ee} /* #283 activity-log popup */ /* floating, movable, NON-modal window (no backdrop) */ #as-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:100020;display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:#fff;border:1px solid #cbbdf0;border-radius:11px;box-shadow:0 12px 40px rgba(40,20,80,.4);font:13px Arial;color:#222;overflow:hidden} .as-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid #ece6f8;color:#563b8f;cursor:move;user-select:none} #as-logpop .as-log-m a{color:#5f3ec0} .as-logpop-sp{margin-left:auto} .as-logpop-copy,.as-logpop-x,.as-logpop-min{font-size:12px;color:#5f3ec0;background:#f3eefb;border:1px solid #c9b8ee;border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit} .as-logpop-copy:hover,.as-logpop-x:hover,.as-logpop-min:hover{background:#e9e0f8} #as-logpop.min .as-log-list,#as-logpop.min .as-logpop-copy,#as-logpop.min .as-logpop-x{display:none} #as-logpop.min{max-height:none;width:auto} #as-logpop.min .as-logpop-sp{display:none} .as-log-badge{color:#9a8cba;font-size:11px} .as-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;background:#faf8fe;padding:8px 11px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.55} .as-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word} .as-log-t{color:#a99fc2;flex:0 0 auto} .as-log-m{flex:1 1 auto;color:#444} .as-log-ok .as-log-m{color:#2e7d4f} .as-log-warn .as-log-m{color:#b06a00} .as-log-error .as-log-m{color:#c0344d} .as-log-debug{opacity:.72} .as-log-debug .as-log-m{color:#7a7a7a} .as-log-empty{color:#9a8cba} .as-setup-x{border:none;background:none;color:#999;font-size:14px;cursor:pointer;padding:0 2px} .as-setup-x:hover{color:#555} .as-setup-body{padding:11px 12px;display:flex;flex-direction:column;gap:11px} /* #262 a bit more breathing room between options */ .as-setup-info{margin:0 0 10px;color:#666;font-size:12px;line-height:1.45} .as-setup-opt{display:flex;gap:8px;align-items:center;cursor:pointer;white-space:nowrap;line-height:1.4} .as-setup-opt input{margin:0} .as-setup-optlbl{display:inline-flex;gap:8px;align-items:center;cursor:pointer} /* #262 label wraps only checkbox+text so the mode select stays independent */ .as-setup-autofront-mode{font-size:12px;padding:1px 3px} .as-ctl{display:flex;align-items:center;gap:6px;font-size:13px;color:#555;white-space:nowrap} .as-size{accent-color:var(--as-acc);flex:0 1 130px;min-width:54px} #as-root select,.as-btn{font:13px inherit;border:1px solid #cfc6e6;background:#fff;border-radius:6px;padding:4px 9px;color:#333;cursor:pointer;white-space:nowrap} .as-btn{display:inline-flex;align-items:center;gap:5px} /* #234: compact toolbar — hide button labels (keep icons + tooltips) when it would otherwise wrap */ .as-bar.as-compact .as-bt{display:none} .as-btn:hover{background:#f6f3fd} /* accent (white-on-purple) buttons must darken on hover, not lighten — else the white text vanishes */ .as-commit:hover:not(:disabled),.as-pop-apply:hover:not(:disabled),.as-cm-go:hover:not(:disabled){background:#4e329f;color:#fff;border-color:#4e329f} /* #275: the Repeat (failed) button is red to signal the error state */ .as-cm-go.as-cm-repeat{background:#d9463f;border-color:#b53a34;color:#fff} .as-cm-go.as-cm-repeat:hover:not(:disabled){background:#c43c36;border-color:#a5342f} .as-add{font-weight:600;color:var(--as-acc)} .as-mh{padding:3px 7px} .as-mh-ic{display:block;background:#80a32b;padding:2px;border-radius:5px;width:14px;height:14px} /* green chip so the white MH icon shows; sized so it doesn't out-tall the text buttons */ #as-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:99999;background:#3b2c70;color:#fff;padding:10px 16px;border-radius:9px;font:13px/1.35 -apple-system,Segoe UI,Roboto,Arial,sans-serif;box-shadow:0 6px 22px rgba(40,20,80,.35);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center} .as-asback{font-weight:700;color:var(--as-acc);background:#f3eefe;border-color:#cdbff2} .as-dl{border-color:#bcd;color:#2a6} .as-sp{flex:1 1 auto} .as-commit{margin-left:auto} /* push Enter edit to the far right of the toolbar */ .as-staged{font-size:12px;color:#a05a00;background:#fff3d6;border-color:#ecd9a0;white-space:nowrap} .as-staged:hover{background:#ffeec0} .as-op{padding:4px 8px;border-radius:5px;font-size:12.5px;color:#333;white-space:nowrap} .as-op:hover{background:#f3eefe} .as-staged-pop{min-width:230px;max-width:420px} .as-dropzone{border:2px dashed #b7a4ee;border-radius:11px;background:#f7f4ff;padding:24px;text-align:center;cursor:pointer;margin-bottom:12px;transition:.1s} .as-dropzone.over{background:#ece4ff;border-color:var(--as-acc)} .as-dz-in{font-weight:600;font-size:15px;color:#6a5b95;display:flex;flex-direction:column;gap:4px} .as-dz-in span{font-weight:400;font-size:12px;color:#9a8ccb} .as-commit{background:var(--as-acc);color:#fff;border-color:var(--as-acc);font-weight:600} .as-commit:disabled{opacity:.45;cursor:default} .as-sec{margin:14px 0 4px;display:flex;align-items:center;gap:8px} .as-sec h3{margin:0;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:#6a5b95} .as-sec-del h3{color:var(--as-warn)} .as-cnt{font-size:12px;color:#9b8fc0} .as-line{flex:1;height:1px;background:#e2dcef} .as-grid{display:flex;flex-wrap:wrap;gap:24px 14px} /* #238 Detailed view: list rows — image + id on the left, all types & full comment on the right */ .as-dlist{display:flex;flex-direction:column;gap:10px} .as-drow{display:flex;gap:16px;align-items:flex-start;border:1px solid #e2dcef;border-radius:9px;padding:10px 12px;background:#fff;position:relative} .as-drow.new{background:repeating-linear-gradient(45deg,#eef7f1,#eef7f1 11px,#e2f0e8 11px,#e2f0e8 22px);border-color:#9bd3b6;border-style:dashed} .as-drow.pending{background:#fdf3d0;border-color:#e6cf86} .as-drow.sel{outline:2px solid var(--as-acc);outline-offset:-1px;background:#f1ecff;box-shadow:inset 4px 0 0 var(--as-acc)} .as-dsel{flex:0 0 auto;width:18px;height:18px;margin:4px 2px 0 2px;accent-color:var(--as-acc);cursor:pointer} .as-dleft{flex:0 0 auto;width:128px;text-align:center} .as-dthumb{position:relative;width:128px;height:128px;border-radius:7px;overflow:hidden;background:#f4f2f9;cursor:zoom-in;display:block} .as-dthumb img{width:100%;height:100%;object-fit:contain;display:block} .as-dthumb.na img{display:none} .as-dthumb.na::after{content:'not on CAA yet';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#9a8ccb;font-size:11px;font-weight:600;text-align:center;padding:0 8px} .as-dthumb.na.as-na-new::after{content:'preview unavailable'} .as-dcap{font-size:11px;font-weight:600;color:#6b5fa0;margin-top:5px;white-space:nowrap} .as-did{font-size:11px;color:#a99fc4;font-variant-numeric:tabular-nums;word-break:break-all;line-height:1.3;margin-top:1px} .as-dmeta{flex:1 1 auto;min-width:0} .as-dlbl{font-weight:700;color:#6a5b95;font-size:12px;margin:0 0 4px} /* a marked-for-removal detail row: greyed image + a "keep" (undo) button, no editing */ .as-drow.del{opacity:.75;background:#fdf6f5;border-color:#eccfca} .as-drow.del .as-dthumb img{filter:grayscale(1) brightness(.85)} .as-dsel-x{flex:0 0 auto;width:18px;text-align:center;color:#c0392b;font-weight:700;margin-top:2px} .as-dmeta-del{display:flex;align-items:center;justify-content:space-between;gap:12px} .as-ddel-lbl{color:#8a5a52;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .as-dtypes{display:grid;grid-template-columns:repeat(auto-fill,minmax(108px,1fr));gap:3px 12px;margin:0 0 10px} .as-dtypes label{display:flex;align-items:center;gap:6px;font-size:13px;color:#333;cursor:pointer} .as-dtypes input{accent-color:var(--as-acc)} .as-dcmt{width:100%;box-sizing:border-box;font:13px inherit;border:1px solid #cfc6e6;border-radius:6px;padding:6px 9px;background:#faf9fe;color:#333} .as-dcmt:focus{outline:2px solid var(--as-acc);outline-offset:-1px;background:#fff} /* compact group rows: label column + cards beside it */ .as-grow{display:flex;align-items:flex-start;gap:16px;padding:12px 0;border-top:1px solid #ece7f6} .as-grow:first-of-type{border-top:none} .as-glabel{flex:0 0 104px;position:sticky;top:58px;padding-top:2px} .as-gl-name{display:block;font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#6a5b95;word-break:break-word} .as-gl-cnt{font-size:11px;color:#9b8fc0} .as-grow .as-grid{flex:1} .as-card{width:var(--as-tile);background:#fff;border:1px solid #e2dcef;border-radius:9px;overflow:visible;position:relative;transition:.1s} .as-card[draggable=true]{cursor:grab} .as-card:hover{box-shadow:0 3px 12px rgba(60,40,110,.15);border-color:#cbbdf0} .as-card.as-dragging{opacity:.4} .as-card.as-drop{outline:2px dashed var(--as-acc);outline-offset:-2px} .as-card.del .as-thumb img{filter:grayscale(1) brightness(.82)} .as-card.del{opacity:.7} .as-sec-new h3{color:#1f9d6b} .as-card.new{background:repeating-linear-gradient(45deg,#eef7f1,#eef7f1 11px,#e2f0e8 11px,#e2f0e8 22px);border-color:#9bd3b6;border-style:dashed} .as-card.pending{background:#fdf3d0;border-color:#e6cf86} .as-card.as-sourcing{border-style:dashed} .as-srcing-thumb{width:100%;aspect-ratio:1;border-radius:9px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:11px;color:#3b8f6b} .as-spinner{width:30px;height:30px;border:3px solid #c7e7d6;border-top-color:#1f9d6b;border-radius:50%;animation:as-spin .8s linear infinite} @keyframes as-spin{to{transform:rotate(360deg)}} .as-srcing-lbl{font:600 12px Arial;color:#2c7a59} .as-pdfban{position:absolute;right:6px;top:6px;z-index:4;background:#7a3a8f;color:#fff;font:700 10px/1 Arial;letter-spacing:.5px;padding:3px 7px;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.25);pointer-events:none} .as-newban{position:absolute;top:8px;right:-26px;transform:rotate(45deg);background:#1f9d6b;color:#fff;font:700 10px Arial;letter-spacing:1px;padding:2px 26px;z-index:5;box-shadow:0 1px 3px rgba(0,0,0,.3);pointer-events:none} /* #249 provider favicon chip, bottom-left of a newly-sourced cover */ .as-prov{position:absolute;left:6px;bottom:6px;z-index:5;width:25px;height:25px;border-radius:6px;background:rgba(255,255,255,.93);box-shadow:0 1px 3px rgba(0,0,0,.32);display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:zoom-in} .as-prov img{width:18px;height:18px;display:block;object-fit:contain} .as-dthumb .as-prov{left:4px;bottom:4px;width:22px;height:22px} .as-dthumb .as-prov img{width:16px;height:16px} .as-thumb{position:relative;display:block;width:100%;aspect-ratio:1;background:#f4f2f9;cursor:zoom-in;border-radius:9px 9px 0 0;overflow:hidden} .as-thumb img{width:100%;height:100%;object-fit:contain;display:block} .as-thumb.na{display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#faf8ff,#efeafb)} .as-thumb.na img{display:none} .as-thumb.na::after{content:'Not on the Cover Art Archive yet';text-align:center;color:#9a8ccb;font-size:12px;font-weight:600;line-height:1.45;padding:0 16px} .as-thumb.na.as-na-new::after{content:'Preview unavailable — the image couldn’t be decoded'} /* #250 a staged blob that won't render (no CAA fallback) */ .as-dim{font-size:12px;font-weight:600;color:#6b5fa0;flex:0 0 auto;margin-left:auto;display:flex;flex-wrap:nowrap;justify-content:flex-end;gap:0 7px;white-space:nowrap} /* nowrap: the size + resolution must never wrap to a 2nd (clipped) line when the hover 🔍 narrows the row */ .as-dim-sz,.as-dim-px{white-space:nowrap} .as-tbtn{position:absolute;top:6px;right:6px;border:none;border-radius:6px;background:rgba(255,255,255,.92);cursor:pointer;font-size:14px;line-height:1;padding:4px 7px;color:#555;box-shadow:0 1px 3px rgba(0,0,0,.2);opacity:0;transition:.1s} .as-card:hover .as-tbtn{opacity:1} .as-rm:hover{background:var(--as-warn);color:#fff} .as-undo{opacity:1;background:#fff;color:var(--as-acc);font-size:12px;font-weight:600} /* #234: footer (mockup) — row 1: comment (left) · dimensions+size (right); row 2: centered type pill on a divider */ .as-foot{padding:5px 8px 0;display:flex;flex-direction:column;gap:6px;border-top:1px solid #efeaf8} .as-foot-row{display:flex;align-items:center;gap:6px;min-height:17px} .as-foot-cmt{flex:0 1 auto;min-width:0;display:flex;align-items:center;overflow:hidden} .as-foot-cmt.as-cmt-collapsed{display:none} .as-cmt-text{font:11px inherit;color:#5a5470;line-height:1.3;cursor:text;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%} .as-cmt-text:hover{color:var(--as-acc)} .as-foot-type{display:flex;align-items:center;gap:7px;transform:translateY(50%);position:relative;z-index:1} .as-card.sel .as-foot-type{padding-right:20px} .as-tline{flex:1;height:1px;background:#e7e1f2} .as-type{font-size:11px;font-weight:700;color:#3b2c70;background:#f2f2f2;border:1px solid #d8ccf5;border-radius:20px;padding:2px 13px;cursor:pointer;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .as-type:hover{background:#e7dffb} .as-type-add{color:#8a7fb8;background:#fff;border-style:dashed;font-weight:600;opacity:.5} .as-card:hover .as-type-add{opacity:1} .as-cmt{font:11px inherit;border:1px solid #e2dcef;border-radius:6px;padding:2px 6px;color:#444;background:#faf9fe;width:100%} .as-pencil{font:11px inherit;border:1px dashed #d8ccf5;background:#fff;color:#8a7fb8;border-radius:6px;padding:0 7px;cursor:pointer;opacity:0;transition:.1s} .as-card:hover .as-pencil{opacity:1} .as-pencil:hover{background:#f6f3fd;color:var(--as-acc)} /* flat reverse-image-search magnifier — in the comment row, after the comment/pencil, revealed on card hover. The dim's nowrap (above) keeps the resolution from wrapping. */ .as-fsearch{display:inline-flex;align-items:center;color:#8a7fb8;background:none;border:none;cursor:pointer;padding:0 2px;flex:none;line-height:0;opacity:0;transition:opacity .1s} .as-card:hover .as-fsearch{opacity:.85} .as-fsearch:hover{opacity:1;color:var(--as-acc)} /* selection + keyboard cursor */ .as-card.sel{outline:3px solid var(--as-acc);outline-offset:-1px;box-shadow:0 3px 14px rgba(95,62,192,.3)} .as-selmark{position:absolute;right:7px;bottom:7px;width:21px;height:21px;line-height:21px;text-align:center;background:var(--as-acc);color:#fff;border-radius:50%;font-size:12px;font-weight:700;box-shadow:0 1px 4px rgba(0,0,0,.35);z-index:6;display:none} .as-card.sel .as-selmark{display:block} #as-root.as-zoomed .as-card.sel .as-selmark{display:none} /* big tiles: outline alone shows selection */ .as-card.sel .as-cmt{padding-right:28px} .as-card.as-cursor{box-shadow:0 0 0 2px #2a6,0 3px 14px rgba(40,160,100,.28)} /* bulk bar */ /* #234: center selection cluster in the main toolbar */ .as-selbox{display:flex;align-items:center;gap:8px;flex:0 1 auto;justify-content:center} .as-selcnt{font-size:13px;font-weight:700;color:var(--as-acc);white-space:nowrap} .as-selcnt.none{font-weight:400;color:#b3a9cc} .as-ic{font:14px/1 inherit;border:1px solid #cfc6e6;background:#fff;border-radius:6px;padding:3px 9px;color:#5a4b8a;cursor:pointer} .as-ic:hover{background:#f1ecff} .as-ic:disabled{opacity:.4;cursor:default} .as-selall{color:#2a7d50} .as-bk-rm{border-color:#e6b8b2;color:var(--as-warn)} .as-view{font-weight:600} .as-dragwarn{font-size:13px;color:#b06a00;background:#fff3d6;border:1px solid #ecd9a0;border-radius:6px;padding:3px 7px;line-height:1;cursor:help} .as-pop-note{color:#9a8ccb;font-size:11px} .as-pop{position:absolute;z-index:10001;background:#fff;border:1px solid #cbbdf0;border-radius:8px;box-shadow:0 6px 22px rgba(60,40,110,.22);padding:6px;min-width:150px;max-height:340px;overflow:auto;font-size:13px} /* z above the lightbox (9999) so the type picker shows over it */ .as-type-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 14px} .as-pop label{display:flex;align-items:center;gap:7px;padding:3px 6px;border-radius:5px;cursor:pointer} .as-pop label:hover{background:#f3eefe}.as-pop input{accent-color:var(--as-acc)} .as-pop-h{font-weight:600;color:#6a5b95;padding:3px 6px 6px;border-bottom:1px solid #eee;margin-bottom:4px} .as-pop-f{display:flex;gap:6px;padding:6px 4px 2px;border-top:1px solid #eee;margin-top:4px;position:sticky;bottom:0;background:#fff} .as-pop-apply{background:var(--as-acc);color:#fff;border-color:var(--as-acc)} .as-cmt-pop{min-width:220px} .as-search-pop{display:flex;flex-direction:column;gap:2px;min-width:160px} .as-search-pop .as-search-eng{display:block;width:100%;text-align:left;border:1px solid transparent;background:none} /* borderless until hover (transparent border keeps it from shifting) */ .as-search-pop .as-search-eng:hover{border-color:#cfc6e6} .as-search-pop .as-pop-h{display:flex;align-items:baseline;justify-content:space-between;gap:14px} /* title left, "Open all" right (#open-all-in-title) */ .as-search-pop .as-search-all{flex:none;font:600 13px inherit;color:var(--as-acc);background:none;border:none;cursor:pointer;padding:0} .as-search-pop .as-search-all:hover{text-decoration:underline} .as-search-pop .as-search-foot{margin-top:6px;padding:7px 6px 2px;border-top:1px solid #eee;font-size:11px;line-height:1.45;color:#9a8ccb} .as-search-pop .as-search-foot a{color:var(--as-acc);font-weight:600;text-decoration:none} .as-search-pop .as-search-foot a:hover{text-decoration:underline} /* grow to fit all providers when the screen allows; no horizontal bar, and hide the vertical scrollbar chrome (still wheel-scrollable on a very short screen) */ .as-src-pop{min-width:340px;max-width:90vw;width:max-content;max-height:calc(100vh - 20px);overflow-x:hidden;scrollbar-width:none} .as-src-pop::-webkit-scrollbar{display:none} .as-src-prov{display:flex;flex-direction:column;gap:5px;margin:6px 0 2px} .as-src-custom{display:flex;flex-direction:column;gap:5px} /* #250 stacked custom-provider buttons */ .as-src-custom:not(:empty){margin:6px 0 2px} .as-src-prov-b{justify-content:flex-start;font-weight:600;color:#3b2c70;gap:8px} .as-src-all{justify-content:center;font-weight:700;color:#fff;background:var(--as-acc);border-color:var(--as-acc);margin-top:3px} .as-src-all:hover{background:#4e329f;border-color:#4e329f} .as-src-ic{width:16px;height:16px;object-fit:contain;flex:0 0 auto} .as-src-n{opacity:.85} .as-src-or{margin:9px 0 0;color:#9a8ccb;font-size:11px;text-transform:uppercase;letter-spacing:.04em} .as-src-inp{width:100%;box-sizing:border-box;margin:4px 0 2px;padding:6px 8px;border:1px solid #cfc6e6;border-radius:6px;font:13px inherit} .as-src-pop > .as-pop-note:last-child{padding:6px 4px 2px;line-height:1.4;white-space:nowrap} .as-src-pop > .as-pop-note.as-src-warn{white-space:normal;color:#a85a00;font-weight:600} .as-src-pop > .as-pop-note.as-src-warn a{color:#a85a00;text-decoration:underline} .as-bulk-cmt{width:100%;box-sizing:border-box;font:13px inherit;border:1px solid #d8ccf5;border-radius:6px;padding:5px 8px;margin:2px 0 2px;background:#faf9fe;color:#333} /* lightbox */ #as-lb{display:none;position:fixed;inset:0;z-index:9999;background:rgba(15,12,28,.92);align-items:center;justify-content:center;flex-direction:column;padding:30px} .as-lb-img{max-width:92vw;max-height:84vh;object-fit:contain;box-shadow:0 8px 40px rgba(0,0,0,.6);border-radius:4px;background:#fff} .as-lb-img.loading{visibility:hidden} #as-lb.na .as-lb-img{display:none} #as-lb.na::after{content:'Image not available, please try again later';color:#f0c4da;font-style:italic;font-size:16px} /* z-index keeps BOTH arrows above the image: applyZoom always sets a transform on .as-lb-img (a stacking context), so without this the prev arrow — a DOM sibling BEFORE the image — gets painted over while next (after it) stays on top. */ .as-lb-nav{position:fixed;top:50%;transform:translateY(-50%);z-index:2;font-size:42px;line-height:1;color:#fff;background:transparent;border:none;border-radius:50%;width:54px;height:54px;cursor:pointer} .as-lb-nav:hover{background:rgba(255,255,255,.25)} .as-lb-prev{left:18px}.as-lb-next{right:18px} .as-lb-top{position:fixed;top:16px;right:20px;display:flex;gap:10px;align-items:center} .as-lb-x,.as-lb-play{font-size:15px;color:#fff;background:transparent;border:none;border-radius:8px;height:42px;cursor:pointer;font-weight:600} .as-lb-x{width:42px;font-size:24px}.as-lb-play{padding:0 14px} .as-lb-x:hover,.as-lb-play:hover{background:rgba(255,255,255,.25)} /* lightbox actions: Delete top-left, Download (size menu) top-centre. No z-index, so a ZOOMED image paints over them — same as Play/✕. The download wrapper lifts only while its menu is open so the menu stays usable. */ .as-lb-del{position:fixed;top:16px;left:20px;font-size:18px;line-height:1;color:#fff;background:transparent;border:none;border-radius:8px;height:42px;width:46px;cursor:pointer} .as-lb-del:hover{background:var(--as-warn)} /* Download moved into the bottom bar (right of the resolution): at the top it was painted over by the image even at 0 zoom. In-flow now; its size menu opens UPWARD so it doesn't fall off the bottom edge. */ .as-lb-dlwrap{position:relative;display:inline-flex;align-items:center} .as-lb-dlwrap:has(.as-lb-dlmenu.open){z-index:3} .as-lb-dl,.as-lb-dlcaret{font:600 13px Arial;color:#fff;background:rgba(255,255,255,.08);border:1px solid transparent;height:34px;cursor:pointer} .as-lb-dl{padding:0 14px;border-radius:8px 0 0 8px;border-right:none} .as-lb-dlcaret{padding:0 11px;border-radius:0 8px 8px 0;font-size:12px} .as-lb-dl:hover,.as-lb-dlcaret:hover{background:rgba(255,255,255,.25)} .as-lb-dlwrap:hover .as-lb-dl,.as-lb-dlwrap:hover .as-lb-dlcaret{border-color:rgba(255,255,255,.28)} /* border only while hovering the Download control */ .as-lb-dlmenu{position:absolute;bottom:40px;left:0;min-width:130px;background:#fff;border-radius:8px;box-shadow:0 6px 22px rgba(0,0,0,.4);padding:5px;display:none;flex-direction:column} .as-lb-dlmenu.open{display:flex} .as-lb-dlmenu button{text-align:left;background:none;border:none;color:#333;font:13px Arial;padding:7px 10px;border-radius:6px;cursor:pointer} .as-lb-dlmenu button:hover{background:#f0ecfa;color:var(--as-acc)} /* z-index:2 keeps the footer above a ZOOMED image — the image's transform makes a stacking context that would otherwise paint over the bar (it sits below in flow). */ .as-lb-bar{margin-top:14px;display:flex;flex-direction:column;align-items:center;gap:8px;width:min(560px,84vw);position:relative;z-index:2} /* while editing (focused), give the bar a readable dark backdrop — otherwise the comment field / type chip are invisible over a light image */ .as-lb-bar:focus-within{background:rgba(15,12,28,.88);padding:11px 16px;border-radius:13px;box-shadow:0 6px 24px rgba(0,0,0,.5)} .as-lb-bar:focus-within .as-lb-cmt{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.5)} .as-lb-caprow{display:flex;align-items:center;justify-content:center;gap:14px;flex-wrap:wrap} .as-lb-cap{color:#eee;font-size:13px;text-align:center;display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap} .as-lb-type{font:700 12px inherit;color:#e7dffb;background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.28);border-radius:20px;padding:3px 14px;cursor:pointer} .as-lb-type:hover{background:rgba(255,255,255,.2);color:#fff} .as-lb-type.as-type-add{font-weight:600;border-style:dashed;color:rgba(255,255,255,.6)} .as-lb-dim{color:#bbb} .as-lb-cmtarea{width:100%;display:flex;justify-content:center} .as-lb-cmt{width:100%;font:13px inherit;border:1px solid rgba(255,255,255,.25);background:rgba(255,255,255,.08);color:#fff;border-radius:7px;padding:7px 11px;text-align:center} .as-lb-cmt-text{font:14px inherit;color:#fff;text-align:center;line-height:1.4;padding:4px 8px;cursor:text;max-width:100%;word-break:break-word} .as-lb-cmt-text:hover{color:#e7dffb} .as-lb-cmt::placeholder{color:rgba(255,255,255,.45)} .as-lb-cmt:focus{outline:none;border-color:rgba(255,255,255,.55);background:rgba(255,255,255,.14)} .as-lb-cmtadd{font:12px inherit;color:rgba(255,255,255,.6);background:transparent;border:1px solid rgba(255,255,255,.2);border-radius:14px;padding:4px 13px;cursor:pointer} .as-lb-cmtadd:hover{color:#fff;border-color:rgba(255,255,255,.5);background:rgba(255,255,255,.1)} /* #251 touch viewer: show only the full image; tap reveals the controls, swipe navigates */ #as-lb.as-lb-touch .as-lb-img{max-width:100vw;max-height:100vh;border-radius:0} #as-lb.as-lb-touch .as-lb-nav{display:none} #as-lb.as-lb-touch .as-lb-del,#as-lb.as-lb-touch .as-lb-dlwrap,#as-lb.as-lb-touch .as-lb-top,#as-lb.as-lb-touch .as-lb-bar{opacity:0;pointer-events:none;transition:opacity .15s} #as-lb.as-lb-touch.as-lb-chrome .as-lb-del,#as-lb.as-lb-touch.as-lb-chrome .as-lb-dlwrap,#as-lb.as-lb-touch.as-lb-chrome .as-lb-top,#as-lb.as-lb-touch.as-lb-chrome .as-lb-bar{opacity:1;pointer-events:auto} .as-ghost{border-radius:9px;background:#fff} /* #251 bigger tap targets on a touch screen (≈44px), incl. the full-screen controls */ @media (pointer: coarse) { #as-root .as-btn,#as-root .as-ic,#as-root select{min-height:40px;padding-top:8px;padding-bottom:8px} #as-root .as-type,#as-root .as-type-add{padding-top:7px;padding-bottom:7px} #as-root .as-pencil{min-height:34px;padding:0 12px} #as-root .as-tbtn{opacity:1;padding:8px 11px} .as-lb-x,.as-lb-play,.as-lb-del,.as-lb-dl,.as-lb-dlcaret{min-width:46px;min-height:46px;font-size:18px} .as-lb-cmtadd,.as-lb-type{min-height:40px;padding:9px 16px} .as-lb-dlmenu button{padding:12px 14px} } /* commit panel */ #as-commit,#as-report{position:fixed;inset:0;z-index:9998;background:rgba(15,12,28,.55);display:flex;align-items:center;justify-content:center;padding:24px} .as-rp-opts{display:flex;flex-wrap:wrap;gap:8px 18px;margin-bottom:10px} .as-rp-opts label{display:flex;align-items:center;gap:6px;font-size:13px;color:#555} .as-rp-out{font:12px/1.45 ui-monospace,Consolas,monospace;border:1px solid #cfc6e6;border-radius:7px;padding:9px 11px;resize:vertical;background:#faf9fe;color:#333;white-space:pre;overflow:auto} .as-rp-note{font-size:12px;color:#a05a00} .as-rp-copy{font-weight:600;color:var(--as-acc)} .as-cm-box{background:#fff;border-radius:12px;box-shadow:0 12px 50px rgba(0,0,0,.4);width:min(680px,94vw);max-height:88vh;display:flex;flex-direction:column;padding:18px 20px;font:14px/1.4 -apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#222} .as-cm-h{font-size:16px;font-weight:700;color:#3b2c70;margin-bottom:12px;display:flex;align-items:center;gap:12px} .as-cm-h-t{flex:1;min-width:0} .as-cm-hist{flex:none;font-size:13px;font-weight:600;color:#6f42c1;text-decoration:none;white-space:nowrap;padding:4px 12px;border:1px solid #d9d2ee;border-radius:8px;background:#fff} .as-cm-hist:hover{background:#f4f1fb;border-color:#c3b6e6} .as-cm-row{display:flex;flex-direction:column;gap:5px;margin-bottom:10px;font-size:13px;color:#555} .as-cm-hint{font-size:11px;color:#9a8ccb;font-weight:400} .as-cm-note{font:13px inherit;border:1px solid #cfc6e6;border-radius:7px;padding:6px 9px;resize:vertical;width:100%;box-sizing:border-box;display:block;margin-bottom:12px} /* #263 if Mammoth is installed it auto-enhances the .edit-note field (saved notes / history panel), which sits below the operations list. It wraps the textarea in .mmth-wrap + a 300px side panel; the 680px modal fits that — give the wrap the note's bottom margin and full width. Hide Mammoth's WIDTH splitter in the modal: it overhangs a shorter panel, and stretching the panel to size-match the field feeds Mammoth's height observer into a runaway (infinite growth, #245). The field stays HEIGHT-resizable via its own corner grip. */ #as-commit .mmth-wrap{margin:0 0 12px;max-width:none} #as-commit .mmth-vsep{display:none} .as-cm-chk{display:flex;align-items:center;gap:6px;cursor:pointer;color:#555} .as-cm-opts{flex-direction:row;gap:18px;flex-wrap:wrap} .as-cm-opts label{display:flex;align-items:center;gap:6px;cursor:pointer;color:#444} .as-cm-dry{color:#a05a00;display:flex;align-items:center;gap:6px;cursor:pointer} .as-cm-list{overflow:auto;border:1px solid #eee;border-radius:8px;padding:6px;margin:4px 0 12px;background:#fafafa;flex:1 1 auto;min-height:0} /* #263 flex so the note + buttons below stay pinned and the list scrolls */ .as-cm-op{padding:5px 6px;border-radius:6px;font-size:13px} .as-cm-op.dry{background:#f3eefe}.as-cm-op.err{background:#fdecea} .as-cm-line{display:flex;align-items:center;gap:6px} /* #278: keep the row on one flex line so the bar can pin right */ .as-cm-st{display:inline-block;min-width:18px;white-space:nowrap;text-align:center;flex:none} .as-cm-lb{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .as-cm-skip{font-size:11px;color:#999;margin-left:6px;background:#eee;border-radius:10px;padding:1px 7px;flex:none} .as-cm-payload{white-space:pre-wrap;font:11px/1.4 ui-monospace,Consolas,monospace;color:#555;margin:4px 0 2px 18px;display:none} .as-cm-op.dry .as-cm-payload,.as-cm-op.err .as-cm-payload{display:block} /* #278 per-row progress bar (right side, was empty) */ .as-cm-bar{flex:0 0 110px;margin-left:auto;height:7px;border-radius:5px;background:#e9e4f3;overflow:hidden;position:relative;display:none} .as-cm-bar.on{display:block} .as-cm-bfill{display:block;height:100%;width:0;background:var(--as-acc);border-radius:5px;transition:width .15s linear} .as-cm-bar.done .as-cm-bfill{background:#2e9b57} .as-cm-bar.dry .as-cm-bfill{background:#b9a4e0} .as-cm-bar.err .as-cm-bfill{background:var(--as-warn)} .as-cm-bar.cancel .as-cm-bfill{background:#bbb} .as-cm-bar.busy .as-cm-bfill{position:absolute;width:40%;left:0;animation:as-cm-ind 1.1s ease-in-out infinite} @keyframes as-cm-ind{0%{left:-40%}100%{left:100%}} /* #278 overall progress (header) */ .as-cm-prog{display:flex;align-items:center;gap:10px;margin:-2px 0 12px} .as-cm-prog-track{flex:1 1 auto;height:8px;border-radius:6px;background:#e9e4f3;overflow:hidden} .as-cm-prog-fill{height:100%;width:0;background:var(--as-acc);border-radius:6px;transition:width .2s linear} .as-cm-prog-txt{flex:none;font-size:12px;font-weight:600;color:#5f3ec0;font-variant-numeric:tabular-nums;white-space:nowrap} .as-cm-f{display:flex;align-items:center;gap:8px} .as-cm-id{color:#a99fc4;font-size:12px;font-variant-numeric:tabular-nums;flex:none} .as-cm-go{background:var(--as-acc);color:#fff;border-color:var(--as-acc);font-weight:600} .as-cm-go:disabled{opacity:.5} .as-cm-note2{font-size:11px;color:#9a8ccb;margin-top:8px;text-align:center} `; const st = document.createElement('style'); st.textContent = css; appendEl(st); // ── #248 add page: harvest images seeded into the native uploader ─────────────── // Integrations (ECAU / Harmony) drop images into MB's add-cover-art uploader. We // read each preview row's blob and stage it as a NEW cover. Runs on load and via an // observer because the seed is async (ECAU fetches + maximises after the page settles). // NOTE: we deliberately do NOT scrape the row's type/comment checkboxes — ECAU sets // them via Knockout and re-renders rows when maximising, so the checked state is only // momentarily readable and racing it was unreliable (#253). Types are set instead from // the file-name option or in AS itself. // #253 read each preview image's OWN type + comment — NEVER doc-wide (that smears // every image's types onto every cover). The native uploader nests an image's // checkbox grid in an ancestor it shares with nothing else (imageScope finds it); // ECAU lays the preview out apart from the grid, so as a fallback the Nth preview // image is matched to the Nth visible type group by render order. function imageScope(img) { let n = img.parentElement, scope = null; while (n) { if (n.querySelectorAll('img.uploader-preview-image, img[src^="blob:"]').length > 1) break; // climbed past this image if (n.querySelector('.cover-art-types input[type=checkbox], input[name*="type_id"]')) scope = n; // widest single-image block holding this image's grid if (n.tagName === 'FORM' || !n.parentElement) break; n = n.parentElement; } return scope; } function readTypeGroup(group, block) { const types = [...group.querySelectorAll('input[type=checkbox]:checked, input[name*="type_id"]:checked')] .map(cb => { const l = cb.closest('label'); return l ? l.textContent.trim() : ''; }) .filter(t => ALL_TYPES.includes(t)); const sel = 'input[name*="comment"], textarea[name*="comment"], input.comment, textarea.comment'; let ci = group.querySelector(sel); // the group's own comment, if nested if (!ci && block && block !== group) { const cs = block.querySelectorAll(sel); if (cs.length === 1) ci = cs[0]; } // else a single-image block's lone comment (never a shared one) return { types, comment: ci ? (ci.value || '') : '' }; } const visibleTypeGroups = root => [...root.querySelectorAll('.cover-art-types')] .filter(g => g.querySelector('input[type=checkbox]') && g.offsetParent !== null); // skip the hidden knockout template function readArtMeta(img) { const scope = imageScope(img); if (scope) return readTypeGroup(scope.querySelector('.cover-art-types') || scope, scope); // ECAU/other restructured uploader → match the Nth image to the Nth type group const root = img.closest('form') || img.getRootNode(); const imgs = [...root.querySelectorAll('img.uploader-preview-image, img[src^="blob:"]')]; const groups = visibleTypeGroups(root); const i = imgs.indexOf(img); if (i >= 0 && groups.length === imgs.length && groups[i]) return readTypeGroup(groups[i], groups[i].closest('td, li, tr') || groups[i].parentElement); return { types: [], comment: '' }; } // harvest is idempotent + re-runnable: a NEW row is staged; a row we've already // staged has its type/comment SYNCED if the integration set them after the image // appeared (common with ECAU) — but only while the user hasn't edited that cover. let _seedNote = ''; // #248 an edit note an integration pre-filled on the native add page → moved to our commit panel // #253 harvest keys on the uploader ROW (tagged once), NOT the blob URL: ECAU // maximises the preview in place — the blob URL changes — so blob-keying re-staged // the SAME image on every pass (multiple identical covers from one seed). Reentrancy // is guarded with a trailing re-run so overlapping passes can't double-add either. let _harvesting = false, _harvestPending = false, _rowSeq = 0; async function harvestSeeds() { if (_harvesting) { _harvestPending = true; return; } _harvesting = true; _harvestPending = false; try { const form = document.getElementById('add-' + ART); if (!form) return; const en = form.querySelector('textarea.edit-note, textarea[name*="edit_note"]'); // capture a seeded edit note if (en && en.value && en.value.trim()) _seedNote = en.value.trim(); const rows = [...form.querySelectorAll('tr')].filter(tr => tr.querySelector('img.uploader-preview-image, img[src^="blob:"]')); const files = [], metas = []; let dirty = false; for (const tr of rows) { const img = tr.querySelector('img.uploader-preview-image, img[src^="blob:"]'); const src = img && (img.src || img.getAttribute('src')); if (!src || !/^blob:/i.test(src)) continue; const rowId = tr.dataset.asRow; if (rowId) { // this row is already staged — swap in a maximised image, never re-add const existing = MODEL.find(m => m._seedSrc === rowId); if (!existing || existing._del) continue; if (src !== existing._seedBlobSrc) { // ECAU maximised → replace the staged blob with the bigger one let blob; try { blob = await fetch(src).then(r => r.ok ? r.blob() : null); } catch (e) { blob = null; } if (blob) { try { URL.revokeObjectURL(existing._file); } catch (e) {} const m = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; existing._file = URL.createObjectURL(blob); existing._fileObj = new File([blob], (existing._fileObj && existing._fileObj.name) || 'seed.jpg', { type: m }); existing._seedBlobSrc = src; existing.bytes = blob.size; existing.w = 0; existing.h = 0; existing._contentKey = await fileKey(existing._fileObj); _imgCache.delete(String(existing.id)); measure(existing); dirty = true; } } continue; } let blob; try { blob = await fetch(src).then(r => r.ok ? r.blob() : null); } catch (e) { blob = null; } if (!blob) continue; // not decodable yet — a later pass will pick it up const id = 'srow' + (++_rowSeq); tr.dataset.asRow = id; // tag BEFORE staging so a racing pass sees it taken const mime = (blob.type && blob.type.startsWith('image/')) ? blob.type : 'image/jpeg'; const ext = (mime.split('/')[1] || 'jpg').replace('jpeg', 'jpg'); files.push(new File([blob], `seed-${Date.now()}-${files.length}.${ext}`, { type: mime })); metas.push({ seedSrc: id, seedBlobSrc: src }); // #253 image only — types are set via the file-name option or in AS, not scraped from ECAU's transient checkboxes } const added = files.length ? await addFilesDeduped(files, metas) : 0; if (added) toast(`Imported ${added} pre-added ${added > 1 ? ITEMS : ITEM} ✓`); else if (dirty) { refreshStaged(); render(); } } finally { _harvesting = false; if (_harvestPending) { _harvestPending = false; harvestSeeds(); } } } function initAdd() { if (!IS_ADD) return; const form = document.getElementById('add-' + ART); if (!form) { // uploader not in the DOM yet — wait for it const o = new MutationObserver(() => { if (document.getElementById('add-' + ART)) { o.disconnect(); initAdd(); } }); o.observe(document.documentElement, { childList: true, subtree: true }); return; } let t; const soon = () => { clearTimeout(t); t = setTimeout(harvestSeeds, 250); }; harvestSeeds(); // a new row, a maximised image (src swap), or a type/comment change all re-harvest. new MutationObserver(soon).observe(form, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); form.addEventListener('change', soon); form.addEventListener('input', soon); // MB applies seeded types/comments by setting the checkbox `checked` PROPERTY (no // change event) a beat after the image, so neither the observer nor the listeners // fire — poll-resync for a while so the per-row type/comment still gets picked up. #253 let polls = 0; const poll = setInterval(() => { if (++polls > 24 || !document.getElementById('as-root')) { clearInterval(poll); return; } harvestSeeds(); }, 800); window.addEventListener('beforeunload', () => clearInterval(poll)); } // we run at document-start; wait for #content before mounting the gallery if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => loadArt().then(initAdd), { once: true }); else loadArt().then(initAdd); })(); }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== credit_hoarder (@run-at document-end) ==================================== (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Credit Hoarder*","namespace":"majkinetor","version":"2026.7.2","description":"Import per-track release credits from streaming/database providers (Discogs, Tidal, Qobuz) into MusicBrainz relationships, with a review phase","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md","supportURL":"https://github.com/majkinetor/musicbrainz-userscripts/issues","icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij4KICANCiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMmY2ZjU0IiBzdHJva2Utd2lkdGg9IjkiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+DQogICAgPGNpcmNsZSBjeD0iMzQiIGN5PSIzOCIgcj0iMi41IiBmaWxsPSIjMmY2ZjU0IiBzdHJva2U9Im5vbmUiLz4NCiAgICA8bGluZSB4MT0iNTAiIHkxPSIzOCIgeDI9Ijk4IiB5Mj0iMzgiLz4NCiAgICA8Y2lyY2xlIGN4PSIzNCIgY3k9IjY0IiByPSIyLjUiIGZpbGw9IiMyZjZmNTQiIHN0cm9rZT0ibm9uZSIvPg0KICAgIDxsaW5lIHgxPSI1MCIgeTE9IjY0IiB4Mj0iOTgiIHkyPSI2NCIvPg0KICAgIDxjaXJjbGUgY3g9IjM0IiBjeT0iOTAiIHI9IjIuNSIgZmlsbD0iIzJmNmY1NCIgc3Ryb2tlPSJub25lIi8+DQogICAgPGxpbmUgeDE9IjUwIiB5MT0iOTAiIHgyPSI3NCIgeTI9IjkwIi8+DQogIDwvZz4NCiAgPGNpcmNsZSBjeD0iOTIiIGN5PSI5MiIgcj0iMjMiIGZpbGw9IiMyZTllNWIiLz4NCiAgPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjciIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+DQogICAgPGxpbmUgeDE9IjkyIiB5MT0iODEiIHgyPSI5MiIgeTI9IjEwMyIvPg0KICAgIDxsaW5lIHgxPSI4MSIgeTE9IjkyIiB4Mj0iMTAzIiB5Mj0iOTIiLz4NCiAgPC9nPg0KPC9zdmc+DQo="}) }) : { script: {"name":"Credit Hoarder*","namespace":"majkinetor","version":"2026.7.2","description":"Import per-track release credits from streaming/database providers (Discogs, Tidal, Qobuz) into MusicBrainz relationships, with a review phase","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md","supportURL":"https://github.com/majkinetor/musicbrainz-userscripts/issues","icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij4KICANCiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMmY2ZjU0IiBzdHJva2Utd2lkdGg9IjkiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+DQogICAgPGNpcmNsZSBjeD0iMzQiIGN5PSIzOCIgcj0iMi41IiBmaWxsPSIjMmY2ZjU0IiBzdHJva2U9Im5vbmUiLz4NCiAgICA8bGluZSB4MT0iNTAiIHkxPSIzOCIgeDI9Ijk4IiB5Mj0iMzgiLz4NCiAgICA8Y2lyY2xlIGN4PSIzNCIgY3k9IjY0IiByPSIyLjUiIGZpbGw9IiMyZjZmNTQiIHN0cm9rZT0ibm9uZSIvPg0KICAgIDxsaW5lIHgxPSI1MCIgeTE9IjY0IiB4Mj0iOTgiIHkyPSI2NCIvPg0KICAgIDxjaXJjbGUgY3g9IjM0IiBjeT0iOTAiIHI9IjIuNSIgZmlsbD0iIzJmNmY1NCIgc3Ryb2tlPSJub25lIi8+DQogICAgPGxpbmUgeDE9IjUwIiB5MT0iOTAiIHgyPSI3NCIgeTI9IjkwIi8+DQogIDwvZz4NCiAgPGNpcmNsZSBjeD0iOTIiIGN5PSI5MiIgcj0iMjMiIGZpbGw9IiMyZTllNWIiLz4NCiAgPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjciIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+DQogICAgPGxpbmUgeDE9IjkyIiB5MT0iODEiIHgyPSI5MiIgeTI9IjEwMyIvPg0KICAgIDxsaW5lIHgxPSI4MSIgeTE9IjkyIiB4Mj0iMTAzIiB5Mj0iOTIiLz4NCiAgPC9nPg0KPC9zdmc+DQo="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ (() => { // src/constants.js var REL_TEMPLATE = { _lineage: [], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0_credit: "", entity1_credit: "", id: null, linkOrder: 0, linkTypeID: null }; var SELECTORS = { MediumsInput: ".multiselect-input", MediumsInputOptions: ".multiselect-input + .menu a", InstrumentsInput: "#add-relationship-dialog .multiselect.instrument input[aria-autocomplete]", VocalsTypeInput: "#add-relationship-dialog .multiselect.vocal input[aria-autocomplete]", AddRelationshipsDialogEntityType: "#add-relationship-dialog .entity-type", AddRelationshipsDialogRelationshipType: "#add-relationship-dialog input.relationship-type", AddRelationshipsDialogRelationshipTarget: "#add-relationship-dialog input.relationship-target", AddRelationshipsDialogEntityCredit: "#add-relationship-dialog input.entity-credit", AddRelationshipsDialogDoneButton: "#add-relationship-dialog .buttons button.positive", AddRelationshipsDialogError: "#add-relationship-dialog .error", AddRelationshipsDialogCancelButton: "#add-relationship-dialog .buttons button.negative", AddReleaseRelationshipButton: "#release-rels button.add-relationship", EditNote: "#edit-note-text", TaskInput: "#add-relationship-dialog .attribute-container.task input" }; var DISCOGS_LOGO_URL = "https://volkerzell.de/favicons/discogs.png"; var EQUIVALENCE_SETS = [ ["writer", "composer"] ]; var DISCOGS_CHANNEL = new BroadcastChannel("discogs-importer-artist"); var pageWindow = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; // src/log.js var _logs = null; var _warn = 0; var _err = 0; var _countListener = null; function onLogCounts(fn) { _countListener = fn; } function resetLogCounts() { _warn = 0; _err = 0; _notifyCounts(); } function _notifyCounts() { if (_countListener) { try { _countListener(_warn, _err); } catch (e) { } } } function setLogContainer(el) { _logs = el; } function getLogContainer() { return _logs; } var _review = null; function setReviewContainer(el) { _review = el; } function getReviewContainer() { return _review || _logs; } function _emit(html, plainText, sev) { if (!_logs) return; const li = document.createElement("li"); if (sev) li.dataset.sev = sev; if (sev === "warn") { _warn++; _notifyCounts(); } else if (sev === "error") { _err++; _notifyCounts(); } const d = /* @__PURE__ */ new Date(); const pad = (n) => String(n).padStart(2, "0"); const stamp = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; li.innerHTML = `${stamp} ${html}`; _logs.insertAdjacentElement("beforeend", li); const bar = document.querySelector(".discogs-bar"); if (bar?._setProgress) { bar._setProgress(null, plainText.replace(/<[^>]*>/g, "").trim().substring(0, 120)); } } var log = { info: (msg) => _emit(msg, msg), warn: (msg) => _emit(`WARN ${msg}`, `WARN ${msg}`, "warn"), error: (msg) => _emit(`ERR ${msg}`, `ERR ${msg}`, "error"), // Entity skipped because it wasn't matched on MB in the review (#118). Kept // OUT of the WARN tally — these are surfaced by the separate "N unresolved" // badge, and the maintainer asked not to lump them with real warnings. Still // rendered (muted amber) so the log shows exactly which roles were dropped. skip: (msg) => _emit(`SKIP ${msg}`, `SKIP ${msg}`, "skip") }; var _debugUl = null; var _debugStartT = null; function _ensureDebugUl() { if (_debugUl) return _debugUl; if (!_logs) return null; const details = document.createElement("details"); details.style.cssText = "margin:0.3rem 0;"; const summary = document.createElement("summary"); summary.textContent = "Preflight diagnostics"; summary.style.cssText = "cursor:pointer;font-size:0.8rem;color:#888;user-select:none;"; details.appendChild(summary); const ul = document.createElement("ul"); ul.style.cssText = "list-style:none;margin:0.3rem 0;padding:0.4rem 0.6rem;background:#f7f7f7;border-radius:0.25rem;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.72rem;color:#444;max-height:24rem;overflow-y:auto;"; details.appendChild(ul); const li = document.createElement("li"); li.style.listStyle = "none"; li.appendChild(details); _logs.appendChild(li); _debugUl = ul; _debugStartT = performance.now(); return _debugUl; } function logDebug(line) { const ul = _ensureDebugUl(); if (!ul) return; const t = Math.round(performance.now() - _debugStartT); const row = document.createElement("div"); row.textContent = `[+${t}ms] ${line}`; ul.appendChild(row); } // src/api-mb.js async function fetchMBEntity(mbid) { const res = await fetch(`/ws/js/entity/${mbid}`); if (!res.ok) throw new Error(`/ws/js/entity/${mbid} \u2192 ${res.status}`); return res.json(); } var mbThrottle = /* @__PURE__ */ (() => { const MAX_CONCURRENT = 4; let _running = 0; let _pauseUntil = 0; const _queue = []; let _totalRequests = 0; let _rateLimited = 0; async function _waitForPause() { let wait = _pauseUntil - Date.now(); if (wait <= 0) return; logDebug(`throttle: waiting ${wait}ms for shared pause`); while ((wait = _pauseUntil - Date.now()) > 0) { await new Promise((r) => setTimeout(r, wait)); } } function _drain() { while (_running < MAX_CONCURRENT && _queue.length > 0) { _running++; const item = _queue.shift(); _run(item).finally(() => { _running--; _drain(); }); } } let _diagReqSeq = 0; const REQUEST_TIMEOUT_MS = 1e4; async function _run(item) { const tag = `req#${++_diagReqSeq}`; const shortUrl = item.url.replace("//musicbrainz.org", "").replace(/^https:/, ""); for (let attempt = 0; attempt <= item.retries; attempt++) { await _waitForPause(); _totalRequests++; const attemptTag = attempt === 0 ? "" : ` (retry ${attempt})`; logDebug(`${tag} [running=${_running} queued=${_queue.length}] GET ${shortUrl}${attemptTag}`); const t0 = Date.now(); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); try { const res = await fetch(item.url, { signal: ctrl.signal }); const elapsed = Date.now() - t0; if (res.status === 429 || res.status === 503) { _rateLimited++; const ra = parseInt(res.headers.get("Retry-After"), 10); const waitMs = ra > 0 ? ra * 1e3 : Math.min(1e3 * Math.pow(2, attempt), 3e4); _pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs); logDebug(`${tag} <- ${res.status} in ${elapsed}ms; shared pause pushed to +${waitMs}ms`); continue; } if (!res.ok) { logDebug(`${tag} <- ${res.status} (give up) in ${elapsed}ms`); item.resolve(item.nf && res.status === 404 ? { notFound: true } : null); return; } const data = item.wantJson ? await res.json() : res; logDebug(`${tag} <- ${res.status} in ${elapsed}ms`); item.resolve(data); return; } catch (e) { const elapsed = Date.now() - t0; const isTimeout = e?.name === "AbortError"; const reason = isTimeout ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : `${e?.message || e}`; if (isTimeout) { _rateLimited++; const waitMs = Math.min(1e3 * Math.pow(2, attempt), 8e3); _pauseUntil = Math.max(_pauseUntil, Date.now() + waitMs); logDebug(`${tag} threw in ${elapsed}ms: ${reason}; shared pause pushed to +${waitMs}ms`); } else { logDebug(`${tag} threw in ${elapsed}ms: ${reason}`); } if (attempt === item.retries) { item.resolve(null); return; } await new Promise((r) => setTimeout(r, 500)); } finally { clearTimeout(timer); } } item.resolve(null); } function _enqueue(url, retries, wantJson, nf = false) { return new Promise((resolve) => { _queue.push({ url, retries, wantJson, nf, resolve }); _drain(); }); } return { fetchJson: (url, retries = 3) => _enqueue(url, retries, true), // Like fetchJson, but a 404 resolves `{ notFound: true }` instead of // null — so null unambiguously means "lookup FAILED" (#193 chip bug). fetchJson404: (url, retries = 3) => _enqueue(url, retries, true, true), fetchRaw: (url, retries = 3) => _enqueue(url, retries, false), stats: () => ({ total: _totalRequests, rateLimited: _rateLimited, inFlight: _running, queued: _queue.length }) }; })(); async function fetchWithRetry(url, retries = 4) { return mbThrottle.fetchJson(url, retries); } var _relTypeCache = /* @__PURE__ */ new Map(); async function fetchArtistRelTypes(mbid) { if (!mbid) return null; if (_relTypeCache.has(mbid)) return _relTypeCache.get(mbid); const json = await mbThrottle.fetchJson( `//musicbrainz.org/ws/2/artist/${mbid}?inc=recording-rels+release-rels+release-group-rels+work-rels&fmt=json&limit=100` ); if (!json) return null; const types = relRoleLabels(json.relations); _relTypeCache.set(mbid, types); return types; } var MODIFIER_ATTRS = /* @__PURE__ */ new Set(["additional", "guest", "solo", "minor", "bonus"]); function relRoleLabels(relations) { const labels = /* @__PURE__ */ new Set(); for (const r of relations || []) { if (!r.type) continue; const attrs = (Array.isArray(r.attributes) ? r.attributes : []).filter((a) => a && !MODIFIER_ATTRS.has(String(a).toLowerCase())); if ((r.type === "instrument" || r.type === "vocal") && attrs.length) { attrs.forEach((a) => labels.add(a)); } else { labels.add(r.type); } } return [...labels].sort(); } function getSourceUrlsForRelease(mbid) { const url = `/ws/js/release/${mbid}?fmt=json&inc=rels`; return fetch(url).then((body) => body.json()).then((json) => { const rels = json.relationships || []; const abs = (u) => u && u.startsWith("//") ? "https:" + u : u; const href = (pred) => abs(rels.find(pred)?.target?.href_url || null); return { discogs: href((rel) => rel.target?.sidebar_name === "Discogs"), tidal: href((rel) => /(^|\/\/)(www\.|listen\.)?tidal\.com\/(browse\/)?album\/\d+/i.test(rel.target?.href_url || "")), qobuz: href((rel) => /(^|\/\/)(www\.|play\.|open\.)?qobuz\.com\/([a-z]{2}-[a-z]{2}\/)?album\//i.test(rel.target?.href_url || "")) }; }); } function resolveLinkTypeId(name, type0, type1) { const lt = pageWindow.MB?.linkedEntities?.link_type; if (!lt) { log.error("MB.linkedEntities.link_type not available"); return null; } const needle = name.toLowerCase().trim(); const stripAttrs = (s) => (s || "").toLowerCase().replace(/\{[^}]*\}/g, "").replace(/\s+/g, " ").trim(); const candidates = Object.values(lt).filter( (v) => v.type0 === type0 && v.type1 === type1 && !v.deprecated ); for (const v of candidates) { if ((v.name || "").toLowerCase() === needle) return v.id; } for (const v of candidates) { if (stripAttrs(v.link_phrase) === needle) return v.id; if (stripAttrs(v.reverse_link_phrase) === needle) return v.id; } const contains = candidates.filter((v) => { const blobs = [v.name, stripAttrs(v.link_phrase), stripAttrs(v.reverse_link_phrase)].filter(Boolean); return blobs.some((b) => b.includes(needle) || needle.includes(b)); }); if (contains.length > 0) { contains.sort((a, b) => ((a.name || "").length || 999) - ((b.name || "").length || 999)); const best = contains[0]; if ((best.name || "").toLowerCase() !== needle) { log.info(`Fuzzy match: "${name}" \u2192 "${best.name}" (${type0}\u2192${type1})`); } return best.id; } const availableNames = candidates.map((v) => v.name).filter(Boolean).sort().join(", "); const allByName = Object.values(lt).filter( (v) => (v.name || "").toLowerCase() === needle || stripAttrs(v.link_phrase) === needle || stripAttrs(v.reverse_link_phrase) === needle ); const deprecatedHit = allByName.find((v) => v.type0 === type0 && v.type1 === type1 && v.deprecated); const wrongPairHits = allByName.filter((v) => !(v.type0 === type0 && v.type1 === type1)); if (deprecatedHit) { const altPairs = [...new Set(allByName.filter((v) => !v.deprecated).map((v) => `${v.type0}\u2192${v.type1}`))].join(", "); log.error(`"${name}" (${type0}\u2192${type1}) is deprecated by MB and would block the commit \u2014 skipping${altPairs ? `. Valid alternative(s): ${altPairs}` : ""}.`); } else if (wrongPairHits.length > 0) { const hitDesc = wrongPairHits.map((v) => `${v.name}(${v.type0}\u2192${v.type1})`).join(", "); log.warn(`No "${name}" link type for (${type0}\u2192${type1}) \u2014 exists for other entity pairs: ${hitDesc} \u2014 skipping`); } else { log.warn(`Unknown link type "${name}" (${type0}\u2192${type1}). Available for this pair: ${availableNames || "none"}`); } return null; } // src/storage.js var DB_NAME = "mblink"; var DB_VERSION = 2; var STORE = "entity_cache"; var db = null; var _request = indexedDB.open(DB_NAME, DB_VERSION); _request.onerror = function() { console.error("Why didn't you allow my web app to use IndexedDB?!"); }; _request.onsuccess = function(event) { db = event.target.result; }; _request.onupgradeneeded = function(event) { const upgradeDb = event.target.result; if (!upgradeDb.objectStoreNames.contains(STORE)) { upgradeDb.createObjectStore(STORE, { keyPath: "discogs_id" }); } }; function mbUrlOf(entityType, mbid) { return `//musicbrainz.org/${entityType}/${mbid}`; } function readIdbRecord(key) { return new Promise((resolve) => { if (!key || !db) return resolve(null); try { const tx = db.transaction([STORE], "readonly"); const req = tx.objectStore(STORE).get(key); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } function writeIdbRecord(key, partial) { return new Promise((resolve) => { if (!key || !db) return resolve(null); try { const tx = db.transaction([STORE], "readwrite"); const store = tx.objectStore(STORE); const getReq = store.get(key); getReq.onsuccess = () => { const existing = getReq.result || {}; const merged = { ...existing, ...partial, discogs_id: key, resolvedAt: (/* @__PURE__ */ new Date()).toISOString() }; if (merged.mbid && merged.entityType && !partial.mbUrl) { merged.mbUrl = mbUrlOf(merged.entityType, merged.mbid); } const putReq = store.put(merged); putReq.onsuccess = () => resolve(merged); putReq.onerror = () => resolve(null); }; getReq.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } function deleteIdbRecord(key) { return new Promise((resolve) => { if (!key || !db) return resolve(false); try { const tx = db.transaction([STORE], "readwrite"); const store = tx.objectStore(STORE); const req = store.delete(key); req.onsuccess = () => resolve(true); req.onerror = () => resolve(false); } catch (e) { resolve(false); } }); } // src/progress-bar.js var _pInterval = null; var _pPos = -40; function _showBar() { const row1 = document.querySelector(".discogs-bar-row1"); const row2 = document.querySelector(".discogs-bar-row2"); const r1h = row1 ? row1.getBoundingClientRect().height : 42; let pb = document.getElementById("discogs-pb"); if (!pb) { pb = document.createElement("div"); pb.id = "discogs-pb"; pb.style.cssText = "position:fixed;left:0;right:0;height:5px;z-index:99999;background:#ddd;overflow:hidden;"; const fill = document.createElement("div"); fill.id = "discogs-pb-fill"; fill.style.cssText = "position:absolute;top:0;height:100%;width:40%;background:#e8771d;transition:width 0.2s linear;"; pb.appendChild(fill); document.body.appendChild(pb); } pb.style.top = r1h + "px"; pb.style.display = "block"; if (row2) row2.style.marginTop = r1h + 5 + "px"; _startMarquee(); } function _startMarquee() { clearInterval(_pInterval); const fill = document.getElementById("discogs-pb-fill"); if (fill) { fill.style.width = "40%"; fill.style.left = _pPos + "%"; fill.style.transition = ""; } _pPos = -40; _pInterval = setInterval(() => { _pPos += 1.5; if (_pPos > 100) _pPos = -40; const f = document.getElementById("discogs-pb-fill"); if (f) f.style.left = _pPos + "%"; }, 16); } function _setProgressPct(pct) { const p = Math.max(0, Math.min(100, Number(pct) || 0)); clearInterval(_pInterval); _pInterval = null; const pctEl = document.getElementById("discogs-progress-pct"); if (pctEl) pctEl.textContent = Math.round(p) + "%"; const fill = document.getElementById("discogs-pb-fill"); if (!fill) return; fill.style.left = "0"; fill.style.transition = "width 0.2s linear"; fill.style.width = p + "%"; } function _hideBar() { clearInterval(_pInterval); _pInterval = null; const pb = document.getElementById("discogs-pb"); if (pb) pb.style.display = "none"; const row2 = document.querySelector(".discogs-bar-row2"); if (row2) row2.style.marginTop = ""; } // src/api-discogs.js var DISCOGS_URL_RE = /^https?:\/\/(?:www|api)\.discogs\.com\/(?:(?:(?!sell).+|sell.+)\/)?(master|release|artist|label)s?\/(\d+)(?:[^?#]*)(?:\?noanv=1|\?anv=[^=]+)?$/i; function parseDiscogsUrl(url) { const m = DISCOGS_URL_RE.exec(url); if (!m) return null; const type = m[1]; const id = m[2]; return { type, id, key: `${type}/${id}`, cleanUrl: `https://www.discogs.com/${type}/${id}` }; } var _releaseDataCache = /* @__PURE__ */ new Map(); function getDiscogsReleaseData(url) { if (_releaseDataCache.has(url)) return Promise.resolve(_releaseDataCache.get(url)); return fetch( `${url.replace( "https://www.discogs.com/release/", "https://api.discogs.com/releases/" )}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ` ).then((body) => body.json()).then((json) => { _releaseDataCache.set(url, json); return json; }); } var _entityDataCache = /* @__PURE__ */ new Map(); function getDiscogsEntityData(resourceUrl) { if (!resourceUrl) return Promise.resolve(null); if (_entityDataCache.has(resourceUrl)) return Promise.resolve(_entityDataCache.get(resourceUrl)); return fetch(`${resourceUrl}?token=gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ`).then((r) => r.ok ? r.json() : null).then((json) => { if (!json) { _entityDataCache.set(resourceUrl, null); return null; } const slim = { profile: json.profile || "", name: json.name || "", namevariations: json.namevariations || [], realname: json.realname || "" }; _entityDataCache.set(resourceUrl, slim); return slim; }).catch(() => null); } // src/data/entity-map.js var ENTITY_TYPE_MAP = { // Places "Arranged At": { entityType: "place", linkType: "arranged at" }, "Engineered At": { entityType: "place", linkType: "engineered at" }, "Recorded At": { entityType: "place", linkType: "recorded at" }, "Mixed At": { entityType: "place", linkType: "mixed at" }, "Mastered At": { entityType: "place", linkType: "mastered at" }, "Lacquer Cut At": { entityType: "place", linkType: "lacquer cut at" }, "edited At": { entityType: "place", linkType: "edited at" }, "Remixed At": { entityType: "place", linkType: "remixed at" }, "Produced At": { entityType: "place", linkType: "produced at" }, "Overdubbed At": null, "manufactured At": { entityType: "place", linkType: "manufactured at" }, "Glass Mastered At": { entityType: "place", linkType: "glass mastered at" }, "Pressed At": { entityType: "place", linkType: "pressed at" }, "Designed At": null, "Filmed At": null, "Exclusive Retailer": null, // labels "Copyright (c)": { entityType: "label", linkType: "copyright" }, "Phonographic Copyright (p)": { entityType: "label", linkType: "phonographic copyright" }, "Copyright \xA9": { entityType: "label", linkType: "copyright" }, "Phonographic Copyright \u2117": { entityType: "label", linkType: "phonographic copyright" }, "Licensed From": { entityType: "label", linkType: "licensor" }, "Licensed To": { entityType: "label", linkType: "licensee" }, "Licensed Through": null, "Distributed By": { entityType: "label", linkType: "distributed" }, "Made By": { entityType: "label", linkType: "manufactured" }, "Manufactured By": { entityType: "label", linkType: "manufactured" }, "Glass Mastered By": { entityType: "label", linkType: "glass mastered" }, "Pressed By": { entityType: "label", linkType: "pressed" }, "Marketed By": { entityType: "label", linkType: "marketed" }, "Printed By": { entityType: "label", linkType: "printed" }, "Promoted By": { entityType: "label", linkType: "promoted" }, "Published By": { entityType: "label", linkType: "published" }, "Rights Society": { entityType: "label", linkType: "rights society" }, "Arranged For": { entityType: "label", linkType: "arranged for" }, "Manufactured For": { entityType: "label", linkType: "manufactured for" }, "Mixed For": { entityType: "label", linkType: "mixed for" }, "Produced For": { entityType: "label", linkType: "produced for" }, "Miscellaneous Support": { entityType: "label", linkType: "misc" }, "Exported By": null, // Artists Performer: { entityType: "artist", linkType: "performer" }, // Discogs "Ensemble" — a group/ensemble credited as performing (e.g. a // vocal quartet or jazz ensemble). MB has no dedicated "ensemble" link // type, so the conventional fit is the generic `performer` relationship // (same as Discogs "Performer"). Without this it fell through to the // INSTRUMENTS table (`Ensemble: null`) and was dropped as unmapped. Ensemble: { entityType: "artist", linkType: "performer" }, // Discogs role "Accompanied By" → MB has no dedicated link type or // attribute for this. Closest semantic fit is `performer` with the // `additional` attribute (used elsewhere in MB for non-primary // contributions). Previously this fell through to the INSTRUMENTS map // and was dispatched as a bare `instrument` rel with the bogus // attribute value "accompanied by" — which MB silently drops, leaving // a junk instrument rel with no instrument named. "Accompanied By": { entityType: "artist", linkType: "performer", attributes: ["additional"] }, Instruments: { entityType: "artist", linkType: "instrument" }, Vocals: { entityType: "artist", linkType: "vocal" }, "Backing Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, Choir: { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "choir vocals" }] }, Chorus: { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "choir vocals" }] }, "Choir Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "choir vocals" }] }, "Lead Vocals": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "lead vocals" }] }, Orchestra: { entityType: "artist", linkType: "orchestra" }, Conductor: { entityType: "artist", linkType: "conductor" }, "Chorus Master": { entityType: "artist", linkType: "chorus master" }, Concertmaster: { entityType: "artist", linkType: "concertmaster" }, Concertmistress: { entityType: "artist", linkType: "concertmaster" }, "Compiled By": { entityType: "artist", linkType: "compiler" }, "DJ Mix": { entityType: "artist", linkType: "DJ-mixer" }, Remix: { entityType: "artist", linkType: "remixer" }, "contains samples by": { entityType: "artist", linkType: "contains samples by" }, "Written-By": { entityType: "artist", linkType: "writer" }, "Written By": { entityType: "artist", linkType: "writer" }, "Composed By": { entityType: "artist", linkType: "composer" }, // #233: author of the source text (e.g. the novel a Hörspiel adapts). MB // 'writer' is a work-level rel, so this lands on the work CH creates. Author: { entityType: "artist", linkType: "writer" }, // #233: spoken-word performer (audio play / Hörspiel cast). MB convention is // the 'vocal' relationship with the 'spoken vocals' attribute; the character // in the Discogs bracket becomes the credited-as (handled in mappers.js). "Voice Actor": { entityType: "artist", linkType: "vocal", attributes: [{ _type: "vocal", value: "spoken vocals" }] }, "Words By": { entityType: "artist", linkType: "lyricist" }, "Lyrics By": { entityType: "artist", linkType: "lyricist" }, "Libretto By": { entityType: "artist", linkType: "librettist" }, "Translated By": { entityType: "artist", linkType: "translator" }, "Arranged By": { entityType: "artist", linkType: "arranger" }, "Instrumentation By": { entityType: "artist", linkType: "instruments arranger" }, "Orchestrated By": { entityType: "artist", linkType: "orchestrator" }, "vocals arranger": { entityType: "artist", linkType: "vocals arranger" }, Producer: { entityType: "artist", linkType: "producer" }, "Co-producer": { entityType: "artist", linkType: "producer", // MB has no `co` attribute. The convention for "Co-X" is `additional` // on the base role (issue #3). See also the matching remap in // `getArtistRoles` for the regex /Co /. attributes: ["additional"] }, "Executive-Producer": { entityType: "artist", linkType: "producer", attributes: ["executive"] }, "Post Production": { entityType: "artist", linkType: "producer" }, Engineer: { entityType: "artist", linkType: "engineer" }, "Audio Engineer": { entityType: "artist", linkType: "audio engineer" }, "Mastered By": { entityType: "artist", linkType: "mastering" }, "Remastered By": { entityType: "artist", linkType: "mastering", attributes: ["re"] }, "Lacquer Cut By": { entityType: "artist", linkType: "lacquer cut" }, "sound engineer": { entityType: "artist", linkType: "sound engineer" }, "Mixed By": { entityType: "artist", linkType: "mix" }, "Recorded By": { entityType: "artist", linkType: "recording" }, "Recording Engineer": { entityType: "artist", linkType: "recording" }, "Programmed By": { entityType: "artist", linkType: "programming" }, Editor: { entityType: "artist", linkType: "editor" }, "Edited By": { entityType: "artist", linkType: "editor" }, "balance engineer": { entityType: "artist", linkType: "engineer" }, "copyrighted by": { entityType: "artist", linkType: "copyright" }, "phonographic copyright by": { entityType: "artist", linkType: "phonographic copyright" }, Legal: { entityType: "artist", linkType: "legal representation" }, Booking: { entityType: "artist", linkType: "booking" }, "Art Direction": { entityType: "artist", linkType: "art direction" }, Artwork: { entityType: "artist", linkType: "artwork" }, "Artwork By": { entityType: "artist", linkType: "artwork" }, Cover: { entityType: "artist", linkType: "artwork" }, Design: { entityType: "artist", linkType: "design" }, "Graphic Design": { entityType: "artist", linkType: "graphic design" }, Illustration: { entityType: "artist", linkType: "illustration" }, "Booklet Editor": { entityType: "artist", linkType: "booklet editor" }, Photography: { entityType: "artist", linkType: "photography" }, "Photography By": { entityType: "artist", linkType: "photography" }, Technician: { entityType: "artist", // MB's link type is singular ("instrument technician"). The plural // form here missed the exact-name match in resolveLinkTypeId, so the // fuzzy contains-match collapsed "instruments technician" down to the // bare "instrument" performance link type — turning a piano tuner / // tech credit into an instrument performer. #155 linkType: "instrument technician" }, publisher: { entityType: "artist", linkType: "published" }, "Liner Notes": { entityType: "artist", linkType: "liner notes" }, "A&R": { entityType: "artist", linkType: "misc" }, // #291: no dedicated MB rel — the generic "misc" relationship carries the // role name as its `task` attribute (mappers.js), so this lands as misc + task "research". Research: { entityType: "artist", linkType: "misc" }, Advisor: { entityType: "artist", linkType: "misc" }, "Concept By": { entityType: "artist", linkType: "misc" }, Contractor: { entityType: "artist", linkType: "misc" }, Coordinator: { entityType: "artist", linkType: "misc" }, Management: { entityType: "artist", linkType: "misc" }, "Musical Assistance": { entityType: "artist", linkType: "misc" }, "Tour Manager": { entityType: "artist", linkType: "misc" }, Other: { entityType: "artist", linkType: "misc" }, "Public Relations": { entityType: "artist", linkType: "misc" }, Promotion: { entityType: "artist", linkType: "misc" }, Crew: { entityType: "artist", linkType: "misc" }, "Supervised By": { entityType: "artist", linkType: "misc" }, "Director Of Photography": { entityType: "artist", linkType: "photography", attributes: [{ _type: "task", value: "director of photography" }] } }; // src/data/instruments.js var INSTRUMENTS = { Afox\u00E9: "afox\xE9", Agog\u00F4: "agog\xF4", Ashiko: "ashiko", Atabal: null, Bapang: "bhapang", "Clarinet": "clarinet", "Percussion": "percussion", "Congas": "congas", "Conga": "congas", "Conga Drum": "congas", "Bongos": "bongos", "Bongo": "bongos", "Tambourine": "tambourine", "Cuica": "cu\xEDca", "Guiro": "g\xFCiro", "G\xFCiro": "g\xFCiro", "Udu": "udu", "La\xFAd": "la\xFAd", "Laud": "la\xFAd", "Mbira": "mbira", "Kalimba": "mbira", "Slide Guitar": "slide guitar", "Acoustic Guitar": "acoustic guitar", "Double Bass": "double bass", "Goblet Drum": "goblet drum", "Maracas": "maracas", "Steel Drum": "steelpan", "Steelpan": "steelpan", "Electronics": "electronics", "Electronic": "electronics", "Synth Bass": "bass synthesizer", "Vocoder": "vocoder", "Xylophone": "xylophone", "Bells": "bells", "Chimes": "wind chimes", "Glockenspiel": "glockenspiel", "Shakers": "shaker", "Shaker": "shaker", "Cowbell": "cowbell", "Claves": "claves", "Timbales": "timbales", "Baritone Guitar": "baritone guitar", "Synth": "synthesizer", "Synthesizer": "synthesizer", "Synths": "synthesizer", "Keyboards": "keyboard", "Keyboard": "keyboard", "Organ": "organ", "Harmonium": "harmonium", "Piano": "piano", "Electric Piano": "electric piano", "Harpsichord": "harpsichord", "Celesta": "celesta", "Strings": "string instruments", "Flute": "flute", "Saxophone": "saxophone", "Vibraphone": "vibraphone", "Trumpet": "trumpet", "Trombone": "trombone", "Violin": "violin", "Cello": "cello", "Viola": "viola", "Harp": "harp", "Banjo": "banjo", "Mandolin": "mandolin", "Ukulele": "ukulele", "Harmonica": "harmonica", "Accordion": "accordion", "Oboe": "oboe", "Bassoon": "bassoon", "Tuba": "tuba", "French Horn": "French horn", "Marimba": "marimba", "Melodica": "melodica", "Sitar": "sitar", "Oud": "oud", "Kora": "kora", "Tabla": "tabla", "Didgeridoo": "didgeridoo", "Theremin": "theremin", "Flugelhorn": "flugelhorn", "Cornet": "cornet", "Alto Saxophone": "alto saxophone", "Tenor Saxophone": "tenor saxophone", "Soprano Saxophone": "soprano saxophone", "Baritone Saxophone": "baritone saxophone", "Bass Clarinet": "bass clarinet", "Bass Flute": "bass flute", "Piccolo": "piccolo", "Bass Drum": "bass drum", Bata: "bat\xE1 drum", "Bell Tree": "bell tree", Bendir: "bendir", Bodhr\u00E1n: "bodhr\xE1n", "Body Percussion": "body percussion", Bombo: null, Bones: "bones", Buhay: null, Buk: "buk", Cabasa: "cabasa", Caixa: "caixa", "Caja Vallenata": null, Caj\u00F3n: "caj\xF3n", Calabash: "calabash", Castanets: "castanets", Caxixi: "caxixi", "Chak'chas": null, Chinch\u00EDn: null, Ching: "ching", Cymbal: "cymbal", Daf: "daf", Davul: "davul", Dhol: "dhol", Dholak: "dholak", Djembe: "djembe", Doira: null, Doli: null, Drum: "drum set", "Drum Programming": null, Drums: "drum set", Dunun: "dunun", "Electronic Drums": null, "Finger Cymbals": "finger cymbals", "Finger Snaps": "finger snaps", "Frame Drum": "frame drum", "Friction Drum": "friction drum", Frottoir: "frottoir", Ganz\u00E1: "ganz\xE1", Ghatam: "ghatam", Ghungroo: null, Gong: "gong", Guacharaca: null, Handbell: "handbell", Handclaps: "handclaps", "Hang Drum": "handpan", Hihat: "hi-hat", Hosho: null, Hyoshigi: "hyoshigi", Idiophone: "idiophone", Jaggo: null, Janggu: "janggu", Jing: "jing", "K'kwaengwari": null, Ka: null, "Kagura Suzu": null, Kanjira: "kanjira", Karkabas: null, Khartal: null, Khurdak: "duggi", Kynggari: null, Lagerphone: "monkey stick", "Lion's Roar": null, Madal: "madal", Mallets: null, "Monkey stick": "monkey stick", Mridangam: "mridangam", Pakhavaj: "pakhawaj", Pandeiro: null, Rainstick: "rainstick", Ratchet: "ratchet", Rattle: "shaken idiophone", "Reco-reco": "reco-reco", Repinique: "repinique", Rototoms: null, Scraper: null, Shakubyoshi: null, Shekere: "shekere", Shuitar: null, "Singing Bowls": null, Skratjie: null, Slapstick: "slapstick", "Slit Drum": "slit drum", Snare: null, Spoons: "spoons", "Stomp Box": null, Surdo: "surdo", Surigane: "atarigane", Taiko: "taiko", "Talking Drum": "talking drum", "Tam-tam": "chau gong", Tambora: null, Tamboril: "tabor", Tamborim: "tamborim", "Tan-Tan": null, "Tap Dance": "tap dance", "Tar (Drum)": null, "Temple Bells": null, "Temple Block": null, Thavil: "thavil", Timpani: "timpani", "Tom Tom": "tom-tom", Triangle: "triangle", T\u00FCng\u00FCr: null, Vibraslap: "vibraslap", Washboard: "washboard", Waterphone: "waterphone", "Wood Block": "wood block", Zabumba: "zabumba", Amadinda: "amadinda", Angklung: "angklung", Balafon: "balafon", Boomwhacker: "boomwhacker", Carillon: null, Crotales: "crotales", Guitaret: "guitaret", Lamellophone: "lamellaphone", Marimbula: "mar\xEDmbula", Metallophone: "metallophone", "Musical Box": "musical box", Prempensua: null, Slagbordun: null, "Steel Drums": "steelpan", "Thumb Piano": "mbira", Tubaphone: null, "Tubular Bells": "tubular bells", Tun: null, Txalaparta: "txalaparta", "Baby Grand Piano": null, Chamberlin: "chamberlin", Claviorgan: "claviorganum", "Concert Grand Piano": null, Dulcitone: "dulcitone", "Electric Harmonium": null, "Electric Harpsichord": null, "Electric Organ": "electronic organ", Fortepiano: null, "Grand Piano": "grand piano", Mellotron: "mellotron", Omnichord: "omnichord", "Ondes Martenot": "ondes martenot", "Parlour Grand Piano": null, Pedalboard: null, "Player Piano": null, Regal: "regal", Stylophone: null, "Tangent Piano": "tangent piano", "Toy Piano": "toy piano", "Upright Piano": "upright piano", Virginal: "virginal", "12-String Acoustic Guitar": null, "12-String Bass": null, "5-String Banjo": null, "6-String Banjo": null, "6-String Bass": null, "Acoustic Bass": "acoustic bass guitar", // #209 — Discogs separates this from "Double Bass", which maps to upright "Arco Bass": null, Arpa: null, Autoharp: "autoharp", Baglama: null, "Bajo Quinto": null, "Bajo Sexto": "bajo sexto", Balalaika: "balalaika", Bandola: null, Bandura: "bandura", Bandurria: "bandurria", Banhu: "banhu", Banjolin: "banjolin", "Baroque Guitar": "baroque guitar", Baryton: "baryton", "Bass Guitar": "bass guitar", Berimbau: "berimbau", Bhapang: "bhapang", Biwa: "biwa", "Blaster Beam": "blaster beam", Bolon: "bolon", Bouzouki: "bouzouki", "Bulbul Tarang": "bulbul tarang", Byzaanchi: null, Cavaquinho: "cavaquinho", "Cello Banjo": null, Changi: null, Chanzy: "chanzy", "Chapman Stick": "chapman stick", Charango: "charango", Chitarrone: null, Chonguri: null, Chuniri: null, Cimbalom: "cimbalom", Citole: "citole", Cittern: "cittern", Cl\u00E0rsach: null, "Classical Guitar": "classical guitar", Clavichord: "clavichord", Clavinet: "clavinet", Cobza: null, Contrabass: "double bass", Cuatro: "cuatro", C\u00FCmb\u00FC\u015F: "c\xFCmb\xFC\u015F", Cura: null, Deaejeng: null, "Diddley Bow": "diddley bow", Dilruba: "dilruba", Dobro: "resonator guitar", Dojo: null, Dombra: "dombra", Domra: "domra", Doshpuluur: "doshpuluur", Dulcimer: null, Dutar: "dutar", "\u0110\xE0n b\u1EA7u": "\u0111\xE0n b\u1EA7u", Ektare: null, "Electric Bass": "bass guitar", // MB's "bass guitar" IS the electric bass (was falling through to generic "instrument") "Electric Guitar": "electric guitar", "Electric Upright Bass": "electric upright bass", "Electric Violin": "electric violin", "Epinette des Vosges": null, Erhu: "erhu", Esraj: "esraj", Fiddle: "fiddle", "Flamenco Guitar": "flamenco guitar", "Fretless Bass": "fretless bass", "Fretless Guitar": null, Gadulka: "gadulka", Gaohu: "gaohu", Gayageum: "gayageum", Geomungo: "geomungo", Giga: "\u0123\u012Bga", Gittern: "gittern", Gottuv\u00E2dyam: null, Guimbri: "gumbri", Guitalele: "guitalele", Guitar: "guitar", "Guitar Banjo": "banjitar", "Guitar Synthesizer": "guitar synthesizer", Guitarr\u00F3n: null, GuitarViol: null, Guqin: "guqin", Gusli: "gusli", Guzheng: "guzheng", Haegum: "haegeum", Halldorophone: null, Hardingfele: "hardingfele", "Harp Guitar": "harp guitar", Hummel: "hummel", Huqin: "huqin", "Hurdy Gurdy": "hurdy gurdy", Igil: "igil", Jarana: null, Jinghu: "jinghu", Jouhikko: "jouhikko", Kabosy: null, Kamancha: "kamancheh", Kankl\u0117s: "kankl\u0117s", Kantele: "kantele", Kanun: "kanun", Kemenche: "kemenche", Kirar: null, Kobyz: null, Kokyu: "kokyu", Koto: "koto", Krar: "krar", Langeleik: "langeleik", Laouto: "laouto", "Lap Steel Guitar": "lap steel guitar", Lavta: "lavta", "Lead Guitar": "guitar", // #209 — MB has no lead/rhythm-guitar instrument; both are just "guitar" Lira: null, "Lira da Braccio": "lira da braccio", Lirone: "lirone", Liuqin: "liuqin", Lute: "lute", Lyre: "lyre", Mandobass: null, Mandocello: "mandocello", Mandoguitar: "mandoguitar", Mandola: "mandola", "Mandolin Banjo": null, Mandolincello: null, Marxophone: "marxophone", Masinko: null, Monochord: null, Morinhoor: null, "Mountain Dulcimer": null, "Musical Bow": "musical bow", Ngoni: "ng\u0254ni", Nyckelharpa: "nyckelharpa", "Open-Back Banjo": null, Outi: "oud", Panduri: null, "Pedal Steel Guitar": "pedal steel guitar", "Piccolo Banjo": null, Pipa: "pipa", "Plectrum Banjo": null, "Portuguese Guitar": "portuguese guitar", Psalmodicon: null, Psaltery: "psaltery", Rabab: "rebab", Rabeca: "rebec", Rebab: "rebab", Rebec: "rebec", Reikin: null, "Requinto Guitar": null, "Resonator Banjo": null, "Resonator Guitar": "resonator guitar", "Rhythm Guitar": "guitar", // #209 — see Lead Guitar Ronroco: "ronroco", Ruan: "ruan", Sanshin: "sanshin", Santoor: "santoor", Sanxian: "sanxian", Sarangi: "sarangi", Sarod: "sarod", "Selmer-Maccaferri Guitar": null, "Semi-Acoustic Guitar": null, Seperewa: null, "Shahi Baaja": "bulbul tarang", Shamisen: "shamisen", Sintir: "gumbri", Spinet: "spinet", "Steel Guitar": "steel guitar", "Stroh Violin": "stroh violin", Strumstick: null, Surbahar: "surbahar", "Svara Mandala": null, Swarmandel: null, Sympitar: null, SynthAxe: null, Taish\u014Dgoto: "taishogoto", Talharpa: "talharpa", Tambura: "tambura", Tamburitza: null, Tapboard: null, "Tar (lute)": null, "Tenor Banjo": "tenor banjo", "Tenor Guitar": "tenor guitar", Theorbo: "theorbo", Timple: "tiple", Tiple: "tiple", Tipple: "tiple", Tonkori: "tonkori", Tres: "tres", "Tromba Marina": "tromba marina", "Twelve-String Guitar": null, Tzouras: "tzoura", "Ukulele Banjo": "banjo-ukulele", \u00DCt\u0151gardon: "\xFCt\u0151gardon", Valiha: "valiha", Veena: "saraswati veena", Vielle: "vielle", Vihuela: "vihuela", Viol: "viola da gamba", "Viola Caipira": "viola caipira", "Viola d'Amore": "viola d'amore", "Viola da Gamba": "viola da gamba", "Viola de Cocho": null, "Viola Kontra": null, "Viola Nordestina": null, "Violino Piccolo": "violino piccolo", Violoncello: "cello", Violone: "violone", "Washtub Bass": "washtub bass", Xalam: "xalam", "Yang T'Chin": "yangqin", Yanggeum: null, Zither: "zither", Zongora: null, Algoza: "algozey", Alphorn: "alphorn", "Alto Clarinet": "alto clarinet", "Alto Flute": "alto flute", "Alto Horn": null, "Alto Recorder": "treble recorder / alto recorder", Apito: null, Bagpipes: "bagpipe", Bandoneon: "bandone\xF3n", Bansuri: "bansuri", "Baritone Horn": "baritone horn", "Barrel Organ": "barrel organ", "Bass Harmonica": "bass harmonica", "Bass Saxophone": "bass saxophone", "Bass Trombone": "bass trombone", "Bass Trumpet": "bass trumpet", "Bass Tuba": null, "Basset Horn": "basset horn", Bawu: "bawu", Bayan: "bayan", Bellowphone: null, Beresta: null, "Blues Harp": "harmonica", "Bolivian Flute": null, Bombarde: "bombarde", Brass: "brass", "Brass Bass": null, Bucium: null, Bugle: null, Chalumeau: "chalumeau", Chanter: null, Charamel: null, Chirimia: "chirim\xEDa", Clarion: null, Claviola: "claviola", Comb: null, "Concert Flute": "concert flute", Concertina: null, Conch: "conch", "Contra-Alto Clarinet": null, "Contrabass Clarinet": "contrabass clarinet", "Contrabass Saxophone": "contrabass saxophone", Contrabassoon: "contrabassoon", "Cor Anglais": "cor anglais", Cornett: "cornett", Cromorne: "crumhorn", Crumhorn: "crumhorn", Daegeum: "daegeum", Danso: "danso", "Dili Tuiduk": null, Dizi: "dizi", Drone: null, Duduk: "duduk", Dulcian: "dulcian", Dulzaina: "dulzaina", "Electronic Valve Instrument": null, "Electronic Wind Instrument": "wind synthesizer", "English Horn": "cor anglais", Euphonium: "euphonium", Fife: "fife", Flageolet: "flageolet", Flugabone: null, Fluier: null, Flumpet: "flumpet", "Flute D'Amour": "fl\xFBte d'amour", Friscaletto: null, Fujara: "fujara", Galoubet: "three-hole pipe", Gemshorn: "gemshorn", Gudastviri: null, Harmet: null, Heckelphone: "heckelphone", Helicon: "helicon", Hichiriki: "hichiriki", "Highland Pipes": null, Horagai: null, Horn: "horn", Horns: null, Hotchiku: "hotchiku", "Hunting Horn": null, Jug: "jug", Kagurabue: "kagurabue", Kaval: "kaval", Kazoo: "kazoo", Khene: "khene", Kortholt: "kortholt", Launeddas: "launeddas", Limbe: "limbe", Liru: null, "Low Whistle": "low whistle", Lur: null, Lyricon: "lyricon", M\u00E4nkeri: null, Mellophone: "mellophone", Melodeon: null, Mey: null, Mizmar: null, Mizwad: "mezwed", Moce\u00F1o: null, "Mouth Organ": "mouth organ", Murli: null, Musette: null, Nadaswaram: "nadaswaram", Ney: "ney", "Northumbrian Pipes": "northumbrian pipes", "Nose Flute": "nose flute", "Oboe d'Amore": "oboe d'amore", "Oboe Da Caccia": "oboe da caccia", Ocarina: "ocarina", Ophicleide: "ophicleide", "Overtone Flute": null, Panpipes: "pan flute", "Piano Accordion": "piano accordion", "Piccolo Flute": null, "Piccolo Trumpet": "piccolo trumpet", Pipe: null, Piri: "piri", Pito: "three-hole pipe", Pixiephone: null, Quena: "quena", Quenacho: null, Quray: null, Rauschpfeife: "rauschpfeife", Recorder: "recorder", Reeds: "reeds", Rhaita: null, Rondador: "rondador", Rozhok: null, Ryuteki: "ryuteki", Sackbut: "sackbut", Salamuri: null, Sampona: "siku", Sarrusophone: "sarrusophone", Saxello: null, Saxhorn: null, Schwyzer\u00F6rgeli: "schwyzer\xF6rgeli", Serpent: "serpent", Shakuhachi: "shakuhachi", Shanai: "shehnai", Shawm: "shawm", Shenai: "shehnai", Sheng: "sheng", Shinobue: "shinobue", Sho: "sho", "Shruti Box": "shruti box", "Slide Whistle": "slide whistle", Smallpipes: null, Sodina: null, Sopilka: "sopilka", "Sopranino Saxophone": "sopranino saxophone", "Soprano Clarinet": "soprano clarinet", "Soprano Cornet": null, "Soprano Flute": "soprano flute", "Soprano Trombone": null, Souna: null, Sousaphone: "sousaphone", "Subcontrabass Saxophone": null, Suling: "suling", Suona: "suona", Taepyungso: null, T\u00E1rogat\u00F3: "taragot", "Tenor Horn": null, "Tenor Trombone": "tenor trombone", "Ti-tse": null, "Tin Whistle": "tin whistle", Tonette: "tonette", Txirula: "three-hole pipe", Txistu: "three-hole pipe", "Uilleann Pipes": "uilleann pipes", "Valve Trombone": "valve trombone", "Valve Trumpet": null, "Wagner Tuba": "wagner tuba", Whistle: "whistle", "Whistling Water Jar": "jug", Wind: null, Woodwind: "woodwind", Xiao: "xiao", Yorgaphone: null, Zhaleika: "zhaleika", Zukra: null, Zurna: "zurna", "Automatic Orchestra": null, Computer: null, "Drum Machine": "drum machine", "Rhythm Box": "drum machine", // #223 — Discogs "[Rhythm Box]" preset drum unit Effects: "effects", Groovebox: null, Loops: null, "MIDI Controller": null, Noises: null, Sampler: "sampler", Scratches: "turntable", Sequencer: null, "Software Instrument": null, Talkbox: "talkbox", Tannerin: null, Tape: "tape", Turntables: "turntable", // 'Accompanied By' deliberately NOT in INSTRUMENTS — it's a meta role, // not an instrument. Mapped in ENTITY_TYPE_MAP to performer + additional. "Audio Generator": null, "Backing Band": null, Band: null, // Discogs "Bass" is generic (could be bass guitar, double bass, …) but MB // has a generic "bass" instrument for exactly that case. Leaving it null // dispatched a bare instrument rel with no instrument → "Missing instrument" // on commit (#133). Map to MB's generic "bass". Bass: "bass", "Brass Band": null, Bullroarer: "bullroarer", "Concert Band": null, "E-Bow": "ebow", Ensemble: null, Gamelan: "gamelan", "Glass Harmonica": "glass harmonica", Guest: null, Homus: null, Instruments: null, "Jew's Harp": "mouth harp", Morchang: null, Musician: null, Orchestra: null, Performer: null, "Rhythm Section": null, Saw: null, Siren: null, Soloist: null, Sounds: null, Toy: null, Trautonium: "trautonium", "Wind Chimes": null, "Wobble Board": null }; // src/mappers.js var INSTRUMENTS_CI = Object.fromEntries( Object.entries(INSTRUMENTS).map(([k, v]) => [k.toLowerCase(), v]) ); function guessSortName(name) { if (!name || !name.trim()) return name; name = name.trim(); const articleRe = /^(the|a|an)\s+(.+)$/i; const honorifics = /^(dr\.?|prof\.?|sir|lady|lord|rev\.?|st\.?|dj|mc|mc\.?)\s+/i; const suffixRe = /^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i; const words = name.split(/\s+/); if (words.length === 1) return name; const articleMatch = name.match(articleRe); if (articleMatch) { const article = articleMatch[1]; const rest = articleMatch[2]; return `${rest}, ${article.charAt(0).toUpperCase() + article.slice(1).toLowerCase()}`; } let suffix = ""; let baseName = name; const suffixMatch = name.match(suffixRe); if (suffixMatch) { baseName = suffixMatch[1].trim(); suffix = " " + suffixMatch[2]; } const baseWords = baseName.split(/\s+/); if (baseWords.length === 1) return name; const familyName = baseWords[baseWords.length - 1]; const givenPart = baseWords.slice(0, -1).join(" "); return `${familyName}, ${givenPart}${suffix}`; } function flattenTracklist(tracklist) { if (!Array.isArray(tracklist)) return []; return tracklist.flatMap((t) => { if (t?.type_ === "index" && Array.isArray(t.sub_tracks)) { const parentExtra = Array.isArray(t.extraartists) ? t.extraartists : []; if (!parentExtra.length) return t.sub_tracks; return t.sub_tracks.map((st) => Object.assign({}, st, { extraartists: [...Array.isArray(st.extraartists) ? st.extraartists : [], ...parentExtra] })); } return [t]; }); } function getAllArtistTracks(tracklist, artistTracks) { tracklist = flattenTracklist(tracklist); return artistTracks.split(",").reduce((trackArray, trackNumber) => { if (/ to /.test(trackNumber)) { const parts = trackNumber.split(" to "); const startTrack = parts[0].trim().replace(".", "-"); const lastTrack = parts[1].trim().replace(".", "-"); let hasFoundStart = false, hasFoundEnd = false; tracklist.forEach((track) => { const resolvedTrackPosition = track.position.replace(".", "-"); if (!hasFoundStart && resolvedTrackPosition === startTrack) { hasFoundStart = true; trackArray.push(track); } else if (hasFoundStart && !hasFoundEnd) { if (resolvedTrackPosition === lastTrack) { hasFoundEnd = true; trackArray.push(track); } else if (track.position === "") { hasFoundEnd = true; } else { trackArray.push(track); } } }); } else { const track = tracklist.find((track2) => { return track2.position === trackNumber.trim(); }); if (track) { trackArray.push(track); } } return trackArray; }, []); } function convertPotentialDJMixers(json) { let djmixers = json.extraartists?.filter((artist) => artist.role === "DJ Mix") || []; djmixers = djmixers.map((artist) => { const tracks = getAllArtistTracks(json.tracklist, artist.tracks); const mediums = json.tracklist.reduce( (mediums2, track, index) => { if (track.type_ === "heading") { if (index > 0) { mediums2.push([]); } } else { mediums2[mediums2.length - 1].push(track); } return mediums2; }, [[]] ); tracks.forEach((t) => { for (let i = 0; i < mediums.length; i++) { mediums[i] = mediums[i].filter((track) => { return t.position !== track.position; }); } }); let mediumsDjAppearsOn = mediums.filter((medium) => medium.length === 0); if (mediumsDjAppearsOn.length !== mediums.length) { json.extraartists = json.extraartists?.filter((a) => { return a !== artist; }) || []; return Object.assign({}, ENTITY_TYPE_MAP["DJ Mix"], { artist, attributes: [ () => { for (let j = mediums.length - 1; j >= 0; j--) { if (mediums[j].length === 0) { $(SELECTORS.MediumsInput).click(); $($(SELECTORS.MediumsInputOptions).get(j)).click(); } } } ] }); } else if (mediumsDjAppearsOn.length === mediums.length) { json.extraartists = json.extraartists?.filter((a) => { return a !== artist; }) || []; return Object.assign({}, ENTITY_TYPE_MAP["DJ Mix"], { artist }); } return null; }).filter((role) => role !== null); return djmixers; } function getArtistRoles(artist) { const roleStr = artist.role; const rawRoles = roleStr.split(","); if (/\([0-9]+\)/.test(artist.anv)) { artist.anv = artist.anv.replace(/\([0-9]+\)/, "").trim(); } if (/\([0-9]+\)/.test(artist.name)) { artist.name = artist.name.replace(/\([0-9]+\)/, "").trim(); } return rawRoles.map((role) => { let additionalAttributes = []; let creditedAs = null; let rolePart = role.trim().split("["); const actualRole = rolePart[0].trim(); if (/Recording Engineer/.test(rolePart[1]) && actualRole === "Engineer") { return Object.assign({}, ENTITY_TYPE_MAP["Recording Engineer"], { artist }); } if (/Mastering Engineer/.test(rolePart[1]) && actualRole === "Engineer") { return Object.assign({}, ENTITY_TYPE_MAP["Mastered By"], { artist }); } if (/Cover Design/.test(rolePart[1]) && actualRole === "Artwork") { return Object.assign({}, ENTITY_TYPE_MAP["Design"], { artist }); } if (/Design/.test(rolePart[1]) && actualRole === "Cover") { return Object.assign({}, ENTITY_TYPE_MAP["Design"], { artist }); } if (/Art/.test(rolePart[1]) && actualRole === "Cover") { return Object.assign({}, ENTITY_TYPE_MAP["Artwork"], { artist }); } if (/Additional/.test(rolePart[1])) { additionalAttributes.push("additional"); } if (/Assistant/.test(rolePart[1])) { additionalAttributes.push("assistant"); } if (/Co /.test(rolePart[1])) { additionalAttributes.push("additional"); } if (/Executive/.test(rolePart[1])) { additionalAttributes.push("executive"); } if (/Associate/.test(rolePart[1])) { additionalAttributes.push("associate"); } if (/Guest/.test(rolePart[1])) { additionalAttributes.push("guest"); } if (/Solo/.test(rolePart[1])) { additionalAttributes.push("solo"); } const mapping = ENTITY_TYPE_MAP[actualRole]; if (mapping && mapping.linkType == "misc") { const taskValue = rolePart[1] ? rolePart[1].replace("]", "").trim().toLowerCase() : actualRole.trim().toLowerCase(); additionalAttributes.push({ _type: "task", value: taskValue }); } if (mapping && mapping.linkType == "engineer" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "mix" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "photography" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "artwork" && rolePart[1]) { additionalAttributes.push({ _type: "task", value: rolePart[1].replace("]", "").trim().toLowerCase() }); } if (mapping && mapping.linkType == "vocal" && rolePart[1]) { creditedAs = rolePart[1].replace(/]/g, "").trim() || null; } const actualRoleLc = actualRole.toLowerCase(); if (!mapping && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, actualRoleLc)) { let instrumentName = INSTRUMENTS_CI[actualRoleLc]; let role2 = ENTITY_TYPE_MAP.Instruments; if (actualRoleLc === "drum programming") { role2 = ENTITY_TYPE_MAP["Programmed By"]; instrumentName = INSTRUMENTS_CI["drum machine"]; } if (!instrumentName && rolePart[1]) { const bracket = rolePart[1].replace(/]/g, "").trim(); for (const candidate of [bracket, bracket.split(",")[0].trim()]) { const lc = candidate.toLowerCase(); if (lc && Object.prototype.hasOwnProperty.call(INSTRUMENTS_CI, lc) && INSTRUMENTS_CI[lc]) { instrumentName = INSTRUMENTS_CI[lc]; break; } } } return Object.assign({}, role2, { artist, attributes: instrumentName ? [{ _type: "instrument", value: instrumentName.toLowerCase() }] : [] }); } if (!mapping) { return null; } if (Array.isArray(mapping.attributes)) { let mapped = mapping.attributes; if (creditedAs && mapping.linkType === "vocal") { mapped = mapped.map((a) => a && a._type === "vocal" ? Object.assign({}, a, { creditedAs }) : a); } additionalAttributes = additionalAttributes.concat(mapped); } return Object.assign({}, mapping, { artist, attributes: additionalAttributes }); }).filter((resolvedRole) => { return !!resolvedRole; }); } function rolesFromDiscogsArtists(artists) { return artists?.reduce((rolesArr, artist) => { const roles = getArtistRoles(artist); if (Array.isArray(roles) && roles.length > 0) { return rolesArr.concat(roles); } return rolesArr; }, []) || []; } // src/sources/tidal.js var TIDAL_PERTRACK_BRIDGE = { "Vocal": "Vocals", "Background Vocal": "Backing Vocals", "Performance Arranger": "Arranged By" }; var TIDAL_PERTRACK_SKIP = /* @__PURE__ */ new Set(["Mastering Engineer", "Associated Performer", "Studio Personnel"]); var TIDAL_ROLE_MAP = { "Producer": { target: "recording", rel: "producer" }, "Remixer": { target: "recording", rel: "remixer" }, // #257 artist→recording remixer (was unrecognized → dropped) "Mixing Engineer": { target: "recording", rel: "mix" }, "Recording Engineer": { target: "recording", rel: "recording" }, "Sound Engineer": { target: "recording", rel: "sound engineer" }, "Lead Vocalist": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "lead vocals" }] }, // #257 // #266 Tidal's per-track background-vocal roles → recording vocal[background vocals] // (same MB attribute as the release-level "Backing Vocals", see entity-map.js). "Group Background Vocalists": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Background Vocalist": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Background Vocalists": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Composer": { target: "work", rel: "composer" }, "Lyricist": { target: "work", rel: "lyricist" }, "Writer": { target: "work", rel: "writer" }, "Orchestrator": { target: "work", rel: "orchestrator" }, "Music Publisher": { target: "work", rel: "publisher" }, "Publisher": { target: "work", rel: "publisher" } // Not mapped (reported, not imported): Mastering Engineer (artist→recording mastering // is deprecated in MB — it's release-level), Sound Editor, the Assistant * Engineer // variants (need an MB "assistant" attribute), and Studio Personnel (too generic). }; var TIDAL_COPYRIGHT_CONTROL_ID = "15780"; var isCopyrightControl = (n) => n?.tidalId === TIDAL_COPYRIGHT_CONTROL_ID || /^copyright control$/i.test(n?.name || ""); var TIDAL_RELEASE_ROLE_MAP = { "Mixing": "Mixed By", "Mixing Engineer": "Mixed By", "Recording": "Recorded By", "Recording Engineer": "Recorded By", "Sound Engineer": "sound engineer", "Mastering": "Mastered By", "Mastering Engineer": "Mastered By", "Vocals (Background)": "Backing Vocals", "Background Vocals": "Backing Vocals", "Composer": "Composed By", "Lyricist": "Lyrics By", "Writer": "Written-By", "Orchestrator": "Orchestrated By", // #325 instrument-name variants Tidal uses that the INSTRUMENTS table doesn't // match verbatim (plurals / qualified forms) → canonical Discogs instrument names. "Guitars": "Guitar", "Bass Instrument": "Bass", "Bass Guitars": "Bass Guitar", "Sax (Alto)": "Alto Saxophone", "Sax (Tenor)": "Tenor Saxophone", "Sax (Baritone)": "Baritone Saxophone", "Sax (Soprano)": "Soprano Saxophone", "Keyboard": "Keyboards" }; var TIDAL_RELEASE_COMPANY_MAP = { "Current Distributor": "Distributed By", "Distributor": "Distributed By" }; var TIDAL_RELEASE_ROLE_SKIP = /* @__PURE__ */ new Set([ "Primary Artist", "Featured Artist", "Main Artist", "Artist", "Record Label", "Manufacturer", "Copyright", "Phonographic Copyright" ]); var TIDAL_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/i; function parseTidalAlbumUrl(url) { const m = TIDAL_ALBUM_RE.exec(url || ""); if (!m) return null; return { id: m[1], creditsUrl: `https://tidal.com/album/${m[1]}/credits` }; } function extractTidalCreditsDom(doc) { const out = []; for (const item of doc.querySelectorAll('[data-test="album-info-item"]')) { const num = item.querySelector('[class*="_trackNumber"]')?.textContent?.trim() || ""; const titleEl = item.querySelector('[class*="_title_"]'); const credits = []; for (const cell of item.querySelectorAll('[class*="_creditsCell"]')) { const role = cell.querySelector("[data-uppercase]")?.textContent?.trim(); if (!role) continue; const names = [...cell.querySelectorAll('[data-test="grid-item-detail-text-title-artist"]')].map((el) => ({ name: el.getAttribute("title") || el.textContent.trim(), tidalId: (el.getAttribute("href") || "").match(/\/artist\/(\d+)/)?.[1] || null })).filter((n) => n.name); credits.push({ role, names }); } out.push({ num, title: titleEl?.getAttribute("title") || titleEl?.textContent?.trim() || "", tidalTrackId: titleEl?.getAttribute("data-test-id") || null, credits }); } return out; } function extractTidalReleaseCreditsDom(doc) { const out = []; for (const cell of doc.querySelectorAll('[class*="_creditsCell"]')) { if (cell.closest('[data-test="album-info-item"]')) continue; const role = cell.querySelector("[data-uppercase]")?.textContent?.trim(); if (!role) continue; const names = []; const seen = /* @__PURE__ */ new Set(); const textBox = cell.querySelector('[class*="_creditsCellText"]') || cell; for (const el of textBox.querySelectorAll("a, [title]")) { const name = (el.getAttribute("title") || el.textContent || "").trim(); if (!name || seen.has(name)) continue; seen.add(name); names.push({ name, tidalId: (el.getAttribute("href") || "").match(/\/artist\/(\d+)/)?.[1] || null }); } if (!names.length) { (textBox.textContent || "").split(/\s*,\s*/).map((s) => s.trim()).filter(Boolean).forEach((name) => { if (!seen.has(name)) { seen.add(name); names.push({ name, tidalId: null }); } }); } if (names.length) out.push({ role, names }); } return out; } var ASSISTANT_RE = /^Assistant\s+(.+)$/i; function tidalRoleBase(role) { const m = ASSISTANT_RE.exec(role || ""); return m ? m[1] : role; } function filterTidalCredits(tracks) { return tracks.map((t) => ({ ...t, credits: t.credits.map((c) => ({ ...c, names: c.names.filter((n) => !(/^(?:Music )?Publisher$/.test(c.role) && isCopyrightControl(n))) })).filter((c) => c.names.length) })); } var TIDAL_ARTIST_RE = /^(?:https?:)?\/\/(?:www\.|listen\.)?tidal\.com\/(?:browse\/)?artist\/(\d+)/i; function parseTidalArtistUrl(url) { const m = TIDAL_ARTIST_RE.exec(url || ""); if (!m) return null; return { id: m[1], key: `tidal-artist/${m[1]}`, cleanUrl: `https://tidal.com/artist/${m[1]}` }; } function tidalPublisherRole(name, track) { const role = { linkType: "publishing", entityType: "label", attributes: [], artist: { name, anv: "", entityType: "label", resource_url: "" } }; if (track) role.track = track; return role; } function tidalCompany(name, entityTypeName) { return { entity_type_name: entityTypeName, name, resource_url: "https://tidal.com/_company/" + encodeURIComponent(entityTypeName) + "/" + encodeURIComponent(name) }; } function tidalToEngine(tracks) { const tracklistRels = []; const tracklist = []; const skipped = []; const filtered = filterTidalCredits(tracks); const nums = filtered.map((t) => String(t.num || "").trim()); const multiVolume = nums.some((n, i) => n && nums.indexOf(n) < i); let vol = 1; const seenInVol = /* @__PURE__ */ new Set(); for (const t of filtered) { const num = String(t.num || "").trim(); if (multiVolume && num && seenInVol.has(num)) { vol++; seenInVol.clear(); } if (num) seenInVol.add(num); const position = multiVolume ? `${vol}-${num}` : num; const track = { position, title: t.title || "", type_: "track" }; tracklist.push(track); for (const c of t.credits) { const base = tidalRoleBase(c.role); const assistant = base !== c.role; if (base === "Music Publisher" || base === "Publisher") { for (const n of c.names) { if (isCopyrightControl(n)) continue; tracklistRels.push(tidalPublisherRole(n.name, track)); } continue; } const mapping = TIDAL_ROLE_MAP[base]; if (mapping) { for (const n of c.names) { tracklistRels.push({ linkType: mapping.rel, entityType: "artist", attributes: [...mapping.attributes || [], ...assistant ? ["assistant"] : []], artist: { id: n.tidalId ? `tidal-${n.tidalId}` : void 0, name: n.name, anv: "", resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : "" }, track }); } continue; } if (TIDAL_PERTRACK_SKIP.has(base)) { for (const n of c.names) skipped.push(`track ${position} "${t.title}": ${c.role} \u2014 ${n.name}`); continue; } const discogsRole = TIDAL_PERTRACK_BRIDGE[base] || base; for (const n of c.names) { const artist = { id: n.tidalId ? `tidal-${n.tidalId}` : void 0, name: n.name, anv: "", role: discogsRole, resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : "" }; const rels = getArtistRoles(artist); if (!rels.length) { skipped.push(`track ${position} "${t.title}": ${c.role} \u2014 ${n.name}`); continue; } for (const r of rels) { tracklistRels.push({ linkType: r.linkType, entityType: "artist", attributes: [...r.attributes || [], ...assistant ? ["assistant"] : []], artist: r.artist, track }); } } } } return { tracklistRels, tracklist, skipped, multiVolume }; } function tidalReleaseArtists(releaseCredits) { const artists = []; const publishers = []; const companies = []; const skipped = []; for (const c of releaseCredits || []) { const baseRole = tidalRoleBase(c.role); if (baseRole === "Music Publisher" || baseRole === "Publisher") { for (const n of c.names || []) { if (isCopyrightControl(n)) continue; publishers.push(tidalPublisherRole(n.name)); } continue; } if (TIDAL_RELEASE_COMPANY_MAP[c.role]) { for (const n of c.names || []) companies.push(tidalCompany(n.name, TIDAL_RELEASE_COMPANY_MAP[c.role])); continue; } if (TIDAL_RELEASE_ROLE_SKIP.has(c.role)) { (c.names || []).forEach((n) => skipped.push(`release: ${c.role} \u2014 ${n.name}`)); continue; } const base = tidalRoleBase(c.role); const assistant = base !== c.role; const discogsRole = TIDAL_RELEASE_ROLE_MAP[base] || base; for (const n of c.names || []) { artists.push({ id: n.tidalId ? `tidal-${n.tidalId}` : void 0, name: n.name, anv: "", role: discogsRole, assistant, tidalRole: c.role, // for "not imported" reporting at the call site resource_url: n.tidalId ? `https://tidal.com/artist/${n.tidalId}` : "" }); } } return { artists, publishers, companies, skipped }; } var HARVEST_KEY = (reqId) => `ch-tidal-result:${reqId}`; var HARVEST_TIMEOUT_MS = 45e3; function runTidalHarvestPage() { const m = location.hash.match(/ch-req=([a-z0-9.-]+)/i); if (!m) return; const reqId = m[1]; const albumId = (location.pathname.match(/\/album\/(\d+)/) || [])[1] || null; const post = (payload) => { try { GM_setValue(HARVEST_KEY(reqId), { albumId, ts: Date.now(), ...payload }); } catch (e) { } }; const started = Date.now(); let lastCount = -1, stableSince = 0; const timer = setInterval(() => { const items = document.querySelectorAll('[data-test="album-info-item"]'); if (items.length > 0) { if (items.length !== lastCount) { lastCount = items.length; stableSince = Date.now(); } else if (Date.now() - stableSince > 1200) { clearInterval(timer); const tracks = extractTidalCreditsDom(document); harvestReleaseThenPost(tracks, post); return; } } if (Date.now() - started > HARVEST_TIMEOUT_MS - 5e3) { clearInterval(timer); post({ ok: false, error: lastCount > 0 ? "render never stabilised" : "credits never rendered (login wall? geo block?)" }); setTimeout(() => window.close(), 250); } }, 300); } function harvestReleaseThenPost(tracks, post) { const finish = (releaseCredits) => { post({ ok: true, tracks, releaseCredits }); setTimeout(() => window.close(), 250); }; const infoTab = document.querySelector('[data-test="album-info-tab-info"]'); if (!infoTab) { finish([]); return; } try { infoTab.click(); } catch (e) { finish([]); return; } const start = Date.now(); const t = setInterval(() => { const cells = [...document.querySelectorAll('[class*="_creditsCell"]')].filter((c) => !c.closest('[data-test="album-info-item"]')); if (cells.length > 0) { clearInterval(t); setTimeout(() => finish(extractTidalReleaseCreditsDom(document)), 600); } else if (Date.now() - start > 6e3) { clearInterval(t); finish([]); } }, 250); } function harvestTidalAlbum(albumUrl) { const parsed = parseTidalAlbumUrl(albumUrl); if (!parsed) return Promise.reject(new Error(`Not a Tidal album URL: ${albumUrl}`)); const reqId = `${parsed.id}.${Date.now().toString(36)}`; const key = HARVEST_KEY(reqId); const harvestUrl = `${parsed.creditsUrl}#ch-req=${reqId}`; if (typeof GM_openInTab === "function") { GM_openInTab(harvestUrl, { active: false, insert: true, setParent: true }); } else { const tab = window.open(harvestUrl, "_blank"); if (!tab) return Promise.reject(new Error("Popup blocked \u2014 allow popups for musicbrainz.org and retry")); } return new Promise((resolve, reject) => { let listenerId = null; let pollTimer = null; const done = (fn, arg) => { if (pollTimer) clearInterval(pollTimer); clearTimeout(deadline); try { if (listenerId !== null && typeof GM_removeValueChangeListener === "function") GM_removeValueChangeListener(listenerId); } catch (e) { } try { GM_deleteValue(key); } catch (e) { } fn(arg); }; const check = (value) => { if (value && typeof value === "object") done(resolve, value); }; if (typeof GM_addValueChangeListener === "function") { listenerId = GM_addValueChangeListener(key, (_n, _o, value) => check(value)); } pollTimer = setInterval(() => { try { check(GM_getValue(key)); } catch (e) { } }, 700); const deadline = setTimeout(() => done(reject, new Error("Tidal harvest timed out \u2014 is the credits tab open and loading?")), HARVEST_TIMEOUT_MS); }); } // src/sources/registry.js function parseSourceEntityUrl(url) { if (!url) return null; return parseDiscogsUrl(url) || parseTidalArtistUrl(url); } function idbKeyForEntity(entity) { if (!entity) return null; return parseSourceEntityUrl(entity.resource_url)?.key || entity._cacheKey || null; } function sourceNameForUrl(url) { if (/tidal\.com\//i.test(url || "")) return "Tidal"; if (/qobuz\.com\//i.test(url || "")) return "Qobuz"; return "Discogs"; } function sourceUrlLinkTypeId(url, entityType) { const src = sourceNameForUrl(url); if (src === "Tidal") return entityType === "artist" ? "978" : null; if (src === "Qobuz") return null; return entityType === "label" ? "217" : entityType === "place" ? "705" : "180"; } // src/preflight.js var KIND_TABLE = { artist: { searchLimit: 10, resultKey: "artists", incRels: "artist-rels" }, label: { searchLimit: 8, resultKey: "labels", incRels: "label-rels" }, // Places also accept label-rels because MB editors often file a // facility as a label rather than a place (issue we've worked around // since the original company resolver). place: { searchLimit: 8, resultKey: "places", incRels: "place-rels+label-rels" } }; async function resolveEntity(entity, kind, opts) { const { bypassIdb } = opts; const { searchLimit, resultKey, incRels } = KIND_TABLE[kind]; const parsed = parseSourceEntityUrl(entity.resource_url); const key = parsed?.key || entity._cacheKey || null; const searchName = entity.name; const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name; const discogsHref = entity.resource_url.replace(/https:\/\/api\.discogs\.com\/(\w+?)s\/(\d+)/, "https://www.discogs.com/$1/$2"); function buildResolved(mbUrl, mbName, mbDisambig, via2, actualKind = kind, fromCache = false, urlLinkedIds2, creditOverride) { return { type: "resolved", entityType: actualKind, entity, displayName, discogsHref, mbUrl, mbName, mbDisambig, // User's saved "Credited as" override from a prior session // (IDB `creditOverride` field). Review-table reads this in // `pickPrefill` to populate the field. Undefined when no // prior override exists. #105. creditOverride, // `urlLinkedIds` — MBIDs that have a relation to this Discogs URL, // harvested from the URL lookup done during // preflight. The review-table uses this to render // the "Add Discogs link" / "already linked" / "linked // to different MB " badge without issuing // another `/ws/2/url?…` query per row. `undefined` // means "preflight didn't ask MB" (IDB hit on a // legacy record that predates this field), in which // case review-table falls back to its own per-row // fetch. `[]` means "asked MB, got no relations" — // no fallback needed. urlLinkedIds: urlLinkedIds2, // `via` — the resolution mechanism (`name` / `url` / `both` / `user`, // or `cache` only when a legacy IDB record predates the // `resolvedVia` field and we genuinely can't recover it). // `fromCache`— whether THIS resolution came from IDB rather than a fresh // MB lookup. The two are orthogonal: a name-resolved entity // loaded from cache is `via='name'` + `fromCache=true`, and // the UI surfaces both as `name (cache)`. logEntry: { displayName, discogsHref, mbUrl, mbName, mbDisambig, via: via2, fromCache } }; } function buildAttention(nameMatches2, nameSearchFailed2, ambiguityReason, urlLinkedIds2, creditOverride) { return { type: "attention", entityType: kind, entity, displayName, discogsHref, nameMatches: nameMatches2 || [], // Saved "Credited as" override — see buildResolved. #105. creditOverride, // Same `urlLinkedIds` contract as on the resolved shape — review-table // uses it to skip the per-row URL fetch even for attention rows once // the user picks an MBID from the candidate list. urlLinkedIds: urlLinkedIds2, // Only artists track this — used by the review table to badge // entries that failed because of a rate-limited name search vs // entries that genuinely don't exist in MB. rateLimited: kind === "artist" && nameSearchFailed2 && !nameMatches2?.length, ambiguityReason: ambiguityReason || null }; } async function fetchMbEntityInfo(et, mbid) { const json = await mbThrottle.fetchJson(`//musicbrainz.org/ws/2/${et}/${mbid}?fmt=json`); return json ? { name: json.name || null, disambiguation: json.disambiguation || "" } : { name: null, disambiguation: "" }; } if (bypassIdb && key) { await deleteIdbRecord(key); } if (!bypassIdb && key) { const cachedRec = await readIdbRecord(key); if (cachedRec?.mbid && cachedRec?.entityType) { const via2 = cachedRec.resolvedVia || "cache"; let cachedLinkedIds = cachedRec.urlLinkedIds; if (cachedLinkedIds === void 0 && (via2 === "url" || via2 === "both")) { cachedLinkedIds = [cachedRec.mbid]; } if (Array.isArray(cachedLinkedIds) && cachedLinkedIds.length === 0) cachedLinkedIds = void 0; if (cachedRec.name) { return buildResolved( cachedRec.mbUrl, cachedRec.name, cachedRec.disambiguation || "", via2, cachedRec.entityType, true, cachedLinkedIds, cachedRec.creditOverride ); } const info = await fetchMbEntityInfo(cachedRec.entityType, cachedRec.mbid); if (info.name) { await writeIdbRecord(key, { name: info.name, disambiguation: info.disambiguation }); } return buildResolved( cachedRec.mbUrl, info.name, info.disambiguation, via2, cachedRec.entityType, true, cachedLinkedIds, cachedRec.creditOverride ); } if (cachedRec && Array.isArray(cachedRec.nameMatches)) { const attnLinkedIds = Array.isArray(cachedRec.urlLinkedIds) && cachedRec.urlLinkedIds.length === 0 ? void 0 : cachedRec.urlLinkedIds; return buildAttention(cachedRec.nameMatches, false, null, attnLinkedIds, cachedRec.creditOverride); } } const [nameJson, urlJson] = await Promise.all([ mbThrottle.fetchJson( `//musicbrainz.org/ws/2/${kind}?query=${encodeURIComponent(searchName)}&fmt=json&limit=${searchLimit}` ), parsed ? mbThrottle.fetchJson404( `//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(parsed.cleanUrl)}&inc=${incRels}&fmt=json` ) : Promise.resolve({ notFound: true }) ]); const nameSearchFailed = nameJson === null; const normalized = searchName.toLowerCase().trim(); const nameMatches = !nameJson?.[resultKey] ? [] : nameJson[resultKey].filter((a) => a.name.toLowerCase().trim() === normalized || a.score != null && a.score >= 70).map((a) => ({ id: a.id, name: a.name, disambiguation: a.disambiguation || a["disambiguation-comment"] || "", score: a.score || 0 })); const exactNameMatches = nameMatches.filter((a) => a.name.toLowerCase().trim() === normalized); const nameHit = exactNameMatches.length === 1 ? { kind, mbid: exactNameMatches[0].id, name: exactNameMatches[0].name, disambiguation: exactNameMatches[0].disambiguation || "" } : null; let urlHit = null; const urlLinkedIds = urlJson === null ? void 0 : (urlJson.relations || []).map((r) => kind === "place" ? r.place?.id || r.label?.id || null : r[kind]?.id || null).filter(Boolean); if (urlJson?.relations?.length > 0) { const rel = kind === "place" ? urlJson.relations.find((r) => r.place || r.label) : urlJson.relations.find((r) => r[kind]); if (rel) { const actualKind = rel[kind] ? kind : rel.label ? "label" : "place"; const a = rel[actualKind]; urlHit = { kind: actualKind, mbid: a.id, name: a.name || null, disambiguation: a.disambiguation || "" }; } } async function cacheAttention(matches) { if (key && !nameSearchFailed) { await writeIdbRecord(key, { mbid: null, entityType: null, name: null, mbUrl: null, disambiguation: "", resolvedVia: null, nameMatches: matches, // Omit when unknown (lookup failed) — never persist a guess. ...urlLinkedIds !== void 0 && { urlLinkedIds } }); } } let resolved = null; let via = null; if (nameHit && urlHit) { if (nameHit.mbid === urlHit.mbid && nameHit.kind === urlHit.kind) { resolved = urlHit; via = "both"; } else { await cacheAttention(nameMatches); return buildAttention( nameMatches, false, `name \u2192 ${nameHit.kind}/${nameHit.mbid}, URL \u2192 ${urlHit.kind}/${urlHit.mbid}`, urlLinkedIds ); } } else if (urlHit) { resolved = urlHit; via = "url"; } else if (nameHit) { resolved = nameHit; via = "name"; } if (resolved) { const mbUrl = `//musicbrainz.org/${resolved.kind}/${resolved.mbid}`; let finalName = resolved.name; let finalDisam = resolved.disambiguation; if (!finalName) { const info = await fetchMbEntityInfo(resolved.kind, resolved.mbid); finalName = info.name || null; finalDisam = info.disambiguation || ""; } if (key) { await writeIdbRecord(key, { mbid: resolved.mbid, entityType: resolved.kind, name: finalName, disambiguation: finalDisam || "", resolvedVia: via, // Omit when unknown (lookup failed) — never persist a guess. ...urlLinkedIds !== void 0 && { urlLinkedIds } }); } return buildResolved(mbUrl, finalName, finalDisam || "", via, resolved.kind, false, urlLinkedIds); } await cacheAttention(nameMatches); return buildAttention(nameMatches, nameSearchFailed, null, urlLinkedIds); } async function resolveAll(entities, opts) { const { kindOf, progressLi, bypassIdb, progressLabel } = opts; const CONCURRENCY = 5; const MIN_GAP_MS = 50; let done = 0; const inFlightNames = /* @__PURE__ */ new Set(); function setProgress() { if (entities.length > 0) { try { _setProgressPct(done / entities.length * 100); } catch (_) { } } if (!progressLi) return; const remaining = entities.length - done; const checking = inFlightNames.size ? ` \u2014 checking ${[...inFlightNames].join(", ")}` : ""; progressLi.innerHTML = `${progressLabel}\u2026 ${done}/${entities.length} done${checking}` + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`); try { const plain = `${progressLabel}\u2026 ${done}/${entities.length} done` + (inFlightNames.size ? ` \u2014 checking ${[...inFlightNames].join(", ")}` : "") + (remaining === 0 ? " \u2714" : ` (${remaining} remaining)`); document.querySelector(".discogs-bar")?._setProgress?.(null, plain); } catch (_) { } } const delay = (ms) => new Promise((r) => setTimeout(r, ms)); const queue = entities.map((e, i) => ({ entity: e, index: i })); const results = new Array(entities.length); setProgress(); async function worker(slotIndex) { const tag = `worker#${slotIndex}`; logDebug(`${tag} starting (stagger ${slotIndex * MIN_GAP_MS}ms)`); await delay(slotIndex * MIN_GAP_MS); let processed = 0; while (queue.length > 0) { const { entity, index } = queue.shift(); const kind = kindOf(entity); if (!kind) { logDebug(`${tag} skip "${entity?.name || "?"}" \u2014 no resolvable kind`); done++; setProgress(); continue; } const displayName = kind === "artist" ? entity.anv && entity.anv.trim() || entity.name : entity.name; inFlightNames.add(displayName); setProgress(); const t0 = Date.now(); logDebug(`${tag} resolving "${displayName}" (${kind})`); results[index] = await resolveEntity(entity, kind, { bypassIdb }); const elapsed = Date.now() - t0; const r = results[index]; const outcome = r?.type === "resolved" ? `resolved via ${r.logEntry?.via || "?"}${r.logEntry?.fromCache ? " (cache)" : ""}` : r?.type === "attention" ? `unresolved (${r.nameMatches?.length || 0} candidates)` : "skipped"; logDebug(`${tag} "${displayName}" -> ${outcome} in ${elapsed}ms`); inFlightNames.delete(displayName); done++; processed++; setProgress(); } logDebug(`${tag} finished (${processed} entit${processed === 1 ? "y" : "ies"})`); } const slots = Math.min(CONCURRENCY, entities.length); logDebug(`resolveAll: ${entities.length} entit${entities.length === 1 ? "y" : "ies"}, ${slots} worker slot(s)`); if (slots > 0) await Promise.all(Array.from({ length: slots }, (_, i) => worker(i))); logDebug(`resolveAll: done`); return { allResults: results.filter(Boolean) }; } var ENTITY_KIND = (e) => e?.entityType || "artist"; var COMPANY_KIND = (c) => ENTITY_TYPE_MAP[c.entity_type_name]?.entityType ?? null; // src/derive/remix.js var STRONG = /* @__PURE__ */ new Set([ "extended", "original", "radio", "instrumental", "acapella", "acappella", "acoustic", "album", "single", "main", "long", "short", "full", "special", "bonus", "alternative", "alternate", "vip", "rmx", "redux", "dancefloor", "unplugged", "demo", "remastered", // remix-family words, in case a lead carries a second one // ("Extended Remix Edit"): strip them off the trailing edge too. "dub", "edit", "mix", "remix", "rework", "remodel", "reshuffle", "reprise", "version", "re-edit", "reedit" ]); var WEAK = /* @__PURE__ */ new Set([ "club", "deep", "tech", "soulful", "disco", "electro", "house", "techno", "progressive", "tribal", "vocal", "classic", "clean", "dirty", "censored", "uncensored", "studio", "live", "the", "a", "an" ]); var SEP_RE = /\s*(?:&|\+|,|\/|\bfeat\.?\b|\bft\.?\b|\bfeaturing\b)\s*/i; var isVinyl = (t) => /^\d+(?:"|''|”|inch|in)?$/.test(t); var norm = (t) => t.toLowerCase().replace(/[.''`]+$/, ""); var isStrong = (t) => { const l = norm(t); return STRONG.has(l) || isVinyl(l); }; var isDecorator = (t) => { const l = norm(t); return STRONG.has(l) || WEAK.has(l) || isVinyl(l); }; var TRAILING_RE = /^(.+?)\s+((?:re[-_ ]?)?(?:remix|rework|remodel|reshuffle|reprise|edit|dub|mix))(?:es|ed|s|d)?$/i; var BY_RE = /^(?:re[-_ ]?)?(?:remix|rework|remodel|reshuffle|rerub)(?:es|ed|s|d)?\s+by\s+(.+)$/i; function cleanName(raw) { let s = String(raw || "").replace(/\s*[([].*$/, "").replace(/[)\]]+\s*$/, ""); let tokens = s.trim().split(/\s+/).filter(Boolean); const pIdx = tokens.findIndex((t) => /['']s$/i.test(t)); if (pIdx !== -1) { tokens = tokens.slice(0, pIdx + 1); tokens[pIdx] = tokens[pIdx].replace(/['']s$/i, ""); } while (tokens.length && isStrong(tokens[tokens.length - 1])) tokens.pop(); if (!tokens.length) return null; if (tokens.every(isDecorator)) return null; const name = tokens.join(" ").replace(/['']s$/i, "").trim(); if (!/[A-Za-z0-9]/.test(name)) return null; return name; } function parseRemixTitle(title) { const result = { base: title || "", remixers: [], kind: null }; if (!title || typeof title !== "string") return result; const groups = title.match(/[([][^)\]]*[)\]]/g); if (!groups) return result; for (const g of groups) { const inner = g.slice(1, -1).trim(); let captured = null, kind = null; let m = BY_RE.exec(inner); if (m) { captured = m[1]; kind = "remix"; } else { m = TRAILING_RE.exec(inner); if (m) { captured = m[1]; kind = /mix$/i.test(m[2]) && !/remix$/i.test(m[2]) ? "mix" : /edit$/i.test(m[2]) ? "edit" : /dub$/i.test(m[2]) ? "dub" : "remix"; } } if (!captured) continue; const names = captured.split(SEP_RE).map(cleanName).filter(Boolean); if (!names.length) continue; result.kind = result.kind || kind; for (const n of names) if (!result.remixers.includes(n)) result.remixers.push(n); result.base = result.base.replace(g, "").replace(/\s{2,}/g, " ").trim(); } return result; } function deriveRemixRoles(tracklist, releaseMbid) { if (!Array.isArray(tracklist)) return []; const roles = []; for (const track of tracklist) { if (!track || track.type_ && track.type_ !== "track") continue; if (!track.title) continue; const { remixers } = parseRemixTitle(track.title); for (const name of remixers) { const artist = { name, anv: "", resource_url: "", _derived: true }; if (releaseMbid) artist._cacheKey = `titles-remix/${releaseMbid}/${name.toLowerCase().trim()}`; roles.push({ linkType: "remixer", artist, track, creditedAs: name, attributes: [], entityType: "artist" }); } } return roles; } // src/edit-note.js function buildEditNote(sourceUrl, opts, extraLines) { const s = GM_info.script; const mbUrl = location.href.split(/[?#]/)[0].replace(/\/edit-relationships$/, ""); const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md"; const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage; const cleanSource = String(sourceUrl || "").split(/[?#]/)[0]; const sourceName = /tidal\.com/i.test(cleanSource) ? "Tidal" : /qobuz\.com/i.test(cleanSource) ? "Qobuz" : "Discogs"; const lines = [ header, "", "Release URL: " + mbUrl, // No source URL → the title-derived "Titles" source (#271): credits come // from the release's own track titles, not an external page. cleanSource ? sourceName + " URL: " + cleanSource : "Source: track titles" ]; if (opts) lines.push("Options: " + opts); if (extraLines) lines.push(...Array.isArray(extraLines) ? extraLines : [extraLines]); return lines.join("\n"); } function buildCreateNote(action = "Created the entity") { const s = GM_info.script; const homepage = s.homepageURL || s.homepage || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md"; const header = s.name + " v" + s.version + " by " + s.author + " - " + homepage; const mbUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ""); return header + "\n\n" + action + " while importing credits onto " + mbUrl; } function splitOurNote(note) { const headerPrefix = GM_info.script.name + " v"; const lines = String(note || "").split("\n"); const idx = lines.findIndex((l) => l.startsWith(headerPrefix)); if (idx === -1) return null; const pre = lines.slice(0, idx).join("\n").replace(/\s+$/, ""); const our = lines.slice(idx); const relIdx = our.findIndex((l) => /^Release URL:/i.test(l)); return { pre, header: our[0], releaseLine: relIdx !== -1 ? our[relIdx] : "", body: (relIdx !== -1 ? our.slice(relIdx + 1) : our.slice(1)).join("\n").trim() }; } function blockSourceKey(block) { const first = (String(block).split("\n")[0] || "").trim(); const m = first.match(/^([A-Za-z][A-Za-z ]*?)\s+URL:/); if (m) return m[1].trim().toLowerCase(); if (/^Source:\s*track titles/i.test(first)) return "titles"; return first.toLowerCase(); } function combineEditNote(existingNote, ourNote) { const fresh = splitOurNote(ourNote); if (!fresh) return ourNote; const newKey = blockSourceKey(fresh.body); const prev = splitOurNote(existingNote); const keptBlocks = prev ? prev.body.split(/\n\n+/).map((b) => b.trim()).filter(Boolean).filter((b) => blockSourceKey(b) !== newKey) : []; const stacked = [fresh.body, ...keptBlocks].join("\n\n"); const ourBlock = `${fresh.header} ${fresh.releaseLine} ${stacked}`; const pre = prev ? prev.pre : String(existingNote || "").replace(/\s+$/, ""); return pre ? `${pre} ${ourBlock}` : ourBlock; } // src/review-table.js var _urlCheckSessionCache = /* @__PURE__ */ new Map(); function ensureCreatingStyle() { if (document.getElementById("ch-creating-style")) return; const st = document.createElement("style"); st.id = "ch-creating-style"; st.textContent = "@keyframes ch-creating-spin { to { transform: rotate(360deg); } }"; document.head.appendChild(st); } async function showReviewTable(allResults, rolesMap, companiesRolesMap, opts) { rolesMap = rolesMap || /* @__PURE__ */ new Map(); companiesRolesMap = companiesRolesMap || /* @__PURE__ */ new Map(); const onRefresh = opts?.onRefresh || null; const headerSlot = opts?.headerSlot || null; const importSourceName = opts?.sourceName || "Discogs"; const sourceIcon = opts?.sourceIcon || ""; const _preloadedNames = /* @__PURE__ */ new Map(); const _nullNames = allResults.filter((r) => r.type === "resolved" && r.mbUrl && !r.mbName); for (const r of _nullNames) { const rUrl = r.entity?.resource_url; try { const idbKey = idbKeyForEntity(r.entity); const rec = await readIdbRecord(idbKey); if (rec?.name) { _preloadedNames.set(rUrl, { name: rec.name, dis: rec.disambiguation || "" }); continue; } const mbid = (r.mbUrl || "").split("/").pop().replace(/[^a-f0-9-]/g, "").substring(0, 36); if (!mbid) continue; const et = r.entityType || "artist"; const data = await mbThrottle.fetchJson(`https://musicbrainz.org/ws/2/${et}/${mbid}?fmt=json`); if (data?.name) { _preloadedNames.set(rUrl, { name: data.name, dis: data.disambiguation || "" }); if (idbKey) { await writeIdbRecord(idbKey, { mbid, entityType: et, name: data.name, disambiguation: data.disambiguation || "" // No resolvedVia change — this is just a name-display // populate; whatever set the cached mbid stays the // source of truth for `resolvedVia`. }); } } } catch (e) { } } return new Promise((resolve) => { const rowState = /* @__PURE__ */ new Map(); const rowSearchInputs = /* @__PURE__ */ new Map(); const linkState = /* @__PURE__ */ new Map(); const rowLinkChips = /* @__PURE__ */ new Map(); let linksNote = null; function updateLinksBadge() { if (!linksNote) return; const n = [...linkState.values()].filter((v) => v === "none").length; linksNote.textContent = n ? `\u{1F517} ${n} link${n === 1 ? "" : "s"}` : ""; linksNote.style.display = n ? "" : "none"; linksNote.classList.toggle("clickable", n > 0); } const keyOf = (r) => r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; let _linkJumpIdx = -1; function jumpNextLink() { const n = allResults.length; let found = -1; for (let step = 1; step <= n; step++) { const i = (_linkJumpIdx + step) % n; if (linkState.get(keyOf(allResults[i])) === "none") { found = i; break; } } if (found === -1) return; _linkJumpIdx = found; const key = keyOf(allResults[found]); const chip = rowLinkChips.get(key); const target = chip || rowSearchInputs.get(key); if (!target) return; target.scrollIntoView({ behavior: "smooth", block: "center" }); if (chip) { const o = chip.style.boxShadow; chip.style.boxShadow = "0 0 0 3px rgba(232,119,29,0.6)"; setTimeout(() => { chip.style.boxShadow = o; }, 1200); } } const attentionCount = allResults.filter((r) => r.type === "attention").length; const mismatchCount = allResults.filter((r) => { if (r.type !== "resolved") return false; const e = r.logEntry; return e && e.mbName && e.displayName && e.mbName.toLowerCase().trim() !== e.displayName.toLowerCase().trim(); }).length; const URL_CHECK_CONCURRENCY = 5; const urlCheckPending = []; let urlCheckRunning = 0; let urlCheckStarted = false; function queuedUrlCheck(fn) { return new Promise((resolve2, reject) => { urlCheckPending.push({ fn, resolve: resolve2, reject }); if (urlCheckRunning < URL_CHECK_CONCURRENCY) { runUrlCheckWorker(); } }); } async function runUrlCheckWorker() { urlCheckRunning++; while (urlCheckPending.length > 0) { const { fn, resolve: resolve2, reject } = urlCheckPending.shift(); try { resolve2(await fn()); } catch (e) { reject(e); } } urlCheckRunning--; } const VIA_STYLES = { both: { text: "name+url", color: "#2a7" }, // green — high confidence url: { text: "url", color: "#46a" }, // blue name: { text: "name", color: "#46a" }, // blue user: { text: "user", color: "#777" }, // grey cache: { text: "cache", color: "#777" } // grey (legacy: original mechanism unknown) }; function viaCfg(via, fromCache) { const base = VIA_STYLES[via]; if (!base) return null; if (fromCache && via !== "cache") { return { text: `${base.text} (cache)`, color: base.color }; } return base; } function makeViaBadge(via, fromCache) { const cfg = viaCfg(via, fromCache); if (!cfg) return null; const span = document.createElement("span"); span.textContent = cfg.text; span.title = fromCache && via !== "cache" ? `Resolved via ${via}, served from cache` : `Resolved via ${via}`; span.style.cssText = `font-size:0.68rem;background:#f5f5f5;color:${cfg.color};padding:0 0.35rem;border-radius:8px;border:1px solid #ddd;flex-shrink:0;`; return span; } const creditOverrides = /* @__PURE__ */ new Map(); const existingCreditByMbid = computeExistingCreditByMbid(); function computeExistingCreditByMbid() { const counts = /* @__PURE__ */ new Map(); const MB = pageWindow?.MB; const iterate = MB?.tree?.iterate; if (!iterate) return counts; const valueOf = (yielded) => Array.isArray(yielded) ? yielded[1] : yielded; const isTree = (x) => x && typeof x === "object" && x.size != null && (x.left !== void 0 || x.right !== void 0 || x.value !== void 0); function tally(rel) { if (!rel || rel._status === 2) return; for (const side of [1, 0]) { const entity = rel[`entity${side}`]; const tgt = entity?.gid; if (!tgt) continue; const credit = rel[`entity${side}_credit`] || entity.name; if (!credit) continue; if (!counts.has(tgt)) counts.set(tgt, /* @__PURE__ */ new Map()); const m = counts.get(tgt); m.set(credit, (m.get(credit) || 0) + 1); } } function walkRels(rels) { if (!isTree(rels)) return; for (const e of iterate(rels)) tally(valueOf(e)); } function walkPhraseGroups(phraseGroups) { if (!isTree(phraseGroups)) return; for (const e of iterate(phraseGroups)) { const pg = valueOf(e); if (pg?.relationships) walkRels(pg.relationships); } } function walkTypeGroups(byTypeId) { if (!isTree(byTypeId)) return; for (const e of iterate(byTypeId)) { const tg = valueOf(e); if (tg?.phraseGroups) walkPhraseGroups(tg.phraseGroups); } } function walkPerSource(perSource) { if (!isTree(perSource)) return; for (const e of iterate(perSource)) { walkTypeGroups(valueOf(e)); } } function walkSource(root) { if (!isTree(root)) return; for (const e of iterate(root)) { walkPerSource(valueOf(e)); } } try { walkSource(MB.relationshipEditor?.state?.existingRelationshipsBySource); } catch (e) { } try { walkSource(MB.relationshipEditor?.state?.relationshipsBySource); } catch (e) { } const out = /* @__PURE__ */ new Map(); for (const [mbid, m] of counts) { let best = null, bestN = 0; for (const [credit, n] of m) { if (n > bestN) { best = credit; bestN = n; } } if (best) out.set(mbid, best); } return out; } const panel = document.createElement("div"); panel.style.cssText = "border:2px solid #c8a000;border-radius:0.5rem;background:#fffef5;padding:1rem 1.5rem;margin:0.5rem 0;"; { const _pb = document.getElementById("discogs-progress-bar"); if (_pb) _pb.style.display = "none"; } const _bar = document.querySelector(".discogs-bar"); if (_bar) { _hideBar(); const _r2 = _bar.querySelector(".discogs-bar-row2"); if (_r2) _r2.style.marginTop = ""; } const heading = document.createElement("div"); heading.style.cssText = "display:flex;align-items:center;gap:0.6rem;margin:0 0 0.5rem;padding:0.4rem 0.6rem;border-radius:0.3rem;background:#f5e8a0;border:1px solid #d4b800;"; if (onRefresh) { const refreshBtn = document.createElement("button"); refreshBtn.textContent = "\u{1F504} Refresh from MB"; refreshBtn.title = "Re-resolve every entity via MusicBrainz API, ignoring the local IDB cache"; refreshBtn.style.cssText = "font-size:0.8rem;cursor:pointer;padding:0.2rem 0.5rem;border:1px solid #b59a00;border-radius:3px;background:#fff;color:#5a4000;flex-shrink:0;"; refreshBtn.addEventListener("click", () => { refreshBtn.disabled = true; refreshBtn.textContent = "\u{1F504} Refreshing\u2026"; (panelLi || panel).remove(); if (headerSlot) headerSlot.replaceChildren(); onRefresh().then((freshResults) => { showReviewTable(freshResults, rolesMap, companiesRolesMap, opts).then((confirmedMap) => resolve(confirmedMap)); }); }); heading.appendChild(refreshBtn); } const headingText = document.createElement("span"); headingText.style.cssText = "font-weight:bold;font-size:1rem;color:#5a4000;flex:1;"; headingText.textContent = `Review \u2014 ${allResults.length} entit${allResults.length === 1 ? "y" : "ies"}`; heading.appendChild(headingText); panel.appendChild(heading); const intro = document.createElement("p"); intro.style.cssText = "margin:0 0 0.75rem;font-size:0.85rem;color:#666;"; intro.innerHTML = 'Review all artist matches before importing. Red rows need attention. Yellow rows have a name mismatch \u2014 verify. Green rows are confirmed. Use the search or create buttons to resolve outstanding issues.'; panel.appendChild(intro); const table = document.createElement("table"); table.style.cssText = "border-collapse:collapse;width:100%;font-size:0.85rem;"; const thead = document.createElement("thead"); const hr = document.createElement("tr"); hr.style.background = "#f5e8a0"; [importSourceName + " entity", "MB match / search"].forEach((col) => { const th = document.createElement("th"); th.style.cssText = "text-align:left;padding:0.3rem 0.5rem;border:1px solid #d4b800;white-space:nowrap;"; th.textContent = col; hr.appendChild(th); }); thead.appendChild(hr); table.appendChild(thead); const tbody = document.createElement("tbody"); allResults.forEach((r) => { const entityType = r.entityType || "artist"; const displayName = r.displayName || r.entity?.name || ""; const discogsHref = r.discogsHref || ""; const srcName = discogsHref ? sourceNameForUrl(discogsHref) : importSourceName; const e = r.logEntry || null; const artist = r.entity; const isResolved = r.type === "resolved"; const initMbUrl = isResolved ? r.mbUrl : null; const _entityKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; const _pl = _preloadedNames.get(_entityKey) || _preloadedNames.get(r.entity?.resource_url); const initMbName = e && e.mbName ? e.mbName : _pl?.name || (isResolved ? r.mbName : null) || null; const initMbDisam = e && e.mbDisambig ? e.mbDisambig : _pl?.dis || r.mbDisambig || ""; const nameMismatch = isResolved && initMbName && initMbName.toLowerCase().trim() !== displayName.toLowerCase().trim(); const needsAttention = r.type === "attention"; const rowBg = needsAttention ? "#ffe0e0" : nameMismatch ? "#fff8e1" : "#fff"; const borderColor = needsAttention ? "#cc6666" : "#d4d4d4"; const tr = document.createElement("tr"); tr.style.cssText = `vertical-align:top;background:${rowBg};`; tr.dataset.entityKey = _entityKey; rowState.set(_entityKey, { mbUrl: initMbUrl, mbName: initMbName, mbDisambig: initMbDisam, confirmed: isResolved && !needsAttention, via: isResolved ? r.logEntry?.via || null : null, fromCache: isResolved ? r.logEntry?.fromCache || false : false }); const tdDiscogs = document.createElement("td"); tdDiscogs.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};white-space:nowrap;`; const nameRow = document.createElement("div"); nameRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:0.6rem;"; const nameWrap = document.createElement("span"); nameWrap.style.cssText = "min-width:0;overflow:hidden;text-overflow:ellipsis;"; if (entityType !== "artist") { const badge = document.createElement("span"); badge.textContent = entityType; badge.style.cssText = "font-size:0.7rem;background:#e0e0e0;border-radius:3px;padding:0 0.3rem;margin-right:0.3rem;color:#555;vertical-align:middle;"; nameWrap.appendChild(badge); } const placeholderUrl = /\/_company\//.test(discogsHref) || /\/_company\//.test(r.entity?.resource_url || ""); const hasDiscogsUrl = !!r.entity?.resource_url && !placeholderUrl; const dlA = document.createElement(hasDiscogsUrl ? "a" : "span"); dlA.href = discogsHref; dlA.target = "_blank"; dlA.rel = "noopener noreferrer nofollow"; dlA.textContent = displayName; if (!hasDiscogsUrl) dlA.className = "discogs-entity-name"; const _srcTitles = [...new Set((r._roles || []).map((x) => x.trackTitle).filter(Boolean))]; if (_srcTitles.length) dlA.title = _srcTitles.join("\n"); nameWrap.appendChild(dlA); const BADGE_BASE = "display:inline-flex;align-items:center;margin-left:0.35rem;padding:0.05rem 0.4rem;font-size:0.65rem;font-weight:600;border-radius:0.7rem;letter-spacing:0.01em;cursor:help;text-transform:lowercase;line-height:1.4;"; if (!hasDiscogsUrl && !placeholderUrl && srcName !== "Titles") { const noUrl = document.createElement("span"); noUrl.textContent = "no profile"; noUrl.title = `No ${srcName} artist page \u2014 name lookup unavailable, search MB manually`; noUrl.style.cssText = BADGE_BASE + "background:#fde0e0;color:#a02020;border:1px solid #d44040;"; nameWrap.appendChild(noUrl); } if (nameMismatch) { const w = document.createElement("span"); w.textContent = "name differs"; w.title = "MB entity name differs from the Discogs display name \u2014 double-check this is the right match"; w.style.cssText = BADGE_BASE + "background:#fff1c4;color:#7a5800;border:1px solid #d4ad3a;"; nameWrap.appendChild(w); } nameRow.appendChild(nameWrap); const actionsLine = document.createElement("span"); actionsLine.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;flex-shrink:0;"; nameRow.appendChild(actionsLine); tdDiscogs.appendChild(nameRow); tr.appendChild(tdDiscogs); const rolesList = r._roles || []; if (rolesList.length > 0) { const seen = /* @__PURE__ */ new Map(); rolesList.forEach(({ displayLabel, linkType, trackPos }) => { const key = displayLabel || linkType; if (!key) return; const uniqueKey = key + (trackPos ? "[" + trackPos + "]" : ""); if (seen.has(uniqueKey)) return; seen.set(uniqueKey, { roleKey: key, displayText: key + (trackPos ? " [" + trackPos + "]" : "") }); }); const chips = [...seen.values()]; const rolesLine = document.createElement("div"); rolesLine.style.cssText = "font-size:0.75rem;color:#888;margin-top:0.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px;"; rolesLine.title = chips.map((c) => c.displayText).join(", "); chips.forEach((chip, i) => { if (i > 0) rolesLine.appendChild(document.createTextNode(", ")); const span = document.createElement("span"); span.className = "discogs-role-chip"; span.dataset.roleKey = chip.roleKey; span.textContent = chip.displayText; rolesLine.appendChild(span); }); tdDiscogs.appendChild(rolesLine); } const credLine = document.createElement("div"); credLine.style.cssText = "display:flex;align-items:center;gap:0.3rem;margin-top:1rem;padding-top:0.25rem;max-width:280px;"; const credLabel = document.createElement("label"); credLabel.textContent = "Credited as:"; credLabel.style.cssText = "font-size:0.72rem;color:#888;flex-shrink:0;"; const credInput = document.createElement("input"); credInput.type = "text"; const CRED_BG_SAME = "#fff"; const CRED_BG_DIFFERENT = "#fff4d0"; credInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.78rem;border:1px solid #ddd;border-radius:3px;background:" + CRED_BG_SAME + ";"; credInput.placeholder = displayName; credInput.title = `Override the credited name dispatched with every rel for this entity. Leave empty to use the default (${srcName} name, or MB's most-frequent existing credit when known).`; function refreshCredBg() { const value = (credInput.value || "").trim(); const same = value === "" || value === displayName; credInput.style.background = same ? CRED_BG_SAME : CRED_BG_DIFFERENT; } function pickPrefill(mbUrl) { if (r.creditOverride !== void 0 && r.creditOverride !== null && r.creditOverride !== "") { return r.creditOverride; } if (mbUrl) { const mbid = (String(mbUrl).split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36); if (mbid && existingCreditByMbid.has(mbid)) return existingCreditByMbid.get(mbid); } if (srcName === "Titles") { const mbName = rowState.get(_entityKey)?.mbName || r.mbName; if (mbName) return mbName; } return displayName; } credInput.value = pickPrefill(r.mbUrl); credInput._userTouched = false; refreshCredBg(); let _credSaveTimer; credInput.addEventListener("input", () => { credInput._userTouched = true; const url = credInput._activeMbUrl; if (url) creditOverrides.set(url, credInput.value); refreshCredBg(); clearTimeout(_credSaveTimer); _credSaveTimer = setTimeout(() => { const idbKey = idbKeyForEntity(r.entity); if (idbKey) writeIdbRecord(idbKey, { creditOverride: credInput.value }); }, 500); }); credInput._activeMbUrl = r.mbUrl; if (r.mbUrl) creditOverrides.set(r.mbUrl, credInput.value); const CRED_BTN_STYLE = "flex-shrink:0;padding:0.05rem 0.35rem;font-size:0.7rem;line-height:1;cursor:pointer;border:1px solid #c8a000;border-radius:3px;background:#fffbe6;color:#7a5000;"; const mbBtn = document.createElement("button"); mbBtn.type = "button"; mbBtn.textContent = "MB"; mbBtn.title = "Set Credited as to the MB entity name"; mbBtn.style.cssText = CRED_BTN_STYLE; const dBtn = document.createElement("button"); dBtn.type = "button"; dBtn.textContent = srcName.charAt(0); dBtn.title = `Set Credited as to the ${srcName} name`; dBtn.style.cssText = CRED_BTN_STYLE; function currentMbName() { return rowState.get(_entityKey)?.mbName || r.mbName || null; } function refreshCredBtns() { const val = credInput.value; const mbName = currentMbName(); mbBtn.style.display = !mbName || val === mbName ? "none" : ""; dBtn.style.display = val === displayName ? "none" : ""; } function setCredViaButton(value) { credInput.value = value; credInput._userTouched = true; credInput.dispatchEvent(new Event("input", { bubbles: true })); } mbBtn.addEventListener("click", () => { const mbName = currentMbName(); if (mbName) setCredViaButton(mbName); }); dBtn.addEventListener("click", () => setCredViaButton(displayName)); credInput.addEventListener("input", refreshCredBtns); refreshCredBtns(); credLine.appendChild(credLabel); credLine.appendChild(credInput); credLine.appendChild(mbBtn); credLine.appendChild(dBtn); tdDiscogs.appendChild(credLine); r._credInput = credInput; r._refreshCredBtns = refreshCredBtns; const tdMb = document.createElement("td"); tdMb.style.cssText = `padding:0.3rem 0.5rem;border:1px solid ${borderColor};min-width:240px;`; const candidateList = document.createElement("div"); candidateList.style.cssText = "display:flex;flex-direction:column;gap:0.2rem;margin-bottom:0.3rem;"; const searchRow = document.createElement("div"); searchRow.style.cssText = "display:flex;gap:0.3rem;"; const searchInput = document.createElement("input"); searchInput.type = "text"; searchInput.value = displayName; searchInput.style.cssText = "flex:1;padding:0.15rem 0.35rem;font-size:0.82rem;border:1px solid #bbb;border-radius:3px;"; rowSearchInputs.set(_entityKey, searchInput); const searchBtn = document.createElement("button"); searchBtn.type = "button"; searchBtn.innerHTML = ''; searchBtn.title = "Search MusicBrainz"; searchBtn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;padding:0 0.45rem;cursor:pointer;color:#666;background:#fff;border:1px solid #bbb;border-radius:3px;"; searchRow.appendChild(searchBtn); searchRow.appendChild(searchInput); tdMb.appendChild(candidateList); tdMb.appendChild(searchRow); tr.appendChild(tdMb); const tdAction = actionsLine; tbody.appendChild(tr); function buildMbRolesEl(explicitMbid) { if (entityType !== "artist") return null; const wrap = document.createElement("span"); wrap.style.cssText = "display:inline-flex;align-items:center;gap:0.25rem;margin-left:0.5rem;min-width:0;overflow:hidden;font-size:0.72rem;"; const trigger = document.createElement("a"); trigger.href = "#"; trigger.textContent = "MB roles \u25BE"; trigger.style.cssText = "color:#7a7a9a;text-decoration:none;cursor:pointer;white-space:nowrap;"; trigger.title = "Fetch this artist's existing MB relationship types to compare with the Discogs role"; trigger.addEventListener("click", async (ev) => { ev.preventDefault(); let mbid = (String(explicitMbid || "").match(/[a-f0-9-]{36}/i) || [])[0]; if (!mbid) { const st = rowState.get(_entityKey); const curUrl = st?.mbUrl || r.mbUrl; mbid = (String(curUrl || "").split("/").pop() || "").replace(/[^a-f0-9-]/gi, "").slice(0, 36); } if (!mbid) { trigger.textContent = "MB roles: (none selected)"; return; } trigger.textContent = "MB roles\u2026"; const types = await fetchArtistRelTypes(mbid); wrap.innerHTML = ""; const label = document.createElement("span"); label.style.color = "#888"; if (!types) { label.textContent = "MB roles: fetch failed"; label.style.color = "#a02020"; wrap.appendChild(label); return; } if (!types.length) { label.textContent = "MB roles: none"; wrap.appendChild(label); return; } label.textContent = "MB roles: "; label.style.whiteSpace = "nowrap"; label.style.flex = "0 0 auto"; wrap.style.alignItems = "flex-start"; wrap.style.overflow = "visible"; wrap.appendChild(label); wrap.title = types.join(", "); const chipsBox = document.createElement("span"); chipsBox.style.cssText = "display:flex;flex-wrap:wrap;gap:0.25rem;min-width:0;"; wrap.appendChild(chipsBox); types.forEach((t) => { const c = document.createElement("span"); c.textContent = t; c.style.cssText = "background:#eaeaf5;border:1px solid #ccccdd;border-radius:0.7rem;padding:0 0.4rem;color:#4a4a77;white-space:nowrap;"; chipsBox.appendChild(c); }); }); wrap.appendChild(trigger); return wrap; } let _creatingEl = null; let _creatingTimer = null; let _creatingCancel = null; function setRowCreating(name, onCancel) { ensureCreatingStyle(); if (_creatingTimer) { clearTimeout(_creatingTimer); _creatingTimer = null; } if (_creatingEl) _creatingEl.remove(); _creatingCancel = onCancel || null; candidateList.style.display = "none"; tdAction.innerHTML = ""; searchInput.disabled = true; searchBtn.disabled = true; const ph = document.createElement("div"); ph.className = "ch-creating"; ph.style.cssText = "padding:0.15rem 0.4rem;border:1px dashed #8a8ad0;border-radius:3px;background:#f4f4ff;display:flex;align-items:center;gap:0.4rem;font-size:0.85rem;color:#55557a;font-style:italic;margin-bottom:0.3rem;"; const spin = document.createElement("span"); spin.textContent = "\u27F3"; spin.style.cssText = "display:inline-block;animation:ch-creating-spin 0.9s linear infinite;"; ph.appendChild(spin); const txt = document.createElement("span"); txt.textContent = `Creating ${name} in the background\u2026`; ph.appendChild(txt); const x = document.createElement("button"); x.textContent = "\u2715"; x.title = "Cancel \u2014 stop waiting and restore the row"; x.style.cssText = "margin-left:auto;font-size:0.75rem;line-height:1;cursor:pointer;border:none;background:none;color:#55557a;padding:0 0.2rem;"; x.addEventListener("click", () => cancelCreating()); ph.appendChild(x); tdMb.insertBefore(ph, candidateList); _creatingEl = ph; tr.style.background = "#f6f6ff"; _creatingTimer = setTimeout(() => { _creatingTimer = null; cancelCreating(); }, 9e4); } function cancelCreating() { if (_creatingCancel) { try { _creatingCancel(); } catch (e2) { } } clearRowCreating(true); } function clearRowCreating(restore) { if (_creatingTimer) { clearTimeout(_creatingTimer); _creatingTimer = null; } _creatingCancel = null; if (_creatingEl) { _creatingEl.remove(); _creatingEl = null; } candidateList.style.display = ""; if (restore) { searchInput.disabled = false; searchBtn.disabled = false; tr.style.background = ""; renderActions(null); } } function setRowResolved(a) { clearRowCreating(); const mbUrl = `//musicbrainz.org/${entityType}/${a.id}`; rowState.set(_entityKey, { mbUrl, mbName: a.name, mbDisambig: a.disambiguation || "", confirmed: true, via: "user", fromCache: false }); if (r._credInput) { const oldUrl = r._credInput._activeMbUrl; if (oldUrl && oldUrl !== mbUrl) creditOverrides.delete(oldUrl); r._credInput._activeMbUrl = mbUrl; if (!r._credInput._userTouched) { const fresh = pickPrefill(mbUrl); r._credInput.value = fresh; } creditOverrides.set(mbUrl, r._credInput.value); refreshCredBg(); if (r._refreshCredBtns) r._refreshCredBtns(); } const _idbKey = idbKeyForEntity(r.entity); if (_idbKey) { writeIdbRecord(_idbKey, { mbid: a.id, entityType, name: a.name, disambiguation: a.disambiguation || "", resolvedVia: "user" // user picked this in the review table }); } tr.style.background = "#f0fff0"; searchInput.disabled = true; searchBtn.disabled = true; candidateList.innerHTML = ""; const selRow = document.createElement("div"); selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid #5a5;border-radius:3px;background:#e8f8e8;display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem;font-size:0.85rem;"; const selA = document.createElement("a"); selA.href = "https:" + mbUrl; selA.target = "_blank"; selA.rel = "noopener noreferrer nofollow"; selA.textContent = "\u2713 " + a.name + (a.disambiguation ? ` (${a.disambiguation})` : ""); selA.style.fontWeight = "bold"; selA.style.whiteSpace = "nowrap"; selA.style.flex = "0 0 auto"; const undoBtn = document.createElement("button"); undoBtn.textContent = "\u2715"; undoBtn.title = "Clear selection"; undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;"; undoBtn.addEventListener("click", () => setRowUnresolved()); selRow.appendChild(selA); const viaBadge = makeViaBadge("user", false); if (viaBadge) selRow.appendChild(viaBadge); const mbRolesEl = buildMbRolesEl(); if (mbRolesEl) selRow.appendChild(mbRolesEl); selRow.appendChild(undoBtn); candidateList.appendChild(selRow); renderActions(a); updateImportBtn(); } function setRowUnresolved() { clearRowCreating(); rowState.set(_entityKey, { mbUrl: null, mbName: null, mbDisambig: "", confirmed: false, via: null, fromCache: false }); if (r._credInput && r._credInput._activeMbUrl) { creditOverrides.delete(r._credInput._activeMbUrl); r._credInput._activeMbUrl = null; } tr.style.background = "#ffe0e0"; searchInput.disabled = false; searchBtn.disabled = false; candidateList.innerHTML = ""; const none = document.createElement("div"); none.style.cssText = "font-size:0.82rem;color:#888;"; none.textContent = "No selection \u2014 search or create"; candidateList.appendChild(none); renderActions(null); updateImportBtn(); } const ACTION_CHIP_STYLE = "display:inline-flex;align-items:center;justify-content:center;min-width:1.6rem;height:1.6rem;padding:0 0.35rem;font-size:0.95rem;line-height:1;cursor:pointer;border:1px solid #d6d6d6;border-radius:0.3rem;background:#fafafa;"; function renderActions(selected) { tdAction.innerHTML = ""; if (!selected) { linkState.delete(_entityKey); rowLinkChips.delete(_entityKey); updateLinksBadge(); } if (selected) { let recheckUrlBypassCache = function() { _urlCheckSessionCache.delete(urlCheckCacheKey); try { localStorage.removeItem(urlCheckLsKey); } catch (e2) { } queuedUrlCheck( () => fetchWithRetry(`//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => { const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id); const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none"; _urlCheckSessionCache.set(urlCheckCacheKey, result); try { localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result })); } catch (e2) { } const healKey = parseSourceEntityUrl(r.entity?.resource_url)?.key; if (healKey) writeIdbRecord(healKey, { urlLinkedIds: linkedIds }); applyUrlCheckResult(result); }).catch(() => applyUrlCheckResult("none")) ); }, applyUrlCheckResult = function(result) { linkState.set(_entityKey, result); if (result !== "none") rowLinkChips.delete(_entityKey); updateLinksBadge(); if (result === "linked") { linkSlot.textContent = "\u2713"; linkSlot.title = srcName + " URL already linked to this MB " + entityType; linkSlot.style.color = "#5a5"; linkSlot.style.fontWeight = "bold"; } else if (result === "other") { linkSlot.textContent = "\u26A0\uFE0F"; linkSlot.title = `${srcName} URL is linked to a DIFFERENT MB ${entityType}`; linkSlot.style.color = "#c80"; } else { linkSlot.textContent = ""; linkSlot.style.color = ""; const addLinkBtn = document.createElement("button"); addLinkBtn.textContent = "\u{1F517}"; addLinkBtn.title = `Add ${srcName} link to MB ${entityType} \xB7 right-click: add it silently in the background`; addLinkBtn.style.cssText = ACTION_CHIP_STYLE + "color:#e8771d;"; const openLinkEdit = (background) => { const ltId = sourceUrlLinkTypeId(discogsHref, entityType); if (!ltId) return; const p = new URLSearchParams({ [`edit-${entityType}.url.0.text`]: discogsHref, [`edit-${entityType}.url.0.link_type_id`]: ltId, [`edit-${entityType}.edit_note`]: buildCreateNote(`Added ${srcName} link`) }); const mbid = selected.id.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); const editUrl = `https://musicbrainz.org/${entityType}/${mbid}/edit?${p}`; if (background && typeof GM_openInTab === "function") { const editTab = GM_openInTab(`${editUrl}#ch-autocommit`, { active: false, insert: true }); const onCommitted = (evt) => { if (evt.data?.type !== "edit-committed" || evt.data.id !== mbid) return; DISCOGS_CHANNEL.removeEventListener("message", onCommitted); try { if (editTab && typeof editTab.close === "function") editTab.close(); } catch (e2) { } recheckUrlBypassCache(); }; DISCOGS_CHANNEL.addEventListener("message", onCommitted); linkSlot.innerHTML = ""; linkSlot.textContent = "\u2026"; linkSlot.title = `Adding ${srcName} link in the background\u2026`; linkSlot.style.color = "#888"; linkSlot.style.fontStyle = "italic"; return; } const linkTab = window.open(editUrl, "_blank"); if (linkTab) { const trySet = () => { try { linkTab.sessionStorage.setItem("discogs-importer-close-after-edit", "1"); } catch (e2) { setTimeout(trySet, 50); } }; trySet(); } linkSlot.innerHTML = ""; linkSlot.textContent = "\u2026"; linkSlot.title = `Verifying ${srcName} link on return to this tab\u2026`; linkSlot.style.color = "#888"; linkSlot.style.fontStyle = "italic"; const onReturn = () => { if (document.visibilityState !== "visible") return; document.removeEventListener("visibilitychange", onReturn); window.removeEventListener("focus", onReturn); recheckUrlBypassCache(); }; document.addEventListener("visibilitychange", onReturn); window.addEventListener("focus", onReturn); }; addLinkBtn.addEventListener("click", () => openLinkEdit(false)); addLinkBtn.addEventListener("contextmenu", (e2) => { e2.preventDefault(); openLinkEdit(true); }); linkSlot.appendChild(addLinkBtn); rowLinkChips.set(_entityKey, addLinkBtn); } }; const linkSlot = document.createElement("span"); linkSlot.style.cssText = "display:inline-flex;align-items:center;font-size:0.8rem;color:#888;"; linkSlot.textContent = "\u2026"; linkSlot.title = `Checking whether MB already has this ${srcName} URL linked`; tdAction.appendChild(linkSlot); const urlCheckCacheKey = `${selected.id}|${discogsHref}`; const urlCheckLsKey = `discogs-urlcheck-${selected.id}-${discogsHref.replace(/[^a-z0-9]/gi, "-").substring(0, 80)}`; const urlCheckToday = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10); const urlCheckExpiry = /* @__PURE__ */ new Date(); urlCheckExpiry.setDate(urlCheckExpiry.getDate() - 7); const urlCheckExpiryStr = urlCheckExpiry.toISOString().slice(0, 10); let urlCheckCached = _urlCheckSessionCache.get(urlCheckCacheKey) ?? null; if (urlCheckCached === null) { try { const s = JSON.parse(localStorage.getItem(urlCheckLsKey) || "null"); if (s?.date >= urlCheckExpiryStr) urlCheckCached = s.result; } catch (e2) { } if (urlCheckCached !== null) _urlCheckSessionCache.set(urlCheckCacheKey, urlCheckCached); } if (!discogsHref || placeholderUrl) { linkState.set(_entityKey, "na"); rowLinkChips.delete(_entityKey); updateLinksBadge(); if (srcName === "Titles" || placeholderUrl) { linkSlot.remove(); } else { linkSlot.textContent = `\u26A0 No ${srcName} page`; linkSlot.style.color = "#c80"; } } else if (urlCheckCached !== null) { applyUrlCheckResult(urlCheckCached); } else if (Array.isArray(r.urlLinkedIds)) { const result = r.urlLinkedIds.includes(selected.id) ? "linked" : r.urlLinkedIds.length > 0 ? "other" : "none"; _urlCheckSessionCache.set(urlCheckCacheKey, result); try { localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result })); } catch (e2) { } applyUrlCheckResult(result); } else { queuedUrlCheck( () => fetchWithRetry(`//musicbrainz.org/ws/2/url?resource=${encodeURIComponent(discogsHref)}&inc=${entityType}-rels&fmt=json`).then((json) => { const linkedIds = (json.relations || []).filter((r2) => r2[entityType]).map((r2) => r2[entityType].id); const result = linkedIds.includes(selected.id) ? "linked" : linkedIds.length > 0 ? "other" : "none"; _urlCheckSessionCache.set(urlCheckCacheKey, result); try { localStorage.setItem(urlCheckLsKey, JSON.stringify({ date: urlCheckToday, result })); } catch (e2) { } const healKey = parseSourceEntityUrl(r.entity?.resource_url)?.key; if (healKey) writeIdbRecord(healKey, { urlLinkedIds: linkedIds }); applyUrlCheckResult(result); }).catch(() => applyUrlCheckResult("none")) ); } } function openCreateTab({ name, disambiguation, background } = {}) { const finalName = (name || displayName).trim(); let createUrl; let createParams; if (entityType === "artist") { createParams = { "edit-artist.name": finalName, "edit-artist.sort_name": guessSortName(finalName), "edit-artist.type_id": "1" }; const ltArtist = sourceUrlLinkTypeId(discogsHref, "artist"); if (discogsHref && ltArtist) { createParams["edit-artist.url.0.text"] = discogsHref; createParams["edit-artist.url.0.link_type_id"] = ltArtist; } if (disambiguation) createParams["edit-artist.comment"] = disambiguation; createParams["edit-artist.edit_note"] = buildCreateNote(); createUrl = "https://musicbrainz.org/artist/create"; } else { const ltId = sourceUrlLinkTypeId(discogsHref, entityType); createParams = { [`edit-${entityType}.name`]: finalName }; if (discogsHref && ltId) { createParams[`edit-${entityType}.url.0.text`] = discogsHref; createParams[`edit-${entityType}.url.0.link_type_id`] = ltId; } if (disambiguation) createParams[`edit-${entityType}.comment`] = disambiguation; createParams[`edit-${entityType}.edit_note`] = buildCreateNote(); createUrl = `https://musicbrainz.org/${entityType}/create`; } const p = new URLSearchParams(createParams); const pendingKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || displayName}`; let bgTab = null; if (background && typeof GM_openInTab === "function") { const url = `${createUrl}?${p}#ch-autocommit=${encodeURIComponent(pendingKey)}`; bgTab = GM_openInTab(url, { active: false, insert: true }); } else { const newTab = window.open(`${createUrl}?${p}`, "_blank"); if (newTab) { const trySet = () => { try { newTab.sessionStorage.setItem("discogs-importer-pending-artist", pendingKey); } catch (e2) { setTimeout(trySet, 50); } }; trySet(); } } const onCreated = (evt) => { if (evt.data?.type !== "artist-created") return; if (evt.data.resourceUrl !== pendingKey) return; DISCOGS_CHANNEL.removeEventListener("message", onCreated); try { if (bgTab && typeof bgTab.close === "function") bgTab.close(); } catch (e2) { } _urlCheckSessionCache.set(`${evt.data.id}|${discogsHref}`, "linked"); setRowResolved({ id: evt.data.id, name: evt.data.name, disambiguation: evt.data.disambiguation }); }; DISCOGS_CHANNEL.addEventListener("message", onCreated); if (background && typeof GM_openInTab === "function") { setRowCreating(finalName, () => { DISCOGS_CHANNEL.removeEventListener("message", onCreated); try { if (bgTab && typeof bgTab.close === "function") bgTab.close(); } catch (e2) { } }); } } const createBtn = document.createElement("button"); createBtn.textContent = "+"; createBtn.title = (discogsHref ? `Create in MB with default ${srcName} name + URL` : "Create in MB with the credited name") + " \xB7 right-click: create silently in a background tab (auto-submitted)"; createBtn.style.cssText = ACTION_CHIP_STYLE + "color:#2a7;font-size:1.15rem;font-weight:600;"; createBtn.addEventListener("click", () => openCreateTab()); createBtn.addEventListener("contextmenu", (e2) => { e2.preventDefault(); openCreateTab({ background: true }); }); const createAdvBtn = document.createElement("button"); createAdvBtn.textContent = "\u25BE"; createAdvBtn.title = "Create in MB with editable name + disambiguation" + (srcName === "Discogs" && discogsHref ? ", pre-filled from the Discogs profile" : ""); createAdvBtn.style.cssText = ACTION_CHIP_STYLE + "color:#666;"; createAdvBtn.addEventListener("click", () => openAdvancedCreatePopup()); tdAction.appendChild(createBtn); tdAction.appendChild(createAdvBtn); async function openAdvancedCreatePopup() { const distinctRoles = []; const seen = /* @__PURE__ */ new Set(); for (const role of r._roles || []) { const label = (role.displayLabel || role.linkType || "").trim(); if (!label || seen.has(label)) continue; seen.add(label); distinctRoles.push(label); if (distinctRoles.length === 3) break; } const defaultDis = distinctRoles.join(", "); const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;display:flex;align-items:center;justify-content:center;"; const modal = document.createElement("div"); modal.style.cssText = "background:#fff;border-radius:0.5rem;padding:1.1rem 1.35rem 1rem;max-width:600px;width:92%;max-height:82vh;display:flex;flex-direction:column;gap:0.55rem;box-shadow:0 12px 32px rgba(0,0,0,0.32);font-family:inherit;"; const heading2 = document.createElement("div"); heading2.style.cssText = "font-weight:bold;font-size:1.02rem;color:#222;margin-bottom:0.15rem;"; heading2.textContent = `Create ${entityType} in MusicBrainz`; modal.appendChild(heading2); const FIELD_LABEL = "font-size:0.78rem;color:#666;font-weight:600;letter-spacing:0.02em;text-transform:uppercase;margin-top:0.25rem;"; const FIELD_INPUT = "padding:0.45rem 0.55rem;border:1px solid #c8c8c8;border-radius:0.3rem;font-size:0.93rem;font-family:inherit;"; const nameLabel = document.createElement("label"); nameLabel.style.cssText = FIELD_LABEL; nameLabel.textContent = "Name"; modal.appendChild(nameLabel); const nameInput = document.createElement("input"); nameInput.type = "text"; nameInput.value = displayName; nameInput.style.cssText = FIELD_INPUT; modal.appendChild(nameInput); let nameUserTouched = false; nameInput.addEventListener("input", () => { nameUserTouched = true; }); const disLabel = document.createElement("label"); disLabel.style.cssText = FIELD_LABEL; disLabel.textContent = "Disambiguation"; modal.appendChild(disLabel); const disInput = document.createElement("input"); disInput.type = "text"; disInput.value = defaultDis; disInput.style.cssText = FIELD_INPUT; modal.appendChild(disInput); let disUserTouched = false; disInput.addEventListener("input", () => { disUserTouched = true; }); const showProfile = srcName === "Discogs" && !!discogsHref; let profileBox = null; if (showProfile) { const profileLabel = document.createElement("div"); profileLabel.style.cssText = "font-size:0.78rem;color:#888;margin-top:0.55rem;"; profileLabel.textContent = "Discogs profile \u2014 select text to copy into Disambiguation"; modal.appendChild(profileLabel); profileBox = document.createElement("div"); profileBox.style.cssText = "border:1px solid #e0e0e0;border-radius:0.3rem;padding:0.5rem 0.6rem;background:#fafafa;font-size:0.85rem;line-height:1.5;white-space:pre-wrap;overflow:auto;min-height:5rem;max-height:18rem;flex:1;color:#444;"; profileBox.textContent = "Loading profile from Discogs\u2026"; modal.appendChild(profileBox); const captureSelection = () => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) return; if (!profileBox.contains(sel.anchorNode)) return; const text = sel.toString().trim(); if (!text) return; disInput.value = text; disUserTouched = true; }; profileBox.addEventListener("mouseup", captureSelection); profileBox.addEventListener("keyup", captureSelection); } const btnRow2 = document.createElement("div"); btnRow2.style.cssText = "display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.55rem;"; const cancelBtn = document.createElement("button"); cancelBtn.textContent = "Cancel"; cancelBtn.style.cssText = "padding:0.4rem 1rem;cursor:pointer;border:1px solid #c8c8c8;border-radius:0.25rem;background:#fafafa;color:#444;font-size:0.88rem;"; const submitBtn = document.createElement("button"); submitBtn.textContent = "Create \u2197"; submitBtn.style.cssText = "padding:0.4rem 1.1rem;cursor:pointer;font-weight:bold;background:#2ecc40;color:#fff;border:none;border-radius:0.25rem;font-size:0.9rem;"; btnRow2.appendChild(cancelBtn); btnRow2.appendChild(submitBtn); modal.appendChild(btnRow2); overlay.appendChild(modal); document.body.appendChild(overlay); const close = () => { document.removeEventListener("keydown", onKey); overlay.remove(); }; const submit = () => { const name = nameInput.value.trim(); const dis = disInput.value.trim(); close(); openCreateTab({ name: name || displayName, disambiguation: dis || null }); }; const onKey = (ev) => { if (ev.key === "Escape") { close(); } else if (ev.key === "Enter" && (ev.target === disInput || ev.target === nameInput)) submit(); }; document.addEventListener("keydown", onKey); overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); }); cancelBtn.addEventListener("click", close); submitBtn.addEventListener("click", submit); disInput.focus(); disInput.select(); if (showProfile) try { const data = await getDiscogsEntityData(r.entity?.resource_url); if (data?.realname && !nameUserTouched && data.realname.trim() !== displayName.trim()) { nameInput.value = data.realname.trim(); } const lines = []; if (data?.namevariations?.length) lines.push(`Also known as: ${data.namevariations.slice(0, 6).join(", ")}`); if (data?.profile) { if (lines.length) lines.push(""); lines.push(data.profile); } profileBox.textContent = lines.length ? lines.join("\n") : "(no Discogs profile)"; } catch (e2) { profileBox.textContent = "(failed to load Discogs profile)"; } } } function makeCandidateRow(a) { const row = document.createElement("div"); row.style.cssText = "display:flex;align-items:center;gap:0.35rem;padding:0.2rem 0.35rem;border:1px solid #ddd;border-radius:3px;background:#fff;font-size:0.82rem;"; const selBtn = document.createElement("button"); selBtn.textContent = "\u2713"; selBtn.title = "Select this candidate as the MB match"; selBtn.style.cssText = "font-size:0.95rem;line-height:1;cursor:pointer;padding:0.1rem 0.45rem;white-space:nowrap;border:1px solid #b5d5b5;border-radius:0.25rem;background:#eaf6ea;color:#2a7;font-weight:600;flex-shrink:0;"; selBtn.addEventListener("click", () => setRowResolved(a)); row.appendChild(selBtn); const info = document.createElement("span"); info.style.flex = "1"; const nameA = document.createElement("a"); nameA.href = `https://musicbrainz.org/${entityType}/${a.id}`; nameA.target = "_blank"; nameA.rel = "noopener noreferrer nofollow"; nameA.style.fontWeight = "bold"; nameA.textContent = a.name; info.appendChild(nameA); if (a.disambiguation) { const d = document.createElement("span"); d.style.cssText = "color:#777;margin-left:0.25rem;"; d.textContent = `(${a.disambiguation})`; info.appendChild(d); } row.appendChild(info); const rolesEl = buildMbRolesEl(a.id); if (rolesEl) { rolesEl.style.marginLeft = "auto"; row.appendChild(rolesEl); } return row; } function extractMbid(q) { const m = q.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i); return m ? m[0] : null; } function doSearch(q) { if (!q) return; const mbid = extractMbid(q); if (mbid) { candidateList.innerHTML = '
      Looking up MBID\u2026
      '; mbThrottle.fetchJson(`//musicbrainz.org/ws/2/${entityType}/${mbid}?fmt=json`).then((json) => { if (!json) return; candidateList.innerHTML = ""; if (json.id) { candidateList.appendChild(makeCandidateRow({ id: json.id, name: json.name, disambiguation: json.disambiguation || "" })); } else { candidateList.innerHTML = '
      Not found
      '; } }).catch(() => { candidateList.innerHTML = `
      MBID not found or wrong entity type
      `; }); return; } candidateList.innerHTML = '
      Searching\u2026
      '; mbThrottle.fetchJson(`//musicbrainz.org/ws/2/${entityType}?query=${encodeURIComponent(q)}&fmt=json&limit=8`).then((json) => { if (!json) { candidateList.innerHTML = '
      Search failed \u2014 MB unavailable
      '; return; } candidateList.innerHTML = ""; const resultKey = entityType === "label" ? "labels" : entityType === "place" ? "places" : "artists"; if (!json[resultKey] || json[resultKey].length === 0) { const none = document.createElement("div"); none.style.cssText = "font-size:0.82rem;color:#888;"; none.textContent = "No results"; candidateList.appendChild(none); } else { json[resultKey].forEach((a) => candidateList.appendChild(makeCandidateRow(a))); } }).catch(() => { candidateList.innerHTML = '
      Search failed
      '; }); } let searchTimer; searchInput.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => doSearch(searchInput.value.trim()), 300); }); searchInput.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); doSearch(searchInput.value.trim()); } }); searchBtn.addEventListener("click", () => doSearch(searchInput.value.trim())); if (isResolved && initMbUrl) { const mbid = initMbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); const correctedMbUrl = `//musicbrainz.org/${entityType}/${mbid}`; const displayName2 = initMbName || mbid; if (!initMbName) { rowState.set(_entityKey, { mbUrl: initMbUrl, mbName: null, mbDisambig: "", confirmed: true, via: r.logEntry?.via || null, fromCache: r.logEntry?.fromCache || false }); tr.style.background = "#fff8e1"; } const fakeA = { id: mbid, name: displayName2, disambiguation: initMbDisam }; candidateList.innerHTML = ""; const selRow = document.createElement("div"); selRow.style.cssText = "padding:0.15rem 0.4rem;border:1px solid #5a5;border-radius:3px;background:#e8f8e8;display:flex;flex-wrap:wrap;align-items:center;gap:0.4rem;font-size:0.85rem;"; const selA = document.createElement("a"); selA.href = "https:" + correctedMbUrl; selA.target = "_blank"; selA.rel = "noopener noreferrer nofollow"; selA.textContent = "\u2713 " + displayName2 + (initMbDisam ? ` (${initMbDisam})` : "") + (!initMbName ? " \u26A0 name unknown" : ""); selA.style.fontWeight = "bold"; selA.style.whiteSpace = "nowrap"; selA.style.flex = "0 0 auto"; const undoBtn = document.createElement("button"); undoBtn.textContent = "\u2715"; undoBtn.title = "Clear selection"; undoBtn.style.cssText = "font-size:0.75rem;cursor:pointer;padding:0 0.3rem;margin-left:auto;"; undoBtn.addEventListener("click", () => setRowUnresolved()); selRow.appendChild(selA); const viaBadge = makeViaBadge(r.logEntry?.via, r.logEntry?.fromCache); if (viaBadge) selRow.appendChild(viaBadge); const mbRolesEl = buildMbRolesEl(); if (mbRolesEl) selRow.appendChild(mbRolesEl); selRow.appendChild(undoBtn); candidateList.appendChild(selRow); renderActions(fakeA); } else if (r.nameMatches && r.nameMatches.length > 0) { r.nameMatches.forEach((a) => candidateList.appendChild(makeCandidateRow(a))); renderActions(null); } else { const none = document.createElement("div"); none.style.cssText = "font-size:0.82rem;color:#888;"; none.textContent = needsAttention ? "No suggestions \u2014 search or create" : ""; if (needsAttention) candidateList.appendChild(none); renderActions(null); } }); table.appendChild(tbody); panel.appendChild(table); const btnRow = document.createElement("div"); btnRow.style.cssText = "display:flex;gap:0.75rem;align-items:center;margin-top:0.75rem;flex-wrap:wrap;"; const importBtn = document.createElement("button"); importBtn.style.cssText = "border:none;padding:0.4rem 1.1rem;border-radius:0.3rem;cursor:pointer;font-weight:bold;font-size:0.95rem;display:inline-flex;align-items:center;gap:5px;"; const issueNote = document.createElement("span"); issueNote.className = "discogs-issue-note"; issueNote.style.cssText = "font-size:0.85rem;color:#7a5c00;"; linksNote = document.createElement("span"); linksNote.className = "discogs-issue-note discogs-links-note"; linksNote.style.cssText = "font-size:0.85rem;color:#e8771d;display:none;"; linksNote.title = `Confirmed matches whose ${importSourceName} URL isn't linked in MB yet \u2014 click to jump to the next one; use its \u{1F517} chip to add the link`; linksNote.addEventListener("click", jumpNextLink); updateLinksBadge(); let _jumpIdx = -1; function jumpNextUnresolved() { const n = allResults.length; let found = -1; for (let step = 1; step <= n; step++) { const i = (_jumpIdx + step) % n; if (!rowState.get(keyOf(allResults[i]))?.confirmed) { found = i; break; } } if (found === -1) return; _jumpIdx = found; const input = rowSearchInputs.get(keyOf(allResults[found])); if (!input) return; input.scrollIntoView({ behavior: "smooth", block: "center" }); try { input.focus({ preventScroll: true }); } catch { input.focus(); } input.select?.(); } issueNote.addEventListener("click", jumpNextUnresolved); function recSelectionCounts() { try { let total = 0, checked = 0; for (const tr of document.querySelectorAll("tr")) { if (!tr.querySelector('a[href*="/recording/"]')) continue; const cb = tr.querySelector('input[type="checkbox"]'); if (!cb) continue; total++; if (cb.checked) checked++; } return total ? { checked, total } : null; } catch (e) { return null; } } function updateImportBtn() { const unresolved = [...rowState.values()].filter((s) => !s.confirmed).length; const sel = recSelectionCounts(); const selLabel = sel && sel.checked > 0 && sel.checked < sel.total ? ` (${sel.checked}/${sel.total})` : ""; if (unresolved === 0) { importBtn.innerHTML = `Start import${selLabel} \u2192`; importBtn.style.background = "#2ecc40"; importBtn.style.color = "#fff"; issueNote.textContent = ""; issueNote.classList.remove("clickable"); issueNote.removeAttribute("title"); } else { importBtn.innerHTML = `Start import anyway${selLabel} \u2192`; importBtn.style.background = "#e0a800"; importBtn.style.color = "#fff"; issueNote.textContent = `\u26A0 ${unresolved} unresolved`; issueNote.classList.add("clickable"); issueNote.title = "Jump to the next unresolved entity \u2014 click again to cycle through them; these will be skipped on import"; } } updateImportBtn(); let _lastSelKey = ""; const _recSelPoll = setInterval(() => { if (!importBtn.isConnected) { clearInterval(_recSelPoll); return; } const s = recSelectionCounts(); const k = s ? `${s.checked}/${s.total}` : ""; if (k !== _lastSelKey) { _lastSelKey = k; updateImportBtn(); } }, 1e3); function buildStaticTableLi() { const tbl = document.createElement("table"); tbl.style.cssText = "border-collapse:collapse;width:100%;font-size:0.78rem;margin:0.4rem 0;"; const thRow = document.createElement("tr"); thRow.style.background = "#f5f5f5"; [importSourceName + " entity", "Roles / Tracks", "MB match", "MBID", "Resolved via"].forEach((h) => { const th = document.createElement("th"); th.style.cssText = "text-align:left;padding:0.2rem 0.4rem;border:1px solid #ddd;white-space:nowrap;"; th.textContent = h; thRow.appendChild(th); }); tbl.appendChild(thRow); allResults.forEach((r) => { const _rKey = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; const state = rowState.get(_rKey) || {}; const tr2 = document.createElement("tr"); const url = r.entity?.resource_url || r.entity?._syntheticKey || ""; const rolesList2 = url ? rolesMap.get(url) || companiesRolesMap.get(url) || [] : []; const grouped2 = /* @__PURE__ */ new Map(); rolesList2.forEach(({ displayLabel, linkType, trackPos }) => { const key = displayLabel || linkType; if (!grouped2.has(key)) grouped2.set(key, /* @__PURE__ */ new Set()); if (trackPos) grouped2.get(key).add(trackPos); }); const rolesText = [...grouped2.entries()].map(([label, tr]) => label + (tr.size ? " [" + [...tr].join(",") + "]" : "")).join("; "); const mbid = state.mbUrl ? state.mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36) : ""; const matchText = state.mbName || (state.mbUrl ? mbid : ""); const vCfg = state.via ? viaCfg(state.via, state.fromCache) : null; const viaText = vCfg ? vCfg.text : state.mbUrl ? "\u2014" : ""; [r.displayName || r.entity?.name, rolesText, matchText, mbid, viaText].forEach((val, ci) => { const td = document.createElement("td"); td.style.cssText = "padding:0.15rem 0.4rem;border:1px solid #ddd;" + (ci === 2 && !val ? "color:#aaa;" : ci === 2 ? "color:#060;" : ci === 4 && vCfg ? `color:${vCfg.color};` : ""); if (ci === 2 && mbid) { const a = document.createElement("a"); a.href = "https:" + state.mbUrl; a.target = "_blank"; a.rel = "noopener noreferrer nofollow"; a.textContent = val || mbid; td.appendChild(a); } else { td.textContent = val || (ci === 1 ? "" : ci === 2 ? "\u2014" : ""); } tr2.appendChild(td); }); tbl.appendChild(tr2); }); const tblLi = document.createElement("li"); tblLi.style.cssText = "list-style:none;margin:0;padding:0;"; tblLi.appendChild(tbl); return tblLi; } importBtn.addEventListener("click", () => { clearInterval(_recSelPoll); const confirmedMap = /* @__PURE__ */ new Map(); rowState.forEach((s, key) => { if (s.mbUrl) confirmedMap.set(key, s.mbUrl); }); getLogContainer().appendChild(buildStaticTableLi()); const unresolvedCount = allResults.filter((r) => { const _k = r.entity?.resource_url || r.entity?._syntheticKey || `_nourl_${r.entity?.name || r.displayName}`; return !rowState.get(_k)?.confirmed; }).length; if (unresolvedCount > 0) { const unresolvedLi = document.createElement("li"); unresolvedLi.style.cssText = "list-style:none;margin:0.2rem 0;font-size:0.82rem;color:#a06000;"; unresolvedLi.textContent = `\u26A0 ${unresolvedCount} entity/entities unresolved \u2014 will be skipped`; getLogContainer().appendChild(unresolvedLi); } confirmedMap.unresolvedCount = unresolvedCount; confirmedMap.totalEntities = allResults.length; confirmedMap.creditOverrides = creditOverrides; (panelLi || panel).remove(); if (headerSlot) headerSlot.replaceChildren(); resolve(confirmedMap); }); if (headerSlot) { headerSlot.replaceChildren(importBtn, issueNote, linksNote); } else { btnRow.appendChild(importBtn); btnRow.appendChild(issueNote); btnRow.appendChild(linksNote); panel.appendChild(btnRow); } updateLinksBadge(); const panelLi = document.createElement("li"); panelLi.style.cssText = "list-style:none;margin:0;padding:0;"; panelLi.classList.add("discogs-review-panel-li"); panelLi._buildStaticTableLi = buildStaticTableLi; panelLi.appendChild(panel); getReviewContainer().appendChild(panelLi); panelLi.scrollIntoView({ behavior: "smooth", block: "nearest" }); _hideBar(); }); } // src/editor-state.js async function waitForMBEditor(timeoutMs = 15e3) { log.info("Waiting for MB relationship editor\u2026"); let waited = 0; while (waited < timeoutMs) { const MB = pageWindow.MB; const re = MB?.relationshipEditor; const st = re?.state; if (st?.entity) { log.info(`Editor ready (${waited}ms). Release: "${st.entity.name}"`); return re; } if (waited % 2e3 === 0 && waited > 0) { const mbKeys = MB ? Object.keys(MB).join(", ") : "undefined"; const reKeys = re ? Object.keys(re).join(", ") : "undefined"; const stKeys = st ? Object.keys(st).join(", ") : "undefined"; log.info(`[${waited}ms] MB={${mbKeys}} re={${reKeys}} state={${stKeys}}`); } await new Promise((r) => setTimeout(r, 200)); waited += 200; } log.error("MB editor not ready after 15s \u2014 aborting"); return null; } function dispatchRelationship(re, sourceEntity, targetEntity, linkTypeID, credit, attributes, trackPos) { if (credit && credit === (targetEntity.name || "")) credit = ""; const swapped = sourceEntity.entityType > targetEntity.entityType; const e0 = swapped ? targetEntity : sourceEntity; const e1 = swapped ? sourceEntity : targetEntity; const ltEntry = pageWindow.MB?.linkedEntities?.link_type?.[linkTypeID]; const ltName = ltEntry ? ltEntry.name : linkTypeID; let attrDesc = ""; if (attributes) { try { const parts = []; for (const a of pageWindow.MB.tree.iterate(attributes)) { const n = a.type?.name || a.typeID; const v = a.text_value ? `=${a.text_value}` : ""; if (n) parts.push(n + v); } if (parts.length) attrDesc = ` [${parts.join(", ")}]`; } catch (e) { } } const posLabel = trackPos != null && trackPos !== "" ? ` #${trackPos}` : ""; log.info(`\u2192 ${ltName}${attrDesc}${posLabel}: ${sourceEntity.name || sourceEntity.gid} \u2194 ${targetEntity.name || targetEntity.gid}${credit && credit !== (targetEntity.name || targetEntity.gid) ? ` (credited: ${credit})` : ""}`); re.dispatch({ type: "update-relationship-state", sourceEntity, batchSelectionCount: null, creditsToChangeForSource: "", creditsToChangeForTarget: "", oldRelationshipState: null, newRelationshipState: { ...REL_TEMPLATE, entity0: e0, entity0_credit: swapped ? credit || "" : "", entity1: e1, entity1_credit: swapped ? "" : credit || "", id: re.getRelationshipStateId(), linkTypeID, attributes: attributes || null } }); } function buildAttributes(rawAttributes, linkTypeID) { if (!rawAttributes || rawAttributes.length === 0) return null; const MB = pageWindow.MB; const tree = MB?.tree; const lat = MB?.linkedEntities?.link_attribute_type; if (!tree || !lat) return null; const linkType = linkTypeID != null ? MB?.linkedEntities?.link_type?.[linkTypeID] : null; const supportedRoots = linkType && linkType.attributes ? new Set(Object.keys(linkType.attributes)) : null; const attrSupported = (found) => { if (!supportedRoots) return true; const rootId = found.root_id != null ? found.root_id : found.id; return supportedRoots.has(String(rootId)); }; function findAttrByName(name) { const lower = name.toLowerCase().trim(); for (const v of Object.values(lat)) { if (v.name?.toLowerCase() === lower) return v; } if (lower.length >= 4) { for (const v of Object.values(lat)) { const vl = v.name?.toLowerCase() || ""; if (vl.length < 4) continue; if (vl.includes(lower) || lower.includes(vl)) return v; } } log.warn(`Attribute "${name}" not found in MB \u2014 dropping attribute but keeping the rel`); return null; } function extractFnValue(fn) { const src = fn.toString(); const m = src.match(/,\s*['"`]([^'"`]+)['"`]\s*\)/); return m ? m[1] : null; } const attrObjs = []; const seen = /* @__PURE__ */ new Set(); for (const attr of rawAttributes) { let attrName = null; let textValue = ""; let creditedAs = ""; if (typeof attr === "string") { attrName = attr; } else if (attr && typeof attr === "object" && attr._type) { if (attr._type === "task") { attrName = "task"; textValue = attr.value; } else { attrName = attr.value; } if (attr.creditedAs) creditedAs = attr.creditedAs; } else if (typeof attr === "function") { attrName = extractFnValue(attr); } if (!attrName) continue; const found = findAttrByName(attrName); if (!found || seen.has(found.id)) continue; if (!attrSupported(found)) { log.warn(`Attribute "${found.name}" isn't supported by link type "${linkType.name || linkTypeID}" \u2014 dropping it (keeping the rel)`); continue; } seen.add(found.id); attrObjs.push({ type: found, typeID: found.id, credited_as: creditedAs, text_value: textValue }); } if (attrObjs.length === 0) return null; attrObjs.sort((a, b) => a.typeID - b.typeID); try { return tree.fromDistinctAscArray(attrObjs); } catch (e) { log.warn(`Attribute tree build failed (${e.message}) \u2014 importing without attributes`); return null; } } // src/data/work-only-rels.js var WORK_ONLY_ARTIST_RELS = [ "writer", "composer", "lyricist", "librettist", "revised by", // NOT 'translator' — a translator credit (liner notes / lyrics / libretto) is a // release-wide credit, so it dispatches at RELEASE level (artist↔release // "translator"), not duplicated onto every work. (MB has the artist-release rel.) "reconstructed by", // 'arranger', // 'instruments arranger', "orchestrator", // 'vocals arranger', "previously attributed to", "miscellaneous support", "dedicated to", "premiered by", "was commissioned by", "publisher", // MB's actual link-type name for the music-publisher rel (label→work). // Tidal/Qobuz "Music Publisher" credits resolve to a label and attach here. "publishing", "inspired the name of" ]; // src/dispatch.js function makeIdentifyingClassifier(lat) { const identifyingRoots = /* @__PURE__ */ new Set(); if (lat) { for (const v of Object.values(lat)) { const isRoot = v.parent_id == null || v.parent_id === v.id; if (isRoot && /^(instrument|vocal)$/i.test(v.name || "")) identifyingRoots.add(v.id); } } const rootCache = /* @__PURE__ */ new Map(); const rootIdOf = (typeID) => { if (rootCache.has(typeID)) return rootCache.get(typeID); let node = lat ? lat[typeID] : null; let guard = 0; while (node && node.parent_id != null && node.parent_id !== node.id && lat[node.parent_id] && guard++ < 64) { node = lat[node.parent_id]; } const rootId = node ? node.id : typeID; rootCache.set(typeID, rootId); return rootId; }; return (typeID) => identifyingRoots.has(rootIdOf(typeID)); } async function dispatchAllRelationships(companies, artistRoles, tracklistRels, applyToTracks, createWorksMode, discogsTracklist, processTracklist, resolvedEntityTypes, confirmedMap, discogsUrl, dedupOpts) { resolvedEntityTypes = resolvedEntityTypes || /* @__PURE__ */ new Map(); confirmedMap = confirmedMap || /* @__PURE__ */ new Map(); dedupOpts = dedupOpts || {}; const dedupeEquivalenceSets = dedupOpts.dedupeEquivalenceSets !== false; const dedupeDuplicateRoles = dedupOpts.dedupeDuplicateRoles !== false; const creditOverrides = dedupOpts.creditOverrides || /* @__PURE__ */ new Map(); const re = await waitForMBEditor(); if (!re) return; const MB = pageWindow.MB; const isIdentifyingAttr = makeIdentifyingClassifier(MB?.linkedEntities?.link_attribute_type); const equivalenceLookup = (() => { const m = /* @__PURE__ */ new Map(); if (!dedupeEquivalenceSets || !MB?.linkedEntities?.link_type) return m; for (const set of EQUIVALENCE_SETS) { const byPair = /* @__PURE__ */ new Map(); for (const [id, lt] of Object.entries(MB.linkedEntities.link_type)) { if (!lt?.name) continue; if (!set.includes(String(lt.name).toLowerCase())) continue; const key = `${lt.type0}|${lt.type1}`; if (!byPair.has(key)) byPair.set(key, []); byPair.get(key).push(Number(id)); } for (const ids of byPair.values()) { if (ids.length < 2) continue; const sibSet = new Set(ids); for (const id of ids) m.set(id, sibSet); } } return m; })(); const releaseEntity = re.state.entity; let added = 0, existedInMb = 0, existedStaged = 0, dedupedThisSession = 0, skipped = 0, failed = 0; const dispatchedThisSession = /* @__PURE__ */ new Set(); const RECORDING_LINK_TYPES = /* @__PURE__ */ new Set([ "performer", "instrument", "vocal", "vocals", "orchestra", "conductor", "concertmaster", "chorus master", "producer", "engineer", "mix", "recording", "remixer", "DJ-mixer", "additional", "guest", "programming" // NOT 'mastering' — MB deprecated artist→recording mastering (link type 136). ]); const isExecProducer = (role) => role.linkType === "producer" && (role.attributes || []).some((a) => a === "executive" || a && a.value === "executive"); log.info(`Starting instant fill: ${companies.length} companies, ${artistRoles.length} release artist roles, ${tracklistRels.length} tracklist roles`); try { _showBar(); } catch (_) { } const bar = document.querySelector(".discogs-bar"); function tickProgress() { const done = added + skipped + failed; const est = Math.max(done + 1, companies.length + artistRoles.length + tracklistRels.length); const pct = Math.min(Math.round(done / est * 99), 99); try { _setProgressPct(pct); } catch (_) { } } const recordingByGid = /* @__PURE__ */ new Map(); const recordingByPosition = /* @__PURE__ */ new Map(); const editorWorkByRecGid = /* @__PURE__ */ new Map(); const positionByGid = /* @__PURE__ */ new Map(); let trackCount = 0; try { let mediumIndex = 0; for (const [mediumKey, medium] of MB.tree.iterate(re.state.mediums)) { mediumIndex++; const tracks = medium?.tracks ?? medium; let trackIndex = 0; for (const rawTrack of MB.tree.iterate(tracks)) { const trackObj = Array.isArray(rawTrack) ? rawTrack[1] : rawTrack; const trackKey = Array.isArray(rawTrack) ? rawTrack[0] : null; const rec = trackObj?.recording ?? trackObj; if (!rec) continue; trackCount++; if (rec.gid) { recordingByGid.set(rec.gid, rec); positionByGid.set(rec.gid, `${mediumIndex}-${trackIndex + 1}`); const rw = trackObj?.relatedWorks; if (rw && rw.size > 0) { try { for (const entry of MB.tree.iterate(rw)) { const raw = Array.isArray(entry) ? entry[1] : entry; const work = raw?.work ?? raw; if (work?.gid || work?.id) { editorWorkByRecGid.set(rec.gid, work); break; } } } catch (e) { } } } const positions = new Set([ trackObj?.position, trackObj?.number, rec?.position, rec?.number, trackKey, trackIndex + 1, // Compound keys: "mediumIndex-trackPosition" `${mediumIndex}-${trackIndex + 1}`, trackObj?.position != null ? `${mediumIndex}-${trackObj.position}` : null, trackObj?.number != null ? `${mediumIndex}-${trackObj.number}` : null ].filter((x) => x != null).map(String)); for (const p of positions) recordingByPosition.set(p, rec); trackIndex++; } } log.info(`Found ${trackCount} track(s) in editor state (${recordingByGid.size} with GID, ${recordingByPosition.size} position entries: ${[...recordingByPosition.keys()].join(",")}). relatedWorks: ${editorWorkByRecGid.size} pre-linked`); } catch (e) { log.warn(`Iterating MB state: ${e.message}`); } const checkedRecGids = /* @__PURE__ */ new Set(); try { for (const raw of MB.tree.iterate(re.state.selectedRecordings)) { const rec = Array.isArray(raw) ? raw[1] : raw; if (rec?.gid) checkedRecGids.add(rec.gid); } } catch (e) { } const recSelectionActive = checkedRecGids.size > 0 && checkedRecGids.size < recordingByGid.size; const applyToRec = (gid) => !recSelectionActive || checkedRecGids.has(gid); if (recSelectionActive) log.info(`Recording selection: applying only to ${checkedRecGids.size}/${recordingByGid.size} checked recording(s).`); const positionToGid = /* @__PURE__ */ new Map(); try { const relMbid = releaseEntity.gid; log.info(`WS2: fetching recordings for release ${relMbid}\u2026`); const wsJson = await fetchWithRetry(`/ws/2/release/${relMbid}?inc=recordings&fmt=json`); log.info(`WS2: response received`); if (wsJson) { const mediaCount = wsJson.media?.length ?? 0; log.info(`WS2: ${mediaCount} medium/media in response`); const mediaArr = wsJson.media || []; const isMultiMedium2 = mediaArr.length > 1; for (const medium of mediaArr) { const medPos = medium.position; for (const track of medium.tracks || []) { const gid = track.recording?.id; if (!gid) continue; if (medPos != null && track.position != null) { positionToGid.set(`${medPos}-${track.position}`, gid); } if (medPos != null && track.number != null) { positionToGid.set(`${medPos}-${track.number}`, gid); } if (!isMultiMedium2) { if (track.position != null) positionToGid.set(String(track.position), gid); if (track.number != null) positionToGid.set(String(track.number), gid); } } } log.info(`WS2 position map: ${positionToGid.size} entries (${[...positionToGid.keys()].sort().join(", ")})`); } } catch (e) { log.warn(`WS2 recording fetch failed: ${e.message} \u2014 using editor state positions only`); } const isMultiMedium = positionToGid.size > 0 && [...positionToGid.keys()].some((k) => /^[2-9]-/.test(k)); function inferDiscFromVinylSide(pos) { const m = String(pos || "").match(/^([A-Z])\d+$/i); if (!m) return null; return Math.floor((m[1].toUpperCase().charCodeAt(0) - 65) / 2) + 1; } function getRecordingEntity(track) { const stripPad = (s) => String(s).replace(/-0+(\d)/g, "-$1"); const pos = track.position != null ? String(track.position) : ""; const num = track.number != null ? String(track.number) : ""; const compounds = /* @__PURE__ */ new Set(); const plain = /* @__PURE__ */ new Set(); if (/^\d+-/.test(pos)) { compounds.add(pos); const unpadded = stripPad(pos); if (unpadded !== pos) compounds.add(unpadded); } else if (pos) { plain.add(pos); const inferredDisc = inferDiscFromVinylSide(pos); if (inferredDisc != null) compounds.add(`${inferredDisc}-${pos}`); for (let m = 1; m <= 10; m++) compounds.add(`${m}-${pos}`); } if (num && num !== pos) { plain.add(num); for (let m = 1; m <= 10; m++) compounds.add(`${m}-${num}`); } const tryKeys = isMultiMedium ? [...compounds] : [...plain, ...compounds]; for (const c of tryKeys) { const gid = positionToGid.get(c); if (gid) { const rec = recordingByGid.get(gid); if (rec) return rec; log.warn(`Recording ${gid} for track ${track.position} not in editor state`); return null; } } for (const c of tryKeys) { const rec = recordingByPosition.get(c); if (rec) return rec; } if (trackCount > 0) { const ws2Keys = positionToGid.size ? [...positionToGid.keys()].join(", ") : "(empty)"; const stateKeys = recordingByPosition.size ? [...recordingByPosition.keys()].join(", ") : "(empty)"; log.warn(`No recording for track ${track.position} "${track.title}". WS2 keys: ${ws2Keys} | State keys: ${stateKeys}`); } return null; } function confirmedMbUrl(entity) { if (!entity) return null; const direct = confirmedMap.get(entity.resource_url) || confirmedMap.get(entity._syntheticKey) || null; if (direct) return direct; if (entity.name) { return confirmedMap.get(`_nourl_${entity.name}`) || null; } return null; } function relAlreadyExists(sourceEntity, linkTypeID, targetGid, attrTree) { const rels = sourceEntity?.relationships; if (!Array.isArray(rels) || rels.length === 0) return null; const acceptableLinkTypes = equivalenceLookup.get(linkTypeID) || /* @__PURE__ */ new Set([linkTypeID]); const sigOf = (attrs) => attrs.map((a) => `${a.typeID}:${a.text_value || ""}:${a.credited_as || ""}`).sort().join(","); const idSigOf = (attrs) => attrs.filter((a) => isIdentifyingAttr(a.typeID)).map((a) => `${a.typeID}:${a.text_value || ""}:${a.credited_as || ""}`).sort().join(","); const candAttrs = (() => { if (!attrTree) return []; try { return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => ({ typeID: a.typeID, text_value: a.text_value || "", credited_as: a.credited_as || "" })); } catch (e) { return []; } })(); const candSig = sigOf(candAttrs); const candIdSig = idSigOf(candAttrs); const lookupName = (id) => { try { return pageWindow.MB.linkedEntities.link_type[id]?.name || `#${id}`; } catch (e) { return `#${id}`; } }; let dupMatch = null; for (const r of rels) { if (!acceptableLinkTypes.has(r.linkTypeID)) continue; const tgt = r.target?.gid || r.entity0?.gid || r.entity1?.gid; if (tgt !== targetGid) continue; const isEquivalent = r.linkTypeID !== linkTypeID; const existingAttrs = (r.attributes || []).map((a) => ({ typeID: a.typeID, text_value: a.text_value || "", credited_as: a.credited_as || "" })); const exactMatch = sigOf(existingAttrs) === candSig; if (exactMatch) { return { kind: isEquivalent ? "equivalence" : "exact", existingLinkName: lookupName(r.linkTypeID), status: r._status }; } if (dedupeDuplicateRoles && !dupMatch && idSigOf(existingAttrs) === candIdSig) { dupMatch = { kind: isEquivalent ? "equivalence" : "duplicate-role", existingLinkName: lookupName(r.linkTypeID), status: r._status }; } } return dupMatch; } async function processOne(sourceEntity, entityType0, entityType1, linkTypeName, mbUrl, rawAttributes, credit, trackPos) { const overrideCredit = creditOverrides.get(mbUrl); if (overrideCredit && String(overrideCredit).trim()) { credit = String(overrideCredit).trim(); } const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); if (!mbid) { log.error(`Bad MBID URL: ${mbUrl}`); failed++; return; } const linkTypeID = resolveLinkTypeId(linkTypeName, entityType0, entityType1); if (!linkTypeID) { failed++; return; } const attrTree = buildAttributes(rawAttributes, linkTypeID); const attrSig = attrTree ? (() => { try { return [...pageWindow.MB.tree.iterate(attrTree)].map((a) => (a.typeID || "") + (a.credited_as ? "~" + a.credited_as : "")).join(","); } catch (e) { return ""; } })() : ""; const sessionKey = `${sourceEntity.gid}|${linkTypeID}|${mbid}|${attrSig}`; if (dispatchedThisSession.has(sessionKey)) { log.info(`Skipped duplicate dispatch of ${linkTypeName}: ${sourceEntity.name} \u2194 ${credit || ""} \u2014 already queued earlier this run`); dedupedThisSession++; return; } dispatchedThisSession.add(sessionKey); let targetEntity; try { targetEntity = await fetchMBEntity(mbid); } catch (e) { log.error(`Entity fetch failed for ${mbid}: ${e.message}`); failed++; return; } const PLACE_TO_LABEL_LINK = { "glass mastered at": "glass mastered", "mastered at": "mastering", "pressed at": "pressed", "manufactured at": "manufactured", "recorded at": "engineer", "mixed at": "mix" }; const LABEL_TO_PLACE_LINK = Object.fromEntries( Object.entries(PLACE_TO_LABEL_LINK).map(([k, v]) => [v, k]) ); let resolvedLinkTypeID = linkTypeID; if (targetEntity.entityType !== entityType1 && targetEntity.entityType !== entityType0) { const at = targetEntity.entityType; const [rt0, rt1] = at < sourceEntity.entityType ? [at, sourceEntity.entityType] : [sourceEntity.entityType, at]; let reResolved = resolveLinkTypeId(linkTypeName, rt0, rt1); if (!reResolved) { const altName = at === "label" ? PLACE_TO_LABEL_LINK[linkTypeName] : LABEL_TO_PLACE_LINK[linkTypeName]; if (altName) reResolved = resolveLinkTypeId(altName, rt0, rt1); } if (reResolved) { resolvedLinkTypeID = reResolved; } else { log.warn(`Entity "${targetEntity.name}" is a ${targetEntity.entityType} but expected ${entityType0}/${entityType1} \u2014 link type "${linkTypeName}" may not apply`); } } const dedupHit = relAlreadyExists(sourceEntity, resolvedLinkTypeID, targetEntity.gid, attrTree); if (dedupHit) { const pair = `${sourceEntity.name} \u2194 ${targetEntity.name}${credit && credit !== targetEntity.name ? ` (credited: ${credit})` : ""}`; const existing = dedupHit.existingLinkName; const staged = dedupHit.status === 1; const where = staged ? "already added this session" : "already in MB"; if (dedupHit.kind === "equivalence") { log.info(`Deduplication (equivalence sets): ${linkTypeName} not added \u2014 equivalent ${existing} ${where} on ${pair}`); } else if (dedupHit.kind === "duplicate-role") { log.info(`Deduplication (duplicate roles): ${linkTypeName} not added \u2014 same role ${where} with different attributes on ${pair}`); } else { log.info(`${staged ? "Already added this session" : "Already in MB"}: ${linkTypeName}: ${pair}`); } if (staged) existedStaged++; else existedInMb++; return; } dispatchRelationship(re, sourceEntity, targetEntity, resolvedLinkTypeID, credit, attrTree, trackPos); added++; } async function dispatchCompanies() { for (const company of companies) { const details = ENTITY_TYPE_MAP[company.entity_type_name]; if (!details) continue; const resolvedEt = resolvedEntityTypes.get(company.resource_url) || details.entityType; if (resolvedEt !== details.entityType) { if (details.entityType === "place" && resolvedEt === "label") { log.warn(`Skipped ${company.name}: MB has no "${details.linkType}" relationship for labels (only places). Add manually if needed.`); skipped++; tickProgress(); continue; } } const mbUrl = confirmedMbUrl(company); if (!mbUrl) { log.skip(`Skipped ${company.name} \u2014 not resolved in review`); skipped++; tickProgress(); continue; } const et = resolvedEt; const [t0, t1] = et <= "release" ? [et, "release"] : ["release", et]; await processOne(releaseEntity, t0, t1, details.linkType, mbUrl, [], ""); tickProgress(); } } async function dispatchReleaseArtists() { for (const role of artistRoles) { if (applyToTracks && RECORDING_LINK_TYPES.has(role.linkType) && !isExecProducer(role)) continue; if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} (${role.linkType}) \u2014 not resolved in review`); skipped++; tickProgress(); continue; } const credit = role.creditedAs || role.artist.anv?.trim() || role.artist.name; await processOne(releaseEntity, "artist", "release", role.linkType, mbUrl, role.attributes || [], credit); tickProgress(); } } async function dispatchTracklist() { if (applyToTracks && recordingByGid.size > 0) { const applicable = artistRoles.filter((role) => RECORDING_LINK_TYPES.has(role.linkType) && !WORK_ONLY_ARTIST_RELS.includes(role.linkType) && !isExecProducer(role)); if (applicable.length > 0) { log.info(`Applying ${applicable.length} release credit(s) to ${recordingByGid.size} recording(s)\u2026`); for (const role of applicable) { const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} (${role.linkType}) in applyToTracks \u2014 not resolved in review`); continue; } const credit = role.creditedAs || role.artist.anv?.trim() || role.artist.name; for (const recEntity of recordingByGid.values()) { if (!applyToRec(recEntity.gid)) continue; await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, positionByGid.get(recEntity.gid) || "*"); } } } } } async function dispatchWorks() { const recordingOfLinkTypeId = resolveLinkTypeId("performance", "recording", "work"); const includeOnlyResolved = createWorksMode === "when-needed"; const workOnlyByGid = /* @__PURE__ */ new Map(); for (const role of tracklistRels) { if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue; const recEntity = getRecordingEntity(role.track); if (!recEntity) { log.error(`Work-only rel for track ${role.track.position} "${role.track.title}" \u2014 no recording found, skipped`); failed++; continue; } if (!applyToRec(recEntity.gid)) continue; if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []); workOnlyByGid.get(recEntity.gid).push({ role, recEntity }); } for (const role of artistRoles) { if (!WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; if (includeOnlyResolved && !confirmedMbUrl(role.artist)) continue; for (const recEntity of recordingByGid.values()) { if (!applyToRec(recEntity.gid)) continue; const syntheticRole = { ...role, track: { position: "", title: recEntity.name || "" } }; if (!workOnlyByGid.has(recEntity.gid)) workOnlyByGid.set(recEntity.gid, []); workOnlyByGid.get(recEntity.gid).push({ role: syntheticRole, recEntity }); } } if (createWorksMode === "when-missing" && recordingOfLinkTypeId) { for (const recEntity of recordingByGid.values()) { if (!applyToRec(recEntity.gid)) continue; if (!workOnlyByGid.has(recEntity.gid)) { workOnlyByGid.set(recEntity.gid, []); } } } if (workOnlyByGid.size === 0) return; if (!recordingOfLinkTypeId) { log.error('Could not resolve "performance" link type \u2014 work processing skipped'); return; } log.info(`Processing work relationships for ${workOnlyByGid.size} recording(s)\u2026`); const existingWorkByRecGid = editorWorkByRecGid; log.info(`Editor state: ${existingWorkByRecGid.size} recording(s) already have a linked work`); function getWorkFromEditorState(recEntity) { try { for (const rel of MB.tree.iterate(recEntity.relationships)) { if (rel._status === 1 && rel.linkTypeID === recordingOfLinkTypeId) { return rel.entity0?.entityType === "work" ? rel.entity0 : rel.entity1; } } } catch (e) { } return null; } const createdWorkRecGids = /* @__PURE__ */ new Set(); for (const [recGid, entries] of workOnlyByGid) { const recEntity = entries[0]?.recEntity ?? recordingByGid.get(recGid); const trackTitle = entries[0]?.role.track.title || recEntity?.name || recGid; const trackPos = entries[0]?.role.track.position ?? ""; if (!recEntity) continue; const hasExistingWork = editorWorkByRecGid.has(recGid); let workEntity = null; if (hasExistingWork) { workEntity = editorWorkByRecGid.get(recGid); const wid = workEntity.gid || workEntity.id; log.info(`Track ${trackPos} "${trackTitle}": work already linked (${workEntity.name || wid || "existing"}) \u2014 skipping creation`); if (!workEntity.gid && !workEntity.id) continue; } if (!workEntity) workEntity = getWorkFromEditorState(recEntity); if (!workEntity && createWorksMode === "never") { for (const { role } of entries) { log.error(`Track ${trackPos} "${trackTitle}": no work exists for ${role.linkType} (${role.artist.name}) \u2014 "Create works" is set to "never". Add the work manually or change the mode.`); failed++; } continue; } if (!workEntity) { if (createdWorkRecGids.has(recGid)) { log.error(`Track ${trackPos} "${trackTitle}": a work was already created for this recording in this run \u2014 skipping to avoid a duplicate work`); failed++; continue; } const newWorkId = re.getRelationshipStateId(); workEntity = { _fromBatchCreateWorksDialog: true, attributes: [], comment: "", editsPending: false, entityType: "work", gid: null, id: newWorkId, iswcs: [], languages: [], name: trackTitle, typeID: null }; if (MB.mergeLinkedEntities) { MB.mergeLinkedEntities({ work: { [newWorkId]: workEntity } }); } re.dispatch({ type: "update-relationship-state", sourceEntity: recEntity, batchSelectionCount: null, creditsToChangeForSource: "", creditsToChangeForTarget: "", oldRelationshipState: null, newRelationshipState: { _lineage: ["batch-created work"], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0: recEntity, entity0_credit: "", entity1: workEntity, entity1_credit: "", id: re.getRelationshipStateId(), linkOrder: 0, linkTypeID: recordingOfLinkTypeId } }); createdWorkRecGids.add(recGid); log.info(`Track ${trackPos} "${trackTitle}": created new work "${trackTitle}"`); added++; tickProgress(); workEntity = getWorkFromEditorState(recEntity) || workEntity; } for (const { role } of entries) { const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} \u2014 not resolved in review (${role.linkType})`); continue; } const credit = role.creditedAs || role.artist.anv?.trim() || role.artist.name; const srcType = role.entityType || "artist"; if (workEntity.gid) { await processOne(workEntity, srcType, "work", role.linkType, mbUrl, role.attributes || [], credit, trackPos || entries[0]?.role?.track?.position); } else { const linkTypeID = resolveLinkTypeId(role.linkType, srcType, "work"); if (linkTypeID) { const mbid = mbUrl.replace(/.*\//, "").replace(/[^a-f0-9-]/gi, "").substring(0, 36); try { const artistEntity = await fetchMBEntity(mbid); dispatchRelationship(re, workEntity, artistEntity, linkTypeID, credit, buildAttributes(role.attributes || [], linkTypeID)); added++; } catch (e) { log.error(`Failed to add ${role.linkType} for new work: ${e.message}`); } } } } } } async function dispatchTracklistArtists() { const seenTrackRels = /* @__PURE__ */ new Set(); for (const role of tracklistRels) { if (WORK_ONLY_ARTIST_RELS.includes(role.linkType)) continue; const mbUrl = confirmedMbUrl(role.artist); if (!mbUrl) { log.skip(`Skipped ${role.artist.name} on track ${role.track.position} \u2014 not resolved in review`); continue; } const recEntity = getRecordingEntity(role.track); if (!recEntity) { log.warn(`No recording found for track ${role.track.position} "${role.track.title}" \u2014 skipped`); failed++; continue; } if (!applyToRec(recEntity.gid)) continue; const credit = role.creditedAs || role.artist.anv?.trim() || role.artist.name; const attrKey = (role.attributes || []).map((a) => typeof a === "string" ? a : a.value || a._type || "").join(","); const trackRelKey = `${role.track.position}|${role.linkType}|${mbUrl}|${attrKey}`; if (seenTrackRels.has(trackRelKey)) continue; seenTrackRels.add(trackRelKey); log.info(`Track ${role.track.position} "${role.track.title}": adding ${role.linkType} \u2014 ${credit}`); await processOne(recEntity, "artist", "recording", role.linkType, mbUrl, role.attributes || [], credit, role.track.position); tickProgress(); } } await dispatchCompanies(); await dispatchReleaseArtists(); await dispatchTracklist(); await dispatchWorks(); await dispatchTracklistArtists(); try { const opts = [ processTracklist !== void 0 ? `per-track:${processTracklist ? "on" : "off"}` : null, applyToTracks !== void 0 ? `move-to-tracks:${applyToTracks ? "on" : "off"}` : null, createWorksMode !== void 0 ? `create-works:${createWorksMode}` : null ].filter(Boolean).join(", "); const trackCount2 = Array.isArray(discogsTracklist) ? discogsTracklist.length : 0; const inputStats = `Input: ${companies?.length || 0} companies, ${artistRoles?.length || 0} release credits, ${tracklistRels?.length || 0} tracklist credits on ${trackCount2} track${trackCount2 === 1 ? "" : "s"}`; const unresolvedCount = confirmedMap?.unresolvedCount || 0; const totalEntities = confirmedMap?.totalEntities || 0; const unresolvedLine = unresolvedCount > 0 ? `Unresolved: ${unresolvedCount} of ${totalEntities} entit${totalEntities === 1 ? "y" : "ies"} skipped in review` : null; const editNoteDedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : ""; const editNoteStagedPart = existedStaged > 0 ? `, ${existedStaged} already added this session` : ""; const resultStats = `Result: ${added} added, ${existedInMb} already in MB${editNoteStagedPart}${editNoteDedupPart}, ${skipped} skipped, ${failed} failed${recSelectionActive ? ` (applied to ${checkedRecGids.size} of ${recordingByGid.size} selected recordings)` : ""}`; const ourNote = buildEditNote(discogsUrl, opts, [inputStats, unresolvedLine, resultStats].filter(Boolean)); const existingNote = document.querySelector(SELECTORS.EditNote)?.value || ""; const note = combineEditNote(existingNote, ourNote); re.dispatch({ type: "update-edit-note", editNote: note }); } catch (e) { } const dedupPart = dedupedThisSession > 0 ? `, ${dedupedThisSession} dispatch duplicate${dedupedThisSession === 1 ? "" : "s"}` : ""; const stagedPart = existedStaged > 0 ? `, ${existedStaged} already added this session` : ""; const selNote = recSelectionActive ? ` \u2014 applied to ${checkedRecGids.size} of ${recordingByGid.size} selected recording(s)` : ""; log.info(`Done: ${added} added, ${existedInMb} already in MB${stagedPart}${dedupPart}, ${skipped} skipped, ${failed} failed${selNote}`); } // src/sources/qobuz.js var QOBUZ_ROLE_MAP = { "Composer": { target: "work", rel: "composer" }, "Lyricist": { target: "work", rel: "lyricist" }, "Author": { target: "work", rel: "lyricist" }, "ComposerLyricist": { target: "work", rel: "writer" }, "Writer": { target: "work", rel: "writer" }, "Arranger": { target: "work", rel: "arranger" }, "Performance Arranger": { target: "recording", rel: "arranger" }, "Producer": { target: "recording", rel: "producer" }, "Co-Producer": { target: "recording", rel: "producer" }, "Assistant Producer": { target: "recording", rel: "producer", attributes: ["assistant"] }, "Mixer": { target: "recording", rel: "mix" }, "MixingEngineer": { target: "recording", rel: "mix" }, "Mixing Engineer": { target: "recording", rel: "mix" }, "Engineer": { target: "recording", rel: "engineer" }, "Assistant Engineer": { target: "recording", rel: "engineer", attributes: ["assistant"] }, "RecordingEngineer": { target: "recording", rel: "recording" }, "Recording Engineer": { target: "recording", rel: "recording" }, "MasteringEngineer": { target: "recording", rel: "mastering" }, "Mastering Engineer": { target: "recording", rel: "mastering" }, "Editor": { target: "recording", rel: "editor" }, "Remixer": { target: "recording", rel: "remixer" }, "Conductor": { target: "recording", rel: "conductor" }, "Vocals": { target: "recording", rel: "vocal" }, "Vocal": { target: "recording", rel: "vocal" }, "Background Vocal": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "Background Vocals": { target: "recording", rel: "vocal", attributes: [{ _type: "vocal", value: "background vocals" }] }, "MusicPublisher": { target: "work", rel: "publisher" }, "Music Publisher": { target: "work", rel: "publisher" }, "MainArtist": null, "Main Artist": null, "FeaturedArtist": null, "Featured Artist": null, "AssociatedPerformer": null, "Associated Performer": null, "StudioPersonnel": null, "Studio Personnel": null }; var QOBUZ_INSTRUMENTS_CI = new Set(Object.keys(INSTRUMENTS).map((k) => k.toLowerCase())); function isQobuzRole(token) { return Object.prototype.hasOwnProperty.call(QOBUZ_ROLE_MAP, token) || QOBUZ_INSTRUMENTS_CI.has(token.toLowerCase()); } var QOBUZ_ALBUM_RE = /^(?:https?:)?\/\/(?:www\.|play\.|open\.)?qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\/(?:[^/]+\/)?([a-z0-9]+)\/?(?:[?#]|$)/i; function parseQobuzAlbumUrl(url) { const m = QOBUZ_ALBUM_RE.exec(url || ""); if (!m) return null; const original = String(url).replace(/^\/\//, "https://"); const isStore = /^https?:\/\/(www\.)?qobuz\.com\/[a-z]{2}-[a-z]{2}\/album\//i.test(original); return { id: m[1], pageUrl: isStore ? original.split(/[?#]/)[0] : `https://www.qobuz.com/us-en/album/x/${m[1]}` }; } function decodeEntities(s) { return String(s).replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(+n)).replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))).replace(/&/g, "&").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " "); } var QOBUZ_NOTICE_RE = /^\s*[(℗©]|\bcopyright\b/i; function parseQobuzCreditLine(line) { const out = []; for (const seg of String(line).split(" - ")) { const raw = seg.trim(); if (!raw || QOBUZ_NOTICE_RE.test(raw)) continue; const tokens = raw.split(",").map((t) => t.trim()).filter(Boolean); const firstRole = tokens.findIndex(isQobuzRole); if (firstRole === -1) { out.push({ name: tokens.join(", "), roles: [] }); continue; } const name = tokens.slice(0, firstRole).join(", "); const roles = tokens.slice(firstRole).filter(isQobuzRole); if (name) out.push({ name, roles }); else if (out.length) out[out.length - 1].roles.push(...roles); } return out; } function extractQobuzCredits(html) { const byTrack = /* @__PURE__ */ new Map(); const re = /id="popinAddToCartBtnPlayerTrack(\d+)"|]*class="[^"]*\btrack__info\b[^"]*"[^>]*>([\s\S]*?)<\/p>/gi; let m, current = 0; while ((m = re.exec(html)) !== null) { if (m[1] !== void 0) { current = parseInt(m[1], 10); continue; } const text = decodeEntities((m[2] || "").replace(/<[^>]+>/g, "").trim()); if (!text || !current) continue; if (!byTrack.has(current)) byTrack.set(current, parseQobuzCreditLine(text)); } return [...byTrack.entries()].sort((a, b) => a[0] - b[0]).map(([index, credits]) => ({ index, credits })); } function extractQobuzAlbumInfo(html) { const og = html.match(/ { if (typeof GM_xmlhttpRequest !== "function") { reject(new Error("GM_xmlhttpRequest unavailable")); return; } GM_xmlhttpRequest({ method: "GET", url: pageUrl, headers: { "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.8" }, timeout: 2e4, onload: (r) => r.status >= 200 && r.status < 400 && r.responseText ? resolve(r.responseText) : reject(new Error(`Qobuz page returned ${r.status}`)), onerror: () => reject(new Error("Qobuz page fetch failed (network)")), ontimeout: () => reject(new Error("Qobuz page fetch timed out")) }); }); } // src/ui-bar.js var _logs2; var _summary; var _discogsJson = null; var _tidalJson = null; var _qobuzJson = null; var SRC_ICON = { Discogs: '', Tidal: '', Qobuz: '', // #271: the "Titles" source derives remixer credits from the track titles // themselves — no provider. A small text/lines glyph. Titles: '' }; var srcIconByUrl = (url) => SRC_ICON[sourceNameForUrl(url)] || ""; function insertDiscogsBar(discogsUrl, sources = {}, meta = {}) { const style = document.createElement("style"); style.innerText = ` .discogs-bar { font-family: inherit; background: #fff; border: 1px solid #e0c88a; border-left: 4px solid #e8771d; border-radius: 0.35rem; margin-bottom: 1rem; overflow: hidden; } .discogs-bar-row1 { display: flex; align-items: center; gap: 0.6rem; row-gap: 0.4rem; flex-wrap: wrap; padding: 0.5rem 0.75rem; background: #fdf8f0; border-bottom: 1px solid #eeddb0; } /* inline options strip in the single bar (#139) */ .discogs-bar-opts { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin-left: 0.9rem; } .discogs-bar-opts .discogs-opts-label { font-size: 0.75rem; color: #999; text-transform: uppercase; letter-spacing: 0.05em; flex-shrink: 0; } .discogs-opts-btn { font-size: 0.8rem; color: #555; background: #fffdf7; border: 1px solid #d8c8a0; border-radius: 2rem; padding: 0.15rem 0.6rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.25rem; } .discogs-opts-btn:hover { border-color: #e8771d; color: #333; } .discogs-opts-caret { color: #999; font-size: 0.7rem; } /* "Options \u25BE" popover (Dedup toggles) */ .discogs-opts-panel { position: fixed; z-index: 100002; display: none; flex-direction: column; gap: 0.4rem; background: #fff; border: 1px solid #d8c8a0; border-radius: 0.4rem; box-shadow: 0 6px 22px rgba(40,20,80,0.18); padding: 0.55rem 0.6rem; font-family: inherit; } .discogs-opts-panel.open { display: flex; } .discogs-opts-panel .discogs-opts-panel-hd { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: #999; font-weight: 600; } /* "Log" header toggle \u2014 a split button (#142, #217): "Log" toggles, the \u25BE half (its own clickable target) opens the copy menu. */ .discogs-log-split { display: inline-flex; align-items: stretch; } .discogs-logtoggle-btn { font-size: 0.78rem; color: #555; background: #fff; border: 1px solid #cfcfcf; border-radius: 0.25rem 0 0 0.25rem; border-right: none; padding: 0.15rem 0.55rem; cursor: pointer; display: inline-flex; align-items: center; white-space: nowrap; } .discogs-log-caret-btn { font-size: 0.78rem; color: #777; background: #fff; border: 1px solid #cfcfcf; border-radius: 0 0.25rem 0.25rem 0; padding: 0.15rem 0.45rem; cursor: pointer; display: inline-flex; align-items: center; } .discogs-logtoggle-btn:hover, .discogs-log-caret-btn:hover { border-color: #999; } .discogs-log-caret-btn:hover { background: #f6f3fc; } .discogs-log-split.active .discogs-logtoggle-btn, .discogs-log-split.active .discogs-log-caret-btn { background: #f0ecfa; border-color: #b9a4e0; color: #5a3e94; } /* "Log \u25BE" dropdown menu (#118): show/hide + the three copy actions. */ .discogs-log-menu { position: fixed; z-index: 100002; display: none; flex-direction: column; min-width: 11rem; background: #fff; border: 1px solid #cfcfcf; border-radius: 0.4rem; box-shadow: 0 6px 22px rgba(40,20,80,0.18); padding: 0.3rem; font-family: inherit; } .discogs-log-menu.open { display: flex; } .discogs-log-menu button { text-align: left; font-size: 0.82rem; color: #444; background: none; border: none; border-radius: 0.25rem; padding: 0.3rem 0.5rem; cursor: pointer; white-space: nowrap; } .discogs-log-menu button:hover { background: #f0ecfa; color: #333; } /* log panel toolbar: severity filter + copy buttons */ .discogs-log-toolbar { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; padding: 0.3rem 0 0.45rem; } .discogs-log-filter { display: inline-flex; border: 1px solid #ddd; border-radius: 0.3rem; overflow: hidden; } .discogs-log-filterbtn { font-size: 0.75rem; color: #666; background: #fff; border: none; border-right: 1px solid #eee; padding: 0.15rem 0.55rem; cursor: pointer; } .discogs-log-filterbtn:last-child { border-right: none; } .discogs-log-filterbtn:hover { background: #f6f3fc; } .discogs-log-filterbtn.active { background: #5f3ec0; color: #fff; } .discogs-log-copyslot { display: inline-flex; gap: 0.4rem; margin-left: auto; } .discogs-log-copybtn { font-size: 0.78rem; color: #555; background: #fff; border: 1px solid #cfcfcf; border-radius: 0.25rem; padding: 0.15rem 0.5rem; cursor: pointer; white-space: nowrap; } .discogs-log-copybtn:hover { border-color: #999; } .discogs-bar img.discogs-logo { height: 20px; width: auto; flex-shrink: 0; opacity: 0.85; } .discogs-bar .discogs-source-icon { display: inline-flex; align-items: center; flex-shrink: 0; } .discogs-bar .discogs-source-icon:hover .discogs-logo { opacity: 1; } .discogs-bar .discogs-source { flex: 0 1 auto; max-width: 20rem; font-size: 0.82rem; color: #555; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Slot in the always-visible header that hosts the review "Start import" button + unresolved message (#139). Content-sized; the right cluster's margin-left:auto does the pushing so the link/help stay right even when this slot is empty (initial state). */ .discogs-bar-action { flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.6rem; } .discogs-bar-action:empty { display: none; } /* Reserved message area (#118): badge + transient status, right-aligned just left of the Discogs/Help/Log cluster. margin-left:auto pushes it (and the right cluster after it) to the edge, leaving the gap up to the "Options" button free for these messages. Collapses to nothing when empty (hidden children don't count as flex items, so the gap contributes no width). */ /* #139: grow to fill the gap between "Options" and the right cluster so the status message can use that whole width (right-aligned next to the cluster). flex:1 also keeps the right cluster pinned to the edge whether this is empty or not \u2014 replacing the old margin-left:auto. Basis 0 (not auto) is essential: with an auto basis a very long message's content width is used for row1's wrap calculation and bumps the whole slot to a second row; basis 0 keeps it on the line and the status just ellipsises inside it. */ .discogs-bar-msgs { flex: 1 1 0; justify-content: flex-end; display: flex; align-items: center; gap: 0.4rem; min-width: 0; } /* #139: no fixed cap \u2014 the message takes the available space up to "Options" and only ellipsises when it genuinely doesn't fit (flex-shrink + min-width:0). */ .discogs-bar-status { font-size: 0.8rem; color: #888; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* #216: persistent end-of-run message (e.g. "No importable credits found") */ .discogs-bar-status-final { color: #b26a00; font-weight: 600; } /* Count badges. Buttons so they can focus/open the log; styled as pills. Borderless + a touch larger per #139; the (deepened) fill carries them. */ .discogs-badge { flex-shrink: 0; font-size: 0.9rem; font-weight: 600; line-height: 1.2; padding: 0.2rem 0.7rem; border-radius: 2rem; border: none; cursor: pointer; white-space: nowrap; } .discogs-badge-warn { color: #8a4b00; background: #ffe1a8; } .discogs-badge-warn:hover{ background: #ffd68a; } .discogs-badge-err { color: #9c1b1b; background: #f9c9c9; } .discogs-badge-err:hover { background: #f5b4b4; } .discogs-badge-unresolved{ color: #6a4a86; background: #e3d8f5; } .discogs-badge-unresolved:hover { background: #d6c6f0; } /* Discogs logo + Help + Log \u2014 pinned to the right edge (the msgs slot's margin-left:auto does the pushing). */ .discogs-bar-right { flex-shrink: 0; display: flex; align-items: center; gap: 0.6rem; min-width: 0; } /* During the review wait the import isn't running, so the "Importing\u2026" button + percentage are redundant \u2014 hide them; they reappear while a real import phase (preflight / dispatch) is active (#139). */ .discogs-bar.is-reviewing .discogs-import-btn, .discogs-bar.is-reviewing #discogs-progress-pct { display: none !important; } /* !important: the % span carries an inline display set by JS */ .discogs-bar-action .discogs-issue-note { font-size: 0.85rem; color: #7a5c00; min-width: 7.5rem; /* reserve space so the bar doesn't reflow as the count appears / changes (#139) */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .discogs-bar-action .discogs-issue-note.clickable { cursor: pointer; text-decoration: underline dotted; } .discogs-bar-action .discogs-issue-note.clickable:hover { color: #a06000; } /* "N links" badge \u2014 orange, short, clickable to cycle through the rows whose source URL still needs linking. No wide min-width reservation. */ .discogs-bar-action .discogs-links-note { min-width: 0; color: #e8771d; } .discogs-bar-action .discogs-links-note.clickable:hover { color: #c25e0a; } .discogs-bar .discogs-source a { color: #e8771d; text-decoration: none; font-weight: bold; } .discogs-bar .discogs-source a:hover { text-decoration: underline; } /* #272: "Import credits:" label + a row of clickable source icons */ .discogs-import-label { flex-shrink: 0; font-size: 0.88rem; font-weight: bold; color: #444; letter-spacing: 0.01em; } /* #272: drop the "Import credits:" label once a run is underway \u2014 only the active source icon + progress/Start-import matter then. */ .discogs-bar.is-importing .discogs-import-label, .discogs-bar.is-reviewing .discogs-import-label { display: none; } .discogs-src-icons { flex-shrink: 0; display: inline-flex; align-items: center; gap: 0.3rem; } .discogs-src-ico { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; cursor: pointer; border: 1px solid #d6d6d6; border-radius: 0.3rem; background: #fff; color: #555; } .discogs-src-ico:hover { background: #fff3e8; border-color: #e8771d; color: #e8771d; } .discogs-src-ico:disabled { opacity: 0.5; cursor: default; } .discogs-src-ico svg { width: 18px; height: 18px; } .discogs-src-ico img.discogs-logo { height: 18px; width: auto; opacity: 1; } .discogs-src-ico.importing { background: #fff3e8; border-color: #e8771d; animation: discogs-ico-pulse 1s ease-in-out infinite; } @keyframes discogs-ico-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(232,119,29,.5); } 50% { box-shadow: 0 0 0 4px rgba(232,119,29,0); } } .discogs-log-menu button svg { vertical-align: -2px; margin-right: 4px; } .discogs-bar-row2 { display: flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.75rem; flex-wrap: wrap; } .discogs-bar-row2 .discogs-opts-label { font-size: 0.75rem; color: #999; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 0.2rem; flex-shrink: 0; } /* Borderless toggles (#118): no pill outline/background \u2014 the dot alone signals on/off, matching the maintainer's mockup. */ .discogs-toggle { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.15rem 0.4rem 0.15rem 0.2rem; border: none; border-radius: 2rem; background: transparent; cursor: pointer; font-size: 0.8rem; color: #555; user-select: none; transition: color 0.12s; } .discogs-toggle:hover { color: #222; } .discogs-toggle input[type=checkbox] { display: none; } .discogs-toggle .discogs-toggle-dot { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #bbb; background: #fff; flex-shrink: 0; transition: border-color 0.12s, background 0.12s; } .discogs-toggle.active { color: #333; } .discogs-toggle.active .discogs-toggle-dot { border-color: #e8771d; background: #e8771d; } .discogs-output { padding: 0.5rem 0.75rem 0.25rem; } .discogs-output.empty { display: none; } /* no log yet \u2192 hide the whole section (#142) */ .discogs-output .summary { margin: 0 0 0.25rem; font-size: 0.88rem; color: #555; } .discogs-output .logs { margin: 0; padding-left: 1.2rem; font-size: 0.83rem; } /* log panel hides behind the header "Log \u25BE" button (#142); the review panel sits in .discogs-review-slot OUTSIDE the panel, always visible. */ .discogs-log-panel { display: none; } .discogs-output.log-open .discogs-log-panel { display: block; } .discogs-review-slot:not(:empty) { margin: 0.2rem 0; } /* severity filter: Warnings shows only warn lines, Errors only error lines \u2014 each view matches its header badge count. "skip" lines (unresolved-entity skips, #118) and info lines show only under "All"; errors are NOT lumped into the Warnings view. */ .discogs-output[data-logfilter="warn"] .discogs-log-body .logs > li:not([data-sev="warn"]) { display: none; } .discogs-output[data-logfilter="error"] .discogs-log-body .logs > li:not([data-sev="error"]) { display: none; } /* \u2500\u2500 Progress / sticky bar \u2500\u2500 */ /* Pinned during import (is-importing) AND kept pinned afterwards (is-pinned, #118) so the WARN/ERR badge stays visible on top while the user scrolls the staged edits below. overflow:hidden on .discogs-bar rules out position:sticky, so we use fixed + an in-flow spacer that reserves row1's height (see .discogs-sticky-spacer). */ .discogs-bar.is-importing .discogs-bar-row1, .discogs-bar.is-pinned .discogs-bar-row1 { position: fixed; top: 0; left: 0; right: 0; z-index: 9000; background: #fdf8f0; border-bottom: 1px solid #eeddb0; box-shadow: 0 2px 8px rgba(0,0,0,0.15); } /* Occupies row1's height in the flow while row1 is fixed, so the page content below the bar doesn't jump up under it. Height set by JS. */ .discogs-sticky-spacer { display: none; } .discogs-bar.is-importing .discogs-sticky-spacer, .discogs-bar.is-pinned .discogs-sticky-spacer { display: block; } .discogs-progress-track { height: 5px; background: #eeddb0; border-radius: 3px; overflow: hidden; } .discogs-progress-fill { height: 100%; width: 0%; background: #e8771d; border-radius: 3px; transition: width 0.3s ease; } .discogs-progress-fill.indeterminate { width: 40%; animation: discogs-slide 1.4s ease-in-out infinite; } @keyframes discogs-slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } .discogs-progress-status { font-size: 0.8rem; color: #7a5000; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .discogs-recent-logs { font-size: 0.78rem; color: #888; max-height: 3.2rem; overflow: hidden; line-height: 1.4; } .discogs-recent-logs span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .discogs-toggle { position: relative; } /* position:fixed so the tooltip escapes .discogs-bar's overflow:hidden (needed there to clip child backgrounds to the bar's rounded corners). Per-hover JS in makeCheckbox sets top/left from the toggle's viewport rect, so the tooltip renders outside any overflow-clipping ancestor. Issue #89. */ .discogs-tooltip { display: none; position: fixed; background: #333; color: #fff; font-size: 0.78rem; line-height: 1.45; padding: 0.45rem 0.65rem; border-radius: 0.3rem; white-space: normal; width: 220px; box-shadow: 0 2px 8px rgba(0,0,0,0.25); pointer-events: none; z-index: 9999; text-align: left; } .discogs-tooltip::after { content: ''; position: absolute; top: 100%; left: var(--arrow-x, 50%); transform: translateX(-50%); border: 5px solid transparent; border-top-color: #333; } /* When the tooltip flipped below the toggle (no room above), flip the arrow to point up from the tooltip's top edge. */ .discogs-tooltip.below::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: #333; } /* Tooltip shown by JS adding .discogs-tooltip-visible after a hover-intent delay (see makeCheckbox). Native browser title= tooltips have a ~1s delay by convention; the custom tooltips used to fire instantly and felt jumpy when sweeping across toggles. */ .discogs-tooltip.discogs-tooltip-visible { display: block; } `; document.head.appendChild(style); const bar = document.createElement("div"); bar.className = "discogs-bar"; const row1 = document.createElement("div"); row1.className = "discogs-bar-row1"; const importSources = []; if (discogsUrl) importSources.push({ name: "Discogs", url: discogsUrl, run: (g) => runImport(discogsUrl, g) }); if (sources.tidal) importSources.push({ name: "Tidal", url: sources.tidal, run: (g) => runTidalImport(sources.tidal, g) }); if (sources.qobuz) importSources.push({ name: "Qobuz", url: sources.qobuz, run: (g) => runQobuzImport(sources.qobuz, g) }); if ((meta.titlesRemixCount || 0) > 0) { importSources.push({ name: "Titles", url: "", run: (g) => runTitlesImport(g) }); } const importLabel = document.createElement("span"); importLabel.className = "discogs-import-label"; importLabel.textContent = "Import credits:"; const srcIcons = document.createElement("span"); srcIcons.className = "discogs-src-icons"; const ORIG_ICON = { Discogs: ``, Tidal: '', Qobuz: '', Titles: SRC_ICON.Titles }; const srcButtons = []; let importing = false; importSources.forEach((s) => { const b = document.createElement("button"); b.type = "button"; b.className = "discogs-src-ico"; b._icon = ORIG_ICON[s.name] || SRC_ICON[s.name] || s.name; b.innerHTML = b._icon; b.dataset.src = s.name; b.title = s.url ? `Import credits from ${s.name} \xB7 right-click to open the ${s.name} page` : "Import remixer credits derived from the track titles"; b.addEventListener("click", () => { if (!importing) startImport(b, s.url, s.run); }); if (s.url) b.addEventListener("contextmenu", (e) => { e.preventDefault(); window.open(s.url, "_blank", "noopener,noreferrer"); }); srcButtons.push(b); srcIcons.appendChild(b); }); const progressPct = document.createElement("span"); progressPct.id = "discogs-progress-pct"; progressPct.style.cssText = "display:none; margin-left:0.5rem; font-size:0.85rem; color:#e8771d; font-weight:bold; min-width:3.5rem;"; row1.appendChild(importLabel); row1.appendChild(srcIcons); row1.appendChild(progressPct); const actionSlot = document.createElement("div"); actionSlot.className = "discogs-bar-action"; row1.appendChild(actionSlot); const optsWrap = document.createElement("div"); optsWrap.className = "discogs-bar-opts"; row1.appendChild(optsWrap); let _optsHost = optsWrap; const msgSlot = document.createElement("div"); msgSlot.className = "discogs-bar-msgs"; const statusEl = document.createElement("span"); statusEl.className = "discogs-bar-status"; statusEl.style.display = "none"; const warnPill = document.createElement("button"); warnPill.type = "button"; warnPill.className = "discogs-badge discogs-badge-warn"; warnPill.style.display = "none"; warnPill.title = "Show warnings in the log"; const errPill = document.createElement("button"); errPill.type = "button"; errPill.className = "discogs-badge discogs-badge-err"; errPill.style.display = "none"; errPill.title = "Show errors in the log"; const unresolvedPill = document.createElement("button"); unresolvedPill.type = "button"; unresolvedPill.className = "discogs-badge discogs-badge-unresolved"; unresolvedPill.style.display = "none"; unresolvedPill.title = "Entities not matched on MusicBrainz \u2014 skipped on import"; msgSlot.append(statusEl, warnPill, errPill, unresolvedPill); row1.appendChild(msgSlot); const rightGroup = document.createElement("div"); rightGroup.className = "discogs-bar-right"; const logSplit = document.createElement("span"); logSplit.className = "discogs-log-split"; logSplit.style.display = "none"; const logToggleBtn = document.createElement("button"); logToggleBtn.type = "button"; logToggleBtn.className = "discogs-logtoggle-btn"; logToggleBtn.textContent = "Log"; logToggleBtn.title = "Show / hide the import log"; const logCaretBtn = document.createElement("button"); logCaretBtn.type = "button"; logCaretBtn.className = "discogs-log-caret-btn"; logCaretBtn.textContent = "\u25BE"; logCaretBtn.title = "More log actions (copy)"; logSplit.append(logToggleBtn, logCaretBtn); const docsHref = typeof GM_info !== "undefined" && (GM_info?.script?.homepageURL || GM_info?.script?.homepage) || "https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/credit_hoarder/README.md"; const docsLink = document.createElement("a"); docsLink.href = docsHref; docsLink.target = "_blank"; docsLink.rel = "noopener noreferrer nofollow"; docsLink.textContent = "? Help"; docsLink.title = "Open the script's README in a new tab"; docsLink.style.cssText = "flex-shrink:0;font-size:0.82rem;color:#7a5000;text-decoration:none;padding:0.1rem 0.45rem;border:1px solid #d4b800;border-radius:0.25rem;background:#fff8e6;"; rightGroup.append(logSplit, docsLink); row1.appendChild(rightGroup); bar.appendChild(row1); const stickySpacer = document.createElement("div"); stickySpacer.className = "discogs-sticky-spacer"; bar.appendChild(stickySpacer); bar._pin = () => { const h = row1.getBoundingClientRect().height; if (h) stickySpacer.style.height = h + "px"; }; window.addEventListener("resize", () => { if (bar.classList.contains("is-pinned")) bar._pin(); }); function makeCheckbox(labelText, checkedByDefault, tooltipText) { const lbl = document.createElement("label"); lbl.className = "discogs-toggle" + (checkedByDefault ? " active" : ""); const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = checkedByDefault; const dot = document.createElement("span"); dot.className = "discogs-toggle-dot"; lbl.appendChild(cb); lbl.appendChild(dot); lbl.appendChild(document.createTextNode(labelText)); if (tooltipText) { const tip = document.createElement("span"); tip.className = "discogs-tooltip"; tip.textContent = tooltipText; lbl.appendChild(tip); const TIP_W = 220, TIP_MARGIN = 6, EDGE_PAD = 8; const HOVER_DELAY_MS = 1e3; let _showTimer, _hideTimer; lbl.addEventListener("mouseenter", () => { clearTimeout(_showTimer); _showTimer = setTimeout(() => { const r = lbl.getBoundingClientRect(); const centerX = r.left + r.width / 2; let x = centerX - TIP_W / 2; x = Math.max(EDGE_PAD, Math.min(x, window.innerWidth - TIP_W - EDGE_PAD)); tip.style.left = `${x}px`; tip.style.top = "-9999px"; tip.classList.add("discogs-tooltip-visible"); const h = tip.offsetHeight; const above = r.top - TIP_MARGIN - h; const fitsAbove = above >= EDGE_PAD; tip.style.top = fitsAbove ? `${above}px` : `${r.bottom + TIP_MARGIN}px`; tip.classList.toggle("below", !fitsAbove); tip.style.setProperty("--arrow-x", `${centerX - x}px`); clearTimeout(_hideTimer); _hideTimer = setTimeout(() => tip.classList.remove("discogs-tooltip-visible"), 4e3); }, HOVER_DELAY_MS); }); lbl.addEventListener("mouseleave", () => { clearTimeout(_showTimer); clearTimeout(_hideTimer); tip.classList.remove("discogs-tooltip-visible"); }); } lbl.addEventListener("click", (e) => { e.preventDefault(); cb.checked = !cb.checked; lbl.classList.toggle("active", cb.checked); document.querySelectorAll(".discogs-tooltip-visible").forEach((t) => t.classList.remove("discogs-tooltip-visible")); }); _optsHost.appendChild(lbl); return cb; } function makeSelect(labelText, initialValue, options, tooltipText) { const wrap = document.createElement("span"); wrap.className = "discogs-select-wrap"; wrap.style.cssText = "display:inline-flex;align-items:center;gap:0.3rem;font-size:0.8rem;color:#555;padding:0.15rem 0.2rem;border:none;background:transparent;"; const lbl = document.createElement("span"); lbl.textContent = labelText + ":"; wrap.appendChild(lbl); const sel = document.createElement("select"); sel.style.cssText = "font-size:0.8rem;padding:0.05rem 0.2rem;border:none;background:transparent;cursor:pointer;color:#333;font-weight:600;"; options.forEach((opt) => { const o = document.createElement("option"); o.value = opt.value; o.textContent = opt.label; if (opt.value === initialValue) o.selected = true; sel.appendChild(o); }); if (tooltipText) wrap.title = tooltipText; wrap.appendChild(sel); _optsHost.appendChild(wrap); return sel; } const OPTS_KEY = "discogs-importer-opts"; let savedOpts = {}; try { savedOpts = JSON.parse(localStorage.getItem(OPTS_KEY) || "{}"); } catch (e) { } const bv = (k, d) => k in savedOpts ? savedOpts[k] : d; const tracklistCb = makeCheckbox( "Per-track credits", bv("tracklist", true), "Import per-track artist credits." ); const applyTracksCb = makeCheckbox( "Move release credits to tracks", bv("applyTracks", false), "Move performance credits from the release down to every recording." ); const _legacyCreateWorks = savedOpts.createWorks; const _initialCreateWorksMode = bv( "createWorksMode", _legacyCreateWorks === true ? "when-missing" : _legacyCreateWorks === false ? "never" : "when-needed" ); const createWorksMode = makeSelect("Create works", _initialCreateWorksMode, [ { value: "when-needed", label: "when needed" }, { value: "when-missing", label: "when missing" }, { value: "never", label: "never" } ], "when needed: create a work only when there is a composer/lyricist/writer credit to attach. when missing: create a work for every recording without one. never: do not create works \u2014 work-only credits with no existing work are logged and skipped."); const optsBtn = document.createElement("button"); optsBtn.type = "button"; optsBtn.className = "discogs-opts-btn"; optsBtn.innerHTML = 'Options \u25BE'; optsBtn.title = "Deduplication options"; const optsPanel = document.createElement("div"); optsPanel.className = "discogs-opts-panel"; const dedupHd = document.createElement("div"); dedupHd.className = "discogs-opts-panel-hd"; dedupHd.textContent = "Deduplication"; optsPanel.appendChild(dedupHd); _optsHost = optsPanel; const dedupeEqCb = makeCheckbox( "Equivalence sets", bv("dedupeEquivalenceSets", true), "Skip a role when an equivalent role already exists on the target (writer \u2261 composer)." ); const dedupeDupCb = makeCheckbox( "Duplicate roles", bv("dedupeDuplicateRoles", true), "Skip adding a role when the target already has the same role (regardless of task / dates / attributes)." ); _optsHost = optsWrap; optsWrap.appendChild(optsBtn); document.body.appendChild(optsPanel); optsBtn.addEventListener("click", (e) => { e.stopPropagation(); const open = optsPanel.classList.toggle("open"); if (!open) return; const r = optsBtn.getBoundingClientRect(); optsPanel.style.left = Math.max(8, Math.min(r.left, window.innerWidth - optsPanel.offsetWidth - 8)) + "px"; optsPanel.style.top = r.bottom + 4 + "px"; const off = (ev) => { if (!optsPanel.contains(ev.target) && ev.target !== optsBtn && !optsBtn.contains(ev.target)) { optsPanel.classList.remove("open"); document.removeEventListener("mousedown", off); } }; setTimeout(() => document.addEventListener("mousedown", off), 0); }); const saveOpts = () => { try { localStorage.setItem(OPTS_KEY, JSON.stringify({ tracklist: tracklistCb.checked, applyTracks: applyTracksCb.checked, createWorksMode: createWorksMode.value, dedupeEquivalenceSets: dedupeEqCb.checked, dedupeDuplicateRoles: dedupeDupCb.checked })); } catch (e) { } }; [tracklistCb, applyTracksCb, dedupeEqCb, dedupeDupCb].forEach((cb) => cb.closest("label").addEventListener("click", () => setTimeout(saveOpts, 0))); createWorksMode.addEventListener("change", saveOpts); const outputDiv = document.createElement("div"); outputDiv.className = "discogs-output empty"; const LOG_OPEN_KEY = "discogs-importer-log-open"; const reviewSlot = document.createElement("div"); reviewSlot.className = "discogs-review-slot"; setReviewContainer(reviewSlot); const logPanel = document.createElement("div"); logPanel.className = "discogs-log-panel"; const logToolbar = document.createElement("div"); logToolbar.className = "discogs-log-toolbar"; const logFilter = document.createElement("div"); logFilter.className = "discogs-log-filter"; [["all", "All"], ["warn", "\u26A0 Warnings"], ["error", "\u26D4 Errors"]].forEach(([f, label]) => { const b = document.createElement("button"); b.type = "button"; b.className = "discogs-log-filterbtn" + (f === "all" ? " active" : ""); b.dataset.f = f; b.textContent = label; b.addEventListener("click", () => { outputDiv.dataset.logfilter = f; logFilter.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x.dataset.f === f)); }); logFilter.appendChild(b); }); logToolbar.append(logFilter); const logBody = document.createElement("div"); logBody.className = "discogs-log-body"; logPanel.append(logToolbar, logBody); outputDiv.append(reviewSlot, logPanel); outputDiv.dataset.logfilter = "all"; const applyLogOpen = () => { const open = localStorage.getItem(LOG_OPEN_KEY) === "1"; outputDiv.classList.toggle("log-open", open); logSplit.classList.toggle("active", open); }; try { applyLogOpen(); } catch (e) { } const setLogOpen = (open) => { outputDiv.classList.toggle("log-open", open); logSplit.classList.toggle("active", open); try { localStorage.setItem(LOG_OPEN_KEY, open ? "1" : "0"); } catch (e) { } }; const logMenu = document.createElement("div"); logMenu.className = "discogs-log-menu"; const mkMenuItem = (label, title, fn) => { const b = document.createElement("button"); b.type = "button"; b.textContent = label; b.title = title; b.addEventListener("click", () => fn(b, label)); return b; }; const copyLogItem = mkMenuItem("Copy log", "Copy the full import log (incl. the raw source data)", (b, l) => bar._copy?.log(b, l)); const copyNoJsonItem = mkMenuItem("Copy without JSON", "Copy the log without the raw source-data block \u2014 fits in a GitHub issue", (b, l) => bar._copy?.noJson(b, l)); logMenu.append(copyLogItem, copyNoJsonItem); if (discogsUrl) logMenu.appendChild(mkMenuItem("Copy Discogs", "Copy the raw Discogs JSON for this release", (b, l) => bar._copy?.discogs(b, l))); if (sources.tidal) logMenu.appendChild(mkMenuItem("Copy Tidal", "Copy the raw Tidal credits harvest for this release", (b, l) => bar._copy?.tidal(b, l))); if (sources.qobuz) logMenu.appendChild(mkMenuItem("Copy Qobuz", "Copy the parsed Qobuz credits for this release", (b, l) => bar._copy?.qobuz(b, l))); document.body.appendChild(logMenu); function openLogMenu() { const open = logMenu.classList.toggle("open"); if (!open) return; const r = logSplit.getBoundingClientRect(); logMenu.style.left = Math.max(8, Math.min(r.left, window.innerWidth - logMenu.offsetWidth - 8)) + "px"; logMenu.style.top = r.bottom + 4 + "px"; const off = (ev) => { if (!logMenu.contains(ev.target) && !logSplit.contains(ev.target)) { logMenu.classList.remove("open"); document.removeEventListener("mousedown", off); } }; setTimeout(() => document.addEventListener("mousedown", off), 0); } logToggleBtn.addEventListener("click", (e) => { e.stopPropagation(); logMenu.classList.remove("open"); setLogOpen(!outputDiv.classList.contains("log-open")); }); logCaretBtn.addEventListener("click", (e) => { e.stopPropagation(); openLogMenu(); }); function openLog(filter, scrollSel) { const f = filter || "all"; outputDiv.classList.add("log-open"); logSplit.classList.add("active"); outputDiv.dataset.logfilter = f; logFilter.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x.dataset.f === f)); requestAnimationFrame(() => { const target = scrollSel && logBody.querySelector(scrollSel) || logPanel; const headerH = bar.classList.contains("is-pinned") ? row1.getBoundingClientRect().height + 6 : 0; const top = target.getBoundingClientRect().top + window.scrollY - headerH - 10; window.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); }); } warnPill.addEventListener("click", () => openLog("warn", 'li[data-sev="warn"]')); errPill.addEventListener("click", () => openLog("error", 'li[data-sev="error"]')); unresolvedPill.addEventListener("click", () => openLog("all", 'li[data-sev="skip"]')); onLogCounts((w, e) => { warnPill.textContent = `\u26A0 ${w}`; warnPill.style.display = w > 0 ? "" : "none"; errPill.textContent = `\u26D4 ${e}`; errPill.style.display = e > 0 ? "" : "none"; }); bar._setUnresolved = (n) => { unresolvedPill.textContent = `\u2298 ${n} unresolved`; unresolvedPill.style.display = n > 0 ? "" : "none"; }; bar._setStopMessage = (msg) => { bar._stopActive = !!msg; statusEl.textContent = msg || ""; statusEl.style.display = msg ? "" : "none"; statusEl.classList.toggle("discogs-bar-status-final", !!msg); }; function startImport(srcBtn, sourceUrl, runner) { importing = true; srcButtons.forEach((b) => { const active = b === srcBtn; b.classList.toggle("importing", active); b.style.display = active ? "" : "none"; }); progressPct.style.display = "inline"; progressPct.textContent = "0%"; bar.classList.add("is-importing", "is-pinned"); bar._pin(); _showBar(); bar.scrollIntoView({ behavior: "smooth", block: "start" }); bar._showProgress = () => { _showBar(); }; requestAnimationFrame(bar._showProgress); resetLogCounts(); bar._setUnresolved(0); bar._setStopMessage(""); _logs2 = document.createElement("ul"); _logs2.className = "logs"; setLogContainer(_logs2); _summary = document.createElement("p"); _summary.className = "summary"; logBody.innerHTML = ""; logBody.appendChild(_summary); logBody.appendChild(_logs2); outputDiv.classList.remove("empty"); logSplit.style.display = ""; try { applyLogOpen(); } catch (e) { } function buildCopyText({ skipDiscogsJson }) { function htmlToMd(el) { function nodeToMd(node) { if (node.nodeType === Node.TEXT_NODE) return node.textContent; const tag = node.tagName?.toLowerCase(); const inner = [...node.childNodes].map(nodeToMd).join(""); if (tag === "strong" || tag === "b") return `**${inner}**`; if (tag === "em" || tag === "i") return `_${inner}_`; if (tag === "a") return `[${inner}](${node.href})`; if (tag === "br") return "\n"; if (tag === "pre") { return "\n```json\n" + node.textContent + "\n```\n"; } if (tag === "details") { const sum = node.querySelector("summary"); const sumText = sum ? [...sum.childNodes].map((n) => { if (n.nodeType === Node.TEXT_NODE) return n.textContent; const t = n.tagName?.toLowerCase(); if (t === "button" || t === "input") return ""; return n.textContent; }).join("").trim() : ""; if (skipDiscogsJson && /raw Discogs JSON/i.test(sumText)) { return ""; } const body = [...node.childNodes].filter((n) => n !== sum).map(nodeToMd).join(""); return "\n\n
      " + sumText + "\n\n" + body + "\n
      \n\n"; } if (tag === "summary") return ""; if (tag === "span") return inner; if (tag === "div") return inner + "\n"; if (tag === "ul") return inner; if (tag === "li" && el !== node) return "- " + inner + "\n"; if (tag === "table") { const rows = [...node.querySelectorAll("tr")]; if (!rows.length) return ""; const cells = rows.map((r) => [...r.querySelectorAll("th,td")].map((c) => c.innerText.trim().replace(/\|/g, "\\|"))); const widths = cells[0]?.map((_, i) => Math.max(...cells.map((r) => (r[i] || "").length), 3)); const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length)); const mdRows = cells.map((row) => "| " + row.map((c, i) => pad(c, widths[i])).join(" | ") + " |"); if (mdRows.length > 1) mdRows.splice(1, 0, "| " + widths.map((w) => "-".repeat(w)).join(" | ") + " |"); return "\n\n" + mdRows.join("\n") + "\n\n"; } return inner; } const _md = nodeToMd(el); return _md.startsWith("\n\n") || _md.endsWith("\n\n") ? _md : _md.replace(/^\n/, "").replace(/\n$/, ""); } const _panel = document.querySelector(".discogs-review-slot .discogs-review-panel-li"); const lines = [..._panel ? [_panel] : [], ..._logs2.querySelectorAll("li")].map((li) => { if (li.classList?.contains("discogs-review-panel-li") && typeof li._buildStaticTableLi === "function") { return htmlToMd(li._buildStaticTableLi()); } const md = htmlToMd(li); if (!md) return ""; if (md.startsWith("\n\n|") || md.startsWith("
      ")) return md; return md + " "; }).filter(Boolean).join("\n"); const releaseName = pageWindow?.MB?.relationshipEditor?.state?.entity?.name || document.title.replace(/ - MusicBrainz.*/, "").trim() || "Import log"; return `
      ${releaseName} ${lines}
      `; } function copyToClipboard(text, btn, restoreText) { const restore = () => { btn.textContent = "Copied!"; setTimeout(() => { btn.textContent = restoreText; }, 1500); }; const fallback = () => { const ta = Object.assign(document.createElement("textarea"), { value: text }); document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); restore(); }; if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text).then(restore, fallback); } else { fallback(); } } bar._copy = { log: (item, label) => copyToClipboard(buildCopyText({ skipDiscogsJson: false }), item, label), noJson: (item, label) => copyToClipboard(buildCopyText({ skipDiscogsJson: true }), item, label), discogs: (item, label) => { if (_discogsJson) copyToClipboard(JSON.stringify(_discogsJson, null, 2), item, label); }, tidal: (item, label) => { if (_tidalJson) copyToClipboard(JSON.stringify(_tidalJson, null, 2), item, label); }, qobuz: (item, label) => { if (_qobuzJson) copyToClipboard(JSON.stringify(_qobuzJson, null, 2), item, label); } }; bar._setProgress = (pct, text) => { if (pct !== null && pct >= 100) _hideBar(); if (text && bar.classList.contains("is-importing") && !bar._stopActive) { statusEl.textContent = text; statusEl.style.display = ""; } }; requestAnimationFrame(_showBar); const getOpts = () => ({ processTracklist: tracklistCb.checked, applyToTracks: applyTracksCb.checked, createWorksMode: createWorksMode.value, dedupeEquivalenceSets: dedupeEqCb.checked, dedupeDuplicateRoles: dedupeDupCb.checked }); const _click = getOpts(); const opts = `per-track:${_click.processTracklist ? "on" : "off"}, move-to-tracks:${_click.applyToTracks ? "on" : "off"}, create-works:${_click.createWorksMode}`; const editNote = buildEditNote(sourceUrl, opts); editNote.split("\n").forEach((line) => { if (!line.trim()) return; const html = line.replace(/(https?:\/\/[^\s]+)/g, '$1'); log.info(html); }); runner(getOpts).finally(() => { importing = false; srcButtons.forEach((b) => { b.classList.remove("importing"); b.style.display = ""; }); progressPct.textContent = "100%"; setTimeout(() => { progressPct.style.display = "none"; }, 2e3); bar.classList.remove("is-reviewing"); setTimeout(() => { bar.classList.remove("is-importing"); _hideBar(); bar._pin(); }, 2e3); delete bar._setProgress; }); } bar.appendChild(outputDiv); function insertBar() { const anchor = document.querySelector(".release-rel-editor") || // MB React wrapper document.querySelector("#content > div") || // generic first content div document.querySelector("#content"); if (!anchor) return setTimeout(insertBar, 300); anchor.insertBefore(bar, anchor.firstChild); } insertBar(); } (function cleanupLocalStorage() { try { const keysToRemove = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (!k) continue; if (k.startsWith("discogs-release-")) keysToRemove.push(k); } keysToRemove.forEach((k) => localStorage.removeItem(k)); } catch (e) { } })(); function runImport(discogsUrl, getOpts) { const initial = getOpts(); const { processTracklist } = initial; return getDiscogsReleaseData(discogsUrl).then((json) => { _discogsJson = json; let artistRoles = rolesFromDiscogsArtists(json.extraartists?.filter((artist) => !artist.tracks)); if (!_logs2._releaseInfoAdded) { _logs2._releaseInfoAdded = true; const trackCount = flattenTracklist(json.tracklist).filter((t) => t.type_ === "track").length; const summary = `${json.title || ""}${json.year ? " \xB7 " + json.year : ""} \xB7 ${trackCount} tracks`; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:#f8f8f8;padding:0.5rem;border:1px solid #ddd;border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(json, null, 2); li.innerHTML = `
      ${summary} \u2014 raw Discogs JSON
      `; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); } log.info(`Found ${json.companies.length + artistRoles.length} release relationships`); artistRoles = artistRoles.concat(convertPotentialDJMixers(json)); let tracklistRels = []; if (processTracklist) { tracklistRels = flattenTracklist(json.tracklist).filter((track) => track.type_ === "track").reduce((map, track) => { if (!track.extraartists || !Array.isArray(track.extraartists)) { return map; } return map.concat( rolesFromDiscogsArtists(track.extraartists).map((rel) => { return Object.assign({}, rel, { track }); }) ); }, []); const releaseLevelTracklistRels = json.extraartists?.filter((artist) => artist.tracks && artist.tracks !== "") || []; if (releaseLevelTracklistRels.length > 0) { tracklistRels = tracklistRels.concat( releaseLevelTracklistRels.reduce((array, artist) => { return array.concat( getAllArtistTracks(json.tracklist, artist.tracks).reduce((array2, track) => { return array2.concat( getArtistRoles(artist).map((rel) => { return Object.assign({}, rel, { artist, track }); }) ); }, []) ); }, []) ); } log.info(`Found ${tracklistRels.length} tracklist relationships`); } return runSourcePipeline({ companies: json.companies, artistRoles, tracklistRels, tracklist: json.tracklist, sourceUrl: discogsUrl, processTracklist, getOpts }); }); } function runTidalImport(tidalUrl, getOpts) { log.info(`Opening the Tidal credits tab \u2014 it closes itself once harvested (a few seconds)\u2026`); return harvestTidalAlbum(tidalUrl).then((harvest) => { _tidalJson = harvest; if (!harvest.ok) throw new Error(`Tidal harvest failed: ${harvest.error || "unknown error"}`); const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:#f8f8f8;padding:0.5rem;border:1px solid #ddd;border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(harvest, null, 2); li.innerHTML = `
      ${harvest.tracks.length} tracks \u2014 raw Tidal harvest
      `; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); const processTracklist = !!getOpts().processTracklist; const { tracklistRels: ptRels, tracklist, skipped, multiVolume } = tidalToEngine(harvest.tracks); const tracklistRels = processTracklist ? ptRels : []; const { artists: relArtists, publishers: relPublishers, companies: relCompanies, skipped: relSkipped } = tidalReleaseArtists(harvest.releaseCredits); const artistRoles = [...relPublishers]; for (const a of relArtists) { const roles = getArtistRoles(a); if (!roles.length) { relSkipped.push(`release: ${a.tidalRole} \u2014 ${a.name}`); continue; } if (a.assistant) roles.forEach((r) => { r.attributes = (r.attributes || []).concat("assistant"); }); artistRoles.push(...roles); } const companies = relCompanies || []; log.info(`Tidal credits: ${tracklistRels.length} per-track + ${artistRoles.length} release-level relationship(s)${companies.length ? ` + ${companies.length} label/company` : ""} across ${tracklist.length} track(s)`); if (!processTracklist) log.info(`Per-track credits disabled \u2014 importing release-level credits only${getOpts().applyToTracks ? " (applied to tracks)" : ""}.`); (processTracklist ? skipped.concat(relSkipped) : relSkipped).forEach((s) => log.info(`Not imported (v1 scope): ${s}`)); if (multiVolume) log.warn(`Multi-volume Tidal album \u2014 track numbers repeat per volume; positions may not all match this release's mediums. Review carefully.`); if (!tracklistRels.length && !artistRoles.length && !companies.length) { log.warn("No importable credits found on the Tidal credits page."); document.querySelector(".discogs-bar")?._setStopMessage?.("No importable credits found"); return; } return runSourcePipeline({ companies, artistRoles, tracklistRels, tracklist, sourceUrl: tidalUrl, processTracklist, getOpts }); }).catch((err) => { log.error(err.message || String(err)); }); } function runQobuzImport(qobuzUrl, getOpts) { const parsed = parseQobuzAlbumUrl(qobuzUrl); if (!parsed) { log.error(`Not a Qobuz album URL: ${qobuzUrl}`); return Promise.resolve(); } log.info(`Fetching Qobuz store page: ${parsed.pageUrl}`); return fetchQobuzAlbumPage(parsed.pageUrl).then((html) => { const albumInfo = extractQobuzAlbumInfo(html); const tracks = extractQobuzCredits(html); _qobuzJson = { pageUrl: parsed.pageUrl, album: albumInfo, tracks }; const li = document.createElement("li"); const pre = document.createElement("pre"); pre.style.cssText = "max-height:400px;overflow:auto;font-size:0.72rem;background:#f8f8f8;padding:0.5rem;border:1px solid #ddd;border-radius:3px;margin:0.3rem 0 0 0;white-space:pre-wrap;word-break:break-all;"; pre.textContent = JSON.stringify(_qobuzJson, null, 2); li.innerHTML = `
      ${albumInfo || "Qobuz album"} \xB7 ${tracks.length} tracks \u2014 parsed Qobuz credits
      `; li.querySelector("details").appendChild(pre); _logs2.appendChild(li); if (!tracks.length) { log.warn("No credits found on the Qobuz page \u2014 nothing to import."); document.querySelector(".discogs-bar")?._setStopMessage?.("No importable credits found"); return; } const { tracklistRels, tracklist, skipped } = qobuzToEngine(tracks); log.info(`Qobuz credits: ${tracklistRels.length} per-track relationship(s) across ${tracklist.length} track(s)`); skipped.forEach((s) => log.info(`Not imported (v1 scope): ${s}`)); if (!tracklistRels.length) { log.warn("No importable credits found on the Qobuz page."); document.querySelector(".discogs-bar")?._setStopMessage?.("No importable credits found"); return; } return runSourcePipeline({ companies: [], artistRoles: [], tracklistRels, tracklist, sourceUrl: qobuzUrl, processTracklist: true, getOpts }); }).catch((err) => { log.error(err.message || String(err)); }); } function buildTitlesTracklist(mbid) { return fetchWithRetry(`/ws/2/release/${mbid}?inc=recordings&fmt=json`).then((json) => { const media = json?.media || []; const multiMedium = media.length > 1; const tracklist = []; for (const medium of media) { const medPos = medium.position; for (const t of medium.tracks || []) { const pos = t.position != null ? t.position : t.number; tracklist.push({ position: multiMedium && medPos != null && pos != null ? `${medPos}-${pos}` : String(pos != null ? pos : ""), title: t.title || t.recording?.title || "", type_: "track" }); } } return tracklist; }); } function probeTitleRemixes(mbid) { if (!mbid) return Promise.resolve({ count: 0, tracklist: [] }); return buildTitlesTracklist(mbid).then((tracklist) => ({ count: deriveRemixRoles(tracklist).length, tracklist })).catch(() => ({ count: 0, tracklist: [] })); } function runTitlesImport(getOpts) { const m = location.pathname.match(/release\/([0-9a-f-]{36})/i); if (!m) { log.error("Not on a release page \u2014 cannot read track titles."); return Promise.resolve(); } log.info("Reading track titles from MusicBrainz to derive remixer credits\u2026"); return buildTitlesTracklist(m[1]).then((tracklist) => { const tracklistRels = deriveRemixRoles(tracklist, m[1]); log.info(`Derived ${tracklistRels.length} remixer credit(s) from ${tracklist.length} track title(s)`); if (!tracklistRels.length) { log.warn("No named remixes found in the track titles \u2014 nothing to import."); document.querySelector(".discogs-bar")?._setStopMessage?.("No remixes found in titles"); return; } return runSourcePipeline({ companies: [], artistRoles: [], tracklistRels, tracklist, sourceUrl: "", processTracklist: true, getOpts }); }).catch((err) => { log.error(err.message || String(err)); }); } function runSourcePipeline({ companies, artistRoles, tracklistRels, tracklist, sourceUrl, processTracklist, getOpts }) { const allArtistRoles = artistRoles.concat(tracklistRels); const uniqueArtists = []; const seenResourceUrls = /* @__PURE__ */ new Set(); const rolesMap = /* @__PURE__ */ new Map(); allArtistRoles.forEach((role) => { const url = role.artist?.resource_url || `_nourl_${role.artist?.name || role.artist?.id}`; if (!rolesMap.has(url)) rolesMap.set(url, []); let displayLabel = role.linkType; if (role.attributes && role.attributes.length > 0) { const attr = role.attributes[0]; if (attr._type === "instrument" && attr.value) displayLabel = attr.value; else if (attr._type === "vocal" && attr.value) displayLabel = attr.value; else if (typeof attr === "string") displayLabel = `${role.linkType} [${attr}]`; } rolesMap.get(url).push({ linkType: role.linkType, displayLabel, trackPos: role.track?.position || "", trackTitle: role.track?.title || "" }); if (!seenResourceUrls.has(url)) { seenResourceUrls.add(url); if (!role.artist?.resource_url && role.artist) role.artist._syntheticKey = url; uniqueArtists.push(role.artist); } }); const _relMbid = (location.pathname.match(/release\/([0-9a-f-]{36})/i) || [])[1]; if (_relMbid) { for (const a of uniqueArtists) { if (a && !a.resource_url && !a._cacheKey && a.name) { a._cacheKey = `nameonly/${_relMbid}/${a.name.toLowerCase().trim()}`; } } } const companiesRolesMap = /* @__PURE__ */ new Map(); companies.forEach((c) => { if (!c.resource_url) return; if (!companiesRolesMap.has(c.resource_url)) companiesRolesMap.set(c.resource_url, []); companiesRolesMap.get(c.resource_url).push({ linkType: c.entity_type_name || "" }); }); const uniqueCompanies = []; const seenCompanyUrls = /* @__PURE__ */ new Set(); companies.forEach((c) => { if (c.resource_url && !seenCompanyUrls.has(c.resource_url) && ENTITY_TYPE_MAP[c.entity_type_name]) { seenCompanyUrls.add(c.resource_url); uniqueCompanies.push(c); } }); function runPreflight(bypassIdb = false) { log.info(`Starting preflight: ${uniqueArtists.length} artist(s), ${uniqueCompanies.length} label(s)/place(s).`); const artistProgressLi = document.createElement("li"); artistProgressLi.textContent = `Checking ${uniqueArtists.length} artist(s) against MusicBrainz\u2026`; _logs2.appendChild(artistProgressLi); const companyProgressLi = document.createElement("li"); companyProgressLi.textContent = `Checking ${uniqueCompanies.length} label(s)/place(s) against MusicBrainz\u2026`; _logs2.appendChild(companyProgressLi); const t0 = performance.now(); return (async () => { const artistResults = await resolveAll(uniqueArtists, { progressLi: artistProgressLi, progressLabel: "Checking artists against MusicBrainz", kindOf: ENTITY_KIND, bypassIdb }); const companyResults = await resolveAll(uniqueCompanies, { progressLi: companyProgressLi, progressLabel: "Checking labels/places against MusicBrainz", kindOf: COMPANY_KIND, bypassIdb }); const elapsed = (performance.now() - t0) / 1e3; log.info(`Preflight done in ${elapsed.toFixed(1)}s.`); return [...artistResults.allResults, ...companyResults.allResults].filter(Boolean); })(); } function annotateRoles(allResults) { allResults.forEach((r) => { if (!r) return; const url = r.entity?.resource_url || r.entity?._syntheticKey; if (url) r._roles = rolesMap.get(url) || companiesRolesMap.get(url) || []; }); } let capturedResults = null; let capturedConfirmedMap = null; return runPreflight().then((allResults) => { annotateRoles(allResults); capturedResults = allResults; if (!allResults.length) { log.warn("Nothing to import \u2014 no entities to review."); document.querySelector(".discogs-bar")?._setStopMessage?.("Nothing to import \u2014 no entities to review"); return; } document.querySelector(".discogs-bar")?.classList.add("is-reviewing"); return showReviewTable(capturedResults, rolesMap, companiesRolesMap, { // Mount the Start-import button + unresolved message in the // always-visible header rather than below the table (#139). // Resolved via the DOM (there's one bar) — `runImport` is a // separate function from the bar builder that owns the slot. headerSlot: document.querySelector(".discogs-bar-action"), // Label fallback for URL-less credits (#193): a Qobuz row // must say "No Qobuz page", not "No Discogs page". The // URL-less Titles source (#271) reports as 'Titles' so the // review table drops Discogs-specific wording/elements. sourceName: sourceUrl ? sourceNameForUrl(sourceUrl) : "Titles", sourceIcon: sourceUrl ? srcIconByUrl(sourceUrl) : SRC_ICON.Titles || "", // #193 — shown on the "Start import" button // "🔄 Refresh from MB" — bypass the IDB cache and re-resolve // every entity via MB API. Used when a cached MBID is stale. onRefresh: () => runPreflight(true).then((freshResults) => { annotateRoles(freshResults); capturedResults = freshResults; return freshResults; }) }); }).then((confirmedMap) => { if (!confirmedMap) return; capturedConfirmedMap = confirmedMap; document.querySelector(".discogs-bar")?.classList.remove("is-reviewing"); document.querySelector(".discogs-bar")?._setUnresolved?.(confirmedMap.unresolvedCount || 0); const cachePromises = []; confirmedMap.forEach((mbUrl, resourceUrl) => { const key = parseSourceEntityUrl(resourceUrl)?.key; if (!key) return; const m = mbUrl.match(/\/(artist|label|place)\/([a-f0-9-]+)/); if (!m) return; cachePromises.push(writeIdbRecord(key, { mbid: m[2], entityType: m[1] // No resolvedVia change — the inline write owns it. })); }); return Promise.all(cachePromises); }).then(() => { if (!capturedConfirmedMap) return; const resolvedEntityTypes = /* @__PURE__ */ new Map(); (capturedResults || []).forEach((r) => { if (r.entity?.resource_url && r.mbUrl && r.entityType) { resolvedEntityTypes.set(r.entity.resource_url, r.entityType); } }); const live = getOpts(); if (live.processTracklist !== processTracklist) { log.warn(`"Per-track credits" toggled during review (preflight ran with "${processTracklist ? "on" : "off"}", import will follow preflight). To change, restart the import.`); } const dedupOpts = { dedupeEquivalenceSets: live.dedupeEquivalenceSets, dedupeDuplicateRoles: live.dedupeDuplicateRoles, creditOverrides: capturedConfirmedMap?.creditOverrides }; return dispatchAllRelationships(companies, artistRoles, tracklistRels, live.applyToTracks, live.createWorksMode, tracklist, processTracklist, resolvedEntityTypes, capturedConfirmedMap, sourceUrl, dedupOpts); }); } // src/credit_hoarder.user.js if (/(^|\.)tidal\.com$/i.test(location.hostname)) { runTidalHarvestPage(); } (function handleCreatePageAutoCommit() { const onCreate = /\/(artist|label|place)\/create\b/i.test(location.pathname); const onEdit = /\/(artist|label|place)\/[a-f0-9-]{36}\/edit\b/i.test(location.pathname); if (!onCreate && !onEdit) return; const m = location.hash.match(/ch-autocommit(?:=([^&]+))?/); if (!m) return; if (onCreate) { let identity = ""; try { identity = decodeURIComponent(m[1] || ""); } catch (e) { identity = m[1] || ""; } try { sessionStorage.setItem("discogs-importer-pending-artist", identity); } catch (e) { } } else { try { sessionStorage.setItem("discogs-importer-close-after-edit", "1"); } catch (e) { } } const et = (location.pathname.match(/\/(artist|label|place)\//) || [])[1] || "artist"; const seedUrl = new URLSearchParams(location.search).get(`edit-${et}.url.0.text`) || ""; const seedKey = seedUrl ? seedUrl.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/+$/, "").toLowerCase() : ""; let tries = 0; const submit = () => { const seedReady = !seedKey || document.body.innerHTML.toLowerCase().includes(seedKey); const btn = document.querySelector("button.submit.positive") || [...document.querySelectorAll('button[type="submit"]')].find((b) => /enter edit/i.test(b.textContent || "")); if (seedReady && btn && !btn.disabled) { btn.click(); return; } if (tries++ < 100) setTimeout(submit, 200); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", submit); else submit(); })(); (function handleEntityPageIfNeeded() { const entityMatch = location.href.match( /musicbrainz\.org\/(artist|label|place)\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})(?:[^/]|$)/i ); if (!entityMatch) return; const entityType = entityMatch[1]; const mbid = entityMatch[2]; try { if (sessionStorage.getItem("discogs-importer-close-after-edit")) { sessionStorage.removeItem("discogs-importer-close-after-edit"); try { DISCOGS_CHANNEL.postMessage({ type: "edit-committed", id: mbid }); } catch (e) { } setTimeout(() => window.close(), 50); return; } } catch (e) { } const pendingKey = "discogs-importer-pending-artist"; const pending = sessionStorage.getItem(pendingKey); if (!pending) return; sessionStorage.removeItem(pendingKey); const NAME_FETCH_TIMEOUT_MS = 1e3; const CLOSE_DELAY_MS = 50; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), NAME_FETCH_TIMEOUT_MS); fetch(`//musicbrainz.org/ws/2/${entityType}/${mbid}?fmt=json`, { signal: ctrl.signal }).then((r) => r.json()).then((json) => ({ name: json.name || "", disambiguation: json.disambiguation || "" })).catch(() => ({ name: "", disambiguation: "" })).then(({ name, disambiguation }) => { clearTimeout(timer); DISCOGS_CHANNEL.postMessage({ type: "artist-created", // keep same message type for compatibility id: mbid, name, disambiguation, resourceUrl: pending }); setTimeout(() => window.close(), CLOSE_DELAY_MS); }); })(); if (/musicbrainz\.org$/i.test(location.hostname)) $(document).ready(function() { const re = /musicbrainz\.org\/release\/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\/edit-relationships/i; const m = window.location.href.match(re); if (!m) return; Promise.all([ getSourceUrlsForRelease(m[1]).catch(() => ({})), probeTitleRemixes(m[1]) ]).then(([sources, remix]) => { const hasProvider = !!(sources.discogs || sources.tidal || sources.qobuz); const remixCount = remix?.count || 0; if (!hasProvider && remixCount === 0) return; insertDiscogsBar(sources.discogs, sources, { titlesRemixCount: remixCount }); }); }); })(); }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== group_therapy (@run-at document-end) ===================================== (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Group Therapy*","namespace":"https://github.com/majkinetor/musicbrainz-userscripts","version":"2026.7.9","description":"MusicBrainz relationship helpers: batch-delete rel groups from a right-click menu, page-wide hover highlight with a count tooltip, and copy/move credits between recordings & clone release credits. Chrome-light — context menus + hover, no toolbar.","author":"majkinetor","homepage":null,"homepageURL":null,"supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48ZyBmaWxsPSJub25lIiBzdHJva2U9IiM1YjZiN2EiIHN0cm9rZS13aWR0aD0iNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9Ijk0IiB5Mj0iNDIiLz48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48bGluZSB4MT0iOTQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48L2c+PGcgZmlsbD0iIzJlOWU1YiIgc3Ryb2tlPSIjMjU2ZjQzIiBzdHJva2Utd2lkdGg9IjQiPjxjaXJjbGUgY3g9IjM0IiBjeT0iNDIiIHI9IjE2Ii8+PGNpcmNsZSBjeD0iOTQiIGN5PSI0MiIgcj0iMTYiLz48Y2lyY2xlIGN4PSI2NCIgY3k9Ijk0IiByPSIxNiIvPjwvZz48L3N2Zz4="}) }) : { script: {"name":"Group Therapy*","namespace":"https://github.com/majkinetor/musicbrainz-userscripts","version":"2026.7.9","description":"MusicBrainz relationship helpers: batch-delete rel groups from a right-click menu, page-wide hover highlight with a count tooltip, and copy/move credits between recordings & clone release credits. Chrome-light — context menus + hover, no toolbar.","author":"majkinetor","homepage":null,"homepageURL":null,"supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48ZyBmaWxsPSJub25lIiBzdHJva2U9IiM1YjZiN2EiIHN0cm9rZS13aWR0aD0iNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9Ijk0IiB5Mj0iNDIiLz48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48bGluZSB4MT0iOTQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48L2c+PGcgZmlsbD0iIzJlOWU1YiIgc3Ryb2tlPSIjMjU2ZjQzIiBzdHJva2Utd2lkdGg9IjQiPjxjaXJjbGUgY3g9IjM0IiBjeT0iNDIiIHI9IjE2Ii8+PGNpcmNsZSBjeD0iOTQiIGN5PSI0MiIgcj0iMTYiLz48Y2lyY2xlIGN4PSI2NCIgY3k9Ijk0IiByPSIxNiIvPjwvZz48L3N2Zz4="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ /* eslint-disable no-undef */ (function () { 'use strict'; const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '2026.7.7'; // from the @version header at runtime const W = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); // ── tiny DOM helpers ────────────────────────────────────────────────────── const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }; const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; // MB renders each rel as …
      name … const REMOVE_SEL = 'button.icon.remove-item'; const ROLE_STOP = new Set(['odd', 'even', 'highlighted', 'selected', 'subrow', 'rel-add', 'rel-edit', 'rel-remove']); const pickRoleClass = tr => { if (!tr) return null; for (const c of tr.classList) if (!ROLE_STOP.has(c) && /^[a-z][a-z0-9-]*$/.test(c)) return c; return null; }; const pickRoleLabel = tr => { const l = tr && tr.querySelector('th.link-phrase label'); return l ? (l.textContent || '').replace(/:\s*$/, '').trim() : 'role'; }; // medium number a track row belongs to — the nearest preceding `tr.subh` ("1▼CD" → 1). Each medium // is its own , so we scan the table's rows in document order (not just siblings). Cached per row. const _medCache = new WeakMap(); const mediumNumberOf = tr => { if (!tr) return null; if (_medCache.has(tr)) return _medCache.get(tr); let med = null; const tbl = tr.closest && tr.closest('table'); if (tbl) { const all = [...tbl.querySelectorAll('tr')], idx = all.indexOf(tr); for (let i = idx - 1; i >= 0; i--) { if (all[i].classList && all[i].classList.contains('subh')) { const m = (all[i].textContent || '').match(/(\d+)/); med = m ? m[1] : null; break; } } } _medCache.set(tr, med); return med; }; // medium FORMAT label for a track row (from the nearest preceding tr.subh, e.g. "1▼CD" → "CD", "2▼12″ Vinyl" → "12″ Vinyl") const mediumFormatOf = tr => { const tbl = tr && tr.closest && tr.closest('table'); if (!tbl) return ''; const all = [...tbl.querySelectorAll('tr')], idx = all.indexOf(tr); for (let i = idx - 1; i >= 0; i--) { if (all[i].classList && all[i].classList.contains('subh')) return (all[i].textContent || '').replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim(); } return ''; }; // track position label from the row's position cell — handles vinyl/multi-disc numbers ("D5", "A1") // as well as plain "5". On multi-medium releases, a plain number is prefixed with the medium so // "1" on CD 1 vs CD 2 read as "1.1" / "2.1". Returns a string (or null for non-track rows). const posLabel = tr => { if (!tr || !tr.querySelector) return null; const c = tr.querySelector('td.pos'); let t = c ? (c.textContent || '').trim() : ''; if (!t) { const m = (tr.textContent || '').match(/^\s*(\d+)\b/); t = m ? m[1] : ''; } if (!t) return null; if (/^\d+$/.test(t) && document.querySelectorAll('tr.subh').length > 1) { const med = mediumNumberOf(tr); if (med) t = med + '.' + t; } return t; }; const targetHref = item => { const a = item && item.querySelector('a[href*="/artist/"], a[href*="/work/"], a[href*="/label/"], a[href*="/place/"], a[href*="/recording/"], a[href*="/url/"], a[href*="/event/"], a[href*="/instrument/"]'); return a ? a.getAttribute('href') : null; }; const targetLabel = item => { const a = item && item.querySelector('a[href*="/"]'); return a ? (a.textContent || '').trim() : 'target'; }; const rowHasClass = (tr, cls) => !!(tr && cls && tr.classList.contains(cls)); const itemHasHref = (item, href) => !!(href && item.querySelector(`a[href="${CSS.escape(href)}"]`)); // a rel's "role" for grouping = its link type PLUS its attributes — because e.g. every instrument rel // shares the one "instrument" link type and the specific instrument (drums, shakers, …) is an // attribute; matching on the CSS role class alone would lump drums + shakers + vocals together. const _roleKeyCache = new WeakMap(); function relRoleKey(item) { if (_roleKeyCache.has(item)) return _roleKeyCache.get(item); const rel = relFromNode(item); let key = null; if (rel) { let attrs = ''; try { if (rel.attributes) attrs = [...W.MB.tree.iterate(rel.attributes)].map(a => a.typeID).sort((x, y) => x - y).join(','); } catch (e) {} key = rel.linkTypeID + '#' + attrs; } _roleKeyCache.set(item, key); return key; } // collect peer relationship-items matching a scope relative to a seed × button function collect(seedBtn, scope) { const seedItem = seedBtn.closest('.relationship-item'); if (!seedItem) return []; const seedKey = relRoleKey(seedItem), href = targetHref(seedItem); return [...document.querySelectorAll('.relationship-item')].filter(item => { if (scope === 'role') return relRoleKey(item) === seedKey; if (scope === 'target') return itemHasHref(item, href); return relRoleKey(item) === seedKey && itemHasHref(item, href); // role+target }); } const removeButtons = items => items.map(it => it.querySelector(REMOVE_SEL)).filter(Boolean); // ── edit-note signature — stamped into MB's edit-note field ONLY when GT actually changes something ── const GT_HOMEPAGE = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/group_therapy/README.md'; function editNoteSig() { let s = {}; try { if (typeof GM_info !== 'undefined' && GM_info.script) s = GM_info.script; } catch (e) {} const name = (s.name || 'Group Therapy').split(/\s+[—–-]\s+/)[0].trim(); // drop the "— MusicBrainz relationship helper" suffix return `${name} by ${s.author || 'majkinetor'} v${s.version || VERSION} - ${s.homepageURL || s.homepage || GT_HOMEPAGE}`; } // Stamp our signature into MB's edit-note field and, under it, an accumulating list of what GT did // ("Copied credits from track 4 to tracks 1–5", "Removed guitar (14)"). Any note that preceded ours // (another script's) is preserved ahead of our block. Idempotent per identical action line. function stampEditNote(action) { const ta = document.querySelector('textarea.edit-note, #edit-note-text'); if (!ta) return; const sig = editNoteSig(), cur = ta.value || ''; let pre = cur.replace(/\s+$/, ''), ourLines = []; const idx = cur.indexOf(sig); if (idx >= 0) { pre = cur.slice(0, idx).replace(/\s+$/, ''); ourLines = cur.slice(idx + sig.length).split('\n').map(l => l.trim()).filter(Boolean); } if (action && !ourLines.includes(action)) ourLines.push(action); const block = ourLines.length ? `${sig}\n\n${ourLines.join('\n')}` : sig; const next = pre ? `${pre}\n\n${block}` : block; try { const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ta), 'value').set; set.call(ta, next); ta.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} } function markUsed(action) { try { stampEditNote(action); } catch (e) {} } // ── subtle context menu ─────────────────────────────────────────────────── let menuEl = null; function closeMenu() { if (menuEl) { menuEl.remove(); menuEl = null; document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('keydown', onKey, true); } } function onDocDown(e) { if (menuEl && !menuEl.contains(e.target)) closeMenu(); } function onKey(e) { if (e.key === 'Escape') closeMenu(); } function openMenu(x, y, items) { // items: [{label, sub, danger, run} | 'sep'] closeMenu(); menuEl = el('div', 'gt-menu'); for (const it of items) { if (it === 'sep') { menuEl.appendChild(el('div', 'gt-sep')); continue; } if (it.header != null) { const h = el('div', 'gt-hdr', it.header); it._set = v => { try { h.textContent = v; } catch (e) {} }; menuEl.appendChild(h); continue; } // #377 live header if (it.note != null) { menuEl.appendChild(el('div', 'gt-note', it.note)); continue; } if (it.checklist) { // per-credit toggles for copy/move — clicking a box toggles, doesn't close the menu const box = el('div', 'gt-ck-list'); it.checklist.forEach(entry => { const lab = el('label', 'gt-ck'); const cb = el('input', 'gt-ck-cb'); cb.type = 'checkbox'; cb.checked = entry.checked !== false; entry.cb = cb; cb.addEventListener('change', () => { if (it.onToggle) it.onToggle(); }); // whole row toggles (it's a

      Bulk / Export

      Paste one ISRC per line, in track order (blank line = skip a track). Lines like 3=USABC1234567 or USABC1234567 | 1.3 target a specific track number. Or paste JSON exported below.

      Edit note

      Attached to every ISRC add/remove you submit. Auto-filled with the script name + counts; edit freely (your text is kept until you Reset).

      Activity log

      
            
      #Track Linked New ISRCAdd
      `; document.body.appendChild(overlay); document.body.appendChild(modal); buildSxPanel(); tbody = modal.querySelector('#ii-tbody'); summaryEl = modal.querySelector('#ii-summary'); progEl = modal.querySelector('#ii-prog'); submitBtn = modal.querySelector('#ii-submit'); // scope tabs (#301): the two operations on a record modal.querySelectorAll('.ii-tab').forEach(t => t.addEventListener('click', () => setScope(t.dataset.scope))); // #341: right-click a LINKED link toggles its "ended" flag — Ctrl = whole track, Alt = that // provider everywhere; right-clicking an already-ended (faded) link reverts it. MIDDLE-click // (with the same modifiers) does the actual removal (#301). Left-click just opens the link. tbody.addEventListener('contextmenu', e => { const a = e.target.closest('.ii-tl-linked .ii-tl.linked'); if (!a) return; e.preventDefault(); const tr = a.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx; if (e.ctrlKey || e.metaKey) TrackLinks.endTrack(idx, a.dataset.code); else if (e.altKey) TrackLinks.endProvider(a.dataset.code, idx); else TrackLinks.endOne(idx, a.dataset.code); }); tbody.addEventListener('auxclick', e => { if (e.button !== 1) return; // middle button const a = e.target.closest('.ii-tl-linked .ii-tl.linked'); if (!a) return; e.preventDefault(); const tr = a.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx; if (e.ctrlKey || e.metaKey) TrackLinks.removeTrack(idx); else if (e.altKey) TrackLinks.removeProvider(a.dataset.code); else TrackLinks.removeOne(idx, a.dataset.code); }); // stop the browser opening the link in a new tab on middle-click (fires on mousedown in some browsers) tbody.addEventListener('mousedown', e => { if (e.button === 1 && e.target.closest('.ii-tl-linked .ii-tl.linked')) e.preventDefault(); }); // Esc closes the modal (no ✕ in the header) — but first let an open pane close, // and ignore it while typing in a field. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modal.classList.contains('open')) return; const openPane = modal.querySelector('.ii-pane.open'); if (openPane) { openPane.classList.remove('open'); return; } const a = document.activeElement; if (a && /^(INPUT|TEXTAREA)$/.test(a.tagName)) return; closeModal(); }); modal.querySelector('#ii-setup-toggle').addEventListener('click', () => togglePane('ii-setup-pane')); modal.querySelector('#ii-bulk-toggle').addEventListener('click', () => togglePane('ii-bulk-pane')); modal.querySelector('#ii-clear-pending').addEventListener('click', clearPending); modal.querySelector('#ii-links-btn').addEventListener('click', () => TrackLinks.resolve()); // #219: resolve candidates modal.querySelector('#ii-addlinks-btn').addEventListener('click', () => TrackLinks.addAll()); // #219: batch-add (B) modal.querySelector('#ii-sx-all').addEventListener('click', runSxAll); // bulk SoundExchange — unchanged (#181) // Track-ISRC-provider menu (#181): the per-track [SX] buttons carry a ▾ that // opens this shared menu of providers available for THIS release; picking one // re-skins EVERY per-track button (global, not persisted). The bulk // SoundExchange button above is intentionally left alone. document.addEventListener('mousedown', e => { const menu = modal.querySelector('#ii-prov-menu'); if (!menu || !menu.classList.contains('open')) return; if (e.target.closest('#ii-prov-menu') || e.target.closest('.ii-sxprov')) return; closeProvMenu(); }); // log pane — opened from the config window's "Log" link (#301) Log.setPane(modal.querySelector('#ii-log-out')); modal.querySelector('#ii-log-link').addEventListener('click', () => togglePane('ii-log-pane')); modal.querySelector('#ii-log-copy').addEventListener('click', () => { // Wrap in a collapsed
      + fenced block so it pastes into a GitHub // issue/comment as a tidy, foldable log rather than a wall of text. try { navigator.clipboard.writeText('
      ISRC Scout Log\n\n```\n' + Log.text().trim() + '\n```\n\n
      \n'); toast('Log copied'); } catch (e) { toast('Copy failed', 'err'); } }); modal.querySelector('#ii-log-clear').addEventListener('click', () => Log.clear()); // SX exact toggles + their collapsible container (collapsed state persisted) const sxGroup = modal.querySelector('#ii-sx-group'); const exactToggle = modal.querySelector('#ii-exact-toggle'); // reflect "any exact option active" on the toggle, so it's visible even when collapsed const refreshExactToggle = () => { const anyOn = sxExact.title || sxExact.artist || sxExact.release; exactToggle.classList.toggle('on', !!anyOn); exactToggle.title = anyOn ? 'Exact-match active: ' + ['title', 'artist', 'release'].filter(k => sxExact[k]).join(', ') : 'Exact-match options'; }; const applyExactCollapsed = (collapsed) => { sxGroup.classList.toggle('collapsed', collapsed); exactToggle.setAttribute('aria-expanded', String(!collapsed)); }; applyExactCollapsed(store.get('sx_exact_collapsed', true)); // collapsed by default to keep the toolbar compact exactToggle.addEventListener('click', () => { const collapsed = !sxGroup.classList.contains('collapsed'); applyExactCollapsed(collapsed); store.set('sx_exact_collapsed', collapsed); }); [['ii-ex-title', 'title'], ['ii-ex-artist', 'artist'], ['ii-ex-release', 'release']].forEach(([id, key]) => { const cb = modal.querySelector('#' + id); cb.checked = sxExact[key]; cb.addEventListener('change', () => { sxExact[key] = cb.checked; saveSxExact(); refreshExactToggle(); Log.info('SX exact ' + key + ' = ' + cb.checked); }); }); refreshExactToggle(); // Import-source buttons: independently show icons and/or text labels // (persisted; default icons-only). Never both off — re-check the last one. const tools = modal.querySelector('#ii-tools'); const cbIcons = modal.querySelector('#ii-show-icons'); const cbText = modal.querySelector('#ii-show-text'); const applySrcDisp = () => { tools.classList.toggle('ii-show-icons', cbIcons.checked); tools.classList.toggle('ii-show-text', cbText.checked); }; cbIcons.checked = store.get('src_show_icons', true); cbText.checked = store.get('src_show_text', false); applySrcDisp(); const onSrcDispChange = changed => { if (!cbIcons.checked && !cbText.checked) { changed.checked = true; } // keep at least one visible store.set('src_show_icons', cbIcons.checked); store.set('src_show_text', cbText.checked); applySrcDisp(); Log.info('Source buttons: icons=' + cbIcons.checked + ' text=' + cbText.checked); }; cbIcons.addEventListener('change', () => onSrcDispChange(cbIcons)); cbText.addEventListener('change', () => onSrcDispChange(cbText)); // #302: pull provider links from the whole release group (opt-in) const cbRg = modal.querySelector('#ii-rg-providers'); if (cbRg) { cbRg.checked = rgProvidersEnabled(); cbRg.addEventListener('change', () => { store.set('rg_providers', cbRg.checked); Log.info('Release-group providers: ' + (cbRg.checked ? 'on' : 'off') + (cbRg.checked ? ' — reopen / reload to rescan' : '')); }); } // #314: respect / ignore Platform Check's barcode-mismatch link confidence const cbPc = modal.querySelector('#ii-ignore-pc'); if (cbPc) { cbPc.checked = ignorePcConfidence(); cbPc.addEventListener('change', () => { store.set('ignore_pc_confidence', cbPc.checked); Log.info('Ignore Platform Check link confidence: ' + (cbPc.checked ? 'on — barcode/format-mismatched PC links will be used' : 'off — PC-withheld links are skipped')); }); } modal.querySelector('#ii-dz-all').addEventListener('click', runDeezer); modal.querySelector('#ii-sp-all').addEventListener('click', runSpotify); modal.querySelector('#ii-bp-all').addEventListener('click', runBeatport); modal.querySelector('#ii-td-all').addEventListener('click', runTidal); modal.querySelector('#ii-vo-all').addEventListener('click', runVolumo); modal.querySelector('#ii-hd-all').addEventListener('click', runHDtracks); // Unified "paste a URL" control (#180) — apollo-style unroll. Click the + // to reveal the input; paste any streaming album URL; on Enter the platform // is auto-detected and imported. Replaces the per-provider ▾ submenus. const urlWrap = modal.querySelector('#ii-urladd'); const urlBtn = modal.querySelector('#ii-url-btn'); const urlInput = modal.querySelector('#ii-url-input'); const openUrlAdd = () => { urlWrap.classList.add('open'); _setTimeout(() => urlInput.focus(), 0); }; const closeUrlAdd = () => { urlWrap.classList.remove('open'); urlInput.value = ''; reflectDetectedSource(''); }; urlBtn.addEventListener('click', () => urlWrap.classList.contains('open') ? closeUrlAdd() : openUrlAdd()); urlInput.addEventListener('input', () => reflectDetectedSource(urlInput.value)); urlInput.addEventListener('keydown', e => { if (e.key === 'Enter') { submitUrlAdd(urlInput.value); closeUrlAdd(); } else if (e.key === 'Escape') closeUrlAdd(); }); // Auto-import on paste — no Enter needed when a recognized album URL is pasted // (#300). Unrecognized text pastes normally so it can still be edited + Entered. urlInput.addEventListener('paste', e => { const v = ((e.clipboardData && e.clipboardData.getData('text')) || '').trim(); if (v && detectSource(v)) { e.preventDefault(); reflectDetectedSource(v); submitUrlAdd(v); closeUrlAdd(); } }); // collapse on click-outside (only when empty, so a half-typed URL isn't lost) document.addEventListener('mousedown', e => { if (!urlWrap.classList.contains('open')) return; if (urlWrap.contains(e.target)) return; if (!urlInput.value.trim()) closeUrlAdd(); }); submitBtn.addEventListener('click', doSubmit); // delete-existing wiring (checkboxes are delegated) modal.querySelector('#ii-delete').addEventListener('click', doDelete); tbody.addEventListener('change', e => { if (!e.target.classList.contains('ii-ex-del')) return; e.target.closest('.ii-ex-item').classList.toggle('del', e.target.checked); refreshDeleteBtn(); }); modal.querySelector('#ii-ex-all').addEventListener('change', e => { const on = e.target.checked; tbody.querySelectorAll('.ii-ex-del').forEach(cb => { cb.checked = on; cb.closest('.ii-ex-item').classList.toggle('del', on); }); refreshDeleteBtn(); }); // edit-note pane wiring modal.querySelector('#ii-note-toggle').addEventListener('click', () => { ensureNote(); togglePane('ii-note-pane'); }); modal.querySelector('#ii-note-reset').addEventListener('click', () => { noteEdited = false; ensureNote(true); }); modal.querySelector('#ii-note-text').addEventListener('input', () => { noteEdited = true; }); // setup pane wiring modal.querySelector('#ii-authorize').addEventListener('click', onAuthorize); modal.querySelector('#ii-signout').addEventListener('click', () => { Auth.signOut(); refreshAuthState(); toast('Signed out'); }); const codeInput = modal.querySelector('#ii-oauth-code'); const tryCode = () => { const c = codeInput.value.trim(); if (c) { codeInput.value = ''; exchangeAndFinish(c, 'pasted'); } }; codeInput.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); tryCode(); } }); codeInput.addEventListener('paste', () => _setTimeout(tryCode, 50)); // bulk pane wiring modal.querySelector('#ii-bulk-apply').addEventListener('click', () => applyBulk(false)); modal.querySelector('#ii-bulk-apply-all').addEventListener('click', () => applyBulk(true)); modal.querySelector('#ii-export-text').addEventListener('click', exportText); modal.querySelector('#ii-export-json').addEventListener('click', exportJson); // #301: link export (Links scope) modal.querySelector('#ii-link-export-csv').addEventListener('click', () => exportLinks('csv')); modal.querySelector('#ii-link-export-json').addEventListener('click', () => exportLinks('json')); modal.querySelector('#ii-link-copy-urls').addEventListener('click', () => exportLinks('urls')); // each sub-pane closes via its own ✕ (next to the title) modal.querySelectorAll('.ii-pane-x').forEach(b => b.addEventListener('click', () => b.closest('.ii-pane').classList.remove('open'))); } function togglePane(id) { modal.querySelectorAll('.ii-pane').forEach(p => { if (p.id === id) p.classList.toggle('open'); else p.classList.remove('open'); }); } // #301: the active scope (ISRCs / Links) — drives column labels, toolbar, footer // (via .ii-only-isrc / .ii-only-links visibility) and the table column widths. let scope = store.get('scope', 'isrc') === 'links' ? 'links' : 'isrc'; function setScope(s) { scope = (s === 'links') ? 'links' : 'isrc'; store.set('scope', scope); modal.classList.toggle('ii-scope-isrc', scope === 'isrc'); modal.classList.toggle('ii-scope-links', scope === 'links'); modal.querySelectorAll('.ii-tab').forEach(t => t.classList.toggle('on', t.dataset.scope === scope)); const cg = modal.querySelector('#ii-colgroup'); if (cg) cg.innerHTML = (scope === 'links') ? '' : ''; } // Some tablets render MusicBrainz's desktop layout wider than the browser's // layout viewport, so the page overflows and the browser zooms-to-fit. A // position:fixed modal is then sized/anchored to the (narrow) layout viewport // and lands in a corner at ~64% width. Pin the modal + overlay to the VISUAL // viewport instead — it reflects what's actually on screen (zoom scale + // offset). CSS handles the normal case; this only engages on narrow viewports // (where the mobile layout is active) and is a no-op without VisualViewport. let _vvSync = null; function pinModalToViewport() { const vv = window.visualViewport; if (!vv || !window.matchMedia('(max-width: 700px)').matches) { clearModalViewportPin(); return; } const w = Math.min(vv.width * 0.96, 1080), h = vv.height * 0.96; const set = (el, props) => { for (const k in props) el.style.setProperty(k, props[k], 'important'); }; set(overlay, { left: vv.offsetLeft + 'px', top: vv.offsetTop + 'px', width: vv.width + 'px', height: vv.height + 'px' }); set(modal, { left: (vv.offsetLeft + (vv.width - w) / 2) + 'px', top: (vv.offsetTop + (vv.height - h) / 2) + 'px', width: w + 'px', 'max-width': 'none', height: h + 'px', 'max-height': 'none', transform: 'none', }); } function clearModalViewportPin() { ['left', 'top', 'width', 'max-width', 'height', 'max-height', 'transform'].forEach(p => modal.style.removeProperty(p)); ['left', 'top', 'width', 'height'].forEach(p => overlay.style.removeProperty(p)); } function openModal() { buildModal(); overlay.classList.add('open'); modal.classList.add('open'); pinModalToViewport(); if (window.visualViewport && !_vvSync) { _vvSync = () => pinModalToViewport(); window.visualViewport.addEventListener('resize', _vvSync); window.visualViewport.addEventListener('scroll', _vvSync); } refreshAuthState(); setScope(scope); // #301: apply the active-scope classes + column widths before render if (!RELEASE) { tbody.innerHTML = 'Loading release…'; fetchRelease() .then(renderTracks) // existing track links ride along on the release fetch (recording-level-rels) .catch(err => { tbody.innerHTML = 'Failed to load release: ' + esc(err.message) + ''; }); } else { renderTracks(); } } function closeModal() { abortSxWork('window closed'); // don't leave batched SX requests running in the background (#127) if (_vvSync && window.visualViewport) { window.visualViewport.removeEventListener('resize', _vvSync); window.visualViewport.removeEventListener('scroll', _vvSync); _vvSync = null; } clearModalViewportPin(); overlay.classList.remove('open'); modal.classList.remove('open'); } function refreshAuthState() { const el = modal.querySelector('#ii-auth-state'); const pane = modal.querySelector('#ii-setup-pane'); const authed = Auth.isAuthorized(); // when authorized show Sign out; when not, show the code field in its place const code = modal.querySelector('#ii-oauth-code'), out = modal.querySelector('#ii-signout'); if (code) code.style.display = authed ? 'none' : ''; if (out) out.style.display = authed ? '' : 'none'; if (authed && code) code.value = ''; if (authed) { el.className = 'ii-authstate ok'; el.textContent = '✓ Authorized — submit is ready.'; } else { el.className = 'ii-authstate no'; el.textContent = '• Not authorized yet. Click Authorize (one time).'; pane.classList.add('open'); // nudge first-time users } } /* ═══════════════════════════════════════════════════════════════════════ TRACK LINKS (#219 PoC) — per-track provider link icons under the artist line. Two streaming providers that expose a stable, public per-track URL resolvable by ISRC (and already wired in Scout): Deezer and Tidal. • faded monochrome → the recording already links to that provider in MB (read once from a recording browse with url-rels) • full brand colour → the track resolves on the provider by ISRC but isn't linked yet — an "add" candidate The actual background add is the next spike: WS2 only submits ISRCs, so adding URL relationships needs MB's website edit API, not submitIsrcs(). ═══════════════════════════════════════════════════════════════════════ */ const TrackLinks = (function () { // linkTypeID: recording↔url relationship type (confirmed live against MB's // editor): 268 = "free streaming" (Deezer/Spotify free tier), 979 = // "streaming" / streaming page (Tidal, subscription). const PROV = [ { code: 'dz', name: 'Deezer', color: _PROV_COLOR.deezer, icon: SRC_ICON.dz, linkTypeID: 268, conc: 3, gap: 60, // #307: a few in flight; a quota hit RECOVERS below (back off + retry) instead of false-negativing test: u => /(?:^|\.)deezer\.com\/(?:[a-z]{2}\/)?track\/\d+/i.test(u), async resolve(isrc) { // Deezer signals a rate limit as HTTP 429 or an error body with code 4 (quota) / 700 (busy); // a genuine miss is code 800 ("no data"). Retry only the rate-limit cases, so throttling // never looks like "not found". #307 for (let attempt = 0; ; attempt++) { const r = await gmGet('https://api.deezer.com/track/isrc:' + encodeURIComponent(isrc), { 'Accept': 'application/json' }); let j = null; try { j = JSON.parse(r.responseText || 'null'); } catch (e) {} const throttled = r.status === 429 || (j && j.error && (j.error.code === 4 || j.error.code === 700)); if (throttled && attempt < 4) { await sleep(Math.min(700 * Math.pow(2, attempt), 6000)); continue; } if (r.status !== 200 || !j || j.error || !j.id) return null; // Don't offer a DEAD track: Deezer keeps the ISRC mapping after pulling the // audio, returning a track that streams in zero countries (readable:false, // available_countries:[]). That's a broken link, so treat it as not found. if (j.readable === false && Array.isArray(j.available_countries) && j.available_countries.length === 0) return null; return j.link || ('https://www.deezer.com/track/' + j.id); } } }, { code: 'td', name: 'Tidal', color: _PROV_COLOR.tidal, icon: SRC_ICON.td, linkTypeID: 979, conc: 3, gap: 0, // #307: tidalGet() self-recovers from 429 (backoff+retry), so a burst is safe — it never false-negatives test: u => /tidal\.com\/(?:browse\/)?track\/\d+/i.test(u), async resolve(isrc) { const token = await tidalToken(); const headers = { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.api+json' }; const r = await tidalGet(TIDAL.api + '/tracks?countryCode=' + TIDAL.country + '&filter%5Bisrc%5D=' + encodeURIComponent(isrc), headers); if (r.status !== 200) return null; let j; try { j = JSON.parse(r.responseText || '{}'); } catch (e) { return null; } const t = (j.data || [])[0]; return t && t.id ? ('https://tidal.com/browse/track/' + t.id) : null; } }, // Bandcamp (#300): no ISRC — resolve by ALBUM. The release's Bandcamp album // page lists every track's URL in order, so we match by position (with a // title sanity-check). Only offered when the release has a Bandcamp album link. { code: 'bc', name: 'Bandcamp', color: _PROV_COLOR.bandcamp, icon: SRC_ICON.bc, linkTypeID: 268, album: true, urlKey: 'bandcampUrl', conc: 99, gap: 0, // #307: one album fetch (cached), the rest is local test: u => /\.bandcamp\.com\/track\//i.test(u), resolve: (isrc, t, idx) => bcResolve(t, idx) }, // Apple Music (#like Bandcamp): the album page's ld+json lists every track URL, // matched by position. Subscription streaming → linkType 979. { code: 'am', name: 'Apple Music', color: _PROV_COLOR.apple, icon: SRC_ICON.am, linkTypeID: 979, album: true, urlKey: 'appleUrl', conc: 99, gap: 0, // #307: one album fetch (cached), the rest is local test: u => /music\.apple\.com\/[a-z]{2}\/(?:song\/|album\/[^"\s]*[?&]i=)/i.test(u), resolve: (isrc, t, idx) => amResolve(t, idx) }, // Volumo (#387): a download store — recording↔url "purchase for download" (254). No global by-ISRC // endpoint, but the release's Volumo album JSON (fetched once, cached) carries every track's ISRC and // id, so we match by ISRC and hand back the per-track URL. Only offered when the release has a Volumo link. { code: 'vo', name: 'Volumo', color: _PROV_COLOR.volumo, icon: SRC_ICON.vo, linkTypeID: 254, album: true, urlKey: 'volumoId', conc: 99, gap: 0, test: u => /(?:^|[./])volumo\.com\/track\//i.test(u), // note the char class incl. '/': the URL is https://volumo.com/… (no www) resolve: (isrc) => provAlbumUrl('volumo', isrc) }, // Beatport (#387): same album-scoped, ISRC-matched shape as Volumo. Its tracklist (with track // id+slug) comes from the API when logged in via Platform Check, else from the Cloudflare tab harvest. { code: 'bp', name: 'Beatport', color: _PROV_COLOR.beatport, icon: SRC_ICON.bp, linkTypeID: 254, album: true, urlKey: 'beatportId', conc: 99, gap: 0, test: u => /(?:^|[./])beatport\.com\/track\//i.test(u), resolve: (isrc) => provAlbumUrl('beatport', isrc) }, ]; let resolving = false; // Existing url rels come free with fetchRelease (inc=recording-level-rels), so // we read what's already linked straight off the track — no extra request. const linkedUrl = (t, p) => (t.recUrls || []).find(u => p.test(u)) || null; // Bandcamp album page (fetched once): ordered [{title, url}] from data-tralbum. let _bcList = null, _bcPromise = null; async function bcAlbum() { if (_bcList) return _bcList; if (_bcPromise) return _bcPromise; const url = RELEASE && RELEASE.bandcampUrl; if (!url) { _bcList = []; return _bcList; } _bcPromise = (async () => { const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const m = (r.responseText || '').match(/data-tralbum="([^"]+)"/); if (!m) return []; const json = m[1].replace(/"/g, '"').replace(/�?39;/g, "'").replace(/&/g, '&'); let j; try { j = JSON.parse(json); } catch (e) { return []; } let origin = ''; try { origin = new URL(j.url).origin; } catch (e) { origin = url.replace(/\/album\/.*$/, ''); } return (j.trackinfo || []).map(ti => ({ title: ti.title || '', url: ti.title_link ? origin + ti.title_link : '' })).filter(ti => ti.url); })(); _bcList = await _bcPromise.catch(() => []); _bcPromise = null; return _bcList; } const _nrm = s => [...(s || '').toLowerCase().normalize('NFD')].filter(c => { const x = c.charCodeAt(0); return x < 0x300 || x > 0x36f; }).join('').replace(/[^a-z0-9]+/g, ' ').trim(); async function bcResolve(t, idx) { const list = await bcAlbum(); const e = list[idx]; if (!e) return null; const a = _nrm(e.title), b = _nrm(t.title); // position match must agree on title (else the editions are out of sync) — don't add a likely-wrong link return (a && b && (a === b || a.indexOf(b) >= 0 || b.indexOf(a) >= 0)) ? e.url : null; } // Apple Music album page (fetched once): ordered [{title, url}] from its ld+json. let _amList = null, _amPromise = null; async function amAlbum() { if (_amList) return _amList; if (_amPromise) return _amPromise; const url = RELEASE && RELEASE.appleUrl; if (!url) { _amList = []; return _amList; } _amPromise = (async () => { const r = await gmGet(url, { 'Accept': 'text/html' }); if (r.status !== 200) return []; const m = (r.responseText || '').match(/]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i); if (!m) return []; let j; try { j = JSON.parse(m[1]); } catch (e) { return []; } return (j.tracks || []).map(t => ({ title: t.name || '', url: t.url || '' })).filter(t => t.url); })(); _amList = await _amPromise.catch(() => []); _amPromise = null; return _amList; } async function amResolve(t, idx) { const list = await amAlbum(); const e = list[idx]; if (!e) return null; const a = _nrm(e.title), b = _nrm(t.title); return (a && b && (a === b || a.indexOf(b) >= 0 || b.indexOf(a) >= 0)) ? e.url : null; } // #387 album-scoped by-ISRC providers (Volumo/Beatport): fetch the release's album tracklist once // (cached in ensureProvAlbum), match the track by ISRC — never position — and return its per-track URL. async function provAlbumUrl(key, isrc) { if (!isrc) return null; const entries = await ensureProvAlbum(key); const e = entries.find(s => normalizeIsrc(s.isrc) === normalizeIsrc(isrc)); return (e && e.url) ? e.url : null; } // Which providers to show for a track: by-ISRC ones always; album ones (Bandcamp) // only when the release has that album link (resolvable) or the recording is // already linked to it. function providersFor(t) { return PROV.filter(p => !p.album || linkedUrl(t, p) || (RELEASE && RELEASE[p.urlKey])); } // #389 Show EVERY linked provider, even ones ISRC Scout can't add per-track. Recognise the common // streaming/store hosts for a nice name/colour (Spotify even gets its glyph); anything else falls back // to its hostname with a generic globe. These are read-only (open in a tab) — no add/remove actions. const _GLOBE = ''; // #389 Qobuz uses the same brand-blue roundel as Platform Check (self-coloured, so the chip colour is moot) const _QOBUZ = ''; const KNOWN_LINK = [ { test: u => /open\.spotify\.com\//i.test(u), name: 'Spotify', color: _PROV_COLOR.spotify, icon: SRC_ICON.sp }, { test: u => /(?:^|[./])qobuz\.com\//i.test(u), name: 'Qobuz', color: '#0070ef', icon: _QOBUZ }, { test: u => /music\.youtube\.com\/watch|youtu\.be\/|youtube\.com\/watch/i.test(u), name: 'YouTube', color: '#ff0000', icon: _GLOBE }, { test: u => /soundcloud\.com\//i.test(u), name: 'SoundCloud', color: '#ff5500', icon: _GLOBE }, { test: u => /music\.amazon\./i.test(u), name: 'Amazon Music', color: '#00a8e1', icon: _GLOBE }, { test: u => /music\.apple\.com\//i.test(u), name: 'Apple Music', color: _PROV_COLOR.apple, icon: SRC_ICON.am }, ]; // recUrls this track carries that no PROV provider already renders → [{url,name,color,icon}] (deduped). function otherLinked(t) { const out = [], seen = new Set(); (t.recUrls || []).forEach(u => { if (!u || seen.has(u) || PROV.some(p => p.test(u))) return; // PROV ones are shown already seen.add(u); const k = KNOWN_LINK.find(x => x.test(u)); if (k) { out.push({ url: u, name: k.name, color: k.color, icon: k.icon }); return; } let host = ''; try { host = new URL(u).hostname.replace(/^www\./, ''); } catch (e) {} if (host) out.push({ url: u, name: host, color: '#8a8f98', icon: _GLOBE }); }); return out; } // #301: two columns. LINKED = monochrome icons for providers already linked on // MB; ADD = a hidden candidate slot per not-yet-linked provider (Find links fills // them, and an added one moves over to the LINKED column). function linkedIcon(p, url, ended) { const verb = ended ? 'un-ends' : 'marks ended'; return '' + p.icon + ''; } function linkedHtml(t) { const cells = providersFor(t).map(p => { const ex = t.recId ? linkedUrl(t, p) : null; return ex ? linkedIcon(p, ex, !!(t.endedUrls && t.endedUrls.has(ex))) : ''; }).filter(Boolean).join(''); // #389 append every OTHER linked provider (read-only), so the column shows all links, not just addable ones const others = t.recId ? otherLinked(t).map(o => '' + o.icon + '').join('') : ''; return '
      ' + cells + others + '
      '; } function addHtml(t) { const cells = providersFor(t).map(p => (!t.recId || linkedUrl(t, p)) ? '' : '' + p.icon + '' ).filter(Boolean).join(''); return '
      ' + cells + '
      '; } const addBox = idx => { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-tl-add') : null; }; const linkedBox = idx => { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-tl-linked') : null; }; function cell(idx, code) { const c = addBox(idx); return c ? c.querySelector('.ii-tl[data-code="' + code + '"]') : null; } // Replace a row's ADD-column slot with a coloured "add" candidate: a link to the // resolved provider URL. Left-click opens the track; RIGHT-click adds the // relationship in the background. function makeNew(idx, p, url) { const el = cell(idx, p.code); if (!el) return; const a = document.createElement('a'); a.className = 'ii-tl new'; a.dataset.code = p.code; a.href = url; a.target = '_blank'; a.rel = 'noopener'; a.style.color = p.color; // #302: mark candidates resolved via an album link pulled from a sibling release const rg = p.urlKey && RELEASE.rgFrom && RELEASE.rgFrom[p.urlKey]; if (rg) a.classList.add('ii-rg'); a.title = p.name + ' — left-click opens · right-click adds this · Ctrl+right-click adds all links on this track · Alt+right-click adds ' + p.name + ' on every track' + (rg ? ' · ' + p.name + ' album link from another release in this group' : ''); a.innerHTML = p.icon; // all add actions on right-click (left-click just opens the track); modifiers // scope it: Ctrl/⌘ = whole track, Alt = this provider across every track. a.addEventListener('contextmenu', e => { e.preventDefault(); if (e.ctrlKey || e.metaKey) addTrack(idx); else if (e.altKey) addProvider(p.code); else addOne(idx, p, url); }); el.replaceWith(a); } // On a successful add: drop the candidate from the ADD column and add a // monochrome icon to the LINKED column; record the URL on the track. function markLinked(idx, p, url) { const t = RELEASE.tracks[idx]; if (t && !(t.recUrls || []).includes(url)) (t.recUrls = t.recUrls || []).push(url); const el = cell(idx, p.code); if (el) el.remove(); const lb = linkedBox(idx); if (lb && !lb.querySelector('.ii-tl[data-code="' + p.code + '"]')) { const tmp = document.createElement('div'); tmp.innerHTML = linkedIcon(p, url); lb.appendChild(tmp.firstChild); } } // Same shape as ISRC Scout's standard edit note (noteHeader + Release line + // an "Added …" summary), so link edits read like the script's ISRC edits. function noteFor(provs) { const counts = {}; provs.forEach(p => { counts[p.name] = (counts[p.name] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(n => n + ' (' + counts[n] + ')').join(', '); return [ noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Added ' + provs.length + ' streaming link' + (provs.length === 1 ? '' : 's') + (breakdown ? ': ' + breakdown : ''), ].join('\n'); } // Add recording→url relationships in the background via MB's internal edit API // (same endpoint the native relationship editor uses). Authenticated by the // logged-in session cookie (same-origin) — no OAuth, no CSRF. One POST carries // the whole batch (auto-applied for auto-editors, else queued). items: // [{ recGid, url, linkTypeID }]. async function submitRels(items, note) { const body = { edits: items.map(it => ({ edit_type: 90, linkTypeID: it.linkTypeID, attributes: [], entities: [{ entityType: 'recording', gid: it.recGid }, { entityType: 'url', name: it.url }], })), editNote: note || '', makeVotable: 0, }; const r = await fetch(MB_ROOT + '/ws/js/edit/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await r.text().catch(() => ''); let j = null; try { j = JSON.parse(text); } catch (e) {} if (!r.ok || (j && j.error)) { const m = (j && (j.error.message || j.error)) || ('HTTP ' + r.status); throw new Error((typeof m === 'string' ? m : JSON.stringify(m)).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200)); } return j; } // Collect resolved "new" candidates matching a selector into add items. function newItems(selector) { const items = []; modal.querySelectorAll(selector).forEach(a => { const p = PROV.find(x => x.code === a.dataset.code); const tr = a.closest('tr[data-idx]'); if (p && tr) items.push({ idx: +tr.dataset.idx, p, url: a.getAttribute('href') }); }); return items; } // Submit a batch of add items in one POST; spin → linked, or restore on failure. async function addBatch(items, confirmMsg) { if (!items.length) return; if (confirmMsg && !confirm(confirmMsg)) return; items.forEach(it => { const el = cell(it.idx, it.p.code); if (el) { el.className = 'ii-tl spin'; el.dataset.code = it.p.code; } }); try { await submitRels(items.map(it => ({ recGid: RELEASE.tracks[it.idx].recId, url: it.url, linkTypeID: it.p.linkTypeID })), noteFor(items.map(it => it.p))); items.forEach(it => markLinked(it.idx, it.p, it.url)); Log.info('Linked ' + items.length + ' track-link' + (items.length === 1 ? '' : 's') + ' on MusicBrainz'); } catch (e) { Log.err('Add links failed: ' + errText(e)); items.forEach(it => makeNew(it.idx, it.p, it.url)); // restore the add affordances } updateAddBtn(); } // right-click a candidate → add just that one const addOne = (idx, p, url) => addBatch([{ idx, p, url }]); // ctrl-click → add every resolved link for THIS track const addTrack = idx => addBatch(newItems('tr[data-idx="' + idx + '"] .ii-tl-add .ii-tl.new')); // alt-click → add every resolved link for THIS provider across all tracks const addProvider = code => addBatch(newItems('.ii-tl-add .ii-tl.new[data-code="' + code + '"]')); // footer button → add everything resolved (no confirm — auto-applied edits) async function addAll() { const items = newItems('.ii-tl-add .ii-tl.new'); if (!items.length) return; const btn = modal.querySelector('#ii-addlinks-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; } await addBatch(items); if (btn) btn.disabled = false; } // ── DELETE (symmetric to add). Removing a relationship needs its internal id, // which WS2 doesn't expose — fetch it from /ws/js/entity (the rel editor's API) // and submit an EDIT_RELATIONSHIP_DELETE (92 — 91 is EDIT, a no-op for removal). ── const _relCache = {}; async function recUrlRels(recGid) { if (_relCache[recGid]) return _relCache[recGid]; const r = await fetch(MB_ROOT + '/ws/js/entity/' + recGid + '?inc=rels', { credentials: 'include', headers: { Accept: 'application/json' } }); if (!r.ok) throw new Error('rels ' + r.status); const j = await r.json(); return (_relCache[recGid] = (j.relationships || []).filter(x => x.target_type === 'url')); } function delNote(provs) { const counts = {}; provs.forEach(p => { counts[p.name] = (counts[p.name] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(n => n + ' (' + counts[n] + ')').join(', '); return [noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, 'Removed ' + provs.length + ' streaming link' + (provs.length === 1 ? '' : 's') + (breakdown ? ': ' + breakdown : '')].join('\n'); } async function postEdits(edits, note) { const r = await fetch(MB_ROOT + '/ws/js/edit/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ edits, editNote: note || '', makeVotable: 0 }) }); const text = await r.text().catch(() => ''); let j = null; try { j = JSON.parse(text); } catch (e) {} if (!r.ok || (j && j.error)) { const m = (j && (j.error.message || j.error)) || ('HTTP ' + r.status); throw new Error((typeof m === 'string' ? m : JSON.stringify(m)).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200)); } return j; } function linkedIcons(selector) { const out = []; modal.querySelectorAll(selector).forEach(a => { const p = PROV.find(x => x.code === a.dataset.code); const tr = a.closest('tr[data-idx]'); if (p && tr) out.push({ idx: +tr.dataset.idx, p, url: a.getAttribute('href'), el: a }); }); return out; } async function removeBatch(icons) { if (!icons.length) return; icons.forEach(ic => ic.el.classList.add('removing')); try { const edits = [], used = []; for (const ic of icons) { const t = RELEASE.tracks[ic.idx]; if (!t.recId) continue; const rels = await recUrlRels(t.recId); const rel = rels.find(r => (r.target && r.target.name) === ic.url) || rels.find(r => ic.p.test((r.target && r.target.name) || '') && r.linkTypeID === ic.p.linkTypeID); if (!rel) { Log.warn('No ' + ic.p.name + ' relationship found to remove on "' + (t.title || t.recId) + '"'); continue; } edits.push({ edit_type: 92, id: rel.id, linkTypeID: rel.linkTypeID, attributes: [], entities: [{ entityType: 'recording', gid: t.recId }, { entityType: 'url', gid: rel.target.gid, name: rel.target.name }] }); used.push(ic); } if (!edits.length) { icons.forEach(ic => ic.el.classList.remove('removing')); return; } await postEdits(edits, delNote(used.map(ic => ic.p))); used.forEach(ic => { const t = RELEASE.tracks[ic.idx]; if (t) { t.recUrls = (t.recUrls || []).filter(u => u !== ic.url); delete _relCache[t.recId]; } ic.el.remove(); const ab = addBox(ic.idx); // re-offer as a hidden Add candidate so Find links can resolve it again if (ab && !ab.querySelector('.ii-tl[data-code="' + ic.p.code + '"]')) { const s = document.createElement('span'); s.className = 'ii-tl cand'; s.dataset.code = ic.p.code; s.title = ic.p.name; s.innerHTML = ic.p.icon; ab.appendChild(s); } }); Log.info('Removed ' + used.length + ' link' + (used.length === 1 ? '' : 's') + ' on MusicBrainz'); } catch (e) { Log.err('Remove links failed: ' + errText(e)); icons.forEach(ic => ic.el.classList.remove('removing')); } updateAddBtn(); } const removeOne = (idx, code) => removeBatch(linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')); const removeTrack = idx => removeBatch(linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked')); const removeProvider = code => removeBatch(linkedIcons('.ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')); // #341: toggle the "ended" flag on a link's relationship (EDIT_RELATIONSHIP, edit_type 91) — // e.g. a release taken down so its streaming links no longer resolve. Reversible. Verified: MB // applies `ended` via edit_type 91 with no end_date required. function endNote(provs, ended) { const counts = {}; provs.forEach(p => { counts[p.name] = (counts[p.name] || 0) + 1; }); const breakdown = Object.keys(counts).sort().map(n => n + ' (' + counts[n] + ')').join(', '); return [noteHeader(), '', 'Release: ' + MB_ROOT + '/release/' + mbid, (ended ? 'Marked ' : 'Un-ended ') + provs.length + ' streaming link' + (provs.length === 1 ? '' : 's') + (ended ? ' as ended' : '') + (breakdown ? ': ' + breakdown : '')].join('\n'); } async function endBatch(icons, ended) { if (!icons.length) return; icons.forEach(ic => ic.el.classList.add('removing')); try { const edits = [], used = []; for (const ic of icons) { const t = RELEASE.tracks[ic.idx]; if (!t.recId) continue; const rels = await recUrlRels(t.recId); const rel = rels.find(r => (r.target && r.target.name) === ic.url) || rels.find(r => ic.p.test((r.target && r.target.name) || '') && r.linkTypeID === ic.p.linkTypeID); if (!rel) { Log.warn('No ' + ic.p.name + ' relationship found on "' + (t.title || t.recId) + '"'); ic.el.classList.remove('removing'); continue; } if (!!rel.ended === ended) { ic.el.classList.remove('removing'); ic.el.classList.toggle('ended', ended); continue; } // already in the desired state edits.push({ edit_type: 91, id: rel.id, linkTypeID: rel.linkTypeID, attributes: [], ended: ended, entities: [{ entityType: 'recording', gid: t.recId }, { entityType: 'url', gid: rel.target.gid, name: rel.target.name }] }); used.push(ic); } if (!edits.length) return; await postEdits(edits, endNote(used.map(ic => ic.p), ended)); used.forEach(ic => { const t = RELEASE.tracks[ic.idx]; if (t) { t.endedUrls = t.endedUrls || new Set(); if (ended) t.endedUrls.add(ic.url); else t.endedUrls.delete(ic.url); delete _relCache[t.recId]; } ic.el.classList.remove('removing'); ic.el.classList.toggle('ended', ended); }); Log.info((ended ? 'Marked ' : 'Un-ended ') + used.length + ' link' + (used.length === 1 ? '' : 's') + (ended ? ' as ended' : '') + ' on MusicBrainz'); } catch (e) { Log.err('End toggle failed: ' + errText(e)); icons.forEach(ic => ic.el.classList.remove('removing')); } } const iconEnded = ic => ic.el.classList.contains('ended'); const endOne = (idx, code) => { const ics = linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]'); if (ics.length) endBatch(ics, !iconEnded(ics[0])); }; const endTrack = (idx, code) => { const c = linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')[0]; endBatch(linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked'), c ? !iconEnded(c) : true); }; const endProvider = (code, idx) => { const c = linkedIcons('tr[data-idx="' + idx + '"] .ii-tl-linked .ii-tl.linked[data-code="' + code + '"]')[0]; endBatch(linkedIcons('.ii-tl-linked .ii-tl.linked[data-code="' + code + '"]'), c ? !iconEnded(c) : true); }; // Show/label the toolbar "Add N links" button based on resolved candidates. // A track is "missing" links when it has none of our providers linked // (at least one link → not missing) — parallels the ISRC "N missing" badge. function missingCount() { let n = 0; (RELEASE ? RELEASE.tracks : []).forEach(t => { if (t.recId && !providersFor(t).some(p => linkedUrl(t, p))) n++; }); return n; } function updateAddBtn() { const m = missingCount(); const badge = modal.querySelector('#ii-badge-links'); // tab badge = tracks with no link if (badge) badge.textContent = m ? (m + ' missing') : ''; const n = modal.querySelectorAll('.ii-tl-add .ii-tl.new').length; // resolved + addable now const linked = modal.querySelectorAll('.ii-tl-linked .ii-tl.linked').length; const total = RELEASE ? RELEASE.tracks.length : 0; const sum = modal.querySelector('#ii-summary-links'); // #301: Links-scope status bar if (sum) sum.innerHTML = '' + total + ' tracks · ' + linked + ' link' + (linked === 1 ? '' : 's') + (m ? ' · ' + m + ' track' + (m === 1 ? '' : 's') + ' with none' : '') + (n ? ' · ' + n + ' to add' : ''); const btn = modal.querySelector('#ii-addlinks-btn'); if (!btn) return; btn.style.display = n ? '' : 'none'; btn.textContent = '➕ Add ' + n + ' link' + (n === 1 ? '' : 's'); btn.disabled = !n; } // Throttled resolve pass: for every track with an ISRC and no existing link on // a provider, look the track up by ISRC and, if found, turn the icon into a // coloured "add" candidate (resolve only — no edits are made here). // #307: resolve one provider across all its candidate tracks, with a bounded // pool (p.conc workers) so a provider's own rate limit is respected while its // requests still overlap. Album providers fetch once (cached) then resolve // locally, so they run effectively instantly. async function resolveProvider(p) { if (p.album && !(RELEASE && RELEASE[p.urlKey])) return; // album provider with no album link → nothing to resolve const jobs = []; for (let idx = 0; idx < RELEASE.tracks.length; idx++) { const t = RELEASE.tracks[idx]; if (!t.recId) continue; const isrc = normalizeIsrc(t.pending) || normalizeIsrc((t.existing || [])[0] || ''); if (!p.album && !isValidIsrc(isrc)) continue; // by-ISRC providers need an ISRC; album ones don't if (linkedUrl(t, p)) continue; // already linked → leave monochrome const el = cell(idx, p.code); if (!el || !el.classList.contains('cand')) continue; el.className = 'ii-tl spin'; el.dataset.code = p.code; // show all candidates spinning up front jobs.push({ idx, isrc, t }); } let next = 0; const worker = async () => { while (next < jobs.length) { const j = jobs[next++]; let url = null; try { url = await p.resolve(j.isrc, j.t, j.idx); } catch (e) { /* rate-limited / not found */ } const fresh = cell(j.idx, p.code); if (fresh) { if (url) makeNew(j.idx, p, url); else { fresh.className = 'ii-tl absent'; fresh.dataset.code = p.code; } } if (p.gap && next < jobs.length) await sleep(p.gap); } }; await Promise.all(Array.from({ length: Math.min(p.conc || 1, jobs.length) }, worker)); } async function resolve() { if (resolving) return; resolving = true; const btn = modal.querySelector('#ii-links-btn'); if (btn) { btn.disabled = true; btn.dataset.busy = '1'; } try { await Promise.all(PROV.map(resolveProvider)); // #307: providers run in parallel } finally { resolving = false; if (btn) { btn.disabled = false; delete btn.dataset.busy; } updateAddBtn(); } } // #301: per-track streaming links for export — what's linked plus what Find // links resolved, across our providers. function linkRows() { const rows = []; (RELEASE ? RELEASE.tracks : []).forEach((t, idx) => { if (!t.recId) return; const seen = new Set(); (t.recUrls || []).forEach(u => { const p = PROV.find(x => x.test(u)); if (p && !seen.has(u)) { seen.add(u); rows.push({ recording: t.recId, track: t.title || '', provider: p.name, url: u, status: 'linked' }); } }); const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); if (tr) tr.querySelectorAll('.ii-tl-add .ii-tl.new').forEach(a => { const p = PROV.find(x => x.code === a.dataset.code); const u = a.getAttribute('href'); if (p && u && !seen.has(u)) { seen.add(u); rows.push({ recording: t.recId, track: t.title || '', provider: p.name, url: u, status: 'to add' }); } }); }); return rows; } return { linkedHtml, addHtml, resolve, addAll, refresh: updateAddBtn, removeOne, removeTrack, removeProvider, endOne, endTrack, endProvider, linkRows }; })(); /* ── render the track table ── */ function renderTracks() { // #301: release title centered (Zen), artist + year on the line below modal.querySelector('#ii-rel-title').textContent = RELEASE.title || ''; modal.querySelector('#ii-rel-sub').textContent = [RELEASE.artist, RELEASE.releaseYear].filter(Boolean).join(' · '); // Provider buttons (#180): show a provider only when the release has its // link in MB OR Platform Check found one. MB-linked buttons are marked // (ring + brand tint via .ii-mb); an unmarked button = a PC-found link. [['Deezer', 'ii-dz-all', 'deezerId'], ['Spotify', 'ii-sp-all', 'spotifyId'], ['Beatport', 'ii-bp-all', 'beatportId'], ['Tidal', 'ii-td-all', 'tidalId'], ['Volumo', 'ii-vo-all', 'volumoId'], ['HDtracks', 'ii-hd-all', 'hdtracksId']].forEach(([source, id, field]) => { const btn = modal.querySelector('#' + id); const mbId = RELEASE[field]; // Spotify imports only via its MB-linked album (ISRC Hunt resolves the // release FROM the URL), so a PC-only Spotify link is not usable (#180). const hasPc = source !== 'Spotify' && !!platformCheckUrl(source); const rg = RELEASE.rgFrom && RELEASE.rgFrom[field]; // #302: pulled from a sibling release in the RG btn.style.display = (mbId || hasPc) ? '' : 'none'; btn.classList.toggle('ii-mb', !!mbId); btn.classList.toggle('ii-rg', !!rg); btn.disabled = false; btn.title = rg ? ('Import from ' + source + ' (link from another release in this group' + (rg.title ? ': ' + rg.title : '') + ')') : mbId ? ('Import from ' + source + ' (linked in MusicBrainz)') : hasPc ? ('Import from ' + source + ' (link found by Platform Check — not yet in MB)') : ('No ' + source + ' link'); }); tbody.innerHTML = ''; let lastMedium = null; RELEASE.tracks.forEach((t, idx) => { if (t.mediumPos !== lastMedium) { lastMedium = t.mediumPos; const mr = document.createElement('tr'); mr.className = 'ii-medrow'; mr.innerHTML = 'Medium ' + t.mediumPos + (t.mediumTitle ? ': ' + esc(t.mediumTitle) : '') + ''; tbody.appendChild(mr); } const tr = document.createElement('tr'); tr.dataset.idx = idx; tr.innerHTML = '' + esc(t.number || t.trackPos) + '' + '
      ' + (t.recId ? '' + esc(t.title) + '' : esc(t.title)) + '
      ' + esc(t.artist) + '
      ' + '' + esc(t.dur) + '' + // col 4 — Existing ISRC (ISRC scope) / Linked providers (Links scope) '
      ' + existingHtml(t.existing, t.pendingRemoval) + '
      ' + '' + // col 5 — New ISRC input + SoundExchange candidates (ISRC scope) / Add (Links scope). // .ii-cands is a sibling of .ii-inwrap (full-width, under the input), NOT inside it. '
      ' + '
      ' + '' + '' + '
      ' + // first track has no previous ISRC to increment — hide +1 but keep its slot so SX text stays aligned '' + '' + '' + '' + '' + '' + '
      ' + ''; const input = tr.querySelector('.ii-input'); tr.querySelector('.ii-clear').addEventListener('click', () => clearRow(idx)); input.addEventListener('input', () => { t.pending = normalizeIsrc(input.value); t.source = 'manual'; if (input.value !== t.pending) { const p = input.selectionStart; input.value = t.pending; input.setSelectionRange(p, p); } input.dataset.autofill = ''; validateInput(input, t); updateSummary(); // #157: don't hit SoundExchange on every keystroke (that spammed SX). // Clear any stale bullet while editing; the SX check now fires on blur // (manual entry) or the row's [SX] button. const lk = rowLookup(idx); if (lk) { lk.className = 'ii-lookup'; lk.textContent = ''; lk.onclick = null; } const sxb = tr.querySelector('.ii-sx'); if (sxb) sxb.disabled = trackBtnDisabled(t, input.value); // #157/#181: provider-aware enabled state }); // Manual entry → verify on SoundExchange only when the field loses focus (#157). input.addEventListener('blur', () => { if (input.dataset.autofill === '1') return; // filled by a source, not manual typing const v = normalizeIsrc(input.value); if (v && isValidIsrc(v)) lookupIsrc(idx, v).catch(e => { if (e && (e.rateLimited || e.captcha)) sxBlocked(e); }); }); const plusBtn = tr.querySelector('.ii-plus'); if (plusBtn && idx > 0) { // first row's +1 is a hidden spacer — don't wire it plusBtn.addEventListener('click', () => plusOne(idx)); plusBtn.addEventListener('contextmenu', e => { e.preventDefault(); plusOneFillDown(idx); }); } // Explicit per-track SoundExchange trigger (#157): the single by-ISRC fetch // the auto-call used to do. Verifies the ENTERED ISRC, or — when the field is // empty — an EXISTING ISRC already on the recording. Metadata search stays on // the separate "⚙ search SoundExchange…" entry. Disabled when there's nothing // to verify (no valid entered ISRC AND no existing ISRC). const sxBtn = tr.querySelector('.ii-sx'); if (sxBtn) { sxBtn.disabled = trackBtnDisabled(t, input.value); sxBtn.addEventListener('click', () => runTrackSingle(idx)); // right-click → run the current provider for ALL tracks (#181) sxBtn.addEventListener('contextmenu', e => { e.preventDefault(); runTrackAll(); }); } // the ▾ next to each per-track button opens the shared provider menu (#181) const provBtn = tr.querySelector('.ii-sxprov'); if (provBtn) provBtn.addEventListener('click', e => { e.stopPropagation(); modal.querySelector('#ii-prov-menu').classList.contains('open') ? closeProvMenu() : openProvMenu(provBtn); }); tbody.appendChild(tr); validateInput(input, t); // initial per-track entry point to the SoundExchange refine panel const rf = document.createElement('div'); rf.className = 'ii-cand-refine'; rf.textContent = '⚙ search SoundExchange…'; rf.addEventListener('click', () => openSxPanel(idx)); tr.querySelector('.ii-cands').appendChild(rf); }); updateSummary(); TrackLinks.refresh(); // #301: set the Links tab "N missing" badge } function existingHtml(arr, pending) { if (!arr || !arr.length) return 'none'; const pend = new Set((pending || []).map(normalizeIsrc)); return arr.map(i => { if (pend.has(normalizeIsrc(i))) return '' + esc(i) + ''; return ''; }).join(''); } function rowInput(idx) { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-input') : null; } function rowCands(idx) { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-cands') : null; } function rowLookup(idx) { const tr = tbody.querySelector('tr[data-idx="' + idx + '"]'); return tr ? tr.querySelector('.ii-lookup') : null; } // verify a typed ISRC on SoundExchange and show inline match/mismatch info function sxLookupCached(isrc) { if (_isrcLookupCache[isrc]) return Promise.resolve(_isrcLookupCache[isrc]); return SX.apiSearchByIsrc(isrc).then(rows => { _isrcLookupCache[isrc] = rows; return rows; }); } function lookupIsrc(idx, isrc) { const el = rowLookup(idx), t = RELEASE.tracks[idx]; if (!el) return Promise.resolve(); el.onclick = null; // drop any "click to verify" handler const cached = !!_isrcLookupCache[isrc]; el.className = 'ii-lookup spin'; el.textContent = cached ? '' : '⏳ checking SoundExchange…'; if (!cached) Log.info('SX lookup ' + isrc + ' (#' + (t.number || t.trackPos) + ')'); return sxLookupCached(isrc).then(rows => { if (!rows.length) { el.className = 'ii-lookup err'; el.textContent = '✗ not found on SoundExchange'; Log.warn('SX lookup ' + isrc + ': not found'); return; } const f = SX.fields(rows[0]); const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const good = cls === 'best'; const rel = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); el.className = 'ii-lookup ' + (good ? 'ok' : 'warn'); el.innerHTML = (good ? '✓ ' : '⚠ ') + sxMetaHtml(f, t) + (rel ? '
      ' + esc(rel) + '' : ''); el.title = [f.title, f.artist, f.year, f.dur].filter(Boolean).join(' · ') + (rel ? ' | ' + rel : ''); Log.info('SX lookup ' + isrc + ': ' + (good ? 'match' : cls === 'warn' ? 'length mismatch' : 'MISMATCH') + ' "' + f.title + '" — ' + f.artist); }).catch(e => { if (e && (e.rateLimited || e.captcha)) { el.className = 'ii-lookup err'; el.textContent = e.captcha ? '⚠ captcha' : '⚠ rate-limited'; throw e; } // let pumpVerify stop the queue (#126/#157) el.className = 'ii-lookup err'; el.textContent = '✗ lookup failed'; Log.err('SX lookup ' + isrc + ' failed: ' + e.message); }); } // SoundExchange verification queue — bulk fills (Deezer / Spotify / paste) route // their per-ISRC verification through here so it's SERIALIZED (no concurrent SX // hits) and CAPPED at SX_BATCH_LIMIT. Past the cap, the rest show a clickable // "click to verify" bullet; clicking resumes the next batch. (Manual typing // stays immediate — it never goes through the queue.) const _vq = { items: [], running: false, done: 0 }; // During a streaming import (Deezer/Spotify) we DEFER per-fill verification so // SoundExchange's requests don't compete with the import's on the GM queue — // SX must not slow or influence the import. Filled rows are collected here and // verified once the import has finished. let _deferVerify = false; let _deferredVerify = new Set(); function enqueueVerify(idx, isrc) { if (!_vq.items.length && !_vq.running) _vq.done = 0; // a fresh burst → reset the allowance _vq.items = _vq.items.filter(it => it.idx !== idx); // one pending verify per row _vq.items.push({ idx, isrc }); pumpVerify(); } function showVerifyPauses() { const remaining = _vq.items.length; _vq.items.forEach(it => { const el = rowLookup(it.idx); if (!el) return; el.className = 'ii-lookup pending'; el.textContent = '⏳ Not verified — click to check the next ' + Math.min(SX_BATCH_LIMIT, remaining) + ' on SoundExchange'; el.onclick = () => { _vq.done = 0; pumpVerify(); }; }); } async function pumpVerify() { if (_vq.running) return; _vq.running = true; const myEpoch = _sxEpoch; // bumped by abortSxWork / a 429 → this loop bails (#126/#127) try { while (_vq.items.length) { if (myEpoch !== _sxEpoch) return; // cancelled (clear / close / rate-limit) if (_vq.done >= SX_BATCH_LIMIT) { showVerifyPauses(); return; } const { idx, isrc } = _vq.items.shift(); const t = RELEASE.tracks[idx]; // skip if the field no longer holds this value (user changed it meanwhile) if (!t || !isValidIsrc(isrc) || normalizeIsrc(t.pending) !== normalizeIsrc(isrc)) continue; const cached = !!_isrcLookupCache[isrc]; // primed SX rows → no request, no pacing/cap try { await lookupIsrc(idx, isrc); } catch (e) { if (e && (e.rateLimited || e.captcha)) { sxBlocked(e); return; } } if (myEpoch !== _sxEpoch) return; // cancelled while the request was in flight if (!cached) { _vq.done++; if (_vq.items.length && _vq.done < SX_BATCH_LIMIT) await sleep(BATCH_DELAY); } } } finally { // always release the lock — a throw in here previously stalled the whole queue _vq.running = false; } } function validateInput(input, t) { const v = normalizeIsrc(input.value); input.classList.remove('bad', 'dup', 'ok'); if (!v) return; if (!isValidIsrc(v)) { input.classList.add('bad'); return; } if (t.existing.includes(v)) { input.classList.add('dup'); input.title = 'Already on this recording'; return; } input.title = ''; input.classList.add('ok'); } function setPending(idx, isrc, flash, source) { const t = RELEASE.tracks[idx]; const input = rowInput(idx); if (!t || !input) return; t.pending = normalizeIsrc(isrc); t.source = source || 'manual'; input.value = t.pending; input.dataset.autofill = '1'; // filled by a source — the on-input handler won't fire validateInput(input, t); { const sxb = input.closest('tr')?.querySelector('.ii-sx'); if (sxb) sxb.disabled = trackBtnDisabled(t, t.pending); } // #157/#181: keep per-track button enabled-state in sync after a fill // #157: do NOT auto-hit SoundExchange on fills. Deezer/Spotify/+1/paste fills // used to enqueue a per-track SX verify, which spammed SX (and double-hit it // during the bulk SX search). Now we only show the match bullet when the SX // data is ALREADY cached — i.e. an SX-sourced pick, whose by-ISRC lookup is // served from cache with no request (renderCands / the panel prime the cache). // Everything else stays blank; verify it via the row [SX] button, by blurring // a manual entry, or the bulk SoundExchange search. const lk0 = rowLookup(idx); if (isValidIsrc(t.pending) && _isrcLookupCache[t.pending]) { enqueueVerify(idx, t.pending); // cached → free } else if (lk0) { lk0.className = 'ii-lookup'; lk0.textContent = ''; lk0.onclick = null; } if (flash) { const tr = input.closest('tr'); tr.classList.remove('ii-row-fill'); void tr.offsetWidth; tr.classList.add('ii-row-fill'); } } function clearPending() { abortSxWork('clear entered'); // cancel queued verifications + the bulk SX search (#127) RELEASE.tracks.forEach((t, i) => { t.pending = ''; t.source = ''; const inp = rowInput(i); if (inp) { inp.value = ''; validateInput(inp, t); } }); tbody.querySelectorAll('.ii-cands').forEach(c => c.innerHTML = ''); tbody.querySelectorAll('.ii-lookup').forEach(l => { l.className = 'ii-lookup'; l.textContent = ''; l.title = ''; l.onclick = null; }); updateSummary(); toast('Cleared entered ISRCs'); } // Clear a single track's New-ISRC field (the row's "×" button). function clearRow(idx) { const t = RELEASE.tracks[idx], input = rowInput(idx); if (!t || !input) return; _vq.items = _vq.items.filter(it => it.idx !== idx); // drop any queued verify for this row t.pending = ''; t.source = ''; input.value = ''; input.dataset.autofill = ''; validateInput(input, t); const lk = rowLookup(idx); if (lk) { lk.className = 'ii-lookup'; lk.textContent = ''; lk.title = ''; lk.onclick = null; } // re-expand the candidate list so a different suggestion can be picked const box = rowCands(idx); if (box) { box.classList.remove('collapsed'); box.querySelectorAll('.ii-cand.chosen').forEach(c => c.classList.remove('chosen')); } updateSummary(); input.focus(); } function plusOne(idx) { // find nearest previous value (pending or first existing) for (let i = idx - 1; i >= 0; i--) { const prev = RELEASE.tracks[i]; const base = prev.pending || (prev.existing[0] || ''); if (!base) continue; const inc = base.replace(/(\d+)(?!.*\d)/, m => String(parseInt(m, 10) + 1).padStart(m.length, '0')); setPending(idx, inc, true); updateSummary(); return; } toast('No previous ISRC to increment'); } // Right-click +1: fill an incrementing ISRC from here all the way to the LAST // track, overwriting whatever's in each New-ISRC field. function plusOneFillDown(idx) { let base = ''; for (let i = idx - 1; i >= 0; i--) { base = RELEASE.tracks[i].pending || RELEASE.tracks[i].existing[0] || ''; if (base) break; } if (!base) { toast('No previous ISRC to increment'); return; } let count = 0; for (let i = idx; i < RELEASE.tracks.length; i++) { base = base.replace(/(\d+)(?!.*\d)/, m => String(parseInt(m, 10) + 1).padStart(m.length, '0')); setPending(i, base, true); count++; } updateSummary(); toast('Filled ' + count + ' track' + (count === 1 ? '' : 's') + ' with a +1 sequence'); } function updateSummary() { const dupSet = highlightDuplicates(); // ISRCs on >1 recording — not submittable let valid = 0, bad = 0, dup = 0, crossDup = 0, missing = 0; RELEASE.tracks.forEach((t, i) => { const rowMissing = !t.existing.length && !t.pending; if (rowMissing) missing++; const row = tbody && tbody.querySelector('tr[data-idx="' + i + '"]'); // #159: flag still-missing rows if (row) row.classList.toggle('ii-row-missing', rowMissing); if (!t.pending) return; const v = normalizeIsrc(t.pending); if (!isValidIsrc(v)) { bad++; return; } if (t.existing.includes(v)) { dup++; return; } // already on this recording if (dupSet.has(v)) { crossDup++; return; } // same ISRC on another recording → blocked valid++; }); const isrcBadge = modal.querySelector('#ii-badge-isrc'); // #301: tab count if (isrcBadge) isrcBadge.textContent = missing ? (missing + ' missing') : ''; const seq = iterativeSequence(); summaryEl.innerHTML = '' + RELEASE.tracks.length + ' tracks' + (bad ? ' · ' + bad + ' invalid' : '') + (dup ? ' · ' + dup + ' already present' : '') + (crossDup ? ' · ' + crossDup + ' duplicated across tracks (blocked)' : '') + (missing ? ' · ' + missing + ' still missing' : '') + (seq ? ' ⛓ sequential ' + esc(seq.from) + ' → ' + esc(seq.to) + '' : ''); submitBtn.textContent = 'Submit to MusicBrainz' + (valid ? ' (' + valid + ')' : ''); submitBtn.disabled = valid === 0; } // If every track has a valid ISRC and they form one perfect +1 run (same first // 7 chars, last-5 designation incrementing by 1), return {from,to,count}; else null. function iterativeSequence() { const isrcs = RELEASE.tracks.map(t => normalizeIsrc(t.pending || t.existing[0] || '')); if (isrcs.length < 2 || isrcs.some(s => !isValidIsrc(s))) return null; for (let i = 1; i < isrcs.length; i++) { if (isrcs[i].slice(0, 7) !== isrcs[i - 1].slice(0, 7)) return null; if (parseInt(isrcs[i].slice(7), 10) !== parseInt(isrcs[i - 1].slice(7), 10) + 1) return null; } return { from: isrcs[0], to: isrcs[isrcs.length - 1], count: isrcs.length }; } // Flag ISRCs that appear on more than one distinct recording (pending or existing) and // return the Set of those (normalized) ISRCs. Same-recording repeats don't count. function highlightDuplicates() { const recsByIsrc = {}; RELEASE.tracks.forEach((t, i) => { const key = t.recId || ('i' + i); const add = raw => { const v = normalizeIsrc(raw); if (!v) return; (recsByIsrc[v] = recsByIsrc[v] || new Set()).add(key); }; const pv = normalizeIsrc(t.pending); if (pv && isValidIsrc(pv)) add(pv); t.existing.forEach(add); }); const dupSet = new Set(Object.keys(recsByIsrc).filter(v => recsByIsrc[v].size > 1)); RELEASE.tracks.forEach((t, i) => { const tr = tbody.querySelector('tr[data-idx="' + i + '"]'); if (!tr) return; const inp = tr.querySelector('.ii-input'); const pv = normalizeIsrc(t.pending); inp.classList.toggle('dupother', !!(pv && isValidIsrc(pv) && !t.existing.includes(pv) && dupSet.has(pv))); tr.querySelectorAll('.ii-existing samp').forEach(s => s.classList.toggle('dup', dupSet.has(normalizeIsrc(s.textContent)))); }); return dupSet; } /* ── bulk paste / export ── */ function findTrackByNumber(token) { // token like "3" or "1.3" (medium.track) or "1-3" const mt = token.match(/^(\d+)[.\-:](\d+)$/); if (mt) { const med = +mt[1], pos = +mt[2]; return RELEASE.tracks.findIndex(t => t.mediumPos === med && (+t.trackPos === pos)); } const n = token.trim(); return RELEASE.tracks.findIndex(t => String(t.number) === n || String(t.trackPos) === n); } function applyBulk(overwrite) { const text = modal.querySelector('#ii-bulk-text').value; const trimmed = text.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { applyJson(text, overwrite); return; } const lines = text.replace(/\r/g, '').split('\n'); let seq = 0, applied = 0; const tryApply = (idx, isrc) => { if (idx < 0 || idx >= RELEASE.tracks.length) return; const v = normalizeIsrc(isrc); if (!isValidIsrc(v)) return; if (!overwrite && RELEASE.tracks[idx].pending) return; setPending(idx, v, true, 'bulk'); applied++; }; lines.forEach(line => { const raw = line.trim(); // targeted forms (do NOT consume a sequential slot): "3=ISRC" | "ISRC | 1.3" | "1.3 ISRC" let m, target = -1, isrc = null; if ((m = raw.match(/^(.+?)\s*=\s*([A-Za-z0-9-]+)$/))) { target = findTrackByNumber(m[1]); isrc = m[2]; } else if ((m = raw.match(/^([A-Za-z0-9-]+)\s*[|,]\s*(.+)$/))) { isrc = m[1]; target = findTrackByNumber(m[2]); } else if ((m = raw.match(/^([\d.\-:]+)\s+([A-Za-z0-9-]+)$/))) { target = findTrackByNumber(m[1]); isrc = m[2]; } if (isrc !== null) { tryApply(target, isrc); return; } // sequential: a plain ISRC fills the next track; a blank line skips one if (raw) tryApply(seq, raw); seq++; }); updateSummary(); toast('Applied ' + applied + ' ISRC' + (applied === 1 ? '' : 's')); } function applyJson(text, overwrite) { let data; try { data = JSON.parse(text); } catch (e) { toast('Invalid JSON', 'err'); return; } let applied = 0; const apply = (idx, isrc) => { if (idx < 0 || idx >= RELEASE.tracks.length) return; const v = normalizeIsrc(isrc); if (!isValidIsrc(v)) return; if (!overwrite && RELEASE.tracks[idx].pending) return; setPending(idx, v, true, 'bulk'); applied++; }; if (Array.isArray(data)) { data.forEach((entry, i) => { if (typeof entry === 'string') apply(i, entry); else if (entry && entry.isrc) { const idx = entry.recording ? RELEASE.tracks.findIndex(t => t.recId === entry.recording) : (entry.track != null ? findTrackByNumber(String(entry.track)) : i); apply(idx, entry.isrc); } }); } else if (data && typeof data === 'object') { // { recordingMbid: "ISRC" | ["ISRC", ...] } Object.entries(data).forEach(([rid, val]) => { const idx = RELEASE.tracks.findIndex(t => t.recId === rid); const isrc = Array.isArray(val) ? val[0] : val; apply(idx, isrc); }); } updateSummary(); toast('Applied ' + applied + ' ISRC' + (applied === 1 ? '' : 's')); } // #340: a recording can carry MORE THAN ONE ISRC — collect them all (existing + the pending one), // deduped + validated, so the export never drops the 2nd/3rd ISRC of a track. function allIsrcs(t) { const out = []; for (const raw of [...(t.existing || []), t.pending]) { const v = normalizeIsrc(raw); if (v && isValidIsrc(v) && !out.includes(v)) out.push(v); } return out; } function exportText() { const out = RELEASE.tracks.map(t => allIsrcs(t).join(' ')).join('\n'); // one line per track; multiple ISRCs space-separated copyToClipboard(out, out.split('\n').length + ' lines copied'); } function exportJson() { const obj = {}; RELEASE.tracks.forEach(t => { const arr = allIsrcs(t); if (arr.length && t.recId) obj[t.recId] = arr.length === 1 ? arr[0] : arr; // string for one ISRC (back-compat), array for several }); copyToClipboard(JSON.stringify(obj, null, 2), 'JSON copied'); } // #301: export the per-track streaming links (linked + resolved) as CSV / JSON / URL list function exportLinks(fmt) { const rows = TrackLinks.linkRows(); const note = modal.querySelector('#ii-link-export-note'); if (!rows.length) { if (note) note.textContent = 'No links yet — run 🔗 Find links first (or this release has none of our providers linked).'; toast('No links to export', 'err'); return; } let out, msg; if (fmt === 'urls') { out = [...new Set(rows.map(r => r.url))].join('\n'); msg = out.split('\n').length + ' URLs copied'; } else if (fmt === 'json') { out = JSON.stringify(rows, null, 2); msg = rows.length + ' links copied (JSON)'; } else { const esc = s => /[",\n]/.test(s) ? '"' + String(s).replace(/"/g, '""') + '"' : s; out = ['recording,track,provider,url,status'].concat(rows.map(r => [r.recording, r.track, r.provider, r.url, r.status].map(esc).join(','))).join('\n'); msg = rows.length + ' links copied (CSV)'; } copyToClipboard(out, msg); if (note) { const linked = rows.filter(r => r.status === 'linked').length; note.textContent = rows.length + ' links · ' + linked + ' linked · ' + (rows.length - linked) + ' to add'; } } function copyToClipboard(text, msg) { const ta = modal.querySelector('#ii-bulk-text'); ta.value = text; modal.querySelector('#ii-bulk-pane').classList.add('open'); ta.focus(); ta.select(); try { navigator.clipboard.writeText(text); } catch (e) {} toast(msg); } /* ── candidate suggestions (SoundExchange) ── */ // Collapse a track's candidate list down to just the one matching `isrc` // (marked .chosen); the rest are hidden until the next search re-renders them. function collapseCandsTo(idx, isrc) { const box = rowCands(idx); if (!box) return; const norm = normalizeIsrc(isrc); let found = false; box.querySelectorAll('.ii-cand').forEach(el => { const on = el.dataset.isrc === norm; el.classList.toggle('chosen', on); if (on) found = true; }); box.classList.toggle('collapsed', found); } function renderCands(idx, rows) { const box = rowCands(idx); const t = RELEASE.tracks[idx]; if (!box) return; box.innerHTML = ''; // prime the by-ISRC lookup cache from these search rows, so when one is picked // the verification bullet renders instantly (no extra SoundExchange request) (rows || []).forEach(item => { const iso = normalizeIsrc(SX.fields(item).isrc); if (iso && !_isrcLookupCache[iso]) _isrcLookupCache[iso] = [item]; }); (rows || []).slice(0, 5).forEach(item => { const f = SX.fields(item); const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const inMb = t.existing.includes(normalizeIsrc(f.isrc)); const relInfo = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); const c = document.createElement('div'); c.className = 'ii-cand' + (cls === 'best' ? ' best' : cls === 'warn' ? ' warn' : ' bad') + (inMb ? ' inmb' : ''); c.dataset.isrc = normalizeIsrc(f.isrc); c.title = relInfo ? 'Appears on: ' + relInfo : ''; c.innerHTML = '' + esc(f.isrc) + '' + '' + sxMetaHtml(f, t) + (relInfo ? ' · ' + esc(relInfo) : '') + '' + (inMb ? '✓ IN MB' : 'SX'); c.addEventListener('click', () => { setPending(idx, f.isrc, true, 'SoundExchange'); updateSummary(); collapseCandsTo(idx, f.isrc); // full list stays one "refine search" click away }); box.appendChild(c); }); box.classList.remove('collapsed'); // "refine search" entry — opens the panel to tweak title/artist/release + exact const refine = document.createElement('div'); refine.className = 'ii-cand-refine'; refine.textContent = (rows && rows.length) ? '⚙ refine search / more…' : '⚙ no match — refine search…'; refine.addEventListener('click', () => openSxPanel(idx)); box.appendChild(refine); } /* ── SoundExchange refine panel ── */ let sxPanel = null, _sxPanelIdx = -1, _sxPanelGen = 0; // Build a URL to the SoundExchange website's own search for the same query, // so the user can fall back to searching there directly (quoted = exact field). function sxPageUrl(title, artist, release) { const enc = s => encodeURIComponent('"' + String(s).replace(/"/g, '') + '"'); const parts = ['tab=' + encodeURIComponent('"simple"')]; if (artist) parts.push('artistName=' + enc(artist)); if (title) parts.push('title=' + enc(title)); if (release) parts.push('releaseName=' + enc(release)); return SX_HOME + '?' + parts.join('&'); } function buildSxPanel() { if (sxPanel) return; sxPanel = document.createElement('div'); sxPanel.id = 'ii-sxpanel'; sxPanel.innerHTML = `
      🔍 SoundExchange
      `; document.body.appendChild(sxPanel); const closePanel = () => sxPanel.classList.remove('open'); sxPanel.querySelector('#ii-sxp-close').addEventListener('click', closePanel); // Esc / click-outside close — this is a transient search panel (unlike the // main editor, which deliberately ignores both to avoid losing entered work). document.addEventListener('keydown', e => { if (e.key === 'Escape' && sxPanel.classList.contains('open')) { e.stopPropagation(); closePanel(); } }, true); // #285: Esc closes the window (sub-popups consume it first). Data is preserved. document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modal.classList.contains('open')) return; if (sxPanel.classList.contains('open')) return; // handled by its own (capture) listener above const pm = modal.querySelector('#ii-prov-menu'); if (pm && pm.classList.contains('open')) { closeProvMenu(); return; } const ua = modal.querySelector('#ii-urladd'); if (ua && ua.classList.contains('open')) return; // the url-add field closes itself closeModal(); }); document.addEventListener('mousedown', e => { if (!sxPanel.classList.contains('open')) return; // ignore clicks inside the panel, and on a "refine search" entry (which re-opens it) if (sxPanel.contains(e.target) || (e.target.closest && e.target.closest('.ii-cand-refine'))) return; closePanel(); }); sxPanel.querySelector('#ii-sxp-search').addEventListener('click', sxPanelSearch); ['#ii-sxp-title', '#ii-sxp-artist', '#ii-sxp-release'].forEach(id => sxPanel.querySelector(id).addEventListener('keydown', e => { if (e.key === 'Enter') sxPanelSearch(); })); // per-term "use this" checkbox (greys the field when off; release is remembered) // and "E" exact toggle (persisted in sxExact, kept across tracks) ['title', 'artist', 'release'].forEach(key => { const en = sxPanel.querySelector('#ii-sxp-en-' + key); en.addEventListener('change', () => { sxPanel.querySelector('#ii-sxp-f-' + key).classList.toggle('off', !en.checked); if (key === 'release') { sxRelEnabled = en.checked; store.set('sx_rel_enabled', sxRelEnabled); } }); const ex = sxPanel.querySelector('#ii-sxp-ex-' + key); ex.addEventListener('click', () => { sxExact[key] = !sxExact[key]; saveSxExact(); ex.classList.toggle('on', sxExact[key]); Log.info('SX exact ' + key + ' = ' + sxExact[key]); }); }); // drag by header const hdr = sxPanel.querySelector('.ii-sxp-hdr'); let dx = 0, dy = 0, drag = false; hdr.addEventListener('mousedown', e => { if (e.target.id === 'ii-sxp-close') return; drag = true; const r = sxPanel.getBoundingClientRect(); sxPanel.style.left = r.left + 'px'; sxPanel.style.top = r.top + 'px'; sxPanel.style.right = 'auto'; dx = e.clientX - r.left; dy = e.clientY - r.top; e.preventDefault(); }); document.addEventListener('mousemove', e => { if (drag) { sxPanel.style.left = (e.clientX - dx) + 'px'; sxPanel.style.top = (e.clientY - dy) + 'px'; } }); document.addEventListener('mouseup', () => { drag = false; }); } function openSxPanel(idx) { buildSxPanel(); const t = RELEASE.tracks[idx]; _sxPanelIdx = idx; sxPanel.querySelector('#ii-sxp-track').textContent = t.title + (t.artist ? ' — ' + t.artist : ''); sxPanel.querySelector('#ii-sxp-title').value = t.title; sxPanel.querySelector('#ii-sxp-artist').value = t.artist; sxPanel.querySelector('#ii-sxp-release').value = RELEASE.title || ''; // prefilled from MusicBrainz const setEnabled = (key, on) => { sxPanel.querySelector('#ii-sxp-en-' + key).checked = on; sxPanel.querySelector('#ii-sxp-f-' + key).classList.toggle('off', !on); }; const setExact = (key, on) => sxPanel.querySelector('#ii-sxp-ex-' + key).classList.toggle('on', on); // title/artist default ON (reset per track); release uses the remembered toggle setEnabled('title', true); setEnabled('artist', true); setEnabled('release', sxRelEnabled); // E (exact) reflects the persisted, kept-across-tracks state setExact('title', sxExact.title); setExact('artist', sxExact.artist); setExact('release', sxExact.release); sxPanel.classList.add('open'); sxPanelSearch(); } function sxPanelSearch() { const idx = _sxPanelIdx; const t = RELEASE.tracks[idx]; // only enabled terms are used; a disabled term is sent empty (= ignored by SX) const use = key => sxPanel.querySelector('#ii-sxp-en-' + key).checked; const val = key => sxPanel.querySelector('#ii-sxp-' + key).value.trim(); const title = use('title') ? val('title') : ''; const artist = use('artist') ? val('artist') : ''; const release = use('release') ? val('release') : ''; const exact = { title: sxExact.title, artist: sxExact.artist, release: sxExact.release }; const stEl = sxPanel.querySelector('.ii-sxp-status'); const resEl = sxPanel.querySelector('.ii-sxp-results'); const goBtn = sxPanel.querySelector('#ii-sxp-search'); const webLink = sxPanel.querySelector('#ii-sxp-web'); if (webLink) webLink.href = sxPageUrl(title, artist, release); stEl.className = 'ii-sxp-status'; stEl.textContent = 'Searching…'; goBtn.disabled = true; const gen = ++_sxPanelGen; Log.info('SX refine #' + (t.number || t.trackPos) + ': "' + title + '" / "' + artist + '"' + (release ? ' / rel "' + release + '"' : ''), exact); SX.apiSearch(title, artist, 0, 25, exact, release).then(rows => { if (gen !== _sxPanelGen) return; goBtn.disabled = false; stEl.textContent = rows.length ? rows.length + ' result' + (rows.length === 1 ? '' : 's') : 'No results'; renderSxPanelResults(idx, rows); }).catch(e => { if (gen !== _sxPanelGen) return; goBtn.disabled = false; stEl.className = 'ii-sxp-status err'; resEl.innerHTML = ''; if (e && (e.rateLimited || e.captcha)) { stEl.textContent = e.captcha ? '⚠ captcha — resolve in browser, then retry' : '⚠ rate-limited — wait a minute'; sxBlocked(e); } else stEl.textContent = '⚠ ' + e.message; }); } function renderSxPanelResults(idx, rows) { const t = RELEASE.tracks[idx]; const resEl = sxPanel.querySelector('.ii-sxp-results'); resEl.innerHTML = ''; // prime the by-ISRC cache so picking a result verifies instantly (no extra request) rows.forEach(item => { const iso = normalizeIsrc(SX.fields(item).isrc); if (iso && !_isrcLookupCache[iso]) _isrcLookupCache[iso] = [item]; }); rows.forEach(item => { const f = SX.fields(item); const cls = SX.classify(f, t.title, t.artist, t.dur, RELEASE.releaseYear); const inMb = t.existing.includes(normalizeIsrc(f.isrc)); const cur = normalizeIsrc(t.pending) === normalizeIsrc(f.isrc); const rel = [f.relTitle, f.relLabel, f.relDate].filter(Boolean).join(' · '); const row = document.createElement('div'); row.className = 'ii-sxp-row' + (cur ? ' cur' : cls === 'best' ? ' best' : cls === 'warn' ? ' warn' : ' bad'); row.innerHTML = '' + esc(f.isrc) + '' + '' + sxMetaHtml(f, t) + '' + (rel ? '' + esc(rel) + '' : '') + '' + (inMb ? '✓ IN MB' : ''); row.addEventListener('click', () => { setPending(idx, f.isrc, true, 'SoundExchange'); updateSummary(); collapseCandsTo(idx, f.isrc); sxPanel.classList.remove('open'); // picked a result → close the search panel }); resEl.appendChild(row); }); } // SoundExchange batch state — we search at most SX_BATCH_LIMIT tracks at a time // so SX doesn't block us; the rest show a "not loaded" message you click to continue. let _sxTodo = [], _sxCursor = 0, _sxMatched = 0, _sxFilled = 0, _sxRunning = false, _sxEpoch = 0; // Cancel ALL batched SoundExchange work — queued verifications and the bulk search — and abort any // in-flight SX request. Bumping the epoch makes the running loops bail at their next checkpoint. #127 function abortSxWork(reason) { _sxEpoch++; _vq.items = []; _vq.done = 0; _vq.running = false; _sxTodo = []; _sxCursor = 0; _sxRunning = false; _deferredVerify.clear(); _deferVerify = false; abortInflight('soundexchange'); const btn = modal && modal.querySelector('#ii-sx-all'); if (btn) btn.disabled = false; if (progEl) { progEl.textContent = ''; progEl.classList.remove('err'); } if (reason) Log.info('SoundExchange: cancelled all queued work (' + reason + ')'); } // SoundExchange blocked us — either a rate limit (HTTP 429) or a captcha (HTTP // 202 {"searchCaptcha": true}, #157). Either way: stop the bulk run, abort // in-flight requests, and surface the cause + how to recover in the toolbar. // The captcha needs the user to solve it on SX's site, so we link there. #126/#157 function sxBlocked(err) { _sxEpoch++; // stop the running loops — don't issue any more requests _sxRunning = false; _vq.running = false; abortInflight('soundexchange'); const btn = modal && modal.querySelector('#ii-sx-all'); if (btn) btn.disabled = false; if (err && err.captcha) { if (progEl) { progEl.classList.add('err'); progEl.textContent = '⚠ SoundExchange captcha — '; const a = document.createElement('a'); a.href = SX_HOME; a.target = '_blank'; a.rel = 'noopener'; a.textContent = 'resolve captcha in browser to unblock ↗'; progEl.appendChild(a); const tail = document.createElement('span'); tail.textContent = ', then retry'; progEl.appendChild(tail); } toast('SoundExchange captcha — resolve it in the browser, then retry.', 'err'); Log.warn('SoundExchange captcha (202 searchCaptcha) — bulk search stopped; resolve in browser to unblock'); } else { if (progEl) { progEl.textContent = '⚠ SoundExchange rate-limited (HTTP 429) — paused; wait a minute and retry'; progEl.classList.add('err'); } toast('SoundExchange rate-limited (429) — stopped. Wait a minute and retry.', 'err'); Log.warn('SoundExchange rate-limited (429) — bulk search stopped'); } } function runSxAll() { const tracks = RELEASE.tracks; _sxTodo = tracks.map((t, i) => i).filter(i => !tracks[i].existing.length && !tracks[i].pending); _sxCursor = 0; _sxMatched = 0; _sxFilled = 0; Log.info('SoundExchange: ' + _sxTodo.length + ' track(s) without an ISRC (skipping ' + (tracks.length - _sxTodo.length) + ' that already have one); up to ' + SX_BATCH_LIMIT + ' per batch'); SX.refreshToken().then(() => Log.info('SX token ready')).catch(e => Log.warn('SX token prefetch failed: ' + e.message)); processNextSxBatch(); } // Render a clickable "not loaded" message in a track's candidate box. Clicking // any of them loads the next batch (in order — like the MagicISRC userscript). function sxPlaceholder(idx, remaining) { const box = rowCands(idx); if (!box) return; box.innerHTML = ''; const m = document.createElement('div'); m.className = 'ii-cand-pending'; m.textContent = '⏳ Not searched — click to load the next ' + Math.min(SX_BATCH_LIMIT, remaining) + ' on SoundExchange'; m.title = 'SoundExchange searches are capped at ' + SX_BATCH_LIMIT + ' at a time to avoid being blocked'; m.addEventListener('click', () => processNextSxBatch()); box.appendChild(m); } async function processNextSxBatch() { if (_sxRunning) return; _sxRunning = true; const myEpoch = _sxEpoch; // a clear / close / 429 bumps this → bail without writing stale results (#126/#127) const btn = modal.querySelector('#ii-sx-all'); btn.disabled = true; if (progEl) progEl.classList.remove('err'); // clear any prior rate-limit warning on a fresh run const tracks = RELEASE.tracks; const batch = _sxTodo.slice(_sxCursor, _sxCursor + SX_BATCH_LIMIT); // clear any "not loaded" placeholders on the tracks we're about to search batch.forEach(i => { const box = rowCands(i); if (box) box.innerHTML = ''; }); let n = 0; for (const i of batch) { const t = tracks[i]; progEl.textContent = 'SoundExchange ' + (_sxCursor + n + 1) + '/' + _sxTodo.length; try { const rows = await SX.apiSearch(t.title, t.artist, 0, 10, sxExact); if (myEpoch !== _sxEpoch) return; // cancelled (clear / close) while in flight renderCands(i, rows); const best = rows.find(r => SX.classify(SX.fields(r), t.title, t.artist, t.dur, RELEASE.releaseYear) === 'best'); const bestIsrc = best && SX.fields(best).isrc; if (bestIsrc) { _sxMatched++; // autofill the input only when it's empty AND the match is a NEW isrc if (!t.pending && !t.existing.includes(bestIsrc)) { setPending(i, bestIsrc, true, 'SoundExchange'); collapseCandsTo(i, bestIsrc); _sxFilled++; } } Log.info('SX #' + (t.number || t.trackPos) + ' "' + t.title + '": ' + rows.length + ' result(s)' + (bestIsrc ? ', best ' + bestIsrc + (t.existing.includes(bestIsrc) ? ' (already in MB)' : '') : ', no confident match')); } catch (e) { if (myEpoch !== _sxEpoch) return; // cancelled while in flight if (e && (e.rateLimited || e.captcha)) { // SoundExchange 429 (rate limit) or 202 captcha → stop the whole bulk run; // leave the unsearched rows (incl. this one) as click-to-retry placeholders, // and surface the cause + recovery in the toolbar. #126/#157 _sxCursor += n; const left = _sxTodo.length - _sxCursor; if (left > 0) _sxTodo.slice(_sxCursor).forEach(j => sxPlaceholder(j, left)); sxBlocked(e); return; } renderCands(i, []); Log.err('SX #' + (t.number || t.trackPos) + ' "' + t.title + '" failed: ' + e.message); } n++; updateSummary(); if (n < batch.length) await sleep(BATCH_DELAY); if (myEpoch !== _sxEpoch) return; // cancelled during the pacing delay } _sxCursor += batch.length; const remaining = _sxTodo.length - _sxCursor; if (remaining > 0) { _sxTodo.slice(_sxCursor).forEach(i => sxPlaceholder(i, remaining)); progEl.textContent = 'SoundExchange ' + _sxCursor + '/' + _sxTodo.length + ' — ' + remaining + ' not loaded (click a row to search the next ' + Math.min(SX_BATCH_LIMIT, remaining) + ')'; Log.info('SoundExchange: paused at ' + _sxCursor + '/' + _sxTodo.length + ' — ' + remaining + ' awaiting a click to continue'); } else { progEl.textContent = 'SoundExchange done — ' + _sxMatched + ' matched, ' + _sxFilled + ' filled'; Log.info('SoundExchange done — ' + _sxMatched + ' matched, ' + _sxFilled + ' newly filled'); } btn.disabled = false; _sxRunning = false; } /* ── streaming-source import (Deezer / Spotify) ── */ // Map ONE fetched ISRC to a track and fill it immediately (live, as it arrives). // Returns 'filled' | 'already' | 'skipped' | 'unmatched'. function mapOneToTrack(s, label) { let idx = RELEASE.tracks.findIndex(t => (+t.trackPos === +s.pos) && ((+t.mediumPos === +s.disc) || RELEASE.tracks.filter(x => +x.mediumPos === +s.disc).length === 0)); if (idx < 0) idx = RELEASE.tracks.findIndex(t => t.title && isGoodMatch(s.title, s.artist, t.title, t.artist)); if (idx < 0) { Log.warn(label + ': no track matched ' + s.isrc + ' "' + s.title + '" (disc ' + s.disc + ' pos ' + s.pos + ')'); return 'unmatched'; } const t = RELEASE.tracks[idx]; if (t.existing.includes(s.isrc)) return 'already'; if (t.pending) return 'skipped'; setPending(idx, s.isrc, true, label); // fills the input box right now updateSummary(); return 'filled'; } const errText = e => (e && (e.message || e.stack)) || String(e) || '(no detail)'; const setProg = (msg, isErr) => { if (!progEl) return; progEl.textContent = msg; progEl.classList.toggle('err', !!isErr); progEl.classList.remove('continue'); progEl.onclick = null; progEl.style.cursor = ''; }; const setProgContinue = (msg, onClick) => { if (!progEl) return; progEl.textContent = msg; progEl.classList.remove('err'); progEl.classList.add('continue'); progEl.style.cursor = 'pointer'; progEl.onclick = () => { onClick(); }; }; // flush the SX verifications deferred during a batch (decoupled from the import) function flushDeferredVerify() { const toVerify = [..._deferredVerify]; _deferredVerify = new Set(); toVerify.forEach(i => { const t = RELEASE.tracks[i]; if (t && isValidIsrc(t.pending)) enqueueVerify(i, t.pending); }); } let _stream = null; // current import: { label, albumId, fetcher, cursor, counts } async function runStreamingSource(label, albumId, fetcher, resume) { if (!resume || !_stream) { _stream = { label, albumId, fetcher, cursor: 0, counts: { filled: 0, already: 0, skipped: 0, unmatched: 0 } }; } const st = _stream, counts = st.counts; setProg(label + ': starting…'); // defer SoundExchange verification so its requests don't compete with the import _deferVerify = true; _deferredVerify = new Set(); let res; try { res = await fetcher(albumId, (d, n) => setProg(n ? (label + ' ' + d + '/' + n) : (label + ': starting…')), s => { counts[mapOneToTrack(s, label)]++; }, // ← fill each ISRC as it's fetched st.cursor); } catch (e) { Log.err(label + ' failed: ' + errText(e)); setProg('⚠ ' + label + ' failed — see Log', true); _deferVerify = false; _deferredVerify = new Set(); return; } finally { _deferVerify = false; } flushDeferredVerify(); // verify this batch's rows now, decoupled from the import // #285: the provider just used to match all tracks becomes the per-track default // (no-op for non-per-track providers like Spotify, and when already selected). const _provKey = (label || '').toLowerCase(); if (trackProv !== _provKey) setTrackProvider(_provKey); const total = (res && res.total != null) ? res.total : st.cursor; const next = (res && res.next != null) ? res.next : null; const parts = [counts.filled + ' filled']; if (counts.already) parts.push(counts.already + ' already present'); if (counts.skipped) parts.push(counts.skipped + ' already entered'); if (counts.unmatched) parts.push(counts.unmatched + ' unmatched'); if (next != null) { // more tracks remain — pause so we don't spam the source; click to continue st.cursor = next; const remaining = total - next; Log.info(label + ': ' + next + '/' + total + ' so far (' + parts.join(', ') + ') — paused to avoid spamming ' + label); setProgContinue(label + ' ' + next + '/' + total + ' — click to fetch the next ' + Math.min(STREAM_BATCH_LIMIT, remaining), () => runStreamingSource(label, albumId, fetcher, true)); } else { Log.info(label + ' done — ' + parts.join(', ')); try { setProg(label + ' done — ' + parts.join(' · ')); } catch (e) { Log.warn(label + ': imported OK, but a UI update hiccuped: ' + errText(e)); } } } // Run a provider's import using its in-MB album id, falling back to the URL // Platform Check found (#180 — a provider button is only shown when one of // those exists, so this resolves unless the link vanished mid-session). async function runProvider(source, mbId, fetcher, btnSel) { const id = providerAlbumId(source, mbId); if (!id) { Log.warn(source + ': no ' + source + ' link on this release'); return; } const btn = modal.querySelector(btnSel); btn.disabled = true; Log.info(source + ': importing album ' + id); try { await runStreamingSource(source, id, fetcher); } finally { btn.disabled = false; } // always re-enable, even if something throws } async function runDeezer() { return runProvider('Deezer', RELEASE.deezerId, fetchDeezer, '#ii-dz-all'); } async function runSpotify() { return runProvider('Spotify', RELEASE.spotifyId, fetchSpotify, '#ii-sp-all'); } async function runBeatport() { return runProvider('Beatport', RELEASE.beatportId, fetchBeatport, '#ii-bp-all'); } async function runTidal() { return runProvider('Tidal', RELEASE.tidalId, fetchTidal, '#ii-td-all'); } async function runVolumo() { return runProvider('Volumo', RELEASE.volumoId, fetchVolumo, '#ii-vo-all'); } async function runHDtracks() { return runProvider('HDtracks', RELEASE.hdtracksId, fetchHDtracks, '#ii-hd-all'); } // Map a source label to its fetcher (used by the unified URL-paste import). function fetcherFor(source) { return source === 'Deezer' ? fetchDeezer : source === 'Spotify' ? fetchSpotify : source === 'Beatport' ? fetchBeatport : source === 'Tidal' ? fetchTidal : source === 'Volumo' ? fetchVolumo : source === 'HDtracks' ? fetchHDtracks : null; } /* ── source links & the unified "paste a URL" control (#180) ── */ // Source name → SRC_ICON key / brand colour, for the URL-add detection feedback. const SRC_CODE = { Deezer: 'dz', Spotify: 'sp', Beatport: 'bp', Tidal: 'td', Volumo: 'vo', HDtracks: 'hd' }; const SRC_COLOR = { dz: '#ef5466', sp: '#1db954', bp: '#0a8754', td: '#1f2d3d', vo: '#7c4dff', hd: '#e63329' }; // If Platform Check (separate userscript) is on the page, read the URL it found // for this source from its sidebar anchor (#mb-online-). function platformCheckUrl(source) { const key = source.toLowerCase(); const a = document.getElementById('mb-online-' + key); if (!a) return null; // Platform Check not installed const href = a.getAttribute('href') || ''; if (!/^https?:\/\//.test(href)) return null; // nothing found yet ('#') // Skip low-quality matches: PC marks its row pc-st-match (good), pc-st-mismatch // (found but wrong — dimmed), or pc-st-notfound. Only trust a confident match (#180). const row = document.getElementById('row-' + key); if (row && !row.classList.contains('pc-st-match')) { // #211: a link PC withheld ONLY by its barcode/format link-confidence CAN still // be the right album for ISRC purposes — an ISRC identifies a recording, which is // independent of the release's barcode/format. PC demotes such a row to // pc-st-mismatch but flags it pc-blocked while leaving the content-match glyph // intact (✓ = found+counts, ? = found). // #314: but a barcode mismatch can also mean PC simply matched the WRONG release // (e.g. a 1-track Beatport single by a same-prefixed artist), so by DEFAULT we now // respect PC's confidence and reject these too. Only accept them when the user has // opted into "Ignore Platform Check link confidence". A genuine content mismatch (~) // or not-found (×) is always rejected. const glyph = ((document.getElementById('ico-' + key) || {}).textContent || '').trim(); const lenient = ignorePcConfidence() && row.classList.contains('pc-blocked') && (glyph === '✓' || glyph === '?'); if (!lenient) return null; } return parseStreamingId(source, href) ? href : null; // only if it parses to an album id } // Album id for a provider button: prefer the in-MB link, else fall back to the // URL Platform Check found (which is why the button is shown at all). function providerAlbumId(source, mbId) { if (mbId) return mbId; const pc = platformCheckUrl(source); return pc ? parseStreamingId(source, pc) : null; } // Detect which streaming platform a pasted URL belongs to (domain-based, so a // bare numeric id — ambiguous across platforms — is intentionally not matched). function detectSource(input) { const s = String(input || '').trim(); const mk = source => { const id = parseStreamingId(source, s); return id ? { source, code: SRC_CODE[source], id } : null; }; if (/deezer\.com/i.test(s)) return mk('Deezer'); if (/beatport\.com/i.test(s)) return mk('Beatport'); if (/tidal\.com/i.test(s)) return mk('Tidal'); if (/volumo\.com/i.test(s)) return mk('Volumo'); if (/hdtracks\.com/i.test(s)) return mk('HDtracks'); // Spotify intentionally NOT detected here: its import resolves the MB release // FROM the Spotify URL (ISRC Hunt), so a non-MB URL can't work (#180). It's // offered only as a provider button when the release has a Spotify MB link. return null; } // Live feedback: show the detected platform's icon (in brand colour) on the + // button, or reset to a plain + when nothing recognizable is typed. function reflectDetectedSource(value) { const btn = document.getElementById('ii-url-btn'); if (!btn) return; const d = detectSource(value); if (d) { btn.innerHTML = SRC_ICON[d.code] || '+'; btn.style.color = SRC_COLOR[d.code] || ''; btn.title = d.source + ' detected — press Enter to import its ISRCs'; } else { btn.textContent = '+'; btn.style.color = ''; btn.title = 'Paste a streaming URL (Deezer / Spotify / Beatport / Tidal / Volumo / HDtracks) — auto-detected and imported'; } } async function submitUrlAdd(value) { const v = String(value || '').trim(); const d = detectSource(v); if (!d) { if (/open\.spotify\.com|spotify:album:/i.test(v)) { toast('Spotify can only be imported from its MusicBrainz-linked album — use the Spotify button', 'err'); } else if (v) { toast('Unrecognized URL — paste a Deezer, Beatport, Tidal, Volumo or HDtracks album link', 'err'); Log.warn('URL import: unrecognized "' + v + '"'); } return; } Log.info(d.source + ': importing pasted album ' + d.id); await runStreamingSource(d.source, d.id, fetcherFor(d.source)); } function parseStreamingId(source, input) { const s = String(input || '').trim(); if (source === 'Deezer') { const m = s.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/); return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Beatport') { const m = s.match(/beatport\.com\/release\/[^/]+\/(\d+)/); return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Tidal') { const m = s.match(/(?:listen\.)?tidal\.com\/(?:browse\/)?album\/(\d+)/); return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'Volumo') { const m = s.match(/volumo\.com\/album\/(\d+)/); // id or leading ICPN in the canonical /album/{icpn}-{slug} return m ? m[1] : (/^\d+$/.test(s) ? s : null); } if (source === 'HDtracks') { // new API form #/album/<24-hex ObjectId>; legacy valbum_code= resolves // via barcode search. A bare 24-hex id or a bare 8+ digit barcode also work. let m = s.match(/hdtracks\.com\/(?:#\/)?album\/([a-f0-9]{24})/i); if (m) return m[1]; m = s.match(/[?&]valbum_code=(\d{8,})/i); if (m) return m[1]; return /^[a-f0-9]{24}$/i.test(s) ? s : (/^\d{8,}$/.test(s) ? s : null); } let m = s.match(/open\.spotify\.com\/album\/([A-Za-z0-9]+)/) || s.match(/spotify:album:([A-Za-z0-9]+)/); return m ? m[1] : (/^[A-Za-z0-9]{18,30}$/.test(s) ? s : null); } /* ── OAuth UI handlers ── */ async function exchangeAndFinish(code, how) { Log.info('OAuth: exchanging authorization code (' + how + ')'); try { await Auth.exchangeCode(code); refreshAuthState(); Log.info('OAuth: authorized (refresh token stored)'); toast('Authorized — you never need to do this again', 'ok'); } catch (e) { Log.err('OAuth exchange failed: ' + e.message); toast('Authorization failed: ' + e.message, 'err'); } } function onAuthorize() { Log.info('OAuth: opening authorize URL'); store.del('oauth_oob_code'); // not 'noopener' so the oob tab can close itself once it captures the code const w = window.open(Auth.authorizeUrl(), '_blank'); const ci = modal.querySelector('#ii-oauth-code'); if (ci) _setTimeout(() => ci.focus(), 100); // ready for a manual paste if the tab can't close let n = 0; const iv = _setInterval(() => { const oob = store.get('oauth_oob_code', null); if (oob && oob.code) { clearInterval(iv); store.del('oauth_oob_code'); try { w && w.close(); } catch (e) {} if (ci) ci.value = oob.code; // show it auto-filled, then exchange exchangeAndFinish(oob.code, 'auto-captured'); return; } if (++n > 300) clearInterval(iv); // stop polling after ~5 min }, 1000); } /* ── submit ── */ async function doSubmit() { const map = {}; let count = 0; const dupSet = highlightDuplicates(); // never submit an ISRC that's on >1 recording RELEASE.tracks.forEach(t => { const v = normalizeIsrc(t.pending); if (!v || !isValidIsrc(v) || !t.recId) return; if (t.existing.includes(v) || dupSet.has(v)) return; (map[t.recId] = map[t.recId] || []).push(v); count++; }); if (!count) { toast('Nothing valid to submit (duplicates are blocked)', 'err'); return; } if (!Auth.isAuthorized()) { togglePane('ii-setup-pane'); toast('Authorize first (⚙ Setup)', 'err'); return; } submitBtn.disabled = true; submitBtn.textContent = 'Submitting…'; const note = getEditNote(); Log.info('Submitting ' + count + ' ISRC(s) across ' + Object.keys(map).length + ' recording(s)', map); Log.info('Edit note: ' + note.replace(/\n/g, ' ')); try { await submitIsrcs(map, note); Log.info('Submit OK'); toast('Submitted ' + count + ' ISRC' + (count === 1 ? '' : 's') + ' ✓', 'ok'); // move submitted into "existing", clear pending RELEASE.tracks.forEach(t => { const v = normalizeIsrc(t.pending); if (v && map[t.recId] && map[t.recId].includes(v)) { t.existing.push(v); t.pending = ''; } }); renderTracks(); updateBtnStatus(); // no errors — close the editor (the ✓ toast lives on and stays visible) _setTimeout(closeModal, 800); } catch (e) { Log.err('Submit failed: ' + e.message); toast('Submit failed: ' + e.message, 'err'); } updateSummary(); // restores "Submit to MusicBrainz (N)" + disabled state } /* ── delete existing ISRCs (via the recording-edit website form + session cookie) ── */ async function doDelete() { const byRec = {}; // recId -> { idx, isrcs: [] } tbody.querySelectorAll('.ii-ex-del:checked').forEach(cb => { const tr = cb.closest('tr[data-idx]'); if (!tr) return; const idx = +tr.dataset.idx, t = RELEASE.tracks[idx]; if (!t.recId) return; (byRec[t.recId] = byRec[t.recId] || { idx, isrcs: [] }).isrcs.push(normalizeIsrc(cb.dataset.isrc)); }); const recs = Object.entries(byRec); const total = recs.reduce((n, [, v]) => n + v.isrcs.length, 0); if (!total) return; if (!Auth.isAuthorized()) { /* deletion uses the session cookie, not OAuth — no auth needed, but warn if not logged in is handled by the request itself */ } if (!confirm('Submit "Remove ISRC" edits for ' + total + ' ISRC' + (total === 1 ? '' : 's') + ' across ' + recs.length + ' recording' + (recs.length === 1 ? '' : 's') + '?\n\nUses your logged-in MusicBrainz session. Unlike additions, ISRC removals are NOT auto-applied — they go to the edit queue for voting, so the ISRCs stay listed (shown ⏳ pending) until the edits pass. Track them under 🕓 My ISRC edits.')) return; const note = getRemovalNote(recs, total); const btn = modal.querySelector('#ii-delete'); btn.disabled = true; Log.info('Submitting Remove-ISRC edits for ' + total + ' ISRC(s) across ' + recs.length + ' recording(s)'); let ok = 0, fail = 0; for (const [recId, info] of recs) { progEl.textContent = 'Submitting removal for ' + recId.slice(0, 8) + '…'; try { await removeIsrcsFromRecording(recId, info.isrcs, note); // mark pending (the edit is queued; don't drop from `existing` — it's still on the recording) const t = RELEASE.tracks[info.idx]; t.pendingRemoval = (t.pendingRemoval || []).concat(info.isrcs); recordPendingRemoval(recId, info.isrcs); // remember across reloads (still pending in MB) ok += info.isrcs.length; Log.info('Submitted Remove-ISRC for ' + info.isrcs.join(', ') + ' (recording ' + recId + ') — pending'); } catch (e) { fail += info.isrcs.length; Log.err('Remove from recording ' + recId + ' failed: ' + e.message); } await sleep(700); } renderTracks(); refreshDeleteBtn(); progEl.textContent = ok + ' removal edit(s) submitted' + (fail ? ', ' + fail + ' failed' : ''); toast(ok + ' Remove-ISRC edit' + (ok === 1 ? '' : 's') + ' submitted (pending in the edit queue)' + (fail ? ' · ' + fail + ' failed (see Log)' : ''), fail ? 'err' : 'ok'); } function decodeHtmlEntities(s) { return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/�?39;/g, "'").replace(/'/gi, "'"); } // The recording-edit form is React-rendered, so the static HTML only carries a few // plain inputs + the field data embedded as JSON for hydration. We reconstruct the // full `edit-recording.*` POST from BOTH: the JSON field leaves (artist credit with // numeric IDs, name, isrcs.N.value) and the static inputs (comment, length, etc.), // marking the target ISRCs `isrcs.N.removed=1` (omission alone does NOT remove — // MBS-13969). No CSRF token field is used; the session cookie authorises. WS2-verified. async function removeIsrcsFromRecording(recId, isrcsToRemove, note) { const editUrl = MB_ROOT + '/recording/' + recId + '/edit'; const gr = await gmGet(editUrl, { 'Accept': 'text/html' }); if (gr.status !== 200) throw new Error('GET form ' + gr.status + (gr.status === 401 || gr.status === 403 ? ' (are you logged into MusicBrainz?)' : '')); const html = gr.responseText; const rm = new Set(isrcsToRemove.map(normalizeIsrc)); const params = new URLSearchParams(); const seen = new Set(); const add = (n, v) => { params.append(n, v); seen.add(n); }; // 1) JSON-hydrated field leaves (flat objects carrying html_name + value) const leaves = new Map(); for (const m of html.matchAll(/\{[^{}]*"html_name":"(edit-recording\.[^"]+)"[^{}]*\}/g)) { const vm = m[0].match(/"value":((?:"(?:[^"\\]|\\.)*")|true|false|null|-?\d+(?:\.\d+)?)/); if (vm) { try { leaves.set(m[1], JSON.parse(vm[1])); } catch (e) {} } } // 2) static plain inputs (comment, length, video, make_votable, …) const formM = html.match(/]*class="edit-recording"[\s\S]*?<\/form>/i); const formHtml = formM ? formM[0] : html; const statics = new Map(); for (const m of formHtml.matchAll(/<(input|textarea|select)\b([^>]*)>/gi)) { const a = m[2]; const nm = (a.match(/name="([^"]*)"/) || [])[1]; if (!nm || !/^edit-recording\./.test(nm)) continue; statics.set(nm, { type: (a.match(/type="([^"]*)"/) || [])[1] || (m[1].toLowerCase() === 'textarea' ? 'textarea' : 'text'), value: decodeHtmlEntities((a.match(/value="([^"]*)"/) || [])[1] || ''), checked: /\bchecked\b/i.test(a), }); } // ISRC entries: prefer the .value leaves; fall back to the static isrcs.N aliases const isrcEntries = []; for (const [n, v] of leaves) { const mi = n.match(/\.isrcs\.(\d+)\.value$/); if (mi) isrcEntries.push({ idx: +mi[1], value: String(v) }); } if (!isrcEntries.length) for (const [n, s] of statics) { const mi = n.match(/\.isrcs\.(\d+)$/); if (mi && s.value) isrcEntries.push({ idx: +mi[1], value: s.value }); } if (!isrcEntries.length) throw new Error('no ISRC fields in the edit form (already removed?)'); // name + artist credit (numeric IDs) from the JSON leaves for (const [n, v] of leaves) { if (/\.isrcs\./.test(n)) continue; if (v === false) continue; add(n, v === true ? '1' : String(v)); } // comment / length / other plain fields from the static inputs (omit unchecked checkboxes + the isrcs alias + edit_note) for (const [n, s] of statics) { if (seen.has(n) || /\.isrcs\.\d+$/.test(n) || /\.edit_note$/.test(n)) continue; if (s.type === 'checkbox') { if (s.checked) add(n, s.value || '1'); continue; } add(n, s.value); } // ISRCs: every existing value, with removed=1 on the targets isrcEntries.forEach(e => { add('edit-recording.isrcs.' + e.idx + '.value', e.value); if (rm.has(normalizeIsrc(e.value))) add('edit-recording.isrcs.' + e.idx + '.removed', '1'); }); add('edit-recording.edit_note', note); Log.info('POST ' + shortUrl(editUrl) + ' (' + [...params.keys()].length + ' fields, removing ' + [...rm].join(',') + ')'); const pr = await gmPost(editUrl, params.toString(), { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'text/html', 'Referer': editUrl, 'Origin': MB_ROOT, }); if (pr.status >= 400) throw new Error('POST ' + pr.status + (pr.status === 401 || pr.status === 403 ? ' (are you logged into MusicBrainz?)' : '')); // A created edit redirects away from /edit to the recording page. A validation // error re-renders the form (stays on /edit, still has the edit-recording inputs). // NOTE: "Remove ISRC" is a normal (non-auto) edit — it enters the edit queue, so the // ISRC stays visible in WS2 until the edit is applied. So we can't verify by re-reading. const finalUrl = pr.finalUrl || ''; const reRendered = /\/edit\/?(?:[?#]|$)/.test(finalUrl) || /name="edit-recording\.name"/.test(pr.responseText || ''); if (reRendered) throw new Error('edit form returned an error (nothing submitted)'); } /* ═══════════════════════════════════════════════════════════════════════ PAGE BUTTON ═══════════════════════════════════════════════════════════════════════ */ const btn = document.createElement('button'); btn.id = 'ii-btn'; btn.type = 'button'; btn.innerHTML = // ISRC Scout's own radar/target logo (monochrome via currentColor so it reads // white on the purple/pink button), replacing the generic magnifying glass. '' + 'ISRC '; btn.addEventListener('click', openModal); function injectButton() { const h1 = document.querySelector('h1'); if (!h1) return false; if (document.getElementById('ii-btn')) return true; h1.appendChild(btn); updateBtnStatus(); // in case the release already loaded before the button injected return true; } // Only the release *overview* page (`/release/`) — not its subpages // (/edit, /edit-relationships, /aliases, /tags, …) which also match `release/*`. const IS_OVERVIEW = /^\/release\/[a-f0-9-]{36}\/?$/.test(location.pathname); whenDomReady(() => { if (!IS_OVERVIEW) return; if (!injectButton()) { const obs = new MutationObserver(() => { if (injectButton()) obs.disconnect(); }); obs.observe(document.documentElement, { childList: true, subtree: true }); } }); function updateBtnStatus() { const statusEl = document.getElementById('ii-btn-status'); if (!statusEl || !RELEASE) return; let total = RELEASE.tracks.length, missing = 0; RELEASE.tracks.forEach(t => { if (!t.existing.length) missing++; }); if (missing === 0) { statusEl.textContent = '✓ ' + total + '/' + total; btn.classList.remove('has-missing'); } else { statusEl.textContent = '⚠ ' + (total - missing) + '/' + total; btn.classList.add('has-missing'); btn.title = missing + ' track' + (missing > 1 ? 's' : '') + ' missing ISRC'; } } // initial status fetch (also primes RELEASE for the modal) — overview page only if (IS_OVERVIEW) { fetchRelease().then(updateBtnStatus).catch(() => { const s = document.getElementById('ii-btn-status'); if (s) s.textContent = '?'; }); } })(); }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== mammoth (@run-at document-idle) =========================================== (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Mammoth*","namespace":"https://musicbrainz.org/","version":"2026.7.3","description":"Edit-note memory for MusicBrainz: auto-remembers your last edit notes and lets you save reusable ones, recalling them from a compact panel beside the edit-note field on every edit form. A nicer replacement for Elephant Editor.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48dGV4dCB4PSI2NCIgeT0iNjgiIGZvbnQtc2l6ZT0iMTA0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCI+8J+mozwvdGV4dD48L3N2Zz4="}) }) : { script: {"name":"Mammoth*","namespace":"https://musicbrainz.org/","version":"2026.7.3","description":"Edit-note memory for MusicBrainz: auto-remembers your last edit notes and lets you save reusable ones, recalling them from a compact panel beside the edit-note field on every edit form. A nicer replacement for Elephant Editor.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48dGV4dCB4PSI2NCIgeT0iNjgiIGZvbnQtc2l6ZT0iMTA0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCI+8J+mozwvdGV4dD48L3N2Zz4="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ // // Mammoth puts a compact saved-notes panel to the RIGHT of MusicBrainz's native // Edit note field (textarea.edit-note), which appears on every edit form, and // widens that (centered) field to make room. // // - AUTO-HISTORY: remembers the last N edit notes you submit (default 10, deduped). // - SAVED notes: ★ favourite (sorts to top), drag (⠿) to reorder, 🗑 delete. // One line each (full text on hover). // - INSERT: a click applies your default action (append or replace, see ⚙); // right-click does the other. Ctrl/⌘ + ↑/↓ cycles saved notes, replacing the // field. Append skips a line already present. Never auto-overwrites blindly, // so it won't clobber notes Apollo / Credit Hoarder / Platform Check write. // - BABY MAMMOTHS (⚙ "Show mammoth babies"): the same save/reuse idea on other // controls — catalog number, label, artist, status, language… A small 🦣 pin // on each field recalls values you've saved for it; ★ pins one as an always- // visible button under the field; one entry can be the default (auto-fills an // empty field). Targets: a built-in release-editor set + any element another // script tags class="mmth-pin". Stored separately (mammoth-fields:data). (function () { 'use strict'; const KEY = 'mammoth:data'; const SKEY = 'mammoth:settings'; const DEFAULTS = { historySize: 10, hideHelp: false, defaultInsert: 'replace', visibleRows: 6, sideWidth: 300, appendNewline: true, minimized: false, showBabies: true, noteSort: 'manual', btnChars: 24, scopePerResource: false }; // defaultInsert: 'replace' | 'append'; noteSort: 'manual' | 'uses' | 'recent'; btnChars: pinned-button label length; scopePerResource: per-type note pools (#309) const VERSION = '2026.6.29'; // keep in sync with @version (fallback when GM_info is unavailable) const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md'; const SYNTAX_URL = 'https://musicbrainz.org/doc/Edit_Note'; const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; // #308: the 🦣 emoji (U+1F9A3) renders as a tofu box in Chrome on systems whose // emoji font lacks it (Firefox bundles its own, hence the inconsistency). Use a // self-contained vector mammoth everywhere the icon shows, so it's font-independent. const MAMMOTH_SVG = ''; // #309: optionally keep notes SEPARATE per edit-note entity type (release / // artist / recording / …), derived from the page URL — opt-in via the // "Scope per resource" setting. Off (default) = one shared pool ('all'). const ENTITY_TYPES = ['area', 'artist', 'event', 'genre', 'instrument', 'label', 'place', 'recording', 'release', 'release-group', 'series', 'work']; const GLOBAL_SCOPE = 'all'; function noteScope() { const seg = (location.pathname.split('/').filter(Boolean)[0] || '').toLowerCase(); return ENTITY_TYPES.includes(seg) ? seg : 'other'; } const SCOPE = noteScope(); const scopeLabel = s => s === 'other' ? 'other' : s.replace(/-/g, ' '); // Multi-scope store: { [scope]: { saved, history } }. Migrate the old flat // { saved, history } into the shared 'all' pool (scoping is off by default). function loadStore() { let raw; try { raw = JSON.parse(GM_getValue(KEY, '{}') || '{}'); } catch (e) { raw = {}; } if (!raw || typeof raw !== 'object') raw = {}; if (Array.isArray(raw.saved) || Array.isArray(raw.history)) raw = { [GLOBAL_SCOPE]: { saved: raw.saved || [], history: raw.history || [] } }; return raw; } const loadSet = () => { try { return Object.assign({}, DEFAULTS, JSON.parse(GM_getValue(SKEY, '{}') || '{}')); } catch (e) { return Object.assign({}, DEFAULTS); } }; const persistSet = () => { try { GM_setValue(SKEY, JSON.stringify(SET)); } catch (e) {} }; // quiet save (no re-render) const saveSet = () => { persistSet(); applyHelp(); render(); }; let SET = loadSet(); let STORE = loadStore(); const dataKey = () => SET.scopePerResource ? SCOPE : GLOBAL_SCOPE; let DATA; function useScope() { // (re)point DATA at the current pool (called on load + when the setting toggles) const k = dataKey(); DATA = STORE[k] || (STORE[k] = { saved: [], history: [] }); if (!Array.isArray(DATA.saved)) DATA.saved = []; if (!Array.isArray(DATA.history)) DATA.history = []; } useScope(); const saveData = () => { try { GM_setValue(KEY, JSON.stringify(STORE)); } catch (e) {} render(); }; const updateScopeChips = () => document.querySelectorAll('.mmth-scope').forEach(c => { c.style.display = SET.scopePerResource ? '' : 'none'; }); const uid = () => 'n' + Math.random().toString(36).slice(2, 9); const babyMammoths = createBabyMammoths(); // field-memory module (gated by SET.showBabies) // ── data ops ───────────────────────────────────────────────────────────────── function recordHistory(text) { text = (text || '').trim(); if (!text) return; DATA.history = DATA.history.filter(h => h.text !== text); DATA.history.unshift({ text, ts: Date.now() }); DATA.history = DATA.history.slice(0, Math.max(1, Math.min(50, SET.historySize | 0 || 10))); saveData(); } function addSaved(text) { text = (text || '').trim(); if (!text) return false; if (DATA.saved.some(s => s.text === text)) return false; DATA.saved.push({ id: uid(), text, ts: Date.now() }); saveData(); return true; } const removeSaved = id => { DATA.saved = DATA.saved.filter(s => s.id !== id); saveData(); }; const removeHistory = text => { DATA.history = DATA.history.filter(h => h.text !== text); saveData(); }; function reorder(srcId, tgtId, before) { if (srcId === tgtId) return; const a = DATA.saved, si = a.findIndex(s => s.id === srcId); if (si < 0) return; const [it] = a.splice(si, 1); let ti = a.findIndex(s => s.id === tgtId); if (ti < 0) { a.splice(si, 0, it); return; } a.splice(before ? ti : ti + 1, 0, it); saveData(); } // #304: scaling helpers for big note lists. const togglePinNote = id => { const s = DATA.saved.find(x => x.id === id); if (s) { s.pinned = !s.pinned; saveData(); } }; // record that a saved note was used (drives the "Most used" / "Recent" sort) function bumpUse(id) { const s = DATA.saved.find(x => x.id === id); if (!s) return; s.uses = (s.uses | 0) + 1; s.lastUsed = Date.now(); saveData(); } // display order for the Saved list — pinned never reorder the list (they get their // own quick-button bar); only the chosen sort mode reshuffles. Manual = stored order. function sortedSaved() { const a = DATA.saved.slice(); const mode = SET.noteSort || 'manual'; if (mode === 'uses') a.sort((x, y) => (y.uses | 0) - (x.uses | 0) || (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0)); else if (mode === 'recent') a.sort((x, y) => (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0)); return a; } // #304: parse pasted notes — each line a note, or (byBlock) blank-line-separated // blocks so multi-line notes survive. Blank lines are dropped either way, so a // readable (blank-line) export round-trips fine in "1 note per line" mode too. function parseNotes(text, byBlock) { const parts = byBlock ? String(text || '').split(/\r?\n[ \t]*\r?\n/) : String(text || '').split(/\r?\n/); return parts.map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean); } // add parsed notes to the edit-note saved list (dedup). Returns the added count. function addSavedNotes(notes) { let added = 0; const have = new Set(DATA.saved.map(s => s.text)); for (const t of notes) { if (have.has(t)) continue; have.add(t); DATA.saved.push({ id: uid(), text: t, ts: Date.now() }); added++; } if (added) saveData(); return added; } // ── insert (React-safe + undoable) ─────────────────────────────────────────── const NATIVE_SET = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; // Set the whole field value via the native edit pipeline so the change joins // the browser's undo stack — ctrl/⌘+Z restores the previous note (#226). // `execCommand` also fires a genuine `input` event, so the React-controlled // edit-note field (release editor) still updates. Falls back to the native // value setter + synthetic events if execCommand is unavailable or no-ops. function setValue(ta, val) { let ok = false; try { ta.focus(); ta.setSelectionRange(0, ta.value.length); // select all → replace as one undoable step ok = val ? document.execCommand('insertText', false, val) : document.execCommand('delete', false, null); if (ok && ta.value !== val) ok = false; // some engines return true but no-op } catch (e) { ok = false; } if (!ok) { NATIVE_SET.call(ta, val); ta.dispatchEvent(new Event('input', { bubbles: true })); } ta.dispatchEvent(new Event('change', { bubbles: true })); } // #304: set when Ctrl/⌘+, un-minimized the panel just to reach search — so we // re-minimize once a note is applied (or the search is cancelled). let restoredFromMin = false; const reMinIfRestored = () => { if (restoredFromMin) { restoredFromMin = false; setMinimized(true); } }; function applyNote(ta, text, replace) { const cur = ta.value || ''; if (!replace && cur.trim()) { // #212: don't append a note already in the field — match whole-field, a // blank-line-separated block, or a single line (handles multi-line notes). const norm = s => s.split('\n').map(l => l.replace(/[ \t]+/g, ' ').trim()).filter(Boolean).join('\n').trim(); const tN = norm(text); const cands = [cur, ...cur.split(/\n{2,}/), ...cur.split('\n')].map(norm); if (tN && cands.includes(tN)) { toast('Already in the note'); reMinIfRestored(); return; } } setValue(ta, (replace || !cur.trim()) ? text : cur.replace(/\s+$/, '') + (SET.appendNewline ? '\n\n' : '\n') + text); ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch (e) {} reMinIfRestored(); } // Toggle `marker` around the selection (GitHub-style): if the text is already // wrapped, strip the markers; otherwise add them. With no selection, act on the // word the caret is in (or just insert/remove an empty pair). function wrapSel(ta, marker) { const v = ta.value; const m = marker, ml = m.length; let s = ta.selectionStart ?? v.length, e = ta.selectionEnd ?? s; if (s === e) { let a = s, b = e; while (a > 0 && /\S/.test(v[a - 1])) a--; while (b < v.length && /\S/.test(v[b])) b++; if (b > a) { s = a; e = b; } } const sel = v.slice(s, e); const reselect = (from, len) => { ta.focus(); try { ta.setSelectionRange(from, from + len); } catch (x) {} }; // #326: already wrapped — markers INSIDE the selection → unwrap if (sel.length >= 2 * ml && sel.startsWith(m) && sel.endsWith(m)) { const inner = sel.slice(ml, sel.length - ml); setValue(ta, v.slice(0, s) + inner + v.slice(e)); reselect(s, inner.length); return; } // #326: already wrapped — markers just OUTSIDE the selection → unwrap if (v.slice(s - ml, s) === m && v.slice(e, e + ml) === m) { setValue(ta, v.slice(0, s - ml) + sel + v.slice(e + ml)); reselect(s - ml, sel.length); return; } // otherwise wrap setValue(ta, v.slice(0, s) + m + sel + m + v.slice(e)); if (sel) reselect(s + ml, sel.length); else { const caret = s + ml; ta.focus(); try { ta.setSelectionRange(caret, caret); } catch (x) {} } } // ── capture on submit ──────────────────────────────────────────────────────── const captureNote = () => document.querySelectorAll('textarea.edit-note').forEach(ta => recordHistory(ta.value)); document.addEventListener('submit', captureNote, true); document.addEventListener('click', e => { const b = e.target.closest && e.target.closest('button, input[type="submit"]'); if (!b) return; if (b.closest('.mmth-side, .mmth-pop')) return; // our own buttons aren't edit submits const t = (b.textContent || b.value || '').trim().toLowerCase(); if (b.id === 'enter-edit' || /^(enter edit|submit|add edit|save)/.test(t) || (b.classList && b.classList.contains('submit'))) captureNote(); }, true); // ── styles ─────────────────────────────────────────────────────────────────── const css = ` fieldset.editnote, .editnote { max-width:100% !important; } /* On the release editor the edit note sits in a 540px .half-width column whose only sibling is the changes warning (not a guidelines column), so give that column the full form width when Mammoth is active. The :has() selector scopes it to our column. min-width:0 lets the editnote fieldset (min-content by default) take that width so margin:auto can center it. SCOPED to the release editor (body.mmth-reledit): on entity-creation/edit pages (artist/label/… /create, /edit) the .editnote sits in a genuine half-width column beside the guidelines, and widening it to 100% broke that two-column layout — visibly so alongside scripts that write into it (#268). */ .mmth-reledit .half-width:has(> .editnote.mmth-on), .mmth-reledit .col:has(> .editnote.mmth-on) { width:100% !important; max-width:100% !important; } .editnote.mmth-on { width:100% !important; max-width:100% !important; min-width:0 !important; box-sizing:border-box; } .editnote.mmth-on > .row { width:100% !important; box-sizing:border-box; } /* hide only the redundant inline "Edit note:" label next to the field — keep the section header (the fieldset's legend) visible (#212). */ .editnote.mmth-on > .row > label[for] { display:none !important; } /* align-items:flex-start (not stretch) so the panel keeps its own bounded height. Stretch made the panel grow to match the field, and the #229 floor (field min-height = panel height) then fed back through it — each pass added the field's padding/border, inflating both without bound (#245). The field is still floored to the panel via JS, so it's never shorter. */ .mmth-wrap { display:flex; gap:0; align-items:flex-start; width:100%; max-width:1040px; margin:6px auto; box-sizing:border-box; position:relative; } /* #304: field column = textarea + pinned-quick-buttons bar, stacked */ .mmth-fieldcol { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; } .mmth-fieldcol > textarea.edit-note { width:100% !important; min-width:0; box-sizing:border-box; } /* #288/#290: foreign edit-note error/warning

      s that MB (and other scripts) insert next to the textarea are RELOCATED out of the flex row by JS (see relocateForeign) so they never sit beside the field. No flex-wrap here — that made the panel itself wrap below the field in a narrow column (#290). */ /* Minimized mode (#265): the panel collapses to a small Mammoth badge in the field's top-right corner; the field takes the full width and the panel floats in only on hover. No width/height coupling, so it can't feed the #245 loop. */ .mmth-min .mmth-vsep { display:none !important; } .mmth-min > .mmth-side { position:absolute; top:30px; right:2px; z-index:60; display:none; box-shadow:0 8px 26px rgba(20,50,35,.22); } .mmth-min > .mmth-side.mmth-open { display:flex; } .mmth-badge { display:none; position:absolute; top:4px; right:5px; z-index:61; width:25px; height:25px; align-items:center; justify-content:center; cursor:pointer; border:1px solid #cfd9d3; border-radius:7px; background:#fbfdfc; box-shadow:0 1px 3px rgba(0,0,0,.12); font-size:15px; line-height:1; user-select:none; } .mmth-badge:hover { background:#eaf5ee; border-color:#5aa67e; } .mmth-min > .mmth-badge { display:flex; } .mmth-vsep { flex:none; width:9px; align-self:flex-start; cursor:col-resize; position:relative; } /* #304: height synced to the field (not the field+pinbar column) */ .mmth-vsep::before { content:''; position:absolute; left:4px; top:0; bottom:0; width:1px; background:#d7e0db; } .mmth-vsep:hover::before, .mmth-vsep.mmth-dragv::before { background:#5aa67e; width:3px; left:3px; } .mmth-hidehelp > p { display:none !important; } .mmth-side { flex:0 0 300px; max-width:300px; display:flex; flex-direction:column; border:1px solid #cfd9d3; border-radius:8px; background:#fbfdfc; font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; overflow:hidden; } .mmth-ft { display:flex; align-items:center; gap:2px; padding:3px 5px; border-bottom:1px solid #e7eee9; background:#f1f6f3; } .mmth-fb { cursor:pointer; border:none; background:none; font-size:13px; line-height:1; padding:3px 6px; border-radius:5px; color:#566; } .mmth-fb:hover { background:#dcefe2; } .mmth-fb.on { background:#cfe9d8; color:#1f5c3d; } .mmth-fb.mmth-spacer { flex:1; pointer-events:none; } .mmth-fb.mmth-grp { margin-left:10px; } /* #309: per-type scope indicator (release / artist / recording / …) */ .mmth-scope { align-self:center; flex:none; font-size:10px; color:#6f7d75; background:#eef3f0; border:1px solid #dde7e1; border-radius:9px; padding:1px 7px; margin-right:4px; white-space:nowrap; max-width:72px; overflow:hidden; text-overflow:ellipsis; } .mmth-ft { flex-wrap:wrap; } /* #304: never clip toolbar buttons — wrap as a last resort below the min width */ /* #304: opt-in search row (search box + count) between the toolbar and the list */ .mmth-filterrow { display:flex; align-items:center; gap:5px; padding:3px 5px; border-bottom:1px solid #e7eee9; background:#f7faf8; } /* width:auto !important defends against MB's form CSS (#content input), which otherwise forces a fixed width and squashes the flex layout (#304) */ /* display:block !important beats MB's ".add-edit-note input { display:none }" (which otherwise hides our search box on /edit and /edits pages); width:auto !important beats MB's fixed input width. #304 */ .mmth-filter { display:block !important; flex:1 1 auto; min-width:0; width:auto !important; box-sizing:border-box; border:1px solid #d7e0db; border-radius:5px; padding:2px 6px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmth-filter:focus { outline:none; border-color:#5aa67e; } .mmth-count { flex:none; font-size:11px; color:#8a978f; white-space:nowrap; } /* #304: pinned saved notes as quick-insert buttons BELOW the field (like baby-field bars) */ .mmth-pinbar { display:flex; flex-wrap:wrap; gap:5px; margin:5px 0 2px; } .mmth-segb { border:1px solid #cfd9d3 !important; background:#fbfdfc; border-radius:7px; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:#27483a; cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); } .mmth-segb:hover { background:#eaf5ee; border-color:#5aa67e; } .mmth-row.mmth-pinned .mmth-txt::before { content:'★'; color:#c2a93e; margin-right:4px; font-size:10px; vertical-align:1px; } /* #304: tabbed config window (Settings / Import-Export) */ .mmth-cfgtabs { display:flex; gap:4px; margin:0 0 8px; border-bottom:1px solid #e7eee9; } .mmth-cfgtab { border:none; background:none; padding:4px 9px; font-size:12px; color:#566; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; } .mmth-cfgtab:hover { color:#1f5c3d; } .mmth-cfgtab.on { color:#1f5c3d; border-bottom-color:#5aa67e; font-weight:600; } .mmth-cfgsec { font-weight:600; font-size:11px; color:#6f7d75; text-transform:uppercase; letter-spacing:.04em; margin:10px 0 4px; padding-bottom:2px; border-bottom:1px solid #eef3f0; } .mmth-cfgpane > .mmth-cfgsec:first-child { margin-top:2px; } /* #304: import/export pane (the pane IS the flex column — no inner .mmth-io wrapper) */ .mmth-cfgpane[data-pane="io"] { display:flex; flex-direction:column; gap:8px; } .mmth-io-modes { display:flex; flex-flow:row wrap; gap:6px 18px; font-size:12px; align-items:center; } .mmth-io-modes label { margin:0; display:inline-flex; align-items:center; gap:6px; } /* width/height !important to beat MB's #content textarea form CSS (#304) */ .mmth-io-ta { width:100% !important; box-sizing:border-box; height:160px !important; min-height:120px; resize:vertical; border:1px solid #d7e0db; border-radius:5px; padding:6px 8px; font:13px/1.55 -apple-system,Segoe UI,Arial,sans-serif; } .mmth-io-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } .mmth-io-btn { cursor:pointer; border:1px solid #cfd9d3; background:#fff; border-radius:5px; padding:2px 8px; font-size:12px; color:#27483a; } .mmth-io-btn:hover { background:#eaf5ee; border-color:#5aa67e; } .mmth-io-msg { font-size:11px; color:#8a978f; } .mmth-list { flex:1 1 auto; overflow-y:auto; scrollbar-width:none; } .mmth-list::-webkit-scrollbar { width:0; height:0; } .mmth-row { display:flex; align-items:center; gap:4px; padding:4px 6px; border-top:1px solid #f0f4f2; cursor:pointer; } .mmth-row:first-child { border-top:none; } .mmth-row:hover { background:#eaf5ee; } .mmth-row.mmth-cyc { background:#d9efe1; } .mmth-row.mmth-drop-before { box-shadow:inset 0 2px 0 #2c7a51; } .mmth-row.mmth-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; } .mmth-row.mmth-dragging { opacity:.45; } .mmth-grab { flex:none; cursor:grab; color:#b7c2bb; font-size:12px; user-select:none; opacity:0; } .mmth-row:hover .mmth-grab { opacity:1; } .mmth-grab:active { cursor:grabbing; } .mmth-txt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#293330; } .mmth-rowacts { flex:none; display:flex; gap:1px; opacity:0; } .mmth-row:hover .mmth-rowacts { opacity:1; } .mmth-ra { cursor:pointer; border:none; background:none; color:#7d8a82; font-size:11px; line-height:1; padding:1px 2px; border-radius:3px; } .mmth-ra:hover { background:#cfe9d8; color:#1f5c3d; } .mmth-empty { padding:12px 8px; color:#9aa6a0; font-style:italic; text-align:center; } .mmth-pop { position:fixed; z-index:99999; background:#fff; border:1px solid #c7d3cc; border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); padding:10px 12px; font:13px/1.45 -apple-system,Segoe UI,Arial,sans-serif; color:#222; width:280px; } .mmth-cfg { width:360px; } /* #304: wider config window so the Import/Export radios sit on one row + a roomier textarea */ .mmth-pop h4 { margin:-10px -12px 8px; padding:6px 10px; font-size:13px; display:flex; align-items:center; gap:6px; background:#f1f6f3; border-bottom:1px solid #e7eee9; border-radius:8px 8px 0 0; } .mmth-tip { color:#8a978f; font-size:11px; margin:0 0 4px 22px; } .mmth-pop h4 .mmth-ver { color:#8a978f; font-weight:400; font-size:11px; } .mmth-pop h4 a { margin-left:auto; font-size:11px; color:#2c7a51; text-decoration:none; font-weight:600; } .mmth-pop h4 .mmth-h4ic { display:inline-flex; width:18px; height:18px; flex:none; } /* #308 vector mammoth */ .mmth-badge svg { width:19px; height:19px; } /* #308 vector mammoth */ .mmth-pop label { display:flex; align-items:center; gap:6px; margin:5px 0; cursor:pointer; } .mmth-pop input[type="number"] { width:46px; border:1px solid #d7e0db; border-radius:4px; padding:1px 4px; } .mmth-pop select { border:1px solid #d7e0db; border-radius:4px; padding:1px 4px; } .mmth-pop code { background:#f1f4f2; border-radius:3px; padding:0 3px; font-size:12px; } .mmth-pop .mmth-syn { display:grid; grid-template-columns:auto 1fr; gap:3px 10px; margin:4px 0; } .mmth-pop .mmth-sub { font-weight:600; font-size:12px; margin:8px 0 2px; } .mmth-toast { position:fixed; z-index:100000; background:#2c3a33; color:#fff; padding:6px 12px; border-radius:6px; font:13px sans-serif; box-shadow:0 4px 14px rgba(0,0,0,.25); left:50%; top:14px; transform:translateX(-50%); } /* While an MB dialog/popover is open, hide Mammoth's panel (incl. the minimized badge) and its popovers — MB's dialogs carry no z-index, so our UI would float on top of them. visibility keeps the docked panel's layout (no jump). #313 */ html.mmthf-dialog .mmth-side, html.mmthf-dialog .mmth-badge, html.mmthf-dialog .mmth-pop { visibility:hidden !important; pointer-events:none !important; } `; (function () { const s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); })(); // #268: only the release editor wants its edit-note .half-width column widened to // full width. Tag it so the widening rule above is scoped to it and never disturbs // the two-column layout of entity create/edit pages (artist, label, work, …). if (/^\/release\/(?:add|[0-9a-f-]{36}\/edit)(?:[/?#]|$)/.test(location.pathname)) document.documentElement.classList.add('mmth-reledit'); // Show a toast near where the user is acting (the Mammoth panel / button they just // clicked) instead of pinned to the top of the page, which reads as unrelated (#268 // follow-up). Falls back to top-centre when there's no recent Mammoth interaction. let _toastPt = null; document.addEventListener('pointerdown', e => { const t = e.target.closest && e.target.closest('.mmth-side, .mmth-pop, .mmth-wrap, .mmth-badge'); if (t) _toastPt = { x: e.clientX, y: e.clientY }; }, true); function toast(msg) { const t = document.createElement('div'); t.className = 'mmth-toast'; t.textContent = msg; document.body.appendChild(t); if (_toastPt) { // anchor just above the click point, clamped into the viewport const w = t.offsetWidth, h = t.offsetHeight; const left = Math.max(6, Math.min(window.innerWidth - w - 6, _toastPt.x - w / 2)); const top = Math.max(6, Math.min(window.innerHeight - h - 6, _toastPt.y - h - 10)); t.style.left = left + 'px'; t.style.top = top + 'px'; t.style.transform = 'none'; } setTimeout(() => t.remove(), 1500); } // #305: dismiss our popovers on the outside *click* (capture), NOT mousedown. // Tearing the popover down on mousedown removed what was under the cursor, so the // trailing mouseup/click fell through to whatever sat beneath it (e.g. Apollo's // cover-art thumbnail under the field) and activated it — "cover art opened when // selecting a label". Handling the click lets us swallow that exact event so the // dismiss can never double as activating something below. // ── popovers (settings + syntax help) ──────────────────────────────────────── let pop = null; function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('click', onPopDown, true); document.removeEventListener('keydown', onPopKey, true); } } function onPopDown(e) { if (pop && !pop.contains(e.target) && !e.target.closest('.mmth-pop-anchor')) { e.preventDefault(); e.stopPropagation(); closePop(); } } function onPopKey(e) { if (pop && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePop(); } } // #347: ESC closes the settings / syntax popup function placePop(p, anchor) { const W = p.offsetWidth, H = p.offsetHeight, vw = window.innerWidth, vh = window.innerHeight; const r = anchor && anchor.getBoundingClientRect(); if (!r || (!r.width && !r.height)) { // anchor gone/hidden (e.g. a baby pin that re-hid) — sensible centred fallback p.style.left = Math.max(6, Math.round((vw - W) / 2)) + 'px'; p.style.top = '70px'; } else { // #304: open just BELOW the trigger and clamp into the viewport, so the popover // stays next to what opened it instead of jumping above (which clamped to the // top-left corner when opened from a baby pin low on a tall page). const left = Math.max(6, Math.min(vw - W - 6, r.left)); let top = r.bottom + 4; // below the trigger if (top + H > vh - 6) { // would overflow the bottom const above = r.top - H - 6; // try above if it fits, else clamp top = above >= 6 ? above : (vh - H - 6); } p.style.left = left + 'px'; p.style.top = Math.max(6, top) + 'px'; } setTimeout(() => { document.addEventListener('click', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); } // #304: drag the popover by its header (so it can be moved out of the way). function makeDraggable(popEl, handle) { handle.classList.add('mmth-draghandle'); let sx = 0, sy = 0, sl = 0, st = 0, on = false; const move = e => { if (!on) return; let l = sl + (e.clientX - sx), t = st + (e.clientY - sy); l = Math.max(6, Math.min(window.innerWidth - popEl.offsetWidth - 6, l)); t = Math.max(6, Math.min(window.innerHeight - popEl.offsetHeight - 6, t)); popEl.style.left = l + 'px'; popEl.style.top = t + 'px'; }; const up = () => { on = false; document.removeEventListener('mousemove', move, true); document.removeEventListener('mouseup', up, true); }; handle.addEventListener('mousedown', e => { if (e.target.closest('a, button, input, select, textarea')) return; // don't hijack the Help link etc. on = true; popEl.dataset.moved = '1'; const r = popEl.getBoundingClientRect(); sl = r.left; st = r.top; sx = e.clientX; sy = e.clientY; e.preventDefault(); document.addEventListener('mousemove', move, true); document.addEventListener('mouseup', up, true); }); } // #304/#309: tabbed config window — Settings + Import / Export. `io` lets the // caller scope import/export to a specific field (e.g. a baby field's values). function openSettings(anchor, tab, io) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop mmth-cfg'; p.innerHTML = `

      ${MAMMOTH_SVG} Mammoth v${scriptVersion()}? Help

      Edit note settings
      General
      `; document.body.appendChild(p); pop = p; makeDraggable(p, p.querySelector('h4')); // #304: movable config window // tab switching — only toggles the pane; position stays put (re-placing here made // the window jump because the two tabs differ in height). #304 const tabs = [...p.querySelectorAll('.mmth-cfgtab')], panes = [...p.querySelectorAll('.mmth-cfgpane')]; const showTab = name => { tabs.forEach(t => t.classList.toggle('on', t.dataset.tab === name)); panes.forEach(pn => { pn.style.display = pn.dataset.pane === name ? '' : 'none'; }); }; tabs.forEach(t => t.onclick = () => showTab(t.dataset.tab)); // ── Settings pane ── const scope = p.querySelector('.mmth-s-scope'); scope.checked = SET.scopePerResource === true; scope.onchange = () => { SET.scopePerResource = scope.checked; persistSet(); useScope(); updateScopeChips(); render(); }; const help = p.querySelector('.mmth-s-help'); help.checked = !!SET.hideHelp; const ins = p.querySelector('.mmth-s-ins'); ins.value = SET.defaultInsert; const nl = p.querySelector('.mmth-s-nl'); nl.checked = SET.appendNewline !== false; const sort = p.querySelector('.mmth-s-sort'); sort.value = SET.noteSort || 'manual'; const btnc = p.querySelector('.mmth-s-btnchars'); btnc.value = SET.btnChars || 24; const rows = p.querySelector('.mmth-s-rows'); rows.value = SET.visibleRows; const hist = p.querySelector('.mmth-s-hist'); hist.value = SET.historySize; help.onchange = () => { SET.hideHelp = help.checked; saveSet(); }; ins.onchange = () => { SET.defaultInsert = ins.value; saveSet(); }; nl.onchange = () => { SET.appendNewline = nl.checked; saveSet(); }; sort.onchange = () => { SET.noteSort = sort.value; persistSet(); render(); }; btnc.onchange = () => { SET.btnChars = Math.max(4, Math.min(80, parseInt(btnc.value, 10) || 24)); btnc.value = SET.btnChars; saveSet(); babyMammoths.relabel(); }; rows.onchange = () => { SET.visibleRows = Math.max(1, Math.min(30, parseInt(rows.value, 10) || 6)); rows.value = SET.visibleRows; saveSet(); }; hist.onchange = () => { SET.historySize = Math.max(1, Math.min(50, parseInt(hist.value, 10) || 10)); hist.value = SET.historySize; saveSet(); recordHistory(''); }; const babies = p.querySelector('.mmth-s-babies'); babies.checked = SET.showBabies !== false; babies.onchange = () => { SET.showBabies = babies.checked; persistSet(); babyMammoths.toggle(babies.checked); }; // ── Import / Export pane ── (#304/#309: scoped to `io` — edit-note notes by // default, or a specific field's values when opened from a baby field) const ctx = io || { items: () => DATA.saved.map(s => s.text), add: notes => addSavedNotes(notes), help: 'Import adds to your saved notes; Export copies them all to the clipboard.' }; const ioTa = p.querySelector('.mmth-io-ta'), ioMsg = p.querySelector('.mmth-io-msg'); const ioBlock = () => p.querySelector('input[name="mmth-iomode"]:checked').value === 'block'; p.querySelector('.mmth-io-help').textContent = ctx.help || ''; p.querySelector('.mmth-io-import').onclick = () => { const notes = parseNotes(ioTa.value, ioBlock()); if (!notes.length) { ioMsg.textContent = 'Paste some notes first'; return; } const added = ctx.add(notes); ioMsg.textContent = `Added ${added} of ${notes.length}` + (added < notes.length ? ' (rest were duplicates)' : ''); if (added) ioTa.value = ''; }; p.querySelector('.mmth-io-export').onclick = async () => { const items = ctx.items(); const text = items.join(ioBlock() ? '\n\n' : '\n'); ioTa.value = text; ioTa.focus(); ioTa.select(); let copied = false; try { await navigator.clipboard.writeText(text); copied = true; } catch (e) { try { copied = document.execCommand('copy'); } catch (x) {} } ioMsg.textContent = `${items.length} item(s)` + (copied ? ' — copied to clipboard' : ' — select & copy'); }; showTab(tab === 'io' ? 'io' : 'settings'); placePop(p, anchor); // position once; tab switches no longer move it } function openSyntax(anchor) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop'; p.innerHTML = `

      Edit-note syntaxdoc ↗

      ''italic''italic '''bold'''bold edit #123456link to an edit doc:Pageor [Page_Name] — wiki doc link
      URLs become links automatically. HTML is not supported.
      Shortcuts
      Ctrl/⌘ Bbold the selection / word Ctrl/⌘ Iitalicise the selection / word Ctrl/⌘ ↑/↓cycle saved notes
      `; document.body.appendChild(p); pop = p; placePop(p, anchor); } // ── sidebars (one per edit-note textarea) ──────────────────────────────────── const instances = []; function applyHelp() { document.querySelectorAll('.editnote').forEach(en => en.classList.toggle('mmth-hidehelp', !!SET.hideHelp)); } const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2; const clearMarks = host => host && host.querySelectorAll('.mmth-drop-before,.mmth-drop-after').forEach(r => r.classList.remove('mmth-drop-before', 'mmth-drop-after')); let _drag = null; const MIN_PANEL = 300; // #304: keep the toolbar (incl. ⚙) from clipping when narrow function setSideWidth(side, w) { w = Math.max(MIN_PANEL, Math.min(640, Math.round(w))); side.style.flex = '0 0 ' + w + 'px'; side.style.maxWidth = w + 'px'; return w; } // #263: never let the panel be wider than the field — cap it to half the row so // the ratio is at most 1:1 (was up to ~1:10 in a narrow Art Station modal). The // cap reads ONLY the wrap's width, which the container fixes and setting the // panel never changes, so this can't oscillate (unlike a field-width-based cap, // which would: shrinking the panel grows the field, re-raising the cap…). function capPanel(wrap, vsep, side) { if (SET.minimized) return; // panel is out of flow when minimized const row = wrap.clientWidth - (vsep ? vsep.offsetWidth : 0); if (!(row > 0)) return; const max = Math.floor(row / 2); const want = Math.max(MIN_PANEL, Math.min(SET.sideWidth || 300, max)); if (Math.round(side.getBoundingClientRect().width) !== want) { side.style.flex = '0 0 ' + want + 'px'; side.style.maxWidth = want + 'px'; } } // ── minimized mode (#265) ───────────────────────────────────────────────────── // A less-intrusive mode: the panel collapses to a small Mammoth badge in the // field's top-right corner and floats back in on hover. Persisted, so it stays // minimized across edit pages. WIDTH/position only — never touches the field's // height, so it can't reintroduce the #245 growth loop. function applyMinState(inst) { const wrap = inst.ta && inst.ta.closest('.mmth-wrap'); if (!wrap) return; const on = !!SET.minimized; wrap.classList.toggle('mmth-min', on); if (inst.minBtn) { inst.minBtn.textContent = on ? '⤢' : '–'; inst.minBtn.title = on ? 'Restore the panel' : 'Minimize to corner'; } if (on) { try { inst.ta.style.minHeight = ''; } catch (x) {} } // drop the panel-height floor — panel is out of flow now if (!on) { if (inst.unpin) inst.unpin(); if (inst.side) inst.side.classList.remove('mmth-open'); } } function setMinimized(on) { SET.minimized = !!on; persistSet(); instances.forEach(i => { applyMinState(i); if (i.recap) i.recap(); }); } // drag the separator to resize the panel vs. the field (persisted) function wireResize(vsep, side) { let startX = 0, startW = 0, on = false; vsep.addEventListener('pointerdown', e => { on = true; startX = e.clientX; startW = side.getBoundingClientRect().width; try { vsep.setPointerCapture(e.pointerId); } catch (x) {} vsep.classList.add('mmth-dragv'); document.body.style.userSelect = 'none'; e.preventDefault(); }); vsep.addEventListener('pointermove', e => { // panel is on the right → drag left widens it; cap at half the row (#263) if (!on) return; const wrap = side.parentNode, row = (wrap ? wrap.clientWidth : 0) - vsep.offsetWidth; const max = row > 0 ? Math.floor(row / 2) : 640; setSideWidth(side, Math.min(max, startW - (e.clientX - startX))); }); const end = e => { if (!on) return; on = false; vsep.classList.remove('mmth-dragv'); document.body.style.userSelect = ''; SET.sideWidth = setSideWidth(side, side.getBoundingClientRect().width); saveSet(); try { vsep.releasePointerCapture(e.pointerId); } catch (x) {} }; vsep.addEventListener('pointerup', end); vsep.addEventListener('pointercancel', end); } function buildSide(ta) { const side = document.createElement('div'); side.className = 'mmth-side'; setSideWidth(side, SET.sideWidth || 300); const ft = document.createElement('div'); ft.className = 'mmth-ft'; // toolbar ON TOP (#212) const filterRow = document.createElement('div'); filterRow.className = 'mmth-filterrow'; // #304 search (opt-in) const list = document.createElement('div'); list.className = 'mmth-list'; const pinbar = document.createElement('div'); pinbar.className = 'mmth-pinbar'; // #304 pinned quick-buttons — placed BELOW the field (in injectAll) side.appendChild(ft); side.appendChild(filterRow); side.appendChild(list); const inst = { ta, list, side, pinbar, filterRow, view: 'saved', cycId: null, filter: '', viewItems: [] }; instances.push(inst); // search box + N/total count (always shown) const fInput = document.createElement('input'); fInput.type = 'text'; fInput.className = 'mmth-filter'; fInput.placeholder = 'Search notes…'; fInput.addEventListener('input', () => { inst.filter = fInput.value; inst.cycId = null; renderInst(inst); }); fInput.addEventListener('keydown', e => { if (e.key === 'Escape') { fInput.value = ''; inst.filter = ''; inst.cycId = null; renderInst(inst); ta.focus(); reMinIfRestored(); return; } // #304: backing out re-minimizes if we'd auto-restored // #304: ↑/↓ move the highlighted match; Enter uses the highlighted one (or the first) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { const items = inst.viewItems || []; if (!items.length) return; e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; let i = items.findIndex(x => x.id === inst.cycId); if (i < 0) i = dir > 0 ? -1 : 0; i = (i + dir + items.length) % items.length; inst.cycId = items[i].id; renderInst(inst); const cur = inst.list.querySelector('.mmth-cyc'); // keep the selection visible within the list if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect(); if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top); else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); } return; } if (e.key === 'Enter') { e.preventDefault(); const items = inst.viewItems || []; const it = items.find(x => x.id === inst.cycId) || items[0]; if (it) { applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (inst.view === 'saved') bumpUse(it.id); } } }); const fCount = document.createElement('span'); fCount.className = 'mmth-count'; inst.countEl = fCount; filterRow.appendChild(fInput); filterRow.appendChild(fCount); inst.filterInput = fInput; const fb = (glyph, title, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-fb' + (cls ? ' ' + cls : ''); b.textContent = glyph; b.title = title; b.onclick = fn; return b; }; ft.appendChild(fb('+', 'Save current edit note', '', () => { const v = (ta.value || '').trim(); if (!v) return toast('Edit note is empty'); toast(addSaved(v) ? 'Saved' : 'Already saved'); })); const bSaved = fb('★', 'Saved notes', 'mmth-grp', () => { inst.view = 'saved'; renderInst(inst); }); const bHist = fb('🕘', 'History (last used)', '', () => { inst.view = 'history'; renderInst(inst); }); ft.appendChild(bSaved); ft.appendChild(bHist); ft.appendChild(fb('✕', 'Clear the edit note', 'mmth-grp', () => { setValue(ta, ''); ta.focus(); })); const sp = document.createElement('span'); sp.className = 'mmth-fb mmth-spacer'; ft.appendChild(sp); // #309: which edit-note type these notes belong to (notes are kept separate per type) const scopeChip = document.createElement('span'); scopeChip.className = 'mmth-scope'; scopeChip.textContent = scopeLabel(SCOPE); scopeChip.title = 'Saved notes & history are kept separate per edit-note type — these are your "' + scopeLabel(SCOPE) + '" notes (#309)'; scopeChip.style.display = SET.scopePerResource ? '' : 'none'; // only shown when scoping is on ft.appendChild(scopeChip); inst.minBtn = fb('–', 'Minimize to corner', 'mmth-min-btn', () => { restoredFromMin = false; setMinimized(!SET.minimized); }); // #265: left of the ? button ft.appendChild(inst.minBtn); ft.appendChild(fb('?', 'Edit-note syntax', 'mmth-pop-anchor', e => openSyntax(e.currentTarget))); ft.appendChild(fb('⚙︎', 'Settings', 'mmth-pop-anchor', e => openSettings(e.currentTarget))); inst.tabs = { saved: bSaved, history: bHist }; ta.addEventListener('keydown', e => { if (!(e.ctrlKey || e.metaKey)) return; const k = e.key.toLowerCase(); // Ctrl/⌘+B / +I wrap the selection in MB edit-note bold / italic markup if (k === 'b' || k === 'i') { e.preventDefault(); wrapSel(ta, k === 'b' ? "'''" : "''"); return; } // Ctrl/⌘+↑/↓ cycle through saved notes, replacing the field (focus stays here) if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; if (inst.view !== 'saved') { inst.view = 'saved'; inst.tabs && inst.tabs.saved.classList.add('on'); inst.tabs && inst.tabs.history.classList.remove('on'); renderInst(inst); } // cycle through exactly what's shown (respects the current sort + filter) #304 const items = inst.viewItems; if (!items || !items.length) return; e.preventDefault(); const dir = e.key === 'ArrowDown' ? 1 : -1; let i = items.findIndex(x => x.id === inst.cycId); if (i < 0) i = dir > 0 ? -1 : 0; i = (i + dir + items.length) % items.length; inst.cycId = items[i].id; setValue(ta, items[i].text); renderInst(inst); // keep the highlighted item visible — scroll WITHIN the list only (not the page) const cur = inst.list.querySelector('.mmth-cyc'); if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect(); if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top); else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); } // setValue can trigger a React re-render on the release editor that steals // focus; re-assert it now AND after the re-render so the editor stays focused. const refocus = () => { try { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } catch (x) {} }; refocus(); requestAnimationFrame(refocus); setTimeout(refocus, 0); }); renderInst(inst); return inst; } // #304: pinned saved notes shown as one-click quick-insert buttons BELOW the // edit-note field (like baby-field bars) — always available, independent of view. function renderPinbar(inst) { const bar = inst.pinbar; bar.innerHTML = ''; const pinned = DATA.saved.filter(s => s.pinned); if (!pinned.length) { bar.style.display = 'none'; return; } bar.style.display = 'flex'; const n = Math.max(4, Math.min(80, SET.btnChars | 0 || 24)); const cap = t => { t = t.replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; }; const dflt = SET.defaultInsert; pinned.forEach(it => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-segb'; b.textContent = cap(it.text); b.style.maxWidth = (n + 1) + 'ch'; b.title = it.text + `\n\n(click: ${dflt} · right-click: ${dflt === 'replace' ? 'append' : 'replace'} · Ctrl/⌘-click: replace + submit)`; b.onclick = e => { e.preventDefault(); // #328: Ctrl/⌘-click = replace the note and submit the edit (parity with Ctrl/⌘+Enter; // shift was prone to text selection). Same as a list row. if (e.ctrlKey || e.metaKey) { applyNote(inst.ta, it.text, true); bumpUse(it.id); const sb = findSubmitBtn(inst.ta); if (sb) sb.click(); return; } applyNote(inst.ta, it.text, dflt === 'replace'); bumpUse(it.id); }; b.oncontextmenu = e => { e.preventDefault(); applyNote(inst.ta, it.text, dflt !== 'replace'); bumpUse(it.id); }; bar.appendChild(b); }); } function renderInst(inst) { const { ta, list } = inst; list.style.maxHeight = (Math.max(1, Math.min(30, SET.visibleRows | 0 || 6)) * 26) + 'px'; // show N items, then scroll (#212) list.innerHTML = ''; if (inst.tabs) { inst.tabs.saved.classList.toggle('on', inst.view === 'saved'); inst.tabs.history.classList.toggle('on', inst.view === 'history'); } const saved = inst.view === 'saved'; renderPinbar(inst); // the search box is always shown inst.filterRow.style.display = 'flex'; // sort (Saved, from config) then search-filter on the full note text; show N/total const all = saved ? sortedSaved() : DATA.history; const q = (inst.filter || '').trim().toLowerCase(); const items = q ? all.filter(it => it.text.toLowerCase().includes(q)) : all; inst.viewItems = items; // Ctrl+↑/↓ and Enter-in-search use exactly what's shown if (inst.countEl) inst.countEl.textContent = q ? (items.length + ' / ' + all.length) : (all.length ? String(all.length) : ''); // drag-reorder only makes sense in the unfiltered manual order const manual = saved && (SET.noteSort || 'manual') === 'manual' && !q; if (!items.length) { const e = document.createElement('div'); e.className = 'mmth-empty'; e.textContent = all.length ? 'No notes match the search' : (saved ? 'No saved notes — + saves the current one' : 'No history yet'); list.appendChild(e); return; } items.forEach((it) => { const row = document.createElement('div'); row.className = 'mmth-row'; if (saved && it.pinned) row.classList.add('mmth-pinned'); if (it.id === inst.cycId) row.classList.add('mmth-cyc'); // arrow-key / cycle selection (both views) #304 const dflt = SET.defaultInsert; row.title = it.text + `\n\n(click: ${dflt} · right-click: ${dflt === 'replace' ? 'append' : 'replace'} · Ctrl/⌘-click: set + submit)`; const txt = document.createElement('span'); txt.className = 'mmth-txt'; txt.textContent = it.text.replace(/\s+/g, ' ').trim(); row.appendChild(txt); // right-side hover actions: pin/unpin + delete (saved); save + remove (history) const acts = document.createElement('div'); acts.className = 'mmth-rowacts'; const ra = (glyph, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-ra'; b.textContent = glyph; b.title = title; b.onclick = e => { e.stopPropagation(); fn(); }; acts.appendChild(b); }; if (saved) { ra(it.pinned ? '★' : '☆', it.pinned ? 'Unpin from quick buttons' : 'Pin as a quick button', () => togglePinNote(it.id)); ra('🗑', 'Delete', () => removeSaved(it.id)); } else { ra('★', 'Save (pin to Saved)', () => { if (addSaved(it.text)) toast('Saved'); }); ra('🗑', 'Remove', () => removeHistory(it.text)); } row.appendChild(acts); if (manual) { const grab = document.createElement('span'); grab.className = 'mmth-grab'; grab.textContent = '⠿'; grab.title = 'Drag to reorder'; grab.draggable = true; grab.addEventListener('dragstart', e => { _drag = { id: it.id }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmth-dragging'); }); grab.addEventListener('dragend', () => { row.classList.remove('mmth-dragging'); clearMarks(list); _drag = null; }); row.appendChild(grab); } row.onclick = e => { // #289/#328: Ctrl/⌘-click sets the note (replace) AND submits the edit — parity // with Ctrl/⌘+Enter (reuses findSubmitBtn); shift was prone to text selection. if (e.ctrlKey || e.metaKey) { applyNote(ta, it.text, true); if (saved) bumpUse(it.id); const b = findSubmitBtn(ta); if (b) b.click(); return; } applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (saved) bumpUse(it.id); }; row.oncontextmenu = e => { e.preventDefault(); applyNote(ta, it.text, SET.defaultInsert !== 'replace'); if (saved) bumpUse(it.id); }; if (manual) { row.addEventListener('dragover', e => { if (!_drag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmth-drop-after' : 'mmth-drop-before'); }); row.addEventListener('dragleave', () => row.classList.remove('mmth-drop-before', 'mmth-drop-after')); row.addEventListener('drop', e => { if (!_drag) return; e.preventDefault(); reorder(_drag.id, it.id, !after(e, row)); clearMarks(list); _drag = null; }); } list.appendChild(row); }); } function render() { instances.forEach(i => { if (i.list.isConnected) renderInst(i); }); } // ── attach ─────────────────────────────────────────────────────────────────── function injectAll() { applyHelp(); document.querySelectorAll('textarea.edit-note').forEach(ta => { if (ta.dataset.mmth) return; ta.dataset.mmth = '1'; const en = ta.closest('.editnote'); if (en) en.classList.add('mmth-on'); // hides the redundant inline "Edit note:" label (#212) const wrap = document.createElement('div'); wrap.className = 'mmth-wrap'; ta.parentNode.insertBefore(wrap, ta); // #304: the textarea and its pinned-quick-buttons bar share one column, so the // bar always sits directly below the field and tracks its left edge / width, // whatever the surrounding form layout does. const fieldCol = document.createElement('div'); fieldCol.className = 'mmth-fieldcol'; wrap.appendChild(fieldCol); fieldCol.appendChild(ta); // remember the textarea height the user sets with the native resize grip (vertical); // the splitter (below) remembers the field/panel split (horizontal). if (SET.taHeight) ta.style.height = SET.taHeight + 'px'; // Persist ONLY a deliberate user resize (height changes between mouse down // and up on the field). The old ResizeObserver fired on any layout-driven // size change too, so visiting a differently-sized edit page (e.g. the // full-width release editor) silently overwrote the saved height. let _downH = null; ta.addEventListener('mousedown', () => { _downH = ta.offsetHeight; }); window.addEventListener('mouseup', () => { if (_downH == null) return; const h = ta.offsetHeight; const was = _downH; _downH = null; if (h > 40 && h !== was && h !== SET.taHeight) { SET.taHeight = h; ta.style.height = h + 'px'; persistSet(); } }); const vsep = document.createElement('div'); vsep.className = 'mmth-vsep'; vsep.title = 'Drag to resize'; wrap.appendChild(vsep); // resizable separator between field & panel (#212) const inst = buildSide(ta); const side = inst.side; wrap.appendChild(side); fieldCol.appendChild(inst.pinbar); // #304: pinned quick-buttons directly below the field, in its column wireResize(vsep, side); // #265 minimized mode: badge in the field's top-right corner; hover (or click // to pin) floats the panel back in. mouseleave closes after a short grace. const badge = document.createElement('div'); badge.className = 'mmth-badge'; badge.title = 'Mammoth — saved notes (click or hover)'; badge.innerHTML = MAMMOTH_SVG; // #308 vector, not the 🦣 emoji wrap.appendChild(badge); inst.badge = badge; // #288/#290: MB (and other scripts, e.g. jesus2099's MERGE HELPOR) insert // edit-note error/warning

      s as siblings of the textarea — which now lives // inside our flex .mmth-wrap, so they'd be laid out BESIDE the field. Keep the // wrap a clean row (field | sep | panel | badge) and relocate any foreign child // OUT to normal flow: warnings above the wrap, validation/other notices below. // (Doing this in JS instead of flex-wrap avoids the panel itself wrapping below // the field in a narrow left-column layout — #290.) // #304: the textarea now lives in .mmth-fieldcol, so MB inserts those

      s into // fieldCol (not wrap) — observe both, and treat fieldCol/ta/pinbar as ours. const isOurs = el => el === ta || el === inst.pinbar || (el.classList && (el.classList.contains('mmth-fieldcol') || el.classList.contains('mmth-vsep') || el.classList.contains('mmth-side') || el.classList.contains('mmth-badge'))); const relocateForeign = node => { if (!node || node.nodeType !== 1 || isOurs(node) || !wrap.parentNode) return; const above = node.classList && node.classList.contains('error') && !node.classList.contains('invalid'); wrap.parentNode.insertBefore(node, above ? wrap : wrap.nextSibling); }; [...wrap.children, ...fieldCol.children].forEach(relocateForeign); const fObs = muts => muts.forEach(m => m.addedNodes.forEach(relocateForeign)); new MutationObserver(fObs).observe(wrap, { childList: true }); new MutationObserver(fObs).observe(fieldCol, { childList: true }); let closeT = null, pinned = false; const openFloat = () => { clearTimeout(closeT); if (SET.minimized) side.classList.add('mmth-open'); }; const closeFloat = () => { clearTimeout(closeT); if (pinned) return; closeT = setTimeout(() => side.classList.remove('mmth-open'), 220); }; badge.addEventListener('mouseenter', openFloat); badge.addEventListener('mouseleave', closeFloat); side.addEventListener('mouseenter', openFloat); side.addEventListener('mouseleave', closeFloat); // click the badge to pin the panel open (so it survives mouse-out); click again to unpin badge.addEventListener('click', () => { if (!SET.minimized) return; pinned = !pinned; pinned ? openFloat() : side.classList.remove('mmth-open'); }); inst.unpin = () => { pinned = false; }; applyMinState(inst); // #263: keep the panel ≤ half the row (never wider than the field). Driven by // the WRAP's width only — stable, no feedback loop. const cap = () => capPanel(wrap, vsep, side); cap(); requestAnimationFrame(cap); setTimeout(cap, 200); try { new ResizeObserver(cap).observe(wrap); } catch (x) {} inst.recap = cap; // The saved-notes panel's height (driven by the Items Shown setting) is the // field's floor, so it's never shorter than the sidebar. With no user-saved // height the field STARTS at exactly that height too — so its initial size // tracks Items Shown — until the user drags the grip (which is remembered). const syncFloor = () => { try { // Minimized: the panel floats out of flow, so the field needs no floor — // applying one here while the panel shows on hover would couple field // height to panel height (the #245 loop). Clear it and bail. if (SET.minimized) { if (ta.style.minHeight) ta.style.minHeight = ''; return; } const h = side.offsetHeight; if (!(h > 0)) return; if ((parseInt(ta.style.minHeight, 10) || 0) !== h) ta.style.minHeight = h + 'px'; if (!SET.taHeight && (parseInt(ta.style.height, 10) || 0) !== h) ta.style.height = h + 'px'; vsep.style.height = ta.offsetHeight + 'px'; // #304: separator spans only the field, not the field+pinbar column } catch (x) {} }; syncFloor(); requestAnimationFrame(syncFloor); setTimeout(syncFloor, 150); setTimeout(syncFloor, 600); // catch the sidebar's final layout try { new ResizeObserver(syncFloor).observe(side); new ResizeObserver(syncFloor).observe(ta); } catch (x) {} }); } // Toggle the "an MB dialog is open" flag so Mammoth's floating UI (main panel // badge/pop + babies) hides under it — MB's dialogs/popovers carry no z-index // and no backdrop, so they can't otherwise win the stack. Global (runs even // when babies are off). #313 // #333: the artist-credit editor is itself a .dialog.popover, but it holds fields we DO pin // (ac-source-artist-*). Don't treat such a dialog as blocking, or we'd hide the baby on its // own rows (and leave its reserved strip empty). Only block for dialogs without our fields. const syncDialog = () => { const dlgs = [...document.querySelectorAll('.dialog.popover, .relationship-dialog')]; const blocking = dlgs.some(d => !d.querySelector('input[id^="ac-source-artist-"]')); document.documentElement.classList.toggle('mmthf-dialog', blocking); }; new MutationObserver(() => { injectAll(); syncDialog(); }).observe(document.documentElement, { childList: true, subtree: true }); syncDialog(); // #252 Ctrl/Cmd+Enter submits the edit. The submit control differs per page, so // look in order: the release editor's "Enter edit" button, then the edit form's // own submit, then a visible button labelled Enter edit / Submit / Finish. Only // act when focus is in the edit-note field or Mammoth's panel (or nowhere), so it // never hijacks Ctrl+Enter in some unrelated field. const isVisible = b => !!(b && b.offsetParent !== null && !b.disabled); function findSubmitBtn(ta) { const re = document.getElementById('enter-edit'); if (isVisible(re)) return re; const form = ta && ta.closest('form'); if (form) { const s = form.querySelector('button.submit, button[type="submit"], button.positive'); if (isVisible(s)) return s; } return [...document.querySelectorAll('button')].find(b => isVisible(b) && /^\s*(enter edit|submit|finish)\b/i.test(b.textContent || '')) || null; } document.addEventListener('keydown', e => { if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; const ta = document.querySelector('textarea.edit-note'); if (!ta) return; const t = e.target; if (t !== ta && !(t && t.closest && t.closest('.mmth-wrap, .mmth-side, .mmth-pop')) && t !== document.body) return; const btn = findSubmitBtn(ta); if (btn) { e.preventDefault(); btn.click(); } }); // #304: Ctrl/Cmd+, focuses the note search box (the panel nearest the focused // field, else the first visible one). When the panel is minimized, restore it // first so the box is reachable. No-op when search is off. document.addEventListener('keydown', e => { if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; const onScreen = () => [...document.querySelectorAll('.mmth-filter')].filter(i => i.offsetParent !== null); let visible = onScreen(); if (!visible.length) { // search box is hidden because the panel is minimized — restore and retry if (SET.minimized) { setMinimized(false); restoredFromMin = true; visible = onScreen(); } if (!visible.length) return; } const wrap = e.target && e.target.closest && e.target.closest('.mmth-wrap'); const near = wrap && wrap.querySelector('.mmth-filter'); const target = (near && near.offsetParent !== null) ? near : visible[0]; e.preventDefault(); target.focus(); target.select(); }); injectAll(); if (SET.showBabies !== false) babyMammoths.start(); // ════════════════════════════════════════════════════════════════════════════ // BABY MAMMOTHS — field memory for arbitrary input controls // Self-contained (own storage key, own CSS, own DOM), gated by SET.showBabies. // start()/stop() let the ⚙ toggle add/remove it cleanly at runtime. // ════════════════════════════════════════════════════════════════════════════ function createBabyMammoths() { const FKEY = 'mammoth-fields:data'; // { [fieldKey]: [{ v, label, ts, pinned?, default? }] } const MAX_PER_FIELD = 25; // #296 follow-up — capture the selected ENTITY's MBID for autocomplete fields, // so a saved Label/Artist resolves the real entity on recall (writeField pastes // the MBID, which MB resolves) instead of being text-only. The gid comes from // the live release editor model; unsafeWindow reaches the page's MB from the // userscript sandbox. Falls back to text when nothing is selected / off-editor. const PAGEWIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const relEntity = () => { try { return PAGEWIN.MB.releaseEditor.rootField.release(); } catch (e) { return null; } }; const labelGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const labs = r && r.labels && r.labels(); const L = labs && labs[i] && labs[i].label && labs[i].label(); return (L && L.gid) || null; }; const _unwrap = x => (typeof x === 'function' ? x() : x); const artistGid = () => { // single-artist box only: exactly one real artist (names has a trailing blank) const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit(); let ns = ac && ac.names; ns = _unwrap(ns); if (!ns) return null; const real = [...ns].map(n => _unwrap(n.artist)).filter(a => a && a.gid); return real.length === 1 ? real[0].gid : null; }; // a specific row of the artist-credit editor bubble (ac-source-artist-) const artistRowGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit(); const ns = _unwrap(ac && ac.names); const a = ns && ns[i] && _unwrap(ns[i].artist); return (a && a.gid) || null; }; // Built-in release add/edit targets. `key` is a STABLE, index-free storage key // (catno-0/label-0 are per-medium, but saved values are shared). `gid` (entity // fields) resolves the selected entity's MBID for capture. const PREDEF = [ { match: 'input[id^="catno-"]', key: 'release.catno', label: 'Catalog number' }, { match: '#primary-type', key: 'release.primary_type', label: 'Primary type' }, { match: '#packaging', key: 'release.packaging', label: 'Packaging' }, { match: '#status', key: 'release.status', label: 'Status' }, { match: '#language', key: 'release.language', label: 'Language' }, { match: '#script', key: 'release.script', label: 'Script' }, { match: 'select[id^="country-"]', key: 'release.country', label: 'Country' }, { match: 'input[id^="label-"]', key: 'release.label', label: 'Label', gid: labelGid }, { match: '#ac-source-single-artist', key: 'release.artist', label: 'Artist', gid: artistGid }, { match: 'input[id^="ac-source-artist-"]', key: 'release.artist', label: 'Artist', gid: artistRowGid }, // artist-credit editor bubble rows ]; const loadF = () => { try { return JSON.parse(GM_getValue(FKEY, '{}') || '{}'); } catch (e) { return {}; } }; const saveF = () => { try { GM_setValue(FKEY, JSON.stringify(FDATA)); } catch (e) {} }; let FDATA = loadF(); const listFor = key => (FDATA[key] = FDATA[key] || []); function rememberValue(key, rec) { if (!rec || !rec.v) return false; const a = listFor(key); const e = a.find(x => x.v === rec.v); if (e) { e.label = rec.label || e.label; e.ts = Date.now(); } // already saved → keep it (and its ★ / default / order) else { a.unshift({ v: rec.v, label: rec.label || rec.v, ts: Date.now() }); FDATA[key] = a.slice(0, MAX_PER_FIELD); } saveF(); return true; } const forgetValue = (key, v) => { FDATA[key] = listFor(key).filter(x => x.v !== v); saveF(); }; const togglePin = (key, v) => { const e = listFor(key).find(x => x.v === v); if (e) { e.pinned = !e.pinned; saveF(); } }; function setDefault(key, v) { const a = listFor(key); const e = a.find(x => x.v === v); if (!e) return; const was = e.default; a.forEach(x => x.default = false); e.default = !was; saveF(); } const defaultOf = key => listFor(key).find(x => x.default); // drag-reorder a saved value relative to another (like the edit-note panel's ⠿) function reorder(key, srcV, tgtV, before) { if (srcV === tgtV) return; const a = listFor(key); const si = a.findIndex(x => x.v === srcV); if (si < 0) return; const [it] = a.splice(si, 1); let ti = a.findIndex(x => x.v === tgtV); if (ti < 0) { a.splice(si, 0, it); return; } a.splice(before ? ti : ti + 1, 0, it); saveF(); } // #304: caption = the full label, truncated with an ellipsis at the configured // length (⚙ "Button label length") — no longer just the first word. const btnChars = () => Math.max(4, Math.min(80, SET.btnChars | 0 || 24)); const captionOf = it => { if (it.cap) return it.cap; const n = btnChars(); const t = (it.label || it.v || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + '…' : t; }; const isSelect = el => el.tagName === 'SELECT'; const isAuto = el => el.classList.contains('ui-autocomplete-input') || el.classList.contains('lookup-performed'); function setNative(el, val) { const proto = isSelect(el) ? HTMLSelectElement.prototype : el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const d = Object.getOwnPropertyDescriptor(proto, 'value'); if (d && d.set) d.set.call(el, val); else el.value = val; } function readField(el) { if (isSelect(el)) { const o = el.options[el.selectedIndex]; return { v: el.value, label: o ? o.textContent.trim() : el.value }; } const v = el.value || ''; return { v, label: v.trim() }; } // What to STORE for the current value. Entity fields (p.gid) store the selected // MBID as the value with the visible name as the label, so recall resolves the // real entity; everything else (and unresolved/empty entity fields) stores text. function captureField(p) { const t = readField(p.el); if (!t.v || !p.gid) return t; let g; try { g = p.gid(p.el); } catch (e) {} return g ? { v: g, label: t.label || g } : t; } function writeField(el, rec) { if (isSelect(el)) { let opt = [...el.options].find(o => o.value === rec.v); if (!opt && rec.label) opt = [...el.options].find(o => o.textContent.trim() === rec.label.trim()); if (!opt) return false; setNative(el, opt.value); el.dispatchEvent(new Event('change', { bubbles: true })); return true; } setNative(el, rec.v); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); if (isAuto(el)) { el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'a' })); el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'a' })); } return true; } function clearField(el) { if (isSelect(el)) { const o = [...el.options].find(o => o.value === ''); if (!o) return; setNative(el, ''); el.dispatchEvent(new Event('change', { bubbles: true })); } else { setNative(el, ''); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } try { el.focus(); } catch (e) {} } // return focus to the field after an interactive apply (caret at end for text inputs) const focusField = el => { try { el.focus(); if (!isSelect(el) && el.setSelectionRange) { const n = (el.value || '').length; el.setSelectionRange(n, n); } } catch (e) {} }; const fLabelText = el => { const l = el.id && document.querySelector(`label[for="${CSS.escape(el.id)}"]`); return (l && l.textContent.trim().replace(/:$/, '')) || el.getAttribute('aria-label') || el.placeholder || ''; }; function keyFor(el, def) { if (def && def.key) return def.key; if (el.dataset.mmthKey) return 'k:' + el.dataset.mmthKey; const base = el.id ? el.id.replace(/-\d+$/, '') : (el.name || ''); return 'auto:' + (base || fLabelText(el).toLowerCase().replace(/\s+/g, '-') || 'field'); } const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2; const clearMarks = host => host && host.querySelectorAll('.mmthf-drop-before,.mmthf-drop-after').forEach(r => r.classList.remove('mmthf-drop-before', 'mmthf-drop-after')); let pins = [], pop = null, mo = null, raf = 0, running = false, _fdrag = null, settleCap = 0; const listeners = []; function injectCss() { if (document.getElementById('mmthf-css')) return; const s = document.createElement('style'); s.id = 'mmthf-css'; s.textContent = ` .mmthf-pin { position:absolute; z-index:9998; width:16px; height:16px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:none; background:none; box-shadow:none; padding:0; font-size:13px; line-height:1; user-select:none; opacity:.35; transition:opacity .12s; filter:grayscale(.3); } .mmthf-pin:hover { opacity:1; filter:none; } .mmthf-pin.has { opacity:.8; filter:none; } /* #296: keep the overlays invisible while the release editor is still reflowing on load (so they don't visibly jump), then FADE them in once the layout goes quiet — instead of bumping into place. */ html.mmthf-settling .mmthf-pin, html.mmthf-settling .mmthf-bar { opacity:0 !important; pointer-events:none !important; } html.mmthf-fadein .mmthf-pin, html.mmthf-fadein .mmthf-bar { transition:opacity .4s ease !important; } /* MB's dialogs/popovers (Add/Edit relationship, …) carry no z-index, so our high-z babies would float on top of them and their dropdowns. Hide the babies while any MB dialog is open. */ html.mmthf-dialog .mmthf-pin, html.mmthf-dialog .mmthf-bar, html.mmthf-dialog .mmthf-pop { opacity:0 !important; pointer-events:none !important; } .mmthf-hl { outline:2px solid #5aa67e !important; outline-offset:1px; } .mmthf-bar { position:absolute; z-index:9996; display:none; } /* #304: individual rounded "tag" buttons that wrap to new rows — matches the main edit-note pins */ .mmthf-seg { display:flex; flex-wrap:wrap; gap:5px; max-width:100%; } .mmthf-segb { border:1px solid #cfd9d3 !important; background:#fbfdfc !important; border-radius:7px !important; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:#27483a !important; cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); } .mmthf-segb:hover { background:#eaf5ee; border-color:#5aa67e; } .mmthf-pop { position:fixed; z-index:9999; background:#fff; border:1px solid #c7d3cc; border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; color:#222; width:260px; overflow:hidden; } .mmthf-ft { display:flex; align-items:center; gap:1px; padding:3px 5px; background:#f1f6f3; border-bottom:1px solid #e7eee9; } .mmthf-fb { cursor:pointer; border:none; background:none; font-size:14px; line-height:1; padding:3px 7px; border-radius:5px; color:#566; } .mmthf-fb:hover { background:#dcefe2; } .mmthf-fb[aria-disabled="true"] { color:#b7c2bb; cursor:default; background:none; } .mmthf-ft-title { flex:1 1 auto; min-width:0; text-align:center; font-weight:700; font-size:13px; color:#293330; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:0 4px; } .mmthf-filterrow { padding:5px 6px; border-bottom:1px solid #e7eee9; background:#f7faf8; } .mmthf-filter { display:block; width:100%; box-sizing:border-box; border:1px solid #d7e0db; border-radius:5px; padding:3px 7px; font:12px -apple-system,Segoe UI,Arial,sans-serif; } .mmthf-filter:focus { outline:none; border-color:#5aa67e; } .mmthf-row.mmthf-sel { background:#e7f2ea; } .mmthf-list { max-height:240px; overflow-y:auto; } .mmthf-row { position:relative; display:flex; align-items:center; gap:6px; padding:5px 10px; border-top:1px solid #f0f4f2; cursor:pointer; } .mmthf-row:first-child { border-top:none; } .mmthf-row:hover { background:#eaf5ee; } .mmthf-rtxt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding-right:42px; } /* stationary indicators at the right edge (★ rightmost so it stays aligned; ◉ when default). Non-interactive — no reserved hover slots. */ .mmthf-ind { position:absolute; right:10px; top:0; height:100%; display:flex; align-items:center; gap:5px; font-size:12px; color:#2c7a51; pointer-events:none; } /* the full action toolbar OVERLAYS the right on hover — reserves no space when idle */ .mmthf-acts { position:absolute; right:5px; top:2px; bottom:2px; display:none; align-items:center; gap:1px; padding:0 3px 0 12px; border-radius:5px; background:#eaf5ee; } .mmthf-row:hover .mmthf-acts { display:flex; } .mmthf-row:hover .mmthf-ind { display:none; } .mmthf-ra { width:18px; box-sizing:border-box; text-align:center; border:none; background:none; color:#7d8a82; cursor:pointer; font-size:11px; padding:1px 0; border-radius:3px; } .mmthf-ra:hover { background:#cfe9d8; color:#1f5c3d; } .mmthf-grab { width:14px; text-align:center; cursor:grab; color:#b7c2bb; font-size:12px; user-select:none; } .mmthf-grab:active { cursor:grabbing; } .mmthf-row.mmthf-dragging { opacity:.45; } .mmthf-row.mmthf-drop-before { box-shadow:inset 0 2px 0 #2c7a51; } .mmthf-row.mmthf-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; } .mmthf-empty { padding:10px; color:#9aa6a0; font-style:italic; text-align:center; } tr.mmthf-rrow > td { vertical-align:top; } /* keep sibling cells from dropping when a cell reserves strip space */ `; (document.head || document.documentElement).appendChild(s); } function scan() { const map = new Map(); const add = (el, def) => { if (el && !map.has(el)) map.set(el, def || {}); }; for (const d of PREDEF) document.querySelectorAll(d.match).forEach(el => add(el, d)); document.querySelectorAll('.mmth-pin').forEach(el => add(el, { key: el.dataset.mmthKey ? 'k:' + el.dataset.mmthKey : null, label: el.dataset.mmthLabel || '' })); for (const [el, def] of map) { if (el.dataset.mmthf || !el.matches('input, select, textarea')) continue; el.dataset.mmthf = '1'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'mmthf-pin'; btn.innerHTML = MAMMOTH_SVG; // #308 vector, not the 🦣 emoji const sel = isSelect(el); // shift the pin left of a native affordance: the ' : ''}

      ${items.map(rowHtml).join('') || '
      No saved values yet
      '}
      `; document.body.appendChild(el); pop = el; const list = el.querySelector('.mmthf-list'); if (searchOn) { const fin = el.querySelector('.mmthf-filter'); let hl = -1; // highlighted row among the currently-visible ones const vis = () => [...el.querySelectorAll('.mmthf-row')].filter(r => r.style.display !== 'none'); const paint = () => { const rows = vis(); rows.forEach((r, i) => r.classList.toggle('mmthf-sel', i === hl)); if (rows[hl]) rows[hl].scrollIntoView({ block: 'nearest' }); }; const applyFilter = () => { const q = (fin.value || '').trim().toLowerCase(); el.querySelectorAll('.mmthf-row').forEach(r => { const t = (r.querySelector('.mmthf-rtxt') || {}).textContent || ''; r.style.display = (!q || t.toLowerCase().includes(q)) ? '' : 'none'; }); hl = vis().length ? 0 : -1; paint(); }; fin.addEventListener('input', applyFilter); fin.addEventListener('keydown', e => { const rows = vis(); if (e.key === 'Escape') { e.stopPropagation(); if (fin.value) { fin.value = ''; applyFilter(); } else closePop(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); if (rows.length) { hl = (hl + 1) % rows.length; paint(); } return; } if (e.key === 'ArrowUp') { e.preventDefault(); if (rows.length) { hl = (hl - 1 + rows.length) % rows.length; paint(); } return; } if (e.key === 'Enter') { e.preventDefault(); const r = rows[hl] || rows[0]; if (r) { writeField(p.el, items[+r.dataset.i]); closePop(); focusField(p.el); } return; } // keyboard apply → refocus the field }); applyFilter(); // highlight the first item so Enter works immediately setTimeout(() => { try { fin.focus(); } catch (e) {} }, 0); } el.querySelector('.mmthf-save').addEventListener('click', () => { if (!cur.v) return; rememberValue(p.key, cur); refreshState(p); reopen(p); }); el.querySelector('.mmthf-clear').addEventListener('click', () => { clearField(p.el); reopen(p); }); // #309: open the config with Import/Export scoped to THIS field's values el.querySelector('.mmthf-cfg').addEventListener('click', () => { const a = p.btn; // #309: entity fields (Artist/Label) store v=MBID, label=name. Export both as // "nameMBID" so a re-import resolves the real entity (text fields stay plain). const io = { items: () => listFor(p.key).map(x => (x.v && x.label && x.v !== x.label) ? (x.label + '\t' + x.v) : (x.label || x.v)), add: notes => { const arr = listFor(p.key); let added = 0; const have = new Set(arr.map(x => x.v)); for (const line of notes) { const i = line.indexOf('\t'); const label = (i >= 0 ? line.slice(0, i) : line).trim(); const v = (i >= 0 ? line.slice(i + 1) : line).trim(); if (!v || have.has(v)) continue; have.add(v); arr.unshift({ v, label: label || v, ts: Date.now() }); added++; } FDATA[p.key] = arr.slice(0, MAX_PER_FIELD); if (added) { saveF(); refreshState(p); } return added; }, help: 'Import / export the “' + p.label + '” values (entity fields keep their MBID).', }; closePop(); openSettings(a, 'io', io); }); el.querySelectorAll('.mmthf-row').forEach(row => { const it = items[+row.dataset.i]; row.addEventListener('click', e => { if (e.target.closest('.mmthf-grab')) return; if (e.target.closest('.mmthf-star')) { togglePin(p.key, it.v); refreshState(p); reopen(p); return; } if (e.target.closest('.mmthf-def')) { setDefault(p.key, it.v); applyDefault(p); reopen(p); return; } if (e.target.closest('.mmthf-del')) { forgetValue(p.key, it.v); refreshState(p); reopen(p); return; } writeField(p.el, it); closePop(); // don't refocus the field on apply — it re-triggers autocomplete }); const grab = row.querySelector('.mmthf-grab'); grab.addEventListener('dragstart', e => { _fdrag = { v: it.v }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmthf-dragging'); }); grab.addEventListener('dragend', () => { row.classList.remove('mmthf-dragging'); clearMarks(list); _fdrag = null; }); row.addEventListener('dragover', e => { if (!_fdrag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmthf-drop-after' : 'mmthf-drop-before'); }); row.addEventListener('dragleave', () => row.classList.remove('mmthf-drop-before', 'mmthf-drop-after')); row.addEventListener('drop', e => { if (!_fdrag) return; e.preventDefault(); reorder(p.key, _fdrag.v, it.v, !after(e, row)); clearMarks(list); _fdrag = null; refreshState(p); reopen(p); }); }); el._atField = atField; place(el, atField ? p.el : p.btn); // #345: hotkey opens at the field's left edge; a pin CLICK opens at the pin, by the cursor setTimeout(() => { document.addEventListener('click', onDown, true); document.addEventListener('keydown', onKey, true); }, 0); } const reopen = p => { const af = pop ? pop._atField : false; closePop(); openPop(p, af); }; // keep the same anchor across a reopen const relayout = () => { syncDialog(); if (running && !raf) raf = requestAnimationFrame(() => { raf = 0; layout(); }); }; function start() { if (running) return; running = true; injectCss(); const on = (t, ev, fn, cap) => { t.addEventListener(ev, fn, cap); listeners.push([t, ev, fn, cap]); }; on(window, 'scroll', relayout, true); on(window, 'resize', relayout, false); ['focusin', 'focusout', 'click', 'input', 'keyup'].forEach(ev => on(document, ev, relayout, true)); // Ctrl/Cmd+, — if a baby popover is open, focus its filter; else if a baby field is focused, open // its popover at the field's left edge (which auto-focuses the filter). on(document, 'keydown', e => { if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return; if (pop) { const f = pop.querySelector('.mmthf-filter'); if (f) { e.preventDefault(); f.focus(); if (f.select) f.select(); return; } } const el = document.activeElement; const p = el && pins.find(pp => pp.el === el); if (p) { e.preventDefault(); openPop(p, true); } // hotkey → anchor to the field's left edge }, true); // #296: the release editor keeps reflowing for a few hundred ms after load, so // the absolutely-positioned overlays would chase the moving fields and visibly // jump. Keep them hidden until the DOM goes quiet for 300ms (capped at 1.5s), // then reveal them already in their final spots. const html = document.documentElement; html.classList.add('mmthf-settling'); let revealed = false, quietT = 0; const reveal = () => { if (revealed) return; revealed = true; clearTimeout(quietT); clearTimeout(settleCap); html.classList.remove('mmthf-settling'); html.classList.add('mmthf-fadein'); relayout(); setTimeout(() => html.classList.remove('mmthf-fadein'), 450); // drop the slow transition once faded in }; const bump = () => { if (revealed) return; clearTimeout(quietT); quietT = setTimeout(reveal, 300); }; settleCap = setTimeout(reveal, 1500); let st = 0; mo = new MutationObserver(() => { syncDialog(); clearTimeout(st); st = setTimeout(scan, 150); bump(); }); mo.observe(document.documentElement, { childList: true, subtree: true }); bump(); scan(); } function stop() { if (!running) return; running = false; if (mo) { mo.disconnect(); mo = null; } clearTimeout(settleCap); document.documentElement.classList.remove('mmthf-settling', 'mmthf-fadein'); listeners.forEach(([t, ev, fn, cap]) => t.removeEventListener(ev, fn, cap)); listeners.length = 0; closePop(); pins.forEach(p => { try { setReserve(p, false); } catch (e) {} p.btn.remove(); p.bar.remove(); delete p.el.dataset.mmthf; }); pins = []; } return { start, stop, toggle(on) { on ? start() : stop(); }, relabel() { pins.forEach(p => renderBar(p)); } }; } })(); }); })(typeof GM_info !== 'undefined' ? GM_info : undefined); // ===== platform_check (@run-at document-end) ==================================== (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Platform Check*","namespace":"http://tampermonkey.net/","version":"2026.7.9","description":"Find a MusicBrainz release on online platforms like Spotify, Discogs, Bandcamp, HDtracks etc.. Uses existing URL relationships when present, otherwise searches for release online using several methods.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/platform_check/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+DQogIDx0aXRsZT5NQiBQbGF0Zm9ybSBDaGVjazwvdGl0bGU+CiAgDQogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMWE1MiIgc3Ryb2tlLXdpZHRoPSI5IiBzdHJva2UtbGluZWNhcD0icm91bmQiPg0KICAgIDxwYXRoIGQ9Ik00MCA4OCBBMzQgMzQgMCAwIDEgNDAgNDAiLz4NCiAgICA8cGF0aCBkPSJNMjkgOTkgQTUwIDUwIDAgMCAxIDI5IDI5Ii8+DQogICAgPHBhdGggZD0iTTg4IDg4IEEzNCAzNCAwIDAgMCA4OCA0MCIvPg0KICAgIDxwYXRoIGQ9Ik05OSA5OSBBNTAgNTAgMCAwIDAgOTkgMjkiLz4NCiAgPC9nPg0KICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyMCIgZmlsbD0iI2U4MjAxYSIvPg0KPC9zdmc+DQo="}) }) : { script: {"name":"Platform Check*","namespace":"http://tampermonkey.net/","version":"2026.7.9","description":"Find a MusicBrainz release on online platforms like Spotify, Discogs, Bandcamp, HDtracks etc.. Uses existing URL relationships when present, otherwise searches for release online using several methods.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/platform_check/README.md","supportURL":null,"icon":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+DQogIDx0aXRsZT5NQiBQbGF0Zm9ybSBDaGVjazwvdGl0bGU+CiAgDQogIDxnIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzJhMWE1MiIgc3Ryb2tlLXdpZHRoPSI5IiBzdHJva2UtbGluZWNhcD0icm91bmQiPg0KICAgIDxwYXRoIGQ9Ik00MCA4OCBBMzQgMzQgMCAwIDEgNDAgNDAiLz4NCiAgICA8cGF0aCBkPSJNMjkgOTkgQTUwIDUwIDAgMCAxIDI5IDI5Ii8+DQogICAgPHBhdGggZD0iTTg4IDg4IEEzNCAzNCAwIDAgMCA4OCA0MCIvPg0KICAgIDxwYXRoIGQ9Ik05OSA5OSBBNTAgNTAgMCAwIDAgOTkgMjkiLz4NCiAgPC9nPg0KICA8Y2lyY2xlIGN4PSI2NCIgY3k9IjY0IiByPSIyMCIgZmlsbD0iI2U4MjAxYSIvPg0KPC9zdmc+DQo="} }; (f=>document.readyState!=='loading'?f():document.addEventListener('DOMContentLoaded',f,{once:true}))(function(){ (function () { 'use strict'; // MusicBrainz origin of the current page (musicbrainz.org or beta.musicbrainz.org), // so the script's own MB API calls + edit-page links stay on the same server. const MB_ORIGIN = location.origin; // ─── Release editor sub-pages (/edit, /edit-relationships) ──────────────── // + click on /release stashes OK URLs in `pc:pending:` and opens the // release's /edit page. We fill the "Add another link" input then set the // type chooser in the next-sibling 's . if (/\/release\/[0-9a-f-]{36}\/edit(?:[?#/]|$)/.test(window.location.pathname)) { runInjectHelper('release'); return; } if (/\/release-group\/[0-9a-f-]{36}\/edit(?:[?#/]|$)/.test(window.location.pathname)) { runInjectHelper('release-group'); return; } // Safe setTimeout wrapper. Firefox throws NS_ERROR_NOT_INITIALIZED from // setTimeout when the script context is being torn down — the Promise // constructor turns that into an unhandled rejection that aborts everything. // Catch + fall back to a requestAnimationFrame-driven busy wait so the // caller still gets a real time delay (not a zero-delay microtask burst // that would make polling loops exit instantly with "never appeared"). function pcWait(ms) { return new Promise(resolve => { try { setTimeout(resolve, ms); return; } catch (_) {} // Fallback: RAF-based wait. Each frame is ~16ms; loop until elapsed. const start = Date.now(); const tick = () => { if (Date.now() - start >= ms) { resolve(); return; } try { requestAnimationFrame(tick); } catch (_) { resolve(); } }; tick(); }); } // MutationObserver-backed waiter — resolves the moment `predicate()` // returns truthy, falls back to a slow poll if observers aren't usable. // Safer than fixed-cadence polling because it doesn't rely on setTimeout // running on time, and it picks up the target as soon as MB inserts it. function pcWaitFor(predicate, timeoutMs = 10000) { return new Promise(resolve => { const found = predicate(); if (found) return resolve(found); let done = false; const finish = result => { if (done) return; done = true; obs?.disconnect(); resolve(result); }; let obs = null; try { obs = new MutationObserver(() => { const r = predicate(); if (r) finish(r); }); obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true }); } catch (_) { /* observer broken — rely on RAF poll below */ } const start = Date.now(); const poll = () => { if (done) return; const r = predicate(); if (r) return finish(r); if (Date.now() - start >= timeoutMs) return finish(null); try { requestAnimationFrame(poll); } catch (_) { try { setTimeout(poll, 100); } catch (_) { finish(null); } } }; poll(); }); } async function runInjectHelper(entityType) { try { const re = new RegExp(`/${entityType}/([0-9a-f-]{36})`); const mbid = (window.location.pathname.match(re) || [])[1]; if (!mbid) return; const key = entityType === 'release-group' ? `pc:pending:rg:${mbid}` : `pc:pending:${mbid}`; const raw = GM_getValue(key, null); // No pending payload means the user navigated to /edit themselves, // not via the panel's + button. Stay silent — banner noise on every // direct edit-page visit is worse than the diagnostic value (the // 2nd-click-doesn't-work bug it was diagnosing has been fixed). if (!raw) return; let pending; try { pending = JSON.parse(raw); } catch (e) { showInjectBanner(`Platform Check: pending payload not JSON: ${e.message}`, [], { fail: true }); return; } const urls = Object.values(pending || {}).filter(Boolean); if (urls.length === 0) return; const tab = [...document.querySelectorAll('a, button, li')].find(el => /^external\s+links$/i.test(el.textContent?.trim() || '')); if (tab) tab.click(); await pcWait(200); await injectInto(urls, key); } catch (e) { // Last-resort surface so the user sees *something* on the page when // a Firefox-specific exception kills the inject path silently. try { showInjectBanner(`Platform Check: inject helper crashed — ${e.name || 'Error'}: ${e.message || e}`, [], { fail: true }); } catch (_) { /* nothing else we can do here */ } } } function findAddLinkInput() { // /release//edit uses placeholder "Add another link". // /release-group//edit uses placeholder "Add link" (no "another"). const all = [...document.querySelectorAll('input[type="text"], input[type="url"], input:not([type])')]; const RE = /^(?:add (?:another )?link|add another url)$/i; return all.find(i => RE.test((i.placeholder || '').trim()) && !i.value) || all.find(i => RE.test((i.placeholder || '').trim())) || null; } async function injectInto(urls, storageKey) { // Force-type providers: MB's URL auto-classifier leaves the type empty // for Apple Music + Bandcamp ("Please select a link type" warning). Map // host pattern → ordered list of preferred link_type_ids. We pick the // first one whose value actually appears as an