// ==UserScript==
// @name TabTabTab UG Exporter
// @namespace https://github.com/aweussom/tabtabtab
// @version 0.3
// @description Eksporter Ultimate Guitar bookmarks (med chord/lyric-body) til TabTabTab-JSON
// @match https://www.ultimate-guitar.com/user/mytabs*
// @grant GM.xmlHttpRequest
// @connect tabs.ultimate-guitar.com
// @connect ultimate-guitar.com
// ==/UserScript==
// Install in Tampermonkey / Violentmonkey / Greasemonkey, then visit
// https://www.ultimate-guitar.com/user/mytabs and set page-size filter to "All"
// before clicking the floating "Eksporter til TabTabTab" button.
//
// What it does:
// 1. Scrapes the bookmark list from the DOM (article[isdesktop=true] rows).
// 2. For each bookmark, fetches the tab page with credentials: same-origin so
// paid Official Tabs unlock for Pro/Lifetime UG subscribers.
// 3. Extracts wiki_tab.content from the page's
JSON
// state, decodes HTML entities, harvests chord names from inline [ch]X[/ch].
// 4. Downloads tabtabtab-ug-import-YYYY-MM-DD.json.
//
// Empirical: 253 OK / 6 failed on Tommy's 259-bookmark run. The 6 failures are
// all UG Official Tabs with publisher protection that returns an empty content
// field even for Pro subscribers. Workaround: bookmark the free -chords-
// community version of those songs and re-run.
//
// See PLAN.md → "Ultimate Guitar bookmark import" for the full design.
(function () {
'use strict';
console.log('[TabTabTab UG] script loaded — bookmarks page detected');
const DELAY_MS = 800; // politeness mellom requests; bump til 1500-2000 hvis UG hangler
// Plukk bookmark-listen fra DOM-en (BlackLights tilnærming — funker fortsatt i 2026)
function getBookmarkList() {
let artist = null;
const list = [];
const container = document.querySelector('article[isdesktop=true] div');
if (!container) return list;
const rows = [...container.childNodes].slice(1);
for (const item of rows) {
const cells = [...item.childNodes];
if (cells.length < 2) continue;
const artistCell = cells[0]?.innerText?.trim();
if (artistCell?.length) artist = artistCell;
const titleCell = cells[1]?.innerText?.trim();
const link = cells[1]?.querySelector?.('a')?.getAttribute('href');
if (!artist || !titleCell || !link) continue;
list.push({ artist, title: titleCell, link });
}
return list;
}
// Dekode HTML entities (UG lagrer Ä/ø/å som Ä/ø/å).
// UG-spesifikk markup ([ch], [tab], [Verse], #-preamble) bevares —
// TabTabTab konverterer downstream.
function decodeBody(raw) {
const decoder = document.createElement('textarea');
decoder.innerHTML = raw;
return decoder.value;
}
// Cross-origin GET via the userscript manager's privileged XHR. The
// bookmark page lives on www.ultimate-guitar.com; tab pages live on
// tabs.ultimate-guitar.com — different subdomains, so a plain fetch()
// hits CORS. GM.xmlHttpRequest bypasses CORS for the hosts declared
// in @connect above, and sends cookies automatically (so Pro/Lifetime
// users get their unlocked Official Tabs content).
function gmGet(url) {
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: 'GET',
url,
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
resolve({ ok: true, text: res.responseText });
} else {
resolve({ ok: false, status: res.status });
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Timeout')),
});
});
}
// Hent én tab-side, parse js-store, returnér body + chord-navn
async function fetchTabBody(url) {
const fullUrl = url.startsWith('http') ? url : `https://www.ultimate-guitar.com${url}`;
const resp = await gmGet(fullUrl);
if (!resp.ok) return { error: `HTTP ${resp.status}` };
const html = resp.text;
const m = html.match(/
]*class="js-store"[^>]*data-content="([^"]+)"/);
if (!m) return { error: 'no js-store element' };
const jsonStr = m[1]
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&');
let state;
try { state = JSON.parse(jsonStr); }
catch (e) { return { error: `JSON parse failed: ${e.message}` }; }
const data = state?.store?.page?.data;
const content = data?.tab_view?.wiki_tab?.content;
if (!content) return { error: 'no content in tab_view.wiki_tab' };
// Plukk chord-navn ut av rå [ch]X[/ch]-markup før vi sender body videre
const chords = new Set();
const re = /\[ch\](.+?)\[\/ch\]/g;
let cm;
while ((cm = re.exec(content)) !== null) chords.add(cm[1].trim());
return {
body: decodeBody(content),
chordnames: [...chords],
tabId: data?.tab?.id ?? null,
tabType: data?.tab?.type ?? null,
};
}
// Hovedløkken
async function exportAll(btn) {
btn.disabled = true;
const list = getBookmarkList();
if (list.length === 0) {
alert('Fant ingen bookmarks. Sjekk at filter er satt til "All" øverst på siden.');
btn.disabled = false;
return;
}
btn.textContent = `Henter 0/${list.length}…`;
const results = [], failed = [];
const startedAt = new Date().toISOString();
for (let i = 0; i < list.length; i++) {
const entry = list[i];
btn.textContent = `Henter ${i + 1}/${list.length}…`;
try {
const body = await fetchTabBody(entry.link);
if (body.error) {
failed.push({ ...entry, error: body.error });
console.warn(`[TabTabTab UG] FAIL ${entry.artist} — ${entry.title}: ${body.error}`);
} else {
const fullUrl = entry.link.startsWith('http') ? entry.link : `https://www.ultimate-guitar.com${entry.link}`;
results.push({
id: `ug-${body.tabId ?? Math.floor(Math.random() * 1e9)}`,
source: 'ultimate-guitar',
source_url: fullUrl,
tab_type: body.tabType,
artist: entry.artist,
song: entry.title,
body: body.body,
chordnames: body.chordnames,
imported_at: new Date().toISOString(),
});
}
} catch (e) {
failed.push({ ...entry, error: e.message });
console.warn(`[TabTabTab UG] EXC ${entry.artist} — ${entry.title}: ${e.message}`);
}
if (i < list.length - 1) await new Promise(r => setTimeout(r, DELAY_MS));
}
const out = {
version: 1,
exported_at: startedAt,
finished_at: new Date().toISOString(),
ok_count: results.length,
failed_count: failed.length,
tabs: results,
failed,
};
const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tabtabtab-ug-import-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
btn.textContent = `Ferdig: ${results.length} OK / ${failed.length} feilet`;
btn.disabled = false;
console.log('[TabTabTab UG] complete:', { ok: results.length, failed: failed.length });
}
// Flytende knapp øverst til høyre — uavhengig av UGs DOM-struktur
function addButton() {
if (document.getElementById('tabtabtab-ug-btn')) return;
if (!document.body) return;
const btn = document.createElement('button');
btn.id = 'tabtabtab-ug-btn';
btn.textContent = '⬇ Eksporter til TabTabTab';
btn.style.cssText = `
position: fixed;
top: 80px;
right: 16px;
z-index: 99999;
background: #ffc600;
color: #000;
padding: 10px 16px;
border: 2px solid #000;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-family: sans-serif;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
`;
btn.onclick = () => exportAll(btn);
document.body.appendChild(btn);
console.log('[TabTabTab UG] button injected');
}
const interval = setInterval(() => {
addButton();
if (document.getElementById('tabtabtab-ug-btn')) clearInterval(interval);
}, 1000);
})();