Use Clipboard to Find Text in a File, Select Text and Copy It

If I could avoid having to create unique names, that would be amazing actually. It reduces visual clutter. In the end, I want to know the chord fingering when playing, the name is just an accessory.
So yes, interactive choice would be brilliant.

If your fingers are busy on your guitar when you are trying to run your macro, it should be possible in Monterey to get your voice to activate the macro that you are writing. I've done things like this (voice control to trigger KM macros). You could literally just say the words "C major" and the macro could fetch and display the information that you want.

This would be pretty cool, but I've got two other people asking me for help, so I have to help them first. You probably need to get this current macro working anyway before you can upgrade it to voice control.

2 Likes

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:

  1. The path to your RTF library file, in the variable chordLibraryPath. (You can use a leading tilde to represent your user directory).
  2. the name of the https://www.manneschlaier.com chord graph font that you want to apply (to the clipboard), in the variable chordFontName
  3. 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();
})();

PS your RTF library file doesn't need to be open – the macro reads it from file.

Interactive choice, you say? How about vocal control to fetch your results? I can "get the same results as you" using just 3 KM actions with 2 macOS Shortcut actions. :flushed:

Here're the three KM actions:

And here're the two Shortcuts actions:

When you speak a phrase like "C sharp minor", as long as your Mac has a microphone, a bunch of images of guitar chords showing C sharp minor images appear on the web browser's screen. Then when you next speak, the next results show up on the screen. The user doesn't have to trigger any macros, other than the initial loop.

The subpath that you see in the screenshot is not a required field. This macro ignores that file. I use it for other purposes, so I left it there.

There are a couple of "minor issues" that would take more code to resolve. For example, the dictation window always makes a beep and always is visible on the screen. There are ways to fix that, but I'm just illustrating some ideas here, not trying to make a perfect macro. If I showed you some fixes that would distract from the simplicity of this macro.

Yes, I know, you already said that you don't want your results from the web, but I wanted to show everyone how easy it is to get results from the web using your voice and Monterey Shortcuts.

1 Like

It works pretty well! Many thanks!

Is the dialogue "clear clipboard" necessary? Couldn't it just skip this and copy the chord graph to the clipboard?

This is not suitable to me, as I said. Chord names are complicated. Also, I don't really like to talk to my computer. Thanks again.

Good !

Are we talking about this sequence in the macro:

?

You could experiment a bit there – the idea was just a timing issue – to make sure that the the script is looking at the newly copied text, rather than any earlier clipboard contents.

(sometimes two separate processes can be in a sort of race with each other, and get out of sequence)

But you may be thinking about something else that I'm not seeing here.

Could you post a screenshot, if that's it ?

I'm talking about the dialog that is displayed as soon as I hit the trigger:

Screenshot 2021-12-09 at 14.15.38

It would be ideal if I could skip it, and the action "Copy chord name as graph" occur automatically, instead of me having to select it.

Ah, it looks as if you may have another macro (with the name "Clear clipboard") which is assigned to the same keyboard shortcut.

(Keyboard Maestro offers us a choice when that happens)

The trick is just to find a unique keyboard shortcut.

(If you are not using Clear Clipboard much, then you could either clear its keyboard shortcut assignment completely, or assign it to something else)

:man_facepalming: Newbie mistake lol. But ok, I started with KM yesterday so yeah.

1 Like

Enjoy it in good health !

It seems to work flawlessly. My mind is blown seeing this automation happen, thank you so very much.
I giggle at the fact that I can even update the library and the automation still works, it's a lot of fun, really.

2 Likes

No problem, no worries. But I did try it out with complicated chord names, like "C-sharp augmented major ninth" and it had no problems. Remember, it's the web search engine that's recognizing the chord names, and web search engines are pretty smart.

FWIW an updated version which throws up a custom dialog (from a KM 10 subroutine) if a chord name matches multiple chord graphs in the library.

(As above, requires the installation of a special font)

Copy as CHORD Macros.kmmacros (31.4 KB)

Hi @ComplexPoint,

Last December you created this amazingly useful script for me. I had a gap in usage due to moving homes and now I'm struggling to make it work again. It was working fine before, and I used it a lot. After the gap in usage, the only changes I remember making were to rename the folder containing and relocating the chord library file. I've adjusted those accordingly in the script.

Now when i try to run the script now I get the error message you can see attached.

Your help would be immensely appreciated. I really benefit from what this script offers to my workflow.

Path adjustments are hard to get right – can you show us your new path strings ?

Sure thing. I've placed the file in iCloud, but I don't have storage optimisation checked, meaning all iCloud files are permanently stored in my computer.

I've replaced my actual name with myname for privacy reasons.

The path I'm using is: /Users/myname/Library/Mobile Documents/com~apple~CloudDocs/Musica/Cifras/Chord Library.rtf

iCloud storage could be a subtle source of the time-out.

Other things to check:

  1. Can you get the macro working again by reverting to the path you gave at the time when it was written ?
  2. There are two spaces in that path I think – have you tried escaping them ?

I've tried all you said, including moving it out of iCloud and it still times out.