// ==UserScript==
// @name [chordwiki] コード to ディグリー
// @description ja.chordwiki.orgのキーが明記されいるページのコード名をディグリーに変換
// @namespace https://gf.qytechs.cn/ja/users/1023652
// @version 0.0.0.6
// @author ゆにてぃー
// @match https://ja.chordwiki.org/wiki*
// @icon https://www.google.com/s2/favicons?sz=64&domain=ja.chordwiki.org
// @license MIT
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// ==/UserScript==
(async function(){
'use strict';
let currentUrl = document.location.href;
let updating = false;
const debugging = true;
const debug = debugging ? console.log : ()=>{};
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
async function main(){
addConvertFab();
hookPlayKeyObserver(); // Play: が変わったら自動更新
}
let isDeg = false;
// ===== 右下固定の丸ボタン(FAB) =====
function addConvertFab(){
if(document.getElementById("cw-degree-fab"))return;
const btn = h('button',{
id:"cw-degree-fab",
title:"コード名 ↔ ディグリー",
onClick:()=>{
const hasKey = !!document.querySelector('p.key');
if(!hasKey){alert("キーがわからない");return;}
if(!isDeg){
convertDocument("deg");
isDeg = true;
btn.textContent = "C";
}else{
convertDocument("orig");
isDeg = false;
btn.textContent = "Ⅰ";
}
},
textContent:"Ⅰ",
style:{
position:'fixed',
right:'16px',
bottom:'16px',
width:'56px',
height:'56px',
borderRadius:'9999px',
zIndex:'2147483647',
border:'none',
cursor:'pointer',
boxShadow:'0 6px 16px rgba(0,0,0,.25)',
background:'#ffffff',
fontSize:'20px',
lineHeight:'56px',
textAlign:'center',
userSelect:'none'
}
});
btn.addEventListener('mouseenter',()=>{
btn.style.transform = 'scale(1.06)';
btn.style.boxShadow = '0 10px 24px rgba(0,0,0,.30)';
});
btn.addEventListener('mouseleave',()=>{
btn.style.transform = '';
btn.style.boxShadow = '0 6px 16px rgba(0,0,0,.25)';
});
document.body.appendChild(btn);
}
// ===== ローマ数字基礎 =====
const ROMAN = ["Ⅰ","Ⅱ","Ⅲ","Ⅳ","Ⅴ","Ⅵ","Ⅶ"];
function buildLetterOrder(key){
const L = ["C","D","E","F","G","A","B"];
const k = (key || "").toUpperCase().match(/^([A-G])/ )?.[1] || "C";
const i = L.indexOf(k);
return i < 0 ? L.slice() : L.slice(i).concat(L.slice(0,i));
}
function buildRomanMap(key){
const order = buildLetterOrder(key);
const m = {};
for(let i = 0;i < order.length;i++){
m[order[i]] = ROMAN[i];
}
return m;
}
// ===== キーの調号マップ(文字ごとに # / b / なし) =====
function normalizeKeyName(key){
const m = (key || "").trim().toUpperCase().match(/^([A-G])([#B])?$/);
if(!m)return "C";
const letter = m[1];
const acc = m[2] === "B" ? "b" : (m[2] || "");
return letter + acc;
}
function buildKeyAccidentalMap(key){
const k = normalizeKeyName(key);
const SHARP_KEYS = ["C","G","D","A","E","B","F#","C#"];
const FLAT_KEYS = ["C","F","Bb","Eb","Ab","Db","Gb","Cb"];
const SHARP_ORDER = ["F","C","G","D","A","E","B"];
const FLAT_ORDER = ["B","E","A","D","G","C","F"];
const map = {C:"",D:"",E:"",F:"",G:"",A:"",B:""};
let idxSharp = SHARP_KEYS.indexOf(k);
let idxFlat = FLAT_KEYS.indexOf(k);
if(idxSharp >= 0){
for(let i = 0;i < idxSharp;i++)map[SHARP_ORDER[i]] = "#";
}else if(idxFlat >= 0){
for(let i = 0;i < idxFlat;i++)map[FLAT_ORDER[i]] = "b";
}
return map;
}
// 単音 → ローマ数字(キーの調号と比較して相対臨時記号を付与)
function noteToDegree(note,romanMap,keyAcc){
const m = (note || "").match(/^([A-G])([#b])?$/i);
if(!m)return note;
const letter = m[1].toUpperCase();
const acc = m[2] || "";
const base = romanMap[letter] || letter;
const dia = keyAcc[letter] || "";
let rel = "";
if(acc === dia)rel = "";
else if(acc === "" && dia === "#")rel = "b";
else if(acc === "" && dia === "b")rel = "#";
else if(acc === "#" && dia === "")rel = "#";
else if(acc === "b" && dia === "")rel = "b";
else if(acc === "#" && dia === "b")rel = "##";
else if(acc === "b" && dia === "#")rel = "bb";
return base + rel;
}
// コード全体:"F#m7-5" "C#7(b9)" "Aadd9/B" "Baug/F"
function convertChordSymbol(sym,romanMap,keyAcc){
const s = (sym || "").trim();
if(s === "N.C.")return s;
const mSlash = s.match(/^\/\s*([A-G](?:#|b)?)\s*$/i);
if(mSlash){
if(!romanMap || !keyAcc)return s; // キー未確定なら元のまま
const bass = mSlash[1];
const bassDeg = noteToDegree(bass,romanMap,keyAcc);
return "/" + bassDeg;
}
const re = /^([A-G](?:#|b)?)(.*?)(?:\/([A-G](?:#|b)?))?$/i;
const m = s.match(re);
if(!m)return s;
const root = m[1], suffix = m[2] || "", bass = m[3] || "";
const rootDeg = noteToDegree(root,romanMap,keyAcc);
const bassDeg = bass ? noteToDegree(bass,romanMap,keyAcc) : "";
return rootDeg + suffix + (bassDeg ? ("/" + bassDeg) : "");
}
// ===== Key 抽出(Play優先) =====
function extractEffectiveKey(text){
const t = (text || "").trim();
let play = null, orig = null, key = null;
// 分割して順に見る(" / "や"/"、縦線区切りにも緩く対応)
const parts = t.split(/[\/|\|]/);
for(let i = 0;i < parts.length;i++){
const seg = parts[i];
let m = null;
// Play
m = seg.match(/Play[::]\s*([A-G](?:#|b)?)/i);
if(m)play = m[1];
// Original Key / 原曲キー
m = seg.match(/Original\s*[Kk]ey[::]\s*([A-G](?:#|b)?)/i);
if(m)orig = m[1];
m = seg.match(/原曲キー[::]\s*([A-G](?:#|b)?)/i);
if(m)orig = m[1];
// Key / キー(単独のKeyのみ拾う意図で別枠)
m = seg.match(/(?<!Original\s)[Kk]ey[::]\s*([A-G](?:#|b)?)/);
if(m)key = m[1];
m = seg.match(/キー[::]\s*([A-G](?:#|b)?)/i);
if(m)key = m[1];
// 演奏キー/移調後キー/プレイ(保険)
m = seg.match(/演奏キー[::]\s*([A-G](?:#|b)?)/i);
if(m)play = m[1];
m = seg.match(/移調後(?:の)?キー[::]\s*([A-G](?:#|b)?)/i);
if(m)play = m[1];
m = seg.match(/プレイ[::]\s*([A-G](?:#|b)?)/i);
if(m)play = m[1];
}
return play || key || orig || null;
}
function extractKeyFromParagraph(el){
const txt = el.innerText || el.textContent || "";
return extractEffectiveKey(txt);
}
// lineEl内のspan.chordを処理
// mode: "deg"(度数へ)/ "orig"(元へ)
function processLine(lineEl,currentKey,mode){
if(!lineEl)return;
const romanMap = currentKey ? buildRomanMap(currentKey) : null;
const keyAcc = currentKey ? buildKeyAccidentalMap(currentKey) : null;
lineEl.querySelectorAll("span.chord").forEach((el)=>{
const textNow = el.innerText || el.textContent || "";
if(!el.dataset.originalChord){
el.dataset.originalChord = textNow;
}
if(mode === "deg"){
const source = el.dataset.originalChord;
if(!source)return;
if(source === "N.C."){
el.innerText = source;
el.dataset.degreeChord = source;
return;
}
if(!romanMap || !keyAcc){
el.innerText = source;
return;
}
const converted = convertChordSymbol(source,romanMap,keyAcc);
el.innerText = converted;
el.dataset.degreeChord = converted;
}else if(mode === "orig"){
if(el.dataset.originalChord){
el.innerText = el.dataset.originalChord;
}
}
});
}
// 文書全体(Key/Original Key/Play のたびに currentKey を更新)
function convertDocument(mode = "deg"){
let currentKey = null;
const nodes = [...document.querySelectorAll("p.key, p.line")];
for(let i = 0;i < nodes.length;i++){
const el = nodes[i];
if(el.matches("p.key")){
const k = extractKeyFromParagraph(el);
if(k)currentKey = k;
continue;
}
if(el.matches("p.line")){
processLine(el,currentKey,mode);
}
}
}
// Play: が書き換わったら自動で再変換(度数表示中のみ)
function hookPlayKeyObserver(){
const target = document.querySelector('p.key') || document.body;
if(!target)return;
let timer = null;
const obs = new MutationObserver((muts)=>{
let touched = false;
for(const m of muts){
const node = m.target;
if(!node)continue;
const container = (node.nodeType === 3 ? node.parentNode : node);
if(container?.closest && container.closest('p.key')){
touched = true;break;
}
}
if(touched && isDeg){
if(timer)clearTimeout(timer);
timer = setTimeout(()=>{convertDocument("deg");},120);
}
});
obs.observe(document.body,{subtree:true,childList:true,characterData:true});
}
function update(){
if(updating)return;
updating = true;
main();
setTimeout(()=>{updating = false;},600);
}
function locationChange(targetPlace = document){
const observer = new MutationObserver(mutations=>{
if(currentUrl !== document.location.href){
currentUrl = document.location.href;
try{
update();
}catch(error){console.error(error)}
}
});
const config = {childList:true,subtree:true};
observer.observe(targetPlace,config);
}
function sleep(time){
return new Promise((resolve)=>{
setTimeout(()=>{return resolve(time)},time);
});
}
function decodeHtml(html){
const txt = document.createElement("div");
txt.innerHTML = html;
return txt.textContent;
}
function h(tag,props = {},...children){
const el = document.createElement(tag);
for(const key in props){
const val = props[key];
if(key === "style" && typeof val === "object"){
Object.assign(el.style,val);
}else if(key.startsWith("on") && typeof val === "function"){
el.addEventListener(key.slice(2).toLowerCase(),val);
}else if(key.startsWith("aria-") || key === "role"){
el.setAttribute(key,val);
}else if(key === "dataset" && typeof val === "object"){
for(const dataKey in val){
if(val[dataKey] != null){
el.dataset[dataKey] = val[dataKey];
}
}
}else if(key.startsWith("data-")){
const prop = key.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase());
el.dataset[prop] = val;
}else if(key === "ref" && typeof val === "function"){
val(el);
}else if(key in el){
el[key] = val;
}else{
el.setAttribute(key,val);
}
}
for(let i = 0;i < children.length;i++){
const child = children[i];
if(Array.isArray(child)){
for(const nested of child){
if(nested == null || nested === false)continue;
el.appendChild(typeof nested === "string" || typeof nested === "number" ? document.createTextNode(nested) : nested);
}
}else if(child != null && child !== false){
el.appendChild(typeof child === "string" || typeof child === "number" ? document.createTextNode(child) : child);
}
}
return el;
}
function waitElementAndGet({query,searchFunction = 'querySelector',interval = 100,retry = 25,searchPlace = document,faildToThrow = false} = {}){
if(!query)throw(`query is needed`);
return new Promise((resolve,reject)=>{
const MAX_RETRY_COUNT = retry;
let retryCounter = 0;
let searchFn;
switch(searchFunction){
case'querySelector':
searchFn = ()=>searchPlace.querySelector(query);
break;
case'getElementById':
searchFn = ()=>searchPlace.getElementById(query);
break;
case'XPath':
searchFn = ()=>{
let section = document.evaluate(query,searchPlace,null,XPathResult.FIRST_ORDERED_NODE_TYPE,null).singleNodeValue;
return section;
};
break;
case'XPathAll':
searchFn = ()=>{
let sections = document.evaluate(query,searchPlace,null,XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,null);
let result = [];
for(let i = 0;i < sections.snapshotLength;i++){
result.push(sections.snapshotItem(i));
}
if(result.length >= 1)return result;
};
break;
default:
searchFn = ()=>searchPlace.querySelectorAll(query);
}
const setIntervalId = setInterval(findTargetElement,interval);
function findTargetElement(){
retryCounter++;
if(retryCounter > MAX_RETRY_COUNT){
clearInterval(setIntervalId);
if(faildToThrow){
return reject(`Max retry count (${MAX_RETRY_COUNT}) reached for query: ${query}`);
}else{
console.warn(`Max retry count (${MAX_RETRY_COUNT}) reached for query: ${query}`);
return resolve(null);
}
}
let targetElements = searchFn();
if(targetElements && (!(targetElements instanceof NodeList) || targetElements.length >= 1)){
clearInterval(setIntervalId);
return resolve(targetElements);
}
}
});
}
locationChange();
main();
})();