Here is a first sketch for you to test.
Before you can run it, you will need to change the values of a few variables at the top of the macro:
- The path to your RTF library file, in the variable
chordLibraryPath
. (You can use a leading tilde to represent your user directory).
- the name of the https://www.manneschlaier.com chord graph font that you want to apply (to the clipboard), in the variable
chordFontName
- the size of the font that you want, in the variable
chordFontSize
.
On the issue of chord names like Bm7
for which your library file contains multiple entries at the moment, this draft simply copies both (or all) matches next to each other, so that you can decide which to delete/keep after you have pasted them.
Generally, the macro is using a JSON record of the mappings (kept in the Keyboard Maestro variable chordMappings
).
When the macro runs, it detects whether your RTF library file has changed since it was last seen. If it has changed, the JSON is automatically updated.
Copy chord name as graph.kmmacros (16.4 KB)
Expand disclosure triangle to view JS Source
(() => {
"use strict";
ObjC.import("AppKit");
// Copy chord name as chord graph
// formatted with given chord font and size
// using name -> code mappings in a given RTF file.
// Rob Trew @ 2021
// Ver 0.03
// main :: IO ()
const main = () => {
const
title = "Copy Chord Name as Chord Graph",
kme = Application("Keyboard Maestro Engine"),
kmValue = k => kme.getvariable(k);
const
kmMappingsName = "chordMappings",
kmLibPathName = "chordLibraryPath",
kmChordFontName = "chordFontName",
kmChordFontSize = "chordFontSize";
return either(alert(title))(x => x)(
bindLR(clipTextLR())(
chordName => bindLR(
updatedMappingsLR(kme)(kmMappingsName)(
filePath(kmValue(kmLibPathName))
)
)(jso => {
const
chordCodes = jso[chordName.trim()];
return Boolean(chordCodes) ? (() => {
const s = chordCodes.join(" ");
return Right((
copyTextInFont(
kmValue(kmChordFontName)
)(
parseFloat(
kmValue(
kmChordFontSize
) || "32",
10
) || 32
)(s),
s
));
})() : Left(
`Chord name not found: "${chordName}"`
);
})
)
);
};
// updatedMappingsLR :: Application -> String ->
// FilePath -> Either String Dict
const updatedMappingsLR = kme =>
mappingsName => fp => doesFileExist(fp) ? (
chordMappingsFromFileOrKMVarLR(kme)(
mappingsName
)(fp)
) : Left(`Library not found at ${fp}`);
// chordMappingsFromFileOrKMVarLR :: Application ->
// String -> FilePath -> Either String Dict
const chordMappingsFromFileOrKMVarLR = kme =>
mapVarName => fp => {
const
kmValue = kme.getvariable,
oldCheckSum = kmValue(
"chordLibLastCheckSum"
),
newCheckSum = Object.assign(
Application.currentApplication(), {
includeStandardAdditions: true
}
).doShellScript(
`shasum -a 256 "${fp}"`
),
libraryHasChanged = (
kme.setvariable(
"chordLibLastCheckSum", {
to: newCheckSum
}
),
newCheckSum !== oldCheckSum
),
lrParse = libraryHasChanged ? (
Left("Library changed")
) : jsonParseLR(
kmValue(mapVarName)
);
return Boolean(lrParse.Right) ? (
lrParse
) : jsoFromChordLibLR(kme)(mapVarName)(fp);
};
// jsoFromChordLibLR :: FilePath -> Either String Dict
const jsoFromChordLibLR = kme =>
// Either a message or a dictionary of mappings
// from Chord names to lists of chord graph codes.
mapVarName => fp => bindLR(
readFileLR(fp)
)(
rtf => bindLR(
plainTextFromRTF(rtf)
)(txt => {
const
dictMappings = chunksOf(2)(
lines(txt).slice(1)
.filter(Boolean)
)
.flatMap(([ks, vs]) => zip(
words(ks.trim())
)(
words(vs.trim())
))
.reduce((a, [k, v]) => (
a[k] = (a[k] || [])
.concat(v),
a
), {});
return Right((
kme.setvariable(mapVarName, {
to: JSON.stringify(
dictMappings, null, 2
)
}),
dictMappings
));
})
);
// ----------------------- JXA -----------------------
// alert :: String => String -> IO String
const alert = title =>
s => {
const sa = Object.assign(
Application("System Events"), {
includeStandardAdditions: true
});
return (
sa.activate(),
sa.displayDialog(s, {
withTitle: title,
buttons: ["OK"],
defaultButton: "OK"
}),
s
);
};
// clipTextLR :: () -> Either String String
const clipTextLR = () => {
// Either a message, (if no clip text is found),
// or the string contents of the clipboard.
const
v = ObjC.unwrap(
$.NSPasteboard.generalPasteboard
.stringForType($.NSPasteboardTypeString)
);
return Boolean(v) && 0 < v.length ? (
Right(v)
) : Left("No utf8-plain-text found in clipboard.");
};
// copyTextInFont :: String -> Float -> String -> IO String
const copyTextInFont = fontName =>
pointSize => txt => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
// As RTF in specified font and size.
pb.setDataForType(
$.NSAttributedString.alloc.init
.initWithStringAttributes(
txt, $({
"NSFont": $.NSFont.fontWithNameSize(
fontName,
pointSize
)
})
).RTFFromRangeDocumentAttributes({
"location": 0,
"length": txt.length
}, {
DocumentType: $.NSRTFTextDocumentType
}),
$.NSPasteboardTypeRTF
),
// Also a plain text version for text editor etc.
pb.setStringForType(
$(txt),
$.NSPasteboardTypeString
),
txt
);
};
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = fp => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(fp)
.stringByStandardizingPath, ref
) && 1 !== ref[0];
};
// filePath :: String -> FilePath
const filePath = s =>
// The given file path with any tilde expanded
// to the full user directory path.
ObjC.unwrap(ObjC.wrap(s)
.stringByStandardizingPath);
// plainTextFromRTFLR :: String -> Either String String
const plainTextFromRTF = rtf => {
// Either an explanatory message or a
// plain text version of the RTF text.
const
attributed = $.NSAttributedString.alloc
.initWithRTFDocumentAttributes(
$(rtf).dataUsingEncoding(
$.NSUTF8StringEncoding
),
$()
);
return attributed.isNil() ? Left(
"Input does not appear to be RTF"
) : Right(ObjC.unwrap(attributed.string));
};
// readFileLR :: FilePath -> Either String IO String
const readFileLR = fp => {
// Either a message or the contents of any
// text file at the given filepath.
const
e = $(),
ns = $.NSString
.stringWithContentsOfFileEncodingError(
$(fp).stringByStandardizingPath,
$.NSUTF8StringEncoding,
e
);
return ns.isNil() ? (
Left(ObjC.unwrap(e.localizedDescription))
) : Right(ObjC.unwrap(ns));
};
// --------------------- GENERIC ---------------------
// Left :: a -> Either a b
const Left = x => ({
type: "Either",
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: "Either",
Right: x
});
// bindLR (>>=) :: Either a ->
// (a -> Either b) -> Either b
const bindLR = m =>
mf => m.Left ? (
m
) : mf(m.Right);
// chunksOf :: Int -> [a] -> [[a]]
const chunksOf = n => {
// xs split into sublists of length n.
// The last sublist will be short if n
// does not evenly divide the length of xs .
const go = xs => {
const chunk = xs.slice(0, n);
return 0 < chunk.length ? (
[chunk].concat(
go(xs.slice(n))
)
) : [];
};
return go;
};
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = fl =>
// Application of the function fl to the
// contents of any Left value in e, or
// the application of fr to its Right value.
fr => e => e.Left ? (
fl(e.Left)
) : fr(e.Right);
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(
[
e.message,
`(line:${e.line} col:${e.column})`
].join("\n")
);
}
};
// lines :: String -> [String]
const lines = s =>
// A list of strings derived from a single
// string delimited by newline and or CR.
0 < s.length ? (
s.split(/[\r\n]+/u)
) : [];
// words :: String -> [String]
const words = s =>
// List of space-delimited sub-strings.
s.split(/\s+/u);
// zip :: [a] -> [b] -> [(a, b)]
const zip = xs =>
// The paired members of xs and ys, up to
// the length of the shorter of the two lists.
ys => Array.from({
length: Math.min(xs.length, ys.length)
}, (_, i) => [xs[i], ys[i]]);
// MAIN ---
return main();
})();